diff --git a/app/page.tsx b/app/page.tsx index 14c7ffc..4a0c6f1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,17 +1,25 @@ import { OverviewClient } from "@/components/overview-client"; -import { getLatestRanking, getMetadata } from "@/lib/data/queries"; +import { + getLatestRanking, + getMetadata, + getStateStackupSummaries +} from "@/lib/data/queries"; import { DEFAULT_METRIC_ID } from "@/lib/metrics/catalog"; export const dynamic = "force-dynamic"; export default async function HomePage() { - const { metrics, refreshStatus } = await getMetadata(); - const overview = await getLatestRanking(DEFAULT_METRIC_ID); + const [{ metrics, refreshStatus }, overview, stateSummaries] = await Promise.all([ + getMetadata(), + getLatestRanking(DEFAULT_METRIC_ID), + getStateStackupSummaries() + ]); return ( diff --git a/app/states/page.tsx b/app/states/page.tsx new file mode 100644 index 0000000..13b1575 --- /dev/null +++ b/app/states/page.tsx @@ -0,0 +1,35 @@ +import { StateExplorerControls } from "@/components/state-explorer-controls"; +import { StateProfileView } from "@/components/state-profile-view"; +import { JURISDICTIONS } from "@/lib/data/jurisdictions"; +import { getStateProfile } from "@/lib/data/queries"; + +export const dynamic = "force-dynamic"; + +const DEFAULT_STATE_SLUG = "california"; + +type StatesPageProps = { + searchParams: Promise<{ + state?: string; + }>; +}; + +export default async function StatesPage({ searchParams }: StatesPageProps) { + const { state } = await searchParams; + const jurisdictions = [...JURISDICTIONS].sort((left, right) => + left.name.localeCompare(right.name) + ); + const selectedSlug = jurisdictions.some((jurisdiction) => jurisdiction.slug === state) + ? state ?? DEFAULT_STATE_SLUG + : DEFAULT_STATE_SLUG; + const profile = await getStateProfile(selectedSlug); + + return ( +
+ + +
+ ); +} diff --git a/components/choropleth-map.tsx b/components/choropleth-map.tsx index 00163e3..b0573b2 100644 --- a/components/choropleth-map.tsx +++ b/components/choropleth-map.tsx @@ -1,13 +1,17 @@ "use client"; +import type { KeyboardEvent, MouseEvent } from "react"; +import { useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import { ComposableMap, Geographies, Geography } from "react-simple-maps"; -import type { RankingRow } from "@/lib/types"; +import type { RankingRow, StateStackupSummary } from "@/lib/types"; const GEO_URL = "https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json"; type ChoroplethMapProps = { rows: RankingRow[]; + stateSummaries: StateStackupSummary[]; }; function fillColor(percentile: number) { @@ -19,27 +23,114 @@ function fillColor(percentile: number) { return "#efb366"; } -export function ChoroplethMap({ rows }: ChoroplethMapProps) { +export function ChoroplethMap({ rows, stateSummaries }: ChoroplethMapProps) { + const router = useRouter(); + const containerRef = useRef(null); const rowByFips = new Map(rows.map((row) => [row.jurisdiction.fips, row])); + const summaryByFips = new Map( + stateSummaries.map((summary) => [summary.jurisdiction.fips, summary]) + ); + const [hovered, setHovered] = useState<{ + summary: StateStackupSummary; + position: { x: number; y: number }; + } | null>(null); + + function updateTooltipPosition(event: MouseEvent) { + const bounds = containerRef.current?.getBoundingClientRect(); + + if (!bounds) { + return { x: 0, y: 0 }; + } + + return { + x: event.clientX - bounds.left, + y: event.clientY - bounds.top + }; + } return ( -
+
{({ geographies }: { geographies: Array<{ id: string | number; rsmKey: string }> }) => geographies.map((geography: { id: string | number; rsmKey: string }) => { - const row = rowByFips.get(String(geography.id).padStart(2, "0")); + const fips = String(geography.id).padStart(2, "0"); + const row = rowByFips.get(fips); + const summary = summaryByFips.get(fips); return ( ) => { + if (!summary) { + return; + } + + setHovered({ + summary, + position: updateTooltipPosition(event) + }); + }} + onMouseMove={(event: MouseEvent) => { + if (!summary) { + return; + } + + setHovered({ + summary, + position: updateTooltipPosition(event) + }); + }} + onMouseLeave={() => setHovered(null)} + onFocus={() => { + if (!summary) { + return; + } + + setHovered({ + summary, + position: { x: 24, y: 24 } + }); + }} + onBlur={() => setHovered(null)} + onClick={() => { + if (!summary) { + return; + } + + router.push(`/states?state=${summary.jurisdiction.slug}`); + }} + onKeyDown={(event: KeyboardEvent) => { + if (!summary) { + return; + } + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + router.push(`/states?state=${summary.jurisdiction.slug}`); + } + }} style={{ - default: { outline: "none" }, - hover: { outline: "none", fill: row ? "#0b5f61" : "#d9e4e8" }, + default: { outline: "none", cursor: summary ? "pointer" : "default" }, + hover: { + outline: "none", + fill: row ? "#0b5f61" : "#d9e4e8", + cursor: summary ? "pointer" : "default" + }, pressed: { outline: "none" } }} /> @@ -48,6 +139,48 @@ export function ChoroplethMap({ rows }: ChoroplethMapProps) { } + {hovered ? ( +
+
{hovered.summary.jurisdiction.name}
+
+ Ranking snapshot +
+
+
+
+ Best 3 +
+
+ {hovered.summary.best.map((item) => ( +
+
{item.metricLabel}
+
Rank #{item.rank}
+
+ ))} +
+
+
+
+ Worst 3 +
+
+ {hovered.summary.worst.map((item) => ( +
+
{item.metricLabel}
+
Rank #{item.rank}
+
+ ))} +
+
+
+
+ ) : null}
); } diff --git a/components/overview-client.tsx b/components/overview-client.tsx index 2b4003b..22f5291 100644 --- a/components/overview-client.tsx +++ b/components/overview-client.tsx @@ -9,12 +9,18 @@ import { Badge } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; import { Card, CardDescription, CardTitle } from "@/components/ui/card"; import { formatMetricValue } from "@/lib/metrics/format"; -import type { MetricDefinition, RankingRow, RefreshStatus } from "@/lib/types"; +import type { + MetricDefinition, + RankingRow, + RefreshStatus, + StateStackupSummary +} from "@/lib/types"; import { formatDateLabel } from "@/lib/utils"; type OverviewClientProps = { metrics: MetricDefinition[]; refreshStatus: RefreshStatus[]; + stateSummaries: StateStackupSummary[]; initialMetric: MetricDefinition; initialRows: RankingRow[]; }; @@ -22,6 +28,7 @@ type OverviewClientProps = { export function OverviewClient({ metrics, refreshStatus, + stateSummaries, initialMetric, initialRows }: OverviewClientProps) { @@ -117,7 +124,7 @@ export function OverviewClient({
- +
Relative rank diff --git a/components/site-header.tsx b/components/site-header.tsx index 2ae0ba6..05ab04d 100644 --- a/components/site-header.tsx +++ b/components/site-header.tsx @@ -1,14 +1,21 @@ +"use client"; + import Link from "next/link"; +import { usePathname } from "next/navigation"; import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; const NAV_ITEMS = [ { href: "/", label: "Overview" }, { href: "/rankings", label: "Rankings" }, + { href: "/states", label: "States" }, { href: "/methodology", label: "Methodology" } ]; export function SiteHeader() { + const pathname = usePathname(); + return (
@@ -29,7 +36,12 @@ export function SiteHeader() { {item.label} diff --git a/components/state-explorer-controls.tsx b/components/state-explorer-controls.tsx new file mode 100644 index 0000000..d88c2ee --- /dev/null +++ b/components/state-explorer-controls.tsx @@ -0,0 +1,82 @@ +"use client"; + +import Link from "next/link"; +import { startTransition, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { Badge } from "@/components/ui/badge"; +import { buttonVariants } from "@/components/ui/button"; +import { Card, CardDescription, CardTitle } from "@/components/ui/card"; + +type StateExplorerControlsProps = { + jurisdictions: Array<{ + slug: string; + name: string; + abbr: string; + }>; + selectedSlug: string; +}; + +export function StateExplorerControls({ + jurisdictions, + selectedSlug +}: StateExplorerControlsProps) { + const router = useRouter(); + const [value, setValue] = useState(selectedSlug); + const selectedJurisdiction = + jurisdictions.find((jurisdiction) => jurisdiction.slug === value) ?? jurisdictions[0]; + + function handleChange(nextSlug: string) { + setValue(nextSlug); + + startTransition(() => { + router.replace(`/states?state=${nextSlug}`, { scroll: false }); + }); + } + + return ( + +
+ States + Pick a state and see where it stacks up. + + Use the explorer to jump to any state, scan its strongest and weakest rankings, and then dig into the latest metric cards and trends below. + +
+
+ +
+
+
Current pick
+
+ {selectedJurisdiction?.name} {selectedJurisdiction ? `(${selectedJurisdiction.abbr})` : null} +
+
+ + Open shareable page + +
+
+
+ ); +} diff --git a/components/state-profile-view.tsx b/components/state-profile-view.tsx index 7ca9e97..ffc48bb 100644 --- a/components/state-profile-view.tsx +++ b/components/state-profile-view.tsx @@ -5,18 +5,74 @@ import { TrendChart } from "@/components/trend-chart"; type StateProfileViewProps = { profile: StateProfile; + showHeading?: boolean; }; -export function StateProfileView({ profile }: StateProfileViewProps) { +export function StateProfileView({ + profile, + showHeading = true +}: StateProfileViewProps) { + const rankedMetrics = profile.metrics.filter( + (metric) => metric.latest != null + ); + const bestMetric = rankedMetrics.reduce<(typeof rankedMetrics)[number] | null>( + (best, metric) => + best == null || metric.latest!.rank < best.latest!.rank ? metric : best, + null + ); + const weakestMetric = rankedMetrics.reduce<(typeof rankedMetrics)[number] | null>( + (weakest, metric) => + weakest == null || metric.latest!.rank > weakest.latest!.rank ? metric : weakest, + null + ); + const topTenCount = rankedMetrics.filter((metric) => metric.latest!.rank <= 10).length; + const bottomTenCount = rankedMetrics.filter((metric) => metric.latest!.rank >= 42).length; + return (
- -
State profile
- {profile.jurisdiction.name} - - Latest rank and recent trend for each MVP metric. - -
+ {showHeading ? ( + +
State profile
+ {profile.jurisdiction.name} + + Latest rank and recent trend for each MVP metric. + +
+ ) : null} +
+
+
Best standing
+
+ {bestMetric ? bestMetric.definition.label : "Waiting for data"} +
+
+ {bestMetric ? `Rank #${bestMetric.latest!.rank}` : "No ranking available yet"} +
+
+
+
Weakest standing
+
+ {weakestMetric ? weakestMetric.definition.label : "Waiting for data"} +
+
+ {weakestMetric ? `Rank #${weakestMetric.latest!.rank}` : "No ranking available yet"} +
+
+
+
Top 10 finishes
+
{topTenCount}
+
+ Metrics where {profile.jurisdiction.name} is in the national top ten. +
+
+
+
Bottom 10 finishes
+
{bottomTenCount}
+
+ Metrics where {profile.jurisdiction.name} is in the bottom ten. +
+
+
{profile.metrics.map((metric) => ( diff --git a/lib/data/queries.ts b/lib/data/queries.ts index 698b8ec..4daf6eb 100644 --- a/lib/data/queries.ts +++ b/lib/data/queries.ts @@ -4,7 +4,13 @@ import { notFound } from "next/navigation"; import { prisma } from "@/lib/db"; import { METRIC_BY_ID, METRIC_CATALOG } from "@/lib/metrics/catalog"; import { rankObservations } from "@/lib/metrics/ranking"; -import type { MetricDefinition, MetricObservation, RefreshStatus, StateProfile } from "@/lib/types"; +import type { + MetricDefinition, + MetricObservation, + RefreshStatus, + StateProfile, + StateStackupSummary +} from "@/lib/types"; function toNumber(value: unknown) { if (value == null) { @@ -287,6 +293,69 @@ export async function getStateProfile(slug: string): Promise { }; } +export async function getStateStackupSummaries(): Promise { + const definitions = await prisma.metricDefinition.findMany({ + orderBy: [ + { category: "asc" }, + { label: "asc" } + ] + }); + + const rankings = await Promise.all( + definitions.map(async (definition) => { + const ranking = await getLatestRanking(definition.id); + + return { + metricId: definition.id, + metricLabel: definition.label, + rows: ranking.rows + }; + }) + ); + + const entriesByJurisdiction = new Map< + string, + { + jurisdiction: StateStackupSummary["jurisdiction"]; + items: Array<{ metricId: string; metricLabel: string; rank: number }>; + } + >(); + + for (const ranking of rankings) { + for (const row of ranking.rows) { + const current = entriesByJurisdiction.get(row.jurisdiction.slug) ?? { + jurisdiction: row.jurisdiction, + items: [] + }; + + current.items.push({ + metricId: ranking.metricId, + metricLabel: ranking.metricLabel, + rank: row.rank + }); + + entriesByJurisdiction.set(row.jurisdiction.slug, current); + } + } + + return [...entriesByJurisdiction.values()] + .map((entry) => { + const best = [...entry.items] + .sort((left, right) => left.rank - right.rank || left.metricLabel.localeCompare(right.metricLabel)) + .slice(0, 3); + const worst = [...entry.items] + .sort((left, right) => right.rank - left.rank || left.metricLabel.localeCompare(right.metricLabel)) + .slice(0, 3); + + return { + jurisdiction: entry.jurisdiction, + best, + worst + }; + }) + .sort((left, right) => left.jurisdiction.name.localeCompare(right.jurisdiction.name)); +} + function metricCadenceToPrisma(cadence: "MONTHLY" | "QUARTERLY" | "ANNUAL") { switch (cadence) { case "MONTHLY": diff --git a/lib/types.ts b/lib/types.ts index f856b70..3092411 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -62,6 +62,18 @@ export type StateProfile = { metrics: StateProfileMetric[]; }; +export type StateStackupItem = { + metricId: string; + metricLabel: string; + rank: number; +}; + +export type StateStackupSummary = { + jurisdiction: MetricObservation["jurisdiction"]; + best: StateStackupItem[]; + worst: StateStackupItem[]; +}; + export type RefreshStatus = { metricId: string; status: "RUNNING" | "SUCCESS" | "FAILED" | "SKIPPED"; diff --git a/tests/component/choropleth-map.test.tsx b/tests/component/choropleth-map.test.tsx new file mode 100644 index 0000000..5eabd29 --- /dev/null +++ b/tests/component/choropleth-map.test.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ChoroplethMap } from "@/components/choropleth-map"; +import type { RankingRow, StateStackupSummary } from "@/lib/types"; + +const push = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push + }) +})); + +vi.mock("react-simple-maps", () => ({ + ComposableMap: ({ children }: { children: React.ReactNode }) => {children}, + Geographies: ({ + children + }: { + children: (args: { + geographies: Array<{ id: string | number; rsmKey: string }>; + }) => React.ReactNode; + }) => + children({ + geographies: [{ id: "06", rsmKey: "california" }] + }), + Geography: ({ + geography, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + onFocus, + onBlur, + onKeyDown, + ...props + }: React.SVGProps & { + geography: { id: string | number; rsmKey: string }; + }) => ( + + ) +})); + +const rows: RankingRow[] = [ + { + rank: 5, + tied: false, + jurisdiction: { + slug: "california", + name: "California", + abbr: "CA", + fips: "06" + }, + value: 1.1, + benchmarkValue: null, + deltaFromBenchmark: null, + percentile: 90, + periodLabel: "Dec 2025", + releaseDate: null, + sourceUrl: "https://example.gov" + } +]; + +const stateSummaries: StateStackupSummary[] = [ + { + jurisdiction: { + slug: "california", + name: "California", + abbr: "CA", + fips: "06" + }, + best: [ + { metricId: "a", metricLabel: "Payroll job growth", rank: 2 }, + { metricId: "b", metricLabel: "Population growth", rank: 4 }, + { metricId: "c", metricLabel: "Median household income", rank: 8 } + ], + worst: [ + { metricId: "x", metricLabel: "Unemployment rate", rank: 40 }, + { metricId: "y", metricLabel: "Gasoline cost", rank: 45 }, + { metricId: "z", metricLabel: "Taxes per capita", rank: 49 } + ] + } +]; + +describe("ChoroplethMap", () => { + it("shows a state stackup tooltip on hover and routes into the states explorer on click", () => { + render(); + + const geography = screen.getByTestId("geography-06"); + + fireEvent.mouseEnter(geography, { + clientX: 120, + clientY: 140 + }); + + expect(screen.getByText("California")).toBeInTheDocument(); + expect(screen.getByText("Best 3")).toBeInTheDocument(); + expect(screen.getByText("Worst 3")).toBeInTheDocument(); + expect(screen.getByText("Payroll job growth")).toBeInTheDocument(); + expect(screen.getByText("Rank #49")).toBeInTheDocument(); + + fireEvent.click(geography); + + expect(push).toHaveBeenCalledWith("/states?state=california"); + }); +}); diff --git a/tests/component/state-explorer-controls.test.tsx b/tests/component/state-explorer-controls.test.tsx new file mode 100644 index 0000000..ae76585 --- /dev/null +++ b/tests/component/state-explorer-controls.test.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { StateExplorerControls } from "@/components/state-explorer-controls"; + +const replace = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + replace + }) +})); + +describe("StateExplorerControls", () => { + it("updates the selected state and pushes the explorer route", () => { + render( + + ); + + fireEvent.change(screen.getByLabelText("State"), { + target: { value: "colorado" } + }); + + expect(screen.getByDisplayValue("Colorado")).toBeInTheDocument(); + expect(replace).toHaveBeenCalledWith("/states?state=colorado", { scroll: false }); + }); +}); diff --git a/tests/component/state-profile-view.test.tsx b/tests/component/state-profile-view.test.tsx new file mode 100644 index 0000000..2ba4a23 --- /dev/null +++ b/tests/component/state-profile-view.test.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { StateProfileView } from "@/components/state-profile-view"; +import type { StateProfile } from "@/lib/types"; + +const profile: StateProfile = { + jurisdiction: { + slug: "colorado", + name: "Colorado", + abbr: "CO", + fips: "08" + }, + metrics: [ + { + definition: { + id: "payroll_growth", + label: "Payroll job growth", + category: "Economy", + sourceName: "BLS", + sourceUrl: "https://bls.gov", + cadence: "MONTHLY", + betterDirection: "HIGHER", + unit: "percent", + description: "desc", + caveats: null, + methodology: "method" + }, + latest: { + rank: 4, + tied: false, + jurisdiction: { + slug: "colorado", + name: "Colorado", + abbr: "CO", + fips: "08" + }, + value: 1.4, + benchmarkValue: null, + deltaFromBenchmark: null, + percentile: 94, + periodLabel: "Dec 2025", + releaseDate: null, + sourceUrl: "https://example.gov" + }, + trend: [] + }, + { + definition: { + id: "poverty_rate", + label: "Poverty rate", + category: "People & Affordability", + sourceName: "Census", + sourceUrl: "https://census.gov", + cadence: "ANNUAL", + betterDirection: "LOWER", + unit: "percent", + description: "desc", + caveats: null, + methodology: "method" + }, + latest: { + rank: 45, + tied: false, + jurisdiction: { + slug: "colorado", + name: "Colorado", + abbr: "CO", + fips: "08" + }, + value: 10.5, + benchmarkValue: null, + deltaFromBenchmark: null, + percentile: 12, + periodLabel: "2025", + releaseDate: null, + sourceUrl: "https://example.gov" + }, + trend: [] + } + ] +}; + +describe("StateProfileView", () => { + it("surfaces a quick ranking summary for the selected state", () => { + render(); + + expect(screen.getByText("Best standing")).toBeInTheDocument(); + expect(screen.getAllByText("Payroll job growth").length).toBeGreaterThan(0); + expect(screen.getByText("Rank #4")).toBeInTheDocument(); + expect(screen.getByText("Weakest standing")).toBeInTheDocument(); + expect(screen.getAllByText("Poverty rate").length).toBeGreaterThan(0); + expect(screen.getByText("Rank #45")).toBeInTheDocument(); + expect( + screen.getByText("Metrics where Colorado is in the national top ten.") + ).toBeInTheDocument(); + expect( + screen.getByText("Metrics where Colorado is in the bottom ten.") + ).toBeInTheDocument(); + }); +});