diff --git a/.firebaserc b/.firebaserc
index 0f85de9..ec0f378 100644
--- a/.firebaserc
+++ b/.firebaserc
@@ -1,5 +1,6 @@
{
"projects": {
- "default": "demo-safebite"
+ "default": "demo-safebite",
+ "staging": "safebite-pilot-urfs3v"
}
}
diff --git a/README.md b/README.md
index 2c81787..cfc97fb 100644
--- a/README.md
+++ b/README.md
@@ -192,6 +192,8 @@ npm --prefix web run dev # terminal 2: http://127.0.0.1:5173
> Discovery (the Discover tab) calls the `searchDestination` / `searchNearby` functions, which read the `PLACES_API_KEY` secret. Locally the emulator reads `functions/.secret.local` (gitignored); the `emu:*` scripts create it from `functions/.secret.local.example` (`PLACES_API_KEY=fixture`) when it is missing, which selects a fixture provider with twelve invented venues and the magic queries `__empty__`, `__unavailable__`, `__quota__`, `__delayed__` (3 s) and `__slow__` (25 s). To try real results locally, put a key restricted to Places API (New) in `.secret.local` — never commit it. Search is off until `config/discovery` exists (`{ enabled: true, dailySearchCap: 50 }`, written by `npm run emu:seed`).
+Saved shows the household's shortlist by default; "All records" shows everything. Each restaurant has shortlist and visited controls plus "Our notes": notes are personal and never evidence. Deleting a restaurant also deletes both members' notes and its shortlist state. Settings has "Change password".
+
Emulator UI: http://127.0.0.1:4000
Records live under households/home/restaurants in the emulator; `npm run emu:e2e` clears them before each scenario via the emulator's REST API.
@@ -202,7 +204,7 @@ npm run typecheck # both packages
npm run test:unit # web unit tests (no emulator)
npm run emu:test # functions + Firestore rules tests (starts emulators)
npm run emu:e2e # Playwright browser tests (starts emulators, seeds, runs Vite)
-npm run emu:e2e:stress # 29 browser scenarios × 3 repeats, retries disabled (flakiness gate)
+npm run emu:e2e:stress # 42 browser scenarios × 3 repeats, retries disabled (flakiness gate)
npm --prefix web run build:check # compile-only build (no Firebase config needed)
npm --prefix web run build:e2e # builds the three synthetic bundles: dist-preview, dist-preview-v2, dist-boot-guard (fixtures in web/.env.preview, .env.preview-v2, .env.boot-guard)
npm --prefix web run e2e:boot-guard # compile-only bundle with demo values refuses to start (Chromium, no emulators)
@@ -211,11 +213,12 @@ npm --prefix web run e2e:upgrade # same-origin release upgrades and the upd
npm --prefix web run icons # re-render the PNG icon set from web/assets/safebite-mark.svg
```
-`npm run test:unit` currently reports 270 tests.
+`npm run test:unit` currently reports 361 tests. `npm run emu:test` currently reports 352 tests (functions + rules). `npm run emu:e2e` currently reports 42 browser scenarios.
### Guardrails
- Local work targets the emulator-only project `demo-safebite`. Nothing here deploys.
+- Deploy order (spec §3.7): Firestore rules and functions first, then hosting; the deletion completion gate protects older cached clients.
- Membership (`users/{uid}`, `households/{hid}`) is written only with the Admin SDK; there is no sign-up.
- Never reuse the legacy seed data from git history; its safety claims were invented.
- `npm --prefix web run build` (used by `firebase deploy`) refuses missing, blank, demo-, or legacy-project Firebase values; the resulting bundle also refuses to start against them.
diff --git a/firestore.rules b/firestore.rules
index 2dd48c0..e1bc720 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -16,6 +16,12 @@ service cloud.firestore {
return signedIn() && request.auth.uid in household(hid).data.memberIds;
}
+ // The caller's own users/{uid} document. Rules get() is not subject to the read rules, so no
+ // peer-user read is introduced. Used wherever a stored display name must be the writer's own.
+ function callerName() {
+ return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.displayName;
+ }
+
// ---- field validators (limits mirror web/src/records/validation.ts LIMITS) ----
function nonBlankString(v, max) {
return v is string && v.trim() != '' && v.size() <= max;
@@ -39,7 +45,7 @@ service cloud.firestore {
}
function validRestaurant(data) {
- return data.keys().hasOnly(['name', 'address', 'phone', 'website', 'lat', 'lng', 'googlePlaceId', 'createdBy', 'createdAt', 'updatedAt', 'version', 'deleting'])
+ return data.keys().hasOnly(['name', 'address', 'phone', 'website', 'lat', 'lng', 'googlePlaceId', 'createdBy', 'createdAt', 'updatedAt', 'version', 'deleting', 'cleanupDone'])
&& data.keys().hasAll(['name', 'address', 'createdBy', 'createdAt', 'updatedAt', 'version', 'deleting'])
&& nonBlankString(data.name, 120)
&& nonBlankString(data.address, 300)
@@ -51,7 +57,8 @@ service cloud.firestore {
&& data.createdAt is timestamp
&& data.updatedAt is timestamp
&& data.version is int
- && data.deleting is bool;
+ && data.deleting is bool
+ && (!('cleanupDone' in data) || data.cleanupDone is bool);
}
// Deletion protocol step 1 (spec §3.5): the mark changes only these keys.
@@ -60,6 +67,16 @@ service cloud.firestore {
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['deleting', 'version', 'updatedAt']);
}
+ // Deletion protocol, completion gate (spec §3.7, audit F1): set only after the client's claim
+ // and note sweeps returned empty from the server and the collection document is gone. Nothing
+ // can be created under a marked restaurant, so the flag cannot go stale.
+ function isMarkingCleanupDone() {
+ return resource.data.deleting == true
+ && resource.data.get('cleanupDone', false) == false
+ && request.resource.data.get('cleanupDone', false) == true
+ && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['cleanupDone', 'version', 'updatedAt']);
+ }
+
// Calendar dates are timestamps at 00:00:00 UTC (spec §3.5, audit F3).
function utcMidnight(ts) {
return ts is timestamp && ts == ts.date();
@@ -108,6 +125,7 @@ service cloud.firestore {
allow create: if isMember(hid)
&& validRestaurant(request.resource.data)
+ && !('cleanupDone' in request.resource.data)
&& request.resource.data.createdBy == request.auth.uid
&& request.resource.data.version == 1
&& request.resource.data.deleting == false
@@ -115,18 +133,24 @@ service cloud.firestore {
&& request.resource.data.updatedAt == request.time;
// Optimistic concurrency: exactly the next version, server-stamped. Once deleting is true
- // nothing may change until the document is removed (so a claim sweep cannot be undercut).
+ // the only permitted change is marking cleanup done (so a claim sweep cannot be undercut).
allow update: if isMember(hid)
&& validRestaurant(request.resource.data)
- && resource.data.deleting == false
&& request.resource.data.createdBy == resource.data.createdBy
&& request.resource.data.createdAt == resource.data.createdAt
&& request.resource.data.updatedAt == request.time
&& request.resource.data.version == resource.data.version + 1
- && (request.resource.data.deleting == false || isMarkingDeleting());
-
- // Step 3 of the protocol: only a marked restaurant can go.
- allow delete: if isMember(hid) && resource.data.deleting == true;
+ && ((resource.data.deleting == false
+ && !('cleanupDone' in request.resource.data)
+ && (request.resource.data.deleting == false || isMarkingDeleting()))
+ || isMarkingCleanupDone());
+
+ // Final step: only a marked restaurant whose cleanup is done and whose collection
+ // document is gone. A client that skips the note/state sweep cannot pass this.
+ allow delete: if isMember(hid)
+ && resource.data.deleting == true
+ && resource.data.get('cleanupDone', false) == true
+ && !exists(/databases/$(database)/documents/households/$(hid)/collection/$(rid));
match /claims/{cid} {
function parentRestaurant() {
@@ -153,6 +177,71 @@ service cloud.firestore {
allow update: if false;
allow delete: if isMember(hid);
}
+
+ // Authored notes (spec §3.7). Only the author edits; the author deletes at any time, and
+ // any member may delete once the restaurant is marked deleting (deletion sweep).
+ match /notes/{nid} {
+ function noteParent() {
+ return get(/databases/$(database)/documents/households/$(hid)/restaurants/$(rid));
+ }
+
+ allow read: if isMember(hid);
+
+ allow create: if isMember(hid)
+ && exists(/databases/$(database)/documents/households/$(hid)/restaurants/$(rid))
+ && noteParent().data.deleting == false
+ && request.resource.data.keys().hasOnly(['text', 'authorUid', 'authorName', 'createdAt', 'updatedAt', 'version'])
+ && request.resource.data.keys().hasAll(['text', 'authorUid', 'authorName', 'createdAt', 'updatedAt', 'version'])
+ && nonBlankString(request.resource.data.text, 2000)
+ && request.resource.data.authorUid == request.auth.uid
+ && request.resource.data.authorName == callerName()
+ && request.resource.data.createdAt == request.time
+ && request.resource.data.updatedAt == request.time
+ && request.resource.data.version == 1;
+
+ allow update: if isMember(hid)
+ && resource.data.authorUid == request.auth.uid
+ && noteParent().data.deleting == false
+ && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['text', 'updatedAt', 'version'])
+ && nonBlankString(request.resource.data.text, 2000)
+ && request.resource.data.updatedAt == request.time
+ && request.resource.data.version == resource.data.version + 1;
+
+ // Author first: `||` short-circuits, so an author can delete even if the parent is gone.
+ allow delete: if isMember(hid)
+ && (resource.data.authorUid == request.auth.uid || noteParent().data.deleting == true);
+ }
+ }
+
+ // Shortlist and visited state, one document per restaurant, id = restaurant id (spec §3.7).
+ match /collection/{rid} {
+ function stateParentPath() {
+ return /databases/$(database)/documents/households/$(hid)/restaurants/$(rid);
+ }
+ function liveParent() {
+ return exists(stateParentPath()) && get(stateParentPath()).data.deleting == false;
+ }
+ function validState(data) {
+ return data.keys().hasOnly(['shortlisted', 'visited', 'visitedOn', 'updatedBy', 'updatedByName', 'updatedAt', 'version'])
+ && data.keys().hasAll(['shortlisted', 'visited', 'updatedBy', 'updatedByName', 'updatedAt', 'version'])
+ && data.shortlisted is bool
+ && data.visited is bool
+ && data.visited == ('visitedOn' in data)
+ && (!('visitedOn' in data)
+ || (utcMidnight(data.visitedOn) && data.visitedOn <= request.time + duration.value(1, 'd')))
+ && data.updatedBy == request.auth.uid
+ && data.updatedByName == callerName()
+ && data.updatedAt == request.time
+ && data.version is int;
+ }
+
+ allow read: if isMember(hid);
+ allow create: if isMember(hid) && liveParent() && validState(request.resource.data)
+ && request.resource.data.version == 1;
+ allow update: if isMember(hid) && liveParent() && validState(request.resource.data)
+ && request.resource.data.version == resource.data.version + 1;
+ // Deletion sweep only: the restaurant must exist and be marked deleting.
+ allow delete: if isMember(hid) && exists(stateParentPath()) && get(stateParentPath()).data.deleting == true;
}
}
}
diff --git a/functions/test/rules.collection.test.ts b/functions/test/rules.collection.test.ts
new file mode 100644
index 0000000..72fec7f
--- /dev/null
+++ b/functions/test/rules.collection.test.ts
@@ -0,0 +1,288 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { afterAll, beforeAll, beforeEach, describe, it } from "vitest";
+import { assertFails, assertSucceeds, initializeTestEnvironment, type RulesTestEnvironment } from "@firebase/rules-unit-testing";
+import { collection, deleteDoc, doc, getDoc, getDocs, serverTimestamp, setDoc, Timestamp, updateDoc } from "firebase/firestore";
+
+const PROJECT_ID = "demo-safebite";
+const RULES_PATH = path.resolve(process.cwd(), "..", "firestore.rules");
+
+let env: RulesTestEnvironment;
+
+beforeAll(async () => {
+ env = await initializeTestEnvironment({
+ projectId: PROJECT_ID,
+ firestore: { rules: readFileSync(RULES_PATH, "utf8"), host: "127.0.0.1", port: 8080 },
+ });
+});
+
+beforeEach(async () => {
+ await env.clearFirestore();
+ await env.withSecurityRulesDisabled(async (ctx) => {
+ const db = ctx.firestore();
+ await setDoc(doc(db, "households/home"), { name: "Home", memberIds: ["ava", "bogdan"], createdAt: new Date() });
+ await setDoc(doc(db, "households/other"), { name: "Other", memberIds: ["stranger"], createdAt: new Date() });
+ await setDoc(doc(db, "users/ava"), { householdId: "home", displayName: "Ava" });
+ await setDoc(doc(db, "users/bogdan"), { householdId: "home", displayName: "Bogdan" });
+ await setDoc(doc(db, "users/stranger"), { householdId: "other", displayName: "Stranger" });
+ });
+});
+
+afterAll(async () => {
+ await env.cleanup();
+});
+
+const as = (uid: string) => env.authenticatedContext(uid).firestore();
+const R = "households/home/restaurants";
+const S = "households/home/collection";
+const utcDate = (d: string) => Timestamp.fromDate(new Date(`${d}T00:00:00.000Z`));
+const isoDay = (date: Date) => date.toISOString().slice(0, 10);
+const daysAhead = (n: number) => isoDay(new Date(Date.now() + n * 86_400_000));
+const NAMES: Record = { ava: "Ava", bogdan: "Bogdan", stranger: "Stranger" };
+
+async function seed(docPath: string, data: Record): Promise {
+ await env.withSecurityRulesDisabled(async (ctx) => {
+ await setDoc(doc(ctx.firestore(), docPath), data);
+ });
+}
+
+async function seedRestaurant(id: string, over: Record = {}): Promise {
+ await seed(`${R}/${id}`, {
+ name: "Seeded",
+ address: "Somewhere 1",
+ createdBy: "ava",
+ createdAt: Timestamp.fromDate(new Date("2026-09-01T10:00:00Z")),
+ updatedAt: Timestamp.fromDate(new Date("2026-09-01T10:00:00Z")),
+ version: 1,
+ deleting: false,
+ ...over,
+ });
+}
+
+/** A valid collection-state write as the client sends it (full document, server updatedAt). */
+function stateWrite(uid = "ava", over: Record = {}): Record {
+ const data: Record = {
+ shortlisted: true,
+ visited: false,
+ updatedBy: uid,
+ updatedByName: NAMES[uid],
+ updatedAt: serverTimestamp(),
+ version: 1,
+ ...over,
+ };
+ for (const key of Object.keys(data)) if (data[key] === undefined) delete data[key];
+ return data;
+}
+
+async function seedState(rid: string, over: Record = {}): Promise {
+ await seed(`${S}/${rid}`, { shortlisted: true, visited: false, updatedBy: "ava", updatedByName: "Ava", updatedAt: new Date(), version: 1, ...over });
+}
+
+describe("collection — reads", () => {
+ it("members read and list; non-members and anonymous do not", async () => {
+ await seedRestaurant("r1");
+ await seedState("r1");
+ await assertSucceeds(getDoc(doc(as("ava"), `${S}/r1`)));
+ await assertSucceeds(getDocs(collection(as("bogdan"), S)));
+ await assertFails(getDoc(doc(as("stranger"), `${S}/r1`)));
+ await assertFails(getDocs(collection(as("stranger"), S)));
+ await assertFails(getDoc(doc(env.unauthenticatedContext().firestore(), `${S}/r1`)));
+ });
+});
+
+describe("collection — create", () => {
+ beforeEach(async () => {
+ await seedRestaurant("r1");
+ });
+
+ it.each([
+ ["shortlisted only", {}],
+ ["visited with a date", { shortlisted: false, visited: true, visitedOn: utcDate("2026-05-03") }],
+ ["visited one day ahead of UTC", { visited: true, visitedOn: utcDate(daysAhead(1)) }],
+ ["neither flag", { shortlisted: false }],
+ ])("accepts %s", async (_label, over) => {
+ await assertSucceeds(setDoc(doc(as("ava"), `${S}/r1`), stateWrite("ava", over)));
+ });
+
+ it.each([
+ ["version is not 1", { version: 2 }],
+ ["updatedBy is someone else", { updatedBy: "bogdan" }],
+ ["updatedByName is not the caller's display name", { updatedByName: "Bogdan" }],
+ ["client-supplied updatedAt", { updatedAt: new Date() }],
+ ["visited without visitedOn", { visited: true }],
+ ["visitedOn without visited", { visitedOn: utcDate("2026-05-03") }],
+ ["visitedOn not at UTC midnight", { visited: true, visitedOn: Timestamp.fromDate(new Date("2026-05-03T10:00:00Z")) }],
+ // three, not two: see rules.records.test.ts on request.time near midnight
+ ["visitedOn three days ahead", { visited: true, visitedOn: utcDate(daysAhead(3)) }],
+ ["shortlisted is a string", { shortlisted: "yes" }],
+ ["an unknown key", { rating: 5 }],
+ ["a legacy savedBy key", { savedBy: "ava" }],
+ ["missing shortlisted", { shortlisted: undefined }],
+ ])("rejects a create where %s", async (_label, over) => {
+ await assertFails(setDoc(doc(as("ava"), `${S}/r1`), stateWrite("ava", over)));
+ });
+
+ it("rejects a create for a missing restaurant or one marked deleting", async () => {
+ await assertFails(setDoc(doc(as("ava"), `${S}/ghost`), stateWrite("ava")));
+ await seedRestaurant("r2", { deleting: true });
+ await assertFails(setDoc(doc(as("ava"), `${S}/r2`), stateWrite("ava")));
+ });
+
+ it("rejects a create by a non-member or anonymous client", async () => {
+ await assertFails(setDoc(doc(as("stranger"), `${S}/r1`), stateWrite("stranger")));
+ await assertFails(setDoc(doc(env.unauthenticatedContext().firestore(), `${S}/r1`), stateWrite("ava")));
+ });
+});
+
+describe("collection — update and delete", () => {
+ beforeEach(async () => {
+ await seedRestaurant("r1");
+ await seedState("r1", { version: 3 });
+ });
+
+ it("either member writes the next version with their own identity", async () => {
+ await assertSucceeds(setDoc(doc(as("bogdan"), `${S}/r1`), stateWrite("bogdan", { shortlisted: false, visited: true, visitedOn: utcDate("2026-05-03"), version: 4 })));
+ });
+
+ it.each([
+ ["the version is stale", { version: 3 }],
+ ["the version skips ahead", { version: 5 }],
+ ["updatedByName is spoofed", { version: 4, updatedByName: "Ava" }],
+ ])("rejects an update where %s", async (_label, over) => {
+ await assertFails(setDoc(doc(as("bogdan"), `${S}/r1`), stateWrite("bogdan", over)));
+ });
+
+ it("rejects updates once the restaurant is marked deleting", async () => {
+ await seedRestaurant("r1", { deleting: true });
+ await assertFails(setDoc(doc(as("ava"), `${S}/r1`), stateWrite("ava", { version: 4 })));
+ });
+
+ it("delete is refused while the restaurant is live and allowed once it is marked deleting", async () => {
+ await assertFails(deleteDoc(doc(as("ava"), `${S}/r1`)));
+ await seedRestaurant("r1", { deleting: true });
+ await assertFails(deleteDoc(doc(as("stranger"), `${S}/r1`)));
+ await assertSucceeds(deleteDoc(doc(as("bogdan"), `${S}/r1`)));
+ });
+});
+
+const N = `${R}/r1/notes`;
+
+/** A valid note create as the client sends it. */
+function noteCreate(uid = "ava", over: Record = {}): Record {
+ const data: Record = {
+ text: "Staff knew exactly what coeliac means.",
+ authorUid: uid,
+ authorName: NAMES[uid],
+ createdAt: serverTimestamp(),
+ updatedAt: serverTimestamp(),
+ version: 1,
+ ...over,
+ };
+ for (const key of Object.keys(data)) if (data[key] === undefined) delete data[key];
+ return data;
+}
+
+async function seedNote(id: string, uid = "ava", over: Record = {}): Promise {
+ await seed(`${N}/${id}`, { ...noteCreate(uid), createdAt: new Date("2026-09-01T10:00:00Z"), updatedAt: new Date("2026-09-01T10:00:00Z"), version: 2, ...over });
+}
+
+describe("notes — reads", () => {
+ it("members read and list; non-members and anonymous do not", async () => {
+ await seedRestaurant("r1");
+ await seedNote("n1");
+ await assertSucceeds(getDoc(doc(as("bogdan"), `${N}/n1`)));
+ await assertSucceeds(getDocs(collection(as("ava"), N)));
+ await assertFails(getDoc(doc(as("stranger"), `${N}/n1`)));
+ await assertFails(getDoc(doc(env.unauthenticatedContext().firestore(), `${N}/n1`)));
+ });
+});
+
+describe("notes — create", () => {
+ beforeEach(async () => {
+ await seedRestaurant("r1");
+ });
+
+ it("accepts a note from either member, including exactly 2,000 characters", async () => {
+ await assertSucceeds(setDoc(doc(as("ava"), `${N}/a`), noteCreate("ava")));
+ await assertSucceeds(setDoc(doc(as("bogdan"), `${N}/b`), noteCreate("bogdan", { text: "x".repeat(2000) })));
+ });
+
+ it.each([
+ ["text of 2,001 characters", { text: "x".repeat(2001) }],
+ ["whitespace-only text", { text: " " }],
+ ["text not a string", { text: 42 }],
+ ["authorUid is someone else", { authorUid: "bogdan" }],
+ ["authorName is not the caller's display name", { authorName: "Bogdan" }],
+ ["client-supplied createdAt", { createdAt: new Date() }],
+ ["client-supplied updatedAt", { updatedAt: new Date() }],
+ ["version is not 1", { version: 2 }],
+ ["an unknown key", { verified: true }],
+ ["missing text", { text: undefined }],
+ ])("rejects a create where %s", async (_label, over) => {
+ await assertFails(setDoc(doc(as("ava"), `${N}/bad`), noteCreate("ava", over)));
+ });
+
+ it("rejects a create under a missing parent or a parent marked deleting", async () => {
+ await assertFails(setDoc(doc(as("ava"), `${R}/ghost/notes/bad`), noteCreate("ava")));
+ await seedRestaurant("r2", { deleting: true });
+ await assertFails(setDoc(doc(as("ava"), `${R}/r2/notes/bad`), noteCreate("ava")));
+ });
+
+ it("rejects a create by a non-member or anonymous client", async () => {
+ await assertFails(setDoc(doc(as("stranger"), `${N}/bad`), noteCreate("stranger")));
+ await assertFails(setDoc(doc(env.unauthenticatedContext().firestore(), `${N}/bad`), noteCreate("ava")));
+ });
+});
+
+describe("notes — update", () => {
+ beforeEach(async () => {
+ await seedRestaurant("r1");
+ await seedNote("n1", "ava");
+ });
+
+ it("the author edits the text with the next version and a server updatedAt", async () => {
+ await assertSucceeds(updateDoc(doc(as("ava"), `${N}/n1`), { text: "Edited", version: 3, updatedAt: serverTimestamp() }));
+ });
+
+ it.each([
+ ["the other member edits", "bogdan", { text: "Edited", version: 3, updatedAt: serverTimestamp() }],
+ ["the version is stale", "ava", { text: "Edited", version: 2, updatedAt: serverTimestamp() }],
+ ["the version skips ahead", "ava", { text: "Edited", version: 4, updatedAt: serverTimestamp() }],
+ ["updatedAt is client-supplied", "ava", { text: "Edited", version: 3, updatedAt: new Date() }],
+ ["authorUid changes", "ava", { authorUid: "bogdan", version: 3, updatedAt: serverTimestamp() }],
+ ["authorName changes", "ava", { authorName: "Bogdan", version: 3, updatedAt: serverTimestamp() }],
+ ["createdAt changes", "ava", { createdAt: new Date(), version: 3, updatedAt: serverTimestamp() }],
+ ["text becomes too long", "ava", { text: "x".repeat(2001), version: 3, updatedAt: serverTimestamp() }],
+ ])("rejects an update where %s", async (_label, uid, patch) => {
+ await assertFails(updateDoc(doc(as(uid), `${N}/n1`), patch));
+ });
+
+ it("rejects an author edit while the restaurant is marked deleting", async () => {
+ await seedRestaurant("r1", { deleting: true });
+ await assertFails(updateDoc(doc(as("ava"), `${N}/n1`), { text: "Edited", version: 3, updatedAt: serverTimestamp() }));
+ });
+});
+
+describe("notes — delete", () => {
+ beforeEach(async () => {
+ await seedRestaurant("r1");
+ await seedNote("n1", "ava");
+ });
+
+ it("the author deletes; the other member and non-members may not while the restaurant is live", async () => {
+ await assertFails(deleteDoc(doc(as("bogdan"), `${N}/n1`)));
+ await assertFails(deleteDoc(doc(as("stranger"), `${N}/n1`)));
+ await assertSucceeds(deleteDoc(doc(as("ava"), `${N}/n1`)));
+ });
+
+ it("once the restaurant is marked deleting any member may delete (the sweep), never a non-member", async () => {
+ await seedRestaurant("r1", { deleting: true });
+ await assertFails(deleteDoc(doc(as("stranger"), `${N}/n1`)));
+ await assertSucceeds(deleteDoc(doc(as("bogdan"), `${N}/n1`)));
+ });
+
+ it("the author may still delete while the restaurant is marked deleting", async () => {
+ await seedRestaurant("r1", { deleting: true });
+ await assertSucceeds(deleteDoc(doc(as("ava"), `${N}/n1`)));
+ });
+});
diff --git a/functions/test/rules.deletion.test.ts b/functions/test/rules.deletion.test.ts
new file mode 100644
index 0000000..46e8e4c
--- /dev/null
+++ b/functions/test/rules.deletion.test.ts
@@ -0,0 +1,161 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { assertFails, initializeTestEnvironment, type RulesTestEnvironment } from "@firebase/rules-unit-testing";
+import { collection, doc, getDoc, getDocs, limit, query, runTransaction, serverTimestamp, setDoc, Timestamp } from "firebase/firestore";
+
+/**
+ * The deletion protocol against the real rules with mixed client versions (spec §3.7, audit F1).
+ * `oldFinish` is the merged Plan 3 client's finishDeleting (blind claim sweep, blind restaurant
+ * delete). `newFinish` mirrors web/src/records/repository.ts finishDeleting after Plan 4 (every
+ * step reads before it deletes). Keep newFinish in step with the repository.
+ */
+const PROJECT_ID = "demo-safebite";
+const RULES_PATH = path.resolve(process.cwd(), "..", "firestore.rules");
+const R = "households/home/restaurants";
+const S = "households/home/collection";
+
+let env: RulesTestEnvironment;
+
+beforeAll(async () => {
+ env = await initializeTestEnvironment({
+ projectId: PROJECT_ID,
+ firestore: { rules: readFileSync(RULES_PATH, "utf8"), host: "127.0.0.1", port: 8080 },
+ });
+});
+
+beforeEach(async () => {
+ await env.clearFirestore();
+ await env.withSecurityRulesDisabled(async (ctx) => {
+ const db = ctx.firestore();
+ await setDoc(doc(db, "households/home"), { name: "Home", memberIds: ["ava", "bogdan"], createdAt: new Date() });
+ await setDoc(doc(db, "users/ava"), { householdId: "home", displayName: "Ava" });
+ await setDoc(doc(db, "users/bogdan"), { householdId: "home", displayName: "Bogdan" });
+ });
+});
+
+afterAll(async () => {
+ await env.cleanup();
+});
+
+const as = (uid: string) => env.authenticatedContext(uid).firestore();
+type Db = ReturnType;
+
+/** A restaurant a member has marked deleting, still holding a claim, both members' notes and state. */
+async function seedDoomed(rid: string): Promise {
+ await env.withSecurityRulesDisabled(async (ctx) => {
+ const db = ctx.firestore();
+ const at = Timestamp.fromDate(new Date("2026-09-01T10:00:00Z"));
+ await setDoc(doc(db, `${R}/${rid}`), { name: "Doomed", address: "1 Road", createdBy: "ava", createdAt: at, updatedAt: at, version: 2, deleting: true });
+ await setDoc(doc(db, `${R}/${rid}/claims/c1`), { kind: "gfMenu", value: "yes", detail: "", source: { type: "ownVisit", label: "x" }, checkedAt: Timestamp.fromDate(new Date("2026-09-01T00:00:00Z")), authorUid: "ava", authorName: "Ava", createdAt: at });
+ await setDoc(doc(db, `${R}/${rid}/notes/n-ava`), { text: "Ava's note", authorUid: "ava", authorName: "Ava", createdAt: at, updatedAt: at, version: 1 });
+ await setDoc(doc(db, `${R}/${rid}/notes/n-bogdan`), { text: "Bogdan's note", authorUid: "bogdan", authorName: "Bogdan", createdAt: at, updatedAt: at, version: 1 });
+ await setDoc(doc(db, `${S}/${rid}`), { shortlisted: true, visited: false, updatedBy: "ava", updatedByName: "Ava", updatedAt: at, version: 1 });
+ });
+}
+
+interface Remaining { restaurant: boolean; claims: number; notes: number; state: boolean }
+
+async function remaining(rid: string): Promise {
+ let out: Remaining = { restaurant: false, claims: 0, notes: 0, state: false };
+ await env.withSecurityRulesDisabled(async (ctx) => {
+ const db = ctx.firestore();
+ out = {
+ restaurant: (await getDoc(doc(db, `${R}/${rid}`))).exists(),
+ claims: (await getDocs(collection(db, `${R}/${rid}/claims`))).size,
+ notes: (await getDocs(collection(db, `${R}/${rid}/notes`))).size,
+ state: (await getDoc(doc(db, `${S}/${rid}`))).exists(),
+ };
+ });
+ return out;
+}
+
+const GONE: Remaining = { restaurant: false, claims: 0, notes: 0, state: false };
+
+async function oldFinish(db: Db, rid: string): Promise {
+ const claims = await getDocs(query(collection(db, `${R}/${rid}/claims`), limit(100)));
+ await runTransaction(db, async (tx) => {
+ for (const c of claims.docs) tx.delete(c.ref);
+ });
+ await runTransaction(db, async (tx) => {
+ tx.delete(doc(db, `${R}/${rid}`));
+ });
+}
+
+async function sweepSub(db: Db, rid: string, sub: "claims" | "notes"): Promise {
+ for (;;) {
+ const page = await getDocs(query(collection(db, `${R}/${rid}/${sub}`), limit(100)));
+ if (page.empty) return;
+ await runTransaction(db, async (tx) => {
+ const snaps = await Promise.all(page.docs.map((d) => tx.get(d.ref)));
+ for (const s of snaps) if (s.exists()) tx.delete(s.ref);
+ });
+ }
+}
+
+const NEW_STEPS: Array<(db: Db, rid: string) => Promise> = [
+ (db, rid) => sweepSub(db, rid, "claims"),
+ (db, rid) => sweepSub(db, rid, "notes"),
+ (db, rid) =>
+ runTransaction(db, async (tx) => {
+ const s = await tx.get(doc(db, `${S}/${rid}`));
+ if (s.exists()) tx.delete(s.ref);
+ }),
+ (db, rid) =>
+ runTransaction(db, async (tx) => {
+ const r = await tx.get(doc(db, `${R}/${rid}`));
+ if (!r.exists()) return;
+ // Mirrors web/src/records/repository.ts markCleanupDone: never mark a live restaurant.
+ if (r.get("deleting") !== true) throw new Error("notFound");
+ if (r.get("cleanupDone") === true) return;
+ tx.update(r.ref, { cleanupDone: true, version: (r.get("version") as number) + 1, updatedAt: serverTimestamp() });
+ }),
+ (db, rid) =>
+ runTransaction(db, async (tx) => {
+ const r = await tx.get(doc(db, `${R}/${rid}`));
+ if (r.exists()) tx.delete(r.ref);
+ }),
+];
+
+async function newFinish(db: Db, rid: string, stepsToRun = NEW_STEPS.length): Promise {
+ for (const step of NEW_STEPS.slice(0, stepsToRun)) await step(db, rid);
+}
+
+describe("deletion protocol with mixed client versions", () => {
+ it("a Plan 3-era finisher is refused at the final delete and leaves a resumable parent; a Plan 4 finisher completes it", async () => {
+ await seedDoomed("r1");
+ await assertFails(oldFinish(as("bogdan"), "r1"));
+ expect(await remaining("r1")).toEqual({ restaurant: true, claims: 0, notes: 2, state: true });
+ await newFinish(as("ava"), "r1");
+ expect(await remaining("r1")).toEqual(GONE);
+ });
+
+ it("an old resumer racing a new deleter never leaves notes or state without their restaurant", async () => {
+ await seedDoomed("r1");
+ await Promise.allSettled([oldFinish(as("bogdan"), "r1"), newFinish(as("ava"), "r1")]);
+ const after = await remaining("r1");
+ if (!after.restaurant) expect(after).toEqual(GONE);
+ await newFinish(as("ava"), "r1");
+ expect(await remaining("r1")).toEqual(GONE);
+ });
+
+ it("two Plan 4 finishers at once both succeed", async () => {
+ await seedDoomed("r1");
+ await Promise.all([newFinish(as("ava"), "r1"), newFinish(as("bogdan"), "r1")]);
+ expect(await remaining("r1")).toEqual(GONE);
+ });
+
+ it.each([1, 2, 3, 4])("a retry after %i completed step(s) finishes without a permission failure", async (done) => {
+ await seedDoomed("r1");
+ await newFinish(as("ava"), "r1", done);
+ await newFinish(as("bogdan"), "r1");
+ expect(await remaining("r1")).toEqual(GONE);
+ });
+
+ it("a finisher running after everything is already gone is a no-op, not a failure", async () => {
+ await seedDoomed("r1");
+ await newFinish(as("ava"), "r1");
+ await newFinish(as("bogdan"), "r1");
+ expect(await remaining("r1")).toEqual(GONE);
+ });
+});
diff --git a/functions/test/rules.records.test.ts b/functions/test/rules.records.test.ts
index 317d7ee..f72a84c 100644
--- a/functions/test/rules.records.test.ts
+++ b/functions/test/rules.records.test.ts
@@ -114,6 +114,7 @@ describe("restaurants — create", () => {
["client-supplied createdAt", { createdAt: new Date() }],
["client-supplied updatedAt", { updatedAt: new Date() }],
["missing deleting flag", { deleting: undefined }],
+ ["cleanupDone is set on create", { cleanupDone: false }],
])("rejects a create where %s", async (_label, over) => {
const data = restaurantCreate("ava", over);
for (const key of Object.keys(data)) if (data[key] === undefined) delete data[key];
@@ -170,13 +171,52 @@ describe("restaurants — delete", () => {
await assertFails(deleteDoc(doc(as("ava"), p)));
});
- it("accepts deleting a marked restaurant by either member, never by a non-member", async () => {
- const p = await seedRestaurant("r1", { deleting: true });
+ it("accepts deleting a marked, cleaned-up restaurant by either member, never by a non-member", async () => {
+ const p = await seedRestaurant("r1", { deleting: true, cleanupDone: true });
await assertFails(deleteDoc(doc(as("stranger"), p)));
await assertSucceeds(deleteDoc(doc(as("bogdan"), p)));
});
});
+describe("restaurants — deletion completion gate (spec §3.7, audit F1)", () => {
+ it("rejects adding cleanupDone through an ordinary update or together with the deleting mark", async () => {
+ const p = await seedRestaurant("r1");
+ await assertFails(updateDoc(doc(as("ava"), p), { name: "x", cleanupDone: true, version: 4, updatedAt: serverTimestamp() }));
+ await assertFails(updateDoc(doc(as("ava"), p), { deleting: true, cleanupDone: true, version: 4, updatedAt: serverTimestamp() }));
+ });
+
+ it("accepts marking cleanup done on a restaurant marked deleting (only cleanupDone, version, updatedAt)", async () => {
+ const p = await seedRestaurant("r1", { deleting: true, version: 4 });
+ await assertSucceeds(updateDoc(doc(as("bogdan"), p), { cleanupDone: true, version: 5, updatedAt: serverTimestamp() }));
+ });
+
+ it.each([
+ ["the restaurant is live", { deleting: false, version: 4 }, { cleanupDone: true, version: 5, updatedAt: serverTimestamp() }],
+ ["the version is stale", { deleting: true, version: 4 }, { cleanupDone: true, version: 4, updatedAt: serverTimestamp() }],
+ ["cleanupDone is false", { deleting: true, version: 4 }, { cleanupDone: false, version: 5, updatedAt: serverTimestamp() }],
+ ["another field changes too", { deleting: true, version: 4 }, { cleanupDone: true, name: "x", version: 5, updatedAt: serverTimestamp() }],
+ ["updatedAt is client-supplied", { deleting: true, version: 4 }, { cleanupDone: true, version: 5, updatedAt: new Date() }],
+ ["it is already done", { deleting: true, cleanupDone: true, version: 4 }, { cleanupDone: true, version: 5, updatedAt: serverTimestamp() }],
+ ])("rejects marking cleanup done when %s", async (_label, seeded, patch) => {
+ const p = await seedRestaurant("r1", seeded);
+ await assertFails(updateDoc(doc(as("ava"), p), patch));
+ });
+
+ it("refuses the final delete until cleanupDone is set and the collection document is gone", async () => {
+ const p = await seedRestaurant("r1", { deleting: true });
+ await assertFails(deleteDoc(doc(as("ava"), p))); // a Plan 3-era finisher stops here
+ await seedRestaurant("r1", { deleting: true, cleanupDone: true });
+ await env.withSecurityRulesDisabled(async (ctx) => {
+ await setDoc(doc(ctx.firestore(), "households/home/collection/r1"), { shortlisted: true, visited: false, updatedBy: "ava", updatedByName: "Ava", updatedAt: new Date(), version: 1 });
+ });
+ await assertFails(deleteDoc(doc(as("ava"), p)));
+ await env.withSecurityRulesDisabled(async (ctx) => {
+ await deleteDoc(doc(ctx.firestore(), "households/home/collection/r1"));
+ });
+ await assertSucceeds(deleteDoc(doc(as("ava"), p)));
+ });
+});
+
const utcDate = (d: string) => Timestamp.fromDate(new Date(`${d}T00:00:00.000Z`));
const isoDay = (date: Date) => date.toISOString().slice(0, 10);
const daysAhead = (n: number) => isoDay(new Date(Date.now() + n * 86_400_000));
diff --git a/planning/audits/2026-09-24-plan-4-design-review.md b/planning/audits/2026-09-24-plan-4-design-review.md
new file mode 100644
index 0000000..c6dffd5
--- /dev/null
+++ b/planning/audits/2026-09-24-plan-4-design-review.md
@@ -0,0 +1,64 @@
+# Plan 4 design review — §3.7
+
+Reviewed commit: `a149918116df8358abfe67289d149cd6dd3e89d6` on `worktree-pwa-04-collection`.
+Reviewed worktree: `/home/godja/Dev/AvaGF/.claude/worktrees/pwa-04-collection`.
+
+## Verdict
+
+**Changes requested before the implementation plan: two P2 design findings and one P3 correction.** The shortlist/visited/notes model is coherent, but the rollout/deletion and account-switch contracts need amendment. This is a source-and-design review, not a claim that unimplemented Plan 4 behavior has been runtime-tested.
+
+Compared §3.7 and the §2.3/§3.3 changes with the existing rules, records repository, authentication, page lifecycle and browser helpers. Independent review passes covered database/deletion and authentication/password behavior. Current official Firebase documentation was checked for the relevant platform guarantees. No production configuration, secrets or deployed data were inspected or changed.
+
+## F1 — P2: old clients can bypass the expanded deletion sequence
+
+Spec locations: lines 949–953 and 1028–1029; collection deletion condition at 923–925. Existing code: `web/src/records/repository.ts:232–252`, `web/src/records/RestaurantsPage.tsx:34–41`, `firestore.rules:128–129`.
+
+The deploy note says the old client never touches the new paths, so rules-first deployment is safe. However, it still deletes their parent restaurant. The existing client finishes deletion by sweeping claims and deleting the restaurant; the current final-delete rule requires only membership and `deleting == true`. It also automatically resumes marked restaurants when the Saved page mounts. §3.7 extends the new client sequence but supplies no revised final-delete/old-client contract.
+
+Concrete mixed-version scenario:
+
+1. A Plan 4 client creates restaurant notes and `collection/{rid}`.
+2. An old open client deletes that restaurant, or resumes a deletion started by the new client.
+3. It sweeps claims and removes the restaurant without sweeping notes or collection state.
+4. The restaurant vanishes from the list, removing the normal resume entry. The collection document cannot satisfy the proposed delete rule anymore because its parent is absent; another member's orphaned notes likewise cannot be swept through the proposed parent-deleting exception.
+
+Firestore does not cascade document deletion to subcollections. [Firebase deletion documentation](https://firebase.google.com/docs/firestore/manage-data/delete-data#delete_documents). The separate collection document also survives independently. This is a concrete consequence of the proposed compatibility contract, not a reproduced production incident.
+
+**Required amendment:** define how incomplete cleanup prevents final parent deletion across bundle versions: a completion gate in the protocol/rules that old finishers cannot skip, server-owned cleanup, or an explicitly enforced cutover that prevents old clients from deleting once Plan 4 data exists. A version marker added only at the initial mark is insufficient if the old resumer can still finish an already-marked document. Merely deploying hosting does not replace every open tab. Do not describe the rollout as unconditionally safe because old clients ignore the new paths.
+
+**Acceptance:** run the old deletion/resume path against the new rules and seeded notes/collection state, including an old resumer racing a new deleter. Either refuse final deletion while preserving a resumable parent or complete all cleanup. Also cover two new-client sweepers and retries after each intermediate step; missing notes/state/parent must not turn successful concurrent cleanup into a misleading permission failure.
+
+If the first-ever deployment will provably contain only Plan 4 clients, that can be documented as a limited rollout precondition instead; it is not a general compatibility guarantee for cached bundles or rollback.
+
+## F2 — P2: the sign-out reset covers only the initiating tab
+
+Spec locations: lines 987–989 and 1024. Existing code: `web/src/firebase.ts:23–25`, `web/src/auth/AuthProvider.tsx:67–85`.
+
+Firebase's default browser auth persistence synchronizes auth state between same-origin tabs. [Firebase persistence documentation](https://firebase.google.com/docs/auth/web/auth-state-persistence#expected_behavior_across_browser_tabs).
+
+With household data loaded in tabs A and B, the proposed `signOut()` wrapper reloads A. B receives an auth-state callback and hides/unmounts its member shell, but does not execute A's wrapper. Its module-scoped Firestore instance and memory cache remain. The design therefore does not meet its cache-clearing promise in all open documents. This finding establishes incomplete clearing, **not demonstrated unauthorized rendering**.
+
+**Required amendment:** define a reset in every document that observes a previously authenticated UID become null or a different UID. Coordinate that with the explicit sign-out action; initial signed-out startup and same-UID token/reauthentication events must not cause reload loops. Hiding the old UI and invalidating pending callbacks remain necessary while reset occurs. A full reload is a reasonable implementation, but is not the only possible reset mechanism.
+
+**Acceptance:** two pages in one browser context, both with loaded records/notes; sign out in one, assert reset and removal of previous-account state in both, then sign in as the non-member without test-driven navigation. The existing auth helper calls `page.goto('/')`, and the account-switch test also calls `page.reload()` (`web/e2e/auth.spec.ts:6,59`); using those between identities would clear memory independently and conceal a broken application reset. Separate contexts used for two household members do not exercise shared auth persistence.
+
+## F3 — P3: password-update rejection is missing from the error contract
+
+Spec location: lines 991–996.
+
+Eight characters is a reasonable client minimum for this design, but Firebase's server policy can impose different length or composition requirements; six is a default, not a universal policy. [Firebase password-policy documentation](https://firebase.google.com/docs/auth/web/password-auth#recommended_set_a_password_policy). No live project-policy check was performed, so this is an incomplete contract, not an observed staging failure.
+
+Keep the proposed reauthentication followed by `updatePassword`, but add password-policy rejection (including `auth/weak-password`) and an unknown-error fallback. Reauthentication failure must not invoke the update. Successful reauthentication followed by a rejected update must show an actionable error, retain usable controls and never report success. Treat eight characters as client validation, or separately make it an explicit owner-controlled server-policy requirement.
+
+## Decisions and implementation-plan clarifications
+
+- **`updatedByName`: accept.** Comparing it with the caller's own admin-managed user document on every state write is consistent with the existing evidence-author design. It records the name at the time of the last change; it does not require peer-user reads.
+- **2,000-character notes, transient filter choice and no list toggles: accept.** These are bounded choices consistent with the pilot scope. The notes limit still needs actual boundary rules tests, not just a literal-parity check.
+- **Reload on sign-out: acceptable once F2 covers all documents.** Replace the claim that it is the only provable reset with the narrower guarantee the implementation will test.
+- **Joined read states:** specify behavior while either restaurant/collection listener is loading, denied or errored. An unknown collection snapshot must not be interpreted as an empty shortlist or base-version-0 record. Confirm absence from a server-backed snapshot before allowing the missing-state default; authoritative empty messages require the necessary ready snapshots, as §3.5 already requires. Keep one offline notice without suppressing errors. Add mixed-state tests for list and detail.
+- **Deletion/notes concurrency:** in addition to the mixed-version case, test duplicate sweepers, author editing/deleting while a restaurant is marked, and a stale note edit/delete on another device. Explicitly retain claim-ID-style confirmation identity for notes. Define which current text a conflict chooser adopts before the next versioned write.
+- **Test isolation:** extend emulator cleanup helpers to remove notes and collection documents; isolate the password-change test's user or restore its password in cleanup. Existing browser suites assume the shared fixture password. Do not manually reload to make account-switch tests pass.
+
+## Next step
+
+Amend F1/F2 and the password error contract in §3.7, carry the acceptance cases into the implementation plan, and then proceed with plan review. No implementation changes or emulator/browser gates were run for this design-only review. The report is left uncommitted; the reviewed spec is unchanged.
diff --git a/planning/audits/2026-09-24-plan-4-execution-ledger.md b/planning/audits/2026-09-24-plan-4-execution-ledger.md
new file mode 100644
index 0000000..768260e
--- /dev/null
+++ b/planning/audits/2026-09-24-plan-4-execution-ledger.md
@@ -0,0 +1,84 @@
+# Plan 4 execution ledger
+
+Copied from the git-ignored SDD workspace at completion so rulings, deferred minors and gate evidence survive for the owner and the external auditor. Branch range 4052c36..88b8d14.
+
+# SDD ledger — plan: planning/plans/2026-09-24-safebite-pwa-04-collection.md
+
+Spec: planning/specs/2026-09-20-safebite-pwa-design.md §3.7 (amended f8edfc9). Branch worktree-pwa-04-collection, start 4052c36.
+Models: implementers sonnet (plans carry full code but edit existing files across several places), task reviewers sonnet, final review opus.
+
+## Pre-flight scan
+
+| Pair / task | Produces → consumes | Finding |
+|---|---|---|
+| T1↔T2 firestore.rules | T1 adds callerName/notes/collection; T2 rewrites restaurant create/update/delete | Disjoint regions; T2's delete gate reads collection/{rid} that T1 defines. OK |
+| T1↔T3 | LIMITS.note (T1) → validateNoteText (T3) | OK |
+| T2↔T3 repository exports | write, ConflictError, NotFoundError, LISTEN, listenerFailure, toDate, restaurantRef, notesCol, collectionRef → collection.ts / notes.ts | Names match. OK |
+| T2↔T4/T5 messages.ts(+test) | deleteProgressText (T2) → pages; T4 appends finishOutcomeText; T5 appends statusOutcomeMessage | Appends only; T2 creates messages.test.ts, T4/T5 append. OK |
+| T2↔T4 RestaurantsPage.tsx | T2 swaps STEP_TEXT for deleteProgressText; T4 replaces the file (still uses deleteProgressText) | OK |
+| T2↔T6 RestaurantFormPage.tsx | T2 progress text; T6 confirm text + outcome verb | Disjoint. OK |
+| T3↔T4/T5/T6 | CollectionState, Note, setShortlisted/setVisited/watch*, add/update/deleteNote signatures | Match (checked arg order hid,rid,author,base,value / hid,rid,nid,base,text). OK |
+| T4↔T5↔T6 combine.ts | isData/anyOffline/combineStates (T4) → StatusBlock, detail page, NotesSection | OK |
+| T5↔T6 RestaurantDetailPage(+test) | T5 adds stateWatch, anyOffline(rs,cs,ss), StatusBlock; T6 adds notesWatch into anyOffline, NotesSection | Sequential edits of same lines, instructions compatible. OK |
+| T4↔T6 styles.css | both append | OK |
+| T4↔T9 web/e2e | T4 edits records.spec (filter-all); T9 edits emulator-rest + new spec | Disjoint. OK |
+| T7↔T10 | resetting / per-tab reset → cross-tab e2e | OK; T7 changes sign-out to a reload, existing e2e waits on signin-form → still valid |
+| T8↔T10 | pw-* testids → e2e | OK |
+| T9↔T10 emulator-rest | seedNote/seedRestaurant/clearRecords (T9) → auth.spec (T10) | T10 depends on T9 order. OK |
+| T1 self | tests vs rules | Consistent |
+| T2 self | Step 3 "expected fail" wording approximate (replaced delete test passes on old rules) | Harmless |
+| T3 self | counts 8 collection + 6 notes + 2 validation | Consistent |
+| T4 self | existing page tests need filter-all + collection mock; e2e scenarios 1,7 | Covered in steps |
+| T5 self | date inputs via fireEvent (jsdom sanitising) | Consistent |
+| T6 self | counter 21/2000 | Consistent |
+| T7 self | two existing AuthProvider tests rewritten | Consistent |
+| T8 self | 15 + 4 + 1 tests | Consistent |
+| T9/T10 self | 29 → 36 → 38 scenarios; globalTimeout 1.2M | Consistent |
+
+Ruling: keep functions/test/rules.deletion.test.ts's `newFinish` as a mirror of repository.finishDeleting (logic duplicated across packages) — the rules test cannot import web code and the real client is covered by e2e C5 — cost if wrong: the mirror drifts from the repository unnoticed; mitigated by its "keep in step" comment and C5.
+Ruling: accept duplicated firestore vi.mock blocks across repository/collection/notes tests — per-file vi.mock factories cannot be shared without hoisting tricks; test scaffolding only — cost if wrong: minor maintenance.
+Ruling: toggle-conflict browser test replaced by unit/component proof + note-conflict e2e (plan self-review deviation, flagged to owner) — cost if wrong: one additional e2e scenario later.
+
+## Progress
+Task 1: dispatched (base 4052c36, implementer a38cc9e41dd289c06, sonnet)
+Task 1: minor (deferred): firestore.rules callerName() duplicates claims' callerDisplayName() (plan-mandated); consolidate later
+Task 1: complete (commits 4052c36..d726498, review clean) — note: worktree had no node_modules; implementer ran npm install in root/functions/web (lockfile untouched)
+Task 2: dispatched (base d726498)
+Task 2: minor (deferred): rules.deletion.test NEW_STEPS mirrors repository by hand (plan-mandated; see preflight ruling)
+Task 2: minor (deferred): race test's orphan assertion is conditional on timing (if !after.restaurant)
+Task 2: minor (deferred): memoryFirestore tx.update creates missing docs (real Firestore fails); memoryPage default 100 duplicates SWEEP_PAGE
+Task 2: minor (deferred): markCleanupDone notFound on a live restaurant would read "Already removed." (unreachable via finishDeleting)
+Task 2: complete (commits d726498..ebf581f, review clean) — counts: unit 276, functions+rules 352, browser 29
+Task 3: dispatched (base ebf581f)
+Task 3: ⚠️ resolved — deleteNote skips parent check: spec §3.7 lets the author delete any time; compliant
+Task 3: minor (deferred): comment the asymmetry (deletes don't gate on restaurant state)
+Task 3: complete (commits ebf581f..09b4594, review clean) — unit 292
+Task 4: dispatched (base 09b4594)
+Task 4: observation — discover.spec.ts scenario 7 (supersede slower search) failed 2/3 full e2e runs (~24 s function time), passed 3rd; untouched code; watch in final stress run
+Task 4: ⚠️ resolved — anyOffline unused until Tasks 5–6 (planned consumers); discover flake tracked above
+Task 4: complete (commits 09b4594..7e9cabe, review clean) — unit 305, browser 29
+Task 5: dispatched (base 7e9cabe)
+Ruling: Task 5 commit 1e5bc05 trailer names Claude Sonnet 5 (the implementing model, per its harness attribution reminder) instead of the plan's Opus line — accepted, no history rewrite; trailers may differ per task — cost if wrong: cosmetic trailer inconsistency, fixable by the owner at squash time
+Task 5: minor (deferred): "On shortlist · Remove" rendered as separate span+button without a literal middot (cosmetic)
+Task 5: complete (commits 7e9cabe..1e5bc05, review clean) — unit 317
+Task 6: dispatched (base 1e5bc05)
+Task 6: observation — e2e 4 runs: discover#7 flake, plus two one-off 'signin-form not visible within 5s on page load' timeouts (auth.spec, records#6); run 4 clean 29/29; machine idle (load <1.5) — quantify in final stress run
+Task 6: minor (deferred): NotesSection.tsx comment says the confirmation reset "mirrors ClaimCard" — ClaimCard resets by key remount; wording inaccurate
+Task 6: complete (commits 1e5bc05..21bf09e, review clean) — unit 329, browser 29 (after re-runs; flakes tracked)
+Task 7: dispatched (base 21bf09e)
+Task 7: ⚠️ resolved — signOutAndWait with reload verified by the implementer's 29/29 e2e first run
+Task 7: minor (deferred): AuthProvider reset predicate vs !user branch readability
+Task 7: complete (commits 21bf09e..f8a9dcf, review clean) — unit 332
+Task 8: dispatched (base f8a9dcf)
+Task 8: minor (deferred): offline message literal duplicated a third time (changePassword.ts; also messages.ts, DiscoverPage.tsx)
+Task 8: complete (commits f8a9dcf..31f3aca, review clean) — unit 352
+Task 9: dispatched (base 31f3aca)
+Task 9: complete (commits 31f3aca..70a78b2, review clean) — browser 36, stress 108/108
+Task 10: dispatched (base 70a78b2)
+Ruling: Task 10 guardrail grep hit on `resetDocument` is a substring false positive; word-bounded grep (-w) is clean — no rename — cost if wrong: none (verified no Firestore write API in web/src).
+Task 10: observation — stress 113/114, discover.spec.ts scenario 7 failed once (third sighting this plan: T4, T6, T10); passes single-pass; flag to final review/owner as a timing-sensitive test
+Task 10: complete (commits 70a78b2..3dec446, review clean) — unit 352, functions+rules 352, browser 38, stress 113/114 (discover#7), boot-guard/preview/upgrade 1/4/7
+Final review: With fixes — 1 Important (discover#7 wait budget), 5 Minor; fix wave dispatched (base 3dec446)
+Final fix wave: commits 3dec446..88b8d14 — all 6 findings ADDRESSED (re-review), no new breakage. Gate: typecheck clean; unit 354; functions+rules 352; browser 38; stress 114/114.
+Final: deferred (out of scope, residual): auth.spec.ts:70 inline signin-form wait after page.reload() still on the 5 s default.
+Final: deferred minors kept per final-review triage: T1 callerName dup, T2 memoryFirestore update/SWEEP_PAGE, T2 markCleanupDone wording, T3 delete-asymmetry comment, T5 middot, T6 NotesSection comment, T7 predicate readability, T8 offline string dup.
diff --git a/planning/audits/2026-09-24-plan-4-implementation-audit.md b/planning/audits/2026-09-24-plan-4-implementation-audit.md
new file mode 100644
index 0000000..3538b93
--- /dev/null
+++ b/planning/audits/2026-09-24-plan-4-implementation-audit.md
@@ -0,0 +1,73 @@
+# Plan 4 implementation audit — 2026-09-24
+
+**Verdict: changes requested.** Audit target: `42e80c5f312f65458f3549f30e6531b4f08d1074` on `worktree-pwa-04-collection`, compared with merged main `6923a97d4f0e638fb0aa6a39c51c39b8249816d2`. Git counts **19 commits** after that base. The checkout was clean on entry. Three independent review scopes covered rules/deletion, auth/password/worker interactions, and records UI/data flow; the primary reviewer checked their findings and ran the local gates.
+
+## Findings
+
+### F1 — P2: an open visit-date draft silently adopts a newer version
+
+**Source:** `web/src/records/StatusBlock.tsx:31`, `:45`, `:63`, `:83–94`.
+
+The date editor stores only its string. Every incoming collection snapshot recalculates `base` from the newest version, even when the date field still contains an older draft. If Ava opens version 1, Bogdan saves another date as version 2, and Ava then saves her existing draft, her write uses version 2 and succeeds. The other member's change is overwritten without a conflict, and the new date was hidden behind Ava's editor. The repository's version check is correct; the component gives it the wrong base.
+
+**Reproduced:** a focused test imports the actual component, opens a draft at version 3, rerenders with version 4, and observes `setVisited(..., 4, oldDraft)` instead of base 3. A second probe used two authenticated Chromium contexts against the real rules: Ava opened version 1 with a 4 May draft; Bogdan saved 10 May as version 2; Ava received that snapshot and then saved. Final state became 4 May, version 3, with no conflict displayed.
+
+**Fix:** capture the base version alongside the date when editing starts. Retain it across live updates until explicit cancellation/reseeding or successful save. A remote change must cause the stale draft to return `conflict` and show the current state, consistent with §3.7.
+
+**Regression:** open a date draft at version 1, let another authenticated member save version 2 and deliver that snapshot to the first page, then save the first draft. Assert conflict and no overwrite. Cover a base-0 draft whose document is created by the other member too. Existing component tests mock a conflict result; they do not verify this live-snapshot interval.
+
+### F2 — P2: typing while a note save is pending loses unsaved text
+
+**Source:** `web/src/records/NotesSection.tsx:58–61`, `:71`, `:99–103`, `:139`.
+
+Both note textareas remain editable while the corresponding save button is busy. A save submits the text captured at the click. If the member adds or changes text before that promise resolves, a successful create unconditionally clears the composer; a successful edit unconditionally closes the editor. The later text was neither submitted nor preserved. Slow mobile connections make this a normal interaction, not a conflicting-client attack.
+
+**Reproduced:** two focused tests import the actual NotesSection and defer its repository promise. Additional text is accepted while saving, then the composer becomes empty / editor disappears after success. The mocked write arguments contain only the pre-edit submission.
+
+**Fix:** either lock the textarea during the submitted operation or preserve edits made since submission and retain a usable editor. Apply the same policy to create, edit and Keep mine. Failure must retain the draft as already promised.
+
+**Regression:** hold the save promise pending, type additional text, resolve success, and assert that the new text cannot disappear unsaved. Cover both composer and existing-note editor.
+
+### F3 — P2: a password update can succeed while the UI says the old password works
+
+**Source:** `web/src/auth/changePassword.ts:18`, `:39–46`, `:59–62`; `web/src/pages/ChangePasswordForm.tsx:35–40`.
+
+The installed Firebase Auth SDK sends `accounts:update` and then performs an account lookup/token persistence before resolving `updatePassword`. A failure after the password-changing request has succeeded rejects that promise. The current helper treats this as an ordinary failure: an unknown error says “Your old password still works”; a network failure says to connect and try again with the retained old current-password field. Neither accounts for a password that already changed.
+
+**Reproduced:** a Chromium probe let the real local Auth emulator commit the new password, then failed the subsequent `accounts:lookup`. With an injected internal error, the screen said “Your old password still works”; independent Auth sign-ins returned `oldAccepted: false`, `newAccepted: true`. Aborting the lookup instead produced the offline/retry message with the same credential results. The synthetic fixture password was restored after each case.
+
+**Fix:** distinguish failures before attempting the update from ambiguous completion after attempting it. Do not promise that the old password works after an uncertain update; provide recovery guidance that allows for the new password already being active. Preserve definitive policy/wrong-current outcomes where justified. Amend the same incorrect promise in §3.7.
+
+**Regression:** allow the real local Auth emulator password update to succeed, fail the subsequent lookup, then independently verify old/new credential acceptance and the recovery message. Test both an internal error and network loss.
+
+## Confirmed safeguards and limitations
+
+- The previous mixed-version deletion defect is closed for the shipped clients. Old finishers cannot set `cleanupDone`; current finishers sweep claims and notes, remove collection state, mark completion, and remove the parent. Transaction rereads/skip-missing behavior supports concurrent finishers and resumption. Existing real-rules tests and browser cleanup scenarios passed locally.
+- `cleanupDone` is **client attestation**, as §3.7 explicitly acknowledges. Rules do not prove that claims/notes are empty. This audit does not elevate it to a guarantee against arbitrary modified member code. Firestore parent deletion does not cascade: [official documentation](https://firebase.google.com/docs/firestore/manage-data/delete-data#delete_documents).
+- Collection writes enforce membership, writer identity/name, exact shape, version progression and calendar dates. Notes enforce author attribution and author-only edits, with the specified deletion-sweep exception. Neither feeds evidence freshness.
+- The auth observer now resets every same-origin tab on sign-out/account change and invalidates stale membership callbacks. The same-context browser regression checks actual document replacement and avoids test-driven navigation between account changes. Firebase's cross-tab behavior is documented [here](https://firebase.google.com/docs/auth/web/auth-state-persistence#expected_behavior_across_browser_tabs).
+- The deletion emulator test mirrors the production sequence rather than importing it. The current sequences match, but future drift remains possible. Browser cleanup tests exercise the real repository. A later shared harness would strengthen this without blocking the present fixes.
+- Small evidence/documentation inaccuracies: README still says 352 web tests; the actual suite has 354. The password browser test's visible navigation assertion alone does not prove absence of a document reload; a window marker would strengthen it. These are lower priority than F1–F3.
+
+## Validation
+
+Independently run at the audited SHA:
+
+| Check | Result |
+|---|---|
+| `npm run typecheck` | Pass, both packages and configured TS projects |
+| `npm run test:unit` | 354 passed across 36 files |
+| `npm run emu:test` | 352 passed across 16 files; local Auth/Firestore/Functions |
+| `npm run emu:e2e:stress` | 114/114 passed; 38 scenarios × 3; retries disabled; 8.1 minutes |
+| Root compile-only build and three synthetic builds | Pass |
+| Boot-guard / preview / upgrade | 1 / 4 / 7 passed |
+| CI's empty-config / non-production / ambient-fixture build refusals | Each refused for the expected reason |
+| Guardrail scans, ignored local secret, `git diff --check`, unchanged Swift tree | Pass |
+| Added diagnostic component probes (outside normal suite) | 3 expected failures, reproducing F1 and both F2 paths |
+| Added diagnostic browser probes | Confirmed F1 against real rules; confirmed F3 with both internal-error and network-loss injection after a real password update |
+
+These green existing suites do not cover the new failure intervals. Diagnostic failures are recorded separately from the passing repository suite. The complete browser repeat log and the focused probe sources/output are preserved in `planning/audits/plan-4-review-probes/`.
+
+Read-only GitHub checks found no remote `worktree-pwa-04-collection` branch and no hosted CI runs for it. Local gates are not hosted CI evidence for this SHA. Live staging, credentials, deployed rules/functions/hosting and real iPhone/Safari behavior were not inspected or changed. O3–O6 are recorded complete in the current spec; this audit does not independently re-certify those owner actions.
+
+Application/specification files were not changed. Audit artifacts remain uncommitted; nothing was pushed, merged or deployed. After fixing F1–F3, rerun the focused regressions and affected local gates, then require hosted CI on the eventual pushed SHA. Plan 5 still needs to include notes and collection state in export/account deletion.
diff --git a/planning/audits/plan-4-review-probes/README.md b/planning/audits/plan-4-review-probes/README.md
new file mode 100644
index 0000000..6e7cd48
--- /dev/null
+++ b/planning/audits/plan-4-review-probes/README.md
@@ -0,0 +1,20 @@
+# Plan 4 diagnostic evidence
+
+Target: `42e80c5`. See the adjacent implementation audit for findings and independently run gates.
+
+- `drafts.test.tsx.txt` imports the real components with mocked repository promises. Its three assertions intentionally fail on the audited code: stale visit base, lost composer text, lost edit text. The `.txt` suffix excludes these audit probes from the normal suite. `component-output.txt` is the actual output.
+- The two `.mjs` browser probes use the real app at `127.0.0.1:5173`, local demo-safebite Auth/Firestore emulators, and synthetic fixture accounts. They assert the observed defects, so they exit successfully when those defects reproduce. No hosted service is called. The visit probe deletes its records; the password probe restores the fixture password in `finally`. `browser-output.txt` records both.
+- `browser-stress-output.txt` records the separate repository browser gate: 114/114 passed with retries disabled. The passing repository suite and failing diagnostic assertions are different tests.
+
+## Repeat on this checkout
+
+Run from the repository root with dependencies installed. Do not run beside another emulator/browser suite: these probes share the emulator ports and fixture accounts.
+
+```bash
+cp planning/audits/plan-4-review-probes/safebite-plan4-*-probe.mjs /tmp/
+cp planning/audits/plan-4-review-probes/safebite-plan4-run-probes.sh.txt /tmp/safebite-plan4-run-probes.sh
+npm --prefix functions run build
+./node_modules/.bin/firebase emulators:exec --only auth,firestore --project demo-safebite 'node functions/lib/seed-emulator.js && bash /tmp/safebite-plan4-run-probes.sh'
+```
+
+The component probe/config files retain the reviewed checkout's absolute paths. To repeat, copy them to `/tmp/safebite-plan4-records-audit/` without `.txt`, link that directory's `node_modules` to this checkout's `web/node_modules`, then run `node web/node_modules/vitest/vitest.mjs run --config /tmp/safebite-plan4-records-audit/vitest.config.mts`. Update the paths when using another worktree. Turn these diagnostic cases into normal regression tests as part of the fixes.
diff --git a/planning/audits/plan-4-review-probes/browser-output.txt b/planning/audits/plan-4-review-probes/browser-output.txt
new file mode 100644
index 0000000..c4a4548
--- /dev/null
+++ b/planning/audits/plan-4-review-probes/browser-output.txt
@@ -0,0 +1,16 @@
+i emulators: Starting emulators: auth, firestore
+i emulators: Detected demo project ID "demo-safebite", emulated services will use a demo configuration and attempts to access non-emulated services for this project will fail.
+i firestore: Firestore Emulator logging to firestore-debug.log
+✔ firestore: Firestore Emulator was started in standard edition.
+✔ firestore: Firestore Emulator UI websocket is running on 9150.
+i Running script: node functions/lib/seed-emulator.js && bash /tmp/safebite-plan4-run-probes.sh
+Emulator seeded: ava@safebite.test, bogdan@safebite.test (members), stranger@safebite.test (not a member). Discovery enabled with a cap of 50 searches/day.
+{"probe":"stale-visited-draft","initialVersion":1,"otherMemberSaved":"2026-05-10","staleDraftSaved":"2026-05-04T00:00:00Z","finalVersion":"3","conflictDisplayed":false}
+{"fault":"internal-error","updateCommitted":true,"injected":true,"outcomeText":"Couldn't change your password. Your old password still works.","oldAccepted":false,"newAccepted":true}
+{"fault":"network-loss","updateCommitted":true,"injected":true,"outcomeText":"You are offline. Connect and try again.","oldAccepted":false,"newAccepted":true}
+✔ Script exited successfully (code 0)
+i emulators: Shutting down emulators.
+i firestore: Stopping Firestore Emulator
+i auth: Stopping Authentication Emulator
+i hub: Stopping emulator hub
+i logging: Stopping Logging Emulator
diff --git a/planning/audits/plan-4-review-probes/browser-stress-output.txt b/planning/audits/plan-4-review-probes/browser-stress-output.txt
new file mode 100644
index 0000000..99f66f3
--- /dev/null
+++ b/planning/audits/plan-4-review-probes/browser-stress-output.txt
@@ -0,0 +1,573 @@
+
+> emu:e2e:stress
+> node tooling/ensure-secret-local.mjs && npm --prefix functions run build && FUNCTIONS_DISCOVERY_TIMEOUT=90 firebase emulators:exec --only auth,firestore,functions --project demo-safebite "node functions/lib/seed-emulator.js && npm --prefix web run e2e -- --repeat-each=3 --retries=0"
+
+[safebite] functions/.secret.local present; leaving it alone.
+
+> safebite-functions@0.1.0 build
+> tsc
+
+i emulators: Starting emulators: auth, functions, firestore
+i emulators: Detected demo project ID "demo-safebite", emulated services will use a demo configuration and attempts to access non-emulated services for this project will fail.
+⚠ functions: Application Default Credentials detected. Non-emulated services will access production using these credentials. Be careful!
+i firestore: Firestore Emulator logging to firestore-debug.log
+✔ firestore: Firestore Emulator was started in standard edition.
+✔ firestore: Firestore Emulator UI websocket is running on 9150.
+i functions: Watching "/home/godja/Dev/AvaGF/.claude/worktrees/pwa-04-collection/functions" for Cloud Functions...
+✔ functions: Using node@22 from host.
+Serving at port 8245
+
+✔ functions: Loaded functions definitions from source: whoami, searchDestination, searchNearby.
+✔ functions[europe-west2-whoami]: http function initialized (http://127.0.0.1:5001/demo-safebite/europe-west2/whoami).
+✔ functions[europe-west2-searchDestination]: http function initialized (http://127.0.0.1:5001/demo-safebite/europe-west2/searchDestination).
+✔ functions[europe-west2-searchNearby]: http function initialized (http://127.0.0.1:5001/demo-safebite/europe-west2/searchNearby).
+i Running script: node functions/lib/seed-emulator.js && npm --prefix web run e2e -- --repeat-each=3 --retries=0
+Emulator seeded: ava@safebite.test, bogdan@safebite.test (members), stranger@safebite.test (not a member). Discovery enabled with a cap of 50 searches/day.
+
+> web@0.0.0 e2e
+> playwright test --repeat-each=3 --retries=0
+
+[WebServer] (node:2076545) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
+[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"MISSING"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 17.403548ms
+
+Running 114 tests using 1 worker
+
+(node:2076651) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
+(Use `node --trace-warnings ...` to show where the warning was created)
+ ✓ 1 [mobile-chromium] › e2e/auth.spec.ts:30:1 › signed-out visitor sees the sign-in form and nothing else (2.1s)
+ ✓ 2 [mobile-chromium] › e2e/auth.spec.ts:36:1 › wrong password shows an error and stays signed out (1.7s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 18.037359ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 329.392243ms
+ ✓ 3 [mobile-chromium] › e2e/auth.spec.ts:46:1 › a member signs in, sees the shell, and the server confirms membership (4.3s)
+ ✓ 4 [mobile-chromium] › e2e/auth.spec.ts:55:1 › a signed-in non-member is refused and can sign out (2.8s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 8.964433ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 51.258285ms
+ ✓ 5 [mobile-chromium] › e2e/auth.spec.ts:63:1 › switching accounts on the same device never shows the previous member's shell (4.7s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 15.843046ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 48.537714ms
+[WebServer] 9:18:38 PM [vite] (client) [console.warn] [2026-09-24T20:18:38.670Z] @firebase/firestore: Firestore (12.19.0): WebChannelConnection RPC 'Listen' stream 0x3b4cb95c transport errored. Name: undefined Message: undefined
+ ✓ 6 [mobile-chromium] › e2e/auth.spec.ts:76:1 › signing out in one tab resets every tab, and the next account never sees the previous household (5.6s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 3.882098ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 36.379859ms
+ ✓ 7 [mobile-chromium] › e2e/auth.spec.ts:119:1 › a member changes their password, stays signed in, and only the new password works afterwards (3.6s)
+ ✓ 8 [mobile-chromium] › e2e/collection.spec.ts:32:1 › C1. a shortlist change by one member appears live on the other member's Saved page (6.1s)
+ ✓ 9 [mobile-chromium] › e2e/collection.spec.ts:57:1 › C2. mark visited, change the date, and clear it (5.4s)
+ ✓ 10 [mobile-chromium] › e2e/collection.spec.ts:80:1 › C3. both members write notes; only the author can edit or delete (7.0s)
+ ✓ 11 [mobile-chromium] › e2e/collection.spec.ts:112:1 › C4. deleting a restaurant removes its evidence, both members' notes and its shortlist state (4.5s)
+ ✓ 12 [mobile-chromium] › e2e/collection.spec.ts:133:1 › C5. a deletion an older client left half-done is finished from the Saved page (3.1s)
+ ✓ 13 [mobile-chromium] › e2e/collection.spec.ts:149:1 › C6. a note edited on another device surfaces as a conflict, and Keep mine wins with the new version (3.5s)
+[WebServer] 9:19:14 PM [vite] (client) [console.warn] [2026-09-24T20:19:14.926Z] @firebase/firestore: Firestore (12.19.0): WebChannelConnection RPC 'Listen' stream 0x4252d0f0 transport errored. Name: undefined Message: undefined
+[WebServer] 9:19:14 PM [vite] (client) [console.warn] [2026-09-24T20:19:14.943Z] @firebase/firestore: Firestore (12.19.0): WebChannelConnection RPC 'Listen' stream 0x4252d0f1 transport errored. Name: undefined Message: undefined
+ ✓ 14 [mobile-chromium] › e2e/collection.spec.ts:167:1 › C7. while offline the page says so once and every write control is disabled (3.3s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 7.297537ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 457.240367ms
+ ✓ 15 [mobile-chromium] › e2e/discover.spec.ts:52:1 › 1. a destination search lists fixture results with Google Maps attribution, links and add buttons (4.6s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 3.39305ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 63.328834ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 56.931549ms
+ ✓ 16 [mobile-chromium] › e2e/discover.spec.ts:69:1 › destination and named-venue intent reach the callable only on submit (2.7s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 4.098295ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":0,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 51.674021ms
+ ✓ 17 [mobile-chromium] › e2e/discover.spec.ts:96:1 › 2. no results shows the empty state and no attribution (2.1s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 4.421681ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-searchDestination" in 31.603137ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-searchDestination" in 32.897318ms
+ ✓ 18 [mobile-chromium] › e2e/discover.spec.ts:105:1 › 3. the kill switch: disabled and missing config both read as switched off (2.6s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 3.511768ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 49.167014ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-searchDestination" in 52.834601ms
+ ✓ 19 [mobile-chromium] › e2e/discover.spec.ts:117:1 › 4. the household's daily cap is enforced and usage is not consumed past it (2.3s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 2.325392ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","durationMs":0,"outcome":"unavailable","status":503,"severity":"WARNING","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 50.510919ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","durationMs":0,"outcome":"quota","status":429,"severity":"WARNING","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 57.036125ms
+ ✓ 20 [mobile-chromium] › e2e/discover.spec.ts:128:1 › 5. provider failures: unavailable and quota exceeded, never sample venues (2.4s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 2.362339ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+ ✓ 21 [mobile-chromium] › e2e/discover.spec.ts:138:1 › 6. a search that never answers times out on the client (22.8s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 5.660997ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 7.269275ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 379.60853ms
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":23516,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 25069.178169ms
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":1,"durationMs":3003,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 3385.919571ms
+ ✓ 22 [mobile-chromium] › e2e/discover.spec.ts:147:1 › 7. a newer search supersedes a slower one; the late answer never replaces it (9.4s)
+i functions: Beginning execution of "europe-west2-searchNearby"
+i functions: Finished "europe-west2-searchNearby" in 4.323178ms
+i functions: Beginning execution of "europe-west2-searchNearby"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"nearby","uid":"ava-uid","householdId":"home","lat":51.51,"lng":-0.13,"resultCount":10,"durationMs":1,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchNearby" in 345.165055ms
+ ✓ 23 [mobile-chromium] › e2e/discover.spec.ts:164:3 › Near me with location granted › 8. Near me searches around the granted position (3.8s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 7.707283ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 59.336559ms
+ ✓ 24 [mobile-chromium] › e2e/discover.spec.ts:172:1 › 9. Near me without permission shows the denied state and calls nothing (2.3s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 3.378073ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":1,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 45.393862ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 55.004931ms
+ ✓ 25 [mobile-chromium] › e2e/discover.spec.ts:183:1 › 10. Add to our records carries only the place id; the member types name and address; the saved record links to Google Maps and the result shows In our records (3.0s)
+[WebServer] 9:20:12 PM [vite] (client) [console.warn] [2026-09-24T20:20:12.318Z] @firebase/firestore: Firestore (12.19.0): WebChannelConnection RPC 'Listen' stream 0x271d8bd9 transport errored. Name: undefined Message: undefined
+[WebServer] 9:20:12 PM [vite] (client) [console.warn] [2026-09-24T20:20:12.326Z] @firebase/firestore: Firestore (12.19.0): WebChannelConnection RPC 'Listen' stream 0x271d8bda transport errored. Name: undefined Message: undefined
+[WebServer] 9:20:12 PM [vite] (client) [console.warn] [2026-09-24T20:20:12.443Z] @firebase/firestore: Firestore (12.19.0): WebChannelConnection RPC 'Listen' stream 0x271d8bdb transport errored. Name: undefined Message: undefined
+ ✓ 26 [mobile-chromium] › e2e/discover.spec.ts:210:1 › 11. searching while offline is refused without a request; a seeded record's Maps link needs no network (3.3s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 7.473704ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":0,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 48.324072ms
+ ✓ 27 [mobile-chromium] › e2e/discover.spec.ts:222:1 › 12. a late location after a newer destination search never supersedes it (audit F1) (2.1s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 5.652145ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 37.638008ms
+ ✓ 28 [mobile-chromium] › e2e/discover.spec.ts:236:1 › 13. leaving Discover before the position resolves never starts a paid search (audit F1) (4.2s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 4.554361ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 55.783053ms
+ ✓ 29 [mobile-chromium] › e2e/discover.spec.ts:249:1 › 14. nothing Google-derived reaches browser history when a result is added but not saved (audit F2) (3.2s)
+ ✓ 30 [mobile-chromium] › e2e/records.spec.ts:37:1 › 1. a member adds a restaurant and sees it with six unknown kinds and the call-ahead prompts (2.7s)
+ ✓ 31 [mobile-chromium] › e2e/records.spec.ts:58:1 › 2. evidence: accreditation needs a link; current, needs-rechecking and conflicting states render (5.2s)
+ ✓ 32 [mobile-chromium] › e2e/records.spec.ts:97:1 › 3. a stale draft is told the restaurant changed elsewhere, its save conflicts, and Reload draft shows the other member's values (6.3s)
+ ✓ 33 [mobile-chromium] › e2e/records.spec.ts:123:1 › 4. deleting a restaurant with evidence leaves nothing behind; a concurrent Add evidence sees not-found (8.0s)
+ ✓ 34 [mobile-chromium] › e2e/records.spec.ts:152:1 › 5. an interrupted deletion (marked, not swept) is finished from the list (2.8s)
+ ✓ 35 [mobile-chromium] › e2e/records.spec.ts:167:1 › 6. saving while offline is refused, nothing is queued, and nothing appears after reconnecting (3.8s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 7.544072ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 37.061171ms
+ ✓ 36 [mobile-chromium] › e2e/records.spec.ts:182:1 › 7. after an account switch no record of the previous member is rendered and no page error fires (4.0s)
+ ✓ 37 [mobile-chromium] › e2e/records.spec.ts:200:1 › 8. live replacement evidence needs its own delete confirmation (3.4s)
+ ✓ 38 [mobile-chromium] › e2e/records.spec.ts:222:1 › 9. malformed website and evidence URLs show field errors and can be corrected (4.0s)
+(node:2080741) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
+(Use `node --trace-warnings ...` to show where the warning was created)
+ ✓ 39 [mobile-chromium] › e2e/auth.spec.ts:30:1 › signed-out visitor sees the sign-in form and nothing else (1.6s)
+ ✓ 40 [mobile-chromium] › e2e/auth.spec.ts:36:1 › wrong password shows an error and stays signed out (1.6s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 16.79004ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 35.764151ms
+ ✓ 41 [mobile-chromium] › e2e/auth.spec.ts:46:1 › a member signs in, sees the shell, and the server confirms membership (3.1s)
+ ✓ 42 [mobile-chromium] › e2e/auth.spec.ts:55:1 › a signed-in non-member is refused and can sign out (2.6s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 19.950831ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 34.852611ms
+ ✓ 43 [mobile-chromium] › e2e/auth.spec.ts:63:1 › switching accounts on the same device never shows the previous member's shell (4.9s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 5.520557ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 33.253216ms
+[WebServer] 9:21:19 PM [vite] (client) [console.warn] [2026-09-24T20:21:19.906Z] @firebase/firestore: Firestore (12.19.0): WebChannelConnection RPC 'Listen' stream 0x4bcfdbe5 transport errored. Name: undefined Message: undefined
+ ✓ 44 [mobile-chromium] › e2e/auth.spec.ts:76:1 › signing out in one tab resets every tab, and the next account never sees the previous household (5.6s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 9.303325ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 25.428367ms
+ ✓ 45 [mobile-chromium] › e2e/auth.spec.ts:119:1 › a member changes their password, stays signed in, and only the new password works afterwards (3.4s)
+ ✓ 46 [mobile-chromium] › e2e/collection.spec.ts:32:1 › C1. a shortlist change by one member appears live on the other member's Saved page (5.8s)
+ ✓ 47 [mobile-chromium] › e2e/collection.spec.ts:57:1 › C2. mark visited, change the date, and clear it (5.4s)
+ ✓ 48 [mobile-chromium] › e2e/collection.spec.ts:80:1 › C3. both members write notes; only the author can edit or delete (7.0s)
+ ✓ 49 [mobile-chromium] › e2e/collection.spec.ts:112:1 › C4. deleting a restaurant removes its evidence, both members' notes and its shortlist state (4.0s)
+ ✓ 50 [mobile-chromium] › e2e/collection.spec.ts:133:1 › C5. a deletion an older client left half-done is finished from the Saved page (2.9s)
+ ✓ 51 [mobile-chromium] › e2e/collection.spec.ts:149:1 › C6. a note edited on another device surfaces as a conflict, and Keep mine wins with the new version (3.5s)
+[WebServer] 9:21:54 PM [vite] (client) [console.warn] [2026-09-24T20:21:54.928Z] @firebase/firestore: Firestore (12.19.0): WebChannelConnection RPC 'Listen' stream 0x83034f68 transport errored. Name: undefined Message: undefined
+[WebServer] 9:21:54 PM [vite] (client) [console.warn] [2026-09-24T20:21:54.946Z] @firebase/firestore: Firestore (12.19.0): WebChannelConnection RPC 'Listen' stream 0x83034f69 transport errored. Name: undefined Message: undefined
+ ✓ 52 [mobile-chromium] › e2e/collection.spec.ts:167:1 › C7. while offline the page says so once and every write control is disabled (3.1s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 5.042576ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 42.949657ms
+ ✓ 53 [mobile-chromium] › e2e/discover.spec.ts:52:1 › 1. a destination search lists fixture results with Google Maps attribution, links and add buttons (2.2s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 2.832947ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 43.054384ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 44.328586ms
+ ✓ 54 [mobile-chromium] › e2e/discover.spec.ts:69:1 › destination and named-venue intent reach the callable only on submit (2.2s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 3.325503ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":0,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 45.518391ms
+ ✓ 55 [mobile-chromium] › e2e/discover.spec.ts:96:1 › 2. no results shows the empty state and no attribution (2.0s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 3.592263ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-searchDestination" in 29.116343ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-searchDestination" in 38.3315ms
+ ✓ 56 [mobile-chromium] › e2e/discover.spec.ts:105:1 › 3. the kill switch: disabled and missing config both read as switched off (2.3s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 2.382563ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 40.874907ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-searchDestination" in 37.984936ms
+ ✓ 57 [mobile-chromium] › e2e/discover.spec.ts:117:1 › 4. the household's daily cap is enforced and usage is not consumed past it (2.2s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 2.734889ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","durationMs":0,"outcome":"unavailable","status":503,"severity":"WARNING","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 40.406161ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","durationMs":0,"outcome":"quota","status":429,"severity":"WARNING","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 45.338128ms
+ ✓ 58 [mobile-chromium] › e2e/discover.spec.ts:128:1 › 5. provider failures: unavailable and quota exceeded, never sample venues (2.3s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 2.496159ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+ ✓ 59 [mobile-chromium] › e2e/discover.spec.ts:138:1 › 6. a search that never answers times out on the client (22.9s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 5.838573ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 57.043952ms
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":25005,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 25049.740126ms
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":1,"durationMs":3008,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 3074.920118ms
+ ✓ 60 [mobile-chromium] › e2e/discover.spec.ts:147:1 › 7. a newer search supersedes a slower one; the late answer never replaces it (7.1s)
+i functions: Beginning execution of "europe-west2-searchNearby"
+i functions: Finished "europe-west2-searchNearby" in 4.73895ms
+i functions: Beginning execution of "europe-west2-searchNearby"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"nearby","uid":"ava-uid","householdId":"home","lat":51.51,"lng":-0.13,"resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchNearby" in 52.986546ms
+ ✓ 61 [mobile-chromium] › e2e/discover.spec.ts:164:3 › Near me with location granted › 8. Near me searches around the granted position (2.1s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 4.606673ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 39.208512ms
+ ✓ 62 [mobile-chromium] › e2e/discover.spec.ts:172:1 › 9. Near me without permission shows the denied state and calls nothing (2.1s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 2.339469ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 38.563354ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 42.457242ms
+ ✓ 63 [mobile-chromium] › e2e/discover.spec.ts:183:1 › 10. Add to our records carries only the place id; the member types name and address; the saved record links to Google Maps and the result shows In our records (2.7s)
+[WebServer] 9:22:44 PM [vite] (client) [console.warn] [2026-09-24T20:22:44.567Z] @firebase/firestore: Firestore (12.19.0): WebChannelConnection RPC 'Listen' stream 0x48c1af29 transport errored. Name: undefined Message: undefined
+[WebServer] 9:22:44 PM [vite] (client) [console.warn] [2026-09-24T20:22:44.576Z] @firebase/firestore: Firestore (12.19.0): WebChannelConnection RPC 'Listen' stream 0x48c1af2a transport errored. Name: undefined Message: undefined
+ ✓ 64 [mobile-chromium] › e2e/discover.spec.ts:210:1 › 11. searching while offline is refused without a request; a seeded record's Maps link needs no network (3.5s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 4.758639ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":0,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 50.939159ms
+ ✓ 65 [mobile-chromium] › e2e/discover.spec.ts:222:1 › 12. a late location after a newer destination search never supersedes it (audit F1) (2.4s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 4.610344ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 20.664219ms
+ ✓ 66 [mobile-chromium] › e2e/discover.spec.ts:236:1 › 13. leaving Discover before the position resolves never starts a paid search (audit F1) (4.4s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 4.31536ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 44.128608ms
+ ✓ 67 [mobile-chromium] › e2e/discover.spec.ts:249:1 › 14. nothing Google-derived reaches browser history when a result is added but not saved (audit F2) (3.2s)
+ ✓ 68 [mobile-chromium] › e2e/records.spec.ts:37:1 › 1. a member adds a restaurant and sees it with six unknown kinds and the call-ahead prompts (2.5s)
+ ✓ 69 [mobile-chromium] › e2e/records.spec.ts:58:1 › 2. evidence: accreditation needs a link; current, needs-rechecking and conflicting states render (4.8s)
+ ✓ 70 [mobile-chromium] › e2e/records.spec.ts:97:1 › 3. a stale draft is told the restaurant changed elsewhere, its save conflicts, and Reload draft shows the other member's values (6.3s)
+ ✓ 71 [mobile-chromium] › e2e/records.spec.ts:123:1 › 4. deleting a restaurant with evidence leaves nothing behind; a concurrent Add evidence sees not-found (7.9s)
+ ✓ 72 [mobile-chromium] › e2e/records.spec.ts:152:1 › 5. an interrupted deletion (marked, not swept) is finished from the list (2.9s)
+ ✓ 73 [mobile-chromium] › e2e/records.spec.ts:167:1 › 6. saving while offline is refused, nothing is queued, and nothing appears after reconnecting (3.6s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 8.711216ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 22.258834ms
+ ✓ 74 [mobile-chromium] › e2e/records.spec.ts:182:1 › 7. after an account switch no record of the previous member is rendered and no page error fires (4.0s)
+ ✓ 75 [mobile-chromium] › e2e/records.spec.ts:200:1 › 8. live replacement evidence needs its own delete confirmation (3.3s)
+ ✓ 76 [mobile-chromium] › e2e/records.spec.ts:222:1 › 9. malformed website and evidence URLs show field errors and can be corrected (3.8s)
+(node:2084718) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
+(Use `node --trace-warnings ...` to show where the warning was created)
+ ✓ 77 [mobile-chromium] › e2e/auth.spec.ts:30:1 › signed-out visitor sees the sign-in form and nothing else (1.5s)
+ ✓ 78 [mobile-chromium] › e2e/auth.spec.ts:36:1 › wrong password shows an error and stays signed out (1.4s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 5.7167ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 22.673937ms
+ ✓ 79 [mobile-chromium] › e2e/auth.spec.ts:46:1 › a member signs in, sees the shell, and the server confirms membership (2.9s)
+ ✓ 80 [mobile-chromium] › e2e/auth.spec.ts:55:1 › a signed-in non-member is refused and can sign out (2.6s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 5.397337ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 18.67896ms
+ ✓ 81 [mobile-chromium] › e2e/auth.spec.ts:63:1 › switching accounts on the same device never shows the previous member's shell (4.5s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 5.662829ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 23.334877ms
+[WebServer] 9:23:50 PM [vite] (client) [console.warn] [2026-09-24T20:23:50.405Z] @firebase/firestore: Firestore (12.19.0): WebChannelConnection RPC 'Listen' stream 0x77742377 transport errored. Name: undefined Message: undefined
+ ✓ 82 [mobile-chromium] › e2e/auth.spec.ts:76:1 › signing out in one tab resets every tab, and the next account never sees the previous household (5.4s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 2.931508ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 22.296002ms
+ ✓ 83 [mobile-chromium] › e2e/auth.spec.ts:119:1 › a member changes their password, stays signed in, and only the new password works afterwards (3.4s)
+ ✓ 84 [mobile-chromium] › e2e/collection.spec.ts:32:1 › C1. a shortlist change by one member appears live on the other member's Saved page (5.3s)
+ ✓ 85 [mobile-chromium] › e2e/collection.spec.ts:57:1 › C2. mark visited, change the date, and clear it (5.1s)
+ ✓ 86 [mobile-chromium] › e2e/collection.spec.ts:80:1 › C3. both members write notes; only the author can edit or delete (6.7s)
+ ✓ 87 [mobile-chromium] › e2e/collection.spec.ts:112:1 › C4. deleting a restaurant removes its evidence, both members' notes and its shortlist state (4.1s)
+ ✓ 88 [mobile-chromium] › e2e/collection.spec.ts:133:1 › C5. a deletion an older client left half-done is finished from the Saved page (2.9s)
+ ✓ 89 [mobile-chromium] › e2e/collection.spec.ts:149:1 › C6. a note edited on another device surfaces as a conflict, and Keep mine wins with the new version (3.3s)
+[WebServer] 9:24:24 PM [vite] (client) [console.warn] [2026-09-24T20:24:24.369Z] @firebase/firestore: Firestore (12.19.0): WebChannelConnection RPC 'Listen' stream 0x6220ad91 transport errored. Name: undefined Message: undefined
+[WebServer] 9:24:24 PM [vite] (client) [console.warn] [2026-09-24T20:24:24.385Z] @firebase/firestore: Firestore (12.19.0): WebChannelConnection RPC 'Listen' stream 0x6220ad92 transport errored. Name: undefined Message: undefined
+ ✓ 90 [mobile-chromium] › e2e/collection.spec.ts:167:1 › C7. while offline the page says so once and every write control is disabled (3.2s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 4.140201ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 42.659022ms
+ ✓ 91 [mobile-chromium] › e2e/discover.spec.ts:52:1 › 1. a destination search lists fixture results with Google Maps attribution, links and add buttons (2.2s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 2.752153ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 37.998093ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 42.019034ms
+ ✓ 92 [mobile-chromium] › e2e/discover.spec.ts:69:1 › destination and named-venue intent reach the callable only on submit (2.2s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 3.869521ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":0,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 45.718546ms
+ ✓ 93 [mobile-chromium] › e2e/discover.spec.ts:96:1 › 2. no results shows the empty state and no attribution (2.1s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 2.254073ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-searchDestination" in 28.469682ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-searchDestination" in 30.50481ms
+ ✓ 94 [mobile-chromium] › e2e/discover.spec.ts:105:1 › 3. the kill switch: disabled and missing config both read as switched off (2.0s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 6.405302ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 47.975436ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-searchDestination" in 47.081089ms
+ ✓ 95 [mobile-chromium] › e2e/discover.spec.ts:117:1 › 4. the household's daily cap is enforced and usage is not consumed past it (2.1s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 3.450407ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","durationMs":0,"outcome":"unavailable","status":503,"severity":"WARNING","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 54.141206ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","durationMs":0,"outcome":"quota","status":429,"severity":"WARNING","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 50.715942ms
+ ✓ 96 [mobile-chromium] › e2e/discover.spec.ts:128:1 › 5. provider failures: unavailable and quota exceeded, never sample venues (2.6s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 3.635842ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+ ✓ 97 [mobile-chromium] › e2e/discover.spec.ts:138:1 › 6. a search that never answers times out on the client (23.0s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 5.560958ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 53.775795ms
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":23517,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 25051.787136ms
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":1,"durationMs":3008,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 3072.135856ms
+ ✓ 98 [mobile-chromium] › e2e/discover.spec.ts:147:1 › 7. a newer search supersedes a slower one; the late answer never replaces it (7.1s)
+i functions: Beginning execution of "europe-west2-searchNearby"
+i functions: Finished "europe-west2-searchNearby" in 5.500068ms
+i functions: Beginning execution of "europe-west2-searchNearby"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"nearby","uid":"ava-uid","householdId":"home","lat":51.51,"lng":-0.13,"resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchNearby" in 52.368251ms
+ ✓ 99 [mobile-chromium] › e2e/discover.spec.ts:164:3 › Near me with location granted › 8. Near me searches around the granted position (2.1s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 4.101245ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 46.848502ms
+ ✓ 100 [mobile-chromium] › e2e/discover.spec.ts:172:1 › 9. Near me without permission shows the denied state and calls nothing (2.3s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 11.755197ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 43.443825ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 43.707912ms
+ ✓ 101 [mobile-chromium] › e2e/discover.spec.ts:183:1 › 10. Add to our records carries only the place id; the member types name and address; the saved record links to Google Maps and the result shows In our records (2.7s)
+[WebServer] 9:25:14 PM [vite] (client) [console.warn] [2026-09-24T20:25:14.307Z] @firebase/firestore: Firestore (12.19.0): WebChannelConnection RPC 'Listen' stream 0x150b488a transport errored. Name: undefined Message: undefined
+[WebServer] 9:25:14 PM [vite] (client) [console.warn] [2026-09-24T20:25:14.315Z] @firebase/firestore: Firestore (12.19.0): WebChannelConnection RPC 'Listen' stream 0x150b488b transport errored. Name: undefined Message: undefined
+ ✓ 102 [mobile-chromium] › e2e/discover.spec.ts:210:1 › 11. searching while offline is refused without a request; a seeded record's Maps link needs no network (3.4s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 4.820776ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":0,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 37.417944ms
+ ✓ 103 [mobile-chromium] › e2e/discover.spec.ts:222:1 › 12. a late location after a newer destination search never supersedes it (audit F1) (2.3s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 14.996103ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 22.004881ms
+ ✓ 104 [mobile-chromium] › e2e/discover.spec.ts:236:1 › 13. leaving Discover before the position resolves never starts a paid search (audit F1) (4.2s)
+i functions: Beginning execution of "europe-west2-searchDestination"
+i functions: Finished "europe-west2-searchDestination" in 4.592908ms
+i functions: Beginning execution of "europe-west2-searchDestination"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+> {"kind":"destination","uid":"ava-uid","householdId":"home","resultCount":10,"durationMs":0,"outcome":"ok","severity":"INFO","message":"discovery.search"}
+i functions: Finished "europe-west2-searchDestination" in 44.548884ms
+ ✓ 105 [mobile-chromium] › e2e/discover.spec.ts:249:1 › 14. nothing Google-derived reaches browser history when a result is added but not saved (audit F2) (3.0s)
+ ✓ 106 [mobile-chromium] › e2e/records.spec.ts:37:1 › 1. a member adds a restaurant and sees it with six unknown kinds and the call-ahead prompts (2.5s)
+ ✓ 107 [mobile-chromium] › e2e/records.spec.ts:58:1 › 2. evidence: accreditation needs a link; current, needs-rechecking and conflicting states render (4.9s)
+ ✓ 108 [mobile-chromium] › e2e/records.spec.ts:97:1 › 3. a stale draft is told the restaurant changed elsewhere, its save conflicts, and Reload draft shows the other member's values (6.2s)
+ ✓ 109 [mobile-chromium] › e2e/records.spec.ts:123:1 › 4. deleting a restaurant with evidence leaves nothing behind; a concurrent Add evidence sees not-found (7.9s)
+ ✓ 110 [mobile-chromium] › e2e/records.spec.ts:152:1 › 5. an interrupted deletion (marked, not swept) is finished from the list (2.8s)
+ ✓ 111 [mobile-chromium] › e2e/records.spec.ts:167:1 › 6. saving while offline is refused, nothing is queued, and nothing appears after reconnecting (3.6s)
+i functions: Beginning execution of "europe-west2-whoami"
+i functions: Finished "europe-west2-whoami" in 6.666011ms
+i functions: Beginning execution of "europe-west2-whoami"
+> {"verifications":{"app":"MISSING","auth":"VALID"},"logging.googleapis.com/labels":{"firebase-log-type":"callable-request-verification"},"severity":"DEBUG","message":"Callable request verification passed"}
+i functions: Finished "europe-west2-whoami" in 20.359291ms
+ ✓ 112 [mobile-chromium] › e2e/records.spec.ts:182:1 › 7. after an account switch no record of the previous member is rendered and no page error fires (4.0s)
+ ✓ 113 [mobile-chromium] › e2e/records.spec.ts:200:1 › 8. live replacement evidence needs its own delete confirmation (3.4s)
+ ✓ 114 [mobile-chromium] › e2e/records.spec.ts:222:1 › 9. malformed website and evidence URLs show field errors and can be corrected (3.9s)
+
+ 114 passed (8.1m)
+✔ Script exited successfully (code 0)
+i emulators: Shutting down emulators.
+i functions: Stopping Functions Emulator
+i firestore: Stopping Firestore Emulator
+i auth: Stopping Authentication Emulator
+i eventarc: Stopping Eventarc Emulator
+i tasks: Stopping Cloud Tasks Emulator
+i hub: Stopping emulator hub
+i logging: Stopping Logging Emulator
diff --git a/planning/audits/plan-4-review-probes/component-output.txt b/planning/audits/plan-4-review-probes/component-output.txt
new file mode 100644
index 0000000..682799c
--- /dev/null
+++ b/planning/audits/plan-4-review-probes/component-output.txt
@@ -0,0 +1,95 @@
+
+ RUN v5.0.1 /home/godja/Dev/AvaGF/.claude/worktrees/pwa-04-collection/web
+
+stdout | ../../../../../../../../tmp/safebite-plan4-records-audit/drafts.test.tsx > visited date draft must keep the version the member started editing
+AUDIT stale visit save arguments [
+ 'home',
+ 'r1',
+ { uid: 'ava-uid', displayName: 'Ava' },
+ 4,
+ '2026-09-02'
+]
+
+stdout | ../../../../../../../../tmp/safebite-plan4-records-audit/drafts.test.tsx > successful add must not erase text typed after submitting the earlier draft
+AUDIT submitted note [ 'home', 'r1', { uid: 'ava-uid', displayName: 'Ava' }, 'First note' ] remaining input
+
+stdout | ../../../../../../../../tmp/safebite-plan4-records-audit/drafts.test.tsx > successful edit must not hide text typed while the earlier save was pending
+AUDIT submitted edit [ 'home', 'r1', 'n1', 1, 'Saved revision' ] remaining editor null
+
+ ❯ ../../../../../../../../tmp/safebite-plan4-records-audit/drafts.test.tsx (3 tests | 3 failed) 746ms
+ × visited date draft must keep the version the member started editing 321ms
+ × successful add must not erase text typed after submitting the earlier draft 214ms
+ × successful edit must not hide text typed while the earlier save was pending 208ms
+
+⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯
+
+ FAIL ../../../../../../../../tmp/safebite-plan4-records-audit/drafts.test.tsx > visited date draft must keep the version the member started editing
+AssertionError: expected "vi.fn()" to be called with arguments: [ 'home', 'r1', …(3) ]
+
+Received:
+
+ 1st vi.fn() call:
+
+@@ -3,8 +3,8 @@
+ "r1",
+ {
+ "displayName": "Ava",
+ "uid": "ava-uid",
+ },
+- 3,
++ 4,
+ "2026-09-02",
+ ]
+
+
+Number of calls: 1
+
+ ❯ ../../../../../../../../tmp/safebite-plan4-records-audit/drafts.test.tsx:22:23
+ 20| await userEvent.click(screen.getByTestId('visited-save'));
+ 21| console.log('AUDIT stale visit save arguments',m.setVisited.mock.call…
+ 22| expect(m.setVisited).toHaveBeenCalledWith('home','r1',author,3,'2026-…
+ | ^
+ 23| });
+ 24| it('successful add must not erase text typed after submitting the earl…
+
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯
+
+ FAIL ../../../../../../../../tmp/safebite-plan4-records-audit/drafts.test.tsx > successful add must not erase text typed after submitting the earlier draft
+Error: expect(element).toHaveValue(First note plus unsaved follow-up)
+
+Expected the element to have value:
+ First note plus unsaved follow-up
+Received:
+
+ ❯ ../../../../../../../../tmp/safebite-plan4-records-audit/drafts.test.tsx:34:46
+ 32| await act(async()=>finish({kind:'ok',value:'new-note'}));
+ 33| console.log('AUDIT submitted note',m.addNote.mock.calls[0],'remaining…
+ 34| expect(screen.getByTestId('note-add-text')).toHaveValue('First note p…
+ | ^
+ 35| });
+ 36| it('successful edit must not hide text typed while the earlier save wa…
+
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯
+
+ FAIL ../../../../../../../../tmp/safebite-plan4-records-audit/drafts.test.tsx > successful edit must not hide text typed while the earlier save was pending
+Error: expect(received).toHaveValue()
+
+received value must be an HTMLElement or an SVGElement.
+Received has type: Null
+Received has value: null
+ ❯ ../../../../../../../../tmp/safebite-plan4-records-audit/drafts.test.tsx:49:52
+ 47| await act(async()=>finish({kind:'ok',value:2}));
+ 48| console.log('AUDIT submitted edit',m.updateNote.mock.calls[0],'remain…
+ 49| expect(screen.queryByTestId('note-edit-text-n1')).toHaveValue('Saved …
+ | ^
+ 50| });
+ 51|
+
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/3]⎯
+
+
+ Test Files 1 failed (1)
+ Tests 3 failed (3)
+ Start at 21:21:36
+ Duration 2.63s (environment 41%, tests 30%, import 11%, transform 11%, setup 7%)
+
diff --git a/planning/audits/plan-4-review-probes/drafts.test.tsx.txt b/planning/audits/plan-4-review-probes/drafts.test.tsx.txt
new file mode 100644
index 0000000..27cd54e
--- /dev/null
+++ b/planning/audits/plan-4-review-probes/drafts.test.tsx.txt
@@ -0,0 +1,50 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, expect, it, vi } from 'vitest';
+const m = vi.hoisted(() => ({setVisited:vi.fn(),setShortlisted:vi.fn(),addNote:vi.fn(),updateNote:vi.fn(),deleteNote:vi.fn()}));
+vi.mock('/home/godja/Dev/AvaGF/.claude/worktrees/pwa-04-collection/web/src/records/collection.ts', () => m);
+vi.mock('/home/godja/Dev/AvaGF/.claude/worktrees/pwa-04-collection/web/src/records/notes.ts', () => m);
+vi.mock('/home/godja/Dev/AvaGF/.claude/worktrees/pwa-04-collection/web/src/records/useToday.ts', () => ({useToday:()=> '2026-09-24'}));
+vi.mock('/home/godja/Dev/AvaGF/.claude/worktrees/pwa-04-collection/web/src/auth/AuthProvider.tsx',()=>({useAuth:()=>({signOut:vi.fn()})}));
+import { StatusBlock } from '/home/godja/Dev/AvaGF/.claude/worktrees/pwa-04-collection/web/src/records/StatusBlock.tsx';
+import { NotesSection } from '/home/godja/Dev/AvaGF/.claude/worktrees/pwa-04-collection/web/src/records/NotesSection.tsx';
+const author={uid:'ava-uid',displayName:'Ava'};
+const state={shortlisted:false,visited:true,visitedOn:'2026-09-01',updatedBy:'ava-uid',updatedByName:'Ava',updatedAt:new Date(),version:3};
+const props={householdId:'home',rid:'r1',author,disabled:false,onRetry:()=>{}};
+beforeEach(()=>{vi.clearAllMocks();m.setVisited.mockResolvedValue({kind:'ok',value:5});});
+it('visited date draft must keep the version the member started editing',async()=>{
+ const {rerender}=render();
+ await userEvent.click(screen.getByTestId('visited-change'));
+ fireEvent.change(screen.getByTestId('visited-date'),{target:{value:'2026-09-02'}});
+ rerender();
+ await userEvent.click(screen.getByTestId('visited-save'));
+ console.log('AUDIT stale visit save arguments',m.setVisited.mock.calls[0]);
+ expect(m.setVisited).toHaveBeenCalledWith('home','r1',author,3,'2026-09-02');
+});
+it('successful add must not erase text typed after submitting the earlier draft',async()=>{
+ let finish!:(x:unknown)=>void;
+ m.addNote.mockImplementation(()=>new Promise(resolve=>{finish=resolve;}));
+ render();
+ await userEvent.type(screen.getByTestId('note-add-text'),'First note');
+ await userEvent.click(screen.getByTestId('note-add-save'));
+ await userEvent.type(screen.getByTestId('note-add-text'),' plus unsaved follow-up');
+ expect(screen.getByTestId('note-add-text')).toHaveValue('First note plus unsaved follow-up');
+ await act(async()=>finish({kind:'ok',value:'new-note'}));
+ console.log('AUDIT submitted note',m.addNote.mock.calls[0],'remaining input',(screen.getByTestId('note-add-text') as HTMLTextAreaElement).value);
+ expect(screen.getByTestId('note-add-text')).toHaveValue('First note plus unsaved follow-up');
+});
+it('successful edit must not hide text typed while the earlier save was pending',async()=>{
+ let finish!:(x:unknown)=>void;
+ m.updateNote.mockImplementation(()=>new Promise(resolve=>{finish=resolve;}));
+ const note={id:'n1',text:'Original',authorUid:author.uid,authorName:'Ava',createdAt:new Date(),updatedAt:new Date(),version:1};
+ render();
+ await userEvent.click(screen.getByTestId('note-edit-n1'));
+ await userEvent.clear(screen.getByTestId('note-edit-text-n1'));
+ await userEvent.type(screen.getByTestId('note-edit-text-n1'),'Saved revision');
+ await userEvent.click(screen.getByTestId('note-save-n1'));
+ await userEvent.type(screen.getByTestId('note-edit-text-n1'),' plus unsaved text');
+ expect(screen.getByTestId('note-edit-text-n1')).toHaveValue('Saved revision plus unsaved text');
+ await act(async()=>finish({kind:'ok',value:2}));
+ console.log('AUDIT submitted edit',m.updateNote.mock.calls[0],'remaining editor',screen.queryByTestId('note-edit-text-n1'));
+ expect(screen.queryByTestId('note-edit-text-n1')).toHaveValue('Saved revision plus unsaved text');
+});
diff --git a/planning/audits/plan-4-review-probes/safebite-plan4-password-probe.mjs b/planning/audits/plan-4-review-probes/safebite-plan4-password-probe.mjs
new file mode 100644
index 0000000..8114e0d
--- /dev/null
+++ b/planning/audits/plan-4-review-probes/safebite-plan4-password-probe.mjs
@@ -0,0 +1,82 @@
+// Run only while the shared local emulator suite is idle.
+// Requires Vite on :5173 and demo-safebite Auth/Firestore/Functions emulators.
+// Restores Bogdan's synthetic fixture password after each scenario. Never logs tokens.
+import { createRequire } from "node:module";
+import { resolve } from "node:path";
+const require = createRequire(resolve("web/package.json"));
+const { chromium, expect } = require("@playwright/test");
+
+const AUTH = "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1";
+const EMAIL = "bogdan@safebite.test";
+const OLD = "pilot-password-1";
+const NEXT = "audit-probe-new-password-1";
+
+async function restorePassword() {
+ const response = await fetch(`${AUTH}/projects/demo-safebite/accounts:update`, {
+ method: "POST",
+ headers: { Authorization: "Bearer owner", "Content-Type": "application/json" },
+ body: JSON.stringify({ localId: "bogdan-uid", password: OLD }),
+ });
+ if (!response.ok) throw new Error(`Fixture restoration failed: HTTP ${response.status}`);
+}
+
+async function accepts(password) {
+ const response = await fetch(`${AUTH}/accounts:signInWithPassword?key=demo-api-key`, {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: EMAIL, password, returnSecureToken: true }),
+ });
+ // Consume but never log the response containing auth tokens.
+ await response.arrayBuffer();
+ return response.ok;
+}
+
+const browser = await chromium.launch({ headless: true });
+try {
+ for (const fault of ["internal-error", "network-loss"]) {
+ await restorePassword();
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ let updateCommitted = false;
+ let injected = false;
+ try {
+ await page.goto("http://127.0.0.1:5173/");
+ await page.getByTestId("signin-email").fill(EMAIL);
+ await page.getByTestId("signin-password").fill(OLD);
+ await page.getByTestId("signin-submit").click();
+ await expect(page.getByTestId("nav-settings")).toBeVisible({ timeout: 15000 });
+ await page.getByTestId("nav-settings").click();
+
+ await page.route(/127\.0\.0\.1:9099\/identitytoolkit\.googleapis\.com\/v1\/accounts:(update|lookup)\?/, async (route) => {
+ const operation = new URL(route.request().url()).pathname.split(":").at(-1);
+ if (operation === "update" && route.request().postDataJSON()?.password === NEXT) {
+ const response = await route.fetch();
+ if (!response.ok()) throw new Error(`Password update unexpectedly failed: HTTP ${response.status()}`);
+ updateCommitted = true;
+ await route.fulfill({ response });
+ } else if (operation === "lookup" && updateCommitted && !injected) {
+ injected = true;
+ if (fault === "network-loss") await route.abort("failed");
+ else await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: { code: 500, message: "INTERNAL_ERROR" } }) });
+ } else await route.continue();
+ });
+
+ await page.getByTestId("pw-current").fill(OLD);
+ await page.getByTestId("pw-new").fill(NEXT);
+ await page.getByTestId("pw-confirm").fill(NEXT);
+ await page.getByTestId("pw-submit").click();
+ await expect(page.getByTestId("pw-outcome")).toHaveAttribute("data-kind", fault === "internal-error" ? "failed" : "offline", { timeout: 15000 });
+ const outcomeText = await page.getByTestId("pw-outcome").innerText();
+ const oldAccepted = await accepts(OLD);
+ const newAccepted = await accepts(NEXT);
+ console.log(JSON.stringify({ fault, updateCommitted, injected, outcomeText, oldAccepted, newAccepted }));
+ expect(injected).toBe(true);
+ expect(oldAccepted).toBe(false);
+ expect(newAccepted).toBe(true);
+ } finally {
+ await context.close();
+ await restorePassword();
+ }
+ }
+} finally {
+ await browser.close();
+}
diff --git a/planning/audits/plan-4-review-probes/safebite-plan4-run-probes.sh.txt b/planning/audits/plan-4-review-probes/safebite-plan4-run-probes.sh.txt
new file mode 100644
index 0000000..3aae211
--- /dev/null
+++ b/planning/audits/plan-4-review-probes/safebite-plan4-run-probes.sh.txt
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+set -euo pipefail
+# Called by firebase emulators:exec, after the normal browser suite has stopped.
+(cd web && exec node node_modules/vite/bin/vite.js --host 127.0.0.1) > /tmp/safebite-plan4-probe-vite.log 2>&1 &
+audit_vite_pid=$!
+trap 'kill "$audit_vite_pid" 2>/dev/null || true; wait "$audit_vite_pid" 2>/dev/null || true' EXIT
+node --input-type=module -e 'for (let i = 0; i < 100; i++) { try { const r = await fetch("http://127.0.0.1:5173/"); if(r.ok) process.exit(0); } catch {} await new Promise(r => setTimeout(r, 100)); } process.exit(1)'
+node /tmp/safebite-plan4-visited-probe.mjs
+node /tmp/safebite-plan4-password-probe.mjs
diff --git a/planning/audits/plan-4-review-probes/safebite-plan4-visited-probe.mjs b/planning/audits/plan-4-review-probes/safebite-plan4-visited-probe.mjs
new file mode 100644
index 0000000..561107c
--- /dev/null
+++ b/planning/audits/plan-4-review-probes/safebite-plan4-visited-probe.mjs
@@ -0,0 +1,53 @@
+// Audit reproduction: run against local demo-safebite emulators and Vite :5173 only.
+import { createRequire } from 'node:module';
+import { resolve } from 'node:path';
+const require = createRequire(resolve('web/package.json'));
+const { chromium, expect } = require('@playwright/test');
+const base = 'http://127.0.0.1:8080/v1/projects/demo-safebite/databases/(default)/documents';
+const rid = `audit-visited-${Date.now()}`;
+const paths = [`households/home/restaurants/${rid}`, `households/home/collection/${rid}`];
+const headers = {Authorization:'Bearer owner','Content-Type':'application/json'};
+const s = stringValue => ({stringValue});
+const t = timestampValue => ({timestampValue});
+const i = n => ({integerValue:String(n)});
+const b = booleanValue => ({booleanValue});
+async function seed(path, fields) {
+ const response = await fetch(`${base}/${path}`, {method:'PATCH',headers,body:JSON.stringify({fields})});
+ if (!response.ok) throw new Error(`Local seed failed: ${response.status}`);
+}
+async function signIn(page, who) {
+ await page.goto('http://127.0.0.1:5173/');
+ await page.getByTestId('signin-email').fill(`${who}@safebite.test`);
+ await page.getByTestId('signin-password').fill('pilot-password-1');
+ await page.getByTestId('signin-submit').click();
+ await expect(page.getByTestId('nav-saved')).toBeVisible({timeout:15000});
+ await page.goto(`http://127.0.0.1:5173/restaurants/${rid}`);
+ await expect(page.getByTestId('visited-state')).toHaveText('Visited 3 May 2026');
+}
+await seed(paths[0], {name:s('Audit visited race'),address:s('1 Test Street'),createdBy:s('ava-uid'),createdAt:t('2026-09-01T00:00:00Z'),updatedAt:t('2026-09-01T00:00:00Z'),version:i(1),deleting:b(false)});
+await seed(paths[1], {shortlisted:b(false),visited:b(true),visitedOn:t('2026-05-03T00:00:00Z'),updatedBy:s('ava-uid'),updatedByName:s('Ava'),updatedAt:t('2026-09-01T00:00:00Z'),version:i(1)});
+const browser = await chromium.launch();
+try {
+ const ac = await browser.newContext();
+ const bc = await browser.newContext();
+ const a = await ac.newPage();
+ const other = await bc.newPage();
+ await signIn(a,'ava');
+ await signIn(other,'bogdan');
+ await a.getByTestId('visited-change').click();
+ await a.getByTestId('visited-date').fill('2026-05-04');
+ await other.getByTestId('visited-change').click();
+ await other.getByTestId('visited-date').fill('2026-05-10');
+ await other.getByTestId('visited-save').click();
+ await expect(other.getByTestId('visited-state')).toHaveText('Visited 10 May 2026');
+ await expect(a.getByTestId('status-changed-by')).toHaveText('Last changed by Bogdan');
+ await expect(a.getByTestId('visited-date')).toHaveValue('2026-05-04');
+ await a.getByTestId('visited-save').click();
+ await expect(other.getByTestId('visited-state')).toHaveText('Visited 4 May 2026');
+ expect(await a.getByTestId('status-outcome').count()).toBe(0);
+ const doc = await (await fetch(`${base}/${paths[1]}`,{headers})).json();
+ console.log(JSON.stringify({probe:'stale-visited-draft',initialVersion:1,otherMemberSaved:'2026-05-10',staleDraftSaved:doc.fields.visitedOn.timestampValue,finalVersion:doc.fields.version.integerValue,conflictDisplayed:false}));
+} finally {
+ await browser.close();
+ for (const path of paths.toReversed()) await fetch(`${base}/${path}`, {method:'DELETE',headers});
+}
diff --git a/planning/audits/plan-4-review-probes/vitest.config.mts.txt b/planning/audits/plan-4-review-probes/vitest.config.mts.txt
new file mode 100644
index 0000000..263a99a
--- /dev/null
+++ b/planning/audits/plan-4-review-probes/vitest.config.mts.txt
@@ -0,0 +1,6 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+export default defineConfig({
+ root:'/home/godja/Dev/AvaGF/.claude/worktrees/pwa-04-collection/web', cacheDir:'/tmp/safebite-plan4-records-audit/cache', plugins:[react()], server:{fs:{allow:['/home/godja/Dev/AvaGF','/tmp/safebite-plan4-records-audit']}},
+ test:{environment:'jsdom',include:['/tmp/safebite-plan4-records-audit/*.test.tsx'],setupFiles:['/home/godja/Dev/AvaGF/.claude/worktrees/pwa-04-collection/web/src/test-setup.ts']}
+});
diff --git a/planning/plans/2026-09-24-safebite-pwa-04-collection.md b/planning/plans/2026-09-24-safebite-pwa-04-collection.md
new file mode 100644
index 0000000..51d1d4a
--- /dev/null
+++ b/planning/plans/2026-09-24-safebite-pwa-04-collection.md
@@ -0,0 +1,3945 @@
+# SafeBite PWA — Plan 4: Shortlist, visited state and notes
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add the household shortlist and visited state (`households/{hid}/collection/{rid}`), authored notes on restaurant records (`restaurants/{rid}/notes/{nid}`), a rules-enforced deletion completion gate that no client version can bypass, per-tab reset on any account change, and a change-password form. Everything is proven by rules, web unit and browser tests.
+
+**Architecture:** Same layout as Plans 1–3 (`web/`, `functions/`, root scripts, `firestore.rules`). Rules gain a top-level `callerName()`, a `collection/{rid}` match, a `notes/{nid}` match under restaurants and a `cleanupDone` completion gate on restaurant deletion. `web/src/records/repository.ts` remains the records module and owns the deletion protocol (now read-before-delete). It also exports its transaction and listener helpers to two new sibling modules, `collection.ts` (shortlist/visited) and `notes.ts`. Pure helpers (`combine.ts` for joined read states, `join.ts` for records × state) keep the pages thin. The restaurant page gains `StatusBlock.tsx` and `NotesSection.tsx`. `AuthProvider` resets its document through `resetDocument()` when a previously seen UID signs out or changes. `ChangePasswordForm.tsx` sits in Settings, backed by `auth/changePassword.ts`.
+
+**Tech Stack:** Vite 8.3, React 19, react-router 8, TypeScript 6 strict (web) / 5.9 (functions), Vitest 5, Playwright 1.63 (Chromium only), Firebase JS SDK 12, `@firebase/rules-unit-testing` 5, firebase-tools 15.30, Node 22.
+
+**Spec:** `planning/specs/2026-09-20-safebite-pwa-design.md`. **§3.7 (as amended at f8edfc9 after `planning/audits/2026-09-24-plan-4-design-review.md`) is the binding design for this plan.** It covers rulings 1–5, the data model, the deletion protocol and completion gate, the client, account switch, change password, read states, note confirmation and conflicts, tests, decisions, the deploy note and exclusions. Also binding: §2.1 non-negotiable rules (a note never grants a label or refreshes a checked date), §2.4 security model, §3.5 (read states, online-only transactional writes, deletion protocol this plan extends) and §3.2 guardrails. Previous plans: `planning/plans/2026-09-21-safebite-pwa-02b-records.md` (repository, pages, e2e helpers) and `planning/plans/2026-09-22-safebite-pwa-03-discovery.md`.
+
+## Global Constraints
+
+- Emulators only (`demo-safebite`). No `firebase deploy`, no `git push`, no billing or console changes, no access to the staging project `safebite-pilot-urfs3v`. The string `safebite-production-13ba1` must not appear in new files.
+- Working directory is the worktree `/home/godja/Dev/AvaGF/.claude/worktrees/pwa-04-collection` on branch `worktree-pwa-04-collection`. Never run anything in `/home/godja/Dev/AvaGF` itself.
+- Every Firestore write from `web/` goes through `runTransaction` via the repository's `write()` helper (online pre-check, typed `WriteOutcome`, never throws). No `setDoc`/`updateDoc`/`deleteDoc`/`writeBatch` in `web/src`. Never `window.confirm`/`alert`/`prompt` in `web/src`.
+- No numerical safety score. Notes and collection documents carry nothing a safety label could be derived from. No write touches a claim except the existing claim functions.
+- `LIMITS.note` = 2000 characters. The client password minimum is 8 characters (client rule only). Visit dates are UTC-midnight timestamps read as calendar days (`dates.ts`), never later than the device's local today on the client, `<= request.time + 1 day` in rules.
+- British spelling in UI copy. The testids of existing screens stay unchanged unless a step says otherwise.
+- The browser Firestore SDK keeps offline persistence off (memory cache). No new `localStorage`/`sessionStorage`/IndexedDB use in `web/src`.
+- Line endings: every file this plan touches is LF. Edit with tools that preserve endings.
+- Node 22; TS strict. Commit messages end with `Co-Authored-By: Claude Opus 5.5 (1M context) `. Implementers may see a different attribution reminder; use this line.
+- Emulator-backed runs (`npm run emu:test`, `npm run emu:e2e`) take one to three minutes here, so give those shell commands a 10 minute timeout.
+- Every task ends with `npm run typecheck` and `npm run test:unit` green. Add `npm run emu:test` when the task touched `functions/` or `firestore.rules`, and `npm run emu:e2e` when it touched `web/e2e`, rules or anything the browser suite exercises. State unit counts as "previous + N new". Do not assert absolute totals in commit messages. Baseline at `f8edfc9`: web unit 270, functions + rules 282, browser 29.
+
+---
+
+## File map
+
+| Path | Responsibility |
+|------|----------------|
+| `firestore.rules` | `callerName()`; restaurant `cleanupDone` + `isMarkingCleanupDone()` + delete gate; `collection/{rid}`; `restaurants/{rid}/notes/{nid}` |
+| `functions/test/rules.collection.test.ts` (new) | Rules tests for collection state and notes |
+| `functions/test/rules.records.test.ts` | Restaurant create/update/delete cases for `cleanupDone` and the gate |
+| `functions/test/rules.deletion.test.ts` (new) | Mixed-version and concurrent deletion protocol against the rules |
+| `web/src/records/types.ts` | `CollectionState`, `Note`, `NoteInput` |
+| `web/src/records/validation.ts` (+test) | `LIMITS.note`; `validateNoteText`; `validateVisitedOn` |
+| `web/src/records/rulesParity.test.ts` | `LIMITS.note` appears in the rules |
+| `web/src/test/memoryFirestore.ts` (new) | In-memory transaction/page fake shared by repository, collection and notes tests |
+| `web/src/records/repository.ts` (+test) | Exports helpers; `notesCol`; read-before-delete `sweep`; `sweepNotes`; `removeCollectionState`; `markCleanupDone`; `finishDeleting` extended; `DeleteStep` gains `sweepingNotes` |
+| `web/src/records/collection.ts` (+test, new) | `watchCollection`, `watchCollectionEntry`, `setShortlisted`, `setVisited` |
+| `web/src/records/notes.ts` (+test, new) | `watchNotes`, `addNote`, `updateNote`, `deleteNote` |
+| `web/src/records/combine.ts` (+test, new) | `isData`, `anyOffline`, `combineStates` |
+| `web/src/records/join.ts` (+test, new) | `joinRecords`, `filterRows` |
+| `web/src/records/messages.ts` (+test, new test) | `statusOutcomeMessage`, `deleteProgressText`, `finishOutcomeText` |
+| `web/src/records/RestaurantsPage.tsx` (+test) | Filter, labels, empty states, joined read state, resume wording |
+| `web/src/records/StatusBlock.tsx` (+test, new) | Shortlist and visited controls |
+| `web/src/records/NotesSection.tsx` (+test, new) | Notes list, composer, edit with conflict chooser, delete with confirmation identity |
+| `web/src/records/RestaurantDetailPage.tsx` (+test) | Wires StatusBlock and NotesSection; one offline notice for four listeners |
+| `web/src/records/RestaurantFormPage.tsx` (+test) | Deletion confirmation text; delete outcome uses the delete verb; progress text via `deleteProgressText` |
+| `web/src/auth/resetDocument.ts` (new) | `resetDocument()` → `window.location.replace("/")` |
+| `web/src/auth/AuthProvider.tsx` (+test) | `lastUid`; `resetting` state; reset on null or a different UID |
+| `web/src/App.tsx` | Renders the `resetting` state |
+| `web/src/auth/changePassword.ts` (+test, new) | `validateNewPassword`, `changePassword`, `CHANGE_PASSWORD_MESSAGES` |
+| `web/src/pages/ChangePasswordForm.tsx` (+test, new) | The Settings form |
+| `web/src/pages/SettingsPage.tsx` (+test) | Renders `ChangePasswordForm` |
+| `web/src/styles.css` | `.labels`, `.label`, `.filter`, `.note` |
+| `web/e2e/emulator-rest.ts` | `clearRecords` also removes notes and collection documents; `seedNote`, `seedCollection`, `listNoteIds`, `collectionExists`, `updateNoteViaRest`, `sweepClaimsViaRest` |
+| `web/e2e/auth-rest.ts` (new) | `setPasswordViaAdmin` for the Auth emulator |
+| `web/e2e/collection.spec.ts` (new) | Shortlist, visited, notes, deletion and conflict scenarios |
+| `web/e2e/records.spec.ts` | Two list assertions select *All records* (Task 4) |
+| `web/e2e/auth.spec.ts` | Cross-tab reset and change-password scenarios |
+| `web/playwright.config.ts` | `globalTimeout` 1,200 s; comment counts |
+| `README.md` | Web section: shortlist/notes, change password, test counts |
+
+---
+
+### Task 1: Rules — collection state and notes
+
+**Files:**
+- Modify: `firestore.rules`
+- Create: `functions/test/rules.collection.test.ts`
+- Modify: `web/src/records/validation.ts` (only `LIMITS`), `web/src/records/rulesParity.test.ts`
+
+**Interfaces:**
+- Consumes: existing rules functions `signedIn`, `isMember(hid)`, `nonBlankString(v, max)`, `utcMidnight(ts)`.
+- Produces: rules accepting exactly the documents later tasks write:
+ - `households/{hid}/collection/{rid}`: `{ shortlisted: bool, visited: bool, visitedOn?: timestamp, updatedBy: string, updatedByName: string, updatedAt: serverTimestamp, version: int }`
+ - `households/{hid}/restaurants/{rid}/notes/{nid}`: `{ text: string, authorUid, authorName, createdAt, updatedAt, version }`
+ - `LIMITS.note = 2000` exported from `web/src/records/validation.ts`.
+
+- [ ] **Step 1: Write the failing rules tests**
+
+Create `functions/test/rules.collection.test.ts`:
+
+```ts
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { afterAll, beforeAll, beforeEach, describe, it } from "vitest";
+import { assertFails, assertSucceeds, initializeTestEnvironment, type RulesTestEnvironment } from "@firebase/rules-unit-testing";
+import { collection, deleteDoc, doc, getDoc, getDocs, serverTimestamp, setDoc, Timestamp, updateDoc } from "firebase/firestore";
+
+const PROJECT_ID = "demo-safebite";
+const RULES_PATH = path.resolve(process.cwd(), "..", "firestore.rules");
+
+let env: RulesTestEnvironment;
+
+beforeAll(async () => {
+ env = await initializeTestEnvironment({
+ projectId: PROJECT_ID,
+ firestore: { rules: readFileSync(RULES_PATH, "utf8"), host: "127.0.0.1", port: 8080 },
+ });
+});
+
+beforeEach(async () => {
+ await env.clearFirestore();
+ await env.withSecurityRulesDisabled(async (ctx) => {
+ const db = ctx.firestore();
+ await setDoc(doc(db, "households/home"), { name: "Home", memberIds: ["ava", "bogdan"], createdAt: new Date() });
+ await setDoc(doc(db, "households/other"), { name: "Other", memberIds: ["stranger"], createdAt: new Date() });
+ await setDoc(doc(db, "users/ava"), { householdId: "home", displayName: "Ava" });
+ await setDoc(doc(db, "users/bogdan"), { householdId: "home", displayName: "Bogdan" });
+ await setDoc(doc(db, "users/stranger"), { householdId: "other", displayName: "Stranger" });
+ });
+});
+
+afterAll(async () => {
+ await env.cleanup();
+});
+
+const as = (uid: string) => env.authenticatedContext(uid).firestore();
+const R = "households/home/restaurants";
+const S = "households/home/collection";
+const utcDate = (d: string) => Timestamp.fromDate(new Date(`${d}T00:00:00.000Z`));
+const isoDay = (date: Date) => date.toISOString().slice(0, 10);
+const daysAhead = (n: number) => isoDay(new Date(Date.now() + n * 86_400_000));
+const NAMES: Record = { ava: "Ava", bogdan: "Bogdan", stranger: "Stranger" };
+
+async function seed(docPath: string, data: Record): Promise {
+ await env.withSecurityRulesDisabled(async (ctx) => {
+ await setDoc(doc(ctx.firestore(), docPath), data);
+ });
+}
+
+async function seedRestaurant(id: string, over: Record = {}): Promise {
+ await seed(`${R}/${id}`, {
+ name: "Seeded",
+ address: "Somewhere 1",
+ createdBy: "ava",
+ createdAt: Timestamp.fromDate(new Date("2026-09-01T10:00:00Z")),
+ updatedAt: Timestamp.fromDate(new Date("2026-09-01T10:00:00Z")),
+ version: 1,
+ deleting: false,
+ ...over,
+ });
+}
+
+/** A valid collection-state write as the client sends it (full document, server updatedAt). */
+function stateWrite(uid = "ava", over: Record = {}): Record {
+ const data: Record = {
+ shortlisted: true,
+ visited: false,
+ updatedBy: uid,
+ updatedByName: NAMES[uid],
+ updatedAt: serverTimestamp(),
+ version: 1,
+ ...over,
+ };
+ for (const key of Object.keys(data)) if (data[key] === undefined) delete data[key];
+ return data;
+}
+
+async function seedState(rid: string, over: Record = {}): Promise {
+ await seed(`${S}/${rid}`, { shortlisted: true, visited: false, updatedBy: "ava", updatedByName: "Ava", updatedAt: new Date(), version: 1, ...over });
+}
+
+describe("collection — reads", () => {
+ it("members read and list; non-members and anonymous do not", async () => {
+ await seedRestaurant("r1");
+ await seedState("r1");
+ await assertSucceeds(getDoc(doc(as("ava"), `${S}/r1`)));
+ await assertSucceeds(getDocs(collection(as("bogdan"), S)));
+ await assertFails(getDoc(doc(as("stranger"), `${S}/r1`)));
+ await assertFails(getDocs(collection(as("stranger"), S)));
+ await assertFails(getDoc(doc(env.unauthenticatedContext().firestore(), `${S}/r1`)));
+ });
+});
+
+describe("collection — create", () => {
+ beforeEach(async () => {
+ await seedRestaurant("r1");
+ });
+
+ it.each([
+ ["shortlisted only", {}],
+ ["visited with a date", { shortlisted: false, visited: true, visitedOn: utcDate("2026-05-03") }],
+ ["visited one day ahead of UTC", { visited: true, visitedOn: utcDate(daysAhead(1)) }],
+ ["neither flag", { shortlisted: false }],
+ ])("accepts %s", async (_label, over) => {
+ await assertSucceeds(setDoc(doc(as("ava"), `${S}/r1`), stateWrite("ava", over)));
+ });
+
+ it.each([
+ ["version is not 1", { version: 2 }],
+ ["updatedBy is someone else", { updatedBy: "bogdan" }],
+ ["updatedByName is not the caller's display name", { updatedByName: "Bogdan" }],
+ ["client-supplied updatedAt", { updatedAt: new Date() }],
+ ["visited without visitedOn", { visited: true }],
+ ["visitedOn without visited", { visitedOn: utcDate("2026-05-03") }],
+ ["visitedOn not at UTC midnight", { visited: true, visitedOn: Timestamp.fromDate(new Date("2026-05-03T10:00:00Z")) }],
+ // three, not two: see rules.records.test.ts on request.time near midnight
+ ["visitedOn three days ahead", { visited: true, visitedOn: utcDate(daysAhead(3)) }],
+ ["shortlisted is a string", { shortlisted: "yes" }],
+ ["an unknown key", { rating: 5 }],
+ ["a legacy savedBy key", { savedBy: "ava" }],
+ ["missing shortlisted", { shortlisted: undefined }],
+ ])("rejects a create where %s", async (_label, over) => {
+ await assertFails(setDoc(doc(as("ava"), `${S}/r1`), stateWrite("ava", over)));
+ });
+
+ it("rejects a create for a missing restaurant or one marked deleting", async () => {
+ await assertFails(setDoc(doc(as("ava"), `${S}/ghost`), stateWrite("ava")));
+ await seedRestaurant("r2", { deleting: true });
+ await assertFails(setDoc(doc(as("ava"), `${S}/r2`), stateWrite("ava")));
+ });
+
+ it("rejects a create by a non-member or anonymous client", async () => {
+ await assertFails(setDoc(doc(as("stranger"), `${S}/r1`), stateWrite("stranger")));
+ await assertFails(setDoc(doc(env.unauthenticatedContext().firestore(), `${S}/r1`), stateWrite("ava")));
+ });
+});
+
+describe("collection — update and delete", () => {
+ beforeEach(async () => {
+ await seedRestaurant("r1");
+ await seedState("r1", { version: 3 });
+ });
+
+ it("either member writes the next version with their own identity", async () => {
+ await assertSucceeds(setDoc(doc(as("bogdan"), `${S}/r1`), stateWrite("bogdan", { shortlisted: false, visited: true, visitedOn: utcDate("2026-05-03"), version: 4 })));
+ });
+
+ it.each([
+ ["the version is stale", { version: 3 }],
+ ["the version skips ahead", { version: 5 }],
+ ["updatedByName is spoofed", { version: 4, updatedByName: "Ava" }],
+ ])("rejects an update where %s", async (_label, over) => {
+ await assertFails(setDoc(doc(as("bogdan"), `${S}/r1`), stateWrite("bogdan", over)));
+ });
+
+ it("rejects updates once the restaurant is marked deleting", async () => {
+ await seedRestaurant("r1", { deleting: true });
+ await assertFails(setDoc(doc(as("ava"), `${S}/r1`), stateWrite("ava", { version: 4 })));
+ });
+
+ it("delete is refused while the restaurant is live and allowed once it is marked deleting", async () => {
+ await assertFails(deleteDoc(doc(as("ava"), `${S}/r1`)));
+ await seedRestaurant("r1", { deleting: true });
+ await assertFails(deleteDoc(doc(as("stranger"), `${S}/r1`)));
+ await assertSucceeds(deleteDoc(doc(as("bogdan"), `${S}/r1`)));
+ });
+});
+
+const N = `${R}/r1/notes`;
+
+/** A valid note create as the client sends it. */
+function noteCreate(uid = "ava", over: Record = {}): Record {
+ const data: Record = {
+ text: "Staff knew exactly what coeliac means.",
+ authorUid: uid,
+ authorName: NAMES[uid],
+ createdAt: serverTimestamp(),
+ updatedAt: serverTimestamp(),
+ version: 1,
+ ...over,
+ };
+ for (const key of Object.keys(data)) if (data[key] === undefined) delete data[key];
+ return data;
+}
+
+async function seedNote(id: string, uid = "ava", over: Record = {}): Promise {
+ await seed(`${N}/${id}`, { ...noteCreate(uid), createdAt: new Date("2026-09-01T10:00:00Z"), updatedAt: new Date("2026-09-01T10:00:00Z"), version: 2, ...over });
+}
+
+describe("notes — reads", () => {
+ it("members read and list; non-members and anonymous do not", async () => {
+ await seedRestaurant("r1");
+ await seedNote("n1");
+ await assertSucceeds(getDoc(doc(as("bogdan"), `${N}/n1`)));
+ await assertSucceeds(getDocs(collection(as("ava"), N)));
+ await assertFails(getDoc(doc(as("stranger"), `${N}/n1`)));
+ await assertFails(getDoc(doc(env.unauthenticatedContext().firestore(), `${N}/n1`)));
+ });
+});
+
+describe("notes — create", () => {
+ beforeEach(async () => {
+ await seedRestaurant("r1");
+ });
+
+ it("accepts a note from either member, including exactly 2,000 characters", async () => {
+ await assertSucceeds(setDoc(doc(as("ava"), `${N}/a`), noteCreate("ava")));
+ await assertSucceeds(setDoc(doc(as("bogdan"), `${N}/b`), noteCreate("bogdan", { text: "x".repeat(2000) })));
+ });
+
+ it.each([
+ ["text of 2,001 characters", { text: "x".repeat(2001) }],
+ ["whitespace-only text", { text: " " }],
+ ["text not a string", { text: 42 }],
+ ["authorUid is someone else", { authorUid: "bogdan" }],
+ ["authorName is not the caller's display name", { authorName: "Bogdan" }],
+ ["client-supplied createdAt", { createdAt: new Date() }],
+ ["client-supplied updatedAt", { updatedAt: new Date() }],
+ ["version is not 1", { version: 2 }],
+ ["an unknown key", { verified: true }],
+ ["missing text", { text: undefined }],
+ ])("rejects a create where %s", async (_label, over) => {
+ await assertFails(setDoc(doc(as("ava"), `${N}/bad`), noteCreate("ava", over)));
+ });
+
+ it("rejects a create under a missing parent or a parent marked deleting", async () => {
+ await assertFails(setDoc(doc(as("ava"), `${R}/ghost/notes/bad`), noteCreate("ava")));
+ await seedRestaurant("r2", { deleting: true });
+ await assertFails(setDoc(doc(as("ava"), `${R}/r2/notes/bad`), noteCreate("ava")));
+ });
+
+ it("rejects a create by a non-member or anonymous client", async () => {
+ await assertFails(setDoc(doc(as("stranger"), `${N}/bad`), noteCreate("stranger")));
+ await assertFails(setDoc(doc(env.unauthenticatedContext().firestore(), `${N}/bad`), noteCreate("ava")));
+ });
+});
+
+describe("notes — update", () => {
+ beforeEach(async () => {
+ await seedRestaurant("r1");
+ await seedNote("n1", "ava");
+ });
+
+ it("the author edits the text with the next version and a server updatedAt", async () => {
+ await assertSucceeds(updateDoc(doc(as("ava"), `${N}/n1`), { text: "Edited", version: 3, updatedAt: serverTimestamp() }));
+ });
+
+ it.each([
+ ["the other member edits", "bogdan", { text: "Edited", version: 3, updatedAt: serverTimestamp() }],
+ ["the version is stale", "ava", { text: "Edited", version: 2, updatedAt: serverTimestamp() }],
+ ["the version skips ahead", "ava", { text: "Edited", version: 4, updatedAt: serverTimestamp() }],
+ ["updatedAt is client-supplied", "ava", { text: "Edited", version: 3, updatedAt: new Date() }],
+ ["authorUid changes", "ava", { authorUid: "bogdan", version: 3, updatedAt: serverTimestamp() }],
+ ["authorName changes", "ava", { authorName: "Bogdan", version: 3, updatedAt: serverTimestamp() }],
+ ["createdAt changes", "ava", { createdAt: new Date(), version: 3, updatedAt: serverTimestamp() }],
+ ["text becomes too long", "ava", { text: "x".repeat(2001), version: 3, updatedAt: serverTimestamp() }],
+ ])("rejects an update where %s", async (_label, uid, patch) => {
+ await assertFails(updateDoc(doc(as(uid), `${N}/n1`), patch));
+ });
+
+ it("rejects an author edit while the restaurant is marked deleting", async () => {
+ await seedRestaurant("r1", { deleting: true });
+ await assertFails(updateDoc(doc(as("ava"), `${N}/n1`), { text: "Edited", version: 3, updatedAt: serverTimestamp() }));
+ });
+});
+
+describe("notes — delete", () => {
+ beforeEach(async () => {
+ await seedRestaurant("r1");
+ await seedNote("n1", "ava");
+ });
+
+ it("the author deletes; the other member and non-members may not while the restaurant is live", async () => {
+ await assertFails(deleteDoc(doc(as("bogdan"), `${N}/n1`)));
+ await assertFails(deleteDoc(doc(as("stranger"), `${N}/n1`)));
+ await assertSucceeds(deleteDoc(doc(as("ava"), `${N}/n1`)));
+ });
+
+ it("once the restaurant is marked deleting any member may delete (the sweep), never a non-member", async () => {
+ await seedRestaurant("r1", { deleting: true });
+ await assertFails(deleteDoc(doc(as("stranger"), `${N}/n1`)));
+ await assertSucceeds(deleteDoc(doc(as("bogdan"), `${N}/n1`)));
+ });
+
+ it("the author may still delete while the restaurant is marked deleting", async () => {
+ await seedRestaurant("r1", { deleting: true });
+ await assertSucceeds(deleteDoc(doc(as("ava"), `${N}/n1`)));
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify the new tests fail**
+
+Run: `npm run emu:test` (10 min timeout)
+Expected: the new `rules.collection.test.ts` cases that expect `assertSucceeds` FAIL (default deny); the existing 282 still pass.
+
+- [ ] **Step 3: Add the rules**
+
+In `firestore.rules`, directly after the `isMember(hid)` function, add:
+
+```
+ // The caller's own users/{uid} document. Rules get() is not subject to the read rules, so no
+ // peer-user read is introduced. Used wherever a stored display name must be the writer's own.
+ function callerName() {
+ return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.displayName;
+ }
+```
+
+Inside `match /restaurants/{rid}`, after the closing brace of `match /claims/{cid}`, add:
+
+```
+ // Authored notes (spec §3.7). Only the author edits; the author deletes at any time, and
+ // any member may delete once the restaurant is marked deleting (deletion sweep).
+ match /notes/{nid} {
+ function noteParent() {
+ return get(/databases/$(database)/documents/households/$(hid)/restaurants/$(rid));
+ }
+
+ allow read: if isMember(hid);
+
+ allow create: if isMember(hid)
+ && exists(/databases/$(database)/documents/households/$(hid)/restaurants/$(rid))
+ && noteParent().data.deleting == false
+ && request.resource.data.keys().hasOnly(['text', 'authorUid', 'authorName', 'createdAt', 'updatedAt', 'version'])
+ && request.resource.data.keys().hasAll(['text', 'authorUid', 'authorName', 'createdAt', 'updatedAt', 'version'])
+ && nonBlankString(request.resource.data.text, 2000)
+ && request.resource.data.authorUid == request.auth.uid
+ && request.resource.data.authorName == callerName()
+ && request.resource.data.createdAt == request.time
+ && request.resource.data.updatedAt == request.time
+ && request.resource.data.version == 1;
+
+ allow update: if isMember(hid)
+ && resource.data.authorUid == request.auth.uid
+ && noteParent().data.deleting == false
+ && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['text', 'updatedAt', 'version'])
+ && nonBlankString(request.resource.data.text, 2000)
+ && request.resource.data.updatedAt == request.time
+ && request.resource.data.version == resource.data.version + 1;
+
+ // Author first: `||` short-circuits, so an author can delete even if the parent is gone.
+ allow delete: if isMember(hid)
+ && (resource.data.authorUid == request.auth.uid || noteParent().data.deleting == true);
+ }
+```
+
+Inside `match /households/{hid}`, after the closing brace of `match /restaurants/{rid}`, add:
+
+```
+ // Shortlist and visited state, one document per restaurant, id = restaurant id (spec §3.7).
+ match /collection/{rid} {
+ function stateParentPath() {
+ return /databases/$(database)/documents/households/$(hid)/restaurants/$(rid);
+ }
+ function liveParent() {
+ return exists(stateParentPath()) && get(stateParentPath()).data.deleting == false;
+ }
+ function validState(data) {
+ return data.keys().hasOnly(['shortlisted', 'visited', 'visitedOn', 'updatedBy', 'updatedByName', 'updatedAt', 'version'])
+ && data.keys().hasAll(['shortlisted', 'visited', 'updatedBy', 'updatedByName', 'updatedAt', 'version'])
+ && data.shortlisted is bool
+ && data.visited is bool
+ && data.visited == ('visitedOn' in data)
+ && (!('visitedOn' in data)
+ || (utcMidnight(data.visitedOn) && data.visitedOn <= request.time + duration.value(1, 'd')))
+ && data.updatedBy == request.auth.uid
+ && data.updatedByName == callerName()
+ && data.updatedAt == request.time
+ && data.version is int;
+ }
+
+ allow read: if isMember(hid);
+ allow create: if isMember(hid) && liveParent() && validState(request.resource.data)
+ && request.resource.data.version == 1;
+ allow update: if isMember(hid) && liveParent() && validState(request.resource.data)
+ && request.resource.data.version == resource.data.version + 1;
+ // Deletion sweep only: the restaurant must exist and be marked deleting.
+ allow delete: if isMember(hid) && exists(stateParentPath()) && get(stateParentPath()).data.deleting == true;
+ }
+```
+
+Check the brace nesting: `collection` sits beside `restaurants` inside `households/{hid}`; `notes` sits beside `claims` inside `restaurants/{rid}`.
+
+- [ ] **Step 4: Add `LIMITS.note` and its parity check**
+
+In `web/src/records/validation.ts` change the `LIMITS` line to:
+
+```ts
+export const LIMITS = { name: 120, address: 300, phone: 40, website: 300, detail: 1000, sourceLabel: 200, sourceUrl: 500, googlePlaceId: 200, note: 2000 } as const;
+```
+
+In `web/src/records/rulesParity.test.ts`, at the end of the `"carries the same field limits as validation.ts"` test body, add:
+
+```ts
+ expect(rules).toContain(`nonBlankString(request.resource.data.text, ${LIMITS.note})`);
+```
+
+- [ ] **Step 5: Run everything**
+
+Run: `npm run emu:test` → all pass (282 + the new file's cases).
+Run: `npm run typecheck && npm run test:unit` → pass (270; the parity test gains an assertion, not a test).
+Run: `npm run emu:e2e` → 29 pass (rules only grew).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add firestore.rules functions/test/rules.collection.test.ts web/src/records/validation.ts web/src/records/rulesParity.test.ts
+git commit -m "feat(rules): shortlist/visited state and authored notes
+
+Co-Authored-By: Claude Opus 5.5 (1M context) "
+```
+
+---
+### Task 2: Deletion completion gate — rules, read-before-delete protocol, mixed-version proof
+
+**Files:**
+- Modify: `firestore.rules` (restaurant match)
+- Modify: `functions/test/rules.records.test.ts`
+- Create: `functions/test/rules.deletion.test.ts`
+- Create: `web/src/test/memoryFirestore.ts`
+- Modify: `web/src/records/repository.ts`, `web/src/records/repository.test.ts`
+- Modify: `web/src/records/messages.ts`; create `web/src/records/messages.test.ts`
+- Modify: `web/src/records/RestaurantsPage.tsx`, `web/src/records/RestaurantFormPage.tsx` (progress text only)
+
+**Interfaces:**
+- Consumes: Task 1 rules (`collection/{rid}` delete while parent `deleting`; notes delete by any member while parent `deleting`).
+- Produces (all in `web/src/records/repository.ts`, used by Tasks 3–6):
+ - `export type DeleteStep = "marking" | "sweeping" | "sweepingNotes" | "removing"`
+ - `export class ConflictError extends Error {}`, `export class NotFoundError extends Error {}`
+ - `export function classify(err: unknown): WriteOutcome`
+ - `export async function write(run: (tx: Transaction) => Promise): Promise>`
+ - `export const LISTEN`, `export function listenerFailure(err: FirestoreError): Snapshot`, `export function toDate(value: unknown): Date`
+ - `export const restaurantRef(hid, rid)`, `export const notesCol(hid, rid)`, `export const collectionRef(hid, rid)`
+ - `export function sweepNotes(hid, rid): Promise>`, `export function removeCollectionState(hid, rid): Promise`, `export function markCleanupDone(hid, rid): Promise`
+ - `finishDeleting(hid, rid, onProgress?)` runs `sweeping → sweepingNotes → removing` (claims, notes, state document, `cleanupDone`, restaurant).
+ - `messages.ts`: `export function deleteProgressText(step: DeleteStep): string`
+ - `web/src/test/memoryFirestore.ts`: `type Store`, `memoryTransactions(runTransaction, store)`, `memoryPage(store, collectionPath)`
+
+- [ ] **Step 1: Write the failing rules tests for the gate**
+
+In `functions/test/rules.records.test.ts`:
+
+(a) In the `"rejects a create where %s"` table of `describe("restaurants — create")`, add the row:
+
+```ts
+ ["cleanupDone is set on create", { cleanupDone: false }],
+```
+
+(b) Replace the test `"accepts deleting a marked restaurant by either member, never by a non-member"` with:
+
+```ts
+ it("accepts deleting a marked, cleaned-up restaurant by either member, never by a non-member", async () => {
+ const p = await seedRestaurant("r1", { deleting: true, cleanupDone: true });
+ await assertFails(deleteDoc(doc(as("stranger"), p)));
+ await assertSucceeds(deleteDoc(doc(as("bogdan"), p)));
+ });
+```
+
+(c) Append a new describe block at the end of the restaurant section (before `const utcDate = …`):
+
+```ts
+describe("restaurants — deletion completion gate (spec §3.7, audit F1)", () => {
+ it("rejects adding cleanupDone through an ordinary update or together with the deleting mark", async () => {
+ const p = await seedRestaurant("r1");
+ await assertFails(updateDoc(doc(as("ava"), p), { name: "x", cleanupDone: true, version: 4, updatedAt: serverTimestamp() }));
+ await assertFails(updateDoc(doc(as("ava"), p), { deleting: true, cleanupDone: true, version: 4, updatedAt: serverTimestamp() }));
+ });
+
+ it("accepts marking cleanup done on a restaurant marked deleting (only cleanupDone, version, updatedAt)", async () => {
+ const p = await seedRestaurant("r1", { deleting: true, version: 4 });
+ await assertSucceeds(updateDoc(doc(as("bogdan"), p), { cleanupDone: true, version: 5, updatedAt: serverTimestamp() }));
+ });
+
+ it.each([
+ ["the restaurant is live", { deleting: false, version: 4 }, { cleanupDone: true, version: 5, updatedAt: serverTimestamp() }],
+ ["the version is stale", { deleting: true, version: 4 }, { cleanupDone: true, version: 4, updatedAt: serverTimestamp() }],
+ ["cleanupDone is false", { deleting: true, version: 4 }, { cleanupDone: false, version: 5, updatedAt: serverTimestamp() }],
+ ["another field changes too", { deleting: true, version: 4 }, { cleanupDone: true, name: "x", version: 5, updatedAt: serverTimestamp() }],
+ ["updatedAt is client-supplied", { deleting: true, version: 4 }, { cleanupDone: true, version: 5, updatedAt: new Date() }],
+ ["it is already done", { deleting: true, cleanupDone: true, version: 4 }, { cleanupDone: true, version: 5, updatedAt: serverTimestamp() }],
+ ])("rejects marking cleanup done when %s", async (_label, seeded, patch) => {
+ const p = await seedRestaurant("r1", seeded);
+ await assertFails(updateDoc(doc(as("ava"), p), patch));
+ });
+
+ it("refuses the final delete until cleanupDone is set and the collection document is gone", async () => {
+ const p = await seedRestaurant("r1", { deleting: true });
+ await assertFails(deleteDoc(doc(as("ava"), p))); // a Plan 3-era finisher stops here
+ await seedRestaurant("r1", { deleting: true, cleanupDone: true });
+ await env.withSecurityRulesDisabled(async (ctx) => {
+ await setDoc(doc(ctx.firestore(), "households/home/collection/r1"), { shortlisted: true, visited: false, updatedBy: "ava", updatedByName: "Ava", updatedAt: new Date(), version: 1 });
+ });
+ await assertFails(deleteDoc(doc(as("ava"), p)));
+ await env.withSecurityRulesDisabled(async (ctx) => {
+ await deleteDoc(doc(ctx.firestore(), "households/home/collection/r1"));
+ });
+ await assertSucceeds(deleteDoc(doc(as("ava"), p)));
+ });
+});
+```
+
+- [ ] **Step 2: Write the failing mixed-version protocol tests**
+
+Create `functions/test/rules.deletion.test.ts`:
+
+```ts
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { assertFails, initializeTestEnvironment, type RulesTestEnvironment } from "@firebase/rules-unit-testing";
+import { collection, doc, getDoc, getDocs, limit, query, runTransaction, serverTimestamp, setDoc, Timestamp } from "firebase/firestore";
+
+/**
+ * The deletion protocol against the real rules with mixed client versions (spec §3.7, audit F1).
+ * `oldFinish` is the merged Plan 3 client's finishDeleting (blind claim sweep, blind restaurant
+ * delete). `newFinish` mirrors web/src/records/repository.ts finishDeleting after Plan 4 (every
+ * step reads before it deletes). Keep newFinish in step with the repository.
+ */
+const PROJECT_ID = "demo-safebite";
+const RULES_PATH = path.resolve(process.cwd(), "..", "firestore.rules");
+const R = "households/home/restaurants";
+const S = "households/home/collection";
+
+let env: RulesTestEnvironment;
+
+beforeAll(async () => {
+ env = await initializeTestEnvironment({
+ projectId: PROJECT_ID,
+ firestore: { rules: readFileSync(RULES_PATH, "utf8"), host: "127.0.0.1", port: 8080 },
+ });
+});
+
+beforeEach(async () => {
+ await env.clearFirestore();
+ await env.withSecurityRulesDisabled(async (ctx) => {
+ const db = ctx.firestore();
+ await setDoc(doc(db, "households/home"), { name: "Home", memberIds: ["ava", "bogdan"], createdAt: new Date() });
+ await setDoc(doc(db, "users/ava"), { householdId: "home", displayName: "Ava" });
+ await setDoc(doc(db, "users/bogdan"), { householdId: "home", displayName: "Bogdan" });
+ });
+});
+
+afterAll(async () => {
+ await env.cleanup();
+});
+
+const as = (uid: string) => env.authenticatedContext(uid).firestore();
+type Db = ReturnType;
+
+/** A restaurant a member has marked deleting, still holding a claim, both members' notes and state. */
+async function seedDoomed(rid: string): Promise {
+ await env.withSecurityRulesDisabled(async (ctx) => {
+ const db = ctx.firestore();
+ const at = Timestamp.fromDate(new Date("2026-09-01T10:00:00Z"));
+ await setDoc(doc(db, `${R}/${rid}`), { name: "Doomed", address: "1 Road", createdBy: "ava", createdAt: at, updatedAt: at, version: 2, deleting: true });
+ await setDoc(doc(db, `${R}/${rid}/claims/c1`), { kind: "gfMenu", value: "yes", detail: "", source: { type: "ownVisit", label: "x" }, checkedAt: Timestamp.fromDate(new Date("2026-09-01T00:00:00Z")), authorUid: "ava", authorName: "Ava", createdAt: at });
+ await setDoc(doc(db, `${R}/${rid}/notes/n-ava`), { text: "Ava's note", authorUid: "ava", authorName: "Ava", createdAt: at, updatedAt: at, version: 1 });
+ await setDoc(doc(db, `${R}/${rid}/notes/n-bogdan`), { text: "Bogdan's note", authorUid: "bogdan", authorName: "Bogdan", createdAt: at, updatedAt: at, version: 1 });
+ await setDoc(doc(db, `${S}/${rid}`), { shortlisted: true, visited: false, updatedBy: "ava", updatedByName: "Ava", updatedAt: at, version: 1 });
+ });
+}
+
+interface Remaining { restaurant: boolean; claims: number; notes: number; state: boolean }
+
+async function remaining(rid: string): Promise {
+ let out: Remaining = { restaurant: false, claims: 0, notes: 0, state: false };
+ await env.withSecurityRulesDisabled(async (ctx) => {
+ const db = ctx.firestore();
+ out = {
+ restaurant: (await getDoc(doc(db, `${R}/${rid}`))).exists(),
+ claims: (await getDocs(collection(db, `${R}/${rid}/claims`))).size,
+ notes: (await getDocs(collection(db, `${R}/${rid}/notes`))).size,
+ state: (await getDoc(doc(db, `${S}/${rid}`))).exists(),
+ };
+ });
+ return out;
+}
+
+const GONE: Remaining = { restaurant: false, claims: 0, notes: 0, state: false };
+
+async function oldFinish(db: Db, rid: string): Promise {
+ const claims = await getDocs(query(collection(db, `${R}/${rid}/claims`), limit(100)));
+ await runTransaction(db, async (tx) => {
+ for (const c of claims.docs) tx.delete(c.ref);
+ });
+ await runTransaction(db, async (tx) => {
+ tx.delete(doc(db, `${R}/${rid}`));
+ });
+}
+
+async function sweepSub(db: Db, rid: string, sub: "claims" | "notes"): Promise {
+ for (;;) {
+ const page = await getDocs(query(collection(db, `${R}/${rid}/${sub}`), limit(100)));
+ if (page.empty) return;
+ await runTransaction(db, async (tx) => {
+ const snaps = await Promise.all(page.docs.map((d) => tx.get(d.ref)));
+ for (const s of snaps) if (s.exists()) tx.delete(s.ref);
+ });
+ }
+}
+
+const NEW_STEPS: Array<(db: Db, rid: string) => Promise> = [
+ (db, rid) => sweepSub(db, rid, "claims"),
+ (db, rid) => sweepSub(db, rid, "notes"),
+ (db, rid) =>
+ runTransaction(db, async (tx) => {
+ const s = await tx.get(doc(db, `${S}/${rid}`));
+ if (s.exists()) tx.delete(s.ref);
+ }),
+ (db, rid) =>
+ runTransaction(db, async (tx) => {
+ const r = await tx.get(doc(db, `${R}/${rid}`));
+ if (!r.exists() || r.get("cleanupDone") === true) return;
+ tx.update(r.ref, { cleanupDone: true, version: (r.get("version") as number) + 1, updatedAt: serverTimestamp() });
+ }),
+ (db, rid) =>
+ runTransaction(db, async (tx) => {
+ const r = await tx.get(doc(db, `${R}/${rid}`));
+ if (r.exists()) tx.delete(r.ref);
+ }),
+];
+
+async function newFinish(db: Db, rid: string, stepsToRun = NEW_STEPS.length): Promise {
+ for (const step of NEW_STEPS.slice(0, stepsToRun)) await step(db, rid);
+}
+
+describe("deletion protocol with mixed client versions", () => {
+ it("a Plan 3-era finisher is refused at the final delete and leaves a resumable parent; a Plan 4 finisher completes it", async () => {
+ await seedDoomed("r1");
+ await assertFails(oldFinish(as("bogdan"), "r1"));
+ expect(await remaining("r1")).toEqual({ restaurant: true, claims: 0, notes: 2, state: true });
+ await newFinish(as("ava"), "r1");
+ expect(await remaining("r1")).toEqual(GONE);
+ });
+
+ it("an old resumer racing a new deleter never leaves notes or state without their restaurant", async () => {
+ await seedDoomed("r1");
+ await Promise.allSettled([oldFinish(as("bogdan"), "r1"), newFinish(as("ava"), "r1")]);
+ const after = await remaining("r1");
+ if (!after.restaurant) expect(after).toEqual(GONE);
+ await newFinish(as("ava"), "r1");
+ expect(await remaining("r1")).toEqual(GONE);
+ });
+
+ it("two Plan 4 finishers at once both succeed", async () => {
+ await seedDoomed("r1");
+ await Promise.all([newFinish(as("ava"), "r1"), newFinish(as("bogdan"), "r1")]);
+ expect(await remaining("r1")).toEqual(GONE);
+ });
+
+ it.each([1, 2, 3, 4])("a retry after %i completed step(s) finishes without a permission failure", async (done) => {
+ await seedDoomed("r1");
+ await newFinish(as("ava"), "r1", done);
+ await newFinish(as("bogdan"), "r1");
+ expect(await remaining("r1")).toEqual(GONE);
+ });
+
+ it("a finisher running after everything is already gone is a no-op, not a failure", async () => {
+ await seedDoomed("r1");
+ await newFinish(as("ava"), "r1");
+ await newFinish(as("bogdan"), "r1");
+ expect(await remaining("r1")).toEqual(GONE);
+ });
+});
+```
+
+- [ ] **Step 3: Run to verify they fail**
+
+Run: `npm run emu:test`
+Expected: FAIL. The existing delete test now seeds `cleanupDone` (an unknown key for `validRestaurant`) but the old delete rule ignores it; the mark-done cases fail (no branch). The mixed-version "refused" case FAILS because the old rule lets `oldFinish` delete the restaurant.
+
+- [ ] **Step 4: Implement the gate in the rules**
+
+In `firestore.rules`:
+
+(a) In `validRestaurant(data)`, add `'cleanupDone'` to the `hasOnly` list and append a type check, so the function reads:
+
+```
+ function validRestaurant(data) {
+ return data.keys().hasOnly(['name', 'address', 'phone', 'website', 'lat', 'lng', 'googlePlaceId', 'createdBy', 'createdAt', 'updatedAt', 'version', 'deleting', 'cleanupDone'])
+ && data.keys().hasAll(['name', 'address', 'createdBy', 'createdAt', 'updatedAt', 'version', 'deleting'])
+ && nonBlankString(data.name, 120)
+ && nonBlankString(data.address, 300)
+ && optionalString(data, 'phone', 40)
+ && optionalHttpUrl(data, 'website', 300)
+ && optionalString(data, 'googlePlaceId', 200)
+ && validCoordinates(data)
+ && data.createdBy is string
+ && data.createdAt is timestamp
+ && data.updatedAt is timestamp
+ && data.version is int
+ && data.deleting is bool
+ && (!('cleanupDone' in data) || data.cleanupDone is bool);
+ }
+```
+
+(b) After `isMarkingDeleting()`, add:
+
+```
+ // Deletion protocol, completion gate (spec §3.7, audit F1): set only after the client's claim
+ // and note sweeps returned empty from the server and the collection document is gone. Nothing
+ // can be created under a marked restaurant, so the flag cannot go stale.
+ function isMarkingCleanupDone() {
+ return resource.data.deleting == true
+ && resource.data.get('cleanupDone', false) == false
+ && request.resource.data.get('cleanupDone', false) == true
+ && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['cleanupDone', 'version', 'updatedAt']);
+ }
+```
+
+(c) In `match /restaurants/{rid}`, replace the `allow create`, `allow update` and `allow delete` rules with:
+
+```
+ allow create: if isMember(hid)
+ && validRestaurant(request.resource.data)
+ && !('cleanupDone' in request.resource.data)
+ && request.resource.data.createdBy == request.auth.uid
+ && request.resource.data.version == 1
+ && request.resource.data.deleting == false
+ && request.resource.data.createdAt == request.time
+ && request.resource.data.updatedAt == request.time;
+
+ // Optimistic concurrency: exactly the next version, server-stamped. Once deleting is true
+ // the only permitted change is marking cleanup done (so a claim sweep cannot be undercut).
+ allow update: if isMember(hid)
+ && validRestaurant(request.resource.data)
+ && request.resource.data.createdBy == resource.data.createdBy
+ && request.resource.data.createdAt == resource.data.createdAt
+ && request.resource.data.updatedAt == request.time
+ && request.resource.data.version == resource.data.version + 1
+ && ((resource.data.deleting == false
+ && !('cleanupDone' in request.resource.data)
+ && (request.resource.data.deleting == false || isMarkingDeleting()))
+ || isMarkingCleanupDone());
+
+ // Final step: only a marked restaurant whose cleanup is done and whose collection
+ // document is gone. A client that skips the note/state sweep cannot pass this.
+ allow delete: if isMember(hid)
+ && resource.data.deleting == true
+ && resource.data.get('cleanupDone', false) == true
+ && !exists(/databases/$(database)/documents/households/$(hid)/collection/$(rid));
+```
+
+- [ ] **Step 5: Run the rules tests**
+
+Run: `npm run emu:test`
+Expected: PASS. All earlier tests pass, plus the new gate cases and the mixed-version file (8 cases).
+
+- [ ] **Step 6: Add the shared in-memory Firestore fake**
+
+Create `web/src/test/memoryFirestore.ts`:
+
+```ts
+import { vi, type Mock } from "vitest";
+
+/** Document path → data. Paths look like "households/home/restaurants/r1". */
+export type Store = Map>;
+
+interface Ref {
+ id: string;
+ path: string;
+}
+
+function snapshot(store: Store, ref: Ref) {
+ const data = store.get(ref.path);
+ return { id: ref.id, ref, exists: () => data !== undefined, data: () => (data === undefined ? undefined : { ...data }) };
+}
+
+/**
+ * Wires a mocked `runTransaction` to an in-memory store, so a sequence of repository calls sees
+ * its own earlier writes. Test-only; paths come from the `doc`/`collection` mocks each test file
+ * installs (they join segments with "/").
+ */
+export function memoryTransactions(runTransaction: Mock, store: Store) {
+ const tx = {
+ get: vi.fn(async (ref: Ref) => snapshot(store, ref)),
+ set: vi.fn((ref: Ref, data: Record) => {
+ store.set(ref.path, { ...data });
+ }),
+ update: vi.fn((ref: Ref, patch: Record) => {
+ store.set(ref.path, { ...store.get(ref.path), ...patch });
+ }),
+ delete: vi.fn((ref: Ref) => {
+ store.delete(ref.path);
+ }),
+ };
+ runTransaction.mockImplementation(async (_db: unknown, run: (t: typeof tx) => Promise) => run(tx));
+ return tx;
+}
+
+/** The documents directly under `collectionPath`, shaped like a getDocsFromServer page. */
+export function memoryPage(store: Store, collectionPath: string, pageSize = 100) {
+ const prefix = `${collectionPath}/`;
+ const docs = [...store.keys()]
+ .filter((p) => p.startsWith(prefix) && !p.slice(prefix.length).includes("/"))
+ .slice(0, pageSize)
+ .map((p) => {
+ const id = p.slice(prefix.length);
+ return { id, ref: { id, path: p } };
+ });
+ return { empty: docs.length === 0, size: docs.length, docs };
+}
+```
+
+- [ ] **Step 7: Write the failing repository tests**
+
+In `web/src/records/repository.test.ts`:
+
+(a) Add to the imports from `"./repository"`: `finishDeleting`, `markCleanupDone`, `removeCollectionState`, `sweepNotes`. Add `import { memoryPage, memoryTransactions, type Store } from "../test/memoryFirestore";`.
+
+(b) Delete the test `"deleteRestaurant runs mark → sweep → remove and reports progress; stops at the first non-ok outcome"` and add, inside `describe("deletion protocol")`:
+
+```ts
+ const RP = "households/home/restaurants/r1";
+
+ function doomedStore(over: Record = {}): Store {
+ return new Map>([
+ [RP, { ...storedRestaurant, deleting: true, version: 4, ...over }],
+ [`${RP}/claims/c1`, { kind: "gfMenu" }],
+ [`${RP}/claims/c2`, { kind: "separateFryer" }],
+ [`${RP}/notes/n1`, { text: "Ava's", authorUid: "ava-uid" }],
+ [`${RP}/notes/n2`, { text: "Bogdan's", authorUid: "bogdan-uid" }],
+ ["households/home/collection/r1", { shortlisted: true, visited: false, version: 2 }],
+ ]);
+ }
+
+ it("finishDeleting sweeps claims and notes, removes the state document, marks cleanupDone, then removes the restaurant", async () => {
+ const store = doomedStore();
+ const tx = memoryTransactions(m.runTransaction, store);
+ m.getDocsFromServer.mockImplementation(async (q: { path: string }) => memoryPage(store, q.path));
+ const steps: string[] = [];
+ expect(await finishDeleting("home", "r1", (s) => steps.push(s))).toEqual({ kind: "ok", value: undefined });
+ expect(steps).toEqual(["sweeping", "sweepingNotes", "removing"]);
+ expect(store.size).toBe(0);
+ expect(tx.update).toHaveBeenCalledWith({ id: "r1", path: RP }, { cleanupDone: true, version: 5, updatedAt: serverTimestamp() });
+ const updateOrder = tx.update.mock.invocationCallOrder[0]!;
+ const restaurantDelete = tx.delete.mock.calls.findIndex(([ref]) => (ref as { path: string }).path === RP);
+ expect(tx.delete.mock.invocationCallOrder[restaurantDelete]!).toBeGreaterThan(updateOrder);
+ });
+
+ it("sweeps skip documents another sweeper already removed instead of failing", async () => {
+ const store = doomedStore();
+ const tx = memoryTransactions(m.runTransaction, store);
+ store.delete(`${RP}/notes/n2`); // removed between the page read and this transaction
+ m.getDocsFromServer
+ .mockResolvedValueOnce({ empty: false, size: 2, docs: [{ id: "n1", ref: { id: "n1", path: `${RP}/notes/n1` } }, { id: "n2", ref: { id: "n2", path: `${RP}/notes/n2` } }] })
+ .mockResolvedValueOnce({ empty: true, size: 0, docs: [] });
+ expect(await sweepNotes("home", "r1")).toEqual({ kind: "ok", value: 1 });
+ expect(tx.delete).toHaveBeenCalledTimes(1);
+ });
+
+ it("finishing an already-removed restaurant is a no-op, not a failure", async () => {
+ const store: Store = new Map();
+ const tx = memoryTransactions(m.runTransaction, store);
+ m.getDocsFromServer.mockImplementation(async (q: { path: string }) => memoryPage(store, q.path));
+ expect(await finishDeleting("home", "r1")).toEqual({ kind: "ok", value: undefined });
+ expect(tx.update).not.toHaveBeenCalled();
+ expect(tx.delete).not.toHaveBeenCalled();
+ });
+
+ it("markCleanupDone refuses a live restaurant and does nothing when already set", async () => {
+ memoryTransactions(m.runTransaction, new Map([[RP, { ...storedRestaurant }]]));
+ expect(await markCleanupDone("home", "r1")).toEqual({ kind: "notFound" });
+ const tx = memoryTransactions(m.runTransaction, new Map([[RP, { ...storedRestaurant, deleting: true, cleanupDone: true }]]));
+ expect(await markCleanupDone("home", "r1")).toEqual({ kind: "ok", value: undefined });
+ expect(tx.update).not.toHaveBeenCalled();
+ });
+
+ it("removeCollectionState deletes only a document that exists", async () => {
+ const tx = memoryTransactions(m.runTransaction, new Map());
+ expect(await removeCollectionState("home", "r1")).toEqual({ kind: "ok", value: undefined });
+ expect(tx.delete).not.toHaveBeenCalled();
+ });
+
+ it("deleteRestaurant runs mark → sweeps → remove and reports every step; stops at the first non-ok outcome", async () => {
+ const store = doomedStore({ deleting: false, version: 3 });
+ memoryTransactions(m.runTransaction, store);
+ m.getDocsFromServer.mockImplementation(async (q: { path: string }) => memoryPage(store, q.path));
+ const steps: string[] = [];
+ expect(await deleteRestaurant("home", "r1", 3, (s) => steps.push(s))).toEqual({ kind: "ok", value: undefined });
+ expect(steps).toEqual(["marking", "sweeping", "sweepingNotes", "removing"]);
+ expect(store.size).toBe(0);
+
+ const stopped: string[] = [];
+ fakeTx({ exists: true, data: { ...storedRestaurant, version: 9 } });
+ expect(await deleteRestaurant("home", "r1", 3, (s) => stopped.push(s))).toEqual({ kind: "conflict" });
+ expect(stopped).toEqual(["marking"]);
+ });
+```
+
+Create `web/src/records/messages.test.ts`:
+
+```ts
+import { describe, expect, it } from "vitest";
+import { deleteProgressText } from "./messages";
+
+describe("deleteProgressText", () => {
+ it("names every deletion step", () => {
+ expect(deleteProgressText("marking")).toBe("Marking…");
+ expect(deleteProgressText("sweeping")).toBe("Removing evidence…");
+ expect(deleteProgressText("sweepingNotes")).toBe("Removing notes…");
+ expect(deleteProgressText("removing")).toBe("Removing restaurant…");
+ });
+});
+```
+
+- [ ] **Step 8: Run to verify they fail**
+
+Run: `npm --prefix web test -- repository messages`
+Expected: FAIL (`finishDeleting` does not sweep notes; `sweepNotes`, `markCleanupDone`, `removeCollectionState`, `deleteProgressText` missing).
+
+- [ ] **Step 9: Implement the repository changes**
+
+In `web/src/records/repository.ts`:
+
+(a) Add `type CollectionReference` to the `firebase/firestore` type imports.
+
+(b) Replace the lines from `export type DeleteStep …` through `const claimsCol = …` with:
+
+```ts
+export type DeleteStep = "marking" | "sweeping" | "sweepingNotes" | "removing";
+
+/** Documents deleted per transaction during a sweep (well under Firestore's per-transaction limit). */
+export const SWEEP_PAGE = 100;
+
+// The helpers below are exported for the sibling record modules (collection.ts, notes.ts) only.
+export class ConflictError extends Error {}
+export class NotFoundError extends Error {}
+
+const restaurantsCol = (hid: string) => collection(db, "households", hid, "restaurants");
+export const restaurantRef = (hid: string, rid: string) => doc(db, "households", hid, "restaurants", rid);
+const claimsCol = (hid: string, rid: string) => collection(db, "households", hid, "restaurants", rid, "claims");
+export const notesCol = (hid: string, rid: string) => collection(db, "households", hid, "restaurants", rid, "notes");
+export const collectionRef = (hid: string, rid: string) => doc(db, "households", hid, "collection", rid);
+```
+
+(c) Add `export` to `function toDate`, `function listenerFailure`, `const LISTEN`, `function classify` and `async function write`. Their bodies are unchanged.
+
+(d) Replace `sweepClaims`, `removeRestaurant` and `finishDeleting` (everything from the `/** Deletion step 2 …` comment up to, but not including, the `/** The whole protocol …` comment) with:
+
+```ts
+/**
+ * Deletion steps 2–3 (spec §3.7): server-read pages; each page's documents are re-read inside one
+ * transaction and only those still present are deleted, so two finishers and retries converge
+ * instead of one of them failing on an already-deleted document.
+ */
+async function sweep(col: CollectionReference): Promise> {
+ let deleted = 0;
+ for (;;) {
+ let page;
+ try {
+ page = await getDocsFromServer(query(col, limit(SWEEP_PAGE)));
+ } catch (err) {
+ return classify(err);
+ }
+ if (page.empty) return { kind: "ok", value: deleted };
+ const refs = page.docs.map((d) => d.ref);
+ const outcome = await write(async (tx) => {
+ const snaps = await Promise.all(refs.map((ref) => tx.get(ref)));
+ let removed = 0;
+ for (const snap of snaps) {
+ if (snap.exists()) {
+ tx.delete(snap.ref);
+ removed += 1;
+ }
+ }
+ return removed;
+ });
+ if (outcome.kind !== "ok") return outcome;
+ deleted += outcome.value;
+ }
+}
+
+export function sweepClaims(hid: string, rid: string): Promise> {
+ return sweep(claimsCol(hid, rid));
+}
+
+export function sweepNotes(hid: string, rid: string): Promise> {
+ return sweep(notesCol(hid, rid));
+}
+
+/** Deletion step 4: the shortlist/visited document, if any. */
+export function removeCollectionState(hid: string, rid: string): Promise {
+ return write(async (tx) => {
+ const snap = await tx.get(collectionRef(hid, rid));
+ if (snap.exists()) tx.delete(snap.ref);
+ });
+}
+
+/** Deletion step 5, the completion gate. Already removed or already done counts as done. */
+export function markCleanupDone(hid: string, rid: string): Promise {
+ return write(async (tx) => {
+ const snap = await tx.get(restaurantRef(hid, rid));
+ if (!snap.exists()) return;
+ const d = snap.data() as DocumentData;
+ if (d.deleting !== true) throw new NotFoundError();
+ if (d.cleanupDone === true) return;
+ tx.update(snap.ref, { cleanupDone: true, version: Number(d.version) + 1, updatedAt: serverTimestamp() });
+ });
+}
+
+/** Deletion step 6. The rules refuse this unless the gate is satisfied. Already removed counts as done. */
+export function removeRestaurant(hid: string, rid: string): Promise {
+ return write(async (tx) => {
+ const snap = await tx.get(restaurantRef(hid, rid));
+ if (snap.exists()) tx.delete(snap.ref);
+ });
+}
+
+export async function finishDeleting(hid: string, rid: string, onProgress?: (step: DeleteStep) => void): Promise {
+ onProgress?.("sweeping");
+ const claims = await sweepClaims(hid, rid);
+ if (claims.kind !== "ok") return claims;
+ onProgress?.("sweepingNotes");
+ const notes = await sweepNotes(hid, rid);
+ if (notes.kind !== "ok") return notes;
+ onProgress?.("removing");
+ const state = await removeCollectionState(hid, rid);
+ if (state.kind !== "ok") return state;
+ const done = await markCleanupDone(hid, rid);
+ if (done.kind !== "ok") return done;
+ return removeRestaurant(hid, rid);
+}
+```
+
+Update the module comment's second paragraph reference: the deletion protocol is now "spec §3.5, extended by §3.7".
+
+In `web/src/records/messages.ts` add:
+
+```ts
+import type { DeleteStep } from "./repository";
+
+const DELETE_PROGRESS: Record = {
+ marking: "Marking…",
+ sweeping: "Removing evidence…",
+ sweepingNotes: "Removing notes…",
+ removing: "Removing restaurant…",
+};
+
+export function deleteProgressText(step: DeleteStep): string {
+ return DELETE_PROGRESS[step];
+}
+```
+
+Merge the new `DeleteStep` import into the existing `import type { WriteOutcome } from "./repository";` line, making it `import type { DeleteStep, WriteOutcome } from "./repository";`.
+
+In `web/src/records/RestaurantsPage.tsx` and `web/src/records/RestaurantFormPage.tsx`, delete the local `STEP_TEXT` constant and the `type DeleteStep` import. Replace every `STEP_TEXT[step]` with `deleteProgressText(step)` and `STEP_TEXT.sweeping` with `deleteProgressText("sweeping")`, and import `deleteProgressText` from `"./messages"`. The form already imports `outcomeMessage` from there, so add `deleteProgressText` to that import.
+
+- [ ] **Step 10: Run the gates**
+
+Run: `npm run typecheck && npm run test:unit` → PASS (270 + 7 new: 6 repository, 1 messages; one repository test replaced).
+Run: `npm run emu:test` → PASS.
+Run: `npm run emu:e2e` → 29 PASS. Records scenarios 4 and 5 now finish deletion through the gate.
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add firestore.rules functions/test/rules.records.test.ts functions/test/rules.deletion.test.ts web/src/test/memoryFirestore.ts web/src/records/repository.ts web/src/records/repository.test.ts web/src/records/messages.ts web/src/records/messages.test.ts web/src/records/RestaurantsPage.tsx web/src/records/RestaurantFormPage.tsx
+git commit -m "feat(records): deletion completion gate and convergent sweeps
+
+Restaurant delete now requires cleanupDone and no collection document, so
+no client version can orphan notes or state; every deletion step reads
+before it deletes. Mixed-version protocol proven against the rules.
+
+Co-Authored-By: Claude Opus 5.5 (1M context) "
+```
+
+---
+### Task 3: Client data layer — types, validation, `collection.ts`, `notes.ts`
+
+**Files:**
+- Modify: `web/src/records/types.ts`, `web/src/records/validation.ts`, `web/src/records/validation.test.ts`
+- Create: `web/src/records/collection.ts`, `web/src/records/collection.test.ts`
+- Create: `web/src/records/notes.ts`, `web/src/records/notes.test.ts`
+
+**Interfaces:**
+- Consumes (Task 2, `./repository`): `write`, `ConflictError`, `NotFoundError`, `LISTEN`, `listenerFailure`, `toDate`, `restaurantRef`, `notesCol`, `collectionRef`, `type Snapshot`, `type WriteOutcome`; `./dates`: `fromCalendarDate`, `toCalendarDate`, `isCalendarDate`, `compareCalendarDates`.
+- Produces:
+ - `types.ts`: `interface CollectionState { shortlisted: boolean; visited: boolean; visitedOn?: CalendarDate; updatedBy: string; updatedByName: string; updatedAt: Date; version: number }` and `interface Note { id: string; text: string; authorUid: string; authorName: string; createdAt: Date; updatedAt: Date; version: number }`
+ - `validation.ts`: `validateNoteText(text: string): string | null`, `validateVisitedOn(value: string, today: CalendarDate): string | null`
+ - `collection.ts`: `watchCollection(hid, cb: (s: Snapshot>) => void): () => void`, `watchCollectionEntry(hid, rid, cb: (s: Snapshot) => void): () => void`, `setShortlisted(hid, rid, author: Author, baseVersion: number, shortlisted: boolean): Promise>`, `setVisited(hid, rid, author: Author, baseVersion: number, visitedOn: CalendarDate | null): Promise>`
+ - `notes.ts`: `watchNotes(hid, rid, cb: (s: Snapshot) => void): () => void` (newest first), `addNote(hid, rid, author: Author, text: string): Promise>`, `updateNote(hid, rid, nid, baseVersion: number, text: string): Promise>`, `deleteNote(hid, rid, nid, baseVersion: number): Promise`
+
+A missing collection document means base version 0. The UI treats it as "not shortlisted, not visited" only when the snapshot is `ready` (Tasks 4–5).
+
+- [ ] **Step 1: Write the failing validation tests**
+
+Append to `web/src/records/validation.test.ts` (merge `validateNoteText, validateVisitedOn` into the existing `./validation` import):
+
+```ts
+describe("validateNoteText", () => {
+ it("refuses blank text and text over the limit, accepts up to 2,000 characters after trimming", () => {
+ expect(validateNoteText(" ")).toBe("Write something first.");
+ expect(validateNoteText("x".repeat(2001))).toBe("Keep this to 2000 characters.");
+ expect(validateNoteText(` ${"x".repeat(2000)} `)).toBeNull();
+ expect(validateNoteText("Lovely staff")).toBeNull();
+ });
+});
+
+describe("validateVisitedOn", () => {
+ it("needs a real calendar date that is not after the device's today", () => {
+ expect(validateVisitedOn("", "2026-09-24")).toBe("Enter the date of the visit.");
+ expect(validateVisitedOn("2026-02-30", "2026-09-24")).toBe("Enter the date of the visit.");
+ expect(validateVisitedOn("2026-09-25", "2026-09-24")).toBe("The visit date can't be in the future.");
+ expect(validateVisitedOn("2026-09-24", "2026-09-24")).toBeNull();
+ expect(validateVisitedOn("2025-05-03", "2026-09-24")).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Write the failing `collection.test.ts`**
+
+Create `web/src/records/collection.test.ts`:
+
+```ts
+import { serverTimestamp, Timestamp } from "firebase/firestore";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { memoryTransactions, type Store } from "../test/memoryFirestore";
+
+const m = vi.hoisted(() => ({ runTransaction: vi.fn(), onSnapshot: vi.fn() }));
+
+vi.mock("../firebase", () => ({ db: { fake: true } }));
+vi.mock("firebase/firestore", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ runTransaction: m.runTransaction,
+ onSnapshot: m.onSnapshot,
+ getDocsFromServer: vi.fn(),
+ collection: (_db: unknown, ...segments: string[]) => ({ path: segments.join("/") }),
+ doc: (parent: unknown, ...segments: string[]) => {
+ const base = typeof (parent as { path?: string }).path === "string" ? (parent as { path: string }).path : "";
+ const path = [base, ...segments].filter(Boolean).join("/");
+ return { id: segments[segments.length - 1] ?? "", path };
+ },
+ query: (source: unknown) => source,
+ orderBy: () => undefined,
+ limit: () => undefined,
+ };
+});
+
+import { setShortlisted, setVisited, watchCollection, watchCollectionEntry } from "./collection";
+
+const RP = "households/home/restaurants/r1";
+const SP = "households/home/collection/r1";
+const AVA = { uid: "ava-uid", displayName: "Ava" };
+const live = { name: "Da Marco", address: "Via Roma 1", deleting: false, version: 2 };
+const stored = { shortlisted: true, visited: true, visitedOn: Timestamp.fromDate(new Date("2026-05-03T00:00:00Z")), updatedBy: "bogdan-uid", updatedByName: "Bogdan", updatedAt: Timestamp.fromDate(new Date("2026-05-03T10:00:00Z")), version: 3 };
+
+beforeEach(() => {
+ Object.defineProperty(navigator, "onLine", { configurable: true, value: true });
+});
+afterEach(() => vi.clearAllMocks());
+
+describe("setShortlisted / setVisited", () => {
+ it("creates version 1 from base 0 with the author's identity and a server timestamp", async () => {
+ const store: Store = new Map([[RP, live]]);
+ memoryTransactions(m.runTransaction, store);
+ expect(await setShortlisted("home", "r1", AVA, 0, true)).toEqual({ kind: "ok", value: 1 });
+ expect(store.get(SP)).toEqual({ shortlisted: true, visited: false, updatedBy: "ava-uid", updatedByName: "Ava", updatedAt: serverTimestamp(), version: 1 });
+ });
+
+ it("changing the shortlist keeps the visit date and bumps the version", async () => {
+ const store: Store = new Map>([[RP, live], [SP, stored]]);
+ memoryTransactions(m.runTransaction, store);
+ expect(await setShortlisted("home", "r1", AVA, 3, false)).toEqual({ kind: "ok", value: 4 });
+ expect(store.get(SP)).toMatchObject({ shortlisted: false, visited: true, visitedOn: Timestamp.fromDate(new Date("2026-05-03T00:00:00Z")), updatedBy: "ava-uid", version: 4 });
+ });
+
+ it("setVisited stores a UTC-midnight date; clearing drops visitedOn and keeps the shortlist", async () => {
+ const store: Store = new Map([[RP, live]]);
+ memoryTransactions(m.runTransaction, store);
+ expect(await setVisited("home", "r1", AVA, 0, "2026-09-20")).toEqual({ kind: "ok", value: 1 });
+ expect((store.get(SP)!.visitedOn as Timestamp).toDate().toISOString()).toBe("2026-09-20T00:00:00.000Z");
+ expect(store.get(SP)).toMatchObject({ shortlisted: false, visited: true });
+ await setShortlisted("home", "r1", AVA, 1, true);
+ expect(await setVisited("home", "r1", AVA, 2, null)).toEqual({ kind: "ok", value: 3 });
+ expect(store.get(SP)).not.toHaveProperty("visitedOn");
+ expect(store.get(SP)).toMatchObject({ shortlisted: true, visited: false, version: 3 });
+ });
+
+ it("reports conflict without writing when the stored version differs from the base", async () => {
+ const store: Store = new Map>([[RP, live], [SP, stored]]);
+ const tx = memoryTransactions(m.runTransaction, store);
+ expect(await setShortlisted("home", "r1", AVA, 2, false)).toEqual({ kind: "conflict" });
+ expect(await setShortlisted("home", "r1", AVA, 0, true)).toEqual({ kind: "conflict" });
+ expect(tx.set).not.toHaveBeenCalled();
+ });
+
+ it("reports notFound for a missing restaurant or one marked deleting", async () => {
+ memoryTransactions(m.runTransaction, new Map());
+ expect(await setShortlisted("home", "r1", AVA, 0, true)).toEqual({ kind: "notFound" });
+ memoryTransactions(m.runTransaction, new Map([[RP, { ...live, deleting: true }]]));
+ expect(await setVisited("home", "r1", AVA, 0, "2026-09-20")).toEqual({ kind: "notFound" });
+ });
+
+ it("reports offline before starting a transaction when the browser is offline", async () => {
+ Object.defineProperty(navigator, "onLine", { configurable: true, value: false });
+ expect(await setShortlisted("home", "r1", AVA, 0, true)).toEqual({ kind: "offline" });
+ expect(m.runTransaction).not.toHaveBeenCalled();
+ });
+});
+
+describe("watchers", () => {
+ it("watchCollection maps documents by restaurant id and reports ready/offline", () => {
+ const seen: unknown[] = [];
+ m.onSnapshot.mockImplementation((_q: unknown, _o: unknown, next: (s: unknown) => void) => {
+ next({ metadata: { fromCache: false }, docs: [{ id: "r1", data: () => stored }] });
+ next({ metadata: { fromCache: true }, docs: [] });
+ return () => {};
+ });
+ watchCollection("home", (s) => seen.push(s));
+ expect(seen[0]).toEqual({ status: "ready", value: { r1: { shortlisted: true, visited: true, visitedOn: "2026-05-03", updatedBy: "bogdan-uid", updatedByName: "Bogdan", updatedAt: new Date("2026-05-03T10:00:00Z"), version: 3 } } });
+ expect(seen[1]).toEqual({ status: "offline", value: {} });
+ });
+
+ it("watchCollectionEntry reports null for a missing document, with the snapshot's source, and denied on permission errors", () => {
+ const seen: unknown[] = [];
+ m.onSnapshot.mockImplementation((_r: unknown, _o: unknown, next: (s: unknown) => void, fail: (e: unknown) => void) => {
+ next({ metadata: { fromCache: false }, exists: () => false });
+ next({ metadata: { fromCache: true }, exists: () => false });
+ fail(Object.assign(new Error("denied"), { code: "permission-denied" }));
+ return () => {};
+ });
+ watchCollectionEntry("home", "r1", (s) => seen.push(s));
+ expect(seen).toEqual([{ status: "ready", value: null }, { status: "offline", value: null }, { status: "denied" }]);
+ expect(m.onSnapshot.mock.calls[0]![1]).toEqual({ includeMetadataChanges: true });
+ });
+});
+```
+
+- [ ] **Step 3: Write the failing `notes.test.ts`**
+
+Create `web/src/records/notes.test.ts`:
+
+```ts
+import { serverTimestamp, Timestamp } from "firebase/firestore";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { memoryTransactions, type Store } from "../test/memoryFirestore";
+
+const m = vi.hoisted(() => ({ runTransaction: vi.fn(), onSnapshot: vi.fn(), orderBy: vi.fn() }));
+
+vi.mock("../firebase", () => ({ db: { fake: true } }));
+vi.mock("firebase/firestore", async (importOriginal) => {
+ const actual = await importOriginal();
+ let generated = 0;
+ return {
+ ...actual,
+ runTransaction: m.runTransaction,
+ onSnapshot: m.onSnapshot,
+ getDocsFromServer: vi.fn(),
+ collection: (_db: unknown, ...segments: string[]) => ({ path: segments.join("/") }),
+ doc: (parent: unknown, ...segments: string[]) => {
+ const base = typeof (parent as { path?: string }).path === "string" ? (parent as { path: string }).path : "";
+ const id = segments.length > 0 ? segments[segments.length - 1]! : `gen-${++generated}`;
+ const path = segments.length > 0 ? [base, ...segments].filter(Boolean).join("/") : `${base}/${id}`;
+ return { id, path };
+ },
+ query: (source: unknown) => source,
+ orderBy: m.orderBy,
+ limit: () => undefined,
+ };
+});
+
+import { addNote, deleteNote, updateNote, watchNotes } from "./notes";
+
+const RP = "households/home/restaurants/r1";
+const NP = `${RP}/notes/n1`;
+const AVA = { uid: "ava-uid", displayName: "Ava" };
+const live = { name: "Da Marco", address: "Via Roma 1", deleting: false, version: 2 };
+const at = Timestamp.fromDate(new Date("2026-09-01T10:00:00Z"));
+const note = { text: "Great staff", authorUid: "ava-uid", authorName: "Ava", createdAt: at, updatedAt: at, version: 2 };
+
+beforeEach(() => {
+ Object.defineProperty(navigator, "onLine", { configurable: true, value: true });
+});
+afterEach(() => vi.clearAllMocks());
+
+describe("addNote", () => {
+ it("writes version 1, the author's identity and server timestamps under a live restaurant", async () => {
+ const store: Store = new Map([[RP, live]]);
+ memoryTransactions(m.runTransaction, store);
+ const outcome = await addNote("home", "r1", AVA, "Staff knew about cross-contamination.");
+ expect(outcome.kind).toBe("ok");
+ const id = (outcome as { value: string }).value;
+ expect(store.get(`${RP}/notes/${id}`)).toEqual({
+ text: "Staff knew about cross-contamination.",
+ authorUid: "ava-uid",
+ authorName: "Ava",
+ createdAt: serverTimestamp(),
+ updatedAt: serverTimestamp(),
+ version: 1,
+ });
+ });
+
+ it("reports notFound under a missing restaurant or one marked deleting", async () => {
+ memoryTransactions(m.runTransaction, new Map());
+ expect((await addNote("home", "r1", AVA, "x")).kind).toBe("notFound");
+ memoryTransactions(m.runTransaction, new Map([[RP, { ...live, deleting: true }]]));
+ expect((await addNote("home", "r1", AVA, "x")).kind).toBe("notFound");
+ });
+});
+
+describe("updateNote", () => {
+ it("writes only text, updatedAt and the next version", async () => {
+ const store: Store = new Map>([[RP, live], [NP, note]]);
+ const tx = memoryTransactions(m.runTransaction, store);
+ expect(await updateNote("home", "r1", "n1", 2, "Edited")).toEqual({ kind: "ok", value: 3 });
+ expect(tx.update).toHaveBeenCalledWith({ id: "n1", path: NP }, { text: "Edited", updatedAt: serverTimestamp(), version: 3 });
+ });
+
+ it("reports conflict on a stale base and notFound for a missing note or a restaurant marked deleting", async () => {
+ const store: Store = new Map>([[RP, live], [NP, note]]);
+ const tx = memoryTransactions(m.runTransaction, store);
+ expect(await updateNote("home", "r1", "n1", 1, "Edited")).toEqual({ kind: "conflict" });
+ expect(await updateNote("home", "r1", "gone", 1, "Edited")).toEqual({ kind: "notFound" });
+ store.set(RP, { ...live, deleting: true });
+ expect(await updateNote("home", "r1", "n1", 2, "Edited")).toEqual({ kind: "notFound" });
+ expect(tx.update).not.toHaveBeenCalled();
+ });
+});
+
+describe("deleteNote", () => {
+ it("deletes only the version the member confirmed", async () => {
+ const store: Store = new Map>([[RP, live], [NP, note]]);
+ memoryTransactions(m.runTransaction, store);
+ expect(await deleteNote("home", "r1", "n1", 1)).toEqual({ kind: "conflict" });
+ expect(store.has(NP)).toBe(true);
+ expect(await deleteNote("home", "r1", "n1", 2)).toEqual({ kind: "ok", value: undefined });
+ expect(store.has(NP)).toBe(false);
+ expect(await deleteNote("home", "r1", "n1", 2)).toEqual({ kind: "notFound" });
+ });
+});
+
+describe("watchNotes", () => {
+ it("lists newest first and reports ready/offline", () => {
+ const seen: unknown[] = [];
+ m.onSnapshot.mockImplementation((_q: unknown, _o: unknown, next: (s: unknown) => void) => {
+ next({ metadata: { fromCache: true }, docs: [{ id: "n1", data: () => note }] });
+ return () => {};
+ });
+ watchNotes("home", "r1", (s) => seen.push(s));
+ expect(m.orderBy).toHaveBeenCalledWith("createdAt", "desc");
+ expect(seen[0]).toEqual({ status: "offline", value: [{ id: "n1", text: "Great staff", authorUid: "ava-uid", authorName: "Ava", createdAt: new Date("2026-09-01T10:00:00Z"), updatedAt: new Date("2026-09-01T10:00:00Z"), version: 2 }] });
+ });
+});
+```
+
+- [ ] **Step 4: Run to verify they fail**
+
+Run: `npm --prefix web test -- validation collection notes`
+Expected: FAIL (modules and functions missing).
+
+- [ ] **Step 5: Implement**
+
+Append to `web/src/records/types.ts`:
+
+```ts
+/**
+ * Read model of households/{hid}/collection/{rid} (spec §3.7): household-wide shortlist and
+ * visited state. A missing document means not shortlisted, not visited, version 0, but only
+ * when the snapshot came from the server.
+ */
+export interface CollectionState {
+ shortlisted: boolean;
+ visited: boolean;
+ visitedOn?: CalendarDate;
+ updatedBy: string;
+ updatedByName: string;
+ updatedAt: Date;
+ version: number;
+}
+
+/** Read model of households/{hid}/restaurants/{rid}/notes/{nid}. Personal notes, never evidence. */
+export interface Note {
+ id: string;
+ text: string;
+ authorUid: string;
+ authorName: string;
+ createdAt: Date;
+ updatedAt: Date;
+ version: number;
+}
+```
+
+Append to `web/src/records/validation.ts`:
+
+```ts
+export function validateNoteText(text: string): string | null {
+ const trimmed = text.trim();
+ if (trimmed === "") return "Write something first.";
+ if (trimmed.length > LIMITS.note) return tooLong(LIMITS.note);
+ return null;
+}
+
+/** `today` is the device's local calendar day (useToday); the rules allow one day of slack. */
+export function validateVisitedOn(value: string, today: CalendarDate): string | null {
+ if (!isCalendarDate(value)) return "Enter the date of the visit.";
+ if (compareCalendarDates(value, today) > 0) return "The visit date can't be in the future.";
+ return null;
+}
+```
+
+Create `web/src/records/collection.ts`:
+
+```ts
+import { collection, onSnapshot, serverTimestamp, Timestamp, type DocumentData, type DocumentSnapshot } from "firebase/firestore";
+import { db } from "../firebase";
+import { fromCalendarDate, toCalendarDate } from "./dates";
+import { collectionRef, ConflictError, LISTEN, listenerFailure, NotFoundError, restaurantRef, toDate, write, type Snapshot, type WriteOutcome } from "./repository";
+import type { Author, CalendarDate, CollectionState } from "./types";
+
+/**
+ * Shortlist and visited state (spec §3.7). One document per restaurant, id = restaurant id,
+ * written whole by every change so the stored shape always matches the rules. Online-only
+ * transactions via the repository's write() (spec §3.5).
+ */
+
+export function toCollectionState(snap: Pick): CollectionState {
+ const d = snap.data() as DocumentData;
+ const s: CollectionState = {
+ shortlisted: d.shortlisted === true,
+ visited: d.visited === true,
+ updatedBy: String(d.updatedBy),
+ updatedByName: String(d.updatedByName),
+ updatedAt: toDate(d.updatedAt),
+ version: Number(d.version),
+ };
+ if (d.visitedOn instanceof Timestamp) s.visitedOn = toCalendarDate(d.visitedOn);
+ return s;
+}
+
+export function watchCollection(hid: string, cb: (s: Snapshot>) => void): () => void {
+ return onSnapshot(
+ collection(db, "households", hid, "collection"),
+ LISTEN,
+ (snap) => {
+ const value: Record = {};
+ for (const d of snap.docs) value[d.id] = toCollectionState(d);
+ cb({ status: snap.metadata.fromCache ? "offline" : "ready", value });
+ },
+ (err) => cb(listenerFailure(err)),
+ );
+}
+
+export function watchCollectionEntry(hid: string, rid: string, cb: (s: Snapshot) => void): () => void {
+ return onSnapshot(
+ collectionRef(hid, rid),
+ LISTEN,
+ (snap) => cb({ status: snap.metadata.fromCache ? "offline" : "ready", value: snap.exists() ? toCollectionState(snap) : null }),
+ (err) => cb(listenerFailure(err)),
+ );
+}
+
+interface StatePatch {
+ shortlisted?: boolean;
+ visitedOn?: CalendarDate | null;
+}
+
+function writeState(hid: string, rid: string, author: Author, baseVersion: number, patch: StatePatch): Promise> {
+ return write(async (tx) => {
+ const parent = await tx.get(restaurantRef(hid, rid));
+ if (!parent.exists() || (parent.data() as DocumentData).deleting === true) throw new NotFoundError();
+ const ref = collectionRef(hid, rid);
+ const snap = await tx.get(ref);
+ const current = snap.exists() ? toCollectionState(snap) : null;
+ const version = current?.version ?? 0;
+ if (version !== baseVersion) throw new ConflictError();
+ const shortlisted = patch.shortlisted ?? current?.shortlisted ?? false;
+ const visitedOn = patch.visitedOn !== undefined ? patch.visitedOn : (current?.visitedOn ?? null);
+ const data: DocumentData = {
+ shortlisted,
+ visited: visitedOn !== null,
+ updatedBy: author.uid,
+ updatedByName: author.displayName,
+ updatedAt: serverTimestamp(),
+ version: version + 1,
+ };
+ if (visitedOn !== null) data.visitedOn = fromCalendarDate(visitedOn);
+ tx.set(ref, data);
+ return version + 1;
+ });
+}
+
+export function setShortlisted(hid: string, rid: string, author: Author, baseVersion: number, shortlisted: boolean): Promise> {
+ return writeState(hid, rid, author, baseVersion, { shortlisted });
+}
+
+/** `null` clears the visit. */
+export function setVisited(hid: string, rid: string, author: Author, baseVersion: number, visitedOn: CalendarDate | null): Promise> {
+ return writeState(hid, rid, author, baseVersion, { visitedOn });
+}
+```
+
+Create `web/src/records/notes.ts`:
+
+```ts
+import { doc, onSnapshot, orderBy, query, serverTimestamp, type DocumentData, type DocumentSnapshot } from "firebase/firestore";
+import { ConflictError, LISTEN, listenerFailure, notesCol, NotFoundError, restaurantRef, toDate, write, type Snapshot, type WriteOutcome } from "./repository";
+import type { Author, Note } from "./types";
+
+/**
+ * Authored notes on a restaurant (spec §3.7). Only the author edits or deletes (rules-enforced);
+ * edits and deletes carry the version the member saw, so a change from another device surfaces
+ * as a conflict instead of being overwritten. Callers pass validated, trimmed text.
+ */
+
+export function toNote(snap: Pick): Note {
+ const d = snap.data() as DocumentData;
+ return {
+ id: snap.id,
+ text: String(d.text),
+ authorUid: String(d.authorUid),
+ authorName: String(d.authorName),
+ createdAt: toDate(d.createdAt),
+ updatedAt: toDate(d.updatedAt),
+ version: Number(d.version),
+ };
+}
+
+export function watchNotes(hid: string, rid: string, cb: (s: Snapshot) => void): () => void {
+ return onSnapshot(
+ query(notesCol(hid, rid), orderBy("createdAt", "desc")),
+ LISTEN,
+ (snap) => cb({ status: snap.metadata.fromCache ? "offline" : "ready", value: snap.docs.map(toNote) }),
+ (err) => cb(listenerFailure(err)),
+ );
+}
+
+export function addNote(hid: string, rid: string, author: Author, text: string): Promise> {
+ return write(async (tx) => {
+ const parent = await tx.get(restaurantRef(hid, rid));
+ if (!parent.exists() || (parent.data() as DocumentData).deleting === true) throw new NotFoundError();
+ const ref = doc(notesCol(hid, rid));
+ tx.set(ref, { text, authorUid: author.uid, authorName: author.displayName, createdAt: serverTimestamp(), updatedAt: serverTimestamp(), version: 1 });
+ return ref.id;
+ });
+}
+
+export function updateNote(hid: string, rid: string, nid: string, baseVersion: number, text: string): Promise> {
+ return write(async (tx) => {
+ const parent = await tx.get(restaurantRef(hid, rid));
+ const snap = await tx.get(doc(notesCol(hid, rid), nid));
+ if (!parent.exists() || (parent.data() as DocumentData).deleting === true || !snap.exists()) throw new NotFoundError();
+ const current = toNote(snap);
+ if (current.version !== baseVersion) throw new ConflictError();
+ const next = baseVersion + 1;
+ tx.update(snap.ref, { text, updatedAt: serverTimestamp(), version: next });
+ return next;
+ });
+}
+
+export function deleteNote(hid: string, rid: string, nid: string, baseVersion: number): Promise {
+ return write(async (tx) => {
+ const snap = await tx.get(doc(notesCol(hid, rid), nid));
+ if (!snap.exists()) throw new NotFoundError();
+ if (toNote(snap).version !== baseVersion) throw new ConflictError();
+ tx.delete(snap.ref);
+ });
+}
+```
+
+- [ ] **Step 6: Run the gates**
+
+Run: `npm run typecheck && npm run test:unit` → PASS (previous + 2 validation + 8 collection + 6 notes).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add web/src/records/types.ts web/src/records/validation.ts web/src/records/validation.test.ts web/src/records/collection.ts web/src/records/collection.test.ts web/src/records/notes.ts web/src/records/notes.test.ts
+git commit -m "feat(records): shortlist/visited and notes data layer
+
+Co-Authored-By: Claude Opus 5.5 (1M context) "
+```
+
+---
+### Task 4: Saved page — joined read state, Shortlist/All filter, labels, empty states, resume wording
+
+**Files:**
+- Create: `web/src/records/combine.ts`, `web/src/records/combine.test.ts`
+- Create: `web/src/records/join.ts`, `web/src/records/join.test.ts`
+- Modify: `web/src/records/messages.ts`, `web/src/records/messages.test.ts`
+- Modify: `web/src/records/RestaurantsPage.tsx`, `web/src/records/RestaurantsPage.test.tsx`
+- Modify: `web/src/styles.css`
+- Modify: `web/e2e/records.spec.ts` (two list assertions now need *All records*)
+
+**Interfaces:**
+- Consumes: `watchRestaurants`, `finishDeleting` (`./repository`); `watchCollection` (`./collection`, Task 3); `WatchState` (`./useWatch`); `formatCalendarDate` (`./dates`); `deleteProgressText` (`./messages`, Task 2).
+- Produces:
+ - `combine.ts`: `isData(s: WatchState): s is Extract, { status: "ready" | "offline" }>`, `anyOffline(...states: Array>): boolean`, `combineStates(a: WatchState, b: WatchState): WatchState<[A, B]>`
+ - `join.ts`: `interface RecordRow { restaurant: Restaurant; state: CollectionState | null }`, `type RecordFilter = "shortlist" | "all"`, `joinRecords(restaurants, states): RecordRow[]`, `filterRows(rows, filter): RecordRow[]`
+ - `messages.ts`: `finishOutcomeText(kind: WriteOutcome["kind"]): string`
+ - Saved page testids (new): `filter-shortlist`, `filter-all` (`aria-pressed`), `label-shortlisted`, `label-visited`, `shortlist-empty`. Existing testids are kept.
+
+- [ ] **Step 1: Write the failing pure-helper tests**
+
+Create `web/src/records/combine.test.ts`:
+
+```ts
+import { describe, expect, it } from "vitest";
+import { anyOffline, combineStates, isData } from "./combine";
+
+describe("combineStates", () => {
+ it("is loading until both listeners have produced a snapshot", () => {
+ expect(combineStates({ status: "loading" }, { status: "ready", value: 1 })).toEqual({ status: "loading" });
+ expect(combineStates({ status: "ready", value: 1 }, { status: "loading" })).toEqual({ status: "loading" });
+ });
+
+ it("never hides denied or an error behind other states, denied first", () => {
+ expect(combineStates({ status: "error", message: "boom" }, { status: "denied" })).toEqual({ status: "denied" });
+ expect(combineStates({ status: "offline", value: 1 }, { status: "error", message: "boom" })).toEqual({ status: "error", message: "boom" });
+ expect(combineStates({ status: "loading" }, { status: "error", message: "boom" })).toEqual({ status: "error", message: "boom" });
+ });
+
+ it("is offline when either side is cache-backed, ready only when both are from the server", () => {
+ expect(combineStates({ status: "offline", value: 1 }, { status: "ready", value: "a" })).toEqual({ status: "offline", value: [1, "a"] });
+ expect(combineStates({ status: "ready", value: 1 }, { status: "ready", value: "a" })).toEqual({ status: "ready", value: [1, "a"] });
+ });
+});
+
+describe("isData / anyOffline", () => {
+ it("classify states", () => {
+ expect(isData({ status: "offline", value: [] })).toBe(true);
+ expect(isData({ status: "loading" })).toBe(false);
+ expect(anyOffline({ status: "ready", value: 1 }, { status: "loading" })).toBe(false);
+ expect(anyOffline({ status: "ready", value: 1 }, { status: "offline", value: 2 })).toBe(true);
+ });
+});
+```
+
+Create `web/src/records/join.test.ts`:
+
+```ts
+import { describe, expect, it } from "vitest";
+import { filterRows, joinRecords } from "./join";
+import type { CollectionState, Restaurant } from "./types";
+
+const r = (id: string, deleting = false): Restaurant => ({ id, name: id, address: "x", createdBy: "ava-uid", createdAt: new Date(0), updatedAt: new Date(0), version: 1, deleting });
+const s = (shortlisted: boolean): CollectionState => ({ shortlisted, visited: false, updatedBy: "ava-uid", updatedByName: "Ava", updatedAt: new Date(0), version: 1 });
+
+describe("joinRecords / filterRows", () => {
+ it("pairs each restaurant with its state by id, null when absent", () => {
+ const rows = joinRecords([r("a"), r("b")], { a: s(true) });
+ expect(rows).toEqual([{ restaurant: r("a"), state: s(true) }, { restaurant: r("b"), state: null }]);
+ });
+
+ it("the shortlist keeps shortlisted rows and every deleting row; all keeps everything", () => {
+ const rows = joinRecords([r("a"), r("b"), r("c"), r("d", true)], { a: s(true), b: s(false) });
+ expect(filterRows(rows, "shortlist").map((x) => x.restaurant.id)).toEqual(["a", "d"]);
+ expect(filterRows(rows, "all").map((x) => x.restaurant.id)).toEqual(["a", "b", "c", "d"]);
+ });
+});
+```
+
+Append to `web/src/records/messages.test.ts` (merge `finishOutcomeText` into the import):
+
+```ts
+describe("finishOutcomeText", () => {
+ it("tells the member what to do when resuming a deletion fails", () => {
+ expect(finishOutcomeText("offline")).toBe("You are offline. Connect, then tap Finish deleting.");
+ expect(finishOutcomeText("notFound")).toBe("Already removed.");
+ expect(finishOutcomeText("permission")).toContain("That change was refused.");
+ expect(finishOutcomeText("failed")).toBe("Could not finish deleting. Tap Finish deleting to try again.");
+ expect(finishOutcomeText("conflict")).toBe("Could not finish deleting. Tap Finish deleting to try again.");
+ });
+});
+```
+
+- [ ] **Step 2: Write the failing page tests**
+
+In `web/src/records/RestaurantsPage.test.tsx`:
+
+(a) Extend the hoisted mocks and add the collection mock. Replace the `m` block, the `./repository` mock line and the `beforeEach` with:
+
+```ts
+const m = vi.hoisted(() => ({
+ watchRestaurants: vi.fn(),
+ watchCollection: vi.fn(),
+ finishDeleting: vi.fn(),
+ signOut: vi.fn(),
+}));
+vi.mock("./repository", () => ({ watchRestaurants: m.watchRestaurants, finishDeleting: m.finishDeleting }));
+vi.mock("./collection", () => ({ watchCollection: m.watchCollection }));
+```
+
+```ts
+let emit: (s: Snapshot) => void = () => {};
+let emitState: (s: Snapshot>) => void = () => {};
+beforeEach(() => {
+ m.watchRestaurants.mockImplementation((_hid: string, cb: (s: Snapshot) => void) => {
+ emit = cb;
+ return () => {};
+ });
+ // Existing tests assume an authoritative, empty collection unless a test says otherwise.
+ m.watchCollection.mockImplementation((_hid: string, cb: (s: Snapshot>) => void) => {
+ emitState = cb;
+ cb({ status: "ready", value: {} });
+ return () => {};
+ });
+ m.finishDeleting.mockResolvedValue({ kind: "ok", value: undefined });
+});
+```
+
+Import `CollectionState` next to `Restaurant` from `./types`.
+
+(b) The existing test `"shows loading, then the household's restaurants as links"` now needs the *All records* filter because nothing is shortlisted. Insert `await userEvent.click(screen.getByTestId("filter-all"));` after the `act(() => emit(…))` line and make the test `async`. In `"disables Add while offline and shows the offline notice with the cached rows"`, add the same click (making it `async`) before asserting the row.
+
+(c) The existing error test counts `watchRestaurants` re-subscriptions. Retry now re-subscribes both listeners, so it still gains exactly one `watchRestaurants` call. Leave it unchanged.
+
+(d) Append:
+
+```ts
+const st = (over: Partial = {}): CollectionState => ({ shortlisted: true, visited: false, updatedBy: "ava-uid", updatedByName: "Ava", updatedAt: new Date(), version: 1, ...over });
+
+describe("RestaurantsPage — shortlist", () => {
+ it("defaults to the shortlist with labels, and All records shows everything", async () => {
+ renderPage();
+ act(() => {
+ emit({ status: "ready", value: [r({ id: "a", name: "Da Marco" }), r({ id: "b", name: "Zest" })] });
+ emitState({ status: "ready", value: { a: st({ visited: true, visitedOn: "2026-05-03" }) } });
+ });
+ expect(screen.getByTestId("filter-shortlist")).toHaveAttribute("aria-pressed", "true");
+ const rows = screen.getAllByTestId("restaurant-row");
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toHaveTextContent("Da Marco");
+ expect(screen.getByTestId("label-shortlisted")).toHaveTextContent("Shortlisted");
+ expect(screen.getByTestId("label-visited")).toHaveTextContent("Visited 3 May 2026");
+ await userEvent.click(screen.getByTestId("filter-all"));
+ expect(screen.getAllByTestId("restaurant-row")).toHaveLength(2);
+ expect(screen.getByTestId("filter-all")).toHaveAttribute("aria-pressed", "true");
+ });
+
+ it("distinguishes no records from an empty shortlist", () => {
+ renderPage();
+ act(() => emit({ status: "ready", value: [r({ id: "a", name: "Da Marco" })] }));
+ expect(screen.getByTestId("shortlist-empty")).toHaveTextContent("Nothing on the shortlist. Open a record and tap Add to shortlist.");
+ expect(screen.queryByTestId("restaurants-empty")).toBeNull();
+ act(() => emit({ status: "ready", value: [] }));
+ expect(screen.getByTestId("restaurants-empty")).toBeInTheDocument();
+ expect(screen.queryByTestId("shortlist-empty")).toBeNull();
+ });
+
+ it("never shows an empty shortlist from a cached or failed collection snapshot", () => {
+ renderPage();
+ act(() => {
+ emit({ status: "ready", value: [r({ id: "a", name: "Da Marco" })] });
+ emitState({ status: "offline", value: {} });
+ });
+ expect(screen.queryByTestId("shortlist-empty")).toBeNull();
+ expect(screen.getAllByTestId("read-offline")).toHaveLength(1);
+ act(() => emitState({ status: "error", message: "boom" }));
+ expect(screen.getByTestId("read-error")).toHaveTextContent("boom");
+ expect(screen.queryByTestId("shortlist-empty")).toBeNull();
+ });
+
+ it("shows one offline notice when both listeners are cache-backed", () => {
+ renderPage();
+ act(() => {
+ emit({ status: "offline", value: [r({ id: "a", name: "Da Marco" })] });
+ emitState({ status: "offline", value: { a: st() } });
+ });
+ expect(screen.getAllByTestId("read-offline")).toHaveLength(1);
+ expect(screen.getByTestId("restaurant-row")).toHaveTextContent("Da Marco");
+ });
+
+ it("keeps deleting rows under the shortlist filter", () => {
+ renderPage();
+ act(() => emit({ status: "ready", value: [r({ id: "d", name: "Doomed", deleting: true })] }));
+ expect(screen.getByTestId("restaurant-deleting")).toHaveTextContent("Doomed");
+ });
+
+ it("explains a failed resume in words the member can act on", async () => {
+ m.finishDeleting.mockResolvedValue({ kind: "failed", message: "x" });
+ renderPage();
+ act(() => emit({ status: "ready", value: [r({ id: "d", name: "Doomed", deleting: true })] }));
+ await waitFor(() => expect(screen.getByTestId("restaurant-deleting")).toHaveTextContent("Could not finish deleting. Tap Finish deleting to try again."));
+ });
+});
+```
+
+- [ ] **Step 3: Run to verify they fail**
+
+Run: `npm --prefix web test -- combine join messages RestaurantsPage`
+Expected: FAIL (modules missing; the page has no filter).
+
+- [ ] **Step 4: Implement the helpers**
+
+Create `web/src/records/combine.ts`:
+
+```ts
+import type { WatchState } from "./useWatch";
+
+/**
+ * Read states for views built from several listeners (spec §3.7 "Read states for joined data").
+ * Precedence: denied, gone, error, loading, then offline if any side is cache-backed, else ready.
+ * An error is never hidden behind the offline notice.
+ */
+export function isData(s: WatchState): s is Extract, { status: "ready" | "offline" }> {
+ return s.status === "ready" || s.status === "offline";
+}
+
+export function anyOffline(...states: Array>): boolean {
+ return states.some((s) => s.status === "offline");
+}
+
+export function combineStates(a: WatchState, b: WatchState): WatchState<[A, B]> {
+ if (a.status === "denied" || b.status === "denied") return { status: "denied" };
+ if (a.status === "gone" || b.status === "gone") return { status: "gone" };
+ if (a.status === "error") return { status: "error", message: a.message };
+ if (b.status === "error") return { status: "error", message: b.message };
+ if (!isData(a) || !isData(b)) return { status: "loading" };
+ return { status: a.status === "offline" || b.status === "offline" ? "offline" : "ready", value: [a.value, b.value] };
+}
+```
+
+Create `web/src/records/join.ts`:
+
+```ts
+import type { CollectionState, Restaurant } from "./types";
+
+export interface RecordRow {
+ restaurant: Restaurant;
+ state: CollectionState | null;
+}
+
+export type RecordFilter = "shortlist" | "all";
+
+export function joinRecords(restaurants: Restaurant[], states: Record): RecordRow[] {
+ return restaurants.map((restaurant) => ({ restaurant, state: states[restaurant.id] ?? null }));
+}
+
+/** Deleting rows stay under both filters so an interrupted deletion can always be finished. */
+export function filterRows(rows: RecordRow[], filter: RecordFilter): RecordRow[] {
+ if (filter === "all") return rows;
+ return rows.filter((row) => row.restaurant.deleting || row.state?.shortlisted === true);
+}
+```
+
+Append to `web/src/records/messages.ts`:
+
+```ts
+/** The Saved page's "Finish deleting" outcomes (the parked Plan 2b resume wording). */
+export function finishOutcomeText(kind: WriteOutcome["kind"]): string {
+ switch (kind) {
+ case "ok": return "";
+ case "offline": return "You are offline. Connect, then tap Finish deleting.";
+ case "notFound": return "Already removed.";
+ case "permission": return outcomeMessage("permission", "This restaurant");
+ case "conflict":
+ case "failed": return "Could not finish deleting. Tap Finish deleting to try again.";
+ }
+}
+```
+
+- [ ] **Step 5: Implement the page**
+
+Replace `web/src/records/RestaurantsPage.tsx` with:
+
+```tsx
+import { useEffect, useRef, useState } from "react";
+import { Link } from "react-router";
+import { watchCollection } from "./collection";
+import { combineStates, isData } from "./combine";
+import { formatCalendarDate } from "./dates";
+import { filterRows, joinRecords, type RecordFilter } from "./join";
+import { deleteProgressText, finishOutcomeText } from "./messages";
+import { finishDeleting, watchRestaurants } from "./repository";
+import { ReadStateNotice } from "./ReadStateNotice";
+import type { CollectionState, Restaurant } from "./types";
+import { useMember } from "./useMember";
+import { useWatch } from "./useWatch";
+
+export function RestaurantsPage() {
+ const { householdId } = useMember();
+ const restaurants = useWatch((cb) => watchRestaurants(householdId, cb), [householdId]);
+ const states = useWatch>((cb) => watchCollection(householdId, cb), [householdId]);
+ const [filter, setFilter] = useState("shortlist");
+ const [progress, setProgress] = useState>({});
+ const resumed = useRef(new Set());
+ const combined = combineStates(restaurants.state, states.state);
+ const offline = combined.status === "offline";
+ const all = isData(combined) ? joinRecords(combined.value[0], combined.value[1]) : [];
+ const rows = filterRows(all, filter);
+
+ function retry() {
+ restaurants.retry();
+ states.retry();
+ }
+
+ async function finish(rid: string) {
+ setProgress((p) => ({ ...p, [rid]: deleteProgressText("sweeping") }));
+ const outcome = await finishDeleting(householdId, rid, (step) => setProgress((p) => ({ ...p, [rid]: deleteProgressText(step) })));
+ if (outcome.kind !== "ok") setProgress((p) => ({ ...p, [rid]: finishOutcomeText(outcome.kind) }));
+ }
+
+ // Resume interrupted deletions once per mount from the authoritative restaurant list alone,
+ // whatever the collection listener is doing (deletion protocol is resumable, spec §3.5/§3.7).
+ const rs = restaurants.state;
+ useEffect(() => {
+ if (rs.status !== "ready") return;
+ for (const r of rs.value) {
+ if (r.deleting && !resumed.current.has(r.id)) {
+ resumed.current.add(r.id);
+ void finish(r.id);
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [rs]);
+
+ return (
+
+ Saved
+ Our restaurant records.
+
+
+ { if (offline) e.preventDefault(); }}
+ className={offline ? "disabled-link" : undefined}
+ >
+ Add restaurant
+
+
+
+
+
+
+ {combined.status === "ready" && all.length === 0 && No restaurants yet. Add the first one.
}
+ {combined.status === "ready" && all.length > 0 && rows.length === 0 && (
+ Nothing on the shortlist. Open a record and tap Add to shortlist.
+ )}
+ {rows.length > 0 && (
+
+ )}
+
+ );
+}
+```
+
+Append to `web/src/styles.css`:
+
+```css
+.filter { display: flex; gap: 0.5rem; }
+.filter button[aria-pressed="true"] { font-weight: 600; text-decoration: underline; }
+.labels { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.3rem; }
+.label { font-size: 0.8rem; border: 1px solid #8886; border-radius: 999px; padding: 0.05rem 0.5rem; }
+```
+
+- [ ] **Step 6: Update the two existing browser assertions**
+
+The Saved tab now opens on the shortlist, and those two scenarios' records are not shortlisted. In `web/e2e/records.spec.ts`:
+- In scenario 1, immediately before `await expect(page.getByTestId("restaurant-row")).toContainText("Da Marco");`, insert `await page.getByTestId("filter-all").click();`.
+- In scenario 7, immediately before `await expect(page.getByTestId("restaurant-row")).toContainText("Ava's place");`, insert the same line.
+
+Scenario 7's later `toHaveCount(0)` on the not-invited screen needs no change.
+
+- [ ] **Step 7: Run the gates**
+
+Run: `npm run typecheck && npm run test:unit` → PASS (previous + 4 combine + 2 join + 1 messages + 6 page).
+Run: `npm run emu:e2e` → 29 PASS.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add web/src/records/combine.ts web/src/records/combine.test.ts web/src/records/join.ts web/src/records/join.test.ts web/src/records/messages.ts web/src/records/messages.test.ts web/src/records/RestaurantsPage.tsx web/src/records/RestaurantsPage.test.tsx web/src/styles.css web/e2e/records.spec.ts
+git commit -m "feat(saved): shortlist filter, visited labels and joined read states
+
+Co-Authored-By: Claude Opus 5.5 (1M context) "
+```
+
+---
+### Task 5: Restaurant page — shortlist and visited status block, one offline notice
+
+**Files:**
+- Create: `web/src/records/StatusBlock.tsx`, `web/src/records/StatusBlock.test.tsx`
+- Modify: `web/src/records/messages.ts`, `web/src/records/messages.test.ts`
+- Modify: `web/src/records/RestaurantDetailPage.tsx`, `web/src/records/RestaurantDetailPage.test.tsx`
+
+**Interfaces:**
+- Consumes: `setShortlisted`, `setVisited`, `watchCollectionEntry` (`./collection`, Task 3); `isData`, `anyOffline` (`./combine`, Task 4); `validateVisitedOn` (Task 3); `useToday`, `formatCalendarDate`, `outcomeMessage`, `ReadStateNotice`.
+- Produces:
+ - `StatusBlock` props: `{ householdId: string; rid: string; author: Author; state: WatchState; disabled: boolean; onRetry: () => void }`
+ - testids: `status-block`, `shortlist-add`, `shortlist-state`, `shortlist-remove`, `visited-mark`, `visited-date`, `visited-save`, `visited-cancel`, `visited-error`, `visited-state`, `visited-change`, `visited-clear`, `status-changed-by`, `status-outcome` (`data-kind`)
+ - `messages.ts`: `statusOutcomeMessage(kind: WriteOutcome["kind"]): string`
+ - The detail page shows exactly one `read-offline` notice when any of its listeners is cache-backed.
+
+Controls are enabled only when the collection snapshot is `ready` (server-backed), the page is not offline, and no write is in flight. A missing document is "not shortlisted, not visited", base version 0.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `web/src/records/messages.test.ts` (merge `statusOutcomeMessage` into the import):
+
+```ts
+describe("statusOutcomeMessage", () => {
+ it("explains a lost race without asking for a draft reload", () => {
+ expect(statusOutcomeMessage("conflict")).toBe("Someone else changed this at the same moment. The current state is shown; try again if you still want the change.");
+ expect(statusOutcomeMessage("notFound")).toBe("This restaurant was deleted.");
+ expect(statusOutcomeMessage("offline")).toBe("You are offline. Connect and try again.");
+ });
+});
+```
+
+Create `web/src/records/StatusBlock.test.tsx`:
+
+```tsx
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { CollectionState } from "./types";
+import type { WatchState } from "./useWatch";
+
+const m = vi.hoisted(() => ({ setShortlisted: vi.fn(), setVisited: vi.fn() }));
+vi.mock("./collection", () => m);
+vi.mock("./useToday", () => ({ useToday: () => "2026-09-24" }));
+vi.mock("../auth/AuthProvider", () => ({ useAuth: () => ({ signOut: vi.fn() }) }));
+
+import { StatusBlock } from "./StatusBlock";
+
+const AVA = { uid: "ava-uid", displayName: "Ava" };
+const st = (over: Partial = {}): CollectionState => ({ shortlisted: false, visited: false, updatedBy: "bogdan-uid", updatedByName: "Bogdan", updatedAt: new Date(), version: 3, ...over });
+
+function renderBlock(state: WatchState, disabled = false) {
+ return render( {}} />);
+}
+
+beforeEach(() => {
+ m.setShortlisted.mockResolvedValue({ kind: "ok", value: 1 });
+ m.setVisited.mockResolvedValue({ kind: "ok", value: 1 });
+});
+afterEach(() => vi.clearAllMocks());
+
+describe("StatusBlock", () => {
+ it("a missing document from the server means not shortlisted, not visited, base version 0", async () => {
+ renderBlock({ status: "ready", value: null });
+ expect(screen.queryByTestId("status-changed-by")).toBeNull();
+ await userEvent.click(screen.getByTestId("shortlist-add"));
+ expect(m.setShortlisted).toHaveBeenCalledWith("home", "r1", AVA, 0, true);
+ });
+
+ it("shows the stored state and who changed it last; Remove uses the stored version", async () => {
+ renderBlock({ status: "ready", value: st({ shortlisted: true, visited: true, visitedOn: "2026-05-03" }) });
+ expect(screen.getByTestId("shortlist-state")).toHaveTextContent("On shortlist");
+ expect(screen.getByTestId("visited-state")).toHaveTextContent("Visited 3 May 2026");
+ expect(screen.getByTestId("status-changed-by")).toHaveTextContent("Last changed by Bogdan");
+ await userEvent.click(screen.getByTestId("shortlist-remove"));
+ expect(m.setShortlisted).toHaveBeenCalledWith("home", "r1", AVA, 3, false);
+ });
+
+ it("Mark visited defaults to today, refuses a future date, and saves a past one", async () => {
+ renderBlock({ status: "ready", value: st() });
+ await userEvent.click(screen.getByTestId("visited-mark"));
+ const input = screen.getByTestId("visited-date");
+ expect(input).toHaveValue("2026-09-24");
+ // jsdom sanitises partial date strings, so set the whole value at once.
+ fireEvent.change(input, { target: { value: "2026-09-30" } });
+ await userEvent.click(screen.getByTestId("visited-save"));
+ expect(screen.getByTestId("visited-error")).toHaveTextContent("The visit date can't be in the future.");
+ expect(m.setVisited).not.toHaveBeenCalled();
+ fireEvent.change(input, { target: { value: "2026-09-20" } });
+ await userEvent.click(screen.getByTestId("visited-save"));
+ expect(m.setVisited).toHaveBeenCalledWith("home", "r1", AVA, 3, "2026-09-20");
+ await waitFor(() => expect(screen.queryByTestId("visited-date")).toBeNull());
+ });
+
+ it("Change date starts from the stored date; Clear sends null", async () => {
+ renderBlock({ status: "ready", value: st({ visited: true, visitedOn: "2026-05-03" }) });
+ await userEvent.click(screen.getByTestId("visited-change"));
+ expect(screen.getByTestId("visited-date")).toHaveValue("2026-05-03");
+ await userEvent.click(screen.getByTestId("visited-cancel"));
+ await userEvent.click(screen.getByTestId("visited-clear"));
+ expect(m.setVisited).toHaveBeenCalledWith("home", "r1", AVA, 3, null);
+ });
+
+ it("a conflict shows the standard message and never retries by itself", async () => {
+ m.setShortlisted.mockResolvedValue({ kind: "conflict" });
+ renderBlock({ status: "ready", value: null });
+ await userEvent.click(screen.getByTestId("shortlist-add"));
+ await waitFor(() => expect(screen.getByTestId("status-outcome")).toHaveAttribute("data-kind", "conflict"));
+ expect(m.setShortlisted).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([
+ ["cached", { status: "offline", value: null } as const, false],
+ ["from a page that is offline", { status: "ready", value: null } as const, true],
+ ])("disables every control when the state is %s", (_label, state, disabled) => {
+ renderBlock(state, disabled);
+ expect(screen.getByTestId("shortlist-add")).toBeDisabled();
+ expect(screen.getByTestId("visited-mark")).toBeDisabled();
+ });
+
+ it("shows loading and errors instead of controls until the state is known", () => {
+ renderBlock({ status: "loading" });
+ expect(screen.queryByTestId("shortlist-add")).toBeNull();
+ expect(screen.getByTestId("read-loading")).toBeInTheDocument();
+ renderBlock({ status: "error", message: "boom" });
+ expect(screen.getByTestId("read-error")).toHaveTextContent("boom");
+ });
+});
+```
+
+In `web/src/records/RestaurantDetailPage.test.tsx`:
+
+(a) Add the collection mock and emitter. Change the hoisted mocks to include `watchCollectionEntry: vi.fn(), setShortlisted: vi.fn(), setVisited: vi.fn()`. Keep `vi.mock("./repository", () => m)` and add `vi.mock("./collection", () => m);`. Add
+
+```ts
+let emitState: (s: Snapshot) => void = () => {};
+```
+
+to the emitters. In `beforeEach` add the following. Existing tests assume an authoritative "nothing stored" state:
+
+```ts
+ m.watchCollectionEntry.mockImplementation((_h: string, _r: string, cb: (s: Snapshot) => void) => {
+ emitState = cb;
+ cb({ status: "ready", value: null });
+ return () => {};
+ });
+```
+
+Import `CollectionState` from `./types`.
+
+(b) Append:
+
+```ts
+describe("RestaurantDetailPage — status block", () => {
+ it("renders the status block with the stored state", () => {
+ renderPage();
+ act(() => {
+ emitRestaurant({ status: "ready", value: restaurant });
+ emitClaims({ status: "ready", value: [] });
+ emitState({ status: "ready", value: { shortlisted: true, visited: false, updatedBy: "bogdan-uid", updatedByName: "Bogdan", updatedAt: new Date(), version: 2 } });
+ });
+ expect(screen.getByTestId("shortlist-state")).toHaveTextContent("On shortlist");
+ });
+
+ it("shows one offline notice when only the collection state is cached", () => {
+ renderPage();
+ act(() => {
+ emitRestaurant({ status: "ready", value: restaurant });
+ emitClaims({ status: "ready", value: [] });
+ emitState({ status: "offline", value: null });
+ });
+ expect(screen.getAllByTestId("read-offline")).toHaveLength(1);
+ expect(screen.getByTestId("shortlist-add")).toBeDisabled();
+ expect(screen.getByTestId("add-evidence")).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("shows a collection error in the status block without hiding the evidence", () => {
+ renderPage();
+ act(() => {
+ emitRestaurant({ status: "ready", value: restaurant });
+ emitClaims({ status: "ready", value: [] });
+ emitState({ status: "error", message: "boom" });
+ });
+ expect(screen.getByTestId("status-block")).toHaveTextContent("boom");
+ expect(screen.getByTestId("evidence-gfMenu")).toBeInTheDocument();
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `npm --prefix web test -- messages StatusBlock RestaurantDetailPage`
+Expected: FAIL (`StatusBlock`, `statusOutcomeMessage` missing; the page has no status block).
+
+- [ ] **Step 3: Implement**
+
+Append to `web/src/records/messages.ts`:
+
+```ts
+/** Shortlist/visited writes: there is no draft to reload, the live state is already on screen. */
+export function statusOutcomeMessage(kind: WriteOutcome["kind"]): string {
+ switch (kind) {
+ case "conflict": return "Someone else changed this at the same moment. The current state is shown; try again if you still want the change.";
+ case "notFound": return "This restaurant was deleted.";
+ default: return outcomeMessage(kind, "This change");
+ }
+}
+```
+
+Create `web/src/records/StatusBlock.tsx`:
+
+```tsx
+import { useState } from "react";
+import { setShortlisted, setVisited } from "./collection";
+import { isData } from "./combine";
+import { formatCalendarDate } from "./dates";
+import { statusOutcomeMessage } from "./messages";
+import { ReadStateNotice } from "./ReadStateNotice";
+import type { WriteOutcome } from "./repository";
+import type { Author, CollectionState } from "./types";
+import { useToday } from "./useToday";
+import type { WatchState } from "./useWatch";
+import { validateVisitedOn } from "./validation";
+
+interface Props {
+ householdId: string;
+ rid: string;
+ author: Author;
+ state: WatchState;
+ /** True when any listener on the page is cache-backed: writes need a connection. */
+ disabled: boolean;
+ onRetry: () => void;
+}
+
+/**
+ * Household shortlist and visited state for one restaurant (spec §3.7). Never computes or shows
+ * anything safety-related; visiting touches only the collection document.
+ */
+export function StatusBlock({ householdId, rid, author, state, disabled, onRetry }: Props) {
+ const today = useToday();
+ const [busy, setBusy] = useState(false);
+ const [outcome, setOutcome] = useState(null);
+ const [editingDate, setEditingDate] = useState(null);
+ const [dateError, setDateError] = useState(null);
+
+ if (!isData(state)) {
+ return (
+
+ );
+ }
+
+ const current = state.value;
+ // A missing or cached document is only a safe base when it came from the server.
+ const locked = disabled || busy || state.status !== "ready";
+ const base = current?.version ?? 0;
+
+ async function run(action: () => Promise>): Promise {
+ setBusy(true);
+ setOutcome(null);
+ const result = await action();
+ setBusy(false);
+ if (result.kind !== "ok") setOutcome(result.kind);
+ return result.kind === "ok";
+ }
+
+ async function saveDate() {
+ if (editingDate === null) return;
+ const problem = validateVisitedOn(editingDate, today);
+ setDateError(problem);
+ if (problem) return;
+ if (await run(() => setVisited(householdId, rid, author, base, editingDate))) setEditingDate(null);
+ }
+
+ return (
+
+
+ {current?.shortlisted ? (
+ <>
+ On shortlist
+
+ >
+ ) : (
+
+ )}
+
+
+ {editingDate === null && current?.visited && current.visitedOn && (
+ <>
+ Visited {formatCalendarDate(current.visitedOn)}
+
+
+ >
+ )}
+ {editingDate === null && !current?.visited && (
+
+ )}
+ {editingDate !== null && (
+ <>
+
+
+
+ {dateError && {dateError}}
+ >
+ )}
+
+ {current && Last changed by {current.updatedByName}
}
+ {outcome && {statusOutcomeMessage(outcome)}
}
+
+ );
+}
+```
+
+In `web/src/records/RestaurantDetailPage.tsx`:
+
+(a) Add imports:
+
+```ts
+import { watchCollectionEntry } from "./collection";
+import { anyOffline, isData } from "./combine";
+import { StatusBlock } from "./StatusBlock";
+```
+
+and change the types import to include `CollectionState`.
+
+(b) In `RestaurantDetailPage`, take `uid` and `displayName` from `useMember()` (`const { householdId, uid, displayName } = useMember();`). After `claimsWatch` add:
+
+```ts
+ const stateWatch = useWatch((cb) => watchCollectionEntry(householdId, rid!, cb), [householdId, rid]);
+```
+
+(c) Replace the lines from `const restaurant = rs.value;` through `const claimsReady = …;` (keeping `onDeleteClaim`) with:
+
+```ts
+ const restaurant = rs.value;
+ const ss = stateWatch.state;
+ // One notice for every cache-backed listener on the page (spec §3.7); writes need the server.
+ const offline = anyOffline(rs, cs, ss);
+ const claimsReady = isData(cs);
+ const claims = claimsReady ? cs.value : [];
+ const summary = summariseEvidence(claims, today);
+```
+
+Keep `async function onDeleteClaim` unchanged. Remove the now-duplicated earlier `offline`, `claims` and `claimsReady` declarations.
+
+(d) In the JSX, replace the first `` with:
+
+```tsx
+ {offline && }
+```
+
+and replace `{(!claimsReady || (cs.status === "offline" && rs.status !== "offline")) && }` with:
+
+```tsx
+ {!claimsReady && }
+```
+
+(e) Directly after the `…
` with the phone/website/maps/edit links, before `Evidence
`, insert:
+
+```tsx
+
+```
+
+- [ ] **Step 4: Run the gates**
+
+Run: `npm run typecheck && npm run test:unit` → PASS (previous + 1 messages + 8 StatusBlock (7 tests, one `it.each` with two rows) + 3 detail page). The existing detail-page offline-notice matrix (6 rows) still passes: exactly one `read-offline`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/src/records/StatusBlock.tsx web/src/records/StatusBlock.test.tsx web/src/records/messages.ts web/src/records/messages.test.ts web/src/records/RestaurantDetailPage.tsx web/src/records/RestaurantDetailPage.test.tsx
+git commit -m "feat(records): shortlist and visited controls on the restaurant page
+
+Co-Authored-By: Claude Opus 5.5 (1M context) "
+```
+
+---
+### Task 6: Notes on the restaurant page; deletion wording in the form
+
+**Files:**
+- Create: `web/src/records/NotesSection.tsx`, `web/src/records/NotesSection.test.tsx`
+- Modify: `web/src/records/RestaurantDetailPage.tsx`, `web/src/records/RestaurantDetailPage.test.tsx`
+- Modify: `web/src/records/RestaurantFormPage.tsx`, `web/src/records/RestaurantFormPage.test.tsx`
+- Modify: `web/src/styles.css`
+
+**Interfaces:**
+- Consumes: `watchNotes`, `addNote`, `updateNote`, `deleteNote` (`./notes`, Task 3); `validateNoteText`, `LIMITS` (Task 3/1); `isData`, `anyOffline` (Task 4); `outcomeMessage`.
+- Produces:
+ - `NotesSection` props: `{ householdId: string; rid: string; author: Author; state: WatchState; disabled: boolean; onRetry: () => void }`
+ - testids: `notes-section`, `notes-empty`, `note-add-text`, `note-add-counter`, `note-add-save`, `note-add-error`, `note-add-outcome`; per note `note-{id}` (`data-version`), `note-edited-{id}`, `note-edit-{id}`, `note-edit-text-{id}`, `note-save-{id}`, `note-cancel-{id}`, `note-error-{id}`, `note-conflict-{id}`, `note-conflict-current-{id}`, `note-keep-mine-{id}`, `note-use-theirs-{id}`, `note-delete-{id}`, `note-delete-confirm-{id}`, `note-delete-cancel-{id}`, `note-outcome-{id}` (`data-kind`)
+ - Form: the delete confirmation reads "Deletes the restaurant, its evidence, and both members' notes." A failed delete uses the delete verb.
+
+Rules for the UI (spec §3.7):
+- Edit and Delete appear only on the caller's own notes.
+- A pending delete confirmation is bound to the note's id and version. It resets when that note changes.
+- A failed add or save keeps the draft.
+- An edit conflict shows the current server text beside the draft. "Keep mine" writes with the version shown; "Use theirs" drops the draft.
+
+- [ ] **Step 1: Write the failing `NotesSection` tests**
+
+Create `web/src/records/NotesSection.test.tsx`:
+
+```tsx
+import { act, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { Note } from "./types";
+import type { WatchState } from "./useWatch";
+
+const m = vi.hoisted(() => ({ addNote: vi.fn(), updateNote: vi.fn(), deleteNote: vi.fn() }));
+vi.mock("./notes", () => m);
+vi.mock("../auth/AuthProvider", () => ({ useAuth: () => ({ signOut: vi.fn() }) }));
+
+import { NotesSection } from "./NotesSection";
+
+const AVA = { uid: "ava-uid", displayName: "Ava" };
+const note = (over: Partial & Pick): Note => ({ text: "Staff were careful", authorUid: "ava-uid", authorName: "Ava", createdAt: new Date("2026-09-01T10:00:00Z"), updatedAt: new Date("2026-09-01T10:00:00Z"), version: 1, ...over });
+
+function renderSection(state: WatchState, disabled = false) {
+ const view = render( {}} />);
+ return {
+ ...view,
+ update: (next: WatchState) => view.rerender( {}} />),
+ };
+}
+
+beforeEach(() => {
+ m.addNote.mockResolvedValue({ kind: "ok", value: "new" });
+ m.updateNote.mockResolvedValue({ kind: "ok", value: 2 });
+ m.deleteNote.mockResolvedValue({ kind: "ok", value: undefined });
+});
+afterEach(() => vi.clearAllMocks());
+
+describe("NotesSection", () => {
+ it("says notes are not evidence, lists notes with authors, and offers controls only on the member's own", () => {
+ renderSection({ status: "ready", value: [note({ id: "a" }), note({ id: "b", authorUid: "bogdan-uid", authorName: "Bogdan", version: 2 })] });
+ expect(screen.getByTestId("notes-section")).toHaveTextContent("Personal notes. They are not evidence and don't change any checked date.");
+ expect(screen.getByTestId("note-b")).toHaveTextContent("Bogdan");
+ expect(screen.getByTestId("note-edited-b")).toBeInTheDocument();
+ expect(screen.queryByTestId("note-edited-a")).toBeNull();
+ expect(screen.getByTestId("note-edit-a")).toBeInTheDocument();
+ expect(screen.queryByTestId("note-edit-b")).toBeNull();
+ expect(screen.queryByTestId("note-delete-b")).toBeNull();
+ });
+
+ it("shows the empty state only from the server", () => {
+ const view = renderSection({ status: "offline", value: [] });
+ expect(screen.queryByTestId("notes-empty")).toBeNull();
+ view.update({ status: "ready", value: [] });
+ expect(screen.getByTestId("notes-empty")).toHaveTextContent("No notes yet.");
+ });
+
+ it("adds a trimmed note, refuses blank text, and keeps the draft when saving fails", async () => {
+ renderSection({ status: "ready", value: [] });
+ await userEvent.click(screen.getByTestId("note-add-save"));
+ expect(screen.getByTestId("note-add-error")).toHaveTextContent("Write something first.");
+ expect(m.addNote).not.toHaveBeenCalled();
+ await userEvent.type(screen.getByTestId("note-add-text"), " Asked about the fryer ");
+ expect(screen.getByTestId("note-add-counter")).toHaveTextContent("21 / 2000");
+ m.addNote.mockResolvedValueOnce({ kind: "offline" });
+ await userEvent.click(screen.getByTestId("note-add-save"));
+ await waitFor(() => expect(screen.getByTestId("note-add-outcome")).toHaveAttribute("data-kind", "offline"));
+ expect(screen.getByTestId("note-add-text")).toHaveValue(" Asked about the fryer ");
+ await userEvent.click(screen.getByTestId("note-add-save"));
+ expect(m.addNote).toHaveBeenLastCalledWith("home", "r1", AVA, "Asked about the fryer");
+ await waitFor(() => expect(screen.getByTestId("note-add-text")).toHaveValue(""));
+ });
+
+ it("edits with the version the member started from", async () => {
+ renderSection({ status: "ready", value: [note({ id: "a", version: 4 })] });
+ await userEvent.click(screen.getByTestId("note-edit-a"));
+ const box = screen.getByTestId("note-edit-text-a");
+ await userEvent.clear(box);
+ await userEvent.type(box, "Went back, still careful");
+ await userEvent.click(screen.getByTestId("note-save-a"));
+ expect(m.updateNote).toHaveBeenCalledWith("home", "r1", "a", 4, "Went back, still careful");
+ await waitFor(() => expect(screen.queryByTestId("note-edit-text-a")).toBeNull());
+ });
+
+ it("an edit conflict shows the current text beside the draft; Keep mine writes with the version shown", async () => {
+ const view = renderSection({ status: "ready", value: [note({ id: "a", version: 1 })] });
+ await userEvent.click(screen.getByTestId("note-edit-a"));
+ await userEvent.clear(screen.getByTestId("note-edit-text-a"));
+ await userEvent.type(screen.getByTestId("note-edit-text-a"), "My draft");
+ view.update({ status: "ready", value: [note({ id: "a", version: 2, text: "Changed on the phone" })] });
+ m.updateNote.mockResolvedValueOnce({ kind: "conflict" });
+ await userEvent.click(screen.getByTestId("note-save-a"));
+ await waitFor(() => expect(screen.getByTestId("note-conflict-a")).toBeInTheDocument());
+ expect(screen.getByTestId("note-conflict-current-a")).toHaveTextContent("Changed on the phone");
+ expect(screen.getByTestId("note-edit-text-a")).toHaveValue("My draft");
+ await userEvent.click(screen.getByTestId("note-keep-mine-a"));
+ expect(m.updateNote).toHaveBeenLastCalledWith("home", "r1", "a", 2, "My draft");
+ });
+
+ it("Use theirs drops the draft", async () => {
+ const view = renderSection({ status: "ready", value: [note({ id: "a" })] });
+ await userEvent.click(screen.getByTestId("note-edit-a"));
+ view.update({ status: "ready", value: [note({ id: "a", version: 2, text: "Theirs" })] });
+ m.updateNote.mockResolvedValueOnce({ kind: "conflict" });
+ await userEvent.click(screen.getByTestId("note-save-a"));
+ await userEvent.click(await screen.findByTestId("note-use-theirs-a"));
+ expect(screen.queryByTestId("note-edit-text-a")).toBeNull();
+ expect(screen.getByTestId("note-a")).toHaveTextContent("Theirs");
+ });
+
+ it("delete needs a confirmation bound to the version shown, and deletes that version", async () => {
+ const view = renderSection({ status: "ready", value: [note({ id: "a", version: 1 })] });
+ await userEvent.click(screen.getByTestId("note-delete-a"));
+ expect(m.deleteNote).not.toHaveBeenCalled();
+ act(() => view.update({ status: "ready", value: [note({ id: "a", version: 2, text: "Edited elsewhere" })] }));
+ expect(screen.queryByTestId("note-delete-confirm-a")).toBeNull();
+ await userEvent.click(screen.getByTestId("note-delete-a"));
+ await userEvent.click(screen.getByTestId("note-delete-confirm-a"));
+ expect(m.deleteNote).toHaveBeenCalledWith("home", "r1", "a", 2);
+ });
+
+ it("disables adding, editing and deleting while the page is offline", () => {
+ renderSection({ status: "offline", value: [note({ id: "a" })] }, true);
+ expect(screen.getByTestId("note-add-save")).toBeDisabled();
+ expect(screen.getByTestId("note-edit-a")).toBeDisabled();
+ expect(screen.getByTestId("note-delete-a")).toBeDisabled();
+ });
+
+ it("shows loading and errors from the notes listener", () => {
+ renderSection({ status: "error", message: "boom" });
+ expect(screen.getByTestId("read-error")).toHaveTextContent("boom");
+ });
+});
+```
+
+- [ ] **Step 2: Write the failing page and form tests**
+
+In `web/src/records/RestaurantDetailPage.test.tsx`: add `watchNotes: vi.fn(), addNote: vi.fn(), updateNote: vi.fn(), deleteNote: vi.fn()` to the hoisted `m`, add `vi.mock("./notes", () => m);`, and add an emitter plus a `beforeEach` default (an authoritative empty list):
+
+```ts
+let emitNotes: (s: Snapshot) => void = () => {};
+```
+
+```ts
+ m.watchNotes.mockImplementation((_h: string, _r: string, cb: (s: Snapshot) => void) => {
+ emitNotes = cb;
+ cb({ status: "ready", value: [] });
+ return () => {};
+ });
+```
+
+Import `Note` from `./types`. Append:
+
+```ts
+describe("RestaurantDetailPage — notes", () => {
+ it("places notes between the evidence and the call-ahead prompts", () => {
+ renderPage();
+ act(() => {
+ emitRestaurant({ status: "ready", value: restaurant });
+ emitClaims({ status: "ready", value: [] });
+ emitNotes({ status: "ready", value: [{ id: "n1", text: "Asked twice, confident answers", authorUid: "bogdan-uid", authorName: "Bogdan", createdAt: new Date(), updatedAt: new Date(), version: 1 }] });
+ });
+ const notes = screen.getByTestId("notes-section");
+ expect(notes).toHaveTextContent("Asked twice, confident answers");
+ const evidence = screen.getByTestId("evidence-gfMenu");
+ const callAhead = screen.getByTestId("call-ahead");
+ expect(evidence.compareDocumentPosition(notes) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(notes.compareDocumentPosition(callAhead) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
+ it("counts cached notes in the single offline notice", () => {
+ renderPage();
+ act(() => {
+ emitRestaurant({ status: "ready", value: restaurant });
+ emitClaims({ status: "ready", value: [] });
+ emitNotes({ status: "offline", value: [] });
+ });
+ expect(screen.getAllByTestId("read-offline")).toHaveLength(1);
+ expect(screen.getByTestId("note-add-save")).toBeDisabled();
+ });
+});
+```
+
+In `web/src/records/RestaurantFormPage.test.tsx`, append inside the existing top-level `describe` that holds the delete tests:
+
+```ts
+ it("names notes in the delete confirmation and reports a failed delete with the delete verb", async () => {
+ m.deleteRestaurant.mockResolvedValue({ kind: "failed", message: "x" });
+ renderAt("/restaurants/r1/edit");
+ act(() => emit({ status: "ready", value: stored }));
+ await userEvent.click(screen.getByTestId("delete-restaurant"));
+ expect(screen.getByTestId("delete-question")).toHaveTextContent("Deletes the restaurant, its evidence, and both members' notes.");
+ await userEvent.click(screen.getByTestId("delete-confirm"));
+ await waitFor(() => expect(screen.getByTestId("save-outcome")).toHaveTextContent("Could not delete this restaurant. Try again."));
+ });
+```
+
+- [ ] **Step 3: Run to verify they fail**
+
+Run: `npm --prefix web test -- NotesSection RestaurantDetailPage RestaurantFormPage`
+Expected: FAIL.
+
+- [ ] **Step 4: Implement `NotesSection`**
+
+Create `web/src/records/NotesSection.tsx`:
+
+```tsx
+import { useState } from "react";
+import { isData } from "./combine";
+import { outcomeMessage } from "./messages";
+import { addNote, deleteNote, updateNote } from "./notes";
+import { ReadStateNotice } from "./ReadStateNotice";
+import type { WriteOutcome } from "./repository";
+import type { Author, Note } from "./types";
+import type { WatchState } from "./useWatch";
+import { LIMITS, validateNoteText } from "./validation";
+
+interface Props {
+ householdId: string;
+ rid: string;
+ author: Author;
+ state: WatchState;
+ /** True when any listener on the page is cache-backed: writes need a connection. */
+ disabled: boolean;
+ onRetry: () => void;
+}
+
+const DATE = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric" });
+
+function noteMessage(kind: WriteOutcome["kind"], adding: boolean): string {
+ if (kind === "notFound") return adding ? "This restaurant was deleted." : "This note was deleted.";
+ if (kind === "conflict") return "This note changed on another device.";
+ return outcomeMessage(kind, "This note");
+}
+
+/** Personal notes (spec §3.7). Never evidence: no kinds, no dates that feed evidence status. */
+export function NotesSection({ householdId, rid, author, state, disabled, onRetry }: Props) {
+ return (
+
+ Our notes
+ Personal notes. They are not evidence and don't change any checked date.
+ {!isData(state) && }
+
+ {state.status === "ready" && state.value.length === 0 && No notes yet.
}
+ {isData(state) &&
+ state.value.map((n) => (
+
+ ))}
+
+ );
+}
+
+function NoteComposer({ householdId, rid, author, disabled }: { householdId: string; rid: string; author: Author; disabled: boolean }) {
+ const [text, setText] = useState("");
+ const [error, setError] = useState(null);
+ const [outcome, setOutcome] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ async function save() {
+ const problem = validateNoteText(text);
+ setError(problem);
+ setOutcome(null);
+ if (problem) return;
+ setBusy(true);
+ const result = await addNote(householdId, rid, author, text.trim());
+ setBusy(false);
+ if (result.kind === "ok") {
+ setText("");
+ return;
+ }
+ setOutcome(result.kind); // the draft stays in the box
+ }
+
+ return (
+
+
+
{text.trim().length} / {LIMITS.note}
+
+ {error &&
{error}}
+ {outcome &&
{noteMessage(outcome, true)}
}
+
+ );
+}
+
+function NoteCard({ householdId, rid, note, own, disabled }: { householdId: string; rid: string; note: Note; own: boolean; disabled: boolean }) {
+ const [editing, setEditing] = useState<{ draft: string; baseVersion: number } | null>(null);
+ const [conflict, setConflict] = useState(false);
+ // The version a pending delete confirmation belongs to (audit clarification, as claims in 37cc46b).
+ const [confirming, setConfirming] = useState(null);
+ const [outcome, setOutcome] = useState(null);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ if (confirming !== null && confirming !== note.version) setConfirming(null);
+
+ async function save(baseVersion: number) {
+ if (!editing) return;
+ const problem = validateNoteText(editing.draft);
+ setError(problem);
+ setOutcome(null);
+ if (problem) return;
+ setBusy(true);
+ const result = await updateNote(householdId, rid, note.id, baseVersion, editing.draft.trim());
+ setBusy(false);
+ if (result.kind === "ok") {
+ setEditing(null);
+ setConflict(false);
+ return;
+ }
+ if (result.kind === "conflict") {
+ setConflict(true);
+ return;
+ }
+ setOutcome(result.kind); // the draft stays open
+ }
+
+ async function confirmDelete() {
+ if (confirming === null) return;
+ setBusy(true);
+ const result = await deleteNote(householdId, rid, note.id, confirming);
+ setBusy(false);
+ if (result.kind !== "ok") {
+ setConfirming(null);
+ setOutcome(result.kind);
+ }
+ }
+
+ function stopEditing() {
+ setEditing(null);
+ setConflict(false);
+ setError(null);
+ }
+
+ return (
+
+ {!editing && {note.text}
}
+
+ {note.authorName} · {DATE.format(note.createdAt)}
+ {note.version > 1 && · edited}
+
+ {editing && (
+
+ )}
+ {own && !editing && (
+
+ {confirming === null ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+ Delete this note?
+
+
+ >
+ )}
+
+ )}
+ {outcome && {noteMessage(outcome, false)}
}
+
+ );
+}
+```
+
+Append to `web/src/styles.css`:
+
+```css
+.notes textarea { width: 100%; box-sizing: border-box; font: inherit; }
+.note-composer { display: grid; gap: 0.4rem; margin-bottom: var(--gap); }
+.note { margin-top: 0.5rem; }
+.note-text { white-space: pre-wrap; }
+.note blockquote { margin: 0.4rem 0; padding-left: 0.6rem; border-left: 3px solid #8886; white-space: pre-wrap; }
+```
+
+- [ ] **Step 5: Wire notes into the page; fix the form wording**
+
+In `web/src/records/RestaurantDetailPage.tsx`:
+- Import `watchNotes` from `"./notes"` and `NotesSection` from `"./NotesSection"`, and add `Note` to the types import.
+- After `stateWatch` add `const notesWatch = useWatch((cb) => watchNotes(householdId, rid!, cb), [householdId, rid]);`
+- Change the offline line to `const offline = anyOffline(rs, cs, ss, notesWatch.state);`
+- Directly before ``, insert:
+
+```tsx
+
+```
+
+In `web/src/records/RestaurantFormPage.tsx`:
+- Add `const [outcomeVerb, setOutcomeVerb] = useState<"save" | "delete">("save");`.
+- In `onSubmit`, next to `setOutcome(null);`, add `setOutcomeVerb("save");`. In `onDelete`, before `setOutcome(result.kind);`, add `setOutcomeVerb("delete");`.
+- Change `{outcomeMessage(outcome, "This restaurant")}` to `{outcomeMessage(outcome, "This restaurant", outcomeVerb)}`.
+- Replace `Delete this restaurant and all of its evidence?` with `Deletes the restaurant, its evidence, and both members' notes.`.
+
+- [ ] **Step 6: Run the gates**
+
+Run: `npm run typecheck && npm run test:unit` → PASS (previous + 9 NotesSection + 2 detail + 1 form).
+Run: `npm run emu:e2e` → 29 PASS. The records scenarios use `delete-confirm`, which is unchanged.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add web/src/records/NotesSection.tsx web/src/records/NotesSection.test.tsx web/src/records/RestaurantDetailPage.tsx web/src/records/RestaurantDetailPage.test.tsx web/src/records/RestaurantFormPage.tsx web/src/records/RestaurantFormPage.test.tsx web/src/styles.css
+git commit -m "feat(records): authored notes on the restaurant page
+
+Co-Authored-By: Claude Opus 5.5 (1M context) "
+```
+
+---
+### Task 7: Account change resets every tab
+
+**Files:**
+- Create: `web/src/auth/resetDocument.ts`
+- Modify: `web/src/auth/AuthProvider.tsx`, `web/src/auth/AuthProvider.test.tsx`
+- Modify: `web/src/App.tsx`
+
+**Interfaces:**
+- Consumes: Firebase `onAuthStateChanged` (cross-tab synchronised by the default browser persistence).
+- Produces:
+ - `resetDocument(): void` → `window.location.replace("/")`
+ - `AuthState` gains `{ status: "resetting" }`. `Gate` renders `Signing out…
` for it.
+ - `signOut()` only calls Firebase sign-out. The listener performs the reset in every document, the initiating one included.
+
+Contract (spec §3.7 "Account switch", audit F2):
+- `lastUid` is the last signed-in UID observed *in this document*.
+- When the listener reports `null` or a different UID while `lastUid` is set:
+ - bump the generation counter, so pending membership lookups are void;
+ - set `resetting`, which hides the member UI immediately;
+ - call `resetDocument()`.
+- No reset when the document starts signed out, or when a callback repeats the same UID.
+
+- [ ] **Step 1: Write the failing tests**
+
+In `web/src/auth/AuthProvider.test.tsx`:
+
+(a) Add a hoisted `resetDocument` mock and module mock:
+
+```ts
+const { resetDocument } = vi.hoisted(() => ({ resetDocument: vi.fn() }));
+vi.mock("./resetDocument", () => ({ resetDocument }));
+```
+
+and add `resetDocument.mockReset();` to `beforeEach`.
+
+(b) Two existing tests now describe resets. Replace `"ignores a slow membership lookup that finishes after the user signed out"` with:
+
+```ts
+ it("a sign-out after a sign-in resets the document and a slow lookup never lands", async () => {
+ const slowUserDoc = deferred>();
+ getDocMock.mockImplementation((path: string) => {
+ if (path === "users/ava-uid") return slowUserDoc.promise;
+ if (path === "households/home") return Promise.resolve(snap({ name: "Home", memberIds: ["ava-uid"] }));
+ return Promise.resolve(snap(undefined));
+ });
+ render();
+ listeners[0]({ uid: "ava-uid", email: "ava@safebite.test" });
+ listeners[0](null);
+ await waitFor(() => expect(screen.getByTestId("state")).toHaveTextContent('"resetting"'));
+ expect(resetDocument).toHaveBeenCalledTimes(1);
+ slowUserDoc.resolve(snap({ householdId: "home", displayName: "Ava" }));
+ await new Promise((r) => setTimeout(r, 20));
+ expect(screen.getByTestId("state")).toHaveTextContent('"resetting"');
+ });
+```
+
+and replace `"never lets an earlier user's lookup overwrite a later user's state"` with:
+
+```ts
+ it("a different user in the same document resets it; the earlier lookup never lands", async () => {
+ const slowUserDoc = deferred>();
+ getDocMock.mockImplementation((path: string) => {
+ if (path === "users/slow-uid") return slowUserDoc.promise;
+ return Promise.resolve(snap(undefined));
+ });
+ render();
+ listeners[0]({ uid: "slow-uid", email: "slow@safebite.test" });
+ listeners[0]({ uid: "fast-uid", email: "fast@safebite.test" });
+ await waitFor(() => expect(screen.getByTestId("state")).toHaveTextContent('"resetting"'));
+ expect(resetDocument).toHaveBeenCalledTimes(1);
+ slowUserDoc.resolve(snap({ householdId: "home", displayName: "Slow" }));
+ await new Promise((r) => setTimeout(r, 20));
+ expect(screen.getByTestId("state")).toHaveTextContent('"resetting"');
+ });
+```
+
+(c) Append:
+
+```ts
+ it("never resets a document that starts signed out, then signs in", async () => {
+ getDocMock.mockResolvedValue(snap(undefined));
+ render();
+ listeners[0](null);
+ listeners[0]({ uid: "ava-uid", email: "ava@safebite.test" });
+ await waitFor(() => expect(screen.getByTestId("state")).toHaveTextContent('"notMember"'));
+ expect(resetDocument).not.toHaveBeenCalled();
+ });
+
+ it("never resets on a repeated callback for the same user", async () => {
+ getDocMock.mockResolvedValue(snap(undefined));
+ render();
+ listeners[0]({ uid: "ava-uid", email: "ava@safebite.test" });
+ listeners[0]({ uid: "ava-uid", email: "ava@safebite.test" });
+ await waitFor(() => expect(screen.getByTestId("state")).toHaveTextContent('"notMember"'));
+ expect(resetDocument).not.toHaveBeenCalled();
+ });
+
+ it("signOut only signs out; the reset comes from the listener", async () => {
+ const { signOut: firebaseSignOut } = await import("firebase/auth");
+ function SignOutButton() {
+ const { signOut } = useAuth();
+ return ;
+ }
+ render();
+ screen.getByText("out").click();
+ await waitFor(() => expect(firebaseSignOut).toHaveBeenCalledTimes(1));
+ expect(resetDocument).not.toHaveBeenCalled();
+ });
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `npm --prefix web test -- AuthProvider`
+Expected: FAIL (no `resetting`, no `resetDocument`).
+
+- [ ] **Step 3: Implement**
+
+Create `web/src/auth/resetDocument.ts`:
+
+```ts
+/**
+ * Full reload to the app root (spec §3.7 "Account switch"). Discards every Firestore listener,
+ * the Firestore memory cache and all React state in this tab. A module of its own so tests can
+ * replace it (jsdom does not implement navigation).
+ */
+export function resetDocument(): void {
+ window.location.replace("/");
+}
+```
+
+In `web/src/auth/AuthProvider.tsx`:
+- Add `| { status: "resetting" }` to `AuthState` (after `loading`).
+- Import `resetDocument` from `"./resetDocument"`.
+- In `AuthProvider`, add `const lastUidRef = useRef(null);` next to `generationRef`.
+- Replace the body of the `onAuthStateChanged` callback with:
+
+```ts
+ const unsubscribe = onAuthStateChanged(auth, (user) => {
+ const previous = lastUidRef.current;
+ // Auth state is shared by every same-origin tab (audit F2): whichever tab signed out or
+ // switched user, each document that had a user resets itself. Starting signed out, or a
+ // repeat of the same UID, is not a change.
+ if (previous !== null && (user === null || user.uid !== previous)) {
+ generationRef.current += 1;
+ setState({ status: "resetting" });
+ resetDocument();
+ return;
+ }
+ if (!user) {
+ generationRef.current += 1;
+ setState({ status: "signedOut" });
+ return;
+ }
+ lastUidRef.current = user.uid;
+ resolveForUser(user);
+ });
+```
+
+`signOut` stays `await firebaseSignOut(auth);`. Leave its body unchanged and add the comment `// The listener resets this document (and every other tab) when the user becomes null.`
+
+In `web/src/App.tsx`, add to `Gate`'s switch:
+
+```tsx
+ case "resetting":
+ return Signing out…
;
+```
+
+- [ ] **Step 4: Run the gates**
+
+Run: `npm run typecheck && npm run test:unit` → PASS (previous + 3 new; two tests rewritten).
+Run: `npm run emu:e2e` → 29 PASS. Sign-out now reloads the page. `signOutAndWait` waits for `signin-form`, which the reloaded document renders.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/src/auth/resetDocument.ts web/src/auth/AuthProvider.tsx web/src/auth/AuthProvider.test.tsx web/src/App.tsx
+git commit -m "feat(auth): reset every tab when its user signs out or changes
+
+Co-Authored-By: Claude Opus 5.5 (1M context) "
+```
+
+---
+
+### Task 8: Change password in Settings
+
+**Files:**
+- Create: `web/src/auth/changePassword.ts`, `web/src/auth/changePassword.test.ts`
+- Create: `web/src/pages/ChangePasswordForm.tsx`, `web/src/pages/ChangePasswordForm.test.tsx`
+- Modify: `web/src/pages/SettingsPage.tsx`, `web/src/pages/SettingsPage.test.tsx`
+
+**Interfaces:**
+- Consumes: `auth` (`../firebase`); `EmailAuthProvider`, `reauthenticateWithCredential`, `updatePassword` (`firebase/auth`).
+- Produces:
+ - `type ChangePasswordResult = "ok" | "wrongCurrent" | "tooManyRequests" | "offline" | "policy" | "recentLogin" | "failed"`
+ - `MIN_PASSWORD_LENGTH = 8`
+ - `validateNewPassword(next: string, confirm: string): string | null`
+ - `changePassword(current: string, next: string): Promise`
+ - `CHANGE_PASSWORD_MESSAGES: Record, string>`
+ - testids: `pw-form`, `pw-current`, `pw-new`, `pw-confirm`, `pw-submit`, `pw-error`, `pw-outcome` (`data-kind`), `pw-success`
+
+The message table is spec §3.7 (amended after audit F3), verbatim. `updatePassword` is never called after a failed reauthentication. "Password changed" appears only after `updatePassword` resolves.
+
+- [ ] **Step 1: Write the failing module tests**
+
+Create `web/src/auth/changePassword.test.ts`:
+
+```ts
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const f = vi.hoisted(() => ({
+ reauthenticateWithCredential: vi.fn(),
+ updatePassword: vi.fn(),
+ credential: vi.fn((email: string, password: string) => ({ email, password })),
+ currentUser: { email: "ava@safebite.test" } as { email: string | null } | null,
+}));
+vi.mock("../firebase", () => ({
+ get auth() {
+ return { currentUser: f.currentUser };
+ },
+}));
+vi.mock("firebase/auth", () => ({
+ EmailAuthProvider: { credential: f.credential },
+ reauthenticateWithCredential: f.reauthenticateWithCredential,
+ updatePassword: f.updatePassword,
+}));
+
+import { CHANGE_PASSWORD_MESSAGES, changePassword, validateNewPassword } from "./changePassword";
+
+const err = (code: string) => Object.assign(new Error(code), { code });
+
+beforeEach(() => {
+ Object.defineProperty(navigator, "onLine", { configurable: true, value: true });
+ f.currentUser = { email: "ava@safebite.test" };
+ f.reauthenticateWithCredential.mockResolvedValue({});
+ f.updatePassword.mockResolvedValue(undefined);
+});
+afterEach(() => vi.clearAllMocks());
+
+describe("validateNewPassword", () => {
+ it("needs at least 8 characters and a matching confirmation", () => {
+ expect(validateNewPassword("short", "short")).toBe("Use at least 8 characters.");
+ expect(validateNewPassword("long-enough", "long-enougH")).toBe("The two new passwords don't match.");
+ expect(validateNewPassword("long-enough", "long-enough")).toBeNull();
+ });
+});
+
+describe("changePassword", () => {
+ it("reauthenticates with the current password, then updates", async () => {
+ expect(await changePassword("old-password", "new-password")).toBe("ok");
+ expect(f.credential).toHaveBeenCalledWith("ava@safebite.test", "old-password");
+ expect(f.updatePassword).toHaveBeenCalledWith({ email: "ava@safebite.test" }, "new-password");
+ });
+
+ it.each([
+ ["auth/invalid-credential", "wrongCurrent"],
+ ["auth/wrong-password", "wrongCurrent"],
+ ["auth/too-many-requests", "tooManyRequests"],
+ ["auth/network-request-failed", "offline"],
+ ["auth/internal-error", "failed"],
+ ])("a failed reauthentication (%s) is %s and never attempts the update", async (code, result) => {
+ f.reauthenticateWithCredential.mockRejectedValue(err(code));
+ expect(await changePassword("old-password", "new-password")).toBe(result);
+ expect(f.updatePassword).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["auth/weak-password", "policy"],
+ ["auth/password-does-not-meet-requirements", "policy"],
+ ["auth/requires-recent-login", "recentLogin"],
+ ["auth/too-many-requests", "tooManyRequests"],
+ ["auth/network-request-failed", "offline"],
+ ["something/unexpected", "failed"],
+ ])("a rejected update (%s) is %s", async (code, result) => {
+ f.updatePassword.mockRejectedValue(err(code));
+ expect(await changePassword("old-password", "new-password")).toBe(result);
+ });
+
+ it("is offline without calling Firebase when the browser is offline, and failed without a signed-in email", async () => {
+ Object.defineProperty(navigator, "onLine", { configurable: true, value: false });
+ expect(await changePassword("a", "b")).toBe("offline");
+ Object.defineProperty(navigator, "onLine", { configurable: true, value: true });
+ f.currentUser = null;
+ expect(await changePassword("a", "b")).toBe("failed");
+ expect(f.reauthenticateWithCredential).not.toHaveBeenCalled();
+ });
+
+ it("has a message for every failure", () => {
+ expect(CHANGE_PASSWORD_MESSAGES).toEqual({
+ wrongCurrent: "That isn't your current password.",
+ tooManyRequests: "Too many attempts. Wait a few minutes and try again.",
+ offline: "You are offline. Connect and try again.",
+ policy: "Your new password doesn't meet this account's password rules. Choose a different one.",
+ recentLogin: "For security, sign out and back in, then try again.",
+ failed: "Couldn't change your password. Your old password still works.",
+ });
+ });
+});
+```
+
+- [ ] **Step 2: Write the failing form tests**
+
+Create `web/src/pages/ChangePasswordForm.test.tsx`:
+
+```tsx
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const { changePassword } = vi.hoisted(() => ({ changePassword: vi.fn() }));
+vi.mock("../auth/changePassword", async (importOriginal) => ({
+ ...(await importOriginal()),
+ changePassword,
+}));
+vi.mock("../firebase", () => ({ auth: {} }));
+
+import { ChangePasswordForm } from "./ChangePasswordForm";
+
+afterEach(() => vi.clearAllMocks());
+
+async function fill(current: string, next: string, confirm = next) {
+ await userEvent.type(screen.getByTestId("pw-current"), current);
+ await userEvent.type(screen.getByTestId("pw-new"), next);
+ await userEvent.type(screen.getByTestId("pw-confirm"), confirm);
+ await userEvent.click(screen.getByTestId("pw-submit"));
+}
+
+describe("ChangePasswordForm", () => {
+ it("validates on the client before calling Firebase", async () => {
+ render();
+ await fill("old-password", "short");
+ expect(screen.getByTestId("pw-error")).toHaveTextContent("Use at least 8 characters.");
+ expect(changePassword).not.toHaveBeenCalled();
+ });
+
+ it("shows success only after the change resolves, and clears the fields", async () => {
+ let resolve!: (v: string) => void;
+ changePassword.mockReturnValue(new Promise((r) => (resolve = r)));
+ render();
+ await fill("old-password", "new-password");
+ expect(screen.queryByTestId("pw-success")).toBeNull();
+ expect(screen.getByTestId("pw-submit")).toBeDisabled();
+ resolve("ok");
+ await waitFor(() => expect(screen.getByTestId("pw-success")).toHaveTextContent("Password changed"));
+ expect(screen.getByTestId("pw-current")).toHaveValue("");
+ expect(screen.getByTestId("pw-new")).toHaveValue("");
+ });
+
+ it("a wrong current password clears only that field and keeps the form usable", async () => {
+ changePassword.mockResolvedValue("wrongCurrent");
+ render();
+ await fill("bad-password", "new-password");
+ await waitFor(() => expect(screen.getByTestId("pw-outcome")).toHaveAttribute("data-kind", "wrongCurrent"));
+ expect(screen.getByTestId("pw-outcome")).toHaveTextContent("That isn't your current password.");
+ expect(screen.getByTestId("pw-current")).toHaveValue("");
+ expect(screen.getByTestId("pw-new")).toHaveValue("new-password");
+ expect(screen.getByTestId("pw-submit")).toBeEnabled();
+ expect(screen.queryByTestId("pw-success")).toBeNull();
+ });
+
+ it("a policy rejection clears the new-password fields; unknown errors keep every field", async () => {
+ changePassword.mockResolvedValueOnce("policy").mockResolvedValueOnce("failed");
+ render();
+ await fill("old-password", "new-password");
+ await waitFor(() => expect(screen.getByTestId("pw-outcome")).toHaveAttribute("data-kind", "policy"));
+ expect(screen.getByTestId("pw-current")).toHaveValue("old-password");
+ expect(screen.getByTestId("pw-new")).toHaveValue("");
+ expect(screen.getByTestId("pw-confirm")).toHaveValue("");
+ await userEvent.type(screen.getByTestId("pw-new"), "another-password");
+ await userEvent.type(screen.getByTestId("pw-confirm"), "another-password");
+ await userEvent.click(screen.getByTestId("pw-submit"));
+ await waitFor(() => expect(screen.getByTestId("pw-outcome")).toHaveAttribute("data-kind", "failed"));
+ expect(screen.getByTestId("pw-outcome")).toHaveTextContent("Couldn't change your password. Your old password still works.");
+ expect(screen.getByTestId("pw-new")).toHaveValue("another-password");
+ });
+});
+```
+
+In `web/src/pages/SettingsPage.test.tsx`, add `vi.mock("./ChangePasswordForm", () => ({ ChangePasswordForm: () => }));` and append:
+
+```ts
+ it("offers the change-password form", () => {
+ render();
+ expect(screen.getByTestId("pw-form")).toBeInTheDocument();
+ });
+```
+
+- [ ] **Step 3: Run to verify they fail**
+
+Run: `npm --prefix web test -- changePassword ChangePasswordForm SettingsPage`
+Expected: FAIL.
+
+- [ ] **Step 4: Implement**
+
+Create `web/src/auth/changePassword.ts`:
+
+```ts
+import { EmailAuthProvider, reauthenticateWithCredential, updatePassword } from "firebase/auth";
+import { auth } from "../firebase";
+
+/**
+ * Change password (spec §3.7, amended after audit F3). The 8-character minimum is a client rule;
+ * a server password policy may be stricter, so its rejection has its own outcome.
+ */
+export type ChangePasswordResult = "ok" | "wrongCurrent" | "tooManyRequests" | "offline" | "policy" | "recentLogin" | "failed";
+
+export const MIN_PASSWORD_LENGTH = 8;
+
+export const CHANGE_PASSWORD_MESSAGES: Record, string> = {
+ wrongCurrent: "That isn't your current password.",
+ tooManyRequests: "Too many attempts. Wait a few minutes and try again.",
+ offline: "You are offline. Connect and try again.",
+ policy: "Your new password doesn't meet this account's password rules. Choose a different one.",
+ recentLogin: "For security, sign out and back in, then try again.",
+ failed: "Couldn't change your password. Your old password still works.",
+};
+
+export function validateNewPassword(next: string, confirm: string): string | null {
+ if (next.length < MIN_PASSWORD_LENGTH) return `Use at least ${MIN_PASSWORD_LENGTH} characters.`;
+ if (next !== confirm) return "The two new passwords don't match.";
+ return null;
+}
+
+const code = (err: unknown) => (err as { code?: string }).code;
+
+function reauthFailure(err: unknown): ChangePasswordResult {
+ switch (code(err)) {
+ case "auth/invalid-credential":
+ case "auth/wrong-password": return "wrongCurrent";
+ case "auth/too-many-requests": return "tooManyRequests";
+ case "auth/network-request-failed": return "offline";
+ default: return "failed";
+ }
+}
+
+function updateFailure(err: unknown): ChangePasswordResult {
+ switch (code(err)) {
+ case "auth/weak-password":
+ case "auth/password-does-not-meet-requirements": return "policy";
+ case "auth/requires-recent-login": return "recentLogin";
+ case "auth/too-many-requests": return "tooManyRequests";
+ case "auth/network-request-failed": return "offline";
+ default: return "failed";
+ }
+}
+
+export async function changePassword(current: string, next: string): Promise {
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return "offline";
+ const user = auth.currentUser;
+ if (!user || !user.email) return "failed";
+ try {
+ await reauthenticateWithCredential(user, EmailAuthProvider.credential(user.email, current));
+ } catch (err) {
+ return reauthFailure(err); // never attempt the update after a failed reauthentication
+ }
+ try {
+ await updatePassword(user, next);
+ } catch (err) {
+ return updateFailure(err);
+ }
+ return "ok";
+}
+```
+
+Create `web/src/pages/ChangePasswordForm.tsx`:
+
+```tsx
+import { useState, type SubmitEvent } from "react";
+import { CHANGE_PASSWORD_MESSAGES, changePassword, validateNewPassword, type ChangePasswordResult } from "../auth/changePassword";
+
+export function ChangePasswordForm() {
+ const [current, setCurrent] = useState("");
+ const [next, setNext] = useState("");
+ const [confirm, setConfirm] = useState("");
+ const [error, setError] = useState(null);
+ const [outcome, setOutcome] = useState | null>(null);
+ const [success, setSuccess] = useState(false);
+ const [busy, setBusy] = useState(false);
+
+ async function onSubmit(event: SubmitEvent) {
+ event.preventDefault();
+ setSuccess(false);
+ setOutcome(null);
+ const problem = validateNewPassword(next, confirm);
+ setError(problem);
+ if (problem) return;
+ setBusy(true);
+ const result = await changePassword(current, next);
+ setBusy(false);
+ if (result === "ok") {
+ setCurrent("");
+ setNext("");
+ setConfirm("");
+ setSuccess(true);
+ return;
+ }
+ if (result === "wrongCurrent") setCurrent("");
+ if (result === "policy") {
+ setNext("");
+ setConfirm("");
+ }
+ setOutcome(result);
+ }
+
+ return (
+
+ );
+}
+```
+
+In `web/src/pages/SettingsPage.tsx`, import `ChangePasswordForm` from `"./ChangePasswordForm"` and render `` directly above the sign-out button.
+
+- [ ] **Step 5: Run the gates**
+
+Run: `npm run typecheck && npm run test:unit` → PASS (previous + 15 changePassword (1 validate + 1 success + 5 + 6 `it.each` rows + 1 offline + 1 messages) + 4 form + 1 settings).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add web/src/auth/changePassword.ts web/src/auth/changePassword.test.ts web/src/pages/ChangePasswordForm.tsx web/src/pages/ChangePasswordForm.test.tsx web/src/pages/SettingsPage.tsx web/src/pages/SettingsPage.test.tsx
+git commit -m "feat(settings): change password with policy-aware errors
+
+Co-Authored-By: Claude Opus 5.5 (1M context) "
+```
+
+---
+### Task 9: Browser scenarios — shortlist, visited, notes, deletion, conflict
+
+**Files:**
+- Modify: `web/e2e/emulator-rest.ts`
+- Create: `web/e2e/collection.spec.ts`
+
+**Interfaces:**
+- Consumes: every testid from Tasks 4–6; seeded users `ava-uid`/`bogdan-uid` (`ava@safebite.test`, `bogdan@safebite.test`, password `pilot-password-1`).
+- Produces (`web/e2e/emulator-rest.ts`): `seedNote(request, rid, id, over?)`, `seedCollection(request, rid, over?)`, `listNoteIds(request, rid)`, `collectionExists(request, rid)`, `updateNoteViaRest(request, rid, nid, text, version)`, `sweepClaimsViaRest(request, rid)`. `clearRecords` also removes notes and every `households/home/collection` document.
+
+- [ ] **Step 1: Extend the emulator helpers**
+
+In `web/e2e/emulator-rest.ts`, replace `clearRecords` with:
+
+```ts
+/** Removes every restaurant (with its claims and notes) and every collection document under households/home. Seeds (users/households) are untouched. */
+export async function clearRecords(request: APIRequestContext): Promise {
+ for (const r of await listDocs(request, "households/home/restaurants")) {
+ const rid = idOf(r);
+ for (const sub of ["claims", "notes"]) {
+ for (const c of await listDocs(request, `households/home/restaurants/${rid}/${sub}`)) {
+ await del(request, `households/home/restaurants/${rid}/${sub}/${idOf(c)}`);
+ }
+ }
+ await del(request, `households/home/restaurants/${rid}`);
+ }
+ for (const s of await listDocs(request, "households/home/collection")) {
+ await del(request, `households/home/collection/${idOf(s)}`);
+ }
+}
+```
+
+Append:
+
+```ts
+const MEMBERS: Record = { "ava-uid": "Ava", "bogdan-uid": "Bogdan" };
+
+export async function seedNote(request: APIRequestContext, rid: string, id: string, over: { authorUid?: string; text?: string; version?: number } = {}): Promise {
+ const authorUid = over.authorUid ?? "ava-uid";
+ const res = await request.post(`${BASE}/households/home/restaurants/${rid}/notes?documentId=${id}`, {
+ headers: HEADERS,
+ data: {
+ fields: {
+ text: s(over.text ?? `Seeded note ${id}`),
+ authorUid: s(authorUid),
+ authorName: s(MEMBERS[authorUid] ?? "Unknown"),
+ createdAt: ts("2026-09-01T10:00:00Z"),
+ updatedAt: ts("2026-09-01T10:00:00Z"),
+ version: { integerValue: String(over.version ?? 1) },
+ },
+ },
+ });
+ if (!res.ok()) throw new Error(`seedNote ${rid}/${id}: ${res.status()} ${await res.text()}`);
+}
+
+export async function seedCollection(request: APIRequestContext, rid: string, over: { shortlisted?: boolean; visitedOn?: string; version?: number } = {}): Promise {
+ const fields: Record = {
+ shortlisted: { booleanValue: over.shortlisted ?? true },
+ visited: { booleanValue: over.visitedOn !== undefined },
+ updatedBy: s("ava-uid"),
+ updatedByName: s("Ava"),
+ updatedAt: ts("2026-09-01T10:00:00Z"),
+ version: { integerValue: String(over.version ?? 1) },
+ };
+ if (over.visitedOn !== undefined) fields.visitedOn = ts(`${over.visitedOn}T00:00:00Z`);
+ const res = await request.post(`${BASE}/households/home/collection?documentId=${rid}`, { headers: HEADERS, data: { fields } });
+ if (!res.ok()) throw new Error(`seedCollection ${rid}: ${res.status()} ${await res.text()}`);
+}
+
+export async function listNoteIds(request: APIRequestContext, rid: string): Promise {
+ return (await listDocs(request, `households/home/restaurants/${rid}/notes`)).map(idOf);
+}
+
+export async function collectionExists(request: APIRequestContext, rid: string): Promise {
+ const res = await request.get(`${BASE}/households/home/collection/${rid}`, { headers: HEADERS });
+ return res.status() === 200;
+}
+
+/** Models the same member editing the note on another device. */
+export async function updateNoteViaRest(request: APIRequestContext, rid: string, nid: string, text: string, version: number): Promise {
+ const url = `${BASE}/households/home/restaurants/${rid}/notes/${nid}?updateMask.fieldPaths=text&updateMask.fieldPaths=version&updateMask.fieldPaths=updatedAt`;
+ const res = await request.patch(url, {
+ headers: HEADERS,
+ data: { fields: { text: s(text), version: { integerValue: String(version) }, updatedAt: ts(new Date().toISOString()) } },
+ });
+ if (!res.ok()) throw new Error(`updateNote ${rid}/${nid}: ${res.status()} ${await res.text()}`);
+}
+
+/** Models a Plan 3-era client that swept claims and then had its final delete refused by the gate. */
+export async function sweepClaimsViaRest(request: APIRequestContext, rid: string): Promise {
+ for (const c of await listDocs(request, `households/home/restaurants/${rid}/claims`)) {
+ await del(request, `households/home/restaurants/${rid}/claims/${idOf(c)}`);
+ }
+}
+```
+
+- [ ] **Step 2: Write the browser scenarios**
+
+Create `web/e2e/collection.spec.ts`:
+
+```ts
+import { expect, test, type Page } from "@playwright/test";
+import {
+ clearRecords,
+ collectionExists,
+ listClaimIds,
+ listNoteIds,
+ markDeletingViaRest,
+ restaurantExists,
+ seedClaim,
+ seedCollection,
+ seedNote,
+ seedRestaurant,
+ sweepClaimsViaRest,
+ updateNoteViaRest,
+} from "./emulator-rest";
+
+const PASSWORD = "pilot-password-1";
+
+async function signIn(page: Page, email: string) {
+ await page.goto("/");
+ await expect(page.getByTestId("signin-form")).toBeVisible();
+ await page.getByTestId("signin-email").fill(email);
+ await page.getByTestId("signin-password").fill(PASSWORD);
+ await page.getByTestId("signin-submit").click();
+ await expect(page.getByTestId("nav-saved")).toBeVisible();
+}
+
+test.beforeEach(async ({ request }) => {
+ await clearRecords(request);
+});
+
+test("C1. a shortlist change by one member appears live on the other member's Saved page", async ({ page, browser, request }) => {
+ await seedRestaurant(request, "r-a", { name: "Da Marco" });
+ await seedRestaurant(request, "r-b", { name: "Zest" });
+
+ const bogdanContext = await browser.newContext();
+ const bogdan = await bogdanContext.newPage();
+ await signIn(bogdan, "bogdan@safebite.test");
+ await bogdan.getByTestId("nav-saved").click();
+ await expect(bogdan.getByTestId("shortlist-empty")).toBeVisible();
+
+ await signIn(page, "ava@safebite.test");
+ await page.goto("/restaurants/r-a");
+ await page.getByTestId("shortlist-add").click();
+ await expect(page.getByTestId("shortlist-state")).toHaveText("On shortlist");
+ await expect(page.getByTestId("status-changed-by")).toHaveText("Last changed by Ava");
+
+ await expect(bogdan.getByTestId("restaurant-row")).toHaveCount(1);
+ await expect(bogdan.getByTestId("restaurant-row")).toContainText("Da Marco");
+ await expect(bogdan.getByTestId("label-shortlisted")).toBeVisible();
+ await bogdan.getByTestId("filter-all").click();
+ await expect(bogdan.getByTestId("restaurant-row")).toHaveCount(2);
+ await bogdanContext.close();
+ expect(await collectionExists(request, "r-a")).toBe(true);
+});
+
+test("C2. mark visited, change the date, and clear it", async ({ page, request }) => {
+ await seedRestaurant(request, "r-v", { name: "Visited Place" });
+ await signIn(page, "ava@safebite.test");
+ await page.goto("/restaurants/r-v");
+ await page.getByTestId("visited-mark").click();
+ await page.getByTestId("visited-date").fill("2026-05-03");
+ await page.getByTestId("visited-save").click();
+ await expect(page.getByTestId("visited-state")).toHaveText("Visited 3 May 2026");
+
+ await page.getByTestId("visited-change").click();
+ await page.getByTestId("visited-date").fill("2026-05-10");
+ await page.getByTestId("visited-save").click();
+ await expect(page.getByTestId("visited-state")).toHaveText("Visited 10 May 2026");
+
+ await page.getByTestId("nav-saved").click();
+ await page.getByTestId("filter-all").click();
+ await expect(page.getByTestId("label-visited")).toHaveText("Visited 10 May 2026");
+
+ await page.goto("/restaurants/r-v");
+ await page.getByTestId("visited-clear").click();
+ await expect(page.getByTestId("visited-mark")).toBeVisible();
+});
+
+test("C3. both members write notes; only the author can edit or delete", async ({ page, browser, request }) => {
+ await seedRestaurant(request, "r-n", { name: "Notes Place" });
+ await seedNote(request, "r-n", "n-ava", { authorUid: "ava-uid", text: "Ava: staff checked with the chef" });
+
+ const bogdanContext = await browser.newContext();
+ const bogdan = await bogdanContext.newPage();
+ await signIn(bogdan, "bogdan@safebite.test");
+ await bogdan.goto("/restaurants/r-n");
+ await expect(bogdan.getByTestId("note-n-ava")).toContainText("Ava: staff checked with the chef");
+ await expect(bogdan.getByTestId("note-edit-n-ava")).toHaveCount(0);
+ await expect(bogdan.getByTestId("note-delete-n-ava")).toHaveCount(0);
+ await bogdan.getByTestId("note-add-text").fill("Bogdan: fryer is shared, avoid chips");
+ await bogdan.getByTestId("note-add-save").click();
+ const bogdanEdit = bogdan.locator('[data-testid^="note-edit-"]');
+ await expect(bogdanEdit).toHaveCount(1);
+ await bogdanEdit.click();
+ await bogdan.locator('[data-testid^="note-edit-text-"]').fill("Bogdan: fryer is shared, no chips");
+ await bogdan.locator('[data-testid^="note-save-"]').click();
+
+ await signIn(page, "ava@safebite.test");
+ await page.goto("/restaurants/r-n");
+ await expect(page.getByTestId("notes-section")).toContainText("Bogdan: fryer is shared, no chips");
+ await expect(page.getByTestId("notes-section")).toContainText("edited");
+ await expect(page.locator('[data-testid^="note-edit-"]')).toHaveCount(1); // only her own
+ await page.getByTestId("note-delete-n-ava").click();
+ await page.getByTestId("note-delete-confirm-n-ava").click();
+ await expect(page.getByTestId("note-n-ava")).toHaveCount(0);
+ await expect(bogdan.getByTestId("note-n-ava")).toHaveCount(0);
+ await bogdanContext.close();
+ expect(await listNoteIds(request, "r-n")).toHaveLength(1);
+});
+
+test("C4. deleting a restaurant removes its evidence, both members' notes and its shortlist state", async ({ page, request }) => {
+ await seedRestaurant(request, "r-del", { name: "Doomed" });
+ await seedClaim(request, "r-del", "c1");
+ await seedNote(request, "r-del", "n1", { authorUid: "ava-uid" });
+ await seedNote(request, "r-del", "n2", { authorUid: "bogdan-uid" });
+ await seedCollection(request, "r-del", { shortlisted: true, visitedOn: "2026-05-03" });
+
+ await signIn(page, "ava@safebite.test");
+ await page.goto("/restaurants/r-del/edit");
+ await expect(page.getByTestId("field-name")).toHaveValue("Doomed");
+ await page.getByTestId("delete-restaurant").click();
+ await expect(page.getByTestId("delete-question")).toHaveText("Deletes the restaurant, its evidence, and both members' notes.");
+ await page.getByTestId("delete-confirm").click();
+ await expect(page.getByTestId("restaurants-empty")).toBeVisible({ timeout: 15_000 });
+
+ expect(await restaurantExists(request, "r-del")).toBe(false);
+ expect(await listClaimIds(request, "r-del")).toEqual([]);
+ expect(await listNoteIds(request, "r-del")).toEqual([]);
+ expect(await collectionExists(request, "r-del")).toBe(false);
+});
+
+test("C5. a deletion an older client left half-done is finished from the Saved page", async ({ page, request }) => {
+ await seedRestaurant(request, "r-old", { name: "Half Gone" });
+ await seedClaim(request, "r-old", "c1");
+ await seedNote(request, "r-old", "n1", { authorUid: "bogdan-uid" });
+ await seedCollection(request, "r-old");
+ await markDeletingViaRest(request, "r-old");
+ await sweepClaimsViaRest(request, "r-old"); // the old client got this far; the gate refused its final delete
+
+ await signIn(page, "ava@safebite.test");
+ await page.getByTestId("nav-saved").click();
+ await expect(page.getByTestId("restaurants-empty")).toBeVisible({ timeout: 15_000 });
+ expect(await restaurantExists(request, "r-old")).toBe(false);
+ expect(await listNoteIds(request, "r-old")).toEqual([]);
+ expect(await collectionExists(request, "r-old")).toBe(false);
+});
+
+test("C6. a note edited on another device surfaces as a conflict, and Keep mine wins with the new version", async ({ page, request }) => {
+ await seedRestaurant(request, "r-c", { name: "Conflict Cafe" });
+ await seedNote(request, "r-c", "n-ava", { authorUid: "ava-uid", text: "First thoughts" });
+
+ await signIn(page, "ava@safebite.test");
+ await page.goto("/restaurants/r-c");
+ await page.getByTestId("note-edit-n-ava").click();
+ await page.getByTestId("note-edit-text-n-ava").fill("My draft from the laptop");
+ await updateNoteViaRest(request, "r-c", "n-ava", "Written on the phone", 2);
+ await expect(page.getByTestId("note-n-ava")).toHaveAttribute("data-version", "2");
+ await page.getByTestId("note-save-n-ava").click();
+ await expect(page.getByTestId("note-conflict-current-n-ava")).toHaveText("Written on the phone");
+ await expect(page.getByTestId("note-edit-text-n-ava")).toHaveValue("My draft from the laptop");
+ await page.getByTestId("note-keep-mine-n-ava").click();
+ await expect(page.getByTestId("note-n-ava")).toContainText("My draft from the laptop");
+ await expect(page.getByTestId("note-n-ava")).toHaveAttribute("data-version", "3");
+});
+
+test("C7. while offline the page says so once and every write control is disabled", async ({ page, context, request }) => {
+ await seedRestaurant(request, "r-off", { name: "Offline Place" });
+ await signIn(page, "ava@safebite.test");
+ await page.goto("/restaurants/r-off");
+ await expect(page.getByTestId("shortlist-add")).toBeEnabled();
+ await context.setOffline(true);
+ await expect(page.getByTestId("read-offline")).toHaveCount(1, { timeout: 15_000 });
+ await expect(page.getByTestId("shortlist-add")).toBeDisabled();
+ await expect(page.getByTestId("note-add-save")).toBeDisabled();
+ await context.setOffline(false);
+ await expect(page.getByTestId("shortlist-add")).toBeEnabled({ timeout: 15_000 });
+ expect(await collectionExists(request, "r-off")).toBe(false);
+});
+```
+
+- [ ] **Step 3: Run the browser suite**
+
+Run: `npm run emu:e2e` (10 min timeout)
+Expected: PASS, 29 + 7 = 36. If C7 does not see the offline state within 15 s, the Firestore SDK has not yet reported `fromCache`. That is a real finding. Report it; do not raise timeouts beyond 15 s.
+
+Run: `npm run emu:e2e:stress` → PASS with `--repeat-each=3 --retries=0`. Report the count.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web/e2e/emulator-rest.ts web/e2e/collection.spec.ts
+git commit -m "test(e2e): shortlist, visited, notes, deletion gate and note conflicts
+
+Co-Authored-By: Claude Opus 5.5 (1M context) "
+```
+
+---
+
+### Task 10: Browser scenarios — cross-tab reset and change password; budget, README
+
+**Files:**
+- Create: `web/e2e/auth-rest.ts`
+- Modify: `web/e2e/auth.spec.ts`
+- Modify: `web/playwright.config.ts`
+- Modify: `README.md`
+
+**Interfaces:**
+- Consumes: Task 7 (`resetting`, per-tab reset), Task 8 testids, Task 9 `seedRestaurant`/`seedNote`.
+- Produces: `setPasswordViaAdmin(request, uid, password)` against the Auth emulator (`Bearer owner`).
+
+- [ ] **Step 1: Add the Auth-emulator helper**
+
+Create `web/e2e/auth-rest.ts`:
+
+```ts
+import type { APIRequestContext } from "@playwright/test";
+
+/**
+ * Admin password reset in the Auth emulator (127.0.0.1:9099, project demo-safebite), used to
+ * restore the shared fixture password after a test changes it. Never talks to a real project.
+ */
+export async function setPasswordViaAdmin(request: APIRequestContext, uid: string, password: string): Promise {
+ const res = await request.post("http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/projects/demo-safebite/accounts:update", {
+ headers: { Authorization: "Bearer owner", "Content-Type": "application/json" },
+ data: { localId: uid, password },
+ });
+ if (!res.ok()) throw new Error(`setPassword ${uid}: ${res.status()} ${await res.text()}`);
+}
+```
+
+- [ ] **Step 2: Write the scenarios**
+
+In `web/e2e/auth.spec.ts`, add imports:
+
+```ts
+import { setPasswordViaAdmin } from "./auth-rest";
+import { clearRecords, seedNote, seedRestaurant } from "./emulator-rest";
+```
+
+and a helper that signs in *without* navigating (the cross-tab test must not reload):
+
+```ts
+/** Fills the sign-in form already on screen. No navigation: a reload would hide a broken reset. */
+async function fillSignIn(page: Page, email: string, password = PASSWORD) {
+ await expect(page.getByTestId("signin-form")).toBeVisible();
+ await page.getByTestId("signin-email").fill(email);
+ await page.getByTestId("signin-password").fill(password);
+ await page.getByTestId("signin-submit").click();
+}
+```
+
+Append:
+
+```ts
+test("signing out in one tab resets every tab, and the next account never sees the previous household", async ({ page, request }) => {
+ await clearRecords(request);
+ await seedRestaurant(request, "r-tabs", { name: "Tab Test Bistro" });
+ await seedNote(request, "r-tabs", "n1", { text: "Tab test note" });
+
+ await signIn(page, "ava@safebite.test");
+ await expect(page.getByTestId("nav-discover")).toBeVisible();
+ const second = await page.context().newPage(); // same browser context: shared auth persistence
+ await page.goto("/restaurants/r-tabs");
+ await second.goto("/restaurants/r-tabs");
+ for (const p of [page, second]) {
+ await expect(p.getByTestId("restaurant-name")).toHaveText("Tab Test Bistro");
+ await expect(p.getByTestId("notes-section")).toContainText("Tab test note");
+ await p.evaluate(() => {
+ (window as unknown as { __beforeSignOut?: boolean }).__beforeSignOut = true;
+ });
+ }
+ // Every later document in the second tab records whether it ever renders the old household.
+ await second.addInitScript(() => {
+ const w = window as unknown as { __sawOldData?: boolean };
+ w.__sawOldData = false;
+ new MutationObserver(() => {
+ const text = document.body?.innerText ?? "";
+ if (text.includes("Tab Test Bistro") || text.includes("Tab test note")) w.__sawOldData = true;
+ }).observe(document, { childList: true, subtree: true, characterData: true });
+ });
+
+ await page.getByTestId("nav-settings").click();
+ await page.getByTestId("signout").click();
+
+ for (const p of [page, second]) {
+ await expect(p.getByTestId("signin-form")).toBeVisible();
+ // A new document: the marker set before sign-out is gone, so the tab really reloaded.
+ await expect.poll(() => p.evaluate(() => (window as unknown as { __beforeSignOut?: boolean }).__beforeSignOut ?? false)).toBe(false);
+ }
+
+ await fillSignIn(second, "stranger@safebite.test");
+ await expect(second.getByTestId("not-invited")).toBeVisible();
+ await expect(second.getByText("Tab Test Bistro")).toHaveCount(0);
+ expect(await second.evaluate(() => (window as unknown as { __sawOldData?: boolean }).__sawOldData)).toBe(false);
+ await second.close();
+});
+
+test("a member changes their password, stays signed in, and only the new password works afterwards", async ({ page, request }) => {
+ try {
+ await signIn(page, "bogdan@safebite.test");
+ await expect(page.getByTestId("nav-discover")).toBeVisible();
+ await page.getByTestId("nav-settings").click();
+
+ await page.getByTestId("pw-current").fill("not-my-password");
+ await page.getByTestId("pw-new").fill("changed-password-1");
+ await page.getByTestId("pw-confirm").fill("changed-password-1");
+ await page.getByTestId("pw-submit").click();
+ await expect(page.getByTestId("pw-outcome")).toHaveAttribute("data-kind", "wrongCurrent");
+
+ await page.getByTestId("pw-current").fill(PASSWORD);
+ await page.getByTestId("pw-submit").click();
+ await expect(page.getByTestId("pw-success")).toHaveText("Password changed");
+ await expect(page.getByTestId("nav-settings")).toBeVisible(); // same UID: no reset
+
+ await signOutAndWait(page);
+ await fillSignIn(page, "bogdan@safebite.test", PASSWORD);
+ await expect(page.getByTestId("signin-error")).toBeVisible();
+ await fillSignIn(page, "bogdan@safebite.test", "changed-password-1");
+ await expect(page.getByTestId("nav-discover")).toBeVisible();
+ } finally {
+ await setPasswordViaAdmin(request, "bogdan-uid", PASSWORD);
+ }
+});
+```
+
+- [ ] **Step 3: Raise the suite budget and document**
+
+In `web/playwright.config.ts`, change `globalTimeout: 900_000` to `globalTimeout: 1_200_000` and update its comment to "38 scenarios".
+
+In `README.md`, section "Web app (PWA) — private pilot":
+- Under local development, add one paragraph. It says Saved shows the household shortlist by default ("All records" shows everything). Each restaurant has shortlist/visited controls and "Our notes"; notes are personal and never evidence. Deleting a restaurant also deletes both members' notes and its shortlist state. Settings has "Change password".
+- Replace "`npm run test:unit` currently reports 270 tests." with the count printed by the final `npm run test:unit` run in this task. Add the functions + rules and browser counts printed by the final `npm run emu:test` / `npm run emu:e2e`, in the same sentence style.
+
+- [ ] **Step 4: Full gate**
+
+Run each and record the counts:
+- `npm run typecheck`
+- `npm run test:unit`
+- `npm run emu:test`
+- `npm run emu:e2e` (retries 0 locally) → 38
+- `npm run emu:e2e:stress` → report passed/total
+- `npm --prefix web run build:e2e && npm --prefix web run e2e:boot-guard && npm --prefix web run e2e:preview && npm --prefix web run e2e:upgrade` → 1 / 4 / 7
+- Guardrail greps (each must print nothing):
+ - `git grep -n "safebite-production-13ba1" -- ':!SafeBite/**' ':!*.md' ':!docs/**' ':!.github/workflows/ci.yml' ':!web/src/config/firebaseEnv.ts' ':!web/src/config/firebaseEnv.test.ts'`
+ - `git grep -nE "setDoc|updateDoc|deleteDoc|writeBatch" -- web/src ':!*.test.ts' ':!*.test.tsx'`
+ - `git grep -nE "window\.(confirm|alert|prompt)" -- web/src`
+ - `git grep -nE "localStorage|sessionStorage|indexedDB" -- web/src/records web/src/auth web/src/pages ':!*.test.ts' ':!*.test.tsx'` (the service worker's existing one-shot reload flag in `web/src/pwa` is out of scope)
+ - `git diff --check f8edfc9`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/e2e/auth-rest.ts web/e2e/auth.spec.ts web/playwright.config.ts README.md
+git commit -m "test(e2e): cross-tab reset and change password; README and suite budget
+
+Co-Authored-By: Claude Opus 5.5 (1M context) "
+```
+
+---
+
+## Plan self-review (2026-09-24)
+
+**Spec coverage (§3.7 as amended):**
+
+| Requirement | Task |
+|---|---|
+| Rulings 1–3: shortlist within records, notes on the record, household-wide visited | 1, 3, 4, 5, 6 |
+| Ruling 4: separate `collection/{rid}` | 1, 3 |
+| Ruling 5: change password | 8, 10 |
+| Collection data model and rules; `updatedByName` | 1 |
+| Notes data model and rules; delete during deletion | 1 |
+| Completion gate and read-before-delete sweeps (F1) | 2 |
+| Mixed-version, concurrent and retry tests (F1 acceptance) | 2 (rules protocol), 9 C5 (real client resumes) |
+| Saved page: filter, labels, order, empty states, joined states, no list toggles | 4 |
+| Restaurant page: status block, notes, subtitle, author-only controls, edited marker, draft kept, conflict chooser, confirmation identity | 5, 6 |
+| One offline notice; errors shown (parked 2b double notice) | 4, 5, 6 |
+| Parked 2b wording (resume flow, form outcome verb) | 4, 6 |
+| Deletion confirmation text; "Removing notes…" | 2, 6 |
+| Account switch across tabs (F2) + unit no-loop cases | 7, 10 |
+| Change password error contract (F3) | 8, 10 |
+| Test isolation: cleanup of notes/collection; password restored | 9, 10 |
+| Deploy note | spec only (no deploy in this plan) |
+
+**Deliberate deviation for the owner/auditor:** spec §3.7 lists a browser test of a "simultaneous toggle conflict". A live listener refreshes the toggle's base version within milliseconds, so two clicks from two browsers cannot be made to race deterministically in Playwright. The toggle conflict is therefore proven at the repository level (Task 3: stale base → `conflict`, no write) and the component level (Task 5: conflict message, no automatic retry). The browser proves the same version-conflict path with a note edited on another device (Task 9, C6), which is deterministic. The rules side is proven in Task 1 (stale version refused).
+
+**Placeholder scan:** none. Every code step carries code. The README counts are to be read from the final gate run, which is intentional (plan constraint: no asserted absolute totals).
+
+**Type consistency:** `DeleteStep` (Task 2) is used by `deleteProgressText` (Tasks 2, 4). `CollectionState`/`Note` (Task 3) are used in Tasks 4–6. `isData`/`anyOffline`/`combineStates` (Task 4) are used in Tasks 5–6. `setShortlisted(hid, rid, author, baseVersion, bool)` and `setVisited(hid, rid, author, baseVersion, date | null)` match between Tasks 3 and 5. `updateNote(hid, rid, nid, baseVersion, text)` and `deleteNote(hid, rid, nid, baseVersion)` match between Tasks 3 and 6.
diff --git a/planning/specs/2026-09-20-safebite-pwa-design.md b/planning/specs/2026-09-20-safebite-pwa-design.md
index da389e6..fa922a5 100644
--- a/planning/specs/2026-09-20-safebite-pwa-design.md
+++ b/planning/specs/2026-09-20-safebite-pwa-design.md
@@ -157,8 +157,8 @@ All household data lives under `households/{householdId}`.
| `households/{hid}` | `name`, `memberIds: string[]`, `createdAt` | admin only |
| `households/{hid}/restaurants/{rid}` | `name`, `address`, `lat`, `lng`, `googlePlaceId?`, `phone?`, `website?`, `createdBy`, `createdAt`, `updatedAt`, `version` | members via rules |
| `households/{hid}/restaurants/{rid}/claims/{cid}` | `kind` (`dedicatedKitchen`, `separateFryer`, `trainedStaff`, `gfMenu`, `preparationPractice`, `accreditation`), `value` (`yes`/`no`/`partial`), `detail`, `source: {type: 'restaurantStatement'|'accreditingBody'|'ownVisit'|'thirdParty', label, url?}`, `checkedAt`, `expiresAt?`, `authorUid`, `createdAt` | members via rules; accreditation kind validated by rules to require `source.type == 'accreditingBody'` and non-empty `source.url` |
-| `households/{hid}/collection/{rid}` | `restaurantId`, `savedBy`, `savedAt`, `visited`, `visitedAt?`, `version` | members via rules |
-| `households/{hid}/collection/{rid}/notes/{nid}` | `authorUid`, `text`, `createdAt`, `updatedAt` | author only |
+| `households/{hid}/collection/{rid}` | `shortlisted`, `visited`, `visitedOn?`, `updatedBy`, `updatedByName`, `updatedAt`, `version` (see §3.7) | members via rules |
+| `households/{hid}/restaurants/{rid}/notes/{nid}` | `text`, `authorUid`, `authorName`, `createdAt`, `updatedAt`, `version` (see §3.7) | author only; any member during restaurant deletion |
| `config/discovery` | `enabled: boolean`, `dailySearchCap: number` | admin only (kill switch) |
| `households/{hid}/usage/{yyyymmdd}` | `searches`, `details` | functions only |
@@ -227,14 +227,16 @@ Real-iPhone acceptance (owner + Ava) happens after staging deploy, outside CI.
### 3.1 Owner-only prerequisites (agents must never do these)
+Owner ruling (2026-09-23): the primary assistant may carry out O3–O6 through the official `gcloud`/`firebase` CLIs, confirming each billing or irreversible step first. Delegated subagents still may not. Deploys and pushes remain governed by §3.2.
+
| ID | Action | Needed before |
|----|--------|---------------|
| O1 | `git pull --ff-only` to bring `a3403d1` into local `main` | Plan 1 |
| O2 | Install a JDK (21+) so Firebase emulators run locally | Plan 1 verification |
-| O3 | `firebase login --reauth`; then inventory `safebite-production-13ba1` read-only (owners, Firestore location, deployed rules, users, billing, API restrictions). Record findings in `planning/specs/firebase-inventory.md` | Plan 5 staging |
-| O4 | Check the historical key from commit `e7c0268` in Google Cloud Console: restrict or delete it. Create a **new** server key restricted to Places API (New) for the pilot project only | Plan 3 staging |
-| O5 | Create a new Firebase project for the pilot (Firestore in `europe-west2`), Blaze plan with a budget alert at £10, add alias `staging` to `.firebaserc` | Plan 5 |
-| O6 | Create the two member accounts in the pilot project's Auth and their `users/` + `households/` docs via console or admin script | Plan 5 |
+| O3 | ~~`firebase login --reauth`; inventory `safebite-production-13ba1` read-only~~ Done 2026-09-23: legacy project not reachable from the owner account; see `planning/specs/firebase-inventory.md` | Plan 5 staging |
+| O4 | Historical key from `e7c0268`: its parent project (`776764264965`) is not held by any owner account, so it cannot be restricted (2026-09-23). A new key restricted to Places API (New) was created in the pilot project and stored as secret `PLACES_API_KEY` (done 2026-09-23) | Plan 3 staging |
+| O5 | ~~Create the pilot Firebase project~~ Done 2026-09-23: `safebite-pilot-urfs3v`, Firestore `europe-west2`, Blaze, project-scoped £10 budget, alias `staging`, self sign-up disabled | Plan 5 |
+| O6 | ~~Create the two member accounts and their `users/` + `households/` docs~~ Done 2026-09-23 by admin script; both sign-ins verified | Plan 5 |
| O7 | ~~Decide whether `docs/` remains a GitHub Pages site~~ Done: `docs/` stays public Pages; planning docs live in `planning/` | — |
| O8 | Approve this spec and Plan 1 | Any implementation |
@@ -267,7 +269,7 @@ branch/worktree, reviewed, then merged before the next begins.
| **1. Foundation and household auth** — `planning/plans/2026-09-20-safebite-pwa-01-foundation.md` | Repo hygiene; `web/` + `functions/` scaffolds; emulator-only config; new rules for `users`/`households`; `requireMember` + `whoami` callable; sign-in / not-invited / member shell; emulator seed; Playwright + CI. A member signs in and sees the shell; a non-member is refused; rules tests prove isolation | O1, O2 |
| **2. Restaurant records and evidence** | **PWA app shell first** (`vite-plugin-pwa` manifest, real icon set replacing the Vite logo, `apple-touch-icon`, `apple-mobile-web-app-capable`, `theme-color`, standalone display — a plan gap found in the Plan 1 final review); then `restaurants` + `claims` model, rules with accreditation validation and version checks, private editing form, evidence display with checked/expired states, "call ahead" prompts, unit + rules + e2e tests | Plan 1 — 2a, 2a-h and 2b executed 2026-09-21 (see §3.5 and `planning/plans/2026-09-21-safebite-pwa-02b-records.md`) |
| **3. Discovery through functions** | `searchDestination`, `searchNearby`, `placeDetails` callables with secret key, kill switch, caps, attribution; discover UI with all failure states; search cancellation; external directions links; "add to our records" from a result (stores place ID only) | Plan 2 — design in §3.6 (2026-09-22) |
-| **4. Shared collection and notes** | `collection` + `notes` model and rules, save/unsave/visited, authored notes, optimistic concurrency with reload prompt, account-switch cache clearing, e2e | Plan 2 (Plan 3 optional) |
+| **4. Shared collection and notes** | `collection` + `notes` model and rules, save/unsave/visited, authored notes, optimistic concurrency with reload prompt, account-switch cache clearing, e2e | Plan 2 (Plan 3 optional) — design in §3.7 (2026-09-24) |
| **5. Privacy, offline, operations** | Opt-in offline download to IndexedDB, clear-on-signout, export callable, account-deletion callable, settings page, privacy/terms content, staging config files, cost-control checklist, real-iPhone acceptance script | Plans 1–4, O3–O6 |
Plans 2–5 are written after Plan 1 is executed and reviewed, so they can name
@@ -879,3 +881,253 @@ regardless (audit observation, 2026-09-22).
Place Details; coordinates on records; offline copies of anything from Google; visited state
and notes (Plan 4); the square-icon question (Plan 5); staging key creation and secret binding
(owner action O4 — a prerequisite for staging, not for this plan); Firestore index changes.
+
+### 3.7 Plan 4 design — shortlist, visited state and notes (brainstormed 2026-09-24)
+
+Plan 2b made the Saved tab list every restaurant record, so a record is already shared by the
+household. Plan 4 therefore does not add a second "saved" list. It adds a shortlist and visited
+state *on top of* the records, plus authored notes. This section supersedes the `collection` and
+`notes` rows of §2.3 wherever they differ.
+
+Owner rulings taken during the brainstorm:
+
+1. **Shortlist within the records.** Records remain everything the household has researched,
+ including places judged unsafe. "Shortlisted" means "we want to go". The Saved tab filters
+ *Shortlist* (default) or *All records*. Named trip lists are deferred.
+2. **Notes belong to the restaurant record**, not to the shortlist entry. They survive
+ un-shortlisting and are removed only with the restaurant.
+3. **Visited is household-wide:** one flag plus a visit date, settable and clearable by either
+ member, independent of the shortlist. No per-member visits, no visit log.
+4. **State lives in a separate document per restaurant** (`collection/{rid}`), so toggling
+ shortlist or visited never bumps the restaurant's `version` and never conflicts with an edit
+ of its details. (Rejected: fields on the restaurant, since every toggle would conflict with edits and
+ reopen the reviewed Plan 2b rules; a `state` subdocument under each restaurant, which needs a
+ collection-group query and index to list.)
+5. **Change password ships in Plan 4** (Settings), not Plan 5.
+
+#### Data model
+
+`households/{hid}/collection/{rid}`: document id = restaurant id; created on first use.
+
+| Field | Rule |
+|---|---|
+| `shortlisted` | bool |
+| `visited` | bool |
+| `visitedOn` | present exactly when `visited == true`; UTC-midnight timestamp (the `checkedAt` convention); `<= request.time + 1 day` |
+| `updatedBy` | `== request.auth.uid` |
+| `updatedByName` | `==` the caller's own `users/{uid}.displayName` (as `authorName` on claims). Members cannot read each other's `users` documents, so the name is denormalised |
+| `updatedAt` | `== request.time` |
+| `version` | create `== 1`; update `== resource.data.version + 1` |
+
+Keys are exactly these (`hasOnly`/`hasAll`, with `visitedOn` optional). Create and update require
+the parent restaurant to exist with `deleting == false`. Delete is allowed only while the parent is
+marked `deleting` (sweep step). Clients never delete a collection document otherwise:
+un-shortlisting writes `shortlisted: false`. The §2.3 fields `restaurantId`, `savedBy` and `savedAt` are
+dropped: the id names the restaurant and `updatedBy`/`updatedAt` replace the others.
+
+`households/{hid}/restaurants/{rid}/notes/{nid}`
+
+| Field | Rule |
+|---|---|
+| `text` | non-blank string, `<= 2000` characters (`LIMITS.note`, parity-tested) |
+| `authorUid` | `== request.auth.uid` on create; immutable |
+| `authorName` | `==` the caller's own `users/{uid}.displayName` on create; immutable |
+| `createdAt` | `== request.time` on create; immutable |
+| `updatedAt` | `== request.time` on every write |
+| `version` | create `== 1`; update `== resource.data.version + 1` |
+
+Read: any member. Create: any member, parent exists and is not `deleting`. Update: the author only,
+changing only `text`, `updatedAt`, `version`, and only while the parent is not `deleting`.
+Delete: the author at any time, **or any member while the parent is `deleting`** (so the sweep can
+remove the other member's notes).
+
+Neither document carries anything a safety label could be derived from, and neither write touches a
+claim, so visiting or noting can never refresh a verification date (§2.1).
+
+#### Deletion protocol (extends §3.5; amended after audit F1)
+
+Mark `deleting` → sweep claims → sweep notes → delete `collection/{rid}` if present → set
+`cleanupDone: true` → delete the restaurant. The existing resume path ("Finish deleting" on the
+Saved page, automatic resume once per mount) runs the extended sequence. Progress text gains
+"Removing notes…". The deletion confirmation reads: "Deletes the restaurant, its evidence, and
+both members' notes."
+
+**Completion gate (rules).** A Plan 3-era client finishes a deletion by sweeping claims and deleting
+the restaurant; Firestore does not cascade to subcollections, so under the old delete rule it
+would orphan notes and collection state. The gate makes that impossible for any client version:
+
+- `restaurants` gains an optional `cleanupDone` (bool; added to `validRestaurant`'s `hasOnly`).
+ Creation and ordinary updates must not set it (the create rule requires it absent; the ordinary
+ update branch requires it unchanged).
+- A new update branch **marks cleanup done**: `resource.data.deleting == true`,
+ `request.resource.data.cleanupDone == true`, affected keys only `cleanupDone`, `version`,
+ `updatedAt`, version `+ 1`, `updatedAt == request.time`.
+- Delete requires `deleting == true && cleanupDone == true &&
+ !exists(.../collection/$(rid))`.
+
+After the `deleting` mark no new claim, note or collection document can be created (their
+create rules require a parent with `deleting == false`). The client therefore sets
+`cleanupDone` only after its sweeps have returned empty from the server, and the flag cannot go
+stale. Rules cannot prove that a subcollection is empty. The gate relies on that ordering, and
+the `exists()` check guards the one sibling document it can see.
+
+An old client's final delete is refused. The restaurant stays listed as "Deleting…", and any
+Plan 4 client completes it (automatic resume or "Finish deleting"). The old tab shows its
+existing permission message, and its update banner offers the reload.
+
+**Idempotent, concurrent sweeps.** Each page is read from the server. The transaction then
+`tx.get`s every document on the page and deletes only those that still exist. Deleting
+`collection/{rid}`, setting `cleanupDone` and deleting the restaurant also read first. An
+already-missing document or an already-set flag counts as done, never as a failure. Two
+sweepers, a sweeper racing an old-client resumer, and a retry after any intermediate step
+therefore all converge without a misleading permission error. (The existing Plan 2b
+`sweepClaims`/`removeRestaurant`, which delete blind, change to the same pattern.)
+
+#### Client
+
+Repository (`web/src/records/repository.ts`, still the only Firestore module for records, or a
+sibling `collection.ts`/`notes.ts` if the file would pass ~400 lines): `watchCollection(hid)`,
+`setShortlisted`, `setVisited(hid, rid, base, visitedOn | null)`, `watchNotes`, `addNote`,
+`updateNote`, `deleteNote`, `sweepNotes`. All writes are `runTransaction`s with typed
+`WriteOutcome`s. Version checks run inside the transaction (a missing collection document is
+base version 0 → create). Online-only, as in §3.5.
+
+**Saved page.** Filter *Shortlist* | *All records*, held in component state only. Rows show name,
+address and plain labels "Shortlisted" / "Visited 3 May 2026", with no safety wording in the list.
+Order by name. Empty states: no records ("No restaurants yet. Add the first one.") vs an empty
+shortlist ("Nothing on the shortlist. Open a record and tap Add to shortlist."). Two listeners
+(restaurants, collection) joined by id in a pure, unit-tested function. One offline notice when
+either is cached. Deleting rows appear under both filters. No toggles in the list.
+
+**Restaurant page.**
+- A status block under the address: "Add to shortlist" / "On shortlist · Remove"; "Mark visited" opens
+ an inline date field (default today, no future dates) with Save/Cancel; when visited: "Visited
+ · Change date · Clear"; "Last changed by ". A version conflict shows the
+ current state with the standard conflict message and never auto-retries. Offline disables the
+ controls, as with "Add evidence".
+- "Our notes", between Evidence and "Call ahead and ask", subtitled "Personal notes. They are not
+ evidence and don't change any checked date." Newest first. Each note shows the text, the author,
+ the date and "edited" when `version > 1`. Edit (inline) and Delete (in-page confirm, never
+ `window.confirm`) are offered only on the caller's own notes. "Add a note" is an inline textarea
+ with a counter.
+- A failed save keeps the draft with the error. An edit conflict shows the current text beside the
+ draft and lets the member choose.
+- The page shows one offline notice for all its listeners, which closes the Plan 2b parked double-notice item.
+ The parked Plan 2b wording items (resume-flow text, the form's shared outcome block) are fixed here.
+
+**Account switch (amended after audit F2).** Firebase synchronises auth state across same-origin
+tabs, so the reset belongs in the auth listener, not in the sign-out button. `AuthProvider` keeps
+`lastUid`, the last signed-in UID observed *in this document*. When `onAuthStateChanged` reports
+`null` or a different UID while `lastUid` is set, the provider bumps its generation counter,
+immediately renders a neutral "Signing out…" state (old UI hidden, pending callbacks void), and
+calls `window.location.replace("/")`. A full reload discards every listener, the Firestore memory
+cache and all React state in that tab. Every tab that had a member signed in resets itself, whichever
+tab initiated the change. Explicit `signOut()` only calls Firebase sign-out, and the listener
+performs the reset in the initiating tab too. No loops: a document that starts signed out has no
+`lastUid`, and after the reload it starts fresh. Token refreshes do not fire
+`onAuthStateChanged`, and reauthentication keeps the same UID, so neither triggers a reset. The
+service-worker precache holds only the app shell.
+
+**Change password (Settings; amended after audit F3).** Current password, new password,
+confirmation. Client validation: at least 8 characters, and the confirmation matches. This is a client
+rule only: the pilot project has no server password policy (verified 2026-09-24), and one could
+be added later. Sequence: `reauthenticateWithCredential`. If it fails, stop and **never** call
+`updatePassword`. Otherwise call `updatePassword`. Each row applies only in the phase where its
+outcome is definitive. Messages:
+
+| Condition | Message |
+|---|---|
+| reauthentication: wrong current password (`auth/invalid-credential`, `auth/wrong-password`) | "That isn't your current password." |
+| reauthentication: `auth/too-many-requests` | "Too many attempts. Wait a few minutes and try again." |
+| reauthentication: `auth/network-request-failed`, or offline before starting | the standard offline message |
+| update: server policy rejects the new password (`auth/weak-password`, `auth/password-does-not-meet-requirements`) | "Your new password doesn't meet this account's password rules. Choose a different one." |
+| update: `auth/requires-recent-login` | "For security, sign out and back in, then try again." |
+| any other failure after the update request was sent (including `auth/network-request-failed`, `auth/too-many-requests` and unknown errors) | "We couldn't confirm whether your password changed. Sign out, then sign in with your new password; if that doesn't work, use your old one." |
+| any other failure before the update request | "Couldn't change your password. Your old password still works." |
+
+The Firebase SDK performs an account lookup after the update succeeds, so a later failure does not
+prove the password is unchanged (implementation audit F3, 2026-09-24).
+
+"Password changed" appears only after `updatePassword` resolves. After any failure the form stays
+usable and submit is re-enabled. A wrong current password clears only that field. A policy rejection
+clears the two new-password fields. The uncertain outcome clears all three fields, because the
+current password may no longer be current. Offline and other errors keep every field. The member stays
+signed in on success (same UID, so no reset). No email reset (it needs templates and a trusted domain).
+A forgotten password is reset via the Admin API.
+
+**Read states for joined data (audit clarification).** The Saved list and the restaurant page each
+combine two or more listeners. The combined view is loading until every listener has produced a
+snapshot. A `denied` or `error` from any listener is shown (errors are never hidden behind the offline
+notice). A missing `collection/{rid}` means "not shortlisted, not visited" (base version 0) only
+when the collection snapshot is server-backed (`ready`). While it is loading, errored or only cached,
+the status controls are disabled and no "Nothing on the shortlist" empty state is shown. Authoritative
+empty messages need `ready` snapshots, as in §3.5. One offline notice covers all listeners that
+are `offline`.
+
+**Notes: confirmation identity and conflicts (audit clarification).** A pending delete confirmation
+is bound to the note's id and version, like the claim confirmations in Plan 2b (37cc46b). It resets if
+that note changes or disappears. An edit conflict shows the current server text next to the
+member's draft. Choosing "Keep mine" writes the draft with the *current* server version as its
+base; choosing "Use theirs" discards the draft. Either way the next write carries the version the
+chooser displayed, so a third change triggers a fresh conflict. A note edited or deleted from
+another device while the restaurant is being deleted gets the ordinary `notFound`/`conflict`
+outcomes.
+
+#### Tests
+
+- **Unit:** repository collection/notes functions, including the version check re-run on
+ transaction retry and base-version-0 create; the records×collection join and filter; visit-date
+ validation; `LIMITS.note` parity with the rules; Saved page filters and empty states; restaurant
+ page status block, notes list (author-only controls, edited marker), note draft preserved on failure,
+ edit-conflict chooser; single offline notice; change-password states; sign-out reload.
+- **Rules (`functions/test`, emulator):** collection create/update/version, the `visitedOn`
+ conditions, `updatedBy`/`updatedByName` spoofing, parent missing or `deleting`, delete only while
+ `deleting`; notes create/update author-only, immutable fields, `authorName` spoofing, text at
+ exactly 2,000 characters accepted and at 2,001 refused, delete by non-author refused unless the parent is `deleting`,
+ author edit/delete while the parent is `deleting`; completion gate: `cleanupDone` refused on
+ create and on ordinary updates, allowed only by the mark-done branch, restaurant delete refused
+ without `cleanupDone` or while `collection/{rid}` exists; non-member refused throughout.
+- **Mixed-version deletion (emulator, repository level):** the Plan 3-era sequence (sweep claims,
+ delete restaurant, blind deletes as in the merged Plan 3 `repository.ts`) run against the new rules with seeded notes and
+ collection state is refused at the final delete, the parent stays marked and resumable, and the
+ new `finishDeleting` then completes all cleanup; an old resumer racing a new deleter; two new
+ sweepers concurrently; a retry after each intermediate step (claims swept, notes swept,
+ collection removed, `cleanupDone` set) completes without a permission outcome.
+- **Browser (`web/e2e`, two members):** a shortlist change seen live by the other member; filter;
+ mark visited, change date, clear; notes by both members with author-only controls; deleting a
+ restaurant sweeps both members' notes and the collection document, including an interrupted
+ deletion resumed from the Saved page; simultaneous toggle conflict; change password, then sign in with the
+ new one (the test uses its own user or restores the fixture password in cleanup);
+ **cross-tab reset:** two pages in one browser context, both showing records and notes. Sign out in
+ page A. Both pages reset (a marker set on `window` before the sign-out is gone afterwards) and show
+ the sign-in form without any test-driven `goto`/`reload`. Then sign in as the non-member in page B,
+ again without test navigation, and no restaurant name is ever rendered. Unit tests: no reload on
+ initial signed-out start, on the same UID, or on token refresh.
+- **Test isolation:** the emulator clean-up helpers also remove notes and collection documents.
+ Account-switch tests never rely on manual reloads.
+- **Gate:** typecheck; web unit; functions + rules; browser (retries 0); boot-guard, preview and
+ upgrade; guardrail greps.
+
+#### Decisions taken without owner input (override if wrong)
+
+| Decision | Reason |
+|----------|--------|
+| Note limit 2,000 characters; password minimum 8 | Room for a visit account; stricter than Firebase's floor |
+| Filter choice not persisted | No new browser storage before Plan 5's offline design |
+| No quick toggles in the list | Avoids accidental taps while scrolling |
+| Account change resets each tab by a full reload | Discards the Firestore memory cache, listeners and React state in one step; the cross-tab browser test is the guarantee |
+
+#### Deploy note (amended after audit F1)
+
+Old clients never write the new paths. The completion gate stops them finishing a deletion that
+would orphan notes or collection state, so old tabs, cached bundles and a hosting rollback remain
+safe after the new rules deploy. Deploy order: rules and functions first, then hosting. Staging has
+never served hosting (verified 2026-09-24: live channel with no releases, site returns 404), so
+the first deploy has no older clients in the wild. The gate is not relied on for that; it covers
+rollbacks and later releases.
+
+#### Not in this plan
+
+Named trip lists; per-member visits or a visit log; email password reset; offline download,
+export and account deletion (Plan 5, which must include `collection` documents and notes, and
+delete the caller's notes on account deletion); list-level quick actions.
diff --git a/planning/specs/firebase-inventory.md b/planning/specs/firebase-inventory.md
new file mode 100644
index 0000000..f9ceccd
--- /dev/null
+++ b/planning/specs/firebase-inventory.md
@@ -0,0 +1,47 @@
+# Firebase and Google Cloud inventory (O3–O6)
+
+Recorded 2026-09-23. On that day the owner authorised the primary assistant to carry out O3–O6 through the official `gcloud` and `firebase` CLIs, running as ``. Every result below was read back after the change was made.
+
+## O3 — legacy project `safebite-production-13ba1`
+
+Not reachable. `gcloud projects describe`, the Firebase Management API (403) and `firebase projects:list` all refuse or omit it for the owner account. It is not in the account's pending-deletion list (the last 30 days). The legacy iOS config (`SafeBite/SafeBite/GoogleService-Info.plist`) gives project number `113380788016`. The pilot does not depend on this project, so no inventory is possible or needed. If it ever reappears under another account, treat its rules (public read) and seeded data as untrusted.
+
+## O4 — historical key from commit `e7c0268`
+
+The API Keys lookup was denied (`apikeys.keys.lookup`), but the error names the key's parent as project number `776764264965`. The owner could not find that project under any account they hold. The key therefore belongs to a deleted or foreign project and cannot be restricted from here. The pilot never uses it. The replacement key was created in the pilot project on 2026-09-23 (see O5 below). `config/discovery` is still absent, so discovery stays switched off until deploy.
+
+## O5 — pilot project
+
+| Item | Value |
+|---|---|
+| Project ID / number | `safebite-pilot-urfs3v` / `1081260388315` |
+| `.firebaserc` alias | `staging` (default stays `demo-safebite`) |
+| Billing | Blaze, billing account `` |
+| Budget | "SafeBite pilot £10": £10/month, scoped to this project only; alerts at 50 %, 90 %, 100 % actual and 100 % forecast (the account-wide £10 budget is separate and unchanged) |
+| Firestore | `(default)`, Native mode, `europe-west2`, delete protection enabled |
+| Firestore rules | None released yet, so all client access is denied until `firestore.rules` is deployed |
+| Auth | Firebase Auth (not Identity Platform), email + password only, **self sign-up disabled** (`client.permissions.disabledUserSignup`; a public `accounts:signUp` returns `ADMIN_ONLY_OPERATION`) |
+| Authorised domains | `localhost`, `safebite-pilot-urfs3v.firebaseapp.com`, `safebite-pilot-urfs3v.web.app` |
+| Web app | "SafeBite PWA", app ID `1:1081260388315:web:6696e3bc27a3170e6041fe`; its `VITE_FIREBASE_*` values come from `firebase apps:sdkconfig` and are not committed |
+| Places key | API key `` ("SafeBite functions Places (server)"), API restriction `places.googleapis.com` only, no application restriction (server-side use from Cloud Functions). Piped straight into Secret Manager secret `PLACES_API_KEY` (version 1, label `firebase-managed=functions`) without being displayed; verified with one free IDs-only Text Search |
+| APIs enabled | Firestore, Identity Toolkit, Cloud Functions, Cloud Build, Artifact Registry, Cloud Run, Eventarc, Secret Manager, Places (New), Firebase Rules, Firebase Hosting, Billing Budgets, API Keys |
+
+The owner considered reusing `mitch-ai-services`, then chose a new project instead. That project runs an unrelated live Cloud Run service whose default service account holds `roles/editor`, so it could reach household data, and its existing spend would make a £10 SafeBite budget meaningless.
+
+A stray console-created project `safebyte-1` (number `1045562242738`, no billing, no data) appeared during setup; the owner decides whether to delete it.
+
+## O6 — member accounts
+
+Created with a one-off, uncommitted admin script (REST, the owner's gcloud credentials, no service-account key). It writes the same shapes as `functions/src/seed-emulator.ts`:
+
+- `households/home`: `name: "Home"`, `memberIds` = both UIDs, `createdAt`
+- `users/`: Bogdan (``), `householdId: "home"`
+- `users/`: Ava (``), `householdId: "home"`
+
+Both accounts signed in successfully. The owner then replaced the generated passwords with chosen ones in `~/.config/safebite/pilot-accounts.txt` (mode 600) on the owner's machine; they were applied through the Admin API and both sign-ins re-verified. The app has no password-change flow yet.
+
+## Still outstanding before staging acceptance
+
+- Create `config/discovery` with `enabled: true` and a daily cap at deploy time.
+- Deploy order (landing review): functions, then rules and hosting together.
+- Real iPhone/Safari acceptance (Plan 5).
diff --git a/web/e2e/auth-rest.ts b/web/e2e/auth-rest.ts
new file mode 100644
index 0000000..b1ddad1
--- /dev/null
+++ b/web/e2e/auth-rest.ts
@@ -0,0 +1,26 @@
+import type { APIRequestContext } from "@playwright/test";
+
+/**
+ * Admin password reset in the Auth emulator (127.0.0.1:9099, project demo-safebite), used to
+ * restore the shared fixture password after a test changes it. Never talks to a real project.
+ */
+export async function setPasswordViaAdmin(request: APIRequestContext, uid: string, password: string): Promise {
+ const res = await request.post("http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/projects/demo-safebite/accounts:update", {
+ headers: { Authorization: "Bearer owner", "Content-Type": "application/json" },
+ data: { localId: uid, password },
+ });
+ if (!res.ok()) throw new Error(`setPassword ${uid}: ${res.status()} ${await res.text()}`);
+}
+
+/**
+ * Whether the Auth emulator accepts this email/password, checked independently of the page. The
+ * emulator accepts any API key. The response carries tokens, so it is consumed and never logged.
+ */
+export async function passwordAccepted(request: APIRequestContext, email: string, password: string): Promise {
+ const res = await request.post("http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=fake-api-key", {
+ headers: { "Content-Type": "application/json" },
+ data: { email, password, returnSecureToken: true },
+ });
+ await res.body();
+ return res.ok();
+}
diff --git a/web/e2e/auth.spec.ts b/web/e2e/auth.spec.ts
index 083b56c..b9f881f 100644
--- a/web/e2e/auth.spec.ts
+++ b/web/e2e/auth.spec.ts
@@ -1,19 +1,29 @@
import { expect, test, type Page } from "@playwright/test";
+import { passwordAccepted, setPasswordViaAdmin } from "./auth-rest";
+import { clearRecords, seedNote, seedRestaurant } from "./emulator-rest";
const PASSWORD = "pilot-password-1";
async function signIn(page: Page, email: string) {
await page.goto("/");
- await expect(page.getByTestId("signin-form")).toBeVisible();
+ await expect(page.getByTestId("signin-form")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("signin-email").fill(email);
await page.getByTestId("signin-password").fill(PASSWORD);
await page.getByTestId("signin-submit").click();
}
+/** Fills the sign-in form already on screen. No navigation: a reload would hide a broken reset. */
+async function fillSignIn(page: Page, email: string, password = PASSWORD) {
+ await expect(page.getByTestId("signin-form")).toBeVisible();
+ await page.getByTestId("signin-email").fill(email);
+ await page.getByTestId("signin-password").fill(password);
+ await page.getByTestId("signin-submit").click();
+}
+
/** Click sign-out and wait until the app has actually returned to the signed-out state. */
async function signOutAndWait(page: Page) {
await page.getByTestId("signout").click();
- await expect(page.getByTestId("signin-form")).toBeVisible();
+ await expect(page.getByTestId("signin-form")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("signout")).toHaveCount(0);
}
@@ -62,3 +72,123 @@ test("switching accounts on the same device never shows the previous member's sh
await expect(page.getByTestId("not-invited")).toBeVisible();
await expect(page.getByTestId("nav-discover")).toHaveCount(0);
});
+
+test("signing out in one tab resets every tab, and the next account never sees the previous household", async ({ page, request }) => {
+ await clearRecords(request);
+ await seedRestaurant(request, "r-tabs", { name: "Tab Test Bistro" });
+ await seedNote(request, "r-tabs", "n1", { text: "Tab test note" });
+
+ await signIn(page, "ava@safebite.test");
+ await expect(page.getByTestId("nav-discover")).toBeVisible();
+ const second = await page.context().newPage(); // same browser context: shared auth persistence
+ await page.goto("/restaurants/r-tabs");
+ await second.goto("/restaurants/r-tabs");
+ for (const p of [page, second]) {
+ await expect(p.getByTestId("restaurant-name")).toHaveText("Tab Test Bistro");
+ await expect(p.getByTestId("notes-section")).toContainText("Tab test note");
+ await p.evaluate(() => {
+ (window as unknown as { __beforeSignOut?: boolean }).__beforeSignOut = true;
+ });
+ }
+ // Every later document in the second tab records whether it ever renders the old household.
+ await second.addInitScript(() => {
+ const w = window as unknown as { __sawOldData?: boolean };
+ w.__sawOldData = false;
+ new MutationObserver(() => {
+ const text = document.body?.innerText ?? "";
+ if (text.includes("Tab Test Bistro") || text.includes("Tab test note")) w.__sawOldData = true;
+ }).observe(document, { childList: true, subtree: true, characterData: true });
+ });
+
+ await page.getByTestId("nav-settings").click();
+ await page.getByTestId("signout").click();
+
+ for (const p of [page, second]) {
+ await expect(p.getByTestId("signin-form")).toBeVisible();
+ // A new document: the marker set before sign-out is gone, so the tab really reloaded.
+ await expect.poll(() => p.evaluate(() => (window as unknown as { __beforeSignOut?: boolean }).__beforeSignOut ?? false)).toBe(false);
+ }
+
+ await fillSignIn(second, "stranger@safebite.test");
+ await expect(second.getByTestId("not-invited")).toBeVisible();
+ await expect(second.getByText("Tab Test Bistro")).toHaveCount(0);
+ expect(await second.evaluate(() => (window as unknown as { __sawOldData?: boolean }).__sawOldData)).toBe(false);
+ await second.close();
+});
+
+test("a member changes their password, stays signed in, and only the new password works afterwards", async ({ page, request }) => {
+ try {
+ await signIn(page, "bogdan@safebite.test");
+ await expect(page.getByTestId("nav-discover")).toBeVisible();
+ await page.getByTestId("nav-settings").click();
+
+ await page.getByTestId("pw-current").fill("not-my-password");
+ await page.getByTestId("pw-new").fill("changed-password-1");
+ await page.getByTestId("pw-confirm").fill("changed-password-1");
+ await page.getByTestId("pw-submit").click();
+ await expect(page.getByTestId("pw-outcome")).toHaveAttribute("data-kind", "wrongCurrent");
+
+ // A marker on the window survives only if the document is never reloaded.
+ await page.evaluate(() => { (window as unknown as { __beforePwChange?: boolean }).__beforePwChange = true; });
+ await page.getByTestId("pw-current").fill(PASSWORD);
+ await page.getByTestId("pw-submit").click();
+ await expect(page.getByTestId("pw-success")).toHaveText("Password changed");
+ await expect(page.getByTestId("nav-settings")).toBeVisible(); // same UID: no reset
+ expect(await page.evaluate(() => (window as unknown as { __beforePwChange?: boolean }).__beforePwChange)).toBe(true);
+
+ await signOutAndWait(page);
+ await fillSignIn(page, "bogdan@safebite.test", PASSWORD);
+ await expect(page.getByTestId("signin-error")).toBeVisible();
+ await fillSignIn(page, "bogdan@safebite.test", "changed-password-1");
+ await expect(page.getByTestId("nav-discover")).toBeVisible();
+ } finally {
+ await setPasswordViaAdmin(request, "bogdan-uid", PASSWORD);
+ }
+});
+
+// The Firebase SDK looks the account up after `accounts:update` succeeds. A failure there rejects
+// updatePassword although the password already changed, so the page must not promise that the old
+// one still works (audit F3). The real emulator commits the update; only the lookup after it fails.
+for (const fault of ["an internal error", "a lost connection"] as const) {
+ test(`a password change that fails after the update request (${fault}) says the outcome is uncertain`, async ({ page, request }) => {
+ const NEXT = "uncertain-password-1";
+ try {
+ await signIn(page, "bogdan@safebite.test");
+ await expect(page.getByTestId("nav-discover")).toBeVisible();
+ await page.getByTestId("nav-settings").click();
+
+ let updateCommitted = false;
+ let injected = false;
+ await page.route(/127\.0\.0\.1:9099\/identitytoolkit\.googleapis\.com\/v1\/accounts:(update|lookup)\?/, async (route) => {
+ const operation = new URL(route.request().url()).pathname.split(":").at(-1);
+ if (operation === "update" && (route.request().postDataJSON() as { password?: string } | null)?.password === NEXT) {
+ const response = await route.fetch();
+ updateCommitted = response.ok();
+ await route.fulfill({ response });
+ } else if (operation === "lookup" && updateCommitted && !injected) {
+ injected = true;
+ if (fault === "a lost connection") await route.abort("failed");
+ else await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: { code: 500, message: "INTERNAL_ERROR" } }) });
+ } else {
+ await route.continue();
+ }
+ });
+
+ await page.getByTestId("pw-current").fill(PASSWORD);
+ await page.getByTestId("pw-new").fill(NEXT);
+ await page.getByTestId("pw-confirm").fill(NEXT);
+ await page.getByTestId("pw-submit").click();
+
+ await expect(page.getByTestId("pw-outcome")).toHaveAttribute("data-kind", "uncertain");
+ await expect(page.getByTestId("pw-outcome")).toHaveText("We couldn't confirm whether your password changed. Sign out, then sign in with your new password; if that doesn't work, use your old one.");
+ await expect(page.getByTestId("pw-success")).toHaveCount(0);
+ await expect(page.getByTestId("pw-current")).toHaveValue("");
+ expect(updateCommitted).toBe(true);
+ expect(injected).toBe(true);
+ expect(await passwordAccepted(request, "bogdan@safebite.test", NEXT)).toBe(true);
+ expect(await passwordAccepted(request, "bogdan@safebite.test", PASSWORD)).toBe(false);
+ } finally {
+ await setPasswordViaAdmin(request, "bogdan-uid", PASSWORD);
+ }
+ });
+}
diff --git a/web/e2e/collection.spec.ts b/web/e2e/collection.spec.ts
new file mode 100644
index 0000000..f2ad08b
--- /dev/null
+++ b/web/e2e/collection.spec.ts
@@ -0,0 +1,216 @@
+import { expect, test, type Page } from "@playwright/test";
+import {
+ clearRecords,
+ collectionExists,
+ getCollectionVisit,
+ listClaimIds,
+ listNoteIds,
+ markDeletingViaRest,
+ restaurantExists,
+ seedClaim,
+ seedCollection,
+ seedNote,
+ seedRestaurant,
+ sweepClaimsViaRest,
+ updateNoteViaRest,
+ writeVisitedByBogdanViaRest,
+} from "./emulator-rest";
+
+const PASSWORD = "pilot-password-1";
+
+async function signIn(page: Page, email: string) {
+ await page.goto("/");
+ await expect(page.getByTestId("signin-form")).toBeVisible({ timeout: 15_000 });
+ await page.getByTestId("signin-email").fill(email);
+ await page.getByTestId("signin-password").fill(PASSWORD);
+ await page.getByTestId("signin-submit").click();
+ await expect(page.getByTestId("nav-saved")).toBeVisible();
+}
+
+test.beforeEach(async ({ request }) => {
+ await clearRecords(request);
+});
+
+test("C1. a shortlist change by one member appears live on the other member's Saved page", async ({ page, browser, request }) => {
+ await seedRestaurant(request, "r-a", { name: "Da Marco" });
+ await seedRestaurant(request, "r-b", { name: "Zest" });
+
+ const bogdanContext = await browser.newContext();
+ const bogdan = await bogdanContext.newPage();
+ await signIn(bogdan, "bogdan@safebite.test");
+ await bogdan.getByTestId("nav-saved").click();
+ await expect(bogdan.getByTestId("shortlist-empty")).toBeVisible();
+
+ await signIn(page, "ava@safebite.test");
+ await page.goto("/restaurants/r-a");
+ await page.getByTestId("shortlist-add").click();
+ await expect(page.getByTestId("shortlist-state")).toHaveText("On shortlist");
+ await expect(page.getByTestId("status-changed-by")).toHaveText("Last changed by Ava");
+
+ await expect(bogdan.getByTestId("restaurant-row")).toHaveCount(1);
+ await expect(bogdan.getByTestId("restaurant-row")).toContainText("Da Marco");
+ await expect(bogdan.getByTestId("label-shortlisted")).toBeVisible();
+ await bogdan.getByTestId("filter-all").click();
+ await expect(bogdan.getByTestId("restaurant-row")).toHaveCount(2);
+ await bogdanContext.close();
+ expect(await collectionExists(request, "r-a")).toBe(true);
+});
+
+test("C2. mark visited, change the date, and clear it", async ({ page, request }) => {
+ await seedRestaurant(request, "r-v", { name: "Visited Place" });
+ await signIn(page, "ava@safebite.test");
+ await page.goto("/restaurants/r-v");
+ await page.getByTestId("visited-mark").click();
+ await page.getByTestId("visited-date").fill("2026-05-03");
+ await page.getByTestId("visited-save").click();
+ await expect(page.getByTestId("visited-state")).toHaveText("Visited 3 May 2026");
+
+ await page.getByTestId("visited-change").click();
+ await page.getByTestId("visited-date").fill("2026-05-10");
+ await page.getByTestId("visited-save").click();
+ await expect(page.getByTestId("visited-state")).toHaveText("Visited 10 May 2026");
+
+ await page.getByTestId("nav-saved").click();
+ await page.getByTestId("filter-all").click();
+ await expect(page.getByTestId("label-visited")).toHaveText("Visited 10 May 2026");
+
+ await page.goto("/restaurants/r-v");
+ await page.getByTestId("visited-clear").click();
+ await expect(page.getByTestId("visited-mark")).toBeVisible();
+});
+
+test("C3. both members write notes; only the author can edit or delete", async ({ page, browser, request }) => {
+ await seedRestaurant(request, "r-n", { name: "Notes Place" });
+ await seedNote(request, "r-n", "n-ava", { authorUid: "ava-uid", text: "Ava: staff checked with the chef" });
+
+ const bogdanContext = await browser.newContext();
+ const bogdan = await bogdanContext.newPage();
+ await signIn(bogdan, "bogdan@safebite.test");
+ await bogdan.goto("/restaurants/r-n");
+ await expect(bogdan.getByTestId("note-n-ava")).toContainText("Ava: staff checked with the chef");
+ await expect(bogdan.getByTestId("note-edit-n-ava")).toHaveCount(0);
+ await expect(bogdan.getByTestId("note-delete-n-ava")).toHaveCount(0);
+ await bogdan.getByTestId("note-add-text").fill("Bogdan: fryer is shared, avoid chips");
+ await bogdan.getByTestId("note-add-save").click();
+ const bogdanEdit = bogdan.locator('[data-testid^="note-edit-"]');
+ await expect(bogdanEdit).toHaveCount(1);
+ await bogdanEdit.click();
+ await bogdan.locator('[data-testid^="note-edit-text-"]').fill("Bogdan: fryer is shared, no chips");
+ await bogdan.locator('[data-testid^="note-save-"]').click();
+
+ await signIn(page, "ava@safebite.test");
+ await page.goto("/restaurants/r-n");
+ await expect(page.getByTestId("notes-section")).toContainText("Bogdan: fryer is shared, no chips");
+ await expect(page.getByTestId("notes-section")).toContainText("edited");
+ await expect(page.locator('[data-testid^="note-edit-"]')).toHaveCount(1); // only her own
+ await page.getByTestId("note-delete-n-ava").click();
+ await page.getByTestId("note-delete-confirm-n-ava").click();
+ await expect(page.getByTestId("note-n-ava")).toHaveCount(0);
+ await expect(bogdan.getByTestId("note-n-ava")).toHaveCount(0);
+ await bogdanContext.close();
+ expect(await listNoteIds(request, "r-n")).toHaveLength(1);
+});
+
+test("C4. deleting a restaurant removes its evidence, both members' notes and its shortlist state", async ({ page, request }) => {
+ await seedRestaurant(request, "r-del", { name: "Doomed" });
+ await seedClaim(request, "r-del", "c1");
+ await seedNote(request, "r-del", "n1", { authorUid: "ava-uid" });
+ await seedNote(request, "r-del", "n2", { authorUid: "bogdan-uid" });
+ await seedCollection(request, "r-del", { shortlisted: true, visitedOn: "2026-05-03" });
+
+ await signIn(page, "ava@safebite.test");
+ await page.goto("/restaurants/r-del/edit");
+ await expect(page.getByTestId("field-name")).toHaveValue("Doomed");
+ await page.getByTestId("delete-restaurant").click();
+ await expect(page.getByTestId("delete-question")).toHaveText("Deletes the restaurant, its evidence, and both members' notes.");
+ await page.getByTestId("delete-confirm").click();
+ await expect(page.getByTestId("restaurants-empty")).toBeVisible({ timeout: 15_000 });
+
+ expect(await restaurantExists(request, "r-del")).toBe(false);
+ expect(await listClaimIds(request, "r-del")).toEqual([]);
+ expect(await listNoteIds(request, "r-del")).toEqual([]);
+ expect(await collectionExists(request, "r-del")).toBe(false);
+});
+
+test("C5. a deletion an older client left half-done is finished from the Saved page", async ({ page, request }) => {
+ await seedRestaurant(request, "r-old", { name: "Half Gone" });
+ await seedClaim(request, "r-old", "c1");
+ await seedNote(request, "r-old", "n1", { authorUid: "bogdan-uid" });
+ await seedCollection(request, "r-old");
+ await markDeletingViaRest(request, "r-old");
+ await sweepClaimsViaRest(request, "r-old"); // the old client got this far; the gate refused its final delete
+
+ await signIn(page, "ava@safebite.test");
+ await page.getByTestId("nav-saved").click();
+ await expect(page.getByTestId("restaurants-empty")).toBeVisible({ timeout: 15_000 });
+ expect(await restaurantExists(request, "r-old")).toBe(false);
+ expect(await listNoteIds(request, "r-old")).toEqual([]);
+ expect(await collectionExists(request, "r-old")).toBe(false);
+});
+
+test("C6. a note edited on another device surfaces as a conflict, and Keep mine wins with the new version", async ({ page, request }) => {
+ await seedRestaurant(request, "r-c", { name: "Conflict Cafe" });
+ await seedNote(request, "r-c", "n-ava", { authorUid: "ava-uid", text: "First thoughts" });
+
+ await signIn(page, "ava@safebite.test");
+ await page.goto("/restaurants/r-c");
+ await page.getByTestId("note-edit-n-ava").click();
+ await page.getByTestId("note-edit-text-n-ava").fill("My draft from the laptop");
+ await updateNoteViaRest(request, "r-c", "n-ava", "Written on the phone", 2);
+ await expect(page.getByTestId("note-n-ava")).toHaveAttribute("data-version", "2");
+ await page.getByTestId("note-save-n-ava").click();
+ await expect(page.getByTestId("note-conflict-current-n-ava")).toHaveText("Written on the phone");
+ await expect(page.getByTestId("note-edit-text-n-ava")).toHaveValue("My draft from the laptop");
+ await page.getByTestId("note-keep-mine-n-ava").click();
+ await expect(page.getByTestId("note-n-ava")).toContainText("My draft from the laptop");
+ await expect(page.getByTestId("note-n-ava")).toHaveAttribute("data-version", "3");
+});
+
+test("C7. while offline the page says so once and every write control is disabled", async ({ page, context, request }) => {
+ await seedRestaurant(request, "r-off", { name: "Offline Place" });
+ await signIn(page, "ava@safebite.test");
+ await page.goto("/restaurants/r-off");
+ await expect(page.getByTestId("shortlist-add")).toBeEnabled();
+ await context.setOffline(true);
+ await expect(page.getByTestId("read-offline")).toHaveCount(1, { timeout: 15_000 });
+ await expect(page.getByTestId("shortlist-add")).toBeDisabled();
+ await expect(page.getByTestId("note-add-save")).toBeDisabled();
+ await context.setOffline(false);
+ await expect(page.getByTestId("shortlist-add")).toBeEnabled({ timeout: 15_000 });
+ expect(await collectionExists(request, "r-off")).toBe(false);
+});
+
+test("C8. a visit-date draft left open while the other member saves a date ends in a conflict, not an overwrite", async ({ page, request }) => {
+ await seedRestaurant(request, "r-d", { name: "Draft Diner" });
+ await seedCollection(request, "r-d", { shortlisted: false, visitedOn: "2026-05-03", version: 1 });
+ await signIn(page, "ava@safebite.test");
+ await page.goto("/restaurants/r-d");
+ await expect(page.getByTestId("visited-state")).toHaveText("Visited 3 May 2026");
+ await page.getByTestId("visited-change").click();
+ await page.getByTestId("visited-date").fill("2026-05-04");
+
+ await writeVisitedByBogdanViaRest(request, "r-d", "2026-05-10", 2);
+ await expect(page.getByTestId("status-changed-by")).toHaveText("Last changed by Bogdan");
+ await expect(page.getByTestId("visited-date")).toHaveValue("2026-05-04");
+ await page.getByTestId("visited-save").click();
+
+ await expect(page.getByTestId("status-outcome")).toHaveAttribute("data-kind", "conflict");
+ await expect(page.getByTestId("visited-state")).toHaveText("Visited 10 May 2026");
+ expect(await getCollectionVisit(request, "r-d")).toEqual({ visitedOn: "2026-05-10T00:00:00Z", version: 2, updatedByName: "Bogdan" });
+});
+
+test("C8b. a Mark visited draft opened before any state existed conflicts once the other member creates it", async ({ page, request }) => {
+ await seedRestaurant(request, "r-d0", { name: "Draft Zero" });
+ await signIn(page, "ava@safebite.test");
+ await page.goto("/restaurants/r-d0");
+ await page.getByTestId("visited-mark").click();
+ await page.getByTestId("visited-date").fill("2026-05-04");
+
+ await writeVisitedByBogdanViaRest(request, "r-d0", "2026-05-10", 1);
+ await expect(page.getByTestId("status-changed-by")).toHaveText("Last changed by Bogdan");
+ await page.getByTestId("visited-save").click();
+
+ await expect(page.getByTestId("status-outcome")).toHaveAttribute("data-kind", "conflict");
+ await expect(page.getByTestId("visited-state")).toHaveText("Visited 10 May 2026");
+ expect(await getCollectionVisit(request, "r-d0")).toEqual({ visitedOn: "2026-05-10T00:00:00Z", version: 1, updatedByName: "Bogdan" });
+});
diff --git a/web/e2e/discover.spec.ts b/web/e2e/discover.spec.ts
index f45a3fb..17f5d65 100644
--- a/web/e2e/discover.spec.ts
+++ b/web/e2e/discover.spec.ts
@@ -8,7 +8,7 @@ const utcDay = () => new Date().toISOString().slice(0, 10).replace(/-/g, "");
async function signIn(page: Page, email: string) {
await page.goto("/");
- await expect(page.getByTestId("signin-form")).toBeVisible();
+ await expect(page.getByTestId("signin-form")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("signin-email").fill(email);
await page.getByTestId("signin-password").fill(PASSWORD);
await page.getByTestId("signin-submit").click();
@@ -149,7 +149,10 @@ test("7. a newer search supersedes a slower one; the late answer never replaces
await search(page, MAGIC.delayed);
await expect(stateOf(page)).toHaveAttribute("data-status", "searching");
await search(page, "pizza");
- await expect(page.getByTestId("discover-result")).toHaveCount(10);
+ // The Functions emulator's AUTO mode cold-spawns a second worker while __delayed__ holds the
+ // warm one (and scenario 6's __slow__ may still hold another), so this second search's results
+ // can take longer than the default 5 s expect timeout.
+ await expect(page.getByTestId("discover-result")).toHaveCount(10, { timeout: 30_000 });
await page.waitForTimeout(4_500); // longer than the fixture's 3 s delay
await expect(page.getByTestId("discover-result")).toHaveCount(10);
await expect(page.getByText("Delayed Diner")).toHaveCount(0);
diff --git a/web/e2e/emulator-rest.ts b/web/e2e/emulator-rest.ts
index b260eeb..cc7b18b 100644
--- a/web/e2e/emulator-rest.ts
+++ b/web/e2e/emulator-rest.ts
@@ -23,15 +23,20 @@ async function del(request: APIRequestContext, docPath: string): Promise {
if (!res.ok()) throw new Error(`delete ${docPath}: ${res.status()} ${await res.text()}`);
}
-/** Removes every restaurant (and its claims) under households/home. Seeds (users/households) are untouched. */
+/** Removes every restaurant (with its claims and notes) and every collection document under households/home. Seeds (users/households) are untouched. */
export async function clearRecords(request: APIRequestContext): Promise {
for (const r of await listDocs(request, "households/home/restaurants")) {
const rid = idOf(r);
- for (const c of await listDocs(request, `households/home/restaurants/${rid}/claims`)) {
- await del(request, `households/home/restaurants/${rid}/claims/${idOf(c)}`);
+ for (const sub of ["claims", "notes"]) {
+ for (const c of await listDocs(request, `households/home/restaurants/${rid}/${sub}`)) {
+ await del(request, `households/home/restaurants/${rid}/${sub}/${idOf(c)}`);
+ }
}
await del(request, `households/home/restaurants/${rid}`);
}
+ for (const s of await listDocs(request, "households/home/collection")) {
+ await del(request, `households/home/collection/${idOf(s)}`);
+ }
}
const s = (v: string) => ({ stringValue: v });
@@ -149,3 +154,94 @@ export async function getRestaurant(request: APIRequestContext, rid: string): Pr
}
return out;
}
+
+const MEMBERS: Record = { "ava-uid": "Ava", "bogdan-uid": "Bogdan" };
+
+export async function seedNote(request: APIRequestContext, rid: string, id: string, over: { authorUid?: string; text?: string; version?: number } = {}): Promise {
+ const authorUid = over.authorUid ?? "ava-uid";
+ const res = await request.post(`${BASE}/households/home/restaurants/${rid}/notes?documentId=${id}`, {
+ headers: HEADERS,
+ data: {
+ fields: {
+ text: s(over.text ?? `Seeded note ${id}`),
+ authorUid: s(authorUid),
+ authorName: s(MEMBERS[authorUid] ?? "Unknown"),
+ createdAt: ts("2026-09-01T10:00:00Z"),
+ updatedAt: ts("2026-09-01T10:00:00Z"),
+ version: { integerValue: String(over.version ?? 1) },
+ },
+ },
+ });
+ if (!res.ok()) throw new Error(`seedNote ${rid}/${id}: ${res.status()} ${await res.text()}`);
+}
+
+export async function seedCollection(request: APIRequestContext, rid: string, over: { shortlisted?: boolean; visitedOn?: string; version?: number } = {}): Promise {
+ const fields: Record = {
+ shortlisted: { booleanValue: over.shortlisted ?? true },
+ visited: { booleanValue: over.visitedOn !== undefined },
+ updatedBy: s("ava-uid"),
+ updatedByName: s("Ava"),
+ updatedAt: ts("2026-09-01T10:00:00Z"),
+ version: { integerValue: String(over.version ?? 1) },
+ };
+ if (over.visitedOn !== undefined) fields.visitedOn = ts(`${over.visitedOn}T00:00:00Z`);
+ const res = await request.post(`${BASE}/households/home/collection?documentId=${rid}`, { headers: HEADERS, data: { fields } });
+ if (!res.ok()) throw new Error(`seedCollection ${rid}: ${res.status()} ${await res.text()}`);
+}
+
+export async function listNoteIds(request: APIRequestContext, rid: string): Promise {
+ return (await listDocs(request, `households/home/restaurants/${rid}/notes`)).map(idOf);
+}
+
+export async function collectionExists(request: APIRequestContext, rid: string): Promise {
+ const res = await request.get(`${BASE}/households/home/collection/${rid}`, { headers: HEADERS });
+ return res.status() === 200;
+}
+
+/** Models the same member editing the note on another device. */
+export async function updateNoteViaRest(request: APIRequestContext, rid: string, nid: string, text: string, version: number): Promise {
+ const url = `${BASE}/households/home/restaurants/${rid}/notes/${nid}?updateMask.fieldPaths=text&updateMask.fieldPaths=version&updateMask.fieldPaths=updatedAt`;
+ const res = await request.patch(url, {
+ headers: HEADERS,
+ data: { fields: { text: s(text), version: { integerValue: String(version) }, updatedAt: ts(new Date().toISOString()) } },
+ });
+ if (!res.ok()) throw new Error(`updateNote ${rid}/${nid}: ${res.status()} ${await res.text()}`);
+}
+
+/** Models a Plan 3-era client that swept claims and then had its final delete refused by the gate. */
+export async function sweepClaimsViaRest(request: APIRequestContext, rid: string): Promise {
+ for (const c of await listDocs(request, `households/home/restaurants/${rid}/claims`)) {
+ await del(request, `households/home/restaurants/${rid}/claims/${idOf(c)}`);
+ }
+}
+
+/**
+ * Models Bogdan saving a visit date on his own device: writes the whole collection document
+ * (creating it when missing) as `version`, attributed to Bogdan.
+ */
+export async function writeVisitedByBogdanViaRest(request: APIRequestContext, rid: string, visitedOn: string, version: number): Promise {
+ const res = await request.patch(`${BASE}/households/home/collection/${rid}`, {
+ headers: HEADERS,
+ data: {
+ fields: {
+ shortlisted: { booleanValue: false },
+ visited: { booleanValue: true },
+ visitedOn: ts(`${visitedOn}T00:00:00Z`),
+ updatedBy: s("bogdan-uid"),
+ updatedByName: s("Bogdan"),
+ updatedAt: ts(new Date().toISOString()),
+ version: { integerValue: String(version) },
+ },
+ },
+ });
+ if (!res.ok()) throw new Error(`writeVisitedByBogdan ${rid}: ${res.status()} ${await res.text()}`);
+}
+
+/** The stored visit date (UTC ISO timestamp) and version of one collection document, or null when missing. */
+export async function getCollectionVisit(request: APIRequestContext, rid: string): Promise<{ visitedOn: string | null; version: number; updatedByName: string } | null> {
+ const res = await request.get(`${BASE}/households/home/collection/${rid}`, { headers: HEADERS });
+ if (res.status() === 404) return null;
+ if (!res.ok()) throw new Error(`getCollectionVisit ${rid}: ${res.status()} ${await res.text()}`);
+ const f = ((await res.json()) as RestDoc).fields as Record;
+ return { visitedOn: f.visitedOn?.timestampValue ?? null, version: Number(f.version?.integerValue), updatedByName: f.updatedByName?.stringValue ?? "" };
+}
diff --git a/web/e2e/records.spec.ts b/web/e2e/records.spec.ts
index 17db4cb..0c181c1 100644
--- a/web/e2e/records.spec.ts
+++ b/web/e2e/records.spec.ts
@@ -5,7 +5,7 @@ const PASSWORD = "pilot-password-1";
async function signIn(page: Page, email: string) {
await page.goto("/");
- await expect(page.getByTestId("signin-form")).toBeVisible();
+ await expect(page.getByTestId("signin-form")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("signin-email").fill(email);
await page.getByTestId("signin-password").fill(PASSWORD);
await page.getByTestId("signin-submit").click();
@@ -15,7 +15,7 @@ async function signIn(page: Page, email: string) {
async function signOutAndWait(page: Page) {
await page.getByTestId("nav-settings").click();
await page.getByTestId("signout").click();
- await expect(page.getByTestId("signin-form")).toBeVisible();
+ await expect(page.getByTestId("signin-form")).toBeVisible({ timeout: 15_000 });
}
const KINDS = ["dedicatedKitchen", "separateFryer", "trainedStaff", "gfMenu", "preparationPractice", "accreditation"];
@@ -51,6 +51,7 @@ test("1. a member adds a restaurant and sees it with six unknown kinds and the c
await expect(page.getByTestId("call-ahead")).toContainText("Italian");
await page.getByTestId("nav-saved").click();
+ await page.getByTestId("filter-all").click();
await expect(page.getByTestId("restaurant-row")).toContainText("Da Marco");
});
@@ -185,6 +186,7 @@ test("7. after an account switch no record of the previous member is rendered an
await signIn(page, "ava@safebite.test");
await page.getByTestId("nav-saved").click();
+ await page.getByTestId("filter-all").click();
await expect(page.getByTestId("restaurant-row")).toContainText("Ava's place");
await signOutAndWait(page);
diff --git a/web/playwright.config.ts b/web/playwright.config.ts
index 7df4c3c..4372005 100644
--- a/web/playwright.config.ts
+++ b/web/playwright.config.ts
@@ -3,8 +3,8 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
- // Caps global setup (the emulator warm-up) plus the whole suite run — 29 scenarios, one CI retry each; scenario 6 alone waits ~25 s for the client timeout.
- globalTimeout: 900_000,
+ // Caps global setup (the emulator warm-up) plus the whole suite run — 42 scenarios, one CI retry each; scenario 6 alone waits ~25 s for the client timeout.
+ globalTimeout: 1_200_000,
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 1 : 0,
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 6bb7847..d559084 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -11,6 +11,8 @@ function Gate() {
switch (state.status) {
case "loading":
return Loading…
;
+ case "resetting":
+ return Signing out…
;
case "signedOut":
return ;
case "notMember":
diff --git a/web/src/auth/AuthProvider.test.tsx b/web/src/auth/AuthProvider.test.tsx
index 2736c4f..3e28d2c 100644
--- a/web/src/auth/AuthProvider.test.tsx
+++ b/web/src/auth/AuthProvider.test.tsx
@@ -6,6 +6,8 @@ const { listeners, unsubscribes, getDocMock } = vi.hoisted(() => ({
unsubscribes: [] as Array>,
getDocMock: vi.fn(),
}));
+const { resetDocument } = vi.hoisted(() => ({ resetDocument: vi.fn() }));
+vi.mock("./resetDocument", () => ({ resetDocument }));
vi.mock("../firebase", () => ({ auth: {}, db: {}, functions: {}, usingEmulators: true }));
vi.mock("firebase/auth", () => ({
@@ -45,6 +47,7 @@ beforeEach(() => {
listeners.length = 0;
unsubscribes.length = 0;
getDocMock.mockReset();
+ resetDocument.mockReset();
});
describe("AuthProvider", () => {
@@ -93,7 +96,7 @@ describe("AuthProvider", () => {
await waitFor(() => expect(screen.getByTestId("state")).toHaveTextContent('"status":"error"'));
});
- it("ignores a slow membership lookup that finishes after the user signed out", async () => {
+ it("a sign-out after a sign-in resets the document and a slow lookup never lands", async () => {
const slowUserDoc = deferred>();
getDocMock.mockImplementation((path: string) => {
if (path === "users/ava-uid") return slowUserDoc.promise;
@@ -102,30 +105,58 @@ describe("AuthProvider", () => {
});
render();
listeners[0]({ uid: "ava-uid", email: "ava@safebite.test" });
- expect(screen.getByTestId("state")).toHaveTextContent('"loading"');
listeners[0](null);
- await waitFor(() => expect(screen.getByTestId("state")).toHaveTextContent('"signedOut"'));
+ await waitFor(() => expect(screen.getByTestId("state")).toHaveTextContent('"resetting"'));
+ expect(resetDocument).toHaveBeenCalledTimes(1);
slowUserDoc.resolve(snap({ householdId: "home", displayName: "Ava" }));
await new Promise((r) => setTimeout(r, 20));
- expect(screen.getByTestId("state")).toHaveTextContent('"signedOut"');
- expect(screen.getByTestId("state")).not.toHaveTextContent('"member"');
+ expect(screen.getByTestId("state")).toHaveTextContent('"resetting"');
});
- it("never lets an earlier user's lookup overwrite a later user's state", async () => {
+ it("a different user in the same document resets it; the earlier lookup never lands", async () => {
const slowUserDoc = deferred>();
getDocMock.mockImplementation((path: string) => {
if (path === "users/slow-uid") return slowUserDoc.promise;
- if (path === "users/fast-uid") return Promise.resolve(snap({ householdId: "home", displayName: "Fast" }));
- if (path === "households/home") return Promise.resolve(snap({ name: "Home", memberIds: ["fast-uid", "slow-uid"] }));
return Promise.resolve(snap(undefined));
});
render();
listeners[0]({ uid: "slow-uid", email: "slow@safebite.test" });
listeners[0]({ uid: "fast-uid", email: "fast@safebite.test" });
- await waitFor(() => expect(screen.getByTestId("state")).toHaveTextContent('"displayName":"Fast"'));
+ await waitFor(() => expect(screen.getByTestId("state")).toHaveTextContent('"resetting"'));
+ expect(resetDocument).toHaveBeenCalledTimes(1);
slowUserDoc.resolve(snap({ householdId: "home", displayName: "Slow" }));
await new Promise((r) => setTimeout(r, 20));
- expect(screen.getByTestId("state")).toHaveTextContent('"displayName":"Fast"');
+ expect(screen.getByTestId("state")).toHaveTextContent('"resetting"');
+ });
+
+ it("never resets a document that starts signed out, then signs in", async () => {
+ getDocMock.mockResolvedValue(snap(undefined));
+ render();
+ listeners[0](null);
+ listeners[0]({ uid: "ava-uid", email: "ava@safebite.test" });
+ await waitFor(() => expect(screen.getByTestId("state")).toHaveTextContent('"notMember"'));
+ expect(resetDocument).not.toHaveBeenCalled();
+ });
+
+ it("never resets on a repeated callback for the same user", async () => {
+ getDocMock.mockResolvedValue(snap(undefined));
+ render();
+ listeners[0]({ uid: "ava-uid", email: "ava@safebite.test" });
+ listeners[0]({ uid: "ava-uid", email: "ava@safebite.test" });
+ await waitFor(() => expect(screen.getByTestId("state")).toHaveTextContent('"notMember"'));
+ expect(resetDocument).not.toHaveBeenCalled();
+ });
+
+ it("signOut only signs out; the reset comes from the listener", async () => {
+ const { signOut: firebaseSignOut } = await import("firebase/auth");
+ function SignOutButton() {
+ const { signOut } = useAuth();
+ return ;
+ }
+ render();
+ screen.getByText("out").click();
+ await waitFor(() => expect(firebaseSignOut).toHaveBeenCalledTimes(1));
+ expect(resetDocument).not.toHaveBeenCalled();
});
it("unsubscribes from auth state changes on unmount", () => {
diff --git a/web/src/auth/AuthProvider.tsx b/web/src/auth/AuthProvider.tsx
index 625ee2a..cb14675 100644
--- a/web/src/auth/AuthProvider.tsx
+++ b/web/src/auth/AuthProvider.tsx
@@ -3,9 +3,11 @@ import { onAuthStateChanged, signInWithEmailAndPassword, signOut as firebaseSign
import { doc, getDoc } from "firebase/firestore";
import { auth, db } from "../firebase";
import { resolveMembership } from "./membership";
+import { resetDocument } from "./resetDocument";
export type AuthState =
| { status: "loading" }
+ | { status: "resetting" }
| { status: "signedOut" }
| { status: "notMember"; email: string | null }
| { status: "member"; uid: string; email: string | null; householdId: string; displayName: string }
@@ -49,6 +51,7 @@ async function stateForUser(user: User): Promise {
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState({ status: "loading" });
const generationRef = useRef(0);
+ const lastUidRef = useRef(null);
const resolveForUser = useCallback((user: User) => {
const mine = ++generationRef.current;
@@ -66,11 +69,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
+ const previous = lastUidRef.current;
+ // Auth state is shared by every same-origin tab (audit F2): whichever tab signed out or
+ // switched user, each document that had a user resets itself. Starting signed out, or a
+ // repeat of the same UID, is not a change.
+ if (previous !== null && (user === null || user.uid !== previous)) {
+ generationRef.current += 1;
+ setState({ status: "resetting" });
+ resetDocument();
+ return;
+ }
if (!user) {
generationRef.current += 1;
setState({ status: "signedOut" });
return;
}
+ lastUidRef.current = user.uid;
resolveForUser(user);
});
return unsubscribe;
@@ -81,6 +95,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
const signOut = useCallback(async () => {
+ // The listener resets this document (and every other tab) when the user becomes null.
await firebaseSignOut(auth);
}, []);
diff --git a/web/src/auth/changePassword.test.ts b/web/src/auth/changePassword.test.ts
new file mode 100644
index 0000000..fe7fd6c
--- /dev/null
+++ b/web/src/auth/changePassword.test.ts
@@ -0,0 +1,98 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const f = vi.hoisted(() => ({
+ reauthenticateWithCredential: vi.fn(),
+ updatePassword: vi.fn(),
+ credential: vi.fn((email: string, password: string) => ({ email, password })),
+ currentUser: { email: "ava@safebite.test" } as { email: string | null } | null,
+}));
+vi.mock("../firebase", () => ({
+ get auth() {
+ return { currentUser: f.currentUser };
+ },
+}));
+vi.mock("firebase/auth", () => ({
+ EmailAuthProvider: { credential: f.credential },
+ reauthenticateWithCredential: f.reauthenticateWithCredential,
+ updatePassword: f.updatePassword,
+}));
+
+import { CHANGE_PASSWORD_MESSAGES, changePassword, validateNewPassword } from "./changePassword";
+
+const err = (code: string) => Object.assign(new Error(code), { code });
+
+beforeEach(() => {
+ Object.defineProperty(navigator, "onLine", { configurable: true, value: true });
+ f.currentUser = { email: "ava@safebite.test" };
+ f.reauthenticateWithCredential.mockResolvedValue({});
+ f.updatePassword.mockResolvedValue(undefined);
+});
+afterEach(() => vi.clearAllMocks());
+
+describe("validateNewPassword", () => {
+ it("needs at least 8 characters and a matching confirmation", () => {
+ expect(validateNewPassword("short", "short")).toBe("Use at least 8 characters.");
+ expect(validateNewPassword("long-enough", "long-enougH")).toBe("The two new passwords don't match.");
+ expect(validateNewPassword("long-enough", "long-enough")).toBeNull();
+ });
+});
+
+describe("changePassword", () => {
+ it("reauthenticates with the current password, then updates", async () => {
+ expect(await changePassword("old-password", "new-password")).toBe("ok");
+ expect(f.credential).toHaveBeenCalledWith("ava@safebite.test", "old-password");
+ expect(f.updatePassword).toHaveBeenCalledWith({ email: "ava@safebite.test" }, "new-password");
+ });
+
+ it.each([
+ ["auth/invalid-credential", "wrongCurrent"],
+ ["auth/wrong-password", "wrongCurrent"],
+ ["auth/too-many-requests", "tooManyRequests"],
+ ["auth/network-request-failed", "offline"],
+ ["auth/internal-error", "failed"],
+ ])("a failed reauthentication (%s) is %s and never attempts the update", async (code, result) => {
+ f.reauthenticateWithCredential.mockRejectedValue(err(code));
+ expect(await changePassword("old-password", "new-password")).toBe(result);
+ expect(f.updatePassword).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["auth/weak-password", "policy"],
+ ["auth/password-does-not-meet-requirements", "policy"],
+ ["auth/requires-recent-login", "recentLogin"],
+ ])("a definitive update rejection (%s) is %s", async (code, result) => {
+ f.updatePassword.mockRejectedValue(err(code));
+ expect(await changePassword("old-password", "new-password")).toBe(result);
+ });
+
+ // The SDK looks the account up after the update request succeeds, so any other failure in this
+ // phase cannot prove the password is unchanged (audit F3).
+ it.each(["auth/network-request-failed", "auth/too-many-requests", "auth/internal-error", "something/unexpected"])(
+ "any other update-phase failure (%s) is uncertain",
+ async (code) => {
+ f.updatePassword.mockRejectedValue(err(code));
+ expect(await changePassword("old-password", "new-password")).toBe("uncertain");
+ },
+ );
+
+ it("is offline without calling Firebase when the browser is offline, and failed without a signed-in email", async () => {
+ Object.defineProperty(navigator, "onLine", { configurable: true, value: false });
+ expect(await changePassword("a", "b")).toBe("offline");
+ Object.defineProperty(navigator, "onLine", { configurable: true, value: true });
+ f.currentUser = null;
+ expect(await changePassword("a", "b")).toBe("failed");
+ expect(f.reauthenticateWithCredential).not.toHaveBeenCalled();
+ });
+
+ it("has a message for every failure", () => {
+ expect(CHANGE_PASSWORD_MESSAGES).toEqual({
+ wrongCurrent: "That isn't your current password.",
+ tooManyRequests: "Too many attempts. Wait a few minutes and try again.",
+ offline: "You are offline. Connect and try again.",
+ policy: "Your new password doesn't meet this account's password rules. Choose a different one.",
+ recentLogin: "For security, sign out and back in, then try again.",
+ failed: "Couldn't change your password. Your old password still works.",
+ uncertain: "We couldn't confirm whether your password changed. Sign out, then sign in with your new password; if that doesn't work, use your old one.",
+ });
+ });
+});
diff --git a/web/src/auth/changePassword.ts b/web/src/auth/changePassword.ts
new file mode 100644
index 0000000..5217562
--- /dev/null
+++ b/web/src/auth/changePassword.ts
@@ -0,0 +1,70 @@
+import { EmailAuthProvider, reauthenticateWithCredential, updatePassword } from "firebase/auth";
+import { auth } from "../firebase";
+
+/**
+ * Change password (spec §3.7, amended after audit F3). The 8-character minimum is a client rule;
+ * a server password policy may be stricter, so its rejection has its own outcome. "uncertain" means
+ * the update request was sent and may have succeeded (implementation audit F3, 2026-09-24).
+ */
+export type ChangePasswordResult = "ok" | "wrongCurrent" | "tooManyRequests" | "offline" | "policy" | "recentLogin" | "failed" | "uncertain";
+
+export const MIN_PASSWORD_LENGTH = 8;
+
+export const CHANGE_PASSWORD_MESSAGES: Record, string> = {
+ wrongCurrent: "That isn't your current password.",
+ tooManyRequests: "Too many attempts. Wait a few minutes and try again.",
+ offline: "You are offline. Connect and try again.",
+ policy: "Your new password doesn't meet this account's password rules. Choose a different one.",
+ recentLogin: "For security, sign out and back in, then try again.",
+ failed: "Couldn't change your password. Your old password still works.",
+ uncertain: "We couldn't confirm whether your password changed. Sign out, then sign in with your new password; if that doesn't work, use your old one.",
+};
+
+export function validateNewPassword(next: string, confirm: string): string | null {
+ if (next.length < MIN_PASSWORD_LENGTH) return `Use at least ${MIN_PASSWORD_LENGTH} characters.`;
+ if (next !== confirm) return "The two new passwords don't match.";
+ return null;
+}
+
+const code = (err: unknown) => (err as { code?: string }).code;
+
+function reauthFailure(err: unknown): ChangePasswordResult {
+ switch (code(err)) {
+ case "auth/invalid-credential":
+ case "auth/wrong-password": return "wrongCurrent";
+ case "auth/too-many-requests": return "tooManyRequests";
+ case "auth/network-request-failed": return "offline";
+ default: return "failed";
+ }
+}
+
+/**
+ * Only these rejections prove the password was not changed. The SDK looks the account up after the
+ * update request succeeds, so any other failure (network, rate limit, unknown) may follow a
+ * committed change.
+ */
+function updateFailure(err: unknown): ChangePasswordResult {
+ switch (code(err)) {
+ case "auth/weak-password":
+ case "auth/password-does-not-meet-requirements": return "policy";
+ case "auth/requires-recent-login": return "recentLogin";
+ default: return "uncertain";
+ }
+}
+
+export async function changePassword(current: string, next: string): Promise {
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return "offline";
+ const user = auth.currentUser;
+ if (!user || !user.email) return "failed";
+ try {
+ await reauthenticateWithCredential(user, EmailAuthProvider.credential(user.email, current));
+ } catch (err) {
+ return reauthFailure(err); // never attempt the update after a failed reauthentication
+ }
+ try {
+ await updatePassword(user, next);
+ } catch (err) {
+ return updateFailure(err);
+ }
+ return "ok";
+}
diff --git a/web/src/auth/resetDocument.ts b/web/src/auth/resetDocument.ts
new file mode 100644
index 0000000..7c79eff
--- /dev/null
+++ b/web/src/auth/resetDocument.ts
@@ -0,0 +1,8 @@
+/**
+ * Full reload to the app root (spec §3.7 "Account switch"). Discards every Firestore listener,
+ * the Firestore memory cache and all React state in this tab. A module of its own so tests can
+ * replace it (jsdom does not implement navigation).
+ */
+export function resetDocument(): void {
+ window.location.replace("/");
+}
diff --git a/web/src/pages/ChangePasswordForm.test.tsx b/web/src/pages/ChangePasswordForm.test.tsx
new file mode 100644
index 0000000..55f1b0c
--- /dev/null
+++ b/web/src/pages/ChangePasswordForm.test.tsx
@@ -0,0 +1,92 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const { changePassword } = vi.hoisted(() => ({ changePassword: vi.fn() }));
+vi.mock("../auth/changePassword", async (importOriginal) => ({
+ ...(await importOriginal()),
+ changePassword,
+}));
+vi.mock("../firebase", () => ({ auth: {} }));
+
+import { ChangePasswordForm } from "./ChangePasswordForm";
+
+afterEach(() => vi.clearAllMocks());
+
+async function fill(current: string, next: string, confirm = next) {
+ await userEvent.type(screen.getByTestId("pw-current"), current);
+ await userEvent.type(screen.getByTestId("pw-new"), next);
+ await userEvent.type(screen.getByTestId("pw-confirm"), confirm);
+ await userEvent.click(screen.getByTestId("pw-submit"));
+}
+
+describe("ChangePasswordForm", () => {
+ it("validates on the client before calling Firebase", async () => {
+ render();
+ await fill("old-password", "short");
+ expect(screen.getByTestId("pw-error")).toHaveTextContent("Use at least 8 characters.");
+ expect(changePassword).not.toHaveBeenCalled();
+ });
+
+ it("requires the current password before checking the new one", async () => {
+ render();
+ await userEvent.type(screen.getByTestId("pw-new"), "short");
+ await userEvent.type(screen.getByTestId("pw-confirm"), "short");
+ await userEvent.click(screen.getByTestId("pw-submit"));
+ expect(screen.getByTestId("pw-error")).toHaveTextContent("Enter your current password.");
+ expect(changePassword).not.toHaveBeenCalled();
+ });
+
+ it("shows success only after the change resolves, and clears the fields", async () => {
+ let resolve!: (v: string) => void;
+ changePassword.mockReturnValue(new Promise((r) => (resolve = r)));
+ render();
+ await fill("old-password", "new-password");
+ expect(screen.queryByTestId("pw-success")).toBeNull();
+ expect(screen.getByTestId("pw-submit")).toBeDisabled();
+ resolve("ok");
+ await waitFor(() => expect(screen.getByTestId("pw-success")).toHaveTextContent("Password changed"));
+ expect(screen.getByTestId("pw-current")).toHaveValue("");
+ expect(screen.getByTestId("pw-new")).toHaveValue("");
+ });
+
+ it("a wrong current password clears only that field and keeps the form usable", async () => {
+ changePassword.mockResolvedValue("wrongCurrent");
+ render();
+ await fill("bad-password", "new-password");
+ await waitFor(() => expect(screen.getByTestId("pw-outcome")).toHaveAttribute("data-kind", "wrongCurrent"));
+ expect(screen.getByTestId("pw-outcome")).toHaveTextContent("That isn't your current password.");
+ expect(screen.getByTestId("pw-current")).toHaveValue("");
+ expect(screen.getByTestId("pw-new")).toHaveValue("new-password");
+ expect(screen.getByTestId("pw-submit")).toBeEnabled();
+ expect(screen.queryByTestId("pw-success")).toBeNull();
+ });
+
+ it("a policy rejection clears the new-password fields; unknown errors keep every field", async () => {
+ changePassword.mockResolvedValueOnce("policy").mockResolvedValueOnce("failed");
+ render();
+ await fill("old-password", "new-password");
+ await waitFor(() => expect(screen.getByTestId("pw-outcome")).toHaveAttribute("data-kind", "policy"));
+ expect(screen.getByTestId("pw-current")).toHaveValue("old-password");
+ expect(screen.getByTestId("pw-new")).toHaveValue("");
+ expect(screen.getByTestId("pw-confirm")).toHaveValue("");
+ await userEvent.type(screen.getByTestId("pw-new"), "another-password");
+ await userEvent.type(screen.getByTestId("pw-confirm"), "another-password");
+ await userEvent.click(screen.getByTestId("pw-submit"));
+ await waitFor(() => expect(screen.getByTestId("pw-outcome")).toHaveAttribute("data-kind", "failed"));
+ expect(screen.getByTestId("pw-outcome")).toHaveTextContent("Couldn't change your password. Your old password still works.");
+ expect(screen.getByTestId("pw-new")).toHaveValue("another-password");
+ });
+
+ it("an uncertain outcome clears every field, explains how to recover, and never claims success", async () => {
+ changePassword.mockResolvedValue("uncertain");
+ render();
+ await fill("old-password", "new-password");
+ await waitFor(() => expect(screen.getByTestId("pw-outcome")).toHaveAttribute("data-kind", "uncertain"));
+ expect(screen.getByTestId("pw-outcome")).toHaveTextContent("We couldn't confirm whether your password changed. Sign out, then sign in with your new password; if that doesn't work, use your old one.");
+ expect(screen.getByTestId("pw-current")).toHaveValue("");
+ expect(screen.getByTestId("pw-new")).toHaveValue("");
+ expect(screen.getByTestId("pw-confirm")).toHaveValue("");
+ expect(screen.queryByTestId("pw-success")).toBeNull();
+ });
+});
diff --git a/web/src/pages/ChangePasswordForm.tsx b/web/src/pages/ChangePasswordForm.tsx
new file mode 100644
index 0000000..8adb86d
--- /dev/null
+++ b/web/src/pages/ChangePasswordForm.tsx
@@ -0,0 +1,69 @@
+import { useState, type SubmitEvent } from "react";
+import { CHANGE_PASSWORD_MESSAGES, changePassword, validateNewPassword, type ChangePasswordResult } from "../auth/changePassword";
+
+export function ChangePasswordForm() {
+ const [current, setCurrent] = useState("");
+ const [next, setNext] = useState("");
+ const [confirm, setConfirm] = useState("");
+ const [error, setError] = useState(null);
+ const [outcome, setOutcome] = useState | null>(null);
+ const [success, setSuccess] = useState(false);
+ const [busy, setBusy] = useState(false);
+
+ async function onSubmit(event: SubmitEvent) {
+ event.preventDefault();
+ setSuccess(false);
+ setOutcome(null);
+ if (!current) {
+ setError("Enter your current password.");
+ return;
+ }
+ const problem = validateNewPassword(next, confirm);
+ setError(problem);
+ if (problem) return;
+ setBusy(true);
+ const result = await changePassword(current, next);
+ setBusy(false);
+ if (result === "ok") {
+ setCurrent("");
+ setNext("");
+ setConfirm("");
+ setSuccess(true);
+ return;
+ }
+ if (result === "wrongCurrent") setCurrent("");
+ // The update may have gone through, so the current password may no longer be current.
+ if (result === "uncertain") {
+ setCurrent("");
+ setNext("");
+ setConfirm("");
+ }
+ if (result === "policy") {
+ setNext("");
+ setConfirm("");
+ }
+ setOutcome(result);
+ }
+
+ return (
+
+ );
+}
diff --git a/web/src/pages/SettingsPage.test.tsx b/web/src/pages/SettingsPage.test.tsx
index 3dcc6dd..4002fa1 100644
--- a/web/src/pages/SettingsPage.test.tsx
+++ b/web/src/pages/SettingsPage.test.tsx
@@ -13,6 +13,7 @@ vi.mock("firebase/functions", () => ({
vi.mock("../auth/AuthProvider", () => ({
useAuth: () => ({ state: { status: "member", uid: "ava-uid", email: "ava@safebite.test", householdId: "home", displayName: "Ava" }, signOut: vi.fn() }),
}));
+vi.mock("./ChangePasswordForm", () => ({ ChangePasswordForm: () => }));
import { SettingsPage } from "./SettingsPage";
@@ -29,4 +30,9 @@ describe("SettingsPage", () => {
render();
await waitFor(() => expect(screen.getByTestId("whoami")).toHaveTextContent('household “home”'));
});
+
+ it("offers the change-password form", () => {
+ render();
+ expect(screen.getByTestId("pw-form")).toBeInTheDocument();
+ });
});
diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx
index 970083d..224f1c7 100644
--- a/web/src/pages/SettingsPage.tsx
+++ b/web/src/pages/SettingsPage.tsx
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { abortable, callable, isAbortError } from "../api/callable";
import { useAuth } from "../auth/AuthProvider";
+import { ChangePasswordForm } from "./ChangePasswordForm";
interface WhoAmI {
uid: string;
@@ -39,6 +40,7 @@ export function SettingsPage() {
)}
{error && {error}
}
+
);
diff --git a/web/src/records/NotesSection.test.tsx b/web/src/records/NotesSection.test.tsx
new file mode 100644
index 0000000..f85d3ce
--- /dev/null
+++ b/web/src/records/NotesSection.test.tsx
@@ -0,0 +1,181 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { Note } from "./types";
+import type { WatchState } from "./useWatch";
+
+const m = vi.hoisted(() => ({ addNote: vi.fn(), updateNote: vi.fn(), deleteNote: vi.fn() }));
+vi.mock("./notes", () => m);
+vi.mock("../auth/AuthProvider", () => ({ useAuth: () => ({ signOut: vi.fn() }) }));
+
+import { NotesSection } from "./NotesSection";
+
+const AVA = { uid: "ava-uid", displayName: "Ava" };
+const note = (over: Partial & Pick): Note => ({ text: "Staff were careful", authorUid: "ava-uid", authorName: "Ava", createdAt: new Date("2026-09-01T10:00:00Z"), updatedAt: new Date("2026-09-01T10:00:00Z"), version: 1, ...over });
+
+function renderSection(state: WatchState, disabled = false) {
+ const view = render( {}} />);
+ return {
+ ...view,
+ update: (next: WatchState) => view.rerender( {}} />),
+ };
+}
+
+beforeEach(() => {
+ m.addNote.mockResolvedValue({ kind: "ok", value: "new" });
+ m.updateNote.mockResolvedValue({ kind: "ok", value: 2 });
+ m.deleteNote.mockResolvedValue({ kind: "ok", value: undefined });
+});
+afterEach(() => vi.clearAllMocks());
+
+describe("NotesSection", () => {
+ it("says notes are not evidence, lists notes with authors, and offers controls only on the member's own", () => {
+ renderSection({ status: "ready", value: [note({ id: "a" }), note({ id: "b", authorUid: "bogdan-uid", authorName: "Bogdan", version: 2 })] });
+ expect(screen.getByTestId("notes-section")).toHaveTextContent("Personal notes. They are not evidence and don't change any checked date.");
+ expect(screen.getByTestId("note-b")).toHaveTextContent("Bogdan");
+ expect(screen.getByTestId("note-edited-b")).toBeInTheDocument();
+ expect(screen.queryByTestId("note-edited-a")).toBeNull();
+ expect(screen.getByTestId("note-edit-a")).toBeInTheDocument();
+ expect(screen.queryByTestId("note-edit-b")).toBeNull();
+ expect(screen.queryByTestId("note-delete-b")).toBeNull();
+ });
+
+ it("shows the empty state only from the server", () => {
+ const view = renderSection({ status: "offline", value: [] });
+ expect(screen.queryByTestId("notes-empty")).toBeNull();
+ view.update({ status: "ready", value: [] });
+ expect(screen.getByTestId("notes-empty")).toHaveTextContent("No notes yet.");
+ });
+
+ it("adds a trimmed note, refuses blank text, and keeps the draft when saving fails", async () => {
+ renderSection({ status: "ready", value: [] });
+ await userEvent.click(screen.getByTestId("note-add-save"));
+ expect(screen.getByTestId("note-add-error")).toHaveTextContent("Write something first.");
+ expect(m.addNote).not.toHaveBeenCalled();
+ await userEvent.type(screen.getByTestId("note-add-text"), " Asked about the fryer ");
+ expect(screen.getByTestId("note-add-counter")).toHaveTextContent("21 / 2000");
+ m.addNote.mockResolvedValueOnce({ kind: "offline" });
+ await userEvent.click(screen.getByTestId("note-add-save"));
+ await waitFor(() => expect(screen.getByTestId("note-add-outcome")).toHaveAttribute("data-kind", "offline"));
+ expect(screen.getByTestId("note-add-text")).toHaveValue(" Asked about the fryer ");
+ await userEvent.click(screen.getByTestId("note-add-save"));
+ expect(m.addNote).toHaveBeenLastCalledWith("home", "r1", AVA, "Asked about the fryer");
+ await waitFor(() => expect(screen.getByTestId("note-add-text")).toHaveValue(""));
+ });
+
+ it("edits with the version the member started from", async () => {
+ renderSection({ status: "ready", value: [note({ id: "a", version: 4 })] });
+ await userEvent.click(screen.getByTestId("note-edit-a"));
+ const box = screen.getByTestId("note-edit-text-a");
+ await userEvent.clear(box);
+ await userEvent.type(box, "Went back, still careful");
+ await userEvent.click(screen.getByTestId("note-save-a"));
+ expect(m.updateNote).toHaveBeenCalledWith("home", "r1", "a", 4, "Went back, still careful");
+ await waitFor(() => expect(screen.queryByTestId("note-edit-text-a")).toBeNull());
+ });
+
+ it("an edit conflict shows the current text beside the draft; Keep mine writes with the version shown", async () => {
+ const view = renderSection({ status: "ready", value: [note({ id: "a", version: 1 })] });
+ await userEvent.click(screen.getByTestId("note-edit-a"));
+ await userEvent.clear(screen.getByTestId("note-edit-text-a"));
+ await userEvent.type(screen.getByTestId("note-edit-text-a"), "My draft");
+ view.update({ status: "ready", value: [note({ id: "a", version: 2, text: "Changed on the phone" })] });
+ m.updateNote.mockResolvedValueOnce({ kind: "conflict" });
+ await userEvent.click(screen.getByTestId("note-save-a"));
+ await waitFor(() => expect(screen.getByTestId("note-conflict-a")).toBeInTheDocument());
+ expect(screen.getByTestId("note-conflict-current-a")).toHaveTextContent("Changed on the phone");
+ expect(screen.getByTestId("note-edit-text-a")).toHaveValue("My draft");
+ await userEvent.click(screen.getByTestId("note-keep-mine-a"));
+ expect(m.updateNote).toHaveBeenLastCalledWith("home", "r1", "a", 2, "My draft");
+ });
+
+ it("Use theirs drops the draft", async () => {
+ const view = renderSection({ status: "ready", value: [note({ id: "a" })] });
+ await userEvent.click(screen.getByTestId("note-edit-a"));
+ view.update({ status: "ready", value: [note({ id: "a", version: 2, text: "Theirs" })] });
+ m.updateNote.mockResolvedValueOnce({ kind: "conflict" });
+ await userEvent.click(screen.getByTestId("note-save-a"));
+ await userEvent.click(await screen.findByTestId("note-use-theirs-a"));
+ expect(screen.queryByTestId("note-edit-text-a")).toBeNull();
+ expect(screen.getByTestId("note-a")).toHaveTextContent("Theirs");
+ });
+
+ it("locks the composer while its save is in flight, so nothing typed meanwhile is lost; success clears it", async () => {
+ let finish!: (x: unknown) => void;
+ m.addNote.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
+ renderSection({ status: "ready", value: [] });
+ const box = screen.getByTestId("note-add-text");
+ await userEvent.type(box, "First note");
+ await userEvent.click(screen.getByTestId("note-add-save"));
+ expect(box).toHaveAttribute("readonly");
+ await userEvent.type(box, " plus more");
+ expect(box).toHaveValue("First note");
+ await act(async () => finish({ kind: "ok", value: "new" }));
+ expect(m.addNote).toHaveBeenCalledWith("home", "r1", AVA, "First note");
+ expect(box).toHaveValue("");
+ expect(box).not.toHaveAttribute("readonly");
+ });
+
+ it("locks the edit box while its save is in flight; success closes the editor", async () => {
+ let finish!: (x: unknown) => void;
+ m.updateNote.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
+ renderSection({ status: "ready", value: [note({ id: "a" })] });
+ await userEvent.click(screen.getByTestId("note-edit-a"));
+ const box = screen.getByTestId("note-edit-text-a");
+ await userEvent.clear(box);
+ await userEvent.type(box, "Saved revision");
+ await userEvent.click(screen.getByTestId("note-save-a"));
+ expect(box).toHaveAttribute("readonly");
+ await userEvent.type(box, " plus more");
+ expect(box).toHaveValue("Saved revision");
+ await act(async () => finish({ kind: "ok", value: 2 }));
+ expect(m.updateNote).toHaveBeenCalledWith("home", "r1", "a", 1, "Saved revision");
+ expect(screen.queryByTestId("note-edit-text-a")).toBeNull();
+ });
+
+ it("locks the edit box while Keep mine is in flight; a failure unlocks it with the draft intact", async () => {
+ const view = renderSection({ status: "ready", value: [note({ id: "a", version: 1 })] });
+ await userEvent.click(screen.getByTestId("note-edit-a"));
+ const box = screen.getByTestId("note-edit-text-a");
+ await userEvent.clear(box);
+ await userEvent.type(box, "My draft");
+ view.update({ status: "ready", value: [note({ id: "a", version: 2, text: "Changed on the phone" })] });
+ m.updateNote.mockResolvedValueOnce({ kind: "conflict" });
+ await userEvent.click(screen.getByTestId("note-save-a"));
+ await waitFor(() => expect(screen.getByTestId("note-conflict-a")).toBeInTheDocument());
+ expect(box).not.toHaveAttribute("readonly");
+ let finish!: (x: unknown) => void;
+ m.updateNote.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
+ await userEvent.click(screen.getByTestId("note-keep-mine-a"));
+ expect(box).toHaveAttribute("readonly");
+ await userEvent.type(box, " plus more");
+ expect(box).toHaveValue("My draft");
+ await act(async () => finish({ kind: "offline" }));
+ expect(screen.getByTestId("note-outcome-a")).toHaveAttribute("data-kind", "offline");
+ expect(box).toHaveValue("My draft");
+ expect(box).not.toHaveAttribute("readonly");
+ });
+
+ it("delete needs a confirmation bound to the version shown, and deletes that version", async () => {
+ const view = renderSection({ status: "ready", value: [note({ id: "a", version: 1 })] });
+ await userEvent.click(screen.getByTestId("note-delete-a"));
+ expect(m.deleteNote).not.toHaveBeenCalled();
+ act(() => view.update({ status: "ready", value: [note({ id: "a", version: 2, text: "Edited elsewhere" })] }));
+ expect(screen.queryByTestId("note-delete-confirm-a")).toBeNull();
+ await userEvent.click(screen.getByTestId("note-delete-a"));
+ await userEvent.click(screen.getByTestId("note-delete-confirm-a"));
+ expect(m.deleteNote).toHaveBeenCalledWith("home", "r1", "a", 2);
+ });
+
+ it("disables adding, editing and deleting while the page is offline", () => {
+ renderSection({ status: "offline", value: [note({ id: "a" })] }, true);
+ expect(screen.getByTestId("note-add-save")).toBeDisabled();
+ expect(screen.getByTestId("note-edit-a")).toBeDisabled();
+ expect(screen.getByTestId("note-delete-a")).toBeDisabled();
+ });
+
+ it("shows loading and errors from the notes listener", () => {
+ renderSection({ status: "error", message: "boom" });
+ expect(screen.getByTestId("read-error")).toHaveTextContent("boom");
+ });
+});
diff --git a/web/src/records/NotesSection.tsx b/web/src/records/NotesSection.tsx
new file mode 100644
index 0000000..ff42daf
--- /dev/null
+++ b/web/src/records/NotesSection.tsx
@@ -0,0 +1,179 @@
+import { useState } from "react";
+import { isData } from "./combine";
+import { outcomeMessage } from "./messages";
+import { addNote, deleteNote, updateNote } from "./notes";
+import { ReadStateNotice } from "./ReadStateNotice";
+import type { WriteOutcome } from "./repository";
+import type { Author, Note } from "./types";
+import type { WatchState } from "./useWatch";
+import { LIMITS, validateNoteText } from "./validation";
+
+interface Props {
+ householdId: string;
+ rid: string;
+ author: Author;
+ state: WatchState;
+ /** True when any listener on the page is cache-backed: writes need a connection. */
+ disabled: boolean;
+ onRetry: () => void;
+}
+
+const DATE = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric" });
+
+function noteMessage(kind: WriteOutcome["kind"], adding: boolean): string {
+ if (kind === "notFound") return adding ? "This restaurant was deleted." : "This note was deleted.";
+ if (kind === "conflict") return "This note changed on another device.";
+ return outcomeMessage(kind, "This note");
+}
+
+/** Personal notes (spec §3.7). Never evidence: no kinds, no dates that feed evidence status. */
+export function NotesSection({ householdId, rid, author, state, disabled, onRetry }: Props) {
+ return (
+
+ Our notes
+ Personal notes. They are not evidence and don't change any checked date.
+ {!isData(state) && }
+
+ {state.status === "ready" && state.value.length === 0 && No notes yet.
}
+ {isData(state) &&
+ state.value.map((n) => (
+
+ ))}
+
+ );
+}
+
+function NoteComposer({ householdId, rid, author, disabled }: { householdId: string; rid: string; author: Author; disabled: boolean }) {
+ const [text, setText] = useState("");
+ const [error, setError] = useState(null);
+ const [outcome, setOutcome] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ async function save() {
+ const problem = validateNoteText(text);
+ setError(problem);
+ setOutcome(null);
+ if (problem) return;
+ setBusy(true);
+ const result = await addNote(householdId, rid, author, text.trim());
+ setBusy(false);
+ if (result.kind === "ok") {
+ setText("");
+ return;
+ }
+ setOutcome(result.kind); // the draft stays in the box
+ }
+
+ return (
+
+
+
{text.trim().length} / {LIMITS.note}
+
+ {error &&
{error}}
+ {outcome &&
{noteMessage(outcome, true)}
}
+
+ );
+}
+
+function NoteCard({ householdId, rid, note, own, disabled }: { householdId: string; rid: string; note: Note; own: boolean; disabled: boolean }) {
+ const [editing, setEditing] = useState<{ draft: string; baseVersion: number } | null>(null);
+ const [conflict, setConflict] = useState(false);
+ // The version a pending delete confirmation belongs to (audit clarification, as claims in 37cc46b).
+ const [confirming, setConfirming] = useState(null);
+ const [outcome, setOutcome] = useState(null);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ if (confirming !== null && confirming !== note.version) setConfirming(null);
+
+ async function save(baseVersion: number) {
+ if (!editing) return;
+ const problem = validateNoteText(editing.draft);
+ setError(problem);
+ setOutcome(null);
+ if (problem) return;
+ setBusy(true);
+ const result = await updateNote(householdId, rid, note.id, baseVersion, editing.draft.trim());
+ setBusy(false);
+ if (result.kind === "ok") {
+ setEditing(null);
+ setConflict(false);
+ return;
+ }
+ if (result.kind === "conflict") {
+ setConflict(true);
+ return;
+ }
+ setOutcome(result.kind); // the draft stays open
+ }
+
+ async function confirmDelete() {
+ if (confirming === null) return;
+ setBusy(true);
+ const result = await deleteNote(householdId, rid, note.id, confirming);
+ setBusy(false);
+ if (result.kind !== "ok") {
+ setConfirming(null);
+ setOutcome(result.kind);
+ }
+ }
+
+ function stopEditing() {
+ setEditing(null);
+ setConflict(false);
+ setError(null);
+ }
+
+ return (
+
+ {!editing && {note.text}
}
+
+ {note.authorName} · {DATE.format(note.createdAt)}
+ {note.version > 1 && · edited}
+
+ {editing && (
+
+ {/* Read-only while Save or Keep mine is in flight, as in the composer (audit F2). */}
+
+ )}
+ {own && !editing && (
+
+ {confirming === null ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+ Delete this note?
+
+
+ >
+ )}
+
+ )}
+ {outcome && {noteMessage(outcome, false)}
}
+
+ );
+}
diff --git a/web/src/records/RestaurantDetailPage.test.tsx b/web/src/records/RestaurantDetailPage.test.tsx
index 4e14240..d799e72 100644
--- a/web/src/records/RestaurantDetailPage.test.tsx
+++ b/web/src/records/RestaurantDetailPage.test.tsx
@@ -2,11 +2,24 @@ import { MemoryRouter, Route, Routes } from "react-router";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import type { Claim, Restaurant } from "./types";
+import type { Claim, CollectionState, Note, Restaurant } from "./types";
import type { Snapshot } from "./repository";
-const m = vi.hoisted(() => ({ watchRestaurant: vi.fn(), watchClaims: vi.fn(), deleteClaim: vi.fn() }));
+const m = vi.hoisted(() => ({
+ watchRestaurant: vi.fn(),
+ watchClaims: vi.fn(),
+ deleteClaim: vi.fn(),
+ watchCollectionEntry: vi.fn(),
+ setShortlisted: vi.fn(),
+ setVisited: vi.fn(),
+ watchNotes: vi.fn(),
+ addNote: vi.fn(),
+ updateNote: vi.fn(),
+ deleteNote: vi.fn(),
+}));
vi.mock("./repository", () => m);
+vi.mock("./collection", () => m);
+vi.mock("./notes", () => m);
vi.mock("../auth/AuthProvider", () => ({
useAuth: () => ({ state: { status: "member", uid: "ava-uid", email: "ava@safebite.test", householdId: "home", displayName: "Ava" }, signOut: vi.fn() }),
}));
@@ -16,6 +29,8 @@ import { RestaurantDetailPage } from "./RestaurantDetailPage";
let emitRestaurant: (s: Snapshot) => void = () => {};
let emitClaims: (s: Snapshot) => void = () => {};
+let emitState: (s: Snapshot) => void = () => {};
+let emitNotes: (s: Snapshot) => void = () => {};
const restaurant: Restaurant = { id: "r1", name: "Da Marco", address: "Via Roma 1", phone: "+39 06 1", website: "https://damarco.it", createdBy: "ava-uid", createdAt: new Date(), updatedAt: new Date(), version: 2, deleting: false };
const claim = (over: Partial & Pick): Claim => ({
kind: "separateFryer", value: "yes", detail: "", source: { type: "restaurantStatement", label: "Manager" }, checkedAt: "2026-09-01",
@@ -26,6 +41,16 @@ beforeEach(() => {
m.watchRestaurant.mockImplementation((_h: string, _r: string, cb: (s: Snapshot) => void) => { emitRestaurant = cb; return () => {}; });
m.watchClaims.mockImplementation((_h: string, _r: string, cb: (s: Snapshot) => void) => { emitClaims = cb; return () => {}; });
m.deleteClaim.mockResolvedValue({ kind: "ok", value: undefined });
+ m.watchCollectionEntry.mockImplementation((_h: string, _r: string, cb: (s: Snapshot) => void) => {
+ emitState = cb;
+ cb({ status: "ready", value: null });
+ return () => {};
+ });
+ m.watchNotes.mockImplementation((_h: string, _r: string, cb: (s: Snapshot) => void) => {
+ emitNotes = cb;
+ cb({ status: "ready", value: [] });
+ return () => {};
+ });
});
afterEach(() => vi.clearAllMocks());
@@ -224,3 +249,66 @@ describe("RestaurantDetailPage", () => {
expect(screen.queryByTestId("restaurant-maps")).not.toBeInTheDocument();
});
});
+
+describe("RestaurantDetailPage — status block", () => {
+ it("renders the status block with the stored state", () => {
+ renderPage();
+ act(() => {
+ emitRestaurant({ status: "ready", value: restaurant });
+ emitClaims({ status: "ready", value: [] });
+ emitState({ status: "ready", value: { shortlisted: true, visited: false, updatedBy: "bogdan-uid", updatedByName: "Bogdan", updatedAt: new Date(), version: 2 } });
+ });
+ expect(screen.getByTestId("shortlist-state")).toHaveTextContent("On shortlist");
+ });
+
+ it("shows one offline notice when only the collection state is cached", () => {
+ renderPage();
+ act(() => {
+ emitRestaurant({ status: "ready", value: restaurant });
+ emitClaims({ status: "ready", value: [] });
+ emitState({ status: "offline", value: null });
+ });
+ expect(screen.getAllByTestId("read-offline")).toHaveLength(1);
+ expect(screen.getByTestId("shortlist-add")).toBeDisabled();
+ expect(screen.getByTestId("add-evidence")).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("shows a collection error in the status block without hiding the evidence", () => {
+ renderPage();
+ act(() => {
+ emitRestaurant({ status: "ready", value: restaurant });
+ emitClaims({ status: "ready", value: [] });
+ emitState({ status: "error", message: "boom" });
+ });
+ expect(screen.getByTestId("status-block")).toHaveTextContent("boom");
+ expect(screen.getByTestId("evidence-gfMenu")).toBeInTheDocument();
+ });
+});
+
+describe("RestaurantDetailPage — notes", () => {
+ it("places notes between the evidence and the call-ahead prompts", () => {
+ renderPage();
+ act(() => {
+ emitRestaurant({ status: "ready", value: restaurant });
+ emitClaims({ status: "ready", value: [] });
+ emitNotes({ status: "ready", value: [{ id: "n1", text: "Asked twice, confident answers", authorUid: "bogdan-uid", authorName: "Bogdan", createdAt: new Date(), updatedAt: new Date(), version: 1 }] });
+ });
+ const notes = screen.getByTestId("notes-section");
+ expect(notes).toHaveTextContent("Asked twice, confident answers");
+ const evidence = screen.getByTestId("evidence-gfMenu");
+ const callAhead = screen.getByTestId("call-ahead");
+ expect(evidence.compareDocumentPosition(notes) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(notes.compareDocumentPosition(callAhead) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
+ it("counts cached notes in the single offline notice", () => {
+ renderPage();
+ act(() => {
+ emitRestaurant({ status: "ready", value: restaurant });
+ emitClaims({ status: "ready", value: [] });
+ emitNotes({ status: "offline", value: [] });
+ });
+ expect(screen.getAllByTestId("read-offline")).toHaveLength(1);
+ expect(screen.getByTestId("note-add-save")).toBeDisabled();
+ });
+});
diff --git a/web/src/records/RestaurantDetailPage.tsx b/web/src/records/RestaurantDetailPage.tsx
index 8d5b550..79cfd48 100644
--- a/web/src/records/RestaurantDetailPage.tsx
+++ b/web/src/records/RestaurantDetailPage.tsx
@@ -2,12 +2,17 @@ import { useState } from "react";
import { Link, useParams } from "react-router";
import { placeUrl } from "../discover/links";
import { CALL_AHEAD_GROUPS } from "./callAhead";
+import { watchCollectionEntry } from "./collection";
+import { anyOffline, isData } from "./combine";
import { formatCalendarDate } from "./dates";
import { evidenceStatus, summariseEvidence, type KindEvidence } from "./evidence";
import { outcomeMessage } from "./messages";
+import { NotesSection } from "./NotesSection";
+import { watchNotes } from "./notes";
import { deleteClaim, watchClaims, watchRestaurant, type WriteOutcome } from "./repository";
import { ReadStateNotice } from "./ReadStateNotice";
-import { CLAIM_KIND_LABELS, CLAIM_VALUE_LABELS, SOURCE_TYPE_LABELS, type CalendarDate, type Claim, type Restaurant } from "./types";
+import { StatusBlock } from "./StatusBlock";
+import { CLAIM_KIND_LABELS, CLAIM_VALUE_LABELS, SOURCE_TYPE_LABELS, type CalendarDate, type Claim, type CollectionState, type Note, type Restaurant } from "./types";
import { useMember } from "./useMember";
import { useToday } from "./useToday";
import { useWatch } from "./useWatch";
@@ -51,11 +56,13 @@ function ClaimCard({ claim, today, disabled, onDelete }: { claim: Claim; today:
}
export function RestaurantDetailPage() {
- const { householdId } = useMember();
+ const { householdId, uid, displayName } = useMember();
const { rid } = useParams();
const today = useToday();
const restaurantWatch = useWatch((cb) => watchRestaurant(householdId, rid!, cb), [householdId, rid]);
const claimsWatch = useWatch((cb) => watchClaims(householdId, rid!, cb), [householdId, rid]);
+ const stateWatch = useWatch((cb) => watchCollectionEntry(householdId, rid!, cb), [householdId, rid]);
+ const notesWatch = useWatch((cb) => watchNotes(householdId, rid!, cb), [householdId, rid]);
const [outcome, setOutcome] = useState(null);
const rs = restaurantWatch.state;
@@ -68,8 +75,11 @@ export function RestaurantDetailPage() {
);
}
const restaurant = rs.value;
- const offline = rs.status === "offline" || cs.status === "offline";
- const claims = cs.status === "ready" || cs.status === "offline" ? cs.value : [];
+ const ss = stateWatch.state;
+ // One notice for every cache-backed listener on the page (spec §3.7); writes need the server.
+ const offline = anyOffline(rs, cs, ss, notesWatch.state);
+ const claimsReady = isData(cs);
+ const claims = claimsReady ? cs.value : [];
const summary = summariseEvidence(claims, today);
async function onDeleteClaim(cid: string) {
@@ -77,11 +87,9 @@ export function RestaurantDetailPage() {
setOutcome(result.kind === "ok" ? null : result.kind);
}
- const claimsReady = cs.status === "ready" || cs.status === "offline";
-
return (
-
+ {offline && }
{restaurant.name}
{restaurant.address}
@@ -92,6 +100,7 @@ export function RestaurantDetailPage() {
)}
Edit
+
Evidence
@@ -106,7 +115,7 @@ export function RestaurantDetailPage() {
{outcome && {outcomeMessage(outcome, "This evidence", "delete")}
}
- {(!claimsReady || (cs.status === "offline" && rs.status !== "offline")) && }
+ {!claimsReady && }
{claimsReady && (
{summary.map((entry) => (
@@ -128,6 +137,8 @@ export function RestaurantDetailPage() {
)}
+
+
Call ahead and ask
Evidence goes out of date. Before you go, ring and ask:
diff --git a/web/src/records/RestaurantFormPage.test.tsx b/web/src/records/RestaurantFormPage.test.tsx
index c9bd289..a465d78 100644
--- a/web/src/records/RestaurantFormPage.test.tsx
+++ b/web/src/records/RestaurantFormPage.test.tsx
@@ -179,6 +179,16 @@ describe("RestaurantFormPage — edit", () => {
expect(screen.queryByTestId("delete-confirm")).not.toBeInTheDocument();
expect(screen.getByTestId("delete-restaurant")).toBeDisabled();
});
+
+ it("names notes in the delete confirmation and reports a failed delete with the delete verb", async () => {
+ m.deleteRestaurant.mockResolvedValue({ kind: "failed", message: "x" });
+ renderAt("/restaurants/r1/edit");
+ act(() => emit({ status: "ready", value: stored }));
+ await userEvent.click(screen.getByTestId("delete-restaurant"));
+ expect(screen.getByTestId("delete-question")).toHaveTextContent("Deletes the restaurant, its evidence, and both members' notes.");
+ await userEvent.click(screen.getByTestId("delete-confirm"));
+ await waitFor(() => expect(screen.getByTestId("save-outcome")).toHaveTextContent("Could not delete this restaurant. Try again."));
+ });
});
function renderCreateWithState(state: unknown) {
diff --git a/web/src/records/RestaurantFormPage.tsx b/web/src/records/RestaurantFormPage.tsx
index 3e76280..99ab4c4 100644
--- a/web/src/records/RestaurantFormPage.tsx
+++ b/web/src/records/RestaurantFormPage.tsx
@@ -2,8 +2,8 @@ import { useState, type SubmitEvent } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router";
import type { RestaurantPrefill } from "../discover/DiscoverPage";
import { placeIdUrl } from "../discover/links";
-import { outcomeMessage } from "./messages";
-import { createRestaurant, deleteRestaurant, updateRestaurant, watchRestaurant, watchRestaurants, type DeleteStep, type WriteOutcome } from "./repository";
+import { deleteProgressText, outcomeMessage } from "./messages";
+import { createRestaurant, deleteRestaurant, updateRestaurant, watchRestaurant, watchRestaurants, type WriteOutcome } from "./repository";
import { ReadStateNotice } from "./ReadStateNotice";
import type { Restaurant, RestaurantInput } from "./types";
import { useMember } from "./useMember";
@@ -11,7 +11,6 @@ import { useWatch } from "./useWatch";
import { LIMITS, normaliseRestaurantInput, validateRestaurantInput, type FieldErrors, type RawRestaurantForm, type RestaurantField } from "./validation";
const EMPTY: RawRestaurantForm = { name: "", address: "", phone: "", website: "" };
-const STEP_TEXT: Record = { marking: "Marking…", sweeping: "Removing evidence…", removing: "Removing restaurant…" };
function toForm(r: Restaurant): RawRestaurantForm {
return { name: r.name, address: r.address, phone: r.phone ?? "", website: r.website ?? "" };
@@ -62,6 +61,7 @@ export function RestaurantFormPage({ mode }: { mode: "create" | "edit" }) {
const [draft, setDraft] = useState(mode === "create" ? { form: EMPTY, seededFrom: EMPTY, baseVersion: 0 } : null);
const [errors, setErrors] = useState>({});
const [outcome, setOutcome] = useState(null);
+ const [outcomeVerb, setOutcomeVerb] = useState<"save" | "delete">("save");
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
const [deleteProgress, setDeleteProgress] = useState(null);
@@ -96,6 +96,7 @@ export function RestaurantFormPage({ mode }: { mode: "create" | "edit" }) {
const problems = validateRestaurantInput(input);
setErrors(problems);
setOutcome(null);
+ setOutcomeVerb("save");
if (Object.keys(problems).length > 0) return;
if (mode === "create" && existingId) {
// A member added this place meanwhile (or before): open it rather than create a twin.
@@ -115,7 +116,7 @@ export function RestaurantFormPage({ mode }: { mode: "create" | "edit" }) {
async function onDelete() {
if (!draft || !rid) return;
setBusy(true);
- const result = await deleteRestaurant(householdId, rid, draft.baseVersion, (step) => setDeleteProgress(STEP_TEXT[step]));
+ const result = await deleteRestaurant(householdId, rid, draft.baseVersion, (step) => setDeleteProgress(deleteProgressText(step)));
setBusy(false);
if (result.kind === "ok") {
void navigate("/restaurants");
@@ -123,6 +124,7 @@ export function RestaurantFormPage({ mode }: { mode: "create" | "edit" }) {
}
setDeleteProgress(null);
setConfirming(false);
+ setOutcomeVerb("delete");
setOutcome(result.kind);
}
@@ -182,7 +184,7 @@ export function RestaurantFormPage({ mode }: { mode: "create" | "edit" }) {
{outcome && outcome !== "ok" && (
-
{outcomeMessage(outcome, "This restaurant")}
+
{outcomeMessage(outcome, "This restaurant", outcomeVerb)}
{outcome === "conflict" && !changedElsewhere &&
}
{outcome === "notFound" &&
Back to Saved}
@@ -195,7 +197,7 @@ export function RestaurantFormPage({ mode }: { mode: "create" | "edit" }) {
)}
{confirming && deleteProgress === null && (
<>
- Delete this restaurant and all of its evidence?
+ Deletes the restaurant, its evidence, and both members' notes.
>
diff --git a/web/src/records/RestaurantsPage.test.tsx b/web/src/records/RestaurantsPage.test.tsx
index b74566e..626da28 100644
--- a/web/src/records/RestaurantsPage.test.tsx
+++ b/web/src/records/RestaurantsPage.test.tsx
@@ -3,15 +3,17 @@ import { MemoryRouter } from "react-router";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import type { Restaurant } from "./types";
+import type { CollectionState, Restaurant } from "./types";
import type { Snapshot } from "./repository";
const m = vi.hoisted(() => ({
watchRestaurants: vi.fn(),
+ watchCollection: vi.fn(),
finishDeleting: vi.fn(),
signOut: vi.fn(),
}));
vi.mock("./repository", () => ({ watchRestaurants: m.watchRestaurants, finishDeleting: m.finishDeleting }));
+vi.mock("./collection", () => ({ watchCollection: m.watchCollection }));
vi.mock("../auth/AuthProvider", () => ({
useAuth: () => ({ state: { status: "member", uid: "ava-uid", email: "ava@safebite.test", householdId: "home", displayName: "Ava" }, signOut: m.signOut }),
}));
@@ -19,11 +21,18 @@ vi.mock("../auth/AuthProvider", () => ({
import { RestaurantsPage } from "./RestaurantsPage";
let emit: (s: Snapshot) => void = () => {};
+let emitState: (s: Snapshot>) => void = () => {};
beforeEach(() => {
m.watchRestaurants.mockImplementation((_hid: string, cb: (s: Snapshot) => void) => {
emit = cb;
return () => {};
});
+ // Existing tests assume an authoritative, empty collection unless a test says otherwise.
+ m.watchCollection.mockImplementation((_hid: string, cb: (s: Snapshot>) => void) => {
+ emitState = cb;
+ cb({ status: "ready", value: {} });
+ return () => {};
+ });
m.finishDeleting.mockResolvedValue({ kind: "ok", value: undefined });
});
afterEach(() => vi.clearAllMocks());
@@ -47,10 +56,11 @@ function renderPage() {
}
describe("RestaurantsPage", () => {
- it("shows loading, then the household's restaurants as links", () => {
+ it("shows loading, then the household's restaurants as links", async () => {
renderPage();
expect(screen.getByTestId("read-loading")).toBeInTheDocument();
act(() => emit({ status: "ready", value: [r({ id: "a", name: "Da Marco" }), r({ id: "b", name: "Zest" })] }));
+ await userEvent.click(screen.getByTestId("filter-all"));
const rows = screen.getAllByTestId("restaurant-row");
expect(rows).toHaveLength(2);
expect(rows[0]).toHaveTextContent("Da Marco");
@@ -67,10 +77,11 @@ describe("RestaurantsPage", () => {
expect(screen.getByTestId("restaurants-empty")).toBeInTheDocument();
});
- it("disables Add while offline and shows the offline notice with the cached rows", () => {
+ it("disables Add while offline and shows the offline notice with the cached rows", async () => {
renderPage();
act(() => emit({ status: "offline", value: [r({ id: "a", name: "Da Marco" })] }));
expect(screen.getByTestId("add-restaurant")).toHaveAttribute("aria-disabled", "true");
+ await userEvent.click(screen.getByTestId("filter-all"));
expect(screen.getByTestId("restaurant-row")).toHaveTextContent("Da Marco");
});
@@ -106,3 +117,70 @@ describe("RestaurantsPage", () => {
expect(row).toHaveTextContent("You are offline");
});
});
+
+const st = (over: Partial = {}): CollectionState => ({ shortlisted: true, visited: false, updatedBy: "ava-uid", updatedByName: "Ava", updatedAt: new Date(), version: 1, ...over });
+
+describe("RestaurantsPage — shortlist", () => {
+ it("defaults to the shortlist with labels, and All records shows everything", async () => {
+ renderPage();
+ act(() => {
+ emit({ status: "ready", value: [r({ id: "a", name: "Da Marco" }), r({ id: "b", name: "Zest" })] });
+ emitState({ status: "ready", value: { a: st({ visited: true, visitedOn: "2026-05-03" }) } });
+ });
+ expect(screen.getByTestId("filter-shortlist")).toHaveAttribute("aria-pressed", "true");
+ const rows = screen.getAllByTestId("restaurant-row");
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toHaveTextContent("Da Marco");
+ expect(screen.getByTestId("label-shortlisted")).toHaveTextContent("Shortlisted");
+ expect(screen.getByTestId("label-visited")).toHaveTextContent("Visited 3 May 2026");
+ await userEvent.click(screen.getByTestId("filter-all"));
+ expect(screen.getAllByTestId("restaurant-row")).toHaveLength(2);
+ expect(screen.getByTestId("filter-all")).toHaveAttribute("aria-pressed", "true");
+ });
+
+ it("distinguishes no records from an empty shortlist", () => {
+ renderPage();
+ act(() => emit({ status: "ready", value: [r({ id: "a", name: "Da Marco" })] }));
+ expect(screen.getByTestId("shortlist-empty")).toHaveTextContent("Nothing on the shortlist. Open a record and tap Add to shortlist.");
+ expect(screen.queryByTestId("restaurants-empty")).toBeNull();
+ act(() => emit({ status: "ready", value: [] }));
+ expect(screen.getByTestId("restaurants-empty")).toBeInTheDocument();
+ expect(screen.queryByTestId("shortlist-empty")).toBeNull();
+ });
+
+ it("never shows an empty shortlist from a cached or failed collection snapshot", () => {
+ renderPage();
+ act(() => {
+ emit({ status: "ready", value: [r({ id: "a", name: "Da Marco" })] });
+ emitState({ status: "offline", value: {} });
+ });
+ expect(screen.queryByTestId("shortlist-empty")).toBeNull();
+ expect(screen.getAllByTestId("read-offline")).toHaveLength(1);
+ act(() => emitState({ status: "error", message: "boom" }));
+ expect(screen.getByTestId("read-error")).toHaveTextContent("boom");
+ expect(screen.queryByTestId("shortlist-empty")).toBeNull();
+ });
+
+ it("shows one offline notice when both listeners are cache-backed", () => {
+ renderPage();
+ act(() => {
+ emit({ status: "offline", value: [r({ id: "a", name: "Da Marco" })] });
+ emitState({ status: "offline", value: { a: st() } });
+ });
+ expect(screen.getAllByTestId("read-offline")).toHaveLength(1);
+ expect(screen.getByTestId("restaurant-row")).toHaveTextContent("Da Marco");
+ });
+
+ it("keeps deleting rows under the shortlist filter", () => {
+ renderPage();
+ act(() => emit({ status: "ready", value: [r({ id: "d", name: "Doomed", deleting: true })] }));
+ expect(screen.getByTestId("restaurant-deleting")).toHaveTextContent("Doomed");
+ });
+
+ it("explains a failed resume in words the member can act on", async () => {
+ m.finishDeleting.mockResolvedValue({ kind: "failed", message: "x" });
+ renderPage();
+ act(() => emit({ status: "ready", value: [r({ id: "d", name: "Doomed", deleting: true })] }));
+ await waitFor(() => expect(screen.getByTestId("restaurant-deleting")).toHaveTextContent("Could not finish deleting. Tap Finish deleting to try again."));
+ });
+});
diff --git a/web/src/records/RestaurantsPage.tsx b/web/src/records/RestaurantsPage.tsx
index ddd7a2f..fc4f54d 100644
--- a/web/src/records/RestaurantsPage.tsx
+++ b/web/src/records/RestaurantsPage.tsx
@@ -1,54 +1,59 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
-import { finishDeleting, watchRestaurants, type DeleteStep } from "./repository";
+import { watchCollection } from "./collection";
+import { combineStates, isData } from "./combine";
+import { formatCalendarDate } from "./dates";
+import { filterRows, joinRecords, type RecordFilter } from "./join";
+import { deleteProgressText, finishOutcomeText } from "./messages";
+import { finishDeleting, watchRestaurants } from "./repository";
import { ReadStateNotice } from "./ReadStateNotice";
-import type { Restaurant } from "./types";
+import type { CollectionState, Restaurant } from "./types";
import { useMember } from "./useMember";
import { useWatch } from "./useWatch";
-const STEP_TEXT: Record = { marking: "Marking…", sweeping: "Removing evidence…", removing: "Removing restaurant…" };
-
-function outcomeText(kind: string): string {
- switch (kind) {
- case "offline": return "You are offline. Connect and tap Finish deleting.";
- case "permission": return "You no longer have access to this household.";
- case "notFound": return "Already removed.";
- default: return "Could not finish deleting. Tap to try again.";
- }
-}
-
export function RestaurantsPage() {
const { householdId } = useMember();
- const { state, retry } = useWatch((cb) => watchRestaurants(householdId, cb), [householdId]);
+ const restaurants = useWatch((cb) => watchRestaurants(householdId, cb), [householdId]);
+ const states = useWatch>((cb) => watchCollection(householdId, cb), [householdId]);
+ const [filter, setFilter] = useState("shortlist");
const [progress, setProgress] = useState>({});
const resumed = useRef(new Set());
- const offline = state.status === "offline";
- const rows = state.status === "ready" || state.status === "offline" ? state.value : [];
+ const combined = combineStates(restaurants.state, states.state);
+ const offline = combined.status === "offline";
+ const all = isData(combined) ? joinRecords(combined.value[0], combined.value[1]) : [];
+ const rows = filterRows(all, filter);
+
+ function retry() {
+ restaurants.retry();
+ states.retry();
+ }
async function finish(rid: string) {
- setProgress((p) => ({ ...p, [rid]: STEP_TEXT.sweeping }));
- const outcome = await finishDeleting(householdId, rid, (step) => setProgress((p) => ({ ...p, [rid]: STEP_TEXT[step] })));
- if (outcome.kind !== "ok") setProgress((p) => ({ ...p, [rid]: outcomeText(outcome.kind) }));
+ setProgress((p) => ({ ...p, [rid]: deleteProgressText("sweeping") }));
+ const outcome = await finishDeleting(householdId, rid, (step) => setProgress((p) => ({ ...p, [rid]: deleteProgressText(step) })));
+ if (outcome.kind !== "ok") setProgress((p) => ({ ...p, [rid]: finishOutcomeText(outcome.kind) }));
}
- // Resume interrupted deletions once per mount (deletion protocol is resumable, spec §3.5).
+ // Resume interrupted deletions once per mount from the authoritative restaurant list alone,
+ // whatever the collection listener is doing (deletion protocol is resumable, spec §3.5/§3.7).
+ const rs = restaurants.state;
useEffect(() => {
- if (state.status !== "ready") return;
- for (const r of state.value) {
+ if (rs.status !== "ready") return;
+ for (const r of rs.value) {
if (r.deleting && !resumed.current.has(r.id)) {
resumed.current.add(r.id);
void finish(r.id);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [state]);
+ }, [rs]);
return (
Saved
Our restaurant records.
-
-
+
+
- {state.status === "ready" && rows.length === 0 && No restaurants yet. Add the first one.
}
+
+
+
+
+ {combined.status === "ready" && all.length === 0 && No restaurants yet. Add the first one.
}
+ {combined.status === "ready" && all.length > 0 && rows.length === 0 && (
+ Nothing on the shortlist. Open a record and tap Add to shortlist.
+ )}
{rows.length > 0 && (
- {rows.map((r) =>
+ {rows.map(({ restaurant: r, state }) =>
r.deleting ? (
-
{r.name} — Deleting…
@@ -77,6 +89,12 @@ export function RestaurantsPage() {
{r.name}
{r.address}
+ {state && (state.shortlisted || state.visitedOn) && (
+
+ {state.shortlisted && Shortlisted}
+ {state.visited && state.visitedOn && Visited {formatCalendarDate(state.visitedOn)}}
+
+ )}
),
diff --git a/web/src/records/StatusBlock.test.tsx b/web/src/records/StatusBlock.test.tsx
new file mode 100644
index 0000000..b699469
--- /dev/null
+++ b/web/src/records/StatusBlock.test.tsx
@@ -0,0 +1,123 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { CollectionState } from "./types";
+import type { WatchState } from "./useWatch";
+
+const m = vi.hoisted(() => ({ setShortlisted: vi.fn(), setVisited: vi.fn() }));
+vi.mock("./collection", () => m);
+vi.mock("./useToday", () => ({ useToday: () => "2026-09-24" }));
+vi.mock("../auth/AuthProvider", () => ({ useAuth: () => ({ signOut: vi.fn() }) }));
+
+import { StatusBlock } from "./StatusBlock";
+
+const AVA = { uid: "ava-uid", displayName: "Ava" };
+const st = (over: Partial = {}): CollectionState => ({ shortlisted: false, visited: false, updatedBy: "bogdan-uid", updatedByName: "Bogdan", updatedAt: new Date(), version: 3, ...over });
+
+function renderBlock(state: WatchState, disabled = false) {
+ return render( {}} />);
+}
+
+beforeEach(() => {
+ m.setShortlisted.mockResolvedValue({ kind: "ok", value: 1 });
+ m.setVisited.mockResolvedValue({ kind: "ok", value: 1 });
+});
+afterEach(() => vi.clearAllMocks());
+
+describe("StatusBlock", () => {
+ it("a missing document from the server means not shortlisted, not visited, base version 0", async () => {
+ renderBlock({ status: "ready", value: null });
+ expect(screen.queryByTestId("status-changed-by")).toBeNull();
+ await userEvent.click(screen.getByTestId("shortlist-add"));
+ expect(m.setShortlisted).toHaveBeenCalledWith("home", "r1", AVA, 0, true);
+ });
+
+ it("shows the stored state and who changed it last; Remove uses the stored version", async () => {
+ renderBlock({ status: "ready", value: st({ shortlisted: true, visited: true, visitedOn: "2026-05-03" }) });
+ expect(screen.getByTestId("shortlist-state")).toHaveTextContent("On shortlist");
+ expect(screen.getByTestId("visited-state")).toHaveTextContent("Visited 3 May 2026");
+ expect(screen.getByTestId("status-changed-by")).toHaveTextContent("Last changed by Bogdan");
+ await userEvent.click(screen.getByTestId("shortlist-remove"));
+ expect(m.setShortlisted).toHaveBeenCalledWith("home", "r1", AVA, 3, false);
+ });
+
+ it("Mark visited defaults to today, refuses a future date, and saves a past one", async () => {
+ renderBlock({ status: "ready", value: st() });
+ await userEvent.click(screen.getByTestId("visited-mark"));
+ const input = screen.getByTestId("visited-date");
+ expect(input).toHaveValue("2026-09-24");
+ // jsdom sanitises partial date strings, so set the whole value at once.
+ fireEvent.change(input, { target: { value: "2026-09-30" } });
+ await userEvent.click(screen.getByTestId("visited-save"));
+ expect(screen.getByTestId("visited-error")).toHaveTextContent("The visit date can't be in the future.");
+ expect(m.setVisited).not.toHaveBeenCalled();
+ fireEvent.change(input, { target: { value: "2026-09-20" } });
+ await userEvent.click(screen.getByTestId("visited-save"));
+ expect(m.setVisited).toHaveBeenCalledWith("home", "r1", AVA, 3, "2026-09-20");
+ await waitFor(() => expect(screen.queryByTestId("visited-date")).toBeNull());
+ });
+
+ it("Change date starts from the stored date; Clear sends null", async () => {
+ renderBlock({ status: "ready", value: st({ visited: true, visitedOn: "2026-05-03" }) });
+ await userEvent.click(screen.getByTestId("visited-change"));
+ expect(screen.getByTestId("visited-date")).toHaveValue("2026-05-03");
+ await userEvent.click(screen.getByTestId("visited-cancel"));
+ await userEvent.click(screen.getByTestId("visited-clear"));
+ expect(m.setVisited).toHaveBeenCalledWith("home", "r1", AVA, 3, null);
+ });
+
+ it("a conflict shows the standard message and never retries by itself", async () => {
+ m.setShortlisted.mockResolvedValue({ kind: "conflict" });
+ renderBlock({ status: "ready", value: null });
+ await userEvent.click(screen.getByTestId("shortlist-add"));
+ await waitFor(() => expect(screen.getByTestId("status-outcome")).toHaveAttribute("data-kind", "conflict"));
+ expect(m.setShortlisted).toHaveBeenCalledTimes(1);
+ });
+
+ it("a conflicted date save closes the editor so the current visited state is shown again", async () => {
+ m.setVisited.mockResolvedValue({ kind: "conflict" });
+ renderBlock({ status: "ready", value: st({ visited: true, visitedOn: "2026-05-03" }) });
+ await userEvent.click(screen.getByTestId("visited-change"));
+ expect(screen.getByTestId("visited-date")).toBeInTheDocument();
+ await userEvent.click(screen.getByTestId("visited-save"));
+ await waitFor(() => expect(screen.getByTestId("status-outcome")).toHaveAttribute("data-kind", "conflict"));
+ expect(screen.queryByTestId("visited-date")).toBeNull();
+ expect(screen.getByTestId("visited-state")).toHaveTextContent("Visited 3 May 2026");
+ });
+
+ it("an open date draft keeps the version it was opened at when another member's change arrives", async () => {
+ const { rerender } = renderBlock({ status: "ready", value: st({ visited: true, visitedOn: "2026-05-03", updatedByName: "Ava" }) });
+ await userEvent.click(screen.getByTestId("visited-change"));
+ fireEvent.change(screen.getByTestId("visited-date"), { target: { value: "2026-05-04" } });
+ rerender( {}} />);
+ expect(screen.getByTestId("visited-date")).toHaveValue("2026-05-04");
+ await userEvent.click(screen.getByTestId("visited-save"));
+ expect(m.setVisited).toHaveBeenCalledWith("home", "r1", AVA, 3, "2026-05-04");
+ });
+
+ it("a date draft opened before the document existed keeps base version 0 after another member creates it", async () => {
+ const { rerender } = renderBlock({ status: "ready", value: null });
+ await userEvent.click(screen.getByTestId("visited-mark"));
+ fireEvent.change(screen.getByTestId("visited-date"), { target: { value: "2026-05-04" } });
+ rerender( {}} />);
+ await userEvent.click(screen.getByTestId("visited-save"));
+ expect(m.setVisited).toHaveBeenCalledWith("home", "r1", AVA, 0, "2026-05-04");
+ });
+
+ it.each([
+ ["cached", { status: "offline", value: null } as const, false],
+ ["from a page that is offline", { status: "ready", value: null } as const, true],
+ ])("disables every control when the state is %s", (_label, state, disabled) => {
+ renderBlock(state, disabled);
+ expect(screen.getByTestId("shortlist-add")).toBeDisabled();
+ expect(screen.getByTestId("visited-mark")).toBeDisabled();
+ });
+
+ it("shows loading and errors instead of controls until the state is known", () => {
+ renderBlock({ status: "loading" });
+ expect(screen.queryByTestId("shortlist-add")).toBeNull();
+ expect(screen.getByTestId("read-loading")).toBeInTheDocument();
+ renderBlock({ status: "error", message: "boom" });
+ expect(screen.getByTestId("read-error")).toHaveTextContent("boom");
+ });
+});
diff --git a/web/src/records/StatusBlock.tsx b/web/src/records/StatusBlock.tsx
new file mode 100644
index 0000000..8fbc997
--- /dev/null
+++ b/web/src/records/StatusBlock.tsx
@@ -0,0 +1,113 @@
+import { useState } from "react";
+import { setShortlisted, setVisited } from "./collection";
+import { isData } from "./combine";
+import { formatCalendarDate } from "./dates";
+import { statusOutcomeMessage } from "./messages";
+import { ReadStateNotice } from "./ReadStateNotice";
+import type { WriteOutcome } from "./repository";
+import type { Author, CollectionState } from "./types";
+import { useToday } from "./useToday";
+import type { WatchState } from "./useWatch";
+import { validateVisitedOn } from "./validation";
+
+interface Props {
+ householdId: string;
+ rid: string;
+ author: Author;
+ state: WatchState;
+ /** True when any listener on the page is cache-backed: writes need a connection. */
+ disabled: boolean;
+ onRetry: () => void;
+}
+
+/**
+ * Household shortlist and visited state for one restaurant (spec §3.7). Never computes or shows
+ * anything safety-related; visiting touches only the collection document.
+ */
+export function StatusBlock({ householdId, rid, author, state, disabled, onRetry }: Props) {
+ const today = useToday();
+ const [busy, setBusy] = useState(false);
+ const [outcome, setOutcome] = useState(null);
+ // The draft keeps the version it was opened at: a live snapshot never rebases it (audit F1).
+ const [draft, setDraft] = useState<{ date: string; baseVersion: number } | null>(null);
+ const [dateError, setDateError] = useState(null);
+
+ if (!isData(state)) {
+ return (
+
+ );
+ }
+
+ const current = state.value;
+ // A missing or cached document is only a safe base when it came from the server.
+ const locked = disabled || busy || state.status !== "ready";
+ const base = current?.version ?? 0;
+
+ async function run(action: () => Promise>): Promise {
+ setBusy(true);
+ setOutcome(null);
+ const result = await action();
+ setBusy(false);
+ if (result.kind !== "ok") setOutcome(result.kind);
+ return result.kind === "ok";
+ }
+
+ function openDraft(date: string) {
+ setDraft({ date, baseVersion: base });
+ setDateError(null);
+ }
+
+ async function saveDate() {
+ if (draft === null) return;
+ const problem = validateVisitedOn(draft.date, today);
+ setDateError(problem);
+ if (problem) return;
+ // Write against the version the draft was opened at, so a change another member made meanwhile
+ // returns a conflict instead of being overwritten. Close the editor on any outcome, ok or not:
+ // the current state is always shown, per the status-outcome message below.
+ await run(() => setVisited(householdId, rid, author, draft.baseVersion, draft.date));
+ setDraft(null);
+ }
+
+ return (
+
+
+ {current?.shortlisted ? (
+ <>
+ On shortlist
+
+ >
+ ) : (
+
+ )}
+
+
+ {draft === null && current?.visited && current.visitedOn && (
+ <>
+ Visited {formatCalendarDate(current.visitedOn)}
+
+
+ >
+ )}
+ {draft === null && !current?.visited && (
+
+ )}
+ {draft !== null && (
+ <>
+
+
+
+ {dateError && {dateError}}
+ >
+ )}
+
+ {current && Last changed by {current.updatedByName}
}
+ {outcome && {statusOutcomeMessage(outcome)}
}
+
+ );
+}
diff --git a/web/src/records/collection.test.ts b/web/src/records/collection.test.ts
new file mode 100644
index 0000000..429c372
--- /dev/null
+++ b/web/src/records/collection.test.ts
@@ -0,0 +1,114 @@
+import { serverTimestamp, Timestamp } from "firebase/firestore";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { memoryTransactions, type Store } from "../test/memoryFirestore";
+
+const m = vi.hoisted(() => ({ runTransaction: vi.fn(), onSnapshot: vi.fn() }));
+
+vi.mock("../firebase", () => ({ db: { fake: true } }));
+vi.mock("firebase/firestore", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ runTransaction: m.runTransaction,
+ onSnapshot: m.onSnapshot,
+ getDocsFromServer: vi.fn(),
+ collection: (_db: unknown, ...segments: string[]) => ({ path: segments.join("/") }),
+ doc: (parent: unknown, ...segments: string[]) => {
+ const base = typeof (parent as { path?: string }).path === "string" ? (parent as { path: string }).path : "";
+ const path = [base, ...segments].filter(Boolean).join("/");
+ return { id: segments[segments.length - 1] ?? "", path };
+ },
+ query: (source: unknown) => source,
+ orderBy: () => undefined,
+ limit: () => undefined,
+ };
+});
+
+import { setShortlisted, setVisited, watchCollection, watchCollectionEntry } from "./collection";
+
+const RP = "households/home/restaurants/r1";
+const SP = "households/home/collection/r1";
+const AVA = { uid: "ava-uid", displayName: "Ava" };
+const live = { name: "Da Marco", address: "Via Roma 1", deleting: false, version: 2 };
+const stored = { shortlisted: true, visited: true, visitedOn: Timestamp.fromDate(new Date("2026-05-03T00:00:00Z")), updatedBy: "bogdan-uid", updatedByName: "Bogdan", updatedAt: Timestamp.fromDate(new Date("2026-05-03T10:00:00Z")), version: 3 };
+
+beforeEach(() => {
+ Object.defineProperty(navigator, "onLine", { configurable: true, value: true });
+});
+afterEach(() => vi.clearAllMocks());
+
+describe("setShortlisted / setVisited", () => {
+ it("creates version 1 from base 0 with the author's identity and a server timestamp", async () => {
+ const store: Store = new Map([[RP, live]]);
+ memoryTransactions(m.runTransaction, store);
+ expect(await setShortlisted("home", "r1", AVA, 0, true)).toEqual({ kind: "ok", value: 1 });
+ expect(store.get(SP)).toEqual({ shortlisted: true, visited: false, updatedBy: "ava-uid", updatedByName: "Ava", updatedAt: serverTimestamp(), version: 1 });
+ });
+
+ it("changing the shortlist keeps the visit date and bumps the version", async () => {
+ const store: Store = new Map>([[RP, live], [SP, stored]]);
+ memoryTransactions(m.runTransaction, store);
+ expect(await setShortlisted("home", "r1", AVA, 3, false)).toEqual({ kind: "ok", value: 4 });
+ expect(store.get(SP)).toMatchObject({ shortlisted: false, visited: true, visitedOn: Timestamp.fromDate(new Date("2026-05-03T00:00:00Z")), updatedBy: "ava-uid", version: 4 });
+ });
+
+ it("setVisited stores a UTC-midnight date; clearing drops visitedOn and keeps the shortlist", async () => {
+ const store: Store = new Map([[RP, live]]);
+ memoryTransactions(m.runTransaction, store);
+ expect(await setVisited("home", "r1", AVA, 0, "2026-09-20")).toEqual({ kind: "ok", value: 1 });
+ expect((store.get(SP)!.visitedOn as Timestamp).toDate().toISOString()).toBe("2026-09-20T00:00:00.000Z");
+ expect(store.get(SP)).toMatchObject({ shortlisted: false, visited: true });
+ await setShortlisted("home", "r1", AVA, 1, true);
+ expect(await setVisited("home", "r1", AVA, 2, null)).toEqual({ kind: "ok", value: 3 });
+ expect(store.get(SP)).not.toHaveProperty("visitedOn");
+ expect(store.get(SP)).toMatchObject({ shortlisted: true, visited: false, version: 3 });
+ });
+
+ it("reports conflict without writing when the stored version differs from the base", async () => {
+ const store: Store = new Map>([[RP, live], [SP, stored]]);
+ const tx = memoryTransactions(m.runTransaction, store);
+ expect(await setShortlisted("home", "r1", AVA, 2, false)).toEqual({ kind: "conflict" });
+ expect(await setShortlisted("home", "r1", AVA, 0, true)).toEqual({ kind: "conflict" });
+ expect(tx.set).not.toHaveBeenCalled();
+ });
+
+ it("reports notFound for a missing restaurant or one marked deleting", async () => {
+ memoryTransactions(m.runTransaction, new Map());
+ expect(await setShortlisted("home", "r1", AVA, 0, true)).toEqual({ kind: "notFound" });
+ memoryTransactions(m.runTransaction, new Map([[RP, { ...live, deleting: true }]]));
+ expect(await setVisited("home", "r1", AVA, 0, "2026-09-20")).toEqual({ kind: "notFound" });
+ });
+
+ it("reports offline before starting a transaction when the browser is offline", async () => {
+ Object.defineProperty(navigator, "onLine", { configurable: true, value: false });
+ expect(await setShortlisted("home", "r1", AVA, 0, true)).toEqual({ kind: "offline" });
+ expect(m.runTransaction).not.toHaveBeenCalled();
+ });
+});
+
+describe("watchers", () => {
+ it("watchCollection maps documents by restaurant id and reports ready/offline", () => {
+ const seen: unknown[] = [];
+ m.onSnapshot.mockImplementation((_q: unknown, _o: unknown, next: (s: unknown) => void) => {
+ next({ metadata: { fromCache: false }, docs: [{ id: "r1", data: () => stored }] });
+ next({ metadata: { fromCache: true }, docs: [] });
+ return () => {};
+ });
+ watchCollection("home", (s) => seen.push(s));
+ expect(seen[0]).toEqual({ status: "ready", value: { r1: { shortlisted: true, visited: true, visitedOn: "2026-05-03", updatedBy: "bogdan-uid", updatedByName: "Bogdan", updatedAt: new Date("2026-05-03T10:00:00Z"), version: 3 } } });
+ expect(seen[1]).toEqual({ status: "offline", value: {} });
+ });
+
+ it("watchCollectionEntry reports null for a missing document, with the snapshot's source, and denied on permission errors", () => {
+ const seen: unknown[] = [];
+ m.onSnapshot.mockImplementation((_r: unknown, _o: unknown, next: (s: unknown) => void, fail: (e: unknown) => void) => {
+ next({ metadata: { fromCache: false }, exists: () => false });
+ next({ metadata: { fromCache: true }, exists: () => false });
+ fail(Object.assign(new Error("denied"), { code: "permission-denied" }));
+ return () => {};
+ });
+ watchCollectionEntry("home", "r1", (s) => seen.push(s));
+ expect(seen).toEqual([{ status: "ready", value: null }, { status: "offline", value: null }, { status: "denied" }]);
+ expect(m.onSnapshot.mock.calls[0]![1]).toEqual({ includeMetadataChanges: true });
+ });
+});
diff --git a/web/src/records/collection.ts b/web/src/records/collection.ts
new file mode 100644
index 0000000..28dc3e0
--- /dev/null
+++ b/web/src/records/collection.ts
@@ -0,0 +1,86 @@
+import { collection, onSnapshot, serverTimestamp, Timestamp, type DocumentData, type DocumentSnapshot } from "firebase/firestore";
+import { db } from "../firebase";
+import { fromCalendarDate, toCalendarDate } from "./dates";
+import { collectionRef, ConflictError, LISTEN, listenerFailure, NotFoundError, restaurantRef, toDate, write, type Snapshot, type WriteOutcome } from "./repository";
+import type { Author, CalendarDate, CollectionState } from "./types";
+
+/**
+ * Shortlist and visited state (spec §3.7). One document per restaurant, id = restaurant id,
+ * written whole by every change so the stored shape always matches the rules. Online-only
+ * transactions via the repository's write() (spec §3.5).
+ */
+
+export function toCollectionState(snap: Pick): CollectionState {
+ const d = snap.data() as DocumentData;
+ const s: CollectionState = {
+ shortlisted: d.shortlisted === true,
+ visited: d.visited === true,
+ updatedBy: String(d.updatedBy),
+ updatedByName: String(d.updatedByName),
+ updatedAt: toDate(d.updatedAt),
+ version: Number(d.version),
+ };
+ if (d.visitedOn instanceof Timestamp) s.visitedOn = toCalendarDate(d.visitedOn);
+ return s;
+}
+
+export function watchCollection(hid: string, cb: (s: Snapshot>) => void): () => void {
+ return onSnapshot(
+ collection(db, "households", hid, "collection"),
+ LISTEN,
+ (snap) => {
+ const value: Record = {};
+ for (const d of snap.docs) value[d.id] = toCollectionState(d);
+ cb({ status: snap.metadata.fromCache ? "offline" : "ready", value });
+ },
+ (err) => cb(listenerFailure(err)),
+ );
+}
+
+export function watchCollectionEntry(hid: string, rid: string, cb: (s: Snapshot) => void): () => void {
+ return onSnapshot(
+ collectionRef(hid, rid),
+ LISTEN,
+ (snap) => cb({ status: snap.metadata.fromCache ? "offline" : "ready", value: snap.exists() ? toCollectionState(snap) : null }),
+ (err) => cb(listenerFailure(err)),
+ );
+}
+
+interface StatePatch {
+ shortlisted?: boolean;
+ visitedOn?: CalendarDate | null;
+}
+
+function writeState(hid: string, rid: string, author: Author, baseVersion: number, patch: StatePatch): Promise> {
+ return write(async (tx) => {
+ const parent = await tx.get(restaurantRef(hid, rid));
+ if (!parent.exists() || (parent.data() as DocumentData).deleting === true) throw new NotFoundError();
+ const ref = collectionRef(hid, rid);
+ const snap = await tx.get(ref);
+ const current = snap.exists() ? toCollectionState(snap) : null;
+ const version = current?.version ?? 0;
+ if (version !== baseVersion) throw new ConflictError();
+ const shortlisted = patch.shortlisted ?? current?.shortlisted ?? false;
+ const visitedOn = patch.visitedOn !== undefined ? patch.visitedOn : (current?.visitedOn ?? null);
+ const data: DocumentData = {
+ shortlisted,
+ visited: visitedOn !== null,
+ updatedBy: author.uid,
+ updatedByName: author.displayName,
+ updatedAt: serverTimestamp(),
+ version: version + 1,
+ };
+ if (visitedOn !== null) data.visitedOn = fromCalendarDate(visitedOn);
+ tx.set(ref, data);
+ return version + 1;
+ });
+}
+
+export function setShortlisted(hid: string, rid: string, author: Author, baseVersion: number, shortlisted: boolean): Promise> {
+ return writeState(hid, rid, author, baseVersion, { shortlisted });
+}
+
+/** `null` clears the visit. */
+export function setVisited(hid: string, rid: string, author: Author, baseVersion: number, visitedOn: CalendarDate | null): Promise> {
+ return writeState(hid, rid, author, baseVersion, { visitedOn });
+}
diff --git a/web/src/records/combine.test.ts b/web/src/records/combine.test.ts
new file mode 100644
index 0000000..f4ea8a4
--- /dev/null
+++ b/web/src/records/combine.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { anyOffline, combineStates, isData } from "./combine";
+
+describe("combineStates", () => {
+ it("is loading until both listeners have produced a snapshot", () => {
+ expect(combineStates({ status: "loading" }, { status: "ready", value: 1 })).toEqual({ status: "loading" });
+ expect(combineStates({ status: "ready", value: 1 }, { status: "loading" })).toEqual({ status: "loading" });
+ });
+
+ it("never hides denied or an error behind other states, denied first", () => {
+ expect(combineStates({ status: "error", message: "boom" }, { status: "denied" })).toEqual({ status: "denied" });
+ expect(combineStates({ status: "offline", value: 1 }, { status: "error", message: "boom" })).toEqual({ status: "error", message: "boom" });
+ expect(combineStates({ status: "loading" }, { status: "error", message: "boom" })).toEqual({ status: "error", message: "boom" });
+ });
+
+ it("is offline when either side is cache-backed, ready only when both are from the server", () => {
+ expect(combineStates({ status: "offline", value: 1 }, { status: "ready", value: "a" })).toEqual({ status: "offline", value: [1, "a"] });
+ expect(combineStates({ status: "ready", value: 1 }, { status: "ready", value: "a" })).toEqual({ status: "ready", value: [1, "a"] });
+ });
+});
+
+describe("isData / anyOffline", () => {
+ it("classify states", () => {
+ expect(isData({ status: "offline", value: [] })).toBe(true);
+ expect(isData({ status: "loading" })).toBe(false);
+ expect(anyOffline({ status: "ready", value: 1 }, { status: "loading" })).toBe(false);
+ expect(anyOffline({ status: "ready", value: 1 }, { status: "offline", value: 2 })).toBe(true);
+ });
+});
diff --git a/web/src/records/combine.ts b/web/src/records/combine.ts
new file mode 100644
index 0000000..0ef2e6d
--- /dev/null
+++ b/web/src/records/combine.ts
@@ -0,0 +1,23 @@
+import type { WatchState } from "./useWatch";
+
+/**
+ * Read states for views built from several listeners (spec §3.7 "Read states for joined data").
+ * Precedence: denied, gone, error, loading, then offline if any side is cache-backed, else ready.
+ * An error is never hidden behind the offline notice.
+ */
+export function isData(s: WatchState): s is Extract, { status: "ready" | "offline" }> {
+ return s.status === "ready" || s.status === "offline";
+}
+
+export function anyOffline(...states: Array>): boolean {
+ return states.some((s) => s.status === "offline");
+}
+
+export function combineStates(a: WatchState, b: WatchState): WatchState<[A, B]> {
+ if (a.status === "denied" || b.status === "denied") return { status: "denied" };
+ if (a.status === "gone" || b.status === "gone") return { status: "gone" };
+ if (a.status === "error") return { status: "error", message: a.message };
+ if (b.status === "error") return { status: "error", message: b.message };
+ if (!isData(a) || !isData(b)) return { status: "loading" };
+ return { status: a.status === "offline" || b.status === "offline" ? "offline" : "ready", value: [a.value, b.value] };
+}
diff --git a/web/src/records/join.test.ts b/web/src/records/join.test.ts
new file mode 100644
index 0000000..51d6ccb
--- /dev/null
+++ b/web/src/records/join.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { filterRows, joinRecords } from "./join";
+import type { CollectionState, Restaurant } from "./types";
+
+const r = (id: string, deleting = false): Restaurant => ({ id, name: id, address: "x", createdBy: "ava-uid", createdAt: new Date(0), updatedAt: new Date(0), version: 1, deleting });
+const s = (shortlisted: boolean): CollectionState => ({ shortlisted, visited: false, updatedBy: "ava-uid", updatedByName: "Ava", updatedAt: new Date(0), version: 1 });
+
+describe("joinRecords / filterRows", () => {
+ it("pairs each restaurant with its state by id, null when absent", () => {
+ const rows = joinRecords([r("a"), r("b")], { a: s(true) });
+ expect(rows).toEqual([{ restaurant: r("a"), state: s(true) }, { restaurant: r("b"), state: null }]);
+ });
+
+ it("the shortlist keeps shortlisted rows and every deleting row; all keeps everything", () => {
+ const rows = joinRecords([r("a"), r("b"), r("c"), r("d", true)], { a: s(true), b: s(false) });
+ expect(filterRows(rows, "shortlist").map((x) => x.restaurant.id)).toEqual(["a", "d"]);
+ expect(filterRows(rows, "all").map((x) => x.restaurant.id)).toEqual(["a", "b", "c", "d"]);
+ });
+});
diff --git a/web/src/records/join.ts b/web/src/records/join.ts
new file mode 100644
index 0000000..8921e65
--- /dev/null
+++ b/web/src/records/join.ts
@@ -0,0 +1,18 @@
+import type { CollectionState, Restaurant } from "./types";
+
+export interface RecordRow {
+ restaurant: Restaurant;
+ state: CollectionState | null;
+}
+
+export type RecordFilter = "shortlist" | "all";
+
+export function joinRecords(restaurants: Restaurant[], states: Record): RecordRow[] {
+ return restaurants.map((restaurant) => ({ restaurant, state: states[restaurant.id] ?? null }));
+}
+
+/** Deleting rows stay under both filters so an interrupted deletion can always be finished. */
+export function filterRows(rows: RecordRow[], filter: RecordFilter): RecordRow[] {
+ if (filter === "all") return rows;
+ return rows.filter((row) => row.restaurant.deleting || row.state?.shortlisted === true);
+}
diff --git a/web/src/records/messages.test.ts b/web/src/records/messages.test.ts
new file mode 100644
index 0000000..fc9fff1
--- /dev/null
+++ b/web/src/records/messages.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { deleteProgressText, finishOutcomeText, statusOutcomeMessage } from "./messages";
+
+describe("deleteProgressText", () => {
+ it("names every deletion step", () => {
+ expect(deleteProgressText("marking")).toBe("Marking…");
+ expect(deleteProgressText("sweeping")).toBe("Removing evidence…");
+ expect(deleteProgressText("sweepingNotes")).toBe("Removing notes…");
+ expect(deleteProgressText("removing")).toBe("Removing restaurant…");
+ });
+});
+
+describe("finishOutcomeText", () => {
+ it("tells the member what to do when resuming a deletion fails", () => {
+ expect(finishOutcomeText("offline")).toBe("You are offline. Connect, then tap Finish deleting.");
+ expect(finishOutcomeText("notFound")).toBe("Already removed.");
+ expect(finishOutcomeText("permission")).toContain("That change was refused.");
+ expect(finishOutcomeText("failed")).toBe("Could not finish deleting. Tap Finish deleting to try again.");
+ expect(finishOutcomeText("conflict")).toBe("Could not finish deleting. Tap Finish deleting to try again.");
+ });
+});
+
+describe("statusOutcomeMessage", () => {
+ it("explains a lost race without asking for a draft reload", () => {
+ expect(statusOutcomeMessage("conflict")).toBe("Someone else changed this at the same moment. The current state is shown; try again if you still want the change.");
+ expect(statusOutcomeMessage("notFound")).toBe("This restaurant was deleted.");
+ expect(statusOutcomeMessage("offline")).toBe("You are offline. Connect and try again.");
+ });
+});
diff --git a/web/src/records/messages.ts b/web/src/records/messages.ts
index 626e3a1..5c60471 100644
--- a/web/src/records/messages.ts
+++ b/web/src/records/messages.ts
@@ -1,4 +1,4 @@
-import type { WriteOutcome } from "./repository";
+import type { DeleteStep, WriteOutcome } from "./repository";
/** One message per outcome kind; only `conflict` invites a reload (spec §3.5, audit F2). */
export function outcomeMessage(kind: WriteOutcome["kind"], what: string, verb: "save" | "delete" = "save"): string {
@@ -11,3 +11,35 @@ export function outcomeMessage(kind: WriteOutcome["kind"], what: string, verb: "
case "failed": return verb === "delete" ? `Could not delete ${what.toLowerCase()}. Try again.` : `Could not save ${what.toLowerCase()}. Try again.`;
}
}
+
+const DELETE_PROGRESS: Record = {
+ marking: "Marking…",
+ sweeping: "Removing evidence…",
+ sweepingNotes: "Removing notes…",
+ removing: "Removing restaurant…",
+};
+
+export function deleteProgressText(step: DeleteStep): string {
+ return DELETE_PROGRESS[step];
+}
+
+/** Shortlist/visited writes: there is no draft to reload, the live state is already on screen. */
+export function statusOutcomeMessage(kind: WriteOutcome["kind"]): string {
+ switch (kind) {
+ case "conflict": return "Someone else changed this at the same moment. The current state is shown; try again if you still want the change.";
+ case "notFound": return "This restaurant was deleted.";
+ default: return outcomeMessage(kind, "This change");
+ }
+}
+
+/** The Saved page's "Finish deleting" outcomes (the parked Plan 2b resume wording). */
+export function finishOutcomeText(kind: WriteOutcome["kind"]): string {
+ switch (kind) {
+ case "ok": return "";
+ case "offline": return "You are offline. Connect, then tap Finish deleting.";
+ case "notFound": return "Already removed.";
+ case "permission": return outcomeMessage("permission", "This restaurant");
+ case "conflict":
+ case "failed": return "Could not finish deleting. Tap Finish deleting to try again.";
+ }
+}
diff --git a/web/src/records/notes.test.ts b/web/src/records/notes.test.ts
new file mode 100644
index 0000000..ba3e8f5
--- /dev/null
+++ b/web/src/records/notes.test.ts
@@ -0,0 +1,110 @@
+import { serverTimestamp, Timestamp } from "firebase/firestore";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { memoryTransactions, type Store } from "../test/memoryFirestore";
+
+const m = vi.hoisted(() => ({ runTransaction: vi.fn(), onSnapshot: vi.fn(), orderBy: vi.fn() }));
+
+vi.mock("../firebase", () => ({ db: { fake: true } }));
+vi.mock("firebase/firestore", async (importOriginal) => {
+ const actual = await importOriginal();
+ let generated = 0;
+ return {
+ ...actual,
+ runTransaction: m.runTransaction,
+ onSnapshot: m.onSnapshot,
+ getDocsFromServer: vi.fn(),
+ collection: (_db: unknown, ...segments: string[]) => ({ path: segments.join("/") }),
+ doc: (parent: unknown, ...segments: string[]) => {
+ const base = typeof (parent as { path?: string }).path === "string" ? (parent as { path: string }).path : "";
+ const id = segments.length > 0 ? segments[segments.length - 1]! : `gen-${++generated}`;
+ const path = segments.length > 0 ? [base, ...segments].filter(Boolean).join("/") : `${base}/${id}`;
+ return { id, path };
+ },
+ query: (source: unknown) => source,
+ orderBy: m.orderBy,
+ limit: () => undefined,
+ };
+});
+
+import { addNote, deleteNote, updateNote, watchNotes } from "./notes";
+
+const RP = "households/home/restaurants/r1";
+const NP = `${RP}/notes/n1`;
+const AVA = { uid: "ava-uid", displayName: "Ava" };
+const live = { name: "Da Marco", address: "Via Roma 1", deleting: false, version: 2 };
+const at = Timestamp.fromDate(new Date("2026-09-01T10:00:00Z"));
+const note = { text: "Great staff", authorUid: "ava-uid", authorName: "Ava", createdAt: at, updatedAt: at, version: 2 };
+
+beforeEach(() => {
+ Object.defineProperty(navigator, "onLine", { configurable: true, value: true });
+});
+afterEach(() => vi.clearAllMocks());
+
+describe("addNote", () => {
+ it("writes version 1, the author's identity and server timestamps under a live restaurant", async () => {
+ const store: Store = new Map([[RP, live]]);
+ memoryTransactions(m.runTransaction, store);
+ const outcome = await addNote("home", "r1", AVA, "Staff knew about cross-contamination.");
+ expect(outcome.kind).toBe("ok");
+ const id = (outcome as { value: string }).value;
+ expect(store.get(`${RP}/notes/${id}`)).toEqual({
+ text: "Staff knew about cross-contamination.",
+ authorUid: "ava-uid",
+ authorName: "Ava",
+ createdAt: serverTimestamp(),
+ updatedAt: serverTimestamp(),
+ version: 1,
+ });
+ });
+
+ it("reports notFound under a missing restaurant or one marked deleting", async () => {
+ memoryTransactions(m.runTransaction, new Map());
+ expect((await addNote("home", "r1", AVA, "x")).kind).toBe("notFound");
+ memoryTransactions(m.runTransaction, new Map([[RP, { ...live, deleting: true }]]));
+ expect((await addNote("home", "r1", AVA, "x")).kind).toBe("notFound");
+ });
+});
+
+describe("updateNote", () => {
+ it("writes only text, updatedAt and the next version", async () => {
+ const store: Store = new Map>([[RP, live], [NP, note]]);
+ const tx = memoryTransactions(m.runTransaction, store);
+ expect(await updateNote("home", "r1", "n1", 2, "Edited")).toEqual({ kind: "ok", value: 3 });
+ expect(tx.update).toHaveBeenCalledWith({ id: "n1", path: NP }, { text: "Edited", updatedAt: serverTimestamp(), version: 3 });
+ });
+
+ it("reports conflict on a stale base and notFound for a missing note or a restaurant marked deleting", async () => {
+ const store: Store = new Map>([[RP, live], [NP, note]]);
+ const tx = memoryTransactions(m.runTransaction, store);
+ expect(await updateNote("home", "r1", "n1", 1, "Edited")).toEqual({ kind: "conflict" });
+ expect(await updateNote("home", "r1", "gone", 1, "Edited")).toEqual({ kind: "notFound" });
+ store.set(RP, { ...live, deleting: true });
+ expect(await updateNote("home", "r1", "n1", 2, "Edited")).toEqual({ kind: "notFound" });
+ expect(tx.update).not.toHaveBeenCalled();
+ });
+});
+
+describe("deleteNote", () => {
+ it("deletes only the version the member confirmed", async () => {
+ const store: Store = new Map>([[RP, live], [NP, note]]);
+ memoryTransactions(m.runTransaction, store);
+ expect(await deleteNote("home", "r1", "n1", 1)).toEqual({ kind: "conflict" });
+ expect(store.has(NP)).toBe(true);
+ expect(await deleteNote("home", "r1", "n1", 2)).toEqual({ kind: "ok", value: undefined });
+ expect(store.has(NP)).toBe(false);
+ expect(await deleteNote("home", "r1", "n1", 2)).toEqual({ kind: "notFound" });
+ });
+});
+
+describe("watchNotes", () => {
+ it("lists newest first and reports ready/offline", () => {
+ const seen: unknown[] = [];
+ m.onSnapshot.mockImplementation((_q: unknown, _o: unknown, next: (s: unknown) => void) => {
+ next({ metadata: { fromCache: true }, docs: [{ id: "n1", data: () => note }] });
+ return () => {};
+ });
+ watchNotes("home", "r1", (s) => seen.push(s));
+ expect(m.orderBy).toHaveBeenCalledWith("createdAt", "desc");
+ expect(seen[0]).toEqual({ status: "offline", value: [{ id: "n1", text: "Great staff", authorUid: "ava-uid", authorName: "Ava", createdAt: new Date("2026-09-01T10:00:00Z"), updatedAt: new Date("2026-09-01T10:00:00Z"), version: 2 }] });
+ });
+});
diff --git a/web/src/records/notes.ts b/web/src/records/notes.ts
new file mode 100644
index 0000000..e7bdf62
--- /dev/null
+++ b/web/src/records/notes.ts
@@ -0,0 +1,63 @@
+import { doc, onSnapshot, orderBy, query, serverTimestamp, type DocumentData, type DocumentSnapshot } from "firebase/firestore";
+import { ConflictError, LISTEN, listenerFailure, notesCol, NotFoundError, restaurantRef, toDate, write, type Snapshot, type WriteOutcome } from "./repository";
+import type { Author, Note } from "./types";
+
+/**
+ * Authored notes on a restaurant (spec §3.7). Only the author edits or deletes (rules-enforced);
+ * edits and deletes carry the version the member saw, so a change from another device surfaces
+ * as a conflict instead of being overwritten. Callers pass validated, trimmed text.
+ */
+
+export function toNote(snap: Pick): Note {
+ const d = snap.data() as DocumentData;
+ return {
+ id: snap.id,
+ text: String(d.text),
+ authorUid: String(d.authorUid),
+ authorName: String(d.authorName),
+ createdAt: toDate(d.createdAt),
+ updatedAt: toDate(d.updatedAt),
+ version: Number(d.version),
+ };
+}
+
+export function watchNotes(hid: string, rid: string, cb: (s: Snapshot) => void): () => void {
+ return onSnapshot(
+ query(notesCol(hid, rid), orderBy("createdAt", "desc")),
+ LISTEN,
+ (snap) => cb({ status: snap.metadata.fromCache ? "offline" : "ready", value: snap.docs.map(toNote) }),
+ (err) => cb(listenerFailure(err)),
+ );
+}
+
+export function addNote(hid: string, rid: string, author: Author, text: string): Promise> {
+ return write(async (tx) => {
+ const parent = await tx.get(restaurantRef(hid, rid));
+ if (!parent.exists() || (parent.data() as DocumentData).deleting === true) throw new NotFoundError();
+ const ref = doc(notesCol(hid, rid));
+ tx.set(ref, { text, authorUid: author.uid, authorName: author.displayName, createdAt: serverTimestamp(), updatedAt: serverTimestamp(), version: 1 });
+ return ref.id;
+ });
+}
+
+export function updateNote(hid: string, rid: string, nid: string, baseVersion: number, text: string): Promise> {
+ return write(async (tx) => {
+ const parent = await tx.get(restaurantRef(hid, rid));
+ const snap = await tx.get(doc(notesCol(hid, rid), nid));
+ if (!parent.exists() || (parent.data() as DocumentData).deleting === true || !snap.exists()) throw new NotFoundError();
+ const current = toNote(snap);
+ if (current.version !== baseVersion) throw new ConflictError();
+ const next = baseVersion + 1;
+ tx.update(snap.ref, { text, updatedAt: serverTimestamp(), version: next });
+ return next;
+ });
+}
+
+export function deleteNote(hid: string, rid: string, nid: string, baseVersion: number): Promise {
+ return write(async (tx) => {
+ const snap = await tx.get(doc(notesCol(hid, rid), nid));
+ if (!snap.exists()) throw new NotFoundError();
+ if (toNote(snap).version !== baseVersion) throw new ConflictError();
+ tx.delete(snap.ref);
+ });
+}
diff --git a/web/src/records/repository.test.ts b/web/src/records/repository.test.ts
index 79c9506..2cb133b 100644
--- a/web/src/records/repository.test.ts
+++ b/web/src/records/repository.test.ts
@@ -33,14 +33,19 @@ import {
addClaim,
createRestaurant,
deleteRestaurant,
+ finishDeleting,
+ markCleanupDone,
markDeleting,
+ removeCollectionState,
sweepClaims,
+ sweepNotes,
toClaim,
toRestaurant,
updateRestaurant,
watchRestaurant,
watchRestaurants,
} from "./repository";
+import { memoryPage, memoryTransactions, type Store } from "../test/memoryFirestore";
function fakeTx(getResult: { exists: boolean; data?: Record }) {
const tx = {
@@ -189,13 +194,75 @@ describe("deletion protocol", () => {
expect(m.getDocsFromServer).toHaveBeenCalledTimes(2);
});
- it("deleteRestaurant runs mark → sweep → remove and reports progress; stops at the first non-ok outcome", async () => {
+ const RP = "households/home/restaurants/r1";
+
+ function doomedStore(over: Record = {}): Store {
+ return new Map>([
+ [RP, { ...storedRestaurant, deleting: true, version: 4, ...over }],
+ [`${RP}/claims/c1`, { kind: "gfMenu" }],
+ [`${RP}/claims/c2`, { kind: "separateFryer" }],
+ [`${RP}/notes/n1`, { text: "Ava's", authorUid: "ava-uid" }],
+ [`${RP}/notes/n2`, { text: "Bogdan's", authorUid: "bogdan-uid" }],
+ ["households/home/collection/r1", { shortlisted: true, visited: false, version: 2 }],
+ ]);
+ }
+
+ it("finishDeleting sweeps claims and notes, removes the state document, marks cleanupDone, then removes the restaurant", async () => {
+ const store = doomedStore();
+ const tx = memoryTransactions(m.runTransaction, store);
+ m.getDocsFromServer.mockImplementation(async (q: { path: string }) => memoryPage(store, q.path));
+ const steps: string[] = [];
+ expect(await finishDeleting("home", "r1", (s) => steps.push(s))).toEqual({ kind: "ok", value: undefined });
+ expect(steps).toEqual(["sweeping", "sweepingNotes", "removing"]);
+ expect(store.size).toBe(0);
+ expect(tx.update).toHaveBeenCalledWith({ id: "r1", path: RP }, { cleanupDone: true, version: 5, updatedAt: serverTimestamp() });
+ const updateOrder = tx.update.mock.invocationCallOrder[0]!;
+ const restaurantDelete = tx.delete.mock.calls.findIndex(([ref]) => (ref as { path: string }).path === RP);
+ expect(tx.delete.mock.invocationCallOrder[restaurantDelete]!).toBeGreaterThan(updateOrder);
+ });
+
+ it("sweeps skip documents another sweeper already removed instead of failing", async () => {
+ const store = doomedStore();
+ const tx = memoryTransactions(m.runTransaction, store);
+ store.delete(`${RP}/notes/n2`); // removed between the page read and this transaction
+ m.getDocsFromServer
+ .mockResolvedValueOnce({ empty: false, size: 2, docs: [{ id: "n1", ref: { id: "n1", path: `${RP}/notes/n1` } }, { id: "n2", ref: { id: "n2", path: `${RP}/notes/n2` } }] })
+ .mockResolvedValueOnce({ empty: true, size: 0, docs: [] });
+ expect(await sweepNotes("home", "r1")).toEqual({ kind: "ok", value: 1 });
+ expect(tx.delete).toHaveBeenCalledTimes(1);
+ });
+
+ it("finishing an already-removed restaurant is a no-op, not a failure", async () => {
+ const store: Store = new Map();
+ const tx = memoryTransactions(m.runTransaction, store);
+ m.getDocsFromServer.mockImplementation(async (q: { path: string }) => memoryPage(store, q.path));
+ expect(await finishDeleting("home", "r1")).toEqual({ kind: "ok", value: undefined });
+ expect(tx.update).not.toHaveBeenCalled();
+ expect(tx.delete).not.toHaveBeenCalled();
+ });
+
+ it("markCleanupDone refuses a live restaurant and does nothing when already set", async () => {
+ memoryTransactions(m.runTransaction, new Map([[RP, { ...storedRestaurant }]]));
+ expect(await markCleanupDone("home", "r1")).toEqual({ kind: "notFound" });
+ const tx = memoryTransactions(m.runTransaction, new Map([[RP, { ...storedRestaurant, deleting: true, cleanupDone: true }]]));
+ expect(await markCleanupDone("home", "r1")).toEqual({ kind: "ok", value: undefined });
+ expect(tx.update).not.toHaveBeenCalled();
+ });
+
+ it("removeCollectionState deletes only a document that exists", async () => {
+ const tx = memoryTransactions(m.runTransaction, new Map());
+ expect(await removeCollectionState("home", "r1")).toEqual({ kind: "ok", value: undefined });
+ expect(tx.delete).not.toHaveBeenCalled();
+ });
+
+ it("deleteRestaurant runs mark → sweeps → remove and reports every step; stops at the first non-ok outcome", async () => {
+ const store = doomedStore({ deleting: false, version: 3 });
+ memoryTransactions(m.runTransaction, store);
+ m.getDocsFromServer.mockImplementation(async (q: { path: string }) => memoryPage(store, q.path));
const steps: string[] = [];
- const tx = fakeTx({ exists: true, data: storedRestaurant });
- m.getDocsFromServer.mockResolvedValue({ size: 0, empty: true, docs: [] });
expect(await deleteRestaurant("home", "r1", 3, (s) => steps.push(s))).toEqual({ kind: "ok", value: undefined });
- expect(steps).toEqual(["marking", "sweeping", "removing"]);
- expect(tx.delete).toHaveBeenCalledTimes(1); // the restaurant document
+ expect(steps).toEqual(["marking", "sweeping", "sweepingNotes", "removing"]);
+ expect(store.size).toBe(0);
const stopped: string[] = [];
fakeTx({ exists: true, data: { ...storedRestaurant, version: 9 } });
diff --git a/web/src/records/repository.ts b/web/src/records/repository.ts
index a4227d4..6265648 100644
--- a/web/src/records/repository.ts
+++ b/web/src/records/repository.ts
@@ -10,6 +10,7 @@ import {
runTransaction,
serverTimestamp,
Timestamp,
+ type CollectionReference,
type DocumentData,
type DocumentSnapshot,
type FirestoreError,
@@ -41,19 +42,22 @@ export type WriteOutcome =
| { kind: "offline" }
| { kind: "failed"; message: string };
-export type DeleteStep = "marking" | "sweeping" | "removing";
+export type DeleteStep = "marking" | "sweeping" | "sweepingNotes" | "removing";
-/** Claims deleted per transaction during a sweep (well under Firestore's per-transaction limit). */
+/** Documents deleted per transaction during a sweep (well under Firestore's per-transaction limit). */
export const SWEEP_PAGE = 100;
-class ConflictError extends Error {}
-class NotFoundError extends Error {}
+// The helpers below are exported for the sibling record modules (collection.ts, notes.ts) only.
+export class ConflictError extends Error {}
+export class NotFoundError extends Error {}
const restaurantsCol = (hid: string) => collection(db, "households", hid, "restaurants");
-const restaurantRef = (hid: string, rid: string) => doc(db, "households", hid, "restaurants", rid);
+export const restaurantRef = (hid: string, rid: string) => doc(db, "households", hid, "restaurants", rid);
const claimsCol = (hid: string, rid: string) => collection(db, "households", hid, "restaurants", rid, "claims");
+export const notesCol = (hid: string, rid: string) => collection(db, "households", hid, "restaurants", rid, "notes");
+export const collectionRef = (hid: string, rid: string) => doc(db, "households", hid, "collection", rid);
-function toDate(value: unknown): Date {
+export function toDate(value: unknown): Date {
return value instanceof Timestamp ? value.toDate() : new Date(0);
}
@@ -97,12 +101,12 @@ export function toClaim(snap: DocumentSnapshot): Claim {
return c;
}
-function listenerFailure(err: FirestoreError): Snapshot {
+export function listenerFailure(err: FirestoreError): Snapshot {
return err.code === "permission-denied" ? { status: "denied" } : { status: "error", message: err.message };
}
/** includeMetadataChanges: a cache→server transition with identical data must still flip offline→ready. */
-const LISTEN = { includeMetadataChanges: true } as const;
+export const LISTEN = { includeMetadataChanges: true } as const;
export function watchRestaurants(hid: string, cb: (s: Snapshot) => void): () => void {
return onSnapshot(
@@ -138,7 +142,7 @@ export function watchClaims(hid: string, rid: string, cb: (s: Snapshot)
);
}
-function classify(err: unknown): WriteOutcome {
+export function classify(err: unknown): WriteOutcome {
if (err instanceof ConflictError) return { kind: "conflict" };
if (err instanceof NotFoundError) return { kind: "notFound" };
const code = (err as { code?: string }).code;
@@ -148,7 +152,7 @@ function classify(err: unknown): WriteOutcome {
}
/** All writes go through here: offline pre-check, one transaction, typed outcome. Never throws. */
-async function write(run: (tx: Transaction) => Promise): Promise> {
+export async function write(run: (tx: Transaction) => Promise): Promise> {
if (typeof navigator !== "undefined" && navigator.onLine === false) return { kind: "offline" };
try {
const value = await runTransaction(db, run);
@@ -209,42 +213,90 @@ export function markDeleting(hid: string, rid: string, baseVersion: number): Pro
});
}
-/** Deletion step 2: server-read pages of claims, each deleted in one transaction, until none remain. */
-export async function sweepClaims(hid: string, rid: string): Promise> {
+/**
+ * Deletion steps 2–3 (spec §3.7): server-read pages; each page's documents are re-read inside one
+ * transaction and only those still present are deleted, so two finishers and retries converge
+ * instead of one of them failing on an already-deleted document.
+ */
+async function sweep(col: CollectionReference): Promise> {
let deleted = 0;
for (;;) {
let page;
try {
- page = await getDocsFromServer(query(claimsCol(hid, rid), limit(SWEEP_PAGE)));
+ page = await getDocsFromServer(query(col, limit(SWEEP_PAGE)));
} catch (err) {
return classify(err);
}
if (page.empty) return { kind: "ok", value: deleted };
const refs = page.docs.map((d) => d.ref);
const outcome = await write(async (tx) => {
- for (const ref of refs) tx.delete(ref);
+ const snaps = await Promise.all(refs.map((ref) => tx.get(ref)));
+ let removed = 0;
+ for (const snap of snaps) {
+ if (snap.exists()) {
+ tx.delete(snap.ref);
+ removed += 1;
+ }
+ }
+ return removed;
});
if (outcome.kind !== "ok") return outcome;
- deleted += refs.length;
+ deleted += outcome.value;
}
}
-/** Deletion step 3. The rules refuse this unless the restaurant is marked deleting. */
+export function sweepClaims(hid: string, rid: string): Promise> {
+ return sweep(claimsCol(hid, rid));
+}
+
+export function sweepNotes(hid: string, rid: string): Promise> {
+ return sweep(notesCol(hid, rid));
+}
+
+/** Deletion step 4: the shortlist/visited document, if any. */
+export function removeCollectionState(hid: string, rid: string): Promise {
+ return write(async (tx) => {
+ const snap = await tx.get(collectionRef(hid, rid));
+ if (snap.exists()) tx.delete(snap.ref);
+ });
+}
+
+/** Deletion step 5, the completion gate. Already removed or already done counts as done. */
+export function markCleanupDone(hid: string, rid: string): Promise {
+ return write(async (tx) => {
+ const snap = await tx.get(restaurantRef(hid, rid));
+ if (!snap.exists()) return;
+ const d = snap.data() as DocumentData;
+ if (d.deleting !== true) throw new NotFoundError();
+ if (d.cleanupDone === true) return;
+ tx.update(snap.ref, { cleanupDone: true, version: Number(d.version) + 1, updatedAt: serverTimestamp() });
+ });
+}
+
+/** Deletion step 6. The rules refuse this unless the gate is satisfied. Already removed counts as done. */
export function removeRestaurant(hid: string, rid: string): Promise {
return write(async (tx) => {
- tx.delete(restaurantRef(hid, rid));
+ const snap = await tx.get(restaurantRef(hid, rid));
+ if (snap.exists()) tx.delete(snap.ref);
});
}
export async function finishDeleting(hid: string, rid: string, onProgress?: (step: DeleteStep) => void): Promise {
onProgress?.("sweeping");
- const swept = await sweepClaims(hid, rid);
- if (swept.kind !== "ok") return swept;
+ const claims = await sweepClaims(hid, rid);
+ if (claims.kind !== "ok") return claims;
+ onProgress?.("sweepingNotes");
+ const notes = await sweepNotes(hid, rid);
+ if (notes.kind !== "ok") return notes;
onProgress?.("removing");
+ const state = await removeCollectionState(hid, rid);
+ if (state.kind !== "ok") return state;
+ const done = await markCleanupDone(hid, rid);
+ if (done.kind !== "ok") return done;
return removeRestaurant(hid, rid);
}
-/** The whole protocol (spec §3.5 "Deletion protocol"). Stops at the first non-ok step. */
+/** The whole protocol (spec §3.5, extended by §3.7). Stops at the first non-ok step. */
export async function deleteRestaurant(hid: string, rid: string, baseVersion: number, onProgress?: (step: DeleteStep) => void): Promise {
onProgress?.("marking");
const marked = await markDeleting(hid, rid, baseVersion);
diff --git a/web/src/records/rulesParity.test.ts b/web/src/records/rulesParity.test.ts
index 64928ae..3865a9b 100644
--- a/web/src/records/rulesParity.test.ts
+++ b/web/src/records/rulesParity.test.ts
@@ -25,5 +25,6 @@ describe("firestore.rules mentions every TypeScript literal", () => {
expect(rules).toContain(`data.detail.size() <= ${LIMITS.detail}`);
expect(rules).toContain(`nonBlankString(s.label, ${LIMITS.sourceLabel})`);
expect(rules).toContain(`optionalHttpUrl(s, 'url', ${LIMITS.sourceUrl})`);
+ expect(rules).toContain(`nonBlankString(request.resource.data.text, ${LIMITS.note})`);
});
});
diff --git a/web/src/records/types.ts b/web/src/records/types.ts
index 3a64813..7dc25e5 100644
--- a/web/src/records/types.ts
+++ b/web/src/records/types.ts
@@ -93,3 +93,29 @@ export interface Author {
uid: string;
displayName: string;
}
+
+/**
+ * Read model of households/{hid}/collection/{rid} (spec §3.7): household-wide shortlist and
+ * visited state. A missing document means not shortlisted, not visited, version 0, but only
+ * when the snapshot came from the server.
+ */
+export interface CollectionState {
+ shortlisted: boolean;
+ visited: boolean;
+ visitedOn?: CalendarDate;
+ updatedBy: string;
+ updatedByName: string;
+ updatedAt: Date;
+ version: number;
+}
+
+/** Read model of households/{hid}/restaurants/{rid}/notes/{nid}. Personal notes, never evidence. */
+export interface Note {
+ id: string;
+ text: string;
+ authorUid: string;
+ authorName: string;
+ createdAt: Date;
+ updatedAt: Date;
+ version: number;
+}
diff --git a/web/src/records/validation.test.ts b/web/src/records/validation.test.ts
index e1760cb..3104ce7 100644
--- a/web/src/records/validation.test.ts
+++ b/web/src/records/validation.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ClaimInput } from "./types";
-import { LIMITS, isHttpUrl, normaliseRestaurantInput, validateClaimInput, validateRestaurantInput } from "./validation";
+import { LIMITS, isHttpUrl, normaliseRestaurantInput, validateClaimInput, validateNoteText, validateRestaurantInput, validateVisitedOn } from "./validation";
// URL() repairs these spellings, but forms must require an explicit web address.
const MALFORMED_HTTP_URLS = [
@@ -105,3 +105,22 @@ describe("validateClaimInput", () => {
expect(errors.sourceType).toBeDefined();
});
});
+
+describe("validateNoteText", () => {
+ it("refuses blank text and text over the limit, accepts up to 2,000 characters after trimming", () => {
+ expect(validateNoteText(" ")).toBe("Write something first.");
+ expect(validateNoteText("x".repeat(2001))).toBe("Keep this to 2000 characters.");
+ expect(validateNoteText(` ${"x".repeat(2000)} `)).toBeNull();
+ expect(validateNoteText("Lovely staff")).toBeNull();
+ });
+});
+
+describe("validateVisitedOn", () => {
+ it("needs a real calendar date that is not after the device's today", () => {
+ expect(validateVisitedOn("", "2026-09-24")).toBe("Enter the date of the visit.");
+ expect(validateVisitedOn("2026-02-30", "2026-09-24")).toBe("Enter the date of the visit.");
+ expect(validateVisitedOn("2026-09-25", "2026-09-24")).toBe("The visit date can't be in the future.");
+ expect(validateVisitedOn("2026-09-24", "2026-09-24")).toBeNull();
+ expect(validateVisitedOn("2025-05-03", "2026-09-24")).toBeNull();
+ });
+});
diff --git a/web/src/records/validation.ts b/web/src/records/validation.ts
index a45944a..ef7baac 100644
--- a/web/src/records/validation.ts
+++ b/web/src/records/validation.ts
@@ -2,7 +2,7 @@ import { compareCalendarDates, isCalendarDate } from "./dates";
import { CLAIM_KINDS, CLAIM_VALUES, SOURCE_TYPES, type CalendarDate, type ClaimInput, type RestaurantInput } from "./types";
/** Identical to the limits in firestore.rules (validRestaurant / validClaim / validSource). */
-export const LIMITS = { name: 120, address: 300, phone: 40, website: 300, detail: 1000, sourceLabel: 200, sourceUrl: 500, googlePlaceId: 200 } as const;
+export const LIMITS = { name: 120, address: 300, phone: 40, website: 300, detail: 1000, sourceLabel: 200, sourceUrl: 500, googlePlaceId: 200, note: 2000 } as const;
export type FieldErrors = Partial>;
export type RestaurantField = "name" | "address" | "phone" | "website";
@@ -85,3 +85,17 @@ export function validateClaimInput(input: ClaimInput, today: CalendarDate): Fiel
}
return errors;
}
+
+export function validateNoteText(text: string): string | null {
+ const trimmed = text.trim();
+ if (trimmed === "") return "Write something first.";
+ if (trimmed.length > LIMITS.note) return tooLong(LIMITS.note);
+ return null;
+}
+
+/** `today` is the device's local calendar day (useToday); the rules allow one day of slack. */
+export function validateVisitedOn(value: string, today: CalendarDate): string | null {
+ if (!isCalendarDate(value)) return "Enter the date of the visit.";
+ if (compareCalendarDates(value, today) > 0) return "The visit date can't be in the future.";
+ return null;
+}
diff --git a/web/src/styles.css b/web/src/styles.css
index 19c533c..093620f 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -46,3 +46,14 @@ body { margin: 0; }
.attribution { margin: 10px 0 5px; padding: 0 10px; }
.attribution img { height: 19px; width: auto; display: block; }
.hint { font-size: 0.9rem; opacity: 0.8; }
+
+.filter { display: flex; gap: 0.5rem; }
+.filter button[aria-pressed="true"] { font-weight: 600; text-decoration: underline; }
+.labels { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.3rem; }
+.label { font-size: 0.8rem; border: 1px solid #8886; border-radius: 999px; padding: 0.05rem 0.5rem; }
+
+.notes textarea { width: 100%; box-sizing: border-box; font: inherit; }
+.note-composer { display: grid; gap: 0.4rem; margin-bottom: var(--gap); }
+.note { margin-top: 0.5rem; }
+.note-text { white-space: pre-wrap; }
+.note blockquote { margin: 0.4rem 0; padding-left: 0.6rem; border-left: 3px solid #8886; white-space: pre-wrap; }
diff --git a/web/src/test/memoryFirestore.ts b/web/src/test/memoryFirestore.ts
new file mode 100644
index 0000000..bfecb01
--- /dev/null
+++ b/web/src/test/memoryFirestore.ts
@@ -0,0 +1,49 @@
+import { vi, type Mock } from "vitest";
+
+/** Document path → data. Paths look like "households/home/restaurants/r1". */
+export type Store = Map>;
+
+interface Ref {
+ id: string;
+ path: string;
+}
+
+function snapshot(store: Store, ref: Ref) {
+ const data = store.get(ref.path);
+ return { id: ref.id, ref, exists: () => data !== undefined, data: () => (data === undefined ? undefined : { ...data }) };
+}
+
+/**
+ * Wires a mocked `runTransaction` to an in-memory store, so a sequence of repository calls sees
+ * its own earlier writes. Test-only; paths come from the `doc`/`collection` mocks each test file
+ * installs (they join segments with "/").
+ */
+export function memoryTransactions(runTransaction: Mock, store: Store) {
+ const tx = {
+ get: vi.fn(async (ref: Ref) => snapshot(store, ref)),
+ set: vi.fn((ref: Ref, data: Record) => {
+ store.set(ref.path, { ...data });
+ }),
+ update: vi.fn((ref: Ref, patch: Record) => {
+ store.set(ref.path, { ...store.get(ref.path), ...patch });
+ }),
+ delete: vi.fn((ref: Ref) => {
+ store.delete(ref.path);
+ }),
+ };
+ runTransaction.mockImplementation(async (_db: unknown, run: (t: typeof tx) => Promise) => run(tx));
+ return tx;
+}
+
+/** The documents directly under `collectionPath`, shaped like a getDocsFromServer page. */
+export function memoryPage(store: Store, collectionPath: string, pageSize = 100) {
+ const prefix = `${collectionPath}/`;
+ const docs = [...store.keys()]
+ .filter((p) => p.startsWith(prefix) && !p.slice(prefix.length).includes("/"))
+ .slice(0, pageSize)
+ .map((p) => {
+ const id = p.slice(prefix.length);
+ return { id, ref: { id, path: p } };
+ });
+ return { empty: docs.length === 0, size: docs.length, docs };
+}