diff --git a/app/src/api/simulationAssociation.ts b/app/src/api/simulationAssociation.ts index 9ba43293f..0df0b8067 100644 --- a/app/src/api/simulationAssociation.ts +++ b/app/src/api/simulationAssociation.ts @@ -7,8 +7,7 @@ export interface UserSimulationStore { findByUser: (userId: string, countryId?: string) => Promise; findById: (userId: string, simulationId: string) => Promise; update: (userSimulationId: string, updates: Partial) => Promise; - // The below are not yet implemented, but keeping for future use - // delete(userSimulationId: string): Promise; + delete: (userId: string, simulationId: string) => Promise; } export class ApiSimulationStore implements UserSimulationStore { @@ -88,8 +87,6 @@ export class ApiSimulationStore implements UserSimulationStore { ); } - // Not yet implemented, but keeping for future use - /* async delete(userId: string, simulationId: string): Promise { const response = await fetch(`/api/user-simulation-associations/${userId}/${simulationId}`, { method: 'DELETE', @@ -99,7 +96,6 @@ export class ApiSimulationStore implements UserSimulationStore { throw new Error('Failed to delete association'); } } - */ } export class LocalStorageSimulationStore implements UserSimulationStore { @@ -179,14 +175,11 @@ export class LocalStorageSimulationStore implements UserSimulationStore { return updated; } - // Not yet implemented, but keeping for future use - /* async delete(userId: string, simulationId: string): Promise { const simulations = this.getStoredSimulations(); const filtered = simulations.filter( - a => !(a.userId === userId && a.simulationId === simulationId) + (association) => !(association.userId === userId && association.simulationId === simulationId) ); this.setStoredSimulations(filtered); } - */ } diff --git a/app/src/api/societyWideCalculation.ts b/app/src/api/societyWideCalculation.ts index 2b472e25c..0d9501f40 100644 --- a/app/src/api/societyWideCalculation.ts +++ b/app/src/api/societyWideCalculation.ts @@ -1,6 +1,7 @@ import { BASE_URL } from '@/constants'; import { ReportOutputSocietyWideUK } from '@/types/metadata/ReportOutputSocietyWideUK'; import { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; +import type { BudgetWindowReportOutput } from '@/types/report/BudgetWindowReportOutput'; export type SocietyWideReportOutput = ReportOutputSocietyWideUS | ReportOutputSocietyWideUK; @@ -9,6 +10,7 @@ export interface SocietyWideCalculationParams { region: string; // Must include a region; "us" for US nationwide, two-letter state code for US states time_period: string; // Four-digit year dataset?: string; // Optional dataset parameter; defaults to API's default dataset + version?: string; // Optional API/model version parameter } export interface SocietyWideCalculationResponse { @@ -19,23 +21,50 @@ export interface SocietyWideCalculationResponse { error?: string; } -export async function fetchSocietyWideCalculation( - countryId: string, - reformPolicyId: string, - baselinePolicyId: string, - params: SocietyWideCalculationParams -): Promise { +export interface BudgetWindowCalculationParams { + region: string; + start_year: string; + window_size: number; + dataset?: string; + version?: string | null; +} + +export interface BudgetWindowCalculationResponse { + status: 'computing' | 'ok' | 'error'; + result: BudgetWindowReportOutput | null; + progress?: number; + completed_years?: string[]; + computing_years?: string[]; + queued_years?: string[]; + message?: string | null; + error?: string; +} + +export class CalculationRequestError extends Error { + status: number; + body: string; + + constructor(message: string, status: number, body = '') { + super(message); + this.name = 'CalculationRequestError'; + this.status = status; + this.body = body; + } +} + +function buildQueryString(params: T): string { const queryParams = new URLSearchParams(); - Object.entries(params).forEach(([key, value]) => { + Object.entries(params as Record).forEach(([key, value]) => { if (value !== undefined) { queryParams.append(key, String(value)); } }); - const queryString = queryParams.toString(); - const url = `${BASE_URL}/${countryId}/economy/${reformPolicyId}/over/${baselinePolicyId}${queryString ? `?${queryString}` : ''}`; + return queryParams.toString(); +} +async function fetchCalculationResponse(url: string, logPrefix: string): Promise { const response = await fetch(url, { headers: { 'Content-Type': 'application/json', @@ -49,16 +78,43 @@ export async function fetchSocietyWideCalculation( } catch { // ignore } - console.error( - `[fetchSocietyWideCalculation] ${response.status} ${response.statusText}`, - url, + console.error(`${logPrefix} ${response.status} ${response.statusText}`, url, body); + throw new CalculationRequestError( + `Society-wide calculation failed (${response.status}): ${body || response.statusText}`, + response.status, body ); - throw new Error( - `Society-wide calculation failed (${response.status}): ${body || response.statusText}` - ); } - const data = await response.json(); - return data; + return response.json(); +} + +export async function fetchSocietyWideCalculation( + countryId: string, + reformPolicyId: string, + baselinePolicyId: string, + params: SocietyWideCalculationParams +): Promise { + const queryString = buildQueryString(params); + const url = `${BASE_URL}/${countryId}/economy/${reformPolicyId}/over/${baselinePolicyId}${queryString ? `?${queryString}` : ''}`; + + return fetchCalculationResponse( + url, + '[fetchSocietyWideCalculation]' + ); +} + +export async function fetchBudgetWindowSocietyWideCalculation( + countryId: string, + reformPolicyId: string, + baselinePolicyId: string, + params: BudgetWindowCalculationParams +): Promise { + const queryString = buildQueryString(params); + const url = `${BASE_URL}/${countryId}/economy/${reformPolicyId}/over/${baselinePolicyId}/budget-window${queryString ? `?${queryString}` : ''}`; + + return fetchCalculationResponse( + url, + '[fetchBudgetWindowSocietyWideCalculation]' + ); } diff --git a/app/src/components/report/ReportActionButtons.tsx b/app/src/components/report/ReportActionButtons.tsx index a71fb624e..0a0f12407 100644 --- a/app/src/components/report/ReportActionButtons.tsx +++ b/app/src/components/report/ReportActionButtons.tsx @@ -54,19 +54,21 @@ export function ReportActionButtons({ View/edit report - - - - - Reproduce in Python - + {onReproduce && ( + + + + + Reproduce in Python + + )} ); diff --git a/app/src/hooks/useAggregatedCalculationStatus.ts b/app/src/hooks/useAggregatedCalculationStatus.ts index a84f7fb17..a87ca1abd 100644 --- a/app/src/hooks/useAggregatedCalculationStatus.ts +++ b/app/src/hooks/useAggregatedCalculationStatus.ts @@ -199,6 +199,7 @@ export function useAggregatedCalculationStatus( return new QueryObserver(queryClient, { queryKey, + enabled: false, }); }); diff --git a/app/src/hooks/useCalculationStatus.ts b/app/src/hooks/useCalculationStatus.ts index e8134b4f1..1ef90306b 100644 --- a/app/src/hooks/useCalculationStatus.ts +++ b/app/src/hooks/useCalculationStatus.ts @@ -61,6 +61,7 @@ function useSingleCalculationStatus(calcId: string, targetType: 'report' | 'simu // Create observer that watches this query key const observer = new QueryObserver(queryClient, { queryKey, + enabled: false, }); // Subscribe to cache updates diff --git a/app/src/hooks/useCreateReport.ts b/app/src/hooks/useCreateReport.ts index 7a7b4d5ef..e7aa40051 100644 --- a/app/src/hooks/useCreateReport.ts +++ b/app/src/hooks/useCreateReport.ts @@ -8,6 +8,7 @@ import { Geography } from '@/types/ingredients/Geography'; import { Household } from '@/types/ingredients/Household'; import { Simulation } from '@/types/ingredients/Simulation'; import { ReportCreationPayload } from '@/types/payloads'; +import { isBudgetWindowReportYear } from '@/utils/reportTiming'; interface CreateReportAndBeginCalculationParams { countryId: (typeof countryIds)[number]; @@ -96,6 +97,10 @@ export function useCreateReport(reportLabel?: string) { const household = populations?.household1; const geography = populations?.geography1; + if (isBudgetWindowReportYear(report.year)) { + return; + } + if (!simulation1) { console.warn('[useCreateReport] No simulation1 provided, cannot start calculation'); return; diff --git a/app/src/pages/ReportOutput.page.tsx b/app/src/pages/ReportOutput.page.tsx index cd7bbe900..2ce2c54d7 100644 --- a/app/src/pages/ReportOutput.page.tsx +++ b/app/src/pages/ReportOutput.page.tsx @@ -15,12 +15,17 @@ import { useSharedReportData } from '@/hooks/useSharedReportData'; import { useUserReportById } from '@/hooks/useUserReports'; import { formatReportTimestamp } from '@/utils/dateUtils'; import { resolveDefaultReportOutputSubpage } from '@/utils/reportOutputSubpage'; +import { isBudgetWindowReportYear } from '@/utils/reportTiming'; import { buildSharePath, createShareData, extractShareDataFromUrl, getShareDataUserReportId, } from '@/utils/shareUtils'; +import { + BUDGET_WINDOW_SUBPAGE, + resolveBudgetWindowSubpage, +} from './report-output/budget-window/budgetWindowUtils'; import { HouseholdReportOutput } from './report-output/HouseholdReportOutput'; import ReportOutputLayout from './report-output/ReportOutputLayout'; import { SocietyWideReportOutput } from './report-output/SocietyWideReportOutput'; @@ -112,9 +117,17 @@ export default function ReportOutputPage({ : simulations?.[0]?.populationType === 'geography' ? 'societyWide' : undefined; + const isBudgetWindowReport = + outputType === 'societyWide' && isBudgetWindowReportYear(report?.year || ''); // Active subpage and view from URL params - const activeTab = resolveDefaultReportOutputSubpage(outputType, subpage); + const defaultResolvedSubpage = resolveDefaultReportOutputSubpage(outputType, subpage, { + societyWideDefaultSubpage: isBudgetWindowReport ? BUDGET_WINDOW_SUBPAGE : undefined, + }); + const activeTab = + isBudgetWindowReport && outputType === 'societyWide' + ? resolveBudgetWindowSubpage(defaultResolvedSubpage) + : defaultResolvedSubpage; const activeView = view || ''; // Format the report creation timestamp using the current country's locale @@ -185,6 +198,10 @@ export default function ReportOutputPage({ // Handle reproduce button click - navigate to reproduce in Python content const handleReproduce = () => { + if (isBudgetWindowReport) { + return; + } + const id = isSharedView ? shareDataUserReportId : userReportId; if (id) { const basePath = `/${countryId}/report-output/${id}/reproduce`; @@ -301,7 +318,7 @@ export default function ReportOutputPage({ onShare={handleShare} onSave={handleSave} onView={!isSharedView ? handleView : undefined} - onReproduce={handleReproduce} + onReproduce={!isBudgetWindowReport ? handleReproduce : undefined} > ( diff --git a/app/src/pages/report-output/BudgetWindowSubPage.tsx b/app/src/pages/report-output/BudgetWindowSubPage.tsx new file mode 100644 index 000000000..1baeb2952 --- /dev/null +++ b/app/src/pages/report-output/BudgetWindowSubPage.tsx @@ -0,0 +1,269 @@ +import { + IconCalendarStats, + IconScale, + IconTrendingDown, + IconTrendingUp, +} from '@tabler/icons-react'; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { ChartContainer } from '@/components/ChartContainer'; +import MetricCard from '@/components/report/MetricCard'; +import { Group, Stack, Text } from '@/components/ui'; +import { + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { colors, spacing, typography } from '@/designTokens'; +import { countryIds } from '@/libs/countries'; +import type { + BudgetWindowAnnualImpact, + BudgetWindowReportOutput, +} from '@/types/report/BudgetWindowReportOutput'; +import { formatCurrencyAbbr } from '@/utils/formatters'; +import { getBudgetWindowMetricLabels } from './budget-window/budgetWindowUtils'; + +interface BudgetWindowSubPageProps { + output: BudgetWindowReportOutput; + countryId: (typeof countryIds)[number]; +} + +function formatImpactValue(value: number, countryId: (typeof countryIds)[number]): string { + return formatCurrencyAbbr(value, countryId, { maximumFractionDigits: 1 }); +} + +function MetricSummaryCard({ + icon, + label, + value, + countryId, +}: { + icon: React.ReactNode; + label: string; + value: number; + countryId: (typeof countryIds)[number]; +}) { + const trend = value === 0 ? 'neutral' : value > 0 ? 'positive' : 'negative'; + + return ( +
+ +
+ {icon} +
+ {label} +
+ + {formatImpactValue(value, countryId)} + +
+ ); +} + +function BudgetWindowTooltip({ + active, + payload, + label, + countryId, +}: { + active?: boolean; + payload?: Array<{ payload?: BudgetWindowAnnualImpact }>; + label?: string; + countryId: (typeof countryIds)[number]; +}) { + const annualImpact = payload?.[0]?.payload; + + if (!active || !annualImpact) { + return null; + } + + return ( +
+ {label} + + Net impact: {formatImpactValue(annualImpact.budgetaryImpact, countryId)} + + + Tax revenue: {formatImpactValue(annualImpact.federalTaxRevenueImpact, countryId)} + + {annualImpact.stateTaxRevenueImpact !== 0 && ( + + State tax: {formatImpactValue(annualImpact.stateTaxRevenueImpact, countryId)} + + )} + + Benefit spending: {formatImpactValue(-annualImpact.benefitSpendingImpact, countryId)} + +
+ ); +} + +export function BudgetWindowSubPage({ output, countryId }: BudgetWindowSubPageProps) { + const metricLabels = getBudgetWindowMetricLabels(countryId); + const showStateTaxColumn = + countryId === 'us' || output.annualImpacts.some((impact) => impact.stateTaxRevenueImpact !== 0); + const chartData = output.annualImpacts.map((impact) => ({ + ...impact, + impactInBillions: impact.budgetaryImpact / 1e9, + })); + const totalImpact = output.totals.budgetaryImpact; + + return ( + + + 0 ? 'positive' : 'negative'} + hero + /> + + Budget-window mode aggregates fiscal impacts year by year. Distributional, poverty, and + inequality analysis remain single-year. + + + +
+ } + label={metricLabels.federalTax} + value={output.totals.federalTaxRevenueImpact} + countryId={countryId} + /> + } + label={metricLabels.benefits} + value={-output.totals.benefitSpendingImpact} + countryId={countryId} + /> + } + label={metricLabels.netBudget} + value={output.totals.budgetaryImpact} + countryId={countryId} + /> +
+ + +
+ + + + + + formatCurrencyAbbr(value * 1e9, countryId, { maximumFractionDigits: 0 }) + } + /> + } /> + + {chartData.map((impact) => ( + = 0 ? colors.primary[600] : colors.gray[500]} + /> + ))} + + + +
+
+ + + + + Annual detail + + + + + Year + {metricLabels.federalTax} + {showStateTaxColumn && ( + {metricLabels.stateTax} + )} + {metricLabels.benefits} + {metricLabels.netBudget} + + + + {output.annualImpacts.map((impact) => ( + + {impact.year} + + {formatImpactValue(impact.federalTaxRevenueImpact, countryId)} + + {showStateTaxColumn && ( + + {formatImpactValue(impact.stateTaxRevenueImpact, countryId)} + + )} + + {formatImpactValue(-impact.benefitSpendingImpact, countryId)} + + + {formatImpactValue(impact.budgetaryImpact, countryId)} + + + ))} + + + + Total + + {formatImpactValue(output.totals.federalTaxRevenueImpact, countryId)} + + {showStateTaxColumn && ( + + {formatImpactValue(output.totals.stateTaxRevenueImpact, countryId)} + + )} + + {formatImpactValue(-output.totals.benefitSpendingImpact, countryId)} + + + {formatImpactValue(output.totals.budgetaryImpact, countryId)} + + + +
+
+
+ ); +} diff --git a/app/src/pages/report-output/ReportOutputLayout.tsx b/app/src/pages/report-output/ReportOutputLayout.tsx index c2105b415..c5af00e75 100644 --- a/app/src/pages/report-output/ReportOutputLayout.tsx +++ b/app/src/pages/report-output/ReportOutputLayout.tsx @@ -5,6 +5,7 @@ import { Container, Group, Stack, Text, Title } from '@/components/ui'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { getReportTimingDisplay } from '@/utils/reportTiming'; interface ReportOutputLayoutProps { reportId: string; @@ -43,6 +44,7 @@ export default function ReportOutputLayout({ }: ReportOutputLayoutProps) { const countryId = useCurrentCountry(); const nav = useAppNavigate(); + const timingDisplay = reportYear ? getReportTimingDisplay(reportYear) : null; return ( @@ -90,11 +92,11 @@ export default function ReportOutputLayout({ {/* Timestamp and year */} - {reportYear && ( + {timingDisplay && ( <> - Year: {reportYear} + {timingDisplay.label}: {timingDisplay.value} • diff --git a/app/src/pages/report-output/SocietyWideReportOutput.tsx b/app/src/pages/report-output/SocietyWideReportOutput.tsx index 8bc36867d..1eecdae48 100644 --- a/app/src/pages/report-output/SocietyWideReportOutput.tsx +++ b/app/src/pages/report-output/SocietyWideReportOutput.tsx @@ -14,12 +14,20 @@ import type { UserPolicy } from '@/types/ingredients/UserPolicy'; import type { UserGeographyPopulation } from '@/types/ingredients/UserPopulation'; import type { UserSimulation } from '@/types/ingredients/UserSimulation'; import { resolveDefaultReportOutputSubpage } from '@/utils/reportOutputSubpage'; +import { isBudgetWindowReportYear } from '@/utils/reportTiming'; import { convertPoliciesToV1Format } from '@/utils/reproducibilityCode'; import { getDisplayStatus } from '@/utils/statusMapping'; +import { + BUDGET_WINDOW_SUBPAGE, + isBudgetWindowReportOutput, + resolveBudgetWindowSubpage, +} from './budget-window/budgetWindowUtils'; +import { BudgetWindowSubPage } from './BudgetWindowSubPage'; import { ComparativeAnalysisPage } from './ComparativeAnalysisPage'; import { ConstituencySubPage } from './ConstituencySubPage'; import DynamicsSubPage from './DynamicsSubPage'; import ErrorPage from './ErrorPage'; +import { useBudgetWindowCalculation } from './hooks/useBudgetWindowCalculation'; import LoadingPage from './LoadingPage'; import { LocalAuthoritySubPage } from './LocalAuthoritySubPage'; import MigrationSubPage from './MigrationSubPage'; @@ -157,7 +165,13 @@ export function SocietyWideReportOutput({ policies, geographies, }: SocietyWideReportOutputProps) { - const normalizedSubpage = resolveDefaultReportOutputSubpage('societyWide', subpage); + const isBudgetWindow = isBudgetWindowReportYear(report?.year || ''); + const normalizedSubpage = resolveDefaultReportOutputSubpage('societyWide', subpage, { + societyWideDefaultSubpage: isBudgetWindow ? BUDGET_WINDOW_SUBPAGE : undefined, + }); + const effectiveSubpage = isBudgetWindow + ? resolveBudgetWindowSubpage(normalizedSubpage) + : normalizedSubpage; // Read datasets from metadata for the reproduce tab const datasets = useSelector((state: RootState) => state.metadata.economyOptions?.datasets); @@ -171,6 +185,14 @@ export function SocietyWideReportOutput({ hasCalcStatus, message: progressMessage, } = useReportProgressDisplay(report?.id); + const shouldRunBudgetWindowCalculation = + isBudgetWindow && effectiveSubpage !== 'population' && !!report && !!simulations?.[0]; + + useBudgetWindowCalculation({ + enabled: shouldRunBudgetWindowCalculation, + report, + simulations, + }); // Build calculation config for auto-start const calcConfigs = useMemo(() => { @@ -214,7 +236,7 @@ export function SocietyWideReportOutput({ // Auto-start calculation if needed (direct URL loads) useStartCalculationOnLoad({ - enabled: !!report && !!calcConfigs, + enabled: !!report && !!calcConfigs && !isBudgetWindow, configs: calcConfigs || [], isComplete: calcStatus.isComplete, }); @@ -229,7 +251,7 @@ export function SocietyWideReportOutput({ } // 2. Data loaded - render input-only tabs immediately (no calculation needed) - const InputTabRenderer = INPUT_ONLY_TABS[normalizedSubpage]; + const InputTabRenderer = INPUT_ONLY_TABS[effectiveSubpage]; if (InputTabRenderer) { return InputTabRenderer({ report, @@ -241,6 +263,10 @@ export function SocietyWideReportOutput({ }); } + if (isBudgetWindow && isBudgetWindowReportOutput(report.output)) { + return ; + } + // 3. Show loading if calculation status is still initializing if (calcStatus.isInitializing) { return ; @@ -261,9 +287,13 @@ export function SocietyWideReportOutput({ // 6. Calculation complete - render output tabs if (calcStatus.isComplete && calcStatus.result) { + if (isBudgetWindow && isBudgetWindowReportOutput(calcStatus.result)) { + return ; + } + const output = calcStatus.result as SocietyWideOutput; - const OutputTabRenderer = OUTPUT_TABS[normalizedSubpage]; + const OutputTabRenderer = OUTPUT_TABS[effectiveSubpage]; if (OutputTabRenderer) { return OutputTabRenderer({ report, @@ -278,5 +308,9 @@ export function SocietyWideReportOutput({ } // 7. Unknown tab or no output + if (isBudgetWindow && isBudgetWindowReportOutput(report.output)) { + return ; + } + return ; } diff --git a/app/src/pages/report-output/budget-window/budgetWindowUtils.ts b/app/src/pages/report-output/budget-window/budgetWindowUtils.ts new file mode 100644 index 000000000..2aa3a6ad6 --- /dev/null +++ b/app/src/pages/report-output/budget-window/budgetWindowUtils.ts @@ -0,0 +1,76 @@ +import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; +import { countryIds } from '@/libs/countries'; +import type { + BudgetWindowAnnualImpact, + BudgetWindowReportOutput, +} from '@/types/report/BudgetWindowReportOutput'; + +export const BUDGET_WINDOW_SUBPAGE = 'budget-window'; +export const BUDGET_WINDOW_SUPPORTED_SUBPAGES = [BUDGET_WINDOW_SUBPAGE, 'population'] as const; + +export function resolveBudgetWindowSubpage(subpage: string): string { + return BUDGET_WINDOW_SUPPORTED_SUBPAGES.includes( + subpage as (typeof BUDGET_WINDOW_SUPPORTED_SUBPAGES)[number] + ) + ? subpage + : BUDGET_WINDOW_SUBPAGE; +} + +export function isBudgetWindowReportOutput(output: unknown): output is BudgetWindowReportOutput { + return ( + typeof output === 'object' && + output !== null && + 'kind' in output && + (output as BudgetWindowReportOutput).kind === 'budgetWindow' + ); +} + +export function extractBudgetWindowAnnualImpact( + year: string, + output: SocietyWideReportOutput +): BudgetWindowAnnualImpact { + const stateTaxRevenueImpact = output.budget.state_tax_revenue_impact; + const taxRevenueImpact = output.budget.tax_revenue_impact; + + return { + year, + taxRevenueImpact, + federalTaxRevenueImpact: taxRevenueImpact - stateTaxRevenueImpact, + stateTaxRevenueImpact, + benefitSpendingImpact: output.budget.benefit_spending_impact, + budgetaryImpact: output.budget.budgetary_impact, + }; +} + +export function sumBudgetWindowAnnualImpacts( + annualImpacts: BudgetWindowAnnualImpact[] +): BudgetWindowAnnualImpact { + return annualImpacts.reduce( + (totals, annualImpact) => ({ + year: 'Total', + taxRevenueImpact: totals.taxRevenueImpact + annualImpact.taxRevenueImpact, + federalTaxRevenueImpact: + totals.federalTaxRevenueImpact + annualImpact.federalTaxRevenueImpact, + stateTaxRevenueImpact: totals.stateTaxRevenueImpact + annualImpact.stateTaxRevenueImpact, + benefitSpendingImpact: totals.benefitSpendingImpact + annualImpact.benefitSpendingImpact, + budgetaryImpact: totals.budgetaryImpact + annualImpact.budgetaryImpact, + }), + { + year: 'Total', + taxRevenueImpact: 0, + federalTaxRevenueImpact: 0, + stateTaxRevenueImpact: 0, + benefitSpendingImpact: 0, + budgetaryImpact: 0, + } + ); +} + +export function getBudgetWindowMetricLabels(countryId: (typeof countryIds)[number]) { + return { + federalTax: countryId === 'us' ? 'Federal tax revenue' : 'Tax revenue', + stateTax: countryId === 'us' ? 'State and local tax revenue' : 'State tax revenue', + benefits: 'Benefit spending', + netBudget: 'Net budget impact', + }; +} diff --git a/app/src/pages/report-output/hooks/useBudgetWindowCalculation.ts b/app/src/pages/report-output/hooks/useBudgetWindowCalculation.ts new file mode 100644 index 000000000..55902c6ba --- /dev/null +++ b/app/src/pages/report-output/hooks/useBudgetWindowCalculation.ts @@ -0,0 +1,308 @@ +import { useContext, useEffect, useRef } from 'react'; +import { QueryClientContext } from '@tanstack/react-query'; +import { markReportCompleted } from '@/api/report'; +import { + CalculationRequestError, + fetchBudgetWindowSocietyWideCalculation, + fetchSocietyWideCalculation, +} from '@/api/societyWideCalculation'; +import { calculationKeys, reportKeys } from '@/libs/queryKeys'; +import type { CalcStatus } from '@/types/calculation'; +import type { Report } from '@/types/ingredients/Report'; +import type { Simulation } from '@/types/ingredients/Simulation'; +import type { BudgetWindowReportOutput } from '@/types/report/BudgetWindowReportOutput'; +import { parseReportTiming } from '@/utils/reportTiming'; +import { + extractBudgetWindowAnnualImpact, + isBudgetWindowReportOutput, + sumBudgetWindowAnnualImpacts, +} from '../budget-window/budgetWindowUtils'; + +const POLL_INTERVAL_MS = 1000; + +interface UseBudgetWindowCalculationParams { + enabled: boolean; + report?: Report; + simulations?: Simulation[]; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function shouldUseSequentialFallback(error: unknown): boolean { + return error instanceof CalculationRequestError && [404, 405].includes(error.status); +} + +function formatBatchErrorMessage(response: { + error?: string; + message?: string | null; + completed_years?: string[]; + computing_years?: string[]; + queued_years?: string[]; +}): string { + const baseMessage = response.error || response.message || 'Budget-window calculation failed'; + const details = [ + response.completed_years?.length ? `completed: ${response.completed_years.join(', ')}` : null, + response.computing_years?.length ? `running: ${response.computing_years.join(', ')}` : null, + response.queued_years?.length ? `queued: ${response.queued_years.join(', ')}` : null, + ] + .filter(Boolean) + .join(' | '); + + return details ? `${baseMessage} (${details})` : baseMessage; +} + +export function useBudgetWindowCalculation({ + enabled, + report, + simulations, +}: UseBudgetWindowCalculationParams): void { + const queryClient = useContext(QueryClientContext); + const runIdRef = useRef(0); + const reportId = report?.id; + const reportYear = report?.year; + const reportCountryId = report?.countryId; + const reportOutput = report?.output; + const simulationIdsKey = report?.simulationIds?.join('|') || ''; + const reportApiVersion = report?.apiVersion ?? null; + const baselinePolicyId = simulations?.[0]?.policyId; + const reformPolicyId = simulations?.[1]?.policyId || baselinePolicyId; + const region = simulations?.[0]?.populationId || reportCountryId; + + useEffect(() => { + if ( + !enabled || + !queryClient || + !report || + !reportId || + !reportYear || + !reportCountryId || + !baselinePolicyId + ) { + return; + } + + const queryKey = calculationKeys.byReportId(reportId); + const existing = queryClient.getQueryData(queryKey); + + if (existing?.status === 'complete') { + return; + } + + if (isBudgetWindowReportOutput(reportOutput)) { + queryClient.setQueryData(queryKey, { + status: 'complete', + result: reportOutput, + metadata: { + calcId: reportId, + calcType: 'societyWide', + targetType: 'report', + startedAt: Date.now(), + }, + }); + return; + } + + runIdRef.current += 1; + const currentRunId = runIdRef.current; + const timing = parseReportTiming(reportYear, reportCountryId); + const years = Array.from({ length: timing.windowSize }, (_, index) => + String(Number.parseInt(timing.startYear, 10) + index) + ); + const effectiveReformPolicyId = reformPolicyId || baselinePolicyId; + const effectiveRegion = region || reportCountryId; + + const metadata = { + calcId: reportId, + calcType: 'societyWide' as const, + targetType: 'report' as const, + startedAt: Date.now(), + }; + + const setStatus = (status: CalcStatus) => { + if (runIdRef.current !== currentRunId) { + return; + } + + queryClient.setQueryData(queryKey, status); + }; + + void (async () => { + try { + const calculateSequentially = async (): Promise => { + const annualImpacts: BudgetWindowReportOutput['annualImpacts'] = []; + + for (let index = 0; index < years.length; index += 1) { + const year = years[index]; + const stepNumber = index + 1; + + setStatus({ + status: 'pending', + progress: Math.round((index / years.length) * 100), + message: `Scoring ${year} (${stepNumber} of ${years.length})...`, + metadata, + }); + + while (true) { + const response = await fetchSocietyWideCalculation( + reportCountryId, + effectiveReformPolicyId, + baselinePolicyId, + { + region: effectiveRegion, + time_period: year, + version: reportApiVersion ?? undefined, + } + ); + + if (runIdRef.current !== currentRunId) { + throw new Error('Budget-window calculation cancelled'); + } + + if (response.status === 'ok' && response.result) { + annualImpacts.push(extractBudgetWindowAnnualImpact(year, response.result)); + break; + } + + if (response.status === 'error') { + throw new Error(response.error || `Budget-window calculation failed for ${year}`); + } + + setStatus({ + status: 'pending', + progress: Math.round(((index + 0.5) / years.length) * 100), + message: `Scoring ${year} (${stepNumber} of ${years.length})...`, + queuePosition: response.queue_position, + metadata, + }); + + await sleep(POLL_INTERVAL_MS); + } + } + + return { + kind: 'budgetWindow', + startYear: timing.startYear, + endYear: timing.endYear, + windowSize: timing.windowSize, + annualImpacts, + totals: sumBudgetWindowAnnualImpacts(annualImpacts), + }; + }; + + const calculateWithBatchEndpoint = async (): Promise => { + while (true) { + const response = await fetchBudgetWindowSocietyWideCalculation( + reportCountryId, + effectiveReformPolicyId, + baselinePolicyId, + { + region: effectiveRegion, + start_year: timing.startYear, + window_size: timing.windowSize, + version: reportApiVersion ?? undefined, + } + ); + + if (runIdRef.current !== currentRunId) { + throw new Error('Budget-window calculation cancelled'); + } + + if (response.status === 'ok' && response.result) { + return response.result; + } + + if (response.status === 'error') { + throw new Error(formatBatchErrorMessage(response)); + } + + setStatus({ + status: 'pending', + progress: response.progress, + message: + response.message || + `Scoring budget window (${response.completed_years?.length || 0} of ${years.length} complete)...`, + metadata, + }); + + await sleep(POLL_INTERVAL_MS); + } + }; + + let result: BudgetWindowReportOutput; + + try { + result = await calculateWithBatchEndpoint(); + } catch (error) { + if (!shouldUseSequentialFallback(error)) { + throw error; + } + + result = await calculateSequentially(); + } + + if (runIdRef.current !== currentRunId) { + return; + } + + const simulationIds = simulationIdsKey ? simulationIdsKey.split('|') : []; + + const completedReport: Report = { + id: reportId, + countryId: reportCountryId, + year: reportYear, + apiVersion: reportApiVersion, + simulationIds, + status: 'complete', + output: result, + }; + + await markReportCompleted(reportCountryId, reportId, completedReport); + + if (runIdRef.current !== currentRunId) { + return; + } + + queryClient.setQueryData(reportKeys.byId(reportId), completedReport); + setStatus({ + status: 'complete', + result, + metadata, + }); + } catch (error) { + if (runIdRef.current !== currentRunId) { + return; + } + + setStatus({ + status: 'error', + error: { + code: 'BUDGET_WINDOW_CALC_ERROR', + message: error instanceof Error ? error.message : 'Budget-window calculation failed', + retryable: true, + }, + metadata, + }); + } + })(); + + return () => { + if (runIdRef.current === currentRunId) { + runIdRef.current += 1; + } + }; + }, [ + baselinePolicyId, + enabled, + queryClient, + reformPolicyId, + region, + reportApiVersion, + reportCountryId, + reportId, + reportOutput, + reportYear, + simulationIdsKey, + ]); +} diff --git a/app/src/pages/report-output/reproduce-in-python/PolicyReproducibility.tsx b/app/src/pages/report-output/reproduce-in-python/PolicyReproducibility.tsx index 907f568a4..f2cb19936 100644 --- a/app/src/pages/report-output/reproduce-in-python/PolicyReproducibility.tsx +++ b/app/src/pages/report-output/reproduce-in-python/PolicyReproducibility.tsx @@ -18,6 +18,7 @@ import { import { colors, spacing } from '@/designTokens'; import { useReportYear } from '@/hooks/useReportYear'; import { trackPythonCodeCopied } from '@/utils/analytics'; +import { parseReportTiming } from '@/utils/reportTiming'; import { getColabLink, getReproducibilityCodeBlock } from '@/utils/reproducibilityCode'; interface PolicyData { @@ -42,7 +43,7 @@ export default function PolicyReproducibility({ }: PolicyReproducibilityProps) { const [copied, setCopied] = useState(false); const reportYear = useReportYear(); - const timePeriod = reportYear ? parseInt(reportYear, 10) : 2024; + const timePeriod = Number.parseInt(parseReportTiming(reportYear || '2024').startYear, 10) || 2024; const codeLines = getReproducibilityCodeBlock( 'policy', diff --git a/app/src/pages/reportBuilder/ModifyReportPage.tsx b/app/src/pages/reportBuilder/ModifyReportPage.tsx index d9e852261..e8200ac06 100644 --- a/app/src/pages/reportBuilder/ModifyReportPage.tsx +++ b/app/src/pages/reportBuilder/ModifyReportPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { IconNewSection, IconPencil, IconStatusChange, IconX } from '@tabler/icons-react'; import { Button, @@ -16,6 +16,7 @@ import { useAppNavigate } from '@/contexts/NavigationContext'; import { spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { getReportOutputPath } from '@/utils/reportRouting'; +import { getDefaultBudgetWindowYears } from '@/utils/reportTiming'; import { ReportBuilderShell, SimulationBlockFull } from './components'; import { useModifyReportSubmission } from './hooks/useModifyReportSubmission'; import { useReportBuilderState } from './hooks/useReportBuilderState'; @@ -40,7 +41,13 @@ export default function ModifyReportPage({ userReportId }: { userReportId?: stri }); const { handleSaveAsNew, handleReplace, isSavingNew, isReplacing } = useModifyReportSubmission({ - reportState: reportState ?? { label: null, year: '', simulations: [] }, + reportState: reportState ?? { + label: null, + analysisMode: 'single-year', + budgetWindowYears: String(getDefaultBudgetWindowYears(countryId)), + year: '', + simulations: [], + }, countryId, existingUserReportId: userReportId ?? '', onSuccess: (resultUserReportId) => { @@ -53,6 +60,15 @@ export default function ModifyReportPage({ userReportId }: { userReportId?: stri const [showSameNameWarning, setShowSameNameWarning] = useState(false); const isEitherSubmitting = isSavingNew || isReplacing; + const isGeographySelected = !!reportState?.simulations[0]?.population?.geography?.id; + + useEffect(() => { + if (!reportState || isGeographySelected || reportState.analysisMode !== 'budget-window') { + return; + } + + setReportState((prev) => (prev ? { ...prev, analysisMode: 'single-year' } : prev)); + }, [isGeographySelected, reportState, setReportState]); // Same-name guard for "Save as new report" const handleSaveAsNewClick = useCallback(() => { diff --git a/app/src/pages/reportBuilder/ReportBuilderPage.tsx b/app/src/pages/reportBuilder/ReportBuilderPage.tsx index d47692879..501a5a438 100644 --- a/app/src/pages/reportBuilder/ReportBuilderPage.tsx +++ b/app/src/pages/reportBuilder/ReportBuilderPage.tsx @@ -15,6 +15,7 @@ import { useAppNavigate } from '@/contexts/NavigationContext'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { initializeSimulationState } from '@/utils/pathwayState/initializeSimulationState'; import { getReportOutputPath } from '@/utils/reportRouting'; +import { getDefaultBudgetWindowYears } from '@/utils/reportTiming'; import { ReportBuilderShell, SimulationBlockFull } from './components'; import { getSamplePopulations } from './constants'; import { createCurrentLawPolicy } from './currentLaw'; @@ -33,6 +34,8 @@ export default function ReportBuilderPage() { const [reportState, setReportState] = useState({ label: null, + analysisMode: 'single-year', + budgetWindowYears: String(getDefaultBudgetWindowYears(countryId)), year: CURRENT_YEAR, simulations: [initialSim], }); @@ -86,6 +89,14 @@ export default function ReportBuilderPage() { } }, [reportState.id, isGeographySelected, setReportState]); + useEffect(() => { + if (isGeographySelected || reportState.analysisMode !== 'budget-window') { + return; + } + + setReportState((prev) => ({ ...prev, analysisMode: 'single-year' })); + }, [isGeographySelected, reportState.analysisMode]); + // Top bar actions (setup mode: just "Run") const topBarActions: TopBarAction[] = useMemo( () => [ diff --git a/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx b/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx index f641a77db..65619fcbe 100644 --- a/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx +++ b/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx @@ -5,12 +5,13 @@ * directly into TopBar's flex layout via display: contents. */ -import React, { useLayoutEffect, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useState } from 'react'; import { IconCheck, IconFileDescription, IconPencil } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import { Button, Input, + SegmentedControl, Select, SelectContent, SelectItem, @@ -20,7 +21,14 @@ import { } from '@/components/ui'; import { CURRENT_YEAR } from '@/constants'; import { colors, spacing, typography } from '@/designTokens'; +import { countryIds } from '@/libs/countries'; import { getTaxYears } from '@/libs/metadataUtils'; +import { + clampBudgetWindowYears, + getBudgetWindowOptions, + getDefaultBudgetWindowYears, + getEffectiveReportAnalysisMode, +} from '@/utils/reportTiming'; import { FONT_SIZES } from '../constants'; import type { ReportBuilderState } from '../types'; @@ -48,6 +56,14 @@ export function ReportMetaPanel({ reportState, setReportState, isReadOnly }: Rep const [labelInput, setLabelInput] = useState(''); const [inputWidth, setInputWidth] = useState(null); const measureRef = React.useRef(null); + const countryId = (reportState.simulations[0]?.countryId || 'us') as (typeof countryIds)[number]; + const isGeographyReport = !!reportState.simulations[0]?.population?.geography?.id; + const budgetWindowOptions = getBudgetWindowOptions(reportState.year, yearOptions, countryId); + const canUseBudgetWindow = isGeographyReport && budgetWindowOptions.length > 0; + const effectiveAnalysisMode = getEffectiveReportAnalysisMode( + reportState.analysisMode, + canUseBudgetWindow ? budgetWindowOptions : [] + ); const handleLabelSubmit = () => { setReportState((prev) => ({ ...prev, label: labelInput || 'Untitled report' })); @@ -64,6 +80,16 @@ export function ReportMetaPanel({ reportState, setReportState, isReadOnly }: Rep } }, [labelInput, isEditingLabel]); + useEffect(() => { + if (reportState.analysisMode !== effectiveAnalysisMode) { + setReportState((prev) => + prev.analysisMode === effectiveAnalysisMode + ? prev + : { ...prev, analysisMode: effectiveAnalysisMode } + ); + } + }, [effectiveAnalysisMode, reportState.analysisMode, setReportState]); + return (
{/* Icon segment */} @@ -194,6 +220,58 @@ export function ReportMetaPanel({ reportState, setReportState, isReadOnly }: Rep )}
+
+ + Timing + + { + const analysisMode = value as ReportBuilderState['analysisMode']; + const normalizedWindowYears = clampBudgetWindowYears( + reportState.budgetWindowYears || String(getDefaultBudgetWindowYears(countryId)), + budgetWindowOptions, + countryId + ); + + setReportState((prev) => ({ + ...prev, + analysisMode, + budgetWindowYears: + analysisMode === 'budget-window' + ? normalizedWindowYears + : prev.budgetWindowYears || normalizedWindowYears, + })); + }} + options={[ + { label: 'Single year', value: 'single-year' }, + { + label: 'Budget window', + value: 'budget-window', + disabled: !canUseBudgetWindow, + }, + ]} + size="xs" + className="tw:min-w-[180px]" + /> +
+ {/* Year segment */}
- Year + {effectiveAnalysisMode === 'budget-window' ? 'Start year' : 'Year'}
+ + {effectiveAnalysisMode === 'budget-window' && ( +
+ + Window + + +
+ )} ); } diff --git a/app/src/pages/reportBuilder/hooks/useModifyReportSubmission.ts b/app/src/pages/reportBuilder/hooks/useModifyReportSubmission.ts index c5e784963..51cb7a082 100644 --- a/app/src/pages/reportBuilder/hooks/useModifyReportSubmission.ts +++ b/app/src/pages/reportBuilder/hooks/useModifyReportSubmission.ts @@ -19,10 +19,17 @@ import { LocalStorageSimulationStore } from '@/api/simulationAssociation'; import { MOCK_USER_ID } from '@/constants'; import { useCalcOrchestratorManager } from '@/contexts/CalcOrchestratorContext'; import { useUpdateReportAssociation } from '@/hooks/useUserReportAssociations'; +import { getTaxYears } from '@/libs/metadataUtils'; import { reportAssociationKeys, reportKeys } from '@/libs/queryKeys'; import { RootState } from '@/store'; import { Report } from '@/types/ingredients/Report'; import { Simulation } from '@/types/ingredients/Simulation'; +import { + getBudgetWindowOptions, + getEffectiveReportAnalysisMode, + isBudgetWindowReportYear, + serializeReportTiming, +} from '@/utils/reportTiming'; import { toApiPolicyId } from '../currentLaw'; import { ReportBuilderState } from '../types'; @@ -40,6 +47,47 @@ interface UseModifyReportSubmissionReturn { isReplacing: boolean; } +async function persistSimulationAssociations( + associations: Array<{ + simulationId: string; + countryId: 'us' | 'uk'; + label?: string; + }> +): Promise { + const simulationStore = new LocalStorageSimulationStore(); + const createdSimulationIds: string[] = []; + + try { + for (const association of associations) { + await simulationStore.create({ + userId: MOCK_USER_ID, + simulationId: association.simulationId, + countryId: association.countryId, + label: association.label, + isCreated: true, + }); + createdSimulationIds.push(association.simulationId); + } + } catch (error) { + await Promise.allSettled( + createdSimulationIds.map((simulationId) => simulationStore.delete(MOCK_USER_ID, simulationId)) + ); + console.error('[useModifyReportSubmission] Failed to store simulation associations:', error); + } +} + +async function deleteSimulationAssociations(simulationIds: string[]): Promise { + if (simulationIds.length === 0) { + return; + } + + const simulationStore = new LocalStorageSimulationStore(); + + await Promise.allSettled( + simulationIds.map((simulationId) => simulationStore.delete(MOCK_USER_ID, simulationId)) + ); +} + export function useModifyReportSubmission({ reportState, countryId, @@ -47,11 +95,22 @@ export function useModifyReportSubmission({ onSuccess, }: UseModifyReportSubmissionArgs): UseModifyReportSubmissionReturn { const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId); + const yearOptions = useSelector(getTaxYears); const manager = useCalcOrchestratorManager(); const updateReportAssociation = useUpdateReportAssociation(); const queryClient = useQueryClient(); const [isSavingNew, setIsSavingNew] = useState(false); const [isReplacing, setIsReplacing] = useState(false); + const isGeographyReport = !!reportState.simulations[0]?.population?.geography?.id; + const availableBudgetWindowOptions = getBudgetWindowOptions( + reportState.year, + yearOptions, + countryId + ); + const effectiveAnalysisMode = getEffectiveReportAnalysisMode( + reportState.analysisMode, + isGeographyReport ? availableBudgetWindowOptions : [] + ); /** * Shared logic: create simulations via API and build the report payload. @@ -60,6 +119,11 @@ export function useModifyReportSubmission({ const createSimulationsAndReport = useCallback(async () => { const simulationIds: string[] = []; const simulations: (Simulation | null)[] = []; + const simulationAssociations: Array<{ + simulationId: string; + countryId: 'us' | 'uk'; + label?: string; + }> = []; for (const simState of reportState.simulations) { const policyId = simState.policy?.id @@ -95,15 +159,10 @@ export function useModifyReportSubmission({ const result = await createSimulation(countryId, payload); const simulationId = result.result.simulation_id; simulationIds.push(simulationId); - - // Create UserSimulation association in localStorage so sharing works - const simulationStore = new LocalStorageSimulationStore(); - await simulationStore.create({ - userId: MOCK_USER_ID, + simulationAssociations.push({ simulationId, countryId, label: simState.label ?? undefined, - isCreated: true, }); simulations.push({ @@ -126,13 +185,17 @@ export function useModifyReportSubmission({ const reportPayload = ReportAdapter.toCreationPayload({ countryId, - year: reportState.year, + year: serializeReportTiming({ + analysisMode: effectiveAnalysisMode, + startYear: reportState.year, + budgetWindowYears: reportState.budgetWindowYears, + }), simulationIds, apiVersion: null, } as Report); - return { simulationIds, simulations, reportPayload }; - }, [reportState, countryId, currentLawId]); + return { simulationIds, simulations, simulationAssociations, reportPayload }; + }, [countryId, currentLawId, effectiveAnalysisMode, reportState]); /** * Shared logic: start calculation after a report is created. @@ -140,6 +203,10 @@ export function useModifyReportSubmission({ */ const startCalculation = useCallback( async (report: Report, simulations: (Simulation | null)[]) => { + if (isBudgetWindowReportYear(report.year)) { + return; + } + const simulation1 = simulations[0]; if (!simulation1) { return; @@ -205,7 +272,8 @@ export function useModifyReportSubmission({ setIsSavingNew(true); try { - const { simulations, reportPayload } = await createSimulationsAndReport(); + const { simulations, simulationAssociations, reportPayload } = + await createSimulationsAndReport(); const result = await createReportAndAssociateWithUser({ countryId, @@ -214,6 +282,8 @@ export function useModifyReportSubmission({ label: label || undefined, }); + await persistSimulationAssociations(simulationAssociations); + queryClient.invalidateQueries({ queryKey: reportKeys.all }); queryClient.invalidateQueries({ queryKey: reportAssociationKeys.all }); @@ -246,7 +316,11 @@ export function useModifyReportSubmission({ setIsReplacing(true); try { - const { simulations, reportPayload } = await createSimulationsAndReport(); + const previousSimulationIds = reportState.simulations + .map((simulation) => simulation.id) + .filter((simulationId): simulationId is string => !!simulationId); + const { simulations, simulationAssociations, reportPayload } = + await createSimulationsAndReport(); const reportMetadata = await createBaseReport(countryId, reportPayload); const report = ReportAdapter.fromMetadata(reportMetadata); @@ -256,6 +330,9 @@ export function useModifyReportSubmission({ updates: { reportId: String(report.id) }, }); + await persistSimulationAssociations(simulationAssociations); + await deleteSimulationAssociations(previousSimulationIds); + queryClient.invalidateQueries({ queryKey: reportKeys.all }); queryClient.invalidateQueries({ queryKey: reportAssociationKeys.all }); diff --git a/app/src/pages/reportBuilder/hooks/useReportSubmission.ts b/app/src/pages/reportBuilder/hooks/useReportSubmission.ts index 699962f2b..e8995fabe 100644 --- a/app/src/pages/reportBuilder/hooks/useReportSubmission.ts +++ b/app/src/pages/reportBuilder/hooks/useReportSubmission.ts @@ -17,11 +17,17 @@ import { createSimulation } from '@/api/simulation'; import { LocalStorageSimulationStore } from '@/api/simulationAssociation'; import { MOCK_USER_ID } from '@/constants'; import { useCreateReport } from '@/hooks/useCreateReport'; +import { getTaxYears } from '@/libs/metadataUtils'; import { RootState } from '@/store'; import { Report } from '@/types/ingredients/Report'; import { Simulation } from '@/types/ingredients/Simulation'; import { SimulationStateProps } from '@/types/pathwayState'; import { trackReportStarted } from '@/utils/analytics'; +import { + getBudgetWindowOptions, + getEffectiveReportAnalysisMode, + serializeReportTiming, +} from '@/utils/reportTiming'; import { toApiPolicyId } from '../currentLaw'; import { ReportBuilderState } from '../types'; @@ -37,6 +43,35 @@ interface UseReportSubmissionReturn { isReportConfigured: boolean; } +async function persistSimulationAssociations( + associations: Array<{ + simulationId: string; + countryId: 'us' | 'uk'; + label?: string; + }> +): Promise { + const simulationStore = new LocalStorageSimulationStore(); + const createdSimulationIds: string[] = []; + + try { + for (const association of associations) { + await simulationStore.create({ + userId: MOCK_USER_ID, + simulationId: association.simulationId, + countryId: association.countryId, + label: association.label, + isCreated: true, + }); + createdSimulationIds.push(association.simulationId); + } + } catch (error) { + await Promise.allSettled( + createdSimulationIds.map((simulationId) => simulationStore.delete(MOCK_USER_ID, simulationId)) + ); + console.error('[useReportSubmission] Failed to store simulation associations:', error); + } +} + function convertToSimulation( simState: SimulationStateProps, simulationId: string, @@ -83,8 +118,19 @@ export function useReportSubmission({ onSuccess, }: UseReportSubmissionArgs): UseReportSubmissionReturn { const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId); + const yearOptions = useSelector(getTaxYears); const [isSubmitting, setIsSubmitting] = useState(false); const { createReport } = useCreateReport(reportState.label || undefined); + const isGeographyReport = !!reportState.simulations[0]?.population?.geography?.id; + const availableBudgetWindowOptions = getBudgetWindowOptions( + reportState.year, + yearOptions, + countryId + ); + const effectiveAnalysisMode = getEffectiveReportAnalysisMode( + reportState.analysisMode, + isGeographyReport ? availableBudgetWindowOptions : [] + ); const isReportConfigured = reportState.simulations.every( (sim) => !!sim.policy.id && !!(sim.population.household?.id || sim.population.geography?.id) @@ -101,6 +147,11 @@ export function useReportSubmission({ try { const simulationIds: string[] = []; const simulations: (Simulation | null)[] = []; + const simulationAssociations: Array<{ + simulationId: string; + countryId: 'us' | 'uk'; + label?: string; + }> = []; for (const simState of reportState.simulations) { const policyId = simState.policy?.id @@ -138,15 +189,10 @@ export function useReportSubmission({ const result = await createSimulation(countryId, payload); const simulationId = result.result.simulation_id; simulationIds.push(simulationId); - - // Create UserSimulation association in localStorage so sharing works - const simulationStore = new LocalStorageSimulationStore(); - await simulationStore.create({ - userId: MOCK_USER_ID, + simulationAssociations.push({ simulationId, countryId, label: simState.label ?? undefined, - isCreated: true, }); const simulation = convertToSimulation(simState, simulationId, countryId, currentLawId); @@ -161,7 +207,11 @@ export function useReportSubmission({ const reportData: Partial = { countryId, - year: reportState.year, + year: serializeReportTiming({ + analysisMode: effectiveAnalysisMode, + startYear: reportState.year, + budgetWindowYears: reportState.budgetWindowYears, + }), simulationIds, apiVersion: null, }; @@ -193,6 +243,8 @@ export function useReportSubmission({ }, } ); + + await persistSimulationAssociations(simulationAssociations); } catch (error) { console.error('[useReportSubmission] Error running report:', error); setIsSubmitting(false); @@ -204,6 +256,7 @@ export function useReportSubmission({ countryId, currentLawId, createReport, + effectiveAnalysisMode, onSuccess, ]); diff --git a/app/src/pages/reportBuilder/types.ts b/app/src/pages/reportBuilder/types.ts index 12f10f491..a17a4ba8e 100644 --- a/app/src/pages/reportBuilder/types.ts +++ b/app/src/pages/reportBuilder/types.ts @@ -3,6 +3,7 @@ */ import { ReactNode } from 'react'; import { PopulationStateProps, SimulationStateProps } from '@/types/pathwayState'; +import type { ReportAnalysisMode } from '@/utils/reportTiming'; // ============================================================================ // CORE STATE TYPES @@ -11,6 +12,8 @@ import { PopulationStateProps, SimulationStateProps } from '@/types/pathwayState export interface ReportBuilderState { id?: string; label: string | null; + analysisMode: ReportAnalysisMode; + budgetWindowYears: string; year: string; simulations: SimulationStateProps[]; } diff --git a/app/src/pages/reportBuilder/utils/hydrateReportBuilderState.ts b/app/src/pages/reportBuilder/utils/hydrateReportBuilderState.ts index c9eae5eda..e2ba617da 100644 --- a/app/src/pages/reportBuilder/utils/hydrateReportBuilderState.ts +++ b/app/src/pages/reportBuilder/utils/hydrateReportBuilderState.ts @@ -11,6 +11,7 @@ import type { import type { UserReport } from '@/types/ingredients/UserReport'; import type { UserSimulation } from '@/types/ingredients/UserSimulation'; import type { SimulationStateProps } from '@/types/pathwayState'; +import { getDefaultBudgetWindowYears, parseReportTiming } from '@/utils/reportTiming'; import { CURRENT_LAW_LABEL, fromApiPolicyId, isCurrentLaw } from '../currentLaw'; import type { ReportBuilderState } from '../types'; @@ -40,6 +41,7 @@ export function hydrateReportBuilderState({ userHouseholds, currentLawId, }: HydrateArgs): ReportBuilderState { + const timing = parseReportTiming(report.year, report.countryId); const hydratedSimulations: SimulationStateProps[] = simulations.map((sim, index) => { // Find the user simulation label const userSim = userSimulations?.find((us) => us.simulationId === sim.id); @@ -96,7 +98,12 @@ export function hydrateReportBuilderState({ return { id: userReport.id, label: userReport.label || null, - year: report.year || '', + analysisMode: timing.analysisMode, + budgetWindowYears: + timing.analysisMode === 'budget-window' + ? String(timing.windowSize) + : String(getDefaultBudgetWindowYears(report.countryId)), + year: timing.startYear || '', simulations: hydratedSimulations, }; } diff --git a/app/src/tests/fixtures/api/societyWideMocks.ts b/app/src/tests/fixtures/api/societyWideMocks.ts index cc97abd8e..8a11f63d5 100644 --- a/app/src/tests/fixtures/api/societyWideMocks.ts +++ b/app/src/tests/fixtures/api/societyWideMocks.ts @@ -1,6 +1,10 @@ import { vi } from 'vitest'; -import { SocietyWideCalculationResponse } from '@/api/societyWideCalculation'; +import { + BudgetWindowCalculationResponse, + SocietyWideCalculationResponse, +} from '@/api/societyWideCalculation'; import { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; +import type { BudgetWindowReportOutput } from '@/types/report/BudgetWindowReportOutput'; // Test IDs and constants export const TEST_COUNTRIES = { @@ -1210,6 +1214,70 @@ export const mockErrorCalculationResponse: SocietyWideCalculationResponse = { error: 'Calculation failed due to invalid parameters', }; +export const mockBudgetWindowResult: BudgetWindowReportOutput = { + kind: 'budgetWindow', + startYear: '2026', + endYear: '2028', + windowSize: 3, + annualImpacts: [ + { + year: '2026', + taxRevenueImpact: 100, + federalTaxRevenueImpact: 80, + stateTaxRevenueImpact: 20, + benefitSpendingImpact: -10, + budgetaryImpact: 90, + }, + { + year: '2027', + taxRevenueImpact: 120, + federalTaxRevenueImpact: 90, + stateTaxRevenueImpact: 30, + benefitSpendingImpact: -20, + budgetaryImpact: 100, + }, + { + year: '2028', + taxRevenueImpact: 140, + federalTaxRevenueImpact: 100, + stateTaxRevenueImpact: 40, + benefitSpendingImpact: -30, + budgetaryImpact: 110, + }, + ], + totals: { + year: 'Total', + taxRevenueImpact: 360, + federalTaxRevenueImpact: 270, + stateTaxRevenueImpact: 90, + benefitSpendingImpact: -60, + budgetaryImpact: 300, + }, +}; + +export const mockBudgetWindowPendingResponse: BudgetWindowCalculationResponse = { + status: 'computing', + result: null, + progress: 30, + completed_years: ['2026'], + computing_years: ['2027', '2028'], + queued_years: ['2029'], + message: 'Scoring 2027, 2028 (1 of 4 complete)...', +}; + +export const mockBudgetWindowCompletedResponse: BudgetWindowCalculationResponse = { + status: 'ok', + result: mockBudgetWindowResult, + progress: 100, +}; + +export const mockBudgetWindowErrorResponse: BudgetWindowCalculationResponse = { + status: 'error', + result: null, + error: 'Budget-window calculation failed', + message: 'Budget-window calculation failed', +}; + // Network error export const mockNetworkError = new Error(ERROR_MESSAGES.NETWORK_ERROR); diff --git a/app/src/tests/fixtures/pages/reportBuilder/useReportSubmissionMocks.ts b/app/src/tests/fixtures/pages/reportBuilder/useReportSubmissionMocks.ts index 985390c1d..5b74bd324 100644 --- a/app/src/tests/fixtures/pages/reportBuilder/useReportSubmissionMocks.ts +++ b/app/src/tests/fixtures/pages/reportBuilder/useReportSubmissionMocks.ts @@ -26,6 +26,10 @@ export const TEST_LABELS = { } as const; export const CURRENT_LAW_ID = 0; +export const TEST_TIME_PERIODS = Array.from({ length: 10 }, (_, index) => { + const year = String(2026 + index); + return { name: year, label: year }; +}); // Mock API responses export const mockCreateSimulationResponse = (simulationId: string) => ({ @@ -35,6 +39,8 @@ export const mockCreateSimulationResponse = (simulationId: string) => ({ // Mock ReportBuilderState for a single-simulation report export const mockSingleSimReportState: ReportBuilderState = { label: TEST_LABELS.REPORT, + analysisMode: 'single-year', + budgetWindowYears: '10', year: '2026', simulations: [ { @@ -59,6 +65,8 @@ export const mockSingleSimReportState: ReportBuilderState = { // Mock ReportBuilderState for a two-simulation report export const mockTwoSimReportState: ReportBuilderState = { label: TEST_LABELS.REPORT, + analysisMode: 'single-year', + budgetWindowYears: '10', year: '2026', simulations: [ { @@ -109,7 +117,7 @@ export function createTestStore(currentLawId: number = CURRENT_LAW_ID) { parameters: {}, entities: {}, variableModules: {}, - economyOptions: { region: [], time_period: [], datasets: [] }, + economyOptions: { region: [], time_period: TEST_TIME_PERIODS, datasets: [] }, currentLawId, basicInputs: [], modelledPolicies: { core: {}, filtered: {} }, @@ -123,12 +131,14 @@ export function createTestStore(currentLawId: number = CURRENT_LAW_ID) { // Mock functions export const mockCreateSimulationFn = vi.fn(); export const mockLocalStorageCreateFn = vi.fn(); +export const mockLocalStorageDeleteFn = vi.fn(); export const mockCreateReportFn = vi.fn(); export const mockOnSuccess = vi.fn(); export function setupDefaultMocks() { mockCreateSimulationFn.mockReset(); mockLocalStorageCreateFn.mockReset(); + mockLocalStorageDeleteFn.mockReset(); mockCreateReportFn.mockReset(); mockOnSuccess.mockReset(); @@ -150,6 +160,7 @@ export function setupDefaultMocks() { countryId: 'us', isCreated: true, }); + mockLocalStorageDeleteFn.mockResolvedValue(undefined); mockCreateReportFn.mockImplementation((_args: any, callbacks: any) => { callbacks?.onSuccess?.({ userReport: { id: 'user-report-new' } }); diff --git a/app/src/tests/unit/api/societyWideCalculation.test.ts b/app/src/tests/unit/api/societyWideCalculation.test.ts index d35b5a108..c644d85ae 100644 --- a/app/src/tests/unit/api/societyWideCalculation.test.ts +++ b/app/src/tests/unit/api/societyWideCalculation.test.ts @@ -1,9 +1,14 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { fetchSocietyWideCalculation } from '@/api/societyWideCalculation'; +import { + fetchBudgetWindowSocietyWideCalculation, + fetchSocietyWideCalculation, +} from '@/api/societyWideCalculation'; import { BASE_URL, CURRENT_YEAR } from '@/constants'; import { ERROR_MESSAGES, HTTP_STATUS, + mockBudgetWindowCompletedResponse, + mockBudgetWindowPendingResponse, mockCompletedResponse, mockErrorCalculationResponse, mockErrorResponse, @@ -290,4 +295,59 @@ describe('societyWide API', () => { ); }); }); + + describe('fetchBudgetWindowSocietyWideCalculation', () => { + test('given valid parameters then fetches the budget-window calculation successfully', async () => { + const countryId = TEST_COUNTRIES.US; + const reformPolicyId = TEST_POLICY_IDS.REFORM; + const baselinePolicyId = TEST_POLICY_IDS.BASELINE; + const params = { + region: 'us', + start_year: '2026', + window_size: 3, + version: '1.0.0', + }; + const mockResponse = mockSuccessResponse(mockBudgetWindowCompletedResponse); + (global.fetch as any).mockResolvedValue(mockResponse); + + const result = await fetchBudgetWindowSocietyWideCalculation( + countryId, + reformPolicyId, + baselinePolicyId, + params + ); + + expect(global.fetch).toHaveBeenCalledWith( + `${BASE_URL}/${countryId}/economy/${reformPolicyId}/over/${baselinePolicyId}/budget-window?region=us&start_year=2026&window_size=3&version=1.0.0`, + { + headers: { + 'Content-Type': 'application/json', + }, + } + ); + expect(result).toEqual(mockBudgetWindowCompletedResponse); + }); + + test('given computing status then returns progress metadata', async () => { + const countryId = TEST_COUNTRIES.US; + const reformPolicyId = TEST_POLICY_IDS.REFORM; + const baselinePolicyId = TEST_POLICY_IDS.BASELINE; + const params = { region: 'us', start_year: '2026', window_size: 4 }; + const mockResponse = mockSuccessResponse(mockBudgetWindowPendingResponse); + (global.fetch as any).mockResolvedValue(mockResponse); + + const result = await fetchBudgetWindowSocietyWideCalculation( + countryId, + reformPolicyId, + baselinePolicyId, + params + ); + + expect(result.status).toBe('computing'); + expect(result.progress).toBe(30); + expect(result.completed_years).toEqual(['2026']); + expect(result.computing_years).toEqual(['2027', '2028']); + expect(result.queued_years).toEqual(['2029']); + }); + }); }); diff --git a/app/src/tests/unit/components/report/ReportActionButtons.test.tsx b/app/src/tests/unit/components/report/ReportActionButtons.test.tsx index 93e54f072..8337297a6 100644 --- a/app/src/tests/unit/components/report/ReportActionButtons.test.tsx +++ b/app/src/tests/unit/components/report/ReportActionButtons.test.tsx @@ -70,6 +70,12 @@ describe('ReportActionButtons', () => { expect(handleReproduce).toHaveBeenCalledOnce(); }); + test('given no onReproduce callback then does not render the reproduce button', () => { + render(); + + expect(screen.queryByRole('button', { name: /reproduce in python/i })).not.toBeInTheDocument(); + }); + test('given onView callback then calls it when view clicked', async () => { // Given const user = userEvent.setup(); diff --git a/app/src/tests/unit/hooks/useCreateReport.test.tsx b/app/src/tests/unit/hooks/useCreateReport.test.tsx index 977d01491..976243888 100644 --- a/app/src/tests/unit/hooks/useCreateReport.test.tsx +++ b/app/src/tests/unit/hooks/useCreateReport.test.tsx @@ -332,6 +332,42 @@ describe('useCreateReport', () => { }); }); + test('given a budget-window report when creating report then skips orchestrator startup', async () => { + (createReportAndAssociateWithUser as any).mockResolvedValue({ + report: { + ...mockReport, + year: '2026-2035', + }, + userReport: mockUserReportAssociation, + metadata: { + baseReportId: TEST_REPORT_ID_STRING, + userReportId: TEST_USER_REPORT_ID, + countryId: TEST_COUNTRY_ID, + }, + }); + + const { result } = renderHook(() => useCreateReport(TEST_LABEL), { wrapper }); + + await result.current.createReport({ + countryId: TEST_COUNTRY_ID, + payload: { + ...mockReportCreationPayload, + year: '2026-2035', + }, + simulations: { + simulation1: mockSocietyWideSimulation, + simulation2: { ...mockSocietyWideSimulation, policyId: 'policy-3' }, + }, + populations: { + geography1: mockNationalGeography, + }, + }); + + await waitFor(() => { + expect(mockStartCalculation).not.toHaveBeenCalled(); + }); + }); + test('given no simulation1 data then still creates report but does not start calculations', async () => { // When const { result } = renderHook(() => useCreateReport(TEST_LABEL), { wrapper }); diff --git a/app/src/tests/unit/pages/report-output/SocietyWideReportOutput.test.tsx b/app/src/tests/unit/pages/report-output/SocietyWideReportOutput.test.tsx index 90c5d3b66..7ded5aab0 100644 --- a/app/src/tests/unit/pages/report-output/SocietyWideReportOutput.test.tsx +++ b/app/src/tests/unit/pages/report-output/SocietyWideReportOutput.test.tsx @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { useCalculationStatus } from '@/hooks/useCalculationStatus'; import { useReportProgressDisplay } from '@/hooks/useReportProgressDisplay'; import { useStartCalculationOnLoad } from '@/hooks/useStartCalculationOnLoad'; +import { useBudgetWindowCalculation } from '@/pages/report-output/hooks/useBudgetWindowCalculation'; import { SocietyWideReportOutput } from '@/pages/report-output/SocietyWideReportOutput'; import { MOCK_CALC_STATUS_COMPLETE, @@ -23,6 +24,7 @@ import { vi.mock('@/hooks/useCalculationStatus'); vi.mock('@/hooks/useReportProgressDisplay'); vi.mock('@/hooks/useStartCalculationOnLoad'); +vi.mock('@/pages/report-output/hooks/useBudgetWindowCalculation'); // Mock subpage components with inline mocks to avoid hoisting issues vi.mock('@/pages/report-output/LoadingPage', () => ({ default: vi.fn(({ message }: { message?: string; progress?: number }) => ( @@ -60,9 +62,36 @@ vi.mock('@/pages/report-output/DynamicsSubPage', () => ({ default: vi.fn(() =>
Dynamics
), })); +vi.mock('@/pages/report-output/BudgetWindowSubPage', () => ({ + BudgetWindowSubPage: vi.fn(() =>
Budget Window
), +})); + const mockUseCalculationStatus = useCalculationStatus as ReturnType; const mockUseReportProgressDisplay = useReportProgressDisplay as ReturnType; const mockUseStartCalculationOnLoad = useStartCalculationOnLoad as ReturnType; +const mockUseBudgetWindowCalculation = useBudgetWindowCalculation as ReturnType; + +const MOCK_BUDGET_WINDOW_OUTPUT = { + kind: 'budgetWindow' as const, + startYear: '2026', + endYear: '2035', + windowSize: 10, + annualImpacts: [], + totals: { + year: 'Total', + taxRevenueImpact: 0, + federalTaxRevenueImpact: 0, + stateTaxRevenueImpact: 0, + benefitSpendingImpact: 0, + budgetaryImpact: 0, + }, +}; + +const MOCK_BUDGET_WINDOW_REPORT = { + ...MOCK_REPORT, + year: '2026-2035', + output: MOCK_BUDGET_WINDOW_OUTPUT, +}; describe('SocietyWideReportOutput', () => { beforeEach(() => { @@ -73,6 +102,7 @@ describe('SocietyWideReportOutput', () => { message: undefined, }); mockUseStartCalculationOnLoad.mockReturnValue(undefined); + mockUseBudgetWindowCalculation.mockReturnValue(undefined); }); test('given no report then shows error message', () => { @@ -320,4 +350,43 @@ describe('SocietyWideReportOutput', () => { }) ); }); + + test('given budget-window report and unsupported subpage then shows budget-window page', () => { + mockUseCalculationStatus.mockReturnValue(MOCK_CALC_STATUS_COMPLETE); + + render( + + ); + + expect(screen.getByTestId('budget-window-page')).toBeInTheDocument(); + expect(screen.queryByTestId('policy-page')).not.toBeInTheDocument(); + }); + + test('given budget-window report and population subpage then shows population page', () => { + mockUseCalculationStatus.mockReturnValue(MOCK_CALC_STATUS_COMPLETE); + + render( + + ); + + expect(screen.getByTestId('population-page')).toBeInTheDocument(); + expect(mockUseBudgetWindowCalculation).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + }) + ); + }); }); diff --git a/app/src/tests/unit/pages/report-output/reproduce-in-python/PolicyReproducibility.test.tsx b/app/src/tests/unit/pages/report-output/reproduce-in-python/PolicyReproducibility.test.tsx index 864cc348b..611f52b8d 100644 --- a/app/src/tests/unit/pages/report-output/reproduce-in-python/PolicyReproducibility.test.tsx +++ b/app/src/tests/unit/pages/report-output/reproduce-in-python/PolicyReproducibility.test.tsx @@ -5,18 +5,20 @@ import { DEFAULT_POLICY_REPRODUCIBILITY_PROPS, EXPECTED_CODE_SNIPPETS, EXPECTED_TEXT, - MOCK_REPORT_YEAR, UK_POLICY_REPRODUCIBILITY_PROPS, } from '@/tests/fixtures/pages/report-output/reproduce-in-python/reproducibilityMocks'; +const mockReportYear = vi.hoisted(() => ({ value: '2024' })); + // Mock the useReportYear hook vi.mock('@/hooks/useReportYear', () => ({ - useReportYear: () => MOCK_REPORT_YEAR, + useReportYear: () => mockReportYear.value, })); describe('PolicyReproducibility', () => { beforeEach(() => { vi.clearAllMocks(); + mockReportYear.value = '2024'; }); describe('rendering', () => { @@ -150,7 +152,15 @@ describe('PolicyReproducibility', () => { // Then // The year from the mock (2024) should appear in the period parameter - expect(screen.getByText(new RegExp(`period=${MOCK_REPORT_YEAR}`))).toBeInTheDocument(); + expect(screen.getByText(new RegExp(`period=${mockReportYear.value}`))).toBeInTheDocument(); + }); + + test('given budget-window year context then uses the start year in calculation', () => { + mockReportYear.value = '2026-2035'; + + render(); + + expect(screen.getByText(/period=2026/)).toBeInTheDocument(); }); }); }); diff --git a/app/src/tests/unit/pages/reportBuilder/hooks/useModifyReportSubmission.test.tsx b/app/src/tests/unit/pages/reportBuilder/hooks/useModifyReportSubmission.test.tsx index 571c57ff3..e8f791408 100644 --- a/app/src/tests/unit/pages/reportBuilder/hooks/useModifyReportSubmission.test.tsx +++ b/app/src/tests/unit/pages/reportBuilder/hooks/useModifyReportSubmission.test.tsx @@ -4,12 +4,14 @@ import { renderHook, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { createReportAndAssociateWithUser } from '@/api/report'; import { useModifyReportSubmission } from '@/pages/reportBuilder/hooks/useModifyReportSubmission'; import { createTestStore, CURRENT_LAW_ID, mockCreateSimulationFn, mockLocalStorageCreateFn, + mockLocalStorageDeleteFn, mockOnSuccess, mockTwoSimReportState, setupDefaultMocks, @@ -25,6 +27,7 @@ vi.mock('@/api/simulation', () => ({ vi.mock('@/api/simulationAssociation', () => ({ LocalStorageSimulationStore: vi.fn().mockImplementation(() => ({ create: mockLocalStorageCreateFn, + delete: mockLocalStorageDeleteFn, })), })); @@ -169,6 +172,38 @@ describe('useModifyReportSubmission', () => { isCreated: true, }); }); + + test('given invalid budget-window timing when saving as new then falls back to the single start year', async () => { + const invalidBudgetWindowState = { + ...mockTwoSimReportState, + analysisMode: 'budget-window' as const, + budgetWindowYears: '10', + year: '2035', + }; + + const { result } = renderHook( + () => + useModifyReportSubmission({ + reportState: invalidBudgetWindowState, + countryId: 'us', + existingUserReportId: EXISTING_USER_REPORT_ID, + onSuccess: mockOnSuccess, + }), + { wrapper } + ); + + await result.current.handleSaveAsNew('New Report Label'); + + await waitFor(() => { + expect(createReportAndAssociateWithUser).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + year: '2035', + }), + }) + ); + }); + }); }); describe('localStorage association creation via handleReplace', () => { @@ -231,5 +266,41 @@ describe('useModifyReportSubmission', () => { expect(mockOnSuccess).toHaveBeenCalledWith(EXISTING_USER_REPORT_ID); }); }); + + test('given replacing a hydrated report then deletes previous local associations', async () => { + const hydratedReportState = { + ...mockTwoSimReportState, + simulations: [ + { + ...mockTwoSimReportState.simulations[0], + id: 'old-sim-1', + }, + { + ...mockTwoSimReportState.simulations[1], + id: 'old-sim-2', + }, + ], + }; + + const { result } = renderHook( + () => + useModifyReportSubmission({ + reportState: hydratedReportState as any, + countryId: 'us', + existingUserReportId: EXISTING_USER_REPORT_ID, + onSuccess: mockOnSuccess, + }), + { wrapper } + ); + + await result.current.handleReplace(); + + await waitFor(() => { + expect(mockLocalStorageDeleteFn).toHaveBeenCalledTimes(2); + }); + + expect(mockLocalStorageDeleteFn).toHaveBeenCalledWith('anonymous', 'old-sim-1'); + expect(mockLocalStorageDeleteFn).toHaveBeenCalledWith('anonymous', 'old-sim-2'); + }); }); }); diff --git a/app/src/tests/unit/pages/reportBuilder/hooks/useReportSubmission.test.tsx b/app/src/tests/unit/pages/reportBuilder/hooks/useReportSubmission.test.tsx index ec2e1f9d0..852403e37 100644 --- a/app/src/tests/unit/pages/reportBuilder/hooks/useReportSubmission.test.tsx +++ b/app/src/tests/unit/pages/reportBuilder/hooks/useReportSubmission.test.tsx @@ -11,6 +11,7 @@ import { mockCreateReportFn, mockCreateSimulationFn, mockLocalStorageCreateFn, + mockLocalStorageDeleteFn, mockOnSuccess, mockSingleSimReportState, mockTwoSimReportState, @@ -28,6 +29,7 @@ vi.mock('@/api/simulation', () => ({ vi.mock('@/api/simulationAssociation', () => ({ LocalStorageSimulationStore: vi.fn().mockImplementation(() => ({ create: mockLocalStorageCreateFn, + delete: mockLocalStorageDeleteFn, })), })); @@ -161,13 +163,18 @@ describe('useReportSubmission', () => { }); }); - test('given submission then calls createSimulation before localStorage association', async () => { + test('given submission then stores local associations after report creation succeeds', async () => { // Given const callOrder: string[] = []; mockCreateSimulationFn.mockImplementation(() => { callOrder.push('createSimulation'); return Promise.resolve({ result: { simulation_id: TEST_SIMULATION_IDS.SIM_NEW_1 } }); }); + mockCreateReportFn.mockImplementation((_args: any, callbacks: any) => { + callOrder.push('createReport'); + callbacks?.onSuccess?.({ userReport: { id: 'user-report-new' } }); + return Promise.resolve(); + }); mockLocalStorageCreateFn.mockImplementation(() => { callOrder.push('localStorageCreate'); return Promise.resolve({}); @@ -188,7 +195,30 @@ describe('useReportSubmission', () => { // Then await waitFor(() => { - expect(callOrder).toEqual(['createSimulation', 'localStorageCreate']); + expect(callOrder).toEqual(['createSimulation', 'createReport', 'localStorageCreate']); + }); + }); + + test('given report creation fails then does not create localStorage associations', async () => { + mockCreateReportFn.mockImplementation((_args: any, callbacks: any) => { + callbacks?.onError?.(new Error('Report creation failed')); + return Promise.reject(new Error('Report creation failed')); + }); + + const { result } = renderHook( + () => + useReportSubmission({ + reportState: mockSingleSimReportState, + countryId: 'us', + onSuccess: mockOnSuccess, + }), + { wrapper } + ); + + await result.current.handleSubmit(); + + await waitFor(() => { + expect(mockLocalStorageCreateFn).not.toHaveBeenCalled(); }); }); @@ -219,6 +249,69 @@ describe('useReportSubmission', () => { ); }); }); + + test('given budget-window mode when submitted then serializes the report year as a range', async () => { + const budgetWindowState = { + ...mockTwoSimReportState, + analysisMode: 'budget-window' as const, + budgetWindowYears: '10', + }; + + const { result } = renderHook( + () => + useReportSubmission({ + reportState: budgetWindowState, + countryId: 'us', + onSuccess: mockOnSuccess, + }), + { wrapper } + ); + + await result.current.handleSubmit(); + + await waitFor(() => { + expect(mockCreateReportFn).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + year: '2026-2035', + }), + }), + expect.anything() + ); + }); + }); + + test('given invalid budget-window timing when submitted then falls back to the single start year', async () => { + const invalidBudgetWindowState = { + ...mockTwoSimReportState, + analysisMode: 'budget-window' as const, + budgetWindowYears: '10', + year: '2035', + }; + + const { result } = renderHook( + () => + useReportSubmission({ + reportState: invalidBudgetWindowState, + countryId: 'us', + onSuccess: mockOnSuccess, + }), + { wrapper } + ); + + await result.current.handleSubmit(); + + await waitFor(() => { + expect(mockCreateReportFn).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + year: '2035', + }), + }), + expect.anything() + ); + }); + }); }); describe('isReportConfigured', () => { diff --git a/app/src/tests/unit/utils/reportOutputSubpage.test.ts b/app/src/tests/unit/utils/reportOutputSubpage.test.ts index ff9b12d74..0a8ca91df 100644 --- a/app/src/tests/unit/utils/reportOutputSubpage.test.ts +++ b/app/src/tests/unit/utils/reportOutputSubpage.test.ts @@ -14,6 +14,14 @@ describe('resolveDefaultReportOutputSubpage', () => { expect(resolveDefaultReportOutputSubpage('societyWide', undefined)).toBe('migration'); }); + test('given a society-wide fallback subpage then uses it', () => { + expect( + resolveDefaultReportOutputSubpage('societyWide', undefined, { + societyWideDefaultSubpage: 'budget-window', + }) + ).toBe('budget-window'); + }); + test('given an explicit subpage then preserves it', () => { expect(resolveDefaultReportOutputSubpage('household', 'policy')).toBe('policy'); }); diff --git a/app/src/tests/unit/utils/reportTiming.test.ts b/app/src/tests/unit/utils/reportTiming.test.ts new file mode 100644 index 000000000..14d26ae31 --- /dev/null +++ b/app/src/tests/unit/utils/reportTiming.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'vitest'; +import { + clampBudgetWindowYears, + formatBudgetWindowYear, + getBudgetWindowOptions, + getDefaultBudgetWindowYears, + getEffectiveReportAnalysisMode, + getReportTimingDisplay, + parseReportTiming, + serializeReportTiming, +} from '@/utils/reportTiming'; + +describe('reportTiming', () => { + test('formats a budget-window year range from a start year and window size', () => { + expect(formatBudgetWindowYear('2026', 10)).toBe('2026-2035'); + }); + + test('parses a single-year report timing', () => { + expect(parseReportTiming('2028', 'us')).toEqual({ + analysisMode: 'single-year', + startYear: '2028', + endYear: '2028', + windowSize: 1, + }); + }); + + test('parses a budget-window report timing', () => { + expect(parseReportTiming('2026-2035', 'us')).toEqual({ + analysisMode: 'budget-window', + startYear: '2026', + endYear: '2035', + windowSize: 10, + }); + }); + + test('serializes a budget-window timing', () => { + expect( + serializeReportTiming({ + analysisMode: 'budget-window', + startYear: '2026', + budgetWindowYears: '10', + }) + ).toBe('2026-2035'); + }); + + test('falls back to a single year when the budget-window size is invalid', () => { + expect( + serializeReportTiming({ + analysisMode: 'budget-window', + startYear: '2026', + budgetWindowYears: 'not-a-number', + }) + ).toBe('2026'); + }); + + test('builds window options from available metadata years', () => { + expect( + getBudgetWindowOptions( + '2029', + [{ value: '2029' }, { value: '2030' }, { value: '2031' }, { value: '2032' }], + 'us' + ) + ).toEqual(['2', '3', '4']); + }); + + test('clamps window size to an allowed option', () => { + expect(clampBudgetWindowYears('10', ['2', '3', '4'], 'us')).toBe('4'); + }); + + test('falls back to single-year mode when no budget-window options are available', () => { + expect(getEffectiveReportAnalysisMode('budget-window', [])).toBe('single-year'); + expect(getEffectiveReportAnalysisMode('budget-window', ['2', '3'])).toBe('budget-window'); + }); + + test('returns sensible defaults and timing labels', () => { + expect(getDefaultBudgetWindowYears('uk')).toBe(5); + expect(getReportTimingDisplay('2026-2035')).toEqual({ + label: 'Budget window', + value: '2026-2035', + }); + }); +}); diff --git a/app/src/types/calculation/CalcStatus.ts b/app/src/types/calculation/CalcStatus.ts index c7165c375..d95f6376c 100644 --- a/app/src/types/calculation/CalcStatus.ts +++ b/app/src/types/calculation/CalcStatus.ts @@ -1,12 +1,13 @@ import { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import { HouseholdData } from '@/types/ingredients/Household'; +import type { BudgetWindowReportOutput } from '@/types/report/BudgetWindowReportOutput'; import { CalcError } from './CalcError'; import { CalcMetadata } from './CalcMetadata'; /** * Union type for all possible calculation results */ -export type CalcResult = SocietyWideReportOutput | HouseholdData; +export type CalcResult = SocietyWideReportOutput | BudgetWindowReportOutput | HouseholdData; /** * Calculation status values diff --git a/app/src/types/ingredients/Report.ts b/app/src/types/ingredients/Report.ts index e34a56a50..40798c782 100644 --- a/app/src/types/ingredients/Report.ts +++ b/app/src/types/ingredients/Report.ts @@ -2,9 +2,13 @@ import { countryIds } from '@/libs/countries'; import type { HouseholdReportOutput } from '@/types/calculation/household'; import { ReportOutputSocietyWideUK } from '@/types/metadata/ReportOutputSocietyWideUK'; import { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; +import type { BudgetWindowReportOutput } from '@/types/report/BudgetWindowReportOutput'; import { Household } from './Household'; -export type EconomyOutput = ReportOutputSocietyWideUS | ReportOutputSocietyWideUK; +export type EconomyOutput = + | ReportOutputSocietyWideUS + | ReportOutputSocietyWideUK + | BudgetWindowReportOutput; export type HouseholdOutput = Household | Household[]; /** diff --git a/app/src/types/report/BudgetWindowReportOutput.ts b/app/src/types/report/BudgetWindowReportOutput.ts new file mode 100644 index 000000000..379527f67 --- /dev/null +++ b/app/src/types/report/BudgetWindowReportOutput.ts @@ -0,0 +1,17 @@ +export interface BudgetWindowAnnualImpact { + year: string; + taxRevenueImpact: number; + federalTaxRevenueImpact: number; + stateTaxRevenueImpact: number; + benefitSpendingImpact: number; + budgetaryImpact: number; +} + +export interface BudgetWindowReportOutput { + kind: 'budgetWindow'; + startYear: string; + endYear: string; + windowSize: number; + annualImpacts: BudgetWindowAnnualImpact[]; + totals: BudgetWindowAnnualImpact; +} diff --git a/app/src/utils/reportOutputSubpage.ts b/app/src/utils/reportOutputSubpage.ts index 0ee77e7a4..8318c4435 100644 --- a/app/src/utils/reportOutputSubpage.ts +++ b/app/src/utils/reportOutputSubpage.ts @@ -7,7 +7,10 @@ const DEFAULT_SUBPAGES: Record = { export function resolveDefaultReportOutputSubpage( outputType: ReportOutputType | undefined, - subpage: string | undefined + subpage: string | undefined, + options?: { + societyWideDefaultSubpage?: string; + } ) { if (subpage) { return subpage; @@ -17,5 +20,9 @@ export function resolveDefaultReportOutputSubpage( return ''; } + if (outputType === 'societyWide' && options?.societyWideDefaultSubpage) { + return options.societyWideDefaultSubpage; + } + return DEFAULT_SUBPAGES[outputType]; } diff --git a/app/src/utils/reportTiming.ts b/app/src/utils/reportTiming.ts new file mode 100644 index 000000000..83f54de6d --- /dev/null +++ b/app/src/utils/reportTiming.ts @@ -0,0 +1,159 @@ +import { countryIds } from '@/libs/countries'; + +type CountryId = (typeof countryIds)[number]; + +export type ReportAnalysisMode = 'single-year' | 'budget-window'; + +export interface ParsedReportTiming { + analysisMode: ReportAnalysisMode; + startYear: string; + endYear: string; + windowSize: number; +} + +const BUDGET_WINDOW_PATTERN = /^(\d{4})-(\d{4})$/; + +export function getDefaultBudgetWindowYears(countryId: CountryId): number { + return countryId === 'uk' ? 5 : 10; +} + +export function formatBudgetWindowYear(startYear: string, windowSize: number): string { + const start = Number.parseInt(startYear, 10); + const normalizedWindowSize = Math.max(2, windowSize); + return `${startYear}-${start + normalizedWindowSize - 1}`; +} + +export function isBudgetWindowReportYear(reportYear: string): boolean { + return BUDGET_WINDOW_PATTERN.test(reportYear); +} + +export function parseReportTiming( + reportYear: string, + _countryId: CountryId = 'us' +): ParsedReportTiming { + const match = reportYear.match(BUDGET_WINDOW_PATTERN); + + if (!match) { + return { + analysisMode: 'single-year', + startYear: reportYear, + endYear: reportYear, + windowSize: 1, + }; + } + + const startYear = match[1]; + const endYear = match[2]; + const start = Number.parseInt(startYear, 10); + const end = Number.parseInt(endYear, 10); + + if (Number.isNaN(start) || Number.isNaN(end) || end < start) { + return { + analysisMode: 'single-year', + startYear: reportYear, + endYear: reportYear, + windowSize: 1, + }; + } + + return { + analysisMode: 'budget-window', + startYear, + endYear, + windowSize: end - start + 1, + }; +} + +export function serializeReportTiming({ + analysisMode, + startYear, + budgetWindowYears, +}: { + analysisMode: ReportAnalysisMode; + startYear: string; + budgetWindowYears: string | number; +}): string { + const parsedBudgetWindowYears = Number.parseInt(String(budgetWindowYears), 10); + + if ( + analysisMode !== 'budget-window' || + Number.isNaN(parsedBudgetWindowYears) || + parsedBudgetWindowYears < 2 + ) { + return startYear; + } + + return formatBudgetWindowYear(startYear, parsedBudgetWindowYears); +} + +export function getEffectiveReportAnalysisMode( + analysisMode: ReportAnalysisMode, + availableBudgetWindowOptions: string[] +): ReportAnalysisMode { + if (analysisMode === 'budget-window' && availableBudgetWindowOptions.length === 0) { + return 'single-year'; + } + + return analysisMode; +} + +export function getBudgetWindowOptions( + startYear: string, + yearOptions: Array<{ value: string }>, + countryId: CountryId +): string[] { + const start = Number.parseInt(startYear, 10); + + if (Number.isNaN(start)) { + return [String(getDefaultBudgetWindowYears(countryId))]; + } + + const availableYears = yearOptions + .map((option) => Number.parseInt(option.value, 10)) + .filter((year) => !Number.isNaN(year)) + .sort((a, b) => a - b); + + const maxAvailableYear = availableYears[availableYears.length - 1]; + if (!maxAvailableYear || maxAvailableYear <= start) { + return []; + } + + const maxWindowSize = maxAvailableYear - start + 1; + return Array.from({ length: maxWindowSize - 1 }, (_, index) => String(index + 2)); +} + +export function clampBudgetWindowYears( + budgetWindowYears: string | number, + availableOptions: string[], + countryId: CountryId +): string { + if (availableOptions.length === 0) { + return String(getDefaultBudgetWindowYears(countryId)); + } + + const requested = String(budgetWindowYears); + if (availableOptions.includes(requested)) { + return requested; + } + + const preferredDefault = String(getDefaultBudgetWindowYears(countryId)); + if (availableOptions.includes(preferredDefault)) { + return preferredDefault; + } + + return availableOptions[availableOptions.length - 1]; +} + +export function getReportTimingDisplay(reportYear: string): { label: string; value: string } { + if (isBudgetWindowReportYear(reportYear)) { + return { + label: 'Budget window', + value: reportYear, + }; + } + + return { + label: 'Year', + value: reportYear, + }; +}