Merge pull request #349 from MBombeck/release/v1.19.1

v1.19.1 — Coach flow, sleep & mood polish, navigation, recovery-gap
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-21 14:46:12 +02:00
commit 02ce177ac4
42 files changed
+1177 -284

No files matched your search

+24
View File
@@ -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=<id>` 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.
+43 -3
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.19.0
version: 1.19.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -11860,9 +11860,38 @@ components:
- workNightsCounted
- freeNightsUntilReady
additionalProperties: false
averagePerNight:
type: object
properties:
state:
type: string
enum:
- partial
- ready
averageMinutes:
type: integer
minimum: 0
maximum: 9007199254740991
description: Mean asleep minutes per scorable night (0 while partial).
nightsCounted:
type: integer
minimum: 0
maximum: 9007199254740991
nightsUntilReady:
type: integer
minimum: 0
maximum: 9007199254740991
description: Scorable nights still needed before the average is asserted.
required:
- state
- averageMinutes
- nightsCounted
- nightsUntilReady
additionalProperties: false
required:
- sleepDebt
- chronotype
- averagePerNight
additionalProperties: false
description: "Server-authoritative sleep-rhythm read: cumulative sleep debt over the rolling window + MCTQ chronotype
(MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work
@@ -20279,6 +20308,10 @@ components:
anyOf:
- type: number
- type: "null"
adverseCoverageDays:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
feltBetterDay:
anyOf:
- type: string
@@ -20293,6 +20326,7 @@ components:
- nadir
- returns
- recoveryGapDays
- adverseCoverageDays
- feltBetterDay
- redFlags
additionalProperties: false
@@ -20429,6 +20463,10 @@ components:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
gapDriverType:
anyOf:
- type: string
- type: "null"
required:
- windowDays
- episodeCount
@@ -20437,10 +20475,12 @@ components:
- gapSampleSize
- byMonth
- byType
- gapDriverType
additionalProperties: false
description: "Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical
(median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally,
and a per-type breakdown. Retrospective only."
(median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, a
per-type breakdown, and the dominant driving vital (`gapDriverType`, null when no gap is surfaced).
Retrospective only."
DisclaimerAckEnvelope:
type: object
properties:
+5 -4
View File
@@ -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,
});
+8 -1
View File
@@ -2587,6 +2587,12 @@
"clearCaption": "Kein aufgelaufenes Defizit über die letzten {nights} Nächte",
"learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für eine verlässliche Bilanz."
},
"average": {
"title": "Durchschnitt pro Nacht",
"value": "{hours} Std. {minutes} Min.",
"caption": "Mittlerer Schlaf über die letzten {nights} Nächte",
"learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für einen verlässlichen Durchschnitt."
},
"chronotype": {
"title": "Chronotyp",
"learning": "Dein Rhythmus wird noch erfasst — {counted} von {total} freien Nächten. Ein Chronotyp braucht ein paar weckerfreie Morgen, um aussagekräftig zu sein.",
@@ -3817,7 +3823,7 @@
"subtitle": "Wähle, welche Erinnerungen du erhältst."
},
"layout": {
"title": "Layout",
"title": "Dashboard",
"description": "Lege fest, wie Dashboard und Insights aufgebaut und angeordnet sind.",
"backToHub": "Zurück zu Layout & Personalisierung",
"dashboard": {
@@ -6874,6 +6880,7 @@
"countOne": "Du hast in diesem Zeitraum 1 Episode erfasst.",
"countOther": "Du hast in diesem Zeitraum {count} Episoden erfasst.",
"typicalGap": "Typische Erholungslücke: {days} Tage zwischen Besserung und normalisierten Werten.",
"typicalGapWithDriver": "Typische Erholungslücke: {days} Tage – du fühlst dich meist besser, bevor sich {vital} normalisiert.",
"typicalGapLearning": "Noch ein paar abgeschlossene Episoden und hier erscheint eine typische Erholungslücke.",
"none": "In diesem Zeitraum keine Episoden erfasst.",
"analyze": "Erholungslücke analysieren"
+8 -1
View File
@@ -2587,6 +2587,12 @@
"clearCaption": "No accumulated shortfall over the last {nights} nights",
"learning": "Still learning your sleep — {counted} nights so far, {remaining} more to read a reliable balance."
},
"average": {
"title": "Average per night",
"value": "{hours}h {minutes}m",
"caption": "Mean sleep across the last {nights} nights",
"learning": "Still learning your sleep — {counted} nights so far, {remaining} more for a reliable average."
},
"chronotype": {
"title": "Chronotype",
"learning": "Still learning your rhythm — {counted} of {total} free nights. A chronotype needs a few alarm-free mornings before it means anything.",
@@ -3817,7 +3823,7 @@
"subtitle": "Choose which reminders you receive."
},
"layout": {
"title": "Layout",
"title": "Dashboard",
"description": "Arrange how the dashboard and insights are laid out.",
"backToHub": "Back to Layout & Personalization",
"dashboard": {
@@ -6874,6 +6880,7 @@
"countOne": "You logged 1 episode in this period.",
"countOther": "You logged {count} episodes in this period.",
"typicalGap": "Typical recovery gap: {days} days between feeling better and your numbers settling.",
"typicalGapWithDriver": "Typical recovery gap: {days} days — you tend to feel better before your {vital} settles.",
"typicalGapLearning": "A few more resolved episodes and a typical recovery gap will appear here.",
"none": "No episodes logged in this period.",
"analyze": "Analyse the recovery gap"
+8 -1
View File
@@ -2587,6 +2587,12 @@
"clearCaption": "Sin déficit acumulado en las últimas {nights} noches",
"learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para un balance fiable."
},
"average": {
"title": "Media por noche",
"value": "{hours} h {minutes} min",
"caption": "Sueño medio en las últimas {nights} noches",
"learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para una media fiable."
},
"chronotype": {
"title": "Cronotipo",
"learning": "Aún estamos aprendiendo tu ritmo — {counted} de {total} noches libres. Un cronotipo necesita varias mañanas sin despertador para tener sentido.",
@@ -3817,7 +3823,7 @@
"subtitle": "Elige qué recordatorios recibes."
},
"layout": {
"title": "Diseño",
"title": "Panel",
"description": "Organiza cómo están dispuestos el panel y los análisis.",
"backToHub": "Volver a Diseño y personalización",
"dashboard": {
@@ -6874,6 +6880,7 @@
"countOne": "Registraste 1 episodio en este período.",
"countOther": "Registraste {count} episodios en este período.",
"typicalGap": "Brecha de recuperación típica: {days} días entre sentirte mejor y la estabilización de tus valores.",
"typicalGapWithDriver": "Brecha de recuperación típica: {days} días: sueles sentirte mejor antes de que {vital} se estabilice.",
"typicalGapLearning": "Con unos pocos episodios resueltos más aparecerá aquí una brecha de recuperación típica.",
"none": "Sin episodios registrados en este período.",
"analyze": "Analizar la brecha de recuperación"
+8 -1
View File
@@ -2587,6 +2587,12 @@
"clearCaption": "Aucun déficit cumulé sur les {nights} dernières nuits",
"learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour un bilan fiable."
},
"average": {
"title": "Moyenne par nuit",
"value": "{hours} h {minutes} min",
"caption": "Sommeil moyen sur les {nights} dernières nuits",
"learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour une moyenne fiable."
},
"chronotype": {
"title": "Chronotype",
"learning": "Votre rythme est encore en cours d'analyse — {counted} sur {total} nuits libres. Un chronotype a besoin de quelques matins sans réveil pour avoir du sens.",
@@ -3817,7 +3823,7 @@
"subtitle": "Choisissez les rappels que vous recevez."
},
"layout": {
"title": "Disposition",
"title": "Tableau de bord",
"description": "Organisez la disposition du tableau de bord et des analyses.",
"backToHub": "Retour à Disposition et personnalisation",
"dashboard": {
@@ -6874,6 +6880,7 @@
"countOne": "Tu as enregistré 1 épisode sur cette période.",
"countOther": "Tu as enregistré {count} épisodes sur cette période.",
"typicalGap": "Écart de récupération typique : {days} jours entre le mieux-être et la stabilisation de tes valeurs.",
"typicalGapWithDriver": "Écart de récupération typique : {days} jours – tu te sens généralement mieux avant que {vital} ne se stabilise.",
"typicalGapLearning": "Encore quelques épisodes résolus et un écart de récupération typique apparaîtra ici.",
"none": "Aucun épisode enregistré sur cette période.",
"analyze": "Analyser l'écart de récupération"
+8 -1
View File
@@ -2587,6 +2587,12 @@
"clearCaption": "Nessun deficit accumulato nelle ultime {nights} notti",
"learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per un bilancio affidabile."
},
"average": {
"title": "Media per notte",
"value": "{hours} h {minutes} min",
"caption": "Sonno medio nelle ultime {nights} notti",
"learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per una media affidabile."
},
"chronotype": {
"title": "Cronotipo",
"learning": "Stiamo ancora imparando il tuo ritmo — {counted} di {total} notti libere. Un cronotipo richiede alcune mattine senza sveglia per avere significato.",
@@ -3817,7 +3823,7 @@
"subtitle": "Scegli quali promemoria ricevere."
},
"layout": {
"title": "Layout",
"title": "Cruscotto",
"description": "Organizza la disposizione del cruscotto e degli approfondimenti.",
"backToHub": "Torna a Layout e personalizzazione",
"dashboard": {
@@ -6874,6 +6880,7 @@
"countOne": "Hai registrato 1 episodio in questo periodo.",
"countOther": "Hai registrato {count} episodi in questo periodo.",
"typicalGap": "Divario di recupero tipico: {days} giorni tra il sentirti meglio e la stabilizzazione dei valori.",
"typicalGapWithDriver": "Divario di recupero tipico: {days} giorni: di solito ti senti meglio prima che {vital} si stabilizzi.",
"typicalGapLearning": "Ancora qualche episodio risolto e qui apparirà un divario di recupero tipico.",
"none": "Nessun episodio registrato in questo periodo.",
"analyze": "Analizza il divario di recupero"
+8 -1
View File
@@ -2587,6 +2587,12 @@
"clearCaption": "Brak skumulowanego niedoboru w ostatnich {nights} nocach",
"learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodny bilans."
},
"average": {
"title": "Średnia na noc",
"value": "{hours} godz. {minutes} min",
"caption": "Średni sen w ciągu ostatnich {nights} nocy",
"learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodną średnią."
},
"chronotype": {
"title": "Chronotyp",
"learning": "Wciąż poznajemy Twój rytm — {counted} z {total} wolnych nocy. Chronotyp wymaga kilku poranków bez budzika, aby coś znaczył.",
@@ -3817,7 +3823,7 @@
"subtitle": "Wybierz, które przypomnienia otrzymujesz."
},
"layout": {
"title": "Układ",
"title": "Pulpit",
"description": "Ustal, jak rozłożone są pulpit i analizy.",
"backToHub": "Powrót do Układ i personalizacja",
"dashboard": {
@@ -6874,6 +6880,7 @@
"countOne": "W tym okresie zapisałeś 1 epizod.",
"countOther": "W tym okresie zapisałeś {count} epizodów.",
"typicalGap": "Typowa luka rekonwalescencji: {days} dni między poprawą a ustabilizowaniem wartości.",
"typicalGapWithDriver": "Typowa luka rekonwalescencji: {days} dni – zwykle czujesz się lepiej, zanim {vital} się ustabilizuje.",
"typicalGapLearning": "Jeszcze kilka zakończonych epizodów i pojawi się tu typowa luka rekonwalescencji.",
"none": "Brak epizodów zapisanych w tym okresie.",
"analyze": "Przeanalizuj lukę rekonwalescencji"
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
+34 -5
View File
@@ -85,7 +85,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
// out of the typical-gap median by the aggregate's signal-density gates.
const gapById = new Map<
string,
{ recoveryGapDays: number | null; gapMeasurementDays: number }
{
recoveryGapDays: number | null;
gapMeasurementDays: number;
gapReturnTypes: string[];
}
>();
if (includeRecoveryGap) {
const gapCandidates = rows
@@ -98,7 +102,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
async (): Promise<
[
string,
{ recoveryGapDays: number | null; gapMeasurementDays: number },
{
recoveryGapDays: number | null;
gapMeasurementDays: number;
gapReturnTypes: string[];
},
]
> => {
const derived = await computeEpisodeCorrelation(
@@ -119,9 +127,28 @@ export const GET = apiHandler(async (request: NextRequest) => {
derived.status === "ok"
? derived.value.recoveryGapDays
: null,
// `historyDays` = distinct episode days with a banded vital,
// present on both the `ok` and `insufficient` arms.
gapMeasurementDays: derived.coverage.historyDays,
// Qualifying-days floor: distinct episode days carrying an
// ADVERSE-direction banded reading — NOT raw coverage
// (`historyDays` counts ANY banded vital, so a WEIGHT-only
// episode with no illness-adverse signal could otherwise clear
// the floor and tip the typical-gap median). Only the `ok` arm
// ever contributes a gap; the `insufficient` arm's gap is null,
// so 0 here is correct (it never reaches the median anyway).
gapMeasurementDays:
derived.status === "ok"
? derived.value.adverseCoverageDays
: 0,
// The vitals that produced a real physiological return in the
// illness-ADVERSE direction — tallied across the qualifying
// episodes to name the dominant driving vital in the summary
// copy. A neutral move (e.g. weight drift with no adverse
// signal) shows a return but must never be named the driver.
gapReturnTypes:
derived.status === "ok"
? derived.value.returns
.filter((r) => r.gapDays !== null && r.adverse)
.map((r) => String(r.type))
: [],
},
];
},
@@ -140,6 +167,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
resolved: r.resolvedAt !== null,
recoveryGapDays: gap?.recoveryGapDays ?? null,
gapMeasurementDays: gap?.gapMeasurementDays ?? 0,
gapReturnTypes: gap?.gapReturnTypes ?? [],
lifecycle: r.lifecycle,
};
});
@@ -152,6 +180,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
episode_count: summary.episodeCount,
gap_sample_size: summary.gapSampleSize,
typical_gap_days: summary.typicalRecoveryGapDays,
gap_driver_type: summary.gapDriverType,
include_recovery_gap: includeRecoveryGap,
},
});
@@ -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: <T extends (...args: unknown[]) => 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<typeof import("@/lib/ai/coach/types")>(
"@/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>,
) => {
void Promise.resolve(
producer({ signal: { aborted: true }, enqueue: () => {} }),
);
return new ReadableStream();
},
}));
import { POST } from "../route";
const post = POST as unknown as (req: Request) => Promise<Response>;
function chatReq(body: Record<string, unknown>): 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.");
});
});
+39 -5
View File
@@ -387,6 +387,24 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
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.
+38 -13
View File
@@ -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 `<CoachConversation>`'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=<id>` 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 `<CoachConversation>`'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=<id>` → 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<CoachNudgeStatus> =>
apiGet<CoachNudgeStatus>("/api/insights/coach/nudge-status"),
enabled: exceptionEligible && !openConversations,
staleTime: 5 * 60 * 1000,
});
const hasUnreadCoachMessage = exceptionEligible && nudge?.unread === true;
return (
<CoachConversation
surface="page"
autoFocusComposer
initialConversationId={deepLinkedId}
// Only auto-resolve most-recent when the entry did not pin a thread
// and did not ask for a fresh chat.
autoOpenMostRecent={deepLinkedId === null && rawC !== "new"}
// Default is the new-chat hero; only resume most-recent for the
// unread coach-initiated exception.
autoOpenMostRecent={hasUnreadCoachMessage}
openHistoryOnMount={openConversations}
/>
);
}
+3 -1
View File
@@ -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).
*/}
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<SleepRhythmSection enabled={!isEmpty} />
<AverageSleepSection enabled={!isEmpty} />
<ChronotypeSection enabled={!isEmpty} />
</div>
+5 -2
View File
@@ -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({
<span key={label} className="flex items-center gap-1">
<div
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: color, opacity: 0.55 }}
style={{ backgroundColor: color }}
/>
{label}
</span>
@@ -87,13 +87,22 @@ export function IllnessInsightsCard({
<p className="text-muted-foreground">
{t("illness.insights.typicalGapLearning")}
</p>
) : gap.data.typicalRecoveryGapDays === null ? (
<p className="text-muted-foreground">
{t("illness.insights.typicalGapLearning")}
</p>
) : gap.data.gapDriverType ? (
<p className="text-muted-foreground">
{t("illness.insights.typicalGapWithDriver", {
days: gap.data.typicalRecoveryGapDays,
vital: t(`illness.vital.${gap.data.gapDriverType}`),
})}
</p>
) : (
<p className="text-muted-foreground">
{gap.data.typicalRecoveryGapDays !== null
? t("illness.insights.typicalGap", {
days: gap.data.typicalRecoveryGapDays,
})
: t("illness.insights.typicalGapLearning")}
{t("illness.insights.typicalGap", {
days: gap.data.typicalRecoveryGapDays,
})}
</p>
)}
</div>
+2
View File
@@ -115,6 +115,7 @@ export interface IllnessCorrelationValue {
nadir: IllnessVitalDeviation[];
returns: IllnessVitalReturn[];
recoveryGapDays: number | null;
adverseCoverageDays: number;
feltBetterDay: string | null;
redFlags: IllnessRedFlag[];
}
@@ -148,4 +149,5 @@ export interface IllnessInsightsResponse {
gapSampleSize: number;
byMonth: Record<string, number>;
byType: Record<string, number>;
gapDriverType: string | null;
}
@@ -135,4 +135,25 @@ describe("<CoachConversation> 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(<CoachConversation surface="page" />, 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(
<CoachConversation surface="page" openHistoryOnMount />,
makeClient(),
);
expect(html).toContain('data-slot="coach-hero"');
expect(html).toContain('data-slot="coach-page-conversations"');
});
});
@@ -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. */}
<div
data-slot="coach-page-toolbar"
className="flex shrink-0 items-center justify-between gap-2 px-4 pt-2 sm:px-6"
>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setHistoryDrawerOpen(true)}
data-slot="coach-page-conversations"
className="text-muted-foreground hover:text-foreground -ml-1 gap-1.5"
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleNewChat}
data-slot="coach-page-new-chat"
className="text-muted-foreground hover:text-foreground -mr-1 gap-1.5"
>
<Plus className="size-4" aria-hidden="true" />
{t("insights.coach.newChat")}
</Button>
</div>
{heroActive ? (
<CoachHero composer={composerNode} />
) : (
@@ -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 (
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent
@@ -118,9 +129,10 @@ export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) {
autoFocusComposer
registerReset={registerReset}
// v1.16.1 — the "Conversations" affordance hands off to the
// full-page route (same path as maximize) instead of opening
// the broken in-panel left tray.
onRequestFullView={handleMaximize}
// full-page route instead of opening the broken in-panel left
// tray. v1.19.1 (C5) — it now carries `?view=conversations` so the
// page opens the history list on arrival, never a blank hero.
onRequestFullView={handleOpenConversations}
// Radix requires an accessible name + description on the
// dialog content. Mount them through the title/description
// render-props so the page can swap in a plain heading.
@@ -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}
/>
</div>
@@ -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({
)}
</div>
<div className="flex max-w-[calc(80%-2.625rem)] flex-col gap-2">
{/* 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 <TypingDots> dots-plus-word combo: there is
never a three-dots indicator AND a "Thinking" word at once. */}
{streaming && startedAt != null && (
<ThinkingDisclosure
startedAt={startedAt}
inProgress={!!inProgress}
hasContent={!!content}
reasoning={reasoning}
/>
)}
{/* 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
`<TypingDots>` 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) && (
<div
className={cn(
"border-border/60 bg-muted/40 text-foreground",
@@ -1,138 +0,0 @@
"use client";
import { useEffect, useId, useState } from "react";
import { ChevronRight, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.18.9 — the ONE Coach thinking indicator on the page surface.
*
* Replaces the old dots-plus-word combo (`<TypingDots>`), 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
* `<details>` 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 (
<div
data-slot="coach-thinking-live"
role="status"
aria-live="polite"
className="text-muted-foreground inline-flex items-center gap-2 py-0.5 text-xs"
>
<Loader2
aria-hidden="true"
className="text-dracula-purple/70 size-3.5 animate-spin motion-reduce:animate-none"
/>
<span>{t("insights.coach.thinking")}</span>
<span aria-hidden="true" className="tabular-nums opacity-70">
{t("insights.coach.thinkingElapsed", { seconds: elapsed })}
</span>
</div>
);
}
// ── Settled: past-tense collapsible disclosure (auto-collapsed) ──
const reasoningText = reasoning?.trim() ?? "";
return (
<details
data-slot="coach-thinking-disclosure"
open={open}
onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}
className="group inline-block"
>
<summary
data-slot="coach-thinking-summary"
aria-controls={panelId}
aria-expanded={open}
aria-label={t("insights.coach.thinkingExpandAria")}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex cursor-pointer items-center gap-1 text-xs",
"marker:hidden [&::-webkit-details-marker]:hidden",
"focus-visible:ring-ring/50 rounded outline-none focus-visible:ring-2",
)}
>
<ChevronRight
aria-hidden="true"
className="size-3 transition-transform group-open:rotate-90"
/>
<span>
{t("insights.coach.thinkingDuration", { seconds: elapsed })}
</span>
</summary>
<p
id={panelId}
data-slot="coach-thinking-detail"
className="text-muted-foreground mt-1.5 pl-4 text-xs leading-relaxed"
>
{reasoningText || t("insights.coach.thinkingDetail")}
</p>
</details>
);
}
@@ -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. */}
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
@@ -130,7 +129,10 @@ export function MoodTimeOfDayChart({
];
}}
/>
<Bar dataKey="value" radius={[3, 3, 0, 0]} fillOpacity={0.55}>
{/* 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. */}
<Bar dataKey="value" radius={[3, 3, 0, 0]}>
{data.map((row) => (
<Cell
key={row.bucket}
@@ -139,6 +141,7 @@ export function MoodTimeOfDayChart({
? "var(--secondary)"
: colorForScore(row.avgScore)
}
fillOpacity={row.avgScore == null ? 0.55 : 1}
/>
))}
</Bar>
@@ -50,6 +50,12 @@ const READY_DTO: SleepRhythmDto = {
workNightsCounted: 14,
freeNightsUntilReady: 0,
},
averagePerNight: {
state: "ready",
averageMinutes: 420,
nightsCounted: 10,
nightsUntilReady: 0,
},
};
beforeEach(() => {
@@ -58,6 +58,12 @@ const READY_DTO: SleepRhythmDto = {
workNightsCounted: 14,
freeNightsUntilReady: 0,
},
averagePerNight: {
state: "ready",
averageMinutes: 420,
nightsCounted: 5,
nightsUntilReady: 0,
},
};
beforeEach(() => {
@@ -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 `<SleepDebtCard>` + `<ChronotypeCard>`: 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 (
<Card data-slot="average-sleep-card">
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<BedDouble className="text-info h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.average.title")}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<LearningGate
message={t("insights.sleep.average.learning", {
counted: average.nightsCounted,
remaining: average.nightsUntilReady,
})}
/>
</CardContent>
</Card>
);
}
const { hours, minutes } = hoursMinutes(average.averageMinutes);
return (
<Card data-slot="average-sleep-card">
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<BedDouble className="text-info h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.average.title")}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-0.5">
<p className="text-2xl font-semibold tabular-nums">
{t("insights.sleep.average.value", { hours, minutes })}
</p>
<p className="text-muted-foreground text-xs">
{t("insights.sleep.average.caption", {
nights: average.nightsCounted,
})}
</p>
</div>
</CardContent>
</Card>
);
}
@@ -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 `<SleepRhythmSection>` (sleep debt) + `<ChronotypeSection>`
* 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 (
<div
data-slot="average-sleep-error"
role="status"
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
>
{t("insights.sleep.rhythm.loadError")}
</div>
);
}
if (isLoading || !data) {
return (
<Skeleton
data-slot="average-sleep-loading"
className="h-28 w-full rounded-xl"
/>
);
}
return <AverageSleepCard average={data.averagePerNight} />;
}
@@ -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
@@ -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", () => {
@@ -109,17 +109,18 @@ describe("<SidebarNav> 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);
});
});
+27 -24
View File
@@ -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<NavDestination> = [
{ href: "/", tKey: "nav.dashboard", icon: Home, tourId: "nav-dashboard" },
{
@@ -78,6 +82,13 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
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<NavDestination> = [
// 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<NavDestination> = [
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
@@ -284,9 +284,11 @@ describe("<SettingsShell>", () => {
// `/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</a>");
expect(html).toContain(">Dashboard</a>");
// 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("<SettingsShell>", () => {
// 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</a>");
expect(html).toContain(">Dashboard</a>");
// v1.18.1 (D4) — Channels ("Kanäle") + Sources ("Quellen-Priorität")
// are their own left-side entries.
expect(html).toContain('href="/settings/channels"');
@@ -16,7 +16,9 @@ import type { MeasurementType } from "@/generated/prisma/client";
import {
computeIllnessCorrelation,
dayDiff,
isAdverseDeviation,
MIN_BASELINE_DAYS,
MIN_EPISODE_COVERAGE_DAYS,
type IllnessCorrelationInput,
type VitalDayPoint,
} from "../correlation";
@@ -224,6 +226,54 @@ describe("computeIllnessCorrelation — golden recovery-gap", () => {
expect(out.value.recoveryGapDays).toBeNull();
expect(out.value.returns[0]?.returnedDay).toBeNull();
});
it("flags a return adverse only when the vital deviated in the adverse direction", () => {
// A neutral-direction vital (WEIGHT) can deviate out of band and stably
// return — it produces a return finding, but `adverse` must stay false so
// it never gets named the recovery driver. An up-adverse vital (RHR) that
// spikes and returns must read adverse: true.
const weight: MeasurementType = "WEIGHT";
const rhr: MeasurementType = "RESTING_HEART_RATE";
const out = computeIllnessCorrelation(
input({
window: {
onsetDay: "2026-01-10",
feltBetterDay: "2026-01-14",
lifecycle: "ACUTE",
},
series: [
{
type: weight,
baselineDays: jitteredBaseline(80, 1, 21, "2026-01-02"),
episodeDays: [
{ day: "2026-01-10", mean: 90 }, // far out of band, neutral
{ day: "2026-01-11", mean: 91 },
{ day: "2026-01-15", mean: 80 }, // back in band
{ day: "2026-01-16", mean: 80 },
{ day: "2026-01-17", mean: 80 },
],
},
{
type: rhr,
baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"),
episodeDays: [
{ day: "2026-01-10", mean: 75 }, // up = adverse
{ day: "2026-01-11", mean: 74 },
{ day: "2026-01-15", mean: 55 }, // back in band
{ day: "2026-01-16", mean: 55 },
{ day: "2026-01-17", mean: 55 },
],
},
],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
const weightReturn = out.value.returns.find((r) => r.type === weight);
const rhrReturn = out.value.returns.find((r) => r.type === rhr);
expect(weightReturn?.adverse).toBe(false);
expect(rhrReturn?.adverse).toBe(true);
});
});
describe("computeIllnessCorrelation — baseline-contamination guard", () => {
@@ -431,6 +481,102 @@ describe("computeIllnessCorrelation — red-flag escalation", () => {
});
});
describe("isAdverseDeviation", () => {
it("pins per-metric adverse direction (up for RHR, down for HRV)", () => {
expect(isAdverseDeviation("RESTING_HEART_RATE", "above")).toBe(true);
expect(isAdverseDeviation("RESTING_HEART_RATE", "below")).toBe(false);
expect(isAdverseDeviation("HEART_RATE_VARIABILITY", "below")).toBe(true);
expect(isAdverseDeviation("HEART_RATE_VARIABILITY", "above")).toBe(false);
});
it("treats neutral-direction vitals (WEIGHT) as never adverse", () => {
expect(isAdverseDeviation("WEIGHT", "above")).toBe(false);
expect(isAdverseDeviation("WEIGHT", "below")).toBe(false);
});
});
describe("computeIllnessCorrelation — adverse-coverage qualifying floor", () => {
it("counts only days with a notable ADVERSE-direction reading", () => {
// RHR (up = adverse) spikes out-of-band on 3 distinct active days, then
// settles. Those 3 days — and only those — count toward the floor.
const type: MeasurementType = "RESTING_HEART_RATE";
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
const episodeDays: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 75 }, // adverse spike
{ day: "2026-01-11", mean: 74 }, // adverse spike
{ day: "2026-01-12", mean: 73 }, // adverse spike
{ day: "2026-01-15", mean: 55 }, // in band
{ day: "2026-01-16", mean: 55 },
{ day: "2026-01-17", mean: 55 },
];
const out = computeIllnessCorrelation(
input({ series: [{ type, baselineDays, episodeDays }] }),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.adverseCoverageDays).toBe(3);
});
it("does NOT count a WEIGHT-only (no adverse direction) episode toward the floor", () => {
// WEIGHT has no illness-adverse direction. Even with plenty of out-of-band
// WEIGHT days (so raw coverage `historyDays` clears the engine floor), the
// adverse-coverage floor stays 0 — a WEIGHT-only episode must not feed the
// cross-episode typical-gap median.
const type: MeasurementType = "WEIGHT";
const baselineDays = jitteredBaseline(80, 1, 21, "2026-01-02");
const episodeDays: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 90 }, // far out of band, but neutral direction
{ day: "2026-01-11", mean: 91 },
{ day: "2026-01-12", mean: 92 },
{ day: "2026-01-13", mean: 70 }, // out of band the other way — still neutral
{ day: "2026-01-14", mean: 69 },
];
const out = computeIllnessCorrelation(
input({ series: [{ type, baselineDays, episodeDays }] }),
);
// Coverage clears the engine floor (5 banded days) so the engine returns ok…
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.coverage.historyDays).toBeGreaterThanOrEqual(
MIN_EPISODE_COVERAGE_DAYS,
);
// …but NO day counts toward the adverse-coverage qualifying floor.
expect(out.value.adverseCoverageDays).toBe(0);
});
it("ignores a non-adverse-direction move on an adverse-typed vital", () => {
// RHR is up-adverse. A big DROP (below band) is notable but NOT adverse, so
// it must not count toward the floor. Pair with a banded neutral vital to
// clear the engine coverage floor without adding adverse days.
const rhr: MeasurementType = "RESTING_HEART_RATE";
const weight: MeasurementType = "WEIGHT";
const out = computeIllnessCorrelation(
input({
series: [
{
type: rhr,
baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"),
episodeDays: [
{ day: "2026-01-10", mean: 40 }, // far BELOW band — not adverse for RHR
{ day: "2026-01-11", mean: 41 },
{ day: "2026-01-12", mean: 55 },
{ day: "2026-01-13", mean: 55 },
],
},
{
type: weight,
baselineDays: jitteredBaseline(80, 1, 21, "2026-01-02"),
episodeDays: flatBaseline(80, 5, "2026-01-15"),
},
],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.adverseCoverageDays).toBe(0);
});
});
describe("computeIllnessCorrelation — return anchor", () => {
it("never reports a return BEFORE the first deviation (no spurious gap)", () => {
// The vital is IN BAND for the first 4 active days (the run-up), then
@@ -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 }),
+51 -5
View File
@@ -206,6 +206,28 @@ export interface VitalReturnFinding {
returnedDay: string | null;
/** Days from felt-better to physiological return (signed); null if N/A. */
gapDays: number | null;
/** Whether this vital deviated in the illness-adverse direction during the
* active span — a neutral move (e.g. weight drift with no adverse signal)
* still produces a return but must not name the recovery driver. */
adverse: boolean;
}
/**
* Whether a banded reading is the illness-adverse move for its metric — a
* RISE for an "up"-adverse vital (resting HR, temperature, BP, …), a DROP for
* a "down"-adverse one (HRV, recovery, SpO2). The single adverse-direction
* predicate the engine reasons with; reused by the qualifying-days floor so a
* neutral-direction vital (WEIGHT, glucose) never counts a day toward it.
*/
export function isAdverseDeviation(
type: MeasurementType,
direction: "above" | "below",
): boolean {
const adverseDir = ADVERSE_DIRECTION[type];
return (
(adverseDir === "up" && direction === "above") ||
(adverseDir === "down" && direction === "below")
);
}
export interface IllnessCorrelationValue {
@@ -222,6 +244,15 @@ export interface IllnessCorrelationValue {
* = the body lagged the felt-better marker.
*/
recoveryGapDays: number | null;
/**
* Distinct episode days carrying at least one ADVERSE-direction banded
* reading (a vital moving the illness-adverse way for its metric). This is
* the qualifying-days floor the cross-episode aggregate gates the typical-
* gap median on — a WEIGHT-only (no adverse direction) episode contributes
* 0 here even when `coverage.historyDays` (any banded reading) is high, so
* an episode with no illness-relevant signal cannot tip the typical gap.
*/
adverseCoverageDays: number;
/** Felt-better marker echoed for the surface. */
feltBetterDay: string | null;
/**
@@ -279,10 +310,7 @@ function finding(
): VitalDeviationFinding {
const sd = deviationSd(value, center, spread);
const direction: "above" | "below" = sd >= 0 ? "above" : "below";
const adverseDir = ADVERSE_DIRECTION[type];
const adverse =
(adverseDir === "up" && direction === "above") ||
(adverseDir === "down" && direction === "below");
const adverse = isAdverseDeviation(type, direction);
return {
type,
day,
@@ -364,6 +392,11 @@ export function computeIllnessCorrelation(
const preOnset: VitalDeviationFinding[] = [];
const nadir: VitalDeviationFinding[] = [];
const returns: VitalReturnFinding[] = [];
// Distinct active-span days with at least one notable ADVERSE-direction
// banded reading. This — not raw `coverageDays` (any banded reading) — is
// the qualifying-days floor the cross-episode aggregate gates on, so a
// neutral-direction-only episode (e.g. WEIGHT) never feeds the typical gap.
const adverseDayKeys = new Set<string>();
for (const { series, band } of banded) {
const { type } = series;
@@ -392,10 +425,17 @@ export function computeIllnessCorrelation(
// anchor the recovery search must start AFTER (an early in-band run before
// the vital ever deviated is not a "return", it is the run-up).
let firstDeviationIndex = -1;
// Whether THIS vital ever deviated adversely in the active span — gates it
// out of the recovery-driver tally even if it shows a return.
let vitalDeviatedAdversely = false;
active.forEach((p, i) => {
const f = finding(type, p.day, p.mean, center, spread);
if (Math.abs(f.deviationSd) >= NOTABLE_SD) {
if (firstDeviationIndex === -1) firstDeviationIndex = i;
if (f.adverse) {
adverseDayKeys.add(p.day);
vitalDeviatedAdversely = true;
}
if (
worstActive === null ||
Math.abs(f.deviationSd) > Math.abs(worstActive.deviationSd)
@@ -422,7 +462,12 @@ export function computeIllnessCorrelation(
returnedDay && window.feltBetterDay
? dayDiff(window.feltBetterDay, returnedDay)
: null;
returns.push({ type, returnedDay, gapDays });
returns.push({
type,
returnedDay,
gapDays,
adverse: vitalDeviatedAdversely,
});
}
}
@@ -463,6 +508,7 @@ export function computeIllnessCorrelation(
),
returns,
recoveryGapDays,
adverseCoverageDays: adverseDayKeys.size,
feltBetterDay: window.feltBetterDay,
redFlags,
},
+32
View File
@@ -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<number, number>;
/** Per-type episode counts in the window (retrospective breakdown). */
byType: Record<string, number>;
/**
* 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<string, number> = {};
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<string, number> = {};
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,
};
}
@@ -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);
});
});
+55
View File
@@ -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,
};
}
+3 -1
View File
@@ -208,6 +208,7 @@ const illnessCorrelationValue = z
nadir: z.array(illnessVitalDeviation),
returns: z.array(illnessVitalReturn),
recoveryGapDays: z.number().nullable(),
adverseCoverageDays: z.number().int(),
feltBetterDay: z.string().nullable(),
redFlags: z.array(illnessRedFlag),
})
@@ -252,11 +253,12 @@ const illnessInsightsResponse = z
gapSampleSize: z.number().int(),
byMonth: z.record(z.string(), z.number().int()),
byType: z.record(z.string(), z.number().int()),
gapDriverType: z.string().nullable(),
})
.meta({
id: "IllnessInsightsResponse",
description:
"Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical (median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, and a per-type breakdown. Retrospective only.",
"Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical (median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, a per-type breakdown, and the dominant driving vital (`gapDriverType`, null when no gap is surfaced). Retrospective only.",
});
const episodeNotFound = {
+16
View File
@@ -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",