diff --git a/.gitignore b/.gitignore index ab4f43e..b3b938d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ dist-ssr # Cloned repos used for research policyengine-uk-data + +# Pre-built metadata (generated at build time, ~58MB each) +public/metadata-*.json +public/param-tree-*.json diff --git a/package.json b/package.json index 0f585b0..acbf3e1 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "fetch-metadata": "node scripts/fetch-metadata.js", + "build": "node scripts/fetch-metadata.js && tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", "test": "vitest run", diff --git a/scripts/fetch-metadata.js b/scripts/fetch-metadata.js new file mode 100644 index 0000000..10833b2 --- /dev/null +++ b/scripts/fetch-metadata.js @@ -0,0 +1,111 @@ +/** + * Pre-fetches metadata from the PolicyEngine API and the GitHub repo file tree, + * writing them to public/ so the app loads from CDN instead of hitting APIs at runtime. + * + * Run: node scripts/fetch-metadata.js + * Called automatically during `bun run build`. + */ + +import { writeFileSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { execSync } from 'child_process'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PUBLIC_DIR = join(__dirname, '..', 'public'); + +const COUNTRIES = ['us', 'uk']; +const API_BASE = 'https://api.policyengine.org'; + +const REPO_MAP = { + us: 'policyengine-us', + uk: 'policyengine-uk', +}; + +async function fetchCountryMetadata(country) { + const url = `${API_BASE}/${country}/metadata`; + console.log(`Fetching ${country} metadata from ${url}...`); + + const start = Date.now(); + const res = await fetch(url); + if (!res.ok) throw new Error(`${country}: API returned ${res.status}`); + + const data = await res.json(); + const result = data.result ?? data; + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + console.log(` ${country}: fetched in ${elapsed}s`); + + return result; +} + +/** + * Fetch the list of .yaml files under parameters/ in the repo. + * Uses `gh` CLI → Git Trees API (fetches only the parameters subtree). + */ +function fetchParameterYamlPaths(country) { + const repo = REPO_MAP[country]; + const repoDir = repo.replace('-', '_'); + console.log(` ${country}: fetching parameter file tree from ${repo}...`); + + try { + // Get the SHA of policyengine_{country}/parameters/ subtree + const topTree = execSync( + `gh api repos/PolicyEngine/${repo}/git/trees/main -q '.tree[] | select(.path == "${repoDir}") | .sha'`, + { encoding: 'utf-8' }, + ).trim(); + + const paramsTree = execSync( + `gh api "repos/PolicyEngine/${repo}/git/trees/${topTree}" -q '.tree[] | select(.path == "parameters") | .sha'`, + { encoding: 'utf-8' }, + ).trim(); + + // Fetch recursive tree of parameters/ and filter to .yaml files + const output = execSync( + `gh api "repos/PolicyEngine/${repo}/git/trees/${paramsTree}?recursive=1" --paginate -q '.tree[] | select(.type == "blob") | select(.path | endswith(".yaml")) | .path'`, + { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }, + ); + + const paths = output.trim().split('\n') + .filter(p => p && !p.startsWith('calibration/')); + + console.log(` ${country}: found ${paths.length} parameter YAML files`); + return paths; + } catch (err) { + console.error(` ${country}: gh CLI failed — ${err.message}`); + return null; + } +} + +async function main() { + mkdirSync(PUBLIC_DIR, { recursive: true }); + + for (const country of COUNTRIES) { + try { + const metadata = await fetchCountryMetadata(country); + + const outPath = join(PUBLIC_DIR, `metadata-${country}.json`); + writeFileSync(outPath, JSON.stringify(metadata)); + + const sizeMB = (Buffer.byteLength(JSON.stringify(metadata)) / 1024 / 1024).toFixed(1); + console.log(` ${country}: wrote ${outPath} (${sizeMB} MB)`); + } catch (err) { + console.error(` ${country}: metadata FAILED — ${err.message}`); + } + + // Fetch parameter YAML file tree for direct source links + try { + const yamlPaths = fetchParameterYamlPaths(country); + if (yamlPaths) { + const treePath = join(PUBLIC_DIR, `param-tree-${country}.json`); + writeFileSync(treePath, JSON.stringify(yamlPaths)); + console.log(` ${country}: wrote ${treePath} (${yamlPaths.length} files)`); + } + } catch (err) { + console.error(` ${country}: param tree FAILED — ${err.message}`); + } + } + + console.log('Done.'); +} + +main(); diff --git a/src/components/parameters/ParameterCard.tsx b/src/components/parameters/ParameterCard.tsx new file mode 100644 index 0000000..5dce81b --- /dev/null +++ b/src/components/parameters/ParameterCard.tsx @@ -0,0 +1,141 @@ +import type { ParameterLeaf, ParameterNode } from '../../types/Variable'; +import { colors, typography, spacing } from '../../designTokens'; + +interface ParameterCardProps { + parameter: ParameterLeaf | ParameterNode; + isSelected: boolean; + onClick: () => void; +} + +function getCurrentValue(param: ParameterLeaf): { text: string; isList: boolean; count: number } { + const entries = Object.entries(param.values).sort(([a], [b]) => b.localeCompare(a)); + if (entries.length === 0) return { text: '—', isList: false, count: 0 }; + const [, val] = entries[0]; + // Handle arrays (list-type parameters) + if (Array.isArray(val)) { + if (val.length === 0) return { text: '(empty)', isList: true, count: 0 }; + return { text: val.join(', '), isList: true, count: val.length }; + } + if (typeof val === 'boolean') return { text: val ? 'true' : 'false', isList: false, count: 0 }; + if (typeof val === 'number') { + if (param.unit === '/1') return { text: `${(val * 100).toFixed(1)}%`, isList: false, count: 0 }; + return { text: val.toLocaleString(), isList: false, count: 0 }; + } + const str = String(val); + if (str.includes(',')) { + const items = str.split(',').map(s => s.trim()).filter(Boolean); + return { text: str, isList: true, count: items.length }; + } + return { text: str, isList: false, count: 0 }; +} + +export default function ParameterCard({ parameter: param, isSelected, onClick }: ParameterCardProps) { + const isLeaf = param.type === 'parameter'; + + return ( + + ); +} diff --git a/src/components/parameters/ParameterDetail.tsx b/src/components/parameters/ParameterDetail.tsx new file mode 100644 index 0000000..c9ca9f0 --- /dev/null +++ b/src/components/parameters/ParameterDetail.tsx @@ -0,0 +1,353 @@ +import { useState, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { IconExternalLink, IconChevronDown, IconChevronRight } from '@tabler/icons-react'; +import type { Parameter, ParameterLeaf } from '../../types/Variable'; +import { colors, typography, spacing } from '../../designTokens'; + +interface ParameterDetailProps { + parameter: Parameter; + country: string; +} + +function formatUnit(unit: string | null): string { + if (!unit) return 'none'; + if (unit === 'currency-USD') return 'USD ($)'; + if (unit === 'currency-GBP') return 'GBP (£)'; + if (unit === '/1') return 'ratio (0–1)'; + return unit; +} + +function MetaRow({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + + {value} + +
+ ); +} + +function formatValue(val: number | string | boolean | string[], unit: string | null): { text: string; items: string[] | null } { + // Handle arrays (list-type parameters) + if (Array.isArray(val)) { + if (val.length === 0) return { text: '(empty)', items: [] }; + return { text: val.join(', '), items: val }; + } + if (typeof val === 'boolean') return { text: val ? 'true' : 'false', items: null }; + if (typeof val === 'number') { + if (unit === '/1') return { text: `${(val * 100).toFixed(2)}%`, items: null }; + if (unit?.startsWith('currency-')) return { text: val.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }), items: null }; + return { text: val.toLocaleString(), items: null }; + } + const str = String(val); + if (str.includes(',')) { + const items = str.split(',').map(s => s.trim()).filter(Boolean); + return { text: str, items }; + } + return { text: str, items: null }; +} + +const PROJECTION_CUTOFF = '2026-01-01'; + +function ValueRow({ date, val, nextDate, unit, isProjected }: { + date: string; + val: number | string | boolean | string[]; + nextDate: string | null; + unit: string | null; + isProjected: boolean; + isLast?: boolean; +}) { + const { text, items } = formatValue(val, unit); + const dateLabel = nextDate ? `${date} → ${nextDate}` : `${date} → present`; + + return ( +
+
+ + {dateLabel} + + {!items && ( + + {text} + + )} + {items && ( + + {items.length} items + + )} +
+ {items && ( +
+ {items.map((item) => ( + + {item.replace(/_/g, ' ')} + + ))} +
+ )} +
+ ); +} + +function ValueTimeline({ param }: { param: ParameterLeaf }) { + const allEntries = Object.entries(param.values).sort(([a], [b]) => a.localeCompare(b)); + if (allEntries.length === 0) return null; + + const legislated = allEntries.filter(([d]) => d < PROJECTION_CUTOFF); + const projected = allEntries.filter(([d]) => d >= PROJECTION_CUTOFF); + const hasProjected = projected.length > 1; // Only collapse if there are multiple projected entries + + const [showProjected, setShowProjected] = useState(false); + + return ( +
+

+ Value history +

+
+ {/* Legislated values */} + {legislated.map(([date, val], i) => { + const nextDate = i < legislated.length - 1 + ? legislated[i + 1][0] + : projected.length > 0 ? projected[0][0] : null; + return ( +
+ +
+ ); + })} + + {/* Projected values section */} + {hasProjected && ( + <> + + {showProjected && projected.map(([date, val], i) => { + const nextDate = i < projected.length - 1 ? projected[i + 1][0] : null; + return ( +
+ +
+ ); + })} + + )} + + {/* Single projected entry (no collapse needed) */} + {projected.length === 1 && ( +
+ +
+ )} +
+
+ ); +} + +// ─── Parameter tree cache (loaded once per country) ────────────────────────── + +const treeCache = new Map | null>(); + +async function loadParamTree(country: string): Promise | null> { + if (treeCache.has(country)) return treeCache.get(country)!; + try { + const res = await fetch(`${import.meta.env.BASE_URL}param-tree-${country}.json`); + if (!res.ok) throw new Error('not found'); + const paths: string[] = await res.json(); + const set = new Set(paths); + treeCache.set(country, set); + return set; + } catch { + treeCache.set(country, null); + return null; + } +} + +/** + * Find the exact YAML file for a parameter path by trying progressively + * shorter path prefixes against the pre-built file tree. + */ +function findYamlFile(paramPath: string, yamlFiles: Set): string | null { + const parts = paramPath.replace(/\./g, '/').split('/'); + for (let i = parts.length; i > 0; i--) { + const candidate = parts.slice(0, i).join('/') + '.yaml'; + if (yamlFiles.has(candidate)) return candidate; + } + return null; +} + +function getGitHubUrl(paramPath: string, githubRepo: string, yamlFile: string | null): string { + const repoDir = githubRepo.replace('-', '_'); + if (yamlFile) { + return `https://github.com/PolicyEngine/${githubRepo}/blob/main/${repoDir}/parameters/${yamlFile}`; + } + // Fallback: link to parent directory + const parts = paramPath.split('.'); + while (parts.length > 1 && (/^[A-Z_]+$/.test(parts[parts.length - 1]) || /^\d+$/.test(parts[parts.length - 1]))) { + parts.pop(); + } + const dirParts = parts.slice(0, Math.max(parts.length - 2, 1)); + return `https://github.com/PolicyEngine/${githubRepo}/tree/main/${repoDir}/parameters/${dirParts.join('/')}`; +} + +export default function ParameterDetail({ parameter: param, country }: ParameterDetailProps) { + const isLeaf = param.type === 'parameter'; + const githubRepo = country === 'uk' ? 'policyengine-uk' : 'policyengine-us'; + + const [yamlFile, setYamlFile] = useState(null); + const [treeLoaded, setTreeLoaded] = useState(false); + + useEffect(() => { + loadParamTree(country).then((tree) => { + if (tree) { + setYamlFile(findYamlFile(param.parameter, tree)); + } + setTreeLoaded(true); + }); + }, [country, param.parameter]); + + const sourceUrl = treeLoaded ? getGitHubUrl(param.parameter, githubRepo, yamlFile) : '#'; + + return ( + +
+ {/* Description */} + {param.description && ( +

+ {param.description} +

+ )} + + {/* Metadata */} +
+ + + {isLeaf && } + {isLeaf && (param as ParameterLeaf).period && ( + + )} +
+ + {/* Value history */} + {isLeaf && } + + {/* Links */} + +
+
+ ); +} diff --git a/src/components/parameters/ParameterExplorer.tsx b/src/components/parameters/ParameterExplorer.tsx new file mode 100644 index 0000000..14034f5 --- /dev/null +++ b/src/components/parameters/ParameterExplorer.tsx @@ -0,0 +1,1161 @@ +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { IconSearch, IconArrowLeft, IconFolder, IconChevronLeft, IconChevronRight } from '@tabler/icons-react'; +import type { Parameter, ParameterLeaf } from '../../types/Variable'; +import { colors, typography, spacing } from '../../designTokens'; +import { useDebounce } from '../../hooks/useDebounce'; +import { + getLevel, getSubGroup, getSubGroupLabel, getSubGroupDescription, + LEVEL_CONFIG, LEVELS_ORDERED, + type Level, +} from '../shared/categoryUtils'; +import ParameterCard from './ParameterCard'; +import ParameterDetail from './ParameterDetail'; + +const PAGE_SIZE = 50; + +// ─── Pagination ───────────────────────────────────────────────────────────── + +function Pagination({ page, totalPages, onPageChange }: { + page: number; + totalPages: number; + onPageChange: (page: number) => void; +}) { + if (totalPages <= 1) return null; + + // Build page numbers: always show first, last, current ± 2, with ellipsis gaps + const pages: (number | '...')[] = []; + const addPage = (p: number) => { + if (p >= 1 && p <= totalPages && !pages.includes(p)) pages.push(p); + }; + + addPage(1); + for (let i = Math.max(2, page - 2); i <= Math.min(totalPages - 1, page + 2); i++) { + addPage(i); + } + addPage(totalPages); + + // Insert ellipsis where there are gaps + const withEllipsis: (number | '...')[] = []; + for (let i = 0; i < pages.length; i++) { + const p = pages[i]; + if (i > 0 && typeof p === 'number' && typeof pages[i - 1] === 'number' && p - (pages[i - 1] as number) > 1) { + withEllipsis.push('...'); + } + withEllipsis.push(p); + } + + const btnBase: React.CSSProperties = { + minWidth: '32px', + height: '32px', + borderRadius: spacing.radius.md, + border: `1px solid ${colors.border.light}`, + backgroundColor: colors.white, + fontFamily: typography.fontFamily.primary, + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeight.medium, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: `0 ${spacing.sm}`, + }; + + return ( +
+ + {withEllipsis.map((p, i) => + p === '...' ? ( + + ... + + ) : ( + + ), + )} + +
+ ); +} + +// ─── Breadcrumb navigation ────────────────────────────────────────────────── + +function BreadcrumbNav({ items, onBack }: { + items: { label: string; onClick: () => void }[]; + onBack: () => void; +}) { + return ( +
+ + {items.map((item, i) => { + const isLast = i === items.length - 1; + return ( + + {i > 0 && ( + / + )} + {isLast ? ( + + {item.label} + + ) : ( + + )} + + ); + })} +
+ ); +} + +interface ParameterExplorerProps { + parameters: Record; + country: string; +} + +/** Count unique file-level parameter groups in a list of leaves. */ +function countUniqueGroups(params: ParameterLeaf[]): number { + const seen = new Set(); + for (const p of params) seen.add(getParameterGroup(p.parameter)); + return seen.size; +} + +/** Map a parameter path to its logical file-level group. + * Strips bracket indices (e.g. [0].amount), trailing ALL_CAPS enum values, + * and trailing pure-numeric segments so that all leaves from one YAML file + * collapse into a single group. */ +function getParameterGroup(path: string): string { + // Strip bracket indices and everything after them + const clean = path.replace(/\[\d+\].*/g, ''); + const parts = clean.split('.'); + // Strip trailing ALL_CAPS enum values and pure-numeric segments + while (parts.length > 1 && (/^[A-Z][A-Z_0-9]+$/.test(parts[parts.length - 1]) || /^\d+$/.test(parts[parts.length - 1]))) { + parts.pop(); + } + return parts.join('.'); +} + +/** Get a display label for a parameter group or folder, using the parameterNode if available. */ +function getGroupLabel(groupKey: string, allParameters: Record): string { + const node = allParameters[groupKey]; + if (node && node.label) return node.label; + const parts = groupKey.split('.'); + return parts[parts.length - 1].replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function getGroupDescription(groupKey: string, allParameters: Record): string | null { + const node = allParameters[groupKey]; + return node?.description || null; +} + +// ─── Folder tree helpers ──────────────────────────────────────────────────── + +/** Find the longest common dot-separated prefix of a list of paths. */ +function commonPrefix(paths: string[]): string { + if (paths.length === 0) return ''; + const parts0 = paths[0].split('.'); + let len = parts0.length; + for (const p of paths.slice(1)) { + const parts = p.split('.'); + len = Math.min(len, parts.length); + for (let i = 0; i < len; i++) { + if (parts[i] !== parts0[i]) { len = i; break; } + } + } + return parts0.slice(0, len).join('.'); +} + +/** + * At a given folder prefix, compute sub-folders and direct parameter groups. + * If a segment appears both as a direct group and has deeper groups, it becomes a folder. + */ +function getFolderContents( + groups: [string, ParameterLeaf[]][], + prefix: string, +): { + folders: { segment: string; fullPath: string; paramCount: number; groupCount: number }[]; + directGroups: [string, ParameterLeaf[]][]; +} { + const prefixDot = prefix + '.'; + + // Collect all groups by their next segment + const bySegment = new Map(); + + for (const [groupKey, params] of groups) { + if (!groupKey.startsWith(prefixDot)) continue; + const remaining = groupKey.slice(prefixDot.length); + const segments = remaining.split('.'); + const segment = segments[0]; + + if (!bySegment.has(segment)) bySegment.set(segment, { direct: null, deeper: [] }); + const entry = bySegment.get(segment)!; + + if (segments.length === 1) { + entry.direct = [groupKey, params]; + } else { + entry.deeper.push([groupKey, params]); + } + } + + const folders: { segment: string; fullPath: string; paramCount: number; groupCount: number }[] = []; + const directGroups: [string, ParameterLeaf[]][] = []; + + // Also include a group that exactly matches the prefix (params at this node level) + const exactGroup = groups.find(([k]) => k === prefix); + if (exactGroup) { + directGroups.push(exactGroup); + } + + for (const [segment, entry] of [...bySegment.entries()].sort(([a], [b]) => a.localeCompare(b))) { + const fullPath = `${prefix}.${segment}`; + + if (entry.deeper.length > 0) { + // It's a folder — include any direct group params in the count + let paramCount = entry.deeper.reduce((sum, [, p]) => sum + p.length, 0); + let groupCount = entry.deeper.length; + if (entry.direct) { + paramCount += entry.direct[1].length; + groupCount += 1; + } + folders.push({ segment, fullPath, paramCount, groupCount }); + } else if (entry.direct) { + directGroups.push(entry.direct); + } + } + + return { folders, directGroups }; +} + +/** Auto-collapse: skip folder levels that have exactly 1 sub-folder and 0 direct groups. */ +function autoCollapseFolder( + groups: [string, ParameterLeaf[]][], + prefix: string, +): string { + let current = prefix; + for (let i = 0; i < 10; i++) { + const { folders, directGroups } = getFolderContents(groups, current); + if (folders.length === 1 && directGroups.length === 0) { + current = folders[0].fullPath; + } else { + break; + } + } + return current; +} + +// ─── Parameter list (shown after selecting a group) ───────────────────────── + +function ParameterList({ + params, + country, + selectedParam, + onSelect, +}: { + params: Parameter[]; + country: string; + selectedParam: string | null; + onSelect: (name: string) => void; +}) { + const [page, setPage] = useState(1); + const totalPages = Math.ceil(params.length / PAGE_SIZE); + const visible = params.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + return ( +
+
+ {visible.map((p) => ( +
+ onSelect(p.parameter)} + /> + + {selectedParam === p.parameter && ( + + )} + +
+ ))} +
+ +
+ ); +} + +// ─── State tile grid ───────────────────────────────────────────────────────── + +function StateTileGrid({ + subGroups, + levelColor, + onSelect, +}: { + subGroups: [string, ParameterLeaf[]][]; + levelColor: string; + onSelect: (key: string) => void; +}) { + const states = subGroups.filter(([k]) => k !== 'Cross-state' && k.length === 2); + const other = subGroups.filter(([k]) => k === 'Cross-state' || k.length !== 2); + return ( +
+
+ {states.map(([key, params]) => { + 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} +
+
+ {countUniqueGroups(params).toLocaleString()} +
+
+ ); + })} +
+ {other.length > 0 && ( +
+ {other.map(([key, params]) => ( + + ))} +
+ )} +
+ ); +} + +// ─── Sub-group card grid ────────────────────────────────────────────────────── + +function SubGroupCardGrid({ + subGroups, + level, + levelColor, + onSelect, +}: { + subGroups: [string, ParameterLeaf[]][]; + level: Level; + levelColor: string; + onSelect: (key: string) => void; +}) { + return ( +
+ {subGroups.map(([key, params], 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`, + }} + > +
+ {label} +
+
+ {countUniqueGroups(params).toLocaleString()} +
+ {desc && ( +
+ {desc} +
+ )} +
+ ); + })} +
+ ); +} + +// ─── Folder contents grid (sub-folders + direct parameter groups) ─────────── + +function FolderContentsGrid({ + folders, + directGroups, + levelColor, + allParameters, + onFolderSelect, + onGroupSelect, +}: { + folders: { segment: string; fullPath: string; paramCount: number; groupCount: number }[]; + directGroups: [string, ParameterLeaf[]][]; + levelColor: string; + allParameters: Record; + onFolderSelect: (fullPath: string) => void; + onGroupSelect: (key: string) => void; +}) { + const [page, setPage] = useState(1); + const totalItems = folders.length + directGroups.length; + const totalPages = Math.ceil(totalItems / PAGE_SIZE); + const allItems: ({ kind: 'folder' } & typeof folders[number] | { kind: 'group'; key: string; params: ParameterLeaf[] })[] = [ + ...folders.map(f => ({ kind: 'folder' as const, ...f })), + ...directGroups.map(([key, params]) => ({ kind: 'group' as const, key, params })), + ]; + const visible = allItems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + return ( +
+
+ {visible.map((item, i) => { + if (item.kind === 'folder') { + const label = getGroupLabel(item.fullPath, allParameters); + const desc = getGroupDescription(item.fullPath, allParameters); + return ( + onFolderSelect(item.fullPath)} + initial={{ opacity: 0, y: 6 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.15, delay: i * 0.01 }} + className="tw:text-left tw:cursor-pointer" + style={{ + padding: `${spacing.md} ${spacing.lg}`, + 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`, + }} + > +
+
+
+ +
+ {label} +
+
+ {desc && ( +
+ {desc} +
+ )} +
+ + {item.groupCount} params + +
+
+ ); + } else { + const label = getGroupLabel(item.key, allParameters); + const desc = getGroupDescription(item.key, allParameters); + const isSingle = item.params.length === 1; + return ( + onGroupSelect(item.key)} + initial={{ opacity: 0, y: 6 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.15, delay: i * 0.01 }} + className="tw:text-left tw:cursor-pointer" + style={{ + padding: `${spacing.md} ${spacing.lg}`, + 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`, + }} + > +
+
+
+ {label} +
+
+ {item.key} +
+ {desc && ( +
+ {desc} +
+ )} +
+ {!isSingle && ( + + {item.params.length} values + + )} +
+
+ ); + } + })} +
+ +
+ ); +} + +// ─── Main explorer ─────────────────────────────────────────────────────────── + +export default function ParameterExplorer({ parameters, country }: ParameterExplorerProps) { + const [search, setSearch] = useState(''); + const [activeLevel, setActiveLevel] = useState(null); + const [activeSubGroup, setActiveSubGroup] = useState(null); + const [folderStack, setFolderStack] = useState([]); + const [activeGroup, setActiveGroup] = useState(null); + const [selectedParam, setSelectedParam] = useState(null); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const sentinelRef = useRef(null); + + const debouncedSearch = useDebounce(search, 200); + const isSearching = !!debouncedSearch; + + // Only show leaf parameters (actual values), exclude nodes and abolitions + const allParameters = useMemo(() => { + return Object.values(parameters) + .filter((p): p is ParameterLeaf => p.type === 'parameter' && !p.parameter.includes('.abolition')) + .sort((a, b) => (a.label ?? '').localeCompare(b.label ?? '')); + }, [parameters]); + + // Count unique parameter groups (files) per level + const levelCounts = useMemo(() => { + const seen: Record> = { federal: new Set(), state: new Set(), local: new Set(), territory: new Set(), reform: new Set(), household: new Set() }; + for (const p of allParameters) { + seen[getLevel(p.parameter)].add(getParameterGroup(p.parameter)); + } + return Object.fromEntries(Object.entries(seen).map(([k, v]) => [k, v.size])) as Record; + }, [allParameters]); + + // Filtered list + const filtered = useMemo(() => { + let result: ParameterLeaf[] = allParameters; + + if (debouncedSearch) { + const words = debouncedSearch.toLowerCase().split(/\s+/).filter(Boolean); + result = result.filter((p) => { + const haystack = [p.parameter, p.label, p.description || '', p.unit || ''].join(' ').toLowerCase(); + return words.every((w) => haystack.includes(w)); + }); + } + + if (activeLevel) result = result.filter((p) => getLevel(p.parameter) === activeLevel); + + return result; + }, [allParameters, debouncedSearch, activeLevel]); + + // Sub-groups for the active level + const subGroups = useMemo(() => { + if (!activeLevel) return []; + const map = new Map(); + for (const p of filtered) { + const key = getSubGroup(p.parameter); + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(p); + } + return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)); + }, [filtered, activeLevel]); + + // Parameters in active sub-group + const subGroupParams = useMemo(() => { + if (!activeSubGroup) return []; + const entry = subGroups.find(([k]) => k === activeSubGroup); + return entry ? entry[1] : []; + }, [subGroups, activeSubGroup]); + + // Parameter groups within the active sub-group + const parameterGroups = useMemo(() => { + if (!activeSubGroup) return []; + const map = new Map(); + for (const p of subGroupParams) { + const key = getParameterGroup(p.parameter); + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(p); + } + return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)); + }, [subGroupParams, activeSubGroup]); + + // Root folder prefix: common prefix of all groups, auto-collapsed + const rootFolderPrefix = useMemo(() => { + if (parameterGroups.length === 0) return ''; + const prefix = commonPrefix(parameterGroups.map(([k]) => k)); + return autoCollapseFolder(parameterGroups, prefix); + }, [parameterGroups]); + + // Current folder: last in stack, or root + const currentFolder = folderStack.length > 0 ? folderStack[folderStack.length - 1] : rootFolderPrefix; + + // Folder contents at current level + const folderContents = useMemo(() => { + if (!activeSubGroup || !currentFolder) return { folders: [], directGroups: [] }; + return getFolderContents(parameterGroups, currentFolder); + }, [parameterGroups, currentFolder, activeSubGroup]); + + // Parameters in active group + const activeGroupParams = useMemo(() => { + if (!activeGroup) return []; + const entry = parameterGroups.find(([k]) => k === activeGroup); + return entry ? entry[1] : []; + }, [parameterGroups, activeGroup]); + + // Reset folder stack when sub-group changes + useEffect(() => { + setFolderStack([]); + setActiveGroup(null); + setSelectedParam(null); + }, [activeSubGroup]); + + // Reset on filter changes + useEffect(() => { + setVisibleCount(PAGE_SIZE); + }, [debouncedSearch, activeLevel, activeSubGroup, activeGroup]); + + // Infinite scroll for 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) => { + setSelectedParam((prev) => (prev === name ? null : name)); + }, []); + + const visibleFlat = filtered.slice(0, visibleCount); + + // Navigation helpers + const goBack = () => { + if (activeGroup) { + setActiveGroup(null); + setSelectedParam(null); + } else if (folderStack.length > 0) { + setFolderStack((prev) => prev.slice(0, -1)); + } else if (activeSubGroup) { + setActiveSubGroup(null); + setSelectedParam(null); + } else { + setActiveLevel(null); + setSelectedParam(null); + } + }; + + const handleFolderSelect = (fullPath: string) => { + const collapsed = autoCollapseFolder(parameterGroups, fullPath); + setFolderStack((prev) => [...prev, collapsed]); + }; + + const handleGroupSelect = (key: string) => { + const entry = parameterGroups.find(([k]) => k === key); + if (entry && entry[1].length === 1) { + setActiveGroup(key); + setSelectedParam(entry[1][0].parameter); + } else { + setActiveGroup(key); + setSelectedParam(null); + } + }; + + // Breadcrumb: each entry has a label and navigation action + const breadcrumbItems = useMemo(() => { + const items: { label: string; onClick: () => void }[] = []; + + if (activeLevel) { + items.push({ + label: LEVEL_CONFIG[activeLevel].label, + onClick: () => { setActiveLevel(activeLevel); setActiveSubGroup(null); setFolderStack([]); setActiveGroup(null); setSelectedParam(null); }, + }); + } + if (activeSubGroup && activeLevel) { + items.push({ + label: getSubGroupLabel(activeSubGroup, activeLevel), + onClick: () => { setFolderStack([]); setActiveGroup(null); setSelectedParam(null); }, + }); + } + for (let i = 0; i < folderStack.length; i++) { + const stackIndex = i; + items.push({ + label: getGroupLabel(folderStack[i], parameters), + onClick: () => { setFolderStack((prev) => prev.slice(0, stackIndex + 1)); setActiveGroup(null); setSelectedParam(null); }, + }); + } + if (activeGroup) { + items.push({ + label: getGroupLabel(activeGroup, parameters), + onClick: () => {}, + }); + } + return items; + }, [activeLevel, activeSubGroup, folderStack, activeGroup, parameters]); + + // Current heading label + const currentHeadingLabel = activeGroup + ? getGroupLabel(activeGroup, parameters) + : folderStack.length > 0 + ? getGroupLabel(currentFolder, parameters) + : activeSubGroup && activeLevel + ? getSubGroupLabel(activeSubGroup, activeLevel) + : ''; + + // Param count summary for folder view (count groups, not individual leaves) + const currentFolderTotalParams = folderContents.folders.reduce((s, f) => s + f.groupCount, 0) + + folderContents.directGroups.length; + const currentFolderTotalItems = folderContents.folders.length + folderContents.directGroups.length; + + 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', + }} + /> +
+ + {/* Search filter pills */} + {isSearching && ( +
+
+ {([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 ( + + ); + })} +
+ + {countUniqueGroups(filtered).toLocaleString()} parameter{countUniqueGroups(filtered) !== 1 ? 's' : ''} + +
+ )} + + {/* ─── View: Search results (flat) ─── */} + {isSearching && ( + <> +
+ {visibleFlat.map((p) => ( +
+ handleSelect(p.parameter)} /> + + {selectedParam === p.parameter && ( + + )} + +
+ ))} +
+ {visibleCount < filtered.length &&
} + {filtered.length === 0 && ( +
+ No parameters 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); setActiveSubGroup(null); setActiveGroup(null); setFolderStack([]); }} + 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 ─── */} + {!isSearching && activeLevel && !activeSubGroup && ( +
+ + +
+

+ {LEVEL_CONFIG[activeLevel].label} +

+ + {countUniqueGroups(filtered).toLocaleString()} parameters across {subGroups.length} groups + +
+ + {activeLevel === 'state' ? ( + + ) : ( + + )} +
+ )} + + {/* ─── View: Folder navigation (sub-group drill-in) ─── */} + {!isSearching && activeLevel && activeSubGroup && !activeGroup && ( +
+ + +
+

+ {currentHeadingLabel} +

+ + {currentFolderTotalParams.toLocaleString()} parameters + {currentFolderTotalItems > 1 && ` across ${currentFolderTotalItems} ${folderContents.folders.length > 0 ? 'programs' : 'groups'}`} + +
+ + +
+ )} + + {/* ─── View: Drilled into a parameter group (leaf list) ─── */} + {!isSearching && activeLevel && activeSubGroup && activeGroup && ( +
+ + +
+

+ {getGroupLabel(activeGroup, parameters)} +

+ {getGroupDescription(activeGroup, parameters) && ( +

+ {getGroupDescription(activeGroup, parameters)} +

+ )} + + {activeGroupParams.length} parameter{activeGroupParams.length !== 1 ? 's' : ''} + +
+ + +
+ )} +
+ ); +} diff --git a/src/components/shared/categoryUtils.ts b/src/components/shared/categoryUtils.ts new file mode 100644 index 0000000..c5311ba --- /dev/null +++ b/src/components/shared/categoryUtils.ts @@ -0,0 +1,144 @@ +export type Level = 'federal' | 'state' | 'local' | 'territory' | 'household' | 'reform'; + +/** Categorize an item by its path prefix (moduleName for variables, parameter path for parameters). */ +export function getLevel(path: string | null): Level { + if (!path) return 'household'; + if (path.startsWith('gov.local')) return 'local'; + if (path.startsWith('gov.states')) return 'state'; + if (path.startsWith('gov.territories')) return 'territory'; + if (path.startsWith('gov.contrib') || path.startsWith('contrib')) return 'reform'; + if (path.startsWith('household') || path.startsWith('input')) return 'household'; + if (path.startsWith('gov.')) return 'federal'; + return 'household'; +} + +/** Get a sub-group key from a path. */ +export function getSubGroup(path: string | null): string { + if (!path) return 'Other'; + const parts = path.split('.'); + + if (path.startsWith('gov.states.tax') || path.startsWith('gov.states.general') || + path.startsWith('gov.states.unemployment') || path.startsWith('gov.states.workers')) { + return 'Cross-state'; + } + if (path.startsWith('gov.states.') && parts.length >= 3 && parts[2].length === 2) { + return parts[2].toUpperCase(); + } + if (path.startsWith('gov.local.') && parts.length >= 4) { + return `${parts[2].toUpperCase()}-${parts[3]}`; + } + if (path.startsWith('gov.territories.') && parts.length >= 3) { + return parts[2].toUpperCase(); + } + if (path.startsWith('gov.contrib.') && parts.length >= 3) { + return parts[2]; + } + if (path.startsWith('contrib.') && parts.length >= 2) { + return parts[1]; + } + if (path.startsWith('gov.')) { + return parts[1]; + } + if (path.startsWith('household.')) { + return parts[1]; + } + return 'Other'; +} + +/** Get a display label for a sub-group key. */ +export function getSubGroupLabel(key: string, level: Level): string { + if (level === 'federal') { + const agencyNames: Record = { + 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[key] || key.toUpperCase(); + } + 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', + }; + if (key === 'Cross-state') return 'Cross-state'; + return stateNames[key] || key; + } + 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 === 'reform') { + // Reform sub-groups are typically program names + return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + } + 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[key] || key; + } + return key; +} + +/** Description for sub-groups. */ +export 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', + simulation: 'Behavioral responses, labor supply elasticities, and simulation settings', + }; + 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; +} + +export 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 }, + reform: { label: 'Reforms', description: 'Contributed reform proposals and policy experiments', color: '#D97706', order: 4 }, + household: { label: 'Household inputs', description: 'Demographics, income, expenses, and geographic inputs', color: '#6B7280', order: 5 }, +}; + +export const LEVELS_ORDERED: Level[] = ['federal', 'state', 'local', 'territory', 'reform', 'household']; diff --git a/src/components/variables/ComputationTree.tsx b/src/components/variables/ComputationTree.tsx new file mode 100644 index 0000000..d61dde2 --- /dev/null +++ b/src/components/variables/ComputationTree.tsx @@ -0,0 +1,292 @@ +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; + +/** 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; + 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 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; + + return ( +
0 ? spacing.lg : 0 }}> + + + + {expanded && ( + + {adds.map((child) => ( + + ))} + {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 adds = toArray(variable.adds); + const subtracts = toArray(variable.subtracts); + + if (adds.length === 0 && subtracts.length === 0) return null; + + const nextVisited = new Set([...visited, variableName]); + + return ( +
+

+ Computation tree +

+
+ {adds.map((child) => ( + + ))} + {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..1a8f699 --- /dev/null +++ b/src/components/variables/VariableCard.tsx @@ -0,0 +1,129 @@ +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 const entityLabels: Record = { + person: 'Person', + tax_unit: 'Tax Unit', + spm_unit: 'SPM Unit', + household: 'Household', + family: 'Family', + marital_unit: 'Marital Unit', + benunit: 'Benefit Unit', +}; + +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..fadcb8e --- /dev/null +++ b/src/components/variables/VariableDetail.tsx @@ -0,0 +1,185 @@ +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'; +import { entityLabels } from './VariableCard'; + +interface VariableDetailProps { + variable: Variable; + variables: Record; + parameters: Record; + country: string; + onViewFlowchart?: (varName: string) => void; +} + +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, 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; + const githubRepo = country === 'uk' ? 'policyengine-uk' : 'policyengine-us'; + // moduleName is e.g. "gov.states.al.tax.income.al_agi" + // maps to variables/gov/states/al/tax/income/al_agi.py + 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 + + )} + +
+ + {/* Computation tree */} + {hasTree && ( + + )} +
+
+ ); +} diff --git a/src/components/variables/VariableExplorer.tsx b/src/components/variables/VariableExplorer.tsx new file mode 100644 index 0000000..d605531 --- /dev/null +++ b/src/components/variables/VariableExplorer.tsx @@ -0,0 +1,755 @@ +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { IconSearch, IconArrowLeft, IconFolder, IconChevronLeft, IconChevronRight } from '@tabler/icons-react'; +import type { Variable, Parameter } from '../../types/Variable'; +import { colors, typography, spacing } from '../../designTokens'; +import { useDebounce } from '../../hooks/useDebounce'; +import { + getLevel as getLevelFromPath, getSubGroup as getSubGroupFromPath, + getSubGroupLabel, getSubGroupDescription, + LEVEL_CONFIG, LEVELS_ORDERED, + type Level, +} from '../shared/categoryUtils'; +import VariableCard from './VariableCard'; +import VariableDetail from './VariableDetail'; + +const PAGE_SIZE = 50; + +// ─── Pagination ───────────────────────────────────────────────────────────── + +function Pagination({ page, totalPages, onPageChange }: { + page: number; + totalPages: number; + onPageChange: (page: number) => void; +}) { + if (totalPages <= 1) return null; + + const pages: (number | '...')[] = []; + const addPage = (p: number) => { + if (p >= 1 && p <= totalPages && !pages.includes(p)) pages.push(p); + }; + addPage(1); + for (let i = Math.max(2, page - 2); i <= Math.min(totalPages - 1, page + 2); i++) addPage(i); + addPage(totalPages); + + const withEllipsis: (number | '...')[] = []; + for (let i = 0; i < pages.length; i++) { + const p = pages[i]; + if (i > 0 && typeof p === 'number' && typeof pages[i - 1] === 'number' && p - (pages[i - 1] as number) > 1) { + withEllipsis.push('...'); + } + withEllipsis.push(p); + } + + const btnBase: React.CSSProperties = { + minWidth: '32px', height: '32px', borderRadius: spacing.radius.md, + border: `1px solid ${colors.border.light}`, backgroundColor: colors.white, + fontFamily: typography.fontFamily.primary, fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeight.medium, cursor: 'pointer', + display: 'flex', alignItems: 'center', justifyContent: 'center', padding: `0 ${spacing.sm}`, + }; + + return ( +
+ + {withEllipsis.map((p, i) => + p === '...' ? ( + ... + ) : ( + + ), + )} + +
+ ); +} + +// ─── Breadcrumb navigation ────────────────────────────────────────────────── + +function BreadcrumbNav({ items, onBack }: { + items: { label: string; onClick: () => void }[]; + onBack: () => void; +}) { + return ( +
+ + {items.map((item, i) => { + const isLast = i === items.length - 1; + return ( + + {i > 0 && /} + {isLast ? ( + + {item.label} + + ) : ( + + )} + + ); + })} +
+ ); +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +interface VariableExplorerProps { + variables: Record; + parameters: Record; + country: string; + onViewFlowchart?: (varName: string) => void; +} + +function getLevel(v: Variable): Level { + return getLevelFromPath(v.moduleName); +} + +function getSubGroup(v: Variable): string { + return getSubGroupFromPath(v.moduleName); +} + +function getFolderLabel(fullPath: string): string { + const parts = fullPath.split('.'); + return parts[parts.length - 1].replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** Find the longest common dot-separated prefix. */ +function commonPrefix(paths: string[]): string { + if (paths.length === 0) return ''; + const parts0 = paths[0].split('.'); + let len = parts0.length; + for (const p of paths.slice(1)) { + const parts = p.split('.'); + len = Math.min(len, parts.length); + for (let i = 0; i < len; i++) { + if (parts[i] !== parts0[i]) { len = i; break; } + } + } + return parts0.slice(0, len).join('.'); +} + +/** At a given folder prefix, compute sub-folders and direct variables. */ +function getVarFolderContents( + vars: Variable[], + prefix: string, +): { + folders: { segment: string; fullPath: string; varCount: number }[]; + directVars: Variable[]; +} { + const prefixDot = prefix + '.'; + const folderCounts = new Map(); + const directVars: Variable[] = []; + + for (const v of vars) { + const path = v.moduleName || ''; + if (!path.startsWith(prefixDot)) continue; + const remaining = path.slice(prefixDot.length); + const segments = remaining.split('.'); + if (segments.length === 1) { + directVars.push(v); + } else { + const folder = segments[0]; + folderCounts.set(folder, (folderCounts.get(folder) || 0) + 1); + } + } + + const folders = [...folderCounts.entries()] + .map(([segment, varCount]) => ({ segment, fullPath: `${prefix}.${segment}`, varCount })) + .sort((a, b) => a.segment.localeCompare(b.segment)); + + return { folders, directVars: directVars.sort((a, b) => a.label.localeCompare(b.label)) }; +} + +/** Auto-collapse single-child folder chains. */ +function autoCollapseVarFolder(vars: Variable[], prefix: string): string { + let current = prefix; + for (let i = 0; i < 10; i++) { + const { folders, directVars } = getVarFolderContents(vars, current); + if (folders.length === 1 && directVars.length === 0) { + current = folders[0].fullPath; + } else { + break; + } + } + return current; +} + +// ─── State tile grid ───────────────────────────────────────────────────────── + +function StateTileGrid({ + subGroups, + levelColor, + onSelect, +}: { + subGroups: [string, Variable[]][]; + levelColor: string; + onSelect: (key: string) => void; +}) { + const states = subGroups.filter(([k]) => k !== 'Cross-state' && k.length === 2); + const other = subGroups.filter(([k]) => k === 'Cross-state' || k.length !== 2); + + return ( +
+
+ {states.map(([key, vars]) => ( + 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 ────────────────────────────────────────────────────── + +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` }} + > +
+ {label} +
+
+ {vars.length} +
+ {desc && ( +
+ {desc} +
+ )} +
+ ); + })} +
+ ); +} + +// ─── Folder contents (sub-folders + direct variables) ─────────────────────── + +function FolderContentsView({ + folders, + directVars, + levelColor, + allVariables, + parameters, + country, + selectedVar, + onFolderSelect, + onVarSelect, + onViewFlowchart, +}: { + folders: { segment: string; fullPath: string; varCount: number }[]; + directVars: Variable[]; + levelColor: string; + allVariables: Record; + parameters: Record; + country: string; + selectedVar: string | null; + onFolderSelect: (fullPath: string) => void; + onVarSelect: (name: string) => void; + onViewFlowchart?: (varName: string) => void; +}) { + const [folderPage, setFolderPage] = useState(1); + const folderTotalPages = Math.ceil(folders.length / PAGE_SIZE); + const visibleFolders = folders.slice((folderPage - 1) * PAGE_SIZE, folderPage * PAGE_SIZE); + + const [varPage, setVarPage] = useState(1); + const varTotalPages = Math.ceil(directVars.length / PAGE_SIZE); + const visibleVars = directVars.slice((varPage - 1) * PAGE_SIZE, varPage * PAGE_SIZE); + + return ( +
+ {/* Folder cards */} + {folders.length > 0 && ( +
+
+ {visibleFolders.map((folder, i) => { + const label = getFolderLabel(folder.fullPath); + return ( + onFolderSelect(folder.fullPath)} + initial={{ opacity: 0, y: 6 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.15, delay: i * 0.01 }} + className="tw:text-left tw:cursor-pointer" + style={{ + padding: `${spacing.md} ${spacing.lg}`, 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` }} + > +
+
+ +
+ {label} +
+
+ + {folder.varCount} vars + +
+
+ ); + })} +
+ +
+ )} + + {/* Direct variables */} + {directVars.length > 0 && ( +
0 ? spacing.xl : 0 }}> + {folders.length > 0 && ( +
+ Variables ({directVars.length}) +
+ )} +
+ {visibleVars.map((v) => ( +
+ onVarSelect(v.name)} /> + + {selectedVar === v.name && ( + + )} + +
+ ))} +
+ +
+ )} +
+ ); +} + +// ─── Main explorer ─────────────────────────────────────────────────────────── + +export default function VariableExplorer({ variables, parameters, country, onViewFlowchart }: VariableExplorerProps) { + const [search, setSearch] = useState(''); + const [activeLevel, setActiveLevel] = useState(null); + const [activeSubGroup, setActiveSubGroup] = useState(null); + const [folderStack, setFolderStack] = useState([]); + const [selectedVar, setSelectedVar] = useState(null); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const sentinelRef = useRef(null); + + const debouncedSearch = useDebounce(search, 200); + const isSearching = !!debouncedSearch; + + // All variables (hidden_input + contrib excluded), sorted + const allVariables = useMemo(() => { + return Object.values(variables) + .filter((v) => !v.hidden_input && !v.moduleName?.startsWith('contrib') && !v.moduleName?.startsWith('gov.puf')) + .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, reform: 0, household: 0 }; + for (const v of allVariables) counts[getLevel(v)]++; + 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) => { + 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 (activeLevel) result = result.filter((v) => getLevel(v) === activeLevel); + return result; + }, [allVariables, debouncedSearch, activeLevel]); + + // Sub-groups for the active level + const subGroups = useMemo(() => { + if (!activeLevel) return []; + const map = new Map(); + for (const v of filtered) { + 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]); + + // Variables in active sub-group + const subGroupVars = useMemo(() => { + if (!activeSubGroup) return []; + const entry = subGroups.find(([k]) => k === activeSubGroup); + return entry ? entry[1] : []; + }, [subGroups, activeSubGroup]); + + // Root folder prefix: common prefix of all module paths, auto-collapsed + const rootFolderPrefix = useMemo(() => { + if (subGroupVars.length === 0) return ''; + const paths = subGroupVars.map((v) => v.moduleName || '').filter(Boolean); + if (paths.length === 0) return ''; + const prefix = commonPrefix(paths); + // Strip the last segment (which would be partial variable name overlap) + const parts = prefix.split('.'); + // Walk back until we find a prefix that's a proper directory (not a variable name) + while (parts.length > 0) { + const candidate = parts.join('.'); + const hasChildren = subGroupVars.some((v) => (v.moduleName || '').startsWith(candidate + '.') && (v.moduleName || '') !== candidate); + if (hasChildren) break; + parts.pop(); + } + const dirPrefix = parts.join('.'); + return autoCollapseVarFolder(subGroupVars, dirPrefix); + }, [subGroupVars]); + + // Current folder + const currentFolder = folderStack.length > 0 ? folderStack[folderStack.length - 1] : rootFolderPrefix; + + // Folder contents at current level + const folderContents = useMemo(() => { + if (!activeSubGroup || !currentFolder) return { folders: [], directVars: [] }; + return getVarFolderContents(subGroupVars, currentFolder); + }, [subGroupVars, currentFolder, activeSubGroup]); + + // Reset folder stack when sub-group changes + useEffect(() => { + setFolderStack([]); + setSelectedVar(null); + }, [activeSubGroup]); + + // Reset on filter changes + useEffect(() => { + setVisibleCount(PAGE_SIZE); + }, [debouncedSearch, activeLevel, activeSubGroup]); + + // Infinite scroll for 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); + + // Navigation + const goBack = () => { + if (folderStack.length > 0) { + setFolderStack((prev) => prev.slice(0, -1)); + setSelectedVar(null); + } else if (activeSubGroup) { + setActiveSubGroup(null); + setSelectedVar(null); + } else { + setActiveLevel(null); + setSelectedVar(null); + } + }; + + const handleFolderSelect = (fullPath: string) => { + const collapsed = autoCollapseVarFolder(subGroupVars, fullPath); + setFolderStack((prev) => [...prev, collapsed]); + setSelectedVar(null); + }; + + // Breadcrumb items + const breadcrumbItems = useMemo(() => { + const items: { label: string; onClick: () => void }[] = []; + if (activeLevel) { + items.push({ + label: LEVEL_CONFIG[activeLevel].label, + onClick: () => { setActiveLevel(activeLevel); setActiveSubGroup(null); setFolderStack([]); setSelectedVar(null); }, + }); + } + if (activeSubGroup && activeLevel) { + items.push({ + label: getSubGroupLabel(activeSubGroup, activeLevel), + onClick: () => { setFolderStack([]); setSelectedVar(null); }, + }); + } + for (let i = 0; i < folderStack.length; i++) { + const stackIndex = i; + items.push({ + label: getFolderLabel(folderStack[i]), + onClick: () => { setFolderStack((prev) => prev.slice(0, stackIndex + 1)); setSelectedVar(null); }, + }); + } + return items; + }, [activeLevel, activeSubGroup, folderStack]); + + // Current heading + const currentHeadingLabel = folderStack.length > 0 + ? getFolderLabel(currentFolder) + : activeSubGroup && activeLevel + ? getSubGroupLabel(activeSubGroup, activeLevel) + : ''; + + + 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', + }} + /> +
+ + {/* Search filter pills */} + {isSearching && ( +
+
+ {([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' : ''} + +
+ )} + + {/* ─── View: Search results (flat) ─── */} + {isSearching && ( + <> +
+ {visibleFlat.map((v) => ( +
+ handleSelect(v.name)} /> + + {selectedVar === v.name && ( + + )} + +
+ ))} +
+ {visibleCount < filtered.length &&
} + {filtered.length === 0 && ( +
+ No variables match your search. +
+ )} + + )} + + {/* ─── View: Level overview cards ─── */} + {!isSearching && !activeLevel && ( +
+ {LEVELS_ORDERED.map((level) => { + const config = LEVEL_CONFIG[level]; + const count = levelCounts[level]; + if (!count) return null; + return ( + { setActiveLevel(level); setActiveSubGroup(null); setFolderStack([]); }} + 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 ─── */} + {!isSearching && activeLevel && !activeSubGroup && ( +
+ +
+

+ {LEVEL_CONFIG[activeLevel].label} +

+ + {filtered.length.toLocaleString()} variables across {subGroups.length} groups + +
+ {activeLevel === 'state' ? ( + + ) : ( + + )} +
+ )} + + {/* ─── View: Folder navigation (sub-group drill-in) ─── */} + {!isSearching && activeLevel && activeSubGroup && ( +
+ +
+

+ {currentHeadingLabel} +

+
+ +
+ )} +
+ ); +} diff --git a/src/data/fetchMetadata.ts b/src/data/fetchMetadata.ts new file mode 100644 index 0000000..924e4c6 --- /dev/null +++ b/src/data/fetchMetadata.ts @@ -0,0 +1,53 @@ +import type { Metadata } from '../types/Variable'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const rawCache = new Map(); +const metadataCache = new Map(); + +/** Try loading pre-built static metadata first, fall back to live API. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function fetchFromStaticOrAPI(country: string): Promise { + // Try the pre-built static file (generated at build time by scripts/fetch-metadata.js) + try { + const staticUrl = `${import.meta.env.BASE_URL}metadata-${country}.json`; + const res = await fetch(staticUrl); + if (res.ok) { + const data = await res.json(); + // Verify it has the expected shape + if (data.variables && data.parameters) return data; + } + } catch { + // Static file not available, fall through to API + } + + // Fall back to live API + 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(); + return data.result ?? data; +} + +/** 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 result = await fetchFromStaticOrAPI(country); + 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..5fef2ba 100644 --- a/src/data/fetchPrograms.ts +++ b/src/data/fetchPrograms.ts @@ -1,7 +1,7 @@ import type { Program, CoverageStatus, StateImplementation } from '../types/Program'; -const GITHUB_BASE = 'https://github.com/PolicyEngine/policyengine-us/tree/master/policyengine_us'; -const TESTS_BASE = 'https://github.com/PolicyEngine/policyengine-us/tree/master/policyengine_us/tests'; +const GITHUB_BASE = 'https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us'; +const TESTS_BASE = 'https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us/tests'; interface ApiProgram { id: string; @@ -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/data/programs.ts b/src/data/programs.ts index 5502245..0adfb25 100644 --- a/src/data/programs.ts +++ b/src/data/programs.ts @@ -1,7 +1,7 @@ import type { Program } from '../types/Program'; -const GITHUB_BASE = 'https://github.com/PolicyEngine/policyengine-us/tree/master/policyengine_us'; -const TESTS_BASE = 'https://github.com/PolicyEngine/policyengine-us/tree/master/policyengine_us/tests'; +const GITHUB_BASE = 'https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us'; +const TESTS_BASE = 'https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us/tests'; export const programs: Program[] = [ // Tax Programs 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/ParametersPage.tsx b/src/pages/rules/ParametersPage.tsx index d9e7479..a6a93f9 100644 --- a/src/pages/rules/ParametersPage.tsx +++ b/src/pages/rules/ParametersPage.tsx @@ -1,12 +1,48 @@ -import ProgramListPage from './ProgramListPage'; +import { useState, useEffect } from 'react'; +import PageHeader from '../../components/layout/PageHeader'; +import ParameterExplorer from '../../components/parameters/ParameterExplorer'; +import { fetchMetadata } from '../../data/fetchMetadata'; +import type { Metadata } from '../../types/Variable'; +import { colors, typography, spacing } from '../../designTokens'; export default function ParametersPage({ 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 parameters... +

+
+ )} + + {metadata && ( + + )} +
); } diff --git a/src/pages/rules/VariablesPage.tsx b/src/pages/rules/VariablesPage.tsx index 3444877..8b4741b 100644 --- a/src/pages/rules/VariablesPage.tsx +++ b/src/pages/rules/VariablesPage.tsx @@ -1,12 +1,156 @@ -import ProgramListPage from './ProgramListPage'; +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()}`; + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + }, [flowchartUrl]); + + return ( +
+
+
+

+ Computation flowchart +

+

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

+
+ + Open in new tab + +
+
+ {loading && ( +
+ + Loading flowchart... + +
+ )} +