diff --git a/app/src/components/ChartContainer.tsx b/app/src/components/ChartContainer.tsx index 67d14545e..ff79fb6e8 100644 --- a/app/src/components/ChartContainer.tsx +++ b/app/src/components/ChartContainer.tsx @@ -1,34 +1,29 @@ import { useRef, type ReactNode } from 'react'; -import { IconDownload } from '@tabler/icons-react'; -import { - Button, - Group, - Stack, - Text, - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui'; +import { ChartDownloadMenu, type ChartCsvData } from '@/components/ChartDownloadMenu'; +import { Group, Stack, Text } from '@/components/ui'; import { typography } from '@/designTokens'; -import { trackChartCsvDownloaded } from '@/utils/analytics'; -import { downloadChartAsSvg } from '@/utils/chartUtils'; interface ChartContainerProps { children: ReactNode; title: string; /** When set, renders a download button that exports the chart as SVG */ downloadFilename?: string; + /** Optional chart data for CSV export. When set alongside downloadFilename, + * the download button becomes a dropdown with SVG + CSV options. */ + csvData?: ChartCsvData; } /** * A consistent container for charts with standard border, padding, and background styling. - * Automatically renders a header with title and SVG download icon button. - * - * @param title - Chart title text - * @param downloadFilename - SVG filename (enables download button when set) - * @param children - Main content (description and chart) displayed inside the white card + * Automatically renders a header with title and a download icon button (SVG only by default, + * or a dropdown with SVG + CSV when csvData is provided). */ -export function ChartContainer({ children, title, downloadFilename }: ChartContainerProps) { +export function ChartContainer({ + children, + title, + downloadFilename, + csvData, +}: ChartContainerProps) { const contentRef = useRef(null); return ( @@ -38,28 +33,12 @@ export function ChartContainer({ children, title, downloadFilename }: ChartConta {title} {downloadFilename && ( - - - - - Download as SVG - + )} diff --git a/app/src/components/ChartDownloadMenu.tsx b/app/src/components/ChartDownloadMenu.tsx new file mode 100644 index 000000000..7b61baf46 --- /dev/null +++ b/app/src/components/ChartDownloadMenu.tsx @@ -0,0 +1,124 @@ +import { IconDownload } from '@tabler/icons-react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { trackChartCsvDownloaded, trackChartSvgDownloaded } from '@/utils/analytics'; +import { downloadChartAsSvg, downloadCsv } from '@/utils/chartUtils'; + +export type ChartCsvData = string[][] | (() => string[][]); + +interface ChartDownloadMenuProps { + /** Ref to the element containing the chart SVG(s) to export. */ + containerRef: React.RefObject; + /** SVG filename (e.g. "winners-losers-income-decile.svg"). */ + svgFilename: string; + /** Title written into the SVG header. */ + title?: string; + /** Subtitle written into the SVG header. */ + subtitle?: string; + /** Optional CSV data. When provided, the button becomes a dropdown with SVG + CSV options. */ + csvData?: ChartCsvData; + /** Icon size for the trigger (default 18). */ + iconSize?: number; + /** Button size variant. */ + buttonSize?: 'icon' | 'icon-xs'; +} + +function deriveCsvFilename(svgFilename: string): string { + return svgFilename.replace(/\.svg$/i, '.csv'); +} + +function resolveCsvData(csvData: ChartCsvData): string[][] { + return typeof csvData === 'function' ? csvData() : csvData; +} + +/** + * Download trigger for charts. Renders a single SVG-download button when no + * CSV data is provided, or a dropdown menu with "Download as SVG" and + * "Download data (CSV)" when csvData is supplied. + */ +export function ChartDownloadMenu({ + containerRef, + svgFilename, + title, + subtitle, + csvData, + iconSize = 18, + buttonSize = 'icon', +}: ChartDownloadMenuProps) { + const handleSvgDownload = (e?: React.MouseEvent) => { + e?.stopPropagation(); + trackChartSvgDownloaded(); + if (containerRef.current) { + downloadChartAsSvg(containerRef.current, { + title, + subtitle, + filename: svgFilename, + }); + } + }; + + const handleCsvDownload = (e?: React.MouseEvent) => { + e?.stopPropagation(); + if (!csvData) { + return; + } + trackChartCsvDownloaded(); + const rows = resolveCsvData(csvData); + if (rows.length === 0) { + return; + } + downloadCsv(rows, deriveCsvFilename(svgFilename)); + }; + + if (!csvData) { + return ( + + + + + Download as SVG + + ); + } + + return ( + + + + + + + + Download + + + handleSvgDownload()}>Download as SVG + handleCsvDownload()}> + Download data (CSV) + + + + ); +} diff --git a/app/src/components/report/DashboardCard.tsx b/app/src/components/report/DashboardCard.tsx index a9e863100..e8452d1ef 100644 --- a/app/src/components/report/DashboardCard.tsx +++ b/app/src/components/report/DashboardCard.tsx @@ -1,13 +1,12 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { IconArrowsMinimize, IconDownload } from '@tabler/icons-react'; +import { IconArrowsMinimize } from '@tabler/icons-react'; import { motion } from 'framer-motion'; +import { ChartDownloadMenu, type ChartCsvData } from '@/components/ChartDownloadMenu'; import { Text } from '@/components/ui'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { colors, spacing } from '@/designTokens'; import { typography } from '@/designTokens/typography'; -import { trackChartCsvDownloaded } from '@/utils/analytics'; -import { downloadChartAsSvg } from '@/utils/chartUtils'; const FADE_MS = 150; const RESIZE_S = 0.35; @@ -42,6 +41,9 @@ interface DashboardCardProps { expandedTitle?: string; /** SVG download filename — renders a download button in the expanded toolbar */ downloadFilename?: string; + /** Optional CSV data for the expanded chart. When set alongside + * downloadFilename, the download button becomes a dropdown with SVG + CSV. */ + csvData?: ChartCsvData; // Style overrides (apply only when shrunken/idle) shrunkenBackground?: string; @@ -84,6 +86,7 @@ export default function DashboardCard({ expandedControls, expandedTitle, downloadFilename, + csvData, shrunkenBackground, shrunkenBorderColor, padding: paddingProp, @@ -192,6 +195,13 @@ export default function DashboardCard({ const shrunkenContentOpacity = phase === 'idle' ? 1 : 0; const mountExpanded = phase === 'expanded' || phase === 'pre-collapse'; const expandedContentOpacity = phase === 'expanded' && expandedVisible ? 1 : 0; + // Clip overflow during animations and in idle state so mini-chart content + // doesn't bleed past the rounded corners. While the card sits at expanded + // dimensions (either fully expanded or fading out on pre-collapse), let + // overflow be visible so Recharts tooltips near the chart edges (e.g. + // decile 8–10 on Winners & Losers) don't get cut off by the card boundary. + const cardOverflow = + phase === 'expanded' || phase === 'pre-collapse' ? 'visible' : 'hidden'; // Animate target: cell size when shrinking, expanded size when growing const getAnimateTarget = (): { width: number; height: number } | undefined => { @@ -265,7 +275,7 @@ export default function DashboardCard({ border: `1px solid ${cardBorderColor}`, padding: cardPadding, cursor: !isExpanded && onToggleMode ? 'pointer' : undefined, - overflow: 'hidden', + overflow: cardOverflow, boxShadow: isLifted ? '0 8px 32px rgba(0,0,0,0.12)' : 'none', display: 'flex', flexDirection: 'column', @@ -273,7 +283,7 @@ export default function DashboardCard({ onClick={!isExpanded ? onToggleMode : undefined} > {/* Content area */} -
+
{/* Shrunken layer — always mounted, opacity-controlled */}
{downloadFilename && ( - - - - - Download as SVG - + )} {onToggleMode && expandButton}
diff --git a/app/src/pages/ReportOutput.page.tsx b/app/src/pages/ReportOutput.page.tsx index 0fc2a9f7b..d18f40871 100644 --- a/app/src/pages/ReportOutput.page.tsx +++ b/app/src/pages/ReportOutput.page.tsx @@ -141,8 +141,12 @@ export default function ReportOutputPage({ const activeView = view || ''; const versionMetadata = extractReportVersionMetadata(report?.output); - // Format the report creation timestamp using the current country's locale - const timestamp = formatReportTimestamp(userReport?.createdAt, countryId); + // Show the most recent run timestamp (updatedAt is set whenever a report is + // replaced/rerun; fall back to createdAt for reports that have never been rerun). + const timestamp = formatReportTimestamp( + userReport?.updatedAt ?? userReport?.createdAt, + countryId + ); // Hook for saving shared reports with all ingredients const { saveSharedReport, saveResult, setSaveResult } = useSaveSharedReport(); diff --git a/app/src/pages/report-output/SocietyWideOverview.tsx b/app/src/pages/report-output/SocietyWideOverview.tsx index 1975d40b3..77dfac6a6 100644 --- a/app/src/pages/report-output/SocietyWideOverview.tsx +++ b/app/src/pages/report-output/SocietyWideOverview.tsx @@ -26,24 +26,42 @@ import { formatParameterValue } from '@/utils/chartValueUtils'; import { formatBudgetaryImpact } from '@/utils/formatPowers'; import { currencySymbol, formatCurrencyAbbr } from '@/utils/formatters'; import { DIVERGING_GRAY_TEAL } from '@/utils/visualization/colorScales'; -import BudgetaryImpactSubPage from './budgetary-impact/BudgetaryImpactSubPage'; +import BudgetaryImpactSubPage, { + buildBudgetaryImpactCsv, +} from './budgetary-impact/BudgetaryImpactSubPage'; import { getBudgetChartTitle } from './budgetary-impact/budgetChartUtils'; import { getDistributionalAverageTitle, getDistributionalRelativeTitle, getWinnersLosersTitle, } from './distributional-impact/distributionalChartUtils'; -import DistributionalImpactIncomeAverageSubPage from './distributional-impact/DistributionalImpactIncomeAverageSubPage'; -import DistributionalImpactIncomeRelativeSubPage from './distributional-impact/DistributionalImpactIncomeRelativeSubPage'; -import WinnersLosersIncomeDecileSubPage from './distributional-impact/WinnersLosersIncomeDecileSubPage'; +import DistributionalImpactIncomeAverageSubPage, { + buildDistributionalAbsoluteCsv, +} from './distributional-impact/DistributionalImpactIncomeAverageSubPage'; +import DistributionalImpactIncomeRelativeSubPage, { + buildDistributionalRelativeCsv, +} from './distributional-impact/DistributionalImpactIncomeRelativeSubPage'; +import WinnersLosersIncomeDecileSubPage, { + buildWinnersLosersCsv, +} from './distributional-impact/WinnersLosersIncomeDecileSubPage'; import { getInequalityTitle } from './inequality-impact/inequalityChartUtils'; -import InequalityImpactSubPage from './inequality-impact/InequalityImpactSubPage'; -import DeepPovertyImpactByAgeSubPage from './poverty-impact/DeepPovertyImpactByAgeSubPage'; +import InequalityImpactSubPage, { + buildInequalityCsv, +} from './inequality-impact/InequalityImpactSubPage'; +import DeepPovertyImpactByAgeSubPage, { + buildDeepPovertyByAgeCsv, +} from './poverty-impact/DeepPovertyImpactByAgeSubPage'; import DeepPovertyImpactByGenderSubPage from './poverty-impact/DeepPovertyImpactByGenderSubPage'; import { getDeepPovertyTitle, getPovertyTitle } from './poverty-impact/povertyChartUtils'; -import PovertyImpactByAgeSubPage from './poverty-impact/PovertyImpactByAgeSubPage'; -import PovertyImpactByGenderSubPage from './poverty-impact/PovertyImpactByGenderSubPage'; -import PovertyImpactByRaceSubPage from './poverty-impact/PovertyImpactByRaceSubPage'; +import PovertyImpactByAgeSubPage, { + buildPovertyByAgeCsv, +} from './poverty-impact/PovertyImpactByAgeSubPage'; +import PovertyImpactByGenderSubPage, { + buildPovertyByGenderCsv, +} from './poverty-impact/PovertyImpactByGenderSubPage'; +import PovertyImpactByRaceSubPage, { + buildPovertyByRaceCsv, +} from './poverty-impact/PovertyImpactByRaceSubPage'; interface SocietyWideOverviewProps { output: SocietyWideReportOutput; @@ -1099,6 +1117,24 @@ export default function SocietyWideOverview({ return `${depthPrefix}poverty-impact-${breakdownSuffix}.svg`; })(); + // Poverty CSV depends on the same selection. + const povertyCsvData = () => { + if (povertyDepth === 'regular') { + if (povertyBreakdown === 'by-age') { + return buildPovertyByAgeCsv(output); + } + if (povertyBreakdown === 'by-gender') { + return buildPovertyByGenderCsv(output); + } + return buildPovertyByRaceCsv(output); + } + if (povertyBreakdown === 'by-age') { + return buildDeepPovertyByAgeCsv(output); + } + // Deep poverty by gender reuses the shared builder with deep=true. + return buildPovertyByGenderCsv(output, true); + }; + // Decile impact mini chart data (absolute) const decileKeys = Object.keys(output.decile.average).sort((a, b) => Number(a) - Number(b)); const decileAbsValues = decileKeys.map((d) => output.decile.average[d]); @@ -1246,6 +1282,7 @@ export default function SocietyWideOverview({ } expandedTitle={getBudgetChartTitle(output.budget.budgetary_impact, countryId, metadata)} downloadFilename="budgetary-impact.svg" + csvData={() => buildBudgetaryImpactCsv(output, countryId)} expandedContent={} onToggleMode={() => toggle('budget')} /> @@ -1313,6 +1350,11 @@ export default function SocietyWideOverview({ ? 'distributional-impact-income-average.svg' : 'distributional-impact-income-relative.svg' } + csvData={() => + decileMode === 'absolute' + ? buildDistributionalAbsoluteCsv(output) + : buildDistributionalRelativeCsv(output) + } expandedContent={ decileMode === 'absolute' ? ( @@ -1417,6 +1459,7 @@ export default function SocietyWideOverview({ } expandedTitle={getWinnersLosersTitle(output, countryId, metadata)} downloadFilename="winners-losers-income-decile.svg" + csvData={() => buildWinnersLosersCsv(output)} expandedContent={} onToggleMode={() => toggle('winners')} /> @@ -1470,6 +1513,7 @@ export default function SocietyWideOverview({ : getDeepPovertyTitle(output, countryId, metadata) } downloadFilename={povertyDownloadFilename} + csvData={povertyCsvData} expandedContent={povertyChart} onToggleMode={() => toggle('poverty')} /> @@ -1503,6 +1547,7 @@ export default function SocietyWideOverview({ } expandedTitle={getInequalityTitle(output, metadata)} downloadFilename="inequality-impact.svg" + csvData={() => buildInequalityCsv(output)} expandedContent={} onToggleMode={() => toggle('inequality')} /> diff --git a/app/src/pages/report-output/budgetary-impact/BudgetaryImpactByProgramSubPage.tsx b/app/src/pages/report-output/budgetary-impact/BudgetaryImpactByProgramSubPage.tsx index 2618ee65b..071665b5c 100644 --- a/app/src/pages/report-output/budgetary-impact/BudgetaryImpactByProgramSubPage.tsx +++ b/app/src/pages/report-output/budgetary-impact/BudgetaryImpactByProgramSubPage.tsx @@ -94,6 +94,30 @@ export default function BudgetaryImpactByProgramSubPage({ output }: Props) { { name: 'Total', value: budgetaryImpact / 1e9, isTotal: true }, ]; + // CSV export: one row per program with baseline/reform/difference in billions, plus total. + const buildCsv = (): string[][] => { + const header = [ + 'Program', + 'Baseline spending (billions)', + 'Reform spending (billions)', + 'Difference (billions)', + ]; + const rows: string[][] = [header]; + for (const [key, values] of Object.entries(detailedBudget)) { + if (values.difference === 0) { + continue; + } + rows.push([ + variables[key]?.label || key, + (values.baseline / 1e9).toFixed(3), + (values.reform / 1e9).toFixed(3), + (values.difference / 1e9).toFixed(3), + ]); + } + rows.push(['Total', '', '', (budgetaryImpact / 1e9).toFixed(3)]); + return rows; + }; + const data = computeWaterfallData(items, (v) => formatBillions(v * 1e9, countryId)); // Attach hover text to each datum for the tooltip @@ -111,6 +135,7 @@ export default function BudgetaryImpactByProgramSubPage({ output }: Props) { = [ + [isUS ? 'Federal tax revenues' : 'Tax revenues', taxImpact / 1e9], + ['State and local income tax revenues', stateTaxImpact / 1e9], + ['Benefit spending', -spendingImpact / 1e9], + ['Net impact', budgetaryImpact / 1e9], + ]; + const header = ['Line item', 'Value (billions)']; + return [header, ...items.filter(([, v]) => v !== 0).map(([k, v]) => [k, v.toFixed(3)])]; +} + export default function BudgetaryImpactSubPage({ output, chartHeight: chartHeightProp, @@ -134,6 +154,7 @@ export default function BudgetaryImpactSubPage({ buildBudgetaryImpactCsv(output, countryId)} > diff --git a/app/src/pages/report-output/distributional-impact/DistributionalImpactIncomeAverageSubPage.tsx b/app/src/pages/report-output/distributional-impact/DistributionalImpactIncomeAverageSubPage.tsx index 6e1758720..afffbfb73 100644 --- a/app/src/pages/report-output/distributional-impact/DistributionalImpactIncomeAverageSubPage.tsx +++ b/app/src/pages/report-output/distributional-impact/DistributionalImpactIncomeAverageSubPage.tsx @@ -37,6 +37,21 @@ interface Props { fillHeight?: boolean; } +/** + * Build a CSV-ready table for the absolute distributional impact by income decile. + * Columns: Decile, Absolute change in household income. + */ +export function buildDistributionalAbsoluteCsv(output: SocietyWideReportOutput): string[][] { + const decileAverage = output.decile.average; + const rows: string[][] = [['Decile', 'Absolute change in household income']]; + Object.keys(decileAverage) + .sort((a, b) => Number(a) - Number(b)) + .forEach((decile) => { + rows.push([decile, decileAverage[decile].toFixed(2)]); + }); + return rows; +} + export default function DistributionalImpactIncomeAverageSubPage({ output, chartHeight: chartHeightProp, @@ -173,6 +188,7 @@ export default function DistributionalImpactIncomeAverageSubPage({ buildDistributionalAbsoluteCsv(output)} > diff --git a/app/src/pages/report-output/distributional-impact/DistributionalImpactIncomeRelativeSubPage.tsx b/app/src/pages/report-output/distributional-impact/DistributionalImpactIncomeRelativeSubPage.tsx index ddc3f51cc..0b5bd85b1 100644 --- a/app/src/pages/report-output/distributional-impact/DistributionalImpactIncomeRelativeSubPage.tsx +++ b/app/src/pages/report-output/distributional-impact/DistributionalImpactIncomeRelativeSubPage.tsx @@ -37,6 +37,21 @@ interface Props { fillHeight?: boolean; } +/** + * Build a CSV-ready table for the relative distributional impact by income decile. + * Columns: Decile, Relative change (%). + */ +export function buildDistributionalRelativeCsv(output: SocietyWideReportOutput): string[][] { + const decileRelative = output.decile.relative; + const rows: string[][] = [['Decile', 'Relative change (%)']]; + Object.keys(decileRelative) + .sort((a, b) => Number(a) - Number(b)) + .forEach((decile) => { + rows.push([decile, (decileRelative[decile] * 100).toFixed(2)]); + }); + return rows; +} + export default function DistributionalImpactIncomeRelativeSubPage({ output, chartHeight: chartHeightProp, @@ -163,6 +178,7 @@ export default function DistributionalImpactIncomeRelativeSubPage({ buildDistributionalRelativeCsv(output)} > diff --git a/app/src/pages/report-output/distributional-impact/DistributionalImpactWealthAverageSubPage.tsx b/app/src/pages/report-output/distributional-impact/DistributionalImpactWealthAverageSubPage.tsx index ad478e6f6..0fae799b5 100644 --- a/app/src/pages/report-output/distributional-impact/DistributionalImpactWealthAverageSubPage.tsx +++ b/app/src/pages/report-output/distributional-impact/DistributionalImpactWealthAverageSubPage.tsx @@ -29,6 +29,16 @@ interface Props { output: SocietyWideReportOutput; } +/** CSV for absolute wealth distributional impact. */ +export function buildWealthAverageCsv(output: SocietyWideReportOutput): string[][] { + const data = output.wealth_decile?.average || {}; + const rows: string[][] = [['Wealth decile', 'Absolute change in household income']]; + for (const k of Object.keys(data).sort((a, b) => Number(a) - Number(b))) { + rows.push([k, data[k].toFixed(2)]); + } + return rows; +} + export default function DistributionalImpactWealthAverageSubPage({ output }: Props) { const mobile = useMediaQuery(MOBILE_BREAKPOINT_QUERY); const countryId = useCurrentCountry(); @@ -102,6 +112,7 @@ export default function DistributionalImpactWealthAverageSubPage({ output }: Pro buildWealthAverageCsv(output)} > diff --git a/app/src/pages/report-output/distributional-impact/DistributionalImpactWealthRelativeSubPage.tsx b/app/src/pages/report-output/distributional-impact/DistributionalImpactWealthRelativeSubPage.tsx index 92b78aa99..4a7940857 100644 --- a/app/src/pages/report-output/distributional-impact/DistributionalImpactWealthRelativeSubPage.tsx +++ b/app/src/pages/report-output/distributional-impact/DistributionalImpactWealthRelativeSubPage.tsx @@ -29,6 +29,16 @@ interface Props { output: SocietyWideReportOutput; } +/** CSV for relative wealth distributional impact. */ +export function buildWealthRelativeCsv(output: SocietyWideReportOutput): string[][] { + const data = output.wealth_decile?.relative || {}; + const rows: string[][] = [['Wealth decile', 'Relative change (%)']]; + for (const k of Object.keys(data).sort((a, b) => Number(a) - Number(b))) { + rows.push([k, (data[k] * 100).toFixed(2)]); + } + return rows; +} + export default function DistributionalImpactWealthRelativeSubPage({ output }: Props) { const mobile = useMediaQuery(MOBILE_BREAKPOINT_QUERY); const countryId = useCurrentCountry(); @@ -92,6 +102,7 @@ export default function DistributionalImpactWealthRelativeSubPage({ output }: Pr buildWealthRelativeCsv(output)} > diff --git a/app/src/pages/report-output/distributional-impact/WinnersLosersIncomeDecileSubPage.tsx b/app/src/pages/report-output/distributional-impact/WinnersLosersIncomeDecileSubPage.tsx index 54c94c321..ecd860d55 100644 --- a/app/src/pages/report-output/distributional-impact/WinnersLosersIncomeDecileSubPage.tsx +++ b/app/src/pages/report-output/distributional-impact/WinnersLosersIncomeDecileSubPage.tsx @@ -46,6 +46,23 @@ const LEGEND_TEXT_MAP: Record = { const BAR_SIZE = 18; +/** + * Build a CSV-ready table for the Winners & Losers chart. + * Columns: Decile, Gain >5%, Gain <5%, No change, Lose <5%, Lose >5% (all as %). + */ +export function buildWinnersLosersCsv(output: SocietyWideReportOutput): string[][] { + const deciles = output.intra_decile.deciles; + const all = output.intra_decile.all; + const header = ['Decile', ...CATEGORIES.map((c) => `${LEGEND_TEXT_MAP[c]} (%)`)]; + const fmt = (v: number) => (v * 100).toFixed(2); + const rows: string[][] = [header]; + for (let i = 0; i < 10; i++) { + rows.push([String(i + 1), ...CATEGORIES.map((c) => fmt(deciles[c][i]))]); + } + rows.push(['All', ...CATEGORIES.map((c) => fmt(all[c]))]); + return rows; +} + function WinnersLosersTooltip({ active, payload, label }: any) { if (!active || !payload?.length) { return null; @@ -285,7 +302,11 @@ export default function WinnersLosersIncomeDecileSubPage({ } return ( - + buildWinnersLosersCsv(output)} + >
diff --git a/app/src/pages/report-output/distributional-impact/WinnersLosersWealthDecileSubPage.tsx b/app/src/pages/report-output/distributional-impact/WinnersLosersWealthDecileSubPage.tsx index 2a33db033..74e0a570f 100644 --- a/app/src/pages/report-output/distributional-impact/WinnersLosersWealthDecileSubPage.tsx +++ b/app/src/pages/report-output/distributional-impact/WinnersLosersWealthDecileSubPage.tsx @@ -44,6 +44,20 @@ const LEGEND_TEXT_MAP: Record = { const BAR_SIZE = 18; +/** CSV for Winners & Losers by wealth decile. */ +export function buildWinnersLosersWealthCsv(output: SocietyWideReportOutput): string[][] { + const deciles: Record = output.intra_wealth_decile?.deciles || {}; + const all: Record = output.intra_wealth_decile?.all || {}; + const header = ['Wealth decile', ...CATEGORIES.map((c) => `${LEGEND_TEXT_MAP[c]} (%)`)]; + const fmt = (v: number | undefined) => ((v ?? 0) * 100).toFixed(2); + const rows: string[][] = [header]; + for (let i = 0; i < 10; i++) { + rows.push([String(i + 1), ...CATEGORIES.map((c) => fmt(deciles[c]?.[i]))]); + } + rows.push(['All', ...CATEGORIES.map((c) => fmt(all[c]))]); + return rows; +} + function WinnersLosersTooltip({ active, payload, label }: any) { if (!active || !payload?.length) { return null; @@ -129,7 +143,11 @@ export default function WinnersLosersWealthDecileSubPage({ output }: Props) { }; return ( - + buildWinnersLosersWealthCsv(output)} + >
{/* Chart area */} diff --git a/app/src/pages/report-output/inequality-impact/InequalityImpactSubPage.tsx b/app/src/pages/report-output/inequality-impact/InequalityImpactSubPage.tsx index 55c959b9f..9b4a809b6 100644 --- a/app/src/pages/report-output/inequality-impact/InequalityImpactSubPage.tsx +++ b/app/src/pages/report-output/inequality-impact/InequalityImpactSubPage.tsx @@ -36,6 +36,28 @@ interface Props { fillHeight?: boolean; } +/** CSV for Inequality impact (Gini, Top 10% share, Top 1% share). */ +export function buildInequalityCsv(output: SocietyWideReportOutput): string[][] { + const { gini, top_10_pct_share: top10, top_1_pct_share: top1 } = output.inequality; + const header = ['Metric', 'Baseline', 'Reform', 'Relative change (%)']; + const rows: string[][] = [header]; + const items: Array<[string, { baseline: number; reform: number }, number]> = [ + ['Gini index', gini, 3], + ['Top 10% share', top10, 4], + ['Top 1% share', top1, 4], + ]; + for (const [label, b, decimals] of items) { + const rel = b.baseline === 0 ? 0 : b.reform / b.baseline - 1; + rows.push([ + label, + b.baseline.toFixed(decimals), + b.reform.toFixed(decimals), + (rel * 100).toFixed(2), + ]); + } + return rows; +} + export default function InequalityImpactSubPage({ output, chartHeight: chartHeightProp, @@ -181,7 +203,11 @@ export default function InequalityImpactSubPage({ } return ( - + buildInequalityCsv(output)} + > {barChart} diff --git a/app/src/pages/report-output/poverty-impact/DeepPovertyImpactByAgeSubPage.tsx b/app/src/pages/report-output/poverty-impact/DeepPovertyImpactByAgeSubPage.tsx index 6d08d434c..51e7a87f6 100644 --- a/app/src/pages/report-output/poverty-impact/DeepPovertyImpactByAgeSubPage.tsx +++ b/app/src/pages/report-output/poverty-impact/DeepPovertyImpactByAgeSubPage.tsx @@ -37,6 +37,29 @@ interface Props { fillHeight?: boolean; } +/** CSV for Deep poverty impact by age. */ +export function buildDeepPovertyByAgeCsv(output: SocietyWideReportOutput): string[][] { + const src = output.poverty.deep_poverty; + const header = ['Group', 'Baseline rate (%)', 'Reform rate (%)', 'Relative change (%)']; + const rows: string[][] = [header]; + const buckets: Array<[string, { baseline: number; reform: number }]> = [ + ['Children', src.child], + ['Working-age adults', src.adult], + ['Seniors', src.senior], + ['All', src.all], + ]; + for (const [label, b] of buckets) { + const rel = b.baseline === 0 ? 0 : b.reform / b.baseline - 1; + rows.push([ + label, + (b.baseline * 100).toFixed(2), + (b.reform * 100).toFixed(2), + (rel * 100).toFixed(2), + ]); + } + return rows; +} + export default function DeepPovertyImpactByAgeSubPage({ output, chartHeight: chartHeightProp, @@ -191,7 +214,11 @@ export default function DeepPovertyImpactByAgeSubPage({ } return ( - + buildDeepPovertyByAgeCsv(output)} + > {barChart} diff --git a/app/src/pages/report-output/poverty-impact/DeepPovertyImpactByGenderSubPage.tsx b/app/src/pages/report-output/poverty-impact/DeepPovertyImpactByGenderSubPage.tsx index 1c50e3627..e5c581184 100644 --- a/app/src/pages/report-output/poverty-impact/DeepPovertyImpactByGenderSubPage.tsx +++ b/app/src/pages/report-output/poverty-impact/DeepPovertyImpactByGenderSubPage.tsx @@ -30,6 +30,7 @@ import { } from '@/utils/chartUtils'; import { formatNumber, formatPercent } from '@/utils/formatters'; import { regionName } from '@/utils/impactChartUtils'; +import { buildPovertyByGenderCsv } from './PovertyImpactByGenderSubPage'; interface Props { output: SocietyWideReportOutput; @@ -193,7 +194,11 @@ export default function DeepPovertyImpactByGenderSubPage({ } return ( - + buildPovertyByGenderCsv(output, true)} + > {barChart} diff --git a/app/src/pages/report-output/poverty-impact/PovertyImpactByAgeSubPage.tsx b/app/src/pages/report-output/poverty-impact/PovertyImpactByAgeSubPage.tsx index f5dbf2f2f..6a57399df 100644 --- a/app/src/pages/report-output/poverty-impact/PovertyImpactByAgeSubPage.tsx +++ b/app/src/pages/report-output/poverty-impact/PovertyImpactByAgeSubPage.tsx @@ -37,6 +37,46 @@ interface Props { fillHeight?: boolean; } +type PovertySource = + | SocietyWideReportOutput['poverty']['poverty'] + | SocietyWideReportOutput['poverty']['deep_poverty']; + +/** + * Shared builder for poverty impact CSVs. + * Columns: Group, Baseline rate (%), Reform rate (%), Relative change (%). + */ +export function buildPovertyImpactCsv( + source: PovertySource, + rows: Array<{ label: string; key: keyof PovertySource }> +): string[][] { + const header = ['Group', 'Baseline rate (%)', 'Reform rate (%)', 'Relative change (%)']; + const out: string[][] = [header]; + for (const { label, key } of rows) { + const bucket = source[key as keyof typeof source] as { baseline: number; reform: number }; + if (!bucket) { + continue; + } + const relChange = bucket.baseline === 0 ? 0 : bucket.reform / bucket.baseline - 1; + out.push([ + label, + (bucket.baseline * 100).toFixed(2), + (bucket.reform * 100).toFixed(2), + (relChange * 100).toFixed(2), + ]); + } + return out; +} + +/** CSV for Poverty impact by age. */ +export function buildPovertyByAgeCsv(output: SocietyWideReportOutput): string[][] { + return buildPovertyImpactCsv(output.poverty.poverty, [ + { label: 'Children', key: 'child' as const }, + { label: 'Working-age adults', key: 'adult' as const }, + { label: 'Seniors', key: 'senior' as const }, + { label: 'All', key: 'all' as const }, + ]); +} + export default function PovertyImpactByAgeSubPage({ output, chartHeight: chartHeightProp, @@ -190,7 +230,11 @@ export default function PovertyImpactByAgeSubPage({ } return ( - + buildPovertyByAgeCsv(output)} + > {barChart} diff --git a/app/src/pages/report-output/poverty-impact/PovertyImpactByGenderSubPage.tsx b/app/src/pages/report-output/poverty-impact/PovertyImpactByGenderSubPage.tsx index ba9a50d14..bb531fb05 100644 --- a/app/src/pages/report-output/poverty-impact/PovertyImpactByGenderSubPage.tsx +++ b/app/src/pages/report-output/poverty-impact/PovertyImpactByGenderSubPage.tsx @@ -31,6 +31,34 @@ import { import { formatNumber, formatPercent } from '@/utils/formatters'; import { regionName } from '@/utils/impactChartUtils'; +/** CSV for Poverty impact by gender. */ +export function buildPovertyByGenderCsv(output: SocietyWideReportOutput, deep = false): string[][] { + const source = deep ? output.poverty.deep_poverty : output.poverty.poverty; + const byGender = deep + ? output.poverty_by_gender?.deep_poverty + : output.poverty_by_gender?.poverty; + const header = ['Gender', 'Baseline rate (%)', 'Reform rate (%)', 'Relative change (%)']; + const rows: string[][] = [header]; + const buckets: Array<[string, { baseline: number; reform: number } | undefined]> = [ + ['Male', byGender?.male], + ['Female', byGender?.female], + ['All', source.all], + ]; + for (const [label, b] of buckets) { + if (!b) { + continue; + } + const rel = b.baseline === 0 ? 0 : b.reform / b.baseline - 1; + rows.push([ + label, + (b.baseline * 100).toFixed(2), + (b.reform * 100).toFixed(2), + (rel * 100).toFixed(2), + ]); + } + return rows; +} + interface Props { output: SocietyWideReportOutput; chartHeight?: number; @@ -193,7 +221,11 @@ export default function PovertyImpactByGenderSubPage({ } return ( - + buildPovertyByGenderCsv(output)} + > {barChart} diff --git a/app/src/pages/report-output/poverty-impact/PovertyImpactByRaceSubPage.tsx b/app/src/pages/report-output/poverty-impact/PovertyImpactByRaceSubPage.tsx index c2953d5f2..e8d8f7a39 100644 --- a/app/src/pages/report-output/poverty-impact/PovertyImpactByRaceSubPage.tsx +++ b/app/src/pages/report-output/poverty-impact/PovertyImpactByRaceSubPage.tsx @@ -36,6 +36,34 @@ interface Props { fillHeight?: boolean; } +/** CSV for Poverty impact by race. Dynamic race keys. */ +export function buildPovertyByRaceCsv(output: SocietyWideReportOutput): string[][] { + type RaceData = Record; + const raceImpact: RaceData = (output.poverty_by_race as any)?.poverty || {}; + const allImpact = output.poverty.poverty; + const header = ['Race', 'Baseline rate (%)', 'Reform rate (%)', 'Relative change (%)']; + const rows: string[][] = [header]; + for (const key of Object.keys(raceImpact).filter((k) => k !== 'all')) { + const b = raceImpact[key]; + const rel = b.baseline === 0 ? 0 : b.reform / b.baseline - 1; + rows.push([ + key.charAt(0).toUpperCase() + key.slice(1), + (b.baseline * 100).toFixed(2), + (b.reform * 100).toFixed(2), + (rel * 100).toFixed(2), + ]); + } + const all = allImpact.all; + const rel = all.baseline === 0 ? 0 : all.reform / all.baseline - 1; + rows.push([ + 'All', + (all.baseline * 100).toFixed(2), + (all.reform * 100).toFixed(2), + (rel * 100).toFixed(2), + ]); + return rows; +} + export default function PovertyImpactByRaceSubPage({ output, chartHeight: chartHeightProp, @@ -189,7 +217,11 @@ export default function PovertyImpactByRaceSubPage({ } return ( - + buildPovertyByRaceCsv(output)} + > {barChart} diff --git a/app/src/tests/unit/components/ChartDownloadMenu.test.tsx b/app/src/tests/unit/components/ChartDownloadMenu.test.tsx new file mode 100644 index 000000000..bea01dc44 --- /dev/null +++ b/app/src/tests/unit/components/ChartDownloadMenu.test.tsx @@ -0,0 +1,116 @@ +import { createRef } from 'react'; +import { render, screen, userEvent } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { ChartDownloadMenu } from '@/components/ChartDownloadMenu'; + +vi.mock('@/utils/chartUtils', () => ({ + downloadChartAsSvg: vi.fn(), + downloadCsv: vi.fn(), +})); + +vi.mock('@/utils/analytics', () => ({ + trackChartSvgDownloaded: vi.fn(), + trackChartCsvDownloaded: vi.fn(), +})); + +describe('ChartDownloadMenu', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given no csvData then renders a single SVG download button', async () => { + const ref = createRef(); + render( +
+ +
+ ); + + expect(screen.getByLabelText(/download as svg/i)).toBeInTheDocument(); + // No dropdown menu trigger should be present. + expect(screen.queryByLabelText(/download chart/i)).not.toBeInTheDocument(); + }); + + test('given csvData then clicking SVG option calls downloadChartAsSvg', async () => { + const user = userEvent.setup(); + const { downloadChartAsSvg } = await import('@/utils/chartUtils'); + const ref = createRef(); + render( +
+ +
+ ); + + await user.click(screen.getByLabelText(/download chart/i)); + await user.click(await screen.findByRole('menuitem', { name: /download as svg/i })); + + expect(downloadChartAsSvg).toHaveBeenCalled(); + }); + + test('given csvData then clicking CSV option calls downloadCsv with derived filename', async () => { + const user = userEvent.setup(); + const { downloadCsv } = await import('@/utils/chartUtils'); + const csvRows = [ + ['Decile', 'Change'], + ['1', '100'], + ]; + const ref = createRef(); + render( +
+ +
+ ); + + await user.click(screen.getByLabelText(/download chart/i)); + await user.click(await screen.findByRole('menuitem', { name: /download data \(csv\)/i })); + + expect(downloadCsv).toHaveBeenCalledWith(csvRows, 'winners.csv'); + }); + + test('given csvData as function then resolves on click', async () => { + const user = userEvent.setup(); + const { downloadCsv } = await import('@/utils/chartUtils'); + const producer = vi.fn(() => [['a'], ['b']] as string[][]); + const ref = createRef(); + render( +
+ +
+ ); + + // Producer shouldn't run until the user actually picks CSV. + expect(producer).not.toHaveBeenCalled(); + + await user.click(screen.getByLabelText(/download chart/i)); + await user.click(await screen.findByRole('menuitem', { name: /download data \(csv\)/i })); + + expect(producer).toHaveBeenCalledTimes(1); + expect(downloadCsv).toHaveBeenLastCalledWith([['a'], ['b']], 'x.csv'); + }); + + test('given analytics wired then SVG and CSV fire distinct events', async () => { + const user = userEvent.setup(); + const { trackChartSvgDownloaded, trackChartCsvDownloaded } = await import( + '@/utils/analytics' + ); + const ref = createRef(); + render( +
+ +
+ ); + + await user.click(screen.getByLabelText(/download chart/i)); + await user.click(await screen.findByRole('menuitem', { name: /download as svg/i })); + expect(trackChartSvgDownloaded).toHaveBeenCalledTimes(1); + expect(trackChartCsvDownloaded).not.toHaveBeenCalled(); + + await user.click(screen.getByLabelText(/download chart/i)); + await user.click(await screen.findByRole('menuitem', { name: /download data \(csv\)/i })); + expect(trackChartCsvDownloaded).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/tests/unit/pages/report-output/budgetary-impact/BudgetaryImpactSubPage.test.tsx b/app/src/tests/unit/pages/report-output/budgetary-impact/BudgetaryImpactSubPage.test.tsx index af2336c1f..95f5a653e 100644 --- a/app/src/tests/unit/pages/report-output/budgetary-impact/BudgetaryImpactSubPage.test.tsx +++ b/app/src/tests/unit/pages/report-output/budgetary-impact/BudgetaryImpactSubPage.test.tsx @@ -67,27 +67,42 @@ describe('BudgetaryImpactSubPage', () => { expect(screen.getByText(/This reform would have no effect on the budget/i)).toBeInTheDocument(); }); - test('given output then renders download SVG button', () => { + test('given output then renders download chart button', () => { // When render(); - // Then - expect(screen.getByLabelText(/download as svg/i)).toBeInTheDocument(); + // Then — the button now opens a menu with SVG + CSV options when csvData is provided + expect(screen.getByLabelText(/download chart/i)).toBeInTheDocument(); }); - test('given user clicks download SVG then calls downloadChartAsSvg', async () => { + test('given user picks SVG from download menu then calls downloadChartAsSvg', async () => { // Given const user = userEvent.setup(); const { downloadChartAsSvg } = await import('@/utils/chartUtils'); render(); - // When - await user.click(screen.getByLabelText(/download as svg/i)); + // When — open the menu, then click the SVG option + await user.click(screen.getByLabelText(/download chart/i)); + await user.click(await screen.findByRole('menuitem', { name: /download as svg/i })); // Then expect(downloadChartAsSvg).toHaveBeenCalled(); }); + test('given user picks CSV from download menu then calls downloadCsv', async () => { + // Given + const user = userEvent.setup(); + const { downloadCsv } = await import('@/utils/chartUtils'); + render(); + + // When + await user.click(screen.getByLabelText(/download chart/i)); + await user.click(await screen.findByRole('menuitem', { name: /download data \(csv\)/i })); + + // Then + expect(downloadCsv).toHaveBeenCalled(); + }); + test('given large positive impact then formats with bn suffix', () => { // When render(); diff --git a/app/src/tests/unit/pages/report-output/reportCsvBuilders.test.ts b/app/src/tests/unit/pages/report-output/reportCsvBuilders.test.ts new file mode 100644 index 000000000..1e9456eaf --- /dev/null +++ b/app/src/tests/unit/pages/report-output/reportCsvBuilders.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, test } from 'vitest'; +import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; +import { buildBudgetaryImpactCsv } from '@/pages/report-output/budgetary-impact/BudgetaryImpactSubPage'; +import { buildDistributionalAbsoluteCsv } from '@/pages/report-output/distributional-impact/DistributionalImpactIncomeAverageSubPage'; +import { buildDistributionalRelativeCsv } from '@/pages/report-output/distributional-impact/DistributionalImpactIncomeRelativeSubPage'; +import { buildWinnersLosersCsv } from '@/pages/report-output/distributional-impact/WinnersLosersIncomeDecileSubPage'; +import { buildInequalityCsv } from '@/pages/report-output/inequality-impact/InequalityImpactSubPage'; +import { buildPovertyByAgeCsv } from '@/pages/report-output/poverty-impact/PovertyImpactByAgeSubPage'; +import { buildPovertyByGenderCsv } from '@/pages/report-output/poverty-impact/PovertyImpactByGenderSubPage'; +import { buildPovertyByRaceCsv } from '@/pages/report-output/poverty-impact/PovertyImpactByRaceSubPage'; + +const asOutput = (partial: object): SocietyWideReportOutput => + partial as SocietyWideReportOutput; + +describe('buildBudgetaryImpactCsv', () => { + const output = asOutput({ + budget: { + budgetary_impact: 5e9, + benefit_spending_impact: -1e9, + state_tax_revenue_impact: 1e9, + tax_revenue_impact: 4e9, + }, + }); + + test('given US output then includes federal and state tax rows in billions', () => { + const rows = buildBudgetaryImpactCsv(output, 'us'); + + expect(rows[0]).toEqual(['Line item', 'Value (billions)']); + expect(rows.find((r) => r[0] === 'Federal tax revenues')?.[1]).toBe('3.000'); + expect(rows.find((r) => r[0] === 'State and local income tax revenues')?.[1]).toBe('1.000'); + expect(rows.find((r) => r[0] === 'Benefit spending')?.[1]).toBe('1.000'); + expect(rows.find((r) => r[0] === 'Net impact')?.[1]).toBe('5.000'); + }); + + test('given UK output then uses generic "Tax revenues" label', () => { + const rows = buildBudgetaryImpactCsv(output, 'uk'); + + expect(rows.some((r) => r[0] === 'Federal tax revenues')).toBe(false); + expect(rows.some((r) => r[0] === 'Tax revenues')).toBe(true); + }); + + test('given zero line item then filters it out', () => { + const rows = buildBudgetaryImpactCsv( + asOutput({ + budget: { + budgetary_impact: 5e9, + benefit_spending_impact: 0, + state_tax_revenue_impact: 0, + tax_revenue_impact: 5e9, + }, + }), + 'us' + ); + + // Header + Federal taxes + Net impact only (State and Benefits filtered). + expect(rows).toHaveLength(3); + expect(rows.some((r) => r[0] === 'Benefit spending')).toBe(false); + expect(rows.some((r) => r[0] === 'State and local income tax revenues')).toBe(false); + }); +}); + +describe('buildDistributionalAbsoluteCsv', () => { + test('given decile averages then emits one row per decile sorted numerically', () => { + const rows = buildDistributionalAbsoluteCsv( + asOutput({ + decile: { + average: { '1': 100, '2': 200, '10': 1000, '3': 300 }, + }, + }) + ); + + expect(rows[0]).toEqual(['Decile', 'Absolute change in household income']); + expect(rows.slice(1).map((r) => r[0])).toEqual(['1', '2', '3', '10']); + expect(rows.find((r) => r[0] === '10')?.[1]).toBe('1000.00'); + }); +}); + +describe('buildDistributionalRelativeCsv', () => { + test('given relative decile changes then converts to percent with 2 decimals', () => { + const rows = buildDistributionalRelativeCsv( + asOutput({ + decile: { + relative: { '1': 0.012345, '2': -0.05 }, + }, + }) + ); + + expect(rows[0]).toEqual(['Decile', 'Relative change (%)']); + expect(rows.find((r) => r[0] === '1')?.[1]).toBe('1.23'); + expect(rows.find((r) => r[0] === '2')?.[1]).toBe('-5.00'); + }); +}); + +describe('buildWinnersLosersCsv', () => { + const makeDecileArray = (value: number) => Array(10).fill(value); + const output = asOutput({ + intra_decile: { + deciles: { + 'Gain more than 5%': makeDecileArray(0.2), + 'Gain less than 5%': makeDecileArray(0.1), + 'No change': makeDecileArray(0.5), + 'Lose less than 5%': makeDecileArray(0.1), + 'Lose more than 5%': makeDecileArray(0.1), + }, + all: { + 'Gain more than 5%': 0.25, + 'Gain less than 5%': 0.15, + 'No change': 0.4, + 'Lose less than 5%': 0.1, + 'Lose more than 5%': 0.1, + }, + }, + }); + + test('given full output then produces 10 decile rows + All row', () => { + const rows = buildWinnersLosersCsv(output); + + // Header + 10 deciles + All row = 12. + expect(rows).toHaveLength(12); + expect(rows[0][0]).toBe('Decile'); + expect(rows.slice(1, 11).map((r) => r[0])).toEqual([ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + ]); + expect(rows[11][0]).toBe('All'); + }); + + test('given values then emits percentages with 2 decimals', () => { + const rows = buildWinnersLosersCsv(output); + const allRow = rows[11]; + + // 'Gain more than 5%' is second column after the "Decile" label. + expect(allRow[1]).toBe('25.00'); + expect(allRow[3]).toBe('40.00'); // No change + }); + + test('given header then uses display labels (Loss not Lose)', () => { + const rows = buildWinnersLosersCsv(output); + + expect(rows[0]).toContain('Loss less than 5% (%)'); + expect(rows[0]).toContain('Loss more than 5% (%)'); + expect(rows[0]).toContain('Gain more than 5% (%)'); + }); +}); + +describe('buildPovertyByAgeCsv', () => { + test('given full poverty data then emits four group rows with relative change', () => { + const rows = buildPovertyByAgeCsv( + asOutput({ + poverty: { + poverty: { + child: { baseline: 0.2, reform: 0.18 }, + adult: { baseline: 0.1, reform: 0.11 }, + senior: { baseline: 0.05, reform: 0.05 }, + all: { baseline: 0.12, reform: 0.11 }, + }, + }, + }) + ); + + expect(rows[0]).toEqual([ + 'Group', + 'Baseline rate (%)', + 'Reform rate (%)', + 'Relative change (%)', + ]); + expect(rows.slice(1).map((r) => r[0])).toEqual([ + 'Children', + 'Working-age adults', + 'Seniors', + 'All', + ]); + + const childRow = rows.find((r) => r[0] === 'Children')!; + // 0.2 → 0.18 = -10% relative. + expect(childRow[1]).toBe('20.00'); + expect(childRow[2]).toBe('18.00'); + expect(childRow[3]).toBe('-10.00'); + }); +}); + +describe('buildPovertyByGenderCsv', () => { + const output = asOutput({ + poverty: { + poverty: { all: { baseline: 0.1, reform: 0.09 } }, + deep_poverty: { all: { baseline: 0.05, reform: 0.04 } }, + }, + poverty_by_gender: { + poverty: { + male: { baseline: 0.08, reform: 0.07 }, + female: { baseline: 0.12, reform: 0.11 }, + }, + deep_poverty: { + male: { baseline: 0.03, reform: 0.025 }, + female: { baseline: 0.07, reform: 0.06 }, + }, + }, + }); + + test('given deep=false then uses regular poverty source', () => { + const rows = buildPovertyByGenderCsv(output, false); + + expect(rows.find((r) => r[0] === 'All')?.[1]).toBe('10.00'); + expect(rows.find((r) => r[0] === 'Male')?.[1]).toBe('8.00'); + }); + + test('given deep=true then uses deep_poverty source', () => { + const rows = buildPovertyByGenderCsv(output, true); + + expect(rows.find((r) => r[0] === 'All')?.[1]).toBe('5.00'); + expect(rows.find((r) => r[0] === 'Male')?.[1]).toBe('3.00'); + }); + + test('given missing gender data then skips those rows', () => { + const minimal = asOutput({ + poverty: { poverty: { all: { baseline: 0.1, reform: 0.09 } } }, + }); + const rows = buildPovertyByGenderCsv(minimal); + + // Header + "All" only (no male/female data in fixture). + expect(rows).toHaveLength(2); + expect(rows[1][0]).toBe('All'); + }); +}); + +describe('buildPovertyByRaceCsv', () => { + test('given dynamic race keys then capitalizes and appends All', () => { + const rows = buildPovertyByRaceCsv( + asOutput({ + poverty: { poverty: { all: { baseline: 0.12, reform: 0.11 } } }, + poverty_by_race: { + poverty: { + white: { baseline: 0.08, reform: 0.075 }, + black: { baseline: 0.2, reform: 0.18 }, + hispanic: { baseline: 0.18, reform: 0.17 }, + all: { baseline: 0.12, reform: 0.11 }, + }, + }, + }) + ); + + const labels = rows.slice(1).map((r) => r[0]); + expect(labels).toContain('White'); + expect(labels).toContain('Black'); + expect(labels).toContain('Hispanic'); + expect(labels).toContain('All'); + expect(labels).not.toContain('all'); // Dynamic 'all' excluded; we add capitalized 'All' from poverty.poverty. + }); +}); + +describe('buildInequalityCsv', () => { + test('given inequality metrics then emits Gini, Top 10%, Top 1% rows', () => { + const rows = buildInequalityCsv( + asOutput({ + inequality: { + gini: { baseline: 0.45, reform: 0.44 }, + top_10_pct_share: { baseline: 0.4, reform: 0.39 }, + top_1_pct_share: { baseline: 0.2, reform: 0.18 }, + }, + }) + ); + + expect(rows[0]).toEqual(['Metric', 'Baseline', 'Reform', 'Relative change (%)']); + expect(rows.slice(1).map((r) => r[0])).toEqual([ + 'Gini index', + 'Top 10% share', + 'Top 1% share', + ]); + + const top1Row = rows.find((r) => r[0] === 'Top 1% share')!; + // 0.2 → 0.18 = -10% relative. + expect(top1Row[3]).toBe('-10.00'); + // Gini shows 3 decimal places, shares show 4. + expect(rows.find((r) => r[0] === 'Gini index')![1]).toBe('0.450'); + expect(top1Row[1]).toBe('0.2000'); + }); +}); diff --git a/app/src/utils/analytics.ts b/app/src/utils/analytics.ts index e3089dbfd..68a551ce9 100644 --- a/app/src/utils/analytics.ts +++ b/app/src/utils/analytics.ts @@ -55,7 +55,12 @@ export function trackPolicyCreated() { trackEvent('policy_created'); } -/** Fires when user downloads CSV data from a chart */ +/** Fires when user downloads a chart as SVG */ +export function trackChartSvgDownloaded() { + trackEvent('chart_svg_downloaded'); +} + +/** Fires when user downloads chart data as CSV */ export function trackChartCsvDownloaded() { trackEvent('chart_csv_downloaded'); }