From b566a1244f68465d267049b37c8c4d8fd0e05b02 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Mon, 26 Oct 2026 18:07:56 +0000 Subject: [PATCH] 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/stats-collector/README.md | 55 +- deploy/stats-collector/stats-jobs.timer | 11 + scripts/stats-fetch.ts | 7 +- scripts/stats/common.ts | 9 + scripts/stats/eurostat.ts | 98 +++- scripts/stats/fred.ts | 97 ++++ scripts/stats/jobs.ts | 498 ++++++++++++++++++ scripts/stats/worldbank.ts | 37 ++ src/lib/stats/JobsExtras.svelte | 165 ++++++ src/lib/stats/beveridge.test.ts | 33 ++ src/lib/stats/chart.ts | 105 ++++ src/lib/stats/matrix.ts | 403 +++++++++++++- src/routes/stats/[place]/[theme]/+page.svelte | 4 + 13 files changed, 1492 insertions(+), 30 deletions(-) create mode 100644 deploy/stats-collector/stats-jobs.timer create mode 100644 scripts/stats/jobs.ts create mode 100644 src/lib/stats/JobsExtras.svelte create mode 100644 src/lib/stats/beveridge.test.ts diff --git a/deploy/stats-collector/README.md b/deploy/stats-collector/README.md index bfb37d1..66e0d51 100644 --- a/deploy/stats-collector/README.md +++ b/deploy/stats-collector/README.md @@ -3,33 +3,34 @@ Host-side collector for the public [/stats](https://stanrunge.dev/stats) section. It pulls open statistics into the `stats` schema with `scripts/stats-fetch.ts`: -| source | timer | schedule (Europe/Amsterdam) | what | -| ---------------- | ---------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cbs` | `stats-cbs.timer` | nightly 05:40 | CBS StatLine OData v4: checks every table's `Modified`, re-pulls changed ones, flags discontinued tables (base-year swaps) | -| `eurostat` | `stats-eurostat.timer` | Mondays 06:20 | Eurostat JSON-stat datasets (energy prices, yields, HICP index 2015 = 100 + annual rates by ECOICOP 2 category (`prc_hicp_minr`), government finance…) | -| `fred` | `stats-fred.timer` | daily 07:20 | FRED graph CSV (Brent, TTF, Fed, Treasuries, US energy prices, indices, US CPI by category) | -| `bis` | `stats-bis.timer` | daily 07:50 | BIS central bank policy rates (monthly + daily) and long CPI series `WS_LONG_CPI` (index 2010 = 100) for the core-7 and comparison countries | -| `worldbank` | `stats-worldbank.timer` | Mondays 04:15 | World Bank WDI indicators for the compared countries | -| `oecd` | `stats-oecd.timer` | Thursdays 06:35 | OECD Data Explorer SDMX CSV (no key): CPI index by category for Korea, Japan (ends 2021-06) and China (total + food), 10-year government bond yields | -| `statcan` | `stats-statcan.timer` | daily 16:10 | Statistics Canada WDS table 18-10-0004-01: CPI all items, food, shelter, energy, core (2002 = 100) | -| `imf` | `stats-imf.timer` | 1st of the month 04:05 | IMF WEO (DataMapper), stored per WEO vintage with forecast flags | -| `owid` | `stats-owid.timer` | 2nd of the month 04:35 | Our World in Data: Maddison GDP per capita, energy dataset | -| `energycharts` | `stats-energycharts.timer` | hourly at :19 | Energy-Charts NL day-ahead prices + cross-border flows (last 3 days; `--backfill` from 2015) | -| `rijksfinancien` | `stats-rijksfinancien.timer` | 3rd of the month 05:05 | Rijksfinanciën budget tables, last year to next year (`--backfill` from 2015) | -| `ustreasury` | `stats-ustreasury.timer` | daily 23:25 | US Treasury MTS receipts/outlays + Debt to the Penny (last 400 days; `--backfill` all) | -| `usaspending` | `stats-usaspending.timer` | Sundays 06:10 | USAspending budgetary resources per agency and fiscal year | -| `eponline` | `stats-eponline.timer` | 3rd of the month 05:20 | EP-Online monthly total file (RVO energy labels): homes per label for NL, Gouda and Gouda PC4s; aggregates only. Needs `EP_ONLINE_API_KEY` in `~/.config/stats/ep-online.env` (mode 600), which the runner exports for this source only | -| `elections` | `stats-elections.timer` | 15th of the month 04:50 | A14: Kiesraad Tweede Kamer results 2010→ (data.overheid.nl CSV + EML zips read with HTTP ranges; national, municipality, Gouda polling stations), CBS 37278 (1918–2006), MIT US presidential by state. Finished elections are skipped unless `--force` | -| `parties` | `stats-parties.timer` | quarterly, 16th 04:40 | A15: CHES trend file + V-Party (RData via `~/.local/share/stats-py`, pyreadr) linked with Party Facts into `wo_party`/`wo_party_position` | -| `wikipolls` | `stats-wikipolls.timer` | daily 08:10 | A16: Wikipedia poll tables (NL seats, Trump approval, Lee Jae-myung approval, KR party polls); wikitext snapshot per revision | -| `gtrends` | `stats-gtrends.timer` | daily 21:45 | A16: Google Trends trending-now RSS for NL, US, KR | -| `gdelt` | `stats-gdelt.timer` | nightly 02:30 | A16: GDELT DOC timelinevol, 10 countries × 12 themes, last 30 days, ≥5.5 s between requests | -| `eurobarometer` | `stats-eurobarometer.timer` | 20th of the month 05:35 | A16: Standard Eurobarometer volume A xlsx (QA3/QA4/QA5 most important issues, trust in institutions); new waves only | -| `whr` | `stats-whr.timer` | 21st of the month 05:10 | A16: World Happiness Report Figure 2.1 (life evaluation and components, 2011→) | -| `cbscw` | `stats-cbscw.timer` | Tuesdays 05:50 | CBS Curaçao: scrapes the topic pages (cbs.cw), matches each table's file by regex, re-imports only when its sha256 changes (`stats.cw_cbs_file`); CPI from PDF (pdfjs), GDP, government finance, population since 1900, LFS, tourism from xlsx | -| `cwintl` | `stats-cwintl.timer` | Wednesdays 05:50 | Curaçao from international APIs: ILOSTAT SDMX (labour, with retries), OWID grapher CSVs (population, life expectancy, median age, CO₂), UN SDG area 531, UN Comtrade public preview reporter 531. EU trade with Curaçao (Eurostat `ext_lt_maineu`) runs with the `eurostat` timer | -| – | – | manual | CBCS (centralbank.cw) blocks scripts with a Cloudflare challenge: download the BoP xlsx by hand and load it into `stats.cw_cbcs_bop` (period, item, mln_ang) | -| – | – | manual | China CPI by category beyond food (NBS, data.stats.gov.cn) has no stable no-key API: not collected. Japan e-Stat needs an app id: skipped | +| source | timer | schedule (Europe/Amsterdam) | what | +| ---------------- | ---------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cbs` | `stats-cbs.timer` | nightly 05:40 | CBS StatLine OData v4: checks every table's `Modified`, re-pulls changed ones, flags discontinued tables (base-year swaps) | +| `eurostat` | `stats-eurostat.timer` | Mondays 06:20 | Eurostat JSON-stat datasets (energy prices, yields, HICP index 2015 = 100 + annual rates by ECOICOP 2 category (`prc_hicp_minr`), government finance…) | +| `fred` | `stats-fred.timer` | daily 07:20 | FRED graph CSV (Brent, TTF, Fed, Treasuries, US energy prices, indices, US CPI by category) | +| `bis` | `stats-bis.timer` | daily 07:50 | BIS central bank policy rates (monthly + daily) and long CPI series `WS_LONG_CPI` (index 2010 = 100) for the core-7 and comparison countries | +| `worldbank` | `stats-worldbank.timer` | Mondays 04:15 | World Bank WDI indicators for the compared countries | +| `oecd` | `stats-oecd.timer` | Thursdays 06:35 | OECD Data Explorer SDMX CSV (no key): CPI index by category for Korea, Japan (ends 2021-06) and China (total + food), 10-year government bond yields | +| `statcan` | `stats-statcan.timer` | daily 16:10 | Statistics Canada WDS table 18-10-0004-01: CPI all items, food, shelter, energy, core (2002 = 100) | +| `imf` | `stats-imf.timer` | 1st of the month 04:05 | IMF WEO (DataMapper), stored per WEO vintage with forecast flags | +| `owid` | `stats-owid.timer` | 2nd of the month 04:35 | Our World in Data: Maddison GDP per capita, energy dataset | +| `energycharts` | `stats-energycharts.timer` | hourly at :19 | Energy-Charts NL day-ahead prices + cross-border flows (last 3 days; `--backfill` from 2015) | +| `rijksfinancien` | `stats-rijksfinancien.timer` | 3rd of the month 05:05 | Rijksfinanciën budget tables, last year to next year (`--backfill` from 2015) | +| `ustreasury` | `stats-ustreasury.timer` | daily 23:25 | US Treasury MTS receipts/outlays + Debt to the Penny (last 400 days; `--backfill` all) | +| `usaspending` | `stats-usaspending.timer` | Sundays 06:10 | USAspending budgetary resources per agency and fiscal year | +| `eponline` | `stats-eponline.timer` | 3rd of the month 05:20 | EP-Online monthly total file (RVO energy labels): homes per label for NL, Gouda and Gouda PC4s; aggregates only. Needs `EP_ONLINE_API_KEY` in `~/.config/stats/ep-online.env` (mode 600), which the runner exports for this source only | +| `elections` | `stats-elections.timer` | 15th of the month 04:50 | A14: Kiesraad Tweede Kamer results 2010→ (data.overheid.nl CSV + EML zips read with HTTP ranges; national, municipality, Gouda polling stations), CBS 37278 (1918–2006), MIT US presidential by state. Finished elections are skipped unless `--force` | +| `parties` | `stats-parties.timer` | quarterly, 16th 04:40 | A15: CHES trend file + V-Party (RData via `~/.local/share/stats-py`, pyreadr) linked with Party Facts into `wo_party`/`wo_party_position` | +| `wikipolls` | `stats-wikipolls.timer` | daily 08:10 | A16: Wikipedia poll tables (NL seats, Trump approval, Lee Jae-myung approval, KR party polls); wikitext snapshot per revision | +| `gtrends` | `stats-gtrends.timer` | daily 21:45 | A16: Google Trends trending-now RSS for NL, US, KR | +| `gdelt` | `stats-gdelt.timer` | nightly 02:30 | A16: GDELT DOC timelinevol, 10 countries × 12 themes, last 30 days, ≥5.5 s between requests | +| `eurobarometer` | `stats-eurobarometer.timer` | 20th of the month 05:35 | A16: Standard Eurobarometer volume A xlsx (QA3/QA4/QA5 most important issues, trust in institutions); new waves only | +| `whr` | `stats-whr.timer` | 21st of the month 05:10 | A16: World Happiness Report Figure 2.1 (life evaluation and components, 2011→) | +| `cbscw` | `stats-cbscw.timer` | Tuesdays 05:50 | CBS Curaçao: scrapes the topic pages (cbs.cw), matches each table's file by regex, re-imports only when its sha256 changes (`stats.cw_cbs_file`); CPI from PDF (pdfjs), GDP, government finance, population since 1900, LFS, tourism from xlsx | +| `cwintl` | `stats-cwintl.timer` | Wednesdays 05:50 | Curaçao from international APIs: ILOSTAT SDMX (labour, with retries), OWID grapher CSVs (population, life expectancy, median age, CO₂), UN SDG area 531, UN Comtrade public preview reporter 531. EU trade with Curaçao (Eurostat `ext_lt_maineu`) runs with the `eurostat` timer | +| `jobs` | `stats-jobs.timer` | Thursdays 06:55 | Jobs & cost of living: OECD SDMX (average/minimum wages USD PPP, hours, 50+ h weeks, temporary/part-time shares, long-term unemployment, monthly youth unemployment, EAG graduate employment + earnings premium, house price-to-income/-rent, comparative price levels USA = 100), Economist Big Mac index (CC BY 4.0), derived US openings per unemployed (run after `fred`). Eurostat/World Bank/FRED jobs series run with their own timers | +| – | – | manual | CBCS (centralbank.cw) blocks scripts with a Cloudflare challenge: download the BoP xlsx by hand and load it into `stats.cw_cbcs_bop` (period, item, mln_ang) | +| – | – | manual | China CPI by category beyond food (NBS, data.stats.gov.cn) has no stable no-key API: not collected. Japan e-Stat needs an app id: skipped | Each timer starts `stats-collector@.service`, which runs `~/.local/bin/stats-collector-run.sh `: it updates a detached checkout of `origin/main` diff --git a/deploy/stats-collector/stats-jobs.timer b/deploy/stats-collector/stats-jobs.timer new file mode 100644 index 0000000..969f32c --- /dev/null +++ b/deploy/stats-collector/stats-jobs.timer @@ -0,0 +1,11 @@ +[Unit] +Description=/stats collector: jobs & cost of living (OECD wages/hours/LFS/EAG/house prices/price levels, Big Mac index, JOLTS ratio), Thursdays + +[Timer] +OnCalendar=Thu *-*-* 06:55:00 Europe/Amsterdam +RandomizedDelaySec=10min +Persistent=true +Unit=stats-collector@jobs.service + +[Install] +WantedBy=timers.target diff --git a/scripts/stats-fetch.ts b/scripts/stats-fetch.ts index eea41a8..78fc889 100644 --- a/scripts/stats-fetch.ts +++ b/scripts/stats-fetch.ts @@ -17,6 +17,7 @@ // bun scripts/stats-fetch.ts elections [--force] [--only=kiesraad,cbs,mit] A14 // bun scripts/stats-fetch.ts parties CHES + V-Party + Party Facts (A15) // bun scripts/stats-fetch.ts wikipolls|eurobarometer [--force]|whr|gtrends|gdelt A16 +// bun scripts/stats-fetch.ts jobs [--only=wages,quality,finding,grads,housing,prices,bigmac,jolts,pricelevel] // bun scripts/stats-fetch.ts normalise-cbs rebuild CBS chart series from the raw rows import { ensureMeta, sql } from './stats/common'; import { cbs, normaliseCbs } from './stats/cbs'; @@ -43,6 +44,7 @@ import { whr } from './stats/whr'; import { gtrends } from './stats/gtrends'; import { gdelt } from './stats/gdelt'; import { history } from './stats/history'; +import { jobs } from './stats/jobs'; const [source, ...args] = process.argv.slice(2); const flag = (name: string) => args.includes(`--${name}`); @@ -124,12 +126,15 @@ try { case 'history': await history({ only: value('only'), job }); break; + case 'jobs': + await jobs({ only: value('only'), job }); + break; case 'gdelt': await gdelt({ job, timespan: value('timespan') }); break; default: throw new Error( - `Unknown source "${source}" (cbs, eurostat, fred, bis, oecd, statcan, worldbank, imf, owid, energycharts, rijksfinancien, ustreasury, usaspending, eponline, cbscw, cwintl, elections, parties, wikipolls, eurobarometer, whr, gtrends, gdelt, history, normalise-cbs)` + `Unknown source "${source}" (cbs, eurostat, fred, bis, oecd, statcan, worldbank, imf, owid, energycharts, rijksfinancien, ustreasury, usaspending, eponline, cbscw, cwintl, elections, parties, wikipolls, eurobarometer, whr, gtrends, gdelt, history, jobs, normalise-cbs)` ); } } finally { diff --git a/scripts/stats/common.ts b/scripts/stats/common.ts index 769e2fd..a008739 100644 --- a/scripts/stats/common.ts +++ b/scripts/stats/common.ts @@ -382,6 +382,14 @@ export const SOURCES = [ key_required: false, attribution: 'Source: Pew Research Center (2025), via Our World in Data' }, + { + source_id: 'economist', + name: 'The Economist Big Mac index', + base_url: 'https://github.com/TheEconomist/big-mac-data', + licence: 'CC BY 4.0', + key_required: false, + attribution: 'Source: The Economist, Big Mac index (CC BY 4.0)' + }, { source_id: 'history', name: 'Curated history and culture notes', @@ -397,6 +405,7 @@ export const REGIONS: { region_code: string; name: string; kind: string; parent: { region_code: 'WLD', name: 'World', kind: 'aggregate', parent: null }, { region_code: 'EU27', name: 'European Union (27)', kind: 'aggregate', parent: 'WLD' }, { region_code: 'EA', name: 'Euro area', kind: 'aggregate', parent: 'EU27' }, + { region_code: 'OECD', name: 'OECD members', kind: 'aggregate', parent: 'WLD' }, { region_code: 'NLD', name: 'Netherlands', kind: 'country', parent: 'EU27' }, { region_code: 'USA', name: 'United States', kind: 'country', parent: 'WLD' }, { region_code: 'KOR', name: 'South Korea', kind: 'country', parent: 'WLD' }, diff --git a/scripts/stats/eurostat.ts b/scripts/stats/eurostat.ts index 753d63a..55e0eff 100644 --- a/scripts/stats/eurostat.ts +++ b/scripts/stats/eurostat.ts @@ -129,7 +129,103 @@ export const EUROSTAT_DATASETS: Dataset[] = [ theme: 'economy', params: { unit: ['CP_MEUR', 'CLV_PCH_PRE'], na_item: 'B1GQ' } }, - { code: 'demo_pjan', theme: 'countries', params: { sex: 'T', age: 'TOTAL', unit: 'NR' } } + { code: 'demo_pjan', theme: 'countries', params: { sex: 'T', age: 'TOTAL', unit: 'NR' } }, + // ── Jobs & cost of living (/stats//jobs) ── + { + code: 'une_rt_m', + theme: 'jobs', + params: { s_adj: 'SA', age: ['TOTAL', 'Y_LT25', 'Y25-74'], unit: 'PC_ACT', sex: 'T' }, + geos: [...EU_GEOS, 'US', 'JP'] + }, + { + code: 'lfsa_urgan', + theme: 'jobs', + params: { unit: 'PC', sex: 'T', age: ['Y15-24', 'Y25-29', 'Y15-74'], citizen: 'TOTAL' } + }, + { + code: 'jvs_q_nace2', + theme: 'jobs', + params: { s_adj: 'NSA', nace_r2: 'B-S', sizeclas: 'TOTAL', indic_em: 'JVR' } + }, + { + // Employment rate of recent graduates (left education 1–3 years ago), 20–34. + code: 'edat_lfse_24', + theme: 'jobs', + params: { duration: 'Y1-3', isced11: ['ED3-8', 'ED5-8'], age: 'Y20-34', sex: 'T', unit: 'PC' } + }, + { + // NEET rate: not employed, not in education or training. + code: 'edat_lfse_20', + theme: 'jobs', + params: { + sex: 'T', + age: ['Y15-29', 'Y15-24'], + training: 'NO_FE_NO_NFE', + wstatus: 'NEMP', + unit: 'PC' + } + }, + { + code: 'lfsa_etpgan', + theme: 'jobs', + params: { unit: 'PC', sex: 'T', age: ['Y15-64', 'Y15-24', 'Y25-34'], citizen: 'TOTAL' } + }, + { + code: 'lfsa_eppga', + theme: 'jobs', + params: { unit: 'PC', sex: 'T', age: ['Y15-64', 'Y15-24'] } + }, + { + code: 'lfsa_ewhan2', + theme: 'jobs', + params: { + nace_r2: 'TOTAL', + wstatus: 'EMP', + worktime: ['TOTAL', 'FT'], + age: 'Y15-64', + sex: 'T', + unit: 'HR' + } + }, + { + code: 'earn_mw_cur', + theme: 'jobs', + params: { currency: ['EUR', 'PPS'] }, + geos: [...EU_GEOS, 'US'] + }, + { + // Price level indices (EU27 = 100): everything, food, alcohol & tobacco, housing, transport, restaurants. + code: 'prc_ppp_ind', + theme: 'jobs', + params: { + na_item: 'PLI_EU27_2020', + ppp_cat: ['GDP', 'A01', 'A0101', 'A0104', 'A0107', 'A0111'] + }, + geos: [...EU_GEOS, 'US', 'JP'] + }, + { + // Housing cost overburden: households spending > 40 % of disposable income on housing. + code: 'ilc_lvho07a', + theme: 'jobs', + params: { unit: 'PC', rskpovth: 'TOTAL', age: ['TOTAL', 'Y18-24', 'Y25-29'], sex: 'T' } + }, + { + code: 'une_ltu_a', + theme: 'jobs', + params: { indic_em: 'LTU', age: 'Y15-74', sex: 'T', unit: 'PC_UNE' } + }, + { + // Share of young adults still living with their parents. + code: 'ilc_lvps08', + theme: 'jobs', + params: { age: ['Y25-29', 'Y25-34'], sex: 'T', unit: 'PC' } + }, + { + // Structure of earnings survey (every 4 years): mean and median earnings, firms with 10+ staff. + code: 'earn_ses_pub2s', + theme: 'jobs', + params: { sizeclas: 'GE10', sex: 'T', unit: ['EUR', 'PPS'] } + } ]; type JsonStat = { diff --git a/scripts/stats/fred.ts b/scripts/stats/fred.ts index 73ed32f..13aae81 100644 --- a/scripts/stats/fred.ts +++ b/scripts/stats/fred.ts @@ -185,6 +185,103 @@ export const FRED_SERIES: Fred[] = [ freq: 'Q', region: 'USA', theme: 'economy' + }, + // Jobs & cost of living + { + id: 'LNS14024887', + title: 'US unemployment rate, 16–24', + unit: '%', + freq: 'M', + region: 'USA', + theme: 'jobs' + }, + { + id: 'LNS14000036', + title: 'US unemployment rate, 20–24', + unit: '%', + freq: 'M', + region: 'USA', + theme: 'jobs' + }, + { + id: 'JTSJOL', + title: 'US job openings (JOLTS)', + unit: 'Thousands', + freq: 'M', + region: 'USA', + theme: 'jobs' + }, + { + id: 'JTSJOR', + title: 'US job openings rate (JOLTS)', + unit: '% of jobs + openings', + freq: 'M', + region: 'USA', + theme: 'jobs' + }, + { + id: 'JTSHIR', + title: 'US hires rate (JOLTS)', + unit: '%', + freq: 'M', + region: 'USA', + theme: 'jobs' + }, + { + id: 'JTSQUR', + title: 'US quits rate (JOLTS)', + unit: '%', + freq: 'M', + region: 'USA', + theme: 'jobs' + }, + { + id: 'UNEMPLOY', + title: 'US unemployed persons', + unit: 'Thousands', + freq: 'M', + region: 'USA', + theme: 'jobs' + }, + { + id: 'LNS13025703', + title: 'US long-term unemployed (27+ weeks), share of unemployed', + unit: '%', + freq: 'M', + region: 'USA', + theme: 'jobs' + }, + { + id: 'CGBD2534', + title: 'US unemployment rate, bachelor’s degree, 25–34', + unit: '%', + freq: 'M', + region: 'USA', + theme: 'jobs' + }, + { + id: 'LES1252881600Q', + title: 'US median usual weekly real earnings', + unit: '1982–84 CPI-adjusted USD', + freq: 'Q', + region: 'USA', + theme: 'jobs' + }, + { + id: 'LES1252881500Q', + title: 'US median usual weekly earnings, full-time', + unit: 'USD', + freq: 'Q', + region: 'USA', + theme: 'jobs' + }, + { + id: 'FEDMINNFRWG', + title: 'US federal minimum wage', + unit: 'USD per hour', + freq: 'M', + region: 'USA', + theme: 'jobs' } ]; diff --git a/scripts/stats/jobs.ts b/scripts/stats/jobs.ts new file mode 100644 index 0000000..95f420a --- /dev/null +++ b/scripts/stats/jobs.ts @@ -0,0 +1,498 @@ +// Jobs & cost of living (/stats//jobs), the parts that are not a plain Eurostat / World +// Bank / FRED series list: +// - OECD Data Explorer SDMX (no key): average and minimum wages in USD PPP, hours, long hours, +// temporary-job shares, long-term unemployment, graduate employment and the tertiary +// earnings premium (Education at a Glance), house price-to-income / price-to-rent, monthly +// youth unemployment and comparative price levels (USA = 100). Series `oecd:jobs::`. +// - Derived from those: real wage growth (yearly change of the constant-price average wage) and +// an approximate median full-time wage (real minimum wage ÷ minimum-to-median ratio), +// `jobs:realwagegrowth:` and `jobs:medianwage:`. +// - The Economist Big Mac index (GitHub CSV, CC BY 4.0): `economist:bigmac::`. +// - Derived from FRED: US job openings per unemployed person (JTSJOL / UNEMPLOY). +// - Derived from World Bank: price level of private consumption, USA = 100 +// (PA.NUS.PRVT.PP ÷ PA.NUS.FCRF; WDI dropped PA.NUS.PPPC.RF), `jobs:pricelevel:`. +// Numbeo is not used: it has no free API and its terms forbid scraping. +import { + getText, + http, + log, + parsePeriod, + REGIONS, + run, + sql, + writeSeries, + type ObsRow, + type SeriesRow +} from './common'; + +const OECD = 'https://sdmx.oecd.org/public/rest/data'; +const CSV = { accept: 'application/vnd.sdmx.data+csv; charset=utf-8' }; +/** Countries and aggregates asked from OECD; non-members simply come back empty. */ +const AREAS = [ + 'NLD', + 'USA', + 'KOR', + 'JPN', + 'CAN', + 'CHN', + 'DEU', + 'FRA', + 'BEL', + 'GBR', + 'IND', + 'BRA', + 'EU27_2020', + 'EA20', + 'OECD' +]; +const AREA_REGION: Record = { EU27_2020: 'EU27', EA20: 'EA', OECD: 'OECD' }; +const areaRegion = (a: string) => AREA_REGION[a] ?? a; + +type OecdSpec = { + job: string; + /** Dataflow `agency,id,version`. */ + flow: string; + /** Key after the area, e.g. `.WG.USD_PPP.A.Q.MEAN._Z` (the area list is prefixed). */ + key: string; + start?: string; + /** Row → our series name (or null to skip). */ + pick: (r: Record) => string | null; + meta: Record; +}; + +const SPECS: OecdSpec[] = [ + { + job: 'wages', + flow: 'OECD.ELS.SAE,DSD_EARNINGS@AV_AN_WAGE,1.0', + key: '.WG.USD_PPP.A.Q.MEAN._Z', + pick: () => 'avwage', + meta: { + avwage: { + title: 'Average annual wage, full-time equivalent (constant prices, USD PPP)', + unit: 'USD PPP' + } + } + }, + { + job: 'wages', + flow: 'OECD.ELS.SAE,DSD_EARNINGS@RMW,1.0', + key: '.SM_WG.USD_PPP.A+H.Q._Z._Z', + pick: (r) => (r.PAY_PERIOD === 'A' ? 'minwage' : r.PAY_PERIOD === 'H' ? 'minwage_h' : null), + meta: { + minwage: { title: 'Real statutory minimum wage, yearly (USD PPP)', unit: 'USD PPP' }, + minwage_h: { title: 'Real statutory minimum wage, hourly (USD PPP)', unit: 'USD PPP / h' } + } + }, + { + job: 'wages', + flow: 'OECD.ELS.SAE,DSD_EARNINGS@MIN2AVE,1.0', + key: '.MR_WG_FT.PT_WG_A_SAL_FT._Z._Z.MEDIAN._Z', + pick: () => 'min2median', + meta: { + min2median: { + title: 'Minimum wage relative to the median full-time wage', + unit: '% of median' + } + } + }, + { + job: 'quality', + flow: 'OECD.ELS.SAE,DSD_HW@DF_AVG_ANN_HRS_WKD,1.0', + key: '.HW.H_Y_PS._Z._Z.EMP.A.ACTUAL._T._Z.MEAN._Z._T', + pick: () => 'hours', + meta: { hours: { title: 'Average annual hours actually worked per worker', unit: 'hours' } } + }, + { + job: 'quality', + flow: 'OECD.ELS.SAE,DSD_HW@DF_EMP_LNG_USL_WK_HRS,1.0', + key: '.SH_EMP_H.PT_WR_SEX_AGE_EMP_ST._T.Y15T64.EMP.W.USUAL._T._Z.OBS.H_GE50.MAIN', + pick: () => 'longhours', + meta: { + longhours: { + title: 'Employees and self-employed usually working 50+ hours a week', + unit: '% of employed' + } + } + }, + { + job: 'quality', + flow: 'OECD.ELS.SAE,DSD_TEMP@DF_TEMP_I,1.0', + key: '.EMP_TEMP.PT_POP_SUB._T._T+Y15T24.ICSE93_1.A', + pick: (r) => (r.AGE === '_T' ? 'temp' : 'temp_youth'), + meta: { + temp: { title: 'Temporary employment, share of employees', unit: '% of employees' }, + temp_youth: { + title: 'Temporary employment, share of employees aged 15–24', + unit: '% of employees' + } + } + }, + { + job: 'finding', + flow: 'OECD.ELS.SAE,DSD_DUR@DF_DUR_I,1.0', + key: '.UNEMP.PT_POP_SUB._T._T.Y_GE1.A', + pick: () => 'ltu', + meta: { + ltu: { title: 'Long-term unemployment (1 year or more)', unit: '% of unemployed' } + } + }, + { + job: 'finding', + flow: 'OECD.SDD.TPS,DSD_LFS@DF_IALFS_UNE_M,1.0', + key: '.UNE_LF_M.PT_LF_SUB._Z.Y._T.Y15T24+Y_GE25+Y_GE15._Z.M', + start: '1990-01', + pick: (r) => + r.AGE === 'Y15T24' + ? 'unemp_youth' + : r.AGE === 'Y_GE25' + ? 'unemp_25plus' + : r.AGE === 'Y_GE15' + ? 'unemp' + : null, + meta: { + unemp: { title: 'Unemployment rate, monthly (OECD harmonised)', unit: '%' }, + unemp_youth: { title: 'Youth unemployment rate 15–24, monthly', unit: '%' }, + unemp_25plus: { title: 'Unemployment rate 25+, monthly', unit: '%' } + } + }, + { + job: 'grads', + flow: 'OECD.EDU.IMEP,DSD_EAG_LSO_EA@DF_LSO_NEAC_EMP,1.0', + key: '._T.Y25T34.ISCED11A_5T8._T.EMPLOYMENT._Z._T._Z.ED_NED.EMP._Z.PT_POP_SUB.OBS._Z.NEAC.A', + pick: () => 'grademp', + meta: { + grademp: { + title: 'Employment rate of tertiary-educated 25–34 year-olds', + unit: '% of age group' + } + } + }, + { + job: 'grads', + flow: 'OECD.EDU.IMEP,DSD_EAG_LSO_EA@DF_LSO_EARN_REL_UPPER,1.0', + key: '._T.Y25T34.ISCED11A_5T8._T.EARN_RELATIVE._Z._T._Z.ED_NED.EMP._Z.PT_EARN_WR_ISCED11A_3.OBS.FT.EARNINGS.A', + pick: () => 'premium', + meta: { + premium: { + title: + 'Earnings of tertiary-educated 25–34 year-olds (full-time), relative to upper secondary', + unit: 'upper secondary = 100' + } + } + }, + { + job: 'housing', + flow: 'OECD.ECO.MPD,DSD_AN_HOUSE_PRICES@DF_HOUSE_PRICES,1.0', + key: '.Q.HPI_YDH_AVG+HPI_RPI_AVG+RPI.', + start: '1970-Q1', + pick: (r) => + r.MEASURE === 'HPI_YDH_AVG' + ? 'ptoi' + : r.MEASURE === 'HPI_RPI_AVG' + ? 'ptor' + : r.MEASURE === 'RPI' + ? 'rent' + : null, + meta: { + ptoi: { + title: 'House price-to-income ratio, vs its long-term average', + unit: 'long-term avg = 100' + }, + ptor: { + title: 'House price-to-rent ratio, vs its long-term average', + unit: 'long-term avg = 100' + }, + rent: { title: 'Rent price index', unit: 'Index 2015=100' } + } + }, + { + job: 'prices', + flow: 'OECD.SDD.TPS,DSD_PPP_M@DF_PP_CPL_M,1.0', + key: '.M.CPL.IX..USA', + start: '2015-01', + pick: (r) => (r.COUNTERPART_AREA === 'USA' ? 'cpl' : null), + meta: { + cpl: { + title: 'Comparative price level of final consumption (USA = 100)', + unit: 'USA = 100' + } + } + } +]; + +function parseCsv(text: string): Record[] { + const lines = text.trim().split(/\r?\n/); + const head = lines[0].split(','); + return lines.slice(1).map((l) => Object.fromEntries(l.split(',').map((v, i) => [head[i], v]))); +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function pullOecd(spec: OecdSpec): Promise { + const url = `${OECD}/${spec.flow}/${AREAS.join('+')}${spec.key}?startPeriod=${spec.start ?? '1990'}`; + const res = await http(url, { headers: CSV }); + const rows = parseCsv(await res.text()); + const series = new Map(); + const obs: ObsRow[] = []; + for (const r of rows) { + const name = spec.pick(r); + if (!name || r.OBS_VALUE === '' || r.OBS_VALUE == null) continue; + const value = Number(r.OBS_VALUE); + const p = parsePeriod(r.TIME_PERIOD ?? ''); + if (!p || !Number.isFinite(value)) continue; + const region = areaRegion(r.REF_AREA); + const id = `oecd:jobs:${name}:${region}`; + if (!series.has(id)) { + const m = spec.meta[name]; + series.set(id, { + series_id: id, + source_id: 'oecd', + source_code: `${spec.flow}/${r.REF_AREA}${spec.key}`, + title: m.title, + unit: m.unit, + freq: p.freq, + region_code: region, + theme: 'jobs', + meta: { url: 'https://data-explorer.oecd.org/', flow: spec.flow } + }); + } + obs.push({ series_id: id, period: p.date, value, status: r.OBS_STATUS || null }); + } + if (!obs.length) throw new Error(`no rows for ${spec.flow}`); + // One row per (series, period): SDMX can repeat a period with a different status. + const seen = new Set(); + const unique = obs.filter((o) => { + const k = `${o.series_id}|${o.period}`; + if (seen.has(k)) return false; + seen.add(k); + return true; + }); + await writeSeries([...series.values()], unique); + log(`jobs oecd ${spec.flow.split(',')[1]}: ${unique.length} obs, ${series.size} series`); + return unique.length; +} + +// ── Big Mac index ───────────────────────────────────────────────────────────────────────── +const BIGMAC = + 'https://raw.githubusercontent.com/TheEconomist/big-mac-data/master/output-data/big-mac-full-index.csv'; +const BIGMAC_REGION: Record = { EUZ: 'EA' }; + +async function bigmac(): Promise { + const rows = parseCsv(await getText(BIGMAC)); + const known = new Set(REGIONS.map((r) => r.region_code)); + const measures: Record = { + dollar_price: { title: 'Big Mac price in US dollars', unit: 'US$' }, + USD_raw: { + title: 'Big Mac index: local currency vs the US dollar (raw)', + unit: '% over/under-valued' + } + }; + const series = new Map(); + const obs: ObsRow[] = []; + for (const r of rows) { + const region = BIGMAC_REGION[r.iso_a3] ?? r.iso_a3; + if (!known.has(region) || !/^\d{4}-\d{2}-\d{2}$/.test(r.date)) continue; + for (const [m, meta] of Object.entries(measures)) { + const raw = Number(r[m]); + if (r[m] === '' || !Number.isFinite(raw)) continue; + const id = `economist:bigmac:${region}:${m}`; + if (!series.has(id)) + series.set(id, { + series_id: id, + source_id: 'economist', + source_code: `big-mac-full-index/${r.iso_a3}/${m}`, + title: meta.title, + unit: meta.unit, + freq: 'S', + region_code: region, + theme: 'jobs', + meta: { url: 'https://github.com/TheEconomist/big-mac-data' } + }); + obs.push({ + series_id: id, + period: r.date, + value: m === 'USD_raw' ? Math.round(raw * 1000) / 10 : raw, + status: null + }); + } + } + if (!obs.length) throw new Error('Big Mac CSV: no rows'); + await writeSeries([...series.values()], obs); + log(`jobs bigmac: ${obs.length} obs, ${series.size} series`); + return obs.length; +} + +// ── Derived: US openings per unemployed person ───────────────────────────────────────────── +async function joltsRatio(): Promise { + const rows = await sql<{ period: string; value: number }[]>` + select o.period::text as period, round((o.value / u.value)::numeric, 3)::float8 as value + from stats.obs o join stats.obs u on u.period = o.period and u.series_id = 'fred:UNEMPLOY' + where o.series_id = 'fred:JTSJOL' and u.value > 0 and o.value is not null + order by o.period`; + if (!rows.length) throw new Error('JOLTS ratio: run the fred collector first (JTSJOL, UNEMPLOY)'); + const id = 'jobs:openings_per_unemployed:USA'; + await writeSeries( + [ + { + series_id: id, + source_id: 'fred', + source_code: 'JTSJOL/UNEMPLOY', + title: 'US job openings per unemployed person (JOLTS openings ÷ unemployed)', + unit: 'openings per unemployed', + freq: 'M', + region_code: 'USA', + theme: 'jobs', + meta: { url: 'https://fred.stlouisfed.org/series/JTSJOL', derived: 'JTSJOL / UNEMPLOY' } + } + ], + rows.map((r) => ({ series_id: id, period: r.period, value: r.value, status: null })) + ); + return rows.length; +} + +// ── Derived: real wage growth and an approximate median wage ──────────────────────────────── +async function wageDerived(): Promise { + const growth = await sql<{ region: string; period: string; value: number }[]>` + select s.region_code as region, o.period::text as period, + round((100 * (o.value / p.value - 1))::numeric, 2)::float8 as value + from stats.series s + join stats.obs o on o.series_id = s.series_id + join stats.obs p on p.series_id = s.series_id and p.period = o.period - interval '1 year' + where s.series_id like 'oecd:jobs:avwage:%' and p.value > 0 and o.value is not null + order by 1, 2`; + const median = await sql<{ region: string; period: string; value: number }[]>` + select s.region_code as region, o.period::text as period, + round((o.value / (r.value / 100))::numeric, 0)::float8 as value + from stats.series s + join stats.obs o on o.series_id = s.series_id + join stats.obs r on r.series_id = 'oecd:jobs:min2median:' || s.region_code and r.period = o.period + where s.series_id like 'oecd:jobs:minwage:%' and r.value > 0 and o.value is not null + order by 1, 2`; + const write = async ( + rows: { region: string; period: string; value: number }[], + name: string, + title: string, + unit: string, + derived: string + ) => { + const regions = [...new Set(rows.map((r) => r.region))]; + await writeSeries( + regions.map((region) => ({ + series_id: `jobs:${name}:${region}`, + source_id: 'oecd', + source_code: derived, + title, + unit, + freq: 'A', + region_code: region, + theme: 'jobs', + meta: { url: 'https://data-explorer.oecd.org/', derived } + })), + rows.map((r) => ({ + series_id: `jobs:${name}:${r.region}`, + period: r.period, + value: r.value, + status: null + })) + ); + return rows.length; + }; + if (!growth.length) throw new Error('wage derived: run the wages job first'); + return ( + (await write( + growth, + 'realwagegrowth', + 'Real average wage growth (constant prices)', + '%', + 'yoy(AV_AN_WAGE, constant prices)' + )) + + (await write( + median, + 'medianwage', + 'Median full-time wage, approx. (real minimum wage ÷ minimum-to-median ratio)', + 'USD PPP', + 'RMW / MIN2AVE(median)' + )) + ); +} + +// ── Derived: price level (USA = 100) from the World Bank PPP and exchange rate ───────────── +async function priceLevel(): Promise { + const rows = await sql<{ region: string; period: string; value: number }[]>` + select p.region_code as region, o.period::text as period, + round((100 * o.value / x.value)::numeric, 1)::float8 as value + from stats.series p + join stats.obs o on o.series_id = p.series_id + join stats.obs x on x.period = o.period and x.series_id = 'wb:PA.NUS.FCRF:' || p.region_code + where p.series_id like 'wb:PA.NUS.PRVT.PP:%' and p.region_code is not null + and x.value > 0 and o.value is not null + order by 1, 2`; + if (!rows.length) + throw new Error('price level: run worldbank first (PA.NUS.PRVT.PP, PA.NUS.FCRF)'); + const regions = [...new Set(rows.map((r) => r.region))]; + await writeSeries( + regions.map((region) => ({ + series_id: `jobs:pricelevel:${region}`, + source_id: 'worldbank', + source_code: 'PA.NUS.PRVT.PP/PA.NUS.FCRF', + title: 'Price level of household consumption (PPP ÷ exchange rate, USA = 100)', + unit: 'USA = 100', + freq: 'A', + region_code: region, + theme: 'jobs', + meta: { + url: 'https://data.worldbank.org/indicator/PA.NUS.PRVT.PP', + derived: '100 × PA.NUS.PRVT.PP / PA.NUS.FCRF' + } + })), + rows.map((r) => ({ + series_id: `jobs:pricelevel:${r.region}`, + period: r.period, + value: r.value, + status: null + })) + ); + return rows.length; +} + +export async function jobs(opts: { only?: string; job: string }) { + await run('oecd', `jobs:${opts.job}`, async (note) => { + const want = (j: string) => !opts.only || opts.only.split(',').includes(j); + let total = 0; + for (const spec of SPECS) { + if (!want(spec.job)) continue; + try { + total += await pullOecd(spec); + } catch (error) { + note(`${spec.flow}: ${error instanceof Error ? error.message : String(error)}`); + } + await sleep(3200); // OECD: ≤ 20 requests a minute + } + if (want('bigmac')) + try { + total += await bigmac(); + } catch (error) { + note(`bigmac: ${error instanceof Error ? error.message : String(error)}`); + } + if (want('jolts')) + try { + total += await joltsRatio(); + } catch (error) { + note(`jolts: ${error instanceof Error ? error.message : String(error)}`); + } + if (want('wages') || want('derived')) + try { + total += await wageDerived(); + } catch (error) { + note(`wage derived: ${error instanceof Error ? error.message : String(error)}`); + } + if (want('pricelevel')) + try { + total += await priceLevel(); + } catch (error) { + note(`pricelevel: ${error instanceof Error ? error.message : String(error)}`); + } + if (!total) throw new Error('nothing loaded'); + return total; + }); +} diff --git a/scripts/stats/worldbank.ts b/scripts/stats/worldbank.ts index 452f6c9..ff4b221 100644 --- a/scripts/stats/worldbank.ts +++ b/scripts/stats/worldbank.ts @@ -102,6 +102,43 @@ export const WB_INDICATORS: { id: string; title: string; unit: string; theme: st title: 'Renewable electricity output', unit: '% of electricity', theme: 'energy' + }, + // Jobs & cost of living (modelled ILO estimates; cover China and most others). + { + id: 'SL.UEM.1524.ZS', + title: 'Youth unemployment, 15–24 (ILO modelled)', + unit: '%', + theme: 'jobs' + }, + { + id: 'SL.UEM.NEET.ZS', + title: 'Youth not in education, employment or training (NEET)', + unit: '% of youth', + theme: 'jobs' + }, + { + id: 'SL.UEM.ADVN.ZS', + title: 'Unemployment with advanced education', + unit: '% of labour force with advanced education', + theme: 'jobs' + }, + { + id: 'SL.TLF.PART.ZS', + title: 'Part-time employment', + unit: '% of total employment', + theme: 'jobs' + }, + { + id: 'PA.NUS.FCRF', + title: 'Official exchange rate', + unit: 'LCU per US$, period average', + theme: 'jobs' + }, + { + id: 'PA.NUS.PRVT.PP', + title: 'PPP conversion factor, private consumption', + unit: 'LCU per international $', + theme: 'jobs' } ]; diff --git a/src/lib/stats/JobsExtras.svelte b/src/lib/stats/JobsExtras.svelte new file mode 100644 index 0000000..7104e5c --- /dev/null +++ b/src/lib/stats/JobsExtras.svelte @@ -0,0 +1,165 @@ + + +{#if anyCard} +

Grad view

+
+ {#each cards as card (card.code)} +
+
+ + {card.place?.name ?? card.code} +
+
+ {#each card.rows as r (r.label)} +
+
{r.label}
+
+ {#if r.c} + {r.c.value} + {r.c.unit} · {r.c.when} + {:else}no data{/if} +
+
+ {/each} +
+
+ {/each} +
+{/if} + +{#if curves.length} +
+ 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 %' })} + /> +
+{/if} + + diff --git a/src/lib/stats/beveridge.test.ts b/src/lib/stats/beveridge.test.ts new file mode 100644 index 0000000..20243dc --- /dev/null +++ b/src/lib/stats/beveridge.test.ts @@ -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']]); + }); +}); diff --git a/src/lib/stats/chart.ts b/src/lib/stats/chart.ts index 0d1a099..1f08c9f 100644 --- a/src/lib/stats/chart.ts +++ b/src/lib/stats/chart.ts @@ -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 { + const acc = new Map(); + 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]}
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' } + }; + }) + }; +} diff --git a/src/lib/stats/matrix.ts b/src/lib/stats/matrix.ts index a9d63cb..32231a5 100644 --- a/src/lib/stats/matrix.ts +++ b/src/lib/stats/matrix.ts @@ -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', diff --git a/src/routes/stats/[place]/[theme]/+page.svelte b/src/routes/stats/[place]/[theme]/+page.svelte index 6026249..73b8ce0 100644 --- a/src/routes/stats/[place]/[theme]/+page.svelte +++ b/src/routes/stats/[place]/[theme]/+page.svelte @@ -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 @@

{theme.blurb}

+ {#if data.theme === 'jobs'} + + {/if} {:else} {#each data.views as v, i (i)}