-
{selectedExpense ? `You → ${payer?.name ?? "bill payer"}` : "Payment"}
+
{selectedExpense ? `You → ${personName(selectedExpense.paidByUserId, "bill payer", { left: true })}` : "Payment"}
{formatMoney(outstanding, hub.currency)}
Commune does not move money or hold payment details yet. Arrange a transfer directly with the bill payer;
@@ -983,7 +1027,7 @@ function NoteNew({ onSave, initial }: {
onSave: (card: NoteCard & { id: string; expectedVersion?: number }, requestId: string) => Promise;
initial?: NoteCard & { id: string; version?: number };
}) {
- const { go, closeWindow, notes, hub } = useApp();
+ const { go, closeWindow, notes, personName } = useApp();
// The version this edit started from. A save replaces only that version.
const baseVersion = useRef(initial?.version);
// One id for this form: an add retried after an unconfirmed save returns
@@ -991,7 +1035,7 @@ function NoteNew({ onSave, initial }: {
const requestId = useRef(newRequestId());
const [conflict, setConflict] = useState(false);
const latest = initial ? notes[initial.id] : undefined;
- const editorName = (userId: string | null | undefined) => hub.members.find((member) => member.userId === userId)?.name ?? "Someone";
+ const editorName = (userId: string | null | undefined) => personName(userId, "Someone");
const fact = (label: string) => initial?.facts.find(([key]) => key === label)?.[1] ?? "";
const [type, setType] = useState<(typeof NOTE_TYPES)[number][0]>(() => initial?.pill === "Landlord" ? "contacts" : initial ? NOTE_TYPES.find(([, label]) => label === initial.pill)?.[0] ?? "other" : "other");
const [title, setTitle] = useState(initial?.title ?? "");
diff --git a/apps/web/tests/activity-log.test.tsx b/apps/web/tests/activity-log.test.tsx
new file mode 100644
index 00000000..9183aa84
--- /dev/null
+++ b/apps/web/tests/activity-log.test.tsx
@@ -0,0 +1,159 @@
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render, screen, within } from "@testing-library/react";
+import { Router } from "wouter";
+import { memoryLocation } from "wouter/memory-location";
+import { App } from "../src/App";
+import { AppProvider } from "../src/state";
+import { api, loadCommunes, loadSnapshot } from "../src/live/api";
+import { hubFromSnapshot, type Commune, type Snapshot } from "../src/live/model";
+import { activityDayHeading, activityStamp, groupByDay } from "../src/ui/ActivityLog";
+
+vi.mock("../src/live/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ api: vi.fn(), post: vi.fn(), loadCommunes: vi.fn(), loadSnapshot: vi.fn(),
+}));
+
+// Activity names the kind of record each row is about, in an icon and a word,
+// shows the time of day, and groups the rows under the day they were recorded.
+// It is Friday 9 October 2026, 4pm in London.
+const zone = process.env.TZ;
+const user = { id: "user-1", name: "Test Person", email: "test@example.test", emailVerified: true };
+const home: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin" };
+const trip: Commune = { id: "trip-1", name: "Lisbon trip", kind: "trip", currency: "EUR", role: "member" };
+const members = [
+ { userId: user.id, name: user.name, role: "admin" as const, active: true },
+ { userId: "user-2", name: "Housemate Two", role: "member" as const, active: true },
+];
+type Event = NonNullable[number];
+const event = (id: string, kind: Event["kind"], title: string, description: string, createdAt: string, recordType?: Event["recordType"]): Event =>
+ ({ id, actorId: "user-2", kind, entityId: `${id}-record`, title, description, createdAt, ...(recordType ? { recordType } : {}) });
+function snapshotOf(commune: Commune, extra: Partial = {}): Snapshot {
+ return {
+ commune, members, expenses: [], funds: [], notes: [], operations: [], notifications: [], reminderTargets: [],
+ recurringBills: [], splitTemplates: [], activityEvents: [], announcements: [], sentReminders: [], ...extra,
+ };
+}
+const gas = {
+ id: "expense-gas", title: "Gas", amountMinor: 3000, paidByUserId: "user-2", dueOn: null, createdAt: "2026-10-05T18:45:00Z",
+ shares: [{ id: "share-gas", userId: user.id, amountMinor: 1500, settledMinor: 1500 }],
+};
+const homeSnapshot = () => snapshotOf(home, {
+ expenses: [gas],
+ activityEvents: [
+ event("a1", "operations", "Inspection", "Created", "2026-10-09T13:05:00Z", "event"),
+ event("a2", "notes", "Router", "Created", "2026-10-08T08:30:00Z", "note"),
+ // Written before the database named record types: the area says what it is.
+ event("a3", "funds", "Boiler", "Created", "2025-12-30T10:00:00Z"),
+ ],
+});
+const tripSnapshot = () => snapshotOf(trip, {
+ activityEvents: [event("b1", "expenses", "Ferry", "Repayment of €20.00 recorded", "2026-10-09T09:10:00Z", "repayment")],
+});
+
+function mount(path: string) {
+ const location = memoryLocation({ path, record: true });
+ render();
+ return location;
+}
+const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
+beforeAll(() => {
+ Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } });
+});
+afterAll(() => {
+ if (modalDescriptor) Object.defineProperty(HTMLDialogElement.prototype, "showModal", modalDescriptor);
+ else delete (HTMLDialogElement.prototype as unknown as Record).showModal;
+});
+beforeEach(() => {
+ process.env.TZ = "Europe/London";
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-10-09T15:00:00Z"));
+ vi.resetAllMocks();
+ sessionStorage.clear();
+ localStorage.clear();
+ const snapshots: Record = { [home.id]: homeSnapshot(), [trip.id]: tripSnapshot() };
+ vi.mocked(loadCommunes).mockImplementation(async () => ({ communes: [home, trip] }));
+ vi.mocked(loadSnapshot).mockImplementation(async (commune) => snapshots[commune.id]);
+ vi.mocked(api).mockImplementation(async (path) => {
+ if (path === "/auth/me") return { user };
+ if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 8, recipientUsage: {}, nextSlotAt: null };
+ if (path.endsWith("/invites")) return { invites: [] };
+ return { ok: true };
+ });
+});
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ if (zone === undefined) delete process.env.TZ;
+ else process.env.TZ = zone;
+});
+
+const rowText = (row: HTMLElement) => [row.querySelector("strong")?.textContent, row.querySelector("small")?.textContent,
+ row.querySelector("time")?.textContent];
+
+describe("activity rows", () => {
+ it("name the record type the database gives, or the one its area suggests", () => {
+ const hub = hubFromSnapshot(homeSnapshot(), user);
+ expect(hub.activity.map((item) => [item.title, item.type])).toEqual([
+ ["Inspection", "event"], ["Router", "note"], ["Gas", "expense"], ["Boiler", "fund"],
+ ]);
+ });
+
+ it("group by the member's own day and say when in words", () => {
+ expect(activityDayHeading("2026-10-09", "2026-10-09")).toBe("Today");
+ expect(activityDayHeading("2026-10-08", "2026-10-09")).toBe("Yesterday");
+ expect(activityDayHeading("2026-10-05", "2026-10-09")).toBe("Monday 5 October");
+ expect(activityDayHeading("2025-12-30", "2026-10-09")).toBe("Tuesday, 30 December 2025");
+ expect(activityDayHeading("", "2026-10-09")).toBe("Earlier");
+ // In London, 23:30 UTC on the 8th is already half past midnight on the 9th.
+ const rows = ["2026-10-09T09:00:00Z", "2026-10-08T23:30:00Z", "2026-10-08T22:30:00Z", undefined];
+ expect(groupByDay(rows, (row) => row).map((group) => [group.day, group.rows.length]))
+ .toEqual([["2026-10-09", 2], ["2026-10-08", 1], ["", 1]]);
+ expect(activityStamp({ createdAt: "2026-10-09T13:05:00Z", time: "9 Oct" }, "2026-10-09")).toBe("Today, 14:05");
+ expect(activityStamp({ createdAt: "2026-10-05T18:45:00Z", time: "5 Oct" }, "2026-10-09")).toBe("5 Oct, 19:45");
+ expect(activityStamp({ createdAt: "2025-12-30T10:00:00Z", time: "30 Dec" }, "2026-10-09")).toBe("30 Dec 2025, 10:00");
+ expect(activityStamp({ createdAt: undefined, time: "Recently" }, "2026-10-09")).toBe("Recently");
+ });
+});
+
+describe("the activity log", () => {
+ it("shows this commune's records under day headings, each with its type and time", async () => {
+ mount("/activity");
+ const today = await screen.findByRole("region", { name: "Today" });
+ expect(screen.getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent))
+ .toEqual(["Today", "Yesterday", "Monday 5 October", "Tuesday, 30 December 2025"]);
+ expect(within(today).getAllByRole("button").map(rowText)).toEqual([["Inspection", "Event · Created · Housemate Two", "14:05"]]);
+ expect(within(screen.getByRole("region", { name: "Yesterday" })).getAllByRole("button").map(rowText))
+ .toEqual([["Router", "Note · Created · Housemate Two", "09:30"]]);
+ expect(within(screen.getByRole("region", { name: "Monday 5 October" })).getAllByRole("button").map(rowText))
+ .toEqual([["Gas", "Expense · Housemate Two paid", "19:45"]]);
+ expect(within(screen.getByRole("region", { name: "Tuesday, 30 December 2025" })).getAllByRole("button").map(rowText))
+ .toEqual([["Boiler", "Fund · Created · Housemate Two", "10:00"]]);
+ // The kind is in words; its icon is decoration.
+ expect(today.querySelector(".c-type-chip.is-plan")?.getAttribute("aria-hidden")).toBe("true");
+ });
+
+ it("shows every commune's records together, each naming its commune", async () => {
+ mount("/activity/all");
+ const today = await screen.findByRole("region", { name: "Today" });
+ await within(today).findByText("Ferry");
+ expect(within(today).getAllByRole("button").map(rowText)).toEqual([
+ ["Inspection", "Our home · Event · Created · Housemate Two", "14:05"],
+ ["Ferry", "Lisbon trip · Repayment · Repayment of €20.00 recorded · Housemate Two", "10:10"],
+ ]);
+ });
+
+ it("gives Home's recent records their type, day and time", async () => {
+ mount("/");
+ const recent = (await screen.findByRole("heading", { name: "Recent activity" })).closest(".a-card") as HTMLElement;
+ await within(recent).findByText("Ferry");
+ const rows = within(recent).getAllByRole("button").filter((button) => button.classList.contains("a-item"));
+ expect(rows.map((row) => [row.querySelector("strong")?.textContent, ...[...row.querySelectorAll(".when")].map((node) => node.textContent)]))
+ .toEqual([
+ ["Inspection", "Our home · Event · Created · Housemate Two", "Today, 14:05"],
+ ["Ferry", "Lisbon trip · Repayment · Repayment of €20.00 recorded · Housemate Two", "Today, 10:10"],
+ ["Router", "Our home · Note · Created · Housemate Two", "Yesterday, 09:30"],
+ ["Gas", "Our home · Expense · Housemate Two paid", "5 Oct, 19:45"],
+ ["Boiler", "Our home · Fund · Created · Housemate Two", "30 Dec 2025, 10:00"],
+ ]);
+ });
+});
diff --git a/apps/web/tests/cover-contrast.test.ts b/apps/web/tests/cover-contrast.test.ts
new file mode 100644
index 00000000..c032159b
--- /dev/null
+++ b/apps/web/tests/cover-contrast.test.ts
@@ -0,0 +1,209 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+
+// Text over commune photos, on Home's cards and the Hub hero, measured over a
+// bright stand-in photo: pure white, the worst a photo can be behind light
+// text. Small text needs 4.5:1; large text (24px, or 19px bold) and buttons 3:1.
+const readStyle = (path: string) => readFileSync(resolve(process.cwd(), "src", path), "utf8");
+const styles = { app: readStyle("styles/app.css"), hub: readStyle("screens/Hub.css") };
+
+function declarations(sheet: keyof typeof styles, selector: string) {
+ const source = `\n${styles[sheet]}`;
+ const start = source.indexOf(`\n${selector} {`);
+ if (start < 0) throw new Error(`Missing ${selector} in ${sheet}`);
+ return source.slice(source.indexOf("{", start) + 1, source.indexOf("}", start));
+}
+function property(sheet: keyof typeof styles, selector: string, name: string) {
+ const match = declarations(sheet, selector).match(new RegExp(`(?:^|[;\\s])${name}:\\s*([^;]+);`));
+ if (!match) throw new Error(`Missing ${name} for ${selector}`);
+ return match[1].trim();
+}
+
+type Colour = { rgb: [number, number, number]; alpha: number };
+function colour(value: string): Colour {
+ if (value === "transparent") return { rgb: [0, 0, 0], alpha: 0 };
+ const short = value.match(/^#([0-9a-f]{3,4})$/i);
+ if (short) return colour(`#${[...short[1]].map((digit) => digit + digit).join("")}`);
+ const hex = value.match(/^#([0-9a-f]{6})([0-9a-f]{2})?$/i);
+ if (hex) {
+ const [r, g, b] = [0, 2, 4].map((at) => parseInt(hex[1].slice(at, at + 2), 16));
+ return { rgb: [r, g, b], alpha: hex[2] ? parseInt(hex[2], 16) / 255 : 1 };
+ }
+ const rgba = value.match(/^rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)$/);
+ if (!rgba) throw new Error(`Not a colour: ${value}`);
+ return { rgb: [Number(rgba[1]), Number(rgba[2]), Number(rgba[3])], alpha: rgba[4] === undefined ? 1 : Number(rgba[4]) };
+}
+const white: Colour["rgb"] = [255, 255, 255];
+const over = (top: Colour, below: Colour["rgb"]) =>
+ below.map((channel, index) => top.alpha * top.rgb[index] + (1 - top.alpha) * channel) as Colour["rgb"];
+const luminance = (rgb: Colour["rgb"]) => {
+ const [r, g, b] = rgb.map((channel) => {
+ const value = channel / 255;
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+};
+const ratio = (a: Colour["rgb"], b: Colour["rgb"]) => {
+ const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (high + 0.05) / (low + 0.05);
+};
+
+/** A shade's colour and its opacity at a height above the cover's bottom edge. */
+function shade(value: string) {
+ const parts = value.replace(/^linear-gradient\(/, "").replace(/\)$/, "").split(/,\s*(?![^(]*\))/);
+ const direction = parts.shift()?.trim();
+ expect(["0deg", "180deg"]).toContain(direction);
+ const stops = parts.map((part, index) => {
+ const [, paint, at] = part.trim().match(/^(.*?\)|#[0-9a-f]+|transparent)(?:\s+(\S+))?$/i)!;
+ // Without a position, the first stop is at the start and the last at the end.
+ return { paint: colour(paint), at: at ?? (index === 0 ? "0" : "100%") };
+ });
+ const tone = stops.find((stop) => stop.paint.alpha > 0)!.paint.rgb;
+ // Every stop is the same colour, or clear: only the opacity changes.
+ expect(stops.every((stop) => stop.paint.alpha === 0 || stop.paint.rgb.join() === tone.join())).toBe(true);
+ return (height: number, cover: number) => {
+ const from = direction === "0deg" ? height : cover - height;
+ const points = stops.map(({ paint, at }) => ({
+ alpha: paint.alpha,
+ at: at.endsWith("%") ? cover * Number(at.slice(0, -1)) / 100 : Number(at.replace(/px$/, "")),
+ }));
+ let alpha = points[0].alpha;
+ for (let index = 1; index < points.length; index += 1) {
+ const [a, b] = [points[index - 1], points[index]];
+ if (from >= b.at) alpha = b.alpha;
+ else if (from > a.at) alpha = a.alpha + (b.alpha - a.alpha) * (from - a.at) / (b.at - a.at);
+ }
+ return { rgb: tone, alpha };
+ };
+}
+
+type Line = { name: string; from: number; to: number; text: Colour; target: number };
+/** The weakest contrast along each line of text, over the shade and a white photo. */
+function weakest(lines: Line[], cover: number, shadeAt: ReturnType) {
+ return Object.fromEntries(lines.map(({ name, from, to, text, target }) => {
+ let lowest = Infinity;
+ for (let step = 0; step <= 20; step += 1) {
+ const background = over(shadeAt(from + (to - from) * step / 20, cover), white);
+ lowest = Math.min(lowest, ratio(over(text, background), background));
+ }
+ return [name, { contrast: Number(lowest.toFixed(2)), target }];
+ }));
+}
+const failures = (result: Record) =>
+ Object.entries(result).filter(([, { contrast, target }]) => contrast < target);
+
+/**
+ * Lines of text stacked up from the cover's bottom padding: each line's height
+ * (font size times a normal line height of 1.2, or the stated line height) and
+ * the gap below it, from the rules for each element.
+ */
+function stack(padding: number, items: Array<{ name: string; line: number; gap: number; text: Colour; target: number; lines?: number }>) {
+ let at = padding;
+ return items.map(({ name, line, gap, text, target, lines = 1 }) => {
+ at += gap;
+ const row = { name, from: at, to: at + line * lines, text, target };
+ at = row.to;
+ return row;
+ });
+}
+
+const light = colour("#f8f5f0");
+const withOpacity = (value: Colour, opacity: number) => ({ ...value, alpha: value.alpha * opacity });
+
+describe("text over Home's commune photos", () => {
+ const status = () => colour(property("app", ".a-place-hero p", "color"));
+ const location = () => colour(property("app", ".a-place-hero .a-place-location,\n.a-focus-hero .a-place-location", "color"));
+
+ it.each([
+ ["with an address", true, 1],
+ ["with a status that wraps", true, 2],
+ ["without an address", false, 1],
+ ] as const)("keeps a card's title, address and status readable %s", (_case, address, statusLines) => {
+ // A 208px card with 18px padding (#home .a-place-hero): status 14px, address
+ // 12px 4px above it, title 21px 4px above that. The title is not large text.
+ const lines = stack(18, [
+ { name: "status", line: 14 * 1.2, gap: 0, text: status(), target: 4.5, lines: statusLines },
+ ...(address ? [{ name: "address", line: 12 * 1.2, gap: 4, text: location(), target: 4.5 }] : []),
+ { name: "title", line: 21 * 1.2, gap: 4, text: light, target: 4.5 },
+ ]);
+ const result = weakest(lines, 208, shade(property("app", ".a-place-hero::after", "background")));
+ expect(failures(result), JSON.stringify(result)).toEqual([]);
+ });
+
+ it.each([
+ ["with an address", true, 1],
+ ["with a status that wraps", true, 2],
+ ] as const)("keeps the featured card's text readable %s", (_case, address, statusLines) => {
+ // A 292px card with 26px bottom padding (.a-focus-hero): "Open this commune"
+ // (13px) centred on 32px avatars, status 15px 16px above, address 12px,
+ // then the title, 2.1rem and bold, which is large text.
+ const people = colour(property("app", ".a-cover-people", "color"));
+ const featured = colour(property("app", ".a-focus-hero p", "color"));
+ const lines = [
+ { name: "people", from: 26 + (32 - 13 * 1.2) / 2, to: 26 + (32 + 13 * 1.2) / 2, text: people, target: 4.5 },
+ ...stack(26 + 32, [
+ { name: "status", line: 15 * 1.2, gap: 16, text: featured, target: 4.5, lines: statusLines },
+ ...(address ? [{ name: "address", line: 12 * 1.2, gap: 4, text: location(), target: 4.5 }] : []),
+ { name: "title", line: 33.6 * 1.2, gap: 8, text: light, target: 3 },
+ ]),
+ ];
+ const result = weakest(lines, 292, shade(property("app", ".a-focus-hero::after", "background")));
+ expect(failures(result), JSON.stringify(result)).toEqual([]);
+ });
+
+ it.each([".a-place-hero .type", ".a-focus-hero .type"])("gives the %s label its own backdrop, wherever it sits", (selector) => {
+ // The label can sit high on a card with a long name, so it does not rely on the shade.
+ const backdrop = over(colour(property("app", selector, "background")), white);
+ expect(ratio(light.rgb, backdrop)).toBeGreaterThanOrEqual(4.5);
+ });
+});
+
+describe("text over the Hub's commune photo", () => {
+ const hubWhite = colour("#ffffff");
+
+ it.each([
+ ["on a computer", 300, 27, 35, 1],
+ ["on a computer, with no address", 300, 27, 35, 0],
+ ["on a computer, for a trip's area and stay", 300, 27, 35, 2],
+ ["on a phone", 280, 22, 29, 1],
+ ["on a phone, for a trip's area and stay", 280, 22, 29, 2],
+ ] as const)("keeps the title, address and people readable %s", (_case, cover, padding, title, addressLines) => {
+ // .c-hub-identity: the people link's 12px text centred on 28px avatars, 17px
+ // below the address (14px, line height 1.5), then the title (line height 1.2).
+ const people = { name: "people", from: padding + 5, to: padding + 23, text: hubWhite, target: 4.5 };
+ const above = stack(padding + 28, [
+ ...(addressLines ? [{ name: "address", line: 21, gap: 17, text: hubWhite, target: 4.5, lines: addressLines }] : []),
+ { name: "title", line: title * 1.2, gap: addressLines ? 6 : 17, text: hubWhite, target: 3 },
+ ]);
+ // A trip's "Area" and "Stay" are softer than the address after them.
+ const labels = withOpacity(hubWhite, Number(property("hub", ".c-hub-location span", "opacity")));
+ const softer = addressLines === 2 ? [{ ...above[0], name: "area and stay", text: labels }] : [];
+ const result = weakest([people, ...above, ...softer], cover, shade(property("hub", ".c-hub-identity.has-photo::after", "background")));
+ expect(failures(result), JSON.stringify(result)).toEqual([]);
+ });
+
+ it("gives the type and the buttons their own backdrop, which also stands out from the photo", () => {
+ const type = over(colour(property("hub", ".c-hub-identity.has-photo .c-hub-type", "background")), white);
+ expect(ratio(white, type)).toBeGreaterThanOrEqual(4.5);
+ const button = over(colour(property("hub", ".c-hub-settings", "background")), white);
+ // The 12px label in the button, and the button against the photo around it.
+ expect(ratio(colour(property("hub", ".c-hub-settings", "color")).rgb, button)).toBeGreaterThanOrEqual(4.5);
+ expect(ratio(button, white)).toBeGreaterThanOrEqual(3);
+ });
+});
+
+describe("the measurement", () => {
+ it("finds what the finding found before the change: under 3:1 for the labels and titles", () => {
+ const before = weakest(stack(18, [
+ { name: "status", line: 14 * 1.2, gap: 0, text: colour("rgba(248, 245, 240, 0.82)"), target: 4.5 },
+ { name: "title", line: 21 * 1.2, gap: 4, text: light, target: 4.5 },
+ ]), 208, shade("linear-gradient(180deg, rgba(26, 30, 46, 0.08) 0%, rgba(26, 30, 46, 0.72) 100%)"));
+ expect(before.title.contrast).toBeLessThan(3.5);
+ expect(ratio(light.rgb, over(colour("rgba(245, 241, 234, 0.16)"), over(colour("rgba(26, 30, 46, 0.4)"), white)))).toBeLessThan(3);
+ const hubBefore = weakest(stack(27 + 28, [{ name: "title", line: 42, gap: 17, text: colour("#ffffff"), target: 3 }]), 300,
+ shade("linear-gradient(0deg, #14251acc 0, transparent 90%)"));
+ expect(hubBefore.title.contrast).toBeLessThan(3);
+ expect(ratio(white, over(colour("#15221c66"), white))).toBeLessThan(3);
+ });
+});
diff --git a/apps/web/tests/design-reference.test.ts b/apps/web/tests/design-reference.test.ts
index b3bd3d92..38f41da3 100644
--- a/apps/web/tests/design-reference.test.ts
+++ b/apps/web/tests/design-reference.test.ts
@@ -101,6 +101,6 @@ describe("design language reference", () => {
expect(html).not.toContain("typography.css");
expect(html).toContain("font-weight: 450;");
expect(html).toContain("font-weight: 550;");
- expect(find(".account").textContent).toContain("Admin · 58 reminders left");
+ expect(find(".account small").textContent).toBe("Admin");
});
});
diff --git a/apps/web/tests/former-members.test.tsx b/apps/web/tests/former-members.test.tsx
new file mode 100644
index 00000000..4d2f998b
--- /dev/null
+++ b/apps/web/tests/former-members.test.tsx
@@ -0,0 +1,156 @@
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import { Router } from "wouter";
+import { memoryLocation } from "wouter/memory-location";
+import { App } from "../src/App";
+import { AppProvider, useApp, type AppState } from "../src/state";
+import { api, loadCommunes, loadSnapshot } from "../src/live/api";
+import { hubFromSnapshot, personName, type Commune, type Snapshot } from "../src/live/model";
+import { initialsOf } from "../src/data/household";
+
+vi.mock("../src/live/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ api: vi.fn(), post: vi.fn(), loadCommunes: vi.fn(), loadSnapshot: vi.fn(),
+}));
+
+// Someone who has left keeps their name wherever their records appear (the
+// snapshot keeps them, 0031), marked "(left)" where it matters that they are
+// no longer a member. The generic words are only for someone it does not know.
+const user = { id: "user-1", name: "Maya Chen", email: "maya@example.test", emailVerified: true };
+const commune: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin" };
+const members = [
+ { userId: user.id, name: user.name, role: "admin" as const, active: true },
+ { userId: "user-2", name: "Ben Baker", role: "member" as const, active: true },
+ { userId: "user-3", name: "Lee Lane", role: "member" as const, active: false },
+];
+let current: Snapshot;
+let app: AppState;
+function Capture() { app = useApp(); return null; }
+function mount(path: string) {
+ const location = memoryLocation({ path, record: true });
+ render();
+ return location;
+}
+const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
+const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close");
+const originalScroll = Element.prototype.scrollIntoView;
+beforeAll(() => {
+ Element.prototype.scrollIntoView = vi.fn();
+ Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } });
+ Object.defineProperty(HTMLDialogElement.prototype, "close", { configurable: true, value: function (this: HTMLDialogElement) { this.removeAttribute("open"); } });
+});
+afterAll(() => {
+ Element.prototype.scrollIntoView = originalScroll;
+ for (const [name, descriptor] of [["showModal", modalDescriptor], ["close", closeDescriptor]] as const) {
+ if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, name, descriptor);
+ else delete (HTMLDialogElement.prototype as unknown as Record)[name];
+ }
+});
+beforeEach(() => {
+ vi.resetAllMocks();
+ sessionStorage.clear();
+ localStorage.clear();
+ current = {
+ commune, members, funds: [], notes: [], operations: [], reminderTargets: [], recurringBills: [], activityEvents: [],
+ sentReminders: [],
+ expenses: [{
+ // Lee left owing Maya for the cleaner, after paying back part of it.
+ id: "expense-1", title: "Cleaner", amountMinor: 3000, paidByUserId: user.id, dueOn: null, createdAt: "2026-10-01T09:00:00Z",
+ shares: [
+ { id: "share-1", userId: user.id, amountMinor: 1500, settledMinor: 1500 },
+ { id: "share-3", userId: "user-3", amountMinor: 1500, settledMinor: 500, receipts: [{
+ id: "receipt-1", amountMinor: 500, receivedOn: "2026-10-02", reference: null, recordedBy: user.id,
+ recordedAt: "2026-10-02T10:00:00Z", origin: "recorded", reversedAt: null, reversedBy: null, reversalReason: null,
+ }] },
+ ],
+ }, {
+ // Lee paid for the paint before leaving; Maya still owes her share.
+ id: "expense-2", title: "Paint", amountMinor: 2000, paidByUserId: "user-3", dueOn: null, createdAt: "2026-09-20T09:00:00Z",
+ shares: [{ id: "share-4", userId: user.id, amountMinor: 1000, settledMinor: 0 }, { id: "share-5", userId: "user-3", amountMinor: 1000, settledMinor: 1000 }],
+ }],
+ splitTemplates: [{ id: "template-1", name: "Thirds", method: "Equal", createdBy: user.id, archivedAt: null,
+ participants: [{ userId: user.id, value: "" }, { userId: "user-3", value: "" }] }],
+ announcements: [{ id: "announcement-1", title: "Boiler service", body: "", authorId: "user-3",
+ createdAt: "2026-10-06T09:00:00.000Z", needsAcknowledgement: false, acknowledgements: [] }],
+ notifications: [{
+ id: "notice-1", kind: "payment-reported", sourceId: "share-3", amountMinor: 500, communeId: commune.id, senderUserId: "user-3",
+ expenseId: "expense-1", title: "Cleaner", detail: null, createdAt: "2026-10-02T09:00:00Z", readAt: null,
+ }],
+ };
+ vi.mocked(loadCommunes).mockImplementation(async () => ({ communes: [commune] }));
+ vi.mocked(loadSnapshot).mockImplementation(async () => current);
+ vi.mocked(api).mockImplementation(async (path) => {
+ if (path === "/auth/me") return { user };
+ if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 8, recipientUsage: {}, nextSlotAt: null };
+ if (path.endsWith("/invites")) return { invites: [] };
+ return { ok: true };
+ });
+});
+afterEach(cleanup);
+
+describe("naming people", () => {
+ it("names anyone the snapshot knows, marks who has left where asked, and keeps the generic words for strangers", () => {
+ expect(personName(members, "user-2", "a former member", { left: true })).toBe("Ben Baker");
+ expect(personName(members, "user-3", "a former member")).toBe("Lee Lane");
+ expect(personName(members, "user-3", "a former member", { left: true })).toBe("Lee Lane (left)");
+ expect(personName(members, "user-9", "a former member", { left: true })).toBe("a former member");
+ expect(personName(members, null, "Someone")).toBe("Someone");
+ expect(personName([{ userId: "user-4", name: " ", role: "member" }], "user-4", "Someone")).toBe("Someone");
+ });
+
+ it("gives someone without a name a neutral mark, not letters from nowhere", () => {
+ expect(initialsOf("Lee Lane")).toBe("LL");
+ expect(initialsOf("")).toBe("?");
+ expect(initialsOf(" ")).toBe("?");
+ const hub = hubFromSnapshot({ ...current, members: [...members, { userId: "user-4", name: " ", role: "member", active: true }] }, user);
+ expect(hub.members.find((member) => member.userId === "user-4")?.ini).toBe("?");
+ });
+
+ it("names someone who has left on Settle, in what they owe and what they paid back", async () => {
+ mount("/settle");
+ const owed = await screen.findByRole("region", { name: "You’re owed" });
+ expect(within(owed).getByText("Lee Lane (left) owes you")).toBeTruthy();
+ expect(within(owed).getByRole("button", { name: "Confirm received from Lee Lane for Cleaner" })).toBeTruthy();
+ const owe = screen.getByRole("region", { name: "You owe" });
+ expect(within(owe).getByText("You owe Lee Lane (left)")).toBeTruthy();
+ expect(within(screen.getByRole("region", { name: "Recent repayments" })).getByText("Lee Lane (left) to you")).toBeTruthy();
+ expect(screen.queryByText(/former member/i)).toBeNull();
+ });
+
+ it("names them on an expense they paid for or share in", async () => {
+ mount("/expenses");
+ await screen.findByRole("heading", { name: "Expenses" });
+ await act(async () => app.go("expense", { expense: "expense-2" }));
+ const paint = await screen.findByRole("dialog", { name: "Paint" });
+ expect(within(paint).getByText(/^Lee Lane \(left\) paid £20\.00\./)).toBeTruthy();
+ await act(async () => app.go("expense", { expense: "expense-1" }));
+ const cleaner = await screen.findByRole("dialog", { name: "Cleaner" });
+ expect(within(cleaner).getByText("Lee Lane (left)")).toBeTruthy();
+ });
+
+ it("names them on recurring bills and pledges", async () => {
+ current.recurringBills = [{ id: "bill-1", title: "Window cleaner", amountMinor: 2000, paidByUserId: "user-3", createdBy: "user-3",
+ version: 1, archivedAt: null, frequency: "monthly", nextDueOn: "2026-11-01", paused: true,
+ allocations: [{ userId: user.id, amountMinor: 1000 }, { userId: "user-3", amountMinor: 1000 }] }];
+ current.funds = [{ id: "fund-1", name: "Boiler fund", targetMinor: 50000, createdBy: user.id, archivedAt: null, commitments: [
+ { id: "pledge-1", userId: "user-3", amountMinor: 5000, contributedMinor: 1000, dueOn: "2026-10-01", cancelledAt: null, contributions: [] },
+ ] }];
+ mount("/recurring");
+ expect(await screen.findByText(/paid by Lee Lane \(left\)/)).toBeTruthy();
+ await act(async () => app.go("funds"));
+ fireEvent.click(await screen.findByRole("button", { name: "View fund Boiler fund" }));
+ const fund = await screen.findByRole("dialog", { name: "Boiler fund" });
+ expect(within(fund).getByText("Lee Lane (left)")).toBeTruthy();
+ });
+
+ it("names them in templates, announcements and notices", async () => {
+ mount("/templates");
+ expect(await screen.findByText(/Equal · Maya Chen, Lee Lane \(left\)/)).toBeTruthy();
+ await act(async () => app.go("hub"));
+ const announcements = await screen.findByRole("region", { name: "Announcements" });
+ expect(within(announcements).getByText(/^Lee Lane · /)).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: /^Notifications/ }));
+ const notices = await screen.findByRole("dialog", { name: "Notices" });
+ expect(within(notices).getByText(/Lee Lane says they sent £5\.00/)).toBeTruthy();
+ });
+});
diff --git a/apps/web/tests/loading.test.tsx b/apps/web/tests/loading.test.tsx
index b7689736..66ebaa81 100644
--- a/apps/web/tests/loading.test.tsx
+++ b/apps/web/tests/loading.test.tsx
@@ -186,39 +186,6 @@ describe("commune loading", () => {
expect(check.disabled).toBe(false);
});
- it.each(["/funds", "/settle"])("keeps the overall reminder allowance in the sidebar instead of the %s toolbar", async (path) => {
- mount(path);
- // Named from what it shows, then the allowance in words.
- const summary = (await screen.findByText("60 reminders left")).closest("summary")!;
- expect(summary.closest(".a-side-account")).not.toBeNull();
- expect(summary.hasAttribute("aria-label")).toBe(false);
- expect(summary.textContent).toBe("60 reminders left (0 of 60 used in the last 30 days)");
- const metadata = summary.closest(".a-side-account-meta")!;
- expect(metadata.querySelector(".a-side-account-role")?.textContent).toBe("Admin");
- expect(metadata.querySelector(".a-side-account-separator")?.getAttribute("aria-hidden")).toBe("true");
- expect(metadata.textContent).toContain("Admin·60 reminders left");
- expect(summary.closest("details")?.parentElement).toBe(metadata);
- expect(summary.closest("button")).toBeNull();
- expect(summary.closest(".a-side-account")?.textContent).not.toContain(commune.name);
- expect(document.querySelector(`${path.replace("/", "#")} .a-reminder-usage`)).toBeNull();
- expect(document.querySelectorAll(".a-reminder-usage")).toHaveLength(1);
- });
-
- it("does not show a separator or an invented count while the reminder allowance is pending", async () => {
- const usage = deferred<{ totalUsed: number; totalLimit: number; recipientLimit: number; recipientUsage: Record; nextSlotAt: null }>();
- const baseApi = vi.mocked(api).getMockImplementation()!;
- vi.mocked(api).mockImplementation(async (path, options) => path === "/reminders/usage" ? usage.promise : baseApi(path, options));
- mount();
- await screen.findByRole("heading", { name: commune.name });
- const metadata = document.querySelector(".a-side-account-meta")!;
- expect(metadata.textContent).toBe("Admin");
- expect(metadata.querySelector(".a-side-account-separator")).toBeNull();
- await act(async () => usage.resolve({ totalUsed: 12, totalLimit: 60, recipientLimit: 6, recipientUsage: {}, nextSlotAt: null }));
- expect((await screen.findByText("48 reminders left")).closest("summary")?.textContent).toContain("12 of 60 used in the last 30 days");
- expect(metadata.textContent).toContain("Admin·48 reminders left");
- expect(metadata.querySelectorAll(".a-side-account-separator")).toHaveLength(1);
- });
-
it("offers the same allowance in account settings and closes its details with Escape", async () => {
mount("/settings");
await screen.findByRole("heading", { name: "Settings", exact: true });
@@ -614,7 +581,7 @@ describe("commune loading", () => {
mount("/");
await screen.findByRole("heading", { name: "Our home" });
expect(screen.getByRole("heading", { name: "Second home" })).toBeTruthy();
- expect(screen.queryByText("Nothing waiting right now.")).toBeNull();
+ expect(screen.queryByText("Nothing for you to do right now.")).toBeNull();
await act(async () => pending.reject(new Error("Not a commune member")));
expect(screen.getByRole("heading", { name: "Our home" })).toBeTruthy();
expect(screen.queryByRole("heading", { name: "Could not load Our home" })).toBeNull();
diff --git a/apps/web/tests/needs-you.test.tsx b/apps/web/tests/needs-you.test.tsx
new file mode 100644
index 00000000..110ef607
--- /dev/null
+++ b/apps/web/tests/needs-you.test.tsx
@@ -0,0 +1,191 @@
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render, screen, within } from "@testing-library/react";
+import { Router } from "wouter";
+import { memoryLocation } from "wouter/memory-location";
+import { App } from "../src/App";
+import { AppProvider } from "../src/state";
+import { api, loadCommunes, loadSnapshot } from "../src/live/api";
+import { needsYou, type Commune, type PaymentReport, type Snapshot } from "../src/live/model";
+
+vi.mock("../src/live/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ api: vi.fn(), post: vi.fn(), loadCommunes: vi.fn(), loadSnapshot: vi.fn(),
+}));
+
+// "Needs you" is one rule on Home and This Hub: payments someone says they
+// have sent (only the bill payer can confirm them), your unpaid shares, your
+// late jobs, then your pledges due. What others owe you waits separately.
+// It is Friday 9 October 2026.
+const today = "2026-10-09";
+const user = { id: "me", name: "Maya Chen", email: "maya@example.test", emailVerified: true };
+const home: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin" };
+const trip: Commune = { id: "trip-1", name: "Lisbon trip", kind: "trip", currency: "EUR", role: "member" };
+const members = [
+ { userId: "me", name: "Maya Chen", role: "admin" as const, active: true },
+ { userId: "ben", name: "Ben Baker", role: "member" as const, active: true },
+ { userId: "cat", name: "Cat Cole", role: "member" as const, active: true },
+ { userId: "lee", name: "Lee Lane", role: "member" as const, active: false },
+];
+const pending = (reportedBy: string, amountMinor: number): PaymentReport => ({
+ id: `report-${reportedBy}-${amountMinor}`, reportedBy, amountMinor, sentOn: "2026-10-08", reference: null, reportedAt: "2026-10-08T10:00:00Z",
+ status: "pending", resolvedAt: null, resolvedBy: null, note: null, receiptId: null,
+});
+type Expense = Snapshot["expenses"][number];
+const expense = (id: string, title: string, paidByUserId: string, dueOn: string | null, shares: Expense["shares"], extra: Partial = {}): Expense =>
+ ({ id, title, amountMinor: shares.reduce((total, share) => total + share.amountMinor, 0), paidByUserId, dueOn, shares, ...extra });
+const share = (userId: string, amountMinor: number, settledMinor = 0, paymentReports: PaymentReport[] = []) =>
+ ({ id: `share-${userId}-${amountMinor}`, userId, amountMinor, settledMinor, paymentReports });
+type Operation = NonNullable[number];
+const task = (id: string, title: string, date: string, assigneeId: string, kind: Operation["kind"] = "task") => ({
+ id, kind, category: kind === "task" ? "chore" : "visit", title, date, time: "", allDay: true, assignee: "", assigneeId,
+ rotation: [], rotationIds: [], repeat: "none", status: "open", notes: "", version: 1,
+}) as unknown as Operation;
+function snapshotOf(commune: Commune, extra: Partial = {}): Snapshot {
+ return {
+ commune, members, expenses: [], funds: [], notes: [], operations: [], notifications: [], reminderTargets: [],
+ recurringBills: [], splitTemplates: [], activityEvents: [], announcements: [], sentReminders: [], ...extra,
+ };
+}
+const homeSnapshot = () => snapshotOf(home, {
+ expenses: [
+ expense("electricity", "Electricity", "me", "2026-10-20", [share("me", 2000, 2000), share("ben", 2000, 0, [pending("ben", 2000)]), share("cat", 2000)]),
+ expense("internet", "Internet", "ben", "2026-10-01", [share("ben", 1500, 1500), share("me", 1500)]),
+ expense("rent", "Rent", "cat", "2026-10-15", [share("cat", 50000, 50000), share("me", 50000, 0, [pending("me", 50000)])]),
+ expense("takeaway", "Takeaway", "me", null, [share("ben", 1000), share("cat", 1000), share("lee", 1000)]),
+ expense("voided", "Voided bill", "ben", "2026-10-02", [share("me", 700)], { voidedAt: "2026-10-03T09:00:00Z" }),
+ expense("groceries", "Groceries", "ben", "2026-10-02", [share("me", 900, 900)]),
+ expense("water", "Water", "cat", null, [share("me", 800)]),
+ ],
+ operations: [
+ task("bins", "Bins", "2026-10-07", "me"), task("hoover", "Hoover", today, "me"), task("mop", "Mop", "2026-10-10", "me"),
+ task("party", "Party", today, "me", "event"), task("dust", "Dust", "2026-10-01", "ben"),
+ ],
+ funds: [
+ { id: "boiler", name: "Boiler", targetMinor: 50000, createdBy: "me", archivedAt: null, commitments: [
+ { id: "pledge-1", userId: "me", amountMinor: 10000, contributedMinor: 4000, dueOn: "2026-10-05", cancelledAt: null, contributions: [] },
+ { id: "pledge-2", userId: "ben", amountMinor: 10000, contributedMinor: 0, dueOn: "2026-10-05", cancelledAt: null, contributions: [] },
+ ] },
+ { id: "old", name: "Old sofa", targetMinor: null, createdBy: "me", archivedAt: "2026-09-01T00:00:00Z", commitments: [
+ { id: "pledge-3", userId: "me", amountMinor: 5000, contributedMinor: 0, dueOn: "2026-09-01", cancelledAt: null, contributions: [] },
+ ] },
+ ] as unknown as Snapshot["funds"],
+});
+// Another commune, seen on Home through its summary: Ben says he has sent Maya his share of the ferry.
+const tripSummary = () => ({ ...snapshotOf(trip, {
+ expenses: [expense("ferry", "Ferry", "me", "2026-10-30", [share("me", 2500, 2500), share("ben", 2500, 0, [pending("ben", 2500)])])],
+}), detail: "summary" as const });
+
+const rows = (need: { title: string; kind: string; when: string; amount?: string }[]) => need.map((item) => [item.kind, item.title, item.when, item.amount]);
+
+describe("the Needs you rule", () => {
+ it("puts payments to confirm first, then shares to pay, late and today's tasks, then pledges due", () => {
+ const { actions } = needsYou(homeSnapshot(), "me", today);
+ expect(rows(actions)).toEqual([
+ ["confirm", "Electricity", "Ben Baker says they sent it", "£20.00"],
+ ["pay", "Internet", "You owe Ben Baker · overdue", "£15.00"],
+ ["pay", "Water", "You owe Cat Cole", "£8.00"],
+ ["task", "Bins", "Overdue · your turn", undefined],
+ ["task", "Hoover", "Your turn today", undefined],
+ ["pledge", "Boiler", "Remaining on your pledge · overdue", "£60.00"],
+ ]);
+ expect(actions.map((item) => item.late)).toEqual([false, true, false, true, false, true]);
+ });
+
+ it("keeps what others owe you, and what you said you sent, waiting on them", () => {
+ const { waiting } = needsYou(homeSnapshot(), "me", today);
+ expect(rows(waiting)).toEqual([
+ // Ben's report is to confirm; Cat's share is still owed.
+ ["owed", "Electricity", "Cat Cole owes you", "£20.00"],
+ ["owed", "Takeaway", "3 people owe you", "£30.00"],
+ ["reported", "Rent", "You said you sent it · waiting for Cat Cole", "£500.00"],
+ ]);
+ });
+});
+
+function mount(path: string) {
+ const location = memoryLocation({ path, record: true });
+ render();
+ return location;
+}
+const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
+beforeAll(() => {
+ Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } });
+});
+afterAll(() => {
+ if (modalDescriptor) Object.defineProperty(HTMLDialogElement.prototype, "showModal", modalDescriptor);
+ else delete (HTMLDialogElement.prototype as unknown as Record).showModal;
+});
+beforeEach(() => {
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date(2026, 9, 9, 12, 0));
+ vi.resetAllMocks();
+ sessionStorage.clear();
+ localStorage.clear();
+ vi.mocked(loadCommunes).mockImplementation(async () => ({ communes: [home, trip] }));
+ vi.mocked(loadSnapshot).mockImplementation(async (commune) => commune.id === home.id ? homeSnapshot() : tripSummary());
+ vi.mocked(api).mockImplementation(async (path) => {
+ if (path === "/auth/me") return { user };
+ if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 8, recipientUsage: {}, nextSlotAt: null };
+ if (path.endsWith("/invites")) return { invites: [] };
+ return { ok: true };
+ });
+});
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+});
+
+const feed = (row: HTMLElement) => [row.querySelector(".pill")?.className, row.querySelector(":scope > div > strong")?.textContent,
+ row.querySelector(".when")?.textContent];
+
+describe("Needs you on Home", () => {
+ it("lists every commune's actions in the same order, then what others owe, more quietly", async () => {
+ mount("/");
+ const card = (await screen.findByRole("heading", { name: "Needs you" })).closest(".a-card") as HTMLElement;
+ await within(card).findByText("Ferry");
+ const waiting = within(card).getByRole("region", { name: "Waiting on others" });
+ const actions = [...card.querySelectorAll(".hub-feed-row")].filter((row) => !waiting.contains(row));
+ expect(actions.map(feed)).toEqual([
+ // Both reports, the trip's too, come before a share that was due earlier.
+ ["pill open", "Electricity", "Ben Baker says they sent it · £20.00"],
+ ["pill open", "Ferry", "Ben Baker says they sent it · €25.00"],
+ ["pill warn", "Internet", "You owe Ben Baker · overdue · £15.00"],
+ ["pill open", "Water", "You owe Cat Cole · £8.00"],
+ ["pill warn", "Bins", "Overdue · your turn"],
+ ["pill open", "Hoover", "Your turn today"],
+ ]);
+ expect(within(card).getByText("And 1 more.")).toBeTruthy();
+ expect([...waiting.querySelectorAll(".hub-feed-row")].map(feed)).toEqual([
+ ["pill", "Electricity", "Cat Cole owes you · £20.00"],
+ ["pill", "Takeaway", "3 people owe you · £30.00"],
+ ["pill", "Rent", "You said you sent it · waiting for Cat Cole · £500.00"],
+ ]);
+ });
+});
+
+describe("Needs you on This Hub", () => {
+ it("counts only what needs the viewer, in the same order, and keeps what others owe apart", async () => {
+ mount("/hub");
+ const needs = await screen.findByRole("region", { name: "Needs you" });
+ await within(needs).findAllByText("Electricity");
+ expect(within(needs).getByText("6 items")).toBeTruthy();
+ const waiting = within(needs).getByRole("region", { name: "Waiting on others" });
+ const money = [...needs.querySelectorAll(".c-hub-attention-row")].map((row) =>
+ [row.querySelector("strong")?.textContent, row.querySelector("small")?.textContent, row.querySelector(".c-hub-attention-end strong")?.textContent]);
+ expect(money).toEqual([
+ ["Electricity", "Ben Baker says they sent it", "£20.00"],
+ ["Internet", "You owe Ben Baker · overdue", "£15.00"],
+ ["Water", "You owe Cat Cole", "£8.00"],
+ ]);
+ const others = [...needs.querySelectorAll(".c-hub-note-row")].filter((row) => !waiting.contains(row))
+ .map((row) => [row.querySelector("strong")?.textContent, row.querySelector("small")?.textContent]);
+ expect(others).toEqual([
+ ["Bins", "Overdue · your turn"],
+ ["Hoover", "Your turn today"],
+ ["Boiler", "Remaining on your pledge · overdue · £60.00"],
+ ]);
+ expect([...waiting.querySelectorAll(".c-hub-note-row")].map((row) => row.querySelector("small")?.textContent)).toEqual([
+ "Cat Cole owes you · £20.00", "3 people owe you · £30.00", "You said you sent it · waiting for Cat Cole · £500.00",
+ ]);
+ });
+});
diff --git a/apps/web/tests/neon-auth.test.tsx b/apps/web/tests/neon-auth.test.tsx
index a569e093..06b29d36 100644
--- a/apps/web/tests/neon-auth.test.tsx
+++ b/apps/web/tests/neon-auth.test.tsx
@@ -44,6 +44,35 @@ describe("hosted read authentication", () => {
expect(mocks.from).not.toHaveBeenCalled();
});
+ it("carries each commune's unread notice count for the bell, when the database sends one (0039)", async () => {
+ listResult([{ ...listed, unreadNotices: 3 }, { ...listed, id: "commune-2" }]);
+ const { listCommunes } = await import("../src/live/neon");
+ const [counted, older] = await listCommunes();
+ expect(counted.unreadNotices).toBe(3);
+ // Before 0039 the field is missing, and the app counts the open commune's own notices.
+ expect("unreadNotices" in older).toBe(false);
+ listResult([{ ...listed, unreadNotices: "many" }]);
+ await expect(listCommunes()).rejects.toMatchObject({ status: 502 });
+ });
+
+ it("checks a verified session before reading notices across communes", async () => {
+ const { neonApi } = await import("../src/live/neon");
+ await expect(neonApi("/notices/unread")).rejects.toThrow("Sign in required");
+ expect(mocks.rpc).not.toHaveBeenCalled();
+ });
+
+ it("reads unread notices across communes through one guarded RPC and refuses a malformed answer", async () => {
+ mocks.getSession.mockResolvedValue({ data: { user: { id: "viewer", emailVerified: true } } });
+ const notices = [{ id: "notice-1", kind: "payment-reported", communeId: "commune-2", senderName: "Ben", fundId: null }];
+ mocks.rpc.mockResolvedValue({ data: notices, error: null });
+ const { neonApi } = await import("../src/live/neon");
+ expect(await neonApi("/notices/unread")).toEqual({ notices });
+ expect(mocks.rpc).toHaveBeenCalledExactlyOnceWith("my_unread_notices");
+ mocks.rpc.mockResolvedValue({ data: null, error: null });
+ await expect(neonApi("/notices/unread")).rejects.toMatchObject({ status: 502 });
+ expect(mocks.from).not.toHaveBeenCalled();
+ });
+
it("does not publish a commune without the viewer's membership or its change mark", async () => {
const { listCommunes } = await import("../src/live/neon");
for (const entry of [{ id: "commune-1", name: "Home" }, { ...listed, role: "owner" }, { ...listed, changeMark: null }]) {
diff --git a/apps/web/tests/notices-across-communes.test.tsx b/apps/web/tests/notices-across-communes.test.tsx
new file mode 100644
index 00000000..8a0c9f9f
--- /dev/null
+++ b/apps/web/tests/notices-across-communes.test.tsx
@@ -0,0 +1,172 @@
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { Router } from "wouter";
+import { memoryLocation } from "wouter/memory-location";
+import { App } from "../src/App";
+import { AppProvider, useApp, type AppState } from "../src/state";
+import { api, post, loadCommunes, loadSnapshot } from "../src/live/api";
+import type { Commune, Notification, Snapshot, UnreadNotice } 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(),
+}));
+
+// The bell counts unread notices in every commune (0039), and Notices lists
+// the other communes' unread ones under their names.
+const user = { id: "user-1", name: "Test Person", email: "test@example.test", emailVerified: true };
+const home: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin" };
+const trip: Commune = { id: "trip-1", name: "Lisbon trip", kind: "trip", currency: "EUR", role: "member" };
+const members = [
+ { userId: user.id, name: user.name, role: "admin" as const, active: true },
+ { userId: "user-2", name: "Housemate Two", role: "member" as const, active: true },
+];
+const homeNotice: Notification = {
+ id: "notice-home", kind: "payment-reported", sourceId: "expense-home", amountMinor: 1200, communeId: home.id,
+ senderUserId: "user-2", expenseId: "expense-home", title: "Gas", detail: null, createdAt: "2026-10-06T09:00:00Z", readAt: null,
+};
+const tripNotice: UnreadNotice = {
+ id: "notice-trip", kind: "expense-corrected", sourceId: "expense-trip", amountMinor: 2500, communeId: trip.id,
+ senderUserId: "user-3", expenseId: "expense-trip", title: "Ferry tickets", detail: "Ann had the cabin",
+ createdAt: "2026-10-07T09:00:00Z", readAt: null, senderName: "Ben Baker", fundId: null,
+};
+const ferryExpense = {
+ id: "expense-trip", title: "Ferry tickets", amountMinor: 4000, paidByUserId: "user-2", dueOn: null,
+ shares: [
+ { id: "share-trip-1", userId: user.id, amountMinor: 2500, settledMinor: 0 },
+ { id: "share-trip-2", userId: "user-2", amountMinor: 1500, settledMinor: 1500 },
+ ],
+};
+function snapshotOf(commune: Commune, extra: Partial = {}): Snapshot {
+ return {
+ commune, members, expenses: [], funds: [], notes: [], operations: [], notifications: [], reminderTargets: [],
+ recurringBills: [], splitTemplates: [], activityEvents: [], announcements: [], sentReminders: [], ...extra,
+ };
+}
+let snapshots: Record;
+let listed: Commune[];
+let unread: UnreadNotice[];
+let app: AppState;
+function Capture() { app = useApp(); return null; }
+function mount(path: string) {
+ const location = memoryLocation({ path, record: true });
+ render();
+ return location;
+}
+const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
+const originalScroll = Element.prototype.scrollIntoView;
+beforeAll(() => {
+ Element.prototype.scrollIntoView = vi.fn();
+ Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } });
+});
+afterAll(() => {
+ Element.prototype.scrollIntoView = originalScroll;
+ if (modalDescriptor) Object.defineProperty(HTMLDialogElement.prototype, "showModal", modalDescriptor);
+ else delete (HTMLDialogElement.prototype as unknown as Record).showModal;
+});
+beforeEach(() => {
+ vi.resetAllMocks();
+ sessionStorage.clear();
+ localStorage.clear();
+ snapshots = {
+ [home.id]: snapshotOf({ ...home, unreadNotices: 1 }, { notifications: [homeNotice] }),
+ [trip.id]: snapshotOf({ ...trip, unreadNotices: 2 }, { expenses: [ferryExpense] }),
+ };
+ listed = [{ ...home, unreadNotices: 1 }, { ...trip, unreadNotices: 2 }];
+ unread = [tripNotice, { ...tripNotice, id: "notice-trip-2", kind: "payment-confirmed", title: "Dinner", expenseId: "expense-dinner",
+ createdAt: "2026-10-05T09:00:00Z" }, { ...homeNotice, senderName: "Housemate Two", fundId: null }];
+ vi.mocked(loadCommunes).mockImplementation(async () => ({ communes: listed }));
+ vi.mocked(loadSnapshot).mockImplementation(async (commune) => snapshots[commune.id]);
+ vi.mocked(api).mockImplementation(async (path) => {
+ if (path === "/auth/me") return { user };
+ if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 8, recipientUsage: {}, nextSlotAt: null };
+ if (path.endsWith("/invites")) return { invites: [] };
+ if (path === "/notices/unread") return { notices: unread };
+ return { ok: true };
+ });
+});
+afterEach(cleanup);
+
+describe("notices across communes", () => {
+ it.each(["/", "/hub", "/expenses"])("counts every commune's unread notices in the bell on %s", async (path) => {
+ mount(path);
+ const bell = await screen.findByRole("button", { name: "Notifications, 3 unread" });
+ expect(bell.classList.contains("has-unread")).toBe(true);
+ });
+
+ it("falls back to the open commune's own notices while the database sends no counts", async () => {
+ listed = [home, trip];
+ snapshots[home.id] = snapshotOf(home, { notifications: [homeNotice] });
+ mount("/hub");
+ expect(await screen.findByRole("button", { name: "Notifications, 1 unread" })).toBeTruthy();
+ });
+
+ it("lists this commune's notices first, then other communes' unread ones under their names", async () => {
+ mount("/hub");
+ fireEvent.click(await screen.findByRole("button", { name: "Notifications, 3 unread" }));
+ const notices = await screen.findByRole("dialog", { name: "Notices" });
+ await within(notices).findByText("Ferry tickets");
+ const headings = within(notices).getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent);
+ expect(headings).toEqual(["Our home", "Lisbon trip"]);
+ const here = within(notices).getByRole("region", { name: "Our home" });
+ expect(within(here).getByText("Gas")).toBeTruthy();
+ expect(within(here).getByText(/Housemate Two says they sent £12\.00/)).toBeTruthy();
+ const there = within(notices).getByRole("region", { name: "Lisbon trip" });
+ // Their own commune's notices are not repeated, and amounts use that commune's currency.
+ expect(within(there).queryByText("Gas")).toBeNull();
+ expect(within(there).getByText(/Corrected by Ben Baker · your share is now €25\.00/)).toBeTruthy();
+ expect(within(there).getByText("Dinner")).toBeTruthy();
+ expect(vi.mocked(api).mock.calls.filter(([path]) => path === "/notices/unread")).toHaveLength(1);
+ });
+
+ it("reads a notice from another commune there, then switches to that commune and opens it", async () => {
+ vi.mocked(post).mockImplementation(async () => {
+ snapshots[trip.id] = snapshotOf({ ...trip, unreadNotices: 1 }, { expenses: [ferryExpense] });
+ return { ok: true };
+ });
+ mount("/hub");
+ fireEvent.click(await screen.findByRole("button", { name: "Notifications, 3 unread" }));
+ const notices = await screen.findByRole("dialog", { name: "Notices" });
+ const ferry = await within(notices).findByText("Ferry tickets");
+ await act(async () => { fireEvent.click(ferry.closest("button")!); });
+ expect(vi.mocked(post)).toHaveBeenCalledWith("/notifications/notice-trip/read", {});
+ await waitFor(() => expect(app.hub.id).toBe(trip.id));
+ expect(app.window).toBe("expense");
+ expect(app.selectedExpense?.id).toBe("expense-trip");
+ // The trip was read again after the notice was, so its count is the new one.
+ expect(vi.mocked(loadSnapshot).mock.calls.some(([commune, detail]) => commune.id === trip.id && detail === undefined)).toBe(true);
+ expect(await screen.findByRole("button", { name: "Notifications, 2 unread" })).toBeTruthy();
+ });
+
+ it("does not ask for other communes' notices when none are unread", async () => {
+ listed = [{ ...home, unreadNotices: 1 }, { ...trip, unreadNotices: 0 }];
+ mount("/hub");
+ fireEvent.click(await screen.findByRole("button", { name: "Notifications, 1 unread" }));
+ const notices = await screen.findByRole("dialog", { name: "Notices" });
+ expect(within(notices).getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual(["Our home"]);
+ expect(vi.mocked(api).mock.calls.some(([path]) => path === "/notices/unread")).toBe(false);
+ });
+
+ it("keeps this commune's notices when the others cannot be read, and offers to try again", async () => {
+ let fail = true;
+ vi.mocked(api).mockImplementation(async (path) => {
+ if (path === "/auth/me") return { user };
+ if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 8, recipientUsage: {}, nextSlotAt: null };
+ if (path.endsWith("/invites")) return { invites: [] };
+ if (path === "/notices/unread") {
+ if (fail) throw new Error("Notices could not be verified. Please try again.");
+ return { notices: unread };
+ }
+ return { ok: true };
+ });
+ mount("/hub");
+ fireEvent.click(await screen.findByRole("button", { name: "Notifications, 3 unread" }));
+ const notices = await screen.findByRole("dialog", { name: "Notices" });
+ const there = within(notices).getByRole("region", { name: "Lisbon trip" });
+ expect((await within(there).findByRole("alert")).textContent).toContain("Notices could not be verified");
+ expect(within(notices).getByText("Gas")).toBeTruthy();
+ fail = false;
+ fireEvent.click(within(there).getByRole("button", { name: "Try again" }));
+ expect(await within(there).findByText("Ferry tickets")).toBeTruthy();
+ });
+});
diff --git a/apps/web/tests/quick-search.test.tsx b/apps/web/tests/quick-search.test.tsx
index d1a2a2c1..89847680 100644
--- a/apps/web/tests/quick-search.test.tsx
+++ b/apps/web/tests/quick-search.test.tsx
@@ -29,7 +29,7 @@ beforeEach(() => {
places: [{ id: "home-1", name: "Our home" }, { id: "home-2", name: "Maple House" }],
session: { hasHubs: true }, go: mocks.go, setSearchQuery: mocks.setQuery, setSearchScope: mocks.setScope,
pageTitle: "Expenses", pageLede: "Shared expenses", setMoreOpen: vi.fn(), native: false,
- you: { name: "Alex", initials: "AL" }, noticesOn: true, notifications: [], window: null };
+ you: { name: "Alex", initials: "AL" }, noticesOn: true, notifications: [], noticeCounts: {}, window: null };
mocks.api.mockResolvedValue(response([item("expense", "expense-2", "Garden supplies")]));
});
afterEach(cleanup);
diff --git a/apps/web/tests/reminder-allowance.test.tsx b/apps/web/tests/reminder-allowance.test.tsx
new file mode 100644
index 00000000..e0876b01
--- /dev/null
+++ b/apps/web/tests/reminder-allowance.test.tsx
@@ -0,0 +1,150 @@
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { Router } from "wouter";
+import { memoryLocation } from "wouter/memory-location";
+import { App } from "../src/App";
+import { AppProvider, useApp, type AppState } from "../src/state";
+import { api, loadCommunes, loadSnapshot } from "../src/live/api";
+import type { Commune, ReminderTarget, ReminderUsage, Snapshot } from "../src/live/model";
+import { ReminderAllowanceNotice } from "../src/ui/ReminderUsage";
+
+vi.mock("../src/live/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ api: vi.fn(), post: vi.fn(), loadCommunes: vi.fn(), loadSnapshot: vi.fn(),
+}));
+
+// The reminder allowance is not part of who you are: it is said where
+// reminders are sent, once a fifth of it or less is left.
+const user = { id: "user-1", name: "Test Person", email: "test@example.test", emailVerified: true };
+const housemate = "user-2";
+const commune: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin" };
+const target = { recipientUserId: housemate, amountMinor: 1500, currency: "GBP", label: "", dueOn: "2026-10-01", lastSentAt: null } as const;
+const reminderTargets: ReminderTarget[] = [{ ...target, kind: "nudge", sourceId: "share-2" }, { ...target, kind: "chase", sourceId: "pledge-1" }];
+const snapshot: Snapshot = {
+ commune, reminderTargets, notifications: [], notes: [], operations: [], recurringBills: [], splitTemplates: [],
+ activityEvents: [], announcements: [], sentReminders: [],
+ members: [{ userId: user.id, name: user.name, role: "admin" }, { userId: housemate, name: "Housemate", role: "member" }],
+ // The viewer paid for the gas, and admins chase pledges to the boiler fund.
+ expenses: [{ id: "expense-1", title: "Gas", amountMinor: 3000, paidByUserId: user.id, dueOn: "2026-10-01", shares: [
+ { id: "share-1", userId: user.id, amountMinor: 1500, settledMinor: 1500 },
+ { id: "share-2", userId: housemate, amountMinor: 1500, settledMinor: 0 },
+ ] }],
+ funds: [{ id: "fund-1", name: "Boiler fund", targetMinor: 50000, createdBy: user.id, archivedAt: null, commitments: [
+ { id: "pledge-1", userId: housemate, amountMinor: 5000, contributedMinor: 0, dueOn: "2026-10-01", cancelledAt: null, contributions: [] },
+ ] }],
+};
+const allowance = (totalUsed: number, nextSlotAt: string | null = null): ReminderUsage =>
+ ({ totalUsed, totalLimit: 60, recipientLimit: 6, recipientUsage: {}, nextSlotAt });
+const nextSlotAt = "2026-11-02T10:00:00Z";
+// When the next one comes back, in the member's own time zone.
+const nextLabel = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" })
+ .format(new Date(nextSlotAt));
+const low = "12 of your 60 reminders left. Each one comes back 30 days after it was sent.";
+
+let usage: ReminderUsage;
+let app: AppState;
+function Capture() { app = useApp(); return null; }
+function mount(path: string) {
+ const location = memoryLocation({ path, record: true });
+ render();
+ return location;
+}
+const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal");
+const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close");
+beforeAll(() => {
+ Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } });
+ Object.defineProperty(HTMLDialogElement.prototype, "close", { configurable: true, value: function (this: HTMLDialogElement) { this.removeAttribute("open"); } });
+});
+afterAll(() => {
+ for (const [name, descriptor] of [["showModal", modalDescriptor], ["close", closeDescriptor]] as const) {
+ if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, name, descriptor);
+ else delete (HTMLDialogElement.prototype as unknown as Record)[name];
+ }
+});
+beforeEach(() => {
+ vi.resetAllMocks();
+ sessionStorage.clear();
+ localStorage.clear();
+ usage = allowance(0);
+ vi.mocked(loadCommunes).mockImplementation(async () => ({ communes: [commune] }));
+ vi.mocked(loadSnapshot).mockImplementation(async () => snapshot);
+ vi.mocked(api).mockImplementation(async (path) => {
+ if (path === "/auth/me") return { user };
+ if (path === "/reminders/usage") return usage;
+ if (path.endsWith("/invites")) return { invites: [] };
+ return { ok: true };
+ });
+});
+afterEach(cleanup);
+
+describe("the reminder allowance", () => {
+ it("says nothing until a fifth or fewer are left, then how many and when they come back", () => {
+ const { container, rerender } = render();
+ expect(container.textContent).toBe("");
+ rerender();
+ expect(screen.getByRole("note").textContent).toBe(low);
+ rerender();
+ expect(screen.getByRole("note").textContent).toBe("1 of your 60 reminders left. Each one comes back 30 days after it was sent.");
+ rerender();
+ expect(screen.getByRole("note").textContent).toBe("No reminders left.");
+ rerender();
+ expect(screen.getByRole("note").textContent).toBe(`No reminders left. The next one comes back on ${nextLabel}.`);
+ rerender();
+ expect(container.textContent).toBe("");
+ });
+
+ it.each(["/funds", "/settle"])("stays out of the sidebar's account line and the %s toolbar", async (path) => {
+ mount(path);
+ await waitFor(() => expect(app.reminderUsage).not.toBeNull());
+ // Only the role sits under the name: a new member has nothing to count yet.
+ expect(document.querySelector(".a-side-account-meta")?.textContent).toBe("Admin");
+ expect(document.querySelector(".a-side-account .a-reminder-usage")).toBeNull();
+ expect(document.querySelector(`${path.replace("/", "#")} .a-toolbar .a-reminder-usage`)).toBeNull();
+ expect(screen.queryByText(/reminders left/)).toBeNull();
+ });
+
+ it("is said on Settle, the expense and the fund once it runs low", async () => {
+ usage = allowance(47);
+ mount("/settle");
+ const owed = await screen.findByRole("region", { name: "You’re owed" });
+ await within(owed).findByText("Housemate owes you");
+ await waitFor(() => expect(app.reminderUsage?.totalUsed).toBe(47));
+ // Thirteen of sixty left is more than a fifth: nothing to say yet.
+ expect(screen.queryByRole("note")).toBeNull();
+ usage = allowance(48);
+ await act(async () => app.refresh());
+ expect((await within(owed).findByRole("note")).textContent).toBe(low);
+ // Only where reminders are sent: not on what you owe.
+ expect(within(screen.getByRole("region", { name: "You owe" })).queryByRole("note")).toBeNull();
+ await act(async () => app.go("expense", { expense: "expense-1" }));
+ expect(within(await screen.findByRole("dialog", { name: "Gas" })).getByRole("note").textContent).toBe(low);
+ await act(async () => app.go("funds"));
+ fireEvent.click(await screen.findByRole("button", { name: "View fund Boiler fund" }));
+ const fund = await screen.findByRole("dialog", { name: "Boiler fund" });
+ expect(within(fund).getByRole("note").textContent).toBe(low);
+ // None left: when the next one comes back.
+ usage = allowance(60, nextSlotAt);
+ await act(async () => app.refresh());
+ await waitFor(() => expect(within(fund).getByRole("note").textContent).toBe(`No reminders left. The next one comes back on ${nextLabel}.`));
+ });
+
+ it("is not offered to someone who cannot send one", async () => {
+ usage = allowance(55);
+ // A member's own pledge, and an expense someone else paid for.
+ vi.mocked(loadCommunes).mockImplementation(async () => ({ communes: [{ ...commune, role: "member" }] }));
+ vi.mocked(loadSnapshot).mockImplementation(async () => ({ ...snapshot, commune: { ...commune, role: "member" },
+ expenses: [{ ...snapshot.expenses[0], paidByUserId: housemate, shares: [
+ { id: "share-1", userId: user.id, amountMinor: 1500, settledMinor: 0 },
+ { id: "share-2", userId: housemate, amountMinor: 1500, settledMinor: 1500 },
+ ] }] }));
+ mount("/settle");
+ await within(await screen.findByRole("region", { name: "You owe" })).findByText("You owe Housemate");
+ await waitFor(() => expect(app.reminderUsage?.totalUsed).toBe(55));
+ expect(screen.queryByRole("note")).toBeNull();
+ await act(async () => app.go("expense", { expense: "expense-1" }));
+ expect(within(await screen.findByRole("dialog", { name: "Gas" })).queryByRole("note")).toBeNull();
+ await act(async () => app.go("funds"));
+ fireEvent.click(await screen.findByRole("button", { name: "View fund Boiler fund" }));
+ expect(within(await screen.findByRole("dialog", { name: "Boiler fund" })).queryByRole("note")).toBeNull();
+ });
+});
diff --git a/apps/web/tests/workflow-screens.test.tsx b/apps/web/tests/workflow-screens.test.tsx
index 2db10bbf..52df2464 100644
--- a/apps/web/tests/workflow-screens.test.tsx
+++ b/apps/web/tests/workflow-screens.test.tsx
@@ -462,6 +462,28 @@ describe("complete shared workflows", () => {
expect(screen.getByRole("heading", { name: "Old split" })).toBeTruthy();
});
+ it("filters templates with the same side-by-side chips as Funds, marking the selected one", async () => {
+ // Each screen's filters: the chips, the one marked as selected, and any other control among them.
+ const filters = () => {
+ const group = document.querySelector(".a-toolbar .a-filters")!;
+ return {
+ chips: [...group.querySelectorAll(".a-chip")].map((chip) => chip.textContent),
+ on: [...group.querySelectorAll(".a-chip.is-on[aria-pressed=true]")].map((chip) => chip.textContent),
+ others: group.querySelectorAll(":scope > :not(.a-chip)").length,
+ };
+ };
+ mount("/funds");
+ await screen.findByRole("button", { name: "Archived", exact: true });
+ const funds = filters();
+ expect(funds).toEqual({ chips: ["Active", "Archived"], on: ["Active"], others: 0 });
+ await act(async () => app.go("templates"));
+ await screen.findByRole("button", { name: "Save template" });
+ expect(filters()).toEqual(funds);
+ fireEvent.click(screen.getByRole("button", { name: "Archived", exact: true }));
+ expect(filters()).toEqual({ chips: ["Active", "Archived"], on: ["Archived"], others: 0 });
+ expect(screen.getByRole("button", { name: "Active", exact: true }).getAttribute("aria-pressed")).toBe("false");
+ });
+
it("saves an actual recurring schedule and its exact penny allocations", async () => {
const base = vi.mocked(api).getMockImplementation()!;
vi.mocked(api).mockImplementation(async (path, options) => {
diff --git a/docs/DESIGN-LANGUAGE.md b/docs/DESIGN-LANGUAGE.md
index 2c8ac46a..d016f070 100644
--- a/docs/DESIGN-LANGUAGE.md
+++ b/docs/DESIGN-LANGUAGE.md
@@ -40,6 +40,7 @@ date ranges and user-written content. Keep the wording plain and concise.
| Focus and green text | `--focus-ring` and `--text-green`: today's sage in light mode, the Hub's #649578 in dark mode and on navy surfaces |
| Error text | `--danger` and `--danger-soft`, one error colour in both themes |
| Touch areas | On phones (560px and under) small controls reach 44px through an invisible `::before` area; on desktop small links reach 24px through padding with a matching negative margin. Nothing moves visually |
+| Text over cover photos | Small text keeps 4.5:1, and large text and buttons 3:1, over a white stand-in (`apps/web/tests/cover-contrast.test.ts`). The shade is darkest behind the text and anchored in pixels from the bottom; type labels and hero buttons have their own dark translucent backdrop. The owner approved the darker shade on 9 October |
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.
@@ -61,6 +62,8 @@ These are system responsibilities, not a demand to rename every existing compone
| SummaryAmount | Label, real amount, concise meaning, optional primary action. Money hierarchy through size rather than excessive boldness |
| ListRow | Leading identity/icon if informative, primary text, secondary metadata, trailing value/status. No nested conflicting click targets |
| StatusBadge | Compact labelled state. Green for settled/complete, amber for pending/attention, red for failure/overdue where appropriate; colour never the sole signal |
+| ActivityRow | A type chip leads: slate for money, sage for plans, cream for shared things. The type word comes first in the small line and the time sits at the end, under day headings in the search-results heading style |
+| FilterChips | Active/Archived and similar filters use `.a-chip` with `is-on` and `aria-pressed`, side by side, as on Funds and Saved splits |
| DateBadge | Shared `CalendarDate`: Operations' weekday strip, white face, date and Today/Tomorrow; add the month for dates beyond the week. 60px face / 52px below 1000px. Operations date column stays 72px / 56px below 1000px, no shadow or coloured edge |
| NoteCard / NoteReader | Existing board and reader. Type-specific fields without changing the basic interaction. Contacts keep editable roles; confidential values stay masked |
| FundCard | Purpose, contributed/target, progress, outstanding pledges and relevant next action. Explicitly records contributions; never implies Commune holds money |
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index d379bfcf..39d35b26 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -92,6 +92,17 @@ lifecycle pack. Work is paused for a handoff, not a production release.
Billing and Analytics are hidden, the unused Currency preference is gone,
and the app has a home-screen name and icon (redraw them with
`CHROMIUM=/path/to/chrome node apps/web/scripts/render-icons.mjs`).
+- Also from 9 October, for review: Home, notices and activity, from the same
+ review. The bell and Notices cover every commune (migration 0039,
+ `GET /api/notices/unread`). Needs you on Home and This Hub comes from one
+ rule, `needsYou()` in `apps/web/src/live/model.ts`: actions first, then
+ Waiting on others. Activity rows show the record type and time, grouped by
+ day (`apps/web/src/ui/ActivityLog.tsx`). `personName` names former members,
+ with "(left)" where it matters. The reminder allowance is in account
+ settings, on an expense its payer can nudge from, in the reminder
+ confirmation, and as a notice once a fifth or less is left; it is no longer
+ in the sidebar. Text over commune photos has a darker shade, measured by
+ `apps/web/tests/cover-contrast.test.ts` against a white stand-in.
- 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
diff --git a/infra/local/README.md b/infra/local/README.md
index 67c449c1..0090604e 100644
--- a/infra/local/README.md
+++ b/infra/local/README.md
@@ -43,7 +43,8 @@ files in order using a database owner connection:
37. `infra/neon/migrations/0036_lighter_loading.sql`
38. `infra/neon/migrations/0037_task_command_lock_order.sql`
39. `infra/neon/migrations/0038_invite_hint_and_client_reports.sql`
-40. `infra/local/after-core.sql`
+40. `infra/neon/migrations/0039_notices_and_activity.sql`
+41. `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 59d4a991..e9606fbf 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 0038 are local only**. They have not been applied to any
-Neon branch. Release order when they are: apply 0013 to 0038 in numeric order
+Migrations **0015 to 0039 are local only**. They have not been applied to any
+Neon branch. Release order when they are: apply 0013 to 0039 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`,
@@ -109,6 +109,14 @@ reports. A report names its release from Vercel's commit SHA, so expose
Vercel's system environment variables to the build; otherwise reports say
"local".
+0039 adds `unreadNotices` to `my_communes()`, `commune_summary()` and the
+snapshot's commune; `public.my_unread_notices()` for Notices across
+communes; a `recordType` on each snapshot activity event; and keeps a share's
+pending payment reports in `commune_summary()`. All of it is additive, so the
+previous client keeps working. The new client calls `my_unread_notices` only
+when another commune has unread notices: deploy it after 0039 and the schema
+cache refresh.
+
## Last recorded production evidence — 27 September 2026
The app is served at `https://app.ourcommune.io` from `main`. The latest
@@ -272,7 +280,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/0038_invite_hint_and_client_reports.sql` in numeric order to the
+ `migrations/0039_notices_and_activity.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/0039_notices_and_activity.sql b/infra/neon/migrations/0039_notices_and_activity.sql
new file mode 100644
index 00000000..9d67c932
--- /dev/null
+++ b/infra/neon/migrations/0039_notices_and_activity.sql
@@ -0,0 +1,119 @@
+-- Notices, activity and "Needs you" across communes.
+-- 1. The bell counted only the open commune's unread notices, so a notice
+-- from another commune showed nothing, even on Home. The commune list
+-- (public.my_communes) and the snapshot's commune now carry
+-- unreadNotices, the reader's unread count in that commune, so the
+-- minute check that already reads the list keeps the bell right for every
+-- commune. public.my_unread_notices() returns the unread notices of every
+-- commune the caller belongs to, in the snapshot's shape with the
+-- sender's name and a pledge notice's fund, for Notices to list under
+-- each commune's name. The app reads it only when Notices opens.
+-- 2. Activity said "Created" without saying what was created. Each activity
+-- event in the snapshot now names its record type (recordType): expense,
+-- repayment, fund, pledge, contribution, recurring, template, task,
+-- event, note, member or announcement. An event records only its area,
+-- so the type comes from what each command records with it.
+-- 3. "Needs you" lists first the payments someone says they have sent,
+-- which only the bill payer can confirm, on Home as on This Hub. Home
+-- shows the communes that are not open from the overview summary, which
+-- left out every payment report; it now keeps the pending ones.
+-- The shared functions are patched in place, so changes other migrations
+-- make to them stay. The web app released with this reads the new fields
+-- and shows what it showed before while they are missing. Apply after 0038.
+BEGIN;
+
+-- 1. Unread notices across communes -----------------------------------------
+
+-- The count reads only unread rows.
+CREATE INDEX notifications_unread_idx ON public.notifications (recipient_user_id, commune_id) WHERE read_at IS NULL;
+
+CREATE FUNCTION commune_private.unread_notice_count(p_commune_id uuid, p_user_id text)
+RETURNS integer LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$
+ SELECT count(*)::integer FROM public.notifications n
+ WHERE n.recipient_user_id = p_user_id AND n.commune_id = p_commune_id AND n.read_at IS NULL;
+$$;
+REVOKE ALL ON FUNCTION commune_private.unread_notice_count(uuid, text) FROM PUBLIC;
+
+-- 2. The record type of an activity event ------------------------------------
+
+-- A repayment's event carries its receipt, a pledge's or contribution's the
+-- row it changed (a pledge names its fund, a contribution its pledge), and a
+-- task or event keeps its kind after it is removed. Events written before
+-- their details were kept fall back to the words each command uses.
+CREATE FUNCTION commune_private.activity_record_type(p_event_id uuid)
+RETURNS text LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$
+ SELECT CASE e.kind
+ WHEN 'expenses' THEN CASE WHEN e.details ? 'receiptId' OR e.description LIKE 'Repayment %'
+ OR e.description = 'Share confirmed received' THEN 'repayment' ELSE 'expense' END
+ WHEN 'funds' THEN CASE WHEN e.details -> 'after' ? 'commitment_id' OR e.description LIKE 'Contribution %' THEN 'contribution'
+ WHEN e.details -> 'after' ? 'fund_id' OR e.description LIKE 'Pledge %' THEN 'pledge' ELSE 'fund' END
+ WHEN 'operations' THEN coalesce((SELECT o.kind FROM public.commune_operations o WHERE o.id = e.entity_id), 'task')
+ WHEN 'recurring' THEN 'recurring'
+ WHEN 'templates' THEN 'template'
+ WHEN 'notes' THEN 'note'
+ WHEN 'members' THEN 'member'
+ WHEN 'announcements' THEN 'announcement'
+ END
+ FROM public.commune_events e WHERE e.id = p_event_id;
+$$;
+REVOKE ALL ON FUNCTION commune_private.activity_record_type(uuid) FROM PUBLIC;
+
+-- Each patch must apply.
+DO $$
+DECLARE v_original text; v_source text;
+BEGIN
+ v_original := pg_get_functiondef('public.my_communes()'::regprocedure);
+ v_source := replace(v_original, $old$'changeMark', commune_private.change_mark(c.id))$old$,
+ $new$'changeMark', commune_private.change_mark(c.id),
+ 'unreadNotices', commune_private.unread_notice_count(c.id, m.user_id))$new$);
+ IF v_source = v_original THEN RAISE EXCEPTION 'Unsupported commune list definition'; END IF;
+ EXECUTE v_source;
+
+ v_original := pg_get_functiondef('public.commune_snapshot(uuid)'::regprocedure);
+ v_source := replace(v_original, $old$'currencyLockedBy',to_jsonb(commune_private.currency_locked_by(co.id))),$old$,
+ $new$'currencyLockedBy',to_jsonb(commune_private.currency_locked_by(co.id)),
+ 'unreadNotices',commune_private.unread_notice_count(co.id,v_actor)),$new$);
+ IF v_source = v_original THEN RAISE EXCEPTION 'Unsupported snapshot commune definition'; END IF;
+ v_original := v_source;
+ v_source := replace(v_original, $old$'description',a.description,'createdAt',a.created_at)$old$,
+ $new$'description',a.description,'createdAt',a.created_at,'recordType',commune_private.activity_record_type(a.id))$new$);
+ IF v_source = v_original THEN RAISE EXCEPTION 'Unsupported snapshot activity definition'; END IF;
+ EXECUTE v_source;
+
+ -- 3. A summary keeps a share's pending payment report, the one thing about
+ -- it that the bill payer has to act on. Repayments and answered reports
+ -- stay on the commune's own screens.
+ v_original := pg_get_functiondef('public.commune_summary(uuid)'::regprocedure);
+ v_source := replace(v_original, $old$y.item || '{"receipts": [], "paymentReports": []}'::jsonb$old$,
+ $new$y.item || jsonb_build_object('receipts', '[]'::jsonb, 'paymentReports',
+ coalesce((SELECT jsonb_agg(r.report ORDER BY r.ord) FROM jsonb_array_elements(y.item -> 'paymentReports') WITH ORDINALITY r(report, ord)
+ WHERE r.report ->> 'status' = 'pending'), '[]'::jsonb))$new$);
+ IF v_source = v_original THEN RAISE EXCEPTION 'Unsupported summary definition'; END IF;
+ EXECUTE v_source;
+END;
+$$;
+
+-- Unread notices in every commune the caller belongs to, newest first, at
+-- most 100 a commune (as the snapshot carries). Read only for communes that
+-- have one. A pledge notice names the fund holding the pledge while the fund
+-- is open, as the reader may not have that commune's records loaded.
+CREATE FUNCTION public.my_unread_notices()
+RETURNS jsonb LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$
+ SELECT coalesce(jsonb_agg(n.notice || jsonb_build_object(
+ 'senderName', names.value ->> (n.notice ->> 'senderUserId'),
+ 'fundId', (SELECT f.id FROM public.fund_commitments c JOIN public.funds f ON f.id = c.fund_id
+ WHERE n.notice ->> 'kind' IN ('pledge-changed', 'pledge-cancelled') AND c.id = (n.notice ->> 'sourceId')::uuid
+ AND f.archived_at IS NULL))
+ ORDER BY (n.notice ->> 'createdAt')::timestamptz DESC, n.notice ->> 'id'), '[]'::jsonb)
+ FROM public.commune_members m
+ CROSS JOIN LATERAL (SELECT commune_private.member_names(m.commune_id) AS value) names
+ CROSS JOIN LATERAL jsonb_array_elements(commune_private.snapshot_notifications(m.commune_id, m.user_id)) n(notice)
+ WHERE m.user_id = commune_private.data_api_user_id() AND m.active
+ AND commune_private.unread_notice_count(m.commune_id, m.user_id) > 0
+ AND n.notice ->> 'readAt' IS NULL;
+$$;
+REVOKE ALL ON FUNCTION public.my_unread_notices() FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.my_unread_notices() TO authenticated;
+
+NOTIFY pgrst, 'reload schema';
+COMMIT;
diff --git a/infra/neon/tests/grants.sql b/infra/neon/tests/grants.sql
index 624eb562..427f3085 100644
--- a/infra/neon/tests/grants.sql
+++ b/infra/neon/tests/grants.sql
@@ -40,9 +40,9 @@ BEGIN
RAISE EXCEPTION 'Anonymous workflow execution grant';
END IF;
- -- 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.
+ -- Money-lifecycle, occurrence, announcement, note and task commands (0015-0027), the
+ -- loading reads (0036) and the notices across communes (0039): 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)',
@@ -69,7 +69,8 @@ BEGIN
'public.my_communes()',
'public.commune_summary(uuid)',
'public.commune_expense_page(uuid,date,integer)',
- 'public.commune_expense_records(uuid,uuid[])'
+ 'public.commune_expense_records(uuid,uuid[])',
+ 'public.my_unread_notices()'
] LOOP
IF has_function_privilege('anonymous', table_name, 'EXECUTE')
OR has_function_privilege('public', table_name, 'EXECUTE')
@@ -115,7 +116,9 @@ BEGIN
'commune_private.expense_window_start()',
'commune_private.snapshot_expense_ids(uuid,timestamptz)',
'commune_private.expense_records(uuid[])',
- 'commune_private.expense_history(uuid)'
+ 'commune_private.expense_history(uuid)',
+ 'commune_private.unread_notice_count(uuid,text)',
+ 'commune_private.activity_record_type(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
index e2d475dd..0fddcaac 100644
--- a/infra/neon/tests/lighter_loading.sql
+++ b/infra/neon/tests/lighter_loading.sql
@@ -637,10 +637,14 @@ BEGIN
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)
+ WHERE e->'revisions' <> '[]'::jsonb OR s->'receipts' <> '[]'::jsonb
+ OR EXISTS (SELECT 1 FROM jsonb_array_elements(s->'paymentReports') r WHERE r->>'status' <> 'pending'))
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';
+ WHERE s->'receipts' <> '[]'::jsonb)
+ -- A pending report is for the bill payer to answer, so Home lists it (0039).
+ OR NOT EXISTS (SELECT 1 FROM jsonb_array_elements(v_summary->'expenses') e, jsonb_array_elements(e->'shares') s
+ WHERE s->>'id' = current_setting('test.report_share') AND s->'paymentReports'->0->>'status' = 'pending') THEN
+ RAISE EXCEPTION 'Summary expenses should keep their shares and pending reports without repayment, answered 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'
diff --git a/infra/neon/tests/notices_and_activity.sql b/infra/neon/tests/notices_and_activity.sql
new file mode 100644
index 00000000..a9001fd9
--- /dev/null
+++ b/infra/neon/tests/notices_and_activity.sql
@@ -0,0 +1,233 @@
+-- Notices, activity and "Needs you" across communes (0039). Run only in an
+-- empty disposable local DB built from infra/neon/tests/local_auth_stub.sql
+-- and every migration. Everything is rolled back.
+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;
+$$;
+-- The reader's count in one commune, as the commune list gives it.
+CREATE FUNCTION pg_temp.listed_unread(p_commune uuid) RETURNS integer LANGUAGE sql AS $$
+ SELECT (c->>'unreadNotices')::integer FROM jsonb_array_elements(public.my_communes()) c WHERE (c->>'id')::uuid = p_commune;
+$$;
+
+INSERT INTO neon_auth."user" (id, name, email) VALUES
+ ('na-ann', 'Ann Archer', 'ann@notices.test'), ('na-ben', 'Ben Baker', 'ben@notices.test'),
+ ('na-cat', 'Cat Cole', 'cat@notices.test'), ('na-out', 'Outsider', 'out@notices.test');
+
+-- Ann is in two communes and has a notice in each: Ben says he paid her in
+-- the house, and corrected a bill of his on the trip.
+DO $$
+DECLARE v_home uuid; v_trip uuid; v_expense uuid; v_share uuid; v_trip_bill uuid; v_fund uuid; v_pledge uuid;
+BEGIN
+ PERFORM pg_temp.act('na-ann');
+ v_home := public.create_commune('Notice house', 'household', 'GBP');
+ INSERT INTO public.commune_members (commune_id, user_id, role) VALUES (v_home, 'na-ben', 'member'), (v_home, 'na-cat', 'member');
+ v_expense := public.create_expense(v_home, 'Gas', 3000,
+ '[{"user_id":"na-ann","amount_minor":1000},{"user_id":"na-ben","amount_minor":1000},{"user_id":"na-cat","amount_minor":1000}]'::jsonb, current_date);
+ SELECT id INTO v_share FROM public.expense_shares WHERE expense_id = v_expense AND user_id = 'na-ben';
+ v_fund := public.create_fund(v_home, 'Boiler', 50000);
+
+ PERFORM pg_temp.act('na-ben');
+ PERFORM public.report_share_payment(v_share, 1000, current_date, NULL, gen_random_uuid());
+ v_pledge := public.pledge_fund(v_fund, 10000, current_date + 7);
+ v_trip := public.create_commune('Notice trip', 'trip', 'EUR');
+ INSERT INTO public.commune_members (commune_id, user_id, role) VALUES (v_trip, 'na-ann', 'member');
+ v_trip_bill := public.create_expense(v_trip, 'Ferry', 4000,
+ '[{"user_id":"na-ann","amount_minor":2000},{"user_id":"na-ben","amount_minor":2000}]'::jsonb, current_date);
+ PERFORM public.correct_expense(v_trip_bill, 1, 'Ferry tickets', 4000,
+ '[{"user_id":"na-ann","amount_minor":2500},{"user_id":"na-ben","amount_minor":1500}]'::jsonb, current_date,
+ 'na-ben', 'Travel', 'Ann had the cabin', gen_random_uuid());
+
+ -- An admin moving Ben's pledge leaves Ben a notice in the house.
+ PERFORM pg_temp.act('na-ann');
+ PERFORM public.update_fund_pledge(v_pledge, 10000, current_date + 14, false, NULL, NULL);
+
+ PERFORM set_config('test.home', v_home::text, true);
+ PERFORM set_config('test.trip', v_trip::text, true);
+ PERFORM set_config('test.share', v_share::text, true);
+ PERFORM set_config('test.fund', v_fund::text, true);
+END;
+$$;
+
+-- 1. The commune list and the snapshot count each commune's unread notices.
+DO $$
+DECLARE
+ v_home uuid := current_setting('test.home')::uuid; v_trip uuid := current_setting('test.trip')::uuid;
+ v_notice uuid;
+BEGIN
+ PERFORM pg_temp.act('na-ann');
+ IF pg_temp.listed_unread(v_home) IS DISTINCT FROM 1 OR pg_temp.listed_unread(v_trip) IS DISTINCT FROM 1 THEN
+ RAISE EXCEPTION 'The list should count one unread notice in each commune: %', public.my_communes();
+ END IF;
+ IF (public.commune_snapshot(v_home)->'commune'->>'unreadNotices')::integer IS DISTINCT FROM 1
+ OR public.commune_summary(v_trip)->'commune' <> public.commune_snapshot(v_trip)->'commune'
+ OR (public.commune_summary(v_trip)->'commune'->>'unreadNotices')::integer IS DISTINCT FROM 1 THEN
+ RAISE EXCEPTION 'The snapshot and the summary should carry the same count';
+ END IF;
+
+ -- Reading the house notice clears only the house.
+ SELECT (n->>'id')::uuid INTO v_notice FROM jsonb_array_elements(public.commune_snapshot(v_home)->'notifications') n;
+ PERFORM public.mark_notification_read(v_notice);
+ IF pg_temp.listed_unread(v_home) IS DISTINCT FROM 0 OR pg_temp.listed_unread(v_trip) IS DISTINCT FROM 1
+ OR (public.commune_snapshot(v_home)->'commune'->>'unreadNotices')::integer IS DISTINCT FROM 0 THEN
+ RAISE EXCEPTION 'Reading a notice should clear only its own commune';
+ END IF;
+
+ -- Someone else's notices never count.
+ PERFORM pg_temp.act('na-cat');
+ IF pg_temp.listed_unread(v_home) IS DISTINCT FROM 0 THEN RAISE EXCEPTION 'Cat has no notices'; END IF;
+END;
+$$;
+
+-- 2. Unread notices from every commune, in the snapshot's shape with the
+-- sender's name, for the caller only.
+DO $$
+DECLARE
+ v_home uuid := current_setting('test.home')::uuid; v_trip uuid := current_setting('test.trip')::uuid;
+ v_notices jsonb; v_left uuid; v_expense uuid; v_report jsonb;
+BEGIN
+ PERFORM pg_temp.act('na-ann');
+ v_notices := public.my_unread_notices();
+ IF jsonb_array_length(v_notices) <> 1 OR v_notices->0->>'communeId' IS DISTINCT FROM v_trip::text
+ OR v_notices->0->>'kind' IS DISTINCT FROM 'expense-corrected' OR v_notices->0->>'senderName' IS DISTINCT FROM 'Ben Baker'
+ OR v_notices->0->>'title' IS DISTINCT FROM 'Ferry tickets' OR v_notices->0->>'readAt' IS NOT NULL
+ OR v_notices->0->'fundId' IS DISTINCT FROM 'null'::jsonb THEN
+ RAISE EXCEPTION 'Ann should get her unread trip notice only: %', v_notices;
+ END IF;
+ IF (v_notices->0) - 'senderName' - 'fundId' <> (SELECT n FROM jsonb_array_elements(public.commune_snapshot(v_trip)->'notifications') n) THEN
+ RAISE EXCEPTION 'A notice should come in the snapshot''s shape: %', v_notices;
+ END IF;
+
+ -- A pledge notice names the fund holding the pledge while the fund is open.
+ PERFORM pg_temp.act('na-ben');
+ v_notices := public.my_unread_notices();
+ IF jsonb_array_length(v_notices) <> 1 OR v_notices->0->>'kind' IS DISTINCT FROM 'pledge-changed'
+ OR v_notices->0->>'fundId' IS DISTINCT FROM current_setting('test.fund') OR v_notices->0->>'senderName' IS DISTINCT FROM 'Ann Archer' THEN
+ RAISE EXCEPTION 'Ben should get his pledge notice with its fund: %', v_notices;
+ END IF;
+ PERFORM pg_temp.act('na-ann');
+ PERFORM public.update_fund_pledge((SELECT id FROM public.fund_commitments WHERE fund_id = current_setting('test.fund')::uuid),
+ 10000, current_date + 14, true, NULL, NULL);
+ PERFORM public.set_fund_archived(current_setting('test.fund')::uuid, true);
+ PERFORM pg_temp.act('na-ben');
+ v_notices := public.my_unread_notices();
+ IF (SELECT array_agg(n->>'kind' ORDER BY n->>'kind') FROM jsonb_array_elements(v_notices) n) IS DISTINCT FROM ARRAY['pledge-cancelled', 'pledge-changed']
+ OR EXISTS (SELECT 1 FROM jsonb_array_elements(v_notices) n WHERE n->'fundId' IS DISTINCT FROM 'null'::jsonb) THEN
+ RAISE EXCEPTION 'An archived fund should not be offered: %', v_notices;
+ END IF;
+
+ -- Someone outside the communes gets nothing, and so does someone who left:
+ -- Ann confirms the payment Cat reported in a third commune, then Cat leaves.
+ PERFORM pg_temp.act('na-out');
+ IF public.my_unread_notices() <> '[]'::jsonb THEN RAISE EXCEPTION 'An outsider should get no notices'; END IF;
+ PERFORM pg_temp.act('na-ann');
+ v_left := public.create_commune('Left house', 'household', 'GBP');
+ INSERT INTO public.commune_members (commune_id, user_id, role) VALUES (v_left, 'na-cat', 'member');
+ v_expense := public.create_expense(v_left, 'Milk', 400,
+ '[{"user_id":"na-ann","amount_minor":200},{"user_id":"na-cat","amount_minor":200}]'::jsonb, current_date);
+ PERFORM pg_temp.act('na-cat');
+ v_report := public.report_share_payment((SELECT id FROM public.expense_shares WHERE expense_id = v_expense AND user_id = 'na-cat'),
+ 200, current_date, NULL, gen_random_uuid());
+ PERFORM pg_temp.act('na-ann');
+ PERFORM public.resolve_share_payment_report((v_report->>'id')::uuid, 'confirm', NULL, gen_random_uuid());
+ PERFORM pg_temp.act('na-cat');
+ IF (SELECT array_agg(n->>'kind') FROM jsonb_array_elements(public.my_unread_notices()) n) IS DISTINCT FROM ARRAY['payment-confirmed'] THEN
+ RAISE EXCEPTION 'Cat should have the confirmation: %', public.my_unread_notices();
+ END IF;
+ PERFORM public.manage_commune_member(v_left, 'na-cat', 'leave');
+ IF public.my_unread_notices() <> '[]'::jsonb
+ OR EXISTS (SELECT 1 FROM jsonb_array_elements(public.my_communes()) c WHERE (c->>'id')::uuid = v_left) THEN
+ RAISE EXCEPTION 'Notices from a commune Cat left should stay out of reach';
+ END IF;
+
+ -- Only members can call it, never anonymous visitors.
+ IF has_function_privilege('anonymous', 'public.my_unread_notices()', 'EXECUTE')
+ OR NOT has_function_privilege('authenticated', 'public.my_unread_notices()', 'EXECUTE') THEN
+ RAISE EXCEPTION 'Unexpected grants on my_unread_notices';
+ END IF;
+END;
+$$;
+
+-- 3. Every activity event names the kind of record it is about.
+DO $$
+DECLARE
+ v_place uuid; v_expense uuid; v_share uuid; v_fund jsonb; v_pledge jsonb; v_task jsonb; v_event jsonb;
+ v_types jsonb; v_expected jsonb;
+BEGIN
+ PERFORM pg_temp.act('na-ann');
+ v_place := public.create_commune('Activity house', 'household', 'GBP');
+ INSERT INTO public.commune_members (commune_id, user_id, role) VALUES (v_place, 'na-ben', 'member');
+ v_expense := public.create_expense(v_place, 'Broadband', 3000,
+ '[{"user_id":"na-ann","amount_minor":1500},{"user_id":"na-ben","amount_minor":1500}]'::jsonb, current_date);
+ SELECT id INTO v_share FROM public.expense_shares WHERE expense_id = v_expense AND user_id = 'na-ben';
+ PERFORM public.record_share_receipt(v_share, 500, current_date, NULL, gen_random_uuid());
+ PERFORM public.correct_expense(v_expense, 1, 'Broadband bill', 3000,
+ '[{"user_id":"na-ann","amount_minor":1500},{"user_id":"na-ben","amount_minor":1500}]'::jsonb, current_date,
+ 'na-ann', 'Utilities', 'Clearer title', gen_random_uuid());
+ v_fund := public.create_fund_command(v_place, 'Sofa', 40000, 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());
+ PERFORM public.create_note(v_place, 'Router', 'Wi-Fi', 'Everyone', 'In the hall', 'Restart it first', '[]'::jsonb, gen_random_uuid());
+ v_task := public.create_operation(v_place, 'task', 'chore', 'Bins', current_date, NULL, true, 'na-ann', NULL, 'weekly', '', gen_random_uuid());
+ PERFORM public.remove_operation((v_task->>'id')::uuid);
+ v_event := public.create_operation(v_place, 'event', 'visit', 'Inspection', current_date + 2, '10:00', false, NULL, NULL, 'none', '', gen_random_uuid());
+ PERFORM public.post_announcement(v_place, 'Boiler service', 'Friday morning', false, gen_random_uuid());
+ PERFORM public.save_recurring_bill(v_place, 'Rent', 2000, 'na-ann',
+ '[{"user_id":"na-ann","amount_minor":1000},{"user_id":"na-ben","amount_minor":1000}]'::jsonb, 'monthly', current_date,
+ NULL, NULL, gen_random_uuid(), NULL);
+ PERFORM public.save_split_template(v_place, 'Halves', 'Equal', '[{"user_id":"na-ann","value":""},{"user_id":"na-ben","value":""}]'::jsonb);
+
+ SELECT jsonb_object_agg(a->>'title' || ': ' || (a->>'description'), a->>'recordType')
+ INTO v_types FROM jsonb_array_elements(public.commune_snapshot(v_place)->'activityEvents') a;
+ v_expected := jsonb_build_object(
+ 'Ben Baker: Joined the commune', 'member',
+ 'Broadband: Repayment of £5.00 recorded', 'repayment',
+ 'Broadband bill: Expense corrected', 'expense',
+ 'Sofa: Created', 'fund',
+ 'Sofa: Pledge made', 'pledge',
+ 'Sofa: Contribution recorded', 'contribution',
+ 'Router: Created', 'note',
+ 'Bins: Created', 'task',
+ 'Bins: Removed', 'task',
+ 'Inspection: Created', 'event',
+ 'Boiler service: Posted', 'announcement',
+ 'Rent: Created', 'recurring',
+ 'Halves: Created', 'template');
+ IF NOT v_types @> v_expected THEN
+ RAISE EXCEPTION 'Unexpected record types: %', v_types;
+ END IF;
+ IF EXISTS (SELECT 1 FROM jsonb_each_text(v_types) t WHERE t.value IS NULL) THEN
+ RAISE EXCEPTION 'Every event should have a record type: %', v_types;
+ END IF;
+END;
+$$;
+
+-- 4. The overview summary keeps a share's pending payment report, and no
+-- repayments or answered reports.
+DO $$
+DECLARE v_home uuid := current_setting('test.home')::uuid; v_reports jsonb;
+BEGIN
+ PERFORM pg_temp.act('na-ann');
+ SELECT s->'paymentReports' INTO v_reports
+ FROM jsonb_array_elements(public.commune_summary(v_home)->'expenses') e, jsonb_array_elements(e->'shares') s
+ WHERE s->>'id' = current_setting('test.share');
+ IF jsonb_array_length(v_reports) IS DISTINCT FROM 1 OR v_reports->0->>'status' IS DISTINCT FROM 'pending'
+ OR (v_reports->0->>'amountMinor')::bigint IS DISTINCT FROM 1000 THEN
+ RAISE EXCEPTION 'The summary should keep the pending report: %', v_reports;
+ END IF;
+ PERFORM public.resolve_share_payment_report((v_reports->0->>'id')::uuid, 'decline', 'Not arrived yet', gen_random_uuid());
+ IF EXISTS (SELECT 1 FROM jsonb_array_elements(public.commune_summary(v_home)->'expenses') e, jsonb_array_elements(e->'shares') s
+ WHERE s->'paymentReports' <> '[]'::jsonb OR s->'receipts' <> '[]'::jsonb) THEN
+ RAISE EXCEPTION 'An answered report should stay out of the summary';
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM jsonb_array_elements(public.commune_snapshot(v_home)->'expenses') e, jsonb_array_elements(e->'shares') s
+ WHERE s->'paymentReports'->0->>'status' = 'declined') THEN
+ RAISE EXCEPTION 'The full snapshot should still carry the answered report';
+ END IF;
+END;
+$$;
+
+ROLLBACK;