fix(pwa): keep the daily briefing text out of IndexedDB

The dashboard snapshot carries the daily briefing and its recall line,
and the persister wrote the snapshot to IndexedDB for up to a day,
although its own comment said narrative text never reaches the disk.

The persister now nulls both fields before the write. The web
dashboard reads the briefing from the Today digest, which is never
persisted, so the restored snapshot paints exactly what it did before.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:38:41 +01:00
1 parent 960f3c4878
commit b3b4c9684e
2 files changed
+62 -6

No files matched your search

@@ -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());
+34 -6
View File
@@ -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<string, unknown> = { ...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(),