From d97212eb9417a56231cb6683a09ad940fe6df7f6 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 12 Mar 2026 00:07:56 +0100 Subject: [PATCH 01/12] Add search-first variable explorer with computation trees Replace the old Variables page (which just listed programs with GitHub links) with a full explorer of all ~4K variables from the metadata API. Features: - Search across name, label, description, module path, entity, and type - Multi-word search (each word matched independently across all fields) - Level filter pills: Federal, State, Local, Territories, Contrib, Household - Grouped browse mode by agency/state/locality when not searching - Expandable computation tree showing adds/subtracts recursively - Shared metadata cache between variables and programs pages Co-Authored-By: Claude Opus 4.6 --- src/components/variables/ComputationTree.tsx | 283 +++++++++ .../variables/ParameterTimeline.tsx | 126 ++++ src/components/variables/VariableCard.tsx | 116 ++++ src/components/variables/VariableDetail.tsx | 178 ++++++ src/components/variables/VariableExplorer.tsx | 554 ++++++++++++++++++ src/data/fetchMetadata.ts | 34 ++ src/data/fetchPrograms.ts | 8 +- src/hooks/useDebounce.ts | 10 + src/pages/rules/VariablesPage.tsx | 51 +- src/types/Variable.ts | 42 ++ 10 files changed, 1391 insertions(+), 11 deletions(-) create mode 100644 src/components/variables/ComputationTree.tsx create mode 100644 src/components/variables/ParameterTimeline.tsx create mode 100644 src/components/variables/VariableCard.tsx create mode 100644 src/components/variables/VariableDetail.tsx create mode 100644 src/components/variables/VariableExplorer.tsx create mode 100644 src/data/fetchMetadata.ts create mode 100644 src/hooks/useDebounce.ts create mode 100644 src/types/Variable.ts diff --git a/src/components/variables/ComputationTree.tsx b/src/components/variables/ComputationTree.tsx new file mode 100644 index 0000000..1a4f473 --- /dev/null +++ b/src/components/variables/ComputationTree.tsx @@ -0,0 +1,283 @@ +import { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { IconPlus, IconMinus, IconChevronRight, IconChevronDown } from '@tabler/icons-react'; +import type { Variable, Parameter } from '../../types/Variable'; +import { colors, typography, spacing } from '../../designTokens'; + +const MAX_DEPTH = 5; + +interface ComputationTreeProps { + variableName: string; + variables: Record; + parameters: Record; + country: string; + depth?: number; + visited?: Set; +} + +interface TreeNodeProps { + varName: string; + variables: Record; + parameters: Record; + country: string; + depth: number; + visited: Set; + sign: '+' | '-'; +} + +function TreeNode({ + varName, + variables, + parameters, + country, + depth, + visited, + sign, +}: TreeNodeProps) { + const [expanded, setExpanded] = useState(false); + const variable = variables[varName]; + + if (!variable) { + return ( +
+ + {varName} + + (not found) +
+ ); + } + + const hasChildren = (variable.adds?.length ?? 0) > 0 || (variable.subtracts?.length ?? 0) > 0; + const isCircular = visited.has(varName); + const atMaxDepth = depth >= MAX_DEPTH; + + return ( +
0 ? spacing.lg : 0 }}> + + + + {expanded && ( + + {variable.adds?.map((child) => ( + + ))} + {variable.subtracts?.map((child) => ( + + ))} + + )} + + + {atMaxDepth && hasChildren && !isCircular && ( + + View full tree in flowchart → + + )} +
+ ); +} + +export default function ComputationTree({ + variableName, + variables, + parameters, + country, + depth = 0, + visited = new Set(), +}: ComputationTreeProps) { + const variable = variables[variableName]; + if (!variable) return null; + + const hasAdds = (variable.adds?.length ?? 0) > 0; + const hasSubtracts = (variable.subtracts?.length ?? 0) > 0; + + if (!hasAdds && !hasSubtracts) return null; + + const nextVisited = new Set([...visited, variableName]); + + return ( +
+

+ Computation tree +

+
+ {variable.adds?.map((child) => ( + + ))} + {variable.subtracts?.map((child) => ( + + ))} +
+
+ ); +} diff --git a/src/components/variables/ParameterTimeline.tsx b/src/components/variables/ParameterTimeline.tsx new file mode 100644 index 0000000..076e74e --- /dev/null +++ b/src/components/variables/ParameterTimeline.tsx @@ -0,0 +1,126 @@ +import type { Parameter, ParameterLeaf } from '../../types/Variable'; +import { colors, typography, spacing } from '../../designTokens'; + +interface ParameterTimelineProps { + parameters: Record; + moduleName: string | null; + country: string; +} + +function formatValue(val: number | string | boolean, unit: string | null): string { + if (typeof val === 'boolean') return val ? 'Yes' : 'No'; + if (typeof val === 'number') { + if (unit?.startsWith('currency-')) { + const currency = unit === 'currency-GBP' ? '£' : '$'; + return val >= 1000 + ? `${currency}${val.toLocaleString()}` + : `${currency}${val}`; + } + if (unit === '/1') return `${(val * 100).toFixed(1)}%`; + return val.toLocaleString(); + } + return String(val); +} + +function isLeaf(p: Parameter): p is ParameterLeaf { + return p.type === 'parameter'; +} + +export default function ParameterTimeline({ parameters, moduleName, country }: ParameterTimelineProps) { + if (!moduleName) return null; + void country; + + // Find parameters that match this variable's module path + const prefix = moduleName.split('.').slice(0, -1).join('.'); + const matching = Object.values(parameters).filter( + (p) => isLeaf(p) && p.parameter.startsWith(prefix) && Object.keys(p.values).length > 0, + ) as ParameterLeaf[]; + + if (matching.length === 0) return null; + + return ( +
+

+ Parameters ({matching.length}) +

+
+ {matching.slice(0, 10).map((p) => { + const entries = Object.entries(p.values).sort( + ([a], [b]) => a.localeCompare(b), + ); + return ( +
+
+ {p.label || p.parameter.split('.').pop()} +
+ {p.description && ( +
+ {p.description} +
+ )} +
+ {entries.map(([date, val]) => ( + + {date.slice(0, 4)}: {formatValue(val, p.unit)} + + ))} +
+
+ ); + })} + {matching.length > 10 && ( +
+ + {matching.length - 10} more parameters +
+ )} +
+
+ ); +} diff --git a/src/components/variables/VariableCard.tsx b/src/components/variables/VariableCard.tsx new file mode 100644 index 0000000..9451701 --- /dev/null +++ b/src/components/variables/VariableCard.tsx @@ -0,0 +1,116 @@ +import type { Variable } from '../../types/Variable'; +import { colors, typography, spacing } from '../../designTokens'; + +interface VariableCardProps { + variable: Variable; + isSelected: boolean; + onClick: () => void; +} + +const entityColors: Record = { + person: '#7C3AED', + tax_unit: '#2563EB', + spm_unit: '#0891B2', + household: '#059669', + family: '#D97706', + marital_unit: '#DC2626', + benunit: '#0891B2', +}; + +export default function VariableCard({ variable, isSelected, onClick }: VariableCardProps) { + const entityColor = entityColors[variable.entity] || colors.gray[500]; + + return ( + + ); +} diff --git a/src/components/variables/VariableDetail.tsx b/src/components/variables/VariableDetail.tsx new file mode 100644 index 0000000..fcf6536 --- /dev/null +++ b/src/components/variables/VariableDetail.tsx @@ -0,0 +1,178 @@ +import { motion } from 'framer-motion'; +import { IconExternalLink } from '@tabler/icons-react'; +import type { Variable, Parameter } from '../../types/Variable'; +import { colors, typography, spacing } from '../../designTokens'; +import ComputationTree from './ComputationTree'; + +interface VariableDetailProps { + variable: Variable; + variables: Record; + parameters: Record; + country: string; +} + +function formatUnit(unit: string | null, country: string): string { + if (!unit) return 'none'; + if (unit === 'currency-USD') return 'USD ($)'; + if (unit === 'currency-GBP') return 'GBP (£)'; + if (unit === '/1') return 'ratio (0–1)'; + void country; + return unit; +} + +function MetaRow({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + + {value} + +
+ ); +} + +export default function VariableDetail({ variable, variables, parameters, country }: VariableDetailProps) { + const hasTree = (variable.adds?.length ?? 0) > 0 || (variable.subtracts?.length ?? 0) > 0; + const githubRepo = country === 'uk' ? 'policyengine-uk' : 'policyengine-us'; + const modulePath = variable.moduleName?.replace(/\./g, '/'); + + return ( + +
+ {/* Documentation */} + {variable.documentation && ( +

+ {variable.documentation} +

+ )} + + {/* Metadata */} +
+ + + + + + {variable.moduleName && } +
+ + {/* Enum possible values */} + {variable.possibleValues && variable.possibleValues.length > 0 && ( +
+

+ Possible values +

+
+ {variable.possibleValues.map((pv) => ( + + {pv.label || pv.value} + + ))} +
+
+ )} + + {/* Links */} +
+ {variable.moduleName && ( + + View source + + )} + + Full computation flowchart + +
+ + {/* Computation tree */} + {hasTree && ( + + )} +
+
+ ); +} diff --git a/src/components/variables/VariableExplorer.tsx b/src/components/variables/VariableExplorer.tsx new file mode 100644 index 0000000..e47444b --- /dev/null +++ b/src/components/variables/VariableExplorer.tsx @@ -0,0 +1,554 @@ +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { IconSearch, IconChevronDown, IconChevronRight } from '@tabler/icons-react'; +import type { Variable, Parameter } from '../../types/Variable'; +import { colors, typography, spacing } from '../../designTokens'; +import { useDebounce } from '../../hooks/useDebounce'; +import VariableCard from './VariableCard'; +import VariableDetail from './VariableDetail'; + +const PAGE_SIZE = 50; + +interface VariableExplorerProps { + variables: Record; + parameters: Record; + country: string; +} + +type KindFilter = 'all' | 'input' | 'computed'; +type LevelFilter = 'all' | 'federal' | 'state' | 'local' | 'territory' | 'contrib' | 'household'; + +interface VariableGroup { + key: string; + label: string; + description: string; + color: string; + variables: Variable[]; +} + +/** Categorize a variable by its moduleName prefix. */ +function getLevel(v: Variable): LevelFilter { + const m = v.moduleName; + if (!m) return 'household'; + if (m.startsWith('gov.local')) return 'local'; + if (m.startsWith('gov.states')) return 'state'; + if (m.startsWith('gov.territories')) return 'territory'; + if (m.startsWith('contrib')) return 'contrib'; + if (m.startsWith('household') || m.startsWith('input')) return 'household'; + // Everything else under gov.* is federal + if (m.startsWith('gov.')) return 'federal'; + return 'household'; +} + +/** Get a human-readable sub-group label for grouping within a level. */ +function getSubGroup(v: Variable): string { + const m = v.moduleName; + if (!m) return 'Other'; + const parts = m.split('.'); + + if (m.startsWith('gov.states.tax') || m.startsWith('gov.states.general') || + m.startsWith('gov.states.unemployment') || m.startsWith('gov.states.workers')) { + return 'Cross-state'; + } + if (m.startsWith('gov.states.') && parts.length >= 3 && parts[2].length === 2) { + return parts[2].toUpperCase(); + } + if (m.startsWith('gov.local.') && parts.length >= 4) { + const state = parts[2].toUpperCase(); + const locality = parts[3]; + const localityNames: Record = { + la: 'Los Angeles', riv: 'Riverside', nyc: 'New York City', + ala: 'Alameda', sf: 'San Francisco', denver: 'Denver', + harris: 'Harris County', montgomery: 'Montgomery County', + multnomah_county: 'Multnomah County', + }; + return `${localityNames[locality] || locality} (${state})`; + } + if (m.startsWith('gov.territories.') && parts.length >= 3) { + const codes: Record = { pr: 'Puerto Rico', gu: 'Guam', vi: 'US Virgin Islands' }; + return codes[parts[2]] || parts[2].toUpperCase(); + } + if (m.startsWith('gov.')) { + const agencyNames: Record = { + irs: 'IRS', hhs: 'HHS', usda: 'USDA', ssa: 'SSA', hud: 'HUD', + ed: 'Dept. of Education', aca: 'ACA', doe: 'Dept. of Energy', + fcc: 'FCC', puf: 'IRS PUF', simulation: 'Simulation', + }; + return agencyNames[parts[1]] || parts[1].toUpperCase(); + } + if (m.startsWith('contrib.')) { + const names: Record = { + taxsim: 'TAXSIM', ubi_center: 'UBI Center', congress: 'Congressional proposals', + }; + return names[parts[1]] || parts[1]; + } + if (m.startsWith('household.')) { + const names: Record = { + demographic: 'Demographics', income: 'Income', expense: 'Expenses', + assets: 'Assets', marginal_tax_rate: 'Marginal tax rates', cliff: 'Cliff analysis', + }; + return names[parts[1]] || parts[1]; + } + return 'Other'; +} + +const LEVEL_CONFIG: Record = { + federal: { label: 'Federal', description: 'IRS, HHS, USDA, SSA, HUD, and other federal agencies', color: '#1D4ED8', order: 0 }, + state: { label: 'State', description: 'State-level tax and benefit programs across all 50 states + DC', color: '#7C3AED', order: 1 }, + local: { label: 'Local', description: 'City and county programs', color: '#059669', order: 2 }, + territory: { label: 'Territories', description: 'Puerto Rico and other US territories', color: '#0891B2', order: 3 }, + contrib: { label: 'Contributed/Reform', description: 'TAXSIM validation, UBI proposals, and congressional reforms', color: '#D97706', order: 4 }, + household: { label: 'Household inputs', description: 'Demographics, income, expenses, and geographic inputs', color: '#6B7280', order: 5 }, + all: { label: 'All', description: '', color: '#000', order: -1 }, +}; + +function GroupSection({ + group, + variables: vars, + allVariables: allVars, + parameters, + country, + selectedVar, + onSelect, + defaultExpanded, +}: { + group: { key: string; label: string; color: string }; + variables: Variable[]; + allVariables: Record; + parameters: Record; + country: string; + selectedVar: string | null; + onSelect: (name: string) => void; + defaultExpanded: boolean; +}) { + const [expanded, setExpanded] = useState(defaultExpanded); + const [showAll, setShowAll] = useState(false); + const visible = showAll ? vars : vars.slice(0, PAGE_SIZE); + + return ( +
+ + + + {expanded && ( + +
+ {visible.map((v) => ( +
+ onSelect(v.name)} + /> + + {selectedVar === v.name && ( + + )} + +
+ ))} +
+ {vars.length > PAGE_SIZE && !showAll && ( + + )} +
+ )} +
+
+ ); +} + +export default function VariableExplorer({ variables, parameters, country }: VariableExplorerProps) { + const [search, setSearch] = useState(''); + const [entityFilter, setEntityFilter] = useState('all'); + const [kindFilter, setKindFilter] = useState('all'); + const [levelFilter, setLevelFilter] = useState('all'); + const [selectedVar, setSelectedVar] = useState(null); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const sentinelRef = useRef(null); + + const debouncedSearch = useDebounce(search, 200); + + // Derive available entity types from data + const entityTypes = useMemo(() => { + const types = new Set(); + for (const v of Object.values(variables)) { + types.add(v.entity); + } + return Array.from(types).sort(); + }, [variables]); + + // All variables as a flat sorted array (hidden_input excluded) + const allVariables = useMemo(() => { + return Object.values(variables) + .filter((v) => !v.hidden_input) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [variables]); + + // Level counts for filter badges + const levelCounts = useMemo(() => { + const counts: Record = {}; + for (const v of allVariables) { + const level = getLevel(v); + counts[level] = (counts[level] || 0) + 1; + } + return counts; + }, [allVariables]); + + // Filtered list + const filtered = useMemo(() => { + let result = allVariables; + + if (debouncedSearch) { + const words = debouncedSearch.toLowerCase().split(/\s+/).filter(Boolean); + result = result.filter((v) => { + // Build a single searchable string from all fields + const haystack = [ + v.name, + v.label, + v.documentation || '', + v.moduleName || '', + v.entity, + v.valueType, + v.unit || '', + ].join(' ').toLowerCase(); + // Every word must appear somewhere + return words.every((w) => haystack.includes(w)); + }); + } + + if (entityFilter !== 'all') { + result = result.filter((v) => v.entity === entityFilter); + } + + if (kindFilter === 'input') { + result = result.filter((v) => v.isInputVariable); + } else if (kindFilter === 'computed') { + result = result.filter((v) => !v.isInputVariable); + } + + if (levelFilter !== 'all') { + result = result.filter((v) => getLevel(v) === levelFilter); + } + + return result; + }, [allVariables, debouncedSearch, entityFilter, kindFilter, levelFilter]); + + // Group filtered variables by level, then sub-group + const groupedByLevel = useMemo((): VariableGroup[] => { + const levelMap = new Map>(); + + for (const v of filtered) { + const level = getLevel(v); + if (!levelMap.has(level)) levelMap.set(level, new Map()); + const subGroup = getSubGroup(v); + const sub = levelMap.get(level)!; + if (!sub.has(subGroup)) sub.set(subGroup, []); + sub.get(subGroup)!.push(v); + } + + const groups: VariableGroup[] = []; + const sortedLevels = [...levelMap.entries()].sort( + ([a], [b]) => (LEVEL_CONFIG[a]?.order ?? 99) - (LEVEL_CONFIG[b]?.order ?? 99), + ); + + for (const [level, subMap] of sortedLevels) { + const config = LEVEL_CONFIG[level]; + const sortedSubs = [...subMap.entries()].sort(([a], [b]) => a.localeCompare(b)); + for (const [subKey, vars] of sortedSubs) { + groups.push({ + key: `${level}-${subKey}`, + label: `${config.label} — ${subKey}`, + description: config.description, + color: config.color, + variables: vars, + }); + } + } + + return groups; + }, [filtered]); + + // For flat search results (when searching), use infinite scroll + const isSearching = !!debouncedSearch; + + // Reset visible count when filters change + useEffect(() => { + setVisibleCount(PAGE_SIZE); + }, [debouncedSearch, entityFilter, kindFilter, levelFilter]); + + // Infinite scroll for flat search mode + useEffect(() => { + if (!isSearching) return; + const sentinel = sentinelRef.current; + if (!sentinel) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + setVisibleCount((prev) => Math.min(prev + PAGE_SIZE, filtered.length)); + } + }, + { rootMargin: '200px' }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [filtered.length, isSearching]); + + const handleSelect = useCallback((name: string) => { + setSelectedVar((prev) => (prev === name ? null : name)); + }, []); + + const visibleFlat = filtered.slice(0, visibleCount); + + return ( +
+ {/* Search */} +
+ + setSearch(e.target.value)} + style={{ + width: '100%', + padding: `${spacing.md} ${spacing.lg} ${spacing.md} ${spacing['3xl']}`, + borderRadius: spacing.radius.lg, + border: `1px solid ${colors.border.light}`, + fontSize: typography.fontSize.sm, + fontFamily: typography.fontFamily.primary, + outline: 'none', + boxSizing: 'border-box', + }} + /> +
+ + {/* Level filter pills */} +
+ {(['all', 'federal', 'state', 'local', 'territory', 'contrib', 'household'] as LevelFilter[]).map((level) => { + const config = LEVEL_CONFIG[level]; + const count = level === 'all' ? allVariables.length : (levelCounts[level] || 0); + if (level !== 'all' && !count) return null; + const isActive = levelFilter === level; + return ( + + ); + })} +
+ + {/* Filters row */} +
+ {/* Kind toggle */} +
+ {(['all', 'input', 'computed'] as KindFilter[]).map((kind) => ( + + ))} +
+ + {/* Entity dropdown */} + + + {/* Result count */} + + {filtered.length.toLocaleString()} variable{filtered.length !== 1 ? 's' : ''} + {levelFilter !== 'all' && ` in ${LEVEL_CONFIG[levelFilter].label.toLowerCase()}`} + +
+ + {/* Results: grouped when browsing, flat when searching */} + {isSearching ? ( + <> +
+ {visibleFlat.map((v) => ( +
+ handleSelect(v.name)} + /> + + {selectedVar === v.name && ( + + )} + +
+ ))} +
+ {visibleCount < filtered.length && ( +
+ )} + + ) : ( +
+ {groupedByLevel.map((group) => ( + + ))} +
+ )} + + {/* Empty state */} + {filtered.length === 0 && ( +
+ No variables match your search. +
+ )} +
+ ); +} diff --git a/src/data/fetchMetadata.ts b/src/data/fetchMetadata.ts new file mode 100644 index 0000000..266b47d --- /dev/null +++ b/src/data/fetchMetadata.ts @@ -0,0 +1,34 @@ +import type { Metadata } from '../types/Variable'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const rawCache = new Map(); +const metadataCache = new Map(); + +/** Fetch and cache the raw API response (shared with fetchPrograms). */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function fetchMetadataRaw(country: string = 'us'): Promise { + if (rawCache.has(country)) return rawCache.get(country)!; + + const res = await fetch(`https://api.policyengine.org/${country}/metadata`); + if (!res.ok) throw new Error(`API returned ${res.status}`); + const data = await res.json(); + const result = data.result ?? data; + + rawCache.set(country, result); + return result; +} + +/** Fetch and cache structured metadata (variables + parameters). */ +export async function fetchMetadata(country: string = 'us'): Promise { + if (metadataCache.has(country)) return metadataCache.get(country)!; + + const raw = await fetchMetadataRaw(country); + + const result: Metadata = { + variables: raw.variables ?? {}, + parameters: raw.parameters ?? {}, + }; + + metadataCache.set(country, result); + return result; +} diff --git a/src/data/fetchPrograms.ts b/src/data/fetchPrograms.ts index 4917f86..e8d0261 100644 --- a/src/data/fetchPrograms.ts +++ b/src/data/fetchPrograms.ts @@ -103,10 +103,10 @@ export async function fetchPrograms(country: string = 'us'): Promise if (cache.has(country)) return cache.get(country)!; try { - const res = await fetch(`https://api.policyengine.org/${country}/metadata`); - if (!res.ok) throw new Error(`API returned ${res.status}`); - const data = await res.json(); - const apiPrograms: ApiProgram[] = data.result?.modelled_policies?.programs; + // Use the shared metadata fetch to avoid duplicate API calls + const { fetchMetadataRaw } = await import('./fetchMetadata'); + const data = await fetchMetadataRaw(country); + const apiPrograms: ApiProgram[] = data.modelled_policies?.programs; if (!apiPrograms || !Array.isArray(apiPrograms)) { throw new Error('No programs array in API response'); } diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..2a8f85f --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,10 @@ +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + return debounced; +} diff --git a/src/pages/rules/VariablesPage.tsx b/src/pages/rules/VariablesPage.tsx index 3444877..bc55ba7 100644 --- a/src/pages/rules/VariablesPage.tsx +++ b/src/pages/rules/VariablesPage.tsx @@ -1,12 +1,49 @@ -import ProgramListPage from './ProgramListPage'; +import { useState, useEffect } from 'react'; +import PageHeader from '../../components/layout/PageHeader'; +import VariableExplorer from '../../components/variables/VariableExplorer'; +import { fetchMetadata } from '../../data/fetchMetadata'; +import type { Metadata } from '../../types/Variable'; +import { colors, typography, spacing } from '../../designTokens'; export default function VariablesPage({ country }: { country: string }) { + const [metadata, setMetadata] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetchMetadata(country) + .then(setMetadata) + .catch((err) => setError(err.message)); + }, [country]); + return ( - +
+ + + {error && ( +

+ Failed to load metadata: {error} +

+ )} + + {!metadata && !error && ( +
+

+ Loading variables... +

+
+ )} + + {metadata && ( + + )} +
); } diff --git a/src/types/Variable.ts b/src/types/Variable.ts new file mode 100644 index 0000000..f39308b --- /dev/null +++ b/src/types/Variable.ts @@ -0,0 +1,42 @@ +export interface Variable { + name: string; + label: string; + documentation: string | null; + entity: string; + valueType: 'float' | 'int' | 'bool' | 'Enum' | 'str'; + definitionPeriod: string; + unit: string | null; + moduleName: string | null; + indexInModule: number; + isInputVariable: boolean; + defaultValue: number | string | boolean | null; + adds: string[] | null; + subtracts: string[] | null; + hidden_input: boolean; + category: string | null; + possibleValues?: Array<{ value: string; label: string }>; +} + +export interface ParameterLeaf { + type: 'parameter'; + parameter: string; + description: string | null; + label: string; + unit: string | null; + period: string | null; + values: Record; +} + +export interface ParameterNode { + type: 'parameterNode'; + parameter: string; + description: string | null; + label: string; +} + +export type Parameter = ParameterLeaf | ParameterNode; + +export interface Metadata { + variables: Record; + parameters: Record; +} From 4cf390ce09f795f8ded2fcdc7062ab7f76dfc55e Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 12 Mar 2026 11:27:25 +0100 Subject: [PATCH 02/12] Clean up explorer UI and fix adds/subtracts type error - Replace flat group list with drill-in level cards (Federal, State, etc.) - Remove entity filter and input/computed filter toggle (keep as label with tooltip) - Add level filter pills when searching - Fix adds/subtracts which can be objects from the API, not just arrays Co-Authored-By: Claude Opus 4.6 --- src/components/variables/ComputationTree.tsx | 25 +- src/components/variables/VariableCard.tsx | 3 + src/components/variables/VariableDetail.tsx | 4 +- src/components/variables/VariableExplorer.tsx | 476 ++++++++---------- 4 files changed, 233 insertions(+), 275 deletions(-) diff --git a/src/components/variables/ComputationTree.tsx b/src/components/variables/ComputationTree.tsx index 1a4f473..d61dde2 100644 --- a/src/components/variables/ComputationTree.tsx +++ b/src/components/variables/ComputationTree.tsx @@ -6,6 +6,13 @@ import { colors, typography, spacing } from '../../designTokens'; const MAX_DEPTH = 5; +/** Normalize adds/subtracts which may be an array, object, or null. */ +function toArray(val: unknown): string[] { + if (Array.isArray(val)) return val; + if (val && typeof val === 'object') return Object.keys(val); + return []; +} + interface ComputationTreeProps { variableName: string; variables: Record; @@ -57,7 +64,9 @@ function TreeNode({ ); } - const hasChildren = (variable.adds?.length ?? 0) > 0 || (variable.subtracts?.length ?? 0) > 0; + const adds = toArray(variable.adds); + const subtracts = toArray(variable.subtracts); + const hasChildren = adds.length > 0 || subtracts.length > 0; const isCircular = visited.has(varName); const atMaxDepth = depth >= MAX_DEPTH; @@ -164,7 +173,7 @@ function TreeNode({ transition={{ duration: 0.2 }} style={{ overflow: 'hidden' }} > - {variable.adds?.map((child) => ( + {adds.map((child) => ( ))} - {variable.subtracts?.map((child) => ( + {subtracts.map((child) => ( 0; - const hasSubtracts = (variable.subtracts?.length ?? 0) > 0; + const adds = toArray(variable.adds); + const subtracts = toArray(variable.subtracts); - if (!hasAdds && !hasSubtracts) return null; + if (adds.length === 0 && subtracts.length === 0) return null; const nextVisited = new Set([...visited, variableName]); @@ -253,7 +262,7 @@ export default function ComputationTree({ backgroundColor: colors.white, }} > - {variable.adds?.map((child) => ( + {adds.map((child) => ( ))} - {variable.subtracts?.map((child) => ( + {subtracts.map((child) => ( {/* Input/Computed badge */} 0 || (variable.subtracts?.length ?? 0) > 0; + const adds = Array.isArray(variable.adds) ? variable.adds : (variable.adds ? Object.keys(variable.adds) : []); + const subtracts = Array.isArray(variable.subtracts) ? variable.subtracts : (variable.subtracts ? Object.keys(variable.subtracts) : []); + const hasTree = adds.length > 0 || subtracts.length > 0; const githubRepo = country === 'uk' ? 'policyengine-uk' : 'policyengine-us'; const modulePath = variable.moduleName?.replace(/\./g, '/'); diff --git a/src/components/variables/VariableExplorer.tsx b/src/components/variables/VariableExplorer.tsx index e47444b..1c00f04 100644 --- a/src/components/variables/VariableExplorer.tsx +++ b/src/components/variables/VariableExplorer.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { IconSearch, IconChevronDown, IconChevronRight } from '@tabler/icons-react'; +import { IconSearch, IconChevronDown, IconChevronRight, IconArrowLeft } from '@tabler/icons-react'; import type { Variable, Parameter } from '../../types/Variable'; import { colors, typography, spacing } from '../../designTokens'; import { useDebounce } from '../../hooks/useDebounce'; @@ -15,19 +15,10 @@ interface VariableExplorerProps { country: string; } -type KindFilter = 'all' | 'input' | 'computed'; -type LevelFilter = 'all' | 'federal' | 'state' | 'local' | 'territory' | 'contrib' | 'household'; - -interface VariableGroup { - key: string; - label: string; - description: string; - color: string; - variables: Variable[]; -} +type Level = 'federal' | 'state' | 'local' | 'territory' | 'contrib' | 'household'; /** Categorize a variable by its moduleName prefix. */ -function getLevel(v: Variable): LevelFilter { +function getLevel(v: Variable): Level { const m = v.moduleName; if (!m) return 'household'; if (m.startsWith('gov.local')) return 'local'; @@ -35,7 +26,6 @@ function getLevel(v: Variable): LevelFilter { if (m.startsWith('gov.territories')) return 'territory'; if (m.startsWith('contrib')) return 'contrib'; if (m.startsWith('household') || m.startsWith('input')) return 'household'; - // Everything else under gov.* is federal if (m.startsWith('gov.')) return 'federal'; return 'household'; } @@ -92,41 +82,44 @@ function getSubGroup(v: Variable): string { return 'Other'; } -const LEVEL_CONFIG: Record = { - federal: { label: 'Federal', description: 'IRS, HHS, USDA, SSA, HUD, and other federal agencies', color: '#1D4ED8', order: 0 }, - state: { label: 'State', description: 'State-level tax and benefit programs across all 50 states + DC', color: '#7C3AED', order: 1 }, - local: { label: 'Local', description: 'City and county programs', color: '#059669', order: 2 }, - territory: { label: 'Territories', description: 'Puerto Rico and other US territories', color: '#0891B2', order: 3 }, - contrib: { label: 'Contributed/Reform', description: 'TAXSIM validation, UBI proposals, and congressional reforms', color: '#D97706', order: 4 }, - household: { label: 'Household inputs', description: 'Demographics, income, expenses, and geographic inputs', color: '#6B7280', order: 5 }, - all: { label: 'All', description: '', color: '#000', order: -1 }, +const LEVEL_CONFIG: Record = { + federal: { label: 'Federal', description: 'IRS, HHS, USDA, SSA, HUD, and other federal agencies', color: '#1D4ED8', order: 0 }, + state: { label: 'State', description: 'State-level tax and benefit programs across all 50 states + DC', color: '#7C3AED', order: 1 }, + local: { label: 'Local', description: 'City and county programs', color: '#059669', order: 2 }, + territory: { label: 'Territories', description: 'Puerto Rico and other US territories', color: '#0891B2', order: 3 }, + contrib: { label: 'Contributed / Reform', description: 'TAXSIM validation, UBI proposals, and congressional reforms', color: '#D97706', order: 4 }, + household: { label: 'Household inputs', description: 'Demographics, income, expenses, and geographic inputs', color: '#6B7280', order: 5 }, }; -function GroupSection({ - group, - variables: vars, +const LEVELS_ORDERED: Level[] = ['federal', 'state', 'local', 'territory', 'contrib', 'household']; + +// ─── Sub-group section (collapsible list of variables) ─────────────────────── + +function SubGroupSection({ + label, + color, + vars, allVariables: allVars, parameters, country, selectedVar, onSelect, - defaultExpanded, }: { - group: { key: string; label: string; color: string }; - variables: Variable[]; + label: string; + color: string; + vars: Variable[]; allVariables: Record; parameters: Record; country: string; selectedVar: string | null; onSelect: (name: string) => void; - defaultExpanded: boolean; }) { - const [expanded, setExpanded] = useState(defaultExpanded); + const [expanded, setExpanded] = useState(false); const [showAll, setShowAll] = useState(false); const visible = showAll ? vars : vars.slice(0, PAGE_SIZE); return ( -
+
@@ -170,7 +155,7 @@ function GroupSection({ transition={{ duration: 0.2 }} style={{ overflow: 'hidden' }} > -
+
{visible.map((v) => (
- Show all {vars.length} variables + Show all {vars.length} )} @@ -218,126 +203,67 @@ function GroupSection({ ); } +// ─── Main explorer ─────────────────────────────────────────────────────────── + export default function VariableExplorer({ variables, parameters, country }: VariableExplorerProps) { const [search, setSearch] = useState(''); - const [entityFilter, setEntityFilter] = useState('all'); - const [kindFilter, setKindFilter] = useState('all'); - const [levelFilter, setLevelFilter] = useState('all'); + const [activeLevel, setActiveLevel] = useState(null); const [selectedVar, setSelectedVar] = useState(null); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const sentinelRef = useRef(null); const debouncedSearch = useDebounce(search, 200); + const isSearching = !!debouncedSearch; - // Derive available entity types from data - const entityTypes = useMemo(() => { - const types = new Set(); - for (const v of Object.values(variables)) { - types.add(v.entity); - } - return Array.from(types).sort(); - }, [variables]); - - // All variables as a flat sorted array (hidden_input excluded) + // All variables (hidden_input excluded), sorted const allVariables = useMemo(() => { return Object.values(variables) .filter((v) => !v.hidden_input) .sort((a, b) => a.label.localeCompare(b.label)); }, [variables]); - // Level counts for filter badges + // Count per level const levelCounts = useMemo(() => { - const counts: Record = {}; - for (const v of allVariables) { - const level = getLevel(v); - counts[level] = (counts[level] || 0) + 1; - } + const counts: Record = { federal: 0, state: 0, local: 0, territory: 0, contrib: 0, household: 0 }; + for (const v of allVariables) counts[getLevel(v)]++; return counts; }, [allVariables]); - // Filtered list + // Filtered list (search + kind + entity + active level) const filtered = useMemo(() => { let result = allVariables; if (debouncedSearch) { const words = debouncedSearch.toLowerCase().split(/\s+/).filter(Boolean); result = result.filter((v) => { - // Build a single searchable string from all fields - const haystack = [ - v.name, - v.label, - v.documentation || '', - v.moduleName || '', - v.entity, - v.valueType, - v.unit || '', - ].join(' ').toLowerCase(); - // Every word must appear somewhere + const haystack = [v.name, v.label, v.documentation || '', v.moduleName || '', v.entity, v.valueType, v.unit || ''].join(' ').toLowerCase(); return words.every((w) => haystack.includes(w)); }); } - if (entityFilter !== 'all') { - result = result.filter((v) => v.entity === entityFilter); - } - - if (kindFilter === 'input') { - result = result.filter((v) => v.isInputVariable); - } else if (kindFilter === 'computed') { - result = result.filter((v) => !v.isInputVariable); - } - - if (levelFilter !== 'all') { - result = result.filter((v) => getLevel(v) === levelFilter); - } + if (activeLevel) result = result.filter((v) => getLevel(v) === activeLevel); return result; - }, [allVariables, debouncedSearch, entityFilter, kindFilter, levelFilter]); - - // Group filtered variables by level, then sub-group - const groupedByLevel = useMemo((): VariableGroup[] => { - const levelMap = new Map>(); + }, [allVariables, debouncedSearch, activeLevel]); + // Sub-groups for the active level + const subGroups = useMemo(() => { + if (!activeLevel && !isSearching) return []; + const map = new Map(); for (const v of filtered) { - const level = getLevel(v); - if (!levelMap.has(level)) levelMap.set(level, new Map()); - const subGroup = getSubGroup(v); - const sub = levelMap.get(level)!; - if (!sub.has(subGroup)) sub.set(subGroup, []); - sub.get(subGroup)!.push(v); - } - - const groups: VariableGroup[] = []; - const sortedLevels = [...levelMap.entries()].sort( - ([a], [b]) => (LEVEL_CONFIG[a]?.order ?? 99) - (LEVEL_CONFIG[b]?.order ?? 99), - ); - - for (const [level, subMap] of sortedLevels) { - const config = LEVEL_CONFIG[level]; - const sortedSubs = [...subMap.entries()].sort(([a], [b]) => a.localeCompare(b)); - for (const [subKey, vars] of sortedSubs) { - groups.push({ - key: `${level}-${subKey}`, - label: `${config.label} — ${subKey}`, - description: config.description, - color: config.color, - variables: vars, - }); - } + const key = getSubGroup(v); + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(v); } + return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)); + }, [filtered, activeLevel, isSearching]); - return groups; - }, [filtered]); - - // For flat search results (when searching), use infinite scroll - const isSearching = !!debouncedSearch; - - // Reset visible count when filters change + // Reset on filter changes useEffect(() => { setVisibleCount(PAGE_SIZE); - }, [debouncedSearch, entityFilter, kindFilter, levelFilter]); + }, [debouncedSearch, activeLevel]); - // Infinite scroll for flat search mode + // Infinite scroll for search mode useEffect(() => { if (!isSearching) return; const sentinel = sentinelRef.current; @@ -360,6 +286,8 @@ export default function VariableExplorer({ variables, parameters, country }: Var const visibleFlat = filtered.slice(0, visibleCount); + // ─── Render ────────────────────────────────────────────────────────────── + return (
{/* Search */} @@ -367,13 +295,7 @@ export default function VariableExplorer({ variables, parameters, country }: Var
- {/* Level filter pills */} -
- {(['all', 'federal', 'state', 'local', 'territory', 'contrib', 'household'] as LevelFilter[]).map((level) => { - const config = LEVEL_CONFIG[level]; - const count = level === 'all' ? allVariables.length : (levelCounts[level] || 0); - if (level !== 'all' && !count) return null; - const isActive = levelFilter === level; - return ( - - ); - })} -
+ {/* Filters row (shown when drilling in or searching) */} + {(activeLevel || isSearching) && ( +
+ {/* Level filter pills */} + {isSearching && ( +
+ {([null, ...LEVELS_ORDERED] as (Level | null)[]).map((level) => { + const label = level ? LEVEL_CONFIG[level].label : 'All'; + const color = level ? LEVEL_CONFIG[level].color : colors.text.secondary; + const isActive = activeLevel === level; + return ( + + ); + })} +
+ )} - {/* Filters row */} -
- {/* Kind toggle */} -
- {(['all', 'input', 'computed'] as KindFilter[]).map((kind) => ( - - ))} + {/* Count */} + + {filtered.length.toLocaleString()} variable{filtered.length !== 1 ? 's' : ''} +
+ )} - {/* Entity dropdown */} - - - {/* Result count */} - - {filtered.length.toLocaleString()} variable{filtered.length !== 1 ? 's' : ''} - {levelFilter !== 'all' && ` in ${LEVEL_CONFIG[levelFilter].label.toLowerCase()}`} - -
- - {/* Results: grouped when browsing, flat when searching */} - {isSearching ? ( + {/* ─── View: Search results (flat) ─── */} + {isSearching && ( <>
{visibleFlat.map((v) => (
- handleSelect(v.name)} - /> + handleSelect(v.name)} /> {selectedVar === v.name && ( - + )}
))}
- {visibleCount < filtered.length && ( -
+ {visibleCount < filtered.length &&
} + {filtered.length === 0 && ( +
+ No variables match your search. +
)} - ) : ( + )} + + {/* ─── View: Level overview cards (default) ─── */} + {!isSearching && !activeLevel && ( +
+ {LEVELS_ORDERED.map((level) => { + const config = LEVEL_CONFIG[level]; + const count = levelCounts[level]; + if (!count) return null; + return ( + setActiveLevel(level)} + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.3, delay: config.order * 0.05 }} + className="tw:text-left tw:cursor-pointer" + style={{ + padding: spacing.xl, + borderRadius: spacing.radius.xl, + border: `1px solid ${colors.border.light}`, + backgroundColor: colors.white, + fontFamily: typography.fontFamily.primary, + transition: 'border-color 0.15s ease, box-shadow 0.15s ease', + }} + whileHover={{ + borderColor: config.color, + boxShadow: `0 0 0 1px ${config.color}25`, + }} + > +
+ + {config.label} + +
+
+ {count.toLocaleString()} +
+
+ {config.description} +
+
+ ); + })} +
+ )} + + {/* ─── View: Drilled into a level (sub-groups) ─── */} + {!isSearching && activeLevel && (
- {groupedByLevel.map((group) => ( - { setActiveLevel(null); setSelectedVar(null); }} + className="tw:flex tw:items-center tw:cursor-pointer" + style={{ + gap: spacing.sm, + padding: `${spacing.xs} 0`, + border: 'none', + backgroundColor: 'transparent', + fontFamily: typography.fontFamily.primary, + marginBottom: spacing.lg, + color: colors.primary[600], + fontSize: typography.fontSize.sm, + fontWeight: typography.fontWeight.medium, + }} + > + + Back to overview + + +
+

+ {LEVEL_CONFIG[activeLevel].label} +

+ + {filtered.length.toLocaleString()} variables + +
+ + {/* Sub-groups */} + {subGroups.map(([label, vars]) => ( + ))}
)} - - {/* Empty state */} - {filtered.length === 0 && ( -
- No variables match your search. -
- )}
); } From 71e77e247e8aa74778b6beb5a4d132d012c85de4 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Fri, 13 Mar 2026 11:46:35 +0100 Subject: [PATCH 03/12] Add three-level drill-in navigation, flowchart iframe, and remove contrib category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace flat variable list with visual drill-in: level cards → sub-group cards/tiles → variable list - Add StateTileGrid with 2-letter state codes and intensity-based opacity - Add SubGroupCardGrid for federal agencies, local, household, territories - Embed flowchart iframe at bottom that updates when clicking "View in flowchart" on a variable - Remove contributed/reform categories (deprecated paths) - Add level filter pills to search mode Co-Authored-By: Claude Opus 4.6 --- src/components/variables/VariableDetail.tsx | 20 +- src/components/variables/VariableExplorer.tsx | 640 ++++++++++++------ src/pages/rules/VariablesPage.tsx | 89 ++- 3 files changed, 542 insertions(+), 207 deletions(-) diff --git a/src/components/variables/VariableDetail.tsx b/src/components/variables/VariableDetail.tsx index a965632..ecd4596 100644 --- a/src/components/variables/VariableDetail.tsx +++ b/src/components/variables/VariableDetail.tsx @@ -9,6 +9,7 @@ interface VariableDetailProps { variables: Record; parameters: Record; country: string; + onViewFlowchart?: (varName: string) => void; } function formatUnit(unit: string | null, country: string): string { @@ -46,7 +47,7 @@ function MetaRow({ label, value }: { label: string; value: string }) { ); } -export default function VariableDetail({ variable, variables, parameters, country }: VariableDetailProps) { +export default function VariableDetail({ variable, variables, parameters, country, onViewFlowchart }: VariableDetailProps) { const adds = Array.isArray(variable.adds) ? variable.adds : (variable.adds ? Object.keys(variable.adds) : []); const subtracts = Array.isArray(variable.subtracts) ? variable.subtracts : (variable.subtracts ? Object.keys(variable.subtracts) : []); const hasTree = adds.length > 0 || subtracts.length > 0; @@ -149,20 +150,21 @@ export default function VariableDetail({ variable, variables, parameters, countr View source )} - onViewFlowchart?.(variable.name)} + className="tw:flex tw:items-center tw:cursor-pointer" style={{ fontSize: typography.fontSize.xs, color: colors.primary[600], - textDecoration: 'none', + background: 'none', + border: 'none', + padding: 0, + fontFamily: typography.fontFamily.primary, gap: spacing.xs, }} > - Full computation flowchart - + View in flowchart ↓ +
{/* Computation tree */} diff --git a/src/components/variables/VariableExplorer.tsx b/src/components/variables/VariableExplorer.tsx index 1c00f04..9ebe69a 100644 --- a/src/components/variables/VariableExplorer.tsx +++ b/src/components/variables/VariableExplorer.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { IconSearch, IconChevronDown, IconChevronRight, IconArrowLeft } from '@tabler/icons-react'; +import { IconSearch, IconArrowLeft } from '@tabler/icons-react'; import type { Variable, Parameter } from '../../types/Variable'; import { colors, typography, spacing } from '../../designTokens'; import { useDebounce } from '../../hooks/useDebounce'; @@ -13,9 +13,10 @@ interface VariableExplorerProps { variables: Record; parameters: Record; country: string; + onViewFlowchart?: (varName: string) => void; } -type Level = 'federal' | 'state' | 'local' | 'territory' | 'contrib' | 'household'; +type Level = 'federal' | 'state' | 'local' | 'territory' | 'household'; /** Categorize a variable by its moduleName prefix. */ function getLevel(v: Variable): Level { @@ -24,13 +25,13 @@ function getLevel(v: Variable): Level { if (m.startsWith('gov.local')) return 'local'; if (m.startsWith('gov.states')) return 'state'; if (m.startsWith('gov.territories')) return 'territory'; - if (m.startsWith('contrib')) return 'contrib'; + if (m.startsWith('contrib')) return 'household'; // filtered out upstream if (m.startsWith('household') || m.startsWith('input')) return 'household'; if (m.startsWith('gov.')) return 'federal'; return 'household'; } -/** Get a human-readable sub-group label for grouping within a level. */ +/** Get a human-readable sub-group key for grouping within a level. */ function getSubGroup(v: Variable): string { const m = v.moduleName; if (!m) return 'Other'; @@ -44,170 +45,355 @@ function getSubGroup(v: Variable): string { return parts[2].toUpperCase(); } if (m.startsWith('gov.local.') && parts.length >= 4) { - const state = parts[2].toUpperCase(); - const locality = parts[3]; - const localityNames: Record = { - la: 'Los Angeles', riv: 'Riverside', nyc: 'New York City', - ala: 'Alameda', sf: 'San Francisco', denver: 'Denver', - harris: 'Harris County', montgomery: 'Montgomery County', - multnomah_county: 'Multnomah County', - }; - return `${localityNames[locality] || locality} (${state})`; + return `${parts[2].toUpperCase()}-${parts[3]}`; } if (m.startsWith('gov.territories.') && parts.length >= 3) { - const codes: Record = { pr: 'Puerto Rico', gu: 'Guam', vi: 'US Virgin Islands' }; - return codes[parts[2]] || parts[2].toUpperCase(); + return parts[2].toUpperCase(); } if (m.startsWith('gov.')) { + return parts[1]; + } + if (m.startsWith('contrib.')) { + return parts[1]; + } + if (m.startsWith('household.')) { + return parts[1]; + } + return 'Other'; +} + +/** Get a display label for a sub-group key. */ +function getSubGroupLabel(key: string, level: Level): string { + if (level === 'federal') { const agencyNames: Record = { - irs: 'IRS', hhs: 'HHS', usda: 'USDA', ssa: 'SSA', hud: 'HUD', - ed: 'Dept. of Education', aca: 'ACA', doe: 'Dept. of Energy', - fcc: 'FCC', puf: 'IRS PUF', simulation: 'Simulation', + irs: 'Internal Revenue Service (IRS)', hhs: 'Health & Human Services (HHS)', + usda: 'USDA', ssa: 'Social Security Administration (SSA)', + hud: 'Housing & Urban Development (HUD)', ed: 'Dept. of Education', + aca: 'Affordable Care Act (ACA)', doe: 'Dept. of Energy', + fcc: 'Federal Communications Commission (FCC)', puf: 'IRS Public Use File', + simulation: 'Simulation', }; - return agencyNames[parts[1]] || parts[1].toUpperCase(); + return agencyNames[key] || key.toUpperCase(); } - if (m.startsWith('contrib.')) { - const names: Record = { - taxsim: 'TAXSIM', ubi_center: 'UBI Center', congress: 'Congressional proposals', + if (level === 'state') { + const stateNames: Record = { + AL:'Alabama',AK:'Alaska',AZ:'Arizona',AR:'Arkansas',CA:'California',CO:'Colorado', + CT:'Connecticut',DE:'Delaware',DC:'District of Columbia',FL:'Florida',GA:'Georgia', + HI:'Hawaii',ID:'Idaho',IL:'Illinois',IN:'Indiana',IA:'Iowa',KS:'Kansas',KY:'Kentucky', + LA:'Louisiana',ME:'Maine',MD:'Maryland',MA:'Massachusetts',MI:'Michigan',MN:'Minnesota', + MS:'Mississippi',MO:'Missouri',MT:'Montana',NE:'Nebraska',NV:'Nevada',NH:'New Hampshire', + NJ:'New Jersey',NM:'New Mexico',NY:'New York',NC:'North Carolina',ND:'North Dakota', + OH:'Ohio',OK:'Oklahoma',OR:'Oregon',PA:'Pennsylvania',RI:'Rhode Island',SC:'South Carolina', + SD:'South Dakota',TN:'Tennessee',TX:'Texas',UT:'Utah',VT:'Vermont',VA:'Virginia', + WA:'Washington',WV:'West Virginia',WI:'Wisconsin',WY:'Wyoming', }; - return names[parts[1]] || parts[1]; + if (key === 'Cross-state') return 'Cross-state'; + return stateNames[key] || key; } - if (m.startsWith('household.')) { + if (level === 'local') { + const [state, locality] = key.split('-'); + const localityNames: Record = { + la: 'Los Angeles', riv: 'Riverside', nyc: 'New York City', + ala: 'Alameda', sf: 'San Francisco', denver: 'Denver', + harris: 'Harris County', montgomery: 'Montgomery County', + multnomah_county: 'Multnomah County', tax: 'Local taxes', + }; + return `${localityNames[locality] || locality} (${state})`; + } + if (level === 'territory') { + const codes: Record = { PR: 'Puerto Rico', GU: 'Guam', VI: 'US Virgin Islands' }; + return codes[key] || key; + } + if (level === 'household') { const names: Record = { demographic: 'Demographics', income: 'Income', expense: 'Expenses', assets: 'Assets', marginal_tax_rate: 'Marginal tax rates', cliff: 'Cliff analysis', }; - return names[parts[1]] || parts[1]; + return names[key] || key; } - return 'Other'; + return key; +} + +/** Description for federal agency sub-groups. */ +function getSubGroupDescription(key: string, level: Level): string | null { + if (level === 'federal') { + const desc: Record = { + irs: 'Income tax, credits, deductions, and filing rules', + hhs: 'Medicaid, CHIP, ACA subsidies, and poverty guidelines', + usda: 'SNAP, school meals, WIC, and CSFP', + ssa: 'Social Security benefits and payroll taxes', + hud: 'Housing assistance and rental subsidies', + ed: 'Pell grants and student aid', + aca: 'Marketplace eligibility and premium subsidies', + doe: 'Energy efficiency rebates and credits', + fcc: 'Affordable Connectivity Program', + }; + return desc[key] || null; + } + if (level === 'household') { + const desc: Record = { + demographic: 'Age, marital status, disability, and household composition', + income: 'Employment, self-employment, investment, and other income sources', + expense: 'Childcare, medical, housing, and other deductible expenses', + assets: 'Savings, property, and other asset holdings', + marginal_tax_rate: 'Effective marginal rates on additional income', + cliff: 'Benefit cliffs from income changes', + }; + return desc[key] || null; + } + return null; } const LEVEL_CONFIG: Record = { - federal: { label: 'Federal', description: 'IRS, HHS, USDA, SSA, HUD, and other federal agencies', color: '#1D4ED8', order: 0 }, - state: { label: 'State', description: 'State-level tax and benefit programs across all 50 states + DC', color: '#7C3AED', order: 1 }, - local: { label: 'Local', description: 'City and county programs', color: '#059669', order: 2 }, - territory: { label: 'Territories', description: 'Puerto Rico and other US territories', color: '#0891B2', order: 3 }, - contrib: { label: 'Contributed / Reform', description: 'TAXSIM validation, UBI proposals, and congressional reforms', color: '#D97706', order: 4 }, - household: { label: 'Household inputs', description: 'Demographics, income, expenses, and geographic inputs', color: '#6B7280', order: 5 }, + federal: { label: 'Federal', description: 'IRS, HHS, USDA, SSA, HUD, and other federal agencies', color: '#1D4ED8', order: 0 }, + state: { label: 'State', description: 'State-level tax and benefit programs across all 50 states + DC', color: '#7C3AED', order: 1 }, + local: { label: 'Local', description: 'City and county programs', color: '#059669', order: 2 }, + territory: { label: 'Territories', description: 'Puerto Rico and other US territories', color: '#0891B2', order: 3 }, + household: { label: 'Household inputs', description: 'Demographics, income, expenses, and geographic inputs', color: '#6B7280', order: 5 }, }; -const LEVELS_ORDERED: Level[] = ['federal', 'state', 'local', 'territory', 'contrib', 'household']; +const LEVELS_ORDERED: Level[] = ['federal', 'state', 'local', 'territory', 'household']; -// ─── Sub-group section (collapsible list of variables) ─────────────────────── +// ─── Variable list (shown after selecting a sub-group) ────────────────────── -function SubGroupSection({ - label, - color, +function VariableList({ vars, - allVariables: allVars, + allVariables, parameters, country, selectedVar, onSelect, + onViewFlowchart, }: { - label: string; - color: string; vars: Variable[]; allVariables: Record; parameters: Record; country: string; selectedVar: string | null; onSelect: (name: string) => void; + onViewFlowchart?: (varName: string) => void; }) { - const [expanded, setExpanded] = useState(false); const [showAll, setShowAll] = useState(false); const visible = showAll ? vars : vars.slice(0, PAGE_SIZE); return ( -
- + )} +
+ ); +} + +// ─── State tile grid ───────────────────────────────────────────────────────── + +function StateTileGrid({ + subGroups, + levelColor, + onSelect, +}: { + subGroups: [string, Variable[]][]; + levelColor: string; + onSelect: (key: string) => void; +}) { + // Separate Cross-state from actual states + const states = subGroups.filter(([k]) => k !== 'Cross-state' && k.length === 2); + const other = subGroups.filter(([k]) => k === 'Cross-state' || k.length !== 2); + const maxCount = Math.max(...states.map(([, v]) => v.length), 1); + + return ( +
+
- {expanded ? ( - - ) : ( - - )} - - {label} - - - ({vars.length}) - - - - - {expanded && ( - { + const intensity = Math.max(0.15, vars.length / maxCount); + return ( + onSelect(key)} + initial={{ opacity: 0, scale: 0.9 }} + animate={{ opacity: 1, scale: 1 }} + transition={{ duration: 0.2 }} + className="tw:cursor-pointer tw:text-center" + style={{ + padding: spacing.md, + borderRadius: spacing.radius.lg, + border: `1px solid ${colors.border.light}`, + backgroundColor: colors.white, + fontFamily: typography.fontFamily.primary, + transition: 'border-color 0.15s ease, box-shadow 0.15s ease', + }} + whileHover={{ + borderColor: levelColor, + boxShadow: `0 0 0 1px ${levelColor}25`, + }} + > +
+ {key} +
+
+ {vars.length} +
+
+ ); + })} +
+ {other.length > 0 && ( +
+ {other.map(([key, vars]) => ( + + ))} +
+ )} +
+ ); +} + +// ─── Sub-group card grid (for federal, local, household, territory) ────────── + +function SubGroupCardGrid({ + subGroups, + level, + levelColor, + onSelect, +}: { + subGroups: [string, Variable[]][]; + level: Level; + levelColor: string; + onSelect: (key: string) => void; +}) { + return ( +
+ {subGroups.map(([key, vars], i) => { + const label = getSubGroupLabel(key, level); + const desc = getSubGroupDescription(key, level); + return ( + onSelect(key)} + initial={{ opacity: 0, y: 8 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.2, delay: i * 0.03 }} + className="tw:text-left tw:cursor-pointer" + style={{ + padding: spacing.lg, + borderRadius: spacing.radius.xl, + border: `1px solid ${colors.border.light}`, + backgroundColor: colors.white, + fontFamily: typography.fontFamily.primary, + transition: 'border-color 0.15s ease, box-shadow 0.15s ease', + }} + whileHover={{ + borderColor: levelColor, + boxShadow: `0 0 0 1px ${levelColor}25`, + }} > -
- {visible.map((v) => ( -
- onSelect(v.name)} - /> - - {selectedVar === v.name && ( - - )} - -
- ))} +
+ {label}
- {vars.length > PAGE_SIZE && !showAll && ( - +
+ {vars.length} +
+ {desc && ( +
+ {desc} +
)} - - )} - + + ); + })}
); } // ─── Main explorer ─────────────────────────────────────────────────────────── -export default function VariableExplorer({ variables, parameters, country }: VariableExplorerProps) { +export default function VariableExplorer({ variables, parameters, country, onViewFlowchart }: VariableExplorerProps) { const [search, setSearch] = useState(''); const [activeLevel, setActiveLevel] = useState(null); + const [activeSubGroup, setActiveSubGroup] = useState(null); const [selectedVar, setSelectedVar] = useState(null); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const sentinelRef = useRef(null); @@ -215,21 +401,21 @@ export default function VariableExplorer({ variables, parameters, country }: Var const debouncedSearch = useDebounce(search, 200); const isSearching = !!debouncedSearch; - // All variables (hidden_input excluded), sorted + // All variables (hidden_input + contrib excluded), sorted const allVariables = useMemo(() => { return Object.values(variables) - .filter((v) => !v.hidden_input) + .filter((v) => !v.hidden_input && !v.moduleName?.startsWith('contrib')) .sort((a, b) => a.label.localeCompare(b.label)); }, [variables]); // Count per level const levelCounts = useMemo(() => { - const counts: Record = { federal: 0, state: 0, local: 0, territory: 0, contrib: 0, household: 0 }; + const counts: Record = { federal: 0, state: 0, local: 0, territory: 0, household: 0 }; for (const v of allVariables) counts[getLevel(v)]++; return counts; }, [allVariables]); - // Filtered list (search + kind + entity + active level) + // Filtered list const filtered = useMemo(() => { let result = allVariables; @@ -248,7 +434,7 @@ export default function VariableExplorer({ variables, parameters, country }: Var // Sub-groups for the active level const subGroups = useMemo(() => { - if (!activeLevel && !isSearching) return []; + if (!activeLevel) return []; const map = new Map(); for (const v of filtered) { const key = getSubGroup(v); @@ -256,12 +442,19 @@ export default function VariableExplorer({ variables, parameters, country }: Var map.get(key)!.push(v); } return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)); - }, [filtered, activeLevel, isSearching]); + }, [filtered, activeLevel]); + + // Variables in active sub-group + const subGroupVars = useMemo(() => { + if (!activeSubGroup) return []; + const entry = subGroups.find(([k]) => k === activeSubGroup); + return entry ? entry[1] : []; + }, [subGroups, activeSubGroup]); // Reset on filter changes useEffect(() => { setVisibleCount(PAGE_SIZE); - }, [debouncedSearch, activeLevel]); + }, [debouncedSearch, activeLevel, activeSubGroup]); // Infinite scroll for search mode useEffect(() => { @@ -286,6 +479,23 @@ export default function VariableExplorer({ variables, parameters, country }: Var const visibleFlat = filtered.slice(0, visibleCount); + // Navigation helpers + const goBack = () => { + if (activeSubGroup) { + setActiveSubGroup(null); + setSelectedVar(null); + } else { + setActiveLevel(null); + setSelectedVar(null); + } + }; + + const breadcrumb = activeLevel + ? activeSubGroup + ? `${LEVEL_CONFIG[activeLevel].label} / ${getSubGroupLabel(activeSubGroup, activeLevel)}` + : LEVEL_CONFIG[activeLevel].label + : null; + // ─── Render ────────────────────────────────────────────────────────────── return ( @@ -315,40 +525,35 @@ export default function VariableExplorer({ variables, parameters, country }: Var />
- {/* Filters row (shown when drilling in or searching) */} - {(activeLevel || isSearching) && ( + {/* Search filter pills */} + {isSearching && (
- {/* Level filter pills */} - {isSearching && ( -
- {([null, ...LEVELS_ORDERED] as (Level | null)[]).map((level) => { - const label = level ? LEVEL_CONFIG[level].label : 'All'; - const color = level ? LEVEL_CONFIG[level].color : colors.text.secondary; - const isActive = activeLevel === level; - return ( - - ); - })} -
- )} - - {/* Count */} +
+ {([null, ...LEVELS_ORDERED] as (Level | null)[]).map((level) => { + const label = level ? LEVEL_CONFIG[level].label : 'All'; + const clr = level ? LEVEL_CONFIG[level].color : colors.text.secondary; + const isActive = activeLevel === level; + return ( + + ); + })} +
{filtered.length.toLocaleString()} variable{filtered.length !== 1 ? 's' : ''} @@ -364,7 +569,7 @@ export default function VariableExplorer({ variables, parameters, country }: Var handleSelect(v.name)} /> {selectedVar === v.name && ( - + )}
@@ -392,7 +597,7 @@ export default function VariableExplorer({ variables, parameters, country }: Var return ( setActiveLevel(level)} + onClick={() => { setActiveLevel(level); setActiveSubGroup(null); }} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3, delay: config.order * 0.05 }} @@ -440,23 +645,17 @@ export default function VariableExplorer({ variables, parameters, country }: Var
)} - {/* ─── View: Drilled into a level (sub-groups) ─── */} - {!isSearching && activeLevel && ( + {/* ─── View: Drilled into a level ─── */} + {!isSearching && activeLevel && !activeSubGroup && (
- {/* Back button + level header */}
-

- {LEVEL_CONFIG[activeLevel].label} -

- - {filtered.length.toLocaleString()} variables - +

+ {LEVEL_CONFIG[activeLevel].label} +

+ + {filtered.length.toLocaleString()} variables across {subGroups.length} groups +
- {/* Sub-groups */} - {subGroups.map(([label, vars]) => ( - - ))} + )} + + {/* Other levels: card grid */} + {activeLevel !== 'state' && ( + + )} +
+ )} + + {/* ─── View: Drilled into a sub-group (variable list) ─── */} + {!isSearching && activeLevel && activeSubGroup && ( +
+ + +
+

+ {getSubGroupLabel(activeSubGroup, activeLevel)} +

+ + {subGroupVars.length} variables + +
+ +
)}
diff --git a/src/pages/rules/VariablesPage.tsx b/src/pages/rules/VariablesPage.tsx index bc55ba7..4222690 100644 --- a/src/pages/rules/VariablesPage.tsx +++ b/src/pages/rules/VariablesPage.tsx @@ -1,13 +1,89 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; +import { IconExternalLink } from '@tabler/icons-react'; import PageHeader from '../../components/layout/PageHeader'; import VariableExplorer from '../../components/variables/VariableExplorer'; import { fetchMetadata } from '../../data/fetchMetadata'; import type { Metadata } from '../../types/Variable'; import { colors, typography, spacing } from '../../designTokens'; +const FLOWCHART_BASE = 'https://policyengine.github.io/flowchart'; +const DEFAULT_VAR = 'household_net_income'; + +function FlowchartPreview({ country, variable, sectionRef }: { + country: string; + variable: string; + sectionRef: React.RefObject; +}) { + const flowchartUrl = `${FLOWCHART_BASE}/?variable=${variable}&country=${country.toUpperCase()}`; + + return ( +
+
+
+

+ Computation flowchart +

+

+ {variable === DEFAULT_VAR + ? 'See how any variable is calculated from its dependencies' + : <>Showing {variable} + } +

+
+ + Open in new tab + +
+
+