From 52fb2989b069ee722748412008a48c7a7d05fefa Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Tue, 14 Jul 2026 13:56:51 -0400 Subject: [PATCH 01/12] wip --- src/app/prosperity-dashboard/DashboardNav.tsx | 24 +++ src/app/prosperity-dashboard/SourceLine.tsx | 43 ++++ .../prosperity-dashboard/[section]/page.tsx | 42 +--- src/app/prosperity-dashboard/dashboard.ts | 173 ++++++++++++++++ src/app/prosperity-dashboard/page.tsx | 184 +++++++++++++----- 5 files changed, 373 insertions(+), 93 deletions(-) create mode 100644 src/app/prosperity-dashboard/DashboardNav.tsx create mode 100644 src/app/prosperity-dashboard/SourceLine.tsx create mode 100644 src/app/prosperity-dashboard/dashboard.ts diff --git a/src/app/prosperity-dashboard/DashboardNav.tsx b/src/app/prosperity-dashboard/DashboardNav.tsx new file mode 100644 index 0000000..430b732 --- /dev/null +++ b/src/app/prosperity-dashboard/DashboardNav.tsx @@ -0,0 +1,24 @@ +import { DASHBOARD_SECTIONS } from "./dashboard"; + +// Anchor nav for the single-page dashboard. Sits below the global navbar +// (~70px) in the sticky stack, mirroring SectionNav on the subpages. +export default function DashboardNav() { + return ( + + ); +} diff --git a/src/app/prosperity-dashboard/SourceLine.tsx b/src/app/prosperity-dashboard/SourceLine.tsx new file mode 100644 index 0000000..1634000 --- /dev/null +++ b/src/app/prosperity-dashboard/SourceLine.tsx @@ -0,0 +1,43 @@ +import { + humanizeSourceName, + humanizeSourceUrl, + type EconomySeriesResponse, +} from "@/lib/api/economy"; + +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); +} + +export default 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); + const sourceUrl = humanizeSourceUrl(source.name, source.url); + return ( +

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

+ ); +} diff --git a/src/app/prosperity-dashboard/[section]/page.tsx b/src/app/prosperity-dashboard/[section]/page.tsx index 11295e2..920821d 100644 --- a/src/app/prosperity-dashboard/[section]/page.tsx +++ b/src/app/prosperity-dashboard/[section]/page.tsx @@ -4,12 +4,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; import { getSiteConfig } from "@/lib/api"; -import { - getEconomicSeries, - humanizeSourceName, - humanizeSourceUrl, - type EconomySeriesResponse, -} from "@/lib/api/economy"; +import { getEconomicSeries } from "@/lib/api/economy"; import { PageHeader } from "@/components/ui/page-header"; import { buildGraph } from "@/lib/schemas/graph"; import { generateOrganizationSchema } from "@/lib/schemas/generators/organization"; @@ -18,6 +13,7 @@ import { Signpost } from "@/components/custom/signpost"; import CombinedSectionChartClient from "../CombinedSectionChartClient"; import IndicatorChartClient from "../IndicatorChartClient"; import SectionNav from "../SectionNav"; +import SourceLine from "../SourceLine"; import { SECTIONS } from "../indicators"; export const dynamicParams = false; @@ -51,40 +47,6 @@ export async function generateMetadata({ }; } -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); - const sourceUrl = humanizeSourceUrl(source.name, source.url); - return ( -

- Source:{" "} - {sourceUrl ? ( - - {sourceName} - - ) : ( - sourceName - )} - {updated && <> · Updated {updated}} -

- ); -} - // Deep-link targets must clear the sticky navbar + SectionNav stack, whose // height grows as the section links wrap on narrower screens. const SECTION_SCROLL_MT = diff --git a/src/app/prosperity-dashboard/dashboard.ts b/src/app/prosperity-dashboard/dashboard.ts new file mode 100644 index 0000000..df98e72 --- /dev/null +++ b/src/app/prosperity-dashboard/dashboard.ts @@ -0,0 +1,173 @@ +// Layout of the single-page prosperity dashboard (modelled on +// lookingforgrowth.uk's State of the Nation): a headline chart followed by +// themed sections rendered as anchored blocks on one page. Measures must +// exist in york_factory's /kpis/series endpoint; charts the API can't serve +// yet are listed under `planned` so the section still shows its intent. +// The per-section subpages keep using the older catalog in indicators.ts. + +import type { IndicatorBenchmark } from "./indicators"; + +export type DashboardIndicator = { + slug: string; + heading: string; + blurb: string; + benchmark?: IndicatorBenchmark; +}; + +export type PlannedIndicator = { + heading: string; + // The series the backend needs to ingest before this chart can ship. + needs: string; +}; + +export type DashboardSection = { + id: string; + title: string; + description?: string; + // Full-width hero treatment for every chart in the section. + hero?: boolean; + indicators: DashboardIndicator[]; + planned?: PlannedIndicator[]; +}; + +export const DASHBOARD_SECTIONS: DashboardSection[] = [ + { + id: "headline", + title: "Headline", + hero: true, + indicators: [ + { + slug: "gdp-per-capita-ppp", + heading: "GDP per capita", + blurb: + "Output per person, adjusted for purchasing power. The clearest single measure of how living standards in Canada compare with peer economies.", + }, + ], + }, + { + id: "economy", + title: "Economy", + description: + "Is Canada's economy growing, hiring, and investing enough to sustain rising living standards?", + indicators: [ + { + slug: "employment-rate", + heading: "Employment", + blurb: + "Share of the population aged 15 and over that is employed. The broadest gauge of whether people who could work, do.", + }, + ], + planned: [ + { + heading: "Employment by age group", + needs: + "Employment rate broken down by age cohort (StatCan Labour Force Survey, table 14-10-0327)", + }, + { + heading: "New business formation", + needs: + "Business openings/entry rate (StatCan experimental business openings, table 33-10-0270)", + }, + { + heading: "Wage growth — 10th, 90th percentile, and average", + needs: + "Hourly wage distribution percentiles (StatCan Labour Force Survey, table 14-10-0063 or 14-10-0417)", + }, + { + heading: "Investment inflows / outflows", + needs: + "Foreign direct investment flows in and out of Canada (StatCan table 36-10-0025 or OECD FDI statistics)", + }, + { + heading: "Capital formation", + needs: + "Gross fixed capital formation, ideally as a share of GDP (StatCan table 36-10-0104 or World Bank NE.GDI.FTOT.ZS)", + }, + ], + }, + { + id: "government-sustainability", + title: "Government Sustainability", + description: + "Can Canadian governments keep their books balanced without crowding out the private economy?", + indicators: [], + planned: [ + { + heading: "Debt to GDP ratio (all levels of government)", + needs: + "Consolidated general government net/gross debt as a share of GDP (StatCan table 10-10-0147 or IMF GFS)", + }, + { + heading: "Private vs public employment share", + needs: + "Employment split by public sector, private sector, and self-employment (StatCan Labour Force Survey, table 14-10-0288)", + }, + ], + }, + { + id: "cost-of-living", + title: "Cost of Living", + description: + "What Canadians pay for the essentials — and whether a home is still within reach.", + indicators: [ + { + slug: "cpi-all-items", + heading: "CPI", + blurb: + "The all-items Consumer Price Index, month by month (2002 = 100). The grey line is where prices would sit had they grown at the Bank of Canada's 2% inflation target.", + // Bank of Canada inflation-control target (2% midpoint), compounded + // through the CPI's 2002 = 100 index base. + benchmark: { + label: "2% target", + annualRatePct: 2, + anchorYear: 2002, + anchorValue: 100, + }, + }, + { + slug: "house-price-to-income", + heading: "Housing price to income ratio", + 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: "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.", + }, + ], + }, + { + id: "immigration", + title: "Immigration", + description: + "How many people are coming, who they are, and how many are leaving.", + indicators: [], + planned: [ + { + heading: "Immigration", + needs: + "Permanent-resident admissions over time (IRCC admissions data or StatCan table 17-10-0008)", + }, + { + heading: "Net emigration", + needs: + "Emigrants and returning emigrants from StatCan's components of population change (table 17-10-0008)", + }, + { + heading: "Temporary foreign workers", + needs: + "TFW program work-permit holders over time (IRCC temporary residents data or StatCan table 17-10-0121)", + }, + { + heading: "Immigration by class", + needs: + "Admissions split by economic, family, refugee, and student classes (IRCC admissions by category)", + }, + ], + }, +]; + +export const DASHBOARD_INDICATORS: DashboardIndicator[] = + DASHBOARD_SECTIONS.flatMap((s) => s.indicators); diff --git a/src/app/prosperity-dashboard/page.tsx b/src/app/prosperity-dashboard/page.tsx index a1fd4b0..ff7ec9e 100644 --- a/src/app/prosperity-dashboard/page.tsx +++ b/src/app/prosperity-dashboard/page.tsx @@ -1,17 +1,26 @@ +import "@buildcanada/charts/styles.css"; + import type { Metadata } from "next"; -import Link from "next/link"; import { getSiteConfig } from "@/lib/api"; -import { getEconomicSeries } from "@/lib/api/economy"; +import { + getEconomicSeries, + 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 SectionNav from "./SectionNav"; -import SectionSparkline from "./SectionSparkline"; -import { SECTIONS } from "./indicators"; +import IndicatorChartClient from "./IndicatorChartClient"; +import DashboardNav from "./DashboardNav"; +import SourceLine from "./SourceLine"; +import { + DASHBOARD_INDICATORS, + DASHBOARD_SECTIONS, + type DashboardIndicator, +} from "./dashboard"; const DESCRIPTION = - "Are we moving in the right direction? Canada's growth, incomes, housing, safety, and wellbeing, measured against the G7 and OECD."; + "Are we moving in the right direction? Canada's growth, jobs, cost of living, and immigration on one page, measured against the G7 and OECD."; export const metadata: Metadata = { title: "Prosperity Dashboard", @@ -28,14 +37,77 @@ export const metadata: Metadata = { }, }; +// Anchor targets must clear the sticky navbar + DashboardNav stack, whose +// height grows as the nav links wrap on narrower screens. +const SECTION_SCROLL_MT = "scroll-mt-[220px] sm:scroll-mt-[160px]"; + +// Chart headings link to their own anchor so a reader can grab a URL +// straight to one chart. +function AnchoredHeading({ id, text }: { id: string; text: string }) { + return ( +

+ + {text} + + +

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

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

+
+ ); +} + +function ChartCard({ + indicator, + response, +}: { + indicator: DashboardIndicator; + response: EconomySeriesResponse | null; +}) { + return ( +
+ +

+ {indicator.blurb} +

+ {response ? ( + <> + + + + ) : ( + + )} +
+ ); +} + export default async function ProsperityDashboardPage() { const configData = getSiteConfig(); - const featuredSeries = await Promise.all( - SECTIONS.map((section) => - getEconomicSeries(section.featuredSlug).catch(() => null), + const results = await Promise.all( + DASHBOARD_INDICATORS.map((indicator) => + getEconomicSeries(indicator.slug).catch(() => null), ), ); + const responseBySlug = new Map( + DASHBOARD_INDICATORS.map((indicator, i) => [indicator.slug, results[i]]), + ); const jsonLd = buildGraph( generateOrganizationSchema(configData), @@ -52,56 +124,62 @@ export default async function ProsperityDashboardPage() { type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> - + - +
-
- - -
- {SECTIONS.map((section, i) => { - const preview = featuredSeries[i]; - const featuredHeading = section.indicators.find( - (indicator) => indicator.slug === section.featuredSlug, - )?.heading; - return ( - + {DASHBOARD_SECTIONS.map((section) => ( +
+

{section.title}

+ {section.description && ( +

+ {section.description} +

+ )} + + {section.indicators.length > 0 && ( +
-

- {section.title} -

- {preview && ( -
-

- {featuredHeading} -

-
- -
-
- )} -

- {section.indicators.map((i) => i.heading).join(" · ")} -

- - View {section.indicators.length}{" "} - {section.indicators.length === 1 ? "chart" : "charts"}{" "} - → - - - ); - })} + {section.indicators.map((indicator) => ( + + ))} +
+ )} -
+ {section.planned && section.planned.length > 0 && ( +
+

In the works

+
    + {section.planned.map((planned) => ( +
  • + {planned.heading} +
  • + ))} +
+
+ )} +
+ ))} -
+

Missing a measure that matters?

From 066aecc405a56901c46c60e575f4605c5bbfbdf4 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Wed, 15 Jul 2026 09:48:21 -0400 Subject: [PATCH 02/12] wip --- docs/STATE_OF_THE_NATION_DATA_SPEC.md | 278 +++++++ .../CombinedSectionChart.tsx | 10 +- .../prosperity-dashboard/IndicatorChart.tsx | 10 +- src/app/prosperity-dashboard/JoinForm.tsx | 67 ++ src/app/prosperity-dashboard/StateChart.tsx | 291 ++++++++ .../prosperity-dashboard/[section]/page.tsx | 55 +- src/app/prosperity-dashboard/dashboard.ts | 165 +++-- src/app/prosperity-dashboard/indicators.ts | 32 +- .../prosperity-dashboard/opengraph-image.tsx | 18 +- src/app/prosperity-dashboard/page.tsx | 327 +++++---- .../state-of-the-nation.ts | 677 ++++++++++++++++++ src/app/prosperity-dashboard/units.ts | 25 + src/lib/api/economy.ts | 48 +- 13 files changed, 1775 insertions(+), 228 deletions(-) create mode 100644 docs/STATE_OF_THE_NATION_DATA_SPEC.md create mode 100644 src/app/prosperity-dashboard/JoinForm.tsx create mode 100644 src/app/prosperity-dashboard/StateChart.tsx create mode 100644 src/app/prosperity-dashboard/state-of-the-nation.ts diff --git a/docs/STATE_OF_THE_NATION_DATA_SPEC.md b/docs/STATE_OF_THE_NATION_DATA_SPEC.md new file mode 100644 index 0000000..1338228 --- /dev/null +++ b/docs/STATE_OF_THE_NATION_DATA_SPEC.md @@ -0,0 +1,278 @@ +# State of the Nation — new measures spec + +york_factory (branch `mikaal/state-of-the-nation-additions`) now serves 34 new +measures covering employment, wages, business formation, investment, government +debt, and immigration. This doc is everything the frontend needs to chart them. + +All measures are served by the existing endpoint: + +``` +GET /api/v1/kpis/series?measure= +GET /api/v1/kpis/series?measure=&from=2000&to=2026 +GET /api/v1/kpis/series?measure=&jurisdictions=ca +``` + +## API change: quarterly series + +Until now `points` came in two shapes: annual `{ year, value }` and monthly +`{ date, value }`. There is now a third measure frequency, `quarterly`, which +serializes **the same way as monthly**: `{ date, value }`, where `date` is the +first day of the quarter (`"2026-01-01"` = Q1 2026). + +Switch on `data.measure.frequency`: + +| `measure.frequency` | point shape | example | +|---|---|---| +| `annual` | `{ year, value }` | `{ "year": 2024, "value": 435421 }` | +| `monthly` | `{ date, value }` | `{ "date": "2026-06-01", "value": 60.8 }` | +| `quarterly` | `{ date, value }` | `{ "date": "2026-01-01", "value": 20133.0 }` | + +If your chart code currently assumes `points[0].date != null` implies monthly +spacing, update it — quarterly points are 3 months apart. Anything keyed off +`frequency` rather than point shape needs the new `"quarterly"` value handled. + +Everything else is unchanged: `meta.source` carries `name`, `url`, +`last_fetched_at`, `license`, `attribution`. **Attribution must be displayed** +for all of these (Statistics Canada Open Licence; IRCC is Open Government +Licence – Canada) — same treatment as the existing OWID/StatCan charts. + +All new series are **Canada-only** (single series, jurisdiction slug `ca`) +except `capital-formation-pct-gdp`, which has the full G7 + OECD + computed-G7 +jurisdiction set like the other World Bank measures. + +There is also a new measure `category` value, `population`, alongside the +existing `economy` / `welfare` / etc. + +--- + +## 1. Employment by age group + +Monthly, seasonally adjusted, percent, ~1993→present. Source: StatCan LFS +table 14-10-0287. All four share unit and frequency, so they work as a +combined overlay chart. + +| slug | series | +|---|---| +| `employment-rate-15-plus` | Employment rate, 15 years and over | +| `employment-rate-15-to-24` | Employment rate, 15–24 | +| `employment-rate-25-to-54` | Employment rate, 25–54 (core working age) | +| `employment-rate-55-to-64` | Employment rate, 55–64 | + +Note: the existing annual `employment-rate` (World Bank, G7 comparison) is a +different measure and stays as-is; these are the Canadian monthly detail. + +## 2. Private vs public employment + +Monthly, seasonally adjusted, **thousands of persons**, ~1993→present. +Source: StatCan LFS table 14-10-0288. + +| slug | series | +|---|---| +| `employment-all-classes` | Total employed, all classes | +| `employment-public-sector` | Public sector employees | +| `employment-private-sector` | Private sector employees | +| `employment-self-employed` | Self-employed | + +**Shares are not stored** — compute client-side, e.g. public share = +`employment-public-sector / employment-all-classes`. The three components sum +to the total (within rounding), so a 100%-stacked area chart works. + +## 3. Wages + +Annual, current dollars per hour, 1997→present. Source: StatCan LFS table +14-10-0064 (all employees, all industries, full- and part-time). + +| slug | series | +|---|---| +| `average-hourly-wage` | Average hourly wage rate | +| `median-hourly-wage` | Median hourly wage rate | + +**The requested 10th/90th percentile series do not exist** in StatCan's public +API — verified against every LFS wage table and the full cube catalog. They +would require LFS microdata or a custom tabulation. Average vs median is the +closest available dispersion signal (the gap widening = top-heavy wage growth). +Copy for this chart should not promise percentiles. + +## 4. New business formation + +Monthly, seasonally adjusted, counts of businesses, 2015→present. Source: +StatCan table 33-10-0270 (business sector industries). + +| slug | series | higher_is_bad | +|---|---|---| +| `business-entrants` | Businesses appearing for the first time (true new-business formation) | false | +| `business-exits` | Businesses permanently ceasing operations | true | +| `active-businesses` | Businesses with 1+ employees | false | + +Entrants/exits measure genuine business creation and death — unlike the same +table's "openings/closings", which count any month a business crosses 0↔1+ +employees (including seasonal reopenings). StatCan's dedicated entry/exit +table (33-10-0165, with entry/exit *rates*) stopped publishing at 2019 Q4, so +this monthly table is the live source. + +Two footnotes for the chart: StatCan labels these **experimental estimates**, +and **`business-exits` runs ~6 months behind `business-entrants`** (an exit is +only confirmed once the business stays closed) — expect the exits line to end +earlier than the entrants line, and don't render the gap as a decline. Net +formation (entrants − exits) can be derived client-side where both months +exist. + +## 5. Investment flows (FDI) + +Quarterly, millions of CAD (total net flows), 2007→present. Source: StatCan +table 36-10-0025 (balance of payments). + +| slug | series | +|---|---| +| `fdi-inflows` | Foreign direct investment **into** Canada | +| `fdi-outflows` | Canadian direct investment **abroad** | + +These are flows, not stocks; values can be negative in principle +(disinvestment). "Outflows" is not bad per se — it's Canadian firms investing +abroad — so avoid red/green framing. + +## 6. Capital formation + +| slug | frequency | unit | coverage | +|---|---|---|---| +| `gross-fixed-capital-formation` | quarterly | chained 2017 $M, SAAR | Canada, 1961→ | +| `business-gross-fixed-capital-formation` | quarterly | chained 2017 $M, SAAR | Canada, 1961→ | +| `capital-formation-pct-gdp` | annual | % of GDP | **G7 comparison** (World Bank) | + +The two StatCan series are levels (seasonally adjusted at annual rates); the +World Bank one is the cross-country comparison chart. Reasonable layout: +featured chart = `capital-formation-pct-gdp` (Canada vs G7), detail charts = +the quarterly Canadian levels. + +## 7. Government debt-to-GDP (all levels) + +Quarterly, percent of GDP, 1990→present, `higher_is_bad: true`. Source: +StatCan table 38-10-0237 — general government consolidated: federal + +provincial/territorial + local + CPP/QPP, national balance sheet accounts +basis. + +| slug | series | latest (Q1 2026) | +|---|---|---| +| `govt-gross-debt-to-gdp` | Gross debt to GDP | ~132% | +| `govt-net-debt-to-gdp` | Net financial liabilities to GDP | ~21% | + +The gross/net gap is large because net subtracts financial assets (including +CPP/QPP assets). Show both or label carefully — "net" here is much lower than +the federal-budget "net debt" figures people know from the news, because of +the NBSA market-value methodology. Don't average or mix them. + +## 8. Immigration & population (new `population` category) + +### Components of population growth — annual, persons, 1971→present + +Source: StatCan table 17-10-0008. **Reference years run July 1–June 30**: the +point `year: 2024` covers July 2023–June 2024. Label as "July–June year" or +"demographic year" to avoid mismatch with calendar-year IRCC numbers. + +| slug | series | +|---|---| +| `immigrants-annual` | Immigrants (PR admissions) | +| `emigrants-annual` | Emigrants | +| `returning-emigrants-annual` | Returning emigrants | +| `net-non-permanent-residents-annual` | Net change in non-permanent residents (can be negative) | + +**Net emigration is derived client-side** as +`emigrants-annual − returning-emigrants-annual`. (StatCan's own "net temporary +emigration" series died in a 2016 methodology change and is not served.) + +### Non-permanent residents by type — quarterly, persons, 2021 Q3→present + +Source: StatCan table 17-10-0121. These are **stocks** (how many people, as of +the first day of the quarter), not flows. Short history — charts will only +have ~20 points; suppress any "20-year trend" framing. + +| slug | series | +|---|---| +| `npr-total` | All non-permanent residents | +| `npr-asylum-claimants` | Asylum claimants, protected persons and related | +| `npr-work-permit-holders` | Work permit holders only — **closest proxy for "temporary foreign workers"** | +| `npr-study-permit-holders` | Study permit holders only | +| `npr-work-and-study-permit-holders` | Holding both permits | + +There is no series literally called "temporary foreign workers" (the TFWP/IMP +program split isn't in StatCan tables); if the chart is titled "Temporary +foreign workers", footnote that it shows work-permit holders. + +### PR admissions by immigration class — monthly, persons, 2015→present + +Source: IRCC Permanent Residents Monthly Updates (~2-month publication lag). + +| slug | series | +|---|---| +| `pr-admissions-total` | All categories | +| `pr-admissions-economic` | Economic (worker programs, PNP, business, TR-to-PR) | +| `pr-admissions-family` | Sponsored family | +| `pr-admissions-refugee` | Resettled refugees & protected persons | +| `pr-admissions-other` | Humanitarian & compassionate, public policy, permit holders | + +The four categories stack to the total → good stacked-area candidate. +**Data caveat for the footnote:** IRCC suppresses cells under 6 and rounds +everything to the nearest 5, so values are approximate and won't exactly match +StatCan's `immigrants-annual` (which also uses July–June years). "Student" is +not a PR class — students appear in `npr-study-permit-holders` above. + +--- + +## Suggested section mapping + +Against the existing `SECTIONS` ids in +`src/app/prosperity-dashboard/indicators.ts`: + +- `economy` → business formation (3), FDI (2), capital formation (3), + debt-to-GDP (2) +- `welfare` → employment by age (4), class of worker (4), wages (2) +- **new section** (suggest `id: "population"`, title "Population & + Immigration") → components (4), NPR (5), PR admissions (5) + +Combined-overlay candidates (same unit + frequency within group): employment +rates by age; class-of-worker levels; NPR types; PR admission categories; +gross vs net debt-to-GDP; average vs median wage; entrants vs exits. + +## Quick reference — all 34 slugs + +``` +employment-rate-15-plus monthly % +employment-rate-15-to-24 monthly % +employment-rate-25-to-54 monthly % +employment-rate-55-to-64 monthly % +employment-all-classes monthly thousands +employment-public-sector monthly thousands +employment-private-sector monthly thousands +employment-self-employed monthly thousands +average-hourly-wage annual $/hour +median-hourly-wage annual $/hour +business-entrants monthly count +business-exits monthly count (lags ~6 months) +active-businesses monthly count +fdi-inflows quarterly $M +fdi-outflows quarterly $M +gross-fixed-capital-formation quarterly $M (chained 2017, SAAR) +business-gross-fixed-capital-formation quarterly $M (chained 2017, SAAR) +capital-formation-pct-gdp annual % of GDP (G7 comparison) +govt-gross-debt-to-gdp quarterly % +govt-net-debt-to-gdp quarterly % +immigrants-annual annual persons (July–June year) +emigrants-annual annual persons (July–June year) +returning-emigrants-annual annual persons (July–June year) +net-non-permanent-residents-annual annual persons (July–June year) +npr-total quarterly persons (stock) +npr-asylum-claimants quarterly persons (stock) +npr-work-permit-holders quarterly persons (stock) +npr-study-permit-holders quarterly persons (stock) +npr-work-and-study-permit-holders quarterly persons (stock) +pr-admissions-total monthly persons +pr-admissions-economic monthly persons +pr-admissions-family monthly persons +pr-admissions-refugee monthly persons +pr-admissions-other monthly persons +``` + +To develop against this locally: run york_factory (`bin/rails server`), point +`YORK_FACTORY_API_URL=http://localhost:3000/api/v1` in `.env.local`, and the +data is already loaded. Example: +`curl "http://localhost:3000/api/v1/kpis/series?measure=pr-admissions-economic"`. diff --git a/src/app/prosperity-dashboard/CombinedSectionChart.tsx b/src/app/prosperity-dashboard/CombinedSectionChart.tsx index 4967d8a..eab392d 100644 --- a/src/app/prosperity-dashboard/CombinedSectionChart.tsx +++ b/src/app/prosperity-dashboard/CombinedSectionChart.tsx @@ -41,7 +41,9 @@ function buildGrapherState( ): GrapherState | null { const first = items[0]?.response; if (!first) return null; - const monthly = first.data.measure.frequency === "monthly"; + // Monthly and quarterly points both carry ISO dates and use Grapher's + // day-based time axis; annual points stay on integer years. + const dated = first.data.measure.frequency !== "annual"; const { source } = first.meta; const itemSeries = items.map( @@ -54,7 +56,7 @@ function buildGrapherState( const series = itemSeries[idx]; if (!series) return []; return series.points.map((p) => ({ - year: monthly && p.date ? daysSinceGrapherEpoch(p.date) : p.year, + year: dated && p.date ? daysSinceGrapherEpoch(p.date) : p.year, entity: { id: idx + 1, code: item.label, name: item.label }, value: p.value, })); @@ -74,7 +76,7 @@ function buildGrapherState( }; data.push( ...longest.points.map((p) => ({ - year: monthly && p.date ? daysSinceGrapherEpoch(p.date) : p.year, + year: dated && p.date ? daysSinceGrapherEpoch(p.date) : p.year, entity, value: benchmarkValue(benchmark, p.year), })), @@ -89,7 +91,7 @@ function buildGrapherState( display: { name: heading, ...displayUnit(first.data.measure.unit), - ...(monthly ? { yearIsDay: true } : {}), + ...(dated ? { yearIsDay: true } : {}), }, origins: source ? [ diff --git a/src/app/prosperity-dashboard/IndicatorChart.tsx b/src/app/prosperity-dashboard/IndicatorChart.tsx index c491fb7..e8beb5f 100644 --- a/src/app/prosperity-dashboard/IndicatorChart.tsx +++ b/src/app/prosperity-dashboard/IndicatorChart.tsx @@ -54,11 +54,13 @@ function buildGrapherState( ): GrapherState | null { const { measure, series } = response.data; const { source } = response.meta; - const monthly = measure.frequency === "monthly"; + // Monthly and quarterly points both carry ISO dates and use Grapher's + // day-based time axis; annual points stay on integer years. + const dated = measure.frequency !== "annual"; const data = series.flatMap((s, idx) => s.points.map((p) => ({ - year: monthly && p.date ? daysSinceGrapherEpoch(p.date) : p.year, + year: dated && p.date ? daysSinceGrapherEpoch(p.date) : p.year, entity: { id: idx + 1, code: s.jurisdiction.code, name: s.jurisdiction.name }, value: p.value, })), @@ -78,7 +80,7 @@ function buildGrapherState( }; data.push( ...longest.points.map((p) => ({ - year: monthly && p.date ? daysSinceGrapherEpoch(p.date) : p.year, + year: dated && p.date ? daysSinceGrapherEpoch(p.date) : p.year, entity, value: benchmarkValue(benchmark, p.year), })), @@ -93,7 +95,7 @@ function buildGrapherState( display: { name: measure.name, ...displayUnit(measure.unit), - ...(monthly ? { yearIsDay: true } : {}), + ...(dated ? { yearIsDay: true } : {}), }, origins: source ? [ diff --git a/src/app/prosperity-dashboard/JoinForm.tsx b/src/app/prosperity-dashboard/JoinForm.tsx new file mode 100644 index 0000000..3ef130c --- /dev/null +++ b/src/app/prosperity-dashboard/JoinForm.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import posthog from "posthog-js"; + +// The State of the Nation design's email-only join form, posting to the same +// /api/subscribe endpoint as SubscribeForm (name and postal code are optional +// there). +export default function JoinForm() { + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [joined, setJoined] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const res = await fetch("/api/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, source: "state-of-the-nation" }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || "Something went wrong"); + return; + } + posthog.identify(email, { email }); + posthog.capture("subscribed", { source: "state-of-the-nation" }); + setJoined(true); + } catch { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( + <> +
+ setEmail(e.target.value)} + className="flex-1 min-w-[240px] bg-bg border-2 border-bg text-dark font-body text-[1.05rem] px-[18px] py-3.5 outline-none placeholder:text-[#8a8178]" + /> + +
+ {error && ( +

{error}

+ )} + + ); +} diff --git a/src/app/prosperity-dashboard/StateChart.tsx b/src/app/prosperity-dashboard/StateChart.tsx new file mode 100644 index 0000000..e74b26e --- /dev/null +++ b/src/app/prosperity-dashboard/StateChart.tsx @@ -0,0 +1,291 @@ +// SVG charts for the State of the Nation page — a direct translation of the +// claude.ai/design "State of the Nation.dc.html" line and bar charts. +// Server-rendered, no client JS. The warm greys (#E3D9CE grid, #6f6a63 +// labels) are the design's own values and have no site token; ink and +// auburn map to the site palette. + +export type ChartFmt = "money" | "pct" | "pct1" | "x" | "index" | "count"; + +// au = brand accent for the headline series; ink = comparison (usually +// dashed); clay/stone/sand = warm muted tones for additional series. +export type SeriesColor = "au" | "ink" | "clay" | "stone" | "sand"; + +export type LegendItem = { label: string; color: SeriesColor; dash?: boolean }; + +export type LineSpec = { + kind: "line"; + unit: string; + fmt: ChartFmt; + // Tick labels for the first, middle, and last x positions. + xLabels: [string, string, string]; + legend?: LegendItem[]; + // Series must share the same start and cadence but may differ in length — + // a shorter series (e.g. business exits, confirmed ~6 months late) simply + // stops early instead of being stretched or clipping the others. + series: { color: SeriesColor; dash?: boolean; points: number[] }[]; +}; + +export type BarSpec = { + kind: "bar"; + unit: string; + fmt: ChartFmt; + bars: { label: string; value: number; accent?: boolean }[]; +}; + +export type ChartSpec = LineSpec | BarSpec; + +const INK = "var(--color-dark)"; +const AU = "var(--color-auburn-800)"; +const GRID = "#E3D9CE"; +const GRAY = "#6f6a63"; +const MONO = "var(--font-label)"; + +const SERIES_COLORS: Record = { + au: AU, + ink: INK, + clay: "#c2724d", + stone: "#8a8178", + sand: "#c7bdb2", +}; + +function fmt(v: number, f: ChartFmt): string { + if (f === "money") return "$" + Math.round(v).toLocaleString("en-CA"); + if (f === "pct") return Math.round(v) + "%"; + if (f === "pct1") return v.toFixed(1) + "%"; + if (f === "x") return v.toFixed(1) + "×"; + if (f === "index") return String(Math.round(v)); + return Math.round(v).toLocaleString("en-CA"); +} + +function LineChart({ spec, wide }: { spec: LineSpec; wide?: boolean }) { + // Full-width (wide) charts get a panoramic viewBox so the SVG renders at + // roughly 1:1 scale instead of stretching the half-column geometry — which + // blew the type and strokes up ~2× on desktop. + const W = wide ? 1240 : 600, + H = wide ? 420 : 320, + L = 8, + R = 62, + T = 30, + B = 46; + const iw = W - L - R, + ih = H - T - B; + const all = spec.series.flatMap((s) => s.points); + let mx = Math.max(...all), + mn = Math.min(...all); + const pad = (mx - mn) * 0.14 || 1; + mx += pad; + mn -= pad; + const n = Math.max(...spec.series.map((s) => s.points.length)); + const X = (i: number) => L + iw * (i / (n - 1)); + const Y = (v: number) => T + ih * (1 - (v - mn) / (mx - mn)); + const baseY = T + ih; + const gridlines = [0, 1, 2, 3, 4]; + const ticks = [0, Math.floor((n - 1) / 2), n - 1]; + + return ( + + {gridlines.map((g) => { + const gy = T + (ih * g) / 4; + const gv = mx - ((mx - mn) * g) / 4; + return ( + + + + {fmt(gv, spec.fmt)} + + + ); + })} + {spec.xLabels.map((label, k) => ( + + {label} + + ))} + {spec.series.map((s, si) => { + const col = SERIES_COLORS[s.color]; + const end = s.points.length - 1; + const pts = s.points.map((v, i) => `${X(i)},${Y(v)}`).join(" "); + const area = + si === 0 + ? `M${X(0)},${Y(s.points[0])} ` + + s.points.map((v, i) => `L${X(i)},${Y(v)}`).join(" ") + + ` L${X(end)},${baseY} L${X(0)},${baseY} Z` + : null; + return ( + + {area && } + + + + ); + })} + + ); +} + +function BarChart({ spec }: { spec: BarSpec }) { + const W = 600, + H = 320, + L = 8, + R = 8, + T = 30, + B = 52; + const iw = W - L - R, + ih = H - T - B; + const bars = spec.bars; + const mx = Math.max(...bars.map((b) => b.value)) * 1.12; + const baseY = T + ih; + const slot = iw / bars.length; + const bw = Math.min(slot * 0.56, 64); + + return ( + + {[0, 1, 2, 3, 4].map((g) => ( + + ))} + {bars.map((b, i) => { + const cx = L + slot * (i + 0.5); + const bh = (b.value / mx) * ih; + return ( + + + + {fmt(b.value, spec.fmt)} + + + {b.label.toUpperCase()} + + + ); + })} + + ); +} + +export default function StateChart({ + spec, + wide, +}: { + spec: ChartSpec; + wide?: boolean; +}) { + return ( +
+ {/* Same treatment as the section headline on the left column. */} +
+ {spec.unit} +
+ {spec.kind === "line" && spec.legend && ( +
+ {spec.legend.map((lg) => ( +
+ + + {lg.label} + +
+ ))} +
+ )} + {spec.kind === "line" ? ( + wide ? ( + // The panoramic viewBox only reads at full-column width; phones + // get the standard geometry. + <> +
+ +
+
+ +
+ + ) : ( + + ) + ) : ( + + )} +
+ ); +} diff --git a/src/app/prosperity-dashboard/[section]/page.tsx b/src/app/prosperity-dashboard/[section]/page.tsx index 920821d..0f6ea29 100644 --- a/src/app/prosperity-dashboard/[section]/page.tsx +++ b/src/app/prosperity-dashboard/[section]/page.tsx @@ -107,24 +107,34 @@ export default async function IndicatorSectionPage({ params }: PageProps) { ), ); - const combinedItems = section.combined - ? section.indicators.flatMap((indicator, i) => { - const response = results[i]; - return response - ? [{ label: indicator.chartLabel ?? indicator.heading, response }] - : []; - }) - : []; + // Each combined chart overlays a subset of the section's indicators + // (defaulting to all of them), in the order its slugs list them. + const combinedCharts = (section.combined ?? []).flatMap((chart) => { + const slugs = chart.slugs ?? section.indicators.map((i) => i.slug); + const items = slugs.flatMap((slug) => { + const index = section.indicators.findIndex((i) => i.slug === slug); + const indicator = section.indicators[index]; + const response = index >= 0 ? results[index] : null; + return indicator && response + ? [{ label: indicator.chartLabel ?? indicator.heading, response }] + : []; + }); + return items.length > 0 ? [{ ...chart, items }] : []; + }); const signpostHeadings = [ - ...(section.combined && combinedItems.length > 0 - ? [{ id: "combined", text: section.combined.heading, level: 2 as const }] - : []), - ...section.indicators.map((indicator) => ({ - id: indicator.slug, - text: indicator.heading, + ...combinedCharts.map((chart) => ({ + id: chart.id, + text: chart.heading, level: 2 as const, })), + ...section.indicators + .filter((indicator) => !indicator.combinedOnly) + .map((indicator) => ({ + id: indicator.slug, + text: indicator.heading, + level: 2 as const, + })), ]; return ( @@ -151,22 +161,23 @@ export default async function IndicatorSectionPage({ params }: PageProps) { showTopBorder={false} />
- {section.combined && combinedItems.length > 0 && ( -
- + {combinedCharts.map((chart) => ( +
+

- {section.combined.blurb} + {chart.blurb}

- +
- )} + ))} {section.indicators.map((indicator, i) => { + if (indicator.combinedOnly) return null; const response = results[i]; return (
s.indicators); + +// Every measure the dashboard fetches — one per single chart, several per +// multi-series chart. +export const DASHBOARD_MEASURE_SLUGS: string[] = Array.from( + new Set( + DASHBOARD_INDICATORS.flatMap( + (indicator) => indicator.series?.map((s) => s.slug) ?? [indicator.slug], + ), + ), +); diff --git a/src/app/prosperity-dashboard/indicators.ts b/src/app/prosperity-dashboard/indicators.ts index 3cb95a0..ac549b2 100644 --- a/src/app/prosperity-dashboard/indicators.ts +++ b/src/app/prosperity-dashboard/indicators.ts @@ -8,6 +8,19 @@ export type Indicator = { blurb: string; // Short line label used when the indicator appears in a combined chart. chartLabel?: string; + // Renders only as a line in one of the section's combined charts, with no + // standalone chart of its own (still fetched, and still a canvas feed). + combinedOnly?: boolean; +}; +// An overlay chart of several of the section's indicators. Every slug listed +// must share the same unit and frequency; the first slug renders emphasized +// in brand red. Omit slugs to overlay the whole section. +export type CombinedChart = { + // DOM id — the chart's #anchor on the section page. + id: string; + heading: string; + blurb: string; + slugs?: string[]; }; // Reference line drawn on every chart in a section (including the combined // chart): a value compounding at annualRatePct per year through @@ -24,10 +37,8 @@ export type IndicatorSection = { description: string; // The section's most striking chart, previewed on the landing-page card. featuredSlug: string; - // Renders one overlay chart of every indicator in the section above the - // individual charts. Only for sections whose indicators all share the - // same unit and frequency. - combined?: { heading: string; blurb: string }; + // Overlay charts rendered above the section's individual charts. + combined?: CombinedChart[]; benchmark?: IndicatorBenchmark; indicators: Indicator[]; }; @@ -92,11 +103,14 @@ export const SECTIONS: IndicatorSection[] = [ description: "What Canadians actually pay for the essentials — monthly consumer prices for food, shelter, and getting around (2002 = 100).", featuredSlug: "cpi-all-items", - combined: { - heading: "The essentials, side by side", - blurb: - "Every cost-of-living component on one chart, indexed to 2002 = 100. The grey line is where prices would sit had they grown at the Bank of Canada's 2% inflation target — everything above it has outrun the target.", - }, + combined: [ + { + id: "combined", + heading: "The essentials, side by side", + blurb: + "Every cost-of-living component on one chart, indexed to 2002 = 100. The grey line is where prices would sit had they grown at the Bank of Canada's 2% inflation target — everything above it has outrun the target.", + }, + ], // Bank of Canada inflation-control target (2% midpoint), compounded // through the CPI's 2002 = 100 index base. benchmark: { diff --git a/src/app/prosperity-dashboard/opengraph-image.tsx b/src/app/prosperity-dashboard/opengraph-image.tsx index 1d3fbe1..af52133 100644 --- a/src/app/prosperity-dashboard/opengraph-image.tsx +++ b/src/app/prosperity-dashboard/opengraph-image.tsx @@ -1,6 +1,5 @@ import { ImageResponse } from "next/og"; import { getEconomicSeries, humanizeSourceName } from "@/lib/api/economy"; -import { indicatorHeading } from "./indicators"; import { buildOgChart, IndicatorOgCard, @@ -11,7 +10,7 @@ import { export const runtime = "nodejs"; export const alt = - "Build Canada — Prosperity Dashboard: Canadian prosperity, measured against the G7 and OECD"; + "Build Canada — Prosperity Dashboard: Canadian prosperity, measured"; export const size = OG_SIZE; @@ -19,17 +18,22 @@ export const contentType = "image/png"; export const revalidate = 3600; -// The landing card leads with the dashboard's clearest single chart. -const FEATURED_SLUG = "gdp-per-capita-ppp"; +// The landing card leads with the dashboard's clearest single chart — +// StatCan's quarterly real GDP per capita, matching the page's headline. +const FEATURED_SLUG = "gdp-per-capita-canada"; +const CHART_HEADING = "GDP per capita"; const TITLE = "Prosperity Dashboard"; const DESCRIPTION = - "Are we moving in the right direction? Canadian prosperity, measured against the G7 and OECD."; + "Are we moving in the right direction? Canadian prosperity, measured."; export default async function OpengraphImage() { - const response = await getEconomicSeries(FEATURED_SLUG).catch(() => null); + // Canada only, matching the dashboard's charts. + const response = await getEconomicSeries(FEATURED_SLUG, { + jurisdictions: "ca", + }).catch(() => null); const chart = response ? buildOgChart(response) : null; - const chartHeading = indicatorHeading(FEATURED_SLUG); + const chartHeading = CHART_HEADING; const source = response?.meta.source; const footnote = source ? `Source: ${humanizeSourceName(source.name)}` : ""; diff --git a/src/app/prosperity-dashboard/page.tsx b/src/app/prosperity-dashboard/page.tsx index ff7ec9e..691b276 100644 --- a/src/app/prosperity-dashboard/page.tsx +++ b/src/app/prosperity-dashboard/page.tsx @@ -1,119 +1,144 @@ -import "@buildcanada/charts/styles.css"; - import type { Metadata } from "next"; import { getSiteConfig } from "@/lib/api"; import { getEconomicSeries, 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 DashboardNav from "./DashboardNav"; -import SourceLine from "./SourceLine"; +import StateChart from "./StateChart"; +import JoinForm from "./JoinForm"; import { - DASHBOARD_INDICATORS, - DASHBOARD_SECTIONS, - type DashboardIndicator, -} from "./dashboard"; + buildSections, + SOTN_INDICATOR_COUNT, + SOTN_MEASURE_SLUGS, + type SotnView, +} from "./state-of-the-nation"; const DESCRIPTION = - "Are we moving in the right direction? Canada's growth, jobs, cost of living, and immigration on one page, measured against the G7 and OECD."; + "Sixteen indicators, read honestly. Where Canada leads, where we lag, and where the picture is genuinely mixed — measured against our own record."; export const metadata: Metadata = { - title: "Prosperity Dashboard", + title: "State of the Nation", description: DESCRIPTION, alternates: { canonical: "/prosperity-dashboard" }, openGraph: { - title: "Prosperity Dashboard", + title: "State of the Nation", description: DESCRIPTION, type: "website", }, twitter: { card: "summary_large_image", - title: "Prosperity Dashboard", + title: "State of the Nation", }, }; -// Anchor targets must clear the sticky navbar + DashboardNav stack, whose -// height grows as the nav links wrap on narrower screens. -const SECTION_SCROLL_MT = "scroll-mt-[220px] sm:scroll-mt-[160px]"; +// Warm grey used by the design for secondary mono text; no site token. +const GRAY = "#6f6a63"; +const BD = "#CDC4BD"; -// Chart headings link to their own anchor so a reader can grab a URL -// straight to one chart. -function AnchoredHeading({ id, text }: { id: string; text: string }) { - return ( -

- - {text} - - -

- ); +function lastUpdatedLabel( + responses: (EconomySeriesResponse | null)[], +): string | null { + const dates = responses + .map((r) => r?.meta.source?.last_fetched_at) + .filter((d): d is string => !!d) + .sort(); + const latest = dates[dates.length - 1]; + if (!latest) return null; + return new Intl.DateTimeFormat("en-CA", { + month: "long", + year: "numeric", + }).format(new Date(latest)); } -function UnavailablePanel() { - return ( -
-

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

-
- ); -} +const VERDICT_BADGE = { + lead: { + label: "Where we lead", + className: "bg-transparent border-dark text-dark", + }, + lag: { + label: "Where we lag", + className: "bg-auburn-800 border-auburn-800 text-bg", + }, + mixed: { + label: "Mixed", + className: "bg-transparent border-[#CDC4BD] text-[#6f6a63]", + }, +} as const; -function ChartCard({ +// A chart card: meta row, chart title + chart, and the source attribution +// the data licences require. Cards tile 2×2 on desktop within their section, +// ruled by the design's warm hairlines; `wide` cards span both columns +// (the headline chart). +function IndicatorCard({ indicator, - response, + wide, }: { - indicator: DashboardIndicator; - response: EconomySeriesResponse | null; + indicator: SotnView; + wide?: boolean; }) { + const badge = VERDICT_BADGE[indicator.verdict]; return ( -
- -

- {indicator.blurb} -

- {response ? ( - <> - - - - ) : ( - - )} -
+
+
+ + {indicator.n} + + + {indicator.title} + + + {badge.label} + +
+ +
+ Source · {indicator.source} +
+
); } -export default async function ProsperityDashboardPage() { +export default async function StateOfTheNationPage() { const configData = getSiteConfig(); + // Canada only — this page reads Canada against its own record. const results = await Promise.all( - DASHBOARD_INDICATORS.map((indicator) => - getEconomicSeries(indicator.slug).catch(() => null), + SOTN_MEASURE_SLUGS.map((slug) => + getEconomicSeries(slug, { jurisdictions: "ca" }).catch(() => null), ), ); const responseBySlug = new Map( - DASHBOARD_INDICATORS.map((indicator, i) => [indicator.slug, results[i]]), + SOTN_MEASURE_SLUGS.map((slug, i) => [slug, results[i]]), ); + const sections = buildSections((slug) => responseBySlug.get(slug) ?? null); + const indicators = sections.flatMap((section) => section.indicators); + const leadCount = indicators.filter((m) => m.verdict === "lead").length; + const lagCount = indicators.filter((m) => m.verdict === "lag").length; + const updated = lastUpdatedLabel(results); + const jsonLd = buildGraph( generateOrganizationSchema(configData), generateBreadcrumbSchema( "/prosperity-dashboard", - "Prosperity Dashboard", + "State of the Nation", configData.siteUrl, ), ); @@ -124,76 +149,110 @@ export default async function ProsperityDashboardPage() { type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> - - +
+
+ State of the Nation · 2026 +
+

+ Canada is not a poor country. +
+ It is a country underperforming its potential. +

+

+ Sixteen indicators, read honestly. Where we lead, where we lag, and + where the picture is genuinely mixed — Canada measured against its + own record. The numbers below are the starting point for every plan + we publish. +

+
+
+
+
+ {leadCount} +
+
+ Areas we lead +
+
+
+
+ {lagCount} +
+
+ Areas we lag +
+
+
+
+ {SOTN_INDICATOR_COUNT} indicators tracked +
+ All figures from live sources + {updated && ( + <> +
+ Last updated · {updated} + + )} +
+
+
-
-
- {DASHBOARD_SECTIONS.map((section) => ( -
+ {sections.map((section) => ( +
+
-

{section.title}

- {section.description && ( -

- {section.description} -

- )} - - {section.indicators.length > 0 && ( -
- {section.indicators.map((indicator) => ( - - ))} -
- )} +

+ {section.title} +

+
+
+ {section.indicators.map((indicator) => ( + + ))} +
+
+ ))} +
- {section.planned && section.planned.length > 0 && ( -
-

In the works

-
    - {section.planned.map((planned) => ( -
  • - {planned.heading} -
  • - ))} -
-
- )} -
- ))} - -
-

- Missing a measure that matters? -

-

- We’re expanding this dashboard. If there’s an - indicator that would sharpen the picture — for better or worse - — tell us and we’ll track it. -

- - Email us - +
+
+
+ Turn the numbers into plans +
+

+ The state of the nation is a choice. Help us change it. +

+

+ We publish memos on behalf of Canada’s top builders — + concrete plans to close the gaps above. Be first to know + what’s possible. +

+ +
+ No spam. Unsubscribe anytime.
diff --git a/src/app/prosperity-dashboard/state-of-the-nation.ts b/src/app/prosperity-dashboard/state-of-the-nation.ts new file mode 100644 index 0000000..af2b281 --- /dev/null +++ b/src/app/prosperity-dashboard/state-of-the-nation.ts @@ -0,0 +1,677 @@ +// The State of the Nation indicators, in the design's layout with the +// dashboard's real chart order and contents: +// Headline — GDP per capita +// Economy — employment by age, business formation, employment, wages, +// investment flows, capital formation +// Government Sustainability — debt to GDP, private vs public employment +// Cost of Living — CPI, house price to income, housing starts +// Immigration — net emigration, immigration, TFWs, immigration by class +// Every chart derives its stat and series from york_factory at request time; +// a chart whose data is unavailable is skipped rather than shown stale. + +import type { EconomySeriesPoint, EconomySeriesResponse } from "@/lib/api/economy"; +import type { ChartSpec, LineSpec } from "./StateChart"; + +export type Verdict = "lead" | "lag" | "mixed"; + +export type SotnView = { + n: string; + title: string; + verdict: Verdict; + stat: string; + statSub: string; + headline: string; + body: string; + source: string; + spec: ChartSpec; +}; + +type Getter = (slug: string) => EconomySeriesResponse | null; + +// All series are fetched Canada-only; the comparisons this page draws are +// against Canada's own record. +export const SOTN_MEASURE_SLUGS = [ + "gdp-per-capita-canada", + "employment-rate-25-to-54", + "employment-rate-15-to-24", + "employment-rate-55-to-64", + "employment-rate-15-plus", + "business-entrants", + "business-exits", + "employment-rate", + "average-hourly-wage", + "median-hourly-wage", + "fdi-inflows", + "fdi-outflows", + "capital-formation-pct-gdp", + "govt-gross-debt-to-gdp", + "govt-net-debt-to-gdp", + "employment-all-classes", + "employment-private-sector", + "employment-public-sector", + "employment-self-employed", + "cpi-all-items", + "house-price-to-income", + "housing-starts-canada", + "emigrants-annual", + "returning-emigrants-annual", + "immigrants-annual", + "npr-work-permit-holders", + "npr-total", + "pr-admissions-total", + "pr-admissions-economic", + "pr-admissions-family", + "pr-admissions-refugee", + "pr-admissions-other", +]; + +const MONTHS = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +function points(get: Getter, slug: string): EconomySeriesPoint[] | null { + const series = get(slug)?.data.series.find( + (s) => s.jurisdiction.slug === "ca", + ); + return series && series.points.length > 1 ? series.points : null; +} + +const last = (ps: EconomySeriesPoint[]) => ps[ps.length - 1]; + +const int = (v: number) => Math.round(v).toLocaleString("en-CA"); + +function monthLabel(p: EconomySeriesPoint): string { + if (!p.date) return String(p.year); + const [y, m] = p.date.split("-"); + return `${MONTHS[Number(m) - 1]} ${y}`; +} + +function quarterLabel(p: EconomySeriesPoint): string { + if (!p.date) return String(p.year); + const [y, m] = p.date.split("-"); + return `Q${Math.floor((Number(m) - 1) / 3) + 1} ${y}`; +} + +// First / middle / last tick labels: month-year for short dated series, +// plain years otherwise. +function xLabels(ps: EconomySeriesPoint[]): [string, string, string] { + const span = last(ps).year - ps[0].year; + const label = (p: EconomySeriesPoint) => + p.date && span < 8 ? monthLabel(p) : String(Math.floor(p.year)); + return [label(ps[0]), label(ps[Math.floor((ps.length - 1) / 2)]), label(last(ps))]; +} + +// Restrict several series to the time keys they all report, so every line in +// a combined chart has the same length. +function align( + seriesList: EconomySeriesPoint[][], +): { base: EconomySeriesPoint[]; values: number[][] } | null { + const key = (p: EconomySeriesPoint) => p.date ?? p.year; + const maps = seriesList.map((ps) => new Map(ps.map((p) => [key(p), p.value]))); + const base = seriesList[0].filter((p) => maps.every((m) => m.has(key(p)))); + if (base.length < 2) return null; + return { base, values: maps.map((m) => base.map((p) => m.get(key(p))!)) }; +} + +type SotnIndicator = { + n: string; + title: string; + verdict: Verdict; + headline: string; + body: string; + build: (get: Getter) => (Pick & { spec: ChartSpec }) | null; +}; + +const line = ( + spec: Omit, +): LineSpec => ({ kind: "line", ...spec }); + +const INDICATORS: SotnIndicator[] = [ + { + n: "01", + title: "Living standards", + verdict: "lag", + headline: "Living standards have gone sideways since 2022.", + body: "Real output per person, in chained 2017 dollars — the clearest single measure of whether living standards are rising. StatCan's quarterly series runs within about two months of the present, and it shows a country producing no more per person than it did four years ago.", + build: (get) => { + const ps = points(get, "gdp-per-capita-canada"); + if (!ps) return null; + const latest = last(ps); + return { + stat: `$${int(latest.value)}`, + statSub: `Real GDP per capita, chained 2017 $ · ${quarterLabel(latest)}`, + source: "Statistics Canada (table 36-10-0706)", + spec: line({ + unit: "Real GDP per capita, chained 2017 dollars", + fmt: "money", + xLabels: xLabels(ps), + legend: [{ label: "Canada", color: "au" }], + series: [{ color: "au", points: ps.map((p) => p.value) }], + }), + }; + }, + }, + { + n: "02", + title: "Employment by age", + verdict: "mixed", + headline: "Core-age employment is near record highs; the young trail behind.", + body: "Share of each age group that is employed, monthly and seasonally adjusted. The 25–54 core-working-age line is the cleanest read — it strips out students and retirees.", + build: (get) => { + const core = points(get, "employment-rate-25-to-54"); + const youth = points(get, "employment-rate-15-to-24"); + const older = points(get, "employment-rate-55-to-64"); + const all = points(get, "employment-rate-15-plus"); + if (!core || !youth || !older || !all) return null; + const aligned = align([core, youth, older, all]); + if (!aligned) return null; + const latest = last(core); + return { + stat: `${latest.value.toFixed(1)}%`, + statSub: `Employment rate, 25–54 · ${monthLabel(latest)}`, + source: "Statistics Canada (table 14-10-0287)", + spec: line({ + unit: "Employment rate by age group, % (seasonally adjusted)", + fmt: "pct", + xLabels: xLabels(aligned.base), + legend: [ + { label: "25–54", color: "au" }, + { label: "15–24", color: "clay" }, + { label: "55–64", color: "stone" }, + { label: "15+", color: "ink", dash: true }, + ], + series: [ + { color: "au", points: aligned.values[0] }, + { color: "clay", points: aligned.values[1] }, + { color: "stone", points: aligned.values[2] }, + { color: "ink", dash: true, points: aligned.values[3] }, + ], + }), + }; + }, + }, + { + n: "03", + title: "Business formation", + verdict: "lag", + headline: "More businesses now close for good than start.", + body: "Businesses appearing for the first time versus permanently ceasing operations, monthly and seasonally adjusted — true births and deaths, not seasonal reopenings. Experimental estimates (Statistics Canada); an exit is only confirmed once a business stays closed, so the exits line ends about six months before the entrants line.", + build: (get) => { + const entrants = points(get, "business-entrants"); + const exits = points(get, "business-exits"); + if (!entrants || !exits) return null; + // Exits lag entrants ~6 months: chart both full-length (the exits line + // simply stops early), and compute net formation only for the months + // where both exist. + const exitByKey = new Map(exits.map((p) => [p.date ?? p.year, p.value])); + const overlap = entrants.filter((p) => exitByKey.has(p.date ?? p.year)); + if (overlap.length === 0) return null; + const lastCommon = last(overlap); + const net = lastCommon.value - exitByKey.get(lastCommon.date ?? lastCommon.year)!; + return { + stat: `${net < 0 ? "−" : "+"}${int(Math.abs(net))}`, + statSub: `Net business formation (entrants − exits) · ${monthLabel(lastCommon)}`, + source: "Statistics Canada (table 33-10-0270)", + spec: line({ + unit: "Business entrants and exits per month", + fmt: "count", + xLabels: xLabels(entrants), + legend: [ + { label: "Entrants", color: "au" }, + { label: "Exits", color: "ink", dash: true }, + ], + series: [ + { color: "au", points: entrants.map((p) => p.value) }, + { color: "ink", dash: true, points: exits.map((p) => p.value) }, + ], + }), + }; + }, + }, + { + n: "04", + title: "Employment", + verdict: "lag", + headline: "The share of Canadians working has slipped for two straight years.", + body: "Share of the population aged 15 and over that is employed. The broadest gauge of whether people who could work, do.", + build: (get) => { + const ps = points(get, "employment-rate"); + if (!ps) return null; + const latest = last(ps); + return { + stat: `${latest.value.toFixed(1)}%`, + statSub: `Employment rate, 15+ · ${Math.floor(latest.year)}`, + source: "World Bank, World Development Indicators", + spec: line({ + unit: "Employment to population ratio, 15+, %", + fmt: "pct", + xLabels: xLabels(ps), + legend: [{ label: "Canada", color: "au" }], + series: [{ color: "au", points: ps.map((p) => p.value) }], + }), + }; + }, + }, + { + n: "05", + title: "Wage growth", + verdict: "mixed", + headline: "Average pay keeps pulling away from the median — gains tilt to the top.", + body: "Hourly wages for all employees, in current dollars (not adjusted for inflation). StatCan's public API carries no 10th or 90th percentile series, so average vs median is the closest available dispersion signal: when the average pulls away from the median, gains are concentrating at the top.", + build: (get) => { + const avg = points(get, "average-hourly-wage"); + const med = points(get, "median-hourly-wage"); + if (!avg || !med) return null; + const aligned = align([avg, med]); + if (!aligned) return null; + const latest = last(aligned.base); + return { + stat: `$${last(avg).value.toFixed(2)}`, + statSub: `Average hourly wage · ${Math.floor(latest.year)}`, + source: "Statistics Canada (table 14-10-0064)", + spec: line({ + unit: "Hourly wage, current dollars", + fmt: "money", + xLabels: xLabels(aligned.base), + legend: [ + { label: "Average", color: "au" }, + { label: "Median", color: "ink", dash: true }, + ], + series: [ + { color: "au", points: aligned.values[0] }, + { color: "ink", dash: true, points: aligned.values[1] }, + ], + }), + }; + }, + }, + { + n: "06", + title: "Investment flows", + verdict: "lag", + headline: "Canadian capital would rather invest abroad than at home.", + body: "Quarterly foreign-direct-investment flows into Canada and Canadian direct investment abroad. Outflows are not a loss — they are Canadian firms investing elsewhere — but the persistent gap shows where capital would rather be. Flows can turn negative when investment is withdrawn.", + build: (get) => { + const inflows = points(get, "fdi-inflows"); + const outflows = points(get, "fdi-outflows"); + if (!inflows || !outflows) return null; + const aligned = align([inflows, outflows]); + if (!aligned) return null; + const net = + aligned.values[0][aligned.values[0].length - 1] - + aligned.values[1][aligned.values[1].length - 1]; + const latest = last(aligned.base); + return { + stat: `${net < 0 ? "−" : "+"}$${int(Math.abs(net) / 1000)}B`, + statSub: `Net direct investment flow · ${quarterLabel(latest)}`, + source: "Statistics Canada (table 36-10-0025)", + spec: line({ + unit: "Direct investment flows, $ millions per quarter", + fmt: "count", + xLabels: xLabels(aligned.base), + legend: [ + { label: "Into Canada", color: "au" }, + { label: "Canadian investment abroad", color: "ink", dash: true }, + ], + series: [ + { color: "au", points: aligned.values[0] }, + { color: "ink", dash: true, points: aligned.values[1] }, + ], + }), + }; + }, + }, + { + n: "07", + title: "Capital formation", + verdict: "mixed", + headline: "Investment holds near 23 per cent of GDP — enough to maintain, not to catch up.", + body: "Gross capital formation as a share of GDP — how much of the economy is devoted to building future capacity: machinery, technology, buildings, and infrastructure.", + build: (get) => { + const ps = points(get, "capital-formation-pct-gdp"); + if (!ps) return null; + const latest = last(ps); + return { + stat: `${latest.value.toFixed(1)}%`, + statSub: `Gross capital formation, % of GDP · ${Math.floor(latest.year)}`, + source: "World Bank, World Development Indicators", + spec: line({ + unit: "Gross capital formation, % of GDP", + fmt: "pct", + xLabels: xLabels(ps), + legend: [{ label: "Canada", color: "au" }], + series: [{ color: "au", points: ps.map((p) => p.value) }], + }), + }; + }, + }, + { + n: "08", + title: "Debt to GDP", + verdict: "lag", + headline: "Government debt has climbed back toward its 1990s crisis peak.", + body: "Count every level of government — federal, provincial, local, CPP and QPP — on a national-balance-sheet basis. Net debt subtracts financial assets, including the pension plans' holdings, which is why it sits far below the federal-budget figures in the news. The two lines measure different things; don't split the difference.", + build: (get) => { + const gross = points(get, "govt-gross-debt-to-gdp"); + const net = points(get, "govt-net-debt-to-gdp"); + if (!gross || !net) return null; + const aligned = align([gross, net]); + if (!aligned) return null; + const latest = last(gross); + return { + stat: `${Math.round(latest.value)}%`, + statSub: `General government gross debt to GDP · ${quarterLabel(latest)}`, + source: "Statistics Canada (table 38-10-0237)", + spec: line({ + unit: "All levels of government, % of GDP", + fmt: "pct", + xLabels: xLabels(aligned.base), + legend: [ + { label: "Gross debt", color: "au" }, + { label: "Net liabilities", color: "ink", dash: true }, + ], + series: [ + { color: "au", points: aligned.values[0] }, + { color: "ink", dash: true, points: aligned.values[1] }, + ], + }), + }; + }, + }, + { + n: "09", + title: "Public vs private jobs", + verdict: "lag", + headline: "Public-sector hiring has outpaced the private economy since the pandemic.", + body: "Each class of worker as a share of all employment, monthly and seasonally adjusted. Since early 2020 the public-sector headcount has grown roughly twice as fast as the private sector's — a mix shift the economy has to carry.", + build: (get) => { + const total = points(get, "employment-all-classes"); + const priv = points(get, "employment-private-sector"); + const pub = points(get, "employment-public-sector"); + const self = points(get, "employment-self-employed"); + if (!total || !priv || !pub || !self) return null; + const aligned = align([total, pub, priv, self]); + if (!aligned) return null; + const [totalV, pubV, privV, selfV] = aligned.values; + const share = (series: number[]) => + series.map((v, i) => (v / totalV[i]) * 100); + const pubShare = share(pubV); + const latest = last(aligned.base); + return { + stat: `${pubShare[pubShare.length - 1].toFixed(1)}%`, + statSub: `Public share of employment · ${monthLabel(latest)}`, + source: "Statistics Canada (table 14-10-0288)", + spec: line({ + unit: "Share of total employment, %", + fmt: "pct", + xLabels: xLabels(aligned.base), + legend: [ + { label: "Public sector", color: "au" }, + { label: "Private sector", color: "ink", dash: true }, + { label: "Self-employed", color: "stone" }, + ], + series: [ + { color: "au", points: pubShare }, + { color: "ink", dash: true, points: share(privV) }, + { color: "stone", points: share(selfV) }, + ], + }), + }; + }, + }, + { + n: "10", + title: "Consumer prices", + verdict: "lag", + headline: "Prices broke from the 2% track in 2021 and never came back.", + body: "The all-items Consumer Price Index, month by month (2002 = 100). The dashed line is where prices would sit had they grown at the Bank of Canada's 2% inflation target — the gap above it is permanent lost ground.", + build: (get) => { + const cpi = points(get, "cpi-all-items"); + if (!cpi) return null; + const target = cpi.map((p) => 100 * Math.pow(1.02, p.year - 2002)); + const latest = last(cpi); + return { + stat: String(latest.value.toFixed(1)), + statSub: `CPI, all items (2002 = 100) · ${monthLabel(latest)}`, + source: "Statistics Canada (table 18-10-0004)", + spec: line({ + unit: "CPI all-items, 2002 = 100", + fmt: "index", + xLabels: xLabels(cpi), + legend: [ + { label: "CPI", color: "au" }, + { label: "2% target path", color: "ink", dash: true }, + ], + series: [ + { color: "au", points: cpi.map((p) => p.value) }, + { color: "ink", dash: true, points: target }, + ], + }), + }; + }, + }, + { + n: "11", + title: "Housing affordability", + verdict: "lag", + headline: "House prices sit half again above their long-term relationship with incomes.", + body: "The OECD's headline affordability measure compares home prices with the incomes that must carry them. Canada's ratio now runs roughly fifty per cent above its long-term average — among the most stretched in the developed world, pricing a whole generation out of ownership.", + build: (get) => { + const ps = points(get, "house-price-to-income"); + if (!ps) return null; + const latest = last(ps); + return { + stat: `${(latest.value / 100).toFixed(1)}×`, + statSub: `House price to income vs. long-term norm · ${Math.floor(latest.year)}`, + source: "OECD Analytical House Prices Database", + spec: line({ + unit: "Price to income, long-term average = 100", + fmt: "index", + xLabels: xLabels(ps), + legend: [{ label: "Canada", color: "au" }], + series: [{ color: "au", points: ps.map((p) => p.value) }], + }), + }; + }, + }, + { + n: "12", + title: "Housing starts", + verdict: "lead", + headline: "Homebuilding is running at rates last sustained in the 1970s.", + body: "Dwelling units started per year across Canada, from CMHC's starts and completions survey. The supply side of the housing equation — rising at last, and still short of what population growth demands.", + build: (get) => { + const ps = points(get, "housing-starts-canada"); + if (!ps) return null; + const latest = last(ps); + return { + stat: int(latest.value), + statSub: `Dwelling units started · ${Math.floor(latest.year)}`, + source: "CMHC via Statistics Canada (table 34-10-0126)", + spec: line({ + unit: "Dwelling units started per year", + fmt: "count", + xLabels: xLabels(ps), + legend: [{ label: "Housing starts", color: "au" }], + series: [{ color: "au", points: ps.map((p) => p.value) }], + }), + }; + }, + }, + { + n: "13", + title: "Net emigration", + verdict: "mixed", + headline: "The counterflow is growing: more people leave than come back.", + body: "Emigrants minus returning emigrants, on StatCan's July–June demographic years (the 2024 point covers July 2023 to June 2024). StatCan stopped publishing its own net series after a 2016 methodology change, so this line is derived from the components.", + build: (get) => { + const emigrants = points(get, "emigrants-annual"); + const returning = points(get, "returning-emigrants-annual"); + if (!emigrants || !returning) return null; + const aligned = align([emigrants, returning]); + if (!aligned) return null; + const net = aligned.values[0].map((v, i) => v - aligned.values[1][i]); + const latest = last(aligned.base); + return { + stat: int(net[net.length - 1]), + statSub: `Net emigration · ${Math.floor(latest.year)} (July–June year)`, + source: "Statistics Canada (table 17-10-0008)", + spec: line({ + unit: "Emigrants minus returning emigrants, persons per year", + fmt: "count", + xLabels: xLabels(aligned.base), + legend: [{ label: "Net emigration", color: "au" }], + series: [{ color: "au", points: net }], + }), + }; + }, + }, + { + n: "14", + title: "Permanent residents", + verdict: "lead", + headline: "Still one of the world's great immigration destinations — intake is easing off record highs.", + body: "Immigrants admitted per year, on StatCan's July–June demographic years. Handled well, this is an engine of growth; handled poorly, it strains the housing and services shown elsewhere on this page.", + build: (get) => { + const ps = points(get, "immigrants-annual"); + if (!ps) return null; + const latest = last(ps); + return { + stat: int(latest.value), + statSub: `Immigrants admitted · ${Math.floor(latest.year)} (July–June year)`, + source: "Statistics Canada (table 17-10-0008)", + spec: line({ + unit: "Immigrants admitted per year", + fmt: "count", + xLabels: xLabels(ps), + legend: [{ label: "Immigrants", color: "au" }], + series: [{ color: "au", points: ps.map((p) => p.value) }], + }), + }; + }, + }, + { + n: "15", + title: "Temporary foreign workers", + verdict: "mixed", + headline: "After more than doubling in three years, the work-permit population is unwinding.", + body: "People holding a work permit on the first day of each quarter — a stock, not a flow, and the closest public measure of \"temporary foreign workers\" (no StatCan series exists under that name). The dashed line is all non-permanent residents. The series only begins in mid-2021.", + build: (get) => { + const permits = points(get, "npr-work-permit-holders"); + const total = points(get, "npr-total"); + if (!permits || !total) return null; + const aligned = align([permits, total]); + if (!aligned) return null; + const latest = last(aligned.base); + return { + stat: `${(last(permits).value / 1_000_000).toFixed(2)}M`, + statSub: `Work-permit holders · ${quarterLabel(latest)}`, + source: "Statistics Canada (table 17-10-0121)", + spec: line({ + unit: "Persons holding temporary status (stock)", + fmt: "count", + xLabels: xLabels(aligned.base), + legend: [ + { label: "Work permits", color: "au" }, + { label: "All non-permanent residents", color: "ink", dash: true }, + ], + series: [ + { color: "au", points: aligned.values[0] }, + { color: "ink", dash: true, points: aligned.values[1] }, + ], + }), + }; + }, + }, + { + n: "16", + title: "By class", + verdict: "mixed", + headline: "A shrinking intake, still anchored by the economic class.", + body: "Permanent residents admitted each month, by immigration class, from IRCC's monthly updates. IRCC rounds counts to the nearest 5 and suppresses small cells, so values are approximate and won't exactly match StatCan's July–June figures above. Students are not a PR class — they appear under work and study permits.", + build: (get) => { + const total = points(get, "pr-admissions-total"); + const economic = points(get, "pr-admissions-economic"); + const family = points(get, "pr-admissions-family"); + const refugee = points(get, "pr-admissions-refugee"); + const other = points(get, "pr-admissions-other"); + if (!total || !economic || !family || !refugee || !other) return null; + const aligned = align([total, economic, family, refugee, other]); + if (!aligned) return null; + const latest = last(aligned.base); + return { + stat: int(last(total).value), + statSub: `Permanent residents admitted, monthly · ${monthLabel(latest)}`, + source: "Immigration, Refugees and Citizenship Canada", + spec: line({ + unit: "PR admissions per month, by class", + fmt: "count", + xLabels: xLabels(aligned.base), + legend: [ + { label: "Economic", color: "au" }, + { label: "Total", color: "ink", dash: true }, + { label: "Family", color: "clay" }, + { label: "Refugee", color: "stone" }, + { label: "Other", color: "sand" }, + ], + series: [ + { color: "au", points: aligned.values[1] }, + { color: "ink", dash: true, points: aligned.values[0] }, + { color: "clay", points: aligned.values[2] }, + { color: "stone", points: aligned.values[3] }, + { color: "sand", points: aligned.values[4] }, + ], + }), + }; + }, + }, +]; + +export const SOTN_INDICATOR_COUNT = INDICATORS.length; + +// Section grouping for the page: charts keep their continuous 01–16 +// numbering; each section renders its own header and card grid. +const SECTION_DEFS = [ + { id: "headline", title: "Headline", ns: ["01"] }, + { id: "economy", title: "Economy", ns: ["02", "03", "04", "05", "06", "07"] }, + { + id: "government-sustainability", + title: "Government Sustainability", + ns: ["08", "09"], + }, + { id: "cost-of-living", title: "Cost of Living", ns: ["10", "11", "12"] }, + { id: "immigration", title: "Immigration", ns: ["13", "14", "15", "16"] }, +]; + +export type SotnSection = { + id: string; + title: string; + indicators: SotnView[]; +}; + +export function buildSections(get: Getter): SotnSection[] { + const views = buildIndicators(get); + return SECTION_DEFS.map((def) => ({ + id: def.id, + title: def.title, + indicators: views.filter((view) => def.ns.includes(view.n)), + })).filter((section) => section.indicators.length > 0); +} + +export function buildIndicators(get: Getter): SotnView[] { + return INDICATORS.flatMap((indicator) => { + const built = indicator.build(get); + if (!built) return []; + return [ + { + n: indicator.n, + title: indicator.title, + verdict: indicator.verdict, + headline: indicator.headline, + body: indicator.body, + ...built, + }, + ]; + }); +} diff --git a/src/app/prosperity-dashboard/units.ts b/src/app/prosperity-dashboard/units.ts index 2d90acc..657ffe7 100644 --- a/src/app/prosperity-dashboard/units.ts +++ b/src/app/prosperity-dashboard/units.ts @@ -51,6 +51,31 @@ const UNIT_DISPLAY: Record = { axisLabel: "million $", formatValue: (v) => `$${int(v)}M`, }, + // CAD levels (e.g. hourly wage rates in current dollars). + $: { + name: "CAD", + shortUnit: "$", + numDecimalPlaces: 2, + axisLabel: "$", + formatValue: (v) => `$${v.toFixed(2)}`, + }, + count: { + name: "count", + shortUnit: "", + numDecimalPlaces: 0, + axisLabel: "count", + formatValue: int, + }, + // Values arrive in thousands (e.g. LFS employment levels); convert to + // persons so Grapher's magnitude abbreviations ("20 million") come out true. + count_thousands: { + name: "persons", + shortUnit: "", + numDecimalPlaces: 0, + conversionFactor: 1_000, + axisLabel: "thousands", + formatValue: (v) => `${int(v)}k`, + }, index: { name: "index", shortUnit: "", diff --git a/src/lib/api/economy.ts b/src/lib/api/economy.ts index cc25adb..09d5ead 100644 --- a/src/lib/api/economy.ts +++ b/src/lib/api/economy.ts @@ -28,11 +28,12 @@ export interface EconomySeriesJurisdiction { level: string; } -// The API serves annual points as { year, value } and monthly points -// (measure.frequency === "monthly") as { date, value }. getEconomicSeries -// normalizes both to a numeric `year` time axis — fractional for monthly -// (year + monthIndex / 12) — so charts keep a single numeric x dimension, -// with the ISO first-of-month date preserved for date formatting. +// The API serves annual points as { year, value }; monthly and quarterly +// points (per measure.frequency) both come as { date, value }, where a +// quarterly date is the first day of the quarter. getEconomicSeries +// normalizes all shapes to a numeric `year` time axis — fractional for dated +// points (year + monthIndex / 12) — so charts keep a single numeric x +// dimension, with the ISO date preserved for date formatting. export interface EconomySeriesPoint { year: number; date?: string; @@ -91,6 +92,17 @@ const SOURCE_NAMES: Record = { econ_owid_corruption_perceptions: "Transparency International via Our World in Data", econ_owid_co2_per_capita: "Global Carbon Budget via Our World in Data", + econ_statcan_gdp_per_capita: "Statistics Canada (table 36-10-0706)", + econ_statcan_employment_rate_by_age: "Statistics Canada (table 14-10-0287)", + econ_statcan_employment_by_class: "Statistics Canada (table 14-10-0288)", + econ_statcan_hourly_wages: "Statistics Canada (table 14-10-0064)", + econ_statcan_business_dynamics: "Statistics Canada (table 33-10-0270)", + econ_statcan_fdi_flows: "Statistics Canada (table 36-10-0025)", + econ_statcan_capital_formation: "Statistics Canada (table 36-10-0104)", + econ_statcan_govt_debt_to_gdp: "Statistics Canada (table 38-10-0237)", + econ_statcan_population_components: "Statistics Canada (table 17-10-0008)", + econ_statcan_npr_by_type: "Statistics Canada (table 17-10-0121)", + econ_ircc_pr_admissions: "Immigration, Refugees and Citizenship Canada", }; export function humanizeSourceName(name: string): string { @@ -114,6 +126,16 @@ const STATCAN_TABLES: Record = { econ_statcan_crime_rate: "35-10-0177", econ_statcan_cpi_essentials: "18-10-0004", econ_statcan_gdp_monthly: "36-10-0434", + econ_statcan_gdp_per_capita: "36-10-0706", + econ_statcan_employment_rate_by_age: "14-10-0287", + econ_statcan_employment_by_class: "14-10-0288", + econ_statcan_hourly_wages: "14-10-0064", + econ_statcan_business_dynamics: "33-10-0270", + econ_statcan_fdi_flows: "36-10-0025", + econ_statcan_capital_formation: "36-10-0104", + econ_statcan_govt_debt_to_gdp: "38-10-0237", + econ_statcan_population_components: "17-10-0008", + econ_statcan_npr_by_type: "17-10-0121", }; export function humanizeSourceUrl( @@ -123,6 +145,10 @@ export function humanizeSourceUrl( const table = STATCAN_TABLES[name]; if (table) return `https://www150.statcan.gc.ca/t1/tbl1/en/tv.action?pid=${table.replaceAll("-", "")}01`; + // IRCC's source URL is the raw open-data CSV; link to the dataset's + // public landing page instead. + if (name === "econ_ircc_pr_admissions") + return "https://open.canada.ca/data/en/dataset/f7e5498e-0ad8-4417-85c9-9b8aff9b9eda"; // World Bank source URLs are raw API queries (JSON dumps); link to the // public indicator page instead. Worldwide Governance Indicators carry a // GOV_WGI_ prefix in the API's source=3 dataset that the public catalog @@ -148,10 +174,20 @@ function normalizePoint(point: RawSeriesPoint): EconomySeriesPoint { export async function getEconomicSeries( measure: string, + options?: { + // Comma-separated jurisdiction slugs (e.g. "ca") to fetch a subset of a + // measure's series; omit for all jurisdictions. + jurisdictions?: string; + }, ): Promise { const response = await apiFetch("/kpis/series", { revalidate: REVALIDATE, - params: { measure }, + params: { + measure, + ...(options?.jurisdictions + ? { jurisdictions: options.jurisdictions } + : {}), + }, }); return { ...response, From 9d0dacc103f9ccb5594b6a90743271a8e9a7ada2 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Wed, 15 Jul 2026 10:01:44 -0400 Subject: [PATCH 03/12] wip --- src/app/prosperity-dashboard/page.tsx | 68 ++++++++----------- .../state-of-the-nation.ts | 44 +++++++----- 2 files changed, 53 insertions(+), 59 deletions(-) diff --git a/src/app/prosperity-dashboard/page.tsx b/src/app/prosperity-dashboard/page.tsx index 691b276..3cad976 100644 --- a/src/app/prosperity-dashboard/page.tsx +++ b/src/app/prosperity-dashboard/page.tsx @@ -53,21 +53,6 @@ function lastUpdatedLabel( }).format(new Date(latest)); } -const VERDICT_BADGE = { - lead: { - label: "Where we lead", - className: "bg-transparent border-dark text-dark", - }, - lag: { - label: "Where we lag", - className: "bg-auburn-800 border-auburn-800 text-bg", - }, - mixed: { - label: "Mixed", - className: "bg-transparent border-[#CDC4BD] text-[#6f6a63]", - }, -} as const; - // A chart card: meta row, chart title + chart, and the source attribution // the data licences require. Cards tile 2×2 on desktop within their section, // ruled by the design's warm hairlines; `wide` cards span both columns @@ -79,7 +64,6 @@ function IndicatorCard({ indicator: SotnView; wide?: boolean; }) { - const badge = VERDICT_BADGE[indicator.verdict]; return (
{indicator.title} - - {badge.label} -
- {sections.map((section) => ( -
-
-

- {section.title} -

-
-
- {section.indicators.map((indicator) => ( - + {sections.map((section) => { + // Wide cards render full-width above the 2×2 grid, outside it so + // the grid's odd/even column rules stay aligned. + const wideIndicators = section.indicators.filter((i) => i.wide); + const gridIndicators = section.indicators.filter((i) => !i.wide); + return ( +
+
+

+ {section.title} +

+
+ {wideIndicators.map((indicator) => ( + ))} -
-
- ))} + {gridIndicators.length > 0 && ( +
+ {gridIndicators.map((indicator) => ( + + ))} +
+ )} +
+ ); + })}
EconomySeriesResponse | null; @@ -120,6 +122,7 @@ type SotnIndicator = { verdict: Verdict; headline: string; body: string; + wide?: boolean; build: (get: Getter) => (Pick & { spec: ChartSpec }) | null; }; @@ -132,6 +135,7 @@ const INDICATORS: SotnIndicator[] = [ n: "01", title: "Living standards", verdict: "lag", + wide: true, headline: "Living standards have gone sideways since 2022.", body: "Real output per person, in chained 2017 dollars — the clearest single measure of whether living standards are rising. StatCan's quarterly series runs within about two months of the present, and it shows a country producing no more per person than it did four years ago.", build: (get) => { @@ -143,7 +147,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Real GDP per capita, chained 2017 $ · ${quarterLabel(latest)}`, source: "Statistics Canada (table 36-10-0706)", spec: line({ - unit: "Real GDP per capita, chained 2017 dollars", + unit: "Economic output per person", fmt: "money", xLabels: xLabels(ps), legend: [{ label: "Canada", color: "au" }], @@ -172,7 +176,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Employment rate, 25–54 · ${monthLabel(latest)}`, source: "Statistics Canada (table 14-10-0287)", spec: line({ - unit: "Employment rate by age group, % (seasonally adjusted)", + unit: "Who's working, by age group", fmt: "pct", xLabels: xLabels(aligned.base), legend: [ @@ -214,7 +218,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Net business formation (entrants − exits) · ${monthLabel(lastCommon)}`, source: "Statistics Canada (table 33-10-0270)", spec: line({ - unit: "Business entrants and exits per month", + unit: "Businesses starting vs closing for good", fmt: "count", xLabels: xLabels(entrants), legend: [ @@ -244,7 +248,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Employment rate, 15+ · ${Math.floor(latest.year)}`, source: "World Bank, World Development Indicators", spec: line({ - unit: "Employment to population ratio, 15+, %", + unit: "The share of Canadians who work", fmt: "pct", xLabels: xLabels(ps), legend: [{ label: "Canada", color: "au" }], @@ -271,7 +275,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Average hourly wage · ${Math.floor(latest.year)}`, source: "Statistics Canada (table 14-10-0064)", spec: line({ - unit: "Hourly wage, current dollars", + unit: "What Canadians earn per hour", fmt: "money", xLabels: xLabels(aligned.base), legend: [ @@ -307,7 +311,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Net direct investment flow · ${quarterLabel(latest)}`, source: "Statistics Canada (table 36-10-0025)", spec: line({ - unit: "Direct investment flows, $ millions per quarter", + unit: "Investment coming into Canada vs leaving it", fmt: "count", xLabels: xLabels(aligned.base), legend: [ @@ -337,7 +341,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Gross capital formation, % of GDP · ${Math.floor(latest.year)}`, source: "World Bank, World Development Indicators", spec: line({ - unit: "Gross capital formation, % of GDP", + unit: "How much of the economy goes to building", fmt: "pct", xLabels: xLabels(ps), legend: [{ label: "Canada", color: "au" }], @@ -364,7 +368,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `General government gross debt to GDP · ${quarterLabel(latest)}`, source: "Statistics Canada (table 38-10-0237)", spec: line({ - unit: "All levels of government, % of GDP", + unit: "What every level of government owes", fmt: "pct", xLabels: xLabels(aligned.base), legend: [ @@ -403,7 +407,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Public share of employment · ${monthLabel(latest)}`, source: "Statistics Canada (table 14-10-0288)", spec: line({ - unit: "Share of total employment, %", + unit: "Where Canadians work: public vs private", fmt: "pct", xLabels: xLabels(aligned.base), legend: [ @@ -436,7 +440,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `CPI, all items (2002 = 100) · ${monthLabel(latest)}`, source: "Statistics Canada (table 18-10-0004)", spec: line({ - unit: "CPI all-items, 2002 = 100", + unit: "Consumer prices vs the 2% target", fmt: "index", xLabels: xLabels(cpi), legend: [ @@ -466,7 +470,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `House price to income vs. long-term norm · ${Math.floor(latest.year)}`, source: "OECD Analytical House Prices Database", spec: line({ - unit: "Price to income, long-term average = 100", + unit: "Home prices vs incomes (100 = the long-term norm)", fmt: "index", xLabels: xLabels(ps), legend: [{ label: "Canada", color: "au" }], @@ -490,7 +494,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Dwelling units started · ${Math.floor(latest.year)}`, source: "CMHC via Statistics Canada (table 34-10-0126)", spec: line({ - unit: "Dwelling units started per year", + unit: "Homes started each year", fmt: "count", xLabels: xLabels(ps), legend: [{ label: "Housing starts", color: "au" }], @@ -518,7 +522,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Net emigration · ${Math.floor(latest.year)} (July–June year)`, source: "Statistics Canada (table 17-10-0008)", spec: line({ - unit: "Emigrants minus returning emigrants, persons per year", + unit: "People leaving Canada for good", fmt: "count", xLabels: xLabels(aligned.base), legend: [{ label: "Net emigration", color: "au" }], @@ -542,7 +546,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Immigrants admitted · ${Math.floor(latest.year)} (July–June year)`, source: "Statistics Canada (table 17-10-0008)", spec: line({ - unit: "Immigrants admitted per year", + unit: "New permanent residents each year", fmt: "count", xLabels: xLabels(ps), legend: [{ label: "Immigrants", color: "au" }], @@ -569,7 +573,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Work-permit holders · ${quarterLabel(latest)}`, source: "Statistics Canada (table 17-10-0121)", spec: line({ - unit: "Persons holding temporary status (stock)", + unit: "People in Canada on temporary permits", fmt: "count", xLabels: xLabels(aligned.base), legend: [ @@ -605,7 +609,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Permanent residents admitted, monthly · ${monthLabel(latest)}`, source: "Immigration, Refugees and Citizenship Canada", spec: line({ - unit: "PR admissions per month, by class", + unit: "Permanent residents by immigration class", fmt: "count", xLabels: xLabels(aligned.base), legend: [ @@ -633,8 +637,11 @@ export const SOTN_INDICATOR_COUNT = INDICATORS.length; // Section grouping for the page: charts keep their continuous 01–16 // numbering; each section renders its own header and card grid. const SECTION_DEFS = [ - { id: "headline", title: "Headline", ns: ["01"] }, - { id: "economy", title: "Economy", ns: ["02", "03", "04", "05", "06", "07"] }, + { + id: "economy", + title: "Economy", + ns: ["01", "02", "03", "04", "05", "06", "07"], + }, { id: "government-sustainability", title: "Government Sustainability", @@ -670,6 +677,7 @@ export function buildIndicators(get: Getter): SotnView[] { verdict: indicator.verdict, headline: indicator.headline, body: indicator.body, + wide: indicator.wide, ...built, }, ]; From ba54de2402d2ed4f601d3a7167f24e8cef84b361 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Wed, 15 Jul 2026 10:05:37 -0400 Subject: [PATCH 04/12] revisions --- src/app/prosperity-dashboard/JoinForm.tsx | 67 ----------------------- src/app/prosperity-dashboard/page.tsx | 35 +++++------- 2 files changed, 13 insertions(+), 89 deletions(-) delete mode 100644 src/app/prosperity-dashboard/JoinForm.tsx diff --git a/src/app/prosperity-dashboard/JoinForm.tsx b/src/app/prosperity-dashboard/JoinForm.tsx deleted file mode 100644 index 3ef130c..0000000 --- a/src/app/prosperity-dashboard/JoinForm.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client"; - -import { useState, type FormEvent } from "react"; -import posthog from "posthog-js"; - -// The State of the Nation design's email-only join form, posting to the same -// /api/subscribe endpoint as SubscribeForm (name and postal code are optional -// there). -export default function JoinForm() { - const [email, setEmail] = useState(""); - const [loading, setLoading] = useState(false); - const [joined, setJoined] = useState(false); - const [error, setError] = useState(null); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setError(null); - setLoading(true); - try { - const res = await fetch("/api/subscribe", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, source: "state-of-the-nation" }), - }); - const data = await res.json(); - if (!res.ok) { - setError(data.error || "Something went wrong"); - return; - } - posthog.identify(email, { email }); - posthog.capture("subscribed", { source: "state-of-the-nation" }); - setJoined(true); - } catch { - setError("Network error. Please try again."); - } finally { - setLoading(false); - } - }; - - return ( - <> -
- setEmail(e.target.value)} - className="flex-1 min-w-[240px] bg-bg border-2 border-bg text-dark font-body text-[1.05rem] px-[18px] py-3.5 outline-none placeholder:text-[#8a8178]" - /> - -
- {error && ( -

{error}

- )} - - ); -} diff --git a/src/app/prosperity-dashboard/page.tsx b/src/app/prosperity-dashboard/page.tsx index 3cad976..827b90a 100644 --- a/src/app/prosperity-dashboard/page.tsx +++ b/src/app/prosperity-dashboard/page.tsx @@ -8,7 +8,6 @@ import { buildGraph } from "@/lib/schemas/graph"; import { generateOrganizationSchema } from "@/lib/schemas/generators/organization"; import { generateBreadcrumbSchema } from "@/lib/schemas/generators/breadcrumb"; import StateChart from "./StateChart"; -import JoinForm from "./JoinForm"; import { buildSections, SOTN_INDICATOR_COUNT, @@ -220,27 +219,19 @@ export default async function StateOfTheNationPage() { })}
-
-
-
- Turn the numbers into plans -
-

- The state of the nation is a choice. Help us change it. -

-

- We publish memos on behalf of Canada’s top builders — - concrete plans to close the gaps above. Be first to know - what’s possible. -

- -
- No spam. Unsubscribe anytime. -
-
+
+

Missing a measure that matters?

+

+ We’re expanding this dashboard. If there’s an indicator + that would sharpen the picture — for better or worse — tell us and + we’ll track it. +

+ + Email us +
); From e045f7d88c4fb189b9ba6b3fe6084578723878d1 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Wed, 15 Jul 2026 10:11:09 -0400 Subject: [PATCH 05/12] rename route --- next.config.ts | 16 ++++++++++++++-- src/app/api/economy/series/route.ts | 2 +- src/app/sitemap.ts | 8 ++++---- .../CombinedSectionChart.tsx | 0 .../CombinedSectionChartClient.tsx | 0 .../DashboardNav.tsx | 0 .../IndicatorChart.tsx | 0 .../IndicatorChartClient.tsx | 0 .../SectionNav.tsx | 4 ++-- .../SectionSparkline.tsx | 0 .../SourceLine.tsx | 0 .../StateChart.tsx | 0 .../[section]/opengraph-image.tsx | 8 ++++---- .../[section]/page.tsx | 10 +++++----- .../canvas/CanvasClient.tsx | 2 +- .../canvas/OverlayChart.tsx | 0 .../canvas/opengraph-image.tsx | 2 +- .../canvas/overlay-types.ts | 0 .../canvas/page.tsx | 6 +++--- .../dashboard.ts | 0 .../indicators.ts | 2 +- .../og-card.tsx | 2 +- .../opengraph-image.tsx | 4 ++-- .../page.tsx | 6 +++--- .../state-of-the-nation.ts | 0 .../units.ts | 0 .../useChartSize.ts | 0 src/components/custom/signpost/config.ts | 2 +- src/lib/schemas/generators/breadcrumb.ts | 2 +- 29 files changed, 44 insertions(+), 32 deletions(-) rename src/app/{prosperity-dashboard => state-of-the-nation}/CombinedSectionChart.tsx (100%) rename src/app/{prosperity-dashboard => state-of-the-nation}/CombinedSectionChartClient.tsx (100%) rename src/app/{prosperity-dashboard => state-of-the-nation}/DashboardNav.tsx (100%) rename src/app/{prosperity-dashboard => state-of-the-nation}/IndicatorChart.tsx (100%) rename src/app/{prosperity-dashboard => state-of-the-nation}/IndicatorChartClient.tsx (100%) rename src/app/{prosperity-dashboard => state-of-the-nation}/SectionNav.tsx (93%) rename src/app/{prosperity-dashboard => state-of-the-nation}/SectionSparkline.tsx (100%) rename src/app/{prosperity-dashboard => state-of-the-nation}/SourceLine.tsx (100%) rename src/app/{prosperity-dashboard => state-of-the-nation}/StateChart.tsx (100%) rename src/app/{prosperity-dashboard => state-of-the-nation}/[section]/opengraph-image.tsx (89%) rename src/app/{prosperity-dashboard => state-of-the-nation}/[section]/page.tsx (96%) rename src/app/{prosperity-dashboard => state-of-the-nation}/canvas/CanvasClient.tsx (99%) rename src/app/{prosperity-dashboard => state-of-the-nation}/canvas/OverlayChart.tsx (100%) rename src/app/{prosperity-dashboard => state-of-the-nation}/canvas/opengraph-image.tsx (98%) rename src/app/{prosperity-dashboard => state-of-the-nation}/canvas/overlay-types.ts (100%) rename src/app/{prosperity-dashboard => state-of-the-nation}/canvas/page.tsx (90%) rename src/app/{prosperity-dashboard => state-of-the-nation}/dashboard.ts (100%) rename src/app/{prosperity-dashboard => state-of-the-nation}/indicators.ts (99%) rename src/app/{prosperity-dashboard => state-of-the-nation}/og-card.tsx (99%) rename src/app/{prosperity-dashboard => state-of-the-nation}/opengraph-image.tsx (93%) rename src/app/{prosperity-dashboard => state-of-the-nation}/page.tsx (97%) rename src/app/{prosperity-dashboard => state-of-the-nation}/state-of-the-nation.ts (100%) rename src/app/{prosperity-dashboard => state-of-the-nation}/units.ts (100%) rename src/app/{prosperity-dashboard => state-of-the-nation}/useChartSize.ts (100%) diff --git a/next.config.ts b/next.config.ts index 64ec06a..1a0a862 100644 --- a/next.config.ts +++ b/next.config.ts @@ -30,14 +30,26 @@ const nextConfig: NextConfig = { destination: "/builders/:slug", permanent: true, }, + // Both prior names of the State of the Nation dashboard redirect + // straight to the current route (no chained hops). { source: "/economic-indicators", - destination: "/prosperity-dashboard", + destination: "/state-of-the-nation", permanent: true, }, { source: "/economic-indicators/:path*", - destination: "/prosperity-dashboard/:path*", + destination: "/state-of-the-nation/:path*", + permanent: true, + }, + { + source: "/prosperity-dashboard", + destination: "/state-of-the-nation", + permanent: true, + }, + { + source: "/prosperity-dashboard/:path*", + destination: "/state-of-the-nation/:path*", permanent: true, }, ]; diff --git a/src/app/api/economy/series/route.ts b/src/app/api/economy/series/route.ts index 30fec77..e27659a 100644 --- a/src/app/api/economy/series/route.ts +++ b/src/app/api/economy/series/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getEconomicSeries } from "@/lib/api/economy"; -import { MEASURE_SLUGS } from "@/app/prosperity-dashboard/indicators"; +import { MEASURE_SLUGS } from "@/app/state-of-the-nation/indicators"; // Same-origin proxy for york_factory's public series endpoint so client // components (the canvas page) can fetch without cross-origin config. The diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 21887af..e3ab732 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -8,7 +8,7 @@ import type { import { getBillsFromCivicsProject } from "@/app/bills/server/get-all-bills-from-civics-project"; import { getAllBillsFromDB } from "@/app/bills/server/get-all-bills-from-db"; import { buildAbsoluteUrl } from "@/app/bills/utils/basePath"; -import { SECTIONS as ECONOMIC_SECTIONS } from "@/app/prosperity-dashboard/indicators"; +import { SECTIONS as ECONOMIC_SECTIONS } from "@/app/state-of-the-nation/indicators"; function toValidDate(value?: Date | string): Date | undefined { if (!value) return undefined; @@ -24,14 +24,14 @@ export default async function sitemap(): Promise { { url: `${baseUrl}/memos`, lastModified: new Date(), changeFrequency: "daily", priority: 0.9 }, { url: `${baseUrl}/posts`, lastModified: new Date(), changeFrequency: "daily", priority: 0.8 }, { url: `${baseUrl}/projects`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.8 }, - { url: `${baseUrl}/prosperity-dashboard`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.7 }, + { url: `${baseUrl}/state-of-the-nation`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.7 }, ...ECONOMIC_SECTIONS.map((section) => ({ - url: `${baseUrl}/prosperity-dashboard/${section.id}`, + url: `${baseUrl}/state-of-the-nation/${section.id}`, lastModified: new Date(), changeFrequency: "weekly" as const, priority: 0.6, })), - { url: `${baseUrl}/prosperity-dashboard/canvas`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.5 }, + { url: `${baseUrl}/state-of-the-nation/canvas`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.5 }, { url: `${baseUrl}/tracker`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.8 }, { url: `${baseUrl}/tracker/commitments`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.7 }, { url: `${baseUrl}/tracker/faq`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.4 }, diff --git a/src/app/prosperity-dashboard/CombinedSectionChart.tsx b/src/app/state-of-the-nation/CombinedSectionChart.tsx similarity index 100% rename from src/app/prosperity-dashboard/CombinedSectionChart.tsx rename to src/app/state-of-the-nation/CombinedSectionChart.tsx diff --git a/src/app/prosperity-dashboard/CombinedSectionChartClient.tsx b/src/app/state-of-the-nation/CombinedSectionChartClient.tsx similarity index 100% rename from src/app/prosperity-dashboard/CombinedSectionChartClient.tsx rename to src/app/state-of-the-nation/CombinedSectionChartClient.tsx diff --git a/src/app/prosperity-dashboard/DashboardNav.tsx b/src/app/state-of-the-nation/DashboardNav.tsx similarity index 100% rename from src/app/prosperity-dashboard/DashboardNav.tsx rename to src/app/state-of-the-nation/DashboardNav.tsx diff --git a/src/app/prosperity-dashboard/IndicatorChart.tsx b/src/app/state-of-the-nation/IndicatorChart.tsx similarity index 100% rename from src/app/prosperity-dashboard/IndicatorChart.tsx rename to src/app/state-of-the-nation/IndicatorChart.tsx diff --git a/src/app/prosperity-dashboard/IndicatorChartClient.tsx b/src/app/state-of-the-nation/IndicatorChartClient.tsx similarity index 100% rename from src/app/prosperity-dashboard/IndicatorChartClient.tsx rename to src/app/state-of-the-nation/IndicatorChartClient.tsx diff --git a/src/app/prosperity-dashboard/SectionNav.tsx b/src/app/state-of-the-nation/SectionNav.tsx similarity index 93% rename from src/app/prosperity-dashboard/SectionNav.tsx rename to src/app/state-of-the-nation/SectionNav.tsx index 547ae1a..33bb266 100644 --- a/src/app/prosperity-dashboard/SectionNav.tsx +++ b/src/app/state-of-the-nation/SectionNav.tsx @@ -10,7 +10,7 @@ export default function SectionNav({ currentId }: { currentId?: string }) { >
s.id === sectionId); - const title = section?.title ?? "Prosperity Dashboard"; + const title = section?.title ?? "State of the Nation"; const description = section?.description ?? "Are we moving in the right direction? Canadian prosperity, measured against the G7 and OECD."; @@ -43,12 +43,12 @@ export default async function OpengraphImage({ const footnote = source ? `Source: ${humanizeSourceName(source.name)}` : ""; const fonts = await loadOgFonts( - `${title}${description}${chartHeading}${chart?.latestLabel ?? ""}${footnote}Prosperity Dashboard`, + `${title}${description}${chartHeading}${chart?.latestLabel ?? ""}${footnote}State of the Nation`, ); return new ImageResponse( s.id === sectionId); if (!section) return {}; - const title = `${section.title} — Prosperity Dashboard`; + const title = `${section.title} — State of the Nation`; return { title, description: section.description, - alternates: { canonical: `/prosperity-dashboard/${section.id}` }, + alternates: { canonical: `/state-of-the-nation/${section.id}` }, openGraph: { title, description: section.description, @@ -95,7 +95,7 @@ export default async function IndicatorSectionPage({ params }: PageProps) { const jsonLd = buildGraph( generateOrganizationSchema(configData), generateBreadcrumbSchema( - `/prosperity-dashboard/${section.id}`, + `/state-of-the-nation/${section.id}`, section.title, configData.siteUrl, ), @@ -215,7 +215,7 @@ export default async function IndicatorSectionPage({ params }: PageProps) { > {prev ? ( ← {prev.title} @@ -225,7 +225,7 @@ export default async function IndicatorSectionPage({ params }: PageProps) { )} {next ? ( {next.title} → diff --git a/src/app/prosperity-dashboard/canvas/CanvasClient.tsx b/src/app/state-of-the-nation/canvas/CanvasClient.tsx similarity index 99% rename from src/app/prosperity-dashboard/canvas/CanvasClient.tsx rename to src/app/state-of-the-nation/canvas/CanvasClient.tsx index c20ad5e..e8c7da0 100644 --- a/src/app/prosperity-dashboard/canvas/CanvasClient.tsx +++ b/src/app/state-of-the-nation/canvas/CanvasClient.tsx @@ -111,7 +111,7 @@ export default function CanvasClient() { const params = new URLSearchParams(); params.set("f", serializeFeeds(resolvedFeeds)); if (mode !== "indexed") params.set("mode", mode); - const next = `/prosperity-dashboard/canvas?${params.toString()}`; + const next = `/state-of-the-nation/canvas?${params.toString()}`; if (`${window.location.pathname}${window.location.search}` !== next) { window.history.replaceState(null, "", next); } diff --git a/src/app/prosperity-dashboard/canvas/OverlayChart.tsx b/src/app/state-of-the-nation/canvas/OverlayChart.tsx similarity index 100% rename from src/app/prosperity-dashboard/canvas/OverlayChart.tsx rename to src/app/state-of-the-nation/canvas/OverlayChart.tsx diff --git a/src/app/prosperity-dashboard/canvas/opengraph-image.tsx b/src/app/state-of-the-nation/canvas/opengraph-image.tsx similarity index 98% rename from src/app/prosperity-dashboard/canvas/opengraph-image.tsx rename to src/app/state-of-the-nation/canvas/opengraph-image.tsx index f34c658..e02e26f 100644 --- a/src/app/prosperity-dashboard/canvas/opengraph-image.tsx +++ b/src/app/state-of-the-nation/canvas/opengraph-image.tsx @@ -52,7 +52,7 @@ export default async function OpengraphImage() { return new ImageResponse(
- ← Prosperity Dashboard + ← State of the Nation
diff --git a/src/app/prosperity-dashboard/dashboard.ts b/src/app/state-of-the-nation/dashboard.ts similarity index 100% rename from src/app/prosperity-dashboard/dashboard.ts rename to src/app/state-of-the-nation/dashboard.ts diff --git a/src/app/prosperity-dashboard/indicators.ts b/src/app/state-of-the-nation/indicators.ts similarity index 99% rename from src/app/prosperity-dashboard/indicators.ts rename to src/app/state-of-the-nation/indicators.ts index ac549b2..4a45697 100644 --- a/src/app/prosperity-dashboard/indicators.ts +++ b/src/app/state-of-the-nation/indicators.ts @@ -1,5 +1,5 @@ // Shared catalog of the economic indicator measures served by york_factory's -// /kpis/series endpoint — each section gets its own /prosperity-dashboard/[section] +// /kpis/series endpoint — each section gets its own /state-of-the-nation/[section] // page and the canvas page uses the flat list as the feed picker. export type Indicator = { diff --git a/src/app/prosperity-dashboard/og-card.tsx b/src/app/state-of-the-nation/og-card.tsx similarity index 99% rename from src/app/prosperity-dashboard/og-card.tsx rename to src/app/state-of-the-nation/og-card.tsx index 2769489..7e59607 100644 --- a/src/app/prosperity-dashboard/og-card.tsx +++ b/src/app/state-of-the-nation/og-card.tsx @@ -2,7 +2,7 @@ import type { EconomySeriesResponse } from "@/lib/api/economy"; import { CANADA_COLOR } from "./indicators"; import { formatValue } from "./units"; -// Shared plumbing for the /prosperity-dashboard opengraph images: one branded +// Shared plumbing for the /state-of-the-nation opengraph images: one branded // card layout plus builders that bake a series into a data-URI SVG. Satori // lays out the text; the chart travels as an so resvg rasterizes real // vector paths instead of satori's partial SVG support. diff --git a/src/app/prosperity-dashboard/opengraph-image.tsx b/src/app/state-of-the-nation/opengraph-image.tsx similarity index 93% rename from src/app/prosperity-dashboard/opengraph-image.tsx rename to src/app/state-of-the-nation/opengraph-image.tsx index af52133..0e0aca7 100644 --- a/src/app/prosperity-dashboard/opengraph-image.tsx +++ b/src/app/state-of-the-nation/opengraph-image.tsx @@ -10,7 +10,7 @@ import { export const runtime = "nodejs"; export const alt = - "Build Canada — Prosperity Dashboard: Canadian prosperity, measured"; + "Build Canada — State of the Nation: Canadian prosperity, measured"; export const size = OG_SIZE; @@ -23,7 +23,7 @@ export const revalidate = 3600; const FEATURED_SLUG = "gdp-per-capita-canada"; const CHART_HEADING = "GDP per capita"; -const TITLE = "Prosperity Dashboard"; +const TITLE = "State of the Nation"; const DESCRIPTION = "Are we moving in the right direction? Canadian prosperity, measured."; diff --git a/src/app/prosperity-dashboard/page.tsx b/src/app/state-of-the-nation/page.tsx similarity index 97% rename from src/app/prosperity-dashboard/page.tsx rename to src/app/state-of-the-nation/page.tsx index 827b90a..8827363 100644 --- a/src/app/prosperity-dashboard/page.tsx +++ b/src/app/state-of-the-nation/page.tsx @@ -21,7 +21,7 @@ const DESCRIPTION = export const metadata: Metadata = { title: "State of the Nation", description: DESCRIPTION, - alternates: { canonical: "/prosperity-dashboard" }, + alternates: { canonical: "/state-of-the-nation" }, openGraph: { title: "State of the Nation", description: DESCRIPTION, @@ -115,7 +115,7 @@ export default async function StateOfTheNationPage() { const jsonLd = buildGraph( generateOrganizationSchema(configData), generateBreadcrumbSchema( - "/prosperity-dashboard", + "/state-of-the-nation", "State of the Nation", configData.siteUrl, ), @@ -227,7 +227,7 @@ export default async function StateOfTheNationPage() { we’ll track it.

Email us diff --git a/src/app/prosperity-dashboard/state-of-the-nation.ts b/src/app/state-of-the-nation/state-of-the-nation.ts similarity index 100% rename from src/app/prosperity-dashboard/state-of-the-nation.ts rename to src/app/state-of-the-nation/state-of-the-nation.ts diff --git a/src/app/prosperity-dashboard/units.ts b/src/app/state-of-the-nation/units.ts similarity index 100% rename from src/app/prosperity-dashboard/units.ts rename to src/app/state-of-the-nation/units.ts diff --git a/src/app/prosperity-dashboard/useChartSize.ts b/src/app/state-of-the-nation/useChartSize.ts similarity index 100% rename from src/app/prosperity-dashboard/useChartSize.ts rename to src/app/state-of-the-nation/useChartSize.ts diff --git a/src/components/custom/signpost/config.ts b/src/components/custom/signpost/config.ts index d739518..0736dde 100644 --- a/src/components/custom/signpost/config.ts +++ b/src/components/custom/signpost/config.ts @@ -14,7 +14,7 @@ export interface SignpostProps { shareTitle?: string; shareUrl?: string; // Pages with an extra sticky bar below the global navbar (e.g. the - // prosperity-dashboard section nav) push the rail and scroll target down, + // state-of-the-nation section nav) push the rail and scroll target down, // and may already have their own narrow-screen navigation. desktopTopClass?: string; scrollOffset?: number; diff --git a/src/lib/schemas/generators/breadcrumb.ts b/src/lib/schemas/generators/breadcrumb.ts index c0d855f..9c46564 100644 --- a/src/lib/schemas/generators/breadcrumb.ts +++ b/src/lib/schemas/generators/breadcrumb.ts @@ -3,7 +3,7 @@ const ROUTE_LABELS: Record = { about: "About", projects: "Projects", content: "Content", - "prosperity-dashboard": "Prosperity Dashboard", + "state-of-the-nation": "State of the Nation", }; export function generateBreadcrumbSchema( From d14ca2f1fdcd0d98ef65340a8cd2617f1d033cc4 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Wed, 15 Jul 2026 10:12:37 -0400 Subject: [PATCH 06/12] copy title --- src/app/state-of-the-nation/page.tsx | 61 +++------------------------- 1 file changed, 6 insertions(+), 55 deletions(-) diff --git a/src/app/state-of-the-nation/page.tsx b/src/app/state-of-the-nation/page.tsx index 8827363..844775c 100644 --- a/src/app/state-of-the-nation/page.tsx +++ b/src/app/state-of-the-nation/page.tsx @@ -65,11 +65,10 @@ function IndicatorCard({ }) { return (
@@ -130,62 +129,14 @@ export default async function StateOfTheNationPage() {
State of the Nation · 2026

- Canada is not a poor country. -
- It is a country underperforming its potential. + State of the Nation

-

- Sixteen indicators, read honestly. Where we lead, where we lag, and - where the picture is genuinely mixed — Canada measured against its - own record. The numbers below are the starting point for every plan - we publish. -

-
-
-
-
- {leadCount} -
-
- Areas we lead -
-
-
-
- {lagCount} -
-
- Areas we lag -
-
-
-
- {SOTN_INDICATOR_COUNT} indicators tracked -
- All figures from live sources - {updated && ( - <> -
- Last updated · {updated} - - )} -
-
+
From 76aaed997c91a6d948516a00e4a9e493115bcfa1 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Wed, 15 Jul 2026 11:31:43 -0400 Subject: [PATCH 07/12] change some naming --- src/app/state-of-the-nation/state-of-the-nation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/state-of-the-nation/state-of-the-nation.ts b/src/app/state-of-the-nation/state-of-the-nation.ts index f873318..85e0dd3 100644 --- a/src/app/state-of-the-nation/state-of-the-nation.ts +++ b/src/app/state-of-the-nation/state-of-the-nation.ts @@ -133,7 +133,7 @@ const line = ( const INDICATORS: SotnIndicator[] = [ { n: "01", - title: "Living standards", + title: "GDP per capita", verdict: "lag", wide: true, headline: "Living standards have gone sideways since 2022.", From 226ad4887df7626e2611cbe440b0cbf9aa94e914 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Thu, 16 Jul 2026 10:05:11 -0400 Subject: [PATCH 08/12] wip --- docs/STATE_OF_THE_NATION_DATA_SPEC.md | 35 ++- src/app/state-of-the-nation/StateChart.tsx | 117 +++++-- src/app/state-of-the-nation/page.tsx | 60 ++-- .../state-of-the-nation.ts | 288 +++++++++++------- src/lib/api/economy.ts | 7 + 5 files changed, 332 insertions(+), 175 deletions(-) diff --git a/docs/STATE_OF_THE_NATION_DATA_SPEC.md b/docs/STATE_OF_THE_NATION_DATA_SPEC.md index 1338228..22da369 100644 --- a/docs/STATE_OF_THE_NATION_DATA_SPEC.md +++ b/docs/STATE_OF_THE_NATION_DATA_SPEC.md @@ -47,7 +47,7 @@ existing `economy` / `welfare` / etc. ## 1. Employment by age group -Monthly, seasonally adjusted, percent, ~1993→present. Source: StatCan LFS +Monthly, seasonally adjusted, percent, 1976→present. Source: StatCan LFS table 14-10-0287. All four share unit and frequency, so they work as a combined overlay chart. @@ -63,7 +63,7 @@ different measure and stays as-is; these are the Canadian monthly detail. ## 2. Private vs public employment -Monthly, seasonally adjusted, **thousands of persons**, ~1993→present. +Monthly, seasonally adjusted, **thousands of persons**, 1976→present. Source: StatCan LFS table 14-10-0288. | slug | series | @@ -218,6 +218,37 @@ not a PR class — students appear in `npr-study-permit-holders` above. --- +## History coverage & matched chart windows + +Every series is served at its **full published history** — nothing is +truncated on the backend. The series cannot all start at the same date +because the underlying StatCan programs began at different times, so +matching happens client-side with the `from=` parameter. Confirmed coverage +(first data point, verified against loaded data): + +| starts | series | +|---|---| +| 1961 | capital formation (both StatCan levels and World Bank % of GDP) | +| 1971 | population components (immigrants, emigrants, returning, net NPR) | +| 1976 | employment rates by age; employment by class of worker | +| 1990 | debt-to-GDP (gross and net) | +| 1997 | average/median hourly wage | +| 2007 | FDI inflows/outflows | +| 2015 | business entrants/exits/active; PR admissions by category | +| 2021 Q3 | non-permanent residents by type | + +Recommended convention for visual consistency: + +- **Default window: `from=1990`** — 5 of 8 dataset families cover it fully, + and it spans a full generation. Use it wherever the section's series reach + that far (employment, wages from 1997, capital formation, debt, population + components). +- Series that physically can't reach 1990 (FDI, business dynamics, PR + admissions, NPR) should render their full history and state the start year + in the chart subtitle (e.g. "since 2015") rather than pad empty space. +- Within a combined/overlay chart, clip all series to the **latest common + start** so lines begin together. + ## Suggested section mapping Against the existing `SECTIONS` ids in diff --git a/src/app/state-of-the-nation/StateChart.tsx b/src/app/state-of-the-nation/StateChart.tsx index e74b26e..8be9996 100644 --- a/src/app/state-of-the-nation/StateChart.tsx +++ b/src/app/state-of-the-nation/StateChart.tsx @@ -16,13 +16,20 @@ export type LineSpec = { kind: "line"; unit: string; fmt: ChartFmt; - // Tick labels for the first, middle, and last x positions. + // Time domain in fractional years. Every chart on the page passes the same + // domain, so a given horizontal position means the same date on every + // graph; series that begin later simply start partway in. + xDomain: [number, number]; + // Tick labels for the left edge, middle, and right edge of the domain. xLabels: [string, string, string]; legend?: LegendItem[]; - // Series must share the same start and cadence but may differ in length — - // a shorter series (e.g. business exits, confirmed ~6 months late) simply - // stops early instead of being stretched or clipping the others. - series: { color: SeriesColor; dash?: boolean; points: number[] }[]; + // xs holds each point's fractional year, parallel to points. + series: { + color: SeriesColor; + dash?: boolean; + xs: number[]; + points: number[]; + }[]; }; export type BarSpec = { @@ -57,6 +64,48 @@ function fmt(v: number, f: ChartFmt): string { return Math.round(v).toLocaleString("en-CA"); } +// A round-number step (1, 2, 2.5, 5, ×10ⁿ) closest to the raw interval, so +// axis labels land on clean values. +function niceStep(raw: number): number { + const mag = Math.pow(10, Math.floor(Math.log10(raw))); + const norm = raw / mag; + const nice = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10; + return nice * mag; +} + +// Frames the y axis around the data's own range rather than forcing a zero +// baseline. Most of these series never approach zero — employment rates in the +// 80s, index values near 150, GDP per capita in the tens of thousands — so +// anchoring there flattens every line into a straight streak near the top. +// Returns a padded range snapped to round tick values (~4–6 gridlines). Zero +// is kept on the chart whenever the data goes negative, and a positive series +// that sits close to zero still floors at zero rather than floating above it. +function niceAxis( + dataMin: number, + dataMax: number, +): { mn: number; mx: number; ticks: number[] } { + let lo = dataMin; + let hi = dataMax; + if (lo === hi) { + lo -= 1; + hi += 1; + } + const span = hi - lo; + lo -= span * 0.08; + hi += span * 0.08; + if (dataMin >= 0 && lo < 0) lo = 0; + const step = niceStep((hi - lo) / 4); + const mn = Math.floor(lo / step) * step; + const mx = Math.ceil(hi / step) * step; + const ticks: number[] = []; + // The 0.001·step slack absorbs floating-point drift so the top tick isn't + // dropped or doubled. + for (let v = mn; v <= mx + step * 0.001; v += step) { + ticks.push(Math.abs(v) < step * 1e-9 ? 0 : v); + } + return { mn, mx, ticks }; +} + function LineChart({ spec, wide }: { spec: LineSpec; wide?: boolean }) { // Full-width (wide) charts get a panoramic viewBox so the SVG renders at // roughly 1:1 scale instead of stretching the half-column geometry — which @@ -70,17 +119,18 @@ function LineChart({ spec, wide }: { spec: LineSpec; wide?: boolean }) { const iw = W - L - R, ih = H - T - B; const all = spec.series.flatMap((s) => s.points); - let mx = Math.max(...all), - mn = Math.min(...all); - const pad = (mx - mn) * 0.14 || 1; - mx += pad; - mn -= pad; - const n = Math.max(...spec.series.map((s) => s.points.length)); - const X = (i: number) => L + iw * (i / (n - 1)); + // Frame the y axis around the data's own range (see niceAxis) so each trend + // fills the panel instead of flattening against a zero baseline. + const { mn, mx, ticks } = niceAxis(Math.min(...all), Math.max(...all)); + const crossesZero = mn < 0 && mx > 0; + const [d0, d1] = spec.xDomain; + const X = (x: number) => L + iw * ((x - d0) / (d1 - d0)); const Y = (v: number) => T + ih * (1 - (v - mn) / (mx - mn)); - const baseY = T + ih; - const gridlines = [0, 1, 2, 3, 4]; - const ticks = [0, Math.floor((n - 1) / 2), n - 1]; + // Area fills close at the bottom of the framed panel — or at the zero line + // when the data straddles it. + const baseY = Y(crossesZero ? 0 : mn); + // Ticks sit at the domain edges and middle — identical across charts. + const tickX = [L, L + iw / 2, L + iw]; return ( - {gridlines.map((g) => { - const gy = T + (ih * g) / 4; - const gv = mx - ((mx - mn) * g) / 4; + {ticks.map((tv, i) => { + const gy = Y(tv); + // The lowest tick doubles as the bottom axis and gets the ink weight. + const isBase = i === 0; return ( - + - {fmt(gv, spec.fmt)} + {fmt(tv, spec.fmt)} ); })} + {crossesZero && ( + + )} {spec.xLabels.map((label, k) => ( { const col = SERIES_COLORS[s.color]; const end = s.points.length - 1; - const pts = s.points.map((v, i) => `${X(i)},${Y(v)}`).join(" "); + const pts = s.points.map((v, i) => `${X(s.xs[i])},${Y(v)}`).join(" "); const area = si === 0 - ? `M${X(0)},${Y(s.points[0])} ` + - s.points.map((v, i) => `L${X(i)},${Y(v)}`).join(" ") + - ` L${X(end)},${baseY} L${X(0)},${baseY} Z` + ? `M${X(s.xs[0])},${Y(s.points[0])} ` + + s.points.map((v, i) => `L${X(s.xs[i])},${Y(v)}`).join(" ") + + ` L${X(s.xs[end])},${baseY} L${X(s.xs[0])},${baseY} Z` : null; return ( @@ -149,7 +210,7 @@ function LineChart({ spec, wide }: { spec: LineSpec; wide?: boolean }) { strokeLinejoin="round" strokeLinecap="round" /> - + ); })} diff --git a/src/app/state-of-the-nation/page.tsx b/src/app/state-of-the-nation/page.tsx index 844775c..40e29df 100644 --- a/src/app/state-of-the-nation/page.tsx +++ b/src/app/state-of-the-nation/page.tsx @@ -1,16 +1,12 @@ import type { Metadata } from "next"; import { getSiteConfig } from "@/lib/api"; -import { - getEconomicSeries, - type EconomySeriesResponse, -} from "@/lib/api/economy"; +import { getEconomicSeries } from "@/lib/api/economy"; import { buildGraph } from "@/lib/schemas/graph"; import { generateOrganizationSchema } from "@/lib/schemas/generators/organization"; import { generateBreadcrumbSchema } from "@/lib/schemas/generators/breadcrumb"; import StateChart from "./StateChart"; import { buildSections, - SOTN_INDICATOR_COUNT, SOTN_MEASURE_SLUGS, type SotnView, } from "./state-of-the-nation"; @@ -37,21 +33,6 @@ export const metadata: Metadata = { const GRAY = "#6f6a63"; const BD = "#CDC4BD"; -function lastUpdatedLabel( - responses: (EconomySeriesResponse | null)[], -): string | null { - const dates = responses - .map((r) => r?.meta.source?.last_fetched_at) - .filter((d): d is string => !!d) - .sort(); - const latest = dates[dates.length - 1]; - if (!latest) return null; - return new Intl.DateTimeFormat("en-CA", { - month: "long", - year: "numeric", - }).format(new Date(latest)); -} - // A chart card: meta row, chart title + chart, and the source attribution // the data licences require. Cards tile 2×2 on desktop within their section, // ruled by the design's warm hairlines; `wide` cards span both columns @@ -65,9 +46,9 @@ function IndicatorCard({ }) { return (
); @@ -106,10 +99,6 @@ export default async function StateOfTheNationPage() { ); const sections = buildSections((slug) => responseBySlug.get(slug) ?? null); - const indicators = sections.flatMap((section) => section.indicators); - const leadCount = indicators.filter((m) => m.verdict === "lead").length; - const lagCount = indicators.filter((m) => m.verdict === "lag").length; - const updated = lastUpdatedLabel(results); const jsonLd = buildGraph( generateOrganizationSchema(configData), @@ -127,16 +116,15 @@ export default async function StateOfTheNationPage() { dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> -
-
+ {/* Compact masthead — the first chart must land above the fold on + desktop. */} +
+
State of the Nation · 2026
-

+

State of the Nation

-
@@ -148,10 +136,10 @@ export default async function StateOfTheNationPage() { return (
-

+

{section.title}

diff --git a/src/app/state-of-the-nation/state-of-the-nation.ts b/src/app/state-of-the-nation/state-of-the-nation.ts index 85e0dd3..24619c9 100644 --- a/src/app/state-of-the-nation/state-of-the-nation.ts +++ b/src/app/state-of-the-nation/state-of-the-nation.ts @@ -9,7 +9,12 @@ // Every chart derives its stat and series from york_factory at request time; // a chart whose data is unavailable is skipped rather than shown stale. -import type { EconomySeriesPoint, EconomySeriesResponse } from "@/lib/api/economy"; +import { + humanizeSourceName, + humanizeSourceUrl, + type EconomySeriesPoint, + type EconomySeriesResponse, +} from "@/lib/api/economy"; import type { ChartSpec, LineSpec } from "./StateChart"; export type Verdict = "lead" | "lag" | "mixed"; @@ -23,6 +28,9 @@ export type SotnView = { headline: string; body: string; source: string; + // Public landing page for the source (humanizeSourceUrl maps raw API + // endpoints to human pages); null when the API reports no URL. + sourceUrl: string | null; spec: ChartSpec; // Renders full-width above its section's card grid. wide?: boolean; @@ -39,7 +47,6 @@ export const SOTN_MEASURE_SLUGS = [ "employment-rate-55-to-64", "employment-rate-15-plus", "business-entrants", - "business-exits", "employment-rate", "average-hourly-wage", "median-hourly-wage", @@ -81,6 +88,46 @@ function points(get: Getter, slug: string): EconomySeriesPoint[] | null { const last = (ps: EconomySeriesPoint[]) => ps[ps.length - 1]; +// Converts nominal dollars to real dollars using the all-items CPI, expressed +// in the latest CPI year's dollars. Values are deflated by each point's +// calendar-year average CPI. +function cpiDeflator( + get: Getter, +): { toReal: (value: number, year: number) => number; baseYear: number } | null { + const cpi = points(get, "cpi-all-items"); + if (!cpi) return null; + const sums = new Map(); + for (const p of cpi) { + const year = Math.floor(p.year); + const entry = sums.get(year) ?? { sum: 0, n: 0 }; + entry.sum += p.value; + entry.n += 1; + sums.set(year, entry); + } + const avg = new Map( + [...sums].map(([year, { sum, n }]) => [year, sum / n]), + ); + const baseYear = Math.max(...avg.keys()); + const base = avg.get(baseYear)!; + return { + baseYear, + toReal: (value, year) => value * (base / (avg.get(Math.floor(year)) ?? base)), + }; +} + +// Attribution name + public link for a chart, from its primary measure's +// source record. +function sourceLink( + get: Getter, + slug: string, +): { source: string; sourceUrl: string | null } { + const source = get(slug)?.meta.source ?? null; + return { + source: source ? humanizeSourceName(source.name) : "", + sourceUrl: source ? humanizeSourceUrl(source.name, source.url) : null, + }; +} + const int = (v: number) => Math.round(v).toLocaleString("en-CA"); function monthLabel(p: EconomySeriesPoint): string { @@ -95,13 +142,24 @@ function quarterLabel(p: EconomySeriesPoint): string { return `Q${Math.floor((Number(m) - 1) / 3) + 1} ${y}`; } -// First / middle / last tick labels: month-year for short dated series, -// plain years otherwise. -function xLabels(ps: EconomySeriesPoint[]): [string, string, string] { - const span = last(ps).year - ps[0].year; - const label = (p: EconomySeriesPoint) => - p.date && span < 8 ? monthLabel(p) : String(Math.floor(p.year)); - return [label(ps[0]), label(ps[Math.floor((ps.length - 1) / 2)]), label(last(ps))]; +// Each chart spans exactly the years it has data for, so short series fill +// their own panel and long ones show their whole history — the x axis is no +// longer shared across graphs. +function domainLabels(domain: [number, number]): [string, string, string] { + const [a, b] = domain; + return [ + String(Math.floor(a)), + String(Math.round((a + b) / 2)), + String(Math.floor(b)), + ]; +} + +const xs = (ps: EconomySeriesPoint[]) => ps.map((p) => p.year); + +// The [min, max] year span across every series a chart plots. +function domainOf(...xsList: number[][]): [number, number] { + const all = xsList.flat(); + return [Math.min(...all), Math.max(...all)]; } // Restrict several series to the time keys they all report, so every line in @@ -123,12 +181,23 @@ type SotnIndicator = { headline: string; body: string; wide?: boolean; - build: (get: Getter) => (Pick & { spec: ChartSpec }) | null; + build: ( + get: Getter, + ) => + | (Pick & { + spec: ChartSpec; + }) + | null; }; +// The x domain and its labels are derived from the series themselves, so each +// chart spans exactly the years it plots — no shared axis, no clipping. const line = ( - spec: Omit, -): LineSpec => ({ kind: "line", ...spec }); + spec: Omit, +): LineSpec => { + const xDomain = domainOf(...spec.series.map((s) => s.xs)); + return { kind: "line", xDomain, xLabels: domainLabels(xDomain), ...spec }; +}; const INDICATORS: SotnIndicator[] = [ { @@ -145,13 +214,12 @@ const INDICATORS: SotnIndicator[] = [ return { stat: `$${int(latest.value)}`, statSub: `Real GDP per capita, chained 2017 $ · ${quarterLabel(latest)}`, - source: "Statistics Canada (table 36-10-0706)", + ...sourceLink(get, "gdp-per-capita-canada"), spec: line({ unit: "Economic output per person", fmt: "money", - xLabels: xLabels(ps), legend: [{ label: "Canada", color: "au" }], - series: [{ color: "au", points: ps.map((p) => p.value) }], + series: [{ color: "au", xs: xs(ps), points: ps.map((p) => p.value) }], }), }; }, @@ -174,11 +242,10 @@ const INDICATORS: SotnIndicator[] = [ return { stat: `${latest.value.toFixed(1)}%`, statSub: `Employment rate, 25–54 · ${monthLabel(latest)}`, - source: "Statistics Canada (table 14-10-0287)", + ...sourceLink(get, "employment-rate-25-to-54"), spec: line({ unit: "Who's working, by age group", fmt: "pct", - xLabels: xLabels(aligned.base), legend: [ { label: "25–54", color: "au" }, { label: "15–24", color: "clay" }, @@ -186,10 +253,10 @@ const INDICATORS: SotnIndicator[] = [ { label: "15+", color: "ink", dash: true }, ], series: [ - { color: "au", points: aligned.values[0] }, - { color: "clay", points: aligned.values[1] }, - { color: "stone", points: aligned.values[2] }, - { color: "ink", dash: true, points: aligned.values[3] }, + { color: "au", xs: xs(aligned.base), points: aligned.values[0] }, + { color: "clay", xs: xs(aligned.base), points: aligned.values[1] }, + { color: "stone", xs: xs(aligned.base), points: aligned.values[2] }, + { color: "ink", dash: true, xs: xs(aligned.base), points: aligned.values[3] }, ], }), }; @@ -199,36 +266,21 @@ const INDICATORS: SotnIndicator[] = [ n: "03", title: "Business formation", verdict: "lag", - headline: "More businesses now close for good than start.", - body: "Businesses appearing for the first time versus permanently ceasing operations, monthly and seasonally adjusted — true births and deaths, not seasonal reopenings. Experimental estimates (Statistics Canada); an exit is only confirmed once a business stays closed, so the exits line ends about six months before the entrants line.", + headline: "New business creation has stalled.", + body: "Businesses appearing for the first time, monthly and seasonally adjusted — true new-business formation, not seasonal reopenings. Experimental estimates (Statistics Canada).", build: (get) => { const entrants = points(get, "business-entrants"); - const exits = points(get, "business-exits"); - if (!entrants || !exits) return null; - // Exits lag entrants ~6 months: chart both full-length (the exits line - // simply stops early), and compute net formation only for the months - // where both exist. - const exitByKey = new Map(exits.map((p) => [p.date ?? p.year, p.value])); - const overlap = entrants.filter((p) => exitByKey.has(p.date ?? p.year)); - if (overlap.length === 0) return null; - const lastCommon = last(overlap); - const net = lastCommon.value - exitByKey.get(lastCommon.date ?? lastCommon.year)!; + if (!entrants) return null; + const latest = last(entrants); return { - stat: `${net < 0 ? "−" : "+"}${int(Math.abs(net))}`, - statSub: `Net business formation (entrants − exits) · ${monthLabel(lastCommon)}`, - source: "Statistics Canada (table 33-10-0270)", + stat: int(latest.value), + statSub: `New businesses, monthly · ${monthLabel(latest)}`, + ...sourceLink(get, "business-entrants"), spec: line({ - unit: "Businesses starting vs closing for good", + unit: "New businesses started each month", fmt: "count", - xLabels: xLabels(entrants), - legend: [ - { label: "Entrants", color: "au" }, - { label: "Exits", color: "ink", dash: true }, - ], - series: [ - { color: "au", points: entrants.map((p) => p.value) }, - { color: "ink", dash: true, points: exits.map((p) => p.value) }, - ], + legend: [{ label: "New businesses", color: "au" }], + series: [{ color: "au", xs: xs(entrants), points: entrants.map((p) => p.value) }], }), }; }, @@ -246,13 +298,12 @@ const INDICATORS: SotnIndicator[] = [ return { stat: `${latest.value.toFixed(1)}%`, statSub: `Employment rate, 15+ · ${Math.floor(latest.year)}`, - source: "World Bank, World Development Indicators", + ...sourceLink(get, "employment-rate"), spec: line({ unit: "The share of Canadians who work", fmt: "pct", - xLabels: xLabels(ps), legend: [{ label: "Canada", color: "au" }], - series: [{ color: "au", points: ps.map((p) => p.value) }], + series: [{ color: "au", xs: xs(ps), points: ps.map((p) => p.value) }], }), }; }, @@ -262,29 +313,36 @@ const INDICATORS: SotnIndicator[] = [ title: "Wage growth", verdict: "mixed", headline: "Average pay keeps pulling away from the median — gains tilt to the top.", - body: "Hourly wages for all employees, in current dollars (not adjusted for inflation). StatCan's public API carries no 10th or 90th percentile series, so average vs median is the closest available dispersion signal: when the average pulls away from the median, gains are concentrating at the top.", + body: "Hourly wages for all employees, adjusted for inflation with the all-items CPI and expressed in the latest year's dollars. StatCan's public API carries no 10th or 90th percentile series, so average vs median is the closest available dispersion signal: when the average pulls away from the median, gains are concentrating at the top.", build: (get) => { const avg = points(get, "average-hourly-wage"); const med = points(get, "median-hourly-wage"); if (!avg || !med) return null; const aligned = align([avg, med]); if (!aligned) return null; + const deflator = cpiDeflator(get); + const real = (values: number[]) => + deflator + ? values.map((v, i) => deflator.toReal(v, aligned.base[i].year)) + : values; + const [realAvg, realMed] = [real(aligned.values[0]), real(aligned.values[1])]; const latest = last(aligned.base); return { - stat: `$${last(avg).value.toFixed(2)}`, - statSub: `Average hourly wage · ${Math.floor(latest.year)}`, - source: "Statistics Canada (table 14-10-0064)", + stat: `$${realAvg[realAvg.length - 1].toFixed(2)}`, + statSub: `Average hourly wage${deflator ? `, ${deflator.baseYear} dollars` : ""} · ${Math.floor(latest.year)}`, + ...sourceLink(get, "average-hourly-wage"), spec: line({ - unit: "What Canadians earn per hour", + unit: deflator + ? `What Canadians earn per hour, in ${deflator.baseYear} dollars` + : "What Canadians earn per hour", fmt: "money", - xLabels: xLabels(aligned.base), legend: [ { label: "Average", color: "au" }, { label: "Median", color: "ink", dash: true }, ], series: [ - { color: "au", points: aligned.values[0] }, - { color: "ink", dash: true, points: aligned.values[1] }, + { color: "au", xs: xs(aligned.base), points: realAvg }, + { color: "ink", dash: true, xs: xs(aligned.base), points: realMed }, ], }), }; @@ -295,32 +353,54 @@ const INDICATORS: SotnIndicator[] = [ title: "Investment flows", verdict: "lag", headline: "Canadian capital would rather invest abroad than at home.", - body: "Quarterly foreign-direct-investment flows into Canada and Canadian direct investment abroad. Outflows are not a loss — they are Canadian firms investing elsewhere — but the persistent gap shows where capital would rather be. Flows can turn negative when investment is withdrawn.", + body: "Quarterly foreign-direct-investment flows into Canada and Canadian direct investment abroad, adjusted for inflation and smoothed as four-quarter averages (the raw quarters are far too jagged to read). Outflows are not a loss — they are Canadian firms investing elsewhere — but the persistent gap shows where capital would rather be.", build: (get) => { const inflows = points(get, "fdi-inflows"); const outflows = points(get, "fdi-outflows"); if (!inflows || !outflows) return null; const aligned = align([inflows, outflows]); if (!aligned) return null; - const net = - aligned.values[0][aligned.values[0].length - 1] - - aligned.values[1][aligned.values[1].length - 1]; - const latest = last(aligned.base); + const deflator = cpiDeflator(get); + const real = (values: number[]) => + deflator + ? values.map((v, i) => deflator.toReal(v, aligned.base[i].year)) + : values; + const [realIn, realOut] = aligned.values.map(real); + // Quarterly FDI is extremely noisy; a trailing four-quarter average + // keeps the in-vs-out comparison legible. + const WINDOW = 4; + if (aligned.base.length <= WINDOW) return null; + const smooth = (values: number[]) => + values + .slice(WINDOW - 1) + .map( + (_, i) => + values.slice(i, i + WINDOW).reduce((a, b) => a + b, 0) / WINDOW, + ); + const base = aligned.base.slice(WINDOW - 1); + // Trailing-year net flow: what actually arrived minus what left over + // the last four quarters. + const net = realIn + .slice(-WINDOW) + .reduce((a, b) => a + b, 0) - + realOut.slice(-WINDOW).reduce((a, b) => a + b, 0); + const latest = last(base); return { stat: `${net < 0 ? "−" : "+"}$${int(Math.abs(net) / 1000)}B`, - statSub: `Net direct investment flow · ${quarterLabel(latest)}`, - source: "Statistics Canada (table 36-10-0025)", + statSub: `Net direct investment, trailing year${deflator ? `, ${deflator.baseYear} dollars` : ""} · ${quarterLabel(latest)}`, + ...sourceLink(get, "fdi-inflows"), spec: line({ - unit: "Investment coming into Canada vs leaving it", + unit: deflator + ? `Investment coming into Canada vs leaving it, in ${deflator.baseYear} dollars` + : "Investment coming into Canada vs leaving it", fmt: "count", - xLabels: xLabels(aligned.base), legend: [ - { label: "Into Canada", color: "au" }, - { label: "Canadian investment abroad", color: "ink", dash: true }, + { label: "Into Canada (4-qtr avg)", color: "au" }, + { label: "Abroad (4-qtr avg)", color: "ink", dash: true }, ], series: [ - { color: "au", points: aligned.values[0] }, - { color: "ink", dash: true, points: aligned.values[1] }, + { color: "au", xs: xs(base), points: smooth(realIn) }, + { color: "ink", dash: true, xs: xs(base), points: smooth(realOut) }, ], }), }; @@ -339,13 +419,12 @@ const INDICATORS: SotnIndicator[] = [ return { stat: `${latest.value.toFixed(1)}%`, statSub: `Gross capital formation, % of GDP · ${Math.floor(latest.year)}`, - source: "World Bank, World Development Indicators", + ...sourceLink(get, "capital-formation-pct-gdp"), spec: line({ unit: "How much of the economy goes to building", fmt: "pct", - xLabels: xLabels(ps), legend: [{ label: "Canada", color: "au" }], - series: [{ color: "au", points: ps.map((p) => p.value) }], + series: [{ color: "au", xs: xs(ps), points: ps.map((p) => p.value) }], }), }; }, @@ -366,18 +445,17 @@ const INDICATORS: SotnIndicator[] = [ return { stat: `${Math.round(latest.value)}%`, statSub: `General government gross debt to GDP · ${quarterLabel(latest)}`, - source: "Statistics Canada (table 38-10-0237)", + ...sourceLink(get, "govt-gross-debt-to-gdp"), spec: line({ unit: "What every level of government owes", fmt: "pct", - xLabels: xLabels(aligned.base), legend: [ { label: "Gross debt", color: "au" }, { label: "Net liabilities", color: "ink", dash: true }, ], series: [ - { color: "au", points: aligned.values[0] }, - { color: "ink", dash: true, points: aligned.values[1] }, + { color: "au", xs: xs(aligned.base), points: aligned.values[0] }, + { color: "ink", dash: true, xs: xs(aligned.base), points: aligned.values[1] }, ], }), }; @@ -405,20 +483,19 @@ const INDICATORS: SotnIndicator[] = [ return { stat: `${pubShare[pubShare.length - 1].toFixed(1)}%`, statSub: `Public share of employment · ${monthLabel(latest)}`, - source: "Statistics Canada (table 14-10-0288)", + ...sourceLink(get, "employment-all-classes"), spec: line({ unit: "Where Canadians work: public vs private", fmt: "pct", - xLabels: xLabels(aligned.base), legend: [ { label: "Public sector", color: "au" }, { label: "Private sector", color: "ink", dash: true }, { label: "Self-employed", color: "stone" }, ], series: [ - { color: "au", points: pubShare }, - { color: "ink", dash: true, points: share(privV) }, - { color: "stone", points: share(selfV) }, + { color: "au", xs: xs(aligned.base), points: pubShare }, + { color: "ink", dash: true, xs: xs(aligned.base), points: share(privV) }, + { color: "stone", xs: xs(aligned.base), points: share(selfV) }, ], }), }; @@ -438,18 +515,17 @@ const INDICATORS: SotnIndicator[] = [ return { stat: String(latest.value.toFixed(1)), statSub: `CPI, all items (2002 = 100) · ${monthLabel(latest)}`, - source: "Statistics Canada (table 18-10-0004)", + ...sourceLink(get, "cpi-all-items"), spec: line({ unit: "Consumer prices vs the 2% target", fmt: "index", - xLabels: xLabels(cpi), legend: [ { label: "CPI", color: "au" }, { label: "2% target path", color: "ink", dash: true }, ], series: [ - { color: "au", points: cpi.map((p) => p.value) }, - { color: "ink", dash: true, points: target }, + { color: "au", xs: xs(cpi), points: cpi.map((p) => p.value) }, + { color: "ink", dash: true, xs: xs(cpi), points: target }, ], }), }; @@ -468,13 +544,12 @@ const INDICATORS: SotnIndicator[] = [ return { stat: `${(latest.value / 100).toFixed(1)}×`, statSub: `House price to income vs. long-term norm · ${Math.floor(latest.year)}`, - source: "OECD Analytical House Prices Database", + ...sourceLink(get, "house-price-to-income"), spec: line({ unit: "Home prices vs incomes (100 = the long-term norm)", fmt: "index", - xLabels: xLabels(ps), legend: [{ label: "Canada", color: "au" }], - series: [{ color: "au", points: ps.map((p) => p.value) }], + series: [{ color: "au", xs: xs(ps), points: ps.map((p) => p.value) }], }), }; }, @@ -492,13 +567,12 @@ const INDICATORS: SotnIndicator[] = [ return { stat: int(latest.value), statSub: `Dwelling units started · ${Math.floor(latest.year)}`, - source: "CMHC via Statistics Canada (table 34-10-0126)", + ...sourceLink(get, "housing-starts-canada"), spec: line({ unit: "Homes started each year", fmt: "count", - xLabels: xLabels(ps), legend: [{ label: "Housing starts", color: "au" }], - series: [{ color: "au", points: ps.map((p) => p.value) }], + series: [{ color: "au", xs: xs(ps), points: ps.map((p) => p.value) }], }), }; }, @@ -520,13 +594,12 @@ const INDICATORS: SotnIndicator[] = [ return { stat: int(net[net.length - 1]), statSub: `Net emigration · ${Math.floor(latest.year)} (July–June year)`, - source: "Statistics Canada (table 17-10-0008)", + ...sourceLink(get, "emigrants-annual"), spec: line({ unit: "People leaving Canada for good", fmt: "count", - xLabels: xLabels(aligned.base), legend: [{ label: "Net emigration", color: "au" }], - series: [{ color: "au", points: net }], + series: [{ color: "au", xs: xs(aligned.base), points: net }], }), }; }, @@ -544,13 +617,12 @@ const INDICATORS: SotnIndicator[] = [ return { stat: int(latest.value), statSub: `Immigrants admitted · ${Math.floor(latest.year)} (July–June year)`, - source: "Statistics Canada (table 17-10-0008)", + ...sourceLink(get, "immigrants-annual"), spec: line({ unit: "New permanent residents each year", fmt: "count", - xLabels: xLabels(ps), legend: [{ label: "Immigrants", color: "au" }], - series: [{ color: "au", points: ps.map((p) => p.value) }], + series: [{ color: "au", xs: xs(ps), points: ps.map((p) => p.value) }], }), }; }, @@ -571,18 +643,17 @@ const INDICATORS: SotnIndicator[] = [ return { stat: `${(last(permits).value / 1_000_000).toFixed(2)}M`, statSub: `Work-permit holders · ${quarterLabel(latest)}`, - source: "Statistics Canada (table 17-10-0121)", + ...sourceLink(get, "npr-work-permit-holders"), spec: line({ unit: "People in Canada on temporary permits", fmt: "count", - xLabels: xLabels(aligned.base), legend: [ { label: "Work permits", color: "au" }, { label: "All non-permanent residents", color: "ink", dash: true }, ], series: [ - { color: "au", points: aligned.values[0] }, - { color: "ink", dash: true, points: aligned.values[1] }, + { color: "au", xs: xs(aligned.base), points: aligned.values[0] }, + { color: "ink", dash: true, xs: xs(aligned.base), points: aligned.values[1] }, ], }), }; @@ -607,11 +678,10 @@ const INDICATORS: SotnIndicator[] = [ return { stat: int(last(total).value), statSub: `Permanent residents admitted, monthly · ${monthLabel(latest)}`, - source: "Immigration, Refugees and Citizenship Canada", + ...sourceLink(get, "pr-admissions-total"), spec: line({ unit: "Permanent residents by immigration class", fmt: "count", - xLabels: xLabels(aligned.base), legend: [ { label: "Economic", color: "au" }, { label: "Total", color: "ink", dash: true }, @@ -620,11 +690,11 @@ const INDICATORS: SotnIndicator[] = [ { label: "Other", color: "sand" }, ], series: [ - { color: "au", points: aligned.values[1] }, - { color: "ink", dash: true, points: aligned.values[0] }, - { color: "clay", points: aligned.values[2] }, - { color: "stone", points: aligned.values[3] }, - { color: "sand", points: aligned.values[4] }, + { color: "au", xs: xs(aligned.base), points: aligned.values[1] }, + { color: "ink", dash: true, xs: xs(aligned.base), points: aligned.values[0] }, + { color: "clay", xs: xs(aligned.base), points: aligned.values[2] }, + { color: "stone", xs: xs(aligned.base), points: aligned.values[3] }, + { color: "sand", xs: xs(aligned.base), points: aligned.values[4] }, ], }), }; diff --git a/src/lib/api/economy.ts b/src/lib/api/economy.ts index 09d5ead..44228a5 100644 --- a/src/lib/api/economy.ts +++ b/src/lib/api/economy.ts @@ -158,6 +158,13 @@ export function humanizeSourceUrl( ); if (worldBank) return `https://data.worldbank.org/indicator/${worldBank[1].replace(/^GOV_WGI_/, "")}`; + // OECD source URLs are raw SDMX CSV queries; deep-link the OECD Data + // Explorer for the same agency + dataflow instead. + const oecd = url?.match( + /^https:\/\/sdmx\.oecd\.org\/public\/rest\/data\/([^,]+),([^,]+),/, + ); + if (oecd) + return `https://data-explorer.oecd.org/vis?df[ds]=dsDisseminate&df[id]=${encodeURIComponent(oecd[2])}&df[ag]=${encodeURIComponent(oecd[1])}`; return url; } From a5020931b76c9205986b765f312ddffb465826c0 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Thu, 16 Jul 2026 10:20:59 -0400 Subject: [PATCH 09/12] titles --- .../state-of-the-nation.ts | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/app/state-of-the-nation/state-of-the-nation.ts b/src/app/state-of-the-nation/state-of-the-nation.ts index 24619c9..993068a 100644 --- a/src/app/state-of-the-nation/state-of-the-nation.ts +++ b/src/app/state-of-the-nation/state-of-the-nation.ts @@ -216,7 +216,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Real GDP per capita, chained 2017 $ · ${quarterLabel(latest)}`, ...sourceLink(get, "gdp-per-capita-canada"), spec: line({ - unit: "Economic output per person", + unit: "Real GDP per capita", fmt: "money", legend: [{ label: "Canada", color: "au" }], series: [{ color: "au", xs: xs(ps), points: ps.map((p) => p.value) }], @@ -244,7 +244,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Employment rate, 25–54 · ${monthLabel(latest)}`, ...sourceLink(get, "employment-rate-25-to-54"), spec: line({ - unit: "Who's working, by age group", + unit: "Employment rate by age group", fmt: "pct", legend: [ { label: "25–54", color: "au" }, @@ -277,7 +277,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `New businesses, monthly · ${monthLabel(latest)}`, ...sourceLink(get, "business-entrants"), spec: line({ - unit: "New businesses started each month", + unit: "New business formation, monthly", fmt: "count", legend: [{ label: "New businesses", color: "au" }], series: [{ color: "au", xs: xs(entrants), points: entrants.map((p) => p.value) }], @@ -300,7 +300,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Employment rate, 15+ · ${Math.floor(latest.year)}`, ...sourceLink(get, "employment-rate"), spec: line({ - unit: "The share of Canadians who work", + unit: "Employment rate, ages 15 and over", fmt: "pct", legend: [{ label: "Canada", color: "au" }], series: [{ color: "au", xs: xs(ps), points: ps.map((p) => p.value) }], @@ -333,8 +333,8 @@ const INDICATORS: SotnIndicator[] = [ ...sourceLink(get, "average-hourly-wage"), spec: line({ unit: deflator - ? `What Canadians earn per hour, in ${deflator.baseYear} dollars` - : "What Canadians earn per hour", + ? `Real hourly wages, average vs median (${deflator.baseYear} dollars)` + : "Hourly wages, average vs median", fmt: "money", legend: [ { label: "Average", color: "au" }, @@ -391,8 +391,8 @@ const INDICATORS: SotnIndicator[] = [ ...sourceLink(get, "fdi-inflows"), spec: line({ unit: deflator - ? `Investment coming into Canada vs leaving it, in ${deflator.baseYear} dollars` - : "Investment coming into Canada vs leaving it", + ? `Foreign direct investment, inflows vs outflows (${deflator.baseYear} dollars)` + : "Foreign direct investment, inflows vs outflows", fmt: "count", legend: [ { label: "Into Canada (4-qtr avg)", color: "au" }, @@ -421,7 +421,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Gross capital formation, % of GDP · ${Math.floor(latest.year)}`, ...sourceLink(get, "capital-formation-pct-gdp"), spec: line({ - unit: "How much of the economy goes to building", + unit: "Gross capital formation, share of GDP", fmt: "pct", legend: [{ label: "Canada", color: "au" }], series: [{ color: "au", xs: xs(ps), points: ps.map((p) => p.value) }], @@ -447,7 +447,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `General government gross debt to GDP · ${quarterLabel(latest)}`, ...sourceLink(get, "govt-gross-debt-to-gdp"), spec: line({ - unit: "What every level of government owes", + unit: "General government debt, share of GDP", fmt: "pct", legend: [ { label: "Gross debt", color: "au" }, @@ -485,7 +485,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Public share of employment · ${monthLabel(latest)}`, ...sourceLink(get, "employment-all-classes"), spec: line({ - unit: "Where Canadians work: public vs private", + unit: "Employment share by class of worker", fmt: "pct", legend: [ { label: "Public sector", color: "au" }, @@ -517,7 +517,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `CPI, all items (2002 = 100) · ${monthLabel(latest)}`, ...sourceLink(get, "cpi-all-items"), spec: line({ - unit: "Consumer prices vs the 2% target", + unit: "Consumer Price Index vs the 2% target path", fmt: "index", legend: [ { label: "CPI", color: "au" }, @@ -546,7 +546,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `House price to income vs. long-term norm · ${Math.floor(latest.year)}`, ...sourceLink(get, "house-price-to-income"), spec: line({ - unit: "Home prices vs incomes (100 = the long-term norm)", + unit: "House-price-to-income ratio (100 = long-term norm)", fmt: "index", legend: [{ label: "Canada", color: "au" }], series: [{ color: "au", xs: xs(ps), points: ps.map((p) => p.value) }], @@ -569,7 +569,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Dwelling units started · ${Math.floor(latest.year)}`, ...sourceLink(get, "housing-starts-canada"), spec: line({ - unit: "Homes started each year", + unit: "Annual housing starts", fmt: "count", legend: [{ label: "Housing starts", color: "au" }], series: [{ color: "au", xs: xs(ps), points: ps.map((p) => p.value) }], @@ -596,7 +596,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Net emigration · ${Math.floor(latest.year)} (July–June year)`, ...sourceLink(get, "emigrants-annual"), spec: line({ - unit: "People leaving Canada for good", + unit: "Emigrants, net of returning Canadians", fmt: "count", legend: [{ label: "Net emigration", color: "au" }], series: [{ color: "au", xs: xs(aligned.base), points: net }], @@ -619,7 +619,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Immigrants admitted · ${Math.floor(latest.year)} (July–June year)`, ...sourceLink(get, "immigrants-annual"), spec: line({ - unit: "New permanent residents each year", + unit: "Permanent residents admitted per year", fmt: "count", legend: [{ label: "Immigrants", color: "au" }], series: [{ color: "au", xs: xs(ps), points: ps.map((p) => p.value) }], @@ -645,7 +645,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Work-permit holders · ${quarterLabel(latest)}`, ...sourceLink(get, "npr-work-permit-holders"), spec: line({ - unit: "People in Canada on temporary permits", + unit: "Work-permit holders vs all non-permanent residents", fmt: "count", legend: [ { label: "Work permits", color: "au" }, @@ -680,7 +680,7 @@ const INDICATORS: SotnIndicator[] = [ statSub: `Permanent residents admitted, monthly · ${monthLabel(latest)}`, ...sourceLink(get, "pr-admissions-total"), spec: line({ - unit: "Permanent residents by immigration class", + unit: "Permanent-resident admissions by class", fmt: "count", legend: [ { label: "Economic", color: "au" }, From eb2e5c87a3bfac9fd57ef661360b84b0585bc376 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Thu, 16 Jul 2026 11:45:17 -0400 Subject: [PATCH 10/12] tweaks --- src/app/state-of-the-nation/StateChart.tsx | 60 ++++++++++++---- .../state-of-the-nation.ts | 70 +++++++++++++++---- 2 files changed, 102 insertions(+), 28 deletions(-) diff --git a/src/app/state-of-the-nation/StateChart.tsx b/src/app/state-of-the-nation/StateChart.tsx index 8be9996..3e5100e 100644 --- a/src/app/state-of-the-nation/StateChart.tsx +++ b/src/app/state-of-the-nation/StateChart.tsx @@ -4,7 +4,14 @@ // labels) are the design's own values and have no site token; ink and // auburn map to the site palette. -export type ChartFmt = "money" | "pct" | "pct1" | "x" | "index" | "count"; +export type ChartFmt = + | "money" + | "pct" + | "pct1" + | "x" + | "index" + | "count" + | "num"; // au = brand accent for the headline series; ink = comparison (usually // dashed); clay/stone/sand = warm muted tones for additional series. @@ -22,6 +29,9 @@ export type LineSpec = { xDomain: [number, number]; // Tick labels for the left edge, middle, and right edge of the domain. xLabels: [string, string, string]; + // Optional explicit x-axis tick years (e.g. decade marks). When present they + // replace the start/mid/end labels and sit at their true date on the axis. + xTicks?: number[]; legend?: LegendItem[]; // xs holds each point's fractional year, parallel to points. series: { @@ -61,6 +71,8 @@ function fmt(v: number, f: ChartFmt): string { if (f === "pct1") return v.toFixed(1) + "%"; if (f === "x") return v.toFixed(1) + "×"; if (f === "index") return String(Math.round(v)); + if (f === "num") + return v.toLocaleString("en-CA", { maximumFractionDigits: 1 }); return Math.round(v).toLocaleString("en-CA"); } @@ -175,19 +187,39 @@ function LineChart({ spec, wide }: { spec: LineSpec; wide?: boolean }) { strokeWidth={1} /> )} - {spec.xLabels.map((label, k) => ( - - {label} - - ))} + {spec.xTicks + ? spec.xTicks.map((t) => { + const [d0, d1] = spec.xDomain; + // Keep a tick that lands on the domain edge from spilling past it. + const anchor = + t <= d0 ? "start" : t >= d1 ? "end" : "middle"; + return ( + + {t} + + ); + }) + : spec.xLabels.map((label, k) => ( + + {label} + + ))} {spec.series.map((s, si) => { const col = SERIES_COLORS[s.color]; const end = s.points.length - 1; diff --git a/src/app/state-of-the-nation/state-of-the-nation.ts b/src/app/state-of-the-nation/state-of-the-nation.ts index 993068a..6025393 100644 --- a/src/app/state-of-the-nation/state-of-the-nation.ts +++ b/src/app/state-of-the-nation/state-of-the-nation.ts @@ -62,6 +62,7 @@ export const SOTN_MEASURE_SLUGS = [ "cpi-all-items", "house-price-to-income", "housing-starts-canada", + "population-canada", "emigrants-annual", "returning-emigrants-annual", "immigrants-annual", @@ -162,6 +163,14 @@ function domainOf(...xsList: number[][]): [number, number] { return [Math.min(...all), Math.max(...all)]; } +// The decade marks (1970, 1980, …) that fall inside a domain — used to label +// the x axis of long-running charts. +function decades([a, b]: [number, number]): number[] { + const ticks: number[] = []; + for (let y = Math.ceil(a / 10) * 10; y <= b; y += 10) ticks.push(y); + return ticks; +} + // Restrict several series to the time keys they all report, so every line in // a combined chart has the same length. function align( @@ -211,15 +220,24 @@ const INDICATORS: SotnIndicator[] = [ const ps = points(get, "gdp-per-capita-canada"); if (!ps) return null; const latest = last(ps); + // Index to the first point (= 100) so the chart reads as cumulative + // growth in living standards rather than an abstract dollar level: the + // long climb and the post-2022 plateau both show at a glance. The + // headline stat keeps the concrete dollar figure. + const baseYear = Math.floor(ps[0].year); + const indexed = ps.map((p) => (p.value / ps[0].value) * 100); return { stat: `$${int(latest.value)}`, statSub: `Real GDP per capita, chained 2017 $ · ${quarterLabel(latest)}`, ...sourceLink(get, "gdp-per-capita-canada"), spec: line({ - unit: "Real GDP per capita", - fmt: "money", + unit: `Real GDP per capita (index, ${baseYear} = 100)`, + fmt: "index", + // The headline chart spans decades; label each one rather than just + // the endpoints. + xTicks: decades(domainOf(xs(ps))), legend: [{ label: "Canada", color: "au" }], - series: [{ color: "au", xs: xs(ps), points: ps.map((p) => p.value) }], + series: [{ color: "au", xs: xs(ps), points: indexed }], }), }; }, @@ -557,22 +575,46 @@ const INDICATORS: SotnIndicator[] = [ { n: "12", title: "Housing starts", - verdict: "lead", - headline: "Homebuilding is running at rates last sustained in the 1970s.", - body: "Dwelling units started per year across Canada, from CMHC's starts and completions survey. The supply side of the housing equation — rising at last, and still short of what population growth demands.", + verdict: "lag", + headline: "Per person, Canada builds barely half as many homes as it did in the 1970s.", + body: "Dwelling units started each year per 1,000 people, from CMHC's starts and completions survey against StatCan's population estimates. In absolute terms starts are back near their 1970s highs — but the population has more than doubled since then, so on a per-person basis homebuilding runs at roughly half the mid-1970s rate. This is the honest read on whether supply is keeping up with the people who need homes.", build: (get) => { const ps = points(get, "housing-starts-canada"); - if (!ps) return null; - const latest = last(ps); + const pop = points(get, "population-canada"); + if (!ps || !pop) return null; + // Population is quarterly; collapse it to a calendar-year average so it + // divides the annual starts flow cleanly. Then express starts per 1,000 + // people — the only way to compare a period over which the population + // more than doubled. + const popByYear = new Map(); + for (const p of pop) { + const y = Math.floor(p.year); + const e = popByYear.get(y) ?? { sum: 0, n: 0 }; + e.sum += p.value; + e.n += 1; + popByYear.set(y, e); + } + const perCapita = ps.flatMap((p) => { + const e = popByYear.get(Math.floor(p.year)); + return e ? [{ year: p.year, value: (p.value / (e.sum / e.n)) * 1000 }] : []; + }); + if (perCapita.length < 2) return null; + const latest = perCapita[perCapita.length - 1]; return { - stat: int(latest.value), - statSub: `Dwelling units started · ${Math.floor(latest.year)}`, + stat: latest.value.toFixed(1), + statSub: `Dwelling units started per 1,000 people · ${Math.floor(latest.year)}`, ...sourceLink(get, "housing-starts-canada"), spec: line({ - unit: "Annual housing starts", - fmt: "count", - legend: [{ label: "Housing starts", color: "au" }], - series: [{ color: "au", xs: xs(ps), points: ps.map((p) => p.value) }], + unit: "Housing starts per 1,000 people", + fmt: "num", + legend: [{ label: "Starts per 1,000", color: "au" }], + series: [ + { + color: "au", + xs: perCapita.map((p) => p.year), + points: perCapita.map((p) => p.value), + }, + ], }), }; }, From 1c9b525edc805cd2128e37a4893986ab74ecb51f Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Thu, 16 Jul 2026 15:08:39 -0400 Subject: [PATCH 11/12] polish --- src/app/state-of-the-nation/StateChart.tsx | 110 ++++--- .../state-of-the-nation.ts | 306 +++++++++++------- src/lib/api/economy.ts | 4 + 3 files changed, 262 insertions(+), 158 deletions(-) diff --git a/src/app/state-of-the-nation/StateChart.tsx b/src/app/state-of-the-nation/StateChart.tsx index 3e5100e..05e8b89 100644 --- a/src/app/state-of-the-nation/StateChart.tsx +++ b/src/app/state-of-the-nation/StateChart.tsx @@ -32,6 +32,10 @@ export type LineSpec = { // Optional explicit x-axis tick years (e.g. decade marks). When present they // replace the start/mid/end labels and sit at their true date on the axis. xTicks?: number[]; + // When set, the y axis frames the data around this reference value instead + // of anchoring at zero, and a floating horizontal rule is drawn at it. Used + // by indexed series (e.g. 100 = base year) where zero carries no meaning. + baseline?: number; legend?: LegendItem[]; // xs holds each point's fractional year, parallel to points. series: { @@ -85,37 +89,56 @@ function niceStep(raw: number): number { return nice * mag; } -// Frames the y axis around the data's own range rather than forcing a zero -// baseline. Most of these series never approach zero — employment rates in the -// 80s, index values near 150, GDP per capita in the tens of thousands — so -// anchoring there flattens every line into a straight streak near the top. -// Returns a padded range snapped to round tick values (~4–6 gridlines). Zero -// is kept on the chart whenever the data goes negative, and a positive series -// that sits close to zero still floors at zero rather than floating above it. +// Builds the tick array for a snapped [mn, mx] range. The 0.001·step slack +// absorbs floating-point drift so the top tick isn't dropped or doubled. +function buildTicks(mn: number, mx: number, step: number): number[] { + const ticks: number[] = []; + for (let v = mn; v <= mx + step * 0.001; v += step) { + ticks.push(Math.abs(v) < step * 1e-9 ? 0 : v); + } + return ticks; +} + +// Chooses the y-axis range and round tick values. +// - Default: anchored at zero, so every line reads as a magnitude from zero +// and the charts are comparable (extends below zero only for negative data). +// - With a `baseline` (e.g. an index's 100): frames the data around that +// reference instead — zero carries no meaning for an indexed series — while +// guaranteeing the baseline itself stays on the chart. function niceAxis( dataMin: number, dataMax: number, + baseline?: number, ): { mn: number; mx: number; ticks: number[] } { - let lo = dataMin; - let hi = dataMax; - if (lo === hi) { - lo -= 1; - hi += 1; + if (baseline !== undefined) { + let lo = Math.min(dataMin, baseline); + let hi = Math.max(dataMax, baseline); + if (hi === lo) { + lo -= 1; + hi += 1; + } + const span = hi - lo; + lo -= span * 0.08; + hi += span * 0.08; + const step = niceStep((hi - lo) / 4); + const mn = Math.floor(lo / step) * step; + const mx = Math.ceil(hi / step) * step; + return { mn, mx, ticks: buildTicks(mn, mx, step) }; } + // Start from zero on both ends, then open whichever side the data occupies. + let lo = Math.min(0, dataMin); + let hi = Math.max(0, dataMax); + if (hi === lo) hi += 1; const span = hi - lo; - lo -= span * 0.08; - hi += span * 0.08; - if (dataMin >= 0 && lo < 0) lo = 0; + // Pad only away from zero; the zero side stays pinned to the axis. + if (hi > 0) hi += span * 0.08; + if (lo < 0) lo -= span * 0.08; const step = niceStep((hi - lo) / 4); - const mn = Math.floor(lo / step) * step; - const mx = Math.ceil(hi / step) * step; - const ticks: number[] = []; - // The 0.001·step slack absorbs floating-point drift so the top tick isn't - // dropped or doubled. - for (let v = mn; v <= mx + step * 0.001; v += step) { - ticks.push(Math.abs(v) < step * 1e-9 ? 0 : v); - } - return { mn, mx, ticks }; + // Zero stays exactly on a gridline: the positive side ceils to a round tick, + // the negative side (if any) floors to one. + const mn = lo < 0 ? Math.floor(lo / step) * step : 0; + const mx = hi > 0 ? Math.ceil(hi / step) * step : 0; + return { mn, mx, ticks: buildTicks(mn, mx, step) }; } function LineChart({ spec, wide }: { spec: LineSpec; wide?: boolean }) { @@ -131,16 +154,24 @@ function LineChart({ spec, wide }: { spec: LineSpec; wide?: boolean }) { const iw = W - L - R, ih = H - T - B; const all = spec.series.flatMap((s) => s.points); - // Frame the y axis around the data's own range (see niceAxis) so each trend - // fills the panel instead of flattening against a zero baseline. - const { mn, mx, ticks } = niceAxis(Math.min(...all), Math.max(...all)); - const crossesZero = mn < 0 && mx > 0; + const { mn, mx, ticks } = niceAxis( + Math.min(...all), + Math.max(...all), + spec.baseline, + ); const [d0, d1] = spec.xDomain; const X = (x: number) => L + iw * ((x - d0) / (d1 - d0)); const Y = (v: number) => T + ih * (1 - (v - mn) / (mx - mn)); - // Area fills close at the bottom of the framed panel — or at the zero line - // when the data straddles it. - const baseY = Y(crossesZero ? 0 : mn); + // The emphasized horizontal rule: the declared baseline (e.g. an index's + // 100) when present, otherwise the zero axis. Area fills close here. + const refVal = spec.baseline ?? 0; + const refOnAxis = refVal >= mn - 1e-9 && refVal <= mx + 1e-9; + // Drawn as a floating line only when it sits inside the range and isn't + // already one of the round ticks (which carry the ink weight themselves). + const refOnTick = ticks.some((t) => Math.abs(t - refVal) < 1e-9); + const showFloatingRef = + refOnAxis && !refOnTick && refVal > mn + 1e-9 && refVal < mx - 1e-9; + const baseY = Y(refOnAxis ? refVal : mn); // Ticks sit at the domain edges and middle — identical across charts. const tickX = [L, L + iw / 2, L + iw]; @@ -151,10 +182,11 @@ function LineChart({ spec, wide }: { spec: LineSpec; wide?: boolean }) { style={{ display: "block", overflow: "visible" }} preserveAspectRatio="xMidYMid meet" > - {ticks.map((tv, i) => { + {ticks.map((tv) => { const gy = Y(tv); - // The lowest tick doubles as the bottom axis and gets the ink weight. - const isBase = i === 0; + // The reference line (baseline, or zero by default) carries the ink + // weight; every other tick is a light gridline. + const isRef = Math.abs(tv - refVal) < 1e-9; return ( ); })} - {crossesZero && ( + {showFloatingRef && ( diff --git a/src/app/state-of-the-nation/state-of-the-nation.ts b/src/app/state-of-the-nation/state-of-the-nation.ts index 6025393..083ca8d 100644 --- a/src/app/state-of-the-nation/state-of-the-nation.ts +++ b/src/app/state-of-the-nation/state-of-the-nation.ts @@ -47,13 +47,17 @@ export const SOTN_MEASURE_SLUGS = [ "employment-rate-55-to-64", "employment-rate-15-plus", "business-entrants", - "employment-rate", + // "employment-rate" (annual 15+) removed with the "Employment" chart below — + // redundant with "Employment by age", which carries the 15+ line monthly. + // "employment-rate", "average-hourly-wage", "median-hourly-wage", - "fdi-inflows", - "fdi-outflows", + "fdi-position-in-canada", + "cdi-position-abroad", "capital-formation-pct-gdp", - "govt-gross-debt-to-gdp", + // Gross debt is deliberately not fetched or charted — it ignores the + // financial assets governments hold and overstates the burden. The debt + // chart shows net financial debt only (govt-net-debt-to-gdp). "govt-net-debt-to-gdp", "employment-all-classes", "employment-private-sector", @@ -65,14 +69,22 @@ export const SOTN_MEASURE_SLUGS = [ "population-canada", "emigrants-annual", "returning-emigrants-annual", - "immigrants-annual", - "npr-work-permit-holders", - "npr-total", + "pr-admissions-annual-historical", + // Temporarily disabled: the NPR-by-type series only start 2021 Q3, which was + // dragging the shared chart window forward to 2021. Commented out with the + // "Temporary foreign workers" indicator below so the window can reach back + // to 2015 (the next-shortest series: business formation and PR admissions). + // "npr-work-permit-holders", + // "npr-total", "pr-admissions-total", "pr-admissions-economic", "pr-admissions-family", "pr-admissions-refugee", "pr-admissions-other", + "pr-admissions-economic-historical", + "pr-admissions-family-historical", + "pr-admissions-refugee-historical", + "pr-admissions-other-historical", ]; const MONTHS = [ @@ -80,11 +92,35 @@ const MONTHS = [ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; -function points(get: Getter, slug: string): EconomySeriesPoint[] | null { +// The x window charts render in — a fixed 2000 start through the latest point +// any series reports. Set at the top of buildIndicators. +let sharedDomain: [number, number] = [2000, 2001]; + +// Real GDP per capita is the one exception: it starts earlier to show its +// deeper history (data begins 1981). +const GDP_START = 1980; + +// Trims a series to a window start (default: the shared 2000 start; GDP passes +// GDP_START). Series with longer histories are cut back to it; series that +// begin later are left untouched and simply render partway into the window. +function clip( + ps: EconomySeriesPoint[] | null, + start = sharedDomain[0], +): EconomySeriesPoint[] | null { + if (!ps) return null; + const kept = ps.filter((p) => p.year >= start - 1e-6); + return kept.length > 1 ? kept : null; +} + +function points( + get: Getter, + slug: string, + start = sharedDomain[0], +): EconomySeriesPoint[] | null { const series = get(slug)?.data.series.find( (s) => s.jurisdiction.slug === "ca", ); - return series && series.points.length > 1 ? series.points : null; + return clip(series && series.points.length > 1 ? series.points : null, start); } const last = (ps: EconomySeriesPoint[]) => ps[ps.length - 1]; @@ -157,20 +193,6 @@ function domainLabels(domain: [number, number]): [string, string, string] { const xs = (ps: EconomySeriesPoint[]) => ps.map((p) => p.year); -// The [min, max] year span across every series a chart plots. -function domainOf(...xsList: number[][]): [number, number] { - const all = xsList.flat(); - return [Math.min(...all), Math.max(...all)]; -} - -// The decade marks (1970, 1980, …) that fall inside a domain — used to label -// the x axis of long-running charts. -function decades([a, b]: [number, number]): number[] { - const ticks: number[] = []; - for (let y = Math.ceil(a / 10) * 10; y <= b; y += 10) ticks.push(y); - return ticks; -} - // Restrict several series to the time keys they all report, so every line in // a combined chart has the same length. function align( @@ -199,15 +221,54 @@ type SotnIndicator = { | null; }; -// The x domain and its labels are derived from the series themselves, so each -// chart spans exactly the years it plots — no shared axis, no clipping. +// Charts render in the shared x window (see buildIndicators) so the timelines +// line up. A chart may pass an earlier `start` to reach further back — only +// Real GDP per capita does, to show its deeper history. const line = ( spec: Omit, + start = sharedDomain[0], ): LineSpec => { - const xDomain = domainOf(...spec.series.map((s) => s.xs)); - return { kind: "line", xDomain, xLabels: domainLabels(xDomain), ...spec }; + const domain: [number, number] = [start, sharedDomain[1]]; + return { + kind: "line", + xDomain: domain, + xLabels: domainLabels(domain), + ...spec, + }; }; +// Splices an IRCC permanent-resident series into one annual line: archived +// annual values for years before the cutover, then the ongoing monthly series +// summed to complete calendar years from the cutover on. Complete years only — +// a partial current year would read as a false collapse. Both slugs are +// clipped to the shared window start by points(). +function spliceIrccAdmissions( + get: Getter, + historicalSlug: string, + monthlySlug: string, + cutover = 2015, +): { year: number; value: number }[] { + const historical = points(get, historicalSlug); + const monthly = points(get, monthlySlug); + const byYear = new Map(); + for (const p of monthly ?? []) { + const y = Math.floor(p.year); + const e = byYear.get(y) ?? { sum: 0, n: 0 }; + e.sum += p.value; + e.n += 1; + byYear.set(y, e); + } + const out: { year: number; value: number }[] = []; + for (const p of historical ?? []) { + if (Math.floor(p.year) < cutover) out.push({ year: p.year, value: p.value }); + } + for (const [y, e] of [...byYear].sort((a, b) => a[0] - b[0])) { + if (y >= cutover && e.n === 12) out.push({ year: y, value: e.sum }); + } + out.sort((a, b) => a.year - b.year); + return out; +} + const INDICATORS: SotnIndicator[] = [ { n: "01", @@ -217,7 +278,9 @@ const INDICATORS: SotnIndicator[] = [ headline: "Living standards have gone sideways since 2022.", body: "Real output per person, in chained 2017 dollars — the clearest single measure of whether living standards are rising. StatCan's quarterly series runs within about two months of the present, and it shows a country producing no more per person than it did four years ago.", build: (get) => { - const ps = points(get, "gdp-per-capita-canada"); + // GDP per capita is the one chart that reaches back past the shared 2000 + // start (data begins 1981). + const ps = points(get, "gdp-per-capita-canada", GDP_START); if (!ps) return null; const latest = last(ps); // Index to the first point (= 100) so the chart reads as cumulative @@ -233,12 +296,12 @@ const INDICATORS: SotnIndicator[] = [ spec: line({ unit: `Real GDP per capita (index, ${baseYear} = 100)`, fmt: "index", - // The headline chart spans decades; label each one rather than just - // the endpoints. - xTicks: decades(domainOf(xs(ps))), + // Indexed series: frame around 100 with a floating baseline there, + // rather than anchoring the axis at zero. + baseline: 100, legend: [{ label: "Canada", color: "au" }], series: [{ color: "au", xs: xs(ps), points: indexed }], - }), + }, GDP_START), }; }, }, @@ -303,6 +366,8 @@ const INDICATORS: SotnIndicator[] = [ }; }, }, + /* Removed — redundant with "Employment by age" (02), which already carries + the 15-and-over line monthly. Kept commented for easy restore. { n: "04", title: "Employment", @@ -326,6 +391,7 @@ const INDICATORS: SotnIndicator[] = [ }; }, }, + */ { n: "05", title: "Wage growth", @@ -368,57 +434,37 @@ const INDICATORS: SotnIndicator[] = [ }, { n: "06", - title: "Investment flows", + title: "Investment position", verdict: "lag", - headline: "Canadian capital would rather invest abroad than at home.", - body: "Quarterly foreign-direct-investment flows into Canada and Canadian direct investment abroad, adjusted for inflation and smoothed as four-quarter averages (the raw quarters are far too jagged to read). Outflows are not a loss — they are Canadian firms investing elsewhere — but the persistent gap shows where capital would rather be.", + headline: "Canada now holds far more investment abroad than the world holds here.", + body: "The accumulated book value of direct investment — how much foreign capital is parked in Canada versus how much Canadian capital is parked abroad (StatCan table 36-10-0008, annual since 1987). In the late 1980s more foreign investment sat in Canada than Canadian investment sat abroad; the two crossed around 2000 and the gap has widened steadily since. Book value, not inflation-adjusted.", build: (get) => { - const inflows = points(get, "fdi-inflows"); - const outflows = points(get, "fdi-outflows"); - if (!inflows || !outflows) return null; - const aligned = align([inflows, outflows]); + const inCanada = points(get, "fdi-position-in-canada"); + const abroad = points(get, "cdi-position-abroad"); + if (!inCanada || !abroad) return null; + const aligned = align([inCanada, abroad]); if (!aligned) return null; - const deflator = cpiDeflator(get); - const real = (values: number[]) => - deflator - ? values.map((v, i) => deflator.toReal(v, aligned.base[i].year)) - : values; - const [realIn, realOut] = aligned.values.map(real); - // Quarterly FDI is extremely noisy; a trailing four-quarter average - // keeps the in-vs-out comparison legible. - const WINDOW = 4; - if (aligned.base.length <= WINDOW) return null; - const smooth = (values: number[]) => - values - .slice(WINDOW - 1) - .map( - (_, i) => - values.slice(i, i + WINDOW).reduce((a, b) => a + b, 0) / WINDOW, - ); - const base = aligned.base.slice(WINDOW - 1); - // Trailing-year net flow: what actually arrived minus what left over - // the last four quarters. - const net = realIn - .slice(-WINDOW) - .reduce((a, b) => a + b, 0) - - realOut.slice(-WINDOW).reduce((a, b) => a + b, 0); - const latest = last(base); + // Plot in $ billions; the raw series are in $ millions. + const inB = aligned.values[0].map((v) => v / 1000); + const outB = aligned.values[1].map((v) => v / 1000); + // Net international direct-investment position: what sits in Canada minus + // what Canadians hold abroad. Negative = Canada is a net outward owner. + const net = inB[inB.length - 1] - outB[outB.length - 1]; + const latest = last(aligned.base); return { - stat: `${net < 0 ? "−" : "+"}$${int(Math.abs(net) / 1000)}B`, - statSub: `Net direct investment, trailing year${deflator ? `, ${deflator.baseYear} dollars` : ""} · ${quarterLabel(latest)}`, - ...sourceLink(get, "fdi-inflows"), + stat: `${net < 0 ? "−" : "+"}$${int(Math.abs(net))}B`, + statSub: `Net direct investment position · ${Math.floor(latest.year)}`, + ...sourceLink(get, "fdi-position-in-canada"), spec: line({ - unit: deflator - ? `Foreign direct investment, inflows vs outflows (${deflator.baseYear} dollars)` - : "Foreign direct investment, inflows vs outflows", + unit: "Direct investment position: in Canada vs abroad ($B)", fmt: "count", legend: [ - { label: "Into Canada (4-qtr avg)", color: "au" }, - { label: "Abroad (4-qtr avg)", color: "ink", dash: true }, + { label: "Foreign-owned in Canada", color: "au" }, + { label: "Canadian-owned abroad", color: "ink", dash: true }, ], series: [ - { color: "au", xs: xs(base), points: smooth(realIn) }, - { color: "ink", dash: true, xs: xs(base), points: smooth(realOut) }, + { color: "au", xs: xs(aligned.base), points: inB }, + { color: "ink", dash: true, xs: xs(aligned.base), points: outB }, ], }), }; @@ -449,32 +495,23 @@ const INDICATORS: SotnIndicator[] = [ }, { n: "08", - title: "Debt to GDP", - verdict: "lag", - headline: "Government debt has climbed back toward its 1990s crisis peak.", - body: "Count every level of government — federal, provincial, local, CPP and QPP — on a national-balance-sheet basis. Net debt subtracts financial assets, including the pension plans' holdings, which is why it sits far below the federal-budget figures in the news. The two lines measure different things; don't split the difference.", + title: "Net financial debt", + verdict: "lead", + headline: "Net financial debt sits near its lowest since 1990, far below the mid-1990s peak.", + body: "Every level of government — federal, provincial, local, CPP and QPP — on a national-balance-sheet basis. This is net financial debt: total liabilities minus financial assets (the pension plans' holdings included). It excludes tangible capital assets like roads and buildings, and it is the honest measure of the public sector's financial position. Gross debt ignores the assets held against those liabilities and overstates the burden; a net-worth measure would wrongly net out non-financial assets. This is neither — just liabilities net of financial assets.", build: (get) => { - const gross = points(get, "govt-gross-debt-to-gdp"); const net = points(get, "govt-net-debt-to-gdp"); - if (!gross || !net) return null; - const aligned = align([gross, net]); - if (!aligned) return null; - const latest = last(gross); + if (!net) return null; + const latest = last(net); return { stat: `${Math.round(latest.value)}%`, - statSub: `General government gross debt to GDP · ${quarterLabel(latest)}`, - ...sourceLink(get, "govt-gross-debt-to-gdp"), + statSub: `General government net financial debt to GDP · ${quarterLabel(latest)}`, + ...sourceLink(get, "govt-net-debt-to-gdp"), spec: line({ - unit: "General government debt, share of GDP", + unit: "Government net financial debt, share of GDP", fmt: "pct", - legend: [ - { label: "Gross debt", color: "au" }, - { label: "Net liabilities", color: "ink", dash: true }, - ], - series: [ - { color: "au", xs: xs(aligned.base), points: aligned.values[0] }, - { color: "ink", dash: true, xs: xs(aligned.base), points: aligned.values[1] }, - ], + legend: [{ label: "Net financial debt", color: "au" }], + series: [{ color: "au", xs: xs(net), points: net.map((p) => p.value) }], }), }; }, @@ -651,24 +688,38 @@ const INDICATORS: SotnIndicator[] = [ title: "Permanent residents", verdict: "lead", headline: "Still one of the world's great immigration destinations — intake is easing off record highs.", - body: "Immigrants admitted per year, on StatCan's July–June demographic years. Handled well, this is an engine of growth; handled poorly, it strains the housing and services shown elsewhere on this page.", + body: "Permanent residents admitted per calendar year. Two IRCC sources are spliced into one continuous line: the archived by-category series (1980–2015) for the historical years, and IRCC's ongoing monthly admissions (summed to calendar years) from 2015 on. Handled well, this is an engine of growth; handled poorly, it strains the housing and services shown elsewhere on this page.", build: (get) => { - const ps = points(get, "immigrants-annual"); - if (!ps) return null; - const latest = last(ps); + const series = spliceIrccAdmissions( + get, + "pr-admissions-annual-historical", + "pr-admissions-total", + ); + if (series.length < 2) return null; + const latest = series[series.length - 1]; return { stat: int(latest.value), - statSub: `Immigrants admitted · ${Math.floor(latest.year)} (July–June year)`, - ...sourceLink(get, "immigrants-annual"), + statSub: `Permanent residents admitted · ${Math.floor(latest.year)}`, + ...sourceLink(get, "pr-admissions-total"), spec: line({ unit: "Permanent residents admitted per year", fmt: "count", - legend: [{ label: "Immigrants", color: "au" }], - series: [{ color: "au", xs: xs(ps), points: ps.map((p) => p.value) }], + legend: [{ label: "PR admissions", color: "au" }], + series: [ + { + color: "au", + xs: series.map((p) => p.year), + points: series.map((p) => p.value), + }, + ], }), }; }, }, + /* Temporarily disabled — the NPR-by-type series only start 2021 Q3, which + forced the shared chart window forward to 2021. Re-enable together with + the "npr-work-permit-holders" / "npr-total" slugs above; note that doing + so pulls every chart's window back to 2021. { n: "15", title: "Temporary foreign workers", @@ -701,25 +752,28 @@ const INDICATORS: SotnIndicator[] = [ }; }, }, + */ { n: "16", title: "By class", verdict: "mixed", headline: "A shrinking intake, still anchored by the economic class.", - body: "Permanent residents admitted each month, by immigration class, from IRCC's monthly updates. IRCC rounds counts to the nearest 5 and suppresses small cells, so values are approximate and won't exactly match StatCan's July–June figures above. Students are not a PR class — they appear under work and study permits.", + body: "Permanent residents admitted per year by immigration class. Two IRCC sources are spliced per class: the archived by-category series through 2014, then the ongoing monthly admissions (summed to calendar years) from 2015 on. IRCC rounds counts and suppresses small cells, so values are approximate; the archived 'Other' bucket is narrower than today's, so that line's pre-2015 level isn't directly comparable. Students are not a PR class — they appear under work and study permits.", build: (get) => { - const total = points(get, "pr-admissions-total"); - const economic = points(get, "pr-admissions-economic"); - const family = points(get, "pr-admissions-family"); - const refugee = points(get, "pr-admissions-refugee"); - const other = points(get, "pr-admissions-other"); - if (!total || !economic || !family || !refugee || !other) return null; - const aligned = align([total, economic, family, refugee, other]); - if (!aligned) return null; - const latest = last(aligned.base); + const economic = spliceIrccAdmissions(get, "pr-admissions-economic-historical", "pr-admissions-economic"); + const family = spliceIrccAdmissions(get, "pr-admissions-family-historical", "pr-admissions-family"); + const refugee = spliceIrccAdmissions(get, "pr-admissions-refugee-historical", "pr-admissions-refugee"); + const other = spliceIrccAdmissions(get, "pr-admissions-other-historical", "pr-admissions-other"); + const total = spliceIrccAdmissions(get, "pr-admissions-annual-historical", "pr-admissions-total"); + if (economic.length < 2 || total.length < 2) return null; + const latest = total[total.length - 1]; + const pts = (s: { year: number; value: number }[]) => ({ + xs: s.map((p) => p.year), + points: s.map((p) => p.value), + }); return { - stat: int(last(total).value), - statSub: `Permanent residents admitted, monthly · ${monthLabel(latest)}`, + stat: int(latest.value), + statSub: `Permanent residents admitted, by class · ${Math.floor(latest.year)}`, ...sourceLink(get, "pr-admissions-total"), spec: line({ unit: "Permanent-resident admissions by class", @@ -732,11 +786,11 @@ const INDICATORS: SotnIndicator[] = [ { label: "Other", color: "sand" }, ], series: [ - { color: "au", xs: xs(aligned.base), points: aligned.values[1] }, - { color: "ink", dash: true, xs: xs(aligned.base), points: aligned.values[0] }, - { color: "clay", xs: xs(aligned.base), points: aligned.values[2] }, - { color: "stone", xs: xs(aligned.base), points: aligned.values[3] }, - { color: "sand", xs: xs(aligned.base), points: aligned.values[4] }, + { color: "au", ...pts(economic) }, + { color: "ink", dash: true, ...pts(total) }, + { color: "clay", ...pts(family) }, + { color: "stone", ...pts(refugee) }, + { color: "sand", ...pts(other) }, ], }), }; @@ -779,6 +833,20 @@ export function buildSections(get: Getter): SotnSection[] { } export function buildIndicators(get: Getter): SotnView[] { + // Standardize charts on one x window: a fixed start of 2000 through the + // latest point any series reports. Series that don't reach back to 2000 + // begin partway in. Real GDP per capita is the exception — it passes + // GDP_START to show its deeper history. + let maxEnd = -Infinity; + for (const slug of SOTN_MEASURE_SLUGS) { + const series = get(slug)?.data.series.find( + (s) => s.jurisdiction.slug === "ca", + ); + if (!series || series.points.length < 2) continue; + maxEnd = Math.max(maxEnd, series.points[series.points.length - 1].year); + } + sharedDomain = [2000, Number.isFinite(maxEnd) ? maxEnd : 2001]; + return INDICATORS.flatMap((indicator) => { const built = indicator.build(get); if (!built) return []; diff --git a/src/lib/api/economy.ts b/src/lib/api/economy.ts index 44228a5..7651dc8 100644 --- a/src/lib/api/economy.ts +++ b/src/lib/api/economy.ts @@ -98,9 +98,11 @@ const SOURCE_NAMES: Record = { econ_statcan_hourly_wages: "Statistics Canada (table 14-10-0064)", econ_statcan_business_dynamics: "Statistics Canada (table 33-10-0270)", econ_statcan_fdi_flows: "Statistics Canada (table 36-10-0025)", + econ_statcan_investment_position: "Statistics Canada (table 36-10-0008)", econ_statcan_capital_formation: "Statistics Canada (table 36-10-0104)", econ_statcan_govt_debt_to_gdp: "Statistics Canada (table 38-10-0237)", econ_statcan_population_components: "Statistics Canada (table 17-10-0008)", + econ_statcan_population_total: "Statistics Canada (table 17-10-0009)", econ_statcan_npr_by_type: "Statistics Canada (table 17-10-0121)", econ_ircc_pr_admissions: "Immigration, Refugees and Citizenship Canada", }; @@ -132,9 +134,11 @@ const STATCAN_TABLES: Record = { econ_statcan_hourly_wages: "14-10-0064", econ_statcan_business_dynamics: "33-10-0270", econ_statcan_fdi_flows: "36-10-0025", + econ_statcan_investment_position: "36-10-0008", econ_statcan_capital_formation: "36-10-0104", econ_statcan_govt_debt_to_gdp: "38-10-0237", econ_statcan_population_components: "17-10-0008", + econ_statcan_population_total: "17-10-0009", econ_statcan_npr_by_type: "17-10-0121", }; From 8e92443dc06d6540102f5145e28095eef14e07e1 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Thu, 16 Jul 2026 16:16:42 -0400 Subject: [PATCH 12/12] revisions --- src/app/state-of-the-nation/StateChart.tsx | 5 +- src/app/state-of-the-nation/page.tsx | 11 -- .../state-of-the-nation.ts | 163 ++++++++++++------ 3 files changed, 115 insertions(+), 64 deletions(-) diff --git a/src/app/state-of-the-nation/StateChart.tsx b/src/app/state-of-the-nation/StateChart.tsx index 05e8b89..a818c88 100644 --- a/src/app/state-of-the-nation/StateChart.tsx +++ b/src/app/state-of-the-nation/StateChart.tsx @@ -256,8 +256,11 @@ function LineChart({ spec, wide }: { spec: LineSpec; wide?: boolean }) { const col = SERIES_COLORS[s.color]; const end = s.points.length - 1; const pts = s.points.map((v, i) => `${X(s.xs[i])},${Y(v)}`).join(" "); + // Fill only single-series trend charts. On multi-line comparisons the + // fill sits under whichever series happens to be first (often not the + // dominant line), which reads as arbitrary shading. const area = - si === 0 + si === 0 && spec.series.length === 1 ? `M${X(s.xs[0])},${Y(s.points[0])} ` + s.points.map((v, i) => `L${X(s.xs[i])},${Y(v)}`).join(" ") + ` L${X(s.xs[end])},${baseY} L${X(s.xs[0])},${baseY} Z` diff --git a/src/app/state-of-the-nation/page.tsx b/src/app/state-of-the-nation/page.tsx index 40e29df..2f08672 100644 --- a/src/app/state-of-the-nation/page.tsx +++ b/src/app/state-of-the-nation/page.tsx @@ -51,17 +51,6 @@ function IndicatorCard({ : "pt-[clamp(32px,4vw,56px)] lg:[&:nth-child(odd):not(:last-child)]:border-r" }`} > -
- - {indicator.n} - - - {indicator.title} - -
, start = sharedDomain[0], @@ -278,9 +279,7 @@ const INDICATORS: SotnIndicator[] = [ headline: "Living standards have gone sideways since 2022.", body: "Real output per person, in chained 2017 dollars — the clearest single measure of whether living standards are rising. StatCan's quarterly series runs within about two months of the present, and it shows a country producing no more per person than it did four years ago.", build: (get) => { - // GDP per capita is the one chart that reaches back past the shared 2000 - // start (data begins 1981). - const ps = points(get, "gdp-per-capita-canada", GDP_START); + const ps = points(get, "gdp-per-capita-canada"); if (!ps) return null; const latest = last(ps); // Index to the first point (= 100) so the chart reads as cumulative @@ -301,7 +300,7 @@ const INDICATORS: SotnIndicator[] = [ baseline: 100, legend: [{ label: "Canada", color: "au" }], series: [{ color: "au", xs: xs(ps), points: indexed }], - }, GDP_START), + }), }; }, }, @@ -345,23 +344,64 @@ const INDICATORS: SotnIndicator[] = [ }, { n: "03", - title: "Business formation", - verdict: "lag", - headline: "New business creation has stalled.", - body: "Businesses appearing for the first time, monthly and seasonally adjusted — true new-business formation, not seasonal reopenings. Experimental estimates (Statistics Canada).", + title: "Business entries", + verdict: "mixed", + headline: "Business entry has held up since 2000, but much of it is churn.", + body: "New business entries per year — businesses gaining a paid employee, including seasonal reopenings, not only first-ever formation. Two StatCan sources are chain-linked: the discontinued quarterly entry series (2000–2019, private sector) rescaled to meet the ongoing monthly openings series (2015→present, business sector) at their overlap, so the trend is continuous across the 2015 join. Source tables 33-10-0165 and 33-10-0270.", build: (get) => { - const entrants = points(get, "business-entrants"); - if (!entrants) return null; - const latest = last(entrants); + const histRaw = points(get, "business-entries-historical"); + const openRaw = points(get, "business-openings"); + if (!histRaw || !openRaw) return null; + // Sum sub-annual points into complete calendar years only. + const annualize = ( + ps: EconomySeriesPoint[], + perYear: number, + ): Map => { + const acc = new Map(); + for (const p of ps) { + const y = Math.floor(p.year); + const e = acc.get(y) ?? { sum: 0, n: 0 }; + e.sum += p.value; + e.n += 1; + acc.set(y, e); + } + const out = new Map(); + for (const [y, e] of acc) if (e.n === perYear) out.set(y, e.sum); + return out; + }; + const hist = annualize(histRaw, 4); // quarterly + const open = annualize(openRaw, 12); // monthly + // Chain-link: the two tables differ in scope (~11% level), so rescale the + // ongoing openings series to the historical level over their overlap, + // preserving the trend across the join. + const overlap = [...hist.keys()].filter((y) => open.has(y)); + if (overlap.length === 0) return null; + const factor = + overlap.reduce((a, y) => a + hist.get(y)! / open.get(y)!, 0) / + overlap.length; + // Historical before the cutover, rescaled openings from it on. + const CUTOVER = 2015; + const series: { year: number; value: number }[] = []; + for (const [y, v] of hist) if (y < CUTOVER) series.push({ year: y, value: v }); + for (const [y, v] of open) if (y >= CUTOVER) series.push({ year: y, value: v * factor }); + series.sort((a, b) => a.year - b.year); + if (series.length < 2) return null; + const latest = series[series.length - 1]; return { stat: int(latest.value), - statSub: `New businesses, monthly · ${monthLabel(latest)}`, - ...sourceLink(get, "business-entrants"), + statSub: `Business entries per year · ${Math.floor(latest.year)}`, + ...sourceLink(get, "business-openings"), spec: line({ - unit: "New business formation, monthly", + unit: "Business entries per year", fmt: "count", - legend: [{ label: "New businesses", color: "au" }], - series: [{ color: "au", xs: xs(entrants), points: entrants.map((p) => p.value) }], + legend: [{ label: "Business entries", color: "au" }], + series: [ + { + color: "au", + xs: series.map((p) => p.year), + points: series.map((p) => p.value), + }, + ], }), }; }, @@ -560,27 +600,44 @@ const INDICATORS: SotnIndicator[] = [ n: "10", title: "Consumer prices", verdict: "lag", - headline: "Prices broke from the 2% track in 2021 and never came back.", - body: "The all-items Consumer Price Index, month by month (2002 = 100). The dashed line is where prices would sit had they grown at the Bank of Canada's 2% inflation target — the gap above it is permanent lost ground.", + headline: "Inflation broke above target in 2021–22 and is only now easing back toward 2%.", + body: "The 12-month change in the all-items Consumer Price Index — the headline inflation rate, month by month. The dashed line is the Bank of Canada's 2% target. After four decades of gradual disinflation from the double-digit early 1980s, inflation spiked above 8% in 2022 before easing back toward target.", build: (get) => { - const cpi = points(get, "cpi-all-items"); - if (!cpi) return null; - const target = cpi.map((p) => 100 * Math.pow(1.02, p.year - 2002)); - const latest = last(cpi); + // Raw (unclipped) monthly CPI so the year-over-year rate is defined right + // at the 1980 window start — computing 1980's rate needs 1979's index. + const raw = get("cpi-all-items")?.data.series.find( + (s) => s.jurisdiction.slug === "ca", + )?.points; + if (!raw || raw.length < 13) return null; + const byMonth = new Map( + raw.filter((p) => p.date).map((p) => [p.date!, p.value]), + ); + // 12-month percentage change for each month with a value a year earlier, + // kept from the shared window start on. + const rate = raw.flatMap((p) => { + if (!p.date) return []; + const prior = byMonth.get( + `${Number(p.date.slice(0, 4)) - 1}${p.date.slice(4)}`, + ); + if (prior === undefined || p.year < sharedDomain[0]) return []; + return [{ year: p.year, value: (p.value / prior - 1) * 100 }]; + }); + if (rate.length < 2) return null; + const rateXs = rate.map((p) => p.year); return { - stat: String(latest.value.toFixed(1)), - statSub: `CPI, all items (2002 = 100) · ${monthLabel(latest)}`, + stat: `${rate[rate.length - 1].value.toFixed(1)}%`, + statSub: `CPI inflation, 12-month change · ${monthLabel(raw[raw.length - 1])}`, ...sourceLink(get, "cpi-all-items"), spec: line({ - unit: "Consumer Price Index vs the 2% target path", - fmt: "index", + unit: "Consumer price inflation vs the 2% target", + fmt: "pct1", legend: [ - { label: "CPI", color: "au" }, - { label: "2% target path", color: "ink", dash: true }, + { label: "Inflation (12-month)", color: "au" }, + { label: "2% target", color: "ink", dash: true }, ], series: [ - { color: "au", xs: xs(cpi), points: cpi.map((p) => p.value) }, - { color: "ink", dash: true, xs: xs(cpi), points: target }, + { color: "au", xs: rateXs, points: rate.map((p) => p.value) }, + { color: "ink", dash: true, xs: rateXs, points: rate.map(() => 2) }, ], }), }; @@ -590,19 +647,22 @@ const INDICATORS: SotnIndicator[] = [ n: "11", title: "Housing affordability", verdict: "lag", - headline: "House prices sit half again above their long-term relationship with incomes.", - body: "The OECD's headline affordability measure compares home prices with the incomes that must carry them. Canada's ratio now runs roughly fifty per cent above its long-term average — among the most stretched in the developed world, pricing a whole generation out of ownership.", + headline: "House prices have swung from below their long-term norm to roughly 50% above it.", + body: "The OECD's headline affordability measure, indexed so 100 is the long-term average of Canada's house-price-to-income ratio. Below the line, homes are cheaper than the historical norm relative to incomes; above it, more stretched. Canada sat below through the 1990s and 2000s, crossed the norm around 2009, and now runs about fifty per cent above — among the most stretched in the developed world.", build: (get) => { const ps = points(get, "house-price-to-income"); if (!ps) return null; const latest = last(ps); return { - stat: `${(latest.value / 100).toFixed(1)}×`, - statSub: `House price to income vs. long-term norm · ${Math.floor(latest.year)}`, + stat: `+${Math.round(latest.value - 100)}%`, + statSub: `Above the long-term price-to-income average · ${Math.floor(latest.year)}`, ...sourceLink(get, "house-price-to-income"), spec: line({ - unit: "House-price-to-income ratio (100 = long-term norm)", + unit: "House-price-to-income ratio (100 = long-term average)", fmt: "index", + // Index where 100 = the long-term norm: frame around it with a + // floating baseline instead of anchoring at zero. + baseline: 100, legend: [{ label: "Canada", color: "au" }], series: [{ color: "au", xs: xs(ps), points: ps.map((p) => p.value) }], }), @@ -833,10 +893,9 @@ export function buildSections(get: Getter): SotnSection[] { } export function buildIndicators(get: Getter): SotnView[] { - // Standardize charts on one x window: a fixed start of 2000 through the - // latest point any series reports. Series that don't reach back to 2000 - // begin partway in. Real GDP per capita is the exception — it passes - // GDP_START to show its deeper history. + // Standardize charts on one x window: a fixed start of 1980 through the + // latest point any series reports. Series that don't reach back to 1980 + // begin partway in, leaving their early years blank. let maxEnd = -Infinity; for (const slug of SOTN_MEASURE_SLUGS) { const series = get(slug)?.data.series.find( @@ -845,7 +904,7 @@ export function buildIndicators(get: Getter): SotnView[] { if (!series || series.points.length < 2) continue; maxEnd = Math.max(maxEnd, series.points[series.points.length - 1].year); } - sharedDomain = [2000, Number.isFinite(maxEnd) ? maxEnd : 2001]; + sharedDomain = [1980, Number.isFinite(maxEnd) ? maxEnd : 1981]; return INDICATORS.flatMap((indicator) => { const built = indicator.build(get);