stats step 3: elections, party positions and opinion feeds (schema, collectors, timers); world-opinions data moved into stats.wo_*
All checks were successful
Deploy production / deploy (push) Successful in 1m12s

This commit is contained in:
2026-10-25 22:16:25 +00:00
parent 030f3c08ee
commit 42c9351ebd
37 changed files with 75721 additions and 892 deletions

View File

@@ -467,3 +467,280 @@ export const statsCwCbcsBop = statsSchema.table(
},
(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] })]
);