mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #365 from MBombeck/release/v1.21.2
release/v1.21.2 — ambient Coach presence
This commit is contained in:
48 files changed
+3471
-32
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+46
-1
@@ -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": {
|
||||
|
||||
+46
-1
@@ -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": {
|
||||
|
||||
+46
-1
@@ -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": {
|
||||
|
||||
+46
-1
@@ -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": {
|
||||
|
||||
+46
-1
@@ -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": {
|
||||
|
||||
+46
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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<DashboardSnapshot>,
|
||||
`${user.id}|dashboard-snapshot`,
|
||||
() => buildDashboardSnapshot(prisma, snapshotUser, { time }),
|
||||
`${user.id}|dashboard-snapshot|${locale}`,
|
||||
() => buildDashboardSnapshot(prisma, snapshotUser, { time, locale }),
|
||||
annotate,
|
||||
SNAPSHOT_CACHE_TTL_MS,
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* v1.21.2 (A1) — "Coach read" strip route.
|
||||
*
|
||||
* `GET /api/insights/coach-read?metric=<MeasurementType>` 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);
|
||||
});
|
||||
@@ -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<typeof import("@/lib/modules/gate")>()),
|
||||
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<typeof import("@/lib/insights/derived")>()),
|
||||
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<Response>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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={
|
||||
<CoachReadStrip
|
||||
metricType="BLOOD_PRESSURE_SYS"
|
||||
unit="mmHg"
|
||||
fractionDigits={0}
|
||||
/>
|
||||
}
|
||||
statStrip={
|
||||
// v1.12.7 — blood pressure is two series, but they share ONE card
|
||||
// with the systolic / diastolic columns side by side (stacking only
|
||||
|
||||
@@ -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: <VitalsDashboard batch={dashboardDerived} layout={layout} />,
|
||||
@@ -491,6 +510,11 @@ export default function InsightsPage() {
|
||||
// <relative>" 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
|
||||
|
||||
@@ -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={
|
||||
<CoachReadStrip
|
||||
metricType={hasRestingHr ? "RESTING_HEART_RATE" : "PULSE"}
|
||||
unit="bpm"
|
||||
fractionDigits={0}
|
||||
/>
|
||||
}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
measurementType="PULSE"
|
||||
|
||||
@@ -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";
|
||||
@@ -88,6 +89,7 @@ export default function InsightsGewichtPage() {
|
||||
windowStats={statsByType?.WEIGHT ?? null}
|
||||
/>
|
||||
}
|
||||
coachReadStrip={<CoachReadStrip metricType="WEIGHT" unit="kg" />}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
measurementType="WEIGHT"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import { COACH_SOURCE_DOMAIN_LABEL } from "@/lib/ai/coach/tools/source-keys";
|
||||
|
||||
/**
|
||||
* v1.21.0 (C4 H1/H4) — metric → Coach scope + seed-question map.
|
||||
@@ -165,3 +166,58 @@ export function scopeSourceFromMetricKey(
|
||||
if (lower === "bmi") return "bmi";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.21.2 (A2) — the human label for a launch scope's primary metric, for
|
||||
* the VISIBLE "the Coach is already on …" pill. The caller resolves the
|
||||
* i18n string via `t("insights.coach.scope.metric.<source>")`; 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<Record<CoachScopeSource, string>> =
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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("<ScopeHintBadge>", () => {
|
||||
it("renders the scope pill with the active metric and the tappable seed", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="scope"
|
||||
label="blood pressure"
|
||||
question="Walk me through my blood pressure trend."
|
||||
onSeed={() => {}}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ScopeHintBadge
|
||||
variant="seeded"
|
||||
label="readiness"
|
||||
question="Why is my readiness lower today?"
|
||||
onSeed={() => {}}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ScopeHintBadge
|
||||
variant="scope"
|
||||
label="pulse"
|
||||
question={question}
|
||||
onSeed={onSeed}
|
||||
/>,
|
||||
);
|
||||
onSeed(question);
|
||||
expect(onSeed).toHaveBeenCalledWith(question);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachHero> scope hint slot", () => {
|
||||
it("renders the scope hint above-composer slot when provided", () => {
|
||||
const html = render(
|
||||
<CoachHero
|
||||
composer={<div data-slot="test-composer">composer</div>}
|
||||
scopeHint={<div data-slot="injected-hint">hint</div>}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<CoachHero composer={<div data-slot="test-composer">composer</div>} />,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 <metric>" 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<CoachSeededQuestionDTO>("/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 = (
|
||||
<ScopeHintBadge
|
||||
variant="scope"
|
||||
label={a2MetricLabel}
|
||||
question={seedQuestion}
|
||||
onSeed={seedComposer}
|
||||
/>
|
||||
);
|
||||
} 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 = (
|
||||
<ScopeHintBadge
|
||||
variant="seeded"
|
||||
label={signalLabel}
|
||||
question={signalQuestion}
|
||||
onSeed={seedComposer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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({
|
||||
</Button>
|
||||
</div>
|
||||
{heroActive ? (
|
||||
<CoachHero composer={composerNode} />
|
||||
<CoachHero composer={composerNode} scopeHint={scopeHint} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
@@ -703,7 +790,16 @@ export function CoachConversation({
|
||||
}
|
||||
sourcesOpen={sourcesTrayOpen}
|
||||
onSourcesOpenChange={setSourcesTrayOpen}
|
||||
sourcesRail={<SourcesRail />}
|
||||
sourcesRail={
|
||||
<SourcesRail
|
||||
// v1.21.2 (A2) — surface the launch scope on the drawer surface;
|
||||
// only set for a fresh, scoped, not-yet-sent conversation so a
|
||||
// continued thread (its own established scope) shows no stale line.
|
||||
activeScopeLabel={
|
||||
currentConversationId === null ? a2MetricLabel : null
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 `<ScopeHintBadge>`): the visible "the Coach is already on <metric>"
|
||||
* 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 `<CoachInput>` 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 `<ScopeHintBadge>`; 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}
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<div
|
||||
data-slot="coach-hero-scope-hint"
|
||||
className={cn(
|
||||
"w-full",
|
||||
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2 motion-safe:duration-500",
|
||||
)}
|
||||
style={{ animationDelay: "240ms", animationFillMode: "both" }}
|
||||
>
|
||||
{scopeHint}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 <metric>" —
|
||||
* 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 <metric>" 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 (
|
||||
<div
|
||||
data-slot="coach-scope-hint"
|
||||
data-variant={variant}
|
||||
className={cn("flex w-full flex-col items-center gap-2.5", className)}
|
||||
>
|
||||
{/* The scope/signal pill — the visible "already on …" line. */}
|
||||
<span
|
||||
data-slot="coach-scope-hint-pill"
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 text-muted-foreground",
|
||||
"inline-flex max-w-full items-center gap-1.5 rounded-full border px-3 py-1",
|
||||
"text-xs font-medium",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className="text-dracula-purple size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{prefix}</span>
|
||||
</span>
|
||||
|
||||
{/* The tappable seed question — the data-aware opener. A real button so
|
||||
keyboard + screen-reader users get the same affordance. */}
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-scope-hint-seed"
|
||||
onClick={() => onSeed(question)}
|
||||
className={cn(
|
||||
"group border-border/70 bg-background hover:bg-muted/50 text-foreground",
|
||||
"flex w-full max-w-md items-center gap-2 rounded-xl border px-3.5 py-2.5 text-left",
|
||||
"transition-colors",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-sm leading-snug">{question}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-foreground text-xs whitespace-nowrap"
|
||||
>
|
||||
{t("insights.coach.scope.tap")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<CoachDefaultWindow> = [
|
||||
|
||||
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 <metric>" 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")}
|
||||
</h3>
|
||||
|
||||
{/* 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 ? (
|
||||
<div
|
||||
data-slot="coach-sources-active-scope"
|
||||
className="border-dracula-purple/30 bg-dracula-purple/5 text-foreground flex items-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-medium"
|
||||
>
|
||||
<Target
|
||||
className="text-dracula-purple size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 truncate">
|
||||
{t("insights.coach.scope.prefix", { metric: activeScopeLabel })}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Window selector — persists to `coachPrefs.defaultWindow` so the
|
||||
chosen timeframe sticks across drawer opens and matches what
|
||||
the cog sheet shows. */}
|
||||
|
||||
@@ -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.
|
||||
<div className="space-y-3">
|
||||
{/* 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 && (
|
||||
<div
|
||||
data-slot="daily-briefing-memory"
|
||||
className="border-border/60 bg-card/40 space-y-1 rounded-md border px-3 py-2"
|
||||
>
|
||||
<p
|
||||
data-slot="daily-briefing-memory-recall"
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
>
|
||||
{t("insights.briefing.memory.recall", {
|
||||
text: memory.recall,
|
||||
})}
|
||||
</p>
|
||||
<p
|
||||
data-slot="daily-briefing-memory-forward"
|
||||
className="text-foreground/80 text-xs leading-snug"
|
||||
>
|
||||
{t("insights.briefing.memory.forward", {
|
||||
text: memory.forward,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 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
|
||||
|
||||
@@ -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<CoachReadStripData>(
|
||||
`/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 (
|
||||
<div
|
||||
data-slot="coach-read-strip"
|
||||
className={cn(
|
||||
"bg-card/60 border-border/60 rounded-xl border px-3.5 py-2.5",
|
||||
"text-muted-foreground space-y-1 text-sm leading-relaxed",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles
|
||||
className="text-primary/70 mt-0.5 size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<span className="text-foreground/70 sr-only text-xs font-medium">
|
||||
{t("insights.coach.readStrip.label")}
|
||||
</span>
|
||||
{baselineLine ? (
|
||||
<p data-slot="coach-read-baseline" className="text-pretty">
|
||||
{baselineLine}
|
||||
</p>
|
||||
) : null}
|
||||
{driverLine ? (
|
||||
<p
|
||||
data-slot="coach-read-driver"
|
||||
className="text-muted-foreground/90 text-pretty"
|
||||
>
|
||||
{driverLine}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<p
|
||||
data-slot="health-score-card-tension"
|
||||
data-band={tension.band}
|
||||
className="text-muted-foreground inline-flex items-start gap-1.5 text-[11px] leading-relaxed"
|
||||
>
|
||||
<Scale className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
{t("insights.tension.label", {
|
||||
positive: listFormatter.format(tension.positive),
|
||||
negative: listFormatter.format(tension.negative),
|
||||
band: t(`insights.tension.contributor.${tension.band}`),
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<p
|
||||
data-slot="health-score-card-return-to-band"
|
||||
className="text-success/90 inline-flex items-start gap-1.5 text-[11px] leading-relaxed"
|
||||
>
|
||||
<CornerDownLeft
|
||||
className="mt-0.5 size-3 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{t("insights.streak.returnLabel", {
|
||||
metric: returnToBand.metricLabel,
|
||||
count: returnToBand.daysInside,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul
|
||||
data-slot="health-score-card-components"
|
||||
// v1.8.5 W4a — the always-visible "Zusammensetzung" breakdown is
|
||||
|
||||
@@ -23,6 +23,7 @@ import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory
|
||||
import { isTrajectoryType } from "@/lib/insights/derived/registry";
|
||||
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 { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
@@ -354,6 +355,14 @@ export function HealthKitMetricPage({
|
||||
medianLabel={statMedianLabel}
|
||||
/>
|
||||
}
|
||||
coachReadStrip={
|
||||
<CoachReadStrip
|
||||
metricType={effectiveType}
|
||||
unit={yAxisUnit ?? unit}
|
||||
fractionDigits={statFractionDigits}
|
||||
valueScale={valueScale}
|
||||
/>
|
||||
}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
measurementType={effectiveType}
|
||||
|
||||
@@ -10,9 +10,23 @@ import { cn } from "@/lib/utils";
|
||||
import {
|
||||
HealthScoreCard,
|
||||
type HealthScoreCardProps,
|
||||
type HealthScoreBand,
|
||||
} from "./health-score-card";
|
||||
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
|
||||
|
||||
/**
|
||||
* v1.21.2 (A5) — readiness contributor keys the Tension Verdict surfaces. Kept
|
||||
* as a local string union so the client never imports the server derived-engine
|
||||
* types. Localised through the existing
|
||||
* `insights.derived.composite.READINESS.component.{key}` labels.
|
||||
*/
|
||||
export type ReadinessContributorKey =
|
||||
| "rhr"
|
||||
| "hrv"
|
||||
| "sleep"
|
||||
| "respiratory"
|
||||
| "mood";
|
||||
|
||||
/**
|
||||
* Insights redesign hero strip.
|
||||
*
|
||||
@@ -78,6 +92,31 @@ interface HeroStripProps {
|
||||
*/
|
||||
restMode?: { active: boolean } | null;
|
||||
} | null;
|
||||
/**
|
||||
* v1.21.2 (A5) — Tension Verdict, server-resolved + locale-agnostic. `band`
|
||||
* is the readiness composite's band; `positive` / `negative` carry the
|
||||
* readiness contributor KEYS (`rhr` / `hrv` / `sleep` / `respiratory` /
|
||||
* `mood`). The hero localises each key through the existing
|
||||
* `insights.derived.composite.READINESS.component.*` labels before handing the
|
||||
* score card its already-localised strings. Null on a coherent day (or under a
|
||||
* clinical red-flag suppress). The score card carries it only when the score
|
||||
* is present.
|
||||
*/
|
||||
tension?: {
|
||||
band: HealthScoreBand;
|
||||
positive: ReadinessContributorKey[];
|
||||
negative: ReadinessContributorKey[];
|
||||
} | null;
|
||||
/**
|
||||
* v1.21.2 (A6) — return-to-baseline, server-resolved + locale-agnostic.
|
||||
* `metricType` is a `MeasurementType` the hero maps to its localised metric
|
||||
* name (the existing `measurements.type*` keys). Null when no salient metric
|
||||
* returned from a genuine prior out-of-band run.
|
||||
*/
|
||||
returnToBand?: {
|
||||
metricType: string;
|
||||
daysInside: number;
|
||||
} | null;
|
||||
/**
|
||||
* v1.16.8 — true while the analytics payload that carries
|
||||
* `healthScore` is still in flight. The right column then reserves the
|
||||
@@ -111,6 +150,32 @@ function resolveGreetingKey(now: Date): string {
|
||||
return "insights.heroGreetingEvening";
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.21.2 (A5) — readiness contributor key → existing localised label key.
|
||||
* Reuses the score-anatomy contributor labels so the Tension Verdict reads the
|
||||
* same names the wellness ring's anatomy view renders — no new keys.
|
||||
*/
|
||||
const TENSION_CONTRIBUTOR_LABEL_KEY: Record<ReadinessContributorKey, string> = {
|
||||
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<string, string> = {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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`. */}
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, DerivedSnapshotEntry> & {
|
||||
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<string, string> = {
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof import("@/lib/insights/derived")>();
|
||||
return {
|
||||
...actual,
|
||||
computeReadiness: (...a: unknown[]) => computeReadiness(...a),
|
||||
computeCoincidentDeviation: (...a: unknown[]) =>
|
||||
computeCoincidentDeviation(...a),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/insights/derived/baseline", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/insights/derived/baseline")>();
|
||||
return {
|
||||
...actual,
|
||||
readDayMeanSeries: (...a: unknown[]) => readDayMeanSeries(...a),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
probeRollupCoverage: vi.fn(async () => new Map<string, boolean>()),
|
||||
}));
|
||||
|
||||
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<string, boolean>();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof probeRollupCoverage>>,
|
||||
): Promise<ScoreNarrativeBlock> {
|
||||
// `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<ReturnType<typeof probeRollupCoverage>> | null,
|
||||
): Promise<ScoreTensionDto | null> {
|
||||
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<ReturnType<typeof probeRollupCoverage>>,
|
||||
): Promise<ScoreReturnToBandDto | null> {
|
||||
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<typeof c> => 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 };
|
||||
}
|
||||
@@ -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 | number>) => 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<Record<ModuleKey, boolean>>;
|
||||
/**
|
||||
* 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<DashboardSnapshot> {
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<CoachCorrelationDriver> &
|
||||
Pick<CoachCorrelationDriver, "outcome" | "r">,
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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<number | null> {
|
||||
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<CoachReadStripData> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<BandPlacement, "in">;
|
||||
};
|
||||
}
|
||||
|
||||
/** 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<string, number>();
|
||||
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));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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=<MeasurementType>`). 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,
|
||||
};
|
||||
Reference in new issue
Block a user