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.
-
-
- setCurrency(event.target.value as "£ GBP" | "$ USD" | "€ EUR")
- }
- >
- £ GBP
- $ USD
- € EUR
-
-
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
go("settings")}>
Open
diff --git a/apps/web/tests/preferences.test.tsx b/apps/web/tests/preferences.test.tsx
new file mode 100644
index 00000000..6e37bec7
--- /dev/null
+++ b/apps/web/tests/preferences.test.tsx
@@ -0,0 +1,51 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render, screen } 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, Snapshot } from "../src/live/model";
+
+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 commune: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin" };
+const snapshot: Snapshot = { commune, members: [{ userId: user.id, name: user.name, role: "admin" }], expenses: [], funds: [], notifications: [], reminderTargets: [] };
+let app: AppState;
+function Capture() { app = useApp(); return null; }
+function mount(path = "/settings") {
+ const location = memoryLocation({ path, record: true });
+ render( );
+ return location;
+}
+beforeEach(() => {
+ vi.resetAllMocks();
+ sessionStorage.clear();
+ localStorage.clear();
+ vi.mocked(api).mockImplementation(async (path) => {
+ if (path === "/auth/me") return { user };
+ 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).mockResolvedValue({ ok: true });
+ vi.mocked(loadCommunes).mockResolvedValue({ communes: [commune] });
+ vi.mocked(loadSnapshot).mockResolvedValue(snapshot);
+});
+afterEach(cleanup);
+
+describe("Settings preferences", () => {
+ it("has no currency preference, since each commune keeps its own", async () => {
+ mount();
+ await screen.findByRole("heading", { name: "Preferences" });
+ expect(screen.queryByLabelText("Currency")).toBeNull();
+ expect(screen.queryByText("Currency")).toBeNull();
+ expect("currency" in app).toBe(false);
+ expect("setCurrency" in app).toBe(false);
+ });
+});
From 9f325d4fa3b76eee05893584c68417f0f17a5eb1 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 9 Oct 2026 16:10:40 +0000
Subject: [PATCH 02/11] Say when the next bill is overdue, and hide Billing and
Analytics
"Next due" took the earliest due date of any open bill, so a rent
payment a month late still read as "4 Sept". It now reads "Overdue
since 4 Sept" once the date has passed.
Billing only said it was not connected, and Analytics was the activity
list with four figures on top. Both leave the navigation, and their old
addresses go to Home, until they do something.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
---
apps/web/src/App.tsx | 9 +--
apps/web/src/data/household.ts | 16 ++---
apps/web/src/live/model.ts | 5 +-
apps/web/src/screens/House.tsx | 32 ----------
apps/web/src/shell/Shell.tsx | 7 +-
apps/web/tests/placeholder-screens.test.tsx | 71 +++++++++++++++++++++
6 files changed, 84 insertions(+), 56 deletions(-)
create mode 100644 apps/web/tests/placeholder-screens.test.tsx
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index fb007e00..b810db83 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -20,8 +20,6 @@ import {
} from "./screens/Money";
import {
ActivityScreen,
- AnalyticsScreen,
- BillingScreen,
MembersScreen,
NotesScreen,
TemplatesScreen,
@@ -44,6 +42,9 @@ export function App() {
+ {/* Billing and Analytics come back when they do something. */}
+
+
@@ -271,10 +272,6 @@ function ScreenBody({ screen }: { screen: ScreenId }) {
return ;
case "templates":
return ;
- case "billing":
- return ;
- case "analytics":
- return ;
case "members":
return ;
case "operations":
diff --git a/apps/web/src/data/household.ts b/apps/web/src/data/household.ts
index bbe785af..8e37bd79 100644
--- a/apps/web/src/data/household.ts
+++ b/apps/web/src/data/household.ts
@@ -19,8 +19,6 @@ export type ScreenId =
| "recurring"
| "funds"
| "templates"
- | "billing"
- | "analytics"
| "members"
| "operations"
| "notes"
@@ -134,7 +132,7 @@ export const HUBS: Record = {
credit: false,
kpis: [
{ go: "funds", tone: "peach", kicker: "House fund", num: "£420", hint: "£180 still from Alex" },
- { go: "analytics", tone: "lilac", kicker: "This month", num: "£2,140", hint: "of £2,400 · on track" },
+ { go: "expenses", tone: "lilac", kicker: "This month", num: "£2,140", hint: "of £2,400 · on track" },
{ go: "recurring", tone: "sage", kicker: "Open bills", num: "2", hint: "Gas + Virgin Media" },
{ go: "expenses", tone: "ink", kicker: "Next due", num: "Fri", hint: "British Gas · 29 Aug" },
],
@@ -195,7 +193,7 @@ export const HUBS: Record = {
credit: false,
kpis: [
{ go: "funds", tone: "peach", kicker: "Studio pot", num: "£90", hint: "Ink run not yet logged" },
- { go: "analytics", tone: "lilac", kicker: "This month", num: "£240", hint: "Wifi + paper + coffee" },
+ { go: "expenses", tone: "lilac", kicker: "This month", num: "£240", hint: "Wifi + paper + coffee" },
{ go: "recurring", tone: "sage", kicker: "Open bills", num: "1", hint: "Printer ink · Monday" },
{ go: "expenses", tone: "ink", kicker: "Next due", num: "Mon", hint: "Ink · Sam’s run" },
],
@@ -254,7 +252,7 @@ export const HUBS: Record = {
credit: true,
kpis: [
{ go: "funds", tone: "peach", kicker: "Trip pot", num: "£380", hint: "Hold already paid by you" },
- { go: "analytics", tone: "lilac", kicker: "Spent", num: "£210", hint: "Dinners + metro" },
+ { go: "expenses", tone: "lilac", kicker: "Spent", num: "£210", hint: "Dinners + metro" },
{ go: "recurring", tone: "sage", kicker: "Open", num: "1", hint: "Airbnb hold Thursday" },
{ go: "expenses", tone: "ink", kicker: "Next", num: "Thu", hint: "Hold expires in 4 days" },
],
@@ -437,8 +435,6 @@ export const PAGE_TITLES: Record = {
recurring: "Recurring",
funds: "Funds",
templates: "Templates",
- billing: "Billing",
- analytics: "Analytics",
breakdown: "Breakdown",
members: "Members",
operations: "Operations",
@@ -479,8 +475,6 @@ export const PAGE_LEDES: Record = {
recurring: "Repeat a bill, check it, then record what was paid.",
funds: "Pledges and recorded contributions.",
templates: "Shared split preferences.",
- billing: "Plan and billing details.",
- analytics: "Activity from your shared ledger.",
breakdown: "The ledger view of who owes whom.",
members: "Balances for this cycle, not personalities.",
operations: "What’s late, what’s yours, then the week.",
@@ -557,8 +551,6 @@ export const SCREEN_PATH: Record = {
recurring: "/recurring",
funds: "/funds",
templates: "/templates",
- billing: "/billing",
- analytics: "/analytics",
members: "/members",
operations: "/operations",
notes: "/notes",
@@ -806,7 +798,7 @@ export function buildCreatedHub(opts: {
kpis: [
{ go: "funds", tone: "peach", kicker: "Fund", num: `${currency}0`, hint: "Nothing in yet" },
{
- go: "analytics",
+ go: "expenses",
tone: "lilac",
kicker: "This month",
num: totalLabel,
diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts
index 72f3f2d0..5e7439e6 100644
--- a/apps/web/src/live/model.ts
+++ b/apps/web/src/live/model.ts
@@ -535,9 +535,10 @@ export function hubFromSnapshot(
credit: youOwe === 0 && youAreOwed > 0,
kpis: [
{ go: "funds", tone: "peach", kicker: "Recorded contributions", num: money(fundTotal, currency), hint: `${snapshot.funds.length} ${snapshot.funds.length === 1 ? "fund" : "funds"}` },
- { go: "analytics", tone: "lilac", kicker: "This month", num: money(monthTotal, currency), hint: `${monthExpenses.length} ${monthExpenses.length === 1 ? "expense" : "expenses"} logged` },
+ { go: "expenses", tone: "lilac", kicker: "This month", num: money(monthTotal, currency), hint: `${monthExpenses.length} ${monthExpenses.length === 1 ? "expense" : "expenses"} logged` },
{ go: "expenses", tone: "sage", kicker: "Open bills", num: String(openRows.length), hint: openRows[0]?.name ?? "Nothing waiting" },
- { go: "expenses", tone: "ink", kicker: "Next due", num: nextDue ? dateLabel(nextDue.dueOn) : "-", hint: nextDue?.name ?? "No dated open bills" },
+ // A date that has passed says so rather than reading as still to come.
+ { go: "expenses", tone: "ink", kicker: "Next due", num: nextDue?.dueOn ? nextDue.dueOn < today ? `Overdue since ${dateLabel(nextDue.dueOn)}` : dateLabel(nextDue.dueOn) : "-", hint: nextDue?.name ?? "No dated open bills" },
],
now: personalNeeds.sort((a, b) => (a.dueOn ?? "9999").localeCompare(b.dueOn ?? "9999")),
moves,
diff --git a/apps/web/src/screens/House.tsx b/apps/web/src/screens/House.tsx
index ebfa3c04..3501b292 100644
--- a/apps/web/src/screens/House.tsx
+++ b/apps/web/src/screens/House.tsx
@@ -21,35 +21,3 @@ export function ActivityScreen() {
}
export { TemplatesScreen } from "./Templates";
-
-export function BillingScreen() {
- return (
-
-
Billing is not connected yet.
-
You can track shared expenses here. No subscription is active and Commune will not take a payment.
-
- );
-}
-
-export function AnalyticsScreen() {
- const { hub, emptyHub } = useApp();
- if (emptyHub) return Nothing to chart yet. Spend shows here after the first expense.
;
- return (
- <>
-
- {hub.kpis.map((item) =>
{item.kicker} {item.num} {item.hint}
)}
-
-
-
Recorded activity
-
Derived from this commune’s saved records. Forecasting and budgets are not connected yet.
- {hub.activity.length ? hub.activity.map((row, index) => (
-
- )) :
No activity yet.
}
-
- >
- );
-}
diff --git a/apps/web/src/shell/Shell.tsx b/apps/web/src/shell/Shell.tsx
index 736e0b79..24b05cf6 100644
--- a/apps/web/src/shell/Shell.tsx
+++ b/apps/web/src/shell/Shell.tsx
@@ -1,5 +1,5 @@
import { useEffect, useId, useRef, useState } from "react";
-import { ArrowRightLeft, ChartColumn, Clock3, CreditCard, FileText, Files, House, LayoutGrid, ReceiptText, Repeat2, ShoppingBag, SquareCheck, UsersRound, type LucideIcon } from "lucide-react";
+import { ArrowRightLeft, Clock3, FileText, Files, House, LayoutGrid, ReceiptText, Repeat2, ShoppingBag, SquareCheck, UsersRound, type LucideIcon } from "lucide-react";
import type { IconName } from "../ui/Icon";
import { Icon } from "../ui/Icon";
import { Avatar } from "../ui/widgets";
@@ -14,7 +14,7 @@ type NavItem = { id: ScreenId; label: string; icon: IconName };
const NAV_ICONS: Partial> = {
home: LayoutGrid, hub: House, expenses: ReceiptText, settle: ArrowRightLeft,
recurring: Repeat2, funds: ShoppingBag, notes: FileText, operations: SquareCheck,
- templates: Files, billing: CreditCard, analytics: ChartColumn,
+ templates: Files,
activity: Clock3, members: UsersRound,
};
@@ -33,11 +33,10 @@ const MONEY: NavItem[] = [
{ id: "recurring", label: "Recurring", icon: "recurring" },
{ id: "funds", label: "Funds", icon: "funds" },
{ id: "templates", label: "Templates", icon: "templates" },
- { id: "billing", label: "Billing", icon: "billing" },
];
+// Billing and Analytics stay out of the navigation until they do something.
const INSIGHTS: NavItem[] = [
- { id: "analytics", label: "Analytics", icon: "analytics" },
{ id: "activity", label: "Activity", icon: "activity" },
];
diff --git a/apps/web/tests/placeholder-screens.test.tsx b/apps/web/tests/placeholder-screens.test.tsx
new file mode 100644
index 00000000..1afb60fd
--- /dev/null
+++ b/apps/web/tests/placeholder-screens.test.tsx
@@ -0,0 +1,71 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, 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 } from "../src/state";
+import { api, loadCommunes, loadSnapshot, post } from "../src/live/api";
+import { hubFromSnapshot, type Commune, type Expense, type Snapshot } from "../src/live/model";
+
+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 other = { userId: "user-2", name: "Other Person", role: "member" as const };
+const commune: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin" };
+const snapshot: Snapshot = { commune, members: [{ userId: user.id, name: user.name, role: "admin" }, other], expenses: [], funds: [], notifications: [], reminderTargets: [] };
+const rent = (dueOn: string): Expense => ({ id: "expense-1", title: "Rent", amountMinor: 120000, paidByUserId: user.id, dueOn,
+ shares: [{ id: "share-1", userId: user.id, amountMinor: 60000, settledMinor: 60000 }, { id: "share-2", userId: other.userId, amountMinor: 60000, settledMinor: 0 }] });
+const nextDue = (expenses: Expense[]) => hubFromSnapshot({ ...snapshot, expenses }, user).kpis.find((kpi) => kpi.kicker === "Next due");
+
+function mount(path: string) {
+ const location = memoryLocation({ path, record: true });
+ render( );
+ return location;
+}
+beforeEach(() => {
+ vi.resetAllMocks();
+ sessionStorage.clear();
+ localStorage.clear();
+ vi.mocked(api).mockImplementation(async (path) => {
+ if (path === "/auth/me") return { user };
+ if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 6, recipientUsage: {}, nextSlotAt: null };
+ if (path.endsWith("/invites")) return { invites: [] };
+ throw new Error(`Unexpected API request ${path}`);
+ });
+ vi.mocked(post).mockResolvedValue({ ok: true });
+ vi.mocked(loadCommunes).mockResolvedValue({ communes: [commune] });
+ vi.mocked(loadSnapshot).mockResolvedValue(snapshot);
+});
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+});
+
+describe("Next due", () => {
+ it("says a date that has passed is overdue, and since when", () => {
+ vi.useFakeTimers({ now: new Date(2026, 9, 9, 12), toFake: ["Date"] });
+ expect(nextDue([rent("2026-09-04")])?.num).toBe("Overdue since 4 Sept");
+ expect(nextDue([rent("2026-10-09")])?.num).toBe("9 Oct");
+ expect(nextDue([rent("2026-11-12")])?.num).toBe("12 Nov");
+ expect(nextDue([])?.num).toBe("-");
+ });
+});
+
+describe("Billing and Analytics", () => {
+ it("are not in the navigation", async () => {
+ mount("/hub");
+ const nav = await screen.findByRole("navigation", { name: "Primary" });
+ expect(within(nav).queryByRole("button", { name: "Billing" })).toBeNull();
+ expect(within(nav).queryByRole("button", { name: "Analytics" })).toBeNull();
+ expect(within(nav).getByRole("button", { name: "Activity" })).toBeTruthy();
+ });
+
+ it.each(["/billing", "/analytics"])("%s goes to Home", async (path) => {
+ const location = mount(path);
+ await screen.findByRole("heading", { name: "Your communes, Test." });
+ await waitFor(() => expect(location.history?.at(-1)).toBe("/"));
+ });
+});
From 1bb87536989eeea8f048063889c30c88cc90b5dc Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 9 Oct 2026 16:15:40 +0000
Subject: [PATCH 03/11] Keep preferences in this browser and let the theme
follow the device
Preferences lived in the tab's session storage, so closing the tab lost
the commune you were using, dark mode, photos and payment hints, and a
new tab opened the newest commune instead.
Device preferences (theme, sidebar, notices) and each account's own
(last commune, photos, payment hints) now live in local storage, read
and written through wrappers that never throw when the store is full,
blocked or private. A tab that still holds the old session copy moves
it across once. A new tab reopens the last commune while the account is
still a member of it, and falls back to the newest otherwise.
The dark mode switch becomes a Theme choice: Match this device (the
default, following prefers-color-scheme), Light or Dark. A change in
one tab shows in the others. Signing out still removes everything kept
for the account and leaves only the device preferences.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
---
apps/web/src/live/preferences.ts | 107 +++++++++++++++
apps/web/src/live/storage.ts | 30 +++++
apps/web/src/screens/Settings.tsx | 18 ++-
apps/web/src/state.tsx | 134 +++++++++---------
apps/web/src/windows/Windows.tsx | 4 +-
apps/web/tests/preferences.test.tsx | 180 ++++++++++++++++++++++++-
apps/web/tests/session-safety.test.tsx | 2 +
apps/web/tests/setup.ts | 8 ++
apps/web/vitest.config.ts | 2 +-
9 files changed, 407 insertions(+), 78 deletions(-)
create mode 100644 apps/web/src/live/preferences.ts
create mode 100644 apps/web/tests/setup.ts
diff --git a/apps/web/src/live/preferences.ts b/apps/web/src/live/preferences.ts
new file mode 100644
index 00000000..953d594a
--- /dev/null
+++ b/apps/web/src/live/preferences.ts
@@ -0,0 +1,107 @@
+import type { HubId } from "../data/household";
+import { isStoredImage, readHubPhotos } from "../data/images";
+import { readLocal, readStored, removeLocal, removeStored, writeLocal } from "./storage";
+
+// What this browser remembers between visits. Device preferences belong to
+// the browser, whoever signs in. Account preferences belong to one account
+// and are removed when it signs out here, so the next person on a shared
+// device finds none of its photos, payment hints or last commune.
+
+export type NavMode = "basic" | "advanced";
+/** "system" follows the device's own light or dark setting. */
+export type Theme = "system" | "light" | "dark";
+export type PayMethod = {
+ provider: "Revolut" | "PayPal" | "Monzo" | "Bank transfer";
+ value: string;
+};
+export type DevicePrefs = {
+ theme: Theme;
+ navMode: NavMode;
+ noticesOn: boolean;
+};
+export type AccountPrefs = {
+ /** The commune last opened, opened again next time while still a member. */
+ activeHubId?: string;
+ payMethods: PayMethod[];
+ hubPhotos: Partial>;
+ avatar?: string;
+};
+
+export const DEFAULT_DEVICE: DevicePrefs = { theme: "system", navMode: "basic", noticesOn: true };
+export const DEFAULT_ACCOUNT: AccountPrefs = { payMethods: [], hubPhotos: {} };
+
+export const DEVICE_KEY = "commune.device";
+export const accountKey = (userId: string) => `commune.local.${userId}`;
+
+function parse(raw: string | null): Record | null {
+ if (!raw) return null;
+ try {
+ const value: unknown = JSON.parse(raw);
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null;
+ } catch {
+ return null;
+ }
+}
+
+function deviceFrom(value: Record): DevicePrefs {
+ return {
+ theme: value.theme === "light" || value.theme === "dark" ? value.theme : "system",
+ navMode: value.navMode === "advanced" ? "advanced" : "basic",
+ noticesOn: value.noticesOn !== false,
+ };
+}
+
+function accountFrom(value: Record): AccountPrefs {
+ return {
+ activeHubId: typeof value.activeHubId === "string" ? value.activeHubId : undefined,
+ payMethods: Array.isArray(value.payMethods) ? value.payMethods.filter(
+ (method): method is PayMethod => !!method && typeof method.value === "string" &&
+ ["Revolut", "PayPal", "Monzo", "Bank transfer"].includes(method.provider),
+ ) : [],
+ hubPhotos: readHubPhotos(value.hubPhotos) ?? {},
+ avatar: isStoredImage(value.avatar) ? value.avatar : undefined,
+ };
+}
+
+/** Null when this browser keeps none, or cannot be read. */
+export function readDevicePrefs(): DevicePrefs | null {
+ const value = parse(readLocal(DEVICE_KEY));
+ return value ? deviceFrom(value) : null;
+}
+
+export function readAccountPrefs(userId: string): AccountPrefs | null {
+ const value = parse(readLocal(accountKey(userId)));
+ return value ? accountFrom(value) : null;
+}
+
+/** Whether the browser kept them; if not, they last for this visit only. */
+export function saveDevicePrefs(prefs: DevicePrefs): boolean {
+ return writeLocal(DEVICE_KEY, JSON.stringify(prefs));
+}
+
+export function saveAccountPrefs(userId: string, prefs: AccountPrefs): boolean {
+ return writeLocal(accountKey(userId), JSON.stringify(prefs));
+}
+
+/** Signing out: nothing of the account stays in this browser. */
+export function forgetAccountPrefs(userId: string): void {
+ removeLocal(accountKey(userId));
+ removeStored(accountKey(userId));
+}
+
+/**
+ * Until October 2026 every preference was kept in the tab's session storage,
+ * one record per account, and was lost when the tab closed. A tab that still
+ * has one moves it to this browser's storage once, keeping anything already
+ * there, and the old copy goes when it has moved. Dark mode on becomes Dark;
+ * off was the default, so it now follows the device.
+ */
+export function adoptTabPrefs(userId: string): void {
+ const key = accountKey(userId);
+ const old = parse(readStored(key));
+ if (!old) return;
+ const account = readLocal(key) !== null || writeLocal(key, JSON.stringify(accountFrom(old)));
+ const device = readLocal(DEVICE_KEY) !== null ||
+ writeLocal(DEVICE_KEY, JSON.stringify({ ...deviceFrom(old), theme: old.dark === true ? "dark" : "system" }));
+ if (account && device) removeStored(key);
+}
diff --git a/apps/web/src/live/storage.ts b/apps/web/src/live/storage.ts
index 119be76e..5882f633 100644
--- a/apps/web/src/live/storage.ts
+++ b/apps/web/src/live/storage.ts
@@ -1,6 +1,10 @@
// Browser storage can be full (photos are large), blocked by a privacy
// setting or missing. Losing a saved preference is better than failing the
// action that wanted to save it, so these never throw.
+//
+// Session storage belongs to one tab and ends with it: where to go after
+// signing in, a password reset link. Local storage is this browser's, shared
+// by its tabs and kept after they close: preferences (see preferences.ts).
export function readStored(key: string): string | null {
try {
@@ -27,3 +31,29 @@ export function removeStored(key: string): void {
// Nothing could have been stored either.
}
}
+
+export function readLocal(key: string): string | null {
+ try {
+ return localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+}
+
+/** Whether the value was kept. */
+export function writeLocal(key: string, value: string): boolean {
+ try {
+ localStorage.setItem(key, value);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function removeLocal(key: string): void {
+ try {
+ localStorage.removeItem(key);
+ } catch {
+ // Nothing could have been stored either.
+ }
+}
diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx
index cc93cbe1..52781301 100644
--- a/apps/web/src/screens/Settings.tsx
+++ b/apps/web/src/screens/Settings.tsx
@@ -4,7 +4,7 @@ import { Icon } from "../ui/Icon";
import { Avatar, Switch } from "../ui/widgets";
import { ImagePick } from "../ui/photos";
import { CoverImage, coverToneClass } from "../ui/CoverImage";
-import { useApp } from "../state";
+import { useApp, type Theme } from "../state";
import { cx } from "../ui/cx";
import { LIMITS } from "../data/limits";
import { CommuneLocationFields, type CommuneLocation } from "../ui/CommuneLocationFields";
@@ -21,8 +21,8 @@ export function SettingsScreen() {
go,
navMode,
setNavMode,
- dark,
- setDark,
+ theme,
+ setTheme,
noticesOn,
setNoticesOn,
signOut,
@@ -133,10 +133,14 @@ export function SettingsScreen() {
- Dark mode
- Ink canvas. Off keeps the cream paper.
+ Theme
+ Cream paper or ink canvas. Match this device follows its light or dark setting.
-
+ setTheme(event.target.value as Theme)}>
+ Match this device
+ Light
+ Dark
+
@@ -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.
go("hub")}>Back to this commune
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
go("settings")}>
Open
diff --git a/apps/web/tests/preferences.test.tsx b/apps/web/tests/preferences.test.tsx
index 6e37bec7..5f8d7806 100644
--- a/apps/web/tests/preferences.test.tsx
+++ b/apps/web/tests/preferences.test.tsx
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { cleanup, render, screen } from "@testing-library/react";
+import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "../src/App";
@@ -13,8 +13,11 @@ vi.mock("../src/live/api", async (importOriginal) => ({
}));
const user = { id: "user-1", name: "Test Person", email: "test@example.test", emailVerified: true };
+// Communes are listed newest first.
+const trip: Commune = { id: "trip-1", name: "Lisbon trip", kind: "trip", currency: "EUR", role: "admin" };
const commune: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin" };
-const snapshot: Snapshot = { commune, members: [{ userId: user.id, name: user.name, role: "admin" }], expenses: [], funds: [], notifications: [], reminderTargets: [] };
+const snapshotOf = (item: Commune): Snapshot => ({ commune: item, members: [{ userId: user.id, name: user.name, role: "admin" }], expenses: [], funds: [], notifications: [], reminderTargets: [] });
+const ACCOUNT = `commune.local.${user.id}`;
let app: AppState;
function Capture() { app = useApp(); return null; }
function mount(path = "/settings") {
@@ -22,10 +25,33 @@ function mount(path = "/settings") {
render( );
return location;
}
+// Closing the tab: the page goes, and so does its session storage.
+function closeTab() {
+ cleanup();
+ sessionStorage.clear();
+}
+const shell = () => document.querySelector(".a-body")!;
+const kept = (key: string) => JSON.parse(localStorage.getItem(key) ?? "null");
+
+// The device's light or dark setting, which a test can change.
+let deviceDark = false;
+const listeners = new Set<() => void>();
+function setDeviceDark(dark: boolean) {
+ deviceDark = dark;
+ for (const listener of listeners) listener();
+}
+
beforeEach(() => {
vi.resetAllMocks();
sessionStorage.clear();
- localStorage.clear();
+ deviceDark = false;
+ listeners.clear();
+ vi.stubGlobal("matchMedia", (query: string) => ({
+ media: query,
+ get matches() { return query === "(prefers-color-scheme: dark)" && deviceDark; },
+ addEventListener: (_type: string, listener: () => void) => listeners.add(listener),
+ removeEventListener: (_type: string, listener: () => void) => listeners.delete(listener),
+ }));
vi.mocked(api).mockImplementation(async (path) => {
if (path === "/auth/me") return { user };
if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 6, recipientUsage: {}, nextSlotAt: null };
@@ -34,10 +60,14 @@ beforeEach(() => {
throw new Error(`Unexpected API request ${path}`);
});
vi.mocked(post).mockResolvedValue({ ok: true });
- vi.mocked(loadCommunes).mockResolvedValue({ communes: [commune] });
- vi.mocked(loadSnapshot).mockResolvedValue(snapshot);
+ vi.mocked(loadCommunes).mockResolvedValue({ communes: [trip, commune] });
+ vi.mocked(loadSnapshot).mockImplementation(async (item) => snapshotOf(item));
+});
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
});
-afterEach(cleanup);
describe("Settings preferences", () => {
it("has no currency preference, since each commune keeps its own", async () => {
@@ -49,3 +79,141 @@ describe("Settings preferences", () => {
expect("setCurrency" in app).toBe(false);
});
});
+
+describe("the theme", () => {
+ it("follows the device until Light or Dark is chosen, and can follow it again", async () => {
+ setDeviceDark(true);
+ mount();
+ const theme = await screen.findByLabelText("Theme") as HTMLSelectElement;
+ expect(theme.value).toBe("system");
+ expect(shell().classList.contains("is-dark")).toBe(true);
+ act(() => setDeviceDark(false));
+ expect(shell().classList.contains("is-dark")).toBe(false);
+ fireEvent.change(theme, { target: { value: "dark" } });
+ expect(shell().classList.contains("is-dark")).toBe(true);
+ act(() => setDeviceDark(true));
+ fireEvent.change(theme, { target: { value: "light" } });
+ expect(shell().classList.contains("is-dark")).toBe(false);
+ fireEvent.change(theme, { target: { value: "system" } });
+ expect(shell().classList.contains("is-dark")).toBe(true);
+ expect(screen.getByRole("option", { name: "Match this device" })).toBeTruthy();
+ });
+
+ it("is kept for the next tab, with the sidebar and notices", async () => {
+ mount();
+ fireEvent.change(await screen.findByLabelText("Theme"), { target: { value: "dark" } });
+ fireEvent.click(screen.getByRole("button", { name: "Advanced" }));
+ fireEvent.click(screen.getByRole("switch", { name: "Notices" }));
+ expect(kept("commune.device")).toEqual({ theme: "dark", navMode: "advanced", noticesOn: false });
+ closeTab();
+ mount();
+ expect((await screen.findByLabelText("Theme") as HTMLSelectElement).value).toBe("dark");
+ expect(shell().classList.contains("is-dark")).toBe(true);
+ expect(shell().classList.contains("nav-advanced")).toBe(true);
+ expect(app.noticesOn).toBe(false);
+ });
+
+ it("changes in every open tab when one tab changes it", async () => {
+ mount();
+ await screen.findByLabelText("Theme");
+ localStorage.setItem("commune.device", JSON.stringify({ theme: "dark", navMode: "basic", noticesOn: true }));
+ act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "commune.device" })); });
+ expect(shell().classList.contains("is-dark")).toBe(true);
+ expect((screen.getByLabelText("Theme") as HTMLSelectElement).value).toBe("dark");
+ });
+});
+
+describe("the last commune", () => {
+ it("opens again in a new tab, rather than the newest", async () => {
+ mount("/hub");
+ await screen.findByRole("heading", { name: trip.name });
+ await act(async () => app.go("hub", { hub: commune.id }));
+ await screen.findByRole("heading", { name: commune.name });
+ closeTab();
+ mount("/hub");
+ await screen.findByRole("heading", { name: commune.name });
+ expect(app.hub.id).toBe(commune.id);
+ });
+
+ it("is passed over once the account has left it", async () => {
+ localStorage.setItem(ACCOUNT, JSON.stringify({ activeHubId: "left-1", payMethods: [], hubPhotos: {} }));
+ mount("/hub");
+ await screen.findByRole("heading", { name: trip.name });
+ expect(app.hub.id).toBe(trip.id);
+ });
+
+ it("belongs to one account", async () => {
+ localStorage.setItem("commune.local.someone-else", JSON.stringify({ activeHubId: commune.id, payMethods: [], hubPhotos: {} }));
+ mount("/hub");
+ await screen.findByRole("heading", { name: trip.name });
+ });
+});
+
+describe("preferences kept by a tab before this release", () => {
+ it("move to this browser once, and the tab's copy goes", async () => {
+ sessionStorage.setItem(ACCOUNT, JSON.stringify({
+ activeHubId: commune.id, navMode: "advanced", dark: true, noticesOn: true,
+ payMethods: [{ provider: "Monzo", value: "@test" }], hubPhotos: {},
+ }));
+ mount("/hub");
+ await screen.findByRole("heading", { name: commune.name });
+ expect(shell().classList.contains("is-dark")).toBe(true);
+ expect(shell().classList.contains("nav-advanced")).toBe(true);
+ expect(app.payMethods).toEqual([{ provider: "Monzo", value: "@test" }]);
+ expect(sessionStorage.getItem(ACCOUNT)).toBeNull();
+ expect(kept(ACCOUNT)).toMatchObject({ activeHubId: commune.id, payMethods: [{ provider: "Monzo", value: "@test" }] });
+ expect(kept("commune.device")).toEqual({ theme: "dark", navMode: "advanced", noticesOn: true });
+ });
+
+ it("never replace what this browser already keeps", async () => {
+ localStorage.setItem("commune.device", JSON.stringify({ theme: "light", navMode: "basic", noticesOn: true }));
+ sessionStorage.setItem(ACCOUNT, JSON.stringify({ dark: true, navMode: "advanced" }));
+ mount();
+ await screen.findByLabelText("Theme");
+ expect(kept("commune.device")).toEqual({ theme: "light", navMode: "basic", noticesOn: true });
+ expect(shell().classList.contains("is-dark")).toBe(false);
+ });
+
+ it("dark mode left off now follows the device", async () => {
+ setDeviceDark(true);
+ sessionStorage.setItem(ACCOUNT, JSON.stringify({ dark: false }));
+ mount();
+ expect((await screen.findByLabelText("Theme") as HTMLSelectElement).value).toBe("system");
+ expect(shell().classList.contains("is-dark")).toBe(true);
+ });
+});
+
+describe("a full or blocked store", () => {
+ it("keeps a change for this visit and breaks nothing", async () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Full", "QuotaExceededError"); });
+ mount();
+ fireEvent.change(await screen.findByLabelText("Theme"), { target: { value: "dark" } });
+ expect(shell().classList.contains("is-dark")).toBe(true);
+ expect(localStorage.getItem("commune.device")).toBeNull();
+ expect(warn).toHaveBeenCalledWith("This browser could not keep a Commune preference");
+ });
+
+ it("that cannot even be read still shows the app", async () => {
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("Blocked", "SecurityError"); });
+ mount("/hub");
+ await screen.findByRole("heading", { name: trip.name });
+ expect(app.theme).toBe("system");
+ });
+});
+
+describe("signing out", () => {
+ it("removes the account's preferences and keeps the device's", async () => {
+ mount();
+ fireEvent.change(await screen.findByLabelText("Theme"), { target: { value: "dark" } });
+ act(() => { app.savePayMethod({ provider: "Monzo", value: "@test" }); });
+ await act(async () => app.go("hub", { hub: commune.id }));
+ expect(kept(ACCOUNT)).toMatchObject({ activeHubId: commune.id, payMethods: [{ provider: "Monzo", value: "@test" }] });
+ await act(async () => { await app.signOut(); });
+ await screen.findByRole("heading", { name: "Welcome back" });
+ expect(localStorage.getItem(ACCOUNT)).toBeNull();
+ expect(sessionStorage.getItem(ACCOUNT)).toBeNull();
+ expect(kept("commune.device")).toMatchObject({ theme: "dark" });
+ await waitFor(() => expect(app.payMethods).toEqual([]));
+ });
+});
diff --git a/apps/web/tests/session-safety.test.tsx b/apps/web/tests/session-safety.test.tsx
index 802caf39..6c8864d7 100644
--- a/apps/web/tests/session-safety.test.tsx
+++ b/apps/web/tests/session-safety.test.tsx
@@ -47,6 +47,7 @@ afterEach(cleanup);
describe("signing out", () => {
it("clears the tab and its stored settings before the server answers", async () => {
+ localStorage.setItem(`commune.local.${user.id}`, JSON.stringify({ activeHubId: commune.id, payMethods: [{ provider: "Monzo", value: "@test" }] }));
sessionStorage.setItem(`commune.local.${user.id}`, JSON.stringify({ activeHubId: commune.id, dark: true }));
const location = mount();
await screen.findByRole("heading", { name: commune.name });
@@ -58,6 +59,7 @@ describe("signing out", () => {
await screen.findByRole("heading", { name: "Welcome back" });
expect(screen.queryByText(commune.name)).toBeNull();
expect(sessionStorage.getItem(`commune.local.${user.id}`)).toBeNull();
+ expect(localStorage.getItem(`commune.local.${user.id}`)).toBeNull();
expect(location.history?.at(-1)).toBe("/login");
expect(currentViewer()).toBeNull();
await act(async () => answer());
diff --git a/apps/web/tests/setup.ts b/apps/web/tests/setup.ts
new file mode 100644
index 00000000..19a2b413
--- /dev/null
+++ b/apps/web/tests/setup.ts
@@ -0,0 +1,8 @@
+import { beforeEach } from "vitest";
+
+// Preferences are kept in this browser's local storage (src/live/preferences.ts),
+// which jsdom keeps for every test in a file. Each test starts from a browser
+// that has kept none, as each file's own sessionStorage.clear() starts a tab.
+beforeEach(() => {
+ if (typeof localStorage !== "undefined") localStorage.clear();
+});
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
index 4b95be3e..7e15c678 100644
--- a/apps/web/vitest.config.ts
+++ b/apps/web/vitest.config.ts
@@ -1,5 +1,5 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
- test: { environment: "jsdom", include: ["tests/**/*.test.{ts,tsx}"], restoreMocks: true },
+ test: { environment: "jsdom", include: ["tests/**/*.test.{ts,tsx}"], setupFiles: ["tests/setup.ts"], restoreMocks: true },
});
From dd69fc6e688a1dbde5abcf74209128d3b4265045 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 9 Oct 2026 16:55:09 +0000
Subject: [PATCH 04/11] Give windows and the commune their own addresses, and
keep the page asked for across sign-in
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every window now has an address that continues its screen's (an expense is
/expenses/, a note's editor /notes//edit, the breakdown
/settle/breakdown, a fund /funds/), and a screen that shows one commune
names it with ?commune=. The address is the only record of what is open,
so Back closes a window, Forward and a reload open it again and a link opens
it for another member. Closing a window returns to the entry it was opened
from when there is one; leaving a form replaces it, so Back never reopens a
saved or cancelled form. Back over typed text asks first, as Close does.
An address for a commune this account is not in says so instead of showing
another commune, and an address Commune does not have shows a not-found page
in the sign-in layout. Each page sets a short title, such as
"Expenses · Flat 4 · Commune".
A page opened while signed out (a reminder email's link to Settings, a shared
expense, an invitation) opens after signing in or creating an account,
including through Google. Only a path on this site is kept: never an
absolute or protocol-relative URL, a backslash or a control character.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS
---
apps/web/src/App.tsx | 93 ++++++-
apps/web/src/auth/InviteOnboard.tsx | 4 +-
apps/web/src/auth/Login.tsx | 8 +-
apps/web/src/auth/Signup.tsx | 16 +-
apps/web/src/data/household.ts | 1 +
apps/web/src/live/neon.ts | 10 +-
apps/web/src/nav.ts | 167 +++++++++++
apps/web/src/screens/Funds.tsx | 52 +++-
apps/web/src/state.tsx | 268 ++++++++++++------
apps/web/src/windows/Windows.tsx | 28 +-
apps/web/tests/addresses.test.tsx | 413 ++++++++++++++++++++++++++++
apps/web/tests/loading.test.tsx | 7 +-
12 files changed, 918 insertions(+), 149 deletions(-)
create mode 100644 apps/web/tests/addresses.test.tsx
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index b810db83..a5a3d021 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,6 +1,6 @@
-import { useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
+import { useEffect, useLayoutEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { diagnosticsEnabled } from "./live/diagnostics";
-import { Redirect, Route, Switch } from "wouter";
+import { Link, Redirect, Route, Switch, useLocation, useSearch } from "wouter";
import { LoginPage } from "./auth/Login";
import { SignupPage, VerifyEmailPage } from "./auth/Signup";
import { ForgotPasswordPage, ResetPasswordPage } from "./auth/ForgotPassword";
@@ -28,8 +28,9 @@ import { OperationsScreen } from "./screens/Operations";
import { CommuneSettingsScreen, SettingsScreen } from "./screens/Settings";
import { Windows } from "./windows/Windows";
import { cx } from "./ui/cx";
-import type { ScreenId } from "./data/household";
-import { keepSearch } from "./nav";
+import { PAGE_TITLES, type ScreenId } from "./data/household";
+import { COMMUNE_SCREENS, forgetReturn, keepSearch, readAddress, readReturn, rememberReturn } from "./nav";
+import { AuthLayout } from "./auth/AuthLayout";
import { PendingShell } from "./shell/PendingShell";
import { AllActivityScreen, SearchScreen } from "./screens/Search";
import { ConnectionRecovery } from "./shell/ConnectionRecovery";
@@ -46,30 +47,45 @@ export function App() {
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -81,27 +97,71 @@ export function App() {
);
}
+// "Expenses · Flat 4 · Commune": short, most specific first.
+function usePageTitle(...parts: Array) {
+ const title = [...parts.filter(Boolean), "Commune"].join(" · ");
+ useEffect(() => { document.title = title; }, [title]);
+}
+
+function PageTitle({ title }: { title: string }) {
+ usePageTitle(title);
+ return null;
+}
+
function GuestOnly({ children }: { children: ReactNode }) {
const { session, authStatus } = useApp();
if (authStatus === "loading") return ;
if (authStatus === "error") return ;
- if (session?.hasHubs) return ;
- if (session) return ;
+ if (session) return ;
return children;
}
+// Signed in: on to the page that asked for a sign-in, or Home, or naming a
+// first commune. Replaced, so Back does not return to the sign-in form.
+function SignedIn({ hasHubs }: { hasHubs: boolean }) {
+ const [target] = useState(() => readReturn() ?? (hasHubs ? "/" : "/onboard"));
+ useEffect(() => forgetReturn(), []);
+ return ;
+}
+
function ProtectedApp() {
+ const [path] = useLocation();
+ // An address Commune does not have needs no sign-in to say so.
+ if (!readAddress(path).screen) return <> >;
return ;
}
function AuthenticatedOnly({ children }: { children: ReactNode }) {
- const { session, authStatus } = useApp();
+ const { session, authStatus, sessionNotice } = useApp();
+ const [path] = useLocation();
+ const search = useSearch();
if (authStatus === "loading") return ;
if (authStatus === "error") return ;
- if (!session) return ;
+ // A link opened signed out (a reminder email's link to Settings, a shared
+ // expense) opens after signing in. A session that ended keeps what is saved.
+ if (!session) return ;
return children;
}
+function SignInFirst({ requested }: { requested: string | null }) {
+ useLayoutEffect(() => { if (requested !== null) rememberReturn(requested); }, [requested]);
+ return ;
+}
+
+function NotFoundPage() {
+ return (
+
+
+
This page isn’t here.
+
The link may be mistyped, or the page may have moved.
+
+ Go to Home
+
+
+
+ );
+}
+
function AppLoading() {
return ;
}
@@ -112,10 +172,12 @@ function AppUnavailable() {
}
function ProductShell() {
- const { screen, navMode, moreOpen, native, dark, window: win, notes, noteKey, apiError, clearApiError, contentError, hub, refresh, searchTarget } = useApp();
+ const { screen, navMode, moreOpen, native, dark, window: win, notes, noteKey, apiError, clearApiError, contentError, hub, refresh, searchTarget, unknownCommune } = useApp();
const noteReader = screen === "notes" && win === "note" && Boolean(notes[noteKey]);
const modalOpen = Boolean(win) && !noteReader;
usePageFocus(screen, hub.id, Boolean(win) || Boolean(searchTarget));
+ usePageTitle(unknownCommune ? "Commune not found" : (win && PAGE_TITLES[win]) || PAGE_TITLES[screen],
+ COMMUNE_SCREENS.has(screen) && !unknownCommune ? hub.name : undefined);
return (
!move.even);
+ // Never another commune's records in its place.
+ if (unknownCommune) {
+ return
This commune isn’t available to you. You may have left it, or the link is for a different account.
go("home")}>Go to Home ;
+ }
+
if (contentLoading && screen !== "settings" && screen !== "search") {
if (contentError) return
Could not load {hub.name} {contentError}
void refresh()}>Try again ;
return
Loading shared records {screen === "hub" &&
{hub.name} {hub.switchMeta}
}
;
diff --git a/apps/web/src/auth/InviteOnboard.tsx b/apps/web/src/auth/InviteOnboard.tsx
index 9b2b41b1..fa39c9ac 100644
--- a/apps/web/src/auth/InviteOnboard.tsx
+++ b/apps/web/src/auth/InviteOnboard.tsx
@@ -4,7 +4,7 @@ import { useApp } from "../state";
import type { Hub } from "../data/household";
import { LIMITS } from "../data/limits";
import { CommuneLocationFields, EMPTY_COMMUNE_LOCATION } from "../ui/CommuneLocationFields";
-import { writeStored } from "../live/storage";
+import { rememberReturn } from "../nav";
export function InvitePage() {
const { go, session, authStatus, previewInvite, acceptInvite } = useApp();
@@ -34,7 +34,7 @@ export function InvitePage() {
}, [authStatus, previewInvite, signedInUserId, token]);
function signInFirst(target: "login" | "signup") {
- writeStored("commune.return-to", `/invite?token=${encodeURIComponent(token)}`);
+ rememberReturn(`/invite?token=${encodeURIComponent(token)}`);
go(target);
}
diff --git a/apps/web/src/auth/Login.tsx b/apps/web/src/auth/Login.tsx
index c8e3b97a..e3d3d109 100644
--- a/apps/web/src/auth/Login.tsx
+++ b/apps/web/src/auth/Login.tsx
@@ -1,16 +1,14 @@
import { useState, type FormEvent } from "react";
-import { Link, useLocation } from "wouter";
+import { Link } from "wouter";
import { AuthLayout } from "./AuthLayout";
import { useApp } from "../state";
import { LIMITS } from "../data/limits";
import { keepSearch } from "../nav";
import { GoogleMark } from "../ui/Icon";
-import { readStored, removeStored } from "../live/storage";
import { useFormError } from "../ui/components";
export function LoginPage() {
const { signInWithPassword, signInWithGoogle, signOutUnconfirmed, retrySignOut, sessionNotice } = useApp();
- const [, setLocation] = useLocation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
@@ -31,10 +29,8 @@ export function LoginPage() {
}
setBusy(true);
try {
+ // Signed in, the page moves on by itself (see GuestOnly in App.tsx).
await signInWithPassword(email.trim(), password);
- const returnTo = readStored("commune.return-to");
- removeStored("commune.return-to");
- setLocation(returnTo || keepSearch("/"));
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Could not sign in.");
} finally {
diff --git a/apps/web/src/auth/Signup.tsx b/apps/web/src/auth/Signup.tsx
index ff713a35..e269b101 100644
--- a/apps/web/src/auth/Signup.tsx
+++ b/apps/web/src/auth/Signup.tsx
@@ -1,16 +1,14 @@
import { useState, type FormEvent } from "react";
-import { Link, useLocation } from "wouter";
+import { Link } from "wouter";
import { AuthLayout } from "./AuthLayout";
import { useApp } from "../state";
import { LIMITS } from "../data/limits";
import { keepSearch } from "../nav";
import { GoogleMark } from "../ui/Icon";
-import { readStored, removeStored } from "../live/storage";
import { useFormError } from "../ui/components";
export function SignupPage() {
const { signUpWithPassword, verifyEmailCode, signInWithPassword, signInWithGoogle } = useApp();
- const [, setLocation] = useLocation();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -37,14 +35,9 @@ export function SignupPage() {
}
setBusy(true);
try {
+ // Signed in, the page moves on by itself (see GuestOnly in App.tsx).
const status = await signUpWithPassword(name.trim(), email.trim(), password);
- if (status === "verify") {
- setVerificationPending(true);
- return;
- }
- const returnTo = readStored("commune.return-to");
- removeStored("commune.return-to");
- setLocation(returnTo || keepSearch("/onboard"));
+ if (status === "verify") setVerificationPending(true);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Could not create your account.");
} finally {
@@ -59,9 +52,6 @@ export function SignupPage() {
try {
await verifyEmailCode(email.trim(), code.trim());
await signInWithPassword(email.trim(), password);
- const returnTo = readStored("commune.return-to");
- removeStored("commune.return-to");
- setLocation(returnTo || keepSearch("/onboard"));
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Could not verify your email.");
} finally {
diff --git a/apps/web/src/data/household.ts b/apps/web/src/data/household.ts
index 8e37bd79..9c5fa7b8 100644
--- a/apps/web/src/data/household.ts
+++ b/apps/web/src/data/household.ts
@@ -535,6 +535,7 @@ export const WINDOW_PAGE: Partial
> = {
"operation-new": "operations",
"operation-edit": "operations",
"announcement-new": "hub",
+ profile: "settings",
};
export const SCREEN_PATH: Record = {
diff --git a/apps/web/src/live/neon.ts b/apps/web/src/live/neon.ts
index 5cae7633..b6fc0c24 100644
--- a/apps/web/src/live/neon.ts
+++ b/apps/web/src/live/neon.ts
@@ -3,7 +3,7 @@ import { ApiError } from "./api";
import { observedDataFetch, reportRead } from "./diagnostics";
import { parseSearchResponse } from "./search";
import { ACCOUNT_CHANGED, currentViewer } from "./session";
-import { readStored, removeStored } from "./storage";
+import { forgetReturn, readReturn } from "../nav";
import type {
Commune, CurrencyCode, Expense, ExpensePage, ReminderUsage, Snapshot, User,
} from "./model";
@@ -390,17 +390,15 @@ async function route(path: string, init: RequestInit): Promise {
response = { user: await currentUser() };
} else if (path === "/auth/google" && method === "POST") {
if (import.meta.env.VITE_GOOGLE_OAUTH_ENABLED !== "true") throw new ApiError("Google sign-in is not configured", 503);
- const requested = readStored("commune.return-to");
- const requestedUrl = new URL(requested ?? "/", window.location.origin);
- const callbackURL = requestedUrl.origin === window.location.origin
- ? requestedUrl.href : new URL("/", window.location.origin).href;
+ // Back from Google to the page that asked for a sign-in, only ever on this site.
+ const callbackURL = new URL(readReturn() ?? "/", window.location.origin).href;
const result = await db().auth.signIn.social({
provider: "google",
callbackURL,
errorCallbackURL: new URL("/login?oauth=error", window.location.origin).href,
});
if (result.error) message(result.error);
- removeStored("commune.return-to");
+ forgetReturn();
response = { ok: true };
} else if (path === "/auth/signup" && method === "POST") {
const result = await db().auth.signUp.email({ name: inputString(body.name, "name"), email: inputString(body.email, "email"), password: inputString(body.password, "password") });
diff --git a/apps/web/src/nav.ts b/apps/web/src/nav.ts
index 5a685bba..595607bb 100644
--- a/apps/web/src/nav.ts
+++ b/apps/web/src/nav.ts
@@ -1,3 +1,13 @@
+import {
+ PATH_SCREEN,
+ SCREEN_PATH,
+ WINDOW_PAGE,
+ isWindowId,
+ type ScreenId,
+ type WindowId,
+} from "./data/household";
+import { readStored, removeStored, writeStored } from "./live/storage";
+
/** Preserve ?native=1 across client navigations. */
export function keepSearch(path: string): string {
@@ -19,3 +29,160 @@ export function isNativeNow(): boolean {
const params = new URLSearchParams(window.location.search);
return params.get("native") === "1";
}
+
+// Every page and window has an address, so Back closes a window, a reload
+// opens it again and a link opens it for another member. A screen that shows
+// one commune names it (?commune=); a window's address continues its
+// screen's, so /expenses/ is that expense over Expenses.
+
+/** Screens that show one commune. */
+export const COMMUNE_SCREENS: ReadonlySet = new Set([
+ "hub", "expenses", "settle", "recurring", "funds", "templates", "members", "operations", "notes",
+ "activity", "commune-settings",
+]);
+
+// Below the screen's address. ":id" is the record the window shows.
+const WINDOW_TAILS: Partial> = {
+ "add-expense": "new",
+ expense: ":id",
+ "expense-paid": ":id/paid",
+ breakdown: "breakdown",
+ pay: "pay/:id",
+ "note-new": "new",
+ note: ":id",
+ "note-edit": ":id/edit",
+ "operation-new": "new",
+ operation: ":id",
+ "operation-edit": ":id/edit",
+ "commune-new": "new",
+ "member-invite": "invite",
+ "announcement-new": "announce",
+ profile: "profile",
+};
+
+/** Windows that edit or add something. Leaving one never leaves it in the history. */
+export const FORM_WINDOWS: ReadonlySet = new Set([
+ "add-expense", "note-new", "note-edit", "operation-new", "operation-edit",
+ "commune-new", "member-invite", "announcement-new",
+]);
+
+// Record ids are UUIDs; the sample data uses short names. "new" is the add form.
+const RECORD_ID = /^(?!new$)[A-Za-z0-9_-]{1,128}$/;
+
+export type Address = {
+ /** Null when Commune has no such page. */
+ screen: ScreenId | null;
+ window: WindowId | null;
+ /** The expense, note, task or fund the address names. */
+ record: string;
+ /** The commune the address names, which this account may not be in. */
+ commune: string;
+ params: URLSearchParams;
+};
+
+export function readAddress(path: string, search = ""): Address {
+ const params = new URLSearchParams(search);
+ const commune = params.get("commune") ?? "";
+ const pathname = path.length > 1 ? path.replace(/\/+$/, "") : path;
+ // A window that belongs to no screen (Notices) opens over the current one.
+ const named = params.get("window") ?? "";
+ const over = isWindowId(named) ? named : null;
+ const screen = PATH_SCREEN[pathname];
+ if (screen) return { screen, window: over, record: "", commune, params };
+ const parts = pathname.split("/").slice(1);
+ for (const [window, tail] of Object.entries(WINDOW_TAILS) as Array<[WindowId, string]>) {
+ const page = WINDOW_PAGE[window];
+ if (!page) continue;
+ const record = matchTail(pathname, SCREEN_PATH[page], tail);
+ if (record !== null) return { screen: page, window, record, commune, params };
+ }
+ // A fund opens in a dialog on Funds rather than in a window.
+ if (parts.length === 2 && parts[0] === "funds" && RECORD_ID.test(parts[1])) {
+ return { screen: "funds", window: over, record: parts[1], commune, params };
+ }
+ return { screen: null, window: null, record: "", commune, params };
+}
+
+// The record id when the path is the screen's followed by the tail, else null.
+function matchTail(pathname: string, base: string, tail: string): string | null {
+ const want = `${base}/${tail}`.split("/").slice(1);
+ const have = pathname.split("/").slice(1);
+ if (want.length !== have.length) return null;
+ let record = "";
+ for (let index = 0; index < want.length; index += 1) {
+ if (want[index] === ":id") {
+ if (!RECORD_ID.test(have[index])) return null;
+ record = have[index];
+ } else if (want[index] !== have[index]) return null;
+ }
+ return record;
+}
+
+/** The address of a screen, or of a window over it. */
+export function addressOf(screen: ScreenId, options: {
+ window?: WindowId | null;
+ record?: string;
+ commune?: string;
+ params?: Record;
+} = {}): string {
+ const { window, record = "", commune = "" } = options;
+ const params = new URLSearchParams();
+ let path = SCREEN_PATH[screen];
+ const tail = window ? WINDOW_TAILS[window] : undefined;
+ if (window && tail && WINDOW_PAGE[window] === screen && (!tail.includes(":id") || RECORD_ID.test(record))) {
+ path = `${path}/${tail.replace(":id", record)}`;
+ } else if (window) {
+ params.set("window", window);
+ } else if (screen === "funds" && RECORD_ID.test(record)) {
+ path = `${path}/${record}`;
+ }
+ if (commune && COMMUNE_SCREENS.has(screen)) params.set("commune", commune);
+ for (const [key, value] of Object.entries(options.params ?? {})) if (value) params.set(key, value);
+ const query = params.toString();
+ return query ? `${path}?${query}` : path;
+}
+
+/** The same address naming a commune, keeping everything else in it. */
+export function withCommune(path: string, search: string, commune: string): string {
+ const params = new URLSearchParams(search);
+ params.set("commune", commune);
+ return `${path}?${params.toString()}`;
+}
+
+const RETURN_TO = "commune.return-to";
+// Pages a sign-in reaches by itself, so not worth coming back to.
+const NO_RETURN = new Set(["/", "/login", "/signup", "/verify-email", "/forgot-password", "/reset-password", "/onboard"]);
+
+/**
+ * A page on this site to open after signing in, or null. Never another site:
+ * not an absolute URL, a protocol-relative one ("//"), or a backslash or
+ * control character that a browser would read as one.
+ */
+export function safeReturnPath(raw: string | null | undefined): string | null {
+ if (!raw || raw.length > 2048 || !raw.startsWith("/") || raw.startsWith("//")) return null;
+ if (/[\\\u0000-\u001f\u007f]/.test(raw)) return null;
+ const base = "https://commune.invalid";
+ let url: URL;
+ try {
+ url = new URL(raw, base);
+ } catch {
+ return null;
+ }
+ if (url.origin !== base || NO_RETURN.has(url.pathname)) return null;
+ return `${url.pathname}${url.search}${url.hash}`;
+}
+
+/** The page someone asked for before signing in. Asking for Home forgets it. */
+export function rememberReturn(path: string): void {
+ const safe = safeReturnPath(path);
+ if (safe) writeStored(RETURN_TO, safe);
+ else removeStored(RETURN_TO);
+}
+
+export function readReturn(): string | null {
+ return safeReturnPath(readStored(RETURN_TO));
+}
+
+export function forgetReturn(): void {
+ removeStored(RETURN_TO);
+}
diff --git a/apps/web/src/screens/Funds.tsx b/apps/web/src/screens/Funds.tsx
index 0ca04bd2..62782be3 100644
--- a/apps/web/src/screens/Funds.tsx
+++ b/apps/web/src/screens/Funds.tsx
@@ -2,7 +2,6 @@ import { useEffect, useRef, useState, type FormEvent } from "react";
import { fundFigures } from "../data/funds";
import { HandCoins, Plus } from "lucide-react";
import { useApp } from "../state";
-import { useSearchRecord } from "../ui/useSearchRecord";
import { money, type Fund, type Commitment } from "../live/model";
import { moneyInputError, parseMoneyInput } from "../data/moneyInput";
import { LIMITS } from "../data/limits";
@@ -70,6 +69,9 @@ export function FundsScreen() {
voidContribution,
sendReminder,
go,
+ fundKey,
+ closeWindow,
+ contentLoading,
} = useApp();
const [mode, setMode] = useState(null);
const [name, setName] = useState("");
@@ -95,8 +97,6 @@ export function FundsScreen() {
// commune has one; the database refuses it otherwise.
const anotherAdmin = (snapshots[hub.id]?.members ?? []).some((member) =>
member.active !== false && member.role === "admin" && member.userId !== session?.userId);
- const searchRecord = useSearchRecord("fund", funds.filter(item => !item.archivedAt).map(item => item.id),
- id => { setShowArchived(false); setMode({ kind: "view", fundId: id }); });
const fund =
mode && "fundId" in mode
? funds.find((item) => item.id === mode.fundId)
@@ -135,6 +135,43 @@ export function FundsScreen() {
}
}, [rebase, commitment, mode]);
+ // A fund's address opens it: a link, a reload, Back to it, a search result
+ // or a notice. Leaving the address closes its view; a form opened from the
+ // view stays until it is finished.
+ const [highlightedId, setHighlightedId] = useState("");
+ const [missingFund, setMissingFund] = useState("");
+ const opened = useRef("");
+ useEffect(() => {
+ if (!fundKey) {
+ opened.current = "";
+ setMissingFund("");
+ setMode((current) => current?.kind === "view" ? null : current);
+ return;
+ }
+ if (contentLoading || opened.current === fundKey) return;
+ opened.current = fundKey;
+ // Opened with View fund on this screen: already showing.
+ if (mode && "fundId" in mode && mode.fundId === fundKey) return;
+ const target = funds.find((item) => item.id === fundKey);
+ if (!target) {
+ setMissingFund("That fund isn’t in this commune. It may have been removed.");
+ return;
+ }
+ setShowArchived(Boolean(target.archivedAt));
+ setHighlightedId(fundKey);
+ const card = document.getElementById(`search-record-fund-${fundKey}`);
+ card?.scrollIntoView({ block: "center" });
+ card?.focus();
+ open({ kind: "view", fundId: fundKey });
+ }, [contentLoading, fundKey, funds]);
+ // Closing the fund's dialogs leaves its address.
+ const shownMode = useRef(null);
+ useEffect(() => {
+ const was = shownMode.current;
+ shownMode.current = mode;
+ if (was && !mode && fundKey && opened.current === fundKey) closeWindow();
+ }, [mode]);
+
function open(next: Mode) {
setError("");
setMessage("");
@@ -486,7 +523,7 @@ export function FundsScreen() {
)}
-
+
{!visible.length && (
pledge.userId === session?.userId,
);
return (
-
open({ kind: "view", fundId: item.id })}
+ onClick={() => {
+ open({ kind: "view", fundId: item.id });
+ go("funds", { fund: item.id });
+ }}
>
View fund{" "}{item.name}
diff --git a/apps/web/src/state.tsx b/apps/web/src/state.tsx
index 1d32d2b2..f2809839 100644
--- a/apps/web/src/state.tsx
+++ b/apps/web/src/state.tsx
@@ -7,14 +7,13 @@ import {
useState,
type ReactNode,
} from "react";
-import { useLocation } from "wouter";
+import { useLocation, useRouter, useSearch } from "wouter";
+import { useBrowserLocation } from "wouter/use-browser-location";
import { isConnectionFailure } from "./live/connection";
import { allocateExpense, validateExpenseAllocation, type ExpenseAllocation } from "../../../packages/core/src/expenseSplit";
import {
PAGE_LEDES,
PAGE_TITLES,
- PATH_SCREEN,
- SCREEN_PATH,
WINDOW_PAGE,
initialsOf,
isWindowId,
@@ -32,7 +31,7 @@ import {
type OperationStore,
} from "./data/operations";
import { newRequestId } from "./data/requestId";
-import { isNativeNow, keepSearch, pathOnly } from "./nav";
+import { COMMUNE_SCREENS, FORM_WINDOWS, addressOf, isNativeNow, keepSearch, readAddress, withCommune } from "./nav";
import { api, post, loadCommunes, loadExpensePage, loadExpenseRecords, loadSnapshot, ApiError } from "./live/api";
import { announceSession, currentViewer, onOtherTabSession, setViewer } from "./live/session";
import {
@@ -190,8 +189,9 @@ type GoOpts = {
operation?: string;
date?: string;
time?: string;
- window?: WindowId;
expense?: string;
+ /** A fund opens at its own address, over Funds. */
+ fund?: string;
template?: string;
record?: { kind: "fund" | "recurring" | "member" | "template"; id: string };
};
@@ -248,6 +248,8 @@ export type AppState = {
contentLoading: boolean;
contentError: string | null;
emptyHub: boolean;
+ /** The address names a commune this account is not in, or no longer in. */
+ unknownCommune: boolean;
window: WindowId | null;
moreOpen: boolean;
navMode: NavMode;
@@ -259,6 +261,8 @@ export type AppState = {
operationsByHub: OperationStore;
operations: OperationItem[];
selectedOperation: OperationItem | null;
+ /** The fund whose address is open, shown in a dialog on Funds. */
+ fundKey: string;
/** Whether the app is dark now: chosen, or following the device. */
dark: boolean;
theme: Theme;
@@ -317,7 +321,16 @@ export type AppState = {
/** Why this tab was sent back to sign-in, when it was not the person's choice. */
sessionNotice: string | null;
go: (target: GoTarget, opts?: GoOpts) => void;
+ /** Back to the window's screen: the entry before it when that is where it opened. */
closeWindow: () => void;
+ /**
+ * An open window reports whether it holds typed text, so Back can ask
+ * before discarding it. Returns a function that stops the reporting.
+ */
+ watchTypedText: (hasTyped: () => boolean) => () => void;
+ /** Back was pressed over typed text; the window waits for an answer. */
+ leaveAsked: boolean;
+ answerLeave: (discard: boolean) => void;
setMoreOpen: (open: boolean) => void;
setNavMode: (mode: NavMode) => void;
setTheme: (theme: Theme) => void;
@@ -462,6 +475,11 @@ function emptyHub(): LiveHub {
};
}
+function addressAt(url: string) {
+ const at = url.indexOf("?");
+ return at < 0 ? readAddress(url) : readAddress(url.slice(0, at), url.slice(at + 1));
+}
+
function authPath(target: GoTarget): string {
if (target === "login") return "/login";
if (target === "signup") return "/signup";
@@ -532,8 +550,11 @@ function withOlderExpenses(snapshot: Snapshot, older: OlderExpenses | undefined)
}
export function AppProvider({ children }: { children: ReactNode }) {
- const [location, setLocation] = useLocation();
- const screen: ScreenId = PATH_SCREEN[pathOnly(location)] ?? "home";
+ const [path, setLocation] = useLocation();
+ const search = useSearch();
+ // Back and Forward belong to the browser's history; tests use a memory location.
+ const browserHistory = useRouter().hook === useBrowserLocation;
+ const url = search ? `${path}?${search}` : path;
const [authStatus, setAuthStatus] = useState("loading");
const [apiError, setApiError] = useState(null);
const [signOutUnconfirmed, setSignOutUnconfirmed] = useState(false);
@@ -550,14 +571,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
const prefersDark = usePrefersDark();
const [hubId, setHubId] = useState("");
const [snapshotErrors, setSnapshotErrors] = useState>({});
- const [windowId, setWindowId] = useState(null);
const [moreOpen, setMoreOpen] = useState(false);
- const [noteKey, setNoteKey] = useState("");
- const [selectedExpenseId, setSelectedExpenseId] = useState("");
- const [expenseTemplateId, setExpenseTemplateId] = useState("");
- const [operationKey, setOperationKey] = useState("");
- const [operationDate, setOperationDate] = useState(todayIso());
- const [operationTime, setOperationTime] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [searchScope, setSearchScope] = useState(null);
const [searchTarget, setSearchTarget] = useState(null);
@@ -580,11 +594,89 @@ export function AppProvider({ children }: { children: ReactNode }) {
// by account, commune and expense.
const recordReads = useRef(new Set());
const [recordResults, setRecordResults] = useState>>({});
+
+ // Navigations this tab makes itself. Any other change of address is the
+ // browser's: Back, Forward or an address typed in.
+ const navigating = useRef(false);
+ const typedText = useRef<(() => boolean) | null>(null);
+ // Back over typed text in a window keeps the window at its address until
+ // the person says whether to discard what they typed.
+ const [heldUrl, setHeldUrl] = useState(null);
+ const [leaveAsked, setLeaveAsked] = useState(false);
+ const [seen, setSeen] = useState(url);
+ if (seen !== url) {
+ setSeen(url);
+ const before = addressAt(seen);
+ const after = readAddress(path, search);
+ const closing = Boolean(before.window) && (before.window !== after.window || before.record !== after.record);
+ if (!navigating.current && closing && after.screen && data && typedText.current?.()) setHeldUrl(seen);
+ else if (heldUrl === url) setHeldUrl(null);
+ }
+ const shown = heldUrl ? addressAt(heldUrl) : readAddress(path, search);
+ const screen: ScreenId = shown.screen ?? "home";
+ const urlRef = useRef(url);
+ urlRef.current = url;
+ const shownRef = useRef(shown);
+ shownRef.current = shown;
+ // A screen for one commune names it in its address. Other screens show the
+ // commune this tab showed last.
+ const urlCommune = shown.screen && COMMUNE_SCREENS.has(shown.screen) ? shown.commune : "";
+ const unknownCommune = Boolean(urlCommune) && Boolean(data) && !data!.communes.some((commune) => commune.id === urlCommune);
+ const chosenId = urlCommune && !unknownCommune ? urlCommune : hubId;
+ const activeRef = useRef("");
const overview = screen === "home" || screen === "home-empty" || screen === "home-inbox" || screen === "communes" || screen === "home-activity";
- const viewRef = useRef({ hubId, overview });
- viewRef.current = { hubId, overview };
+ const viewRef = useRef({ hubId: chosenId, overview });
+ viewRef.current = { hubId: chosenId, overview };
const native = isNativeNow();
+ useEffect(() => { navigating.current = false; }, [url]);
+ // Back went past a window with typed text: put the window back and ask.
+ useEffect(() => {
+ if (!heldUrl || heldUrl === url) return;
+ navigating.current = true;
+ setLocation(heldUrl, { state: { communeBack: url } });
+ setLeaveAsked(true);
+ }, [heldUrl, url, setLocation]);
+
+ // Opening something adds an entry to the history; leaving a window returns
+ // to the entry before it when that is where it opened, so Back never goes
+ // to a window already closed. A replaced entry keeps where it opened from.
+ const navigate = useCallback((to: string, how: { returning?: boolean; fallback?: "push" | "replace" } = {}) => {
+ const target = keepSearch(to);
+ const here = urlRef.current;
+ if (target === here) return;
+ navigating.current = true;
+ const entry: unknown = browserHistory ? window.history.state : null;
+ const back = entry && typeof entry === "object" && typeof (entry as { communeBack?: unknown }).communeBack === "string"
+ ? (entry as { communeBack: string }).communeBack : null;
+ // Where this tab is going, before the address shows it.
+ urlRef.current = target;
+ if (how.returning && back === target) {
+ window.history.back();
+ return;
+ }
+ if (how.fallback === "replace") setLocation(target, { replace: true, state: back ? { communeBack: back } : null });
+ else setLocation(target, { state: { communeBack: here } });
+ }, [browserHistory, setLocation]);
+ // A window, or a fund's dialog, closes to the screen it is over.
+ const leaveRecord = useCallback(() => {
+ const here = shownRef.current;
+ if (!here.screen || (!here.window && !here.record)) return;
+ navigate(addressOf(here.screen, { commune: here.commune || activeRef.current }), { returning: true, fallback: "replace" });
+ }, [navigate]);
+ const watchTypedText = useCallback((hasTyped: () => boolean) => {
+ typedText.current = hasTyped;
+ return () => { if (typedText.current === hasTyped) typedText.current = null; };
+ }, []);
+ const answerLeave = useCallback((discard: boolean) => {
+ setLeaveAsked(false);
+ if (!discard) return;
+ // Carry on to where Back was going.
+ if (!browserHistory) return leaveRecord();
+ navigating.current = true;
+ window.history.back();
+ }, [browserHistory, leaveRecord]);
+
const readSnapshot = useCallback((commune: Commune, sequence: number, force = false, detail?: "summary"): Promise => {
const current = dataRef.current;
if (!current || sequence !== refreshSequence.current) return Promise.resolve();
@@ -654,7 +746,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
setSnapshotErrors({});
snapshotRevisions.current.clear();
setHubId("");
- setWindowId(null);
// Device preferences stay; the account's go with it.
setPrefs(DEFAULT_ACCOUNT);
if (userId) forgetAccountPrefs(userId);
@@ -952,9 +1043,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
forgetSession();
setApiError(null);
announceSession(null);
- setLocation(keepSearch("/login"));
+ navigate("/login");
await confirmSignOut();
- }, [confirmSignOut, forgetSession, setLocation]);
+ }, [confirmSignOut, forgetSession, navigate]);
const changePassword = useCallback((currentPassword: string, newPassword: string) => mutate(async () => {
await post("/auth/change-password", { currentPassword, newPassword });
}), [mutate]);
@@ -987,8 +1078,21 @@ export function AppProvider({ children }: { children: ReactNode }) {
youMeta: `${commune.role === "admin" ? "Admin" : "Member"} · ${commune.name}`,
};
}) : [];
- const hub = places.find((place) => place.id === hubId) ?? places[0] ?? emptyHub();
+ const hub = places.find((place) => place.id === chosenId) ?? places[0] ?? emptyHub();
const activeId = hub.id;
+ activeRef.current = activeId;
+ // The open window and the record it shows come from the address.
+ const windowId = unknownCommune ? null : shown.window;
+ const recordId = unknownCommune ? "" : shown.record;
+ const selectedExpenseId = windowId === "expense" || windowId === "expense-paid" || windowId === "pay" ? recordId : "";
+ const noteKey = windowId === "note" || windowId === "note-edit" ? recordId : "";
+ const operationKey = windowId === "operation" || windowId === "operation-edit" ? recordId : "";
+ const fundKey = screen === "funds" ? recordId : "";
+ const expenseTemplateId = windowId === "add-expense" ? shown.params.get("template") ?? "" : "";
+ const askedDate = windowId === "operation-new" ? shown.params.get("date") ?? "" : "";
+ const askedTime = windowId === "operation-new" ? shown.params.get("time") ?? "" : "";
+ const operationDate = /^\d{4}-\d{2}-\d{2}$/.test(askedDate) ? askedDate : todayIso();
+ const operationTime = /^\d{2}:\d{2}$/.test(askedTime) ? askedTime : "";
const held = data?.snapshots[activeId];
// The open commune's own screens never show a summary; they wait for its record.
const snapshot = held?.detail === "summary" ? undefined : held;
@@ -1233,12 +1337,12 @@ export function AppProvider({ children }: { children: ReactNode }) {
} }, invites: { ...latest.invites, [result.id]: [] },
});
setHubId(result.id);
- savePrefs({ activeHubId: result.id });
setMoreOpen(false);
- setLocation(keepSearch(SCREEN_PATH.hub));
- setWindowId(opts?.openExpense ? "add-expense" : null);
+ // The form that made it is done with, so it leaves no entry to go back to.
+ navigate(opts?.openExpense ? addressOf("expenses", { window: "add-expense", commune: result.id })
+ : addressOf("hub", { commune: result.id }), { fallback: "replace" });
return result.id;
- }), [mutate, publishData, savePrefs, setLocation]);
+ }), [mutate, navigate, publishData]);
const updateCommune = useCallback((input: {
name: string; type: LiveHub["type"]; currency: CurrencyCode;
@@ -1272,7 +1376,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
requestId: input.requestId,
});
await refreshRecords();
- setSelectedExpenseId(result.id);
return result.id;
}), [activeId, data, mutate, refreshRecords]);
@@ -1389,10 +1492,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
const result = await post<{ communeId: string }>(`/invites/${encodeURIComponent(token)}/accept`, {});
await refresh();
setHubId(result.communeId);
- savePrefs({ activeHubId: result.communeId });
- setLocation(keepSearch(SCREEN_PATH.hub));
+ navigate(addressOf("hub", { commune: result.communeId }), { fallback: "replace" });
return result.communeId;
- }), [mutate, refresh, savePrefs, setLocation]);
+ }), [mutate, navigate, refresh]);
// A repeat of the same requestId returns the record the first attempt made.
const createFund = useCallback((name: string, targetMinor: number | null, requestId: string) => mutate(async () => {
@@ -1452,8 +1554,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
const saveSplitTemplate = async (input: SplitTemplateInput) => (await workflow<{id:string}>(`/communes/${encodeURIComponent(activeId)}/templates`,input)).id;
const manageMember = async (userId: string, action: "admin" | "member" | "leave" | "remove") => mutate(async () => {
await post(`/communes/${encodeURIComponent(activeId)}/members/${encodeURIComponent(userId)}`,{action});
+ // Its addresses are no longer yours: go Home before the list drops it.
+ if (action === "leave") navigate(addressOf("home"));
await refresh();
- if (action === "leave") { setWindowId(null); setLocation(keepSearch("/")); }
});
const sendReminder = useCallback((kind: "nudge" | "chase", sourceId: string, requestId: string) => mutate(async () => {
const actorId = dataRef.current?.user.id;
@@ -1483,7 +1586,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
const { id: _draftId, ...payload } = card;
const result = await post<{ id: string }>(`/communes/${encodeURIComponent(activeId)}/notes`, { ...payload, requestId });
await refreshRecords();
- setNoteKey(result.id);
return result.id;
}), [activeId, mutate, refreshRecords]);
const updateNote = useCallback((card: NoteCard & { id: string; expectedVersion?: number }) => mutate(async () => {
@@ -1499,7 +1601,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
throw error;
}
await refreshRecords();
- setNoteKey(id);
return id;
}), [activeId, data, mutate, refreshRecords]);
const loadNoteRevision = useCallback(async (noteId: string, revisionId: string) =>
@@ -1508,8 +1609,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
if (!activeId) throw new Error("Choose a commune first");
const result = await post<{ id: string }>(`/communes/${encodeURIComponent(activeId)}/operations`, { ...operationPayload(item), requestId });
await refreshRecords();
- setOperationKey(result.id);
- setOperationDate(item.date);
return result.id;
}), [activeId, mutate, refreshRecords]);
const updateOperation = useCallback((id: string, item: NewOperation, expectedVersion?: number) => mutate(async () => {
@@ -1527,8 +1626,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
throw error;
}
await refreshRecords();
- setOperationDate(item.date);
- if (item.time) setOperationTime(item.time);
}), [mutate, operations, refreshRecords]);
// Task actions are commands: the database advances the date and rotation
// and rejects a stale click, so two people cannot complete one occurrence.
@@ -1580,64 +1677,71 @@ export function AppProvider({ children }: { children: ReactNode }) {
const removeOperation = useCallback((id: string) => mutate(async () => {
await api(`/operations/${encodeURIComponent(id)}`, { method: "DELETE" });
await refreshRecords();
- setWindowId(null);
- }), [mutate, refreshRecords]);
+ leaveRecord();
+ }), [leaveRecord, mutate, refreshRecords]);
+
+ // A commune named in the address is the one this tab shows, on every screen.
+ useEffect(() => {
+ if (urlCommune && !unknownCommune && urlCommune !== hubId) setHubId(urlCommune);
+ }, [hubId, unknownCommune, urlCommune]);
+ // A screen for one commune names it, so a reload or a shared link opens the same one.
+ useEffect(() => {
+ if (heldUrl || !data || !shown.screen || !COMMUNE_SCREENS.has(shown.screen) || shown.commune || !activeId) return;
+ // Never over a newer navigation that this render has not seen.
+ if (urlRef.current !== url) return;
+ navigate(withCommune(path, search, activeId), { fallback: "replace" });
+ }, [activeId, data, heldUrl, navigate, path, search, shown.commune, shown.screen, url]);
+ // The commune this tab shows opens next time, while the account is still in it.
+ const remembered = useRef("");
+ useEffect(() => {
+ if (!viewerId || !activeId || remembered.current === `${viewerId}:${activeId}`) return;
+ remembered.current = `${viewerId}:${activeId}`;
+ savePrefs({ activeHubId: activeId });
+ }, [activeId, savePrefs, viewerId]);
const go = useCallback((target: GoTarget, opts?: GoOpts) => {
- setSearchTarget(opts?.record ? { ...opts.record, communeId: opts.hub ?? activeId } : null);
- if (opts?.hub && places.some((place) => place.id === opts.hub)) {
- if (opts.hub !== activeId) {
- setSelectedExpenseId("");
- setNoteKey("");
- setOperationKey("");
- }
- setHubId(opts.hub);
- savePrefs({ activeHubId: opts.hub });
- }
- if (opts?.expense) setSelectedExpenseId(opts.expense);
- if (target === "add-expense") setExpenseTemplateId(opts?.template ?? "");
- if (opts?.note) setNoteKey(opts.note);
- if (opts?.operation) setOperationKey(opts.operation);
- if (opts?.date) setOperationDate(opts.date);
- if (opts?.time) setOperationTime(opts.time);
- else if (target === "operation-new") setOperationTime("");
+ // A fund opens at its own address; other records are found on their screen.
+ const fund = opts?.fund ?? (opts?.record?.kind === "fund" ? opts.record.id : undefined);
+ setSearchTarget(opts?.record && opts.record.kind !== "fund" ? { ...opts.record, communeId: opts.hub ?? activeId } : null);
setMoreOpen(false);
if (target === "login" || target === "signup" || target === "forgot" ||
target === "invite" || target === "onboard") {
- setWindowId(null);
- setLocation(keepSearch(authPath(target)));
+ navigate(authPath(target));
return;
}
- if (isWindowId(target)) {
- const stay = target === "add-expense" &&
- (screen === "hub-empty" || screen === "home-empty" || screen === "expenses-empty");
- if (!stay) {
- const page = WINDOW_PAGE[target];
- if (page) {
- const dest: ScreenId = page;
- if (dest !== screen) setLocation(keepSearch(SCREEN_PATH[dest]));
- }
- }
- setWindowId(target);
- return;
- }
- let dest: ScreenId = target;
- if (target === "home-empty") dest = "home";
- if (target === "hub-empty") dest = "hub";
- if (target === "expenses-empty") dest = "expenses";
- if (target === "settle-even") dest = "settle";
- setWindowId(opts?.window ?? null);
- setLocation(keepSearch(SCREEN_PATH[dest]));
- }, [activeId, data, places, savePrefs, screen, session?.hasHubs, setLocation]);
+ const commune = opts?.hub && places.some((place) => place.id === opts.hub) ? opts.hub : activeId;
+ // Screens that do not name a commune still show this one.
+ if (commune !== hubId) setHubId(commune);
+ const here = shownRef.current;
+ const win = isWindowId(target) ? target : null;
+ const dest: ScreenId = win ? WINDOW_PAGE[win] ?? here.screen ?? "home"
+ : target === "home-empty" ? "home" : target === "hub-empty" ? "hub"
+ : target === "expenses-empty" ? "expenses" : target === "settle-even" ? "settle" : target as ScreenId;
+ const record = win === "expense" || win === "expense-paid" || win === "pay" ? opts?.expense
+ : win === "note" || win === "note-edit" ? opts?.note
+ : win === "operation" || win === "operation-edit" ? opts?.operation
+ : !win && dest === "funds" ? fund : undefined;
+ const to = addressOf(dest, { window: win, record, commune, params: {
+ template: win === "add-expense" ? opts?.template : undefined,
+ date: win === "operation-new" ? opts?.date : undefined,
+ time: win === "operation-new" ? opts?.time : undefined,
+ } });
+ // From a window, going where it opened from is Back. A finished or
+ // cancelled form is replaced, so Back never reopens it.
+ navigate(to, {
+ returning: Boolean(here.window || here.record),
+ fallback: here.window && FORM_WINDOWS.has(here.window) ? "replace" : "push",
+ });
+ }, [activeId, hubId, navigate, places]);
// Dialogs have their own heading; the page behind them remains the page.
const isHome = screen === "home" || screen === "home-empty";
const firstName = you.name.trim().split(/\s+/)[0];
- const pageTitle = isHome
+ const pageTitle = unknownCommune ? "Commune not found" : isHome
? firstName ? `Your communes, ${firstName}.` : "Your communes."
: PAGE_TITLES[screen] ?? "Home";
const outstandingShareCount = places.reduce((sum, place) => sum + place.moves.length, 0);
- const pageLede = isHome
+ const pageLede = unknownCommune ? "" : isHome
? `${places.length} ${places.length === 1 ? "space" : "spaces"} · shared details that stay in sync for everyone.`
: screen === "communes"
? session?.hasHubs
@@ -1656,9 +1760,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
return 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,
+ hub, places, snapshots: data?.snapshots ?? {}, contentLoading, contentError, emptyHub: empty, unknownCommune, window: windowId, moreOpen,
navMode: device.navMode, noteKey, notes, operationKey, operationDate,
- operationTime, operationsByHub, operations, selectedOperation,
+ operationTime, operationsByHub, operations, selectedOperation, fundKey,
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,
@@ -1666,7 +1770,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
funds, recurringBills, splitTemplates, expenseTemplateId, reminderTargets, reminderUsage, sentReminders, notifications,
signOut, signOutUnconfirmed, retrySignOut: confirmSignOut, sessionNotice, go,
changePassword, signOutOtherDevices,
- closeWindow: () => { if (!mutationCount) setWindowId(null); }, setMoreOpen,
+ closeWindow: () => { if (!mutationCount) leaveRecord(); }, watchTypedText, leaveAsked, answerLeave, setMoreOpen,
setNavMode: (navMode) => saveDevice({ navMode }),
setTheme: (theme) => saveDevice({ theme }),
setNoticesOn: (noticesOn) => saveDevice({ noticesOn }),
diff --git a/apps/web/src/windows/Windows.tsx b/apps/web/src/windows/Windows.tsx
index caa82417..5d353ad9 100644
--- a/apps/web/src/windows/Windows.tsx
+++ b/apps/web/src/windows/Windows.tsx
@@ -60,6 +60,9 @@ export function Windows() {
expenseRead,
retryExpenseRead,
refresh,
+ watchTypedText,
+ leaveAsked,
+ answerLeave,
} = useApp();
const frameRef = useRef(null);
const inlineNote = window === "note" && screen === "notes" && Boolean(notes[noteKey]);
@@ -76,6 +79,8 @@ export function Windows() {
const dismissRef = useRef(dismiss);
useEffect(() => { dismissRef.current = dismiss; });
useEffect(() => { if (!isOpen) setAsking(false); }, [isOpen]);
+ // Back asks too (see heldUrl in state.tsx).
+ useEffect(() => isOpen ? watchTypedText(typed.hasTyped) : undefined, [isOpen, typed.hasTyped, watchTypedText]);
useEffect(() => {
if (!isOpen) return;
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
@@ -159,7 +164,8 @@ export function Windows() {
>}
- {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.
-
{
- closeWindow();
- go("settle");
- }}
- >
+ go("settle")}>
Open settlement
@@ -786,7 +785,7 @@ function Profile() {
}
function Breakdown() {
- const { go, closeWindow, hub, expenses, voidedExpenses, session, snapshots, expenseHistory, loadFullHistory } = useApp();
+ const { go, hub, expenses, voidedExpenses, session, snapshots, expenseHistory, loadFullHistory } = useApp();
// A statement covers everything shared with that person, so it waits for every older expense.
const [historyFailed, setHistoryFailed] = useState(false);
useEffect(() => {
@@ -827,14 +826,7 @@ function Breakdown() {
Settle
- {
- closeWindow();
- go("settle");
- }}
- >
+ go("settle")}>
Open settlement
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/