diff --git a/src/lib/pwa/__tests__/query-persister.test.ts b/src/lib/pwa/__tests__/query-persister.test.ts index a68f1edf0..2c7b08a12 100644 --- a/src/lib/pwa/__tests__/query-persister.test.ts +++ b/src/lib/pwa/__tests__/query-persister.test.ts @@ -407,6 +407,34 @@ describe("persister round-trips through a fake IndexedDB", () => { }); }); + it("writes the dashboard snapshot without the model-written briefing", async () => { + installRoundTripIdb(); + const source = new QueryClient(); + source.setQueryData(["auth", "me"], { id: "user-a" }); + const briefing = { headline: "Your pressure ran high this week." }; + const briefingMemory = { recall: "Last week: steady.", forward: "Calm." }; + source.setQueryData(["dashboard", "snapshot", "en"], { + weightKg: 81, + briefing, + briefingMemory, + briefingState: "ready", + }); + await persistOnce(source); + + const written = JSON.stringify(roundTripStore.get("react-query")); + expect(written).toContain('"weightKg":81'); + expect(written).not.toContain(briefing.headline); + expect(written).not.toContain(briefingMemory.recall); + // The live cache keeps what the server sent. + expect( + source.getQueryData<{ briefing: unknown }>([ + "dashboard", + "snapshot", + "en", + ])?.briefing, + ).toBe(briefing); + }); + it("discards a snapshot written before any account was known", async () => { installRoundTripIdb(); await persistOnce(new QueryClient()); diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts index eee75a8ed..7183831a1 100644 --- a/src/lib/pwa/query-persister.ts +++ b/src/lib/pwa/query-persister.ts @@ -12,9 +12,11 @@ * dashboard-offline families off disk. This is the privacy floor: a * denylist would persist every family it didn't name (cycle, mood, illness, * labs, coach, insights, medications, …) in plaintext. Only the dashboard - * snapshot, its resolved widget layout, and the measurement daily-series — - * the data the dashboard needs to paint last-known values offline — are - * eligible. Clinical / narrative families are never written to disk. + * snapshot, its resolved widget layout, and the measurement daily-series + * are eligible. Clinical / narrative families are never written to disk. + * - Model-written text never is either: the snapshot carries the daily + * briefing and its recall line, and both are nulled before the write + * (`withoutModelText`). * - `clearPersistedQueryCache()` is called on every session END (logout AND * the 401 / session-expiry redirect), so a shared device never leaks one * account's cached data to the next once the session is gone. @@ -57,8 +59,7 @@ const MAX_PERSIST_BYTES = 2 * 1024 * 1024; // 2 MB /** * Strict allowlist of the only query families written to disk — the - * low-sensitivity reads the dashboard needs to paint last-known values when - * opened offline. Everything else (coach, insights, illness, labs, mood, + * low-sensitivity reads the dashboard paints first. Everything else (coach, insights, illness, labs, mood, * medications, cycle, auth, admin, tokens, …) stays in memory only. * * - `["dashboard", …]` — the unified first-paint snapshot @@ -80,6 +81,29 @@ const MAX_PERSIST_BYTES = 2 * 1024 * 1024; // 2 MB */ const PERSIST_ALLOWLIST_HEADS = ["dashboard", "chart-data"] as const; +/** Snapshot fields a model may have written: the daily briefing and its recall line. */ +const MODEL_TEXT_FIELDS = ["briefing", "briefingMemory"] as const; + +/** + * The dashboard snapshot with its model-written fields nulled. The web + * dashboard reads the briefing from the Today digest, which is never + * persisted, so the restored snapshot loses nothing it paints. Returns new + * objects; the live cache keeps its copy. + */ +export function withoutModelText( + query: DehydratedState["queries"][number], +): DehydratedState["queries"][number] { + const data = query.state.data; + if (query.queryKey[0] !== "dashboard" || !data || typeof data !== "object") { + return query; + } + const stripped: Record = { ...data }; + for (const field of MODEL_TEXT_FIELDS) { + if (field in stripped) stripped[field] = null; + } + return { ...query, state: { ...query.state, data: stripped } }; +} + export function isPersistableKey(queryKey: readonly unknown[]): boolean { const head = queryKey[0]; if (typeof head !== "string") return false; @@ -253,10 +277,14 @@ export function startPersistingQueryCache( // the flush (rather than filtering keys) also means the last good snapshot // of the caller's own dashboard survives a switch untouched. if (isReadingSharedRecord()) return; - const state = dehydrate(queryClient, { + const dehydrated = dehydrate(queryClient, { shouldDehydrateQuery: (query) => query.state.status === "success" && isPersistableKey(query.queryKey), }); + const state: DehydratedState = { + ...dehydrated, + queries: dehydrated.queries.map(withoutModelText), + }; const payload: PersistedPayload = { buildVersion, savedAt: Date.now(),