From dfd5489298a99b4f17a50e0494af9b2f6f9c4cd1 Mon Sep 17 00:00:00 2001 From: bakodramane Date: Wed, 3 Jun 2026 22:46:02 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Phase=204=20=E2=80=94=20five-step?= =?UTF-8?q?=20wizard=20UI=20with=20R-only=20badges=20and=20data-path=20spl?= =?UTF-8?q?it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-work (data path split): - UserInputs.censusDataPath now exclusively for unit-level census microdata - UserInputs.areaDataPath added for area-level direct estimates + auxiliaries - codegen.ts buildTokenMap updated: AREA_DATA uses areaDataPath, CENSUS_DATA uses censusDataPath (previously both shared the same field) - codegen.test.ts: FH_INPUTS updated to use areaDataPath Wizard UI (src/wizard/): - WizardContext.tsx — WizardState + useReducer with 12 action types; RESET action - Tooltip.tsx — hover tooltip component + InfoIcon (? badge) - Step1Import.tsx — CSV upload via papaparse (drag-and-drop + click), manual row-by-row entry, preview table with remove; validates ≥1 variable - Step2Roles.tsx — role/type dropdowns per variable; auto-suggests roles from variable name/type (weight, area-id, coordinate, direct-est, direct-var); validates exactly 1 target, ≥1 area-id, ≥1 auxiliary; role legend with tooltips - Step3Availability.tsx — checkboxes for all 9 DataAvailability flags with plain-language labels and tooltips; target-type radio (continuous/binary/ proportion/count/poverty/unknown); Stata version number input - Step4Methods.tsx — ranked method cards calling recommend(); badges: Area-level, Unit-level, Model-assisted, Bayesian, Robust, Spatial, R only (always visible), Stata v14 workaround; expandable Why this method? panel; single selection + optional comparison; R-only badge is immediate (not in expandable panel) - Step5Generate.tsx — variable summary; data path overrides (survey, census, area); nSimulations/mseMethod overrides; code preview in
 blocks;
  Download R button (always enabled); Stata button disabled for R-only methods
  (hb-fh, m-quantile, mqgwr, reblup, hb-unit) replaced by plain-language note;
  yellow fallback banner when usedFallback
- Wizard.tsx — top progress bar (5 steps), Back/Next navigation, Start over;
  validation-gated Next button; replaces App.tsx placeholder

isROnly utility: entry.stataCommand.startsWith((no) — covers all 5 R-only
methods (hb-fh, m-quantile, mqgwr, reblup, hb-unit) without hardcoding IDs.

E2E smoke test (e2e/wizard.spec.ts):
- Uploads test-codebook.csv (7 variables including target, area-id, auxiliaries,
  weight, direct-est, direct-var)
- Assigns roles via dropdown interaction
- Ticks availability flags (microdata, area aggregates, weights, census auxiliaries)
- Verifies FH-EBLUP card is present in the recommendation list
- Selects FH-EBLUP and advances to Step 5
- Triggers R script download and asserts filename ends in .R

All checks pass:
- npm run build PASS
- npm run lint PASS (0 warnings)
- npm test PASS (430 unit tests)
- playwright test e2e/wizard.spec.ts PASS (1/1)

Co-Authored-By: Claude Sonnet 4.6 
---
 e2e/fixtures/test-codebook.csv   |   8 ++
 e2e/wizard.spec.ts               |  96 +++++++++++++
 package-lock.json                |  32 +++++
 package.json                     |   2 +
 src/App.tsx                      |  13 +-
 src/engine/codegen.test.ts       |   2 +-
 src/engine/codegen.ts            |   8 +-
 src/wizard/Step1Import.tsx       | 206 ++++++++++++++++++++++++++++
 src/wizard/Step2Roles.tsx        | 140 +++++++++++++++++++
 src/wizard/Step3Availability.tsx | 166 +++++++++++++++++++++++
 src/wizard/Step4Methods.tsx      | 225 +++++++++++++++++++++++++++++++
 src/wizard/Step5Generate.tsx     | 220 ++++++++++++++++++++++++++++++
 src/wizard/Tooltip.tsx           |  35 +++++
 src/wizard/Wizard.tsx            | 134 ++++++++++++++++++
 src/wizard/WizardContext.tsx     | 118 ++++++++++++++++
 15 files changed, 1391 insertions(+), 14 deletions(-)
 create mode 100644 e2e/fixtures/test-codebook.csv
 create mode 100644 e2e/wizard.spec.ts
 create mode 100644 src/wizard/Step1Import.tsx
 create mode 100644 src/wizard/Step2Roles.tsx
 create mode 100644 src/wizard/Step3Availability.tsx
 create mode 100644 src/wizard/Step4Methods.tsx
 create mode 100644 src/wizard/Step5Generate.tsx
 create mode 100644 src/wizard/Tooltip.tsx
 create mode 100644 src/wizard/Wizard.tsx
 create mode 100644 src/wizard/WizardContext.tsx

diff --git a/e2e/fixtures/test-codebook.csv b/e2e/fixtures/test-codebook.csv
new file mode 100644
index 0000000..498b385
--- /dev/null
+++ b/e2e/fixtures/test-codebook.csv
@@ -0,0 +1,8 @@
+name,label,type
+income,Household income,continuous
+district,District identifier,identifier
+edu_rate,Education rate,continuous
+urban_pct,Urban percentage,continuous
+survey_weight,Survey sampling weight,continuous
+dir_est,Direct estimate of mean income,continuous
+dir_var,Sampling variance of direct estimate,continuous
diff --git a/e2e/wizard.spec.ts b/e2e/wizard.spec.ts
new file mode 100644
index 0000000..eca4cf2
--- /dev/null
+++ b/e2e/wizard.spec.ts
@@ -0,0 +1,96 @@
+import { test, expect, type Page } from '@playwright/test'
+import path from 'path'
+import { fileURLToPath } from 'url'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+
+// Helper to select an option by label text in a  { if (e.target.files?.[0]) handleFile(e.target.files[0]) }}
+          data-testid="file-input"
+        />
+        

Drag and drop a CSV file here, or click to browse

+

Required column: name — optional: label, type

+ + + {error && ( +

+ {error} +

+ )} + + {/* Manual entry */} +
+ Add a variable manually +
+
+ + setNewName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && addManual()} + placeholder="e.g. income" + /> +
+
+ + setNewLabel(e.target.value)} + onKeyDown={e => e.key === 'Enter' && addManual()} + placeholder="e.g. Household income" + /> +
+
+ + +
+
+ +
+ + {/* Preview table */} + {state.variables.length > 0 && ( +
+

+ Variables ({state.variables.length}) +

+
+ + + + + + + + + + + {state.variables.map((v, i) => ( + + + + + + + ))} + +
NameLabelType
{v.name}{v.label ?? '—'}{v.type} + +
+
+
+ )} + + ) +} + diff --git a/src/wizard/Step2Roles.tsx b/src/wizard/Step2Roles.tsx new file mode 100644 index 0000000..3fda446 --- /dev/null +++ b/src/wizard/Step2Roles.tsx @@ -0,0 +1,140 @@ +import { useEffect } from 'react' +import type { Variable, VariableRole, VariableType } from '../types/index.js' +import { useWizard } from './WizardContext.js' +import { Tooltip, InfoIcon } from './Tooltip.js' + +const ROLES: { value: VariableRole; label: string; tip: string }[] = [ + { value: 'target', label: 'Target', tip: 'The outcome variable you want to estimate for small areas (e.g. poverty rate, income).' }, + { value: 'area-id', label: 'Area identifier', tip: 'The column that identifies which small area each record belongs to (e.g. district code).' }, + { value: 'auxiliary', label: 'Auxiliary', tip: 'A covariate available in both the survey and the population data that is correlated with the target.' }, + { value: 'weight', label: 'Survey weight', tip: 'The sampling weight for each unit, reflecting the probability of selection.' }, + { value: 'direct-est', label: 'Direct estimate', tip: 'A pre-computed area-level estimate (e.g. from the direct estimator). Used only by area-level methods.' }, + { value: 'direct-var', label: 'Sampling variance', tip: 'The estimated sampling variance of the direct estimate. Required by Fay–Herriot models.' }, + { value: 'coordinate', label: 'Coordinate', tip: 'A geographic coordinate (latitude or longitude). Required by spatially adaptive methods.' }, + { value: 'ignored', label: 'Ignore', tip: 'This variable will not be used in the analysis.' }, +] + +const TYPES: VariableType[] = ['continuous', 'binary', 'proportion', 'count', 'categorical', 'identifier', 'unknown'] + +function suggestRole(v: Variable): VariableRole { + const n = v.name.toLowerCase() + if (v.type === 'identifier') return 'area-id' + if (/weight|wt$|pweight|fweight/.test(n)) return 'weight' + if (/area|district|region|province|zone|psu|ea$|code$/.test(n)) return 'area-id' + if (/lat$|latitude/.test(n) || /lon$|longitude/.test(n)) return 'coordinate' + if (/dir_est|direct_est|ye$|yhat/.test(n)) return 'direct-est' + if (/dir_var|var_dir|mse$/.test(n)) return 'direct-var' + return 'ignored' +} + +export function Step2Roles() { + const { state, dispatch } = useWizard() + + // Auto-suggest roles on first render of this step + useEffect(() => { + const anyAssigned = state.variables.some(v => v.role !== 'ignored') + if (!anyAssigned) { + const suggested = state.variables.map(v => ({ ...v, role: suggestRole(v) })) + dispatch({ type: 'SET_VARIABLES', payload: suggested }) + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + function setRole(i: number, role: VariableRole) { + const updated = state.variables.map((v, idx) => idx === i ? { ...v, role } : v) + dispatch({ type: 'SET_VARIABLES', payload: updated }) + } + + function setType(i: number, type: VariableType) { + const updated = state.variables.map((v, idx) => idx === i ? { ...v, type } : v) + dispatch({ type: 'SET_VARIABLES', payload: updated }) + } + + const targets = state.variables.filter(v => v.role === 'target') + const areaIds = state.variables.filter(v => v.role === 'area-id') + const auxVars = state.variables.filter(v => v.role === 'auxiliary') + + const errors: string[] = [] + if (targets.length !== 1) errors.push(`Exactly one variable must be assigned the "Target" role (currently ${targets.length}).`) + if (areaIds.length < 1) errors.push('At least one variable must be assigned the "Area identifier" role.') + if (auxVars.length < 1) errors.push('At least one variable must be assigned the "Auxiliary" role.') + + return ( +
+
+

Step 2 — Variable roles and types

+

+ Assign a role and confirm the type for each variable. Roles marked with (?) have tooltips explaining what they mean. +

+
+ +
+ + + + + + + + + + + {state.variables.map((v, i) => ( + + + + + + + ))} + +
NameLabel + + Role + + + + + Type
{v.name}{v.label ?? '—'} + + + +
+
+ + {/* Role legend */} +
+ {ROLES.filter(r => r.value !== 'ignored').map(r => ( +
+ + {r.label} + + — {r.tip.split('.')[0]}. +
+ ))} +
+ + {errors.length > 0 && ( +
    + {errors.map((e, i) =>
  • • {e}
  • )} +
+ )} +
+ ) +} + diff --git a/src/wizard/Step3Availability.tsx b/src/wizard/Step3Availability.tsx new file mode 100644 index 0000000..b01cfe1 --- /dev/null +++ b/src/wizard/Step3Availability.tsx @@ -0,0 +1,166 @@ +import type { DataAvailability } from '../types/index.js' +import { useWizard } from './WizardContext.js' +import { Tooltip, InfoIcon } from './Tooltip.js' + +interface FlagConfig { + key: keyof Omit + label: string + tip: string +} + +const FLAGS: FlagConfig[] = [ + { + key: 'hasMicrodata', + label: 'I have individual household/unit records (microdata)', + tip: 'The survey dataset contains one row per household or person, with all variables recorded at the individual level.', + }, + { + key: 'hasAreaAggregates', + label: 'I have area-level direct estimates and their sampling variances', + tip: 'Pre-computed estimates for each small area (e.g. from the direct estimator) along with their estimated mean squared errors or variances.', + }, + { + key: 'hasWeights', + label: 'My microdata includes sampling weights', + tip: 'Each record has a weight variable that reflects the probability of being sampled. Required for design-consistent estimation.', + }, + { + key: 'hasCensusAuxiliaries', + label: 'I have population-level auxiliary variables from a census or registry', + tip: 'Covariates available for the entire population — either as individual census records (unit-level) or as area-level means/totals. Required for most model-based methods.', + }, + { + key: 'hasContiguityMatrix', + label: 'I have a spatial contiguity or distance matrix for the areas', + tip: 'A matrix describing which areas are neighbours or their distances apart. Required for spatially correlated models such as Spatial Fay–Herriot.', + }, + { + key: 'hasCoordinates', + label: 'I have geographic coordinates (centroids or unit locations)', + tip: 'Latitude/longitude values for each area centroid or survey unit. Required for geographically weighted methods (MQGWR).', + }, + { + key: 'hasOutOfSampleAreas', + label: 'Some target areas have no sample observations (out-of-sample areas)', + tip: 'Areas where the sample size is zero — no households were surveyed. Model-based methods can still produce estimates for these areas using a synthetic predictor.', + }, + { + key: 'likelyOutliers', + label: 'I suspect there are outlying areas or unusual observations', + tip: 'A few areas or units may have extreme values that could unduly influence model estimates. Robust methods (M-quantile, REBLUP) are preferred in this case.', + }, + { + key: 'likelySpatialCorrelation', + label: 'Neighbouring areas are likely to have similar values (spatial correlation)', + tip: 'Areas that are geographically close tend to share characteristics. Spatial models borrow additional strength from neighbours and can improve precision.', + }, +] + +const TARGET_TYPES: { value: DataAvailability['targetType']; label: string }[] = [ + { value: 'continuous', label: 'Continuous (e.g. income, expenditure, yield)' }, + { value: 'binary', label: 'Binary — yes/no outcome (e.g. employed, food-insecure)' }, + { value: 'proportion', label: 'Proportion / rate (e.g. school attendance rate)' }, + { value: 'count', label: 'Count (e.g. number of events per household or area)' }, + { value: 'poverty', label: 'Poverty or inequality indicator (e.g. headcount, Gini)' }, + { value: 'unknown', label: 'I am not sure yet' }, +] + +export function Step3Availability() { + const { state, dispatch } = useWizard() + const { availability, stataVersion } = state + + function toggleFlag(key: keyof Omit) { + dispatch({ + type: 'SET_AVAILABILITY', + payload: { ...availability, [key]: !availability[key] }, + }) + } + + function setTargetType(v: DataAvailability['targetType']) { + dispatch({ type: 'SET_AVAILABILITY', payload: { ...availability, targetType: v } }) + } + + return ( +
+
+

Step 3 — Data availability

+

+ Tick everything that describes your data situation. The recommender uses these flags + to filter and rank appropriate methods. +

+
+ + {/* Target type */} +
+ + What kind of variable is your target?{' '} + + + + +
+ {TARGET_TYPES.map(tt => ( + + ))} +
+
+ +
+ + {/* Boolean flags */} +
+ What data do you have available? + {FLAGS.map(f => ( + + ))} +
+ +
+ + {/* Stata version */} +
+ + dispatch({ type: 'SET_STATA_VERSION', payload: Number(e.target.value) })} + className="w-24 border border-gray-300 rounded px-3 py-1.5 text-sm focus:ring-2 focus:ring-indigo-500 focus:outline-none" + aria-label="Stata version number" + /> +

Enter your Stata version number (e.g. 14, 16, 17). Enter 0 if you only use R.

+
+
+ ) +} + diff --git a/src/wizard/Step4Methods.tsx b/src/wizard/Step4Methods.tsx new file mode 100644 index 0000000..e78a800 --- /dev/null +++ b/src/wizard/Step4Methods.tsx @@ -0,0 +1,225 @@ +import { useState } from 'react' +import type { CatalogueEntry } from '../types/index.js' +import { recommend } from '../engine/recommender.js' +import type { Recommendation } from '../engine/recommender.js' +import { useWizard } from './WizardContext.js' + +function isROnly(entry: CatalogueEntry): boolean { + return entry.stataCommand.startsWith('(no') +} + +function LevelBadge({ entry }: { entry: CatalogueEntry }) { + const map: Record = { + area: 'bg-blue-100 text-blue-800', + unit: 'bg-green-100 text-green-800', + 'model-assisted': 'bg-purple-100 text-purple-800', + } + const label: Record = { + area: 'Area-level', + unit: 'Unit-level', + 'model-assisted': 'Model-assisted', + } + return ( + + {label[entry.level]} + + ) +} + +function MethodBadges({ rec }: { rec: Recommendation }) { + const { entry } = rec + return ( +
+ + {entry.inferenceType === 'bayesian' && ( + + Bayesian + + )} + {entry.robust && ( + + Robust + + )} + {entry.spatial && ( + + Spatial + + )} + {isROnly(entry) && ( + + R only + + )} + {rec.stataV14Warning && !isROnly(entry) && ( + + ⚠ Stata v14 workaround + + )} +
+ ) +} + +function MethodCard({ rec, selected, comparison, onSelect, onToggleComparison }: { + rec: Recommendation + selected: boolean + comparison: boolean + onSelect: () => void + onToggleComparison: () => void +}) { + const [expanded, setExpanded] = useState(false) + const { entry } = rec + + return ( +
+
+ {/* Rank badge */} + + {rec.rank} + + +
+
+

{entry.displayName}

+
+ +
+
+ + + +

{rec.whyApplicable}

+ + {/* Software info */} +
+ R: {entry.rPackage} + {!isROnly(entry) && ( + Stata: {entry.stataCommand} + )} + MSE: {entry.mseMethod} +
+ + {/* Caveats */} + {rec.caveats.length > 0 && ( +
    + {rec.caveats.map((c, i) => ( +
  • + {c} +
  • + ))} +
+ )} + + {/* Why this method — expandable */} +
+ + {expanded && ( +
+

{entry.whyChooseThis}

+
+

Assumptions:

+
    + {entry.assumptions.map((a, i) =>
  • • {a}
  • )} +
+
+
+ )} +
+ + {/* Comparison checkbox — not shown for the direct estimator */} + {entry.id !== 'direct' && ( + + )} +
+
+
+ ) +} + +export function Step4Methods() { + const { state, dispatch } = useWizard() + const recs = recommend(state.availability) + + function selectMethod(id: string) { + dispatch({ type: 'SELECT_METHOD', payload: id }) + if (state.comparisonMethodId === id) { + dispatch({ type: 'SELECT_COMPARISON', payload: null }) + } + } + + function toggleComparison(id: string) { + dispatch({ + type: 'SELECT_COMPARISON', + payload: state.comparisonMethodId === id ? null : id, + }) + } + + return ( +
+
+

Step 4 — Recommended methods

+

+ Methods are ranked by suitability for your data. Select one to generate code for, and + optionally tick a second method to generate a comparison script. +

+
+ + {recs.length === 0 ? ( +

+ No methods matched your data description. Please go back and check your availability flags. +

+ ) : ( +
+ {recs.map(rec => ( + selectMethod(rec.entry.id)} + onToggleComparison={() => toggleComparison(rec.entry.id)} + /> + ))} +
+ )} + + {recs.length > 0 && !state.selectedMethodId && ( +

+ Please select a method before continuing. +

+ )} +
+ ) +} + diff --git a/src/wizard/Step5Generate.tsx b/src/wizard/Step5Generate.tsx new file mode 100644 index 0000000..ae81c7a --- /dev/null +++ b/src/wizard/Step5Generate.tsx @@ -0,0 +1,220 @@ +import type { CatalogueEntry } from '../types/index.js' +import { catalogue } from '../catalogue/index.js' +import { generateCode } from '../engine/codegen.js' +import type { UserInputs } from '../engine/codegen.js' +import { useWizard } from './WizardContext.js' + +function isROnly(entry: CatalogueEntry): boolean { + return entry.stataCommand.startsWith('(no') +} + +function download(content: string, filename: string) { + const blob = new Blob([content], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +function buildInputs(state: ReturnType['state']): UserInputs { + const targetVar = state.variables.find(v => v.role === 'target')?.name ?? '' + const areaIdVar = state.variables.find(v => v.role === 'area-id')?.name ?? '' + const auxiliaryVars = state.variables.filter(v => v.role === 'auxiliary').map(v => v.name) + const weightVar = state.variables.find(v => v.role === 'weight')?.name + const directEstVar = state.variables.find(v => v.role === 'direct-est')?.name + const directVarVar = state.variables.find(v => v.role === 'direct-var')?.name + + return { + targetVar, + areaIdVar, + auxiliaryVars, + weightVar, + directEstVar, + directVarVar, + surveyDataPath: state.surveyDataPath, + censusDataPath: state.censusDataPath, + areaDataPath: state.areaDataPath, + stataVersion: state.stataVersion, + nSimulations: state.nSimulations, + mseMethod: state.mseMethod, + } +} + +function CodeBlock({ code, label }: { code: string; label: string }) { + return ( +
+
{label}
+
+        {code}
+      
+
+ ) +} + +function ScriptSection({ entry }: { entry: CatalogueEntry }) { + const { state, dispatch } = useWizard() + const inputs = buildInputs(state) + const code = generateCode(entry, inputs) + const rOnly = isROnly(entry) + const slug = entry.id + + const needsOOS = entry.id === 'ebp-censuseb' || entry.id === 'ell' + + return ( +
+

{entry.displayName}

+ + {/* Overrides */} +
+
+
+ + dispatch({ type: 'SET_SURVEY_PATH', payload: e.target.value })} + /> +
+
+ + dispatch({ type: 'SET_CENSUS_PATH', payload: e.target.value })} + /> +
+
+ + dispatch({ type: 'SET_AREA_PATH', payload: e.target.value })} + /> +
+
+
+ {needsOOS && ( +
+ + dispatch({ type: 'SET_N_SIM', payload: Number(e.target.value) })} + /> +
+ )} +
+ + +
+
+
+ + {/* Fallback banner */} + {code.usedFallback && ( +
+ Stata version notice: Your Stata version ({state.stataVersion}) does not + support {entry.displayName} natively. The .do file uses the base{' '} + mixed command instead. + Use the R script for the full implementation. {code.fallbackNote} +
+ )} + + {/* Script previews */} + + + + {/* Download buttons */} +
+ + + {rOnly ? ( +

+ No Stata implementation available — this method requires R. +

+ ) : ( + + )} +
+
+ ) +} + +export function Step5Generate() { + const { state } = useWizard() + const { selectedMethodId, comparisonMethodId, variables } = state + + const selectedEntry = catalogue.find(e => e.id === selectedMethodId) + const comparisonEntry = catalogue.find(e => e.id === comparisonMethodId) + + const targetVar = variables.find(v => v.role === 'target') + const areaIdVar = variables.find(v => v.role === 'area-id') + const auxVars = variables.filter(v => v.role === 'auxiliary') + + return ( +
+
+

Step 5 — Download scripts

+

+ Review the generated scripts, adjust paths if needed, then download. +

+
+ + {/* Summary */} +
+

Target variable: {targetVar?.name ?? '—'}

+

Area identifier: {areaIdVar?.name ?? '—'}

+

Auxiliary variables:{' '} + {auxVars.map(v => {v.name})} +

+

Stata version: {state.stataVersion}

+
+ + {selectedEntry && } + {comparisonEntry && ( +
+

Comparison method

+ +
+ )} +
+ ) +} diff --git a/src/wizard/Tooltip.tsx b/src/wizard/Tooltip.tsx new file mode 100644 index 0000000..47fa38c --- /dev/null +++ b/src/wizard/Tooltip.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react' + +interface TooltipProps { + text: string + children: ReactNode +} + +export function Tooltip({ text, children }: TooltipProps) { + return ( + + {children} + + {text} + + + ) +} + +export function InfoIcon() { + return ( + + ) +} diff --git a/src/wizard/Wizard.tsx b/src/wizard/Wizard.tsx new file mode 100644 index 0000000..ad99228 --- /dev/null +++ b/src/wizard/Wizard.tsx @@ -0,0 +1,134 @@ +import { WizardProvider, useWizard } from './WizardContext.js' +import { Step1Import } from './Step1Import.js' +import { Step2Roles } from './Step2Roles.js' +import { Step3Availability } from './Step3Availability.js' +import { Step4Methods } from './Step4Methods.js' +import { Step5Generate } from './Step5Generate.js' +import type { Variable } from '../types/index.js' + +function step1Valid(variables: Variable[]) { return variables.length > 0 } +function step2Valid(variables: Variable[]) { + return ( + variables.filter(v => v.role === 'target').length === 1 && + variables.filter(v => v.role === 'area-id').length >= 1 && + variables.filter(v => v.role === 'auxiliary').length >= 1 + ) +} + +const STEPS = [ + { label: 'Variables' }, + { label: 'Roles' }, + { label: 'Data' }, + { label: 'Methods' }, + { label: 'Download' }, +] + +function ProgressBar({ step }: { step: number }) { + return ( + + ) +} + +function WizardInner() { + const { state, dispatch } = useWizard() + const { step, variables, selectedMethodId } = state + + const canAdvance = + step === 1 ? step1Valid(variables) + : step === 2 ? step2Valid(variables) + : step === 3 ? true // no required flags + : step === 4 ? selectedMethodId !== null + : false + + function goNext() { if (canAdvance) dispatch({ type: 'SET_STEP', payload: step + 1 }) } + function goBack() { dispatch({ type: 'SET_STEP', payload: step - 1 }) } + function reset() { dispatch({ type: 'RESET' }) } + + return ( +
+
+
+

SAE Syntax Generator

+

Small area estimation — script generator

+
+ +
+ +
+ + +
+ {step === 1 && } + {step === 2 && } + {step === 3 && } + {step === 4 && } + {step === 5 && } +
+ +
+ {step > 1 ? ( + + ) : } + + {step < 5 && ( + + )} +
+
+
+ ) +} + +export function Wizard() { + return ( + + + + ) +} diff --git a/src/wizard/WizardContext.tsx b/src/wizard/WizardContext.tsx new file mode 100644 index 0000000..02baafb --- /dev/null +++ b/src/wizard/WizardContext.tsx @@ -0,0 +1,118 @@ +import { createContext, useContext, useReducer } from 'react' +import type { ReactNode } from 'react' +import type { Variable, DataAvailability } from '../types/index.js' + +// ── State ────────────────────────────────────────────────────────────────────── + +export interface WizardState { + step: number + variables: Variable[] + availability: DataAvailability + stataVersion: number + selectedMethodId: string | null + comparisonMethodId: string | null + surveyDataPath: string + censusDataPath: string + areaDataPath: string + nSimulations: number + mseMethod: 'analytic' | 'bootstrap' +} + +// ── Actions ──────────────────────────────────────────────────────────────────── + +type WizardAction = + | { type: 'SET_STEP'; payload: number } + | { type: 'SET_VARIABLES'; payload: Variable[] } + | { type: 'SET_AVAILABILITY'; payload: DataAvailability } + | { type: 'SET_STATA_VERSION'; payload: number } + | { type: 'SELECT_METHOD'; payload: string | null } + | { type: 'SELECT_COMPARISON'; payload: string | null } + | { type: 'SET_SURVEY_PATH'; payload: string } + | { type: 'SET_CENSUS_PATH'; payload: string } + | { type: 'SET_AREA_PATH'; payload: string } + | { type: 'SET_N_SIM'; payload: number } + | { type: 'SET_MSE_METHOD'; payload: 'analytic' | 'bootstrap' } + | { type: 'RESET' } + +// ── Initial state ────────────────────────────────────────────────────────────── + +const defaultAvailability: DataAvailability = { + hasMicrodata: false, + hasAreaAggregates: false, + hasWeights: false, + hasCensusAuxiliaries: false, + hasContiguityMatrix: false, + hasCoordinates: false, + hasOutOfSampleAreas: false, + targetType: 'unknown', + likelyOutliers: false, + likelySpatialCorrelation: false, +} + +const initialState: WizardState = { + step: 1, + variables: [], + availability: defaultAvailability, + stataVersion: 14, + selectedMethodId: null, + comparisonMethodId: null, + surveyDataPath: 'survey.csv', + censusDataPath: 'census.csv', + areaDataPath: 'area_data.csv', + nSimulations: 200, + mseMethod: 'bootstrap', +} + +// ── Reducer ──────────────────────────────────────────────────────────────────── + +function reducer(state: WizardState, action: WizardAction): WizardState { + switch (action.type) { + case 'SET_STEP': + return { ...state, step: action.payload } + case 'SET_VARIABLES': + return { ...state, variables: action.payload } + case 'SET_AVAILABILITY': + return { ...state, availability: action.payload } + case 'SET_STATA_VERSION': + return { ...state, stataVersion: action.payload } + case 'SELECT_METHOD': + return { ...state, selectedMethodId: action.payload } + case 'SELECT_COMPARISON': + return { ...state, comparisonMethodId: action.payload } + case 'SET_SURVEY_PATH': + return { ...state, surveyDataPath: action.payload } + case 'SET_CENSUS_PATH': + return { ...state, censusDataPath: action.payload } + case 'SET_AREA_PATH': + return { ...state, areaDataPath: action.payload } + case 'SET_N_SIM': + return { ...state, nSimulations: action.payload } + case 'SET_MSE_METHOD': + return { ...state, mseMethod: action.payload } + case 'RESET': + return initialState + default: + return state + } +} + +// ── Context ──────────────────────────────────────────────────────────────────── + +interface WizardContextValue { + state: WizardState + dispatch: React.Dispatch +} + +const WizardContext = createContext(null) + +export function WizardProvider({ children }: { children: ReactNode }) { + const [state, dispatch] = useReducer(reducer, initialState) + return {children} +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useWizard(): WizardContextValue { + const ctx = useContext(WizardContext) + if (!ctx) throw new Error('useWizard must be used inside WizardProvider') + return ctx +} From 4c15d98dd761f620fecf5101e436d8e4c7467272 Mon Sep 17 00:00:00 2001 From: bakodramane Date: Thu, 4 Jun 2026 16:03:57 +0200 Subject: [PATCH 2/2] fix: regenerate corrupted package-lock.json so npm ci succeeds in CI The lockfile had two missing closing braces (papaparse and undici-types entries) plus a depth imbalance that caused JSON.parse to fail, making npm ci abort with "lockfileVersion >= 1 required". Regenerated cleanly with npm install. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 80 +++++++++++++++-------------------------------- 1 file changed, 25 insertions(+), 55 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3f8874e..622975e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1479,56 +1479,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", - "license": "MIT", - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } - }, - "node_modules/@types/papaparse": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", - "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/react": { - "version": "19.2.16", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", - "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", "node_modules/@babel/plugin-transform-unicode-escapes": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", @@ -3706,6 +3656,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.16", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", @@ -7681,11 +7649,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/papaparse": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", - "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", - "license": "MIT" "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -7693,6 +7656,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -9538,6 +9507,7 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",