stats: core-7 economy page with CPI index levels, rebase and categories
All checks were successful
Deploy production / deploy (push) Successful in 1m13s

- /stats/economy defaults multi-country charts to CAN KOR CHN JPN USA NLD CUW,
  with add-a-country chips and an EU 27 comparison line
- CPI index levels rebased to a chosen base year, index/YoY toggle,
  categories (food, energy, housing, core) where the source has them
- new no-key collectors: oecd (CPI categories, 10y yields), statcan (CPI);
  bis WS_LONG_CPI, FRED US CPI categories, Eurostat prc_hicp_minr, WB FP.CPI.TOTL
- Canada added as a region; CBS Curacao fallbacks for growth/unemployment
- timers stats-oecd and stats-statcan
This commit is contained in:
2026-10-26 00:34:27 +00:00
parent 28140bd425
commit 45841d6f6c
18 changed files with 926 additions and 122 deletions

View File

@@ -0,0 +1,96 @@
<script lang="ts">
// Country chips for the multi-country charts on /stats/economy: the core seven are on by
// default, the extra countries can be added, and the EU 27 comparison line can be toggled.
import { COUNTRY } from './regions.js';
import { FLAG } from './countries.js';
import { CORE7, EXTRA } from './econ.js';
let { selected = $bindable(), eu = $bindable() }: { selected: string[]; eu: boolean } = $props();
const toggle = (c: string) =>
(selected = selected.includes(c) ? selected.filter((x) => x !== c) : [...selected, c]);
const isCore = $derived(
selected.length === CORE7.length && CORE7.every((c) => selected.includes(c))
);
</script>
<div class="picker" role="group" aria-label="Countries on the charts">
<div class="row">
<span class="label">core</span>
{#each CORE7 as c (c)}
<button
type="button"
class="chip"
aria-pressed={selected.includes(c)}
onclick={() => toggle(c)}
>
<span aria-hidden="true">{FLAG[c]}</span>{COUNTRY[c]}
</button>
{/each}
<button type="button" class="chip eu" aria-pressed={eu} onclick={() => (eu = !eu)}>
<span aria-hidden="true">🇪🇺</span>EU 27 line
</button>
</div>
<div class="row">
<span class="label">add</span>
{#each EXTRA as c (c)}
<button
type="button"
class="chip"
aria-pressed={selected.includes(c)}
onclick={() => toggle(c)}
>
<span aria-hidden="true">{FLAG[c]}</span>{COUNTRY[c]}
</button>
{/each}
{#if !isCore}
<button type="button" class="reset" onclick={() => (selected = [...CORE7])}>reset</button>
{/if}
</div>
</div>
<style>
.picker {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0 0 1rem;
padding: 0.75rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-bg);
}
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
}
.label {
width: 2.6rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
}
.chip,
.reset {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
background: transparent;
color: var(--site-muted);
cursor: pointer;
}
.chip[aria-pressed='true'] {
color: var(--site-text);
border-color: var(--site-text);
background: color-mix(in srgb, var(--site-text) 8%, transparent);
}
.reset {
border-style: dashed;
}
</style>

View File

@@ -13,6 +13,7 @@ export const COUNTRY_PAGES = [
'BEL',
'GBR',
'JPN',
'CAN',
'IND',
'BRA',
'RUS',
@@ -34,6 +35,7 @@ export const FLAG: Record<string, string> = {
BEL: '🇧🇪',
GBR: '🇬🇧',
JPN: '🇯🇵',
CAN: '🇨🇦',
IND: '🇮🇳',
BRA: '🇧🇷',
RUS: '🇷🇺',
@@ -52,6 +54,7 @@ export const COUNTRY_COLOR: Record<string, string> = {
BEL: '#a16207',
GBR: '#a855f7',
JPN: '#ec4899',
CAN: '#dc2626',
IND: '#10b981',
BRA: '#84cc16',
RUS: '#0d9488',

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { CORE7, CPI_BY_CATEGORY, CPI_TOTAL, rebase, yearMean, yoyByDate } from './econ.js';
describe('econ helpers', () => {
const pts: [string, number][] = [
['2014-12-01', 99],
['2015-01-01', 100],
['2015-07-01', 102],
['2016-01-01', 103],
['2016-07-01', 104.04]
];
it('averages a calendar year', () => expect(yearMean(pts, 2015)).toBe(101));
it('rebases to the base-year mean', () =>
expect(rebase(pts, 2015)?.[3]).toEqual(['2016-01-01', 101.98]));
it('returns null when the base year is missing', () => expect(rebase(pts, 2010)).toBeNull());
it('computes year on year by date and skips gaps', () =>
expect(yoyByDate(pts)).toEqual([
['2016-01-01', 3],
['2016-07-01', 2]
]));
it('covers every core country for CPI', () => {
for (const c of CORE7) {
expect(CPI_TOTAL[c]).toBeDefined();
expect(CPI_BY_CATEGORY[c]?.ids.total).toBeDefined();
}
});
});

151
src/lib/stats/econ.ts Normal file
View File

@@ -0,0 +1,151 @@
// Country sets and series maps for /stats/economy. The core set (CAN, KOR, CHN, JPN, USA, NLD,
// CUW) is the default on every multi-country chart; the extra countries can be added, and the
// EU aggregate is kept as a comparison line. Every id points at a stored series; nothing here is
// estimated, gaps stay gaps.
import type { StatPoint } from './chart.js';
export const CORE7 = ['CAN', 'KOR', 'CHN', 'JPN', 'USA', 'NLD', 'CUW'] as const;
export const EXTRA = ['DEU', 'FRA', 'GBR', 'BEL', 'IND', 'BRA', 'RUS'] as const;
export const ECON_COUNTRIES = [...CORE7, ...EXTRA] as const;
export type EconCountry = (typeof ECON_COUNTRIES)[number];
/** Comparison aggregates: EU 27 by default; the euro area where the data is per currency area. */
export type Aggregate = 'EU27' | 'EA';
/** All-items CPI, index level, best monthly source per region (national office first). */
export const CPI_TOTAL: Record<string, { id: string; source: string; base: string }> = {
// CBS CPI as carried by the BIS long series (1965→). CBS 83131NED (2015 = 100) stops in
// December 2025 and 86141NED (2025 = 100) starts in December 2009, so neither covers the span.
NLD: { id: 'bis:cpi:NL', source: 'bis', base: '2010 = 100' },
USA: { id: 'fred:CPIAUCSL', source: 'fred', base: '1982–84 = 100' },
CAN: { id: 'statcan:cpi:total', source: 'statcan', base: '2002 = 100' },
JPN: { id: 'bis:cpi:JP', source: 'bis', base: '2010 = 100' },
KOR: { id: 'bis:cpi:KR', source: 'bis', base: '2010 = 100' },
CHN: { id: 'bis:cpi:CN', source: 'bis', base: '2010 = 100' },
CUW: { id: 'cw:cpi:index', source: 'cbscw', base: '2023 = 100' },
DEU: { id: 'bis:cpi:DE', source: 'bis', base: '2010 = 100' },
FRA: { id: 'bis:cpi:FR', source: 'bis', base: '2010 = 100' },
GBR: { id: 'bis:cpi:GB', source: 'bis', base: '2010 = 100' },
BEL: { id: 'bis:cpi:BE', source: 'bis', base: '2010 = 100' },
IND: { id: 'bis:cpi:IN', source: 'bis', base: '2010 = 100' },
BRA: { id: 'bis:cpi:BR', source: 'bis', base: '2010 = 100' },
RUS: { id: 'bis:cpi:RU', source: 'bis', base: '2010 = 100' },
EU27: {
id: 'eurostat:prc_hicp_minr:I15.TOTAL.EU27_2020',
source: 'eurostat',
base: '2015 = 100'
},
EA: { id: 'eurostat:prc_hicp_minr:I15.TOTAL.EA20', source: 'eurostat', base: '2015 = 100' }
};
export const CPI_CATS = ['total', 'food', 'housing', 'energy', 'core'] as const;
export type CpiCat = (typeof CPI_CATS)[number];
export const CPI_CAT_LABEL: Record<CpiCat, string> = {
total: 'All items',
food: 'Food',
housing: 'Housing',
energy: 'Energy',
core: 'Core (excl. food and energy)'
};
const hicp = (g: string): Record<CpiCat, string> => ({
total: `eurostat:prc_hicp_minr:I15.TOTAL.${g}`,
food: `eurostat:prc_hicp_minr:I15.CP01.${g}`,
housing: `eurostat:prc_hicp_minr:I15.CP04.${g}`,
energy: `eurostat:prc_hicp_minr:I15.NRG.${g}`,
core: `eurostat:prc_hicp_minr:I15.TOT_X_NRG_FOOD.${g}`
});
const oecd = (c: string, cats: CpiCat[]) =>
Object.fromEntries(cats.map((k) => [k, `oecd:cpi:${c}:${k}`])) as Partial<Record<CpiCat, string>>;
/** CPI by main category where the source publishes it. */
export const CPI_BY_CATEGORY: Record<
string,
{ ids: Partial<Record<CpiCat, string>>; source: string; note: string }
> = {
NLD: { ids: hicp('NL'), source: 'eurostat', note: 'HICP (Eurostat), 2015 = 100' },
USA: {
ids: {
total: 'fred:CPIAUCSL',
food: 'fred:CPIUFDSL',
housing: 'fred:CPIHOSSL',
energy: 'fred:CPIENGSL',
core: 'fred:CPILFESL'
},
source: 'fred',
note: 'BLS CPI-U, seasonally adjusted, via FRED'
},
CAN: {
ids: Object.fromEntries(CPI_CATS.map((k) => [k, `statcan:cpi:${k}`])),
source: 'statcan',
note: 'Statistics Canada 18-10-0004-01; housing = shelter'
},
KOR: {
ids: oecd('KOR', [...CPI_CATS]),
source: 'oecd',
note: 'OECD prices database'
},
JPN: {
ids: oecd('JPN', [...CPI_CATS]),
source: 'oecd',
note: 'OECD prices database; the OECD series for Japan stop in June 2021'
},
CHN: {
ids: oecd('CHN', ['total', 'food']),
source: 'oecd',
note: 'OECD prices database: all items and food only (other NBS categories are manual)'
},
CUW: {
ids: { total: 'cw:cpi:index' },
source: 'cbscw',
note: 'CBS Curaçao: all items only'
},
EU27: { ids: hicp('EU27_2020'), source: 'eurostat', note: 'HICP (Eurostat), 2015 = 100' },
EA: { ids: hicp('EA20'), source: 'eurostat', note: 'HICP (Eurostat), 2015 = 100' }
};
/** BIS policy rate area per country (euro members → ECB); missing = no BIS series. */
export const POLICY_RATE: Record<string, string> = {
CAN: 'CA',
KOR: 'KR',
CHN: 'CN',
JPN: 'JP',
USA: 'US',
NLD: 'XM',
GBR: 'GB',
EA: 'XM'
};
/** Annual macro indicators: IMF WEO first, then CBS Curaçao for Curaçao, then the World Bank. */
export const ANNUAL = {
growth: { imf: 'NGDP_RPCH', wb: 'NY.GDP.MKTP.KD.ZG', cw: 'cw:gdp:real_growth_pct' },
unemployment: {
imf: 'LUR',
wb: 'SL.UEM.TOTL.ZS',
cw: 'cw:lfs:unemployment_rate_pct:total'
},
debt: { imf: 'GGXWDG_NGDP', wb: 'GC.DOD.TOTL.GD.ZS', cw: null },
trade: { imf: null, wb: 'NE.TRD.GNFS.ZS', cw: null }
} as const;
/** Mean of the points in a calendar year, or null when the year has no observations. */
export function yearMean(points: StatPoint[], year: number): number | null {
const vals = points.filter(([d]) => d.startsWith(`${year}-`)).map(([, v]) => v);
return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
}
/** Rebase an index to (base year average) = 100. Null when the base year is not covered. */
export function rebase(points: StatPoint[], year: number): StatPoint[] | null {
const base = yearMean(points, year);
if (!base) return null;
return points.map(([d, v]) => [d, Math.round((v / base) * 10000) / 100]);
}
/** Year-on-year % change by date (same month a year earlier); missing months stay missing. */
export function yoyByDate(points: StatPoint[]): StatPoint[] {
const map = new Map(points);
const out: StatPoint[] = [];
for (const [d, v] of points) {
const prev = map.get(`${Number(d.slice(0, 4)) - 1}${d.slice(4)}`);
if (prev) out.push([d, Math.round(((v - prev) / prev) * 10000) / 100]);
}
return out;
}

View File

@@ -19,6 +19,7 @@ export const COUNTRY: Record<string, string> = {
CN: 'China',
GB: 'United Kingdom',
JP: 'Japan',
CA: 'Canada',
NLD: 'Netherlands',
USA: 'United States',
KOR: 'South Korea',
@@ -31,6 +32,7 @@ export const COUNTRY: Record<string, string> = {
BEL: 'Belgium',
GBR: 'United Kingdom',
JPN: 'Japan',
CAN: 'Canada',
IND: 'India',
BRA: 'Brazil',
RUS: 'Russia',