diff --git a/apps/local-api/README.md b/apps/local-api/README.md
index 5e2c696e..8730d8a2 100644
--- a/apps/local-api/README.md
+++ b/apps/local-api/README.md
@@ -17,11 +17,13 @@ browser code sends same-origin `/api` requests. Non-2xx errors are
| POST | `/api/auth/login` | `{email,password}` | `{user}` + cookie |
| POST | `/api/auth/logout` | `{}` | `{ok:true}` + cleared cookie |
| GET | `/api/auth/me` | public | `{user}` or `{user:null}` |
-| GET | `/api/communes` | | `{communes:[...]}` |
+| GET | `/api/communes` | | `{communes:[{id,name,kind,currency,role,memberCount,address,destination,stayName,stayAddress,changeMark}]}`; a commune's `changeMark` moves whenever anything in it changes |
| GET | `/api/search?q=...&commune=...&limit=8` | session required; optional commune UUID; 2–80 chars, 1–25 results per type | `{query,items,total,hasMore}`; only active memberships, safe note excerpts |
-| POST | `/api/communes` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}` | `{id}` |
+| POST | `/api/communes` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}`; one account creates at most 10 communes an hour, 30 a day and 100 in all (409) | `{id}` |
| PATCH | `/api/communes/:id` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}` admin; the currency is fixed (400) once the commune has an expense, fund, recurring bill or exact split template, voided or archived ones included | `{ok:true}` |
-| GET | `/api/communes/:id/snapshot` | membership checked; outsiders get 404 | `{commune,members,expenses,funds,notes,operations,notifications,reminderTargets,recurringBills,splitTemplates,activityEvents}` |
+| GET | `/api/communes/:id/snapshot` | membership checked; outsiders get 404; `?detail=summary` leaves out what only the commune's own screens show | `{commune,members,expenses,funds,notes,operations,notifications,reminderTargets,recurringBills,splitTemplates,activityEvents,expenseHistory,changeMark}`; `expenses` are the open ones, those created or changed since the start of last month (UTC) and the five newest that are not voided; `expenseHistory` is `{since,olderCount,olderVoidedCount}` |
+| GET | `/api/communes/:id/expenses/history?before=&months=` | member; `before` (a date) defaults to the snapshot's `since`; `months` 1 to 12, or left out for every older expense | `{expenses,before,from,olderCount,olderVoidedCount}`: whole months (UTC) before `before`, reaching back past months with none; `from` is null once nothing older remains |
+| GET | `/api/communes/:id/expenses/records?ids=` | member; 1 to 50 comma-separated expense ids; ids from another commune are left out | `{expenses}` in the snapshot's shape |
| POST | `/api/communes/:id/expenses` | `{title,amountMinor,paidByUserId?,category?,shares:[{userId,amountMinor}],dueOn?,requestId?}`; recording for another payer requires admin; a repeated `requestId` returns the same expense, and reusing it for different details is 400 | `{id,duplicate}` (201, or 200 for a repeat) |
| POST | `/api/shares/:id/confirm` | `{}` payer only; records one receipt for everything outstanding | `{ok:true}` |
| POST | `/api/shares/:id/receipts` | `{amountMinor,receivedOn,reference?,requestId}` payer only; amount up to the outstanding share; a repeated `requestId` returns the same entry | `{id,duplicate,settledMinor,outstandingMinor}` (201, or 200 for a repeat) |
@@ -99,6 +101,7 @@ API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/money-lifecycle.mjs
API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/operations-history.mjs
API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/invites-pledges-requests.mjs
API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/session-viewer.mjs
+API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/lighter-loading.mjs
```
`money-lifecycle.mjs` needs migrations 0015 to 0017 and covers reminder
@@ -124,6 +127,10 @@ the signed-in account and requests without the header are still served.
`invitation-email-opt-in.mjs` needs no API:
`node --test apps/local-api/tests/invitation-email-opt-in.mjs`.
+`lighter-loading.mjs` needs 0036 and covers change marks in the list and the
+snapshot, the overview summary, pages of older expenses, named expense records
+and the limit on creating communes. It refuses port 3002.
+
Search integration requires migration 0014. It covers all eight result types,
active membership, selected/all-commune scopes, removed-member denial, literal
wildcards, result caps and private-note masking. Its loopback guard refuses
diff --git a/apps/local-api/src/server.ts b/apps/local-api/src/server.ts
index afb86001..398a3d6e 100644
--- a/apps/local-api/src/server.ts
+++ b/apps/local-api/src/server.ts
@@ -210,30 +210,20 @@ function failed(key: string): void {
});
}
+// The same list the hosted app reads, with each commune's change mark.
async function listCommunes(user: User) {
return withActor(user.id, async (client) => {
- const { rows } = await client.query(`
- SELECT c.id, c.name, c.kind, c.currency, c.address,
- c.destination, c.stay_name, c.stay_address, m.role,
- (SELECT count(*)::integer FROM public.commune_members mm
- WHERE mm.commune_id = c.id AND mm.active) AS member_count
- FROM public.communes c
- JOIN public.commune_members m ON m.commune_id = c.id
- WHERE m.user_id = auth.user_id() AND m.active
- ORDER BY c.created_at DESC
- `);
- return { communes: rows.map((row) => ({
- id: row.id, name: row.name, kind: row.kind, currency: row.currency,
- address: row.address, destination: row.destination,
- stayName: row.stay_name, stayAddress: row.stay_address,
- role: row.role, memberCount: row.member_count,
- })) };
+ const { rows } = await client.query("SELECT public.my_communes() AS communes");
+ return { communes: rows[0].communes };
});
}
-async function snapshot(user: User, communeId: string) {
+// A summary is what the overview screens show of a commune that is not open.
+async function snapshot(user: User, communeId: string, summary = false) {
return withActor(user.id, async client => {
- const result = await client.query("SELECT CASE WHEN public.is_commune_member($1) THEN public.commune_snapshot($1) ELSE NULL END AS snapshot", [communeId]);
+ const result = await client.query(summary
+ ? "SELECT CASE WHEN public.is_commune_member($1) THEN public.commune_summary($1) ELSE NULL END AS snapshot"
+ : "SELECT CASE WHEN public.is_commune_member($1) THEN public.commune_snapshot($1) ELSE NULL END AS snapshot", [communeId]);
if (!result.rows[0].snapshot) throw new ApiError(404,"Commune not found");
return result.rows[0].snapshot;
});
@@ -404,7 +394,34 @@ route("PATCH", /^\/api\/communes\/([0-9a-f-]{36})$/, async (req, res, match) =>
});
route("GET", /^\/api\/communes\/([0-9a-f-]{36})\/snapshot$/, async (req, res, match) => {
- send(res, await snapshot(await actor(req), uuid(match[1])));
+ const detail = new URL(req.url ?? "", "http://127.0.0.1").searchParams.get("detail");
+ if (detail !== null && detail !== "summary") throw new ApiError(400, "Invalid detail");
+ send(res, await snapshot(await actor(req), uuid(match[1]), detail === "summary"));
+});
+
+// Expenses older than the snapshot holds: the given number of months before
+// `before` (the snapshot's own start when absent), or all of them when months
+// is absent, as a statement or an export needs.
+route("GET", /^\/api\/communes\/([0-9a-f-]{36})\/expenses\/history$/, async (req, res, match) => {
+ const params = new URL(req.url ?? "", "http://127.0.0.1").searchParams;
+ const before = isoDate(params.get("before"), true, "before");
+ const months = params.has("months") ? Number(params.get("months")) : null;
+ if (months !== null && (!Number.isInteger(months) || months < 1 || months > 12)) {
+ throw new ApiError(400, "Ask for 1 to 12 months of expenses at a time");
+ }
+ const result = await userAction(req, async client => (await client.query(
+ "SELECT public.commune_expense_page($1, $2::date, $3) AS result", [uuid(match[1]), before, months])).rows[0].result);
+ send(res, result);
+});
+
+// Named expenses, for a link to one that is not loaded.
+route("GET", /^\/api\/communes\/([0-9a-f-]{36})\/expenses\/records$/, async (req, res, match) => {
+ const ids = (new URL(req.url ?? "", "http://127.0.0.1").searchParams.get("ids") ?? "").split(",").filter(Boolean);
+ if (ids.length < 1 || ids.length > 50) throw new ApiError(400, "Ask for 1 to 50 expenses at a time");
+ const result = await userAction(req, async client => (await client.query(
+ "SELECT public.commune_expense_records($1, $2::uuid[]) AS result",
+ [uuid(match[1]), ids.map((id) => uuid(id, "ids"))])).rows[0].result);
+ send(res, result);
});
route("GET", /^\/api\/reminders\/usage$/, async (req, res) => {
diff --git a/apps/local-api/tests/lighter-loading.mjs b/apps/local-api/tests/lighter-loading.mjs
new file mode 100644
index 00000000..67681de2
--- /dev/null
+++ b/apps/local-api/tests/lighter-loading.mjs
@@ -0,0 +1,116 @@
+import assert from "node:assert/strict";
+import { randomUUID } from "node:crypto";
+
+// Change marks in the commune list, the overview summary, pages of older
+// expenses, named expense records and the limit on creating communes (0036).
+// Creates accounts and communes, so it refuses the normal application API.
+const base = process.env.API_BASE;
+if (!base) throw new Error("Set API_BASE to an isolated local test API, not the user's database.");
+const url = new URL(base);
+if (url.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(url.hostname) || !url.port ||
+ url.port === "3002" || url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
+ throw new Error("Lighter loading tests require an isolated loopback API port (not 3002).");
+}
+// Letters only, so a commune name that carries it never holds a phone-like run of digits.
+const suffix = randomUUID().slice(0, 8).replace(/[0-9]/g, (digit) => "ghijklmnop"[digit]);
+function session() {
+ let cookie = "";
+ return async (method, path, input) => {
+ const response = await fetch(`${base}/api${path}`, {
+ method,
+ headers: { ...(input === undefined ? {} : { "Content-Type": "application/json" }), ...(cookie ? { Cookie: cookie } : {}) },
+ body: input === undefined ? undefined : JSON.stringify(input),
+ });
+ const set = response.headers.get("set-cookie");
+ if (set) cookie = set.split(";")[0];
+ return { ...(await response.json()), status: response.status };
+ };
+}
+async function signup(call, name, slug) {
+ const result = await call("POST", "/auth/signup", { name, email: `${slug}-${suffix}@example.test`, password: "DisposableLoadingFixture-2026!" });
+ assert.equal(result.status, 201, JSON.stringify(result));
+ return result.user;
+}
+const owner = session(), member = session(), outsider = session(), maker = session();
+const avery = await signup(owner, "Avery Loading", "loading-owner");
+const blake = await signup(member, "Blake Loading", "loading-member");
+await signup(outsider, "Casey Loading", "loading-outsider");
+const home = await owner("POST", "/communes", { name: `Loading ${suffix}`, kind: "household", currency: "GBP" });
+assert.equal(home.status, 201, JSON.stringify(home));
+const invite = await owner("POST", `/communes/${home.id}/invites`, { email: blake.email, role: "member" });
+assert.equal((await member("POST", `/invites/${invite.token}/accept`, {})).status, 200);
+
+// The list carries each commune's mark, which stays put until something changes.
+async function listed(call) {
+ const list = await call("GET", "/communes");
+ assert.equal(list.status, 200, JSON.stringify(list));
+ return list.communes.find((commune) => commune.id === home.id);
+}
+const first = await listed(owner);
+assert.deepEqual(Object.keys(first).sort(), ["address", "changeMark", "currency", "destination", "id", "kind",
+ "memberCount", "name", "role", "stayAddress", "stayName"]);
+assert.deepEqual([first.name, first.role, first.memberCount], [`Loading ${suffix}`, "admin", 2]);
+assert.match(first.changeMark, /^\d{4}-\d{2}-\d{2}:\d+:\d+$/);
+assert.equal((await listed(owner)).changeMark, first.changeMark, "Reading moved the mark");
+assert.equal((await listed(member)).changeMark, first.changeMark, "Members see the same mark");
+assert.equal((await listed(outsider)), undefined, "Someone outside sees the commune");
+const snapshot = await owner("GET", `/communes/${home.id}/snapshot`);
+assert.equal(snapshot.changeMark, first.changeMark);
+const today = new Date();
+const since = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 1, 1)).toISOString().slice(0, 10);
+assert.deepEqual(snapshot.expenseHistory, { since, olderCount: 0, olderVoidedCount: 0 });
+
+// A write by anyone in the commune moves it.
+const expense = await member("POST", `/communes/${home.id}/expenses`, { title: "Milk", category: "Groceries", amountMinor: 300,
+ shares: [{ userId: avery.id, amountMinor: 150 }, { userId: blake.id, amountMinor: 150 }], requestId: randomUUID() });
+assert.equal(expense.status, 201, JSON.stringify(expense));
+const moved = (await listed(owner)).changeMark;
+assert.notEqual(moved, first.changeMark, "A new expense did not move the mark");
+
+// The overview summary: the same parts without the detail of the commune's own screens.
+const summary = await owner("GET", `/communes/${home.id}/snapshot?detail=summary`);
+assert.equal(summary.status, 200, JSON.stringify(summary));
+assert.equal(summary.detail, "summary");
+assert.equal(summary.changeMark, moved);
+assert.deepEqual(summary.expenses.map((item) => item.id), [expense.id]);
+assert.deepEqual(summary.expenses[0].shares.map((share) => share.amountMinor), [150, 150]);
+assert.equal((await owner("GET", `/communes/${home.id}/snapshot?detail=everything`)).status, 400);
+assert.equal((await outsider("GET", `/communes/${home.id}/snapshot?detail=summary`)).status, 404);
+
+// Older pages. Everything here is new, so there is nothing before the snapshot's start.
+const page = await member("GET", `/communes/${home.id}/expenses/history?months=1`);
+assert.equal(page.status, 200, JSON.stringify(page));
+assert.deepEqual([page.expenses, page.before, page.from, page.olderCount, page.olderVoidedCount], [[], since, null, 0, 0]);
+const all = await member("GET", `/communes/${home.id}/expenses/history`);
+assert.deepEqual([all.status, all.expenses, all.from], [200, [], null]);
+const earlier = await member("GET", `/communes/${home.id}/expenses/history?before=2026-01-15&months=2`);
+assert.deepEqual([earlier.status, earlier.before], [200, "2026-01-01"]);
+for (const query of ["months=0", "months=13", "months=one", "before=2026-02-30", "before=yesterday"]) {
+ assert.equal((await member("GET", `/communes/${home.id}/expenses/history?${query}`)).status, 400, query);
+}
+assert.equal((await outsider("GET", `/communes/${home.id}/expenses/history?months=1`)).status, 403);
+
+// Named records come in the snapshot's shape, and only from this commune.
+const fresh = await owner("GET", `/communes/${home.id}/snapshot`);
+const named = await owner("GET", `/communes/${home.id}/expenses/records?ids=${expense.id},${randomUUID()}`);
+assert.equal(named.status, 200, JSON.stringify(named));
+assert.deepEqual(named.expenses, fresh.expenses);
+const tooMany = Array.from({ length: 51 }, () => randomUUID()).join(",");
+for (const query of ["", "ids=", `ids=${tooMany}`, "ids=not-an-id"]) {
+ assert.equal((await owner("GET", `/communes/${home.id}/expenses/records?${query}`)).status, 400, query);
+}
+assert.equal((await outsider("GET", `/communes/${home.id}/expenses/records?ids=${expense.id}`)).status, 403);
+
+// Ten new communes in an hour is the most; the next one is refused calmly.
+await signup(maker, "Morgan Loading", "loading-maker");
+for (const letter of "abcdefghij") {
+ const made = await maker("POST", "/communes", { name: `Maker ${letter} ${suffix}`, kind: "household", currency: "GBP" });
+ assert.equal(made.status, 201, JSON.stringify(made));
+}
+const refused = await maker("POST", "/communes", { name: `Maker more ${suffix}`, kind: "household", currency: "GBP" });
+assert.equal(refused.status, 409, JSON.stringify(refused));
+assert.equal(refused.error, "You have created 10 communes in the last hour. Try again later.");
+assert.equal((await maker("GET", "/communes")).communes.length, 10);
+// Someone else can still create one.
+assert.equal((await outsider("POST", "/communes", { name: `Casey ${suffix}`, kind: "trip", currency: "EUR" })).status, 201);
+console.log("Lighter loading: marks, summary, older pages, named records and the creation limit behave as expected.");
diff --git a/apps/reminder-worker/tests/integration.mjs b/apps/reminder-worker/tests/integration.mjs
index 3c79c58b..c5000b52 100644
--- a/apps/reminder-worker/tests/integration.mjs
+++ b/apps/reminder-worker/tests/integration.mjs
@@ -91,6 +91,12 @@ async function runWorker() {
headers: { "x-neon-trigger-invocation-id": "local-test" },
}));
assert.equal(response.status, 200);
+ return response.json();
+}
+
+async function changeMark(communeId) {
+ const { rows } = await db.query("SELECT commune_private.change_mark($1) AS mark", [communeId]);
+ return rows[0].mark;
}
async function outbox(reminderId) {
@@ -428,8 +434,12 @@ try {
`, [commune, afterShare, alice, bob])).rows[0].id;
await db.query("INSERT INTO public.reminder_email_outbox (reminder_id) VALUES ($1)", [afterReminder]);
const callsBefore = providerCalls.length;
+ // What became of the email shows in the sender's reminders, so the run
+ // moves the commune's change mark (0036) and open apps read it again.
+ const markBefore = await changeMark(commune);
await runWorker();
assert.equal((await outbox(afterReminder)).status, "suppressed");
+ assert.notEqual(await changeMark(commune), markBefore, "A delivery outcome must move the commune's mark");
assert.equal(providerCalls.length, callsBefore);
// The sender gets the same answer as when an email went, so it never
// tells them the recipient turned email off.
@@ -449,6 +459,23 @@ try {
assert.equal((await outbox(third.reminderId)).payload_amount_minor, null);
assert.notEqual((await outbox(batchIds[0])).payload, null, "A recent email is kept");
+ // The same run drops change marks older than 2 days. A mark looks back to
+ // the start of yesterday at most, so recent ones stay.
+ const { rows: [oldChange] } = await db.query(`
+ INSERT INTO commune_private.commune_changes (commune_id, changed_at)
+ VALUES ($1, now() - interval '3 days') RETURNING id
+ `, [commune]);
+ const { rows: [recentChange] } = await db.query(`
+ INSERT INTO commune_private.commune_changes (commune_id, changed_at)
+ VALUES ($1, now() - interval '1 day') RETURNING id
+ `, [commune]);
+ const sweep = await runWorker();
+ assert.equal(sweep.pruned.changeMarks, 1);
+ const { rows: kept } = await db.query(
+ "SELECT id FROM commune_private.commune_changes WHERE id IN ($1, $2)", [oldChange.id, recentChange.id],
+ );
+ assert.deepEqual(kept.map((row) => row.id), [recentChange.id]);
+
// An address the provider reported as bouncing (or as a spam complaint) is
// skipped exactly like an opt-out, whatever the case of the account's
// address, so the sender cannot tell which happened.
diff --git a/apps/web/index.html b/apps/web/index.html
index 751d0d1e..9d441d98 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -11,12 +11,6 @@
name="description"
content="Hub OS for shared spaces. Track splits, settle balances, and keep house money clear."
/>
-
-
-
diff --git a/apps/web/src/data/covers.ts b/apps/web/src/data/covers.ts
new file mode 100644
index 00000000..50dcaed2
--- /dev/null
+++ b/apps/web/src/data/covers.ts
@@ -0,0 +1,65 @@
+import { HUBS, type Hub } from "./household";
+
+/**
+ * Commune covers. A photo someone chose in this browser is drawn as it is.
+ * The default covers are Unsplash photos, which Unsplash resizes on request,
+ * so each placement offers a few widths and says how wide it is drawn; the
+ * browser then fetches the one that suits the screen instead of the full
+ * photo. Every cover sits on a calm tone for the commune's type (the
+ * --cover-* tokens in shared.css), which shows while the photo loads and
+ * stays if it cannot load.
+ */
+export type CoverPlacement = "featured" | "card" | "hero" | "preview";
+export type CoverTone = "household" | "workspace" | "trip";
+export type CoverHub = Pick;
+export type CoverSource = { src: string; srcSet?: string; sizes?: string };
+
+const WIDTHS = [480, 800, 1200, 1600];
+
+// How wide each placement is drawn, in CSS pixels. The sidebar (248px) goes
+// away at 560px. featured: the first card on Home. card: the smaller cards on
+// Home and Places (three, then two, then one column). hero: the photo at the
+// top of This Hub (at most 1208px). preview: the commune settings preview
+// (240px beside the form, full width once the form drops below it at 1000px).
+const PLACEMENTS: Record = {
+ featured: { sizes: "(max-width: 560px) 100vw, calc(100vw - 248px)", fallback: 1200 },
+ card: { sizes: "(max-width: 560px) 100vw, (max-width: 850px) 50vw, 33vw", fallback: 800 },
+ hero: { sizes: "(max-width: 560px) 100vw, (max-width: 1500px) calc(100vw - 292px), 1208px", fallback: 1200 },
+ preview: { sizes: "(max-width: 560px) 100vw, (max-width: 1000px) 560px, 240px", fallback: 480 },
+};
+
+export function coverTone(hub: Pick): CoverTone {
+ return hub.type === "Workspace" ? "workspace" : hub.type === "Trip" ? "trip" : "household";
+}
+
+const DEFAULT_COVERS: Record = {
+ household: HUBS.park.photo,
+ workspace: HUBS.studio.photo,
+ trip: HUBS.lisbon.photo,
+};
+
+/** The image to draw for a commune's cover in one placement. */
+export function coverSource(hub: CoverHub, placement: CoverPlacement): CoverSource {
+ // A commune whose records are still loading has no photo yet; it gets the
+ // same default it will have once they arrive, so nothing is fetched twice.
+ const photo = hub.photo || DEFAULT_COVERS[coverTone(hub)];
+ const resize = unsplashResizer(photo);
+ if (!resize) return { src: photo };
+ const { sizes, fallback } = PLACEMENTS[placement];
+ return { src: resize(fallback), srcSet: WIDTHS.map((width) => `${resize(width)} ${width}w`).join(", "), sizes };
+}
+
+function unsplashResizer(photo: string): ((width: number) => string) | null {
+ let url: URL;
+ try {
+ url = new URL(photo);
+ } catch {
+ return null;
+ }
+ if (url.protocol !== "https:" || url.hostname !== "images.unsplash.com") return null;
+ return (width) => {
+ const sized = new URL(url);
+ sized.searchParams.set("w", String(width));
+ return sized.href;
+ };
+}
diff --git a/apps/web/src/data/household.ts b/apps/web/src/data/household.ts
index 92f7dbfa..bbe785af 100644
--- a/apps/web/src/data/household.ts
+++ b/apps/web/src/data/household.ts
@@ -757,12 +757,6 @@ export function initialsOf(name: string): string {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
-export function heroClass(hub: Hub): "park" | "studio" | "lisbon" {
- if (hub.id === "studio" || hub.type === "Workspace") return "studio";
- if (hub.id === "lisbon" || hub.type === "Trip") return "lisbon";
- return "park";
-}
-
export function buildCreatedHub(opts: {
name: string;
type: Hub["type"];
diff --git a/apps/web/src/legal/LegalPages.tsx b/apps/web/src/legal/LegalPages.tsx
index f07eb9fc..996d47ba 100644
--- a/apps/web/src/legal/LegalPages.tsx
+++ b/apps/web/src/legal/LegalPages.tsx
@@ -58,7 +58,7 @@ export function PrivacyPage() {
Photos you add, the payment details you keep for Pay me, and display settings such as dark mode are kept in your browser. They are not sent to Commune’s database or shown to other members.
- Neon stores Commune’s data and runs sign-in. Vercel hosts the app. Resend delivers invitation and reminder emails. Google provides the app’s Inter font and, if you choose it, Google sign-in. Unsplash supplies the default cover photos. When your browser loads the font or a cover photo, Google or Unsplash receives your IP address, as with any website.
+ Neon stores Commune’s data and runs sign-in. Vercel hosts the app. Resend delivers invitation and reminder emails. If you choose Google sign-in, Google handles it. Unsplash supplies the default cover photos. When your browser loads a cover photo, Unsplash receives your IP address, as with any website.
Commune records what people pay each other outside the app. It does not take payments, hold money or store card details.
diff --git a/apps/web/src/live/api.ts b/apps/web/src/live/api.ts
index 82d6f938..bb58b731 100644
--- a/apps/web/src/live/api.ts
+++ b/apps/web/src/live/api.ts
@@ -1,4 +1,4 @@
-import type { Commune, Snapshot, User } from "./model";
+import type { Commune, Expense, ExpensePage, Snapshot, User } from "./model";
import { currentViewer } from "./session";
// Pass already-verified identity and commune metadata through one refresh.
@@ -11,12 +11,28 @@ export async function loadCommunes(user: User): Promise<{ communes: Commune[] }>
return api("/communes");
}
-export async function loadSnapshot(commune: Commune): Promise {
+/** A summary is what the overview screens show of a commune that is not open. */
+export async function loadSnapshot(commune: Commune, detail?: "summary"): Promise {
if (import.meta.env.PROD) {
const { snapshot } = await import("./neon");
- return snapshot(commune.id, commune);
+ return snapshot(commune.id, commune, detail);
}
- return api(`/communes/${encodeURIComponent(commune.id)}/snapshot`);
+ return api(`/communes/${encodeURIComponent(commune.id)}/snapshot${detail ? "?detail=summary" : ""}`);
+}
+
+/**
+ * Expenses older than the snapshot holds, created in the `months` calendar
+ * months (UTC) before `before`, or all of them when months is left out.
+ * Without `before`, the page starts where the snapshot's expenses do.
+ */
+export function loadExpensePage(communeId: string, before: string | null, months?: number): Promise {
+ const query = new URLSearchParams({ ...(before ? { before } : {}), ...(months ? { months: String(months) } : {}) }).toString();
+ return api(`/communes/${encodeURIComponent(communeId)}/expenses/history${query ? `?${query}` : ""}`);
+}
+
+/** Named expenses of one commune, at most 50, for links to ones not loaded. */
+export function loadExpenseRecords(communeId: string, ids: string[]): Promise<{ expenses: Expense[] }> {
+ return api(`/communes/${encodeURIComponent(communeId)}/expenses/records?ids=${ids.map(encodeURIComponent).join(",")}`);
}
export class ApiError extends Error {
diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts
index 5419adf1..72f3f2d0 100644
--- a/apps/web/src/live/model.ts
+++ b/apps/web/src/live/model.ts
@@ -32,6 +32,8 @@ export type Commune = {
destination?: string;
stayName?: string;
stayAddress?: string;
+ /** Moves whenever anything in the commune changes; compared with a snapshot's own. */
+ changeMark?: string;
/** In a snapshot: what the database says keeps the currency as it is (0035). */
currencyLockedBy?: CurrencyLock[];
};
@@ -283,7 +285,18 @@ export type Snapshot = {
splitTemplates?: SplitTemplate[];
announcements?: Announcement[];
activityEvents?: Array<{id:string;actorId:string;kind:"funds"|"members"|"recurring"|"templates"|"notes"|"operations"|"expenses"|"announcements";entityId:string;title:string;description:string;createdAt:string}>;
+ /**
+ * The snapshot holds every expense created since `since`, every open one
+ * and the five newest that are not voided.
+ */
+ expenseHistory?: ExpenseHistory;
+ changeMark?: string;
+ /** An overview summary leaves out what only the commune's own screens show. */
+ detail?: "summary";
};
+export type ExpenseHistory = { since: string; olderCount: number; olderVoidedCount: number };
+/** Older expenses: those created from `from` up to `before`, and how many are older still. */
+export type ExpensePage = { expenses: Expense[]; before: string; from: string | null; olderCount: number; olderVoidedCount: number };
export type LiveExpenseRow = Omit & {
amountMinor: number;
paidByUserId: string;
@@ -391,6 +404,8 @@ export function hubFromSnapshot(
// Voided expenses stay in the ledger as history but never count towards a
// balance, a total or an outstanding share anywhere on the hub.
const snapshot: Snapshot = { ...source, expenses: source.expenses.filter((expense) => !expense.voidedAt) };
+ // Older settled expenses stay on the server until asked for; they still count as logged.
+ const olderLogged = (source.expenseHistory?.olderCount ?? 0) - (source.expenseHistory?.olderVoidedCount ?? 0);
const { commune } = snapshot;
const currency = commune.currency;
const names = new Map(snapshot.members.map((member) => [member.userId, member.name]));
@@ -545,7 +560,7 @@ export function hubFromSnapshot(
}))].sort((a,b)=>(Date.parse(b.createdAt ?? "")||0)-(Date.parse(a.createdAt ?? "")||0)),
notes,
jumps: [
- { go: "expenses", kicker: "Expenses", title: `${snapshot.expenses.length} logged`, hint: `${openRows.length} open` },
+ { go: "expenses", kicker: "Expenses", title: `${snapshot.expenses.length + olderLogged} logged`, hint: `${openRows.length} open` },
{ go: "settle", kicker: "Settle", title: moves.length ? oweLabel : "You’re even", hint: moves.length ? money(moneyFocus, currency) : "No outstanding shares involving you" },
{ go: "funds", kicker: "Funds", title: `${snapshot.funds.length} funds`, hint: `${money(fundTotal, currency)} recorded in` },
{ go: "notes", kicker: "Notes", title: `${notes.length} notes`, hint: "Shared information" },
diff --git a/apps/web/src/live/neon.ts b/apps/web/src/live/neon.ts
index f7b80c57..5cae7633 100644
--- a/apps/web/src/live/neon.ts
+++ b/apps/web/src/live/neon.ts
@@ -5,7 +5,7 @@ import { parseSearchResponse } from "./search";
import { ACCOUNT_CHANGED, currentViewer } from "./session";
import { readStored, removeStored } from "./storage";
import type {
- Commune, CurrencyCode, ReminderUsage, Snapshot, User,
+ Commune, CurrencyCode, Expense, ExpensePage, ReminderUsage, Snapshot, User,
} from "./model";
// This module is used only by production builds. The browser sees endpoint
@@ -165,12 +165,12 @@ function idResult(value: unknown): { id: string } {
if (typeof value !== "string" || !value) throw new ApiError("Database did not return an ID", 502);
return { id: value };
}
-function communal(row: Row, role: string, memberCount: number): Commune {
+function communal(row: Row): Commune {
return {
id: str(row.id), name: str(row.name), kind: row.kind as Commune["kind"],
- currency: row.currency as CurrencyCode, role: role as Commune["role"], memberCount,
+ currency: row.currency as CurrencyCode, role: row.role as Commune["role"], memberCount: minor(row.memberCount),
address: str(row.address), destination: str(row.destination),
- stayName: str(row.stay_name), stayAddress: str(row.stay_address),
+ stayName: str(row.stayName), stayAddress: str(row.stayAddress), changeMark: str(row.changeMark),
};
}
@@ -197,44 +197,63 @@ async function requireUser(): Promise {
return user;
}
+// One call returns the caller's communes, their role and member count in
+// each, and each commune's change mark, read together by the database.
export async function listCommunes(knownViewer?: User): Promise {
const viewer = await requireUser();
if (knownViewer && knownViewer.id !== viewer.id) throw new ApiError("Your account changed. Reload Commune.", 401);
- const [communeResult, memberResult] = await Promise.all([
- db().from("communes").select("id,name,kind,currency,address,destination,stay_name,stay_address,created_at").order("created_at", { ascending: false }),
- db().from("commune_members").select("commune_id,user_id,role").eq("active", true),
- ]);
- const communeData = unwrap(communeResult);
- const memberData = unwrap(memberResult);
- if (!Array.isArray(communeData) || !Array.isArray(memberData)) {
- throw new ApiError("Could not verify your commune list. Please try again.", 502);
- }
- const communeRows = rows(communeData);
- const memberRows = rows(memberData);
- if (memberRows.some((member) => member.user_id === viewer.id && !communeRows.some((row) => row.id === member.commune_id))) {
- throw new ApiError("Your commune membership changed while loading. Please try again.", 409);
- }
- return communeRows.map((row) => {
- const group = memberRows.filter((member) => member.commune_id === row.id);
- const membership = group.find((member) => member.user_id === viewer.id);
- if (!membership || (membership.role !== "admin" && membership.role !== "member")) {
+ const data = unwrap(await db().rpc("my_communes"));
+ if (!Array.isArray(data)) throw new ApiError("Could not verify your commune list. Please try again.", 502);
+ return rows(data).map((row) => {
+ if (typeof row.id !== "string" || !row.id || (row.role !== "admin" && row.role !== "member") ||
+ typeof row.changeMark !== "string") {
throw new ApiError("Could not verify your commune membership. Please try again.", 409);
}
- return communal(row, str(membership.role), group.length);
+ return communal(row);
});
}
-export async function snapshot(communeId: string, knownCommune?: Commune): Promise {
+export async function snapshot(communeId: string, knownCommune?: Commune, detail?: "summary"): Promise {
await requireUser();
if (knownCommune && knownCommune.id !== communeId) throw new ApiError("Commune mismatch", 400);
// The RPC re-checks membership against the verified JWT and reads one
// consistent database snapshot. No browser-stored identity is authority.
- const data = unwrap(await db().rpc("commune_snapshot", { p_commune_id: communeId })) as Snapshot;
- if (!data || data.commune?.id !== communeId || !Array.isArray(data.members) || !Array.isArray(data.expenses) || !Array.isArray(data.funds)) {
+ const data = unwrap(await db().rpc(detail === "summary" ? "commune_summary" : "commune_snapshot", { p_commune_id: communeId })) as Snapshot;
+ if (!data || data.commune?.id !== communeId || !Array.isArray(data.members) || !Array.isArray(data.expenses) || !Array.isArray(data.funds) ||
+ data.detail !== detail) {
throw new ApiError("Shared records could not be verified. Please try again.", 502);
}
return data;
}
+
+// Older expenses and named ones come in the snapshot's shape, so they are
+// checked the same way.
+function expenseList(value: unknown): { expenses: Expense[] } {
+ const item = asObject(value);
+ if (!Array.isArray(item.expenses) || item.expenses.some((expense) => typeof asObject(expense).id !== "string" ||
+ !Array.isArray(asObject(expense).shares))) {
+ throw new ApiError("Shared records could not be verified. Please try again.", 502);
+ }
+ return item as { expenses: Expense[] };
+}
+async function expensePage(communeId: string, params: URLSearchParams): Promise {
+ await requireUser();
+ const months = params.has("months") ? Number(params.get("months")) : null;
+ if (months !== null && (!Number.isInteger(months) || months < 1 || months > 12)) {
+ throw new ApiError("Ask for 1 to 12 months of expenses at a time", 400);
+ }
+ const page = unwrap(await db().rpc("commune_expense_page", { p_commune_id: communeId,
+ p_before: params.get("before") || null, p_months: months }));
+ const item = asObject(page);
+ return { ...expenseList(item), before: str(item.before), from: typeof item.from === "string" ? item.from : null,
+ olderCount: minor(item.olderCount), olderVoidedCount: minor(item.olderVoidedCount) };
+}
+async function expenseRecords(communeId: string, params: URLSearchParams): Promise<{ expenses: Expense[] }> {
+ await requireUser();
+ const ids = (params.get("ids") ?? "").split(",").filter(Boolean);
+ if (ids.length < 1 || ids.length > 50) throw new ApiError("Ask for 1 to 50 expenses at a time", 400);
+ return expenseList(unwrap(await db().rpc("commune_expense_records", { p_commune_id: communeId, p_ids: ids })));
+}
async function reminderUsage(): Promise {
await requireUser();
const records = rows(unwrap(await db().rpc("reminder_usage")));
@@ -462,8 +481,12 @@ async function route(path: string, init: RequestInit): Promise {
} else if (path === "/preferences" && method === "PUT") {
if (typeof body.emailReminders !== "boolean") throw new ApiError("Choose whether to get reminder emails", 400);
response = { emailReminders: unwrap(await db().rpc("set_email_reminders", { p_enabled: body.emailReminders })) === true };
- } else if (method === "GET" && pathId(path, /^\/communes\/([^/]+)\/snapshot$/)) {
- response = await snapshot(pathId(path, /^\/communes\/([^/]+)\/snapshot$/)!);
+ } else if (method === "GET" && pathId(path, /^\/communes\/([^/]+)\/snapshot(?:\?detail=summary)?$/)) {
+ response = await snapshot(pathId(path, /^\/communes\/([^/]+)\/snapshot/)!, undefined, path.endsWith("?detail=summary") ? "summary" : undefined);
+ } else if (method === "GET" && pathId(path, /^\/communes\/([^/]+)\/expenses\/history(?:\?|$)/)) {
+ response = await expensePage(pathId(path, /^\/communes\/([^/]+)\/expenses\/history/)!, new URL(path, "https://commune.invalid").searchParams);
+ } else if (method === "GET" && pathId(path, /^\/communes\/([^/]+)\/expenses\/records\?/)) {
+ response = await expenseRecords(pathId(path, /^\/communes\/([^/]+)\/expenses\/records/)!, new URL(path, "https://commune.invalid").searchParams);
} else if (method === "POST" && pathId(path, /^\/communes\/([^/]+)\/expenses$/)) {
const shares = Array.isArray(body.shares) ? body.shares.map((share) => {
const item = asObject(share);
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 1cffa68b..0411cf35 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -8,7 +8,7 @@ import "./styles/auth.css";
import "./styles/legal.css";
import "./styles/overrides.css";
import "./styles/design-system.css";
-import "@fontsource-variable/dm-sans/standard.css";
+import "./styles/fonts.css";
const root = document.getElementById("root");
if (!root) throw new Error("Missing #root");
diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx
index fd9da2d8..4ff51ffe 100644
--- a/apps/web/src/screens/Home.tsx
+++ b/apps/web/src/screens/Home.tsx
@@ -2,16 +2,14 @@ import type { ReactNode } from "react";
import { useApp } from "../state";
import { Avatar, Pill } from "../ui/widgets";
import { CalendarDate } from "../ui/CalendarDate";
-import { heroClass } from "../data/household";
import type { LiveHub } from "../live/model";
import { cx } from "../ui/cx";
+import { CoverImage, coverToneClass } from "../ui/CoverImage";
-function PhotoFace({ photo, focus, className, children }: { photo: string; focus?: boolean; className?: string; children: ReactNode }) {
+function PhotoFace({ hub, focus, children }: { hub: LiveHub; focus?: boolean; children: ReactNode }) {
return (
-
+
+
{children}
);
@@ -25,7 +23,7 @@ function PlaceCard({ hub, focus = false }: { hub: LiveHub; focus?: boolean }) {
const memberCount = ready ? hub.members.length : hub.memberCount;
return (
go("hub", { hub: hub.id })}>
-
+
{hub.type}{memberCount == null ? "" : ` · ${memberCount} ${memberCount === 1 ? "person" : "people"}`}
{focus ? {hub.name} : {hub.name} }
{(hub.type === "Trip" ? hub.destination : hub.address) ? {hub.type === "Trip" ? hub.destination : hub.address}
: null}
diff --git a/apps/web/src/screens/Hub.css b/apps/web/src/screens/Hub.css
index 78d400e2..68d469f2 100644
--- a/apps/web/src/screens/Hub.css
+++ b/apps/web/src/screens/Hub.css
@@ -43,7 +43,8 @@
.c-hub :is(button,summary) { -webkit-tap-highlight-color: transparent; }
.c-hub-identity { position: relative; isolation: isolate; min-height: 300px; padding: 80px 29px 27px; display: flex; align-items: flex-end; border-radius: var(--radius-surface); overflow: hidden; background: var(--hub-sage); }
-.c-hub-identity.has-photo { color: #fff; background: #1a1e2b; }
+/* The commune type's tone shows until the cover arrives, or if it cannot. */
+.c-hub-identity.has-photo { color: #fff; background: var(--cover-tone, #1a1e2b); }
.c-hub-cover { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center 60%; z-index: -2; }
.c-hub-identity.has-photo::after { content: ''; position: absolute; inset: 0; background: linear-gradient(0deg,#14251acc,transparent 90%); z-index: -1; }
.c-hub-identity-copy { min-width: 0; width: 100%; text-shadow: 0 1px 5px #0004; }
diff --git a/apps/web/src/screens/Hub.tsx b/apps/web/src/screens/Hub.tsx
index 18619dc3..7f4a16f8 100644
--- a/apps/web/src/screens/Hub.tsx
+++ b/apps/web/src/screens/Hub.tsx
@@ -4,6 +4,7 @@ import { useApp } from "../state";
import { Avatar } from "../ui/widgets";
import { Icon } from "../ui/Icon";
import { CalendarDate } from "../ui/CalendarDate";
+import { CoverImage, coverToneClass } from "../ui/CoverImage";
import { isNotePinned, noteCategory, notePreview } from "../data/notePresentation";
import { addDays, dateFromIso, todayIso } from "../data/operations";
import type { Hub, RouteId } from "../data/household";
@@ -175,8 +176,8 @@ export function HubScreen() {
const attentionCount = outstanding.length + yours.length + yourPledges.length + toConfirm.length;
const canInvite = hub.role === "admin";
return
-
- {hub.photo && }
+
+ {hub.photo && }
{hub.type}
{hub.name}
diff --git a/apps/web/src/screens/Money.tsx b/apps/web/src/screens/Money.tsx
index e46e2b33..d0572260 100644
--- a/apps/web/src/screens/Money.tsx
+++ b/apps/web/src/screens/Money.tsx
@@ -18,14 +18,44 @@ function formatMoney(minor: number, currency: string): string {
}).format(minor / 100);
}
+// Months start at midnight UTC, as the shared records count them.
+function monthName(month: string): string {
+ const date = new Date(`${month}T00:00:00Z`);
+ return new Intl.DateTimeFormat("en-GB", { month: "long", timeZone: "UTC",
+ ...(date.getUTCFullYear() === new Date().getUTCFullYear() ? {} : { year: "numeric" }) }).format(date);
+}
+
export function ExpensesScreen() {
- const { go, expenses, voidedExpenses, session, hub } = useApp();
+ const { go, expenses, voidedExpenses, session, hub, expenseHistory, loadEarlierExpenses } = useApp();
const [searchQuery, setSearchQuery] = useState("");
const [filter, setFilter] = useState<"All" | "Open" | "Settled" | "Voided">("All");
+ // Open expenses always show. Settled and voided ones show from the start of
+ // the latest month (UTC) with a settled one loaded, or of this month, and
+ // Show earlier reaches back a month at a time.
+ const [shownFrom, setShownFrom] = useState(() => {
+ const newest = Math.max(...expenses.filter((row) => !row.open).map((row) => Date.parse(row.createdAt ?? "")).filter(Number.isFinite));
+ return `${new Date(Number.isFinite(newest) ? newest : Date.now()).toISOString().slice(0, 7)}-01`;
+ });
+ const [earlier, setEarlier] = useState<"" | "loading" | "error">("");
+ const shown = (row: (typeof expenses)[number]) =>
+ row.open || !row.createdAt || Date.parse(row.createdAt) >= Date.parse(`${shownFrom}T00:00:00Z`);
const needle = searchQuery.trim().toLocaleLowerCase();
// Voided expenses are history only: never in All, Open, Settled or a total.
- const showVoided = filter === "Voided" && voidedExpenses.length > 0;
- const rows = (showVoided ? voidedExpenses : expenses)
+ const showVoided = filter === "Voided";
+ const pool = showVoided ? voidedExpenses : expenses;
+ const more = filter !== "Open" && (pool.some((row) => !shown(row)) ||
+ (showVoided ? expenseHistory.olderVoidedCount : expenseHistory.olderCount - expenseHistory.olderVoidedCount) > 0);
+ async function showEarlier() {
+ setEarlier("loading");
+ try {
+ const month = await loadEarlierExpenses(shownFrom, showVoided);
+ if (month) setShownFrom(month);
+ setEarlier("");
+ } catch {
+ setEarlier("error");
+ }
+ }
+ const rows = [...pool.filter((row) => row.open), ...pool.filter((row) => !row.open && shown(row))]
.filter((row) => {
if (filter === "All") return true;
if (filter === "Open") return row.open === true;
@@ -44,7 +74,7 @@ export function ExpensesScreen() {
<>
- {(voidedExpenses.length ? ["All", "Open", "Settled", "Voided"] as const : ["All", "Open", "Settled"] as const).map((chip) => (
+ {(voidedExpenses.length || expenseHistory.olderVoidedCount ? ["All", "Open", "Settled", "Voided"] as const : ["All", "Open", "Settled"] as const).map((chip) => (
)}
{!rows.length && No expenses match this view. Try another filter or search.
}
+ {more ?
+ {earlier === "error" ?
Earlier expenses couldn’t be loaded. Try again.
+ :
{earlier === "loading" ? "Loading earlier expenses…"
+ : `${showVoided ? "Voided" : "Settled"} expenses before ${monthName(shownFrom)} aren’t shown yet.`}
}
+
void showEarlier()}>Show earlier
+
: null}
>
);
}
diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx
index 13810823..8d86bcff 100644
--- a/apps/web/src/screens/Settings.tsx
+++ b/apps/web/src/screens/Settings.tsx
@@ -3,6 +3,7 @@ import { Link } from "wouter";
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 { cx } from "../ui/cx";
import { LIMITS } from "../data/limits";
@@ -13,6 +14,7 @@ import { SignInSettings } from "../ui/SignInSettings";
import { communeExportJson, downloadText, expensesCsv } from "../data/export";
import { todayIso } from "../data/operations";
import { currencyHint, currencyLockedBy } from "../data/currency";
+import type { Snapshot } from "../live/model";
export function SettingsScreen() {
const {
@@ -226,7 +228,7 @@ export function SettingsScreen() {
export function CommuneSettingsScreen() {
const {
- hub, session, go, setHubPhoto, updateCommune, snapshots,
+ hub, session, go, setHubPhoto, updateCommune, snapshots, loadFullHistory,
} = useApp();
const [name, setName] = useState(hub.name);
const [type, setType] = useState(hub.type);
@@ -240,6 +242,7 @@ export function CommuneSettingsScreen() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [saved, setSaved] = useState(false);
+ const [exporting, setExporting] = useState<"" | "loading" | "error">("");
// The database's rule, as its snapshot reports it.
const currencyLocks = currencyLockedBy(snapshots[hub.id]);
const canEdit = hub.role === "admin";
@@ -270,6 +273,18 @@ export function CommuneSettingsScreen() {
}
}
+ // A file holds every expense, so the older ones are loaded first.
+ async function download(save: (ledger: Snapshot) => void) {
+ setExporting("loading");
+ try {
+ const ledger = await loadFullHistory();
+ if (ledger) save(ledger);
+ setExporting("");
+ } catch {
+ setExporting("error");
+ }
+ }
+
if (!hub.id) {
return
No commune selected. go("commune-new")}>Create a commune ;
}
@@ -278,7 +293,9 @@ export function CommuneSettingsScreen() {
-
+
+
+
{hub.name}
{hub.type} · {hub.members.length} {hub.members.length === 1 ? "person" : "people"}
Records
- {
- const snapshot = snapshots[hub.id];
- if (snapshot) downloadText(`${hub.name} expenses ${todayIso()}.csv`, expensesCsv(snapshot), "text/csv;charset=utf-8");
- }}>
+ void download((ledger) =>
+ downloadText(`${hub.name} expenses ${todayIso()}.csv`, expensesCsv(ledger), "text/csv;charset=utf-8"))}>
Download expenses A spreadsheet with one row per share: amounts, payments received and what is outstanding.
- {
- const snapshot = snapshots[hub.id];
- if (snapshot) downloadText(`${hub.name} records ${todayIso()}.json`, communeExportJson(snapshot), "application/json");
- }}>
+ void download((ledger) =>
+ downloadText(`${hub.name} records ${todayIso()}.json`, communeExportJson(ledger), "application/json"))}>
Download all records Everything you can see in this commune, as a file. Passwords and door codes in notes are left out.
+ {exporting === "loading" ? Loading earlier expenses for the file…
: null}
+ {exporting === "error" ? Earlier expenses couldn’t be loaded, so no file was made. Try again.
: null}
Files are made on this device from what you can already see. Nothing is sent anywhere.
diff --git a/apps/web/src/state.tsx b/apps/web/src/state.tsx
index 8a85e609..3e7a7a27 100644
--- a/apps/web/src/state.tsx
+++ b/apps/web/src/state.tsx
@@ -34,7 +34,7 @@ import {
import { isStoredImage, readHubPhotos } from "./data/images";
import { newRequestId } from "./data/requestId";
import { isNativeNow, keepSearch, pathOnly } from "./nav";
-import { api, post, loadCommunes, loadSnapshot, ApiError } from "./live/api";
+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 {
@@ -43,6 +43,7 @@ import {
money,
type Commune,
type CurrencyCode,
+ type Expense,
type Fund,
type LiveExpenseRow,
type LiveHub,
@@ -201,6 +202,30 @@ type LiveData = {
snapshots: Record
;
invites: Record;
reminderUsage: ReminderUsage | null;
+ older: Record;
+};
+/**
+ * Expenses older than a commune's snapshot holds, fetched when someone asks
+ * for earlier months, a statement, an export or a link to one. They are kept
+ * when the snapshot is read again, and the snapshot's copy of an expense wins.
+ */
+type OlderExpenses = {
+ /** The snapshot's window these continue. When it moves on, they are dropped. */
+ since: string;
+ /** Every expense created from this date (UTC) on is loaded; null once all are. */
+ from: string | null;
+ olderCount: number;
+ olderVoidedCount: number;
+ expenses: Record;
+};
+/** How far back the open commune's expenses are loaded. */
+export type ExpenseHistoryState = {
+ /** Every expense created from this date (UTC) on is loaded. */
+ loadedFrom: string | null;
+ complete: boolean;
+ /** Expenses older than what is loaded, and how many of them are voided. */
+ olderCount: number;
+ olderVoidedCount: number;
};
type AuthStatus = "loading" | "guest" | "authenticated" | "error";
@@ -251,6 +276,21 @@ export type AppState = {
createdExpenses: LiveExpenseRow[];
/** Kept for history. Never part of a balance, total or open count. */
voidedExpenses: LiveExpenseRow[];
+ /**
+ * The open expense window's expense is older than what is loaded: it is
+ * being fetched, or fetching it failed and can be tried again.
+ */
+ expenseRead: "loading" | "failed" | null;
+ retryExpenseRead: () => void;
+ expenseHistory: ExpenseHistoryState;
+ /**
+ * The month (UTC) to show settled expenses from, or voided ones, going back
+ * from `before`: the latest earlier month that has one, loading older
+ * expenses as needed. Null when there are none before it.
+ */
+ loadEarlierExpenses: (before: string, voided?: boolean) => Promise;
+ /** Loads every older expense, for a statement or an export, and returns the whole record. */
+ loadFullHistory: () => Promise;
funds: Fund[];
recurringBills: RecurringBill[];
splitTemplates: SplitTemplate[];
@@ -478,6 +518,33 @@ function operationPayload(item: NewOperation | OperationItem) {
};
}
+// The older expenses kept for this snapshot's window, if any.
+function olderFor(snapshot: Snapshot, older: OlderExpenses | undefined): OlderExpenses | undefined {
+ return older && older.since === snapshot.expenseHistory?.since ? older : undefined;
+}
+
+function historyState(snapshot: Snapshot | undefined, older: OlderExpenses | undefined): ExpenseHistoryState {
+ const history = snapshot?.expenseHistory;
+ // A snapshot that does not say holds every expense.
+ if (!snapshot || !history) return { loadedFrom: null, complete: true, olderCount: 0, olderVoidedCount: 0 };
+ const kept = olderFor(snapshot, older);
+ return kept
+ ? { loadedFrom: kept.from, complete: kept.from === null || kept.olderCount === 0, olderCount: kept.olderCount, olderVoidedCount: kept.olderVoidedCount }
+ : { loadedFrom: history.since, complete: history.olderCount === 0, olderCount: history.olderCount, olderVoidedCount: history.olderVoidedCount };
+}
+
+// The snapshot's expenses and the older ones fetched for it, newest first as
+// the database orders them. The snapshot's copy of an expense is the latest.
+function withOlderExpenses(snapshot: Snapshot, older: OlderExpenses | undefined): Snapshot {
+ const kept = olderFor(snapshot, older);
+ if (!kept) return snapshot;
+ const held = new Set(snapshot.expenses.map((expense) => expense.id));
+ const extra = Object.values(kept.expenses).filter((expense) => !held.has(expense.id));
+ if (!extra.length) return snapshot;
+ return { ...snapshot, expenses: [...snapshot.expenses, ...extra].sort((a, b) =>
+ (Date.parse(b.createdAt ?? "") || 0) - (Date.parse(a.createdAt ?? "") || 0) || b.id.localeCompare(a.id)) };
+}
+
export function AppProvider({ children }: { children: ReactNode }) {
const [location, setLocation] = useLocation();
const screen: ScreenId = PATH_SCREEN[pathOnly(location)] ?? "home";
@@ -515,23 +582,33 @@ export function AppProvider({ children }: { children: ReactNode }) {
// it was not saved if the tab is then signed out.
const refusedAction = useRef(false);
const lastRefreshAt = useRef(0);
+ // Someone used this tab (pointer, keys, touch). The minute check stops
+ // after ten quiet minutes and starts again with the next use.
+ const lastActivityAt = useRef(Date.now());
const snapshotReads = useRef(new Map>());
- const snapshotTimes = useRef(new Map());
const snapshotRevisions = useRef(new Map());
+ const olderReads = useRef(new Map>());
+ // Linked expenses being fetched, and the ones that were refused or failed,
+ // by account, commune and expense.
+ const recordReads = useRef(new Set());
+ const [recordResults, setRecordResults] = useState>>({});
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 native = isNativeNow();
- const readSnapshot = useCallback((commune: Commune, sequence: number, force = false): Promise => {
+ const readSnapshot = useCallback((commune: Commune, sequence: number, force = false, detail?: "summary"): Promise => {
const current = dataRef.current;
if (!current || sequence !== refreshSequence.current) return Promise.resolve();
const cacheKey = `${current.user.id}:${commune.id}`;
const revision = snapshotRevisions.current.get(cacheKey) ?? 0;
- const key = `${sequence}:${commune.id}:${revision}`;
+ const key = `${sequence}:${commune.id}:${revision}:${detail ?? "full"}`;
const pending = snapshotReads.current.get(key);
if (pending) return pending;
- if (!force && current.snapshots[commune.id] && Date.now() - (snapshotTimes.current.get(cacheKey) ?? 0) < 15_000) {
+ // The list's change mark says whether anything changed since the copy held
+ // here was read. A summary will do only where one was asked for.
+ const held = current.snapshots[commune.id];
+ if (!force && held && held.changeMark === commune.changeMark && (detail === "summary" || held.detail !== "summary")) {
return Promise.resolve();
}
setSnapshotErrors((errors) => {
@@ -539,10 +616,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
delete next[commune.id];
return next;
});
- const request = loadSnapshot(commune).then((snapshot) => {
+ const request = (detail ? loadSnapshot(commune, detail) : loadSnapshot(commune)).then((snapshot) => {
if (sequence !== refreshSequence.current || dataRef.current?.user.id !== current.user.id || revision !== (snapshotRevisions.current.get(cacheKey) ?? 0)) return;
if (snapshot.commune.id !== commune.id) throw new ApiError("Commune response did not match the request", 502);
- snapshotTimes.current.set(cacheKey, Date.now());
+ // A summary never replaces the open commune's record; its full read is under way.
+ if (snapshot.detail === "summary" && viewRef.current.hubId === commune.id && dataRef.current.snapshots[commune.id]) return;
publishData({ ...dataRef.current, snapshots: { ...dataRef.current.snapshots, [commune.id]: snapshot } });
}).catch((error: unknown) => {
if (sequence !== refreshSequence.current || dataRef.current?.user.id !== current.user.id || revision !== (snapshotRevisions.current.get(cacheKey) ?? 0)) return;
@@ -552,7 +630,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
if (error instanceof ApiError && [401, 403, 404].includes(error.status)) {
const snapshots = { ...dataRef.current.snapshots };
delete snapshots[commune.id];
- snapshotTimes.current.delete(cacheKey);
publishData({ ...dataRef.current, snapshots });
}
}).finally(() => snapshotReads.current.delete(key));
@@ -569,11 +646,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
if (!active) return;
await readSnapshot(active, sequence, force);
// Overview cards appear immediately from verified membership metadata.
- // Fill the remaining cards progressively instead of bursting N full
- // commune reads into the selected page's critical path.
+ // Fill the remaining cards progressively, from summaries, instead of
+ // bursting N full commune reads into the selected page's critical path.
for (const commune of current.communes) {
if (sequence !== refreshSequence.current || !viewRef.current.overview) return;
- if (commune.id !== active.id) await readSnapshot(commune, sequence, force);
+ if (commune.id !== active.id) await readSnapshot(commune, sequence, force, "summary");
}
}, [readSnapshot]);
@@ -587,7 +664,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
publishData(null);
setViewer(null);
setSnapshotErrors({});
- snapshotTimes.current.clear();
snapshotRevisions.current.clear();
setHubId("");
setWindowId(null);
@@ -598,9 +674,14 @@ export function AppProvider({ children }: { children: ReactNode }) {
loadedRef.current = true;
}, [publishData]);
+ // A full refresh (start-up, a sign-in, Try again, a membership change made
+ // here) asks who is signed in and reads everything on screen again. A check
+ // reads the commune list, whose change marks say which communes changed, and
+ // reads only those again, with their invitations and the reminder counts.
+ // Coming back to the tab, a check also asks who is signed in.
// announce: whether a sign-in this finds may be told to the other tabs.
// Only a load this tab started itself may tell them (see the listener).
- const load = useCallback(async (announce: boolean) => {
+ const load = useCallback(async (announce: boolean, check?: { session: boolean }) => {
const sequence = ++refreshSequence.current;
const shownBefore = currentViewer();
refreshing.current = true;
@@ -618,76 +699,97 @@ export function AppProvider({ children }: { children: ReactNode }) {
}
}
try {
- const result = await startupRead(() => api<{ user: User | null }>("/auth/me"));
- if (sequence !== refreshSequence.current) return;
- const shown = currentViewer();
- if (!result.user || (shown && shown !== result.user.id)) {
- // Signed out, or signed in as someone else in another tab: never
- // switch this screen to another person's data without a sign-in.
- forgetSession(shown ? endedNotice(refusedAction.current) : null);
+ let user = check && !check.session ? dataRef.current?.user ?? null : null;
+ if (!user) {
+ const result = await startupRead(() => api<{ user: User | null }>("/auth/me"));
+ if (sequence !== refreshSequence.current) return;
+ const shown = currentViewer();
+ if (!result.user || (shown && shown !== result.user.id)) {
+ // Signed out, or signed in as someone else in another tab: never
+ // switch this screen to another person's data without a sign-in.
+ forgetSession(shown ? endedNotice(refusedAction.current) : null);
+ refusedAction.current = false;
+ setApiError(null);
+ return;
+ }
refusedAction.current = false;
- setApiError(null);
- return;
+ setViewer(result.user.id);
+ // A tab that showed no one now shows this account: a sign-in, however
+ // it happened (password, code, or the return from Google), or a new
+ // tab opening. Tell the other tabs.
+ if (announce && !shown) announceSession(result.user.id);
+ setSignOutUnconfirmed(false);
+ setSessionNotice(null);
+ user = result.user;
}
- refusedAction.current = false;
- setViewer(result.user.id);
- // A tab that showed no one now shows this account: a sign-in, however
- // it happened (password, code, or the return from Google), or a new
- // tab opening. Tell the other tabs.
- if (announce && !shown) announceSession(result.user.id);
- setSignOutUnconfirmed(false);
- setSessionNotice(null);
- const { communes } = await startupRead(() => loadCommunes(result.user!));
+ const viewer = user;
+ const { communes } = await startupRead(() => loadCommunes(viewer));
if (sequence !== refreshSequence.current) return;
- const previous = dataRef.current?.user.id === result.user.id ? dataRef.current : null;
+ const previous = dataRef.current?.user.id === viewer.id ? dataRef.current : null;
if (!previous) {
setSnapshotErrors({});
- snapshotTimes.current.clear();
snapshotRevisions.current.clear();
}
+ // A commune changed if its mark moved, your role in it changed or you just joined it.
+ const seen = new Map((previous?.communes ?? []).map((commune) => [commune.id, commune]));
+ const changed = communes.filter((commune) => seen.get(commune.id)?.changeMark !== commune.changeMark ||
+ seen.get(commune.id)?.role !== commune.role);
+ const listed = (id: string) => communes.some((commune) => commune.id === id);
+ const admins = communes.filter((commune) => commune.role === "admin");
+ const invites = Object.fromEntries(Object.entries(previous?.invites ?? {})
+ .filter(([id]) => admins.some((commune) => commune.id === id)));
// Identity and membership are ready. Keep existing content on refresh;
// missing snapshots render a loading state, never a false empty ledger.
publishData({
- user: result.user, communes,
+ user: viewer, communes,
snapshots: Object.fromEntries(communes.filter((commune) => previous?.snapshots[commune.id])
.map((commune) => [commune.id, previous!.snapshots[commune.id]])),
- invites: previous?.invites ?? {}, reminderUsage: previous?.reminderUsage ?? null,
+ invites, reminderUsage: previous?.reminderUsage ?? null,
+ older: Object.fromEntries(Object.entries(previous?.older ?? {}).filter(([id]) => listed(id))),
});
setAuthStatus("authenticated");
setHubId((current) => communes.some((commune) => commune.id === current)
- ? current : communes.find((commune) => commune.id === readPrefs(result.user!.id).activeHubId)?.id ?? communes[0]?.id ?? "");
- if (!previous) setPrefs(readPrefs(result.user.id));
+ ? current : communes.find((commune) => commune.id === readPrefs(viewer.id).activeHubId)?.id ?? communes[0]?.id ?? "");
+ if (!previous) setPrefs(readPrefs(viewer.id));
loadedRef.current = true;
- // Counts and invitations must not delay the commune itself.
- void (
- // A pending database rollout must not make the rest of the app unusable.
- // The send RPC still enforces limits even if this display is unavailable.
- api("/reminders/usage").catch((error: unknown) => {
- console.warn("Reminder usage could not be loaded", error instanceof Error ? error.message : "unknown error");
- return null;
- })
- ).then((reminderUsage) => {
- if (sequence !== refreshSequence.current || !dataRef.current) return;
- publishData({ ...dataRef.current, reminderUsage });
- });
- void Promise.all(communes.filter((commune) => commune.role === "admin")
- .map(async (commune) => {
- const result = await api<{ invites: InviteRecord[] }>(`/communes/${encodeURIComponent(commune.id)}/invites`);
- return [commune.id, result.invites.map((invite) => ({
- id: invite.id, email: invite.email ?? null,
- role: invite.role === "admin" ? "Admin" as const : "Member" as const,
- status: inviteStatus(invite), createdAt: invite.createdAt, expiresAt: invite.expiresAt,
- acceptedAt: invite.acceptedAt, revokedAt: invite.revokedAt,
- invitedBy: invite.invitedBy ?? null, invitedByName: invite.invitedByName ?? null,
- acceptedBy: invite.acceptedBy ?? null,
- }))] as const;
- })).then((inviteEntries) => {
- if (sequence !== refreshSequence.current || !dataRef.current) return;
- publishData({ ...dataRef.current, invites: Object.fromEntries(inviteEntries) });
- }).catch((error: unknown) => {
- if (sequence === refreshSequence.current) setApiError(error instanceof Error ? error.message : "Could not refresh invitations");
- });
- await loadVisibleSnapshots(sequence, true);
+ // Counts and invitations must not delay the commune itself. A check
+ // reads the counts when something changed or a reminder slot has opened.
+ const usage = previous?.reminderUsage;
+ if (!check || changed.length || communes.length !== seen.size || !usage ||
+ (usage.nextSlotAt && Date.parse(usage.nextSlotAt) <= Date.now())) {
+ void (
+ // A pending database rollout must not make the rest of the app unusable.
+ // The send RPC still enforces limits even if this display is unavailable.
+ api("/reminders/usage").catch((error: unknown) => {
+ console.warn("Reminder usage could not be loaded", error instanceof Error ? error.message : "unknown error");
+ return null;
+ })
+ ).then((reminderUsage) => {
+ if (sequence !== refreshSequence.current || !dataRef.current) return;
+ publishData({ ...dataRef.current, reminderUsage });
+ });
+ }
+ // An invitation sent, accepted or withdrawn moves its commune's mark.
+ const inviteReads = admins.filter((commune) => !check || changed.includes(commune) || !invites[commune.id]);
+ if (inviteReads.length) {
+ void Promise.all(inviteReads.map(async (commune) => {
+ const result = await api<{ invites: InviteRecord[] }>(`/communes/${encodeURIComponent(commune.id)}/invites`);
+ return [commune.id, result.invites.map((invite) => ({
+ id: invite.id, email: invite.email ?? null,
+ role: invite.role === "admin" ? "Admin" as const : "Member" as const,
+ status: inviteStatus(invite), createdAt: invite.createdAt, expiresAt: invite.expiresAt,
+ acceptedAt: invite.acceptedAt, revokedAt: invite.revokedAt,
+ invitedBy: invite.invitedBy ?? null, invitedByName: invite.invitedByName ?? null,
+ acceptedBy: invite.acceptedBy ?? null,
+ }))] as const;
+ })).then((inviteEntries) => {
+ if (sequence !== refreshSequence.current || !dataRef.current) return;
+ publishData({ ...dataRef.current, invites: { ...dataRef.current.invites, ...Object.fromEntries(inviteEntries) } });
+ }).catch((error: unknown) => {
+ if (sequence === refreshSequence.current) setApiError(error instanceof Error ? error.message : "Could not refresh invitations");
+ });
+ }
+ await loadVisibleSnapshots(sequence, !check);
if (sequence !== refreshSequence.current) return;
lastRefreshAt.current = Date.now();
} catch (error) {
@@ -707,7 +809,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
if (sequence === refreshSequence.current) refreshing.current = false;
}
}, [forgetSession, loadVisibleSnapshots, publishData]);
- const refresh = useCallback(() => load(true), [load]);
+ const refresh = useCallback((check?: { session: boolean }) => load(true, check), [load]);
// Another tab signed in or out. A tab showing an account that is no longer
// the signed-in one forgets it (its screens then ask for a sign-in); a
@@ -729,21 +831,41 @@ export function AppProvider({ children }: { children: ReactNode }) {
if (authStatus === "authenticated") void loadVisibleSnapshots(refreshSequence.current);
}, [authStatus, hubId, overview, loadVisibleSnapshots]);
+ // Once a minute, while the tab is visible and someone has used it in the
+ // last ten minutes, check the commune list for changes. Coming back to the
+ // tab checks at once and asks who is signed in; using it again after a
+ // quiet spell checks at once.
useEffect(() => {
if (authStatus !== "authenticated") return;
+ const quiet = () => Date.now() - lastActivityAt.current >= 10 * 60_000;
+ const due = (gap: number) => !refreshing.current && Date.now() - lastRefreshAt.current > gap;
const onFocus = () => {
- if (document.visibilityState === "visible" && !refreshing.current && Date.now() - lastRefreshAt.current > 15_000) void refresh();
+ if (document.visibilityState !== "visible") return;
+ lastActivityAt.current = Date.now();
+ if (due(15_000)) void refresh({ session: true });
};
- const onVisible = onFocus;
+ const onActivity = () => {
+ const returning = quiet();
+ lastActivityAt.current = Date.now();
+ if (returning && document.visibilityState === "visible" && due(60_000)) void refresh({ session: false });
+ };
+ const onMinute = () => {
+ if (document.visibilityState === "visible" && !quiet() && due(15_000)) void refresh({ session: false });
+ };
+ const activity = ["pointerdown", "pointermove", "keydown", "touchstart", "wheel"];
window.addEventListener("focus", onFocus);
- document.addEventListener("visibilitychange", onVisible);
- const timer = window.setInterval(onFocus, 60_000);
+ document.addEventListener("visibilitychange", onFocus);
+ for (const name of activity) window.addEventListener(name, onActivity, { capture: true, passive: true });
+ const timer = window.setInterval(onMinute, 60_000);
return () => {
window.removeEventListener("focus", onFocus);
- document.removeEventListener("visibilitychange", onVisible);
+ document.removeEventListener("visibilitychange", onFocus);
+ for (const name of activity) window.removeEventListener(name, onActivity, { capture: true });
window.clearInterval(timer);
};
}, [authStatus, refresh]);
+ // Screens only ever ask for a full refresh, even when they pass refresh as a handler.
+ const fullRefresh = useCallback(() => refresh(), [refresh]);
const mutate = useCallback(async (action: () => Promise): Promise => {
setMutationCount(count => count + 1);
@@ -827,8 +949,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
email: data.user.email,
emailVerified: data.user.emailVerified === true,
hasHubs: data.communes.length > 0,
- emptyLedger: data.communes.every((commune) =>
- (data.snapshots[commune.id]?.expenses.length ?? 0) === 0),
+ emptyLedger: data.communes.every((commune) => (data.snapshots[commune.id]?.expenses.length ?? 0) +
+ (data.snapshots[commune.id]?.expenseHistory?.olderCount ?? 0) === 0),
seed: "new",
hubPhotos: prefs.hubPhotos,
avatar: prefs.avatar,
@@ -848,18 +970,29 @@ export function AppProvider({ children }: { children: ReactNode }) {
}) : [];
const hub = places.find((place) => place.id === hubId) ?? places[0] ?? emptyHub();
const activeId = hub.id;
- const snapshot = data?.snapshots[activeId];
+ 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;
+ const older = data?.older[activeId];
+ const expenseHistory = historyState(snapshot, older);
const contentLoading = !!data?.communes.length && !overview && !snapshot;
const contentError = snapshotErrors[activeId] ?? null;
- const allExpenses = snapshot && data ? expenseRows(snapshot, data.user.id) : [];
+ const allExpenses = snapshot && data ? expenseRows(withOlderExpenses(snapshot, older), data.user.id) : [];
const expenses = allExpenses.filter((row) => !row.voidedAt);
const voidedExpenses = allExpenses.filter((row) => row.voidedAt);
const selectedExpense = selectedExpenseId ? allExpenses.find((row) => row.id === selectedExpenseId) ?? null : expenses[0] ?? null;
const lastExpense = selectedExpense
? { name: selectedExpense.name, amount: selectedExpense.amount, paidBy: selectedExpense.paidBy }
: null;
- // A commune whose only expenses are voided still has a ledger to show.
- const empty = !!session?.hasHubs && allExpenses.length === 0;
+ // A commune whose only expenses are voided, or older than the snapshot
+ // holds, still has a ledger to show.
+ const empty = !!session?.hasHubs && allExpenses.length === 0 && !snapshot?.expenseHistory?.olderCount;
+ const selectedLoaded = !selectedExpenseId || allExpenses.some((row) => row.id === selectedExpenseId);
+ const recordKey = `${data?.user.id ?? ""}:${activeId}:${selectedExpenseId}`;
+ const recordResult = recordResults[recordKey];
+ // Until it is fetched, a linked expense older than what is loaded is on its way.
+ const expenseRead = (windowId === "expense" || windowId === "expense-paid") && !selectedLoaded &&
+ snapshot?.expenseHistory && recordResult !== "missing" ? recordResult ?? "loading" : null;
const notes: Record = Object.fromEntries(
(snapshot?.notes ?? []).map((note) => [note.id, note]),
);
@@ -905,7 +1038,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
const latest = dataRef.current;
if (!stillCurrent() || !latest || !latest.communes.some(item => item.id === activeId)) return;
if (snapshot.commune.id !== activeId) throw new ApiError("Commune response did not match the request", 502);
- snapshotTimes.current.set(cacheKey, Date.now());
publishData({ ...latest, communes: latest.communes.map(item => item.id === activeId ? { ...item, ...snapshot.commune } : item), snapshots: { ...latest.snapshots, [activeId]: snapshot } });
setSnapshotErrors(errors => { const next = { ...errors }; delete next[activeId]; return next; });
} catch (error) {
@@ -915,7 +1047,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
if (error instanceof ApiError && [401, 403, 404].includes(error.status)) {
const snapshots = { ...latest.snapshots };
delete snapshots[activeId];
- snapshotTimes.current.delete(cacheKey);
publishData({ ...latest, snapshots });
}
// The write already committed. Do not invite a duplicate submission when
@@ -923,6 +1054,112 @@ export function AppProvider({ children }: { children: ReactNode }) {
}
}, [activeId, publishData]);
+ // Older expenses of one commune, a page at a time: whole months (UTC) going
+ // back from where the loaded ones start, or all of them when months is left
+ // out. One read per commune at a time; a second caller waits for the first.
+ const readOlder = useCallback((communeId: string, months?: number): Promise => {
+ const current = dataRef.current;
+ const held = current?.snapshots[communeId];
+ const since = held?.expenseHistory?.since;
+ if (!current || !held || held.detail === "summary" || !since) return Promise.resolve();
+ const key = `${current.user.id}:${communeId}`;
+ const pending = olderReads.current.get(key);
+ if (pending) return pending;
+ const state = historyState(held, current.older[communeId]);
+ if (state.complete) return Promise.resolve();
+ const request = loadExpensePage(communeId, state.loadedFrom, months).then((page) => {
+ const latest = dataRef.current;
+ const now = latest?.snapshots[communeId];
+ // A new month moves the snapshot's window on; pages for the old one no longer join up.
+ if (!latest || latest.user.id !== current.user.id || !now || now.expenseHistory?.since !== since) return;
+ const kept = olderFor(now, latest.older[communeId]);
+ publishData({ ...latest, older: { ...latest.older, [communeId]: { since, from: page.from, olderCount: page.olderCount,
+ olderVoidedCount: page.olderVoidedCount,
+ expenses: { ...kept?.expenses, ...Object.fromEntries(page.expenses.map((expense) => [expense.id, expense])) } } } });
+ }).finally(() => olderReads.current.delete(key));
+ olderReads.current.set(key, request);
+ return request;
+ }, [publishData]);
+
+ const loadEarlierExpenses = useCallback(async (before: string, voided = false): Promise => {
+ const communeId = activeId;
+ const limit = Date.parse(`${before}T00:00:00Z`);
+ // Each page reaches back at least a month, so this ends; the bound is a backstop.
+ for (let page = 0; page < 600; page += 1) {
+ const current = dataRef.current;
+ const held = current?.snapshots[communeId];
+ if (!current || !held || held.detail === "summary") return null;
+ const older = current.older[communeId];
+ const state = historyState(held, older);
+ // Open expenses always show, so only settled ones (or voided ones) count.
+ const newest = Math.max(...withOlderExpenses(held, older).expenses
+ .filter((expense) => voided ? !!expense.voidedAt
+ : !expense.voidedAt && expense.shares.every((share) => share.settledMinor >= share.amountMinor))
+ .map((expense) => Date.parse(expense.createdAt ?? "")).filter((time) => time < limit));
+ // Everything between that month and `before` must be loaded too.
+ const loadedFrom = state.loadedFrom ? Date.parse(`${state.loadedFrom}T00:00:00Z`) : -Infinity;
+ if (Number.isFinite(newest) && (state.complete || newest >= loadedFrom)) {
+ const month = new Date(newest);
+ return new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth(), 1)).toISOString().slice(0, 10);
+ }
+ if (state.complete) return null;
+ await readOlder(communeId, 1);
+ }
+ return null;
+ }, [activeId, readOlder]);
+
+ const loadFullHistory = useCallback(async (): Promise => {
+ const communeId = activeId;
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ const current = dataRef.current;
+ const held = current?.snapshots[communeId];
+ if (!current || !held || held.detail === "summary") return null;
+ const older = current.older[communeId];
+ if (historyState(held, older).complete) {
+ // Every expense is in it now, so it says nothing about older ones.
+ const { expenseHistory: _history, changeMark: _mark, ...ledger } = withOlderExpenses(held, older);
+ return ledger;
+ }
+ await readOlder(communeId);
+ }
+ throw new Error("Earlier expenses couldn’t be loaded. Try again.");
+ }, [activeId, readOlder]);
+
+ // A link to an expense older than what is loaded (from search, a notice,
+ // activity or a recurring bill's history) fetches that one expense. If it
+ // is refused or gone, the window says it isn't in this commune.
+ useEffect(() => {
+ const current = dataRef.current;
+ const key = recordKey;
+ if (!current || expenseRead !== "loading" || recordReads.current.has(key)) return;
+ recordReads.current.add(key);
+ const finish = (result?: "missing" | "failed") => {
+ recordReads.current.delete(key);
+ if (result) setRecordResults((results) => ({ ...results, [key]: result }));
+ };
+ loadExpenseRecords(activeId, [selectedExpenseId]).then(({ expenses: found }) => {
+ const latest = dataRef.current;
+ const now = latest?.snapshots[activeId];
+ if (!latest || latest.user.id !== current.user.id || !now?.expenseHistory) return finish();
+ if (!found.length) return finish("missing");
+ // The snapshot holds every expense changed since its window began, and
+ // its copy wins, so an expense fetched here is never out of date.
+ const kept = olderFor(now, latest.older[activeId]) ?? { since: now.expenseHistory.since, from: now.expenseHistory.since,
+ olderCount: now.expenseHistory.olderCount, olderVoidedCount: now.expenseHistory.olderVoidedCount, expenses: {} };
+ publishData({ ...latest, older: { ...latest.older, [activeId]: { ...kept,
+ expenses: { ...kept.expenses, ...Object.fromEntries(found.map((expense) => [expense.id, expense])) } } } });
+ finish();
+ }, (error: unknown) => {
+ // A refusal stands; a failed connection can be tried again.
+ finish(error instanceof ApiError && error.status >= 400 && error.status < 500 ? "missing" : "failed");
+ });
+ }, [activeId, expenseRead, publishData, recordKey, selectedExpenseId]);
+ const retryExpenseRead = useCallback(() => setRecordResults((results) => {
+ const next = { ...results };
+ delete next[recordKey];
+ return next;
+ }), [recordKey]);
+
const workflow = useCallback(async (path: string, body: unknown, method = "POST"): Promise => mutate(async () => {
const result = await api(path,{method,body:JSON.stringify(body)});
await refreshRecords();
@@ -968,7 +1205,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
};
const latest = dataRef.current;
if (!latest || latest.user.id !== current.user.id) return result.id;
- snapshotTimes.current.set(`${current.user.id}:${result.id}`, Date.now());
+ // Neither this nor its list entry has a change mark, so it is not read
+ // again until the next check lists the commune with one.
publishData({ ...latest, communes: [commune, ...latest.communes],
snapshots: { ...latest.snapshots, [result.id]: {
commune, members: [{ userId: current.user.id, name: current.user.name, email: current.user.email, role: "admin" }],
@@ -1397,7 +1635,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
: PAGE_LEDES[screen] ?? "";
return 0, clearApiError: () => setApiError(null), refresh,
+ 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,
@@ -1405,6 +1643,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
dark: prefs.dark, noticesOn: prefs.noticesOn, currency: prefs.currency,
searchQuery, searchScope, searchTarget, noteSearchQuery, invites: data?.invites[activeId] ?? [], payMethods: prefs.payMethods, native, you,
lastExpense, expenses, selectedExpense, createdExpenses: expenses, voidedExpenses,
+ expenseRead, retryExpenseRead, expenseHistory, loadEarlierExpenses, loadFullHistory,
funds, recurringBills, splitTemplates, expenseTemplateId, reminderTargets, reminderUsage, sentReminders, notifications,
signOut, signOutUnconfirmed, retrySignOut: confirmSignOut, sessionNotice, go,
changePassword, signOutOtherDevices,
diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css
index 85e49e09..e0df5c6a 100644
--- a/apps/web/src/styles/app.css
+++ b/apps/web/src/styles/app.css
@@ -199,7 +199,7 @@
}
.a-wordmark {
- font-family: "DM Sans Variable", "DM Sans", sans-serif;
+ font-family: "DM Sans Wordmark", "DM Sans", sans-serif;
font-size: 34px;
font-weight: 600;
letter-spacing: -2px;
@@ -893,11 +893,11 @@
}
.a-commune-photo-preview {
+ position: relative;
+ overflow: hidden;
width: 100%;
height: 160px;
- background-color: var(--navy);
- background-position: center;
- background-size: cover;
+ background-color: var(--cover-tone, var(--navy));
border-radius: 12px;
margin-bottom: 14px;
}
@@ -1933,11 +1933,6 @@ button.a-kpi {
position: absolute;
inset: 0;
z-index: 0;
- background: var(
- --hub-photo,
- url("https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?auto=format&fit=crop&w=1600&q=80")
- )
- center/cover no-repeat;
transform: scale(1.03);
transform-origin: center;
}
@@ -2360,27 +2355,6 @@ button.a-kpi {
height: 78px;
}
-.a-commune-card .band.home {
- background:
- linear-gradient(180deg, rgba(26, 30, 46, 0.2), rgba(26, 30, 46, 0.45)),
- url("https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?auto=format&fit=crop&w=800&q=70")
- center/cover;
-}
-
-.a-commune-card .band.studio {
- background:
- linear-gradient(180deg, rgba(26, 30, 46, 0.2), rgba(26, 30, 46, 0.5)),
- url("https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&w=800&q=70")
- center/cover;
-}
-
-.a-commune-card .band.trip {
- background:
- linear-gradient(180deg, rgba(26, 30, 46, 0.2), rgba(26, 30, 46, 0.5)),
- url("https://images.unsplash.com/photo-1555881400-74d7acaacd8b?auto=format&fit=crop&w=800&q=70")
- center/cover;
-}
-
.a-commune-card .pad {
padding: 14px 16px 16px;
}
@@ -3138,37 +3112,14 @@ button.a-item {
flex-direction: column;
justify-content: flex-end;
color: #f8f5f0;
- background: #1a1e2b;
+ background: var(--cover-tone, #1a1e2b);
}
-.a-place-hero::before {
- content: "";
+/* The cover photo (ui/CoverImage.tsx) sits under the shade and the text. */
+.a-place-hero > .c-cover-photo,
+.a-focus-hero > .c-cover-photo {
position: absolute;
- inset: 0;
z-index: 0;
- background-size: cover;
- background-position: center;
-}
-
-.a-place-hero.park::before,
-.a-inbox-hub .thumb.park,
-.a-focus-hero::before {
- background-image: url("https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?auto=format&fit=crop&w=1200&q=80");
-}
-
-.a-place-hero.studio::before,
-.a-inbox-hub .thumb.studio {
- background-image: url("https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&w=1200&q=80");
-}
-
-.a-place-hero.lisbon::before,
-.a-inbox-hub .thumb.lisbon {
- background-image: url("https://images.unsplash.com/photo-1555881400-74d7acaacd8b?auto=format&fit=crop&w=1200&q=80");
-}
-
-.a-place-hero.has-photo::before,
-.a-focus-hero.has-photo::before {
- background-image: var(--place-photo);
}
.a-place-hero::after {
@@ -3349,16 +3300,10 @@ button.a-item {
text-align: left;
font: inherit;
color: #f8f5f0;
- background: #1a1e2b;
+ background: var(--cover-tone, #1a1e2b);
}
-.a-focus-hero::before {
- content: "";
- position: absolute;
- inset: 0;
- z-index: 0;
- background-size: cover;
- background-position: center;
+.a-focus-hero > .c-cover-photo {
transform: scale(1.03);
}
diff --git a/apps/web/src/styles/fonts.css b/apps/web/src/styles/fonts.css
new file mode 100644
index 00000000..5bef5737
--- /dev/null
+++ b/apps/web/src/styles/fonts.css
@@ -0,0 +1,89 @@
+/*
+ * The app serves its own fonts, so no other company sees who opens it.
+ *
+ * Inter: the files fonts.gstatic.com serves for
+ * fonts.googleapis.com/css2?family=Inter:wght@400..800 (Inter 4.001, v20),
+ * every subset in the same order with the same descriptors, so text renders
+ * as it did. A browser downloads a file only when the page shows a character
+ * in its unicode-range: the interface needs only Latin, and the others are
+ * fetched just for names such as Łukasz, Дмитрий, Νίκος or Nguyễn.
+ *
+ * DM Sans: only the glyphs of the "commune." wordmark (.a-wordmark), with both
+ * variation axes kept so it renders identically at every size it is drawn.
+ * Made from @fontsource-variable/dm-sans 5.3.0 dm-sans-latin-standard-normal.woff2:
+ * pyftsubset dm-sans-latin-standard-normal.woff2 --unicodes=U+002E,U+0063,U+0065,U+006D,U+006E,U+006F,U+0075 --layout-features='*' --flavor=woff2
+ * Make it again if the wordmark's letters change.
+ *
+ * Both are under the SIL Open Font License; the licences sit beside the files.
+ */
+
+@font-face {
+ font-family: "Inter";
+ font-style: normal;
+ font-weight: 400 800;
+ font-display: swap;
+ src: url("./fonts/inter-cyrillic-ext.woff2") format("woff2");
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+
+@font-face {
+ font-family: "Inter";
+ font-style: normal;
+ font-weight: 400 800;
+ font-display: swap;
+ src: url("./fonts/inter-cyrillic.woff2") format("woff2");
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+
+@font-face {
+ font-family: "Inter";
+ font-style: normal;
+ font-weight: 400 800;
+ font-display: swap;
+ src: url("./fonts/inter-greek-ext.woff2") format("woff2");
+ unicode-range: U+1F00-1FFF;
+}
+
+@font-face {
+ font-family: "Inter";
+ font-style: normal;
+ font-weight: 400 800;
+ font-display: swap;
+ src: url("./fonts/inter-greek.woff2") format("woff2");
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+
+@font-face {
+ font-family: "Inter";
+ font-style: normal;
+ font-weight: 400 800;
+ font-display: swap;
+ src: url("./fonts/inter-vietnamese.woff2") format("woff2");
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+
+@font-face {
+ font-family: "Inter";
+ font-style: normal;
+ font-weight: 400 800;
+ font-display: swap;
+ src: url("./fonts/inter-latin-ext.woff2") format("woff2");
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+@font-face {
+ font-family: "Inter";
+ font-style: normal;
+ font-weight: 400 800;
+ font-display: swap;
+ src: url("./fonts/inter-latin.woff2") format("woff2");
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: "DM Sans Wordmark";
+ font-style: normal;
+ font-weight: 100 1000;
+ font-display: swap;
+ src: url("./fonts/dm-sans-wordmark.woff2") format("woff2");
+}
diff --git a/apps/web/src/styles/fonts/DMSans-OFL.txt b/apps/web/src/styles/fonts/DMSans-OFL.txt
new file mode 100644
index 00000000..441171d6
--- /dev/null
+++ b/apps/web/src/styles/fonts/DMSans-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/apps/web/src/styles/fonts/Inter-OFL.txt b/apps/web/src/styles/fonts/Inter-OFL.txt
new file mode 100644
index 00000000..21f6aff9
--- /dev/null
+++ b/apps/web/src/styles/fonts/Inter-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/apps/web/src/styles/fonts/dm-sans-wordmark.woff2 b/apps/web/src/styles/fonts/dm-sans-wordmark.woff2
new file mode 100644
index 00000000..eaffe845
Binary files /dev/null and b/apps/web/src/styles/fonts/dm-sans-wordmark.woff2 differ
diff --git a/apps/web/src/styles/fonts/inter-cyrillic-ext.woff2 b/apps/web/src/styles/fonts/inter-cyrillic-ext.woff2
new file mode 100644
index 00000000..de83a9c7
Binary files /dev/null and b/apps/web/src/styles/fonts/inter-cyrillic-ext.woff2 differ
diff --git a/apps/web/src/styles/fonts/inter-cyrillic.woff2 b/apps/web/src/styles/fonts/inter-cyrillic.woff2
new file mode 100644
index 00000000..d7509147
Binary files /dev/null and b/apps/web/src/styles/fonts/inter-cyrillic.woff2 differ
diff --git a/apps/web/src/styles/fonts/inter-greek-ext.woff2 b/apps/web/src/styles/fonts/inter-greek-ext.woff2
new file mode 100644
index 00000000..6e7141f8
Binary files /dev/null and b/apps/web/src/styles/fonts/inter-greek-ext.woff2 differ
diff --git a/apps/web/src/styles/fonts/inter-greek.woff2 b/apps/web/src/styles/fonts/inter-greek.woff2
new file mode 100644
index 00000000..024f0770
Binary files /dev/null and b/apps/web/src/styles/fonts/inter-greek.woff2 differ
diff --git a/apps/web/src/styles/fonts/inter-latin-ext.woff2 b/apps/web/src/styles/fonts/inter-latin-ext.woff2
new file mode 100644
index 00000000..479d010d
Binary files /dev/null and b/apps/web/src/styles/fonts/inter-latin-ext.woff2 differ
diff --git a/apps/web/src/styles/fonts/inter-latin.woff2 b/apps/web/src/styles/fonts/inter-latin.woff2
new file mode 100644
index 00000000..d15208de
Binary files /dev/null and b/apps/web/src/styles/fonts/inter-latin.woff2 differ
diff --git a/apps/web/src/styles/fonts/inter-vietnamese.woff2 b/apps/web/src/styles/fonts/inter-vietnamese.woff2
new file mode 100644
index 00000000..a40c4699
Binary files /dev/null and b/apps/web/src/styles/fonts/inter-vietnamese.woff2 differ
diff --git a/apps/web/src/styles/shared.css b/apps/web/src/styles/shared.css
index d36a444c..4d1de6bd 100644
--- a/apps/web/src/styles/shared.css
+++ b/apps/web/src/styles/shared.css
@@ -18,6 +18,10 @@
--lilac: #e8e1ef;
--gold: #f1e5bf;
--coral: #eaa681;
+ /* Each commune type's tone behind its cover photo (see .c-cover-* below). */
+ --cover-household: #3d5a4c;
+ --cover-workspace: #3f4b5a;
+ --cover-trip: #6a4b3c;
--border: rgba(23, 27, 36, 0.08);
--border-strong: rgba(23, 27, 36, 0.14);
--shadow-sm: 0 1px 3px rgba(23, 27, 36, 0.06), 0 1px 2px rgba(23, 27, 36, 0.04);
@@ -127,6 +131,20 @@ img {
border-radius: inherit;
}
+/* Commune covers (ui/CoverImage.tsx). The container takes its type's tone,
+ which shows while the photo loads and stays if the photo cannot load. */
+.c-cover-household { --cover-tone: var(--cover-household); }
+.c-cover-workspace { --cover-tone: var(--cover-workspace); }
+.c-cover-trip { --cover-tone: var(--cover-trip); }
+
+.c-cover-photo {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
.avatar-stack {
display: flex;
}
diff --git a/apps/web/src/ui/CoverImage.tsx b/apps/web/src/ui/CoverImage.tsx
new file mode 100644
index 00000000..33706bb1
--- /dev/null
+++ b/apps/web/src/ui/CoverImage.tsx
@@ -0,0 +1,30 @@
+import { useState } from "react";
+import { coverSource, coverTone, type CoverHub, type CoverPlacement } from "../data/covers";
+import { cx } from "./cx";
+
+/** The class that gives a cover's container the calm tone for its commune type. */
+export function coverToneClass(hub: CoverHub): string {
+ return `c-cover-${coverTone(hub)}`;
+}
+
+/**
+ * A commune's cover photo, filling its container. The container carries
+ * coverToneClass(hub), so its tone shows while the photo loads; if the photo
+ * cannot load, it is removed rather than left as a broken image.
+ */
+export function CoverImage({ hub, placement, className }: { hub: CoverHub; placement: CoverPlacement; className?: string }) {
+ const source = coverSource(hub, placement);
+ const [failed, setFailed] = useState("");
+ if (failed === source.src) return null;
+ return (
+ setFailed(source.src)}
+ />
+ );
+}
diff --git a/apps/web/src/windows/Windows.tsx b/apps/web/src/windows/Windows.tsx
index b82c9fc2..4ef0fd22 100644
--- a/apps/web/src/windows/Windows.tsx
+++ b/apps/web/src/windows/Windows.tsx
@@ -56,6 +56,8 @@ export function Windows() {
mutationPending,
contentLoading,
contentError,
+ expenseRead,
+ retryExpenseRead,
refresh,
} = useApp();
const frameRef = useRef(null);
@@ -98,7 +100,8 @@ export function Windows() {
}, [isOpen]);
useEffect(() => { if (window) frameRef.current?.focus(); }, [window]);
if (!window || inlineNote) return null;
- const waitingForRecord = contentLoading && ["expense", "expense-paid", "note", "note-edit", "operation", "operation-edit"].includes(window);
+ const waitingForRecord = (contentLoading && ["expense", "expense-paid", "note", "note-edit", "operation", "operation-edit"].includes(window)) || !!expenseRead;
+ const recordError = contentLoading ? contentError : expenseRead === "failed" ? "That expense couldn’t be loaded. Try again." : null;
const title =
window === "expense" || window === "expense-paid"
? selectedExpense?.name ?? "Expense"
@@ -119,8 +122,8 @@ export function Windows() {
- {waitingForRecord ? contentError ?
{contentError}
-
void refresh()}>Try again :
+ {waitingForRecord ? recordError ?
{recordError}
+
contentLoading ? void refresh() : retryExpenseRead()}>Try again :
: <>
{window === "notices" &&
}
{window === "reminder-preview" &&
}
@@ -764,7 +767,16 @@ function Profile() {
}
function Breakdown() {
- const { go, closeWindow, hub, expenses, voidedExpenses, session, snapshots } = useApp();
+ const { go, closeWindow, 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(() => {
+ if (expenseHistory.complete || historyFailed) return;
+ let open = true;
+ loadFullHistory().catch(() => { if (open) setHistoryFailed(true); });
+ return () => { open = false; };
+ }, [expenseHistory.complete, historyFailed, loadFullHistory]);
+ const history = expenseHistory.complete ? "ready" : historyFailed ? "error" : "loading";
const others = hub.members.filter((member) => member.userId !== session?.userId);
const ledger = [...expenses, ...voidedExpenses];
// People who have left keep their statements, listed under their own
@@ -828,7 +840,10 @@ function Breakdown() {
);})}
{!moves.length ?
No outstanding shares for you in this commune.
: null}
- {other && statement ? (
+ {other && history === "loading" ? Loading earlier expenses for the statement…
: null}
+ {other && history === "error" ? Earlier expenses couldn’t be loaded, so the statement isn’t shown.
+
setHistoryFailed(false)}>Try again : null}
+ {other && statement && history === "ready" ? (
Statement with
diff --git a/apps/web/tests/covers.test.tsx b/apps/web/tests/covers.test.tsx
new file mode 100644
index 00000000..d0fc32c8
--- /dev/null
+++ b/apps/web/tests/covers.test.tsx
@@ -0,0 +1,164 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, fireEvent, render, screen } 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 } from "../src/live/api";
+import type { Commune, Snapshot } from "../src/live/model";
+import { HUBS, type Hub } from "../src/data/household";
+import { coverSource, type CoverPlacement } from "../src/data/covers";
+import { CoverImage, coverToneClass } from "../src/ui/CoverImage";
+
+vi.mock("../src/live/api", async (importOriginal) => ({
+ ...await importOriginal
(),
+ api: vi.fn(), post: vi.fn(), loadCommunes: vi.fn(), loadSnapshot: vi.fn(),
+}));
+
+const widths = (srcSet = "") => srcSet.split(", ").map((candidate) => {
+ const [url, width] = candidate.split(" ");
+ return { photo: new URL(url).pathname, w: new URL(url).searchParams.get("w"), width };
+});
+const PLACEMENTS: CoverPlacement[] = ["featured", "card", "hero", "preview"];
+const CUSTOM = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ";
+
+describe("cover sources", () => {
+ it.each([
+ ["Household", HUBS.park.photo],
+ ["Workspace", HUBS.studio.photo],
+ ["Trip", HUBS.lisbon.photo],
+ ] as const)("offers each placement the %s default in widths that fit it", (type, photo) => {
+ const id = new URL(photo).pathname;
+ for (const placement of PLACEMENTS) {
+ const source = coverSource({ type, photo }, placement);
+ expect(widths(source.srcSet)).toEqual([480, 800, 1200, 1600].map((w) => ({ photo: id, w: String(w), width: `${w}w` })));
+ expect(source.sizes).toBeTruthy();
+ const src = new URL(source.src);
+ expect(src.pathname).toBe(id);
+ expect(Object.fromEntries(src.searchParams)).toMatchObject({ auto: "format", fit: "crop", q: "80" });
+ // A commune still loading has no photo yet and gets the same image.
+ expect(coverSource({ type, photo: "" }, placement)).toEqual(source);
+ }
+ });
+
+ it("never offers a small placement the full-size photo by default", () => {
+ const fallback = (placement: CoverPlacement) => new URL(coverSource({ type: "Household", photo: "" }, placement).src).searchParams.get("w");
+ expect(fallback("card")).toBe("800");
+ expect(fallback("preview")).toBe("480");
+ expect(coverSource({ type: "Household", photo: "" }, "card").sizes).toBe("(max-width: 560px) 100vw, (max-width: 850px) 50vw, 33vw");
+ expect(coverSource({ type: "Household", photo: "" }, "preview").sizes).toBe("(max-width: 560px) 100vw, (max-width: 1000px) 560px, 240px");
+ });
+
+ it("draws a photo chosen in this browser as it is", () => {
+ for (const placement of PLACEMENTS) expect(coverSource({ type: "Trip", photo: CUSTOM }, placement)).toEqual({ src: CUSTOM });
+ });
+});
+
+describe("a cover that cannot load", () => {
+ const hub: Pick = { type: "Workspace", photo: HUBS.studio.photo };
+ function Frame({ photo = hub.photo }: { photo?: string }) {
+ return
;
+ }
+
+ it("is removed, leaving the commune type's tone instead of a broken image", () => {
+ const { rerender } = render( );
+ const image = document.querySelector("img.c-cover-photo")!;
+ expect(image.getAttribute("alt")).toBe("");
+ expect(image.getAttribute("srcset")).toBe(coverSource(hub, "card").srcSet);
+ expect(image.getAttribute("sizes")).toBe(coverSource(hub, "card").sizes);
+ fireEvent.error(image);
+ expect(document.querySelector("img")).toBeNull();
+ expect(screen.getByTestId("frame").className).toBe("c-cover-workspace");
+ // A different photo gets its own chance to load.
+ rerender( );
+ expect(document.querySelector("img.c-cover-photo")?.getAttribute("src")).toBe(CUSTOM);
+ });
+});
+
+const user = { id: "user-1", name: "Test Person", email: "test@example.test", emailVerified: true };
+const communes: Commune[] = [
+ { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin", address: "1 Test Street" },
+ { id: "work-1", name: "The studio", kind: "workspace", currency: "GBP", role: "admin", address: "2 Test Yard" },
+ { id: "trip-1", name: "Away days", kind: "trip", currency: "GBP", role: "admin", destination: "Lisbon" },
+];
+const snapshotOf = (commune: Commune): Snapshot => ({
+ commune, members: [{ userId: user.id, name: user.name, role: "admin" }], expenses: [], funds: [], notifications: [], reminderTargets: [],
+});
+function mount(path: string) {
+ const location = memoryLocation({ path, record: true });
+ render( );
+}
+const failAll = () => document.querySelectorAll("img.c-cover-photo").forEach((image) => fireEvent.error(image));
+
+describe("covers across the app", () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ sessionStorage.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(loadCommunes).mockResolvedValue({ communes });
+ vi.mocked(loadSnapshot).mockImplementation(async (commune) => snapshotOf(commune));
+ });
+ afterEach(cleanup);
+
+ it("sizes Home's covers for their cards and falls back to each type's tone", async () => {
+ mount("/");
+ await screen.findByRole("heading", { name: "Our home" });
+ const featured = document.querySelector(".a-focus-hero")!;
+ const cards = [...document.querySelectorAll(".a-home-places .a-place-hero")];
+ expect(featured.classList.contains("c-cover-household")).toBe(true);
+ expect(cards.map((card) => [...card.classList].find((name) => name.startsWith("c-cover-")))).toEqual(["c-cover-workspace", "c-cover-trip"]);
+ expect(featured.querySelector("img")?.getAttribute("sizes")).toBe(coverSource({ type: "Household", photo: "" }, "featured").sizes);
+ for (const card of cards) expect(card.querySelector("img")?.getAttribute("sizes")).toBe(coverSource({ type: "Trip", photo: "" }, "card").sizes);
+ expect(cards[1].querySelector("img")?.getAttribute("src")).toBe(coverSource({ type: "Trip", photo: "" }, "card").src);
+ failAll();
+ expect(document.querySelector("img.c-cover-photo")).toBeNull();
+ expect(screen.getByRole("heading", { name: "Our home" })).toBeTruthy();
+ expect(screen.getByRole("heading", { name: "Away days" })).toBeTruthy();
+ });
+
+ it("keeps This Hub's loading placeholder plain, then sizes the hero and keeps the tone if it fails", async () => {
+ let release!: () => void;
+ vi.mocked(loadSnapshot).mockImplementation((commune) => new Promise((resolve) => { release = () => resolve(snapshotOf(commune)); }));
+ mount("/hub");
+ await screen.findByRole("status", { name: "Loading shared records" });
+ // Plain sage while the records load: no default cover that a photo chosen
+ // in this browser would then replace.
+ const placeholder = document.querySelector(".c-skeleton-identity")!;
+ expect(placeholder.className).toBe("c-skeleton-identity");
+ expect(placeholder.querySelector("img")).toBeNull();
+ expect(placeholder.style.backgroundImage).toBe("");
+ await act(async () => release());
+ const identity = await screen.findByRole("region", { name: "Commune identity" });
+ const hero = coverSource({ type: "Household", photo: HUBS.park.photo }, "hero");
+ const cover = identity.querySelector("img.c-hub-cover")!;
+ expect([cover.getAttribute("src"), cover.getAttribute("srcset"), cover.getAttribute("sizes")]).toEqual([hero.src, hero.srcSet, hero.sizes]);
+ fireEvent.error(cover);
+ expect(identity.querySelector("img")).toBeNull();
+ expect(identity.className).toBe("c-hub-identity has-photo c-cover-household");
+ expect(screen.getByRole("heading", { name: "Our home" })).toBeTruthy();
+ });
+
+ it("shows a photo chosen in this browser in This Hub's loading placeholder, as before", async () => {
+ sessionStorage.setItem(`commune.local.${user.id}`, JSON.stringify({ hubPhotos: { "home-1": CUSTOM } }));
+ vi.mocked(loadSnapshot).mockImplementation(() => new Promise(() => undefined));
+ mount("/hub");
+ await screen.findByRole("status", { name: "Loading shared records" });
+ const placeholder = document.querySelector(".c-skeleton-identity")!;
+ expect(placeholder.style.backgroundImage).toContain(CUSTOM);
+ expect(placeholder.querySelector("img")).toBeNull();
+ });
+
+ it("previews the cover in commune settings at its small size", async () => {
+ mount("/commune-settings");
+ const preview = await screen.findByRole("img", { name: "Our home cover photo" });
+ expect(preview.classList.contains("c-cover-household")).toBe(true);
+ expect(preview.querySelector("img")?.getAttribute("sizes")).toBe(coverSource({ type: "Household", photo: "" }, "preview").sizes);
+ failAll();
+ expect(preview.querySelector("img")).toBeNull();
+ });
+});
diff --git a/apps/web/tests/fonts.test.ts b/apps/web/tests/fonts.test.ts
new file mode 100644
index 00000000..d95b8d9e
--- /dev/null
+++ b/apps/web/tests/fonts.test.ts
@@ -0,0 +1,45 @@
+// @vitest-environment node
+import { existsSync, readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+const styles = new URL("../src/styles/", import.meta.url);
+const css = readFileSync(new URL("fonts.css", styles), "utf8");
+const faces = css.match(/@font-face\s*{[^}]*}/g) ?? [];
+const family = (face: string) => /font-family:\s*"([^"]+)"/.exec(face)?.[1];
+
+describe("self-hosted fonts", () => {
+ it("serves Inter and the wordmark from files kept with the app", () => {
+ expect(faces.map(family)).toEqual([...Array(7).fill("Inter"), "DM Sans Wordmark"]);
+ for (const face of faces) {
+ const file = /src:\s*url\("(\.\/fonts\/[\w-]+\.woff2)"\)\s*format\("woff2"\);/.exec(face)?.[1];
+ expect(file).toBeTruthy();
+ expect(existsSync(new URL(file!, styles))).toBe(true);
+ }
+ });
+
+ it("keeps every Inter subset the old Google Fonts link served, so names in other scripts stay in Inter", () => {
+ const inter = faces.filter((face) => family(face) === "Inter");
+ expect(inter.map((face) => /inter-([\w-]+)\.woff2/.exec(face)?.[1]))
+ .toEqual(["cyrillic-ext", "cyrillic", "greek-ext", "greek", "vietnamese", "latin-ext", "latin"]);
+ // A browser downloads a subset only for a page that shows one of its characters.
+ for (const face of inter) expect(face).toMatch(/unicode-range: U\+[0-9A-F]{4}/);
+ });
+
+ it("shows text straight away and swaps the font in when it arrives", () => {
+ for (const face of faces) expect(face).toContain("font-display: swap;");
+ });
+
+ it("keeps each font's licence beside it", () => {
+ for (const licence of ["fonts/Inter-OFL.txt", "fonts/DMSans-OFL.txt"]) {
+ expect(readFileSync(new URL(licence, styles), "utf8")).toContain("SIL OPEN FONT LICENSE Version 1.1");
+ }
+ });
+
+ it("draws the wordmark with the subset made for it", () => {
+ const app = readFileSync(new URL("app.css", styles), "utf8");
+ expect(/\.a-wordmark\s*{[^}]*font-family:\s*"DM Sans Wordmark"/.exec(app)).not.toBeNull();
+ const main = readFileSync(new URL("../main.tsx", styles), "utf8");
+ expect(main).toContain('import "./styles/fonts.css";');
+ expect(main).not.toContain("@fontsource");
+ });
+});
diff --git a/apps/web/tests/lighter-loading.test.tsx b/apps/web/tests/lighter-loading.test.tsx
new file mode 100644
index 00000000..6967cba7
--- /dev/null
+++ b/apps/web/tests/lighter-loading.test.tsx
@@ -0,0 +1,533 @@
+import { afterEach, 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, post, loadCommunes, loadExpensePage, loadExpenseRecords, loadSnapshot, ApiError } from "../src/live/api";
+import { downloadText } from "../src/data/export";
+import type { Commune, Expense, ExpensePage, 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(), loadExpensePage: vi.fn(), loadExpenseRecords: vi.fn(),
+}));
+vi.mock("../src/data/export", async (importOriginal) => ({
+ ...await importOriginal(),
+ downloadText: 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, active: true };
+const home: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin", changeMark: "2026-10-08:3:30" };
+const trip: Commune = { id: "trip-1", name: "Lisbon trip", kind: "trip", currency: "EUR", role: "member", changeMark: "2026-10-08:2:12" };
+
+// Months are counted in UTC, as the shared records count them.
+const today = new Date();
+function monthStart(back: number) {
+ return new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - back, 1)).toISOString().slice(0, 10);
+}
+function monthLabel(back: number) {
+ const start = new Date(`${monthStart(back)}T00:00:00Z`);
+ return new Intl.DateTimeFormat("en-GB", { month: "long", timeZone: "UTC",
+ ...(start.getUTCFullYear() === today.getUTCFullYear() ? {} : { year: "numeric" }) }).format(start);
+}
+/**
+ * The snapshot holds every expense created from the start of last month,
+ * every open one and the five newest that are not voided.
+ */
+const since = monthStart(1);
+function expense(id: string, title: string, back: number, kind: "settled" | "open" | "voided" = "settled", day = 1): Expense {
+ const at = `${monthStart(back).slice(0, 8)}${String(day).padStart(2, "0")}T12:00:00.000Z`;
+ return {
+ id, title, category: "Other", amountMinor: 2000, paidByUserId: user.id, dueOn: null, createdAt: at, version: 1,
+ voidedAt: kind === "voided" ? at : null, voidReason: kind === "voided" ? "Entered twice" : null,
+ shares: [
+ { id: `${id}-own`, userId: user.id, amountMinor: 1000, settledMinor: 1000 },
+ { id: `${id}-share`, userId: housemate.userId, amountMinor: 1000, settledMinor: kind === "open" ? 0 : 1000 },
+ ],
+ };
+}
+function record(commune: Commune, expenses: Expense[] = [], history = { olderCount: 0, olderVoidedCount: 0 }): Snapshot {
+ const { changeMark, ...identity } = commune;
+ return {
+ commune: identity, members: [{ userId: user.id, name: user.name, role: commune.role, active: true }, housemate],
+ expenses, funds: [], notes: [], operations: [], notifications: [], reminderTargets: [], sentReminders: [],
+ recurringBills: [], splitTemplates: [], activityEvents: [], expenseHistory: { since, ...history }, changeMark,
+ };
+}
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((yes) => { resolve = yes; });
+ return { promise, resolve };
+}
+
+let listed: Commune[];
+let records: Record;
+let app: AppState;
+function Capture() { app = useApp(); return null; }
+function mount(path: string) {
+ const location = memoryLocation({ path, record: true });
+ render( );
+ return location;
+}
+const calls = (path: string) => vi.mocked(api).mock.calls.filter(([called]) => called === path).length;
+const reads = (id: string, detail?: "summary") =>
+ vi.mocked(loadSnapshot).mock.calls.filter(([commune, asked]) => commune.id === id && asked === detail).length;
+// Lets every request the app has started finish: the mocks answer at once.
+const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0)));
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ sessionStorage.clear();
+ listed = [home];
+ records = { [home.id]: record(home) };
+ 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).mockImplementation(async () => ({ communes: listed.map((commune) => ({ ...commune })) }));
+ vi.mocked(loadSnapshot).mockImplementation(async (commune, detail) =>
+ detail ? { ...records[commune.id], detail } : records[commune.id]);
+});
+afterEach(cleanup);
+
+describe("checking for changes", () => {
+ let visibility: DocumentVisibilityState;
+ beforeEach(() => {
+ const now = Date.now();
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(now);
+ visibility = "visible";
+ Object.defineProperty(document, "visibilityState", { configurable: true, get: () => visibility });
+ vi.spyOn(window, "setInterval");
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ delete (document as unknown as Record).visibilityState;
+ });
+ // Minutes pass: the clock moves on and the app's minute timer runs each time.
+ async function minutesPass(count = 1) {
+ for (let minute = 0; minute < count; minute += 1) {
+ vi.advanceTimersByTime(60_000);
+ const timer = vi.mocked(window.setInterval).mock.calls.filter(([, delay]) => delay === 60_000).at(-1)?.[0];
+ act(() => (timer as () => void)());
+ await settle();
+ }
+ }
+ async function started(path = "/hub") {
+ mount(path);
+ await waitFor(() => expect(calls("/reminders/usage")).toBe(1));
+ await settle();
+ }
+
+ it("reads only the commune list while nothing has changed", async () => {
+ await started();
+ expect([calls("/auth/me"), calls(`/communes/${home.id}/invites`), reads(home.id)]).toEqual([1, 1, 1]);
+ await minutesPass(3);
+ expect(loadCommunes).toHaveBeenCalledTimes(4);
+ expect([calls("/auth/me"), calls("/reminders/usage"), calls(`/communes/${home.id}/invites`), reads(home.id)])
+ .toEqual([1, 1, 1, 1]);
+ });
+
+ it("reads the open commune again when its mark moves, with its invitations and the reminder counts", async () => {
+ await started();
+ listed = [{ ...home, changeMark: "2026-10-09:4:31" }];
+ records[home.id] = record(listed[0], [expense("expense-new", "Window cleaner", 0)]);
+ await minutesPass();
+ expect(await screen.findByText("Window cleaner")).toBeTruthy();
+ expect([reads(home.id), calls("/reminders/usage"), calls(`/communes/${home.id}/invites`), calls("/auth/me")])
+ .toEqual([2, 2, 2, 1]);
+ await minutesPass();
+ expect([reads(home.id), calls("/reminders/usage"), calls(`/communes/${home.id}/invites`)]).toEqual([2, 2, 2]);
+ });
+
+ it("reads other communes as summaries on overview screens, and in full once one is opened", async () => {
+ listed = [home, trip];
+ records[trip.id] = record(trip);
+ await started("/places");
+ await waitFor(() => expect(reads(trip.id, "summary")).toBe(1));
+ expect([reads(home.id), reads(home.id, "summary"), reads(trip.id)]).toEqual([1, 0, 0]);
+ // A trip member does not manage its invitations.
+ expect(calls(`/communes/${trip.id}/invites`)).toBe(0);
+
+ listed = [home, { ...trip, changeMark: "2026-10-09:3:44" }];
+ records[trip.id] = record(listed[1]);
+ await minutesPass();
+ expect([reads(home.id), reads(trip.id, "summary"), reads(trip.id)]).toEqual([1, 2, 0]);
+
+ // Its own screens never show the summary: they wait for the whole record.
+ const full = deferred();
+ vi.mocked(loadSnapshot).mockImplementationOnce(() => full.promise);
+ act(() => app.go("hub", { hub: trip.id }));
+ expect(await screen.findByRole("status", { name: "Loading shared records" })).toBeTruthy();
+ expect(reads(trip.id)).toBe(1);
+ await act(async () => full.resolve(records[trip.id]));
+ await waitFor(() => expect(screen.queryByRole("status", { name: "Loading shared records" })).toBeNull());
+ expect(app.snapshots[trip.id].detail).toBeUndefined();
+ });
+
+ it("follows a commune joined or left from the list alone", async () => {
+ await started();
+ listed = [home, trip];
+ records[trip.id] = record(trip);
+ await minutesPass();
+ expect(app.places.map((place) => place.id)).toEqual([home.id, trip.id]);
+ // Not on screen, so not read until it is opened.
+ expect([reads(trip.id), reads(trip.id, "summary"), calls("/reminders/usage")]).toEqual([0, 0, 2]);
+
+ listed = [trip];
+ await minutesPass();
+ await waitFor(() => expect(app.hubId).toBe(trip.id));
+ expect(reads(trip.id)).toBe(1);
+ expect(screen.queryByText(home.name)).toBeNull();
+ });
+
+ it("stops checking in a tab nobody has used for ten minutes, and checks again once it is used", async () => {
+ await started();
+ await minutesPass(60);
+ // Nine checks while the start counts as recent use, then none.
+ expect(loadCommunes).toHaveBeenCalledTimes(10);
+ expect([calls("/auth/me"), reads(home.id)]).toEqual([1, 1]);
+ fireEvent.pointerMove(document.body);
+ await settle();
+ expect(loadCommunes).toHaveBeenCalledTimes(11);
+ await minutesPass();
+ expect(loadCommunes).toHaveBeenCalledTimes(12);
+ expect(calls("/auth/me")).toBe(1);
+ });
+
+ it("does not check while the tab is hidden, and asks who is signed in when it is shown again", async () => {
+ await started();
+ visibility = "hidden";
+ act(() => { document.dispatchEvent(new Event("visibilitychange")); });
+ await minutesPass(5);
+ expect(loadCommunes).toHaveBeenCalledTimes(1);
+ visibility = "visible";
+ act(() => { document.dispatchEvent(new Event("visibilitychange")); });
+ await settle();
+ expect([calls("/auth/me"), vi.mocked(loadCommunes).mock.calls.length, reads(home.id)]).toEqual([2, 2, 1]);
+ // Focus straight after does not ask again.
+ fireEvent.focus(window);
+ await settle();
+ expect(calls("/auth/me")).toBe(2);
+ });
+
+ it("signs the tab out when a check finds the session has ended", async () => {
+ await started();
+ vi.mocked(loadCommunes).mockRejectedValue(new ApiError("Sign in required", 401));
+ await minutesPass();
+ await screen.findByRole("heading", { name: "Welcome back" });
+ expect(screen.getByRole("status").textContent).toBe("You were signed out. Sign in to carry on.");
+ expect(screen.queryByText(home.name)).toBeNull();
+ });
+
+ it("ignores a check that answers after a newer refresh", async () => {
+ await started();
+ const late = deferred<{ communes: Commune[] }>();
+ vi.mocked(loadCommunes).mockImplementationOnce(() => late.promise);
+ await minutesPass();
+ listed = [{ ...home, changeMark: "2026-10-09:4:31" }];
+ records[home.id] = record(listed[0], [expense("expense-new", "Window cleaner", 0)]);
+ await act(async () => app.refresh());
+ expect(await screen.findByText("Window cleaner")).toBeTruthy();
+ // The older answer would have taken the commune away.
+ await act(async () => late.resolve({ communes: [] }));
+ await settle();
+ expect(screen.getByRole("heading", { name: home.name })).toBeTruthy();
+ expect(screen.getByText("Window cleaner")).toBeTruthy();
+ });
+});
+
+describe("older expenses", () => {
+ const groceries = expense("expense-groceries", "Groceries", 0);
+ const internet = expense("expense-internet", "Internet", 1);
+ const boiler = expense("expense-boiler", "Boiler repair", 5, "open");
+ const garden = expense("expense-garden", "Garden tools", 3);
+ const duplicate = expense("expense-duplicate", "Duplicate rent", 4, "voided");
+ const sofa = expense("expense-sofa", "Sofa", 7);
+ // The database would also send Garden tools and Sofa, as two of the five
+ // newest; these tests leave them out to page from the window's own start.
+ // The tests for a commune that has gone quiet, below, use what it sends.
+ const older = [garden, duplicate, sofa];
+ // Pages as commune_expense_page returns them from what the snapshot left
+ // out (newest first): whole months back from `before`, reaching back to
+ // the newest older expense past empty months.
+ const pagesOf = (leftOut: Expense[]) => (_communeId: string, before: string | null, months?: number): Promise => {
+ const end = before ?? since;
+ const earlier = leftOut.filter((item) => item.createdAt! < end);
+ let from: string | null = null;
+ if (months && earlier.length) {
+ const [year, month] = end.split("-").map(Number);
+ const back = new Date(Date.UTC(year, month - 1 - months, 1)).toISOString().slice(0, 10);
+ const newest = `${earlier[0].createdAt!.slice(0, 7)}-01`;
+ from = back < newest ? back : newest;
+ }
+ const rest = earlier.filter((item) => from !== null && item.createdAt! < from);
+ return Promise.resolve({ expenses: earlier.filter((item) => !rest.includes(item)), before: end, from,
+ olderCount: rest.length, olderVoidedCount: rest.filter((item) => item.voidedAt).length });
+ };
+ const shownNames = () => within(screen.getByRole("region", { name: "Shared expenses" })).queryAllByRole("button")
+ .map((button) => button.querySelector("strong")?.textContent);
+ beforeEach(() => {
+ records[home.id] = record(home, [groceries, internet, boiler], { olderCount: 3, olderVoidedCount: 1 });
+ vi.mocked(loadExpensePage).mockImplementation(pagesOf(older));
+ });
+
+ it("shows open expenses and this month's settled ones, and earlier months on request", async () => {
+ mount("/expenses");
+ await screen.findByRole("button", { name: /Groceries/ });
+ expect(shownNames()).toEqual(["Boiler repair", "Groceries"]);
+ expect(screen.getByText(`Settled expenses before ${monthLabel(0)} aren’t shown yet.`)).toBeTruthy();
+
+ // Last month is in the snapshot already.
+ fireEvent.click(screen.getByRole("button", { name: "Show earlier" }));
+ await screen.findByText(`Settled expenses before ${monthLabel(1)} aren’t shown yet.`);
+ expect(shownNames()).toEqual(["Boiler repair", "Groceries", "Internet"]);
+ expect(loadExpensePage).not.toHaveBeenCalled();
+
+ // Two months back holds nothing, so the next month shown is three months back.
+ fireEvent.click(screen.getByRole("button", { name: "Show earlier" }));
+ await screen.findByText(`Settled expenses before ${monthLabel(3)} aren’t shown yet.`);
+ expect(shownNames()).toEqual(["Boiler repair", "Groceries", "Internet", "Garden tools"]);
+ expect(loadExpensePage).toHaveBeenCalledExactlyOnceWith(home.id, since, 1);
+
+ // Voided ones page the same way, and filters apply to what is shown.
+ fireEvent.click(screen.getByRole("button", { name: "Voided" }));
+ expect(screen.getByText("0 voided expenses · kept for history, not counted in any balance")).toBeTruthy();
+ expect(screen.getByText(`Voided expenses before ${monthLabel(3)} aren’t shown yet.`)).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Show earlier" }));
+ await screen.findByRole("button", { name: /Duplicate rent/ });
+ expect(loadExpensePage).toHaveBeenLastCalledWith(home.id, monthStart(3), 1);
+ expect(screen.queryByRole("button", { name: "Show earlier" })).toBeNull();
+
+ fireEvent.click(screen.getByRole("button", { name: "All" }));
+ expect(shownNames()).toEqual(["Boiler repair", "Groceries", "Internet", "Garden tools"]);
+ fireEvent.click(screen.getByRole("button", { name: "Show earlier" }));
+ await screen.findByRole("button", { name: /Sofa/ });
+ expect(loadExpensePage).toHaveBeenCalledTimes(3);
+ // Everything is loaded now.
+ expect(screen.queryByRole("button", { name: "Show earlier" })).toBeNull();
+ fireEvent.change(screen.getByLabelText("Filter expenses in this commune"), { target: { value: "sofa" } });
+ expect(shownNames()).toEqual(["Sofa"]);
+ });
+
+ it("starts from last month while nothing has been settled this month", async () => {
+ records[home.id] = record(home, [internet, boiler], { olderCount: 3, olderVoidedCount: 1 });
+ mount("/expenses");
+ await screen.findByRole("button", { name: /Internet/ });
+ expect(shownNames()).toEqual(["Boiler repair", "Internet"]);
+ expect(screen.getByText(`Settled expenses before ${monthLabel(1)} aren’t shown yet.`)).toBeTruthy();
+ });
+
+ it("pages voided expenses on their own, passing over settled ones", async () => {
+ mount("/expenses");
+ await screen.findByRole("button", { name: /Groceries/ });
+ fireEvent.click(screen.getByRole("button", { name: "Voided" }));
+ expect(screen.getByText(`Voided expenses before ${monthLabel(0)} aren’t shown yet.`)).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Show earlier" }));
+ await screen.findByRole("button", { name: /Duplicate rent/ });
+ expect(shownNames()).toEqual(["Duplicate rent"]);
+ expect(vi.mocked(loadExpensePage).mock.calls).toEqual([[home.id, since, 1], [home.id, monthStart(3), 1]]);
+ expect(screen.queryByRole("button", { name: "Show earlier" })).toBeNull();
+ });
+
+ it("says when earlier expenses couldn’t be loaded, and tries again", async () => {
+ vi.mocked(loadExpensePage).mockRejectedValueOnce(new TypeError("Failed to fetch"));
+ mount("/expenses");
+ await screen.findByRole("button", { name: /Groceries/ });
+ fireEvent.click(screen.getByRole("button", { name: "Show earlier" }));
+ await screen.findByRole("button", { name: /Internet/ });
+ fireEvent.click(screen.getByRole("button", { name: "Show earlier" }));
+ expect((await screen.findByRole("alert")).textContent).toBe("Earlier expenses couldn’t be loaded. Try again.");
+ fireEvent.click(screen.getByRole("button", { name: "Show earlier" }));
+ await screen.findByRole("button", { name: /Garden tools/ });
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+
+ it("keeps older expenses when the snapshot is read again, and the snapshot’s copy wins", async () => {
+ mount("/expenses");
+ await screen.findByRole("button", { name: /Groceries/ });
+ fireEvent.click(screen.getByRole("button", { name: "Show earlier" }));
+ await screen.findByRole("button", { name: /Internet/ });
+ fireEvent.click(screen.getByRole("button", { name: "Show earlier" }));
+ await screen.findByRole("button", { name: /Garden tools/ });
+ // Someone corrects the older expense, so the snapshot carries it now.
+ const corrected = { ...garden, title: "Garden tools and seeds", version: 2, updatedAt: new Date().toISOString() };
+ listed = [{ ...home, changeMark: "2026-10-09:4:31" }];
+ records[home.id] = record(listed[0], [groceries, internet, corrected, boiler], { olderCount: 2, olderVoidedCount: 1 });
+ await act(async () => app.refresh());
+ await screen.findByRole("button", { name: /Garden tools and seeds/ });
+ expect(shownNames()).toEqual(["Boiler repair", "Groceries", "Internet", "Garden tools and seeds"]);
+ expect(loadExpensePage).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not call a commune empty when its only expenses are voided ones older than the snapshot", async () => {
+ records[home.id] = record(home, [], { olderCount: 1, olderVoidedCount: 1 });
+ mount("/expenses");
+ fireEvent.click(await screen.findByRole("button", { name: "Voided" }));
+ expect(screen.getByText(`Voided expenses before ${monthLabel(0)} aren’t shown yet.`)).toBeTruthy();
+ expect(screen.queryByText("No expenses yet.")).toBeNull();
+ expect(app.emptyHub).toBe(false);
+ });
+
+ describe("a commune that has gone quiet", () => {
+ // Nothing since the window's start: the snapshot holds the five newest
+ // that are not voided (Hotel is still open too), and the rest are left out.
+ const farewell = expense("expense-farewell", "Farewell dinner", 3, "settled", 20);
+ const taxi = expense("expense-taxi", "Airport taxi", 3, "settled", 12);
+ const museum = expense("expense-museum", "Museum tickets", 4, "settled", 18);
+ const hotel = expense("expense-hotel", "Hotel", 4, "open", 10);
+ const ferry = expense("expense-ferry", "Ferry", 4, "settled", 6);
+ const repeated = expense("expense-repeated", "Taxi entered twice", 3, "voided", 25);
+ const tram = expense("expense-tram", "Tram passes", 4, "settled", 2);
+ const lunch = expense("expense-lunch", "Harbour lunch", 6, "settled", 15);
+ beforeEach(() => {
+ records[home.id] = record(home, [farewell, taxi, museum, hotel, ferry], { olderCount: 3, olderVoidedCount: 1 });
+ vi.mocked(loadExpensePage).mockImplementation(pagesOf([repeated, tram, lunch]));
+ });
+
+ it("starts at its newest settled month and pages back past the snapshot’s start", async () => {
+ mount("/expenses");
+ await screen.findByRole("button", { name: /Farewell dinner/ });
+ expect(shownNames()).toEqual(["Hotel", "Farewell dinner", "Airport taxi"]);
+ expect(screen.getByText(`Settled expenses before ${monthLabel(3)} aren’t shown yet.`)).toBeTruthy();
+ expect(loadExpensePage).not.toHaveBeenCalled();
+
+ // Four months back is partly in the snapshot; the rest of it is fetched first.
+ fireEvent.click(screen.getByRole("button", { name: "Show earlier" }));
+ await screen.findByText(`Settled expenses before ${monthLabel(4)} aren’t shown yet.`);
+ expect(shownNames()).toEqual(["Hotel", "Farewell dinner", "Airport taxi", "Museum tickets", "Ferry", "Tram passes"]);
+ expect(vi.mocked(loadExpensePage).mock.calls).toEqual([[home.id, since, 1], [home.id, monthStart(3), 1]]);
+
+ fireEvent.click(screen.getByRole("button", { name: "Show earlier" }));
+ await screen.findByRole("button", { name: /Harbour lunch/ });
+ expect(loadExpensePage).toHaveBeenLastCalledWith(home.id, monthStart(4), 1);
+ expect(screen.queryByRole("button", { name: "Show earlier" })).toBeNull();
+ });
+
+ it("offers the earlier months its snapshot holds when nothing older is left", async () => {
+ records[home.id] = record(home, [farewell, museum]);
+ mount("/expenses");
+ await screen.findByRole("button", { name: /Farewell dinner/ });
+ expect(shownNames()).toEqual(["Farewell dinner"]);
+ expect(screen.getByText(`Settled expenses before ${monthLabel(3)} aren’t shown yet.`)).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Show earlier" }));
+ await screen.findByRole("button", { name: /Museum tickets/ });
+ expect(shownNames()).toEqual(["Farewell dinner", "Museum tickets"]);
+ expect(screen.queryByRole("button", { name: "Show earlier" })).toBeNull();
+ expect(loadExpensePage).not.toHaveBeenCalled();
+ });
+
+ it("lists its latest expenses on the hub and in its activity", async () => {
+ mount("/hub");
+ const recent = await screen.findByRole("region", { name: "Recent expenses" });
+ expect(within(recent).getAllByRole("button").filter((button) => button.textContent !== "All expenses")
+ .map((button) => button.querySelector("strong")?.textContent)).toEqual(["Farewell dinner", "Airport taxi", "Museum tickets"]);
+ expect(app.hub.activity.map((row) => row.title)).toEqual(["Farewell dinner", "Airport taxi", "Museum tickets", "Hotel", "Ferry"]);
+ });
+ });
+
+ it("fetches a linked expense that is older than what is loaded", async () => {
+ const pending = deferred<{ expenses: Expense[] }>();
+ vi.mocked(loadExpenseRecords).mockReturnValueOnce(pending.promise);
+ mount("/hub");
+ await screen.findByRole("heading", { name: home.name });
+ act(() => app.go("expense", { expense: sofa.id }));
+ const loading = await screen.findByRole("dialog", { name: "Expense" });
+ expect(within(loading).getByRole("status", { name: "Loading selected record" })).toBeTruthy();
+ expect(within(loading).queryByText("That expense isn’t in this hub.")).toBeNull();
+ expect(loadExpenseRecords).toHaveBeenCalledExactlyOnceWith(home.id, [sofa.id]);
+ await act(async () => pending.resolve({ expenses: [sofa] }));
+ const shown = await screen.findByRole("dialog", { name: "Sofa" });
+ expect(within(shown).getByText(/All shares are settled/)).toBeTruthy();
+ // Kept with the other older expenses, so it opens again without a request.
+ act(() => app.closeWindow());
+ act(() => app.go("expense", { expense: sofa.id }));
+ await screen.findByRole("dialog", { name: "Sofa" });
+ expect(loadExpenseRecords).toHaveBeenCalledTimes(1);
+ });
+
+ it("says a linked expense isn’t in the commune when it is refused or gone, and offers to try again when the connection fails", async () => {
+ mount("/hub");
+ await screen.findByRole("heading", { name: home.name });
+ vi.mocked(loadExpenseRecords).mockRejectedValueOnce(new ApiError("Not a commune member", 403));
+ act(() => app.go("expense", { expense: "expense-elsewhere" }));
+ expect(await screen.findByText("That expense isn’t in this hub.")).toBeTruthy();
+ act(() => app.closeWindow());
+ vi.mocked(loadExpenseRecords).mockResolvedValueOnce({ expenses: [] });
+ act(() => app.go("expense", { expense: "expense-gone" }));
+ expect(await screen.findByText("That expense isn’t in this hub.")).toBeTruthy();
+ act(() => app.closeWindow());
+
+ vi.mocked(loadExpenseRecords).mockRejectedValueOnce(new TypeError("Failed to fetch"));
+ act(() => app.go("expense", { expense: sofa.id }));
+ const alert = await screen.findByRole("alert");
+ expect(alert.textContent).toContain("That expense couldn’t be loaded. Try again.");
+ vi.mocked(loadExpenseRecords).mockResolvedValueOnce({ expenses: [sofa] });
+ fireEvent.click(within(alert).getByRole("button", { name: "Try again" }));
+ await screen.findByRole("dialog", { name: "Sofa" });
+ expect(loadExpenseRecords).toHaveBeenCalledTimes(4);
+ });
+
+ it("loads every older expense before showing a statement", async () => {
+ const pending = deferred();
+ vi.mocked(loadExpensePage).mockReturnValueOnce(pending.promise);
+ mount("/settle");
+ fireEvent.click(await screen.findByRole("button", { name: "View breakdown" }));
+ const dialog = await screen.findByRole("dialog", { name: "Breakdown" });
+ expect(within(dialog).getByText("Loading earlier expenses for the statement…")).toBeTruthy();
+ expect(within(dialog).queryByRole("region", { name: "Statement" })).toBeNull();
+ expect(loadExpensePage).toHaveBeenCalledExactlyOnceWith(home.id, since, undefined);
+ await act(async () => pending.resolve({ expenses: older, before: since, from: null, olderCount: 0, olderVoidedCount: 0 }));
+ const statement = await within(dialog).findByRole("region", { name: "Statement" });
+ for (const title of ["Sofa", "Duplicate rent", "Garden tools", "Internet"]) expect(statement.textContent).toContain(title);
+ expect(within(dialog).queryByText("Loading earlier expenses for the statement…")).toBeNull();
+ });
+
+ it("says when a statement can’t be shown because earlier expenses didn’t load", async () => {
+ vi.mocked(loadExpensePage).mockRejectedValueOnce(new TypeError("Failed to fetch"));
+ mount("/settle");
+ fireEvent.click(await screen.findByRole("button", { name: "View breakdown" }));
+ const dialog = await screen.findByRole("dialog", { name: "Breakdown" });
+ expect((await within(dialog).findByRole("alert")).textContent).toContain("Earlier expenses couldn’t be loaded, so the statement isn’t shown.");
+ expect(within(dialog).queryByRole("region", { name: "Statement" })).toBeNull();
+ fireEvent.click(within(dialog).getByRole("button", { name: "Try again" }));
+ expect((await within(dialog).findByRole("region", { name: "Statement" })).textContent).toContain("Sofa");
+ });
+
+ it("loads every older expense before making an export file", async () => {
+ const pending = deferred();
+ vi.mocked(loadExpensePage).mockReturnValueOnce(pending.promise);
+ mount("/commune-settings");
+ fireEvent.click(await screen.findByRole("button", { name: /Download expenses/ }));
+ expect(await screen.findByText("Loading earlier expenses for the file…")).toBeTruthy();
+ expect((screen.getByRole("button", { name: /Download all records/ }) as HTMLButtonElement).disabled).toBe(true);
+ expect(downloadText).not.toHaveBeenCalled();
+ await act(async () => pending.resolve({ expenses: older, before: since, from: null, olderCount: 0, olderVoidedCount: 0 }));
+ await waitFor(() => expect(downloadText).toHaveBeenCalledTimes(1));
+ const csv = vi.mocked(downloadText).mock.calls[0][1];
+ for (const title of ["Sofa", "Duplicate rent", "Garden tools", "Groceries", "Boiler repair"]) expect(csv).toContain(title);
+ expect(screen.queryByText("Loading earlier expenses for the file…")).toBeNull();
+
+ // The next file needs nothing more, and says nothing about a window.
+ fireEvent.click(screen.getByRole("button", { name: /Download all records/ }));
+ await waitFor(() => expect(downloadText).toHaveBeenCalledTimes(2));
+ const file = JSON.parse(vi.mocked(downloadText).mock.calls[1][1]) as Snapshot;
+ expect(file.expenses.map((item) => item.title)).toEqual(["Groceries", "Internet", "Garden tools", "Duplicate rent", "Boiler repair", "Sofa"]);
+ expect(file.expenseHistory).toBeUndefined();
+ expect(file.changeMark).toBeUndefined();
+ expect(loadExpensePage).toHaveBeenCalledTimes(1);
+ });
+
+ it("makes no file when earlier expenses couldn’t be loaded", async () => {
+ vi.mocked(loadExpensePage).mockRejectedValueOnce(new TypeError("Failed to fetch"));
+ mount("/commune-settings");
+ fireEvent.click(await screen.findByRole("button", { name: /Download expenses/ }));
+ expect((await screen.findByRole("alert")).textContent).toBe("Earlier expenses couldn’t be loaded, so no file was made. Try again.");
+ expect(downloadText).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/tests/loading.test.tsx b/apps/web/tests/loading.test.tsx
index 49d4daa3..c3813f46 100644
--- a/apps/web/tests/loading.test.tsx
+++ b/apps/web/tests/loading.test.tsx
@@ -9,6 +9,7 @@ import type { Commune, Snapshot } from "../src/live/model";
import { hubFromSnapshot } from "../src/live/model";
import { todayIso, type OperationItem } from "../src/data/operations";
import { HUBS } from "../src/data/household";
+import { coverSource } from "../src/data/covers";
import { UNCERTAIN_RETRY_SAFE } from "../src/data/commands";
vi.mock("../src/live/api", async (importOriginal) => ({
@@ -127,7 +128,7 @@ describe("commune loading", () => {
await screen.findByRole("heading", { name: commune.name });
expect(screen.getByRole("region", { name: "Pinned notes" }).textContent).toContain(note.title);
expect(screen.getByRole("checkbox", { name: "Mark Take recycling out done" })).toBeTruthy();
- expect(document.querySelector(".c-hub-cover")?.getAttribute("src")).toBe(HUBS.park.photo);
+ expect(document.querySelector(".c-hub-cover")?.getAttribute("src")).toBe(coverSource({ type: "Household", photo: HUBS.park.photo }, "hero").src);
expect(document.querySelector(".c-hub")?.textContent).not.toContain("↗");
expect(within(screen.getByRole("region", { name: "Your balance" })).getByRole("button", { name: "Add expense" })).toBeTruthy();
expect(within(screen.getByRole("region", { name: "Your balance" })).getByRole("button", { name: "View settlement" })).toBeTruthy();
diff --git a/apps/web/tests/money-lifecycle.test.tsx b/apps/web/tests/money-lifecycle.test.tsx
index c1c6f4a8..8878dc31 100644
--- a/apps/web/tests/money-lifecycle.test.tsx
+++ b/apps/web/tests/money-lifecycle.test.tsx
@@ -687,7 +687,9 @@ describe("safe expense correction and void", () => {
act(() => app.go("expenses"));
expect(await screen.findByText("1 expense · £24.00 total recorded in this view")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Voided" }));
- expect(screen.getByText("1 voided expense · kept for history, not counted in any balance")).toBeTruthy();
+ // It was recorded in January; earlier months show on request.
+ fireEvent.click(screen.getByRole("button", { name: "Show earlier" }));
+ expect(await screen.findByText("1 voided expense · kept for history, not counted in any balance")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /House supplies/ }));
const windowFrame = await screen.findByRole("dialog", { name: "House supplies" });
expect(within(windowFrame).getByText("Voided by Test Person on 8 Jan 2026: Entered twice")).toBeTruthy();
@@ -720,7 +722,8 @@ describe("review fixes: voided ledger, lost replies, locks and Escape", () => {
expect(await screen.findByRole("button", { name: "Voided" })).toBeTruthy();
expect(screen.queryByText("No expenses yet.")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Voided" }));
- expect(screen.getByRole("button", { name: /House supplies/ })).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Show earlier" }));
+ expect(await screen.findByRole("button", { name: /House supplies/ })).toBeTruthy();
});
it("tells an admin who did not pay to ask the payer to reverse, not to reverse themselves", async () => {
diff --git a/apps/web/tests/neon-auth.test.tsx b/apps/web/tests/neon-auth.test.tsx
index c1e4d157..a569e093 100644
--- a/apps/web/tests/neon-auth.test.tsx
+++ b/apps/web/tests/neon-auth.test.tsx
@@ -14,40 +14,46 @@ beforeEach(() => {
});
describe("hosted read authentication", () => {
- function listResults(communes: unknown, members: unknown, error: unknown = null) {
+ // The list, each role and member count and each change mark come from one
+ // database function, read together.
+ const listed = { id: "commune-1", name: "Home", kind: "household", currency: "GBP", address: "", destination: "",
+ stayName: "", stayAddress: "", role: "admin", memberCount: 2, changeMark: "2026-10-09:3:41" };
+ function listResult(data: unknown, error: unknown = null) {
mocks.getSession.mockResolvedValue({ data: { user: { id: "viewer", emailVerified: true } } });
- mocks.from.mockImplementation((table: string) => {
- const result = Promise.resolve({ data: table === "communes" ? communes : members, error });
- return { select: () => Object.assign(result, { order: () => result, eq: () => result }) };
- });
+ mocks.rpc.mockResolvedValue({ data, error });
}
it("reports missing database identity as authentication failure, not an empty list", async () => {
- listResults(null, null, { code: "28000", message: "Request identity could not be verified" });
+ listResult(null, { code: "28000", message: "Request identity could not be verified" });
const { listCommunes } = await import("../src/live/neon");
await expect(listCommunes()).rejects.toMatchObject({ status: 401 });
});
it("does not turn malformed database responses into No communes yet", async () => {
- listResults(null, []);
+ listResult(null);
const { listCommunes } = await import("../src/live/neon");
await expect(listCommunes()).rejects.toMatchObject({ status: 502 });
});
- it("rejects inconsistent membership reads instead of displaying an empty account", async () => {
- listResults([], [{ commune_id: "commune-1", user_id: "viewer", role: "admin" }]);
+ it("reads communes, roles, member counts and change marks in one consistent call", async () => {
+ listResult([listed]);
const { listCommunes } = await import("../src/live/neon");
- await expect(listCommunes()).rejects.toMatchObject({ status: 409 });
+ expect(await listCommunes()).toEqual([{ id: "commune-1", name: "Home", kind: "household", currency: "GBP", role: "admin",
+ memberCount: 2, address: "", destination: "", stayName: "", stayAddress: "", changeMark: "2026-10-09:3:41" }]);
+ expect(mocks.rpc).toHaveBeenCalledExactlyOnceWith("my_communes");
+ expect(mocks.from).not.toHaveBeenCalled();
});
- it("does not publish a commune without the viewer's membership", async () => {
- listResults([{ id: "commune-1", name: "Home" }], []);
+ it("does not publish a commune without the viewer's membership or its change mark", async () => {
const { listCommunes } = await import("../src/live/neon");
- await expect(listCommunes()).rejects.toMatchObject({ status: 409 });
+ for (const entry of [{ id: "commune-1", name: "Home" }, { ...listed, role: "owner" }, { ...listed, changeMark: null }]) {
+ listResult([entry]);
+ await expect(listCommunes()).rejects.toMatchObject({ status: 409 });
+ }
});
it("still accepts a genuinely empty new account", async () => {
- listResults([], []);
+ listResult([]);
const { listCommunes } = await import("../src/live/neon");
expect(await listCommunes()).toEqual([]);
});
@@ -68,6 +74,18 @@ describe("hosted read authentication", () => {
expect(mocks.rpc).toHaveBeenCalledExactlyOnceWith("commune_snapshot",{p_commune_id:commune.id});
expect(mocks.from).not.toHaveBeenCalled();
});
+ it("reads an overview summary through its own RPC and never takes one for the full record", async () => {
+ mocks.getSession.mockResolvedValue({ data: { user: { id: "viewer", emailVerified: true } } });
+ const data = { commune, members: [], expenses: [], funds: [], reminderTargets: [], notifications: [], detail: "summary" };
+ mocks.rpc.mockResolvedValue({ data, error: null });
+ const { snapshot, neonApi } = await import("../src/live/neon");
+ expect(await snapshot(commune.id, commune, "summary")).toEqual(data);
+ expect(mocks.rpc).toHaveBeenLastCalledWith("commune_summary", { p_commune_id: commune.id });
+ expect(await neonApi(`/communes/${commune.id}/snapshot?detail=summary`)).toEqual(data);
+ expect(mocks.rpc).toHaveBeenLastCalledWith("commune_summary", { p_commune_id: commune.id });
+ await expect(snapshot(commune.id, commune)).rejects.toMatchObject({ status: 502 });
+ expect(mocks.rpc).toHaveBeenLastCalledWith("commune_snapshot", { p_commune_id: commune.id });
+ });
it("rejects a mis-scoped snapshot instead of rendering another commune's records",async()=>{
mocks.getSession.mockResolvedValue({data:{user:{id:"viewer",emailVerified:true}}});
mocks.rpc.mockResolvedValue({data:{commune:{...commune,id:"other"},members:[],expenses:[],funds:[]},error:null});
@@ -80,7 +98,7 @@ describe("hosted read authentication", () => {
const { listCommunes } = await import("../src/live/neon");
await expect(listCommunes({ id: "old-user", name: "Old", email: "old@example.test", emailVerified: true }))
.rejects.toThrow("Your account changed");
- expect(mocks.from).not.toHaveBeenCalled();
+ expect(mocks.rpc).not.toHaveBeenCalled();
});
it("checks a verified session before requesting reminder usage", async () => {
diff --git a/apps/web/tests/neon-lifecycle-contract.test.tsx b/apps/web/tests/neon-lifecycle-contract.test.tsx
index 05e989ac..755ff06e 100644
--- a/apps/web/tests/neon-lifecycle-contract.test.tsx
+++ b/apps/web/tests/neon-lifecycle-contract.test.tsx
@@ -134,6 +134,32 @@ describe("hosted money lifecycle and task commands", () => {
]);
});
+ it("pages older expenses and fetches named ones in the snapshot's shape", async () => {
+ const expense = { id: "expense-old", title: "Gas", amountMinor: 2000, paidByUserId: "viewer", dueOn: null, shares: [] };
+ const page = { expenses: [expense], before: "2026-09-01", from: "2026-08-01", olderCount: 4, olderVoidedCount: 1 };
+ mocks.rpc.mockResolvedValue({ data: page, error: null });
+ expect(await send("/communes/home-1/expenses/history?before=2026-09-01&months=1", undefined, "GET")).toEqual(page);
+ expect(mocks.rpc).toHaveBeenLastCalledWith("commune_expense_page", { p_commune_id: "home-1", p_before: "2026-09-01", p_months: 1 });
+ // Everything older, from where the snapshot's expenses start, for a statement or an export.
+ mocks.rpc.mockResolvedValue({ data: { ...page, expenses: [], from: null, olderCount: 0, olderVoidedCount: 0 }, error: null });
+ expect((await send("/communes/home-1/expenses/history", undefined, "GET")).from).toBeNull();
+ expect(mocks.rpc).toHaveBeenLastCalledWith("commune_expense_page", { p_commune_id: "home-1", p_before: null, p_months: null });
+ mocks.rpc.mockResolvedValue({ data: { expenses: [expense] }, error: null });
+ expect(await send("/communes/home-1/expenses/records?ids=expense-old,expense-gone", undefined, "GET")).toEqual({ expenses: [expense] });
+ expect(mocks.rpc).toHaveBeenLastCalledWith("commune_expense_records", { p_commune_id: "home-1", p_ids: ["expense-old", "expense-gone"] });
+ // A refusal stays a refusal, and an expense in another shape is not shown.
+ mocks.rpc.mockResolvedValue({ data: null, error: { code: "42501", message: "Not a commune member" } });
+ await expect(send("/communes/home-1/expenses/records?ids=expense-old", undefined, "GET")).rejects.toMatchObject({ status: 403 });
+ mocks.rpc.mockResolvedValue({ data: { ...page, expenses: [{ id: "expense-old" }] }, error: null });
+ await expect(send("/communes/home-1/expenses/history?months=1", undefined, "GET")).rejects.toMatchObject({ status: 502 });
+ const calls = mocks.rpc.mock.calls.length;
+ await expect(send("/communes/home-1/expenses/history?months=13", undefined, "GET")).rejects.toMatchObject({ status: 400 });
+ await expect(send(`/communes/home-1/expenses/records?ids=${Array.from({ length: 51 }, (_, index) => `e${index}`).join(",")}`, undefined, "GET"))
+ .rejects.toMatchObject({ status: 400 });
+ expect(mocks.rpc).toHaveBeenCalledTimes(calls);
+ expect(mocks.from).not.toHaveBeenCalled();
+ });
+
it("completes and undoes a task through occurrence functions, not a row overwrite", async () => {
mocks.rpc.mockResolvedValue({ data: { id: "occurrence-1", duplicate: false, date: "2026-10-09", assignee: "Blake", status: "open" }, error: null });
const done = await send("/operations/task-1/occurrences", { action: "completed", expectedDate: "2026-10-02", today: "2026-10-02", requestId });
diff --git a/apps/web/tests/preload.test.ts b/apps/web/tests/preload.test.ts
new file mode 100644
index 00000000..85b8e490
--- /dev/null
+++ b/apps/web/tests/preload.test.ts
@@ -0,0 +1,54 @@
+// @vitest-environment node
+import { fileURLToPath } from "node:url";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { build, type Rollup } from "vite";
+import { preloadDataClient } from "../vite.config";
+
+type Item = { type: "chunk" | "asset"; fileName: string; isEntry?: boolean; facadeModuleId?: string | null; imports?: string[] };
+type Tag = { tag: string; attrs: Record; injectTo: string };
+
+function preloadTags(items: Item[], base = "/"): Tag[] {
+ const plugin = preloadDataClient();
+ (plugin.configResolved as (config: { base: string }) => void)({ base });
+ const hook = plugin.transformIndexHtml as { handler: (html: string, ctx: { bundle?: Record }) => Tag[] };
+ return hook.handler("", { bundle: Object.fromEntries(items.map((item) => [item.fileName, item])) });
+}
+
+const chunk = (fileName: string, extra: Partial- = {}): Item =>
+ ({ type: "chunk", fileName, isEntry: false, facadeModuleId: null, imports: [], ...extra });
+
+describe("preloading the data client", () => {
+ afterEach(() => vi.unstubAllEnvs());
+
+ it("names the data client and the chunks it shares, but not the main bundle the page already loads", () => {
+ const tags = preloadTags([
+ chunk("assets/index-a.js", { isEntry: true, facadeModuleId: "/app/index.html" }),
+ chunk("assets/neon-b.js", { facadeModuleId: "/app/src/live/neon.ts", imports: ["assets/index-a.js", "assets/shared-c.js"] }),
+ chunk("assets/shared-c.js"),
+ chunk("assets/other-d.js", { facadeModuleId: "/app/src/screens/other.tsx" }),
+ { type: "asset", fileName: "assets/index-e.css" },
+ ], "/app/");
+ expect(tags).toEqual(["assets/neon-b.js", "assets/shared-c.js"].map((file) => ({
+ tag: "link", attrs: { rel: "modulepreload", crossorigin: true, href: `/app/${file}` }, injectTo: "head",
+ })));
+ });
+
+ it("adds nothing to a build without the data client, such as a development build", () => {
+ expect(preloadTags([chunk("assets/index-a.js", { isEntry: true, facadeModuleId: "/app/index.html" })])).toEqual([]);
+ });
+
+ it("is in the index.html of a production build, naming the chunk the app imports", async () => {
+ // Vitest runs with NODE_ENV=test, which would build the local app.
+ vi.stubEnv("NODE_ENV", "production");
+ const root = fileURLToPath(new URL("..", import.meta.url));
+ const result = await build({ root, configFile: `${root}vite.config.ts`, logLevel: "silent", build: { write: false } });
+ const { output } = (Array.isArray(result) ? result[0] : result) as Rollup.RollupOutput;
+ const chunks = output.filter((item): item is Rollup.OutputChunk => item.type === "chunk");
+ const entry = chunks.find((item) => item.isEntry);
+ const client = chunks.find((item) => item.facadeModuleId?.endsWith("/src/live/neon.ts"));
+ expect(client).toBeDefined();
+ expect(entry?.dynamicImports).toContain(client!.fileName);
+ const html = output.find((item): item is Rollup.OutputAsset => item.fileName === "index.html");
+ expect(String(html?.source)).toContain(`
`);
+ }, 120_000);
+});
diff --git a/apps/web/tests/security-headers.test.ts b/apps/web/tests/security-headers.test.ts
index ad7963c1..02e31957 100644
--- a/apps/web/tests/security-headers.test.ts
+++ b/apps/web/tests/security-headers.test.ts
@@ -26,6 +26,12 @@ describe("hosted security headers", () => {
expect([...policy.values()].flat()).not.toContain("'unsafe-eval'");
});
+ it("takes fonts and styles only from the app's own files", () => {
+ expect(policy.get("style-src")).toEqual(["'self'"]);
+ expect(policy.get("font-src")).toEqual(["'self'"]);
+ expect(readFileSync(new URL("../index.html", import.meta.url), "utf8")).not.toMatch(/fonts\.(googleapis|gstatic)\.com/);
+ });
+
it("sends nosniff, a referrer policy and a permissions policy", () => {
expect(headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
diff --git a/apps/web/vercel.json b/apps/web/vercel.json
index a5c9fdfc..f8ca1aec 100644
--- a/apps/web/vercel.json
+++ b/apps/web/vercel.json
@@ -6,7 +6,7 @@
"headers": [
{
"key": "Content-Security-Policy",
- "value": "default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://images.unsplash.com; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'"
+ "value": "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self' data: https://images.unsplash.com; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'"
},
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index ba039407..0d8aab54 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -1,6 +1,6 @@
import net from "node:net";
import { fileURLToPath } from "node:url";
-import { defineConfig, loadEnv, type Plugin } from "vite";
+import { defineConfig, loadEnv, type Plugin, type Rollup } from "vite";
import react from "@vitejs/plugin-react";
const PORT = 3000;
@@ -55,12 +55,49 @@ export function exactConnectPolicy(env: Record): Plu
};
}
+/**
+ * The production data client (src/live/neon.ts and the Neon SDK) is a chunk
+ * of its own that src/live/api.ts imports on first use. Every visit needs it
+ * at once to check the session, so the page starts downloading it alongside
+ * the main bundle rather than after the main bundle has run.
+ */
+export function preloadDataClient(): Plugin {
+ let base = "/";
+ return {
+ name: "preload-data-client",
+ apply: "build",
+ configResolved(config) {
+ base = config.base;
+ },
+ transformIndexHtml: {
+ order: "post",
+ handler(_html, ctx) {
+ const chunks = Object.values(ctx.bundle ?? {}).filter((item): item is Rollup.OutputChunk => item.type === "chunk");
+ const client = chunks.find((chunk) => !chunk.isEntry && chunk.facadeModuleId?.endsWith("/src/live/neon.ts"));
+ if (!client) return [];
+ const entries = new Set(chunks.filter((chunk) => chunk.isEntry).map((chunk) => chunk.fileName));
+ return [client.fileName, ...client.imports.filter((file) => !entries.has(file))].map((file) => ({
+ tag: "link",
+ attrs: { rel: "modulepreload", crossorigin: true, href: `${base}${file}` },
+ injectTo: "head" as const,
+ }));
+ },
+ },
+ };
+}
+
export default defineConfig(({ mode }) => ({
plugins: [
react(),
listenIpv6Too(),
exactConnectPolicy(loadEnv(mode, fileURLToPath(new URL(".", import.meta.url)), "VITE_")),
+ preloadDataClient(),
],
+ build: {
+ // The hosted policy allows fonts only from the app's own origin
+ // (font-src 'self'), so a small font must never become a data: URL.
+ assetsInlineLimit: (file: string) => (file.endsWith(".woff2") ? false : undefined),
+ },
server: {
host: "127.0.0.1",
port: PORT,
diff --git a/docs/DESIGN-LANGUAGE.md b/docs/DESIGN-LANGUAGE.md
index ebaabf91..54eb30db 100644
--- a/docs/DESIGN-LANGUAGE.md
+++ b/docs/DESIGN-LANGUAGE.md
@@ -26,8 +26,8 @@ date ranges and user-written content. Keep the wording plain and concise.
| Rules | Thin neutral borders; boundaries rather than decoration |
| Money icon | Slate #637689 on #edf0f3 when a category chip is useful |
| Operations icon | Sage on pale green; status must also have a text label |
-| Interface typography | Existing Inter stack; do not swap the entire app to the wordmark font |
-| Wordmark | Existing lowercase DM Sans wordmark, position and treatment retained |
+| Interface typography | Existing Inter stack, served from the app with every subset Google served (`apps/web/src/styles/fonts`); do not swap the entire app to the wordmark font |
+| Wordmark | Existing lowercase DM Sans wordmark, position and treatment retained. Its font file holds only the letters of "commune.", so new wordmark text needs a new subset (see `fonts.css`) |
| Type weights | Body 400; row names and buttons 500; section headings 550–600; avoid default 700/800 headings |
| Type sizes | Body 14px, secondary 12–13px, sections 18px, page titles 21px, hero 32–35px, principal money 36–44px |
| Numbers | Tabular numerals; 450–500 weight on large totals; labels must explain whose money and what state |
@@ -36,6 +36,7 @@ date ranges and user-written content. Keep the wording plain and concise.
| Header | Existing 60px desktop header; content aligned to the page gutter |
| Corners | One shared token family: controls/search/icon actions 9px; cards/hero/dialogs 15px. Pills only for statuses, filter tracks and avatars; inset segments follow their track geometry |
| Elevation | No default card shadows. Overlay elevation only when it communicates layering |
+| Cover tones | `--cover-household` #3d5a4c, `--cover-workspace` #3f4b5a, `--cover-trip` #6a4b3c: behind a commune's cover photo while it loads, and in its place if it cannot load |
Use semantic tokens with explicit dark-mode counterparts when consolidating CSS. Do not flatten dark mode with hard-coded light overrides. Normal text must reach 4.5:1 contrast; control boundaries/focus must remain discernible. Small metadata must not rely on very low opacity.
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index 2382a137..ee4daae0 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -48,6 +48,23 @@ lifecycle pack. Work is paused for a handoff, not a production release.
commune's currency once it has an expense, fund, recurring bill or exact
split template (migration 0035); statements open for former members; and
money dialogs no longer say nothing was saved after an unsure attempt.
+- Also from 9 October, for review: speed fixes from the same review. An open
+ tab checks a small commune list each minute, only while someone is using
+ it, and reads a commune again only when something in it changed (migration
+ 0036). A snapshot carries open and recent expenses and the five newest;
+ older ones load on Show earlier, and statements and exports load them all
+ first. The commune list no longer slows as the platform grows, and an
+ account can create at most 10 communes an hour, 30 a day and 100 in all.
+ Deploy the client straight after 0036: see
+ [infra/neon/README.md](../infra/neon/README.md).
+- Also from 9 October, for review: fonts, cover photos and start-up loading
+ from the same review. Inter and the wordmark font are served from the app
+ (`apps/web/src/styles/fonts`, the OFL licence beside them), so the hosted
+ policy allows fonts and styles only from the app's own origin. Default cover
+ photos still come from Unsplash, at the width each placement needs
+ (`apps/web/src/data/covers.ts`), on a calm tone for the commune type while
+ they load or if they fail; bundling them needs the three original files from
+ Edem. Production builds preload the data client chunk (`vite.config.ts`).
- The website is `web/` (the editorial homepage); the earlier v2 site is in
`web-backup/`. Launch names `website` (3004) and `website-backup` (3003).
- No push, deployment, hosted migration or provider change has been made. Do
@@ -161,7 +178,7 @@ no further copy-cleanup pass is needed for the handoff.
Read [the product-depth roadmap](PRODUCT-DEPTH-ROADMAP-2026-10-02.md) and the
Remaining section of [money lifecycle](MONEY-LIFECYCLE-2026-10-02.md).
-1. **Hosted parity.** Migrations 0013 to 0035, schema-cache refresh, the
+1. **Hosted parity.** Migrations 0013 to 0036, schema-cache refresh, the
matching web client and sender, auth, invitations and real email delivery
are all unverified on Neon. Apply migrations before deploying the client.
Measure cold/warm/idle loading with separate accounts. Do not declare
diff --git a/docs/HOSTED-RELEASE-CHECKLIST.md b/docs/HOSTED-RELEASE-CHECKLIST.md
index 73198add..b19408a2 100644
--- a/docs/HOSTED-RELEASE-CHECKLIST.md
+++ b/docs/HOSTED-RELEASE-CHECKLIST.md
@@ -50,7 +50,10 @@ occurrence command as before).
rotation; a member leaving with tasks assigned.
7. **Preview deploy** of the web client against the branch. Walk Settle,
Recurring, Notes, Operations, Members and commune settings in light and
- dark, at phone and desktop widths.
+ dark, at phone and desktop widths. The policy in `apps/web/vercel.json` no
+ longer allows fonts.googleapis.com or fonts.gstatic.com, because the app
+ serves its own fonts; `img-src` keeps images.unsplash.com until the default
+ covers are bundled. Check the console for refused fonts or images.
8. **Production.** Repeat 2 to 5 on production in a quiet window, then deploy
the web client. Keep the step 1 backup until the release has run cleanly
for a week.
diff --git a/infra/local/README.md b/infra/local/README.md
index 46b4e0bc..1589f003 100644
--- a/infra/local/README.md
+++ b/infra/local/README.md
@@ -40,7 +40,8 @@ files in order using a database owner connection:
34. `infra/neon/migrations/0033_invitation_email_safety.sql`
35. `infra/neon/migrations/0034_database_hardening.sql`
36. `infra/neon/migrations/0035_currency_rule.sql`
-37. `infra/local/after-core.sql`
+37. `infra/neon/migrations/0036_lighter_loading.sql`
+38. `infra/local/after-core.sql`
`after-core.sql` is idempotent. Re-run it after applying a newer migration to
an existing local database so the local-only reminder helpers stay current.
diff --git a/infra/neon/README.md b/infra/neon/README.md
index b65b5c92..d2be9948 100644
--- a/infra/neon/README.md
+++ b/infra/neon/README.md
@@ -14,8 +14,8 @@ this is not a replacement of Neon Auth or a switch back to Supabase.
- [Current design language and preserved layouts](../../docs/DESIGN-LANGUAGE.md)
- [Local build-out from 5 October: migrations 0020 onwards](../../docs/LOCAL-BUILDOUT-2026-10-05.md)
-Migrations **0015 to 0035 are local only**. They have not been applied to any
-Neon branch. Release order when they are: apply 0013 to 0035 in numeric order
+Migrations **0015 to 0036 are local only**. They have not been applied to any
+Neon branch. Release order when they are: apply 0013 to 0036 in numeric order
to the preview branch, refresh that branch's Data API schema cache, then
deploy the matching web client. The new client calls `send_reminder_command`,
`record_share_receipt`, `reverse_share_receipt`, `correct_expense`,
@@ -70,6 +70,20 @@ Six of the 9 October migrations change that rule:
1.3, pg_trgm 1.6). None of them reads Commune's data; `crypt`,
`gen_random_bytes`, `set_limit` and the rest take parameters such an API
cannot pass.
+- **0036** adds `my_communes`, `commune_summary`, `commune_expense_page` and
+ `commune_expense_records`, which the new client calls, and a change mark
+ per commune, so the client reads a commune again only when something in it
+ changed. The snapshot now carries open expenses, those created or changed
+ since the start of last month (UTC) and the five newest that are not
+ voided. A client from before 0036, including a tab left open, keeps
+ working but sees only those, so its statements and exports leave older
+ expenses out: deploy the new client straight after 0036 and the schema
+ cache refresh. 0036 also limits each account to creating 10 communes an
+ hour, 30 a day and 100 in all (communes it has since left still count),
+ and makes the `communes` and `commune_members` read policies set-based
+ (the same rows stay visible). It replaces `prune_delivery_records()` in
+ place, so the worker needs no redeploy; each run also deletes change rows
+ older than two days.
0026 replaces `save_recurring_bill` and adds `set_recurring_bill_paused` and
`set_recurring_bill_archived`; production has no recurring bills yet, so only
@@ -238,7 +252,7 @@ rate limits) are recorded in [`docs/AUTH-SETTINGS.md`](../../docs/AUTH-SETTINGS.
Broad grants would let direct writes bypass ledger invariants. Keep preview
branches separate from production.
3. Apply `migrations/0001_commune_core.sql` through
- `migrations/0035_currency_rule.sql` in numeric order to the
+ `migrations/0036_lighter_loading.sql` in numeric order to the
development branch via an **unpooled** connection. Run `tests/grants.sql`,
review RLS, and run two-account signed-JWT tests before applying the same
reviewed migrations to production. After a migration adds or changes a
diff --git a/infra/neon/migrations/0036_lighter_loading.sql b/infra/neon/migrations/0036_lighter_loading.sql
new file mode 100644
index 00000000..cf6706c6
--- /dev/null
+++ b/infra/neon/migrations/0036_lighter_loading.sql
@@ -0,0 +1,392 @@
+-- Lighter loading. The app read every commune's whole snapshot once a minute
+-- in every open tab, and a snapshot carried every expense ever recorded with
+-- its repayments, reports and corrections: about 1.8 MB for a busy household's
+-- year. The commune list read two tables whose row checks ran once per row,
+-- so it slowed as the platform grew, and anyone could create communes without
+-- limit.
+-- 1. Change marks. Every write that a snapshot, the commune list or an
+-- invitation list shows adds a row to commune_private.commune_changes, at
+-- most one per commune per transaction. A commune's mark summarises its
+-- recent rows; the app compares marks (one small request) and reads a
+-- snapshot only when its mark has moved. The worker's scheduled run prunes
+-- rows older than 2 days.
+-- 2. public.my_communes() is the commune list with each commune's mark. The
+-- communes and commune_members row checks look the caller's communes up
+-- once per query. create_commune refuses an 11th commune in an hour, a 31st
+-- in a day and a 101st in all.
+-- 3. The snapshot carries the expenses still open, those created or changed
+-- since the start of last month (UTC) and the five newest that are not
+-- voided, plus expenseHistory, which says from when and how many older
+-- ones were left out. Older ones come a month or more at a time from
+-- public.commune_expense_page, and named ones from
+-- public.commune_expense_records, in the same shape.
+-- The web app released with this needs it applied first. Apply after 0035.
+BEGIN;
+
+-- 1. Change marks --------------------------------------------------------
+
+-- Rows are only ever inserted. A shared counter row would make every write in
+-- a commune wait for the one before it, and with the commune lock that money
+-- commands take first it could deadlock. No foreign key either: one would take
+-- a key-share lock on the commune row from every write.
+CREATE TABLE commune_private.commune_changes (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ commune_id uuid NOT NULL,
+ changed_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX commune_changes_recent_idx ON commune_private.commune_changes (commune_id, changed_at) INCLUDE (id);
+CREATE INDEX commune_changes_age_idx ON commune_private.commune_changes (changed_at);
+ALTER TABLE commune_private.commune_changes ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON commune_private.commune_changes FROM PUBLIC, authenticated;
+
+-- The trigger's argument names the column that leads to the commune. The
+-- communes already noted in this transaction are kept in a transaction-local
+-- setting, so a command that writes twenty rows adds one change row. A
+-- subtransaction that rolls back takes its change row and its note with it.
+CREATE FUNCTION commune_private.note_commune_change()
+RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $$
+DECLARE
+ v_key uuid := (to_jsonb(CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END) ->> TG_ARGV[0])::uuid;
+ v_noted text := coalesce(current_setting('commune.changed_communes', true), '');
+ v_commune uuid;
+BEGIN
+ v_commune := CASE TG_ARGV[0]
+ WHEN 'id' THEN v_key
+ WHEN 'commune_id' THEN v_key
+ WHEN 'expense_id' THEN (SELECT e.commune_id FROM public.expenses e WHERE e.id = v_key)
+ WHEN 'share_id' THEN (SELECT e.commune_id FROM public.expense_shares s
+ JOIN public.expenses e ON e.id = s.expense_id WHERE s.id = v_key)
+ WHEN 'fund_id' THEN (SELECT f.commune_id FROM public.funds f WHERE f.id = v_key)
+ WHEN 'commitment_id' THEN (SELECT f.commune_id FROM public.fund_commitments c
+ JOIN public.funds f ON f.id = c.fund_id WHERE c.id = v_key)
+ WHEN 'reminder_id' THEN (SELECT r.commune_id FROM public.reminders r WHERE r.id = v_key)
+ WHEN 'announcement_id' THEN (SELECT a.commune_id FROM public.commune_announcements a WHERE a.id = v_key)
+ WHEN 'bill_id' THEN (SELECT b.commune_id FROM public.recurring_bills b WHERE b.id = v_key)
+ END;
+ -- A row whose parent went first, as in a cascading delete, was noted with
+ -- its parent.
+ IF v_commune IS NULL OR strpos(v_noted, v_commune::text) > 0 THEN
+ RETURN NULL;
+ END IF;
+ INSERT INTO commune_private.commune_changes (commune_id) VALUES (v_commune);
+ PERFORM set_config('commune.changed_communes', v_noted || v_commune::text || ' ', true);
+ RETURN NULL;
+END;
+$$;
+REVOKE ALL ON FUNCTION commune_private.note_commune_change() FROM PUBLIC;
+
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.communes
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.commune_members
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.commune_invites
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.expenses
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.expense_shares
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('expense_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.expense_share_receipts
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('share_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.expense_share_payment_reports
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('share_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.expense_revisions
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('expense_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.funds
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.fund_commitments
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('fund_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.fund_contributions
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commitment_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.fund_pledge_requests
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commitment_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.notifications
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.reminders
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id');
+-- A sent reminder shows whether an email may have gone (reminder_email_state),
+-- which only the status and the error decide. Trimming old payloads is not a
+-- change anyone sees.
+CREATE TRIGGER change_mark AFTER INSERT OR DELETE OR UPDATE OF status, last_error ON public.reminder_email_outbox
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('reminder_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.commune_notes
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.note_revisions
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.commune_operations
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.operation_occurrences
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.commune_announcements
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.announcement_acknowledgements
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('announcement_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.recurring_bills
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.recurring_bill_occurrences
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('bill_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.split_templates
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id');
+CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.commune_events
+ FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id');
+
+-- A commune's mark: today's date (UTC), then the count and the highest id of
+-- its change rows since the start of yesterday (UTC). Ids are handed out when
+-- a row is inserted, not when it commits, so a write can commit with a lower
+-- id than one already seen; the count still moves. Within a day rows only
+-- join the window, so the count moves with every commit, and the window
+-- reaches back a day so that a write which began just before midnight and
+-- committed after it still counts. The date makes every mark move once a day,
+-- which also refreshes what the snapshot works out from the date (reminders
+-- that are due, the expense window). Pruning after 2 days never touches it.
+CREATE FUNCTION commune_private.change_mark(p_commune_id uuid)
+RETURNS text LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$
+ SELECT to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD') || ':' || count(*) || ':' || coalesce(max(c.id), 0)
+ FROM commune_private.commune_changes c
+ WHERE c.commune_id = p_commune_id
+ AND c.changed_at >= (date_trunc('day', now() AT TIME ZONE 'UTC') - interval '1 day') AT TIME ZONE 'UTC';
+$$;
+REVOKE ALL ON FUNCTION commune_private.change_mark(uuid) FROM PUBLIC;
+
+-- Only the worker calls this, as the database owner.
+CREATE OR REPLACE FUNCTION commune_private.prune_delivery_records()
+RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $$
+DECLARE v_payloads integer; v_invitations integer; v_changes integer;
+BEGIN
+ UPDATE public.reminder_email_outbox SET payload = NULL, payload_amount_minor = NULL
+ WHERE payload IS NOT NULL AND created_at < now() - interval '30 days';
+ GET DIAGNOSTICS v_payloads = ROW_COUNT;
+ DELETE FROM public.invitation_email_deliveries
+ WHERE created_at < now() - interval '30 days' AND status IN ('sent', 'failed', 'sending');
+ GET DIAGNOSTICS v_invitations = ROW_COUNT;
+ -- Marks look back to the start of yesterday at most.
+ DELETE FROM commune_private.commune_changes WHERE changed_at < now() - interval '2 days';
+ GET DIAGNOSTICS v_changes = ROW_COUNT;
+ RETURN jsonb_build_object('reminderPayloads', v_payloads, 'invitationEmails', v_invitations, 'changeMarks', v_changes);
+END;
+$$;
+REVOKE ALL ON FUNCTION commune_private.prune_delivery_records() FROM PUBLIC;
+
+-- 2. The commune list and creating communes --------------------------------
+
+-- The caller's communes, looked up once per query by the row checks below.
+-- The row checks run as the caller, so it may run this helper; the Data API
+-- offers only public, so it is not an RPC.
+CREATE FUNCTION commune_private.my_commune_ids()
+RETURNS uuid[] LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$
+ SELECT coalesce(array_agg(m.commune_id), '{}') FROM public.commune_members m
+ WHERE m.user_id = commune_private.data_api_user_id() AND m.active;
+$$;
+REVOKE ALL ON FUNCTION commune_private.my_commune_ids() FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION commune_private.my_commune_ids() TO authenticated;
+
+ALTER POLICY commune_read ON public.communes
+ USING (id = ANY ((SELECT commune_private.my_commune_ids())::uuid[]));
+ALTER POLICY member_read ON public.commune_members
+ USING (commune_id = ANY ((SELECT commune_private.my_commune_ids())::uuid[]));
+
+CREATE FUNCTION public.my_communes()
+RETURNS jsonb LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$
+ SELECT coalesce(jsonb_agg(jsonb_build_object('id', c.id, 'name', c.name, 'kind', c.kind, 'currency', c.currency,
+ 'address', c.address, 'destination', c.destination, 'stayName', c.stay_name, 'stayAddress', c.stay_address,
+ 'role', m.role, 'memberCount', (SELECT count(*) FROM public.commune_members cm WHERE cm.commune_id = c.id AND cm.active),
+ 'changeMark', commune_private.change_mark(c.id)) ORDER BY c.created_at DESC, c.id), '[]'::jsonb)
+ FROM public.commune_members m JOIN public.communes c ON c.id = m.commune_id
+ WHERE m.user_id = commune_private.data_api_user_id() AND m.active;
+$$;
+REVOKE ALL ON FUNCTION public.my_communes() FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.my_communes() TO authenticated;
+
+CREATE INDEX communes_created_by_idx ON public.communes (created_by, created_at);
+
+-- One person's creations are counted one at a time, so two at once cannot
+-- both slip under a limit.
+DO $$
+DECLARE v_original text; v_source text;
+BEGIN
+ v_original := pg_get_functiondef('public.create_commune(text,text,text)'::regprocedure);
+ v_source := replace(v_original, ' INSERT INTO public.communes (name, kind, currency, created_by)',
+ $patch$ PERFORM pg_advisory_xact_lock(hashtextextended('commune-create:' || v_user, 0));
+ IF (SELECT count(*) FROM public.communes WHERE created_by = v_user AND created_at > now() - interval '1 hour') >= 10 THEN
+ RAISE EXCEPTION 'You have created 10 communes in the last hour. Try again later.' USING ERRCODE = 'P0001';
+ END IF;
+ IF (SELECT count(*) FROM public.communes WHERE created_by = v_user AND created_at > now() - interval '1 day') >= 30 THEN
+ RAISE EXCEPTION 'You have created 30 communes in the last day. Try again tomorrow.' USING ERRCODE = 'P0001';
+ END IF;
+ IF (SELECT count(*) FROM public.communes WHERE created_by = v_user) >= 100 THEN
+ RAISE EXCEPTION 'You have created 100 communes, which is the most one account can create.' USING ERRCODE = 'P0001';
+ END IF;
+ INSERT INTO public.communes (name, kind, currency, created_by)$patch$);
+ IF v_source = v_original THEN RAISE EXCEPTION 'Unsupported definition of public.create_commune'; END IF;
+ EXECUTE v_source;
+END;
+$$;
+
+-- 3. A bounded snapshot and paged expense history ---------------------------
+
+-- Where a snapshot's expense history starts: the first day of last month, UTC.
+CREATE FUNCTION commune_private.expense_window_start()
+RETURNS timestamptz LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$
+ SELECT (date_trunc('month', now() AT TIME ZONE 'UTC') - interval '1 month') AT TIME ZONE 'UTC';
+$$;
+REVOKE ALL ON FUNCTION commune_private.expense_window_start() FROM PUBLIC;
+
+-- The expenses a snapshot carries: every open one (not voided, a share still
+-- owed), every one created, corrected or voided since the window start or
+-- with a share settled since then, and the five newest that are not voided,
+-- so a commune that has gone quiet still shows its latest. A repayment, its
+-- reversal and a payment report either leave a share owed or settle it, and
+-- a correction or void sets updated_at, so these cover them all. Ties on
+-- created_at go by id, so every caller leaves out the same ones. The
+-- commune's expenses are read through expenses_commune_idx and each one's
+-- shares through their (expense_id, user_id) key.
+CREATE FUNCTION commune_private.snapshot_expense_ids(p_commune_id uuid, p_since timestamptz)
+RETURNS SETOF uuid LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$
+ SELECT e.id FROM public.expenses e
+ WHERE e.commune_id = p_commune_id
+ AND (e.created_at >= p_since OR e.updated_at >= p_since
+ OR EXISTS (SELECT 1 FROM public.expense_shares s WHERE s.expense_id = e.id
+ AND ((e.voided_at IS NULL AND s.settled_minor < s.amount_minor) OR s.settled_at >= p_since)))
+ UNION
+ (SELECT e.id FROM public.expenses e WHERE e.commune_id = p_commune_id AND e.voided_at IS NULL
+ ORDER BY e.created_at DESC, e.id DESC LIMIT 5);
+$$;
+REVOKE ALL ON FUNCTION commune_private.snapshot_expense_ids(uuid, timestamptz) FROM PUBLIC;
+
+-- Every path builds an expense the same way, newest first.
+CREATE FUNCTION commune_private.expense_records(p_ids uuid[])
+RETURNS jsonb LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$
+ SELECT coalesce(jsonb_agg(jsonb_build_object('id',e.id,'title',e.title,'category',e.category,'amountMinor',e.amount_minor,'paidByUserId',e.paid_by_user_id,'recordedBy',e.recorded_by,'dueOn',e.due_on,'createdAt',e.created_at,
+ 'version',e.version,'updatedAt',e.updated_at,'voidedAt',e.voided_at,'voidedBy',e.voided_by,'voidReason',e.void_reason,
+ 'recurring',EXISTS(SELECT 1 FROM public.recurring_bill_occurrences o WHERE o.expense_id=e.id),
+ 'revisions',coalesce((SELECT jsonb_agg(jsonb_build_object('id',v.id,'version',v.version,'action',v.action,'actorId',v.actor_id,'reason',v.reason,'before',v.before,'after',v.after,'createdAt',v.created_at) ORDER BY v.version DESC)
+ FROM (SELECT * FROM public.expense_revisions rv WHERE rv.expense_id=e.id ORDER BY rv.version DESC LIMIT 20) v),'[]'::jsonb),
+ 'shares',coalesce((SELECT jsonb_agg(jsonb_build_object('id',s.id,'userId',s.user_id,'amountMinor',s.amount_minor,'settledMinor',s.settled_minor,'settledAt',s.settled_at,
+ 'receipts',coalesce((SELECT jsonb_agg(jsonb_build_object('id',r.id,'amountMinor',r.amount_minor,'receivedOn',r.received_on,'reference',r.reference,'recordedBy',r.recorded_by,'recordedAt',r.recorded_at,'origin',r.origin,
+ 'reversedAt',r.reversed_at,'reversedBy',r.reversed_by,'reversalReason',r.reversal_reason) ORDER BY r.recorded_at DESC, r.id) FROM public.expense_share_receipts r WHERE r.share_id=s.id),'[]'::jsonb),
+ 'paymentReports',coalesce((SELECT jsonb_agg(jsonb_build_object('id',p.id,'reportedBy',p.reported_by,'amountMinor',p.amount_minor,'sentOn',p.sent_on,'reference',p.reference,
+ 'reportedAt',p.reported_at,'status',p.status,'resolvedAt',p.resolved_at,'resolvedBy',p.resolved_by,'note',p.resolution_note,'receiptId',p.receipt_id) ORDER BY p.reported_at DESC, p.id)
+ FROM (SELECT * FROM public.expense_share_payment_reports x WHERE x.share_id=s.id ORDER BY x.reported_at DESC, x.id LIMIT 10) p),'[]'::jsonb)) ORDER BY s.user_id) FROM public.expense_shares s WHERE s.expense_id=e.id),'[]'::jsonb)) ORDER BY e.created_at DESC, e.id DESC),'[]'::jsonb)
+ FROM public.expenses e WHERE e.id = ANY (p_ids);
+$$;
+REVOKE ALL ON FUNCTION commune_private.expense_records(uuid[]) FROM PUBLIC;
+
+CREATE OR REPLACE FUNCTION commune_private.snapshot_expenses(p_commune_id uuid)
+RETURNS jsonb LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$
+ SELECT commune_private.expense_records(ARRAY(
+ SELECT commune_private.snapshot_expense_ids(p_commune_id, commune_private.expense_window_start())));
+$$;
+REVOKE ALL ON FUNCTION commune_private.snapshot_expenses(uuid) FROM PUBLIC;
+
+-- From when the snapshot's expenses are complete (every expense created on or
+-- after that day is in it), and how many older ones it leaves out.
+CREATE FUNCTION commune_private.expense_history(p_commune_id uuid)
+RETURNS jsonb LANGUAGE plpgsql STABLE SET search_path = public, pg_temp AS $$
+DECLARE v_since timestamptz := commune_private.expense_window_start();
+BEGIN
+ RETURN (SELECT jsonb_build_object('since', to_char(v_since AT TIME ZONE 'UTC', 'YYYY-MM-DD'),
+ 'olderCount', count(*), 'olderVoidedCount', count(*) FILTER (WHERE e.voided_at IS NOT NULL))
+ FROM public.expenses e
+ WHERE e.commune_id = p_commune_id AND e.created_at < v_since
+ AND e.id NOT IN (SELECT commune_private.snapshot_expense_ids(p_commune_id, v_since)));
+END;
+$$;
+REVOKE ALL ON FUNCTION commune_private.expense_history(uuid) FROM PUBLIC;
+
+-- The snapshot is STABLE, so the mark is read with the same view of the data
+-- as everything else in it.
+DO $$
+DECLARE v_original text; v_source text;
+BEGIN
+ v_original := pg_get_functiondef('public.commune_snapshot(uuid)'::regprocedure);
+ v_source := replace(v_original, $anchor$'expenses',commune_private.snapshot_expenses(co.id),$anchor$,
+ $patch$'expenses',commune_private.snapshot_expenses(co.id),
+ 'expenseHistory',commune_private.expense_history(co.id),'changeMark',commune_private.change_mark(co.id),$patch$);
+ IF v_source = v_original THEN RAISE EXCEPTION 'Unsupported snapshot expenses definition'; END IF;
+ EXECUTE v_source;
+END;
+$$;
+
+-- What the overview screens show of a commune that is not open: the snapshot
+-- with the same keys, less the detail only its own screens use (repayment,
+-- report and correction history, note bodies, task and bill history, notices,
+-- templates, sent reminders and the reader's notices there). detail says so,
+-- and the app never shows a commune's own screens from it.
+CREATE FUNCTION public.commune_summary(p_commune_id uuid)
+RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$
+DECLARE v_full jsonb := public.commune_snapshot(p_commune_id);
+BEGIN
+ RETURN v_full || jsonb_build_object('detail', 'summary',
+ 'expenses', (SELECT coalesce(jsonb_agg(x.item || jsonb_build_object('revisions', '[]'::jsonb,
+ 'shares', (SELECT coalesce(jsonb_agg(y.item || '{"receipts": [], "paymentReports": []}'::jsonb ORDER BY y.ord), '[]'::jsonb)
+ FROM jsonb_array_elements(x.item -> 'shares') WITH ORDINALITY y(item, ord))) ORDER BY x.ord), '[]'::jsonb)
+ FROM jsonb_array_elements(v_full -> 'expenses') WITH ORDINALITY x(item, ord)),
+ 'notes', (SELECT coalesce(jsonb_agg(x.item || '{"body": "", "revisions": []}'::jsonb ORDER BY x.ord), '[]'::jsonb)
+ FROM jsonb_array_elements(v_full -> 'notes') WITH ORDINALITY x(item, ord)),
+ 'operations', (SELECT coalesce(jsonb_agg(x.item || '{"history": []}'::jsonb ORDER BY x.ord), '[]'::jsonb)
+ FROM jsonb_array_elements(v_full -> 'operations') WITH ORDINALITY x(item, ord)),
+ 'recurringBills', (SELECT coalesce(jsonb_agg(x.item || '{"history": []}'::jsonb ORDER BY x.ord), '[]'::jsonb)
+ FROM jsonb_array_elements(v_full -> 'recurringBills') WITH ORDINALITY x(item, ord)),
+ 'announcements', '[]'::jsonb, 'splitTemplates', '[]'::jsonb, 'sentReminders', '[]'::jsonb,
+ 'notifications', '[]'::jsonb);
+END;
+$$;
+REVOKE ALL ON FUNCTION public.commune_summary(uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.commune_summary(uuid) TO authenticated;
+
+-- Expenses the snapshot left out, created in the p_months calendar months
+-- (UTC) before p_before, or all of them before it when p_months is null.
+-- p_before defaults to the snapshot's window start. When those months hold
+-- none, the page reaches back to the month of the newest older one, so a
+-- quiet spell never ends the history early. olderCount is how many older
+-- ones remain; "from" is null when the page reaches back to the beginning.
+CREATE FUNCTION public.commune_expense_page(p_commune_id uuid, p_before date, p_months integer)
+RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$
+DECLARE
+ v_window uuid[];
+ v_before timestamptz;
+ v_from timestamptz;
+ v_newest timestamptz;
+BEGIN
+ IF NOT public.is_commune_member(p_commune_id) THEN RAISE EXCEPTION 'Not a commune member' USING ERRCODE = '42501'; END IF;
+ IF p_months IS NOT NULL AND p_months NOT BETWEEN 1 AND 12 THEN
+ RAISE EXCEPTION 'Ask for 1 to 12 months of expenses at a time' USING ERRCODE = '22023';
+ END IF;
+ v_window := ARRAY(SELECT commune_private.snapshot_expense_ids(p_commune_id, commune_private.expense_window_start()));
+ v_before := date_trunc('month', coalesce(p_before::timestamp, commune_private.expense_window_start() AT TIME ZONE 'UTC')) AT TIME ZONE 'UTC';
+ SELECT max(e.created_at) INTO v_newest FROM public.expenses e
+ WHERE e.commune_id = p_commune_id AND e.created_at < v_before AND NOT (e.id = ANY (v_window));
+ v_from := CASE WHEN p_months IS NULL OR v_newest IS NULL THEN NULL
+ ELSE least((date_trunc('month', v_before AT TIME ZONE 'UTC') - make_interval(months => p_months)) AT TIME ZONE 'UTC',
+ date_trunc('month', v_newest AT TIME ZONE 'UTC') AT TIME ZONE 'UTC') END;
+ RETURN jsonb_build_object(
+ 'expenses', commune_private.expense_records(ARRAY(SELECT e.id FROM public.expenses e
+ WHERE e.commune_id = p_commune_id AND e.created_at < v_before AND e.created_at >= coalesce(v_from, '-infinity')
+ AND NOT (e.id = ANY (v_window)))),
+ 'before', to_char(v_before AT TIME ZONE 'UTC', 'YYYY-MM-DD'),
+ 'from', to_char(v_from AT TIME ZONE 'UTC', 'YYYY-MM-DD'))
+ || (SELECT jsonb_build_object('olderCount', count(*), 'olderVoidedCount', count(*) FILTER (WHERE e.voided_at IS NOT NULL))
+ FROM public.expenses e
+ WHERE e.commune_id = p_commune_id AND e.created_at < coalesce(v_from, '-infinity') AND NOT (e.id = ANY (v_window)));
+END;
+$$;
+REVOKE ALL ON FUNCTION public.commune_expense_page(uuid, date, integer) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.commune_expense_page(uuid, date, integer) TO authenticated;
+
+-- Named expenses of one commune, for a link to an expense that is not loaded.
+-- An id from elsewhere is left out, as is one that does not exist.
+CREATE FUNCTION public.commune_expense_records(p_commune_id uuid, p_ids uuid[])
+RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$
+BEGIN
+ IF NOT public.is_commune_member(p_commune_id) THEN RAISE EXCEPTION 'Not a commune member' USING ERRCODE = '42501'; END IF;
+ IF p_ids IS NULL OR cardinality(p_ids) NOT BETWEEN 1 AND 50 THEN
+ RAISE EXCEPTION 'Ask for 1 to 50 expenses at a time' USING ERRCODE = '22023';
+ END IF;
+ RETURN jsonb_build_object('expenses', commune_private.expense_records(ARRAY(SELECT e.id FROM public.expenses e
+ WHERE e.commune_id = p_commune_id AND e.id = ANY (p_ids))));
+END;
+$$;
+REVOKE ALL ON FUNCTION public.commune_expense_records(uuid, uuid[]) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.commune_expense_records(uuid, uuid[]) TO authenticated;
+
+NOTIFY pgrst, 'reload schema';
+COMMIT;
diff --git a/infra/neon/tests/change_mark_order.mjs b/infra/neon/tests/change_mark_order.mjs
new file mode 100644
index 00000000..065f462a
--- /dev/null
+++ b/infra/neon/tests/change_mark_order.mjs
@@ -0,0 +1,102 @@
+// Change marks when two writes commit out of order (0036). A change row's id
+// is handed out when the row is inserted, not when its transaction commits,
+// so a write that started first can commit last with the lower id. The app
+// reads a commune's snapshot only when its mark moves, so a mark that went by
+// the highest id alone would miss that write until the next one. This test
+// keeps the first write open while a second one commits, reads the mark,
+// then commits the first and checks that the mark moved again. It also shows
+// that the second write did not wait for the first.
+//
+// Run against a disposable local database with every migration applied, on
+// top of infra/local/bootstrap.sql or infra/neon/tests/local_auth_stub.sql:
+// DATABASE_URL=postgres://commune:commune@127.0.0.1:5432/ node infra/neon/tests/change_mark_order.mjs
+// It adds a commune and removes it again. Never point it at a shared or
+// hosted database.
+import assert from "node:assert/strict";
+import { randomUUID } from "node:crypto";
+import { createRequire } from "node:module";
+
+if (!process.env.DATABASE_URL?.includes("127.0.0.1")) {
+ throw new Error("The change mark order test requires a local 127.0.0.1 DATABASE_URL");
+}
+// The pg driver comes from the local API's dependencies.
+const { Client } = createRequire(new URL("../../../apps/local-api/package.json", import.meta.url))("pg");
+
+const avery = randomUUID(), blake = randomUUID(), casey = randomUUID();
+const connect = async () => {
+ const client = new Client({ connectionString: process.env.DATABASE_URL });
+ await client.connect();
+ // A wait that never ends fails the test instead of hanging it.
+ await client.query("SET statement_timeout = '20s'");
+ return client;
+};
+const owner = await connect();
+
+// One transaction as one person, the way the local API runs a request.
+async function begin(userId) {
+ const client = await connect();
+ await client.query("BEGIN");
+ await client.query("SELECT set_config('request.jwt.sub', $1, true)", [userId]);
+ await client.query("SELECT set_config('request.jwt.claims', $1, true)", [JSON.stringify({ sub: userId })]);
+ await client.query("SET LOCAL ROLE authenticated");
+ return client;
+}
+async function as(userId, sql, values) {
+ const client = await begin(userId);
+ try {
+ const result = await client.query(sql, values);
+ await client.query("COMMIT");
+ return result.rows[0];
+ } finally {
+ await client.end();
+ }
+}
+// The mark the app compares, read the way the app reads it.
+async function mark(commune) {
+ const { list } = await as(avery, "SELECT public.my_communes() AS list");
+ return list.find((entry) => entry.id === commune).changeMark;
+}
+
+const commune = (await as(avery, "SELECT public.create_commune('Mark order', 'household', 'GBP') AS id")).id;
+const sessions = [];
+try {
+ await owner.query("BEGIN");
+ // Fixture members skip the invitation, with Avery as the actor in the history.
+ await owner.query("SELECT set_config('request.jwt.claims', $1, true)", [JSON.stringify({ sub: avery })]);
+ await owner.query("INSERT INTO public.commune_members (commune_id, user_id, role) VALUES ($1, $2, 'member'), ($1, $3, 'member')",
+ [commune, blake, casey]);
+ await owner.query("COMMIT");
+ const notice = (await as(avery, "SELECT public.post_announcement($1, 'Boiler service', 'Friday morning', true, $2) ->> 'id' AS id",
+ [commune, randomUUID()])).id;
+ const before = await mark(commune);
+
+ // Blake confirms the notice first and takes the lower change id, but has not committed.
+ const first = await begin(blake);
+ sessions.push(first);
+ await first.query("SELECT public.acknowledge_announcement($1)", [notice]);
+ // Casey confirms it too and commits straight away: nothing makes her wait for Blake.
+ const second = await begin(casey);
+ sessions.push(second);
+ await second.query("SELECT public.acknowledge_announcement($1)", [notice]);
+ await second.query("COMMIT");
+ const between = await mark(commune);
+ assert.notEqual(between, before, "A committed write did not move the mark");
+
+ await first.query("COMMIT");
+ const after = await mark(commune);
+ assert.notEqual(after, between, "A write that committed after a later one did not move the mark");
+ // The highest id is the same both times; only the count shows Blake's write.
+ assert.equal(after.split(":")[2], between.split(":")[2], `Expected the lower id to commit last: ${between} then ${after}`);
+ assert.equal(Number(after.split(":")[1]), Number(between.split(":")[1]) + 1);
+
+ // The snapshot carries the same mark as the list.
+ const { snapshot } = await as(avery, "SELECT public.commune_snapshot($1) AS snapshot", [commune]);
+ assert.equal(snapshot.changeMark, after);
+ console.log("change mark order passed: a write that commits after a later one still moves the mark");
+} finally {
+ // Ending a session rolls back anything it left open.
+ await Promise.all(sessions.map((client) => client.end().catch(() => undefined)));
+ await owner.query("DELETE FROM public.communes WHERE id = $1", [commune]);
+ await owner.query("DELETE FROM commune_private.commune_changes WHERE commune_id = $1", [commune]);
+ await owner.end();
+}
diff --git a/infra/neon/tests/database_hardening.sql b/infra/neon/tests/database_hardening.sql
index 5111416b..93f90f90 100644
--- a/infra/neon/tests/database_hardening.sql
+++ b/infra/neon/tests/database_hardening.sql
@@ -102,7 +102,8 @@ BEGIN
CASE i.email WHEN 'dee@hardening.test' THEN now() - interval '31 days' ELSE now() END
FROM public.commune_invites i WHERE i.commune_id = v_home;
v_pruned := commune_private.prune_delivery_records();
- IF v_pruned <> '{"reminderPayloads": 1, "invitationEmails": 1}'::jsonb
+ -- Change marks are pruned in the same run (0036, lighter_loading.sql).
+ IF v_pruned - 'changeMarks' <> '{"reminderPayloads": 1, "invitationEmails": 1}'::jsonb
OR (SELECT payload FROM public.reminder_email_outbox WHERE reminder_id = v_reminder_old) IS NOT NULL
OR (SELECT payload_amount_minor FROM public.reminder_email_outbox WHERE reminder_id = v_reminder_old) IS NOT NULL
OR (SELECT status FROM public.reminder_email_outbox WHERE reminder_id = v_reminder_old) <> 'sent'
@@ -112,7 +113,7 @@ BEGIN
OR NOT EXISTS (SELECT 1 FROM public.commune_invites WHERE email = 'dee@hardening.test') THEN
RAISE EXCEPTION 'Old email records were not trimmed as expected: %', v_pruned;
END IF;
- IF commune_private.prune_delivery_records() <> '{"reminderPayloads": 0, "invitationEmails": 0}'::jsonb THEN
+ IF commune_private.prune_delivery_records() - 'changeMarks' <> '{"reminderPayloads": 0, "invitationEmails": 0}'::jsonb THEN
RAISE EXCEPTION 'A second run should find nothing to trim';
END IF;
END;
diff --git a/infra/neon/tests/grants.sql b/infra/neon/tests/grants.sql
index aeeb821d..624eb562 100644
--- a/infra/neon/tests/grants.sql
+++ b/infra/neon/tests/grants.sql
@@ -40,8 +40,9 @@ BEGIN
RAISE EXCEPTION 'Anonymous workflow execution grant';
END IF;
- -- Money-lifecycle, occurrence, announcement, note and task commands (0015-0027): members only,
- -- never anonymous, and the private helpers are not callable by any client role.
+ -- Money-lifecycle, occurrence, announcement, note and task commands (0015-0027) and the
+ -- loading reads (0036): members only, never anonymous, and the private helpers are not
+ -- callable by any client role.
FOREACH table_name IN ARRAY ARRAY[
'public.send_reminder_command(text,uuid,uuid)',
'public.record_share_receipt(uuid,bigint,date,text,uuid)',
@@ -64,7 +65,11 @@ BEGIN
'public.remove_note(uuid)',
'public.create_operation(uuid,text,text,text,date,time,boolean,text,jsonb,text,text,uuid)',
'public.update_operation(uuid,integer,text,text,text,date,time,boolean,text,jsonb,text,text)',
- 'public.remove_operation(uuid)'
+ 'public.remove_operation(uuid)',
+ 'public.my_communes()',
+ 'public.commune_summary(uuid)',
+ 'public.commune_expense_page(uuid,date,integer)',
+ 'public.commune_expense_records(uuid,uuid[])'
] LOOP
IF has_function_privilege('anonymous', table_name, 'EXECUTE')
OR has_function_privilege('public', table_name, 'EXECUTE')
@@ -104,7 +109,13 @@ BEGIN
'commune_private.operation_content(text,text,text,date,time,boolean,text,jsonb,text,text)',
'commune_private.money_correction_reserve(text,text,jsonb)',
'commune_private.prune_delivery_records()',
- 'commune_private.currency_locked_by(uuid)'
+ 'commune_private.currency_locked_by(uuid)',
+ 'commune_private.note_commune_change()',
+ 'commune_private.change_mark(uuid)',
+ 'commune_private.expense_window_start()',
+ 'commune_private.snapshot_expense_ids(uuid,timestamptz)',
+ 'commune_private.expense_records(uuid[])',
+ 'commune_private.expense_history(uuid)'
] LOOP
IF has_function_privilege('authenticated', table_name, 'EXECUTE')
OR has_function_privilege('anonymous', table_name, 'EXECUTE') THEN
diff --git a/infra/neon/tests/lighter_loading.sql b/infra/neon/tests/lighter_loading.sql
new file mode 100644
index 00000000..e2d475dd
--- /dev/null
+++ b/infra/neon/tests/lighter_loading.sql
@@ -0,0 +1,686 @@
+-- Lighter loading (0036): change marks, the commune list and its row checks,
+-- creation limits, the bounded snapshot, paged history and the overview
+-- summary. Run only in an empty disposable local DB built from
+-- infra/neon/tests/local_auth_stub.sql and every migration. Everything is
+-- rolled back. infra/neon/tests/change_mark_order.mjs covers two real
+-- transactions committing in the reverse order of their change rows.
+BEGIN;
+
+CREATE FUNCTION pg_temp.act(p_user text) RETURNS void LANGUAGE plpgsql AS $$
+BEGIN
+ PERFORM set_config('request.jwt.claims', json_build_object('sub', p_user, 'role', 'authenticated')::text, true);
+END;
+$$;
+-- This test runs in one transaction. Forgetting the communes already noted
+-- makes the next write look like the first of a new transaction.
+CREATE FUNCTION pg_temp.next_transaction() RETURNS void LANGUAGE plpgsql AS $$
+BEGIN
+ PERFORM set_config('commune.changed_communes', '', true);
+END;
+$$;
+CREATE FUNCTION pg_temp.changes(p_commune uuid) RETURNS bigint LANGUAGE sql AS $$
+ SELECT count(*) FROM commune_private.commune_changes WHERE commune_id = p_commune;
+$$;
+CREATE FUNCTION pg_temp.refused(p_sql text) RETURNS text LANGUAGE plpgsql AS $$
+BEGIN
+ EXECUTE p_sql;
+ RETURN NULL;
+EXCEPTION WHEN OTHERS THEN
+ RETURN SQLSTATE || ' ' || SQLERRM;
+END;
+$$;
+
+-- Ann and Ben share a home; Cat has her own; Dee used to be in Ann's.
+DO $$
+DECLARE
+ v_home uuid; v_other uuid; v_expense uuid; v_share uuid; v_reported uuid; v_report_share uuid;
+ v_fund jsonb; v_pledge jsonb; v_note jsonb; v_task jsonb; v_notice jsonb; v_bill uuid; v_reminder uuid;
+BEGIN
+ PERFORM pg_temp.act('ll-ann');
+ v_home := public.create_commune('Lighter home', 'household', 'GBP');
+ INSERT INTO public.commune_members (commune_id, user_id, role) VALUES (v_home, 'll-ben', 'member'), (v_home, 'll-dee', 'member');
+ v_expense := public.create_expense(v_home, 'Gas', 2000,
+ '[{"user_id":"ll-ann","amount_minor":1000},{"user_id":"ll-ben","amount_minor":1000}]'::jsonb, current_date);
+ SELECT id INTO v_share FROM public.expense_shares WHERE expense_id = v_expense AND user_id = 'll-ben';
+ PERFORM public.record_share_receipt(v_share, 400, current_date, NULL, gen_random_uuid());
+ PERFORM public.correct_expense(v_expense, 1, 'Gas bill', 2000,
+ '[{"user_id":"ll-ann","amount_minor":1000},{"user_id":"ll-ben","amount_minor":1000}]'::jsonb, current_date,
+ 'll-ann', 'Utilities', 'Clearer title', gen_random_uuid());
+ v_reported := public.create_expense(v_home, 'Water', 2000,
+ '[{"user_id":"ll-ann","amount_minor":1000},{"user_id":"ll-ben","amount_minor":1000}]'::jsonb, current_date);
+ SELECT id INTO v_report_share FROM public.expense_shares WHERE expense_id = v_reported AND user_id = 'll-ben';
+ v_fund := public.create_fund_command(v_home, 'Boiler', 50000, gen_random_uuid());
+ v_pledge := public.pledge_fund_command((v_fund->>'id')::uuid, 5000, current_date + 30, gen_random_uuid());
+ PERFORM public.record_fund_contribution_command((v_pledge->>'id')::uuid, 1000, gen_random_uuid());
+ v_note := public.create_note(v_home, 'Wifi', 'Wi-Fi', 'Everyone', 'Router in the hall', 'Restart it first', '[]'::jsonb, gen_random_uuid());
+ PERFORM public.update_note((v_note->>'id')::uuid, 1, 'Wifi', 'Wi-Fi', 'Everyone', 'Router in the hall', 'Restart it twice', '[]'::jsonb);
+ v_task := public.create_operation(v_home, 'task', 'chore', 'Bins', current_date, NULL, true, 'll-ann', NULL, 'weekly', '', gen_random_uuid());
+ PERFORM public.record_operation_occurrence((v_task->>'id')::uuid, 'completed', current_date, current_date, gen_random_uuid());
+ v_notice := public.post_announcement(v_home, 'Boiler service', 'Friday morning', true, gen_random_uuid());
+ v_bill := public.save_recurring_bill(v_home, 'Rent', 2000, 'll-ann',
+ '[{"user_id":"ll-ann","amount_minor":1000},{"user_id":"ll-ben","amount_minor":1000}]'::jsonb, 'monthly', current_date,
+ NULL, NULL, gen_random_uuid(), NULL);
+ PERFORM public.record_recurring_period(v_bill, current_date);
+ PERFORM public.save_split_template(v_home, 'Halves', 'Equal', '[{"user_id":"ll-ann","value":""},{"user_id":"ll-ben","value":""}]'::jsonb);
+ PERFORM public.create_commune_invite(v_home, 'eve@lighter.test', 'member');
+ INSERT INTO public.reminders (commune_id, kind, source_id, sender_user_id, recipient_user_id, amount_minor)
+ VALUES (v_home, 'nudge', v_share, 'll-ann', 'll-ben', 600) RETURNING id INTO v_reminder;
+ INSERT INTO public.reminder_email_outbox (reminder_id, payload) VALUES (v_reminder, '{"to": ["ben@lighter.test"]}');
+
+ PERFORM pg_temp.act('ll-ben');
+ PERFORM public.report_share_payment(v_report_share, 1000, current_date, NULL, gen_random_uuid());
+ PERFORM public.acknowledge_announcement((v_notice->>'id')::uuid);
+
+ PERFORM pg_temp.act('ll-cat');
+ v_other := public.create_commune('Other home', 'household', 'GBP');
+
+ PERFORM set_config('test.home', v_home::text, true);
+ PERFORM set_config('test.other', v_other::text, true);
+ PERFORM set_config('test.expense', v_expense::text, true);
+ PERFORM set_config('test.share', v_share::text, true);
+ PERFORM set_config('test.report_share', v_report_share::text, true);
+ PERFORM set_config('test.fund', v_fund->>'id', true);
+ PERFORM set_config('test.pledge', v_pledge->>'id', true);
+ PERFORM set_config('test.notice', v_notice->>'id', true);
+ PERFORM set_config('test.bill', v_bill::text, true);
+ PERFORM set_config('test.reminder', v_reminder::text, true);
+END;
+$$;
+
+-- 1. Every kind of write moves the mark of its own commune, and only that.
+DO $$
+DECLARE
+ v_home uuid := current_setting('test.home')::uuid;
+ v_other uuid := current_setting('test.other')::uuid;
+ v_write record; v_home_before bigint; v_other_before bigint; v_mark text; v_other_mark text;
+BEGIN
+ PERFORM pg_temp.act('ll-ann');
+ FOR v_write IN SELECT * FROM (VALUES
+ ('communes', 'name', format('id = %L', v_home)),
+ ('commune_members', 'role', format('commune_id = %L AND user_id = %L', v_home, 'll-ben')),
+ ('commune_invites', 'role', format('commune_id = %L', v_home)),
+ ('expenses', 'title', format('commune_id = %L', v_home)),
+ ('expense_shares', 'amount_minor', format('id = %L', current_setting('test.share'))),
+ ('expense_share_receipts', 'reference', format('share_id = %L', current_setting('test.share'))),
+ ('expense_share_payment_reports', 'reference', format('share_id = %L', current_setting('test.report_share'))),
+ ('expense_revisions', 'reason', format('expense_id = %L', current_setting('test.expense'))),
+ ('funds', 'name', format('commune_id = %L', v_home)),
+ ('fund_commitments', 'amount_minor', format('fund_id = %L', current_setting('test.fund'))),
+ ('fund_contributions', 'amount_minor', format('commitment_id = %L', current_setting('test.pledge'))),
+ ('fund_pledge_requests', 'amount_minor', format('commitment_id = %L', current_setting('test.pledge'))),
+ ('notifications', 'detail', format('commune_id = %L', v_home)),
+ ('reminders', 'amount_minor', format('commune_id = %L', v_home)),
+ ('reminder_email_outbox', 'status', format('reminder_id = %L', current_setting('test.reminder'))),
+ ('commune_notes', 'title', format('commune_id = %L', v_home)),
+ ('note_revisions', 'title', format('commune_id = %L', v_home)),
+ ('commune_operations', 'title', format('commune_id = %L', v_home)),
+ ('operation_occurrences', 'reason', format('commune_id = %L', v_home)),
+ ('commune_announcements', 'title', format('commune_id = %L', v_home)),
+ ('announcement_acknowledgements', 'user_id', format('announcement_id = %L', current_setting('test.notice'))),
+ ('recurring_bills', 'title', format('commune_id = %L', v_home)),
+ ('recurring_bill_occurrences', 'note', format('bill_id = %L', current_setting('test.bill'))),
+ ('split_templates', 'name', format('commune_id = %L', v_home)),
+ ('commune_events', 'title', format('commune_id = %L', v_home))
+ ) AS w(table_name, column_name, condition) LOOP
+ PERFORM pg_temp.next_transaction();
+ v_home_before := pg_temp.changes(v_home);
+ v_other_before := pg_temp.changes(v_other);
+ v_mark := commune_private.change_mark(v_home);
+ v_other_mark := commune_private.change_mark(v_other);
+ EXECUTE format('UPDATE public.%I SET %I = %I WHERE %s', v_write.table_name, v_write.column_name, v_write.column_name, v_write.condition);
+ IF pg_temp.changes(v_home) <> v_home_before + 1 OR commune_private.change_mark(v_home) = v_mark THEN
+ RAISE EXCEPTION 'A write to % did not move its commune''s mark', v_write.table_name;
+ END IF;
+ IF pg_temp.changes(v_other) <> v_other_before OR commune_private.change_mark(v_other) <> v_other_mark THEN
+ RAISE EXCEPTION 'A write to % moved another commune''s mark', v_write.table_name;
+ END IF;
+ END LOOP;
+
+ -- Inserting and deleting count too; trimming an old email payload does not.
+ PERFORM pg_temp.next_transaction();
+ v_home_before := pg_temp.changes(v_home);
+ INSERT INTO public.commune_events (commune_id, actor_id, kind, entity_id, title, description)
+ VALUES (v_home, 'll-ann', 'notes', gen_random_uuid(), 'Wifi', 'Updated');
+ PERFORM pg_temp.next_transaction();
+ DELETE FROM public.commune_events WHERE commune_id = v_home AND title = 'Wifi' AND description = 'Updated';
+ PERFORM pg_temp.next_transaction();
+ UPDATE public.reminder_email_outbox SET payload = NULL WHERE reminder_id = current_setting('test.reminder')::uuid;
+ IF pg_temp.changes(v_home) <> v_home_before + 2 THEN
+ RAISE EXCEPTION 'Expected one change for the insert and one for the delete, and none for trimming a payload, got %',
+ pg_temp.changes(v_home) - v_home_before;
+ END IF;
+END;
+$$;
+
+-- 2. One change row per commune per transaction, however many rows a command
+-- writes; a rolled-back part of a transaction takes its change row with it.
+DO $$
+DECLARE
+ v_home uuid := current_setting('test.home')::uuid;
+ v_other uuid := current_setting('test.other')::uuid;
+ v_before bigint; v_other_before bigint;
+BEGIN
+ PERFORM pg_temp.act('ll-ann');
+ PERFORM pg_temp.next_transaction();
+ v_before := pg_temp.changes(v_home);
+ v_other_before := pg_temp.changes(v_other);
+ PERFORM public.create_expense_command(v_home, 'Big shop', 9000,
+ '[{"user_id":"ll-ann","amount_minor":3000},{"user_id":"ll-ben","amount_minor":3000},{"user_id":"ll-dee","amount_minor":3000}]'::jsonb,
+ NULL, NULL, 'Groceries', gen_random_uuid());
+ PERFORM public.record_share_receipt(current_setting('test.share')::uuid, 100, current_date, NULL, gen_random_uuid());
+ PERFORM public.update_commune_details(v_home, 'Lighter house', 'household', 'GBP');
+ IF pg_temp.changes(v_home) <> v_before + 1 THEN
+ RAISE EXCEPTION 'A transaction with many writes should add one change row, added %', pg_temp.changes(v_home) - v_before;
+ END IF;
+ -- A second commune in the same transaction gets its own row.
+ INSERT INTO public.commune_events (commune_id, actor_id, kind, entity_id, title, description)
+ VALUES (v_other, 'll-cat', 'notes', gen_random_uuid(), 'Keys', 'Created');
+ IF pg_temp.changes(v_other) <> v_other_before + 1 OR pg_temp.changes(v_home) <> v_before + 1 THEN
+ RAISE EXCEPTION 'Each commune written to should have one change row';
+ END IF;
+
+ PERFORM pg_temp.next_transaction();
+ v_before := pg_temp.changes(v_home);
+ BEGIN
+ INSERT INTO public.commune_events (commune_id, actor_id, kind, entity_id, title, description)
+ VALUES (v_home, 'll-ann', 'notes', gen_random_uuid(), 'Bins', 'Created');
+ RAISE EXCEPTION 'roll this part back';
+ EXCEPTION WHEN raise_exception THEN NULL;
+ END;
+ IF pg_temp.changes(v_home) <> v_before THEN RAISE EXCEPTION 'A rolled-back write left a change row'; END IF;
+ INSERT INTO public.commune_events (commune_id, actor_id, kind, entity_id, title, description)
+ VALUES (v_home, 'll-ann', 'notes', gen_random_uuid(), 'Bins', 'Created');
+ IF pg_temp.changes(v_home) <> v_before + 1 THEN
+ RAISE EXCEPTION 'A write after a rolled-back one should still be noted';
+ END IF;
+END;
+$$;
+
+-- 3. What a mark counts: rows since the start of yesterday (UTC), so a lower
+-- id committing late still moves it, and pruning never reaches them.
+DO $$
+DECLARE
+ v_home uuid := current_setting('test.home')::uuid;
+ v_mark text; v_yesterday timestamptz := (date_trunc('day', now() AT TIME ZONE 'UTC') - interval '1 day') AT TIME ZONE 'UTC';
+ v_pruned jsonb;
+BEGIN
+ v_mark := commune_private.change_mark(v_home);
+ IF split_part(v_mark, ':', 1) <> to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD') THEN
+ RAISE EXCEPTION 'A mark starts with today''s UTC date, got %', v_mark;
+ END IF;
+ -- A transaction that took a lower id commits after the mark was read.
+ INSERT INTO commune_private.commune_changes (id, commune_id) OVERRIDING SYSTEM VALUE
+ VALUES ((SELECT min(id) - 1 FROM commune_private.commune_changes), v_home);
+ IF commune_private.change_mark(v_home) = v_mark THEN
+ RAISE EXCEPTION 'A change with a lower id that commits late must move the mark';
+ END IF;
+ v_mark := commune_private.change_mark(v_home);
+ INSERT INTO commune_private.commune_changes (commune_id, changed_at) VALUES (v_home, v_yesterday - interval '1 second');
+ IF commune_private.change_mark(v_home) <> v_mark THEN
+ RAISE EXCEPTION 'A change from before yesterday is outside the mark';
+ END IF;
+ INSERT INTO commune_private.commune_changes (commune_id, changed_at) VALUES (v_home, v_yesterday);
+ IF commune_private.change_mark(v_home) = v_mark THEN
+ RAISE EXCEPTION 'A change from the start of yesterday is inside the mark';
+ END IF;
+ v_mark := commune_private.change_mark(v_home);
+ INSERT INTO commune_private.commune_changes (commune_id, changed_at) VALUES
+ (v_home, now() - interval '2 days 1 second'), (v_home, now() - interval '3 days');
+ v_pruned := commune_private.prune_delivery_records();
+ IF (v_pruned->>'changeMarks')::int <> 2 OR commune_private.change_mark(v_home) <> v_mark
+ OR NOT EXISTS (SELECT 1 FROM commune_private.commune_changes WHERE commune_id = v_home AND changed_at = v_yesterday - interval '1 second') THEN
+ RAISE EXCEPTION 'Pruning should remove only changes older than 2 days, got %', v_pruned;
+ END IF;
+END;
+$$;
+
+-- 4. The commune list: the caller's own communes, newest first, with marks.
+DO $$
+DECLARE
+ v_home uuid := current_setting('test.home')::uuid;
+ v_other uuid := current_setting('test.other')::uuid;
+ v_list jsonb; v_second uuid;
+BEGIN
+ PERFORM pg_temp.act('ll-ann');
+ v_second := public.create_commune('Ann''s workspace', 'workspace', 'EUR');
+ UPDATE public.communes SET created_at = created_at + interval '1 minute' WHERE id = v_second;
+ v_list := public.my_communes();
+ IF jsonb_array_length(v_list) <> 2 OR (v_list->0->>'id')::uuid <> v_second OR (v_list->1->>'id')::uuid <> v_home THEN
+ RAISE EXCEPTION 'Ann should see her two communes, newest first: %', v_list;
+ END IF;
+ IF v_list->1->>'name' <> 'Lighter house' OR v_list->1->>'kind' <> 'household' OR v_list->1->>'currency' <> 'GBP'
+ OR v_list->1->>'role' <> 'admin' OR (v_list->1->>'memberCount')::int <> 3 OR v_list->1->>'address' <> ''
+ OR NOT (v_list->1 ?& ARRAY['destination', 'stayName', 'stayAddress'])
+ OR v_list->1->>'changeMark' <> commune_private.change_mark(v_home)
+ OR v_list->1->>'changeMark' <> public.commune_snapshot(v_home)->>'changeMark' THEN
+ RAISE EXCEPTION 'The list should carry the commune''s details and the same mark as its snapshot: %', v_list->1;
+ END IF;
+ PERFORM pg_temp.act('ll-cat');
+ v_list := public.my_communes();
+ IF jsonb_array_length(v_list) <> 1 OR (v_list->0->>'id')::uuid <> v_other OR v_list->0->>'role' <> 'admin' THEN
+ RAISE EXCEPTION 'Cat should see only her own commune: %', v_list;
+ END IF;
+ -- Someone who left sees nothing of the commune.
+ UPDATE public.commune_members SET active = false, left_at = now() WHERE commune_id = v_home AND user_id = 'll-dee';
+ PERFORM pg_temp.act('ll-dee');
+ IF public.my_communes() <> '[]'::jsonb THEN RAISE EXCEPTION 'A former member still lists the commune'; END IF;
+ PERFORM pg_temp.act('ll-ann');
+ IF (public.my_communes()->1->>'memberCount')::int <> 2 THEN RAISE EXCEPTION 'A former member is still counted'; END IF;
+END;
+$$;
+
+-- 5. The row checks on communes and commune_members still let a member read
+-- their own communes and nobody else's, now looked up once per query.
+SELECT pg_temp.act('ll-ann');
+SET LOCAL ROLE authenticated;
+DO $$
+BEGIN
+ IF (SELECT count(*) FROM public.communes) <> 2
+ OR EXISTS (SELECT 1 FROM public.communes WHERE id = current_setting('test.other')::uuid)
+ OR (SELECT count(*) FROM public.commune_members WHERE commune_id = current_setting('test.home')::uuid) <> 3
+ OR EXISTS (SELECT 1 FROM public.commune_members WHERE commune_id = current_setting('test.other')::uuid) THEN
+ RAISE EXCEPTION 'Ann should read her own communes and their members only';
+ END IF;
+END;
+$$;
+RESET ROLE;
+SELECT pg_temp.act('ll-dee');
+SET LOCAL ROLE authenticated;
+DO $$
+BEGIN
+ IF EXISTS (SELECT 1 FROM public.communes) OR EXISTS (SELECT 1 FROM public.commune_members) THEN
+ RAISE EXCEPTION 'A former member can still read the commune';
+ END IF;
+END;
+$$;
+RESET ROLE;
+SELECT pg_temp.act('ll-cat');
+SET LOCAL ROLE authenticated;
+DO $$
+BEGIN
+ IF (SELECT count(*) FROM public.communes) <> 1
+ OR (SELECT count(*) FROM public.commune_members) <> 1
+ OR EXISTS (SELECT 1 FROM public.communes WHERE id = current_setting('test.home')::uuid) THEN
+ RAISE EXCEPTION 'Cat should read only her own commune';
+ END IF;
+END;
+$$;
+RESET ROLE;
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename = 'communes' AND policyname = 'commune_read'
+ AND qual LIKE '%my_commune_ids%')
+ OR NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename = 'commune_members' AND policyname = 'member_read'
+ AND qual LIKE '%my_commune_ids%') THEN
+ RAISE EXCEPTION 'The row checks should look the caller''s communes up once per query';
+ END IF;
+ IF has_function_privilege('anonymous', 'commune_private.my_commune_ids()', 'EXECUTE') THEN
+ RAISE EXCEPTION 'Only a signed-in person''s row checks use the helper';
+ END IF;
+END;
+$$;
+
+-- 6. Creating communes is limited to 10 an hour, 30 a day and 100 in all.
+DO $$
+DECLARE v_error text; i integer;
+BEGIN
+ PERFORM pg_temp.act('ll-maker');
+ FOR i IN 1..10 LOOP PERFORM public.create_commune('Maker ' || i, 'household', 'GBP'); END LOOP;
+ v_error := pg_temp.refused($sql$SELECT public.create_commune('One more', 'household', 'GBP')$sql$);
+ IF v_error IS DISTINCT FROM 'P0001 You have created 10 communes in the last hour. Try again later.' THEN
+ RAISE EXCEPTION 'An eleventh commune in an hour: %', v_error;
+ END IF;
+ -- The other way of creating one is held to the same limit.
+ v_error := pg_temp.refused($sql$SELECT public.create_commune_with_location('One more', 'household', 'GBP', '', '', '', '')$sql$);
+ IF v_error NOT LIKE 'P0001 You have created 10 communes in the last hour%' THEN
+ RAISE EXCEPTION 'Creating with a location skipped the limit: %', v_error;
+ END IF;
+ UPDATE public.communes SET created_at = now() - interval '3 hours' WHERE created_by = 'll-maker';
+ FOR i IN 11..20 LOOP PERFORM public.create_commune('Maker ' || i, 'household', 'GBP'); END LOOP;
+ UPDATE public.communes SET created_at = now() - interval '2 hours' WHERE created_by = 'll-maker' AND created_at = now();
+ FOR i IN 21..30 LOOP PERFORM public.create_commune('Maker ' || i, 'household', 'GBP'); END LOOP;
+ UPDATE public.communes SET created_at = now() - interval '90 minutes' WHERE created_by = 'll-maker' AND created_at = now();
+ v_error := pg_temp.refused($sql$SELECT public.create_commune('One more', 'household', 'GBP')$sql$);
+ IF v_error IS DISTINCT FROM 'P0001 You have created 30 communes in the last day. Try again tomorrow.' THEN
+ RAISE EXCEPTION 'A 31st commune in a day: %', v_error;
+ END IF;
+ UPDATE public.communes SET created_at = now() - interval '2 days' WHERE created_by = 'll-maker';
+ INSERT INTO public.communes (name, kind, currency, created_by, created_at)
+ SELECT 'Older ' || g, 'household', 'GBP', 'll-maker', now() - interval '30 days' FROM generate_series(1, 69) g;
+ PERFORM public.create_commune('The hundredth', 'household', 'GBP');
+ v_error := pg_temp.refused($sql$SELECT public.create_commune('One more', 'household', 'GBP')$sql$);
+ IF v_error IS DISTINCT FROM 'P0001 You have created 100 communes, which is the most one account can create.' THEN
+ RAISE EXCEPTION 'A 101st commune: %', v_error;
+ END IF;
+ -- Someone else is not affected.
+ PERFORM pg_temp.act('ll-fresh');
+ PERFORM public.create_commune('Fresh start', 'household', 'GBP');
+END;
+$$;
+
+-- 7. The snapshot carries open expenses and those created or changed since
+-- the start of last month (UTC); expenseHistory says how many it left out.
+CREATE FUNCTION pg_temp.expense(p_home uuid, p_title text, p_settle boolean) RETURNS uuid LANGUAGE plpgsql AS $$
+DECLARE v_id uuid;
+BEGIN
+ v_id := public.create_expense(p_home, p_title, 2000,
+ '[{"user_id":"ll-ann","amount_minor":1000},{"user_id":"ll-ben","amount_minor":1000}]'::jsonb, current_date);
+ IF p_settle THEN
+ PERFORM public.record_share_receipt((SELECT id FROM public.expense_shares WHERE expense_id = v_id AND user_id = 'll-ben'),
+ 1000, current_date, NULL, gen_random_uuid());
+ END IF;
+ RETURN v_id;
+END;
+$$;
+-- When each part of an expense happened, as if recorded then.
+CREATE FUNCTION pg_temp.age(p_expense uuid, p_created timestamptz, p_changed timestamptz) RETURNS void LANGUAGE plpgsql AS $$
+BEGIN
+ PERFORM set_config('session_replication_role', 'replica', true);
+ UPDATE public.expenses SET created_at = p_created,
+ updated_at = CASE WHEN updated_at IS NULL THEN NULL ELSE p_changed END,
+ voided_at = CASE WHEN voided_at IS NULL THEN NULL ELSE p_changed END
+ WHERE id = p_expense;
+ UPDATE public.expense_shares SET settled_at = CASE WHEN settled_at IS NULL THEN NULL ELSE p_changed END WHERE expense_id = p_expense;
+ UPDATE public.expense_share_receipts r SET recorded_at = p_changed
+ FROM public.expense_shares s WHERE s.id = r.share_id AND s.expense_id = p_expense;
+ UPDATE public.expense_revisions SET created_at = p_changed WHERE expense_id = p_expense;
+ PERFORM set_config('session_replication_role', 'origin', true);
+END;
+$$;
+
+DO $$
+DECLARE
+ v_home uuid; v_window timestamptz := commune_private.expense_window_start();
+ v_old timestamptz := commune_private.expense_window_start() - interval '3 months';
+ v_recent timestamptz := now() - interval '1 hour';
+ v_open uuid; v_quiet uuid; v_new uuid; v_settled uuid; v_voided uuid; v_quiet_void uuid; v_edited uuid; v_edge_in uuid; v_edge_out uuid;
+ v_today uuid[]; v_snapshot jsonb; v_ids uuid[]; v_history jsonb;
+BEGIN
+ PERFORM pg_temp.act('ll-ann');
+ v_home := public.create_commune('History home', 'household', 'GBP');
+ INSERT INTO public.commune_members (commune_id, user_id, role) VALUES (v_home, 'll-ben', 'member');
+ v_open := pg_temp.expense(v_home, 'Old and open', false);
+ v_quiet := pg_temp.expense(v_home, 'Old and settled', true);
+ v_new := pg_temp.expense(v_home, 'New and settled', true);
+ v_settled := pg_temp.expense(v_home, 'Old, settled lately', true);
+ v_voided := pg_temp.expense(v_home, 'Old, voided lately', false);
+ PERFORM public.void_expense(v_voided, 1, 'Entered twice', gen_random_uuid());
+ v_quiet_void := pg_temp.expense(v_home, 'Old and voided', false);
+ PERFORM public.void_expense(v_quiet_void, 1, 'Entered twice', gen_random_uuid());
+ v_edited := pg_temp.expense(v_home, 'Old, edited lately', true);
+ PERFORM public.correct_expense(v_edited, 1, 'Old, edited lately', 2000,
+ '[{"user_id":"ll-ann","amount_minor":1000},{"user_id":"ll-ben","amount_minor":1000}]'::jsonb, current_date,
+ 'll-ann', 'Utilities', 'Right category', gen_random_uuid());
+ v_edge_in := pg_temp.expense(v_home, 'First moment of the window', true);
+ v_edge_out := pg_temp.expense(v_home, 'Last moment before it', true);
+ -- Three from today keep the five newest inside the window, so the window
+ -- alone decides here. Section 9 has a commune that has gone quiet.
+ v_today := ARRAY[pg_temp.expense(v_home, 'Today one', true), pg_temp.expense(v_home, 'Today two', true),
+ pg_temp.expense(v_home, 'Today three', true)];
+ PERFORM pg_temp.age(v_open, v_old, v_old);
+ PERFORM pg_temp.age(v_quiet, v_old, v_old);
+ PERFORM pg_temp.age(v_new, v_recent, v_recent);
+ PERFORM pg_temp.age(v_settled, v_old, v_recent);
+ PERFORM pg_temp.age(v_voided, v_old, v_recent);
+ PERFORM pg_temp.age(v_quiet_void, v_old, v_old);
+ PERFORM pg_temp.age(v_edited, v_old, v_old);
+ UPDATE public.expenses SET updated_at = v_recent WHERE id = v_edited;
+ PERFORM pg_temp.age(v_edge_in, v_window, v_window);
+ PERFORM pg_temp.age(v_edge_out, v_window - interval '1 second', v_window - interval '1 second');
+
+ v_snapshot := public.commune_snapshot(v_home);
+ SELECT array_agg((e->>'id')::uuid) INTO v_ids FROM jsonb_array_elements(v_snapshot->'expenses') e;
+ IF NOT v_ids @> (ARRAY[v_open, v_new, v_settled, v_voided, v_edited, v_edge_in] || v_today)
+ OR v_ids && ARRAY[v_quiet, v_quiet_void, v_edge_out] OR cardinality(v_ids) <> 9 THEN
+ RAISE EXCEPTION 'The snapshot carried the wrong expenses: %', v_snapshot->'expenses';
+ END IF;
+ v_history := v_snapshot->'expenseHistory';
+ IF v_history <> jsonb_build_object('since', to_char(v_window AT TIME ZONE 'UTC', 'YYYY-MM-DD'), 'olderCount', 3, 'olderVoidedCount', 1) THEN
+ RAISE EXCEPTION 'expenseHistory should count the three left out, one of them voided: %', v_history;
+ END IF;
+ IF to_char(v_window AT TIME ZONE 'UTC', 'DD HH24:MI:SS') <> '01 00:00:00'
+ OR v_window <> (date_trunc('month', (date_trunc('month', now() AT TIME ZONE 'UTC') - interval '1 day')) AT TIME ZONE 'UTC') THEN
+ RAISE EXCEPTION 'The window should start at midnight UTC on the first day of last month, got %', v_window;
+ END IF;
+ -- Every other part of the snapshot keeps its shape.
+ IF NOT v_snapshot ?& ARRAY['commune', 'members', 'expenses', 'funds', 'notifications', 'reminderTargets', 'sentReminders',
+ 'notes', 'operations', 'announcements', 'recurringBills', 'splitTemplates', 'activityEvents', 'expenseHistory', 'changeMark'] THEN
+ RAISE EXCEPTION 'The snapshot lost a part: %', (SELECT array_agg(k) FROM jsonb_object_keys(v_snapshot) k);
+ END IF;
+ -- One builder: an expense looks the same however it is fetched.
+ IF (SELECT e FROM jsonb_array_elements(v_snapshot->'expenses') e WHERE (e->>'id')::uuid = v_open)
+ <> public.commune_expense_records(v_home, ARRAY[v_open])->'expenses'->0 THEN
+ RAISE EXCEPTION 'The snapshot and the record of one expense differ';
+ END IF;
+ PERFORM set_config('test.history_home', v_home::text, true);
+ PERFORM set_config('test.quiet', v_quiet::text, true);
+ PERFORM set_config('test.quiet_void', v_quiet_void::text, true);
+ PERFORM set_config('test.edge_out', v_edge_out::text, true);
+ PERFORM set_config('test.history_open', v_open::text, true);
+END;
+$$;
+
+-- 8. Pages of older expenses, a month or more at a time, and named records.
+DO $$
+DECLARE
+ v_home uuid := current_setting('test.history_home')::uuid;
+ v_window timestamptz := commune_private.expense_window_start();
+ v_page jsonb; v_ids uuid[]; v_error text; v_other_expense uuid;
+BEGIN
+ PERFORM pg_temp.act('ll-ben');
+ -- The month before the window holds the one from its last second.
+ v_page := public.commune_expense_page(v_home, NULL, 1);
+ SELECT array_agg((e->>'id')::uuid) INTO v_ids FROM jsonb_array_elements(v_page->'expenses') e;
+ IF v_ids IS DISTINCT FROM ARRAY[current_setting('test.edge_out')::uuid]
+ OR v_page->>'before' <> to_char(v_window AT TIME ZONE 'UTC', 'YYYY-MM-DD')
+ OR v_page->>'from' <> to_char((v_window - interval '1 month') AT TIME ZONE 'UTC', 'YYYY-MM-DD')
+ OR (v_page->>'olderCount')::int <> 2 OR (v_page->>'olderVoidedCount')::int <> 1 THEN
+ RAISE EXCEPTION 'First page: %', v_page;
+ END IF;
+ -- The next month is empty, so the page reaches back to the month that has some.
+ v_page := public.commune_expense_page(v_home, (v_page->>'from')::date, 1);
+ SELECT array_agg((e->>'id')::uuid ORDER BY e->>'title') INTO v_ids FROM jsonb_array_elements(v_page->'expenses') e;
+ IF NOT v_ids @> ARRAY[current_setting('test.quiet')::uuid, current_setting('test.quiet_void')::uuid] OR cardinality(v_ids) <> 2
+ OR v_page->>'from' <> to_char((v_window - interval '3 months') AT TIME ZONE 'UTC', 'YYYY-MM-DD')
+ OR (v_page->>'olderCount')::int <> 0 THEN
+ RAISE EXCEPTION 'Second page should skip the empty month: %', v_page;
+ END IF;
+ -- Nothing older: an empty page with no start.
+ v_page := public.commune_expense_page(v_home, (v_page->>'from')::date, 1);
+ IF v_page->'expenses' <> '[]'::jsonb OR v_page->'from' <> 'null'::jsonb OR (v_page->>'olderCount')::int <> 0 THEN
+ RAISE EXCEPTION 'Past the beginning: %', v_page;
+ END IF;
+ -- Everything before the window at once, for a statement or an export.
+ v_page := public.commune_expense_page(v_home, NULL, NULL);
+ IF jsonb_array_length(v_page->'expenses') <> 3 OR v_page->'from' <> 'null'::jsonb OR (v_page->>'olderCount')::int <> 0 THEN
+ RAISE EXCEPTION 'All older expenses: %', v_page;
+ END IF;
+ -- An open expense is always in the snapshot, never in a page.
+ IF EXISTS (SELECT 1 FROM jsonb_array_elements(v_page->'expenses') e WHERE (e->>'id')::uuid = current_setting('test.history_open')::uuid) THEN
+ RAISE EXCEPTION 'A page repeated an open expense';
+ END IF;
+ v_error := pg_temp.refused(format('SELECT public.commune_expense_page(%L, NULL, 0)', v_home));
+ IF v_error NOT LIKE '22023 %' THEN RAISE EXCEPTION 'Zero months: %', v_error; END IF;
+ v_error := pg_temp.refused(format('SELECT public.commune_expense_page(%L, NULL, 13)', v_home));
+ IF v_error NOT LIKE '22023 %' THEN RAISE EXCEPTION 'Thirteen months: %', v_error; END IF;
+
+ -- Named records: only this commune's, at most 50 at a time.
+ SELECT e.id INTO v_other_expense FROM public.expenses e WHERE e.commune_id = current_setting('test.home')::uuid LIMIT 1;
+ v_page := public.commune_expense_records(v_home, ARRAY[current_setting('test.quiet')::uuid, v_other_expense, gen_random_uuid()]);
+ IF jsonb_array_length(v_page->'expenses') <> 1 OR (v_page->'expenses'->0->>'id')::uuid <> current_setting('test.quiet')::uuid THEN
+ RAISE EXCEPTION 'Records should hold only this commune''s expense: %', v_page;
+ END IF;
+ v_error := pg_temp.refused(format('SELECT public.commune_expense_records(%L, %L::uuid[])', v_home,
+ (SELECT array_agg(gen_random_uuid()) FROM generate_series(1, 51))));
+ IF v_error NOT LIKE '22023 %' THEN RAISE EXCEPTION 'Fifty-one ids: %', v_error; END IF;
+ v_error := pg_temp.refused(format('SELECT public.commune_expense_records(%L, ''{}''::uuid[])', v_home));
+ IF v_error NOT LIKE '22023 %' THEN RAISE EXCEPTION 'No ids: %', v_error; END IF;
+END;
+$$;
+
+-- 9. A commune that has gone quiet keeps its five newest expenses that are
+-- not voided, however old, and its counts and pages leave out the same ones.
+DO $$
+DECLARE
+ v_home uuid; v_window timestamptz := commune_private.expense_window_start();
+ v_m1 timestamptz; v_m2 timestamptz; v_m3 timestamptz;
+ v_void uuid; v_first uuid; v_second uuid; v_third uuid; v_fourth uuid; v_a uuid; v_b uuid; v_tie_in uuid; v_tie_out uuid;
+ v_lunch uuid; v_deposit uuid; v_back uuid; v_snapshot jsonb; v_ids uuid[]; v_page jsonb; v_paged uuid[];
+BEGIN
+ PERFORM pg_temp.act('ll-ann');
+ v_home := public.create_commune('Quiet home', 'household', 'GBP');
+ INSERT INTO public.commune_members (commune_id, user_id, role) VALUES (v_home, 'll-ben', 'member');
+ v_void := pg_temp.expense(v_home, 'Voided after the rest', false);
+ PERFORM public.void_expense(v_void, 1, 'Entered twice', gen_random_uuid());
+ v_first := pg_temp.expense(v_home, 'Farewell dinner', true);
+ v_second := pg_temp.expense(v_home, 'Airport taxi', true);
+ v_third := pg_temp.expense(v_home, 'Museum', true);
+ v_fourth := pg_temp.expense(v_home, 'Ferry', true);
+ v_a := pg_temp.expense(v_home, 'Tram', true);
+ v_b := pg_temp.expense(v_home, 'Bus', true);
+ v_lunch := pg_temp.expense(v_home, 'Lunch', true);
+ v_deposit := pg_temp.expense(v_home, 'Deposit', false);
+ -- Two share the fifth place; the higher id takes it.
+ v_tie_in := greatest(v_a, v_b);
+ v_tie_out := least(v_a, v_b);
+ -- The starts of the three months before the window, in UTC.
+ v_m1 := ((v_window AT TIME ZONE 'UTC') - interval '1 month') AT TIME ZONE 'UTC';
+ v_m2 := ((v_window AT TIME ZONE 'UTC') - interval '2 months') AT TIME ZONE 'UTC';
+ v_m3 := ((v_window AT TIME ZONE 'UTC') - interval '3 months') AT TIME ZONE 'UTC';
+ PERFORM pg_temp.age(v_void, v_m1 + interval '20 days', v_m1 + interval '20 days');
+ PERFORM pg_temp.age(v_first, v_m1 + interval '15 days', v_m1 + interval '15 days');
+ PERFORM pg_temp.age(v_second, v_m1 + interval '10 days', v_m1 + interval '10 days');
+ PERFORM pg_temp.age(v_third, v_m2 + interval '15 days', v_m2 + interval '15 days');
+ PERFORM pg_temp.age(v_fourth, v_m2 + interval '10 days', v_m2 + interval '10 days');
+ -- Aged in this order, the row that loses the tie is stored first, so a rule
+ -- that left ties to storage order would keep it.
+ PERFORM pg_temp.age(v_tie_out, v_m2 + interval '5 days', v_m2 + interval '5 days');
+ PERFORM pg_temp.age(v_tie_in, v_m2 + interval '5 days', v_m2 + interval '5 days');
+ PERFORM pg_temp.age(v_lunch, v_m3 + interval '5 days', v_m3 + interval '5 days');
+ PERFORM pg_temp.age(v_deposit, v_m3 - interval '3 months', v_m3 - interval '3 months');
+
+ -- The five newest that are not voided, newest first, and the old open one.
+ v_snapshot := public.commune_snapshot(v_home);
+ SELECT array_agg((x.item->>'id')::uuid ORDER BY x.ord) INTO v_ids
+ FROM jsonb_array_elements(v_snapshot->'expenses') WITH ORDINALITY x(item, ord);
+ IF v_ids IS DISTINCT FROM ARRAY[v_first, v_second, v_third, v_fourth, v_tie_in, v_deposit] THEN
+ RAISE EXCEPTION 'A quiet commune''s snapshot should keep its five newest that are not voided: %', v_snapshot->'expenses';
+ END IF;
+ IF v_snapshot->'expenseHistory' <> jsonb_build_object('since', to_char(v_window AT TIME ZONE 'UTC', 'YYYY-MM-DD'),
+ 'olderCount', 3, 'olderVoidedCount', 1) THEN
+ RAISE EXCEPTION 'expenseHistory should count the voided one, the one that lost the tie and the oldest: %', v_snapshot->'expenseHistory';
+ END IF;
+ IF (SELECT array_agg(e->>'id') FROM jsonb_array_elements(public.commune_summary(v_home)->'expenses') e)
+ IS DISTINCT FROM (SELECT array_agg(e->>'id') FROM jsonb_array_elements(v_snapshot->'expenses') e) THEN
+ RAISE EXCEPTION 'The summary should carry the snapshot''s expenses';
+ END IF;
+
+ -- Pages go back a month at a time from the window and never repeat one the
+ -- snapshot holds.
+ PERFORM pg_temp.act('ll-ben');
+ v_page := public.commune_expense_page(v_home, NULL, 1);
+ IF v_page->'expenses'->0->>'id' IS DISTINCT FROM v_void::text OR jsonb_array_length(v_page->'expenses') <> 1
+ OR v_page->>'from' <> to_char(v_m1 AT TIME ZONE 'UTC', 'YYYY-MM-DD')
+ OR (v_page->>'olderCount')::int <> 2 OR (v_page->>'olderVoidedCount')::int <> 0 THEN
+ RAISE EXCEPTION 'A quiet commune''s first page: %', v_page;
+ END IF;
+ v_page := public.commune_expense_page(v_home, (v_page->>'from')::date, 1);
+ IF v_page->'expenses'->0->>'id' IS DISTINCT FROM v_tie_out::text OR jsonb_array_length(v_page->'expenses') <> 1
+ OR v_page->>'from' <> to_char(v_m2 AT TIME ZONE 'UTC', 'YYYY-MM-DD') OR (v_page->>'olderCount')::int <> 1 THEN
+ RAISE EXCEPTION 'The month of the tie should hold only the one that lost it: %', v_page;
+ END IF;
+ v_page := public.commune_expense_page(v_home, (v_page->>'from')::date, 1);
+ IF v_page->'expenses'->0->>'id' IS DISTINCT FROM v_lunch::text OR jsonb_array_length(v_page->'expenses') <> 1
+ OR v_page->>'from' <> to_char(v_m3 AT TIME ZONE 'UTC', 'YYYY-MM-DD') OR (v_page->>'olderCount')::int <> 0 THEN
+ RAISE EXCEPTION 'A quiet commune''s last page: %', v_page;
+ END IF;
+ -- Everything older at once holds exactly what the snapshot left out.
+ v_page := public.commune_expense_page(v_home, NULL, NULL);
+ SELECT array_agg((e->>'id')::uuid) INTO v_paged FROM jsonb_array_elements(v_page->'expenses') e;
+ IF cardinality(v_paged) <> 3 OR NOT v_paged @> ARRAY[v_void, v_tie_out, v_lunch] OR v_paged && v_ids THEN
+ RAISE EXCEPTION 'All of a quiet commune''s older expenses: %', v_page;
+ END IF;
+
+ -- A new expense takes one of the five places, and the one it pushes out
+ -- joins the older ones.
+ PERFORM pg_temp.act('ll-ann');
+ v_back := pg_temp.expense(v_home, 'Back again', true);
+ v_snapshot := public.commune_snapshot(v_home);
+ SELECT array_agg((x.item->>'id')::uuid ORDER BY x.ord) INTO v_ids
+ FROM jsonb_array_elements(v_snapshot->'expenses') WITH ORDINALITY x(item, ord);
+ IF v_ids IS DISTINCT FROM ARRAY[v_back, v_first, v_second, v_third, v_fourth, v_deposit]
+ OR (v_snapshot->'expenseHistory'->>'olderCount')::int <> 4 THEN
+ RAISE EXCEPTION 'A new expense should push the fifth newest out: %', v_snapshot;
+ END IF;
+ SELECT array_agg((e->>'id')::uuid) INTO v_paged FROM jsonb_array_elements(public.commune_expense_page(v_home, NULL, NULL)->'expenses') e;
+ IF cardinality(v_paged) <> 4 OR NOT v_paged @> ARRAY[v_void, v_tie_in, v_tie_out, v_lunch] THEN
+ RAISE EXCEPTION 'The one pushed out should be among the older expenses: %', v_paged;
+ END IF;
+END;
+$$;
+
+-- 10. The overview summary: the same parts, less the detail of the commune's
+-- own screens, and marked as a summary.
+DO $$
+DECLARE
+ v_home uuid := current_setting('test.home')::uuid;
+ v_full jsonb; v_summary jsonb;
+BEGIN
+ PERFORM pg_temp.act('ll-ann');
+ v_full := public.commune_snapshot(v_home);
+ v_summary := public.commune_summary(v_home);
+ IF v_summary->>'detail' <> 'summary' OR v_full ? 'detail'
+ OR (SELECT array_agg(k ORDER BY k) FROM jsonb_object_keys(v_summary - 'detail') k)
+ <> (SELECT array_agg(k ORDER BY k) FROM jsonb_object_keys(v_full) k) THEN
+ RAISE EXCEPTION 'A summary should have the snapshot''s parts and say it is one';
+ END IF;
+ IF (SELECT array_agg(e->>'id') FROM jsonb_array_elements(v_summary->'expenses') e)
+ <> (SELECT array_agg(e->>'id') FROM jsonb_array_elements(v_full->'expenses') e)
+ OR EXISTS (SELECT 1 FROM jsonb_array_elements(v_summary->'expenses') e, jsonb_array_elements(e->'shares') s
+ WHERE e->'revisions' <> '[]'::jsonb OR s->'receipts' <> '[]'::jsonb OR s->'paymentReports' <> '[]'::jsonb)
+ OR NOT EXISTS (SELECT 1 FROM jsonb_array_elements(v_full->'expenses') e, jsonb_array_elements(e->'shares') s
+ WHERE s->'receipts' <> '[]'::jsonb) THEN
+ RAISE EXCEPTION 'Summary expenses should keep their shares without repayment, report or correction history';
+ END IF;
+ IF v_summary->'notes'->0->>'body' <> '' OR v_summary->'notes'->0->>'title' <> 'Wifi'
+ OR v_summary->'operations'->0->'history' <> '[]'::jsonb OR v_summary->'operations'->0->>'title' <> 'Bins'
+ OR v_summary->'recurringBills'->0->'history' <> '[]'::jsonb
+ OR v_summary->'announcements' <> '[]'::jsonb OR v_summary->'splitTemplates' <> '[]'::jsonb
+ OR v_summary->'sentReminders' <> '[]'::jsonb OR v_summary->'notifications' <> '[]'::jsonb
+ OR v_summary->'commune' <> v_full->'commune' OR v_summary->'members' <> v_full->'members'
+ OR v_summary->'activityEvents' <> v_full->'activityEvents' OR v_summary->'funds' <> v_full->'funds'
+ OR v_summary->'expenseHistory' <> v_full->'expenseHistory' OR v_summary->>'changeMark' <> v_full->>'changeMark' THEN
+ RAISE EXCEPTION 'The summary dropped or kept the wrong parts';
+ END IF;
+END;
+$$;
+
+-- 11. Someone outside a commune, or who left it, gets none of it.
+DO $$
+DECLARE v_home uuid := current_setting('test.history_home')::uuid; v_user text; v_call text; v_error text;
+BEGIN
+ INSERT INTO public.commune_members (commune_id, user_id, role, active, left_at) VALUES (v_home, 'll-dee', 'member', false, now());
+ FOREACH v_user IN ARRAY ARRAY['ll-cat', 'll-dee'] LOOP
+ PERFORM pg_temp.act(v_user);
+ FOREACH v_call IN ARRAY ARRAY[
+ format('SELECT public.commune_snapshot(%L)', v_home),
+ format('SELECT public.commune_summary(%L)', v_home),
+ format('SELECT public.commune_expense_page(%L, NULL, 1)', v_home),
+ format('SELECT public.commune_expense_page(%L, NULL, NULL)', v_home),
+ format('SELECT public.commune_expense_records(%L, ARRAY[%L]::uuid[])', v_home, current_setting('test.quiet'))
+ ] LOOP
+ v_error := pg_temp.refused(v_call);
+ IF v_error NOT LIKE '42501 %' THEN RAISE EXCEPTION '% was not refused for %: %', v_call, v_user, v_error; END IF;
+ END LOOP;
+ END LOOP;
+ FOREACH v_call IN ARRAY ARRAY['public.my_communes()', 'public.commune_summary(uuid)',
+ 'public.commune_expense_page(uuid,date,integer)', 'public.commune_expense_records(uuid,uuid[])'] LOOP
+ IF has_function_privilege('anonymous', v_call, 'EXECUTE') OR has_function_privilege('public', v_call, 'EXECUTE')
+ OR NOT has_function_privilege('authenticated', v_call, 'EXECUTE') THEN
+ RAISE EXCEPTION 'Unexpected execution grant on %', v_call;
+ END IF;
+ END LOOP;
+END;
+$$;
+
+ROLLBACK;
diff --git a/infra/neon/tests/run-local.sh b/infra/neon/tests/run-local.sh
index 8c21e351..5ebd86d2 100755
--- a/infra/neon/tests/run-local.sh
+++ b/infra/neon/tests/run-local.sh
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Runs the database tests in this folder against a local PostgreSQL server:
# every *.sql test on its own fresh database, then every *.mjs test
-# (task_lock_order.mjs) the same way. CI runs it, and so can you before
-# pushing a migration:
+# (task_lock_order.mjs, change_mark_order.mjs) the same way. CI runs it, and
+# so can you before pushing a migration:
#
# PGHOST=127.0.0.1 PGPORT=5432 PGUSER=commune PGPASSWORD=commune \
# infra/neon/tests/run-local.sh [test-name ...]