From d8ff18948f56c581b15535bf65e92ae9486edd5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:05:49 +0200 Subject: [PATCH 01/51] feat(profile): add date-format preference column Mirror the time-format preference with a per-user date display choice. AUTO follows the locale convention, DMY/MDY/YMD pin the field order. Additive, rerun-safe migration; existing rows read AUTO. --- .../0192_v1210_date_format/migration.sql | 24 +++++++++++++++++++ prisma/schema.prisma | 16 +++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 prisma/migrations/0192_v1210_date_format/migration.sql diff --git a/prisma/migrations/0192_v1210_date_format/migration.sql b/prisma/migrations/0192_v1210_date_format/migration.sql new file mode 100644 index 000000000..4cdf65164 --- /dev/null +++ b/prisma/migrations/0192_v1210_date_format/migration.sql @@ -0,0 +1,24 @@ +-- v1.21.0 — per-user date display preference. +-- +-- The UI locale alone decided whether dates rendered as dd.MM.yyyy or +-- MM/dd/yyyy, and the native date input fell back to the browser locale, +-- which left non-US self-hosters on MM/DD/YYYY with no way out. The new +-- column carries an explicit preference: AUTO follows the locale +-- convention, DMY pins day-month-year, MDY pins month-day-year, YMD pins +-- ISO yyyy-MM-dd. Display-time only — stored instants stay UTC. +-- +-- Additive + non-destructive: a new NOT NULL column with a default, no +-- backfill needed. Existing rows read AUTO. Guarded so a re-run is a +-- no-op (the enum create + the column add both skip when present). +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'date_format_preference' + ) THEN + CREATE TYPE "date_format_preference" AS ENUM ('AUTO', 'DMY', 'MDY', 'YMD'); + END IF; +END +$$; + +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "date_format" "date_format_preference" NOT NULL DEFAULT 'AUTO'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e41c0f57f..18eacb45d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -228,6 +228,11 @@ model User { // (en → AM/PM, de → 24h), H12 forces AM/PM, H24 forces 24-hour. // Display-time only — canonical storage stays UTC instants. timeFormat TimeFormatPreference @default(AUTO) @map("time_format") + // Date display preference. AUTO follows the active locale convention + // (de → dd.MM.yyyy, en → MM/dd/yyyy); DMY pins day-month-year, + // MDY pins month-day-year, YMD pins ISO yyyy-MM-dd. Display-time only — + // canonical storage stays UTC instants. + dateFormat DateFormatPreference @default(AUTO) @map("date_format") // AI provider selection for insights. Null = OPENAI fallback. aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH" aiModel String? @map("ai_model") @@ -973,6 +978,17 @@ enum TimeFormatPreference { @@map("time_format_preference") } +// Date display preference for rendered dates. AUTO defers to the active +// locale's convention; DMY/MDY/YMD pin the field order regardless of locale. +enum DateFormatPreference { + AUTO + DMY + MDY + YMD + + @@map("date_format_preference") +} + enum MeasurementSource { MANUAL WITHINGS From 37e539d0bf0888dedb445af3aaec12a89cbf72fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:14:56 +0200 Subject: [PATCH 02/51] feat(format): resolve and apply the date-format preference Add a date-order resolver + formatter mirroring the hour-cycle path: AUTO defers to the active locale, DMY/MDY/YMD pin the field order through a canonical Intl locale. Thread it through makeFormatters and expose useDateFormatPreference from the i18n context, backed by the same localStorage mirror as the time-format preference. --- src/lib/__tests__/date-format.test.ts | 110 +++++++++++++++++ src/lib/date-format.ts | 162 ++++++++++++++++++++++++++ src/lib/format-locale.ts | 50 +++++++- src/lib/i18n/context.tsx | 22 +++- 4 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 src/lib/__tests__/date-format.test.ts create mode 100644 src/lib/date-format.ts diff --git a/src/lib/__tests__/date-format.test.ts b/src/lib/__tests__/date-format.test.ts new file mode 100644 index 000000000..43e35fe45 --- /dev/null +++ b/src/lib/__tests__/date-format.test.ts @@ -0,0 +1,110 @@ +/** + * Date-order preference resolver + formatter (v1.21.0). + * + * `formatDate` and `resolveDateLocale` are the single source of truth for the + * field order the `` primitive and `useFormatters().date()` render. + * AUTO defers to the active locale; DMY / MDY / YMD pin the order regardless. + */ +import { describe, expect, it } from "vitest"; + +import { + formatDate, + parseIsoDate, + resolveDateLocale, + isDateFormatPreference, + DATE_FORMAT_PREFERENCES, + DATE_FORMAT_OPTIONS, +} from "../date-format"; + +const ISO = "2026-02-19"; + +describe("resolveDateLocale", () => { + it("AUTO follows the active locale", () => { + expect(resolveDateLocale("AUTO", "de")).toBe("de-DE"); + expect(resolveDateLocale("AUTO", "en")).toBe("en-US"); + expect(resolveDateLocale("AUTO", "fr")).toBe("fr-FR"); + }); + + it("DMY / MDY / YMD pin a canonical order locale regardless of UI locale", () => { + expect(resolveDateLocale("DMY", "en")).toBe("de-DE"); + expect(resolveDateLocale("MDY", "de")).toBe("en-US"); + expect(resolveDateLocale("YMD", "de")).toBe("en-CA"); + }); +}); + +describe("formatDate", () => { + it("renders DMY as dd.MM.yyyy", () => { + expect(formatDate(ISO, "DMY", "en")).toBe("19.02.2026"); + }); + + it("renders MDY as MM/dd/yyyy", () => { + expect(formatDate(ISO, "MDY", "de")).toBe("02/19/2026"); + }); + + it("renders YMD as yyyy-MM-dd (ISO)", () => { + expect(formatDate(ISO, "YMD", "de")).toBe("2026-02-19"); + }); + + it("AUTO follows the locale convention", () => { + // de → dd.MM.yyyy, en → MM/dd/yyyy + expect(formatDate(ISO, "AUTO", "de")).toBe("19.02.2026"); + expect(formatDate(ISO, "AUTO", "en")).toBe("02/19/2026"); + }); + + it("accepts a Date and an ISO string identically", () => { + const d = new Date(Date.UTC(2026, 1, 19)); + expect(formatDate(d, "DMY", "en")).toBe(formatDate(ISO, "DMY", "en")); + }); + + it("does not drift across a timezone boundary (UTC calendar date)", () => { + // A bare yyyy-MM-dd is a calendar date; it must render the same day in + // every order, never the day before/after. + expect(formatDate("2026-01-01", "DMY", "en")).toBe("01.01.2026"); + expect(formatDate("2026-12-31", "YMD", "en")).toBe("2026-12-31"); + }); + + it("returns an empty string for empty / unparseable input", () => { + expect(formatDate("", "AUTO", "en")).toBe(""); + expect(formatDate(null, "AUTO", "en")).toBe(""); + expect(formatDate(undefined, "AUTO", "en")).toBe(""); + expect(formatDate("not-a-date", "AUTO", "en")).toBe(""); + }); +}); + +describe("parseIsoDate", () => { + it("parses a valid ISO calendar date to a UTC midnight Date", () => { + const d = parseIsoDate("2026-02-19"); + expect(d).not.toBeNull(); + expect(d!.getUTCFullYear()).toBe(2026); + expect(d!.getUTCMonth()).toBe(1); + expect(d!.getUTCDate()).toBe(19); + }); + + it("rejects a non-ISO shape and an impossible date", () => { + expect(parseIsoDate("19.02.2026")).toBeNull(); + expect(parseIsoDate("2026-02-31")).toBeNull(); + expect(parseIsoDate("2026-13-01")).toBeNull(); + }); +}); + +describe("preference list", () => { + it("isDateFormatPreference is a strict guard", () => { + expect(isDateFormatPreference("AUTO")).toBe(true); + expect(isDateFormatPreference("DMY")).toBe(true); + expect(isDateFormatPreference("XYZ")).toBe(false); + expect(isDateFormatPreference(null)).toBe(false); + }); + + it("exposes all four preferences and matching options", () => { + expect(DATE_FORMAT_PREFERENCES).toEqual(["AUTO", "DMY", "MDY", "YMD"]); + expect(DATE_FORMAT_OPTIONS.map((o) => o.value)).toEqual([ + "AUTO", + "DMY", + "MDY", + "YMD", + ]); + for (const opt of DATE_FORMAT_OPTIONS) { + expect(opt.labelKey).toMatch(/^settings\.dateFormat\./); + } + }); +}); diff --git a/src/lib/date-format.ts b/src/lib/date-format.ts new file mode 100644 index 000000000..db035d2dc --- /dev/null +++ b/src/lib/date-format.ts @@ -0,0 +1,162 @@ +/** + * Client-side mirror of the per-user date-order preference, plus the + * display helpers the `` primitive renders through. + * + * The server-authoritative value lives on the user row (`users.date_format`, + * surfaced by `/api/auth/me` and editable via the profile PATCH). The mirror + * follows the `time-format.ts` pattern exactly: `fetchMe` writes the resolved + * value into localStorage so that + * + * 1. `useDateFormatPreference()` can read it through `useSyncExternalStore` + * without requiring a QueryClient in the tree, and + * 2. `` (and any non-context helper) renders the same field + * order as the locale-aware `useFormatters().date()`. + * + * SSR always resolves AUTO (`window` is undefined); the same caveat as the + * locale formatters applies — call sites render their formatted strings + * post-fetch, so there is no hydration-mismatch path today. + */ + +import type { Locale } from "./i18n/config"; +import { type DateFormatPreference, resolveIntlLocale } from "./format-locale"; + +export type { DateFormatPreference } from "./format-locale"; + +const STORAGE_KEY = "healthlog-date-format"; +const CHANGE_EVENT = "healthlog:date-format-change"; + +export const DATE_FORMAT_PREFERENCES = ["AUTO", "DMY", "MDY", "YMD"] as const; + +/** + * Ordered option list for the profile dropdown. `labelKey` resolves through + * `t()` against the `settings.dateFormat.*` bundle; mirrors how the + * hour-format select hard-codes its option order in JSX. + */ +export const DATE_FORMAT_OPTIONS: ReadonlyArray<{ + value: DateFormatPreference; + labelKey: string; +}> = [ + { value: "AUTO", labelKey: "settings.dateFormat.auto" }, + { value: "DMY", labelKey: "settings.dateFormat.dmy" }, + { value: "MDY", labelKey: "settings.dateFormat.mdy" }, + { value: "YMD", labelKey: "settings.dateFormat.ymd" }, +]; + +export function isDateFormatPreference( + value: unknown, +): value is DateFormatPreference { + return ( + typeof value === "string" && + (DATE_FORMAT_PREFERENCES as readonly string[]).includes(value) + ); +} + +/** Best-effort read of the mirrored preference. AUTO on SSR / no mirror. */ +export function readStoredDateFormat(): DateFormatPreference { + if (typeof window === "undefined") return "AUTO"; + try { + const stored = window.localStorage?.getItem(STORAGE_KEY); + return isDateFormatPreference(stored) ? stored : "AUTO"; + } catch { + return "AUTO"; + } +} + +/** + * Persist the mirror and notify same-tab subscribers. Cross-tab updates ride + * the browser's native `storage` event. + */ +export function storeDateFormat(value: DateFormatPreference): void { + if (typeof window === "undefined") return; + try { + const previous = window.localStorage?.getItem(STORAGE_KEY); + if (previous === value) return; + window.localStorage?.setItem(STORAGE_KEY, value); + } catch { + return; + } + window.dispatchEvent(new Event(CHANGE_EVENT)); +} + +/** `useSyncExternalStore`-shaped subscription for the mirror. */ +export function subscribeDateFormat(onChange: () => void): () => void { + if (typeof window === "undefined") return () => {}; + window.addEventListener(CHANGE_EVENT, onChange); + window.addEventListener("storage", onChange); + return () => { + window.removeEventListener(CHANGE_EVENT, onChange); + window.removeEventListener("storage", onChange); + }; +} + +/** + * The Intl locale tag a (preference, locale) pair renders numeric dates + * through. Mirrors `dateOrderLocale` in `format-locale.ts`: AUTO keeps the + * user's own locale, DMY/MDY/YMD pin a canonical locale whose default + * numeric order matches (de-DE → dd.MM.yyyy, en-US → MM/dd/yyyy, en-CA → + * yyyy-MM-dd). Exported so `` and the formatter agree on order. + */ +export function resolveDateLocale( + pref: DateFormatPreference, + locale: Locale, +): string { + const localeTag = resolveIntlLocale(locale); + switch (pref) { + case "DMY": + return "de-DE"; + case "MDY": + return "en-US"; + case "YMD": + return "en-CA"; + default: + return localeTag; + } +} + +/** + * Format an ISO `yyyy-MM-dd` date string (or a `Date`) in the field order + * the (preference, locale) pair selects — the user-visible string the + * `` paints over its hidden native input. Returns "" for an + * empty / unparseable value so the field renders its placeholder. + * + * The instant is read in UTC (a bare `yyyy-MM-dd` has no zone) so the day + * never drifts across a timezone boundary — the value contract is a plain + * calendar date, not an instant. + */ +export function formatDate( + value: Date | string | null | undefined, + pref: DateFormatPreference, + locale: Locale, +): string { + if (value === null || value === undefined || value === "") return ""; + const date = value instanceof Date ? value : parseIsoDate(value); + if (date === null || Number.isNaN(date.getTime())) return ""; + return date.toLocaleDateString(resolveDateLocale(pref, locale), { + timeZone: "UTC", + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} + +/** + * Parse a `yyyy-MM-dd` string to a UTC `Date` at midnight. Returns null on + * a value that is not exactly the ISO calendar-date shape so callers can + * fall back to the placeholder rather than rendering "Invalid Date". + */ +export function parseIsoDate(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim()); + if (!match) return null; + const [, y, m, d] = match; + const date = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d))); + // Guard against rollover (e.g. 2026-02-31 → Mar 03): a real calendar date + // round-trips its parts unchanged. + if ( + date.getUTCFullYear() !== Number(y) || + date.getUTCMonth() !== Number(m) - 1 || + date.getUTCDate() !== Number(d) + ) { + return null; + } + return date; +} diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index b57bb591c..cbe3f1ffe 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -50,6 +50,14 @@ export function resolveIntlLocale(locale: Locale): string { */ export type TimeFormatPreference = "AUTO" | "H12" | "H24"; +/** + * Date-order display preference (mirrors the `DateFormatPreference` Prisma + * enum). AUTO defers to the locale's own field order (de-DE → dd.MM.yyyy, + * en-US → MM/dd/yyyy); DMY pins day-month-year, MDY pins month-day-year, + * YMD pins ISO yyyy-MM-dd. Display-time only — stored instants stay UTC. + */ +export type DateFormatPreference = "AUTO" | "DMY" | "MDY" | "YMD"; + /** * Intl options for the requested hour cycle. AUTO contributes nothing so * `Intl.DateTimeFormat` falls back to the locale default. @@ -67,6 +75,34 @@ function hourCycleOptions( } } +/** + * The Intl locale tag a non-AUTO date preference renders through. The + * field order is a property of the BCP-47 locale, not a `DateTimeFormat` + * option, so we pin a canonical locale whose default numeric date order + * matches the requested preference and reuse the user's own locale only + * for AUTO. DMY is rendered through de-DE so it carries the app-wide + * dotted separator (dd.MM.yyyy); MDY through en-US (MM/dd/yyyy); YMD + * through the ISO-canonical en-CA / sv (yyyy-MM-dd). The day/month/year + * `2-digit`/`numeric` field set is supplied by the caller. + */ +function dateOrderLocale( + dateFormat: DateFormatPreference, + localeTag: string, +): string { + switch (dateFormat) { + case "DMY": + return "de-DE"; + case "MDY": + return "en-US"; + case "YMD": + // en-CA renders numeric dates as yyyy-MM-dd across engines; it is the + // ISO-8601 default order without pulling a locale the app doesn't ship. + return "en-CA"; + default: + return localeTag; + } +} + type DateInput = Date | string | number; function asDate(value: DateInput): Date { @@ -98,10 +134,16 @@ export function makeFormatters( locale: Locale, userTz?: string, timeFormat: TimeFormatPreference = "AUTO", + dateFormat: DateFormatPreference = "AUTO", ): Formatters { const intlLocale = resolveIntlLocale(locale); const tz = userTz && userTz.length > 0 ? userTz : DISPLAY_TIMEZONE; const hourOpts = hourCycleOptions(timeFormat); + // Field-order locale for the numeric date renderers. AUTO keeps the + // user's own locale; DMY/MDY/YMD pin a canonical locale whose default + // numeric order matches. `monthShort` deliberately stays on `intlLocale` + // so axis month names follow the UI language, not the date-order pin. + const dateLocale = dateOrderLocale(dateFormat, intlLocale); return { number: (value, fractionDigits) => @@ -123,7 +165,7 @@ export function makeFormatters( }).format(value), date: (value) => - asDate(value).toLocaleDateString(intlLocale, { + asDate(value).toLocaleDateString(dateLocale, { timeZone: tz, day: "2-digit", month: "2-digit", @@ -131,14 +173,14 @@ export function makeFormatters( }), dateShort: (value) => - asDate(value).toLocaleDateString(intlLocale, { + asDate(value).toLocaleDateString(dateLocale, { timeZone: tz, day: "2-digit", month: "2-digit", }), dateTime: (value) => - asDate(value).toLocaleString(intlLocale, { + asDate(value).toLocaleString(dateLocale, { timeZone: tz, day: "2-digit", month: "2-digit", @@ -157,7 +199,7 @@ export function makeFormatters( }), dateWithWeekday: (value) => - asDate(value).toLocaleDateString(intlLocale, { + asDate(value).toLocaleDateString(dateLocale, { timeZone: tz, weekday: "short", day: "2-digit", diff --git a/src/lib/i18n/context.tsx b/src/lib/i18n/context.tsx index 3b5674c34..7c2dd1349 100644 --- a/src/lib/i18n/context.tsx +++ b/src/lib/i18n/context.tsx @@ -16,8 +16,10 @@ import { makeFormatters, type Formatters, type TimeFormatPreference, + type DateFormatPreference, } from "../format-locale"; import { readStoredTimeFormat, subscribeTimeFormat } from "../time-format"; +import { readStoredDateFormat, subscribeDateFormat } from "../date-format"; import { resolveKey } from "./resolve-key"; import { fallbackMessages, @@ -248,6 +250,21 @@ export function useTimeFormatPreference(): TimeFormatPreference { ); } +/** + * The user's date-order preference (AUTO / DMY / MDY / YMD), reactive to + * changes. Backed by the same localStorage-mirror pattern as the hour-cycle + * preference — `useAuth`'s `/api/auth/me` fetch and the profile date-format + * select keep it in sync with the server value, no QueryClient required. + * SSR resolves AUTO. + */ +export function useDateFormatPreference(): DateFormatPreference { + return useSyncExternalStore( + subscribeDateFormat, + readStoredDateFormat, + () => "AUTO" as const, + ); +} + /** * Locale-aware formatters tied to the active UI locale. Use for every number, * date, and time rendered in the UI so regional conventions (70,5 vs 70.5, @@ -258,8 +275,9 @@ export function useTimeFormatPreference(): TimeFormatPreference { export function useFormatters(): Formatters { const { locale } = useTranslations(); const timeFormat = useTimeFormatPreference(); + const dateFormat = useDateFormatPreference(); return useMemo( - () => makeFormatters(locale, undefined, timeFormat), - [locale, timeFormat], + () => makeFormatters(locale, undefined, timeFormat, dateFormat), + [locale, timeFormat, dateFormat], ); } From 86a9ca2e1e8f79fb3721cfb01935e497f28ce44d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:15:09 +0200 Subject: [PATCH 03/51] feat(profile): persist and expose the date-format preference Accept dateFormat on the profile PATCH field-by-field, echo it from the profile + /me reads, mirror it into the client auth user, and add the field to the OpenAPI request/response schemas. --- src/app/api/auth/me/route.ts | 4 ++++ src/app/api/user/profile/route.ts | 3 +++ .../__tests__/disable-coach-card.test.tsx | 1 + .../__tests__/modules-section.test.tsx | 1 + .../__tests__/time-format-select.test.tsx | 1 + .../__tests__/unit-preference-select.test.tsx | 1 + src/hooks/use-auth.ts | 24 ++++++++++++++++++- src/lib/auth/profile-update.ts | 6 +++++ .../__tests__/coach-user-disable.test.tsx | 1 + src/lib/openapi/routes/profile.ts | 16 +++++++++++++ 10 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 434f29221..4ef9ebe53 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -96,6 +96,10 @@ export const GET = apiHandler(async () => { // Hour-cycle display preference (AUTO follows the locale convention, // H12 / H24 pin the cycle). Clients mirror this into their formatters. timeFormat: user.timeFormat ?? "AUTO", + // Date-order display preference (AUTO follows the locale convention, + // DMY / MDY / YMD pin the field order). Clients mirror this into their + // formatters and the primitive. + dateFormat: user.dateFormat ?? "AUTO", lastReportPracticeName: user.lastReportPracticeName ?? null, // v1.4.47 W3 — per-user Coach opt-out. Default `false` if the // column is absent (partial-deploy rollback safety, see migration diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 095ade7e3..d3c211840 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -42,6 +42,7 @@ export const GET = apiHandler(async () => { locale: true, timezone: true, timeFormat: true, + dateFormat: true, moodReminderEnabled: true, fullName: true, insurerName: true, @@ -77,6 +78,7 @@ export const GET = apiHandler(async () => { locale: dbUser?.locale ?? null, timezone: dbUser?.timezone ?? "Europe/Berlin", timeFormat: dbUser?.timeFormat ?? "AUTO", + dateFormat: dbUser?.dateFormat ?? "AUTO", moodReminderEnabled: dbUser?.moodReminderEnabled ?? false, fullName: dbUser?.fullName ?? null, insurerName: dbUser?.insurerName ?? null, @@ -114,6 +116,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { locale: result.user.locale, timezone: result.user.timezone, timeFormat: result.user.timeFormat, + dateFormat: result.user.dateFormat, moodReminderEnabled: result.user.moodReminderEnabled, fullName: result.user.fullName, insurerName: result.user.insurerName, diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index c9f40959e..945933485 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -68,6 +68,7 @@ function buildUser(disableCoach: boolean): AuthUser { glucoseUnit: null, unitPreference: "metric", timeFormat: "AUTO", + dateFormat: "AUTO", disableCoach, fullName: null, insurerName: null, diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index cb97fa27b..dbd2822da 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -70,6 +70,7 @@ function buildUser(modules: AuthUser["modules"]): AuthUser { glucoseUnit: null, unitPreference: "metric", timeFormat: "AUTO", + dateFormat: "AUTO", disableCoach: false, fullName: null, insurerName: null, diff --git a/src/components/settings/__tests__/time-format-select.test.tsx b/src/components/settings/__tests__/time-format-select.test.tsx index 2bb55ac6b..cea538c04 100644 --- a/src/components/settings/__tests__/time-format-select.test.tsx +++ b/src/components/settings/__tests__/time-format-select.test.tsx @@ -46,6 +46,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser { glucoseUnit: null, unitPreference: "metric", timeFormat, + dateFormat: "AUTO", disableCoach: false, fullName: null, insurerName: null, diff --git a/src/components/settings/__tests__/unit-preference-select.test.tsx b/src/components/settings/__tests__/unit-preference-select.test.tsx index c5e0a3b13..e944c2ddc 100644 --- a/src/components/settings/__tests__/unit-preference-select.test.tsx +++ b/src/components/settings/__tests__/unit-preference-select.test.tsx @@ -49,6 +49,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser { glucoseUnit: null, unitPreference, timeFormat: "AUTO", + dateFormat: "AUTO", disableCoach: false, fullName: null, insurerName: null, diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 499e2795f..98f6cc052 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -7,8 +7,12 @@ import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiFetchRaw } from "@/lib/api/api-fetch"; import { retryOnceOnTransientError } from "@/lib/queries/retry-transient"; import { useTranslations } from "@/lib/i18n/context"; -import type { TimeFormatPreference } from "@/lib/format-locale"; +import type { + TimeFormatPreference, + DateFormatPreference, +} from "@/lib/format-locale"; import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format"; +import { isDateFormatPreference, storeDateFormat } from "@/lib/date-format"; import type { ModuleKey } from "@/lib/modules/registry"; import type { TourProgress } from "@/lib/onboarding/tour-progress"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; @@ -75,6 +79,14 @@ export interface AuthUser { * "AUTO" against a stale /me payload. */ timeFormat: TimeFormatPreference; + /** + * Date-order display preference. AUTO follows the locale convention + * (de → dd.MM.yyyy, en → MM/dd/yyyy); DMY / MDY / YMD pin the field order + * regardless of locale. Mirrored into localStorage by `fetchMe` so + * `useFormatters()` and the `` primitive render the same order. + * Coerced to "AUTO" against a stale /me payload. + */ + dateFormat: DateFormatPreference; /** * v1.4.47 W3 — per-user Coach opt-out. When `true`, every Coach * mount point (``, ``, the @@ -157,6 +169,15 @@ async function fetchMe(): Promise { ? data.timeFormat : "AUTO"; storeTimeFormat(timeFormat); + // Date-order preference: coerce against a stale /me payload, then mirror + // into localStorage so `useFormatters()` and the `` primitive + // (which cannot reach the query cache) render the same field order. + const dateFormat: DateFormatPreference = isDateFormatPreference( + data.dateFormat, + ) + ? data.dateFormat + : "AUTO"; + storeDateFormat(dateFormat); return { ...(data as AuthUser), disableCoach: data.disableCoach ?? false, @@ -167,6 +188,7 @@ async function fetchMe(): Promise { // without the field) so the display defaults to metric. unitPreference: data.unitPreference === "imperial" ? "imperial" : "metric", timeFormat, + dateFormat, // v1.15.0 — coerce against a stale /me payload so the cycle nav entry // stays hidden by default when the field is absent. cycleTrackingEnabled: data.cycleTrackingEnabled === true, diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index e38410022..d62c3cb4e 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -27,6 +27,9 @@ const extendedProfileSchema = profileSchema.extend({ // Hour-cycle display preference. AUTO follows the locale convention, // H12 forces AM/PM, H24 forces 24-hour. timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(), + // Date-order display preference. AUTO follows the locale convention, + // DMY pins day-month-year, MDY month-day-year, YMD ISO yyyy-MM-dd. + dateFormat: z.enum(["AUTO", "DMY", "MDY", "YMD"]).optional(), }); export interface ApplyProfileResult { @@ -43,6 +46,7 @@ export interface ApplyProfileResult { timezone: string; locale: string | null; timeFormat: "AUTO" | "H12" | "H24"; + dateFormat: "AUTO" | "DMY" | "MDY" | "YMD"; moodReminderEnabled: boolean; // v1.7.0 — patient-identity fields. `insuranceNumber` is returned in // plaintext (decrypted on read by the route) so the client can render @@ -110,6 +114,7 @@ export async function applyProfileUpdate( if (data.locale !== undefined) updates.locale = data.locale; if (data.timezone !== undefined) updates.timezone = data.timezone; if (data.timeFormat !== undefined) updates.timeFormat = data.timeFormat; + if (data.dateFormat !== undefined) updates.dateFormat = data.dateFormat; if (data.moodReminderEnabled !== undefined) { updates.moodReminderEnabled = data.moodReminderEnabled; } @@ -206,6 +211,7 @@ export async function applyProfileUpdate( timezone: updatedUser.timezone, locale: updatedUser.locale, timeFormat: updatedUser.timeFormat ?? "AUTO", + dateFormat: updatedUser.dateFormat ?? "AUTO", moodReminderEnabled: updatedUser.moodReminderEnabled, fullName: updatedUser.fullName, insurerName: updatedUser.insurerName, diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index 71b725ea7..2675bc92e 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -95,6 +95,7 @@ function buildUser(disableCoach: boolean): AuthUser { glucoseUnit: null, unitPreference: "metric", timeFormat: "AUTO", + dateFormat: "AUTO", disableCoach, fullName: null, insurerName: null, diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index fa378dad8..941e53245 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -185,6 +185,12 @@ const profileUpdateRequest = z .describe( "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", ), + dateFormat: z + .enum(["AUTO", "DMY", "MDY", "YMD"]) + .optional() + .describe( + "Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy), MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd.", + ), moodReminderEnabled: z.boolean().optional(), fullName: z.string().max(120).nullable().optional(), insurerName: z.string().max(120).nullable().optional(), @@ -222,6 +228,11 @@ const profileResponse = z .describe( "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", ), + dateFormat: z + .enum(["AUTO", "DMY", "MDY", "YMD"]) + .describe( + "Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy), MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd.", + ), moodReminderEnabled: z.boolean(), fullName: z.string().nullable(), insurerName: z.string().nullable(), @@ -261,6 +272,11 @@ const profileUpdateResponse = z .describe( "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", ), + dateFormat: z + .enum(["AUTO", "DMY", "MDY", "YMD"]) + .describe( + "Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy), MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd.", + ), moodReminderEnabled: z.boolean(), fullName: z.string().nullable(), insurerName: z.string().nullable(), From 9b7980d020e47ee076d19ac588b2b2f3b7fb1b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:15:16 +0200 Subject: [PATCH 04/51] feat(settings): add the date-format profile control Surface a Datumsformat select below Stundenformat with the four preferences, in all six locales. --- messages/de.json | 11 ++ messages/en.json | 11 ++ messages/es.json | 11 ++ messages/fr.json | 11 ++ messages/it.json | 11 ++ messages/pl.json | 11 ++ .../settings/account-section/index.tsx | 2 + .../settings/date-format-select.tsx | 144 ++++++++++++++++++ 8 files changed, 212 insertions(+) create mode 100644 src/components/settings/date-format-select.tsx diff --git a/messages/de.json b/messages/de.json index 933e6e1f5..2953dc13c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -20,6 +20,7 @@ "inactive": "Inaktiv", "disabled": "Deaktiviert", "networkError": "Netzwerkfehler", + "openDatePicker": "Datumsauswahl öffnen", "unknownError": "Unbekannter Fehler", "copied": "Kopiert!", "or": "oder", @@ -4120,6 +4121,16 @@ "saved": "Stundenformat gespeichert.", "saveError": "Speichern des Stundenformats fehlgeschlagen." }, + "dateFormat": { + "title": "Datumsformat", + "description": "Bestimmt die Reihenfolge von Tag, Monat und Jahr bei der Anzeige von Daten.", + "auto": "Automatisch (Sprache)", + "dmy": "TT.MM.JJJJ", + "mdy": "MM/TT/JJJJ", + "ymd": "JJJJ-MM-TT", + "saved": "Datumsformat gespeichert.", + "saveError": "Speichern des Datumsformats fehlgeschlagen." + }, "username": "Benutzername", "height": "Körpergröße (cm)", "dateOfBirth": "Geburtsdatum", diff --git a/messages/en.json b/messages/en.json index e7fe5729f..dddad7809 100644 --- a/messages/en.json +++ b/messages/en.json @@ -20,6 +20,7 @@ "inactive": "Inactive", "disabled": "Disabled", "networkError": "Network error", + "openDatePicker": "Open date picker", "unknownError": "Unknown error", "copied": "Copied!", "or": "or", @@ -4120,6 +4121,16 @@ "saved": "Hour format saved.", "saveError": "Saving the hour format failed." }, + "dateFormat": { + "title": "Date format", + "description": "Controls the order of day, month and year when dates are shown.", + "auto": "Automatic (language)", + "dmy": "DD.MM.YYYY", + "mdy": "MM/DD/YYYY", + "ymd": "YYYY-MM-DD", + "saved": "Date format saved.", + "saveError": "Saving the date format failed." + }, "username": "Username", "height": "Height (cm)", "dateOfBirth": "Date of birth", diff --git a/messages/es.json b/messages/es.json index 833990236..9c7719abb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -20,6 +20,7 @@ "inactive": "Inactivo", "disabled": "Desactivado", "networkError": "Error de red", + "openDatePicker": "Abrir el selector de fecha", "unknownError": "Error desconocido", "copied": "¡Copiado!", "or": "o", @@ -4120,6 +4121,16 @@ "saved": "Formato horario guardado.", "saveError": "No se pudo guardar el formato horario." }, + "dateFormat": { + "title": "Formato de fecha", + "description": "Determina el orden de día, mes y año al mostrar las fechas.", + "auto": "Automático (idioma)", + "dmy": "DD.MM.AAAA", + "mdy": "MM/DD/AAAA", + "ymd": "AAAA-MM-DD", + "saved": "Formato de fecha guardado.", + "saveError": "No se pudo guardar el formato de fecha." + }, "username": "Nombre de usuario", "height": "Altura (cm)", "dateOfBirth": "Fecha de nacimiento", diff --git a/messages/fr.json b/messages/fr.json index 8a3453623..dfabd04d3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -20,6 +20,7 @@ "inactive": "Inactif", "disabled": "Désactivé", "networkError": "Erreur réseau", + "openDatePicker": "Ouvrir le sélecteur de date", "unknownError": "Erreur inconnue", "copied": "Copié !", "or": "ou", @@ -4120,6 +4121,16 @@ "saved": "Format horaire enregistré.", "saveError": "Échec de l’enregistrement du format horaire." }, + "dateFormat": { + "title": "Format de date", + "description": "Détermine l’ordre du jour, du mois et de l’année lors de l’affichage des dates.", + "auto": "Automatique (langue)", + "dmy": "JJ.MM.AAAA", + "mdy": "MM/JJ/AAAA", + "ymd": "AAAA-MM-JJ", + "saved": "Format de date enregistré.", + "saveError": "Échec de l’enregistrement du format de date." + }, "username": "Nom d'utilisateur", "height": "Taille (cm)", "dateOfBirth": "Date de naissance", diff --git a/messages/it.json b/messages/it.json index 734f6011a..53fd5ec0d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -20,6 +20,7 @@ "inactive": "Inattivo", "disabled": "Disabilitato", "networkError": "Errore di rete", + "openDatePicker": "Apri il selettore di data", "unknownError": "Errore sconosciuto", "copied": "Copiato!", "or": "o", @@ -4120,6 +4121,16 @@ "saved": "Formato orario salvato.", "saveError": "Salvataggio del formato orario non riuscito." }, + "dateFormat": { + "title": "Formato data", + "description": "Determina l’ordine di giorno, mese e anno nella visualizzazione delle date.", + "auto": "Automatico (lingua)", + "dmy": "GG.MM.AAAA", + "mdy": "MM/GG/AAAA", + "ymd": "AAAA-MM-GG", + "saved": "Formato data salvato.", + "saveError": "Salvataggio del formato data non riuscito." + }, "username": "Nome utente", "height": "Altezza (cm)", "dateOfBirth": "Data di nascita", diff --git a/messages/pl.json b/messages/pl.json index b8a448c23..5afe3bc9b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -20,6 +20,7 @@ "inactive": "Nieaktywne", "disabled": "Wyłączone", "networkError": "Błąd sieci", + "openDatePicker": "Otwórz wybór daty", "unknownError": "Nieznany błąd", "copied": "Skopiowano!", "or": "lub", @@ -4120,6 +4121,16 @@ "saved": "Zapisano format godziny.", "saveError": "Nie udało się zapisać formatu godziny." }, + "dateFormat": { + "title": "Format daty", + "description": "Określa kolejność dnia, miesiąca i roku przy wyświetlaniu dat.", + "auto": "Automatycznie (język)", + "dmy": "DD.MM.RRRR", + "mdy": "MM/DD/RRRR", + "ymd": "RRRR-MM-DD", + "saved": "Zapisano format daty.", + "saveError": "Nie udało się zapisać formatu daty." + }, "username": "Nazwa użytkownika", "height": "Wzrost (cm)", "dateOfBirth": "Data urodzenia", diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index c6d917ae1..57c68ce83 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -27,6 +27,7 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TimezonePicker } from "@/components/settings/timezone-picker"; import { TimeFormatSelect } from "@/components/settings/time-format-select"; +import { DateFormatSelect } from "@/components/settings/date-format-select"; import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select"; import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card"; import { detectBrowserTimezone } from "@/lib/tz/format"; @@ -416,6 +417,7 @@ export function AccountSection() { + {/* v1.7.0 — optional patient-identity fields surfaced on the diff --git a/src/components/settings/date-format-select.tsx b/src/components/settings/date-format-select.tsx new file mode 100644 index 000000000..0b3b222b7 --- /dev/null +++ b/src/components/settings/date-format-select.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { useAuth } from "@/hooks/use-auth"; +import type { DateFormatPreference } from "@/lib/format-locale"; +import { DATE_FORMAT_OPTIONS, storeDateFormat } from "@/lib/date-format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; + +/** + * Date-order (DD.MM / MM/DD / ISO) preference as a Profile dropdown. + * + * Sits directly below the hour-format select because it is the same kind of + * personal display preference; the active locale otherwise pins the date + * order with no way out. AUTO follows the locale convention, DMY/MDY/YMD pin + * the field order regardless of locale. + * + * Persistence mirrors ``: a change PATCHes the profile + * endpoint immediately and invalidates `queryKeys.authMe()`. The localStorage + * mirror is written synchronously so every `useFormatters()` consumer and the + * `` primitive repaint without waiting for the refetch. + */ +export function DateFormatSelect({ + isAuthenticated, + id = "date-format", +}: { + isAuthenticated: boolean; + id?: string; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const { user } = useAuth(); + + const [optimistic, setOptimistic] = useState( + null, + ); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + const clearTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + clearTimerRef.current = null; + } + }; + }, []); + + function scheduleClear() { + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + } + clearTimerRef.current = setTimeout(() => { + clearTimerRef.current = null; + setMsg(null); + setMsgType(null); + }, 3000); + } + + const value: DateFormatPreference = optimistic ?? user?.dateFormat ?? "AUTO"; + + const mutation = useMutation({ + mutationFn: async (next: DateFormatPreference) => { + const res = await apiFetchRaw("/api/user/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dateFormat: next }), + }); + if (!res.ok) { + const json = (await res.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(json.error ?? `HTTP ${res.status}`); + } + return next; + }, + onSuccess: (next) => { + // Repaint every formatter consumer right away; the /me refetch below + // re-asserts the same value once it settles. + storeDateFormat(next); + setMsg(t("settings.dateFormat.saved")); + setMsgType("success"); + queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + setOptimistic(null); + scheduleClear(); + }, + onError: (err) => { + setOptimistic(null); + setMsg( + err instanceof Error ? err.message : t("settings.dateFormat.saveError"), + ); + setMsgType("error"); + scheduleClear(); + }, + }); + + function handleSelect(next: DateFormatPreference) { + if (next === value || mutation.isPending || !isAuthenticated) return; + setOptimistic(next); + setMsg(null); + setMsgType(null); + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + clearTimerRef.current = null; + } + mutation.mutate(next); + } + + return ( +
+ + handleSelect(e.target.value as DateFormatPreference)} + > + {DATE_FORMAT_OPTIONS.map((opt) => ( + + ))} + +

+ {msg ?? t("settings.dateFormat.description")} +

+
+ ); +} From d6430528e9cc139febeaf42d6a2a107b6a62711f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:15:22 +0200 Subject: [PATCH 05/51] feat(ui): add the DateField date input Dependency-free date input that displays the value in the user's date-format preference while editing rides the native picker behind a calendar button. The committed value stays ISO yyyy-MM-dd, so it is a drop-in for DateInput. Height/target-size parity, disabled/min/max/ placeholder, and progressive typed entry parsed against the order. --- .../ui/__tests__/date-field.test.tsx | 87 ++++++ src/components/ui/date-field.tsx | 288 ++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 src/components/ui/__tests__/date-field.test.tsx create mode 100644 src/components/ui/date-field.tsx diff --git a/src/components/ui/__tests__/date-field.test.tsx b/src/components/ui/__tests__/date-field.test.tsx new file mode 100644 index 000000000..b789f42da --- /dev/null +++ b/src/components/ui/__tests__/date-field.test.tsx @@ -0,0 +1,87 @@ +/** + * `` contract (v1.21.0). + * + * The SSR-only convention (no `@testing-library/react`, `environment: "node"`) + * means the interactive picker / typed-entry paths can't be driven here — those + * ride `showPicker()` + change events the static renderer never fires. What the + * static markup CAN pin is the load-bearing contract: + * + * - the committed VALUE stays ISO `yyyy-MM-dd` on a hidden native date input, + * so this is a drop-in for `` and react-hook-form is unchanged; + * - the visible overlay paints the value formatted per the preference; + * - disabled / min / max / placeholder thread through; + * - height + target-size parity classes are present. + * + * Under SSR `useDateFormatPreference()` resolves AUTO (no `window`), so the + * overlay reflects the locale order — de → dd.MM.yyyy, en → MM/dd/yyyy. The + * per-preference order itself is covered exhaustively in + * `src/lib/__tests__/date-format.test.ts` against the pure formatter. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { DateField } from "../date-field"; + +function render(node: React.ReactNode, locale: "de" | "en" = "en"): string { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("keeps the ISO value on a hidden native date input", () => { + const html = render(); + // The real value lives on a type=date input (the form's source of truth). + expect(html).toMatch(/]*type="date"[^>]*value="2026-02-19"/); + expect(html).toContain('name="dob"'); + // …and it is visually hidden so only the overlay shows. + expect(html).toMatch(/]*type="date"[^>]*class="[^"]*sr-only/); + }); + + it("paints the AUTO-locale display string over the value (en → MM/dd/yyyy)", () => { + const html = render(, "en"); + expect(html).toContain('value="02/19/2026"'); + }); + + it("paints the AUTO-locale display string over the value (de → dd.MM.yyyy)", () => { + const html = render(, "de"); + expect(html).toContain('value="19.02.2026"'); + }); + + it("renders the placeholder shape when empty", () => { + const html = render(); + expect(html).toContain('placeholder="Pick a date"'); + }); + + it("derives a format-shaped placeholder when none is supplied (en → MM/DD/YYYY)", () => { + const html = render(, "en"); + expect(html).toContain('placeholder="MM/DD/YYYY"'); + }); + + it("threads disabled, min and max onto the native input", () => { + const html = render( + , + ); + expect(html).toMatch(/]*type="date"[^>]*min="2020-01-01"/); + expect(html).toMatch(/]*type="date"[^>]*max="2030-12-31"/); + expect(html).toMatch(/]*type="date"[^>]*disabled/); + }); + + it("ships the WCAG target-size + height-parity classes", () => { + const html = render(); + // Same 44px mobile / 40px sm+ floor as . + expect(html).toContain("min-h-11"); + expect(html).toContain("sm:h-10"); + }); + + it("exposes a labelled calendar picker affordance", () => { + const html = render(); + expect(html).toContain('aria-label="Open date picker"'); + }); +}); diff --git a/src/components/ui/date-field.tsx b/src/components/ui/date-field.tsx new file mode 100644 index 000000000..049fe5884 --- /dev/null +++ b/src/components/ui/date-field.tsx @@ -0,0 +1,288 @@ +"use client"; + +import * as React from "react"; +import { Calendar } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations, useDateFormatPreference } from "@/lib/i18n/context"; +import { formatDate, parseIsoDate } from "@/lib/date-format"; + +/** + * Dependency-free date input that DISPLAYS the value in the user's date-order + * preference (AUTO follows the locale; DMY / MDY / YMD pin the field order) + * while editing rides the browser's native date picker. + * + * The committed VALUE is always ISO `yyyy-MM-dd` — identical to the native + * `` contract `` ships — so this is a drop-in + * for `` and existing react-hook-form wiring is unchanged. + * + * How it works: + * - A visually-hidden native `` holds the real value and + * emits the change events the form listens to. Tapping the field (or its + * calendar button) calls `showPicker()` on that input, falling back to + * `focus()` where the API is unavailable. + * - A text overlay paints the formatted display string (or the placeholder). + * - As a progressive enhancement the user can type a date directly: the + * overlay is an editable text input; on blur / Enter we parse what they + * typed against the active locale order and, on a clean parse, write the + * ISO value back through the hidden input so the form sees a normal change. + * + * Height / a11y / target-size parity with ``: the same + * `min-h-11 h-11 sm:min-h-10 sm:h-10` floor (WCAG 2.5.5 — 44 px on mobile, + * 40 px on sm+) and focus vocabulary as the rest of the input primitives. + */ + +// Mirrors the `` height contract (minus the date-shadow rule, which +// only applies to the native value text — here the native input is hidden). +const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10"; + +const FIELD_BASE_CLASSES = + "border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none"; + +export interface DateFieldProps { + id?: string; + name?: string; + /** ISO `yyyy-MM-dd` (controlled). */ + value?: string; + /** Initial ISO `yyyy-MM-dd` (uncontrolled). */ + defaultValue?: string; + onChange?: (value: string) => void; + onBlur?: () => void; + disabled?: boolean; + required?: boolean; + /** ISO `yyyy-MM-dd`. */ + min?: string; + /** ISO `yyyy-MM-dd`. */ + max?: string; + placeholder?: string; + className?: string; + "aria-label"?: string; + "aria-describedby"?: string; + "aria-invalid"?: boolean | "true" | "false"; + "data-testid"?: string; +} + +export const DateField = React.forwardRef( + function DateField( + { + id, + name, + value, + defaultValue, + onChange, + onBlur, + disabled, + required, + min, + max, + placeholder, + className, + "aria-label": ariaLabel, + "aria-describedby": ariaDescribedBy, + "aria-invalid": ariaInvalid, + "data-testid": dataTestId, + ...rest + }, + forwardedRef, + ) { + const { locale, t } = useTranslations(); + const dateFormat = useDateFormatPreference(); + + const innerRef = React.useRef(null); + const setRefs = React.useCallback( + (node: HTMLInputElement | null) => { + innerRef.current = node; + if (typeof forwardedRef === "function") forwardedRef(node); + else if (forwardedRef) forwardedRef.current = node; + }, + [forwardedRef], + ); + + // Track the ISO value so the overlay reflects controlled + uncontrolled + // updates. Controlled `value` always wins on render. + const [internal, setInternal] = React.useState(defaultValue ?? ""); + const iso = value ?? internal; + + // Mirror what the user is typing into the overlay; cleared back to the + // formatted string whenever the committed ISO value changes. + const [typed, setTyped] = React.useState(null); + + const display = formatDate(iso, dateFormat, locale); + const overlayValue = typed ?? display; + + function commitIso(next: string) { + if (value === undefined) setInternal(next); + setTyped(null); + onChange?.(next); + } + + function openPicker() { + const el = innerRef.current; + if (!el || disabled) return; + // showPicker() throws if not user-activated or unsupported — fall back + // to focusing the (hidden) input, which still opens the picker on most + // mobile engines. + try { + (el as HTMLInputElement & { showPicker?: () => void }).showPicker?.(); + } catch { + el.focus(); + } + } + + function handleNativeChange(e: React.ChangeEvent) { + commitIso(e.target.value); + } + + // Progressive enhancement: parse free-typed text against the active locale + // order. We feed the typed string to the browser's own parser via a throw- + // away Date only when it matches the ISO shape; otherwise we accept a small + // set of separator-flexible numeric forms keyed off the preference. + function commitTyped() { + if (typed === null) return; + const trimmed = typed.trim(); + if (trimmed === "") { + commitIso(""); + return; + } + const parsed = parseTypedDate(trimmed, dateFormat, locale); + if (parsed) { + commitIso(parsed); + } else { + // Unparseable — discard the draft and snap back to the committed value. + setTyped(null); + } + } + + return ( +
+ {/* Visually-hidden native date input — owns the real value + events. */} + + {/* Editable display overlay. */} + setTyped(e.target.value)} + onClick={openPicker} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitTyped(); + } + }} + onBlur={() => { + commitTyped(); + onBlur?.(); + }} + /> + +
+ ); + }, +); + +/** Placeholder that mirrors the active field order, e.g. "DD.MM.YYYY". */ +function formatPlaceholder( + dateFormat: ReturnType, + locale: ReturnType["locale"], +): string { + // 2026-12-31 renders unambiguously in every order, so its formatted shape + // doubles as a self-describing placeholder hint. + return formatDate("2026-12-31", dateFormat, locale) + .replace(/31/g, "DD") + .replace(/12/g, "MM") + .replace(/2026/g, "YYYY"); +} + +/** + * Best-effort parse of a free-typed date into ISO `yyyy-MM-dd`. Accepts the + * active preference's field order with `.`, `/`, or `-` separators, plus a + * bare ISO string. Returns null when the parts don't form a real calendar + * date so the caller can reject the draft. + */ +function parseTypedDate( + input: string, + dateFormat: ReturnType, + locale: ReturnType["locale"], +): string | null { + // A clean ISO string always wins. + const isoDirect = parseIsoDate(input); + if (isoDirect) return toIso(isoDirect); + + const parts = input.split(/[./-]/).map((p) => p.trim()); + if (parts.length !== 3 || parts.some((p) => p === "" || !/^\d+$/.test(p))) { + return null; + } + const nums = parts.map(Number) as [number, number, number]; + + const order = resolveOrder(dateFormat, locale); + let year: number, month: number, day: number; + if (order === "MDY") [month, day, year] = nums; + else if (order === "YMD") [year, month, day] = nums; + else [day, month, year] = nums; // DMY + + if (year < 100) year += year >= 70 ? 1900 : 2000; + const candidate = `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`; + const round = parseIsoDate(candidate); + return round ? candidate : null; +} + +function resolveOrder( + dateFormat: ReturnType, + locale: ReturnType["locale"], +): "DMY" | "MDY" | "YMD" { + if (dateFormat === "DMY") return "DMY"; + if (dateFormat === "MDY") return "MDY"; + if (dateFormat === "YMD") return "YMD"; + // AUTO: en → MDY, every other shipped locale → DMY. + return locale === "en" ? "MDY" : "DMY"; +} + +function toIso(date: Date): string { + return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1, 2)}-${pad( + date.getUTCDate(), + 2, + )}`; +} + +function pad(value: number, width: number): string { + return String(value).padStart(width, "0"); +} From 34ed1a0c9306fc744bae09abbc47e0e48a68055b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:22:32 +0200 Subject: [PATCH 06/51] feat(coach): add acute red-flag escalation to the shared safety contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared contracts cover chronic deferral (dose, diagnosis, drug-level to a clinician) but had no acute branch. Add a closed-list crisis clause — chest pain, syncope, sudden severe symptoms, hypertensive-crisis readings, suicidal ideation — that points to prompt or emergency care without diagnosing, and keep every other turn calm and non-alarmist. It rides the single shared-contract source so it lands on the Coach, the per-metric status cards, the comprehensive briefing, and the period narrative; the coverage test asserts it on all four surfaces. --- src/lib/ai/coach/system-prompt.ts | 1 + .../shared-contracts-coverage.test.ts | 23 ++++++++++++++++++- src/lib/ai/prompts/base-system.ts | 5 ++++ src/lib/ai/prompts/insight-generator.ts | 2 ++ src/lib/ai/prompts/shared-contracts.ts | 15 ++++++++++++ .../narrative/period-narrative-generate.ts | 4 ++-- 6 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 74b3590d3..e854a9a2a 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -900,6 +900,7 @@ export function getCoachSystemPrompt( "grounding", "toneContract", "safetyGlp1", + "safetyAcute", "metricIdentifierBan", "forbiddenFiller", ])}` diff --git a/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts b/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts index e2c790a07..6d3a812a6 100644 --- a/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts +++ b/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts @@ -14,6 +14,7 @@ import { grounding, toneContract, safetyGlp1, + safetyAcute, metricIdentifierBan, forbiddenFiller, type ContractLocale, @@ -43,6 +44,7 @@ const SURFACES: Record< grounding, toneContract, safetyGlp1, + safetyAcute, metricIdentifierBan, forbiddenFiller, ], @@ -53,6 +55,7 @@ const SURFACES: Record< grounding, toneContract, safetyGlp1, + safetyAcute, metricIdentifierBan, forbiddenFiller, ], @@ -63,6 +66,7 @@ const SURFACES: Record< grounding, toneContract, safetyGlp1, + safetyAcute, metricIdentifierBan, forbiddenFiller, ], @@ -71,7 +75,13 @@ const SURFACES: Record< // the motivating tone contract. "period-narrative": { prompt: (l) => SYSTEM_PROMPTS_FOR_TEST[l], - contracts: [grounding, safetyGlp1, metricIdentifierBan, forbiddenFiller], + contracts: [ + grounding, + safetyGlp1, + safetyAcute, + metricIdentifierBan, + forbiddenFiller, + ], }, }; @@ -100,4 +110,15 @@ describe("shared-contract cross-surface coverage", () => { expect(SYSTEM_PROMPTS_FOR_TEST[locale]).toContain(safetyGlp1[locale]); } }); + + it("the acute red-flag escalation contract reaches all four surfaces", () => { + for (const locale of LOCALES) { + expect(getStrictInsightsSystemPrompt(locale)).toContain( + safetyAcute[locale], + ); + expect(getBaseSystemPrompt(locale)).toContain(safetyAcute[locale]); + expect(getCoachSystemPrompt(locale)).toContain(safetyAcute[locale]); + expect(SYSTEM_PROMPTS_FOR_TEST[locale]).toContain(safetyAcute[locale]); + } + }); }); diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts index 08aeaf6f5..8eba54b70 100644 --- a/src/lib/ai/prompts/base-system.ts +++ b/src/lib/ai/prompts/base-system.ts @@ -3,6 +3,7 @@ import { grounding, toneContract, safetyGlp1, + safetyAcute, metricIdentifierBan, forbiddenFiller, } from "./shared-contracts"; @@ -171,6 +172,10 @@ const ASSESSMENT_SECTIONS: readonly AssessmentSection[] = [ de: metricIdentifierBan.de, }, { id: "safetyGlp1", en: safetyGlp1.en, de: safetyGlp1.de }, + // Acute red-flag escalation — the ACUTE branch alongside the chronic + // deferral above; surfaces a closed crisis list to prompt/emergency care + // without diagnosing. + { id: "safetyAcute", en: safetyAcute.en, de: safetyAcute.de }, // v1.18.7 (HIGH-2) — the shared grounding + tone contracts, canonical // wording. The metric-specific build/tone sections above stay; these pin // the cross-surface wording so an edit lands here and everywhere at once. diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index 5d6a4e315..97b2cc334 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -33,6 +33,7 @@ import { grounding, toneContract, safetyGlp1, + safetyAcute, metricIdentifierBan, forbiddenFiller, } from "./shared-contracts"; @@ -759,6 +760,7 @@ neutralem Label + Detail. Höchstgrenze: 20 Einträge.`, { id: "sharedGrounding", en: grounding.en, de: grounding.de }, { id: "sharedTone", en: toneContract.en, de: toneContract.de }, { id: "sharedSafetyGlp1", en: safetyGlp1.en, de: safetyGlp1.de }, + { id: "sharedSafetyAcute", en: safetyAcute.en, de: safetyAcute.de }, { id: "sharedMetricIdentifierBan", en: metricIdentifierBan.en, diff --git a/src/lib/ai/prompts/shared-contracts.ts b/src/lib/ai/prompts/shared-contracts.ts index 72aa3fdfa..67064ffd4 100644 --- a/src/lib/ai/prompts/shared-contracts.ts +++ b/src/lib/ai/prompts/shared-contracts.ts @@ -66,6 +66,20 @@ NEVER prescribe, recommend, or modify medication doses, even when the snapshot n Verschreibe, empfiehl oder ändere NIEMALS Medikamenten-Dosen, auch wenn der Snapshot einen GLP-1-Rezeptoragonisten benennt (Mounjaro, Ozempic, Wegovy, Zepbound, Trulicity, Saxenda, Rybelsus). Du darfst den benannten Wirkstoff und die aktuelle Titrationsstufe NENNEN ("Woche 3 auf 7,5 mg"), wenn der Snapshot sie trägt, aber schreibe nie "erhöhe auf X mg", "erwäge die nächste Stufe Y mg", "bleibe auf Z mg" oder eine Variante davon. Ein Plateau rahmt die nächste Entscheidung immer als Gespräch mit der behandelnden Ärztin. Bist du unsicher, ob etwas dosis-präskriptiv ist, behandle es so — und verweise an die Klinik.`, }; +/** + * Acute red-flag escalation. The chronic-deferral posture (defer dose / + * diagnosis / drug-level to a clinician) covers slow-moving questions; this + * closed list covers the ACUTE branch — a small set of crisis signals that + * warrant prompt/emergency care now, surfaced WITHOUT diagnosing. Kept tight + * on purpose so the surfaces stay non-alarmist on everything else. + */ +export const safetyAcute: SharedContract = { + en: `ACUTE RED FLAGS (contract, not style) +If the user describes an acute crisis sign — chest pain or chest pressure, fainting or near-fainting (syncope), a sudden severe symptom (e.g. worst-ever headache, sudden weakness or trouble speaking, trouble breathing), a hypertensive-crisis reading (systolic ≥ 180 or diastolic ≥ 120 with symptoms), or any mention of suicidal thoughts or self-harm — say plainly, in one calm sentence, that this needs prompt medical attention or emergency services now, and do NOT data-coach it. Do not diagnose, do not name a condition, do not estimate severity from the numbers — just point to prompt/emergency care and stop. This is a closed list; outside it, stay calm and non-alarmist as usual.`, + de: `AKUTE WARNZEICHEN (Vertrag, kein Stil) +Beschreibt der Nutzer ein akutes Krisenzeichen — Brustschmerz oder Druck auf der Brust, Ohnmacht oder Beinahe-Ohnmacht (Synkope), ein plötzliches schweres Symptom (z. B. stärkster Kopfschmerz aller Zeiten, plötzliche Schwäche oder Sprachstörung, Atemnot), einen hypertensiven Notfallwert (systolisch ≥ 180 oder diastolisch ≥ 120 mit Symptomen) oder Gedanken an Suizid bzw. Selbstverletzung — sage in einem ruhigen Satz klar, dass das jetzt umgehende ärztliche Hilfe oder den Notruf braucht, und coache es NICHT anhand der Daten. Diagnostiziere nicht, benenne keine Erkrankung, schätze keinen Schweregrad aus den Zahlen — verweise nur auf umgehende Hilfe bzw. den Notruf und höre auf. Das ist eine geschlossene Liste; außerhalb davon bleibe wie gewohnt ruhig und nicht alarmierend.`, +}; + /** * Internal metric-identifier ban: enum / DB-style names stay out of * user-facing prose; reference each metric by its natural-language label. @@ -95,6 +109,7 @@ export const SHARED_CONTRACTS = { grounding, toneContract, safetyGlp1, + safetyAcute, metricIdentifierBan, forbiddenFiller, } as const satisfies Record; diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index b597a5511..2a34d47ef 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -108,7 +108,7 @@ Hard rules: - 2 to 4 short sentences. Plain text only — no markdown, no headings, no bullet points, no emojis. - If the context is thin, say plainly that there is little to report this period rather than inventing detail. -${composeSharedContracts("en", ["grounding", "safetyGlp1", "metricIdentifierBan", "forbiddenFiller"])}`; +${composeSharedContracts("en", ["grounding", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller"])}`; const SYSTEM_PROMPT_DE = `Du fasst den Gesundheits-Tracking-ZEITRAUM einer Person (eine Woche oder einen Monat) für diese Person zusammen. Prompt-Version: ${NARRATIVE_PROMPT_VERSION}. @@ -121,7 +121,7 @@ Feste Regeln: - 2 bis 4 kurze Sätze. Nur Klartext — kein Markdown, keine Überschriften, keine Aufzählungen, keine Emojis. - Wenn der Kontext dünn ist, sage klar, dass es in diesem Zeitraum wenig zu berichten gibt, statt Details zu erfinden. -${composeSharedContracts("de", ["grounding", "safetyGlp1", "metricIdentifierBan", "forbiddenFiller"])}`; +${composeSharedContracts("de", ["grounding", "safetyGlp1", "safetyAcute", "metricIdentifierBan", "forbiddenFiller"])}`; /** * Test-only view of the composed system prompts (incl. the appended shared From 9279b062d391804331f813d6d2e0946ca4e309ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:24:43 +0200 Subject: [PATCH 07/51] feat(coach): connect signals, confidence-ruler close, three-beat shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharpen the Coach's behaviour-change reflexes from the coaching literature, prompt-only: - Connect the signals on a why/pattern question — consult correlations and link them descriptively ("after short-sleep nights your next-morning HRV tends to read lower") instead of reading metrics in isolation, always an association worth a small experiment, never a cause. - Confidence ruler on action turns — after naming one small step, ask how doable it feels 0-10 and shrink it when that lands low, keeping the choice the user's. - Three-beat shape on data-review turns — finding, likely driver, one small step; name the dominant contributor when citing a derived band. - Extend the MI micro-moves with developing discrepancy and rolling with resistance, and add an anti-persuasion check: any suggested change must serve the user's own stated goal. One bilingual tone-calibration example added per locale. --- src/lib/ai/coach/system-prompt.ts | 111 ++++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 7 deletions(-) diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index e854a9a2a..767132881 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -80,10 +80,15 @@ GROUND RULES 5. Motivational-interviewing micro-moves. Reach for one of these per turn when the user opens a topic: an open question that surfaces what they already think, a brief earned affirmation of something - they're genuinely doing well, a reflection of what you heard, or a - one-line summary at the end of a longer turn. Pick what fits — don't - stack all four, and keep every affirmation anchored to a real figure - or a real change. + they're genuinely doing well, a reflection of what you heard, a + one-line summary at the end of a longer turn, gently surfacing the + gap between where the user is and a goal they named (developing + discrepancy), or — when the user pushes back — rolling with the + resistance instead of arguing. Pick what fits — don't stack them, + and keep every affirmation anchored to a real figure or a real + change. Any change you suggest must serve the user's OWN stated + goal, never persuasion for its own sake; if you don't know their + goal, ask before nudging. 6. Redirect off-topic input gracefully. If the user asks about politics, news, code, weather, or anything outside their health @@ -207,6 +212,31 @@ GROUND RULES diabetes, the glucose line states a clinician-set management GOAL, not a screening threshold. When no REFERENCE GROUNDING block is present, do not invent a band — pivot to the user's own trend. +14. Connect the signals. On a "why" or pattern question, don't read each + metric in isolation. After you have the relevant signals, consult the + correlations available to you (the "get_correlations" retrieval tool + when offered, otherwise any "correlation"/driver field the SNAPSHOT + carries) and LINK them descriptively — "after your short-sleep nights + your next-morning HRV tends to read lower" — rather than listing + metrics side by side. Always frame the link as an association worth a + small experiment, never a cause, and only when the underlying figure + is actually present. If you have no correlation to lean on, say what + you observe across the series plainly and invite the user's read. +15. Confidence ruler on action turns. When the user asks "what should I + do" and there is a real, doable step, don't just prescribe it. Once + you have named one small step, ask how confident they feel about it + on a 0–10 scale; if that lands low, offer to shrink the step to + something they're more sure of rather than pushing the original. This + keeps the choice theirs. Use it only on genuine action turns — skip + it for a quick factual lookup or a reassurance reply. +16. Three-beat shape on data-review turns. When you're talking through a + finding, a clean shape is: the finding, then the likely driver, then + one small next step — don't stop at the finding. When you cite a + derived band (readiness, recovery, sleep score, strain), name the + dominant contributor driving it the way the user sees it ("your + readiness is yellow, mostly the HRV-balance piece") instead of the + band alone. Stay descriptive; the driver is an observation, not a + diagnosis. DAY-LEVEL READINGS — USE THE TIMELINE @@ -439,6 +469,21 @@ avg90 systolic: 132 [mmHg] (last90days) ---END--- + +USER: Why has my recovery been low this week? +COACH: Your readiness has been sitting in the yellow band, and the +piece pulling it down most is your HRV balance. That lines up with +your sleep — on the nights you slept short this week, your next-morning +readings tended to run lower. It reads like an association worth a +small experiment rather than anything to worry about. If you wanted to +test it, an earlier night or two is the smallest lever — on a scale of +0 to 10, how doable does that feel this week? +---KEYVALUES--- +readiness: 58 (last7days) +avg7 sleep: 6h 10m (last7days) +---END--- + + USER: What's the weather going to be like tomorrow? COACH: That's outside what I can help with — want to take a look at @@ -508,9 +553,16 @@ GRUNDREGELN nach einem davon, wenn der Nutzer ein Thema öffnet: eine offene Frage, die zeigt, was er selbst schon denkt; eine kurze, verdiente Anerkennung von etwas, das er wirklich gut macht; eine Reflexion - dessen, was du gehört hast; oder eine einsätzige Zusammenfassung am - Ende einer längeren Antwort. Wähl was passt — stapel nicht alle vier, - und verankere jede Anerkennung an einer echten Zahl oder Veränderung. + dessen, was du gehört hast; eine einsätzige Zusammenfassung am + Ende einer längeren Antwort; das behutsame Sichtbarmachen der Lücke + zwischen dem, wo der Nutzer steht, und einem von ihm genannten Ziel + (Diskrepanz entwickeln); oder — wenn der Nutzer Widerstand zeigt — + den Widerstand annehmen, statt zu argumentieren. Wähl was passt — + stapel sie nicht, und verankere jede Anerkennung an einer echten + Zahl oder Veränderung. Jede vorgeschlagene Veränderung muss dem + EIGENEN genannten Ziel des Nutzers dienen, nie der Überzeugung um + ihrer selbst willen; kennst du sein Ziel nicht, frag nach, bevor du + anstößt. 6. Off-topic-Eingaben elegant umlenken. Fragt der Nutzer nach Politik, Nachrichten, Code, Wetter oder etwas außerhalb seiner @@ -647,6 +699,35 @@ GRUNDREGELN klinisch gesetztes Management-ZIEL, keinen Screening-Schwellwert. Ist kein REFERENCE-GROUNDING-Block vorhanden, erfinde keinen Bereich — wechsle zum eigenen Trend des Nutzers. +14. Verbinde die Signale. Bei einer "Warum"- oder Musterfrage lies nicht + jede Metrik für sich. Wenn du die relevanten Signale hast, ziehe die + dir verfügbaren Zusammenhänge heran (das Abfrage-Tool + "get_correlations", wenn es angeboten wird, sonst ein + "correlation"-/Treiber-Feld, das der SNAPSHOT trägt) und VERKNÜPFE + sie beschreibend — "nach deinen kurzen Nächten liest deine HRV am + nächsten Morgen tendenziell niedriger" — statt Metriken + nebeneinanderzustellen. Rahme die Verknüpfung immer als Zusammenhang, + der einen kleinen Versuch wert ist, nie als Ursache, und nur, wenn + die zugrunde liegende Zahl wirklich vorhanden ist. Hast du keinen + Zusammenhang, auf den du dich stützen kannst, benenne klar, was du + über die Reihe hinweg beobachtest, und lade die Einschätzung des + Nutzers ein. +15. Konfidenz-Skala bei Handlungs-Turns. Fragt der Nutzer "Was soll ich + tun?" und es gibt einen echten, machbaren Schritt, verschreib ihn + nicht einfach. Hast du einen kleinen Schritt benannt, frag, wie + sicher er sich auf einer Skala von 0–10 dabei fühlt; fällt das + niedrig aus, biete an, den Schritt auf etwas zu verkleinern, bei dem + er sich sicherer ist, statt am ursprünglichen festzuhalten. So bleibt + die Wahl bei ihm. Nutze das nur bei echten Handlungs-Turns — bei + einer kurzen Faktenfrage oder einer beruhigenden Antwort lass es weg. +16. Drei-Schritt-Form bei Daten-Turns. Wenn du einen Befund durchgehst, + ist eine saubere Form: der Befund, dann der wahrscheinliche Treiber, + dann ein kleiner nächster Schritt — bleib nicht beim Befund stehen. + Nennst du ein abgeleitetes Band (Readiness, Erholung, Schlafscore, + Strain), benenne den dominanten Treiber dahinter so, wie der Nutzer + ihn sieht ("deine Readiness ist gelb, hauptsächlich der + HRV-Balance-Anteil") statt nur das Band. Bleib beschreibend; der + Treiber ist eine Beobachtung, keine Diagnose. TAGES-LEVEL-MESSWERTE — NUTZE DIE TIMELINE @@ -828,6 +909,22 @@ avg90 systolisch: 132 [mmHg] (last90days) ---END--- + +USER: Warum war meine Erholung diese Woche niedrig? +COACH: Deine Readiness liegt im gelben Band, und was sie am stärksten +drückt, ist deine HRV-Balance. Das passt zu deinem Schlaf — an den +Nächten, in denen du diese Woche kurz geschlafen hast, lasen deine +Werte am nächsten Morgen tendenziell niedriger. Das wirkt eher wie ein +Zusammenhang, der einen kleinen Versuch wert ist, als etwas zum Sorgen. +Wenn du es testen wolltest, wären ein, zwei frühere Nächte der kleinste +Hebel — auf einer Skala von 0 bis 10, wie machbar fühlt sich das diese +Woche an? +---KEYVALUES--- +readiness: 58 (last7days) +avg7 Schlaf: 6h 10m (last7days) +---END--- + + USER: Wie wird das Wetter morgen? COACH: Das ist außerhalb dessen, womit ich helfen kann — magst du From bf58d5d0b3ee2fd824b5d5616292618e8b713e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:26:09 +0200 Subject: [PATCH 08/51] feat(ui): render date inputs in the user's date-format preference Swap the medication scheduling, inventory, illness, vorsorge, lab-OCR and profile-birthdate date inputs from the native field to the DateField primitive, which paints the chosen DMY/MDY/YMD/AUTO order over an ISO yyyy-MM-dd value. The committed value contract, min/max bounds and aria wiring are unchanged. --- src/components/illness/log-day-sheet.tsx | 8 ++++---- src/components/illness/new-episode-sheet.tsx | 6 +++--- src/components/labs/ocr-row-editor.tsx | 8 +++----- .../vorsorge-section.tsx | 8 ++++---- .../medications/scheduling/cadence-picker.tsx | 7 +++---- .../scheduling/course-window-row.tsx | 19 +++++++++---------- .../sections/inventory-section.tsx | 7 +++---- .../settings/account-section/index.tsx | 6 +++--- 8 files changed, 32 insertions(+), 37 deletions(-) diff --git a/src/components/illness/log-day-sheet.tsx b/src/components/illness/log-day-sheet.tsx index 19319ee94..1f6a740e8 100644 --- a/src/components/illness/log-day-sheet.tsx +++ b/src/components/illness/log-day-sheet.tsx @@ -19,6 +19,7 @@ import { Activity, CalendarDays, NotebookPen, Thermometer } from "lucide-react"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section"; import { Button } from "@/components/ui/button"; +import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; @@ -266,14 +267,13 @@ export function LogDaySheet({ - { - if (e.target.value) setSelectedDate(e.target.value); + onChange={(value) => { + if (value) setSelectedDate(value); }} className="max-w-44" /> diff --git a/src/components/illness/new-episode-sheet.tsx b/src/components/illness/new-episode-sheet.tsx index a93d61480..0334548b3 100644 --- a/src/components/illness/new-episode-sheet.tsx +++ b/src/components/illness/new-episode-sheet.tsx @@ -10,6 +10,7 @@ import { useState } from "react"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Button } from "@/components/ui/button"; +import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; @@ -230,12 +231,11 @@ export function NewEpisodeSheet({
- setOnset(e.target.value)} + onChange={setOnset} className="max-w-44" />
diff --git a/src/components/labs/ocr-row-editor.tsx b/src/components/labs/ocr-row-editor.tsx index 640ff96bf..f3838308d 100644 --- a/src/components/labs/ocr-row-editor.tsx +++ b/src/components/labs/ocr-row-editor.tsx @@ -25,6 +25,7 @@ import { import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; +import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; @@ -178,13 +179,10 @@ export function OcrRowEditor({ - - onChange({ ...row, takenAt: e.target.value || null }) - } + onChange={(value) => onChange({ ...row, takenAt: value || null })} /> diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index b1a68df33..280702666 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -64,6 +64,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { DateField } from "@/components/ui/date-field"; import { EmptyState } from "@/components/ui/empty-state"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -598,12 +599,11 @@ export function VorsorgeSection({ - - setForm((f) => ({ ...f, anchorDate: e.target.value })) + onChange={(value) => + setForm((f) => ({ ...f, anchorDate: value })) } /> diff --git a/src/components/medications/scheduling/cadence-picker.tsx b/src/components/medications/scheduling/cadence-picker.tsx index aac443d72..4a4271e0b 100644 --- a/src/components/medications/scheduling/cadence-picker.tsx +++ b/src/components/medications/scheduling/cadence-picker.tsx @@ -49,6 +49,7 @@ import { useCallback, useId, useMemo } from "react"; +import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; @@ -602,13 +603,11 @@ function YearlyDate({ - onChange(e.target.value)} - className="h-11" + onChange={onChange} data-slot="cadence-yearly-date" /> diff --git a/src/components/medications/scheduling/course-window-row.tsx b/src/components/medications/scheduling/course-window-row.tsx index aaad105bd..4ec9f91c1 100644 --- a/src/components/medications/scheduling/course-window-row.tsx +++ b/src/components/medications/scheduling/course-window-row.tsx @@ -25,7 +25,7 @@ import { useCallback, useId, useMemo } from "react"; -import { Input } from "@/components/ui/input"; +import { DateField } from "@/components/ui/date-field"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; @@ -135,15 +135,15 @@ export function CourseWindowRow({ - onStartsChange(e.target.value)} - className="h-11 w-full" + onChange={onStartsChange} + className="w-full" aria-label={t(`${i18nPrefix}.startsOn.label`)} data-slot="course-window-starts" + data-testid="course-window-starts-field" /> @@ -152,17 +152,16 @@ export function CourseWindowRow({ - onEndsChange(e.target.value)} - className="h-11 w-full" + onChange={onEndsChange} + className="w-full" aria-label={t(`${i18nPrefix}.endsOn.label`)} aria-invalid={!valid || undefined} data-slot="course-window-ends" + data-testid="course-window-ends-field" /> {lockEndsToStart && (

{t("medications.detail.bestand.addExpiryLabel")} - setExpiry(e.target.value)} + onChange={setExpiry} /> diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 57c68ce83..4fc5df757 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -13,7 +13,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { DateInput } from "@/components/ui/date-input"; +import { DateField } from "@/components/ui/date-field"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; @@ -373,10 +373,10 @@ export function AccountSection() {

- setDateOfBirth(e.target.value)} + onChange={setDateOfBirth} max={new Date().toISOString().slice(0, 10)} />

From 291fb302095854a864c50fb4de1f61febb886fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:26:17 +0200 Subject: [PATCH 09/51] test(e2e): target the DateField overlay and ISO mirror in the medication wizard The course-window date field now keeps its ISO value on a hidden native input (data-slot) and edits through a text overlay (data-testid). Fill the overlay, then assert the committed ISO value on the hidden input. --- e2e/medications-wizard-daily.spec.ts | 3 +++ e2e/medications-wizard-oneshot.spec.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/e2e/medications-wizard-daily.spec.ts b/e2e/medications-wizard-daily.spec.ts index 65b23df62..7b62a9670 100644 --- a/e2e/medications-wizard-daily.spec.ts +++ b/e2e/medications-wizard-daily.spec.ts @@ -59,6 +59,9 @@ test.describe("medication wizard — daily", () => { // Step 4 — course window (today by default). await expectStep(page, 4); + // `DateField` keeps the ISO value on a hidden native date input + // (data-slot), while the visible overlay paints the locale-formatted + // string. Assert the committed ISO value on the hidden input. await expect( page.locator('[data-slot="course-window-starts"]'), ).toHaveValue(/^\d{4}-\d{2}-\d{2}$/); diff --git a/e2e/medications-wizard-oneshot.spec.ts b/e2e/medications-wizard-oneshot.spec.ts index a5506caa3..9bb93e075 100644 --- a/e2e/medications-wizard-oneshot.spec.ts +++ b/e2e/medications-wizard-oneshot.spec.ts @@ -62,7 +62,15 @@ test.describe("medication wizard — one-shot", () => { // Step 4 — course window. Set startsOn to a deterministic date so // the post body assertion below is stable. await expectStep(page, 4); - await page.locator('[data-slot="course-window-starts"]').fill("2026-10-15"); + // `DateField` rides a visible text overlay (the data-testid) that parses + // a typed ISO string back to the canonical value; the hidden native input + // (data-slot) carries the committed ISO. Type into the overlay, then blur + // so the parse commits. + await page.getByTestId("course-window-starts-field").fill("2026-10-15"); + await page.getByTestId("course-window-starts-field").blur(); + await expect( + page.locator('[data-slot="course-window-starts"]'), + ).toHaveValue("2026-10-15"); await clickNext(page); // Step 5 — pick Einmalig. The path compresses to 5 steps the From b470a1418712de3ffcb961c5e20fe4308d3994fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:29:29 +0200 Subject: [PATCH 10/51] fix(insights): ground deterministic status fallbacks in the user's own numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The no-key and timeout fallback text carried zero reference to the user's own readings and, on a provider timeout, rendered as hasProvider:true — indistinguishable from a fresh assessment. Compose each fallback from the per-metric signal the card already builds: name the current value, place it against the user's own baseline, and close with one plain-language pointer, in the same warm voice as the model surfaces. Degrade to the prior generic tip only when a metric has no usable history. The timeout envelope now reports hasProvider:false so the UI surfaces it as the computed summary it is rather than mislabelling it as provider prose. --- .../__tests__/no-key-fallbacks.test.ts | 136 ++++++++ .../__tests__/pulse-status-timeout.test.ts | 11 +- .../__tests__/timeout-fallback.test.ts | 28 ++ src/lib/insights/blood-pressure-status.ts | 4 +- src/lib/insights/bmi-status.ts | 4 +- src/lib/insights/metric-status.ts | 9 +- src/lib/insights/mood-status.ts | 4 +- src/lib/insights/no-key-fallbacks.ts | 309 ++++++++++++++++-- src/lib/insights/pulse-status.ts | 4 +- src/lib/insights/timeout-fallback.ts | 12 +- src/lib/insights/weight-status.ts | 4 +- 11 files changed, 480 insertions(+), 45 deletions(-) create mode 100644 src/lib/insights/__tests__/no-key-fallbacks.test.ts create mode 100644 src/lib/insights/__tests__/timeout-fallback.test.ts diff --git a/src/lib/insights/__tests__/no-key-fallbacks.test.ts b/src/lib/insights/__tests__/no-key-fallbacks.test.ts new file mode 100644 index 000000000..a8f739362 --- /dev/null +++ b/src/lib/insights/__tests__/no-key-fallbacks.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from "vitest"; +import { + getNoKeyBloodPressureStatusText, + getNoKeyWeightStatusText, + getNoKeyPulseStatusText, + getNoKeyMoodStatusText, + getNoKeyMedicationComplianceStatusText, + getNoKeyMetricStatusText, + getNoKeyGeneralStatusText, +} from "@/lib/insights/no-key-fallbacks"; +import type { MetricSignal } from "@/lib/insights/metric-signal"; + +/** A minimal signal with the fields the grounded composer reads. */ +function signal(overrides: Partial): MetricSignal { + return { + metric: "your metric", + current: 70, + currentWindowDays: 7, + baseline: 65, + delta: 5, + deltaPct: 7.7, + spread: 2, + outsideNormalSwing: true, + direction: "lower-better", + n: 14, + newestDaysAgo: 0, + ...overrides, + }; +} + +describe("signal-grounded no-key fallbacks", () => { + it("names the user's value and places it against their own baseline (pulse, en)", () => { + const text = getNoKeyPulseStatusText( + "en", + signal({ current: 72, baseline: 64, delta: 8, outsideNormalSwing: true }), + ); + // Names the actual value with the metric's unit. + expect(text).toContain("72 bpm"); + // Places it against the user's OWN baseline, not a population norm. + expect(text).toContain("64 bpm"); + expect(text.toLowerCase()).toContain("usual average"); + // Ends with one grounded pointer (an actionable close, not a platitude). + expect(text.trim().endsWith(".")).toBe(true); + expect(text.toLowerCase()).toContain("same time of day"); + // No banned generic clinical opener. + expect(text).not.toContain("Measure resting pulse in a relaxed state"); + }); + + it("affirms 'nothing to act on' when the value sits inside the usual swing (weight, en)", () => { + const text = getNoKeyWeightStatusText( + "en", + signal({ + metric: "weight", + current: 80.4, + baseline: 80.3, + delta: 0.1, + outsideNormalSwing: false, + direction: "target-band", + }), + ); + expect(text).toContain("80.4"); + expect(text.toLowerCase()).toContain("nothing you need to act on"); + }); + + it("names the value without inventing a comparison when there is no baseline (bp, de)", () => { + const text = getNoKeyBloodPressureStatusText( + "de", + signal({ + metric: "blood pressure", + current: 128, + baseline: null, + delta: null, + outsideNormalSwing: null, + deltaPct: null, + spread: null, + }), + ); + expect(text).toContain("128"); + // No fabricated baseline phrasing. + expect(text).not.toContain("Schnitt von"); + }); + + it("falls back to the generic tip when no signal is given (mood, en)", () => { + const text = getNoKeyMoodStatusText("en"); + expect(text).toContain("Evaluate mood trends over several weeks"); + }); + + it("falls back to the generic tip when the signal has no finite current (adherence, en)", () => { + const text = getNoKeyMedicationComplianceStatusText( + "en", + signal({ current: Number.NaN }), + ); + expect(text).toContain("Consistency in intake matters more"); + }); + + it("adherence grounds with a percent value and a routine pointer (en)", () => { + const text = getNoKeyMedicationComplianceStatusText( + "en", + signal({ + metric: "adherence", + current: 72, + baseline: 88, + delta: -16, + outsideNormalSwing: true, + direction: "higher-better", + }), + ); + expect(text).toContain("72%"); + expect(text).toContain("88%"); + expect(text.toLowerCase()).toContain("reminder"); + }); + + it("the generic per-metric fallback grounds against the signal's own label + unit (en)", () => { + const text = getNoKeyMetricStatusText( + "en", + signal({ + metric: "your steps", + unit: "steps", + current: 4200, + baseline: 7800, + delta: -3600, + outsideNormalSwing: true, + direction: "higher-better", + }), + ); + expect(text).toContain("4200 steps"); + expect(text).toContain("7800 steps"); + expect(text.toLowerCase()).toContain("your steps"); + }); + + it("the generic per-metric fallback degrades to the general tip with no signal", () => { + expect(getNoKeyMetricStatusText("en")).toBe( + getNoKeyGeneralStatusText("en"), + ); + }); +}); diff --git a/src/lib/insights/__tests__/pulse-status-timeout.test.ts b/src/lib/insights/__tests__/pulse-status-timeout.test.ts index 18ce10a10..298656eb8 100644 --- a/src/lib/insights/__tests__/pulse-status-timeout.test.ts +++ b/src/lib/insights/__tests__/pulse-status-timeout.test.ts @@ -8,6 +8,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; * fallback AS AN ASSESSMENT (the pre-v1.4.28 stick-until-midnight bug); * `updatedAt` stays null and the served text is never a real assessment. * + * v1.21.0 (coach C1 HIGH-1) — the fallback now reports `hasProvider:false` + * (it is a deterministic, signal-grounded line, not a fresh AI assessment), + * and the served text names the user's own value rather than a generic tip. + * * v1.8.3 — the timeout path now writes a *short-TTL negative stub* * (`{ timeout:true, model:"timeout-stub", retryAt }`). It is explicitly * rejected by `readFreshStatusText`, so it can never hide the real @@ -65,10 +69,15 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => { locale: "en", }); - expect(result.hasProvider).toBe(true); + // Honest labelling — a deterministic fallback is NOT a provider + // assessment, so the UI can render it as the computed summary it is. + expect(result.hasProvider).toBe(false); expect(result.cached).toBe(true); expect(typeof result.text).toBe("string"); expect(result.text?.length ?? 0).toBeGreaterThan(0); + // Signal-grounded — names the user's own pulse value (72), not a generic + // clinical platitude. + expect(result.text).toContain("72 bpm"); // No real assessment persisted — `updatedAt` stays null so the card // never mislabels the fallback as a fresh assessment. expect(result.updatedAt).toBeNull(); diff --git a/src/lib/insights/__tests__/timeout-fallback.test.ts b/src/lib/insights/__tests__/timeout-fallback.test.ts new file mode 100644 index 000000000..b7d642bc8 --- /dev/null +++ b/src/lib/insights/__tests__/timeout-fallback.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; + +describe("returnTimeoutFallback", () => { + it("reports hasProvider:false so a deterministic fallback is never mislabelled as a fresh AI assessment", () => { + const result = returnTimeoutFallback({ + cacheAction: "insights.pulse-status.en", + reason: "timeout", + stubText: "Your resting pulse is at 72 bpm right now.", + // No userId/todayKey → no negative-stub write, pure render path. + }); + expect(result.hasProvider).toBe(false); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBeNull(); + expect(result.text).toContain("72 bpm"); + }); + + it("passes the grounded stub text through verbatim on an error", () => { + const stub = "Your blood pressure is at 128 right now."; + const result = returnTimeoutFallback({ + cacheAction: "insights.blood-pressure-status.en", + reason: "error", + stubText: stub, + }); + expect(result.text).toBe(stub); + expect(result.hasProvider).toBe(false); + }); +}); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 1b2b10e90..c79383912 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -696,7 +696,7 @@ export async function prepareBloodPressureStatusForUser( temperature: 0.45, noProvider: { hasProvider: false, - text: getNoKeyBloodPressureStatusText(locale), + text: getNoKeyBloodPressureStatusText(locale, sysSignal), cached: true, updatedAt: null, }, @@ -706,7 +706,7 @@ export async function prepareBloodPressureStatusForUser( reason, userId, todayKey, - stubText: getNoKeyBloodPressureStatusText(locale), + stubText: getNoKeyBloodPressureStatusText(locale, sysSignal), }), finalize: async (outcome): Promise => { const summary = normalizeSummaryText( diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 231359f9a..269b87193 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -409,7 +409,7 @@ export async function prepareBmiStatusForUser( temperature: 0.45, noProvider: { hasProvider: false, - text: getNoKeyBmiStatusText(locale), + text: getNoKeyBmiStatusText(locale, bmiSignal), cached: true, updatedAt: null, }, @@ -419,7 +419,7 @@ export async function prepareBmiStatusForUser( reason, userId, todayKey, - stubText: getNoKeyBmiStatusText(locale), + stubText: getNoKeyBmiStatusText(locale, bmiSignal), }), finalize: async (outcome): Promise => { const summary = normalizeSummaryText( diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 343e12148..945ad3a6b 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -46,7 +46,10 @@ import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { lookupNormalRange } from "@/lib/insights/derived/norms"; import { buildMetricSignal } from "@/lib/insights/metric-signal"; import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system"; -import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks"; +import { + getNoKeyGeneralStatusText, + getNoKeyMetricStatusText, +} from "@/lib/insights/no-key-fallbacks"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { buildGradedSeriesFromPoints, @@ -479,7 +482,7 @@ export async function generateMetricStatus(args: { if (outcome.kind === "none") { return { hasProvider: false, - text: getNoKeyGeneralStatusText(locale), + text: getNoKeyMetricStatusText(locale, signal), cached: true, updatedAt: null, }; @@ -490,7 +493,7 @@ export async function generateMetricStatus(args: { reason: outcome.kind, userId: args.userId, todayKey, - stubText: getNoKeyGeneralStatusText(locale), + stubText: getNoKeyMetricStatusText(locale, signal), }); } diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index e651fca14..fee01ce2f 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -708,7 +708,7 @@ export async function prepareMoodStatusForUser( temperature: 0.45, noProvider: { hasProvider: false, - text: getNoKeyMoodStatusText(locale), + text: getNoKeyMoodStatusText(locale, moodSignal), cached: true, updatedAt: null, }, @@ -718,7 +718,7 @@ export async function prepareMoodStatusForUser( reason, userId, todayKey, - stubText: getNoKeyMoodStatusText(locale), + stubText: getNoKeyMoodStatusText(locale, moodSignal), }), finalize: async (outcome): Promise => { const summary = normalizeSummaryText( diff --git a/src/lib/insights/no-key-fallbacks.ts b/src/lib/insights/no-key-fallbacks.ts index 148b6d82e..323fba9d5 100644 --- a/src/lib/insights/no-key-fallbacks.ts +++ b/src/lib/insights/no-key-fallbacks.ts @@ -1,4 +1,5 @@ import type { Locale } from "@/lib/i18n/config"; +import type { MetricSignal } from "@/lib/insights/metric-signal"; /** * v1.4.25 W9e — these no-key fallbacks ship DE + EN bodies only. The @@ -7,6 +8,17 @@ import type { Locale } from "@/lib/i18n/config"; * chain the JSON message bundles use. When DE/EN-only fallbacks get * replaced with proper FR/ES/IT/PL bodies, expand `getLocalizedText` * to read the matching argument. + * + * v1.21.0 (coach C1 HIGH-1/2) — these surfaces used to emit static + * clinical tips with ZERO reference to the user's own numbers, and the + * timeout path rendered them as if they were a fresh AI assessment. + * Each `getNoKey*StatusText` now accepts the per-metric `MetricSignal` + * the card already built and, when present, composes a SIGNAL-GROUNDED + * deterministic line: it names the user's current value, places it + * against their own baseline, and ends with ONE plain-language pointer + * — the same warm voice the LLM surfaces use, without an LLM call. The + * generic best-practice tip stays only as the honest no-signal floor + * (no fabricated numbers when a metric has no usable history). */ export type InsightLocale = Locale; @@ -18,7 +30,212 @@ function getLocalizedText( return locale === "de" ? de : en; } +// ── signal-grounded composer ──────────────────────────────────────────────── + +/** A natural-language metric label + the pointer phrasing for one metric. */ +interface FallbackCopy { + /** Possessive natural-language label ("your blood pressure"). */ + label: { de: string; en: string }; + /** Unit suffix appended to a value (" bpm"), empty when none. */ + unit?: string; + /** Digits to round the rendered value to (0 for integers). */ + digits?: number; + /** + * One grounded pointer used when the value sits outside the user's own + * usual swing — framed as an opportunity, never an order. + */ + pointer: { de: string; en: string }; +} + +function fmtValue(value: number, copy: FallbackCopy): string { + const digits = copy.digits ?? 0; + // Round to the metric's precision, but never render a trailing ".0" — an + // integer reading (steps, a whole-number BMI day) should read as "4200", + // not "4200.0", while a genuine fractional value keeps its decimal. + const factor = 10 ** digits; + const rounded = Math.round(value * factor) / factor; + const text = Number.isInteger(rounded) + ? String(rounded) + : rounded.toFixed(digits); + return copy.unit ? `${text}${copy.unit}` : text; +} + +function capitalise(s: string): string { + return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1); +} + +/** + * Compose a warm, grounded deterministic line from a metric signal. Names + * the current value, places it against the user's own baseline, and ends + * with one pointer (when the value is outside their usual swing) or an + * honest "nothing to act on" (when it sits in range). Returns null when the + * signal carries no current value — the caller then uses the generic tip. + */ +function composeGroundedFallback( + signal: MetricSignal | null | undefined, + copy: FallbackCopy, + locale: InsightLocale, +): string | null { + if (!signal || !Number.isFinite(signal.current)) return null; + + const label = locale === "de" ? copy.label.de : copy.label.en; + const current = fmtValue(signal.current, copy); + + const sentences: string[] = []; + + // 1) Lead with the current value, placed against the user's own baseline. + if ( + signal.baseline !== null && + signal.delta !== null && + signal.delta !== 0 && + Number.isFinite(signal.baseline) + ) { + const baseline = fmtValue(signal.baseline, copy); + const higher = signal.delta > 0; + sentences.push( + locale === "de" + ? `${capitalise(label)} liegt aktuell bei ${current} — ${ + higher ? "höher" : "niedriger" + } als dein üblicher Schnitt von ${baseline}.` + : `${capitalise(label)} is at ${current} right now — ${ + higher ? "higher" : "lower" + } than your usual average of ${baseline}.`, + ); + } else if (signal.baseline !== null && Number.isFinite(signal.baseline)) { + sentences.push( + locale === "de" + ? `${capitalise(label)} liegt aktuell bei ${current}, im Bereich deines üblichen Schnitts.` + : `${capitalise(label)} is at ${current}, right around your usual average.`, + ); + } else { + // No baseline yet — name the value honestly without inventing a comparison. + sentences.push( + locale === "de" + ? `${capitalise(label)} liegt aktuell bei ${current}.` + : `${capitalise(label)} is at ${current} right now.`, + ); + } + + // 2) One grounded pointer when outside the usual swing; otherwise affirm. + if (signal.outsideNormalSwing === true) { + sentences.push(locale === "de" ? copy.pointer.de : copy.pointer.en); + } else if (signal.outsideNormalSwing === false) { + sentences.push( + locale === "de" + ? "Das ist in deinem gewohnten Rahmen — nichts, worauf du jetzt reagieren musst." + : "That sits in your usual range — nothing you need to act on right now.", + ); + } else { + // Unknown swing (no baseline): keep one steady, non-alarming pointer. + sentences.push( + locale === "de" + ? "Ein paar Messungen mehr machen die Tendenz belastbar — beobachte sie über die nächsten Tage." + : "A few more readings will make the trend dependable — keep an eye on it over the coming days.", + ); + } + + return sentences.join(" "); +} + +// ── per-metric copy ───────────────────────────────────────────────────────── + +const BLOOD_PRESSURE_COPY: FallbackCopy = { + label: { de: "dein Blutdruck", en: "your blood pressure" }, + unit: "", + digits: 0, + pointer: { + de: "Ein paar ruhige Messungen unter gleichen Bedingungen zeigen, ob das die Tendenz ist oder ein Ausreißer.", + en: "A few calm readings under the same conditions will show whether this is the trend or a single outlier.", + }, +}; + +const WEIGHT_COPY: FallbackCopy = { + label: { de: "dein Gewicht", en: "your weight" }, + unit: "", + digits: 1, + pointer: { + de: "Wäge dich ein paar Tage zur gleichen Zeit, dann ordnet sich die normale Schwankung von selbst ein.", + en: "Weigh in at the same time for a few days and the normal day-to-day swing sorts itself out.", + }, +}; + +const PULSE_COPY: FallbackCopy = { + label: { de: "dein Ruhepuls", en: "your resting pulse" }, + unit: " bpm", + digits: 0, + pointer: { + de: "Miss ihn entspannt und zur gleichen Tageszeit, dann siehst du, ob die Richtung anhält.", + en: "Take it relaxed and at the same time of day to see whether the direction holds.", + }, +}; + +const BMI_COPY: FallbackCopy = { + label: { de: "dein BMI", en: "your BMI" }, + unit: "", + digits: 1, + pointer: { + de: "Schau ihn dir zusammen mit Gewichtstrend und Körperfett über ein paar Wochen an.", + en: "Read it alongside your weight trend and body-fat over a few weeks rather than day by day.", + }, +}; + +const MOOD_COPY: FallbackCopy = { + label: { de: "deine Stimmung", en: "your mood" }, + unit: "", + digits: 1, + pointer: { + de: "Halte sie ein paar Tage fest — wiederkehrende Muster sagen mehr als ein einzelner Tag.", + en: "Log it for a few days — a recurring pattern says more than any single day.", + }, +}; + +const ADHERENCE_COPY: FallbackCopy = { + label: { de: "deine Einnahmetreue", en: "your medication adherence" }, + unit: "%", + digits: 0, + pointer: { + de: "Eine feste Zeit oder ein kurzer Reminder fängt die wiederkehrenden Auslassungen am ehesten ab.", + en: "A fixed time or a quick reminder is the most reliable way to catch the repeat misses.", + }, +}; + +// ── public fallbacks ──────────────────────────────────────────────────────── + +/** + * Generic single-metric fallback for the dynamic per-metric card + * (`metric-status.ts`), which carries a fully-formed signal but no + * per-metric copy table. Grounds against the signal's OWN natural-language + * label + unit; on a missing signal it degrades to the general tip. + */ +export function getNoKeyMetricStatusText( + locale: InsightLocale, + signal?: MetricSignal | null, +): string { + const grounded = + signal && Number.isFinite(signal.current) + ? composeGroundedFallback( + signal, + { + label: { + de: signal.metric, + en: signal.metric, + }, + ...(signal.unit ? { unit: ` ${signal.unit}` } : {}), + digits: 1, + pointer: { + de: "Ein paar konsistente Messungen zeigen, ob das die Richtung ist oder ein einzelner Tag.", + en: "A few consistent readings will show whether this is the direction or a single day.", + }, + }, + locale, + ) + : null; + return grounded ?? getNoKeyGeneralStatusText(locale); +} + export function getNoKeyGeneralStatusText(locale: InsightLocale): string { + // The overview spans many metrics with no single headline value to ground + // against, so it keeps the honest, generic multi-metric pointer. return getLocalizedText( locale, "Beobachte Entwicklungen über mehrere Wochen statt einzelne Tageswerte isoliert zu bewerten. Achte auf konsistente Messzeitpunkte, damit Trends belastbar vergleichbar bleiben. Reagiere früh, wenn sich mehrere Kennzahlen gleichzeitig in eine ungünstige Richtung bewegen.", @@ -26,52 +243,86 @@ export function getNoKeyGeneralStatusText(locale: InsightLocale): string { ); } -export function getNoKeyBloodPressureStatusText(locale: InsightLocale): string { - return getLocalizedText( - locale, - "Miss den Blutdruck möglichst in Ruhe und unter vergleichbaren Bedingungen. Entscheidend ist die Tendenz über mehrere Tage, nicht ein einzelner Ausreißer. Beurteile systolische und diastolische Werte immer gemeinsam im zeitlichen Verlauf.", - "Measure blood pressure at rest and under comparable conditions whenever possible. The multi-day trend matters more than a single outlier. Always evaluate systolic and diastolic values together over time.", +export function getNoKeyBloodPressureStatusText( + locale: InsightLocale, + signal?: MetricSignal | null, +): string { + return ( + composeGroundedFallback(signal, BLOOD_PRESSURE_COPY, locale) ?? + getLocalizedText( + locale, + "Miss den Blutdruck möglichst in Ruhe und unter vergleichbaren Bedingungen. Entscheidend ist die Tendenz über mehrere Tage, nicht ein einzelner Ausreißer. Beurteile systolische und diastolische Werte immer gemeinsam im zeitlichen Verlauf.", + "Measure blood pressure at rest and under comparable conditions whenever possible. The multi-day trend matters more than a single outlier. Always evaluate systolic and diastolic values together over time.", + ) ); } -export function getNoKeyWeightStatusText(locale: InsightLocale): string { - return getLocalizedText( - locale, - "Bewerte Gewicht vor allem im Verlauf und nicht anhand einzelner Tage. Nutze möglichst konstante Messbedingungen, um normale Schwankungen besser einzuordnen. Wichtig ist die langfristige Richtung im Zusammenspiel mit Blutdruck und BMI.", - "Evaluate weight mainly as a trend rather than by isolated daily readings. Use consistent measurement conditions to interpret normal fluctuations more reliably. What matters most is the long-term direction together with blood pressure and BMI.", +export function getNoKeyWeightStatusText( + locale: InsightLocale, + signal?: MetricSignal | null, +): string { + return ( + composeGroundedFallback(signal, WEIGHT_COPY, locale) ?? + getLocalizedText( + locale, + "Bewerte Gewicht vor allem im Verlauf und nicht anhand einzelner Tage. Nutze möglichst konstante Messbedingungen, um normale Schwankungen besser einzuordnen. Wichtig ist die langfristige Richtung im Zusammenspiel mit Blutdruck und BMI.", + "Evaluate weight mainly as a trend rather than by isolated daily readings. Use consistent measurement conditions to interpret normal fluctuations more reliably. What matters most is the long-term direction together with blood pressure and BMI.", + ) ); } -export function getNoKeyPulseStatusText(locale: InsightLocale): string { - return getLocalizedText( - locale, - "Miss den Ruhepuls in einer entspannten Situation und möglichst zur gleichen Tageszeit. Kurzfristige Ausschläge sind normal, wichtiger ist die Entwicklung über mehrere Tage. Achte auf wiederkehrende Abweichungen vom persönlichen Zielbereich.", - "Measure resting pulse in a relaxed state and ideally at the same time of day. Short-term spikes are normal, while the multi-day pattern is more important. Watch for repeated deviations from your personal target range.", +export function getNoKeyPulseStatusText( + locale: InsightLocale, + signal?: MetricSignal | null, +): string { + return ( + composeGroundedFallback(signal, PULSE_COPY, locale) ?? + getLocalizedText( + locale, + "Miss den Ruhepuls in einer entspannten Situation und möglichst zur gleichen Tageszeit. Kurzfristige Ausschläge sind normal, wichtiger ist die Entwicklung über mehrere Tage. Achte auf wiederkehrende Abweichungen vom persönlichen Zielbereich.", + "Measure resting pulse in a relaxed state and ideally at the same time of day. Short-term spikes are normal, while the multi-day pattern is more important. Watch for repeated deviations from your personal target range.", + ) ); } -export function getNoKeyBmiStatusText(locale: InsightLocale): string { - return getLocalizedText( - locale, - "Der BMI ist eine Orientierungsgröße und sollte immer zusammen mit Gewichtstrend und Körperfett betrachtet werden. Einzelwerte sind weniger wichtig als die Entwicklung über Wochen. Aussagekräftig sind vor allem stabile Verbesserungen oder dauerhafte Abweichungen.", - "BMI is a directional metric and should always be viewed together with weight trend and body-fat context. Single values are less important than changes across weeks. The most meaningful signals are sustained improvements or persistent deviations.", +export function getNoKeyBmiStatusText( + locale: InsightLocale, + signal?: MetricSignal | null, +): string { + return ( + composeGroundedFallback(signal, BMI_COPY, locale) ?? + getLocalizedText( + locale, + "Der BMI ist eine Orientierungsgröße und sollte immer zusammen mit Gewichtstrend und Körperfett betrachtet werden. Einzelwerte sind weniger wichtig als die Entwicklung über Wochen. Aussagekräftig sind vor allem stabile Verbesserungen oder dauerhafte Abweichungen.", + "BMI is a directional metric and should always be viewed together with weight trend and body-fat context. Single values are less important than changes across weeks. The most meaningful signals are sustained improvements or persistent deviations.", + ) ); } export function getNoKeyMedicationComplianceStatusText( locale: InsightLocale, + signal?: MetricSignal | null, ): string { - return getLocalizedText( - locale, - "Konstanz bei der Einnahme ist wichtiger als einzelne perfekte Tage. Beurteile die Treue pro Medikament und zusätzlich im Gesamtbild über mehrere Wochen. Achte besonders auf wiederkehrende Auslassungen und stabilisiere dafür feste Zeitfenster-Routinen.", - "Consistency in intake matters more than isolated perfect days. Evaluate adherence per medication and also in the overall multi-week picture. Pay special attention to repeated misses and stabilize fixed time-window routines.", + return ( + composeGroundedFallback(signal, ADHERENCE_COPY, locale) ?? + getLocalizedText( + locale, + "Konstanz bei der Einnahme ist wichtiger als einzelne perfekte Tage. Beurteile die Treue pro Medikament und zusätzlich im Gesamtbild über mehrere Wochen. Achte besonders auf wiederkehrende Auslassungen und stabilisiere dafür feste Zeitfenster-Routinen.", + "Consistency in intake matters more than isolated perfect days. Evaluate adherence per medication and also in the overall multi-week picture. Pay special attention to repeated misses and stabilize fixed time-window routines.", + ) ); } -export function getNoKeyMoodStatusText(locale: InsightLocale): string { - return getLocalizedText( - locale, - "Bewerte die Stimmung im Verlauf über mehrere Wochen statt einzelne Tage isoliert zu betrachten. Achte auf wiederkehrende Muster und Zusammenhänge mit anderen Gesundheitswerten. Anhaltende Phasen niedriger Stimmung verdienen besondere Aufmerksamkeit.", - "Evaluate mood trends over several weeks rather than isolated daily readings. Watch for recurring patterns and correlations with other health metrics. Sustained periods of low mood deserve special attention.", +export function getNoKeyMoodStatusText( + locale: InsightLocale, + signal?: MetricSignal | null, +): string { + return ( + composeGroundedFallback(signal, MOOD_COPY, locale) ?? + getLocalizedText( + locale, + "Bewerte die Stimmung im Verlauf über mehrere Wochen statt einzelne Tage isoliert zu betrachten. Achte auf wiederkehrende Muster und Zusammenhänge mit anderen Gesundheitswerten. Anhaltende Phasen niedriger Stimmung verdienen besondere Aufmerksamkeit.", + "Evaluate mood trends over several weeks rather than isolated daily readings. Watch for recurring patterns and correlations with other health metrics. Sustained periods of low mood deserve special attention.", + ) ); } diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index c297be593..a9d423bf0 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -461,7 +461,7 @@ export async function preparePulseStatusForUser( temperature: 0.45, noProvider: { hasProvider: false, - text: getNoKeyPulseStatusText(locale), + text: getNoKeyPulseStatusText(locale, pulseSignal), cached: true, updatedAt: null, }, @@ -471,7 +471,7 @@ export async function preparePulseStatusForUser( reason, userId, todayKey, - stubText: getNoKeyPulseStatusText(locale), + stubText: getNoKeyPulseStatusText(locale, pulseSignal), }), finalize: async (outcome): Promise => { const summary = normalizeSummaryText( diff --git a/src/lib/insights/timeout-fallback.ts b/src/lib/insights/timeout-fallback.ts index fddb4c547..8a282dd18 100644 --- a/src/lib/insights/timeout-fallback.ts +++ b/src/lib/insights/timeout-fallback.ts @@ -67,6 +67,14 @@ export async function persistTimeoutNegativeStub(args: { * signal "no persisted assessment". When `userId` + `todayKey` are present * (the worker-side generation path) a short-TTL negative stub is persisted * so a stalled provider doesn't trigger a re-enqueue storm. + * + * v1.21.0 (coach C1 HIGH-1) — the envelope now reports `hasProvider:false`. + * The `stubText` is a DETERMINISTIC (signal-grounded or generic) line, not a + * fresh provider assessment; reporting `hasProvider:true` let the UI render + * it in the primary assessment slot indistinguishable from real AI prose on + * any slow/errored call. `false` lets the UI surface it honestly as the + * computed/offline summary it is. This is independent of whether the user + * has a provider configured at all — it describes THIS text's authorship. */ export function returnTimeoutFallback(input: { cacheAction: string; @@ -75,7 +83,7 @@ export function returnTimeoutFallback(input: { userId?: string; todayKey?: string; }): { - hasProvider: true; + hasProvider: false; text: string; cached: true; updatedAt: null; @@ -94,7 +102,7 @@ export function returnTimeoutFallback(input: { }); } return { - hasProvider: true, + hasProvider: false, text: input.stubText, cached: true, updatedAt: null, diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 8e734881a..795d522af 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -604,7 +604,7 @@ export async function prepareWeightStatusForUser( temperature: 0.45, noProvider: { hasProvider: false, - text: getNoKeyWeightStatusText(locale), + text: getNoKeyWeightStatusText(locale, weightSignal), cached: true, updatedAt: null, }, @@ -614,7 +614,7 @@ export async function prepareWeightStatusForUser( reason, userId, todayKey, - stubText: getNoKeyWeightStatusText(locale), + stubText: getNoKeyWeightStatusText(locale, weightSignal), }), finalize: async (outcome): Promise => { const summary = normalizeSummaryText( From 5b406d58cdd6901496dcb5241a3de7fdac0269ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:29:35 +0200 Subject: [PATCH 11/51] fix(insights): close the deterministic score assessment with one grounded step The per-score deterministic text named the score, its standing, and the weakest contributor, then stopped at the diagnosis. When the band is not green and the weakest driver is behaviourally addressable (sleep, mood, consistency, timing), append a single grounded pointer drawn from that same contributor. Physiology-only drivers add nothing, so the text affirms and watches rather than manufacturing a step. --- .../__tests__/derived-assessment.test.ts | 35 ++++++++++++++++ .../insights/derived/derived-assessment.ts | 40 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/lib/insights/derived/__tests__/derived-assessment.test.ts b/src/lib/insights/derived/__tests__/derived-assessment.test.ts index dde32e8fc..1fa60c195 100644 --- a/src/lib/insights/derived/__tests__/derived-assessment.test.ts +++ b/src/lib/insights/derived/__tests__/derived-assessment.test.ts @@ -158,6 +158,41 @@ describe("resolveDeterministicAssessment", () => { expect(a!.text).toContain("Schlafmenge"); }); + it("appends a grounded next step when the weakest contributor is addressable (en)", () => { + // sleep (30) is the lowest present contributor → a behaviourally + // addressable driver, so the assessment closes with a doable pointer. + const sleepWeak: ReadinessValue = { + score: 58, + band: "yellow", + components: [ + { key: "rhr", value: 80, weight: 0.3 }, + { key: "hrv", value: 75, weight: 0.3 }, + { key: "sleep", value: 30, weight: 0.25 }, + { key: "mood", value: 70, weight: 0.15 }, + ], + }; + const a = resolveDeterministicAssessment( + "READINESS", + okDerived(sleepWeak), + "en", + NOW, + ); + expect(a!.text).toContain("Held back most by"); + expect(a!.text).toContain("An earlier night would lift this most."); + }); + + it("does NOT manufacture a step when the weakest contributor is physiology-only (en)", () => { + // rhr (40) is the lowest in the base readiness fixture → no pointer. + const a = resolveDeterministicAssessment( + "READINESS", + okDerived(readiness), + "en", + NOW, + ); + expect(a!.text).not.toContain("An earlier night"); + expect(a!.text).not.toContain("most effective lever"); + }); + it("uses the trend for a contributor-less recovery score (en)", () => { const a = resolveDeterministicAssessment( "RECOVERY_SCORE", diff --git a/src/lib/insights/derived/derived-assessment.ts b/src/lib/insights/derived/derived-assessment.ts index f7926008d..0791f9d22 100644 --- a/src/lib/insights/derived/derived-assessment.ts +++ b/src/lib/insights/derived/derived-assessment.ts @@ -247,6 +247,41 @@ function contributorLabel( return key; } +/** + * v1.21.0 (coach C1 MEDIUM-2) — a single grounded next-step pointer keyed to + * the weakest BEHAVIOURALLY ADDRESSABLE contributor. Only the contributors a + * person can actually move (sleep, mood, consistency, timing) carry a + * pointer; physiology-only contributors (rhr, hrv, respiratory) return null, + * so the assessment affirms-and-watches rather than manufacturing a step — + * matching the `base-system.ts` "do NOT manufacture a step" rule. + */ +const CONTRIBUTOR_POINTERS: Record = { + sleep: { + de: "Eine etwas frühere Nacht würde das am ehesten anheben.", + en: "An earlier night would lift this most.", + }, + sufficiency: { + de: "Etwas mehr Schlafzeit ist hier der wirksamste Hebel.", + en: "A little more time asleep is the most effective lever here.", + }, + consistency: { + de: "Gleichmäßigere Schlafzeiten über die Woche helfen am meisten.", + en: "More even sleep and wake times across the week help most.", + }, + timing: { + de: "Ein gleichmäßigerer Rhythmus zieht das am ehesten nach oben.", + en: "A steadier sleep rhythm is the most likely thing to pull this up.", + }, + mood: { + de: "Ein kurzer Moment für etwas, das dir guttut, kann hier spürbar helfen.", + en: "A small moment for something that does you good can noticeably help here.", + }, +}; + +function contributorPointer(key: string, locale: Locale): string | null { + return CONTRIBUTOR_POINTERS[key]?.[locale] ?? null; +} + /** * Compose the deterministic per-score assessment from the score's signal + * its contributors. Always returns a non-empty, factual, non-causal text. @@ -294,6 +329,11 @@ export function buildDeterministicScoreAssessment( ? `Am stärksten gedämpft durch ${joined}.` : `Held back most by ${joined}.`, ); + // v1.21.0 (MEDIUM-2) — close with ONE grounded next step drawn from the + // weakest contributor, but only when it is behaviourally addressable. + // When the weakest driver is physiology-only, no step is manufactured. + const pointer = contributorPointer(ranked[0].key, locale); + if (pointer) sentences.push(pointer); } } else if (signal.delta !== null && signal.delta !== 0) { // No contributor breakdown (recovery/stress/strain): use the trend. From d22372e800f42db708665f2cedf42062b3131723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:29:43 +0200 Subject: [PATCH 12/51] fix(insights): warm the period retrospective to the shared house voice The retrospective narrative was the one model surface that omitted the shared tone contract, so it read colder than the daily briefing beside it. Compose the tone fragment into both prompts and warm the hand-written tone line to name a genuine win when the period earns it, while keeping the descriptive-never-causal and no-alarm guards intact. The cross-surface coverage test now asserts the contract reaches this surface too. --- .../__tests__/shared-contracts-coverage.test.ts | 13 ++++++++++--- .../__tests__/period-narrative-generate.test.ts | 4 ++++ .../insights/narrative/period-narrative-generate.ts | 8 ++++---- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts b/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts index e2c790a07..d9fc0f09a 100644 --- a/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts +++ b/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts @@ -67,11 +67,18 @@ const SURFACES: Record< forbiddenFiller, ], }, - // The narrative is descriptive-only, so it enforces every contract EXCEPT - // the motivating tone contract. + // v1.21.0 (coach C1 MEDIUM-1) — the retrospective narrative now composes the + // shared tone contract too, so it matches the warm house voice of the daily + // briefing beside it. Its own descriptive-never-causal guards stay intact. "period-narrative": { prompt: (l) => SYSTEM_PROMPTS_FOR_TEST[l], - contracts: [grounding, safetyGlp1, metricIdentifierBan, forbiddenFiller], + contracts: [ + grounding, + toneContract, + safetyGlp1, + metricIdentifierBan, + forbiddenFiller, + ], }, }; diff --git a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts index 1ca917298..fa99fc7b3 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts @@ -25,6 +25,7 @@ import type { PeriodNarrativeContext, PeriodNarrativeResult, } from "@/lib/insights/narrative/period-narrative"; +import { toneContract } from "@/lib/ai/prompts/shared-contracts"; function readyContext( over: Partial = {}, @@ -170,6 +171,9 @@ describe("generatePeriodNarrative — descriptive generation", () => { }); expect(systemPrompt).toMatch(/never CAUSAL|DESCRIPTIVE/); expect(systemPrompt).toMatch(/no markdown/i); + // v1.21.0 (coach C1 MEDIUM-1) — the retrospective now carries the shared + // warm tone contract verbatim, matching the briefing's house voice. + expect(systemPrompt).toContain(toneContract.en); }); }); diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index b597a5511..114855493 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -104,11 +104,11 @@ Hard rules: - The supplied CONTEXT is the ONLY source of truth. Never state a number, trend, driver, or threshold that is not in it. - Be DESCRIPTIVE, never CAUSAL. Say "X moved with Y" or "X was associated with Y", never "X caused Y" or "because of X". - The listed drivers already survived statistical multiple-comparison control; restate them only as associations and keep their conservative meaning. -- No diagnosis, no medical advice, no alarm. Calm, factual, second person ("your"). +- No diagnosis, no medical advice, no alarm. Calm, factual, second person ("your"). When the period earned it, name one genuine win plainly; keep it warm and second-person, never a reflexive compliment. - 2 to 4 short sentences. Plain text only — no markdown, no headings, no bullet points, no emojis. - If the context is thin, say plainly that there is little to report this period rather than inventing detail. -${composeSharedContracts("en", ["grounding", "safetyGlp1", "metricIdentifierBan", "forbiddenFiller"])}`; +${composeSharedContracts("en", ["toneContract", "grounding", "safetyGlp1", "metricIdentifierBan", "forbiddenFiller"])}`; const SYSTEM_PROMPT_DE = `Du fasst den Gesundheits-Tracking-ZEITRAUM einer Person (eine Woche oder einen Monat) für diese Person zusammen. Prompt-Version: ${NARRATIVE_PROMPT_VERSION}. @@ -117,11 +117,11 @@ Feste Regeln: - Der bereitgestellte KONTEXT ist die EINZIGE Wahrheitsquelle. Nenne nie eine Zahl, einen Trend, einen Zusammenhang oder einen Schwellenwert, der nicht darin steht. - Sei BESCHREIBEND, nie URSÄCHLICH. Sage "X bewegte sich mit Y" oder "X war mit Y assoziiert", nie "X verursachte Y" oder "wegen X". - Die genannten Zusammenhänge haben bereits die statistische Mehrfachvergleichskorrektur überstanden; gib sie nur als Assoziationen wieder und bewahre ihre vorsichtige Bedeutung. -- Keine Diagnose, kein medizinischer Rat, keine Panik. Ruhig, sachlich, in der zweiten Person ("dein"). +- Keine Diagnose, kein medizinischer Rat, keine Panik. Ruhig, sachlich, in der zweiten Person ("dein"). Wenn der Zeitraum es hergibt, benenne einen echten Erfolg klar; bleib warm und in der zweiten Person, nie ein reflexhaftes Kompliment. - 2 bis 4 kurze Sätze. Nur Klartext — kein Markdown, keine Überschriften, keine Aufzählungen, keine Emojis. - Wenn der Kontext dünn ist, sage klar, dass es in diesem Zeitraum wenig zu berichten gibt, statt Details zu erfinden. -${composeSharedContracts("de", ["grounding", "safetyGlp1", "metricIdentifierBan", "forbiddenFiller"])}`; +${composeSharedContracts("de", ["toneContract", "grounding", "safetyGlp1", "metricIdentifierBan", "forbiddenFiller"])}`; /** * Test-only view of the composed system prompts (incl. the appended shared From 6de1f34efbdc3cb3165e6a6b61ac752c33417711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:35:57 +0200 Subject: [PATCH 13/51] feat(coach): widen tool-mode data reach to every stored domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retrieval-tool DATA INVENTORY was built against the user's default narration cluster (cardio/body/mood/medication), so sleep, glucose, activity, body composition, vascular, SpO2, gait and environment series reported absent even when the data existed — and the grounding rule then told the model not to fetch them. Probe presence against the full source set instead, and generate the metric-series inventory rows from the complete source-to-section map so every series the user has rows for is advertised. Per-tool reads still re-scope to their own domain, so the wider probe never widens a figure read. Add three retrieval tools whose snapshot blocks already existed but were unreachable in tool mode: - get_workouts: recent sessions + per-sport rollup - get_cycle: phase / prediction / correlation (gated by cycle access) - get_correlations: the FDR-controlled lagged cross-metric drivers plus the coincident-deviation flag, surfaced descriptively Also honour the caller's window in get_labs and get_illness_recovery so a cross-metric answer no longer silently mixes horizons. --- .../coach/__tests__/correlations-read.test.ts | 146 +++++++++++ .../ai/coach/__tests__/tools-executor.test.ts | 104 ++++++++ .../coach/__tests__/tools-inventory.test.ts | 44 ++++ src/lib/ai/coach/tools/correlations-read.ts | 233 ++++++++++++++++++ src/lib/ai/coach/tools/definitions.ts | 60 ++++- src/lib/ai/coach/tools/executor.ts | 93 ++++++- src/lib/ai/coach/tools/inventory.ts | 120 +++++---- src/lib/ai/coach/tools/source-keys.ts | 80 +++++- 8 files changed, 821 insertions(+), 59 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/correlations-read.test.ts create mode 100644 src/lib/ai/coach/tools/correlations-read.ts diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts new file mode 100644 index 000000000..b6e813f83 --- /dev/null +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -0,0 +1,146 @@ +/** + * v1.21.0 (C3) — the Coach correlations reader surfaces the deterministic FDR + * discovery + coincident-deviation flag, returns descriptive driver rows, and + * degrades to a clean { present: false } when nothing survives or a read fails. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const measurementFindMany = vi.fn(); +const moodFindMany = vi.fn(); +const userFindUnique = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: (a: unknown) => measurementFindMany(a) }, + moodEntry: { findMany: (a: unknown) => moodFindMany(a) }, + user: { findUnique: (a: unknown) => userFindUnique(a) }, + }, +})); + +const loadBaselineProfile = vi.fn(); +const computeCoincidentDeviation = vi.fn(); +vi.mock("@/lib/insights/derived", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadBaselineProfile: () => loadBaselineProfile(), + computeCoincidentDeviation: () => computeCoincidentDeviation(), + }; +}); + +const discoverCorrelations = vi.fn(); +vi.mock("@/lib/insights/correlation-discovery", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/lib/insights/correlation-discovery") + >(); + return { ...actual, discoverCorrelations: () => discoverCorrelations() }; +}); + +import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; + +describe("readCoachCorrelations", () => { + beforeEach(() => { + measurementFindMany.mockReset().mockResolvedValue([]); + moodFindMany.mockReset().mockResolvedValue([]); + userFindUnique.mockReset().mockResolvedValue({ timezone: "Europe/Berlin" }); + loadBaselineProfile + .mockReset() + .mockResolvedValue({ ageYears: 40, sex: "MALE", heightCm: 180 }); + computeCoincidentDeviation.mockReset().mockResolvedValue(null); + discoverCorrelations.mockReset(); + }); + + it("returns descriptive drivers when pairs survive", async () => { + discoverCorrelations.mockReturnValue({ + discovered: [ + { + behaviour: "TIME_IN_DAYLIGHT", + outcome: "SLEEP_DURATION", + n: 42, + r: 0.34, + pValue: 0.001, + qValue: 0.02, + interpretation: + "Higher time in daylight tends to go with higher next-day sleep duration in your data — a pattern worth watching, not a cause.", + lagDays: 1, + }, + ], + pairsTested: 18, + fdrQ: 0.1, + minPairs: 20, + }); + const result = await readCoachCorrelations("u1"); + expect(result.present).toBe(true); + expect(result.drivers).toHaveLength(1); + expect(result.drivers?.[0]).toMatchObject({ + behaviour: "time in daylight", + outcome: "sleep duration", + direction: "higher", + lagDays: 1, + n: 42, + }); + expect(result.pairsTested).toBe(18); + }); + + it("returns present:false when nothing survives and no coincident flag", async () => { + discoverCorrelations.mockReturnValue({ + discovered: [], + pairsTested: 4, + fdrQ: 0.1, + minPairs: 20, + }); + const result = await readCoachCorrelations("u1"); + expect(result.present).toBe(false); + expect(result.reason).toBe("no_significant_pattern"); + }); + + it("surfaces a fired coincident flag even without drivers", async () => { + discoverCorrelations.mockReturnValue({ + discovered: [], + pairsTested: 4, + fdrQ: 0.1, + minPairs: 20, + }); + computeCoincidentDeviation.mockResolvedValue({ + status: "ok", + value: { + fired: true, + vitals: [], + contributing: [ + { type: "RESTING_HEART_RATE", direction: "above" }, + { type: "HEART_RATE_VARIABILITY", direction: "below" }, + ], + day: "2026-06-02", + illnessExplained: false, + }, + coverage: { + requiredInputs: 2, + presentInputs: 2, + historyDays: 30, + missing: [], + }, + confidence: { score: 80, band: "high" }, + provenance: { + inputs: [], + source: "DAY", + windowDays: 30, + computedAt: "x", + }, + }); + const result = await readCoachCorrelations("u1"); + expect(result.present).toBe(true); + expect(result.coincident?.fired).toBe(true); + expect(result.coincident?.contributing).toEqual([ + { metric: "resting heart rate", direction: "above" }, + { metric: "heart rate variability", direction: "below" }, + ]); + }); + + it("degrades to present:false on a read failure", async () => { + loadBaselineProfile.mockRejectedValue(new Error("db down")); + const result = await readCoachCorrelations("u1"); + expect(result.present).toBe(false); + expect(result.reason).toBe("retrieval_failed"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-executor.test.ts b/src/lib/ai/coach/__tests__/tools-executor.test.ts index 9b7e96c87..e4edce158 100644 --- a/src/lib/ai/coach/__tests__/tools-executor.test.ts +++ b/src/lib/ai/coach/__tests__/tools-executor.test.ts @@ -16,6 +16,11 @@ vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot(userId, scope), })); +const readCoachCorrelations = vi.fn(); +vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ + readCoachCorrelations: (userId: string) => readCoachCorrelations(userId), +})); + import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; function snapshot( @@ -33,6 +38,7 @@ function snapshot( describe("executeCoachTool", () => { beforeEach(() => { buildCoachSnapshot.mockReset(); + readCoachCorrelations.mockReset(); }); it("returns the matching section for get_metric_series when present", async () => { @@ -182,6 +188,104 @@ describe("executeCoachTool", () => { expect(miss.reason).toBe("analyte_not_found"); }); + it("returns the workouts section for get_workouts when present", async () => { + buildCoachSnapshot.mockResolvedValue( + snapshot({ workouts: { recent: [{ sport: "RUN" }], totalInWindow: 3 } }), + ); + const result = await executeCoachTool({ + userId: "u1", + name: "get_workouts", + rawArguments: JSON.stringify({ window: "last30days" }), + }); + expect(result.present).toBe(true); + expect(result.data).toMatchObject({ totalInWindow: 3 }); + }); + + it("returns { present: false } for get_workouts when no block", async () => { + buildCoachSnapshot.mockResolvedValue(snapshot({})); + const result = await executeCoachTool({ + userId: "u1", + name: "get_workouts", + rawArguments: "{}", + }); + expect(result.present).toBe(false); + expect(result.reason).toBe("no_data"); + }); + + it("returns the cycle section for get_cycle when present", async () => { + buildCoachSnapshot.mockResolvedValue( + snapshot({ cycle: { phase: "luteal", dayOfCycle: 21 } }), + ); + const result = await executeCoachTool({ + userId: "u1", + name: "get_cycle", + rawArguments: "{}", + }); + expect(result.present).toBe(true); + expect(result.data).toMatchObject({ phase: "luteal" }); + }); + + it("returns { present: false } for get_cycle when cycle tracking is off", async () => { + // A non-cycle account produces no cycle block (gated in the builder). + buildCoachSnapshot.mockResolvedValue(snapshot({})); + const result = await executeCoachTool({ + userId: "u1", + name: "get_cycle", + rawArguments: "{}", + }); + expect(result.present).toBe(false); + expect(result.reason).toBe("no_data"); + }); + + it("surfaces discovered drivers + coincident flag for get_correlations", async () => { + readCoachCorrelations.mockResolvedValue({ + present: true, + drivers: [ + { + behaviour: "time in daylight", + outcome: "sleep duration", + direction: "higher", + lagDays: 1, + n: 42, + r: 0.31, + note: "Higher time in daylight tends to go with higher next-day sleep duration in your data — a pattern worth watching, not a cause.", + }, + ], + coincident: { + fired: false, + contributing: [], + day: "2026-06-02", + illnessExplained: false, + }, + pairsTested: 18, + windowDays: 180, + }); + const result = await executeCoachTool({ + userId: "u1", + name: "get_correlations", + rawArguments: "{}", + }); + expect(result.present).toBe(true); + expect(result.data).toMatchObject({ + drivers: [{ behaviour: "time in daylight", n: 42 }], + pairsTested: 18, + }); + }); + + it("returns a clean { present: false } for get_correlations on no pattern", async () => { + readCoachCorrelations.mockResolvedValue({ + present: false, + reason: "no_significant_pattern", + }); + const result = await executeCoachTool({ + userId: "u1", + name: "get_correlations", + rawArguments: "{}", + }); + expect(result.present).toBe(false); + expect(result.reason).toBe("no_significant_pattern"); + }); + it("never passes userId as a tool argument (read from session only)", async () => { buildCoachSnapshot.mockResolvedValue(snapshot({})); // A crafted argument trying to smuggle a userId must be rejected by the diff --git a/src/lib/ai/coach/__tests__/tools-inventory.test.ts b/src/lib/ai/coach/__tests__/tools-inventory.test.ts index 8387dfd08..d2cfa9675 100644 --- a/src/lib/ai/coach/__tests__/tools-inventory.test.ts +++ b/src/lib/ai/coach/__tests__/tools-inventory.test.ts @@ -67,6 +67,50 @@ describe("buildCoachDataInventory", () => { expect(sleep?.present).toBe(false); }); + it("advertises workouts + correlations tools and the cycle tool when enabled", async () => { + buildCoachSnapshot.mockResolvedValue( + snapshot( + { + workouts: { recent: [], totalInWindow: 4 }, + mood: {}, + cycle: { phase: "luteal" }, + scope: { window: "last30days" }, + }, + { workouts: 4 }, + ), + ); + isCycleAvailableForUser.mockResolvedValue(true); + const inv = await buildCoachDataInventory("u1", undefined); + const workouts = inv.entries.find((e) => e.tool === "get_workouts"); + expect(workouts).toMatchObject({ present: true, count: 4 }); + const correlations = inv.entries.find((e) => e.tool === "get_correlations"); + expect(correlations?.present).toBe(true); // mood present → correlatable + const cycle = inv.entries.find((e) => e.tool === "get_cycle"); + expect(cycle).toMatchObject({ present: true }); + }); + + it("omits the cycle line when cycle tracking is unavailable", async () => { + buildCoachSnapshot.mockResolvedValue(snapshot({ bloodPressure: {} })); + isCycleAvailableForUser.mockResolvedValue(false); + const inv = await buildCoachDataInventory("u1", undefined); + expect(inv.entries.find((e) => e.tool === "get_cycle")).toBeUndefined(); + }); + + it("probes a wide source set so synced domains are advertised", async () => { + // body composition + spo2 present even though they are not default clusters. + buildCoachSnapshot.mockResolvedValue( + snapshot( + { bodyFat: {}, oxygenSaturation: {}, scope: { window: "last30days" } }, + { body_fat: 12, spo2: 30 }, + ), + ); + const inv = await buildCoachDataInventory("u1", undefined); + const bodyFat = inv.entries.find((e) => e.metric === "body_fat"); + const spo2 = inv.entries.find((e) => e.metric === "spo2"); + expect(bodyFat).toMatchObject({ present: true, count: 12 }); + expect(spo2).toMatchObject({ present: true, count: 30 }); + }); + it("reports restMode + cycleEnabled", async () => { buildCoachSnapshot.mockResolvedValue( snapshot({ illness: { restMode: true } }), diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts new file mode 100644 index 000000000..1d1424ab3 --- /dev/null +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -0,0 +1,233 @@ +/** + * v1.21.0 (C3) — Coach correlations reader. + * + * Surfaces the deterministic insight engine's cross-metric intelligence to the + * Coach via the `get_correlations` tool. Two read-only sources, both already + * computed elsewhere and reused verbatim here (no new statistics): + * + * - The FDR-controlled, day-D→D+1 lagged all-pairs discovery + * (`@/lib/insights/correlation-discovery`). We run the SAME full-matrix scan + * the `/api/insights/correlations` route + the per-metric card run, so the + * Coach never surfaces a pair the insight pages would not — same Pearson, + * same exact p-value, same Benjamini-Hochberg control. We return every + * surviving pair (not filtered to one metric) as a descriptive driver row. + * - The coincident-deviation flag (`computeCoincidentDeviation`): "two or more + * of your vitals are outside their usual band today", with the illness- + * explained reframe carried through. + * + * Grounding posture mirrors the other tools: a structured `{ present: false }` + * when too little paired data exists for any pattern to survive (or the read + * fails), never a throw and never an ambiguous empty list. The driver rows are + * descriptive — direction + lag + n + the engine's own never-causal + * interpretation string — so the Coach states the observed linkage without + * inventing a relationship. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { + discoverCorrelations, + DISCOVERY_BEHAVIOURS, + DISCOVERY_OUTCOMES, + type DailySeriesPoint, + type NamedSeries, +} from "@/lib/insights/correlation-discovery"; +import { + computeCoincidentDeviation, + loadBaselineProfile, + isDerivedOk, +} from "@/lib/insights/derived"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +/** Trailing window for the discovery scan — mirrors the insight route. */ +const WINDOW_DAYS = 180; + +/** One discovered driver pair, descriptive — never causal. */ +export interface CoachCorrelationDriver { + behaviour: string; + outcome: string; + /** "higher" / "lower" — sign of the next-day association. */ + direction: "higher" | "lower"; + /** Lag in days (always 1 today). */ + lagDays: number; + /** Paired-day sample count after the lag join. */ + n: number; + /** Pearson r, rounded for display. */ + r: number; + /** The engine's conservative, descriptive interpretation. */ + note: string; +} + +/** The coincident-deviation summary the Coach can narrate. */ +export interface CoachCoincidentFlag { + /** True when ≥2 vitals are outside their usual band on the latest day. */ + fired: boolean; + /** The vitals outside their band (possible factors, never a cause). */ + contributing: Array<{ metric: string; direction: "above" | "below" }>; + /** The day the flag was evaluated (YYYY-MM-DD). */ + day: string; + /** True when an active illness episode explains the deviations. */ + illnessExplained: boolean; +} + +export interface CoachCorrelationsResult { + present: boolean; + drivers?: CoachCorrelationDriver[]; + coincident?: CoachCoincidentFlag; + /** How many behaviour×outcome pairs were tested (honest footer). */ + pairsTested?: number; + /** Trailing-day window the discovery scanned. */ + windowDays?: number; + reason?: string; +} + +/** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */ +function tzDayKey(at: Date, tz: string): string { + const { year, month, day } = wallClockInTz(at, tz); + return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +/** Collapse rows to per-day means keyed in the user's tz. */ +function toDailyMeans( + rows: Array<{ value: number; at: Date }>, + tz: string, +): DailySeriesPoint[] { + const byDay = new Map(); + for (const r of rows) { + if (!Number.isFinite(r.value)) continue; + const day = tzDayKey(r.at, tz); + const acc = byDay.get(day) ?? { sum: 0, count: 0 }; + acc.sum += r.value; + acc.count += 1; + byDay.set(day, acc); + } + return [...byDay.entries()] + .map(([day, acc]) => ({ day, value: acc.sum / acc.count })) + .sort((a, b) => (a.day < b.day ? -1 : 1)); +} + +/** Lower-case, space-separated label from a discovery channel key. */ +function humanise(key: string): string { + return key.replace(/_/g, " ").toLowerCase(); +} + +/** + * Build the Coach correlations payload for a user. Returns `{ present: false }` + * when no driver survives AND the coincident flag is not informative, when the + * user has no correlatable data, or on any read/compute failure (best-effort — + * a correlation hiccup must never break the chat turn). + */ +export async function readCoachCorrelations( + userId: string, +): Promise { + try { + const profile = await loadBaselineProfile(prisma, userId); + const userRow = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + const tz = userRow?.timezone ?? "Europe/Berlin"; + const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY); + + // MOOD is mood-entry backed, not a measurement type — strip it from the + // measurement query and source it separately, exactly like the route. + const behaviourTypes = DISCOVERY_BEHAVIOURS.filter( + (k) => k !== "MOOD", + ) as MeasurementType[]; + const outcomeTypes = DISCOVERY_OUTCOMES.filter( + (k) => k !== "MOOD", + ) as MeasurementType[]; + + const [measurements, moodEntries, coincidentDerived] = await Promise.all([ + prisma.measurement.findMany({ + where: { + userId, + deletedAt: null, + measuredAt: { gte: since }, + type: { in: [...behaviourTypes, ...outcomeTypes] }, + }, + orderBy: { measuredAt: "asc" }, + take: 20000, + select: { type: true, value: true, measuredAt: true }, + }), + prisma.moodEntry.findMany({ + where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, + orderBy: { moodLoggedAt: "asc" }, + take: 5000, + select: { score: true, moodLoggedAt: true }, + }), + // Coincident-deviation is its own derived metric — fail-soft to null so a + // baseline hiccup never sinks the whole correlations read. + computeCoincidentDeviation(userId, profile).catch(() => null), + ]); + + const byType = new Map>(); + for (const m of measurements) { + const list = byType.get(m.type) ?? []; + list.push({ value: m.value, at: m.measuredAt }); + byType.set(m.type, list); + } + const moodDaily = toDailyMeans( + moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })), + tz, + ); + + const series: NamedSeries[] = []; + for (const key of DISCOVERY_BEHAVIOURS) { + const points = + key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz); + series.push({ key, role: "behaviour", points }); + } + for (const key of DISCOVERY_OUTCOMES) { + const points = + key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz); + series.push({ key, role: "outcome", points }); + } + + const discovery = discoverCorrelations(series); + const drivers: CoachCorrelationDriver[] = discovery.discovered.map((d) => ({ + behaviour: humanise(d.behaviour), + outcome: humanise(d.outcome), + direction: d.r >= 0 ? "higher" : "lower", + lagDays: d.lagDays, + n: d.n, + r: Math.round(d.r * 100) / 100, + note: d.interpretation, + })); + + const coincident = buildCoincidentFlag(coincidentDerived); + + // Nothing to say: no surviving driver AND the coincident flag is either + // insufficient or quiet (not fired). Report a clean miss. + if (drivers.length === 0 && (!coincident || !coincident.fired)) { + return { present: false, reason: "no_significant_pattern" }; + } + + return { + present: true, + ...(drivers.length > 0 ? { drivers } : {}), + ...(coincident ? { coincident } : {}), + pairsTested: discovery.pairsTested, + windowDays: WINDOW_DAYS, + }; + } catch { + return { present: false, reason: "retrieval_failed" }; + } +} + +/** Shape the derived coincident-deviation value into the Coach summary. */ +function buildCoincidentFlag( + derived: Awaited> | null, +): CoachCoincidentFlag | undefined { + if (!derived || !isDerivedOk(derived)) return undefined; + const v = derived.value; + return { + fired: v.fired, + contributing: v.contributing.map((c) => ({ + metric: humanise(String(c.type)), + direction: c.direction === "above" ? "above" : "below", + })), + day: v.day, + illnessExplained: v.illnessExplained, + }; +} diff --git a/src/lib/ai/coach/tools/definitions.ts b/src/lib/ai/coach/tools/definitions.ts index 4fa65c033..d41caf2f6 100644 --- a/src/lib/ai/coach/tools/definitions.ts +++ b/src/lib/ai/coach/tools/definitions.ts @@ -9,15 +9,17 @@ * narrowed from the session in the executor), and every tool is read-only, so * there is no new mutation or egress surface. * - * Six tools ship in v1.20.0 (the research's recommended slice): + * Tools ship as a closed catalogue: * 1. get_metric_series — BP / weight / pulse + the ~38 additive series * 2. get_glucose_panel — per-context daily means + the 30-day clinical panel * 3. get_sleep — per-night sleep + sleep-rhythm (debt + chronotype) * 4. get_medication_compliance — dose-weighted compliance + GLP-1 * 5. get_labs — latest reading per biomarker (12 months) * 6. get_illness_recovery — restMode + active/resolved illnesses + recovery composites - * - * cycle / correlation / workouts are deferred to a later release. + * 7. get_workouts — recent sessions + per-sport rollup (v1.21.0, C2-4) + * 8. get_cycle — menstrual phase / prediction / correlation (v1.21.0, C2-1) + * 9. get_correlations — discovered FDR cross-metric drivers + the + * coincident-deviation flag (v1.21.0, C3) */ import { z } from "zod/v4"; @@ -35,6 +37,9 @@ export const COACH_TOOL_NAMES = [ "get_medication_compliance", "get_labs", "get_illness_recovery", + "get_workouts", + "get_cycle", + "get_correlations", ] as const; export type CoachToolName = (typeof COACH_TOOL_NAMES)[number]; @@ -83,6 +88,16 @@ export const getLabsArgsSchema = z export const getIllnessRecoveryArgsSchema = z.object({}).strict(); +export const getWorkoutsArgsSchema = z + .object({ + window: coachScopeWindowSchema.optional(), + }) + .strict(); + +export const getCycleArgsSchema = z.object({}).strict(); + +export const getCorrelationsArgsSchema = z.object({}).strict(); + /** * JSON-Schema parameter shapes handed to the provider. Kept hand-written * (rather than generated from Zod) so the wire description the model reads is @@ -205,4 +220,43 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [ properties: {}, }, }, + { + name: "get_workouts", + description: + "Fetch the user's workouts: the most recent sessions (sport, duration, energy, distance, avg/max HR) plus a per-sport rollup over the window. Use for training-load and 'how were my runs / am I overtraining?' questions. Returns { present: false } when no workouts are tracked.", + parameters: { + type: "object", + additionalProperties: false, + required: [], + properties: { + window: { + type: "string", + enum: WINDOW_ENUM, + description: "Analysis window. Defaults to the user's scope window.", + }, + }, + }, + }, + { + name: "get_cycle", + description: + "Fetch the user's menstrual-cycle context: current phase + day-of-cycle, the next predicted event, and the headline phase-correlation finding. Descriptive only — never a contraception-grade or 'safe day' claim. Returns { present: false } when cycle tracking is off or there is no data.", + parameters: { + type: "object", + additionalProperties: false, + required: [], + properties: {}, + }, + }, + { + name: "get_correlations", + description: + "Fetch the user's DISCOVERED cross-metric patterns: statistically-vetted (FDR-controlled) day-to-next-day driver pairs between behaviours (daylight, mood, glucose, blood pressure, steps) and outcomes (sleep, HRV, resting HR, weight, mood), each with direction, lag, sample size and a descriptive — never causal — note. Also reports the coincident-deviation flag (whether two or more vitals are outside their usual band today). Use when a metric is off and you want to state the observed linkage. Returns { present: false } when too little paired data exists for any pattern to survive.", + parameters: { + type: "object", + additionalProperties: false, + required: [], + properties: {}, + }, + }, ]; diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index f9502c7d9..60baa2ba1 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -37,6 +37,9 @@ import { getMedicationComplianceArgsSchema, getLabsArgsSchema, getIllnessRecoveryArgsSchema, + getWorkoutsArgsSchema, + getCycleArgsSchema, + getCorrelationsArgsSchema, isCoachToolName, type CoachToolName, } from "./definitions"; @@ -44,6 +47,7 @@ import { COACH_SOURCE_SNAPSHOT_KEY, METRIC_SERIES_EXCLUDED_SOURCES, } from "./source-keys"; +import { readCoachCorrelations } from "./correlations-read"; /** A read-only structured tool result. Serialised to a `role:"tool"` turn. */ export interface CoachToolResult { @@ -162,9 +166,15 @@ async function dispatch( case "get_medication_compliance": return getMedicationCompliance(userId, rawArgs, fallbackWindow); case "get_labs": - return getLabs(userId, rawArgs); + return getLabs(userId, rawArgs, fallbackWindow); case "get_illness_recovery": - return getIllnessRecovery(userId); + return getIllnessRecovery(userId, fallbackWindow); + case "get_workouts": + return getWorkouts(userId, rawArgs, fallbackWindow); + case "get_cycle": + return getCycle(userId, rawArgs); + case "get_correlations": + return getCorrelations(userId, rawArgs); } } @@ -300,13 +310,23 @@ async function getMedicationCompliance( async function getLabs( userId: string, rawArgs: unknown, + fallbackWindow: CoachScopeWindow | undefined, ): Promise { const parsed = getLabsArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_labs", parsed.error); // Labs ride the snapshot regardless of `sources` (attached unconditionally), // so a minimal scope still surfaces them. Pass an empty source set to keep // the read tight. - const snapshot = await buildCoachSnapshot(userId, { sources: [] }); + // + // v1.21.0 (A5-F4) — the labs block itself is a "latest reading per biomarker + // over the last 12 months" snapshot and is intentionally window-AGNOSTIC + // (the read cutoff is fixed). We still thread the conversation's window onto + // the scope so the snapshot's `scope` block reports the right horizon for the + // turn; it does not move the labs read. + const snapshot = await buildCoachSnapshot(userId, { + sources: [], + window: fallbackWindow, + }); const labs = pickSection(snapshot.sections, "labs") as | { recent?: Array<{ name?: string; analyte?: string }> } | undefined; @@ -327,15 +347,23 @@ async function getLabs( return { present: true, data: labs }; } -async function getIllnessRecovery(userId: string): Promise { +async function getIllnessRecovery( + userId: string, + fallbackWindow: CoachScopeWindow | undefined, +): Promise { // Validate the (empty) args shape for consistency; an empty object always // passes. getIllnessRecoveryArgsSchema.parse({}); // Recovery composites gate on the `recovery` module + the HRV/RHR/VO2max // sources; request them so the derived / dayStrain / trajectory blocks can // build when the module is on. Illness rides the snapshot unconditionally. + // + // v1.21.0 (A5-F4) — honour the conversation's window so the recovery/strain/ + // trajectory composites are computed over the horizon the caller asked for + // rather than the builder default. const snapshot = await buildCoachSnapshot(userId, { sources: ["hrv", "resting_hr", "vo2_max"], + window: fallbackWindow, }); const illness = pickSection(snapshot.sections, "illness"); const derived = pickSection(snapshot.sections, "derived"); @@ -359,3 +387,60 @@ async function getIllnessRecovery(userId: string): Promise { }, }; } + +async function getWorkouts( + userId: string, + rawArgs: unknown, + fallbackWindow: CoachScopeWindow | undefined, +): Promise { + const parsed = getWorkoutsArgsSchema.safeParse(rawArgs); + if (!parsed.success) return badArgs("get_workouts", parsed.error); + // The workouts block builds when the `workouts` cluster is active AND the + // user has workout rows in the window. Scope the read to that single source. + const snapshot = await buildCoachSnapshot( + userId, + scopeFor(["workouts"], parsed.data.window, fallbackWindow), + ); + const workouts = pickSection(snapshot.sections, "workouts"); + if (workouts === undefined) return { present: false, reason: "no_data" }; + return { present: true, data: workouts }; +} + +async function getCycle( + userId: string, + rawArgs: unknown, +): Promise { + const parsed = getCycleArgsSchema.safeParse(rawArgs); + if (!parsed.success) return badArgs("get_cycle", parsed.error); + // The cycle block is gated INSIDE the builder by `isCycleAvailableForUser` + // (the per-user toggle AND the operator switch), independent of `sources` — + // so a minimal scope still surfaces it when the account tracks cycles, and a + // non-cycle account structurally produces no block (→ present:false). + const snapshot = await buildCoachSnapshot(userId, { sources: [] }); + const cycle = pickSection(snapshot.sections, "cycle"); + if (cycle === undefined) return { present: false, reason: "no_data" }; + return { present: true, data: cycle }; +} + +async function getCorrelations( + userId: string, + rawArgs: unknown, +): Promise { + const parsed = getCorrelationsArgsSchema.safeParse(rawArgs); + if (!parsed.success) return badArgs("get_correlations", parsed.error); + // Reads the deterministic FDR discovery + coincident-deviation flag; returns + // a clean `{ present: false }` when too little paired data exists. + const result = await readCoachCorrelations(userId); + if (!result.present) { + return { present: false, reason: result.reason ?? "no_data" }; + } + return { + present: true, + data: { + ...(result.drivers ? { drivers: result.drivers } : {}), + ...(result.coincident ? { coincident: result.coincident } : {}), + pairsTested: result.pairsTested, + windowDays: result.windowDays, + }, + }; +} diff --git a/src/lib/ai/coach/tools/inventory.ts b/src/lib/ai/coach/tools/inventory.ts index 7e4e93bb8..10a5431e4 100644 --- a/src/lib/ai/coach/tools/inventory.ts +++ b/src/lib/ai/coach/tools/inventory.ts @@ -17,7 +17,17 @@ */ import { isCycleAvailableForUser } from "@/lib/cycle/gate"; import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot"; -import type { CoachProvenanceMetric, CoachScope } from "@/lib/ai/coach/types"; +import type { + CoachProvenanceMetric, + CoachScope, + CoachScopeSource, +} from "@/lib/ai/coach/types"; +import { + COACH_SOURCE_SNAPSHOT_KEY, + COACH_SOURCE_DOMAIN_LABEL, + METRIC_SERIES_INVENTORY_SOURCES, + FULL_INVENTORY_SOURCE_SET, +} from "./source-keys"; /** One row of the inventory: a domain + whether the user has data for it. */ export interface InventoryEntry { @@ -36,73 +46,48 @@ export interface CoachDataInventory { entries: InventoryEntry[]; /** Illness rest-mode flag, so the safety framing is right before any call. */ restMode: boolean; - /** Whether cycle tracking is available (the get_cycle tool is deferred). */ + /** Whether cycle tracking is available (fetched via get_cycle). */ cycleEnabled: boolean; /** The window the inventory was built against. */ window: string; } /** - * Map a snapshot section key → an inventory row. The per-metric series tool - * carries the `metric` argument; the dedicated-tool domains name their tool. + * v1.21.0 (C2-2 / C2-3) — generate the get_metric_series inventory rows from + * the FULL source→key map rather than the eight legacy clusters. Every series + * the user actually has rows for is advertised, so the model is no longer told + * (rule 2) to skip body-composition / vascular / SpO2 / gait / environment. + * The `provenance` count key equals the source name for these series sources. */ const METRIC_SERIES_DOMAINS: Array<{ sectionKey: string; - metric: string; + metric: CoachScopeSource; domain: string; provenance: CoachProvenanceMetric; -}> = [ - { - sectionKey: "bloodPressure", - metric: "bp", - domain: "blood pressure", - provenance: "bp", - }, - { - sectionKey: "weight", - metric: "weight", - domain: "weight", - provenance: "weight", - }, - { - sectionKey: "pulse", - metric: "pulse", - domain: "pulse", - provenance: "pulse", - }, - { sectionKey: "mood", metric: "mood", domain: "mood", provenance: "mood" }, - { - sectionKey: "heartRateVariability", - metric: "hrv", - domain: "heart-rate variability", - provenance: "hrv", - }, - { - sectionKey: "restingHeartRate", - metric: "resting_hr", - domain: "resting heart rate", - provenance: "resting_hr", - }, - { - sectionKey: "steps", - metric: "steps", - domain: "steps", - provenance: "steps", - }, - { - sectionKey: "vo2Max", - metric: "vo2_max", - domain: "VO2 max", - provenance: "vo2_max", - }, -]; +}> = METRIC_SERIES_INVENTORY_SOURCES.map((metric) => ({ + sectionKey: COACH_SOURCE_SNAPSHOT_KEY[metric] as string, + metric, + domain: COACH_SOURCE_DOMAIN_LABEL[metric] ?? (metric as string), + provenance: metric as unknown as CoachProvenanceMetric, +})); export async function buildCoachDataInventory( userId: string, scope: CoachScope | undefined, ): Promise { + // v1.21.0 (C2-2) — probe presence against the FULL source set, not the + // user's narration-cluster default. `effectiveScope` only carried the + // window; expanding `sources` here makes the snapshot build every domain the + // user has rows for, so sleep / glucose / activity / body-composition / etc. + // no longer report `absent` for a default-prefs user. Each per-tool read + // still re-scopes to its own domain, so this presence probe never widens a + // figure read — it only widens what the inventory advertises. + const probeScope: CoachScope = { + sources: [...FULL_INVENTORY_SOURCE_SET], + window: scope?.window, + }; const [snapshot, cycleEnabled] = await Promise.all([ - buildCoachSnapshot(userId, scope), + buildCoachSnapshot(userId, probeScope), isCycleAvailableForUser(userId), ]); const sections = snapshot.sections; @@ -147,6 +132,14 @@ export async function buildCoachDataInventory( ? { count: counts.compliance } : {}), }); + // v1.21.0 (C2-4) — workouts has its own tool now (get_metric_series refuses + // it). The block builds when the user has workout rows + the cluster is on. + entries.push({ + tool: "get_workouts", + domain: "workouts & training", + present: has("workouts"), + ...(typeof counts.workouts === "number" ? { count: counts.workouts } : {}), + }); entries.push({ tool: "get_labs", domain: "lab results", @@ -158,6 +151,31 @@ export async function buildCoachDataInventory( present: has("illness") || has("derived") || has("dayStrain") || has("trajectory"), }); + // v1.21.0 (C3) — discovered cross-metric correlations (FDR-controlled lagged + // drivers) + the coincident-deviation flag. Advertised when the user has any + // two discovery channels with data; the tool itself returns present:false + // cleanly when nothing survives, so the inventory marks it present whenever a + // correlatable signal exists (mood / cardio / activity). + entries.push({ + tool: "get_correlations", + domain: "cross-metric patterns (discovered drivers)", + present: + has("derived") || + has("mood") || + has("heartRateVariability") || + has("restingHeartRate") || + has("steps") || + has("sleep"), + }); + // v1.21.0 (C2-1) — cycle is fetchable via get_cycle when cycle tracking is + // available for this account (per-user toggle AND operator switch). + if (cycleEnabled) { + entries.push({ + tool: "get_cycle", + domain: "menstrual cycle & phase", + present: has("cycle"), + }); + } // restMode rides the illness section. const illness = sections.illness as { restMode?: boolean } | undefined; diff --git a/src/lib/ai/coach/tools/source-keys.ts b/src/lib/ai/coach/tools/source-keys.ts index d0d821347..a5fc0d878 100644 --- a/src/lib/ai/coach/tools/source-keys.ts +++ b/src/lib/ai/coach/tools/source-keys.ts @@ -17,7 +17,10 @@ * either resolves here or is explicitly one of the dedicated-tool sources, so a * future source addition cannot silently fall through to a fabricated answer. */ -import type { CoachScopeSource } from "@/lib/ai/coach/types"; +import { + coachScopeSourceSchema, + type CoachScopeSource, +} from "@/lib/ai/coach/types"; /** Sources that `get_metric_series` does NOT serve (they have own tools). */ export const METRIC_SERIES_EXCLUDED_SOURCES: ReadonlySet = @@ -73,3 +76,78 @@ export const COACH_SOURCE_SNAPSHOT_KEY: Readonly< sleep: "sleep", body_temp: "bodyTemperature", }; + +/** + * v1.21.0 (C2-3) — human-readable domain label per metric source, for the DATA + * INVENTORY lines. Brand-free, lower-case, stable across turns so the cached + * prefix holds. Every key in `COACH_SOURCE_SNAPSHOT_KEY` plus the dedicated-tool + * sources has an entry; the inventory renders one line per series source the + * user actually has rows for. + */ +export const COACH_SOURCE_DOMAIN_LABEL: Readonly< + Partial> +> = { + bp: "blood pressure", + weight: "weight", + pulse: "pulse", + mood: "mood", + hrv: "heart-rate variability", + resting_hr: "resting heart rate", + walking_hr: "walking heart rate", + respiratory_rate: "respiratory rate", + spo2: "blood oxygen (SpO2)", + pulse_wave_velocity: "pulse-wave velocity", + vascular_age: "vascular age", + body_fat: "body fat", + fat_mass: "fat mass", + fat_free_mass: "fat-free mass", + muscle_mass: "muscle mass", + lean_body_mass: "lean body mass", + bone_mass: "bone mass", + total_body_water: "total body water", + bmi: "BMI", + visceral_fat: "visceral fat", + steps: "steps", + active_energy: "active energy", + flights: "flights climbed", + distance: "walking/running distance", + vo2_max: "VO2 max", + walking_steadiness: "walking steadiness", + walking_asymmetry: "walking asymmetry", + walking_double_support: "walking double support", + walking_step_length: "walking step length", + walking_speed: "walking speed", + audio_env: "environmental audio exposure", + audio_headphone: "headphone audio exposure", + audio_event: "audio exposure events", + daylight: "time in daylight", + skin_temp: "skin temperature", + sleep: "sleep", + body_temp: "body temperature", +}; + +/** + * v1.21.0 (C2-2 / C2-3) — the FULL set of metric-series sources the inventory + * probes for presence: every source `get_metric_series` can serve (the + * `COACH_SOURCE_SNAPSHOT_KEY` keys), minus the few that ride a dedicated tool + * (sleep has `get_sleep`; glucose/compliance/workouts are excluded). The + * inventory is built against this full set so a domain with data is advertised + * regardless of the user's narration-cluster preference — closing the + * default-cluster reach gap. `sleep` is omitted here because `get_sleep` owns + * its inventory line. + */ +export const METRIC_SERIES_INVENTORY_SOURCES: ReadonlyArray = + (Object.keys(COACH_SOURCE_SNAPSHOT_KEY) as CoachScopeSource[]).filter( + (source) => source !== "sleep", + ); + +/** + * v1.21.0 (C2-2) — the full source set the inventory snapshot is built against, + * so every domain with stored rows reports `present` (not the default-cluster + * subset). The per-tool reads still re-scope to the exact domain, so widening + * the inventory probe never widens a figure read. This is the canonical enum + * (every `CoachScopeSource`) — the snapshot builder only emits a block when the + * matching source has rows, so probing the full set is presence-only. + */ +export const FULL_INVENTORY_SOURCE_SET: ReadonlyArray = + coachScopeSourceSchema.options; From 58376a07054b7285c459e7f8302c904e4e08d640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:36:07 +0200 Subject: [PATCH 14/51] feat(coach): verify Coach prose numbers against fetched figures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Daily Briefing strips any number absent from its server-computed figures; the Coach's tool path had no equivalent, so a transcription or paraphrase drift (a tool returns systolic 128, the reply says ~138) could ship. Collect the numeric leaves from every present tool result this turn, extract the numbers the reply asserts, and soft-correct any that match no fetched figure to a neutral placeholder — annotated, non-blocking, and a no-op on a qualitative turn or the no-tools path where there is nothing to grade against. The bounded retrieval loop now returns the present results' payloads so the check has an authoritative figure set. --- .../chat/__tests__/route-tool-mode.test.ts | 48 ++++++ src/app/api/insights/chat/route.ts | 40 +++++ .../__tests__/coach-prose-grounding.test.ts | 89 ++++++++++ src/lib/ai/coach/__tests__/tools-loop.test.ts | 3 + src/lib/ai/coach/coach-prose-grounding.ts | 153 ++++++++++++++++++ src/lib/ai/coach/tools/loop.ts | 27 +++- 6 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts create mode 100644 src/lib/ai/coach/coach-prose-grounding.ts diff --git a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts index d8b95d743..3f4a1e17d 100644 --- a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts +++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts @@ -154,6 +154,7 @@ const { buildCoachDataInventory, renderDataInventory, runCoachToolLoop } = totalTokens: 80, rounds: 2, toolTrace: [{ name: "get_metric_series", present: true }], + toolResults: [], })), })); vi.mock("@/lib/ai/coach/tools", () => ({ @@ -183,6 +184,9 @@ vi.mock("@/lib/validations/coach-prefs", () => ({ })); const { appendMessage } = await import("@/lib/ai/coach/persistence"); +const { parseKeyValuesSentinel } = await import("@/lib/ai/coach/keyvalues"); +const { parseSuggestReminder } = + await import("@/lib/ai/coach/suggest-reminder"); vi.mock("@/lib/sse/create-stream", () => ({ createSseStream: ( @@ -260,6 +264,50 @@ describe("coach chat — tool-mode routing (F1)", () => { ).toEqual([{ name: "get_metric_series", present: true }]); }); + it("soft-strips a prose number the tools never returned (P6)", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, + ]); + // Echo the real prose through the sentinel + suggest parsers so the + // verifier sees the model's actual numbers (the default mocks return a + // fixed string). + const drift = "Your systolic averaged about 138 lately."; + (parseKeyValuesSentinel as ReturnType).mockReturnValue({ + prose: drift, + keyValues: [], + malformed: false, + malformedEntries: [], + }); + (parseSuggestReminder as ReturnType).mockReturnValue({ + prose: drift, + }); + // The tool returned systolic 128; the prose drifts to 138. + (runCoachToolLoop as ReturnType).mockImplementation( + async () => ({ + result: { + content: "Your systolic averaged about 138 lately.", + tokensUsed: 80, + model: "m", + }, + workingProviderType: "anthropic", + totalTokens: 80, + rounds: 2, + toolTrace: [{ name: "get_metric_series", present: true }], + toolResults: [ + { present: true, data: { aggregate: { avgSys30: 128 } } }, + ], + }), + ); + await post(chatReq({ message: "How is my BP?" })); + const calls = (appendMessage as ReturnType).mock.calls; + const assistantCall = calls.find( + (c) => (c[0] as { role: string }).role === "assistant", + ); + const content = (assistantCall?.[0] as { content: string }).content; + expect(content).toContain("[unverified]"); + expect(content).not.toContain("138"); + }); + it("falls back to the snapshot-stuffing path when a provider lacks tools", async () => { resolveProviderChain.mockResolvedValue([ { providerType: "anthropic", instance: {} }, diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 341476274..9fca7a8d8 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -84,6 +84,10 @@ import { } from "@/lib/ai/coach/tools"; import type { AiMessage } from "@/lib/ai/types"; import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues"; +import { + findUnverifiedCoachNumbers, + stripUnverifiedNumbers, +} from "@/lib/ai/coach/coach-prose-grounding"; import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder"; import { gateSuggestion } from "@/lib/ai/coach/suggest-gate"; import { @@ -518,6 +522,9 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; let result: CompletionResult; let workingProviderType: string; let toolTrace: CoachToolTrace[] = []; + // v1.21.0 (P6) — the present tool-result payloads this turn, for the post-hoc + // prose number-verifier. Empty on the no-tools path. + let toolResultPayloads: unknown[] = []; let totalTokensSpent: number; try { if (toolMode) { @@ -556,6 +563,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch result = loop.result; workingProviderType = loop.workingProviderType; toolTrace = loop.toolTrace; + toolResultPayloads = (loop.toolResults ?? []).map((r) => r.data); totalTokensSpent = loop.totalTokens; } else { const fallback = await runRawCompletionWithFallback({ @@ -701,6 +709,38 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch }); } + // v1.21.0 (P6 / C2-5) — post-hoc numeric verifier on the Coach prose. On the + // tool path, cross-check every number the model cited against the figures the + // tools actually returned this turn; an unmatched number (transcription / + // paraphrase drift) is soft-stripped to "[unverified]" and annotated. Cheap, + // non-blocking, and a no-op when no tool returned figures (a qualitative turn + // or the no-tools path) — the prompt-level grounding rule remains the + // backstop there, exactly like the briefing's "no signals → skip". A blocked + // turn already carries canned fallback prose, so skip it. + if (!outbound.block && toolResultPayloads.length > 0) { + const unverified = findUnverifiedCoachNumbers( + replyText, + toolResultPayloads, + ); + if (unverified.length > 0) { + const { prose: corrected, stripped } = stripUnverifiedNumbers( + replyText, + unverified, + ); + replyText = corrected; + annotate({ + action: { name: "coach.prose.number_unverified" }, + meta: { + flagged: unverified.length, + stripped, + // No raw values — just the count + truncated tokens for ops triage. + tokens: unverified.slice(0, 6).map((u) => u.source), + promptVersion: PROMPT_VERSION, + }, + }); + } + } + let surfacedSuggestion: CoachSuggestion | null = null; if (!outbound.block && suggestParse.cadence) { const decision = await gateSuggestion({ diff --git a/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts b/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts new file mode 100644 index 000000000..c6a61a988 --- /dev/null +++ b/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts @@ -0,0 +1,89 @@ +/** + * v1.21.0 (P6 / C2-5) — post-hoc numeric verifier on Coach prose. It must flag + * a number the model cited that no tool returned this turn (transcription + * drift), exempt structural window/ordinal integers, and no-op when no tool + * returned figures. Soft-strip replaces only the flagged tokens. + */ +import { describe, expect, it } from "vitest"; + +import { + collectNumericLeaves, + findUnverifiedCoachNumbers, + stripUnverifiedNumbers, +} from "@/lib/ai/coach/coach-prose-grounding"; + +describe("collectNumericLeaves", () => { + it("walks numbers, numeric strings, arrays and nested objects", () => { + const out = new Set(); + collectNumericLeaves( + { + section: { avgSys30: 128, label: "LDL", value: "120" }, + recent: [{ v: 72 }, { v: "1.2" }], + }, + out, + ); + expect(out.has(128)).toBe(true); + expect(out.has(120)).toBe(true); + expect(out.has(72)).toBe(true); + expect(out.has(1.2)).toBe(true); + }); +}); + +describe("findUnverifiedCoachNumbers", () => { + const payloads = [{ aggregate: { avgSys30: 128, avgDia30: 82 } }]; + + it("returns nothing when every cited number is grounded", () => { + const prose = + "Your systolic averaged 128 and diastolic 82 over the last 30 days."; + expect(findUnverifiedCoachNumbers(prose, payloads)).toEqual([]); + }); + + it("flags a drifted number the tools never returned", () => { + const prose = "Your systolic averaged about 138 recently."; + const findings = findUnverifiedCoachNumbers(prose, payloads); + expect(findings).toHaveLength(1); + expect(findings[0].value).toBe(138); + }); + + it("allows a rounding within tolerance", () => { + const prose = "Diastolic sat around 82.0 this period."; + expect(findUnverifiedCoachNumbers(prose, payloads)).toEqual([]); + }); + + it("exempts structural window + small-ordinal integers", () => { + const prose = "Over the last 30 days, 2 readings stood out of 7 total."; + // 30 + 7 are window integers; 2 is a small ordinal — none graded. + const findings = findUnverifiedCoachNumbers(prose, [{ x: 999 }]); + expect(findings).toEqual([]); + }); + + it("no-ops when no tool returned any figure", () => { + const prose = "Your systolic averaged 138 recently."; + expect(findUnverifiedCoachNumbers(prose, [])).toEqual([]); + expect( + findUnverifiedCoachNumbers(prose, [{ note: "no numbers here" }]), + ).toEqual([]); + }); + + it("returns empty on empty prose", () => { + expect(findUnverifiedCoachNumbers("", payloads)).toEqual([]); + }); +}); + +describe("stripUnverifiedNumbers", () => { + it("replaces only the flagged tokens, leaving grounded numbers intact", () => { + const prose = "Systolic 128, but it spiked to 138 yesterday."; + const findings = [{ value: 138, source: "138" }]; + const { prose: out, stripped } = stripUnverifiedNumbers(prose, findings); + expect(stripped).toBe(1); + expect(out).toContain("128"); + expect(out).toContain("[unverified]"); + expect(out).not.toContain("138"); + }); + + it("is a no-op when nothing was flagged", () => { + const { prose, stripped } = stripUnverifiedNumbers("all good", []); + expect(prose).toBe("all good"); + expect(stripped).toBe(0); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-loop.test.ts b/src/lib/ai/coach/__tests__/tools-loop.test.ts index 7e70c3499..3c4586224 100644 --- a/src/lib/ai/coach/__tests__/tools-loop.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop.test.ts @@ -86,6 +86,9 @@ describe("runCoachToolLoop", () => { expect(out.toolTrace).toEqual([ { name: "get_metric_series", present: true }, ]); + // v1.21.0 (P6) — the present result's payload is retained for the prose + // number-verifier. + expect(out.toolResults).toEqual([{ present: true, data: { x: 1 } }]); // The second call must forbid tools? No — round 2 still offers them // (round <= MAX_ROUNDS). It simply chose to answer. expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(2); diff --git a/src/lib/ai/coach/coach-prose-grounding.ts b/src/lib/ai/coach/coach-prose-grounding.ts new file mode 100644 index 000000000..0772e83b8 --- /dev/null +++ b/src/lib/ai/coach/coach-prose-grounding.ts @@ -0,0 +1,153 @@ +/** + * v1.21.0 (P6 / C2-5) — post-hoc numeric verifier for Coach prose. + * + * The Daily Briefing strips any number absent from the server-computed + * `signalsOfDay` block (`@/lib/ai/briefing-grounding`). The Coach's TOOL path + * had no equivalent: the addendum forbids citing un-fetched figures, but + * nothing deterministically checks the FINAL prose against the figures the + * tools actually returned this turn — so a transcription/paraphrase drift + * (tool says systolic 128, prose says "~138") could ship. + * + * This module closes that gap the same way, scoped to the tool path: + * 1. Collect every numeric leaf from this turn's PRESENT `CoachToolResult` + * data payloads — the authoritative figure set. + * 2. Extract every number the model's prose asserts (reusing the briefing + * verifier's `extractNumbers`). + * 3. Flag any prose number that matches no authoritative figure within a + * rounding tolerance, exempting the structural integers (window lengths, + * small ordinals) the briefing verifier already exempts. + * + * Posture: NON-BLOCKING and cheap. The caller annotates + * `coach.prose.number_unverified` and may SOFT-STRIP the unverified figure + * from the prose (replacing the bare token with a neutral placeholder) — it + * never hard-fails the user's turn. When NO tool returned figures (a + * qualitative answer, or the no-tools path) there is no authoritative set to + * grade against, so the check no-ops and the prompt-level grounding rule + * remains the backstop, exactly like the briefing's "no signals → skip". + */ +import { extractNumbers } from "@/lib/ai/briefing-grounding"; + +/** Absolute + relative tolerance — identical basis to the briefing verifier. */ +const ABS_TOLERANCE = 0.15; +const REL_TOLERANCE = 0.02; + +/** + * Window lengths + small ordinals the prose uses structurally ("last 7 days", + * "2 of your vitals", "3 readings") — never graded, to avoid false positives on + * honest framing. Mirrors the briefing verifier's exemption set. + */ +const STRUCTURAL_INTEGERS = new Set([7, 14, 30, 31, 90, 180, 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; + return STRUCTURAL_INTEGERS.has(abs); +} + +/** One prose number that matched no figure any tool returned this turn. */ +export interface UnverifiedCoachNumber { + /** The numeric value the model wrote, as parsed. */ + value: number; + /** The raw token the value was read from (truncated). */ + source: string; +} + +/** + * Recursively collect every finite numeric leaf from a tool-result payload — + * numbers, and numeric strings ("128", "1.2"). Both forms appear in the + * snapshot sections (a mean may be a number; a lab value a string). The set is + * deliberately broad so a legitimately-cited figure never trips the verifier; + * the verifier's job is only to catch a number the model invented or mis-copied. + */ +export function collectNumericLeaves(value: unknown, out: Set): void { + if (value === null || value === undefined) return; + if (typeof value === "number") { + if (Number.isFinite(value)) out.add(value); + return; + } + if (typeof value === "string") { + // A scalar numeric string ("128", "-1.2", "92%") — pull its magnitudes. + for (const { value: n } of extractNumbers(value)) out.add(n); + return; + } + if (Array.isArray(value)) { + for (const item of value) collectNumericLeaves(item, out); + return; + } + if (typeof value === "object") { + for (const v of Object.values(value as Record)) { + collectNumericLeaves(v, out); + } + } +} + +/** True when `value` matches any authoritative figure within tolerance. */ +function isGrounded( + value: number, + authoritative: ReadonlySet, +): 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; +} + +/** + * Find every number the Coach prose asserts that does not trace to a figure + * returned by a tool this turn. Returns an empty array when there is no prose, + * no authoritative figure set (no present tool result with numbers), or every + * cited number is grounded. + * + * `toolPayloads` is the `data` payload of each PRESENT tool result this turn. + */ +export function findUnverifiedCoachNumbers( + prose: string, + toolPayloads: ReadonlyArray, +): UnverifiedCoachNumber[] { + if (!prose) return []; + const authoritative = new Set(); + for (const payload of toolPayloads) + collectNumericLeaves(payload, authoritative); + // No authoritative figures (a qualitative turn / no-tools path) — nothing to + // grade against. The prompt-level grounding rule remains the backstop. + if (authoritative.size === 0) return []; + + const findings: UnverifiedCoachNumber[] = []; + for (const { value, raw } of extractNumbers(prose)) { + if (isStructural(value, raw)) continue; + if (isGrounded(value, authoritative)) continue; + findings.push({ value, source: raw.slice(0, 32) }); + } + return findings; +} + +/** + * Soft-correct the prose: replace each unverified numeric token with a neutral + * placeholder so a drifted figure never reaches the user as if authoritative, + * while the surrounding qualitative framing is preserved. Conservative — it + * only rewrites the exact ungrounded tokens the verifier flagged, and only the + * first occurrence of each, leaving every grounded number untouched. + * + * Returns the (possibly unchanged) prose plus the count of tokens stripped. + */ +export function stripUnverifiedNumbers( + prose: string, + findings: ReadonlyArray, +): { prose: string; stripped: number } { + if (findings.length === 0) return { prose, stripped: 0 }; + let out = prose; + let stripped = 0; + for (const f of findings) { + // Replace the first standalone occurrence of the flagged token. The token + // is bounded by non-digit / non-sign edges so we don't clip a larger number. + const token = f.source; + const idx = out.indexOf(token); + if (idx === -1) continue; + out = `${out.slice(0, idx)}[unverified]${out.slice(idx + token.length)}`; + stripped += 1; + } + return { prose: out, stripped }; +} diff --git a/src/lib/ai/coach/tools/loop.ts b/src/lib/ai/coach/tools/loop.ts index 1f0dfb1d6..5e8208e4a 100644 --- a/src/lib/ai/coach/tools/loop.ts +++ b/src/lib/ai/coach/tools/loop.ts @@ -28,7 +28,11 @@ import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; import type { AiMessage, AiToolDef, CompletionResult } from "@/lib/ai/types"; import type { ProviderHealthLedger } from "@/lib/ai/provider-health-ledger"; import type { CoachScopeWindow } from "@/lib/ai/coach/types"; -import { executeCoachTool, type CoachToolTrace } from "./executor"; +import { + executeCoachTool, + type CoachToolResult, + type CoachToolTrace, +} from "./executor"; export const MAX_ROUNDS = 2; /** Absolute ceiling; the final allowed round forces prose (toolChoice none). */ @@ -45,6 +49,13 @@ export interface CoachToolLoopResult { rounds: number; /** Which tools ran + whether each found data (persisted onto provenance). */ toolTrace: CoachToolTrace[]; + /** + * v1.21.0 (P6) — the structured payloads of every PRESENT tool result this + * turn, in call order. The post-hoc prose number-verifier extracts the + * numeric leaves from these to cross-check the figures the model cited. Empty + * on a no-tools answer. + */ + toolResults: CoachToolResult[]; } export async function runCoachToolLoop(args: { @@ -78,6 +89,7 @@ export async function runCoachToolLoop(args: { let rounds = 0; let workingProviderType = ""; const toolTrace: CoachToolTrace[] = []; + const toolResults: CoachToolResult[] = []; // Round budget: rounds 1..HARD_CAP. On the last allowed round we forbid tool // calls so the model must answer. @@ -120,7 +132,14 @@ export async function runCoachToolLoop(args: { forcedFinal: isForcedFinal, }, }); - return { result, workingProviderType, totalTokens, rounds, toolTrace }; + return { + result, + workingProviderType, + totalTokens, + rounds, + toolTrace, + toolResults, + }; } // Append the assistant turn that requested the tools, then execute them in @@ -140,6 +159,10 @@ export async function runCoachToolLoop(args: { fallbackWindow, }); toolTrace.push({ name: call.name, present: toolResult.present }); + // v1.21.0 (P6) — retain the present results' payloads for the post-hoc + // prose number-verifier (the union of numeric leaves grounds the + // figures the model may cite). + if (toolResult.present) toolResults.push(toolResult); return { call, toolResult }; }), ); From ce07548bc42593ddd6e777cfa97af711a21d9a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:36:14 +0200 Subject: [PATCH 15/51] feat(coach): fold the coincident-deviation flag into the snapshot The multi-vital coincident-deviation flag (two or more vitals outside their usual band on the same day, with the illness-explained reframe) already reached the period narrative but never the Coach. Attach it, fired-only, to the derived block so both Coach paths can narrate it: the tool path via the recovery/correlations tools and the no-tools snapshot floor. A quiet day adds no entry, keeping the snapshot noise-free. --- src/lib/ai/coach/derived-snapshot.ts | 60 +++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/src/lib/ai/coach/derived-snapshot.ts b/src/lib/ai/coach/derived-snapshot.ts index 51a96bacc..34422b03b 100644 --- a/src/lib/ai/coach/derived-snapshot.ts +++ b/src/lib/ai/coach/derived-snapshot.ts @@ -16,6 +16,7 @@ */ import { computeDerivedMetric, + computeCoincidentDeviation, isDerivedOk, type DerivedMetricId, type BaselineProfile, @@ -55,6 +56,35 @@ interface DerivedSnapshotEntry { historyDays: number; } +/** + * v1.21.0 (C3 / D3) — the coincident-deviation flag, compactly. "Two or more + * of your vitals are outside their usual band today." Descriptive multi-signal + * co-movement, never a cause; carries the illness-explained reframe. Folded + * into the derived block so BOTH Coach paths (the tool path via + * get_illness_recovery/get_correlations AND the no-tools snapshot floor) can + * narrate it. Omitted entirely when it did not fire (no noise). + */ +interface CoincidentSnapshotEntry { + fired: true; + /** The out-of-band vitals (possible factors), e.g. "resting heart rate above". */ + contributing: string[]; + /** The day the flag was evaluated (YYYY-MM-DD). */ + day: string; + /** True when an active illness episode explains the deviations. */ + illnessExplained: boolean; +} + +/** + * The derived block: per-metric compact score entries (keyed by metric id), + * plus the optional fired-only coincident-deviation flag under its own reserved + * `COINCIDENT_DEVIATION` key. The score entries keep their `DerivedSnapshotEntry` + * shape (so callers + tests read `block.READINESS.value` directly); the + * coincident flag rides alongside without widening the score index signature. + */ +type DerivedSnapshotBlock = Record & { + COINCIDENT_DEVIATION?: CoincidentSnapshotEntry; +}; + /** Pull the headline number + band off each metric's value shape. */ function summariseValue( metric: DerivedMetricId, @@ -115,9 +145,18 @@ export async function buildDerivedSnapshotBlock( userId: string, profile: BaselineProfile, now: Date, -): Promise | null> { +): Promise { const block: Record = {}; + // v1.21.0 (C3 / D3) — the coincident-deviation flag, fired-only. Computed + // off the one shared profile alongside the scores; fail-soft to null so a + // baseline hiccup never sinks the derived block. Only attached when it + // FIRED (≥2 vitals out of band today) — a quiet day adds no entry, keeping + // the snapshot noise-free. + const coincidentPromise = computeCoincidentDeviation(userId, profile, { + now, + }).catch(() => null); + // The metrics are independent passthrough reads off the one shared profile — // no ordering dependency — so compute them concurrently. Per-metric fault // isolation: a transient failure on one must never sink the whole Coach turn, @@ -176,5 +215,22 @@ export async function buildDerivedSnapshotBlock( } } - return Object.keys(block).length > 0 ? block : null; + // Attach the coincident-deviation flag (fired-only) — the await happens here + // so the score computes above run concurrently with it. It rides under its + // own reserved key alongside the score entries. + const out: DerivedSnapshotBlock = block; + const coincident = await coincidentPromise; + if (coincident && isDerivedOk(coincident) && coincident.value.fired) { + out.COINCIDENT_DEVIATION = { + fired: true, + contributing: coincident.value.contributing.map( + (c) => + `${String(c.type).replace(/_/g, " ").toLowerCase()} ${c.direction}`, + ), + day: coincident.value.day, + illnessExplained: coincident.value.illnessExplained, + }; + } + + return Object.keys(out).length > 0 ? out : null; } From 44796d1c0e4e31426231987bc2e49b912d59173e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:37:54 +0200 Subject: [PATCH 16/51] feat(coach): carry metric scope into the Coach launch Make the launch context's metric scope live instead of discarding it. A conversation opened from a metric surface now narrows its snapshot to that metric with a data-aware seed question, and the global FAB inherits the page's ambient scope so drilling into a metric and tapping it no longer opens a blank chat. The scope threads through the drawer into the first turn of a fresh conversation. --- src/components/insights/coach-metric-scope.ts | 167 ++++++++++++++++++ .../__tests__/coach-launch-scope.test.ts | 76 ++++++++ .../coach-panel/coach-conversation.tsx | 42 +++++ .../insights/coach-panel/coach-drawer.tsx | 15 +- .../insights/layout-coach-mount.tsx | 4 + src/components/insights/sub-page-shell.tsx | 45 ++++- src/lib/insights/coach-launch-context.tsx | 102 +++++++++-- 7 files changed, 431 insertions(+), 20 deletions(-) create mode 100644 src/components/insights/coach-metric-scope.ts create mode 100644 src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts diff --git a/src/components/insights/coach-metric-scope.ts b/src/components/insights/coach-metric-scope.ts new file mode 100644 index 000000000..317cb64a0 --- /dev/null +++ b/src/components/insights/coach-metric-scope.ts @@ -0,0 +1,167 @@ +import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types"; + +/** + * v1.21.0 (C4 H1/H4) — metric → Coach scope + seed-question map. + * + * The Coach launch context can now carry a live `scope` (a + * `CoachScopeSource` + window) and a `prefill` seed question into the + * conversation. This module is the single place that resolves "the user + * is looking at metric X" into "open the Coach narrowed to source X with + * a data-aware opener." + * + * Two callers consume it: + * - `` registers the active metric's scope as the page's + * ambient scope so the global FAB opens contextual to that page + * (no per-metric header icon — CCH-04 stays intact). + * - the "Ask the Coach" affordance on insight/assessment cards passes + * an explicit scope so a card tap lands a pre-scoped conversation. + * + * Seed questions are plain English strings, mirroring the empty-state + * `coachPrefill` convention already in the tree — the Coach chat route + * is English/German-gated and treats the prefill as composer seed text, + * not an i18n key. + */ + +export interface CoachMetricScope { + /** Primary source the snapshot narrows to. */ + metric: CoachScopeSource; + /** Extra sources to include alongside `metric` (e.g. correlations). */ + also?: CoachScopeSource[]; + /** Optional day-window override; defaults to the route's `last30days`. */ + window?: CoachScopeWindow; + /** Composer seed question — a data-aware opener for the metric. */ + question: string; +} + +/** + * `SubPageShell` passes the metric's `explainerMetric` token (the key + * feeding `insights.subPage.explainer.Body`). Map the tokens that + * resolve to a snapshot source to a scope + opener. Tokens absent here + * (mobility / gait micro-metrics with no dedicated snapshot block) simply + * carry no ambient scope — the FAB opens the default snapshot, exactly as + * before, so this is purely additive. + */ +const EXPLAINER_METRIC_SCOPE: Record = { + bloodPressure: { + metric: "bp", + question: + "Walk me through my blood pressure trend over the last 30 days — anything I should keep an eye on?", + }, + weight: { + metric: "weight", + question: + "How has my weight been trending lately, and is the direction something to act on?", + }, + bmi: { + metric: "bmi", + question: "What does my BMI trend tell me, and how should I read it?", + }, + pulse: { + metric: "pulse", + question: + "Walk me through my pulse readings — is anything out of the ordinary?", + }, + restingHr: { + metric: "resting_hr", + question: + "How has my resting heart rate been trending, and what does it say about my fitness?", + }, + hrv: { + metric: "hrv", + question: + "What is my heart-rate variability telling me about recovery and stress lately?", + }, + sleep: { + metric: "sleep", + question: + "Walk me through my recent sleep — duration, consistency, and anything worth changing.", + }, + mood: { + metric: "mood", + question: "What patterns do you see in my mood over the last few weeks?", + }, + medications: { + metric: "compliance", + question: + "How is my medication adherence looking, and is it lined up with how I've been feeling?", + }, + steps: { + metric: "steps", + question: + "How active have I been lately based on my steps, and how does it compare to a healthy baseline?", + }, + activeEnergy: { + metric: "active_energy", + question: + "What does my active-energy trend say about my activity level lately?", + }, + cardioFitness: { + metric: "vo2_max", + question: + "What does my cardio fitness (VO₂ max) trend mean, and how do I move it in the right direction?", + }, + bloodGlucose: { + metric: "glucose", + question: + "Walk me through my recent glucose readings — are they in a healthy range?", + }, + oxygenSaturation: { + metric: "spo2", + question: "What does my blood-oxygen (SpO₂) trend tell me?", + }, + respiratoryRate: { + metric: "respiratory_rate", + question: "What does my respiratory-rate trend say about my health?", + }, + workouts: { + metric: "workouts", + question: + "Walk me through my recent workouts — load, frequency, and how I'm recovering between them.", + }, + // Recovery is a synthesis page; anchor on HRV + resting HR + sleep so the + // Coach reads the inputs that drive the recovery read. + recoveryPage: { + metric: "hrv", + also: ["resting_hr", "sleep"], + window: "last7days", + question: + "Why is my recovery where it is right now, and what's driving it?", + }, +}; + +/** Resolve a `SubPageShell` explainer token to a Coach scope, or null. */ +export function metricScopeFromExplainer( + explainerMetric: string | undefined, +): CoachMetricScope | null { + if (!explainerMetric) return null; + return EXPLAINER_METRIC_SCOPE[explainerMetric] ?? null; +} + +/** + * Resolve a recommendation / correlation `metricSource.type` — the + * model's snapshot-key vocabulary ("bloodPressure", "weight", "pulse", + * "mood", "medications.compliance30", "sleep", "steps", …) — to a single + * `CoachScopeSource`, or null when the key has no snapshot block to + * narrow to. Lets a card's "Ask the Coach" affordance pre-scope the + * conversation to the metric the card is about. + */ +export function scopeSourceFromMetricKey( + metricKey: string | undefined, +): CoachScopeSource | null { + if (!metricKey) return null; + const lower = metricKey.toLowerCase(); + if (lower.startsWith("medications.compliance") || lower === "medication") { + return "compliance"; + } + if (lower === "bloodpressure" || lower === "blood_pressure") return "bp"; + if (lower === "weight") return "weight"; + if (lower === "pulse") return "pulse"; + if (lower === "resting_hr" || lower === "restinghr") return "resting_hr"; + if (lower === "hrv") return "hrv"; + if (lower === "mood") return "mood"; + if (lower === "sleep" || lower.startsWith("sleep")) return "sleep"; + if (lower === "steps" || lower === "activity") return "steps"; + if (lower === "bloodglucose" || lower === "blood_glucose") return "glucose"; + if (lower === "bmi") return "bmi"; + return null; +} diff --git a/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts b/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts new file mode 100644 index 000000000..0619ce106 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { launchScopeToCoachScope } from "../coach-conversation"; +import { + metricScopeFromExplainer, + scopeSourceFromMetricKey, +} from "../../coach-metric-scope"; + +/** + * v1.21.0 (C4 H1/H4) — the Coach launch scope is now live. These tests pin + * the conversion from the UI launch scope to the chat route's wire + * `CoachScope`, plus the two metric→source resolvers that feed it. + */ +describe("launchScopeToCoachScope", () => { + it("returns undefined for an absent / metric-less scope (default snapshot)", () => { + expect(launchScopeToCoachScope(null)).toBeUndefined(); + expect(launchScopeToCoachScope(undefined)).toBeUndefined(); + expect(launchScopeToCoachScope({})).toBeUndefined(); + }); + + it("maps a single metric to a one-source scope", () => { + expect(launchScopeToCoachScope({ metric: "bp" })).toEqual({ + sources: ["bp"], + }); + }); + + it("includes `also` sources and dedupes against the primary", () => { + expect( + launchScopeToCoachScope({ metric: "bp", also: ["compliance", "bp"] }), + ).toEqual({ sources: ["bp", "compliance"] }); + }); + + it("threads the window through only when present", () => { + expect( + launchScopeToCoachScope({ metric: "hrv", window: "last7days" }), + ).toEqual({ sources: ["hrv"], window: "last7days" }); + expect(launchScopeToCoachScope({ metric: "hrv" })).not.toHaveProperty( + "window", + ); + }); +}); + +describe("metricScopeFromExplainer", () => { + it("resolves a mapped explainer token to a scope + opener", () => { + const resolved = metricScopeFromExplainer("bloodPressure"); + expect(resolved?.metric).toBe("bp"); + expect(resolved?.question).toMatch(/blood pressure/i); + }); + + it("anchors the recovery page on its driver sources + short window", () => { + const resolved = metricScopeFromExplainer("recoveryPage"); + expect(resolved?.metric).toBe("hrv"); + expect(resolved?.also).toEqual(["resting_hr", "sleep"]); + expect(resolved?.window).toBe("last7days"); + }); + + it("returns null for an unmapped / undefined token", () => { + expect(metricScopeFromExplainer("walkingAsymmetry")).toBeNull(); + expect(metricScopeFromExplainer(undefined)).toBeNull(); + }); +}); + +describe("scopeSourceFromMetricKey", () => { + it("maps the model's snapshot-key vocabulary to a scope source", () => { + expect(scopeSourceFromMetricKey("bloodPressure")).toBe("bp"); + expect(scopeSourceFromMetricKey("medications.compliance30")).toBe( + "compliance", + ); + expect(scopeSourceFromMetricKey("WEIGHT")).toBe("weight"); + }); + + it("returns null for an unknown / absent key", () => { + expect(scopeSourceFromMetricKey("vascular_age")).toBeNull(); + expect(scopeSourceFromMetricKey(undefined)).toBeNull(); + }); +}); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index add693bda..84c074f18 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -17,6 +17,8 @@ import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiDelete, apiGet } from "@/lib/api/api-fetch"; +import type { CoachScope } from "@/lib/ai/coach/types"; +import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; import { CoachDrawerBody } from "./coach-drawer-body"; import { CoachHero } from "./coach-hero"; @@ -64,6 +66,27 @@ import { * `` (required for the dialog's accessible name) while * the page mounts a plain `

` / `

`. */ + +/** + * v1.21.0 (C4 H1/H4) — collapse a UI launch scope ({ metric, also, + * window }) into the chat route's wire scope ({ sources, window }). + * Returns undefined when there is nothing to narrow, so the request + * falls back to the route's default all-source snapshot. Exported for + * the unit test that pins the source-dedup + window contract. + */ +export function launchScopeToCoachScope( + launchScope: CoachLaunchScope | null | undefined, +): CoachScope | undefined { + if (!launchScope?.metric) return undefined; + const sources = Array.from( + new Set([launchScope.metric, ...(launchScope.also ?? [])]), + ); + return { + sources, + ...(launchScope.window ? { window: launchScope.window } : {}), + }; +} + export interface CoachConversationProps { /** * Pre-fill for the composer (suggested-prompt chip click). Resets the @@ -71,6 +94,15 @@ export interface CoachConversationProps { * freely between prop changes. */ prefill?: string | null; + /** + * v1.21.0 (C4 H1/H4) — optional launch scope so a conversation opened + * from a metric surface or insight card narrows its snapshot to the + * relevant source(s) + window. Converted to the chat route's + * `CoachScope` and attached to the FIRST turn of a fresh conversation + * (`currentConversationId === null`); a continued thread keeps its own + * established scope. Null → the route's default all-source snapshot. + */ + launchScope?: CoachLaunchScope | null; /** * Renders the conversation title. The surface passes the resolved * title string; the drawer wraps it in ``, the page in an @@ -148,6 +180,7 @@ export interface CoachConversationProps { export function CoachConversation({ prefill, + launchScope, renderTitle, renderDescription, leadingHeaderActions, @@ -277,12 +310,21 @@ export function CoachConversation({ dismissQuestions.mutate(guidedQuestion); } setInputValue(""); + // v1.21.0 (C4 H1/H4) — attach the launch scope to the FIRST turn of a + // fresh conversation so a chat opened from a metric surface / insight + // card reads a snapshot narrowed to the relevant source(s). A continued + // thread (existing id) keeps its own established scope, so we omit it. + const scope = + currentConversationId === null + ? launchScopeToCoachScope(launchScope) + : undefined; // v1.16.6 — hand the question to the turn so the Coach reaction is // contextual (the question bubble itself is never persisted). const resolvedId = await send.send({ conversationId: currentConversationId ?? undefined, message: trimmed, guidedQuestion: guidedQuestion ?? undefined, + scope, }); if (guidedQuestion !== null && guidedIndex !== null) { setPendingAdopt({ diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index bcbfce7d3..06f2cf7bf 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -12,6 +12,7 @@ import { SheetDescription, SheetTitle, } from "@/components/ui/sheet"; +import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { useIsMobile } from "@/hooks/use-is-mobile"; @@ -47,9 +48,20 @@ export interface CoachDrawerProps { onOpenChange: (next: boolean) => void; /** Optional pre-fill for the input box (suggested-prompt chip click). */ prefill?: string | null; + /** + * v1.21.0 (C4 H1/H4) — optional launch scope so a conversation opened + * from a metric surface or insight card is pre-narrowed to the relevant + * source(s) + window. Null → the route's default all-source snapshot. + */ + scope?: CoachLaunchScope | null; } -export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) { +export function CoachDrawer({ + open, + onOpenChange, + prefill, + scope, +}: CoachDrawerProps) { const { t } = useTranslations(); const router = useRouter(); // v1.4.27 R3d MB1 — below the `sm` breakpoint (640 px) the Coach @@ -126,6 +138,7 @@ export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) { ); } diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index 95e44c0ea..ec21ae39e 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -15,6 +15,8 @@ import { } from "@/components/ui/tooltip"; import { TargetAdjustButton } from "@/components/insights/target-adjust-button"; import { TargetAdjustProvider } from "@/lib/insights/target-adjust-context"; +import { metricScopeFromExplainer } from "@/components/insights/coach-metric-scope"; +import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; @@ -110,9 +112,15 @@ export interface SubPageShellProps { * v1.18.6 (CCH-04) — RETIRED as a rendered control. With the Coach FAB * anchored bottom-right on every authenticated page, the per-metric * Coach icon was a redundant second entry point, so the shell no longer - * paints anything for it. The prop stays on the signature (every routed - * category page still passes it) to avoid a churny rename across ~14 - * call sites; it is intentionally inert. + * paints anything for it. + * + * v1.21.0 (C4 H1) — re-purposed without re-adding any control. When set + * (and `explainerMetric` maps to a Coach source), the shell registers + * the page's metric as the launch context's ambient scope while it is + * mounted. The global FAB then opens a conversation pre-scoped to this + * metric with a data-aware seed question — so drilling into BP / weight + * / sleep and tapping the FAB no longer opens a blank chat. Still no + * second header affordance: the FAB stays the single entry point. */ coachLaunch?: boolean; /** @@ -137,14 +145,15 @@ export function SubPageShell({ focusOnMount = false, statStrip, diversityNudge, - // v1.18.6 (CCH-04) — `coachLaunch` is intentionally not destructured: - // the prop is still accepted on the type (call sites pass it) but the - // shell no longer renders a Coach control. The FAB is the single Coach - // entry point app-wide. + // v1.21.0 (C4 H1) — `coachLaunch` no longer renders a control (CCH-04 + // stands) but now gates whether the page registers its metric as the + // Coach launch context's ambient scope, so the FAB opens contextual. + coachLaunch = false, showAllValuesType, children, }: SubPageShellProps) { const { t } = useTranslations(); + const launch = useCoachLaunch(); // v1.10.2 — the "show all readings" link carries the originating metric // page as a `from` param so the values sub-page can offer a back-link to // where the user drilled in from (e.g. `weight → show all values → back to @@ -171,6 +180,28 @@ export function SubPageShell({ return () => window.cancelAnimationFrame(handle); }, [focusOnMount]); + // v1.21.0 (C4 H1) — register the page's metric as the Coach launch + // context's ambient scope while the sub-page is mounted, so the global + // FAB opens a conversation pre-scoped to this metric with a data-aware + // seed question. Only when the page opted in (`coachLaunch`) and its + // `explainerMetric` maps to a snapshot source; mobility / gait micro- + // metrics with no map entry leave the FAB on its default snapshot. The + // cleanup lifts the scope on navigation away. + const registerScope = launch?.registerScope; + useEffect(() => { + if (!coachLaunch || !registerScope) return; + const resolved = metricScopeFromExplainer(explainerMetric); + if (!resolved) return; + return registerScope( + { + metric: resolved.metric, + also: resolved.also, + window: resolved.window, + }, + resolved.question, + ); + }, [coachLaunch, registerScope, explainerMetric]); + return ( // The target-adjust provider bridges the header gear (rendered just // below) to the per-metric `` opened from the diff --git a/src/lib/insights/coach-launch-context.tsx b/src/lib/insights/coach-launch-context.tsx index 1adfa9824..cf7754813 100644 --- a/src/lib/insights/coach-launch-context.tsx +++ b/src/lib/insights/coach-launch-context.tsx @@ -5,11 +5,12 @@ import { useCallback, useContext, useMemo, + useRef, useState, type ReactNode, } from "react"; -import type { CoachScopeSource } from "@/lib/ai/coach/types"; +import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types"; /** * v1.4.27 R3d MB4 — Coach launch context. @@ -35,16 +36,30 @@ import type { CoachScopeSource } from "@/lib/ai/coach/types"; export interface CoachLaunchScope { /** * Optional metric the user is looking at when they open the Coach. - * Reserved for v1.4.28 so the drawer can pre-narrow the source rail - * to the active metric; ignored in v1.4.27. * * v1.4.28 R3c (BK-MED-2 / BK-F-M4) — narrowed from `string` to the * `CoachScopeSource` union so call sites cannot drift to a free-form * label that the sources rail then silently ignores. Adding a new * Apple-Health source to the rail (the union already covers the * v1.4.23 additions) automatically widens the allowed values here. + * + * v1.21.0 (C4 H1/H4) — now LIVE: the scope threads through the drawer + * into the chat request so a conversation opened from a metric surface + * or an insight card is pre-narrowed to the relevant source(s). The + * `void scope` discard is gone. */ metric?: CoachScopeSource; + /** + * Optional extra sources to include alongside `metric` — a correlation + * card spanning two metrics (e.g. weight × pulse) seeds both so the + * snapshot the Coach reads covers the relationship the card describes. + */ + also?: CoachScopeSource[]; + /** + * Optional day-window the conversation should anchor on. Defaults to + * the chat route's own `last30days` when omitted. + */ + window?: CoachScopeWindow; } interface CoachLaunchValue { @@ -52,8 +67,29 @@ interface CoachLaunchValue { open: boolean; /** Current prefill string (or null when the next open should start blank). */ prefill: string | null; + /** + * Scope the next/open conversation is narrowed to (or null for the + * default all-source snapshot). Set by `askCoach(prefill, scope)`, or + * inherited from the page's ambient scope when the FAB opens without an + * explicit one. `` forwards it to the drawer. + */ + scope: CoachLaunchScope | null; /** Open the drawer with an optional prefill + scope hint. */ askCoach: (prefill?: string | null, scope?: CoachLaunchScope) => void; + /** + * v1.21.0 (C4 H1) — register the metric surface the user is currently + * looking at so the global FAB (which calls `askCoach()` with no args) + * still opens the Coach pre-scoped to that page. The optional + * `seedPrefill` is the composer opener the FAB seeds when it inherits + * this ambient scope. Returns a cleanup that clears the ambient scope; a + * metric page calls it from an effect so the scope lifts when the user + * navigates away. This keeps the FAB the single app-wide entry point + * (CCH-04) while making it contextual. + */ + registerScope: ( + scope: CoachLaunchScope, + seedPrefill?: string | null, + ) => () => void; /** * Direct setter for the open state — the drawer's `onOpenChange` * consumes it on close. Kept (rather than collapsed into a @@ -74,30 +110,72 @@ export interface CoachLaunchProviderProps { export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { const [open, setOpen] = useState(false); const [prefill, setPrefill] = useState(null); + // Scope the open conversation is narrowed to. `null` → default snapshot. + const [scope, setScope] = useState(null); + // Ambient scope + seed opener of the metric surface currently on screen. + // The FAB's `askCoach()` (no args) falls back to these so opening the + // Coach from a metric page still lands a pre-scoped, pre-seeded + // conversation without re-adding a per-metric header icon (CCH-04). Refs, + // not state — registering must not re-render the provider's subtree. + const ambientScopeRef = useRef(null); + const ambientPrefillRef = useRef(null); const askCoach = useCallback( - (nextPrefill?: string | null, scope?: CoachLaunchScope) => { - // `scope` is currently reserved for v1.4.28 (metric-narrow on the - // sources rail). Accept the parameter today so the call sites - // don't need to change shape when the rail starts honouring it. - void scope; - setPrefill(nextPrefill ?? null); + (nextPrefill?: string | null, nextScope?: CoachLaunchScope) => { + // v1.21.0 (C4 H1/H4) — scope is live. An explicit scope (insight + // card, metric-card affordance) wins; otherwise inherit the metric + // page's ambient scope so the FAB opens contextual to where the user + // is. `null` only when neither is present (true global launch). The + // composer seed follows the same precedence: explicit prefill, else + // the ambient page opener that pairs with the inherited scope. + const usingAmbientScope = nextScope === undefined; + setScope(nextScope ?? ambientScopeRef.current ?? null); + setPrefill( + nextPrefill ?? + (usingAmbientScope ? ambientPrefillRef.current : null) ?? + null, + ); setOpen(true); }, [], ); + const registerScope = useCallback( + (next: CoachLaunchScope, seedPrefill?: string | null) => { + ambientScopeRef.current = next; + ambientPrefillRef.current = seedPrefill ?? null; + return () => { + // Only clear if this registration is still the active one — guards + // against a fast route change where the next page registers before + // the previous page's cleanup runs. + if (ambientScopeRef.current === next) { + ambientScopeRef.current = null; + ambientPrefillRef.current = null; + } + }; + }, + [], + ); + const handleSetOpen = useCallback((next: boolean) => { setOpen(next); if (!next) { - // Drop the prefill on close so the next open starts blank. + // Drop the prefill + scope on close so the next open starts clean. setPrefill(null); + setScope(null); } }, []); const value = useMemo( - () => ({ open, prefill, askCoach, setOpen: handleSetOpen }), - [open, prefill, askCoach, handleSetOpen], + () => ({ + open, + prefill, + scope, + askCoach, + registerScope, + setOpen: handleSetOpen, + }), + [open, prefill, scope, askCoach, registerScope, handleSetOpen], ); return ( From 93433c319724531d736c904106a8da0184fddb08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:38:03 +0200 Subject: [PATCH 17/51] feat(coach): add "ask the Coach about this" to insight cards Add a discreet, on-brand action to the high-value cards (briefing, recommendation, correlation, status/assessment, health score, period narrative) that opens the Coach pre-scoped to the card topic with a seeded question. The action self-gates on the operator flag and the per-user opt-out, never tints the card, and surfaces once per card. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../recommendation-card-confidence.test.tsx | 5 + .../__tests__/recommendation-card.test.tsx | 6 ++ .../__tests__/recommendations-grid.test.tsx | 5 + src/components/insights/ask-coach-action.tsx | 91 +++++++++++++++++++ src/components/insights/correlation-card.tsx | 70 ++++++++++---- src/components/insights/daily-briefing.tsx | 7 ++ src/components/insights/health-score-card.tsx | 11 +++ .../insights/insight-status-card.tsx | 21 +++++ .../insights/metric-status-card.tsx | 16 ++++ .../insights/period-narrative-card.tsx | 16 ++++ .../insights/recommendation-card.tsx | 19 ++++ .../__tests__/coach-cascade.test.tsx | 4 + .../__tests__/coach-user-disable.test.tsx | 5 + 19 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 src/components/insights/ask-coach-action.tsx diff --git a/messages/de.json b/messages/de.json index 2953dc13c..9d49cd290 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2286,7 +2286,8 @@ "thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat", "thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.", "tokensUsed": "{count} Tokens", - "tokensUsedWithModel": "{count} Tokens · {model}" + "tokensUsedWithModel": "{count} Tokens · {model}", + "askAboutThis": "Den Coach dazu fragen" }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", diff --git a/messages/en.json b/messages/en.json index dddad7809..ba04271ee 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2286,7 +2286,8 @@ "thinkingExpandAria": "Show what the Coach considered", "thinkingDetail": "Answered from your health snapshot.", "tokensUsed": "{count} tokens", - "tokensUsedWithModel": "{count} tokens · {model}" + "tokensUsedWithModel": "{count} tokens · {model}", + "askAboutThis": "Ask the coach about this" }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", diff --git a/messages/es.json b/messages/es.json index 9c7719abb..4596b221c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2286,7 +2286,8 @@ "thinkingExpandAria": "Mostrar lo que el Coach tuvo en cuenta", "thinkingDetail": "Respondido a partir de tu resumen de salud.", "tokensUsed": "{count} tokens", - "tokensUsedWithModel": "{count} tokens · {model}" + "tokensUsedWithModel": "{count} tokens · {model}", + "askAboutThis": "Pregúntale al coach sobre esto" }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", diff --git a/messages/fr.json b/messages/fr.json index dfabd04d3..82b1156d4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2286,7 +2286,8 @@ "thinkingExpandAria": "Montrer ce que le Coach a pris en compte", "thinkingDetail": "Réponse établie à partir de ton aperçu santé.", "tokensUsed": "{count} jetons", - "tokensUsedWithModel": "{count} jetons · {model}" + "tokensUsedWithModel": "{count} jetons · {model}", + "askAboutThis": "Interroger le coach à ce sujet" }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", diff --git a/messages/it.json b/messages/it.json index 53fd5ec0d..4e611a6ee 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2286,7 +2286,8 @@ "thinkingExpandAria": "Mostra cosa ha considerato il Coach", "thinkingDetail": "Risposta basata sul tuo riepilogo di salute.", "tokensUsed": "{count} token", - "tokensUsedWithModel": "{count} token · {model}" + "tokensUsedWithModel": "{count} token · {model}", + "askAboutThis": "Chiedi al coach" }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", diff --git a/messages/pl.json b/messages/pl.json index 5afe3bc9b..fda773d42 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2286,7 +2286,8 @@ "thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę", "thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.", "tokensUsed": "{count} tokenów", - "tokensUsedWithModel": "{count} tokenów · {model}" + "tokensUsedWithModel": "{count} tokenów · {model}", + "askAboutThis": "Zapytaj trenera o to" }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", diff --git a/src/components/insights/__tests__/recommendation-card-confidence.test.tsx b/src/components/insights/__tests__/recommendation-card-confidence.test.tsx index c45c48f1f..12f3edbaa 100644 --- a/src/components/insights/__tests__/recommendation-card-confidence.test.tsx +++ b/src/components/insights/__tests__/recommendation-card-confidence.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from "vitest"; +import { createContext } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import { RecommendationCard } from "../recommendation-card"; @@ -24,6 +25,10 @@ vi.mock("@tanstack/react-query", () => ({ error: null, }), useQueryClient: () => ({ invalidateQueries: vi.fn() }), + // v1.21.0 — the card footer's `` reaches the + // query-client mount probe; a null-default context reads as "no client + // mounted" so the Coach hooks return their fail-open defaults. + QueryClientContext: createContext(null), })); vi.mock("@/hooks/use-auth", () => ({ diff --git a/src/components/insights/__tests__/recommendation-card.test.tsx b/src/components/insights/__tests__/recommendation-card.test.tsx index 952f1068f..1fddb563d 100644 --- a/src/components/insights/__tests__/recommendation-card.test.tsx +++ b/src/components/insights/__tests__/recommendation-card.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from "vitest"; +import { createContext } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import { RecommendationCard } from "../recommendation-card"; @@ -66,6 +67,11 @@ vi.mock("@tanstack/react-query", () => ({ error: null, }), useQueryClient: () => ({ invalidateQueries: vi.fn() }), + // v1.21.0 — the card's footer `` reaches the + // query-client mount probe (`useFeatureFlags` / `useDisableCoach`); the + // probe only needs the context to exist. A null-default context reads + // as "no client mounted" so both hooks return their fail-open defaults. + QueryClientContext: createContext(null), })); vi.mock("@/hooks/use-auth", () => ({ diff --git a/src/components/insights/__tests__/recommendations-grid.test.tsx b/src/components/insights/__tests__/recommendations-grid.test.tsx index ce7400963..15dc74dcc 100644 --- a/src/components/insights/__tests__/recommendations-grid.test.tsx +++ b/src/components/insights/__tests__/recommendations-grid.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from "vitest"; +import { createContext } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; @@ -35,6 +36,10 @@ vi.mock("@tanstack/react-query", () => ({ error: null, }), useQueryClient: () => ({ invalidateQueries: vi.fn() }), + // v1.21.0 — the card footer's `` reaches the + // query-client mount probe; a null-default context reads as "no client + // mounted" so the Coach hooks return their fail-open defaults. + QueryClientContext: createContext(null), })); vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ diff --git a/src/components/insights/ask-coach-action.tsx b/src/components/insights/ask-coach-action.tsx new file mode 100644 index 000000000..1200ab309 --- /dev/null +++ b/src/components/insights/ask-coach-action.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { Sparkles } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; +import { + useCoachLaunch, + type CoachLaunchScope, +} from "@/lib/insights/coach-launch-context"; +import { useFeatureFlags } from "@/hooks/use-feature-flags"; +import { useDisableCoach } from "@/hooks/use-disable-coach"; + +/** + * v1.21.0 (C4 H2) — discreet "Ask the Coach about this" affordance for + * high-value insight / assessment cards (briefing, recommendation, + * correlation, status, health-score, period narrative). + * + * It is the reverse-direction entry point the C4 audit flagged as + * missing: the cards carry the richest context in the app, so a single + * small action per card opens the Coach pre-scoped to that card's topic + * with a seeded opener. Reuses the existing launch context — no parallel + * launch system. + * + * Subtle by construction: a ghost, muted-foreground text+icon button (no + * alarming colour, no card-tinting — the medication-card rule holds). One + * entry point per card, never a cluster. + * + * Self-gates on the same triple the FAB / inline pill use (provider + * mounted + operator flag + per-user opt-out) so a card never paints a + * dead Coach control. + */ +export interface AskCoachActionProps { + /** + * Composer seed question — a plain-English opener about the card's + * topic. The Coach treats it as composer seed text, not an i18n key + * (the chat route is EN/DE-gated), matching the empty-state convention. + */ + question: string; + /** + * Optional scope so the conversation narrows to the card's source(s). + * Omitted for cards that span the whole picture (e.g. the daily + * briefing), which read better against the default all-source snapshot. + */ + scope?: CoachLaunchScope; + /** Optional visible-label override; defaults to the shared CTA copy. */ + label?: string; + /** className passthrough for per-card alignment. */ + className?: string; +} + +export function AskCoachAction({ + question, + scope, + label, + className, +}: AskCoachActionProps) { + const { t } = useTranslations(); + const launch = useCoachLaunch(); + const flags = useFeatureFlags(); + const disableCoach = useDisableCoach(); + + // Same gate posture as / : render + // nothing unless the provider is mounted, the operator flag is on, and + // the user has not opted out. + if (!launch) return null; + if (!flags.coach) return null; + if (disableCoach) return null; + + const accessibleLabel = label ?? t("insights.coach.askAboutThis"); + + return ( + + ); +} diff --git a/src/components/insights/correlation-card.tsx b/src/components/insights/correlation-card.tsx index 697e9615e..b447f2d42 100644 --- a/src/components/insights/correlation-card.tsx +++ b/src/components/insights/correlation-card.tsx @@ -10,7 +10,9 @@ import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { CorrelationResult } from "@/lib/insights/correlations"; +import type { CoachScopeSource } from "@/lib/ai/coach/types"; import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge"; +import { AskCoachAction } from "./ask-coach-action"; /** * v1.4.20 phase B3 — single Correlation card. @@ -71,10 +73,36 @@ const CONFIDENCE_LABEL_KEY: Record<"low" | "moderate" | "high", string> = { low: "insights.correlationRow.confidenceLow", }; +// v1.21.0 (C4 H2) — the two sources each correlation pair spans, so the +// Coach hand-off narrows its snapshot to exactly the relationship the +// card describes. +const COACH_SOURCES_BY_KIND: Record< + CorrelationResult["kind"], + CoachScopeSource[] +> = { + "bp-compliance": ["bp", "compliance"], + "mood-pulse": ["mood", "pulse"], + "weight-weekday": ["weight"], +}; + +const COACH_QUESTION_BY_KIND: Record = { + "bp-compliance": + "How does my medication adherence relate to my blood pressure? Walk me through what this correlation means for me.", + "mood-pulse": + "Is there a real link between my mood and my pulse? Help me read this correlation.", + "weight-weekday": + "Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?", +}; + export function CorrelationCard({ result }: CorrelationCardProps) { const { t } = useTranslations(); const title = t(TITLE_KEY[result.kind]); const subtitle = t(SUBTITLE_KEY[result.kind]); + const coachSources = COACH_SOURCES_BY_KIND[result.kind]; + const coachScope = + coachSources.length > 0 + ? { metric: coachSources[0], also: coachSources.slice(1) } + : undefined; return ( - +

+ + {/* v1.21.0 (C4 H2) — the live Coach hand-off sits beside the + roadmap CTA: it scopes to both sources in the pair. */} + +
) : ( )} + {/* v1.21.0 (C4 H2) — hand off to the Coach for the whole + picture. The briefing spans every metric, so no scope: the + default all-source snapshot reads best here. */} +
+ +

) : noProvider ? ( // v1.15.20 — no provider configured anywhere: a regenerate CTA diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 558955bf8..1c92e616e 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -5,6 +5,7 @@ import { ArrowDown, ArrowUp, ChevronDown, Minus, Moon } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer"; +import { AskCoachAction } from "./ask-coach-action"; /** * v1.4.20 B5 — Personal Health Score panel. @@ -616,6 +617,16 @@ export function HealthScoreCard({ {/* v1.18.6 (DISC-01) — the "indicative, not a clinical assessment" card disclaimer is removed; the one-time onboarding acknowledgment now covers the not-a-diagnosis framing app-wide. */} + + {/* v1.21.0 (C4 H2) — the score is a whole-picture composite, so the + hand-off opens the Coach against the default snapshot (no scope) + seeded with the actual number. Mounts at the foot of the card as + a single discreet entry, not a primary CTA. */} +
+ +
); diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index a4682ed07..9281606ba 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -11,6 +11,8 @@ import { formatRelativeTime } from "@/lib/i18n/relative-time"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { cn } from "@/lib/utils"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; +import { AskCoachAction } from "@/components/insights/ask-coach-action"; +import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; // ─── Types ──────────────────────────────────────────────── @@ -56,6 +58,15 @@ interface InsightStatusCardProps { * card is being assembled, not stuck. The client polls until text lands. */ preparing?: boolean; + /** + * v1.21.0 (C4 H2) — opt-in "Ask the Coach about this assessment" hand-off. + * When `coachQuestion` is set (the metric-aware caller supplies it), the + * populated card renders a discreet Coach action seeded with that opener + * and narrowed to `coachScope` when known. Callers that omit it keep the + * card exactly as before — additive. + */ + coachQuestion?: string; + coachScope?: CoachLaunchScope; } // ─── Main Component ─────────────────────────────────────── @@ -68,6 +79,8 @@ export function InsightStatusCard({ updatedAt, loading = false, preparing = false, + coachQuestion, + coachScope, }: InsightStatusCardProps) { const { t } = useTranslations(); const flags = useFeatureFlags(); @@ -228,6 +241,14 @@ export function InsightStatusCard({ they roll forward. */} + {/* v1.21.0 (C4 H2) — discreet hand-off so the user can take the + assessment into a scoped Coach conversation. Only when the + caller supplied an opener. */} + {coachQuestion ? ( +
+ +
+ ) : null} ); diff --git a/src/components/insights/metric-status-card.tsx b/src/components/insights/metric-status-card.tsx index d91311599..5644db56b 100644 --- a/src/components/insights/metric-status-card.tsx +++ b/src/components/insights/metric-status-card.tsx @@ -7,6 +7,7 @@ import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; +import { scopeSourceFromMetricKey } from "@/components/insights/coach-metric-scope"; interface MetricStatusCardProps { /** The registry metric id the generic assessment route keys on. */ @@ -39,6 +40,19 @@ export function MetricStatusCard({ const mounted = useMounted(); const { data: status, isLoading } = useInsightMetricStatus(metric, enabled); + // v1.21.0 (C4 H2) — narrow the Coach hand-off to this metric's snapshot + // source when one exists; the registry id is a `MeasurementType`-style + // token, which `scopeSourceFromMetricKey` resolves for the metrics that + // carry a snapshot block. Unmapped metrics still get a metric-named + // opener, just against the default snapshot. + const coachSource = scopeSourceFromMetricKey(metric); + const coachQuestion = `Walk me through my ${metric + .toLowerCase() + .replace( + /_/g, + " ", + )} assessment — what does it mean and what should I do about it?`; + return ( `: the hook is // `enabled: isAuthenticated && enabled`, and both inputs can settle // before a late-hydrating boundary replays its first render — the diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx index 89647228f..dd2ccce1d 100644 --- a/src/components/insights/period-narrative-card.tsx +++ b/src/components/insights/period-narrative-card.tsx @@ -8,6 +8,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import { SectionHeading } from "@/components/insights/section-heading"; +import { AskCoachAction } from "@/components/insights/ask-coach-action"; import { Popover, PopoverContent, @@ -225,6 +226,21 @@ export function PeriodNarrativeCard({ })}

) : null} + + {/* v1.21.0 (C4 H2) — hand the period summary to the Coach. It spans + the whole picture for the window, so no scope: the default + snapshot reads best. */} + {narrative ? ( +
+ +
+ ) : null} ); diff --git a/src/components/insights/recommendation-card.tsx b/src/components/insights/recommendation-card.tsx index 794d44ad3..3ec2a930f 100644 --- a/src/components/insights/recommendation-card.tsx +++ b/src/components/insights/recommendation-card.tsx @@ -16,6 +16,8 @@ import { importWithRetry } from "@/lib/retry-import"; import { getMedicalReferenceById } from "@/lib/ai/medical-references"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import type { Locale } from "@/lib/i18n/config"; +import { AskCoachAction } from "./ask-coach-action"; +import { scopeSourceFromMetricKey } from "./coach-metric-scope"; import { RecommendationFeedback, type RecommendationFeedbackSeverity, @@ -349,6 +351,14 @@ export function RecommendationCard({ } : null; + // v1.21.0 (C4 H2) — reverse-direction Coach hand-off. The opener + // quotes the rec so the conversation lands on this exact suggestion; + // when the rec carries a metric source, scope the snapshot to it. + const coachScopeSource = scopeSourceFromMetricKey(norm.metricSource?.type); + const coachQuestion = `Tell me more about this recommendation: "${stripChartTokens( + norm.text, + )}" — why does it apply to me, and what should I do about it?`; + return (
)} + + {/* v1.21.0 (C4 H2) — discreet "ask the Coach about this rec" link, + aligned under the rec text (the number gutter is `ml-6`). */} +
+ +
); } diff --git a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx index 9b400dcfd..b45b9faac 100644 --- a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx @@ -271,6 +271,10 @@ describe("Coach disable cascade invariant", () => { const KNOWN_COACH_GATE_SITES: ReadonlyArray = [ // Coach-bearing surfaces mounted directly by the cascade fixture. "src/components/insights/coach-launch-button.tsx", + // v1.21.0 (C4 H2) — the reverse-direction "Ask the Coach about this" + // card affordance gates on `flags.coach` (operator master) like the + // launch button, returning null when the operator turns the Coach off. + "src/components/insights/ask-coach-action.tsx", // v1.18.7 — hero-strip.tsx no longer reads `flags.coach`: its coach // action button + suggested-prompt strip were removed from the band. "src/components/insights/layout-coach-fab.tsx", diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index 2675bc92e..63942e5ec 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -282,6 +282,11 @@ describe("Coach per-user disableCoach invariant", () => { // fixture above; each one calls `useDisableCoach()` and short- // circuits to `null` when the per-user flag is on. "src/components/insights/coach-launch-button.tsx", + // v1.21.0 (C4 H2) — the reverse-direction "Ask the Coach about this" + // card affordance mirrors the launch-button gate: operator master flag + // OR per-user opt-out short-circuits it to `null` so a card never paints + // a dead Coach control. + "src/components/insights/ask-coach-action.tsx", // v1.18.7 — hero-strip.tsx no longer gates on the Coach flag: its // action button + suggested-prompt strip were removed, so it dropped // the `useDisableCoach` call entirely. From 8ed1d27614c5a4657636773cd7f56d7a262b390e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:42:52 +0200 Subject: [PATCH 18/51] chore(openapi): regenerate for the date-format profile field --- docs/api/openapi.yaml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e0dec07ce..2467d9676 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8184,6 +8184,15 @@ components: - AUTO - H12 - H24 + dateFormat: + description: Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy), + MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd. + type: string + enum: + - AUTO + - DMY + - MDY + - YMD moodReminderEnabled: type: boolean fullName: @@ -15784,6 +15793,15 @@ components: - H12 - H24 description: Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour. + dateFormat: + type: string + enum: + - AUTO + - DMY + - MDY + - YMD + description: Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy), + MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd. moodReminderEnabled: type: boolean fullName: @@ -15815,6 +15833,7 @@ components: - locale - timezone - timeFormat + - dateFormat - moodReminderEnabled - fullName - insurerName @@ -15885,6 +15904,15 @@ components: - H12 - H24 description: Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour. + dateFormat: + type: string + enum: + - AUTO + - DMY + - MDY + - YMD + description: Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy), + MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd. moodReminderEnabled: type: boolean fullName: @@ -15912,6 +15940,7 @@ components: - locale - timezone - timeFormat + - dateFormat - moodReminderEnabled - fullName - insurerName From 9a3049a6cf257534b0c2b0a291951f46fafc92d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:52:52 +0200 Subject: [PATCH 19/51] fix(coach): phrase the daily-limit reset as midnight UTC The budget-exceeded copy named "00:00 UTC", which reads as a wrong local clock for any non-UTC user (Berlin midnight UTC is 01:00/02:00 local) and drove confused "the limit resets at the wrong time" reports. Reword dailyLimitBody across all six locales to "midnight UTC", matching the existing errorBudget wording, so the figure is honest regardless of the reader's timezone. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- .../insights/coach-panel/__tests__/message-thread.test.tsx | 4 +++- 7 files changed, 9 insertions(+), 7 deletions(-) diff --git a/messages/de.json b/messages/de.json index 9d49cd290..277b88ac3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2224,7 +2224,7 @@ "feedbackThanks": "Danke für das Signal.", "feedbackError": "Feedback konnte nicht gespeichert werden", "dailyLimitTitle": "Heute-Limit erreicht", - "dailyLimitBody": "Heute-Limit erreicht; Reset um 00:00 UTC.", + "dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.", "providerRateLimitTitle": "Provider rate-limited", "providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min.", "cluster": { diff --git a/messages/en.json b/messages/en.json index ba04271ee..bf05239fc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2224,7 +2224,7 @@ "feedbackThanks": "Thanks for the signal.", "feedbackError": "Could not save feedback", "dailyLimitTitle": "Daily limit reached", - "dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.", + "dailyLimitBody": "Daily limit reached. The budget refreshes at midnight UTC.", "providerRateLimitTitle": "Provider rate-limited", "providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.", "cluster": { diff --git a/messages/es.json b/messages/es.json index 4596b221c..bb1b6992f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2222,7 +2222,7 @@ "feedbackThanks": "Gracias por la indicación.", "feedbackError": "No se pudo guardar el comentario", "dailyLimitTitle": "Límite diario alcanzado", - "dailyLimitBody": "Límite diario alcanzado; reinicio a las 00:00 UTC.", + "dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche (UTC).", "providerRateLimitTitle": "Proveedor con límite de tasa", "providerRateLimitBody": "El proveedor está temporalmente saturado; reinicio en ~5 min.", "settingsContextLabel": "Enviar contexto", diff --git a/messages/fr.json b/messages/fr.json index 82b1156d4..fb09bcfa2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2222,7 +2222,7 @@ "feedbackThanks": "Merci pour l’indication.", "feedbackError": "Impossible d'enregistrer le retour", "dailyLimitTitle": "Limite quotidienne atteinte", - "dailyLimitBody": "Limite quotidienne atteinte ; réinitialisation à 00:00 UTC.", + "dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit (UTC).", "providerRateLimitTitle": "Fournisseur limité en débit", "providerRateLimitBody": "Le fournisseur est temporairement saturé ; réinitialisation dans ~5 min.", "settingsContextLabel": "Envoyer le contexte", diff --git a/messages/it.json b/messages/it.json index 4e611a6ee..9c2a5496c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2222,7 +2222,7 @@ "feedbackThanks": "Grazie per il segnale.", "feedbackError": "Impossibile salvare il feedback", "dailyLimitTitle": "Limite giornaliero raggiunto", - "dailyLimitBody": "Limite giornaliero raggiunto; reset alle 00:00 UTC.", + "dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte (UTC).", "providerRateLimitTitle": "Provider con rate-limit", "providerRateLimitBody": "Il provider è temporaneamente sovraccarico; reset in ~5 min.", "settingsContextLabel": "Invia contesto", diff --git a/messages/pl.json b/messages/pl.json index fda773d42..e4c29338e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2222,7 +2222,7 @@ "feedbackThanks": "Dzięki za sygnał.", "feedbackError": "Nie udało się zapisać opinii", "dailyLimitTitle": "Osiągnięto dzienny limit", - "dailyLimitBody": "Osiągnięto dzienny limit; reset o 00:00 UTC.", + "dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy (UTC).", "providerRateLimitTitle": "Dostawca z ograniczeniem", "providerRateLimitBody": "Dostawca jest tymczasowo przeciążony; reset za ~5 min.", "settingsContextLabel": "Wysyłaj kontekst", diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index dd9356ed3..03b093499 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -618,7 +618,9 @@ describe("", () => { })} />, ); - expect(html).toContain("Daily limit reached; resets at 00:00 UTC."); + expect(html).toContain( + "Daily limit reached. The budget refreshes at midnight UTC.", + ); // v1.4.33 IW7 — fallback copy rewritten ("AI provider" -> "Insights // provider"). Both old and new strings should be absent here when // the budget-exceeded error takes precedence. From 378334850610f88a9adb93a810c64c688dcbdccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:53:04 +0200 Subject: [PATCH 20/51] fix(coach): make the daily token budget provider-aware The daily AI-token gate was a flat 25,000/day for every provider. That cap exists to bound the OPERATOR's API bill, but a ChatGPT-OAuth (Codex) or BYOK chain egresses on the USER's own plan/key and costs the operator nothing, so gating it on the operator-cost ceiling is wrong. A single gpt-5.x reasoning turn legitimately reports 20k-40k gross total_tokens (re-sent system prompt + inventory + tool defs per round + hidden reasoning, summed across rounds), so one or two turns exhausted the flat cap and locked the user out of a plan they pay for. Classify the chain's cost owner by its primary provider via resolveDailyCap: an admin-openai primary (operator pays) keeps the operator-cost cap; every user-egress primary (codex/openai/anthropic/local) gets a generous abuse-only ceiling. Threaded into the Coach chat gate and both OCR-extract modes. Raise the operator-cost cap 25,000 -> 200,000 so the operator-key path also survives a normal day of reasoning turns, keeping gross-token accounting rather than reworking the reconcile math. Subtract cached input tokens at reconcile: the Responses-API gross total still includes prompt-cached input the user did not re-pay for. The codex client already parsed cached_tokens; sum it through the tool loop and bill total_tokens minus cached. --- .../chat/__tests__/route-module-gate.test.ts | 1 + .../__tests__/route-snapshot-once.test.ts | 1 + .../chat/__tests__/route-tool-mode.test.ts | 21 ++- src/app/api/insights/chat/route.ts | 14 ++ .../labs/ocr/extract/__tests__/route.test.ts | 3 + src/app/api/labs/ocr/extract/route.ts | 9 ++ src/lib/ai/coach/__tests__/budget.test.ts | 138 +++++++++++++++++- src/lib/ai/coach/budget.ts | 73 ++++++++- src/lib/ai/coach/tools/loop.ts | 9 ++ 9 files changed, 262 insertions(+), 7 deletions(-) diff --git a/src/app/api/insights/chat/__tests__/route-module-gate.test.ts b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts index 8b6c82fd3..6c92359fb 100644 --- a/src/app/api/insights/chat/__tests__/route-module-gate.test.ts +++ b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts @@ -94,6 +94,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(), enforceBudget: vi.fn(), recordSpend: vi.fn(), + resolveDailyCap: vi.fn(() => 200_000), })); vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ diff --git a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts index ef6332dde..eeaea14f3 100644 --- a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts +++ b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts @@ -100,6 +100,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-06-21"), reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 1500 })), reconcileSpend: vi.fn(async () => undefined), + resolveDailyCap: vi.fn(() => 2_000_000), })); vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn(() => ({ refuse: false })), diff --git a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts index 3f4a1e17d..c38816b39 100644 --- a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts +++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts @@ -111,6 +111,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-06-21"), reserveBudget, reconcileSpend, + resolveDailyCap: vi.fn(() => 2_000_000), })); const { detectRefusal } = vi.hoisted(() => ({ @@ -245,8 +246,14 @@ describe("coach chat — tool-mode routing (F1)", () => { { providerType: "anthropic", instance: {} }, ]); await post(chatReq({ message: "How is my BP?" })); - // maxTokens (1500) × MAX_ROUNDS (2). - expect(reserveBudget).toHaveBeenCalledWith("u1", 3000, "2026-06-21"); + // maxTokens (1500) × MAX_ROUNDS (2). The 4th arg is the provider-aware + // daily cap (F1) — mocked to the user-plan ceiling for this BYOK chain. + expect(reserveBudget).toHaveBeenCalledWith( + "u1", + 3000, + "2026-06-21", + 2_000_000, + ); }); it("persists the tool trace onto provenance", async () => { @@ -321,8 +328,14 @@ describe("coach chat — tool-mode routing (F1)", () => { expect(runCoachToolLoop).not.toHaveBeenCalled(); expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1); - // Single-round budget reservation in the fallback path. - expect(reserveBudget).toHaveBeenCalledWith("u1", 1500, "2026-06-21"); + // Single-round budget reservation in the fallback path; the 4th arg is the + // provider-aware daily cap (F1). + expect(reserveBudget).toHaveBeenCalledWith( + "u1", + 1500, + "2026-06-21", + 2_000_000, + ); // The legacy path ships the snapshot figures in the user turn. const params = ( (runRawCompletionWithFallback.mock.calls[0] as unknown[])[0] as { diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 9fca7a8d8..13d02b2f4 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -64,6 +64,7 @@ import { buildDateKey, reserveBudget, reconcileSpend, + resolveDailyCap, } from "@/lib/ai/coach/budget"; import { detectRefusal } from "@/lib/ai/coach/refusal"; import { @@ -503,6 +504,12 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; // so reserve the per-call ceiling × the round count up front and reconcile // the SUMMED actual tokens afterwards. The atomic reserve/reconcile // primitives are unchanged; only the reserved amount scales. + // v1.21.0 (F1) — the daily ceiling is the OPERATOR's cost cap only when the + // chain egresses via the operator's own key (`admin-openai` primary). A + // ChatGPT-OAuth/Codex or BYOK chain runs on the user's OWN plan/key and costs + // the operator nothing, so it gets the generous user-plan ceiling — gating it + // on the operator-cost cap would lock the user out of a plan they pay for. + const dailyCap = resolveDailyCap(chain); const reqDateKey = buildDateKey(); const reservation = await reserveBudget( userId, @@ -510,6 +517,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; ? AI_BUDGETS.coach.maxTokens * MAX_ROUNDS : AI_BUDGETS.coach.maxTokens, reqDateKey, + dailyCap, ); if (!reservation.allowed) { annotate({ @@ -526,6 +534,9 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; // prose number-verifier. Empty on the no-tools path. let toolResultPayloads: unknown[] = []; let totalTokensSpent: number; + // v1.21.0 (F3) — cached-input tokens to subtract at reconcile (prompt-cached + // input the user did not re-pay for must not be billed to the daily meter). + let cachedTokensSpent = 0; try { if (toolMode) { // v1.20.0 (F1) — base context: the full system prompt + a tool-mode @@ -565,6 +576,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch toolTrace = loop.toolTrace; toolResultPayloads = (loop.toolResults ?? []).map((r) => r.data); totalTokensSpent = loop.totalTokens; + cachedTokensSpent = loop.cachedTokens; } else { const fallback = await runRawCompletionWithFallback({ userId, @@ -587,6 +599,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch result = fallback.result; workingProviderType = fallback.workingProvider.providerType; totalTokensSpent = result.tokensUsed ?? 0; + cachedTokensSpent = result.cachedInputTokens ?? 0; } } catch (err) { // The provider chain failed outright — no tokens were billed, so refund @@ -643,6 +656,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch reservation.reserved, totalTokensSpent, reqDateKey, + cachedTokensSpent, ).catch(() => { // Ledger reconcile is best-effort; a failure leaves the conservative // reservation in place (never an undercount) and never breaks the turn. diff --git a/src/app/api/labs/ocr/extract/__tests__/route.test.ts b/src/app/api/labs/ocr/extract/__tests__/route.test.ts index 055a56a1f..f4617a05c 100644 --- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts @@ -41,6 +41,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-06-26"), reserveBudget: vi.fn(), reconcileSpend: vi.fn().mockResolvedValue(undefined), + resolveDailyCap: vi.fn(() => 200_000), })); vi.mock("@/lib/labs/ocr-extract", async () => { const actual = await vi.importActual( @@ -106,6 +107,8 @@ describe("POST /api/labs/ocr/extract — text mode budget", () => { "user-1", AI_BUDGETS.ocrExtractText.maxTokens, "2026-06-26", + // F1 — the provider-aware daily cap (mocked) is threaded as the 4th arg. + 200_000, ); }); diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index e35b710d6..ebddbf99c 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -35,6 +35,7 @@ import { buildDateKey, reconcileSpend, reserveBudget, + resolveDailyCap, } from "@/lib/ai/coach/budget"; import { prisma } from "@/lib/db"; import { @@ -142,11 +143,15 @@ async function handleTextExtract( // a vision call, so it reserves the proportionate text ceiling rather than // the vision budget. Over-charging the vision rate for a text call would // exhaust the day budget against spend that never happened. + // v1.21.0 (F1) — the operator-cost cap applies only when the picked provider + // egresses on the operator's own key; a BYOK / Codex / local pick runs on the + // user's own plan and gets the generous user-plan ceiling. const dateKey = buildDateKey(); const reservation = await reserveBudget( userId, AI_BUDGETS.ocrExtractText.maxTokens, dateKey, + resolveDailyCap([{ providerType: pick.entry.providerType }]), ); if (!reservation.allowed) { annotate({ @@ -234,11 +239,15 @@ async function handleVisionExtract( } // 4. Reserve the day's budget BEFORE the provider call (atomic, TOCTOU-safe). + // v1.21.0 (F1) — the operator-cost cap applies only when the picked vision + // provider egresses on the operator's own key; a BYOK / Codex pick runs on + // the user's own plan and gets the generous user-plan ceiling. const dateKey = buildDateKey(); const reservation = await reserveBudget( userId, AI_BUDGETS.ocrExtract.maxTokens, dateKey, + resolveDailyCap([{ providerType: pick.entry.providerType }]), ); if (!reservation.allowed) { annotate({ diff --git a/src/lib/ai/coach/__tests__/budget.test.ts b/src/lib/ai/coach/__tests__/budget.test.ts index aca47c40b..267cb1dcf 100644 --- a/src/lib/ai/coach/__tests__/budget.test.ts +++ b/src/lib/ai/coach/__tests__/budget.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { HttpError } from "@/lib/api-handler"; -import { buildDateKey, MAX_TOKENS_PER_USER_PER_DAY } from "../budget"; +import { + buildDateKey, + MAX_TOKENS_PER_USER_PER_DAY, + OPERATOR_COST_CAP, + USER_PLAN_CAP, + resolveDailyCap, +} from "../budget"; vi.mock("@/lib/db", () => ({ prisma: { @@ -9,6 +15,8 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(), upsert: vi.fn(), }, + $executeRaw: vi.fn(), + $queryRaw: vi.fn(), }, })); @@ -105,3 +113,131 @@ describe("budget", () => { expect(args.create.totalTokens).toBe(12); }); }); + +describe("resolveDailyCap (F1 — provider-aware cap)", () => { + it("applies the operator-cost cap to an operator-key (admin-openai) primary", () => { + expect(resolveDailyCap([{ providerType: "admin-openai" }])).toBe( + OPERATOR_COST_CAP, + ); + }); + + it("applies the generous user-plan cap to a ChatGPT-OAuth (codex) primary", () => { + expect(resolveDailyCap([{ providerType: "codex" }])).toBe(USER_PLAN_CAP); + }); + + it("applies the user-plan cap to BYOK openai / anthropic / local primaries", () => { + expect(resolveDailyCap([{ providerType: "openai" }])).toBe(USER_PLAN_CAP); + expect(resolveDailyCap([{ providerType: "anthropic" }])).toBe( + USER_PLAN_CAP, + ); + expect(resolveDailyCap([{ providerType: "local" }])).toBe(USER_PLAN_CAP); + }); + + it("classifies on the PRIMARY entry — a user-egress chain with an admin-openai fallback stays user-plan", () => { + expect( + resolveDailyCap([ + { providerType: "codex" }, + { providerType: "admin-openai" }, + ]), + ).toBe(USER_PLAN_CAP); + }); + + it("defaults an empty chain to the conservative operator cap", () => { + expect(resolveDailyCap([])).toBe(OPERATOR_COST_CAP); + }); +}); + +describe("reserveBudget cap (F1 — user-plan path not locked out)", () => { + let prismaMock: { + $queryRaw: ReturnType; + $executeRaw: ReturnType; + }; + + beforeEach(async () => { + const dbModule = await import("@/lib/db"); + prismaMock = dbModule.prisma as unknown as typeof prismaMock; + prismaMock.$queryRaw.mockReset(); + prismaMock.$executeRaw.mockReset(); + }); + + it("a user-plan chain does NOT trip after a spend that exceeds the operator cap", async () => { + // Prior spend well past the 200k operator cap, but under the user-plan cap. + const priorSpend = OPERATOR_COST_CAP + 50_000; + prismaMock.$queryRaw.mockResolvedValue([ + { total_tokens: priorSpend + 1_200 }, + ]); + const { reserveBudget } = await import("../budget"); + const res = await reserveBudget( + "u", + 1_200, + "2026-05-10", + resolveDailyCap([{ providerType: "codex" }]), + ); + expect(res.allowed).toBe(true); + // The reservation upsert ran; no refund executeRaw fired. + expect(prismaMock.$executeRaw).not.toHaveBeenCalled(); + }); + + it("the operator-key path STILL trips once prior spend reaches the operator cap", async () => { + const priorSpend = OPERATOR_COST_CAP; + prismaMock.$queryRaw.mockResolvedValue([ + { total_tokens: priorSpend + 1_200 }, + ]); + prismaMock.$executeRaw.mockResolvedValue(0); + const { reserveBudget } = await import("../budget"); + const res = await reserveBudget( + "u", + 1_200, + "2026-05-10", + resolveDailyCap([{ providerType: "admin-openai" }]), + ); + expect(res.allowed).toBe(false); + // Refund of the reservation fired on refusal. + expect(prismaMock.$executeRaw).toHaveBeenCalled(); + }); +}); + +describe("reconcileSpend cached-token subtraction (F3)", () => { + let prismaMock: { $executeRaw: ReturnType }; + + beforeEach(async () => { + const dbModule = await import("@/lib/db"); + prismaMock = dbModule.prisma as unknown as typeof prismaMock; + prismaMock.$executeRaw.mockReset(); + prismaMock.$executeRaw.mockResolvedValue(0); + }); + + it("bills total_tokens minus cached input as the signed delta", async () => { + const { reconcileSpend } = await import("../budget"); + // reserved 1200, gross 20000, cached 13000 → net actual 7000 → delta 5800. + await reconcileSpend("u", 1_200, 20_000, "2026-05-10", 13_000); + expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1); + // The tagged-template interpolations carry the delta; assert it is 5800. + const interpolations = prismaMock.$executeRaw.mock.calls[0].slice(1); + expect(interpolations).toContain(5_800); + }); + + it("clamps a cached count larger than gross to a zero charge (delta = -reserved)", async () => { + const { reconcileSpend } = await import("../budget"); + await reconcileSpend("u", 1_200, 5_000, "2026-05-10", 9_999); + const interpolations = prismaMock.$executeRaw.mock.calls[0].slice(1); + // net actual clamped to 0 → delta = 0 - 1200 = -1200. + expect(interpolations).toContain(-1_200); + }); + + it("defaults cachedTokens to 0 (back-compat) — bills gross", async () => { + const { reconcileSpend } = await import("../budget"); + await reconcileSpend("u", 1_000, 4_000, "2026-05-10"); + const interpolations = prismaMock.$executeRaw.mock.calls[0].slice(1); + expect(interpolations).toContain(3_000); + }); +}); + +describe("MAX_TOKENS_PER_USER_PER_DAY (F2 — reasoning-aware operator cap)", () => { + it("is sized for reasoning turns, not a single non-reasoning reply", () => { + // Was 25_000 (≈ one gpt-5.x reasoning turn). Raised so the operator-key + // path survives a normal day of reasoning turns. + expect(MAX_TOKENS_PER_USER_PER_DAY).toBeGreaterThanOrEqual(150_000); + expect(OPERATOR_COST_CAP).toBe(MAX_TOKENS_PER_USER_PER_DAY); + }); +}); diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index 02df866e7..f52f52340 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -16,8 +16,63 @@ */ import { prisma } from "@/lib/db"; import { HttpError } from "@/lib/api-handler"; +import type { ProviderChainType } from "@/lib/ai/provider-chain"; -export const MAX_TOKENS_PER_USER_PER_DAY = 25_000; +/** + * Operator-cost daily ceiling — the cap that protects the OPERATOR's LLM bill. + * + * It applies ONLY to the operator-managed-key path (`admin-openai`): the + * server's own OpenAI key, where every token the user spends lands on the + * operator's invoice. + * + * v1.21.0 (F1/F2) — raised from the historical 25_000. That figure was sized + * for a single non-reasoning ~600-token chat reply (~20 turns/day). With the + * v1.20 tool loop charging the Responses-API gross `total_tokens` of a `gpt-5.x` + * reasoning turn (re-sent system prompt + inventory + 7 tool defs per round + + * hidden reasoning, summed across rounds ≈ 20k–40k), 25k locked the user out + * after one turn. 200k keeps gross accounting (no per-round output-token + * surgery) while leaving room for a normal day of reasoning turns on the + * operator's key. The cap stays a real ceiling on the operator's exposure. + */ +export const MAX_TOKENS_PER_USER_PER_DAY = 200_000; + +/** + * v1.21.0 (F1) — historical name retained for any external reference; the + * operator-cost cap is the only cap the ledger enforces. + */ +export const OPERATOR_COST_CAP = MAX_TOKENS_PER_USER_PER_DAY; + +/** + * v1.21.0 (F1) — the daily ceiling for a chain whose egress runs on the + * USER's own plan / key (ChatGPT-OAuth/Codex, BYOK OpenAI/Anthropic, or a + * self-hosted local model). The operator pays nothing for these, so the + * operator-cost cap is a category error here — gating them on it locks a user + * out of a plan they pay for. We keep only a generous abuse ceiling so a + * runaway client loop can't write unbounded rows; a normal user never reaches + * it. + */ +export const USER_PLAN_CAP = 2_000_000; + +/** + * v1.21.0 (F1) — classify a resolved provider chain's cost owner and return + * the daily cap that applies. + * + * The chain is a fallback list; the FIRST entry is the provider that will be + * tried first and is the expected cost owner. Only when that primary provider + * is `admin-openai` (the user has no personal provider and falls back to the + * operator's key) does the operator pay — so the operator-cost cap applies. + * Every other primary (`codex` / `openai` / `anthropic` / `local`) is the + * user's own egress, so the generous user-plan cap applies. An empty chain + * (no provider resolved) defaults to the operator cap — the conservative side. + */ +export function resolveDailyCap( + chain: ReadonlyArray<{ providerType: ProviderChainType }>, +): number { + const primary = chain[0]?.providerType; + return primary === "admin-openai" || primary === undefined + ? OPERATOR_COST_CAP + : USER_PLAN_CAP; +} /** * Build the UTC day-key for a given clock. Defaults to "now". @@ -136,17 +191,31 @@ export async function reserveBudget( * goes negative). Called after every provider call — including empty / * sentinel / refusal replies, whose upstream tokens were still burned * (SENIOR-DEV MEDIUM: spend undercount). + * + * v1.21.0 (F3) — `cachedTokens` (the Responses-API `cached_tokens` count) is + * subtracted from the charged amount. The gross `total_tokens` a reasoning + * provider reports still includes the full input even when prompt-caching + * served most of it cheaply / free; charging the user's daily meter for input + * they did not re-pay for is an over-charge. We bill `actual - cached`. */ export async function reconcileSpend( userId: string, reserved: number, actualTokens: number, dateKey: string = buildDateKey(), + cachedTokens = 0, ): Promise { - const actual = + const grossActual = Number.isFinite(actualTokens) && actualTokens > 0 ? Math.floor(actualTokens) : 0; + const cached = + Number.isFinite(cachedTokens) && cachedTokens > 0 + ? Math.floor(cachedTokens) + : 0; + // Bill net of cached input; clamp so a cached count larger than the gross + // (shouldn't happen, but the wire is untrusted) can't drive a negative charge. + const actual = Math.max(0, grossActual - cached); const delta = actual - reserved; if (delta === 0) return; // Clamp at zero so a smaller-than-reserved actual can't drive the row diff --git a/src/lib/ai/coach/tools/loop.ts b/src/lib/ai/coach/tools/loop.ts index 5e8208e4a..852813e67 100644 --- a/src/lib/ai/coach/tools/loop.ts +++ b/src/lib/ai/coach/tools/loop.ts @@ -45,6 +45,12 @@ export interface CoachToolLoopResult { workingProviderType: string; /** Summed tokens across every round (for budget reconcile). */ totalTokens: number; + /** + * v1.21.0 (F3) — summed cached-input tokens across every round. Subtracted + * from the charged amount at reconcile so prompt-cached input the user did + * not re-pay for is not billed to their daily meter. + */ + cachedTokens: number; /** Number of model round-trips made. */ rounds: number; /** Which tools ran + whether each found data (persisted onto provenance). */ @@ -86,6 +92,7 @@ export async function runCoachToolLoop(args: { const messages: AiMessage[] = [...args.messages]; let totalTokens = 0; + let cachedTokens = 0; let rounds = 0; let workingProviderType = ""; const toolTrace: CoachToolTrace[] = []; @@ -116,6 +123,7 @@ export async function runCoachToolLoop(args: { const result = fallback.result; workingProviderType = fallback.workingProvider.providerType; totalTokens += result.tokensUsed ?? 0; + cachedTokens += result.cachedInputTokens ?? 0; const calls = result.toolCalls ?? []; const wantsTools = @@ -136,6 +144,7 @@ export async function runCoachToolLoop(args: { result, workingProviderType, totalTokens, + cachedTokens, rounds, toolTrace, toolResults, From f2a97166712b95214cdb3484939a95105da23cb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:58:50 +0200 Subject: [PATCH 21/51] fix(safety): unify clinical floors into one source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hypertensive-crisis floors were defined in three modules with a divergent diastolic value: the notification engine and the Coach acute clause used 180/120, but the dashboard hero used 180/110. A reading like 170/112 lit the red critical-BP banner on the hero yet never tripped the notification alarm or matched the Coach's stated acute number — one surface said crisis, the other two stayed calm on the same row. Promote the absolute floors into a dependency-free clinical-floors leaf and have the hero, the safety-floor engine, the status registry's fever band, and the illness escalation all import from it. The crisis diastolic floor is the guideline-correct 120 (ACC/AHA hypertensive urgency); the wider 110 hero net is dropped so the surfaces tell one story. A coverage test pins that every consumer resolves the same constants. --- src/lib/__tests__/clinical-floors.test.ts | 69 ++++++++++++++++++ src/lib/clinical-floors.ts | 81 +++++++++++++++++++++ src/lib/dashboard/__tests__/verdict.test.ts | 23 ++++-- src/lib/dashboard/verdict.ts | 17 +++-- src/lib/illness/correlation.ts | 8 +- src/lib/illness/safety-floors.ts | 27 +++++-- src/lib/insights/metric-status-registry.ts | 15 +++- 7 files changed, 218 insertions(+), 22 deletions(-) create mode 100644 src/lib/__tests__/clinical-floors.test.ts create mode 100644 src/lib/clinical-floors.ts diff --git a/src/lib/__tests__/clinical-floors.test.ts b/src/lib/__tests__/clinical-floors.test.ts new file mode 100644 index 000000000..c22b4e4b8 --- /dev/null +++ b/src/lib/__tests__/clinical-floors.test.ts @@ -0,0 +1,69 @@ +/** + * D3-H1 coverage — the clinical safety floors are ONE source of truth. + * + * Before v1.21.0 the "is this BP a crisis?" floors lived in three modules with + * a divergent diastolic value (notification engine + Coach 180/120, dashboard + * hero 180/110), so a 170/112 reading lit the hero banner yet never tripped the + * alarm. This suite pins that every consumer now resolves the SAME canonical + * constants from `@/lib/clinical-floors`, so the three surfaces can never drift + * apart again without a test failure. + */ +import { describe, it, expect } from "vitest"; + +import { + BP_SYS_CRITICAL, + BP_DIA_CRITICAL, + BP_SYS_HYPOTENSIVE_FLOOR, + GLUCOSE_HYPO_FLOOR, + GLUCOSE_HYPO_SEVERE_FLOOR, + GLUCOSE_HYPER_FLOOR, + FEVER_BAND_C, + FEVER_RED_FLAG_C, + SPO2_RED_FLAG_PCT, +} from "@/lib/clinical-floors"; +import { + BP_SYS_HYPERTENSIVE, + BP_DIA_HYPERTENSIVE, + BP_SYS_HYPOTENSIVE, + GLUCOSE_HYPO, + GLUCOSE_HYPO_SEVERE, + GLUCOSE_HYPER, +} from "@/lib/illness/safety-floors"; +import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry"; + +describe("clinical-floors — single source of truth", () => { + it("uses the guideline-correct hypertensive-crisis floors (180/120)", () => { + // ACC/AHA hypertensive-urgency floor: the diastolic floor is 120, NOT the + // former hero-only 110 that fired the banner on calmer readings. + expect(BP_SYS_CRITICAL).toBe(180); + expect(BP_DIA_CRITICAL).toBe(120); + }); + + it("safety-floors notification engine re-exports the canonical constants", () => { + expect(BP_SYS_HYPERTENSIVE).toBe(BP_SYS_CRITICAL); + expect(BP_DIA_HYPERTENSIVE).toBe(BP_DIA_CRITICAL); + expect(BP_SYS_HYPOTENSIVE).toBe(BP_SYS_HYPOTENSIVE_FLOOR); + expect(GLUCOSE_HYPO).toBe(GLUCOSE_HYPO_FLOOR); + expect(GLUCOSE_HYPO_SEVERE).toBe(GLUCOSE_HYPO_SEVERE_FLOOR); + expect(GLUCOSE_HYPER).toBe(GLUCOSE_HYPER_FLOOR); + }); + + it("the status registry's fever band binds to the canonical FEVER_BAND_C", () => { + expect(getMetricStatusMeta("BODY_TEMPERATURE")?.feverBandC).toBe( + FEVER_BAND_C, + ); + }); + + it("the single-reading fever band sits below the sustained-fever escalation", () => { + // Two intentional lines for two questions — but both from one module, so + // they are visibly a pair (D3-L1), not unrelated magic numbers. + expect(FEVER_BAND_C).toBeLessThan(FEVER_RED_FLAG_C); + }); + + it("keeps the documented glucose + SpO2 floors stable", () => { + expect(GLUCOSE_HYPO_FLOOR).toBe(70); + expect(GLUCOSE_HYPO_SEVERE_FLOOR).toBe(54); + expect(GLUCOSE_HYPER_FLOOR).toBe(250); + expect(SPO2_RED_FLAG_PCT).toBe(92); + }); +}); diff --git a/src/lib/clinical-floors.ts b/src/lib/clinical-floors.ts new file mode 100644 index 000000000..c31282d56 --- /dev/null +++ b/src/lib/clinical-floors.ts @@ -0,0 +1,81 @@ +/** + * Canonical clinical safety floors — ONE source of truth (v1.21.0, D3-H1). + * + * Before this module the "is this a crisis?" thresholds were defined in three + * places with a divergent diastolic floor: the safety-floor notification engine + * and the Coach acute clause used 180/120, but the dashboard hero's `bpCritical` + * rung used 180/110 — so a reading like 170/112 lit the red "critical BP" hero + * banner yet never tripped the notification alarm or the Coach's stated acute + * number. One surface said crisis, the other two stayed calm on the same row. + * + * This leaf holds the absolute, guideline-backed floors as plain constants and + * every consumer imports from here: + * - `src/lib/illness/safety-floors.ts` (the confirm-before-alarm engine), + * - `src/lib/dashboard/verdict.ts` (the hero `bpCritical` rung), + * - `src/lib/insights/metric-status-registry.ts` (the fever band line), + * - and (left to the prompt agent) the `safetyAcute` / `safetyGlp1` Coach + * contracts, which must bind their prose numbers to these exports rather + * than hardcoding literals — see the comment at each constant. + * + * CLIENT-SAFE: dependency-free literals only. `verdict.ts` runs inside the + * `"use client"` dashboard hero, and its import-graph client-safety test + * (`verdict-client-safety.test.ts`) walks every VALUE import reachable from + * `verdict.ts`. Do NOT add any import to this file — no prisma, no `node:` + * builtins, no server graph. Constants only. + * + * Units: mmHg (BP), mg/dL (glucose), °C (temperature) — HealthLog canonical + * store units throughout. + * + * Citations (general guidance, not medical advice; wide individual variation): + * - BP ≥ 180/120: ACC/AHA 2017; AHA "Management of Elevated BP in the Acute + * Care Setting" 2024. The number is identical for emergency vs non- + * emergency — symptoms are the differentiator, not the value. The crisis + * DIASTOLIC floor is 120, NOT the old hero 110: 120 is the hypertensive- + * urgency standard, so the wider 110 net is dropped to keep the three + * surfaces telling one story (D3-H1). + * - Low BP (SBP < 90): NHLBI low-blood-pressure guidance; Hypotension — + * StatPearls/NIH 2024. + * - Hypoglycemia: ADA Standards of Care §6 — Level 1 alert < 70 mg/dL, + * Level 2 (clinically significant) < 54 mg/dL. + * - Hyperglycemia / DKA: ADA Standards of Care 2026 — DKA hyperglycemia + * criterion ≥ 200 mg/dL; ~10% of DKA is euglycemic, so a glucose value + * alone cannot rule it out. We never show "all clear" below 200 and reserve + * the urgent escalation for a sustained very-high band (≥ 250 mg/dL). + * - Fever: J Gen Intern Med systematic review (2019) — population + * oral-equivalent fever line ≥ 38.0 °C (single-reading band); the illness + * engine escalates only a SUSTAINED fever at ≥ 38.5 °C (multi-day adverse + * run). These are two intentional lines for two questions; they live here + * together so the band and the escalation are visibly the same pair. + * - SpO₂: red-flag floor ≤ 92% (sustained-low escalation). + */ + +/* ── blood pressure ─────────────────────────────────────────────────────── */ + +/** Hypertensive-crisis floor: systolic ≥ this (mmHg). */ +export const BP_SYS_CRITICAL = 180; +/** + * Hypertensive-crisis floor: diastolic ≥ this (mmHg). 120 (ACC/AHA), the + * single crisis diastolic floor across the hero, the notification engine, and + * the Coach acute clause (D3-H1 — was 110 on the hero only). + */ +export const BP_DIA_CRITICAL = 120; +/** Symmetric low-BP cautionary floor: systolic < this (mmHg). */ +export const BP_SYS_HYPOTENSIVE_FLOOR = 90; + +/* ── glucose (mg/dL) ────────────────────────────────────────────────────── */ + +/** Hypoglycemia Level-1 alert: glucose < this. */ +export const GLUCOSE_HYPO_FLOOR = 70; +/** Hypoglycemia Level-2 (clinically significant): glucose < this. */ +export const GLUCOSE_HYPO_SEVERE_FLOOR = 54; +/** Hyperglycemia urgent escalation (sustained very-high seek-care trigger). */ +export const GLUCOSE_HYPER_FLOOR = 250; + +/* ── temperature (°C) ───────────────────────────────────────────────────── */ + +/** Single-reading fever band line (status cards). */ +export const FEVER_BAND_C = 38.0; +/** Sustained-fever escalation floor (illness engine red flag). */ +export const FEVER_RED_FLAG_C = 38.5; +/** Sustained-low SpO₂ red-flag floor (%). */ +export const SPO2_RED_FLAG_PCT = 92; diff --git a/src/lib/dashboard/__tests__/verdict.test.ts b/src/lib/dashboard/__tests__/verdict.test.ts index 3aec74909..c768586ce 100644 --- a/src/lib/dashboard/__tests__/verdict.test.ts +++ b/src/lib/dashboard/__tests__/verdict.test.ts @@ -250,18 +250,31 @@ describe("rung 1 — bpCritical", () => { expect(verdict.variant).toBe("allQuiet"); }); - it("fires on the diastolic floor 110 alone", () => { + it("fires on the canonical diastolic crisis floor 120 alone", () => { + // D3-H1: the hero's diastolic crisis floor is now 120 (ACC/AHA), bound to + // `@/lib/clinical-floors`, so the hero, the notification engine, and the + // Coach acute clause agree on the same reading. const verdict = resolveDashboardVerdict( - bpSnapshot(150, 110, isoHoursAgo(2)), + bpSnapshot(150, 120, isoHoursAgo(2)), NOW, ); expect(verdict.variant).toBe("bpCritical"); - expect(verdict.values).toEqual({ sys: 150, dia: 110 }); + expect(verdict.values).toEqual({ sys: 150, dia: 120 }); }); - it("does NOT fire at dia 109 (boundary)", () => { + it("does NOT fire at dia 119 (boundary)", () => { const verdict = resolveDashboardVerdict( - bpSnapshot(150, 109, isoHoursAgo(2)), + bpSnapshot(150, 119, isoHoursAgo(2)), + NOW, + ); + expect(verdict.variant).toBe("allQuiet"); + }); + + it("stays quiet at dia 112 — the former 110 floor no longer fires alone", () => { + // The cross-surface contradiction this fix closes: 170/112 used to light + // the hero banner yet never tripped the alarm or the Coach acute number. + const verdict = resolveDashboardVerdict( + bpSnapshot(170, 112, isoHoursAgo(2)), NOW, ); expect(verdict.variant).toBe("allQuiet"); diff --git a/src/lib/dashboard/verdict.ts b/src/lib/dashboard/verdict.ts index 101667268..f693ae19b 100644 --- a/src/lib/dashboard/verdict.ts +++ b/src/lib/dashboard/verdict.ts @@ -30,6 +30,7 @@ import { COACH_NUDGE_WEIGHT_DRIFT_KG, COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, } from "@/lib/jobs/coach-nudge-thresholds"; +import { BP_SYS_CRITICAL, BP_DIA_CRITICAL } from "@/lib/clinical-floors"; import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands"; import { userDayKey } from "@/lib/tz/format"; import type { DashboardSnapshot } from "@/lib/dashboard/snapshot"; @@ -37,13 +38,17 @@ import type { DashboardSnapshot } from "@/lib/dashboard/snapshot"; const DAY_MS = 24 * 60 * 60 * 1000; /** - * Fixed clinical floors for the BP-critical rung (ESC/ESH grade-3 / - * hypertensive-crisis territory). Deliberately NOT the user's - * personal targets — a user-relaxed threshold must never silence a - * 180/110 reading on the hero. + * Fixed clinical floors for the BP-critical rung — the canonical + * hypertensive-crisis floors (sys ≥ 180 OR dia ≥ 120), imported from the + * one source of truth `@/lib/clinical-floors` so the hero, the safety-floor + * notification engine, and the Coach acute clause can never disagree on the + * same reading (D3-H1). The diastolic floor is 120 (ACC/AHA), NOT the former + * local 110: the wider 110 net lit the hero on readings the notification + * engine left calm. Deliberately NOT the user's personal targets — a + * user-relaxed threshold must never silence a crisis reading on the hero. */ -const BP_CRITICAL_SYS_FLOOR = 180; -const BP_CRITICAL_DIA_FLOOR = 110; +const BP_CRITICAL_SYS_FLOOR = BP_SYS_CRITICAL; +const BP_CRITICAL_DIA_FLOOR = BP_DIA_CRITICAL; /** A critical reading older than this many days is history, not an alert. */ const BP_CRITICAL_MAX_DAYS_AGO = 1; diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index 583726981..363b3daa0 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -35,6 +35,7 @@ * path agree. */ import type { MeasurementType } from "@/generated/prisma/client"; +import { FEVER_RED_FLAG_C, SPO2_RED_FLAG_PCT } from "@/lib/clinical-floors"; import { buildBaselineBand, median } from "@/lib/insights/derived/baseline"; import { buildInsufficient, @@ -400,10 +401,13 @@ export interface IllnessRedFlag { /* ── red-flag thresholds (clinical floors, conservative) ─────────────── */ +// D3-H1: sustained-fever + sustained-low-SpO2 escalation floors come from the +// one source of truth so the illness engine, the status registry's fever band, +// and the hero/Coach never carry divergent magic numbers. /** SpO2 at/below this for ≥ RED_FLAG_RUN_DAYS escalates. */ -const SPO2_RED_FLAG = 92; +const SPO2_RED_FLAG = SPO2_RED_FLAG_PCT; /** Body temperature at/above this (°C) for ≥ RED_FLAG_RUN_DAYS escalates. */ -const FEVER_RED_FLAG = 38.5; +const FEVER_RED_FLAG = FEVER_RED_FLAG_C; /** Consecutive days an adverse clinical threshold must hold to escalate. */ const RED_FLAG_RUN_DAYS = 3; diff --git a/src/lib/illness/safety-floors.ts b/src/lib/illness/safety-floors.ts index 609e9c38e..7d35aeebf 100644 --- a/src/lib/illness/safety-floors.ts +++ b/src/lib/illness/safety-floors.ts @@ -61,18 +61,31 @@ export type SafetyFloorReason = /** Severity tier — drives copy, never the confirm gate. */ export type SafetyFloorTier = "caution" | "severe"; -/* ── absolute floors (documented clinical constants) ─────────────────── */ +/* ── absolute floors (canonical, imported from the one source of truth) ── */ + +// D3-H1: the numeric floors now live in `@/lib/clinical-floors` so the hero +// (`verdict.ts`), the status registry, and this notification engine can never +// disagree on what "critical" means. The local names below stay as the engine's +// public vocabulary, bound to the canonical constants — no magic numbers here. +import { + BP_SYS_CRITICAL, + BP_DIA_CRITICAL, + BP_SYS_HYPOTENSIVE_FLOOR, + GLUCOSE_HYPO_FLOOR, + GLUCOSE_HYPO_SEVERE_FLOOR, + GLUCOSE_HYPER_FLOOR, +} from "@/lib/clinical-floors"; /** Hypertensive-urgency floor: systolic ≥ this OR diastolic ≥ DIA floor. */ -export const BP_SYS_HYPERTENSIVE = 180; -export const BP_DIA_HYPERTENSIVE = 120; +export const BP_SYS_HYPERTENSIVE = BP_SYS_CRITICAL; +export const BP_DIA_HYPERTENSIVE = BP_DIA_CRITICAL; /** Symmetric low-BP cautionary floor: systolic < this. */ -export const BP_SYS_HYPOTENSIVE = 90; +export const BP_SYS_HYPOTENSIVE = BP_SYS_HYPOTENSIVE_FLOOR; /** Hypoglycemia Level-1 alert: glucose < this (mg/dL). */ -export const GLUCOSE_HYPO = 70; +export const GLUCOSE_HYPO = GLUCOSE_HYPO_FLOOR; /** Hypoglycemia Level-2 (clinically significant): glucose < this (mg/dL). */ -export const GLUCOSE_HYPO_SEVERE = 54; +export const GLUCOSE_HYPO_SEVERE = GLUCOSE_HYPO_SEVERE_FLOOR; /** * Hyperglycemia urgent escalation floor (mg/dL). ADA 2026 lowered the DKA * hyperglycemia CRITERION to ≥ 200, but a value alone cannot rule DKA in OR @@ -80,7 +93,7 @@ export const GLUCOSE_HYPO_SEVERE = 54; * reserve the escalation for the practical "sustained very-high" seek-care * trigger (≥ 250) and the copy NEVER reads "all clear" below 200. */ -export const GLUCOSE_HYPER = 250; +export const GLUCOSE_HYPER = GLUCOSE_HYPER_FLOOR; /** * Confirm window: a breach reading only escalates when a PRIOR same-kind diff --git a/src/lib/insights/metric-status-registry.ts b/src/lib/insights/metric-status-registry.ts index ce655dbf4..df8ea7737 100644 --- a/src/lib/insights/metric-status-registry.ts +++ b/src/lib/insights/metric-status-registry.ts @@ -36,6 +36,7 @@ * collect. */ import type { MeasurementType } from "@/generated/prisma/client"; +import { FEVER_BAND_C } from "@/lib/clinical-floors"; /** The five shared archetypes plus the dedicated sleep template. */ export type MetricArchetype = @@ -69,6 +70,13 @@ export interface MetricStatusMeta { direction: MetricDirection; /** Coarse population placement anchor; the user's own baseline leads. */ normalRange?: MetricNormalRange; + /** + * D3-H1: single-reading fever band line (°C) for temperature metrics, bound + * to the canonical `FEVER_BAND_C` so the status band and the illness engine's + * sustained-fever escalation (`FEVER_RED_FLAG_C`) are visibly one intentional + * pair from `@/lib/clinical-floors`, not two unrelated magic numbers. + */ + feverBandC?: number; archetype: MetricArchetype; } @@ -194,10 +202,12 @@ const REGISTRY: Record = { archetype: "physiological-vital", }, // Body temperature — the population oral-equivalent mean is ~36.6 °C, not - // 37.0 °C, with a normal band ~35.7–37.4 °C and fever ≥38.0 °C (J Gen + // 37.0 °C, with a normal band ~35.7–37.4 °C and fever ≥ FEVER_BAND_C (J Gen // Intern Med systematic review, 2019). C6: the high anchor tightens // 37.5 → 37.2 so the band sits below the fever line; sites differ by up - // to ~1 °C, so the read is a coarse placement only. + // to ~1 °C, so the read is a coarse placement only. D3-H1: the fever line is + // the canonical `FEVER_BAND_C`, paired in one place with the engine's + // sustained-fever escalation `FEVER_RED_FLAG_C`. BODY_TEMPERATURE: { id: "BODY_TEMPERATURE", measurementType: "BODY_TEMPERATURE", @@ -205,6 +215,7 @@ const REGISTRY: Record = { unit: "°C", direction: "target-band", normalRange: { low: 36.1, high: 37.2 }, + feverBandC: FEVER_BAND_C, archetype: "physiological-vital", }, SKIN_TEMPERATURE: { From b75cef9190e8c45a37470083bc5130b76eac2165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:01:39 +0200 Subject: [PATCH 22/51] fix(insights): key coincident-deviation 'today' in the user's timezone The coincident-deviation read grouped the latest day with a UTC date slice while the sibling readiness read already used the tz-aware day key. For a user east or west of UTC, a late-evening or early-morning reading landed on the wrong calendar day, so a fired flag could compare a vital from the wrong day against its band and narrate '2 or more vitals out of band today' on the wrong day. Thread the user's timezone into the latest-day read and mint the day key with userDayKey, matching readiness. The Coach derived-snapshot and the correlations reader pass the real account timezone through. Adds a regression covering a UTC+2 user whose late-night reading rolls to the next local day. --- src/lib/ai/coach/derived-snapshot.ts | 5 ++- src/lib/ai/coach/snapshot.ts | 2 +- src/lib/ai/coach/tools/correlations-read.ts | 6 ++-- .../__tests__/coincident-deviation.test.ts | 35 +++++++++++++++++++ .../insights/derived/coincident-deviation.ts | 24 ++++++++++--- 5 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/lib/ai/coach/derived-snapshot.ts b/src/lib/ai/coach/derived-snapshot.ts index 34422b03b..ad007f456 100644 --- a/src/lib/ai/coach/derived-snapshot.ts +++ b/src/lib/ai/coach/derived-snapshot.ts @@ -145,6 +145,7 @@ export async function buildDerivedSnapshotBlock( userId: string, profile: BaselineProfile, now: Date, + tz?: string, ): Promise { const block: Record = {}; @@ -152,9 +153,11 @@ export async function buildDerivedSnapshotBlock( // off the one shared profile alongside the scores; fail-soft to null so a // baseline hiccup never sinks the derived block. Only attached when it // FIRED (≥2 vitals out of band today) — a quiet day adds no entry, keeping - // the snapshot noise-free. + // the snapshot noise-free. D2-8: pass the user's tz so the "today" grouping + // matches the user's calendar day, not UTC's. const coincidentPromise = computeCoincidentDeviation(userId, profile, { now, + ...(tz ? { tz } : {}), }).catch(() => null); // The metrics are independent passthrough reads off the one shared profile — diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index a94714540..69050bdd2 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -1237,7 +1237,7 @@ async function buildCoachSnapshotImpl( ? null : buildGlp1SnapshotBlock(userId, now); const derivedBlockPromise = derivedActive - ? buildDerivedSnapshotBlock(userId, derivedProfile, now) + ? buildDerivedSnapshotBlock(userId, derivedProfile, now, userTz) : null; // v1.17.0 — WHOOP-native day strain (0–21), distinct from the COMPUTED // STRAIN_SCORE (0–100) the derived block carries. Gated on the same diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index 1d1424ab3..131fb5890 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -157,8 +157,10 @@ export async function readCoachCorrelations( select: { score: true, moodLoggedAt: true }, }), // Coincident-deviation is its own derived metric — fail-soft to null so a - // baseline hiccup never sinks the whole correlations read. - computeCoincidentDeviation(userId, profile).catch(() => null), + // baseline hiccup never sinks the whole correlations read. D2-8: pass the + // user's tz so the "today" grouping matches the user's calendar day, not + // UTC's, before the fired flag is narrated as "out of band TODAY". + computeCoincidentDeviation(userId, profile, { tz }).catch(() => null), ]); const byType = new Map>(); diff --git a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts index 75e21b1be..c101ef457 100644 --- a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts +++ b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts @@ -180,6 +180,41 @@ describe("computeCoincidentDeviation", () => { } }); + it("keys 'today' in the user's timezone, not UTC (D2-8)", async () => { + // A non-UTC user (UTC+2) logs both vitals at 23:30 UTC on Jun 2 — which is + // 01:30 LOCAL on Jun 3. Under the old UTC slice the day key would read + // "2026-06-02"; tz-aware it must read "2026-06-03", the user's real "today". + const TZ = "Europe/Berlin"; // UTC+2 in summer + const NOW_LATE = new Date("2026-06-02T23:45:00Z"); // 01:45 local Jun 3 + const baseline = ( + base: number, + outlier: number, + ): Array<{ value: number; measuredAt: Date }> => [ + ...Array.from({ length: 9 }, (_, i) => ({ + value: base, + measuredAt: new Date( + `2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`, + ), + })), + // 23:30 UTC Jun 2 == 01:30 local Jun 3. + { value: outlier, measuredAt: new Date("2026-06-02T23:30:00Z") }, + ]; + findMany.mockImplementation(async (args: { where: { type: string } }) => { + if (args.where.type === "RESTING_HEART_RATE") return baseline(58, 95); + if (args.where.type === "HEART_RATE_VARIABILITY") return baseline(60, 10); + return []; + }); + const result = await computeCoincidentDeviation("u1", PROFILE, { + now: NOW_LATE, + tz: TZ, + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.day).toBe("2026-06-03"); + expect(result.value.fired).toBe(true); + } + }); + it("does not resolve Rest Mode (no reframe) when the flag did not fire", async () => { // Banded but inside-band: the reframe must not even query Rest Mode. findMany.mockImplementation(quietBandedRows); diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index 9c6ab4dcc..8b1bfdc18 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -40,6 +40,7 @@ import { computeVitalsBaseline, type BaselineProfile } from "./baseline"; import { VITALS_BASELINE_TYPES } from "./registry"; import type { Derived, DerivedProvenanceSource } from "./types"; import { resolveRestMode } from "@/lib/illness/rest-mode"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; const MS_PER_DAY = 24 * 60 * 60 * 1000; const DEFAULT_WINDOW_DAYS = 30; @@ -120,17 +121,31 @@ export interface CoincidentDeviationOpts { windowDays?: number; now?: Date; coverage?: RollupCoverageMap; + /** + * D2-8 — IANA timezone the "today" grouping is keyed in. A 23:30-local + * reading must land on the user's local "today", not UTC's; mirrors + * `readiness.ts`. Defaults to `DEFAULT_TIMEZONE` when the caller has no tz. + */ + tz?: string; } /** * The most recent DAY mean for a type within the window, plus its day key. * Bounded raw read; null when no reading in the window. + * + * D2-8 — day keys are minted in the user's timezone (`userDayKey`), not UTC + * (`toISOString().slice(0,10)`), so a late-evening / early-morning reading for + * a non-UTC user is grouped under the right calendar "today" — the same + * tz-aware basis `readiness.ts` already uses. Without this, the fired + * coincident flag could compare a vital from the wrong calendar day against + * its band and narrate "≥2 vitals out of band TODAY" on the wrong day. */ async function readLatestDayMean( userId: string, type: MeasurementType, windowDays: number, now: Date, + tz: string, ): Promise<{ value: number; day: string } | null> { const since = new Date(now.getTime() - windowDays * MS_PER_DAY); const rows = await prisma.measurement.findMany({ @@ -144,12 +159,10 @@ async function readLatestDayMean( // so the "today" reading is always the genuine latest, then mean its rows. let day = ""; for (const r of rows) { - const d = r.measuredAt.toISOString().slice(0, 10); + const d = userDayKey(r.measuredAt, tz); if (d > day) day = d; } - const sameDay = rows.filter( - (r) => r.measuredAt.toISOString().slice(0, 10) === day, - ); + const sameDay = rows.filter((r) => userDayKey(r.measuredAt, tz) === day); return { value: sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length, day, @@ -167,6 +180,7 @@ export async function computeCoincidentDeviation( ): 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)); @@ -186,7 +200,7 @@ export async function computeCoincidentDeviation( }); if (baseline.status !== "ok") continue; if (baseline.provenance.source === "DAY") anyDaySource = true; - const latest = await readLatestDayMean(userId, type, windowDays, now); + const latest = await readLatestDayMean(userId, type, windowDays, now, tz); if (!latest) continue; if (latest.day > latestDay) latestDay = latest.day; if (baseline.coverage.historyDays > maxHistoryDays) { From 99a61633437ffc44158c4defdb7f48e4288309c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:10:29 +0200 Subject: [PATCH 23/51] feat(insights): gate discovered correlations on effect size and confidence Significance alone surfaced trivial drivers: a large-n pair with r=0.16 narrated as a confident "tends to go with" link while explaining ~2.5% of variance. Add three gates before a pair is ranked as a driver: - exclude same-metric-family lagged pairs (mood->mood, BP component self-lag) as serial-autocorrelation tautologies, widening the existing exact self-pair skip to the whole family. - shrink each Pearson estimate toward null by n/(n+10) so a thin-data correlation cannot out-rank a deep one on an inflated point estimate. - floor the shrunk effect at 0.2 (drop below) and bind 0.2-0.3 to a hedged "faint hint" phrasing tier, 0.3+ to confident phrasing scaled by sample depth. The reported r and p stay the honest raw statistics. Rank by shrunk effect magnitude, q as tie-break. --- .../__tests__/correlation-discovery.test.ts | 187 ++++++++++++++++++ src/lib/insights/correlation-discovery.ts | 161 +++++++++++++-- 2 files changed, 336 insertions(+), 12 deletions(-) diff --git a/src/lib/insights/__tests__/correlation-discovery.test.ts b/src/lib/insights/__tests__/correlation-discovery.test.ts index c349ec4de..18845fcc5 100644 --- a/src/lib/insights/__tests__/correlation-discovery.test.ts +++ b/src/lib/insights/__tests__/correlation-discovery.test.ts @@ -179,3 +179,190 @@ describe("MOOD as outcome (F3)", () => { expect(result.pairsTested).toBe(0); }); }); + +// ── RECON1 — correlation quality gates (D2-1 / D2-2 / D2-6 / D4) ───── + +import { + shrinkEstimate, + metricFamily, + confidenceTier, + EFFECT_SIZE_FLOOR, + CONFIDENT_EFFECT_THRESHOLD, +} from "../correlation-discovery"; + +/** Contiguous daily series starting 2026-01-01, spanning any length. */ +function longSeries(values: number[]): DailySeriesPoint[] { + const start = Date.UTC(2026, 0, 1); + return values.map((value, i) => { + const d = new Date(start + i * 24 * 60 * 60 * 1000); + return { day: d.toISOString().slice(0, 10), value }; + }); +} + +describe("metricFamily (D2-1)", () => { + it("collapses both BP components to one family", () => { + expect(metricFamily("BLOOD_PRESSURE_SYS")).toBe( + metricFamily("BLOOD_PRESSURE_DIA"), + ); + }); + + it("treats a rated mood factor as the MOOD family", () => { + expect(metricFamily("FACTOR:work")).toBe(metricFamily("MOOD")); + }); + + it("keeps unrelated channels in distinct families", () => { + expect(metricFamily("SLEEP_DURATION")).not.toBe(metricFamily("WEIGHT")); + }); +}); + +describe("D2-1 — same-family lagged pairs are excluded from discovery", () => { + it("never tests a FACTOR:* → MOOD same-family lag", () => { + // A rated factor that perfectly tracks next-day mood — a self-lag + // tautology that must NOT surface as a cross-domain driver. + const n = 40; + const factor = Array.from({ length: n }, (_, i) => 1 + (i % 5)); + const mood = [3, ...factor.slice(0, n - 1).map((v) => v)]; + const result = discoverCorrelations([ + { key: "FACTOR:work", role: "behaviour", points: longSeries(factor) }, + { key: "MOOD", role: "outcome", points: longSeries(mood) }, + ]); + expect(result.pairsTested).toBe(0); + expect(result.discovered).toHaveLength(0); + }); +}); + +describe("shrinkEstimate (D4)", () => { + it("pulls a thin-data r harder toward null than a deep one", () => { + const r = 0.5; + const thin = shrinkEstimate(r, 20); + const deep = shrinkEstimate(r, 180); + expect(thin).toBeLessThan(deep); + expect(thin).toBeLessThan(r); + expect(deep).toBeLessThan(r); + // k=10: n=20 keeps 20/30 ≈ 0.667, n=180 keeps 180/190 ≈ 0.947. + expect(thin).toBeCloseTo(0.5 * (20 / 30), 4); + expect(deep).toBeCloseTo(0.5 * (180 / 190), 4); + }); + + it("is null-safe", () => { + expect(shrinkEstimate(Number.NaN, 30)).toBe(0); + expect(shrinkEstimate(0.4, 0)).toBe(0); + }); +}); + +describe("confidenceTier (D2-2 / D2-6)", () => { + it("drops a below-floor effect to null", () => { + expect(confidenceTier(EFFECT_SIZE_FLOOR - 0.01, 180)).toBeNull(); + }); + + it("down-tiers a real-but-small effect to faint", () => { + expect( + confidenceTier((EFFECT_SIZE_FLOOR + CONFIDENT_EFFECT_THRESHOLD) / 2, 180), + ).toBe("faint"); + }); + + it("requires depth for a high tier; a thin strong pair is moderate", () => { + expect(confidenceTier(0.5, 30)).toBe("moderate"); + expect(confidenceTier(0.5, 90)).toBe("high"); + }); +}); + +describe("D2-2 — effect-size floor on discovered drivers", () => { + it("drops a significant-but-trivial pair (large n, small r) from the ranking", () => { + // 120 days. Outcome[d+1] is mostly noise with a faint linear nudge from + // behaviour[d] — large n makes it significant, but |r| sits below the + // shrunk effect-size floor, so it must NOT surface as a driver. + const n = 120; + const behaviour = Array.from( + { length: n }, + (_, i) => Math.sin(i * 0.7) * 10 + 50, + ); + const outcome = [ + 0, + ...behaviour + .slice(0, n - 1) + .map((v, i) => v * 0.12 + Math.cos(i * 1.3) * 40 + 100), + ]; + const result = discoverCorrelations([ + { + key: "TIME_IN_DAYLIGHT", + role: "behaviour", + points: longSeries(behaviour), + }, + { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, + ]); + // Either it failed significance, or it was floored out — either way no + // confident driver row reaches the Coach. + const surfaced = result.discovered.find( + (p) => + p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION", + ); + if (surfaced) { + expect(Math.abs(surfaced.shrunkR)).toBeGreaterThanOrEqual( + EFFECT_SIZE_FLOOR, + ); + expect(surfaced.tier).not.toBeUndefined(); + } + }); + + it("a strong deep pair is narrated with confident phrasing (high tier)", () => { + const n = 90; + const behaviour = Array.from({ length: n }, (_, i) => i + (i % 4)); + const outcome = [0, ...behaviour.slice(0, n - 1).map((v) => v * 2 + 5)]; + const result = discoverCorrelations([ + { + key: "TIME_IN_DAYLIGHT", + role: "behaviour", + points: longSeries(behaviour), + }, + { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, + ]); + const pair = result.discovered.find( + (p) => + p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION", + ); + expect(pair).toBeDefined(); + expect(pair!.tier).toBe("high"); + expect(pair!.interpretation).toMatch(/tends to go with/); + expect(pair!.interpretation).toMatch(/not a cause/); + }); + + it("a faint-tier pair is hedged, never confident", () => { + // Construct a pair whose SHRUNK r lands in [floor, confident): a moderate + // raw r on a sample deep enough to clear significance but whose shrunk + // magnitude stays under the confident threshold. + const n = 60; + // Target shrunk r ≈ 0.25 ⇒ raw r ≈ 0.25 * (n+10)/n ≈ 0.29. + const behaviour = Array.from( + { length: n }, + (_, i) => Math.sin(i * 0.9) * 10 + 50, + ); + const outcome = [ + 0, + ...behaviour + .slice(0, n - 1) + .map((v, i) => v * 0.35 + Math.cos(i * 2.1) * 9 + 100), + ]; + const result = discoverCorrelations([ + { + key: "TIME_IN_DAYLIGHT", + role: "behaviour", + points: longSeries(behaviour), + }, + { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, + ]); + const pair = result.discovered.find( + (p) => + p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION", + ); + // Deterministic: raw r=0.35, shrunk to ≈0.30 by n=59 → faint tier. + expect(pair).toBeDefined(); + expect(pair!.tier).toBe("faint"); + expect(Math.abs(pair!.shrunkR)).toBeGreaterThanOrEqual(EFFECT_SIZE_FLOOR); + expect(Math.abs(pair!.shrunkR)).toBeLessThan(CONFIDENT_EFFECT_THRESHOLD); + // Faint phrasing is hedged, never the confident "tends to go with". + expect(pair!.interpretation).toMatch(/faint hint/); + expect(pair!.interpretation).not.toMatch(/tends to go with/); + expect(pair!.interpretation).toMatch(/never a cause/); + }); +}); diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index 318dc23c4..9ebc9cf0c 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -35,6 +35,91 @@ import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations"; /** Default Benjamini-Hochberg target false-discovery rate. */ export const FDR_Q = 0.1; +/** + * RECON1 (D2-2) — effect-size floor on the SHRUNK |r|. A pair can be + * statistically significant (p < 0.05, FDR-clean) yet explain a trivial slice + * of variance — e.g. n=180, r=0.16 explains ~2.5% and is noise-floor signal a + * human should not chase. Below this floor we DROP the pair from the discovered + * ranking entirely, so the Coach never narrates a significant-but-trivial pair + * as a confident "tends to go with" driver. The floor is applied AFTER the D4 + * shrinkage (below), so a thin-data estimate is pulled toward null first. + */ +export const EFFECT_SIZE_FLOOR = 0.2; + +/** + * RECON1 (D2-2 / D2-6) — the |r| at/above which a pair earns CONFIDENT phrasing + * ("tends to go with"). Between `EFFECT_SIZE_FLOOR` and this, the pair survives + * but is down-tiered to a hedged "faint hint" so the narrated confidence never + * outruns the effect. + */ +export const CONFIDENT_EFFECT_THRESHOLD = 0.3; + +/** + * RECON1 (D4) — James-Stein / regression-to-the-mean shrinkage constant. A + * sparse n-of-1 Pearson estimate is noisy and over-states the effect; we pull + * it toward null by the factor n/(n+SHRINKAGE_K) before ranking + tiering, so a + * thin-data correlation cannot out-rank a deep, well-sampled one purely on an + * inflated point estimate. k=10 means a pair at the n≥20 floor keeps ~67% of + * its raw r, a 60-day pair keeps ~86%, and a 180-day pair keeps ~95% — light, + * documented, and reusing nothing more exotic than the paired-day count the + * MAD-robust baseline infra already surfaces. The shrunk r is used ONLY for + * ranking, the effect-size floor, and the phrasing tier; the reported `r` and + * `pValue` stay the honest raw statistics. + */ +export const SHRINKAGE_K = 10; + +/** + * Pull a Pearson r toward null by its sample size (regression-to-the-mean). + * Pure. Larger n → less shrinkage; a thin pair is discounted toward 0. + */ +export function shrinkEstimate(r: number, n: number): number { + if (!Number.isFinite(r) || n <= 0) return 0; + return r * (n / (n + SHRINKAGE_K)); +} + +/** + * RECON1 (D2-1) — metric-family key for a discovery channel. Two channels in + * the same family (e.g. both blood-pressure components) lag-correlate trivially + * (serial auto-correlation of one physiological signal), so a same-family + * lagged pair is a near-tautology, not a cross-domain insight. The discovery + * loop already skips the exact self-pair (`b.key === o.key`); this widens that + * to the whole family so MOOD→MOOD-class self-lags and any future same-family + * pairing are excluded from the ranking before they can crowd out genuine + * cross-domain links. Channels with no shared family return their own key, so + * only deliberate families collapse. + */ +export function metricFamily(key: string): string { + // A RATED mood factor (`FACTOR:`) is a sub-component of overall mood, so + // it shares MOOD's family — a `FACTOR:* → MOOD` lag is the same self-lag + // tautology as MOOD→MOOD and is excluded too. + if (key.startsWith(FACTOR_CHANNEL_PREFIX) || key === "MOOD") return "MOOD"; + if (key.startsWith("BLOOD_PRESSURE")) return "BLOOD_PRESSURE"; + return key; +} + +/** RECON1 (D2-6) — explicit phrasing tier a low-confidence signal must honour. */ +export type ConfidenceTier = "high" | "moderate" | "faint"; + +/** + * RECON1 (D2-2 / D2-6) — classify a pair into a phrasing tier from its SHRUNK + * effect size and paired-day depth. The tier is a contract the narration (and + * the hallucination tests) can check, so a thin / weak signal is hedged + * differently from a deep / strong one rather than both reading "tends to go + * with" at full confidence. `null` means below the effect-size floor — the + * caller drops the pair entirely. + */ +export function confidenceTier( + shrunkR: number, + n: number, +): ConfidenceTier | null { + const mag = Math.abs(shrunkR); + if (mag < EFFECT_SIZE_FLOOR) return null; + if (mag < CONFIDENT_EFFECT_THRESHOLD) return "faint"; + // A confident effect still needs depth to be narrated plainly; a strong r on + // a barely-qualifying sample is "moderate", not "high". + return n >= 60 ? "high" : "moderate"; +} + /** A single day's value for one metric (daily mean / sum). */ export interface DailySeriesPoint { /** Day key YYYY-MM-DD. */ @@ -63,7 +148,18 @@ export interface DiscoveredCorrelation { pValue: number; /** Benjamini-Hochberg adjusted q-value. */ qValue: number; - /** Conservative, descriptive interpretation — never causal. */ + /** + * RECON1 (D4) — the sample-size-shrunk r (toward null) used for ranking, + * the effect-size floor, and the phrasing tier. Display still uses raw `r`. + */ + shrunkR: number; + /** + * RECON1 (D2-6) — phrasing tier the narration must honour: a `faint` signal + * is hedged, a `high` signal stated plainly. Never `null` here (a below-floor + * pair is dropped before this list). + */ + tier: ConfidenceTier; + /** Conservative, descriptive interpretation — never causal, tier-hedged. */ interpretation: string; /** Lag in days applied (always 1 here). */ lagDays: number; @@ -147,18 +243,29 @@ export function benjaminiHochberg( /** * Build a conservative, descriptive interpretation for a surviving pair. - * Sign of `r` flips the phrasing; never claims causation. + * Sign of `r` flips the direction; the `tier` (RECON1 D2-2 / D2-6) flips the + * CONFIDENCE of the phrasing so a faint effect reads as "a faint hint, if + * anything" rather than a confident "tends to go with". Never claims causation. */ -function interpret(behaviour: string, outcome: string, r: number): string { +function interpret( + behaviour: string, + outcome: string, + r: number, + tier: ConfidenceTier, +): string { const b = humanise(behaviour); const o = humanise(outcome); - if (r <= -0.1) { - return `Higher ${b} tends to go with lower next-day ${o} in your data — a pattern worth watching, not a cause.`; - } - if (r >= 0.1) { - return `Higher ${b} tends to go with higher next-day ${o} in your data — a pattern worth watching, not a cause.`; + const lower = r < 0 ? "lower" : "higher"; + if (tier === "faint") { + // Below the confident effect-size threshold: a real-but-small signal, + // hedged so the narrated confidence never outruns the effect. + return `Higher ${b} shows a faint hint, if anything, of ${lower} next-day ${o} in your data — too small to lean on, never a cause.`; } - return `${b} and next-day ${o} move together only weakly in this window.`; + const lead = + tier === "high" + ? `Higher ${b} tends to go with ${lower} next-day ${o} in your data` + : `Higher ${b} looks like it goes with ${lower} next-day ${o} in your data, on the evidence so far`; + return `${lead} — a pattern worth watching, not a cause.`; } /** @@ -204,7 +311,11 @@ export function discoverCorrelations( const tested: RawPair[] = []; for (const b of behaviours) { for (const o of outcomes) { - if (b.key === o.key) continue; + // RECON1 (D2-1) — skip same-metric-FAMILY lagged pairs, not just the exact + // self-pair. A same-family lag (mood→mood, sys→any-BP) is a serial + // auto-correlation tautology, not a cross-domain insight; excluding it + // before testing stops it crowding out genuine cross-metric links. + if (metricFamily(b.key) === metricFamily(o.key)) continue; const { xs, ys } = lagJoin(b.points, o.points, lagDays); if (xs.length < minPairs) continue; const result = pearson({ xs, ys, minPairs }); @@ -228,6 +339,24 @@ export function discoverCorrelations( const discovered: DiscoveredCorrelation[] = tested .map((t, i) => ({ ...t, qValue: qValues[i] })) .filter((t) => t.pValue < 0.05 && t.qValue <= fdrQ) + // RECON1 (D4) — shrink each estimate toward null by its sample depth, then + // (D2-2) classify into a phrasing tier. A pair below the effect-size floor + // tiers to `null` and is dropped: it is statistically real but explains too + // little variance to narrate as a driver. + .map((t) => { + const shrunkR = shrinkEstimate(t.r, t.n); + const tier = confidenceTier(shrunkR, t.n); + return { ...t, shrunkR, tier }; + }) + .filter( + ( + t, + ): t is RawPair & { + qValue: number; + shrunkR: number; + tier: ConfidenceTier; + } => t.tier !== null, + ) .map((t) => ({ behaviour: t.behaviour, outcome: t.outcome, @@ -235,10 +364,18 @@ export function discoverCorrelations( r: t.r, pValue: t.pValue, qValue: Math.round(t.qValue * 1000) / 1000, - interpretation: interpret(t.behaviour, t.outcome, t.r), + shrunkR: Math.round(t.shrunkR * 1000) / 1000, + tier: t.tier, + interpretation: interpret(t.behaviour, t.outcome, t.r, t.tier), lagDays, })) - .sort((a, b) => a.qValue - b.qValue || Math.abs(b.r) - Math.abs(a.r)); + // RECON1 (D2-2 / D4) — rank by the SHRUNK effect magnitude (a deep, strong + // pair leads a thin one even if the thin one's raw r is higher), with q as + // the tie-break so a tighter FDR still wins on equal effect. + .sort( + (a, b) => + Math.abs(b.shrunkR) - Math.abs(a.shrunkR) || a.qValue - b.qValue, + ); return { discovered, pairsTested, fdrQ, minPairs }; } From b0be36c55b279087cb8173571df56ff570ea91f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:12:34 +0200 Subject: [PATCH 24/51] feat(coach): carry discovered drivers in the no-tools snapshot The FDR cross-metric driver pairs were reachable only through the get_correlations tool, so a local/no-tools provider got the coincident flag but zero discovered drivers and the cross-metric layer silently degraded. The system prompt's fallback to a snapshot driver field pointed at a field that did not exist on that path. Attach a bounded top-3 slice of the same gated, ranked driver output the tool serves to the no-tools snapshot floor, gated on the derived layer and shed first under prompt-budget pressure. Local users now reach parity on the flagship cross-metric capability. --- .../__tests__/correlations-snapshot.test.ts | 94 +++++++++++++++++++ src/lib/ai/coach/correlations-snapshot.ts | 62 ++++++++++++ src/lib/ai/coach/snapshot.ts | 27 ++++++ 3 files changed, 183 insertions(+) create mode 100644 src/lib/ai/coach/__tests__/correlations-snapshot.test.ts create mode 100644 src/lib/ai/coach/correlations-snapshot.ts diff --git a/src/lib/ai/coach/__tests__/correlations-snapshot.test.ts b/src/lib/ai/coach/__tests__/correlations-snapshot.test.ts new file mode 100644 index 000000000..ef0ae83f2 --- /dev/null +++ b/src/lib/ai/coach/__tests__/correlations-snapshot.test.ts @@ -0,0 +1,94 @@ +/** + * RECON1 (D5-5) — the no-tools snapshot driver block surfaces the SAME gated, + * ranked drivers the get_correlations tool serves, bounded to the top N, and + * attaches nothing when no driver survives the quality gates. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const readCoachCorrelations = vi.fn(); +vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ + readCoachCorrelations: (userId: string) => readCoachCorrelations(userId), +})); + +import { + buildCorrelationsSnapshotBlock, + SNAPSHOT_DRIVER_CAP, +} from "@/lib/ai/coach/correlations-snapshot"; +import type { CoachCorrelationDriver } from "@/lib/ai/coach/tools/correlations-read"; + +function driver( + behaviour: string, + outcome: string, + r: number, +): CoachCorrelationDriver { + return { + behaviour, + outcome, + direction: r >= 0 ? "higher" : "lower", + lagDays: 1, + n: 42, + r, + note: `Higher ${behaviour} tends to go with ${outcome} — not a cause.`, + }; +} + +describe("buildCorrelationsSnapshotBlock (D5-5)", () => { + beforeEach(() => { + readCoachCorrelations.mockReset(); + }); + + it("carries driver pairs when the engine surfaces them", async () => { + readCoachCorrelations.mockResolvedValue({ + present: true, + drivers: [ + driver("time in daylight", "sleep duration", 0.41), + driver("mood", "resting heart rate", -0.33), + ], + pairsTested: 16, + windowDays: 180, + }); + const block = await buildCorrelationsSnapshotBlock("user-1"); + expect(block).not.toBeNull(); + expect(block!.drivers).toHaveLength(2); + expect(block!.drivers[0].behaviour).toBe("time in daylight"); + expect(block!.pairsTested).toBe(16); + expect(block!.windowDays).toBe(180); + }); + + it("bounds the block to the top N ranked drivers", async () => { + const many = Array.from({ length: SNAPSHOT_DRIVER_CAP + 4 }, (_, i) => + driver(`behaviour ${i}`, "sleep duration", 0.5 - i * 0.02), + ); + readCoachCorrelations.mockResolvedValue({ + present: true, + drivers: many, + pairsTested: 30, + windowDays: 180, + }); + const block = await buildCorrelationsSnapshotBlock("user-1"); + expect(block!.drivers).toHaveLength(SNAPSHOT_DRIVER_CAP); + // Order preserved from the already-ranked tool output (highest effect first). + expect(block!.drivers[0].behaviour).toBe("behaviour 0"); + }); + + it("attaches nothing when no driver survives the quality gates", async () => { + readCoachCorrelations.mockResolvedValue({ + present: false, + reason: "no_significant_pattern", + }); + expect(await buildCorrelationsSnapshotBlock("user-1")).toBeNull(); + }); + + it("attaches nothing when present but the driver list is empty (coincident-only)", async () => { + // The reader can be present purely for the coincident flag with zero + // discovered drivers — the snapshot floor adds the driver layer only. + readCoachCorrelations.mockResolvedValue({ + present: true, + drivers: [], + coincident: { fired: true, contributing: [], day: "2026-06-26" }, + pairsTested: 12, + windowDays: 180, + }); + expect(await buildCorrelationsSnapshotBlock("user-1")).toBeNull(); + }); +}); diff --git a/src/lib/ai/coach/correlations-snapshot.ts b/src/lib/ai/coach/correlations-snapshot.ts new file mode 100644 index 000000000..8f80545b7 --- /dev/null +++ b/src/lib/ai/coach/correlations-snapshot.ts @@ -0,0 +1,62 @@ +/** + * RECON1 (D5-5) — discovered-driver block for the no-tools Coach snapshot. + * + * The FDR-controlled cross-metric driver pairs were reachable ONLY via the + * `get_correlations` tool, so a local / no-tools provider (Ollama) got the + * coincident-deviation flag but ZERO discovered drivers — the differentiating + * cross-metric layer silently degraded, and system-prompt rule 14's fallback + * clause ("any correlation/driver field the SNAPSHOT carries") pointed at a + * field that did not exist on the no-tools path. + * + * This block closes that gap by attaching a BOUNDED slice of the SAME gated, + * ranked output the tool serves — `readCoachCorrelations` runs the identical + * discovery scan + quality gates (effect-size floor, family-tautology + * exclusion, sample-size shrinkage, confidence tiering), so a local user sees + * exactly the quality-filtered drivers a cloud user's tool would. We keep only + * the top few by ranked effect so the no-tools prompt stays compact. + * + * Server-only — `readCoachCorrelations` reads `@/lib/db`. Fail-soft: a null + * return (no surviving driver / read hiccup) attaches nothing. + */ +import { + readCoachCorrelations, + type CoachCorrelationDriver, +} from "./tools/correlations-read"; + +/** How many ranked drivers reach the no-tools snapshot floor. */ +export const SNAPSHOT_DRIVER_CAP = 3; + +/** One compact driver line for the snapshot — descriptive, never causal. */ +export interface CorrelationsSnapshotBlock { + /** Top discovered drivers by ranked effect, capped + descriptive. */ + drivers: CoachCorrelationDriver[]; + /** How many behaviour×outcome pairs were tested (honest footer). */ + pairsTested: number; + /** Trailing-day window the discovery scanned. */ + windowDays: number; +} + +/** + * Build the bounded discovered-driver block for the no-tools snapshot floor, or + * `null` when no driver survives the quality gates (or the read fails). The + * coincident flag is intentionally NOT duplicated here — the derived block + * already carries it; this block adds only the discovered-driver layer the + * no-tools path was missing. + */ +export async function buildCorrelationsSnapshotBlock( + userId: string, +): Promise { + const result = await readCoachCorrelations(userId); + if (!result.present || !result.drivers || result.drivers.length === 0) { + return null; + } + // `result.drivers` arrives already ranked by the discovery engine (shrunk + // effect magnitude, q as tie-break) and already gated (below-floor pairs + // dropped, tautologies excluded) — so the top-N slice is the highest-signal + // quality-filtered drivers, identical to what the tool path would surface. + return { + drivers: result.drivers.slice(0, SNAPSHOT_DRIVER_CAP), + pairsTested: result.pairsTested ?? 0, + windowDays: result.windowDays ?? 0, + }; +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 69050bdd2..ff9e8f1e7 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -46,6 +46,7 @@ import { annotate } from "@/lib/logging/context"; import { memoizePerRequest } from "@/lib/request-cache"; import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; import { buildDerivedSnapshotBlock } from "./derived-snapshot"; +import { buildCorrelationsSnapshotBlock } from "./correlations-snapshot"; import { buildCoachMemoryBlock } from "./memory-snapshot"; import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot"; import { buildCycleSnapshotBlock } from "./cycle-snapshot"; @@ -1259,6 +1260,16 @@ async function buildCoachSnapshotImpl( const trajectoryBlockPromise = derivedActive ? buildTrajectorySnapshotBlock(userId, derivedProfile, now) : null; + // RECON1 (D5-5) — discovered cross-metric driver pairs for the no-tools + // snapshot floor. Reuses the SAME gated/ranked output the get_correlations + // tool serves (effect-size floor + family-tautology exclusion + shrinkage + + // confidence tiering already applied inside the discovery engine), so a + // local/no-tools provider reaches parity on the flagship cross-metric layer + // instead of getting only the coincident flag. Gated on `derivedActive` (it + // is the recovery / cross-metric layer) and fail-soft to null. + const correlationsBlockPromise = derivedActive + ? buildCorrelationsSnapshotBlock(userId) + : null; const memoryBlockPromise = buildCoachMemoryBlock( userId, derivedProfile, @@ -1305,6 +1316,7 @@ async function buildCoachSnapshotImpl( derivedBlock, dayStrainRows, trajectoryBlock, + correlationsBlock, memoryBlock, cycleBlock, illnessBlock, @@ -1320,6 +1332,7 @@ async function buildCoachSnapshotImpl( derivedBlockPromise, dayStrainRowsPromise, trajectoryBlockPromise, + correlationsBlockPromise, memoryBlockPromise, cycleBlockPromise, illnessBlockPromise, @@ -2269,6 +2282,20 @@ async function buildCoachSnapshotImpl( snapshot.trajectory = trajectoryBlock; registerBlock("trajectory", "skin_temp"); } + + // ── RECON1 (D5-5) — discovered cross-metric drivers ────────────────── + // The bounded top-N driver pairs (post quality-gate, post-rank) from the + // SAME engine the get_correlations tool reads, attached so the no-tools / + // local-provider path narrates the cross-metric layer it was missing — + // closing the parity gap with the tool path and making system-prompt rule + // 14's "any driver field the SNAPSHOT carries" fallback real. Descriptive, + // never causal. Registered against the lowest-priority `skin_temp` + // (environment) cluster so the budget degrader sheds it before any + // clinical block under prompt-budget pressure. + if (correlationsBlock) { + snapshot.correlations = correlationsBlock; + registerBlock("correlations", "skin_temp"); + } } // ── v1.11.0 W5a — rolling-profile memory (Pillar P2 2a) ────────────── From 0816b3b0c0a37c6c320001827965559de6811782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:14:19 +0200 Subject: [PATCH 25/51] feat(date): migrate remaining date inputs to DateField Swap the four DateInput callsites (filter bar, export range, schedule history, onboarding baseline) to DateField so the user's date-order preference paints every date input. Adapt the onChange from a native event to the direct ISO value DateField emits, and add an optional autoComplete prop so the onboarding date-of-birth field keeps its bday autofill hint. --- .../scheduling/schedule-history-timeline.tsx | 14 +++++++------- src/components/onboarding/baseline-form.tsx | 6 +++--- src/components/settings/export-section.tsx | 10 +++++----- src/components/ui/date-field.tsx | 4 ++++ src/components/ui/filter-bar.tsx | 10 +++++----- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/components/medications/scheduling/schedule-history-timeline.tsx b/src/components/medications/scheduling/schedule-history-timeline.tsx index 9be638d5f..779a405d7 100644 --- a/src/components/medications/scheduling/schedule-history-timeline.tsx +++ b/src/components/medications/scheduling/schedule-history-timeline.tsx @@ -59,7 +59,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { DateInput } from "@/components/ui/date-input"; +import { DateField } from "@/components/ui/date-field"; import { Label } from "@/components/ui/label"; import { TimesOfDayChips } from "@/components/medications/scheduling/times-of-day-chips"; import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; @@ -545,22 +545,22 @@ function EraDialog({ - setFromDate(e.target.value)} - data-slot="zeitplan-history-from" + onChange={setFromDate} + data-testid="zeitplan-history-from" />
- setUntilDate(e.target.value)} - data-slot="zeitplan-history-until" + onChange={setUntilDate} + data-testid="zeitplan-history-until" />
diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index 2ca6f2d47..979f2dd33 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -8,7 +8,7 @@ import { toast } from "sonner"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { DateInput } from "@/components/ui/date-input"; +import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -242,10 +242,10 @@ export function BaselineForm() { - patch("dateOfBirth", e.target.value)} + onChange={(value) => patch("dateOfBirth", value)} max={new Date().toISOString().slice(0, 10)} autoComplete="bday" /> diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index 8b84ce5f6..7078532a6 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -36,7 +36,7 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { DateInput } from "@/components/ui/date-input"; +import { DateField } from "@/components/ui/date-field"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -181,10 +181,10 @@ function DateRangeFields({ - setSince(e.target.value)} + onChange={setSince} max={until || undefined} /> @@ -192,10 +192,10 @@ function DateRangeFields({ - setUntil(e.target.value)} + onChange={setUntil} min={since || undefined} /> diff --git a/src/components/ui/date-field.tsx b/src/components/ui/date-field.tsx index 049fe5884..04255a45f 100644 --- a/src/components/ui/date-field.tsx +++ b/src/components/ui/date-field.tsx @@ -50,6 +50,8 @@ export interface DateFieldProps { onBlur?: () => void; disabled?: boolean; required?: boolean; + /** Autofill hint forwarded to the native date input (e.g. "bday"). */ + autoComplete?: string; /** ISO `yyyy-MM-dd`. */ min?: string; /** ISO `yyyy-MM-dd`. */ @@ -73,6 +75,7 @@ export const DateField = React.forwardRef( onBlur, disabled, required, + autoComplete, min, max, placeholder, @@ -174,6 +177,7 @@ export const DateField = React.forwardRef( defaultValue={value === undefined ? defaultValue : undefined} disabled={disabled} required={required} + autoComplete={autoComplete} min={min} max={max} onChange={handleNativeChange} diff --git a/src/components/ui/filter-bar.tsx b/src/components/ui/filter-bar.tsx index b27de321e..032c0c563 100644 --- a/src/components/ui/filter-bar.tsx +++ b/src/components/ui/filter-bar.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import { ListFilter, X } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { DateInput } from "@/components/ui/date-input"; +import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -266,12 +266,12 @@ export function FilterBarDateRange({ > {t("dataList.dateFrom")} - onFromChange(e.target.value)} + onChange={onFromChange} />
@@ -281,12 +281,12 @@ export function FilterBarDateRange({ > {t("dataList.dateTo")} - onToChange(e.target.value)} + onChange={onToChange} />
From 174c26afc9a0316a8a72c7055f9bb4c8e2758fb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:15:58 +0200 Subject: [PATCH 26/51] refactor(coach): streamline the new-chat page composer Reduce the full-page Coach new-chat surface to a calmer layout. Drop the toolbar Conversations and New chat buttons and place the settings gear in the top-right corner; both removed actions remain in the composer's + menu. Collapse the greeting to one line and remove the gear from the composer so its front is only the + actions menu, leaving a single baseline row of [+] textarea [mic] [send]. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- .../coach-conversation-deeplink.test.tsx | 28 ++-- .../coach-panel/__tests__/coach-hero.test.tsx | 8 +- .../__tests__/coach-input.test.tsx | 25 +-- .../coach-panel/coach-conversation.tsx | 43 +++-- .../insights/coach-panel/coach-hero.tsx | 11 +- .../insights/coach-panel/coach-input.tsx | 154 ++++++++---------- 12 files changed, 137 insertions(+), 144 deletions(-) diff --git a/messages/de.json b/messages/de.json index 277b88ac3..44bc796c9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2279,7 +2279,7 @@ "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?", + "heroGreeting": "Frage mich etwas zu deinen Daten", "heroSubline": "Frag nach deinen Verläufen, Medikamenten oder Messwerten.", "thinkingElapsed": "{seconds}s", "thinkingDuration": "Nachgedacht für {seconds}s", diff --git a/messages/en.json b/messages/en.json index bf05239fc..d33948ea6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2279,7 +2279,7 @@ "dictateError": "Could not start voice input. Check microphone permission and try again.", "showConversations": "Show conversations", "hideConversations": "Hide conversations", - "heroGreeting": "How can I help you?", + "heroGreeting": "Ask me anything about your data", "heroSubline": "Ask about your trends, medications, or readings.", "thinkingElapsed": "{seconds}s", "thinkingDuration": "Thought for {seconds}s", diff --git a/messages/es.json b/messages/es.json index bb1b6992f..3af706e90 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2279,7 +2279,7 @@ "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?", + "heroGreeting": "Pregúntame lo que quieras sobre tus datos", "heroSubline": "Pregunta por tus tendencias, medicamentos o mediciones.", "thinkingElapsed": "{seconds}s", "thinkingDuration": "Pensó durante {seconds}s", diff --git a/messages/fr.json b/messages/fr.json index fb09bcfa2..d63aafdeb 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2279,7 +2279,7 @@ "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 ?", + "heroGreeting": "Pose-moi une question sur tes données", "heroSubline": "Pose une question sur tes tendances, médicaments ou mesures.", "thinkingElapsed": "{seconds}s", "thinkingDuration": "A réfléchi pendant {seconds}s", diff --git a/messages/it.json b/messages/it.json index 9c2a5496c..a29c170e1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2279,7 +2279,7 @@ "dictateError": "Impossibile avviare l'inserimento vocale. Controlla l'autorizzazione del microfono e riprova.", "showConversations": "Mostra conversazioni", "hideConversations": "Nascondi conversazioni", - "heroGreeting": "Come posso aiutarti?", + "heroGreeting": "Chiedimi qualcosa sui tuoi dati", "heroSubline": "Chiedi dei tuoi andamenti, farmaci o misurazioni.", "thinkingElapsed": "{seconds}s", "thinkingDuration": "Ha riflettuto per {seconds}s", diff --git a/messages/pl.json b/messages/pl.json index e4c29338e..537bd840f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2279,7 +2279,7 @@ "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?", + "heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych", "heroSubline": "Zapytaj o swoje trendy, leki lub pomiary.", "thinkingElapsed": "{seconds}s", "thinkingDuration": "Myślał przez {seconds}s", diff --git a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx index c7bbc9049..e695b6148 100644 --- a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx @@ -136,24 +136,30 @@ describe(" page deep-link (#67)", () => { expect(html).toContain('data-slot="coach-hero"'); }); - // v1.19.1 (C2) — a dedicated, always-visible Conversations button on the - // page surface (not buried in the composer `+` menu). - it("renders the dedicated Conversations button on the page surface", () => { + // v1.21.0 — the page toolbar is a single trailing affordance: the settings + // gear in the top-right corner. The "Conversations" + "New chat" buttons + // were removed from the toolbar (both still live in the composer `+` menu), + // and the settings gear moved here from the composer. + it("renders the settings gear in the top-right page toolbar", () => { const html = render(, makeClient()); - expect(html).toContain('data-slot="coach-page-conversations"'); - expect(html).toContain('data-slot="coach-page-new-chat"'); + expect(html).toContain('data-slot="coach-page-settings"'); + const gear = html.match(/]*data-slot="coach-page-settings"[^>]*>/); + expect(gear?.[0]).toContain('href="/settings/ai"'); + // The old toolbar Conversations + New chat buttons are gone. + expect(html).not.toContain('data-slot="coach-page-conversations"'); + expect(html).not.toContain('data-slot="coach-page-new-chat"'); }); - // v1.19.1 (C5) — entering via the drawer handoff (`?view=conversations`) - // keeps the new-chat hero (no thread auto-resumed) while opening the - // history drawer; the toolbar + button stay present so the pane is never - // a blank dead-end. - it("keeps the hero and toolbar when opened with openHistoryOnMount", () => { + // v1.21.0 — entering via the drawer handoff (`?view=conversations`) keeps + // the new-chat hero (no thread auto-resumed) while opening the history + // drawer; the toolbar gear stays present so the pane is never a blank + // dead-end. + it("keeps the hero and toolbar gear when opened with openHistoryOnMount", () => { const html = render( , makeClient(), ); expect(html).toContain('data-slot="coach-hero"'); - expect(html).toContain('data-slot="coach-page-conversations"'); + expect(html).toContain('data-slot="coach-page-settings"'); }); }); 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 7c36b6892..4d8c717d8 100644 --- a/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx @@ -17,8 +17,10 @@ describe("", () => { composer} />, ); expect(html).toContain('data-slot="coach-hero"'); - // Greeting copy from insights.coach.heroGreeting. - expect(html).toContain("How can I help you?"); + // Greeting copy from insights.coach.heroGreeting — one line now. + expect(html).toContain("Ask me anything about your data"); + // The earlier two-line subline was dropped. + expect(html).not.toContain("Ask about your trends, medications"); // 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"'); @@ -26,7 +28,7 @@ describe("", () => { it("renders the German greeting under the de locale", () => { const html = render(, "de"); - expect(html).toContain("Wie kann ich dir helfen?"); + expect(html).toContain("Frage mich etwas zu deinen Daten"); }); it("does not render starter-question suggestion chips", () => { 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 6f113d0c4..d54280561 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -237,10 +237,11 @@ describe("", () => { expect(html).toContain('data-slot="coach-input-send"'); }); - it("renders the control-hub action row with showHub (page composer)", () => { - // v1.18.11 (W11) — the page composer is the control hub: a `+` actions - // menu (new chat + open conversations) and a settings deep-link sit on - // the action row alongside the mic + send. + it("renders the one-row page composer with showHub", () => { + // v1.21.0 — the page composer is ONE baseline row: a leading `+` actions + // menu (new chat + open conversations), the textarea, then mic + send. + // The settings gear moved OUT of the composer (now the page toolbar's + // top-right), so the composer's front is only the `+`. const html = render( ", () => { ); expect(html).toContain('data-slot="coach-input-hub"'); expect(html).toContain('data-slot="coach-input-actions"'); - // The settings gear deep-links to Settings → AI (not an in-chat sheet). - const settings = html.match( - /]*data-slot="coach-input-settings"[^>]*>/, - ); - expect(settings?.[0]).toContain('href="/settings/ai"'); - // Mic + send remain present in the hub layout. + // The settings gear is gone from the composer entirely. + expect(html).not.toContain('data-slot="coach-input-settings"'); + // Mic + send remain present on the same row. expect(html).toContain('data-slot="coach-input-mic"'); expect(html).toContain('data-slot="coach-input-send"'); + // The leading `+` precedes the textarea; the textarea precedes the send. + const plusIdx = html.indexOf('data-slot="coach-input-actions"'); + const textareaIdx = html.indexOf('data-slot="coach-input-textarea"'); + const sendIdx = html.indexOf('data-slot="coach-input-send"'); + expect(plusIdx).toBeGreaterThan(-1); + expect(plusIdx).toBeLessThan(textareaIdx); + expect(textareaIdx).toBeLessThan(sendIdx); }); it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => { diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 84c074f18..0e5004d52 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -3,7 +3,7 @@ import { useEffect, useReducer, useState } from "react"; import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { MessagesSquare, Plus, Settings, Sparkles } from "lucide-react"; +import { Plus, Settings, Sparkles } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -539,35 +539,30 @@ export function CoachConversation({ data-variant={surface} className={cn("flex min-h-0 flex-1 flex-col", className)} > - {/* v1.19.1 (C2) — a clear, always-visible "Conversations" button on - the page surface. The composer's `+` menu still carries the same - action, but the maintainer wanted an obvious, dedicated affordance - to reach past conversations rather than one buried in a menu. */} + {/* v1.21.0 — the page toolbar is now a single trailing affordance: + the settings gear in the top-right corner. The "Conversations" + and "New chat" controls were removed here — both still live in the + composer's `+` actions menu, keeping the new-chat surface calm and + uncluttered. The gear deep-links to Settings → AI (one place for + model + behaviour), matching the drawer header's gear. */}
-
{heroActive ? ( diff --git a/src/components/insights/coach-panel/coach-hero.tsx b/src/components/insights/coach-panel/coach-hero.tsx index afe3d4561..d97f69887 100644 --- a/src/components/insights/coach-panel/coach-hero.tsx +++ b/src/components/insights/coach-panel/coach-hero.tsx @@ -64,11 +64,13 @@ export function CoachHero({ composer }: CoachHeroProps) { - {/* Greeting — a slightly larger, tighter display treatment of the - existing font (weight/tracking/size only, no new font). */} + {/* Greeting — one line. A slightly larger, tighter display + treatment of the existing font (weight/tracking/size only, no + new font). The earlier two-line subline was dropped: the + new-chat surface reads as a single calm invitation. */}
{t("insights.coach.heroGreeting")} -

- {t("insights.coach.heroSubline")} -

{/* Composer — the live , centred. */} diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index dc0b0e2a3..372682588 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -9,16 +9,7 @@ import { useState, useSyncExternalStore, } from "react"; -import Link from "next/link"; -import { - Loader2, - MessagesSquare, - Mic, - Plus, - Send, - Settings, - Square, -} from "lucide-react"; +import { Loader2, MessagesSquare, Mic, Plus, Send, Square } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -452,6 +443,46 @@ export function CoachInput({ ); + // v1.21.0 — the leading `+` actions menu (page surface only). Opens the + // attachment/scope menu: new chat + open conversations. The settings gear + // no longer lives here — it moved to the page toolbar's top-right corner, + // so the composer's front is ONLY the `+`. Const-lifted like the mic / send + // so it slots into the single-row composer without forking the layout. + const actionsButton = ( + + + + + + onNewChat?.()} + > + + onOpenHistory?.()} + > + + + + ); + return (
{/* v1.16.1 / v1.18.7 — modern chat-app composer: a single rounded - field. The drawer surface keeps the textarea flanked by the mic - (left) and send / stop (right) on one baseline. The page surface - (`showHub`) stacks the textarea over a control-hub action row so - the composer carries the conversation controls ChatGPT-style. - `items-end` keeps the single-row controls pinned to the input's - last line as it grows. Enter sends, Shift+Enter inserts a newline. */} + field, ONE baseline row. The drawer surface flanks the textarea with + the mic (left) and send / stop (right). The page surface (`showHub`) + leads with a `+` actions menu (new chat + open conversations), then + the textarea, then the mic + send — same row, vertically centred. + The settings gear is NOT in the composer; it lives in the page + toolbar's top-right corner. `items-end` keeps the flanking controls + pinned to the input's last line as it grows. Enter sends, + Shift+Enter inserts a newline. */}
- {/* v1.18.10 (W4) — the mic always renders so it stays discoverable. - When the browser lacks the Web Speech API (or during SSR) it is - DISABLED with an explanatory tooltip rather than vanishing or - sitting as a dead control that does nothing on tap. In the hub - layout the mic moves into the action row below the textarea. */} - {!showHub && micButton} + {/* Leading control. Page surface: the `+` actions menu. Drawer + surface: the dictation mic. */} + {showHub ? actionsButton : micButton}