mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
42 files changed
+1177
-284
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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/*`
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user