From 8d1c90b8b6efbf11d20ba28dbb2e3b2662ed0c5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 13:56:38 +0200 Subject: [PATCH 01/12] fix(settings): label the layout nav entry Dashboard to match where it leads The left-nav entry read "Layout" but the editor it opens is the dashboard layout home. Rename the label across all six locales; the route stays /settings/layout. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- .../settings/__tests__/settings-shell.test.tsx | 13 ++++++++----- 7 files changed, 14 insertions(+), 11 deletions(-) diff --git a/messages/de.json b/messages/de.json index 4f600dbab..e1a27e32d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3817,7 +3817,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": { diff --git a/messages/en.json b/messages/en.json index 0c7cde8de..2dcf994be 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3817,7 +3817,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": { diff --git a/messages/es.json b/messages/es.json index 7899eb0a7..4b61aeb82 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3817,7 +3817,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": { diff --git a/messages/fr.json b/messages/fr.json index e923ab65c..16210e300 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3817,7 +3817,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": { diff --git a/messages/it.json b/messages/it.json index 5a4fece9a..3575c4db8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3817,7 +3817,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": { diff --git a/messages/pl.json b/messages/pl.json index e99e307cc..04cd5b0fb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3817,7 +3817,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": { 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"'); From bc1f30fec4698ddd5c60ee56366f9cc022fa1951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 13:56:49 +0200 Subject: [PATCH 02/12] perf(coach): send the health snapshot once per conversation The full SNAPSHOT block (every cluster plus labs, illness, cycle and the reference-grounding lines) was interpolated into the prompt on every turn, so a one-word follow-up re-paid the entire grounding prefix. Ship it only on the first turn, or once the history window starts eliding turns; on a cheap follow-up send a short pointer back to the snapshot the model already received earlier in the same conversation. The prior turns ride the transcript, so grounding and cross-metric correlation are preserved while a follow-up turn sheds about 6,000 tokens. Record snapshotSent and promptChars on the reply annotation so the cut is observable. --- .../__tests__/route-snapshot-once.test.ts | 209 ++++++++++++++++++ src/app/api/insights/chat/route.ts | 44 +++- 2 files changed, 248 insertions(+), 5 deletions(-) create mode 100644 src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts 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. From de1423853537b9d9252209cc947143b2c8739a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 13:58:43 +0200 Subject: [PATCH 03/12] feat(nav): fix the sidebar order of the clinical and insight spine Below the dashboard/measurements/mood/cycle head, the entries now follow one fixed sequence: Medications, Vorsorge, Labs, Illness, Insights, Coach, Achievements. A disabled module drops out; the rest keep this order. --- .../layout/__tests__/nav-model.test.ts | 18 ++++--- .../layout/__tests__/sidebar-nav.test.tsx | 17 ++++--- src/components/layout/nav-model.ts | 51 ++++++++++--------- 3 files changed, 48 insertions(+), 38 deletions(-) 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 From a86abd3662ae32a13260fe3a208cd16e913f9064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 13:58:57 +0200 Subject: [PATCH 04/12] fix(coach): restore the typing animation for the live turn Replace the elapsed-time thinking disclosure with the classic typing indicator: while a turn is still thinking with no prose, the assistant bubble shows the pulsing typing dots, then swaps to the streamed word-by-word prose once tokens land. Drop the now-unused reasoning/ startedAt bubble props and the orphaned disclosure component. --- .../insights/coach-panel/message-thread.tsx | 51 ++----- .../coach-panel/thinking-disclosure.tsx | 138 ------------------ 2 files changed, 11 insertions(+), 178 deletions(-) delete mode 100644 src/components/insights/coach-panel/thinking-disclosure.tsx diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index add50de79..54c1880c0 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -26,7 +26,6 @@ import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me"; import { SourceChips } from "./source-chips"; import { ReminderSuggestionCard } from "./reminder-suggestion-card"; import { StreamedProse } from "./streamed-prose"; -import { ThinkingDisclosure } from "./thinking-disclosure"; import { MessageTokenFooter } from "./message-token-footer"; import type { CoachConversationDetailDTO, @@ -470,11 +469,8 @@ export function MessageThread({ providerType={streaming.inProgress ? "streaming" : null} inProgress={streaming.inProgress} errorCode={streaming.errorCode} - // v1.18.9 — live word-fade + the elapsed-time thinking - // disclosure + the just-landed token footer. + // v1.18.9 — live word-fade + the just-landed token footer. streaming - startedAt={streaming.startedAt} - reasoning={streaming.reasoning} usage={streaming.usage} /> @@ -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")} -

-
- ); -} From c6f47726069c6bf69bbb7419f499d8634e01843a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 13:59:05 +0200 Subject: [PATCH 05/12] fix(coach): default to new chat and never dead-end on conversations Reverse the resume-most-recent entry default: opening the Coach now lands on the new-chat hero. The only exception is an unread coach-initiated message (nudge-status `unread`), which auto-opens the most-recent thread so the user sees what the Coach wrote. The `?c=` deep-link is unchanged. Add a dedicated, always-visible Conversations button (plus a New chat button) to the page surface so reaching past conversations no longer hides in the composer menu. The drawer's Conversations handoff now routes to `/coach?view=conversations`, which opens the history drawer on arrival, so the side-view to full-view path always shows the list instead of a blank pane. --- src/app/coach/page.tsx | 51 ++++++++++++++----- .../coach-conversation-deeplink.test.tsx | 21 ++++++++ .../coach-panel/coach-conversation.tsx | 48 ++++++++++++++++- .../insights/coach-panel/coach-drawer.tsx | 18 +++++-- 4 files changed, 120 insertions(+), 18 deletions(-) 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/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 ( Date: Sun, 21 Jun 2026 14:01:30 +0200 Subject: [PATCH 06/12] feat(sleep): add average-per-night card beside sleep debt and chronotype Compute the mean asleep minutes per night from the same canonical per-night data the sleep-debt and chronotype cards read, exposed as a new averagePerNight field on the sleep-rhythm DTO. Surface it as a third peer card with identical chrome, so the three tiles sit side by side. Calm partial state under a four-night floor; localised in six locales. --- messages/de.json | 6 ++ messages/en.json | 6 ++ messages/es.json | 6 ++ messages/fr.json | 6 ++ messages/it.json | 6 ++ messages/pl.json | 6 ++ src/app/insights/sleep/page.tsx | 4 +- .../__tests__/chronotype-section.test.tsx | 6 ++ .../__tests__/sleep-rhythm-section.test.tsx | 6 ++ .../insights/sleep/average-sleep-card.tsx | 83 +++++++++++++++++++ .../insights/sleep/average-sleep-section.tsx | 53 ++++++++++++ .../insights/sleep/use-sleep-rhythm.ts | 3 +- .../derived/__tests__/sleep-rhythm.test.ts | 33 ++++++++ src/lib/insights/derived/sleep-rhythm.ts | 55 ++++++++++++ 14 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 src/components/insights/sleep/average-sleep-card.tsx create mode 100644 src/components/insights/sleep/average-sleep-section.tsx diff --git a/messages/de.json b/messages/de.json index 4f600dbab..47c4805eb 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.", diff --git a/messages/en.json b/messages/en.json index 0c7cde8de..5d81ccedc 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.", diff --git a/messages/es.json b/messages/es.json index 7899eb0a7..4cf3c2a82 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.", diff --git a/messages/fr.json b/messages/fr.json index e923ab65c..76d7b0535 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.", diff --git a/messages/it.json b/messages/it.json index 5a4fece9a..33140d2c3 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.", diff --git a/messages/pl.json b/messages/pl.json index e99e307cc..43e8cd823 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ł.", 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/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/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, }; } From bbff6ab1439dc3ba2ae09d314e68b25164bdb73e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 14:01:37 +0200 Subject: [PATCH 07/12] fix(mood): full-saturate the time-of-day chart and mood calendar The v1.19.0 saturation fix missed two surfaces: the mood-by-time-of-day bars and the mood calendar still rendered populated values at 0.55 opacity, reading as a dimmed glitch. Drop the dimming on populated bars and cells (and the legend swatches); keep the quiet tint only on the no-data empty state. --- src/components/charts/mood-heatmap.tsx | 7 +++++-- .../insights/mood/mood-time-of-day-chart.tsx | 13 ++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) 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/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) => ( ))} From 36affd5dafbcec3d1f326adaab07f264637135fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 14:07:44 +0200 Subject: [PATCH 08/12] fix(illness): gate the recovery-gap floor on adverse-direction days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-episode typical-gap median counts an episode toward its qualifying-days floor via the per-episode coverage `historyDays`, which counts a day if ANY banded vital has a reading. A WEIGHT-only episode (no illness-adverse direction) could therefore clear the floor and feed the median despite carrying no illness-relevant signal, weakening the honest- sample claim. Compute a distinct-adverse-day count in the engine — days with at least one notable reading in the metric's adverse direction — and feed that as the qualifying-days floor instead. Reuses the engine's single adverse- direction predicate (now exported as `isAdverseDeviation`); a neutral- direction vital never counts a day. The insufficient arm contributes 0, which is correct since its gap is null and never reaches the median. --- docs/api/openapi.yaml | 5 + src/app/api/illness/insights/route.ts | 14 ++- src/components/illness/types.ts | 1 + src/lib/illness/__tests__/correlation.test.ts | 98 +++++++++++++++++++ src/lib/illness/correlation.ts | 39 +++++++- src/lib/openapi/routes/illness.ts | 1 + 6 files changed, 151 insertions(+), 7 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f424cf858..f5cc0e154 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -20279,6 +20279,10 @@ components: anyOf: - type: number - type: "null" + adverseCoverageDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 feltBetterDay: anyOf: - type: string @@ -20293,6 +20297,7 @@ components: - nadir - returns - recoveryGapDays + - adverseCoverageDays - feltBetterDay - redFlags additionalProperties: false diff --git a/src/app/api/illness/insights/route.ts b/src/app/api/illness/insights/route.ts index 1e47c0482..f11ec4bab 100644 --- a/src/app/api/illness/insights/route.ts +++ b/src/app/api/illness/insights/route.ts @@ -119,9 +119,17 @@ 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, }, ]; }, diff --git a/src/components/illness/types.ts b/src/components/illness/types.ts index 6526f22e4..ec3563e1b 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[]; } diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index f9941c81d..e3366fe99 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"; @@ -431,6 +433,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/correlation.ts b/src/lib/illness/correlation.ts index 22f519670..ae8676a10 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -208,6 +208,24 @@ export interface VitalReturnFinding { gapDays: number | null; } +/** + * 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 { episodeId: string; /** "How did it announce itself" — notable pre-onset deviations. */ @@ -222,6 +240,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 +306,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 +388,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; @@ -396,6 +425,7 @@ export function computeIllnessCorrelation( 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); if ( worstActive === null || Math.abs(f.deviationSd) > Math.abs(worstActive.deviationSd) @@ -463,6 +493,7 @@ export function computeIllnessCorrelation( ), returns, recoveryGapDays, + adverseCoverageDays: adverseDayKeys.size, feltBetterDay: window.feltBetterDay, redFlags, }, diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 8759a005b..2c8bc9571 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), }) From 638bc6c8352fd48e567de17ed16a616ab1d79cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 14:11:20 +0200 Subject: [PATCH 09/12] feat(illness): name the driving vital in the recovery-gap summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-episode summary already computes per-vital physiological returns but the card surfaced only one bare line. Thread the dominant returning vital through the aggregate and the DTO so the copy reads 'you tend to feel better before your resting heart rate settles' instead of a context-free gap figure. The aggregate tallies which vital produced a real return across the episodes that qualified for the typical-gap median and reports the most frequent one (deterministic alphabetical tiebreak); the driver is named only when a gap actually clears the magnitude gate. Zero new compute — the returns were already in hand. Adds the localized copy in all six locales. --- docs/api/openapi.yaml | 10 ++- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + src/app/api/illness/insights/route.ts | 23 ++++++- .../illness/illness-insights-card.tsx | 19 ++++-- src/components/illness/types.ts | 1 + .../illness/__tests__/retrospective.test.ts | 68 +++++++++++++++++++ src/lib/illness/retrospective.ts | 32 +++++++++ src/lib/openapi/routes/illness.ts | 3 +- 13 files changed, 152 insertions(+), 10 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f5cc0e154..14d3a2fe2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -20434,6 +20434,10 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + gapDriverType: + anyOf: + - type: string + - type: "null" required: - windowDays - episodeCount @@ -20442,10 +20446,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/messages/de.json b/messages/de.json index 6674ca67f..c15520593 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6880,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 2f753c94a..4ad161b92 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6880,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 ff16bdc8f..66d9d2421 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6880,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 a3c2c476c..608aef1a6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6880,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 3e512e59b..226eaebb0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6880,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 9709e45af..4853b2972 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6880,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/src/app/api/illness/insights/route.ts b/src/app/api/illness/insights/route.ts index f11ec4bab..0e31fb6d3 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( @@ -130,6 +138,15 @@ export const GET = apiHandler(async (request: NextRequest) => { derived.status === "ok" ? derived.value.adverseCoverageDays : 0, + // The vitals that produced a real physiological return — + // tallied across the qualifying episodes to name the dominant + // driving vital in the summary copy. + gapReturnTypes: + derived.status === "ok" + ? derived.value.returns + .filter((r) => r.gapDays !== null) + .map((r) => String(r.type)) + : [], }, ]; }, @@ -148,6 +165,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, }; }); @@ -160,6 +178,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/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 ec3563e1b..6bda26316 100644 --- a/src/components/illness/types.ts +++ b/src/components/illness/types.ts @@ -149,4 +149,5 @@ export interface IllnessInsightsResponse { gapSampleSize: number; byMonth: Record; byType: Record; + gapDriverType: string | null; } 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/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/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 2c8bc9571..900f49119 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -253,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 = { From e2ce0642543de5ab4a913fdc487cdf36112ef0ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 14:23:24 +0200 Subject: [PATCH 10/12] fix(illness,sleep): document the sleep average DTO and gate the recovery driver Add averagePerNight to the SleepRhythmResource OpenAPI schema so the new average-per-night card's field is part of the iOS-consumed contract (the endpoint already returned it; the Zod source had not been updated, so the in-sync check could not catch the omission). Tag each vital return with whether it deviated in the illness-adverse direction and tally only adverse returns when naming the recovery driver, so a neutral move (e.g. weight drift) can no longer be surfaced as the vital someone recovered ahead of. --- docs/api/openapi.yaml | 29 +++++++++++ src/app/api/illness/insights/route.ts | 10 ++-- src/lib/illness/__tests__/correlation.test.ts | 48 +++++++++++++++++++ src/lib/illness/correlation.ts | 19 +++++++- src/lib/openapi/routes/measurements.ts | 16 +++++++ 5 files changed, 116 insertions(+), 6 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 14d3a2fe2..65115f9f1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/app/api/illness/insights/route.ts b/src/app/api/illness/insights/route.ts index 0e31fb6d3..c56fbf9c2 100644 --- a/src/app/api/illness/insights/route.ts +++ b/src/app/api/illness/insights/route.ts @@ -138,13 +138,15 @@ export const GET = apiHandler(async (request: NextRequest) => { derived.status === "ok" ? derived.value.adverseCoverageDays : 0, - // The vitals that produced a real physiological return — - // tallied across the qualifying episodes to name the dominant - // driving vital in the summary copy. + // 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) + .filter((r) => r.gapDays !== null && r.adverse) .map((r) => String(r.type)) : [], }, diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index e3366fe99..27bc955b3 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -226,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", () => { diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index ae8676a10..e220b0f14 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -206,6 +206,10 @@ 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; } /** @@ -421,11 +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); + if (f.adverse) { + adverseDayKeys.add(p.day); + vitalDeviatedAdversely = true; + } if ( worstActive === null || Math.abs(f.deviationSd) > Math.abs(worstActive.deviationSd) @@ -452,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, + }); } } 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", From 41dab24985e135b2a4a1b632c087a64bf93ddaaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 14:24:39 +0200 Subject: [PATCH 11/12] chore(release): v1.19.1 --- CHANGELOG.md | 24 ++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 27 insertions(+), 3 deletions(-) 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 65115f9f1..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. 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/*` From 67ead71e24fc3e4aba20cf3fe17c4801404c445f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 14:35:59 +0200 Subject: [PATCH 12/12] test(e2e): match the coach conversations handoff query string The drawer's Conversations button now hands off to /coach?view=conversations so the full page opens the conversation list on arrival; widen the navigation assertion to accept the query string. --- e2e/v1427-coach-launch.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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, });