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 (