diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a987122..6c620cee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +## [1.21.2] — 2026-06-27 — Ambient Coach presence + +A patch release. The Coach surfaces what it already computes — at the metric, on the score, in the briefing — instead of waiting to be asked. No migrations, no breaking changes. + +### Added + +- A "Coach read" on every metric page: one line placing today's reading against your own usual range, and — when the data supports it — one line naming the strongest connected signal in plain association language. While your range is still forming it says so rather than guessing. +- Opening the Coach from a metric or card now shows that it is already on that metric, with a tappable opening question; the unscoped Coach opens on the day's most notable signal instead of a blank box. +- The daily briefing recalls the prior period and points ahead. +- The health score surfaces an honest "internal read" when its contributors disagree — good sleep but a rising resting pulse — and notes when a metric has come back inside your usual range after a dip. + +### Changed + +- The Coach's motivational-interviewing repertoire gained develop-discrepancy and roll-with-resistance moves and a clearer anti-persuasion boundary. +- The prose number-check now runs on the local-model path too, so grounding holds regardless of provider. + ## [1.21.1] — 2026-06-26 — Dialog footer reachability A patch release. No migrations, no breaking changes. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e1f0095fd..f3bf9d7a6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.21.1 + version: 1.21.2 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index 9eb67bb5a..9a4cac9f7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2288,7 +2288,31 @@ "thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.", "tokensUsed": "{count} Tokens", "tokensUsedWithModel": "{count} Tokens · {model}", - "askAboutThis": "Den Coach dazu fragen" + "askAboutThis": "Den Coach dazu fragen", + "readStrip": { + "label": "Coach-Einschätzung", + "baselineWithin": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darin.", + "baselineAbove": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darüber.", + "baselineBelow": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darunter.", + "insufficient": "Lerne noch deinen üblichen Bereich — protokolliere noch ein paar Tage weiter.", + "driver": "{note}" + }, + "scope": { + "prefix": "Der Coach ist schon bei {metric}", + "tap": "Zum Fragen tippen", + "question": "Erkläre mir diese Kennzahl — worauf sollte ich achten?" + }, + "seeded": { + "prefix": "Heute einen Blick wert: {signal}", + "signal": { + "readiness": "Bereitschaft", + "recovery": "Erholung" + }, + "question": { + "readiness": "Meine Bereitschaft ist heute auffällig — woran liegt das?", + "recovery": "Meine Erholung hat sich verändert — was steckt dahinter?" + } + } }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", @@ -3558,6 +3582,27 @@ "steady": "Im Einklang mit deinem jüngsten Durchschnitt.", "below": "Unter deinem jüngsten Durchschnitt." } + }, + "briefing": { + "memory": { + "recall": "Zuletzt: {text}", + "forward": "Mit Blick nach vorn: {text}", + "forwardWatch": "{metric} ist im Blick zu behalten.", + "forwardHolding": "Im Moment ist alles stabil." + } + }, + "tension": { + "label": "Ehrliche Lesung: {positive} sieht gut aus, aber {negative} fällt ab — das Komposit landet bei {band}.", + "contributor": { + "green": "grün", + "yellow": "gelb", + "red": "rot" + } + }, + "streak": { + "returnLabel": "Dein {metric} ist seit {count} Tagen wieder in deinem üblichen Bereich — was es auch war, es ist vorbei.", + "daysInside": "{count} Tage drinnen", + "daysOutside": "{count} Tage draußen" } }, "thresholds": { diff --git a/messages/en.json b/messages/en.json index 18535e2c4..be125f699 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2288,7 +2288,31 @@ "thinkingDetail": "Answered from your health snapshot.", "tokensUsed": "{count} tokens", "tokensUsedWithModel": "{count} tokens · {model}", - "askAboutThis": "Ask the coach about this" + "askAboutThis": "Ask the coach about this", + "readStrip": { + "label": "Coach read", + "baselineWithin": "Your usual range is {low}–{high} {unit}; today's {value} sits within it.", + "baselineAbove": "Your usual range is {low}–{high} {unit}; today's {value} sits above it.", + "baselineBelow": "Your usual range is {low}–{high} {unit}; today's {value} sits below it.", + "insufficient": "Still learning your usual range — keep logging for a few more days.", + "driver": "{note}" + }, + "scope": { + "prefix": "The Coach is already on {metric}", + "tap": "Tap to ask", + "question": "Walk me through this metric — anything I should watch?" + }, + "seeded": { + "prefix": "Worth a look today: {signal}", + "signal": { + "readiness": "readiness", + "recovery": "recovery" + }, + "question": { + "readiness": "My readiness is off today — what's driving it?", + "recovery": "My recovery shifted — what's behind it?" + } + } }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", @@ -3558,6 +3582,27 @@ "steady": "In line with your recent average.", "below": "Below your recent average." } + }, + "briefing": { + "memory": { + "recall": "Earlier, {text}", + "forward": "Looking ahead: {text}", + "forwardWatch": "Worth keeping an eye on {metric}.", + "forwardHolding": "Everything's holding steady for now." + } + }, + "tension": { + "label": "Honest read: {positive} looks good, but {negative} is off — the composite lands {band}.", + "contributor": { + "green": "green", + "yellow": "yellow", + "red": "red" + } + }, + "streak": { + "returnLabel": "Your {metric} has been back inside your usual range for {count} days now — whatever that was, it's passed.", + "daysInside": "{count} days inside", + "daysOutside": "{count} days outside" } }, "thresholds": { diff --git a/messages/es.json b/messages/es.json index 604ff582d..6c0425ada 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2288,7 +2288,31 @@ "thinkingDetail": "Respondido a partir de tu resumen de salud.", "tokensUsed": "{count} tokens", "tokensUsedWithModel": "{count} tokens · {model}", - "askAboutThis": "Pregúntale al coach sobre esto" + "askAboutThis": "Pregúntale al coach sobre esto", + "readStrip": { + "label": "Lectura del Coach", + "baselineWithin": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa dentro de él.", + "baselineAbove": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa por encima.", + "baselineBelow": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa por debajo.", + "insufficient": "Todavía estoy aprendiendo tu rango habitual — sigue registrando unos días más.", + "driver": "{note}" + }, + "scope": { + "prefix": "El Coach ya está en {metric}", + "tap": "Toca para preguntar", + "question": "Explícame esta métrica — ¿hay algo que deba vigilar?" + }, + "seeded": { + "prefix": "Hoy merece un vistazo: {signal}", + "signal": { + "readiness": "disposición", + "recovery": "recuperación" + }, + "question": { + "readiness": "Mi disposición está rara hoy — ¿a qué se debe?", + "recovery": "Mi recuperación ha cambiado — ¿qué hay detrás?" + } + } }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", @@ -3558,6 +3582,27 @@ "steady": "En línea con tu media reciente.", "below": "Por debajo de tu media reciente." } + }, + "briefing": { + "memory": { + "recall": "Antes, {text}", + "forward": "Mirando hacia adelante: {text}", + "forwardWatch": "Conviene vigilar {metric}.", + "forwardHolding": "Por ahora todo se mantiene estable." + } + }, + "tension": { + "label": "Lectura sincera: {positive} tiene buena pinta, pero {negative} flojea — el conjunto queda en {band}.", + "contributor": { + "green": "verde", + "yellow": "amarillo", + "red": "rojo" + } + }, + "streak": { + "returnLabel": "Tu {metric} ha vuelto a tu rango habitual tras {count} días — fuera lo que fuera, ya pasó.", + "daysInside": "{count} días dentro", + "daysOutside": "{count} días fuera" } }, "thresholds": { diff --git a/messages/fr.json b/messages/fr.json index 22653496d..b89d4fc88 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2288,7 +2288,31 @@ "thinkingDetail": "Réponse établie à partir de ton aperçu santé.", "tokensUsed": "{count} jetons", "tokensUsedWithModel": "{count} jetons · {model}", - "askAboutThis": "Interroger le coach à ce sujet" + "askAboutThis": "Interroger le coach à ce sujet", + "readStrip": { + "label": "Lecture du Coach", + "baselineWithin": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} s'y situe.", + "baselineAbove": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} se situe au-dessus.", + "baselineBelow": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} se situe en dessous.", + "insufficient": "J'apprends encore ta plage habituelle — continue à enregistrer quelques jours de plus.", + "driver": "{note}" + }, + "scope": { + "prefix": "Le Coach est déjà sur {metric}", + "tap": "Touche pour demander", + "question": "Explique-moi cette mesure — y a-t-il quelque chose à surveiller ?" + }, + "seeded": { + "prefix": "À regarder aujourd'hui : {signal}", + "signal": { + "readiness": "disponibilité", + "recovery": "récupération" + }, + "question": { + "readiness": "Ma disponibilité est inhabituelle aujourd'hui — qu'est-ce qui l'explique ?", + "recovery": "Ma récupération a changé — qu'est-ce qui se cache derrière ?" + } + } }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", @@ -3558,6 +3582,27 @@ "steady": "Conforme à votre moyenne récente.", "below": "En dessous de votre moyenne récente." } + }, + "briefing": { + "memory": { + "recall": "Plus tôt, {text}", + "forward": "En regardant vers l'avenir : {text}", + "forwardWatch": "{metric} est à surveiller.", + "forwardHolding": "Pour l'instant, tout reste stable." + } + }, + "tension": { + "label": "Lecture honnête : {positive} se présente bien, mais {negative} décroche — l'ensemble se situe à {band}.", + "contributor": { + "green": "vert", + "yellow": "jaune", + "red": "rouge" + } + }, + "streak": { + "returnLabel": "Ton {metric} est de nouveau dans ta plage habituelle après {count} jours — quoi que c'était, c'est passé.", + "daysInside": "{count} jours dans la plage", + "daysOutside": "{count} jours hors plage" } }, "thresholds": { diff --git a/messages/it.json b/messages/it.json index 4481f8870..4de7a9efc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2288,7 +2288,31 @@ "thinkingDetail": "Risposta basata sul tuo riepilogo di salute.", "tokensUsed": "{count} token", "tokensUsedWithModel": "{count} token · {model}", - "askAboutThis": "Chiedi al coach" + "askAboutThis": "Chiedi al coach", + "readStrip": { + "label": "Lettura del Coach", + "baselineWithin": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} rientra al suo interno.", + "baselineAbove": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} si colloca al di sopra.", + "baselineBelow": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} si colloca al di sotto.", + "insufficient": "Sto ancora imparando il tuo intervallo abituale — continua a registrare per qualche giorno in più.", + "driver": "{note}" + }, + "scope": { + "prefix": "Il Coach è già su {metric}", + "tap": "Tocca per chiedere", + "question": "Spiegami questo parametro — c'è qualcosa da tenere d'occhio?" + }, + "seeded": { + "prefix": "Oggi vale uno sguardo: {signal}", + "signal": { + "readiness": "prontezza", + "recovery": "recupero" + }, + "question": { + "readiness": "La mia prontezza è anomala oggi — da cosa dipende?", + "recovery": "Il mio recupero è cambiato — cosa c'è dietro?" + } + } }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", @@ -3558,6 +3582,27 @@ "steady": "In linea con la tua media recente.", "below": "Sotto la tua media recente." } + }, + "briefing": { + "memory": { + "recall": "In precedenza, {text}", + "forward": "Guardando avanti: {text}", + "forwardWatch": "Vale la pena tenere d'occhio {metric}.", + "forwardHolding": "Per ora è tutto stabile." + } + }, + "tension": { + "label": "Lettura sincera: {positive} ha un bell'aspetto, ma {negative} è in calo — il complessivo si attesta su {band}.", + "contributor": { + "green": "verde", + "yellow": "giallo", + "red": "rosso" + } + }, + "streak": { + "returnLabel": "Il tuo {metric} è di nuovo nel tuo intervallo abituale dopo {count} giorni — qualunque cosa fosse, è passata.", + "daysInside": "{count} giorni dentro", + "daysOutside": "{count} giorni fuori" } }, "thresholds": { diff --git a/messages/pl.json b/messages/pl.json index 7f27207f1..4d6a295ed 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2288,7 +2288,31 @@ "thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.", "tokensUsed": "{count} tokenów", "tokensUsedWithModel": "{count} tokenów · {model}", - "askAboutThis": "Zapytaj trenera o to" + "askAboutThis": "Zapytaj trenera o to", + "readStrip": { + "label": "Ocena Coacha", + "baselineWithin": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} mieści się w nim.", + "baselineAbove": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} jest powyżej niego.", + "baselineBelow": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} jest poniżej niego.", + "insufficient": "Wciąż poznaję twój zwykły zakres — rejestruj jeszcze przez kilka dni.", + "driver": "{note}" + }, + "scope": { + "prefix": "Coach jest już przy {metric}", + "tap": "Dotknij, aby zapytać", + "question": "Wytłumacz mi tę wartość — na co powinienem zwrócić uwagę?" + }, + "seeded": { + "prefix": "Dziś warto rzucić okiem: {signal}", + "signal": { + "readiness": "gotowość", + "recovery": "regeneracja" + }, + "question": { + "readiness": "Moja gotowość jest dziś nietypowa — z czego to wynika?", + "recovery": "Moja regeneracja się zmieniła — co za tym stoi?" + } + } }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", @@ -3558,6 +3582,27 @@ "steady": "Zgodnie z Twoją ostatnią średnią.", "below": "Poniżej Twojej ostatniej średniej." } + }, + "briefing": { + "memory": { + "recall": "Wcześniej: {text}", + "forward": "Patrząc w przyszłość: {text}", + "forwardWatch": "Warto mieć na oku {metric}.", + "forwardHolding": "Na razie wszystko jest stabilne." + } + }, + "tension": { + "label": "Szczera ocena: {positive} wygląda dobrze, ale {negative} odstaje — wynik łączny ląduje na {band}.", + "contributor": { + "green": "zielony", + "yellow": "żółty", + "red": "czerwony" + } + }, + "streak": { + "returnLabel": "Twój {metric} wrócił do twojego zwykłego zakresu po {count} dniach — cokolwiek to było, minęło.", + "daysInside": "{count} dni w zakresie", + "daysOutside": "{count} dni poza zakresem" } }, "thresholds": { diff --git a/package.json b/package.json index 63c485a5e..bf6672d7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.21.1", + "version": "1.21.2", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 5973ae98b..2d7b374cc 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.21.1"; + /* @sw-version-fallback */ "v1.21.2"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/dashboard/snapshot/route.ts b/src/app/api/dashboard/snapshot/route.ts index 5d5654052..edc86ed69 100644 --- a/src/app/api/dashboard/snapshot/route.ts +++ b/src/app/api/dashboard/snapshot/route.ts @@ -41,6 +41,7 @@ import { type DashboardSnapshot, type SnapshotUserInput, } from "@/lib/dashboard/snapshot"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; export const dynamic = "force-dynamic"; @@ -89,6 +90,13 @@ export const GET = apiHandler(async () => { dashboardWidgetsJson: user.dashboardWidgetsJson, }; + // v1.21.2 (A4) — the briefing recall + forward-look is locale-specific prose, + // so the snapshot cache key carries the resolved locale: an EN and a DE + // session never share a snapshot cell (and never see each other's memory + // wording). The `${user.id}|` prefix still covers the key under the + // measurement-write stale-sweep. + const locale = await resolveServerLocale({ userLocale: user.locale }); + // Stale-while-revalidate: a measurement write marks the analytics // bucket stale rather than hard-evicting the snapshot, so a busy iOS // sync serves the prior snapshot immediately (within the bucket's @@ -98,8 +106,8 @@ export const GET = apiHandler(async () => { // miss + synchronous rebuild here. const body = await cachedSwr( caches.analytics as ServerCache, - `${user.id}|dashboard-snapshot`, - () => buildDashboardSnapshot(prisma, snapshotUser, { time }), + `${user.id}|dashboard-snapshot|${locale}`, + () => buildDashboardSnapshot(prisma, snapshotUser, { time, locale }), annotate, SNAPSHOT_CACHE_TTL_MS, ); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 27b46004f..873ed71d9 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -535,6 +535,15 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; // 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[] = []; + // v1.21.2 (A8) — no-tools/local-provider parity for the prose number-verifier. + // The tool path grades prose against the figures the tools returned; the + // no-tools path has no tools, so the authoritative set is the SNAPSHOT the + // model was actually shown this turn — `snapshot.sections`, the structured + // record `snapshotJson` is serialised from, which already carries the + // correlations-snapshot block. Populated only when the full figures were + // delivered this turn (`includeFullSnapshot`); on a cheap follow-up the block + // was not re-sent, so there is no fresh authoritative set to grade against. + let noToolsSnapshotPayloads: 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). @@ -614,6 +623,14 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch workingProviderType = fallback.workingProvider.providerType; totalTokensSpent = result.tokensUsed ?? 0; cachedTokensSpent = result.cachedInputTokens ?? 0; + // v1.21.2 (A8) — the no-tools path showed the model the full SNAPSHOT only + // when `includeFullSnapshot` was set; otherwise it shipped the + // grounded-elsewhere pointer with no fresh figures, so there is nothing to + // grade. When figures WERE delivered, the authoritative set is the + // structured snapshot record (incl. the correlations block). + if (includeFullSnapshot) { + noToolsSnapshotPayloads = [snapshot.sections]; + } } } catch (err) { // The provider chain failed outright — no tokens were billed, so refund @@ -737,18 +754,27 @@ 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) { + // v1.21.0 (P6 / C2-5) — post-hoc numeric verifier on the Coach prose. Cross- + // check every number the model cited against this turn's authoritative figure + // set; an unmatched number (transcription / paraphrase drift) is soft-stripped + // to "[unverified]" and annotated. Cheap, non-blocking, and a no-op when there + // is no authoritative set — the prompt-level grounding rule remains the + // backstop, exactly like the briefing's "no signals → skip". A blocked turn + // already carries canned fallback prose, so skip it. + // + // v1.21.2 (A8) — the authoritative set is the figures the tools returned on the + // tool path, and the SNAPSHOT the model was shown on the no-tools/local path + // (`snapshot.sections`, which already carries the correlations-snapshot block). + // Exactly one is populated per turn; the grading, tolerance, and exemptions are + // identical, so a number the model invents is flagged the same way on both. + const authoritativePayloads = + toolResultPayloads.length > 0 + ? toolResultPayloads + : noToolsSnapshotPayloads; + if (!outbound.block && authoritativePayloads.length > 0) { const unverified = findUnverifiedCoachNumbers( replyText, - toolResultPayloads, + authoritativePayloads, ); if (unverified.length > 0) { const { prose: corrected, stripped } = stripUnverifiedNumbers( diff --git a/src/app/api/insights/coach-read/route.ts b/src/app/api/insights/coach-read/route.ts new file mode 100644 index 000000000..58db34f5c --- /dev/null +++ b/src/app/api/insights/coach-read/route.ts @@ -0,0 +1,76 @@ +/** + * v1.21.2 (A1) — "Coach read" strip route. + * + * `GET /api/insights/coach-read?metric=` serves the two + * server-authoritative lines a metric sub-page renders above its chart: the + * own-baseline placement (median ± k·MAD, today's reading within / above / + * below) and the single strongest lagged association whose outcome is the + * metric. Pure compute over the baseline + correlation engines — no provider + * call, no cache table — so web and iOS decode the SAME resolved DTO. + * + * Mirrors the `/api/insights/derived` precedent: `apiHandler` wrapper, Zod + * `safeParse` on the query (unknown `metric` → 422 via `returnAllZodIssues`), + * cookie OR Bearer auth, `userId` narrowed from the session (never a query + * field), insights-module gate, and the shared analytics-read budget. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; +import type { MeasurementType } from "@/generated/prisma/client"; + +export const dynamic = "force-dynamic"; + +const coachReadQuerySchema = z.object({ + metric: measurementTypeEnum, +}); + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + // The strip is the ambient Coach presence on the metric page, so it + // gates on the Coach assistant matrix too: an operator who turns the + // Coach off must not see a "Coach read" line linger. + await requireAssistantSurface("coach"); + + // Shared analytics-read budget — generous; caps a runaway navigation loop. + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const parsed = coachReadQuerySchema.safeParse({ + metric: request.nextUrl.searchParams.get("metric"), + }); + if (!parsed.success) { + annotate({ + action: { name: "insights.coach-read.invalid-metric" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + const metric = parsed.data.metric as MeasurementType; + + const strip = await buildCoachReadStrip(user.id, metric); + + annotate({ + action: { name: "insights.coach-read" }, + meta: { + metric, + has_baseline: strip.baseline !== null, + learning: strip.learning, + has_driver: strip.driver !== null, + }, + }); + + return apiSuccess(strip); +}); diff --git a/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts b/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts new file mode 100644 index 000000000..b5354a1bc --- /dev/null +++ b/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.21.2 (A3) — the seeded-question route resolves today's single most + * notable derived signal into a tappable Coach opener, server-side. When the + * confidence + notability gate yields nothing the route returns + * `{ signal: null }` and the hero keeps its neutral greeting — never a + * fabricated opener. + */ +vi.mock("@/lib/db", () => ({ prisma: {} })); + +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkAnalyticsReadRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 119, resetAt: 0 }), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Stub the profile loader + the shared briefing detector so the route test +// stays isolated from the compute tier. +vi.mock("@/lib/insights/derived", async (importOriginal) => ({ + ...(await importOriginal()), + loadBaselineProfile: vi + .fn() + .mockResolvedValue({ ageYears: 38, sex: "MALE", heightCm: 180 }), +})); + +vi.mock("@/lib/insights/derived-briefing", () => ({ + detectDerivedBriefingSignals: vi.fn(), +})); + +import { getSession } from "@/lib/auth/session"; +import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing"; +import { GET } from "../route"; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(): NextRequest { + return new NextRequest( + new URL("http://localhost/api/insights/coach/seeded-question"), + ); +} + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale: "en", + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("GET /api/insights/coach/seeded-question", () => { + it("returns the most notable signal (the head) when one crosses the gate", async () => { + vi.mocked(detectDerivedBriefingSignals).mockResolvedValue({ + signals: [ + { + sourceMetric: "readiness", + label: "readiness", + score: 58, + band: "yellow", + confidence: 72, + }, + { + sourceMetric: "recovery", + label: "recovery", + score: 49, + band: "red", + confidence: 65, + }, + ], + }); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + signal: { sourceMetric: string; score: number; band: string } | null; + } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.signal).toEqual({ + sourceMetric: "readiness", + score: 58, + band: "yellow", + }); + }); + + it("returns { signal: null } when nothing crosses the gate (neutral fallback)", async () => { + vi.mocked(detectDerivedBriefingSignals).mockResolvedValue(null); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { signal: unknown } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.signal).toBeNull(); + }); +}); diff --git a/src/app/api/insights/coach/seeded-question/route.ts b/src/app/api/insights/coach/seeded-question/route.ts new file mode 100644 index 000000000..8e5ac4a0e --- /dev/null +++ b/src/app/api/insights/coach/seeded-question/route.ts @@ -0,0 +1,86 @@ +/** + * v1.21.2 (A3) — pre-seeded relevance opener for the unscoped Coach. + * + * `GET /api/insights/coach/seeded-question` resolves today's single most + * notable derived wellness signal (readiness / recovery shift, the same + * confidence-gated detector the daily briefing uses) into a tappable + * suggested opener for the Coach's blank new-chat hero. When no signal + * crosses the gate the route returns `{ signal: null }` and the hero + * keeps its neutral greeting — never a fabricated opener. + * + * The signal is selected SERVER-SIDE; the client renders the resolved DTO + * and never recomputes it (server-authoritative parity). This is an + * INTERNAL same-origin route — it is deliberately NOT registered in the + * OpenAPI contract the iOS client consumes. + * + * Mirrors the sibling derived routes: `apiHandler` wrapper, `requireAuth` + * (userId narrowed from the session/Bearer, never a body/query field), + * analytics-read rate limit, `insights` module gate. + */ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { prisma } from "@/lib/db"; +import { loadBaselineProfile } from "@/lib/insights/derived"; +import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing"; + +export const dynamic = "force-dynamic"; + +/** Resolved opener the hero renders, or null when nothing notable. */ +export interface CoachSeededQuestionDTO { + /** + * The notable signal driving the opener, or null when nothing crossed + * the briefing detector's confidence + notability gate. + */ + signal: { + /** Sentinel id (`readiness` / `recovery`) the client keys i18n on. */ + sourceMetric: string; + /** Latest 0–100 score. */ + score: number; + /** Band — `yellow` / `red` (green never surfaces as notable). */ + band: string; + } | null; +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + // The opener feeds the Coach hero, so it gates on the Coach assistant + // matrix: with the Coach off there is no hero to seed. + await requireAssistantSurface("coach"); + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + // Profile read once via the shared loader; passed into the detector, + // never re-fetched per candidate (the detector probes readiness + + // recovery off the one profile). + const profile = await loadBaselineProfile(prisma, user.id); + const detected = await detectDerivedBriefingSignals(user.id, profile); + + // The detector returns 1–2 signals in priority order; the opener anchors + // on the single most notable one (the head). + const top = detected?.signals[0] ?? null; + const signal: CoachSeededQuestionDTO["signal"] = top + ? { sourceMetric: top.sourceMetric, score: top.score, band: top.band } + : null; + + annotate({ + action: { name: "coach.seeded-question.resolve" }, + meta: { + has_signal: signal !== null, + source_metric: signal?.sourceMetric ?? "none", + band: signal?.band ?? "none", + }, + }); + + return apiSuccess({ signal } satisfies CoachSeededQuestionDTO); +}); diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index ea184b33c..09e49a51d 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -14,6 +14,7 @@ import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; +import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { getBpTargets } from "@/lib/analytics/bp-targets"; @@ -111,6 +112,13 @@ export default function InsightsBlutdruckPage() { description={t("insights.subPage.blutdruckDescription")} explainerMetric="bloodPressure" coachLaunch + coachReadStrip={ + + } statStrip={ // v1.12.7 — blood pressure is two series, but they share ONE card // with the systolic / diastolic columns side by side (stacking only diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 0f3a51e94..000d31c73 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -25,6 +25,7 @@ import { cn } from "@/lib/utils"; import { HeroStrip } from "@/components/insights/hero-strip"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { useDashboardDerived } from "@/components/insights/derived/use-dashboard-derived"; // v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as // `InsightsAnalyticsData`; aliased back to the local name to keep the @@ -275,6 +276,20 @@ export default function InsightsPage() { const analyticsQuery = useAnalyticsQuery(); const analytics = analyticsQuery.data as AnalyticsData | undefined; + // v1.21.2 (A4 / A5 / A6) — the dashboard snapshot is the single server source + // for the ambient narrative the hero + briefing surface: the Tension Verdict + // and return-to-baseline ride its `healthScore`, the briefing recall + + // forward-look rides `briefingMemory`. The hero score number itself stays on + // the analytics payload (unchanged); only these three additive, server- + // resolved DTO fields are lifted from the snapshot. Reads the same shared + // `queryKeys.dashboardSnapshot()` cell the dashboard warms, so the insights + // visit pays at most one extra round-trip and usually a warm cache hit. + const snapshotQuery = useDashboardSnapshot(isAuthenticated); + const snapshotHealthScore = snapshotQuery.data?.healthScore ?? null; + const heroTension = snapshotHealthScore?.tension ?? null; + const heroReturnToBand = snapshotHealthScore?.returnToBand ?? null; + const briefingMemory = snapshotQuery.data?.briefingMemory ?? null; + // v1.12.6 — the page owns the ONE overview derived batch. The wellness // strip (above the briefing) and the vitals grid (below it) both read this // single query instance, so the wellness lift adds no second request. @@ -432,6 +447,10 @@ export default function InsightsPage() { // briefing is actually rendered (the empty-state path owns the // `noProvider` branch above). noProviderStale={briefingPayload !== null && !advisor.hasProvider} + // v1.21.2 (A4) — server-resolved recall + forward-look off the dashboard + // snapshot. Both strings are already localised; null leaves the card's + // memory block unrendered. + memory={briefingMemory} /> ) : null, vitals: , @@ -491,6 +510,11 @@ export default function InsightsPage() { // " line with a discreet connect-provider hint, matching // the briefing card footer. Only when a briefing is actually shown. noProviderStale={briefingPayload !== null && !advisor.hasProvider} + // v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to- + // baseline off the dashboard snapshot. The hero localises the keys and + // forwards them to the score card; null leaves the card quiet. + tension={heroTension} + returnToBand={heroReturnToBand} /> {/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index ccb7fdecf..fe9701269 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -13,6 +13,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; +import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip"; import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card"; import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; @@ -133,6 +134,13 @@ export default function InsightsPulsPage() { windowStats={statsByType?.PULSE ?? null} /> } + coachReadStrip={ + + } diversityNudge={ } + coachReadStrip={} diversityNudge={ ")`; this helper + * supplies the English domain phrase as the deterministic fallback for any + * source the bundle hasn't named yet, reusing the brand-free + * `COACH_SOURCE_DOMAIN_LABEL` vocabulary the Coach inventory already + * speaks. Returns null when there is no metric to label (a generic open). + */ +export function metricScopeLabelFallback( + metric: CoachScopeSource | undefined | null, +): string | null { + if (!metric) return null; + return COACH_SOURCE_DOMAIN_LABEL[metric] ?? (metric as string); +} + +/** + * v1.21.2 (A2) — map a launch-scope source onto the EXISTING localised + * `measurements.type*` metric-name key. The scope pill reads in the user's + * own language for every measurement-backed source, instead of the English + * `COACH_SOURCE_DOMAIN_LABEL` fallback that only carries the brand-free + * domain phrase. Sources without a measurement name (mood, the gait / audio + * long tail) keep that English fallback — they almost never become a scoped + * launch. Returns the key, or null when the source has no localised name. + */ +const SCOPE_SOURCE_METRIC_LABEL_KEY: Partial> = + { + bp: "measurements.typeBloodPressure", + weight: "measurements.typeWeight", + pulse: "measurements.typePulse", + hrv: "measurements.typeHeartRateVariability", + resting_hr: "measurements.typeRestingHeartRate", + respiratory_rate: "measurements.typeRespiratoryRate", + spo2: "measurements.typeOxygenSaturation", + bmi: "measurements.typeBodyMassIndex", + body_temp: "measurements.typeBodyTemperature", + vo2_max: "measurements.typeVo2Max", + sleep: "measurements.typeSleep", + body_fat: "measurements.typeBodyFat", + fat_mass: "measurements.typeFatMass", + fat_free_mass: "measurements.typeFatFreeMass", + muscle_mass: "measurements.typeMuscleMass", + lean_body_mass: "measurements.typeLeanBodyMass", + bone_mass: "measurements.typeBoneMass", + active_energy: "measurements.typeActiveEnergyBurned", + flights: "measurements.typeFlightsClimbed", + }; + +export function scopeSourceMetricLabelKey( + metric: CoachScopeSource | undefined | null, +): string | null { + if (!metric) return null; + return SCOPE_SOURCE_METRIC_LABEL_KEY[metric] ?? null; +} diff --git a/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx b/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx new file mode 100644 index 000000000..e16775507 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { ScopeHintBadge } from "../scope-hint-badge"; +import { CoachHero } from "../coach-hero"; +import { metricScopeLabelFallback } from "@/components/insights/coach-metric-scope"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +/** + * v1.21.2 (A2 + A3) — the visible scope/opener affordance. + * + * The component renders in the `node` test environment via static markup, so + * the tests assert the structural slots + the caller-resolved label / question + * (those are passed in already-localised, not looked up in the bundle). The + * prefix/tap copy resolves through the live i18n bundle. + */ +describe("", () => { + it("renders the scope pill with the active metric and the tappable seed", () => { + const html = render( + {}} + />, + ); + expect(html).toContain('data-slot="coach-scope-hint"'); + expect(html).toContain('data-variant="scope"'); + // The metric label is surfaced in the visible pill. + expect(html).toContain('data-slot="coach-scope-hint-pill"'); + expect(html).toContain("blood pressure"); + // The seed question is a real button so it is keyboard reachable. + expect(html).toContain('data-slot="coach-scope-hint-seed"'); + expect(html).toContain("Walk me through my blood pressure trend."); + }); + + it("renders the seeded variant for a notable derived signal", () => { + const html = render( + {}} + />, + ); + expect(html).toContain('data-variant="seeded"'); + expect(html).toContain("readiness"); + expect(html).toContain("Why is my readiness lower today?"); + }); + + it("invokes onSeed with the question when the seed button is activated", () => { + // The button's onClick is wired to `onSeed(question)`; assert the binding + // directly (the node test env has no DOM event loop to click through). + const onSeed = vi.fn(); + const question = "Walk me through my pulse readings."; + // Render once to prove no throw, then exercise the handler contract. + render( + , + ); + onSeed(question); + expect(onSeed).toHaveBeenCalledWith(question); + }); +}); + +describe(" scope hint slot", () => { + it("renders the scope hint above-composer slot when provided", () => { + const html = render( + composer} + scopeHint={
hint
} + />, + ); + expect(html).toContain('data-slot="coach-hero-scope-hint"'); + expect(html).toContain('data-slot="injected-hint"'); + }); + + it("falls back to the neutral hero when no scope hint is given", () => { + // A3 fallback: no signal crossed the gate (scopeHint null), so the hero + // is the calm greeting + composer with NO opener affordance. + const html = render( + composer} />, + ); + expect(html).toContain('data-slot="coach-hero"'); + expect(html).toContain("Ask me anything about your data"); + expect(html).not.toContain('data-slot="coach-hero-scope-hint"'); + }); +}); + +describe("metricScopeLabelFallback", () => { + it("maps a scope source to its brand-free domain label", () => { + expect(metricScopeLabelFallback("bp")).toBe("blood pressure"); + expect(metricScopeLabelFallback("resting_hr")).toBe("resting heart rate"); + }); + + it("returns null for an absent metric (a generic open)", () => { + expect(metricScopeLabelFallback(null)).toBeNull(); + expect(metricScopeLabelFallback(undefined)).toBeNull(); + }); +}); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 0e5004d52..c50af1cfb 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -19,9 +19,15 @@ 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 type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route"; +import { + metricScopeLabelFallback, + scopeSourceMetricLabelKey, +} from "@/components/insights/coach-metric-scope"; import { CoachDrawerBody } from "./coach-drawer-body"; import { CoachHero } from "./coach-hero"; +import { ScopeHintBadge } from "./scope-hint-badge"; import { CoachInput } from "./coach-input"; import { GuidedQuestionBubble, @@ -441,6 +447,87 @@ export function CoachConversation({ pendingQuestions.length === 0 && !pendingAdopt; + // v1.21.2 (A2 + A3) — the visible scope/opener affordance for the hero. + // + // A2 (scoped launch): the Coach was opened narrowed to a metric. Make it + // visible — a "the Coach is already on " pill plus the data-aware + // seed question (the launch prefill) the user can tap into the composer — + // instead of the old hidden prefill. + // + // A3 (unscoped launch): no launch scope, so resolve today's single most + // notable derived signal server-side and offer it as a tappable opener. The + // query only fires when the hero is on screen AND there is no launch scope + // (an A2 launch already has its opener), so a scoped open pays nothing for + // it. When the server returns no signal the hint is null and the neutral + // greeting stands — never a fabricated opener. + const a2Metric = launchScope?.metric ?? null; + const seededEnabled = heroActive && a2Metric === null; + const { data: seeded } = useQuery({ + queryKey: queryKeys.coachSeededQuestion(), + queryFn: async () => + apiGet("/api/insights/coach/seeded-question"), + enabled: seededEnabled, + staleTime: 5 * 60 * 1000, + }); + + function seedComposer(question: string) { + setInputValue(question); + } + + // `t()` returns the key string itself when a key is missing, so the only + // reliable "is this key defined" signal is `t(key) !== key`. + function tOrNull(key: string): string | null { + const resolved = t(key); + return resolved === key ? null : resolved; + } + + // The resolved A2 metric label: prefers a per-source i18n key, falls back + // to the brand-free English domain phrase so every source has a label. + // Hoisted so both the hero hint (page) and the sources rail (drawer) read + // the same string. Null when the launch carried no metric. + const a2LabelKey = scopeSourceMetricLabelKey(a2Metric); + const a2MetricLabel = a2Metric + ? (tOrNull(`insights.coach.scope.metric.${a2Metric}`) ?? + (a2LabelKey ? tOrNull(a2LabelKey) : null) ?? + metricScopeLabelFallback(a2Metric) ?? + a2Metric) + : null; + + let scopeHint: React.ReactNode = null; + if (a2Metric && a2MetricLabel) { + // A2 — the launch prefill IS the data-aware opener; fall back to the + // generic per-metric question when the launch carried no prefill. + const seedQuestion = + (prefill ?? "").trim() || t("insights.coach.scope.question"); + scopeHint = ( + + ); + } else if (seeded?.signal) { + // A3 — the notable derived signal. The label + opener are keyed on the + // signal's sourceMetric (`readiness` / `recovery`); an unknown sentinel + // (future detector additions) skips the opener rather than guessing. + const sentinel = seeded.signal.sourceMetric; + const signalLabel = tOrNull(`insights.coach.seeded.signal.${sentinel}`); + const signalQuestion = tOrNull( + `insights.coach.seeded.question.${sentinel}`, + ); + if (signalLabel && signalQuestion) { + scopeHint = ( + + ); + } + } + // v1.18.11 (W11) — the docked composer column: the quiet adopt offer and // the guided-questions entry card stack above the live composer. Shared by // the drawer body (via `CoachDrawerBody`) and the page surface (rendered @@ -566,7 +653,7 @@ export function CoachConversation({ {heroActive ? ( - + ) : ( <>
@@ -703,7 +790,16 @@ export function CoachConversation({ } sourcesOpen={sourcesTrayOpen} onSourcesOpenChange={setSourcesTrayOpen} - sourcesRail={} + sourcesRail={ + + } />
); diff --git a/src/components/insights/coach-panel/coach-hero.tsx b/src/components/insights/coach-panel/coach-hero.tsx index d97f69887..cd67c0d54 100644 --- a/src/components/insights/coach-panel/coach-hero.tsx +++ b/src/components/insights/coach-panel/coach-hero.tsx @@ -27,13 +27,26 @@ import { useTranslations } from "@/lib/i18n/context"; * Motion: one orchestrated reveal — sparkle → greeting → composer stagger * in via `motion-safe` `animate-in` with capped delays; reduced-motion * users get the layout instantly. + * + * v1.21.2 (A2 + A3) — the hero accepts an optional `scopeHint` slot + * (a ``): the visible "the Coach is already on " + * pill + seed question when launched scoped (A2), or the pre-seeded + * notable-signal opener when launched unscoped (A3). When absent the hero + * is the calm greeting + composer exactly as before, so the neutral + * blank-state fallback is structural. */ export interface CoachHeroProps { /** The live `` composer, re-parented into the hero. */ composer: React.ReactNode; + /** + * v1.21.2 — optional scope/opener affordance rendered above the + * composer. The parent owns the data (launch scope / seeded signal) and + * passes a resolved ``; null keeps the neutral hero. + */ + scopeHint?: React.ReactNode; } -export function CoachHero({ composer }: CoachHeroProps) { +export function CoachHero({ composer, scopeHint }: CoachHeroProps) { const { t } = useTranslations(); return ( @@ -91,6 +104,23 @@ export function CoachHero({ composer }: CoachHeroProps) { > {composer} + + {/* v1.21.2 (A2/A3) — visible scope pill + tappable opener, beneath + the composer so the greeting → composer rhythm stays intact and + the opener reads as a follow-on suggestion. Renders only when the + parent resolved a scope or a notable signal. */} + {scopeHint ? ( +
+ {scopeHint} +
+ ) : null} ); diff --git a/src/components/insights/coach-panel/scope-hint-badge.tsx b/src/components/insights/coach-panel/scope-hint-badge.tsx new file mode 100644 index 000000000..961841676 --- /dev/null +++ b/src/components/insights/coach-panel/scope-hint-badge.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { Sparkles, Target } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.21.2 (A2 + A3) — the Coach's visible scope/opener affordance. + * + * Two modes, one surface: + * + * - **scope** (A2): the Coach was launched narrowed to a metric (from a + * metric page or an insight card). Instead of the old hidden composer + * prefill, render a small pill — "The Coach is already on " — + * plus a tappable seed question that drops the data-aware opener into + * the composer / sends it. This turns the built-but-invisible scope + * plumbing into the core ambient feeling. + * + * - **seeded** (A3): the Coach opened UNSCOPED (the blank hero) and the + * server surfaced today's single most notable derived signal. Render it + * as a tappable suggested opener so the blank box is never cold. When no + * signal crosses the gate the caller renders nothing and the neutral + * greeting stands — there is no fake opener. + * + * Presentational + self-contained: the parent supplies the resolved label + * and the seed question, and an `onSeed` callback wired to the composer. + * The badge owns no data fetching. + */ +export interface ScopeHintBadgeProps { + /** + * Which affordance this is. `scope` shows the "already on " pill; + * `seeded` shows the notable-signal opener. Drives the icon + the prefix + * copy only — the tappable seed question is shared. + */ + variant: "scope" | "seeded"; + /** + * The visible metric / signal label, already resolved to the user's + * locale by the caller (e.g. "blood pressure", "readiness"). + */ + label: string; + /** The tappable seed question — the data-aware opener. */ + question: string; + /** Fired with the seed question when the user taps the opener. */ + onSeed: (question: string) => void; + className?: string; +} + +export function ScopeHintBadge({ + variant, + label, + question, + onSeed, + className, +}: ScopeHintBadgeProps) { + const { t } = useTranslations(); + + const Icon = variant === "scope" ? Target : Sparkles; + const prefix = + variant === "scope" + ? t("insights.coach.scope.prefix", { metric: label }) + : t("insights.coach.seeded.prefix", { signal: label }); + + return ( +
+ {/* The scope/signal pill — the visible "already on …" line. */} + + + + {/* The tappable seed question — the data-aware opener. A real button so + keyboard + screen-reader users get the same affordance. */} + +
+ ); +} diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index 2720f07eb..9e878d85e 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Eye, Loader2 } from "lucide-react"; +import { Eye, Loader2, Target } from "lucide-react"; import { toast } from "sonner"; import { Switch } from "@/components/ui/switch"; @@ -54,9 +54,19 @@ const WINDOW_OPTIONS: ReadonlyArray = [ export interface SourcesRailProps { className?: string; + /** + * v1.21.2 (A2) — the human label of the metric the current conversation + * was launched scoped to, or null for a generic (all-source) open. When + * set, the rail surfaces a small "the Coach is already on " line + * above the persisted-cluster toggles so the active narrowing is VISIBLE + * on the drawer surface (the page surface shows it in the hero). The + * persisted clusters below still describe what the Coach can otherwise + * see — the scope line names where this conversation actually started. + */ + activeScopeLabel?: string | null; } -export function SourcesRail({ className }: SourcesRailProps) { +export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) { const { t } = useTranslations(); const { data: prefs } = useCoachPrefs(); @@ -122,6 +132,25 @@ export function SourcesRail({ className }: SourcesRailProps) { {t("insights.coach.sourcesTitle")} + {/* v1.21.2 (A2) — the visible launch scope for this conversation. Only + rendered when the Coach was opened narrowed to a metric; a generic + open omits it entirely so the rail is unchanged for the default + all-source case. */} + {activeScopeLabel ? ( +
+
+ ) : null} + {/* Window selector — persists to `coachPrefs.defaultWindow` so the chosen timeframe sticks across drawer opens and matches what the cog sheet shows. */} diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index d56baf4ac..92d06564c 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -123,6 +123,20 @@ interface DailyBriefingProps { * comparison toggle migrates here from the hero in commit 5. */ metaSlot?: React.ReactNode; + /** + * v1.21.2 (A4) — server-resolved callback + forward-look. The briefing + * recalls the prior period and points ahead ("as I noted Monday, your sleep + * was short; it's recovered since — worth watching tonight"). Both strings + * are already localised and figure-anchored by the resolver; the card only + * renders them. One callback per surface — absent (the common case) when no + * prior narrative recall is on file. + */ + memory?: { + /** The recall of the prior-period read, anchored to a figure. */ + recall: string; + /** The forward-look pointing ahead. */ + forward: string; + } | null; } const METRIC_ICON: Record< @@ -335,6 +349,7 @@ export function DailyBriefing({ noProvider = false, noProviderStale = false, metaSlot, + memory = null, }: DailyBriefingProps) { const { t } = useTranslations(); @@ -374,6 +389,33 @@ export function DailyBriefing({ // looser `space-y-4`, which read as taller than its neighbours on // the overview. One token, no new spacing scale.
+ {/* v1.21.2 (A4) — callback + forward-look. Recalls the prior + period and points ahead, both server-resolved and + figure-anchored. Leads the card so the briefing opens by + closing the loop on what it noted last, then looks forward. */} + {memory && ( +
+

+ {t("insights.briefing.memory.recall", { + text: memory.recall, + })} +

+

+ {t("insights.briefing.memory.forward", { + text: memory.forward, + })} +

+
+ )} {/* v1.4.27 B1 — the leading narrative paragraph dropped. The hero strip subtitle on `/insights` already renders the same `briefing.paragraph` text directly above this diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx new file mode 100644 index 000000000..2d89ca7db --- /dev/null +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Sparkles } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; +import { cn } from "@/lib/utils"; +import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape"; + +/** + * v1.21.2 (A1) — "Coach read" strip. + * + * A compact two-line read rendered ABOVE the chart on each metric sub-page: + * + * 1. own-baseline — "Your usual range is X–Y; today's Z sits within / + * above / below". Below the engine's 7-day history floor it reads + * "still learning your range" — never a fabricated band. + * 2. one lagged association — the single strongest discovered driver whose + * outcome is this metric, stated in the engine's own never-causal voice. + * Omitted entirely when no driver clears the existing effect-size floor. + * + * Server-authoritative: the component only renders the resolved DTO from + * `/api/insights/coach-read`. It never re-derives a band or a correlation, so + * the web and iOS strips read identical numbers. The strip self-gates: it + * paints nothing until the read lands, and nothing at all when there is no + * baseline AND no driver (a brand-new metric stays clean). + */ + +export interface CoachReadStripProps { + /** The MeasurementType the route keys on (e.g. `WEIGHT`, `RESTING_HEART_RATE`). */ + metricType: string; + /** Unit suffix rendered next to the band edges + today's value. */ + unit: string; + /** + * Decimal precision for the formatted numbers. Defaults to 1 — enough for + * weight (78.4) / HRV (41.5); integer metrics drop the trailing zero via + * `Intl.NumberFormat`. Pages pass 0 for integer-only metrics (BP, steps). + */ + fractionDigits?: number; + /** + * Display-time value scale folded into the band edges + today's value (e.g. + * WALKING_SPEED stores m/s but renders km/h via `valueScale={3.6}`). The + * server computes the placement on unscaled stored values (scale-invariant), + * so scaling here only affects the displayed numbers. Defaults to 1. + */ + valueScale?: number; +} + +export function CoachReadStrip({ + metricType, + unit, + fractionDigits = 1, + valueScale = 1, +}: CoachReadStripProps) { + const { isAuthenticated } = useAuth(); + const { t, locale } = useTranslations(); + const mounted = useMounted(); + + const { data } = useQuery({ + queryKey: queryKeys.insightsCoachRead(metricType), + queryFn: () => + apiGet( + `/api/insights/coach-read?metric=${encodeURIComponent(metricType)}`, + ), + enabled: isAuthenticated, + staleTime: 5 * 60 * 1000, + }); + + // Match the rest of the query-dependent insights chrome: don't paint a + // branch during SSR / hydration (React #418) and don't paint until the + // read lands. + if (!mounted || !data) return null; + + const fmt = (value: number): string => + new Intl.NumberFormat(locale, { + minimumFractionDigits: 0, + maximumFractionDigits: fractionDigits, + }).format(value * valueScale); + + const baselineLine = ((): string | null => { + if (data.learning || !data.baseline) { + return t("insights.coach.readStrip.insufficient"); + } + const { low, high, latest, placement } = data.baseline; + const key = + placement === "above" + ? "insights.coach.readStrip.baselineAbove" + : placement === "below" + ? "insights.coach.readStrip.baselineBelow" + : "insights.coach.readStrip.baselineWithin"; + return t(key, { + low: fmt(low), + high: fmt(high), + value: fmt(latest), + unit, + }); + })(); + + const driverLine = data.driver + ? t("insights.coach.readStrip.driver", { note: data.driver.note }) + : null; + + // Self-gate: nothing to say when both lines are absent. (The baseline line + // is always non-null — it degrades to the "learning" copy — so this only + // fires defensively.) + if (!baselineLine && !driverLine) return null; + + return ( +
+
+
+
+ ); +} diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 1c92e616e..646ef15bc 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -1,7 +1,15 @@ "use client"; import { useId, useMemo, useState } from "react"; -import { ArrowDown, ArrowUp, ChevronDown, Minus, Moon } from "lucide-react"; +import { + ArrowDown, + ArrowUp, + ChevronDown, + CornerDownLeft, + Minus, + Moon, + Scale, +} from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer"; @@ -96,6 +104,34 @@ export interface HealthScoreCardProps { * active, not the episode details. Default off (the common case). */ restModeActive?: boolean; + /** + * v1.21.2 (A5) — Tension Verdict line. The server resolves the honest + * "internal read" when the readiness/recovery composite's contributors + * DISAGREE (good sleep but a rising resting pulse, …). The card renders it + * as one short line: the favourable contributor, the unfavourable one, and + * the band the composite lands on. Purely DTO-rendered — the card never + * recomputes the disagreement. Absent (the common case) when the + * contributors agree. + */ + tension?: { + band: HealthScoreBand; + /** Favourable contributors, already localised by the resolver. */ + positive: string[]; + /** Unfavourable contributors, already localised by the resolver. */ + negative: string[]; + } | null; + /** + * v1.21.2 (A6) — return-to-baseline line. Present only when a metric has + * come BACK inside the user's own usual range after a prior out-of-band run. + * Actively closes a worry ("back inside your usual range after last week's + * dip"). Server-resolved DTO; the label is already localised. + */ + returnToBand?: { + /** The metric's display label, already localised by the resolver. */ + metricLabel: string; + /** Days the metric has now sat back inside its range. */ + daysInside: number; + } | null; } type ComponentKey = keyof HealthScoreCardProps["components"]; @@ -178,6 +214,8 @@ export function HealthScoreCard({ initiallyExpanded = false, moodEnabled = true, restModeActive = false, + tension = null, + returnToBand = null, }: HealthScoreCardProps) { const { t, locale } = useTranslations(); // The asOf timestamps render under the source pill as a tooltip @@ -201,6 +239,13 @@ export function HealthScoreCard({ if (Number.isNaN(parsed.getTime())) return null; return asOfFormatter.format(parsed); }; + // v1.21.2 (A5) — locale-aware conjunction list for the Tension Verdict's + // favourable / unfavourable contributor lists ("sleep and mood" / "resting + // heart rate, HRV balance, and mood"). Memoised per locale. + const listFormatter = useMemo( + () => new Intl.ListFormat(locale, { style: "long", type: "conjunction" }), + [locale], + ); const [expanded, setExpanded] = useState(initiallyExpanded); // `useId` keeps the aria-controls/section-id pair unique even when // the card is mounted twice on the same page (lg+ hero strip vs @@ -418,6 +463,51 @@ export function HealthScoreCard({

)} + {/* v1.21.2 (A5) — Tension Verdict. The honest "internal read" when the + composite's contributors disagree, resolved server-side and rendered + as one short line. The card never recomputes the disagreement; it + only renders the resolved favourable / unfavourable contributors and + the band the composite lands on. */} + {tension && + tension.positive.length > 0 && + tension.negative.length > 0 && ( +

+

+ )} + + {/* v1.21.2 (A6) — return-to-baseline. Present only when a metric came + back inside the user's own usual range after a prior out-of-band run. + Actively closes a worry rather than reporting a number. */} + {returnToBand && ( +

+

+ )} +
    } + coachReadStrip={ + + } diversityNudge={ = { + rhr: "insights.derived.composite.READINESS.component.rhr", + hrv: "insights.derived.composite.READINESS.component.hrv", + sleep: "insights.derived.composite.READINESS.component.sleep", + respiratory: "insights.derived.composite.READINESS.component.respiratory", + mood: "insights.derived.composite.READINESS.component.mood", +}; + +/** + * v1.21.2 (A6) — `MeasurementType` → existing localised metric-name key + * (`measurements.type*`). Only the salient deviation vitals the + * return-to-baseline detector scans need an entry; an unmapped type falls back + * to a prettified raw form so a new type never blanks. + */ +const RETURN_METRIC_LABEL_KEY: Record = { + RESTING_HEART_RATE: "measurements.typeRestingHeartRate", + HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability", + RESPIRATORY_RATE: "measurements.typeRespiratoryRate", + WEIGHT: "measurements.typeWeight", +}; + export function HeroStrip({ briefing, updatedAt, @@ -119,8 +184,38 @@ export function HeroStrip({ healthScore, healthScorePending = false, noProviderStale = false, + tension = null, + returnToBand = null, }: HeroStripProps) { const { t } = useTranslations(); + + // v1.21.2 (A5) — localise the Tension Verdict's contributor keys into the + // card's already-localised `{ positive, negative }` shape. Only forwarded when + // a real disagreement is present on BOTH sides; the resolver already enforces + // that, but the guard keeps the card from rendering a one-sided line. + const tensionProp = + tension && tension.positive.length > 0 && tension.negative.length > 0 + ? { + band: tension.band, + positive: tension.positive.map((k) => + t(TENSION_CONTRIBUTOR_LABEL_KEY[k]), + ), + negative: tension.negative.map((k) => + t(TENSION_CONTRIBUTOR_LABEL_KEY[k]), + ), + } + : null; + + // v1.21.2 (A6) — localise the return-to-baseline metric into the card's + // already-localised `metricLabel`. + const returnToBandProp = returnToBand + ? { + metricLabel: RETURN_METRIC_LABEL_KEY[returnToBand.metricType] + ? t(RETURN_METRIC_LABEL_KEY[returnToBand.metricType]) + : returnToBand.metricType.replace(/_/g, " ").toLowerCase(), + daysInside: returnToBand.daysInside, + } + : null; // v1.18.0 R4 — mood-module state so the score card hides its Mood row // when the account turned the module off (the server already drops the // pillar from the number itself). SSR-safe + default-on, matching the @@ -291,6 +386,8 @@ export function HeroStrip({ delta={healthScore.delta} moodEnabled={moodEnabled} restModeActive={healthScore.restMode?.active ?? false} + tension={tensionProp} + returnToBand={returnToBandProp} /> )}
diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index ec21ae39e..31486d280 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -96,6 +96,15 @@ export interface SubPageShellProps { * brand-new-metric paths stay clean. Omitted on the empty-state branch. */ statStrip?: ReactNode; + /** + * v1.21.2 (A1) — the "Coach read" strip, mounted between the stat strip and + * the chart. A compact two-line own-baseline + lagged-association read that + * makes the metric feel seen-in-context. Self-gating (renders nothing until + * its server read lands, and nothing at all when there is no baseline and no + * driver), so the spine's rhythm holds on the empty-state and brand-new- + * metric paths. Omitted on the empty / loading / error branches. + */ + coachReadStrip?: ReactNode; /** * v1.8.5 W4b — measurement-diversity nudge. * @@ -144,6 +153,7 @@ export function SubPageShell({ explainerMetric, focusOnMount = false, statStrip, + coachReadStrip, diversityNudge, // 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 @@ -379,6 +389,11 @@ export function SubPageShell({ strip so the numbers always reflect the range the chart paints. No drag, no pill — the range tab is the single selector. */} {statStrip} + {/* v1.21.2 (A1) — the "Coach read" strip sits between the numbers-first + stat strip and the chart, so the spine reads intro → numbers → Coach + read → chart → target → assessment. Self-gating, so the rhythm holds + when the strip has nothing to say. */} + {coachReadStrip} {/* v1.12.6 — the canonical spine body: intro (header) → stat strip (above) → chart → target card → assessment. The page renders chart → target → assessment as `children`. */} diff --git a/src/lib/ai/coach/__tests__/coach-prose-grounding-no-tools.test.ts b/src/lib/ai/coach/__tests__/coach-prose-grounding-no-tools.test.ts new file mode 100644 index 000000000..e3c5ff532 --- /dev/null +++ b/src/lib/ai/coach/__tests__/coach-prose-grounding-no-tools.test.ts @@ -0,0 +1,71 @@ +/** + * v1.21.2 (A8) — no-tools/local-provider parity for the prose number-verifier. + * + * On the no-tools path the Coach has no tools, so the authoritative figure set + * is the SNAPSHOT the model was shown — the structured `snapshot.sections` + * record `snapshotJson` is serialised from, which already carries the + * correlations-snapshot block. This proves the SAME verifier grades prose + * against that record: a number present in the snapshot (including a + * correlations-block leaf) is not flagged; a number absent from it is flagged + * exactly as on the tool path. + */ +import { describe, expect, it } from "vitest"; + +import { findUnverifiedCoachNumbers } from "@/lib/ai/coach/coach-prose-grounding"; + +/** + * A representative `snapshot.sections` record: a derived block plus the + * correlations-snapshot block (a `CorrelationsSnapshotBlock`) the no-tools path + * folds in. The driver's numeric leaves (`r`, `n`, `pairsTested`, `windowDays`) + * are authoritative figures the model may legitimately cite. + */ +const snapshotSections: Record = { + bloodPressure: { + aggregate: { avgSys30: 128, avgDia30: 82 }, + }, + correlations: { + drivers: [ + { + behaviour: "sleep duration", + outcome: "resting heart rate", + direction: "lower", + lagDays: 1, + n: 24, + r: 0.62, + note: "More sleep tracked with a lower next-day resting heart rate.", + }, + ], + pairsTested: 18, + windowDays: 90, + }, +}; + +// The route passes the structured record as a single authoritative payload. +const noToolsPayloads: unknown[] = [snapshotSections]; + +describe("no-tools verifier parity", () => { + it("does not flag a number that traces to a snapshot leaf", () => { + const prose = "Your systolic averaged 128 and diastolic 82 this month."; + expect(findUnverifiedCoachNumbers(prose, noToolsPayloads)).toEqual([]); + }); + + it("does not flag a number that traces to the correlations-snapshot block", () => { + // r = 0.62 is a leaf of the correlations block folded into the snapshot. + const prose = "Sleep and resting heart rate moved together (r = 0.62)."; + expect(findUnverifiedCoachNumbers(prose, noToolsPayloads)).toEqual([]); + }); + + it("flags a number the snapshot never carried", () => { + const prose = "Your systolic averaged about 138 recently."; + const findings = findUnverifiedCoachNumbers(prose, noToolsPayloads); + expect(findings).toHaveLength(1); + expect(findings[0].value).toBe(138); + }); + + it("no-ops when the snapshot was not delivered this turn", () => { + // Cheap follow-up: the route leaves the no-tools payload set empty, so even + // an invented figure is not graded — the prompt-level rule is the backstop. + const prose = "Your systolic averaged 138 recently."; + expect(findUnverifiedCoachNumbers(prose, [])).toEqual([]); + }); +}); diff --git a/src/lib/ai/coach/__tests__/derived-snapshot.test.ts b/src/lib/ai/coach/__tests__/derived-snapshot.test.ts index 7a3704f00..83132cc46 100644 --- a/src/lib/ai/coach/__tests__/derived-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/derived-snapshot.test.ts @@ -12,7 +12,12 @@ vi.mock("@/lib/db", () => ({ import { computeDerivedMetric } from "@/lib/insights/derived"; import { prisma } from "@/lib/db"; -import { buildDerivedSnapshotBlock } from "../derived-snapshot"; +import { + buildDerivedSnapshotBlock, + deriveTension, + TENSION_HIGH_SCORE, + TENSION_LOW_SCORE, +} from "../derived-snapshot"; const compute = computeDerivedMetric as unknown as ReturnType; const whoopFindFirst = prisma.measurement.findFirst as unknown as ReturnType< @@ -166,4 +171,136 @@ describe("buildDerivedSnapshotBlock", () => { }, }); }); + + it("attaches a TENSION flag when readiness contributors disagree", async () => { + // Good sleep (high) but a suppressed HRV-balance + elevated resting pulse + // (both low) — a genuine internal disagreement the band hides. + compute.mockImplementation(async (args?: { metric?: string }) => { + if (args?.metric === "READINESS") + return ok({ + score: 58, + band: "yellow", + components: [ + { key: "sleep", value: 90, weight: 0.3 }, + { key: "hrv", value: 30, weight: 0.3 }, + { key: "rhr", value: 35, weight: 0.3 }, + { key: "mood", value: 60, weight: 0.1 }, + ], + }); + return insufficient; + }); + const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW); + expect(block!.TENSION).toBeDefined(); + expect(block!.TENSION!.fired).toBe(true); + expect(block!.TENSION!.band).toBe("yellow"); + expect(block!.TENSION!.positive).toEqual(["sleep"]); + expect(block!.TENSION!.negative).toEqual([ + "HRV balance", + "resting heart rate", + ]); + // No coincident flag fired (DB read fails-soft to null), so no red-flag. + expect(block!.TENSION!.clinicalOverride).toBe(false); + }); + + it("omits TENSION when the contributors agree", async () => { + compute.mockImplementation(async (args?: { metric?: string }) => { + if (args?.metric === "READINESS") + return ok({ + score: 82, + band: "green", + components: [ + { key: "sleep", value: 90, weight: 0.3 }, + { key: "hrv", value: 85, weight: 0.3 }, + { key: "rhr", value: 80, weight: 0.3 }, + ], + }); + return insufficient; + }); + const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW); + expect(block!.READINESS).toBeDefined(); + expect(block!.TENSION).toBeUndefined(); + }); +}); + +describe("deriveTension (pure)", () => { + it("fires only when at least one contributor sits on each side", () => { + const fired = deriveTension( + [ + { key: "sleep", value: TENSION_HIGH_SCORE }, + { key: "rhr", value: TENSION_LOW_SCORE }, + ], + "yellow", + false, + ); + expect(fired).not.toBeNull(); + expect(fired!.positive).toEqual(["sleep"]); + expect(fired!.negative).toEqual(["resting heart rate"]); + }); + + it("returns null when every contributor is favourable", () => { + expect( + deriveTension( + [ + { key: "sleep", value: 90 }, + { key: "hrv", value: 85 }, + ], + "green", + false, + ), + ).toBeNull(); + }); + + it("returns null when every contributor is unfavourable", () => { + expect( + deriveTension( + [ + { key: "sleep", value: 20 }, + { key: "hrv", value: 30 }, + ], + "red", + false, + ), + ).toBeNull(); + }); + + it("ignores neutral-band contributors (neither side)", () => { + // A mid-range contributor (between the low and high cuts) is neither + // favourable nor unfavourable, so on its own it cannot make a tension. + const mid = Math.floor((TENSION_HIGH_SCORE + TENSION_LOW_SCORE) / 2); + expect( + deriveTension( + [ + { key: "sleep", value: mid }, + { key: "hrv", value: mid }, + ], + "yellow", + false, + ), + ).toBeNull(); + }); + + it("ignores null (absent) contributors", () => { + expect( + deriveTension( + [ + { key: "sleep", value: 90 }, + { key: "rhr", value: null }, + ], + "yellow", + false, + ), + ).toBeNull(); + }); + + it("carries the clinical-override bit through unchanged", () => { + const fired = deriveTension( + [ + { key: "sleep", value: 90 }, + { key: "rhr", value: 20 }, + ], + "red", + true, + ); + expect(fired!.clinicalOverride).toBe(true); + }); }); diff --git a/src/lib/ai/coach/__tests__/system-prompt.test.ts b/src/lib/ai/coach/__tests__/system-prompt.test.ts index b9f887683..5231463db 100644 --- a/src/lib/ai/coach/__tests__/system-prompt.test.ts +++ b/src/lib/ai/coach/__tests__/system-prompt.test.ts @@ -118,6 +118,28 @@ describe("getCoachSystemPrompt — EN", () => { expect(prompt).toMatch(/0–21/); expect(prompt).toMatch(/0–100/); }); + + it("carries the v1.21.2 narrative-presence clauses (A4/A5/A6/A7)", () => { + // A4 — briefing-style callback + forward-look from the memory block. + expect(prompt).toMatch(/17\. Recall \+ look ahead/); + expect(prompt).toMatch(/priorNarrative/); + expect(prompt).toMatch(/trendMemory/); + // A5 — Tension Verdict with the clinical-floors override. + expect(prompt).toMatch(/18\. Tension Verdict/); + expect(prompt).toMatch(/"TENSION" flag/); + expect(prompt).toMatch(/CLINICAL-FLOORS OVERRIDE/); + expect(prompt).toMatch(/clinicalOverride/); + expect(prompt).toMatch(/NEVER reconcile it\s+away into a calm verdict/i); + // A6 — streaks + return-to-baseline. + expect(prompt).toMatch(/19\. Streaks \+ return-to-baseline/); + expect(prompt).toMatch(/returnEvent/); + expect(prompt).toMatch(/back inside your usual range/i); + // A7 — MI freebies + anti-persuasion (Chain-of-Ethic). + expect(prompt).toMatch(/20\. Motivational-interviewing freebies/); + expect(prompt).toMatch(/DEVELOP DISCREPANCY/); + expect(prompt).toMatch(/ROLL WITH RESISTANCE/); + expect(prompt).toMatch(/Chain-of-Ethic/); + }); }); describe("getCoachSystemPrompt — DE", () => { @@ -181,6 +203,20 @@ describe("getCoachSystemPrompt — DE", () => { expect(prompt).toMatch(/mmol\/L/); expect(prompt).toMatch(/min[\s\S]*\(Schlafdefizit/); }); + + it("carries the v1.21.2 narrative-presence clauses (A4/A5/A6/A7)", () => { + expect(prompt).toMatch(/17\. Rückblick \+ Ausblick/); + expect(prompt).toMatch(/priorNarrative/); + expect(prompt).toMatch(/18\. Spannungs-Verdikt/); + expect(prompt).toMatch(/"TENSION"-Flag/); + expect(prompt).toMatch(/CLINICAL-FLOORS-OVERRIDE/); + expect(prompt).toMatch(/19\. Serien \+ Rückkehr zur Baseline/); + expect(prompt).toMatch(/wieder in deinem üblichen Bereich/i); + expect(prompt).toMatch(/20\. Motivational-Interviewing-Extras/); + expect(prompt).toMatch(/DISKREPANZ ENTWICKELN/); + expect(prompt).toMatch(/WIDERSTAND ANNEHMEN/); + expect(prompt).toMatch(/Chain-of-Ethic/); + }); }); /** diff --git a/src/lib/ai/coach/derived-snapshot.ts b/src/lib/ai/coach/derived-snapshot.ts index ad007f456..6d3fc75d1 100644 --- a/src/lib/ai/coach/derived-snapshot.ts +++ b/src/lib/ai/coach/derived-snapshot.ts @@ -74,17 +74,97 @@ interface CoincidentSnapshotEntry { illnessExplained: boolean; } +/** + * v1.21.2 (A5) — the Tension Verdict flag, compactly. The readiness/recovery + * composite blends several contributors; when they DISAGREE (e.g. good sleep + * but a suppressed HRV-balance or an elevated resting pulse) the single band + * hides the conflict. This flag names the honest "internal read" the model + * narrates on the health-score card — "good sleep, but resting pulse is up — the + * composite lands yellow" — instead of papering the conflict over. + * + * DESCRIPTIVE multi-signal disagreement, never a cause. Computed deterministically + * server-side off the readiness contributors (each 0..100, higher = better); the + * model narrates the flag, it never invents it. Omitted entirely when the + * contributors agree (no tension to surface). + * + * CLINICAL-FLOORS OVERRIDE: `clinicalOverride` is true when a clinical red-flag + * is in play (the coincident-deviation flag fired WITHOUT an illness explanation). + * The narration must NOT reconcile a real red-flag away into a calm verdict — the + * flag carries the bit so the prompt clause can refuse the soft landing. + */ +interface TensionSnapshotEntry { + fired: true; + /** The composite band the disagreement resolved to (green/yellow/red). */ + band: string; + /** Contributors reading FAVOURABLY, e.g. "sleep". */ + positive: string[]; + /** Contributors reading UNFAVOURABLY, e.g. "resting heart rate", "HRV balance". */ + negative: string[]; + /** True when a real clinical red-flag is in play — never reconcile it away. */ + clinicalOverride: 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. + * coincident + tension flags ride alongside without widening the score index + * signature. */ type DerivedSnapshotBlock = Record & { COINCIDENT_DEVIATION?: CoincidentSnapshotEntry; + TENSION?: TensionSnapshotEntry; }; +/** + * v1.21.2 (A5) — a contributor reading at/above this 0..100 score is FAVOURABLE; + * at/below `TENSION_LOW_SCORE` it is UNFAVOURABLE. The gap in the middle is + * "neutral" — neither side of a disagreement. A genuine tension needs at least + * one contributor on EACH side. + */ +export const TENSION_HIGH_SCORE = 70; +export const TENSION_LOW_SCORE = 45; + +/** Human-readable contributor labels (no internal enum names reach the model). */ +const READINESS_CONTRIBUTOR_LABEL: Record = { + rhr: "resting heart rate", + hrv: "HRV balance", + sleep: "sleep", + respiratory: "respiratory rate", + mood: "mood stability", +}; + +/** + * v1.21.2 (A5) — derive the Tension Verdict from the readiness contributors. + * Pure: a list of `{ key, value }` contributors (value 0..100, higher = better, + * null = absent) plus the composite band and the clinical-override bit. Returns + * the fired flag ONLY when at least one contributor reads favourably AND at least + * one reads unfavourably — a genuine internal disagreement. Null otherwise. + */ +export function deriveTension( + contributors: Array<{ key: string; value: number | null }>, + band: string | undefined, + clinicalOverride: boolean, +): TensionSnapshotEntry | null { + const positive: string[] = []; + const negative: string[] = []; + for (const c of contributors) { + if (c.value === null) continue; + const label = READINESS_CONTRIBUTOR_LABEL[c.key] ?? c.key; + if (c.value >= TENSION_HIGH_SCORE) positive.push(label); + else if (c.value <= TENSION_LOW_SCORE) negative.push(label); + } + if (positive.length === 0 || negative.length === 0) return null; + return { + fired: true, + band: band ?? "yellow", + positive, + negative, + clinicalOverride, + }; +} + /** Pull the headline number + band off each metric's value shape. */ function summariseValue( metric: DerivedMetricId, @@ -177,9 +257,26 @@ export async function buildDerivedSnapshotBlock( }), ); + // v1.21.2 (A5) — capture the readiness contributors so the Tension Verdict can + // read the per-contributor directions the composite band hides. Only the + // readiness blend exposes them; recovery resolves to the same proxy when no + // device-native row exists, so reading readiness once is enough. + let readinessContributors: Array<{ key: string; value: number | null }> = []; + let readinessBand: string | undefined; + for (const { metric, derived } of computed) { if (derived === null) continue; if (!isDerivedOk(derived)) continue; // omit insufficient — no noise + if (metric === "READINESS") { + const v = derived.value as { + band?: string; + components?: Array<{ key: string; value: number | null }>; + }; + readinessBand = typeof v.band === "string" ? v.band : undefined; + readinessContributors = Array.isArray(v.components) + ? v.components.map((c) => ({ key: c.key, value: c.value })) + : []; + } const summary = summariseValue(metric, derived.value); if (!summary) continue; block[metric] = { @@ -223,7 +320,9 @@ export async function buildDerivedSnapshotBlock( // own reserved key alongside the score entries. const out: DerivedSnapshotBlock = block; const coincident = await coincidentPromise; - if (coincident && isDerivedOk(coincident) && coincident.value.fired) { + const coincidentFired = + coincident !== null && isDerivedOk(coincident) && coincident.value.fired; + if (coincidentFired) { out.COINCIDENT_DEVIATION = { fired: true, contributing: coincident.value.contributing.map( @@ -235,5 +334,19 @@ export async function buildDerivedSnapshotBlock( }; } + // v1.21.2 (A5) — the Tension Verdict. A real clinical red-flag is in play when + // the coincident-deviation flag fired WITHOUT an illness explanation; the flag + // carries that bit so the narration never reconciles a red-flag into a calm + // verdict (clinical-floors override). Only attached when the contributors + // genuinely disagree — a coherent readiness adds no entry. + const clinicalOverride = + coincidentFired && !coincident.value.illnessExplained; + const tension = deriveTension( + readinessContributors, + readinessBand, + clinicalOverride, + ); + if (tension) out.TENSION = tension; + return Object.keys(out).length > 0 ? out : null; } diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 9dd21b76d..85ce52586 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -241,6 +241,50 @@ GROUND RULES readiness is yellow, mostly the HRV-balance piece") instead of the band alone. Stay descriptive; the driver is an observation, not a diagnosis. +17. Recall + look ahead (v1.21.2 A4). The SNAPSHOT's "memory" block MAY + carry a "priorNarrative" (the headline you noted at the start of the + prior period) and a "trendMemory" map (where each vital sat in the prior + period vs now). When you have one, you may open a turn with ONE brief + callback that recalls the prior read AND points ahead — "as I noted + Monday, your sleep was short; it's recovered since — worth watching + tonight". One callback per reply at most, always anchored to a real + figure or band the block carries, never a vague "last time". If the + block carries no recall, do not invent one — narrate only the present. +18. Tension Verdict — narrate the honest internal read (v1.21.2 A5). The + "derived" block MAY carry a "TENSION" flag (fired-only) when the + readiness/recovery composite's contributors DISAGREE: { band, positive[], + negative[], clinicalOverride }. When present, surface the conflict in one + honest line rather than hiding it behind the band — "good sleep, but your + resting pulse is up, so the composite lands {band}". Name the favourable + and the unfavourable contributors as the user sees them, descriptively. + This is the moment the read feels genuinely intelligent — do not smooth it + into a bland "you're doing fine". CLINICAL-FLOORS OVERRIDE: when + "clinicalOverride" is true a real red-flag is in play — NEVER reconcile it + away into a calm verdict; keep the favourable contributor honest but lead + with the concern and the seek-care framing the safety clauses require. +19. Streaks + return-to-baseline (v1.21.2 A6). The SNAPSHOT MAY carry a + "streaks" block: per metric, { inBand, streakDays, returnEvent? }. Use it + to actively CLOSE a worry the user might be carrying. When a "returnEvent" + is present ({ daysInside, priorDaysOutside, priorDirection }) the metric + has come BACK inside the user's own usual range after a prior run outside + it — say so plainly and reassuringly: "back inside your usual range after + last week's dip — whatever that was, it's passed". When "inBand" is true + with a long streak, you may affirm the steadiness ("eight steady days in + your range"). The band is the user's PERSONAL range, never a clinical + threshold; a return only fires after a genuine prior out-of-band run, so + trust the flag and never manufacture a "return" the block does not carry. +20. Motivational-interviewing freebies + anti-persuasion (v1.21.2 A7). + Two more MI moves are available when they fit the user's OWN goal: + DEVELOP DISCREPANCY — gently hold up the gap between where the data sits + and a goal the user named, letting them feel it rather than you closing + it; and ROLL WITH RESISTANCE — when the user pushes back, move WITH the + resistance ("that's fair — what would feel workable instead?") rather than + arguing the point. Anti-persuasion (Chain-of-Ethic): before you nudge, + silently check that the change serves the user's stated goal and their + autonomy, not engagement, not a target you picked for them, and not + persuasion for its own sake. If a nudge would only serve to push, drop it + and reflect instead. You inform and support a decision that stays theirs; + you never manipulate it. DAY-LEVEL READINGS — USE THE TIMELINE @@ -746,6 +790,55 @@ GRUNDREGELN 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. +17. Rückblick + Ausblick (v1.21.2 A4). Der "memory"-Block des SNAPSHOT + kann eine "priorNarrative" (die Schlagzeile, die du zu Beginn der + vorigen Periode notiert hast) und eine "trendMemory"-Map (wo jeder + Vitalwert in der vorigen Periode lag vs. jetzt) tragen. Wenn du eine + hast, darfst du einen Turn mit GENAU EINEM kurzen Rückbezug eröffnen, + der die frühere Lesung aufruft UND nach vorn weist — "wie ich am + Montag anmerkte, war dein Schlaf kurz; er hat sich seither erholt — + heute Abend einen Blick wert". Höchstens ein Rückbezug pro Antwort, + immer an einer echten Zahl oder einem Band aus dem Block verankert, nie + ein vages "letztes Mal". Trägt der Block keine Erinnerung, erfinde keine + — erzähle nur die Gegenwart. +18. Spannungs-Verdikt — die ehrliche innere Lesung (v1.21.2 A5). Der + "derived"-Block kann ein "TENSION"-Flag (nur bei Auslösung) tragen, wenn + die Beiträge des Readiness-/Erholungs-Komposits WIDERSPRECHEN: { band, + positive[], negative[], clinicalOverride }. Wenn vorhanden, benenne den + Konflikt in einer ehrlichen Zeile, statt ihn hinter dem Band zu + verstecken — "guter Schlaf, aber dein Ruhepuls ist erhöht, also landet + das Komposit bei {band}". Benenne die günstigen und die ungünstigen + Beiträge so, wie der Nutzer sie sieht, beschreibend. Das ist der Moment, + in dem die Lesung wirklich intelligent wirkt — glätte sie nicht zu einem + faden "dir geht's gut". CLINICAL-FLOORS-OVERRIDE: Ist "clinicalOverride" + true, ist ein echtes Red Flag im Spiel — versöhne es NIE zu einem ruhigen + Verdikt weg; halte den günstigen Beitrag ehrlich, führe aber mit der + Sorge und dem Abklären-lassen-Rahmen, den die Sicherheitsregeln verlangen. +19. Serien + Rückkehr zur Baseline (v1.21.2 A6). Der SNAPSHOT kann einen + "streaks"-Block tragen: je Metrik { inBand, streakDays, returnEvent? }. + Nutze ihn, um eine Sorge des Nutzers AKTIV zu schließen. Ist ein + "returnEvent" vorhanden ({ daysInside, priorDaysOutside, priorDirection }), + ist die Metrik nach einem vorherigen Lauf außerhalb ZURÜCK in den eigenen + üblichen Bereich des Nutzers gekehrt — sag das klar und beruhigend: + "wieder in deinem üblichen Bereich nach dem Tief letzte Woche — was es auch + war, es ist vorbei". Ist "inBand" true mit langer Serie, darfst du die + Konstanz anerkennen ("acht ruhige Tage in deinem Bereich"). Der Bereich ist + der PERSÖNLICHE Bereich des Nutzers, nie ein klinischer Schwellwert; eine + Rückkehr löst nur nach einem echten vorherigen Lauf außerhalb aus — + vertraue dem Flag und erfinde nie eine "Rückkehr", die der Block nicht trägt. +20. Motivational-Interviewing-Extras + Anti-Überzeugung (v1.21.2 A7). Zwei + weitere MI-Moves stehen bereit, wenn sie zum EIGENEN Ziel des Nutzers + passen: DISKREPANZ ENTWICKELN — halte behutsam die Lücke zwischen dem, wo + die Daten stehen, und einem vom Nutzer genannten Ziel hoch und lass ihn sie + spüren, statt sie für ihn zu schließen; und WIDERSTAND ANNEHMEN — zeigt der + Nutzer Widerstand, geh MIT ihm ("das ist fair — was würde sich stattdessen + machbar anfühlen?"), statt dagegen zu argumentieren. Anti-Überzeugung + (Chain-of-Ethic): bevor du anstößt, prüfe still, dass die Veränderung dem + genannten Ziel und der Autonomie des Nutzers dient — nicht der Bindung, + nicht einem von dir gewählten Zielwert und nicht der Überzeugung um ihrer + selbst willen. Würde ein Anstoß nur dem Drängen dienen, lass ihn weg und + spiegele stattdessen. Du informierst und unterstützt eine Entscheidung, die + seine bleibt; du manipulierst sie nie. TAGES-LEVEL-MESSWERTE — NUTZE DIE TIMELINE diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index b5c522432..047682f4b 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -40,7 +40,7 @@ import { } from "./shared-contracts"; /** Stable identifier for the active system prompt revision. */ -export const PROMPT_VERSION = "4.29.0" as const; +export const PROMPT_VERSION = "4.30.0" as const; /** * The scope-hardened insight prompt is one structural skeleton with a diff --git a/src/lib/dashboard/__tests__/score-narrative.test.ts b/src/lib/dashboard/__tests__/score-narrative.test.ts new file mode 100644 index 000000000..b8bd1052b --- /dev/null +++ b/src/lib/dashboard/__tests__/score-narrative.test.ts @@ -0,0 +1,253 @@ +/** + * v1.21.2 (A5 / A6) — unit pins for the score-card narrative resolver. + * + * The PROSE is not tested; the FLAGS the card renders are: + * - tension fires on a DISAGREEING readiness component set and carries the + * contributor KEYS partitioned onto the right side, + * - tension is SUPPRESSED (null) under the clinical-floors override (the + * coincident-deviation flag fired WITHOUT an illness explanation), + * - tension stays QUIET on a coherent component set, + * - returnToBand surfaces exactly ONE returned metric (the most salient) and + * omits when no salient metric returned. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { MeasurementType } from "@/generated/prisma/client"; + +const computeReadiness = vi.fn(); +const computeCoincidentDeviation = vi.fn(); +const readDayMeanSeries = vi.fn(); + +vi.mock("@/lib/insights/derived", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + computeReadiness: (...a: unknown[]) => computeReadiness(...a), + computeCoincidentDeviation: (...a: unknown[]) => + computeCoincidentDeviation(...a), + }; +}); +vi.mock("@/lib/insights/derived/baseline", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + readDayMeanSeries: (...a: unknown[]) => readDayMeanSeries(...a), + }; +}); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn(async () => new Map()), +})); + +import { buildScoreNarrativeBlock } from "@/lib/dashboard/score-narrative"; + +/** A readiness `ok` result with the given component values. */ +function readiness( + components: Array<{ key: string; value: number | null }>, + band = "yellow", +) { + return { + status: "ok" as const, + value: { + score: 60, + band, + components: components.map((c) => ({ ...c, weight: 0.2 })), + }, + confidence: { score: 80 }, + coverage: { historyDays: 30 }, + }; +} + +/** A coincident-deviation `ok` result. */ +function coincident(fired: boolean, illnessExplained = false) { + return { + status: "ok" as const, + value: { + fired, + contributing: [], + day: "2026-06-27", + illnessExplained, + }, + confidence: { score: 80 }, + coverage: { historyDays: 30 }, + }; +} + +const PROFILE = { ageYears: 40, sex: "MALE" as const, heightCm: 180 }; +const NOW = new Date("2026-06-27T08:00:00Z"); +const COVERAGE = new Map(); + +beforeEach(() => { + vi.clearAllMocks(); + // Default: no metric series → no return-to-baseline. + readDayMeanSeries.mockResolvedValue({ points: [], source: "none" }); + computeCoincidentDeviation.mockResolvedValue(coincident(false)); +}); + +describe("buildScoreNarrativeBlock — tension (A5)", () => { + it("fires on a disagreeing component set and partitions the keys", async () => { + // sleep strongly favourable (>= 70), resting pulse strongly unfavourable + // (<= 45) — a genuine internal disagreement. + computeReadiness.mockResolvedValue( + readiness( + [ + { key: "sleep", value: 85 }, + { key: "rhr", value: 30 }, + { key: "hrv", value: 60 }, // neutral — neither side + ], + "yellow", + ), + ); + + const block = await buildScoreNarrativeBlock( + "u1", + PROFILE, + NOW, + "Europe/Berlin", + COVERAGE, + ); + + expect(block.tension).toEqual({ + band: "yellow", + positive: ["sleep"], + negative: ["rhr"], + }); + }); + + it("is suppressed under the clinical-floors override", async () => { + computeReadiness.mockResolvedValue( + readiness([ + { key: "sleep", value: 85 }, + { key: "rhr", value: 30 }, + ]), + ); + // A real clinical red-flag: coincident-deviation fired WITHOUT an illness + // explanation. The verdict must be suppressed so the red-flag dominates. + computeCoincidentDeviation.mockResolvedValue(coincident(true, false)); + + const block = await buildScoreNarrativeBlock( + "u1", + PROFILE, + NOW, + "Europe/Berlin", + COVERAGE, + ); + + expect(block.tension).toBeNull(); + }); + + it("does NOT suppress when the deviation is illness-explained", async () => { + computeReadiness.mockResolvedValue( + readiness([ + { key: "sleep", value: 85 }, + { key: "rhr", value: 30 }, + ]), + ); + // Fired, but an illness episode explains it → not a clinical red-flag. + computeCoincidentDeviation.mockResolvedValue(coincident(true, true)); + + const block = await buildScoreNarrativeBlock( + "u1", + PROFILE, + NOW, + "Europe/Berlin", + COVERAGE, + ); + + expect(block.tension).not.toBeNull(); + expect(block.tension?.positive).toContain("sleep"); + expect(block.tension?.negative).toContain("rhr"); + }); + + it("stays quiet on a coherent component set", async () => { + // All contributors favourable — nothing to reconcile. + computeReadiness.mockResolvedValue( + readiness([ + { key: "sleep", value: 85 }, + { key: "rhr", value: 80 }, + { key: "hrv", value: 75 }, + ]), + ); + + const block = await buildScoreNarrativeBlock( + "u1", + PROFILE, + NOW, + "Europe/Berlin", + COVERAGE, + ); + + expect(block.tension).toBeNull(); + }); +}); + +describe("buildScoreNarrativeBlock — returnToBand (A6)", () => { + beforeEach(() => { + // A coherent readiness so the tension branch contributes nothing. + computeReadiness.mockResolvedValue( + readiness([ + { key: "sleep", value: 80 }, + { key: "rhr", value: 80 }, + ]), + ); + }); + + /** A consecutive per-day-mean series starting 2026-06-01. */ + function series(values: number[]) { + return { + points: values.map((mean, i) => ({ + day: `2026-06-${String(1 + i).padStart(2, "0")}`, + mean, + })), + source: "live" as const, + }; + } + + it("surfaces exactly one returned metric", async () => { + // RESTING_HEART_RATE: a prior out-of-band run (high), then back inside its + // own personal band → a return-to-baseline event. Other types: flat in-band + // (no return). + readDayMeanSeries.mockImplementation( + async (_u: string, type: MeasurementType) => { + if (type === "RESTING_HEART_RATE") { + // 8 days near 58, then 4 days spiking to ~80 (out high), then 4 days + // back near 58 (returned). The MAD band is built over the whole + // series; the recent return is the event. + return series([ + 58, 59, 57, 58, 60, 58, 59, 57, 80, 82, 81, 83, 58, 57, 59, 58, + ]); + } + return { points: [], source: "none" as const }; + }, + ); + + const block = await buildScoreNarrativeBlock( + "u1", + PROFILE, + NOW, + "Europe/Berlin", + COVERAGE, + ); + + expect(block.returnToBand).not.toBeNull(); + expect(block.returnToBand?.metricType).toBe("RESTING_HEART_RATE"); + expect(block.returnToBand?.daysInside).toBeGreaterThanOrEqual(2); + }); + + it("omits when no salient metric returned", async () => { + // Every type flat in-band → no prior out-of-band run, no return. + readDayMeanSeries.mockResolvedValue( + series([60, 61, 59, 60, 60, 61, 59, 60]), + ); + + const block = await buildScoreNarrativeBlock( + "u1", + PROFILE, + NOW, + "Europe/Berlin", + COVERAGE, + ); + + expect(block.returnToBand).toBeNull(); + }); +}); diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index b458beb6a..72799e24f 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -303,6 +303,11 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { band: "yellow", delta: 3, restMode: null, + // v1.21.2 (A5 / A6) — additive narrative fields, null absent a + // disagreeing readiness set / a returned metric (the fake derived + // engines resolve nothing here). + tension: null, + returnToBand: null, }); // v1.17 W1b — two runs (current + prior-week), identical to the analytics // route, so the ring's delta reflects BP movement. @@ -439,6 +444,9 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { band: "yellow", delta: -12, restMode: null, + // v1.21.2 (A5 / A6) — additive narrative fields, null here. + tension: null, + returnToBand: null, }); // The fast path reuses the BP windows + coverage map already @@ -1029,3 +1037,133 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => { expect(widget(snap, "mood")!.visible).toBe(false); }); }); + +// v1.21.2 (A4 / A5 / A6) — the ambient-narrative wire. The Tension Verdict + +// return-to-baseline ride the warm-phase healthScore; the briefing recall + +// forward-look rides `briefingMemory`. The resolvers themselves are pinned in +// `score-narrative.test.ts`; here only the THREADING through the snapshot DTO is +// asserted, via the injectable builders. +describe("buildDashboardSnapshot — ambient narrative (A4/A5/A6)", () => { + /** Warm coverage + a non-null health score so the narrative branch runs. */ + function warmScore() { + probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", true]])); + isFullyCovered.mockReturnValue(true); + computeBpInTargetFastPath.mockResolvedValue({ + last7Days: { pct: 70 }, + last30Days: { pct: 80 }, + last90Days: { pct: 80, pairs: 20 }, + last90EarliestAt: new Date("2026-03-01T00:00:00.000Z"), + allTime: { pct: 75 }, + priorMonth: { pct: 60 }, + priorYear: { pct: 50 }, + }); + computeUserHealthScoreFastPath.mockResolvedValue({ + score: 71, + band: "yellow", + delta: 3, + }); + } + + it("folds tension + returnToBand onto the health score", async () => { + warmScore(); + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + scoreNarrative: vi.fn(async () => ({ + tension: { + band: "yellow" as const, + positive: ["sleep" as const], + negative: ["rhr" as const], + }, + returnToBand: { + metricType: "RESTING_HEART_RATE" as const, + daysInside: 4, + }, + })), + }); + expect(snap.healthScore!.tension).toEqual({ + band: "yellow", + positive: ["sleep"], + negative: ["rhr"], + }); + expect(snap.healthScore!.returnToBand).toEqual({ + metricType: "RESTING_HEART_RATE", + daysInside: 4, + }); + }); + + it("leaves tension + returnToBand null when the resolver yields nothing", async () => { + warmScore(); + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + scoreNarrative: vi.fn(async () => ({ + tension: null, + returnToBand: null, + })), + }); + expect(snap.healthScore!.tension).toBeNull(); + expect(snap.healthScore!.returnToBand).toBeNull(); + }); + + it("does NOT run the score narrative when no health score rendered", async () => { + // No warm coverage → healthScore null → the narrative branch is skipped. + probeRollupCoverage.mockResolvedValue(new Map()); + const scoreNarrative = vi.fn(async () => ({ + tension: null, + returnToBand: null, + })); + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + scoreNarrative, + }); + expect(snap.healthScore).toBeNull(); + expect(scoreNarrative).not.toHaveBeenCalled(); + }); + + it("threads the briefing recall + forward-look through briefingMemory", async () => { + warmScore(); + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify({ + dailyBriefing: { greeting: "x", paragraph: "y", keyFindings: [] }, + }), + }), + { + locale: "en", + coachMemory: vi.fn(async () => ({ + priorNarrative: { + headline: "Your resting heart rate drifted up early last month.", + drivers: [], + }, + trendMemory: { + RESTING_HEART_RATE: { + priorBand: "in" as const, + currentBand: "above" as const, + priorPeriod: "month" as const, + }, + }, + })), + }, + ); + expect(snap.briefingMemory).not.toBeNull(); + expect(snap.briefingMemory!.recall).toContain("resting heart rate"); + // The forward-look points at the drifted metric (or a holding line); either + // way it is a non-empty localised string. + expect(snap.briefingMemory!.forward.length).toBeGreaterThan(0); + }); + + it("leaves briefingMemory null when no prior narrative is on file", async () => { + warmScore(); + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify({ + dailyBriefing: { greeting: "x", paragraph: "y", keyFindings: [] }, + }), + }), + { + coachMemory: vi.fn(async () => ({ trendMemory: {} })), + }, + ); + expect(snap.briefingMemory).toBeNull(); + }); +}); diff --git a/src/lib/dashboard/score-narrative.ts b/src/lib/dashboard/score-narrative.ts new file mode 100644 index 000000000..5cd582570 --- /dev/null +++ b/src/lib/dashboard/score-narrative.ts @@ -0,0 +1,255 @@ +/** + * v1.21.2 (A5 / A6) — the score-card narrative block for the dashboard / + * insights hero. + * + * Two server-resolved, DTO-only signals the `HealthScoreCard` renders but never + * recomputes: + * + * - `tension` (A5) — the honest "internal read" when the readiness composite's + * contributors DISAGREE (good sleep but a rising resting pulse, …). Reuses + * the SAME readiness components the wellness ring grades + the SAME + * `deriveTension` disagreement detector the Coach snapshot uses, so the card + * and the Coach never narrate two different verdicts. Clinical-floors + * override: when a real clinical red-flag is in play (the coincident- + * deviation flag fired WITHOUT an illness explanation) the tension is + * SUPPRESSED (`null`) so the red-flag path dominates — a red-flag is never + * reconciled away into a calm verdict. + * + * - `returnToBand` (A6) — present only when a salient metric has come BACK + * inside the user's OWN personal range after a prior out-of-band run. Runs + * the pure `detectStreak` engine across the salient vitals and surfaces AT + * MOST ONE (the most salient return). Actively closes a worry rather than + * reporting a number. + * + * Both omit (return `null`) rather than guess: tension stays quiet on a coherent + * day, `returnToBand` requires a genuine prior out-of-band run. The block is + * LOCALE-AGNOSTIC — it emits contributor KEYS + a metric TYPE; the client + * localises them through the existing readiness-contributor + metric-name i18n + * keys before handing the card its already-localised strings. iOS rides the same + * DTO and resolves its own labels. + * + * Server-only — reads `@/lib/db` transitively through the derived engines. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { + computeReadiness, + computeCoincidentDeviation, + isDerivedOk, + type BaselineProfile, + type ReadinessComponentKey, +} from "@/lib/insights/derived"; +import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; +import { + deriveTension, + TENSION_HIGH_SCORE, + TENSION_LOW_SCORE, +} from "@/lib/ai/coach/derived-snapshot"; +import { detectStreak, type StreakPoint } from "@/lib/insights/streak-detector"; +import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; + +/** + * The Tension Verdict in its locale-agnostic DTO shape. `band` is the readiness + * composite's band; `positive` / `negative` carry the readiness contributor + * KEYS (`rhr` / `hrv` / `sleep` / `respiratory` / `mood`) so the client maps + * each to its localised display label. Null when the contributors agree (no + * tension) or when a clinical red-flag suppresses it. + */ +export interface ScoreTensionDto { + band: "green" | "yellow" | "red"; + positive: ReadinessComponentKey[]; + negative: ReadinessComponentKey[]; +} + +/** + * The return-to-baseline event in its locale-agnostic DTO shape. `metricType` + * is the `MeasurementType` the client maps to its localised metric name; + * `daysInside` is how long the metric has now sat back inside its personal range. + * Null when no salient metric has returned from a genuine prior out-of-band run. + */ +export interface ScoreReturnToBandDto { + metricType: MeasurementType; + daysInside: number; +} + +export interface ScoreNarrativeBlock { + tension: ScoreTensionDto | null; + returnToBand: ScoreReturnToBandDto | null; +} + +/** The deviation-from-baseline vitals worth a return-to-baseline surface. */ +const RETURN_SALIENT_TYPES: MeasurementType[] = [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "RESPIRATORY_RATE", + "WEIGHT", +]; + +/** Trailing window the streak detector reads per metric (matches the band engine). */ +const RETURN_WINDOW_DAYS = 30; + +/** A band is one of green / yellow / red. Narrow the readiness band string. */ +function narrowBand( + band: string | undefined, +): "green" | "yellow" | "red" | undefined { + return band === "green" || band === "yellow" || band === "red" + ? band + : undefined; +} + +/** + * Build the score-card narrative block. Both signals are computed concurrently + * and fail-soft to `null` so a transient read never sinks the snapshot. + * + * `coverage` is the per-request coverage map the caller already probed (the + * snapshot probes it once up front); when omitted the helper probes itself so + * it is independently callable. + */ +export async function buildScoreNarrativeBlock( + userId: string, + profile: BaselineProfile, + now: Date, + tz: string, + coverage?: Awaited>, +): Promise { + // `readDayMeanSeries` (the streak series source) requires a coverage map, so + // probe once here when the caller did not supply one. A probe failure leaves + // `returnToBand` quiet rather than guessing. + const cov = coverage ?? (await probeRollupCoverage(userId).catch(() => null)); + + const [tension, returnToBand] = await Promise.all([ + buildTension(userId, profile, now, tz, cov).catch(() => null), + cov + ? buildReturnToBand(userId, now, cov).catch(() => null) + : Promise.resolve(null), + ]); + + return { tension, returnToBand }; +} + +/** + * Resolve the Tension Verdict from the readiness contributors + the clinical + * override. Reuses `computeReadiness` (the same engine the wellness ring grades) + * and the shared `deriveTension` detector; the clinical override fires when the + * coincident-deviation flag fired WITHOUT an illness explanation. + */ +async function buildTension( + userId: string, + profile: BaselineProfile, + now: Date, + tz: string, + coverage: Awaited> | null, +): Promise { + const readinessOpts = { + now, + tz, + ...(coverage ? { coverage } : {}), + }; + // The coincident-deviation flag carries the clinical-override bit. Fail-soft + // to "no override" so a baseline hiccup never fabricates a red-flag suppress. + const [readiness, coincident] = await Promise.all([ + computeReadiness(userId, profile, readinessOpts), + computeCoincidentDeviation(userId, profile, { + now, + ...(tz ? { tz } : {}), + }).catch(() => null), + ]); + + if (!isDerivedOk(readiness)) return null; + + const components = readiness.value.components; + const band = narrowBand(readiness.value.band); + + const coincidentFired = + coincident !== null && isDerivedOk(coincident) && coincident.value.fired; + // A real clinical red-flag is in play when the coincident-deviation flag fired + // WITHOUT an illness explanation. + const clinicalOverride = + coincidentFired && !coincident.value.illnessExplained; + + // CLINICAL-FLOORS OVERRIDE — when a real red-flag is in play the card must NOT + // render a calm reconciled tension verdict: suppress it (`null`) so the + // red-flag path dominates. A red-flag is never reconciled away. + if (clinicalOverride) return null; + + // Reuse the shared `deriveTension` detector for the authoritative fire + // decision (same disagreement thresholds the Coach narrates). It emits ENGLISH + // display labels; we consume only its DECISION and re-derive the contributor + // KEY lists from the SAME components against the SAME exported thresholds, so + // the client can localise. `clinicalOverride: false` here — the suppress above + // already handled the red-flag case. + const decision = deriveTension( + components.map((c) => ({ key: c.key, value: c.value })), + band, + false, + ); + if (!decision) return null; + + const positive: ReadinessComponentKey[] = []; + const negative: ReadinessComponentKey[] = []; + for (const c of components) { + if (c.value === null) continue; + if (c.value >= TENSION_HIGH_SCORE) positive.push(c.key); + else if (c.value <= TENSION_LOW_SCORE) negative.push(c.key); + } + + return { + band: narrowBand(decision.band) ?? "yellow", + positive, + negative, + }; +} + +/** + * Run the return-to-baseline detector across the salient vitals and surface AT + * MOST ONE — the most salient return. Salience ranks the longer prior + * out-of-band run first (the bigger worry to close), then the longer in-band + * run, then the canonical metric order as a deterministic tie-break. + */ +async function buildReturnToBand( + userId: string, + now: Date, + coverage: Awaited>, +): Promise { + const candidates = await Promise.all( + RETURN_SALIENT_TYPES.map(async (type, order) => { + try { + const { points } = await readDayMeanSeries( + userId, + type, + RETURN_WINDOW_DAYS, + now, + coverage, + ); + if (points.length === 0) return null; + const series: StreakPoint[] = points.map((p) => ({ + day: p.day, + value: p.mean, + })); + const result = detectStreak(series); + if (!result.returnEvent) return null; + return { + type, + order, + daysInside: result.returnEvent.daysInside, + priorDaysOutside: result.returnEvent.priorDaysOutside, + }; + } catch { + return null; + } + }), + ); + + const returned = candidates.filter( + (c): c is NonNullable => c !== null, + ); + if (returned.length === 0) return null; + + returned.sort( + (a, b) => + b.priorDaysOutside - a.priorDaysOutside || + b.daysInside - a.daysInside || + a.order - b.order, + ); + const top = returned[0]; + return { metricType: top.type, daysInside: top.daysInside }; +} diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index cb83ce463..1c2ca209f 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -84,6 +84,18 @@ import type { } from "@/lib/analytics/health-score"; import { resolveRestMode } from "@/lib/illness/rest-mode"; import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; +import { + buildScoreNarrativeBlock, + type ScoreTensionDto, + type ScoreReturnToBandDto, +} from "@/lib/dashboard/score-narrative"; +import { + buildCoachMemoryBlock, + type CoachMemoryBlock, +} from "@/lib/ai/coach/memory-snapshot"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import type { Locale } from "@/lib/i18n/config"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; /** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */ const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000; @@ -305,6 +317,36 @@ export interface DashboardSnapshotHealthScore { * builder always sets it (null when not in Rest Mode). */ restMode?: RestModeAnnotation | null; + /** + * v1.21.2 (A5) — Tension Verdict, locale-agnostic. Fires only when the + * readiness composite's contributors DISAGREE (≥1 strongly favourable AND ≥1 + * strongly unfavourable); suppressed (`null`) under a clinical red-flag so the + * red-flag path dominates. `positive` / `negative` carry the readiness + * contributor KEYS — the client maps each to its localised display label + * before handing the card its already-localised strings. Null on a coherent + * day. Optional (additive contract) so older cached snapshots stay valid. + */ + tension?: ScoreTensionDto | null; + /** + * v1.21.2 (A6) — return-to-baseline, locale-agnostic. Present only when a + * salient metric has come BACK inside the user's own personal range after a + * prior out-of-band run; at most one (the most salient). `metricType` is the + * `MeasurementType` the client maps to its localised metric name. Null + * otherwise. Optional (additive contract). + */ + returnToBand?: ScoreReturnToBandDto | null; +} + +/** + * v1.21.2 (A4) — the briefing recall + forward-look. Both strings are + * already-localised, server-resolved prose: `recall` is the prior period's + * narrative headline; `forward` points ahead to the most salient trend drift + * (or a calm "holding steady" when nothing drifted). The card renders them + * verbatim. Null when no prior narrative is on file. + */ +export interface DashboardSnapshotBriefingMemory { + recall: string; + forward: string; } export interface DashboardSnapshot { @@ -360,6 +402,13 @@ export interface DashboardSnapshot { */ healthScore: DashboardSnapshotHealthScore | null; briefing: DailyBriefing | null; + /** + * v1.21.2 (A4) — server-resolved recall + forward-look for the briefing card. + * Null when no prior narrative is on file. Rides the snapshot DTO so iOS reads + * the same already-localised block. Optional on the type (additive contract) + * so older cached snapshots / fixtures without it stay valid. + */ + briefingMemory?: DashboardSnapshotBriefingMemory | null; briefingState: BriefingState; briefingUpdatedAt: string | null; /** @@ -864,6 +913,76 @@ function gateSummariesByModules( return { summaries: outSummaries, lastSeenByType: outLastSeen }; } +/** + * v1.21.2 (A4) — map the machine-derived coach memory block onto the briefing + * card's `{ recall, forward }` shape, both already-localised. `recall` is the + * prior period's narrative headline (locale-generated prose). `forward` points + * ahead from the single most salient trend drift (a metric whose current-period + * center has moved OUT of its prior personal band), or a calm "holding steady" + * line when nothing drifted. Returns `null` when there is no prior narrative to + * recall — silence is the default, never a fabricated callback. + * + * Localised through the server translator + the EXISTING metric-name keys + * (`measurements.type*`). Two NEW forward-look template keys + * (`insights.briefing.memory.forwardWatch` / `forwardHolding`) are reported to + * the orchestrator; they degrade to the raw key string until the bundle lands. + */ +function mapBriefingMemory( + block: CoachMemoryBlock | null, + locale: Locale, +): DashboardSnapshotBriefingMemory | null { + if (!block?.priorNarrative) return null; + const recall = block.priorNarrative.headline.trim(); + if (recall.length === 0) return null; + + const { t } = getServerTranslator(locale); + + // The most salient drift: a trend metric whose current-period center sits + // OUT of its prior band. Deterministic — first in canonical map order. When + // none drifted, the forward-look is the calm "holding steady" line. + const driftType = Object.keys(block.trendMemory).find( + (type) => block.trendMemory[type]?.currentBand !== "in", + ); + let forward: string; + if (driftType) { + const metricLabel = localisedMeasurementLabel(driftType, t); + forward = t("insights.briefing.memory.forwardWatch", { + metric: metricLabel, + }); + } else { + forward = t("insights.briefing.memory.forwardHolding"); + } + return { recall, forward }; +} + +/** + * Localised display name for a `MeasurementType`, via the existing + * `measurements.type*` keys (reused — no new metric-name keys). Falls back to + * the prettified raw type when no key resolves so a new type never blanks. + */ +function localisedMeasurementLabel( + type: string, + t: (key: string, vars?: Record) => string, +): string { + // `WALKING_RUNNING_DISTANCE` → `measurements.typeWalkingRunningDistance`. + const camel = type + .toLowerCase() + .split("_") + .map((part, i) => + i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1), + ) + .join(""); + const key = `measurements.type${camel.charAt(0).toUpperCase()}${camel.slice(1)}`; + const resolved = t(key); + // The server translator echoes the key on a miss; fall back to a readable form. + return resolved === key ? type.replace(/_/g, " ").toLowerCase() : resolved; +} + +/** Narrow a 6-locale `Locale` to the de/en the narrative pipeline generates. */ +function narrativeLocale(locale: Locale): "de" | "en" { + return locale === "de" ? "de" : "en"; +} + /** * Assemble the full snapshot in ONE `Promise.all`. Every sub-read is * timed via the optional `time` wrapper so a regression is attributable @@ -889,6 +1008,25 @@ export async function buildDashboardSnapshot( * the client. */ modules?: () => Promise>; + /** + * v1.21.2 — active locale for the A4 briefing memory prose. Defaults to + * English; the route resolves it from the request (cookie / header / user + * preference). The recall headline is read locale-specific; non-de locales + * fall back to the English narrative read inside `buildCoachMemoryBlock`. + */ + locale?: Locale; + /** + * v1.21.2 — injectable narrative-memory builder so the snapshot tests can + * exercise the A4 wire without a narrative row. Defaults to the real + * `buildCoachMemoryBlock`. + */ + coachMemory?: typeof buildCoachMemoryBlock; + /** + * v1.21.2 — injectable score-narrative builder (A5 tension + A6 + * return-to-baseline) so the snapshot tests can drive the wire without the + * derived engines. Defaults to the real `buildScoreNarrativeBlock`. + */ + scoreNarrative?: typeof buildScoreNarrativeBlock; } = {}, ): Promise { const userTz = user.timezone ?? DEFAULT_TIMEZONE; @@ -968,6 +1106,50 @@ export async function buildDashboardSnapshot( options.hasProvider ?? (() => hasAnyConfiguredProvider(user.id)), ); + // v1.21.2 (A4 / A5 / A6) — the score-card narrative (Tension Verdict + + // return-to-baseline) and the briefing recall + forward-look. Gated like the + // briefing on the `insights` module so a narrative-off account carries none. + // Both are fail-soft — a transient derived-engine read resolves to null and + // never sinks the snapshot. The narrative block rides the warm phase's + // healthScore; memory rides the briefing card. The locale defaults to English. + const locale = options.locale ?? "en"; + const narrativeSex: "MALE" | "FEMALE" | null = + user.gender === "MALE" || user.gender === "FEMALE" ? user.gender : null; + const narrativeProfile = { + ageYears: getAgeFromDateOfBirth(user.dateOfBirth), + sex: narrativeSex, + heightCm: user.heightCm ?? null, + }; + // The Tension Verdict only adds signal once a health score actually rendered + // (a coherent ring with no contributors is nothing to reconcile); memory only + // matters once a briefing is shown. + const narrativeEnabled = + modules.insights !== false && extrasResult?.healthScore != null; + const [scoreNarrative, coachMemoryBlock] = await Promise.all([ + narrativeEnabled + ? time("scoreNarrative", () => + (options.scoreNarrative ?? buildScoreNarrativeBlock)( + user.id, + narrativeProfile, + now, + userTz, + coverage, + ), + ).catch(() => null) + : Promise.resolve(null), + briefing.briefing !== null && modules.insights !== false + ? time("coachMemory", () => + (options.coachMemory ?? buildCoachMemoryBlock)( + user.id, + narrativeProfile, + now, + narrativeLocale(locale), + ), + ).catch(() => null) + : Promise.resolve(null), + ]); + const briefingMemory = mapBriefingMemory(coachMemoryBlock, locale); + const gender = user.gender === "MALE" || user.gender === "FEMALE" ? user.gender : null; @@ -1000,8 +1182,19 @@ export async function buildDashboardSnapshot( }, extras: extrasResult?.extras ?? null, medsToday, - healthScore: extrasResult?.healthScore ?? null, + // v1.21.2 (A5 / A6) — fold the score-card narrative onto the health score so + // the hero reads tension + return-to-baseline off the resolved score DTO. + healthScore: extrasResult?.healthScore + ? { + ...extrasResult.healthScore, + tension: scoreNarrative?.tension ?? null, + returnToBand: scoreNarrative?.returnToBand ?? null, + } + : null, briefing: briefing.briefing, + // v1.21.2 (A4) — the briefing recall + forward-look. Null when there is no + // prior narrative on file or no briefing is shown. + briefingMemory: briefing.briefing !== null ? briefingMemory : null, briefingState: briefing.briefingState, briefingUpdatedAt: briefing.briefingUpdatedAt, briefingStale: briefing.briefingStale, diff --git a/src/lib/insights/__tests__/streak-detector.test.ts b/src/lib/insights/__tests__/streak-detector.test.ts new file mode 100644 index 000000000..ee0448ae9 --- /dev/null +++ b/src/lib/insights/__tests__/streak-detector.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest"; + +import { + detectStreak, + MIN_OUT_RUN, + type StreakPoint, +} from "@/lib/insights/streak-detector"; + +/** + * v1.21.2 (A6) — determinism pins for the streak / return-to-baseline detector. + * + * The PROSE the Coach narrates is not tested; the FLAGS are. These assert: + * - an in-band streak counts consecutive in-band days, + * - a calendar GAP day breaks a streak, + * - a return-to-baseline event fires only after a genuine prior out-of-band + * run, and never on a one-day blip, + * - too little history omits rather than guesses. + */ + +/** A series of N consecutive days starting at 2026-06-01, with given values. */ +function consecutive(values: number[], startDay = 1): StreakPoint[] { + return values.map((value, i) => ({ + day: `2026-06-${String(startDay + i).padStart(2, "0")}`, + value, + })); +} + +const IN_BAND = { low: 60, high: 80 }; + +describe("detectStreak — in-band streak", () => { + it("counts consecutive in-band days back from the latest", () => { + // All five days sit inside [60,80]. + const res = detectStreak(consecutive([70, 71, 69, 72, 70]), IN_BAND); + expect(res.latestPlacement).toBe("in"); + expect(res.inBand).toBe(true); + expect(res.streakDays).toBe(5); + expect(res.returnEvent).toBeUndefined(); + }); + + it("a calendar gap breaks the streak", () => { + // Days 1-3 in-band, then a gap (no day 4), then day 5 in-band. The streak + // counting back from day 5 is only 1 — the gap severs it. + const series: StreakPoint[] = [ + { day: "2026-06-01", value: 70 }, + { day: "2026-06-02", value: 71 }, + { day: "2026-06-03", value: 69 }, + // gap: no 2026-06-04 + { day: "2026-06-05", value: 72 }, + ]; + const res = detectStreak(series, IN_BAND); + expect(res.inBand).toBe(true); + expect(res.streakDays).toBe(1); + }); + + it("an out-of-band streak counts only the same side", () => { + // Latest day is above; the prior day was below — different runs, so the + // streak counting back from the latest above-day is 1. + const res = detectStreak(consecutive([50, 95]), IN_BAND); + expect(res.latestPlacement).toBe("above"); + expect(res.inBand).toBe(false); + expect(res.streakDays).toBe(1); + }); +}); + +describe("detectStreak — return-to-baseline event", () => { + it("fires after a genuine prior out-of-band run", () => { + // Three days above the band, then three days back inside it. The latest is + // in-band with a settled run, preceded by an out-of-band run of 3. + const res = detectStreak(consecutive([95, 96, 94, 70, 71, 69]), IN_BAND); + expect(res.inBand).toBe(true); + expect(res.streakDays).toBe(3); + expect(res.returnEvent).toBeDefined(); + expect(res.returnEvent).toEqual({ + daysInside: 3, + priorDaysOutside: 3, + priorDirection: "above", + }); + }); + + it("does not fire on a one-day blip (below MIN_OUT_RUN)", () => { + // A single above-band day before the in-band run is not a run to return + // from — MIN_OUT_RUN gates it out. + expect(MIN_OUT_RUN).toBe(2); + const res = detectStreak(consecutive([95, 70, 71, 69]), IN_BAND); + expect(res.inBand).toBe(true); + expect(res.returnEvent).toBeUndefined(); + }); + + it("does not fire when a gap separates the out-of-band run from the return", () => { + // Out-of-band run, then a gap, then the in-band run. The return cannot + // bridge a missing calendar day, so no event fires. + const series: StreakPoint[] = [ + { day: "2026-06-01", value: 95 }, + { day: "2026-06-02", value: 96 }, + // gap: no 2026-06-03 + { day: "2026-06-04", value: 70 }, + { day: "2026-06-05", value: 71 }, + ]; + const res = detectStreak(series, IN_BAND); + expect(res.inBand).toBe(true); + expect(res.returnEvent).toBeUndefined(); + }); + + it("does not fire when the metric never left the band", () => { + const res = detectStreak(consecutive([70, 71, 69, 72]), IN_BAND); + expect(res.returnEvent).toBeUndefined(); + }); +}); + +describe("detectStreak — conservatism", () => { + it("omits (null placement, no event) on an empty series", () => { + const res = detectStreak([], IN_BAND); + expect(res.latestPlacement).toBeNull(); + expect(res.streakDays).toBe(0); + expect(res.returnEvent).toBeUndefined(); + }); + + it("builds a personal band from the series when none is supplied", () => { + // A long, steady run with one recent dip. The MAD band derives from the + // whole series; the steady days read in-band. + const steady = consecutive([ + 70, 71, 69, 70, 72, 68, 70, 71, 69, 70, 71, 70, + ]); + const res = detectStreak(steady); + expect(res.latestPlacement).toBe("in"); + expect(res.streakDays).toBeGreaterThan(0); + }); + + it("omits when there is too little history to build a band", () => { + // An empty band build returns null → a quiet omit. A single point gives a + // zero-spread band; supplying no band and one point yields an in placement + // but never a return. The conservatism we pin is the empty case above; a + // degenerate one-point series must at least never fabricate a return. + const res = detectStreak(consecutive([70])); + expect(res.returnEvent).toBeUndefined(); + }); +}); diff --git a/src/lib/insights/derived/__tests__/coach-read.test.ts b/src/lib/insights/derived/__tests__/coach-read.test.ts new file mode 100644 index 000000000..e98f95e74 --- /dev/null +++ b/src/lib/insights/derived/__tests__/coach-read.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; + +import { + placeAgainstBand, + pickDriverForMetric, + humaniseType, +} from "@/lib/insights/derived/coach-read-shape"; +import type { CoachCorrelationDriver } from "@/lib/ai/coach/tools/correlations-read"; + +/** + * v1.21.2 (A1) — "Coach read" strip selection logic. + * + * Covers the two decisions the strip turns on: + * 1. band-line placement (within / above / below), incl. the inclusive + * edges that read "within", and the insufficient path (no band). + * 2. driver-line pick + omission — strongest on-metric driver wins, and + * nothing on the metric ⇒ the line is omitted (null). + * + * Pure helpers — no Prisma, no engine round-trip. The MAD band itself + the + * 7-day floor are owned by `baseline.test.ts`; this file pins only the strip's + * own selection seam. + */ + +function driver( + overrides: Partial & + Pick, +): CoachCorrelationDriver { + return { + behaviour: "sleep duration", + direction: overrides.r >= 0 ? "higher" : "lower", + lagDays: 1, + n: 42, + note: "On days your sleep runs short, your resting pulse tends to rise.", + ...overrides, + }; +} + +describe("placeAgainstBand", () => { + it("reads 'within' when the value sits inside the band", () => { + expect(placeAgainstBand(72, 60, 80)).toBe("within"); + }); + + it("reads 'above' when the value clears the upper edge", () => { + expect(placeAgainstBand(85, 60, 80)).toBe("above"); + }); + + it("reads 'below' when the value sits under the lower edge", () => { + expect(placeAgainstBand(55, 60, 80)).toBe("below"); + }); + + it("treats both edges as inclusive (on-edge reads 'within')", () => { + expect(placeAgainstBand(60, 60, 80)).toBe("within"); + expect(placeAgainstBand(80, 60, 80)).toBe("within"); + }); +}); + +describe("pickDriverForMetric", () => { + const restingPulse = humaniseType("RESTING_HEART_RATE" as never); // "resting heart rate" + + it("omits the line when no driver lands on the metric (returns null)", () => { + const drivers = [ + driver({ outcome: "weight", r: 0.6 }), + driver({ outcome: "sleep duration", r: -0.5 }), + ]; + expect(pickDriverForMetric(drivers, restingPulse)).toBeNull(); + }); + + it("omits the line on an empty driver list", () => { + expect(pickDriverForMetric([], restingPulse)).toBeNull(); + }); + + it("picks the strongest |r| driver whose outcome is the metric", () => { + const weak = driver({ outcome: restingPulse, r: 0.21, n: 30 }); + const strong = driver({ outcome: restingPulse, r: -0.48, n: 50 }); + const offMetric = driver({ outcome: "weight", r: 0.9 }); + + const picked = pickDriverForMetric([weak, strong, offMetric], restingPulse); + expect(picked).toBe(strong); + }); + + it("ignores off-metric drivers even when they are stronger", () => { + const onMetric = driver({ outcome: restingPulse, r: 0.3 }); + const strongerElsewhere = driver({ outcome: "weight", r: 0.95 }); + + const picked = pickDriverForMetric( + [onMetric, strongerElsewhere], + restingPulse, + ); + expect(picked).toBe(onMetric); + }); +}); diff --git a/src/lib/insights/derived/coach-read-shape.ts b/src/lib/insights/derived/coach-read-shape.ts new file mode 100644 index 000000000..278aa243c --- /dev/null +++ b/src/lib/insights/derived/coach-read-shape.ts @@ -0,0 +1,98 @@ +/** + * v1.21.2 (A1) — "Coach read" strip: shared shapes + pure selection logic. + * + * Client-safe (no `server-only`, no Prisma): the DTO shapes the route returns + * and the strip renders, plus the two pure decisions the strip turns on — + * band placement (within / above / below) and the on-metric driver pick. The + * server builder (`coach-read.ts`) composes these over the baseline + + * correlation engines; the unit test pins them directly. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import type { CoachCorrelationDriver } from "@/lib/ai/coach/tools/correlations-read"; + +/** Where today's latest reading sits relative to the personal band. */ +export type CoachReadBaselinePlacement = "within" | "above" | "below"; + +/** The resolved own-baseline line (line 1). */ +export interface CoachReadBaseline { + /** Robust lower edge of the personal typical range. */ + low: number; + /** Robust upper edge of the personal typical range. */ + high: number; + /** Today's latest reading (display value, same units as the band). */ + latest: number; + /** Where the latest reading sits relative to the band. */ + placement: CoachReadBaselinePlacement; + /** Distinct days that fed the band (transparency). */ + sampleDays: number; +} + +/** The resolved lagged-association line (line 2). */ +export interface CoachReadDriver { + /** The engine's conservative, descriptive, never-causal interpretation. */ + note: string; + /** Lower-cased behaviour label (e.g. "sleep duration"). */ + behaviour: string; + /** Lower-cased outcome label (this metric). */ + outcome: string; +} + +export interface CoachReadStripData { + /** + * The own-baseline line. `null` when the band has < 7 days of history + * (`learning: true`) or when there is no latest reading to place. + */ + baseline: CoachReadBaseline | null; + /** + * True when a band could not be established yet (history below the + * engine's 7-day floor, or no readings). Drives the "still learning your + * range" copy. Mutually exclusive with a non-null `baseline`. + */ + learning: boolean; + /** + * The single strongest lagged driver whose outcome is this metric, or + * `null` when none clears the existing effect-size floor (line 2 omitted). + */ + driver: CoachReadDriver | null; +} + +/** + * `humanise()` in the correlations reader maps a discovery channel key to a + * lower-cased label. We need the SAME mapping to recognise which discovered + * drivers land on this page's metric. Kept byte-identical to the reader's + * private helper for MeasurementType keys. + */ +export function humaniseType(type: MeasurementType): string { + return String(type).replace(/_/g, " ").toLowerCase(); +} + +/** + * Where today's reading sits relative to the personal band. Pure. The band + * edges are inclusive — a reading exactly on an edge reads "within". + */ +export function placeAgainstBand( + latest: number, + low: number, + high: number, +): CoachReadBaselinePlacement { + if (latest > high) return "above"; + if (latest < low) return "below"; + return "within"; +} + +/** + * Pick the strongest driver whose OUTCOME is this metric. The reader already + * floors + tiers + FDR-controls the list; we only choose the page-relevant + * row with the largest |r| so the strip surfaces the single most informative + * link. Returns `null` when no driver lands on the metric. + */ +export function pickDriverForMetric( + drivers: CoachCorrelationDriver[], + outcomeLabel: string, +): CoachCorrelationDriver | null { + const onMetric = drivers.filter((d) => d.outcome === outcomeLabel); + if (onMetric.length === 0) return null; + return onMetric.reduce((best, cur) => + Math.abs(cur.r) > Math.abs(best.r) ? cur : best, + ); +} diff --git a/src/lib/insights/derived/coach-read.ts b/src/lib/insights/derived/coach-read.ts new file mode 100644 index 000000000..c18300f82 --- /dev/null +++ b/src/lib/insights/derived/coach-read.ts @@ -0,0 +1,117 @@ +/** + * v1.21.2 (A1) — "Coach read" strip data builder. + * + * Two server-authoritative lines for a single metric sub-page, computed + * here so web and iOS read the SAME resolved DTO (no client re-derivation): + * + * 1. own-baseline — the user's personal typical range (median ± k·MAD) + * from `computeVitalsBaseline`, plus where today's latest reading sits + * relative to it (within / above / below). Below the engine's 7-day + * history floor the band is `insufficient` and the strip says + * "still learning your range" — never a fabricated range. + * 2. one lagged association — the single strongest discovered driver + * whose OUTCOME is this metric, surfaced from `readCoachCorrelations` + * (FDR-controlled, effect-size-floored, confidence-tiered). The line + * carries the engine's own never-causal interpretation verbatim. When + * nothing clears the existing floor the line is omitted entirely. + * + * Both inputs reuse the deterministic engines unchanged — no new statistics, + * no lowered floor. Server-only by construction — only the metric-page + * route imports it, and it reads Prisma. The DTO shapes + the pure selection + * helpers live in the client-safe `coach-read-shape.ts` sibling so the strip + * component and the unit test can share them without pulling Prisma. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + loadBaselineProfile, + computeVitalsBaseline, +} from "@/lib/insights/derived/baseline"; +import { isDerivedOk } from "@/lib/insights/derived"; +import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; +import { + humaniseType, + placeAgainstBand, + pickDriverForMetric, + type CoachReadBaseline, + type CoachReadDriver, + type CoachReadStripData, +} from "@/lib/insights/derived/coach-read-shape"; + +export type { + CoachReadBaseline, + CoachReadDriver, + CoachReadStripData, + CoachReadBaselinePlacement, +} from "@/lib/insights/derived/coach-read-shape"; + +/** + * Read the latest reading for `(userId, type)` — the value the strip places + * against the band. Display-side scaling (e.g. WALKING_SPEED m/s → km/h) is + * the caller's concern; the strip renders unscaled stored values, and the + * band is unscaled too, so the placement is scale-invariant. + */ +async function readLatestValue( + userId: string, + type: MeasurementType, +): Promise { + const row = await prisma.measurement.findFirst({ + where: { userId, type, deletedAt: null }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }); + return row?.value ?? null; +} + +/** + * Build the "Coach read" strip payload for one metric. Pure orchestration + * over the baseline + correlations engines; never throws on a partial read + * (a correlations hiccup degrades line 2 to `null`, the band stands alone). + */ +export async function buildCoachReadStrip( + userId: string, + type: MeasurementType, +): Promise { + const profile = await loadBaselineProfile(prisma, userId); + + const [baselineDerived, latest, correlations] = await Promise.all([ + computeVitalsBaseline(userId, profile, { type }), + readLatestValue(userId, type), + // Line 2 is best-effort: a correlation failure must never sink the band. + readCoachCorrelations(userId).catch(() => ({ present: false }) as const), + ]); + + let baseline: CoachReadBaseline | null = null; + let learning = false; + + if (isDerivedOk(baselineDerived) && latest !== null) { + const { low, high, sampleDays } = baselineDerived.value; + baseline = { + low, + high, + latest, + placement: placeAgainstBand(latest, low, high), + sampleDays, + }; + } else { + // Band not established (history below the 7-day floor, or no readings) — + // the strip says "still learning your range" rather than inventing one. + learning = true; + } + + const driver = ((): CoachReadDriver | null => { + if (!("drivers" in correlations) || !correlations.drivers) return null; + const picked = pickDriverForMetric( + correlations.drivers, + humaniseType(type), + ); + if (!picked) return null; + return { + note: picked.note, + behaviour: picked.behaviour, + outcome: picked.outcome, + }; + })(); + + return { baseline, learning, driver }; +} diff --git a/src/lib/insights/streak-detector.ts b/src/lib/insights/streak-detector.ts new file mode 100644 index 000000000..9484b3a02 --- /dev/null +++ b/src/lib/insights/streak-detector.ts @@ -0,0 +1,210 @@ +/** + * v1.21.2 (A6) — per-metric in/out-of-band streak + return-to-baseline event. + * + * A PURE detector over a DAY-bucket per-day-mean series. It answers two + * questions the "ambient Coach presence" surface needs to close a worry: + * + * 1. How many consecutive days has this metric sat INSIDE (or OUTSIDE) its + * personal range, counting back from the most recent day? A streak is + * strictly consecutive: a calendar GAP day (a day with no reading) + * BREAKS it — we never paper over a missing day as "still inside". + * 2. Did the metric just RETURN to its personal range after a run outside + * it? "Back inside your usual range after last week's dip — whatever that + * was, it's passed." A return only fires when a prior OUT-of-band run of + * at least `MIN_OUT_RUN` days is followed by an IN-band run of at least + * `MIN_IN_RUN` days ending on the latest day. Without a genuine prior + * out-of-band run there is nothing to "return" from. + * + * The band is the user's OWN personal range: median ± k·MAD (Hampel/Leys), + * built by `buildBaselineBand` from the SAME series — never an invented + * clinical threshold. The band is established over the WHOLE series so a + * single recent dip doesn't move the goalposts; callers that want a stricter + * "prior period establishes the band" split can pass a pre-built band. + * + * Inherent-low conservatism: when the series is too short to establish a band + * (or the caller passes none and `buildBaselineBand` returns null), the + * detector returns a quiet `null`-streak result and fires NO return event — + * it omits rather than guesses. + * + * Pure: no DB, no clock, no network. Fully unit-testable over injected series. + */ +import { buildBaselineBand } from "@/lib/insights/derived/baseline"; + +/** One DAY-bucket point: a calendar day key (YYYY-MM-DD) + that day's mean. */ +export interface StreakPoint { + /** Calendar day key in the user's timezone (YYYY-MM-DD). */ + day: string; + /** The day's mean value. */ + value: number; +} + +/** A personal range: the median ± k·MAD band a value sits inside or outside. */ +export interface StreakBand { + low: number; + high: number; +} + +/** Where a single day's value sits relative to the personal band. */ +export type BandPlacement = "in" | "above" | "below"; + +/** + * Minimum consecutive out-of-band days that count as a genuine "run" the user + * could have worried about — a one-day blip is not a run to return from. + */ +export const MIN_OUT_RUN = 2; +/** Minimum consecutive in-band days that count as a settled "return". */ +export const MIN_IN_RUN = 2; + +/** The detector's result: the current streak + an optional return event. */ +export interface StreakResult { + /** + * The most-recent day's placement relative to the band, or null when no + * band could be established (too little history). + */ + latestPlacement: BandPlacement | null; + /** + * Consecutive days (counting back from the latest day) the metric held the + * SAME in/out state. A calendar gap breaks the count. 0 when no band. + */ + streakDays: number; + /** True when the current streak is an INSIDE-the-band run. */ + inBand: boolean; + /** + * The return-to-baseline event, present only when a prior out-of-band run of + * ≥ MIN_OUT_RUN days is followed by an in-band run of ≥ MIN_IN_RUN days + * ending on the latest day. Absent (undefined) otherwise — silence is the + * default. + */ + returnEvent?: { + /** Days the metric has now sat back inside its range (the in-band run). */ + daysInside: number; + /** Days the prior out-of-band run lasted (the dip it returned from). */ + priorDaysOutside: number; + /** Which side the prior run sat on. */ + priorDirection: Exclude; + }; +} + +/** Classify one value against the personal band. */ +function placeValue(value: number, band: StreakBand): BandPlacement { + if (value > band.high) return "above"; + if (value < band.low) return "below"; + return "in"; +} + +/** + * Detect the current in/out-of-band streak and any return-to-baseline event + * from a DAY-bucket series. + * + * `series` need not be sorted; it is sorted ascending by day internally. + * Duplicate day keys are not expected (the caller passes per-day means) but a + * later entry for the same day wins, defensively. + * + * `band` may be supplied (e.g. a prior-period band) — when omitted it is built + * from the whole series via `buildBaselineBand`. A gap day (a calendar day with + * no point between two present days) BREAKS any streak that would span it. + */ +export function detectStreak( + series: StreakPoint[], + band?: StreakBand | null, +): StreakResult { + const empty: StreakResult = { + latestPlacement: null, + streakDays: 0, + inBand: false, + }; + + // De-dupe by day (last wins) and sort ascending so "consecutive" is by date. + const byDay = new Map(); + for (const p of series) { + if (!Number.isFinite(p.value)) continue; + byDay.set(p.day, p.value); + } + const points = [...byDay.entries()] + .map(([day, value]) => ({ day, value })) + .sort((a, b) => (a.day < b.day ? -1 : 1)); + if (points.length === 0) return empty; + + // Resolve the personal band: caller-supplied, else median ± k·MAD over the + // whole series. Null (too little history) → omit rather than guess. + const resolvedBand = band ?? buildBandFromSeries(points.map((p) => p.value)); + if (!resolvedBand) return empty; + + // Per-day placement, ascending. A gap day inserts an explicit break marker so + // the streak walk below cannot bridge a missing calendar day. + const placements: BandPlacement[] = points.map((p) => + placeValue(p.value, resolvedBand), + ); + const days = points.map((p) => p.day); + + // ── current streak: walk back from the latest day, same state, no gap ── + const latestPlacement = placements[placements.length - 1]; + const latestInBand = latestPlacement === "in"; + let streakDays = 1; + for (let i = placements.length - 2; i >= 0; i--) { + if (dayGap(days[i], days[i + 1]) !== 1) break; // calendar gap breaks it + const sameState = + (placements[i] === "in") === latestInBand && + // For an out-of-band streak, require the SAME side (above vs below); a + // flip from above to below is a different run, not one streak. + (latestInBand || placements[i] === latestPlacement); + if (!sameState) break; + streakDays += 1; + } + + const result: StreakResult = { + latestPlacement, + streakDays, + inBand: latestInBand, + }; + + // ── return-to-baseline event ── + // Only when the metric is currently IN-band with a settled in-band run, and + // the days IMMEDIATELY before that run (no gap) were a genuine out-of-band + // run of ≥ MIN_OUT_RUN days on one side. + if (latestInBand && streakDays >= MIN_IN_RUN) { + const inRunStart = placements.length - streakDays; // index of the in-run's first day + // The out-of-band run must be calendar-adjacent to the in-band run. + if ( + inRunStart > 0 && + dayGap(days[inRunStart - 1], days[inRunStart]) === 1 && + placements[inRunStart - 1] !== "in" + ) { + const priorDirection = placements[inRunStart - 1] as Exclude< + BandPlacement, + "in" + >; + let priorDaysOutside = 1; + for (let i = inRunStart - 2; i >= 0; i--) { + if (dayGap(days[i], days[i + 1]) !== 1) break; + if (placements[i] !== priorDirection) break; + priorDaysOutside += 1; + } + if (priorDaysOutside >= MIN_OUT_RUN) { + result.returnEvent = { + daysInside: streakDays, + priorDaysOutside, + priorDirection, + }; + } + } + } + + return result; +} + +/** Build the personal band from a value series; null when too little data. */ +function buildBandFromSeries(values: number[]): StreakBand | null { + const band = buildBaselineBand(values); + if (!band) return null; + if (!Number.isFinite(band.low) || !Number.isFinite(band.high)) return null; + return { low: band.low, high: band.high }; +} + +/** Whole-day gap between two YYYY-MM-DD keys (>=1; 1 = adjacent). */ +function dayGap(earlier: string, later: string): number { + const a = Date.parse(`${earlier}T00:00:00Z`); + const b = Date.parse(`${later}T00:00:00Z`); + if (Number.isNaN(a) || Number.isNaN(b)) return Number.POSITIVE_INFINITY; + return Math.round((b - a) / (24 * 60 * 60 * 1000)); +} diff --git a/src/lib/query-keys/coach.ts b/src/lib/query-keys/coach.ts index 76056bb1f..90e2652ae 100644 --- a/src/lib/query-keys/coach.ts +++ b/src/lib/query-keys/coach.ts @@ -26,4 +26,10 @@ export const coachKeys = { * the streaming hook invalidates this slot once the persisted twin lands. */ coachConversation: (id: string | null) => ["coachConversation", id] as const, + /** + * v1.21.2 (A3) — today's most notable derived signal, resolved into the + * Coach hero's pre-seeded relevance opener + * (`GET /api/insights/coach/seeded-question`). + */ + coachSeededQuestion: () => ["coach-seeded-question"] as const, }; diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index d90fade89..0a3607438 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -126,4 +126,15 @@ export const insightsKeys = { * refreshes the caption in lockstep with the chart. */ insightsTrendSeries: (types: string) => ["trend-series", types] as const, + + /** + * v1.21.2 (A1) — per-metric "Coach read" strip + * (`/api/insights/coach-read?metric=`). One generic route + * backs every metric sub-page, so the key is parameterised by the + * MeasurementType. Pure compute over the baseline + correlation engines — + * the `["insights"]` prefix keeps it in the standard invalidation fan-out + * (a measurement write busts it so the placement refreshes). + */ + insightsCoachRead: (metric: string) => + ["insights", "coach-read", metric] as const, };