From f32fa0e650f56b3ed77b46eabd240e09cab8c1dd Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Tue, 6 Oct 2026 12:37:50 -0400 Subject: [PATCH 1/7] Removing redundant report components --- .../src/components/Reports/DashboardGrid.tsx | 0 .../Reports/cards/MetricComparisonCard.tsx | 0 .../Reports/cards/StatComparisonCard.tsx | 0 .../demographics/AgeDistributionChart.tsx | 93 -------------- .../Reports/demographics/MedianAgeCard.tsx | 111 ---------------- .../demographics/MedianAgeHistoryChart.tsx | 100 --------------- .../Reports/demographics/PopulationCard.tsx | 119 ------------------ .../demographics/PopulationHistoryChart.tsx | 100 --------------- .../demographics/RaceDistributionChart.tsx | 98 --------------- .../economic/MedianHouseholdIncomeCard.tsx | 115 ----------------- .../MedianHouseholdIncomeHistoryChart.tsx | 104 --------------- .../Reports/economic/PerCapitaIncomeCard.tsx | 111 ---------------- .../economic/PerCapitaIncomeHistoryChart.tsx | 103 --------------- .../Reports/economic/UnemploymentRateCard.tsx | 112 ----------------- .../src/components/Reports/economic/index.ts | 5 - .../education/EducationalAttainment.tsx | 99 --------------- .../src/components/Reports/education/index.ts | 1 - .../Reports/housing/MedianHomeValueCard.tsx | 110 ---------------- .../housing/MedianHomeValueHistoryChart.tsx | 103 --------------- .../housing/OccupancyDistributionChart.tsx | 91 -------------- .../Reports/housing/TotalHousingUnitsCard.tsx | 112 ----------------- .../housing/TotalHousingUnitsHistoryChart.tsx | 101 --------------- .../housing/VacancyDistributionChart.tsx | 91 -------------- .../src/components/Reports/housing/index.ts | 6 - .../land_use/AcreageByDistrictType.tsx | 107 ---------------- 25 files changed, 1992 deletions(-) delete mode 100644 frontend/src/components/Reports/DashboardGrid.tsx delete mode 100644 frontend/src/components/Reports/cards/MetricComparisonCard.tsx delete mode 100644 frontend/src/components/Reports/cards/StatComparisonCard.tsx delete mode 100644 frontend/src/components/Reports/demographics/AgeDistributionChart.tsx delete mode 100644 frontend/src/components/Reports/demographics/MedianAgeCard.tsx delete mode 100644 frontend/src/components/Reports/demographics/MedianAgeHistoryChart.tsx delete mode 100644 frontend/src/components/Reports/demographics/PopulationCard.tsx delete mode 100644 frontend/src/components/Reports/demographics/PopulationHistoryChart.tsx delete mode 100644 frontend/src/components/Reports/demographics/RaceDistributionChart.tsx delete mode 100644 frontend/src/components/Reports/economic/MedianHouseholdIncomeCard.tsx delete mode 100644 frontend/src/components/Reports/economic/MedianHouseholdIncomeHistoryChart.tsx delete mode 100644 frontend/src/components/Reports/economic/PerCapitaIncomeCard.tsx delete mode 100644 frontend/src/components/Reports/economic/PerCapitaIncomeHistoryChart.tsx delete mode 100644 frontend/src/components/Reports/economic/UnemploymentRateCard.tsx delete mode 100644 frontend/src/components/Reports/economic/index.ts delete mode 100644 frontend/src/components/Reports/education/EducationalAttainment.tsx delete mode 100644 frontend/src/components/Reports/education/index.ts delete mode 100644 frontend/src/components/Reports/housing/MedianHomeValueCard.tsx delete mode 100644 frontend/src/components/Reports/housing/MedianHomeValueHistoryChart.tsx delete mode 100644 frontend/src/components/Reports/housing/OccupancyDistributionChart.tsx delete mode 100644 frontend/src/components/Reports/housing/TotalHousingUnitsCard.tsx delete mode 100644 frontend/src/components/Reports/housing/TotalHousingUnitsHistoryChart.tsx delete mode 100644 frontend/src/components/Reports/housing/VacancyDistributionChart.tsx delete mode 100644 frontend/src/components/Reports/housing/index.ts delete mode 100644 frontend/src/components/Reports/land_use/AcreageByDistrictType.tsx diff --git a/frontend/src/components/Reports/DashboardGrid.tsx b/frontend/src/components/Reports/DashboardGrid.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/src/components/Reports/cards/MetricComparisonCard.tsx b/frontend/src/components/Reports/cards/MetricComparisonCard.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/src/components/Reports/cards/StatComparisonCard.tsx b/frontend/src/components/Reports/cards/StatComparisonCard.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/src/components/Reports/demographics/AgeDistributionChart.tsx b/frontend/src/components/Reports/demographics/AgeDistributionChart.tsx deleted file mode 100644 index 45072ef0..00000000 --- a/frontend/src/components/Reports/demographics/AgeDistributionChart.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Card, Title } from '@mantine/core'; -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer, -} from 'recharts'; -import { DataRow } from '@/types/cachedCharts'; - -interface AgeDistributionChartProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} - -const AGE_GROUPS = [ - 'Under 18', - '18 to 24', - '25 to 34', - '35 to 44', - '45 to 54', - '55 to 64', - '65 to 74', - '75 Plus', -]; - -function getValue(data: DataRow[], variable: string): number { - const row = data.find((d) => d.Variable === variable); - return row ? Number(row.Percent) : 0; -} - -function buildAgeData( - primary: DataRow[], - comparison: DataRow[], - primaryName: string, - comparisonName: string, -) { - return AGE_GROUPS.map((age) => ({ - age, - [primaryName]: getValue(primary, age), - [comparisonName]: getValue(comparison, age), - })); -} - -export default function AgeDistributionChart({ - primary, - comparison, - primaryName, - comparisonName, -}: AgeDistributionChartProps) { - const data = buildAgeData(primary, comparison, primaryName, comparisonName); - - return ( - - - Age Distribution - - - - - - - `${value}%`} /> - `${Number(value).toFixed(1)}%`} /> - - - - - - - ); -} diff --git a/frontend/src/components/Reports/demographics/MedianAgeCard.tsx b/frontend/src/components/Reports/demographics/MedianAgeCard.tsx deleted file mode 100644 index 7fd17b19..00000000 --- a/frontend/src/components/Reports/demographics/MedianAgeCard.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { Card, Group, Stack, Text, ThemeIcon, Title } from '@mantine/core'; -import { IconCalendarStats } from '@tabler/icons-react'; -import { DataRow } from '@/types/cachedCharts'; - -interface MedianAgeCardProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName?: string; - comparisonName?: string; -} - -function getVariableValue(data: DataRow[], variable: string): number | null { - const row = data.find((d) => d.Variable === variable); - if (!row) return null; - const value = Number(row.Value); - return Number.isFinite(value) ? value : null; -} - -export default function MedianAgeCard({ - primary, - comparison, - primaryName, - comparisonName, -}: MedianAgeCardProps) { - const primaryAge = getVariableValue(primary, 'Median Age'); - const comparisonAge = getVariableValue(comparison, 'Median Age'); - - const difference = - primaryAge !== null && comparisonAge !== null - ? primaryAge - comparisonAge - : null; - - return ( - - - - - Median Age - - - - {primaryAge !== null ? `${primaryAge.toFixed(1)} years` : '—'} - - - - - - - - - - - - {comparisonName ? `${comparisonName}` : 'Comparison'} - - - - {comparisonAge !== null ? `${comparisonAge.toFixed(1)} years` : '—'} - - - - - - Difference - - - 0 ? 'green' : 'red' - } - > - {difference === null - ? '—' - : `${difference > 0 ? '+' : ''}${difference.toFixed(1)} yrs`} - - - - - {difference === null ? ( - '' - ) : difference > 0 ? ( - <> - {primaryName} has an - older median population than {comparisonName} - - ) : difference < 0 ? ( - <> - {primaryName} has a - younger median population than {comparisonName} - - ) : ( - <> - {primaryName} and{' '} - {comparisonName} have the same median age - - )} - - - - ); -} diff --git a/frontend/src/components/Reports/demographics/MedianAgeHistoryChart.tsx b/frontend/src/components/Reports/demographics/MedianAgeHistoryChart.tsx deleted file mode 100644 index 55fbd358..00000000 --- a/frontend/src/components/Reports/demographics/MedianAgeHistoryChart.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { Card, Title } from '@mantine/core'; -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer, -} from 'recharts'; -import { DataRow } from '@/types/cachedCharts'; - -interface MedianAgeHistoryChartProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} - -function buildSeries( - primary: DataRow[], - comparison: DataRow[], - primaryName: string, - comparisonName: string, -) { - const years = Array.from( - new Set( - [...primary, ...comparison] - .map((r) => Number(r.year)) - .filter((y) => Number.isFinite(y)), - ), - ).sort((a, b) => a - b); - - return years.map((year) => ({ - year, - [primaryName]: - primary.find((r) => Number(r.year) === year)?.Median_Age ?? null, - [comparisonName]: - comparison.find((r) => Number(r.year) === year)?.Median_Age ?? null, - })); -} - -export default function MedianAgeHistoryChart({ - primary, - comparison, - primaryName, - comparisonName, -}: MedianAgeHistoryChartProps) { - const data = buildSeries(primary, comparison, primaryName, comparisonName); - - if (data.length === 0) return null; - - return ( - - - Median Age Over Time - - - - - - - `${Number(v).toFixed(0)}`} - tick={{ fontSize: 12 }} - /> - - v != null ? `${Number(v).toFixed(1)} years` : '—' - } - /> - - - - - - - ); -} diff --git a/frontend/src/components/Reports/demographics/PopulationCard.tsx b/frontend/src/components/Reports/demographics/PopulationCard.tsx deleted file mode 100644 index ff04c677..00000000 --- a/frontend/src/components/Reports/demographics/PopulationCard.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { Card, Group, Stack, Text, ThemeIcon, Title } from '@mantine/core'; -import { IconUsers } from '@tabler/icons-react'; -import { DataRow } from '@/types/cachedCharts'; - -interface PopulationCardProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName?: string; - comparisonName?: string; -} - -function findValue(data: DataRow[], variable: string): number | null { - const row = data.find((d) => d.Variable === variable); - if (!row) return null; - const value = Number(row.Value); - return Number.isFinite(value) ? value : null; -} - -export default function PopulationCard({ - primary, - comparison, - comparisonName, -}: PopulationCardProps) { - const primaryPopulation = findValue(primary, 'Population (ACS)'); - const comparisonPopulation = findValue(comparison, 'Population (ACS)'); - - const difference = - primaryPopulation !== null && comparisonPopulation !== null - ? primaryPopulation - comparisonPopulation - : null; - - const percentDifference = - difference !== null && comparisonPopulation - ? (difference / comparisonPopulation) * 100 - : null; - - return ( - - - - - Population - - - {primaryPopulation?.toLocaleString() ?? '—'} - - - - - - - - - - - {comparisonName ? `${comparisonName}` : 'Comparison'} - - - {comparisonPopulation?.toLocaleString() ?? '—'} - - - - - Difference - - - 0 - ? 'green' - : difference < 0 - ? 'red' - : undefined - } - > - {difference == null - ? '—' - : `${difference > 0 ? '+' : ''}${difference.toLocaleString()}`} - - - - - - Percent Difference - - - 0 - ? 'green' - : percentDifference < 0 - ? 'red' - : undefined - } - > - {percentDifference == null - ? '—' - : `${percentDifference > 0 ? '+' : ''}${percentDifference.toFixed(1)}%`} - - - - - ); -} diff --git a/frontend/src/components/Reports/demographics/PopulationHistoryChart.tsx b/frontend/src/components/Reports/demographics/PopulationHistoryChart.tsx deleted file mode 100644 index 4ed4eff4..00000000 --- a/frontend/src/components/Reports/demographics/PopulationHistoryChart.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { Card, Title } from '@mantine/core'; -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer, -} from 'recharts'; -import { DataRow } from '@/types/cachedCharts'; - -interface PopulationHistoryChartProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} - -function buildSeries( - primary: DataRow[], - comparison: DataRow[], - primaryName: string, - comparisonName: string, -) { - const years = Array.from( - new Set( - [...primary, ...comparison] - .map((r) => Number(r.year)) - .filter((y) => Number.isFinite(y)), - ), - ).sort((a, b) => a - b); - - return years.map((year) => ({ - year, - [primaryName]: - primary.find((r) => Number(r.year) === year)?.Population ?? null, - [comparisonName]: - comparison.find((r) => Number(r.year) === year)?.Population ?? null, - })); -} - -export default function PopulationHistoryChart({ - primary, - comparison, - primaryName, - comparisonName, -}: PopulationHistoryChartProps) { - const data = buildSeries(primary, comparison, primaryName, comparisonName); - - if (data.length === 0) return null; - - return ( - - - Population Over Time - - - - - - - Number(v).toLocaleString()} - tick={{ fontSize: 12 }} - /> - - v != null ? Number(v).toLocaleString() : '—' - } - /> - - - - - - - ); -} diff --git a/frontend/src/components/Reports/demographics/RaceDistributionChart.tsx b/frontend/src/components/Reports/demographics/RaceDistributionChart.tsx deleted file mode 100644 index 2bd77a3d..00000000 --- a/frontend/src/components/Reports/demographics/RaceDistributionChart.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { Card, Title } from '@mantine/core'; -import { - BarChart, - Bar, - XAxis, - YAxis, - Tooltip, - Legend, - ResponsiveContainer, - CartesianGrid, -} from 'recharts'; -import { DataRow } from '@/types/cachedCharts'; - -interface RaceDistributionChartProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} - -const RACE_CATEGORIES = [ - 'White', - 'Black or African American', - 'American Indian and Alaska Native', - 'Asian', - 'Native Hawaiian and Other Pacific Islander', - 'Some other race', - 'Two or more races', - 'Hispanic or Latino (of any race)', -]; - -function getValue(data: DataRow[], variable: string): number { - const row = data.find((d) => d.Variable === variable); - return row ? Number(row.Percent) : 0; -} - -function buildRaceData( - primary: DataRow[], - comparison: DataRow[], - primaryName: string, - comparisonName: string, -) { - return RACE_CATEGORIES.map((race) => ({ - race, - [primaryName]: getValue(primary, race), - [comparisonName]: getValue(comparison, race), - })); -} - -export default function RaceDistributionChart({ - primary, - comparison, - primaryName, - comparisonName, -}: RaceDistributionChartProps) { - const data = buildRaceData(primary, comparison, primaryName, comparisonName); - - return ( - - - Race Distribution (%) - - - - - - `${v}%`} /> - - `${Number(value).toFixed(1)}%`} /> - - - - - - - ); -} diff --git a/frontend/src/components/Reports/economic/MedianHouseholdIncomeCard.tsx b/frontend/src/components/Reports/economic/MedianHouseholdIncomeCard.tsx deleted file mode 100644 index cb16eb47..00000000 --- a/frontend/src/components/Reports/economic/MedianHouseholdIncomeCard.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { Card, Group, Stack, Text, ThemeIcon, Title } from '@mantine/core'; -import { IconPigMoney } from '@tabler/icons-react'; -import { DataRow } from '@/types/cachedCharts'; -import { COLORS } from '@/app/theme'; - -interface MedianHHIncomeCardProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName?: string; - comparisonName?: string; -} - -function getVariableValue(data: DataRow[], variable: string): number | null { - const row = data.find((d) => d.Variable === variable); - if (!row) return null; - const value = Number(row.Value); - return Number.isFinite(value) ? value : null; -} - -export default function MedianHouseholdIncomeCard({ - primary, - comparison, - primaryName, - comparisonName, -}: MedianHHIncomeCardProps) { - const primaryValue = getVariableValue(primary, 'Median Household Income'); - const comparisonValue = getVariableValue( - comparison, - 'Median Household Income', - ); - - const difference = - primaryValue !== null && comparisonValue !== null - ? primaryValue - comparisonValue - : null; - - return ( - - - - - Median Household Income - - - - {primaryValue !== null ? `$${primaryValue.toLocaleString()}` : '—'} - - - - - - - - - - - - {comparisonName} - - - {comparisonValue !== null - ? `$${comparisonValue.toLocaleString()}` - : '—'} - - - - - Difference - - 0 ? 'green' : 'red' - } - > - {difference === null - ? '—' - : `${difference > 0 ? '+' : ''}$${difference.toLocaleString()}`} - - - - - {difference === null ? ( - '' - ) : difference > 0 ? ( - <> - {primaryName} has a - higher median household income than {comparisonName} - - ) : difference < 0 ? ( - <> - {primaryName} has a - lower median household income than {comparisonName} - - ) : ( - <> - {primaryName} and{' '} - {comparisonName} have the same median household - income - - )} - - - - ); -} diff --git a/frontend/src/components/Reports/economic/MedianHouseholdIncomeHistoryChart.tsx b/frontend/src/components/Reports/economic/MedianHouseholdIncomeHistoryChart.tsx deleted file mode 100644 index f5e84a97..00000000 --- a/frontend/src/components/Reports/economic/MedianHouseholdIncomeHistoryChart.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { Card, Title } from '@mantine/core'; -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer, -} from 'recharts'; -import { DataRow } from '@/types/cachedCharts'; - -interface MedianHouseholdIncomeHistoryChartProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} - -function buildSeries( - primary: DataRow[], - comparison: DataRow[], - primaryName: string, - comparisonName: string, -) { - const years = Array.from( - new Set( - [...primary, ...comparison] - .map((r) => Number(r.year)) - .filter((y) => Number.isFinite(y)), - ), - ).sort((a, b) => a - b); - - return years.map((year) => ({ - year, - [primaryName]: - primary.find((r) => Number(r.year) === year)?.Median_Household_Income ?? - null, - [comparisonName]: - comparison.find((r) => Number(r.year) === year) - ?.Median_Household_Income ?? null, - })); -} - -export default function MedianHouseholdIncomeHistoryChart({ - primary, - comparison, - primaryName, - comparisonName, -}: MedianHouseholdIncomeHistoryChartProps) { - const data = buildSeries(primary, comparison, primaryName, comparisonName); - - if (data.length === 0) return null; - - return ( - - - Median Household Income Over Time - - - - - - - `$${(Number(v) / 1000).toFixed(0)}k`} - tick={{ fontSize: 12 }} - /> - - v != null - ? `$${Number(v).toLocaleString('en-US', { maximumFractionDigits: 0 })}` - : '—' - } - /> - - - - - - - ); -} diff --git a/frontend/src/components/Reports/economic/PerCapitaIncomeCard.tsx b/frontend/src/components/Reports/economic/PerCapitaIncomeCard.tsx deleted file mode 100644 index 08bf6c92..00000000 --- a/frontend/src/components/Reports/economic/PerCapitaIncomeCard.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { Card, Group, Stack, Text, ThemeIcon, Title } from '@mantine/core'; -import { IconUserDollar } from '@tabler/icons-react'; -import { DataRow } from '@/types/cachedCharts'; -import { COLORS } from '@/app/theme'; - -interface PerCapitaIncomeCardProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName?: string; - comparisonName?: string; -} - -function getVariableValue(data: DataRow[], variable: string): number | null { - const row = data.find((d) => d.Variable === variable); - if (!row) return null; - const value = Number(row.Value); - return Number.isFinite(value) ? value : null; -} - -export default function PerCapitaIncomeCard({ - primary, - comparison, - primaryName, - comparisonName, -}: PerCapitaIncomeCardProps) { - const primaryValue = getVariableValue(primary, 'Per Capita Income'); - const comparisonValue = getVariableValue(comparison, 'Per Capita Income'); - - const difference = - primaryValue !== null && comparisonValue !== null - ? primaryValue - comparisonValue - : null; - - return ( - - - - - Per Capita Income - - - - {primaryValue !== null ? `$${primaryValue.toLocaleString()}` : '—'} - - - - - - - - - - - - {comparisonName} - - - {comparisonValue !== null - ? `$${comparisonValue.toLocaleString()}` - : '—'} - - - - - Difference - - 0 ? 'green' : 'red' - } - > - {difference === null - ? '—' - : `${difference > 0 ? '+' : ''}$${difference.toLocaleString()}`} - - - - - {difference === null ? ( - '' - ) : difference > 0 ? ( - <> - {primaryName} has a - higher per capita income than {comparisonName} - - ) : difference < 0 ? ( - <> - {primaryName} has a - lower per capita income than {comparisonName} - - ) : ( - <> - {primaryName} and{' '} - {comparisonName} have the same per capita income - - )} - - - - ); -} diff --git a/frontend/src/components/Reports/economic/PerCapitaIncomeHistoryChart.tsx b/frontend/src/components/Reports/economic/PerCapitaIncomeHistoryChart.tsx deleted file mode 100644 index c775b205..00000000 --- a/frontend/src/components/Reports/economic/PerCapitaIncomeHistoryChart.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { Card, Title } from '@mantine/core'; -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer, -} from 'recharts'; -import { DataRow } from '@/types/cachedCharts'; - -interface PerCapitaIncomeHistoryChartProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} - -function buildSeries( - primary: DataRow[], - comparison: DataRow[], - primaryName: string, - comparisonName: string, -) { - const years = Array.from( - new Set( - [...primary, ...comparison] - .map((r) => Number(r.year)) - .filter((y) => Number.isFinite(y)), - ), - ).sort((a, b) => a - b); - - return years.map((year) => ({ - year, - [primaryName]: - primary.find((r) => Number(r.year) === year)?.Per_Capita_Income ?? null, - [comparisonName]: - comparison.find((r) => Number(r.year) === year)?.Per_Capita_Income ?? - null, - })); -} - -export default function PerCapitaIncomeHistoryChart({ - primary, - comparison, - primaryName, - comparisonName, -}: PerCapitaIncomeHistoryChartProps) { - const data = buildSeries(primary, comparison, primaryName, comparisonName); - - if (data.length === 0) return null; - - return ( - - - Per Capita Income Over Time - - - - - - - `$${(Number(v) / 1000).toFixed(0)}k`} - tick={{ fontSize: 12 }} - /> - - v != null - ? `$${Number(v).toLocaleString('en-US', { maximumFractionDigits: 0 })}` - : '—' - } - /> - - - - - - - ); -} diff --git a/frontend/src/components/Reports/economic/UnemploymentRateCard.tsx b/frontend/src/components/Reports/economic/UnemploymentRateCard.tsx deleted file mode 100644 index e8478f15..00000000 --- a/frontend/src/components/Reports/economic/UnemploymentRateCard.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { Card, Group, Stack, Text, ThemeIcon, Title } from '@mantine/core'; -import { IconBriefcase } from '@tabler/icons-react'; -import { DataRow } from '@/types/cachedCharts'; -import { COLORS } from '@/app/theme'; - -interface UnemploymentRateCardProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} - -function getVariableValue(data: DataRow[], variable: string): number | null { - const row = data.find((d) => d.Variable === variable); - if (!row) return null; - const value = Number(row.Percent); - return Number.isFinite(value) ? value : null; -} - -export default function UnemploymentRateCard({ - primary, - comparison, - primaryName, - comparisonName, -}: UnemploymentRateCardProps) { - const primaryRate = getVariableValue(primary, 'Unemployment Rate'); - const comparisonRate = getVariableValue(comparison, 'Unemployment Rate'); - - const difference = - primaryRate !== null && comparisonRate !== null - ? primaryRate - comparisonRate - : null; - - return ( - - - - - Unemployment Rate - - - - {primaryRate !== null ? `${primaryRate.toFixed(1)}%` : '—'} - - - - - - - - - - - - {comparisonName} - - - - {comparisonRate !== null ? `${comparisonRate.toFixed(1)}%` : '—'} - - - - - - Difference - - - 0 ? 'red' : 'green' - } - > - {difference === null - ? '—' - : `${difference > 0 ? '+' : ''}${difference.toFixed(1)} %`} - - - - - {difference === null ? ( - '' - ) : difference > 0 ? ( - <> - {primaryName} has a - higher unemployment rate than {comparisonName} - - ) : difference < 0 ? ( - <> - {primaryName} has a - lower unemployment rate than {comparisonName} - - ) : ( - <> - {primaryName} and{' '} - {comparisonName} have the same unemployment rate - - )} - - - - ); -} diff --git a/frontend/src/components/Reports/economic/index.ts b/frontend/src/components/Reports/economic/index.ts deleted file mode 100644 index ca40a822..00000000 --- a/frontend/src/components/Reports/economic/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { default as UnemploymentRateCard } from './UnemploymentRateCard'; -export { default as MedianHouseholdIncomeCard } from './MedianHouseholdIncomeCard'; -export { default as PerCapitaIncomeCard } from './PerCapitaIncomeCard'; -export { default as MedianHouseholdIncomeHistoryChart } from './MedianHouseholdIncomeHistoryChart'; -export { default as PerCapitaIncomeHistoryChart } from './PerCapitaIncomeHistoryChart'; diff --git a/frontend/src/components/Reports/education/EducationalAttainment.tsx b/frontend/src/components/Reports/education/EducationalAttainment.tsx deleted file mode 100644 index fd2c89ac..00000000 --- a/frontend/src/components/Reports/education/EducationalAttainment.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { Card, Title } from '@mantine/core'; -import { - BarChart, - Bar, - XAxis, - YAxis, - Tooltip, - Legend, - ResponsiveContainer, - CartesianGrid, -} from 'recharts'; -import { DataRow } from '@/types/cachedCharts'; - -interface EducationalAttainmentProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} - -const EDUCATION_CATEGORIES = [ - 'No High School Diploma', - 'High School Graduate', - 'Some College, No Degree', - "Associate's Degree", - "Bachelor's Degree", - 'Postgraduate Degree', -]; - -function getValue(data: DataRow[], variable: string): number { - const row = data.find((d) => d.Variable === variable); - return row ? Number(row.Percent) : 0; -} - -function buildEducationalAttainmentData( - primary: DataRow[], - comparison: DataRow[], - primaryName: string, - comparisonName: string, -) { - return EDUCATION_CATEGORIES.map((educ) => ({ - educ, - [primaryName]: getValue(primary, educ), - [comparisonName]: getValue(comparison, educ), - })); -} - -export default function EducationalAttainmentChart({ - primary, - comparison, - primaryName, - comparisonName, -}: EducationalAttainmentProps) { - const data = buildEducationalAttainmentData( - primary, - comparison, - primaryName, - comparisonName, - ); - return ( - - - Educational Attainment Distribution (%) - - - - - `${v}%`} /> - - `${Number(value).toFixed(1)}%`} /> - - - - - - - ); -} diff --git a/frontend/src/components/Reports/education/index.ts b/frontend/src/components/Reports/education/index.ts deleted file mode 100644 index b47d815c..00000000 --- a/frontend/src/components/Reports/education/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as EducationalAttainment } from './EducationalAttainment'; diff --git a/frontend/src/components/Reports/housing/MedianHomeValueCard.tsx b/frontend/src/components/Reports/housing/MedianHomeValueCard.tsx deleted file mode 100644 index ffa41c31..00000000 --- a/frontend/src/components/Reports/housing/MedianHomeValueCard.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { Card, Group, Stack, Text, ThemeIcon, Title } from '@mantine/core'; -import { IconHomeDollar } from '@tabler/icons-react'; -import { DataRow } from '@/types/cachedCharts'; - -interface MedianHomeValueCardProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName?: string; - comparisonName?: string; -} - -function getVariableValue(data: DataRow[], variable: string): number | null { - const row = data.find((d) => d.Variable === variable); - if (!row) return null; - const value = Number(row.Value); - return Number.isFinite(value) ? value : null; -} - -export default function MedianHomeValueCard({ - primary, - comparison, - primaryName, - comparisonName, -}: MedianHomeValueCardProps) { - const primaryValue = getVariableValue(primary, 'Median Home Value'); - const comparisonValue = getVariableValue(comparison, 'Median Home Value'); - - const difference = - primaryValue !== null && comparisonValue !== null - ? primaryValue - comparisonValue - : null; - - return ( - - - - - Median Home Value - - - - {primaryValue !== null ? `$${primaryValue.toLocaleString()}` : '—'} - - - - - - - - - - - - {comparisonName} - - - {comparisonValue !== null - ? `$${comparisonValue.toLocaleString()}` - : '—'} - - - - - Difference - - 0 ? 'green' : 'red' - } - > - {difference === null - ? '—' - : `${difference > 0 ? '+' : ''}$${difference.toLocaleString()}`} - - - - - {difference === null ? ( - '' - ) : difference > 0 ? ( - <> - {primaryName} has a - higher median home value than {comparisonName} - - ) : difference < 0 ? ( - <> - {primaryName} has a - lower median home value than {comparisonName} - - ) : ( - <> - {primaryName} and{' '} - {comparisonName} have the same median home value - - )} - - - - ); -} diff --git a/frontend/src/components/Reports/housing/MedianHomeValueHistoryChart.tsx b/frontend/src/components/Reports/housing/MedianHomeValueHistoryChart.tsx deleted file mode 100644 index 55492cba..00000000 --- a/frontend/src/components/Reports/housing/MedianHomeValueHistoryChart.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { Card, Title } from '@mantine/core'; -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer, -} from 'recharts'; -import { DataRow } from '@/types/cachedCharts'; - -interface MedianHomeValueHistoryChartProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} - -function buildSeries( - primary: DataRow[], - comparison: DataRow[], - primaryName: string, - comparisonName: string, -) { - const years = Array.from( - new Set( - [...primary, ...comparison] - .map((r) => Number(r.year)) - .filter((y) => Number.isFinite(y)), - ), - ).sort((a, b) => a - b); - - return years.map((year) => ({ - year, - [primaryName]: - primary.find((r) => Number(r.year) === year)?.Median_Home_Value ?? null, - [comparisonName]: - comparison.find((r) => Number(r.year) === year)?.Median_Home_Value ?? - null, - })); -} - -export default function MedianHomeValueHistoryChart({ - primary, - comparison, - primaryName, - comparisonName, -}: MedianHomeValueHistoryChartProps) { - const data = buildSeries(primary, comparison, primaryName, comparisonName); - - if (data.length === 0) return null; - - return ( - - - Median Home Value Over Time - - - - - - - `$${(Number(v) / 1000).toFixed(0)}k`} - tick={{ fontSize: 12 }} - /> - - v != null - ? `$${Number(v).toLocaleString('en-US', { maximumFractionDigits: 0 })}` - : '—' - } - /> - - - - - - - ); -} diff --git a/frontend/src/components/Reports/housing/OccupancyDistributionChart.tsx b/frontend/src/components/Reports/housing/OccupancyDistributionChart.tsx deleted file mode 100644 index 8cd11297..00000000 --- a/frontend/src/components/Reports/housing/OccupancyDistributionChart.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Card, Title } from '@mantine/core'; -import { - BarChart, - Bar, - XAxis, - YAxis, - Tooltip, - Legend, - ResponsiveContainer, - CartesianGrid, -} from 'recharts'; -import { DataRow } from '@/types/cachedCharts'; - -interface OccupancyDistributionChartProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} - -function getValue(data: DataRow[], variable: string): number { - const row = data.find((d) => d.Variable === variable); - return row ? Number(row.Percent) : 0; -} - -function buildOccupancyData( - primary: DataRow[], - comparison: DataRow[], - primaryName: string, - comparisonName: string, -) { - return [ - { - category: 'Owner-Occupied', - [primaryName]: getValue(primary, 'Owner-Occupied Units'), - [comparisonName]: getValue(comparison, 'Owner-Occupied Units'), - }, - { - category: 'Renter-Occupied', - [primaryName]: getValue(primary, 'Renter-Occupied Units'), - [comparisonName]: getValue(comparison, 'Renter-Occupied Units'), - }, - ]; -} - -export default function OccupancyDistributionChart({ - primary, - comparison, - primaryName, - comparisonName, -}: OccupancyDistributionChartProps) { - const data = buildOccupancyData( - primary, - comparison, - primaryName, - comparisonName, - ); - - return ( - - - Occupancy Distribution - - - - - - `${value}%`} - /> - - `${Number(value).toFixed(1)}%`} /> - - - - - - - ); -} diff --git a/frontend/src/components/Reports/housing/TotalHousingUnitsCard.tsx b/frontend/src/components/Reports/housing/TotalHousingUnitsCard.tsx deleted file mode 100644 index e0ed1788..00000000 --- a/frontend/src/components/Reports/housing/TotalHousingUnitsCard.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { Card, Group, Stack, Text, ThemeIcon, Title } from '@mantine/core'; -import { IconBuildingCommunity } from '@tabler/icons-react'; -import { DataRow } from '@/types/cachedCharts'; - -interface HousingUnitsCardProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName?: string; - comparisonName?: string; -} - -function getVariableValue(data: DataRow[], variable: string): number | null { - const row = data.find((d) => d.Variable === variable); - if (!row) return null; - const value = Number(row.Value); - return Number.isFinite(value) ? value : null; -} - -export default function TotalHousingUnitsCard({ - primary, - comparison, - primaryName, - comparisonName, -}: HousingUnitsCardProps) { - const primaryValue = getVariableValue(primary, 'Total Housing Units'); - const comparisonValue = getVariableValue(comparison, 'Total Housing Units'); - - const difference = - primaryValue !== null && comparisonValue !== null - ? primaryValue - comparisonValue - : null; - - return ( - - - - - Total Housing Units - - - - {primaryValue !== null ? `${primaryValue.toLocaleString()}` : '—'} - - - - - - - - - - - - {comparisonName} - - - {comparisonValue !== null - ? `${comparisonValue.toLocaleString()}` - : '—'} - - - - - Difference - - 0 ? 'green' : 'red' - } - > - {difference === null - ? '—' - : `${difference > 0 ? '+' : ''}${difference.toLocaleString()}`} - - - - - {difference === null ? ( - '' - ) : difference > 0 ? ( - <> - {primaryName} has{' '} - {difference.toLocaleString()} more housing units than{' '} - {comparisonName} - - ) : difference < 0 ? ( - <> - {primaryName} has{' '} - {(difference * -1).toLocaleString()} less housing - units than {comparisonName} - - ) : ( - <> - {primaryName} and{' '} - {comparisonName} have the number of housing units - - )} - - - - ); -} diff --git a/frontend/src/components/Reports/housing/TotalHousingUnitsHistoryChart.tsx b/frontend/src/components/Reports/housing/TotalHousingUnitsHistoryChart.tsx deleted file mode 100644 index adef9011..00000000 --- a/frontend/src/components/Reports/housing/TotalHousingUnitsHistoryChart.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Card, Title } from '@mantine/core'; -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer, -} from 'recharts'; -import { DataRow } from '@/types/cachedCharts'; - -interface TotalHousingUnitsHistoryChartProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} - -function buildSeries( - primary: DataRow[], - comparison: DataRow[], - primaryName: string, - comparisonName: string, -) { - const years = Array.from( - new Set( - [...primary, ...comparison] - .map((r) => Number(r.year)) - .filter((y) => Number.isFinite(y)), - ), - ).sort((a, b) => a - b); - - return years.map((year) => ({ - year, - [primaryName]: - primary.find((r) => Number(r.year) === year)?.Total_Housing_Units ?? null, - [comparisonName]: - comparison.find((r) => Number(r.year) === year)?.Total_Housing_Units ?? - null, - })); -} - -export default function TotalHousingUnitsHistoryChart({ - primary, - comparison, - primaryName, - comparisonName, -}: TotalHousingUnitsHistoryChartProps) { - const data = buildSeries(primary, comparison, primaryName, comparisonName); - - if (data.length === 0) return null; - - return ( - - - Total Housing Units Over Time - - - - - - - Number(v).toLocaleString()} - tick={{ fontSize: 12 }} - /> - - v != null ? Number(v).toLocaleString() : '—' - } - /> - - - - - - - ); -} diff --git a/frontend/src/components/Reports/housing/VacancyDistributionChart.tsx b/frontend/src/components/Reports/housing/VacancyDistributionChart.tsx deleted file mode 100644 index a307d62c..00000000 --- a/frontend/src/components/Reports/housing/VacancyDistributionChart.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Card, Title } from '@mantine/core'; -import { - BarChart, - Bar, - XAxis, - YAxis, - Tooltip, - Legend, - ResponsiveContainer, - CartesianGrid, -} from 'recharts'; -import { DataRow } from '@/types/cachedCharts'; - -interface VacancyDistributionChartProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} - -function getValue(data: DataRow[], variable: string): number { - const row = data.find((d) => d.Variable === variable); - return row ? Number(row.Percent) : 0; -} - -function buildVacancyData( - primary: DataRow[], - comparison: DataRow[], - primaryName: string, - comparisonName: string, -) { - return [ - { - category: 'Own', - [primaryName]: getValue(primary, 'Homeowner Vacancy Rate'), - [comparisonName]: getValue(comparison, 'Homeowner Vacancy Rate'), - }, - { - category: 'Rent', - [primaryName]: getValue(primary, 'Rental Vacancy Rate'), - [comparisonName]: getValue(comparison, 'Rental Vacancy Rate'), - }, - ]; -} - -export default function VacancyDistributionChart({ - primary, - comparison, - primaryName, - comparisonName, -}: VacancyDistributionChartProps) { - const data = buildVacancyData( - primary, - comparison, - primaryName, - comparisonName, - ); - - return ( - - - Vacancy Distribution - - - - - - `${value}%`} - /> - - `${Number(value).toFixed(1)}%`} /> - - - - - - - ); -} diff --git a/frontend/src/components/Reports/housing/index.ts b/frontend/src/components/Reports/housing/index.ts deleted file mode 100644 index 42982586..00000000 --- a/frontend/src/components/Reports/housing/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { default as MedianHomeValueCard } from './MedianHomeValueCard'; -export { default as TotalHousingUnitsCard } from './TotalHousingUnitsCard'; -export { default as OccupancyDistributionChart } from './OccupancyDistributionChart'; -export { default as VacancyDistributionChart } from './VacancyDistributionChart'; -export { default as MedianHomeValueHistoryChart } from './MedianHomeValueHistoryChart'; -export { default as TotalHousingUnitsHistoryChart } from './TotalHousingUnitsHistoryChart'; diff --git a/frontend/src/components/Reports/land_use/AcreageByDistrictType.tsx b/frontend/src/components/Reports/land_use/AcreageByDistrictType.tsx deleted file mode 100644 index 07a7fb9d..00000000 --- a/frontend/src/components/Reports/land_use/AcreageByDistrictType.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { Card, Title } from '@mantine/core'; -import { - BarChart, - Bar, - XAxis, - YAxis, - Tooltip, - Legend, - ResponsiveContainer, - CartesianGrid, -} from 'recharts'; -import { DataRow } from '@/types/cachedCharts'; - -interface DistrictTypeChartProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} - -const DISTRICT_TYPES = ['Nonresidential', 'Mixed', 'Overlay', 'Residential']; - -function getRow(data: DataRow[], districtType: string): DataRow | undefined { - return data.find((d) => d['District Type'] === districtType); -} - -function buildDistrictTypeData( - primary: DataRow[], - comparison: DataRow[], - primaryName: string, - comparisonName: string, -) { - return DISTRICT_TYPES.map((type) => { - const primaryRow = getRow(primary, type); - const comparisonRow = getRow(comparison, type); - - return { - category: type, - [primaryName]: Number(primaryRow?.Acres ?? 0), - [comparisonName]: Number(comparisonRow?.Acres ?? 0), - primaryColor: primaryRow?.hex_color || '#a73c00', - }; - }); -} - -export default function DistrictTypeChart({ - primary, - comparison, - primaryName, - comparisonName, -}: DistrictTypeChartProps) { - const data = buildDistrictTypeData( - primary, - comparison, - primaryName, - comparisonName, - ); - - return ( - - - Acreage by District Type - - - - - - - Number(value).toLocaleString()} - /> - - - - - `${Number(value).toLocaleString(undefined, { - maximumFractionDigits: 1, - })} acres` - } - /> - - - - - - - - - - ); -} From f5c3d5d310ad452c4ad66576a18fd7b96405e06c Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Tue, 6 Oct 2026 12:52:03 -0400 Subject: [PATCH 2/7] Improved Reports by Topic page UI --- .../reports-by-topic/ReportsByTopic.tsx | 408 +++++++----------- .../reports-by-topic/TopicLanding.tsx | 154 +++++++ .../data-comparison/reports-by-topic/page.tsx | 9 +- .../reports-by-topic/topics.ts | 2 - .../dashboards/DemographicDashboard.tsx | 329 ++++---------- .../Reports/dashboards/EconomicDashboard.tsx | 123 +++--- .../Reports/dashboards/EducationDashboard.tsx | 55 +-- .../Reports/dashboards/HealthDashboard.tsx | 60 +-- .../Reports/dashboards/HousingDashboard.tsx | 170 ++++---- .../Reports/dashboards/LandUseDashboard.tsx | 100 ++--- .../demographics/SexDistributionChart.tsx | 93 ++-- .../components/Reports/demographics/index.ts | 6 - .../Reports/health/IndicatorTable.tsx | 18 +- .../Reports/health/KeyIndicatorTiles.tsx | 25 +- .../Reports/health/PovertyUninsuredTrends.tsx | 45 +- .../land_use/WastewaterPermitsTable.tsx | 39 +- .../Reports/land_use/ZoningAllowanceChart.tsx | 44 +- .../land_use/ZoningCoverageStatCard.tsx | 49 +-- .../src/components/Reports/land_use/index.ts | 1 - .../Reports/shared/ComparisonBarChart.tsx | 116 +++++ .../Reports/shared/HistoryLineChart.tsx | 90 ++++ .../Reports/shared/ReportContext.tsx | 54 +++ .../src/components/Reports/shared/Section.tsx | 131 ++++++ .../components/Reports/shared/StatCard.tsx | 56 +++ .../src/components/Reports/shared/colors.ts | 13 + .../src/components/Reports/shared/data.ts | 71 +++ .../src/components/Reports/shared/index.ts | 8 + 27 files changed, 1277 insertions(+), 992 deletions(-) create mode 100644 frontend/src/app/data-comparison/reports-by-topic/TopicLanding.tsx create mode 100644 frontend/src/components/Reports/shared/ComparisonBarChart.tsx create mode 100644 frontend/src/components/Reports/shared/HistoryLineChart.tsx create mode 100644 frontend/src/components/Reports/shared/ReportContext.tsx create mode 100644 frontend/src/components/Reports/shared/Section.tsx create mode 100644 frontend/src/components/Reports/shared/StatCard.tsx create mode 100644 frontend/src/components/Reports/shared/colors.ts create mode 100644 frontend/src/components/Reports/shared/data.ts create mode 100644 frontend/src/components/Reports/shared/index.ts diff --git a/frontend/src/app/data-comparison/reports-by-topic/ReportsByTopic.tsx b/frontend/src/app/data-comparison/reports-by-topic/ReportsByTopic.tsx index db1c038a..fbe7fb78 100644 --- a/frontend/src/app/data-comparison/reports-by-topic/ReportsByTopic.tsx +++ b/frontend/src/app/data-comparison/reports-by-topic/ReportsByTopic.tsx @@ -1,17 +1,18 @@ 'use client'; import { useState, useEffect, useMemo } from 'react'; -import { useRouter } from 'next/navigation'; +import Link from 'next/link'; import axios from 'axios'; import { Alert, + Anchor, + Badge, Box, + Button, Container, Group, - Grid, Loader, Paper, - Select, Stack, Text, Title, @@ -29,7 +30,19 @@ import { // import { ChartStack } from '@/components/Charts'; // import { createChartItem } from '@/utils/itemFactory'; import { DataRow } from '@/types/cachedCharts'; -import { IconInfoCircle } from '@tabler/icons-react'; +import { + IconArrowLeft, + IconDownload, + IconInfoCircle, +} from '@tabler/icons-react'; +import { COLORS } from '@/app/theme'; +import { + COMPARISON_COLOR, + ReportProvider, + TOPIC_ACCENTS, +} from '@/components/Reports/shared'; +import type { ReportData } from '@/components/Reports/shared'; +import { exportReport } from '@/utils/exportReport'; import { DashboardSection, TOPIC_SLUGS, topicPath } from './topics'; // --------------------------------------------------------------------------- @@ -305,208 +318,142 @@ function buildLocationFilters( return {}; } -function HeroSection({ +// The topic switcher and the places being compared, stuck under the site +// header so both stay in view while the report scrolls. +function ReportHeader({ section, - setSection, - year, - setYear, - availableYears, + data, + showYear, }: { - section: string; - setSection: (value: string) => void; - year: number; - setYear: (value: number) => void; - availableYears: number[]; + section: DashboardSection; + data: ReportData; + showYear: boolean; }) { - return ( - - - - - - - American Community Survey - - - - Reports by Topic - + const [exporting, setExporting] = useState(false); + const accent = TOPIC_ACCENTS[section]; + + const handleExport = async () => { + setExporting(true); + try { + await exportReport('report-body', { + title: section, + primaryName: data.primary.name, + comparisonName: data.comparison.name, + year: data.year, + }); + } catch (e) { + console.error('PDF export failed:', e); + } finally { + setExporting(false); + } + }; - - Curated dashboards by data topic. Select a category to get - started. - - - - - - - - - + const place = (color: string, label: string, name: string) => ( + + + + {label} + + + {name} + + ); -} -function ExplorerControls({ - section, - setSection, - year, - setYear, - availableYears, -}: { - section: string; - setSection: (value: string) => void; - year: number; - setYear: (value: number) => void; - availableYears: number[]; -}) { return ( - - - - Analysis - - - + <Group gap="sm" mb={4}> + <Anchor + component={Link} + href="/data-comparison/reports-by-topic/" + size="sm" + fw={600} + c={COLORS.birchDim} + style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }} > - Choose a dataset - - - - Select an ACS topic and year to begin exploring detailed Census - tables. - - - - - v && setYear(Number(v))} - data={availableYears.map((y) => ({ - value: String(y), - label: String(y), - }))} - disabled={availableYears.length === 0} - w={150} - styles={{ - label: { - color: '#E7B563', - fontFamily: "'IBM Plex Mono', ui-monospace, monospace", - textTransform: 'uppercase', - fontSize: 11, - letterSpacing: '.08em', - }, - input: { - borderRadius: 12, - height: 48, - background: 'rgba(255,255,255,.08)', - border: '1px solid rgba(255,255,255,.18)', - color: '#F6F5EF', - }, - dropdown: { - borderRadius: 12, - }, - }} - /> + + All topics + + + + Reports by Topic + + + {/* Plain links, so each topic stays its own statically exported route. */} + + {(Object.keys(TOPIC_SLUGS) as DashboardSection[]).map((topic) => { + const active = topic === section; + return ( + + {topic} + + ); + })} - + + + + + + + {place(accent, 'Primary', data.primary.name)} + {place(COMPARISON_COLOR, 'Benchmark', data.comparison.name)} + {showYear && ( + + ACS {data.year} + + )} + + + + + ); } @@ -523,11 +470,8 @@ export default function ReportsByTopic({ }: { section: DashboardSection; }) { - const { - myLocation: profileLocation, - comparison: profileComparison, - yearMax: profileYearMax, - } = useProfile(); + const { myLocation: profileLocation, comparison: profileComparison } = + useProfile(); // Everything below uses the resolved areas (see resolveLocation). const primaryPick = useMemo( () => resolveLocation(profileLocation), @@ -539,11 +483,6 @@ export default function ReportsByTopic({ ); const myLocation = primaryPick.location; const comparison = comparisonPick.location; - const router = useRouter(); - const setSection = (value: string) => { - if (!(value in TOPIC_SLUGS) || value === section) return; - router.push(topicPath(value as DashboardSection), { scroll: false }); - }; const [primaryData, setPrimaryData] = useState([]); const [compareData, setCompareData] = useState([]); const [timeseriesData, setTimeseriesData] = useState< @@ -680,22 +619,11 @@ export default function ReportsByTopic({ ), ).sort(); - const [year, setYear] = useState(profileYearMax); - - // When section changes the available years may shift — clamp to closest. - useEffect(() => { - if (availableYears.length === 0) return; - if (!availableYears.includes(year)) { - const nearest = availableYears.reduce((a, b) => - Math.abs(b - year) < Math.abs(a - year) ? b : a, - ); - // eslint-disable-next-line react-hooks/set-state-in-effect -- clamp selection when the section's year range shifts - setYear(nearest); - } - }, [availableYears.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps + // Always the latest year available; there's no year picker. + const year = availableYears.length ? Math.max(...availableYears) : 0; // --------------------------------------------------------------------------- - // Slice to selected year + // Slice to the latest year // --------------------------------------------------------------------------- const yearStr = String(year); const cfg = SECTIONS[section]; @@ -711,7 +639,7 @@ export default function ReportsByTopic({ // Charts are labeled with the place the section's data actually describes // (e.g. a town's county on Community Health), not the profile's choice; // the notes above the report explain any substitution. - const dashboardData = useMemo(() => { + const dashboardData = useMemo(() => { const cfg = SECTIONS[section]; const primaryPlace = dataLocation(cfg, myLocation); const comparisonPlace = dataLocation(cfg, comparison); @@ -748,31 +676,7 @@ export default function ReportsByTopic({ comparison, ]); - interface DashboardData { - year: number; - primary: { - name: string; - location?: Location; - current: DataRow[]; - history: DataRow[]; - }; - comparison: { - name: string; - location?: Location; - current: DataRow[]; - history: DataRow[]; - }; - timeseries?: Record; - allAreas?: Record; - } - - interface DashboardProps { - data: DashboardData; - } - - const dashboards: Partial< - Record> - > = { + const dashboards: Partial> = { Demographics: DemographicsDashboard, Housing: HousingDashboard, //Education: EducationDashboard, //Temporarily down until more education data gathered @@ -796,14 +700,12 @@ export default function ReportsByTopic({ // --------------------------------------------------------------------------- return ( <> - 0 && cfg.hasYearDimension !== false} /> - + {error && {error}} {areaNotes.length > 0 && ( ) : ( - Dashboard && + Dashboard && ( + +
+ +
+
+ ) )}
diff --git a/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.tsx b/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.tsx new file mode 100644 index 00000000..d815f004 --- /dev/null +++ b/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.tsx @@ -0,0 +1,154 @@ +'use client'; + +import Link from 'next/link'; +import { + Box, + Button, + Container, + Group, + SimpleGrid, + Stack, + Text, + Title, +} from '@mantine/core'; +import { + IconBriefcase, + IconHeartbeat, + IconHome2, + IconMap2, + IconPencil, + IconUsers, + type Icon, +} from '@tabler/icons-react'; +import { COLORS } from '@/app/theme'; +import { useProfile } from '@/components/profile/profileStore'; +import { + COMPARISON_COLOR, + TOPIC_ACCENTS, +} from '@/components/Reports/shared/colors'; +import { DashboardSection, TOPIC_SLUGS, topicPath } from './topics'; + +const TOPICS: Record< + DashboardSection, + { icon: Icon; blurb: string; shows: string } +> = { + Demographics: { + icon: IconUsers, + blurb: 'Who lives here.', + shows: 'Population, age, sex, race and ethnicity', + }, + Housing: { + icon: IconHome2, + blurb: 'The homes and what they cost.', + shows: 'Home values, housing units, occupancy, vacancy', + }, + 'Labor & Economy': { + icon: IconBriefcase, + blurb: 'How people earn.', + shows: 'Household and per capita income, unemployment', + }, + 'Land Use': { + icon: IconMap2, + blurb: 'How land can be used.', + shows: 'Zoning districts, residential allowances, wastewater permits', + }, + 'Community Health': { + icon: IconHeartbeat, + blurb: 'How residents are doing.', + shows: 'CDC health indicators, poverty and insurance trends', + }, +}; + +export default function TopicLanding() { + const { myLocation, comparison, openProfileModal } = useProfile(); + + return ( + + + + + Reports by Topic + + + Pick a topic to see how your place compares with a benchmark. + + + {[ + [COLORS.spruce, 'Your place', myLocation.name], + [COMPARISON_COLOR, 'Compared with', comparison.name], + ].map(([color, label, name]) => ( + + + + + {label} + + + {name} + + + + ))} + + + + + + {(Object.keys(TOPIC_SLUGS) as DashboardSection[]).map((topic) => { + const { icon: TopicIcon, blurb, shows } = TOPICS[topic]; + const accent = TOPIC_ACCENTS[topic]; + return ( + + + + + {topic} + + + + {blurb} + + + {shows} + + + Open report → + + + ); + })} + + + + ); +} diff --git a/frontend/src/app/data-comparison/reports-by-topic/page.tsx b/frontend/src/app/data-comparison/reports-by-topic/page.tsx index efb722b1..a47861f1 100644 --- a/frontend/src/app/data-comparison/reports-by-topic/page.tsx +++ b/frontend/src/app/data-comparison/reports-by-topic/page.tsx @@ -1,8 +1,7 @@ -import ReportsByTopic from './ReportsByTopic'; -import { DEFAULT_SECTION } from './topics'; +import TopicLanding from './TopicLanding'; -// The bare /reports-by-topic/ URL (header and menu links) opens the default -// topic; each topic also has its own page under [topic]/. +// The bare /reports-by-topic/ URL (header and menu links) is the topic +// chooser; each topic has its own page under [topic]/. export default function ReportsByTopicPage() { - return ; + return ; } diff --git a/frontend/src/app/data-comparison/reports-by-topic/topics.ts b/frontend/src/app/data-comparison/reports-by-topic/topics.ts index bad0dfb8..7f184e16 100644 --- a/frontend/src/app/data-comparison/reports-by-topic/topics.ts +++ b/frontend/src/app/data-comparison/reports-by-topic/topics.ts @@ -22,8 +22,6 @@ export const TOPIC_SLUGS: Record = { 'Community Health': 'community-health', }; -export const DEFAULT_SECTION: DashboardSection = 'Demographics'; - export function sectionFromSlug(slug: string): DashboardSection | undefined { return (Object.keys(TOPIC_SLUGS) as DashboardSection[]).find( (s) => TOPIC_SLUGS[s] === slug, diff --git a/frontend/src/components/Reports/dashboards/DemographicDashboard.tsx b/frontend/src/components/Reports/dashboards/DemographicDashboard.tsx index ee6a2741..3ceddfa4 100644 --- a/frontend/src/components/Reports/dashboards/DemographicDashboard.tsx +++ b/frontend/src/components/Reports/dashboards/DemographicDashboard.tsx @@ -1,245 +1,106 @@ -import { useState } from 'react'; +import { Grid } from '@mantine/core'; +import { IconCalendarStats, IconUsers } from '@tabler/icons-react'; import { - Paper, - Title, - Text, - Group, - Stack, - Badge, - Grid, - Button, - ActionIcon, - Tooltip, - Divider, -} from '@mantine/core'; -import { IconDownload, IconMapPin, IconRefresh } from '@tabler/icons-react'; -import { DataRow } from '@/types/cachedCharts'; -import { - PopulationCard, - MedianAgeCard, - SexDistributionChart, - AgeDistributionChart, - RaceDistributionChart, - PopulationHistoryChart, - MedianAgeHistoryChart, -} from '@/components/Reports/demographics'; -import { exportReport } from '@/utils/exportReport'; - -export interface DashboardData { - year: number; - primary: { - name: string; - current: DataRow[]; - history: DataRow[]; - }; - comparison: { - name: string; - current: DataRow[]; - history: DataRow[]; - }; - // Additional time-series tables, keyed by the SECTIONS.timeseries config - // key (e.g. "medianAge", "historicPopulation") — populated only for - // topics that declare extra endpoints in reports-by-topic/page.tsx. - timeseries?: Record; -} - -export interface DashboardProps { - data: DashboardData; - onRefresh?: () => void; - onExport?: () => void; -} - -export default function DemographicsDashboard({ - data, - onRefresh, - onExport, -}: DashboardProps) { - const { year, primary, comparison, timeseries } = data; - const [isExporting, setIsExporting] = useState(false); + ComparisonBarChart, + HistoryLineChart, + ReportSection, + StatCard, + StatStrip, + takeaway, + useReport, +} from '@/components/Reports/shared'; +import { SexDistributionChart } from '@/components/Reports/demographics'; - const handleExport = async () => { - // If a custom parent handler is passed, call it - if (onExport) { - onExport(); - return; - } +const AGE_GROUPS = [ + 'Under 18', + '18 to 24', + '25 to 34', + '35 to 44', + '45 to 54', + '55 to 64', + '65 to 74', + '75 Plus', +]; - // Default client-side PDF export fallback - setIsExporting(true); - try { - await exportReport('demographics-dashboard-report', { - title: 'Demographics', - primaryName: primary.name, - comparisonName: comparison.name, - year: year, - }); - } catch (error) { - console.error('PDF export failed:', error); - } finally { - setIsExporting(false); - } - }; +const RACE_CATEGORIES = [ + 'White', + 'Black or African American', + 'American Indian and Alaska Native', + 'Asian', + 'Native Hawaiian and Other Pacific Islander', + 'Some other race', + 'Two or more races', + 'Hispanic or Latino (of any race)', +]; +export default function DemographicsDashboard() { + const ctx = useReport(); return ( - - {/* Integrated Action & Metric Context Header */} - + - - - - - Demographic Profile - - - ACS {year} - - - - - - - - Primary: - - - {primary.name} - - - - - - - - - Benchmark: - - - {comparison.name} - - - - - - - {onRefresh && ( - - - - - - )} - - - - - - - {/* PDF Exportable Container */} -
- - {/* Row 1: KPI Summary Cards */} -
- - - - - - - - -
- - {/* Row 2: Sex & Age Charts */} -
- - - - - - - - -
+ + } + variable="Population (ACS)" + format="int" + percentDiff + /> + } + variable="Median Age" + format="years" + /> + + + + + + + + + + - {/* Row 3: Diversity Breakdown Chart */} -
- + + + + + + -
+ + + - {/* Row 4: Historic trend charts, from timeseries tables */} - {timeseries && ( -
- - {timeseries.historicPopulation && ( - - - - )} - {timeseries.medianAge && ( - - - - )} - -
- )} -
-
-
+ + + + ); } diff --git a/frontend/src/components/Reports/dashboards/EconomicDashboard.tsx b/frontend/src/components/Reports/dashboards/EconomicDashboard.tsx index 7c5e86bf..327186e3 100644 --- a/frontend/src/components/Reports/dashboards/EconomicDashboard.tsx +++ b/frontend/src/components/Reports/dashboards/EconomicDashboard.tsx @@ -1,83 +1,70 @@ import { Grid } from '@mantine/core'; -import { DataRow } from '@/types/cachedCharts'; import { - UnemploymentRateCard, - MedianHouseholdIncomeCard, - PerCapitaIncomeCard, - MedianHouseholdIncomeHistoryChart, - PerCapitaIncomeHistoryChart, -} from '@/components/Reports/economic'; - -export interface DashboardData { - year: number; - primary: { - name: string; - current: DataRow[]; - history: DataRow[]; - }; - comparison: { - name: string; - current: DataRow[]; - history: DataRow[]; - }; - // Additional time-series tables, keyed by the SECTIONS.timeseries config - // key in reports-by-topic/page.tsx (e.g. "householdIncome"). - timeseries?: Record; -} - -export interface DashboardProps { - data: DashboardData; -} - -export default function EconomicDashboard({ data }: DashboardProps) { - const { primary, comparison, timeseries } = data; + IconBriefcase, + IconPigMoney, + IconUserDollar, +} from '@tabler/icons-react'; +import { + HistoryLineChart, + ReportSection, + StatCard, + StatStrip, + takeaway, + useReport, +} from '@/components/Reports/shared'; +export default function EconomicDashboard() { + const ctx = useReport(); return ( - - - + + } + variable="Median Household Income" + format="usd" /> - - - } + variable="Per Capita Income" + format="usd" /> - - - } + variable="Unemployment Rate" + field="Percent" + format="pct" /> - - {timeseries?.householdIncome && ( + + - - )} - {timeseries?.perCapitaIncome && ( - - )} - + + ); } diff --git a/frontend/src/components/Reports/dashboards/EducationDashboard.tsx b/frontend/src/components/Reports/dashboards/EducationDashboard.tsx index 9e0f0038..63006b7a 100644 --- a/frontend/src/components/Reports/dashboards/EducationDashboard.tsx +++ b/frontend/src/components/Reports/dashboards/EducationDashboard.tsx @@ -1,41 +1,24 @@ -import { Grid } from '@mantine/core'; -import { DataRow } from '@/types/cachedCharts'; -import { EducationalAttainment } from '@/components/Reports/education'; +import { ComparisonBarChart, ReportSection } from '@/components/Reports/shared'; -export interface DashboardData { - year: number; +const EDUCATION_CATEGORIES = [ + 'No High School Diploma', + 'High School Graduate', + 'Some College, No Degree', + "Associate's Degree", + "Bachelor's Degree", + 'Postgraduate Degree', +]; - primary: { - name: string; - current: DataRow[]; - history: DataRow[]; - }; - - comparison: { - name: string; - current: DataRow[]; - history: DataRow[]; - }; -} - -export interface DashboardProps { - data: DashboardData; -} - -export default function EducationDashboard({ data }: DashboardProps) { - const { primary, comparison } = data; - - // TODO: Add more charts and tables for educational attainment dashboard (from dept of education data?) +// TODO: Add more charts and tables for educational attainment dashboard (from dept of education data?) +export default function EducationDashboard() { return ( - - - - - + + + ); } diff --git a/frontend/src/components/Reports/dashboards/HealthDashboard.tsx b/frontend/src/components/Reports/dashboards/HealthDashboard.tsx index ae9e16a1..62f6d1e4 100644 --- a/frontend/src/components/Reports/dashboards/HealthDashboard.tsx +++ b/frontend/src/components/Reports/dashboards/HealthDashboard.tsx @@ -1,6 +1,6 @@ -import { Grid, Text } from '@mantine/core'; -import { DataRow } from '@/types/cachedCharts'; +import { Text } from '@mantine/core'; import type { Location } from '@/components/profile/profileStore'; +import { ReportSection, useReport } from '@/components/Reports/shared'; import { IndicatorTable, KeyIndicatorTiles, @@ -14,33 +14,8 @@ import { indicatorPlace, } from '@/components/Reports/health/indicatorRows'; -export interface DashboardData { - year: number; - primary: { - name: string; - location?: Location; - current: DataRow[]; - history: DataRow[]; - }; - comparison: { - name: string; - location?: Location; - current: DataRow[]; - history: DataRow[]; - }; - // Extra endpoints declared in reports-by-topic's SECTIONS (e.g. - // "povertyUninsured"). - timeseries?: Record; - // Section-wide endpoints (e.g. "countyValues"), not per location. - allAreas?: Record; -} - -export interface DashboardProps { - data: DashboardData; -} - -export default function HealthDashboard({ data }: DashboardProps) { - const { primary, comparison, timeseries, allAreas } = data; +export default function HealthDashboard() { + const { primary, comparison, allAreas } = useReport(); const rows = buildIndicatorRows(primary.current, comparison.current); const dataYear = Math.max( 0, @@ -56,8 +31,8 @@ export default function HealthDashboard({ data }: DashboardProps) { const comparisonCounty = countyOf(comparison.location); return ( - - + <> + - - - - - + + + + + + + - - Source: CDC PLACES {dataYear ? ` estimates for ${dataYear}` : ''}, age-adjusted. These @@ -102,7 +72,7 @@ export default function HealthDashboard({ data }: DashboardProps) { statewide figure, so Vermont values are the county estimates averaged by each county's adult population. - - + + ); } diff --git a/frontend/src/components/Reports/dashboards/HousingDashboard.tsx b/frontend/src/components/Reports/dashboards/HousingDashboard.tsx index 7a88561c..4df04ef8 100644 --- a/frontend/src/components/Reports/dashboards/HousingDashboard.tsx +++ b/frontend/src/components/Reports/dashboards/HousingDashboard.tsx @@ -1,92 +1,94 @@ import { Grid } from '@mantine/core'; -import { DataRow } from '@/types/cachedCharts'; +import { IconBuildingCommunity, IconHomeDollar } from '@tabler/icons-react'; import { - MedianHomeValueCard, - TotalHousingUnitsCard, - OccupancyDistributionChart, - VacancyDistributionChart, - MedianHomeValueHistoryChart, - TotalHousingUnitsHistoryChart, -} from '@/components/Reports/housing'; - -export interface DashboardData { - year: number; - primary: { - name: string; - current: DataRow[]; - history: DataRow[]; - }; - comparison: { - name: string; - current: DataRow[]; - history: DataRow[]; - }; - // Additional time-series tables, keyed by the SECTIONS.timeseries config - // key in reports-by-topic/page.tsx (e.g. "medianHomeValue"). - timeseries?: Record; -} - -export interface DashboardProps { - data: DashboardData; -} - -export default function HousingDashboard({ data }: DashboardProps) { - const { primary, comparison, timeseries } = data; + ComparisonBarChart, + HistoryLineChart, + ReportSection, + StatCard, + StatStrip, + takeaway, + useReport, +} from '@/components/Reports/shared'; +export default function HousingDashboard() { + const ctx = useReport(); return ( - - - - - - - - - - - - - - {timeseries?.medianHomeValue && ( - - + + + } + variable="Median Home Value" + format="usd" /> - - )} - {timeseries?.totalUnits && ( - - } + variable="Total Housing Units" + format="int" /> - - )} - + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/frontend/src/components/Reports/dashboards/LandUseDashboard.tsx b/frontend/src/components/Reports/dashboards/LandUseDashboard.tsx index 9ea26825..bf37c3dd 100644 --- a/frontend/src/components/Reports/dashboards/LandUseDashboard.tsx +++ b/frontend/src/components/Reports/dashboards/LandUseDashboard.tsx @@ -1,72 +1,52 @@ import { Grid } from '@mantine/core'; import { DataRow } from '@/types/cachedCharts'; import { - AcreageByDistrictType, - ZoningAllowanceChart, + ComparisonBarChart, + ReportSection, + StatStrip, +} from '@/components/Reports/shared'; +import { WastewaterPermitsTable, + ZoningAllowanceChart, ZoningCoverageStatCard, } from '@/components/Reports/land_use'; -export interface DashboardData { - year: number; - - primary: { - name: string; - current: DataRow[]; - history: DataRow[]; - }; - - comparison: { - name: string; - current: DataRow[]; - history: DataRow[]; - }; +const DISTRICT_TYPES = ['Nonresidential', 'Mixed', 'Overlay', 'Residential']; - // Additional endpoints, keyed by the SECTIONS.timeseries config key in - // reports-by-topic/page.tsx (e.g. "allowances", "wastewaterPermits"). - timeseries?: Record; -} - -export interface DashboardProps { - data: DashboardData; -} - -export default function LandUseDashboard({ data }: DashboardProps) { - const { primary, comparison, timeseries } = data; +const acresOf = (rows: DataRow[], type: string) => + Number(rows.find((d) => d['District Type'] === type)?.Acres ?? 0); +export default function LandUseDashboard() { return ( - - - - - - - - {timeseries?.allowances && ( - - - - )} - {timeseries?.wastewaterPermits && ( - - - - )} - + <> + + + + + + + v.toLocaleString()} + tip={(v) => + `${v.toLocaleString(undefined, { maximumFractionDigits: 1 })} acres` + } + /> + + + + + + + + + + + ); } diff --git a/frontend/src/components/Reports/demographics/SexDistributionChart.tsx b/frontend/src/components/Reports/demographics/SexDistributionChart.tsx index 9b34937c..b75c8db1 100644 --- a/frontend/src/components/Reports/demographics/SexDistributionChart.tsx +++ b/frontend/src/components/Reports/demographics/SexDistributionChart.tsx @@ -1,49 +1,40 @@ -import { Card, Grid, Group, Stack, Text, Title } from '@mantine/core'; +'use client'; + +import { Grid, Group, Stack, Text } from '@mantine/core'; import { PieChart, Pie, ResponsiveContainer, Tooltip } from 'recharts'; import { DataRow } from '@/types/cachedCharts'; +import { + COMPARISON_COLOR, + ChartBlock, + getValue, + useReport, +} from '@/components/Reports/shared'; -interface SexDistributionChartProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} - -function getValue(data: DataRow[], variable: string): number { - const row = data.find((d) => d.Variable === variable); - - return row ? Number(row.Percent) : 0; -} - -function buildSexData(data: DataRow[]) { - return [ +function SexDonut({ + title, + rows, + accent, +}: { + title: string; + rows: DataRow[]; + accent: string; +}) { + const data = [ { name: 'Female', - value: getValue(data, 'Female'), - fill: '#5474B4', + value: getValue(rows, 'Female', 'Percent') ?? 0, + fill: accent, }, { name: 'Male', - value: getValue(data, 'Male'), - fill: '#868e96', + value: getValue(rows, 'Male', 'Percent') ?? 0, + fill: COMPARISON_COLOR, }, ]; -} - -function SexDonut({ - title, - data, -}: { - title: string; - data: { - name: string; - value: number; - }[]; -}) { return ( {title} - + - - Sex Distribution - - + - + - + - + ); } diff --git a/frontend/src/components/Reports/demographics/index.ts b/frontend/src/components/Reports/demographics/index.ts index 8a4652b5..0fe68f4e 100644 --- a/frontend/src/components/Reports/demographics/index.ts +++ b/frontend/src/components/Reports/demographics/index.ts @@ -1,7 +1 @@ -export { default as PopulationCard } from './PopulationCard'; -export { default as MedianAgeCard } from './MedianAgeCard'; -export { default as RaceDistributionChart } from './RaceDistributionChart'; -export { default as AgeDistributionChart } from './AgeDistributionChart'; export { default as SexDistributionChart } from './SexDistributionChart'; -export { default as PopulationHistoryChart } from './PopulationHistoryChart'; -export { default as MedianAgeHistoryChart } from './MedianAgeHistoryChart'; diff --git a/frontend/src/components/Reports/health/IndicatorTable.tsx b/frontend/src/components/Reports/health/IndicatorTable.tsx index 7f8b406e..67a84200 100644 --- a/frontend/src/components/Reports/health/IndicatorTable.tsx +++ b/frontend/src/components/Reports/health/IndicatorTable.tsx @@ -4,7 +4,6 @@ import { useState } from 'react'; import { Badge, Box, - Card, Group, SegmentedControl, Stack, @@ -14,6 +13,10 @@ import { Title, } from '@mantine/core'; import { COLORS } from '@/app/theme'; +import { + COMPARISON_COLOR, + TOPIC_ACCENTS, +} from '@/components/Reports/shared/colors'; // Presentational only: knows nothing about geography levels or the API. // Callers decide which estimates each place gets (e.g. a town shown with its @@ -60,8 +63,8 @@ interface IndicatorTableProps { } // Same pair as the report's other comparison charts. -export const PRIMARY_COLOR = '#5474B4'; -export const COMPARISON_COLOR = '#868e96'; +export const PRIMARY_COLOR = TOPIC_ACCENTS['Community Health']; +export { COMPARISON_COLOR }; export type Verdict = 'different' | 'similar' | 'unknown'; @@ -358,12 +361,7 @@ export default function IndicatorTable({ ); return ( - + {title} @@ -433,7 +431,7 @@ export default function IndicatorTable({ {footnote} )} - + ); } diff --git a/frontend/src/components/Reports/health/KeyIndicatorTiles.tsx b/frontend/src/components/Reports/health/KeyIndicatorTiles.tsx index b936d321..88c30235 100644 --- a/frontend/src/components/Reports/health/KeyIndicatorTiles.tsx +++ b/frontend/src/components/Reports/health/KeyIndicatorTiles.tsx @@ -1,12 +1,4 @@ -import { - Box, - Card, - Group, - SimpleGrid, - Text, - ThemeIcon, - Title, -} from '@mantine/core'; +import { Box, Group, SimpleGrid, Text, ThemeIcon, Title } from '@mantine/core'; import type { Icon } from '@tabler/icons-react'; import { COLORS } from '@/app/theme'; import { DataRow } from '@/types/cachedCharts'; @@ -103,11 +95,14 @@ function Tile({ ]; return ( - @@ -157,7 +152,7 @@ function Tile({ /> )} - + ); } diff --git a/frontend/src/components/Reports/health/PovertyUninsuredTrends.tsx b/frontend/src/components/Reports/health/PovertyUninsuredTrends.tsx index 25032c00..ee9b8a6e 100644 --- a/frontend/src/components/Reports/health/PovertyUninsuredTrends.tsx +++ b/frontend/src/components/Reports/health/PovertyUninsuredTrends.tsx @@ -1,4 +1,4 @@ -import { Card, SimpleGrid, Stack, Text, Title } from '@mantine/core'; +import { SimpleGrid, Stack, Text } from '@mantine/core'; import { CartesianGrid, Legend, @@ -10,6 +10,11 @@ import { YAxis, } from 'recharts'; import { DataRow } from '@/types/cachedCharts'; +import { + COMPARISON_COLOR, + ChartBlock, + useReport, +} from '@/components/Reports/shared'; // Census ACS poverty and uninsured rates over time for both places, from // /load/acs5-db/timeseries/economics/poverty-uninsured (rows: year, @@ -23,13 +28,6 @@ const SERIES = [ { variable: 'No health insurance', title: 'People without health insurance' }, ]; -interface PovertyUninsuredTrendsProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} - function placeName(rows: DataRow[], fallback: string) { const name = rows.find((r) => r.Location)?.Location; return name ? String(name) : fallback; @@ -58,12 +56,12 @@ function seriesFor( })); } -export default function PovertyUninsuredTrends({ - primary, - comparison, - primaryName, - comparisonName, -}: PovertyUninsuredTrendsProps) { +export default function PovertyUninsuredTrends() { + const { timeseries, primary: p, comparison: c, accent } = useReport(); + const primary = timeseries?.povertyUninsured?.primary ?? []; + const comparison = timeseries?.povertyUninsured?.comparison ?? []; + const primaryName = p.name; + const comparisonName = c.name; const first = placeName(primary, primaryName); const second = placeName(comparison, comparisonName); const charts = SERIES.map((s) => ({ @@ -73,15 +71,10 @@ export default function PovertyUninsuredTrends({ if (charts.length === 0) return null; return ( - - - Change Over Time - - Census ACS 5-year estimates. The CDC health estimates on this page are - a single snapshot; these show how poverty and insurance coverage - changed around them. - - + {charts.map((chart) => ( @@ -110,7 +103,7 @@ export default function PovertyUninsuredTrends({ type="monotone" dataKey="primary" name={first} - stroke="#5474B4" + stroke={accent} strokeWidth={2} dot={false} connectNulls={false} @@ -119,7 +112,7 @@ export default function PovertyUninsuredTrends({ type="monotone" dataKey="comparison" name={second} - stroke="#868e96" + stroke={COMPARISON_COLOR} strokeWidth={2} dot={false} connectNulls={false} @@ -129,6 +122,6 @@ export default function PovertyUninsuredTrends({ ))} - + ); } diff --git a/frontend/src/components/Reports/land_use/WastewaterPermitsTable.tsx b/frontend/src/components/Reports/land_use/WastewaterPermitsTable.tsx index 098d4540..24f17aee 100644 --- a/frontend/src/components/Reports/land_use/WastewaterPermitsTable.tsx +++ b/frontend/src/components/Reports/land_use/WastewaterPermitsTable.tsx @@ -1,12 +1,6 @@ -import { Anchor, Card, ScrollArea, Table, Text, Title } from '@mantine/core'; +import { Anchor, ScrollArea, Table, Text } from '@mantine/core'; import { DataRow } from '@/types/cachedCharts'; - -interface WastewaterPermitsTableProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} +import { ChartBlock, useReport } from '@/components/Reports/shared'; function PermitRows({ rows }: { rows: DataRow[] }) { if (rows.length === 0) { @@ -59,38 +53,27 @@ function PermitRows({ rows }: { rows: DataRow[] }) { ); } -export default function WastewaterPermitsTable({ - primary, - comparison, - primaryName, - comparisonName, -}: WastewaterPermitsTableProps) { +export default function WastewaterPermitsTable() { + const { timeseries, primary: p, comparison: c } = useReport(); + const primary = timeseries?.wastewaterPermits?.primary ?? []; + const comparison = timeseries?.wastewaterPermits?.comparison ?? []; const hasComparison = comparison.length > 0; return ( - - - Wastewater Treatment Facility Permits - - - - {primaryName} + + + {p.name} {hasComparison && ( <> - {comparisonName} + {c.name} )} - + ); } diff --git a/frontend/src/components/Reports/land_use/ZoningAllowanceChart.tsx b/frontend/src/components/Reports/land_use/ZoningAllowanceChart.tsx index ee67af84..1f581f2c 100644 --- a/frontend/src/components/Reports/land_use/ZoningAllowanceChart.tsx +++ b/frontend/src/components/Reports/land_use/ZoningAllowanceChart.tsx @@ -1,4 +1,4 @@ -import { Card, Text, Title } from '@mantine/core'; +import { ChartBlock, useReport } from '@/components/Reports/shared'; import { BarChart, Bar, @@ -11,13 +11,6 @@ import { } from 'recharts'; import { DataRow } from '@/types/cachedCharts'; -interface ZoningAllowanceChartProps { - primary: DataRow[]; - comparison: DataRow[]; - primaryName: string; - comparisonName: string; -} - // Mirrors the categorization in components/Charts/Bar.tsx's // ZoningAllowanceStackedBarChart (the working-report version of this chart), // so the two reports agree on labels and colors. @@ -77,33 +70,26 @@ function buildData(primary: DataRow[], comparison: DataRow[]) { }); } -export default function ZoningAllowanceChart({ - primary, - comparison, - primaryName, - comparisonName, -}: ZoningAllowanceChartProps) { +export default function ZoningAllowanceChart() { + const { timeseries, primary: p, comparison: c } = useReport(); + const primary = timeseries?.allowances?.primary ?? []; + const comparison = timeseries?.allowances?.comparison ?? []; + const primaryName = p.name; + const comparisonName = c.name; const data = buildData(primary, comparison); const hasComparison = comparison.length > 0; if (data.length === 0) return null; return ( - - - Residential Zoning Allowance by Unit Type - - {hasComparison && ( - - Solid = {primaryName} · Lighter = {comparisonName} - - )} - - + ); } diff --git a/frontend/src/components/Reports/land_use/ZoningCoverageStatCard.tsx b/frontend/src/components/Reports/land_use/ZoningCoverageStatCard.tsx index 6aa0a1bd..c36401ff 100644 --- a/frontend/src/components/Reports/land_use/ZoningCoverageStatCard.tsx +++ b/frontend/src/components/Reports/land_use/ZoningCoverageStatCard.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react'; import axios from 'axios'; -import { Card, Group, Stack, Text, ThemeIcon, Title } from '@mantine/core'; +import { Text } from '@mantine/core'; import { IconMap2 } from '@tabler/icons-react'; import { BASE_API_URL } from '@/config'; import { DataRow } from '@/types/cachedCharts'; +import { StatCell } from '@/components/Reports/shared'; // Statewide dataset coverage, independent of the primary/comparison location // selection — how many zoning districts and towns this dataset covers, not a @@ -38,43 +39,15 @@ export default function ZoningCoverageStatCard() { : null; return ( - } + value={districtCount !== null ? districtCount.toLocaleString() : '—'} > - - - Statewide Zoning Coverage - - - - - - - - - - {districtCount !== null ? districtCount.toLocaleString() : '—'} - - - Zoning Districts - - - - - {townCount !== null ? townCount.toLocaleString() : '—'} - - - Towns with Zoning Data - - - - + + Zoning districts across{' '} + {townCount !== null ? townCount.toLocaleString() : '—'} towns + + ); } diff --git a/frontend/src/components/Reports/land_use/index.ts b/frontend/src/components/Reports/land_use/index.ts index cbc7bf0e..eccc8459 100644 --- a/frontend/src/components/Reports/land_use/index.ts +++ b/frontend/src/components/Reports/land_use/index.ts @@ -1,4 +1,3 @@ -export { default as AcreageByDistrictType } from './AcreageByDistrictType'; export { default as ZoningAllowanceChart } from './ZoningAllowanceChart'; export { default as WastewaterPermitsTable } from './WastewaterPermitsTable'; export { default as ZoningCoverageStatCard } from './ZoningCoverageStatCard'; diff --git a/frontend/src/components/Reports/shared/ComparisonBarChart.tsx b/frontend/src/components/Reports/shared/ComparisonBarChart.tsx new file mode 100644 index 00000000..ca95a181 --- /dev/null +++ b/frontend/src/components/Reports/shared/ComparisonBarChart.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { DataRow } from '@/types/cachedCharts'; +import { COMPARISON_COLOR } from './colors'; +import { getValue } from './data'; +import { useReport } from './ReportContext'; +import { ChartBlock } from './Section'; + +type Category = string | { key: string; label: string }; + +/** + * Primary vs benchmark bars over a fixed list of categories, read from the + * current-year rows. Categories are looked up by `Variable` and `field` + * unless `readValue` says otherwise. `horizontal` lays bars out left-to-right + * with the labels down the side. + */ +export default function ComparisonBarChart({ + title, + categories, + field = 'Percent', + readValue, + horizontal = false, + domain, + height = 340, + labelWidth = 150, + tick = (v) => `${v}%`, + tip = (v) => `${v.toFixed(1)}%`, +}: { + title: string; + categories: Category[]; + field?: string; + readValue?: (rows: DataRow[], key: string) => number; + horizontal?: boolean; + domain?: [number, number]; + height?: number; + labelWidth?: number; + tick?: (v: number) => string; + tip?: (v: number) => string; +}) { + const { primary, comparison, accent } = useReport(); + const read = + readValue ?? + ((rows: DataRow[], key: string) => getValue(rows, key, field) ?? 0); + const data = categories.map((c) => { + const { key, label } = typeof c === 'string' ? { key: c, label: c } : c; + return { + label, + [primary.name]: read(primary.current, key), + [comparison.name]: read(comparison.current, key), + }; + }); + const radius: [number, number, number, number] = horizontal + ? [0, 4, 4, 0] + : [4, 4, 0, 0]; + + return ( + + + + + {horizontal ? ( + <> + tick(Number(v))} + tick={{ fontSize: 12 }} + /> + + + ) : ( + <> + + tick(Number(v))} + tick={{ fontSize: 12 }} + /> + + )} + tip(Number(v))} /> + + + + + + + ); +} diff --git a/frontend/src/components/Reports/shared/HistoryLineChart.tsx b/frontend/src/components/Reports/shared/HistoryLineChart.tsx new file mode 100644 index 00000000..1e1a25b9 --- /dev/null +++ b/frontend/src/components/Reports/shared/HistoryLineChart.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { COMPARISON_COLOR } from './colors'; +import { FORMATS, type FormatKey } from './data'; +import { useReport } from './ReportContext'; +import { ChartBlock } from './Section'; + +/** + * Primary vs benchmark over time, from the `timeseries[source]` table the + * topic's SECTIONS config fetched. `field` is the column holding the value. + */ +export default function HistoryLineChart({ + source, + field, + title, + format, +}: { + source: string; + field: string; + title: string; + format: FormatKey; +}) { + const { timeseries, primary, comparison, accent } = useReport(); + const ts = timeseries?.[source]; + if (!ts) return null; + + const years = Array.from( + new Set( + [...ts.primary, ...ts.comparison] + .map((r) => Number(r.year)) + .filter((y) => Number.isFinite(y)), + ), + ).sort((a, b) => a - b); + if (years.length === 0) return null; + + const at = (rows: typeof ts.primary, year: number) => + rows.find((r) => Number(r.year) === year)?.[field] ?? null; + const data = years.map((year) => ({ + year, + [primary.name]: at(ts.primary, year), + [comparison.name]: at(ts.comparison, year), + })); + const f = FORMATS[format]; + + return ( + + + + + + f.tick(Number(v))} + tick={{ fontSize: 12 }} + /> + (v != null ? f.value(Number(v)) : '—')} + /> + + + + + + + ); +} diff --git a/frontend/src/components/Reports/shared/ReportContext.tsx b/frontend/src/components/Reports/shared/ReportContext.tsx new file mode 100644 index 00000000..fa8152c1 --- /dev/null +++ b/frontend/src/components/Reports/shared/ReportContext.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { createContext, useContext, type ReactNode } from 'react'; +import { DataRow } from '@/types/cachedCharts'; +import type { Location } from '@/components/profile/profileStore'; +import { DEFAULT_ACCENT, TOPIC_ACCENTS } from './colors'; + +interface Place { + name: string; + location?: Location; + current: DataRow[]; + history: DataRow[]; +} + +export interface ReportData { + year: number; + primary: Place; + comparison: Place; + // Extra time-series tables, keyed by the SECTIONS.timeseries config key. + timeseries?: Record; + // Section-wide endpoints, keyed by the SECTIONS.allAreas config key. + allAreas?: Record; +} + +export interface ReportContextValue extends ReportData { + topic: string; + accent: string; +} + +const ReportContext = createContext(null); + +export function ReportProvider({ + topic, + data, + children, +}: { + topic: string; + data: ReportData; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useReport(): ReportContextValue { + const ctx = useContext(ReportContext); + if (!ctx) throw new Error('useReport must be used inside '); + return ctx; +} diff --git a/frontend/src/components/Reports/shared/Section.tsx b/frontend/src/components/Reports/shared/Section.tsx new file mode 100644 index 00000000..ef8199dc --- /dev/null +++ b/frontend/src/components/Reports/shared/Section.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { Children, type ReactNode } from 'react'; +import { Box, Group, Stack, Text, Title } from '@mantine/core'; +import { COLORS } from '@/app/theme'; +import { useReport } from './ReportContext'; + +/** + * One open section of a report: eyebrow, title, optional one-line takeaway, + * then its content directly on the page. Sections are separated by a hairline + * rather than boxed, and each one is a PDF export block. + */ +export function ReportSection({ + eyebrow, + title, + takeaway, + children, +}: { + eyebrow: string; + title: string; + takeaway?: string; + children: ReactNode; +}) { + const { accent } = useReport(); + return ( + + + + + + {eyebrow} + + + {title} + {takeaway && ( + + {takeaway} + + )} + + {children} + + ); +} + +/** A chart's caption plus the chart, with no surrounding box. */ +export function ChartBlock({ + title, + note, + children, +}: { + title: string; + note?: string; + children: ReactNode; +}) { + return ( + + + {title} + + {note && ( + + {note} + + )} + {children} + + ); +} + +const COLS: Record = { + 1: 'md:grid-cols-1', + 2: 'md:grid-cols-2', + 3: 'md:grid-cols-3', + 4: 'md:grid-cols-4', +}; + +/** Headline numbers in one row, divided by hairlines instead of boxed. */ +export function StatStrip({ children }: { children: ReactNode }) { + const cells = Children.toArray(children); + return ( +
+ {cells.map((cell, i) => ( +
+ {cell} +
+ ))} +
+ ); +} + +/** A label, a big value, and optional supporting lines. */ +export function StatCell({ + label, + value, + icon, + children, +}: { + label: string; + value: string; + icon?: ReactNode; + children?: ReactNode; +}) { + const { accent } = useReport(); + return ( + + + {icon} + + {label} + + + + {value} + + {children} + + ); +} diff --git a/frontend/src/components/Reports/shared/StatCard.tsx b/frontend/src/components/Reports/shared/StatCard.tsx new file mode 100644 index 00000000..b36578a0 --- /dev/null +++ b/frontend/src/components/Reports/shared/StatCard.tsx @@ -0,0 +1,56 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { Group, Text } from '@mantine/core'; +import { COMPARISON_COLOR } from './colors'; +import { FORMATS, compare, type FormatKey } from './data'; +import { useReport } from './ReportContext'; +import { StatCell } from './Section'; + +const NONE = '—'; + +/** + * A primary-vs-benchmark headline number: the primary value large, with the + * benchmark's value and the difference beneath. Reads the report context, so + * a new stat is just a variable name and a format. + */ +export default function StatCard({ + label, + icon, + variable, + format, + field = 'Value', + percentDiff = false, +}: { + label: string; + icon: ReactNode; + variable: string; + format: FormatKey; + field?: string; + percentDiff?: boolean; +}) { + const ctx = useReport(); + const { p, c, diff } = compare(ctx, variable, field); + const f = FORMATS[format]; + const pct = percentDiff && diff !== null && c ? (diff / c) * 100 : null; + + return ( + + + + {ctx.comparison.name}: {c !== null ? f.value(c) : NONE} + + + {diff !== null && ( + 0 ? 'green.8' : diff < 0 ? 'red.8' : 'dimmed'} + > + {f.diff(diff)} + {pct !== null && ` (${pct > 0 ? '+' : ''}${pct.toFixed(1)}%)`} + + )} + + ); +} diff --git a/frontend/src/components/Reports/shared/colors.ts b/frontend/src/components/Reports/shared/colors.ts new file mode 100644 index 00000000..dc3ac6e6 --- /dev/null +++ b/frontend/src/components/Reports/shared/colors.ts @@ -0,0 +1,13 @@ +// Neutral series for the benchmark place on every chart; the primary place +// takes its topic's accent so a topic reads as one color at a glance. +export const COMPARISON_COLOR = '#868e96'; + +export const TOPIC_ACCENTS: Record = { + Demographics: '#2F6F9F', + Housing: '#B4532A', + 'Labor & Economy': '#2E7D5B', + 'Land Use': '#7B5EA7', + 'Community Health': '#B83A5E', +}; + +export const DEFAULT_ACCENT = '#2F6F9F'; diff --git a/frontend/src/components/Reports/shared/data.ts b/frontend/src/components/Reports/shared/data.ts new file mode 100644 index 00000000..48b72399 --- /dev/null +++ b/frontend/src/components/Reports/shared/data.ts @@ -0,0 +1,71 @@ +import { DataRow } from '@/types/cachedCharts'; +import type { ReportContextValue } from './ReportContext'; + +type Fmt = (n: number) => string; + +// One place for how each kind of number reads: `value` for the headline and +// tooltips, `diff` for signed differences, `tick` for compact axis labels. +export const FORMATS = { + int: { + value: ((n) => n.toLocaleString()) as Fmt, + diff: ((n) => `${n > 0 ? '+' : ''}${n.toLocaleString()}`) as Fmt, + tick: ((n) => n.toLocaleString()) as Fmt, + }, + usd: { + value: ((n) => + `$${n.toLocaleString('en-US', { maximumFractionDigits: 0 })}`) as Fmt, + diff: ((n) => + `${n > 0 ? '+' : n < 0 ? '-' : ''}$${Math.abs(n).toLocaleString('en-US', { maximumFractionDigits: 0 })}`) as Fmt, + tick: ((n) => `$${(n / 1000).toFixed(0)}k`) as Fmt, + }, + pct: { + value: ((n) => `${n.toFixed(1)}%`) as Fmt, + diff: ((n) => `${n > 0 ? '+' : ''}${n.toFixed(1)} pts`) as Fmt, + tick: ((n) => `${n}%`) as Fmt, + }, + years: { + value: ((n) => `${n.toFixed(1)} years`) as Fmt, + diff: ((n) => `${n > 0 ? '+' : ''}${n.toFixed(1)} yrs`) as Fmt, + tick: ((n) => n.toFixed(0)) as Fmt, + }, +} as const; + +export type FormatKey = keyof typeof FORMATS; + +/** First row for `variable`, read from `field`; null if missing/non-numeric. */ +export function getValue( + rows: DataRow[], + variable: string, + field = 'Value', +): number | null { + const row = rows.find((d) => d.Variable === variable); + if (!row) return null; + const n = Number(row[field]); + return Number.isFinite(n) ? n : null; +} + +export function compare( + ctx: ReportContextValue, + variable: string, + field = 'Value', +) { + const p = getValue(ctx.primary.current, variable, field); + const c = getValue(ctx.comparison.current, variable, field); + return { p, c, diff: p !== null && c !== null ? p - c : null }; +} + +/** "Burlington has a higher median age than Vermont (+4.2 yrs)", or undefined. */ +export function takeaway( + ctx: ReportContextValue, + variable: string, + noun: string, + format: FormatKey, + field = 'Value', +): string | undefined { + const { diff } = compare(ctx, variable, field); + if (diff === null) return undefined; + const a = ctx.primary.name; + const b = ctx.comparison.name; + if (diff === 0) return `${a} and ${b} have the same ${noun}.`; + return `${a} has a ${diff > 0 ? 'higher' : 'lower'} ${noun} than ${b} (${FORMATS[format].diff(diff)}).`; +} diff --git a/frontend/src/components/Reports/shared/index.ts b/frontend/src/components/Reports/shared/index.ts new file mode 100644 index 00000000..1d90fa7c --- /dev/null +++ b/frontend/src/components/Reports/shared/index.ts @@ -0,0 +1,8 @@ +export { ReportProvider, useReport } from './ReportContext'; +export type { ReportData } from './ReportContext'; +export { ReportSection, ChartBlock, StatStrip, StatCell } from './Section'; +export { default as StatCard } from './StatCard'; +export { default as HistoryLineChart } from './HistoryLineChart'; +export { default as ComparisonBarChart } from './ComparisonBarChart'; +export { COMPARISON_COLOR, TOPIC_ACCENTS } from './colors'; +export { FORMATS, getValue, compare, takeaway } from './data'; From 9e0b5947486fd7fda2653165d6fbab599077766c Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Tue, 6 Oct 2026 13:01:04 -0400 Subject: [PATCH 3/7] Improved StatCard readability + condensed report header --- .../reports-by-topic/ReportsByTopic.tsx | 174 +++++++----------- .../reports-by-topic/TopicLanding.tsx | 2 +- .../dashboards/DemographicDashboard.tsx | 1 + .../Reports/dashboards/EconomicDashboard.tsx | 1 + .../Reports/dashboards/EducationDashboard.tsx | 2 +- .../Reports/dashboards/HealthDashboard.tsx | 2 +- .../Reports/dashboards/HousingDashboard.tsx | 1 + .../Reports/dashboards/LandUseDashboard.tsx | 2 +- .../Reports/shared/ComparisonBarChart.tsx | 13 +- .../Reports/shared/HistoryLineChart.tsx | 25 ++- .../src/components/Reports/shared/Section.tsx | 61 +++++- .../components/Reports/shared/StatCard.tsx | 77 ++++++-- 12 files changed, 222 insertions(+), 139 deletions(-) diff --git a/frontend/src/app/data-comparison/reports-by-topic/ReportsByTopic.tsx b/frontend/src/app/data-comparison/reports-by-topic/ReportsByTopic.tsx index fbe7fb78..08eaea8e 100644 --- a/frontend/src/app/data-comparison/reports-by-topic/ReportsByTopic.tsx +++ b/frontend/src/app/data-comparison/reports-by-topic/ReportsByTopic.tsx @@ -6,7 +6,6 @@ import axios from 'axios'; import { Alert, Anchor, - Badge, Box, Button, Container, @@ -15,7 +14,6 @@ import { Paper, Stack, Text, - Title, } from '@mantine/core'; import { useProfile, Location } from '@/components/profile/profileStore'; import { BASE_API_URL } from '@/config'; @@ -36,11 +34,7 @@ import { IconInfoCircle, } from '@tabler/icons-react'; import { COLORS } from '@/app/theme'; -import { - COMPARISON_COLOR, - ReportProvider, - TOPIC_ACCENTS, -} from '@/components/Reports/shared'; +import { ReportProvider } from '@/components/Reports/shared'; import type { ReportData } from '@/components/Reports/shared'; import { exportReport } from '@/utils/exportReport'; import { DashboardSection, TOPIC_SLUGS, topicPath } from './topics'; @@ -318,19 +312,16 @@ function buildLocationFilters( return {}; } -// The topic switcher and the places being compared, stuck under the site -// header so both stay in view while the report scrolls. +// One slim bar stuck under the site header: back to the topic chooser, the +// topic tabs, and export. The places and year live in the report's intro. function ReportHeader({ section, data, - showYear, }: { section: DashboardSection; data: ReportData; - showYear: boolean; }) { const [exporting, setExporting] = useState(false); - const accent = TOPIC_ACCENTS[section]; const handleExport = async () => { setExporting(true); @@ -348,23 +339,6 @@ function ReportHeader({ } }; - const place = (color: string, label: string, name: string) => ( - - - - {label} - - - {name} - - - ); - return ( - - - + + + + + Reports by Topic + + + {/* Plain links, so each topic stays its own static route. */} + + {(Object.keys(TOPIC_SLUGS) as DashboardSection[]).map((topic) => { + const active = topic === section; + return ( + + {topic} + + ); + })} + + + - - - - - - {place(accent, 'Primary', data.primary.name)} - {place(COMPARISON_COLOR, 'Benchmark', data.comparison.name)} - {showYear && ( - - ACS {data.year} - - )} - - - - - ); } @@ -700,11 +658,7 @@ export default function ReportsByTopic({ // --------------------------------------------------------------------------- return ( <> - 0 && cfg.hasYearDimension !== false} - /> + {error && {error}} {areaNotes.length > 0 && ( diff --git a/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.tsx b/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.tsx index d815f004..411ffaf5 100644 --- a/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.tsx +++ b/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.tsx @@ -104,7 +104,7 @@ export default function TopicLanding() { style={{ alignSelf: 'flex-start' }} px={0} > - Edit your places + Change comparison
diff --git a/frontend/src/components/Reports/dashboards/DemographicDashboard.tsx b/frontend/src/components/Reports/dashboards/DemographicDashboard.tsx index 3ceddfa4..1df43af0 100644 --- a/frontend/src/components/Reports/dashboards/DemographicDashboard.tsx +++ b/frontend/src/components/Reports/dashboards/DemographicDashboard.tsx @@ -38,6 +38,7 @@ export default function DemographicsDashboard() { return ( <> + - + export default function LandUseDashboard() { return ( <> - + diff --git a/frontend/src/components/Reports/shared/ComparisonBarChart.tsx b/frontend/src/components/Reports/shared/ComparisonBarChart.tsx index ca95a181..abfbdfea 100644 --- a/frontend/src/components/Reports/shared/ComparisonBarChart.tsx +++ b/frontend/src/components/Reports/shared/ComparisonBarChart.tsx @@ -59,12 +59,23 @@ export default function ComparisonBarChart({ [comparison.name]: read(comparison.current, key), }; }); + const gap = data + .map((d) => ({ + label: String(d.label), + p: Number(d[primary.name]), + c: Number(d[comparison.name]), + })) + .sort((a, b) => Math.abs(b.p - b.c) - Math.abs(a.p - a.c))[0]; + const insight = + gap && gap.p !== gap.c + ? `Biggest gap: ${gap.label}, ${tip(gap.p)} in ${primary.name.replace(/, Vermont$/, '')} vs. ${tip(gap.c)} in ${comparison.name.replace(/, Vermont$/, '')}.` + : undefined; const radius: [number, number, number, number] = horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]; return ( - + { + const pts = data.filter((d) => Number(d[key]) > 0); + if (pts.length < 2) return null; + const first = pts[0]; + const last = pts[pts.length - 1]; + return { + from: first.year, + pct: + ((Number(last[key]) - Number(first[key])) / Number(first[key])) * 100, + }; + }; + const pc = change(primary.name); + const cc = change(comparison.name); + const verb = (n: number) => (n >= 0 ? 'grew' : 'fell'); + const short = (n: string) => n.replace(/, Vermont$/, ''); + const insight = pc + ? `${short(primary.name)} ${verb(pc.pct)} ${Math.abs(pc.pct).toFixed(0)}% since ${pc.from}` + + (cc + ? ` (${short(comparison.name)}: ${cc.pct >= 0 ? '+' : '-'}${Math.abs(cc.pct).toFixed(0)}%).` + : '.') + : undefined; + return ( - + ( + + + + + {name.replace(/, Vermont$/, '')} + + + {role} + + + + ); + return ( - - - - {eyebrow} - + + + + + {intro ? topic : eyebrow} + + + {intro && year > 0 && ( + + ACS {year} + + )} {title} {takeaway && ( @@ -41,6 +72,12 @@ export function ReportSection({ {takeaway} )} + {intro && ( + + {place(accent, primary.name, 'Primary')} + {place(COMPARISON_COLOR, comparison.name, 'Benchmark')} + + )} {children} @@ -51,10 +88,13 @@ export function ReportSection({ export function ChartBlock({ title, note, + insight, children, }: { title: string; note?: string; + /** One interpretive sentence between the caption and the chart. */ + insight?: string; children: ReactNode; }) { return ( @@ -67,6 +107,11 @@ export function ChartBlock({ {note} )} + {insight && ( + + {insight} + + )} {children} ); diff --git a/frontend/src/components/Reports/shared/StatCard.tsx b/frontend/src/components/Reports/shared/StatCard.tsx index b36578a0..1a9bdba6 100644 --- a/frontend/src/components/Reports/shared/StatCard.tsx +++ b/frontend/src/components/Reports/shared/StatCard.tsx @@ -1,7 +1,8 @@ 'use client'; import type { ReactNode } from 'react'; -import { Group, Text } from '@mantine/core'; +import { Box, Stack, Text } from '@mantine/core'; +import { COLORS } from '@/app/theme'; import { COMPARISON_COLOR } from './colors'; import { FORMATS, compare, type FormatKey } from './data'; import { useReport } from './ReportContext'; @@ -9,10 +10,42 @@ import { StatCell } from './Section'; const NONE = '—'; +function Bar({ + name, + value, + max, + color, + text, +}: { + name: string; + value: number; + max: number; + color: string; + text: string; +}) { + return ( + + + {name.replace(/, Vermont$/, '')} · {text} + + + 0 ? (Math.abs(value) / max) * 100 : 0}%`, + borderRadius: 4, + }} + /> + + + ); +} + /** - * A primary-vs-benchmark headline number: the primary value large, with the - * benchmark's value and the difference beneath. Reads the report context, so - * a new stat is just a variable name and a format. + * A primary-vs-benchmark headline: the primary value large, the gap to the + * benchmark in words, then both values as bars on one scale. Reads the report + * context, so a new stat is just a variable name and a format. */ export default function StatCard({ label, @@ -33,24 +66,38 @@ export default function StatCard({ const { p, c, diff } = compare(ctx, variable, field); const f = FORMATS[format]; const pct = percentDiff && diff !== null && c ? (diff / c) * 100 : null; + const max = Math.max(Math.abs(p ?? 0), Math.abs(c ?? 0)); return ( - - - {ctx.comparison.name}: {c !== null ? f.value(c) : NONE} - - {diff !== null && ( - 0 ? 'green.8' : diff < 0 ? 'red.8' : 'dimmed'} - > + {f.diff(diff)} - {pct !== null && ` (${pct > 0 ? '+' : ''}${pct.toFixed(1)}%)`} + {pct !== null && + ` (${pct > 0 ? '+' : ''}${pct.toFixed(1)}%)`} vs.{' '} + {ctx.comparison.name.replace(/, Vermont$/, '')} )} + + {p !== null && ( + + )} + {c !== null && ( + + )} + ); } From 262c006319930dec02c856438545e0ab752b15f3 Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Tue, 6 Oct 2026 13:03:24 -0400 Subject: [PATCH 4/7] Condensed landing page --- .../reports-by-topic/TopicLanding.tsx | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.tsx b/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.tsx index 411ffaf5..14c364c1 100644 --- a/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.tsx +++ b/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.tsx @@ -16,6 +16,7 @@ import { IconHeartbeat, IconHome2, IconMap2, + IconArrowRight, IconPencil, IconUsers, type Icon, @@ -64,49 +65,50 @@ export default function TopicLanding() { return ( - - - + <Container size="xl" pt={32} pb={48}> + <Stack gap={6} mb="lg"> + <Text size="xs" fw={700} tt="uppercase" lts={1.2} c={COLORS.slate}> Reports by Topic + </Text> + <Title order={1} c={COLORS.spruce} fz={40}> + Understand your community in context - Pick a topic to see how your place compares with a benchmark. + Explore demographics, housing, labor, land use, and community + health. - - {[ - [COLORS.spruce, 'Your place', myLocation.name], - [COMPARISON_COLOR, 'Compared with', comparison.name], - ].map(([color, label, name]) => ( - + + + + {[ + [COLORS.spruce, myLocation.name], + [COMPARISON_COLOR, comparison.name], + ].map(([color, name], i) => ( + + {i === 1 && } + - - - {label} - - - {name} - - + + {name} + - ))} - + + ))} - + {(Object.keys(TOPIC_SLUGS) as DashboardSection[]).map((topic) => { From 0ed8052e02603ec9187a4cc2878ff03ab26abc2d Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Tue, 6 Oct 2026 13:17:18 -0400 Subject: [PATCH 5/7] Landing page color pallete --- .../reports-by-topic/ReportsByTopic.tsx | 302 +---------------- .../reports-by-topic/TopicLanding.module.css | 57 ++++ .../reports-by-topic/TopicLanding.tsx | 251 ++++++++++++-- .../reports-by-topic/reportData.ts | 311 ++++++++++++++++++ .../src/components/Reports/shared/colors.ts | 12 +- 5 files changed, 600 insertions(+), 333 deletions(-) create mode 100644 frontend/src/app/data-comparison/reports-by-topic/TopicLanding.module.css create mode 100644 frontend/src/app/data-comparison/reports-by-topic/reportData.ts diff --git a/frontend/src/app/data-comparison/reports-by-topic/ReportsByTopic.tsx b/frontend/src/app/data-comparison/reports-by-topic/ReportsByTopic.tsx index 08eaea8e..1e3f85a3 100644 --- a/frontend/src/app/data-comparison/reports-by-topic/ReportsByTopic.tsx +++ b/frontend/src/app/data-comparison/reports-by-topic/ReportsByTopic.tsx @@ -16,7 +16,6 @@ import { Text, } from '@mantine/core'; import { useProfile, Location } from '@/components/profile/profileStore'; -import { BASE_API_URL } from '@/config'; import { DemographicsDashboard, LandUseDashboard, @@ -37,281 +36,16 @@ import { COLORS } from '@/app/theme'; import { ReportProvider } from '@/components/Reports/shared'; import type { ReportData } from '@/components/Reports/shared'; import { exportReport } from '@/utils/exportReport'; +import { + SECTIONS, + TimeseriesEndpoint, + dataLocation, + geographyNotes, + postSection, + resolveLocation, +} from './reportData'; import { DashboardSection, TOPIC_SLUGS, topicPath } from './topics'; -// --------------------------------------------------------------------------- -// Section config — url and which field to use as the bar value -// --------------------------------------------------------------------------- - -type YField = 'Percent' | 'Value'; - -// Additional named endpoints a topic can pull in alongside its primary `url` -// — e.g. dedicated time-series tables for trend charts. Fetched for both the -// primary and comparison locations and handed to the topic's Dashboard -// component as `data.timeseries[key]`. -interface TimeseriesEndpoint { - url: string; - // Filter by the profile place's ACS name (the Location filter) instead of - // the section's own location filter -- for ACS context on a county-only - // section, since ACS publishes the town itself. - byPlaceName?: boolean; -} - -interface SectionConfig { - url: string; - yField: YField; - unit: string; - yearMin: number; - yearMax: number; - timeseries?: Record; - // Endpoints fetched once per section with no location filter, for data on - // every area (e.g. each county's values, to rank one among the rest). - // Handed to the topic's Dashboard component as `data.allAreas[key]`. - allAreas?: Record; - // Filter label sent as the location key (defaults to "Location", which - // matches the ACS5 routes' NAME column). Zoning/wastewater tables have no - // "Location" column in their filter schema — they use "Jurisdiction" - // (the `town` column) instead, so requests sent with the default key - // silently return unfiltered, statewide results. - locationFilterKey?: string; - // False for datasets with no `year` column (e.g. zoning is a current- - // snapshot dataset) — skips the year slice below, which would otherwise - // filter every row out since `String(undefined) !== yearStr`. Defaults to - // true. - hasYearDimension?: boolean; - // True for datasets only published at county grain (e.g. CDC PLACES has - // no town-level rows) — always filters by location.county, even for a - // town-type selection, instead of trying (and failing) to filter by town. - countyOnly?: boolean; - // Source name used in the notes shown when the report can't use the - // profile's own areas (see geographyNotes). - sourceLabel?: string; -} - -const SECTIONS: Record = { - Demographics: { - url: `${BASE_API_URL}/load/acs5-db/tidy/demographics`, - yField: 'Percent', - unit: '%', - yearMin: 2010, - yearMax: 2024, - timeseries: { - historicPopulation: { - url: `${BASE_API_URL}/load/acs5-db/timeseries/demographics/historic-population`, - }, - medianAge: { - url: `${BASE_API_URL}/load/acs5-db/timeseries/demographics/median-age`, - }, - }, - }, - // Education: { - // url: `${BASE_API_URL}/load/acs5-db/tidy/education`, - // yField: 'Percent', - // unit: '%', - // yearMin: 2012, - // yearMax: 2024, - // }, - Housing: { - url: `${BASE_API_URL}/load/acs5-db/tidy/housing`, - yField: 'Value', - unit: '', - yearMin: 2010, - yearMax: 2024, - timeseries: { - medianHomeValue: { - url: `${BASE_API_URL}/load/acs5-db/timeseries/housing/median-home-value`, - }, - totalUnits: { - url: `${BASE_API_URL}/load/acs5-db/timeseries/housing/total-units`, - }, - }, - }, - 'Labor & Economy': { - url: `${BASE_API_URL}/load/acs5-db/tidy/economics`, - yField: 'Value', - unit: '', - yearMin: 2010, - yearMax: 2024, - timeseries: { - householdIncome: { - url: `${BASE_API_URL}/load/acs5-db/timeseries/economics/median-hh-income`, - }, - perCapitaIncome: { - url: `${BASE_API_URL}/load/acs5-db/timeseries/economics/per-capita-income`, - }, - }, - }, - 'Land Use': { - url: `${BASE_API_URL}/load/data/zoning/aggregated`, - yField: 'Value', - unit: '', - yearMin: 2024, - yearMax: 2024, - locationFilterKey: 'Jurisdiction', - hasYearDimension: false, - timeseries: { - allowances: { - url: `${BASE_API_URL}/load/data/zoning/allowances`, - }, - wastewaterPermits: { - url: `${BASE_API_URL}/load/mapping/wastewater/treatment_facility/permits`, - }, - }, - }, - 'Community Health': { - url: `${BASE_API_URL}/load/data/cdc/places`, - yField: 'Value', - unit: '%', - yearMin: 2024, - yearMax: 2024, - locationFilterKey: 'County', - countyOnly: true, - hasYearDimension: false, - sourceLabel: 'CDC PLACES', - timeseries: { - // CDC PLACES is a single snapshot; Census rates show the change. - povertyUninsured: { - url: `${BASE_API_URL}/load/acs5-db/timeseries/economics/poverty-uninsured`, - byPlaceName: true, - }, - }, - allAreas: { - countyValues: `${BASE_API_URL}/load/data/cdc/places/by-county`, - }, - }, -}; - -// The county area containing a town-type location. -function countyArea(l: Location, county: string): Location { - return { - ...l, - type: 'county', - town: null, - name: `${county} County, Vermont`, - }; -} - -// A profile saved as "Town" before a town was picked (the profile form now -// requires one) has no town and the placeholder name "Unknown". Show its -// county instead, so every section can filter and label it, and say so. -function resolveLocation(l: Location): { location: Location; note?: string } { - if (l.type !== 'town' || l.town || !l.county) return { location: l }; - return { - location: countyArea(l, l.county), - note: `Your profile has Town selected but no town picked, so ${l.county} County is shown instead.`, - }; -} - -// The place a section's data actually describes for a profile area: a -// county-only source (CDC PLACES) shows a town's county. Other areas are -// shown as picked (Vermont is still Vermont, just a computed figure). -function dataLocation(cfg: SectionConfig, l: Location): Location { - if (!cfg.countyOnly || l.type !== 'town' || !l.county) return l; - return countyArea(l, l.county); -} - -// Plain-language notes for each profile area this section can't show as-is -// (e.g. a town shown with its county's numbers), so the substitution is -// visible at the top of the report rather than only in chart footnotes. -function geographyNotes( - cfg: SectionConfig, - primary: Location, - comparison: Location, -): string[] { - if (!cfg.countyOnly) return []; - const source = cfg.sourceLabel ?? 'This data'; - const noteFor = (l: Location) => { - switch (l.type) { - case 'county': - return null; - case 'town': - return l.county - ? `${source} doesn't publish town-level estimates, so ${l.town ?? l.name} shows ${l.county} County's numbers.` - : `${source} doesn't publish town-level estimates for ${l.name}.`; - case 'state': - return `${source} doesn't publish a statewide figure, so Vermont shows the average of its county estimates, weighted by adult population.`; - default: - return `${source} has no estimates for ${l.name}, so it has no data on this page.`; - } - }; - const notes = [noteFor(primary), noteFor(comparison)]; - const countyOf = (l: Location) => - l.type === 'town' || l.type === 'county' ? l.county : null; - const shared = countyOf(primary); - if ( - shared && - shared === countyOf(comparison) && - primary.name !== comparison.name - ) { - notes.push( - `Both areas use ${shared} County's numbers, so their values are the same.`, - ); - } - return Array.from(new Set(notes.filter((n): n is string => !!n))); -} - -// county_town_names.json (the profile's town picker) stores Census-style -// subdivision names — e.g. location.town is "Burlington city" or "Addison -// town" — which is exactly what the cleaned datasets' `town` columns hold, so -// that is the primary candidate. Rows the cleaners couldn't match to a -// municipality keep their raw source spelling ("Huntington", "Barre Town"), -// so the bare and title-cased-suffix forms ride along in the IN-list too. -const TOWN_SUFFIX_RE = /\s+(town|city|gore|grant)$/i; -const bareTownName = (town: string) => town.replace(TOWN_SUFFIX_RE, ''); -const titledTownName = (town: string) => - town.replace( - /(town|city|gore|grant)$/i, - (s) => s[0].toUpperCase() + s.slice(1).toLowerCase(), - ); - -// Builds the location portion of a filter request for a section. ACS-style -// sections (no locationFilterKey) send the profile's full display name, -// which matches the ACS NAME column at any grain (town/county/state) — this -// is the pre-existing behavior. Zoning/wastewater sections have separate -// Town/County columns instead of one combined name string, so the display -// name (e.g. "Burlington city, Chittenden County, Vermont") never matches; -// filter by whichever grain the selected location actually is instead, and -// send no filter for state/national (no statewide-equivalent column to -// filter to — this naturally falls back to the full dataset). A town pick -// filters BOTH Jurisdiction and County — Vermont has same-named towns in -// different counties, so Jurisdiction alone can be ambiguous. -// Returns null when the section has no data for that kind of location. -function buildLocationFilters( - cfg: SectionConfig, - location: Location, -): Record | null { - if (!cfg.locationFilterKey) { - return { Location: [location.name] }; - } - if (cfg.countyOnly) { - if (location.county) return { County: [location.county] }; - // No county filter means "all counties", which the API combines into a - // Vermont estimate: right for a statewide pick, but an RPC or national - // pick has no county-grain equivalent and would be mislabeled. - return location.type === 'state' ? {} : null; - } - if (location.type === 'town' && location.town) { - // `town` columns hold the Census-style name as-is ("Rockingham town"); - // the bare/titled spellings only match rows the cleaners left unmatched. - const candidates = Array.from( - new Set([ - location.town, - bareTownName(location.town), - titledTownName(location.town), - ]), - ); - const filters: Record = { - [cfg.locationFilterKey]: candidates, - }; - if (location.county) filters.County = [location.county]; - return filters; - } - if (location.type === 'county' && location.county) { - return { County: [location.county] }; - } - return {}; -} - // One slim bar stuck under the site header: back to the topic chooser, the // topic tabs, and export. The places and year live in the report's intro. function ReportHeader({ @@ -469,25 +203,7 @@ export default function ReportsByTopic({ url: string, location: Location, endpoint?: TimeseriesEndpoint, - ) => { - const locationFilters = endpoint?.byPlaceName - ? { Location: [location.name] } - : buildLocationFilters(cfg, location); - if (!locationFilters) return Promise.resolve({ data: [] }); - return axios - .post(url, { - filters: { - ...locationFilters, - year: { - min: cfg.yearMin, - max: cfg.yearMax, - }, - }, - include: [], - }) - .then((r) => r.data) - .catch(() => ({ data: [] })); - }; + ) => postSection(cfg, url, location, endpoint); const timeseriesKeys = Object.keys(cfg.timeseries ?? {}); // Ignore a response once the locations change again: the first fetch diff --git a/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.module.css b/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.module.css new file mode 100644 index 00000000..a29450ba --- /dev/null +++ b/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.module.css @@ -0,0 +1,57 @@ +.card { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + text-decoration: none; + color: inherit; + background: #fff; + border: 1px solid rgba(27, 58, 47, 0.14); + border-radius: 12px; + box-shadow: 0 1px 2px rgba(27, 58, 47, 0.06); + transition: + transform 180ms ease, + box-shadow 180ms ease; +} + +.card, +.card:hover, +.card:focus-visible { + text-decoration: none; +} + +.card:hover, +.card:focus-visible { + transform: translateY(-3px); + box-shadow: 0 10px 24px rgba(27, 58, 47, 0.16); +} + +.card:focus-visible { + outline: 3px solid #dd9a2f; + outline-offset: 2px; +} + +.arrow { + display: inline-flex; + transition: transform 180ms ease; +} + +/* The up-right arrow turns 45° clockwise to point straight right. */ +.card:hover .arrow, +.card:focus-visible .arrow { + transform: rotate(45deg); +} + +@media (prefers-reduced-motion: reduce) { + .card, + .arrow { + transition: none; + } + + .card:hover, + .card:focus-visible, + .card:hover .arrow, + .card:focus-visible .arrow { + transform: none; + } +} diff --git a/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.tsx b/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.tsx index 14c364c1..6671d557 100644 --- a/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.tsx +++ b/frontend/src/app/data-comparison/reports-by-topic/TopicLanding.tsx @@ -1,12 +1,14 @@ 'use client'; import Link from 'next/link'; +import { useEffect, useState } from 'react'; import { Box, Button, Container, + Grid, Group, - SimpleGrid, + Skeleton, Stack, Text, Title, @@ -17,12 +19,21 @@ import { IconHome2, IconMap2, IconArrowRight, + IconArrowUpRight, IconPencil, IconUsers, type Icon, } from '@tabler/icons-react'; import { COLORS } from '@/app/theme'; import { useProfile } from '@/components/profile/profileStore'; +import { + FORMATS, + getValue, + type FormatKey, +} from '@/components/Reports/shared/data'; +import type { DataRow } from '@/types/cachedCharts'; +import classes from './TopicLanding.module.css'; +import { SECTIONS, postSection, resolveLocation } from './reportData'; import { COMPARISON_COLOR, TOPIC_ACCENTS, @@ -60,8 +71,159 @@ const TOPICS: Record< }, }; +// The stats on each card, read from the topic's main dataset for one place. +interface Stat { + label: string; + format: FormatKey; + read: (rows: DataRow[]) => number | null; +} + +// ACS rows span several years; a card shows the latest. +function latest(rows: DataRow[], key: 'year' | 'Year') { + const y = Math.max(0, ...rows.map((r) => Number(r[key]) || 0)); + return y ? rows.filter((r) => Number(r[key]) === y) : rows; +} +const acs = + (variable: string, field = 'Value') => + (rows: DataRow[]) => + getValue(latest(rows, 'year'), variable, field); +const cdc = (measure: string) => (rows: DataRow[]) => { + const v = latest( + rows.filter((r) => r.Measure === measure), + 'Year', + )[0]?.Value; + return v == null ? null : Number(v); +}; + +const STATS: Record = { + Demographics: [ + { label: 'Population', format: 'int', read: acs('Population (ACS)') }, + { label: 'Median age', format: 'years', read: acs('Median Age') }, + ], + Housing: [ + { + label: 'Median home value', + format: 'usd', + read: acs('Median Home Value'), + }, + { label: 'Housing units', format: 'int', read: acs('Total Housing Units') }, + ], + 'Labor & Economy': [ + { + label: 'Median household income', + format: 'usd', + read: acs('Median Household Income'), + }, + { + label: 'Unemployment', + format: 'pct', + read: acs('Unemployment Rate', 'Percent'), + }, + ], + 'Land Use': [ + { + label: 'Zoning districts', + format: 'int', + read: (rows) => rows.length || null, + }, + ], + 'Community Health': [ + { + label: 'Depression', + format: 'pct', + read: cdc('Depression among adults'), + }, + { + label: 'Uninsured (18–64)', + format: 'pct', + read: cdc( + 'Current lack of health insurance among adults aged 18-64 years', + ), + }, + ], +}; + +type TopicRows = Record; + +// One fetch per topic per place, same endpoints and filters as the reports. +// Failed fetches come back empty, so a card just shows dashes. +function useTopicRows(primaryName: string, comparisonName: string) { + const { myLocation, comparison } = useProfile(); + const [rows, setRows] = useState(null); + + useEffect(() => { + let cancelled = false; + const topics = Object.keys(TOPIC_SLUGS) as DashboardSection[]; + const primary = resolveLocation(myLocation).location; + const bench = resolveLocation(comparison).location; + // eslint-disable-next-line react-hooks/set-state-in-effect -- fetch effect: back to the loading state for the new places + setRows(null); + Promise.all( + topics.map(async (t) => { + const cfg = SECTIONS[t]; + const [p, c] = await Promise.all([ + postSection(cfg, cfg.url, primary), + postSection(cfg, cfg.url, bench), + ]); + // Land Use's `data` is acreage by district type (a few rows); its + // `tableData` has one row per zoning district, which is what is counted. + const key = t === 'Land Use' ? 'tableData' : 'data'; + const rowsOf = (r: typeof p) => (Array.isArray(r[key]) ? r[key] : []); + return [t, { primary: rowsOf(p), comparison: rowsOf(c) }] as const; + }), + ).then((entries) => { + if (!cancelled) setRows(Object.fromEntries(entries)); + }); + return () => { + cancelled = true; + }; + // The names change whenever the place does; see ReportsByTopic. + }, [primaryName, comparisonName]); // eslint-disable-line react-hooks/exhaustive-deps + + return rows; +} + +function CardStat({ + stat, + topic, + rows, +}: { + stat: Stat; + topic: DashboardSection; + rows: TopicRows | null; +}) { + const f = FORMATS[stat.format]; + const p = rows ? stat.read(rows[topic].primary) : null; + const c = rows ? stat.read(rows[topic].comparison) : null; + return ( + + + {stat.label} + + {rows ? ( + <> + + {p !== null ? f.value(p) : '—'} + + {p !== null && c !== null && ( + + {f.diff(p - c)} vs. {f.value(c)} + + )} + + ) : ( + <> + + + + )} + + ); +} + export default function TopicLanding() { const { myLocation, comparison, openProfileModal } = useProfile(); + const rows = useTopicRows(myLocation.name, comparison.name); return ( @@ -110,46 +272,67 @@ export default function TopicLanding() { - - {(Object.keys(TOPIC_SLUGS) as DashboardSection[]).map((topic) => { + + {(Object.keys(TOPIC_SLUGS) as DashboardSection[]).map((topic, i) => { const { icon: TopicIcon, blurb, shows } = TOPICS[topic]; const accent = TOPIC_ACCENTS[topic]; return ( - - - - - {topic} - - - - {blurb} - - - {shows} - - - Open report → - - + + + + + + {topic} + + + + + + + + {blurb} + + + {shows} + + + + {STATS[topic].map((stat) => ( + + ))} + + + + Open report + + + + + + ); })} - + ); diff --git a/frontend/src/app/data-comparison/reports-by-topic/reportData.ts b/frontend/src/app/data-comparison/reports-by-topic/reportData.ts new file mode 100644 index 00000000..175c13fe --- /dev/null +++ b/frontend/src/app/data-comparison/reports-by-topic/reportData.ts @@ -0,0 +1,311 @@ +/** + * @description + * Where each Reports by Topic section gets its data, and how a profile + * place becomes that section's location filter. Plain TS (no React) so both + * the report page and the topic landing page can fetch the same way. + */ + +import axios from 'axios'; +import type { Location } from '@/components/profile/profileStore'; +import { BASE_API_URL } from '@/config'; +import type { DataRow } from '@/types/cachedCharts'; + +// --------------------------------------------------------------------------- +// Section config — url and which field to use as the bar value +// --------------------------------------------------------------------------- + +type YField = 'Percent' | 'Value'; + +// Additional named endpoints a topic can pull in alongside its primary `url` +// — e.g. dedicated time-series tables for trend charts. Fetched for both the +// primary and comparison locations and handed to the topic's Dashboard +// component as `data.timeseries[key]`. +export interface TimeseriesEndpoint { + url: string; + // Filter by the profile place's ACS name (the Location filter) instead of + // the section's own location filter -- for ACS context on a county-only + // section, since ACS publishes the town itself. + byPlaceName?: boolean; +} + +export interface SectionConfig { + url: string; + yField: YField; + unit: string; + yearMin: number; + yearMax: number; + timeseries?: Record; + // Endpoints fetched once per section with no location filter, for data on + // every area (e.g. each county's values, to rank one among the rest). + // Handed to the topic's Dashboard component as `data.allAreas[key]`. + allAreas?: Record; + // Filter label sent as the location key (defaults to "Location", which + // matches the ACS5 routes' NAME column). Zoning/wastewater tables have no + // "Location" column in their filter schema — they use "Jurisdiction" + // (the `town` column) instead, so requests sent with the default key + // silently return unfiltered, statewide results. + locationFilterKey?: string; + // False for datasets with no `year` column (e.g. zoning is a current- + // snapshot dataset) — skips the year slice below, which would otherwise + // filter every row out since `String(undefined) !== yearStr`. Defaults to + // true. + hasYearDimension?: boolean; + // True for datasets only published at county grain (e.g. CDC PLACES has + // no town-level rows) — always filters by location.county, even for a + // town-type selection, instead of trying (and failing) to filter by town. + countyOnly?: boolean; + // Source name used in the notes shown when the report can't use the + // profile's own areas (see geographyNotes). + sourceLabel?: string; +} + +export const SECTIONS: Record = { + Demographics: { + url: `${BASE_API_URL}/load/acs5-db/tidy/demographics`, + yField: 'Percent', + unit: '%', + yearMin: 2010, + yearMax: 2024, + timeseries: { + historicPopulation: { + url: `${BASE_API_URL}/load/acs5-db/timeseries/demographics/historic-population`, + }, + medianAge: { + url: `${BASE_API_URL}/load/acs5-db/timeseries/demographics/median-age`, + }, + }, + }, + // Education: { + // url: `${BASE_API_URL}/load/acs5-db/tidy/education`, + // yField: 'Percent', + // unit: '%', + // yearMin: 2012, + // yearMax: 2024, + // }, + Housing: { + url: `${BASE_API_URL}/load/acs5-db/tidy/housing`, + yField: 'Value', + unit: '', + yearMin: 2010, + yearMax: 2024, + timeseries: { + medianHomeValue: { + url: `${BASE_API_URL}/load/acs5-db/timeseries/housing/median-home-value`, + }, + totalUnits: { + url: `${BASE_API_URL}/load/acs5-db/timeseries/housing/total-units`, + }, + }, + }, + 'Labor & Economy': { + url: `${BASE_API_URL}/load/acs5-db/tidy/economics`, + yField: 'Value', + unit: '', + yearMin: 2010, + yearMax: 2024, + timeseries: { + householdIncome: { + url: `${BASE_API_URL}/load/acs5-db/timeseries/economics/median-hh-income`, + }, + perCapitaIncome: { + url: `${BASE_API_URL}/load/acs5-db/timeseries/economics/per-capita-income`, + }, + }, + }, + 'Land Use': { + url: `${BASE_API_URL}/load/data/zoning/aggregated`, + yField: 'Value', + unit: '', + yearMin: 2024, + yearMax: 2024, + locationFilterKey: 'Jurisdiction', + hasYearDimension: false, + timeseries: { + allowances: { + url: `${BASE_API_URL}/load/data/zoning/allowances`, + }, + wastewaterPermits: { + url: `${BASE_API_URL}/load/mapping/wastewater/treatment_facility/permits`, + }, + }, + }, + 'Community Health': { + url: `${BASE_API_URL}/load/data/cdc/places`, + yField: 'Value', + unit: '%', + yearMin: 2024, + yearMax: 2024, + locationFilterKey: 'County', + countyOnly: true, + hasYearDimension: false, + sourceLabel: 'CDC PLACES', + timeseries: { + // CDC PLACES is a single snapshot; Census rates show the change. + povertyUninsured: { + url: `${BASE_API_URL}/load/acs5-db/timeseries/economics/poverty-uninsured`, + byPlaceName: true, + }, + }, + allAreas: { + countyValues: `${BASE_API_URL}/load/data/cdc/places/by-county`, + }, + }, +}; + +// The county area containing a town-type location. +export function countyArea(l: Location, county: string): Location { + return { + ...l, + type: 'county', + town: null, + name: `${county} County, Vermont`, + }; +} + +// A profile saved as "Town" before a town was picked (the profile form now +// requires one) has no town and the placeholder name "Unknown". Show its +// county instead, so every section can filter and label it, and say so. +export function resolveLocation(l: Location): { + location: Location; + note?: string; +} { + if (l.type !== 'town' || l.town || !l.county) return { location: l }; + return { + location: countyArea(l, l.county), + note: `Your profile has Town selected but no town picked, so ${l.county} County is shown instead.`, + }; +} + +// The place a section's data actually describes for a profile area: a +// county-only source (CDC PLACES) shows a town's county. Other areas are +// shown as picked (Vermont is still Vermont, just a computed figure). +export function dataLocation(cfg: SectionConfig, l: Location): Location { + if (!cfg.countyOnly || l.type !== 'town' || !l.county) return l; + return countyArea(l, l.county); +} + +// Plain-language notes for each profile area this section can't show as-is +// (e.g. a town shown with its county's numbers), so the substitution is +// visible at the top of the report rather than only in chart footnotes. +export function geographyNotes( + cfg: SectionConfig, + primary: Location, + comparison: Location, +): string[] { + if (!cfg.countyOnly) return []; + const source = cfg.sourceLabel ?? 'This data'; + const noteFor = (l: Location) => { + switch (l.type) { + case 'county': + return null; + case 'town': + return l.county + ? `${source} doesn't publish town-level estimates, so ${l.town ?? l.name} shows ${l.county} County's numbers.` + : `${source} doesn't publish town-level estimates for ${l.name}.`; + case 'state': + return `${source} doesn't publish a statewide figure, so Vermont shows the average of its county estimates, weighted by adult population.`; + default: + return `${source} has no estimates for ${l.name}, so it has no data on this page.`; + } + }; + const notes = [noteFor(primary), noteFor(comparison)]; + const countyOf = (l: Location) => + l.type === 'town' || l.type === 'county' ? l.county : null; + const shared = countyOf(primary); + if ( + shared && + shared === countyOf(comparison) && + primary.name !== comparison.name + ) { + notes.push( + `Both areas use ${shared} County's numbers, so their values are the same.`, + ); + } + return Array.from(new Set(notes.filter((n): n is string => !!n))); +} + +// county_town_names.json (the profile's town picker) stores Census-style +// subdivision names — e.g. location.town is "Burlington city" or "Addison +// town" — which is exactly what the cleaned datasets' `town` columns hold, so +// that is the primary candidate. Rows the cleaners couldn't match to a +// municipality keep their raw source spelling ("Huntington", "Barre Town"), +// so the bare and title-cased-suffix forms ride along in the IN-list too. +const TOWN_SUFFIX_RE = /\s+(town|city|gore|grant)$/i; +const bareTownName = (town: string) => town.replace(TOWN_SUFFIX_RE, ''); +const titledTownName = (town: string) => + town.replace( + /(town|city|gore|grant)$/i, + (s) => s[0].toUpperCase() + s.slice(1).toLowerCase(), + ); + +// Builds the location portion of a filter request for a section. ACS-style +// sections (no locationFilterKey) send the profile's full display name, +// which matches the ACS NAME column at any grain (town/county/state) — this +// is the pre-existing behavior. Zoning/wastewater sections have separate +// Town/County columns instead of one combined name string, so the display +// name (e.g. "Burlington city, Chittenden County, Vermont") never matches; +// filter by whichever grain the selected location actually is instead, and +// send no filter for state/national (no statewide-equivalent column to +// filter to — this naturally falls back to the full dataset). A town pick +// filters BOTH Jurisdiction and County — Vermont has same-named towns in +// different counties, so Jurisdiction alone can be ambiguous. +// Returns null when the section has no data for that kind of location. +export function buildLocationFilters( + cfg: SectionConfig, + location: Location, +): Record | null { + if (!cfg.locationFilterKey) { + return { Location: [location.name] }; + } + if (cfg.countyOnly) { + if (location.county) return { County: [location.county] }; + // No county filter means "all counties", which the API combines into a + // Vermont estimate: right for a statewide pick, but an RPC or national + // pick has no county-grain equivalent and would be mislabeled. + return location.type === 'state' ? {} : null; + } + if (location.type === 'town' && location.town) { + // `town` columns hold the Census-style name as-is ("Rockingham town"); + // the bare/titled spellings only match rows the cleaners left unmatched. + const candidates = Array.from( + new Set([ + location.town, + bareTownName(location.town), + titledTownName(location.town), + ]), + ); + const filters: Record = { + [cfg.locationFilterKey]: candidates, + }; + if (location.county) filters.County = [location.county]; + return filters; + } + if (location.type === 'county' && location.county) { + return { County: [location.county] }; + } + return {}; +} + +/** POSTs one endpoint for one place, scoped to the section's year range. + * Failures and places the section has no data for come back as empty rows. */ +export function postSection( + cfg: SectionConfig, + url: string, + location: Location, + endpoint?: TimeseriesEndpoint, +): Promise<{ data: DataRow[]; tableData?: DataRow[] }> { + const locationFilters = endpoint?.byPlaceName + ? { Location: [location.name] } + : buildLocationFilters(cfg, location); + if (!locationFilters) return Promise.resolve({ data: [] }); + return axios + .post(url, { + filters: { + ...locationFilters, + year: { min: cfg.yearMin, max: cfg.yearMax }, + }, + include: [], + }) + .then((r) => r.data) + .catch(() => ({ data: [] })); +} diff --git a/frontend/src/components/Reports/shared/colors.ts b/frontend/src/components/Reports/shared/colors.ts index dc3ac6e6..d249ff47 100644 --- a/frontend/src/components/Reports/shared/colors.ts +++ b/frontend/src/components/Reports/shared/colors.ts @@ -3,11 +3,11 @@ export const COMPARISON_COLOR = '#868e96'; export const TOPIC_ACCENTS: Record = { - Demographics: '#2F6F9F', - Housing: '#B4532A', - 'Labor & Economy': '#2E7D5B', - 'Land Use': '#7B5EA7', - 'Community Health': '#B83A5E', + Demographics: '#2F4F6F', + Housing: '#A4452C', + 'Labor & Economy': '#5B7B3A', + 'Land Use': '#8B7A3A', + 'Community Health': '#6B3F5E', }; -export const DEFAULT_ACCENT = '#2F6F9F'; +export const DEFAULT_ACCENT = '#2F4F6F'; From cb6e74772c6c9736c5888e3ec553237ea1e73672 Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Tue, 6 Oct 2026 13:19:09 -0400 Subject: [PATCH 6/7] Disable chart animations on report PDF export --- .../reports-by-topic/ReportsByTopic.tsx | 23 +++++++++++++++---- .../demographics/SexDistributionChart.tsx | 7 +++++- .../Reports/health/PovertyUninsuredTrends.tsx | 10 +++++++- .../Reports/land_use/ZoningAllowanceChart.tsx | 4 +++- .../Reports/shared/ComparisonBarChart.tsx | 10 ++++++-- .../Reports/shared/HistoryLineChart.tsx | 4 +++- .../Reports/shared/ReportContext.tsx | 12 +++++++++- 7 files changed, 59 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/data-comparison/reports-by-topic/ReportsByTopic.tsx b/frontend/src/app/data-comparison/reports-by-topic/ReportsByTopic.tsx index 1e3f85a3..798b4501 100644 --- a/frontend/src/app/data-comparison/reports-by-topic/ReportsByTopic.tsx +++ b/frontend/src/app/data-comparison/reports-by-topic/ReportsByTopic.tsx @@ -51,15 +51,20 @@ import { DashboardSection, TOPIC_SLUGS, topicPath } from './topics'; function ReportHeader({ section, data, + exporting, + setExporting, }: { section: DashboardSection; data: ReportData; + exporting: boolean; + setExporting: (v: boolean) => void; }) { - const [exporting, setExporting] = useState(false); - const handleExport = async () => { setExporting(true); try { + // Let the charts re-render without animation (see ReportContext's + // `exporting`) so the capture isn't a half-drawn frame. + await new Promise((resolve) => setTimeout(resolve, 400)); await exportReport('report-body', { title: section, primaryName: data.primary.name, @@ -183,6 +188,7 @@ export default function ReportsByTopic({ const [allAreasData, setAllAreasData] = useState>( {}, ); + const [exporting, setExporting] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -374,7 +380,12 @@ export default function ReportsByTopic({ // --------------------------------------------------------------------------- return ( <> - + {error && {error}} {areaNotes.length > 0 && ( @@ -404,7 +415,11 @@ export default function ReportsByTopic({ ) : ( Dashboard && ( - +
diff --git a/frontend/src/components/Reports/demographics/SexDistributionChart.tsx b/frontend/src/components/Reports/demographics/SexDistributionChart.tsx index b75c8db1..83bf578d 100644 --- a/frontend/src/components/Reports/demographics/SexDistributionChart.tsx +++ b/frontend/src/components/Reports/demographics/SexDistributionChart.tsx @@ -14,10 +14,12 @@ function SexDonut({ title, rows, accent, + exporting, }: { title: string; rows: DataRow[]; accent: string; + exporting: boolean; }) { const data = [ { @@ -37,6 +39,7 @@ function SexDonut({ @@ -70,6 +73,7 @@ export default function SexDistributionChart() { title={primary.name} rows={primary.current} accent={accent} + exporting={exporting} /> @@ -77,6 +81,7 @@ export default function SexDistributionChart() { title={comparison.name} rows={comparison.current} accent={accent} + exporting={exporting} /> diff --git a/frontend/src/components/Reports/health/PovertyUninsuredTrends.tsx b/frontend/src/components/Reports/health/PovertyUninsuredTrends.tsx index ee9b8a6e..e8dae38e 100644 --- a/frontend/src/components/Reports/health/PovertyUninsuredTrends.tsx +++ b/frontend/src/components/Reports/health/PovertyUninsuredTrends.tsx @@ -57,7 +57,13 @@ function seriesFor( } export default function PovertyUninsuredTrends() { - const { timeseries, primary: p, comparison: c, accent } = useReport(); + const { + timeseries, + primary: p, + comparison: c, + accent, + exporting, + } = useReport(); const primary = timeseries?.povertyUninsured?.primary ?? []; const comparison = timeseries?.povertyUninsured?.comparison ?? []; const primaryName = p.name; @@ -100,6 +106,7 @@ export default function PovertyUninsuredTrends() { /> {VAL_ORDER.map((group) => ( ( string; tip?: (v: number) => string; }) { - const { primary, comparison, accent } = useReport(); + const { primary, comparison, accent, exporting } = useReport(); const read = readValue ?? ((rows: DataRow[], key: string) => getValue(rows, key, field) ?? 0); @@ -114,8 +114,14 @@ export default function ComparisonBarChart({ )} tip(Number(v))} /> - + (null); @@ -32,15 +35,22 @@ const ReportContext = createContext(null); export function ReportProvider({ topic, data, + exporting = false, children, }: { topic: string; data: ReportData; + exporting?: boolean; children: ReactNode; }) { return ( {children} From a4a195f9ca97334f016768c8c7e4ed910c82c8e5 Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Tue, 6 Oct 2026 13:24:02 -0400 Subject: [PATCH 7/7] More spacing in StatCards to account for PDF condensing --- frontend/src/components/Reports/shared/Section.tsx | 4 ++-- frontend/src/components/Reports/shared/StatCard.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Reports/shared/Section.tsx b/frontend/src/components/Reports/shared/Section.tsx index 7479aeb2..f299c85f 100644 --- a/frontend/src/components/Reports/shared/Section.tsx +++ b/frontend/src/components/Reports/shared/Section.tsx @@ -160,14 +160,14 @@ export function StatCell({ }) { const { accent } = useReport(); return ( - + {icon} {label} - + <Title order={2} style={{ fontSize: 34, lineHeight: '42px' }}> {value} {children} diff --git a/frontend/src/components/Reports/shared/StatCard.tsx b/frontend/src/components/Reports/shared/StatCard.tsx index 1a9bdba6..495b0cfe 100644 --- a/frontend/src/components/Reports/shared/StatCard.tsx +++ b/frontend/src/components/Reports/shared/StatCard.tsx @@ -25,7 +25,7 @@ function Bar({ }) { return ( - + {name.replace(/, Vermont$/, '')} · {text} @@ -71,14 +71,14 @@ export default function StatCard({ return ( {diff !== null && ( - + {f.diff(diff)} {pct !== null && ` (${pct > 0 ? '+' : ''}${pct.toFixed(1)}%)`} vs.{' '} {ctx.comparison.name.replace(/, Vermont$/, '')} )} - + {p !== null && (