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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
111 changes: 111 additions & 0 deletions scripts/fetch-metadata.js
Original file line number Diff line number Diff line change
@@ -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();
141 changes: 141 additions & 0 deletions src/components/parameters/ParameterCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
onClick={onClick}
className="tw:w-full tw:text-left tw:cursor-pointer"
style={{
padding: `${spacing.md} ${spacing.lg}`,
borderRadius: spacing.radius.lg,
border: `1px solid ${isSelected ? colors.primary[400] : colors.border.light}`,
backgroundColor: isSelected ? colors.primary[50] : colors.white,
fontFamily: typography.fontFamily.primary,
transition: 'all 0.15s ease',
display: 'block',
}}
>
<div className="tw:flex tw:items-start tw:justify-between" style={{ gap: spacing.md }}>
<div className="tw:flex-1 tw:min-w-0">
<div
style={{
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
}}
>
{param.label}
</div>
<div className="tw:truncate"
style={{
fontSize: typography.fontSize.xs,
fontFamily: typography.fontFamily.mono,
color: colors.text.tertiary,
marginTop: '2px',
}}
>
{param.parameter}
</div>
{param.description && (
<div
className="tw:truncate"
style={{
fontSize: typography.fontSize.xs,
color: colors.text.secondary,
marginTop: spacing.xs,
maxWidth: '500px',
}}
>
{param.description}
</div>
)}
</div>
<div className="tw:flex tw:items-center tw:flex-shrink-0" style={{ gap: spacing.xs }}>
{/* Current value badge (leaf only) */}
{isLeaf && (() => {
const { text, isList, count } = getCurrentValue(param as ParameterLeaf);
return (
<span
title={isList ? text : `Current value: ${text}`}
style={{
fontSize: '10px',
fontWeight: typography.fontWeight.semibold,
padding: `1px ${spacing.xs}`,
borderRadius: spacing.radius.sm,
backgroundColor: '#DBEAFE',
color: '#1D4ED8',
fontFamily: typography.fontFamily.mono,
maxWidth: '120px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{isList ? (count === 0 ? '(empty)' : `${count} items`) : text}
</span>
);
})()}
{/* Unit badge */}
{isLeaf && (param as ParameterLeaf).unit && (
<span
style={{
fontSize: '10px',
padding: `1px ${spacing.xs}`,
borderRadius: spacing.radius.sm,
backgroundColor: colors.gray[100],
color: colors.gray[600],
fontFamily: typography.fontFamily.mono,
}}
>
{(param as ParameterLeaf).unit === '/1' ? '%' : (param as ParameterLeaf).unit}
</span>
)}
{/* Type badge */}
<span
style={{
fontSize: '10px',
fontWeight: typography.fontWeight.semibold,
padding: `1px ${spacing.xs}`,
borderRadius: spacing.radius.sm,
backgroundColor: isLeaf ? '#F0FDF4' : '#FEF3C7',
color: isLeaf ? '#166534' : '#92400E',
}}
>
{isLeaf ? 'value' : 'group'}
</span>
</div>
</div>
</button>
);
}
Loading