Repository navigation
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
Conversation
anth-volk
force-pushed
the
apiv2/1d-2-v3-household-report-saved-households
branch
from
October 19, 2026 22:15
ccb586e to
7d03427
Compare
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
anth-volk
force-pushed
the
apiv2/1d-2-v3-household-report-saved-households
branch
from
October 19, 2026 22:38
7d03427 to
cc310c2
Compare
…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
force-pushed
the
apiv2/1d-2-v3-household-report-saved-households
branch
from
October 20, 2026 13:09
cc310c2 to
9fdd682
Compare
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>
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>
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/householdsdocument exactly as the Public API translates a v1 household (translate_legacy_household): people and groups take ids in name order, v1 names are kept assource_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:POST /v2/households(which returns the existing household for identical content), thenPOST /v2/user-householdswith the label and the v2household_id;PATCHof the name), delete (the row only; households are shared);replaceHouseholdBaseForAssociation, which now takes the saved-household type and an optionalcreateHouseholdId(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 asPOST /v2/householdstakes it,haveSameStoredContentin the codec, as a stored household reads back with its groups in name order); a saved-household picker; andSavedHouseholds.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:HouseholdImpactResult), or net income alone for a baseline-only household (HouseholdNetIncomeResult), calculated once;HouseholdBreakdowns);HouseholdSubPage), the policy summary (PolicySubPage) and Reproduce in Python (HouseholdReproducibility), each block pinned to the .py bundle its own calculation reported;copyHouseholdToYear;EarningsVariationSubPage's newalsoOfferInputs, passed only by this report);HouseholdReproducibility's newofferDataFrame, 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;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.tsxchecks 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 whenReport.pageimports one either way). The household-report branch of the report page stays covered byReportLoadStates.test.tsx.What the switch PR (plan item 4A-1) changes to reach them:
components/flagship/reform/PopulationPicker.tsx) lists "A household" as a disabled option marked "Soon". 4A-1 enables it: choosing it offersSavedHouseholdPickeror a new household, and Build's run opensHouseholdReportPagewith that household, the draft reform, and the run bar's year and baseline.pages/flagship/Report.page.tsx) showsReportNotSocietyWide("This report cannot open here") in itshouseholdReportbranch. 4A-1 rendersHouseholdReportPagethere for household reports saved through/v2/reportsand/v2/user-reports(3B-2).calculator-app/src/app/[countryId]/(workspace)/households/page.tsxrenderingSavedHouseholdsPage, a "households" screen inworkspaceRoutes.tsandlibs/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 onlylegacy_household_id,nameanddescription(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 acceptshousehold_id, the store'srepointbecomes 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 givencreateHouseholdIdis 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) andHouseholdReproducibility.test.tsx(+2): the DataFrame code for a point calculation and an earnings variation; none unless asked for; the switch only withofferDataFrame.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
mainwith #1334 and #1335, with the review fixes): 5,418 passed, 10 skipped; the same twoScorecardComparisons.test.tsxtests fail locally here and onmain(currency formatting), unrelated; they pass in CI. The PR checks workflow runs only for PRs intomain, so it runs here once #1338 merges and this PR's base becomesmain.tsc, ESLint and Prettier are clean; the calculator type check and build pass.Overlaps and order
/v2/policies) also adds v2 client code to this repository. The household wire types here are in their owntypes/v2Households.tsand use the existingV2UserClientandAccountAssociationRoutes, so the overlap is limited; if 1E introduces a generated TypeScript client, these types can move to it.usandukthroughisAssociationCountry.🤖 Generated with Claude Code