From 4eff46f49633d8391a5ea583f3f1c6da06335c82 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Wed, 8 Jul 2026 14:54:19 -0400 Subject: [PATCH 01/11] Add economic indicators dashboard Add /economic-indicators dashboard showing how Canada compares against G7/OECD peers across growth, productivity, incomes, inequality, and housing, backed by a new /api/economy/series proxy and economy API client. Includes a Canvas view (/economic-indicators/canvas) to overlay and compare up to three indicator series, with indexed and raw scale modes. Nav link for Economy is hidden for now while the dashboard is in progress. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/economy/series/route.ts | 26 ++ .../economic-indicators/IndicatorChart.tsx | 180 +++++++++ .../IndicatorChartClient.tsx | 19 + .../canvas/CanvasClient.tsx | 341 ++++++++++++++++++ .../canvas/OverlayChart.tsx | 225 ++++++++++++ .../canvas/overlay-types.ts | 8 + src/app/economic-indicators/canvas/page.tsx | 53 +++ src/app/economic-indicators/indicators.ts | 294 +++++++++++++++ src/app/economic-indicators/page.tsx | 184 ++++++++++ src/app/sitemap.ts | 2 + src/lib/api/economy.ts | 101 ++++++ 11 files changed, 1433 insertions(+) create mode 100644 src/app/api/economy/series/route.ts create mode 100644 src/app/economic-indicators/IndicatorChart.tsx create mode 100644 src/app/economic-indicators/IndicatorChartClient.tsx create mode 100644 src/app/economic-indicators/canvas/CanvasClient.tsx create mode 100644 src/app/economic-indicators/canvas/OverlayChart.tsx create mode 100644 src/app/economic-indicators/canvas/overlay-types.ts create mode 100644 src/app/economic-indicators/canvas/page.tsx create mode 100644 src/app/economic-indicators/indicators.ts create mode 100644 src/app/economic-indicators/page.tsx create mode 100644 src/lib/api/economy.ts diff --git a/src/app/api/economy/series/route.ts b/src/app/api/economy/series/route.ts new file mode 100644 index 0000000..37d971f --- /dev/null +++ b/src/app/api/economy/series/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getEconomicSeries } from "@/lib/api/economy"; +import { MEASURE_SLUGS } from "@/app/economic-indicators/indicators"; + +// Same-origin proxy for york_factory's public series endpoint so client +// components (the canvas page) can fetch without cross-origin config. The +// upstream fetch is server-cached for an hour by getEconomicSeries. +export async function GET(request: NextRequest) { + const measure = request.nextUrl.searchParams.get("measure"); + + if (!measure || !MEASURE_SLUGS.has(measure)) { + return NextResponse.json({ error: "Unknown measure" }, { status: 400 }); + } + + try { + const response = await getEconomicSeries(measure); + return NextResponse.json(response, { + headers: { "Cache-Control": "public, s-maxage=3600, max-age=600" }, + }); + } catch { + return NextResponse.json( + { error: "Upstream data unavailable" }, + { status: 502 }, + ); + } +} diff --git a/src/app/economic-indicators/IndicatorChart.tsx b/src/app/economic-indicators/IndicatorChart.tsx new file mode 100644 index 0000000..4a609d1 --- /dev/null +++ b/src/app/economic-indicators/IndicatorChart.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Bounds, + createTestDataset, + DimensionProperty, + GRAPHER_CHART_TYPES, + Grapher, + GrapherState, + legacyToChartsTableAndDimensionsWithMandatorySlug, +} from "@buildcanada/charts"; +import { + humanizeSourceName, + type EconomySeriesResponse, + type EconomySeriesUnit, +} from "@/lib/api/economy"; + +function displayUnit(unit: EconomySeriesUnit): { + unit: string; + shortUnit: string; + numDecimalPlaces: number; +} { + switch (unit.symbol) { + case "%": + return { unit: "%", shortUnit: "%", numDecimalPlaces: 1 }; + case "intl_$": + return { unit: "international-$", shortUnit: "$", numDecimalPlaces: 0 }; + case "intl_$_per_hour": + return { + unit: "international-$ per hour worked", + shortUnit: "$", + numDecimalPlaces: 1, + }; + case "index": + return { unit: "index", shortUnit: "", numDecimalPlaces: 1 }; + case "ratio": + return { unit: "ratio", shortUnit: "", numDecimalPlaces: 3 }; + case "units": + return { unit: "dwelling units", shortUnit: "", numDecimalPlaces: 0 }; + case "hours": + return { unit: "hours per year", shortUnit: "", numDecimalPlaces: 0 }; + case "score": + return { unit: "score", shortUnit: "", numDecimalPlaces: 2 }; + case "births_per_woman": + return { unit: "births per woman", shortUnit: "", numDecimalPlaces: 2 }; + case "per_100_people": + return { unit: "per 100 people", shortUnit: "", numDecimalPlaces: 1 }; + case "rate_per_100k": + return { unit: "per 100,000 people", shortUnit: "", numDecimalPlaces: 2 }; + case "t_co2_per_capita": + return { unit: "tonnes of CO₂ per person", shortUnit: "t", numDecimalPlaces: 1 }; + case "ug_m3": + return { unit: "µg/m³", shortUnit: "µg/m³", numDecimalPlaces: 1 }; + case "tonne_km_millions": + return { unit: "million ton-km", shortUnit: "", numDecimalPlaces: 0 }; + default: + return { + unit: unit.base_unit, + shortUnit: unit.symbol, + numDecimalPlaces: 1, + }; + } +} + +function buildGrapherState( + response: EconomySeriesResponse, + bounds: Bounds, +): GrapherState | null { + const { measure, series } = response.data; + const { source } = response.meta; + + const data = series.flatMap((s, idx) => + s.points.map((p) => ({ + year: p.year, + entity: { id: idx + 1, code: s.jurisdiction.code, name: s.jurisdiction.name }, + value: p.value, + })), + ); + if (data.length === 0) return null; + + const variableId = 1; + const dimensions = [{ variableId, property: DimensionProperty.y }]; + + const metadata = { + id: variableId, + display: { + name: measure.name, + ...displayUnit(measure.unit), + }, + origins: source + ? [ + { + id: 1, + title: humanizeSourceName(source.name), + urlMain: source.url ?? undefined, + datePublished: source.last_fetched_at ?? undefined, + }, + ] + : [], + }; + + const grapherState = new GrapherState({ + bounds, + // Fill the available bounds exactly instead of scaling to Grapher's + // ideal 680x480 aspect ratio. + isEmbeddedInPage: true, + chartTypes: [GRAPHER_CHART_TYPES.LineChart], + selectedEntityNames: series.map((s) => s.jurisdiction.name), + dimensions, + }); + + grapherState.entityType = "country"; + // Emphasize Canada; the other series render muted until hovered. + grapherState.focusedSeriesNames = ["Canada"]; + grapherState.focusArray.clearAllAndAdd("Canada"); + // Chart area only — no header (title/logo), tabs, entity selector, + // timeline, or footer (data source, download, full-screen). The enum + // isn't re-exported from the package root, hence the cast. + grapherState.variant = "uncaptioned" as typeof grapherState.variant; + + grapherState.inputTable = legacyToChartsTableAndDimensionsWithMandatorySlug( + createTestDataset([{ data, metadata }]), + dimensions, + {}, + ); + + return grapherState; +} + +export default function IndicatorChart({ + response, +}: { + response: EconomySeriesResponse; +}) { + const containerRef = useRef(null); + const [size, setSize] = useState({ width: 800, height: 480 }); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const measure = () => { + const w = Math.min(1040, Math.max(320, el.clientWidth - 16)); + const h = Math.max(320, Math.min(460, Math.round(w * 0.5))); + setSize({ width: w, height: h }); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const grapherState = useMemo( + () => + buildGrapherState(response, new Bounds(0, 0, size.width, size.height)), + [response, size.width, size.height], + ); + + return ( + // In the uncaptioned variant, Grapher draws chart content at the padded + // origin (24,24) inside an svg whose viewBox starts at 0,0 — the bottom + // axis and right-edge labels get clipped unless the svg can overflow. +
+ {grapherState ? ( +
+ +
+ ) : ( +
+

+ No data available for this indicator. +

+
+ )} +
+ ); +} diff --git a/src/app/economic-indicators/IndicatorChartClient.tsx b/src/app/economic-indicators/IndicatorChartClient.tsx new file mode 100644 index 0000000..f26847b --- /dev/null +++ b/src/app/economic-indicators/IndicatorChartClient.tsx @@ -0,0 +1,19 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type { EconomySeriesResponse } from "@/lib/api/economy"; + +const IndicatorChart = dynamic(() => import("./IndicatorChart"), { + ssr: false, + loading: () => ( +
+ ), +}); + +export default function IndicatorChartClient({ + response, +}: { + response: EconomySeriesResponse; +}) { + return ; +} diff --git a/src/app/economic-indicators/canvas/CanvasClient.tsx b/src/app/economic-indicators/canvas/CanvasClient.tsx new file mode 100644 index 0000000..976061f --- /dev/null +++ b/src/app/economic-indicators/canvas/CanvasClient.tsx @@ -0,0 +1,341 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import dynamic from "next/dynamic"; +import { useSearchParams } from "next/navigation"; +import type { EconomySeriesResponse } from "@/lib/api/economy"; +import { SECTIONS, MEASURE_SLUGS, indicatorHeading } from "../indicators"; +import type { OverlaySeries, OverlayMode } from "./overlay-types"; + +const OverlayChart = dynamic(() => import("./OverlayChart"), { + ssr: false, + loading: () => ( +
+ ), +}); + +// auburn-600, lake-600, pine-600 from the brand palette. +const FEED_COLORS = ["#c43e3e", "#0880b5", "#17794d"]; +const MAX_FEEDS = 3; +const DEFAULT_MEASURE = "gdp-per-capita-ppp"; +// Canada's warehouse jurisdiction slug (the pre-existing federal CA row). +const DEFAULT_JURISDICTION = "ca"; + +type Feed = { measure: string; jurisdiction: string }; +type MeasureData = EconomySeriesResponse | "loading" | "error"; + +function parseFeedsParam(param: string | null): Feed[] { + if (!param) return []; + return param + .split("|") + .map((part) => { + const [measure, jurisdiction] = part.split(":"); + if (!measure || !MEASURE_SLUGS.has(measure)) return null; + return { measure, jurisdiction: jurisdiction || DEFAULT_JURISDICTION }; + }) + .filter((f): f is Feed => f !== null) + .slice(0, MAX_FEEDS); +} + +function serializeFeeds(feeds: Feed[]): string { + return feeds.map((f) => `${f.measure}:${f.jurisdiction}`).join("|"); +} + +// Pearson correlation over the years where both series report a value. +function pearson( + a: { year: number; value: number }[], + b: { year: number; value: number }[], +): { r: number; n: number } | null { + const byYear = new Map(a.map((p) => [p.year, p.value])); + const pairs = b + .filter((p) => byYear.has(p.year)) + .map((p) => [byYear.get(p.year) as number, p.value]); + const n = pairs.length; + if (n < 3) return null; + + const meanX = pairs.reduce((s, [x]) => s + x, 0) / n; + const meanY = pairs.reduce((s, [, y]) => s + y, 0) / n; + let cov = 0; + let varX = 0; + let varY = 0; + for (const [x, y] of pairs) { + cov += (x - meanX) * (y - meanY); + varX += (x - meanX) ** 2; + varY += (y - meanY) ** 2; + } + if (varX === 0 || varY === 0) return null; + return { r: cov / Math.sqrt(varX * varY), n }; +} + +export default function CanvasClient() { + const searchParams = useSearchParams(); + + const [feeds, setFeeds] = useState(() => { + const fromUrl = parseFeedsParam(searchParams.get("f")); + return fromUrl.length > 0 + ? fromUrl + : [{ measure: DEFAULT_MEASURE, jurisdiction: DEFAULT_JURISDICTION }]; + }); + const [mode, setMode] = useState(() => + searchParams.get("mode") === "raw" ? "raw" : "indexed", + ); + const [dataByMeasure, setDataByMeasure] = useState< + Record + >({}); + + const inflightMeasures = useRef(new Set()); + + // A feed's stored jurisdiction can be invalid for its measure (e.g. after + // switching to a Canada-only measure), so resolve it against the loaded + // series at render time instead of correcting state in an effect. + const resolvedFeeds: Feed[] = useMemo( + () => + feeds.map((feed) => { + const data = dataByMeasure[feed.measure]; + if (!data || data === "loading" || data === "error") return feed; + const slugs = data.data.series.map((s) => s.jurisdiction.slug); + if (slugs.includes(feed.jurisdiction)) return feed; + return { + ...feed, + jurisdiction: slugs.includes(DEFAULT_JURISDICTION) + ? DEFAULT_JURISDICTION + : (slugs[0] ?? feed.jurisdiction), + }; + }), + [feeds, dataByMeasure], + ); + + // Keep the URL shareable. Shallow replaceState only — router.replace would + // refetch the RSC payload and remount this component on every change. + useEffect(() => { + const params = new URLSearchParams(); + params.set("f", serializeFeeds(resolvedFeeds)); + if (mode !== "indexed") params.set("mode", mode); + const next = `/economic-indicators/canvas?${params.toString()}`; + if (`${window.location.pathname}${window.location.search}` !== next) { + window.history.replaceState(null, "", next); + } + }, [resolvedFeeds, mode]); + + // Fetch series data for any selected measure we haven't loaded yet. + useEffect(() => { + for (const feed of feeds) { + const measure = feed.measure; + if (dataByMeasure[measure] || inflightMeasures.current.has(measure)) { + continue; + } + inflightMeasures.current.add(measure); + fetch(`/api/economy/series?measure=${encodeURIComponent(measure)}`) + .then((res) => (res.ok ? res.json() : Promise.reject())) + .then((json: EconomySeriesResponse) => + setDataByMeasure((prev) => ({ ...prev, [measure]: json })), + ) + .catch(() => + setDataByMeasure((prev) => ({ ...prev, [measure]: "error" })), + ) + .finally(() => inflightMeasures.current.delete(measure)); + } + }, [feeds, dataByMeasure]); + + const overlaySeries: (OverlaySeries | null)[] = useMemo( + () => + resolvedFeeds.map((feed, i) => { + const data = dataByMeasure[feed.measure]; + if (!data || data === "loading" || data === "error") return null; + const series = data.data.series.find( + (s) => s.jurisdiction.slug === feed.jurisdiction, + ); + if (!series) return null; + return { + label: `${indicatorHeading(feed.measure)} — ${series.jurisdiction.name}`, + color: FEED_COLORS[i], + unitSymbol: data.data.measure.unit.symbol, + points: series.points, + }; + }), + [resolvedFeeds, dataByMeasure], + ); + + const loadedSeries = overlaySeries.filter( + (s): s is OverlaySeries => s !== null, + ); + + const updateFeed = (index: number, patch: Partial) => + setFeeds((prev) => + prev.map((f, i) => (i === index ? { ...f, ...patch } : f)), + ); + + const correlations = useMemo(() => { + const out: { a: string; b: string; r: number; n: number }[] = []; + for (let i = 0; i < loadedSeries.length; i++) { + for (let j = i + 1; j < loadedSeries.length; j++) { + const result = pearson(loadedSeries[i].points, loadedSeries[j].points); + if (result) { + out.push({ + a: loadedSeries[i].label, + b: loadedSeries[j].label, + ...result, + }); + } + } + } + return out; + // loadedSeries is derived fresh each render from overlaySeries. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [overlaySeries]); + + return ( +
+ {/* Chart is the primary content — it comes first. */} +
+ {loadedSeries.length > 0 ? ( + + ) : ( +
+

+ {feeds.some((f) => dataByMeasure[f.measure] === "error") + ? "Data is temporarily unavailable." + : "Loading data…"} +

+
+ )} +
+ + {/* Subtle controls, tucked beneath the chart. */} +
+
+ {resolvedFeeds.map((feed, i) => { + const data = dataByMeasure[feed.measure]; + const jurisdictions = + data && data !== "loading" && data !== "error" + ? data.data.series.map((s) => s.jurisdiction) + : []; + return ( +
+ + + + {data === "error" && ( + + Failed to load this indicator. + + )} + {feeds.length > 1 && ( + + )} +
+ ); + })} +
+ +
+ {feeds.length < MAX_FEEDS && ( + + )} + +
+ Scale mode + {( + [ + ["indexed", "Indexed (first shared year = 100)"], + ["raw", "Raw values (separate axes)"], + ] as const + ).map(([value, label]) => ( + + ))} +
+
+ + {correlations.length > 0 && ( +
+ {correlations.map((c) => ( +

+ r = {c.r.toFixed(2)} · {c.a} vs {c.b}{" "} + + ({c.n} overlapping years; correlation ≠ causation) + +

+ ))} +
+ )} +
+
+ ); +} diff --git a/src/app/economic-indicators/canvas/OverlayChart.tsx b/src/app/economic-indicators/canvas/OverlayChart.tsx new file mode 100644 index 0000000..6f390a6 --- /dev/null +++ b/src/app/economic-indicators/canvas/OverlayChart.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { + Chart as ChartJS, + LinearScale, + PointElement, + LineElement, + LineController, + Tooltip, + Legend, +} from "chart.js"; +import type { OverlaySeries, OverlayMode } from "./overlay-types"; + +ChartJS.register( + LinearScale, + PointElement, + LineElement, + LineController, + Tooltip, + Legend, +); + +function formatValue(value: number, unitSymbol: string): string { + switch (unitSymbol) { + case "%": + return `${value.toFixed(1)}%`; + case "intl_$": + return `$${Math.round(value).toLocaleString("en-CA")}`; + case "intl_$_per_hour": + return `$${value.toFixed(2)}/h`; + case "ratio": + return value.toFixed(3); + case "units": + return Math.round(value).toLocaleString("en-CA"); + case "hours": + return `${Math.round(value).toLocaleString("en-CA")} h`; + case "index": + return value.toFixed(1); + case "score": + case "births_per_woman": + return value.toFixed(2); + case "per_100_people": + case "rate_per_100k": + case "t_co2_per_capita": + case "ug_m3": + return value.toFixed(1); + case "tonne_km_millions": + return Math.round(value).toLocaleString("en-CA"); + default: + return value.toLocaleString("en-CA"); + } +} + +function axisLabel(unitSymbol: string): string { + switch (unitSymbol) { + case "%": + return "%"; + case "intl_$": + return "international-$"; + case "intl_$_per_hour": + return "international-$ / hour"; + case "ratio": + return "ratio"; + case "units": + return "dwelling units"; + case "hours": + return "hours / year"; + case "index": + return "index"; + case "score": + return "score"; + case "births_per_woman": + return "births per woman"; + case "per_100_people": + return "per 100 people"; + case "rate_per_100k": + return "per 100,000"; + case "t_co2_per_capita": + return "t CO₂ / person"; + case "ug_m3": + return "µg/m³"; + case "tonne_km_millions": + return "million ton-km"; + default: + return unitSymbol; + } +} + +// The first year present in every series, so indexed mode rebases all series +// to a shared point. Falls back to each series' own first year when the +// series never overlap. +function firstSharedYear(series: OverlaySeries[]): number | null { + if (series.length === 0) return null; + const shared = series + .map((s) => new Set(s.points.map((p) => p.year))) + .reduce((acc, years) => new Set([...acc].filter((y) => years.has(y)))); + if (shared.size === 0) return null; + return Math.min(...shared); +} + +export default function OverlayChart({ + series, + mode, +}: { + series: OverlaySeries[]; + mode: OverlayMode; +}) { + const canvasRef = useRef(null); + const chartRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const baseYear = mode === "indexed" ? firstSharedYear(series) : null; + + const datasets = series.map((s, i) => { + const base = + baseYear !== null + ? (s.points.find((p) => p.year === baseYear)?.value ?? + s.points[0]?.value) + : s.points[0]?.value; + + return { + label: s.label, + data: s.points.map((p) => ({ + x: p.year, + y: + mode === "indexed" && base + ? (p.value / base) * 100 + : p.value, + raw: p.value, + })), + borderColor: s.color, + backgroundColor: s.color, + pointRadius: 0, + pointHoverRadius: 4, + borderWidth: 2, + tension: 0.15, + yAxisID: mode === "raw" ? `y${i}` : "y", + }; + }); + + const rawAxes = Object.fromEntries( + mode === "raw" + ? series.map((s, i) => [ + `y${i}`, + { + type: "linear" as const, + position: i === 0 ? ("left" as const) : ("right" as const), + title: { + display: true, + text: axisLabel(s.unitSymbol), + color: s.color, + }, + ticks: { color: s.color }, + grid: { drawOnChartArea: i === 0 }, + }, + ]) + : [], + ); + + chartRef.current?.destroy(); + chartRef.current = new ChartJS(canvas, { + type: "line", + data: { datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "nearest", axis: "x", intersect: false }, + scales: { + x: { + type: "linear", + ticks: { callback: (v) => String(v), precision: 0 }, + grid: { display: false }, + }, + ...(mode === "indexed" + ? { + y: { + type: "linear" as const, + title: { + display: true, + text: + baseYear !== null + ? `Index (${baseYear} = 100)` + : "Index (first year = 100)", + }, + }, + } + : rawAxes), + }, + plugins: { + legend: { position: "bottom", labels: { boxWidth: 12 } }, + tooltip: { + callbacks: { + title: (items) => String(items[0]?.parsed.x ?? ""), + label: (item) => { + const s = series[item.datasetIndex]; + const raw = (item.raw as { raw: number }).raw; + const formatted = formatValue(raw, s.unitSymbol); + if (mode === "indexed") { + const indexed = item.parsed.y ?? 0; + return `${s.label}: ${indexed.toFixed(1)} (${formatted})`; + } + return `${s.label}: ${formatted}`; + }, + }, + }, + }, + }, + }); + + return () => { + chartRef.current?.destroy(); + chartRef.current = null; + }; + }, [series, mode]); + + return ( +
+ +
+ ); +} diff --git a/src/app/economic-indicators/canvas/overlay-types.ts b/src/app/economic-indicators/canvas/overlay-types.ts new file mode 100644 index 0000000..0078345 --- /dev/null +++ b/src/app/economic-indicators/canvas/overlay-types.ts @@ -0,0 +1,8 @@ +export type OverlayMode = "indexed" | "raw"; + +export type OverlaySeries = { + label: string; + color: string; + unitSymbol: string; + points: { year: number; value: number }[]; +}; diff --git a/src/app/economic-indicators/canvas/page.tsx b/src/app/economic-indicators/canvas/page.tsx new file mode 100644 index 0000000..e300c41 --- /dev/null +++ b/src/app/economic-indicators/canvas/page.tsx @@ -0,0 +1,53 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import Link from "next/link"; +import { PageHeader } from "@/components/ui/page-header"; +import CanvasClient from "./CanvasClient"; + +const DESCRIPTION = + "Overlay up to three economic indicator series and eyeball how they move together."; + +export const metadata: Metadata = { + title: "Indicator Canvas", + description: DESCRIPTION, + alternates: { canonical: "/economic-indicators/canvas" }, + openGraph: { + title: "Indicator Canvas", + description: DESCRIPTION, + type: "website", + }, + twitter: { + card: "summary_large_image", + title: "Indicator Canvas", + }, +}; + +export default function CanvasPage() { + return ( +
+ + + + +
+
+ + + +
+
+
+ ); +} diff --git a/src/app/economic-indicators/indicators.ts b/src/app/economic-indicators/indicators.ts new file mode 100644 index 0000000..d8a2539 --- /dev/null +++ b/src/app/economic-indicators/indicators.ts @@ -0,0 +1,294 @@ +// Shared catalog of the economic indicator measures served by york_factory's +// /kpis/series endpoint — the /economic-indicators page renders every entry +// and the canvas page uses it as the feed picker. + +export type Indicator = { slug: string; heading: string; blurb: string }; +export type IndicatorSection = { + id: string; + title: string; + description: string; + indicators: Indicator[]; +}; + +export const SECTIONS: IndicatorSection[] = [ + { + id: "economy", + title: "Overall Economy", + description: + "Is Canada's economy growing and producing enough to sustain rising living standards?", + indicators: [ + { + slug: "gdp-per-capita-ppp", + heading: "GDP per capita (PPP)", + blurb: + "Output per person, adjusted for purchasing power. The clearest single measure of how living standards in Canada compare with peer economies.", + }, + { + slug: "gdp-growth-annual", + heading: "GDP growth", + blurb: + "Annual growth in real gross domestic product. Sustained growth is what compounds into higher incomes over a generation.", + }, + { + slug: "trade-balance-pct-gdp", + heading: "Trade balance", + blurb: + "External balance on goods and services as a share of GDP. Shows whether Canada sells more to the world than it buys.", + }, + { + slug: "labour-productivity-gdp-per-hour", + heading: "Labour productivity", + blurb: + "GDP per hour worked. Productivity growth is the engine of rising wages — and where Canada has fallen furthest behind.", + }, + ], + }, + { + id: "welfare", + title: "Individual Economics & Welfare", + description: + "Does prosperity reach everyday Canadians — in wages, work, and the distribution of income?", + indicators: [ + { + slug: "employment-rate", + heading: "Employment rate", + blurb: + "Share of the population aged 15 and over that is employed. The broadest gauge of whether people who could work, do.", + }, + { + slug: "real-minimum-wage-ppp", + heading: "Real minimum wage", + blurb: + "Statutory hourly minimum wage at constant prices, in purchasing-power dollars. Italy has no statutory minimum wage, so no G7 average is shown.", + }, + { + slug: "average-annual-hours-worked", + heading: "Hours worked", + blurb: + "Average annual hours actually worked per worker. Working more hours for the same income is not prosperity.", + }, + { + slug: "household-debt-to-income", + heading: "Household debt", + blurb: + "Household debt as a share of net disposable income. Canadian households carry among the heaviest debt loads in the G7.", + }, + { + slug: "gini-index", + heading: "Income inequality (Gini index)", + blurb: + "Gini index of income inequality on a 0–100 scale, where 0 is perfect equality. World Bank estimates, internationally comparable.", + }, + { + slug: "gini-after-tax-canada", + heading: "Income inequality after tax (Canada)", + blurb: + "Statistics Canada's official Gini coefficient of adjusted after-tax income — a longer and more current Canada-only series.", + }, + { + slug: "bottom-quintile-income-share", + heading: "Bottom-quintile income share", + blurb: + "Share of income that accrues to the poorest 20%. A direct check on whether growth reaches the bottom of the distribution.", + }, + { + slug: "age-dependency-ratio", + heading: "Age dependency ratio", + blurb: + "Dependents (under 15 or over 64) per 100 working-age people. A rising ratio means fewer workers supporting more people.", + }, + { + slug: "low-income-entry-rate", + heading: "Low income entry rate", + blurb: + "Share of Canadian tax filers not in low income who fell into it the following year. Measures how many people poverty pulls in.", + }, + { + slug: "low-income-exit-rate", + heading: "Low income exit rate", + blurb: + "Share of Canadian tax filers in low income who escaped it the following year. Measures how quickly people get back out.", + }, + ], + }, + { + id: "wellbeing", + title: "Happiness & Wellbeing", + description: + "Are Canadians satisfied with their lives — and confident enough in the future to build one here?", + indicators: [ + { + slug: "life-satisfaction", + heading: "Life satisfaction", + blurb: + "Average self-reported life evaluation on the 0–10 Cantril ladder — the score the World Happiness Report ranks countries by.", + }, + { + slug: "fertility-rate", + heading: "Fertility rate", + blurb: + "Births per woman. A blunt but honest proxy for whether people feel confident enough in the future to start families.", + }, + ], + }, + { + id: "safety", + title: "Crime & Public Safety", + description: "Is Canada getting safer or more dangerous?", + indicators: [ + { + slug: "crime-severity-index", + heading: "Crime Severity Index", + blurb: + "Statistics Canada's headline crime measure, weighting offences by seriousness (2006 = 100).", + }, + { + slug: "police-reported-crime-rate", + heading: "Police-reported crime rate", + blurb: + "Criminal Code incidents (excluding traffic) per 100,000 Canadians.", + }, + { + slug: "homicide-rate", + heading: "Homicide rate", + blurb: + "Intentional homicides per 100,000 people — the crime statistic least affected by reporting differences across countries.", + }, + ], + }, + { + id: "governance", + title: "Governance & Transparency", + description: + "Is Canada's government clean, capable, and accountable by international standards?", + indicators: [ + { + slug: "corruption-perceptions-index", + heading: "Corruption perceptions", + blurb: + "Transparency International's index from 0 (highly corrupt) to 100 (very clean). Comparable since 2012.", + }, + { + slug: "government-effectiveness", + heading: "Government effectiveness", + blurb: + "World Governance Indicators estimate of public service quality and policy execution, from −2.5 (weak) to 2.5 (strong).", + }, + ], + }, + { + id: "infrastructure", + title: "Infrastructure & Industry", + description: + "Is Canada investing in the physical and technological capacity to compete?", + indicators: [ + { + slug: "rd-spending-pct-gdp", + heading: "R&D spending", + blurb: + "Gross domestic expenditure on research and development as a share of GDP.", + }, + { + slug: "manufacturing-value-added-pct-gdp", + heading: "Manufacturing value added", + blurb: "Manufacturing's contribution to GDP.", + }, + { + slug: "fixed-broadband-subscriptions", + heading: "Broadband penetration", + blurb: "Fixed broadband subscriptions per 100 people.", + }, + { + slug: "rail-freight-tonne-km", + heading: "Rail freight", + blurb: + "Goods moved by rail, in million ton-kilometres. A physical-economy pulse check.", + }, + ], + }, + { + id: "international", + title: "International Relations", + description: "Does Canada pull its weight in the world?", + indicators: [ + { + slug: "oda-pct-gni", + heading: "Development assistance", + blurb: + "Official development assistance as a share of gross national income, against the UN's 0.7% target.", + }, + ], + }, + { + id: "environment", + title: "Environment", + description: + "Is Canada cutting emissions and protecting land while the economy grows? Overlay CO₂ with GDP on the canvas to see decoupling.", + indicators: [ + { + slug: "co2-emissions-per-capita", + heading: "CO₂ emissions per capita", + blurb: + "Production-based carbon emissions per person, in tonnes per year.", + }, + { + slug: "air-pollution-pm25", + heading: "Air quality (PM2.5)", + blurb: + "Population-weighted exposure to fine particulate matter. The WHO guideline is 5 µg/m³.", + }, + { + slug: "protected-areas-pct", + heading: "Protected areas", + blurb: "Terrestrial protected areas as a share of total land.", + }, + { + slug: "forest-area-pct", + heading: "Forest coverage", + blurb: "Forested land as a share of total land area.", + }, + ], + }, + { + id: "housing", + title: "Housing", + description: + "Can Canadians afford a home — and is the country building enough of them?", + indicators: [ + { + slug: "house-price-to-income", + heading: "House price to income", + blurb: + "House prices relative to disposable income per person, shown against each country's long-term average (100). The OECD's headline affordability indicator.", + }, + { + slug: "real-house-price-index", + heading: "Real house prices", + blurb: + "House prices adjusted for inflation, indexed to 2015. Shows how far prices have outrun the general cost of living.", + }, + { + slug: "housing-starts-canada", + heading: "Housing starts", + blurb: + "Dwelling units started per year across Canada, from CMHC's starts and completions survey. The supply side of the housing equation.", + }, + { + slug: "rental-vacancy-rate-canada", + heading: "Rental vacancy rate", + blurb: + "Vacancy rate in purpose-built rental apartments across census metropolitan areas. A healthy rental market sits near 3%.", + }, + ], + }, +]; + +export const ALL_INDICATORS: Indicator[] = SECTIONS.flatMap( + (s) => s.indicators, +); + +export const MEASURE_SLUGS = new Set(ALL_INDICATORS.map((i) => i.slug)); + +export function indicatorHeading(slug: string): string { + return ALL_INDICATORS.find((i) => i.slug === slug)?.heading ?? slug; +} diff --git a/src/app/economic-indicators/page.tsx b/src/app/economic-indicators/page.tsx new file mode 100644 index 0000000..e187edd --- /dev/null +++ b/src/app/economic-indicators/page.tsx @@ -0,0 +1,184 @@ +import "@buildcanada/charts/styles.css"; + +import type { Metadata } from "next"; +import { getSiteConfig } from "@/lib/api"; +import { + getEconomicSeries, + humanizeSourceName, + type EconomySeriesResponse, +} from "@/lib/api/economy"; +import { PageHeader } from "@/components/ui/page-header"; +import { buildGraph } from "@/lib/schemas/graph"; +import { generateOrganizationSchema } from "@/lib/schemas/generators/organization"; +import { generateBreadcrumbSchema } from "@/lib/schemas/generators/breadcrumb"; +import IndicatorChartClient from "./IndicatorChartClient"; +import { SECTIONS } from "./indicators"; + +const DESCRIPTION = + "How Canada stacks up against the G7 and OECD — growth, productivity, incomes, inequality, and housing."; + +export const metadata: Metadata = { + title: "Economic Indicators", + description: DESCRIPTION, + alternates: { canonical: "/economic-indicators" }, + openGraph: { + title: "Economic Indicators", + description: DESCRIPTION, + type: "website", + }, + twitter: { + card: "summary_large_image", + title: "Economic Indicators", + }, +}; + +function formatFetchedDate(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + return new Intl.DateTimeFormat("en-CA", { dateStyle: "long" }).format(date); +} + +function SourceLine({ response }: { response: EconomySeriesResponse }) { + const source = response.meta.source; + if (!source) return null; + const updated = source.last_fetched_at + ? formatFetchedDate(source.last_fetched_at) + : ""; + const sourceName = humanizeSourceName(source.name); + return ( +

+ Source:{" "} + {source.url ? ( + + {sourceName} + + ) : ( + sourceName + )} + {updated && <> · Updated {updated}} +

+ ); +} + +function UnavailablePanel() { + return ( +
+

+ Data is temporarily unavailable. Please check back soon. +

+
+ ); +} + +export default async function EconomicIndicatorsPage() { + const configData = getSiteConfig(); + + const jsonLd = buildGraph( + generateOrganizationSchema(configData), + generateBreadcrumbSchema( + "/economic-indicators", + "Economic Indicators", + configData.siteUrl, + ), + ); + + const slugs = SECTIONS.flatMap((s) => s.indicators.map((i) => i.slug)); + const results = await Promise.all( + slugs.map((slug) => getEconomicSeries(slug).catch(() => null)), + ); + const responseBySlug = new Map(slugs.map((slug, i) => [slug, results[i]])); + + return ( +
+