Skip to content
56 changes: 26 additions & 30 deletions app/src/components/flagship/CalibrationMatches.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { Text } from '@/components/ui';
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { colors, spacing, typography } from '@/designTokens';
import {
CalibrationMatches,
calibrationMatchesForPaths,
CalibrationRing,
calibrationTargetLabel,
CalibrationTargetRow,
calibrationVariableLabel,
dashboardTargetsUrl,
geographyForRegion,
} from '@/libs/flagship/calibrationMatching';
import type { ReportValidationSnapshot } from '@/libs/flagship/reportValidation';
import type { RootState } from '@/store';
import {
CellNote,
detailStyle,
Expand Down Expand Up @@ -55,17 +59,6 @@ export const ATTENTION_ERROR = 0.25;

const CALIBRATION_COLUMNS = ['Target', 'Period', 'Official', 'PolicyEngine', 'Gap'];

function humanize(variable: string): string {
const labels: Record<string, string> = {
refundable_ctc: 'Refundable Child Tax Credit',
ctc: 'Child Tax Credit',
income_tax: 'Income tax',
assigned_aca_ptc: 'Assigned ACA premium tax credit',
};
const label = labels[variable] ?? variable.replaceAll('_', ' ');
return label.charAt(0).toUpperCase() + label.slice(1);
}

function compactNumber(value: number | null): string {
return value === null
? '—'
Expand Down Expand Up @@ -134,18 +127,21 @@ function PinNote({ pin }: { pin: CalibrationPin }) {
);
}

/** A target's name, opening its exact values. */
function TargetDetails({ target }: { target: CalibrationTargetRow }) {
/** A target's name, opening its exact values; the dashboard id rides along as a tooltip. */
function TargetDetails({ target, group }: { target: CalibrationTargetRow; group: string }) {
const label = calibrationTargetLabel(target);
return (
<Dialog>
<DialogTrigger asChild>
<button type="button" style={rowButtonStyle}>
{target.name}
<button type="button" style={rowButtonStyle} title={target.name}>
{label}
</button>
</DialogTrigger>
<DialogContent aria-describedby={undefined}>
<DialogTitle>Calibration target</DialogTitle>
<Text style={{ ...detailStyle, overflowWrap: 'anywhere' }}>{target.name}</Text>
<Text style={detailStyle}>
{group} · {label}
</Text>
<dl
style={{
display: 'grid',
Expand All @@ -157,17 +153,18 @@ function TargetDetails({ target }: { target: CalibrationTargetRow }) {
}}
>
{[
['Target id', target.name],
['Source', target.sourceLabel ?? target.source],
['Geography', target.geography],
['Period', target.period ?? '—'],
['Measure', target.measure ?? '—'],
['Official', exactNumber(target.target)],
['PolicyEngine', exactNumber(target.estimate)],
['Gap', gapOf(target)],
].map(([label, value]) => (
<div key={label} style={{ display: 'contents' }}>
<dt>{label}</dt>
<dd style={{ margin: 0 }}>{value}</dd>
].map(([field, value]) => (
<div key={field} style={{ display: 'contents' }}>
<dt>{field}</dt>
<dd style={{ margin: 0, overflowWrap: 'anywhere' }}>{value}</dd>
</div>
))}
</dl>
Expand Down Expand Up @@ -196,6 +193,7 @@ export function CalibrationMatchSection({
matches: CalibrationMatches | null | undefined;
pin?: CalibrationPin;
}) {
const variables = useSelector((state: RootState) => state.metadata.variables);
const level = matches ? (matches.geography === 'US' ? 'national' : 'state') : null;
const card = (content: React.ReactNode) => (
<ValidationCard
Expand All @@ -214,14 +212,10 @@ export function CalibrationMatchSection({
return card(<ValidationLoading label="Checking calibration targets" />);
}
if (matches.matches.length === 0) {
const where = matches.geography === 'US' ? 'nationally' : `in ${matches.geography}`;
// Silence would read as "fine". State credits are the usual case: the
// dashboard's state income tax targets carry no model-variable mapping.
// Silence would read as "fine"; loose matches would read as evidence.
return card(
<ValidationNote>
{matches.reachedCount > 0
? `None of the ${matches.reachedCount} variables this reform moves is a calibration target ${where}.`
: 'No calibration target covers this reform.'}
No calibration target measures the programs this reform changes.
</ValidationNote>
);
}
Expand All @@ -233,6 +227,10 @@ export function CalibrationMatchSection({
const sources = [
...new Set(match.targets.map((target) => target.sourceLabel ?? target.source)),
].join(', ');
const group = calibrationVariableLabel(
match.variable,
variables?.[match.variable]?.label
);
return (
<tbody key={match.variable}>
<tr style={rowStyle}>
Expand All @@ -241,9 +239,7 @@ export function CalibrationMatchSection({
colSpan={CALIBRATION_COLUMNS.length}
style={{ ...labelCellStyle, paddingBottom: 0 }}
>
<span style={{ fontWeight: typography.fontWeight.semibold }}>
{humanize(match.variable)}
</span>
<span style={{ fontWeight: typography.fontWeight.semibold }}>{group}</span>
<CellNote>
<span>
{sources} · {matches.geography}
Expand All @@ -263,7 +259,7 @@ export function CalibrationMatchSection({
{match.targets.map((target) => (
<tr key={target.name}>
<th scope="row" style={labelCellStyle}>
<TargetDetails target={target} />
<TargetDetails target={target} group={group} />
</th>
<td style={numberCellStyle}>{target.period ?? '—'}</td>
<td style={numberCellStyle}>{compactNumber(target.target)}</td>
Expand Down
54 changes: 39 additions & 15 deletions app/src/components/flagship/ScorecardComparisons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { IconExternalLink } from '@tabler/icons-react';
import { Stack, Text } from '@/components/ui';
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { colors, spacing, typography } from '@/designTokens';
import { scorecardEntryUrl } from '@/libs/flagship/modelValidation';
import {
loadParameterDependencies,
variablesReachedByPaths,
Expand Down Expand Up @@ -47,6 +48,7 @@ const sourceNames: Record<string, string> = {
tpc: 'Tax Policy Center',
cpsp: 'Center on Poverty and Social Policy',
pwbm: 'Penn Wharton Budget Model',
treasury: 'US Treasury',
};
const metricNames: Record<string, string> = {
revenue_change: 'Change in tax revenue',
Expand All @@ -72,10 +74,11 @@ function displayTitle(title: string): string {
.trim();
return cleaned ? cleaned[0].toUpperCase() + cleaned.slice(1) : title;
}
function modelPeriod(row: RelatedScorecardClaim): string {
/** The model's period when the receipt records one; null says nothing, so nothing shows. */
function modelPeriod(row: RelatedScorecardClaim): string | null {
const receipt = [row.latest?.construction, ...(row.latest?.annotations ?? [])].join(' ');
const years = [...new Set([...receipt.matchAll(/\bCY\s*(20\d{2})/gi)].map((match) => match[1]))];
return years.length === 1 ? `Calendar year ${years[0]}` : 'Period not recorded separately';
return years.length === 1 ? `Calendar year ${years[0]}` : null;
}

function comparisonReason(
Expand All @@ -96,11 +99,12 @@ function comparisonReason(
);
const calendar = pePeriod ?? modelPeriod(row);
const fiscal = /fiscal|FY/i.test(externalPeriod ?? claimPeriod(row));
if (baselineDiffers && fiscal && calendar.startsWith('Calendar year')) {
const calendarYear = calendar?.startsWith('Calendar year') ? calendar : null;
if (baselineDiffers && fiscal && calendarYear) {
return 'Different starting policies and timing: the source uses a fiscal year; PolicyEngine uses a calendar year.';
}
if (fiscal && calendar.startsWith('Calendar year')) {
return `Different timing: the source uses a fiscal year; PolicyEngine uses ${calendar.toLowerCase()}.`;
if (fiscal && calendarYear) {
return `Different timing: the source uses a fiscal year; PolicyEngine uses ${calendarYear.toLowerCase()}.`;
}
if (baselineDiffers) {
return 'The source and PolicyEngine start from different baseline policies.';
Expand Down Expand Up @@ -164,6 +168,7 @@ function ComparisonContext({ row }: { row: RelatedScorecardClaim }) {
],
]);
if (isJctCtc(row)) {
const period = modelPeriod(row);
const reversed = row.latest?.construction?.includes('scored as expiry reversal');
const stacked =
row.latest?.baseline === 'obbba_stack_below__jcx_producer__obbba_child_tax_credit';
Expand Down Expand Up @@ -238,7 +243,7 @@ function ComparisonContext({ row }: { row: RelatedScorecardClaim }) {
{reversed
? 'This run keeps other enacted tax changes in place while reversing the CTC expiry. JCT starts with only the bill’s tax-rate, standard-deduction and personal-exemption changes applied. Those different tax rules affect how much CTC families can receive.'
: stacked
? `This run adds the CTC changes after the bill’s tax-rate, standard-deduction and personal-exemption changes. Model period: ${modelPeriod(row)}; check the source year below.`
? `This run adds the CTC changes after the bill’s tax-rate, standard-deduction and personal-exemption changes.${period ? ` Model period: ${period}; check the source year below.` : ''}`
: 'The stored calculation does not provide enough detail to confirm the starting tax rules.'}
</Text>
</Stack>
Expand Down Expand Up @@ -316,6 +321,8 @@ export interface ClaimPresentation {
modelPeriod?: string;
externalValue?: string;
peValue?: string;
/** Where the claim sits on the scorecard, when not its registry of published claims. */
scorecardUrl?: string;
}

/** A scorecard claim, and how to present it. */
Expand All @@ -331,6 +338,11 @@ function claimTitle(row: RelatedScorecardClaim, presentation?: ClaimPresentation
);
}

/** The claim's own place on the scorecard, as close as the scorecard's links reach. */
function claimScorecardUrl(row: RelatedScorecardClaim, presentation?: ClaimPresentation): string {
return presentation?.scorecardUrl ?? scorecardEntryUrl('validation', row.country);
}

/** The source, and the geography when the claim records one: "Joint Committee on Taxation · US". */
function claimWhere(row: RelatedScorecardClaim, presentation?: ClaimPresentation): string {
const sourceName =
Expand Down Expand Up @@ -380,15 +392,15 @@ export function ScorecardClaimDetails({ row, presentation }: ScorecardClaimEntry
left={{
label: 'Published estimate',
value: presentation?.externalValue ?? claimValue(row.external_value, row.unit_concept),
note: presentation?.externalPeriod ?? claimPeriod(row),
note: presentation?.externalPeriod ?? (claimPeriod(row) || undefined),
}}
right={{
label:
row.reform_framework === 'baseline'
? 'PolicyEngine estimate'
: 'PolicyEngine scorecard estimate',
value: presentation?.peValue ?? claimValue(latest?.value, row.unit_concept),
note: presentation?.modelPeriod ?? modelPeriod(row),
note: presentation?.modelPeriod ?? modelPeriod(row) ?? undefined,
}}
/>
{status === 'constructed' &&
Expand Down Expand Up @@ -477,6 +489,10 @@ export function ScorecardClaimDetails({ row, presentation }: ScorecardClaimEntry
/** One claim as a table row: what is compared, both values, and the gap. */
function ScorecardClaimRow({ row, presentation }: ScorecardClaimEntry) {
const sourceUrl = safeUrl(row.url);
const smallLink = { ...linkStyle, fontSize: typography.fontSize.xs };
// Only a period that is recorded; "not recorded" under every value says nothing.
const publishedNote = presentation?.externalPeriod ?? claimPeriod(row);
const peNote = presentation?.modelPeriod ?? modelPeriod(row);
return (
<tr style={rowStyle}>
<th scope="row" style={labelCellStyle}>
Expand All @@ -499,24 +515,32 @@ function ScorecardClaimRow({ row, presentation }: ScorecardClaimEntry) {
</DialogContent>
</Dialog>
<CellNote>{claimWhere(row, presentation)}</CellNote>
{sourceUrl && (
<CellNote>
{sourceUrl && (
<>
<a href={sourceUrl} target="_blank" rel="noreferrer" style={smallLink}>
Read source
</a>
{' · '}
</>
)}
<a
href={sourceUrl}
href={claimScorecardUrl(row, presentation)}
target="_blank"
rel="noreferrer"
style={{ ...linkStyle, fontSize: typography.fontSize.xs }}
style={smallLink}
>
Read source
View in scorecard
</a>
)}
</CellNote>
</th>
<td style={numberCellStyle}>
{presentation?.externalValue ?? claimValue(row.external_value, row.unit_concept)}
<CellNote>{presentation?.externalPeriod ?? claimPeriod(row)}</CellNote>
{publishedNote && <CellNote>{publishedNote}</CellNote>}
</td>
<td style={numberCellStyle}>
{presentation?.peValue ?? claimValue(row.latest?.value, row.unit_concept)}
<CellNote>{presentation?.modelPeriod ?? modelPeriod(row)}</CellNote>
{peNote && <CellNote>{peNote}</CellNote>}
</td>
<td style={numberCellStyle}>{gapText(row.latest?.value, row.external_value)}</td>
</tr>
Expand Down
Loading
Loading