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
All checks were successful
Deploy production / deploy (push) Successful in 1m20s
All checks were successful
Deploy production / deploy (push) Successful in 1m20s
This commit is contained in:
@@ -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@<source>.service`, which runs
|
||||
`~/.local/bin/stats-collector-run.sh <source>`: it updates a detached checkout of `origin/main`
|
||||
|
||||
11
deploy/stats-collector/stats-jobs.timer
Normal file
11
deploy/stats-collector/stats-jobs.timer
Normal file
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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/<place>/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 = {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
498
scripts/stats/jobs.ts
Normal file
498
scripts/stats/jobs.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
// Jobs & cost of living (/stats/<place>/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:<name>:<ISO3>`.
|
||||
// - 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:<ISO3>` and `jobs:medianwage:<ISO3>`.
|
||||
// - The Economist Big Mac index (GitHub CSV, CC BY 4.0): `economist:bigmac:<ISO3>:<measure>`.
|
||||
// - 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:<ISO3>`.
|
||||
// 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<string, string> = { 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, string>) => string | null;
|
||||
meta: Record<string, { title: string; unit: string }>;
|
||||
};
|
||||
|
||||
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<string, string>[] {
|
||||
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<number> {
|
||||
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<string, SeriesRow>();
|
||||
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<string>();
|
||||
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<string, string> = { EUZ: 'EA' };
|
||||
|
||||
async function bigmac(): Promise<number> {
|
||||
const rows = parseCsv(await getText(BIGMAC));
|
||||
const known = new Set(REGIONS.map((r) => r.region_code));
|
||||
const measures: Record<string, { title: string; unit: string }> = {
|
||||
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<string, SeriesRow>();
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
165
src/lib/stats/JobsExtras.svelte
Normal file
165
src/lib/stats/JobsExtras.svelte
Normal 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
src/lib/stats/beveridge.test.ts
Normal file
33
src/lib/stats/beveridge.test.ts
Normal 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']]);
|
||||
});
|
||||
});
|
||||
@@ -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' }
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user