Files
website/src/lib/server/db/schema/stats.ts
2026-10-25 23:25:46 +00:00

852 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
bigint,
boolean,
char,
date,
doublePrecision,
index,
integer,
jsonb,
primaryKey,
text
} from 'drizzle-orm/pg-core';
import { statsSchema } from './schemas';
import { timestamptz } from './helpers';
// Public /stats section: open statistics from official sources (CBS, Eurostat, FRED, BIS, …).
// Two layers (see docs/stats.md):
// - raw tables per source (`cbs_*`, `eurostat_*`, `fred_*`, `bis_*`), keeping the source's own codes;
// - a normalised layer (`source`, `series`, `obs`, `region`) that every chart reads.
// Written only by the stats collector (scripts/stats-fetch.ts, systemd user timers on the host);
// every run is logged in `fetch_run`. Everything here is open data, so personal_ro may read it.
export const statsSource = statsSchema.table('source', {
sourceId: text('source_id').primaryKey(),
name: text('name').notNull(),
baseUrl: text('base_url').notNull(),
licence: text('licence').notNull(),
keyRequired: boolean('key_required').notNull().default(false),
/** Footer line under every chart that uses this source. */
attribution: text('attribution').notNull()
});
export const statsRegion = statsSchema.table('region', {
/** ISO3 for countries, 'EU27' / 'EA' / 'WLD' aggregates, CBS codes (GM0513, CR28) below that. */
regionCode: text('region_code').primaryKey(),
name: text('name').notNull(),
/** country, municipality, corop, aggregate */
kind: text('kind').notNull(),
parent: text('parent')
});
export const statsSeries = statsSchema.table(
'series',
{
/** Stable id, e.g. `cbs:86141NED:cpi-yoy`, `fred:DCOILBRENTEU`, `bis:XM`. */
seriesId: text('series_id').primaryKey(),
sourceId: text('source_id')
.notNull()
.references(() => statsSource.sourceId),
/** The source's own code (table + measure + dimension keys, FRED id, …). */
sourceCode: text('source_code').notNull(),
title: text('title').notNull(),
unit: text('unit').notNull(),
/** D, M, Q, S (semester) or A */
freq: char('freq', { length: 1 }).notNull(),
regionCode: text('region_code').references(() => statsRegion.regionCode),
/** economy, energy, gouda, budget, countries, … */
theme: text('theme').notNull(),
meta: jsonb('meta').$type<Record<string, unknown>>().notNull().default({})
},
(t) => [index('series_theme_idx').on(t.theme), index('series_source_idx').on(t.sourceId)]
);
export const statsObs = statsSchema.table(
'obs',
{
seriesId: text('series_id')
.notNull()
.references(() => statsSeries.seriesId, { onDelete: 'cascade' }),
/** Period start date: 2026-S1 → 2026-01-01, 2026Q3 → 2026-07-01, 2026 → 2026-01-01. */
period: date('period', { mode: 'string' }).notNull(),
value: doublePrecision('value'),
/** Source status flag (provisional, estimated, …) where given. */
status: text('status'),
fetchedAt: timestamptz('fetched_at').notNull().defaultNow()
},
(t) => [primaryKey({ columns: [t.seriesId, t.period] })]
);
export const statsFetchRun = statsSchema.table(
'fetch_run',
{
runId: integer('run_id').primaryKey().generatedAlwaysAsIdentity(),
sourceId: text('source_id')
.notNull()
.references(() => statsSource.sourceId),
/** What the run did: 'refresh', 'backfill', or a single table/dataset code. */
job: text('job').notNull().default('refresh'),
startedAt: timestamptz('started_at').notNull().defaultNow(),
finishedAt: timestamptz('finished_at'),
rows: integer('rows'),
ok: boolean('ok'),
error: text('error')
},
(t) => [index('fetch_run_source_idx').on(t.sourceId, t.startedAt)]
);
// ── A1 CBS StatLine (OData v4, long format) ───────────────────────────────────────────────
export const statsCbsTable = statsSchema.table('cbs_table', {
tableId: text('table_id').primaryKey(),
title: text('title').notNull(),
/** `Modified` from the table's Properties; the nightly run re-pulls only when it changes. */
modified: timestamptz('modified'),
status: text('status'),
/** OData $filter used for big tables (regions of interest only); null = full table. */
filter: text('filter'),
obsCount: integer('obs_count'),
fetchedAt: timestamptz('fetched_at')
});
export const statsCbsCode = statsSchema.table(
'cbs_code',
{
tableId: text('table_id').notNull(),
/** 'Measure' for MeasureCodes, otherwise the dimension identifier (RegioS, Perioden, …). */
dimension: text('dimension').notNull(),
code: text('code').notNull(),
title: text('title').notNull(),
unit: text('unit'),
decimals: integer('decimals'),
groupId: text('group_id'),
sortIndex: integer('sort_index')
},
(t) => [primaryKey({ columns: [t.tableId, t.dimension, t.code] })]
);
export const statsCbsObs = statsSchema.table(
'cbs_obs',
{
tableId: text('table_id').notNull(),
/** CBS observation Id (unique per table). */
obsId: bigint('obs_id', { mode: 'number' }).notNull(),
measure: text('measure').notNull(),
/** Every non-time dimension, trimmed: {"RegioS":"GM0513","Geslacht":"T001038"}. */
dims: jsonb('dims').$type<Record<string, string>>().notNull(),
/** CBS period code as-is: 2026MM09, 2026KW03, 2026JJ00. */
period: text('period').notNull(),
value: doublePrecision('value'),
valueAttribute: text('value_attribute')
},
(t) => [
primaryKey({ columns: [t.tableId, t.obsId] }),
index('cbs_obs_table_measure_idx').on(t.tableId, t.measure)
]
);
// ── A2 Eurostat dissemination API (JSON-stat) ─────────────────────────────────────────────
export const statsEurostatDataset = statsSchema.table('eurostat_dataset', {
code: text('code').primaryKey(),
label: text('label').notNull(),
/** `updated` from the JSON-stat payload. */
updated: timestamptz('updated'),
/** Query used (filters), so a refresh repeats it exactly. */
query: text('query').notNull(),
/** Dimension ids in order plus their category labels. */
dims: jsonb('dims').$type<Record<string, unknown>>().notNull().default({}),
obsCount: integer('obs_count'),
fetchedAt: timestamptz('fetched_at')
});
export const statsEurostatObs = statsSchema.table(
'eurostat_obs',
{
dataset: text('dataset').notNull(),
/** Non-time dimension codes joined with '.', in the dataset's dimension order. */
key: text('key').notNull(),
geo: text('geo').notNull(),
/** Eurostat time code as-is: 2026-S1, 2026-09, 2026-Q2, 2025. */
period: text('period').notNull(),
value: doublePrecision('value'),
status: text('status')
},
(t) => [
primaryKey({ columns: [t.dataset, t.key, t.period] }),
index('eurostat_obs_dataset_geo_idx').on(t.dataset, t.geo)
]
);
// ── A7 FRED graph CSV, A5 BIS policy rates ────────────────────────────────────────────────
export const statsFredObs = statsSchema.table(
'fred_obs',
{
seriesId: text('series_id').notNull(),
period: date('period', { mode: 'string' }).notNull(),
value: doublePrecision('value')
},
(t) => [primaryKey({ columns: [t.seriesId, t.period] })]
);
export const statsBisPolicyRate = statsSchema.table(
'bis_policy_rate',
{
/** BIS area code: US, XM (euro area), KR, CN, GB, JP. */
refArea: text('ref_area').notNull(),
/** D or M */
freq: char('freq', { length: 1 }).notNull(),
period: date('period', { mode: 'string' }).notNull(),
rate: doublePrecision('rate')
},
(t) => [primaryKey({ columns: [t.refArea, t.freq, t.period] })]
);
// ── A3 World Bank indicators, A4 IMF WEO (DataMapper) ─────────────────────────────────────
export const statsWbIndicator = statsSchema.table(
'wb_indicator',
{
indicatorId: text('indicator_id').notNull(),
countryIso3: text('country_iso3').notNull(),
year: integer('year').notNull(),
value: doublePrecision('value')
},
(t) => [primaryKey({ columns: [t.indicatorId, t.countryIso3, t.year] })]
);
export const statsImfWeo = statsSchema.table(
'imf_weo',
{
/** WEO round the values come from, e.g. '2026-10' (October 2026 WEO). */
vintage: text('vintage').notNull(),
indicator: text('indicator').notNull(),
countryIso3: text('country_iso3').notNull(),
year: integer('year').notNull(),
value: doublePrecision('value'),
isForecast: boolean('is_forecast').notNull()
},
(t) => [primaryKey({ columns: [t.vintage, t.indicator, t.countryIso3, t.year] })]
);
// ── A9 Our World in Data (Maddison GDP per capita, energy dataset) ───────────────────────
export const statsOwidMaddison = statsSchema.table(
'owid_maddison',
{
entity: text('entity').notNull(),
/** null for OWID regions (Western Europe, World, …). */
iso3: text('iso3'),
year: integer('year').notNull(),
/** International-$ at 2011 prices. */
gdpPc: doublePrecision('gdp_pc')
},
(t) => [primaryKey({ columns: [t.entity, t.year] })]
);
export const statsOwidEnergy = statsSchema.table(
'owid_energy',
{
country: text('country').notNull(),
iso3: text('iso3'),
year: integer('year').notNull(),
/** Selected OWID columns as-is: {"solar_share_elec": 21.3, "energy_per_capita": 40000, …}. */
values: jsonb('values').$type<Record<string, number>>().notNull()
},
(t) => [primaryKey({ columns: [t.country, t.year] })]
);
// ── A10 Energy-Charts (Fraunhofer ISE): day-ahead prices, cross-border physical flows ─────
export const statsEcDayaheadPrice = statsSchema.table(
'ec_dayahead_price',
{
bzn: text('bzn').notNull(),
ts: timestamptz('ts').notNull(),
eurMwh: doublePrecision('eur_mwh')
},
(t) => [primaryKey({ columns: [t.bzn, t.ts] })]
);
export const statsEcCrossborder = statsSchema.table(
'ec_crossborder',
{
/** Lower-case Energy-Charts country code, e.g. 'nl'. */
country: text('country').notNull(),
/** Neighbour name as Energy-Charts reports it ('Belgium', …, 'sum'). */
neighbour: text('neighbour').notNull(),
ts: timestamptz('ts').notNull(),
/** GW; positive = import into `country`, negative = export. */
gw: doublePrecision('gw')
},
(t) => [primaryKey({ columns: [t.country, t.neighbour, t.ts] })]
);
// ── A13 Rijksfinanciën (NL central-government budget tables) ──────────────────────────────
export const statsRfBudgetTable = statsSchema.table(
'rf_budget_table',
{
year: integer('year').notNull(),
/** OWB (budget), O1/O2 (supplementary budgets), JV (annual report). */
phase: text('phase').notNull(),
/** Rijksfinanciën row index, unique within year + phase (includes chapter/article/vuo). */
rowKey: text('row_key').notNull(),
/** U = expenditure, O = revenue, V = commitments. */
vuo: char('vuo', { length: 1 }).notNull(),
chapter: text('chapter').notNull(),
chapterName: text('chapter_name').notNull(),
article: text('article'),
articleName: text('article_name'),
detail: jsonb('detail').$type<Record<string, unknown>>().notNull().default({}),
/** Thousand euro. */
amountEurK: doublePrecision('amount_eur_k')
},
(t) => [
primaryKey({ columns: [t.year, t.phase, t.rowKey] }),
index('rf_budget_table_chapter_idx').on(t.year, t.vuo, t.chapter)
]
);
// ── A11 US Treasury Fiscal Data (Monthly Treasury Statement, Debt to the Penny) ───────────
const mtsColumns = {
recordDate: date('record_date', { mode: 'string' }).notNull(),
classificationId: text('classification_id').notNull(),
parentId: text('parent_id'),
description: text('description').notNull(),
level: integer('level'),
printOrder: integer('print_order'),
/** Net amount for the month and fiscal year to date, USD. */
netMonth: doublePrecision('net_month'),
netFytd: doublePrecision('net_fytd'),
grossMonth: doublePrecision('gross_month')
};
export const statsUstMtsReceipts = statsSchema.table('ust_mts_receipts', mtsColumns, (t) => [
primaryKey({ columns: [t.recordDate, t.classificationId] })
]);
export const statsUstMtsOutlays = statsSchema.table('ust_mts_outlays', { ...mtsColumns }, (t) => [
primaryKey({ columns: [t.recordDate, t.classificationId] })
]);
export const statsUstDebtDaily = statsSchema.table('ust_debt_daily', {
recordDate: date('record_date', { mode: 'string' }).primaryKey(),
debtHeldPublic: doublePrecision('debt_held_public'),
intragovHoldings: doublePrecision('intragov_holdings'),
totalDebt: doublePrecision('total_debt')
});
// ── A12 USAspending (budgetary resources per top-tier agency and fiscal year) ─────────────
export const statsUsasAgencyFy = statsSchema.table(
'usas_agency_fy',
{
toptierCode: text('toptier_code').notNull(),
fiscalYear: integer('fiscal_year').notNull(),
agencyName: text('agency_name').notNull(),
abbreviation: text('abbreviation'),
budgetaryResources: doublePrecision('budgetary_resources'),
obligated: doublePrecision('obligated'),
outlayed: doublePrecision('outlayed')
},
(t) => [primaryKey({ columns: [t.toptierCode, t.fiscalYear] })]
);
// ── B11 EP-Online (registered energy labels, homes; aggregates only, no addresses) ─────────
export const statsEpLabelAgg = statsSchema.table(
'ep_label_agg',
{
/** Publication date of the monthly total file. */
snapshot: date('snapshot', { mode: 'string' }).notNull(),
/** NL, GOUDA (postcodes 2800–2809) or a Gouda PC4. */
area: text('area').notNull(),
label: text('label').notNull(),
homes: integer('homes').notNull()
},
(t) => [primaryKey({ columns: [t.snapshot, t.area, t.label] })]
);
// ── §8 Curaçao: CBS Curaçao files (hash-tracked) and CBCS manual uploads ───────────────────
/** One row per CBS Curaçao table: the file last imported and its hash (re-import on change). */
export const statsCwCbsFile = statsSchema.table('cw_cbs_file', {
url: text('url').primaryKey(),
topic: text('topic').notNull(),
filename: text('filename').notNull(),
sha256: text('sha256').notNull(),
fetchedAt: timestamptz('fetched_at').notNull().defaultNow()
});
export const statsCwCbsCpi = statsSchema.table('cw_cbs_cpi', {
period: date('period', { mode: 'string' }).primaryKey(),
cpiIndex: doublePrecision('cpi_index').notNull(),
inflMom: doublePrecision('infl_mom'),
inflYoy: doublePrecision('infl_yoy'),
/** CBS's inflation figure: change of the 12-month average index. */
infl12mAvg: doublePrecision('infl_12m_avg'),
base: text('base')
});
export const statsCwCbsGdpIndustry = statsSchema.table(
'cw_cbs_gdp_industry',
{
year: integer('year').notNull(),
/** ISIC 4 section(s), or GVA / TLS / GDP / REAL_GROWTH_PCT / NOMINAL_GROWTH_PCT. */
isic: text('isic').notNull(),
label: text('label').notNull(),
/** mln ANG (current prices); % for the *_PCT rows. */
mlnAng: doublePrecision('mln_ang'),
provisional: boolean('provisional').notNull().default(false)
},
(t) => [primaryKey({ columns: [t.year, t.isic] })]
);
export const statsCwCbsGovFinance = statsSchema.table(
'cw_cbs_gov_finance',
{
year: integer('year').notNull(),
/** R./E. + ESA code (R.D2, E.D1, …) or R.TOTAL / E.TOTAL. */
itemCode: text('item_code').notNull(),
label: text('label').notNull(),
mlnAng: doublePrecision('mln_ang'),
provisional: boolean('provisional').notNull().default(true)
},
(t) => [primaryKey({ columns: [t.year, t.itemCode] })]
);
export const statsCwCbsPopulation = statsSchema.table(
'cw_cbs_population',
{
year: integer('year').notNull(),
sex: text('sex').notNull(),
ageGroup: text('age_group').notNull(),
/** jan1, dec31 (before 1992), census, midyear. */
kind: text('kind').notNull(),
value: doublePrecision('value')
},
(t) => [primaryKey({ columns: [t.year, t.sex, t.ageGroup, t.kind] })]
);
export const statsCwCbsLfs = statsSchema.table(
'cw_cbs_lfs',
{
year: integer('year').notNull(),
sex: text('sex').notNull(),
ageGroup: text('age_group').notNull(),
measure: text('measure').notNull(),
value: doublePrecision('value')
},
(t) => [primaryKey({ columns: [t.year, t.sex, t.ageGroup, t.measure] })]
);
export const statsCwCbsTourism = statsSchema.table(
'cw_cbs_tourism',
{
period: date('period', { mode: 'string' }).notNull(),
/** cruise_calls, cruise_pax, daytrippers, nights, air_landings_scheduled, air_landings_civil. */
measure: text('measure').notNull(),
/** total or source market (north_america, europe, …). */
market: text('market').notNull(),
value: doublePrecision('value')
},
(t) => [primaryKey({ columns: [t.period, t.measure, t.market] })]
);
/** CBCS balance of payments: manual upload only (centralbank.cw blocks scripted access). */
export const statsCwCbcsBop = statsSchema.table(
'cw_cbcs_bop',
{
period: date('period', { mode: 'string' }).notNull(),
item: text('item').notNull(),
mlnAng: doublePrecision('mln_ang'),
uploadedAt: timestamptz('uploaded_at').notNull().defaultNow()
},
(t) => [primaryKey({ columns: [t.period, t.item] })]
);
// ── Step 3: elections (A14) ──────────────────────────────────────────────────────────────
/** One election: NL Tweede Kamer (Kiesraad, CBS 37278) or US presidential (MIT Election Lab). */
export const statsElecElection = statsSchema.table('elec_election', {
/** NL-TK-2025, US-PRES-2024, … */
electionId: text('election_id').primaryKey(),
/** ISO 3166-1 alpha-3. */
country: char('country', { length: 3 }).notNull(),
/** TK (Tweede Kamer), PRES (US president). */
kind: text('kind').notNull(),
date: date('date', { mode: 'string' }).notNull(),
name: text('name').notNull(),
sourceId: text('source_id').notNull(),
totalSeats: integer('total_seats')
});
export const statsElecResult = statsSchema.table(
'elec_result',
{
electionId: text('election_id').notNull(),
/** national, province (kieskring not used), muni, station, state. */
level: text('level').notNull(),
/** NL / GM0513 / GM0513-SB101 / TX … */
areaCode: text('area_code').notNull(),
areaName: text('area_name'),
/** List name as the source spells it (normalised to a short label on the page). */
party: text('party').notNull(),
votes: bigint('votes', { mode: 'number' }),
seats: integer('seats')
},
(t) => [
primaryKey({ columns: [t.electionId, t.level, t.areaCode, t.party] }),
index('elec_result_area_idx').on(t.level, t.areaCode)
]
);
export const statsElecTurnout = statsSchema.table(
'elec_turnout',
{
electionId: text('election_id').notNull(),
level: text('level').notNull(),
areaCode: text('area_code').notNull(),
areaName: text('area_name'),
/** Persons entitled to vote (kiesgerechtigden). */
electorate: bigint('electorate', { mode: 'number' }),
/** Ballots cast / counted (opkomst). */
ballots: bigint('ballots', { mode: 'number' }),
valid: bigint('valid', { mode: 'number' }),
blank: bigint('blank', { mode: 'number' }),
invalid: bigint('invalid', { mode: 'number' })
},
(t) => [primaryKey({ columns: [t.electionId, t.level, t.areaCode] })]
);
// ── Step 3: world opinions (§4, A15) ─────────────────────────────────────────────────────
// The /projects/world-opinions guide reads these. Curated rows (seeded from the former
// src/lib/world-opinions/data.ts) keep Stan's text exactly; collectors add CHES / V-Party data.
export const statsWoCountry = statsSchema.table('wo_country', {
iso3: char('iso3', { length: 3 }).primaryKey(),
/** ISO 3166-1 alpha-2, lower case (the guide's own id). */
id: text('id').notNull().unique(),
/** ISO 3166-1 numeric as a zero-padded string (world-atlas shape id). */
isoNum: text('iso_num').notNull(),
name: text('name').notNull(),
/** One of the 11 RegionId keys in src/lib/world-opinions/data.ts. */
region: text('region').notNull(),
/** left | market | western */
liberalMeaning: text('liberal_meaning'),
/** economy | culture | history | none */
mainDivide: text('main_divide'),
note: text('note'),
/** Country profile {center, fights[], timeline[{year,label}], blindSpots[]}, or null. */
profile: jsonb('profile'),
sort: integer('sort').notNull()
});
/** Inglehart–Welzel map positions; the guide's hand-placed values are wave 'curated'. */
export const statsWoCulturalMap = statsSchema.table(
'wo_cultural_map',
{
iso3: char('iso3', { length: 3 }).notNull(),
wave: text('wave').notNull(),
yearFrom: integer('year_from'),
yearTo: integer('year_to'),
/** y axis: traditional (-) ↔ secular-rational (+). */
tradSecular: doublePrecision('trad_secular'),
/** x axis: survival (-) ↔ self-expression (+). */
survSelfexp: doublePrecision('surv_selfexp'),
n: integer('n'),
source: text('source')
},
(t) => [primaryKey({ columns: [t.iso3, t.wave] })]
);
/** WVS/EVS/Pew item aggregates (Tier C manual loads; empty until then). */
export const statsWoValueScore = statsSchema.table(
'wo_value_score',
{
iso3: char('iso3', { length: 3 }).notNull(),
wave: text('wave').notNull(),
itemCode: text('item_code').notNull(),
itemLabel: text('item_label'),
stat: text('stat').notNull(),
value: doublePrecision('value'),
n: integer('n')
},
(t) => [primaryKey({ columns: [t.iso3, t.wave, t.itemCode, t.stat] })]
);
export const statsWoNarrative = statsSchema.table('wo_narrative', {
sort: integer('sort').primaryKey(),
event: text('event').notNull(),
years: text('years').notNull(),
/** [{country (alpha-2), story}] in display order. */
views: jsonb('views').notNull()
});
export const statsWoSource = statsSchema.table('wo_source', {
sort: integer('sort').primaryKey(),
name: text('name').notNull(),
href: text('href').notNull()
});
/**
* Parties. Curated guide parties: party_id `wo:<cc>:<short>`; data-driven parties:
* `pf:<partyfacts_id>` (or `ches:<id>` / `vp:<id>` when Party Facts has no link).
*/
export const statsWoParty = statsSchema.table(
'wo_party',
{
partyId: text('party_id').primaryKey(),
iso3: char('iso3', { length: 3 }),
name: text('name'),
abbrev: text('abbrev'),
family: text('family'),
chesId: integer('ches_id'),
manifestoId: integer('manifesto_id'),
vpartyId: integer('vparty_id'),
partyfactsId: integer('partyfacts_id'),
/** Display order of curated parties (null for data-driven rows). */
sort: integer('sort')
},
(t) => [index('wo_party_iso3_idx').on(t.iso3)]
);
export const statsWoPartyPosition = statsSchema.table(
'wo_party_position',
{
partyId: text('party_id').notNull(),
year: integer('year').notNull(),
/** ches, vparty */
source: text('source').notNull(),
/** lrgen, lrecon, galtan, eu_position, v2xpa_popul, v2pariglef, … */
dim: text('dim').notNull(),
value: doublePrecision('value'),
voteShare: doublePrecision('vote_share')
},
(t) => [primaryKey({ columns: [t.partyId, t.year, t.source, t.dim] })]
);
/** Stan's curated shared-compass placements (x = economic 0–10, y = social 0–10). */
export const statsWoCompassOverride = statsSchema.table('wo_compass_override', {
partyId: text('party_id').primaryKey(),
x: doublePrecision('x').notNull(),
y: doublePrecision('y').notNull(),
note: text('note')
});
// ── Step 3: opinion feeds (A16) ──────────────────────────────────────────────────────────
/** Raw Wikipedia wikitext of the poll pages, one row per revision seen. */
export const statsOpWikiSnapshot = statsSchema.table(
'op_wiki_snapshot',
{
page: text('page').notNull(),
revid: bigint('revid', { mode: 'number' }).notNull(),
fetchedAt: timestamptz('fetched_at').notNull().defaultNow(),
wikitext: text('wikitext').notNull()
},
(t) => [primaryKey({ columns: [t.page, t.revid] })]
);
/** Parsed poll rows (long format): one value per poll × option. */
export const statsOpPoll = statsSchema.table(
'op_poll',
{
/** nl-tk (seats), us-approval, us-approval-avg, us-generic, kr-approval, kr-party. */
series: text('series').notNull(),
/** `${fieldwork_end}|${pollster}|${n}` (n = order within that pollster and date). */
pollKey: text('poll_key').notNull(),
fieldworkEnd: date('fieldwork_end', { mode: 'string' }).notNull(),
pollster: text('pollster').notNull(),
sample: integer('sample'),
option: text('option').notNull(),
value: doublePrecision('value'),
seenAt: timestamptz('seen_at').notNull().defaultNow()
},
(t) => [
primaryKey({ columns: [t.series, t.pollKey, t.option] }),
index('op_poll_series_date_idx').on(t.series, t.fieldworkEnd)
]
);
/** Standard Eurobarometer volume A: most-important-issue and trust questions per country. */
export const statsEbItem = statsSchema.table(
'eb_item',
{
/** 104.1 etc. */
wave: text('wave').notNull(),
fieldworkEnd: date('fieldwork_end', { mode: 'string' }),
/** mii_country, mii_personal, mii_eu, trust */
topic: text('topic').notNull(),
/** Sheet name in the wave's file (QA3, QA6_9, …). */
qcode: text('qcode').notNull(),
/** Answer (issue) for MII; institution for trust. */
item: text('item').notNull(),
/** Eurobarometer country code (EU27, BE, EL, …). */
country: text('country').notNull(),
/** Share of respondents, 0–1. */
share: doublePrecision('share')
},
(t) => [primaryKey({ columns: [t.wave, t.topic, t.item, t.country] })]
);
/** World Happiness Report: life evaluation (3-year average) and its explained parts. */
export const statsWhrScore = statsSchema.table(
'whr_score',
{
year: integer('year').notNull(),
country: text('country').notNull(),
iso3: char('iso3', { length: 3 }),
rank: integer('rank'),
ladder: doublePrecision('ladder'),
lower: doublePrecision('lower'),
upper: doublePrecision('upper'),
gdp: doublePrecision('gdp'),
social: doublePrecision('social'),
health: doublePrecision('health'),
freedom: doublePrecision('freedom'),
generosity: doublePrecision('generosity'),
corruption: doublePrecision('corruption'),
dystopia: doublePrecision('dystopia')
},
(t) => [primaryKey({ columns: [t.year, t.country] })]
);
/** Google Trends daily trending searches (RSS snapshot). */
export const statsGtTrending = statsSchema.table(
'gt_trending',
{
geo: text('geo').notNull(),
date: date('date', { mode: 'string' }).notNull(),
rank: integer('rank').notNull(),
query: text('query').notNull(),
approxTraffic: integer('approx_traffic'),
pubDate: timestamptz('pub_date'),
/** First related news headline + source, for context. */
news: text('news')
},
(t) => [primaryKey({ columns: [t.geo, t.date, t.query] })]
);
/** GDELT DOC timelinevol: share of monitored news coverage per theme and source country. */
export const statsGdeltThemeVol = statsSchema.table(
'gdelt_theme_vol',
{
date: date('date', { mode: 'string' }).notNull(),
/** ISO 3166-1 alpha-3 of the source country. */
country: char('country', { length: 3 }).notNull(),
theme: text('theme').notNull(),
/** Percentage of all monitored articles (daily mean of GDELT's volume intensity). */
volPct: doublePrecision('vol_pct')
},
(t) => [primaryKey({ columns: [t.date, t.country, t.theme] })]
);
// ── Step 4: history and culture per place (§9) ───────────────────────────────────────────
// Places are region codes: NLD, GM0513 (Gouda), CUW, USA, KOR. Every row carries its source link.
/** Curated key events (scripts/stats/history-events.json), each with a source link. */
export const statsHistEvent = statsSchema.table(
'hist_event',
{
place: text('place').notNull(),
/** Stable slug within the place, e.g. 'dutch-revolt'. */
slug: text('slug').notNull(),
year: integer('year').notNull(),
yearEnd: integer('year_end'),
/** Optional exact or partial date label ("1 July 1863"). */
dateLabel: text('date_label'),
title: text('title').notNull(),
summary: text('summary').notNull(),
/** politics | war | economy | society | culture | science */
theme: text('theme').notNull(),
sourceUrl: text('source_url').notNull()
},
(t) => [primaryKey({ columns: [t.place, t.slug] })]
);
/** Curated short culture notes: cuisine and cultural exports. */
export const statsHistFact = statsSchema.table(
'hist_fact',
{
place: text('place').notNull(),
slug: text('slug').notNull(),
/** cuisine | export */
topic: text('topic').notNull(),
title: text('title').notNull(),
body: text('body').notNull(),
sourceUrl: text('source_url').notNull(),
sort: integer('sort').notNull()
},
(t) => [primaryKey({ columns: [t.place, t.slug] })]
);
/** Office holders from Wikidata (P39 with start/end qualifiers). */
export const statsHistOffice = statsSchema.table(
'hist_office',
{
place: text('place').notNull(),
/** head_of_government | head_of_state | mayor | governor */
kind: text('kind').notNull(),
officeQid: text('office_qid').notNull(),
officeLabel: text('office_label').notNull(),
personQid: text('person_qid').notNull(),
personLabel: text('person_label').notNull(),
party: text('party'),
startDate: date('start_date', { mode: 'string' }).notNull(),
endDate: date('end_date', { mode: 'string' })
},
(t) => [primaryKey({ columns: [t.officeQid, t.personQid, t.startDate] })]
);
/** Well-known people per place from Wikidata, ranked by number of Wikipedia sitelinks. */
export const statsHistPerson = statsSchema.table(
'hist_person',
{
place: text('place').notNull(),
personQid: text('person_qid').notNull(),
label: text('label').notNull(),
description: text('description'),
birthYear: integer('birth_year'),
deathYear: integer('death_year'),
sitelinks: integer('sitelinks').notNull(),
/** Wikipedia article (en, else nl) or the Wikidata page. */
url: text('url').notNull(),
rank: integer('rank').notNull()
},
(t) => [primaryKey({ columns: [t.place, t.personQid] })]
);
/** Public holidays from the Nager.Date API. */
export const statsHistHoliday = statsSchema.table(
'hist_holiday',
{
place: text('place').notNull(),
date: date('date', { mode: 'string' }).notNull(),
name: text('name').notNull(),
localName: text('local_name'),
/** Nationwide (false = regional only). */
global: boolean('global').notNull(),
types: jsonb('types')
},
(t) => [primaryKey({ columns: [t.place, t.date, t.name] })]
);
/** UNESCO World Heritage sites (UNESCO WHC open data, dataset whc001). */
export const statsUnescoSite = statsSchema.table('unesco_site', {
idNo: integer('id_no').primaryKey(),
name: text('name').notNull(),
/** Region codes of the places this site lies in (NLD, USA, KOR, CUW). */
places: jsonb('places').notNull(),
category: text('category').notNull(),
yearInscribed: integer('year_inscribed'),
danger: boolean('danger').notNull(),
description: text('description'),
lat: doublePrecision('lat'),
lon: doublePrecision('lon'),
imageUrl: text('image_url')
});