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 && ( +
    + {rows.map(({ restaurant: r, state }) => + r.deleting ? ( +
  • + {r.name} — Deleting… +
    + {progress[r.id]} + +
    +
  • + ) : ( +
  • + + {r.name} +
    + {r.address} + {state && (state.shortlisted || state.visitedOn) && ( + + {state.shortlisted && Shortlisted} + {state.visited && state.visitedOn && Visited {formatCalendarDate(state.visitedOn)}} + + )} + +
  • + ), + )} +
+ )} +
+ ); +} +``` + +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 ( +
+