Skip to content
Merged
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
33 changes: 11 additions & 22 deletions app/rules/page.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,20 @@
import SectionOverview from '../../src/components/layout/SectionOverview';
import { detectCountry } from '../../src/components/comparison/detectCountry';
import { rulesSection } from '../../src/data/rulesSection';

export default function RulesOverviewPage() {
export default async function RulesOverviewPage({
searchParams,
}: {
searchParams: Promise<{ country?: string | string[] }>;
}) {
const country = await detectCountry((await searchParams).country);
const { description, children } = rulesSection[country];
return (
<SectionOverview
category="Model"
title="Rules"
description="How PolicyEngine encodes tax-and-benefit rules — which programs are covered, the legislative parameters that drive calculations, and the input and output variables exposed to households and microsimulations."
children={[
{
label: 'Coverage tracker',
description:
'Which federal, state, and local tax-and-benefit programs PolicyEngine and peer models implement.',
path: '/rules/coverage',
},
{
label: 'Parameters',
description:
'Browse every legislative parameter that drives the model — thresholds, rates, brackets, eligibility cut-offs.',
path: '/rules/parameters',
},
{
label: 'Variables',
description:
'Inspect the input and output variables computed at the person, family, tax unit, and household level.',
path: '/rules/variables',
},
]}
description={description}
children={children}
/>
);
}
5 changes: 4 additions & 1 deletion proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,13 @@ export function proxy(req: NextRequest) {
}

export const config = {
// Registry snapshots need country-prefix rewrites even though they are assets.
// Registry snapshots and their parameter trees need country-prefix rewrites
// even though they are assets.
matcher: [
'/((?!_next|.*\\..*).*)',
'/:country(us|uk)/programs-:registry(us|uk).json',
'/:country(us|uk)/model/programs-:registry(us|uk).json',
'/:country(us|uk)/param-tree-:registry(us|uk).json',
'/:country(us|uk)/model/param-tree-:registry(us|uk).json',
],
};
6 changes: 4 additions & 2 deletions scripts/fetch-metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,13 @@ export async function fetchMetadataFiles({

for (const country of countries) {
const snapshotPath = join(outputDir, `programs-${country}.json`);
const treePath = join(outputDir, `param-tree-${country}.json`);
let source;
try {
// A failed refresh must not leave a snapshot from an earlier build behind.
// A failed refresh must not leave a snapshot or parameter tree from an
// earlier build behind, so whatever is written comes from one commit.
rmSync(snapshotPath, { force: true });
rmSync(treePath, { force: true });
source = await resolveRepository(country, fetchImpl);
} catch (error) {
logger.warn(` ${country}: could not resolve registry source — ${error.message}`);
Expand All @@ -172,7 +175,6 @@ export async function fetchMetadataFiles({
(async () => {
try {
const paths = await fetchParameterYamlPaths(country, source, fetchImpl);
const treePath = join(outputDir, `param-tree-${country}.json`);
writeFileSync(treePath, JSON.stringify(paths));
logger.log(` ${country}: wrote ${treePath} (${paths.length} files)`);
} catch (error) {
Expand Down
10 changes: 9 additions & 1 deletion src/components/rules/CoverageProvenance.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import type { ProgramsSource } from '../../data/fetchPrograms';
import { colors, spacing, typography } from '../../designTokens';

export default function CoverageProvenance({ source }: { source: ProgramsSource }) {
export default function CoverageProvenance({
source,
aheadNote,
}: {
source: ProgramsSource;
/** Added when the snapshot's version is not the one the production API served. */
aheadNote?: string;
}) {
let text: string;
if (source.kind === 'snapshot') {
const model = source.repo.split('/').pop();
Expand All @@ -10,6 +17,7 @@ export default function CoverageProvenance({ source }: { source: ProgramsSource
if (source.apiVersion) {
text += ` The production API served ${source.apiVersion} when the snapshot was fetched.`;
}
if (aheadNote && source.apiVersion !== source.version) text += ` ${aheadNote}`;
} else if (source.kind === 'api') {
const model = source.repo.split('/').pop();
text = `Coverage reflects the production API, ${model} ${source.apiVersion || '(version unavailable)'}.`;
Expand Down
122 changes: 10 additions & 112 deletions src/components/rules/RulesOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,108 +16,19 @@ import {
} from '../../data/programStatus';
import type { CoverageStatus, Program } from '../../types/Program';
import { IconX, IconCalendar } from '@tabler/icons-react';
import {
StatCard,
StatusDot,
collectAllYears,
programIncludesYear,
statusLabels,
} from './coverageParts';
import UkCoverageTracker from './UkCoverageTracker';
import type { Country } from '../../hooks/useCountry';




const MAX_FORWARD_YEAR = new Date().getFullYear() + 5;

/** Parse a verifiedYears string like "2022-2026", "2022+", or "2024" into a Set of year numbers. */
function parseYearRange(verifiedYears?: string): Set<number> {
if (!verifiedYears) return new Set();
const trimmed = verifiedYears.trim();
// "2022+" means from 2022 onward (no end year — uprating covers future)
const openMatch = trimmed.match(/^(\d{4})\+$/);
if (openMatch) {
const start = parseInt(openMatch[1], 10);
const years = new Set<number>();
for (let y = start; y <= MAX_FORWARD_YEAR; y++) years.add(y);
return years;
}
// "2022-2026" range
const rangeMatch = trimmed.match(/^(\d{4})\s*-\s*(\d{4})$/);
if (rangeMatch) {
const start = parseInt(rangeMatch[1], 10);
const end = parseInt(rangeMatch[2], 10);
const years = new Set<number>();
for (let y = start; y <= end; y++) years.add(y);
return years;
}
// Single year "2024"
const singleMatch = trimmed.match(/^(\d{4})$/);
if (singleMatch) return new Set([parseInt(singleMatch[1], 10)]);
return new Set();
}

/** Collect all unique years across all programs' verifiedYears fields. */
function collectAllYears(programs: Program[]): number[] {
const allYears = new Set<number>();
for (const p of programs) {
for (const y of parseYearRange(p.verifiedYears)) allYears.add(y);
}
return Array.from(allYears).sort((a, b) => a - b);
}

/** Check if a program's verifiedYears includes a given year. */
function programIncludesYear(program: Program, year: number): boolean {
return parseYearRange(program.verifiedYears).has(year);
}

const statusLabels: Record<CoverageStatus, string> = {
complete: 'Complete',
partial: 'Partial',
inProgress: 'In progress',
notStarted: 'Not started',
};

const statusIcons: Record<CoverageStatus, string> = {
complete: '\u2713',
partial: '\u25D1',
inProgress: '\u21BB',
notStarted: '\u25CB',
};

function StatusDot({ status, size = 10 }: { status: CoverageStatus; size?: number }) {
return (
<span
className="tw:inline-flex tw:items-center tw:justify-center"
style={{
width: size,
height: size,
borderRadius: '50%',
backgroundColor: statusColors[status],
color: status === 'complete' || status === 'partial' ? colors.white : colors.gray[600],
fontSize: size * 0.7,
lineHeight: 1,
}}
>
{size >= 14 ? statusIcons[status] : ''}
</span>
);
}

function StatCard({ label, count, color, delay }: { label: string; count: number; color: string; delay: number }) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay }}
className="tw:flex-1 tw:text-center"
style={{
minWidth: '120px',
padding: spacing.xl,
borderRadius: spacing.radius.xl,
border: `1px solid ${colors.border.light}`,
backgroundColor: colors.white,
}}
>
<div style={{ fontSize: typography.fontSize['3xl'], fontWeight: typography.fontWeight.bold, color, marginBottom: spacing.xs }}>{count}</div>
<div style={{ fontSize: typography.fontSize.xs, color: colors.text.secondary, fontWeight: typography.fontWeight.medium }}>{label}</div>
</motion.div>
);
}

type ViewMode = 'programs' | 'states';

function ProgramDetailPanel({ program, onClose, allPrograms }: { program: Program; onClose: () => void; allPrograms: Program[] }) {
Expand Down Expand Up @@ -357,7 +268,7 @@ export default function RulesOverview({ country = 'us' }: { country?: Country })
const { programs, source } = loadedPrograms;

useEffect(() => {
// The UK view currently displays a static introduction, not registry data.
// The UK tracker loads its own registry.
if (country === 'uk') return;
let active = true;
fetchProgramsWithSource(country).then(result => {
Expand Down Expand Up @@ -425,20 +336,7 @@ export default function RulesOverview({ country = 'us' }: { country?: Country })
}).sort((a, b) => b.pct - a.pct);
}, [yearFilteredPrograms]);

if (country === 'uk') {
return (
<div>
<p style={{ fontSize: typography.fontSize.lg, color: colors.text.secondary, lineHeight: 1.7, maxWidth: '720px' }}>
PolicyEngine UK models over 50 tax and benefit programs across HMRC, DWP, DfE, Treasury, and devolved administrations including income tax, National Insurance, VAT, Universal Credit, pension credit, child benefit, and many more.
</p>
<p style={{ fontSize: typography.fontSize.sm, color: colors.primary[600], marginTop: spacing.lg }}>
<a href="https://github.com/PolicyEngine/policyengine-uk" target="_blank" rel="noopener noreferrer" style={{ color: colors.primary[600], textDecoration: 'underline' }}>
View the full UK model on GitHub
</a>
</p>
</div>
);
}
if (country === 'uk') return <UkCoverageTracker />;

return (
<div>
Expand Down
Loading