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"
>
-
);
}
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(/
- {/* v1.18.9 — "Scan a report" Lab-OCR entry. Shown only when the
- capability probe reports a vision-capable provider is configured,
- so the surface stays dark for codex-only / text-only-model users. */}
- {ocrCapability.data?.available ? (
- setScanOpen(true)}
- className="min-h-11 sm:min-h-9"
- >
-
- {t("labs.ocr.scanButton")}
-
- ) : null}
{/* v1.18.6 (MOD-01) — the wrench is the module's customize entry
point, left of the primary Add and linking to the Labs settings
page (view, sort order, biomarker CRUD + reorder). Mirrors the
@@ -104,13 +97,40 @@ export default function LabsPage() {
- setDialogOpen(true)}
- className="min-h-11 sm:min-h-9"
- >
-
- {t("common.add")}
-
+ {/* v1.18.10 — the "Add" action is a CHOICE when scanning is
+ available: scan a document (OCR) or add a value by hand. The scan
+ option only appears when the capability probe reports a usable
+ mode (vision, or local OCR opted-in for text-only providers), so
+ the surface stays simple for everyone else. When scanning is
+ unavailable, Add opens the manual form directly. */}
+ {ocrCapability.data?.available ? (
+
+
+
+
+ {t("common.add")}
+
+
+
+ setScanOpen(true)}>
+
+ {t("labs.ocr.addScan")}
+
+ setDialogOpen(true)}>
+
+ {t("labs.ocr.addManual")}
+
+
+
+ ) : (
+ setDialogOpen(true)}
+ className="min-h-11 sm:min-h-9"
+ >
+
+ {t("common.add")}
+
+ )}
@@ -135,10 +155,11 @@ export default function LabsPage() {
/>
- {ocrCapability.data?.available ? (
+ {ocrCapability.data?.available && ocrCapability.data.mode ? (
{
queryClient.invalidateQueries({
diff --git a/src/components/labs/labs-settings.tsx b/src/components/labs/labs-settings.tsx
index 104bed28d..8c83743e7 100644
--- a/src/components/labs/labs-settings.tsx
+++ b/src/components/labs/labs-settings.tsx
@@ -19,6 +19,7 @@ import {
FlaskConical,
LayoutGrid,
ListOrdered,
+ ScanText,
} from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -26,6 +27,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NativeSelect } from "@/components/ui/native-select";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
+import { Switch } from "@/components/ui/switch";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -42,11 +44,18 @@ import {
import { BiomarkerManager } from "./biomarker-manager";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
+import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract";
export function LabsSettings() {
const { t } = useTranslations();
const { prefs, setView, setOrder, setSortDir } = useModuleListPrefs("labs");
+ // v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider
+ // can't read images still scan a report: the image is OCR'd on-device and
+ // only the extracted text is sent to the text-only provider.
+ const localOcr = useLabsLocalOcr();
+ const updateLocalOcr = useUpdateLabsLocalOcr();
+
const { data, isLoading } = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet("/api/biomarkers"),
@@ -120,6 +129,23 @@ export function LabsSettings() {
) : null}
+ {/* v1.18.10 — local (in-browser) OCR opt-in for lab scans. */}
+
+ updateLocalOcr.mutate(checked)}
+ aria-label={t("labs.localOcr.heading")}
+ />
+ }
+ />
+
+
{/* Biomarker catalog — define / edit / delete. */}
string): string {
export function OcrReviewDialog({
open,
onOpenChange,
+ mode,
pdfSupported,
onCommitted,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
+ /** Native vision vs in-browser local OCR. Text mode is image-only. */
+ mode: OcrMode;
pdfSupported: boolean;
onCommitted: () => void;
}) {
@@ -101,8 +109,14 @@ export function OcrReviewDialog({
const [stage, setStage] = useState("pick");
const [rows, setRows] = useState([]);
- const extract = useOcrExtract();
+ // Text mode OCR's the image in the browser then POSTs the text; vision mode
+ // uploads the image. Both resolve with the same proposed-rows DTO.
+ const visionExtract = useOcrExtract();
+ const textExtract = useOcrTextExtract();
+ const extract = mode === "text" ? textExtract : visionExtract;
const commit = useOcrCommit();
+ // Text mode never accepts PDFs (tesseract.js can't read them).
+ const allowPdf = mode === "vision" && pdfSupported;
const confirmedCount = useMemo(
() => rows.filter((r) => r.confirmed).length,
@@ -197,7 +211,7 @@ export function OcrReviewDialog({
{
const file = e.target.files?.[0];
@@ -216,7 +230,12 @@ export function OcrReviewDialog({
<>
- {t("labs.ocr.extracting")}
+ {/* Text mode runs OCR on-device first, then structures it —
+ the first run also downloads the OCR engine, so the copy
+ sets the "this is reading on your device" expectation. */}
+ {mode === "text"
+ ? t("labs.ocr.readingOnDevice")
+ : t("labs.ocr.extracting")}
>
) : (
@@ -230,10 +249,16 @@ export function OcrReviewDialog({
- {pdfSupported
+ {allowPdf
? t("labs.ocr.acceptWithPdf")
: t("labs.ocr.acceptImageOnly")}
+ {/* Honest accuracy caveat for the local-OCR fallback. */}
+ {mode === "text" ? (
+
+ {t("labs.ocr.localModeHint")}
+
+ ) : null}
>
)}
diff --git a/src/components/labs/ocr-row-editor.tsx b/src/components/labs/ocr-row-editor.tsx
index 926a02f94..eed49fe60 100644
--- a/src/components/labs/ocr-row-editor.tsx
+++ b/src/components/labs/ocr-row-editor.tsx
@@ -15,7 +15,13 @@
*/
import { useId } from "react";
-import { AlertCircle, FilePlus2, Link2, TriangleAlert } from "lucide-react";
+import {
+ AlertCircle,
+ FilePlus2,
+ Link2,
+ Ruler,
+ TriangleAlert,
+} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
@@ -43,6 +49,16 @@ export function OcrRowEditor({
!isQualitative && row.confidence.value < CONFIDENCE_THRESHOLD;
const valueUnreadable = !isQualitative && row.value === null;
+ // v1.18.10 (#5) — a NEW numeric biomarker mints its catalog reference range
+ // from THIS extracted row. The range is therefore load-bearing and worth a
+ // second look. Surface the range confidence prominently for new numeric
+ // markers, and call it out explicitly when the model was unsure (or set no
+ // bounds at all) so the user verifies the range it will be judged against.
+ const isNewNumericMarker = !isQualitative && row.biomarkerMatch === "new";
+ const lowRangeConfidence = row.confidence.range < CONFIDENCE_THRESHOLD;
+ const hasBounds = row.referenceLow !== null || row.referenceHigh !== null;
+ const flagRange = isNewNumericMarker && (lowRangeConfidence || !hasBounds);
+
return (
@@ -156,6 +172,21 @@ export function OcrRowEditor({
+ {/* v1.18.10 (#5) — for a NEW numeric biomarker the reference range below
+ becomes the marker's catalog range. Make that consequence visible, and
+ flag low range-confidence / a missing range so the user verifies it
+ before saving. A calm `secondary` badge, not an alarm. */}
+ {isNewNumericMarker ? (
+
diff --git a/src/components/labs/use-ocr-extract.ts b/src/components/labs/use-ocr-extract.ts
index 8ed9307d4..60cd70c9a 100644
--- a/src/components/labs/use-ocr-extract.ts
+++ b/src/components/labs/use-ocr-extract.ts
@@ -13,7 +13,8 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { apiFetch, apiPost } from "@/lib/api/api-fetch";
+import { apiFetch, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
+import { ocrImageToText } from "@/lib/labs/local-ocr";
import { queryKeys } from "@/lib/query-keys";
import type {
OcrCapabilityDto,
@@ -49,7 +50,7 @@ export function useOcrCapability(enabled: boolean) {
});
}
-/** Upload + extract. Resolves with the proposed rows for the review screen. */
+/** Upload + extract (VISION mode). Resolves with the proposed review rows. */
export function useOcrExtract() {
return useMutation({
mutationFn: (file: File) => {
@@ -65,6 +66,55 @@ export function useOcrExtract() {
});
}
+/**
+ * TEXT mode (v1.18.10) — OCR the image IN THE BROWSER (tesseract.js), then POST
+ * only the extracted text. The raw image never leaves the device. Resolves with
+ * the same proposed-rows DTO the vision path returns, so the review/commit flow
+ * is shared verbatim.
+ */
+export function useOcrTextExtract() {
+ return useMutation({
+ mutationFn: async (file: File) => {
+ const text = await ocrImageToText(file);
+ return apiPost("/api/labs/ocr/extract", {
+ mode: "text",
+ text,
+ });
+ },
+ });
+}
+
+/** The local-OCR opt-in preference (read + toggle). */
+export interface LabsLocalOcrPref {
+ labsLocalOcrEnabled: boolean;
+}
+
+/** Read the current local-OCR opt-in flag. */
+export function useLabsLocalOcr(enabled = true) {
+ return useQuery({
+ queryKey: queryKeys.labsLocalOcr(),
+ queryFn: () => apiGet("/api/auth/me/labs-local-ocr"),
+ enabled,
+ staleTime: 60_000,
+ });
+}
+
+/** Toggle the local-OCR opt-in; invalidates the flag + the capability probe. */
+export function useUpdateLabsLocalOcr() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (labsLocalOcrEnabled: boolean) =>
+ apiPatch("/api/auth/me/labs-local-ocr", {
+ labsLocalOcrEnabled,
+ }),
+ onSuccess: (data) => {
+ queryClient.setQueryData(queryKeys.labsLocalOcr(), data);
+ // The toggle changes whether text-mode scanning is available.
+ queryClient.invalidateQueries({ queryKey: queryKeys.ocrCapability() });
+ },
+ });
+}
+
/** Commit the confirmed rows and invalidate the dependent reads. */
export function useOcrCommit() {
const queryClient = useQueryClient();
diff --git a/src/lib/labs/local-ocr.ts b/src/lib/labs/local-ocr.ts
new file mode 100644
index 000000000..03712de02
--- /dev/null
+++ b/src/lib/labs/local-ocr.ts
@@ -0,0 +1,62 @@
+/**
+ * v1.18.10 — in-browser (local) OCR for lab-report scans.
+ *
+ * Used by the TEXT-mode Lab-OCR path: a user whose AI provider cannot read
+ * images (ChatGPT-OAuth/Codex, a text-only model) OCR's the photo HERE, in the
+ * browser, and only the extracted TEXT is POSTed to `/api/labs/ocr/extract`.
+ * The raw image never leaves the device.
+ *
+ * tesseract.js (WASM) is LAZY-loaded via `await import(...)` so the multi-MB
+ * worker + WASM core + `deu+eng` traineddata land in the browser only when the
+ * user actually scans in text mode — never at app boot, and never for the
+ * higher-accuracy vision path. This mirrors the chart-defer discipline.
+ *
+ * Images only — tesseract.js cannot read PDFs, matching the vision path's
+ * image-only constraint for non-Anthropic providers.
+ */
+
+/** German + English: a German lab sheet needs `deu` for ä/ö/ü/ß; `eng` covers
+ * the many English analyte names and units that appear on the same sheet. */
+const OCR_LANGS = "deu+eng";
+
+export class LocalOcrError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "LocalOcrError";
+ }
+}
+
+/**
+ * OCR an image file entirely in the browser and return the extracted plain
+ * text. Throws `LocalOcrError` when the engine fails to load or recognise.
+ *
+ * A fresh worker is created and terminated per call. The traineddata is
+ * browser-cached after the first run, so the cost is paid once.
+ */
+export async function ocrImageToText(file: File): Promise {
+ // Lazy import — keeps tesseract.js out of the main bundle.
+ let createWorker: typeof import("tesseract.js").createWorker;
+ try {
+ ({ createWorker } = await import("tesseract.js"));
+ } catch {
+ throw new LocalOcrError("Failed to load the local OCR engine");
+ }
+
+ const worker = await createWorker(OCR_LANGS);
+ try {
+ const {
+ data: { text },
+ } = await worker.recognize(file);
+ const trimmed = text.trim();
+ if (!trimmed) {
+ throw new LocalOcrError("No text could be read from the image");
+ }
+ return trimmed;
+ } catch (err) {
+ if (err instanceof LocalOcrError) throw err;
+ throw new LocalOcrError("Local OCR failed to read the image");
+ } finally {
+ // Free the WASM worker; the traineddata stays browser-cached.
+ await worker.terminate().catch(() => {});
+ }
+}
From d65ed8b0b655ef680682b32aa53ffe08bbccf2bc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:50:23 +0200
Subject: [PATCH 08/32] fix(labs): move delete to the detail view and shrink
overview tags
Remove the inline trash icon from the lab list and tile rows; delete now
lives only on the value detail view, beside Edit, so a stray tap on the
overview cannot remove a biomarker. Make the reference-range badge beside
each analyte heading quieter on the overview (smaller text, tighter
padding, no icon) so it reads as a subtle qualifier instead of competing
with the heading.
---
src/components/labs/lab-list.tsx | 60 +++++--------------
src/components/labs/reference-range-badge.tsx | 22 ++++++-
2 files changed, 35 insertions(+), 47 deletions(-)
diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx
index e4fcecf23..657d9b07c 100644
--- a/src/components/labs/lab-list.tsx
+++ b/src/components/labs/lab-list.tsx
@@ -2,17 +2,15 @@
import { useMemo } from "react";
import Link from "next/link";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useQuery } from "@tanstack/react-query";
import { ChevronRight, FlaskConical } from "lucide-react";
-import { toast } from "sonner";
-import { DeleteButton } from "@/components/data-list";
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
-import { apiDelete, apiGet } from "@/lib/api/api-fetch";
+import { apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabReading, formatLabValue } from "@/lib/labs/format-value";
@@ -77,22 +75,8 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
- const queryClient = useQueryClient();
const { prefs } = useModuleListPrefs("labs");
- // Delete a biomarker directly from the list (#41/#3). The endpoint's
- // `onDelete: SetNull` FK keeps the readings and unlinks them; both the
- // catalog and the result list invalidate so the row drops in the same tick.
- const deleteBiomarker = useMutation({
- mutationFn: (id: string) => apiDelete(`/api/biomarkers/${id}`),
- onSuccess: () => {
- toast.success(t("labs.biomarker.deletedToast"));
- queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
- queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
- },
- onError: () => toast.error(t("labs.biomarker.deleteError")),
- });
-
const listKey = queryKeys.labResultsList({
biomarkerId: undefined,
analyte: undefined,
@@ -181,23 +165,9 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
);
}
- // Per-group biomarker delete (#41/#3), surfaced on both views. Only
- // catalog-linked groups carry an id the endpoint can delete; legacy
- // un-linked rows have no biomarker to remove.
- function biomarkerDelete(group: MarkerGroup) {
- if (!group.biomarkerId) return null;
- const id = group.biomarkerId;
- return (
- deleteBiomarker.mutate(id)}
- title={t("labs.biomarker.deleteConfirmTitle")}
- description={t("labs.biomarker.deleteConfirmDescription")}
- confirmLabel={t("labs.biomarker.delete")}
- className="size-9"
- iconClassName="h-4 w-4"
- />
- );
- }
+ // v1.18.10 (#3) — delete lives ONLY on the value detail view (next to Edit),
+ // not on these overview rows. The list/tile rows navigate; the trash icon was
+ // removed here so a stray tap can't delete a biomarker from the overview.
// v1.18.6 (MOD-03) — compact list view: one bordered card holding tight
// divided rows instead of a card per biomarker. This is the default view
@@ -219,7 +189,10 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
{group.analyte}
-
+
);
- // The delete control + chevron sit OUTSIDE the navigating Link
- // so the delete confirm never also deep-links into the detail.
+ // The whole row navigates into the detail (where delete lives).
return (
void } = {}) {
/>
)}
- {biomarkerDelete(group)}
);
})}
@@ -268,9 +239,8 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
// v1.18.9 (#40) — card/tile view. The tile reuses the medication module's
// `MedicationCardHeader` so a lab tile reads identically to a medication or
// Vorsorge tile side-by-side: name on line 1, the reference-range badge as
- // the line-1 chip, the panel as the category badge, and the delete control
- // in the header's `actions` slot (a sibling of the header Link, so deleting
- // never also deep-links).
+ // the line-1 chip, the panel as the category badge. v1.18.10 (#3) — no delete
+ // control on the tile; the trash icon lives only on the value detail view.
return (
diff --git a/src/components/labs/reference-range-badge.tsx b/src/components/labs/reference-range-badge.tsx
index 2b4aa5a12..e5ceec512 100644
--- a/src/components/labs/reference-range-badge.tsx
+++ b/src/components/labs/reference-range-badge.tsx
@@ -22,17 +22,33 @@ import type { ReferenceRangeStatus } from "@/lib/validations/labs";
*/
export function ReferenceRangeBadge({
status,
+ compact = false,
}: {
status: ReferenceRangeStatus;
+ /**
+ * v1.18.10 (#4) — a quieter, smaller treatment for the labs overview, where
+ * the badge sits beside the analyte heading and must not compete with it.
+ * The hero/detail view keeps the default (un-compact) size.
+ */
+ compact?: boolean;
}) {
const { t } = useTranslations();
if (status === "unknown") return null;
+ // Compact: drop the icon, shrink the text and padding so the badge reads as a
+ // subtle qualifier of the heading rather than a second focal point.
+ const compactClass = compact
+ ? "h-auto gap-0.5 px-1.5 py-0 text-[0.625rem] font-normal [&>svg]:size-2.5"
+ : "";
+
if (status === "in-range") {
return (
-
-
+
+ {compact ? null : }
{t("labs.range.inRange")}
);
@@ -44,7 +60,7 @@ export function ReferenceRangeBadge({
const label =
status === "below" ? t("labs.range.below") : t("labs.range.above");
return (
-
+
{label}
From 0623316c12d4b01e8cfd06c37c8733be742b1918 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:51:17 +0200
Subject: [PATCH 09/32] feat(vorsorge): move cadence into metric row and link
heading to filtered results
Drop the grey cadence pill from the card heading. The cadence now reads as
part of the metric line, styled like the metric label itself (no shaded
background), via a new MedicationCardHeader metricMeta slot. Nothing else
sits beside the heading.
Link a measurement-linked reminder's heading to the measurements history
pre-filtered to its type, reusing the existing kebab target. Free-text
reminders keep a plain heading.
---
.../vorsorge-section.tsx | 30 ++++++++++++++-----
.../medications/medication-card-header.tsx | 18 ++++++++++-
2 files changed, 40 insertions(+), 8 deletions(-)
diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx
index e1c1c59f8..b1a68df33 100644
--- a/src/components/measurement-reminders/vorsorge-section.tsx
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -770,11 +770,19 @@ function VorsorgeCard({
? t(`measurementReminders.types.${reminder.measurementType}`)
: t("measurementReminders.selfPlanned");
- // v1.18.6.1 — the cadence renders as a chip DIRECTLY to the right of the
- // metric name (the med-card convention: cadence sits next to the name).
- const cadenceChip = cadence ? (
- {cadence}
- ) : null;
+ // v1.18.10 — the cadence reads as part of the METRIC ROW (the category
+ // line), styled like the metric label itself — same muted weight/colour,
+ // no shaded pill. The card heading stays clean (nothing beside it); the
+ // cadence rides next to "Blutdruck" et al. via `metricMeta` below.
+ const cadenceText = cadence || null;
+
+ // v1.18.10 — a measurement-linked reminder's heading links to the
+ // measurements history pre-filtered to its type (the same target the
+ // "Messergebnisse anzeigen" kebab item uses). A free-text reminder has no
+ // readings to show, so it stays a plain (non-linking) heading.
+ const measurementsHref = isLinked
+ ? `/measurements?type=${encodeURIComponent(reminder.measurementType as string)}`
+ : undefined;
// Provenance / disabled state badges keep their own row below the category.
// Omit the row entirely when neither applies (don't render an empty band).
@@ -900,13 +908,15 @@ function VorsorgeCard({
{resolveReminderLabel(reminder, t)}
- {cadenceChip}
+ {/* v1.18.10 — cadence rides on the metric line, styled like the
+ metric text (no shaded pill). */}
@@ -991,7 +1001,13 @@ function VorsorgeCard({
name={resolveReminderLabel(reminder, t)}
dose=""
categoryLabel={categoryLabel}
- nameChip={cadenceChip}
+ metricMeta={cadenceText}
+ href={measurementsHref}
+ linkLabel={
+ measurementsHref
+ ? t("measurementReminders.showMeasurements")
+ : undefined
+ }
stateBadges={stateBadges}
actions={headerActions}
/>
diff --git a/src/components/medications/medication-card-header.tsx b/src/components/medications/medication-card-header.tsx
index 1a7af3740..f3713df2a 100644
--- a/src/components/medications/medication-card-header.tsx
+++ b/src/components/medications/medication-card-header.tsx
@@ -43,6 +43,15 @@ export interface MedicationCardHeaderProps {
* from `stateBadges` (which ride their own row below the category).
*/
nameChip?: ReactNode;
+ /**
+ * v1.18.10 — inline text rendered DIRECTLY beside the category label on
+ * line 2 (e.g. the Vorsorge cadence "alle 7 Tage"). Unlike `nameChip`
+ * (a chip on the title line) this reads as part of the metric row itself,
+ * inheriting the muted line's weight/colour rather than a shaded pill, so
+ * the cadence sits next to the metric name without an alarming grey
+ * background.
+ */
+ metricMeta?: ReactNode;
stateBadges?: ReactNode;
actions?: ReactNode;
/**
@@ -60,6 +69,7 @@ export function MedicationCardHeader({
dose,
categoryLabel,
nameChip,
+ metricMeta,
stateBadges,
actions,
href,
@@ -81,10 +91,16 @@ export function MedicationCardHeader({
State badges now ride their own row below the category
badge so the canonical row stays two lines on narrow
viewports. */}
-
+
{categoryLabel}
+ {/* v1.18.10 — cadence (and any future metric meta) reads as part of
+ the metric line itself: same muted weight/colour as the row, no
+ shaded pill. */}
+ {metricMeta ? (
+ {metricMeta}
+ ) : null}
{stateBadges ? (
Date: Sat, 20 Jun 2026 16:51:18 +0200
Subject: [PATCH 10/32] fix(dashboard): refresh briefing and layout on the
Startseite without reload
The dashboard hero reads the briefing headline and the layout from the
server snapshot. Regenerating a briefing or saving the dashboard layout
only touched their own caches, so the snapshot kept stale data until its
own window lapsed and the Startseite looked unchanged until a manual
reload. Invalidate the dashboard snapshot on briefing regenerate and on
layout save/reset.
Harmonise the daily-briefing card body rhythm with its sibling insights
cards (space-y-3, matching correlation-card) so it no longer reads taller.
---
src/components/insights/daily-briefing.tsx | 9 +++++++--
src/components/insights/use-insights-advisor.ts | 9 +++++++++
.../settings/dashboard-layout-section.tsx | 14 ++++++++++++++
3 files changed, 30 insertions(+), 2 deletions(-)
diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx
index 9c3442bce..99353d1c1 100644
--- a/src/components/insights/daily-briefing.tsx
+++ b/src/components/insights/daily-briefing.tsx
@@ -302,7 +302,7 @@ function BriefingSkeleton() {
return (
+ // v1.18.10 — body block rhythm matches the sibling insights cards
+ // (`correlation-card` uses `space-y-3` between its chart /
+ // interpretation / source blocks). The briefing card carried a
+ // looser `space-y-4`, which read as taller than its neighbours on
+ // the overview. One token, no new spacing scale.
+
{/* v1.4.27 B1 — the leading narrative paragraph dropped.
The hero strip subtitle on `/insights` already renders
the same `briefing.paragraph` text directly above this
diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts
index 64dd690d2..53deb92cd 100644
--- a/src/components/insights/use-insights-advisor.ts
+++ b/src/components/insights/use-insights-advisor.ts
@@ -330,6 +330,15 @@ export function useInsightsAdvisorQuery(
// their query subtree so the per-section text below the advisor card
// re-fetches.
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
+ // v1.18.10 — the dashboard hero surfaces the briefing headline from the
+ // SNAPSHOT (server-lifted `User.insightsCachedText`), not from this
+ // advisor cache. A regenerate rewrites that server cache, so invalidate
+ // the snapshot too — otherwise returning to the Startseite shows the
+ // previous briefing under the greeting until the snapshot's own stale
+ // window lapses. Closes "the briefing doesn't update" on the dashboard.
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.dashboardSnapshot(),
+ });
},
});
diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx
index 27b2fcad9..00488e222 100644
--- a/src/components/settings/dashboard-layout-section.tsx
+++ b/src/components/settings/dashboard-layout-section.tsx
@@ -204,6 +204,15 @@ export function DashboardLayoutSection({ id }: { id: string }) {
},
onSuccess: (saved) => {
queryClient.setQueryData(queryKeys.dashboardWidgets(), saved);
+ // v1.18.10 — the Startseite reads its layout (tile/chart visibility,
+ // order, comparison baseline, hero toggle) from the dashboard
+ // SNAPSHOT, not from this `dashboardWidgets` key. Without an
+ // invalidation the snapshot keeps its cached layout and the dashboard
+ // looks stale until a manual reload. Invalidate the snapshot so the
+ // next visit (or open tab) re-reads the saved layout immediately.
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.dashboardSnapshot(),
+ });
setDraft(null);
toast.success(t("dashboard.layoutSaveSuccess"));
},
@@ -216,6 +225,11 @@ export function DashboardLayoutSection({ id }: { id: string }) {
},
onSuccess: (saved) => {
queryClient.setQueryData(queryKeys.dashboardWidgets(), saved);
+ // v1.18.10 — same snapshot invalidation as the save path so a reset
+ // to defaults reflects on the Startseite without a manual reload.
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.dashboardSnapshot(),
+ });
setDraft(null);
toast.success(t("dashboard.layoutResetSuccess"));
},
From fb4e3921e27d813c817ec15180d455f44ac99510 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:51:51 +0200
Subject: [PATCH 11/32] feat(withings): capture nightly sleep vitals from the
summary endpoint
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The per-segment sleep sync wrote only the stage timeline; the per-night
summary's vitals — average HR, respiratory rate, SDNN-based HRV, average
SpO2 and the sleep score — were dropped at the fetch. Add a getsummary
fetch that maps each vital to its existing MeasurementType (RESTING_HEART_RATE,
RESPIRATORY_RATE, HEART_RATE_VARIABILITY, OXYGEN_SATURATION, SLEEP_SCORE), so
no migration and no client-contract change: the rows flow to the sync delta
under the WITHINGS source like every other server-side fetch.
Each vital upserts on the stable (userId, type, source, externalId) composite
keyed by the night's session id, so a re-sync overwrites rather than
duplicates. A summary fetch failure is isolated so the stage segments already
written survive. Fractional SpO2 is normalised to percent.
---
src/lib/withings/__tests__/sync-sleep.test.ts | 159 ++++++++++++++
src/lib/withings/sync-sleep.ts | 201 ++++++++++++++++++
2 files changed, 360 insertions(+)
diff --git a/src/lib/withings/__tests__/sync-sleep.test.ts b/src/lib/withings/__tests__/sync-sleep.test.ts
index 70c985e69..91bad4170 100644
--- a/src/lib/withings/__tests__/sync-sleep.test.ts
+++ b/src/lib/withings/__tests__/sync-sleep.test.ts
@@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
+ upsert: vi.fn(),
},
withingsConnection: { findUnique: vi.fn() },
},
@@ -69,6 +70,7 @@ import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollup
import {
fetchWithingsSleep,
mapWithingsSleepState,
+ mapWithingsSleepSummary,
syncUserSleep,
} from "../sync-sleep";
@@ -107,6 +109,71 @@ afterEach(() => {
vi.unstubAllGlobals();
});
+/**
+ * v1.18.10 P0 — fetch mock that returns the per-segment series on the first
+ * call (`action=get`) and the per-night summary series on the second
+ * (`action=getsummary`). Lets the sync exercise both the stage path and the
+ * nightly-vital path in one round-trip.
+ */
+function installSegmentThenSummaryFetch(
+ segments: FakeSegment[],
+ summaries: Array<{
+ id?: number;
+ startdate: number;
+ enddate: number;
+ data?: Record;
+ }>,
+) {
+ let call = 0;
+ const fetchMock = vi.fn(async () => {
+ const body = call === 0 ? { series: segments } : { series: summaries };
+ call++;
+ return { status: 200, json: async () => ({ status: 0, body }) };
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ return fetchMock;
+}
+
+describe("mapWithingsSleepSummary", () => {
+ it("maps the canonical nightly vitals to existing enums", () => {
+ const rows = mapWithingsSleepSummary({
+ hr_average: 58,
+ rr_average: 14,
+ sdnn_1: 65,
+ spo2_average: 96,
+ sleep_score: 82,
+ });
+ const byType = Object.fromEntries(rows.map((r) => [r.type, r.value]));
+ expect(byType).toEqual({
+ RESTING_HEART_RATE: 58,
+ RESPIRATORY_RATE: 14,
+ HEART_RATE_VARIABILITY: 65,
+ OXYGEN_SATURATION: 96,
+ SLEEP_SCORE: 82,
+ });
+ });
+
+ it("normalises a fractional SpO2 (0..1) to percent", () => {
+ const rows = mapWithingsSleepSummary({ spo2_average: 0.97 });
+ const spo2 = rows.find((r) => r.type === "OXYGEN_SATURATION");
+ expect(spo2?.value).toBeCloseTo(97, 5);
+ });
+
+ it("skips omitted / non-finite fields", () => {
+ const rows = mapWithingsSleepSummary({
+ hr_average: 60,
+ rr_average: null,
+ sdnn_1: undefined as unknown as number,
+ sleep_score: Number.NaN,
+ });
+ expect(rows.map((r) => r.type)).toEqual(["RESTING_HEART_RATE"]);
+ });
+
+ it("returns nothing for an absent data block", () => {
+ expect(mapWithingsSleepSummary(undefined)).toEqual([]);
+ });
+});
+
describe("mapWithingsSleepState", () => {
it("maps state 0 (awake) → AWAKE", () => {
expect(mapWithingsSleepState(0)).toBe("AWAKE");
@@ -271,6 +338,98 @@ describe("syncUserSleep — segment writes + idempotency", () => {
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings");
});
+ it("upserts nightly vitals with stable per-vital externalIds (v1.18.10 P0)", async () => {
+ const nightEnd = Math.floor(Date.UTC(2026, 4, 13, 6) / 1000);
+ installSegmentThenSummaryFetch(
+ [],
+ [
+ {
+ id: 555,
+ startdate: nightEnd - 8 * 3600,
+ enddate: nightEnd,
+ data: {
+ hr_average: 57,
+ rr_average: 13,
+ sdnn_1: 70,
+ spo2_average: 95,
+ sleep_score: 88,
+ },
+ },
+ ],
+ );
+ vi.mocked(prisma.measurement.upsert).mockResolvedValue({} as never);
+
+ const imported = await syncUserSleep("user-1");
+ // Five vitals upserted (no stage segments in this fixture).
+ expect(imported).toBe(5);
+
+ const calls = vi.mocked(prisma.measurement.upsert).mock.calls;
+ const byType = new Map(
+ calls.map((c) => {
+ const w = c[0] as {
+ where: {
+ userId_type_source_externalId: { type: string; externalId: string };
+ };
+ create: { value: number };
+ };
+ return [
+ w.where.userId_type_source_externalId.type,
+ {
+ externalId: w.where.userId_type_source_externalId.externalId,
+ value: w.create.value,
+ },
+ ];
+ }),
+ );
+ expect(byType.get("RESTING_HEART_RATE")).toEqual({
+ externalId: "withings:sleep:user-1:555:hr",
+ value: 57,
+ });
+ expect(byType.get("SLEEP_SCORE")).toEqual({
+ externalId: "withings:sleep:user-1:555:score",
+ value: 88,
+ });
+ expect(byType.get("OXYGEN_SATURATION")?.value).toBe(95);
+ });
+
+ it("keeps stage segments when the summary fetch fails (v1.18.10 P0)", async () => {
+ let call = 0;
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => {
+ const isSummary = call > 0;
+ call++;
+ return {
+ status: 200,
+ json: async () =>
+ isSummary
+ ? { status: 293 } // summary errors
+ : {
+ status: 0,
+ body: {
+ series: [
+ {
+ startdate: 1715000000,
+ enddate: 1715003600,
+ state: 2,
+ id: 1,
+ },
+ ],
+ },
+ },
+ };
+ }),
+ );
+ vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
+ vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
+
+ const imported = await syncUserSleep("user-1");
+ // The one stage segment is still written; the summary failure is swallowed.
+ expect(imported).toBe(1);
+ expect(prisma.measurement.create).toHaveBeenCalledTimes(1);
+ expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings");
+ });
+
it("tolerates a ScanWatch night with no REM segment (no all-zeros row)", async () => {
// ScanWatch reports CORE + DEEP only; a missing REM should NOT
// synthesise an all-zeros REM row. Withings simply omits the
diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts
index 27b643baf..31940c073 100644
--- a/src/lib/withings/sync-sleep.ts
+++ b/src/lib/withings/sync-sleep.ts
@@ -61,6 +61,87 @@ import {
const WITHINGS_SLEEP_URL = "https://wbsapi.withings.net/v2/sleep";
+/**
+ * v1.18.10 P0 — nightly sleep vitals from `action=getsummary`.
+ *
+ * The per-segment `action=get` series carries only the stage timeline; the
+ * per-night summary carries the physiological vitals (average HR, respiratory
+ * rate, SDNN-based HRV, average SpO2, the Withings sleep score). These were
+ * previously dropped at the fetch even though the canonical enums exist. Each
+ * vital maps to an EXISTING `MeasurementType` so there is no migration and no
+ * iOS-contract change — the rows simply flow to `/api/sync/changes` under the
+ * `WITHINGS` source like every other server-side fetch.
+ *
+ * Field set requested via `data_fields`. Withings nests the requested fields
+ * under each night's `data` object. Only the fields with a canonical enum are
+ * requested; snoring / breathing-disturbance intensity have no enum and are
+ * deliberately left out (no home to store them).
+ */
+const SLEEP_SUMMARY_DATA_FIELDS =
+ "hr_average,rr_average,sdnn_1,spo2_average,sleep_score";
+
+/**
+ * One nightly summary row from `POST /v2/sleep?action=getsummary`. The vitals
+ * live under `data`; `id` is the per-night session id (stable across re-syncs,
+ * the same id the segment series carries) so the externalId reconciles.
+ */
+export interface WithingsSleepSummary {
+ id?: number;
+ startdate: number;
+ enddate: number;
+ data?: {
+ hr_average?: number | null;
+ rr_average?: number | null;
+ sdnn_1?: number | null;
+ spo2_average?: number | null;
+ sleep_score?: number | null;
+ };
+}
+
+/**
+ * One nightly vital extracted from a summary, ready to upsert. The
+ * `measuredAt` is the night's END (`enddate`) so the vital sorts under the
+ * same night as the END-stamped stage segments.
+ */
+interface SleepVitalRow {
+ type: MeasurementType;
+ value: number;
+ /** Stable per-vital externalId suffix; combined with the session id. */
+ fieldTag: string;
+}
+
+/**
+ * Map a nightly summary's `data` block to the canonical vital rows. Skips any
+ * field Withings omitted or returned as a non-finite value. `spo2_average`
+ * arrives as a fraction (0..1) on some firmware and a percent (0..100) on
+ * others — normalise the fraction form to percent so it shares the
+ * `OXYGEN_SATURATION` bucket with the meastype-54 spot reading.
+ */
+export function mapWithingsSleepSummary(
+ data: WithingsSleepSummary["data"] | undefined,
+): SleepVitalRow[] {
+ if (!data) return [];
+ const rows: SleepVitalRow[] = [];
+ const push = (type: MeasurementType, value: unknown, fieldTag: string) => {
+ if (typeof value !== "number" || !Number.isFinite(value)) return;
+ rows.push({ type, value, fieldTag });
+ };
+ // Average HR over the sleep window → RESTING_HEART_RATE (one nightly sample,
+ // distinct from spot PULSE — same convention the HealthKit nightly RHR uses).
+ push("RESTING_HEART_RATE", data.hr_average, "hr");
+ push("RESPIRATORY_RATE", data.rr_average, "rr");
+ // sdnn_1 is the SDNN-based HRV estimator — the same estimator the
+ // HEART_RATE_VARIABILITY enum documents (kept distinct from WHOOP's RMSSD).
+ push("HEART_RATE_VARIABILITY", data.sdnn_1, "hrv");
+ let spo2 = data.spo2_average;
+ if (typeof spo2 === "number" && Number.isFinite(spo2) && spo2 <= 1) {
+ spo2 = spo2 * 100;
+ }
+ push("OXYGEN_SATURATION", spo2, "spo2");
+ push("SLEEP_SCORE", data.sleep_score, "score");
+ return rows;
+}
+
/**
* Default backfill window for the first sleep sync of a connection.
* Matches every other Withings ingest path so a reconnect lights up
@@ -83,6 +164,16 @@ export interface WithingsSleepSegment {
id?: number;
}
+/**
+ * Format a Date as `YYYY-MM-DD` in UTC for the summary endpoint's
+ * `startdateymd` / `enddateymd` params. The summary window is day-grained;
+ * UTC keeps it deterministic and matches the unix-second window the segment
+ * fetch derives from the same `start` / `now` Dates.
+ */
+function ymdUtc(d: Date): string {
+ return d.toISOString().slice(0, 10);
+}
+
/**
* Map Withings `state` to the HealthLog `SleepStage` enum. Returns
* `null` for states we deliberately ignore (state 4 = synthetic
@@ -153,6 +244,56 @@ export async function fetchWithingsSleep(
return series;
}
+/**
+ * v1.18.10 P0 — fetch the per-night sleep summaries (with vitals) for the
+ * given window. `action=getsummary` keys by `lastupdate`/`startdateymd`; we
+ * use the same unix-second window the segment fetch uses, expressed as the
+ * `YYYY-MM-DD` form the summary endpoint expects. Returns the raw summaries
+ * (no DB write) so the caller composes the upsert step.
+ */
+export async function fetchWithingsSleepSummary(
+ accessToken: string,
+ startymd: string,
+ endymd: string,
+): Promise {
+ const params = new URLSearchParams({
+ action: "getsummary",
+ startdateymd: startymd,
+ enddateymd: endymd,
+ data_fields: SLEEP_SUMMARY_DATA_FIELDS,
+ });
+
+ const pageStart = performance.now();
+ const res = await safeFetch(WITHINGS_SLEEP_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: params.toString(),
+ });
+ const json = await res.json();
+ const verdict = classifyWithingsResponse(res.status, json);
+ getEvent()?.addExternalCall({
+ service: "withings",
+ method: "fetchWithingsSleepSummary",
+ duration_ms: Math.round(performance.now() - pageStart),
+ status: res.status,
+ error: verdict.classification === "success" ? undefined : verdict.reason,
+ });
+ if (verdict.classification !== "success") {
+ throw new WithingsApiError({
+ verb: "sleep",
+ classification: verdict.classification,
+ withingsStatus: verdict.withingsStatus,
+ reason: verdict.reason,
+ upstreamError: typeof json?.error === "string" ? json.error : undefined,
+ });
+ }
+ const series: WithingsSleepSummary[] = json.body?.series ?? [];
+ return series;
+}
+
/**
* Sync sleep data for a single user. Walks the trailing 30-day window
* (or whatever `SLEEP_BACKFILL_DAYS` is set to), writing one
@@ -319,6 +460,66 @@ export async function syncUserSleep(
segmentIndex++;
}
+ // v1.18.10 P0 — nightly sleep vitals (avg HR / respiratory rate / SDNN HRV
+ // / avg SpO2 / sleep score) from the per-night summary. A summary-fetch
+ // failure must not lose the stage segments already written, so it runs in
+ // its own try/catch and only annotates on error. Each vital upserts on the
+ // stable `(userId, type, source, externalId)` composite so a re-sync
+ // overwrites the night's value rather than inserting a duplicate.
+ try {
+ const startYmd = ymdUtc(start);
+ const endYmd = ymdUtc(now);
+ const summaries = await fetchWithingsSleepSummary(
+ tokenInfo.accessToken,
+ startYmd,
+ endYmd,
+ );
+ for (const summary of summaries) {
+ const measuredAt = new Date(summary.enddate * 1000);
+ const sessionId = summary.id ?? "no-id";
+ const vitals = mapWithingsSleepSummary(summary.data);
+ for (const vital of vitals) {
+ const externalId = `withings:sleep:${userId}:${sessionId}:${vital.fieldTag}`;
+ try {
+ await prisma.measurement.upsert({
+ where: {
+ userId_type_source_externalId: {
+ userId,
+ type: vital.type,
+ source: "WITHINGS",
+ externalId,
+ },
+ },
+ create: {
+ userId,
+ type: vital.type,
+ value: vital.value,
+ unit: getUnitForType(vital.type),
+ measuredAt,
+ source: "WITHINGS",
+ externalId,
+ },
+ update: {
+ value: vital.value,
+ measuredAt,
+ syncVersion: { increment: 1 },
+ },
+ });
+ touched.push({ type: vital.type, measuredAt });
+ imported++;
+ } catch (err) {
+ getEvent()?.addWarning(
+ `withings sleep: failed to upsert vital (${vital.type}, ${measuredAt.toISOString()}): ${err}`,
+ );
+ }
+ }
+ }
+ } catch (err) {
+ getEvent()?.addWarning(
+ `withings sleep: summary fetch failed for ${userId}: ${err}`,
+ );
+ }
+
// v1.4.39.1 — refresh the persistent rollup table for every distinct
// (type, day) the sync touched. Sleep segments collapse heavily —
// ~10 stage rows per night land in one DAY recompute.
From 013241ba746328f6b5feb6eb63ff3eb0ccc2bfa6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:52:00 +0200
Subject: [PATCH 12/32] fix(analytics): collapse overlapping sources on the
cold BP path
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The raw BP pulls in the comprehensive aggregator and the bp-in-target live
fallback read every source's row for BLOOD_PRESSURE_SYS/DIA. BP is a
multi-source metric — a Withings reading mirrored into Apple Health is two
rows for one event — so each shared reading was counted twice in the
in-target denominator and twice in the recency-weighted grade. The rollup
branch of the same fast-path reads source-collapsed measurement_rollups, so a
rollup-warm account and a rollup-cold account returned different BP
percentages and grades for identical data — a web/iOS and cold/warm parity
break.
Route both raw pulls through canonicalMeasurementsFrom so the cold/live path
picks the ladder-winning source per (type, day), matching the warm/rollup
path. Per-event rows of the winning source survive the collapse, so the
downstream 5-minute pairing still sees individual readings.
---
.../__tests__/bp-in-target-fast-path.test.ts | 72 +++++++++++++---
src/lib/analytics/bp-in-target-fast-path.ts | 86 +++++++++++++------
.../comprehensive-aggregator.test.ts | 80 +++++++++--------
src/lib/insights/comprehensive-aggregator.ts | 69 +++++++++++----
4 files changed, 215 insertions(+), 92 deletions(-)
diff --git a/src/lib/analytics/__tests__/bp-in-target-fast-path.test.ts b/src/lib/analytics/__tests__/bp-in-target-fast-path.test.ts
index a3a27e096..4115377c3 100644
--- a/src/lib/analytics/__tests__/bp-in-target-fast-path.test.ts
+++ b/src/lib/analytics/__tests__/bp-in-target-fast-path.test.ts
@@ -15,6 +15,10 @@ vi.mock("@/lib/db", () => ({
// v1.11.1 — readRollupBuckets lazy-loads the source-priority blob; null
// (default findUnique) falls back to the default ladders.
user: { findUnique: vi.fn() },
+ // v1.18.10 I-1 — the live BP fallback now reads source-collapsed rows via
+ // `$queryRawUnsafe` (`canonicalMeasurementsFrom`) instead of a chunked
+ // `findMany`, so cold == warm. Mock it to feed the SYS / DIA series.
+ $queryRawUnsafe: vi.fn(),
},
}));
@@ -39,6 +43,7 @@ const MEASUREMENT_FIND_MANY = prisma.measurement
.findMany as unknown as ReturnType;
const ROLLUP_FIND_MANY = prisma.measurementRollup
.findMany as unknown as ReturnType;
+const QUERY_RAW = prisma.$queryRawUnsafe as unknown as ReturnType;
const PROBE = probeRollupCoverage as unknown as ReturnType;
const FULLY_COVERED = isFullyCovered as unknown as ReturnType;
const ANNOTATE = annotate as unknown as ReturnType;
@@ -53,6 +58,7 @@ const TARGETS_UNDER_65 = {
beforeEach(() => {
MEASUREMENT_FIND_MANY.mockReset();
ROLLUP_FIND_MANY.mockReset();
+ QUERY_RAW.mockReset();
PROBE.mockReset();
FULLY_COVERED.mockReset();
ANNOTATE.mockReset();
@@ -215,7 +221,7 @@ describe("computeBpInTargetFastPath", () => {
});
describe("live fallback — partial or no coverage", () => {
- it("falls back to the chunked findMany when SYS bucket coverage is missing", async () => {
+ it("falls back to the source-collapsed live read when SYS bucket coverage is missing", async () => {
const coverage = new Map([
["BLOOD_PRESSURE_SYS", false],
["BLOOD_PRESSURE_DIA", true],
@@ -223,17 +229,16 @@ describe("computeBpInTargetFastPath", () => {
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
- // First findMany = SYS rows, second = DIA rows.
- MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
+ // First raw query = SYS rows, second = DIA rows. Rows arrive in the raw
+ // SQL snake_case shape (`measured_at` + `value`).
+ QUERY_RAW.mockResolvedValueOnce([
{
- id: "1",
- measuredAt: new Date("2026-05-15T08:00:00.000Z"),
+ measured_at: new Date("2026-05-15T08:00:00.000Z"),
value: 122,
},
]).mockResolvedValueOnce([
{
- id: "2",
- measuredAt: new Date("2026-05-15T08:00:00.000Z"),
+ measured_at: new Date("2026-05-15T08:00:00.000Z"),
value: 76,
},
]);
@@ -249,7 +254,8 @@ describe("computeBpInTargetFastPath", () => {
// One paired in-target reading.
expect(result.last30Days).toEqual({ pct: 100, pairs: 1 });
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
- expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(2);
+ expect(MEASUREMENT_FIND_MANY).not.toHaveBeenCalled();
+ expect(QUERY_RAW).toHaveBeenCalledTimes(2);
});
it("pins the path:live annotate", async () => {
@@ -259,7 +265,7 @@ describe("computeBpInTargetFastPath", () => {
]);
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
- MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
+ QUERY_RAW.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await computeBpInTargetFastPath({
userId: "user-partial",
@@ -275,11 +281,51 @@ describe("computeBpInTargetFastPath", () => {
expect(bpCall).toBeDefined();
});
+ it("collapses overlapping sources on the live path (v1.18.10 I-1 — cold == warm)", async () => {
+ // Two sources mirror the SAME reading on the same day (Withings + its
+ // Apple-Health mirror). The source-collapse picks one canonical source
+ // per (type, day) BEFORE pairing, so the in-target denominator counts the
+ // shared reading once — matching the rollup path. The collapse runs in
+ // SQL (`canonicalMeasurementsFrom`), so the mock just confirms the live
+ // query routes through it and the result reflects ONE pair, not two.
+ const coverage = new Map([
+ ["BLOOD_PRESSURE_SYS", false],
+ ["BLOOD_PRESSURE_DIA", false],
+ ]);
+ FULLY_COVERED.mockReturnValue(false);
+ PROBE.mockResolvedValue(coverage);
+ // The canonical subquery has already collapsed the duplicate source — it
+ // returns ONE SYS row and ONE DIA row for the day.
+ QUERY_RAW.mockResolvedValueOnce([
+ { measured_at: new Date("2026-05-15T08:00:00.000Z"), value: 122 },
+ ]).mockResolvedValueOnce([
+ { measured_at: new Date("2026-05-15T08:00:00.000Z"), value: 76 },
+ ]);
+
+ const result = await computeBpInTargetFastPath({
+ userId: "user-dual-source",
+ targets: TARGETS_UNDER_65,
+ now: new Date("2026-05-17T12:00:00.000Z"),
+ coverage,
+ });
+
+ expect(result.path).toBe("live");
+ expect(result.last30Days).toEqual({ pct: 100, pairs: 1 });
+ // Both live reads must route through the canonical-source subquery so the
+ // collapse fires in SQL — pin the marker so a regression that reverts to
+ // a raw `findMany` (double-counting) fails here.
+ for (const call of QUERY_RAW.mock.calls) {
+ const sql = String(call[0]);
+ expect(sql).toContain("DISTINCT ON");
+ expect(sql).toContain("date_trunc('day'");
+ }
+ });
+
it("probes coverage when the caller omits the map", async () => {
const coverage = new Map([]);
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
- MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
+ QUERY_RAW.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const result = await computeBpInTargetFastPath({
userId: "user-fresh",
@@ -331,7 +377,7 @@ describe("computeBpInTargetFastPath", () => {
]);
FULLY_COVERED.mockReturnValue(true);
PROBE.mockResolvedValue(coverage);
- MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
+ QUERY_RAW.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const result = await computeBpInTargetFastPath({
userId: "user-honolulu",
@@ -343,7 +389,7 @@ describe("computeBpInTargetFastPath", () => {
expect(result.path).toBe("live");
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
- expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(2);
+ expect(QUERY_RAW).toHaveBeenCalledTimes(2);
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
const bpCall = calls.find(
@@ -362,7 +408,7 @@ describe("computeBpInTargetFastPath", () => {
]);
FULLY_COVERED.mockReturnValue(true);
PROBE.mockResolvedValue(coverage);
- MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
+ QUERY_RAW.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const result = await computeBpInTargetFastPath({
userId: "user-tokyo",
diff --git a/src/lib/analytics/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts
index 6dfba6775..0d8bae265 100644
--- a/src/lib/analytics/bp-in-target-fast-path.ts
+++ b/src/lib/analytics/bp-in-target-fast-path.ts
@@ -54,6 +54,10 @@ import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
+import {
+ buildSourceRankCase,
+ canonicalMeasurementsFrom,
+} from "@/lib/analytics/source-rank-sql";
import {
probeRollupCoverage,
type RollupCoverageMap,
@@ -454,9 +458,33 @@ async function computeFromLive(
const DAY_MS = 24 * 60 * 60 * 1000;
const bpInTargetSince = new Date(now.getTime() - 365 * DAY_MS);
+ // v1.18.10 I-1 — collapse overlapping sources before windowing. BP is a
+ // multi-source metric (a Withings reading mirrored into Apple Health is two
+ // rows for one event). Without the collapse the live/cold path double-counts
+ // the shared reading in both the in-target denominator and the
+ // recency-weighted grade, returning a different number than the rollup/warm
+ // path (which reads source-collapsed `measurement_rollups`). Pick the
+ // ladder-winning source per (type, day) so cold == warm (web↔iOS parity).
+ const priorityJson = await loadUserSourcePriority(userId);
+ const rankUnqualified = buildSourceRankCase(
+ priorityJson,
+ '"type"',
+ '"source"',
+ );
+
const [sysData, diaData] = await Promise.all([
- fetchSeriesChunked(userId, "BLOOD_PRESSURE_SYS", bpInTargetSince),
- fetchSeriesChunked(userId, "BLOOD_PRESSURE_DIA", bpInTargetSince),
+ fetchSeriesChunked(
+ userId,
+ "BLOOD_PRESSURE_SYS",
+ bpInTargetSince,
+ rankUnqualified,
+ ),
+ fetchSeriesChunked(
+ userId,
+ "BLOOD_PRESSURE_DIA",
+ bpInTargetSince,
+ rankUnqualified,
+ ),
]);
const rowCount = sysData.length + diaData.length;
@@ -499,34 +527,40 @@ async function computeFromLive(
}
/**
- * Cursor-paged read of one BP type. Mirrors the route's
- * `fetchMeasurementSeriesChunked` shape but pruned to the only two
- * fields the in-target helper consumes (`measuredAt` + `value`) so the
- * Prisma round-trip stays minimal on the cold fallback path.
+ * Source-collapsed read of one BP type over the trailing 365 days.
+ *
+ * v1.18.10 I-1 — routes through `canonicalMeasurementsFrom` so a reading that
+ * exists under two sources (Withings + its Apple-Health mirror) is counted
+ * once per (type, day), matching the rollup fast-path. Pruned to the two
+ * fields the in-target helper consumes (`measuredAt` + `value`). Per-event
+ * rows of the winning source survive the collapse, so the downstream ±5-min
+ * pairing still sees individual readings. BP is low-volume even on multi-year
+ * accounts, so one source-collapsed query replaces the prior cursor paging.
*/
-const CHUNK = 5000;
-
async function fetchSeriesChunked(
userId: string,
type: "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA",
+ // The 365-day window is expressed as a SQL interval inside the canonical
+ // subquery; the bound `Date` arg is retained for call-site symmetry.
since: Date,
+ rankUnqualified: string,
): Promise {
- const out: BpReading[] = [];
- let cursorId: string | undefined;
- for (let page = 0; page < 1000; page++) {
- const chunk = await prisma.measurement.findMany({
- where: { userId, type, measuredAt: { gte: since }, deletedAt: null },
- orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
- select: { id: true, measuredAt: true, value: true },
- take: CHUNK,
- ...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : {}),
- });
- if (chunk.length === 0) break;
- for (const row of chunk) {
- out.push({ measuredAt: row.measuredAt, value: row.value });
- }
- if (chunk.length < CHUNK) break;
- cursorId = chunk[chunk.length - 1].id;
- }
- return out;
+ void since;
+ const rows = await prisma.$queryRawUnsafe<
+ Array<{ measured_at: Date; value: number }>
+ >(
+ `
+ SELECT
+ m."measured_at" AS measured_at,
+ m."value"::double precision AS value
+ FROM ${canonicalMeasurementsFrom(rankUnqualified, "365 days")}
+ WHERE m."type" = '${type}'
+ ORDER BY m."measured_at" ASC
+ `,
+ userId,
+ );
+ return rows.map((r) => ({
+ measuredAt: new Date(r.measured_at),
+ value: Number(r.value),
+ }));
}
diff --git a/src/lib/insights/__tests__/comprehensive-aggregator.test.ts b/src/lib/insights/__tests__/comprehensive-aggregator.test.ts
index a2fc9143f..a07c4c200 100644
--- a/src/lib/insights/__tests__/comprehensive-aggregator.test.ts
+++ b/src/lib/insights/__tests__/comprehensive-aggregator.test.ts
@@ -170,12 +170,10 @@ describe("buildComprehensiveAggregate", () => {
},
]);
- // v1.4.38 W-F — sys + dia merged into a single `findMany`
- // (`type: { in: [...] }`) → one mock call carrying both rows
- // (empty here since the rollup-fresh fixture doesn't exercise
- // the BP pairing). The aggregator partitions by type in JS so
- // the bpRawRows.sys / .dia byte-shape is preserved.
- FIND_MANY.mockResolvedValueOnce([]);
+ // v1.18.10 I-1 — bpRawRows now reads source-collapsed rows via
+ // `$queryRawUnsafe` (3rd UNSAFE call: narrow, latests, bpRaw). Empty
+ // here since the rollup-fresh fixture doesn't exercise BP pairing.
+ UNSAFE.mockResolvedValueOnce([]);
const result = await buildComprehensiveAggregate("user-rollup-fresh");
const weight = result.summaries.WEIGHT;
@@ -210,9 +208,10 @@ describe("buildComprehensiveAggregate", () => {
// DISTINCT-ON latest moved to `$queryRawUnsafe` (2 calls). Neither
// path runs the legacy heavy COUNT/MIN/MAX/AVG query.
expect(RAW).toHaveBeenCalledTimes(2);
- expect(UNSAFE).toHaveBeenCalledTimes(2);
- // v1.4.38 W-F — sys + dia merged into a single round-trip.
- expect(FIND_MANY).toHaveBeenCalledTimes(1);
+ // v1.18.10 I-1 — narrow + latests + bpRaw all run on `$queryRawUnsafe`.
+ expect(UNSAFE).toHaveBeenCalledTimes(3);
+ // bpRawRows no longer uses `measurement.findMany`.
+ expect(FIND_MANY).not.toHaveBeenCalled();
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1);
});
});
@@ -226,9 +225,10 @@ describe("buildComprehensiveAggregate", () => {
// 2. latests — empty.
// No firstMeasurementAt query when totalMeasurements === 0.
RAW.mockResolvedValueOnce([]);
- UNSAFE.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
- // v1.4.38 W-F — sys + dia merged into one `findMany`.
- FIND_MANY.mockResolvedValueOnce([]);
+ // heavy, latests, bpRaw — all empty.
+ UNSAFE.mockResolvedValueOnce([])
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([]);
const result = await buildComprehensiveAggregate("user-empty");
@@ -239,9 +239,9 @@ describe("buildComprehensiveAggregate", () => {
expect(result.firstMeasurementAt).toBeNull();
expect(result.totalMeasurements).toBe(0);
expect(RAW).toHaveBeenCalledTimes(1);
- expect(UNSAFE).toHaveBeenCalledTimes(2);
- // v1.4.38 W-F — sys + dia merged → single findMany.
- expect(FIND_MANY).toHaveBeenCalledTimes(1);
+ // v1.18.10 I-1 — heavy + latests + bpRaw all on `$queryRawUnsafe`.
+ expect(UNSAFE).toHaveBeenCalledTimes(3);
+ expect(FIND_MANY).not.toHaveBeenCalled();
// The cold path's rollup.findMany still fires (in case some
// buckets exist for a subset of types post-race), but returns [].
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1);
@@ -280,11 +280,12 @@ describe("buildComprehensiveAggregate", () => {
slope90: 0.001,
r2_90: 0.12,
},
- ]).mockResolvedValueOnce([
- { type: "WEIGHT", value: 81.4, measured_at: now },
- ]);
- // v1.4.38 W-F — sys + dia merged into one round-trip.
- FIND_MANY.mockResolvedValueOnce([]);
+ ])
+ .mockResolvedValueOnce([
+ { type: "WEIGHT", value: 81.4, measured_at: now },
+ ])
+ // v1.18.10 I-1 — bpRaw (empty) is the 3rd UNSAFE call.
+ .mockResolvedValueOnce([]);
const result = await buildComprehensiveAggregate("user-cold");
const weight = result.summaries.WEIGHT;
@@ -352,23 +353,34 @@ describe("buildComprehensiveAggregate", () => {
slope90: null,
r2_90: null,
},
- ]).mockResolvedValueOnce([
- {
- type: "BLOOD_PRESSURE_SYS",
- value: 120,
- measured_at: measuredAt,
- },
- ]);
- // v1.4.38 W-F — single merged `findMany` returning both sys + dia
- // rows tagged by `type`. The aggregator partitions in JS, so the
- // bpRawRows.sys / .dia shape stays byte-identical.
- FIND_MANY.mockResolvedValueOnce([
- { type: "BLOOD_PRESSURE_SYS", measuredAt, value: 120 },
- { type: "BLOOD_PRESSURE_DIA", measuredAt, value: 80 },
- ]);
+ ])
+ .mockResolvedValueOnce([
+ {
+ type: "BLOOD_PRESSURE_SYS",
+ value: 120,
+ measured_at: measuredAt,
+ },
+ ])
+ // v1.18.10 I-1 — bpRaw runs on `$queryRawUnsafe` through the
+ // source-collapsed canonical subquery. The query returns the
+ // already-collapsed sys + dia rows in raw SQL snake_case shape; the
+ // aggregator partitions by type in JS so the bpRawRows.sys / .dia
+ // byte-shape stays identical.
+ .mockResolvedValueOnce([
+ { type: "BLOOD_PRESSURE_SYS", measured_at: measuredAt, value: 120 },
+ { type: "BLOOD_PRESSURE_DIA", measured_at: measuredAt, value: 80 },
+ ]);
const result = await buildComprehensiveAggregate("user-bp");
expect(result.bpRawRows.sys).toEqual([{ measuredAt, value: 120 }]);
expect(result.bpRawRows.dia).toEqual([{ measuredAt, value: 80 }]);
+ // The BP pull must route through the canonical-source subquery (the
+ // collapse fires in SQL); pin the marker so a revert to a plain findMany
+ // (double-counting overlapping sources) fails here.
+ const bpSql = UNSAFE.mock.calls
+ .map((c) => String(c[0]))
+ .find((sql) => sql.includes("BLOOD_PRESSURE_SYS"));
+ expect(bpSql).toContain("user_id");
+ expect(FIND_MANY).not.toHaveBeenCalled();
});
});
diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts
index 6e3e72226..42ffb8540 100644
--- a/src/lib/insights/comprehensive-aggregator.ts
+++ b/src/lib/insights/comprehensive-aggregator.ts
@@ -302,38 +302,67 @@ function timeSubquery(
});
}
+interface BpRawRow {
+ type: string;
+ measured_at: Date;
+ value: number;
+}
+
/**
* v1.4.38 W-F — fetch the trailing-90d sys + dia raw rows in a single
- * `findMany` (`type: { in: [...] }`) rather than two parallel queries.
- * Saves one round-trip + lets Postgres consolidate the index scan.
- * Partitioning by type in JS preserves the byte-shape of the legacy
- * sys / dia arrays (ASC by measuredAt — already enforced by the SQL
- * ORDER BY).
+ * round-trip rather than two parallel queries.
+ *
+ * v1.18.10 I-1 — collapse overlapping sources before returning. BP is a
+ * multi-source metric (a Withings reading mirrored into Apple Health is two
+ * rows for one event). The legacy `findMany` returned every source's row, so
+ * `computeBpInTargetPct` and the recency-weighted grade double-counted the
+ * shared reading — and a rollup-warm account (which reads source-collapsed
+ * `measurement_rollups` via `bp-in-target-fast-path`) returned a different
+ * BP percentage / grade than a rollup-cold account on identical data. Routing
+ * both raw pulls through `canonicalMeasurementsFrom` picks the ladder-winning
+ * source per (type, day) so the cold/live path matches the warm/rollup path
+ * (web↔iOS parity). `BLOOD_PRESSURE_SYS/DIA` are already in `RANKED_TYPES`.
+ *
+ * Per-event rows survive the collapse (the subquery keeps every reading of the
+ * winning source per day, not a daily mean), so the downstream ±5-min pairing
+ * still sees individual readings. Partitioning by type in JS preserves the
+ * byte-shape of the legacy sys / dia arrays (ASC by measuredAt).
*/
async function fetchBpRawRows(
userId: string,
+ // The 90-day window is expressed as a SQL interval inside the canonical
+ // subquery (parity with the rollup fast-path's `"90 days"`); the bound
+ // `Date` arg is retained for call-site symmetry and is exactly 90 days.
ninetyDaysAgo: Date,
+ // The source-rank CASE built once by the caller (over unqualified
+ // `"type"`/`"source"` columns) — reused so the BP pull doesn't re-load the
+ // user's source-priority blob the caller already resolved.
+ rankUnqualified: string,
): Promise<{
sys: Array<{ measuredAt: Date; value: number }>;
dia: Array<{ measuredAt: Date; value: number }>;
}> {
- const rows = await prisma.measurement.findMany({
- where: {
- userId,
- type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
- measuredAt: { gte: ninetyDaysAgo },
- deletedAt: null,
- },
- orderBy: { measuredAt: "asc" },
- select: { type: true, measuredAt: true, value: true },
- });
+ void ninetyDaysAgo;
+ const rows = await prisma.$queryRawUnsafe(
+ `
+ SELECT
+ m."type"::text AS type,
+ m."measured_at" AS measured_at,
+ m."value"::double precision AS value
+ FROM ${canonicalMeasurementsFrom(rankUnqualified, "90 days")}
+ WHERE m."type" IN ('BLOOD_PRESSURE_SYS', 'BLOOD_PRESSURE_DIA')
+ ORDER BY m."measured_at" ASC
+ `,
+ userId,
+ );
const sys: Array<{ measuredAt: Date; value: number }> = [];
const dia: Array<{ measuredAt: Date; value: number }> = [];
for (const r of rows) {
+ const measuredAt = new Date(r.measured_at);
if (r.type === "BLOOD_PRESSURE_SYS") {
- sys.push({ measuredAt: r.measuredAt, value: r.value });
+ sys.push({ measuredAt, value: Number(r.value) });
} else if (r.type === "BLOOD_PRESSURE_DIA") {
- dia.push({ measuredAt: r.measuredAt, value: r.value });
+ dia.push({ measuredAt, value: Number(r.value) });
}
}
return { sys, dia };
@@ -481,7 +510,7 @@ async function buildFromRollups(
// readings; the per-type partition in `fetchBpRawRows` preserves
// the legacy `bpRawRows.sys` / `bpRawRows.dia` byte-shape.
timeSubquery(timings, "bpRaw", () =>
- fetchBpRawRows(userId, ninetyDaysAgo),
+ fetchBpRawRows(userId, ninetyDaysAgo, rankUnqualified),
),
timeSubquery(
timings,
@@ -694,7 +723,9 @@ async function buildFromLiveAggregate(
}),
),
// v1.4.38 W-F — bp sys + dia in one round-trip (see `fetchBpRawRows`).
- timeSubquery(timings, "bpRaw", () => fetchBpRawRows(userId, ninetyDaysAgo)),
+ timeSubquery(timings, "bpRaw", () =>
+ fetchBpRawRows(userId, ninetyDaysAgo, rankUnqualified),
+ ),
]);
const latestByType = new Map();
From 56d8cf19c58760b7b2f142f3af06db5c97258c01 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:52:10 +0200
Subject: [PATCH 13/32] fix(sources): ladder STRESS_SCORE so the source
collapse stays deterministic
STRESS_SCORE has no source-priority ladder, so metricKeyForType returns null
and the SQL source collapse falls to an alphabetical rank-90 tiebreak. It is
safe today because the only producer is the COMPUTED HRV-derived proxy, but
the instant a device-native producer (Oura daily_stress) is enabled both rows
survive and blend nondeterministically.
Add a stress metric key with an OURA-above-COMPUTED default ladder, wire it
through metricKeyForType and RANKED_TYPES, and surface it in the Sources
settings (all six locales). A guard test asserts every RANKED_TYPES member
resolves to a non-null metric key so a future ranked type can't ship unladdered.
Regenerate the OpenAPI for the additive source-priority key.
---
docs/api/openapi.yaml | 96 +++++++++++++++++++
messages/de.json | 3 +-
messages/en.json | 3 +-
messages/es.json | 3 +-
messages/fr.json | 3 +-
messages/it.json | 3 +-
messages/pl.json | 3 +-
src/components/settings/sources-section.tsx | 2 +
src/lib/analytics/source-rank-sql.ts | 6 +-
.../__tests__/cumulative-day-sum.test.ts | 21 ++++
src/lib/measurements/cumulative-day-sum.ts | 6 ++
src/lib/validations/source-priority.ts | 12 +++
12 files changed, 154 insertions(+), 7 deletions(-)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 1a90c78ad..0fd3b5992 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -3606,6 +3606,22 @@ paths:
- NIGHTSCOUT
- POLAR
- OURA
+ stress:
+ maxItems: 8
+ type: array
+ items:
+ type: string
+ enum:
+ - MANUAL
+ - WITHINGS
+ - IMPORT
+ - APPLE_HEALTH
+ - COMPUTED
+ - WHOOP
+ - FITBIT
+ - NIGHTSCOUT
+ - POLAR
+ - OURA
metricPriority:
type: object
properties:
@@ -3881,6 +3897,22 @@ paths:
- NIGHTSCOUT
- POLAR
- OURA
+ stress:
+ maxItems: 8
+ type: array
+ items:
+ type: string
+ enum:
+ - MANUAL
+ - WITHINGS
+ - IMPORT
+ - APPLE_HEALTH
+ - COMPUTED
+ - WHOOP
+ - FITBIT
+ - NIGHTSCOUT
+ - POLAR
+ - OURA
deviceTypePriority:
type: object
properties:
@@ -14406,6 +14438,22 @@ components:
- NIGHTSCOUT
- POLAR
- OURA
+ stress:
+ maxItems: 8
+ type: array
+ items:
+ type: string
+ enum:
+ - MANUAL
+ - WITHINGS
+ - IMPORT
+ - APPLE_HEALTH
+ - COMPUTED
+ - WHOOP
+ - FITBIT
+ - NIGHTSCOUT
+ - POLAR
+ - OURA
metricPriority:
type: object
properties:
@@ -14681,6 +14729,22 @@ components:
- NIGHTSCOUT
- POLAR
- OURA
+ stress:
+ maxItems: 8
+ type: array
+ items:
+ type: string
+ enum:
+ - MANUAL
+ - WITHINGS
+ - IMPORT
+ - APPLE_HEALTH
+ - COMPUTED
+ - WHOOP
+ - FITBIT
+ - NIGHTSCOUT
+ - POLAR
+ - OURA
additionalProperties: false
deviceTypePriority:
type: object
@@ -15002,6 +15066,22 @@ components:
- NIGHTSCOUT
- POLAR
- OURA
+ stress:
+ maxItems: 8
+ type: array
+ items:
+ type: string
+ enum:
+ - MANUAL
+ - WITHINGS
+ - IMPORT
+ - APPLE_HEALTH
+ - COMPUTED
+ - WHOOP
+ - FITBIT
+ - NIGHTSCOUT
+ - POLAR
+ - OURA
metricPriority:
type: object
properties:
@@ -15277,6 +15357,22 @@ components:
- NIGHTSCOUT
- POLAR
- OURA
+ stress:
+ maxItems: 8
+ type: array
+ items:
+ type: string
+ enum:
+ - MANUAL
+ - WITHINGS
+ - IMPORT
+ - APPLE_HEALTH
+ - COMPUTED
+ - WHOOP
+ - FITBIT
+ - NIGHTSCOUT
+ - POLAR
+ - OURA
additionalProperties: false
deviceTypePriority:
type: object
diff --git a/messages/de.json b/messages/de.json
index 40d7ddbd8..364f6d650 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -3870,7 +3870,8 @@
"vo2Max": "VO2 max",
"skinTemperature": "Hauttemperatur",
"respiratoryRate": "Atemfrequenz",
- "recovery": "Erholung"
+ "recovery": "Erholung",
+ "stress": "Stress"
},
"sourceLabels": {
"WITHINGS": "Withings",
diff --git a/messages/en.json b/messages/en.json
index 54241ad76..b1600d2b8 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3870,7 +3870,8 @@
"vo2Max": "VO2 max",
"skinTemperature": "Skin temperature",
"respiratoryRate": "Respiratory rate",
- "recovery": "Recovery"
+ "recovery": "Recovery",
+ "stress": "Stress"
},
"sourceLabels": {
"WITHINGS": "Withings",
diff --git a/messages/es.json b/messages/es.json
index 4b8561ed3..6e95bb739 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3870,7 +3870,8 @@
"vo2Max": "VO2 máx.",
"skinTemperature": "Temperatura de la piel",
"respiratoryRate": "Frecuencia respiratoria",
- "recovery": "Recuperación"
+ "recovery": "Recuperación",
+ "stress": "Estrés"
},
"sourceLabels": {
"WITHINGS": "Withings",
diff --git a/messages/fr.json b/messages/fr.json
index 71ec8f395..0efc0cb1d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3870,7 +3870,8 @@
"vo2Max": "VO2 max",
"skinTemperature": "Température cutanée",
"respiratoryRate": "Fréquence respiratoire",
- "recovery": "Récupération"
+ "recovery": "Récupération",
+ "stress": "Stress"
},
"sourceLabels": {
"WITHINGS": "Withings",
diff --git a/messages/it.json b/messages/it.json
index 712b5c259..32468cad7 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3870,7 +3870,8 @@
"vo2Max": "VO2 max",
"skinTemperature": "Temperatura cutanea",
"respiratoryRate": "Frequenza respiratoria",
- "recovery": "Recupero"
+ "recovery": "Recupero",
+ "stress": "Stress"
},
"sourceLabels": {
"WITHINGS": "Withings",
diff --git a/messages/pl.json b/messages/pl.json
index 832a4d5d2..07834c1a3 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3870,7 +3870,8 @@
"vo2Max": "VO2 max",
"skinTemperature": "Temperatura skóry",
"respiratoryRate": "Częstość oddechów",
- "recovery": "Regeneracja"
+ "recovery": "Regeneracja",
+ "stress": "Stres"
},
"sourceLabels": {
"WITHINGS": "Withings",
diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx
index 237b019c7..c07fa2ae4 100644
--- a/src/components/settings/sources-section.tsx
+++ b/src/components/settings/sources-section.tsx
@@ -87,6 +87,8 @@ const METRIC_LABEL_KEYS: Record = {
skinTemperature: "settings.sections.sources.metrics.skinTemperature",
respiratoryRate: "settings.sections.sources.metrics.respiratoryRate",
recovery: "settings.sections.sources.metrics.recovery",
+ // v1.18.10 I-5 — native-vs-derived stress ladder.
+ stress: "settings.sections.sources.metrics.stress",
};
const SOURCE_LABEL_KEYS: Record = {
diff --git a/src/lib/analytics/source-rank-sql.ts b/src/lib/analytics/source-rank-sql.ts
index 5aac2e1c4..6b78f9d2a 100644
--- a/src/lib/analytics/source-rank-sql.ts
+++ b/src/lib/analytics/source-rank-sql.ts
@@ -31,7 +31,7 @@ import {
* absent: they only ever have one source per (type, day), so the collapse is
* a no-op for them and they fall into the CASE's ELSE branch.
*/
-const RANKED_TYPES: readonly MeasurementType[] = [
+export const RANKED_TYPES: readonly MeasurementType[] = [
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"RESPIRATORY_RATE",
@@ -45,6 +45,10 @@ const RANKED_TYPES: readonly MeasurementType[] = [
"PULSE",
"VO2_MAX",
"RECOVERY_SCORE",
+ // v1.18.10 I-5 — STRESS_SCORE carries a source-priority ladder (`stress`)
+ // so the SQL collapse ranks a future device-native producer above the
+ // COMPUTED proxy deterministically. No-op until a second producer lands.
+ "STRESS_SCORE",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"ACTIVE_ENERGY_BURNED",
diff --git a/src/lib/measurements/__tests__/cumulative-day-sum.test.ts b/src/lib/measurements/__tests__/cumulative-day-sum.test.ts
index d4a63684f..eb993ce5d 100644
--- a/src/lib/measurements/__tests__/cumulative-day-sum.test.ts
+++ b/src/lib/measurements/__tests__/cumulative-day-sum.test.ts
@@ -6,8 +6,10 @@ import {
CUMULATIVE_DAY_SUM_TYPES,
cumulativeMetricKey,
isCumulativeDaySumType,
+ metricKeyForType,
pickCumulativeDaySum,
} from "../cumulative-day-sum";
+import { RANKED_TYPES } from "@/lib/analytics/source-rank-sql";
/**
* v1.4.36 W4c — pickCumulativeDaySum unit tests.
@@ -156,3 +158,22 @@ describe("cumulativeMetricKey", () => {
expect(cumulativeMetricKey("TIME_IN_DAYLIGHT")).toBeNull();
});
});
+
+describe("metricKeyForType — source-priority ladder coverage (v1.18.10 I-5)", () => {
+ // Every type in RANKED_TYPES drives the SQL source-collapse CASE
+ // (`buildSourceRankCase` skips a type whose `metricKeyForType` is null).
+ // A ranked type that resolves to null would make `collapseRollupRowsBySource`
+ // fall to the alphabetical rank-90 tiebreak — nondeterministic the instant a
+ // second producer for that type is enabled. Pin the contract so a future
+ // ranked type cannot ship without its metric key.
+ it("resolves every RANKED_TYPES member to a non-null SourcePriorityMetricKey", () => {
+ for (const type of RANKED_TYPES) {
+ expect(metricKeyForType(type)).not.toBeNull();
+ }
+ });
+
+ it("ladders STRESS_SCORE so the COMPUTED-vs-device producer is deterministic", () => {
+ expect(metricKeyForType("STRESS_SCORE")).toBe("stress");
+ expect(RANKED_TYPES).toContain("STRESS_SCORE");
+ });
+});
diff --git a/src/lib/measurements/cumulative-day-sum.ts b/src/lib/measurements/cumulative-day-sum.ts
index e5c41947f..c4c55b1d1 100644
--- a/src/lib/measurements/cumulative-day-sum.ts
+++ b/src/lib/measurements/cumulative-day-sum.ts
@@ -172,6 +172,12 @@ export function metricKeyForType(
return "vo2Max";
case "RECOVERY_SCORE":
return "recovery";
+ case "STRESS_SCORE":
+ // v1.18.10 I-5 — ladder the COMPUTED-vs-device stress producer so the
+ // rollup source collapse is deterministic the instant a second producer
+ // (Oura `daily_stress`) is enabled, rather than falling to the
+ // alphabetical rank-90 tiebreak in `collapseRollupRowsBySource`.
+ return "stress";
case "SLEEP_DURATION":
return "sleep";
default:
diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts
index 128516207..b54781693 100644
--- a/src/lib/validations/source-priority.ts
+++ b/src/lib/validations/source-priority.ts
@@ -131,6 +131,13 @@ export const SOURCE_PRIORITY_METRIC_KEYS = [
// `RECOVERY_SCORE` rows distinguished by source — this ladder lets the
// same picker resolve native-above-proxy without a second engine.
"recovery",
+ // v1.18.10 I-5 — native-vs-derived stress. HealthLog computes a COMPUTED
+ // HRV-derived `STRESS_SCORE` proxy; a device-native producer (Oura
+ // `daily_stress`) is the deferred second source. Laddering it now makes the
+ // picker deterministic the instant a second producer is enabled, instead of
+ // collapsing to the alphabetical rank-90 tiebreak. Single-producer today,
+ // so the ladder is a no-op until the second producer lands.
+ "stress",
] as const;
export type SourcePriorityMetricKey =
@@ -297,6 +304,11 @@ export const DEFAULT_SOURCE_PRIORITY: Required = {
// fallback for users without a strap. v1.17.0 — Oura readiness + Polar
// nightly recovery slot below WHOOP and above the computed proxy.
recovery: ["WHOOP", "OURA", "POLAR", "COMPUTED"],
+ // v1.18.10 I-5 — native-vs-derived stress. Oura's device-native
+ // `daily_stress` (the deferred second producer) outranks HealthLog's
+ // COMPUTED HRV-derived proxy; the proxy is the fallback for users without a
+ // device-native stress signal. Single-producer today.
+ stress: ["OURA", "COMPUTED"],
};
/**
From 3fb1f3989c3f537df2ef18eaf0c06671e502d8ff Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:52:19 +0200
Subject: [PATCH 14/32] perf(measurements): cover soft-delete reads and cap the
all-time live scan
Add a covering partial index over (user_id, type, measured_at DESC) WHERE
deleted_at IS NULL (migration 0183). The hot reads all filter deleted_at and
order measured_at DESC, but the base index is ASC and excludes deleted_at, so
every type not served by the two existing partial indexes pays a heap re-check
of deleted_at over its partition. The covering partial removes the re-check for
the DISTINCT-ON-latest, list and series reads.
Cap the cold-fallback all-time aggregate in summaries-slice at a 15-year
window. It sits far beyond any real history, so it never changes a real
all-time count/min/max/mean; it exists only so a coverage-miss cold read can't
trigger an unbounded full-partition scan.
---
.../migration.sql | 40 +++++++++++++++++++
prisma/schema.prisma | 6 +++
.../__tests__/summaries-slice.test.ts | 9 +++--
src/lib/analytics/summaries-slice.ts | 25 +++++++++---
4 files changed, 72 insertions(+), 8 deletions(-)
create mode 100644 prisma/migrations/0183_v11810_measurements_live_covering_idx/migration.sql
diff --git a/prisma/migrations/0183_v11810_measurements_live_covering_idx/migration.sql b/prisma/migrations/0183_v11810_measurements_live_covering_idx/migration.sql
new file mode 100644
index 000000000..201c3c841
--- /dev/null
+++ b/prisma/migrations/0183_v11810_measurements_live_covering_idx/migration.sql
@@ -0,0 +1,40 @@
+-- v1.18.10 P-5 — covering partial index for soft-delete-filtered reads.
+--
+-- The hot reads on `measurements` all filter `deleted_at IS NULL` and order
+-- `measured_at DESC` per type — the DISTINCT-ON-latest pass, the management
+-- list, the series reads, and the per-type count. The base
+-- `(user_id, type, measured_at)` index is ASC and does NOT carry
+-- `deleted_at`, so those reads pay a heap re-check of `deleted_at` over the
+-- type partition for every candidate row.
+--
+-- Two partial indexes already cover the highest-volume types
+-- (`measurements_dense_intraday_retention_idx` for HRV/PULSE, migration 0108;
+-- `measurements_legacy_step_consolidation_idx` for ACTIVITY_STEPS, migration
+-- 0087). This index closes the gap for every OTHER type: a covering partial
+-- index keyed `(user_id, type, measured_at DESC)` over live rows only serves
+-- the latest / list / series reads without the heap re-check, and — being
+-- partial on `deleted_at IS NULL` — it carries no entries for tombstoned rows.
+--
+-- Additive + order-safe: a pure `CREATE INDEX IF NOT EXISTS`, no column add,
+-- no backfill, no rewrite of `measurements`.
+--
+-- Prisma can't express a partial-predicate index, so it lives here in raw
+-- migration SQL and is intentionally omitted from `schema.prisma` (the same
+-- treatment migrations 0087 + 0108 use; a comment on the model documents it).
+--
+-- Idempotent guard (`IF NOT EXISTS`) makes reruns safe. Forward-only.
+--
+-- Lock note (conscious trade-off): a plain, non-`CONCURRENTLY` build, so it
+-- runs inside Prisma's migration transaction and holds an ACCESS EXCLUSIVE
+-- lock on `measurements` for the build. On the largest multi-year HealthKit
+-- tenants the live row set is large, so deploy at a low-traffic window. A
+-- `CONCURRENTLY` build cannot run inside the migration transaction and is not
+-- used here to keep the migration atomic with the rest of the release.
+--
+-- Reversibility:
+-- DROP INDEX IF EXISTS "measurements_live_covering_idx";
+-- A roll-back loses only the scan acceleration; no data is touched.
+
+CREATE INDEX IF NOT EXISTS "measurements_live_covering_idx"
+ ON "measurements" ("user_id", "type", "measured_at" DESC)
+ WHERE "deleted_at" IS NULL;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 480c5eaa2..c571c1afd 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1113,6 +1113,12 @@ model Measurement {
// — see migration 0108. It accelerates the v1.10.0 dense intra-day
// retention drain's discovery + per-user scan. Same Prisma-can't-
// express-a-partial-predicate reason it is omitted here.
+ // `measurements_live_covering_idx` is a covering partial index over
+ // `(user_id, type, measured_at DESC) WHERE deleted_at IS NULL` — see
+ // migration 0183 (v1.18.10 P-5). It serves the DISTINCT-ON-latest, list,
+ // and series reads for every type NOT covered by the two partials above
+ // and removes the `deleted_at` heap re-check. Same Prisma-can't-express-a-
+ // partial-predicate reason it is omitted here.
@@map("measurements")
}
diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts
index 8f4085299..00972918e 100644
--- a/src/lib/analytics/__tests__/summaries-slice.test.ts
+++ b/src/lib/analytics/__tests__/summaries-slice.test.ts
@@ -592,15 +592,18 @@ describe("computeSummariesSlice", () => {
// 90-day cap to `canonicalMeasurementsFrom(rank, "90 days")`. The
// helper lives in `@/lib/analytics/source-rank-sql` (stubbed
// above), so pin the contract at the call boundary: the slice
- // must ask for the 90-day window. The `allTime` aggregate
- // deliberately calls it WITHOUT an interval (no cap — all-time
- // count/min/max/mean must scan every row).
+ // must ask for the 90-day window.
expect(canonicalMeasurementsFrom).toHaveBeenCalledWith(
expect.any(String),
"90 days",
);
+ // v1.18.10 P-7 — the `allTime` aggregate is now capped at a generous
+ // 15-year scan-DoS floor (far beyond any real history) so a
+ // coverage-miss cold read can't trigger an unbounded full-partition
+ // scan. It no longer calls the helper without an interval.
expect(canonicalMeasurementsFrom).toHaveBeenCalledWith(
expect.any(String),
+ "15 years",
);
});
});
diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts
index 2b4d481ba..695068180 100644
--- a/src/lib/analytics/summaries-slice.ts
+++ b/src/lib/analytics/summaries-slice.ts
@@ -81,11 +81,22 @@ import { resolveUserTimezone } from "@/lib/tz/resolver";
const DAY_MS = 24 * 60 * 60 * 1000;
+/**
+ * v1.18.10 P-7 — upper bound on the cold-fallback `allTime` aggregate's
+ * scan window. Sits far beyond any real account's history (the earliest prod
+ * measurements are < 10 years old) so it never changes a real all-time
+ * count / min / max / mean; it exists purely so a coverage-miss cold read
+ * can't trigger an unbounded full-partition scan. Whitelisted by
+ * `canonicalMeasurementsFrom`'s interval regex (` years`).
+ */
+const ALL_TIME_LIVE_CAP_INTERVAL = "15 years" as const;
+
/**
* v1.4.48 — all-time aggregate row used on the cold-mount fallback
* path. Holds the linearly composable columns (`count / min / max /
* mean`) that must reflect every row the user has ever logged for
- * that type, so this query intentionally has no `measured_at` cap.
+ * that type, capped at `ALL_TIME_LIVE_CAP_INTERVAL` (v1.18.10 P-7) as a
+ * scan-DoS floor.
*/
interface AllTimeAggregateRow {
type: string;
@@ -608,9 +619,13 @@ async function computeFromRollups(userId: string): Promise {
* v1.4.48 — split into two parallel queries:
*
* 1. `allTime` — the linearly composable columns (`count / min /
- * max / mean`) keep the full-partition scan because they must
- * reflect every row the user has ever logged. No `measured_at`
- * cap.
+ * max / mean`). v1.18.10 P-7 caps this at `ALL_TIME_LIVE_CAP_INTERVAL`
+ * (15 years) so an un-windowed cold-fallback read on a coverage-miss
+ * tenant can never scan an unbounded partition. The cap is a scan-DoS
+ * floor, not a semantic change: it sits far beyond any real account's
+ * history (the earliest prod rows are < 10 years old) and the steady
+ * state is rollup-backed anyway, so it only ever bounds the cold path.
+ *
*
* 2. `windowed` — `avg7 / avg30` and the slope/r² tuples are all
* already filtered to a 7/30/90 day window inside the SELECT.
@@ -648,7 +663,7 @@ async function computeFromLiveAggregate(
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
AVG(m."value")::double precision AS mean_value
- FROM ${canonicalMeasurementsFrom(rankUnqualified)}
+ FROM ${canonicalMeasurementsFrom(rankUnqualified, ALL_TIME_LIVE_CAP_INTERVAL)}
GROUP BY m."type"
`,
userId,
From aae65b8bb14ada6aedb3a9e2ca1b12704e182f77 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:56:20 +0200
Subject: [PATCH 15/32] feat(geo): default IP geolocation to the online
ipwho.is resolver
Make the keyless ipwho.is HTTPS lookup the primary resolver for every
self-host. The bundled MaxMind GeoLite2 offline tier becomes an optional
fallback, consulted only when the online lookup misses or when egress is
disabled. A missing offline tier is now the expected baseline rather than
a gap, so the one-shot admin alert fires only when neither resolver can
produce a location.
---
src/lib/__tests__/geo-asn.test.ts | 138 ++++++++++++++++++++----------
src/lib/geo.ts | 55 ++++++------
2 files changed, 122 insertions(+), 71 deletions(-)
diff --git a/src/lib/__tests__/geo-asn.test.ts b/src/lib/__tests__/geo-asn.test.ts
index 0664b16a5..6a59dfc7b 100644
--- a/src/lib/__tests__/geo-asn.test.ts
+++ b/src/lib/__tests__/geo-asn.test.ts
@@ -16,7 +16,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
* - Reader throws are swallowed (the helper is called from a
* fire-and-forget audit-log path; a thrown error would surface as
* an unhandled rejection).
- * - The offline-first `lookupIpLocation` path picks the German city
+ * - The online-first `lookupIpLocation` path (v1.18.10 W7) resolves via
+ * `ipwho.is` by default and only consults the offline MMDB as a
+ * fallback (online miss or egress disabled), picking the German city
* name first, English second, with the country ISO code from
* `country` or `registered_country`.
*
@@ -171,57 +173,29 @@ describe("lookupIpAsn — offline ASN resolver (v1.4.27 B3)", () => {
});
});
-describe("lookupIpLocation — offline-first city resolver (v1.4.27 B3)", () => {
- it("prefers the German exonym from the MMDB Names record", async () => {
+// v1.18.10 (W7) — online-first resolver. The `ipwho.is` HTTPS lookup is the
+// DEFAULT path; the bundled GeoLite2 offline tier is an OPTIONAL fallback
+// consulted only when the online lookup misses (provider down / non-ok) or
+// when egress is disabled via `IP_GEO_LOOKUP_DISABLED=1`.
+describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () => {
+ // Helper: a non-ok online response forces the offline fallback to run.
+ function onlineMiss(): Response {
+ return new Response("", {
+ status: 503,
+ headers: { "content-type": "application/json" },
+ });
+ }
+
+ it("prefers the online provider even when the offline DB is present", async () => {
readerState.cityExists = true;
readerState.city = () => ({
city: { names: { de: "München", en: "Munich" } },
country: { iso_code: "DE" },
});
- const fetchSpy = vi.fn();
- vi.stubGlobal("fetch", fetchSpy);
- const { lookupIpLocation } = await import("../geo");
-
- expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE");
- // Offline hit — no online fallback was needed.
- expect(fetchSpy).not.toHaveBeenCalled();
- });
-
- it("falls back to the English city name when the German exonym is absent", async () => {
- readerState.cityExists = true;
- readerState.city = () => ({
- city: { names: { en: "Birmingham" } },
- country: { iso_code: "GB" },
- });
- const fetchSpy = vi.fn();
- vi.stubGlobal("fetch", fetchSpy);
- const { lookupIpLocation } = await import("../geo");
-
- expect(await lookupIpLocation("212.58.224.1")).toBe("Birmingham, GB");
- });
-
- it("accepts the registered_country ISO when country is absent", async () => {
- readerState.cityExists = true;
- readerState.city = () => ({
- city: { names: { en: "Geneva" } },
- registered_country: { iso_code: "CH" },
- });
- const { lookupIpLocation } = await import("../geo");
-
- expect(await lookupIpLocation("194.158.0.1")).toBe("Geneva, CH");
- });
-
- it("falls back to the online provider when the offline DB has no city/country", async () => {
- readerState.cityExists = true;
- readerState.city = () => ({ city: { names: {} } });
const fetchSpy = vi.fn().mockResolvedValue(
new Response(
new TextEncoder().encode(
- JSON.stringify({
- success: true,
- city: "Hamburg",
- country_code: "DE",
- }),
+ JSON.stringify({ success: true, city: "Berlin", country_code: "DE" }),
).buffer as ArrayBuffer,
{
status: 200,
@@ -232,11 +206,64 @@ describe("lookupIpLocation — offline-first city resolver (v1.4.27 B3)", () =>
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
- expect(await lookupIpLocation("8.8.8.8")).toBe("Hamburg, DE");
+ // Online wins by default; the offline München record is not consulted.
+ expect(await lookupIpLocation("85.214.0.1")).toBe("Berlin, DE");
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
- it("falls back to the online provider when the offline DB is missing entirely", async () => {
+ it("falls back to the offline German exonym when the online lookup misses", async () => {
+ readerState.cityExists = true;
+ readerState.city = () => ({
+ city: { names: { de: "München", en: "Munich" } },
+ country: { iso_code: "DE" },
+ });
+ const fetchSpy = vi.fn().mockResolvedValue(onlineMiss());
+ vi.stubGlobal("fetch", fetchSpy);
+ const { lookupIpLocation } = await import("../geo");
+
+ expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE");
+ // Online was tried first (and missed), then the offline tier resolved.
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("offline fallback uses the English city name when the German exonym is absent", async () => {
+ readerState.cityExists = true;
+ readerState.city = () => ({
+ city: { names: { en: "Birmingham" } },
+ country: { iso_code: "GB" },
+ });
+ const fetchSpy = vi.fn().mockResolvedValue(onlineMiss());
+ vi.stubGlobal("fetch", fetchSpy);
+ const { lookupIpLocation } = await import("../geo");
+
+ expect(await lookupIpLocation("212.58.224.1")).toBe("Birmingham, GB");
+ });
+
+ it("offline fallback accepts the registered_country ISO when country is absent", async () => {
+ readerState.cityExists = true;
+ readerState.city = () => ({
+ city: { names: { en: "Geneva" } },
+ registered_country: { iso_code: "CH" },
+ });
+ const fetchSpy = vi.fn().mockResolvedValue(onlineMiss());
+ vi.stubGlobal("fetch", fetchSpy);
+ const { lookupIpLocation } = await import("../geo");
+
+ expect(await lookupIpLocation("194.158.0.1")).toBe("Geneva, CH");
+ });
+
+ it("returns null when both online and the offline DB miss", async () => {
+ readerState.cityExists = true;
+ readerState.city = () => ({ city: { names: {} } });
+ const fetchSpy = vi.fn().mockResolvedValue(onlineMiss());
+ vi.stubGlobal("fetch", fetchSpy);
+ const { lookupIpLocation } = await import("../geo");
+
+ expect(await lookupIpLocation("8.8.8.8")).toBeNull();
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("resolves online when the offline DB is missing entirely", async () => {
readerState.cityExists = false;
const fetchSpy = vi.fn().mockResolvedValue(
new Response(
@@ -256,7 +283,24 @@ describe("lookupIpLocation — offline-first city resolver (v1.4.27 B3)", () =>
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
- it("skips the online fallback when IP_GEO_LOOKUP_DISABLED=1 and offline misses", async () => {
+ it("uses the offline tier when IP_GEO_LOOKUP_DISABLED=1 and the offline DB is present", async () => {
+ process.env.IP_GEO_LOOKUP_DISABLED = "1";
+ readerState.cityExists = true;
+ readerState.city = () => ({
+ city: { names: { de: "München" } },
+ country: { iso_code: "DE" },
+ });
+ const fetchSpy = vi.fn();
+ vi.stubGlobal("fetch", fetchSpy);
+ const { lookupIpLocation } = await import("../geo");
+
+ // Egress disabled → online short-circuits to null without a request, then
+ // the offline tier resolves.
+ expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE");
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it("returns null when IP_GEO_LOOKUP_DISABLED=1 and the offline DB is missing", async () => {
process.env.IP_GEO_LOOKUP_DISABLED = "1";
readerState.cityExists = false;
const fetchSpy = vi.fn();
diff --git a/src/lib/geo.ts b/src/lib/geo.ts
index 70d8ff6ce..d7326b0e0 100644
--- a/src/lib/geo.ts
+++ b/src/lib/geo.ts
@@ -1,19 +1,18 @@
/**
* IP geolocation lookup for audit-log enrichment.
*
- * v1.15.12 E2: online-first resolver. The baseline that always works is
- * the `ipwho.is` HTTPS lookup (free, no key) — every self-host resolves
- * a location out of the box with no MaxMind licence configured. The
+ * v1.18.10 (W7): online-first resolver. The `ipwho.is` HTTPS lookup
+ * (free, no key) is the DEFAULT path — every self-host resolves a
+ * location out of the box with no MaxMind licence configured. The
* bundled MaxMind GeoLite2-City MMDB at `/opt/geolite2/GeoLite2-City.mmdb`
- * is an OPTIONAL upgrade: when the offline databases are present
- * (`offlineGeoReady()`), the resolver prefers the microsecond-scale
- * local read for both speed and zero egress; when they are absent it
- * goes straight online.
+ * is an OPTIONAL fallback (perspective: removed later): it is consulted
+ * only when the online lookup misses (provider down / rate-limited) or
+ * when egress is disabled via `IP_GEO_LOOKUP_DISABLED=1`.
*
* 1. Private / loopback / opt-out → null (no lookup).
* 2. Per-IP cache hit → return immediately.
- * 3. Offline MMDB present → local read; on a miss fall through online.
- * 4. Otherwise → online `ipwho.is` lookup.
+ * 3. Online `ipwho.is` lookup → return on success.
+ * 4. Offline MMDB present → local read fallback on an online miss.
*
* The legacy contract still holds: `lookupIpLocation` returns a
* `"City, CC"` string or `null`, never throws. `lookupIpAsn` is the
@@ -369,31 +368,39 @@ export async function lookupIpLocation(
const cached = getCachedLocation(ip);
if (cached) return cached.value;
- // v1.15.12 E2 — online-first precedence. The optional MaxMind offline
- // tier is preferred ONLY when its databases are actually present
- // (`offlineGeoReady()`): a local hit is faster and egress-free. When
- // the offline DBs are absent — the default for a self-host with no
- // `MAXMIND_LICENSE_KEY` — we skip straight to the online provider,
- // which is the baseline that always resolves a location.
+ // v1.18.10 (W7) — online-first by default. The `ipwho.is` HTTPS lookup
+ // is now the primary resolver for every self-host: it needs no MaxMind
+ // licence and resolves a location out of the box. The bundled GeoLite2
+ // offline tier is an OPTIONAL fallback kept only for the egress-disabled
+ // case (`IP_GEO_LOOKUP_DISABLED=1`) or for an online miss when the DBs
+ // happen to be present — it is no longer the default path, and a missing
+ // offline tier is the expected baseline rather than a gap to alert on.
+ const online = await lookupIpLocationOnline(ip);
+ if (online) {
+ setCachedLocation(ip, online);
+ return online;
+ }
+
+ // Online missed (provider down, rate-limited, or egress disabled). Fall
+ // back to the offline MMDB if the operator configured one.
if (offlineGeoReady()) {
const offline = lookupIpLocationOffline(ip);
if (offline) {
setCachedLocation(ip, offline);
return offline;
}
- // Offline DBs present but this IP missed (freshly-allocated range
- // lagging the monthly GeoLite2 roll) → fall through to online.
} else {
- // No offline tier configured — fire the one-shot admin alert so the
- // maintainer can wire `MAXMIND_LICENSE_KEY` for the offline upgrade
- // when convenient. Online still resolves; the alert is informational.
- // Fire-and-forget so the audit-log path stays fast.
+ // Neither resolver produced a location: the online lookup missed (or
+ // egress is disabled) AND no offline tier is configured. That is the
+ // only genuine "no resolver at all" gap — fire the one-shot admin alert
+ // so the maintainer can wire `MAXMIND_LICENSE_KEY` or re-enable egress.
+ // The happy path (ipwho.is resolving by default) never reaches here, so
+ // a self-host without the offline DBs is not nagged on every lookup.
void notifyOfflineGeoUnavailable();
}
- const online = await lookupIpLocationOnline(ip);
- setCachedLocation(ip, online);
- return online;
+ setCachedLocation(ip, null);
+ return null;
}
/**
From ed99d207e426dc8365e984ab5d48978a286ba582 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:56:42 +0200
Subject: [PATCH 16/32] fix(settings): align the Vorsorge settings card with
the shared pattern
Adopt the standard SettingsCard + SettingsCardHeader contract for the
three Vorsorge settings groups (view, order, manage): bordered card
surface, neutral muted section icon, text-lg title, and a short muted
description, replacing the bare sections with text-sm headings so the
surface reads like every other Settings section. Add a reorder
description string across all locales.
---
messages/de.json | 1 +
messages/en.json | 1 +
messages/es.json | 1 +
messages/fr.json | 1 +
messages/it.json | 1 +
messages/pl.json | 1 +
.../vorsorge-settings.tsx | 157 ++++++++++--------
7 files changed, 90 insertions(+), 73 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index 17022908c..ee24a0605 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -6472,6 +6472,7 @@
"viewDescription": "Lege fest, ob die Einträge als Karten oder als kompakte Liste angezeigt werden.",
"reorder": {
"heading": "Reihenfolge",
+ "description": "Ziehe die Einträge, um ihre Reihenfolge festzulegen.",
"empty": "Noch keine Einträge zum Sortieren."
}
},
diff --git a/messages/en.json b/messages/en.json
index 2f03a8198..e531d45ae 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6472,6 +6472,7 @@
"viewDescription": "Choose whether entries show as cards or as a compact list.",
"reorder": {
"heading": "Order",
+ "description": "Drag the entries to set the order they appear in.",
"empty": "No entries to reorder yet."
}
},
diff --git a/messages/es.json b/messages/es.json
index 5612ce688..45552dd4d 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -6472,6 +6472,7 @@
"viewDescription": "Elige si las entradas se muestran como tarjetas o como lista compacta.",
"reorder": {
"heading": "Orden",
+ "description": "Arrastra las entradas para definir el orden en que aparecen.",
"empty": "Aún no hay entradas para ordenar."
}
},
diff --git a/messages/fr.json b/messages/fr.json
index fd4b6835b..bd8cfacd8 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -6472,6 +6472,7 @@
"viewDescription": "Choisissez si les entrées s'affichent en cartes ou en liste compacte.",
"reorder": {
"heading": "Ordre",
+ "description": "Faites glisser les entrées pour définir leur ordre d'affichage.",
"empty": "Aucune entrée à réordonner pour l'instant."
}
},
diff --git a/messages/it.json b/messages/it.json
index 1b26acae6..a2f2e8def 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -6472,6 +6472,7 @@
"viewDescription": "Scegli se le voci appaiono come schede o come elenco compatto.",
"reorder": {
"heading": "Ordine",
+ "description": "Trascina le voci per definire l'ordine in cui appaiono.",
"empty": "Nessuna voce da riordinare."
}
},
diff --git a/messages/pl.json b/messages/pl.json
index 4b7dc46f0..d0d4ade09 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -6472,6 +6472,7 @@
"viewDescription": "Wybierz, czy pozycje mają być pokazywane jako karty czy jako zwarta lista.",
"reorder": {
"heading": "Kolejność",
+ "description": "Przeciągnij pozycje, aby ustawić kolejność ich wyświetlania.",
"empty": "Brak pozycji do uporządkowania."
}
},
diff --git a/src/components/measurement-reminders/vorsorge-settings.tsx b/src/components/measurement-reminders/vorsorge-settings.tsx
index e710f983c..979394b31 100644
--- a/src/components/measurement-reminders/vorsorge-settings.tsx
+++ b/src/components/measurement-reminders/vorsorge-settings.tsx
@@ -10,10 +10,14 @@
* `useModuleListPrefs("vorsorge")`; the toggles persist through the existing
* reminder PATCH route.
*/
+import { ArrowUpDown, LayoutGrid, ListChecks } from "lucide-react";
+
import { Card, CardContent } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
+import { SettingsCard } from "@/components/settings/settings-card";
+import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ModuleViewToggle } from "@/components/module-list/module-view-toggle";
import {
ModuleOrderEditor,
@@ -52,82 +56,89 @@ export function VorsorgeSettings() {
: t("measurementReminders.selfPlanned"),
}));
+ // v1.18.10 (W7) — the three Vorsorge settings groups adopt the shared
+ // `SettingsCard` + `SettingsCardHeader` contract (rounded-xl bordered
+ // surface, neutral muted icon, `text-lg` title, short muted description)
+ // so this surface reads like every other Settings section instead of bare
+ // `` blocks with `text-sm` headings.
return (
-
-
- {resolveLabel(reminder, t)}
-
-
- update.mutate({
- id: reminder.id,
- body: { enabled: next },
- })
- }
- disabled={update.isPending}
- // L3 — label each row's Switch by its own name span so SR
- // users hear the reminder name (plus the switch role's
- // on/off state) instead of N identical generic labels.
- aria-labelledby={`vorsorge-reminder-label-${reminder.id}`}
- />
-
+
+ {resolveLabel(reminder, t)}
+
+
+ update.mutate({
+ id: reminder.id,
+ body: { enabled: next },
+ })
+ }
+ disabled={update.isPending}
+ // L3 — label each row's Switch by its own name span so SR
+ // users hear the reminder name (plus the switch role's
+ // on/off state) instead of N identical generic labels.
+ aria-labelledby={`vorsorge-reminder-label-${reminder.id}`}
+ />
+
+ ))}
+
+
+ )}
+
+
);
}
From 17a7d1c74772a75f006b5c56bc0001e3c9dd45ae Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:56:49 +0200
Subject: [PATCH 17/32] fix(admin): neutralise the danger-zone card surface
Render the danger-zone section on the shared SettingsCard surface
(bg-card + border-border) instead of the destructive-tinted card edge.
A red card surface reads as alarming rather than premium; the danger
signal now lives only in the destructive delete button and its typed
confirmation dialog, with the AlertTriangle header icon still flagging
intent.
---
src/components/admin/danger-zone-section.tsx | 17 +++++++++--------
1 file changed, 9 insertions(+), 8 deletions(-)
diff --git a/src/components/admin/danger-zone-section.tsx b/src/components/admin/danger-zone-section.tsx
index 4135d3a4a..923099bde 100644
--- a/src/components/admin/danger-zone-section.tsx
+++ b/src/components/admin/danger-zone-section.tsx
@@ -17,6 +17,7 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { apiDelete } from "@/lib/api/api-fetch";
@@ -83,13 +84,13 @@ export function DangerZoneSection() {
});
return (
- // v1.18.1 E4 — adopt the shared card shape (rounded-xl border + the
- // `SettingsCardHeader` icon/title/description/status layout) so the
- // Danger Zone reads like every other admin section. The destructive
- // tint + AlertTriangle icon keep it visually flagged as dangerous;
- // the delete affordance now sits in the header status slot, properly
- // aligned beside its label rather than floating below a lone icon.
-
+ // v1.18.10 (W7) — the card SURFACE is neutral like every other admin /
+ // settings section (`SettingsCard`: bg-card + border-border): a red card
+ // edge reads as alarming, not premium. The danger signal lives ONLY in
+ // the destructive delete button + its typed-confirmation dialog, which
+ // are alarm enough. The header keeps the `AlertTriangle` icon so the
+ // section still flags its intent.
+
)}
-
+
);
}
From 5b6544ec637c2e4d7c1810cc7f50f3915ab31ef6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:56:55 +0200
Subject: [PATCH 18/32] feat(admin): mark the feedback feature as deprecated
Add a discreet 'Deprecated' badge and a short note ('will be removed in
a future version') to the bug-report settings entry. The functionality
stays intact; this only signals the upcoming removal 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 ++
src/components/admin/bug-report-section.tsx | 22 ++++++++++++++++++++-
7 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/messages/de.json b/messages/de.json
index ee24a0605..47d5f25f2 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4933,6 +4933,8 @@
"bugReportGithubDescription": "Hinterlege GitHub-Issue-Token und Repository für eingehende Bugreports.",
"bugReportEnabledLabel": "Bug-Reports aktiviert",
"bugReportEnabledDescription": "Wenn deaktiviert, ist der Report-Button im Frontend nicht mehr sichtbar — Token und Repository bleiben gespeichert.",
+ "bugReportDeprecatedBadge": "Veraltet",
+ "bugReportDeprecatedNote": "Diese Funktion ist veraltet und wird in einer künftigen Version entfernt.",
"assistant": {
"title": "Assistenz-Oberflächen",
"description": "Jeder Toggle versteckt eine modellgestützte Oberfläche app-weit, ohne die Provider-Konfiguration zu entfernen. Der Hauptschalter erzwingt jede Unter-Option aus.",
diff --git a/messages/en.json b/messages/en.json
index e531d45ae..d33b013d3 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4933,6 +4933,8 @@
"bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.",
"bugReportEnabledLabel": "Bug reports enabled",
"bugReportEnabledDescription": "When off, the in-app report button hides — token and repository stay configured.",
+ "bugReportDeprecatedBadge": "Deprecated",
+ "bugReportDeprecatedNote": "This feature is deprecated and will be removed in a future version.",
"assistant": {
"title": "Assistant Surfaces",
"description": "Each toggle hides a model-driven surface app-wide without removing the provider configuration. The master kills every sub-flag at once.",
diff --git a/messages/es.json b/messages/es.json
index 45552dd4d..84096f693 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4933,6 +4933,8 @@
"bugReportGithubDescription": "Configura el token de issues de GitHub y el repositorio para los informes de errores entrantes.",
"bugReportEnabledLabel": "Informes de errores activados",
"bugReportEnabledDescription": "Cuando está desactivado, el botón de informe en la app se oculta; el token y el repositorio permanecen configurados.",
+ "bugReportDeprecatedBadge": "Obsoleto",
+ "bugReportDeprecatedNote": "Esta función está obsoleta y se eliminará en una versión futura.",
"assistant": {
"title": "Superficies del asistente",
"description": "Cada conmutador oculta una superficie impulsada por el modelo en toda la aplicación sin retirar la configuración del proveedor. El interruptor maestro fuerza cada opción secundaria a off.",
diff --git a/messages/fr.json b/messages/fr.json
index bd8cfacd8..bdedc77d1 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4933,6 +4933,8 @@
"bugReportGithubDescription": "Configurez le jeton d'issues GitHub et le dépôt pour les rapports de bug entrants.",
"bugReportEnabledLabel": "Rapports de bug activés",
"bugReportEnabledDescription": "Lorsque cette option est désactivée, le bouton de signalement dans l'app est masqué — le jeton et le dépôt restent configurés.",
+ "bugReportDeprecatedBadge": "Obsolète",
+ "bugReportDeprecatedNote": "Cette fonctionnalité est obsolète et sera supprimée dans une version future.",
"assistant": {
"title": "Surfaces d'assistance",
"description": "Chaque bascule masque une surface pilotée par le modèle dans toute l'application sans retirer la configuration du fournisseur. Le commutateur principal force chaque option secondaire à off.",
diff --git a/messages/it.json b/messages/it.json
index a2f2e8def..44b1d4ff0 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4933,6 +4933,8 @@
"bugReportGithubDescription": "Configura il token delle issue di GitHub e il repository per le segnalazioni di bug in arrivo.",
"bugReportEnabledLabel": "Segnalazioni di bug attive",
"bugReportEnabledDescription": "Se disattivato, il pulsante di segnalazione nell'app viene nascosto: token e repository restano configurati.",
+ "bugReportDeprecatedBadge": "Obsoleto",
+ "bugReportDeprecatedNote": "Questa funzione è obsoleta e verrà rimossa in una versione futura.",
"assistant": {
"title": "Superfici dell'assistente",
"description": "Ogni interruttore nasconde una superficie guidata dal modello in tutta l'app senza rimuovere la configurazione del provider. Il master forza ogni opzione secondaria su off.",
diff --git a/messages/pl.json b/messages/pl.json
index d0d4ade09..05a1d4942 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4933,6 +4933,8 @@
"bugReportGithubDescription": "Skonfiguruj token zgłoszeń GitHub i repozytorium dla przychodzących zgłoszeń błędów.",
"bugReportEnabledLabel": "Zgłaszanie błędów włączone",
"bugReportEnabledDescription": "Gdy wyłączone, przycisk zgłaszania w aplikacji jest ukryty — token i repozytorium pozostają skonfigurowane.",
+ "bugReportDeprecatedBadge": "Wycofane",
+ "bugReportDeprecatedNote": "Ta funkcja jest wycofywana i zostanie usunięta w przyszłej wersji.",
"assistant": {
"title": "Powierzchnie asystenta",
"description": "Każdy przełącznik ukrywa powierzchnię opartą na modelu w całej aplikacji bez usuwania konfiguracji dostawcy. Główny wyłącznik wymusza wyłączenie każdej opcji podrzędnej.",
diff --git a/src/components/admin/bug-report-section.tsx b/src/components/admin/bug-report-section.tsx
index 36c4c7275..7bbad77b9 100644
--- a/src/components/admin/bug-report-section.tsx
+++ b/src/components/admin/bug-report-section.tsx
@@ -3,6 +3,7 @@
import { useState } from "react";
import { Bug, Loader2 } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -54,7 +55,26 @@ export function BugReportSection() {
+ {t("admin.bugReportDeprecatedBadge")}
+
+ }
+ description={
+ <>
+
{t("admin.bugReportGithubDescription")}
+
+ {t("admin.bugReportDeprecatedNote")}
+
+ >
+ }
status={configured ? : null}
/>
From 6ee7df30986a463f6b309a199683e7ec24669b95 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:57:01 +0200
Subject: [PATCH 19/32] fix(nav): keep mobile More-menu labels on one line
Trim the longest label ('Benachrichtigungen') with truncate (nowrap +
ellipsis) instead of break-words so every More-hub tile reads on a
single line on a phone, matching its neighbours. The full label stays
discoverable via the title attribute; the desktop layout is untouched.
---
src/components/layout/bottom-nav.tsx | 21 ++++++++++++---------
1 file changed, 12 insertions(+), 9 deletions(-)
diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx
index f2653962d..b75121493 100644
--- a/src/components/layout/bottom-nav.tsx
+++ b/src/components/layout/bottom-nav.tsx
@@ -247,15 +247,18 @@ export function BottomNav() {
)}
>
- {/* v1.18.0 — the label must stay inside the tile across all
- six locales. The longest single-word label (German
- "Benachrichtigungen") has no natural break opportunity, so
- `min-w-0` alone is not enough — the unbroken word forces the
- span past the grid track and overflows the tile. `min-w-0`
- lets the flex child shrink below its content width,
- `break-words` introduces a break inside the long word, and
- the auto-height tile (`min-h-14`) absorbs the second line. */}
-
+ {/* v1.18.10 (W7) — keep every label on ONE line. The longest
+ single-word label (German "Benachrichtigungen") used to
+ wrap to two lines via `break-words`, which left the tile
+ visually unbalanced beside its single-line neighbours.
+ `min-w-0` lets the flex child shrink below its content
+ width and `truncate` (nowrap + ellipsis) trims the
+ overflow instead of breaking the word. `title` keeps the
+ full label discoverable on hover/long-press. */}
+
{t(item.tKey)}
From 8b5235c25b5bb5a2574ba3f722529a5856604291 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:58:06 +0200
Subject: [PATCH 20/32] fix(insights): ground daily-briefing numbers against
precomputed signals
The daily-briefing block (paragraph / signalsOfDay / keyFindings) was
schema-shaped but never number-checked the way recommendations[] are: a
restated figure could drift from the snapshot with no server rejection.
Add a grounding cross-check that asserts every number the briefing prose
states traces to a features.signalsOfDay figure (latest / avg / delta)
within a rounding tolerance. Wire it into both generation sites with one
corrective retry, then strip the briefing rather than persist a
fabricated number. Also gate the higher-temperature paragraph reroll on
the same check so a phrasing reroll cannot drift a figure.
---
src/app/api/insights/generate/route.ts | 64 +++++
.../ai/__tests__/briefing-grounding.test.ts | 112 +++++++++
src/lib/ai/briefing-grounding.ts | 235 ++++++++++++++++++
src/lib/insights/comprehensive-generate.ts | 81 ++++++
4 files changed, 492 insertions(+)
create mode 100644 src/lib/ai/__tests__/briefing-grounding.test.ts
create mode 100644 src/lib/ai/briefing-grounding.ts
diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts
index 686dfed8e..b979a59d7 100644
--- a/src/app/api/insights/generate/route.ts
+++ b/src/app/api/insights/generate/route.ts
@@ -50,6 +50,11 @@ import {
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
+import {
+ findUngroundedBriefingNumbers,
+ readBriefingBlock,
+ buildBriefingGroundingCorrection,
+} from "@/lib/ai/briefing-grounding";
import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
@@ -729,6 +734,65 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("AI response was not valid JSON", 422);
}
+ // v1.18.10 (HIGH-1) — daily-briefing number-grounding gate, mirroring the
+ // recommendations citation cross-check next door. The schema only checks the
+ // briefing's SHAPE; this asserts every number the briefing prose restates
+ // traces to a `features.signalsOfDay` figure. On a miss: one corrective
+ // retry, then drop the briefing block rather than ship a fabricated number.
+ {
+ const signals = features.signalsOfDay ?? null;
+ let ungrounded = findUngroundedBriefingNumbers(
+ readBriefingBlock(insights),
+ signals,
+ );
+ if (ungrounded.length > 0) {
+ annotate({
+ action: { name: "insights.generate.briefing_grounding_retry" },
+ meta: { ungroundedCount: ungrounded.length },
+ });
+ try {
+ const retry = await runRawCompletionWithFallback({
+ userId,
+ providers: chain,
+ params: {
+ systemPrompt: buildSystemPromptWithReferences(
+ locale,
+ referenceMetrics,
+ ),
+ userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
+ temperature: 0.3,
+ maxTokens: 1500,
+ },
+ });
+ const parsedRetry = JSON.parse(retry.result.content);
+ const validatedRetry = insightResultSchema.safeParse(parsedRetry);
+ const retryInsights = validatedRetry.success
+ ? validatedRetry.data
+ : parsedRetry;
+ ungrounded = findUngroundedBriefingNumbers(
+ readBriefingBlock(retryInsights),
+ signals,
+ );
+ if (ungrounded.length === 0) {
+ insights = retryInsights;
+ result = retry.result;
+ workingProviderType = retry.workingProvider.providerType;
+ }
+ } catch {
+ // Retry failed to produce parseable JSON — fall through to the strip.
+ }
+ }
+ // Still ungrounded after the retry: strip the briefing rather than ship a
+ // fabricated figure. The structured recommendations/citations stay.
+ if (ungrounded.length > 0 && insights && typeof insights === "object") {
+ (insights as Record).dailyBriefing = null;
+ annotate({
+ action: { name: "insights.generate.briefing_grounding_stripped" },
+ meta: { ungroundedCount: ungrounded.length },
+ });
+ }
+ }
+
await prisma.user.update({
where: { id: userId },
data: {
diff --git a/src/lib/ai/__tests__/briefing-grounding.test.ts b/src/lib/ai/__tests__/briefing-grounding.test.ts
new file mode 100644
index 000000000..c7bcebbc4
--- /dev/null
+++ b/src/lib/ai/__tests__/briefing-grounding.test.ts
@@ -0,0 +1,112 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ findUngroundedBriefingNumbers,
+ readBriefingBlock,
+ buildBriefingGroundingCorrection,
+ extractNumbers,
+} from "@/lib/ai/briefing-grounding";
+import type { SignalOfDay } from "@/lib/insights/features";
+
+function signal(partial: Partial): SignalOfDay {
+ return {
+ metric: "weight",
+ label: "Weight",
+ latest: 82,
+ latestDaysAgo: 0,
+ avg7: 83,
+ avg30: 84,
+ deltaVs7: -1,
+ deltaVs30: -2,
+ spread30: 0.5,
+ outsideNormalSwing: true,
+ emergingTrend: "falling",
+ recentAnomaly: null,
+ ...partial,
+ };
+}
+
+describe("extractNumbers", () => {
+ it("parses signed, decimal, and comma-decimal numbers", () => {
+ expect(
+ extractNumbers("dropped 2.5 kg, +1,2 and -3").map((n) => n.value),
+ ).toEqual([2.5, 1.2, -3]);
+ });
+});
+
+describe("findUngroundedBriefingNumbers", () => {
+ const signals = [signal({})];
+
+ it("returns nothing when there are no signals to grade against", () => {
+ const out = findUngroundedBriefingNumbers(
+ { paragraph: "your weight dropped 9.9 kg" },
+ null,
+ );
+ expect(out).toEqual([]);
+ });
+
+ it("passes a paragraph whose numbers match the signal figures", () => {
+ const out = findUngroundedBriefingNumbers(
+ {
+ paragraph: "Your weight is 82 kg, down 2 kg from your monthly average.",
+ },
+ signals,
+ );
+ expect(out).toEqual([]);
+ });
+
+ it("flags a fabricated delta that no signal supports", () => {
+ const out = findUngroundedBriefingNumbers(
+ {
+ paragraph: "Your weight dropped 6.4 kg this week.",
+ signalsOfDay: [
+ { headline: "down 6.4 kg", nudge: "keep going", delta: "-6.4 kg" },
+ ],
+ },
+ signals,
+ );
+ expect(out.length).toBeGreaterThan(0);
+ expect(out.some((f) => f.value === 6.4)).toBe(true);
+ });
+
+ it("tolerates rounding within the band", () => {
+ const out = findUngroundedBriefingNumbers(
+ { paragraph: "down 2.1 kg vs your 84.0 average" },
+ [signal({ avg30: 84, deltaVs30: -2 })],
+ );
+ expect(out).toEqual([]);
+ });
+
+ it("does not flag structural integers like a 7-day window or 3 signals", () => {
+ const out = findUngroundedBriefingNumbers(
+ { paragraph: "Over the last 7 days, your 3 readings held steady." },
+ signals,
+ );
+ expect(out).toEqual([]);
+ });
+
+ it("returns nothing when the briefing is absent", () => {
+ expect(findUngroundedBriefingNumbers(null, signals)).toEqual([]);
+ });
+});
+
+describe("readBriefingBlock", () => {
+ it("reads the dailyBriefing block off a parsed payload", () => {
+ const block = readBriefingBlock({ dailyBriefing: { paragraph: "hi" } });
+ expect(block?.paragraph).toBe("hi");
+ });
+ it("returns null when no briefing is present", () => {
+ expect(readBriefingBlock({ summary: "x" })).toBeNull();
+ expect(readBriefingBlock(null)).toBeNull();
+ });
+});
+
+describe("buildBriefingGroundingCorrection", () => {
+ it("names the offending numbers and asks for a re-write", () => {
+ const msg = buildBriefingGroundingCorrection([
+ { field: "paragraph", value: 7, source: "7 kg" },
+ ]);
+ expect(msg).toContain("7 kg");
+ expect(msg.toLowerCase()).toContain("signalsofday");
+ });
+});
diff --git a/src/lib/ai/briefing-grounding.ts b/src/lib/ai/briefing-grounding.ts
new file mode 100644
index 000000000..80a2abdf5
--- /dev/null
+++ b/src/lib/ai/briefing-grounding.ts
@@ -0,0 +1,235 @@
+/**
+ * v1.18.10 (HIGH-1 / MEDIUM-3) — Daily-Briefing number-grounding cross-check.
+ *
+ * The comprehensive-insight schema validates that `dailyBriefing.paragraph`,
+ * `signalsOfDay[]` and `keyFindings[]` are well-shaped (non-empty strings,
+ * known enums, length caps) — but it does NOT verify that any NUMBER the model
+ * restates in those fields traces back to a figure the server pre-computed.
+ * `recommendations[]` carry that guarantee (the citation cross-check + retry +
+ * 422 in `generate-insight.ts`); the briefing block did not. A model could
+ * write "your weight dropped 3 kg this week" while the snapshot's
+ * `features.signalsOfDay` says 2 kg, and the payload still passed.
+ *
+ * This module closes that gap the same way the recommendations gate does:
+ * extract every number the briefing prose asserts, and reject the payload when
+ * one of them cannot be matched (within a rounding tolerance) to a value the
+ * deterministic `features.signalsOfDay` block already holds — `latest`,
+ * `avg7`, `avg30`, `deltaVs7`, `deltaVs30`, `spread30`, or a recent-anomaly
+ * value. The caller turns a non-empty finding list into the existing
+ * corrective-retry → 422 machinery.
+ *
+ * Scope + posture:
+ * - The check only fires when `features.signalsOfDay` is present. When the
+ * server pre-computed no signals there is no authoritative number set to
+ * grade against, so the prose is left to the prompt-level grounding rule
+ * (same as before) — we never reject for a number we have nothing to
+ * compare to.
+ * - It grades NUMBERS only. Tone, framing and word choice stay the prompt's
+ * job; this is purely "did the model restate a figure we did not give it".
+ * - Pure + side-effect-free, so it unit-tests in isolation.
+ */
+import type { SignalOfDay } from "@/lib/insights/features";
+
+/**
+ * Absolute rounding tolerance when matching a restated number to a
+ * pre-computed signal figure. The model legitimately rounds ("+1.24 kg" →
+ * "+1.2 kg"); anything inside this band is treated as the same number. A
+ * relative slack widens it for large values (heart rate, steps) where a
+ * one-decimal restatement of a server mean drifts by more than 0.15 in
+ * absolute terms.
+ */
+const ABS_TOLERANCE = 0.15;
+const REL_TOLERANCE = 0.02;
+
+/** The briefing sub-payload we grade. Mirrors `dailyBriefingSchema` loosely. */
+export interface BriefingForGrounding {
+ paragraph?: unknown;
+ signalsOfDay?: Array<{
+ headline?: unknown;
+ nudge?: unknown;
+ delta?: unknown;
+ }> | null;
+ keyFindings?: Array<{
+ headline?: unknown;
+ detail?: unknown;
+ delta?: unknown;
+ }> | null;
+}
+
+/** One ungrounded number the briefing asserted. */
+export interface UngroundedBriefingNumber {
+ /** Which field carried it. */
+ field: "paragraph" | "signalsOfDay" | "keyFindings";
+ /** The numeric value the model wrote, as parsed. */
+ value: number;
+ /** The raw token the value was read from (truncated). */
+ source: string;
+}
+
+/**
+ * Parse every standalone number out of a free-text string. Captures an
+ * optional leading sign and both `.`/`,` decimal separators (the DE briefing
+ * writes "1,2 kg"). Percent / unit suffixes are ignored — only the magnitude
+ * matters for grounding. Years and obvious dates (YYYY-MM-DD, "6 May") are not
+ * special-cased: a 4-digit run is allowed through and simply won't match any
+ * signal figure, which is fine because real briefings do not quote bare years
+ * as health numbers, and the tolerance band keeps small ordinals from
+ * colliding with real values.
+ */
+export function extractNumbers(
+ text: string,
+): Array<{ value: number; raw: string }> {
+ const out: Array<{ value: number; raw: string }> = [];
+ // Sign, integer part, optional decimal (.,) — bounded so we don't span words.
+ const re = /[+-]?\d+(?:[.,]\d+)?/g;
+ let match: RegExpExecArray | null;
+ while ((match = re.exec(text)) !== null) {
+ const raw = match[0];
+ const normalised = raw.replace(",", ".");
+ const value = Number.parseFloat(normalised);
+ if (Number.isFinite(value)) out.push({ value, raw });
+ }
+ return out;
+}
+
+/**
+ * The set of authoritative magnitudes one signal exposes. We grade against
+ * absolute magnitudes so a "+1.2" delta matches a `deltaVs7` of `-1.2` only
+ * when the model also flipped the sign in prose — but because briefings phrase
+ * direction in words ("dropped", "up"), grading on |value| avoids penalising
+ * the model for choosing a sign convention. We keep both the signed and the
+ * absolute form so an exact signed restatement also matches.
+ */
+function authoritativeValues(signals: readonly SignalOfDay[]): number[] {
+ const values: number[] = [];
+ const push = (n: number | null | undefined) => {
+ if (typeof n === "number" && Number.isFinite(n)) {
+ values.push(n);
+ values.push(Math.abs(n));
+ }
+ };
+ for (const s of signals) {
+ push(s.latest);
+ push(s.avg7);
+ push(s.avg30);
+ push(s.deltaVs7);
+ push(s.deltaVs30);
+ push(s.spread30);
+ if (s.recentAnomaly) push(s.recentAnomaly.value);
+ }
+ return values;
+}
+
+/** True when `value` matches any authoritative figure within tolerance. */
+function isGrounded(value: number, authoritative: readonly number[]): boolean {
+ for (const a of authoritative) {
+ const tol = Math.max(ABS_TOLERANCE, Math.abs(a) * REL_TOLERANCE);
+ if (Math.abs(value - a) <= tol) return true;
+ }
+ return false;
+}
+
+/**
+ * Numbers a briefing may always state without a signal match: small integers
+ * the prose uses structurally (a day count, "3 signals", "last 7 days"), which
+ * would otherwise false-positive against honest window/ordinal phrasing. We
+ * exempt only bare integers (no decimal) that are either a small ordinal (≤ 3)
+ * or one of the literal window lengths the briefing references (7/14/30/90/365).
+ *
+ * Trade-off: a fabricated number that happens to be exactly one of those bare
+ * window integers slips through. That is the deliberate conservative choice —
+ * the typical fabrication shapes (a restated delta/mean) carry a decimal or a
+ * non-window magnitude (a 6.4 kg drop, a pulse of 72, "down 5 kg") and are
+ * still graded; exempting the window vocabulary avoids reflexively flagging
+ * every honest "over the last 7 days". A real restated health figure that is a
+ * round window integer is rare and the grounding rule in the prompt remains the
+ * backstop for it.
+ */
+const STRUCTURAL_INTEGERS = new Set([7, 14, 30, 31, 90, 365]);
+
+function isStructural(value: number, raw: string): boolean {
+ if (raw.includes(".") || raw.includes(",")) return false;
+ if (!Number.isInteger(value)) return false;
+ const abs = Math.abs(value);
+ if (abs <= 3) return true; // "3 signals", "2 days", ordinals
+ return STRUCTURAL_INTEGERS.has(abs);
+}
+
+/**
+ * Find every number the briefing block asserts that does not trace to a
+ * pre-computed `features.signalsOfDay` figure. Returns an empty array when the
+ * briefing is absent, when no signals were pre-computed (nothing to grade
+ * against), or when every restated number is grounded.
+ */
+export function findUngroundedBriefingNumbers(
+ briefing: BriefingForGrounding | null | undefined,
+ signals: readonly SignalOfDay[] | null | undefined,
+): UngroundedBriefingNumber[] {
+ if (!briefing) return [];
+ if (!signals || signals.length === 0) return [];
+
+ const authoritative = authoritativeValues(signals);
+ // No usable authoritative figures (every field null) — nothing to grade.
+ if (authoritative.length === 0) return [];
+
+ const findings: UngroundedBriefingNumber[] = [];
+
+ const grade = (
+ field: UngroundedBriefingNumber["field"],
+ text: unknown,
+ ): void => {
+ if (typeof text !== "string" || text.length === 0) return;
+ for (const { value, raw } of extractNumbers(text)) {
+ if (isStructural(value, raw)) continue;
+ if (isGrounded(value, authoritative)) continue;
+ findings.push({ field, value, source: raw.slice(0, 32) });
+ }
+ };
+
+ grade("paragraph", briefing.paragraph);
+ for (const s of briefing.signalsOfDay ?? []) {
+ grade("signalsOfDay", s?.headline);
+ grade("signalsOfDay", s?.nudge);
+ grade("signalsOfDay", s?.delta);
+ }
+ for (const f of briefing.keyFindings ?? []) {
+ grade("keyFindings", f?.headline);
+ grade("keyFindings", f?.detail);
+ grade("keyFindings", f?.delta);
+ }
+
+ return findings;
+}
+
+/**
+ * Read the `dailyBriefing` block off a parsed comprehensive payload, whatever
+ * its concrete type (the strict `InsightResult` or the loose passthrough
+ * object). Returns null when no briefing is present.
+ */
+export function readBriefingBlock(
+ parsed: unknown,
+): BriefingForGrounding | null {
+ if (!parsed || typeof parsed !== "object") return null;
+ const briefing = (parsed as { dailyBriefing?: unknown }).dailyBriefing;
+ if (!briefing || typeof briefing !== "object") return null;
+ return briefing as BriefingForGrounding;
+}
+
+/**
+ * Build the corrective user-prompt suffix appended on a grounding miss, naming
+ * the offending numbers so the model can self-correct on the single retry —
+ * the same cite-or-omit posture the recommendations gate uses.
+ */
+export function buildBriefingGroundingCorrection(
+ findings: readonly UngroundedBriefingNumber[],
+): string {
+ const list = findings
+ .slice(0, 6)
+ .map((f) => `${f.source} (in ${f.field})`)
+ .join(", ");
+ return `
+Your daily-briefing block stated numbers that do NOT appear in the snapshot's pre-computed "signalsOfDay" figures: ${list}.
+
+Re-write the dailyBriefing (paragraph, signalsOfDay, keyFindings) so EVERY number you state matches a value the snapshot already gives you — the latest reading, the 7-day or 30-day average, or the signed delta against one of those. Do NOT invent or re-derive a figure. If you cannot ground a number, drop the number and keep only the qualitative framing. Return the full JSON object again.
+`;
+}
diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts
index 4936c7208..278c45e7e 100644
--- a/src/lib/insights/comprehensive-generate.ts
+++ b/src/lib/insights/comprehensive-generate.ts
@@ -22,7 +22,13 @@ import { prisma } from "@/lib/db";
import {
extractFeatures,
FeaturesPayloadTooLargeError,
+ type SignalOfDay,
} from "@/lib/insights/features";
+import {
+ findUngroundedBriefingNumbers,
+ readBriefingBlock,
+ buildBriefingGroundingCorrection,
+} from "@/lib/ai/briefing-grounding";
import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
import {
@@ -169,6 +175,14 @@ async function rerollBriefingParagraph(args: {
userPrompt: string;
cachedText: string;
locale: "de" | "en";
+ /**
+ * v1.18.10 (MEDIUM-3) — the pre-computed signals the briefing must match.
+ * The reroll runs at a higher, seedless temperature for phrasing variety,
+ * which is exactly the path most prone to drifting a restated number; the
+ * fresh paragraph is rejected (caller keeps the cached one) when a number in
+ * it does not trace to one of these figures.
+ */
+ signals: readonly SignalOfDay[] | null;
}): Promise<{ text: string; providerType: string } | null> {
let cached: Record;
try {
@@ -229,6 +243,18 @@ async function rerollBriefingParagraph(args: {
return null;
}
+ // v1.18.10 (MEDIUM-3) — grounding gate on the rerolled paragraph. The higher
+ // seedless temperature can drift a restated figure ("+1.2 kg" → "+1.3 kg");
+ // reject the reroll on any ungrounded number so the caller keeps the cached
+ // (already-grounded) paragraph rather than swapping in a fabricated one.
+ const rerollUngrounded = findUngroundedBriefingNumbers(
+ { paragraph: freshParagraph },
+ args.signals,
+ );
+ if (rerollUngrounded.length > 0) {
+ return null;
+ }
+
// Keep the cached payload verbatim; swap in only the fresh paragraph.
const mergedBriefing = {
...(cachedBriefing as Record),
@@ -816,6 +842,7 @@ export async function generateComprehensiveInsight(
userPrompt,
cachedText: dbUser.insightsCachedText,
locale,
+ signals: features.signalsOfDay ?? null,
});
if (rerolled) {
await prisma.user.update({
@@ -926,6 +953,60 @@ export async function generateComprehensiveInsight(
}
}
+ // v1.18.10 (HIGH-1) — daily-briefing number-grounding gate. The cron +
+ // force-warm path produces the same briefing the POST route does, so it
+ // enforces the same cross-check: every number the briefing restates must
+ // trace to a `features.signalsOfDay` figure. One corrective retry, then strip
+ // the briefing rather than persist a fabricated number.
+ {
+ const signals = features.signalsOfDay ?? null;
+ let ungrounded = findUngroundedBriefingNumbers(
+ readBriefingBlock(insights),
+ signals,
+ );
+ if (ungrounded.length > 0) {
+ annotate({
+ action: { name: "insights.generate.briefing_grounding_retry" },
+ meta: { locale, ungroundedCount: ungrounded.length },
+ });
+ try {
+ const retry = await runRawCompletionWithFallback({
+ userId,
+ providers: chain,
+ params: {
+ systemPrompt,
+ userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
+ temperature: AI_BUDGETS.comprehensive.temperature,
+ maxTokens: AI_BUDGETS.comprehensive.maxTokens,
+ responseFormat: "json",
+ },
+ });
+ const retryInsights = parseComprehensiveResult(retry.result.content);
+ const retryUngrounded = findUngroundedBriefingNumbers(
+ readBriefingBlock(retryInsights),
+ signals,
+ );
+ if (retryInsights !== null && retryUngrounded.length === 0) {
+ insights = retryInsights;
+ result = retry.result;
+ workingProviderType = retry.workingProvider.providerType;
+ ungrounded = [];
+ } else {
+ ungrounded = retryUngrounded;
+ }
+ } catch {
+ // Retry failed — fall through to the strip below.
+ }
+ }
+ if (ungrounded.length > 0 && insights && typeof insights === "object") {
+ (insights as Record).dailyBriefing = null;
+ annotate({
+ action: { name: "insights.generate.briefing_grounding_stripped" },
+ meta: { locale, ungroundedCount: ungrounded.length },
+ });
+ }
+ }
+
// v1.9.0 — the caller abandoned this generation (its bounded timeout
// fired and it has moved on to warm the per-status caches itself). Bail
// out before the cache write so a late-resolving comprehensive cannot
From 64de009d66cebe4a95b4b5b1b4cc1827dd8428ca Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:58:23 +0200
Subject: [PATCH 21/32] fix(coach): screen the assistant reply before it
streams
detectRefusal guards only the inbound user message; the model's reply
was streamed verbatim. A dose-prescription or a fabricated clinical risk
score that slipped past the system-prompt contracts reached the user
unfiltered.
Add a deterministic outbound screen on the assembled reply (which the
route already buffers before streaming): tight dose-change patterns plus
risk-score patterns, EN/DE. On a trip the turn is replaced with a calm,
grounded fallback and any reminder suggestion / key-value provenance from
the discarded reply is dropped.
---
.../ai/coach/__tests__/outbound-guard.test.ts | 92 +++++++++++
src/lib/ai/coach/outbound-guard.ts | 148 ++++++++++++++++++
2 files changed, 240 insertions(+)
create mode 100644 src/lib/ai/coach/__tests__/outbound-guard.test.ts
create mode 100644 src/lib/ai/coach/outbound-guard.ts
diff --git a/src/lib/ai/coach/__tests__/outbound-guard.test.ts b/src/lib/ai/coach/__tests__/outbound-guard.test.ts
new file mode 100644
index 000000000..1c71d24fa
--- /dev/null
+++ b/src/lib/ai/coach/__tests__/outbound-guard.test.ts
@@ -0,0 +1,92 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ screenCoachReply,
+ coachOutboundFallback,
+ COACH_OUTBOUND_DOSE_BLOCK_EN,
+ COACH_OUTBOUND_DOSE_BLOCK_DE,
+ COACH_OUTBOUND_RISK_BLOCK_EN,
+} from "@/lib/ai/coach/outbound-guard";
+
+describe("screenCoachReply — dose-prescription", () => {
+ const blocked = [
+ "You should step up to 2.4 mg next week.",
+ "Consider increasing to 10 mg.",
+ "I'd recommend a 0.5 mg dose to start.",
+ "Erhöhe auf 7,5 mg, sobald du dich daran gewöhnt hast.",
+ "Du kannst die nächste Stufe 5 mg ausprobieren.",
+ "Lower your dose to 1.0 mg.",
+ ];
+ for (const reply of blocked) {
+ it(`blocks: ${reply.slice(0, 32)}`, () => {
+ const d = screenCoachReply(reply);
+ expect(d.block).toBe(true);
+ expect(d.reason).toBe("dose_prescription");
+ });
+ }
+
+ const allowed = [
+ "You're on week 3 of 7.5 mg, and your weight is tracking down.",
+ "Your snapshot shows you take 5 mg daily — adherence looks steady.",
+ "Dein Snapshot zeigt 7,5 mg in Woche 3 — die Einnahmetreue ist gut.",
+ "Talk to your prescriber about whether your dose is still right.",
+ ];
+ for (const reply of allowed) {
+ it(`allows factual mention: ${reply.slice(0, 32)}`, () => {
+ expect(screenCoachReply(reply).block).toBe(false);
+ });
+ }
+});
+
+describe("screenCoachReply — risk score", () => {
+ const blocked = [
+ "Your 10-year cardiovascular risk is about 12%.",
+ "That puts your stroke risk at 8%.",
+ "Your ASCVD score suggests elevated risk.",
+ "Dein 10-Jahres-Risiko liegt bei etwa 14%.",
+ "Das ergibt ein Risiko von 12% in den nächsten Jahren.",
+ ];
+ for (const reply of blocked) {
+ it(`blocks: ${reply.slice(0, 32)}`, () => {
+ const d = screenCoachReply(reply);
+ expect(d.block).toBe(true);
+ expect(d.reason).toBe("risk_score");
+ });
+ }
+
+ it("allows a plain percentage that is not a risk claim", () => {
+ expect(
+ screenCoachReply("Your adherence was 92% over the last 30 days.").block,
+ ).toBe(false);
+ });
+});
+
+describe("screenCoachReply — clean replies", () => {
+ it("passes a grounded, non-prescriptive reply", () => {
+ const reply =
+ "Your systolic is averaging 128 mmHg this week, 4 below your monthly mean. Nice and steady — keep the routine going.";
+ expect(screenCoachReply(reply).block).toBe(false);
+ });
+ it("passes an empty reply through (handled upstream)", () => {
+ expect(screenCoachReply("").block).toBe(false);
+ });
+});
+
+describe("coachOutboundFallback", () => {
+ it("returns localised dose-block copy", () => {
+ expect(coachOutboundFallback("dose_prescription", "en")).toBe(
+ COACH_OUTBOUND_DOSE_BLOCK_EN,
+ );
+ expect(coachOutboundFallback("dose_prescription", "de")).toBe(
+ COACH_OUTBOUND_DOSE_BLOCK_DE,
+ );
+ });
+ it("returns risk-block copy and rides EN for non-de locales", () => {
+ expect(coachOutboundFallback("risk_score", "en")).toBe(
+ COACH_OUTBOUND_RISK_BLOCK_EN,
+ );
+ expect(coachOutboundFallback("risk_score", "fr")).toBe(
+ COACH_OUTBOUND_RISK_BLOCK_EN,
+ );
+ });
+});
diff --git a/src/lib/ai/coach/outbound-guard.ts b/src/lib/ai/coach/outbound-guard.ts
new file mode 100644
index 000000000..1470b3d59
--- /dev/null
+++ b/src/lib/ai/coach/outbound-guard.ts
@@ -0,0 +1,148 @@
+/**
+ * v1.18.10 (HIGH-2) — Coach OUTBOUND safety screen.
+ *
+ * `detectRefusal()` guards the INBOUND channel (the user's message) only. The
+ * model's reply was tokenised and streamed verbatim with no equivalent
+ * server-side check, so a dose-prescription ("step up to 2.4 mg") or a
+ * fabricated risk score in the assistant turn reached the user unfiltered. The
+ * GLP-1 dose-safety contract and grounding block are composed into the Coach
+ * system prompt, but those are instructions, not enforcement.
+ *
+ * The chat route already buffers the full reply before streaming (the provider
+ * clients return the body in one shot, then `tokeniseForStreaming` chunks it
+ * for the UI), so the practical screen runs on the assembled reply BEFORE
+ * persistence and streaming. On a trip the route swaps in a safe fallback turn
+ * — the user sees a calm "talk to your prescriber" message, never the
+ * fabricated/prescriptive text.
+ *
+ * Posture, mirroring the inbound detector:
+ * - Pattern-based + deterministic, so it is cheap and auditable; an LLM judge
+ * would itself be promptable.
+ * - Errs toward catching: the dose-prescription patterns are tight (a verb +
+ * a target dose with a unit), so an ordinary "you're on 7.5 mg this week"
+ * factual restatement (which the contract explicitly PERMITS) does not trip
+ * — only an imperative to change the dose does.
+ */
+import type { Locale } from "@/lib/i18n/config";
+
+/** Why the outbound reply was blocked, for the Wide-Event annotation. */
+export type CoachOutboundReason = "dose_prescription" | "risk_score";
+
+export interface CoachOutboundDecision {
+ /** True when the assembled reply must be replaced with the fallback. */
+ block: boolean;
+ /** Which screen tripped — drives Wide-Event metadata. */
+ reason: CoachOutboundReason | null;
+}
+
+/**
+ * Dose-prescription patterns. Each requires a CHANGE verb + a target dose with
+ * a unit, so a factual restatement of the current step ("you are on 7.5 mg",
+ * "week 3 on 7.5 mg") — which the GLP-1 contract permits — does not match,
+ * while an imperative ("step up to 2.4 mg", "increase to 10 mg", "erhöhe auf
+ * 7,5 mg") does. The unit set covers the GLP-1 + general oral-dose vocabulary.
+ */
+const DOSE_UNIT = "(?:mg|mcg|µg|ml|units?|ie|i\\.e\\.|einheiten)";
+
+const DOSE_PRESCRIPTION_PATTERNS: readonly RegExp[] = [
+ // EN: step/move/increase/raise/bump/titrate/go up to|by N unit
+ new RegExp(
+ `\\b(?:step|move|increase|raise|bump|titrat\\w*|go|ramp|push|up)\\s+(?:it\\s+)?(?:up\\s+|your\\s+dose\\s+)?(?:to|by)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`,
+ "i",
+ ),
+ // EN: lower/reduce/cut/drop/decrease/back off to|by N unit
+ new RegExp(
+ `\\b(?:lower|reduce|cut|drop|decrease|back\\s+off|taper)\\s+(?:it\\s+|your\\s+dose\\s+)?(?:to|by)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`,
+ "i",
+ ),
+ // EN: consider/try/should/recommend ... N unit dose
+ new RegExp(
+ `\\b(?:consider|try|you\\s+should|i'?d?\\s+recommend|i\\s+suggest)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`,
+ "i",
+ ),
+ // DE: erhöhe/steigere/geh hoch auf|um N unit
+ new RegExp(
+ `\\b(?:erhöh\\w*|steiger\\w*|setz\\w*\\s+(?:hoch|rauf)|geh\\w*\\s+(?:hoch|rauf))\\s+(?:auf|um)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`,
+ "i",
+ ),
+ // DE: reduziere/senke/verringere auf|um N unit
+ new RegExp(
+ `\\b(?:reduzier\\w*|senk\\w*|verringer\\w*|nimm\\s+(?:weniger|runter))\\s+(?:auf|um)\\s+[\\d.,]+\\s*${DOSE_UNIT}\\b`,
+ "i",
+ ),
+ // DE: nächste Stufe / nächste Dosis N unit (recommend-shaped)
+ new RegExp(
+ `\\bn[äa]chste\\s+(?:stufe|dosis)\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`,
+ "i",
+ ),
+];
+
+/**
+ * Risk-score fabrication patterns: the Coach is grounded on the snapshot and
+ * must never invent a computed clinical risk percentage / score. A "10-year
+ * cardiovascular risk of 12%" or a fabricated "your risk score is 8/10" is a
+ * number the server never computed and the snapshot never carried.
+ */
+const RISK_SCORE_PATTERNS: readonly RegExp[] = [
+ // EN: % (cardiovascular|heart|stroke|mortality|...) risk
+ /\b\d{1,3}\s*%\s+(?:risk|chance|probability|likelihood)\b/i,
+ /\b(?:risk|chance|probability|likelihood)\s+(?:of|is|at)\s+(?:about\s+|roughly\s+|~)?\d{1,3}\s*%/i,
+ /\b(?:10[- ]year|ten[- ]year|lifetime)\s+(?:cardiovascular|cardiac|heart|stroke|mortality|cvd|ascvd)\s+risk\b/i,
+ /\b(?:framingham|ascvd|score2?|qrisk)\b/i,
+ // DE: Risiko von N% / N% Risiko
+ /\brisiko\s+(?:von|bei|liegt\s+bei)\s+(?:etwa\s+|ungefähr\s+|~)?\d{1,3}\s*%/i,
+ /\b\d{1,3}\s*%\s+(?:risiko|wahrscheinlichkeit)\b/i,
+ /\b(?:10[- ]jahres|zehn[- ]jahres|lebenszeit)[- ]?(?:risiko)\b/i,
+];
+
+/**
+ * Screen an assembled assistant reply. Runs the dose-prescription patterns
+ * first (highest medical-safety leverage), then the risk-score patterns.
+ */
+export function screenCoachReply(reply: string): CoachOutboundDecision {
+ const text = reply ?? "";
+ if (text.trim().length === 0) {
+ return { block: false, reason: null };
+ }
+ for (const pattern of DOSE_PRESCRIPTION_PATTERNS) {
+ if (pattern.test(text)) {
+ return { block: true, reason: "dose_prescription" };
+ }
+ }
+ for (const pattern of RISK_SCORE_PATTERNS) {
+ if (pattern.test(text)) {
+ return { block: true, reason: "risk_score" };
+ }
+ }
+ return { block: false, reason: null };
+}
+
+/**
+ * Safe fallback copy when the outbound screen trips. de/en carry a native
+ * body; the other UI locales ride the EN body (the Coach itself replies in
+ * de/en, so this is the consistent posture with the inbound refusal copy).
+ * Exposed as constants so server-only code (tests, logs) can pin the wording.
+ */
+export const COACH_OUTBOUND_DOSE_BLOCK_EN =
+ "I can't suggest a specific medication dose or a change to one — dose decisions belong with your prescribing clinician. I can help you read what your own data shows around your medication; ask me about that and I'll stay grounded in your numbers.";
+
+export const COACH_OUTBOUND_DOSE_BLOCK_DE =
+ "Eine konkrete Medikamenten-Dosis oder eine Dosisänderung kann ich nicht vorschlagen — solche Entscheidungen gehören zu deiner behandelnden Ärztin oder deinem Arzt. Gerne helfe ich dir, das zu lesen, was deine eigenen Daten rund um deine Medikation zeigen; frag mich danach, dann bleibe ich bei deinen Zahlen.";
+
+export const COACH_OUTBOUND_RISK_BLOCK_EN =
+ "I can't put a number on a clinical risk like that — it isn't something your tracked data lets me compute, and an invented figure would be misleading. I can walk you through what your own measurements are doing instead; ask me about a specific metric.";
+
+export const COACH_OUTBOUND_RISK_BLOCK_DE =
+ "Eine solche klinische Risikozahl kann ich nicht angeben — deine erfassten Daten lassen das nicht berechnen, und ein erfundener Wert wäre irreführend. Stattdessen kann ich dir zeigen, was deine eigenen Messwerte machen; frag mich nach einer konkreten Metrik.";
+
+/** Resolve the localised fallback copy for a tripped outbound screen. */
+export function coachOutboundFallback(
+ reason: CoachOutboundReason,
+ locale: Locale,
+): string {
+ const de = locale === "de";
+ if (reason === "dose_prescription") {
+ return de ? COACH_OUTBOUND_DOSE_BLOCK_DE : COACH_OUTBOUND_DOSE_BLOCK_EN;
+ }
+ return de ? COACH_OUTBOUND_RISK_BLOCK_DE : COACH_OUTBOUND_RISK_BLOCK_EN;
+}
From 869f1d9c11517487ed4f099939640127095460bf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:58:23 +0200
Subject: [PATCH 22/32] fix(coach): wire the outbound screen and an SSE cancel
handler
Apply the outbound safety screen to the assembled Coach reply before
persistence and streaming, with a per-turn audit row on a block.
createSseStream gains a cancel handler + a shared AbortSignal: a client
disconnect now flips the signal so the token-emit loop stops enqueuing
into a closed connection and any provider fetch wired to the signal is
torn down. The chat emit loop honours it between frames.
---
src/app/api/insights/chat/route.ts | 43 +++++++++++++++++++++++++++---
src/lib/sse/create-stream.ts | 29 +++++++++++++++++++-
2 files changed, 68 insertions(+), 4 deletions(-)
diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts
index ea8907ad7..f73812a66 100644
--- a/src/app/api/insights/chat/route.ts
+++ b/src/app/api/insights/chat/route.ts
@@ -66,6 +66,10 @@ import {
reconcileSpend,
} from "@/lib/ai/coach/budget";
import { detectRefusal } from "@/lib/ai/coach/refusal";
+import {
+ screenCoachReply,
+ coachOutboundFallback,
+} from "@/lib/ai/coach/outbound-guard";
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
@@ -564,9 +568,33 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// + dismissal memory + cooldown + dedup against a live COACH reminder).
// A suppressed proposal leaves the prose unchanged and emits no card.
const suggestParse = parseSuggestReminder(proseAfterStrip);
- const replyText = suggestParse.prose.trim() || proseAfterStrip;
+ let replyText = suggestParse.prose.trim() || proseAfterStrip;
+
+ // v1.18.10 (HIGH-2) — OUTBOUND safety screen on the assembled assistant
+ // reply, before persistence and streaming. The inbound `detectRefusal`
+ // guards the user's message; this guards the model's reply for a
+ // dose-prescription or a fabricated clinical risk score that slipped past
+ // the system-prompt GLP-1/grounding contracts. On a trip the turn is
+ // replaced with a calm, grounded fallback and any reminder suggestion /
+ // key-value provenance is dropped — the user never sees the unsafe text.
+ const outbound = screenCoachReply(replyText);
+ if (outbound.block && outbound.reason) {
+ replyText = coachOutboundFallback(outbound.reason, locale);
+ annotate({
+ action: { name: "insights.coach.outbound_blocked" },
+ meta: { reason: outbound.reason, promptVersion: PROMPT_VERSION },
+ });
+ await auditLog("insights.coach.outbound_blocked", {
+ userId,
+ details: {
+ conversationId: workingConversationId,
+ reason: outbound.reason,
+ },
+ });
+ }
+
let surfacedSuggestion: CoachSuggestion | null = null;
- if (suggestParse.cadence) {
+ if (!outbound.block && suggestParse.cadence) {
const decision = await gateSuggestion({
prisma,
userId,
@@ -614,7 +642,11 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
}
const enrichedProvenance: typeof snapshot.provenance = {
...snapshot.provenance,
- ...(sentinel.keyValues.length > 0 ? { keyValues: sentinel.keyValues } : {}),
+ // v1.18.10 (HIGH-2) — a blocked turn carries the fallback prose, so the
+ // key-values from the discarded reply must not ride along as provenance.
+ ...(!outbound.block && sentinel.keyValues.length > 0
+ ? { keyValues: sentinel.keyValues }
+ : {}),
...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}),
};
if (sentinel.malformed) {
@@ -692,9 +724,14 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// pace).
const stream = createSseStream(async (controller) => {
for (const tok of tokeniseForStreaming(replyText)) {
+ // v1.18.10 (A-2) — stop tokenising the moment the client disconnects;
+ // the cancel handler flips this signal, so we don't pace frames into a
+ // closed connection.
+ if (controller.signal.aborted) return;
controller.enqueue(encodeFrame({ type: "token", token: tok }));
await flushTick();
}
+ if (controller.signal.aborted) return;
controller.enqueue(
encodeFrame({
type: "provenance",
diff --git a/src/lib/sse/create-stream.ts b/src/lib/sse/create-stream.ts
index 78181a7c2..15ee3cb13 100644
--- a/src/lib/sse/create-stream.ts
+++ b/src/lib/sse/create-stream.ts
@@ -20,6 +20,14 @@
export interface SseController {
enqueue: (chunk: T) => void;
+ /**
+ * v1.18.10 (A-2) — aborts when the consumer cancels the stream (client
+ * disconnect, navigation away, `Response.body.cancel()`). The `emit`
+ * callback should check `signal.aborted` between frames and stop producing,
+ * and pass `signal` to any in-flight provider fetch so a mid-stream
+ * disconnect tears the upstream call down instead of streaming into the void.
+ */
+ signal: AbortSignal;
}
/**
@@ -27,17 +35,36 @@ export interface SseController {
* supplied `emit` function inside a try/finally that always closes the
* controller. Use the returned stream with the standard SSE response
* headers (`Content-Type: text/event-stream`, `Cache-Control: no-cache`).
+ *
+ * v1.18.10 (A-2) — the stream now wires a `cancel` handler: when the consumer
+ * cancels (client disconnect), the shared `AbortController` fires, so the
+ * `emit` callback's `signal` flips to aborted and any provider fetch wired to
+ * it is torn down. Without this the emit loop kept enqueuing frames (and an
+ * abort-less upstream fetch kept running) after the client had gone.
*/
export function createSseStream(
emit: (controller: SseController) => void | Promise,
): ReadableStream {
+ const abort = new AbortController();
return new ReadableStream({
async start(controller) {
+ const sse: SseController = {
+ enqueue: (chunk) => {
+ // A late enqueue after the consumer cancelled would throw on the
+ // closed controller — swallow it so the abort path stays clean.
+ if (abort.signal.aborted) return;
+ controller.enqueue(chunk);
+ },
+ signal: abort.signal,
+ };
try {
- await emit(controller);
+ await emit(sse);
} finally {
controller.close();
}
},
+ cancel(reason) {
+ abort.abort(reason);
+ },
});
}
From 080ce73475f3b9e9a86853f1a94fbd9c65baa94a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:58:33 +0200
Subject: [PATCH 23/32] fix(insights): add a grounding gate + retry to the
period narrative
The period narrative was written straight to storage with no
post-generation check and no corrective retry, unlike Comprehensive
Insights. A populated context could still yield causal phrasing or a
number absent from the context block.
Add a validator that flags causal language (EN/DE) and any number not in
the structured context; on a trip, retry once with a corrective suffix,
then write nothing (the last good row stays) rather than persist an
ungrounded or causal story.
---
.../__tests__/narrative-grounding.test.ts | 117 ++++++++++
.../insights/narrative/narrative-grounding.ts | 210 ++++++++++++++++++
.../narrative/period-narrative-generate.ts | 57 ++++-
3 files changed, 383 insertions(+), 1 deletion(-)
create mode 100644 src/lib/insights/narrative/__tests__/narrative-grounding.test.ts
create mode 100644 src/lib/insights/narrative/narrative-grounding.ts
diff --git a/src/lib/insights/narrative/__tests__/narrative-grounding.test.ts b/src/lib/insights/narrative/__tests__/narrative-grounding.test.ts
new file mode 100644
index 000000000..c1528fc4a
--- /dev/null
+++ b/src/lib/insights/narrative/__tests__/narrative-grounding.test.ts
@@ -0,0 +1,117 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ validateNarrativeText,
+ buildNarrativeCorrection,
+} from "@/lib/insights/narrative/narrative-grounding";
+import type { PeriodNarrativeContext } from "@/lib/insights/narrative/period-narrative";
+
+function ctx(
+ partial: Partial = {},
+): PeriodNarrativeContext {
+ return {
+ status: "ready",
+ period: "week",
+ metricDeltas: [
+ {
+ type: "WEIGHT" as PeriodNarrativeContext["metricDeltas"][number]["type"],
+ unit: "kg",
+ current: 82,
+ prior: 84,
+ delta: -2,
+ deltaPercent: -2.4,
+ currentDays: 6,
+ priorDays: 7,
+ },
+ ],
+ bandTransitions: [],
+ drivers: [],
+ coincidentFlags: [],
+ pairsTested: 5,
+ fdrQ: 0.1,
+ provenance: {
+ metrics: ["weight"],
+ window: { from: "2026-06-01", to: "2026-06-07" },
+ computedAt: "2026-06-07T00:00:00.000Z",
+ },
+ ...partial,
+ };
+}
+
+describe("validateNarrativeText — causal language", () => {
+ it("flags 'because'", () => {
+ const out = validateNarrativeText(
+ "Your weight fell because you slept more.",
+ ctx(),
+ );
+ expect(out.some((f) => f.reason === "causal_language")).toBe(true);
+ });
+ it("flags German 'wegen'", () => {
+ const out = validateNarrativeText(
+ "Dein Gewicht sank wegen des Schlafs.",
+ ctx(),
+ );
+ expect(out.some((f) => f.reason === "causal_language")).toBe(true);
+ });
+ it("flags 'culprit'", () => {
+ const out = validateNarrativeText(
+ "Stress is the likely culprit here.",
+ ctx(),
+ );
+ expect(out.some((f) => f.reason === "causal_language")).toBe(true);
+ });
+ it("allows descriptive 'associated with' / 'moved with'", () => {
+ const out = validateNarrativeText(
+ "Your weight of 82 kg moved with your sleep this week, associated with a 2 kg drop.",
+ ctx(),
+ );
+ expect(out).toEqual([]);
+ });
+});
+
+describe("validateNarrativeText — number grounding", () => {
+ it("passes numbers present in the context", () => {
+ const out = validateNarrativeText(
+ "Your weight is 82 kg, down 2 kg (about -2.4%) from the prior week.",
+ ctx(),
+ );
+ expect(out).toEqual([]);
+ });
+ it("flags a number not in the context", () => {
+ const out = validateNarrativeText(
+ "Your weight dropped 9 kg this week.",
+ ctx(),
+ );
+ expect(
+ out.some((f) => f.reason === "ungrounded_number" && f.source === "9"),
+ ).toBe(true);
+ });
+ it("does not flag structural integers (7-day window)", () => {
+ const out = validateNarrativeText(
+ "Over the last 7 days your weight held at 82 kg.",
+ ctx(),
+ );
+ expect(out).toEqual([]);
+ });
+});
+
+describe("buildNarrativeCorrection", () => {
+ it("names causal + ungrounded findings (en)", () => {
+ const msg = buildNarrativeCorrection(
+ [
+ { reason: "causal_language", source: "because" },
+ { reason: "ungrounded_number", source: "9" },
+ ],
+ "en",
+ );
+ expect(msg.toLowerCase()).toContain("causal");
+ expect(msg).toContain("9");
+ });
+ it("produces German copy", () => {
+ const msg = buildNarrativeCorrection(
+ [{ reason: "causal_language", source: "weil" }],
+ "de",
+ );
+ expect(msg).toContain("URSÄCHLICHE");
+ });
+});
diff --git a/src/lib/insights/narrative/narrative-grounding.ts b/src/lib/insights/narrative/narrative-grounding.ts
new file mode 100644
index 000000000..0422c0ef3
--- /dev/null
+++ b/src/lib/insights/narrative/narrative-grounding.ts
@@ -0,0 +1,210 @@
+/**
+ * v1.18.10 (HIGH-3) — period-narrative grounding validator.
+ *
+ * The period narrative was plain text written straight to storage after the
+ * provider call, with NO post-generation check and NO corrective retry —
+ * unlike Comprehensive Insights, which rejects + retries + 422s on a grounding
+ * miss. The system prompt bans causal language and forbids stating a number
+ * not in the context, but those are instructions, not enforcement: a populated
+ * (non-`insufficient`) context could still yield "stress is the likely
+ * culprit" or a figure absent from the context block, and nothing rejected it.
+ *
+ * This module is the missing gate. Given the generated text and the structured
+ * `PeriodNarrativeContext`, it flags:
+ * 1. causal phrasing ("because", "caused", "due to", "weil", "wegen", …);
+ * 2. any number the narrative states that does not trace to a context figure
+ * within a rounding tolerance.
+ * The generator runs it after the completion, retries ONCE with a corrective
+ * suffix on a trip, and falls back to NO narrative (writes nothing) rather than
+ * persisting an ungrounded story.
+ *
+ * Pure + side-effect-free, so it unit-tests in isolation.
+ */
+import type { PeriodNarrativeContext } from "@/lib/insights/narrative/period-narrative";
+
+/** Absolute + relative number-match tolerance (rounding slack). */
+const ABS_TOLERANCE = 0.15;
+const REL_TOLERANCE = 0.02;
+
+/**
+ * Causal phrases that violate the descriptive-never-causal contract. Word-
+ * boundary anchored, case-insensitive. The list is the EN + DE causal
+ * vocabulary; "associated with" / "moved with" / "assoziiert" stay PERMITTED
+ * (they are the descriptive framing the contract requires).
+ */
+const CAUSAL_PATTERNS: readonly RegExp[] = [
+ /\bbecause\s+of\b/i,
+ /\bbecause\b/i,
+ /\bcaused?\s+by\b/i,
+ /\bcaus(?:e|es|ed|ing)\b/i,
+ /\bdue\s+to\b/i,
+ /\bled\s+to\b/i,
+ /\bresulted\s+in\b/i,
+ /\bresult\s+of\b/i,
+ /\bculprit\b/i,
+ /\bdriven\s+by\b/i,
+ /\bthanks\s+to\b/i,
+ /\bowing\s+to\b/i,
+ // DE
+ /\bweil\b/i,
+ /\bwegen\b/i,
+ /\bverursach\w*/i,
+ /\baufgrund\b/i,
+ /\bführt[e]?\s+zu\b/i,
+ /\bdurch\s+\w+\s+(?:verursacht|ausgelöst)\b/i,
+ /\bschuld\b/i,
+ /\bauslöser\b/i,
+];
+
+export type NarrativeGroundingReason = "causal_language" | "ungrounded_number";
+
+export interface NarrativeGroundingFinding {
+ reason: NarrativeGroundingReason;
+ /** The matched token (truncated). */
+ source: string;
+}
+
+/** Parse every standalone number out of free text (`.`/`,` decimals). */
+function extractNumbers(text: string): Array<{ value: number; raw: string }> {
+ const out: Array<{ value: number; raw: string }> = [];
+ const re = /[+-]?\d+(?:[.,]\d+)?/g;
+ let match: RegExpExecArray | null;
+ while ((match = re.exec(text)) !== null) {
+ const raw = match[0];
+ const value = Number.parseFloat(raw.replace(",", "."));
+ if (Number.isFinite(value)) out.push({ value, raw });
+ }
+ return out;
+}
+
+/** Every authoritative magnitude the context exposes (signed + absolute). */
+function authoritativeValues(context: PeriodNarrativeContext): number[] {
+ const values: number[] = [];
+ const push = (n: number | null | undefined) => {
+ if (typeof n === "number" && Number.isFinite(n)) {
+ values.push(n);
+ values.push(Math.abs(n));
+ }
+ };
+ for (const d of context.metricDeltas) {
+ push(d.current);
+ push(d.prior);
+ push(d.delta);
+ push(d.deltaPercent);
+ push(d.currentDays);
+ push(d.priorDays);
+ }
+ for (const b of context.bandTransitions) {
+ push(b.center);
+ push(b.bandLow);
+ push(b.bandHigh);
+ push(b.baselineDays);
+ }
+ for (const dr of context.drivers) {
+ push(dr.r);
+ push(dr.qValue);
+ push(dr.n);
+ }
+ push(context.pairsTested);
+ push(context.fdrQ);
+ return values;
+}
+
+function isGrounded(value: number, authoritative: readonly number[]): boolean {
+ for (const a of authoritative) {
+ const tol = Math.max(ABS_TOLERANCE, Math.abs(a) * REL_TOLERANCE);
+ if (Math.abs(value - a) <= tol) return true;
+ }
+ return false;
+}
+
+/**
+ * Structural integers the narrative may state without a context match — small
+ * ordinals and the literal period lengths the prompt references (7 / 30 days).
+ */
+const STRUCTURAL_INTEGERS = new Set([7, 30]);
+
+function isStructural(value: number, raw: string): boolean {
+ if (raw.includes(".") || raw.includes(",")) return false;
+ if (!Number.isInteger(value)) return false;
+ const abs = Math.abs(value);
+ if (abs <= 3) return true;
+ return STRUCTURAL_INTEGERS.has(abs);
+}
+
+/**
+ * Validate a generated narrative against its context. Returns every grounding
+ * violation found; an empty array means the narrative is grounded and
+ * causally-clean.
+ */
+export function validateNarrativeText(
+ text: string,
+ context: PeriodNarrativeContext,
+): NarrativeGroundingFinding[] {
+ const findings: NarrativeGroundingFinding[] = [];
+ if (typeof text !== "string" || text.trim().length === 0) return findings;
+
+ for (const pattern of CAUSAL_PATTERNS) {
+ const m = pattern.exec(text);
+ if (m) {
+ findings.push({ reason: "causal_language", source: m[0].slice(0, 32) });
+ break; // one causal flag is enough to trigger the corrective retry
+ }
+ }
+
+ const authoritative = authoritativeValues(context);
+ for (const { value, raw } of extractNumbers(text)) {
+ if (isStructural(value, raw)) continue;
+ if (isGrounded(value, authoritative)) continue;
+ findings.push({ reason: "ungrounded_number", source: raw.slice(0, 32) });
+ }
+
+ return findings;
+}
+
+/**
+ * Corrective suffix appended to the user prompt on the single retry, naming the
+ * violations so the model can self-correct — mirroring the comprehensive
+ * insight's `buildRetryCorrectionMessage` posture.
+ */
+export function buildNarrativeCorrection(
+ findings: readonly NarrativeGroundingFinding[],
+ locale: "de" | "en",
+): string {
+ const causal = findings.some((f) => f.reason === "causal_language");
+ const numbers = findings
+ .filter((f) => f.reason === "ungrounded_number")
+ .slice(0, 6)
+ .map((f) => f.source)
+ .join(", ");
+ if (locale === "de") {
+ const parts = [
+ "Deine vorige Zusammenfassung hat die festen Regeln verletzt.",
+ ];
+ if (causal) {
+ parts.push(
+ "Sie enthielt URSÄCHLICHE Formulierungen. Schreibe ausschließlich BESCHREIBEND ('X bewegte sich mit Y', 'X war mit Y assoziiert') — nie 'weil', 'wegen', 'verursacht', 'führte zu'.",
+ );
+ }
+ if (numbers) {
+ parts.push(
+ `Sie nannte Zahlen, die NICHT im Kontext stehen: ${numbers}. Nenne nur Zahlen, die wörtlich im KONTEXT vorkommen, oder lass die Zahl weg.`,
+ );
+ }
+ parts.push("Schreibe die Zusammenfassung erneut (2–4 kurze Sätze).");
+ return `\n\n${parts.join(" ")}`;
+ }
+ const parts = ["Your previous summary broke the hard rules."];
+ if (causal) {
+ parts.push(
+ "It used CAUSAL wording. Write strictly DESCRIPTIVELY ('X moved with Y', 'X was associated with Y') — never 'because', 'due to', 'caused', 'led to'.",
+ );
+ }
+ if (numbers) {
+ parts.push(
+ `It stated numbers NOT in the context: ${numbers}. State only numbers that appear verbatim in the CONTEXT, or drop the number.`,
+ );
+ }
+ parts.push("Re-write the summary (2-4 short sentences).");
+ return `\n\n${parts.join(" ")}`;
+}
diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts
index 9e1efaa8e..b597a5511 100644
--- a/src/lib/insights/narrative/period-narrative-generate.ts
+++ b/src/lib/insights/narrative/period-narrative-generate.ts
@@ -41,6 +41,10 @@ import {
DETERMINISTIC_PROVIDER_TYPE,
} from "@/lib/insights/narrative/period-narrative-deterministic";
import { composeSharedContracts } from "@/lib/ai/prompts/shared-contracts";
+import {
+ validateNarrativeText,
+ buildNarrativeCorrection,
+} from "@/lib/insights/narrative/narrative-grounding";
/**
* Stable identifier for the narrative prompt revision. Bumped whenever the
@@ -350,10 +354,61 @@ export async function generatePeriodNarrative(
return { status: "generated", providerType: DETERMINISTIC_PROVIDER_TYPE };
}
- const text = completion.content.trim();
+ let text = completion.content.trim();
if (text.length === 0) {
return { status: "failed", reason: "empty" };
}
+
+ // v1.18.10 (HIGH-3) — grounding gate on the AI prose, matching the rejecting
+ // gate Comprehensive Insights already has. Flag causal phrasing and any
+ // number absent from the context; on a trip, retry ONCE with a corrective
+ // suffix, then fall back to writing NOTHING (last good row stays) rather than
+ // persisting an ungrounded or causal story.
+ let findings = validateNarrativeText(text, context);
+ if (findings.length > 0) {
+ annotate({
+ action: { name: "insights.narrative.grounding_retry" },
+ meta: {
+ period,
+ locale,
+ causal: findings.some((f) => f.reason === "causal_language"),
+ ungroundedNumbers: findings.filter(
+ (f) => f.reason === "ungrounded_number",
+ ).length,
+ },
+ });
+ const retry = await runCompletion({
+ userId,
+ cacheAction: `insights.narrative.${period}.${locale}`,
+ consentSurface: "insights",
+ systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN,
+ userPrompt:
+ buildNarrativeUserPrompt(context, locale) +
+ buildNarrativeCorrection(findings, locale),
+ temperature: AI_BUDGETS.narrative.temperature,
+ maxTokens: AI_BUDGETS.narrative.maxTokens,
+ seed: REFERENCE_AI_SEED,
+ responseFormat: "text",
+ });
+ if (retry.kind === "ok") {
+ const retryText = retry.content.trim();
+ const retryFindings = validateNarrativeText(retryText, context);
+ if (retryText.length > 0 && retryFindings.length === 0) {
+ text = retryText;
+ findings = [];
+ } else {
+ findings = retryFindings;
+ }
+ }
+ if (findings.length > 0) {
+ annotate({
+ action: { name: "insights.narrative.grounding_rejected" },
+ meta: { period, locale },
+ });
+ return { status: "failed", reason: "ungrounded" };
+ }
+ }
+
await persist(text, completion.providerType);
return { status: "generated", providerType: completion.providerType };
}
From 22fac8ae904957e307bfc9aa8d0af31b5ab42f92 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:58:33 +0200
Subject: [PATCH 24/32] perf(coach): cap the snapshot measurement read
The single multi-type window read had no take bound, so a lastYear /
allTime scope over a high-frequency type (PULSE / glucose are
200k-row-class) loaded a year of rows just to fold them into the ~21
daily + ~10 weekly buckets the prompt shows. Read newest-first with a
6000-row cap and restore ascending order; the coarse MONTH/YEAR tail
still comes from the rollup tier, so deep history survives.
---
src/lib/ai/coach/snapshot.ts | 64 ++++++++++++++++++++++++++----------
1 file changed, 46 insertions(+), 18 deletions(-)
diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts
index 143b7f19a..1292ed9f7 100644
--- a/src/lib/ai/coach/snapshot.ts
+++ b/src/lib/ai/coach/snapshot.ts
@@ -111,6 +111,19 @@ const DAILY_TIMELINE_DAYS = 14;
/** Default window when the caller doesn't pass a scope. */
const DEFAULT_WINDOW: CoachScopeWindow = "last30days";
+/**
+ * v1.18.10 (P-2) — newest-first cap on the single multi-type measurement read
+ * that feeds the Coach snapshot timelines. The window read can reach 365 days
+ * (`lastYear` / `allTime`) across high-frequency types (PULSE / glucose are
+ * 200k-row-class), but the prompt only renders ~21 daily + ~10 weekly buckets
+ * per metric, so an uncapped read loaded a year of rows to discard almost all
+ * of them. 6000 keeps the recent-daily + weekly window exact even with several
+ * dense types active (≈ a year of multi-daily readings on one type, or a
+ * handful of types at a few readings/day) while bounding the worst case; the
+ * coarse MONTH/YEAR tail comes from the rollup tier, not this read.
+ */
+const SNAPSHOT_MEASUREMENT_ROW_CAP = 6000;
+
/**
* v1.17.0 — the glucose clinical panel is a fixed trailing-30-day artifact,
* identical across the insights panel, the dashboard snapshot, the doctor
@@ -965,24 +978,39 @@ async function buildCoachSnapshotImpl(
const measurementRowsPromise =
wantedTypes.length > 0
- ? prisma.measurement.findMany({
- where: {
- userId,
- type: { in: wantedTypes as never[] },
- measuredAt: { gte: cutoff },
- deletedAt: null,
- },
- orderBy: { measuredAt: "asc" },
- // v1.7.0 — `glucoseContext` rides along so the glucose block
- // can split fasting / postprandial / random / bedtime without
- // a second query. NULL on every non-glucose row.
- select: {
- type: true,
- value: true,
- measuredAt: true,
- glucoseContext: true,
- },
- })
+ ? prisma.measurement
+ .findMany({
+ where: {
+ userId,
+ type: { in: wantedTypes as never[] },
+ measuredAt: { gte: cutoff },
+ deletedAt: null,
+ },
+ // v1.18.10 (P-2) — read NEWEST-first + cap. PULSE / glucose are
+ // 200k-row-class types and the window can reach 365 days
+ // (lastYear / allTime), so an uncapped read pulled the entire
+ // year of high-frequency rows into memory just to fold them into
+ // ~21 daily + ~10 weekly buckets the prompt actually shows. The
+ // newest-first cap keeps the recent-daily timeline exact and only
+ // sheds the deepest weekly buckets on an extreme-volume account;
+ // the coarse MONTH/YEAR tail is read separately from the rollup
+ // tier (`buildCoarseTimelineTail`), so deep history survives.
+ orderBy: { measuredAt: "desc" },
+ take: SNAPSHOT_MEASUREMENT_ROW_CAP,
+ // v1.7.0 — `glucoseContext` rides along so the glucose block
+ // can split fasting / postprandial / random / bedtime without
+ // a second query. NULL on every non-glucose row.
+ select: {
+ type: true,
+ value: true,
+ measuredAt: true,
+ glucoseContext: true,
+ },
+ })
+ // Downstream bucketers re-sort/group internally, but restore
+ // ascending order so any order-sensitive consumer sees the same
+ // shape as before the cap.
+ .then((rows) => rows.reverse())
: Promise.resolve([]);
// v1.11.3 — `extractFeatures` and the shared measurement read are
From db68f994c2fff23eecce087eeff8b0ec37654c2c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:58:44 +0200
Subject: [PATCH 25/32] fix(insights): feed the six specialised cards a
precomputed SIGNAL block
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
BP, pulse, weight, BMI, mood and medication-compliance still asked the
model to derive the recent-vs-baseline comparison from raw buckets — the
exact arithmetic small models do unreliably, and the pattern the
base-system prompt already advertises as precomputed.
Build the SIGNAL block (current / baseline / signed delta / normal-swing
verdict) with buildMetricSignal from each card's graded series and inject
it into the snapshot; BP carries two signals, compliance one per
medication. Each card's prompt section now leads from the signal block
and is told to state, not recompute. The server-computed compliance %
is unchanged.
---
src/lib/ai/prompts/blood-pressure.ts | 8 +++---
src/lib/ai/prompts/bmi.ts | 8 +++---
src/lib/ai/prompts/medication-compliance.ts | 8 +++---
src/lib/ai/prompts/mood.ts | 8 +++---
src/lib/ai/prompts/pulse.ts | 8 +++---
src/lib/ai/prompts/weight.ts | 8 +++---
.../__tests__/blood-pressure-status.test.ts | 11 +++++++-
src/lib/insights/blood-pressure-status.ts | 28 +++++++++++++++++++
src/lib/insights/bmi-status.ts | 13 +++++++++
.../insights/medication-compliance-status.ts | 20 +++++++++++++
src/lib/insights/mood-status.ts | 14 ++++++++++
src/lib/insights/pulse-status.ts | 15 ++++++++++
src/lib/insights/weight-status.ts | 14 ++++++++++
13 files changed, 138 insertions(+), 25 deletions(-)
diff --git a/src/lib/ai/prompts/blood-pressure.ts b/src/lib/ai/prompts/blood-pressure.ts
index 559131da3..910adae73 100644
--- a/src/lib/ai/prompts/blood-pressure.ts
+++ b/src/lib/ai/prompts/blood-pressure.ts
@@ -2,8 +2,8 @@ import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const BP_SECTION_DE = `METRIK — BLUTDRUCK:
-- Der Snapshot trägt systolisch (bloodPressure.systolic) und diastolisch (bloodPressure.diastolic) jeweils mit summary + graded series; beurteile beide Komponenten GEMEINSAM, nie isoliert.
-- Tendenz über Tage/Wochen: Vergleiche das recent-Mittel (letzte Tage/Woche) mit dem weekly/monthly-Mittel der Person. Eine systolische Abweichung gilt ab ~5 mmHg, eine diastolische ab ~3 mmHg gegenüber der eigenen Baseline als signifikant — kleinere Schwankungen sind normal und kein Befund.
+- Der Snapshot trägt systolisch (bloodPressure.systolic) und diastolisch (bloodPressure.diastolic) jeweils mit signal (der fertige Vergleich) + summary + graded series; beurteile beide Komponenten GEMEINSAM, nie isoliert.
+- Tendenz über Tage/Wochen: Führe mit bloodPressure.systolic.signal und bloodPressure.diastolic.signal — current, delta gegenüber baselineLabel und outsideNormalSwing sind bereits berechnet. NENNE sie, rechne sie NICHT neu. Behandle outsideNormalSwing: false als "innerhalb der üblichen Schwankung — kein Befund".
- Leitlinien-Anker (ESH 2023), zur Einordnung, nicht zur Diagnose:
* Optimal < 120/80, normal 120-129/80-84, hochnormal 130-139/85-89.
* Hypertonie Grad 1 ab 140/90, Grad 2 ab 160/100, Grad 3 ab 180/110.
@@ -14,8 +14,8 @@ const BP_SECTION_DE = `METRIK — BLUTDRUCK:
- Eine Botschaft: Wenn die Werte über der Baseline liegen, schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. ein paar Tage zur selben Uhrzeit nachmessen, oder — bei lückenhafter Einnahmetreue — die Einnahme verlässlicher machen). Bei stabilen Werten im Ziel: das ehrlich anerkennen und stattdessen einen Punkt nennen, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`;
const BP_SECTION_EN = `METRIC — BLOOD PRESSURE:
-- The snapshot carries systolic (bloodPressure.systolic) and diastolic (bloodPressure.diastolic), each with a summary + graded series; judge both components TOGETHER, never in isolation.
-- Trend over days/weeks: compare the recent mean (last days/week) with the person's weekly/monthly mean. Treat a systolic shift of ~5 mmHg or a diastolic shift of ~3 mmHg against their own baseline as significant — smaller swings are normal and not a finding.
+- The snapshot carries systolic (bloodPressure.systolic) and diastolic (bloodPressure.diastolic), each with a signal (the finished comparison) + summary + graded series; judge both components TOGETHER, never in isolation.
+- Trend over days/weeks: lead from bloodPressure.systolic.signal and bloodPressure.diastolic.signal — current, delta vs baselineLabel and outsideNormalSwing are already computed. STATE them, do NOT recompute. Treat outsideNormalSwing: false as "inside the usual swing — not a finding".
- Guideline anchors (ESH 2023), for placement, not diagnosis:
* Optimal < 120/80, normal 120-129/80-84, high-normal 130-139/85-89.
* Hypertension grade 1 from 140/90, grade 2 from 160/100, grade 3 from 180/110.
diff --git a/src/lib/ai/prompts/bmi.ts b/src/lib/ai/prompts/bmi.ts
index fd4c2ffca..c0e4c1031 100644
--- a/src/lib/ai/prompts/bmi.ts
+++ b/src/lib/ai/prompts/bmi.ts
@@ -2,18 +2,18 @@ import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const BMI_SECTION_DE = `METRIK — BMI:
-- Der Snapshot trägt bmi.summary + bmi.series (graded). bmi.latestDayFocus zeigt den jüngsten Wert, dessen WHO-Klassifikation und den Schritt zum Vortag; bmi.target ist das grüne Band (18.5-24.9).
+- Der Snapshot trägt bmi.signal (der fertige Vergleich) + bmi.summary + bmi.series (graded). bmi.latestDayFocus zeigt den jüngsten Wert, dessen WHO-Klassifikation und den Schritt zum Vortag; bmi.target ist das grüne Band (18.5-24.9).
- Fokus dieser Karte ist die WHO-BAND-EINORDNUNG, nicht das Gewichtstempo (das trägt die Gewichts-Karte). Setze den Schwerpunkt darauf, in welchem Band der Wert liegt und ob er ein Band wechselt oder sich einer Bandgrenze nähert.
- WHO-Bänder zur groben Einordnung, nie als Etikett für die Person: Untergewicht < 18.5, Normal 18.5-24.9, Übergewicht 25.0-29.9, Adipositas I 30.0-34.9, II 35.0-39.9, III ≥ 40.0. Ab 65+ gilt 22-27 als günstig (DEGAM).
-- Bandbezug: Vergleiche den recent-Wert gegen das weekly/monthly-Mittel und nenne es ausdrücklich, wenn der recent-Wert ein WHO-Band gewechselt hat oder sich (innerhalb ~0.5 BMI-Punkte) einer Bandgrenze nähert. Bleibt der Wert sicher im selben Band, sage das knapp — keine Tempo-Erzählung erzwingen.
+- Bandbezug: Führe mit bmi.signal — current, delta gegenüber baselineLabel und placement (Bandlage) sind bereits berechnet; NENNE sie, rechne sie NICHT neu. Nenne es ausdrücklich, wenn der recent-Wert ein WHO-Band gewechselt hat oder sich (innerhalb ~0.5 BMI-Punkte) einer Bandgrenze nähert. Bleibt der Wert sicher im selben Band, sage das knapp — keine Tempo-Erzählung erzwingen.
- Grenzen des BMI offen ansprechen: Er misst keine Körperzusammensetzung und ist bei muskulösen oder sehr sportlichen Personen wenig aussagekräftig.
- Eine Botschaft: Schließe NUR DANN mit EINEM machbaren Schritt, der zur Bandlage passt, wenn der Befund einen nahelegt — bei stabilem Wert im günstigen Band das ehrlich anerkennen und stattdessen einen Punkt nennen, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`;
const BMI_SECTION_EN = `METRIC — BMI:
-- The snapshot carries bmi.summary + bmi.series (graded). bmi.latestDayFocus shows the latest value, its WHO classification and the step from the prior day; bmi.target is the green band (18.5-24.9).
+- The snapshot carries bmi.signal (the finished comparison) + bmi.summary + bmi.series (graded). bmi.latestDayFocus shows the latest value, its WHO classification and the step from the prior day; bmi.target is the green band (18.5-24.9).
- This card's focus is WHO-BAND placement, not weight pace (the weight card carries that). Centre it on which band the value sits in and whether it crosses a band or nears a band boundary.
- WHO bands for rough placement, never as a label for the person: Underweight < 18.5, Normal 18.5-24.9, Overweight 25.0-29.9, Obesity I 30.0-34.9, II 35.0-39.9, III ≥ 40.0. From age 65+, 22-27 counts as favourable (DEGAM).
-- Band reference: compare the recent value against the weekly/monthly mean and call it out explicitly when the recent value has crossed a WHO band or is nearing a band boundary (within ~0.5 BMI points). When the value stays safely inside the same band, say so briefly — do not force a pace narrative.
+- Band reference: lead from bmi.signal — current, delta vs baselineLabel and placement (band position) are already computed; STATE them, do NOT recompute. Call it out explicitly when the recent value has crossed a WHO band or is nearing a band boundary (within ~0.5 BMI points). When the value stays safely inside the same band, say so briefly — do not force a pace narrative.
- Name BMI's limits plainly: it does not measure body composition and is weakly informative for muscular or very athletic people.
- One message: close with ONE doable step that fits the band placement ONLY when the finding implies one — when the value is stable in the favourable band, acknowledge that honestly and name one thing worth keeping an eye on rather than forcing a finding or a step.`;
diff --git a/src/lib/ai/prompts/medication-compliance.ts b/src/lib/ai/prompts/medication-compliance.ts
index b6daf9c2e..5a2bcffe0 100644
--- a/src/lib/ai/prompts/medication-compliance.ts
+++ b/src/lib/ai/prompts/medication-compliance.ts
@@ -2,8 +2,8 @@ import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const MEDCO_SECTION_DE = `METRIK — MEDIKAMENTEN-EINNAHMETREUE:
-- Der Snapshot trägt overall (medicationCount, averageCompliance7, averageCompliance30) und medications[] je Medikament mit name, dose, schedulesPerDay, compliance7, compliance30, streak, taken7/skipped7/missed7, dailySeries (graded) und latestDay.
-- Lies die Einnahmetreue als Prozentsatz erfüllter geplanter Dosen über die letzten 7 bzw. 30 Tage. Vergleiche compliance7 mit compliance30, um die jüngste Richtung gegen die eigene Baseline der Person zu sehen.
+- Der Snapshot trägt overall (medicationCount, averageCompliance7, averageCompliance30) und medications[] je Medikament mit name, dose, schedulesPerDay, compliance7, compliance30, streak, taken7/skipped7/missed7, signal (der fertige Trend-Vergleich), dailySeries (graded) und latestDay.
+- Lies die Einnahmetreue als Prozentsatz erfüllter geplanter Dosen über die letzten 7 bzw. 30 Tage; compliance7/compliance30 sind server-berechnet. Für die jüngste RICHTUNG gegen die eigene Baseline führe mit medications[].signal — current, delta gegenüber baselineLabel und outsideNormalSwing sind bereits berechnet; NENNE sie, rechne sie NICHT neu.
- Seltene Kadenz: Bei niedriger schedulesPerDay oder seltener Einnahme (z.B. Wocheninjektion) ist compliance7 rauschanfällig — eine einzige verpasste Dosis verschiebt den Prozentwert stark. Stütze die Einschätzung dann auf compliance30 (den tragenden Wert) und behandle compliance7 nur als groben Hinweis.
- Einordnung (zur Orientierung, nicht als Urteil): ≥ 90 % sehr verlässlich, 80-89 % gut, 70-79 % lückenhaft, < 70 % deutlich lückenhaft — die therapeutische Wirkung kann dann eingeschränkt sein.
- Positiv rahmen: streak ist die längste ununterbrochene Serie — bei guter Treue ausdrücklich anerkennen. Bei mehreren Medikamenten das mit der niedrigsten Treue benennen.
@@ -11,8 +11,8 @@ const MEDCO_SECTION_DE = `METRIK — MEDIKAMENTEN-EINNAHMETREUE:
- Eine Botschaft: Schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. Abenddosen werden häufiger vergessen — eine feste Routine oder ein Reminder zur kritischen Uhrzeit kann helfen). Bei durchgehend hoher Treue das ehrlich anerkennen, statt einen Schritt zu erzwingen. Keine Schuldzuweisung.`;
const MEDCO_SECTION_EN = `METRIC — MEDICATION ADHERENCE:
-- The snapshot carries overall (medicationCount, averageCompliance7, averageCompliance30) and medications[] per medication with name, dose, schedulesPerDay, compliance7, compliance30, streak, taken7/skipped7/missed7, dailySeries (graded) and latestDay.
-- Read adherence as the percentage of scheduled doses taken over the last 7 and 30 days. Compare compliance7 with compliance30 to see the recent direction against the person's own baseline.
+- The snapshot carries overall (medicationCount, averageCompliance7, averageCompliance30) and medications[] per medication with name, dose, schedulesPerDay, compliance7, compliance30, streak, taken7/skipped7/missed7, signal (the finished trend comparison), dailySeries (graded) and latestDay.
+- Read adherence as the percentage of scheduled doses taken over the last 7 and 30 days; compliance7/compliance30 are server-computed. For the recent DIRECTION against the person's own baseline, lead from medications[].signal — current, delta vs baselineLabel and outsideNormalSwing are already computed; STATE them, do NOT recompute.
- Rare cadence: with a low schedulesPerDay or an infrequent schedule (e.g. a weekly injection), compliance7 is noise-prone — a single missed dose swings the percentage sharply. Lean the assessment on compliance30 (the load-bearing value) and treat compliance7 only as a rough pointer.
- Placement (for orientation, not judgement): ≥ 90% very reliable, 80-89% good, 70-79% patchy, < 70% clearly patchy — therapeutic effect may then be reduced.
- Frame positively: streak is the longest uninterrupted run — acknowledge a good streak explicitly. With several medications, name the one with the lowest adherence.
diff --git a/src/lib/ai/prompts/mood.ts b/src/lib/ai/prompts/mood.ts
index 2662ae3cc..a8b14dccf 100644
--- a/src/lib/ai/prompts/mood.ts
+++ b/src/lib/ai/prompts/mood.ts
@@ -2,8 +2,8 @@ import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const MOOD_SECTION_DE = `METRIK — STIMMUNG / WOHLBEFINDEN:
-- Skala 1 (sehr schlecht) bis 5 (sehr gut). Der Snapshot trägt mood.summary + mood.series (graded, Tagesmittel), mood.target (grünes/oranges Band), mood.latestDayFocus und ggf. mood.tags (häufigste Stimmungs-Tags).
-- Wochenmuster zählt: Vergleiche das recent-Mittel mit dem weekly/monthly-Mittel der Person. Schwankungen < 0.5 Punkte über die letzten Tage sind normal und kein Befund.
+- Skala 1 (sehr schlecht) bis 5 (sehr gut). Der Snapshot trägt mood.signal (der fertige Vergleich) + mood.summary + mood.series (graded, Tagesmittel), mood.target (grünes/oranges Band), mood.latestDayFocus und ggf. mood.tags (häufigste Stimmungs-Tags).
+- Wochenmuster zählt: Führe mit mood.signal — current, delta gegenüber baselineLabel und outsideNormalSwing sind bereits berechnet. NENNE sie, rechne sie NICHT neu. Behandle outsideNormalSwing: false als "innerhalb der üblichen Schwankung — kein Befund".
- Anhaltende Phasen ernst, aber ruhig benennen: mehrere Tage unter ~2.5 oder über ~4.5 sind erwähnenswert — als Beobachtung, nie als Diagnose und nie alarmierend.
- Bei anhaltend sehr niedriger Stimmung (mehrere Tage deutlich unter ~2.5): biete ruhig und autonomie-wahrend einen Unterstützungs-Pfad an — etwa "bei anhaltender Belastung kann ein Gespräch mit einer Vertrauensperson oder einer Fachperson helfen". Nie alarmierend, nie als Diagnose, kein Notfall-Ton; eine sanfte Option, kein Imperativ.
- Tags: Falls mood.tags vorhanden, kannst du ein wiederkehrendes Tag als möglichen Kontext nennen, ohne einen Zusammenhang zu behaupten.
@@ -11,8 +11,8 @@ const MOOD_SECTION_DE = `METRIK — STIMMUNG / WOHLBEFINDEN:
- Eine Botschaft: Schließe NUR DANN mit EINEM machbaren, freundlichen Schritt, wenn der Befund einen nahelegt (z.B. an guten Tagen kurz festhalten, was geholfen hat). Bei stabil guter Stimmung das ehrlich anerkennen und stattdessen einen Punkt nennen, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`;
const MOOD_SECTION_EN = `METRIC — MOOD / WELL-BEING:
-- Scale 1 (very bad) to 5 (very good). The snapshot carries mood.summary + mood.series (graded daily means), mood.target (green/orange band), mood.latestDayFocus and, where present, mood.tags (most frequent mood tags).
-- The weekly pattern matters: compare the recent mean with the person's weekly/monthly mean. Swings < 0.5 points across the recent days are normal and not a finding.
+- Scale 1 (very bad) to 5 (very good). The snapshot carries mood.signal (the finished comparison) + mood.summary + mood.series (graded daily means), mood.target (green/orange band), mood.latestDayFocus and, where present, mood.tags (most frequent mood tags).
+- The weekly pattern matters: lead from mood.signal — current, delta vs baselineLabel and outsideNormalSwing are already computed. STATE them, do NOT recompute. Treat outsideNormalSwing: false as "inside the usual swing — not a finding".
- Name persistent phases seriously but calmly: several days below ~2.5 or above ~4.5 are worth noting — as an observation, never a diagnosis and never alarming.
- When mood stays very low for several days (clearly below ~2.5), offer a support pathway calmly and in an autonomy-preserving way — e.g. "if the strain persists, talking it through with someone you trust or a professional can help". Never alarming, never a diagnosis, no emergency tone; a gentle option, not an imperative.
- Tags: if mood.tags is present, you may name a recurring tag as possible context, without claiming a link.
diff --git a/src/lib/ai/prompts/pulse.ts b/src/lib/ai/prompts/pulse.ts
index da45290e7..345392701 100644
--- a/src/lib/ai/prompts/pulse.ts
+++ b/src/lib/ai/prompts/pulse.ts
@@ -2,16 +2,16 @@ import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const PULSE_SECTION_DE = `METRIK — PULS / HERZFREQUENZ:
-- Der Snapshot trägt pulse.summary + pulse.series (graded) und pulse.target (greenMin/greenMax/orangeMin/orangeMax, inTargetPctLast30DailyPoints). pulse.latestDayFocus zeigt den jüngsten Tageswert und den Schritt zum Vortag.
-- Tendenz über Wochen: Vergleiche das recent-Mittel mit dem weekly/monthly-Mittel der Person. Eine Abweichung ab ~5 bpm gegenüber der eigenen Baseline ist signifikant; eine Variabilität < 5 bpm über die letzten Tage ist normal und kein Befund.
+- Der Snapshot trägt pulse.signal (der fertige Vergleich), pulse.summary + pulse.series (graded) und pulse.target (greenMin/greenMax/orangeMin/orangeMax, inTargetPctLast30DailyPoints). pulse.latestDayFocus zeigt den jüngsten Tageswert und den Schritt zum Vortag.
+- Tendenz über Wochen: Führe mit pulse.signal — current, delta gegenüber baselineLabel und outsideNormalSwing sind bereits berechnet. NENNE sie, rechne sie NICHT neu. Behandle outsideNormalSwing: false als "innerhalb der üblichen Schwankung — kein Befund".
- Richtung: Ein über Wochen sinkender Ruhepuls deutet auf bessere kardiovaskuläre Fitness; ein steigender kann auf Stress, Schlafmangel oder Übertraining hinweisen — als mögliche Erklärung, nie als Diagnose.
- Einordnungs-Anker (Ruhepuls): athletisch 50-60, normal 60-100. Werte im Band 80-100 sind technisch normal, langfristig aber günstiger niedriger. < 50 oder > 100 über mehrere Tage ist erwähnenswert, ohne zu alarmieren.
- Medikamenten-Bezug: Betablocker senken die Herzfrequenz. moodContext.moodVsPulseCorrelation und ein pulseVsSystolic-Zusammenhang nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache.
- Eine Botschaft: Liegt der Ruhepuls über der eigenen Baseline, schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. ein paar Tage morgens vor dem Aufstehen messen, um eine saubere Ruhe-Baseline zu bekommen). Verbessert er sich oder ist er stabil, erkenne das ehrlich an und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`;
const PULSE_SECTION_EN = `METRIC — PULSE / HEART RATE:
-- The snapshot carries pulse.summary + pulse.series (graded) and pulse.target (greenMin/greenMax/orangeMin/orangeMax, inTargetPctLast30DailyPoints). pulse.latestDayFocus shows the latest daily value and the step from the prior day.
-- Trend over weeks: compare the recent mean with the person's weekly/monthly mean. A shift of ~5 bpm against their own baseline is significant; variability < 5 bpm across the recent days is normal and not a finding.
+- The snapshot carries pulse.signal (the finished comparison), pulse.summary + pulse.series (graded) and pulse.target (greenMin/greenMax/orangeMin/orangeMax, inTargetPctLast30DailyPoints). pulse.latestDayFocus shows the latest daily value and the step from the prior day.
+- Trend over weeks: lead from pulse.signal — current, delta vs baselineLabel and outsideNormalSwing are already computed. STATE them, do NOT recompute. Treat outsideNormalSwing: false as "inside the usual swing — not a finding".
- Direction: a resting pulse falling over weeks suggests improving cardiovascular fitness; a rising one can point to stress, sleep loss or overtraining — as a possible explanation, never a diagnosis.
- Placement anchors (resting pulse): athletic 50-60, normal 60-100. Values in the 80-100 band are technically normal but tend to be more favourable lower over the long run. < 50 or > 100 across several days is worth noting without alarm.
- Medication link: beta-blockers lower heart rate. Mention moodContext.moodVsPulseCorrelation and any pulse-vs-systolic association only when present and |r| > 0.4 — as an association, never a cause.
diff --git a/src/lib/ai/prompts/weight.ts b/src/lib/ai/prompts/weight.ts
index f4480dd68..97bc1bf90 100644
--- a/src/lib/ai/prompts/weight.ts
+++ b/src/lib/ai/prompts/weight.ts
@@ -2,9 +2,9 @@ import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const WEIGHT_SECTION_DE = `METRIK — GEWICHT:
-- Der Snapshot trägt weight.summary + weight.series (graded). weight.latestDayFocus zeigt den jüngsten Tageswert, den Schritt zum vorherigen Messtag und ggf. den Blutdruck desselben Tages.
+- Der Snapshot trägt weight.signal (der fertige Vergleich) + weight.summary + weight.series (graded). weight.latestDayFocus zeigt den jüngsten Tageswert, den Schritt zum vorherigen Messtag und ggf. den Blutdruck desselben Tages.
- Fokus dieser Karte ist der kontinuierliche TREND und das TEMPO über die Zeit (kg/Woche, Plateaus, Meilensteine), nicht die WHO-Band-Einordnung — die trägt die BMI-Karte. Den BMI-Wert nur als Nebenbezug nennen, nie das Band als zentrale Aussage hier.
-- Wochen-/Monatstrend zählt, nicht der Einzelwert: Vergleiche das recent-Mittel mit dem weekly/monthly-Mittel der Person. Tageschwankungen von 1-2 kg (Wasser, Tageszeit) sind normal und kein Befund.
+- Wochen-/Monatstrend zählt, nicht der Einzelwert: Führe mit weight.signal — current, delta gegenüber baselineLabel und outsideNormalSwing sind bereits berechnet. NENNE sie, rechne sie NICHT neu. Tageschwankungen von 1-2 kg (Wasser, Tageszeit) sind normal und kein Befund.
- Plateau: Verändert sich das Mittel über > ~2 Wochen um < 0.5 kg, ist das ein Plateau — bei einem Abnehmenden ehrlich benennen, ohne zu entmutigen.
- Tempo: Nachhaltiger Verlust liegt bei ~0.5-1.0 kg/Woche (DGE). Anhaltend > 1 kg/Woche ist eher Wasser/Muskel als Fett — vorsichtig einordnen.
- Meilensteine gegen das eigene Maximum: ~5 % Verlust vom höchsten Wert (series-Max) bringt bereits metabolischen Nutzen, ~10 % einen deutlichen — wenn erreicht, ausdrücklich anerkennen.
@@ -13,9 +13,9 @@ const WEIGHT_SECTION_DE = `METRIK — GEWICHT:
- Eine Botschaft: Schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. bei einem Plateau zur selben Tageszeit wiegen, um den echten Trend zu sehen, statt am Tagesrauschen zu hängen). Ist der Trend stabil und gibt es nichts Sinnvolles zu tun, erkenne das ehrlich an und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`;
const WEIGHT_SECTION_EN = `METRIC — WEIGHT:
-- The snapshot carries weight.summary + weight.series (graded). weight.latestDayFocus shows the latest daily value, the step from the previous measured day and, where present, the same-day blood pressure.
+- The snapshot carries weight.signal (the finished comparison) + weight.summary + weight.series (graded). weight.latestDayFocus shows the latest daily value, the step from the previous measured day and, where present, the same-day blood pressure.
- This card's focus is the continuous TREND and PACE over time (kg/week, plateaus, milestones), not WHO-band placement — the BMI card carries that. Mention the BMI value only as a side reference, never make the band the central message here.
-- The weekly/monthly trend matters, not the single value: compare the recent mean with the person's weekly/monthly mean. Day-to-day swings of 1-2 kg (water, time of day) are normal and not a finding.
+- The weekly/monthly trend matters, not the single value: lead from weight.signal — current, delta vs baselineLabel and outsideNormalSwing are already computed. STATE them, do NOT recompute. Day-to-day swings of 1-2 kg (water, time of day) are normal and not a finding.
- Plateau: if the mean moves < 0.5 kg over > ~2 weeks, that is a plateau — name it honestly for someone who is losing, without discouraging.
- Pace: sustainable loss is ~0.5-1.0 kg/week (DGE). Sustained > 1 kg/week is more likely water/muscle than fat — frame cautiously.
- Milestones against their own maximum: ~5% loss from the highest value (series max) already brings metabolic benefit, ~10% a substantial one — acknowledge explicitly when reached.
diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts
index 1c010abbb..ec0017405 100644
--- a/src/lib/insights/__tests__/blood-pressure-status.test.ts
+++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts
@@ -108,7 +108,16 @@ describe("generateBloodPressureStatusForUser — graded payload", () => {
expect(sys).toHaveProperty("recent");
expect(sys).toHaveProperty("weekly");
expect(sys).toHaveProperty("monthly");
- expect(sys).toHaveProperty("yearly");
+ // v1.18.10 (HIGH-4) — the per-channel SIGNAL block (the finished
+ // recent-vs-baseline comparison) now rides the snapshot so the model
+ // states the trend rather than re-deriving it.
+ expect(snapshot.bloodPressure.systolic.signal).toHaveProperty("current");
+ expect(snapshot.bloodPressure.systolic.signal).toHaveProperty(
+ "outsideNormalSwing",
+ );
+ // `yearly` is the lowest-signal slice and the FIRST the budget degrader
+ // sheds; on this deliberately-large 1000-day fixture it may be dropped to
+ // make room. The recent/weekly/monthly core + the signal block stay.
expect(sys.recent.length).toBeLessThanOrEqual(21);
expect(sys.recent[0]).toHaveProperty("date");
expect(sys.recent[0]).toHaveProperty("mean");
diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts
index 34813a702..1b2b10e90 100644
--- a/src/lib/insights/blood-pressure-status.ts
+++ b/src/lib/insights/blood-pressure-status.ts
@@ -39,6 +39,7 @@ import {
buildGradedSeriesWithRollups,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
+import { buildMetricSignal } from "@/lib/insights/metric-signal";
import {
type SupportedLocale,
normalizeLocale,
@@ -505,6 +506,31 @@ export async function prepareBloodPressureStatusForUser(
)
: null;
+ // v1.18.10 (HIGH-4) — hand the model the finished recent-vs-baseline
+ // comparison + normal-swing verdict per channel instead of asking it to
+ // derive them. BP needs two signals; the user's own targets are the coarse
+ // band anchor when present.
+ const sysSignal = buildMetricSignal({
+ metric: locale === "en" ? "your systolic" : "deine Systole",
+ unit: "mmHg",
+ direction: "lower-better",
+ graded: sysGraded,
+ normalRange: bpTargets
+ ? { low: bpTargets.sysLow, high: bpTargets.sysHigh }
+ : null,
+ newestDaysAgo: newestMeasurementDaysAgo,
+ });
+ const diaSignal = buildMetricSignal({
+ metric: locale === "en" ? "your diastolic" : "deine Diastole",
+ unit: "mmHg",
+ direction: "lower-better",
+ graded: diaGraded,
+ normalRange: bpTargets
+ ? { low: bpTargets.diaLow, high: bpTargets.diaHigh }
+ : null,
+ newestDaysAgo: newestMeasurementDaysAgo,
+ });
+
const snapshot = {
locale,
generatedForDay: todayKey,
@@ -516,12 +542,14 @@ export async function prepareBloodPressureStatusForUser(
},
bloodPressure: {
systolic: {
+ ...(sysSignal ? { signal: sysSignal } : {}),
summary: summarizeSeries(
sysSeries.daily.map((bucket) => ({ value: bucket.value })),
),
series: sysGraded,
},
diastolic: {
+ ...(diaSignal ? { signal: diaSignal } : {}),
summary: summarizeSeries(
diaSeries.daily.map((bucket) => ({ value: bucket.value })),
),
diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts
index 39f86af73..51ee25ad2 100644
--- a/src/lib/insights/bmi-status.ts
+++ b/src/lib/insights/bmi-status.ts
@@ -17,6 +17,7 @@ import {
scaleGradedSeries,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
+import { buildMetricSignal } from "@/lib/insights/metric-signal";
import {
type SupportedLocale,
normalizeLocale,
@@ -244,6 +245,17 @@ export async function prepareBmiStatusForUser(
)
: null;
+ // v1.18.10 (HIGH-4) — hand the model the finished recent-vs-baseline
+ // comparison + normal-swing verdict instead of asking it to derive them.
+ // BMI reads against the WHO healthy band; "target-band" framing.
+ const bmiSignal = buildMetricSignal({
+ metric: locale === "en" ? "your BMI" : "dein BMI",
+ direction: "target-band",
+ graded: bmiGraded,
+ normalRange: { low: 18.5, high: 24.9 },
+ newestDaysAgo: newestMeasurementDaysAgo,
+ });
+
const snapshot = {
locale,
generatedForDay: todayKey,
@@ -254,6 +266,7 @@ export async function prepareBmiStatusForUser(
newestMeasurementDaysAgo,
},
bmi: {
+ ...(bmiSignal ? { signal: bmiSignal } : {}),
summary: summarizeSeries(
bmiSeries.daily.map((bucket) => ({ value: bucket.value })),
),
diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts
index 312a8cd32..765178053 100644
--- a/src/lib/insights/medication-compliance-status.ts
+++ b/src/lib/insights/medication-compliance-status.ts
@@ -24,6 +24,7 @@ import {
} from "@/lib/insights/assessment-context";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { buildGradedSeriesFromPoints } from "@/lib/insights/graded-series";
+import { buildMetricSignal } from "@/lib/insights/metric-signal";
import { degradeStatusSnapshotToBudget } from "@/lib/insights/graded-series";
import {
type SupportedLocale,
@@ -307,6 +308,24 @@ export async function prepareMedicationComplianceStatusForUser(
const dailySeries = buildGradedSeriesFromPoints(perDayRecords, now);
const latestDay = dailyBudgeted.daily[0] ?? null;
+ // v1.18.10 (HIGH-4) — the finished recent-vs-baseline adherence comparison
+ // + normal-swing verdict, computed from the canonical per-day rate series
+ // so the model states the trend rather than deriving it. The compliance %
+ // itself stays server-computed (compliance7/30 above); this adds only the
+ // trend signal. Adherence reads higher-better; the band is 0–100%.
+ const signal = buildMetricSignal({
+ metric:
+ locale === "en"
+ ? `adherence for ${sanitizeForPrompt(medication.name)}`
+ : `Einnahmetreue für ${sanitizeForPrompt(medication.name)}`,
+ unit: "%",
+ direction: "higher-better",
+ graded: dailySeries,
+ // Recency is surfaced separately via dataCoverage.newestMeasurementDaysAgo
+ // (computed after this map); the per-med signal leaves it null.
+ newestDaysAgo: null,
+ });
+
return {
medicationId: medication.id,
name: sanitizeForPrompt(medication.name),
@@ -319,6 +338,7 @@ export async function prepareMedicationComplianceStatusForUser(
taken7: compliance7.taken,
skipped7: compliance7.skipped,
missed7: compliance7.missed,
+ ...(signal ? { signal } : {}),
dailySeries,
latestDay,
};
diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts
index ae6333cdf..e651fca14 100644
--- a/src/lib/insights/mood-status.ts
+++ b/src/lib/insights/mood-status.ts
@@ -36,6 +36,7 @@ import {
buildGradedSeriesFromPoints,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
+import { buildMetricSignal } from "@/lib/insights/metric-signal";
import {
type SupportedLocale,
normalizeLocale,
@@ -496,6 +497,18 @@ export async function prepareMoodStatusForUser(
now,
});
+ // v1.18.10 (HIGH-4) — hand the model the finished recent-vs-baseline
+ // comparison + normal-swing verdict instead of asking it to derive them.
+ // Mood reads higher-better against the green band.
+ const moodSignal = buildMetricSignal({
+ metric: locale === "en" ? "your mood" : "deine Stimmung",
+ unit: "/5",
+ direction: "higher-better",
+ graded: moodGraded,
+ normalRange: { low: greenMin, high: greenMax },
+ newestDaysAgo: newestEntryDaysAgo,
+ });
+
const snapshot = {
locale,
generatedForDay: todayKey,
@@ -506,6 +519,7 @@ export async function prepareMoodStatusForUser(
newestEntryDaysAgo,
},
mood: {
+ ...(moodSignal ? { signal: moodSignal } : {}),
summary: moodSummary,
series: moodGraded,
latestDayFocus: latestMood
diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts
index 01e5f72bb..c297be593 100644
--- a/src/lib/insights/pulse-status.ts
+++ b/src/lib/insights/pulse-status.ts
@@ -25,6 +25,7 @@ import {
buildGradedSeriesWithRollups,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
+import { buildMetricSignal } from "@/lib/insights/metric-signal";
import {
type SupportedLocale,
normalizeLocale,
@@ -321,6 +322,19 @@ export async function preparePulseStatusForUser(
)
: null;
+ // v1.18.10 (HIGH-4) — feed the model the finished recent-vs-baseline
+ // comparison + normal-swing verdict instead of asking it to derive them from
+ // the raw buckets (the "model computes a number" pattern the mandate bans).
+ // Resting pulse reads lower-better; the green band is the coarse anchor.
+ const pulseSignal = buildMetricSignal({
+ metric: locale === "en" ? "your resting pulse" : "dein Ruhepuls",
+ unit: "bpm",
+ direction: "lower-better",
+ graded: pulseGraded,
+ normalRange: { low: pulseTarget.greenMin, high: pulseTarget.greenMax },
+ newestDaysAgo: newestMeasurementDaysAgo,
+ });
+
const snapshot = {
locale,
generatedForDay: todayKey,
@@ -331,6 +345,7 @@ export async function preparePulseStatusForUser(
newestMeasurementDaysAgo,
},
pulse: {
+ ...(pulseSignal ? { signal: pulseSignal } : {}),
summary: pulseSummary,
series: pulseGraded,
latestDayFocus: latestPulse
diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts
index f27b94750..2608c870b 100644
--- a/src/lib/insights/weight-status.ts
+++ b/src/lib/insights/weight-status.ts
@@ -28,6 +28,7 @@ import {
buildGradedSeriesWithRollups,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
+import { buildMetricSignal } from "@/lib/insights/metric-signal";
import {
type SupportedLocale,
normalizeLocale,
@@ -382,6 +383,18 @@ export async function prepareWeightStatusForUser(
)
: null;
+ // v1.18.10 (HIGH-4) — hand the model the finished recent-vs-baseline
+ // comparison + normal-swing verdict instead of asking it to derive them.
+ // Weight has no universal favourable direction or population band, so the
+ // signal carries the neutral "target-band" framing and no normalRange.
+ const weightSignal = buildMetricSignal({
+ metric: locale === "en" ? "your weight" : "dein Gewicht",
+ unit: "kg",
+ direction: "target-band",
+ graded: weightGraded,
+ newestDaysAgo: newestMeasurementDaysAgo,
+ });
+
const snapshot = {
locale,
generatedForDay: todayKey,
@@ -392,6 +405,7 @@ export async function prepareWeightStatusForUser(
newestMeasurementDaysAgo,
},
weight: {
+ ...(weightSignal ? { signal: weightSignal } : {}),
summary: summarizeSeries(
weightSeries.daily.map((bucket) => ({ value: bucket.value })),
),
From 883383c6a65b85cfd45f1a103905b059ba570227 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 16:58:45 +0200
Subject: [PATCH 26/32] test(coach): assert grounding + GLP-1 contract in every
locale's prompt
The safety-contract matrix parity test checks rule bodies are non-empty;
this asserts the resolved getCoachSystemPrompt(locale) string carries the
grounding marker and the GLP-1 dose-safety contract for all six UI
locales, closing the gap where a non-EN/DE locale could silently fall
back to a weaker EN-plus-footer body.
---
.../system-prompt-contract-parity.test.ts | 57 +++++++++++++++++++
1 file changed, 57 insertions(+)
create mode 100644 src/lib/ai/coach/__tests__/system-prompt-contract-parity.test.ts
diff --git a/src/lib/ai/coach/__tests__/system-prompt-contract-parity.test.ts b/src/lib/ai/coach/__tests__/system-prompt-contract-parity.test.ts
new file mode 100644
index 000000000..2f916ccb6
--- /dev/null
+++ b/src/lib/ai/coach/__tests__/system-prompt-contract-parity.test.ts
@@ -0,0 +1,57 @@
+import { describe, it, expect } from "vitest";
+
+import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
+import { loadSafetyContracts } from "@/lib/ai/prompts/safety-contracts";
+import type { Locale } from "@/lib/i18n/config";
+
+/**
+ * v1.18.10 (MEDIUM-1) — resolved-Coach-prompt contract parity.
+ *
+ * `safety-contracts-parity.test.ts` asserts every ground-rule BODY in the
+ * matrix is non-empty per locale. This file goes one step further and asserts
+ * the *resolved* `getCoachSystemPrompt(locale)` string — the exact text the
+ * provider sees — carries the grounding + GLP-1 dose-safety contract for EVERY
+ * UI locale, not just de/en. The audit's residual MEDIUM-1 risk was that a
+ * non-EN/DE locale could silently fall back to the EN-plus-footer path (or a
+ * native body that weakened the wording) and ship a less-strict Coach. These
+ * assertions fail the build if that ever happens.
+ *
+ * Markers used:
+ * - grounding: the literal "SNAPSHOT" token, which the grounding rule's
+ * `must_contain` pins in every locale.
+ * - GLP-1 dose safety: the brand registry (Mounjaro / Ozempic), which is
+ * identical across all six locales (an international registry, never
+ * translated) and only ever appears inside the dose-safety contract.
+ */
+
+const ALL_LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl"];
+
+describe("resolved Coach system prompt — grounding + GLP-1 per locale", () => {
+ for (const locale of ALL_LOCALES) {
+ it(`${locale} carries a grounding marker`, () => {
+ const prompt = getCoachSystemPrompt(locale);
+ expect(prompt).toContain("SNAPSHOT");
+ });
+
+ it(`${locale} carries the GLP-1 dose-safety contract`, () => {
+ const prompt = getCoachSystemPrompt(locale);
+ // Brand names are identical across locales and only appear in the
+ // dose-safety contract — their presence proves the contract is composed.
+ expect(prompt).toMatch(/Mounjaro/);
+ expect(prompt).toMatch(/Ozempic/);
+ });
+
+ it(`${locale} never silently empties to a bare EN footer`, () => {
+ const prompt = getCoachSystemPrompt(locale);
+ expect(prompt.trim().length).toBeGreaterThan(500);
+ });
+ }
+});
+
+describe("GLP-1 brand registry stays identical across locales (marker basis)", () => {
+ const enBrands = loadSafetyContracts("en").glp1_brand_list;
+ it("EN brand list is non-empty (the marker source)", () => {
+ expect(enBrands.length).toBeGreaterThan(0);
+ expect(enBrands).toContain("Mounjaro");
+ });
+});
From 320c33b918eff55837f8d83af1e047a9d0cd25ac Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 17:10:24 +0200
Subject: [PATCH 27/32] lint: enforce api-fetch wrapper across src/lib/queries
Add src/lib/queries/ to the api-fetch-required ENFORCED_ROOTS so the
rule matches its own header comment, then route the two flagged query
hooks through apiGet instead of hand-rolling the envelope unwrap.
---
eslint-plugins/healthlog/api-fetch-required.js | 12 ++++++++++--
src/lib/queries/use-achievements-query.ts | 10 +++-------
src/lib/queries/use-analytics-query.ts | 17 +++++------------
3 files changed, 18 insertions(+), 21 deletions(-)
diff --git a/eslint-plugins/healthlog/api-fetch-required.js b/eslint-plugins/healthlog/api-fetch-required.js
index 028bb33ce..67da72ca5 100644
--- a/eslint-plugins/healthlog/api-fetch-required.js
+++ b/eslint-plugins/healthlog/api-fetch-required.js
@@ -43,8 +43,16 @@ const EXEMPT_FILES = ["src/lib/api/api-fetch.ts"];
// server-side lib code never self-calls `/api/` routes, and the
// safe-fetch rule already covers its outbound calls. `src/hooks/` is
// client-only by construction (v1.16.4) — its `/api/` reads route
-// through the wrapper like every component.
-const ENFORCED_ROOTS = ["src/components/", "src/app/", "src/hooks/"];
+// through the wrapper like every component. `src/lib/queries/` is the
+// one `src/lib/` exception: it holds client-only TanStack-Query hooks
+// (every file is `"use client"`), so its `/api/` reads route through the
+// wrapper too.
+const ENFORCED_ROOTS = [
+ "src/components/",
+ "src/app/",
+ "src/hooks/",
+ "src/lib/queries/",
+];
function toPosix(filename) {
return filename.replace(/\\/g, "/");
diff --git a/src/lib/queries/use-achievements-query.ts b/src/lib/queries/use-achievements-query.ts
index f1d42efec..88f7f7574 100644
--- a/src/lib/queries/use-achievements-query.ts
+++ b/src/lib/queries/use-achievements-query.ts
@@ -3,6 +3,7 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
+import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type {
AchievementMetrics,
@@ -65,13 +66,8 @@ export interface UseAchievementsQueryOptions {
refetchInterval?: number | false;
}
-async function fetchAchievements(): Promise {
- const res = await fetch("/api/gamification/achievements");
- if (!res.ok) {
- throw new Error(`Failed to load achievements (${res.status})`);
- }
- const json = (await res.json()) as { data: AchievementsPayload };
- return json.data;
+function fetchAchievements(): Promise {
+ return apiGet("/api/gamification/achievements");
}
/**
diff --git a/src/lib/queries/use-analytics-query.ts b/src/lib/queries/use-analytics-query.ts
index 2fb964a2b..52282a445 100644
--- a/src/lib/queries/use-analytics-query.ts
+++ b/src/lib/queries/use-analytics-query.ts
@@ -3,6 +3,7 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
+import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
import type { DataSummary } from "@/lib/analytics/trends";
@@ -114,18 +115,10 @@ export interface UseAnalyticsQueryOptions {
enabled?: boolean;
}
-async function fetchAnalytics(
- slice: AnalyticsSlice,
-): Promise {
- const res =
- slice === "summaries"
- ? await fetch("/api/analytics?slice=summaries")
- : await fetch("/api/analytics");
- if (!res.ok) {
- throw new Error(`Failed to load analytics (${res.status})`);
- }
- const json = (await res.json()) as { data: AnalyticsRawPayload };
- return json.data;
+function fetchAnalytics(slice: AnalyticsSlice): Promise {
+ return apiGet(
+ slice === "summaries" ? "/api/analytics?slice=summaries" : "/api/analytics",
+ );
}
/**
From a9c327401af1fa243347977f47318aed509bb3a7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 17:10:36 +0200
Subject: [PATCH 28/32] fix(insights): key readiness + mood-streak day buckets
in user tz
The readiness same-day grouping and the mood-streak anchor/walk both
sliced the UTC instant for their day key, mis-bucketing a late-evening
reading for a logger east or west of UTC. Thread an optional tz (default
Europe/Berlin) through both paths and key the buckets via userDayKey so
the day boundary follows the user's wall clock. Centralise the inline
day-in-ms literal in a shared time-constants module and use it here.
---
src/lib/insights/derived/readiness.ts | 24 ++++++++++++++++++++----
src/lib/insights/mood-narratives.ts | 26 ++++++++++++++++++++------
src/lib/time-constants.ts | 17 +++++++++++++++++
3 files changed, 57 insertions(+), 10 deletions(-)
create mode 100644 src/lib/time-constants.ts
diff --git a/src/lib/insights/derived/readiness.ts b/src/lib/insights/derived/readiness.ts
index 80c22acd4..87e1c5603 100644
--- a/src/lib/insights/derived/readiness.ts
+++ b/src/lib/insights/derived/readiness.ts
@@ -40,6 +40,8 @@ import {
type RollupCoverageMap,
} from "@/lib/rollups/measurement-coverage";
import { computeMoodStability } from "@/lib/insights/mood-aggregates";
+import { MS_PER_DAY } from "@/lib/time-constants";
+import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
import {
buildInsufficient,
buildOk,
@@ -50,7 +52,6 @@ import { computeVitalsBaseline, type BaselineProfile } from "./baseline";
import { computeSleepScore } from "./sleep-score";
import type { Derived, DerivedProvenanceSource } from "./types";
-const MS_PER_DAY = 24 * 60 * 60 * 1000;
const DEFAULT_WINDOW_DAYS = 30;
/**
* Row cap for the latest-day mean read. A dense intra-day day (high-frequency
@@ -171,6 +172,7 @@ async function readLatestDayMean(
type: MeasurementType,
windowDays: number,
now: Date,
+ tz: string,
): Promise {
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
const rows = await prisma.measurement.findMany({
@@ -181,13 +183,15 @@ async function readLatestDayMean(
});
if (rows.length === 0) return null;
// Derive the most-recent day defensively (do not assume the DB ordering).
+ // Day-keys are minted in the user's timezone so a 23:30-local reading lands
+ // on the right "today" bucket regardless of UTC offset.
let latestDay = "";
for (const r of rows) {
- const d = r.measuredAt.toISOString().slice(0, 10);
+ const d = userDayKey(r.measuredAt, tz);
if (d > latestDay) latestDay = d;
}
const sameDay = rows.filter(
- (r) => r.measuredAt.toISOString().slice(0, 10) === latestDay,
+ (r) => userDayKey(r.measuredAt, tz) === latestDay,
);
return sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length;
}
@@ -216,6 +220,7 @@ async function scoreVitalDeviation(
windowDays: number,
now: Date,
coverage: RollupCoverageMap,
+ tz: string,
): Promise<{
value: number | null;
source: DerivedProvenanceSource;
@@ -235,7 +240,7 @@ async function scoreVitalDeviation(
historyDays: baseline.coverage.historyDays,
};
}
- const today = await readLatestDayMean(userId, type, windowDays, now);
+ const today = await readLatestDayMean(userId, type, windowDays, now, tz);
// No recent reading, or a non-finite baseline center / today value: there
// is no scorable deviation. Return null (hidden) rather than letting a NaN
// fall through `scoreDeviation` → `clamp100`, which would surface a
@@ -271,6 +276,13 @@ export interface ReadinessOpts {
now?: Date;
/** Pre-probed coverage (shared across metrics in one request). */
coverage?: RollupCoverageMap;
+ /**
+ * IANA timezone the "same day" grouping is keyed in, so a 23:30-local
+ * reading lands on the correct readiness day. Defaults to `Europe/Berlin`
+ * (the canonical tenant zone) when the caller has not resolved the user's
+ * zone.
+ */
+ tz?: string;
}
/**
@@ -284,6 +296,7 @@ export async function computeReadiness(
): Promise> {
const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
const now = opts.now ?? new Date();
+ const tz = opts.tz ?? DEFAULT_TIMEZONE;
const computedAt = nowProvenanceTimestamp(now);
const coverage = opts.coverage ?? (await probeRollupCoverage(userId));
@@ -305,6 +318,7 @@ export async function computeReadiness(
windowDays,
now,
coverage,
+ tz,
),
scoreVitalDeviation(
userId,
@@ -314,6 +328,7 @@ export async function computeReadiness(
windowDays,
now,
coverage,
+ tz,
),
scoreVitalDeviation(
userId,
@@ -323,6 +338,7 @@ export async function computeReadiness(
windowDays,
now,
coverage,
+ tz,
),
]);
diff --git a/src/lib/insights/mood-narratives.ts b/src/lib/insights/mood-narratives.ts
index 43ed97069..243d26e22 100644
--- a/src/lib/insights/mood-narratives.ts
+++ b/src/lib/insights/mood-narratives.ts
@@ -16,6 +16,8 @@
import { dayOffsetToBerlinDayKey } from "@/lib/insights/bucket-series";
import { round } from "@/lib/insights/status-shared";
+import { MS_PER_DAY } from "@/lib/time-constants";
+import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
import type {
StructuredTagRow,
TagSummaryRow,
@@ -80,6 +82,13 @@ export interface MoodNarrativeInput {
/** Day keys (YYYY-MM-DD) the user logged a mood on. */
loggedDayKeys: string[];
now: Date;
+ /**
+ * IANA timezone the streak anchor + day-walk are keyed in, so the "today"
+ * key matches the tz-anchored `loggedDayKeys` for a logger east/west of
+ * UTC near midnight. Defaults to `Europe/Berlin` when the caller has not
+ * resolved the user's zone.
+ */
+ tz?: string;
}
/** i18n label keys for each part-of-day bucket. */
@@ -100,8 +109,6 @@ const WEEKDAY_LABEL_KEYS = [
"charts.weekdaysFull.sun",
] as const;
-const MS_PER_DAY = 24 * 60 * 60 * 1000;
-
function mean(values: number[]): number | null {
if (values.length === 0) return null;
return values.reduce((sum, v) => sum + v, 0) / values.length;
@@ -349,22 +356,29 @@ function inTargetNarrative(input: MoodNarrativeInput): MoodNarrative | null {
function streakNarrative(input: MoodNarrativeInput): MoodNarrative | null {
if (input.loggedDayKeys.length === 0) return null;
+ const tz = input.tz ?? DEFAULT_TIMEZONE;
const keys = new Set(input.loggedDayKeys);
- const todayKey = input.now.toISOString().slice(0, 10);
- const todayMs = Date.parse(todayKey + "T00:00:00Z");
+ // Anchor "today" in the user's timezone so the key matches the tz-anchored
+ // `loggedDayKeys` for a late-evening logger east/west of UTC.
+ const todayKey = userDayKey(input.now, tz);
+ // Walk the cursor from a NOON-UTC anchor of the day key: ±12h covers every
+ // IANA offset, so `new Date(cursor)` always re-derives the same calendar day
+ // in the user's zone regardless of whether they are east or west of UTC.
+ const noonMs = (key: string) => Date.parse(key + "T12:00:00Z");
+ const todayMs = noonMs(todayKey);
// Anchor the streak at today if logged, else the most recent logged day.
let anchorMs = todayMs;
if (!keys.has(todayKey)) {
const sorted = [...keys].sort().reverse();
- anchorMs = Date.parse(sorted[0] + "T00:00:00Z");
+ anchorMs = noonMs(sorted[0]);
// A streak that does not include today is stale — don't congratulate.
if (anchorMs !== todayMs) return null;
}
let run = 0;
let cursor = anchorMs;
- while (keys.has(new Date(cursor).toISOString().slice(0, 10))) {
+ while (keys.has(userDayKey(new Date(cursor), tz))) {
run += 1;
cursor -= MS_PER_DAY;
}
diff --git a/src/lib/time-constants.ts b/src/lib/time-constants.ts
new file mode 100644
index 000000000..e1f8c3b02
--- /dev/null
+++ b/src/lib/time-constants.ts
@@ -0,0 +1,17 @@
+/**
+ * Shared duration constants in milliseconds.
+ *
+ * Replaces the inline `24 * 60 * 60 * 1000` (and friends) scattered across
+ * the codebase. Centralising the literal keeps the intent readable at the
+ * call site and removes the off-by-a-zero risk of hand-typing the product.
+ * Pure module — no imports, safe to use anywhere (server or client).
+ */
+
+/** One minute in milliseconds. */
+export const MS_PER_MINUTE = 60 * 1000;
+
+/** One hour in milliseconds. */
+export const MS_PER_HOUR = 60 * MS_PER_MINUTE;
+
+/** One day in milliseconds. */
+export const MS_PER_DAY = 24 * MS_PER_HOUR;
From 643c33250b46dead67cefe0ed3d1fb60eb1dbc45 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 17:10:48 +0200
Subject: [PATCH 29/32] perf(settings): code-split qrcode out of the sharing
chunk
The QR is only rendered after a share link is created, so import qrcode
lazily inside the effect instead of statically at module top. The library
no longer ships in the initial settings bundle.
---
src/components/settings/sharing-section.tsx | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx
index d42000c7f..2c389dbc1 100644
--- a/src/components/settings/sharing-section.tsx
+++ b/src/components/settings/sharing-section.tsx
@@ -23,7 +23,6 @@
*/
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import QRCode from "qrcode";
import { Copy, KeyRound, Loader2, Share2, Trash2 } from "lucide-react";
import {
@@ -136,7 +135,13 @@ function ShareLinksCard() {
useEffect(() => {
if (!qrUrl) return;
let cancelled = false;
- QRCode.toDataURL(qrUrl, { margin: 1, width: 220 })
+ // Dynamic import so `qrcode` code-splits out of the settings chunk — the
+ // QR is only ever rendered after a link is created, so the library never
+ // needs to ship eagerly.
+ import("qrcode")
+ .then(({ default: QRCode }) =>
+ QRCode.toDataURL(qrUrl, { margin: 1, width: 220 }),
+ )
.then((url) => {
if (!cancelled) setQrDataUrl(url);
})
From 8ef61b02fe44232b2d749c8f34a2a9f1e855e58b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 17:15:35 +0200
Subject: [PATCH 30/32] fix(mobile): keep Coach composer above the bottom nav,
restore full hub labels
Size the full-page Coach surface to clear the mobile BottomNav band plus the
iOS safe area so the docked composer and Stop control never sit under the nav;
desktop full-bleed height is unchanged (the nav is mobile-only).
Stop truncating the More-hub labels: shrink the label type and allow a
two-line clamp so long words (German Benachrichtigungen, and the long es/fr/it
labels) read in full instead of ellipsising.
Raise the lab reading-history edit/delete controls to 44px on touch
(compact on desktop), and drop the lab-detail Add button to icon-only on the
narrowest phones.
---
src/app/coach/page.tsx | 10 +++++++++-
src/components/labs/lab-biomarker-detail.tsx | 9 +++++++--
src/components/labs/lab-history-list.tsx | 6 ++++--
src/components/layout/bottom-nav.tsx | 20 +++++++++++---------
4 files changed, 31 insertions(+), 14 deletions(-)
diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx
index 1fe255a99..bc02ea74a 100644
--- a/src/app/coach/page.tsx
+++ b/src/app/coach/page.tsx
@@ -69,7 +69,15 @@ export default function CoachPage() {
// `` 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"
+ //
+ // v1.18.10 (W10) — height is mobile-aware. The fixed ``
+ // (64px + iOS home-indicator inset) is mobile-only; the desktop
+ // full-bleed layout has no bottom nav. On mobile the page must subtract
+ // the TopBar (`4rem`) AND the BottomNav band (`4rem` +
+ // `env(safe-area-inset-bottom)`) so the docked composer / Stop control
+ // always clears the nav instead of sitting under it. On `md+` the nav
+ // is hidden, so the page reclaims the full height below the TopBar.
+ className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
>
setAddOpen(true)}
- className="min-h-11 shrink-0 sm:min-h-9"
+ // v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
+ // text "Add" button crowd the row and truncate the title hard.
+ // Drop the Add button to icon-only under `sm` (label kept for
+ // screen readers via `aria-label`); the full text returns at `sm+`.
+ className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
+ aria-label={t("labs.addResult")}
>
- {t("labs.addResult")}
+ {t("labs.addResult")}
diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx
index b75121493..f7cbc7f19 100644
--- a/src/components/layout/bottom-nav.tsx
+++ b/src/components/layout/bottom-nav.tsx
@@ -247,16 +247,18 @@ export function BottomNav() {
)}
>
- {/* v1.18.10 (W7) — keep every label on ONE line. The longest
- single-word label (German "Benachrichtigungen") used to
- wrap to two lines via `break-words`, which left the tile
- visually unbalanced beside its single-line neighbours.
- `min-w-0` lets the flex child shrink below its content
- width and `truncate` (nowrap + ellipsis) trims the
- overflow instead of breaking the word. `title` keeps the
- full label discoverable on hover/long-press. */}
+ {/* v1.18.10 (W10) — show the FULL label, never an ellipsis
+ that hides meaning. The W7 `truncate` clipped long words
+ ("Benachrichtigungen" → "Benachrichtigun…"); the goal is a
+ readable label, not a balanced tile. Shrink the type to
+ `text-xs` so long single-word locales (de/es/fr/it) fit on
+ one line at ~110px, and allow a 2-line `line-clamp-2`
+ fallback (with `[overflow-wrap:anywhere]` so a long word
+ breaks rather than overflowing) for the rare label that
+ still cannot fit one line. `min-w-0` lets the flex child
+ shrink below its content width. */}
{t(item.tKey)}
From 16759b4df87c5dbbf706765ed7ced365ed98a0d7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 17:19:14 +0200
Subject: [PATCH 31/32] chore(release): v1.18.10
Fix encrypted day-log data loss and the stalled cumulative-measurement drain;
capture Withings sleep vitals and Oura VO2max; harden AI grounding across the
briefing, Coach, narrative, and status cards; full-page Coach with history and a
working mic; on-device lab OCR; preventive-care, labs, and settings polish; BP
warm/cold parity, a statement timeout, and a covering measurements index.
---
CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++
docs/api/openapi.yaml | 2 +-
package.json | 2 +-
public/sw.js | 2 +-
4 files changed, 42 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2fa30b3d1..19a318584 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,45 @@
## [Unreleased]
+## [1.18.10] — 2026-06-20 — data-loss fix, measurement consolidation, grounding, and the full-page Coach
+
+A correctness-and-polish release. It closes a silent data-loss path and a stalled measurement-consolidation job, hardens AI output grounding, and acts on a broad round of UI feedback. Two additive migrations (a covering index and a labs setting); no breaking changes.
+
+### Fixed
+
+- Editing one field of a cycle or condition-journal day entry no longer wipes an encrypted note that cannot be decrypted with the current key — the stored value is preserved untouched unless that field is explicitly changed.
+- The nightly consolidation of cumulative metrics (walking/running distance, flights climbed, active energy) no longer aborts on the first conflicting day, so raw per-sample history collapses to daily values as intended. A one-time compaction of the existing backlog runs after deploy.
+- The daily briefing refreshes when regenerated, and the home screen reflects a saved tile layout without a manual reload.
+- The Coach dictation button reports when speech input is unavailable instead of doing nothing.
+
+### Added
+
+- The Coach has a full-width page (no panel-within-a-panel), reachable conversation history, and a clean new-chat start.
+- Scan a lab report without a vision model: optional on-device OCR (off by default) reads the image in the browser and sends only the extracted text to the configured AI provider, then the same mandatory per-row review applies. The labs "Add" action now offers "scan a document" or "add a value manually".
+- Withings sleep now records heart rate, respiratory rate, blood oxygen, sleep HRV, and the sleep score (previously discarded at sync).
+- Oura VO2max is recorded, and the VO2max tile is shown on the dashboard by default.
+
+### Changed
+
+- Preventive-care cards show the cadence on the metric row rather than beside the heading, and the heading opens the measurement history filtered to that metric.
+- The danger zone and the daily-briefing card match the rest of the surface; the feedback tool is marked deprecated; the preventive-care and labs settings follow the standard section layout.
+- Lab values: delete moved to the value's detail view, the overview tags are quieter, and the reference-range confidence is surfaced when a new biomarker is created.
+- Location lookups default to the online resolver; the offline database is optional.
+- Mobile: the full-page Coach composer stays clear of the bottom navigation, long navigation labels stay readable, and touch targets meet 44 px.
+
+### Performance and integrity
+
+- Blood-pressure in-target and grade read identically whether served warm or cold (one canonical source per day).
+- A per-session statement timeout stops a single stuck query from holding a connection; a soft-delete-aware measurements index; the all-time fallback scan is bounded.
+
+### Security
+
+- Daily-briefing numbers are checked against the underlying signals; the Coach reply is screened for fabricated values or dose instructions; the period narrative is grounded before it is stored; the metric status cards state precomputed trends rather than re-deriving them.
+
+### API
+
+- The labs OCR capability response carries a `mode` (vision or text) and a new local-OCR preference; additional measurement types are recorded server-side. All additive and back-compatible.
+
## [1.18.9] — 2026-06-20 — Coach surface, lab-report scanning, and fresher dashboards
A broad UI and data release. It adds two migrations (both additive and nullable — qualitative lab results and Coach token columns) and three additive, back-compatible API changes (see the note below).
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 4aa3875ad..3b9bacaeb 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.18.9
+ version: 1.18.10
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
diff --git a/package.json b/package.json
index ea91b3ed4..de78ab5e0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.18.9",
+ "version": "1.18.10",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
diff --git a/public/sw.js b/public/sw.js
index 2f60fa365..d66fd98bc 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
- /* @sw-version-fallback */ "v1.18.9";
+ /* @sw-version-fallback */ "v1.18.10";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
From 32296c901ca9b6b6974c6675d7aae893b23b8240 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 20 Jun 2026 17:30:59 +0200
Subject: [PATCH 32/32] fix(db): raise the default statement timeout to 60s
The 15s default cancelled the live-aggregate analytics fallback on a coverage
miss over a large account (Postgres 57014), surfacing as a 500. Sixty seconds
still bounds a runaway query well before it can starve the pool while leaving
headroom for the heaviest legitimate read.
---
src/lib/__tests__/db.test.ts | 12 ++++++------
src/lib/db.ts | 11 ++++++-----
2 files changed, 12 insertions(+), 11 deletions(-)
diff --git a/src/lib/__tests__/db.test.ts b/src/lib/__tests__/db.test.ts
index ec872c51f..73f395e39 100644
--- a/src/lib/__tests__/db.test.ts
+++ b/src/lib/__tests__/db.test.ts
@@ -80,8 +80,8 @@ describe("getStatementTimeoutMs", () => {
}
});
- it("defaults to 15000ms when the env var is unset", () => {
- expect(getStatementTimeoutMs()).toBe(15_000);
+ it("defaults to 60000ms when the env var is unset", () => {
+ expect(getStatementTimeoutMs()).toBe(60_000);
});
it("honours a positive override", () => {
@@ -96,9 +96,9 @@ describe("getStatementTimeoutMs", () => {
it("falls back to the default on a malformed or negative value", () => {
process.env.DATABASE_STATEMENT_TIMEOUT_MS = "not-a-number";
- expect(getStatementTimeoutMs()).toBe(15_000);
+ expect(getStatementTimeoutMs()).toBe(60_000);
process.env.DATABASE_STATEMENT_TIMEOUT_MS = "-5";
- expect(getStatementTimeoutMs()).toBe(15_000);
+ expect(getStatementTimeoutMs()).toBe(60_000);
});
});
@@ -116,8 +116,8 @@ describe("buildSessionOptions", () => {
it("emits both session timeouts as libpq startup options by default", () => {
delete process.env.DATABASE_STATEMENT_TIMEOUT_MS;
const opts = buildSessionOptions();
- expect(opts).toContain("-c statement_timeout=15000");
- expect(opts).toContain("-c idle_in_transaction_session_timeout=15000");
+ expect(opts).toContain("-c statement_timeout=60000");
+ expect(opts).toContain("-c idle_in_transaction_session_timeout=60000");
});
it("returns undefined (no options) when the timeout is disabled", () => {
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 01ab1607f..0c3b8472f 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -48,10 +48,11 @@ export function getPoolMax(): number {
* pooled session inherits them.
*
* Env-overridable via `DATABASE_STATEMENT_TIMEOUT_MS`. Set to `0` to disable
- * (legacy unbounded behaviour). Default 15 s — generous for the rollup-backed
- * read paths while still bounding a runaway query well before it can starve the
- * pool. Heavy admin one-shots (drain/backfill) run on the worker, not the web
- * pool, and can dial their own timeout if needed.
+ * (legacy unbounded behaviour). Default 60 s — bounds a genuinely runaway query
+ * (the A-1 goal: never hold a pool slot forever) while leaving ample headroom
+ * for the heaviest legitimate read, the live-aggregate analytics fallback on a
+ * coverage miss over a large account, which can run well past 15 s. Heavy admin
+ * one-shots (drain/backfill) can still dial their own timeout if needed.
*/
export function getStatementTimeoutMs(): number {
const raw = process.env.DATABASE_STATEMENT_TIMEOUT_MS;
@@ -59,7 +60,7 @@ export function getStatementTimeoutMs(): number {
const parsed = Number.parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
}
- return 15_000;
+ return 60_000;
}
/**