import 'server-only'; import { api, safe } from '@/lib/api'; import { apiCompare } from '@/lib/api-compare'; import { apiExplore } from '@/lib/api-explore'; import { formatValue, grouped } from '@/lib/format'; import type { Story, StoryBlock, TextParagraph, VarSpec } from '@/lib/stories'; import type { CountrySummary, FormatSpec, IndicatorCard, MapResponse, RankingResponse, Series } from '@/lib/types'; import type { TrendResponse } from '@/lib/types-explore'; /** * Fetches every payload a story needs (in parallel, deduplicated by request key) and resolves the text * variables from those payloads. All fetches are tolerant (`safe`): a missing payload makes the block render * an "unavailable" state and drops the sentences that depend on it. */ export interface StoryData { countries: CountrySummary[]; trends: Map; // `${indicator}|${group}` maps: Map; // `${indicator}|${year}` ('' = latest) rankings: Map; // `${indicator}|${year}|${sort}|${top}` series: Map; // `${indicator}|${countries}|${from}` } const trendKey = (ind: string, group: string) => `${ind}|${group}`; const mapKey = (ind: string, year: number | undefined) => `${ind}|${year ?? ''}`; const rankKey = (ind: string, year: number | undefined, sort: string | undefined, top: number) => `${ind}|${year ?? ''}|${sort ?? ''}|${top}`; const seriesKey = (ind: string, countries: string[], from: number | undefined) => `${ind}|${countries.join(',')}|${from ?? ''}`; export async function loadStoryData(story: Story): Promise { const trendKeys = new Set(); const mapKeys = new Set(); const rankKeys = new Set(); const seriesKeys = new Set(); const visitVar = (v: VarSpec) => { if (v.type === 'trend') trendKeys.add(trendKey(v.indicator, v.group ?? 'world')); else if (v.type === 'mapCount') mapKeys.add(mapKey(v.indicator, v.year)); else if (v.type === 'rankTop') rankKeys.add(rankKey(v.indicator, v.year, v.sort, Math.max(v.pos ?? 1, 3))); else if (v.type === 'country') seriesKeys.add(seriesKey(v.indicator, [v.country], undefined)); else if (v.type === 'share') { trendKeys.add(trendKey(v.indicator, v.group)); trendKeys.add(trendKey(v.indicator, 'world')); } }; for (const b of story.blocks) { if (b.kind === 'text') for (const p of b.paragraphs) for (const v of Object.values(p.vars)) visitVar(v); else if (b.kind === 'map') for (const y of b.years) mapKeys.add(mapKey(b.indicator, y)); else if (b.kind === 'trend') for (const g of b.groups ?? ['world']) trendKeys.add(trendKey(b.indicator, g)); else if (b.kind === 'lines') seriesKeys.add(seriesKey(b.indicator, b.countries, b.from)); else if (b.kind === 'ranked') rankKeys.add(rankKey(b.indicator, b.year, b.sort, b.top)); else if (b.kind === 'shares') { for (const g of b.groups) trendKeys.add(trendKey(b.indicator, g)); trendKeys.add(trendKey(b.indicator, 'world')); } } const [countriesRes, trendVals, mapVals, rankVals, seriesVals] = await Promise.all([ safe(api.countries()), Promise.all([...trendKeys].map((k) => { const [ind, group] = k.split('|'); // min_n 3: small groups (North America has three members) would otherwise return no points. return safe(apiExplore.indicatorTrend(ind!, group!, { min_n: 3 })); })), Promise.all([...mapKeys].map((k) => { const [ind, year] = k.split('|'); return safe(api.indicatorMap(ind!, year ? { year: Number(year) } : {})); })), Promise.all([...rankKeys].map((k) => { const [ind, year, sort, top] = k.split('|'); return safe(apiCompare.ranking(ind!, { year: year ? Number(year) : null, sort: (sort || null) as 'asc' | 'desc' | null, limit: Number(top), sparkline: false })); })), Promise.all([...seriesKeys].map((k) => { const [ind, cs, from] = k.split('|'); return safe(apiExplore.seriesBundle(cs!.split(','), [ind!], from ? { from: Number(from), include_forecast: false } : { include_forecast: false })); })), ]); return { countries: countriesRes?.items ?? [], trends: new Map([...trendKeys].map((k, i) => [k, trendVals[i] ?? null])), maps: new Map([...mapKeys].map((k, i) => [k, mapVals[i] ?? null])), rankings: new Map([...rankKeys].map((k, i) => [k, rankVals[i] ?? null])), series: new Map([...seriesKeys].map((k, i) => [k, seriesVals[i]?.series ?? null])), }; } export const getTrend = (d: StoryData, ind: string, group = 'world') => d.trends.get(trendKey(ind, group)) ?? null; export const getMap = (d: StoryData, ind: string, year?: number) => d.maps.get(mapKey(ind, year)) ?? null; export const getRanking = (d: StoryData, b: Extract) => d.rankings.get(rankKey(b.indicator, b.year, b.sort, b.top)) ?? null; export const getSeries = (d: StoryData, b: Extract) => d.series.get(seriesKey(b.indicator, b.countries, b.from)) ?? null; type TrendStat = 'preferred' | 'median' | 'mean' | 'weighted_mean' | 'sum'; function trendValue(tr: TrendResponse, year: 'first' | 'last' | number, stat: TrendStat = 'preferred'): { year: number; value: number | null; n: number } | null { const pts = tr.points; if (!pts.length) return null; const p = year === 'first' ? pts[0]! : year === 'last' ? pts[pts.length - 1]! : pts.find((q) => q.year === year); if (!p) return null; const key = stat === 'preferred' || !stat ? (tr.preferred as 'median' | 'mean' | 'weighted_mean' | 'sum') : stat; const v = (p as unknown as Record)[key] ?? p.median; return { year: p.year, value: v ?? null, n: p.n }; } /** Resolve one variable to a display string, or null when it cannot be computed from the loaded data. */ export function resolveVar(v: VarSpec, d: StoryData): string | null { if (v.type === 'trend') { const tr = getTrend(d, v.indicator, v.group ?? 'world'); if (!tr) return null; const r = trendValue(tr, v.year, v.stat); if (!r || r.value == null) return null; if (v.format === 'year') return String(r.year); if (v.format === 'n') return grouped(r.n); return formatValue(r.value, tr.indicator); } if (v.type === 'mapCount') { const m = getMap(d, v.indicator, v.year); if (!m || m.n === 0) return null; const vals = Object.values(m.values).filter((x): x is number => typeof x === 'number'); const n = vals.filter((x) => (v.op === 'gte' ? x >= v.threshold : v.op === 'gt' ? x > v.threshold : v.op === 'lte' ? x <= v.threshold : x < v.threshold)).length; if (v.format === 'total') return grouped(vals.length); if (v.format === 'year') return String(m.year_used ?? v.year ?? ''); if (v.format === 'pct') return `${Math.round((n / vals.length) * 100)} %`; return grouped(n); } if (v.type === 'rankTop') { const r = d.rankings.get(rankKey(v.indicator, v.year, v.sort, Math.max(v.pos ?? 1, 3))); const row = r?.rows[(v.pos ?? 1) - 1]; if (!r || !row) return null; if (v.format === 'name') return row.country.name ?? row.country.id; if (v.format === 'year') return String(row.year ?? r.year_used ?? ''); return formatValue(row.value, r.indicator); } if (v.type === 'country') { const s = d.series.get(seriesKey(v.indicator, [v.country], undefined))?.[0]; if (!s) return null; const vals = s.values.filter((x) => x.value != null && !x.is_forecast); const p = v.year === 'first' ? vals[0] : v.year === 'last' ? vals[vals.length - 1] : vals.find((x) => x.year === v.year); if (!p) return null; if (v.format === 'year') return String(p.year ?? ''); if (v.format === 'name') return s.country.name ?? s.country.id; return formatValue(p.value, s.indicator); } if (v.type === 'share') { const g = getTrend(d, v.indicator, v.group); const w = getTrend(d, v.indicator, 'world'); if (!g || !w) return null; const gv = trendValue(g, v.year, 'sum'); if (!gv || gv.value == null) return null; const wv = trendValue(w, gv.year, 'sum'); if (!wv || !wv.value) return null; if (v.format === 'year') return String(gv.year); return `${((gv.value / wv.value) * 100).toFixed(1)} %`; } return null; } /** Fill a paragraph template; null when any variable is missing (the sentence is then omitted). */ export function resolveParagraph(p: TextParagraph, d: StoryData): string | null { let out = p.template; for (const [k, spec] of Object.entries(p.vars)) { const val = resolveVar(spec, d); if (val == null) return null; out = out.split(`{${k}}`).join(val); } return out; } export function specOf(ind: IndicatorCard): FormatSpec { return { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: ind.frequency, name: ind.short_name ?? ind.name, higher_is_better: ind.higher_is_better }; }