stats: jobs & cost of living theme (unemployment by age, vacancies, JOLTS, graduates, job quality, wages, price levels, housing), grad view cards, Beveridge curve; jobs collector (OECD SDMX, Big Mac index, derived series) + Eurostat/World Bank/FRED jobs series, stats-jobs.timer
Deploy production / deploy (push) Successful in 1m20s

This commit is contained in:
stan committed 2026-10-26 18:08:06 +00:00
1 parent f2f7bd0a19
commit b566a1244f
13 files changed
+1492 -30

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
<script lang="ts">
// Extras on /stats/<place>/jobs: a "grad view" card per place (the five numbers a recent
// graduate cares about first) and a Beveridge curve (unemployment against vacancies), both
// built from the theme's metric results, so they show exactly the series the charts use.
import StatChart from './StatChart.svelte';
import { beveridgeOption, quarterPairs } from './chart.js';
import { compact, num, period } from './format.js';
import { PLACE_BY_CODE, placeName } from './matrix.js';
import type { MetricResult } from '#lib/server/stats-matrix.js';
let {
metrics,
codes,
attributions
}: { metrics: MetricResult[]; codes: string[]; attributions: Record<string, string> } = $props();
const byKey = $derived(new Map(metrics.map((m) => [m.metric.key, m])));
/** Card rows: the first metric key with data wins (per place). */
const ROWS: { label: string; keys: string[] }[] = [
{ label: 'Youth unemployment', keys: ['youth', 'young'] },
{ label: 'Recent-grad employment', keys: ['gradrate', 'grademp'] },
{ label: 'Average wage (PPP)', keys: ['avwage', 'medwage', 'mwmonth'] },
{ label: 'Price level', keys: ['pricelevel', 'cpl', 'pli'] },
{ label: 'Housing squeeze', keys: ['overburden', 'ptoi', 'parents'] }
];
const cell = (code: string, keys: string[]) => {
for (const k of keys) {
const m = byKey.get(k);
const l = m?.lines.find((x) => x.code === code);
const p = l?.points.at(-1);
if (m && l && p) {
const v = Math.abs(p[1]) >= 1e5 ? compact(p[1]) : num(p[1], m.metric.decimals ?? 1);
return { value: v, unit: m.metric.unit, title: m.metric.title, when: period(p[0], l.freq) };
}
}
return null;
};
const cards = $derived(
codes.map((code) => ({
code,
place: PLACE_BY_CODE.get(code),
rows: ROWS.map((r) => ({ label: r.label, c: cell(code, r.keys) }))
}))
);
const anyCard = $derived(cards.some((c) => c.rows.some((r) => r.c)));
const curves = $derived.by(() => {
const u = byKey.get('unemp');
const v = byKey.get('vacancy');
if (!u || !v) return [];
return v.lines
.map((vl) => {
const ul = u.lines.find((x) => x.code === vl.code);
return {
code: vl.code,
name: placeName(vl.code),
color: PLACE_BY_CODE.get(vl.code)?.color,
pairs: ul ? quarterPairs(ul.points, vl.points, '2010-01-01') : [],
sources: [ul?.sourceId, vl.sourceId].filter((s): s is string => !!s)
};
})
.filter((c) => c.pairs.length > 3);
});
const noCurve = $derived(codes.filter((c) => !curves.some((x) => x.code === c)));
</script>
{#if anyCard}
<h2 class="stats-section-title">Grad view</h2>
<div class="grad-cards">
{#each cards as card (card.code)}
<article class="grad-card" style:--accent={card.place?.color ?? '#64748b'}>
<header>
<span class="flag" aria-hidden="true">{card.place?.flag}</span>
<strong>{card.place?.name ?? card.code}</strong>
</header>
<dl>
{#each card.rows as r (r.label)}
<div title={r.c?.title}>
<dt>{r.label}</dt>
<dd>
{#if r.c}
<span class="v">{r.c.value}</span>
<span class="u">{r.c.unit} · {r.c.when}</span>
{:else}<span class="na">no data</span>{/if}
</dd>
</div>
{/each}
</dl>
</article>
{/each}
</div>
{/if}
{#if curves.length}
<div class="stats-grid">
<StatChart
title="Beveridge curve: how hard is it to find a job?"
subtitle="quarterly unemployment rate (x) vs job vacancy rate (y), since 2010 · up and left = tighter market, easier to get hired"
sources={curves.flatMap((c) => c.sources)}
{attributions}
height="340px"
wide
note={noCurve.length ? `No vacancy series for ${noCurve.map(placeName).join(', ')}.` : ''}
option={(t) => beveridgeOption(t, curves, { xUnit: 'unemployment %', yUnit: 'vacancies %' })}
/>
</div>
{/if}
<style>
.grad-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.grad-card {
border: 1px solid var(--site-border);
border-top: 3px solid var(--accent);
border-radius: 0.75rem;
padding: 0.75rem 0.9rem;
background: var(--site-surface, transparent);
}
.grad-card header {
display: flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.5rem;
font-size: 0.95rem;
}
.flag {
font-size: 1.15rem;
}
dl {
display: grid;
gap: 0.35rem;
margin: 0;
}
dl > div {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
}
dt {
color: var(--site-muted);
font-size: 0.78rem;
}
dd {
margin: 0;
text-align: right;
}
.v {
font-weight: 650;
font-variant-numeric: tabular-nums;
}
.u {
display: block;
color: var(--site-muted);
font-size: 0.65rem;
}
.na {
color: var(--site-muted);
font-size: 0.75rem;
}
</style>
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { quarterPairs } from './chart.js';
describe('quarterPairs', () => {
it('averages monthly x per quarter and pairs it with quarterly y', () => {
const x: [string, number][] = [
['2024-01-01', 4],
['2024-02-01', 5],
['2024-03-01', 6],
['2024-04-01', 3]
];
const y: [string, number][] = [
['2024-01-01', 2.5],
['2024-04-01', 3],
['2024-07-01', 9]
];
expect(quarterPairs(x, y)).toEqual([
[5, 2.5, '2024-Q1'],
[3, 3, '2024-Q2']
]);
});
it('drops quarters before `from`', () => {
const x: [string, number][] = [
['2009-10-01', 8],
['2010-01-01', 7]
];
const y: [string, number][] = [
['2009-10-01', 1],
['2010-01-01', 1.2]
];
expect(quarterPairs(x, y, '2010-01-01')).toEqual([[7, 1.2, '2010-Q1']]);
});
});
+105
View File
@@ -228,3 +228,108 @@ export function yoy(points: StatPoint[] = [], lag = 12): StatPoint[] {
}
return out;
}
/** Quarter key (`2024-Q3`) and its start date for a YYYY-MM-DD period. */
function quarterOf(date: string): { key: string; start: string } {
const y = date.slice(0, 4);
const q = Math.floor((Number(date.slice(5, 7)) - 1) / 3) + 1;
return { key: `${y}-Q${q}`, start: `${y}-${String((q - 1) * 3 + 1).padStart(2, '0')}-01` };
}
function quarterMeans(points: StatPoint[]): Map<string, { start: string; value: number }> {
const acc = new Map<string, { start: string; sum: number; n: number }>();
for (const [d, v] of points) {
if (v == null || !Number.isFinite(v)) continue;
const q = quarterOf(d);
const a = acc.get(q.key) ?? { start: q.start, sum: 0, n: 0 };
a.sum += v;
a.n += 1;
acc.set(q.key, a);
}
return new Map([...acc].map(([k, a]) => [k, { start: a.start, value: a.sum / a.n }]));
}
/**
* Beveridge-curve pairs: quarterly mean of x (unemployment) against quarterly mean of y
* (vacancies), for the quarters both have, oldest first: [x, y, quarter label].
*/
export function quarterPairs(
x: StatPoint[],
y: StatPoint[],
from = '0000-01-01'
): [number, number, string][] {
const qx = quarterMeans(x);
const qy = quarterMeans(y);
const out: [number, number, string][] = [];
for (const [key, a] of [...qx].sort((m, n) => m[1].start.localeCompare(n[1].start))) {
const b = qy.get(key);
if (!b || a.start < from) continue;
out.push([Math.round(a.value * 100) / 100, Math.round(b.value * 100) / 100, key]);
}
return out;
}
/** Beveridge curve: one connected scatter per place, unemployment (x) against vacancies (y). */
export function beveridgeOption(
theme: ChartTheme,
curves: { name: string; color?: string; pairs: [number, number, string][] }[],
o: { xUnit: string; yUnit: string }
): EChartsCoreOption {
const axisLabel = { color: theme.muted, fontSize: 11 };
const splitLine = { lineStyle: { color: theme.border, opacity: 0.6 } };
const axis = (name: string) => ({
type: 'value',
scale: true,
axisLabel,
splitLine,
name,
nameTextStyle: { color: theme.muted, fontSize: 11 }
});
return {
animation: false,
grid: { left: 8, right: 24, top: curves.length > 1 ? 56 : 32, bottom: 28, containLabel: true },
legend:
curves.length < 2
? undefined
: {
top: 0,
left: 0,
type: 'scroll',
textStyle: { color: theme.muted, fontSize: 11 },
icon: 'roundRect',
itemWidth: 10,
itemHeight: 10
},
tooltip: {
trigger: 'item',
backgroundColor: theme.surface,
borderColor: theme.border,
textStyle: { color: theme.text, fontSize: 12 },
formatter: (p: { seriesName: string; data: [number, number, string] }) =>
`${p.seriesName} · ${p.data[2]}<br>unemployment ${p.data[0]} % · vacancies ${p.data[1]} %`
},
xAxis: { ...axis(o.xUnit), nameLocation: 'middle', nameGap: 24 },
yAxis: axis(o.yUnit),
series: curves.map((c, i) => {
const color = c.color ?? PALETTE[i % PALETTE.length];
const last = c.pairs.length - 1;
return {
name: c.name,
type: 'line',
data: c.pairs,
itemStyle: { color },
lineStyle: { width: 1.5, color, opacity: 0.55 },
symbolSize: (_: unknown, p: { dataIndex: number }) => (p.dataIndex === last ? 10 : 4),
label: {
show: true,
position: 'top',
fontSize: 10,
color: theme.muted,
formatter: (p: { dataIndex: number; data: [number, number, string] }) =>
p.dataIndex === last || p.dataIndex === 0 ? p.data[2] : ''
},
emphasis: { focus: 'series' }
};
})
};
}
+402 -1
View File
@@ -420,7 +420,408 @@ export const THEMES: Theme[] = [
icon: 'stats-jobs',
blurb:
'For recent grads: how easy it is to find work, how good the jobs are, what they pay and what life costs.',
metrics: [{ ...UNEMP, group: 'Unemployment', byCode: { USA: ['fred:UNRATE'] } }]
metrics: [
// ── Unemployment ──
{
...UNEMP,
group: 'Unemployment',
subtitle: 'monthly where available',
ids: [
'oecd:jobs:unemp:{c}',
'eurostat:une_rt_m:SA.TOTAL.PC_ACT.T.{e}',
'imf:LUR:{c}',
'wb:SL.UEM.TOTL.ZS:{c}'
],
byCode: { USA: ['fred:UNRATE'], CUW: ['cw:lfs:unemployment_rate_pct:total'] },
from: '2000-01-01'
},
{
key: 'youth',
group: 'Unemployment',
title: 'Youth unemployment, 15–24',
subtitle: 'US 16–24',
unit: '%',
ids: [
'oecd:jobs:unemp_youth:{c}',
'eurostat:une_rt_m:SA.Y_LT25.PC_ACT.T.{e}',
'wb:SL.UEM.1524.ZS:{c}'
],
byCode: {
USA: ['fred:LNS14024887'],
CUW: ['cw:lfs:unemployment_rate_pct:15-24', 'ilostat:une_deap_sex_age_rt:total:15-24']
},
from: '2000-01-01',
lowerIsBetter: true
},
{
key: 'young',
group: 'Unemployment',
title: 'Unemployment, 25–29',
subtitle: 'CW 25–34 · US 20–24',
unit: '%',
ids: ['eurostat:lfsa_urgan:PC.T.Y25-29.TOTAL.{e}'],
byCode: {
CUW: ['cw:lfs:unemployment_rate_pct:25-34'],
USA: ['fred:LNS14000036']
},
from: '2000-01-01',
lowerIsBetter: true
},
{
key: 'gradunemp',
group: 'Unemployment',
title: 'Unemployment with a degree',
subtitle: 'advanced education · US bachelor’s, 25–34',
unit: '%',
ids: ['wb:SL.UEM.ADVN.ZS:{c}'],
byCode: { USA: ['fred:CGBD2534'] },
from: '2000-01-01',
lowerIsBetter: true
},
// ── Finding a job ──
{
key: 'vacancy',
group: 'Finding a job',
title: 'Job vacancy rate',
subtitle: 'vacancies ÷ (jobs + vacancies) · US JOLTS openings rate',
unit: '%',
ids: ['eurostat:jvs_q_nace2:NSA.B-S.TOTAL.JVR.{e}'],
byCode: { USA: ['fred:JTSJOR'] },
from: '2005-01-01',
lowerIsBetter: false
},
{
key: 'openings',
group: 'Finding a job',
title: 'Job openings per unemployed person',
subtitle: 'above 1 = more openings than jobseekers',
unit: 'ratio',
decimals: 2,
ids: ['jobs:openings_per_unemployed:{c}'],
lowerIsBetter: false
},
{
key: 'ltu',
group: 'Finding a job',
title: 'Long-term unemployed (1 year +)',
subtitle: 'share of the unemployed',
unit: '% of unemployed',
ids: ['oecd:jobs:ltu:{c}', 'eurostat:une_ltu_a:LTU.Y15-74.T.PC_UNE.{e}'],
from: '2000-01-01',
lowerIsBetter: true
},
{
key: 'quits',
group: 'Finding a job',
title: 'Quits rate',
subtitle: 'people leaving their job voluntarily, a sign of confidence (JOLTS)',
unit: '%',
ids: [],
byCode: { USA: ['fred:JTSQUR'] },
from: '2001-01-01'
},
// ── Graduates ──
{
key: 'gradrate',
group: 'Graduates',
title: 'Employment rate of recent graduates',
subtitle: 'tertiary, left education 1–3 years ago, aged 20–34',
unit: '%',
ids: ['eurostat:edat_lfse_24:Y1-3.ED5-8.Y20-34.T.PC.{e}'],
from: '2000-01-01',
lowerIsBetter: false
},
{
key: 'grademp',
group: 'Graduates',
title: 'Employment rate, tertiary-educated 25–34',
unit: '%',
ids: ['oecd:jobs:grademp:{c}'],
lowerIsBetter: false
},
{
key: 'neet',
group: 'Graduates',
title: 'Not in employment, education or training (NEET)',
subtitle: 'EU 15–29 · elsewhere 15–24',
unit: '%',
ids: ['eurostat:edat_lfse_20:T.Y15-29.NO_FE_NO_NFE.NEMP.PC.{e}', 'wb:SL.UEM.NEET.ZS:{c}'],
from: '2000-01-01',
lowerIsBetter: true
},
{
key: 'premium',
group: 'Graduates',
title: 'Graduate earnings premium',
subtitle: 'full-time earnings of tertiary-educated 25–34s, upper secondary = 100',
unit: 'index',
decimals: 0,
ids: ['oecd:jobs:premium:{c}']
},
// ── Job quality ──
{
key: 'temp',
group: 'Job quality',
title: 'Temporary contracts',
subtitle: 'share of employees',
unit: '%',
ids: ['eurostat:lfsa_etpgan:PC.T.Y15-64.TOTAL.{e}', 'oecd:jobs:temp:{c}'],
from: '2000-01-01',
lowerIsBetter: true
},
{
key: 'tempyouth',
group: 'Job quality',
title: 'Temporary contracts, 15–24',
unit: '%',
ids: ['eurostat:lfsa_etpgan:PC.T.Y15-24.TOTAL.{e}', 'oecd:jobs:temp_youth:{c}'],
from: '2000-01-01',
lowerIsBetter: true
},
{
key: 'parttime',
group: 'Job quality',
title: 'Part-time work',
subtitle: 'share of employment',
unit: '%',
ids: ['eurostat:lfsa_eppga:PC.T.Y15-64.{e}', 'wb:SL.TLF.PART.ZS:{c}'],
from: '2000-01-01'
},
{
key: 'hours',
group: 'Job quality',
title: 'Hours worked per worker, per year',
unit: 'hours',
decimals: 0,
ids: ['oecd:jobs:hours:{c}'],
from: '1990-01-01',
lowerIsBetter: true
},
{
key: 'longhours',
group: 'Job quality',
title: 'Very long hours (50 h + a week)',
subtitle: 'work–life balance: share of workers',
unit: '%',
ids: ['oecd:jobs:longhours:{c}'],
lowerIsBetter: true
},
{
key: 'weekhours',
group: 'Job quality',
title: 'Usual weekly hours, full-time job',
unit: 'hours',
ids: ['eurostat:lfsa_ewhan2:TOTAL.EMP.FT.Y15-64.T.HR.{e}'],
lowerIsBetter: true
},
// ── Wages ──
{
key: 'avwage',
group: 'Wages',
title: 'Average annual wage',
subtitle: 'full-time equivalent, constant prices, PPP',
unit: 'US$ PPP',
decimals: 0,
ids: ['oecd:jobs:avwage:{c}'],
from: '1990-01-01',
lowerIsBetter: false
},
{
key: 'medwage',
group: 'Wages',
title: 'Median full-time wage (approx.)',
subtitle: 'real minimum wage ÷ minimum-to-median ratio, PPP',
unit: 'US$ PPP',
decimals: 0,
ids: ['jobs:medianwage:{c}'],
lowerIsBetter: false
},
{
key: 'wagegrowth',
group: 'Wages',
title: 'Real wage growth',
subtitle: 'yearly change of the average wage after inflation',
unit: '%',
ids: ['jobs:realwagegrowth:{c}'],
from: '1995-01-01',
lowerIsBetter: false
},
{
key: 'minwage',
group: 'Wages',
title: 'Minimum wage, hourly',
subtitle: 'constant prices, PPP',
unit: 'US$ PPP',
decimals: 2,
ids: ['oecd:jobs:minwage_h:{c}'],
from: '2000-01-01',
lowerIsBetter: false
},
{
key: 'min2med',
group: 'Wages',
title: 'Minimum wage vs median wage',
unit: '% of median',
ids: ['oecd:jobs:min2median:{c}'],
from: '2000-01-01'
},
{
key: 'mwmonth',
group: 'Wages',
title: 'Minimum wage, monthly (EU)',
subtitle: 'purchasing power standards',
unit: 'PPS',
decimals: 0,
ids: ['eurostat:earn_mw_cur:PPS.{e}'],
byCode: { USA: ['eurostat:earn_mw_cur:PPS.US'] },
from: '2000-01-01',
lowerIsBetter: false
},
// ── Cost of living ──
{
key: 'pricelevel',
group: 'Cost of living',
title: 'Price level of household consumption',
subtitle: 'how expensive life is at market exchange rates, USA = 100',
unit: 'USA = 100',
decimals: 0,
ids: ['jobs:pricelevel:{c}'],
from: '1990-01-01',
lowerIsBetter: true
},
{
key: 'cpl',
group: 'Cost of living',
title: 'Price level now (OECD, monthly)',
subtitle: 'comparative price level, USA = 100',
unit: 'USA = 100',
decimals: 0,
ids: ['oecd:jobs:cpl:{c}'],
bars: true,
lowerIsBetter: true
},
{
key: 'pli',
group: 'Cost of living',
title: 'Price level of household consumption (EU27 = 100)',
unit: 'EU27 = 100',
decimals: 0,
ids: ['eurostat:prc_ppp_ind:PLI_EU27_2020.A01.{e}'],
byCode: {
USA: ['eurostat:prc_ppp_ind:PLI_EU27_2020.A01.US'],
JPN: ['eurostat:prc_ppp_ind:PLI_EU27_2020.A01.JP']
},
from: '1995-01-01',
lowerIsBetter: true
},
{
key: 'plifood',
group: 'Cost of living',
title: 'Price level: food (EU27 = 100)',
unit: 'EU27 = 100',
decimals: 0,
ids: ['eurostat:prc_ppp_ind:PLI_EU27_2020.A0101.{e}'],
bars: true,
lowerIsBetter: true
},
{
key: 'plihousing',
group: 'Cost of living',
title: 'Price level: housing, water, energy (EU27 = 100)',
unit: 'EU27 = 100',
decimals: 0,
ids: ['eurostat:prc_ppp_ind:PLI_EU27_2020.A0104.{e}'],
bars: true,
lowerIsBetter: true
},
{
key: 'plitransport',
group: 'Cost of living',
title: 'Price level: transport (EU27 = 100)',
unit: 'EU27 = 100',
decimals: 0,
ids: ['eurostat:prc_ppp_ind:PLI_EU27_2020.A0107.{e}'],
bars: true,
lowerIsBetter: true
},
{
key: 'plirest',
group: 'Cost of living',
title: 'Price level: restaurants and hotels (EU27 = 100)',
unit: 'EU27 = 100',
decimals: 0,
ids: ['eurostat:prc_ppp_ind:PLI_EU27_2020.A0111.{e}'],
bars: true,
lowerIsBetter: true
},
{
key: 'bigmac',
group: 'Cost of living',
title: 'Big Mac price',
subtitle: 'NL and EU: euro-area average',
unit: 'US$',
decimals: 2,
ids: ['economist:bigmac:{c}:dollar_price'],
byCode: {
NLD: ['economist:bigmac:EA:dollar_price'],
EU27: ['economist:bigmac:EA:dollar_price'],
DEU: ['economist:bigmac:EA:dollar_price'],
FRA: ['economist:bigmac:EA:dollar_price'],
BEL: ['economist:bigmac:EA:dollar_price']
},
lowerIsBetter: true
},
// ── Housing ──
{
key: 'ptoi',
group: 'Housing',
title: 'House price-to-income',
subtitle: 'vs its own long-term average (= 100)',
unit: 'index',
decimals: 0,
ids: ['oecd:jobs:ptoi:{c}'],
from: '1980-01-01',
lowerIsBetter: true
},
{
key: 'ptor',
group: 'Housing',
title: 'House price-to-rent',
subtitle: 'vs its own long-term average (= 100)',
unit: 'index',
decimals: 0,
ids: ['oecd:jobs:ptor:{c}'],
from: '1980-01-01'
},
{
key: 'rent',
group: 'Housing',
title: 'Rents',
unit: 'Index 2015=100',
decimals: 0,
ids: ['oecd:jobs:rent:{c}'],
from: '2000-01-01'
},
{
key: 'overburden',
group: 'Housing',
title: 'Housing cost overburden, aged 25–29',
subtitle: 'living in a household spending over 40 % of income on housing',
unit: '%',
ids: ['eurostat:ilc_lvho07a:PC.TOTAL.Y25-29.T.{e}'],
from: '2005-01-01',
lowerIsBetter: true
},
{
key: 'parents',
group: 'Housing',
title: 'Still living with their parents, 25–29',
unit: '%',
ids: ['eurostat:ilc_lvps08:Y25-29.T.PC.{e}'],
from: '2005-01-01',
lowerIsBetter: true
}
]
},
{
slug: 'energy',
@@ -3,6 +3,7 @@
// render where one fits the combination; otherwise the generic metric charts do.
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import MatrixMetrics from '#lib/stats/MatrixMetrics.svelte';
import JobsExtras from '#lib/stats/JobsExtras.svelte';
import EconomyView from '#lib/stats/views/EconomyView.svelte';
import EnergyView from '#lib/stats/views/EnergyView.svelte';
import GoudaView from '#lib/stats/views/GoudaView.svelte';
@@ -31,6 +32,9 @@
</h1>
<p>{theme.blurb}</p>
</section>
{#if data.theme === 'jobs'}
<JobsExtras metrics={data.metrics} codes={sel.codes} attributions={data.attributions} />
{/if}
<MatrixMetrics metrics={data.metrics} codes={sel.codes} attributions={data.attributions} />
{:else}
{#each data.views as v, i (i)}