From b8340c003c96cc6b807dd39ddb3107be1aa9a9c2 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:14:55 +0400 Subject: [PATCH 01/29] v2 household documents: write and read households as the Public API stores 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 --- app/src/models/household/v2DocumentCodec.ts | 200 ++++++++++++++++++ .../fixtures/models/v2DocumentCodecMocks.ts | 152 +++++++++++++ .../models/household/v2DocumentCodec.test.ts | 86 ++++++++ app/src/types/v2Households.ts | 98 +++++++++ 4 files changed, 536 insertions(+) create mode 100644 app/src/models/household/v2DocumentCodec.ts create mode 100644 app/src/tests/fixtures/models/v2DocumentCodecMocks.ts create mode 100644 app/src/tests/unit/models/household/v2DocumentCodec.test.ts create mode 100644 app/src/types/v2Households.ts diff --git a/app/src/models/household/v2DocumentCodec.ts b/app/src/models/household/v2DocumentCodec.ts new file mode 100644 index 000000000..f915e2619 --- /dev/null +++ b/app/src/models/household/v2DocumentCodec.ts @@ -0,0 +1,200 @@ +import type { CountryId } from '@/libs/countries'; +import { Household } from '@/models/Household'; +import type { + V1HouseholdData, + V1HouseholdGroupData, + V1HouseholdPersonData, +} from '@/models/household/v1Types'; +import type { SPMSelection } from '@/types/spm'; +import type { + V2HouseholdCreateBody, + V2HouseholdDocument, + V2HouseholdEntityRecord, + V2HouseholdItem, + V2HouseholdPersonRecord, +} from '@/types/v2Households'; +import { isAssociationCountry } from '@/types/v2User'; + +/** + * The app's household model to and from the Public API's v2 household + * document (`/v2/households`). + * + * The document is built exactly as the Public API translates a v1 + * household (`translate_legacy_household`): people and each collection's + * groups take ids in name order ("person-1", "tax_unit-2"), each keeps its + * v1 name as `source_name`, each group's members become every person's + * `memberships`, and the SPM settings move onto the document. So a + * household v3 saves is the same stored household as the dual-write copy + * of the identical v1 household: the Public API deduplicates them. + */ + +/** Each country's v2 collections, beside the v1 collection each comes from. */ +const COLLECTIONS: Record<'us' | 'uk', ReadonlyArray<[v2Name: string, v1Name: string]>> = { + us: [ + ['household', 'households'], + ['family', 'families'], + ['tax_unit', 'tax_units'], + ['spm_unit', 'spm_units'], + ['marital_unit', 'marital_units'], + ], + uk: [ + ['household', 'households'], + ['benunit', 'benunits'], + ], +}; + +/** A group of a collection, as a person "is not in any …": "marital unit". */ +const COLLECTION_NOUNS: Record = { + household: 'household', + family: 'family', + tax_unit: 'tax unit', + spm_unit: 'SPM unit', + marital_unit: 'marital unit', + benunit: 'benefit unit', +}; + +/** A household that cannot be written as, or read from, a v2 document. */ +export class V2HouseholdDocumentError extends Error { + constructor(message: string) { + super(message); + this.name = 'V2HouseholdDocumentError'; + } +} + +function collectionsFor(countryId: string) { + if (!isAssociationCountry(countryId)) { + throw new V2HouseholdDocumentError(`v2 households are not available for ${countryId}.`); + } + return COLLECTIONS[countryId]; +} + +/** + * Ids in name order, as the Public API gives them (Python's `sorted`, + * which orders by code point, as JavaScript's default sort does). + */ +function stableIds(kind: string, names: string[]): Map { + return new Map([...names].sort().map((name, index) => [name, `${kind}-${index + 1}`])); +} + +/** A v1 household's data as a v2 document. */ +export function v2DocumentFromV1Data( + countryId: string, + data: V1HouseholdData, + spm?: SPMSelection +): V2HouseholdDocument { + const collections = collectionsFor(countryId); + const people = data.people ?? {}; + const personNames = Object.keys(people); + const personIds = stableIds('person', personNames); + const memberships = new Map>( + personNames.map((name) => [name, {}]) + ); + const document: Record = {}; + + for (const [collection, v1Name] of collections) { + const groups = + (data as unknown as Record>)[v1Name] ?? {}; + const groupIds = stableIds(collection, Object.keys(groups)); + document[collection] = [...groupIds.keys()].map((groupName): V2HouseholdEntityRecord => { + const { members, ...values } = groups[groupName]; + for (const member of members ?? []) { + const personMemberships = memberships.get(member); + if (!personMemberships) { + throw new V2HouseholdDocumentError(`${groupName} names ${member}, who is not a person.`); + } + const previous = personMemberships[collection]; + if (previous && previous !== groupIds.get(groupName)) { + throw new V2HouseholdDocumentError( + `${member} is in more than one ${COLLECTION_NOUNS[collection]}.` + ); + } + personMemberships[collection] = groupIds.get(groupName)!; + } + return { id: groupIds.get(groupName)!, source_name: groupName, values }; + }); + } + + document.people = [...personIds.keys()].map((name): V2HouseholdPersonRecord => { + const personMemberships = memberships.get(name)!; + for (const [collection] of collections) { + if (!personMemberships[collection]) { + throw new V2HouseholdDocumentError( + `${name} is not in any ${COLLECTION_NOUNS[collection]}.` + ); + } + } + return { + id: personIds.get(name)!, + source_name: name, + values: { ...people[name] }, + memberships: personMemberships, + }; + }); + if (countryId === 'us' && spm) { + document.spm = spm; + } + return document as unknown as V2HouseholdDocument; +} + +/** A v2 document as a v1 household's data, named by each record's v1 name (else its id). */ +export function v1DataFromV2Document( + countryId: string, + document: V2HouseholdDocument +): { data: V1HouseholdData; spm?: SPMSelection } { + const collections = collectionsFor(countryId); + const records = document as unknown as Record; + const nameOf = (record: V2HouseholdEntityRecord) => record.source_name ?? record.id; + // People in the household builder's order ("you", "your partner", then + // dependants), not the document's name order. + const personByName = new Map(document.people.map((person) => [nameOf(person), person])); + const people = Household.sortPersonNames([...personByName.keys()]).map( + (name) => personByName.get(name)! + ); + const data: Record = { + people: Object.fromEntries( + people.map((person) => [nameOf(person), { ...person.values }]) + ) as Record, + }; + for (const [collection, v1Name] of collections) { + const groups = records[collection] ?? []; + data[v1Name] = Object.fromEntries( + groups.map((group) => [ + nameOf(group), + { + members: people + .filter((person) => person.memberships[collection] === group.id) + .map(nameOf), + ...group.values, + }, + ]) + ); + } + const spm = 'spm' in document ? document.spm : undefined; + return { data: data as unknown as V1HouseholdData, ...(spm ? { spm } : {}) }; +} + +/** What `POST /v2/households` takes for a household: its content, in its year, without its label. */ +export function householdToV2CreateBody(household: Household): V2HouseholdCreateBody { + const countryId = household.countryId; + collectionsFor(countryId); + const payload = household.toV1CreationPayload(); + const year = household.year; + if (year === null || !Number.isInteger(year)) { + throw new V2HouseholdDocumentError('The household has no year to save it in.'); + } + return { + country_id: countryId as V2HouseholdCreateBody['country_id'], + default_year: year, + household_data: v2DocumentFromV1Data(countryId, payload.data, payload.spm), + }; +} + +/** A stored v2 household as the app's household model, with the stored id. */ +export function householdFromV2Item(item: V2HouseholdItem): Household { + const { data, spm } = v1DataFromV2Document(item.country_id, item.household_data); + const household = Household.fromV1CreationPayload( + { country_id: item.country_id as CountryId, data, ...(spm ? { spm } : {}) }, + { id: item.id } + ); + return household; +} diff --git a/app/src/tests/fixtures/models/v2DocumentCodecMocks.ts b/app/src/tests/fixtures/models/v2DocumentCodecMocks.ts new file mode 100644 index 000000000..b20001ece --- /dev/null +++ b/app/src/tests/fixtures/models/v2DocumentCodecMocks.ts @@ -0,0 +1,152 @@ +import { Household } from '@/models/Household'; +import type { + V2HouseholdCreateBody, + V2HouseholdDocument, + V2HouseholdItem, +} from '@/types/v2Households'; + +export const V2_CODEC_YEAR = '2026'; +export const STORED_V2_HOUSEHOLD_ID = '5d2c1a7e-3f40-4b8a-9c1d-2e6f7a8b9c0d'; +const STORED_AT = '2026-10-20T09:00:00Z'; + +/** A single US parent with one child in California, on national SPM thresholds. */ +export const US_SINGLE_PARENT = Household.starter('us', V2_CODEC_YEAR) + .withBuilderChildCount(V2_CODEC_YEAR, 1) + .setGroupVariableAtYear('households', 'your household', 'state_name', V2_CODEC_YEAR, 'CA') + .withSPM({ geography_kind: 'national' }); + +/** + * The same household as the Public API translates its v1 form + * (translate_legacy_household): people and groups take ids in name order, + * each keeps its v1 name as source_name, group members become each + * person's memberships, and the SPM settings move onto the document. + */ +export const US_SINGLE_PARENT_V2_BODY: V2HouseholdCreateBody = { + country_id: 'us', + default_year: 2026, + household_data: { + people: [ + { + id: 'person-1', + source_name: 'you', + values: { age: { 2026: 30 }, employment_income: { 2026: 0 } }, + memberships: { + household: 'household-1', + family: 'family-1', + tax_unit: 'tax_unit-1', + spm_unit: 'spm_unit-1', + marital_unit: 'marital_unit-2', + }, + }, + { + id: 'person-2', + source_name: 'your first dependent', + values: { + age: { 2026: 10 }, + is_tax_unit_dependent: { 2026: true }, + employment_income: { 2026: 0 }, + }, + memberships: { + household: 'household-1', + family: 'family-1', + tax_unit: 'tax_unit-1', + spm_unit: 'spm_unit-1', + marital_unit: 'marital_unit-1', + }, + }, + ], + household: [ + { id: 'household-1', source_name: 'your household', values: { state_name: { 2026: 'CA' } } }, + ], + family: [{ id: 'family-1', source_name: 'your family', values: {} }], + tax_unit: [{ id: 'tax_unit-1', source_name: 'your tax unit', values: {} }], + spm_unit: [{ id: 'spm_unit-1', source_name: 'your household', values: {} }], + marital_unit: [ + { id: 'marital_unit-1', source_name: "your first dependent's marital unit", values: {} }, + { id: 'marital_unit-2', source_name: 'your marital unit', values: {} }, + ], + spm: { geography_kind: 'national' }, + }, +}; + +/** A UK parent with one child in the North East. */ +export const UK_SINGLE_PARENT = Household.starter('uk', V2_CODEC_YEAR) + .withBuilderChildCount(V2_CODEC_YEAR, 1) + .setGroupVariableAtYear('households', 'your household', 'region', V2_CODEC_YEAR, 'NORTH_EAST'); + +export const UK_SINGLE_PARENT_V2_BODY: V2HouseholdCreateBody = { + country_id: 'uk', + default_year: 2026, + household_data: { + people: [ + { + id: 'person-1', + source_name: 'you', + values: { age: { 2026: 30 }, employment_income: { 2026: 0 } }, + memberships: { household: 'household-1', benunit: 'benunit-1' }, + }, + { + id: 'person-2', + source_name: 'your first dependent', + values: { age: { 2026: 10 }, employment_income: { 2026: 0 } }, + memberships: { household: 'household-1', benunit: 'benunit-1' }, + }, + ], + household: [ + { + id: 'household-1', + source_name: 'your household', + values: { region: { 2026: 'NORTH_EAST' } }, + }, + ], + benunit: [{ id: 'benunit-1', source_name: 'your benefit unit', values: {} }], + }, +}; + +/** A married US couple with two children: four people, three marital units. */ +export const US_COUPLE_WITH_TWO_CHILDREN = Household.starter('us', V2_CODEC_YEAR) + .withBuilderMaritalStatus(V2_CODEC_YEAR, 'married') + .withBuilderChildCount(V2_CODEC_YEAR, 2) + .withSPM({ geography_kind: 'county' }) + .setGroupVariableAtYear('households', 'your household', 'county_fips', V2_CODEC_YEAR, '06037'); + +/** A stored v2 household as the Public API returns it. */ +export function storedV2Household( + countryId: V2HouseholdItem['country_id'], + householdData: V2HouseholdDocument +): V2HouseholdItem { + return { + id: STORED_V2_HOUSEHOLD_ID, + country_id: countryId, + default_year: 2026, + household_data: householdData, + created_at: STORED_AT, + updated_at: STORED_AT, + }; +} + +/** A UK household stored without v1 names: its ids name its people and groups. */ +export const UK_DOCUMENT_WITHOUT_SOURCE_NAMES: V2HouseholdDocument = { + people: [ + { + id: 'adult', + values: { age: { 2026: 45 } }, + memberships: { household: 'home', benunit: 'family' }, + }, + ], + household: [{ id: 'home', values: {} }], + benunit: [{ id: 'family', values: {} }], +}; + +/** A US household where the child is in no marital unit: v2 needs every membership. */ +export const US_PERSON_WITHOUT_MARITAL_UNIT = Household.fromV1CreationPayload({ + country_id: 'us', + data: { + people: { you: { age: { 2026: 30 } }, child: { age: { 2026: 5 } } }, + households: { home: { members: ['you', 'child'] } }, + families: { family: { members: ['you', 'child'] } }, + tax_units: { 'tax unit': { members: ['you', 'child'] } }, + spm_units: { 'spm unit': { members: ['you', 'child'] } }, + marital_units: { 'marital unit': { members: ['you'] } }, + }, +}); diff --git a/app/src/tests/unit/models/household/v2DocumentCodec.test.ts b/app/src/tests/unit/models/household/v2DocumentCodec.test.ts new file mode 100644 index 000000000..e57a14de3 --- /dev/null +++ b/app/src/tests/unit/models/household/v2DocumentCodec.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'vitest'; +import { + householdFromV2Item, + householdToV2CreateBody, + V2HouseholdDocumentError, +} from '@/models/household/v2DocumentCodec'; +import { + STORED_V2_HOUSEHOLD_ID, + storedV2Household, + UK_DOCUMENT_WITHOUT_SOURCE_NAMES, + UK_SINGLE_PARENT, + UK_SINGLE_PARENT_V2_BODY, + US_COUPLE_WITH_TWO_CHILDREN, + US_PERSON_WITHOUT_MARITAL_UNIT, + US_SINGLE_PARENT, + US_SINGLE_PARENT_V2_BODY, +} from '@/tests/fixtures/models/v2DocumentCodecMocks'; + +describe('householdToV2CreateBody', () => { + test('given a US household then it is the document the Public API translates its v1 form into', () => { + expect(householdToV2CreateBody(US_SINGLE_PARENT)).toEqual(US_SINGLE_PARENT_V2_BODY); + }); + + test('given a UK household then it has people, households and benefit units only', () => { + expect(householdToV2CreateBody(UK_SINGLE_PARENT)).toEqual(UK_SINGLE_PARENT_V2_BODY); + }); + + test('given a labelled household then the label is not sent: v2 households have no presentation fields', () => { + const body = householdToV2CreateBody(US_SINGLE_PARENT.withLabel('Our family')); + + expect(JSON.stringify(body)).not.toContain('Our family'); + }); + + test('given a person outside one of the collections then it fails, naming the collection', () => { + expect(() => householdToV2CreateBody(US_PERSON_WITHOUT_MARITAL_UNIT)).toThrow( + V2HouseholdDocumentError + ); + expect(() => householdToV2CreateBody(US_PERSON_WITHOUT_MARITAL_UNIT)).toThrow( + 'child is not in any marital unit.' + ); + }); +}); + +describe('householdFromV2Item', () => { + test('given a stored US household then it reads back as the household that was saved', () => { + const stored = storedV2Household('us', US_SINGLE_PARENT_V2_BODY.household_data); + + const household = householdFromV2Item(stored); + + expect(household.toV1CreationPayload()).toEqual(US_SINGLE_PARENT.toV1CreationPayload()); + expect(household.spm).toEqual({ geography_kind: 'national' }); + }); + + test('given a household saved and read back then nothing is lost', () => { + for (const original of [US_COUPLE_WITH_TWO_CHILDREN, UK_SINGLE_PARENT]) { + const body = householdToV2CreateBody(original); + + const household = householdFromV2Item( + storedV2Household(body.country_id, body.household_data) + ); + + expect(household.toV1CreationPayload()).toEqual(original.toV1CreationPayload()); + } + }); + + test('given a stored household then it carries the stored id and year', () => { + const household = householdFromV2Item( + storedV2Household('uk', UK_SINGLE_PARENT_V2_BODY.household_data) + ); + + expect(household.id).toBe(STORED_V2_HOUSEHOLD_ID); + expect(household.countryId).toBe('uk'); + expect(household.year).toBe(2026); + }); + + test('given a household stored without v1 names then its ids name its people and groups', () => { + const household = householdFromV2Item( + storedV2Household('uk', UK_DOCUMENT_WITHOUT_SOURCE_NAMES) + ); + + const data = household.toV1CreationPayload().data; + expect(Object.keys(data.people)).toEqual(['adult']); + expect(data.households).toEqual({ home: { members: ['adult'] } }); + expect(data.benunits).toEqual({ family: { members: ['adult'] } }); + }); +}); diff --git a/app/src/types/v2Households.ts b/app/src/types/v2Households.ts new file mode 100644 index 000000000..a5fc3ee8f --- /dev/null +++ b/app/src/types/v2Households.ts @@ -0,0 +1,98 @@ +import type { SPMSelection } from '@/types/spm'; +import type { AssociationCountryId } from '@/types/v2User'; + +/** + * Wire types for the Public API's v2 household routes: immutable, + * deduplicated household content (`/v2/households`) and the user's saved + * households that point at it (`/v2/user-households`). Field names are + * snake_case, as on the wire. + */ + +/** One group in a household (a tax unit, a benefit unit…): its id and its values. */ +export interface V2HouseholdEntityRecord { + /** Unique within its collection; people's memberships refer to it. */ + id: string; + /** The group's name in the v1 household it came from ("your tax unit"). */ + source_name?: string | null; + /** Variable name → value, each keyed by period as in a v1 household. */ + values: Record; +} + +/** One person: their values, and the group they belong to in every collection. */ +export interface V2HouseholdPersonRecord extends V2HouseholdEntityRecord { + /** Collection ("tax_unit") → the id of the person's group in it; names every collection. */ + memberships: Record; +} + +export interface V2USHouseholdDocument { + people: V2HouseholdPersonRecord[]; + household: V2HouseholdEntityRecord[]; + family: V2HouseholdEntityRecord[]; + tax_unit: V2HouseholdEntityRecord[]; + spm_unit: V2HouseholdEntityRecord[]; + marital_unit: V2HouseholdEntityRecord[]; + /** The SPM settings; left out (never null) when none are chosen. */ + spm?: SPMSelection; +} + +export interface V2UKHouseholdDocument { + people: V2HouseholdPersonRecord[]; + household: V2HouseholdEntityRecord[]; + benunit: V2HouseholdEntityRecord[]; +} + +export type V2HouseholdDocument = V2USHouseholdDocument | V2UKHouseholdDocument; + +/** `POST /v2/households`: strict on the server, with no label or other presentation field. */ +export interface V2HouseholdCreateBody { + country_id: AssociationCountryId; + /** The year that values without an explicit period are read in (1900–2200). */ + default_year: number; + household_data: V2HouseholdDocument; +} + +/** A stored household. Creating identical content returns the existing one, with its id. */ +export interface V2HouseholdItem { + id: string; + country_id: AssociationCountryId; + default_year: number | null; + household_data: V2HouseholdDocument; + created_at: string; + updated_at: string; +} + +/** A household the user saved: their name for it, and the household it points at. */ +export interface UserHouseholdItem { + id: string; + country_id: AssociationCountryId; + user_id: string; + /** The v2 household; null for a row that only names a v1 household. */ + household_id: string | null; + legacy_household_id: number | null; + name: string | null; + description: string | null; + created_at: string; + updated_at: string; +} + +/** `POST /v2/user-households`: a v2 household_id, a v1 legacy_household_id, or both. */ +export interface UserHouseholdCreateBody { + country_id: AssociationCountryId; + household_id?: string | null; + legacy_household_id?: number | null; + name?: string | null; + description?: string | null; +} + +/** + * `PATCH /v2/user-households/{id}`: only the supplied fields change. + * `household_id` points the saved household at another stored household, + * keeping its id, name and creation date; the Public API checks that the new + * household is in the saved household's country. + */ +export interface UserHouseholdPatchBody { + household_id?: string; + legacy_household_id?: number; + name?: string | null; + description?: string | null; +} From f901c02364592170428ea15d22a1e4b6559d77ef Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:17:05 +0400 Subject: [PATCH 02/29] Saved households on /v2/households and /v2/user-households 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 --- app/src/api/savedHouseholdStore.ts | 260 ++++++++++++++ .../tests/fixtures/api/savedHouseholdMocks.ts | 130 +++++++ .../unit/api/savedHouseholdStore.test.ts | 336 ++++++++++++++++++ 3 files changed, 726 insertions(+) create mode 100644 app/src/api/savedHouseholdStore.ts create mode 100644 app/src/tests/fixtures/api/savedHouseholdMocks.ts create mode 100644 app/src/tests/unit/api/savedHouseholdStore.test.ts diff --git a/app/src/api/savedHouseholdStore.ts b/app/src/api/savedHouseholdStore.ts new file mode 100644 index 000000000..5c6709906 --- /dev/null +++ b/app/src/api/savedHouseholdStore.ts @@ -0,0 +1,260 @@ +import { + AccountAssociationRoutes, + BrowserOnlyIdError, + isAccountId, + type ApiStoreContext, +} from '@/api/v2Associations'; +import { clampStoreText } from '@/libs/flagship/storeText'; +import type { Household } from '@/models/Household'; +import { householdFromV2Item, householdToV2CreateBody } from '@/models/household/v2DocumentCodec'; +import type { + UserHouseholdCreateBody, + UserHouseholdItem, + UserHouseholdPatchBody, + V2HouseholdItem, +} from '@/types/v2Households'; +import type { AssociationCountryId, V2ItemResult } from '@/types/v2User'; + +/** + * v3's saved households, in the account (signed in, or signed out as this + * browser's unauthenticated user). A household's content is stored once + * on the Public API's `/v2/households`, which never changes a household + * and returns the existing one for identical content; the user's saved + * household is a `/v2/user-households` row holding their name for it and + * the household's v2 id. An edit stores the new content and points the + * saved household at it, so the saved household keeps its id. Without + * accounts there are no saved households. + */ + +/** A saved household that opens: the user's row and the household it points at. */ +export interface SavedHousehold { + /** The saved-household row (an account UUID). */ + id: string; + countryId: AssociationCountryId; + /** The user's name for it; null when saved without one. */ + label: string | null; + /** The stored v2 household. */ + householdId: string; + household: Household; + createdAt: string; + updatedAt: string; + unavailable?: false; +} + +/** + * A saved household that is listed but cannot open: its row names no v2 + * household (only a v1 one), or its household failed to load. + */ +export interface UnavailableSavedHousehold { + id: string; + countryId: AssociationCountryId; + label: string | null; + householdId: string | null; + createdAt: string; + updatedAt: string; + unavailable: true; +} + +export type ListedSavedHousehold = SavedHousehold | UnavailableSavedHousehold; + +/** A saved row after a rename: the row's own fields, without its household. */ +export type SavedHouseholdRow = Omit; + +/** What changed in a saved household: its content, its label, or both. */ +export interface SavedHouseholdChanges { + /** The edited household; left out when its content is unchanged. */ + household?: Household; + /** The new label; left out when it is unchanged. */ + label?: string; +} + +export interface SavedHouseholdStore { + /** This session's saved households, most recently updated first. */ + list: () => Promise; + /** A saved household with its household; null when it does not exist (or is someone else's). */ + get: (savedHouseholdId: string) => Promise; + save: (args: { household: Household; label: string }) => Promise; + rename: (savedHouseholdId: string, label: string) => Promise; + /** Deletes the saved row; the stored household stays, as households are shared. */ + remove: (savedHouseholdId: string) => Promise; + /** + * Saves changes to a saved household, keeping its id: edited content is + * stored and the saved household pointed at it, in one request with any + * new label. + */ + saveChanges: (saved: SavedHousehold, changes: SavedHouseholdChanges) => Promise; +} + +/** A saved row that names no v2 household cannot be opened in v3. */ +export class SavedHouseholdUnavailableError extends Error { + constructor() { + super('This saved household cannot be opened here: it was saved before households moved.'); + this.name = 'SavedHouseholdUnavailableError'; + } +} + +/** + * The saved household could not be changed: it still holds what it held + * before. Edited content may already be stored, which changes nothing, as + * a stored household belongs to no one until a saved household points at it. + */ +export class SavedHouseholdChangesNotSavedError extends Error { + constructor(cause: unknown) { + super( + `Your changes were not saved: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause } + ); + this.name = 'SavedHouseholdChangesNotSavedError'; + } +} + +/** Most recently updated first. */ +function byRecentUpdate(items: T[]): T[] { + return [...items].sort((first, second) => + (second.updatedAt || second.createdAt).localeCompare(first.updatedAt || first.createdAt) + ); +} + +function rowOf(item: UserHouseholdItem): SavedHouseholdRow { + return { + id: item.id, + countryId: item.country_id, + label: item.name, + householdId: item.household_id, + createdAt: item.created_at, + updatedAt: item.updated_at, + }; +} + +function savedHouseholdOf( + item: UserHouseholdItem, + householdId: string, + household: Household +): SavedHousehold { + return { ...rowOf(item), householdId, household }; +} + +/** The user's name for a household, as the Public API takes it: at most 255 characters, else none. */ +function nameFor(label: string): string | null { + return clampStoreText(label.trim()) || null; +} + +export class ApiSavedHouseholdStore implements SavedHouseholdStore { + private readonly routes: AccountAssociationRoutes; + + constructor(private readonly context: ApiStoreContext) { + this.routes = new AccountAssociationRoutes(context, 'user-households', 'Saved household'); + } + + async list(): Promise { + const rows = await this.routes.list(); + // A household that fails to load is listed as unavailable, rather than failing the list. + const loaded = await Promise.allSettled( + rows.map((row) => + row.household_id + ? this.fetchHousehold(row.country_id, row.household_id) + : Promise.reject(new SavedHouseholdUnavailableError()) + ) + ); + return byRecentUpdate( + rows.map((row, index): ListedSavedHousehold => { + const outcome = loaded[index]; + return outcome.status === 'fulfilled' && row.household_id + ? savedHouseholdOf(row, row.household_id, outcome.value) + : { ...rowOf(row), unavailable: true }; + }) + ); + } + + async get(savedHouseholdId: string): Promise { + const row = await this.routes.get(savedHouseholdId); + if (!row) { + return null; + } + if (!row.household_id) { + throw new SavedHouseholdUnavailableError(); + } + return savedHouseholdOf( + row, + row.household_id, + await this.fetchHousehold(row.country_id, row.household_id) + ); + } + + async save({ household, label }: { household: Household; label: string }) { + const stored = await this.storeHousehold(household); + const row = await this.routes.create({ + country_id: stored.country_id, + household_id: stored.id, + name: nameFor(label), + }); + return savedHouseholdOf(row, stored.id, householdFromV2Item(stored)); + } + + async rename(savedHouseholdId: string, label: string): Promise { + const body: UserHouseholdPatchBody = { name: nameFor(label) }; + return rowOf(await this.routes.patch(savedHouseholdId, body)); + } + + remove(savedHouseholdId: string): Promise { + return this.routes.delete(savedHouseholdId); + } + + async saveChanges( + saved: SavedHousehold, + { household, label }: SavedHouseholdChanges + ): Promise { + // Checked before anything is stored: a household saved only in this + // browser has no saved row to point at the edit. + if (!isAccountId(saved.id)) { + throw new BrowserOnlyIdError(); + } + const stored = household ? await this.storeHousehold(household) : null; + const body: UserHouseholdPatchBody = {}; + // Identical content is the stored household the saved one already points at. + if (stored && stored.id !== saved.householdId) { + body.household_id = stored.id; + } + if (label !== undefined && nameFor(label) !== saved.label) { + body.name = nameFor(label); + } + const nextHousehold = stored ? householdFromV2Item(stored) : saved.household; + if (Object.keys(body).length === 0) { + return { ...saved, household: nextHousehold }; + } + let row: UserHouseholdItem; + try { + row = await this.routes.patch(saved.id, body); + } catch (error) { + throw new SavedHouseholdChangesNotSavedError(error); + } + return savedHouseholdOf(row, row.household_id ?? saved.householdId, nextHousehold); + } + + /** Stores a household's content (`POST /v2/households`), or finds the identical stored one. */ + private async storeHousehold(household: Household): Promise { + const body = householdToV2CreateBody(household); + const result = await this.context.client.request>( + 'POST', + 'households', + { query: { country_id: body.country_id }, body } + ); + return result.item; + } + + private async fetchHousehold(countryId: string, householdId: string): Promise { + const result = await this.context.client.request>( + 'GET', + `households/${encodeURIComponent(householdId)}`, + { query: { country_id: countryId } } + ); + return householdFromV2Item(result.item); + } +} + +/** The saved-household store for a session: the account's; none without accounts. */ +export function getSavedHouseholdStore( + account: ApiStoreContext | null | undefined +): SavedHouseholdStore | null { + return account ? new ApiSavedHouseholdStore(account) : null; +} diff --git a/app/src/tests/fixtures/api/savedHouseholdMocks.ts b/app/src/tests/fixtures/api/savedHouseholdMocks.ts new file mode 100644 index 000000000..7badbf364 --- /dev/null +++ b/app/src/tests/fixtures/api/savedHouseholdMocks.ts @@ -0,0 +1,130 @@ +import type { vi } from 'vitest'; +import { Household } from '@/models/Household'; +import { householdFromV2Item, householdToV2CreateBody } from '@/models/household/v2DocumentCodec'; +import { PE_USER_ID, TIMESTAMPS } from '@/tests/fixtures/api/v2UserMocks'; +import type { UserHouseholdItem, V2HouseholdItem } from '@/types/v2Households'; + +export const SAVED_HOUSEHOLD_YEAR = '2026'; + +/** The user's saved-household rows. */ +export const SAVED_HOUSEHOLD_IDS = { + FAMILY: '22222222-2222-4222-8222-222222222222', + RETIRED: '66666666-6666-4666-8666-666666666666', + V1_ONLY: '77777777-7777-4777-8777-777777777777', +} as const; + +/** Stored v2 households. */ +export const V2_HOUSEHOLD_IDS = { + FAMILY: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + EDITED_FAMILY: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + RETIRED: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', +} as const; + +export const FAMILY_LABEL = 'Our family'; +export const RETIRED_LABEL = 'Retired couple'; + +/** A couple with two children, as the household builder makes it. */ +export const FAMILY_HOUSEHOLD = Household.starter('us', SAVED_HOUSEHOLD_YEAR) + .withBuilderMaritalStatus(SAVED_HOUSEHOLD_YEAR, 'married') + .withBuilderChildCount(SAVED_HOUSEHOLD_YEAR, 2) + .withSPM({ geography_kind: 'national' }); + +/** The same family, after the reader raises "you"'s earnings. */ +export const EDITED_FAMILY_HOUSEHOLD = FAMILY_HOUSEHOLD.setPersonVariableAtYear( + 'you', + 'employment_income', + SAVED_HOUSEHOLD_YEAR, + 65000 +); + +export const RETIRED_HOUSEHOLD = Household.starter('us', SAVED_HOUSEHOLD_YEAR) + .withBuilderMaritalStatus(SAVED_HOUSEHOLD_YEAR, 'married') + .setPersonVariableAtYear('you', 'age', SAVED_HOUSEHOLD_YEAR, 70) + .withSPM({ geography_kind: 'national' }); + +/** A household as `/v2/households` returns it. */ +export function v2HouseholdItem(household: Household, id: string): V2HouseholdItem { + const body = householdToV2CreateBody(household); + return { + id, + country_id: body.country_id, + default_year: body.default_year, + household_data: body.household_data, + created_at: TIMESTAMPS.CREATED, + updated_at: TIMESTAMPS.CREATED, + }; +} + +/** A saved household as `/v2/user-households` returns it. */ +export function userHouseholdItem(overrides: Partial = {}): UserHouseholdItem { + return { + id: SAVED_HOUSEHOLD_IDS.FAMILY, + country_id: 'us', + user_id: PE_USER_ID, + household_id: V2_HOUSEHOLD_IDS.FAMILY, + legacy_household_id: null, + name: FAMILY_LABEL, + description: null, + created_at: TIMESTAMPS.CREATED, + updated_at: TIMESTAMPS.UPDATED, + ...overrides, + }; +} + +/** One request the store sent, as a handler sees it. */ +export interface RoutedRequest { + method: string; + path: string; + query: Record; + body: any; +} + +type RouteHandler = (request: RoutedRequest) => Response | Promise; + +/** + * Answers the store's requests by method and path, as the Public API + * would, and keeps each request in order. A request no route expects + * fails the test. + */ +export function routeRequests( + fetchMock: ReturnType, + routes: Record +): RoutedRequest[] { + const requests: RoutedRequest[] = []; + fetchMock.mockImplementation(async (url: string, init: RequestInit) => { + const parsed = new URL(url); + const request: RoutedRequest = { + method: init.method ?? 'GET', + path: parsed.pathname, + query: Object.fromEntries(parsed.searchParams.entries()), + body: init.body ? JSON.parse(String(init.body)) : undefined, + }; + requests.push(request); + const handler = routes[`${request.method} ${request.path}`]; + if (!handler) { + throw new Error(`Unexpected request ${request.method} ${request.path}`); + } + return handler(request); + }); + return requests; +} + +/** + * The family as `/v2/households` returns it and the store reads it back: + * the same content, with its groups in the document's name order ("your + * first dependent's marital unit" before "your marital unit"). + */ +export const FAMILY_HOUSEHOLD_AS_STORED = householdFromV2Item( + v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY) +); + +/** The saved family as the store opens it. */ +export const SAVED_FAMILY = { + id: SAVED_HOUSEHOLD_IDS.FAMILY, + countryId: 'us' as const, + label: FAMILY_LABEL, + householdId: V2_HOUSEHOLD_IDS.FAMILY, + household: FAMILY_HOUSEHOLD.withId(V2_HOUSEHOLD_IDS.FAMILY), + createdAt: TIMESTAMPS.CREATED, + updatedAt: TIMESTAMPS.UPDATED, +}; diff --git a/app/src/tests/unit/api/savedHouseholdStore.test.ts b/app/src/tests/unit/api/savedHouseholdStore.test.ts new file mode 100644 index 000000000..fb2b1bb57 --- /dev/null +++ b/app/src/tests/unit/api/savedHouseholdStore.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, test } from 'vitest'; +import { + ApiSavedHouseholdStore, + getSavedHouseholdStore, + SavedHouseholdChangesNotSavedError, + SavedHouseholdUnavailableError, +} from '@/api/savedHouseholdStore'; +import { BrowserOnlyIdError } from '@/api/v2Associations'; +import { MAX_STORE_NAME_LENGTH } from '@/libs/flagship/storeText'; +import { householdToV2CreateBody } from '@/models/household/v2DocumentCodec'; +import { + EDITED_FAMILY_HOUSEHOLD, + FAMILY_HOUSEHOLD, + FAMILY_LABEL, + RETIRED_HOUSEHOLD, + RETIRED_LABEL, + routeRequests, + SAVED_HOUSEHOLD_IDS, + userHouseholdItem, + V2_HOUSEHOLD_IDS, + v2HouseholdItem, +} from '@/tests/fixtures/api/savedHouseholdMocks'; +import { + createTestApiContext, + errorResponse, + itemResponse, + noContentResponse, + pageResponse, +} from '@/tests/fixtures/api/v2UserMocks'; + +function savedHouseholdStore() { + const { fetchMock, context } = createTestApiContext('us'); + return { store: new ApiSavedHouseholdStore(context), fetchMock }; +} + +const familyRow = userHouseholdItem(); +const familyHouseholdItem = v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY); +const editedHouseholdItem = v2HouseholdItem( + EDITED_FAMILY_HOUSEHOLD, + V2_HOUSEHOLD_IDS.EDITED_FAMILY +); + +describe('ApiSavedHouseholdStore', () => { + test('given a household and a label then its content is stored, and the saved row points at it by its v2 id', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const requests = routeRequests(fetchMock, { + 'POST /v2/households': () => itemResponse(familyHouseholdItem, 201), + 'POST /v2/user-households': () => itemResponse(familyRow, 201), + }); + + const saved = await store.save({ household: FAMILY_HOUSEHOLD, label: FAMILY_LABEL }); + + expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([ + 'POST /v2/households', + 'POST /v2/user-households', + ]); + expect(requests[0].query).toEqual({ country_id: 'us' }); + expect(requests[0].body).toEqual(householdToV2CreateBody(FAMILY_HOUSEHOLD)); + expect(requests[1].body).toEqual({ + country_id: 'us', + household_id: V2_HOUSEHOLD_IDS.FAMILY, + name: FAMILY_LABEL, + }); + expect(saved).toMatchObject({ + id: SAVED_HOUSEHOLD_IDS.FAMILY, + label: FAMILY_LABEL, + householdId: V2_HOUSEHOLD_IDS.FAMILY, + }); + expect(saved.household.toV1CreationPayload()).toEqual(FAMILY_HOUSEHOLD.toV1CreationPayload()); + }); + + test('given a long or empty label then it is shortened to the limit, or saved without a name', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const requests = routeRequests(fetchMock, { + 'POST /v2/households': () => itemResponse(familyHouseholdItem, 201), + 'POST /v2/user-households': () => itemResponse(familyRow, 201), + }); + + await store.save({ household: FAMILY_HOUSEHOLD, label: 'A'.repeat(300) }); + await store.save({ household: FAMILY_HOUSEHOLD, label: ' ' }); + + expect(requests[1].body.name).toHaveLength(MAX_STORE_NAME_LENGTH); + expect(requests[3].body.name).toBeNull(); + }); + + test('given saved households then each is listed with its household, most recently updated first', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const retiredRow = userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.RETIRED, + household_id: V2_HOUSEHOLD_IDS.RETIRED, + name: RETIRED_LABEL, + updated_at: '2026-10-13T09:00:00+00:00', + }); + routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse([familyRow, retiredRow]), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHouseholdItem), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.RETIRED}`]: () => + itemResponse(v2HouseholdItem(RETIRED_HOUSEHOLD, V2_HOUSEHOLD_IDS.RETIRED)), + }); + + const listed = await store.list(); + + expect(listed.map((saved) => saved.label)).toEqual([RETIRED_LABEL, FAMILY_LABEL]); + expect(listed.every((saved) => !saved.unavailable)).toBe(true); + }); + + test('given a row without a v2 household, or one that fails to load, then it is listed as unavailable, not failing the list', async () => { + const { store, fetchMock } = savedHouseholdStore(); + routeRequests(fetchMock, { + 'GET /v2/user-households': () => + pageResponse([ + familyRow, + userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.V1_ONLY, + household_id: null, + legacy_household_id: 123, + }), + userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.RETIRED, + household_id: V2_HOUSEHOLD_IDS.RETIRED, + }), + ]), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHouseholdItem), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.RETIRED}`]: () => + errorResponse(503, 'V2 household persistence is unavailable'), + }); + + const listed = await store.list(); + + const unavailable = listed.filter((saved) => saved.unavailable).map((saved) => saved.id); + expect(unavailable.sort()).toEqual( + [SAVED_HOUSEHOLD_IDS.RETIRED, SAVED_HOUSEHOLD_IDS.V1_ONLY].sort() + ); + }); + + test('given a saved household then it opens with its household; an unknown one is null', async () => { + const { store, fetchMock } = savedHouseholdStore(); + routeRequests(fetchMock, { + [`GET /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyRow), + [`GET /v2/user-households/${SAVED_HOUSEHOLD_IDS.RETIRED}`]: () => + errorResponse(404, 'Not found'), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHouseholdItem), + }); + + const opened = await store.get(SAVED_HOUSEHOLD_IDS.FAMILY); + + expect(opened?.label).toBe(FAMILY_LABEL); + expect(opened?.household.toV1CreationPayload()).toEqual(FAMILY_HOUSEHOLD.toV1CreationPayload()); + expect(await store.get(SAVED_HOUSEHOLD_IDS.RETIRED)).toBeNull(); + }); + + test('given a row that names only a v1 household then opening it says so', async () => { + const { store, fetchMock } = savedHouseholdStore(); + routeRequests(fetchMock, { + [`GET /v2/user-households/${SAVED_HOUSEHOLD_IDS.V1_ONLY}`]: () => + itemResponse( + userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.V1_ONLY, + household_id: null, + legacy_household_id: 123, + }) + ), + }); + + await expect(store.get(SAVED_HOUSEHOLD_IDS.V1_ONLY)).rejects.toBeInstanceOf( + SavedHouseholdUnavailableError + ); + }); + + test('given a new label then only the name is patched', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const requests = routeRequests(fetchMock, { + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse({ ...familyRow, name: RETIRED_LABEL }), + }); + + await store.rename(SAVED_HOUSEHOLD_IDS.FAMILY, RETIRED_LABEL); + + expect(requests).toHaveLength(1); + expect(requests[0].body).toEqual({ name: RETIRED_LABEL }); + }); + + test('given delete then the saved row is deleted, and the stored household stays', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const requests = routeRequests(fetchMock, { + [`DELETE /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => noContentResponse(), + }); + + await store.remove(SAVED_HOUSEHOLD_IDS.FAMILY); + + expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([ + `DELETE /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`, + ]); + }); + + describe('given changes to a saved household', () => { + async function savedFamily(store: ApiSavedHouseholdStore, fetchMock: any) { + routeRequests(fetchMock, { + [`GET /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyRow), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHouseholdItem), + }); + return (await store.get(SAVED_HOUSEHOLD_IDS.FAMILY))!; + } + + test('given an edited household then it is stored, and the saved household keeps its id and points at it', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const saved = await savedFamily(store, fetchMock); + const requests = routeRequests(fetchMock, { + 'POST /v2/households': () => itemResponse(editedHouseholdItem, 201), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse({ ...familyRow, household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY }), + }); + + const updated = await store.saveChanges(saved, { household: EDITED_FAMILY_HOUSEHOLD }); + + expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([ + 'POST /v2/households', + `PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`, + ]); + expect(requests[0].body).toEqual(householdToV2CreateBody(EDITED_FAMILY_HOUSEHOLD)); + expect(requests[1].query).toEqual({ country_id: 'us' }); + expect(requests[1].body).toEqual({ household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY }); + expect(updated).toMatchObject({ + id: SAVED_HOUSEHOLD_IDS.FAMILY, + label: FAMILY_LABEL, + householdId: V2_HOUSEHOLD_IDS.EDITED_FAMILY, + }); + expect(updated.household.toV1CreationPayload()).toEqual( + EDITED_FAMILY_HOUSEHOLD.toV1CreationPayload() + ); + }); + + test('given an edited household and a new label then one request changes both', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const saved = await savedFamily(store, fetchMock); + const requests = routeRequests(fetchMock, { + 'POST /v2/households': () => itemResponse(editedHouseholdItem, 201), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse({ + ...familyRow, + household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY, + name: RETIRED_LABEL, + }), + }); + + const updated = await store.saveChanges(saved, { + household: EDITED_FAMILY_HOUSEHOLD, + label: RETIRED_LABEL, + }); + + expect(requests.filter((request) => request.method === 'PATCH')).toHaveLength(1); + expect(requests[1].body).toEqual({ + household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY, + name: RETIRED_LABEL, + }); + expect(updated.label).toBe(RETIRED_LABEL); + }); + + test('given a new label alone then only the name is patched, and no household is stored', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const saved = await savedFamily(store, fetchMock); + const requests = routeRequests(fetchMock, { + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse({ ...familyRow, name: RETIRED_LABEL }), + }); + + const updated = await store.saveChanges(saved, { label: RETIRED_LABEL }); + + expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([ + `PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`, + ]); + expect(requests[0].body).toEqual({ name: RETIRED_LABEL }); + expect(updated).toMatchObject({ label: RETIRED_LABEL, householdId: V2_HOUSEHOLD_IDS.FAMILY }); + }); + + test('given an edit back to the same content and the same label then no saved row changes', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const saved = await savedFamily(store, fetchMock); + const requests = routeRequests(fetchMock, { + // The Public API deduplicates: identical content is the stored household. + 'POST /v2/households': () => itemResponse(familyHouseholdItem), + }); + + const updated = await store.saveChanges(saved, { + household: FAMILY_HOUSEHOLD, + label: FAMILY_LABEL, + }); + + expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([ + 'POST /v2/households', + ]); + expect(updated).toMatchObject({ + id: SAVED_HOUSEHOLD_IDS.FAMILY, + householdId: V2_HOUSEHOLD_IDS.FAMILY, + updatedAt: saved.updatedAt, + }); + }); + + test('given the saved household cannot be pointed at the edit then the changes are not saved, and it says so', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const saved = await savedFamily(store, fetchMock); + routeRequests(fetchMock, { + 'POST /v2/households': () => itemResponse(editedHouseholdItem, 201), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + errorResponse(503, 'Saved work is temporarily unavailable.'), + }); + + const update = store.saveChanges(saved, { household: EDITED_FAMILY_HOUSEHOLD }); + + await expect(update).rejects.toBeInstanceOf(SavedHouseholdChangesNotSavedError); + await expect(update).rejects.toThrow( + 'Your changes were not saved: Saved work is temporarily unavailable.' + ); + }); + + test('given a household saved only in this browser then nothing is sent', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const saved = await savedFamily(store, fetchMock); + const requests = routeRequests(fetchMock, {}); + + await expect( + store.saveChanges({ ...saved, id: 'lfh-browser-only' }, { label: RETIRED_LABEL }) + ).rejects.toBeInstanceOf(BrowserOnlyIdError); + expect(requests).toEqual([]); + }); + }); +}); + +describe('getSavedHouseholdStore', () => { + test('given an account then saved households go to the Public API; without one there are none', () => { + expect(getSavedHouseholdStore(createTestApiContext().context)).toBeInstanceOf( + ApiSavedHouseholdStore + ); + expect(getSavedHouseholdStore(null)).toBeNull(); + }); +}); From 468ecf5fd602f971c09fddc78b9370c5403cb469 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:19:30 +0400 Subject: [PATCH 03/29] Saved households: query and mutation hooks for the session 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 --- app/src/hooks/useSavedHouseholds.ts | 124 +++++++++++ .../unit/hooks/useSavedHouseholds.test.tsx | 200 ++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 app/src/hooks/useSavedHouseholds.ts create mode 100644 app/src/tests/unit/hooks/useSavedHouseholds.test.tsx diff --git a/app/src/hooks/useSavedHouseholds.ts b/app/src/hooks/useSavedHouseholds.ts new file mode 100644 index 000000000..ccb497d57 --- /dev/null +++ b/app/src/hooks/useSavedHouseholds.ts @@ -0,0 +1,124 @@ +import { useMemo } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + getSavedHouseholdStore, + type ListedSavedHousehold, + type SavedHousehold, + type SavedHouseholdChanges, + type SavedHouseholdStore, +} from '@/api/savedHouseholdStore'; +import { useV3SavedWork } from '@/hooks/useFlagshipStores'; +import type { Household } from '@/models/Household'; + +/** + * v3's saved households for this session: in the account, signed in or + * signed out as this browser's unauthenticated user. Without accounts + * there are none, and nothing is saved anywhere else. + */ + +/** Cached saved households are scoped to whose they are (see V3SavedWork). */ +export const savedHouseholdKeys = { + all: (scope: string) => ['saved-households', scope] as const, + list: (scope: string) => ['saved-households', scope, 'list'] as const, + one: (scope: string, savedHouseholdId: string) => + ['saved-households', scope, 'one', savedHouseholdId] as const, +}; + +const STALE_TIME = 5 * 60 * 1000; + +/** Saving without accounts: v3 keeps no household in the browser. */ +export class SavedHouseholdsUnavailableError extends Error { + constructor() { + super('Saved households need an account.'); + this.name = 'SavedHouseholdsUnavailableError'; + } +} + +export function useSavedHouseholdStore(): { store: SavedHouseholdStore | null; scope: string } { + const { account, scope } = useV3SavedWork(); + return useMemo(() => ({ store: getSavedHouseholdStore(account), scope }), [account, scope]); +} + +function requireStore(store: SavedHouseholdStore | null): SavedHouseholdStore { + if (!store) { + throw new SavedHouseholdsUnavailableError(); + } + return store; +} + +/** This session's saved households, most recently updated first. */ +export function useSavedHouseholds() { + const { store, scope } = useSavedHouseholdStore(); + const query = useQuery({ + queryKey: savedHouseholdKeys.list(scope), + queryFn: () => requireStore(store).list(), + enabled: !!store, + staleTime: STALE_TIME, + }); + return { + /** Whether saved households exist here: only with accounts. */ + available: !!store, + savedHouseholds: (query.data ?? []) as ListedSavedHousehold[], + isLoading: !!store && query.isLoading, + error: query.error, + refetch: query.refetch, + }; +} + +/** One saved household with its household; null when it does not exist. */ +export function useSavedHousehold(savedHouseholdId: string | undefined) { + const { store, scope } = useSavedHouseholdStore(); + return useQuery({ + queryKey: savedHouseholdKeys.one(scope, savedHouseholdId ?? ''), + queryFn: () => requireStore(store).get(savedHouseholdId!), + enabled: !!store && !!savedHouseholdId, + staleTime: STALE_TIME, + }); +} + +/** Reads the session's saved households, the list and each one opened, again after a change. */ +function useInvalidateSavedHouseholds() { + const queryClient = useQueryClient(); + const { scope } = useSavedHouseholdStore(); + return () => queryClient.invalidateQueries({ queryKey: savedHouseholdKeys.all(scope) }); +} + +export function useSaveHousehold() { + const { store } = useSavedHouseholdStore(); + const invalidate = useInvalidateSavedHouseholds(); + return useMutation({ + mutationFn: ({ household, label }: { household: Household; label: string }) => + requireStore(store).save({ household, label }), + onSuccess: invalidate, + }); +} + +export function useRenameSavedHousehold() { + const { store } = useSavedHouseholdStore(); + const invalidate = useInvalidateSavedHouseholds(); + return useMutation({ + mutationFn: ({ savedHouseholdId, label }: { savedHouseholdId: string; label: string }) => + requireStore(store).rename(savedHouseholdId, label), + onSuccess: invalidate, + }); +} + +export function useDeleteSavedHousehold() { + const { store } = useSavedHouseholdStore(); + const invalidate = useInvalidateSavedHouseholds(); + return useMutation({ + mutationFn: (savedHouseholdId: string) => requireStore(store).remove(savedHouseholdId), + onSuccess: invalidate, + }); +} + +/** Saves changes to a saved household (its content, its label or both), keeping its id. */ +export function useSaveHouseholdChanges() { + const { store } = useSavedHouseholdStore(); + const invalidate = useInvalidateSavedHouseholds(); + return useMutation({ + mutationFn: ({ saved, changes }: { saved: SavedHousehold; changes: SavedHouseholdChanges }) => + requireStore(store).saveChanges(saved, changes), + onSuccess: invalidate, + }); +} diff --git a/app/src/tests/unit/hooks/useSavedHouseholds.test.tsx b/app/src/tests/unit/hooks/useSavedHouseholds.test.tsx new file mode 100644 index 000000000..404c39771 --- /dev/null +++ b/app/src/tests/unit/hooks/useSavedHouseholds.test.tsx @@ -0,0 +1,200 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { + useDeleteSavedHousehold, + useRenameSavedHousehold, + useSavedHousehold, + useSavedHouseholds, + useSaveHousehold, + useSaveHouseholdChanges, +} from '@/hooks/useSavedHouseholds'; +import { + EDITED_FAMILY_HOUSEHOLD, + FAMILY_HOUSEHOLD, + FAMILY_LABEL, + RETIRED_LABEL, + routeRequests, + SAVED_HOUSEHOLD_IDS, + userHouseholdItem, + V2_HOUSEHOLD_IDS, + v2HouseholdItem, +} from '@/tests/fixtures/api/savedHouseholdMocks'; +import { + createTestApiContext, + itemResponse, + noContentResponse, + pageResponse, +} from '@/tests/fixtures/api/v2UserMocks'; + +const mockSavedWork = vi.fn(); +vi.mock('@/hooks/useFlagshipStores', () => ({ + useV3SavedWork: () => mockSavedWork(), +})); + +const SESSION_SCOPE = 'account:auth0|test-user'; + +function renderWithQueries(hook: () => Result) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + return renderHook(hook, { wrapper }); +} + +/** A session with an account, whose requests the fetch mock answers. */ +function signedInSession() { + const { fetchMock, context } = createTestApiContext('us'); + mockSavedWork.mockReturnValue({ account: context, scope: SESSION_SCOPE }); + return fetchMock; +} + +const familyRow = userHouseholdItem(); +const familyHousehold = v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY); + +describe('useSavedHouseholds', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given no accounts then saved households are unavailable and nothing is asked for', () => { + mockSavedWork.mockReturnValue({ account: null, scope: 'browser' }); + + const { result } = renderWithQueries(() => useSavedHouseholds()); + + expect(result.current.available).toBe(false); + expect(result.current.savedHouseholds).toEqual([]); + expect(result.current.isLoading).toBe(false); + }); + + test("given an account then the session's saved households are listed", async () => { + const fetchMock = signedInSession(); + routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse([familyRow]), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHousehold), + }); + + const { result } = renderWithQueries(() => useSavedHouseholds()); + + await waitFor(() => expect(result.current.savedHouseholds).toHaveLength(1)); + expect(result.current.available).toBe(true); + expect(result.current.savedHouseholds[0].label).toBe(FAMILY_LABEL); + }); + + test('given a household saved then the list shows it', async () => { + const fetchMock = signedInSession(); + let rows: unknown[] = []; + routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse(rows), + 'POST /v2/households': () => itemResponse(familyHousehold, 201), + 'POST /v2/user-households': () => { + rows = [familyRow]; + return itemResponse(familyRow, 201); + }, + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHousehold), + }); + const { result } = renderWithQueries(() => ({ + list: useSavedHouseholds(), + save: useSaveHousehold(), + })); + await waitFor(() => expect(result.current.list.isLoading).toBe(false)); + + await act(() => + result.current.save.mutateAsync({ household: FAMILY_HOUSEHOLD, label: FAMILY_LABEL }) + ); + + await waitFor(() => expect(result.current.list.savedHouseholds).toHaveLength(1)); + }); + + test('given a rename or a delete then the list is read again', async () => { + const fetchMock = signedInSession(); + let rows: unknown[] = [familyRow]; + const requests = routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse(rows), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHousehold), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => { + rows = [{ ...familyRow, name: RETIRED_LABEL }]; + return itemResponse(rows[0]); + }, + [`DELETE /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => { + rows = []; + return noContentResponse(); + }, + }); + const { result } = renderWithQueries(() => ({ + list: useSavedHouseholds(), + rename: useRenameSavedHousehold(), + remove: useDeleteSavedHousehold(), + })); + await waitFor(() => expect(result.current.list.savedHouseholds).toHaveLength(1)); + + await act(() => + result.current.rename.mutateAsync({ + savedHouseholdId: SAVED_HOUSEHOLD_IDS.FAMILY, + label: RETIRED_LABEL, + }) + ); + await waitFor(() => expect(result.current.list.savedHouseholds[0]?.label).toBe(RETIRED_LABEL)); + + await act(() => result.current.remove.mutateAsync(SAVED_HOUSEHOLD_IDS.FAMILY)); + await waitFor(() => expect(result.current.list.savedHouseholds).toEqual([])); + expect(requests.filter((request) => request.path === '/v2/user-households')).toHaveLength(3); + }); + + test('given changes to a saved household then the list and the opened household are read again', async () => { + const fetchMock = signedInSession(); + const editedHousehold = v2HouseholdItem( + EDITED_FAMILY_HOUSEHOLD, + V2_HOUSEHOLD_IDS.EDITED_FAMILY + ); + let row = familyRow; + const requests = routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse([row]), + [`GET /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(row), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHousehold), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.EDITED_FAMILY}`]: () => itemResponse(editedHousehold), + 'POST /v2/households': () => itemResponse(editedHousehold, 201), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => { + row = { ...familyRow, household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY }; + return itemResponse(row); + }, + }); + const { result } = renderWithQueries(() => ({ + list: useSavedHouseholds(), + opened: useSavedHousehold(SAVED_HOUSEHOLD_IDS.FAMILY), + saveChanges: useSaveHouseholdChanges(), + })); + await waitFor(() => expect(result.current.opened.data).toBeTruthy()); + + await act(() => + result.current.saveChanges.mutateAsync({ + saved: result.current.opened.data!, + changes: { household: EDITED_FAMILY_HOUSEHOLD }, + }) + ); + + await waitFor(() => + expect(result.current.opened.data?.householdId).toBe(V2_HOUSEHOLD_IDS.EDITED_FAMILY) + ); + await waitFor(() => + expect(result.current.list.savedHouseholds[0]).toMatchObject({ + householdId: V2_HOUSEHOLD_IDS.EDITED_FAMILY, + }) + ); + expect( + requests.filter( + (request) => request.method === 'GET' && request.path === '/v2/user-households' + ) + ).toHaveLength(2); + }); + + test('given no accounts then saving fails plainly rather than saving anywhere else', async () => { + mockSavedWork.mockReturnValue({ account: null, scope: 'browser' }); + const { result } = renderWithQueries(() => useSaveHousehold()); + + await expect( + result.current.mutateAsync({ household: FAMILY_HOUSEHOLD, label: FAMILY_LABEL }) + ).rejects.toThrow('Saved households need an account.'); + }); +}); From edc34c64a61a2f384e917e06fc1a3da6487d02c8 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:21:30 +0400 Subject: [PATCH 04/29] Saved households: a label field that saves a household or its changes 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 --- .../flagship/household/SaveHouseholdForm.tsx | 119 ++++++++++ app/src/models/household/v2DocumentCodec.ts | 32 +++ .../household/SaveHouseholdForm.test.tsx | 205 ++++++++++++++++++ .../models/household/v2DocumentCodec.test.ts | 37 ++++ 4 files changed, 393 insertions(+) create mode 100644 app/src/components/flagship/household/SaveHouseholdForm.tsx create mode 100644 app/src/tests/unit/components/flagship/household/SaveHouseholdForm.test.tsx diff --git a/app/src/components/flagship/household/SaveHouseholdForm.tsx b/app/src/components/flagship/household/SaveHouseholdForm.tsx new file mode 100644 index 000000000..d10e3a7cb --- /dev/null +++ b/app/src/components/flagship/household/SaveHouseholdForm.tsx @@ -0,0 +1,119 @@ +import { useId, useState } from 'react'; +import { IconDeviceFloppy } from '@tabler/icons-react'; +import type { SavedHousehold } from '@/api/savedHouseholdStore'; +import { Button, Input, Label, Stack, Text } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; +import { + useSavedHouseholdStore, + useSaveHousehold, + useSaveHouseholdChanges, +} from '@/hooks/useSavedHouseholds'; +import type { Household } from '@/models/Household'; +import { haveSameStoredContent } from '@/models/household/v2DocumentCodec'; + +const SAVED_HOUSEHOLDS_UNAVAILABLE = 'Saved households are not available here.'; + +/** + * A label field and a Save button for the household in the builder. A new + * household is saved under the label. A saved household's changes are + * saved to it, keeping its id: an edit is stored as a new household + * (stored households never change) and the saved household pointed at + * it; a new label alone only renames it. + */ +export default function SaveHouseholdForm({ + household, + saved, + onSaved, +}: { + /** The household as the builder holds it. */ + household: Household; + /** The saved household the builder is editing; a new one is saved without it. */ + saved?: SavedHousehold | null; + /** The saved household after the save. */ + onSaved?: (saved: SavedHousehold) => void; +}) { + const labelId = useId(); + const { store } = useSavedHouseholdStore(); + const saveHousehold = useSaveHousehold(); + const saveChanges = useSaveHouseholdChanges(); + const [label, setLabel] = useState(saved?.label ?? ''); + const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); + const [errorMessage, setErrorMessage] = useState(null); + + if (!store) { + return ( + + {SAVED_HOUSEHOLDS_UNAVAILABLE} + + ); + } + + const contentChanged = !saved || !haveSameStoredContent(household, saved.household); + const labelChanged = !saved || label.trim() !== (saved.label ?? ''); + const nothingToSave = !!saved && !contentChanged && !labelChanged; + + const save = async () => { + setStatus('saving'); + setErrorMessage(null); + try { + const result = saved + ? await saveChanges.mutateAsync({ + saved, + changes: { + ...(contentChanged ? { household } : {}), + ...(labelChanged ? { label } : {}), + }, + }) + : await saveHousehold.mutateAsync({ household, label }); + setStatus('saved'); + onSaved?.(result); + } catch (error) { + setStatus('idle'); + setErrorMessage(error instanceof Error ? error.message : String(error)); + } + }; + + return ( + + +
+ { + setLabel(event.target.value); + setStatus('idle'); + }} + style={{ maxWidth: 320 }} + /> + + {status === 'saved' && ( + + Saved + + )} +
+ {errorMessage && ( + + {errorMessage} + + )} +
+ ); +} diff --git a/app/src/models/household/v2DocumentCodec.ts b/app/src/models/household/v2DocumentCodec.ts index f915e2619..5c6927c46 100644 --- a/app/src/models/household/v2DocumentCodec.ts +++ b/app/src/models/household/v2DocumentCodec.ts @@ -189,6 +189,38 @@ export function householdToV2CreateBody(household: Household): V2HouseholdCreate }; } +/** JSON with every object's keys sorted, so the order keys were set in never reads as a change. */ +function sortedKeyJson(value: unknown): string { + return JSON.stringify(value, (_key, nested) => + nested && typeof nested === 'object' && !Array.isArray(nested) + ? Object.fromEntries( + Object.keys(nested) + .sort() + .map((name) => [name, (nested as Record)[name]]) + ) + : nested + ); +} + +/** + * Whether two households would be the same stored household: compared as + * `POST /v2/households` takes them, whatever their ids, labels and the + * order of their people, groups and inputs. A household read back from + * the Public API lists its groups in name order, so it can differ in order + * alone from the same household as the builder holds it. + */ +export function haveSameStoredContent(first: Household, second: Household): boolean { + try { + return ( + sortedKeyJson(householdToV2CreateBody(first)) === + sortedKeyJson(householdToV2CreateBody(second)) + ); + } catch { + // A household that cannot be stored is compared as changed; saving it says why. + return false; + } +} + /** A stored v2 household as the app's household model, with the stored id. */ export function householdFromV2Item(item: V2HouseholdItem): Household { const { data, spm } = v1DataFromV2Document(item.country_id, item.household_data); diff --git a/app/src/tests/unit/components/flagship/household/SaveHouseholdForm.test.tsx b/app/src/tests/unit/components/flagship/household/SaveHouseholdForm.test.tsx new file mode 100644 index 000000000..3f527704d --- /dev/null +++ b/app/src/tests/unit/components/flagship/household/SaveHouseholdForm.test.tsx @@ -0,0 +1,205 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import SaveHouseholdForm from '@/components/flagship/household/SaveHouseholdForm'; +import { + EDITED_FAMILY_HOUSEHOLD, + FAMILY_HOUSEHOLD, + FAMILY_HOUSEHOLD_AS_STORED, + FAMILY_LABEL, + RETIRED_LABEL, + routeRequests, + SAVED_FAMILY, + SAVED_HOUSEHOLD_IDS, + userHouseholdItem, + V2_HOUSEHOLD_IDS, + v2HouseholdItem, +} from '@/tests/fixtures/api/savedHouseholdMocks'; +import { + createTestApiContext, + errorResponse, + itemResponse, +} from '@/tests/fixtures/api/v2UserMocks'; + +const mockSavedWork = vi.fn(); +vi.mock('@/hooks/useFlagshipStores', () => ({ + useV3SavedWork: () => mockSavedWork(), +})); + +function signedInSession() { + const { fetchMock, context } = createTestApiContext('us'); + mockSavedWork.mockReturnValue({ account: context, scope: 'account:test' }); + return fetchMock; +} + +function renderForm(props: Partial> = {}) { + const onSaved = vi.fn(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + ); + return { onSaved }; +} + +const routedPaths = (requests: { method: string; path: string }[]) => + requests.map((request) => `${request.method} ${request.path}`); + +describe('SaveHouseholdForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given a label then the household is saved with it', async () => { + const user = userEvent.setup(); + const requests = routeRequests(signedInSession(), { + 'POST /v2/households': () => + itemResponse(v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY), 201), + 'POST /v2/user-households': () => itemResponse(userHouseholdItem(), 201), + }); + const { onSaved } = renderForm(); + + await user.type(screen.getByRole('textbox', { name: 'Household label' }), FAMILY_LABEL); + await user.click(screen.getByRole('button', { name: 'Save household' })); + + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + expect(routedPaths(requests)).toEqual(['POST /v2/households', 'POST /v2/user-households']); + expect(requests[1].body.name).toBe(FAMILY_LABEL); + expect(onSaved.mock.calls[0][0]).toMatchObject({ id: SAVED_HOUSEHOLD_IDS.FAMILY }); + expect(screen.getByRole('status')).toHaveTextContent('Saved'); + }); + + test('given an edited saved household then the edit is stored and the saved household points at it, keeping its id', async () => { + const user = userEvent.setup(); + const requests = routeRequests(signedInSession(), { + 'POST /v2/households': () => + itemResponse(v2HouseholdItem(EDITED_FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.EDITED_FAMILY), 201), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse(userHouseholdItem({ household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY })), + }); + const { onSaved } = renderForm({ household: EDITED_FAMILY_HOUSEHOLD, saved: SAVED_FAMILY }); + + expect(screen.getByRole('textbox', { name: 'Household label' })).toHaveValue(FAMILY_LABEL); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + expect(routedPaths(requests)).toEqual([ + 'POST /v2/households', + `PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`, + ]); + expect(requests[1].body).toEqual({ household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY }); + expect(onSaved.mock.calls[0][0]).toMatchObject({ + id: SAVED_HOUSEHOLD_IDS.FAMILY, + householdId: V2_HOUSEHOLD_IDS.EDITED_FAMILY, + }); + expect(screen.getByRole('status')).toHaveTextContent('Saved'); + }); + + test('given an edited saved household and a new label then one request saves both', async () => { + const user = userEvent.setup(); + const requests = routeRequests(signedInSession(), { + 'POST /v2/households': () => + itemResponse(v2HouseholdItem(EDITED_FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.EDITED_FAMILY), 201), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse( + userHouseholdItem({ household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY, name: RETIRED_LABEL }) + ), + }); + const { onSaved } = renderForm({ household: EDITED_FAMILY_HOUSEHOLD, saved: SAVED_FAMILY }); + + const label = screen.getByRole('textbox', { name: 'Household label' }); + await user.clear(label); + await user.type(label, RETIRED_LABEL); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + expect(requests[1].body).toEqual({ + household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY, + name: RETIRED_LABEL, + }); + expect(onSaved.mock.calls[0][0]).toMatchObject({ label: RETIRED_LABEL }); + }); + + test('given the saved household cannot be pointed at the edit then it says the changes were not saved', async () => { + const user = userEvent.setup(); + routeRequests(signedInSession(), { + 'POST /v2/households': () => + itemResponse(v2HouseholdItem(EDITED_FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.EDITED_FAMILY), 201), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + errorResponse(503, 'Saved work is temporarily unavailable.'), + }); + const { onSaved } = renderForm({ household: EDITED_FAMILY_HOUSEHOLD, saved: SAVED_FAMILY }); + + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Your changes were not saved: Saved work is temporarily unavailable.' + ); + expect(onSaved).not.toHaveBeenCalled(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + test('given only a new label for a saved household then only its name changes', async () => { + const user = userEvent.setup(); + const requests = routeRequests(signedInSession(), { + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse(userHouseholdItem({ name: RETIRED_LABEL })), + }); + const { onSaved } = renderForm({ household: SAVED_FAMILY.household, saved: SAVED_FAMILY }); + + const label = screen.getByRole('textbox', { name: 'Household label' }); + await user.clear(label); + await user.type(label, RETIRED_LABEL); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + expect(routedPaths(requests)).toEqual([ + `PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`, + ]); + expect(onSaved.mock.calls[0][0]).toMatchObject({ + id: SAVED_HOUSEHOLD_IDS.FAMILY, + label: RETIRED_LABEL, + }); + }); + + test('given a saved household unchanged then there is nothing to save', () => { + signedInSession(); + renderForm({ household: SAVED_FAMILY.household, saved: SAVED_FAMILY }); + + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); + }); + + test('given the builder household and the same household as stored then there is nothing to save', () => { + signedInSession(); + renderForm({ + household: FAMILY_HOUSEHOLD, + saved: { ...SAVED_FAMILY, household: FAMILY_HOUSEHOLD_AS_STORED }, + }); + + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); + }); + + test('given a failed save then it says why, and nothing is reported saved', async () => { + const user = userEvent.setup(); + routeRequests(signedInSession(), { + 'POST /v2/households': () => errorResponse(503, 'V2 household persistence is unavailable'), + }); + const { onSaved } = renderForm(); + + await user.click(screen.getByRole('button', { name: 'Save household' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'V2 household persistence is unavailable' + ); + expect(onSaved).not.toHaveBeenCalled(); + }); + + test('given no accounts then it says saved households are not available, with nothing to press', () => { + mockSavedWork.mockReturnValue({ account: null, scope: 'browser' }); + renderForm(); + + expect(screen.getByText('Saved households are not available here.')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save household' })).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/models/household/v2DocumentCodec.test.ts b/app/src/tests/unit/models/household/v2DocumentCodec.test.ts index e57a14de3..71feefa8b 100644 --- a/app/src/tests/unit/models/household/v2DocumentCodec.test.ts +++ b/app/src/tests/unit/models/household/v2DocumentCodec.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest'; import { + haveSameStoredContent, householdFromV2Item, householdToV2CreateBody, V2HouseholdDocumentError, @@ -14,6 +15,7 @@ import { US_PERSON_WITHOUT_MARITAL_UNIT, US_SINGLE_PARENT, US_SINGLE_PARENT_V2_BODY, + V2_CODEC_YEAR, } from '@/tests/fixtures/models/v2DocumentCodecMocks'; describe('householdToV2CreateBody', () => { @@ -84,3 +86,38 @@ describe('householdFromV2Item', () => { expect(data.benunits).toEqual({ family: { members: ['adult'] } }); }); }); + +describe('haveSameStoredContent', () => { + test('given a household and the same household read back from the Public API then they are the same', () => { + const body = householdToV2CreateBody(US_COUPLE_WITH_TWO_CHILDREN); + const readBack = householdFromV2Item(storedV2Household(body.country_id, body.household_data)); + + expect(haveSameStoredContent(US_COUPLE_WITH_TWO_CHILDREN, readBack)).toBe(true); + }); + + test('given a different label or id then they are still the same stored household', () => { + expect( + haveSameStoredContent( + US_SINGLE_PARENT, + US_SINGLE_PARENT.withLabel('Our family').withId(STORED_V2_HOUSEHOLD_ID) + ) + ).toBe(true); + }); + + test('given a changed input then they differ', () => { + const edited = US_SINGLE_PARENT.setPersonVariableAtYear( + 'you', + 'employment_income', + V2_CODEC_YEAR, + 40000 + ); + + expect(haveSameStoredContent(US_SINGLE_PARENT, edited)).toBe(false); + }); + + test('given a household that cannot be stored then it is compared as changed', () => { + expect( + haveSameStoredContent(US_PERSON_WITHOUT_MARITAL_UNIT, US_PERSON_WITHOUT_MARITAL_UNIT) + ).toBe(false); + }); +}); From 331ae18a3b152984429e6fdf59e9c81dfdb9dbcd Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:04:45 +0400 Subject: [PATCH 05/29] Saved households: a picker that opens one 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 --- .../flagship/household/SaveHouseholdForm.tsx | 3 +- .../household/SavedHouseholdPicker.tsx | 99 +++++++++++ .../household/savedHouseholdDisplay.tsx | 84 +++++++++ .../tests/fixtures/api/savedHouseholdMocks.ts | 2 + .../household/SavedHouseholdPicker.test.tsx | 159 ++++++++++++++++++ 5 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 app/src/components/flagship/household/SavedHouseholdPicker.tsx create mode 100644 app/src/components/flagship/household/savedHouseholdDisplay.tsx create mode 100644 app/src/tests/unit/components/flagship/household/SavedHouseholdPicker.test.tsx diff --git a/app/src/components/flagship/household/SaveHouseholdForm.tsx b/app/src/components/flagship/household/SaveHouseholdForm.tsx index d10e3a7cb..866a371b5 100644 --- a/app/src/components/flagship/household/SaveHouseholdForm.tsx +++ b/app/src/components/flagship/household/SaveHouseholdForm.tsx @@ -1,6 +1,7 @@ import { useId, useState } from 'react'; import { IconDeviceFloppy } from '@tabler/icons-react'; import type { SavedHousehold } from '@/api/savedHouseholdStore'; +import { SAVED_HOUSEHOLDS_UNAVAILABLE } from '@/components/flagship/household/savedHouseholdDisplay'; import { Button, Input, Label, Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { @@ -11,8 +12,6 @@ import { import type { Household } from '@/models/Household'; import { haveSameStoredContent } from '@/models/household/v2DocumentCodec'; -const SAVED_HOUSEHOLDS_UNAVAILABLE = 'Saved households are not available here.'; - /** * A label field and a Save button for the household in the builder. A new * household is saved under the label. A saved household's changes are diff --git a/app/src/components/flagship/household/SavedHouseholdPicker.tsx b/app/src/components/flagship/household/SavedHouseholdPicker.tsx new file mode 100644 index 000000000..0efb9bf65 --- /dev/null +++ b/app/src/components/flagship/household/SavedHouseholdPicker.tsx @@ -0,0 +1,99 @@ +import { IconHome } from '@tabler/icons-react'; +import { useSelector } from 'react-redux'; +import type { SavedHousehold } from '@/api/savedHouseholdStore'; +import { + savedHouseholdDescription, + savedHouseholdName, + savedHouseholdsNotice, +} from '@/components/flagship/household/savedHouseholdDisplay'; +import { colors, spacing, typography } from '@/designTokens'; +import { useSavedHouseholds } from '@/hooks/useSavedHouseholds'; +import type { RootState } from '@/store'; + +/** + * The session's saved households in a country, to open one: each by its + * label, with who it describes. One that cannot open (saved before + * households moved to the Public API's v2 routes, or failing to load) is + * listed but cannot be chosen. + */ +export default function SavedHouseholdPicker({ + countryId, + selectedId, + onSelect, +}: { + countryId: string; + /** The saved household the builder holds, marked as chosen. */ + selectedId?: string; + onSelect: (saved: SavedHousehold) => void; +}) { + const variables = useSelector((state: RootState) => state.metadata?.variables); + const { available, savedHouseholds, isLoading, error } = useSavedHouseholds(); + const households = savedHouseholds.filter((saved) => saved.countryId === countryId); + const notice = savedHouseholdsNotice({ + available, + isLoading, + error, + count: households.length, + }); + if (notice) { + return notice; + } + + return ( +
    + {households.map((saved) => { + const selected = saved.id === selectedId; + return ( +
  • + +
  • + ); + })} +
+ ); +} diff --git a/app/src/components/flagship/household/savedHouseholdDisplay.tsx b/app/src/components/flagship/household/savedHouseholdDisplay.tsx new file mode 100644 index 000000000..4a87e5e84 --- /dev/null +++ b/app/src/components/flagship/household/savedHouseholdDisplay.tsx @@ -0,0 +1,84 @@ +import type { CSSProperties, ReactElement } from 'react'; +import type { ListedSavedHousehold } from '@/api/savedHouseholdStore'; +import { Spinner, Text } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; +import { describeHousehold } from '@/libs/flagship/householdDescription'; +import type { VariableInfo } from '@/utils/VariableResolver'; + +/** + * What v3's saved-household controls (the save form, the picker and the + * list) share: how a saved household is named and described, and what + * they say before there is a saved household to show. + */ + +/** Without accounts there are no saved households; each control says so instead. */ +export const SAVED_HOUSEHOLDS_UNAVAILABLE = 'Saved households are not available here.'; + +const quietText: CSSProperties = { + margin: 0, + fontSize: typography.fontSize.sm, + color: colors.text.secondary, +}; + +/** A saved household's name, or what it is when saved without one. */ +export function savedHouseholdName(saved: Pick): string { + return saved.label || 'Untitled household'; +} + +/** + * Who a saved household describes ("Married · 2 children · California"), + * in the year its inputs are in; for one that cannot open, that it cannot. + */ +export function savedHouseholdDescription( + saved: ListedSavedHousehold, + variables?: Record +): string { + if (saved.unavailable) { + return 'Cannot be opened here'; + } + const { household } = saved; + return household.year === null + ? 'No year set' + : describeHousehold(household, String(household.year), saved.countryId, variables); +} + +/** + * What a list of saved households shows instead of its households: that + * there are none here (no accounts), that they are loading, why they + * could not load, or that none are saved. Null once there are some to show. + */ +export function savedHouseholdsNotice({ + available, + isLoading, + error, + count, +}: { + available: boolean; + isLoading: boolean; + error: Error | null; + /** How many saved households there are to show. */ + count: number; +}): ReactElement | null { + if (!available) { + return {SAVED_HOUSEHOLDS_UNAVAILABLE}; + } + if (isLoading) { + return ( +
+ + Loading your households... +
+ ); + } + if (error) { + return ( + + Your households could not be loaded. {error.message} + + ); + } + if (count === 0) { + return No saved households yet.; + } + return null; +} diff --git a/app/src/tests/fixtures/api/savedHouseholdMocks.ts b/app/src/tests/fixtures/api/savedHouseholdMocks.ts index 7badbf364..aa36efc21 100644 --- a/app/src/tests/fixtures/api/savedHouseholdMocks.ts +++ b/app/src/tests/fixtures/api/savedHouseholdMocks.ts @@ -22,6 +22,8 @@ export const V2_HOUSEHOLD_IDS = { export const FAMILY_LABEL = 'Our family'; export const RETIRED_LABEL = 'Retired couple'; +/** A row saved before households moved to v2: it names only a v1 household. */ +export const V1_ONLY_LABEL = 'Saved long ago'; /** A couple with two children, as the household builder makes it. */ export const FAMILY_HOUSEHOLD = Household.starter('us', SAVED_HOUSEHOLD_YEAR) diff --git a/app/src/tests/unit/components/flagship/household/SavedHouseholdPicker.test.tsx b/app/src/tests/unit/components/flagship/household/SavedHouseholdPicker.test.tsx new file mode 100644 index 000000000..803c84c03 --- /dev/null +++ b/app/src/tests/unit/components/flagship/household/SavedHouseholdPicker.test.tsx @@ -0,0 +1,159 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, within } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import SavedHouseholdPicker from '@/components/flagship/household/SavedHouseholdPicker'; +import { + FAMILY_HOUSEHOLD, + FAMILY_LABEL, + RETIRED_HOUSEHOLD, + RETIRED_LABEL, + routeRequests, + SAVED_HOUSEHOLD_IDS, + userHouseholdItem, + V1_ONLY_LABEL, + V2_HOUSEHOLD_IDS, + v2HouseholdItem, +} from '@/tests/fixtures/api/savedHouseholdMocks'; +import { + createTestApiContext, + errorResponse, + itemResponse, + pageResponse, +} from '@/tests/fixtures/api/v2UserMocks'; + +const mockSavedWork = vi.fn(); +vi.mock('@/hooks/useFlagshipStores', () => ({ + useV3SavedWork: () => mockSavedWork(), +})); + +function renderPicker(props: Partial> = {}) { + const onSelect = vi.fn(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + ); + return { onSelect }; +} + +const retiredRow = userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.RETIRED, + household_id: V2_HOUSEHOLD_IDS.RETIRED, + name: RETIRED_LABEL, +}); + +/** A session whose account lists these saved rows, and holds the family and the retired couple. */ +function signedInWith(rows: unknown[]) { + const { fetchMock, context } = createTestApiContext('us'); + mockSavedWork.mockReturnValue({ account: context, scope: 'account:test' }); + routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse(rows), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse(v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY)), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.RETIRED}`]: () => + itemResponse(v2HouseholdItem(RETIRED_HOUSEHOLD, V2_HOUSEHOLD_IDS.RETIRED)), + }); +} + +describe('SavedHouseholdPicker', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given saved households then each is offered by its label, with who it describes', async () => { + signedInWith([userHouseholdItem(), retiredRow]); + renderPicker(); + + const list = await screen.findByRole('list', { name: 'Saved households' }); + expect(within(list).getByRole('button', { name: new RegExp(FAMILY_LABEL) })).toHaveTextContent( + 'Married · 2 children' + ); + expect(within(list).getByRole('button', { name: new RegExp(RETIRED_LABEL) })).toHaveTextContent( + 'Married · no children' + ); + }); + + test('given a choice then the saved household is handed on with its household', async () => { + const user = userEvent.setup(); + signedInWith([userHouseholdItem()]); + const { onSelect } = renderPicker(); + + await user.click(await screen.findByRole('button', { name: new RegExp(FAMILY_LABEL) })); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect.mock.calls[0][0]).toMatchObject({ + id: SAVED_HOUSEHOLD_IDS.FAMILY, + label: FAMILY_LABEL, + householdId: V2_HOUSEHOLD_IDS.FAMILY, + }); + expect(onSelect.mock.calls[0][0].household.toV1CreationPayload().data.people).toEqual( + FAMILY_HOUSEHOLD.toV1CreationPayload().data.people + ); + }); + + test('given the saved household the builder holds then it is marked as chosen', async () => { + signedInWith([userHouseholdItem(), retiredRow]); + renderPicker({ selectedId: SAVED_HOUSEHOLD_IDS.RETIRED }); + + expect(await screen.findByRole('button', { name: new RegExp(RETIRED_LABEL) })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + expect(screen.getByRole('button', { name: new RegExp(FAMILY_LABEL) })).toHaveAttribute( + 'aria-pressed', + 'false' + ); + }); + + test('given a saved household that cannot open then it is listed, but cannot be chosen', async () => { + signedInWith([ + userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.V1_ONLY, + household_id: null, + legacy_household_id: 123, + name: V1_ONLY_LABEL, + }), + ]); + renderPicker(); + + const option = await screen.findByRole('button', { name: new RegExp(V1_ONLY_LABEL) }); + expect(option).toBeDisabled(); + expect(option).toHaveTextContent('Cannot be opened here'); + }); + + test('given a saved household without a label then it is offered as an untitled household', async () => { + signedInWith([userHouseholdItem({ name: null })]); + renderPicker(); + + expect(await screen.findByRole('button', { name: /Untitled household/ })).toBeInTheDocument(); + }); + + test('given none saved then it says so', async () => { + signedInWith([]); + renderPicker(); + + expect(await screen.findByText('No saved households yet.')).toBeInTheDocument(); + }); + + test('given the list fails to load then it says why', async () => { + const { fetchMock, context } = createTestApiContext('us'); + mockSavedWork.mockReturnValue({ account: context, scope: 'account:test' }); + routeRequests(fetchMock, { + 'GET /v2/user-households': () => errorResponse(503, 'Saved work is temporarily unavailable.'), + }); + renderPicker(); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Your households could not be loaded. Saved work is temporarily unavailable.' + ); + }); + + test('given no accounts then it says saved households are not available', () => { + mockSavedWork.mockReturnValue({ account: null, scope: 'browser' }); + renderPicker(); + + expect(screen.getByText('Saved households are not available here.')).toBeInTheDocument(); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + }); +}); From 2b30c5b759a5efa3778dfa0cbbb8674cae1f9ce3 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:06:28 +0400 Subject: [PATCH 06/29] Saved households: a list to rename or delete them 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 --- .../household/DeleteSavedHouseholdDialog.tsx | 51 +++++ .../household/SavedHouseholdsList.tsx | 144 +++++++++++++ .../pages/flagship/SavedHouseholds.page.tsx | 24 +++ .../flagship/SavedHouseholds.page.test.tsx | 203 ++++++++++++++++++ 4 files changed, 422 insertions(+) create mode 100644 app/src/components/flagship/household/DeleteSavedHouseholdDialog.tsx create mode 100644 app/src/components/flagship/household/SavedHouseholdsList.tsx create mode 100644 app/src/pages/flagship/SavedHouseholds.page.tsx create mode 100644 app/src/tests/unit/pages/flagship/SavedHouseholds.page.test.tsx diff --git a/app/src/components/flagship/household/DeleteSavedHouseholdDialog.tsx b/app/src/components/flagship/household/DeleteSavedHouseholdDialog.tsx new file mode 100644 index 000000000..0e62f138b --- /dev/null +++ b/app/src/components/flagship/household/DeleteSavedHouseholdDialog.tsx @@ -0,0 +1,51 @@ +import { IconTrash } from '@tabler/icons-react'; +import { AlertDialog } from 'radix-ui'; +import { Button } from '@/components/ui'; + +/** + * A saved household's Delete button. A deleted saved household cannot be + * brought back, so the button asks first. Only the user's saved row goes: + * the stored household stays, as reports and other users may use it. + */ +export default function DeleteSavedHouseholdDialog({ + name, + disabled, + onConfirm, +}: { + name: string; + disabled?: boolean; + onConfirm: () => void; +}) { + return ( + + + + + + + + + Delete “{name}”? + + + This removes it from your households for good. Reports already run with it are not + changed. + +
+ + + + + + +
+
+
+
+ ); +} diff --git a/app/src/components/flagship/household/SavedHouseholdsList.tsx b/app/src/components/flagship/household/SavedHouseholdsList.tsx new file mode 100644 index 000000000..358538890 --- /dev/null +++ b/app/src/components/flagship/household/SavedHouseholdsList.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { IconPencil } from '@tabler/icons-react'; +import { useSelector } from 'react-redux'; +import type { ListedSavedHousehold } from '@/api/savedHouseholdStore'; +import { RenameIngredientModal } from '@/components/common/RenameIngredientModal'; +import DeleteSavedHouseholdDialog from '@/components/flagship/household/DeleteSavedHouseholdDialog'; +import { + savedHouseholdDescription, + savedHouseholdName, + savedHouseholdsNotice, +} from '@/components/flagship/household/savedHouseholdDisplay'; +import { Button, Text } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; +import { + useDeleteSavedHousehold, + useRenameSavedHousehold, + useSavedHouseholds, +} from '@/hooks/useSavedHouseholds'; +import type { RootState } from '@/store'; + +/** + * The session's saved households, each with who it describes, to rename + * or delete. A saved household that cannot open is listed, and can still + * be renamed or deleted. A failed rename keeps its dialog open with the + * reason; a failed delete names the household and the reason above the list. + */ +export default function SavedHouseholdsList() { + const variables = useSelector((state: RootState) => state.metadata?.variables); + const { available, savedHouseholds, isLoading, error } = useSavedHouseholds(); + const renameHousehold = useRenameSavedHousehold(); + const deleteHousehold = useDeleteSavedHousehold(); + const [renaming, setRenaming] = useState(null); + const [deleting, setDeleting] = useState(null); + + const notice = savedHouseholdsNotice({ + available, + isLoading, + error, + count: savedHouseholds.length, + }); + if (notice) { + return notice; + } + + const openRename = (saved: ListedSavedHousehold) => { + // An earlier rename's failure belongs to the household it was tried on. + renameHousehold.reset(); + setRenaming(saved); + }; + + const remove = (saved: ListedSavedHousehold) => { + setDeleting(saved); + deleteHousehold.mutate(saved.id); + }; + + return ( + <> + {deleteHousehold.isError && deleting && ( + + “{savedHouseholdName(deleting)}” could not be deleted. {deleteHousehold.error.message} + + )} +
    + {savedHouseholds.map((saved) => { + const name = savedHouseholdName(saved); + return ( +
  • + + + {name} + + + {savedHouseholdDescription(saved, variables)} + + + + + remove(saved)} + /> + +
  • + ); + })} +
+ setRenaming(null)} + currentLabel={renaming?.label ?? ''} + ingredientType="household" + isLoading={renameHousehold.isPending} + submissionError={renameHousehold.error?.message ?? null} + onRename={async (label) => { + if (!renaming) { + return; + } + try { + await renameHousehold.mutateAsync({ savedHouseholdId: renaming.id, label }); + setRenaming(null); + } catch { + // The dialog shows the reason and stays open. + } + }} + /> + + ); +} diff --git a/app/src/pages/flagship/SavedHouseholds.page.tsx b/app/src/pages/flagship/SavedHouseholds.page.tsx new file mode 100644 index 000000000..1ab8cee77 --- /dev/null +++ b/app/src/pages/flagship/SavedHouseholds.page.tsx @@ -0,0 +1,24 @@ +import SavedHouseholdsList from '@/components/flagship/household/SavedHouseholdsList'; +import { Stack, Text, Title } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; + +/** + * v3's list of saved households, to rename or delete. Saved households + * live on the Public API's `/v2/households` and `/v2/user-households`, so + * no route renders this page until v3 moves to those routes. + */ +export default function SavedHouseholdsPage() { + return ( + + + + Your households + + + Households you have saved. Choose one when you run a household report. + + + + + ); +} diff --git a/app/src/tests/unit/pages/flagship/SavedHouseholds.page.test.tsx b/app/src/tests/unit/pages/flagship/SavedHouseholds.page.test.tsx new file mode 100644 index 000000000..ed48fe91c --- /dev/null +++ b/app/src/tests/unit/pages/flagship/SavedHouseholds.page.test.tsx @@ -0,0 +1,203 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor, within } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import SavedHouseholdsPage from '@/pages/flagship/SavedHouseholds.page'; +import { + FAMILY_HOUSEHOLD, + FAMILY_LABEL, + RETIRED_LABEL, + routeRequests, + SAVED_HOUSEHOLD_IDS, + userHouseholdItem, + V1_ONLY_LABEL, + V2_HOUSEHOLD_IDS, + v2HouseholdItem, +} from '@/tests/fixtures/api/savedHouseholdMocks'; +import { + createTestApiContext, + errorResponse, + itemResponse, + noContentResponse, + pageResponse, +} from '@/tests/fixtures/api/v2UserMocks'; + +const mockSavedWork = vi.fn(); +vi.mock('@/hooks/useFlagshipStores', () => ({ + useV3SavedWork: () => mockSavedWork(), +})); + +const SAVED_WORK_UNAVAILABLE = 'Saved work is temporarily unavailable.'; + +const v1OnlyRow = userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.V1_ONLY, + household_id: null, + legacy_household_id: 123, + name: V1_ONLY_LABEL, +}); + +/** + * The account holds these saved rows and the family's household; a rename + * or delete changes what the list reads next, unless `failing` says that + * request fails. + */ +function signedInWith( + initialRows: ReturnType[], + failing: { rename?: boolean; remove?: boolean } = {} +) { + const { fetchMock, context } = createTestApiContext('us'); + mockSavedWork.mockReturnValue({ account: context, scope: 'account:test' }); + let rows = initialRows; + const rowRoutes = Object.fromEntries( + initialRows.flatMap((row) => [ + [ + `PATCH /v2/user-households/${row.id}`, + (request: { body: { name: string } }) => { + if (failing.rename) { + return errorResponse(503, SAVED_WORK_UNAVAILABLE); + } + rows = rows.map((other) => + other.id === row.id ? { ...other, name: request.body.name } : other + ); + return itemResponse(rows.find((other) => other.id === row.id)); + }, + ], + [ + `DELETE /v2/user-households/${row.id}`, + () => { + if (failing.remove) { + return errorResponse(503, SAVED_WORK_UNAVAILABLE); + } + rows = rows.filter((other) => other.id !== row.id); + return noContentResponse(); + }, + ], + ]) + ); + return routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse(rows), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse(v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY)), + ...rowRoutes, + }); +} + +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + ); +} + +async function renameTo(row: HTMLElement, label: string) { + const user = userEvent.setup(); + await user.click(within(row).getByRole('button', { name: 'Rename' })); + const nameField = await screen.findByRole('textbox'); + await user.clear(nameField); + await user.type(nameField, label); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Rename' })); +} + +describe('SavedHouseholdsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given saved households then each is listed with who it describes', async () => { + signedInWith([userHouseholdItem()]); + renderPage(); + + expect(screen.getByRole('heading', { name: 'Your households' })).toBeInTheDocument(); + const row = await screen.findByRole('listitem', { name: FAMILY_LABEL }); + expect(row).toHaveTextContent('Married · 2 children'); + }); + + test('given Rename then the saved household takes the new label', async () => { + const requests = signedInWith([userHouseholdItem()]); + renderPage(); + + await renameTo(await screen.findByRole('listitem', { name: FAMILY_LABEL }), RETIRED_LABEL); + + expect(await screen.findByRole('listitem', { name: RETIRED_LABEL })).toBeInTheDocument(); + expect(requests.find((request) => request.method === 'PATCH')?.body).toEqual({ + name: RETIRED_LABEL, + }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + test('given a rename that fails then the dialog stays open and says why', async () => { + signedInWith([userHouseholdItem()], { rename: true }); + renderPage(); + + await renameTo(await screen.findByRole('listitem', { name: FAMILY_LABEL }), RETIRED_LABEL); + + expect( + await within(screen.getByRole('dialog')).findByText(SAVED_WORK_UNAVAILABLE) + ).toBeInTheDocument(); + // The open dialog hides the page behind it from assistive technology. + expect(screen.getByRole('listitem', { name: FAMILY_LABEL, hidden: true })).toBeInTheDocument(); + }); + + test('given Delete, once confirmed, then the saved household leaves the list', async () => { + const user = userEvent.setup(); + const requests = signedInWith([userHouseholdItem()]); + renderPage(); + const row = await screen.findByRole('listitem', { name: FAMILY_LABEL }); + + await user.click(within(row).getByRole('button', { name: 'Delete' })); + expect(requests.some((request) => request.method === 'DELETE')).toBe(false); + await user.click(await screen.findByRole('button', { name: 'Delete household' })); + + await waitFor(() => + expect(screen.queryByRole('listitem', { name: FAMILY_LABEL })).not.toBeInTheDocument() + ); + expect(screen.getByText('No saved households yet.')).toBeInTheDocument(); + }); + + test('given Delete, then Cancel, then nothing is deleted', async () => { + const user = userEvent.setup(); + const requests = signedInWith([userHouseholdItem()]); + renderPage(); + const row = await screen.findByRole('listitem', { name: FAMILY_LABEL }); + + await user.click(within(row).getByRole('button', { name: 'Delete' })); + await user.click(await screen.findByRole('button', { name: 'Cancel' })); + + expect(requests.some((request) => request.method === 'DELETE')).toBe(false); + expect(screen.getByRole('listitem', { name: FAMILY_LABEL })).toBeInTheDocument(); + }); + + test('given a delete that fails then it says which household and why, and keeps it', async () => { + const user = userEvent.setup(); + signedInWith([userHouseholdItem()], { remove: true }); + renderPage(); + const row = await screen.findByRole('listitem', { name: FAMILY_LABEL }); + + await user.click(within(row).getByRole('button', { name: 'Delete' })); + await user.click(await screen.findByRole('button', { name: 'Delete household' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + `“${FAMILY_LABEL}” could not be deleted. ${SAVED_WORK_UNAVAILABLE}` + ); + expect(screen.getByRole('listitem', { name: FAMILY_LABEL })).toBeInTheDocument(); + }); + + test('given a saved household that cannot open then it is listed, and can be renamed or deleted', async () => { + signedInWith([userHouseholdItem(), v1OnlyRow]); + renderPage(); + + const row = await screen.findByRole('listitem', { name: V1_ONLY_LABEL }); + expect(row).toHaveTextContent('Cannot be opened here'); + expect(within(row).getByRole('button', { name: 'Rename' })).toBeEnabled(); + expect(within(row).getByRole('button', { name: 'Delete' })).toBeEnabled(); + }); + + test('given no accounts then it says saved households are not available', () => { + mockSavedWork.mockReturnValue({ account: null, scope: 'browser' }); + renderPage(); + + expect(screen.getByText('Saved households are not available here.')).toBeInTheDocument(); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + }); +}); From 7e9f14323c5e60ce35def0cdcc72747c963ddf68 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:11:14 +0400 Subject: [PATCH 07/29] Reproduce in Python: an option to end each block with a pandas DataFrame 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 --- .../HouseholdReproducibility.tsx | 22 ++++++- .../HouseholdReproducibility.test.tsx | 22 +++++++ .../unit/utils/reproducibilityCode.test.ts | 46 ++++++++++++++ app/src/utils/reproducibilityCode.ts | 60 ++++++++++++++++++- 4 files changed, 148 insertions(+), 2 deletions(-) diff --git a/app/src/pages/report-output/reproduce-in-python/HouseholdReproducibility.tsx b/app/src/pages/report-output/reproduce-in-python/HouseholdReproducibility.tsx index 95edd5012..b766da9a1 100644 --- a/app/src/pages/report-output/reproduce-in-python/HouseholdReproducibility.tsx +++ b/app/src/pages/report-output/reproduce-in-python/HouseholdReproducibility.tsx @@ -29,14 +29,18 @@ interface HouseholdReproducibilityProps { countryId: string; year: string | null; simulations: HouseholdReproduction[]; + /** Offers a switch that ends each block with a pandas DataFrame of its results. */ + offerDataFrame?: boolean; } export default function HouseholdReproducibility({ countryId, year: reportYear, simulations, + offerDataFrame = false, }: HouseholdReproducibilityProps) { const [earningVariation, setEarningVariation] = useState(false); + const [includeDataFrame, setIncludeDataFrame] = useState(false); const year = reportYear && /^\d{4}$/.test(reportYear) ? Number(reportYear) : null; return ( @@ -55,6 +59,16 @@ export default function HouseholdReproducibility({ onCheckedChange={setEarningVariation} /> + {offerDataFrame && ( + + Include a pandas DataFrame + + + )} {simulations.length === 0 ? No simulations are available to reproduce. : null} {simulations.map((simulation) => ( ))} @@ -74,16 +89,21 @@ function HouseholdSimulationCode({ simulation, year, earningVariation, + includeDataFrame, }: { countryId: string; simulation: HouseholdReproduction; year: number | null; earningVariation: boolean; + includeDataFrame: boolean; }) { const [copied, setCopied] = useState(false); const { policyengineVersion, modelVersion } = simulation; const title = simulation.role === 'baseline' ? 'Baseline simulation' : 'Reform simulation'; - const lines = getHouseholdReproducibilityCode(countryId, simulation, year, earningVariation); + const lines = getHouseholdReproducibilityCode(countryId, simulation, year, { + earningVariation, + includeDataFrame, + }); const unavailableReason = getHouseholdReproductionUnavailableReason(countryId, simulation, year); const codeText = lines.join('\n'); diff --git a/app/src/tests/unit/pages/report-output/reproduce-in-python/HouseholdReproducibility.test.tsx b/app/src/tests/unit/pages/report-output/reproduce-in-python/HouseholdReproducibility.test.tsx index 9e15fb637..44f9e23e2 100644 --- a/app/src/tests/unit/pages/report-output/reproduce-in-python/HouseholdReproducibility.test.tsx +++ b/app/src/tests/unit/pages/report-output/reproduce-in-python/HouseholdReproducibility.test.tsx @@ -171,4 +171,26 @@ describe('HouseholdReproducibility', () => { expect(screen.getByText(new RegExp(MOCK_REPORT_YEAR))).toBeInTheDocument(); }); }); + + describe('given the pandas DataFrame option (issue #1095)', () => { + test('then a switch adds DataFrame code to each block', async () => { + const user = userEvent.setup(); + render( + + ); + + expect(screen.queryByText(/pd\.DataFrame/)).not.toBeInTheDocument(); + await user.click(screen.getByRole('switch', { name: 'Include a pandas DataFrame' })); + + expect(screen.getByText(/pd\.DataFrame/)).toBeInTheDocument(); + }); + + test('then without it there is no DataFrame switch, as before', () => { + render(); + + expect( + screen.queryByRole('switch', { name: 'Include a pandas DataFrame' }) + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/app/src/tests/unit/utils/reproducibilityCode.test.ts b/app/src/tests/unit/utils/reproducibilityCode.test.ts index 4ef1e17b7..234daef27 100644 --- a/app/src/tests/unit/utils/reproducibilityCode.test.ts +++ b/app/src/tests/unit/utils/reproducibilityCode.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, vi } from 'vitest'; import { POPULACE_US_DEFAULT_DATASET_URI } from '@/constants'; import { TEST_COUNTRIES } from '@/tests/fixtures/constants'; +import { DEFAULT_HOUSEHOLD_REPRODUCIBILITY_PROPS } from '@/tests/fixtures/pages/report-output/reproduce-in-python/reproducibilityMocks'; import { BASELINE_AND_REFORM_POLICY, COMPLEX_HOUSEHOLD, @@ -25,6 +26,7 @@ import { import { convertPoliciesToV1Format, getColabLink, + getHouseholdReproducibilityCode, getReproducibilityCodeBlock, sanitizeStringToPython, } from '@/utils/reproducibilityCode'; @@ -895,3 +897,47 @@ describe('reproducibilityCode', () => { }); }); }); + +describe('getHouseholdReproducibilityCode with a pandas DataFrame (issue #1095)', () => { + const reproduction = DEFAULT_HOUSEHOLD_REPRODUCIBILITY_PROPS.simulations[0]; + const year = Number(DEFAULT_HOUSEHOLD_REPRODUCIBILITY_PROPS.year); + + test('given a point calculation then a DataFrame of the household results follows the code', () => { + const code = getHouseholdReproducibilityCode(TEST_COUNTRIES.US, reproduction, year, { + includeDataFrame: true, + }).join('\n'); + + expect(code).toContain('import pandas as pd'); + expect(code).toContain( + 'variables = ["household_net_income", "household_market_income", "household_tax", "household_benefits"]' + ); + expect(code).toContain( + `"value": [float(simulation.calculate(variable, ${year}).sum()) for variable in variables],` + ); + expect(code.trimEnd().endsWith('print(df)')).toBe(true); + }); + + test('given an earnings variation then the DataFrame has one row per earnings level, ready to chart', () => { + const code = getHouseholdReproducibilityCode(TEST_COUNTRIES.US, reproduction, year, { + earningVariation: true, + includeDataFrame: true, + }).join('\n'); + + expect(code).toContain('axes'); + expect(code).toContain( + `"employment_income": simulation.calculate("employment_income", ${year}, map_to="household"),` + ); + expect(code).toContain( + `"household_net_income": simulation.calculate("household_net_income", ${year}),` + ); + }); + + test('given no DataFrame asked for then the code is as before', () => { + const code = getHouseholdReproducibilityCode(TEST_COUNTRIES.US, reproduction, year, { + earningVariation: true, + }).join('\n'); + + expect(code).toContain('# Reform earning variation'); + expect(code).not.toContain('pandas'); + }); +}); diff --git a/app/src/utils/reproducibilityCode.ts b/app/src/utils/reproducibilityCode.ts index 0dd923f3a..f19acc742 100644 --- a/app/src/utils/reproducibilityCode.ts +++ b/app/src/utils/reproducibilityCode.ts @@ -420,12 +420,69 @@ export function getHouseholdReproductionUnavailableReason( return null; } +/** The household results a reproduction's DataFrame holds: each is in the US and UK models. */ +const DATAFRAME_HOUSEHOLD_VARIABLES = [ + 'household_net_income', + 'household_market_income', + 'household_tax', + 'household_benefits', +] as const; + +/** + * Code that puts the simulation's results in a pandas DataFrame, for + * charts and further analysis (issue #1095). A point calculation gives one + * row per result; an earnings variation gives one row per earnings level, + * with the household's earnings beside its results. + */ +function getDataFrameCode(year: number, earningVariation: boolean): string[] { + if (!earningVariation) { + const variables = DATAFRAME_HOUSEHOLD_VARIABLES.map((name) => `"${name}"`).join(', '); + return [ + '', + '# The household results as a pandas DataFrame, for charts and analysis.', + 'import pandas as pd', + '', + `variables = [${variables}]`, + 'df = pd.DataFrame(', + ' {', + ' "variable": variables,', + ` "value": [float(simulation.calculate(variable, ${year}).sum()) for variable in variables],`, + ' }', + ')', + 'print(df)', + ]; + } + return [ + '', + '# The results at each earnings level as a pandas DataFrame, ready to chart.', + 'import pandas as pd', + '', + 'df = pd.DataFrame(', + ' {', + ` "employment_income": simulation.calculate("employment_income", ${year}, map_to="household"),`, + ...DATAFRAME_HOUSEHOLD_VARIABLES.map( + (name) => ` "${name}": simulation.calculate("${name}", ${year}),` + ), + ' }', + ')', + 'print(df)', + ]; +} + +/** What a household reproduction block covers beyond the household at its own earnings. */ +export interface HouseholdReproductionOptions { + /** Calculates the household across a range of the first adult's earnings. */ + earningVariation?: boolean; + /** Ends the block with code that puts its results in a pandas DataFrame. */ + includeDataFrame?: boolean; +} + /** Each block runs in its own notebook so different resolved versions stay isolated. */ export function getHouseholdReproducibilityCode( countryId: string, reproduction: HouseholdReproduction, year: number | null, - earningVariation: boolean = false + { earningVariation = false, includeDataFrame = false }: HouseholdReproductionOptions = {} ): string[] { const { role, household, policy, policyengineVersion, modelVersion, spmConfig } = reproduction; if (getHouseholdReproductionUnavailableReason(countryId, reproduction, year)) { @@ -474,6 +531,7 @@ export function getHouseholdReproducibilityCode( role, spmConfig ), + ...(includeDataFrame ? getDataFrameCode(year!, earningVariation) : []), ]; } From e4e00c541d4970899bd1f084d7a52b26d30052fe Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:19:19 +0400 Subject: [PATCH 08/29] Earnings variation: offer the inputs a household sets, for the household 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 --- .../report/household/HouseholdBreakdowns.tsx | 7 +- app/src/libs/flagship/householdReport.ts | 43 +++++++++++ .../EarningsVariationSubPage.tsx | 21 +++++- .../libs/flagship/householdReportMocks.ts | 66 ++++++++++++++++ .../report/HouseholdBreakdownsInputs.test.tsx | 73 ++++++++++++++++++ .../libs/flagship/householdReport.test.ts | 9 +++ .../EarningsVariationInputs.test.tsx | 75 +++++++++++++++++++ 7 files changed, 289 insertions(+), 5 deletions(-) create mode 100644 app/src/libs/flagship/householdReport.ts create mode 100644 app/src/tests/fixtures/libs/flagship/householdReportMocks.ts create mode 100644 app/src/tests/unit/components/flagship/report/HouseholdBreakdownsInputs.test.tsx create mode 100644 app/src/tests/unit/libs/flagship/householdReport.test.ts create mode 100644 app/src/tests/unit/pages/report-output/earnings-variation/EarningsVariationInputs.test.tsx diff --git a/app/src/components/flagship/report/household/HouseholdBreakdowns.tsx b/app/src/components/flagship/report/household/HouseholdBreakdowns.tsx index 93ae4e0d0..8884ec54b 100644 --- a/app/src/components/flagship/report/household/HouseholdBreakdowns.tsx +++ b/app/src/components/flagship/report/household/HouseholdBreakdowns.tsx @@ -43,6 +43,11 @@ export interface HouseholdBreakdownSource { reform: HouseholdCalculationData; /** Whether the reform raises the household's net income: the breakdown's accent. */ gains: boolean; + /** + * The household's own amount inputs, which the earnings variation offers + * beside its calculated variables (issue #1096); none unless given. + */ + householdInputs?: string[]; } /** @@ -184,7 +189,7 @@ export default function HouseholdBreakdowns({ }; content = view === 'earnings-variation' ? ( - + ) : ( ); diff --git a/app/src/libs/flagship/householdReport.ts b/app/src/libs/flagship/householdReport.ts new file mode 100644 index 000000000..f2631caf3 --- /dev/null +++ b/app/src/libs/flagship/householdReport.ts @@ -0,0 +1,43 @@ +import type { Household } from '@/models/Household'; + +/** + * Helpers for v3's standalone household report: one household, run under + * a baseline and, optionally, a reform. + */ + +/** + * Inputs every household carries that say nothing on a chart against + * earnings: the earnings the chart varies, and age, which the household + * builder sets for everyone. + */ +const NOT_OFFERED_ON_EARNINGS_CHART = new Set(['employment_income', 'age']); + +/** Whether an input's value, or any of its yearly values, is a nonzero amount. */ +function setsAnAmount(value: unknown): boolean { + const amounts = + value && typeof value === 'object' && !Array.isArray(value) + ? Object.values(value as Record) + : [value]; + return amounts.some((amount) => typeof amount === 'number' && amount !== 0); +} + +/** + * The amounts the household itself sets, by variable name, in the order + * first met: tip income for a household with tips. The earnings variation + * offers these beside its calculated variables (issue #1096). + */ +export function householdAmountInputs(household: Household): string[] { + const variables = [ + ...household + .getAllPeople() + .flatMap((person) => household.getPersonVariableEntries(person.name)), + ...household.getConfiguredGroupsWithVariables().flatMap((group) => group.variables), + ]; + const names = variables + .filter( + (variable) => + !NOT_OFFERED_ON_EARNINGS_CHART.has(variable.name) && setsAnAmount(variable.value) + ) + .map((variable) => variable.name); + return [...new Set(names)]; +} diff --git a/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx b/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx index f393941f7..6218697ea 100644 --- a/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx +++ b/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx @@ -40,6 +40,12 @@ interface Props { reformVariation?: HouseholdCalculationOutput | null; /** What shows while the variation calculates; a spinner and a line by default. */ renderLoading?: () => ReactNode; + /** + * Inputs the household itself sets, offered beside the calculated + * variables (issue #1096): tip income, for a household with tips. Other + * inputs stay out. + */ + alsoOfferInputs?: string[]; } /** @@ -57,6 +63,7 @@ export default function EarningsVariationSubPage({ baselineVariation: providedBaselineVariation = null, reformVariation: providedReformVariation = null, renderLoading, + alsoOfferInputs, }: Props) { const [selectedVariable, setSelectedVariable] = useState('household_net_income'); const [searchOpen, setSearchOpen] = useState(false); @@ -133,7 +140,9 @@ export default function EarningsVariationSubPage({ const resolvedBaselineVariation = providedBaselineVariation ?? baselineVariation ?? null; const resolvedReformVariation = providedReformVariation ?? reformVariation ?? null; - // Build variable options (only non-input variables with array values) + // Build variable options: calculated variables with array values, and + // any of the household's own inputs it was asked to offer. + const offeredInputs = useMemo(() => new Set(alsoOfferInputs ?? []), [alsoOfferInputs]); const variableOptions = useMemo(() => { if (!reportYear || !resolvedBaselineVariation?.householdData?.people) { return []; @@ -142,8 +151,12 @@ export default function EarningsVariationSubPage({ return Object.keys(metadata.variables) .filter((varName) => { const variable = metadata.variables[varName]; - // Exclude input variables and marginal_tax_rate (has its own page) - if (!variable || variable.isInputVariable || varName === 'marginal_tax_rate') { + // Exclude other inputs and marginal_tax_rate (has its own page) + if ( + !variable || + (variable.isInputVariable && !offeredInputs.has(varName)) || + varName === 'marginal_tax_rate' + ) { return false; } @@ -161,7 +174,7 @@ export default function EarningsVariationSubPage({ value: varName, label: metadata.variables[varName]?.label || varName, })); - }, [metadata, normalizedReportYear, reportYear, resolvedBaselineVariation]); + }, [metadata, normalizedReportYear, offeredInputs, reportYear, resolvedBaselineVariation]); const sortedVariableOptions = useMemo( () => [...variableOptions].sort( diff --git a/app/src/tests/fixtures/libs/flagship/householdReportMocks.ts b/app/src/tests/fixtures/libs/flagship/householdReportMocks.ts new file mode 100644 index 000000000..c296cbc26 --- /dev/null +++ b/app/src/tests/fixtures/libs/flagship/householdReportMocks.ts @@ -0,0 +1,66 @@ +import { Household } from '@/models/Household'; +import type { HouseholdCalculationOutput } from '@/types/calculation/household'; + +export const REPORT_YEAR = '2026'; + +/** Points across earnings in a variation, as the Public API returns them (401 in production). */ +const VARIATION_POINTS = 5; +const acrossEarnings = (step: number) => + Array.from({ length: VARIATION_POINTS }, (_, pointIndex) => pointIndex * step); +const constantAcrossEarnings = (value: number) => acrossEarnings(0).map(() => value); + +/** + * A US household with tip income (issue #1096), earnings the chart varies, + * and a rent input left at zero, which says nothing about the household. + */ +export const HOUSEHOLD_WITH_TIPS = Household.starter('us', REPORT_YEAR) + .setPersonVariableAtYear('you', 'employment_income', REPORT_YEAR, 40000) + .setPersonVariableAtYear('you', 'tip_income', REPORT_YEAR, 6000) + .setPersonVariableAtYear('you', 'rent', REPORT_YEAR, 0) + .setGroupVariableAtYear('households', 'your household', 'state_name', REPORT_YEAR, 'NV'); + +/** The model's metadata for the variation's variables: tip income and age are inputs. */ +export const VARIATION_METADATA_VARIABLES = { + employment_income: { entity: 'person', label: 'Employment income', isInputVariable: false }, + tip_income: { entity: 'person', label: 'Tip income', isInputVariable: true }, + age: { entity: 'person', label: 'Age', isInputVariable: true }, + marginal_tax_rate: { entity: 'person', label: 'Marginal tax rate' }, + household_net_income: { entity: 'household', label: 'Net income', isInputVariable: false }, +}; + +/** The household calculated across earnings: every variable an array of points. */ +export const TIPS_VARIATION: HouseholdCalculationOutput = { + countryId: 'us', + householdData: { + people: { + you: { + age: { [REPORT_YEAR]: constantAcrossEarnings(30) }, + employment_income: { [REPORT_YEAR]: acrossEarnings(10000) }, + tip_income: { [REPORT_YEAR]: constantAcrossEarnings(6000) }, + }, + }, + households: { + 'your household': { + members: ['you'], + household_net_income: { [REPORT_YEAR]: acrossEarnings(9000) }, + }, + }, + }, +}; + +/** The same household calculated once, at its own earnings. */ +export const TIPS_POINT_OUTPUT: HouseholdCalculationOutput = { + countryId: 'us', + householdData: { + people: { + you: { + age: { [REPORT_YEAR]: 30 }, + employment_income: { [REPORT_YEAR]: 40000 }, + tip_income: { [REPORT_YEAR]: 6000 }, + }, + }, + households: { + 'your household': { members: ['you'], household_net_income: { [REPORT_YEAR]: 41000 } }, + }, + }, +}; diff --git a/app/src/tests/unit/components/flagship/report/HouseholdBreakdownsInputs.test.tsx b/app/src/tests/unit/components/flagship/report/HouseholdBreakdownsInputs.test.tsx new file mode 100644 index 000000000..420da4104 --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/HouseholdBreakdownsInputs.test.tsx @@ -0,0 +1,73 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import HouseholdBreakdowns, { + type HouseholdBreakdownSource, + type HouseholdBreakdownView, +} from '@/components/flagship/report/household/HouseholdBreakdowns'; +import { + BASELINE_POLICY_ID, + BASELINE_RESULT, + CREATED_HOUSEHOLD_ID, + HOUSEHOLD_YEAR, + REFORM_POLICY_ID, + REFORM_RESULT, +} from '@/tests/fixtures/libs/flagship/householdRunMocks'; + +vi.mock('@/api/policy', () => ({ + fetchPolicyById: async (_countryId: string, policyId: string) => ({ + id: policyId, + country_id: 'us', + api_version: '1', + policy_json: {}, + label: null, + }), +})); +// The model's parameters, loaded: the policies parse at once. +vi.mock('@/hooks/useParameterMetadata', () => ({ + useParameterMetadata: () => ({ parameters: {}, isReady: true }), +})); +// The v2 variation pages, tested on their own: stand-ins show the inputs +// the earnings variation is asked to offer. +vi.mock('@/pages/report-output/earnings-variation/EarningsVariationSubPage', () => ({ + default: ({ alsoOfferInputs }: { alsoOfferInputs?: string[] }) => ( +
{(alsoOfferInputs ?? []).join(' ') || 'none'}
+ ), +})); +vi.mock('@/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage', () => ({ + default: () =>
, +})); + +const SOURCE: HouseholdBreakdownSource = { + countryId: 'us', + year: HOUSEHOLD_YEAR, + householdId: String(CREATED_HOUSEHOLD_ID), + baselinePolicyId: BASELINE_POLICY_ID, + reformPolicyId: REFORM_POLICY_ID, + baseline: BASELINE_RESULT, + reform: REFORM_RESULT, + gains: true, +}; + +function renderBreakdowns(view: HouseholdBreakdownView, source: HouseholdBreakdownSource) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + ); +} + +describe('HouseholdBreakdowns household inputs (issue #1096)', () => { + test("given the household's own amount inputs then the earnings variation offers them", async () => { + renderBreakdowns('earnings-variation', { ...SOURCE, householdInputs: ['tip_income'] }); + + expect(await screen.findByLabelText('Earnings variation')).toHaveTextContent('tip_income'); + }); + + test('given no household inputs then the earnings variation offers none, as in the report section', async () => { + renderBreakdowns('earnings-variation', SOURCE); + + expect(await screen.findByLabelText('Earnings variation')).toHaveTextContent('none'); + }); +}); diff --git a/app/src/tests/unit/libs/flagship/householdReport.test.ts b/app/src/tests/unit/libs/flagship/householdReport.test.ts new file mode 100644 index 000000000..456194d0f --- /dev/null +++ b/app/src/tests/unit/libs/flagship/householdReport.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from 'vitest'; +import { householdAmountInputs } from '@/libs/flagship/householdReport'; +import { HOUSEHOLD_WITH_TIPS } from '@/tests/fixtures/libs/flagship/householdReportMocks'; + +describe('householdAmountInputs', () => { + test('given a household with tip income then its amounts are listed, but not the earnings the chart varies, age, or amounts left at zero', () => { + expect(householdAmountInputs(HOUSEHOLD_WITH_TIPS)).toEqual(['tip_income']); + }); +}); diff --git a/app/src/tests/unit/pages/report-output/earnings-variation/EarningsVariationInputs.test.tsx b/app/src/tests/unit/pages/report-output/earnings-variation/EarningsVariationInputs.test.tsx new file mode 100644 index 000000000..ec419a730 --- /dev/null +++ b/app/src/tests/unit/pages/report-output/earnings-variation/EarningsVariationInputs.test.tsx @@ -0,0 +1,75 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent } from '@test-utils'; +import { Provider } from 'react-redux'; +import { describe, expect, test, vi } from 'vitest'; +import EarningsVariationSubPage from '@/pages/report-output/earnings-variation/EarningsVariationSubPage'; +import metadataReducer from '@/reducers/metadataReducer'; +import { + REPORT_YEAR, + TIPS_POINT_OUTPUT, + TIPS_VARIATION, + VARIATION_METADATA_VARIABLES, +} from '@/tests/fixtures/libs/flagship/householdReportMocks'; + +vi.mock('@/hooks/useReportYear', () => ({ useReportYear: () => REPORT_YEAR })); +// The chart is tested on its own; here only the variable choice matters. +vi.mock('@/pages/report-output/earnings-variation/BaselineOnlyChart', () => ({ + default: () =>

Earnings chart

, +})); + +function renderSubPage(alsoOfferInputs?: string[]) { + const store = configureStore({ + reducer: { metadata: metadataReducer }, + preloadedState: { + metadata: { + ...metadataReducer(undefined, { type: 'test/init' }), + currentCountry: 'us', + version: 'test-model', + variables: VARIATION_METADATA_VARIABLES as any, + entities: { person: { plural: 'people' }, household: { plural: 'households' } } as any, + }, + }, + }); + render( + + + + + + ); +} + +/** The variables the chart's variable menu offers. */ +async function offeredVariables() { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Net income' })); + return screen.getAllByRole('button').map((button) => button.textContent ?? ''); +} + +describe('EarningsVariationSubPage variables (issue #1096)', () => { + test('given no inputs to offer then only calculated variables are offered, as before', async () => { + renderSubPage(); + + const offered = await offeredVariables(); + + expect(offered.some((text) => text.includes('Net income'))).toBe(true); + expect(offered.some((text) => text.includes('Tip income'))).toBe(false); + }); + + test('given inputs the household sets then they are offered beside the calculated variables', async () => { + renderSubPage(['tip_income']); + + const offered = await offeredVariables(); + + expect(offered.some((text) => text.includes('Tip income'))).toBe(true); + // An input the household was not asked to offer is still left out. + expect(offered.some((text) => text.startsWith('Age'))).toBe(false); + }); +}); From 0924c101b5e7e55760fdb286e1f0b57c88339c47 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:27:02 +0400 Subject: [PATCH 09/29] v3 household builder: one configuration for the report section and the 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 --- .../household/HouseholdBuilderPanel.tsx | 136 ++++++++++++++++ .../household/HouseholdImpactSection.tsx | 102 +----------- .../household/HouseholdBuilderPanel.test.tsx | 154 ++++++++++++++++++ 3 files changed, 298 insertions(+), 94 deletions(-) create mode 100644 app/src/components/flagship/household/HouseholdBuilderPanel.tsx create mode 100644 app/src/tests/unit/components/flagship/household/HouseholdBuilderPanel.test.tsx diff --git a/app/src/components/flagship/household/HouseholdBuilderPanel.tsx b/app/src/components/flagship/household/HouseholdBuilderPanel.tsx new file mode 100644 index 000000000..c4282c0fa --- /dev/null +++ b/app/src/components/flagship/household/HouseholdBuilderPanel.tsx @@ -0,0 +1,136 @@ +import { useCallback, useMemo, type ComponentProps } from 'react'; +import { useSelector } from 'react-redux'; +import { + commonHouseholdInputs, + householdInputDescriptionOf, + householdInputNameOf, + householdTopicIcon, +} from '@/components/flagship/report/household/householdPickerInputs'; +import HouseholdBuilderForm from '@/components/household/HouseholdBuilderForm'; +import type { CountryId } from '@/libs/countries'; +import { householdState } from '@/libs/flagship/householdDescription'; +import { + describesAHousehold, + HOUSEHOLD_INCOME_SUBGROUPS, + HOUSEHOLD_VARIABLE_GROUPS, + householdVariableGroupOf, + householdVariableSubgroupOf, + listsForState, + twinsABasicInput, +} from '@/libs/flagship/householdVariableGroups'; +import { getBasicInputFields } from '@/libs/metadataUtils'; +import type { Household } from '@/models/Household'; +import type { RootState } from '@/store'; +import type { VariableInfo } from '@/utils/VariableResolver'; + +type BuilderFormProps = ComponentProps; + +/** + * A person's name as a country's builder shows it: a UK household's + * dependants are its children ("your first child"), as the UK model's + * benefits count them; the US keeps "dependent", which a tax unit's + * dependants are called. Only the name shown changes, never the key. + */ +export function householdPersonNameOf( + countryId: string +): ((personKey: string) => string) | undefined { + return countryId === 'uk' + ? (personKey) => personKey.replace(/\bdependent\b/, 'child') + : undefined; +} + +/** + * The household builder as v3 configures it, for the report section and + * the standalone household report: every input the model takes, grouped + * by topic with the country's featured inputs first; the variable search + * lists only the household's own state's programs, only inputs that + * describe a household (no survey-reported totals), and not the twin of a + * field the builder already shows; the US SPM geography choice; and each + * input labelled. + */ +export default function HouseholdBuilderPanel({ + household, + countryId, + year, + onChange, + disabled, + suggestedVariables, + onVariablePickerOpen, + onVariableAdd, +}: { + household: Household; + countryId: CountryId; + year: string; + onChange: (household: Household) => void; + disabled?: boolean; + /** Inputs the reform reads, which lead the variable search as related to it. */ + suggestedVariables?: string[]; + onVariablePickerOpen?: BuilderFormProps['onVariablePickerOpen']; + onVariableAdd?: BuilderFormProps['onVariableAdd']; +}) { + const metadata = useSelector((state: RootState) => state.metadata); + const basicInputFields = useSelector(getBasicInputFields); + const composition = useMemo(() => household.deriveBuilderComposition(year), [household, year]); + const basicNonPersonFields = useMemo( + () => + Object.entries(basicInputFields) + .filter(([key]) => key !== 'person') + .flatMap(([, fields]) => fields), + [basicInputFields] + ); + const stateCode = householdState(household, year, countryId); + const basicPersonFields = basicInputFields.person; + const variableFilter = useCallback( + (variable: VariableInfo) => + listsForState(variable, stateCode) && + describesAHousehold(variable, countryId) && + !twinsABasicInput(variable, basicPersonFields ?? []), + [stateCode, countryId, basicPersonFields] + ); + // The picker's groups, featured inputs and names, for the country's model. + const picker = useMemo( + () => ({ + groupOf: householdVariableGroupOf(countryId), + subgroupOf: householdVariableSubgroupOf(countryId), + common: commonHouseholdInputs(countryId), + nameOf: householdInputNameOf(countryId), + describe: householdInputDescriptionOf(countryId), + personNameOf: householdPersonNameOf(countryId), + }), + [countryId] + ); + + return ( + onChange(household.withBuilderMaritalStatus(year, status))} + onNumChildrenChange={(count) => onChange(household.withBuilderChildCount(year, count))} + disabled={disabled} + layout="columns" + suggestedVariables={suggestedVariables} + suggestedLabel="Related to this reform" + searchRanking="relevance" + variableGroupOf={picker.groupOf} + variableGroupOrder={HOUSEHOLD_VARIABLE_GROUPS} + variableFilter={variableFilter} + variableSubgroupOf={picker.subgroupOf} + variableSubgroupOrder={HOUSEHOLD_INCOME_SUBGROUPS} + searchByEntity + variablePickerCommon={picker.common} + variableGroupIcon={householdTopicIcon} + variableNameOf={picker.nameOf} + variableDescribe={picker.describe} + personDisplayNameOf={picker.personNameOf} + labelInputs + onVariablePickerOpen={onVariablePickerOpen} + onVariableAdd={onVariableAdd} + /> + ); +} diff --git a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx index 88d7d5dc5..a0421d34a 100644 --- a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx +++ b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx @@ -1,36 +1,22 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { IconPencil, IconPlus, IconTrash } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; +import HouseholdBuilderPanel from '@/components/flagship/household/HouseholdBuilderPanel'; import HouseholdImpactResult from '@/components/flagship/report/household/HouseholdImpactResult'; -import { - commonHouseholdInputs, - householdInputDescriptionOf, - householdInputNameOf, - householdTopicIcon, -} from '@/components/flagship/report/household/householdPickerInputs'; +import { commonHouseholdInputs } from '@/components/flagship/report/household/householdPickerInputs'; import RunProgress from '@/components/flagship/report/RunProgress'; import { ErrorState } from '@/components/flagship/states'; -import HouseholdBuilderForm from '@/components/household/HouseholdBuilderForm'; import { Button, Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { useFlagshipHouseholdPayload } from '@/hooks/useFlagshipHousehold'; import type { CountryId } from '@/libs/countries'; -import { describeHousehold, householdState } from '@/libs/flagship/householdDescription'; +import { describeHousehold } from '@/libs/flagship/householdDescription'; import { suggestedHouseholdInputs } from '@/libs/flagship/householdSuggestions'; -import { - describesAHousehold, - HOUSEHOLD_INCOME_SUBGROUPS, - HOUSEHOLD_VARIABLE_GROUPS, - householdVariableGroupOf, - householdVariableSubgroupOf, - listsForState, - twinsABasicInput, -} from '@/libs/flagship/householdVariableGroups'; +import { describesAHousehold } from '@/libs/flagship/householdVariableGroups'; import { hasParameterDependencies, loadParameterDependencies, } from '@/libs/flagship/parameterDependencies'; -import { getBasicInputFields } from '@/libs/metadataUtils'; import { Household } from '@/models/Household'; import type { V1HouseholdCreateEnvelope } from '@/models/household/v1Types'; import type { RootState } from '@/store'; @@ -44,7 +30,7 @@ import { trackHouseholdVariableAdded, } from '@/utils/analytics'; import { HouseholdValidation } from '@/utils/HouseholdValidation'; -import { getInputVariables, type VariableInfo } from '@/utils/VariableResolver'; +import { getInputVariables } from '@/utils/VariableResolver'; /** The countries whose results, inputs and currency the section knows. */ const HOUSEHOLD_COUNTRIES = new Set(['us', 'uk']); @@ -94,20 +80,6 @@ function newHousehold(countryId: CountryId, year: string, region?: string): Hous ); } -/** - * A person's name as a country's builder shows it: a UK household's - * dependants are its children ("your first child"), as the UK model's - * benefits count them; the US keeps "dependent", which a tax unit's - * dependants are called. Only the name shown changes, never the key. - */ -export function householdPersonNameOf( - countryId: string -): ((personKey: string) => string) | undefined { - return countryId === 'uk' - ? (personKey) => personKey.replace(/\bdependent\b/, 'child') - : undefined; -} - /** What the builder sets for each person it adds: not an input the reader chose. */ const ADDED_PERSON_DEFAULTS = new Set(['age', 'is_tax_unit_dependent']); @@ -355,7 +327,6 @@ function HouseholdCard({ // The household as the builder edits it: every input the model takes, // per person and per household, as in the household builder. const metadata = useSelector((state: RootState) => state.metadata); - const basicInputFields = useSelector(getBasicInputFields); const [household, setHousehold] = useState(() => newHousehold(countryId, year, region)); const [requested, setRequested] = useState<{ payload: V1HouseholdCreateEnvelope; @@ -367,38 +338,6 @@ function HouseholdCard({ reformPolicyId, payload: requested?.payload ?? null, }); - const composition = useMemo(() => household.deriveBuilderComposition(year), [household, year]); - const basicNonPersonFields = useMemo( - () => - Object.entries(basicInputFields) - .filter(([key]) => key !== 'person') - .flatMap(([, fields]) => fields), - [basicInputFields] - ); - // The variable search lists only the household's own state's programs, - // only inputs that describe a household (no survey-reported totals), and - // not the twin of a field the builder already shows. - const stateCode = householdState(household, year, countryId); - const basicPersonFields = basicInputFields.person; - const variableFilter = useCallback( - (variable: VariableInfo) => - listsForState(variable, stateCode) && - describesAHousehold(variable, countryId) && - !twinsABasicInput(variable, basicPersonFields ?? []), - [stateCode, countryId, basicPersonFields] - ); - // The picker's groups, featured inputs and names, for the country's model. - const picker = useMemo( - () => ({ - groupOf: householdVariableGroupOf(countryId), - subgroupOf: householdVariableSubgroupOf(countryId), - common: commonHouseholdInputs(countryId), - nameOf: householdInputNameOf(countryId), - describe: householdInputDescriptionOf(countryId), - personNameOf: householdPersonNameOf(countryId), - }), - [countryId] - ); // Whether the household can run, as the household builder checks it. const validation = useMemo( () => HouseholdValidation.isReadyForSimulation(household, countryId, year, metadata), @@ -553,38 +492,13 @@ function HouseholdCard({
)}
- - setHousehold(household.withBuilderMaritalStatus(year, status)) - } - onNumChildrenChange={(count) => - setHousehold(household.withBuilderChildCount(year, count)) - } disabled={running} - layout="columns" suggestedVariables={suggestedVariables} - suggestedLabel="Related to this reform" - searchRanking="relevance" - variableGroupOf={picker.groupOf} - variableGroupOrder={HOUSEHOLD_VARIABLE_GROUPS} - variableFilter={variableFilter} - variableSubgroupOf={picker.subgroupOf} - variableSubgroupOrder={HOUSEHOLD_INCOME_SUBGROUPS} - searchByEntity - variablePickerCommon={picker.common} - variableGroupIcon={householdTopicIcon} - variableNameOf={picker.nameOf} - variableDescribe={picker.describe} - personDisplayNameOf={picker.personNameOf} - labelInputs onVariablePickerOpen={(entity) => trackHouseholdPickerOpened({ countryId, entity })} onVariableAdd={({ variable, source, entity }) => trackHouseholdVariableAdded({ countryId, variable, source, entity }) diff --git a/app/src/tests/unit/components/flagship/household/HouseholdBuilderPanel.test.tsx b/app/src/tests/unit/components/flagship/household/HouseholdBuilderPanel.test.tsx new file mode 100644 index 000000000..139244497 --- /dev/null +++ b/app/src/tests/unit/components/flagship/household/HouseholdBuilderPanel.test.tsx @@ -0,0 +1,154 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { render, screen, userEvent } from '@test-utils'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import HouseholdBuilderPanel from '@/components/flagship/household/HouseholdBuilderPanel'; +import { commonHouseholdInputs } from '@/components/flagship/report/household/householdPickerInputs'; +import { Household } from '@/models/Household'; +import metadataReducer from '@/reducers/metadataReducer'; +import type { VariableInfo } from '@/utils/VariableResolver'; + +const PANEL_YEAR = '2026'; + +/** Inputs the variable search could list, each standing for a rule the panel applies. */ +const CANDIDATE_INPUTS: VariableInfo[] = [ + { name: 'tip_income', moduleName: 'household.income.person.tip_income' }, + { name: 'ca_rent', moduleName: 'gov.states.ca.tax.income.credits.renters' }, + { name: 'ny_rent', moduleName: 'gov.states.ny.tax.income.credits.renters' }, + { name: 'employment_income_reported', moduleName: 'household.income.person' }, + { name: 'employment_income_before_lsr', moduleName: 'household.income.person' }, +] as VariableInfo[]; + +// The household builder, tested on its own: a stand-in shows how the panel +// configures it and makes the calls it makes. +vi.mock('@/components/household/HouseholdBuilderForm', () => ({ + default: (props: any) => ( +
input.name) + .join(' ')} + data-listed={CANDIDATE_INPUTS.filter(props.variableFilter) + .map((variable) => variable.name) + .join(' ')} + data-child-name={ + props.personDisplayNameOf?.('your first dependent') ?? 'your first dependent' + } + data-label-inputs={String(!!props.labelInputs)} + data-suggested={`${props.suggestedLabel}: ${(props.suggestedVariables ?? []).join(' ')}`} + data-disabled={String(!!props.disabled)} + > + + + +
+ ), +})); + +const californiaHousehold = Household.starter('us', PANEL_YEAR).setGroupVariableAtYear( + 'households', + 'your household', + 'state_name', + PANEL_YEAR, + 'CA' +); + +/** Model information whose builder always shows a person's age and employment income. */ +function storeWithBasicInputs() { + return configureStore({ + reducer: { metadata: metadataReducer }, + preloadedState: { + metadata: { + ...metadataReducer(undefined, { type: 'test/init' }), + currentCountry: 'us', + basicInputs: ['age', 'employment_income'], + variables: { + age: { name: 'age', entity: 'person' }, + employment_income: { name: 'employment_income', entity: 'person' }, + } as any, + entities: { person: { plural: 'people', is_person: true } } as any, + }, + }, + }); +} + +function renderPanel(props: Partial> = {}) { + const onChange = vi.fn(); + render( + + + + ); + return { onChange, builder: screen.getByLabelText('Household builder') }; +} + +describe('HouseholdBuilderPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("given a US household then the search lists its own state's programs, and no survey totals or twins of basic fields", () => { + const { builder } = renderPanel(); + + expect(builder).toHaveAttribute('data-listed', 'tip_income ca_rent'); + }); + + test("given a country then the builder features that country's inputs and names", () => { + const { builder } = renderPanel({ + household: Household.starter('uk', PANEL_YEAR), + countryId: 'uk', + }); + + expect(builder).toHaveAttribute( + 'data-featured', + commonHouseholdInputs('uk') + .map((input) => input.name) + .join(' ') + ); + expect(builder).toHaveAttribute('data-child-name', 'your first child'); + expect(builder).toHaveAttribute('data-label-inputs', 'true'); + }); + + test('given suggested inputs then the builder leads with them as related to the reform', () => { + const { builder } = renderPanel({ suggestedVariables: ['tip_income'] }); + + expect(builder).toHaveAttribute('data-suggested', 'Related to this reform: tip_income'); + }); + + test('given the marital status and children chosen then the household changes to match', async () => { + const user = userEvent.setup(); + const { onChange, builder } = renderPanel(); + expect(builder).toHaveAttribute('data-composition', 'single · 0'); + + await user.click(screen.getByRole('button', { name: 'Married' })); + await user.click(screen.getByRole('button', { name: 'Two children' })); + + expect(onChange.mock.calls[0][0].deriveBuilderComposition(PANEL_YEAR).maritalStatus).toBe( + 'married' + ); + expect(onChange.mock.calls[1][0].deriveBuilderComposition(PANEL_YEAR).numChildren).toBe(2); + }); + + test("given the caller's analytics then the builder tells it when a search opens", async () => { + const user = userEvent.setup(); + const onVariablePickerOpen = vi.fn(); + renderPanel({ onVariablePickerOpen }); + + await user.click(screen.getByRole('button', { name: 'Open search' })); + + expect(onVariablePickerOpen).toHaveBeenCalledWith('person'); + }); +}); From 45fd8039e8fd224b56078e60ca6f5919d65a792c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:29:10 +0400 Subject: [PATCH 10/29] v3 household results: net income under one policy, for baseline-only 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 --- .../household/HouseholdNetIncomeResult.tsx | 103 ++++++++++++++++ .../household/HouseholdImpactResult.tsx | 94 ++++---------- .../report/household/householdResultParts.tsx | 90 ++++++++++++++ .../HouseholdNetIncomeResult.test.tsx | 116 ++++++++++++++++++ 4 files changed, 330 insertions(+), 73 deletions(-) create mode 100644 app/src/components/flagship/household/HouseholdNetIncomeResult.tsx create mode 100644 app/src/components/flagship/report/household/householdResultParts.tsx create mode 100644 app/src/tests/unit/components/flagship/household/HouseholdNetIncomeResult.test.tsx diff --git a/app/src/components/flagship/household/HouseholdNetIncomeResult.tsx b/app/src/components/flagship/household/HouseholdNetIncomeResult.tsx new file mode 100644 index 000000000..8416ebaaf --- /dev/null +++ b/app/src/components/flagship/household/HouseholdNetIncomeResult.tsx @@ -0,0 +1,103 @@ +import { useId, useState } from 'react'; +import HouseholdBreakdowns, { + HOUSEHOLD_BREAKDOWN_VIEWS, + type HouseholdBreakdownSource, + type HouseholdBreakdownView, +} from '@/components/flagship/report/household/HouseholdBreakdowns'; +import { + householdMoney, + HouseholdOutputTabs, + householdResultCardStyle, +} from '@/components/flagship/report/household/householdResultParts'; +import SPMMethodologyFootnote from '@/components/household/SPMMethodologyFootnote'; +import { Stack, Text, Title } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; +import type { CountryId } from '@/libs/countries'; +import type { HouseholdCalculationProvenance } from '@/libs/flagship/householdRun'; + +/** + * A household's result under one policy, with no reform to compare: its + * annual net income, then the v2 breakdowns of it (the net income tree, + * varying earnings, marginal tax rates), each under that policy alone. + */ +export default function HouseholdNetIncomeResult({ + netIncome, + summary, + breakdowns, + countryId, + methodology, +}: { + /** The household's annual net income under the policy. */ + netIncome: number; + /** Who the result is for, e.g. "Married · 2 children · California · 2026". */ + summary: string; + /** The run the breakdowns read; its baseline and reform policies are the same one. */ + breakdowns: HouseholdBreakdownSource; + /** The household's country: its currency. */ + countryId: CountryId; + /** + * How the calculation was made: a US calculation's SPM settings and + * receipt show as a methodology footnote under the net income tree. + */ + methodology?: HouseholdCalculationProvenance; +}) { + const [view, setView] = useState('net-income'); + const id = useId(); + + return ( +
+ + + Household net income + + + {summary} + + + +
+ + Annual net income + + + {householdMoney(netIncome, countryId)} + +
+ + + +
+ +
+ + {/* The variation views show their own calculation's methodology. */} + {methodology && view === 'net-income' && } +
+ ); +} diff --git a/app/src/components/flagship/report/household/HouseholdImpactResult.tsx b/app/src/components/flagship/report/household/HouseholdImpactResult.tsx index 3a71f11da..8d7a6f322 100644 --- a/app/src/components/flagship/report/household/HouseholdImpactResult.tsx +++ b/app/src/components/flagship/report/household/HouseholdImpactResult.tsx @@ -4,6 +4,12 @@ import HouseholdBreakdowns, { type HouseholdBreakdownSource, type HouseholdBreakdownView, } from '@/components/flagship/report/household/HouseholdBreakdowns'; +import { + HouseholdOutputTabs, + householdResultCardStyle, + householdMoney as money, + signedHouseholdMoney as signedMoney, +} from '@/components/flagship/report/household/householdResultParts'; import SPMMethodologyFootnote from '@/components/household/SPMMethodologyFootnote'; import { Stack, Text, Title } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; @@ -14,20 +20,6 @@ import type { HouseholdLineChange, } from '@/libs/flagship/householdRun'; import { trackHouseholdOutputViewed, type HouseholdOutputTab } from '@/utils/analytics'; -import { formatCurrency } from '@/utils/formatters'; - -/** Whole dollars (or pounds), with a true minus sign: "−$1,200", "−£1,200". */ -function money(value: number, countryId: CountryId): string { - const rounded = Math.round(value); - const text = formatCurrency(Math.abs(rounded), countryId, { maximumFractionDigits: 0 }); - return rounded < 0 ? `−${text}` : text; -} - -/** A change, always signed: "+$1,200", "−$300", "$0". */ -function signedMoney(value: number, countryId: CountryId): string { - const rounded = Math.round(value); - return rounded > 0 ? `+${money(rounded, countryId)}` : money(rounded, countryId); -} /** Gains read in the brand teal, losses in red. */ const effectColor = (effect: number) => @@ -176,7 +168,10 @@ function SummaryTables({ ); } -const VIEW_OPTIONS = [{ value: 'summary', label: 'Summary' }, ...HOUSEHOLD_BREAKDOWN_VIEWS]; +const VIEW_OPTIONS: ReadonlyArray<{ value: 'summary' | HouseholdBreakdownView; label: string }> = [ + { value: 'summary', label: 'Summary' }, + ...HOUSEHOLD_BREAKDOWN_VIEWS, +]; /** Each output tab as analytics names it. */ const VIEW_ANALYTICS_NAMES: Record<'summary' | HouseholdBreakdownView, HouseholdOutputTab> = { @@ -234,18 +229,7 @@ export default function HouseholdImpactResult({ .slice(0, 3); return ( -
+
Household impact @@ -353,53 +337,17 @@ export default function HouseholdImpactResult({ {breakdowns && ( // A tab per output: the summary, then the v2 household report's breakdowns. - <div - role="tablist" - aria-label="Household outputs" - style={{ - display: 'flex', - gap: spacing.lg, - overflowX: 'auto', - borderBottom: `1px solid ${colors.border.light}`, + <HouseholdOutputTabs + options={VIEW_OPTIONS} + selected={view} + onSelect={(next) => { + // Sent per tab opened: a click on the open tab sends nothing. + if (next !== view) { + trackHouseholdOutputViewed({ countryId, tab: VIEW_ANALYTICS_NAMES[next] }); + } + setView(next); }} - > - {VIEW_OPTIONS.map((option) => { - const selected = view === option.value; - return ( - <button - key={option.value} - type="button" - role="tab" - aria-selected={selected} - onClick={() => { - const next = option.value as typeof view; - // Sent per tab opened: a click on the open tab sends nothing. - if (next !== view) { - trackHouseholdOutputViewed({ countryId, tab: VIEW_ANALYTICS_NAMES[next] }); - } - setView(next); - }} - className="tw:cursor-pointer tw:hover:text-gray-900" - style={{ - border: 'none', - background: 'none', - padding: `${spacing.sm} 0`, - marginBottom: -1, - borderBottom: `2px solid ${selected ? colors.primary[600] : 'transparent'}`, - whiteSpace: 'nowrap', - fontSize: typography.fontSize.sm, - fontFamily: typography.fontFamily.primary, - fontWeight: selected - ? typography.fontWeight.semibold - : typography.fontWeight.normal, - color: selected ? colors.text.primary : colors.text.secondary, - }} - > - {option.label} - </button> - ); - })} - </div> + /> )} {breakdowns && view !== 'summary' ? ( diff --git a/app/src/components/flagship/report/household/householdResultParts.tsx b/app/src/components/flagship/report/household/householdResultParts.tsx new file mode 100644 index 000000000..9c24be814 --- /dev/null +++ b/app/src/components/flagship/report/household/householdResultParts.tsx @@ -0,0 +1,90 @@ +import type { CSSProperties } from 'react'; +import { colors, spacing, typography } from '@/designTokens'; +import type { CountryId } from '@/libs/countries'; +import { formatCurrency } from '@/utils/formatters'; + +/** + * What v3's household results share: the household's change in net + * income with a reform (HouseholdImpactResult) and its net income under + * one policy (HouseholdNetIncomeResult). + */ + +/** Whole dollars (or pounds), with a true minus sign: "−$1,200", "−£1,200". */ +export function householdMoney(value: number, countryId: CountryId): string { + const rounded = Math.round(value); + const text = formatCurrency(Math.abs(rounded), countryId, { maximumFractionDigits: 0 }); + return rounded < 0 ? `−${text}` : text; +} + +/** A change, always signed: "+$1,200", "−$300", "$0". */ +export function signedHouseholdMoney(value: number, countryId: CountryId): string { + const rounded = Math.round(value); + return rounded > 0 + ? `+${householdMoney(rounded, countryId)}` + : householdMoney(rounded, countryId); +} + +/** The bordered card each household result sits in. */ +export const householdResultCardStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: spacing.lg, + padding: spacing.lg, + border: `1px solid ${colors.border.light}`, + borderRadius: spacing.radius.container, + background: colors.background.primary, +}; + +/** A tab per household output, under the result's headline. */ +export function HouseholdOutputTabs<View extends string>({ + options, + selected, + onSelect, +}: { + options: ReadonlyArray<{ value: View; label: string }>; + selected: View; + onSelect: (view: View) => void; +}) { + return ( + <div + role="tablist" + aria-label="Household outputs" + style={{ + display: 'flex', + gap: spacing.lg, + overflowX: 'auto', + borderBottom: `1px solid ${colors.border.light}`, + }} + > + {options.map((option) => { + const isSelected = option.value === selected; + return ( + <button + key={option.value} + type="button" + role="tab" + aria-selected={isSelected} + onClick={() => onSelect(option.value)} + className="tw:cursor-pointer tw:hover:text-gray-900" + style={{ + border: 'none', + background: 'none', + padding: `${spacing.sm} 0`, + marginBottom: -1, + borderBottom: `2px solid ${isSelected ? colors.primary[600] : 'transparent'}`, + whiteSpace: 'nowrap', + fontSize: typography.fontSize.sm, + fontFamily: typography.fontFamily.primary, + fontWeight: isSelected + ? typography.fontWeight.semibold + : typography.fontWeight.normal, + color: isSelected ? colors.text.primary : colors.text.secondary, + }} + > + {option.label} + </button> + ); + })} + </div> + ); +} diff --git a/app/src/tests/unit/components/flagship/household/HouseholdNetIncomeResult.test.tsx b/app/src/tests/unit/components/flagship/household/HouseholdNetIncomeResult.test.tsx new file mode 100644 index 000000000..f214a9e82 --- /dev/null +++ b/app/src/tests/unit/components/flagship/household/HouseholdNetIncomeResult.test.tsx @@ -0,0 +1,116 @@ +import { render, screen, userEvent, within } from '@test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import HouseholdNetIncomeResult from '@/components/flagship/household/HouseholdNetIncomeResult'; +import type { HouseholdBreakdownSource } from '@/components/flagship/report/household/HouseholdBreakdowns'; +import { + BASELINE_POLICY_ID, + BASELINE_RESULT, + CREATED_HOUSEHOLD_ID, + HOUSEHOLD_YEAR, + NATIONAL_SPM_CONFIG, +} from '@/tests/fixtures/libs/flagship/householdRunMocks'; +import { SPM_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; + +// The v2 breakdowns, tested on their own: a stand-in names the one shown +// and the policies it compares. +vi.mock('@/components/flagship/report/household/HouseholdBreakdowns', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('@/components/flagship/report/household/HouseholdBreakdowns') + >()), + default: ({ view, source }: { view: string; source: HouseholdBreakdownSource }) => ( + <div aria-label="Breakdown">{`${view} · ${source.baselinePolicyId} · ${source.reformPolicyId}`}</div> + ), +})); + +/** One policy's run: the breakdowns compare the policy with itself. */ +const BASELINE_ONLY_SOURCE: HouseholdBreakdownSource = { + countryId: 'us', + year: HOUSEHOLD_YEAR, + householdId: String(CREATED_HOUSEHOLD_ID), + baselinePolicyId: BASELINE_POLICY_ID, + reformPolicyId: BASELINE_POLICY_ID, + baseline: BASELINE_RESULT, + reform: BASELINE_RESULT, + gains: false, +}; + +const NATIONAL_THRESHOLDS_PROVENANCE = { + policyengineBundle: null, + spmConfig: NATIONAL_SPM_CONFIG, + spmProvenance: SPM_RECEIPT, +}; + +const SUMMARY = 'Married · 2 children · New York · 2026'; + +const pointMethodology = () => + screen.queryByRole('region', { name: 'SPM point calculation methodology' }); + +describe('HouseholdNetIncomeResult', () => { + test('given a US household then its annual net income reads in whole dollars, with who it is for', () => { + render( + <HouseholdNetIncomeResult + netIncome={52300.4} + summary={SUMMARY} + countryId="us" + breakdowns={BASELINE_ONLY_SOURCE} + /> + ); + + expect(screen.getByRole('group', { name: 'Annual net income' })).toHaveTextContent('$52,300'); + expect(screen.getByText(SUMMARY)).toBeInTheDocument(); + }); + + test('given a UK household then its net income reads in pounds', () => { + render( + <HouseholdNetIncomeResult + netIncome={25425.55} + summary="Single · no children · 2026" + countryId="uk" + breakdowns={{ ...BASELINE_ONLY_SOURCE, countryId: 'uk' }} + /> + ); + + expect(screen.getByRole('group', { name: 'Annual net income' })).toHaveTextContent('£25,426'); + }); + + test('given the result then the net income tree shows first, under the one policy, and no summary is offered', () => { + render( + <HouseholdNetIncomeResult + netIncome={52300} + summary={SUMMARY} + countryId="us" + breakdowns={BASELINE_ONLY_SOURCE} + /> + ); + + const tabs = within(screen.getByRole('tablist', { name: 'Household outputs' })); + expect(tabs.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([ + 'Net income', + 'Varying earnings', + 'Marginal tax rates', + ]); + expect(tabs.getByRole('tab', { name: 'Net income' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByLabelText('Breakdown')).toHaveTextContent( + `net-income · ${BASELINE_POLICY_ID} · ${BASELINE_POLICY_ID}` + ); + }); + + test('given SPM provenance then the methodology shows under the net income tree, and not under the variation views', async () => { + const user = userEvent.setup(); + render( + <HouseholdNetIncomeResult + netIncome={52300} + summary={SUMMARY} + countryId="us" + breakdowns={BASELINE_ONLY_SOURCE} + methodology={NATIONAL_THRESHOLDS_PROVENANCE} + /> + ); + expect(pointMethodology()).toBeInTheDocument(); + + await user.click(screen.getByRole('tab', { name: 'Varying earnings' })); + + expect(screen.getByLabelText('Breakdown')).toHaveTextContent('earnings-variation'); + expect(pointMethodology()).not.toBeInTheDocument(); + }); +}); From 2c89dfbca1b083e2ee4b20789c4a6e406b568a1f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:34:58 +0400 Subject: [PATCH 11/29] v3 standalone household report 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> --- .../household/HouseholdImpactSection.tsx | 49 +- app/src/libs/flagship/householdReport.ts | 51 +- app/src/libs/flagship/householdRun.ts | 6 +- .../pages/flagship/HouseholdReport.page.tsx | 448 +++++++++++++++ .../tests/fixtures/api/savedHouseholdMocks.ts | 12 +- .../flagship/HouseholdReport.page.test.tsx | 525 ++++++++++++++++++ 6 files changed, 1038 insertions(+), 53 deletions(-) create mode 100644 app/src/pages/flagship/HouseholdReport.page.tsx create mode 100644 app/src/tests/unit/pages/flagship/HouseholdReport.page.test.tsx diff --git a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx index a0421d34a..9b64b0c09 100644 --- a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx +++ b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx @@ -11,13 +11,14 @@ import { colors, spacing, typography } from '@/designTokens'; import { useFlagshipHouseholdPayload } from '@/hooks/useFlagshipHousehold'; import type { CountryId } from '@/libs/countries'; import { describeHousehold } from '@/libs/flagship/householdDescription'; +import { newV3Household } from '@/libs/flagship/householdReport'; import { suggestedHouseholdInputs } from '@/libs/flagship/householdSuggestions'; import { describesAHousehold } from '@/libs/flagship/householdVariableGroups'; import { hasParameterDependencies, loadParameterDependencies, } from '@/libs/flagship/parameterDependencies'; -import { Household } from '@/models/Household'; +import type { Household } from '@/models/Household'; import type { V1HouseholdCreateEnvelope } from '@/models/household/v1Types'; import type { RootState } from '@/store'; import { @@ -38,48 +39,6 @@ const HOUSEHOLD_COUNTRIES = new Set(['us', 'uk']); /** Both policies run at once; a household new to the API takes about three minutes. */ const EXPECTED_MS = 3 * 60 * 1000; -/** - * Where a household in each UK nation lives unless the reader says - * otherwise: the nation's region, with its capital's local authority and - * Broad Rental Market Area, so the three name one place. England has no - * one region; its households keep the model's defaults. - */ -const UK_NATION_PLACES: Record<string, Record<string, string>> = { - scotland: { region: 'SCOTLAND', local_authority: 'CITY_OF_EDINBURGH', brma: 'LOTHIAN' }, - wales: { region: 'WALES', local_authority: 'CARDIFF', brma: 'CARDIFF' }, - northern_ireland: { region: 'NORTHERN_IRELAND', local_authority: 'BELFAST', brma: 'BELFAST' }, -}; - -/** A UK report's nation's place, for "country/scotland"; none for any other region. */ -export function ukNationPlace(region: string | undefined): Record<string, string> | null { - const match = /^country\/(.+)$/i.exec(region ?? ''); - return (match && UK_NATION_PLACES[match[1].toLowerCase()]) || null; -} - -/** - * A new household: the household builder's own start, one adult and - * nothing else set. A US household starts on national poverty - * thresholds, which the builder's SPM geography choice can change to a - * county's; only the US model has them. A UK household in a report on - * Scotland, Wales or Northern Ireland starts there. - */ -function newHousehold(countryId: CountryId, year: string, region?: string): Household { - const household = Household.starter(countryId, year); - if (countryId === 'us') { - return household.withSPM({ geography_kind: 'national' }); - } - const place = countryId === 'uk' ? ukNationPlace(region) : null; - const group = household.getPreferredGroupName('households'); - if (!place || !group) { - return household; - } - return Object.entries(place).reduce( - (current, [name, value]) => - current.setEntityVariableAtYear('households', group, name, year, value), - household - ); -} - /** What the builder sets for each person it adds: not an input the reader chose. */ const ADDED_PERSON_DEFAULTS = new Set(['age', 'is_tax_unit_dependent']); @@ -97,7 +56,7 @@ export function nonDefaultInputCount( ): number { const keyOf = (scope: string, name: string, value: unknown) => `${scope}|${name}|${JSON.stringify(value)}`; - const starter = newHousehold(countryId, year, region); + const starter = newV3Household(countryId, year, region); const starterPeople = new Set(starter.personNames); const starterKeys = new Set([ ...starter.personNames.flatMap((person) => @@ -327,7 +286,7 @@ function HouseholdCard({ // The household as the builder edits it: every input the model takes, // per person and per household, as in the household builder. const metadata = useSelector((state: RootState) => state.metadata); - const [household, setHousehold] = useState(() => newHousehold(countryId, year, region)); + const [household, setHousehold] = useState(() => newV3Household(countryId, year, region)); const [requested, setRequested] = useState<{ payload: V1HouseholdCreateEnvelope; summary: string; diff --git a/app/src/libs/flagship/householdReport.ts b/app/src/libs/flagship/householdReport.ts index f2631caf3..f01909e96 100644 --- a/app/src/libs/flagship/householdReport.ts +++ b/app/src/libs/flagship/householdReport.ts @@ -1,10 +1,55 @@ -import type { Household } from '@/models/Household'; +import type { CountryId } from '@/libs/countries'; +import { Household } from '@/models/Household'; /** - * Helpers for v3's standalone household report: one household, run under - * a baseline and, optionally, a reform. + * Helpers for v3's household reports: the report section's households and + * the standalone household report (one household, run under a baseline + * and, optionally, a reform). */ +/** + * Where a household in each UK nation lives unless the reader says + * otherwise: the nation's region, with its capital's local authority and + * Broad Rental Market Area, so the three name one place. England has no + * one region; its households keep the model's defaults. + */ +const UK_NATION_PLACES: Record<string, Record<string, string>> = { + scotland: { region: 'SCOTLAND', local_authority: 'CITY_OF_EDINBURGH', brma: 'LOTHIAN' }, + wales: { region: 'WALES', local_authority: 'CARDIFF', brma: 'CARDIFF' }, + northern_ireland: { region: 'NORTHERN_IRELAND', local_authority: 'BELFAST', brma: 'BELFAST' }, +}; + +/** A UK report's nation's place, for "country/scotland"; none for any other region. */ +export function ukNationPlace(region: string | undefined): Record<string, string> | null { + const match = /^country\/(.+)$/i.exec(region ?? ''); + return (match && UK_NATION_PLACES[match[1].toLowerCase()]) || null; +} + +/** + * A new household for v3's report section or standalone household report: + * the household builder's own start, one adult and nothing else set. A US + * household starts on national poverty thresholds, which the builder's SPM + * geography choice can change to a county's; only the US model has them. + * A UK household in a report on Scotland, Wales or Northern Ireland starts + * there. + */ +export function newV3Household(countryId: CountryId, year: string, region?: string): Household { + const household = Household.starter(countryId, year); + if (countryId === 'us') { + return household.withSPM({ geography_kind: 'national' }); + } + const place = countryId === 'uk' ? ukNationPlace(region) : null; + const group = household.getPreferredGroupName('households'); + if (!place || !group) { + return household; + } + return Object.entries(place).reduce( + (current, [name, value]) => + current.setEntityVariableAtYear('households', group, name, year, value), + household + ); +} + /** * Inputs every household carries that say nothing on a chart against * earnings: the earnings the chart varies, and age, which the household diff --git a/app/src/libs/flagship/householdRun.ts b/app/src/libs/flagship/householdRun.ts index 40943ed87..88851534c 100644 --- a/app/src/libs/flagship/householdRun.ts +++ b/app/src/libs/flagship/householdRun.ts @@ -331,9 +331,9 @@ export interface FlagshipHouseholdRun extends FlagshipHouseholdImpact { reform: HouseholdCalculationData; /** * How each policy's calculation was made, as its response says. The - * SPM methodology footnote reads the SPM settings and receipt; the .py - * bundle is kept so Reproduce in Python can name the versions that - * calculated the result, which v3 does not offer yet. + * SPM methodology footnote reads the SPM settings and receipt; the + * standalone household report's Reproduce in Python pins each block to + * the .py bundle that calculated it. */ provenance: { baseline: HouseholdCalculationProvenance; reform: HouseholdCalculationProvenance }; } diff --git a/app/src/pages/flagship/HouseholdReport.page.tsx b/app/src/pages/flagship/HouseholdReport.page.tsx new file mode 100644 index 000000000..877375c0d --- /dev/null +++ b/app/src/pages/flagship/HouseholdReport.page.tsx @@ -0,0 +1,448 @@ +import { useId, useMemo, useState, type ReactNode } from 'react'; +import { useSelector } from 'react-redux'; +import type { SavedHousehold } from '@/api/savedHouseholdStore'; +import HouseholdBuilderPanel from '@/components/flagship/household/HouseholdBuilderPanel'; +import HouseholdNetIncomeResult from '@/components/flagship/household/HouseholdNetIncomeResult'; +import SavedHouseholdPicker from '@/components/flagship/household/SavedHouseholdPicker'; +import SaveHouseholdForm from '@/components/flagship/household/SaveHouseholdForm'; +import type { HouseholdBreakdownSource } from '@/components/flagship/report/household/HouseholdBreakdowns'; +import HouseholdImpactResult from '@/components/flagship/report/household/HouseholdImpactResult'; +import RunProgress from '@/components/flagship/report/RunProgress'; +import { ErrorState } from '@/components/flagship/states'; +import { Button, Label, Spinner, Stack, Text, Title } from '@/components/ui'; +import { ReportYearProvider } from '@/contexts/ReportYearContext'; +import { colors, spacing, typography } from '@/designTokens'; +import { + useFlagshipHouseholdPayload, + useFlagshipHouseholdPolicies, +} from '@/hooks/useFlagshipHousehold'; +import { describeHousehold } from '@/libs/flagship/householdDescription'; +import { householdAmountInputs, newV3Household } from '@/libs/flagship/householdReport'; +import type { HouseholdCalculationProvenance } from '@/libs/flagship/householdRun'; +import { getTaxYears } from '@/libs/metadataUtils'; +import type { Household } from '@/models/Household'; +import type { V1HouseholdCreateEnvelope } from '@/models/household/v1Types'; +import HouseholdSubPage from '@/pages/report-output/HouseholdSubPage'; +import PolicySubPage from '@/pages/report-output/PolicySubPage'; +import HouseholdReproducibility from '@/pages/report-output/reproduce-in-python/HouseholdReproducibility'; +import { copyHouseholdToYear } from '@/pages/reportBuilder/utils/changeReportYear'; +import type { RootState } from '@/store'; +import { HouseholdValidation } from '@/utils/HouseholdValidation'; +import { convertPoliciesToV1Format, type HouseholdReproduction } from '@/utils/reproducibilityCode'; + +/** Both policies run at once; a household new to the Public API takes about three minutes. */ +const EXPECTED_MS = 3 * 60 * 1000; + +export interface HouseholdReportPageProps { + /** The household's country: its model, currency and inputs. */ + countryId: 'us' | 'uk'; + /** + * The year the report opens in; the household it opens with moves to it. + * The reader can choose another. + */ + year: string; + /** The household the report opens with; a new household when absent. */ + household?: Household; + /** The saved household the report opens with: the builder holds it, and saving edits it. */ + savedHousehold?: SavedHousehold | null; + /** The baseline policy: current law unless given. */ + baselinePolicyId?: string; + /** What the baseline is called: "current law" unless given. */ + baselineLabel?: string; + /** The reform policy; without it, or when it is the baseline, the report is baseline-only. */ + reformPolicyId?: string; + /** What the reform is called, for the heading. */ + reformLabel?: string; + title?: string; +} + +/** The household and the payload sent for it, fixed when Calculate is pressed. */ +interface HouseholdRequest { + household: Household; + payload: V1HouseholdCreateEnvelope; +} + +/** A calculation's model versions, from its .py bundle, else its SPM receipt. */ +function versionsOf(provenance: HouseholdCalculationProvenance, countryId: string) { + return { + policyengineVersion: + provenance.policyengineBundle?.policyengine_version ?? + provenance.spmProvenance?.runtime_versions?.policyengine ?? + null, + modelVersion: + provenance.policyengineBundle?.model_version ?? + provenance.spmProvenance?.runtime_versions?.[`policyengine-${countryId}`] ?? + null, + }; +} + +/** A household moved into a year, or the reason it cannot move (inputs in several years). */ +function moveToYear( + household: Household, + year: string +): { household: Household; error: null } | { household: null; error: string } { + try { + return { household: copyHouseholdToYear(household, year), error: null }; + } catch (error) { + return { household: null, error: error instanceof Error ? error.message : String(error) }; + } +} + +/** + * The household the report opens with, moved into the year it opens in, + * as choosing a year or a saved household moves it. A household that + * cannot move opens in its own year, with the reason, so the year shown + * is always the one its inputs are in. + */ +function openingHousehold( + household: Household, + year: string +): { household: Household; year: string; yearError: string | null } { + const moved = moveToYear(household, year); + if (moved.household) { + return { household: moved.household, year, yearError: null }; + } + return { + household, + year: household.year === null ? year : String(household.year), + yearError: moved.error, + }; +} + +/** One section of the report, named for assistive technology. */ +function ReportSection({ label, children }: { label: string; children: ReactNode }) { + return ( + <section + aria-label={label} + style={{ + padding: spacing.lg, + border: `1px solid ${colors.border.light}`, + borderRadius: spacing.radius.container, + background: colors.background.primary, + minWidth: 0, + }} + > + {children} + </section> + ); +} + +/** + * v3's standalone household report: one household, run under a baseline + * and, optionally, a reform, in a year the reader chooses (issue #498). + * It shows the net income headline (the change in it when there is a + * reform), the v2 breakdowns (net income tree, varying earnings with the + * household's own amounts offered, issue #1096, and marginal tax rates), + * the household's inputs, the policies and Reproduce in Python (with a + * pandas DataFrame option, issue #1095). With accounts, a saved household + * can be opened, and the household saved or its changes saved. + * + * It calculates through the Public API's v1 household routes, as the + * report section does, until v3's household calculations move to v2. No + * route renders it yet. + */ +export default function HouseholdReportPage({ + countryId, + year: initialYear, + household: initialHousehold, + savedHousehold: initialSaved = null, + baselinePolicyId: givenBaselinePolicyId, + baselineLabel = 'current law', + reformPolicyId, + reformLabel, + title = 'Household report', +}: HouseholdReportPageProps) { + const yearId = useId(); + const metadata = useSelector((state: RootState) => state.metadata); + const taxYears = useSelector(getTaxYears); + const baselinePolicyId = givenBaselinePolicyId ?? String(metadata.currentLawId); + const runsReform = !!reformPolicyId && reformPolicyId !== baselinePolicyId; + const [opening] = useState(() => + openingHousehold( + initialSaved?.household ?? initialHousehold ?? newV3Household(countryId, initialYear), + initialYear + ) + ); + const [year, setYear] = useState(opening.year); + const [household, setHousehold] = useState<Household>(opening.household); + const [yearError, setYearError] = useState<string | null>(opening.yearError); + const [saved, setSaved] = useState<SavedHousehold | null>(initialSaved); + // The save form's key: how many saved households the picker has opened. + // The form starts again, with the opened household's label, only then; a + // save keeps it, so its confirmation or its error stays on screen. + const [savedHouseholdOpenings, setSavedHouseholdOpenings] = useState(0); + const [requested, setRequested] = useState<HouseholdRequest | null>(null); + + const run = useFlagshipHouseholdPayload({ + year, + baselinePolicyId, + reformPolicyId: runsReform ? reformPolicyId : baselinePolicyId, + payload: requested?.payload ?? null, + }); + const running = !!requested && run.isFetching; + const failed = !!requested && !running && run.isError; + const result = requested && !running ? run.data : undefined; + + const policyIds = useMemo( + () => (runsReform ? [baselinePolicyId, reformPolicyId] : [baselinePolicyId]), + [runsReform, baselinePolicyId, reformPolicyId] + ); + const policyQuery = useFlagshipHouseholdPolicies({ countryId, policyIds, enabled: !!result }); + + // The amounts the calculated household sets, once per calculation: a new + // list on every render would make the earnings variation rebuild its + // options from every model variable each time the page renders. + const householdInputs = useMemo( + () => (requested ? householdAmountInputs(requested.household) : []), + [requested] + ); + const yearOptions = useMemo(() => { + const years = taxYears.map((option) => String(option.value)); + return years.includes(year) ? years : [year, ...years]; + }, [taxYears, year]); + const validation = useMemo( + () => HouseholdValidation.isReadyForSimulation(household, countryId, year, metadata), + [household, countryId, year, metadata] + ); + const payload = useMemo(() => household.toV1CreationPayload(), [household]); + // The household on screen, or on its way, is the one in the builder. + const sameAsRequested = + !!requested && JSON.stringify(requested.payload) === JSON.stringify(payload); + + /** A household moved into the report's year; null, with the reason shown, when it cannot be. */ + const inReportYear = (next: Household, nextYear: string): Household | null => { + const moved = moveToYear(next, nextYear); + setYearError(moved.error); + return moved.household; + }; + + const changeYear = (nextYear: string) => { + const moved = inReportYear(household, nextYear); + if (moved) { + setYear(nextYear); + setHousehold(moved); + // A result belongs to its year: the next one waits for Calculate. + setRequested(null); + } + }; + + const openSaved = (next: SavedHousehold) => { + const moved = inReportYear(next.household, year); + if (moved) { + setSaved(next); + setSavedHouseholdOpenings((openings) => openings + 1); + setHousehold(moved); + setRequested(null); + } + }; + + const calculate = () => { + // The same household again after a failure is the same query, which + // keeps its error until it is asked again, as the report section does. + if (sameAsRequested) { + void run.refetch(); + return; + } + setRequested({ household, payload }); + }; + + // Who the result is for: the household calculated, not the builder's + // household if the reader has edited it since (a year change clears the result). + const summary = requested + ? `${describeHousehold(requested.household, year, countryId, metadata?.variables)} · ${year}` + : ''; + const comparison = runsReform + ? `${reformLabel ?? 'Reform'} compared with ${baselineLabel}` + : `Under ${baselineLabel}`; + + const breakdowns: HouseholdBreakdownSource | null = result + ? { + countryId, + year, + householdId: result.householdId, + baselinePolicyId, + reformPolicyId: runsReform ? reformPolicyId : baselinePolicyId, + baseline: result.baseline, + reform: runsReform ? result.reform : result.baseline, + gains: runsReform && Math.round(result.netIncome.change) > 0, + householdInputs, + } + : null; + + const reproductions: HouseholdReproduction[] = + result && requested && !policyQuery.isLoading + ? policyIds.map((policyId, index) => { + const role = index === 0 ? 'baseline' : 'reform'; + const provenance = result.provenance[role]; + // Each block's own policy, by id: when one policy fails to load, + // the other is never given to its block, which says it cannot load. + const policy = policyQuery.policies.find((loaded) => loaded.id === policyId); + return { + role, + household: requested.household, + policy: policy ? convertPoliciesToV1Format([policy]).baseline.data : null, + spmConfig: provenance.spmConfig, + spmProvenance: provenance.spmProvenance, + ...versionsOf(provenance, countryId), + }; + }) + : []; + + const quietText = { margin: 0, fontSize: typography.fontSize.sm, color: colors.text.secondary }; + + return ( + <Stack style={{ width: '100%', minWidth: 0, gap: spacing.xl }}> + <Stack style={{ gap: spacing.xs }}> + <Title order={1} style={{ margin: 0 }}> + {title} + + {comparison} + + + + + + + Saved households + + + + +
+ + + + + {yearError && ( + + {yearError} + + )} +
+ +
+ +
+ +
+ + {!validation.isValid && validation.errors[0] && ( + + {validation.errors[0].message} + + )} +
+ + +
+
+ + {running && } + + {failed && ( + {run.error.message} + ) + } + onRetry={calculate} + /> + )} + + {result && requested && breakdowns && ( + <> + {runsReform ? ( + + ) : ( + + )} + + + + + + + {policyQuery.error ? ( + + ) : policyQuery.isLoading ? ( +
+ + Loading the report's policies... +
+ ) : ( + + + + )} +
+ + + + + + )} + + ); +} diff --git a/app/src/tests/fixtures/api/savedHouseholdMocks.ts b/app/src/tests/fixtures/api/savedHouseholdMocks.ts index aa36efc21..0ecf31edb 100644 --- a/app/src/tests/fixtures/api/savedHouseholdMocks.ts +++ b/app/src/tests/fixtures/api/savedHouseholdMocks.ts @@ -2,7 +2,11 @@ import type { vi } from 'vitest'; import { Household } from '@/models/Household'; import { householdFromV2Item, householdToV2CreateBody } from '@/models/household/v2DocumentCodec'; import { PE_USER_ID, TIMESTAMPS } from '@/tests/fixtures/api/v2UserMocks'; -import type { UserHouseholdItem, V2HouseholdItem } from '@/types/v2Households'; +import type { + UserHouseholdItem, + V2HouseholdCreateBody, + V2HouseholdItem, +} from '@/types/v2Households'; export const SAVED_HOUSEHOLD_YEAR = '2026'; @@ -46,7 +50,11 @@ export const RETIRED_HOUSEHOLD = Household.starter('us', SAVED_HOUSEHOLD_YEAR) /** A household as `/v2/households` returns it. */ export function v2HouseholdItem(household: Household, id: string): V2HouseholdItem { - const body = householdToV2CreateBody(household); + return storedV2HouseholdItem(householdToV2CreateBody(household), id); +} + +/** The household `POST /v2/households` was sent, as it returns it stored under `id`. */ +export function storedV2HouseholdItem(body: V2HouseholdCreateBody, id: string): V2HouseholdItem { return { id, country_id: body.country_id, diff --git a/app/src/tests/unit/pages/flagship/HouseholdReport.page.test.tsx b/app/src/tests/unit/pages/flagship/HouseholdReport.page.test.tsx new file mode 100644 index 000000000..ca8d2966c --- /dev/null +++ b/app/src/tests/unit/pages/flagship/HouseholdReport.page.test.tsx @@ -0,0 +1,525 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor, within } from '@test-utils'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { Household } from '@/models/Household'; +import HouseholdReportPage from '@/pages/flagship/HouseholdReport.page'; +import metadataReducer from '@/reducers/metadataReducer'; +import { + EDITED_FAMILY_HOUSEHOLD, + FAMILY_HOUSEHOLD, + FAMILY_LABEL, + routeRequests, + SAVED_FAMILY, + SAVED_HOUSEHOLD_IDS, + storedV2HouseholdItem, + userHouseholdItem, + V2_HOUSEHOLD_IDS, + v2HouseholdItem, +} from '@/tests/fixtures/api/savedHouseholdMocks'; +import { + createTestApiContext, + errorResponse, + itemResponse, + pageResponse, +} from '@/tests/fixtures/api/v2UserMocks'; +import { + BASELINE_POLICY_ID, + BASELINE_RESULT, + BASELINE_RUN_BUNDLE, + calculationResponse, + CREATED_HOUSEHOLD_ID, + HOUSEHOLD_YEAR, + REFORM_POLICY_ID, + REFORM_RESULT, + REFORM_RUN_BUNDLE, + UK_BASELINE_RESULT, +} from '@/tests/fixtures/libs/flagship/householdRunMocks'; + +/** + * v3's standalone household report: one household under a baseline and, + * optionally, a reform (issues #498, #1095, #1096). No route renders it + * before v3 moves to the Public API's v2 routes. + */ + +const mockCreateHousehold = vi.fn(); +const mockFetchHouseholdCalculation = vi.fn(); +const mockFetchPolicyById = vi.fn(); +const mockSavedWork = vi.fn(); + +vi.mock('@/api/household', () => ({ + createHousehold: (...args: unknown[]) => mockCreateHousehold(...args), +})); +vi.mock('@/api/householdCalculation', () => ({ + fetchHouseholdCalculationWithBundle: async (...args: unknown[]) => { + const [, , policyId] = args as [string, string, string]; + return calculationResponse(await mockFetchHouseholdCalculation(...args), { + policyengine_bundle: + policyId === BASELINE_POLICY_ID ? BASELINE_RUN_BUNDLE : REFORM_RUN_BUNDLE, + }); + }, +})); +vi.mock('@/api/policy', () => ({ + fetchPolicyById: (...args: unknown[]) => mockFetchPolicyById(...args), +})); + +/** A policy as the Public API returns it: no parameter changes unless given. */ +function policyMetadata(countryId: string, policyId: string, policyJson = {}) { + return { + id: policyId, + country_id: countryId, + api_version: '1', + label: null, + policy_json: policyJson, + }; +} + +/** A UK reform's parameter change, which only the reform's Python block may set. */ +const REFORM_PARAMETER = 'gov.dwp.universal_credit.elements.child.amount'; +const REFORM_POLICY_JSON = { [REFORM_PARAMETER]: { '2026-01-01.2100-12-31': 3000 } }; +/** The model's metadata for that parameter, which reading the reform's policy needs. */ +const REFORM_PARAMETER_METADATA = { + [REFORM_PARAMETER]: { + type: 'parameter', + parameter: REFORM_PARAMETER, + values: { '2026-01-01': 2900 }, + }, +}; +vi.mock('@/hooks/useParameterMetadata', () => ({ + useParameterMetadata: () => ({ parameters: REFORM_PARAMETER_METADATA, isReady: true }), +})); +vi.mock('@/hooks/useFlagshipStores', () => ({ + useV3SavedWork: () => mockSavedWork(), +})); +// The household builder, tested on its own: a stand-in shows the household +// and makes the builder's own changes. +vi.mock('@/components/household/HouseholdBuilderForm', () => ({ + default: ({ + household, + year, + onChange, + disabled, + }: { + household: Household; + year: string; + onChange: (household: Household) => void; + disabled?: boolean; + }) => { + const { maritalStatus, numChildren } = household.deriveBuilderComposition(year); + return ( +
+ {`${maritalStatus} · ${numChildren}`} + + +
+ ); + }, +})); +// The v2 breakdowns, tested on their own: a stand-in shows what each is given. +vi.mock('@/components/flagship/report/household/HouseholdBreakdowns', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('@/components/flagship/report/household/HouseholdBreakdowns') + >()), + default: ({ view, source }: any) => ( +
+ {[view, source.baselinePolicyId, source.reformPolicyId].join(' · ')} +
+ ), +})); +vi.mock('@/utils/HouseholdValidation', () => ({ + HouseholdValidation: { isReadyForSimulation: () => ({ isValid: true, errors: [] }) }, +})); + +/** The years the model's metadata lists for the report. */ +const MODEL_YEARS = [2026, 2027]; +/** A model year after the households' own (they are built in 2026). */ +const LATER_MODEL_YEAR = '2027'; + +function renderReport(props: Partial> = {}) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const initialMetadata = metadataReducer(undefined, { type: 'test/init' }); + const store = configureStore({ + reducer: { metadata: metadataReducer }, + preloadedState: { + metadata: { + ...initialMetadata, + economyOptions: { + ...initialMetadata.economyOptions, + time_period: MODEL_YEARS.map((year) => ({ name: year, label: String(year) })), + }, + }, + }, + }); + render( + + + + + , + props.countryId ?? 'us' + ); +} + +async function calculate() { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Calculate' })); + return user; +} + +/** The household the report sent to the Public API's v1 household route. */ +const createdPayload = (callIndex = 0) => mockCreateHousehold.mock.calls[callIndex][0]; + +describe('HouseholdReportPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSavedWork.mockReturnValue({ account: null, scope: 'browser' }); + mockFetchPolicyById.mockImplementation(async (countryId: string, policyId: string) => + policyMetadata(countryId, policyId) + ); + mockCreateHousehold.mockResolvedValue({ result: { household_id: CREATED_HOUSEHOLD_ID } }); + mockFetchHouseholdCalculation.mockImplementation( + async (_country: string, _household: string, policyId: string) => + policyId === BASELINE_POLICY_ID ? BASELINE_RESULT : REFORM_RESULT + ); + }); + + test('given a household and a reform then the change in its net income is reported', async () => { + renderReport({ household: FAMILY_HOUSEHOLD, reformPolicyId: REFORM_POLICY_ID }); + + await calculate(); + + const result = await screen.findByRole('region', { name: 'Household impact' }); + expect( + within(result).getByRole('group', { name: 'Change in annual net income' }) + ).toHaveTextContent('+$1,000'); + expect(mockFetchHouseholdCalculation.mock.calls.map((call) => call[2])).toEqual([ + BASELINE_POLICY_ID, + REFORM_POLICY_ID, + ]); + }); + + test("given no reform then it reports the household's net income under the baseline alone", async () => { + renderReport({ household: FAMILY_HOUSEHOLD }); + + await calculate(); + + const result = await screen.findByRole('region', { name: 'Household net income' }); + expect(within(result).getByRole('group', { name: 'Annual net income' })).toHaveTextContent( + '$72,000' + ); + expect(screen.queryByText('Change in annual net income')).not.toBeInTheDocument(); + expect(mockFetchHouseholdCalculation).toHaveBeenCalledTimes(1); + }); + + test('given the reform is the baseline then the report is baseline-only', async () => { + renderReport({ household: FAMILY_HOUSEHOLD, reformPolicyId: BASELINE_POLICY_ID }); + + await calculate(); + + expect(await screen.findByRole('region', { name: 'Household net income' })).toBeVisible(); + expect(mockFetchHouseholdCalculation).toHaveBeenCalledTimes(1); + }); + + test('given a baseline-only result then its tabs open the breakdowns under the baseline alone', async () => { + renderReport({ household: FAMILY_HOUSEHOLD }); + const user = await calculate(); + const result = await screen.findByRole('region', { name: 'Household net income' }); + + await user.click(within(result).getByRole('tab', { name: 'Marginal tax rates' })); + + expect(within(result).getByLabelText('Breakdown')).toHaveTextContent( + `marginal-tax-rates · ${BASELINE_POLICY_ID} · ${BASELINE_POLICY_ID}` + ); + }); + + test("given a result then the household's inputs, the policy and Reproduce in Python follow it", async () => { + renderReport({ household: FAMILY_HOUSEHOLD, reformPolicyId: REFORM_POLICY_ID }); + + await calculate(); + + const inputs = await screen.findByRole('region', { name: 'Household inputs' }); + expect(inputs).toHaveTextContent('Population information'); + expect(await screen.findByRole('region', { name: 'Policy' })).toHaveTextContent( + 'Policy information' + ); + const reproduce = await screen.findByRole('region', { name: 'Reproduce in Python' }); + expect(within(reproduce).getByRole('heading', { name: 'Baseline simulation' })).toBeVisible(); + expect(within(reproduce).getByRole('heading', { name: 'Reform simulation' })).toBeVisible(); + // The DataFrame code of issue #1095 is offered here. + expect( + within(reproduce).getByRole('switch', { name: 'Include a pandas DataFrame' }) + ).toBeInTheDocument(); + // Each block is pinned to the .py bundle its own calculation reported. + expect( + within(reproduce).getByRole('region', { name: 'Baseline simulation' }) + ).toHaveTextContent(`policyengine-us ${BASELINE_RUN_BUNDLE.model_version}`); + expect(within(reproduce).getByRole('region', { name: 'Reform simulation' })).toHaveTextContent( + `policyengine-us ${REFORM_RUN_BUNDLE.model_version}` + ); + }); + + test('given both policies then each Python block reproduces its own policy', async () => { + mockFetchHouseholdCalculation.mockResolvedValue(UK_BASELINE_RESULT); + mockFetchPolicyById.mockImplementation(async (countryId: string, policyId: string) => + policyMetadata(countryId, policyId, policyId === REFORM_POLICY_ID ? REFORM_POLICY_JSON : {}) + ); + renderReport({ countryId: 'uk', reformPolicyId: REFORM_POLICY_ID }); + + await calculate(); + + const reformBlock = await screen.findByRole('region', { name: 'Reform simulation' }); + await waitFor(() => expect(reformBlock).toHaveTextContent(REFORM_PARAMETER)); + expect(screen.getByRole('region', { name: 'Baseline simulation' })).not.toHaveTextContent( + REFORM_PARAMETER + ); + }); + + test("given the baseline's policy failing to load then no Python block is given the reform's policy", async () => { + mockFetchHouseholdCalculation.mockResolvedValue(UK_BASELINE_RESULT); + mockFetchPolicyById.mockImplementation(async (countryId: string, policyId: string) => { + if (policyId === BASELINE_POLICY_ID) { + throw new Error('Policy could not be loaded'); + } + return policyMetadata(countryId, policyId, REFORM_POLICY_JSON); + }); + renderReport({ countryId: 'uk', reformPolicyId: REFORM_POLICY_ID }); + + await calculate(); + + expect(await screen.findByText("The report's policies could not be loaded")).toBeVisible(); + const baselineBlock = screen.getByRole('region', { name: 'Baseline simulation' }); + expect(baselineBlock).toHaveTextContent( + "Load this simulation's household and policy to generate its code." + ); + expect(baselineBlock).not.toHaveTextContent(REFORM_PARAMETER); + }); + + test('given a failed calculation then trying again calculates the same household again', async () => { + mockFetchHouseholdCalculation.mockRejectedValueOnce(new Error('Household calculation failed')); + renderReport({ household: FAMILY_HOUSEHOLD }); + const user = await calculate(); + + await user.click(await screen.findByRole('button', { name: 'Try again' })); + + const result = await screen.findByRole('region', { name: 'Household net income' }); + expect(within(result).getByRole('group', { name: 'Annual net income' })).toHaveTextContent( + '$72,000' + ); + expect(mockFetchHouseholdCalculation).toHaveBeenCalledTimes(2); + }); + + test('given the household calculated and unchanged then there is nothing new to calculate', async () => { + renderReport({ household: FAMILY_HOUSEHOLD }); + const user = await calculate(); + await screen.findByRole('region', { name: 'Household net income' }); + + expect(screen.getByRole('button', { name: 'Calculate' })).toBeDisabled(); + await user.click(screen.getByRole('button', { name: 'Add tip income' })); + + expect(screen.getByRole('button', { name: 'Calculate' })).toBeEnabled(); + }); + + test.each([ + ['a household', { household: FAMILY_HOUSEHOLD }], + ['a saved household', { savedHousehold: SAVED_FAMILY }], + ])( + "given %s from another year then the report opens it in the report's year", + async (_opening, openingProps) => { + renderReport({ ...openingProps, year: LATER_MODEL_YEAR }); + + await calculate(); + + await waitFor(() => expect(mockCreateHousehold).toHaveBeenCalledTimes(1)); + expect(createdPayload().data.people.you.age).toEqual({ [LATER_MODEL_YEAR]: 30 }); + expect(screen.getByRole('combobox', { name: 'Year' })).toHaveValue(LATER_MODEL_YEAR); + } + ); + + test('given the household edited after its result then the result still describes the household calculated', async () => { + renderReport({ household: FAMILY_HOUSEHOLD }); + const user = await calculate(); + const result = await screen.findByRole('region', { name: 'Household net income' }); + + await user.click(screen.getByRole('button', { name: 'Make single' })); + + expect(result).toHaveTextContent('Married · 2 children'); + expect(result).not.toHaveTextContent('Single'); + }); + + test("given another year then the household's inputs move to it before it runs (issue #498)", async () => { + const user = userEvent.setup(); + renderReport({ household: FAMILY_HOUSEHOLD }); + + expect( + within(screen.getByRole('combobox', { name: 'Year' })) + .getAllByRole('option') + .map((option) => option.textContent) + ).toEqual(['2026', '2027']); + await user.selectOptions(screen.getByRole('combobox', { name: 'Year' }), LATER_MODEL_YEAR); + await user.click(screen.getByRole('button', { name: 'Calculate' })); + + await waitFor(() => expect(mockCreateHousehold).toHaveBeenCalledTimes(1)); + expect(createdPayload().data.people.you.age).toEqual({ [LATER_MODEL_YEAR]: 30 }); + }); + + test('given another year after a result then the result clears until the next calculation', async () => { + renderReport({ household: FAMILY_HOUSEHOLD }); + const user = await calculate(); + await screen.findByRole('region', { name: 'Household net income' }); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Year' }), LATER_MODEL_YEAR); + + expect(screen.queryByRole('region', { name: 'Household net income' })).not.toBeInTheDocument(); + }); + + test('given a household with tip income then the earnings variation offers it (issue #1096)', async () => { + const user = userEvent.setup(); + renderReport({ household: FAMILY_HOUSEHOLD }); + + await user.click(screen.getByRole('button', { name: 'Add tip income' })); + await user.click(screen.getByRole('button', { name: 'Calculate' })); + const result = await screen.findByRole('region', { name: 'Household net income' }); + await user.click(within(result).getByRole('tab', { name: 'Varying earnings' })); + + expect(within(result).getByLabelText('Breakdown')).toHaveAttribute('data-inputs', 'tip_income'); + }); + + test('given a new US household then it starts on national poverty thresholds', async () => { + renderReport(); + + await calculate(); + + await waitFor(() => expect(mockCreateHousehold).toHaveBeenCalledTimes(1)); + expect(createdPayload().spm).toEqual({ geography_kind: 'national' }); + }); + + test('given a UK household and no reform then it runs in the UK and reads in pounds', async () => { + mockFetchHouseholdCalculation.mockResolvedValue(UK_BASELINE_RESULT); + renderReport({ countryId: 'uk' }); + + await calculate(); + + const result = await screen.findByRole('region', { name: 'Household net income' }); + expect(within(result).getByRole('group', { name: 'Annual net income' })).toHaveTextContent( + '£24,940' + ); + expect(mockFetchHouseholdCalculation.mock.calls[0][0]).toBe('uk'); + expect(createdPayload().spm).toBeUndefined(); + }); + + describe('saved households', () => { + /** Signed in, with the family saved; `moreRoutes` answers the saves a test makes. */ + function signedInWithFamily(moreRoutes: Parameters[1] = {}) { + const { fetchMock, context } = createTestApiContext('us'); + mockSavedWork.mockReturnValue({ account: context, scope: 'account:test' }); + return routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse([userHouseholdItem()]), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse(v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY)), + ...moreRoutes, + }); + } + + test('given a new household saved then the report says it is saved, with nothing left to save', async () => { + const user = userEvent.setup(); + signedInWithFamily({ + 'POST /v2/households': () => + itemResponse(v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY), 201), + 'POST /v2/user-households': () => itemResponse(userHouseholdItem(), 201), + }); + renderReport({ household: FAMILY_HOUSEHOLD }); + + await user.type(screen.getByRole('textbox', { name: 'Household label' }), FAMILY_LABEL); + await user.click(screen.getByRole('button', { name: 'Save household' })); + + expect(await screen.findByText('Saved')).toHaveAttribute('role', 'status'); + // The stored household reads back with the same content. + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); + }); + + test('given an edited saved household saved then it points at the edit and keeps its id', async () => { + const user = userEvent.setup(); + const requests = signedInWithFamily({ + 'POST /v2/households': (request) => + itemResponse(storedV2HouseholdItem(request.body, V2_HOUSEHOLD_IDS.EDITED_FAMILY), 201), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + itemResponse(userHouseholdItem({ household_id: V2_HOUSEHOLD_IDS.EDITED_FAMILY })), + }); + renderReport({ savedHousehold: SAVED_FAMILY }); + + await user.click(screen.getByRole('button', { name: 'Add tip income' })); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByText('Saved')).toHaveAttribute('role', 'status'); + expect(requests.find((request) => request.method === 'PATCH')?.path).toBe( + `/v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}` + ); + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); + }); + + test('given the edit cannot be saved then the report says the changes were not saved', async () => { + const user = userEvent.setup(); + signedInWithFamily({ + 'POST /v2/households': () => + itemResponse( + v2HouseholdItem(EDITED_FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.EDITED_FAMILY), + 201 + ), + [`PATCH /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => + errorResponse(503, 'Saved work is temporarily unavailable.'), + }); + renderReport({ savedHousehold: SAVED_FAMILY }); + + await user.click(screen.getByRole('button', { name: 'Add tip income' })); + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Your changes were not saved'); + }); + + test('given a saved household chosen then the builder holds it, and saving edits it', async () => { + const user = userEvent.setup(); + signedInWithFamily(); + renderReport(); + + expect(screen.getByLabelText('Household builder')).toHaveTextContent('single · 0'); + await user.click(await screen.findByRole('button', { name: new RegExp(FAMILY_LABEL) })); + + expect(screen.getByLabelText('Household builder')).toHaveTextContent('married · 2'); + expect(screen.getByRole('textbox', { name: 'Household label' })).toHaveValue(FAMILY_LABEL); + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); + }); + + test('given the report opened from a saved household then it is the one the save form edits', () => { + signedInWithFamily(); + renderReport({ savedHousehold: SAVED_FAMILY }); + + expect(screen.getByLabelText('Household builder')).toHaveTextContent('married · 2'); + expect(screen.getByRole('textbox', { name: 'Household label' })).toHaveValue(FAMILY_LABEL); + }); + + test('given no accounts then saved households are not offered', () => { + renderReport({ household: FAMILY_HOUSEHOLD }); + + expect(screen.getAllByText('Saved households are not available here.')).toHaveLength(2); + expect(screen.queryByRole('textbox', { name: 'Household label' })).not.toBeInTheDocument(); + }); + }); +}); From f35523d773da610846bfbedcd118fc3d1a849723 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:37:11 +0400 Subject: [PATCH 12/29] v3 household features: one entry-point module, unreachable until v3 moves 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 --- .../pages/flagship/HouseholdReport.page.tsx | 2 +- .../pages/flagship/SavedHouseholds.page.tsx | 3 +- .../pages/flagship/householdEntryPoints.ts | 37 ++++++ .../flagship/householdEntryPoints.test.tsx | 124 ++++++++++++++++++ 4 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 app/src/pages/flagship/householdEntryPoints.ts create mode 100644 app/src/tests/unit/pages/flagship/householdEntryPoints.test.tsx diff --git a/app/src/pages/flagship/HouseholdReport.page.tsx b/app/src/pages/flagship/HouseholdReport.page.tsx index 877375c0d..3e0a8b1e4 100644 --- a/app/src/pages/flagship/HouseholdReport.page.tsx +++ b/app/src/pages/flagship/HouseholdReport.page.tsx @@ -139,7 +139,7 @@ function ReportSection({ label, children }: { label: string; children: ReactNode * * It calculates through the Public API's v1 household routes, as the * report section does, until v3's household calculations move to v2. No - * route renders it yet. + * route renders it yet: see `pages/flagship/householdEntryPoints.ts`. */ export default function HouseholdReportPage({ countryId, diff --git a/app/src/pages/flagship/SavedHouseholds.page.tsx b/app/src/pages/flagship/SavedHouseholds.page.tsx index 1ab8cee77..b01b7ecb2 100644 --- a/app/src/pages/flagship/SavedHouseholds.page.tsx +++ b/app/src/pages/flagship/SavedHouseholds.page.tsx @@ -5,7 +5,8 @@ import { colors, spacing, typography } from '@/designTokens'; /** * v3's list of saved households, to rename or delete. Saved households * live on the Public API's `/v2/households` and `/v2/user-households`, so - * no route renders this page until v3 moves to those routes. + * no route renders this page until v3 moves to those routes: see + * `pages/flagship/householdEntryPoints.ts`. */ export default function SavedHouseholdsPage() { return ( diff --git a/app/src/pages/flagship/householdEntryPoints.ts b/app/src/pages/flagship/householdEntryPoints.ts new file mode 100644 index 000000000..c7703ac47 --- /dev/null +++ b/app/src/pages/flagship/householdEntryPoints.ts @@ -0,0 +1,37 @@ +/** + * v3's household features that work only on the Public API's v2 routes, + * gathered in one place: the standalone household report and saved + * households. + * + * Saved households live on the Public API's `/v2/households` and + * `/v2/user-households`, and household reports will be saved through + * `/v2/reports`. So until v3 moves to the v2 routes, no route or control + * reaches anything exported here, and nothing routed imports this module + * or the modules behind it (`householdEntryPoints.test.tsx` checks both). + * + * What the change that moves v3 to the v2 routes does to reach them: + * + * 1. Build's population picker (`components/flagship/reform/PopulationPicker.tsx`) + * lists "A household" as a disabled option marked "Soon". The change + * 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. A report whose simulations are households shows `ReportNotSocietyWide` + * (`pages/flagship/Report.page.tsx`, its `householdReport` branch). The + * change renders `HouseholdReportPage` there for household reports saved + * through `/v2/reports` and `/v2/user-reports`. + * 3. Saved households have no route. The change 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. + * + * Before then, `HouseholdReportPage`'s calculations move from the Public + * API's v1 household routes to `/v2/household-calculations`, and saving + * changes to a saved household needs `PATCH /v2/user-households` to accept + * `household_id`. + */ +export { default as HouseholdReportPage } from '@/pages/flagship/HouseholdReport.page'; +export { default as SavedHouseholdsPage } from '@/pages/flagship/SavedHouseholds.page'; +export { default as SavedHouseholdPicker } from '@/components/flagship/household/SavedHouseholdPicker'; +export { default as SaveHouseholdForm } from '@/components/flagship/household/SaveHouseholdForm'; diff --git a/app/src/tests/unit/pages/flagship/householdEntryPoints.test.tsx b/app/src/tests/unit/pages/flagship/householdEntryPoints.test.tsx new file mode 100644 index 000000000..4d62c08c2 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/householdEntryPoints.test.tsx @@ -0,0 +1,124 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test } from 'vitest'; +import PopulationPicker from '@/components/flagship/reform/PopulationPicker'; +import * as householdEntryPoints from '@/pages/flagship/householdEntryPoints'; + +/** + * v3's standalone household report and saved households work only on the + * Public API's v2 routes, so until v3 moves to them nothing a user can + * reach leads there. These checks keep it so: the change that moves v3 + * changes them on purpose. (A report whose simulations are households + * still shows "This report cannot open here": see ReportLoadStates.test.tsx.) + */ + +/** app/src, whether the tests run from the app's directory or the repository's. */ +const APP_SOURCE = existsSync(resolve(process.cwd(), 'src/pages/flagship')) + ? resolve(process.cwd(), 'src') + : resolve(process.cwd(), 'app/src'); +const CALCULATOR_SOURCE = resolve(APP_SOURCE, '../../calculator-app/src'); + +/** The modules only the household features use, by the specifier the app imports them with. */ +const UNROUTED_MODULES = [ + '@/pages/flagship/householdEntryPoints', + '@/pages/flagship/HouseholdReport.page', + '@/pages/flagship/SavedHouseholds.page', + '@/components/flagship/household/SaveHouseholdForm', + '@/components/flagship/household/SavedHouseholdPicker', + '@/components/flagship/household/SavedHouseholdsList', + '@/components/flagship/household/DeleteSavedHouseholdDialog', + '@/components/flagship/household/savedHouseholdDisplay', + '@/components/flagship/household/HouseholdNetIncomeResult', + '@/hooks/useSavedHouseholds', + '@/api/savedHouseholdStore', + '@/models/household/v2DocumentCodec', +]; + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + return entry.name === 'tests' || entry.name === 'node_modules' ? [] : sourceFiles(path); + } + return /\.(ts|tsx)$/.test(entry.name) ? [path] : []; + }); +} + +/** The app specifier a file under app/src is imported by: "@/api/savedHouseholdStore". */ +function specifierOf(path: string): string | null { + if (!path.startsWith(APP_SOURCE)) { + return null; + } + return `@/${relative(APP_SOURCE, path).replace(/\.(ts|tsx)$/, '')}`; +} + +/** Every module a source file names: imports (static, dynamic, side effect), re-exports, requires. */ +function importedSpecifiers(source: string): string[] { + const specifierPattern = + /(?:\bfrom\s+|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)(['"])([^'"]+)\1/g; + return [...source.matchAll(specifierPattern)].map((match) => match[2]); +} + +/** + * The app specifier an import names, however it is written: "@/…" as is, + * and a relative path ("./HouseholdReport.page") resolved from the + * importing file. Package imports name no app module. + */ +function appSpecifierOf(importer: string, specifier: string): string | null { + if (specifier.startsWith('@/')) { + return specifier.replace(/\.(ts|tsx)$/, ''); + } + if (specifier.startsWith('.')) { + return specifierOf(resolve(dirname(importer), specifier)); + } + return null; +} + +/** Whether a source file names one of the household modules, by either specifier form. */ +function importsAHouseholdModule(importer: string, source: string): boolean { + return importedSpecifiers(source).some((specifier) => + UNROUTED_MODULES.includes(appSpecifierOf(importer, specifier) ?? '') + ); +} + +describe('household entry points before v3 moves to the v2 routes', () => { + test('given Build\'s population picker then "A household" is listed, but cannot be chosen', async () => { + const user = userEvent.setup(); + render( {}} regions={[]} countryId="us" />); + + await user.click(screen.getByRole('button', { name: /Population/ })); + + expect(screen.getByRole('option', { name: /A household/ })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + test('given the app and calculator sources then only the household modules import one another', () => { + const importers = [...sourceFiles(APP_SOURCE), ...sourceFiles(CALCULATOR_SOURCE)] + .filter((path) => !UNROUTED_MODULES.includes(specifierOf(path) ?? '')) + .filter((path) => importsAHouseholdModule(path, readFileSync(path, 'utf8'))) + .map((path) => relative(resolve(APP_SOURCE, '../..'), path)); + + expect(importers).toEqual([]); + }); + + test.each([ + ['an @/ specifier', "import HouseholdReportPage from '@/pages/flagship/HouseholdReport.page';"], + ['a relative path', "import HouseholdReportPage from './HouseholdReport.page';"], + ])('given a routed page importing the report by %s then the check finds it', (_form, source) => { + const routedPage = resolve(APP_SOURCE, 'pages/flagship/Report.page.tsx'); + + expect(importsAHouseholdModule(routedPage, source)).toBe(true); + }); + + test('given the entry points then the report, the saved households page, the picker and the save form are gathered there', () => { + expect(Object.keys(householdEntryPoints).sort()).toEqual([ + 'HouseholdReportPage', + 'SaveHouseholdForm', + 'SavedHouseholdPicker', + 'SavedHouseholdsPage', + ]); + }); +}); From 753db26970549c8d8e32ed64252c4cfd148fc8b3 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:54:05 +0400 Subject: [PATCH 13/29] Household report: trying again never sends a household that cannot run 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 --- .../household/HouseholdImpactSection.tsx | 52 +++--------- app/src/hooks/useFlagshipHousehold.ts | 79 +++++++++++++++++++ .../pages/flagship/HouseholdReport.page.tsx | 50 +++--------- .../flagship/HouseholdReport.page.test.tsx | 23 +++++- 4 files changed, 123 insertions(+), 81 deletions(-) diff --git a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx index 9b64b0c09..e9f8d58d7 100644 --- a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx +++ b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx @@ -8,7 +8,7 @@ import RunProgress from '@/components/flagship/report/RunProgress'; import { ErrorState } from '@/components/flagship/states'; import { Button, Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; -import { useFlagshipHouseholdPayload } from '@/hooks/useFlagshipHousehold'; +import { useRequestedHouseholdRun } from '@/hooks/useFlagshipHousehold'; import type { CountryId } from '@/libs/countries'; import { describeHousehold } from '@/libs/flagship/householdDescription'; import { newV3Household } from '@/libs/flagship/householdReport'; @@ -19,7 +19,6 @@ import { loadParameterDependencies, } from '@/libs/flagship/parameterDependencies'; import type { Household } from '@/models/Household'; -import type { V1HouseholdCreateEnvelope } from '@/models/household/v1Types'; import type { RootState } from '@/store'; import { householdErrorCategory, @@ -287,16 +286,8 @@ function HouseholdCard({ // per person and per household, as in the household builder. const metadata = useSelector((state: RootState) => state.metadata); const [household, setHousehold] = useState(() => newV3Household(countryId, year, region)); - const [requested, setRequested] = useState<{ - payload: V1HouseholdCreateEnvelope; - summary: string; - } | null>(null); - const run = useFlagshipHouseholdPayload({ - year, - baselinePolicyId, - reformPolicyId, - payload: requested?.payload ?? null, - }); + const { requested, run, running, failed, result, request, retry, shows } = + useRequestedHouseholdRun({ year, baselinePolicyId, reformPolicyId }); // Whether the household can run, as the household builder checks it. const validation = useMemo( () => HouseholdValidation.isReadyForSimulation(household, countryId, year, metadata), @@ -322,22 +313,9 @@ function HouseholdCard({ const calculate = () => { startRun(); - const payload = household.toV1CreationPayload(); - // The same household again, after a failure, asks once more. - if (requested && JSON.stringify(requested.payload) === JSON.stringify(payload)) { - void run.refetch(); - } else { - setRequested({ - payload, - summary: describeHousehold(household, year, countryId, metadata?.variables), - }); - } + request(household); setEditing(false); }; - - const running = !!requested && run.isFetching; - const failed = !!requested && !running && run.isError; - const result = requested && !running ? run.data : undefined; // Once a household runs, its result leads: the inputs fold to one line // until the reader edits them again. const [editing, setEditing] = useState(true); @@ -373,21 +351,11 @@ function HouseholdCard({ }); }, [running, runData, runError, run.isError, countryId, householdCount]); - const retry = () => { + const tryAgain = () => { trackHouseholdCalculationRetried({ countryId }); - // The household as it is now: an input fixed after the failure counts. - if (validation.isValid) { - calculate(); - } else { - startRun(); - void run.refetch(); - } + startRun(); + retry(household, validation.isValid); }; - // The household on screen, or on its way, is the one in the builder. - const sameAsShown = - !!requested && - !failed && - JSON.stringify(requested.payload) === JSON.stringify(household.toV1CreationPayload()); return (
Calculate @@ -500,7 +468,7 @@ function HouseholdCard({ {run.error.message} ) } - onRetry={retry} + onRetry={tryAgain} /> )} @@ -513,7 +481,7 @@ function HouseholdCard({ // One calculation served both policies: its methodology shows once. reform: baselinePolicyId === reformPolicyId ? null : result.provenance.reform, }} - summary={`${requested.summary} · ${year}`} + summary={`${describeHousehold(requested.household, year, countryId, metadata?.variables)} · ${year}`} breakdowns={{ countryId, year, diff --git a/app/src/hooks/useFlagshipHousehold.ts b/app/src/hooks/useFlagshipHousehold.ts index d82d928e4..82ffb5b47 100644 --- a/app/src/hooks/useFlagshipHousehold.ts +++ b/app/src/hooks/useFlagshipHousehold.ts @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { PolicyAdapter } from '@/adapters/PolicyAdapter'; import { fetchPolicyById } from '@/api/policy'; @@ -5,6 +6,7 @@ import { useParameterMetadata } from '@/hooks/useParameterMetadata'; import { useParallelQueries } from '@/hooks/utils/queryUtils'; import { runFlagshipHouseholdPayload } from '@/libs/flagship/householdRun'; import { policyKeys } from '@/libs/queryKeys'; +import type { Household } from '@/models/Household'; import type { V1HouseholdCreateEnvelope } from '@/models/household/v1Types'; import type { Policy } from '@/types/ingredients/Policy'; @@ -44,6 +46,83 @@ export function useFlagshipHouseholdPayload({ }); } +/** The household a run was asked for, and the payload sent for it. */ +export interface RequestedHousehold { + household: Household; + payload: V1HouseholdCreateEnvelope; +} + +const samePayload = (first: V1HouseholdCreateEnvelope, second: V1HouseholdCreateEnvelope) => + JSON.stringify(first) === JSON.stringify(second); + +/** + * A v3 household result's run, asked for when the reader presses + * Calculate, for the report section's households and the standalone + * household report alike. The household asked for is fixed when asked, + * so the result keeps describing it while the reader edits the builder. + */ +export function useRequestedHouseholdRun({ + year, + baselinePolicyId, + reformPolicyId, +}: { + year: string; + baselinePolicyId: string; + reformPolicyId: string; +}) { + const [requested, setRequested] = useState(null); + const run = useFlagshipHouseholdPayload({ + year, + baselinePolicyId, + reformPolicyId, + payload: requested?.payload ?? null, + }); + const running = !!requested && run.isFetching; + const failed = !!requested && !running && run.isError; + + /** + * Asks for a household's run. The same household again is the same + * query, which keeps its error until asked again, so it asks once more. + */ + const request = (household: Household) => { + const payload = household.toV1CreationPayload(); + if (requested && samePayload(requested.payload, payload)) { + void run.refetch(); + } else { + setRequested({ household, payload }); + } + }; + + return { + /** The household asked for; null until Calculate. */ + requested, + /** The run's query, for its data and error. */ + run, + running, + failed, + /** The run's result, once it has one and is not running again. */ + result: requested && !running ? run.data : undefined, + request, + /** + * After a failure: the household as it is now when it can run, so an + * input fixed after the failure counts; otherwise the household that + * failed, once more, never one that cannot run. + */ + retry: (household: Household, canRun: boolean) => { + if (canRun) { + request(household); + } else { + void run.refetch(); + } + }, + /** Forgets the request and its result: the next result waits for Calculate. */ + clear: () => setRequested(null), + /** Whether the household on screen, or on its way, is this one. */ + shows: (household: Household) => + !!requested && !failed && samePayload(requested.payload, household.toV1CreationPayload()), + }; +} + /** * The report's policies in the app's model, for the v2 household * breakdowns that send a policy's parameters with their request (earnings diff --git a/app/src/pages/flagship/HouseholdReport.page.tsx b/app/src/pages/flagship/HouseholdReport.page.tsx index 3e0a8b1e4..e10971cac 100644 --- a/app/src/pages/flagship/HouseholdReport.page.tsx +++ b/app/src/pages/flagship/HouseholdReport.page.tsx @@ -13,15 +13,14 @@ import { Button, Label, Spinner, Stack, Text, Title } from '@/components/ui'; import { ReportYearProvider } from '@/contexts/ReportYearContext'; import { colors, spacing, typography } from '@/designTokens'; import { - useFlagshipHouseholdPayload, useFlagshipHouseholdPolicies, + useRequestedHouseholdRun, } from '@/hooks/useFlagshipHousehold'; import { describeHousehold } from '@/libs/flagship/householdDescription'; import { householdAmountInputs, newV3Household } from '@/libs/flagship/householdReport'; import type { HouseholdCalculationProvenance } from '@/libs/flagship/householdRun'; import { getTaxYears } from '@/libs/metadataUtils'; import type { Household } from '@/models/Household'; -import type { V1HouseholdCreateEnvelope } from '@/models/household/v1Types'; import HouseholdSubPage from '@/pages/report-output/HouseholdSubPage'; import PolicySubPage from '@/pages/report-output/PolicySubPage'; import HouseholdReproducibility from '@/pages/report-output/reproduce-in-python/HouseholdReproducibility'; @@ -56,12 +55,6 @@ export interface HouseholdReportPageProps { title?: string; } -/** The household and the payload sent for it, fixed when Calculate is pressed. */ -interface HouseholdRequest { - household: Household; - payload: V1HouseholdCreateEnvelope; -} - /** A calculation's model versions, from its .py bundle, else its SPM receipt. */ function versionsOf(provenance: HouseholdCalculationProvenance, countryId: string) { return { @@ -171,17 +164,12 @@ export default function HouseholdReportPage({ // The form starts again, with the opened household's label, only then; a // save keeps it, so its confirmation or its error stays on screen. const [savedHouseholdOpenings, setSavedHouseholdOpenings] = useState(0); - const [requested, setRequested] = useState(null); - - const run = useFlagshipHouseholdPayload({ - year, - baselinePolicyId, - reformPolicyId: runsReform ? reformPolicyId : baselinePolicyId, - payload: requested?.payload ?? null, - }); - const running = !!requested && run.isFetching; - const failed = !!requested && !running && run.isError; - const result = requested && !running ? run.data : undefined; + const { requested, run, running, failed, result, request, retry, clear, shows } = + useRequestedHouseholdRun({ + year, + baselinePolicyId, + reformPolicyId: runsReform ? reformPolicyId : baselinePolicyId, + }); const policyIds = useMemo( () => (runsReform ? [baselinePolicyId, reformPolicyId] : [baselinePolicyId]), @@ -204,10 +192,6 @@ export default function HouseholdReportPage({ () => HouseholdValidation.isReadyForSimulation(household, countryId, year, metadata), [household, countryId, year, metadata] ); - const payload = useMemo(() => household.toV1CreationPayload(), [household]); - // The household on screen, or on its way, is the one in the builder. - const sameAsRequested = - !!requested && JSON.stringify(requested.payload) === JSON.stringify(payload); /** A household moved into the report's year; null, with the reason shown, when it cannot be. */ const inReportYear = (next: Household, nextYear: string): Household | null => { @@ -222,7 +206,7 @@ export default function HouseholdReportPage({ setYear(nextYear); setHousehold(moved); // A result belongs to its year: the next one waits for Calculate. - setRequested(null); + clear(); } }; @@ -232,18 +216,8 @@ export default function HouseholdReportPage({ setSaved(next); setSavedHouseholdOpenings((openings) => openings + 1); setHousehold(moved); - setRequested(null); - } - }; - - const calculate = () => { - // The same household again after a failure is the same query, which - // keeps its error until it is asked again, as the report section does. - if (sameAsRequested) { - void run.refetch(); - return; + clear(); } - setRequested({ household, payload }); }; // Who the result is for: the household calculated, not the builder's @@ -349,8 +323,8 @@ export default function HouseholdReportPage({
@@ -382,7 +356,7 @@ export default function HouseholdReportPage({ {run.error.message} ) } - onRetry={calculate} + onRetry={() => retry(household, validation.isValid)} /> )} diff --git a/app/src/tests/unit/pages/flagship/HouseholdReport.page.test.tsx b/app/src/tests/unit/pages/flagship/HouseholdReport.page.test.tsx index ca8d2966c..54160436f 100644 --- a/app/src/tests/unit/pages/flagship/HouseholdReport.page.test.tsx +++ b/app/src/tests/unit/pages/flagship/HouseholdReport.page.test.tsx @@ -139,9 +139,12 @@ vi.mock('@/components/flagship/report/household/HouseholdBreakdowns', async (imp
), })); +const mockHouseholdValidation = vi.fn(); vi.mock('@/utils/HouseholdValidation', () => ({ - HouseholdValidation: { isReadyForSimulation: () => ({ isValid: true, errors: [] }) }, + HouseholdValidation: { isReadyForSimulation: () => mockHouseholdValidation() }, })); +const READY_TO_RUN = { isValid: true, errors: [] }; +const NOT_READY_TO_RUN = { isValid: false, errors: [{ message: 'Choose a county' }] }; /** The years the model's metadata lists for the report. */ const MODEL_YEARS = [2026, 2027]; @@ -190,6 +193,7 @@ const createdPayload = (callIndex = 0) => mockCreateHousehold.mock.calls[callInd describe('HouseholdReportPage', () => { beforeEach(() => { vi.clearAllMocks(); + mockHouseholdValidation.mockReturnValue(READY_TO_RUN); mockSavedWork.mockReturnValue({ account: null, scope: 'browser' }); mockFetchPolicyById.mockImplementation(async (countryId: string, policyId: string) => policyMetadata(countryId, policyId) @@ -326,6 +330,23 @@ describe('HouseholdReportPage', () => { expect(mockFetchHouseholdCalculation).toHaveBeenCalledTimes(2); }); + test('given a failed calculation and the household then edited so it cannot run, trying again asks for the household that failed', async () => { + mockFetchHouseholdCalculation.mockRejectedValueOnce(new Error('Household calculation failed')); + renderReport({ household: FAMILY_HOUSEHOLD }); + const user = await calculate(); + await screen.findByRole('button', { name: 'Try again' }); + + mockHouseholdValidation.mockReturnValue(NOT_READY_TO_RUN); + await user.click(screen.getByRole('button', { name: 'Add tip income' })); + await user.click(screen.getByRole('button', { name: 'Try again' })); + + await screen.findByRole('region', { name: 'Household net income' }); + expect(mockCreateHousehold).toHaveBeenCalledTimes(2); + expect( + mockCreateHousehold.mock.calls.map((call) => call[0].data.people.you.tip_income) + ).toEqual([undefined, undefined]); + }); + test('given the household calculated and unchanged then there is nothing new to calculate', async () => { renderReport({ household: FAMILY_HOUSEHOLD }); const user = await calculate(); From 749c10a1326023e1157274ee75d78b774954cc80 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:54:54 +0400 Subject: [PATCH 14/29] Save household form: "Saved" shows only while it describes what is on 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 --- .../flagship/household/SaveHouseholdForm.tsx | 26 ++++++++++++------- .../household/SaveHouseholdForm.test.tsx | 23 ++++++++++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/app/src/components/flagship/household/SaveHouseholdForm.tsx b/app/src/components/flagship/household/SaveHouseholdForm.tsx index 866a371b5..7bfc61303 100644 --- a/app/src/components/flagship/household/SaveHouseholdForm.tsx +++ b/app/src/components/flagship/household/SaveHouseholdForm.tsx @@ -36,7 +36,10 @@ export default function SaveHouseholdForm({ const saveHousehold = useSaveHousehold(); const saveChanges = useSaveHouseholdChanges(); const [label, setLabel] = useState(saved?.label ?? ''); - const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); + const [saving, setSaving] = useState(false); + // What the last save stored: "Saved" shows only while the household and + // label on screen are still what it stored. + const [lastSaved, setLastSaved] = useState<{ household: Household; label: string } | null>(null); const [errorMessage, setErrorMessage] = useState(null); if (!store) { @@ -50,9 +53,13 @@ export default function SaveHouseholdForm({ const contentChanged = !saved || !haveSameStoredContent(household, saved.household); const labelChanged = !saved || label.trim() !== (saved.label ?? ''); const nothingToSave = !!saved && !contentChanged && !labelChanged; + const showsSaved = + !!lastSaved && + lastSaved.label === label.trim() && + haveSameStoredContent(household, lastSaved.household); const save = async () => { - setStatus('saving'); + setSaving(true); setErrorMessage(null); try { const result = saved @@ -64,11 +71,13 @@ export default function SaveHouseholdForm({ }, }) : await saveHousehold.mutateAsync({ household, label }); - setStatus('saved'); + setLastSaved({ household, label: label.trim() }); onSaved?.(result); } catch (error) { - setStatus('idle'); + setLastSaved(null); setErrorMessage(error instanceof Error ? error.message : String(error)); + } finally { + setSaving(false); } }; @@ -81,17 +90,14 @@ export default function SaveHouseholdForm({ value={label} placeholder="For example, our family" maxLength={255} - onChange={(event) => { - setLabel(event.target.value); - setStatus('idle'); - }} + onChange={(event) => setLabel(event.target.value)} style={{ maxWidth: 320 }} /> - - {status === 'saved' && ( + {showsSaved && ( { expect(screen.getByRole('status')).toHaveTextContent('Saved'); }); + test('given the household edited after a save then it no longer says it is saved', async () => { + const user = userEvent.setup(); + routeRequests(signedInSession(), { + 'POST /v2/households': () => + itemResponse(v2HouseholdItem(FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.FAMILY), 201), + 'POST /v2/user-households': () => itemResponse(userHouseholdItem(), 201), + }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const formFor = (household: typeof FAMILY_HOUSEHOLD) => ( + + + + ); + const view = render(formFor(FAMILY_HOUSEHOLD)); + await user.type(screen.getByRole('textbox', { name: 'Household label' }), FAMILY_LABEL); + await user.click(screen.getByRole('button', { name: 'Save household' })); + expect(await screen.findByRole('status')).toHaveTextContent('Saved'); + + view.rerender(formFor(EDITED_FAMILY_HOUSEHOLD)); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + test('given an edited saved household then the edit is stored and the saved household points at it, keeping its id', async () => { const user = userEvent.setup(); const requests = routeRequests(signedInSession(), { From 9730c73a2cebde3e961dfde1bb0f285aa1f3ea0f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:56:31 +0400 Subject: [PATCH 15/29] Saved households: open in their stored year, and from the picker as the 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 --- app/src/models/household/v2DocumentCodec.ts | 9 ++++-- .../pages/flagship/HouseholdReport.page.tsx | 16 ++++++----- .../tests/fixtures/api/savedHouseholdMocks.ts | 9 ++++++ .../fixtures/models/v2DocumentCodecMocks.ts | 28 +++++++++++++++++++ .../models/household/v2DocumentCodec.test.ts | 15 ++++++++++ .../flagship/HouseholdReport.page.test.tsx | 20 +++++++++++++ 6 files changed, 88 insertions(+), 9 deletions(-) diff --git a/app/src/models/household/v2DocumentCodec.ts b/app/src/models/household/v2DocumentCodec.ts index 5c6927c46..65d512c56 100644 --- a/app/src/models/household/v2DocumentCodec.ts +++ b/app/src/models/household/v2DocumentCodec.ts @@ -221,12 +221,17 @@ export function haveSameStoredContent(first: Household, second: Household): bool } } -/** A stored v2 household as the app's household model, with the stored id. */ +/** + * A stored v2 household as the app's household model, with the stored id, + * in the stored default year: not the latest year its values name, which + * differs for inputs in several years, and which values without a year + * do not give. + */ export function householdFromV2Item(item: V2HouseholdItem): Household { const { data, spm } = v1DataFromV2Document(item.country_id, item.household_data); const household = Household.fromV1CreationPayload( { country_id: item.country_id as CountryId, data, ...(spm ? { spm } : {}) }, { id: item.id } ); - return household; + return Household.fromAppInput({ ...household.toJSON(), year: item.default_year }); } diff --git a/app/src/pages/flagship/HouseholdReport.page.tsx b/app/src/pages/flagship/HouseholdReport.page.tsx index e10971cac..75a5292c7 100644 --- a/app/src/pages/flagship/HouseholdReport.page.tsx +++ b/app/src/pages/flagship/HouseholdReport.page.tsx @@ -210,14 +210,16 @@ export default function HouseholdReportPage({ } }; + // A saved household opens as the report opens one: in the report's year, + // or, when it cannot move, in its own year with the reason. const openSaved = (next: SavedHousehold) => { - const moved = inReportYear(next.household, year); - if (moved) { - setSaved(next); - setSavedHouseholdOpenings((openings) => openings + 1); - setHousehold(moved); - clear(); - } + const opened = openingHousehold(next.household, year); + setSaved(next); + setSavedHouseholdOpenings((openings) => openings + 1); + setHousehold(opened.household); + setYear(opened.year); + setYearError(opened.yearError); + clear(); }; // Who the result is for: the household calculated, not the builder's diff --git a/app/src/tests/fixtures/api/savedHouseholdMocks.ts b/app/src/tests/fixtures/api/savedHouseholdMocks.ts index 0ecf31edb..794334055 100644 --- a/app/src/tests/fixtures/api/savedHouseholdMocks.ts +++ b/app/src/tests/fixtures/api/savedHouseholdMocks.ts @@ -22,6 +22,7 @@ export const V2_HOUSEHOLD_IDS = { FAMILY: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', EDITED_FAMILY: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', RETIRED: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + TWO_YEAR_FAMILY: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', } as const; export const FAMILY_LABEL = 'Our family'; @@ -43,6 +44,14 @@ export const EDITED_FAMILY_HOUSEHOLD = FAMILY_HOUSEHOLD.setPersonVariableAtYear( 65000 ); +/** The family with next year's earnings also set: its inputs are in two years. */ +export const TWO_YEAR_FAMILY_HOUSEHOLD = FAMILY_HOUSEHOLD.setPersonVariableAtYear( + 'you', + 'employment_income', + String(Number(SAVED_HOUSEHOLD_YEAR) + 1), + 50000 +); + export const RETIRED_HOUSEHOLD = Household.starter('us', SAVED_HOUSEHOLD_YEAR) .withBuilderMaritalStatus(SAVED_HOUSEHOLD_YEAR, 'married') .setPersonVariableAtYear('you', 'age', SAVED_HOUSEHOLD_YEAR, 70) diff --git a/app/src/tests/fixtures/models/v2DocumentCodecMocks.ts b/app/src/tests/fixtures/models/v2DocumentCodecMocks.ts index b20001ece..b1ad2122f 100644 --- a/app/src/tests/fixtures/models/v2DocumentCodecMocks.ts +++ b/app/src/tests/fixtures/models/v2DocumentCodecMocks.ts @@ -150,3 +150,31 @@ export const US_PERSON_WITHOUT_MARITAL_UNIT = Household.fromV1CreationPayload({ marital_units: { 'marital unit': { members: ['you'] } }, }, }); + +/** A UK household stored with inputs in two years: its default year is the earlier one. */ +export const UK_DOCUMENT_WITH_TWO_YEARS: V2HouseholdDocument = { + people: [ + { + id: 'person-1', + source_name: 'you', + values: { age: { 2026: 45 }, employment_income: { 2026: 30000, 2027: 31000 } }, + memberships: { household: 'household-1', benunit: 'benunit-1' }, + }, + ], + household: [{ id: 'household-1', source_name: 'your household', values: {} }], + benunit: [{ id: 'benunit-1', source_name: 'your benefit unit', values: {} }], +}; + +/** A UK household stored with values that name no year, which the v2 document allows. */ +export const UK_DOCUMENT_WITHOUT_YEARS: V2HouseholdDocument = { + people: [ + { + id: 'person-1', + source_name: 'you', + values: { age: 45 }, + memberships: { household: 'household-1', benunit: 'benunit-1' }, + }, + ], + household: [{ id: 'household-1', source_name: 'your household', values: {} }], + benunit: [{ id: 'benunit-1', source_name: 'your benefit unit', values: {} }], +}; diff --git a/app/src/tests/unit/models/household/v2DocumentCodec.test.ts b/app/src/tests/unit/models/household/v2DocumentCodec.test.ts index 71feefa8b..8ada7b752 100644 --- a/app/src/tests/unit/models/household/v2DocumentCodec.test.ts +++ b/app/src/tests/unit/models/household/v2DocumentCodec.test.ts @@ -8,7 +8,9 @@ import { import { STORED_V2_HOUSEHOLD_ID, storedV2Household, + UK_DOCUMENT_WITH_TWO_YEARS, UK_DOCUMENT_WITHOUT_SOURCE_NAMES, + UK_DOCUMENT_WITHOUT_YEARS, UK_SINGLE_PARENT, UK_SINGLE_PARENT_V2_BODY, US_COUPLE_WITH_TWO_CHILDREN, @@ -65,6 +67,19 @@ describe('householdFromV2Item', () => { } }); + test.each([ + ['inputs in two years', UK_DOCUMENT_WITH_TWO_YEARS], + ['values that name no year', UK_DOCUMENT_WITHOUT_YEARS], + ])( + 'given a stored household with %s then it reads back in its stored default year', + (_case, document) => { + const household = householdFromV2Item(storedV2Household('uk', document)); + + expect(household.year).toBe(2026); + expect(householdToV2CreateBody(household).default_year).toBe(2026); + } + ); + test('given a stored household then it carries the stored id and year', () => { const household = householdFromV2Item( storedV2Household('uk', UK_SINGLE_PARENT_V2_BODY.household_data) diff --git a/app/src/tests/unit/pages/flagship/HouseholdReport.page.test.tsx b/app/src/tests/unit/pages/flagship/HouseholdReport.page.test.tsx index 54160436f..aa3e850ff 100644 --- a/app/src/tests/unit/pages/flagship/HouseholdReport.page.test.tsx +++ b/app/src/tests/unit/pages/flagship/HouseholdReport.page.test.tsx @@ -14,6 +14,7 @@ import { SAVED_FAMILY, SAVED_HOUSEHOLD_IDS, storedV2HouseholdItem, + TWO_YEAR_FAMILY_HOUSEHOLD, userHouseholdItem, V2_HOUSEHOLD_IDS, v2HouseholdItem, @@ -528,6 +529,25 @@ describe('HouseholdReportPage', () => { expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); }); + test('given a saved household with inputs in two years then the picker opens it in its own year, saying why', async () => { + const user = userEvent.setup(); + signedInWithFamily({ + 'GET /v2/user-households': () => + pageResponse([userHouseholdItem({ household_id: V2_HOUSEHOLD_IDS.TWO_YEAR_FAMILY })]), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.TWO_YEAR_FAMILY}`]: () => + itemResponse( + v2HouseholdItem(TWO_YEAR_FAMILY_HOUSEHOLD, V2_HOUSEHOLD_IDS.TWO_YEAR_FAMILY) + ), + }); + renderReport({ year: LATER_MODEL_YEAR }); + + await user.click(await screen.findByRole('button', { name: new RegExp(FAMILY_LABEL) })); + + expect(screen.getByLabelText('Household builder')).toHaveTextContent('married · 2'); + expect(screen.getByRole('combobox', { name: 'Year' })).toHaveValue(HOUSEHOLD_YEAR); + expect(screen.getByRole('alert')).toHaveTextContent('inputs for multiple years'); + }); + test('given the report opened from a saved household then it is the one the save form edits', () => { signedInWithFamily(); renderReport({ savedHousehold: SAVED_FAMILY }); From 4954b48e718a572a15f327aa9e6e7f2a940f6b20 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:57:29 +0400 Subject: [PATCH 16/29] Saved households: cache them per country as well as per user 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 --- app/src/hooks/useSavedHouseholds.ts | 43 +++++++++++++------ .../tests/fixtures/api/savedHouseholdMocks.ts | 15 +++++++ .../unit/hooks/useSavedHouseholds.test.tsx | 28 ++++++++++++ 3 files changed, 73 insertions(+), 13 deletions(-) diff --git a/app/src/hooks/useSavedHouseholds.ts b/app/src/hooks/useSavedHouseholds.ts index ccb497d57..6527e26b1 100644 --- a/app/src/hooks/useSavedHouseholds.ts +++ b/app/src/hooks/useSavedHouseholds.ts @@ -16,12 +16,16 @@ import type { Household } from '@/models/Household'; * there are none, and nothing is saved anywhere else. */ -/** Cached saved households are scoped to whose they are (see V3SavedWork). */ +/** + * Cached saved households are scoped to whose they are (see V3SavedWork) + * and to the country, as the store lists one country's saved households. + */ export const savedHouseholdKeys = { - all: (scope: string) => ['saved-households', scope] as const, - list: (scope: string) => ['saved-households', scope, 'list'] as const, - one: (scope: string, savedHouseholdId: string) => - ['saved-households', scope, 'one', savedHouseholdId] as const, + all: (scope: string, countryId: string) => ['saved-households', scope, countryId] as const, + list: (scope: string, countryId: string) => + ['saved-households', scope, countryId, 'list'] as const, + one: (scope: string, countryId: string, savedHouseholdId: string) => + ['saved-households', scope, countryId, 'one', savedHouseholdId] as const, }; const STALE_TIME = 5 * 60 * 1000; @@ -34,9 +38,21 @@ export class SavedHouseholdsUnavailableError extends Error { } } -export function useSavedHouseholdStore(): { store: SavedHouseholdStore | null; scope: string } { +export function useSavedHouseholdStore(): { + store: SavedHouseholdStore | null; + scope: string; + /** The country the store reads and writes; none without accounts. */ + countryId: string | null; +} { const { account, scope } = useV3SavedWork(); - return useMemo(() => ({ store: getSavedHouseholdStore(account), scope }), [account, scope]); + return useMemo( + () => ({ + store: getSavedHouseholdStore(account), + scope, + countryId: account?.countryId ?? null, + }), + [account, scope] + ); } function requireStore(store: SavedHouseholdStore | null): SavedHouseholdStore { @@ -48,9 +64,9 @@ function requireStore(store: SavedHouseholdStore | null): SavedHouseholdStore { /** This session's saved households, most recently updated first. */ export function useSavedHouseholds() { - const { store, scope } = useSavedHouseholdStore(); + const { store, scope, countryId } = useSavedHouseholdStore(); const query = useQuery({ - queryKey: savedHouseholdKeys.list(scope), + queryKey: savedHouseholdKeys.list(scope, countryId ?? ''), queryFn: () => requireStore(store).list(), enabled: !!store, staleTime: STALE_TIME, @@ -67,9 +83,9 @@ export function useSavedHouseholds() { /** One saved household with its household; null when it does not exist. */ export function useSavedHousehold(savedHouseholdId: string | undefined) { - const { store, scope } = useSavedHouseholdStore(); + const { store, scope, countryId } = useSavedHouseholdStore(); return useQuery({ - queryKey: savedHouseholdKeys.one(scope, savedHouseholdId ?? ''), + queryKey: savedHouseholdKeys.one(scope, countryId ?? '', savedHouseholdId ?? ''), queryFn: () => requireStore(store).get(savedHouseholdId!), enabled: !!store && !!savedHouseholdId, staleTime: STALE_TIME, @@ -79,8 +95,9 @@ export function useSavedHousehold(savedHouseholdId: string | undefined) { /** Reads the session's saved households, the list and each one opened, again after a change. */ function useInvalidateSavedHouseholds() { const queryClient = useQueryClient(); - const { scope } = useSavedHouseholdStore(); - return () => queryClient.invalidateQueries({ queryKey: savedHouseholdKeys.all(scope) }); + const { scope, countryId } = useSavedHouseholdStore(); + return () => + queryClient.invalidateQueries({ queryKey: savedHouseholdKeys.all(scope, countryId ?? '') }); } export function useSaveHousehold() { diff --git a/app/src/tests/fixtures/api/savedHouseholdMocks.ts b/app/src/tests/fixtures/api/savedHouseholdMocks.ts index 794334055..d4523a9b9 100644 --- a/app/src/tests/fixtures/api/savedHouseholdMocks.ts +++ b/app/src/tests/fixtures/api/savedHouseholdMocks.ts @@ -15,6 +15,7 @@ export const SAVED_HOUSEHOLD_IDS = { FAMILY: '22222222-2222-4222-8222-222222222222', RETIRED: '66666666-6666-4666-8666-666666666666', V1_ONLY: '77777777-7777-4777-8777-777777777777', + UK_HOME: '88888888-8888-4888-8888-888888888888', } as const; /** Stored v2 households. */ @@ -23,6 +24,7 @@ export const V2_HOUSEHOLD_IDS = { EDITED_FAMILY: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', RETIRED: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', TWO_YEAR_FAMILY: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + UK_HOME: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', } as const; export const FAMILY_LABEL = 'Our family'; @@ -147,3 +149,16 @@ export const SAVED_FAMILY = { createdAt: TIMESTAMPS.CREATED, updatedAt: TIMESTAMPS.UPDATED, }; + +export const UK_HOME_LABEL = 'Our flat in Leeds'; + +/** A UK household saved by the same user. */ +export const UK_HOME_HOUSEHOLD = Household.starter('uk', SAVED_HOUSEHOLD_YEAR); + +/** The UK household's saved row, as `/v2/user-households?country_id=uk` returns it. */ +export const UK_HOME_ROW = userHouseholdItem({ + id: SAVED_HOUSEHOLD_IDS.UK_HOME, + country_id: 'uk', + household_id: V2_HOUSEHOLD_IDS.UK_HOME, + name: UK_HOME_LABEL, +}); diff --git a/app/src/tests/unit/hooks/useSavedHouseholds.test.tsx b/app/src/tests/unit/hooks/useSavedHouseholds.test.tsx index 404c39771..573965781 100644 --- a/app/src/tests/unit/hooks/useSavedHouseholds.test.tsx +++ b/app/src/tests/unit/hooks/useSavedHouseholds.test.tsx @@ -17,6 +17,9 @@ import { RETIRED_LABEL, routeRequests, SAVED_HOUSEHOLD_IDS, + UK_HOME_HOUSEHOLD, + UK_HOME_LABEL, + UK_HOME_ROW, userHouseholdItem, V2_HOUSEHOLD_IDS, v2HouseholdItem, @@ -82,6 +85,31 @@ describe('useSavedHouseholds', () => { expect(result.current.savedHouseholds[0].label).toBe(FAMILY_LABEL); }); + test("given the country changes for the same user then that country's saved households are listed", async () => { + const { fetchMock: usFetch, context: usContext } = createTestApiContext('us'); + const { fetchMock: ukFetch, context: ukContext } = createTestApiContext('uk'); + routeRequests(usFetch, { + 'GET /v2/user-households': () => pageResponse([familyRow]), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHousehold), + }); + const ukRequests = routeRequests(ukFetch, { + 'GET /v2/user-households': () => pageResponse([UK_HOME_ROW]), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.UK_HOME}`]: () => + itemResponse(v2HouseholdItem(UK_HOME_HOUSEHOLD, V2_HOUSEHOLD_IDS.UK_HOME)), + }); + mockSavedWork.mockReturnValue({ account: usContext, scope: SESSION_SCOPE }); + const { result, rerender } = renderWithQueries(() => useSavedHouseholds()); + await waitFor(() => expect(result.current.savedHouseholds).toHaveLength(1)); + + mockSavedWork.mockReturnValue({ account: ukContext, scope: SESSION_SCOPE }); + rerender(); + + await waitFor(() => + expect(result.current.savedHouseholds.map((saved) => saved.label)).toEqual([UK_HOME_LABEL]) + ); + expect(ukRequests[0].query.country_id).toBe('uk'); + }); + test('given a household saved then the list shows it', async () => { const fetchMock = signedInSession(); let rows: unknown[] = []; From 5093f202d29bbe40961d59a391409745b8f978a6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:59:13 +0400 Subject: [PATCH 17/29] Saved households: fetch each stored household once 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 --- app/src/api/savedHouseholdStore.ts | 37 ++++++++++--- .../unit/api/savedHouseholdStore.test.ts | 55 +++++++++++++++++++ 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/app/src/api/savedHouseholdStore.ts b/app/src/api/savedHouseholdStore.ts index 5c6709906..08f00da6f 100644 --- a/app/src/api/savedHouseholdStore.ts +++ b/app/src/api/savedHouseholdStore.ts @@ -141,6 +141,12 @@ function nameFor(label: string): string | null { export class ApiSavedHouseholdStore implements SavedHouseholdStore { private readonly routes: AccountAssociationRoutes; + /** + * Stored households by v2 id. A stored household never changes, so each + * is fetched once for the store's life, not on every read of the list; a + * failed fetch is not kept, so the next read asks again. + */ + private readonly storedHouseholds = new Map>(); constructor(private readonly context: ApiStoreContext) { this.routes = new AccountAssociationRoutes(context, 'user-households', 'Saved household'); @@ -188,7 +194,7 @@ export class ApiSavedHouseholdStore implements SavedHouseholdStore { household_id: stored.id, name: nameFor(label), }); - return savedHouseholdOf(row, stored.id, householdFromV2Item(stored)); + return savedHouseholdOf(row, stored.id, this.remember(stored)); } async rename(savedHouseholdId: string, label: string): Promise { @@ -218,7 +224,7 @@ export class ApiSavedHouseholdStore implements SavedHouseholdStore { if (label !== undefined && nameFor(label) !== saved.label) { body.name = nameFor(label); } - const nextHousehold = stored ? householdFromV2Item(stored) : saved.household; + const nextHousehold = stored ? this.remember(stored) : saved.household; if (Object.keys(body).length === 0) { return { ...saved, household: nextHousehold }; } @@ -242,13 +248,26 @@ export class ApiSavedHouseholdStore implements SavedHouseholdStore { return result.item; } - private async fetchHousehold(countryId: string, householdId: string): Promise { - const result = await this.context.client.request>( - 'GET', - `households/${encodeURIComponent(householdId)}`, - { query: { country_id: countryId } } - ); - return householdFromV2Item(result.item); + /** A stored household as the app's model, kept for later reads. */ + private remember(item: V2HouseholdItem): Household { + const household = householdFromV2Item(item); + this.storedHouseholds.set(item.id, Promise.resolve(household)); + return household; + } + + private fetchHousehold(countryId: string, householdId: string): Promise { + const kept = this.storedHouseholds.get(householdId); + if (kept) { + return kept; + } + const fetched = this.context.client + .request< + V2ItemResult + >('GET', `households/${encodeURIComponent(householdId)}`, { query: { country_id: countryId } }) + .then((result) => householdFromV2Item(result.item)); + this.storedHouseholds.set(householdId, fetched); + fetched.catch(() => this.storedHouseholds.delete(householdId)); + return fetched; } } diff --git a/app/src/tests/unit/api/savedHouseholdStore.test.ts b/app/src/tests/unit/api/savedHouseholdStore.test.ts index fb2b1bb57..89a7ca844 100644 --- a/app/src/tests/unit/api/savedHouseholdStore.test.ts +++ b/app/src/tests/unit/api/savedHouseholdStore.test.ts @@ -40,7 +40,62 @@ const editedHouseholdItem = v2HouseholdItem( V2_HOUSEHOLD_IDS.EDITED_FAMILY ); +const householdReads = (requests: { method: string; path: string }[]) => + requests.filter( + (request) => request.method === 'GET' && request.path.startsWith('/v2/households/') + ); + describe('ApiSavedHouseholdStore', () => { + test('given the list read again then each stored household is fetched once, as stored households never change', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const requests = routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse([familyRow]), + [`GET /v2/user-households/${SAVED_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyRow), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => itemResponse(familyHouseholdItem), + }); + + await store.list(); + const [listedAgain] = await store.list(); + await store.get(SAVED_HOUSEHOLD_IDS.FAMILY); + + expect(householdReads(requests)).toHaveLength(1); + expect(listedAgain.unavailable).toBeFalsy(); + }); + + test('given a stored household that failed to load then the next list asks for it again', async () => { + const { store, fetchMock } = savedHouseholdStore(); + let householdAnswers = 0; + const requests = routeRequests(fetchMock, { + 'GET /v2/user-households': () => pageResponse([familyRow]), + [`GET /v2/households/${V2_HOUSEHOLD_IDS.FAMILY}`]: () => + householdAnswers++ === 0 + ? errorResponse(503, 'Households are temporarily unavailable.') + : itemResponse(familyHouseholdItem), + }); + + const [first] = await store.list(); + const [second] = await store.list(); + + expect(first.unavailable).toBe(true); + expect(second.unavailable).toBeFalsy(); + expect(householdReads(requests)).toHaveLength(2); + }); + + test('given a household just saved then listing it does not fetch it again', async () => { + const { store, fetchMock } = savedHouseholdStore(); + const requests = routeRequests(fetchMock, { + 'POST /v2/households': () => itemResponse(familyHouseholdItem, 201), + 'POST /v2/user-households': () => itemResponse(familyRow, 201), + 'GET /v2/user-households': () => pageResponse([familyRow]), + }); + + await store.save({ household: FAMILY_HOUSEHOLD, label: FAMILY_LABEL }); + const [listed] = await store.list(); + + expect(householdReads(requests)).toHaveLength(0); + expect(listed.unavailable).toBeFalsy(); + }); + test('given a household and a label then its content is stored, and the saved row points at it by its v2 id', async () => { const { store, fetchMock } = savedHouseholdStore(); const requests = routeRequests(fetchMock, { From 63e771b19d4cdffb28639b6588350b0685521acf Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:00:57 +0400 Subject: [PATCH 18/29] Saved work: one delete confirmation for saved reforms and saved households 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 --- ...ialog.tsx => DeleteConfirmationDialog.tsx} | 31 +++++++--- .../flagship/DeleteReformDialog.tsx | 40 +++--------- .../household/SavedHouseholdsList.tsx | 9 ++- .../DeleteConfirmationDialog.test.tsx | 62 +++++++++++++++++++ .../flagship/householdEntryPoints.test.tsx | 1 - 5 files changed, 99 insertions(+), 44 deletions(-) rename app/src/components/flagship/{household/DeleteSavedHouseholdDialog.tsx => DeleteConfirmationDialog.tsx} (64%) create mode 100644 app/src/tests/unit/components/flagship/DeleteConfirmationDialog.test.tsx diff --git a/app/src/components/flagship/household/DeleteSavedHouseholdDialog.tsx b/app/src/components/flagship/DeleteConfirmationDialog.tsx similarity index 64% rename from app/src/components/flagship/household/DeleteSavedHouseholdDialog.tsx rename to app/src/components/flagship/DeleteConfirmationDialog.tsx index 0e62f138b..8233e2712 100644 --- a/app/src/components/flagship/household/DeleteSavedHouseholdDialog.tsx +++ b/app/src/components/flagship/DeleteConfirmationDialog.tsx @@ -1,26 +1,40 @@ +import type { ReactNode } from 'react'; import { IconTrash } from '@tabler/icons-react'; import { AlertDialog } from 'radix-ui'; import { Button } from '@/components/ui'; /** - * A saved household's Delete button. A deleted saved household cannot be - * brought back, so the button asks first. Only the user's saved row goes: - * the stored household stays, as reports and other users may use it. + * A Delete button that asks first, for v3's saved work: a delete cannot be + * undone. The dialog names what is deleted and says what the delete does. */ -export default function DeleteSavedHouseholdDialog({ +export default function DeleteConfirmationDialog({ name, + description, + confirmLabel, disabled, onConfirm, + compact = false, }: { + /** What is deleted, as the dialog's title names it. */ name: string; + /** What the delete does, under the title. */ + description: ReactNode; + /** The button that deletes, e.g. "Delete reform". */ + confirmLabel: string; disabled?: boolean; onConfirm: () => void; + /** A small, borderless Delete button, for a row of a list. */ + compact?: boolean; }) { return ( - @@ -31,8 +45,7 @@ export default function DeleteSavedHouseholdDialog({ Delete “{name}”? - This removes it from your households for good. Reports already run with it are not - changed. + {description}
@@ -40,7 +53,7 @@ export default function DeleteSavedHouseholdDialog({
diff --git a/app/src/components/flagship/DeleteReformDialog.tsx b/app/src/components/flagship/DeleteReformDialog.tsx index e6d7b0cf5..1713abc9c 100644 --- a/app/src/components/flagship/DeleteReformDialog.tsx +++ b/app/src/components/flagship/DeleteReformDialog.tsx @@ -1,6 +1,4 @@ -import { IconTrash } from '@tabler/icons-react'; -import { AlertDialog } from 'radix-ui'; -import { Button } from '@/components/ui'; +import DeleteConfirmationDialog from '@/components/flagship/DeleteConfirmationDialog'; /** What the delete does to the reports run from the reform. */ function reportsNote(reportCount: number): string { @@ -29,34 +27,12 @@ export default function DeleteReformDialog({ onConfirm: () => void; }) { return ( - - - - - - - - - Delete “{name}”? - - - This removes the reform for good.{reportsNote(reportCount)} - -
- - - - - - -
-
-
-
+ ); } diff --git a/app/src/components/flagship/household/SavedHouseholdsList.tsx b/app/src/components/flagship/household/SavedHouseholdsList.tsx index 358538890..f495ff25a 100644 --- a/app/src/components/flagship/household/SavedHouseholdsList.tsx +++ b/app/src/components/flagship/household/SavedHouseholdsList.tsx @@ -3,7 +3,7 @@ import { IconPencil } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import type { ListedSavedHousehold } from '@/api/savedHouseholdStore'; import { RenameIngredientModal } from '@/components/common/RenameIngredientModal'; -import DeleteSavedHouseholdDialog from '@/components/flagship/household/DeleteSavedHouseholdDialog'; +import DeleteConfirmationDialog from '@/components/flagship/DeleteConfirmationDialog'; import { savedHouseholdDescription, savedHouseholdName, @@ -110,8 +110,13 @@ export default function SavedHouseholdsList() { Rename - remove(saved)} /> diff --git a/app/src/tests/unit/components/flagship/DeleteConfirmationDialog.test.tsx b/app/src/tests/unit/components/flagship/DeleteConfirmationDialog.test.tsx new file mode 100644 index 000000000..20f98b324 --- /dev/null +++ b/app/src/tests/unit/components/flagship/DeleteConfirmationDialog.test.tsx @@ -0,0 +1,62 @@ +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import DeleteConfirmationDialog from '@/components/flagship/DeleteConfirmationDialog'; + +const ITEM_NAME = 'Our family'; +const DESCRIPTION = 'This removes it from your households for good.'; +const CONFIRM_LABEL = 'Delete household'; + +function renderDialog(props: Partial> = {}) { + const onConfirm = vi.fn(); + render( + + ); + return { onConfirm }; +} + +describe('DeleteConfirmationDialog', () => { + test('given Delete then it asks first, naming what is deleted and what the delete does', async () => { + const user = userEvent.setup(); + const { onConfirm } = renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(screen.getByRole('alertdialog', { name: `Delete “${ITEM_NAME}”?` })).toHaveTextContent( + DESCRIPTION + ); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + test('given the delete confirmed then it is done', async () => { + const user = userEvent.setup(); + const { onConfirm } = renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + await user.click(screen.getByRole('button', { name: CONFIRM_LABEL })); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + test('given Cancel then nothing is deleted', async () => { + const user = userEvent.setup(); + const { onConfirm } = renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onConfirm).not.toHaveBeenCalled(); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + + test('given a delete already under way then Delete cannot be pressed', () => { + renderDialog({ disabled: true }); + + expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled(); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/householdEntryPoints.test.tsx b/app/src/tests/unit/pages/flagship/householdEntryPoints.test.tsx index 4d62c08c2..9cd31d4f5 100644 --- a/app/src/tests/unit/pages/flagship/householdEntryPoints.test.tsx +++ b/app/src/tests/unit/pages/flagship/householdEntryPoints.test.tsx @@ -27,7 +27,6 @@ const UNROUTED_MODULES = [ '@/components/flagship/household/SaveHouseholdForm', '@/components/flagship/household/SavedHouseholdPicker', '@/components/flagship/household/SavedHouseholdsList', - '@/components/flagship/household/DeleteSavedHouseholdDialog', '@/components/flagship/household/savedHouseholdDisplay', '@/components/flagship/household/HouseholdNetIncomeResult', '@/hooks/useSavedHouseholds', From 685ea577eb1aab578f40a2596bc9fb6c30dac86f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:02:15 +0400 Subject: [PATCH 19/29] v3 household features: design tokens for new styles, and the report's 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 --- .../household/HouseholdNetIncomeResult.tsx | 4 +- .../flagship/household/SaveHouseholdForm.tsx | 2 +- .../pages/flagship/SavedHouseholds.page.tsx | 2 +- .../flagship/householdReportPageMocks.ts | 34 ++++++++++++++++ .../flagship/HouseholdReport.page.test.tsx | 39 +++++-------------- 5 files changed, 48 insertions(+), 33 deletions(-) create mode 100644 app/src/tests/fixtures/pages/flagship/householdReportPageMocks.ts diff --git a/app/src/components/flagship/household/HouseholdNetIncomeResult.tsx b/app/src/components/flagship/household/HouseholdNetIncomeResult.tsx index 8416ebaaf..092ebdae9 100644 --- a/app/src/components/flagship/household/HouseholdNetIncomeResult.tsx +++ b/app/src/components/flagship/household/HouseholdNetIncomeResult.tsx @@ -46,7 +46,7 @@ export default function HouseholdNetIncomeResult({ return (
- + Household net income @@ -63,7 +63,7 @@ export default function HouseholdNetIncomeResult({ flexDirection: 'column', gap: spacing.xs, padding: spacing.lg, - borderRadius: 10, + borderRadius: spacing.radius.feature, background: colors.gray[50], }} > diff --git a/app/src/components/flagship/household/SaveHouseholdForm.tsx b/app/src/components/flagship/household/SaveHouseholdForm.tsx index 7bfc61303..82783d1d5 100644 --- a/app/src/components/flagship/household/SaveHouseholdForm.tsx +++ b/app/src/components/flagship/household/SaveHouseholdForm.tsx @@ -91,7 +91,7 @@ export default function SaveHouseholdForm({ placeholder="For example, our family" maxLength={255} onChange={(event) => setLabel(event.target.value)} - style={{ maxWidth: 320 }} + className="tw:max-w-xs" />