Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b8340c0
v2 household documents: write and read households as the Public API s…
anth-volk Oct 21, 2026
f901c02
Saved households on /v2/households and /v2/user-households
anth-volk Oct 21, 2026
468ecf5
Saved households: query and mutation hooks for the session
anth-volk Oct 21, 2026
edc34c6
Saved households: a label field that saves a household or its changes
anth-volk Oct 21, 2026
331ae18
Saved households: a picker that opens one
anth-volk Oct 22, 2026
2b30c5b
Saved households: a list to rename or delete them
anth-volk Oct 22, 2026
7e9f143
Reproduce in Python: an option to end each block with a pandas DataFrame
anth-volk Oct 22, 2026
e4e00c5
Earnings variation: offer the inputs a household sets, for the househ…
anth-volk Oct 22, 2026
0924c10
v3 household builder: one configuration for the report section and th…
anth-volk Oct 22, 2026
45fd803
v3 household results: net income under one policy, for baseline-only …
anth-volk Oct 22, 2026
2c89dfb
v3 standalone household report
anth-volk Oct 22, 2026
f35523d
v3 household features: one entry-point module, unreachable until v3 m…
anth-volk Oct 22, 2026
753db26
Household report: trying again never sends a household that cannot run
anth-volk Oct 22, 2026
749c10a
Save household form: "Saved" shows only while it describes what is on…
anth-volk Oct 22, 2026
9730c73
Saved households: open in their stored year, and from the picker as t…
anth-volk Oct 22, 2026
4954b48
Saved households: cache them per country as well as per user
anth-volk Oct 22, 2026
5093f20
Saved households: fetch each stored household once
anth-volk Oct 22, 2026
63e771b
Saved work: one delete confirmation for saved reforms and saved house…
anth-volk Oct 22, 2026
685ea57
v3 household features: design tokens for new styles, and the report's…
anth-volk Oct 22, 2026
795f373
Household report: a save that finishes after another household is ope…
anth-volk Oct 22, 2026
b9b75c3
Household report: no saved household opens while a calculation runs
anth-volk Oct 22, 2026
dc80ffe
Saved households: one stored-household cache for the session
anth-volk Oct 22, 2026
ee64d95
Saved households: a failed save of changes always says the changes we…
anth-volk Oct 22, 2026
2893069
v3 households: one Calculate row, run progress and run failure for bo…
anth-volk Oct 22, 2026
76d230f
v2 household documents: type each collection instead of converting th…
anth-volk Oct 22, 2026
0ccaac7
Save household form: moving a saved household into the report's year …
anth-volk Oct 22, 2026
03eb4e3
Save household form: after a save, the form edits what it saved
anth-volk Oct 22, 2026
e922241
Saved-household picker and household output tabs: hover colors that a…
anth-volk Oct 22, 2026
f559675
v3 household features: the account's country type, the codec fixture'…
anth-volk Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
317 changes: 317 additions & 0 deletions app/src/api/savedHouseholdStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
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<UnavailableSavedHousehold, 'unavailable'>;

/** 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<ListedSavedHousehold[]>;
/** A saved household with its household; null when it does not exist (or is someone else's). */
get: (savedHouseholdId: string) => Promise<SavedHousehold | null>;
save: (args: { household: Household; label: string }) => Promise<SavedHousehold>;
rename: (savedHouseholdId: string, label: string) => Promise<SavedHouseholdRow>;
/** Deletes the saved row; the stored household stays, as households are shared. */
remove: (savedHouseholdId: string) => Promise<void>;
/**
* 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<SavedHousehold>;
}

/**
* Where a store keeps stored households. A stored household never
* changes, so each is fetched once, not on every read of the list.
*/
export interface StoredHouseholdCache {
/** The stored household; `fetchHousehold` runs only when it is not kept, and a failed fetch is not kept. */
get: (
countryId: string,
householdId: string,
fetchHousehold: () => Promise<Household>
) => Promise<Household>;
/** Keeps a stored household the store already has, such as one it has just saved. */
set: (countryId: string, householdId: string, household: Household) => void;
}

/** Stored households kept for one store's life: for a store used on its own. */
export function storeLifetimeHouseholdCache(): StoredHouseholdCache {
const kept = new Map<string, Promise<Household>>();
const keyOf = (countryId: string, householdId: string) => `${countryId}/${householdId}`;
return {
get: (countryId, householdId, fetchHousehold) => {
const key = keyOf(countryId, householdId);
const existing = kept.get(key);
if (existing) {
return existing;
}
const fetched = fetchHousehold();
kept.set(key, fetched);
fetched.catch(() => kept.delete(key));
return fetched;
},
set: (countryId, householdId, household) => {
kept.set(keyOf(countryId, householdId), Promise.resolve(household));
},
};
}

/** 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<T extends { updatedAt: string; createdAt: string }>(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<UserHouseholdItem>;

constructor(
private readonly context: ApiStoreContext,
/** Where stored households are kept; the session's, from the hooks. */
private readonly storedHouseholds: StoredHouseholdCache = storeLifetimeHouseholdCache()
) {
this.routes = new AccountAssociationRoutes(context, 'user-households', 'Saved household');
}

async list(): Promise<ListedSavedHousehold[]> {
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<SavedHousehold | null> {
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<UserHouseholdCreateBody>({
country_id: stored.country_id,
household_id: stored.id,
name: nameFor(label),
});
return savedHouseholdOf(row, stored.id, this.remember(stored));
}

async rename(savedHouseholdId: string, label: string): Promise<SavedHouseholdRow> {
const body: UserHouseholdPatchBody = { name: nameFor(label) };
return rowOf(await this.routes.patch(savedHouseholdId, body));
}

remove(savedHouseholdId: string): Promise<void> {
return this.routes.delete(savedHouseholdId);
}

async saveChanges(
saved: SavedHousehold,
{ household, label }: SavedHouseholdChanges
): Promise<SavedHousehold> {
// 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();
}
let stored: V2HouseholdItem | null = null;
try {
stored = household ? await this.storeHousehold(household) : null;
} catch (error) {
// Nothing has changed: the saved household still points at what it did.
throw new SavedHouseholdChangesNotSavedError(error);
}
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 ? this.remember(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<V2HouseholdItem> {
const body = householdToV2CreateBody(household);
const result = await this.context.client.request<V2ItemResult<V2HouseholdItem>>(
'POST',
'households',
{ query: { country_id: body.country_id }, body }
);
return 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.country_id, item.id, household);
return household;
}

private fetchHousehold(countryId: string, householdId: string): Promise<Household> {
return this.storedHouseholds.get(countryId, householdId, async () => {
const result = await this.context.client.request<V2ItemResult<V2HouseholdItem>>(
'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,
storedHouseholds?: StoredHouseholdCache
): SavedHouseholdStore | null {
return account ? new ApiSavedHouseholdStore(account, storedHouseholds) : null;
}
64 changes: 64 additions & 0 deletions app/src/components/flagship/DeleteConfirmationDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { ReactNode } from 'react';
import { IconTrash } from '@tabler/icons-react';
import { AlertDialog } from 'radix-ui';
import { Button } from '@/components/ui';

/**
* 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 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 (
<AlertDialog.Root>
<AlertDialog.Trigger asChild>
<Button
size={compact ? 'sm' : 'default'}
variant={compact ? 'ghost' : 'outline'}
disabled={disabled}
>
<IconTrash size={compact ? 14 : 16} aria-hidden />
Delete
</Button>
</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay className="tw:fixed tw:inset-0 tw:z-50 tw:bg-black/50" />
<AlertDialog.Content className="tw:bg-background tw:fixed tw:top-[50%] tw:left-[50%] tw:z-50 tw:grid tw:w-full tw:max-w-[calc(100%-2rem)] tw:translate-x-[-50%] tw:translate-y-[-50%] tw:gap-4 tw:rounded-lg tw:border tw:p-6 tw:shadow-lg tw:outline-none tw:sm:max-w-[28rem]">
<AlertDialog.Title className="tw:text-lg tw:leading-snug tw:font-semibold">
Delete “{name}”?
</AlertDialog.Title>
<AlertDialog.Description className="tw:text-muted-foreground tw:text-sm">
{description}
</AlertDialog.Description>
<div className="tw:flex tw:flex-col-reverse tw:gap-2 tw:sm:flex-row tw:sm:justify-end">
<AlertDialog.Cancel asChild>
<Button variant="outline">Cancel</Button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<Button variant="destructive" onClick={onConfirm}>
{confirmLabel}
</Button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
);
}
Loading
Loading