diff --git a/CHANGELOG.md b/CHANGELOG.md index 73001dbaa..9e85dfc98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## [Unreleased] +## [1.19.1] — 2026-06-21 — Coach flow, sleep and mood polish, navigation, recovery-gap + +A follow-up to 1.19.0 that refines the Coach conversation flow and cost, adds a sleep average card, completes the mood colour pass, tidies navigation, and sharpens the illness recovery-gap. No schema changes; no breaking changes. + +### Added + +- A sleep average-per-night card sits beside sleep debt and chronotype as a third peer, computed over the same scorable-night set so the three cannot disagree. It stays calm until enough nights are scored. +- The recovery-gap names the vital someone tends to recover ahead of — for example "you tend to feel better before your resting heart rate settles" — rather than a single bare line. + +### Changed + +- The Coach opens on a fresh chat by default. It resumes a past conversation only when the Coach has written something unread; an explicit `?c=` deep-link still opens that conversation. This refines the 1.19.0 behaviour, which always reopened the most recent thread. +- The chat view carries an always-visible toolbar with a clear way into past conversations and a new-chat action, and reaching conversations from the dashboard tile now always lands on the conversation list instead of an empty pane. +- The Coach sends the full health snapshot once per conversation (and again only if the conversation grows long enough to scroll the original context out of view); short follow-ups ride the existing transcript. A one-word reply no longer re-pays the whole snapshot, cutting per-message cost on follow-ups by roughly an order of magnitude while keeping the same grounding and cross-metric correlation. +- The streaming reply shows a typing indicator that gives way to the streamed text, replacing the reasoning-status disclosure. +- The settings navigation entry that opens the dashboard layout is labelled "Dashboard" to match where it leads. +- The main sidebar order groups the care surfaces together: medications, preventive care, lab values, illness, then insights, the Coach, and achievements. + +### Fixed + +- The mood time-of-day chart and the mood calendar now use full colour saturation in line with the other mood surfaces; only no-data cells stay muted. +- The cross-episode recovery-gap baseline counts only days that deviated in the illness-adverse direction, so a neutral reading (such as a weight drift) can no longer pad the sample or be named the recovery driver. +- The sleep average-per-night field is part of the documented sleep-rhythm response contract. + ## [1.19.0] — 2026-06-21 — Coach context, a leaner AI pipeline, new device signals, interactive reminders, and wider self-hosting A feature release. The Coach gains the lab values and a direct way in; the daily AI work is restructured to do less and reuse more; several new device signals are captured; reminders become interactive over Telegram; charts and the cycle and sleep surfaces are corrected and polished; and the project ships ready-made templates for one-click self-hosting. Three additive migrations (`0185`–`0187`); no breaking changes. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f424cf858..3fe9cd882 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.19.0 + version: 1.19.1 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -11860,9 +11860,38 @@ components: - workNightsCounted - freeNightsUntilReady additionalProperties: false + averagePerNight: + type: object + properties: + state: + type: string + enum: + - partial + - ready + averageMinutes: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Mean asleep minutes per scorable night (0 while partial). + nightsCounted: + type: integer + minimum: 0 + maximum: 9007199254740991 + nightsUntilReady: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Scorable nights still needed before the average is asserted. + required: + - state + - averageMinutes + - nightsCounted + - nightsUntilReady + additionalProperties: false required: - sleepDebt - chronotype + - averagePerNight additionalProperties: false description: "Server-authoritative sleep-rhythm read: cumulative sleep debt over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work @@ -20279,6 +20308,10 @@ components: anyOf: - type: number - type: "null" + adverseCoverageDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 feltBetterDay: anyOf: - type: string @@ -20293,6 +20326,7 @@ components: - nadir - returns - recoveryGapDays + - adverseCoverageDays - feltBetterDay - redFlags additionalProperties: false @@ -20429,6 +20463,10 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + gapDriverType: + anyOf: + - type: string + - type: "null" required: - windowDays - episodeCount @@ -20437,10 +20475,12 @@ components: - gapSampleSize - byMonth - byType + - gapDriverType additionalProperties: false description: "Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical - (median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, - and a per-type breakdown. Retrospective only." + (median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, a + per-type breakdown, and the dominant driving vital (`gapDriverType`, null when no gap is surfaced). + Retrospective only." DisclaimerAckEnvelope: type: object properties: diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index 7cb9b8c83..ffa64c88e 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -280,8 +280,8 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { page, }, testInfo) => { // The in-panel history tray is gone from the drawer; the button - // navigates to `/coach` where the conversation list - // renders inline on lg+. + // navigates to `/coach?view=conversations`, where the full page opens + // the conversation list on arrival instead of a blank pane. test.skip( testInfo.project.name !== "chromium-desktop", "exercises the desktop drawer layout", @@ -312,8 +312,9 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { await expect(historyButton).toBeVisible({ timeout: 10_000 }); await historyButton.click(); - // No in-panel tray opens — the click navigates to the full view. - await page.waitForURL("**/coach", { timeout: 10_000 }); + // No in-panel tray opens — the click navigates to the full view, which + // carries the `view=conversations` handoff so the list opens on arrival. + await page.waitForURL(/\/coach(\?|$)/, { timeout: 10_000 }); await expect(page.locator('[data-slot="coach-page"]')).toBeVisible({ timeout: 10_000, }); diff --git a/messages/de.json b/messages/de.json index 4f600dbab..c15520593 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2587,6 +2587,12 @@ "clearCaption": "Kein aufgelaufenes Defizit über die letzten {nights} Nächte", "learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für eine verlässliche Bilanz." }, + "average": { + "title": "Durchschnitt pro Nacht", + "value": "{hours} Std. {minutes} Min.", + "caption": "Mittlerer Schlaf über die letzten {nights} Nächte", + "learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für einen verlässlichen Durchschnitt." + }, "chronotype": { "title": "Chronotyp", "learning": "Dein Rhythmus wird noch erfasst — {counted} von {total} freien Nächten. Ein Chronotyp braucht ein paar weckerfreie Morgen, um aussagekräftig zu sein.", @@ -3817,7 +3823,7 @@ "subtitle": "Wähle, welche Erinnerungen du erhältst." }, "layout": { - "title": "Layout", + "title": "Dashboard", "description": "Lege fest, wie Dashboard und Insights aufgebaut und angeordnet sind.", "backToHub": "Zurück zu Layout & Personalisierung", "dashboard": { @@ -6874,6 +6880,7 @@ "countOne": "Du hast in diesem Zeitraum 1 Episode erfasst.", "countOther": "Du hast in diesem Zeitraum {count} Episoden erfasst.", "typicalGap": "Typische Erholungslücke: {days} Tage zwischen Besserung und normalisierten Werten.", + "typicalGapWithDriver": "Typische Erholungslücke: {days} Tage – du fühlst dich meist besser, bevor sich {vital} normalisiert.", "typicalGapLearning": "Noch ein paar abgeschlossene Episoden und hier erscheint eine typische Erholungslücke.", "none": "In diesem Zeitraum keine Episoden erfasst.", "analyze": "Erholungslücke analysieren" diff --git a/messages/en.json b/messages/en.json index 0c7cde8de..4ad161b92 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2587,6 +2587,12 @@ "clearCaption": "No accumulated shortfall over the last {nights} nights", "learning": "Still learning your sleep — {counted} nights so far, {remaining} more to read a reliable balance." }, + "average": { + "title": "Average per night", + "value": "{hours}h {minutes}m", + "caption": "Mean sleep across the last {nights} nights", + "learning": "Still learning your sleep — {counted} nights so far, {remaining} more for a reliable average." + }, "chronotype": { "title": "Chronotype", "learning": "Still learning your rhythm — {counted} of {total} free nights. A chronotype needs a few alarm-free mornings before it means anything.", @@ -3817,7 +3823,7 @@ "subtitle": "Choose which reminders you receive." }, "layout": { - "title": "Layout", + "title": "Dashboard", "description": "Arrange how the dashboard and insights are laid out.", "backToHub": "Back to Layout & Personalization", "dashboard": { @@ -6874,6 +6880,7 @@ "countOne": "You logged 1 episode in this period.", "countOther": "You logged {count} episodes in this period.", "typicalGap": "Typical recovery gap: {days} days between feeling better and your numbers settling.", + "typicalGapWithDriver": "Typical recovery gap: {days} days — you tend to feel better before your {vital} settles.", "typicalGapLearning": "A few more resolved episodes and a typical recovery gap will appear here.", "none": "No episodes logged in this period.", "analyze": "Analyse the recovery gap" diff --git a/messages/es.json b/messages/es.json index 7899eb0a7..66d9d2421 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2587,6 +2587,12 @@ "clearCaption": "Sin déficit acumulado en las últimas {nights} noches", "learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para un balance fiable." }, + "average": { + "title": "Media por noche", + "value": "{hours} h {minutes} min", + "caption": "Sueño medio en las últimas {nights} noches", + "learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para una media fiable." + }, "chronotype": { "title": "Cronotipo", "learning": "Aún estamos aprendiendo tu ritmo — {counted} de {total} noches libres. Un cronotipo necesita varias mañanas sin despertador para tener sentido.", @@ -3817,7 +3823,7 @@ "subtitle": "Elige qué recordatorios recibes." }, "layout": { - "title": "Diseño", + "title": "Panel", "description": "Organiza cómo están dispuestos el panel y los análisis.", "backToHub": "Volver a Diseño y personalización", "dashboard": { @@ -6874,6 +6880,7 @@ "countOne": "Registraste 1 episodio en este período.", "countOther": "Registraste {count} episodios en este período.", "typicalGap": "Brecha de recuperación típica: {days} días entre sentirte mejor y la estabilización de tus valores.", + "typicalGapWithDriver": "Brecha de recuperación típica: {days} días: sueles sentirte mejor antes de que {vital} se estabilice.", "typicalGapLearning": "Con unos pocos episodios resueltos más aparecerá aquí una brecha de recuperación típica.", "none": "Sin episodios registrados en este período.", "analyze": "Analizar la brecha de recuperación" diff --git a/messages/fr.json b/messages/fr.json index e923ab65c..608aef1a6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2587,6 +2587,12 @@ "clearCaption": "Aucun déficit cumulé sur les {nights} dernières nuits", "learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour un bilan fiable." }, + "average": { + "title": "Moyenne par nuit", + "value": "{hours} h {minutes} min", + "caption": "Sommeil moyen sur les {nights} dernières nuits", + "learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour une moyenne fiable." + }, "chronotype": { "title": "Chronotype", "learning": "Votre rythme est encore en cours d'analyse — {counted} sur {total} nuits libres. Un chronotype a besoin de quelques matins sans réveil pour avoir du sens.", @@ -3817,7 +3823,7 @@ "subtitle": "Choisissez les rappels que vous recevez." }, "layout": { - "title": "Disposition", + "title": "Tableau de bord", "description": "Organisez la disposition du tableau de bord et des analyses.", "backToHub": "Retour à Disposition et personnalisation", "dashboard": { @@ -6874,6 +6880,7 @@ "countOne": "Tu as enregistré 1 épisode sur cette période.", "countOther": "Tu as enregistré {count} épisodes sur cette période.", "typicalGap": "Écart de récupération typique : {days} jours entre le mieux-être et la stabilisation de tes valeurs.", + "typicalGapWithDriver": "Écart de récupération typique : {days} jours – tu te sens généralement mieux avant que {vital} ne se stabilise.", "typicalGapLearning": "Encore quelques épisodes résolus et un écart de récupération typique apparaîtra ici.", "none": "Aucun épisode enregistré sur cette période.", "analyze": "Analyser l'écart de récupération" diff --git a/messages/it.json b/messages/it.json index 5a4fece9a..226eaebb0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2587,6 +2587,12 @@ "clearCaption": "Nessun deficit accumulato nelle ultime {nights} notti", "learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per un bilancio affidabile." }, + "average": { + "title": "Media per notte", + "value": "{hours} h {minutes} min", + "caption": "Sonno medio nelle ultime {nights} notti", + "learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per una media affidabile." + }, "chronotype": { "title": "Cronotipo", "learning": "Stiamo ancora imparando il tuo ritmo — {counted} di {total} notti libere. Un cronotipo richiede alcune mattine senza sveglia per avere significato.", @@ -3817,7 +3823,7 @@ "subtitle": "Scegli quali promemoria ricevere." }, "layout": { - "title": "Layout", + "title": "Cruscotto", "description": "Organizza la disposizione del cruscotto e degli approfondimenti.", "backToHub": "Torna a Layout e personalizzazione", "dashboard": { @@ -6874,6 +6880,7 @@ "countOne": "Hai registrato 1 episodio in questo periodo.", "countOther": "Hai registrato {count} episodi in questo periodo.", "typicalGap": "Divario di recupero tipico: {days} giorni tra il sentirti meglio e la stabilizzazione dei valori.", + "typicalGapWithDriver": "Divario di recupero tipico: {days} giorni: di solito ti senti meglio prima che {vital} si stabilizzi.", "typicalGapLearning": "Ancora qualche episodio risolto e qui apparirà un divario di recupero tipico.", "none": "Nessun episodio registrato in questo periodo.", "analyze": "Analizza il divario di recupero" diff --git a/messages/pl.json b/messages/pl.json index e99e307cc..4853b2972 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2587,6 +2587,12 @@ "clearCaption": "Brak skumulowanego niedoboru w ostatnich {nights} nocach", "learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodny bilans." }, + "average": { + "title": "Średnia na noc", + "value": "{hours} godz. {minutes} min", + "caption": "Średni sen w ciągu ostatnich {nights} nocy", + "learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodną średnią." + }, "chronotype": { "title": "Chronotyp", "learning": "Wciąż poznajemy Twój rytm — {counted} z {total} wolnych nocy. Chronotyp wymaga kilku poranków bez budzika, aby coś znaczył.", @@ -3817,7 +3823,7 @@ "subtitle": "Wybierz, które przypomnienia otrzymujesz." }, "layout": { - "title": "Układ", + "title": "Pulpit", "description": "Ustal, jak rozłożone są pulpit i analizy.", "backToHub": "Powrót do Układ i personalizacja", "dashboard": { @@ -6874,6 +6880,7 @@ "countOne": "W tym okresie zapisałeś 1 epizod.", "countOther": "W tym okresie zapisałeś {count} epizodów.", "typicalGap": "Typowa luka rekonwalescencji: {days} dni między poprawą a ustabilizowaniem wartości.", + "typicalGapWithDriver": "Typowa luka rekonwalescencji: {days} dni – zwykle czujesz się lepiej, zanim {vital} się ustabilizuje.", "typicalGapLearning": "Jeszcze kilka zakończonych epizodów i pojawi się tu typowa luka rekonwalescencji.", "none": "Brak epizodów zapisanych w tym okresie.", "analyze": "Przeanalizuj lukę rekonwalescencji" diff --git a/package.json b/package.json index bbe72a444..3c4ab7789 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.19.0", + "version": "1.19.1", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 9b257ceae..0cb85fa5a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.19.0"; + /* @sw-version-fallback */ "v1.19.1"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/illness/insights/route.ts b/src/app/api/illness/insights/route.ts index 1e47c0482..c56fbf9c2 100644 --- a/src/app/api/illness/insights/route.ts +++ b/src/app/api/illness/insights/route.ts @@ -85,7 +85,11 @@ export const GET = apiHandler(async (request: NextRequest) => { // out of the typical-gap median by the aggregate's signal-density gates. const gapById = new Map< string, - { recoveryGapDays: number | null; gapMeasurementDays: number } + { + recoveryGapDays: number | null; + gapMeasurementDays: number; + gapReturnTypes: string[]; + } >(); if (includeRecoveryGap) { const gapCandidates = rows @@ -98,7 +102,11 @@ export const GET = apiHandler(async (request: NextRequest) => { async (): Promise< [ string, - { recoveryGapDays: number | null; gapMeasurementDays: number }, + { + recoveryGapDays: number | null; + gapMeasurementDays: number; + gapReturnTypes: string[]; + }, ] > => { const derived = await computeEpisodeCorrelation( @@ -119,9 +127,28 @@ export const GET = apiHandler(async (request: NextRequest) => { derived.status === "ok" ? derived.value.recoveryGapDays : null, - // `historyDays` = distinct episode days with a banded vital, - // present on both the `ok` and `insufficient` arms. - gapMeasurementDays: derived.coverage.historyDays, + // Qualifying-days floor: distinct episode days carrying an + // ADVERSE-direction banded reading — NOT raw coverage + // (`historyDays` counts ANY banded vital, so a WEIGHT-only + // episode with no illness-adverse signal could otherwise clear + // the floor and tip the typical-gap median). Only the `ok` arm + // ever contributes a gap; the `insufficient` arm's gap is null, + // so 0 here is correct (it never reaches the median anyway). + gapMeasurementDays: + derived.status === "ok" + ? derived.value.adverseCoverageDays + : 0, + // The vitals that produced a real physiological return in the + // illness-ADVERSE direction — tallied across the qualifying + // episodes to name the dominant driving vital in the summary + // copy. A neutral move (e.g. weight drift with no adverse + // signal) shows a return but must never be named the driver. + gapReturnTypes: + derived.status === "ok" + ? derived.value.returns + .filter((r) => r.gapDays !== null && r.adverse) + .map((r) => String(r.type)) + : [], }, ]; }, @@ -140,6 +167,7 @@ export const GET = apiHandler(async (request: NextRequest) => { resolved: r.resolvedAt !== null, recoveryGapDays: gap?.recoveryGapDays ?? null, gapMeasurementDays: gap?.gapMeasurementDays ?? 0, + gapReturnTypes: gap?.gapReturnTypes ?? [], lifecycle: r.lifecycle, }; }); @@ -152,6 +180,7 @@ export const GET = apiHandler(async (request: NextRequest) => { episode_count: summary.episodeCount, gap_sample_size: summary.gapSampleSize, typical_gap_days: summary.typicalRecoveryGapDays, + gap_driver_type: summary.gapDriverType, include_recovery_gap: includeRecoveryGap, }, }); diff --git a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts new file mode 100644 index 000000000..1ce27f76c --- /dev/null +++ b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * v1.19.1 (C4) — token-efficiency contract. The full SNAPSHOT block (the + * expensive grounding prefix) must ride the prompt only ONCE per conversation: + * on the first turn (no prior turns on disk). A follow-up turn that still sits + * inside the verbatim history window must send a short pointer in place of the + * figures, so typing one word no longer re-ships ~15k tokens of snapshot. This + * suite captures the `userPrompt` handed to the provider runner on each turn + * and pins both the first-turn-full and follow-up-cheap shapes. + */ + +const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}'; +const GROUNDING = "REFERENCE RANGES\nBP optimal < 120/80."; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), + HttpError: class HttpError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } + }, +})); + +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); +vi.mock("@/lib/feature-flags", () => ({ + requireAssistantSurface: vi.fn(async () => undefined), +})); +vi.mock("@/lib/api-response", () => ({ + apiError: (error: string, status: number) => ({ data: null, error, status }), + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) }, + coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) }, + }, +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: vi.fn(async () => "en"), +})); + +const { runRawCompletionWithFallback } = vi.hoisted(() => ({ + runRawCompletionWithFallback: vi.fn(), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runRawCompletionWithFallback, +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain: vi.fn(async () => [ + { providerType: "admin-openai", instance: {} }, + ]), + resolveProvider: vi.fn(), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ + assertConsentForChain: vi.fn(async () => undefined), +})); +vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" })); +vi.mock("@/lib/ai/ai-budgets", () => ({ + AI_BUDGETS: { coach: { maxTokens: 1500, temperature: 0.4 } }, +})); + +vi.mock("@/lib/ai/coach/types", async () => { + const actual = await vi.importActual( + "@/lib/ai/coach/types", + ); + return actual; +}); + +const { fetchConversationWithMessages } = vi.hoisted(() => ({ + fetchConversationWithMessages: vi.fn(), +})); +vi.mock("@/lib/ai/coach/persistence", () => ({ + appendMessage: vi.fn(async () => ({ id: "m1" })), + createConversation: vi.fn(async () => ({ id: "c1" })), + fetchConversationWithMessages, + listConversations: vi.fn(), +})); +vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({ + enqueueCoachMemoryRefresh: vi.fn(), +})); +vi.mock("@/lib/ai/coach/facts", () => ({ + storeDeterministicFacts: vi.fn(async () => undefined), +})); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(() => "2026-06-21"), + reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 1500 })), + reconcileSpend: vi.fn(async () => undefined), +})); +vi.mock("@/lib/ai/coach/refusal", () => ({ + detectRefusal: vi.fn(() => ({ refuse: false })), +})); +vi.mock("@/lib/ai/coach/outbound-guard", () => ({ + screenCoachReply: vi.fn(() => ({ block: false })), + coachOutboundFallback: vi.fn(() => "fallback"), +})); +vi.mock("@/lib/ai/coach/system-prompt", () => ({ + getCoachSystemPrompt: vi.fn(() => "SYSTEM"), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(async () => null), +})); +vi.mock("@/lib/ai/coach/snapshot", () => ({ + buildCoachSnapshot: vi.fn(async () => ({ + snapshotJson: SNAPSHOT_JSON, + provenance: { windows: ["last30days"], metrics: ["bp"] }, + referenceGrounding: GROUNDING, + })), +})); +vi.mock("@/lib/ai/coach/keyvalues", () => ({ + parseKeyValuesSentinel: vi.fn(() => ({ + prose: "Your BP looks stable.", + keyValues: [], + malformed: false, + malformedEntries: [], + })), +})); +vi.mock("@/lib/ai/coach/suggest-reminder", () => ({ + parseSuggestReminder: vi.fn(() => ({ prose: "Your BP looks stable." })), +})); +vi.mock("@/lib/ai/coach/suggest-gate", () => ({ gateSuggestion: vi.fn() })); +vi.mock("@/lib/validations/coach-prefs", () => ({ + parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })), + DEFAULT_REMINDER_SUGGESTION_PREFS: {}, +})); +vi.mock("@/lib/sse/create-stream", () => ({ + // Drain the producer synchronously so the handler completes; we only care + // about the provider call args, not the wire frames. + createSseStream: ( + producer: (c: { + signal: { aborted: boolean }; + enqueue: () => void; + }) => void | Promise, + ) => { + void Promise.resolve( + producer({ signal: { aborted: true }, enqueue: () => {} }), + ); + return new ReadableStream(); + }, +})); + +import { POST } from "../route"; + +const post = POST as unknown as (req: Request) => Promise; + +function chatReq(body: Record): Request { + return new Request("http://localhost/api/insights/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function lastUserPrompt(): string { + const calls = runRawCompletionWithFallback.mock.calls as unknown as Array< + [{ params: { userPrompt: string } }] + >; + const last = calls[calls.length - 1]; + return last[0].params.userPrompt; +} + +describe("coach chat — snapshot sent once per conversation (C4)", () => { + beforeEach(() => { + vi.clearAllMocks(); + runRawCompletionWithFallback.mockResolvedValue({ + result: { content: "Your BP looks stable.", tokensUsed: 42, model: "m" }, + workingProvider: { providerType: "admin-openai" }, + }); + }); + + it("ships the full SNAPSHOT + grounding on the first turn", async () => { + await post(chatReq({ message: "How is my BP?" })); + const prompt = lastUserPrompt(); + expect(prompt).toContain(SNAPSHOT_JSON); + expect(prompt).toContain(GROUNDING); + }); + + it("does NOT re-ship the snapshot figures on a follow-up turn", async () => { + fetchConversationWithMessages.mockResolvedValue({ + id: "c1", + summary: null, + messages: [ + { role: "user", content: "How is my BP?" }, + { role: "assistant", content: "Your BP looks stable." }, + ], + }); + + await post(chatReq({ conversationId: "c1", message: "High?" })); + const prompt = lastUserPrompt(); + // The expensive figures + grounding must be gone … + expect(prompt).not.toContain(SNAPSHOT_JSON); + expect(prompt).not.toContain(GROUNDING); + // … replaced by the cheap pointer back to the earlier snapshot, and the + // prior turns still ride the transcript so grounding is preserved. + expect(prompt).toContain("provided earlier in this conversation"); + expect(prompt).toContain("Your BP looks stable."); + }); +}); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index f73812a66..a0589fec3 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -387,6 +387,24 @@ async function handleChatRequest(request: NextRequest): Promise { locale: locale === "en" ? "en" : "de", }); } + // v1.19.1 (C4) — token efficiency. The full SNAPSHOT (now incl. labs, + // illness, cycle, every cluster + the reference-grounding block) is the + // single biggest cost in a Coach turn — typing one word used to re-ship the + // whole ~15k-token block. The grounding only needs to enter the prompt ONCE + // per conversation: the model's first reply is composed against it and that + // reply rides the conversation transcript on every following turn, so the + // figures stay in-context without re-paying for them. We therefore include + // the full block only when: + // - this is the first turn (no prior turns on disk), OR + // - the history window has begun eliding turns (`allTurns.length > TURN_CAP`) + // — once the oldest turns are folded into the rolling summary the original + // snapshot may have scrolled out of the verbatim window, so we re-ground. + // On the cheap path (a follow-up inside the verbatim window) we send a short + // pointer instead of the figures, preserving grounding + cross-metric + // correlation quality at a fraction of the wire cost. + const isFirstTurn = priorTurns.length === 0; + const historyEliding = allTurns.length > TURN_CAP; + const includeFullSnapshot = isFirstTurn || historyEliding; const transcript = window .map((t) => `${t.role.toUpperCase()}: ${t.content}`) .join("\n\n"); @@ -407,12 +425,23 @@ React briefly and personally to the answer; do not repeat the question and do no // inspectable and the model reads the published population bands + the // user's placement. Omitted entirely when no present metric is covered by // the reference backbone (the builder returns null). - const groundingBlock = snapshot.referenceGrounding - ? `\n${snapshot.referenceGrounding}\n` - : ""; - const userPrompt = `SNAPSHOT + const groundingBlock = + includeFullSnapshot && snapshot.referenceGrounding + ? `\n${snapshot.referenceGrounding}\n` + : ""; + // v1.19.1 (C4) — the SNAPSHOT block is the expensive prefix. On the first + // turn (or after the history window starts eliding) we ship the full figures; + // on a cheap follow-up we ship a one-line pointer back to the snapshot the + // model already received earlier in this same conversation, so it keeps + // grounding its numbers in that data without us re-paying for the block. + const snapshotBlock = includeFullSnapshot + ? `SNAPSHOT ${snapshot.snapshotJson || "(no metric data in this user's log yet)"} -${groundingBlock}${guidedBlock} +${groundingBlock}` + : `SNAPSHOT +(The full health snapshot was provided earlier in this conversation — keep grounding your answer in those figures. Do not invent numbers you were not given.) +`; + const userPrompt = `${snapshotBlock}${guidedBlock} CONVERSATION ${transcript} @@ -704,6 +733,11 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; promptVersion: PROMPT_VERSION, conversationId: workingConversationId, historyTurns: window.length, + // v1.19.1 (C4) — whether the full SNAPSHOT block rode this turn (the + // expensive prefix) vs the cheap pointer. Lets the dashboards confirm + // the once-per-conversation grounding cut the tokens-per-message. + snapshotSent: includeFullSnapshot, + promptChars: userPrompt.length, // v1.7.0 — count of provenance metrics the snapshot surfaced // this turn (a proxy for cluster breadth) so the dashboards can // correlate reply shape with cluster activation. diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 0274097d2..3e82f4059 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -2,11 +2,15 @@ import { Suspense, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation"; +import type { CoachNudgeStatus } from "@/components/insights/layout-coach-fab"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useDisableCoach } from "@/hooks/use-disable-coach"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; /** * v1.12.0 (Coach v2 #6) — full-page Coach conversation. @@ -41,32 +45,53 @@ import { useDisableCoach } from "@/hooks/use-disable-coach"; * the active conversation. All of that lives in ``'s * page branch, so the page stays a thin gating + sizing wrapper. * - * v1.18.11 (W11, #67) — the page is now a deep-link target INTO a - * conversation. A `?c=` query param opens that specific thread on - * mount; absent it, the page auto-opens the user's most-recent thread - * (server-authoritative `updatedAt desc` head of the rail, so it lands the - * same thread on web + mobile). Both feed ``'s existing - * selection state — the dashboard Coach entry navigates straight into the - * live conversation instead of a blank landing. `?c=new` (or no thread at - * all) keeps the new-chat hero. The search-param read sits in a Suspense - * child so the client-bailout never de-opts the route. + * v1.19.1 (C1/C5) — the Coach now DEFAULTS to the new-chat hero, reversing + * the v1.19.0 (W7) "always resume most-recent" default the maintainer + * disliked. Resolution order on mount: + * - `?c=` → open that specific thread (explicit deep-link, unchanged). + * - `?c=new` or no param → the new-chat hero. + * - `?view=conversations` → land on the new-chat hero AND open the + * conversation-history drawer, so the drawer's "Conversations" handoff + * (C5) never drops the user on a blank pane. + * - EXCEPTION: when the Coach has proactively written an UNREAD message + * (`/api/insights/coach/nudge-status` → `unread`), auto-open the + * most-recent thread so the user lands on what the Coach said. + * The search-param read sits in a Suspense child so the client-bailout + * never de-opts the route. */ function CoachPageBody() { const searchParams = useSearchParams(); // `?c=new` is an explicit "start a fresh chat" escape hatch; any other // value is treated as a conversation id to open. A blank/absent param - // falls through to most-recent auto-open. + // defaults to the new-chat hero. const rawC = searchParams.get("c"); const deepLinkedId = rawC && rawC !== "new" ? rawC : null; + // C5 — the drawer's "Conversations" handoff routes here with this flag so + // the page opens the history drawer on arrival instead of a blank hero. + const openConversations = searchParams.get("view") === "conversations"; + + // C1 exception — an unread coach-initiated message opens the most-recent + // conversation (which holds that proactive turn). Only consulted when the + // entry did not pin a specific thread or ask for a fresh chat / list. + const exceptionEligible = deepLinkedId === null && rawC !== "new"; + const { data: nudge } = useQuery({ + queryKey: queryKeys.coachNudgeStatus(), + queryFn: async (): Promise => + apiGet("/api/insights/coach/nudge-status"), + enabled: exceptionEligible && !openConversations, + staleTime: 5 * 60 * 1000, + }); + const hasUnreadCoachMessage = exceptionEligible && nudge?.unread === true; return ( ); } diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index 101b49d4d..fda0a981f 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -15,6 +15,7 @@ import { MetricTargetSummary } from "@/components/insights/metric-target-summary import { SleepOverview } from "@/components/insights/sleep-overview"; import { SleepRhythmSection } from "@/components/insights/sleep/sleep-rhythm-section"; import { ChronotypeSection } from "@/components/insights/sleep/chronotype-section"; +import { AverageSleepSection } from "@/components/insights/sleep/average-sleep-section"; import { SleepQualitySection } from "@/components/insights/sleep/sleep-quality-section"; import { SubPageShell } from "@/components/insights/sub-page-shell"; @@ -123,8 +124,9 @@ export default function InsightsSchlafPage() { viewports and sit side-by-side from `sm` up; each card keeps its own internals (the debt headline and the chronotype band treatment). */} -
+
+
diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx index 749e613e5..c1c547447 100644 --- a/src/components/charts/mood-heatmap.tsx +++ b/src/components/charts/mood-heatmap.tsx @@ -271,7 +271,10 @@ export function MoodHeatmap({ height={cellSize} rx={2} fill={cell.color} - fillOpacity={cell.cell ? 0.55 : 1} + // v1.19.1 — populated days render at full saturation so the + // mood band reads clearly; only the no-entry cells stay the + // quiet `--secondary` empty-state tint. + fillOpacity={1} className="cursor-pointer" onPointerEnter={(e) => { if (e.pointerType === "touch") return; @@ -331,7 +334,7 @@ export function MoodHeatmap({
{label} diff --git a/src/components/illness/illness-insights-card.tsx b/src/components/illness/illness-insights-card.tsx index 15ea9f02f..86beede7a 100644 --- a/src/components/illness/illness-insights-card.tsx +++ b/src/components/illness/illness-insights-card.tsx @@ -87,13 +87,22 @@ export function IllnessInsightsCard({

{t("illness.insights.typicalGapLearning")}

+ ) : gap.data.typicalRecoveryGapDays === null ? ( +

+ {t("illness.insights.typicalGapLearning")} +

+ ) : gap.data.gapDriverType ? ( +

+ {t("illness.insights.typicalGapWithDriver", { + days: gap.data.typicalRecoveryGapDays, + vital: t(`illness.vital.${gap.data.gapDriverType}`), + })} +

) : (

- {gap.data.typicalRecoveryGapDays !== null - ? t("illness.insights.typicalGap", { - days: gap.data.typicalRecoveryGapDays, - }) - : t("illness.insights.typicalGapLearning")} + {t("illness.insights.typicalGap", { + days: gap.data.typicalRecoveryGapDays, + })}

)}
diff --git a/src/components/illness/types.ts b/src/components/illness/types.ts index 6526f22e4..6bda26316 100644 --- a/src/components/illness/types.ts +++ b/src/components/illness/types.ts @@ -115,6 +115,7 @@ export interface IllnessCorrelationValue { nadir: IllnessVitalDeviation[]; returns: IllnessVitalReturn[]; recoveryGapDays: number | null; + adverseCoverageDays: number; feltBetterDay: string | null; redFlags: IllnessRedFlag[]; } @@ -148,4 +149,5 @@ export interface IllnessInsightsResponse { gapSampleSize: number; byMonth: Record; byType: Record; + gapDriverType: string | null; } diff --git a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx index fd9894a48..c7bbc9049 100644 --- a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx @@ -135,4 +135,25 @@ describe(" page deep-link (#67)", () => { // though recent conversations exist in the cache. expect(html).toContain('data-slot="coach-hero"'); }); + + // v1.19.1 (C2) — a dedicated, always-visible Conversations button on the + // page surface (not buried in the composer `+` menu). + it("renders the dedicated Conversations button on the page surface", () => { + const html = render(, makeClient()); + expect(html).toContain('data-slot="coach-page-conversations"'); + expect(html).toContain('data-slot="coach-page-new-chat"'); + }); + + // v1.19.1 (C5) — entering via the drawer handoff (`?view=conversations`) + // keeps the new-chat hero (no thread auto-resumed) while opening the + // history drawer; the toolbar + button stay present so the pane is never + // a blank dead-end. + it("keeps the hero and toolbar when opened with openHistoryOnMount", () => { + const html = render( + , + makeClient(), + ); + expect(html).toContain('data-slot="coach-hero"'); + expect(html).toContain('data-slot="coach-page-conversations"'); + }); }); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index b166d6334..add693bda 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -3,7 +3,7 @@ import { useEffect, useReducer, useState } from "react"; import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Plus, Settings, Sparkles } from "lucide-react"; +import { MessagesSquare, Plus, Settings, Sparkles } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -137,6 +137,13 @@ export interface CoachConversationProps { * "new chat" or thread switch is never overridden. */ autoOpenMostRecent?: boolean; + /** + * v1.19.1 (C5) — page surface only: open the conversation-history drawer + * once on mount. The drawer's "Conversations" handoff routes to + * `/coach?view=conversations`, which sets this so the user always sees the + * conversation list on arrival instead of a blank new-chat pane. + */ + openHistoryOnMount?: boolean; } export function CoachConversation({ @@ -152,6 +159,7 @@ export function CoachConversation({ surface, initialConversationId, autoOpenMostRecent = false, + openHistoryOnMount = false, }: CoachConversationProps) { const { t } = useTranslations(); @@ -165,7 +173,12 @@ export function CoachConversation({ // the composer's `+` actions menu. Replaces the old inline collapsible // rail + top rail-tray strip; the top header bar is gone entirely on the // page so the composer is the single control hub. - const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false); + // v1.19.1 (C5) — seed the history drawer open when the page is entered via + // the drawer's "Conversations" handoff (`?view=conversations`), so the + // list is on screen immediately rather than a blank hero. + const [historyDrawerOpen, setHistoryDrawerOpen] = useState( + () => openHistoryOnMount, + ); const [inputValue, setInputValue] = useResettableValue(prefill ?? ""); // v1.16.4 — self-context backflow: `pendingAdopt` raises a quiet // offer to fold a clarifying-question answer back into the @@ -484,6 +497,37 @@ export function CoachConversation({ data-variant={surface} className={cn("flex min-h-0 flex-1 flex-col", className)} > + {/* v1.19.1 (C2) — a clear, always-visible "Conversations" button on + the page surface. The composer's `+` menu still carries the same + action, but the maintainer wanted an obvious, dedicated affordance + to reach past conversations rather than one buried in a menu. */} +
+ + +
{heroActive ? ( ) : ( diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 07885095e..bcbfce7d3 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -88,6 +88,17 @@ export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) { router.push("/coach"); }, [onOpenChange, router]); + // v1.19.1 (C5) — the drawer's "Conversations" affordance hands off to the + // full page WITH the `?view=conversations` flag so the page opens the + // conversation-history drawer on arrival. Previously it shared + // `handleMaximize` and, after the v1.19.1 new-chat default (C1), landed on + // a blank hero with no list — the dead-end the maintainer reported. + const handleOpenConversations = useCallback(() => { + onOpenChange(false); + resetRef.current?.(); + router.push("/coach?view=conversations"); + }, [onOpenChange, router]); + return (
@@ -513,17 +509,6 @@ interface ChatBubbleProps { * settled plain text. */ streaming?: boolean; - /** - * v1.18.9 — send-anchored timestamp (epoch ms) for the streaming turn, - * feeding the thinking disclosure's elapsed timer. Null on persisted - * bubbles. - */ - startedAt?: number | null; - /** - * v1.18.9 — reasoning-summary text from the additive `reasoning` SSE - * frame, shown inside the thinking disclosure when present. - */ - reasoning?: string; /** * v1.18.9 — per-turn token usage for the just-finished streaming bubble * (from `done.usage`). Persisted bubbles read `tokensUsed` / `model` @@ -548,8 +533,6 @@ function ChatBubble({ errorCode, messageId, streaming, - startedAt, - reasoning, usage, tokensUsed, model, @@ -678,28 +661,16 @@ function ChatBubble({ )}
- {/* v1.18.9 — the single thinking indicator (live elapsed-time line, - then an auto-collapsed past-tense disclosure). Renders only for - the live streaming turn — persisted bubbles pass no `startedAt`, - so the disclosure returns null and history stays settled. This - replaces the old dots-plus-word combo: there is - never a three-dots indicator AND a "Thinking" word at once. */} - {streaming && startedAt != null && ( - - )} - {/* The prose bubble. Skipped while the live turn is still thinking - with no prose (the disclosure carries the live state above), so - the user never sees an empty bubble under the indicator. The - `` fallback below only fires when there is no live - disclosure (e.g. a defensive null `startedAt`). */} - {(content || - safeError || - (inProgress && !(streaming && startedAt != null))) && ( + {/* v1.19.1 (C3) — the live turn shows the classic typing animation + (three pulsing dots) while it is still thinking with no prose + yet, restoring the writing/typing indicator the maintainer + prefers over the "Denke nach / Nachgedacht" reasoning disclosure. + The dots render inside the prose bubble below; the disclosure is + retired. Persisted bubbles never stream, so history stays settled. */} + {/* The prose bubble. While the live turn is still thinking with no + prose the bubble carries the typing animation; once tokens land + it swaps to the streamed prose without a layout jump. */} + {(content || safeError || inProgress) && (
`), which showed - * three bouncing dots AND the word "Thinking…" at the same time — the - * exact pairing the maintainer disliked. This is a single, calm control: - * - * - While the turn is thinking (streaming, no prose yet): a live - * "Thinking… N s" line with a small spinner. `role="status"` so a - * screen reader announces "thinking" once, not on every tick. - * - Once the first token lands OR the turn settles: it relabels to the - * past tense ("Thought for N s") and auto-collapses into a quiet - * `
` disclosure. Expanding reveals the reasoning summary when - * a reasoning-capable provider supplied one (the additive `reasoning` - * SSE frame), otherwise a one-line factual fallback — never fabricated - * chain-of-thought. - * - * The elapsed time is computed from the send-anchored `startedAt` - * timestamp: it ticks live while thinking and freezes the instant the - * first token (or the settled state) arrives. - */ -export interface ThinkingDisclosureProps { - /** - * Epoch-ms timestamp the turn started (the moment the send fired). Null - * disables the elapsed timer (the component renders nothing). - */ - startedAt: number | null; - /** True until the turn settles (the `done`/`error` frame closed it). */ - inProgress: boolean; - /** - * Whether any assistant prose has streamed in yet. Drives the live → - * past-tense relabel + auto-collapse: the moment the answer starts, the - * "Thinking…" line freezes and folds away. - */ - hasContent: boolean; - /** - * Optional reasoning-summary text from the additive `reasoning` SSE - * frame, rendered inside the expanded disclosure when present. - */ - reasoning?: string; -} - -export function ThinkingDisclosure({ - startedAt, - inProgress, - hasContent, - reasoning, -}: ThinkingDisclosureProps) { - const { t } = useTranslations(); - const panelId = useId(); - const [open, setOpen] = useState(false); - - // The turn is "thinking" only while it is in progress with no prose yet. - const thinking = inProgress && hasContent === false && startedAt !== null; - - // `elapsed` ticks once a second while thinking; the interval stops the - // moment `thinking` flips false, so the last value it set is the frozen - // "thought for N s" figure — no render-time ref, no sync setState in the - // effect body (the first tick lands on the interval, not inline). - const [elapsed, setElapsed] = useState(0); - useEffect(() => { - if (!thinking || startedAt === null) return; - const tick = () => - setElapsed(Math.max(0, Math.round((Date.now() - startedAt) / 1000))); - tick(); - const handle = window.setInterval(tick, 1000); - return () => window.clearInterval(handle); - }, [thinking, startedAt]); - - if (startedAt === null) return null; - - // ── Live "thinking" line (single indicator, no dots-plus-word) ── - if (thinking) { - return ( -
-
- ); - } - - // ── Settled: past-tense collapsible disclosure (auto-collapsed) ── - const reasoningText = reasoning?.trim() ?? ""; - return ( -
setOpen((e.target as HTMLDetailsElement).open)} - className="group inline-block" - > - - -

- {reasoningText || t("insights.coach.thinkingDetail")} -

-
- ); -} diff --git a/src/components/insights/mood/mood-time-of-day-chart.tsx b/src/components/insights/mood/mood-time-of-day-chart.tsx index e1e8f61bc..5a5aa102b 100644 --- a/src/components/insights/mood/mood-time-of-day-chart.tsx +++ b/src/components/insights/mood/mood-time-of-day-chart.tsx @@ -84,10 +84,9 @@ export function MoodTimeOfDayChart({ data={data} margin={{ top: 8, right: 12, bottom: 8, left: 0 }} > - {/* Muted chart language — same treatment as the weekday/distribution - siblings: `--muted-foreground` axis ticks, a quiet comment-grey - target guide, and 0.55 bar fill so the level hues inform without - shouting. */} + {/* Quiet chart language — `--muted-foreground` axis ticks and a + comment-grey target guide. The bars themselves render at full + saturation (v1.19.1) so the mood level hues read clearly. */} - + {/* v1.19.1 — populated buckets render at full saturation so the + level hues read clearly; only the no-data buckets keep the quiet + `--secondary` empty-state tint. */} + {data.map((row) => ( ))} diff --git a/src/components/insights/sleep/__tests__/chronotype-section.test.tsx b/src/components/insights/sleep/__tests__/chronotype-section.test.tsx index 8d26be570..6216b89bb 100644 --- a/src/components/insights/sleep/__tests__/chronotype-section.test.tsx +++ b/src/components/insights/sleep/__tests__/chronotype-section.test.tsx @@ -50,6 +50,12 @@ const READY_DTO: SleepRhythmDto = { workNightsCounted: 14, freeNightsUntilReady: 0, }, + averagePerNight: { + state: "ready", + averageMinutes: 420, + nightsCounted: 10, + nightsUntilReady: 0, + }, }; beforeEach(() => { diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx index d4af80009..5ed46274a 100644 --- a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx +++ b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx @@ -58,6 +58,12 @@ const READY_DTO: SleepRhythmDto = { workNightsCounted: 14, freeNightsUntilReady: 0, }, + averagePerNight: { + state: "ready", + averageMinutes: 420, + nightsCounted: 5, + nightsUntilReady: 0, + }, }; beforeEach(() => { diff --git a/src/components/insights/sleep/average-sleep-card.tsx b/src/components/insights/sleep/average-sleep-card.tsx new file mode 100644 index 000000000..81cb2b9c1 --- /dev/null +++ b/src/components/insights/sleep/average-sleep-card.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { BedDouble } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { LearningGate } from "@/components/ui/learning-gate"; +import type { AverageSleepDto } from "./use-sleep-rhythm"; + +/** Whole hours + minutes from a minute total, for the headline figure. */ +function hoursMinutes(totalMinutes: number): { + hours: number; + minutes: number; +} { + const hours = Math.floor(totalMinutes / 60); + const minutes = Math.round(totalMinutes - hours * 60); + return { hours, minutes }; +} + +/** + * v1.19.1 — average-sleep-per-night headline. + * + * The third peer of `` + ``: identical card + * chrome (icon + `text-base` `CardTitle` header, a `space-y-0.5` block with a + * bold `text-2xl` primary value and one `text-xs` muted secondary line) so the + * three tiles read uniformly in the shared grid row. The figure is the mean of + * the canonical per-night asleep totals over the same scorable-night window the + * debt + chronotype read — server-authoritative, never recomputed here. Under + * the night floor it shows the calm `partial` state and asserts no average. + */ +export function AverageSleepCard({ average }: { average: AverageSleepDto }) { + const { t } = useTranslations(); + + if (average.state === "partial") { + return ( + + +
+ + + {t("insights.sleep.average.title")} + +
+
+ + + +
+ ); + } + + const { hours, minutes } = hoursMinutes(average.averageMinutes); + + return ( + + +
+ + + {t("insights.sleep.average.title")} + +
+
+ +
+

+ {t("insights.sleep.average.value", { hours, minutes })} +

+

+ {t("insights.sleep.average.caption", { + nights: average.nightsCounted, + })} +

+
+
+
+ ); +} diff --git a/src/components/insights/sleep/average-sleep-section.tsx b/src/components/insights/sleep/average-sleep-section.tsx new file mode 100644 index 000000000..8e848fd18 --- /dev/null +++ b/src/components/insights/sleep/average-sleep-section.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useAuth } from "@/hooks/use-auth"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useTranslations } from "@/lib/i18n/context"; +import { useSleepRhythm } from "./use-sleep-rhythm"; +import { AverageSleepCard } from "./average-sleep-card"; + +/** + * v1.19.1 — average-sleep-per-night card on the Sleep view. + * + * The third peer of `` (sleep debt) + `` + * in the shared grid row. Reads the SAME server-authoritative DTO via + * `useSleepRhythm`, so it shares the `["sleep-rhythm"]` cache the siblings + * already warmed — no extra round-trip. Gated on `enabled` so a source-less + * account never fetches. + * + * States mirror the sibling sections: loading → skeleton, error → quiet inline + * notice, settled → the card (which carries its own calm "still learning" copy + * for the no-nights-yet case, since the route always returns a full DTO). + */ +export function AverageSleepSection({ enabled }: { enabled: boolean }) { + const { isAuthenticated } = useAuth(); + const { t } = useTranslations(); + const { data, isLoading, isError } = useSleepRhythm( + isAuthenticated && enabled, + ); + + if (!enabled) return null; + + if (isError) { + return ( +
+ {t("insights.sleep.rhythm.loadError")} +
+ ); + } + + if (isLoading || !data) { + return ( + + ); + } + + return ; +} diff --git a/src/components/insights/sleep/use-sleep-rhythm.ts b/src/components/insights/sleep/use-sleep-rhythm.ts index 64a66213d..2b4de708e 100644 --- a/src/components/insights/sleep/use-sleep-rhythm.ts +++ b/src/components/insights/sleep/use-sleep-rhythm.ts @@ -11,10 +11,11 @@ import { apiGet } from "@/lib/api/api-fetch"; import type { SleepDebtDto, ChronotypeDto, + AverageSleepDto, SleepRhythmDto, } from "@/lib/insights/derived/sleep-rhythm"; -export type { SleepDebtDto, ChronotypeDto, SleepRhythmDto }; +export type { SleepDebtDto, ChronotypeDto, AverageSleepDto, SleepRhythmDto }; /** * Read the server-authoritative sleep-rhythm DTO. Gated on `enabled` so a diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts index 0ee21814b..89c8c6c7b 100644 --- a/src/components/layout/__tests__/nav-model.test.ts +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -50,9 +50,13 @@ describe("nav-model destination list", () => { expect(hrefs).toContain("/vorsorge"); const vorsorge = NAV_DESTINATIONS.find((d) => d.href === "/vorsorge"); expect(vorsorge?.tKey).toBe("nav.vorsorge"); - // Peer to Labs: sits in the clinical spine, before Insights. - expect(hrefs.indexOf("/labs")).toBeLessThan(hrefs.indexOf("/vorsorge")); - expect(hrefs.indexOf("/vorsorge")).toBeLessThan(hrefs.indexOf("/insights")); + // v1.19.1 (S4) — the fixed spine is Medications → Vorsorge → Labs → + // Illness → Insights, so Vorsorge sits after Medications and before Labs. + expect(hrefs.indexOf("/medications")).toBeLessThan( + hrefs.indexOf("/vorsorge"), + ); + expect(hrefs.indexOf("/vorsorge")).toBeLessThan(hrefs.indexOf("/labs")); + expect(hrefs.indexOf("/labs")).toBeLessThan(hrefs.indexOf("/insights")); }); it("does not list Recovery as a left-nav destination (it is an Insights pill)", () => { @@ -99,15 +103,17 @@ describe("nav-model utility tail (N-1 — one shared list)", () => { }); describe("visibleNavDestinations module gate", () => { - it("includes Cycle (between Medications and Insights) only when its module is enabled", () => { + it("includes Cycle (between Mood and Medications) only when its module is enabled", () => { // v1.18.0 — cycle is `requiresModule: "cycle"` and reads the delegated // `cycle` key from the same resolved module map every other gate uses. + // v1.19.1 (S4) — cycle sits in the head block, after Mood and before the + // fixed Medications → … → Achievements spine. const off = visibleNavDestinations({ cycle: false }).map((d) => d.href); const on = visibleNavDestinations({ cycle: true }).map((d) => d.href); expect(off).not.toContain("/cycle"); expect(on).toContain("/cycle"); - expect(on.indexOf("/medications")).toBeLessThan(on.indexOf("/cycle")); - expect(on.indexOf("/cycle")).toBeLessThan(on.indexOf("/insights")); + expect(on.indexOf("/mood")).toBeLessThan(on.indexOf("/cycle")); + expect(on.indexOf("/cycle")).toBeLessThan(on.indexOf("/medications")); }); it("drops a module-gated entry (mood / labs / coach / achievements / insights / medications) when its module is disabled", () => { diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 14e46af07..049694844 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -109,17 +109,18 @@ describe(" cycle entry gate (v1.15.0 / v1.18.0 module map)", () => { expect(html).not.toContain('href="/cycle"'); }); - it("places the Cycle entry between Medications and Insights when enabled", () => { + it("places the Cycle entry between Mood and Medications when enabled", () => { const html = render({ modules: { cycle: true } }); - const med = html.indexOf('href="/medications"'); + const mood = html.indexOf('href="/mood"'); const cycle = html.indexOf('href="/cycle"'); - const insights = html.indexOf('href="/insights"'); - expect(med).toBeGreaterThan(-1); + const med = html.indexOf('href="/medications"'); + expect(mood).toBeGreaterThan(-1); expect(cycle).toBeGreaterThan(-1); - expect(insights).toBeGreaterThan(-1); - // Cycle sits after Medications and before Insights in document order. - expect(med).toBeLessThan(cycle); - expect(cycle).toBeLessThan(insights); + expect(med).toBeGreaterThan(-1); + // v1.19.1 (S4) — Cycle sits after Mood and before Medications in document + // order; Medications opens the fixed clinical/insight spine below it. + expect(mood).toBeLessThan(cycle); + expect(cycle).toBeLessThan(med); }); }); diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index c8f083d31..c0c94a18d 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -63,6 +63,10 @@ export interface NavDestination { * (after Medications) and is filtered out when the account gate is off; * the order is otherwise identical for both surfaces. */ +// v1.19.1 (S4) — the clinical/insight spine below the dashboard/measurements/ +// mood/cycle head follows one fixed sequence, each entry directly below the +// previous: Medications → Vorsorge → Labs → Illness → Insights → Coach → +// Achievements. A disabled module simply drops out; the rest keep this order. export const NAV_DESTINATIONS: ReadonlyArray = [ { href: "/", tKey: "nav.dashboard", icon: Home, tourId: "nav-dashboard" }, { @@ -78,6 +82,13 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ tourId: "nav-mood", requiresModule: "mood", }, + { + href: "/cycle", + tKey: "nav.cycle", + icon: Droplets, + tourId: "nav-cycle", + requiresModule: "cycle", + }, { href: "/medications", tKey: "nav.medications", @@ -87,12 +98,23 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ // module; the nav entry now drops when the account turns the module off. requiresModule: "medications", }, + // v1.17.1 — Vorsorge (preventive-care) gets a top-level nav home in the + // clinical spine. It is a first-class tracking surface ("wann muss ich was + // wo machen"), not pure configuration, so it belongs in the model both bars + // render — not buried three taps deep under Settings → Reminders. The + // Reminders hub still links to it; this is the direct front door. { - href: "/cycle", - tKey: "nav.cycle", - icon: Droplets, - tourId: "nav-cycle", - requiresModule: "cycle", + href: "/vorsorge", + tKey: "nav.vorsorge", + icon: Stethoscope, + tourId: "nav-vorsorge", + // v1.18.1 — deliberately NOT module-gated (no `requiresModule`). Unlike + // labs / illness / cycle (opt-in clinical-spine verticals born off by + // default), preventive-care reminders are a CORE surface available to + // every account from birth: a reminder can target core vitals + // (weight / BP / pulse) that are never behind a module toggle, and a + // free-text "Großes Blutbild" reminder belongs to no module at all. + // Gating the entry would orphan reminders the user can still create. }, { href: "/labs", @@ -112,25 +134,6 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ tourId: "nav-illness", requiresModule: "illness", }, - // v1.17.1 — Vorsorge (preventive-care) gets a top-level nav home in the - // clinical spine, peer to Labs and Recovery. It is a first-class tracking - // surface ("wann muss ich was wo machen"), not pure configuration, so it - // belongs in the model both bars render — not buried three taps deep under - // Settings → Reminders. The Reminders hub still links to it; this is the - // direct front door so the customer knows where it lives. - { - href: "/vorsorge", - tKey: "nav.vorsorge", - icon: Stethoscope, - tourId: "nav-vorsorge", - // v1.18.1 — deliberately NOT module-gated (no `requiresModule`). Unlike - // labs / illness / cycle (opt-in clinical-spine verticals born off by - // default), preventive-care reminders are a CORE surface available to - // every account from birth: a reminder can target core vitals - // (weight / BP / pulse) that are never behind a module toggle, and a - // free-text "Großes Blutbild" reminder belongs to no module at all. - // Gating the entry would orphan reminders the user can still create. - }, // v1.18.0 — Workouts and Recovery both left the left-nav: each already // surfaces as an Insights tab-strip pill (`/insights/workouts` gated on // a workout row, `/insights/recovery` always present), so neither is a diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index da80f4bed..ebdc1945e 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -284,9 +284,11 @@ describe("", () => { // `/notifications` inbox now shares the "Notifications" label. expect(html).toContain("Notifications"); // v1.17.1 (F-2) — the dashboard + insights arrangement editors are - // reached through one Layout hub entry. v1.18.1 (D6) — renamed to "Layout". + // reached through one hub entry (route stays `/settings/layout`). + // v1.19.1 (S3) — the nav label now reads "Dashboard" to match where + // it leads; the route is unchanged. expect(html).toContain('href="/settings/layout"'); - expect(html).toContain(">Layout"); + expect(html).toContain(">Dashboard"); // v1.18.1 (D4) — Channels + Sources are their own left-side entries. expect(html).toContain('href="/settings/channels"'); expect(html).toContain('href="/settings/sources"'); @@ -322,10 +324,11 @@ describe("", () => { // compound "Benachrichtigungs-Kanäle" wrapped). The `/notifications` // inbox now shares the "Benachrichtigungen" label. expect(html).toContain("Benachrichtigungen"); - // v1.17.1 (F-2) — the arrangement editors are reached through one - // Layout hub entry. v1.18.1 (D6) — renamed to "Layout" in every locale. + // v1.17.1 (F-2) — the arrangement editors are reached through one hub + // entry (route stays `/settings/layout`). v1.19.1 (S3) — the nav label + // reads "Dashboard" in every locale to match where it leads. expect(html).toContain('href="/settings/layout"'); - expect(html).toContain(">Layout"); + expect(html).toContain(">Dashboard"); // v1.18.1 (D4) — Channels ("Kanäle") + Sources ("Quellen-Priorität") // are their own left-side entries. expect(html).toContain('href="/settings/channels"'); diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index f9941c81d..27bc955b3 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -16,7 +16,9 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { computeIllnessCorrelation, dayDiff, + isAdverseDeviation, MIN_BASELINE_DAYS, + MIN_EPISODE_COVERAGE_DAYS, type IllnessCorrelationInput, type VitalDayPoint, } from "../correlation"; @@ -224,6 +226,54 @@ describe("computeIllnessCorrelation — golden recovery-gap", () => { expect(out.value.recoveryGapDays).toBeNull(); expect(out.value.returns[0]?.returnedDay).toBeNull(); }); + + it("flags a return adverse only when the vital deviated in the adverse direction", () => { + // A neutral-direction vital (WEIGHT) can deviate out of band and stably + // return — it produces a return finding, but `adverse` must stay false so + // it never gets named the recovery driver. An up-adverse vital (RHR) that + // spikes and returns must read adverse: true. + const weight: MeasurementType = "WEIGHT"; + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { + type: weight, + baselineDays: jitteredBaseline(80, 1, 21, "2026-01-02"), + episodeDays: [ + { day: "2026-01-10", mean: 90 }, // far out of band, neutral + { day: "2026-01-11", mean: 91 }, + { day: "2026-01-15", mean: 80 }, // back in band + { day: "2026-01-16", mean: 80 }, + { day: "2026-01-17", mean: 80 }, + ], + }, + { + type: rhr, + baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"), + episodeDays: [ + { day: "2026-01-10", mean: 75 }, // up = adverse + { day: "2026-01-11", mean: 74 }, + { day: "2026-01-15", mean: 55 }, // back in band + { day: "2026-01-16", mean: 55 }, + { day: "2026-01-17", mean: 55 }, + ], + }, + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const weightReturn = out.value.returns.find((r) => r.type === weight); + const rhrReturn = out.value.returns.find((r) => r.type === rhr); + expect(weightReturn?.adverse).toBe(false); + expect(rhrReturn?.adverse).toBe(true); + }); }); describe("computeIllnessCorrelation — baseline-contamination guard", () => { @@ -431,6 +481,102 @@ describe("computeIllnessCorrelation — red-flag escalation", () => { }); }); +describe("isAdverseDeviation", () => { + it("pins per-metric adverse direction (up for RHR, down for HRV)", () => { + expect(isAdverseDeviation("RESTING_HEART_RATE", "above")).toBe(true); + expect(isAdverseDeviation("RESTING_HEART_RATE", "below")).toBe(false); + expect(isAdverseDeviation("HEART_RATE_VARIABILITY", "below")).toBe(true); + expect(isAdverseDeviation("HEART_RATE_VARIABILITY", "above")).toBe(false); + }); + + it("treats neutral-direction vitals (WEIGHT) as never adverse", () => { + expect(isAdverseDeviation("WEIGHT", "above")).toBe(false); + expect(isAdverseDeviation("WEIGHT", "below")).toBe(false); + }); +}); + +describe("computeIllnessCorrelation — adverse-coverage qualifying floor", () => { + it("counts only days with a notable ADVERSE-direction reading", () => { + // RHR (up = adverse) spikes out-of-band on 3 distinct active days, then + // settles. Those 3 days — and only those — count toward the floor. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 75 }, // adverse spike + { day: "2026-01-11", mean: 74 }, // adverse spike + { day: "2026-01-12", mean: 73 }, // adverse spike + { day: "2026-01-15", mean: 55 }, // in band + { day: "2026-01-16", mean: 55 }, + { day: "2026-01-17", mean: 55 }, + ]; + const out = computeIllnessCorrelation( + input({ series: [{ type, baselineDays, episodeDays }] }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.adverseCoverageDays).toBe(3); + }); + + it("does NOT count a WEIGHT-only (no adverse direction) episode toward the floor", () => { + // WEIGHT has no illness-adverse direction. Even with plenty of out-of-band + // WEIGHT days (so raw coverage `historyDays` clears the engine floor), the + // adverse-coverage floor stays 0 — a WEIGHT-only episode must not feed the + // cross-episode typical-gap median. + const type: MeasurementType = "WEIGHT"; + const baselineDays = jitteredBaseline(80, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 90 }, // far out of band, but neutral direction + { day: "2026-01-11", mean: 91 }, + { day: "2026-01-12", mean: 92 }, + { day: "2026-01-13", mean: 70 }, // out of band the other way — still neutral + { day: "2026-01-14", mean: 69 }, + ]; + const out = computeIllnessCorrelation( + input({ series: [{ type, baselineDays, episodeDays }] }), + ); + // Coverage clears the engine floor (5 banded days) so the engine returns ok… + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.coverage.historyDays).toBeGreaterThanOrEqual( + MIN_EPISODE_COVERAGE_DAYS, + ); + // …but NO day counts toward the adverse-coverage qualifying floor. + expect(out.value.adverseCoverageDays).toBe(0); + }); + + it("ignores a non-adverse-direction move on an adverse-typed vital", () => { + // RHR is up-adverse. A big DROP (below band) is notable but NOT adverse, so + // it must not count toward the floor. Pair with a banded neutral vital to + // clear the engine coverage floor without adding adverse days. + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const weight: MeasurementType = "WEIGHT"; + const out = computeIllnessCorrelation( + input({ + series: [ + { + type: rhr, + baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"), + episodeDays: [ + { day: "2026-01-10", mean: 40 }, // far BELOW band — not adverse for RHR + { day: "2026-01-11", mean: 41 }, + { day: "2026-01-12", mean: 55 }, + { day: "2026-01-13", mean: 55 }, + ], + }, + { + type: weight, + baselineDays: jitteredBaseline(80, 1, 21, "2026-01-02"), + episodeDays: flatBaseline(80, 5, "2026-01-15"), + }, + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.adverseCoverageDays).toBe(0); + }); +}); + describe("computeIllnessCorrelation — return anchor", () => { it("never reports a return BEFORE the first deviation (no spurious gap)", () => { // The vital is IN BAND for the first 4 active days (the run-up), then diff --git a/src/lib/illness/__tests__/retrospective.test.ts b/src/lib/illness/__tests__/retrospective.test.ts index 878072f52..f9dc38125 100644 --- a/src/lib/illness/__tests__/retrospective.test.ts +++ b/src/lib/illness/__tests__/retrospective.test.ts @@ -131,6 +131,74 @@ describe("summarizeIllnessRetrospective", () => { expect(out.typicalRecoveryGapDays).toBeNull(); // …but the magnitude is noise }); + it("names the dominant driving vital when a gap is surfaced", () => { + const out = summarizeIllnessRetrospective([ + ep({ + id: "a", + recoveryGapDays: 4, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ep({ + id: "b", + recoveryGapDays: 6, + gapReturnTypes: ["RESTING_HEART_RATE", "HEART_RATE_VARIABILITY"], + }), + ep({ + id: "c", + recoveryGapDays: 5, + gapReturnTypes: ["HEART_RATE_VARIABILITY"], + }), + ]); + // RHR returned in 2 of 3 episodes, HRV in 2 as well — tie → deterministic + // alphabetical resolution picks HEART_RATE_VARIABILITY. + expect(out.typicalRecoveryGapDays).toBe(5); + expect(out.gapDriverType).toBe("HEART_RATE_VARIABILITY"); + }); + + it("picks the strictly-most-frequent driver", () => { + const out = summarizeIllnessRetrospective([ + ep({ + id: "a", + recoveryGapDays: 4, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ep({ + id: "b", + recoveryGapDays: 6, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ep({ + id: "c", + recoveryGapDays: 5, + gapReturnTypes: ["HEART_RATE_VARIABILITY"], + }), + ]); + expect(out.gapDriverType).toBe("RESTING_HEART_RATE"); + }); + + it("leaves the driver null when no gap is surfaced", () => { + const out = summarizeIllnessRetrospective([ + ep({ + id: "a", + recoveryGapDays: 0, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ep({ + id: "b", + recoveryGapDays: 1, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ep({ + id: "c", + recoveryGapDays: -1, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ]); + // Magnitude gate withholds the gap → no driver is named. + expect(out.typicalRecoveryGapDays).toBeNull(); + expect(out.gapDriverType).toBeNull(); + }); + it("surfaces a negative gap once its magnitude clears the floor", () => { const out = summarizeIllnessRetrospective([ ep({ id: "a", recoveryGapDays: -2 }), diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index 22f519670..e220b0f14 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -206,6 +206,28 @@ export interface VitalReturnFinding { returnedDay: string | null; /** Days from felt-better to physiological return (signed); null if N/A. */ gapDays: number | null; + /** Whether this vital deviated in the illness-adverse direction during the + * active span — a neutral move (e.g. weight drift with no adverse signal) + * still produces a return but must not name the recovery driver. */ + adverse: boolean; +} + +/** + * Whether a banded reading is the illness-adverse move for its metric — a + * RISE for an "up"-adverse vital (resting HR, temperature, BP, …), a DROP for + * a "down"-adverse one (HRV, recovery, SpO2). The single adverse-direction + * predicate the engine reasons with; reused by the qualifying-days floor so a + * neutral-direction vital (WEIGHT, glucose) never counts a day toward it. + */ +export function isAdverseDeviation( + type: MeasurementType, + direction: "above" | "below", +): boolean { + const adverseDir = ADVERSE_DIRECTION[type]; + return ( + (adverseDir === "up" && direction === "above") || + (adverseDir === "down" && direction === "below") + ); } export interface IllnessCorrelationValue { @@ -222,6 +244,15 @@ export interface IllnessCorrelationValue { * = the body lagged the felt-better marker. */ recoveryGapDays: number | null; + /** + * Distinct episode days carrying at least one ADVERSE-direction banded + * reading (a vital moving the illness-adverse way for its metric). This is + * the qualifying-days floor the cross-episode aggregate gates the typical- + * gap median on — a WEIGHT-only (no adverse direction) episode contributes + * 0 here even when `coverage.historyDays` (any banded reading) is high, so + * an episode with no illness-relevant signal cannot tip the typical gap. + */ + adverseCoverageDays: number; /** Felt-better marker echoed for the surface. */ feltBetterDay: string | null; /** @@ -279,10 +310,7 @@ function finding( ): VitalDeviationFinding { const sd = deviationSd(value, center, spread); const direction: "above" | "below" = sd >= 0 ? "above" : "below"; - const adverseDir = ADVERSE_DIRECTION[type]; - const adverse = - (adverseDir === "up" && direction === "above") || - (adverseDir === "down" && direction === "below"); + const adverse = isAdverseDeviation(type, direction); return { type, day, @@ -364,6 +392,11 @@ export function computeIllnessCorrelation( const preOnset: VitalDeviationFinding[] = []; const nadir: VitalDeviationFinding[] = []; const returns: VitalReturnFinding[] = []; + // Distinct active-span days with at least one notable ADVERSE-direction + // banded reading. This — not raw `coverageDays` (any banded reading) — is + // the qualifying-days floor the cross-episode aggregate gates on, so a + // neutral-direction-only episode (e.g. WEIGHT) never feeds the typical gap. + const adverseDayKeys = new Set(); for (const { series, band } of banded) { const { type } = series; @@ -392,10 +425,17 @@ export function computeIllnessCorrelation( // anchor the recovery search must start AFTER (an early in-band run before // the vital ever deviated is not a "return", it is the run-up). let firstDeviationIndex = -1; + // Whether THIS vital ever deviated adversely in the active span — gates it + // out of the recovery-driver tally even if it shows a return. + let vitalDeviatedAdversely = false; active.forEach((p, i) => { const f = finding(type, p.day, p.mean, center, spread); if (Math.abs(f.deviationSd) >= NOTABLE_SD) { if (firstDeviationIndex === -1) firstDeviationIndex = i; + if (f.adverse) { + adverseDayKeys.add(p.day); + vitalDeviatedAdversely = true; + } if ( worstActive === null || Math.abs(f.deviationSd) > Math.abs(worstActive.deviationSd) @@ -422,7 +462,12 @@ export function computeIllnessCorrelation( returnedDay && window.feltBetterDay ? dayDiff(window.feltBetterDay, returnedDay) : null; - returns.push({ type, returnedDay, gapDays }); + returns.push({ + type, + returnedDay, + gapDays, + adverse: vitalDeviatedAdversely, + }); } } @@ -463,6 +508,7 @@ export function computeIllnessCorrelation( ), returns, recoveryGapDays, + adverseCoverageDays: adverseDayKeys.size, feltBetterDay: window.feltBetterDay, redFlags, }, diff --git a/src/lib/illness/retrospective.ts b/src/lib/illness/retrospective.ts index ff93e92b0..8e01c45c4 100644 --- a/src/lib/illness/retrospective.ts +++ b/src/lib/illness/retrospective.ts @@ -62,6 +62,14 @@ export interface RetrospectiveEpisode { * the engine computed one. Absent/0 → does not qualify. */ gapMeasurementDays?: number; + /** + * The vital metric types that produced a non-null physiological-return gap + * for this episode (the engine's `returns[]` filtered to a real return). + * Tallied across the qualifying episodes to name the dominant driving vital + * in the cross-episode summary ("felt better ~X days before resting heart + * rate settled"). Absent/empty → contributes no driver. + */ + gapReturnTypes?: string[]; /** Lifecycle — CHRONIC_ONGOING never contributes a gap. */ lifecycle: string; } @@ -87,6 +95,13 @@ export interface IllnessRetrospectiveSummary { byMonth: Record; /** Per-type episode counts in the window (retrospective breakdown). */ byType: Record; + /** + * The dominant vital that drove the typical recovery gap — the metric type + * that most often produced a physiological return across the qualifying + * episodes — or null when no gap is surfaced or no vital clearly leads. The + * card names it ("…before resting heart rate settled"). Retrospective only. + */ + gapDriverType: string | null; } /** @@ -109,6 +124,10 @@ export function summarizeIllnessRetrospective( const byType: Record = {}; let resolvedCount = 0; const gaps: number[] = []; + // Tally of which vital produced a physiological return, across only the + // episodes that qualified for the typical-gap median — the dominant one + // names the driving vital in the card. + const driverTally: Record = {}; for (const e of episodes) { const month = Number(e.onsetDay.slice(5, 7)); @@ -124,6 +143,8 @@ export function summarizeIllnessRetrospective( (e.gapMeasurementDays ?? 0) >= MIN_GAP_MEASUREMENT_DAYS ) { gaps.push(e.recoveryGapDays); + for (const type of e.gapReturnTypes ?? []) + driverTally[type] = (driverTally[type] ?? 0) + 1; } } @@ -139,6 +160,16 @@ export function summarizeIllnessRetrospective( ? medianGap : null; + // Name the driving vital only when a gap is actually surfaced — the most + // frequently-returning vital across the qualifying episodes. Ties resolve + // deterministically by metric name so the copy is stable across reads. + const gapDriverType = + typicalRecoveryGapDays !== null + ? (Object.entries(driverTally).sort((a, b) => + b[1] !== a[1] ? b[1] - a[1] : a[0] < b[0] ? -1 : 1, + )[0]?.[0] ?? null) + : null; + return { episodeCount: episodes.length, resolvedCount, @@ -146,5 +177,6 @@ export function summarizeIllnessRetrospective( gapSampleSize: gaps.length, byMonth, byType, + gapDriverType, }; } diff --git a/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts b/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts index 13f1cc29b..c518ee56a 100644 --- a/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts @@ -23,6 +23,7 @@ import * as chronotypeMod from "../chronotype"; import { buildSleepRhythm, + computeAverageSleep, computeSleepRhythmFromNights, defaultDayType, type RhythmNight, @@ -146,3 +147,35 @@ describe("computeSleepRhythmFromNights window cap", () => { expect(oneYear.chronotype).toEqual(sixWeeks.chronotype); }); }); + +describe("computeAverageSleep", () => { + function nights(...minutes: number[]): RhythmNight[] { + return minutes.map((m, i) => ({ + night: `2026-06-${String(10 + i).padStart(2, "0")}`, + asleepMinutes: m, + midpoint: 4 * 60, + })); + } + + it("stays partial under the four-night floor and asserts no average", () => { + const avg = computeAverageSleep(nights(420, 420, 420)); + expect(avg.state).toBe("partial"); + expect(avg.averageMinutes).toBe(0); + expect(avg.nightsCounted).toBe(3); + expect(avg.nightsUntilReady).toBe(1); + }); + + it("returns the rounded mean over the scorable nights once ready", () => { + const avg = computeAverageSleep(nights(400, 410, 420, 430, 450)); + expect(avg.state).toBe("ready"); + expect(avg.averageMinutes).toBe(422); // 2110 / 5 = 422 + expect(avg.nightsCounted).toBe(5); + expect(avg.nightsUntilReady).toBe(0); + }); + + it("matches the average the rhythm assembler emits over the same nights", () => { + const dto = computeSleepRhythmFromNights(nights(420, 420, 420, 420), 420); + expect(dto.averagePerNight.state).toBe("ready"); + expect(dto.averagePerNight.averageMinutes).toBe(420); + }); +}); diff --git a/src/lib/insights/derived/sleep-rhythm.ts b/src/lib/insights/derived/sleep-rhythm.ts index b46039715..ec3cbbaff 100644 --- a/src/lib/insights/derived/sleep-rhythm.ts +++ b/src/lib/insights/derived/sleep-rhythm.ts @@ -71,6 +71,23 @@ export interface SleepDebtDto { nightsUntilReady: number; } +/** + * Wire DTO for the average sleep per night over the rhythm window. + * + * v1.19.1 — a third peer card next to sleep-debt + chronotype. The mean of the + * canonical per-night asleep totals over the same scorable-night set the debt + * and chronotype read, so the three cards can never contradict one another. + * Carries the same calm `partial` state under a minimum-night threshold so it + * never asserts an average off one or two thin nights. + */ +export interface AverageSleepDto { + state: "partial" | "ready"; + /** Mean asleep minutes per scorable night (0 while partial). */ + averageMinutes: number; + nightsCounted: number; + nightsUntilReady: number; +} + /** Wire DTO for the MCTQ chronotype — see `ChronotypeResult`. */ export interface ChronotypeDto { state: "learning" | "ready"; @@ -93,6 +110,39 @@ export interface ChronotypeDto { export interface SleepRhythmDto { sleepDebt: SleepDebtDto; chronotype: ChronotypeDto; + averagePerNight: AverageSleepDto; +} + +/** + * Minimum scorable nights before the average asserts a figure. Mirrors the + * sleep-debt `minNights` floor (4) so the three peer cards settle in step. + */ +const AVG_MIN_NIGHTS = 4; + +/** + * Mean asleep minutes over the scorable nights, or the calm `partial` state + * under the night floor. Pure: takes the already-filtered, ascending-sorted + * scorable nights the rhythm assembler holds. + */ +export function computeAverageSleep( + scorable: readonly RhythmNight[], +): AverageSleepDto { + const nightsCounted = scorable.length; + if (nightsCounted < AVG_MIN_NIGHTS) { + return { + state: "partial", + averageMinutes: 0, + nightsCounted, + nightsUntilReady: AVG_MIN_NIGHTS - nightsCounted, + }; + } + const total = scorable.reduce((sum, n) => sum + n.asleepMinutes, 0); + return { + state: "ready", + averageMinutes: Math.round(total / nightsCounted), + nightsCounted, + nightsUntilReady: 0, + }; } /** @@ -211,9 +261,14 @@ export function computeSleepRhythmFromNights( })); const chronotype = computeChronotype(chronoNights); + // Average sleep per night over the same scorable set the debt + chronotype + // read, so the three peer cards share one window and can't disagree. + const averagePerNight = computeAverageSleep(scorable); + return { sleepDebt: toDebtDto(sleepDebt), chronotype: toChronotypeDto(chronotype), + averagePerNight, }; } diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 8759a005b..900f49119 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -208,6 +208,7 @@ const illnessCorrelationValue = z nadir: z.array(illnessVitalDeviation), returns: z.array(illnessVitalReturn), recoveryGapDays: z.number().nullable(), + adverseCoverageDays: z.number().int(), feltBetterDay: z.string().nullable(), redFlags: z.array(illnessRedFlag), }) @@ -252,11 +253,12 @@ const illnessInsightsResponse = z gapSampleSize: z.number().int(), byMonth: z.record(z.string(), z.number().int()), byType: z.record(z.string(), z.number().int()), + gapDriverType: z.string().nullable(), }) .meta({ id: "IllnessInsightsResponse", description: - "Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical (median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, and a per-type breakdown. Retrospective only.", + "Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical (median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, a per-type breakdown, and the dominant driving vital (`gapDriverType`, null when no gap is surfaced). Retrospective only.", }); const episodeNotFound = { diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index d1b1f1ef0..9af53ec26 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -322,10 +322,26 @@ const chronotypeResource = z.object({ .describe("Free-day nights still needed before a band is asserted."), }); +const averageSleepResource = z.object({ + state: z.enum(["partial", "ready"]), + averageMinutes: z + .number() + .int() + .nonnegative() + .describe("Mean asleep minutes per scorable night (0 while partial)."), + nightsCounted: z.number().int().nonnegative(), + nightsUntilReady: z + .number() + .int() + .nonnegative() + .describe("Scorable nights still needed before the average is asserted."), +}); + const sleepRhythmResponse = z .object({ sleepDebt: sleepDebtResource, chronotype: chronotypeResource, + averagePerNight: averageSleepResource, }) .meta({ id: "SleepRhythmResource",