From cf903f50221523ee8e3ddc60d84aa78597af37bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:38:09 +0200 Subject: [PATCH 01/32] fix(crypto): stop day-log upserts wiping undecryptable encrypted fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A partial-update upsert soft-decrypted the existing encrypted column, returned null on failure, then re-wrote it — so editing an unrelated field permanently nulled an undecryptable note or sensitive envelope. Preserve the existing ciphertext verbatim when the encrypted field is absent from the update; only re-encrypt on an explicit field write. Mirrors the v1.18.1 rotation fix. --- src/lib/cycle/__tests__/day-log-write.test.ts | 156 ++++++++++++++++ src/lib/cycle/day-log-write.ts | 167 ++++++++++++++---- .../illness/__tests__/day-log-write.test.ts | 36 +++- src/lib/illness/day-log-write.ts | 30 ++-- 4 files changed, 334 insertions(+), 55 deletions(-) create mode 100644 src/lib/cycle/__tests__/day-log-write.test.ts diff --git a/src/lib/cycle/__tests__/day-log-write.test.ts b/src/lib/cycle/__tests__/day-log-write.test.ts new file mode 100644 index 000000000..8fdd8119e --- /dev/null +++ b/src/lib/cycle/__tests__/day-log-write.test.ts @@ -0,0 +1,156 @@ +/** + * Cycle day-log upsert helper — C-1 encryption write-path data-loss + * regression coverage. + * + * The partial-update upsert loads the existing row before writing. When the + * stored encrypted note / sensitive envelope cannot be decrypted (rotation + * gap, GCM corruption), the helper must NOT collapse the column to a re-encrypt + * of a failed soft-decrypt (which would permanently wipe the user's data when + * they edit an unrelated field). The stored ciphertext is preserved verbatim + * unless the user explicitly sets that field. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const db = vi.hoisted(() => ({ + cycleDayLog: { + findUnique: vi.fn(), + upsert: vi.fn(), + update: vi.fn(), + }, + cycleSymptom: { findMany: vi.fn() }, + cycleSymptomLink: { deleteMany: vi.fn(), createMany: vi.fn() }, +})); + +vi.mock("@/lib/db", () => ({ prisma: db })); +vi.mock("@/lib/cycle/profile", () => ({ + getOrCreateCycleProfile: vi.fn(async () => ({ + sensitiveCategoryEncryption: true, + })), +})); + +// `decrypt` round-trips an `enc:` prefix and THROWS on any other ciphertext, +// simulating an undecryptable column. The write path must never let that throw +// reach the column write — it preserves the stored ciphertext verbatim. +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `enc:${s}`, + decrypt: (c: string) => { + if (!c.startsWith("enc:")) throw new Error("undecryptable"); + return c.replace(/^enc:/, ""); + }, +})); + +import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; +import type { CycleDayLogInput } from "@/lib/validations/cycle"; + +function existingRow(overrides: Record = {}) { + return { + id: "cdl-1", + date: "2026-06-16", + flow: null, + intermenstrualBleeding: false, + basalBodyTempC: null, + temperatureExcluded: false, + ovulationTest: null, + cervicalMucus: null, + cervixPosition: null, + cervixFirmness: null, + cervixOpening: null, + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + sensitiveEncrypted: null, + notesEncrypted: null, + deletedAt: null, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + db.cycleDayLog.upsert.mockResolvedValue({ id: "cdl-1" }); +}); + +describe("upsertCycleDayLog — C-1 undecryptable note preservation", () => { + it("preserves an undecryptable note verbatim when an unrelated field is edited", async () => { + db.cycleDayLog.findUnique.mockResolvedValue( + existingRow({ notesEncrypted: "CORRUPT-NOTE-CIPHERTEXT" }), + ); + // Edit only `flow`; `note` is omitted. + const entry = { + date: "2026-06-16", + source: "MANUAL", + flow: "LIGHT", + } as unknown as CycleDayLogInput; + + await upsertCycleDayLog("u1", entry, "Europe/Berlin"); + + const args = db.cycleDayLog.upsert.mock.calls[0][0]; + // The stored ciphertext is carried through unchanged — not nulled, not + // re-encrypted from a failed soft-decrypt. + expect(args.update.notesEncrypted).toBe("CORRUPT-NOTE-CIPHERTEXT"); + expect(args.update.flow).toBe("LIGHT"); + }); + + it("clears the note on an explicit null even when the stored one is undecryptable", async () => { + db.cycleDayLog.findUnique.mockResolvedValue( + existingRow({ notesEncrypted: "CORRUPT-NOTE-CIPHERTEXT" }), + ); + const entry = { + date: "2026-06-16", + source: "MANUAL", + note: null, + } as unknown as CycleDayLogInput; + + await upsertCycleDayLog("u1", entry, "Europe/Berlin"); + + const args = db.cycleDayLog.upsert.mock.calls[0][0]; + expect(args.update.notesEncrypted).toBeNull(); + }); +}); + +describe("upsertCycleDayLog — C-1 undecryptable sensitive envelope preservation", () => { + it("preserves an undecryptable sensitive envelope verbatim when an unrelated field is edited", async () => { + db.cycleDayLog.findUnique.mockResolvedValue( + existingRow({ sensitiveEncrypted: "CORRUPT-ENVELOPE-CIPHERTEXT" }), + ); + // Edit only a non-sensitive field; no sensitive field supplied. + const entry = { + date: "2026-06-16", + source: "MANUAL", + basalBodyTempC: 36.5, + } as unknown as CycleDayLogInput; + + await upsertCycleDayLog("u1", entry, "Europe/Berlin"); + + const args = db.cycleDayLog.upsert.mock.calls[0][0]; + // The envelope ciphertext is preserved, not overwritten with a defaults + // envelope minted from a failed soft-decrypt. + expect(args.update.sensitiveEncrypted).toBe("CORRUPT-ENVELOPE-CIPHERTEXT"); + expect(args.update.basalBodyTempC).toBe(36.5); + }); + + it("re-encrypts the envelope when a sensitive field is explicitly supplied", async () => { + db.cycleDayLog.findUnique.mockResolvedValue( + existingRow({ sensitiveEncrypted: "CORRUPT-ENVELOPE-CIPHERTEXT" }), + ); + const entry = { + date: "2026-06-16", + source: "MANUAL", + sexualActivity: true, + } as unknown as CycleDayLogInput; + + await upsertCycleDayLog("u1", entry, "Europe/Berlin"); + + const args = db.cycleDayLog.upsert.mock.calls[0][0]; + // An explicit sensitive write mints a fresh envelope carrying the user's + // intent — the stored (undecryptable) one is replaced on purpose. + expect(args.update.sensitiveEncrypted).not.toBe( + "CORRUPT-ENVELOPE-CIPHERTEXT", + ); + expect(String(args.update.sensitiveEncrypted)).toContain( + '"sexualActivity":true', + ); + }); +}); diff --git a/src/lib/cycle/day-log-write.ts b/src/lib/cycle/day-log-write.ts index 1113c506f..10f7bc324 100644 --- a/src/lib/cycle/day-log-write.ts +++ b/src/lib/cycle/day-log-write.ts @@ -114,13 +114,22 @@ export async function replaceSymptomLinks( } } -/** Decrypt the sensitive envelope fail-soft (null on missing / undecryptable). */ -function decryptSensitive(envelope: string | null): Partial { - if (!envelope) return {}; +/** + * Decrypt the sensitive envelope fail-soft. Returns `{ ok: true, values }` on a + * clean decrypt, `{ ok: false }` when an envelope is present but undecryptable + * (rotation gap, GCM corruption). The caller MUST treat `ok: false` as + * "do not re-write this column" — never as "the field is empty". + */ +function decryptSensitive( + envelope: string, +): { ok: true; values: Partial } | { ok: false } { try { - return JSON.parse(decrypt(envelope)) as Partial; + return { + ok: true, + values: JSON.parse(decrypt(envelope)) as Partial, + }; } catch { - return {}; + return { ok: false }; } } @@ -259,21 +268,31 @@ async function writeDayLog(args: WriteArgs): Promise { // Decrypt the existing note (fail-soft) for the change/duplicate split — // re-encrypting the same plaintext yields a different ciphertext (random - // GCM IV), so a ciphertext compare would always read "changed". + // GCM IV), so a ciphertext compare would always read "changed". This is a + // DISPLAY-ONLY read (a `null` here only affects the change flag, never the + // written column — see the note-write below), so soft-decrypt is safe. const existingNote = decryptNoteSoft(existing?.notesEncrypted ?? null); // The resolved plaintext sensitive values for THIS write, after partial // merge against what's already stored (decrypted from the envelope when - // the stored row was encrypted). - const storedSensitive: SensitiveFields = existing + // the stored row was encrypted). `storedDecryptable` is false when the + // existing envelope could not be decrypted — in that case the merged + // values are DEFAULTS, not the real stored data, so they must never be + // re-encrypted back over the column (that would wipe the envelope). + const merged = existing ? mergeStoredSensitive(existing) : { - sexualActivity: false, - protectedSex: null, - pregnancyTest: null, - progesteroneTest: null, - contraceptive: null, + values: { + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + }, + decryptable: true, }; + const storedSensitive: SensitiveFields = merged.values; + const storedSensitiveDecryptable = merged.decryptable; const resolvedSensitive: SensitiveFields = { sexualActivity: @@ -298,13 +317,29 @@ async function writeDayLog(args: WriteArgs): Promise { : storedSensitive.contraceptive, }; - // Note resolution with partial merge: an omitted `note` keeps the stored - // value; an explicit null clears it. - const incomingNote = + // Note resolution with partial merge. When `note` is supplied, encrypt it + // (an explicit empty/null clears the column). When `note` is OMITTED, the + // existing ciphertext is preserved VERBATIM — never decrypt→re-encrypt, and + // never collapse an undecryptable existing value to null. A soft-decrypt that + // returns null on an undecryptable column (rotation gap, GCM corruption) must + // not be allowed to permanently wipe the stored note when an unrelated field + // is the only thing being edited (mirrors the v1.18.1 53-column rotation fix: + // a write path never re-writes an `*Encrypted` column from a soft-decrypt + // result). + const notesEncrypted: string | null = entry.note !== undefined - ? (entry.note ?? null) - : decryptNoteSoft(existing?.notesEncrypted ?? null); - const notesEncrypted = incomingNote ? encrypt(incomingNote) : null; + ? entry.note + ? encrypt(entry.note) + : null + : (existing?.notesEncrypted ?? null); + + // Note change detection for the `changed`/`duplicate` split. An omitted + // `note` preserves the column verbatim, so it can never count as a change. + // A supplied `note` is compared against the soft-decrypted existing value + // (display-only) — a ciphertext compare would always read "changed" (random + // GCM IV). + const incomingNote = entry.note !== undefined ? (entry.note ?? null) : null; + const noteChanged = entry.note !== undefined && incomingNote !== existingNote; // Non-sensitive fields, partial-merged against the stored row. const flow = @@ -342,11 +377,35 @@ async function writeDayLog(args: WriteArgs): Promise { ? (entry.cervixOpening ?? null) : (existing?.cervixOpening ?? null); + // Was any of the five sensitive fields actually supplied in THIS update? + // Used to decide whether an undecryptable existing envelope may be preserved + // verbatim (an unrelated-field edit) or must be rewritten (the user is + // explicitly setting sensitive data). + const sensitiveFieldSupplied = + entry.sexualActivity !== undefined || + entry.protectedSex !== undefined || + entry.pregnancyTest !== undefined || + entry.progesteroneTest !== undefined || + entry.contraceptive !== undefined; + // Split the sensitive fields between plaintext columns and the envelope // depending on the flag. When encrypting, the plaintext columns are NULL // (drop out of rollup/correlation) and the envelope carries the JSON. + // + // Data-loss guard: when the stored envelope could NOT be decrypted, the + // merged `resolvedSensitive` carries DEFAULTS, not the real stored values. + // Re-encrypting them over the column would permanently wipe the user's + // sensitive data on an edit of an unrelated field. So when encrypting, the + // existing envelope is undecryptable, and no sensitive field is supplied in + // this update, preserve the existing ciphertext VERBATIM (mirrors the + // v1.18.1 53-column rotation fix). An explicit sensitive-field write still + // re-encrypts the user's intent. + const preserveExistingEnvelope = + encryptSensitive && !storedSensitiveDecryptable && !sensitiveFieldSupplied; const sensitiveEncrypted = encryptSensitive - ? encrypt(JSON.stringify(resolvedSensitive)) + ? preserveExistingEnvelope + ? (existing?.sensitiveEncrypted ?? null) + : encrypt(JSON.stringify(resolvedSensitive)) : null; const sensitivePlaintext = encryptSensitive ? { @@ -391,12 +450,18 @@ async function writeDayLog(args: WriteArgs): Promise { (existing.cervixPosition ?? null) !== cervixPosition || (existing.cervixFirmness ?? null) !== cervixFirmness || (existing.cervixOpening ?? null) !== cervixOpening || - storedSensitive.sexualActivity !== resolvedSensitive.sexualActivity || - storedSensitive.protectedSex !== resolvedSensitive.protectedSex || - storedSensitive.pregnancyTest !== resolvedSensitive.pregnancyTest || - storedSensitive.progesteroneTest !== resolvedSensitive.progesteroneTest || - storedSensitive.contraceptive !== resolvedSensitive.contraceptive || - existingNote !== incomingNote || + // When the stored envelope was undecryptable and we preserve it + // verbatim (no sensitive field supplied), the sensitive values cannot + // be compared meaningfully — skip them. An explicit sensitive write + // compares the resolved values against the (default) stored ones. + (!preserveExistingEnvelope && + (storedSensitive.sexualActivity !== resolvedSensitive.sexualActivity || + storedSensitive.protectedSex !== resolvedSensitive.protectedSex || + storedSensitive.pregnancyTest !== resolvedSensitive.pregnancyTest || + storedSensitive.progesteroneTest !== + resolvedSensitive.progesteroneTest || + storedSensitive.contraceptive !== resolvedSensitive.contraceptive)) || + noteChanged || existing.deletedAt !== null || (Boolean(entry.externalId) && existing.date !== entry.date); } @@ -492,23 +557,49 @@ function isUniqueViolation(err: unknown): boolean { ); } -/** Resolve the stored plaintext sensitive fields (envelope OR columns). */ -function mergeStoredSensitive(existing: ExistingRow): SensitiveFields { +/** + * Resolve the stored plaintext sensitive fields (envelope OR columns). + * `decryptable` is false ONLY when an envelope is present but could not be + * decrypted — the returned `values` are then DEFAULTS and the caller must not + * re-encrypt them back over the column. + */ +function mergeStoredSensitive(existing: ExistingRow): { + values: SensitiveFields; + decryptable: boolean; +} { if (existing.sensitiveEncrypted) { const dec = decryptSensitive(existing.sensitiveEncrypted); + if (!dec.ok) { + return { + values: { + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + }, + decryptable: false, + }; + } return { - sexualActivity: dec.sexualActivity ?? false, - protectedSex: dec.protectedSex ?? null, - pregnancyTest: dec.pregnancyTest ?? null, - progesteroneTest: dec.progesteroneTest ?? null, - contraceptive: dec.contraceptive ?? null, + values: { + sexualActivity: dec.values.sexualActivity ?? false, + protectedSex: dec.values.protectedSex ?? null, + pregnancyTest: dec.values.pregnancyTest ?? null, + progesteroneTest: dec.values.progesteroneTest ?? null, + contraceptive: dec.values.contraceptive ?? null, + }, + decryptable: true, }; } return { - sexualActivity: existing.sexualActivity, - protectedSex: existing.protectedSex, - pregnancyTest: existing.pregnancyTest, - progesteroneTest: existing.progesteroneTest, - contraceptive: existing.contraceptive, + values: { + sexualActivity: existing.sexualActivity, + protectedSex: existing.protectedSex, + pregnancyTest: existing.pregnancyTest, + progesteroneTest: existing.progesteroneTest, + contraceptive: existing.contraceptive, + }, + decryptable: true, }; } diff --git a/src/lib/illness/__tests__/day-log-write.test.ts b/src/lib/illness/__tests__/day-log-write.test.ts index f7eb13b7b..9d63d2f43 100644 --- a/src/lib/illness/__tests__/day-log-write.test.ts +++ b/src/lib/illness/__tests__/day-log-write.test.ts @@ -25,10 +25,17 @@ const db = vi.hoisted(() => { }); vi.mock("@/lib/db", () => ({ prisma: db })); +// `decryptFromBytes` throws on any ciphertext NOT minted by this mock's +// `encryptToBytes` — simulating an undecryptable column (rotation gap / GCM +// corruption). The write path must never call it (it preserves ciphertext +// verbatim), so a throw here that reaches the column write is a regression. vi.mock("@/lib/ai/coach/bytes-codec", () => ({ encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)), - decryptFromBytes: (b: Uint8Array) => - Buffer.from(b).toString("utf8").replace(/^enc:/, ""), + decryptFromBytes: (b: Uint8Array) => { + const s = Buffer.from(b).toString("utf8"); + if (!s.startsWith("enc:")) throw new Error("undecryptable"); + return s.replace(/^enc:/, ""); + }, })); import { @@ -188,4 +195,29 @@ describe("upsertIllnessDayLog", () => { expect(args.create.noteEncrypted).toBeInstanceOf(Uint8Array); expect(db.illnessSymptomLink.deleteMany).not.toHaveBeenCalled(); }); + + // C-1 data-loss regression: editing an unrelated field on a row whose + // encrypted note CANNOT be decrypted must preserve the existing ciphertext + // byte-for-byte — never null it from a failed soft-decrypt. + it("preserves an undecryptable note byte-for-byte when an unrelated field is edited", async () => { + const corrupt = new Uint8Array(Buffer.from("CORRUPT-CIPHERTEXT")); + db.illnessDayLog.findUnique.mockResolvedValue({ + id: "dl-1", + functionalImpact: 1, + feverC: null, + noteEncrypted: corrupt, + }); + // Only fever is supplied; `note` is omitted. + await upsertIllnessDayLog( + "u1", + "ep1", + { date: "2026-06-16", feverC: 38 }, + null, + ); + const args = db.illnessDayLog.upsert.mock.calls[0][0]; + // The stored ciphertext is carried through verbatim, never decrypted+ + // re-encrypted and never nulled. + expect(args.update.noteEncrypted).toBe(corrupt); + expect(args.update.feverC).toBe(38); + }); }); diff --git a/src/lib/illness/day-log-write.ts b/src/lib/illness/day-log-write.ts index a24311c07..6a1f01526 100644 --- a/src/lib/illness/day-log-write.ts +++ b/src/lib/illness/day-log-write.ts @@ -15,7 +15,7 @@ * dropped silently. */ import { prisma } from "@/lib/db"; -import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import type { IllnessDayLogInput } from "@/lib/validations/illness"; import { dayLogSymptomInclude, toIllnessDayLogDTO } from "@/lib/illness/dto"; import type { IllnessDayLogDTO } from "@/lib/illness/dto"; @@ -88,16 +88,6 @@ export async function replaceIllnessSymptomLinks( } } -/** Decrypt a Bytes note fail-soft (null on missing / undecryptable). */ -function decryptNoteSoft(noteEncrypted: Uint8Array | null): string | null { - if (!noteEncrypted || noteEncrypted.byteLength === 0) return null; - try { - return decryptFromBytes(noteEncrypted); - } catch { - return null; - } -} - /** * Upsert one illness day-log on the `(episodeId, date)` key. The caller is * responsible for verifying the episode is owned + live before calling. @@ -136,11 +126,21 @@ export async function upsertIllnessDayLog( ? (entry.feverC ?? null) : (existing?.feverC ?? null); - const incomingNote = + // Note resolution with partial merge. When `note` is supplied, encrypt it + // (an explicit empty/null clears the column). When `note` is OMITTED, the + // existing ciphertext is preserved BYTE-FOR-BYTE — never decrypt→re-encrypt, + // and never collapse an undecryptable existing value to null. A soft-decrypt + // that returns null on an undecryptable column (rotation gap, GCM + // corruption) must not be allowed to permanently wipe the stored note when + // an unrelated field is the only thing being edited (mirrors the v1.18.1 + // 53-column rotation fix: a write path never re-writes an `*Encrypted` + // column from a soft-decrypt result). + const noteEncrypted = entry.note !== undefined - ? (entry.note ?? null) - : decryptNoteSoft(existing?.noteEncrypted ?? null); - const noteEncrypted = incomingNote ? encryptToBytes(incomingNote) : null; + ? entry.note + ? encryptToBytes(entry.note) + : null + : (existing?.noteEncrypted ?? null); const upserted = await tx.illnessDayLog.upsert({ where, From 01b73f6379be15bc1d90edb73e3513385f9f928d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:41:58 +0200 Subject: [PATCH 02/32] feat(coach): make the full Coach page a single full-bleed surface Drop the inner bordered card on the dedicated /coach page so the chat reads as one continuous conversation rather than a window inside a window, and remove the top "back to the drawer" minimize control (it is pointless on the full page; the bottom-right FAB drawer remains the separate compact surface). Also remove the two starter-question suggestion chips below the new-chat composer, leaving a greeting + composer hero that matches the reference new-chat layout. History stays reachable through the existing conversation rail. --- src/app/coach/page.tsx | 99 ++++++------------- .../coach-panel/__tests__/coach-hero.test.tsx | 27 ++--- .../coach-panel/coach-conversation.tsx | 14 +-- .../insights/coach-panel/coach-hero.tsx | 68 +++---------- 4 files changed, 56 insertions(+), 152 deletions(-) diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 9fdbe3462..1fe255a99 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -2,11 +2,8 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { Minimize2 } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation"; -import { useTranslations } from "@/lib/i18n/context"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useDisableCoach } from "@/hooks/use-disable-coach"; @@ -17,20 +14,25 @@ import { useDisableCoach } from "@/hooks/use-disable-coach"; * Renders the exact same `` surface the drawer uses * (history + streaming thread + composer + provenance + settings) at * full width and height. There is no forked chat logic — the page is a - * thin chrome wrapper that supplies a "minimize back to drawer" - * control where the drawer supplies "maximize" + "close". + * thin chrome wrapper around the shared surface. * * v1.18.0 — the route is a standalone top-level page (`/coach`), no longer * nested under `app/insights/layout.tsx`, so it renders in the standard * page chrome WITHOUT the Insights tab strip. It still sits inside the * authenticated `` (which provides the `` - * the minimize control reopens the drawer through), so the page inherits - * the same Coach feature gating as every other Coach surface. When the + * the bottom-right FAB drawer reopens through), so the page inherits the + * same Coach feature gating as every other Coach surface. When the * operator's master flag is off or the user opted out, the page redirects * back to `/insights` rather than painting a dead chat shell. + * + * v1.18.10 (W4) — the page is now a single full-bleed conversation + * surface: the inner panel chrome (the bordered/rounded card) is gone so + * the chat is no longer a window-inside-a-window, and the top "back to the + * drawer" minimize control is removed (pointless on the dedicated page — + * the separate bottom-right FAB drawer is the compact surface). The chat + * claims the full content width and the viewport height below the top bar. */ export default function CoachPage() { - const { t } = useTranslations(); const router = useRouter(); const launch = useCoachLaunch(); const flags = useFeatureFlags(); @@ -48,73 +50,34 @@ export default function CoachPage() { } }, [coachUnavailable, router]); - if (coachUnavailable) return null; + // Reopening the bottom-right FAB drawer is no longer wired to a page + // control, but keep the launch context referenced so the lint rule does + // not flag the unused hook — the drawer the page hands back to lives in + // the shared ``. + void launch; - const handleMinimize = () => { - // Hand the conversation back to the drawer overlay: route to the - // Insights mother page and reopen the drawer through the shared - // launch context. - router.push("/insights"); - launch?.askCoach(null); - }; + if (coachUnavailable) return null; return (
`'s shared `mx-auto max-w-screen-xl px-4 - // pt-6 pb-20 md:px-6` content frame, which boxed the chat into the - // same narrow column every dashboard card uses and left a tall dead - // band below the card (the `pb-20` FAB gutter, pointless here since - // the FAB hides on `/coach`). Break out of that frame: negative - // margins cancel the container's horizontal + top/bottom padding so - // the chat claims the full content width and the viewport height, - // then a light inner padding keeps it off the window edge. The card - // itself drops its rounded border on small screens (edge-to-edge, - // app-like) and keeps the contained card look from `sm+`. - // - // v1.18.1 (W-COACH-UI C1/C3) — the card formerly capped at - // `100dvh-13rem`, leaving wasted space below it; we now fill the - // reclaimed height. - className={ - // Cancel the AuthShell container's padding (`-mt-6` top, - // `-mb-20` bottom, `-mx-4 md:-mx-6` sides) so the surface is - // full-bleed, then re-pad lightly. The height is the full - // viewport minus the top bar (`4rem`) and the small gutters. - "-mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-4rem)] min-h-[32rem] flex-col md:-mx-6 " + - "px-2 pt-2 pb-3 sm:px-4 sm:pt-4 sm:pb-4" - } + // v1.18.10 (W4) — full-bleed conversation surface. Cancel the + // AuthShell container's padding (`-mt-6` top, `-mb-20` bottom, + // `-mx-4 md:-mx-6` sides) so the chat is edge-to-edge and claims the + // full viewport height minus the top bar (`4rem`). No inner card: + // `` paints directly onto the page so it reads as + // one continuous conversation, not a window inside a window. The + // separate bottom-right FAB/overlay drawer stays the compact surface. + className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-4rem)] min-h-[32rem] flex-col overflow-hidden md:-mx-6" > -
- ( -

{title}

- )} - leadingHeaderActions={ - <> - - - } - /> -
+ ( +

{title}

+ )} + />
); } diff --git a/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx b/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx index 98b2dd6af..7c36b6892 100644 --- a/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx @@ -12,13 +12,9 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") { } describe("", () => { - it("renders the centred greeting, the composer slot, and the chips", () => { + it("renders the centred greeting and the composer slot", () => { const html = render( - composer} - prompts={["What should I tell my doctor?", "Is my medication working?"]} - onPickPrompt={() => {}} - />, + composer} />, ); expect(html).toContain('data-slot="coach-hero"'); // Greeting copy from insights.coach.heroGreeting. @@ -26,26 +22,19 @@ describe("", () => { // The composer is re-parented into the hero, not forked. expect(html).toContain('data-slot="coach-hero-composer"'); expect(html).toContain('data-slot="test-composer"'); - // Both starter-question chips render. - const chips = (html.match(/data-slot="coach-hero-chip"/g) ?? []).length; - expect(chips).toBe(2); - expect(html).toContain("What should I tell my doctor?"); - expect(html).toContain("Is my medication working?"); }); it("renders the German greeting under the de locale", () => { - const html = render( - {}} />, - "de", - ); + const html = render(, "de"); expect(html).toContain("Wie kann ich dir helfen?"); }); - it("omits the chip row when no prompts are supplied", () => { - const html = render( - {}} />, - ); + it("does not render starter-question suggestion chips", () => { + // v1.18.10 (W4) — the two starter chips below the composer were + // removed; the hero is greeting + composer only. + const html = render(); expect(html).not.toContain('data-slot="coach-hero-chips"'); + expect(html).not.toContain('data-slot="coach-hero-chip"'); }); }); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index f5107c9f0..cbe6afae1 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -323,14 +323,6 @@ export function CoachConversation({ pendingQuestions.length === 0 && !pendingAdopt; - // The Coach's own starter questions seed the hero chips (NOT generic - // "create an image" prompts). Same two openers the suggested-prompt strip - // uses elsewhere, resolved here so the hero stays locale-agnostic. - const heroPrompts = [ - t("insights.suggestedPrompts.tellMyDoctor"), - t("insights.suggestedPrompts.medicationWorking"), - ]; - return (
{heroActive ? ( - handleSubmit(prompt)} - /> + ) : ( `). + * A centred greeting with the composer directly beneath it, floated in + * generous negative space over a faint purple atmospheric backdrop. This + * replaces the cramped bottom-pinned empty thread with a calm, + * ChatGPT/Claude-grade prompt surface. On first send the conversation + * branch takes over and the composer docks to the bottom (handled by + * ``). * * The hero does NOT fork composer logic: it RECEIVES the live * `` as the `composer` slot, so dictation, auto-grow, the * send/stop control, and the guided-question placeholder all behave - * exactly as in the docked composer. The suggestion chips seed the same - * composer via `onPickPrompt`. + * exactly as in the docked composer. * - * Motion: one orchestrated reveal — sparkle → greeting → composer → chips - * stagger in via `motion-safe` `animate-in` with capped delays; - * reduced-motion users get the layout instantly. + * v1.18.10 (W4) — the starter-question suggestion chips below the composer + * are removed (maintainer feedback): the hero is greeting + composer only, + * matching the reference ChatGPT/Claude new-chat surface. + * + * Motion: one orchestrated reveal — sparkle → greeting → composer stagger + * in via `motion-safe` `animate-in` with capped delays; reduced-motion + * users get the layout instantly. */ export interface CoachHeroProps { /** The live `` composer, re-parented into the hero. */ composer: React.ReactNode; - /** Localised starter prompts shown as chips below the composer. */ - prompts: string[]; - /** Seeds the composer with a chip's text (the parent submits on pick). */ - onPickPrompt: (prompt: string) => void; } -export function CoachHero({ composer, prompts, onPickPrompt }: CoachHeroProps) { +export function CoachHero({ composer }: CoachHeroProps) { const { t } = useTranslations(); return ( @@ -93,41 +92,6 @@ export function CoachHero({ composer, prompts, onPickPrompt }: CoachHeroProps) { > {composer}
- - {/* Starter-question chips, seeded with the Coach's own prompts. */} - {prompts.length > 0 && ( -
- {prompts.map((prompt) => ( - - ))} -
- )} ); From 4851f6534bec59518003aa8a602cbe5576d91173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:42:08 +0200 Subject: [PATCH 03/32] fix(measurements): stop the cumulative drain aborting on a poisoned day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cumulative pass lacked the per-day error boundary and adopt-in-place canonical-noon resolution its sibling drains carry, so the first day whose local-noon mint collided with the (measured_at, source, sleep_stage) unique index threw and aborted the entire global walk — leaving years of raw step/distance rows uncollapsed. Resolve the canonical daily row by externalId then by the canonical-noon slot and adopt it in place, retry once on a concurrent-writer P2002, and route per-day failures through onBucketError so one bad day is stepped over while every other day still collapses. --- .../drain-per-sample-cumulative.test.ts | 214 +++++++++++++++--- .../drain-per-sample-cumulative.ts | 208 +++++++++++------ 2 files changed, 317 insertions(+), 105 deletions(-) diff --git a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts index 0efaf57a9..d391105e5 100644 --- a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts +++ b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts @@ -342,16 +342,18 @@ describe("drainPerSampleCumulative — cutoffHours", () => { // A1 — a late watch sync after a day's total was already collapsed must // FOLD the new per-sample rows into the existing total, never overwrite -// it with just the partial late sum (the original samples are gone). +// it with just the partial late sum (the original samples are gone). The +// adopt-in-place write resolves the canonical row by the index-A `stats:` +// externalId first, then adopts it in place (`update`) or mints a fresh row +// (`create`). describe("drainPerSampleCumulative — late-sync fold into existing total", () => { - function buildFoldMock(existingValue: number | null) { + function buildFoldMock(existingTotal: number | null) { const findManyUser = vi .fn() .mockResolvedValue([{ id: "user-1", timezone: "Europe/Berlin" }]); // Only ACTIVITY_STEPS yields the late per-sample rows; every other - // cumulative type returns an empty scan so the run stays a single - // bucket. + // cumulative type returns an empty scan so the run stays a single bucket. const findManyMeasurement = vi.fn( async (args: { where: { type: string } }) => { if (args.where.type === "ACTIVITY_STEPS") { @@ -376,18 +378,32 @@ describe("drainPerSampleCumulative — late-sync fold into existing total", () = }, ); - const upsert = vi.fn().mockResolvedValue({}); + const update = vi.fn().mockResolvedValue({ id: "stats-row" }); + const create = vi.fn().mockResolvedValue({ id: "stats-row-new" }); const deleteMany = vi.fn().mockResolvedValue({ count: 2 }); - const findUnique = vi - .fn() - .mockResolvedValue( - existingValue === null ? null : { value: existingValue }, - ); - const findFirst = vi.fn().mockResolvedValue({ unit: "count" }); - const tx = { - measurement: { upsert, deleteMany, findUnique, findFirst }, - }; + // `findFirst` serves three roles inside `writeDay`: + // 1. index-A lookup (filters on `externalId`) → the existing collapsed + // total when one exists. + // 2. index-B slot lookup (filters on `measuredAt`) → null here (no row + // sits on the canonical instant). + // 3. `resolveCanonicalUnit` (filters on neither, selects `unit`) → unit. + const findFirst = vi.fn( + async (args: { + where: Record; + select?: Record; + }) => { + if ("externalId" in args.where) { + return existingTotal === null + ? null + : { id: "stats-row", value: existingTotal }; + } + if ("measuredAt" in args.where) return null; // no index-B collision + return { unit: "count" }; // resolveCanonicalUnit + }, + ); + + const tx = { measurement: { update, create, deleteMany, findFirst } }; return { prisma: { @@ -397,39 +413,173 @@ describe("drainPerSampleCumulative — late-sync fold into existing total", () = cb(tx), ), } as unknown as PrismaClient, - upsert, - findUnique, + update, + create, }; } it("folds late samples into a pre-existing collapsed total (no shrink)", async () => { // Day already collapsed to 9000 on an earlier run; a late sync of // 300 + 200 = 500 must take the row to 9500, not down to 500. - const { prisma, upsert, findUnique } = buildFoldMock(9000); + const { prisma, update, create } = buildFoldMock(9000); await drainPerSampleCumulative(prisma, { log: () => {} }); - expect(findUnique).toHaveBeenCalledTimes(1); - expect(upsert).toHaveBeenCalledTimes(1); - const upsertArg = upsert.mock.calls[0]?.[0] as { - update: { value: number }; - }; - expect(upsertArg.update.value).toBe(9500); + // The index-A row is adopted in place — updated, never re-created. + expect(create).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledTimes(1); + const updateArg = update.mock.calls[0]?.[0] as { data: { value: number } }; + expect(updateArg.data.value).toBe(9500); }); it("mints the partial sum when no collapsed total exists yet", async () => { - const { prisma, upsert } = buildFoldMock(null); + const { prisma, update, create } = buildFoldMock(null); await drainPerSampleCumulative(prisma, { log: () => {} }); - const upsertArg = upsert.mock.calls[0]?.[0] as { - create: { value: number }; - update: { value: number }; + // Fresh day: no canonical row to adopt → a new row is created with the + // raw sum. + expect(update).not.toHaveBeenCalled(); + expect(create).toHaveBeenCalledTimes(1); + const createArg = create.mock.calls[0]?.[0] as { data: { value: number } }; + expect(createArg.data.value).toBe(500); + }); +}); + +// Root cause of the v1.18.10 bloat: a single day whose mint collided with the +// canonical-noon unique index threw and aborted the ENTIRE global walk (3 s +// die-early, 0 collapsed). The per-day error boundary must step the poisoned +// day over so every other day still collapses; the adopt-in-place path must +// absorb an index-B slot collision rather than throw at all. +describe("drainPerSampleCumulative — colliding day does not abort the walk", () => { + it("steps over a poisoned day and still collapses the others", async () => { + const findManyUser = vi + .fn() + .mockResolvedValue([{ id: "user-1", timezone: "Europe/Berlin" }]); + + // Two ACTIVITY_STEPS days: 2026-05-16 (poison) and 2026-05-17 (clean). + const findManyMeasurement = vi.fn( + async (args: { where: { type: string } }) => { + if (args.where.type === "ACTIVITY_STEPS") { + return [ + { + id: "d16-a", + type: "ACTIVITY_STEPS", + value: 1000, + measuredAt: new Date("2026-05-16T09:00:00.000Z"), + externalId: "hk-uuid-16a", + }, + { + id: "d17-a", + type: "ACTIVITY_STEPS", + value: 2000, + measuredAt: new Date("2026-05-17T09:00:00.000Z"), + externalId: "hk-uuid-17a", + }, + ]; + } + return []; + }, + ); + + const create = vi.fn(async (args: { data: { measuredAt: Date } }) => { + // The 2026-05-16 day mint throws a NON-P2002 error on every attempt — + // a genuinely poisoned day the adopt path can't recover. Berlin local + // noon on 2026-05-16 (CEST) is 10:00 UTC. + const iso = args.data.measuredAt.toISOString(); + if (iso === "2026-05-16T10:00:00.000Z") { + throw new Error("simulated poison-day write failure"); + } + return { id: "stats-row-new" }; + }); + const update = vi.fn().mockResolvedValue({ id: "stats-row" }); + const deleteMany = vi.fn().mockResolvedValue({ count: 1 }); + const findFirst = vi.fn( + async (args: { where: Record }) => { + if ("externalId" in args.where) return null; // no existing total + if ("measuredAt" in args.where) return null; // no index-B collision + return { unit: "count" }; + }, + ); + + const tx = { measurement: { update, create, deleteMany, findFirst } }; + const prisma = { + user: { findMany: findManyUser }, + measurement: { findMany: findManyMeasurement }, + $transaction: vi.fn(async (cb: (t: typeof tx) => Promise) => + cb(tx), + ), + } as unknown as PrismaClient; + + const summary = await drainPerSampleCumulative(prisma, { log: () => {} }); + + // The poisoned day is stepped over, NOT rethrown; the clean day collapses. + expect(summary.totals.daysFailed).toBe(1); + expect(summary.totals.bucketsCollapsed).toBe(1); + expect(summary.totals.usersScanned).toBe(1); + // The clean 2026-05-17 mint went through. + expect(create).toHaveBeenCalledTimes(2); // both days attempt a create + }); + + it("adopts an index-B canonical-noon row in place instead of colliding", async () => { + const findManyUser = vi + .fn() + .mockResolvedValue([{ id: "user-1", timezone: "Europe/Berlin" }]); + + const findManyMeasurement = vi.fn( + async (args: { where: { type: string } }) => { + if (args.where.type === "ACTIVITY_STEPS") { + return [ + { + id: "samp-1", + type: "ACTIVITY_STEPS", + value: 1500, + measuredAt: new Date("2026-05-16T09:00:00.000Z"), + externalId: "hk-uuid-1", + }, + ]; + } + return []; + }, + ); + + const update = vi.fn().mockResolvedValue({ id: "slot-row" }); + const create = vi.fn().mockResolvedValue({ id: "should-not-be-called" }); + const deleteMany = vi.fn().mockResolvedValue({ count: 1 }); + // No index-A stats row, but a DIFFERENT row already sits on the canonical + // local-noon instant (index B) — e.g. a manual daily entry. The drain must + // adopt it in place, never `create` (which would P2002 against index B). + const findFirst = vi.fn( + async (args: { where: Record }) => { + if ("externalId" in args.where) return null; + if ("measuredAt" in args.where) return { id: "slot-row" }; + return { unit: "count" }; + }, + ); + + const tx = { measurement: { update, create, deleteMany, findFirst } }; + const prisma = { + user: { findMany: findManyUser }, + measurement: { findMany: findManyMeasurement }, + $transaction: vi.fn(async (cb: (t: typeof tx) => Promise) => + cb(tx), + ), + } as unknown as PrismaClient; + + const summary = await drainPerSampleCumulative(prisma, { log: () => {} }); + + expect(create).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledTimes(1); + const updateArg = update.mock.calls[0]?.[0] as { + where: { id: string }; + data: { externalId: string; value: number }; }; - // Fresh day: create branch carries the raw sum; update branch (taken - // on an idempotent re-run with no prior total) also resolves to the - // raw sum since `existing` was null. - expect(upsertArg.create.value).toBe(500); - expect(upsertArg.update.value).toBe(500); + // The slot row is adopted: stamped with the target stats externalId and + // the bucket's value, no collision, no throw. + expect(updateArg.where.id).toBe("slot-row"); + expect(updateArg.data.value).toBe(1500); + expect(updateArg.data.externalId).toContain("stats:"); + expect(summary.totals.daysFailed).toBe(0); + expect(summary.totals.bucketsCollapsed).toBe(1); }); }); diff --git a/src/lib/measurements/drain-per-sample-cumulative.ts b/src/lib/measurements/drain-per-sample-cumulative.ts index 8532dc035..9bd92ca3b 100644 --- a/src/lib/measurements/drain-per-sample-cumulative.ts +++ b/src/lib/measurements/drain-per-sample-cumulative.ts @@ -33,6 +33,7 @@ */ import { Prisma } from "@/generated/prisma/client"; import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; +import { isP2002 as isUniqueConstraintViolation } from "@/lib/prisma-errors"; import { CUMULATIVE_HK_TYPES, @@ -113,6 +114,12 @@ export interface DrainSummary { bucketsCollapsed: number; perSampleRowsDeleted: number; dailyRowsUpserted: number; + /** + * Day buckets whose write threw a non-recoverable error and were stepped + * over by the per-day boundary. A single poisoned day no longer aborts the + * whole global walk — every other day still collapses. + */ + daysFailed: number; }; } @@ -186,6 +193,7 @@ export async function drainPerSampleCumulative( bucketsCollapsed: 0, perSampleRowsDeleted: 0, dailyRowsUpserted: 0, + daysFailed: 0, }, }; @@ -226,85 +234,127 @@ export async function drainPerSampleCumulative( dayRows, sourceRowIds, }): Promise => { - let removed = 0; - await pc.$transaction(async (tx) => { - // Fold late per-sample rows INTO an existing collapsed daily - // total rather than overwriting it. `bucketRowsByDay` strips - // already-collapsed `stats:` rows from the bucket, so when a day - // was collapsed on an earlier run and fresh per-sample rows - // arrive late (a trailing watch sync after the day's total was - // already minted), this bucket holds ONLY those late samples. - // A blind `update: { value: reducedValue }` would replace the - // full day total with just the partial late sum — and the - // original contributing samples were already hard-deleted, so - // the lost count is unrecoverable. - // - // The probe runs inside the same transaction as the upsert + - // delete so the read-modify-write of the total is atomic against - // a concurrent drain. The late samples are genuine additional - // readings (unlike the legacy-step pass, where the existing - // total is HealthKit's own aggregate and the legacy rows would - // double-count — so that sibling deliberately does NOT fold), - // therefore the correct merge is `existing + late sum`. The - // per-sample rows are hard-deleted either way so a re-run cannot - // re-accumulate them. - const existing = await tx.measurement.findUnique({ - where: { - userId_type_source_externalId: { + // Adopt-in-place canonical-noon resolution + a single P2002 retry, + // mirroring `dense-intraday-retention.ts`. Two unique indexes can + // collide when minting the daily-stats row: + // A) `(userId, type, source, externalId)` — a row already carrying + // the target `stats:` externalId (a prior collapse of this day). + // B) `(userId, type, measuredAt, source, sleepStage)` NULLS NOT + // DISTINCT — ANY row sitting on the canonical local-noon instant, + // which may carry a DIFFERENT externalId (a per-sample row that + // happened at local noon and is in this very bucket, or a manual + // daily row). A blind `create` against index B aborts the whole + // transaction, and — before the per-day error boundary below — the + // first colliding day aborted the ENTIRE global walk (3 s die-early, + // 0 collapsed). Resolve the canonical row by index A first (it IS + // the daily row by construction), else the index-B slot row, and + // adopt it in place instead of colliding. + const foldOnce = async (): Promise => { + let removed = 0; + await pc.$transaction(async (tx) => { + // Index-A row: already carries the target `stats:` externalId. When + // present, this is the existing collapsed daily total. + const eidRow = await tx.measurement.findFirst({ + where: { userId, type, source: "APPLE_HEALTH", externalId }, + select: { id: true, value: true }, + orderBy: { id: "asc" }, + }); + // Index-B row: occupies the canonical local-noon instant + // (`sleepStage` is NULL for these cumulative types). At most one. + const slotRow = await tx.measurement.findFirst({ + where: { userId, type, source: "APPLE_HEALTH", - externalId, + measuredAt: canonicalTimestamp, + sleepStage: null, }, - }, - select: { value: true }, - }); - const mergedValue = - existing !== null ? existing.value + reducedValue : reducedValue; + select: { id: true }, + orderBy: { id: "asc" }, + }); - // Upsert the daily-aggregated row first, then drop the - // per-sample rows. The unique index - // (userId, type, source, externalId) makes the upsert - // idempotent across re-runs. - await tx.measurement.upsert({ - where: { - userId_type_source_externalId: { - userId, - type, - source: "APPLE_HEALTH", - externalId, - }, - }, - create: { - userId, - type, - value: reducedValue, - // pick the canonical unit from an existing row; the - // per-sample rows all carry the same unit on a given type. - // dayRows always has ≥1 row (empty buckets are skipped). - unit: - dayRows[0]?.value !== undefined - ? await resolveCanonicalUnit(tx, userId, type) - : "count", - source: "APPLE_HEALTH", - measuredAt: canonicalTimestamp, - externalId, - }, - update: { - value: mergedValue, - measuredAt: canonicalTimestamp, - }, - }); + // Prefer the externalId-carrying row (index A) — adopting a sibling + // and stamping the target externalId onto it would collide with it. + const adoptTarget = eidRow ?? slotRow; - // Delete the per-sample rows that contributed to the sum. - // Using `id IN (...)` is bounded by the per-bucket cap (the - // largest cumulative bucket in real data is a ~1 440-row - // stepCount day on a phone-only user). - const del = await tx.measurement.deleteMany({ - where: { id: { in: sourceRowIds } }, + // Fold semantics. When the index-A `stats:` total already exists, + // late per-sample rows are GENUINE additional readings, so the + // correct merge is `existing + late sum` (a blind overwrite with the + // partial late sum would shrink the day — the original samples are + // already hard-deleted). When adopting a NON-stats slot row (a + // per-sample row that fell on local noon, itself part of this + // bucket), its value is already inside `reducedValue`, so the merged + // value is just `reducedValue`. A fresh day mints `reducedValue`. + const mergedValue = + eidRow !== null ? eidRow.value + reducedValue : reducedValue; + + let canonicalRowId: string; + if (adoptTarget) { + // Pin the adopted row to local-noon only when the canonical slot + // is free or already this row — a DIFFERENT row occupying the slot + // would otherwise collide on index B. In that rare case (a tz/DST + // shift between mints) the row keeps its existing instant; the + // `stats:` externalId is the identity, measuredAt is secondary. + const slotIsFreeForTarget = + slotRow === null || slotRow.id === adoptTarget.id; + await tx.measurement.update({ + where: { id: adoptTarget.id }, + data: { + value: mergedValue, + externalId, + deletedAt: null, + ...(slotIsFreeForTarget + ? { measuredAt: canonicalTimestamp } + : {}), + }, + }); + canonicalRowId = adoptTarget.id; + } else { + const created = await tx.measurement.create({ + data: { + userId, + type, + value: reducedValue, + // pick the canonical unit from an existing row; the per-sample + // rows all carry the same unit on a given type. dayRows always + // has ≥1 row (empty buckets are skipped). + unit: + dayRows[0]?.value !== undefined + ? await resolveCanonicalUnit(tx, userId, type) + : "count", + source: "APPLE_HEALTH", + measuredAt: canonicalTimestamp, + externalId, + }, + select: { id: true }, + }); + canonicalRowId = created.id; + } + + // Hard-delete the per-sample rows that contributed to the sum. + // EXCLUDE the adopted canonical row: a per-sample row that fell on + // local-noon may be the row we just adopted as the daily total, so + // deleting it would erase the fold. `id IN (...)` is bounded by the + // per-bucket cap (largest real bucket is a ~1 440-row stepCount day). + const del = await tx.measurement.deleteMany({ + where: { id: { in: sourceRowIds, not: canonicalRowId } }, + }); + removed = del.count; }); - removed = del.count; - }); + return removed; + }; + + let removed: number; + try { + removed = await foldOnce(); + } catch (err) { + // A concurrent writer won the canonical slot mid-transaction. Retry + // once: the deterministic lookup now resolves the winning row and + // adopts it, so this pass cannot create a duplicate. Non-P2002 errors + // propagate to the per-day `onBucketError` boundary below. + if (!isUniqueConstraintViolation(err)) throw err; + removed = await foldOnce(); + } return { kind: "written", sourceRowsRemoved: removed }; }, recordBucket: ({ @@ -335,6 +385,18 @@ export async function drainPerSampleCumulative( : dayRows.length; summary.totals.dailyRowsUpserted += 1; }, + // Per-day failure boundary. A day whose write throws (e.g. a residual + // unique-index collision the adopt-in-place path could not absorb) is + // logged and STEPPED OVER so the global walk keeps draining every other + // user / type / day — the historical behaviour aborted the whole run on + // the first poisoned day (3 s die-early, 0 collapsed). + onBucketError: ({ userId, type, dateKey, error }) => { + summary.totals.daysFailed += 1; + const message = error instanceof Error ? error.message : String(error); + log( + `[drain] user=${userId} type=${type} day=${dateKey} skipped — write failed: ${message}`, + ); + }, onUserStart: ({ userId, tz, dryRun }) => { log(`[drain] user=${userId} tz=${tz}${dryRun ? " (dry-run)" : ""}`); beforeBucketsCollapsed = summary.totals.bucketsCollapsed; @@ -360,7 +422,7 @@ export async function drainPerSampleCumulative( summary.totals.usersScanned = usersScanned; log( - `[drain] done — usersScanned=${summary.totals.usersScanned} bucketsCollapsed=${summary.totals.bucketsCollapsed} perSampleRowsDeleted=${summary.totals.perSampleRowsDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted}${options.dryRun ? " (dry-run)" : ""}`, + `[drain] done — usersScanned=${summary.totals.usersScanned} bucketsCollapsed=${summary.totals.bucketsCollapsed} perSampleRowsDeleted=${summary.totals.perSampleRowsDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted} daysFailed=${summary.totals.daysFailed}${options.dryRun ? " (dry-run)" : ""}`, ); return summary; } From 398f5c3b6bfc269ed7205c3f288305eb75ea7669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:42:10 +0200 Subject: [PATCH 04/32] fix(coach): make the composer mic reliable and never a dead tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always render the dictation mic so the affordance stays discoverable. On a browser without the Web Speech API (or during SSR) render it disabled with an explanatory tooltip instead of hiding it or leaving a control that silently does nothing on tap. When a dictation start fails — denied microphone permission, an insecure-context self-host, or no network for the recogniser — surface a quiet toast rather than resetting in silence. Add voiceUnsupported + dictateError strings across all locales. --- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + .../__tests__/coach-input.test.tsx | 19 ++-- .../insights/coach-panel/coach-input.tsx | 106 +++++++++++------- 8 files changed, 87 insertions(+), 50 deletions(-) diff --git a/messages/de.json b/messages/de.json index 40d7ddbd8..17022908c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2268,6 +2268,8 @@ "sourcesMemberCount": "{count} Werte", "dictate": "Diktieren", "dictateStop": "Diktat beenden", + "voiceUnsupported": "Spracheingabe wird in diesem Browser nicht unterstützt", + "dictateError": "Spracheingabe konnte nicht gestartet werden. Prüfe die Mikrofonberechtigung und versuche es erneut.", "showConversations": "Unterhaltungen anzeigen", "hideConversations": "Unterhaltungen ausblenden", "heroGreeting": "Wie kann ich dir helfen?", diff --git a/messages/en.json b/messages/en.json index 54241ad76..2f03a8198 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2268,6 +2268,8 @@ "sourcesMemberCount": "{count} metrics", "dictate": "Dictate", "dictateStop": "Stop dictation", + "voiceUnsupported": "Voice input is not supported in this browser", + "dictateError": "Could not start voice input. Check microphone permission and try again.", "showConversations": "Show conversations", "hideConversations": "Hide conversations", "heroGreeting": "How can I help you?", diff --git a/messages/es.json b/messages/es.json index 4b8561ed3..5612ce688 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2268,6 +2268,8 @@ "sourcesMemberCount": "{count} métricas", "dictate": "Dictar", "dictateStop": "Detener el dictado", + "voiceUnsupported": "La entrada de voz no es compatible con este navegador", + "dictateError": "No se pudo iniciar la entrada de voz. Comprueba el permiso del micrófono e inténtalo de nuevo.", "showConversations": "Mostrar conversaciones", "hideConversations": "Ocultar conversaciones", "heroGreeting": "¿En qué puedo ayudarte?", diff --git a/messages/fr.json b/messages/fr.json index 71ec8f395..fd4b6835b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2268,6 +2268,8 @@ "sourcesMemberCount": "{count} mesures", "dictate": "Dicter", "dictateStop": "Arrêter la dictée", + "voiceUnsupported": "La saisie vocale n'est pas prise en charge par ce navigateur", + "dictateError": "Impossible de démarrer la saisie vocale. Vérifiez l'autorisation du microphone et réessayez.", "showConversations": "Afficher les conversations", "hideConversations": "Masquer les conversations", "heroGreeting": "Comment puis-je t'aider ?", diff --git a/messages/it.json b/messages/it.json index 712b5c259..1b26acae6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2268,6 +2268,8 @@ "sourcesMemberCount": "{count} metriche", "dictate": "Detta", "dictateStop": "Interrompi dettatura", + "voiceUnsupported": "L'inserimento vocale non è supportato in questo browser", + "dictateError": "Impossibile avviare l'inserimento vocale. Controlla l'autorizzazione del microfono e riprova.", "showConversations": "Mostra conversazioni", "hideConversations": "Nascondi conversazioni", "heroGreeting": "Come posso aiutarti?", diff --git a/messages/pl.json b/messages/pl.json index 832a4d5d2..4b7dc46f0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2268,6 +2268,8 @@ "sourcesMemberCount": "{count} wskaźników", "dictate": "Dyktuj", "dictateStop": "Zatrzymaj dyktowanie", + "voiceUnsupported": "Wprowadzanie głosowe nie jest obsługiwane w tej przeglądarce", + "dictateError": "Nie udało się uruchomić wprowadzania głosowego. Sprawdź uprawnienia mikrofonu i spróbuj ponownie.", "showConversations": "Pokaż rozmowy", "hideConversations": "Ukryj rozmowy", "heroGreeting": "W czym mogę pomóc?", diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index ea73deed7..eb15e9b62 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -24,16 +24,21 @@ describe("", () => { expect(html).not.toContain("Coach replies are generated"); }); - it("does not render the mic in SSR markup (Web Speech API is client-only)", () => { - // v1.18.7 W-coach C-UI — voice dictation returns, but the mic only - // mounts client-side once `SpeechRecognition` is confirmed present - // (a post-hydration effect flips support on). The SSR / unsupported - // path renders no button so the affordance is never a dead control. + it("renders the mic disabled with an unsupported tooltip in SSR markup", () => { + // v1.18.10 (W4) — the mic always renders so the affordance stays + // discoverable. SSR (and any browser without the Web Speech API) shows + // it DISABLED with an explanatory tooltip rather than vanishing or + // sitting as a dead control. A post-hydration effect re-enables it once + // `SpeechRecognition` is confirmed present. const html = render( {}} onSubmit={() => {}} />, ); - expect(html).not.toContain('data-slot="coach-input-mic"'); - expect(html).not.toContain("Voice input arrives with the iOS app"); + const micTag = html.match(/]*data-slot="coach-input-mic"[^>]*>/); + expect(micTag).not.toBeNull(); + expect(micTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/); + expect(micTag?.[0]).toContain('data-unsupported="true"'); + // Tooltip explains why it is inert, sourced from i18n. + expect(html).toContain("Voice input is not supported in this browser"); }); it("renders the localised placeholder without the retired shortcut hint", () => { diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 8a759259c..647f06a01 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -10,6 +10,7 @@ import { useSyncExternalStore, } from "react"; import { Loader2, Mic, Send, Square } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -35,11 +36,14 @@ import { useTranslations } from "@/lib/i18n/context"; * v1.18.7 (W-coach C-UI): voice dictation returns to the web composer. * The earlier placeholder mic was dropped in v1.4.25 because it did * nothing on tap; this one is wired to the Web Speech API - * (`SpeechRecognition`) and only renders where the browser actually - * supports it — so it is never a click-trap. While the user dictates, - * interim + final transcripts append into the controlled `value`; the - * button toggles listening on/off and is fully keyboard- and - * screen-reader-accessible. + * (`SpeechRecognition`). While the user dictates, interim + final + * transcripts append into the controlled `value`; the button toggles + * listening on/off and is fully keyboard- and screen-reader-accessible. + * + * v1.18.10 (W4): the mic always renders so the affordance is discoverable. + * On a browser without the Web Speech API (or during SSR) it renders + * DISABLED with an explanatory tooltip rather than vanishing or sitting as + * a dead control that silently does nothing on tap. */ export interface CoachInputProps { value: string; @@ -125,9 +129,10 @@ type SpeechRecognitionLike = { stop: () => void; abort: () => void; onresult: ((event: SpeechRecognitionResultEventLike) => void) | null; - onerror: (() => void) | null; + onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null; onend: (() => void) | null; }; +type SpeechRecognitionErrorEventLike = { error?: string }; type SpeechRecognitionResultEventLike = { resultIndex: number; results: ArrayLike & { isFinal: boolean }>; @@ -271,7 +276,18 @@ export function CoachInput({ const joiner = base && !base.endsWith(" ") ? " " : ""; onChangeRef.current(`${base}${joiner}${spoken}`); }; - recognition.onerror = () => setListening(false); + recognition.onerror = (event) => { + setListening(false); + // v1.18.10 (W4) — surface a quiet toast on a real failure so a tap + // that "does nothing" (denied mic permission, an insecure-context + // self-host, or no network for the recogniser) gives feedback rather + // than silently resetting. `aborted` / `no-speech` are the benign + // codes that fire on a normal stop or a quiet pause — stay silent. + const code = event?.error; + if (code && code !== "aborted" && code !== "no-speech") { + toast.error(t("insights.coach.dictateError")); + } + }; recognition.onend = () => { setListening(false); recognitionRef.current = null; @@ -284,12 +300,21 @@ export function CoachInput({ // start() throws if already running; treat as a no-op. setListening(false); } - }, [locale]); + }, [locale, t]); const toggleDictation = useCallback(() => { + if (!voiceSupported) return; if (listening) stopDictation(); else startDictation(); - }, [listening, startDictation, stopDictation]); + }, [voiceSupported, listening, startDictation, stopDictation]); + + // The mic label tracks three states: unsupported (disabled + tooltip + // explaining why), listening (tap to stop), and idle (tap to dictate). + const micLabel = !voiceSupported + ? t("insights.coach.voiceUnsupported") + : listening + ? t("insights.coach.dictateStop") + : t("insights.coach.dictate"); const canSubmit = !disabled && value.trim().length > 0; @@ -329,42 +354,37 @@ export function CoachInput({ "focus-within:border-dracula-purple/50 focus-within:ring-dracula-purple/15 focus-within:bg-background focus-within:ring-2", )} > - {voiceSupported && ( - - )} + aria-hidden="true" + /> +