stats: /stats overview, economy, energy and Gouda pages; CBS series map; nav, homepage, /start and sitemap links
All checks were successful
Deploy production / deploy (push) Successful in 1m10s

- CBS_SERIES: CPI, household tariffs (2007-now), energy balance, renewables, heat pumps,
  solar, cars, population (monthly, since 1942, components), house prices, housing stock,
  municipal levies for Gouda and its neighbours
- shared StatChart/Kpi/chart helpers with per-chart source attribution
- drop Eurostat nrg_pc_20x_h (404; CBS covers that history)
This commit is contained in:
2026-10-25 20:50:47 +00:00
parent 6dcbcfee25
commit 76bfb3dcec
24 changed files with 3386 additions and 9 deletions

View File

@@ -9,6 +9,9 @@
<script lang="ts">
import BookOpen from 'lucide-svelte/icons/book-open';
import ChartLine from 'lucide-svelte/icons/chart-line';
import Flame from 'lucide-svelte/icons/flame';
import Landmark from 'lucide-svelte/icons/landmark';
import CandlestickChart from 'lucide-svelte/icons/candlestick-chart';
import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined';
import FolderKanban from 'lucide-svelte/icons/folder-kanban';
@@ -38,7 +41,11 @@
'house tracker': House,
lists: ListChecks,
health: HeartPulse,
books: BookOpen
books: BookOpen,
stats: ChartLine,
economy: Landmark,
energy: Flame,
gouda: House
};
const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban);

View File

@@ -8,7 +8,7 @@
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import { projects, visibleTrackers } from '#lib/projects.js';
import { projects, visibleTrackers, statsPages } from '#lib/projects.js';
import { Button, buttonVariants } from '#lib/components/ui/button/index.js';
import * as Sheet from '#lib/components/ui/sheet/index.js';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
@@ -51,6 +51,7 @@
{ label: 'home', href: '/' },
{ label: 'projects', href: '/projects' },
{ label: 'trackers', href: '/trackers' },
{ label: 'stats', href: '/stats' },
{ label: 'blog', href: '/blog' },
{ label: 'lists', href: '/lists' }
] as const;
@@ -65,7 +66,8 @@
};
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({
'/projects': { label: 'projects', items: projects },
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner, data.inactiveGames) }
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner, data.inactiveGames) },
'/stats': { label: 'stats', items: statsPages }
});
const socialLinks = [
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
@@ -274,7 +276,13 @@
aria-label="Stan Runge home"
>
<span class="h-7 w-7 overflow-hidden rounded-full bg-black"
><img src={logo} alt="" width="112" height="112" class="h-full w-full object-cover" /></span
><img
src={logo}
alt=""
width="112"
height="112"
class="h-full w-full object-cover"
/></span
>
<span>stan runge</span>
</a>

View File

@@ -32,6 +32,28 @@ export const projects = [
}
] as const;
// The public /stats section: open data on the economy, energy and Gouda (src/routes/stats).
export const statsPages = [
{
name: 'economy',
subtitle: 'rates, inflation, bond spreads, GDP and debt',
href: '/stats/economy',
external: false
},
{
name: 'energy',
subtitle: 'tariffs, oil and gas, renewables, heat pumps and EVs',
href: '/stats/energy',
external: false
},
{
name: 'gouda',
subtitle: 'population, housing and neighbourhoods in Gouda',
href: '/stats/gouda',
external: false
}
] as const;
type Tracker = {
name: string;
subtitle: string;

183
src/lib/server/stats.ts Normal file
View File

@@ -0,0 +1,183 @@
// Read side of the public /stats section: normalised series from stats.series/obs, source
// attributions and data freshness from stats.fetch_run. Everything here is open data.
import { client } from '#lib/server/db/index.js';
// Data changes at most hourly; keep page loads cheap with a short in-process cache.
const TTL_MS = 10 * 60 * 1000;
const memo = new Map<string, { at: number; value: Promise<unknown> }>();
export function cached<T>(key: string, fn: () => Promise<T>): Promise<T> {
const hit = memo.get(key);
if (hit && Date.now() - hit.at < TTL_MS) return hit.value as Promise<T>;
const value = fn().catch((error) => {
memo.delete(key);
throw error;
});
memo.set(key, { at: Date.now(), value });
return value;
}
export type StatPoint = [period: string, value: number];
export type StatSeries = {
id: string;
title: string;
unit: string;
freq: string;
region: string | null;
sourceId: string;
points: StatPoint[];
};
export type SeriesRequest = {
id: string;
/** Only observations on or after this date (YYYY-MM-DD). */
from?: string;
/** Average daily data per month or week to keep payloads small. */
resample?: 'M' | 'W';
};
/** Loads the requested series (missing ids are simply absent from the result). */
export async function loadSeries(
requests: (string | SeriesRequest)[]
): Promise<Record<string, StatSeries>> {
const reqs = requests.map((r) => (typeof r === 'string' ? { id: r } : r));
if (!reqs.length) return {};
const meta = await client<
{
series_id: string;
title: string;
unit: string;
freq: string;
region_code: string | null;
source_id: string;
}[]
>`select series_id, title, unit, freq, region_code, source_id from stats.series
where series_id = any(${reqs.map((r) => r.id)})`;
const out: Record<string, StatSeries> = {};
await Promise.all(
meta.map(async (m) => {
const req = reqs.find((r) => r.id === m.series_id)!;
const from = req.from ?? '0001-01-01';
const rows = req.resample
? await client<{ period: string; value: number }[]>`
select to_char(date_trunc(${req.resample === 'M' ? 'month' : 'week'}, period), 'YYYY-MM-DD') as period,
avg(value)::float8 as value
from stats.obs where series_id = ${m.series_id} and period >= ${from} and value is not null
group by 1 order by 1`
: await client<{ period: string; value: number }[]>`
select to_char(period, 'YYYY-MM-DD') as period, value from stats.obs
where series_id = ${m.series_id} and period >= ${from} and value is not null order by period`;
out[m.series_id] = {
id: m.series_id,
title: m.title,
unit: m.unit,
freq: m.freq,
region: m.region_code,
sourceId: m.source_id,
points: rows.map((r) => [r.period, Math.round(r.value * 1e6) / 1e6])
};
})
);
return out;
}
export type StatSource = {
id: string;
name: string;
attribution: string;
licence: string;
baseUrl: string;
};
export async function loadSources(): Promise<Record<string, StatSource>> {
const rows = await client<
{ source_id: string; name: string; attribution: string; licence: string; base_url: string }[]
>`
select source_id, name, attribution, licence, base_url from stats.source order by source_id`;
return Object.fromEntries(
rows.map((r) => [
r.source_id,
{
id: r.source_id,
name: r.name,
attribution: r.attribution,
licence: r.licence,
baseUrl: r.base_url
}
])
);
}
export type Freshness = {
sourceId: string;
name: string;
lastOk: string | null;
lastRun: string | null;
lastRunOk: boolean | null;
rows: number | null;
series: number;
observations: number;
latestPeriod: string | null;
};
export async function loadFreshness(): Promise<Freshness[]> {
const rows = await client<
{
source_id: string;
name: string;
last_ok: Date | string | null;
last_run: Date | string | null;
last_run_ok: boolean | null;
rows: number | null;
series: number;
observations: number;
latest_period: string | null;
}[]
>`
select s.source_id, s.name,
(select max(finished_at) from stats.fetch_run r where r.source_id = s.source_id and r.ok) as last_ok,
l.started_at as last_run, l.ok as last_run_ok, l.rows,
coalesce(c.series, 0)::int as series, coalesce(c.observations, 0)::int as observations,
to_char(c.latest, 'YYYY-MM-DD') as latest_period
from stats.source s
left join lateral (select started_at, ok, rows from stats.fetch_run r
where r.source_id = s.source_id order by started_at desc limit 1) l on true
left join lateral (select count(distinct se.series_id) as series, count(o.*) as observations, max(o.period) as latest
from stats.series se left join stats.obs o on o.series_id = se.series_id
where se.source_id = s.source_id) c on true
order by s.source_id`;
return rows.map((r) => ({
sourceId: r.source_id,
name: r.name,
lastOk: r.last_ok ? new Date(r.last_ok).toISOString() : null,
lastRun: r.last_run ? new Date(r.last_run).toISOString() : null,
lastRunOk: r.last_run_ok,
rows: r.rows,
series: r.series,
observations: r.observations,
latestPeriod: r.latest_period
}));
}
/** Rows of a raw CBS table for one measure, grouped by a dimension (e.g. kwb per buurt). */
export async function loadCbsCross(
table: string,
measures: string[],
dim: string,
codePrefix: string[]
): Promise<{ code: string; title: string; values: Record<string, number | null> }[]> {
const rows = await client<
{ code: string; title: string; measure: string; value: number | null }[]
>`
select o.dims->>${dim} as code, coalesce(c.title, o.dims->>${dim}) as title, o.measure, o.value
from stats.cbs_obs o
left join stats.cbs_code c on c.table_id = o.table_id and c.dimension = ${dim} and c.code = o.dims->>${dim}
where o.table_id = ${table} and o.measure = any(${measures})
and left(o.dims->>${dim}, 6) = any(${codePrefix})`;
const byCode = new Map<
string,
{ code: string; title: string; values: Record<string, number | null> }
>();
for (const r of rows) {
const entry = byCode.get(r.code) ?? { code: r.code, title: r.title, values: {} };
entry.values[r.measure] = r.value;
byCode.set(r.code, entry);
}
return [...byCode.values()].sort((a, b) => a.code.localeCompare(b.code));
}

50
src/lib/stats/Kpi.svelte Normal file
View File

@@ -0,0 +1,50 @@
<script lang="ts">
// One overview tile: coloured dot, label, big value, and a sub line (period / change).
let {
label,
value,
unit = '',
sub = '',
color = '#3b82f6',
delta = null,
deltaUnit = '',
invert = false,
href = ''
}: {
label: string;
value: string;
unit?: string;
sub?: string;
color?: string;
delta?: number | null;
deltaUnit?: string;
/** Treat a rise as bad (red), e.g. prices. */
invert?: boolean;
href?: string;
} = $props();
const good = $derived(delta == null ? null : invert ? delta < 0 : delta > 0);
</script>
<svelte:element this={href ? 'a' : 'div'} class="kpi" href={href || undefined}>
<span class="kpi-label"><span class="dot" style:background={color}></span>{label}</span>
<span class="kpi-value">{value}<small>{unit}</small></span>
<span class="kpi-sub"
>{#if delta != null && Number.isFinite(delta)}<span class={good ? 'up' : 'down'}
>{delta > 0 ? '▲' : delta < 0 ? '▼' : '•'}
{Math.abs(delta).toLocaleString('en-GB', { maximumFractionDigits: 2 })}{deltaUnit}</span
>
{/if}{sub}</span
>
</svelte:element>
<style>
small {
font-size: 0.8rem;
font-weight: 500;
color: var(--site-muted);
margin-left: 0.2rem;
}
a.kpi:hover {
border-color: var(--site-text);
}
</style>

View File

@@ -0,0 +1,88 @@
<script lang="ts">
// One /stats chart panel: title, optional subtitle, the chart, and an attribution footer
// built from stats.source.attribution for every source the chart uses.
import EnergyChart, { type ChartTheme } from '#lib/energy/EnergyChart.svelte';
import type { EChartsCoreOption } from 'echarts/core';
import type { Snippet } from 'svelte';
let {
title,
subtitle = '',
option,
sources,
attributions,
height = '320px',
note = '',
wide = false,
children
}: {
title: string;
subtitle?: string;
option: (theme: ChartTheme) => EChartsCoreOption;
/** Source ids (cbs, eurostat, fred, bis, …). */
sources: string[];
/** source id → attribution line (layout data). */
attributions: Record<string, string>;
height?: string;
note?: string;
wide?: boolean;
children?: Snippet;
} = $props();
const footer = $derived(
[...new Set(sources)]
.map((s) => attributions[s])
.filter(Boolean)
.join(' · ')
);
</script>
<figure class="stat-panel" class:wide>
<header>
<h3>{title}</h3>
{#if subtitle}<span>{subtitle}</span>{/if}
</header>
{@render children?.()}
<EnergyChart {option} label={title} {height} />
{#if note}<p class="note">{note}</p>{/if}
<figcaption>{footer}</figcaption>
</figure>
<style>
.stat-panel {
margin: 0;
min-width: 0;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-bg);
padding: 1rem 1rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.wide {
grid-column: 1 / -1;
}
header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.25rem 1rem;
}
h3 {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.03em;
}
header span,
.note {
color: var(--site-muted);
font-size: 0.8rem;
}
figcaption {
color: var(--site-muted);
font-size: 0.7rem;
border-top: 1px solid var(--site-border);
padding-top: 0.5rem;
}
</style>

230
src/lib/stats/chart.ts Normal file
View File

@@ -0,0 +1,230 @@
// ECharts option builders for /stats: black-and-white chrome from the site theme, colourful
// series. Used through StatChart.svelte (which renders with the shared lazy ECharts wrapper).
// Same shape as ChartTheme in #lib/energy/EnergyChart.svelte (svelte module types can't be
// imported from plain .ts under tsgo).
export type ChartTheme = {
text: string;
muted: string;
border: string;
surface: string;
dark: boolean;
};
import type { EChartsCoreOption } from 'echarts/core';
export type StatPoint = [string, number];
export type Line = {
name: string;
points: StatPoint[];
color?: string;
/** 'line' (default), 'bar', 'area' or 'step'. */
kind?: 'line' | 'bar' | 'area' | 'step';
dashed?: boolean;
/** Plot on the right-hand axis. */
right?: boolean;
stack?: string;
};
export const PALETTE = [
'#3b82f6', // blue
'#f97316', // orange
'#10b981', // emerald
'#e11d48', // rose
'#a855f7', // purple
'#eab308', // yellow
'#06b6d4', // cyan
'#84cc16', // lime
'#ec4899', // pink
'#64748b' // slate
];
export type AxisOptions = {
unit?: string;
rightUnit?: string;
decimals?: number;
/** Category x axis (e.g. years as labels) instead of a time axis. */
category?: boolean;
zoom?: boolean;
legend?: boolean;
min?: number | 'dataMin';
};
const fmt = (decimals: number) =>
new Intl.NumberFormat('en-GB', { maximumFractionDigits: decimals, minimumFractionDigits: 0 });
export function lineOption(
theme: ChartTheme,
lines: Line[],
o: AxisOptions = {}
): EChartsCoreOption {
const number = fmt(o.decimals ?? 2);
const axisLabel = { color: theme.muted, fontSize: 11 };
const splitLine = { lineStyle: { color: theme.border, opacity: 0.6 } };
const yAxis = (unit: string | undefined, right = false) => ({
type: 'value',
position: right ? 'right' : 'left',
scale: o.min === 'dataMin',
min: typeof o.min === 'number' ? o.min : undefined,
axisLabel: { ...axisLabel, formatter: (v: number) => fmt(2).format(v) },
splitLine: right ? { show: false } : splitLine,
name: unit,
nameTextStyle: { color: theme.muted, fontSize: 11, align: right ? 'right' : 'left' }
});
const hasRight = lines.some((l) => l.right);
return {
animation: false,
grid: {
left: 8,
right: hasRight ? 8 : 16,
top: o.legend === false ? 28 : 56,
bottom: o.zoom ? 56 : 24,
containLabel: true
},
legend:
o.legend === false || lines.length < 2
? undefined
: {
top: 0,
left: 0,
type: 'scroll',
textStyle: { color: theme.muted, fontSize: 11 },
icon: 'roundRect',
itemWidth: 10,
itemHeight: 10
},
tooltip: {
trigger: 'axis',
backgroundColor: theme.surface,
borderColor: theme.border,
textStyle: { color: theme.text, fontSize: 12 },
valueFormatter: (v: number) => (v == null ? '–' : number.format(v))
},
xAxis: o.category
? { type: 'category', axisLabel, axisLine: { lineStyle: { color: theme.border } } }
: {
type: 'time',
axisLabel,
axisLine: { lineStyle: { color: theme.border } },
splitLine: { show: false }
},
yAxis: hasRight ? [yAxis(o.unit), yAxis(o.rightUnit, true)] : yAxis(o.unit),
dataZoom: o.zoom
? [
{ type: 'inside', throttle: 50 },
{
type: 'slider',
height: 18,
bottom: 8,
borderColor: theme.border,
textStyle: { color: theme.muted },
fillerColor: 'rgba(127,127,127,0.15)'
}
]
: undefined,
series: lines.map((l, i) => {
const color = l.color ?? PALETTE[i % PALETTE.length];
const base = {
name: l.name,
data: l.points,
yAxisIndex: l.right ? 1 : 0,
itemStyle: { color },
emphasis: { focus: 'series' },
stack: l.stack
};
if (l.kind === 'bar') return { ...base, type: 'bar', barMaxWidth: 18 };
return {
...base,
type: 'line',
step: l.kind === 'step' ? 'end' : undefined,
showSymbol: l.points.length < 40,
symbolSize: 5,
sampling: 'lttb',
lineStyle: { width: 2, color, type: l.dashed ? 'dashed' : 'solid' },
areaStyle: l.kind === 'area' ? { opacity: 0.18, color } : undefined
};
})
};
}
/** Horizontal bar ranking (e.g. countries or neighbourhoods). */
export function barRankOption(
theme: ChartTheme,
rows: { label: string; value: number; highlight?: boolean }[],
o: { decimals?: number; unit?: string; color?: string } = {}
): EChartsCoreOption {
const number = fmt(o.decimals ?? 1);
const sorted = [...rows].sort((a, b) => a.value - b.value);
return {
animation: false,
grid: { left: 8, right: 56, top: 8, bottom: 8, containLabel: true },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: theme.surface,
borderColor: theme.border,
textStyle: { color: theme.text, fontSize: 12 },
valueFormatter: (v: number) => `${number.format(v)}${o.unit ? ` ${o.unit}` : ''}`
},
xAxis: {
type: 'value',
axisLabel: { color: theme.muted, fontSize: 11 },
splitLine: { lineStyle: { color: theme.border, opacity: 0.6 } }
},
yAxis: {
type: 'category',
data: sorted.map((r) => r.label),
axisLabel: { color: theme.text, fontSize: 11 },
axisLine: { show: false },
axisTick: { show: false }
},
series: [
{
type: 'bar',
barMaxWidth: 16,
data: sorted.map((r) => ({
value: r.value,
itemStyle: {
color: r.highlight ? '#f97316' : (o.color ?? '#3b82f6'),
borderRadius: [0, 3, 3, 0]
}
})),
label: {
show: true,
position: 'right',
color: theme.muted,
fontSize: 11,
formatter: (p: { value: number }) => number.format(p.value)
}
}
]
};
}
/** Last point of a series, or null. */
export const latest = (points: StatPoint[] | undefined) =>
points?.length ? points[points.length - 1] : null;
/** Value a given number of periods back (by index), or null. */
export function change(points: StatPoint[] | undefined, back: number) {
if (!points || points.length <= back) return null;
const now = points[points.length - 1][1];
const then = points[points.length - 1 - back][1];
return { abs: now - then, pct: then ? ((now - then) / Math.abs(then)) * 100 : null };
}
/** Difference of two series on shared dates (a − b). */
export function spread(a: StatPoint[] = [], b: StatPoint[] = []): StatPoint[] {
const map = new Map(b);
return a
.filter(([d]) => map.has(d))
.map(([d, v]) => [d, Math.round((v - map.get(d)!) * 1000) / 1000]);
}
/** Year-over-year % change for monthly (12) or quarterly (4) points. */
export function yoy(points: StatPoint[] = [], lag = 12): StatPoint[] {
const out: StatPoint[] = [];
for (let i = lag; i < points.length; i++) {
const prev = points[i - lag][1];
if (prev) out.push([points[i][0], Math.round(((points[i][1] - prev) / prev) * 10000) / 100]);
}
return out;
}

38
src/lib/stats/format.ts Normal file
View File

@@ -0,0 +1,38 @@
// Number and period formatting for /stats tiles and tables.
const cache = new Map<number, Intl.NumberFormat>();
export function num(v: number | null | undefined, decimals = 1) {
if (v == null || !Number.isFinite(v)) return '–';
let f = cache.get(decimals);
if (!f) {
f = new Intl.NumberFormat('en-GB', {
maximumFractionDigits: decimals,
minimumFractionDigits: decimals
});
cache.set(decimals, f);
}
return f.format(v);
}
export function compact(v: number | null | undefined) {
if (v == null || !Number.isFinite(v)) return '–';
return new Intl.NumberFormat('en-GB', { notation: 'compact', maximumFractionDigits: 1 }).format(
v
);
}
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
/** Period label for a series frequency: 2026 / Q3 2026 / Sep 2026 / 24 Oct 2026. */
export function period(p: string | null | undefined, freq = 'M') {
if (!p) return '–';
const [y, m, d] = p.split('-').map(Number);
if (freq === 'A') return String(y);
if (freq === 'S') return `H${m <= 6 ? 1 : 2} ${y}`;
if (freq === 'Q') return `Q${Math.floor((m - 1) / 3) + 1} ${y}`;
if (freq === 'M') return `${MONTHS[m - 1]} ${y}`;
return `${d} ${MONTHS[m - 1]} ${y}`;
}
export function ago(iso: string | null | undefined) {
if (!iso) return 'never';
const s = (Date.now() - new Date(iso).getTime()) / 1000;
if (s < 3600) return `${Math.max(1, Math.round(s / 60))} min ago`;
if (s < 86400 * 2) return `${Math.round(s / 3600)} h ago`;
return `${Math.round(s / 86400)} days ago`;
}

35
src/lib/stats/regions.ts Normal file
View File

@@ -0,0 +1,35 @@
// Display names for the region codes used by Eurostat (geo), BIS (ref_area) and ISO3.
export const COUNTRY: Record<string, string> = {
NL: 'Netherlands',
DE: 'Germany',
FR: 'France',
BE: 'Belgium',
IT: 'Italy',
ES: 'Spain',
PL: 'Poland',
SE: 'Sweden',
DK: 'Denmark',
AT: 'Austria',
EU27_2020: 'EU 27',
EA20: 'Euro area',
EA: 'Euro area',
US: 'United States',
XM: 'Euro area',
KR: 'South Korea',
CN: 'China',
GB: 'United Kingdom',
JP: 'Japan',
NLD: 'Netherlands',
USA: 'United States',
KOR: 'South Korea',
CHN: 'China',
CUW: 'Curaçao',
EUU: 'European Union',
WLD: 'World',
DEU: 'Germany',
FRA: 'France',
BEL: 'Belgium',
GBR: 'United Kingdom',
JPN: 'Japan',
IND: 'India'
};