diff --git a/app/package.json b/app/package.json
index 3d9e54f86..726fe3939 100644
--- a/app/package.json
+++ b/app/package.json
@@ -37,6 +37,7 @@
"@tanstack/react-query": "^5.84.1",
"@tanstack/react-query-devtools": "^5.83.0",
"dayjs": "^1.11.13",
+ "dexie": "^4.2.1",
"framer-motion": "^12.23.24",
"fuse.js": "^7.0.0",
"jsonp": "^0.2.1",
@@ -71,6 +72,7 @@
"@types/react-plotly.js": "^2.6.3",
"@types/react-syntax-highlighter": "^15.5.0",
"@types/topojson-client": "^3.1.5",
+ "@types/topojson-specification": "^1.0.5",
"@types/wordwrapjs": "^5.1.2",
"@vercel/node": "^5.5.10",
"@vitejs/plugin-react": "^4.5.2",
diff --git a/app/src/CalculatorRouter.tsx b/app/src/CalculatorRouter.tsx
index e28e04783..6c3bb2d37 100644
--- a/app/src/CalculatorRouter.tsx
+++ b/app/src/CalculatorRouter.tsx
@@ -17,7 +17,6 @@ import ReportPathwayWrapper from './pathways/report/ReportPathwayWrapper';
import SimulationPathwayWrapper from './pathways/simulation/SimulationPathwayWrapper';
import { CountryGuard } from './routing/guards/CountryGuard';
import { MetadataGuard } from './routing/guards/MetadataGuard';
-import { MetadataLazyLoader } from './routing/guards/MetadataLazyLoader';
import { RedirectToCountry } from './routing/RedirectToCountry';
/**
@@ -43,55 +42,11 @@ const router = createBrowserRouter(
path: '/:countryId',
element: ,
children: [
- // Routes with standard layout that need metadata (blocking)
- // Layout is OUTSIDE the guard so app shell remains visible during loading
- {
- element: ,
- children: [
- {
- element: ,
- children: [
- {
- path: 'report-output/:reportId/:subpage?/:view?',
- element: ,
- },
- ],
- },
- ],
- },
- // Pathway routes that need metadata (blocking)
- // Pathways manage their own AppShell layouts - do NOT wrap in StandardLayoutOutlet
- // This allows views like PolicyParameterSelectorView to use custom AppShell configurations
+ // All routes need metadata (variables, datasets, parameters)
{
element: ,
children: [
- {
- element: ,
- children: [
- {
- path: 'reports/create',
- element: ,
- },
- {
- path: 'simulations/create',
- element: ,
- },
- {
- path: 'households/create',
- element: ,
- },
- {
- path: 'policies/create',
- element: ,
- },
- ],
- },
- ],
- },
- // Routes that benefit from metadata but don't require it (lazy loader)
- {
- element: ,
- children: [
+ // Routes with StandardLayout
{
element: ,
children: [
@@ -123,6 +78,32 @@ const router = createBrowserRouter(
path: 'account',
element:
Account settings page
,
},
+ {
+ path: 'report-output/:reportId/:subpage?/:view?',
+ element: ,
+ },
+ ],
+ },
+ // Pathway routes
+ {
+ element: ,
+ children: [
+ {
+ path: 'reports/create',
+ element: ,
+ },
+ {
+ path: 'simulations/create',
+ element: ,
+ },
+ {
+ path: 'households/create',
+ element: ,
+ },
+ {
+ path: 'policies/create',
+ element: ,
+ },
],
},
],
diff --git a/app/src/adapters/HouseholdAdapter.ts b/app/src/adapters/HouseholdAdapter.ts
index 0a560be47..3ab706f60 100644
--- a/app/src/adapters/HouseholdAdapter.ts
+++ b/app/src/adapters/HouseholdAdapter.ts
@@ -1,3 +1,4 @@
+import { getEntities } from '@/data/static';
import { countryIds } from '@/libs/countries';
import { store } from '@/store';
import { Household, HouseholdData } from '@/types/ingredients/Household';
@@ -5,11 +6,12 @@ import { HouseholdMetadata } from '@/types/metadata/householdMetadata';
import { HouseholdCreationPayload } from '@/types/payloads';
/**
- * Get entity metadata from the Redux store
+ * Get entity metadata from static data based on current country
*/
function getEntityMetadata() {
const state = store.getState();
- return state.metadata?.entities || {};
+ const countryId = state.metadata?.currentCountry || 'us';
+ return getEntities(countryId);
}
/**
diff --git a/app/src/adapters/MetadataAdapter.ts b/app/src/adapters/MetadataAdapter.ts
new file mode 100644
index 000000000..f62cd0537
--- /dev/null
+++ b/app/src/adapters/MetadataAdapter.ts
@@ -0,0 +1,122 @@
+import {
+ ParameterMetadata,
+ V2DatasetMetadata,
+ V2ParameterMetadata,
+ V2ParameterValueMetadata,
+ V2VariableMetadata,
+ VariableMetadata,
+} from '@/types/metadata';
+import { ValuesList } from '@/types/subIngredients/valueInterval';
+
+/**
+ * Dataset type used in the frontend (simplified from V2DatasetMetadata)
+ */
+export interface DatasetEntry {
+ name: string;
+ label: string;
+ title: string;
+ default: boolean;
+}
+
+/**
+ * Adapter for converting between V2 API metadata and internal formats
+ */
+export class MetadataAdapter {
+ /**
+ * Convert a single V2 variable to frontend VariableMetadata
+ */
+ static variableFromV2(v2: V2VariableMetadata): VariableMetadata {
+ return {
+ id: v2.id,
+ name: v2.name,
+ entity: v2.entity,
+ description: v2.description,
+ data_type: v2.data_type,
+ possible_values: v2.possible_values,
+ tax_benefit_model_version_id: v2.tax_benefit_model_version_id,
+ created_at: v2.created_at,
+ // Auto-generate label from name (sentence case)
+ // TODO: V2 API should provide labels like V1 API did
+ label: v2.name.replace(/_/g, ' ').replace(/^./, (c: string) => c.toUpperCase()),
+ };
+ }
+
+ /**
+ * Convert V2 variables array to a keyed record
+ */
+ static variablesFromV2(variables: V2VariableMetadata[]): Record {
+ const record: Record = {};
+ for (const v of variables) {
+ record[v.name] = MetadataAdapter.variableFromV2(v);
+ }
+ return record;
+ }
+
+ /**
+ * Convert a single V2 parameter to frontend ParameterMetadata
+ */
+ static parameterFromV2(p: V2ParameterMetadata): ParameterMetadata {
+ return {
+ id: p.id,
+ name: p.name,
+ label: p.label,
+ description: p.description,
+ unit: p.unit,
+ data_type: p.data_type,
+ tax_benefit_model_version_id: p.tax_benefit_model_version_id,
+ created_at: p.created_at,
+ parameter: p.name, // Use name as parameter path
+ type: 'parameter',
+ values: {}, // Parameter values are fetched on-demand
+ };
+ }
+
+ /**
+ * Convert V2 parameters array to a keyed record
+ */
+ static parametersFromV2(parameters: V2ParameterMetadata[]): Record {
+ const record: Record = {};
+ for (const p of parameters) {
+ record[p.name] = MetadataAdapter.parameterFromV2(p);
+ }
+ return record;
+ }
+
+ /**
+ * Convert a single V2 dataset to frontend DatasetEntry
+ * @param d V2 dataset
+ * @param isDefault Whether this is the default dataset
+ */
+ static datasetFromV2(d: V2DatasetMetadata, isDefault: boolean): DatasetEntry {
+ return {
+ name: d.name,
+ label: d.name,
+ title: d.description || d.name,
+ default: isDefault,
+ };
+ }
+
+ /**
+ * Convert V2 datasets array to frontend format
+ * First dataset is marked as default
+ */
+ static datasetsFromV2(datasets: V2DatasetMetadata[]): DatasetEntry[] {
+ return datasets.map((d, i) => MetadataAdapter.datasetFromV2(d, i === 0));
+ }
+
+ /**
+ * Convert V2 parameter values array to ValuesList format
+ * ValuesList is a Record used by the frontend
+ * @param values Array of V2 parameter value records
+ * @returns ValuesList format (e.g., { "2023-01-01": 100, "2024-01-01": 150 })
+ */
+ static parameterValuesFromV2(values: V2ParameterValueMetadata[]): ValuesList {
+ const valuesList: ValuesList = {};
+ for (const v of values) {
+ // Convert ISO timestamp (e.g., "2025-01-01T00:00:00") to date string (e.g., "2025-01-01")
+ const dateKey = v.start_date.split('T')[0];
+ valuesList[dateKey] = v.value_json;
+ }
+ return valuesList;
+ }
+}
diff --git a/app/src/adapters/index.ts b/app/src/adapters/index.ts
index e6d8da67e..203c07c14 100644
--- a/app/src/adapters/index.ts
+++ b/app/src/adapters/index.ts
@@ -3,6 +3,8 @@ export { PolicyAdapter } from './PolicyAdapter';
export { SimulationAdapter } from './SimulationAdapter';
export { ReportAdapter } from './ReportAdapter';
export { HouseholdAdapter } from './HouseholdAdapter';
+export { MetadataAdapter } from './MetadataAdapter';
+export type { DatasetEntry } from './MetadataAdapter';
// User Ingredient Adapters
export { UserReportAdapter } from './UserReportAdapter';
diff --git a/app/src/api/metadata.ts b/app/src/api/metadata.ts
deleted file mode 100644
index 87e26aa36..000000000
--- a/app/src/api/metadata.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { BASE_URL } from '@/constants';
-import { MetadataApiPayload } from '@/types/metadata';
-
-/**
- * Fetch metadata for a country
- *
- * @param countryId - Country code (us, uk)
- */
-export async function fetchMetadata(countryId: string): Promise {
- const url = `${BASE_URL}/${countryId}/metadata`;
-
- const res = await fetch(url, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- Accept: 'application/json',
- },
- });
-
- if (!res.ok) {
- throw new Error(`Failed to fetch metadata for ${countryId}`);
- }
-
- return res.json();
-}
diff --git a/app/src/api/v2/datasets.ts b/app/src/api/v2/datasets.ts
new file mode 100644
index 000000000..cf5446012
--- /dev/null
+++ b/app/src/api/v2/datasets.ts
@@ -0,0 +1,16 @@
+import type { V2DatasetMetadata } from '@/types/metadata';
+import { API_V2_BASE_URL, getModelName } from './taxBenefitModels';
+
+/**
+ * Fetch all datasets for a country
+ */
+export async function fetchDatasets(countryId: string): Promise {
+ const modelName = getModelName(countryId);
+ const res = await fetch(`${API_V2_BASE_URL}/datasets/?tax_benefit_model_name=${modelName}`);
+
+ if (!res.ok) {
+ throw new Error(`Failed to fetch datasets for ${countryId}`);
+ }
+
+ return res.json();
+}
diff --git a/app/src/api/v2/index.ts b/app/src/api/v2/index.ts
new file mode 100644
index 000000000..14b009cf8
--- /dev/null
+++ b/app/src/api/v2/index.ts
@@ -0,0 +1,23 @@
+// Tax benefit models
+export {
+ API_V2_BASE_URL,
+ COUNTRY_TO_MODEL_NAME,
+ getModelName,
+ fetchTaxBenefitModels,
+ fetchModelVersion,
+ fetchModelVersionId,
+ type TaxBenefitModel,
+ type TaxBenefitModelVersion,
+} from './taxBenefitModels';
+
+// Variables
+export { fetchVariables } from './variables';
+
+// Parameters
+export { fetchParameters } from './parameters';
+
+// Parameter values (on-demand fetching)
+export { fetchParameterValues, BASELINE_POLICY_ID } from './parameterValues';
+
+// Datasets
+export { fetchDatasets } from './datasets';
diff --git a/app/src/api/v2/parameterValues.ts b/app/src/api/v2/parameterValues.ts
new file mode 100644
index 000000000..622c06b8a
--- /dev/null
+++ b/app/src/api/v2/parameterValues.ts
@@ -0,0 +1,43 @@
+import type { V2ParameterValueMetadata } from '@/types/metadata';
+import { API_V2_BASE_URL } from './taxBenefitModels';
+
+/**
+ * Special constant to indicate baseline (current law) values.
+ * Baseline values have policy_id = null in the database.
+ * When fetching baseline values, we omit the policy_id parameter entirely.
+ */
+export const BASELINE_POLICY_ID = 'baseline' as const;
+
+/**
+ * Fetch parameter values for a specific parameter and policy.
+ *
+ * All parameter values are stored separately and linked to a policy.
+ * Both baseline (current law) and reform policies have their own sets of values.
+ *
+ * @param parameterId - The ID of the parameter to fetch values for
+ * @param policyId - The ID of the policy, or "baseline" for current law values (omits policy_id from query)
+ * @returns Array of parameter value records
+ */
+export async function fetchParameterValues(
+ parameterId: string,
+ policyId: string
+): Promise {
+ const params = new URLSearchParams();
+ params.set('parameter_id', parameterId);
+
+ // For baseline (current law), omit policy_id to get values where policy_id IS NULL
+ // For reform policies, include the actual policy UUID
+ if (policyId !== BASELINE_POLICY_ID) {
+ params.set('policy_id', policyId);
+ }
+
+ const res = await fetch(`${API_V2_BASE_URL}/parameter-values/?${params.toString()}`);
+
+ if (!res.ok) {
+ throw new Error(
+ `Failed to fetch parameter values for parameter ${parameterId} with policy ${policyId}`
+ );
+ }
+
+ return res.json();
+}
diff --git a/app/src/api/v2/parameters.ts b/app/src/api/v2/parameters.ts
new file mode 100644
index 000000000..87bc7a9e9
--- /dev/null
+++ b/app/src/api/v2/parameters.ts
@@ -0,0 +1,18 @@
+import type { V2ParameterMetadata } from '@/types/metadata';
+import { API_V2_BASE_URL, getModelName } from './taxBenefitModels';
+
+/**
+ * Fetch all parameters for a country.
+ */
+export async function fetchParameters(countryId: string): Promise {
+ const modelName = getModelName(countryId);
+ const res = await fetch(
+ `${API_V2_BASE_URL}/parameters/?tax_benefit_model_name=${modelName}&limit=10000`
+ );
+
+ if (!res.ok) {
+ throw new Error(`Failed to fetch parameters for ${countryId}`);
+ }
+
+ return res.json();
+}
diff --git a/app/src/api/v2/taxBenefitModels.ts b/app/src/api/v2/taxBenefitModels.ts
new file mode 100644
index 000000000..562802580
--- /dev/null
+++ b/app/src/api/v2/taxBenefitModels.ts
@@ -0,0 +1,113 @@
+export const API_V2_BASE_URL = 'https://v2.api.policyengine.org';
+
+/**
+ * Map country IDs to their API model names.
+ * The API uses model names (e.g., "policyengine-us") for filtering.
+ */
+export const COUNTRY_TO_MODEL_NAME: Record = {
+ us: 'policyengine-us',
+ uk: 'policyengine-uk',
+};
+
+export interface TaxBenefitModel {
+ id: string;
+ name: string;
+ description: string;
+ created_at: string;
+}
+
+export interface TaxBenefitModelVersion {
+ id: string;
+ model_id: string;
+ version: string;
+}
+
+/**
+ * Fetch all tax benefit models
+ */
+export async function fetchTaxBenefitModels(): Promise {
+ const res = await fetch(`${API_V2_BASE_URL}/tax-benefit-models/`);
+
+ if (!res.ok) {
+ throw new Error('Failed to fetch tax benefit models');
+ }
+
+ return res.json();
+}
+
+/**
+ * Get the model name for a country (e.g., "policyengine-us")
+ */
+export function getModelName(countryId: string): string {
+ const modelName = COUNTRY_TO_MODEL_NAME[countryId];
+ if (!modelName) {
+ throw new Error(`Unknown country: ${countryId}`);
+ }
+ return modelName;
+}
+
+/**
+ * Fetch the current version for a country's model
+ */
+export async function fetchModelVersion(countryId: string): Promise {
+ const modelName = getModelName(countryId);
+ const res = await fetch(`${API_V2_BASE_URL}/tax-benefit-models/`);
+
+ if (!res.ok) {
+ throw new Error(`Failed to fetch models`);
+ }
+
+ const models: TaxBenefitModel[] = await res.json();
+ const model = models.find((m) => m.name === modelName);
+ if (!model) {
+ throw new Error(`Model not found for ${countryId}`);
+ }
+
+ const versionsRes = await fetch(`${API_V2_BASE_URL}/tax-benefit-model-versions/`);
+
+ if (!versionsRes.ok) {
+ throw new Error(`Failed to fetch model versions`);
+ }
+
+ const versions: TaxBenefitModelVersion[] = await versionsRes.json();
+ const modelVersions = versions.filter((v) => v.model_id === model.id);
+
+ if (modelVersions.length === 0) {
+ throw new Error(`No versions found for ${countryId}`);
+ }
+
+ return modelVersions[0].version;
+}
+
+/**
+ * Fetch the current version ID for a country's model
+ */
+export async function fetchModelVersionId(countryId: string): Promise {
+ const modelName = getModelName(countryId);
+ const res = await fetch(`${API_V2_BASE_URL}/tax-benefit-models/`);
+
+ if (!res.ok) {
+ throw new Error(`Failed to fetch models`);
+ }
+
+ const models: TaxBenefitModel[] = await res.json();
+ const model = models.find((m) => m.name === modelName);
+ if (!model) {
+ throw new Error(`Model not found for ${countryId}`);
+ }
+
+ const versionsRes = await fetch(`${API_V2_BASE_URL}/tax-benefit-model-versions/`);
+
+ if (!versionsRes.ok) {
+ throw new Error(`Failed to fetch model versions`);
+ }
+
+ const versions: TaxBenefitModelVersion[] = await versionsRes.json();
+ const modelVersions = versions.filter((v) => v.model_id === model.id);
+
+ if (modelVersions.length === 0) {
+ throw new Error(`No versions found for ${countryId}`);
+ }
+
+ return modelVersions[0].id;
+}
diff --git a/app/src/api/v2/variables.ts b/app/src/api/v2/variables.ts
new file mode 100644
index 000000000..b5b5ad5cc
--- /dev/null
+++ b/app/src/api/v2/variables.ts
@@ -0,0 +1,18 @@
+import type { V2VariableMetadata } from '@/types/metadata';
+import { API_V2_BASE_URL, getModelName } from './taxBenefitModels';
+
+/**
+ * Fetch all variables for a country.
+ */
+export async function fetchVariables(countryId: string): Promise {
+ const modelName = getModelName(countryId);
+ const res = await fetch(
+ `${API_V2_BASE_URL}/variables/?tax_benefit_model_name=${modelName}&limit=10000`
+ );
+
+ if (!res.ok) {
+ throw new Error(`Failed to fetch variables for ${countryId}`);
+ }
+
+ return res.json();
+}
diff --git a/app/src/components/common/LazyNestedMenu.tsx b/app/src/components/common/LazyNestedMenu.tsx
new file mode 100644
index 000000000..a9affb464
--- /dev/null
+++ b/app/src/components/common/LazyNestedMenu.tsx
@@ -0,0 +1,77 @@
+/**
+ * LazyNestedMenu - Renders a nested menu with on-demand child loading.
+ *
+ * Unlike NestedMenu which expects pre-populated children arrays,
+ * this component fetches children via a callback when a node is expanded.
+ */
+
+import { useState } from 'react';
+import { NavLink } from '@mantine/core';
+
+export interface LazyMenuNode {
+ name: string;
+ label: string;
+ type: 'parameterNode' | 'parameter';
+}
+
+interface LazyNestedMenuProps {
+ /** Initial nodes to display (root level) */
+ nodes: LazyMenuNode[];
+ /** Callback to get children for a path */
+ getChildren: (path: string) => LazyMenuNode[];
+ /** Callback when a leaf node (parameter) is clicked */
+ onParameterClick?: (name: string) => void;
+}
+
+export default function LazyNestedMenu({
+ nodes,
+ getChildren,
+ onParameterClick,
+}: LazyNestedMenuProps) {
+ const [expandedPaths, setExpandedPaths] = useState>(new Set());
+ const [activePath, setActivePath] = useState(null);
+
+ function toggleExpanded(path: string) {
+ setExpandedPaths((prev) => {
+ const next = new Set(prev);
+ if (next.has(path)) {
+ next.delete(path);
+ } else {
+ next.add(path);
+ }
+ return next;
+ });
+ }
+
+ function handleClick(node: LazyMenuNode) {
+ setActivePath(node.name);
+
+ if (node.type === 'parameter') {
+ onParameterClick?.(node.name);
+ } else {
+ toggleExpanded(node.name);
+ }
+ }
+
+ function renderNodes(nodeList: LazyMenuNode[]): React.ReactNode {
+ return nodeList.map((node) => {
+ const isExpanded = expandedPaths.has(node.name);
+ const isLeaf = node.type === 'parameter';
+
+ return (
+ handleClick(node)}
+ opened={isExpanded}
+ childrenOffset={16}
+ >
+ {!isLeaf && isExpanded && renderNodes(getChildren(node.name))}
+
+ );
+ });
+ }
+
+ return <>{renderNodes(nodes)}>;
+}
diff --git a/app/src/components/common/MetadataLoadingExperience.tsx b/app/src/components/common/MetadataLoadingExperience.tsx
deleted file mode 100644
index f678c82ff..000000000
--- a/app/src/components/common/MetadataLoadingExperience.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { Container, Divider, Loader, Stack, Text, Title } from '@mantine/core';
-
-interface MetadataLoadingExperienceProps {
- /** Country code (us or uk) */
- countryId: string;
-}
-
-/**
- * MetadataLoadingExperience - Simple loading screen for metadata fetch
- *
- * Matches the PathwayView design pattern used in create pages.
- * Shows a large centered loader within the app shell.
- */
-export function MetadataLoadingExperience({ countryId }: MetadataLoadingExperienceProps) {
- const countryName = countryId === 'uk' ? 'United Kingdom' : 'United States';
-
- return (
-
-
- Loading
-
-
- Fetching {countryName} policy data
-
-
-
-
-
-
- This may take a moment for first-time loads
-
-
-
- );
-}
diff --git a/app/src/components/household/HouseholdSummaryCard.tsx b/app/src/components/household/HouseholdSummaryCard.tsx
index 7469db933..0479f6002 100644
--- a/app/src/components/household/HouseholdSummaryCard.tsx
+++ b/app/src/components/household/HouseholdSummaryCard.tsx
@@ -1,7 +1,6 @@
-import { useSelector } from 'react-redux';
import { Box, Stack, Text } from '@mantine/core';
import { colors, spacing, typography } from '@/designTokens';
-import { RootState } from '@/store';
+import { useHouseholdMetadataContext } from '@/hooks/useMetadata';
import { Household } from '@/types/ingredients/Household';
import { calculateVariableComparison } from '@/utils/householdComparison';
import { formatVariableValue } from '@/utils/householdValues';
@@ -22,9 +21,9 @@ export default function HouseholdSummaryCard({
reform,
policyLabels,
}: HouseholdSummaryCardProps) {
- const metadata = useSelector((state: RootState) => state.metadata);
+ const metadataContext = useHouseholdMetadataContext();
- const rootVariable = metadata.variables.household_net_income;
+ const rootVariable = metadataContext.variables.household_net_income;
if (!rootVariable) {
return (
@@ -39,7 +38,7 @@ export default function HouseholdSummaryCard({
'household_net_income',
baseline,
reform,
- metadata
+ metadataContext
);
// Format the value
diff --git a/app/src/components/household/VariableArithmetic.tsx b/app/src/components/household/VariableArithmetic.tsx
index 28d070acd..74e7a86e3 100644
--- a/app/src/components/household/VariableArithmetic.tsx
+++ b/app/src/components/household/VariableArithmetic.tsx
@@ -3,6 +3,7 @@ import { IconCircleMinus, IconCirclePlus, IconTriangleFilled } from '@tabler/ico
import { useSelector } from 'react-redux';
import { ActionIcon, Box, Group, Text } from '@mantine/core';
import { spacing, typography } from '@/designTokens';
+import { useHouseholdMetadataContext } from '@/hooks/useMetadata';
import { useReportYear } from '@/hooks/useReportYear';
import { RootState } from '@/store';
import { Household } from '@/types/ingredients/Household';
@@ -40,16 +41,17 @@ export default function VariableArithmetic({
}: VariableArithmeticProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
const reportYear = useReportYear();
- const metadata = useSelector((state: RootState) => state.metadata);
+ const metadataContext = useHouseholdMetadataContext();
+ const parameters = useSelector((state: RootState) => state.metadata.parameters);
- const variable = metadata.variables[variableName];
+ const variable = metadataContext.variables[variableName];
if (!variable) {
return null;
}
// Calculate comparison (handles both single and comparison modes)
const isComparisonMode = reform !== null;
- const comparison = calculateVariableComparison(variableName, baseline, reform, metadata);
+ const comparison = calculateVariableComparison(variableName, baseline, reform, metadataContext);
// Get child variables (adds and subtracts)
let addsArray: string[] = [];
@@ -58,7 +60,7 @@ export default function VariableArithmetic({
if (variable.adds) {
if (typeof variable.adds === 'string') {
// It's a parameter name - resolve it
- const parameter = metadata.parameters[variable.adds];
+ const parameter = parameters[variable.adds];
if (parameter) {
addsArray = getParameterAtInstant(parameter, `${reportYear}-01-01`) || [];
}
@@ -70,7 +72,7 @@ export default function VariableArithmetic({
if (variable.subtracts) {
if (typeof variable.subtracts === 'string') {
// It's a parameter name - resolve it
- const parameter = metadata.parameters[variable.subtracts];
+ const parameter = parameters[variable.subtracts];
if (parameter) {
subtractsArray = getParameterAtInstant(parameter, `${reportYear}-01-01`) || [];
}
@@ -81,10 +83,10 @@ export default function VariableArithmetic({
// Filter child variables to only show non-zero ones
const visibleAdds = addsArray.filter((v) =>
- shouldShowVariable(v, baseline, reform, metadata, false)
+ shouldShowVariable(v, baseline, reform, metadataContext, false)
);
const visibleSubtracts = subtractsArray.filter((v) =>
- shouldShowVariable(v, baseline, reform, metadata, false)
+ shouldShowVariable(v, baseline, reform, metadataContext, false)
);
// Recursively render children
@@ -119,7 +121,11 @@ export default function VariableArithmetic({
}
// Get display text and style configuration
- const displayText = getVariableDisplayText(variable.label, comparison, isComparisonMode);
+ const displayText = getVariableDisplayText(
+ variable.label ?? variable.name,
+ comparison,
+ isComparisonMode
+ );
const styleConfig = getDisplayStyleConfig(isComparisonMode, comparison.direction, isAdd);
// Create arrow element based on configuration (hide if no change)
diff --git a/app/src/components/household/VariableInput.tsx b/app/src/components/household/VariableInput.tsx
index bac3d86c0..01ebe70f4 100644
--- a/app/src/components/household/VariableInput.tsx
+++ b/app/src/components/household/VariableInput.tsx
@@ -1,7 +1,7 @@
/**
* VariableInput - Renders the appropriate input control based on variable metadata
*
- * Dynamically selects NumberInput, Select, Switch, or TextInput based on valueType.
+ * Dynamically selects NumberInput, Select, Switch, or TextInput based on data_type.
* Uses VariableResolver for entity-aware value getting/setting.
*/
@@ -38,12 +38,11 @@ export default function VariableInput({
// Get formatting props for number inputs
const formattingProps = getInputFormattingProps({
- valueType: variable.valueType,
- unit: variable.unit,
+ data_type: variable.dataType,
});
- // Render based on valueType
- switch (variable.valueType) {
+ // Render based on data_type (V2 API field)
+ switch (variable.dataType) {
// Note: Same pattern in ValueInputBox.tsx - extract to shared component if reused again
case 'bool': {
const isChecked = Boolean(currentValue);
@@ -65,15 +64,19 @@ export default function VariableInput({
}
case 'Enum':
- if (variable.possibleValues && variable.possibleValues.length > 0) {
+ if (
+ variable.possibleValues &&
+ Array.isArray(variable.possibleValues) &&
+ variable.possibleValues.length > 0
+ ) {
return (