Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions app/src/adapters/ReportAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Report } from '@/types/ingredients/Report';
import { ReportMetadata } from '@/types/metadata/reportMetadata';
import { ReportCreationPayload } from '@/types/payloads/ReportCreationPayload';
import { ReportSetOutputPayload } from '@/types/payloads/ReportSetOutputPayload';
import { parseRequiredSPMComparisonCalculationProvenance } from '@/types/spm';
import { convertJsonToReportOutput, convertReportOutputToJson } from './conversionHelpers';

/**
Expand Down Expand Up @@ -37,20 +38,45 @@ export class ReportAdapter {
? [String(metadata.simulation_1_id), String(metadata.simulation_2_id)]
: [String(metadata.simulation_1_id)];

let output = convertJsonToReportOutput(metadata.output) as Report['output'];
let status = this.mapApiStatusToReportStatus(metadata.status);
if (metadata.country_id === 'us' && status === 'complete' && this.isSocietyWideOutput(output)) {
try {
const spm = parseRequiredSPMComparisonCalculationProvenance(output);
output = {
...(output as unknown as Record<string, unknown>),
...spm,
} as Report['output'];
} catch {
output = null;
status = 'pending';
}
}

return {
id: String(metadata.id),
countryId: metadata.country_id,
year: metadata.year,
apiVersion: metadata.api_version,
simulationIds,
status: this.mapApiStatusToReportStatus(metadata.status),
status,
...(metadata.requested_at ? { requestedAt: metadata.requested_at } : {}),
...(metadata.started_at ? { startedAt: metadata.started_at } : {}),
...(metadata.finished_at ? { finishedAt: metadata.finished_at } : {}),
output: convertJsonToReportOutput(metadata.output) as any, // Can be economy or household output
output,
};
}

private static isSocietyWideOutput(value: unknown): boolean {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
'budget' in value &&
'decile' in value
);
}

/**
* Converts Report to format for API POST request
* API expects snake_case format - simulation IDs and year needed for creation
Expand Down
27 changes: 26 additions & 1 deletion app/src/adapters/SimulationAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Simulation } from '@/types/ingredients/Simulation';
import { SimulationMetadata } from '@/types/metadata/simulationMetadata';
import { SimulationCreationPayload, SimulationSetOutputPayload } from '@/types/payloads';
import { parseOptionalSPMCalculationProvenance } from '@/types/spm';

/**
* Adapter for converting between Simulation and API formats
Expand Down Expand Up @@ -64,6 +65,21 @@ export class SimulationAdapter {
}
}

let status = this.mapApiStatusToSimulationStatus(metadata.status);
if (
metadata.population_type === 'household' &&
status === 'complete' &&
this.hasSPMCalculationFields(parsedOutput)
) {
try {
const parsedSPM = parseOptionalSPMCalculationProvenance(parsedOutput);
parsedOutput = { ...(parsedOutput as Record<string, unknown>), ...parsedSPM };
} catch {
parsedOutput = null;
status = 'pending';
}
}

const simulation = {
id: String(metadata.id),
countryId: metadata.country_id,
Expand All @@ -74,13 +90,22 @@ export class SimulationAdapter {
label: null,
isCreated: true,
output: parsedOutput,
status: this.mapApiStatusToSimulationStatus(metadata.status),
status,
...this.parseErrorMessage(metadata.error_message),
};

return simulation;
}

private static hasSPMCalculationFields(value: unknown): boolean {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
('spm_config' in value || 'spm_provenance' in value)
);
}

/**
* Converts Simulation to format for API POST request
*/
Expand Down
14 changes: 7 additions & 7 deletions app/src/api/householdCalculation.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
import type { PolicyEngineBundle } from '@/api/societyWideCalculation';
import { BASE_URL } from '@/constants';
import type { HouseholdCalculationData } from '@/types/calculation/household';
import type { SPMProvenance, SPMSelection } from '@/types/spm';
import { parseOptionalSPMCalculationProvenance, type SPMProvenance } from '@/types/spm';
import { householdAPIError, householdAPIErrorFromBody } from './householdError';

export interface HouseholdCalculationResponse {
status: 'ok' | 'error';
result: HouseholdCalculationData | null;
error?: string;
policyengine_bundle?: PolicyEngineBundle | null;
spm_config?: SPMSelection;
spm_provenance?: SPMProvenance;
}

export interface HouseholdCalculationResult {
result: HouseholdCalculationData;
policyengine_bundle?: PolicyEngineBundle | null;
spm_config?: SPMSelection;
spm_provenance?: SPMProvenance;
}

Expand Down Expand Up @@ -47,17 +45,19 @@ export async function fetchHouseholdCalculationWithBundle(
);
}

const data: HouseholdCalculationResponse = await response.json();
const rawData: unknown = await response.json();
const data = rawData as HouseholdCalculationResponse;

if (data.status === 'error' || !data.result) {
throw householdAPIErrorFromBody(data, 'Household calculation failed');
throw householdAPIErrorFromBody(rawData, 'Household calculation failed');
}

const spm = parseOptionalSPMCalculationProvenance(rawData);

return {
result: data.result,
policyengine_bundle: data.policyengine_bundle ?? null,
...(data.spm_config ? { spm_config: data.spm_config } : {}),
...(data.spm_provenance ? { spm_provenance: data.spm_provenance } : {}),
...(spm ?? {}),
};
} catch (error) {
clearTimeout(timeoutId);
Expand Down
16 changes: 11 additions & 5 deletions app/src/api/householdVariation.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { BASE_URL } from '@/constants';
import type { HouseholdCalculationData } from '@/types/calculation/household';
import type { SPMProvenance, SPMSelection } from '@/types/spm';
import {
parseOptionalSPMCalculationProvenance,
type SPMProvenance,
type SPMSelection,
} from '@/types/spm';
import { householdAPIError, householdAPIErrorFromBody } from './householdError';

export interface HouseholdVariationResponse {
status: 'ok' | 'error';
result: HouseholdCalculationData | null;
error?: string;
spm_config?: SPMSelection;
spm_provenance?: SPMProvenance;
}

Expand Down Expand Up @@ -52,13 +55,16 @@ export async function fetchHouseholdVariationWithProvenance(
);
}

const data: HouseholdVariationResponse = await response.json();
const rawData: unknown = await response.json();
const data = rawData as HouseholdVariationResponse;

if (data.status === 'error' || !data.result) {
throw householdAPIErrorFromBody(data, 'Household variation calculation failed');
throw householdAPIErrorFromBody(rawData, 'Household variation calculation failed');
}

return { ...data, result: data.result };
const parsedSPM = parseOptionalSPMCalculationProvenance(rawData);

return { ...data, ...parsedSPM, result: data.result };
} catch (error) {
clearTimeout(timeoutId);

Expand Down
5 changes: 5 additions & 0 deletions app/src/api/societyWideCalculation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BASE_URL } from '@/constants';
import { ReportOutputSocietyWideUK } from '@/types/metadata/ReportOutputSocietyWideUK';
import { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS';
import { parseRequiredSPMComparisonCalculationProvenance } from '@/types/spm';

export type SocietyWideReportOutput = ReportOutputSocietyWideUS | ReportOutputSocietyWideUK;

Expand Down Expand Up @@ -95,5 +96,9 @@ export async function fetchSocietyWideCalculation(
}

const data: SocietyWideCalculationResponse = await response.json();
if (countryId === 'us' && data.status === 'ok' && data.result) {
const spm = parseRequiredSPMComparisonCalculationProvenance(data.result);
data.result = { ...data.result, ...spm } as ReportOutputSocietyWideUS;
}
return mergePolicyEngineBundle(data);
}
10 changes: 2 additions & 8 deletions app/src/components/household/SPMMethodologyFootnote.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import SPMProvenanceDetails from '@/components/report/SPMProvenanceDetails';
import { Stack, Text } from '@/components/ui';
import type { HouseholdCalculationOutput } from '@/types/calculation/household';

Expand Down Expand Up @@ -36,14 +37,7 @@ export default function SPMMethodologyFootnote({
{' · '}
{item!.spmProvenance!.scenario}
</summary>
<Text size="sm">Forecast: {item!.spmProvenance!.forecast_id}</Text>
<pre className="tw:overflow-auto tw:whitespace-pre-wrap tw:break-all tw:text-xs">
{JSON.stringify(
{ spm_config: item!.spmConfig, spm_provenance: item!.spmProvenance },
null,
2
)}
</pre>
<SPMProvenanceDetails label={label} receipt={item!.spmProvenance!} />
</details>
))}
</Stack>
Expand Down
31 changes: 31 additions & 0 deletions app/src/components/report/SPMComparisonMethodology.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import SPMProvenanceDetails from '@/components/report/SPMProvenanceDetails';
import { Stack, Text } from '@/components/ui';
import type { SPMComparisonProvenance } from '@/types/spm';

/** Display the baseline and reform receipts attached to a society-wide result. */
export default function SPMComparisonMethodology({
provenance,
}: {
provenance: SPMComparisonProvenance;
}) {
return (
<Stack
role="region"
aria-label="SPM society-wide methodology"
gap="md"
className="tw:mt-lg tw:border-t tw:border-border tw:pt-md"
>
<Text size="sm">Supplemental Poverty Measure methodology</Text>
<SPMProvenanceDetails
label="Baseline"
receipt={provenance.baseline.receipt}
executionCount={provenance.baseline.execution_count}
/>
<SPMProvenanceDetails
label="Reform"
receipt={provenance.reform.receipt}
executionCount={provenance.reform.execution_count}
/>
</Stack>
);
}
33 changes: 33 additions & 0 deletions app/src/components/report/SPMProvenanceDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Stack, Text } from '@/components/ui';
import { buildSPMProvenanceDisplayRows, type SPMProvenance } from '@/types/spm';

interface SPMProvenanceDetailsProps {
label: string;
receipt: SPMProvenance;
executionCount?: number;
}

/** Render the compact public receipt without exposing internal diagnostic data. */
export default function SPMProvenanceDetails({
label,
receipt,
executionCount,
}: SPMProvenanceDetailsProps) {
const rows = buildSPMProvenanceDisplayRows(receipt);
return (
<Stack role="region" aria-label={`${label} SPM provenance`} gap="xs">
<Text size="sm">{label}</Text>
{executionCount !== undefined && (
<Text size="sm">Executions: {executionCount.toLocaleString()}</Text>
)}
<dl className="tw:grid tw:grid-cols-[max-content_1fr] tw:gap-x-md tw:gap-y-xs tw:text-sm">
{rows.map((row) => (
<div key={row.label} className="tw:contents">
<dt className="tw:font-medium">{row.label}</dt>
<dd className="tw:break-all">{row.value}</dd>
</div>
))}
</dl>
</Stack>
);
}
1 change: 0 additions & 1 deletion app/src/hooks/useHouseholdVariation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ export function useHouseholdVariation({
id: householdId,
countryId: countryId as (typeof countryIds)[number],
householdData: calculation.result,
spmConfig: calculation.spm_config,
spmProvenance: calculation.spm_provenance,
};
return result;
Expand Down
3 changes: 1 addition & 2 deletions app/src/pages/report-output/HouseholdReportOutput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,15 +219,14 @@ export function HouseholdReportOutput({
role: index === 0 ? 'baseline' : 'reform',
household,
policy: policy ? convertPoliciesToV1Format([policy]).baseline.data : null,
spmConfig: output?.spm_config,
spmProvenance: output?.spm_provenance,
policyengineVersion:
output?.policyengine_bundle?.policyengine_version ??
output?.spm_provenance?.runtime_versions?.policyengine ??
null,
modelVersion:
output?.policyengine_bundle?.model_version ??
output?.spm_provenance?.runtime_versions?.[`policyengine-${report.countryId}`] ??
output?.spm_provenance?.runtime_versions?.['policyengine-us'] ??
null,
};
}
Expand Down
1 change: 0 additions & 1 deletion app/src/pages/report-output/HouseholdReportViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,6 @@ export class HouseholdReportViewModel {
)
.map(({ simulation, householdData }) => ({
id: simulation.id,
spmConfig: (simulation.output as HouseholdCalculationResult)?.spm_config,
spmProvenance: (simulation.output as HouseholdCalculationResult)?.spm_provenance,
countryId: this.report!.countryId,
householdData,
Expand Down
2 changes: 2 additions & 0 deletions app/src/pages/report-output/SocietyWideOverview.story.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react';
import { CountryProvider } from '@/contexts/CountryContext';
import { ReportYearProvider } from '@/contexts/ReportYearContext';
import { SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks';
import type { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS';
import SocietyWideOverview from './SocietyWideOverview';

Expand Down Expand Up @@ -35,6 +36,7 @@ function buildMockOutput(overrides: {
} = overrides;

return {
spm_provenance: SPM_COMPARISON_RECEIPT,
budget: {
baseline_net_income: 12_000_000_000_000,
benefit_spending_impact: budgetaryImpact > 0 ? -budgetaryImpact * 0.4 : budgetaryImpact * 0.6,
Expand Down
4 changes: 4 additions & 0 deletions app/src/pages/report-output/SocietyWideOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { normalizeDistrictId } from '@/adapters/congressional-district/congressi
import { SocietyWideReportOutput } from '@/api/societyWideCalculation';
import DashboardCard from '@/components/report/DashboardCard';
import MetricCard from '@/components/report/MetricCard';
import SPMComparisonMethodology from '@/components/report/SPMComparisonMethodology';
import { Group, SegmentedControl, Stack, Text } from '@/components/ui';
import { MapTypeToggle } from '@/components/visualization/choropleth/MapTypeToggle';
import type { MapVisualizationType } from '@/components/visualization/choropleth/types';
Expand Down Expand Up @@ -1439,6 +1440,9 @@ export default function SocietyWideOverview({
/>
)}
</div>
{'spm_provenance' in output && (
<SPMComparisonMethodology provenance={output.spm_provenance} />
)}
</Stack>
);
}
Expand Down
2 changes: 2 additions & 0 deletions app/src/tests/fixtures/adapters/reportMocks.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks';
import { Report } from '@/types/ingredients/Report';
import { ReportMetadata } from '@/types/metadata/reportMetadata';
import { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS';
import { ReportCreationPayload } from '@/types/payloads/ReportCreationPayload';
import { ReportSetOutputPayload } from '@/types/payloads/ReportSetOutputPayload';

export const mockReportOutput: ReportOutputSocietyWideUS = {
spm_provenance: SPM_COMPARISON_RECEIPT,
budget: {
baseline_net_income: 1000000,
benefit_spending_impact: -50000,
Expand Down
2 changes: 2 additions & 0 deletions app/src/tests/fixtures/api/reportMocks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ReportAdapter } from '@/adapters/ReportAdapter';
import { SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks';
import { Report } from '@/types/ingredients/Report';
import { ReportMetadata } from '@/types/metadata/reportMetadata';
import type { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS';
Expand Down Expand Up @@ -28,6 +29,7 @@ export const MOCK_HOUSEHOLD_RESULT = {

// Complete mock for US society-wide result
export const MOCK_ECONOMY_RESULT: ReportOutputSocietyWideUS = {
spm_provenance: SPM_COMPARISON_RECEIPT,
budget: {
baseline_net_income: 1000000,
benefit_spending_impact: -50000,
Expand Down
Loading
Loading