From 398c52b639a875c18d68e96fc13c843a2612acaf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:07:34 +0000 Subject: [PATCH 01/11] Remove the Currency preference, which nothing read Each commune keeps its own currency, so the Settings choice changed nothing on any screen. The preference, its stored value and its state go; the Profile window no longer lists it among the settings. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/src/screens/Settings.tsx | 19 ----------- apps/web/src/state.tsx | 8 +---- apps/web/src/windows/Windows.tsx | 2 +- apps/web/tests/preferences.test.tsx | 51 +++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 27 deletions(-) create mode 100644 apps/web/tests/preferences.test.tsx diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index 5e3ccbc9..cc93cbe1 100644 --- a/apps/web/src/screens/Settings.tsx +++ b/apps/web/src/screens/Settings.tsx @@ -25,8 +25,6 @@ export function SettingsScreen() { setDark, noticesOn, setNoticesOn, - currency, - setCurrency, signOut, you, setAvatar, @@ -163,23 +161,6 @@ export function SettingsScreen() { disabled={emailReminders.enabled === null || emailReminders.saving} /> -
- - Currency - Your display preference; each commune keeps its own currency. - - -
Language diff --git a/apps/web/src/state.tsx b/apps/web/src/state.tsx index 819345c7..584f8328 100644 --- a/apps/web/src/state.tsx +++ b/apps/web/src/state.tsx @@ -191,7 +191,6 @@ type LocalPrefs = { navMode: NavMode; dark: boolean; noticesOn: boolean; - currency: "£ GBP" | "$ USD" | "€ EUR"; payMethods: PayMethod[]; hubPhotos: Partial>; avatar?: string; @@ -261,7 +260,6 @@ export type AppState = { selectedOperation: OperationItem | null; dark: boolean; noticesOn: boolean; - currency: "£ GBP" | "$ USD" | "€ EUR"; searchQuery: string; searchScope: string | null; searchTarget: SearchTarget | null; @@ -321,7 +319,6 @@ export type AppState = { setNavMode: (mode: NavMode) => void; setDark: (on: boolean) => void; setNoticesOn: (on: boolean) => void; - setCurrency: (currency: "£ GBP" | "$ USD" | "€ EUR") => void; setSearchQuery: (query: string) => void; setSearchScope: (id: string | null) => void; clearSearchTarget: () => void; @@ -412,7 +409,6 @@ const DEFAULT_PREFS: LocalPrefs = { navMode: "basic", dark: false, noticesOn: true, - currency: "£ GBP", payMethods: [], hubPhotos: {}, }; @@ -438,7 +434,6 @@ function readPrefs(userId: string): LocalPrefs { navMode: parsed.navMode === "advanced" ? "advanced" : "basic", dark: parsed.dark === true, noticesOn: parsed.noticesOn !== false, - currency: parsed.currency === "$ USD" || parsed.currency === "€ EUR" ? parsed.currency : "£ GBP", payMethods: Array.isArray(parsed.payMethods) ? parsed.payMethods.filter( (method): method is PayMethod => !!method && typeof method.value === "string" && ["Revolut", "PayPal", "Monzo", "Bank transfer"].includes(method.provider), @@ -1654,7 +1649,7 @@ export function AppProvider({ children }: { children: ReactNode }) { hub, places, snapshots: data?.snapshots ?? {}, contentLoading, contentError, emptyHub: empty, window: windowId, moreOpen, navMode: prefs.navMode, noteKey, notes, operationKey, operationDate, operationTime, operationsByHub, operations, selectedOperation, - dark: prefs.dark, noticesOn: prefs.noticesOn, currency: prefs.currency, + dark: prefs.dark, noticesOn: prefs.noticesOn, searchQuery, searchScope, searchTarget, noteSearchQuery, invites: data?.invites[activeId] ?? [], payMethods: prefs.payMethods, native, you, lastExpense, expenses, selectedExpense, createdExpenses: expenses, voidedExpenses, expenseRead, retryExpenseRead, expenseHistory, loadEarlierExpenses, loadFullHistory, @@ -1665,7 +1660,6 @@ export function AppProvider({ children }: { children: ReactNode }) { setNavMode: (navMode) => savePrefs({ navMode }), setDark: (dark) => savePrefs({ dark }), setNoticesOn: (noticesOn) => savePrefs({ noticesOn }), - setCurrency: (currency) => savePrefs({ currency }), setSearchQuery, setSearchScope, clearSearchTarget, setNoteSearchQuery, savePayMethod, setHubPhoto, setAvatar, addNote, updateNote, loadNoteRevision, addOperation, updateOperation, toggleOperation, skipOperation, handOverOperation, undoOperation, removeOperation, createHub, updateCommune, recordExpense, confirmExpenseShare, correctExpense, voidExpense, recordShareReceipt, reverseShareReceipt, diff --git a/apps/web/src/windows/Windows.tsx b/apps/web/src/windows/Windows.tsx index ca520d88..5e8986f2 100644 --- a/apps/web/src/windows/Windows.tsx +++ b/apps/web/src/windows/Windows.tsx @@ -775,7 +775,7 @@ function Profile() {
Settings -
Currency, notices, sidebar
+
Dark mode, notices, sidebar
- Dark mode - Ink canvas. Off keeps the cream paper. + Theme + Cream paper or ink canvas. Match this device follows its light or dark setting. - +
@@ -288,7 +292,7 @@ export function CommuneSettingsScreen() { onPhoto={(dataUrl) => setHubPhoto(hub.id, dataUrl)} onClear={() => setHubPhoto(hub.id, null)} /> -

This photo is kept in this browser session for now. It is not shared with other members.

+

This photo is kept in this browser for now. It is not shared with other members.

diff --git a/apps/web/src/state.tsx b/apps/web/src/state.tsx index 584f8328..1d32d2b2 100644 --- a/apps/web/src/state.tsx +++ b/apps/web/src/state.tsx @@ -31,12 +31,27 @@ import { type OperationItem, type OperationStore, } from "./data/operations"; -import { isStoredImage, readHubPhotos } from "./data/images"; import { newRequestId } from "./data/requestId"; import { isNativeNow, keepSearch, pathOnly } from "./nav"; import { api, post, loadCommunes, loadExpensePage, loadExpenseRecords, loadSnapshot, ApiError } from "./live/api"; import { announceSession, currentViewer, onOtherTabSession, setViewer } from "./live/session"; -import { readStored, removeStored, writeStored } from "./live/storage"; +import { + DEFAULT_ACCOUNT, + DEFAULT_DEVICE, + DEVICE_KEY, + accountKey, + adoptTabPrefs, + forgetAccountPrefs, + readAccountPrefs, + readDevicePrefs, + saveAccountPrefs, + saveDevicePrefs, + type AccountPrefs, + type DevicePrefs, + type NavMode, + type PayMethod, + type Theme, +} from "./live/preferences"; import { expenseRows, hubFromSnapshot, @@ -67,9 +82,8 @@ import { type AnnouncementResult, } from "./live/model"; -const LOCAL_PREFIX = "commune.local."; export type Seed = "new"; -export type NavMode = "basic" | "advanced"; +export type { NavMode, PayMethod, Theme }; /** An invitation and where it is in its life. Admins see them all. */ export type PendingInvite = { /** Null once the person who accepted it has left: their address is theirs. */ @@ -88,10 +102,6 @@ export function inviteStatus(invite: Pick now ? "pending" : "expired"; } -export type PayMethod = { - provider: "Revolut" | "PayPal" | "Monzo" | "Bank transfer"; - value: string; -}; export type Session = { userId: string; name: string; @@ -186,15 +196,6 @@ type GoOpts = { record?: { kind: "fund" | "recurring" | "member" | "template"; id: string }; }; export type SearchTarget = NonNullable & { communeId: string }; -type LocalPrefs = { - activeHubId?: string; - navMode: NavMode; - dark: boolean; - noticesOn: boolean; - payMethods: PayMethod[]; - hubPhotos: Partial>; - avatar?: string; -}; type LiveData = { user: User; communes: Commune[]; @@ -258,7 +259,9 @@ export type AppState = { operationsByHub: OperationStore; operations: OperationItem[]; selectedOperation: OperationItem | null; + /** Whether the app is dark now: chosen, or following the device. */ dark: boolean; + theme: Theme; noticesOn: boolean; searchQuery: string; searchScope: string | null; @@ -317,7 +320,7 @@ export type AppState = { closeWindow: () => void; setMoreOpen: (open: boolean) => void; setNavMode: (mode: NavMode) => void; - setDark: (on: boolean) => void; + setTheme: (theme: Theme) => void; setNoticesOn: (on: boolean) => void; setSearchQuery: (query: string) => void; setSearchScope: (id: string | null) => void; @@ -405,14 +408,6 @@ export type AppState = { const Ctx = createContext(null); -const DEFAULT_PREFS: LocalPrefs = { - navMode: "basic", - dark: false, - noticesOn: true, - payMethods: [], - hubPhotos: {}, -}; - function endedNotice(unsaved: boolean): string { return unsaved ? "You were signed out, so your last change wasn’t saved. Sign in to try again." @@ -423,27 +418,19 @@ function endedNotice(unsaved: boolean): string { // itself; only one that nothing caught reaches the alert strip. const commandFailures = new WeakSet(); -function readPrefs(userId: string): LocalPrefs { - try { - const raw = readStored(`${LOCAL_PREFIX}${userId}`); - if (!raw) return { ...DEFAULT_PREFS }; - const parsed = JSON.parse(raw) as Partial; - const photos = readHubPhotos(parsed.hubPhotos); - return { - activeHubId: typeof parsed.activeHubId === "string" ? parsed.activeHubId : undefined, - navMode: parsed.navMode === "advanced" ? "advanced" : "basic", - dark: parsed.dark === true, - noticesOn: parsed.noticesOn !== false, - payMethods: Array.isArray(parsed.payMethods) ? parsed.payMethods.filter( - (method): method is PayMethod => !!method && typeof method.value === "string" && - ["Revolut", "PayPal", "Monzo", "Bank transfer"].includes(method.provider), - ) : [], - hubPhotos: photos ?? {}, - avatar: isStoredImage(parsed.avatar) ? parsed.avatar : undefined, - }; - } catch { - return { ...DEFAULT_PREFS }; - } +// Whether the device is set to dark, followed while the theme is "system". +function usePrefersDark(): boolean { + const query = () => typeof window.matchMedia === "function" ? window.matchMedia("(prefers-color-scheme: dark)") : null; + const [dark, setDark] = useState(() => query()?.matches === true); + useEffect(() => { + const media = query(); + if (!media) return; + const update = () => setDark(media.matches); + update(); + media.addEventListener?.("change", update); + return () => media.removeEventListener?.("change", update); + }, []); + return dark; } function emptyHub(): LiveHub { @@ -558,7 +545,9 @@ export function AppProvider({ children }: { children: ReactNode }) { dataRef.current = next; setData(next); }, []); - const [prefs, setPrefs] = useState({ ...DEFAULT_PREFS }); + const [prefs, setPrefs] = useState(DEFAULT_ACCOUNT); + const [device, setDevice] = useState(() => readDevicePrefs() ?? DEFAULT_DEVICE); + const prefersDark = usePrefersDark(); const [hubId, setHubId] = useState(""); const [snapshotErrors, setSnapshotErrors] = useState>({}); const [windowId, setWindowId] = useState(null); @@ -640,7 +629,7 @@ export function AppProvider({ children }: { children: ReactNode }) { const current = dataRef.current; if (!current || sequence !== refreshSequence.current) return; const active = current.communes.find((item) => item.id === viewRef.current.hubId) - ?? current.communes.find((item) => item.id === readPrefs(current.user.id).activeHubId) + ?? current.communes.find((item) => item.id === readAccountPrefs(current.user.id)?.activeHubId) ?? current.communes[0]; if (!active) return; await readSnapshot(active, sequence, force); @@ -666,8 +655,9 @@ export function AppProvider({ children }: { children: ReactNode }) { snapshotRevisions.current.clear(); setHubId(""); setWindowId(null); - setPrefs({ ...DEFAULT_PREFS }); - if (userId) removeStored(`${LOCAL_PREFIX}${userId}`); + // Device preferences stay; the account's go with it. + setPrefs(DEFAULT_ACCOUNT); + if (userId) forgetAccountPrefs(userId); setSessionNotice(notice); setAuthStatus("guest"); loadedRef.current = true; @@ -722,6 +712,7 @@ export function AppProvider({ children }: { children: ReactNode }) { user = result.user; } const viewer = user; + adoptTabPrefs(viewer.id); const { communes } = await startupRead(() => loadCommunes(viewer)); if (sequence !== refreshSequence.current) return; const previous = dataRef.current?.user.id === viewer.id ? dataRef.current : null; @@ -748,8 +739,11 @@ export function AppProvider({ children }: { children: ReactNode }) { }); setAuthStatus("authenticated"); setHubId((current) => communes.some((commune) => commune.id === current) - ? current : communes.find((commune) => commune.id === readPrefs(viewer.id).activeHubId)?.id ?? communes[0]?.id ?? ""); - if (!previous) setPrefs(readPrefs(viewer.id)); + ? current : communes.find((commune) => commune.id === readAccountPrefs(viewer.id)?.activeHubId)?.id ?? communes[0]?.id ?? ""); + if (!previous) { + setPrefs(readAccountPrefs(viewer.id) ?? DEFAULT_ACCOUNT); + setDevice((shown) => readDevicePrefs() ?? shown); + } loadedRef.current = true; // Counts and invitations must not delay the commune itself. A check // reads the counts when something changed or a reminder slot has opened. @@ -896,15 +890,31 @@ export function AppProvider({ children }: { children: ReactNode }) { return () => window.removeEventListener("unhandledrejection", onUnhandled); }, []); - const savePrefs = useCallback((patch: Partial) => { + // Each change starts from what the browser keeps, which another tab may + // have changed since. A full or blocked store keeps it for this visit only. + const savePrefs = useCallback((patch: Partial) => { if (!data) return; - const next = { ...prefs, ...patch }; - // A full or blocked store keeps the change for this visit only. - if (!writeStored(`${LOCAL_PREFIX}${data.user.id}`, JSON.stringify(next))) { + const next = { ...(readAccountPrefs(data.user.id) ?? prefs), ...patch }; + if (!saveAccountPrefs(data.user.id, next)) { console.warn("This browser could not keep a Commune preference"); } setPrefs(next); }, [data, prefs]); + const saveDevice = useCallback((patch: Partial) => { + const next = { ...(readDevicePrefs() ?? device), ...patch }; + if (!saveDevicePrefs(next)) console.warn("This browser could not keep a Commune preference"); + setDevice(next); + }, [device]); + // A change made in another tab shows here too. + const viewerId = data?.user.id; + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if (event.key === null || event.key === DEVICE_KEY) setDevice(readDevicePrefs() ?? DEFAULT_DEVICE); + if (viewerId && (event.key === null || event.key === accountKey(viewerId))) setPrefs(readAccountPrefs(viewerId) ?? DEFAULT_ACCOUNT); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, [viewerId]); // A sign-in ends in a refresh, which tells the other tabs. For Google it is // the one that runs when the page comes back from Google. @@ -1647,9 +1657,9 @@ export function AppProvider({ children }: { children: ReactNode }) { authStatus, apiError, mutationPending: mutationCount > 0, clearApiError: () => setApiError(null), refresh: fullRefresh, signInWithPassword, signInWithGoogle, signUpWithPassword, verifyEmailCode, session, screen, hubId: activeId, hub, places, snapshots: data?.snapshots ?? {}, contentLoading, contentError, emptyHub: empty, window: windowId, moreOpen, - navMode: prefs.navMode, noteKey, notes, operationKey, operationDate, + navMode: device.navMode, noteKey, notes, operationKey, operationDate, operationTime, operationsByHub, operations, selectedOperation, - dark: prefs.dark, noticesOn: prefs.noticesOn, + dark: device.theme === "dark" || (device.theme === "system" && prefersDark), theme: device.theme, noticesOn: device.noticesOn, searchQuery, searchScope, searchTarget, noteSearchQuery, invites: data?.invites[activeId] ?? [], payMethods: prefs.payMethods, native, you, lastExpense, expenses, selectedExpense, createdExpenses: expenses, voidedExpenses, expenseRead, retryExpenseRead, expenseHistory, loadEarlierExpenses, loadFullHistory, @@ -1657,9 +1667,9 @@ export function AppProvider({ children }: { children: ReactNode }) { signOut, signOutUnconfirmed, retrySignOut: confirmSignOut, sessionNotice, go, changePassword, signOutOtherDevices, closeWindow: () => { if (!mutationCount) setWindowId(null); }, setMoreOpen, - setNavMode: (navMode) => savePrefs({ navMode }), - setDark: (dark) => savePrefs({ dark }), - setNoticesOn: (noticesOn) => savePrefs({ noticesOn }), + setNavMode: (navMode) => saveDevice({ navMode }), + setTheme: (theme) => saveDevice({ theme }), + setNoticesOn: (noticesOn) => saveDevice({ noticesOn }), setSearchQuery, setSearchScope, clearSearchTarget, setNoteSearchQuery, savePayMethod, setHubPhoto, setAvatar, addNote, updateNote, loadNoteRevision, addOperation, updateOperation, toggleOperation, skipOperation, handOverOperation, undoOperation, removeOperation, createHub, updateCommune, recordExpense, confirmExpenseShare, correctExpense, voidExpense, recordShareReceipt, reverseShareReceipt, diff --git a/apps/web/src/windows/Windows.tsx b/apps/web/src/windows/Windows.tsx index 5e8986f2..caa82417 100644 --- a/apps/web/src/windows/Windows.tsx +++ b/apps/web/src/windows/Windows.tsx @@ -764,7 +764,7 @@ function Profile() { You

- Your photo is saved in this browser session. Expense shares and balances are shared with your commune. + Your photo is saved in this browser. Expense shares and balances are shared with your commune.

@@ -775,7 +775,7 @@ function Profile() {
Settings -
Dark mode, notices, sidebar
+
Theme, notices, sidebar
; + } + if (contentLoading && screen !== "settings" && screen !== "search") { if (contentError) return

Could not load {hub.name}

{contentError}

; return
Loading shared records{screen === "hub" &&

{hub.name}

{hub.switchMeta}

} - {asking ? { setAsking(false); closeWindow(); }} onKeep={() => setAsking(false)} /> : null} + {asking ? { setAsking(false); closeWindow(); }} onKeep={() => setAsking(false)} /> + : leaveAsked ? answerLeave(true)} onKeep={() => answerLeave(false)} /> : null}
); } @@ -560,14 +566,7 @@ function ExpensePaid() { share after receiving payment outside the app.

-
diff --git a/apps/web/tests/addresses.test.tsx b/apps/web/tests/addresses.test.tsx new file mode 100644 index 00000000..5c5eb1e9 --- /dev/null +++ b/apps/web/tests/addresses.test.tsx @@ -0,0 +1,413 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { Router } from "wouter"; +import { memoryLocation } from "wouter/memory-location"; +import { App } from "../src/App"; +import { AppProvider, useApp, type AppState } from "../src/state"; +import { api, loadCommunes, loadSnapshot, post } from "../src/live/api"; +import type { Commune, Expense, Snapshot } from "../src/live/model"; +import { todayIso, type OperationItem } from "../src/data/operations"; +import { WINDOW_IDS, WINDOW_PAGE } from "../src/data/household"; +import { addressOf, readAddress, safeReturnPath } from "../src/nav"; +import { setViewer } from "../src/live/session"; + +vi.mock("../src/live/api", async (importOriginal) => ({ + ...await importOriginal(), + api: vi.fn(), post: vi.fn(), loadCommunes: vi.fn(), loadSnapshot: vi.fn(), +})); + +const user = { id: "user-1", name: "Test Person", email: "test@example.test", emailVerified: true }; +const housemate = { userId: "user-2", name: "Housemate", role: "member" as const }; +const home: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin" }; +const flat: Commune = { id: "flat-4", name: "Flat 4", kind: "household", currency: "GBP", role: "member" }; +const rent: Expense = { id: "expense-1", title: "Rent", amountMinor: 120000, paidByUserId: user.id, dueOn: null, + shares: [{ id: "share-1", userId: user.id, amountMinor: 60000, settledMinor: 60000 }, { id: "share-2", userId: housemate.userId, amountMinor: 60000, settledMinor: 0 }] }; +const rules = { id: "note-1", title: "House rules", pill: "House rules", kicker: "Everyone", lede: "", body: "Quiet after 11", facts: [] as Array<[string, string]> }; +const bins: OperationItem = { id: "task-1", hubId: home.id, kind: "task", category: "chore", title: "Bins", date: todayIso(), allDay: true, + assignee: user.name, repeat: "none", status: "open", notes: "" }; +const fund = { id: "fund-1", name: "House fund", targetMinor: 20000, commitments: [] }; +function snapshotOf(item: Commune): Snapshot { + const members = [{ userId: user.id, name: user.name, role: item.role }, housemate]; + return item.id === home.id + ? { commune: home, members, expenses: [rent], notes: [rules], operations: [bins], funds: [fund], notifications: [], reminderTargets: [] } + : { commune: flat, members, expenses: [], funds: [], notifications: [], reminderTargets: [] }; +} + +let signedIn: typeof user | null = user; +let app: AppState; +function Capture() { app = useApp(); return null; } +function mount(path: string) { + const location = memoryLocation({ path, record: true }); + render(); + return location; +} + +// jsdom does not implement the browser's native modal top layer. +const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal"); +const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close"); +const originalScroll = Element.prototype.scrollIntoView; +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { + configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); }, + }); + Object.defineProperty(HTMLDialogElement.prototype, "close", { + configurable: true, value: function (this: HTMLDialogElement) { this.removeAttribute("open"); }, + }); +}); +afterAll(() => { + Element.prototype.scrollIntoView = originalScroll; + if (modalDescriptor) Object.defineProperty(HTMLDialogElement.prototype, "showModal", modalDescriptor); + else delete (HTMLDialogElement.prototype as unknown as Record).showModal; + if (closeDescriptor) Object.defineProperty(HTMLDialogElement.prototype, "close", closeDescriptor); + else delete (HTMLDialogElement.prototype as unknown as Record).close; +}); + +beforeEach(() => { + vi.resetAllMocks(); + sessionStorage.clear(); + // A new tab, which has shown no one yet. + setViewer(null); + signedIn = user; + document.title = "Commune"; + vi.mocked(api).mockImplementation(async (path) => { + if (path === "/auth/me") return { user: signedIn }; + if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 6, recipientUsage: {}, nextSlotAt: null }; + if (path.endsWith("/invites")) return { invites: [] }; + if (path === "/preferences") return { emailReminders: true }; + throw new Error(`Unexpected API request ${path}`); + }); + vi.mocked(post).mockImplementation(async (path) => { + if (path === "/auth/login") signedIn = user; + return { ok: true }; + }); + vi.mocked(loadCommunes).mockResolvedValue({ communes: [home, flat] }); + vi.mocked(loadSnapshot).mockImplementation(async (item) => snapshotOf(item)); +}); +afterEach(cleanup); + +describe("an address for every window", () => { + it("reads back what it was made from", () => { + const shows = ["expense", "expense-paid", "pay", "note", "note-edit", "operation", "operation-edit"]; + for (const window of WINDOW_IDS) { + const record = shows.includes(window) ? "record-1" : ""; + const made = addressOf(WINDOW_PAGE[window] ?? "hub", { window, record, commune: "home-1" }); + const [path, search] = made.split("?"); + const read = readAddress(path, search); + expect(read.window, made).toBe(window); + expect(read.record, made).toBe(record); + } + expect(addressOf("expenses", { window: "expense", record: "expense-1", commune: "home-1" })).toBe("/expenses/expense-1?commune=home-1"); + expect(addressOf("notes", { window: "note-edit", record: "note-1", commune: "home-1" })).toBe("/notes/note-1/edit?commune=home-1"); + expect(addressOf("settle", { window: "breakdown", commune: "home-1" })).toBe("/settle/breakdown?commune=home-1"); + expect(addressOf("funds", { record: "fund-1", commune: "home-1" })).toBe("/funds/fund-1?commune=home-1"); + expect(addressOf("settings", { window: "notices", commune: "home-1" })).toBe("/settings?window=notices"); + expect(addressOf("operations", { window: "operation-new", commune: "home-1", params: { date: "2026-10-12" } })) + .toBe("/operations/new?commune=home-1&date=2026-10-12"); + }); + + it("knows the addresses it does not have", () => { + for (const path of ["/nonsense", "/expenses/expense-1/extra", "/notes/new/edit", "/expenses/