Skip to content

v3 standalone household report and saved households, unreachable until v3 moves to the v2 routes - #1392

Draft
anth-volk wants to merge 29 commits into
v2plan/1e-112-v3-uk-us-householdsfrom
v2plan/1e-113-household-report-saved-households
Draft

anth-volk wants to merge 29 commits into
v2plan/1e-112-v3-uk-us-householdsfrom
v2plan/1e-113-household-report-saved-households

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1391

Stacked on #1386.

What changes

The code for v3's standalone household report and saved households. No route or control reaches it yet (see "Unreachable until v3 moves to the v2 routes"), so users see no change.

v2 household documents (models/household/v2DocumentCodec.ts, types/v2Households.ts). The app writes a household as the Public API's /v2/households document exactly as the Public API translates a v1 household (translate_legacy_household): people and groups take ids in name order, v1 names are kept as source_name, group members become each person's memberships, the SPM settings go on the document, and no label is sent. So a household v3 saves deduplicates with the dual-write copy of the same v1 household. A stored household reads back into the app's model in its stored default_year. haveSameStoredContent compares two households as POST /v2/households takes them, since a stored household reads back with its groups in name order.

Saved households (api/savedHouseholdStore.ts, hooks/useSavedHouseholds.ts), with accounts on, signed in or signed out as the browser's unauthenticated user:

  • save: POST /v2/households (which returns the existing household for identical content), then POST /v2/user-households with the label and the v2 household_id;
  • list (most recently updated first; a row naming only a v1 household, or whose household fails to load, is listed as unavailable rather than failing the list), open, rename (PATCH of the name) and delete (the row only: stored households are shared);
  • save changes: edited content is stored, then one PATCH /v2/user-households/{id} points the saved household at it (household_id) with any new label, so it keeps its id; identical content changes no row; either step failing says "Your changes were not saved";
  • queries are cached per user and country; each stored household is fetched once per session (kept in the query client by country and id, as stored households never change).

Without accounts there are no saved households: each control says so, and nothing is saved in the browser.

Saved household interface (components/flagship/household/): a "Household label" field with Save (SaveHouseholdForm: a new household, changes to a saved one, or a rename alone; moving a saved household into the report's year is not an edit; "Saved" shows only while it describes what is on screen); a picker (SavedHouseholdPicker); and SavedHouseholds.page ("Your households") with Rename and Delete, which asks first.

Standalone household report (pages/flagship/HouseholdReport.page.tsx), for one household under a baseline and, optionally, a reform:

  • the net income headline: the change in net income with a reform (the report section's HouseholdImpactResult), or net income alone for a baseline-only household (HouseholdNetIncomeResult), including one whose reform is the baseline;
  • the net income tree, varying earnings and marginal tax rates (HouseholdBreakdowns);
  • the household's inputs (HouseholdSubPage), the policy summary (PolicySubPage) and Reproduce in Python (HouseholdReproducibility), each Python block given its own policy by id and pinned to the .py bundle its own calculation reported;
  • a year the reader chooses from the model's tax years (Add user-selectable year to standalone household creation #498): the household's inputs move to it with the report builder's copyHouseholdToYear; a household or saved household with inputs in several years opens in its own year, with the reason;
  • varying earnings also offers the amounts the household sets, such as tip income (App does not display all possible variables in "Varying your earnings" #1096; EarningsVariationSubPage's new alsoOfferInputs, passed only by this report; the SALT cap, the issue's other example, is a calculated variable the list already offers);
  • Reproduce in Python can end each block with a pandas DataFrame (Add dataframe-generating code to Reproduce in Python segment #1095; getHouseholdReproducibilityCode's new includeDataFrame option and HouseholdReproducibility's offerDataFrame, passed only by this report). Blocks generated for a UK household under a personal allowance reform (run against policyengine-uk 2.130.1) and for a US household under current law (policyengine-us 2.2.1) ran locally: the point block prints a four-row table and the earnings variation block a 401-row one;
  • the household builder as v3 configures it, the saved-household picker (disabled while a calculation runs) and the save form.

It calculates through the Public API's v1 household routes, as the report section does; "Try again" never sends a household that cannot run.

Shared with the live report section and the saved reforms page, behaving as before (their existing tests pass unchanged):

  • HouseholdBuilderPanel: the builder configuration the section used, now used by both;
  • useRequestedHouseholdRun (the run request: asking again for the same household, retrying only a household that can run), householdRunParts (Calculate, progress and failure) and householdResultParts (money formatting, card, headline and output tabs; the tabs' hover color now applies);
  • newV3Household, the section's new household, moved to libs/flagship/householdReport;
  • DeleteConfirmationDialog, now rendered by DeleteReformDialog and the saved households list.

The existing household report output and the report section offer the same earnings variables and Python code as before: the new options default off.

Unreachable until v3 moves to the v2 routes

No runtime flag or environment variable. Everything that works only on the v2 routes is gathered in app/src/pages/flagship/householdEntryPoints.ts, which nothing routed imports. householdEntryPoints.test.tsx checks that Build's "A household" option cannot be chosen, and that no file in the app or the calculator imports the household-only modules, by an @/ specifier or a relative path, except those modules themselves (confirmed to fail, naming Report.page.tsx, when that page imported one by a relative path). A report whose simulations are households still shows "This report cannot open here" (ReportLoadStates.test.tsx).

What the change that moves v3 to the v2 routes does to reach them:

  1. Enables Build's "A household" option (components/flagship/reform/PopulationPicker.tsx): choosing it offers SavedHouseholdPicker or a new household, and Build's run opens HouseholdReportPage with that household, the draft reform, and the run bar's year and baseline.
  2. Renders HouseholdReportPage in Report.page.tsx's householdReport branch for household reports saved through /v2/reports.
  3. Adds a saved households route (calculator-app/src/app/[countryId]/(workspace)/households/page.tsx rendering SavedHouseholdsPage), a "households" screen in workspaceRoutes.ts and libs/flagship/shellRoutes.ts, and a link in the workspace navigation.

Before then, the report's calculations move to /v2/household-calculations, and saving changes to a saved household needs the Public API's PATCH /v2/user-households/{id} to accept household_id (today its update model accepts only legacy_household_id, name and description, so that request is refused and the form says the changes were not saved).

Tests

New or changed, all passing (188 tests in these files):

  • v2DocumentCodec.test.ts (15): US and UK documents match the Public API's translation (checked against translate_legacy_household for three households: same canonical content hash and default year); no label is sent; a person outside a collection fails, naming it; read-back keeps the stored id and default_year (including inputs in two years, values without a year, and no default year).
  • savedHouseholdStore.test.ts (19): save, list (order, unavailable rows), open, rename, delete; save changes (one PATCH keeping the id, identical content, either step failing); each stored household fetched once, a failed fetch asked again.
  • useSavedHouseholds.test.tsx (8): the hooks against routed Public API responses, including no accounts, a country change for the same user, and one stored-household cache across hooks.
  • SaveHouseholdForm.test.tsx (11), SavedHouseholdPicker.test.tsx (9), SavedHouseholds.page.test.tsx (8), DeleteConfirmationDialog.test.tsx (4), HouseholdBuilderPanel.test.tsx (5), HouseholdNetIncomeResult.test.tsx (4).
  • HouseholdReport.page.test.tsx (28): a reform's change and a baseline-only household's net income; baseline-only breakdowns; the inputs, policy and Reproduce in Python sections, each block with its own policy and bundle; trying again (including after an edit that cannot run); Calculate disabled for the household shown; years (Add user-selectable year to standalone household creation #498); the result describing the household calculated; tip income in varying earnings (App does not display all possible variables in "Varying your earnings" #1096); national poverty thresholds for a new US household; a UK household in pounds; saved households (save, save changes, a failed save, opening from the picker or on entry, another year, a save finishing after another household is opened, no picker during a calculation, no accounts).
  • householdEntryPoints.test.tsx (5), reproducibilityCode.test.ts (+3), HouseholdReproducibility.test.tsx (+2), EarningsVariationInputs.test.tsx (2), HouseholdBreakdownsInputs.test.tsx (2), householdReport.test.ts (1).

Each commit's tests were run failing first, then passing; refactor and style commits say so where there was no behavior to fail first. Full app suite on this branch: 5,660 passed, 10 skipped (Node 26). tsc (app, website and calculator), ESLint, Stylelint and Prettier are clean. The PR checks (lint and format, type checking, tests, build, embedded site URLs, visual regression) pass on this PR.

Three review rounds were run on this branch: the first found two bugs (a retry could send a household that cannot run; "Saved" stayed after an edit) and the second one (a save finishing after another saved household was opened pointed the form at the wrong saved household); each was fixed with a failing test first, with the material quality findings. The third found none.

Environment variables

None added or changed, and no route reaches the new code. When reached, it uses the app's existing clients: VITE_API_URL (set as NEXT_PUBLIC_API_URL or VITE_API_URL) for the Public API's address, and the existing sign-in configuration (NEXT_PUBLIC_AUTH_ENABLED, NEXT_PUBLIC_AUTH0_DOMAIN, NEXT_PUBLIC_AUTH0_CLIENT_ID, NEXT_PUBLIC_AUTH0_AUDIENCE, or their VITE_ names), all from the Vercel build environment and mapped in calculator-app/next.config.ts. Their Vercel values were not re-checked here.

Before merging

Overlaps

  • Draft Send explicit UK claimant roles and stop reading an adult dependant as the partner #1240 (UK claimant roles): not modified; no file in common.
  • Draft Use compact typed SPM provenance contracts #1239 (compact SPM provenance) also edits utils/reproducibilityCode.ts and drops spmConfig from HouseholdReproduction. Whichever merges second adapts: here the options object and the DataFrame code are independent of the SPM settings, and the household report passes each block's spmConfig, which would then be left out.
  • v3's saved reforms on /v2/policies also add v2 client code here; the household wire types are in their own types/v2Households.ts and use the existing V2UserClient and AccountAssociationRoutes.

🤖 Generated with Claude Code

anth-volk and others added 29 commits October 22, 2026 02:30
…tores them

Saved households in v3 will live on the Public API's /v2/households
(immutable, deduplicated content) and /v2/user-households (the user's
name for one), so the app needs the v2 household document:

- types/v2Households.ts: the wire types for both routes. The PATCH body
  includes household_id, which points a saved household at another
  stored household once the Public API accepts it.
- householdToV2CreateBody builds the document exactly as the Public API
  translates a v1 household (translate_legacy_household): ids in name
  order, v1 names kept as source_name, group members as each person's
  memberships, the SPM settings on the document, and no label. So a
  household v3 saves deduplicates with the dual-write copy of the same
  v1 household. A person outside a collection, which v2 rejects, fails
  here first, naming the collection.
- householdFromV2Item reads a stored household back into the app's
  model, with the stored id, people in the builder's order, and a
  record's id as its name when it has no v1 name.

Tests first: the codec tests failed to import the missing module. The
fixtures' documents were checked against the Public API's own
translation: for the US single parent, the UK single parent and the US
couple with two children, translate_legacy_household on the same v1
households gives the same canonical content hash and default year.

Nothing uses it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store behind v3's saved households, with accounts on, signed in or
signed out as the browser's unauthenticated user:

- save: POST /v2/households (which returns the existing household for
  identical content), then POST /v2/user-households with the label and
  the v2 household_id;
- list, most recently updated first; a row naming only a v1 household,
  or whose household fails to load, is listed as unavailable rather
  than failing the list;
- open, rename (PATCH of the name) and delete (the row only: stored
  households are shared);
- saveChanges: edited content is stored, and one PATCH points the saved
  household at it (household_id) together with any new label, so the
  saved household keeps its id, name and creation date. Identical
  content changes no row. If the PATCH fails, the reader is told the
  changes were not saved; the saved household is as it was.

Pointing a saved household at another stored household needs the
Public API's PATCH /v2/user-households to accept household_id; until it
does, nothing reaches this code.

Tests first: the store's tests (15) failed to import the missing module,
then pass against Public API responses routed by method and path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useSavedHouseholds lists the session's saved households (signed in, or
signed out as the browser's unauthenticated user), useSavedHousehold
opens one, and save, rename, delete and saveChanges are mutations that
read the list and every opened saved household again once they
succeed. Cached saved households are keyed by whose they are, as v3's
other saved work is. Without accounts nothing is listed or asked for,
and saving fails plainly ("Saved households need an account.") rather
than saving in the browser.

Tests first: the hook tests (6) failed to import the missing module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SaveHouseholdForm, for the household in the builder: a "Household
label" field and a Save button. A new household is saved under the
label. For a saved household, "Save changes" saves only what changed:
edited content is stored and the saved household pointed at it, with
any new label in the same request, so the saved household keeps its id;
a new label alone only renames it. A household counts as edited only
when its content differs as POST /v2/households takes it
(haveSameStoredContent in the codec), since a household read back from
the Public API lists its groups in name order and would otherwise read
as changed. A failed save says why; without accounts the form says
saved households are not available, with nothing to press.

Tests first: the form's tests (9) failed to import the missing
component, and the comparison's tests (4) failed with
haveSameStoredContent not a function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SavedHouseholdPicker lists the session's saved households in a country,
each by its label (or "Untitled household") with who it describes, in
the year its inputs are in, and hands the chosen one on with its
household. The saved household the builder holds is marked as chosen.
One that cannot open (its row names only a v1 household, or its
household failed to load) is listed but cannot be chosen. While loading,
on a failed load, with none saved, and without accounts, it says so.

savedHouseholdDisplay holds what the save form, this picker and the
saved-household list share: the name, the description, the notice shown
instead of a list, and the "not available" text the save form now
imports.

Tests first: the picker's tests (8) failed to import the missing
component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SavedHouseholdsPage ("Your households") lists the session's saved
households, each by its name with who it describes, to rename or
delete:

- Rename opens the shared rename dialog; the new label is saved with a
  PATCH of the name. A failed rename keeps the dialog open with the
  reason.
- Delete asks first, then deletes the saved row only (the stored
  household stays). A failed delete names the household and the reason
  above the list, and the household stays listed.
- A saved household that cannot open is listed and can still be renamed
  or deleted.

Without accounts it says saved households are not available. No route
renders the page yet.

Tests first: the page's tests (8) failed to import the missing page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue #1095 asks for code that goes from a household result straight to
a pandas DataFrame for charts. getHouseholdReproducibilityCode takes its
options as named fields ({ earningVariation, includeDataFrame }) rather
than positional booleans, and includeDataFrame appends:

- for a point calculation, one row per household result (net income,
  market income, tax, benefits);
- for an earnings variation, one row per earnings level, with the
  household's employment income (summed to the household) beside each
  result, ready to chart.

The four variables exist in the US and UK models. Generated blocks were
run locally: for a UK household under a personal allowance reform
against policyengine-uk 2.130.1, and for a US household under current
law against policyengine-us 2.2.1, the point block prints four rows and
the variation block 401.

HouseholdReproducibility offers the switch ("Include a pandas
DataFrame") only when offerDataFrame is passed. Nothing passes it yet,
so the existing household report's Reproduce in Python is unchanged.

Tests first: the code tests failed (no DataFrame code; the options
object did not type-check) and the component's switch test failed (no
such switch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…old report

Issue #1096: "Varying your earnings" never offers tip income, even for a
household with tips, because the variable list leaves out every input
variable, and tip_income is an input.

- EarningsVariationSubPage takes alsoOfferInputs: inputs offered beside
  the calculated variables when the variation returns values for them.
  Other inputs stay out.
- householdAmountInputs lists the nonzero amounts a household itself sets
  (tip income, for one with tips), leaving out the earnings the chart
  varies and age, which every household carries.
- HouseholdBreakdownSource takes householdInputs, which reach only the
  earnings variation.

Nothing passes them yet, so the existing household report and v3's
report section offer the same variables as before; the standalone
household report will pass the household's inputs. The SALT cap, the
issue's other example, is calculated, so the list already offers it.

Tests first: householdReport.test.ts failed to import the missing
module; the variation page offered no tip income when asked; the
breakdowns did not pass the household's inputs on; the new props did
not type-check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e household report

HouseholdBuilderPanel is the household builder as v3 configures it:
inputs grouped by topic with the country's featured inputs first, a
variable search that lists only the household's own state's programs,
only inputs that describe a household and no twin of a field the
builder already shows, the country's names for people (a UK household's
children), labelled inputs, and the SPM geography choice the builder
shows for a US household. The report section's household card now
renders it, passing its suggestions and analytics callbacks, so the
standalone household report can use the same configuration instead of
a copy that could drift.

The section behaves as before: its tests, which render a stand-in for
the builder and check what it is given, pass unchanged.

Tests first: the panel's tests (5) failed to import the missing
component; the filter test also failed until the test's model
information listed employment income as a basic field, showing the
twin rule is checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…households

HouseholdNetIncomeResult shows a household's result when there is no
reform to compare: its annual net income in whole dollars or pounds,
then the v2 breakdowns under that one policy (the net income tree
first, varying earnings and marginal tax rates), with the calculation's
SPM methodology under the net income tree and not under the variation
views, which show their own.

householdResultParts holds what it shares with HouseholdImpactResult:
the money formatting, the card style and the output tabs. The impact
result now uses them and behaves as before (its tests and the section's
analytics tests pass unchanged).

Nothing renders the new result yet; the standalone household report
will, for a household with no reform.

Tests first: the result's tests (4) failed to import the missing
component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HouseholdReportPage reports one household under a baseline and,
optionally, a reform:

- the net income headline, calculated once: the change in net income
  with a reform (HouseholdImpactResult), or net income alone for a
  baseline-only household, including one whose reform is the baseline
  (HouseholdNetIncomeResult);
- the net income tree, varying earnings and marginal tax rates, with the
  amounts the household sets, such as tip income, offered in varying
  earnings (#1096);
- the household's inputs, the policy summary, and Reproduce in Python
  with the pandas DataFrame option (#1095); each Python block is given
  its own policy by id (so a policy that fails to load is never given to
  the other block) and pinned to the .py bundle its own calculation
  reported;
- a year the reader chooses from the model's tax years (#498): the
  household's inputs move to it with the report builder's
  copyHouseholdToYear, a household or saved household from another year
  opens in the report's year, and a year change clears the result;
- the household builder as v3 configures it (HouseholdBuilderPanel; a
  new US household starts on national poverty thresholds), the
  saved-household picker and the save form, whose confirmation or error
  stays on screen after a save.

Calculate is disabled while the household on screen is the one
calculated; after a failure, "Try again" asks for the same household
once more. The result keeps describing the household calculated after
the builder changes. It calculates through the Public API's v1
household routes, as the report section does.

newV3Household, the report section's new household (with a UK nation's
place), moves to libs/flagship/householdReport so both use it; the
section behaves as before.

Tests first: the page's tests (23) failed to import the missing page.
Two then failed for their own setup (one rendered nothing; one stored a
different household from the one sent) and were fixed in the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oves to v2

householdEntryPoints.ts gathers the standalone household report, the
saved households page, the saved-household picker and the save form,
and says what the change that moves v3 to the Public API's v2 routes
does to reach them: enable Build's "A household" option, render the
report for household reports saved through /v2/reports, and add a
saved households route. No runtime flag or environment variable.

householdEntryPoints.test.tsx checks that:

- Build's "A household" option is listed but cannot be chosen;
- no file in the app or the calculator imports a household-only module
  (the report, the saved households page and list, the picker, the save
  form and their shared parts, the hooks, the store and the v2 codec),
  by an @/ specifier or a relative path, except those modules
  themselves;
- the entry points are the four above.

Tests first: the tests failed to import the missing module. The import
check was confirmed to fail, naming Report.page.tsx, when that page
imported the saved households page by a relative path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review found that the household report's "Try again" asked for the
household the builder held whenever it differed from the one that
failed, without checking that it could run, so a household the
Calculate button refused (for example county poverty thresholds without
a county) could reach the Public API. The report section already tried
the failed household again in that case.

The request handling the two shared by copy now lives in one hook,
useRequestedHouseholdRun: what was asked for, running, failed and the
result, asking again for the same household, whether the builder's
household is the one shown, and retry, which asks for the household as
it is now only when it can run, and otherwise for the household that
failed. The report section and the household report both use it; the
section's analytics and behavior are unchanged.

Tests first: a report test that fails a calculation, edits the
household into one that cannot run and presses "Try again" failed (the
edited household, with tip income, was sent); it passes now, and the
section's tests pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… screen

Review found that after a save the form kept saying "Saved" when the
household was then edited (or moved to another year), next to an
enabled "Save changes"; only a label edit cleared it. The form now
remembers the household and label its last save stored, and shows
"Saved" only while the builder's household has the same stored content
and the label is unchanged. A failed save clears it.

Tests first: a test that saves, then gives the form an edited household,
failed ("Saved" was still shown); it passes now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he report opens one

Review found two uneven year rules:

- householdFromV2Item took a stored household's year from the latest
  year its values name, not the stored default_year. A household with
  inputs in two years read back in the later one, and one whose values
  name no year (which the v2 document allows) read back with no year,
  showing "No year set" and always counting as changed. It now reads
  back in default_year.
- The household report opens a household that cannot move into the
  report's year (inputs in several years) in its own year, with the
  reason, but the saved-household picker refused to open one at all.
  The picker's choice now opens the same way.

Tests first: the codec's two cases failed (2027 and null instead of
2026), and a report test that opens a two-year saved household from the
picker in a later report year failed (the builder kept its household);
they pass now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review found the saved-household queries were keyed only by whose they
are, while the store lists one country's saved households, unlike v3's
other saved work, which keys by user and country. In a query client
shared across countries, the same user's list for one country was shown
for the other. savedHouseholdKeys now include the store's country, and
changes invalidate that country's queries.

Tests first: a hook test that lists a user's US households, then
switches the same user to the UK with the same query client, failed
(the US list was shown); it passes now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review found that every read of the saved-household list (on load, and
again after each save, rename or delete) sent a GET /v2/households/{id}
for every saved row. Stored households never change, so the store now
keeps each one by its v2 id for its life: a household it has just saved
or saved changes to is kept from the response, and a failed fetch is not
kept, so the next read asks again.

Tests first: listing twice and opening the saved household sent three
household reads (now one), and listing right after a save sent one (now
none); a household that failed to load is asked for again on the next
list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…holds

Review found that the saved-household delete dialog copied the saved
reform delete dialog's markup and classes. DeleteConfirmationDialog now
holds them: a Delete button that asks first, naming what is deleted and
what the delete does, with a compact form for a row of a list.
DeleteReformDialog keeps its text (including what happens to the
reform's reports) and now renders it; the saved-household list uses it
directly, and its copied dialog is removed.

The saved reforms page behaves as before: its tests pass unchanged.

Tests first: the shared dialog's tests (4) failed to import the missing
component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… test data in fixtures

Review found hard-coded values in the new components and test data
written into the report page's test:

- HouseholdNetIncomeResult's heading gap and headline radius now use
  spacing tokens; the save form's label field and the saved households
  page take their widths from Tailwind utilities.
- The report page test's policy responses, reform parameter, validation
  answers and model years move to a fixtures module.

No behavior changes, so there was no failing test to write first; the
affected tests pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ned changes nothing

Review found a race: opening a saved household from the picker while a
save was still running gave the save form a new key, but the earlier
form's save still reported its result when it finished, so the page
pointed the builder's saved household back at the row that save made.
The picker then marked that row as chosen, and "Save changes" would
have sent the opened household's content to that other saved row.

SaveHouseholdForm now reports nothing for a save that finishes after
the form has gone. The save itself still completes, and the saved
households list is read again as after any save.

Tests first: a report test that starts saving a new household, opens a
saved one before the save finishes, then lets it finish, failed (the
picker marked the new row and "Save changes" was enabled); it passes
now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review found that while a household calculated, the report disabled
the builder and the year, but the saved-household picker still opened
another household, which replaced the builder's household and threw the
running calculation away without notice. SavedHouseholdPicker takes
disabled, and the report passes it while a calculation runs.

Tests first: a picker test with disabled and a report test that opens
nothing while calculating failed (the options could be chosen); they
pass now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review found that the cache of stored households added earlier lived in
each store instance, and each hook call makes its own store, so the
save form, the picker and the list each kept their own: a list read
after a save in the same page still fetched the household just saved.

ApiSavedHouseholdStore now takes a StoredHouseholdCache. The hooks give
every store the session's query client, keyed by country and v2 id
(stored households are the same for every user and never change); a
store used on its own keeps them for its life, as before. A household
just saved or saved to is kept from the response, and a failed fetch is
not kept.

Tests first: a hook test that saves through one hook and lists through
another failed (the list fetched the saved household); it passes now,
and the store's own cache tests pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re not saved

Review found that saveChanges said "Your changes were not saved: …"
only when pointing the saved household at the edit failed; when storing
the edited content failed first, the reader saw the Public API's raw
message, although in both cases nothing changed. Both now raise
SavedHouseholdChangesNotSavedError.

Tests first: a store test whose POST /v2/households fails during
saveChanges failed (a raw V2ApiError); it passes now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th households

Review found that the household report copied the report section's
Calculate button with its validation message, the run's progress (with
a second expected-duration constant) and the run's failure panel, and
that the baseline-only result copied the impact result's headline
styles.

householdRunParts now holds HouseholdCalculateRow, HouseholdRunProgress,
HouseholdRunFailure and the expected run duration, and
householdResultParts the headline's caption and figure styles. The
report section, the impact result, the household report and the
baseline-only result use them, with the same text and styles as before.

No behavior changes, so there was no failing test to write first; the
section's, the results' and the report's tests pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rough unknown

Review found that the codec built and read documents as plain
Record<string, unknown> objects and converted them with
"as unknown as", so the country-specific document types never checked
its output. The collections table now names V2GroupCollection and
V1GroupCollection keys (derived from the document and v1 types), the
codec builds and reads groups as typed collections, and the only
remaining assertions are for values passed through as stored.

The codec's tests (exact documents for US and UK households, matching
the Public API's translation, and round trips) pass unchanged. A test
that a stored household without a default year reads back in the year
its values name is added; it passed at once, as the household model
already falls back to that year.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is not an edit

Review noted that a saved household opened in a report of another year
counted as edited at once: the report moves it into its year, so its
stored content differed, "Save changes" was enabled on opening, and a
save of a new label alone also stored the moved household and pointed
the saved household at it.

The form now compares the builder's household with the saved household
moved into the builder's year (when it can move). Opening it is not an
edit, a new label alone renames it, and an edit after opening is saved
as before, in the report's year.

Tests first: a report test that opens a 2026 saved household in a 2027
report failed ("Save changes" was enabled); it now also checks that a
new label sends only a PATCH of the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review noted that only a caller passing the save's result back as
`saved` turned "Save household" into "Save changes": with a caller that
did not, the button stayed enabled and a second press saved the
household again as another saved household. The form now keeps the
saved household its last successful save made or changed and edits
that one, whether or not the caller passes it back; a saved household
the caller passes still comes first.

Tests first: a form test with no onSaved that saves a new household
failed (no "Save changes"); it passes now, with only the one save sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pply

Review noted that the picker's options set their border color inline,
which overrode the hover border class, and that the household output
tabs (shared with the report section) did the same with their text
color, so neither hover color ever showed. The colors that change on
hover now come from classes (the same tokens), and only the chosen
option or tab sets its color without a hover.

A style-only change, so there was no failing test to write first; the
picker's, the results' and the section's tests pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s folder, one card style

Review noted three smaller points:

- The saved-household hooks, the picker and the household report took
  their country as a plain string (or restated 'us' | 'uk'), while the
  store and wire types use AssociationCountryId; they now use it, and
  the list's cast to ListedSavedHousehold[] is removed.
- The codec's fixture moves to the fixtures folder that mirrors the
  codec's own (models/household).
- The report's sections reuse the household result card style instead
  of repeating its border, radius, background and padding.

No behavior changes, so there was no failing test to write first; the
affected tests pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
policyengine-calculator-next Ready Ready Preview Oct 22, 2026 2:49pm UTC
policyengine-website Ready Ready Preview Oct 22, 2026 2:49pm UTC

Request Review

This branch was successfully deployed

2 active deployments
Preview – policyengine-calculator-next — fd9138ab Deployed Oct 22, 2026 by vercel[bot]
Preview – policyengine-website — fd9138ab Deployed Oct 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v3: standalone household report and saved households, unreachable until v3 moves to the v2 routes

1 participant