Skip to content

v3 standalone household report and saved households on /v2/households, unreachable until v3 moves to v2 - #1339

Closed
anth-volk wants to merge 17 commits into
apiv2/1d-1-v3-uk-us-householdsfrom
apiv2/1d-2-v3-household-report-saved-households
Closed

anth-volk wants to merge 17 commits into
apiv2/1d-1-v3-uk-us-householdsfrom
apiv2/1d-2-v3-household-report-saved-households

Conversation

@anth-volk

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

Copy link
Copy Markdown
Collaborator

Fixes #1337

Stacked on #1338.

What changes

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 move onto the document, and no label is sent. So a household v3 saves deduplicates with the dual-write copy of the same v1 household. It reads a stored household back into the app's model.

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), delete (the row only; households are shared);
  • edit: a new v2 household, and the saved household pointed at it, through replaceHouseholdBaseForAssociation, which now takes the saved-household type and an optional createHouseholdId (without it, it still creates a v1 household, so the legacy builder is unchanged).

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

Saved household interface (components/flagship/household/): a "Household label" field with Save (a new household, an edit as a new household pointed to by the saved one, or a rename alone; a household counts as edited only when its content differs as POST /v2/households takes it, haveSameStoredContent in the codec, as a stored household reads back with its groups in name order); a saved-household picker; 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), calculated once;
  • the net income breakdown tree, varying earnings and marginal tax rates (HouseholdBreakdowns);
  • the household's inputs (HouseholdSubPage), the policy summary (PolicySubPage) and Reproduce in Python (HouseholdReproducibility), each block 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;
  • 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);
  • Reproduce in Python offers a pandas DataFrame (Add dataframe-generating code to Reproduce in Python segment #1095; HouseholdReproducibility's new offerDataFrame, passed only by this report). Generated for a UK household and run against policyengine-uk 2.130.1, the point block prints a four-row table and the earnings variation block a 401-row one;
  • the household builder as v3 configures it, with the US SPM geography choice, the saved-household picker and the save form.

It calculates through the Public API's v1 household routes, as the report section does. Plan item 3B-2 moves it to /v2/household-calculations.

The legacy household report, the report section and the legacy builder behave as before: the new options default off, and only the new report passes them.

Unreachable until v3 moves to v2

No runtime flag or environment variable. Everything that works only on v2 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 modules, by an @/ specifier or a relative path, except those modules themselves (confirmed to fail when Report.page imports one either way). The household-report branch of the report page stays covered by ReportLoadStates.test.tsx.

What the switch PR (plan item 4A-1) changes to reach them:

  1. Build's population picker (components/flagship/reform/PopulationPicker.tsx) lists "A household" as a disabled option marked "Soon". 4A-1 enables it: 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. The report page (pages/flagship/Report.page.tsx) shows ReportNotSocietyWide ("This report cannot open here") in its householdReport branch. 4A-1 renders HouseholdReportPage there for household reports saved through /v2/reports and /v2/user-reports (3B-2).
  3. Saved households have no route. 4A-1 adds 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.

Needs a decision: the Public API cannot repoint a saved household

PATCH /v2/user-households/{id} accepts only legacy_household_id, name and description (UserHouseholdUpdateInput, which forbids other fields). It cannot point a saved household at another v2 household. So an edit here replaces the row: a new row with the same name points at the new household, then the earlier row is deleted. The saved household's id changes (nothing in v3 refers to it), identical content changes nothing, and if deleting the earlier row fails the reader is told both are now saved. If the Public API's PATCH accepts household_id, the store's repoint becomes one PATCH that keeps the id; that change belongs in the Public API, not this PR.

Tests

New or changed, all passing:

  • v2DocumentCodec.test.ts (8): US and UK households match the Public API's translation exactly; the label is never sent; a person outside a collection fails, naming it; a stored household reads back unchanged, with its id and year; ids name people and groups stored without v1 names.
  • savedHouseholdStore.test.ts (13): save, list (order, unavailable rows), open, rename, delete; an edit stores a new household and repoints, keeping the name; identical content changes no row; failures after storing the household or after the new row say what was kept.
  • replaceHouseholdBaseForAssociation.test.ts (+1): a given createHouseholdId is used instead of the v1 route.
  • useSavedHouseholds.test.tsx (5), SaveHouseholdForm.test.tsx (7, including a household read back from the Public API comparing equal to the builder's), SavedHouseholdPicker.test.tsx (5), SavedHouseholds.page.test.tsx (4): the hooks and the interface through the real store against routed Public API responses, including no accounts.
  • reproducibilityCode.test.ts (+3) and HouseholdReproducibility.test.tsx (+2): the DataFrame code for a point calculation and an earnings variation; none unless asked for; the switch only with offerDataFrame.
  • EarningsVariationInputs.test.tsx (new, 2), householdReport.test.ts (new, 1), HouseholdBreakdownsSPM.test.tsx (+2): the household's own amounts are offered only when passed.
  • HouseholdReport.page.test.tsx (new, 19): a reform's change and a baseline-only household's net income; baseline-only breakdowns; the inputs, policy and Reproduce in Python sections, pinned to each calculation's bundle, each block with its own policy (by id, so a policy that fails to load is never given to the other block); trying again after a failed calculation; a year change moves the inputs before the run, and a household or saved household from another year opens in the report's year; the result keeps describing the household calculated after the builder changes; tip income offered in varying earnings; the SPM choice; a UK household in pounds; opening a saved household, the report opened from one, saving a new household ("Saved", then nothing left to save), an edit whose earlier row cannot be deleted (the reader is told both are saved), and no accounts.
  • householdEntryPoints.test.tsx (new, 3): see above.

Full app suite on this branch (rebased onto main with #1334 and #1335, with the review fixes): 5,418 passed, 10 skipped; the same two ScorecardComparisons.test.tsx tests fail locally here and on main (currency formatting), unrelated; they pass in CI. The PR checks workflow runs only for PRs into main, so it runs here once #1338 merges and this PR's base becomes main. tsc, ESLint and Prettier are clean; the calculator type check and build pass.

Overlaps and order

  • Merges after v3 household section: US and UK households with SPM geography, calculation provenance and UK input suggestions #1338.
  • Group 1E (v3 saved reforms on /v2/policies) also adds v2 client code to this repository. The household wire types here are in their own types/v2Households.ts and use the existing V2UserClient and AccountAssociationRoutes, so the overlap is limited; if 1E introduces a generated TypeScript client, these types can move to it.
  • Group 1F (removing Canada, Nigeria and Israel) may change the country lists; the household document codec accepts only us and uk through isAssociationCountry.

🤖 Generated with Claude Code

@anth-volk
anth-volk added this pull request to stack #1340 October 19, 2026 22:12
@anth-volk anth-volk changed the title apiv2/1d 2 v3 household report saved households v3 standalone household report and saved households on /v2/households, unreachable until v3 moves to v2 Oct 19, 2026
@anth-volk
anth-volk force-pushed the apiv2/1d-2-v3-household-report-saved-households branch from ccb586e to 7d03427 Compare October 19, 2026 22:15
@vercel

vercel Bot commented Oct 19, 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 20, 2026 1:25pm UTC
policyengine-website Ready Ready Preview Oct 20, 2026 1:25pm UTC

Request Review

anth-volk and others added 8 commits October 20, 2026 17:08
…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, including the
  PATCH body, which has no household_id.
- 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.

Nothing uses it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A store for v3's saved households in the account (signed in, or signed
out as the browser's unauthenticated user):

- save stores the household's content with POST /v2/households (which
  returns the existing household for identical content), then saves a
  /v2/user-households row with the user's label and the v2
  household_id. A label is trimmed and limited to 255 characters; an
  empty one saves no name.
- list reads the rows and each one's household, most recently updated
  first. A row that names only a v1 household, or whose household fails
  to load, is listed as unavailable instead of failing the list.
- get opens one with its household; rename patches only the name;
  remove deletes the row and leaves the shared household.
- replaceHousehold saves an edit through
  replaceHouseholdBaseForAssociation: a new v2 household, and the saved
  household pointed at it. The Public API's PATCH has no household_id,
  so pointing a row at another v2 household replaces the row: a new row
  with the same name, then the earlier row is deleted (its id changes;
  nothing refers to it). Identical content changes no row. If the new
  row fails, the error names the stored household; if deleting the
  earlier row fails, the error says both are now saved.

replaceHouseholdBaseForAssociation takes the saved-household type as a
type parameter and an optional createHouseholdId; without it, it creates
a v1 household as before, so the legacy builder is unchanged.

Nothing uses the store yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useSavedHouseholds, useSavedHousehold and the save, rename, delete and
replace mutations read and change the session's saved households
through the saved-household store, with queries keyed by whose they are
(the session scope useV3SavedWork gives), so a session that ends moves
to new keys. Each change reads the list again; an edit reads it even
when it fails, as a failure after the edit was saved can leave two.

Without accounts the hooks say saved households are unavailable, ask
for nothing, and a save fails with that message: v3 keeps no household
in the browser.

Nothing uses the hooks yet.

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

The v3 interface for saved households, on the saved-household hooks:

- SaveHouseholdForm: a "Household label" field and a Save button for
  the household in the builder. A new household is saved under its
  label. For a saved household, an edit is saved as a new household
  with the saved household pointed at it, and a new label alone only
  renames it; with nothing changed there is nothing to save. A failure
  shows the Public API's message; if an edit was saved but the earlier
  version could not be removed, it says both are saved and goes on.
- SavedHouseholdPicker: the session's saved households in the page's
  country, each by its label with who it describes ("Married · 2
  children · California"), handed on with its household when chosen. A
  saved household that cannot open is listed but cannot be chosen.
- SavedHouseholdsList and SavedHouseholds.page ("Your households"):
  each saved household with Rename (the shared rename dialog) and Delete
  (which asks first).

Without accounts each says saved households are not available here.
No route renders any of them yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue #1095 asks for code that goes from a household result straight to
a DataFrame for charts. getHouseholdReproducibilityCode takes
includeDataFrame, which 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 (mapped to the household) beside each
  result: ready to chart.

The four variables exist in both the US and UK models. Generated for a
UK household and run against policyengine-uk 2.130.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 legacy household report's Reproduce in Python is unchanged; the
v3 household report will pass it.

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. 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 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 legacy 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 a calculated variable the list already offers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HouseholdReport.page: one household, run under a baseline (current law
unless given) and, optionally, a reform, through API v1's household
routes as the report section runs them, until v3's household
calculations move to v2.

- The net income headline: the change in annual net income with a
  reform (the report section's HouseholdImpactResult), or, for a
  baseline-only household, its annual net income
  (HouseholdNetIncomeResult), with the calculation run once.
- The v2 breakdowns as tabs: the net income tree, varying earnings and
  marginal tax rates, under the baseline alone when there is no reform.
  Varying earnings also offers the amounts the household sets, such as
  tip income (issue #1096).
- The household's inputs (HouseholdSubPage), the policies
  (PolicySubPage) and Reproduce in Python (HouseholdReproducibility, with
  the pandas DataFrame option of issue #1095), each block pinned to the
  .py bundle its own calculation reported.
- A year chosen from the model's tax years (issue #498): the household's
  inputs move to it with the report builder's copyHouseholdToYear, and a
  result waits for Calculate in the new year.
- The household builder as v3 configures it (HouseholdBuilderPanel),
  with the US SPM geography choice; the saved-household picker, which
  loads a saved household into the builder; and the label field and
  save, which saves the household or an edit of the saved one.

No route renders it yet.

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

pages/flagship/householdEntryPoints.ts gathers what only works on API
v2 (the standalone household report, the saved households page, the
saved-household picker and the save form) and says exactly what the
switch to v2 (plan item 4A-1) changes to reach them:

1. Build's population picker lists "A household" as disabled ("Soon");
   4A-1 enables it, offering a saved or new household, and Build's run
   opens the household report.
2. A report whose simulations are households shows "This report cannot
   open here"; 4A-1 renders the household report there for household
   reports saved through /v2/reports (plan item 3B-2).
3. Saved households have no route; 4A-1 adds the workspace route
   /[countryId]/households and its link.

No runtime flag or environment variable is involved. The tests check
that "A household" cannot be chosen, and that no file in the app or the
calculator imports the household modules except those modules
themselves (confirmed to fail when Report.page imports one). The
household-report branch of the report page stays covered by
ReportLoadStates.test.tsx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the apiv2/1d-2-v3-household-report-saved-households branch from cc310c2 to 9fdd682 Compare October 20, 2026 13:09
After a failed calculation, "Try again" (and Calculate) sent the same
household again as a new request with the same payload. That is the
same query, which keeps its error, so nothing ran and the error stayed.
The page now asks that query again when the household is unchanged
after a failure, as the report section's retry does.

Test first: HouseholdReport.page.test.tsx fails the first calculation,
presses "Try again" and expects the result. It failed (no second
calculation, no result) before the change and passes after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 6 commits October 20, 2026 17:12
The page keyed the save form by the saved household's id, so the form
started again whenever a save returned one: a new household's save, and
every edit (an edit's saved household has a new id). Its "Saved"
confirmation, and its message that the earlier version could not be
removed so both are now saved, vanished at once. The form now starts
again only when the picker opens a saved household.

Test first: HouseholdReport.page.test.tsx saves a new household and
expects "Saved", and saves an edit whose earlier row cannot be deleted
and expects the "both are saved now" alert. Both failed before the
change and pass after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The form decided whether a saved household had changed by comparing the
two households' v1 data as JSON, which depends on the order of their
groups. A household read back from /v2/households lists its groups in
name order, so a US family with children (several marital units) read
as changed straight after saving it, and "Save changes" stayed enabled
with nothing to save. Households are now compared as POST /v2/households
takes them, with object keys sorted (haveSameStoredContent in the v2
document codec): the content the Public API deduplicates on.

Found while testing the save confirmation. Test first:
SaveHouseholdForm.test.tsx gives the form the builder's family and the
same family as stored and expects nothing to save, and
HouseholdReport.page.test.tsx expects "Save changes" disabled after a
new household is saved. Both failed before the change and pass after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page took its opening household (a household or a saved household)
as given, while choosing a year or a saved household moves the household
into the report's year. A household from another year was then sent with
its own year's inputs while the report ran in its year, so its results
could not be read for the report's year. The opening household now moves
into the year the page opens in; one that cannot move (inputs in several
years) opens in its own year, with the reason shown.

Test first: HouseholdReport.page.test.tsx opens a 2026 household, and a
2026 saved household, in a 2027 report and expects 2027 inputs. Both
sent 2026 inputs before the change and pass after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The result's summary ("Married · 2 children · 2026") was built from the
household in the builder, so editing the household after a result
relabelled that result with a household that had not been calculated.
The inputs and Reproduce in Python sections already read the household
sent; the summary now does too, as the report section's does.

Test first: HouseholdReport.page.test.tsx calculates a married couple,
then makes the builder's household single, and expects the result still
to read "Married · 2 children". It read "Single" before the change and
passes after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reproduce in Python took each block's policy by its position in the
loaded policies. When one policy failed to load, the list was shorter:
the baseline's block was given the reform's policy, and the reform's
block none. Each block now takes its policy by id; a block whose policy
did not load says it cannot generate its code.

Test first: HouseholdReport.page.test.tsx loads a UK household's reform
policy, with a parameter change, while the baseline's fails, and expects
the baseline block to carry no reform parameter. It carried the reform's
parameter before the change and passes after it. A companion test, with
both policies loaded, checks each block holds its own policy.

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

The check that nothing routed imports the household modules looked only
for their "@/…" specifiers. A relative import, such as
"./HouseholdReport.page" from Report.page.tsx in the same directory,
passed unnoticed. It now reads every import, re-export and require in
the app and calculator sources and resolves relative paths from the
importing file before comparing.

Test-only change, checked both ways: with a temporary
`import './HouseholdReport.page'` at the top of Report.page.tsx the old
check passed and the new one fails, naming Report.page.tsx; a temporary
dynamic import of '@/pages/flagship/householdEntryPoints' also fails it.
On the branch as committed it passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 2 commits October 20, 2026 17:21
…lation

The page passed householdAmountInputs(requested.household) to the
breakdowns inline, a new list on every render. The earnings variation
keys its variable options on that list, so it rebuilt them from every
model variable whenever the page rendered, such as on each edit in the
builder. The list is now memoized on the calculated household.

Render cost only, so no test could fail first; the earnings variation
test that offers tip income (HouseholdReport.page.test.tsx) passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comments on the standalone household report and the household entry
points said "API v1's household routes" and "API v2". They now say the
Public API's v1 household routes and the Public API's v2 routes.

Wording only, so no test could fail first; householdEntryPoints.test.tsx
and HouseholdReport.page.test.tsx pass.

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

This branch was successfully deployed

2 active deployments
Preview – policyengine-calculator-next — 425a17aa Deployed Oct 20, 2026 by vercel[bot]
Preview – policyengine-website — 425a17aa Deployed Oct 20, 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 on /v2/households (unreachable until v3 moves to v2)

1 participant