From 12153fe143d68be1587f28674df3dbd0a7fe4586 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 4 Oct 2026 20:00:07 +0200
Subject: [PATCH 01/33] feat(coach): move conversations into a collapsible
right panel
The Coach page gets a conversations panel on the right of the thread. From
1280 px it docks beside the thread and slides open and shut; the reading
column keeps its width either way and only its centring moves. It is open
on a first visit and remembers the last choice per device. Below 1280 px it
is a sheet from the right. The toggle sits at the trailing edge of the top
bar, through a small page-owned slot the top bar now renders.
The panel lists every conversation grouped by recency, with a row menu to
rename in place (Enter saves, Escape cancels without closing the panel) and
delete with the existing undo toast. The undo window now survives the sheet
closing. New chat is the round button at the panel's bottom right, and
picking or starting a conversation keeps ?c= in the URL.
The gear in the panel header opens quick settings: a popover from md up, a
bottom sheet on a phone. It switches the answering provider (chain reordered
through the existing route), the model and reasoning level where the person
owns them, and shows what the Coach can see. Keys, the Codex sign-in and the
fallback chain stay in Settings > AI. The drawer's gear opens the same
overlay. The composer's + menu is gone.
Settings > Coach links to /coach?settings=data, which opens the overlay on
what the Coach can see instead of a rail that no longer exists.
---
messages/de.json | 21 +-
messages/en.json | 21 +-
messages/es.json | 21 +-
messages/fr.json | 21 +-
messages/it.json | 21 +-
messages/ko.json | 21 +-
messages/pl.json | 21 +-
.../coach-stream-lifecycle-guard.test.ts | 20 +-
src/app/coach/conversations/page.tsx | 56 +--
src/app/coach/page-client.tsx | 23 ++
.../coach-conversation-deeplink.test.tsx | 16 +-
.../__tests__/coach-input.test.tsx | 46 +--
.../__tests__/coach-model-picker.test.tsx | 121 ++++++
.../__tests__/coach-settings-overlay.test.tsx | 117 ++++++
.../__tests__/history-rail.test.tsx | 35 +-
.../coach-panel/coach-conversation.tsx | 188 +++++-----
.../insights/coach-panel/coach-input.tsx | 95 +----
.../coach-panel/coach-model-picker.tsx | 351 ++++++++++++++++++
.../coach-panel/coach-settings-body.tsx | 76 ++++
.../coach-panel/coach-settings-overlay.tsx | 129 +++++++
.../coach-panel/conversation-rename.tsx | 37 +-
.../coach-panel/conversations-panel.tsx | 288 ++++++++++++++
.../insights/coach-panel/history-rail.tsx | 348 ++++++++++-------
.../layout/__tests__/top-bar-actions.test.tsx | 28 ++
src/components/layout/top-bar-actions.tsx | 53 +++
src/components/layout/top-bar.tsx | 7 +
.../coach-links-without-coach.test.tsx | 6 +-
.../settings/coach-prefs-section.tsx | 7 +-
src/components/ui/responsive-sheet.tsx | 10 +
.../__tests__/use-coach-panel-open.test.ts | 57 +++
src/hooks/use-coach-panel-open.ts | 135 +++++++
.../coach-conversation-groups.test.ts | 48 +++
src/lib/insights/coach-conversation-groups.ts | 58 +++
33 files changed, 2074 insertions(+), 428 deletions(-)
create mode 100644 src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx
create mode 100644 src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx
create mode 100644 src/components/insights/coach-panel/coach-model-picker.tsx
create mode 100644 src/components/insights/coach-panel/coach-settings-body.tsx
create mode 100644 src/components/insights/coach-panel/coach-settings-overlay.tsx
create mode 100644 src/components/insights/coach-panel/conversations-panel.tsx
create mode 100644 src/components/layout/__tests__/top-bar-actions.test.tsx
create mode 100644 src/components/layout/top-bar-actions.tsx
create mode 100644 src/hooks/__tests__/use-coach-panel-open.test.ts
create mode 100644 src/hooks/use-coach-panel-open.ts
create mode 100644 src/lib/insights/__tests__/coach-conversation-groups.test.ts
create mode 100644 src/lib/insights/coach-conversation-groups.ts
diff --git a/messages/de.json b/messages/de.json
index 4f69db510..617fb1412 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2943,8 +2943,8 @@
"settingsVerbosityDefault": "Standard",
"settingsVerbosityDetailed": "Ausführlich",
"settingsVerbosityConciseHint": "Der knappe Tonfall hält Antworten ohnehin kurz — die Ausführlichkeit ist dabei fest auf „Kurz“ gesetzt.",
- "settingsSourcesPointer": "Welche Datenquellen der Coach nutzen darf und das Standard-Analysefenster verwaltest du direkt im Coach-Chat (Leiste „Worauf ich zugreife“).",
- "settingsSourcesPointerLink": "Coach öffnen",
+ "settingsSourcesPointer": "Welche Daten der Coach lesen darf und wie weit er zurückblickt, stellst du im Coach selbst ein, hinter dem Zahnrad („Worauf ich zugreife“).",
+ "settingsSourcesPointerLink": "Coach-Einstellungen öffnen",
"settingsExcludeLabel": "Metriken ausschließen",
"settingsExcludeHint": "Der Coach sieht keine Daten der ausgewählten Metriken.",
"settingsContextLabel": "Kontext mitschicken",
@@ -3113,7 +3113,22 @@
"errorNotPermitted": "Der Coach ist in einer Akte, die jemand mit dir teilt, nicht verfügbar.",
"errorUnavailable": "Der Coach konnte seine Einstellungen gerade nicht prüfen. Bitte versuch es gleich noch einmal.",
"errorOperatorOff": "KI ist auf diesem Server abgeschaltet, deshalb kann der Coach nicht antworten.",
- "errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten."
+ "errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten.",
+ "frame": {
+ "showPanel": "Unterhaltungen einblenden",
+ "hidePanel": "Unterhaltungen ausblenden",
+ "panelDescription": "Deine Unterhaltungen mit dem Coach, neueste zuerst.",
+ "rowActions": "Aktionen für {title}",
+ "rename": "Umbenennen",
+ "delete": "Löschen",
+ "modelSection": "Modell",
+ "provider": "Antwortet mit",
+ "modelManaged": "Das Modell für diesen Anbieter legst du in den KI-Einstellungen fest.",
+ "noProvider": "Kein Anbieter verfügbar",
+ "saving": "Wird gespeichert…",
+ "saveFailed": "Die Änderung ließ sich nicht speichern.",
+ "allSettings": "Alle KI-Einstellungen"
+ }
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
diff --git a/messages/en.json b/messages/en.json
index 92d60007b..856fdecd4 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2943,8 +2943,8 @@
"settingsVerbosityDefault": "Default",
"settingsVerbosityDetailed": "Detailed",
"settingsVerbosityConciseHint": "The concise tone already keeps replies short — verbosity is pinned to Brief while it is active.",
- "settingsSourcesPointer": "Which data clusters the coach may draw on and the default analysis window are managed directly in the coach chat (\"What I draw on\" rail).",
- "settingsSourcesPointerLink": "Open the coach",
+ "settingsSourcesPointer": "Which data the Coach may read and how far back it looks are set in the Coach itself, behind the gear (\"What I can see\").",
+ "settingsSourcesPointerLink": "Open Coach settings",
"settingsExcludeLabel": "Exclude metrics",
"settingsExcludeHint": "The Coach will not see data for selected metrics.",
"settingsContextLabel": "Include context",
@@ -3113,7 +3113,22 @@
"errorNotPermitted": "The Coach is not available inside a record someone shared with you.",
"errorUnavailable": "The Coach could not check its settings just now. Please try again in a moment.",
"errorOperatorOff": "AI is switched off on this server, so the Coach cannot answer.",
- "errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules."
+ "errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules.",
+ "frame": {
+ "showPanel": "Show conversations",
+ "hidePanel": "Hide conversations",
+ "panelDescription": "Your conversations with the Coach, newest first.",
+ "rowActions": "Actions for {title}",
+ "rename": "Rename",
+ "delete": "Delete",
+ "modelSection": "Model",
+ "provider": "Answers with",
+ "modelManaged": "The model for this provider is set in AI settings.",
+ "noProvider": "No provider available",
+ "saving": "Saving…",
+ "saveFailed": "That change could not be saved.",
+ "allSettings": "All AI settings"
+ }
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
diff --git a/messages/es.json b/messages/es.json
index 7fc4efbfb..2ff2debd6 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2943,8 +2943,8 @@
"settingsVerbosityDefault": "Estándar",
"settingsVerbosityDetailed": "Detallado",
"settingsVerbosityConciseHint": "El tono conciso ya mantiene las respuestas cortas: la extensión queda fijada en «Breve» mientras esté activo.",
- "settingsSourcesPointer": "Los grupos de datos que el coach puede usar y la ventana de análisis predeterminada se gestionan directamente en el chat del coach (panel «En qué me baso»).",
- "settingsSourcesPointerLink": "Abrir el coach",
+ "settingsSourcesPointer": "Qué datos puede leer el coach y hasta cuándo mira atrás se ajusta en el propio coach, tras el engranaje («A qué accedo»).",
+ "settingsSourcesPointerLink": "Abrir ajustes del coach",
"settingsExcludeLabel": "Excluir métricas",
"settingsExcludeHint": "El coach no verá los datos de las métricas seleccionadas.",
"settingsContextLabel": "Enviar contexto",
@@ -3113,7 +3113,22 @@
"errorNotPermitted": "El Coach no está disponible dentro de un registro que alguien comparte contigo.",
"errorUnavailable": "El Coach no pudo comprobar su configuración ahora. Inténtalo de nuevo en un momento.",
"errorOperatorOff": "La IA está desactivada en este servidor, así que el Coach no puede responder.",
- "errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos."
+ "errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos.",
+ "frame": {
+ "showPanel": "Mostrar conversaciones",
+ "hidePanel": "Ocultar conversaciones",
+ "panelDescription": "Tus conversaciones con el coach, las más recientes primero.",
+ "rowActions": "Acciones para {title}",
+ "rename": "Renombrar",
+ "delete": "Eliminar",
+ "modelSection": "Modelo",
+ "provider": "Responde con",
+ "modelManaged": "El modelo de este proveedor se configura en los ajustes de IA.",
+ "noProvider": "Ningún proveedor disponible",
+ "saving": "Guardando…",
+ "saveFailed": "No se pudo guardar el cambio.",
+ "allSettings": "Todos los ajustes de IA"
+ }
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
diff --git a/messages/fr.json b/messages/fr.json
index 989e0ab17..78bb57638 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2943,8 +2943,8 @@
"settingsVerbosityDefault": "Standard",
"settingsVerbosityDetailed": "Détaillé",
"settingsVerbosityConciseHint": "Le ton concis garde déjà les réponses courtes — la longueur reste fixée sur « Bref » tant qu'il est actif.",
- "settingsSourcesPointer": "Les groupes de données que le coach peut utiliser et la fenêtre d'analyse par défaut se gèrent directement dans le chat du coach (volet « Ce sur quoi je m'appuie »).",
- "settingsSourcesPointerLink": "Ouvrir le coach",
+ "settingsSourcesPointer": "Les données que le coach peut lire et jusqu’où il remonte se règlent dans le coach lui-même, derrière la roue dentée (« Ce à quoi j’accède »).",
+ "settingsSourcesPointerLink": "Ouvrir les paramètres du coach",
"settingsExcludeLabel": "Exclure des métriques",
"settingsExcludeHint": "Le coach ne verra pas les données des métriques sélectionnées.",
"settingsContextLabel": "Envoyer le contexte",
@@ -3113,7 +3113,22 @@
"errorNotPermitted": "Le Coach n'est pas disponible dans un dossier que quelqu'un partage avec vous.",
"errorUnavailable": "Le Coach n'a pas pu vérifier ses réglages pour l'instant. Réessayez dans un instant.",
"errorOperatorOff": "L'IA est désactivée sur ce serveur, le Coach ne peut donc pas répondre.",
- "errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules."
+ "errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules.",
+ "frame": {
+ "showPanel": "Afficher les conversations",
+ "hidePanel": "Masquer les conversations",
+ "panelDescription": "Tes conversations avec le coach, les plus récentes d’abord.",
+ "rowActions": "Actions pour {title}",
+ "rename": "Renommer",
+ "delete": "Supprimer",
+ "modelSection": "Modèle",
+ "provider": "Répond avec",
+ "modelManaged": "Le modèle de ce fournisseur se règle dans les paramètres IA.",
+ "noProvider": "Aucun fournisseur disponible",
+ "saving": "Enregistrement…",
+ "saveFailed": "Impossible d’enregistrer cette modification.",
+ "allSettings": "Tous les paramètres IA"
+ }
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
diff --git a/messages/it.json b/messages/it.json
index b8d636332..17bcdf252 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2943,8 +2943,8 @@
"settingsVerbosityDefault": "Standard",
"settingsVerbosityDetailed": "Dettagliata",
"settingsVerbosityConciseHint": "Il tono conciso mantiene già le risposte brevi: la verbosità resta fissata su «Breve» finché è attivo.",
- "settingsSourcesPointer": "I gruppi di dati che il coach può usare e la finestra di analisi predefinita si gestiscono direttamente nella chat del coach (pannello «Su cosa mi baso»).",
- "settingsSourcesPointerLink": "Apri il coach",
+ "settingsSourcesPointer": "Quali dati il coach può leggere e fin dove guarda indietro si imposta nel coach stesso, dietro l’ingranaggio («A cosa accedo»).",
+ "settingsSourcesPointerLink": "Apri le impostazioni del coach",
"settingsExcludeLabel": "Escludi metriche",
"settingsExcludeHint": "Il coach non vede i dati delle metriche selezionate.",
"settingsContextLabel": "Invia contesto",
@@ -3113,7 +3113,22 @@
"errorNotPermitted": "Il Coach non è disponibile in una cartella che qualcuno condivide con te.",
"errorUnavailable": "Il Coach non è riuscito a controllare le sue impostazioni. Riprova tra un momento.",
"errorOperatorOff": "L'IA è disattivata su questo server, quindi il Coach non può rispondere.",
- "errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli."
+ "errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli.",
+ "frame": {
+ "showPanel": "Mostra conversazioni",
+ "hidePanel": "Nascondi conversazioni",
+ "panelDescription": "Le tue conversazioni con il coach, dalla più recente.",
+ "rowActions": "Azioni per {title}",
+ "rename": "Rinomina",
+ "delete": "Elimina",
+ "modelSection": "Modello",
+ "provider": "Risponde con",
+ "modelManaged": "Il modello di questo fornitore si imposta nelle impostazioni IA.",
+ "noProvider": "Nessun fornitore disponibile",
+ "saving": "Salvataggio…",
+ "saveFailed": "Impossibile salvare la modifica.",
+ "allSettings": "Tutte le impostazioni IA"
+ }
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
diff --git a/messages/ko.json b/messages/ko.json
index 5dc5fadf4..a840c5328 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -2943,8 +2943,8 @@
"settingsVerbosityDefault": "기본",
"settingsVerbosityDetailed": "자세히",
"settingsVerbosityConciseHint": "간결한 말투는 이미 답변을 짧게 유지해요 — 켜 두는 동안 답변 길이는 '짧게'로 고정돼요.",
- "settingsSourcesPointer": "코치가 참고할 데이터 묶음과 기본 분석 기간은 코치 대화의 '내가 참고하는 것' 레일에서 바로 관리해요.",
- "settingsSourcesPointerLink": "코치 열기",
+ "settingsSourcesPointer": "코치가 읽을 수 있는 데이터와 되돌아볼 기간은 코치 안의 톱니바퀴('내가 볼 수 있는 것')에서 정해요.",
+ "settingsSourcesPointerLink": "코치 설정 열기",
"settingsExcludeLabel": "제외할 지표",
"settingsExcludeHint": "선택한 지표의 데이터는 코치가 보지 않아요.",
"settingsContextLabel": "포함할 맥락",
@@ -3113,7 +3113,22 @@
"errorNotPermitted": "다른 사람이 공유한 기록 안에서는 코치를 사용할 수 없습니다.",
"errorUnavailable": "코치가 지금 설정을 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.",
"errorOperatorOff": "이 서버에서 AI가 꺼져 있어 코치가 답변할 수 없습니다.",
- "errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다."
+ "errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다.",
+ "frame": {
+ "showPanel": "대화 목록 보기",
+ "hidePanel": "대화 목록 숨기기",
+ "panelDescription": "코치와 나눈 대화, 최신순이에요.",
+ "rowActions": "{title} 작업",
+ "rename": "이름 바꾸기",
+ "delete": "삭제",
+ "modelSection": "모델",
+ "provider": "응답 제공자",
+ "modelManaged": "이 제공자의 모델은 AI 설정에서 정해요.",
+ "noProvider": "사용할 수 있는 제공자가 없어요",
+ "saving": "저장 중…",
+ "saveFailed": "변경 사항을 저장하지 못했어요.",
+ "allSettings": "모든 AI 설정"
+ }
},
"relativeJustNow": "방금",
"relativeMinutesAgoOne": "{count}분 전",
diff --git a/messages/pl.json b/messages/pl.json
index b967ecdc4..12af86e9f 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2943,8 +2943,8 @@
"settingsVerbosityDefault": "Standardowa",
"settingsVerbosityDetailed": "Szczegółowa",
"settingsVerbosityConciseHint": "Zwięzły ton i tak skraca odpowiedzi — szczegółowość pozostaje ustawiona na „Krótko”, dopóki jest aktywny.",
- "settingsSourcesPointer": "Tym, z jakich grup danych może korzystać coach, oraz domyślnym oknem analizy zarządzasz bezpośrednio w czacie coacha (panel „Z czego korzystam”).",
- "settingsSourcesPointerLink": "Otwórz coacha",
+ "settingsSourcesPointer": "To, jakie dane coach może czytać i jak daleko wstecz patrzy, ustawisz w samym coachu, pod kołem zębatym („Do czego mam dostęp”).",
+ "settingsSourcesPointerLink": "Otwórz ustawienia coacha",
"settingsExcludeLabel": "Wyklucz metryki",
"settingsExcludeHint": "Coach nie zobaczy danych wybranych metryk.",
"settingsContextLabel": "Wysyłaj kontekst",
@@ -3113,7 +3113,22 @@
"errorNotPermitted": "Coach nie jest dostępny w kartotece, którą ktoś Ci udostępnił.",
"errorUnavailable": "Coach nie mógł teraz sprawdzić swoich ustawień. Spróbuj ponownie za chwilę.",
"errorOperatorOff": "AI jest wyłączone na tym serwerze, więc Coach nie może odpowiedzieć.",
- "errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły."
+ "errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły.",
+ "frame": {
+ "showPanel": "Pokaż rozmowy",
+ "hidePanel": "Ukryj rozmowy",
+ "panelDescription": "Twoje rozmowy z coachem, od najnowszej.",
+ "rowActions": "Działania dla {title}",
+ "rename": "Zmień nazwę",
+ "delete": "Usuń",
+ "modelSection": "Model",
+ "provider": "Odpowiada przez",
+ "modelManaged": "Model tego dostawcy ustawisz w ustawieniach AI.",
+ "noProvider": "Brak dostępnego dostawcy",
+ "saving": "Zapisywanie…",
+ "saveFailed": "Nie udało się zapisać zmiany.",
+ "allSettings": "Wszystkie ustawienia AI"
+ }
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
diff --git a/src/__tests__/coach-stream-lifecycle-guard.test.ts b/src/__tests__/coach-stream-lifecycle-guard.test.ts
index 466a34f93..7cb5fb0b7 100644
--- a/src/__tests__/coach-stream-lifecycle-guard.test.ts
+++ b/src/__tests__/coach-stream-lifecycle-guard.test.ts
@@ -63,18 +63,26 @@ describe("Coach streaming lifecycle", () => {
"components/insights/coach-panel/coach-conversation.tsx",
);
- // Isolate the HistoryRail onSelect callback body.
+ // One switch handler, shared by the drawer's history tray and the page's
+ // conversations panel, so neither surface can skip the reset.
const match = source.match(
- / {
diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx
index 457bf92e0..d42ffa50f 100644
--- a/src/app/coach/conversations/page.tsx
+++ b/src/app/coach/conversations/page.tsx
@@ -24,8 +24,8 @@ import {
useCoachConversationHistory,
useDeleteCoachConversationWithUndo,
} from "@/components/insights/coach-panel/use-coach";
-import type { CoachConversationDTO } from "@/lib/ai/coach/types";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
+import { groupConversationsByRecency } from "@/lib/insights/coach-conversation-groups";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useAiCapabilityAnswer } from "@/hooks/use-ai-capability";
import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel";
@@ -58,58 +58,6 @@ import { cn } from "@/lib/utils";
* wrapper matches the Coach page so the two surfaces share one chrome.
*/
-type RecencyGroupId = "today" | "yesterday" | "thisWeek" | "earlier";
-
-interface RecencyGroup {
- id: RecencyGroupId;
- labelKey: string;
- conversations: CoachConversationDTO[];
-}
-
-/**
- * Bucket conversations by LOCAL calendar recency. `updatedAt` is an ISO
- * string; comparisons run against local day boundaries so "Today" tracks
- * the user's own midnight, not UTC.
- */
-function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] {
- const now = new Date();
- const startOfToday = new Date(
- now.getFullYear(),
- now.getMonth(),
- now.getDate(),
- ).getTime();
- const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
- // "This week" = the six days before yesterday (a rolling 7-day window
- // that already excludes today + yesterday, which have their own groups).
- const startOfWeek = startOfToday - 6 * 24 * 60 * 60 * 1000;
-
- const groups: Record = {
- today: [],
- yesterday: [],
- thisWeek: [],
- earlier: [],
- };
-
- for (const conversation of conversations) {
- const updated = new Date(conversation.updatedAt).getTime();
- if (updated >= startOfToday) groups.today.push(conversation);
- else if (updated >= startOfYesterday) groups.yesterday.push(conversation);
- else if (updated >= startOfWeek) groups.thisWeek.push(conversation);
- else groups.earlier.push(conversation);
- }
-
- return (
- [
- { id: "today", labelKey: "insights.coach.history.groupToday" },
- { id: "yesterday", labelKey: "insights.coach.history.groupYesterday" },
- { id: "thisWeek", labelKey: "insights.coach.history.groupThisWeek" },
- { id: "earlier", labelKey: "insights.coach.history.groupEarlier" },
- ] as const
- )
- .map((g) => ({ ...g, conversations: groups[g.id] }))
- .filter((g) => g.conversations.length > 0);
-}
-
function CoachConversationsBody({ readOnly }: { readOnly: boolean }) {
const { t, locale } = useTranslations();
const [openId, setOpenId] = useState(null);
@@ -139,7 +87,7 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) {
);
const isSearching = debouncedFilter.trim().length > 0;
- const groups = useMemo(() => groupByRecency(visible), [visible]);
+ const groups = useMemo(() => groupConversationsByRecency(visible), [visible]);
// Same callback-ref pattern as the rail: a `useState` setter (not a plain
// `useRef`) so the sentinel hook's `root` re-fires its effect once the
diff --git a/src/app/coach/page-client.tsx b/src/app/coach/page-client.tsx
index 6ac9fbfc9..f22b463d5 100644
--- a/src/app/coach/page-client.tsx
+++ b/src/app/coach/page-client.tsx
@@ -81,6 +81,13 @@ import { coachScopeSourceSchema } from "@/lib/ai/coach/types";
* this is a prefill, not an auto-send.
* Both are ignored once `?c=` or `?doc=` pin an existing/scoped thread — scope
* only ever applies to a fresh conversation's first turn.
+ *
+ * The conversations panel on the right of the thread carries the history,
+ * New chat and the settings gear (the composer's `+` menu is gone). Picking a
+ * conversation or starting a new one rewrites `?c=` in place, so a reload
+ * stays on the open thread. `?settings=data` (the link from Settings → Coach)
+ * opens the gear on "What I can see"; the param is consumed on mount and
+ * dropped from the URL so a reload does not reopen the overlay.
*/
function CoachPageBody() {
const searchParams = useSearchParams();
@@ -118,6 +125,21 @@ function CoachPageBody() {
: null;
const seedPrefill = freshChat ? searchParams.get("ask") : null;
+ // Read once: the panel consumes it on mount, then the URL loses it.
+ const [openSettingsOnData] = useState(
+ () => searchParams.get("settings") === "data",
+ );
+ useEffect(() => {
+ if (!openSettingsOnData) return;
+ const url = new URL(window.location.href);
+ url.searchParams.delete("settings");
+ window.history.replaceState(
+ window.history.state,
+ "",
+ url.pathname + url.search,
+ );
+ }, [openSettingsOnData]);
+
// #781 — a navigation away mid-reply recorded the interrupted conversation
// (see `COACH_INTERRUPTED_STORAGE_KEY` in `use-coach.ts`). Returning to the
// Coach reopens that thread — where the persisted `cancelled` marker shows
@@ -200,6 +222,7 @@ function CoachPageBody() {
// unread coach-initiated exception and the interrupted first-turn
// resume (#781, whose conversation id never reached the client).
autoOpenMostRecent={hasUnreadCoachMessage || resumeInterruptedLatest}
+ openSettingsOnData={openSettingsOnData}
/>
);
}
diff --git a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx
index 3cd40e333..21626c678 100644
--- a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx
@@ -158,16 +158,16 @@ describe(" page deep-link (#67)", () => {
expect(html).toContain('data-slot="coach-hero"');
});
- // v1.21.4 (A) — the page-toolbar gear was removed; Settings now lives in the
- // composer's `+` actions menu. The page chrome is the composer alone, so the
- // surface exposes the `+` actions trigger and no standalone toolbar gear.
- it("drops the page-toolbar gear and exposes the composer + actions menu", () => {
+ // The page carries no toolbar and the composer no `+` menu: New chat, the
+ // conversation list and the settings gear live in the conversations panel.
+ // On the server the panel is closed (it docks only once the client knows
+ // the viewport), so the SSR markup holds the conversation column alone.
+ it("renders the conversation column with no toolbar and no composer menu", () => {
const html = render(, makeClient());
- // The old page toolbar + its gear are gone.
+ expect(html).toContain('data-slot="coach-page-main"');
expect(html).not.toContain('data-slot="coach-page-settings"');
expect(html).not.toContain('data-slot="coach-page-toolbar"');
- // Settings now lives behind the composer's `+` actions menu (the menu
- // content is portalled open-on-demand, so SSR shows only the trigger).
- expect(html).toContain('data-slot="coach-input-actions"');
+ expect(html).not.toContain('data-slot="coach-input-actions"');
+ expect(html).not.toContain('href="/coach/conversations"');
});
});
diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx
index 99e93bcea..d6f200bf4 100644
--- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx
@@ -229,10 +229,9 @@ describe("", () => {
expect(handler).toHaveBeenCalledWith("typed");
});
- it("omits the control-hub action row by default (drawer composer)", () => {
- // v1.18.11 (W11) — the hub is page-only; without `showHub` the composer
- // stays the single-row drawer layout (mic + send on one baseline) and
- // grows no actions menu or settings deep-link.
+ it("renders the single-row composer with no actions menu", () => {
+ // The composer carries text, attach and send only; it grows no actions
+ // menu or settings deep-link on either surface.
const html = render(
{}} onSubmit={() => {}} />,
);
@@ -251,26 +250,25 @@ describe("", () => {
value=""
onChange={() => {}}
onSubmit={() => {}}
- showHub
- onNewChat={() => {}}
- onOpenHistory={() => {}}
+ attachEnabled
+ onPickFromVault={() => {}}
+ onUploadNew={() => {}}
/>,
);
expect(html).toContain('data-slot="coach-input-controls"');
expect(html).toContain('data-slot="coach-input-leading"');
- expect(html).toContain('data-slot="coach-input-actions"');
- expect(html).not.toContain('data-slot="coach-input-settings"');
+ expect(html).toContain('data-slot="coach-input-attach"');
expect(html).not.toContain('data-slot="coach-input-mic"');
expect(html).toContain('data-slot="coach-input-send"');
// DOM and visual order agree at every breakpoint: the textarea comes first,
- // followed by the leading actions and then Send.
+ // followed by the leading attach control and then Send.
const textareaIdx = html.indexOf('data-slot="coach-input-textarea"');
- const plusIdx = html.indexOf('data-slot="coach-input-actions"');
+ const attachIdx = html.indexOf('data-slot="coach-input-attach"');
const sendIdx = html.indexOf('data-slot="coach-input-send"');
expect(textareaIdx).toBeGreaterThan(-1);
- expect(textareaIdx).toBeLessThan(plusIdx);
- expect(plusIdx).toBeLessThan(sendIdx);
+ expect(textareaIdx).toBeLessThan(attachIdx);
+ expect(attachIdx).toBeLessThan(sendIdx);
const controls = html.match(
/
]*data-slot="coach-input-controls"[^>]*>/,
@@ -295,22 +293,28 @@ describe("", () => {
expect(controls?.[0]).toMatch(/\bself-end\b/);
});
- it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => {
+ it("carries no conversation menu: New chat, Conversations and Settings live in the page's panel", () => {
const html = render(
{}}
onSubmit={() => {}}
- showHub
- onNewChat={() => {}}
- onOpenHistory={() => {}}
+ attachEnabled
+ onPickFromVault={() => {}}
+ onUploadNew={() => {}}
/>,
);
- const actions = html.match(
- /
) : null;
- // v1.18.11 (W11) — the PAGE surface drops the top header bar and the
- // rail-tray strip entirely. The composer is the single control hub; the
- // thread (`[&>*]:max-w-2xl` inner gutter) and the docked composer
- // (`mx-auto max-w-2xl`) share ONE centred, max-width-capped column so the
- // composer never changes width between the new-chat hero and an active
- // conversation. The drawer surface keeps its own header + body chrome
- // (handled below) untouched.
+ // The PAGE surface has no header bar of its own. Conversations, New chat
+ // and the settings gear live in the conversations panel on the right; its
+ // toggle sits in the app's top bar. The drawer surface keeps its own header
+ // + body chrome (handled below).
if (surface === "page") {
return (
- {docScopeBanner}
- {!heroActive ?
{title}
: null}
- {/* v1.21.4 (A) — the page-toolbar gear was removed; Settings now lives
- in the composer's `+` actions menu alongside New chat and
- Conversations, keeping the page chrome to the composer alone. */}
- {heroActive ? (
-
- ) : (
- <>
-
-
-
- {/* Docked composer — the SAME centred, capped column as the
- thread (and the hero composer), so the width is constant
- across the new-chat → conversation transition. */}
-
-
{composerStack}
-
- >
- )}
+ {/* The conversation column. Its thread and composer keep one centred,
+ capped column (`max-w-2xl`) across the new-chat hero and an active
+ conversation, and that width does not change when the panel
+ beside it opens or closes: only the centring moves. */}
+
+ {docScopeBanner}
+ {!heroActive ?
{title}
: null}
+ {heroActive ? (
+
+ ) : (
+ <>
+
+
+
+
+
{composerStack}
+
+ >
+ )}
+
+
{attachEnabled ? (
- {/* v1.16.1 — the Coach preferences moved into Settings → AI
- (one place for model + behaviour). The header keeps a gear
- that deep-links there instead of opening an in-chat sheet. */}
-
-
-
-
-
+ {/* The same quick settings the page's panel opens: model and what
+ the Coach may read. Everything else stays in Settings → AI. */}
+
{trailingHeaderActions}
@@ -1299,22 +1336,7 @@ export function CoachConversation({
historyRail={
{
- setCurrentConversationId(id);
- setHistoryTrayOpen(false);
- setPendingAdopt(null);
- // v1.29.x (S7) — the selected thread's own `fenced` flag +
- // `attachments` are now authoritative; drop any staged `?doc=`
- // seed so a pending attachment can't leak across a thread switch.
- setPendingAttachmentIds([]);
- dispatchGuided({ type: "RESET" });
- // Drop the finished stream's trailing state, as `handleNewChat`
- // already does. Without it the previous thread's last assistant
- // or error bubble renders appended to the newly selected thread
- // — `streamingActive` stays true because that messageId is
- // absent from the new thread's messages — until the next send.
- send.reset();
- }}
+ onSelect={selectConversation}
/>
}
sourcesOpen={sourcesTrayOpen}
diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx
index 6d7680a32..6784290d6 100644
--- a/src/components/insights/coach-panel/coach-input.tsx
+++ b/src/components/insights/coach-panel/coach-input.tsx
@@ -7,17 +7,12 @@ import {
useEffect,
useRef,
} from "react";
-import Link from "next/link";
import {
FolderOpen,
Loader2,
- MessagesSquare,
Paperclip,
- Plus,
Send,
- Settings,
Square,
- Target,
Upload,
} from "lucide-react";
@@ -55,10 +50,8 @@ import { COACH_COMPOSER_ID } from "./composer-focus";
* the guard stack (system prompt, prose-grounding check, outbound
* refusal), not a rendered disclaimer line.
*
- * v1.18.11 (W11): the composer is the conversation's control hub on the
- * full-page Coach surface. When `showHub` is set it includes a `+` actions menu
- * for a new chat or conversation history. The drawer omits `showHub` because
- * its header already carries those actions.
+ * New chat, the conversation list and the settings gear live in the Coach
+ * page's conversations panel (and the drawer's header), not in the composer.
*
* Voice input was removed after repeated browser and permission failures made
* the control unreliable. The composer now exposes only actions that work
@@ -95,28 +88,12 @@ export interface CoachInputProps {
* while a question is live. Falls back to the stock composer copy.
*/
placeholder?: string;
- /**
- * v1.18.11 — mount the control-hub action row (leading `+` menu +
- * settings link) inside the composer card. The page surface sets this;
- * the drawer leaves it off and keeps the single-row composer.
- */
- showHub?: boolean;
- /**
- * v1.18.11 — start a fresh conversation. Wired into the `+` actions
- * menu. Required when `showHub` is set.
- */
- onNewChat?: () => void;
- /**
- * v1.18.11 — open the left conversation-history drawer. Wired into the
- * `+` actions menu. Required when `showHub` is set.
- */
- onOpenHistory?: () => void;
/**
* v1.29.x (S7) — mount the document-attach trigger (a leading paperclip menu:
* "Choose from documents" + "Upload new document"). Set by the parent only
* when the `inboundDocuments` module is enabled for the user; hidden entirely
- * otherwise. Separate from the `showHub` `+` actions menu — this affordance
- * stages fenced document attachments, it does not manage conversations.
+ * otherwise. It stages fenced document attachments; conversations are managed
+ * in the page's conversations panel.
*/
attachEnabled?: boolean;
/**
@@ -177,9 +154,6 @@ export function CoachInput({
inputId = COACH_COMPOSER_ID,
autoFocusOnOpen = false,
placeholder,
- showHub = false,
- onNewChat,
- onOpenHistory,
attachEnabled = false,
onPickFromVault,
onUploadNew,
@@ -293,61 +267,10 @@ export function CoachInput({
);
- // Leading `+` actions menu for the full-page Coach surface. The settings
- // shortcut remains in this menu while the dedicated gear stays in the page
- // toolbar.
- const actionsButton = (
-
-
-
-
-
-
-
- onNewChat?.()}
- >
-
- {t("insights.coach.newChat")}
-
- onOpenHistory?.()}
- >
-
- {t("insights.coach.historyTitle")}
-
-
-
-
- {t("coach.plans.title")}
-
-
-
-
-
- {t("insights.coach.settings")}
-
-
-
-
- );
-
// v1.29.x (S7) — the document-attach affordance: a leading paperclip menu
// ("Choose from documents" opens the vault picker; "Upload new document"
// opens the file input, reusing the existing `/api/documents/inbound`
- // pipeline). Rendered only when the parent enables it (module-gated). It is a
- // SEPARATE control from the `showHub` `+` actions menu — the two never merge.
+ // pipeline). Rendered only when the parent enables it (module-gated).
const attachButton = attachEnabled ? (
<>
{attachButton}
- {showHub ? actionsButton : null}
-
-
- {sendButton}
+
{sendButton}
diff --git a/src/components/insights/coach-panel/coach-model-picker.tsx b/src/components/insights/coach-panel/coach-model-picker.tsx
new file mode 100644
index 000000000..079d99c96
--- /dev/null
+++ b/src/components/insights/coach-panel/coach-model-picker.tsx
@@ -0,0 +1,351 @@
+"use client";
+
+import { useState } from "react";
+import { Loader2 } from "lucide-react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { NativeSelect } from "@/components/ui/native-select";
+import { ReasoningEffortField } from "@/components/settings/ai/reasoning-effort-field";
+import {
+ ANTHROPIC_MODEL_PRESETS,
+ LOCAL_MODEL_PRESETS,
+ OPENAI_MODEL_PRESETS,
+ uiToLegacyProviderEnum,
+ type ProviderChainData,
+ type ProviderType,
+ type ReasoningEffort,
+ type UserAIProvider,
+} from "@/components/settings/ai/shared";
+import { apiGet, apiPatch, apiPut } from "@/lib/api/api-fetch";
+import { useTranslations } from "@/lib/i18n/context";
+import {
+ aiInputDependentKeys,
+ invalidateKeys,
+ queryKeys,
+} from "@/lib/query-keys";
+
+/**
+ * The quick model choice in the Coach's settings overlay: which provider
+ * answers, which model it uses, and how hard a reasoning model thinks.
+ *
+ * It is a shortcut onto the same settings Settings → AI writes, never a second
+ * copy of them. Keys, base URLs, the Codex sign-in and the order of the
+ * fallback chain stay there; this reads the same two queries and writes
+ * through the same two routes:
+ * - Provider: `PUT /api/insights/provider-chain` with the chosen entry moved
+ * to the front and every other entry in its current order. The entries'
+ * reasoning settings are not sent, so the server keeps what it stored.
+ * - Model and reasoning: `PATCH /api/user/ai-provider`, only where a person
+ * owns the setting (their own key's provider, or their gateway). The
+ * operator's shared provider and Codex show their state read-only.
+ * Every write invalidates the AI input keys, so the capability on
+ * `/api/auth/me` comes back from the server rather than being guessed here.
+ */
+
+/** The configured chain with `provider` first; everything else keeps order. */
+export function chainWithFirst(
+ chain: ProviderChainData["configuredChain"],
+ provider: ProviderType,
+): { providerType: ProviderType; priority: number; enabled: boolean }[] {
+ const rest = chain.filter((entry) => entry.providerType !== provider);
+ const ordered = [
+ { providerType: provider, enabled: true },
+ ...rest.map((entry) => ({
+ providerType: entry.providerType,
+ enabled: entry.enabled,
+ })),
+ ];
+ return ordered.map((entry, idx) => ({ ...entry, priority: idx + 1 }));
+}
+
+/** The providers a person can switch to: enabled and able to answer. */
+export function selectableProviders(
+ chain: ProviderChainData["configuredChain"],
+): ProviderType[] {
+ return chain
+ .filter((entry) => entry.enabled && entry.available)
+ .map((entry) => entry.providerType);
+}
+
+export type ModelControl =
+ | { kind: "preset"; presets: readonly string[]; value: string | null }
+ | { kind: "gateway"; value: string | null }
+ | { kind: "managed" };
+
+const PRESETS: Partial> = {
+ openai: OPENAI_MODEL_PRESETS,
+ anthropic: ANTHROPIC_MODEL_PRESETS,
+ local: LOCAL_MODEL_PRESETS,
+};
+
+/**
+ * Which model control the active provider gets. The model column is shared by
+ * the OpenAI, Anthropic and Local providers and belongs to whichever of them
+ * the person configured, so the control is offered only for that one; a
+ * model name chosen for another would be sent to the wrong vendor.
+ */
+export function modelControlFor(
+ active: ProviderType | null,
+ userProvider: UserAIProvider | null | undefined,
+): ModelControl {
+ if (!active || !userProvider) return { kind: "managed" };
+ if (active === "openai-compatible") {
+ return { kind: "gateway", value: userProvider.compatModel };
+ }
+ const presets = PRESETS[active];
+ if (presets && userProvider.provider === uiToLegacyProviderEnum(active)) {
+ return { kind: "preset", presets, value: userProvider.model };
+ }
+ return { kind: "managed" };
+}
+
+/** The reasoning setting the active provider carries, if any. */
+export function reasoningFieldFor(
+ active: ProviderType | null,
+ userProvider: UserAIProvider | null | undefined,
+): {
+ field: "localReasoningEffort" | "compatReasoningEffort";
+ value: ReasoningEffort | null;
+} | null {
+ if (active === "local") {
+ return {
+ field: "localReasoningEffort",
+ value: userProvider?.localReasoningEffort ?? null,
+ };
+ }
+ if (active === "openai-compatible") {
+ return {
+ field: "compatReasoningEffort",
+ value: userProvider?.compatReasoningEffort ?? null,
+ };
+ }
+ return null;
+}
+
+export function CoachModelPicker() {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+
+ const chainQuery = useQuery({
+ queryKey: queryKeys.insightsProviderChain(),
+ queryFn: () => apiGet("/api/insights/provider-chain"),
+ });
+ const providerQuery = useQuery({
+ queryKey: queryKeys.userAiProvider(),
+ queryFn: () => apiGet("/api/user/ai-provider"),
+ });
+
+ const [error, setError] = useState(null);
+
+ const onSaved = () => {
+ setError(null);
+ void invalidateKeys(queryClient, aiInputDependentKeys);
+ };
+ const onFailed = (e: unknown) =>
+ setError(
+ e instanceof Error && e.message
+ ? e.message
+ : t("insights.coach.frame.saveFailed"),
+ );
+
+ const switchProvider = useMutation({
+ mutationFn: (provider: ProviderType) =>
+ apiPut("/api/insights/provider-chain", {
+ chain: chainWithFirst(chainQuery.data?.configuredChain ?? [], provider),
+ }),
+ onSuccess: onSaved,
+ onError: onFailed,
+ });
+ const patchProvider = useMutation({
+ mutationFn: (body: Record) =>
+ apiPatch("/api/user/ai-provider", body),
+ onSuccess: onSaved,
+ onError: onFailed,
+ });
+
+ const chain = chainQuery.data;
+ const userProvider = providerQuery.data;
+ const active = chain?.activeProvider ?? null;
+ const options = chain ? selectableProviders(chain.configuredChain) : [];
+ const model = modelControlFor(active, userProvider);
+ const reasoning = reasoningFieldFor(active, userProvider);
+ const busy = switchProvider.isPending || patchProvider.isPending;
+
+ if (chainQuery.isError || providerQuery.isError) {
+ return (
+
+ );
+}
+
+/** A gateway names its own models, so its model is free text with a save. */
+function GatewayModelField({
+ value,
+ disabled,
+ onSave,
+}: {
+ value: string | null;
+ disabled: boolean;
+ onSave: (next: string | null) => void;
+}) {
+ const { t } = useTranslations();
+ const [draft, setDraft] = useState(value ?? "");
+ const changed = draft.trim() !== (value ?? "");
+ return (
+
+ );
+}
diff --git a/src/components/insights/coach-panel/coach-settings-body.tsx b/src/components/insights/coach-panel/coach-settings-body.tsx
new file mode 100644
index 000000000..2a844cbb9
--- /dev/null
+++ b/src/components/insights/coach-panel/coach-settings-body.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import Link from "next/link";
+import { Cpu } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { useTranslations } from "@/lib/i18n/context";
+
+import { CoachModelPicker } from "./coach-model-picker";
+import { SourcesRail } from "./sources-rail";
+
+/**
+ * The content of the Coach's quick settings (see `coach-settings-overlay`):
+ * the model choice, "What I can see" (the existing sources rail, wired to the
+ * saved Coach preferences: data areas and how far back the Coach looks) and
+ * a way on to the full AI settings.
+ *
+ * `focusData` lands focus on the "What I can see" section when the body
+ * mounts, for the `/coach?settings=data` deep link. It runs here rather than
+ * in the overlay because the body loads on demand and may arrive after the
+ * popover or sheet has already placed focus.
+ */
+export const COACH_SETTINGS_DATA_SECTION_ID = "coach-settings-data";
+
+export function CoachSettingsBody({
+ focusData = false,
+}: {
+ focusData?: boolean;
+}) {
+ const { t } = useTranslations();
+ const dataRef = useRef(null);
+ useEffect(() => {
+ if (!focusData) return;
+ const frame = requestAnimationFrame(() => {
+ dataRef.current?.focus();
+ dataRef.current?.scrollIntoView({ block: "start" });
+ });
+ return () => cancelAnimationFrame(frame);
+ }, [focusData]);
+ return (
+
+ );
+}
diff --git a/src/components/insights/coach-panel/coach-settings-overlay.tsx b/src/components/insights/coach-panel/coach-settings-overlay.tsx
new file mode 100644
index 000000000..1a0d0a12a
--- /dev/null
+++ b/src/components/insights/coach-panel/coach-settings-overlay.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+import { useRef } from "react";
+import dynamic from "next/dynamic";
+import { Loader2, Settings } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
+import { useIsMobile } from "@/hooks/use-is-mobile";
+import { useTranslations } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+
+// The body (model picker, the sources rail, their queries) loads when the
+// gear is first opened, as one shared chunk, instead of riding along with
+// every route that can mount the Coach drawer.
+const CoachSettingsBody = dynamic(
+ () => import("./coach-settings-body").then((m) => m.CoachSettingsBody),
+ { ssr: false, loading: () => },
+);
+
+function BodyLoading() {
+ return (
+
+
+
+ );
+}
+
+/**
+ * The Coach's quick settings, behind the gear: which model answers, and what
+ * the Coach may read.
+ *
+ * A popover anchored on the gear from `md` up, a bottom sheet on a phone.
+ * Both trap focus, close on Escape and hand focus back to the gear. Nothing
+ * in here does model work; the provider write invalidates the AI input keys
+ * and the capability comes back from the server.
+ *
+ * `focusData` opens it on the "What I can see" section: Settings → Coach
+ * links here with `/coach?settings=data`.
+ */
+export interface CoachSettingsOverlayProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Open on the "What I can see" section instead of the top. */
+ focusData?: boolean;
+ /** Classes for the gear button, so each header sizes it like its row. */
+ className?: string;
+}
+
+export function CoachSettingsOverlay({
+ open,
+ onOpenChange,
+ focusData = false,
+ className,
+}: CoachSettingsOverlayProps) {
+ const { t } = useTranslations();
+ const isPhone = useIsMobile("md");
+ const gearRef = useRef(null);
+
+ const gear = (
+ onOpenChange(true) : undefined}
+ className={cn(
+ "text-muted-foreground hover:text-foreground size-11 shrink-0 sm:size-9",
+ className,
+ )}
+ >
+
+
+ );
+
+ if (isPhone) {
+ return (
+ <>
+ {gear}
+ {
+ event.preventDefault();
+ gearRef.current?.focus();
+ }}
+ >
+
+
+ >
+ );
+ }
+
+ return (
+
+ {gear}
+ {
+ // The body moves focus to its data section once it has loaded.
+ if (focusData) event.preventDefault();
+ }}
+ >
+
+ {t("insights.coach.settingsAriaLabel")}
+
+
+
+
+ );
+}
diff --git a/src/components/insights/coach-panel/conversation-rename.tsx b/src/components/insights/coach-panel/conversation-rename.tsx
index a45e123c6..a55664613 100644
--- a/src/components/insights/coach-panel/conversation-rename.tsx
+++ b/src/components/insights/coach-panel/conversation-rename.tsx
@@ -25,18 +25,39 @@ export function getConversationRenameKeyAction(
export interface ConversationRenameProps {
id: string;
title: string;
- compact?: boolean;
+ /**
+ * Controlled mode, for a surface that opens the editor from somewhere else
+ * (the conversations panel's row menu). When set, the component renders no
+ * trigger of its own: it shows the form while `editing` is true and reports
+ * the end of an edit (saved or cancelled) through `onEditingChange`.
+ */
+ editing?: boolean;
+ onEditingChange?: (editing: boolean) => void;
}
export function ConversationRename({
id,
title,
- compact = false,
+ editing: controlledEditing,
+ onEditingChange,
}: ConversationRenameProps) {
const { t } = useTranslations();
const rename = useRenameCoachConversation();
- const [editing, setEditing] = useState(false);
+ const controlled = controlledEditing !== undefined;
+ const [localEditing, setLocalEditing] = useState(false);
+ const editing = controlled ? controlledEditing : localEditing;
const [draft, setDraft] = useState(title);
+ // A controlled editor opens from outside, so it seeds its draft from the
+ // current title each time it opens (render-phase, no effect).
+ const [seededOpen, setSeededOpen] = useState(false);
+ if (controlled && editing !== seededOpen) {
+ setSeededOpen(editing);
+ if (editing) setDraft(title);
+ }
+ function setEditing(next: boolean) {
+ if (controlled) onEditingChange?.(next);
+ else setLocalEditing(next);
+ }
const submittingRef = useRef(false);
function cancel() {
@@ -82,23 +103,27 @@ export function ConversationRename({
const action = getConversationRenameKeyAction(event.key);
if (!action) return;
event.preventDefault();
+ // Escape belongs to the editor while it is open: the panel and the sheet
+ // around it must not close on the same key press.
+ event.stopPropagation();
if (action === "cancel") cancel();
else void save();
}
if (!editing) {
+ if (controlled) return null;
return (
{
setDraft(title);
setEditing(true);
}}
aria-label={t("insights.coach.rename.action")}
data-slot="coach-conversation-rename"
- className={cn("shrink-0", compact && "size-11")}
+ className="shrink-0"
>
@@ -115,7 +140,7 @@ export function ConversationRename({
data-slot="coach-conversation-rename-form"
className={cn(
"bg-background absolute inset-y-1 left-1 z-10 flex min-w-0 items-center gap-1",
- compact ? "right-14" : "right-16",
+ controlled ? "right-1" : "right-16",
)}
>
void;
+ onNewChat: () => void;
+ /**
+ * `/coach?settings=data`: reveal the panel and open the settings gear on
+ * the "What I can see" section. Read once, on mount.
+ */
+ openSettingsOnData?: boolean;
+}
+
+export function ConversationsPanel({
+ activeId,
+ onSelect,
+ onNewChat,
+ openSettingsOnData = false,
+}: ConversationsPanelProps) {
+ const { t } = useTranslations();
+ const { docked, open: rememberedOpen, setOpen } = useCoachPanelOpen();
+ const toggleRef = useRef(null);
+ // Owned here, not by the list, so a pending delete keeps its undo window
+ // when the sheet (and the list inside it) closes.
+ const deletion = useDeleteCoachConversationWithUndo();
+
+ // A settings deep-link shows the panel without rewriting the remembered
+ // choice; the next toggle settles it.
+ const [revealed, setRevealed] = useState(openSettingsOnData);
+ const [sheetOpen, setSheetOpen] = useState(openSettingsOnData);
+ const [settingsOpen, setSettingsOpen] = useState(openSettingsOnData);
+ const [settingsOnData, setSettingsOnData] = useState(openSettingsOnData);
+
+ const dockedOpen = rememberedOpen || revealed;
+ const expanded = docked ? dockedOpen : sheetOpen;
+
+ function toggle() {
+ if (docked) {
+ setRevealed(false);
+ setOpen(!dockedOpen);
+ } else {
+ setSheetOpen(!sheetOpen);
+ }
+ }
+
+ function closeDocked() {
+ setRevealed(false);
+ setOpen(false);
+ toggleRef.current?.focus();
+ }
+
+ function afterPick() {
+ if (!docked) setSheetOpen(false);
+ }
+
+ const toggleLabel = expanded
+ ? t("insights.coach.frame.hidePanel")
+ : t("insights.coach.frame.showPanel");
+
+ const header = (inSheet: boolean) => (
+
+ );
+
+ const body = (inSheet: boolean) => (
+ <>
+ {header(inSheet)}
+ {
+ onSelect(id);
+ afterPick();
+ }}
+ deletion={deletion}
+ onDeleteActive={() => onNewChat()}
+ onUndoDeleteActive={(id) => onSelect(id)}
+ className="min-h-0 flex-1"
+ />
+ {
+ onNewChat();
+ afterPick();
+ }}
+ data-slot="coach-panel-new-chat"
+ aria-label={t("insights.coach.newChat")}
+ title={t("insights.coach.newChat")}
+ className="absolute right-4 bottom-4 size-12 rounded-full shadow-md"
+ >
+
+
+ >
+ );
+
+ return (
+ <>
+
+
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ {docked ? (
+
+ ) : (
+
+ {
+ // Land on the sheet itself, not its first control: on a phone
+ // focusing the search field would raise the keyboard over the
+ // list the person opened the sheet to read.
+ event.preventDefault();
+ (event.currentTarget as HTMLElement | null)?.focus();
+ }}
+ onCloseAutoFocus={(event) => {
+ // The toggle lives in the top bar, outside the sheet; hand
+ // focus back to it explicitly.
+ event.preventDefault();
+ toggleRef.current?.focus();
+ }}
+ onEscapeKeyDown={(event) => {
+ // The rename field takes Escape for itself (cancel the edit);
+ // the sheet stays open. Radix listens in the capture phase, so
+ // the field's own handler has not run yet when this fires.
+ const target = event.target as Element | null;
+ if (
+ target?.closest?.(
+ '[data-slot="coach-conversation-rename-form"]',
+ )
+ ) {
+ event.preventDefault();
+ }
+ }}
+ >
+
+ {t("insights.coach.frame.panelDescription")}
+
+ {body(true)}
+
+
+ )}
+ >
+ );
+}
diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx
index d8cd1675a..d7756af6d 100644
--- a/src/components/insights/coach-panel/history-rail.tsx
+++ b/src/components/insights/coach-panel/history-rail.tsx
@@ -1,10 +1,12 @@
"use client";
-import { useMemo, useState } from "react";
+import { useMemo, useRef, useState } from "react";
import {
Loader2,
MessagesSquare,
+ MoreHorizontal,
Paperclip,
+ Pencil,
RotateCw,
Search,
Trash2,
@@ -12,12 +14,19 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
-import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel";
+import type { CoachConversationDTO } from "@/lib/ai/coach/types";
+import { groupConversationsByRecency } from "@/lib/insights/coach-conversation-groups";
import { COACH_SCROLLBAR } from "./message-thread";
import { ConversationRename } from "./conversation-rename";
@@ -27,36 +36,46 @@ import {
} from "./use-coach";
/**
- * v1.4.20 phase B2b — conversation history rail.
+ * The Coach's conversation list: search, the full history grouped by recency
+ * (Today / Yesterday / This week / Earlier), and one row per conversation with
+ * a "⋯" menu for Rename and Delete.
*
- * Lives in the left column of the Coach drawer on `lg+`. Renders the
- * caller's FULL conversation history (v1.30.2 — cursor-paginated via
- * `useInfiniteQuery`, loaded incrementally as the list scrolls near its
- * end), lets the user search by title (server-side, debounced 200 ms),
- * select one to resume, and delete with an undo-able confirm.
+ * It fills the Coach page's conversations panel and the drawer's history
+ * tray. The history is cursor-paginated (`useInfiniteQuery`, loaded as the
+ * list scrolls near its end) and searched server-side on the title, debounced
+ * 200 ms.
*
- * Selection / activeId is a controlled prop so the drawer owns the
- * authoritative state across the message thread + composer.
+ * Rename edits the title in place (`ConversationRename`, Enter saves, Escape
+ * cancels without closing the panel around it) and returns focus to the row's
+ * menu button. Delete hides the row at once and commits after the undo window
+ * (`useDeleteCoachConversationWithUndo`); the toast carries the Undo. A
+ * surface that can unmount the list while a delete is pending (the panel's
+ * sheet) passes its own `deletion` from a component that stays mounted, so
+ * closing the sheet does not cut the undo window short.
*
- * Empty + loading + error states are all rendered inline — the rail
- * never collapses to "0 px" so the layout stays predictable.
+ * Selection is a controlled prop: the surface owns the active conversation.
+ * Loading, error and empty states render inline so the list never collapses.
*/
export interface HistoryRailProps {
activeId: string | null;
onSelect: (id: string) => void;
- /**
- * Optional className passthrough so the parent can override the
- * default layout (currently the drawer wraps it in an `
) : isError && visible.length === 0 ? (
- // A load failure must not masquerade as "no conversations yet" —
- // offer a retry instead of the empty copy.
+ // A load failure must not read as "no conversations yet".
- onSelect(c.id)}
- className="min-w-0 flex-1 truncate text-left"
- data-slot="coach-history-select"
- >
-
- {/* v1.29.x (S7) — a fenced thread runs on the hardened
- fenced endpoint over its attached documents; badge it
- with a paperclip (+ count when more than one) so it
- reads as distinct in the rail. */}
- {c.fenced ? (
-
-
- {(c.attachments?.length ?? 0) > 1 ? (
-
- {c.attachments?.length}
-
- ) : null}
-
- ) : null}
- {c.title}
-
-
- {formatRelativeTime(c.updatedAt, t, locale)}
-
-
-
- {/* v1.4.27 R3d MB2 — drop the hover-only `opacity-0`
- reveal: touch surfaces have no hover, so the delete
- affordance was invisible on mobile. The button
- sits at 44 px always-visible per the WCAG 2.5.5
- floor. v1.30.1 M5 — one tap deletes (with an Undo
- toast); the old arm/confirm double-tap is gone. */}
- handleDeleteRequest(c.id)}
- aria-label={t("insights.coach.historyDeleteAria")}
- data-slot="coach-history-delete"
- className="size-11 shrink-0"
- >
-
-
-
- );
- })
+ groups.map((group) => (
+
+ {/* h3: one step under the panel's h2 title, and a sibling of the
+ list's own h3 label where that shows (heading order). */}
+
+ {t(group.labelKey)}
+
+
+ {group.conversations.map((c) => (
+
+ ))}
+
+
+ ))
)}
- {/* v1.30.2 (QoL H1) — IntersectionObserver sentinel: scrolling this
- div into the rail's own scrollport pulls the next cursor page.
- Empty + aria-hidden — the loading row right below it is the
- a11y-visible signal. */}
+ {/* IntersectionObserver sentinel: scrolling it into the list's own
+ scrollport pulls the next cursor page. */}
{visible.length > 0 && hasNextPage ? (
) : null}
@@ -292,3 +256,133 @@ export function HistoryRail({
);
}
+
+function HistoryRow({
+ conversation: c,
+ active,
+ onSelect,
+ onDelete,
+}: {
+ conversation: CoachConversationDTO;
+ active: boolean;
+ onSelect: (id: string) => void;
+ onDelete: (id: string) => void;
+}) {
+ const { t } = useTranslations();
+ const [renaming, setRenaming] = useState(false);
+ const menuTriggerRef = useRef(null);
+ // Set when the menu closes INTO the rename editor, so the menu does not
+ // hand focus back to its trigger and steal it from the title field.
+ const openingRenameRef = useRef(false);
+
+ function endRename() {
+ setRenaming(false);
+ // Back to the row's menu button, where the edit started.
+ requestAnimationFrame(() => menuTriggerRef.current?.focus());
+ }
+
+ return (
+
+ onSelect(c.id)}
+ aria-current={active ? "true" : undefined}
+ title={c.title}
+ data-slot="coach-history-select"
+ className={cn(
+ "flex min-h-11 w-full min-w-0 items-center gap-1.5 rounded-lg py-2 pr-11 pl-2 text-left text-sm transition-colors",
+ "focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
+ // The open conversation reads like the open page in the left
+ // navigation: the same primary wash, so the two rails agree.
+ active
+ ? "bg-primary/10 text-primary font-medium"
+ : "text-foreground hover:bg-muted/60",
+ )}
+ >
+ {/* A fenced thread runs on the hardened document endpoint over its
+ attachments; the paperclip (with a count above one) marks it. */}
+ {c.fenced ? (
+
+
+ {(c.attachments?.length ?? 0) > 1 ? (
+
+ {c.attachments?.length}
+
+ ) : null}
+
+ ) : null}
+ {c.title}
+
+
+ {renaming ? null : (
+
+
+
+
+
+
+ {
+ if (openingRenameRef.current) {
+ openingRenameRef.current = false;
+ event.preventDefault();
+ }
+ }}
+ >
+ {
+ openingRenameRef.current = true;
+ setRenaming(true);
+ }}
+ >
+
+ {t("insights.coach.frame.rename")}
+
+ onDelete(c.id)}
+ >
+
+ {t("insights.coach.frame.delete")}
+
+
+
+ )}
+
+ {
+ if (!next) endRename();
+ }}
+ />
+
+ );
+}
diff --git a/src/components/layout/__tests__/top-bar-actions.test.tsx b/src/components/layout/__tests__/top-bar-actions.test.tsx
new file mode 100644
index 000000000..ce9b865b6
--- /dev/null
+++ b/src/components/layout/__tests__/top-bar-actions.test.tsx
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { TopBarActions, TopBarActionsOutlet } from "../top-bar-actions";
+
+describe("top bar actions slot", () => {
+ it("renders an empty outlet that takes itself out of the row", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toBe(
+ '',
+ );
+ });
+
+ it("renders nothing in place: page actions only ever appear in the outlet", () => {
+ // On the server (and before the top bar has mounted) there is no outlet,
+ // so the page's actions render nowhere rather than inline in the page.
+ const html = renderToStaticMarkup(
+
+
+ Toggle
+
+
,
+ );
+ expect(html).toBe("");
+ });
+});
diff --git a/src/components/layout/top-bar-actions.tsx b/src/components/layout/top-bar-actions.tsx
new file mode 100644
index 000000000..c053846e3
--- /dev/null
+++ b/src/components/layout/top-bar-actions.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+import { createPortal } from "react-dom";
+
+/**
+ * A page-owned slot at the trailing edge of the top bar.
+ *
+ * The top bar is shell chrome and knows nothing about pages; a page that
+ * needs a control up there (the Coach's conversations-panel toggle) renders
+ * `` anywhere in its own tree and the content is portalled
+ * into ``. Portalling keeps the control inside the
+ * page's React tree, so it reads the page's state and context directly and
+ * no node has to be handed across a context on every render.
+ *
+ * The slot empties itself when the page unmounts: the portal goes with the
+ * component that rendered it, so no other route ever shows a stale control.
+ *
+ * The outlet's element is held in a module-level store rather than a context
+ * provider. The shell mounts exactly one top bar, so there is one outlet, and
+ * a store keeps the shell's tree untouched.
+ */
+let outlet: HTMLElement | null = null;
+const listeners = new Set<() => void>();
+
+function setOutlet(node: HTMLElement | null) {
+ if (outlet === node) return;
+ outlet = node;
+ for (const listener of listeners) listener();
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+/** Where the top bar paints page actions. Empty unless a page fills it. */
+export function TopBarActionsOutlet({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+/** Render `children` into the top bar's action slot. */
+export function TopBarActions({ children }: { children: React.ReactNode }) {
+ const target = useSyncExternalStore(
+ subscribe,
+ () => outlet,
+ () => null,
+ );
+ if (!target) return null;
+ return createPortal(children, target);
+}
diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx
index f2aa2aaf8..71436d8b5 100644
--- a/src/components/layout/top-bar.tsx
+++ b/src/components/layout/top-bar.tsx
@@ -15,6 +15,7 @@ import {
visibleUtilityDestinations,
} from "@/components/layout/nav-model";
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
+import { TopBarActionsOutlet } from "@/components/layout/top-bar-actions";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -106,6 +107,12 @@ export function TopBar() {
{/* Desktop: empty spacer (user controls are in sidebar) */}
+ {/* Page-owned actions (the Coach's conversations toggle). `ml-auto`
+ keeps them at the trailing edge, right before the mobile avatar
+ menu; `empty:hidden` takes the slot out of the row on every page
+ that leaves it empty, so the header lays out exactly as before. */}
+
+
{/* Mobile-only auth section (desktop uses sidebar user section) */}
{isLoading ? (
diff --git a/src/components/settings/__tests__/coach-links-without-coach.test.tsx b/src/components/settings/__tests__/coach-links-without-coach.test.tsx
index 8eddb9860..4a2ecb8da 100644
--- a/src/components/settings/__tests__/coach-links-without-coach.test.tsx
+++ b/src/components/settings/__tests__/coach-links-without-coach.test.tsx
@@ -54,16 +54,16 @@ beforeEach(() => {
});
describe("Coach links follow the Coach's capability", () => {
- it("the sources pointer links to the Coach while it is available", () => {
+ it("the sources pointer opens the Coach's settings on what it can see", () => {
expect(render()).toContain(
- 'href="/coach"',
+ 'href="/coach?settings=data"',
);
});
it("the sources pointer offers no dead link without the Coach", () => {
coachState.available = false;
const html = render();
- expect(html).not.toContain('href="/coach"');
+ expect(html).not.toContain('href="/coach?settings=data"');
expect(html).not.toContain("coach-prefs-sources-pointer");
});
diff --git a/src/components/settings/coach-prefs-section.tsx b/src/components/settings/coach-prefs-section.tsx
index 7e12ed1e5..dfced9eb9 100644
--- a/src/components/settings/coach-prefs-section.tsx
+++ b/src/components/settings/coach-prefs-section.tsx
@@ -223,8 +223,9 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
- {/* Data clusters + analysis window live on ONE owner: the
- sources rail inside the chat ("What I draw on"). This card
+ {/* Data clusters + analysis window live on ONE owner: "What I
+ can see" behind the Coach's settings gear, which
+ `/coach?settings=data` opens directly. This card
used to render its own copies of both controls; two writable
surfaces for the same persisted fields is exactly the
redundancy the rail was built to end. A pointer replaces
@@ -237,7 +238,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
>
{t("insights.coach.settingsSourcesPointer")}{" "}
{t("insights.coach.settingsSourcesPointerLink")}
diff --git a/src/components/ui/responsive-sheet.tsx b/src/components/ui/responsive-sheet.tsx
index c25175db8..b1a7f2a00 100644
--- a/src/components/ui/responsive-sheet.tsx
+++ b/src/components/ui/responsive-sheet.tsx
@@ -88,6 +88,13 @@ export interface ResponsiveSheetProps {
* default, so the width now resolves from this single source.
*/
contentWidth?: "md" | "lg" | "2xl" | "3xl" | "4xl" | "6xl";
+ /**
+ * Where focus goes when the surface closes. Radix returns it to a
+ * `Dialog.Trigger`, and this primitive has none, so a caller whose opener
+ * should get focus back (a gear, a menu button) hands it over here: call
+ * `event.preventDefault()` and focus the opener.
+ */
+ onCloseAutoFocus?: (event: Event) => void;
children: React.ReactNode;
}
@@ -136,6 +143,7 @@ export function ResponsiveSheet({
bodyClassName,
showCloseButton = true,
contentWidth = "md",
+ onCloseAutoFocus,
children,
}: ResponsiveSheetProps) {
const isMobile = useIsMobile();
@@ -144,6 +152,7 @@ export function ResponsiveSheet({
return (
= {}) {
+ const map = new Map(Object.entries(initial));
+ return {
+ getItem: (key: string) => map.get(key) ?? null,
+ setItem: (key: string, value: string) => void map.set(key, value),
+ map,
+ };
+}
+
+const throwing = {
+ getItem: () => {
+ throw new Error("SecurityError");
+ },
+ setItem: () => {
+ throw new Error("QuotaExceededError");
+ },
+};
+
+describe("coach panel open preference", () => {
+ it("opens the docked panel on a first visit", () => {
+ const storage = memoryStorage();
+ expect(readCoachPanelPreference(storage)).toBeNull();
+ expect(resolveCoachPanelOpen(readCoachPanelPreference(storage))).toBe(true);
+ });
+
+ it("remembers a closed panel, and an opened one, across reads", () => {
+ const storage = memoryStorage();
+ expect(writeCoachPanelPreference(storage, false)).toBe(true);
+ expect(storage.map.get(COACH_PANEL_STORAGE_KEY)).toBe("false");
+ expect(resolveCoachPanelOpen(readCoachPanelPreference(storage))).toBe(
+ false,
+ );
+ writeCoachPanelPreference(storage, true);
+ expect(resolveCoachPanelOpen(readCoachPanelPreference(storage))).toBe(true);
+ });
+
+ it("treats an unknown stored value as no choice", () => {
+ const storage = memoryStorage({ [COACH_PANEL_STORAGE_KEY]: "maybe" });
+ expect(readCoachPanelPreference(storage)).toBeNull();
+ });
+
+ it("falls back to the default when storage throws or is absent", () => {
+ expect(readCoachPanelPreference(throwing)).toBeNull();
+ expect(writeCoachPanelPreference(throwing, false)).toBe(false);
+ expect(readCoachPanelPreference(null)).toBeNull();
+ expect(writeCoachPanelPreference(undefined, true)).toBe(false);
+ });
+});
diff --git a/src/hooks/use-coach-panel-open.ts b/src/hooks/use-coach-panel-open.ts
new file mode 100644
index 000000000..280d0d34f
--- /dev/null
+++ b/src/hooks/use-coach-panel-open.ts
@@ -0,0 +1,135 @@
+"use client";
+
+import { useCallback, useSyncExternalStore } from "react";
+
+/**
+ * The Coach page's conversations panel: whether it sits docked beside the
+ * thread, and whether the person last left it open there.
+ *
+ * Docked means a viewport of at least 1280 px (`xl`). Below that the panel is
+ * a sheet over the page and opens only when asked, so nothing here applies.
+ * At `xl` it is open on a first visit and remembers the last choice per
+ * device after that.
+ *
+ * The choice is a convenience, not a record: it lives in `localStorage`, every
+ * read and write is wrapped because storage can throw (private windows,
+ * blocked site data), and an unreadable store falls back to the first-visit
+ * default. Both values come through `useSyncExternalStore`, so the server and
+ * the hydrating client agree (docked = false, open = true) and the live value
+ * lands on the first client render after hydration.
+ */
+export const COACH_PANEL_STORAGE_KEY = "healthlog.coach.panelOpen";
+
+/** The breakpoint at which the panel docks beside the thread. */
+export const COACH_PANEL_DOCKED_QUERY = "(min-width: 1280px)";
+
+/** Same-tab change signal; the `storage` event only fires in other tabs. */
+const CHANGE_EVENT = "healthlog:coach-panel-open";
+
+/**
+ * The last choice made in this tab. Read when the store has nothing, so a
+ * click still sticks for the session where storage refuses every write.
+ */
+let sessionChoice: boolean | null = null;
+
+/**
+ * The remembered choice. `null` = nothing stored yet (a first visit), or a
+ * store that cannot be read; the caller treats both as "open".
+ */
+export function readCoachPanelPreference(
+ storage: Pick | null | undefined,
+): boolean | null {
+ if (!storage) return null;
+ try {
+ const raw = storage.getItem(COACH_PANEL_STORAGE_KEY);
+ if (raw === "true") return true;
+ if (raw === "false") return false;
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+/** Persist the choice. Returns false when the store refused the write. */
+export function writeCoachPanelPreference(
+ storage: Pick | null | undefined,
+ open: boolean,
+): boolean {
+ if (!storage) return false;
+ try {
+ storage.setItem(COACH_PANEL_STORAGE_KEY, String(open));
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** The open state the docked panel paints: a first visit opens it. */
+export function resolveCoachPanelOpen(stored: boolean | null): boolean {
+ return stored ?? true;
+}
+
+function safeLocalStorage(): Storage | null {
+ if (typeof window === "undefined") return null;
+ try {
+ return window.localStorage;
+ } catch {
+ return null;
+ }
+}
+
+function subscribePreference(callback: () => void): () => void {
+ if (typeof window === "undefined") return () => {};
+ const onStorage = (event: StorageEvent) => {
+ if (event.key === null || event.key === COACH_PANEL_STORAGE_KEY) {
+ callback();
+ }
+ };
+ window.addEventListener("storage", onStorage);
+ window.addEventListener(CHANGE_EVENT, callback);
+ return () => {
+ window.removeEventListener("storage", onStorage);
+ window.removeEventListener(CHANGE_EVENT, callback);
+ };
+}
+
+function subscribeDocked(callback: () => void): () => void {
+ if (typeof window === "undefined" || !window.matchMedia) return () => {};
+ const mql = window.matchMedia(COACH_PANEL_DOCKED_QUERY);
+ mql.addEventListener("change", callback);
+ return () => mql.removeEventListener("change", callback);
+}
+
+function readDocked(): boolean {
+ if (typeof window === "undefined" || !window.matchMedia) return false;
+ return window.matchMedia(COACH_PANEL_DOCKED_QUERY).matches;
+}
+
+/**
+ * `docked`: the viewport is wide enough for the panel to sit beside the
+ * thread. `open`: the remembered docked state (meaningful only when docked).
+ * `setOpen` persists and notifies every subscriber in this tab.
+ */
+export function useCoachPanelOpen(): {
+ docked: boolean;
+ open: boolean;
+ setOpen: (open: boolean) => void;
+} {
+ const docked = useSyncExternalStore(subscribeDocked, readDocked, () => false);
+ const open = useSyncExternalStore(
+ subscribePreference,
+ () =>
+ resolveCoachPanelOpen(
+ readCoachPanelPreference(safeLocalStorage()) ?? sessionChoice,
+ ),
+ () => true,
+ );
+ const setOpen = useCallback((next: boolean) => {
+ sessionChoice = next;
+ writeCoachPanelPreference(safeLocalStorage(), next);
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(new Event(CHANGE_EVENT));
+ }
+ }, []);
+ return { docked, open, setOpen };
+}
diff --git a/src/lib/insights/__tests__/coach-conversation-groups.test.ts b/src/lib/insights/__tests__/coach-conversation-groups.test.ts
new file mode 100644
index 000000000..5b5116e9c
--- /dev/null
+++ b/src/lib/insights/__tests__/coach-conversation-groups.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+
+import { groupConversationsByRecency } from "../coach-conversation-groups";
+
+// The suite runs with TZ=UTC, so local day boundaries are UTC midnights.
+const NOW = new Date("2026-10-04T15:00:00.000Z");
+
+function c(id: string, updatedAt: string) {
+ return { id, updatedAt };
+}
+
+describe("groupConversationsByRecency", () => {
+ it("buckets by local day: today, yesterday, the six days before, earlier", () => {
+ const groups = groupConversationsByRecency(
+ [
+ c("a", "2026-10-04T00:00:00.000Z"), // today, at midnight
+ c("b", "2026-10-03T23:59:59.000Z"), // yesterday, last second
+ c("c", "2026-10-03T00:00:00.000Z"), // yesterday, first second
+ c("d", "2026-09-28T00:00:00.000Z"), // six days before today
+ c("e", "2026-09-27T23:59:59.000Z"), // one second older: earlier
+ ],
+ NOW,
+ );
+ expect(groups.map((g) => [g.id, g.conversations.map((x) => x.id)])).toEqual(
+ [
+ ["today", ["a"]],
+ ["yesterday", ["b", "c"]],
+ ["thisWeek", ["d"]],
+ ["earlier", ["e"]],
+ ],
+ );
+ });
+
+ it("drops empty groups and keeps the caller's order inside a group", () => {
+ const groups = groupConversationsByRecency(
+ [c("x", "2026-01-02T00:00:00.000Z"), c("y", "2026-03-02T00:00:00.000Z")],
+ NOW,
+ );
+ expect(groups).toHaveLength(1);
+ expect(groups[0].id).toBe("earlier");
+ expect(groups[0].labelKey).toBe("insights.coach.history.groupEarlier");
+ expect(groups[0].conversations.map((x) => x.id)).toEqual(["x", "y"]);
+ });
+
+ it("returns nothing for an empty list", () => {
+ expect(groupConversationsByRecency([], NOW)).toEqual([]);
+ });
+});
diff --git a/src/lib/insights/coach-conversation-groups.ts b/src/lib/insights/coach-conversation-groups.ts
new file mode 100644
index 000000000..44d24ac6d
--- /dev/null
+++ b/src/lib/insights/coach-conversation-groups.ts
@@ -0,0 +1,58 @@
+/**
+ * Bucket Coach conversations by how recently they were last touched: Today,
+ * Yesterday, This week (the six days before yesterday), Earlier.
+ *
+ * Shared by the Coach page's conversations panel and the standalone
+ * conversation list, so both group a thread the same way. Comparisons run
+ * against LOCAL day boundaries, so "Today" follows the person's own midnight
+ * rather than UTC's. Empty groups are dropped; order inside a group is the
+ * caller's (the server already returns `updatedAt desc`).
+ */
+export type CoachRecencyGroupId =
+ "today" | "yesterday" | "thisWeek" | "earlier";
+
+export interface CoachRecencyGroup {
+ id: CoachRecencyGroupId;
+ labelKey: string;
+ conversations: T[];
+}
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+const GROUPS: ReadonlyArray<{ id: CoachRecencyGroupId; labelKey: string }> = [
+ { id: "today", labelKey: "insights.coach.history.groupToday" },
+ { id: "yesterday", labelKey: "insights.coach.history.groupYesterday" },
+ { id: "thisWeek", labelKey: "insights.coach.history.groupThisWeek" },
+ { id: "earlier", labelKey: "insights.coach.history.groupEarlier" },
+];
+
+export function groupConversationsByRecency(
+ conversations: readonly T[],
+ now: Date = new Date(),
+): CoachRecencyGroup[] {
+ const startOfToday = new Date(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate(),
+ ).getTime();
+ const startOfYesterday = startOfToday - DAY_MS;
+ const startOfWeek = startOfToday - 6 * DAY_MS;
+
+ const buckets: Record = {
+ today: [],
+ yesterday: [],
+ thisWeek: [],
+ earlier: [],
+ };
+ for (const conversation of conversations) {
+ const updated = new Date(conversation.updatedAt).getTime();
+ if (updated >= startOfToday) buckets.today.push(conversation);
+ else if (updated >= startOfYesterday) buckets.yesterday.push(conversation);
+ else if (updated >= startOfWeek) buckets.thisWeek.push(conversation);
+ else buckets.earlier.push(conversation);
+ }
+
+ return GROUPS.map((g) => ({ ...g, conversations: buckets[g.id] })).filter(
+ (g) => g.conversations.length > 0,
+ );
+}
From b8d31f945d4f8a1c0cceb6cc49062e864eabb7ad Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 4 Oct 2026 20:00:07 +0200
Subject: [PATCH 02/33] test(e2e): cover the Coach page frame at 1440, 1280 and
390
Measures that the thread and composer keep the same width with the panel
open and closed, that the choice survives a reload, Escape and focus
return for the panel, the sheet and the settings overlay, rename and
delete with undo from the row menu, the settings deep link, and axe on the
panel and the overlay in both themes. Screenshots are attached to the
report.
---
e2e/coach-layout.spec.ts | 585 +++++++++++++++++++++++++++++++++++++++
1 file changed, 585 insertions(+)
create mode 100644 e2e/coach-layout.spec.ts
diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts
new file mode 100644
index 000000000..c04ef3dec
--- /dev/null
+++ b/e2e/coach-layout.spec.ts
@@ -0,0 +1,585 @@
+import AxeBuilder from "@axe-core/playwright";
+import type { Locator, Page, Request, Route, TestInfo } from "@playwright/test";
+
+import { expect, test } from "./setup/test";
+import { STORAGE_STATE_PATH } from "./setup/global-setup";
+import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
+
+/**
+ * The Coach page frame: the conversations panel on the right, its toggle in
+ * the top bar, the settings gear in the panel header, and New chat at the
+ * panel's bottom right.
+ *
+ * Contracts under test, at 1440, 1280 and 390 px:
+ *
+ * 1. From 1280 px the panel docks beside the thread, open on a first visit,
+ * and the reading column (thread and composer) is exactly as wide with
+ * the panel open as with it closed. The choice survives a reload.
+ * 2. Escape inside the docked panel closes it and returns focus to the
+ * toggle.
+ * 3. A row's menu renames by keyboard (Escape cancels without closing the
+ * panel, Enter saves once) and deletes with an Undo that keeps the
+ * DELETE from ever leaving; a delete left alone commits once.
+ * 4. The gear opens the quick settings as a popover; Escape closes it and
+ * focus returns to the gear. `/coach?settings=data` opens it on "What I
+ * can see" and drops the param from the URL.
+ * 5. axe finds nothing on the open panel or the open settings, in the
+ * light and the dark theme.
+ * 6. At 390 px the panel is a dialog sheet with New chat 16 px from the
+ * right edge; Escape closes it and focus returns to the toggle. The gear
+ * opens a bottom sheet that closes on Escape back to the gear. Nothing
+ * scrolls sideways.
+ *
+ * Every Coach route is stubbed; the provider chain and provider config are
+ * stubbed too, so the model section renders the same on every machine.
+ * Screenshots are attached to the report (no pixel baselines).
+ */
+
+const NOW = Date.now();
+const HOUR = 60 * 60 * 1000;
+
+interface Conversation {
+ id: string;
+ title: string;
+ updatedAt: string;
+}
+
+function conversations(): Conversation[] {
+ return [
+ {
+ id: "frame-bp",
+ title: "Blood pressure this week",
+ updatedAt: new Date(NOW - HOUR).toISOString(),
+ },
+ {
+ id: "frame-sleep",
+ title: "Sleep since May",
+ updatedAt: new Date(NOW - 26 * HOUR).toISOString(),
+ },
+ {
+ id: "frame-weight",
+ title: "Weight over the summer",
+ updatedAt: new Date(NOW - 10 * 24 * HOUR).toISOString(),
+ },
+ ];
+}
+
+const ANSWER =
+ "Your blood pressure held a steady band this week. The mornings ran a little higher than the evenings, which is common and nothing that stands out on its own.";
+
+function fulfilJson(route: Route, data: unknown, status = 200) {
+ return route.fulfill({
+ status,
+ contentType: "application/json",
+ body: JSON.stringify({ data, error: null }),
+ });
+}
+
+interface CoachMock {
+ patches: Array<{ id: string; title: string }>;
+ deletes: string[];
+}
+
+async function mockCoach(page: Page): Promise {
+ const state = conversations();
+ const mock: CoachMock = { patches: [], deletes: [] };
+
+ const dto = (c: Conversation) => ({
+ id: c.id,
+ title: c.title,
+ createdAt: c.updatedAt,
+ updatedAt: c.updatedAt,
+ messageCount: 2,
+ fenced: false,
+ attachments: [],
+ documentTitle: null,
+ });
+
+ await page.route(
+ /\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
+ async (route: Route, request: Request) => {
+ const url = new URL(request.url());
+ const match = url.pathname.match(/^\/api\/insights\/chat\/([^/]+)$/);
+ if (match) {
+ const id = decodeURIComponent(match[1]);
+ const found = state.find((c) => c.id === id);
+ if (request.method() === "PATCH") {
+ const { title } = request.postDataJSON() as { title: string };
+ mock.patches.push({ id, title });
+ if (found) found.title = title;
+ return fulfilJson(route, { id, title });
+ }
+ if (request.method() === "DELETE") {
+ mock.deletes.push(id);
+ const idx = state.findIndex((c) => c.id === id);
+ if (idx >= 0) state.splice(idx, 1);
+ return fulfilJson(route, { deleted: true });
+ }
+ if (!found) return fulfilJson(route, null, 404);
+ return fulfilJson(route, {
+ ...dto(found),
+ attachmentCount: 0,
+ summary: null,
+ messages: [
+ {
+ id: `${id}-u`,
+ role: "user",
+ content: `Tell me about ${found.title.toLowerCase()}.`,
+ createdAt: found.updatedAt,
+ metricSource: null,
+ providerType: null,
+ promptVersion: null,
+ tokensUsed: null,
+ model: null,
+ },
+ {
+ id: `${id}-a`,
+ role: "assistant",
+ content: ANSWER,
+ createdAt: found.updatedAt,
+ metricSource: null,
+ providerType: "mock",
+ promptVersion: "e2e",
+ tokensUsed: 640,
+ model: "mock",
+ },
+ ],
+ });
+ }
+ if (url.pathname.startsWith("/api/insights/chat/")) {
+ return fulfilJson(route, { results: [] });
+ }
+ const q = (url.searchParams.get("q") ?? "").toLowerCase();
+ return fulfilJson(route, {
+ conversations: state
+ .filter((c) => c.title.toLowerCase().includes(q))
+ .map(dto),
+ nextCursor: null,
+ });
+ },
+ );
+ await page.route("**/api/insights/coach/nudge-status*", (route) =>
+ fulfilJson(route, { nudgedAt: null, unread: false }),
+ );
+ await page.route("**/api/coach/about-me/questions*", (route) =>
+ fulfilJson(route, { questions: [] }),
+ );
+ await page.route("**/api/insights/coach/seeded-question*", (route) =>
+ fulfilJson(route, { signal: null }),
+ );
+ await page.route("**/api/insights/provider-chain", (route) =>
+ route.request().method() === "GET"
+ ? fulfilJson(route, {
+ activeProvider: "local",
+ cachedActiveProvider: "local",
+ configuredChain: [
+ { providerType: "local", enabled: true, available: true },
+ { providerType: "admin-openai", enabled: true, available: true },
+ ],
+ })
+ : fulfilJson(route, { saved: true }),
+ );
+ await page.route("**/api/user/ai-provider", (route) =>
+ route.request().method() === "GET"
+ ? fulfilJson(route, {
+ provider: "LOCAL",
+ model: "qwen2.5",
+ baseUrl: "http://localhost:11434/v1",
+ hasAnthropicKey: false,
+ anthropicKeyPreview: null,
+ hasLocalKey: false,
+ hasOpenaiKey: false,
+ openaiKeyPreview: null,
+ compatBaseUrl: null,
+ compatModel: null,
+ hasCompatKey: false,
+ responseTimeoutSeconds: null,
+ localReasoningEffort: null,
+ compatReasoningEffort: null,
+ })
+ : fulfilJson(route, { updated: true }),
+ );
+ return mock;
+}
+
+const toggle = (page: Page) => page.locator('[data-slot="coach-panel-toggle"]');
+const panel = (page: Page) =>
+ page.locator('[data-slot="coach-conversations-panel"]');
+
+/** Width of the reading column: the thread's capped inner column. */
+async function columnWidths(page: Page) {
+ const thread = page.locator('[data-slot="coach-message-thread"] > *').first();
+ const composer = page.locator('[data-slot="coach-page-composer"] > div');
+ const t = await thread.boundingBox();
+ const c = await composer.boundingBox();
+ expect(t, "thread column").not.toBeNull();
+ expect(c, "composer column").not.toBeNull();
+ return { thread: t!.width, composer: c!.width, threadX: t!.x };
+}
+
+async function expectNoSidewaysScroll(page: Page) {
+ const overflow = await page.evaluate(() => {
+ const doc = document.scrollingElement!;
+ const main = document.getElementById("main-content")!;
+ return {
+ doc: doc.scrollWidth - doc.clientWidth,
+ main: main.scrollWidth - main.clientWidth,
+ };
+ });
+ expect(overflow.doc).toBeLessThanOrEqual(0);
+ expect(overflow.main).toBeLessThanOrEqual(0);
+}
+
+async function shot(page: Page, testInfo: TestInfo, name: string) {
+ const path = testInfo.outputPath(`${name}.png`);
+ await page.screenshot({ path });
+ await testInfo.attach(name, { path, contentType: "image/png" });
+ const dir = process.env.COACH_SHOTS_DIR;
+ if (dir) await page.screenshot({ path: `${dir}/${name}.png` });
+}
+
+async function openCoach(page: Page, path = "/coach?c=frame-bp") {
+ await page.goto(path, { waitUntil: "domcontentloaded" });
+ await expect(
+ page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
+ ).toBeVisible({ timeout: 15_000 });
+}
+
+async function waitForWidth(locator: Locator, width: number) {
+ await expect
+ .poll(async () => Math.round((await locator.boundingBox())?.width ?? -1))
+ .toBe(width);
+}
+
+test.describe("Coach page frame", () => {
+ test.use({ storageState: STORAGE_STATE_PATH });
+
+ test.beforeEach(async ({ page }, testInfo) => {
+ test.skip(
+ testInfo.project.name !== "chromium-desktop",
+ "each case sets its own viewport; the desktop project runs them all",
+ );
+ await serveAiBlock(page, aiBlockAvailable());
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ });
+
+ for (const width of [1440, 1280]) {
+ test(`${width}: the panel docks, toggles, persists, and the reading column never changes width`, async ({
+ page,
+ }, testInfo) => {
+ await page.setViewportSize({ width, height: 900 });
+ await mockCoach(page);
+ await openCoach(page);
+
+ // First visit: open.
+ await expect(panel(page)).toHaveAttribute("data-state", "open");
+ await waitForWidth(panel(page), 288);
+ await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
+ await expect(toggle(page)).toHaveAttribute(
+ "aria-controls",
+ "coach-conversations-panel",
+ );
+ const open = await columnWidths(page);
+ expect(open.thread).toBeCloseTo(672, 0);
+ expect(Math.abs(open.composer - open.thread)).toBeLessThanOrEqual(1);
+ // The panel sits right of the column, never over it.
+ const aside = (await panel(page).boundingBox())!;
+ expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread);
+ await expectNoSidewaysScroll(page);
+ await shot(page, testInfo, `coach-frame-${width}-open`);
+
+ await toggle(page).click();
+ await expect(panel(page)).toHaveAttribute("data-state", "closed");
+ await waitForWidth(panel(page), 0);
+ await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
+ const closed = await columnWidths(page);
+ expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1);
+ expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1);
+ // Closed, nothing inside the panel is reachable.
+ await expect(
+ panel(page).locator('[data-slot="coach-history-search"]'),
+ ).not.toBeInViewport();
+ await expectNoSidewaysScroll(page);
+ await shot(page, testInfo, `coach-frame-${width}-closed`);
+
+ // Remembered across a reload.
+ await openCoach(page);
+ await expect(panel(page)).toHaveAttribute("data-state", "closed");
+
+ // Open again, then Escape from inside closes it, focus to the toggle.
+ await toggle(page).click();
+ await expect(panel(page)).toHaveAttribute("data-state", "open");
+ await panel(page).locator('[data-slot="coach-history-search"]').focus();
+ await page.keyboard.press("Escape");
+ await expect(panel(page)).toHaveAttribute("data-state", "closed");
+ await expect(toggle(page)).toBeFocused();
+ });
+ }
+
+ test("1440: picking a row opens it and New chat clears it, both in the URL", async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await mockCoach(page);
+ await openCoach(page);
+
+ const rows = panel(page).locator('[data-slot="coach-history-select"]');
+ await expect(rows).toHaveCount(3);
+ await expect(panel(page).locator('[data-group="today"]')).toContainText(
+ "Blood pressure this week",
+ );
+ await expect(panel(page).locator('[data-group="yesterday"]')).toContainText(
+ "Sleep since May",
+ );
+ await expect(rows.nth(0)).toHaveAttribute("aria-current", "true");
+
+ await rows.nth(1).click();
+ await expect(page).toHaveURL(/\/coach\?c=frame-sleep$/);
+ await expect(rows.nth(1)).toHaveAttribute("aria-current", "true");
+
+ const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
+ const fabBox = (await fab.boundingBox())!;
+ const asideBox = (await panel(page).boundingBox())!;
+ expect(asideBox.x + asideBox.width - (fabBox.x + fabBox.width)).toBeCloseTo(
+ 16,
+ 0,
+ );
+ await fab.click();
+ await expect(page).toHaveURL(/\/coach$/);
+ await expect(
+ page.locator('[data-slot="coach-input-textarea"]'),
+ ).toBeVisible();
+ await expect(panel(page).locator('[aria-current="true"]')).toHaveCount(0);
+ });
+
+ test("1440: rename by keyboard and delete with undo from the row menu", async ({
+ page,
+ }, testInfo) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ const mock = await mockCoach(page);
+ await openCoach(page);
+
+ const row = panel(page)
+ .locator('[data-slot="coach-history-item"]')
+ .filter({ hasText: "Sleep since May" });
+ const menu = row.locator('[data-slot="coach-history-row-menu"]');
+ await expect(menu).toHaveAttribute(
+ "aria-label",
+ "Actions for Sleep since May",
+ );
+
+ // Keyboard: focus the menu button, open it, pick Rename.
+ await menu.focus();
+ await page.keyboard.press("Enter");
+ await page.getByRole("menuitem", { name: "Rename" }).click();
+ const input = row.locator('[data-slot="coach-conversation-rename-input"]');
+ await expect(input).toBeFocused();
+ await input.fill("Discard this");
+ await page.keyboard.press("Escape");
+ await expect(input).toHaveCount(0);
+ await expect(panel(page)).toHaveAttribute("data-state", "open");
+ await expect(row).toContainText("Sleep since May");
+ await expect(menu).toBeFocused();
+ expect(mock.patches).toHaveLength(0);
+
+ await page.keyboard.press("Enter");
+ await page.getByRole("menuitem", { name: "Rename" }).click();
+ await expect(input).toBeFocused();
+ await input.fill("Sleep since spring");
+ await page.keyboard.press("Enter");
+ const renamed = panel(page)
+ .locator('[data-slot="coach-history-item"]')
+ .filter({ hasText: "Sleep since spring" });
+ await expect(renamed).toHaveCount(1);
+ await expect(input).toHaveCount(0);
+ expect(mock.patches).toEqual([
+ { id: "frame-sleep", title: "Sleep since spring" },
+ ]);
+
+ // Delete, then Undo: the row returns and no DELETE leaves.
+ await renamed.hover();
+ await renamed.locator('[data-slot="coach-history-row-menu"]').click();
+ await page.getByRole("menuitem", { name: "Delete" }).click();
+ await expect(renamed).toHaveCount(0);
+ await shot(page, testInfo, "coach-frame-1440-delete-toast");
+ await page.getByRole("button", { name: "Undo" }).click();
+ await expect(renamed).toHaveCount(1);
+ await page.waitForTimeout(6_500);
+ expect(mock.deletes).toEqual([]);
+
+ // Delete left alone commits once, after the undo window.
+ await renamed.hover();
+ await renamed.locator('[data-slot="coach-history-row-menu"]').click();
+ await page.getByRole("menuitem", { name: "Delete" }).click();
+ await expect(renamed).toHaveCount(0);
+ await expect
+ .poll(() => mock.deletes, { timeout: 10_000 })
+ .toEqual(["frame-sleep"]);
+ });
+
+ test("1440: the gear opens the quick settings as a popover; Escape returns to the gear", async ({
+ page,
+ }, testInfo) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await mockCoach(page);
+ await openCoach(page);
+
+ const gear = panel(page).locator('[data-slot="coach-settings"]');
+ await gear.click();
+ const popover = page.locator('[data-slot="coach-settings-popover"]');
+ await expect(popover).toBeVisible();
+ await expect(popover).toHaveAttribute("role", "dialog");
+ await expect(
+ popover.locator('[data-slot="coach-quick-provider"]'),
+ ).toHaveValue("local");
+ await expect(
+ popover.locator('[data-slot="coach-quick-model"]'),
+ ).toHaveValue("qwen2.5");
+ await expect(
+ popover.locator('[data-slot="coach-sources-window"]'),
+ ).toBeVisible();
+ // The popover stays inside the viewport.
+ const box = (await popover.boundingBox())!;
+ expect(box.x + box.width).toBeLessThanOrEqual(1440);
+ expect(box.y + box.height).toBeLessThanOrEqual(900);
+ await shot(page, testInfo, "coach-frame-1440-settings");
+
+ await page.keyboard.press("Escape");
+ await expect(popover).toHaveCount(0);
+ await expect(gear).toBeFocused();
+ // Escape on the popover does not also close the panel.
+ await expect(panel(page)).toHaveAttribute("data-state", "open");
+ });
+
+ test("1440: /coach?settings=data opens the settings on what the Coach sees", async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await mockCoach(page);
+ // A closed panel is revealed for the deep link.
+ await page.addInitScript(() => {
+ window.localStorage.setItem("healthlog.coach.panelOpen", "false");
+ });
+ await page.goto("/coach?settings=data", { waitUntil: "domcontentloaded" });
+
+ const popover = page.locator('[data-slot="coach-settings-popover"]');
+ await expect(popover).toBeVisible({ timeout: 15_000 });
+ await expect(panel(page)).toHaveAttribute("data-state", "open");
+ await expect(
+ popover.locator('[data-slot="coach-settings-data"]'),
+ ).toBeFocused();
+ await expect(page).toHaveURL(/\/coach$/);
+
+ await page.keyboard.press("Escape");
+ await expect(popover).toHaveCount(0);
+ });
+
+ for (const theme of ["light", "dark"] as const) {
+ test(`1440 ${theme}: axe finds nothing on the panel or the quick settings`, async ({
+ page,
+ }, testInfo) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" });
+ await page.addInitScript((value: string) => {
+ window.localStorage.setItem("healthlog-theme", value);
+ }, theme);
+ await mockCoach(page);
+ await openCoach(page);
+ await expect(page.locator("html")).toHaveClass(
+ new RegExp(`(^|\\s)${theme}(\\s|$)`),
+ );
+ await expect(panel(page)).toHaveAttribute("data-state", "open");
+
+ const scan = async (selector: string, label: string) => {
+ const result = await new AxeBuilder({ page })
+ .include(selector)
+ // The data-area switches belong to the sources rail, which this
+ // frame embeds unchanged; a switched-off row dims its label below
+ // AA (`opacity-60` over muted text). That is the rail's own defect,
+ // left for the rail's rework with the lookback limit.
+ .exclude('[data-slot="coach-sources-list"]')
+ .withTags([
+ "wcag2a",
+ "wcag2aa",
+ "wcag21a",
+ "wcag21aa",
+ "best-practice",
+ ])
+ .analyze();
+ expect(
+ result.violations.map((v) => ({
+ id: v.id,
+ nodes: v.nodes.map((n) => n.target.join(" ")),
+ })),
+ `${label} accessibility violations`,
+ ).toEqual([]);
+ };
+
+ await scan('[data-slot="coach-conversations-panel"]', "panel");
+ await shot(page, testInfo, `coach-frame-1440-${theme}-panel`);
+
+ await panel(page).locator('[data-slot="coach-settings"]').click();
+ const popover = page.locator('[data-slot="coach-settings-popover"]');
+ await expect(
+ popover.locator('[data-slot="coach-quick-provider"]'),
+ ).toBeVisible();
+ await scan('[data-slot="coach-settings-popover"]', "settings");
+ await shot(page, testInfo, `coach-frame-1440-${theme}-settings`);
+ });
+ }
+
+ test("390: the panel is a sheet, the gear a bottom sheet, and nothing scrolls sideways", async ({
+ page,
+ }, testInfo) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await mockCoach(page);
+ await openCoach(page);
+
+ // Below 1280 px the panel waits to be asked.
+ await expect(panel(page)).toHaveCount(0);
+ await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
+ await expectNoSidewaysScroll(page);
+ await shot(page, testInfo, "coach-frame-390-thread");
+
+ await toggle(page).click();
+ const sheet = page.getByRole("dialog", { name: "Conversations" });
+ await expect(sheet).toBeVisible();
+ await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
+ const fab = sheet.locator('[data-slot="coach-panel-new-chat"]');
+ const fabBox = (await fab.boundingBox())!;
+ expect(390 - (fabBox.x + fabBox.width)).toBeCloseTo(16, 0);
+ expect(fabBox.width).toBeGreaterThanOrEqual(44);
+ await shot(page, testInfo, "coach-frame-390-panel");
+
+ await page.keyboard.press("Escape");
+ await expect(sheet).toHaveCount(0);
+ await expect(toggle(page)).toBeFocused();
+
+ // The gear in the sheet opens a bottom sheet over it.
+ await toggle(page).click();
+ await expect(sheet).toBeVisible();
+ const gear = sheet.locator('[data-slot="coach-settings"]');
+ await gear.click();
+ const settings = page.locator('[data-slot="responsive-sheet-content"]');
+ await expect(settings).toHaveAttribute("data-variant", "sheet");
+ await expect(
+ settings.locator('[data-slot="coach-quick-provider"]'),
+ ).toBeVisible();
+ const box = (await settings.boundingBox())!;
+ expect(Math.round(box.y + box.height)).toBe(844);
+ await shot(page, testInfo, "coach-frame-390-settings");
+ await page.keyboard.press("Escape");
+ await expect(settings).toHaveCount(0);
+ await expect(gear).toBeFocused();
+ await expect(sheet).toBeVisible();
+
+ // Picking a conversation closes the sheet and opens the thread.
+ await sheet
+ .locator('[data-slot="coach-history-select"]')
+ .filter({ hasText: "Weight over the summer" })
+ .click();
+ await expect(sheet).toHaveCount(0);
+ await expect(page).toHaveURL(/\/coach\?c=frame-weight$/);
+ await expectNoSidewaysScroll(page);
+ });
+});
From 5cfacba16a5048a591b731db16b95968a918ab31 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 4 Oct 2026 19:56:30 +0200
Subject: [PATCH 03/33] feat(dashboard): add a Today overview to the hero
The hero led with the first sentence of the cached briefing, which was
often a greeting ("Good morning.") while the sentence with content was
never shown. Without an AI provider it fell back to "Your health score
today is N.", which the hero then removed because the ring already
shows the number, leaving a ring and "Nothing needs your attention".
The daily digest now publishes the lead resolved on the server and a
short list of facts about the day:
- lead: the reaction line or the first briefing sentence that is not a
greeting and does not repeat the score while AI text is available,
otherwise a deterministic sentence about the strongest signal (a vital
outside its personal range, a night an hour off the usual, vitals in
range, last night). A fact the lead was built from leaves the list.
- today: up to five statements in priority order (Rest Mode, doses taken
today, an appointment today or tomorrow that is not already on the
rail, last night's sleep against the usual, vitals against their
personal range, cycle day). Module-gated on the server, never padded,
never asking for anything. A phone shows the first four.
- restMode: passed through while an illness episode is active.
- score.steadyWeeks: how long the score has held, read off the stored
daily scores, shown under the ring in place of a delta.
briefingLead skips greetings and score sentences the same way, so the
morning push improves with it. Visit times are rendered in the profile
timezone and hour-cycle preference; the cycle day comes from the same
verdict the cycle page uses. The ring, the rail and the charts are
unchanged. OpenAPI regenerated; additive fields only.
---
docs/api/openapi.yaml | 97 ++-
e2e/dashboard.spec.ts | 3 +
e2e/today-overview.spec.ts | 319 ++++++++++
e2e/v1341-ui-evidence.spec.ts | 4 +
messages/de.json | 84 ++-
messages/en.json | 84 ++-
messages/es.json | 84 ++-
messages/fr.json | 84 ++-
messages/it.json | 84 ++-
messages/ko.json | 84 ++-
messages/pl.json | 84 ++-
.../api/daily/digest/__tests__/route.test.ts | 3 +
.../delegated-write-affordances.test.tsx | 3 +
.../__tests__/today-hero-just-in.test.tsx | 10 +
.../daily/__tests__/today-hero.test.tsx | 215 ++++++-
src/components/daily/today-hero.tsx | 240 ++++---
src/lib/cycle/today-verdict.ts | 120 ++++
.../__tests__/daily-briefing-push.test.ts | 3 +
src/lib/daily/__tests__/digest.test.ts | 113 +++-
src/lib/daily/__tests__/load-digest.test.ts | 213 +++++++
src/lib/daily/__tests__/score-steady.test.ts | 79 +++
.../daily/__tests__/today-overview.test.ts | 588 +++++++++++++++++
src/lib/daily/digest.ts | 144 ++++-
src/lib/daily/load-digest.ts | 303 ++++++++-
src/lib/daily/score-steady.ts | 77 +++
src/lib/daily/today-overview.ts | 594 ++++++++++++++++++
src/lib/openapi/routes/daily.ts | 67 +-
.../daily-digest-today-overview.test.ts | 224 +++++++
28 files changed, 3837 insertions(+), 170 deletions(-)
create mode 100644 e2e/today-overview.spec.ts
create mode 100644 src/lib/cycle/today-verdict.ts
create mode 100644 src/lib/daily/__tests__/score-steady.test.ts
create mode 100644 src/lib/daily/__tests__/today-overview.test.ts
create mode 100644 src/lib/daily/score-steady.ts
create mode 100644 src/lib/daily/today-overview.ts
create mode 100644 tests/integration/daily-digest-today-overview.test.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index bb71a2e8e..9cf85e31b 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -72427,6 +72427,15 @@ components:
so this is what distinguishes a narrow score from a broad one. Optional so older cached digests
without the field stay valid.
$ref: "#/components/schemas/HealthScoreBasis"
+ steadyWeeks:
+ description: Whole weeks the score has held where it is (same band, within two points, computed the same way), read off
+ the stored daily scores. Null when it has not held for two weeks, when the stored record does not
+ reach today, or when the score moved. Shown in place of a delta, never beside one.
+ anyOf:
+ - type: integer
+ exclusiveMinimum: 0
+ maximum: 9007199254740991
+ - type: "null"
required:
- value
- band
@@ -72469,8 +72478,56 @@ components:
anyOf:
- type: string
- type: "null"
- description: First sentence of the cached briefing paragraph. Null while the `briefing` capability is unavailable;
- `line` then falls to its deterministic floor.
+ description: "The first sentence of the cached briefing paragraph that says something: a greeting or a sentence that
+ only repeats the score is skipped. Null while the `briefing` capability is unavailable or when no sentence
+ qualifies; `line` then falls to its deterministic floor."
+ lead:
+ anyOf:
+ - type: object
+ properties:
+ text:
+ type: string
+ source:
+ type: string
+ enum:
+ - reaction
+ - briefing
+ - signal
+ description: "`reaction` and `briefing` are cached model text; `signal` is the deterministic sentence about the day's
+ strongest signal."
+ required:
+ - text
+ - source
+ additionalProperties: false
+ - type: "null"
+ description: "The Today hero's lead line, resolved: the reaction line or a briefing sentence while their capabilities
+ are available, otherwise a deterministic sentence about the day's strongest signal (a vital outside its
+ personal range, an unusual night, vitals in range, last night). Null when there is nothing to say. When the
+ deterministic sentence is built from a fact, that fact is left out of `today`. Additive since v1.40."
+ today:
+ maxItems: 5
+ type: array
+ items:
+ $ref: "#/components/schemas/DailyTodayFact"
+ description: "The Today overview: up to 5 statements about the day in priority order (Rest Mode, medications taken
+ today, an appointment today or tomorrow that is not already on the rail, last night's sleep, vitals against
+ their personal range, cycle day), never padded. A fact about a switched-off module is absent. A narrow
+ surface shows the first 4. Additive since v1.40."
+ restMode:
+ anyOf:
+ - type: object
+ properties:
+ day:
+ type: integer
+ exclusiveMinimum: 0
+ maximum: 9007199254740991
+ description: 1-based day of the episode on the reader's calendar.
+ required:
+ - day
+ additionalProperties: false
+ - type: "null"
+ description: "Rest Mode: present while an illness episode is active and the illness module is on, else null. Additive
+ since v1.40."
line:
type: string
description: "Push / lock-screen line: cached-AI lead with a deterministic floor."
@@ -72535,6 +72592,9 @@ components:
- score
- topSignal
- briefingLead
+ - lead
+ - today
+ - restMode
- line
- worthALook
- justIn
@@ -72543,6 +72603,39 @@ components:
additionalProperties: false
description: The one data spine of the daily-value system — composed from already-cached data, never a fresh AI call.
Consumed by the Today surface, the daily push, and a future iOS widget.
+ DailyTodayFact:
+ type: object
+ properties:
+ kind:
+ type: string
+ enum:
+ - rest_mode
+ - medications
+ - appointment
+ - sleep
+ - vitals
+ - cycle
+ description: Closed fact kind.
+ label:
+ type: string
+ description: Localised label (resolved server-side).
+ value:
+ type: string
+ description: Localised value (resolved server-side).
+ href:
+ type: string
+ description: The page the fact belongs to.
+ moduleKey:
+ description: The module that admitted the fact; absent for core data.
+ type: string
+ required:
+ - kind
+ - label
+ - value
+ - href
+ additionalProperties: false
+ description: One statement about the day on the Today overview. A fact never asks the reader to do anything; the rail is
+ the only place that does.
DailyPriorityItem:
type: object
properties:
diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts
index f76e12c9c..4b69093d9 100644
--- a/e2e/dashboard.spec.ts
+++ b/e2e/dashboard.spec.ts
@@ -115,6 +115,9 @@ test.describe("authenticated dashboard render", () => {
score: { value: 82, band: "green", delta: 2 },
topSignal: null,
briefingLead: "Your week is trending steady.",
+ lead: { text: "Your week is trending steady.", source: "briefing" },
+ today: [],
+ restMode: null,
line: "Your week is trending steady.",
worthALook: [
{
diff --git a/e2e/today-overview.spec.ts b/e2e/today-overview.spec.ts
new file mode 100644
index 000000000..9ac063b2d
--- /dev/null
+++ b/e2e/today-overview.spec.ts
@@ -0,0 +1,319 @@
+import { mkdirSync } from "node:fs";
+import { join } from "node:path";
+
+import type { Page } from "@playwright/test";
+
+import { expect, test } from "./setup/test";
+import { STORAGE_STATE_PATH } from "./setup/global-setup";
+import {
+ mockDashboardSnapshot,
+ POPULATED_SUMMARIES,
+} from "./utils/mock-dashboard-snapshot";
+import type { DailyDigest } from "@/lib/daily/digest";
+import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
+
+/**
+ * The Today overview on the dashboard hero, at a desktop and a phone width.
+ *
+ * What it proves is geometry, because that is what the unit suites cannot
+ * see: the facts sit beside the ring on a wide screen and above it on a
+ * phone, a phone shows four facts and a wide screen five, nothing pushes the
+ * page sideways, the hero keeps the width of the cards around it, and the
+ * ring keeps its size. Each state is also captured as an element screenshot
+ * under `test-results/today-overview/` for a visual check.
+ *
+ * The digests are what the server would publish for each state. They carry
+ * the pre-overview fields too (`briefingLead`, `line`), with the values the
+ * old server sent, so the same fixtures render the previous hero on an older
+ * build for a before/after comparison.
+ *
+ * Assertions anchor on `data-slot` / `data-kind`, never on visible text.
+ */
+test.use({ storageState: STORAGE_STATE_PATH });
+
+const SHOTS = join(process.cwd(), "test-results", "today-overview");
+
+const VIEWPORTS = [
+ { name: "desktop", width: 1440, height: 900 },
+ { name: "mobile", width: 390, height: 844 },
+] as const;
+
+const BASE: DailyDigest = {
+ generatedAt: new Date().toISOString(),
+ ai: DIGEST_AI_AVAILABLE,
+ phase: "final",
+ sleepPending: false,
+ score: {
+ value: 94,
+ band: "green",
+ delta: null,
+ deltaReason: "first_eligibility_window",
+ steadyWeeks: 4,
+ },
+ topSignal: null,
+ briefingLead: null,
+ lead: null,
+ today: [],
+ restMode: null,
+ line: "Your health score today is 94.",
+ worthALook: [],
+ justIn: null,
+ reactionLine: null,
+};
+
+const FACTS: DailyDigest["today"] = [
+ {
+ kind: "medications",
+ label: "Medications",
+ value: "1 of 3 taken",
+ href: "/medications",
+ moduleKey: "medications",
+ },
+ {
+ kind: "appointment",
+ label: "Appointment",
+ value: "Tomorrow 09:30, Routine visit",
+ href: "/checkups",
+ },
+ {
+ kind: "sleep",
+ label: "Last night",
+ value: "7h 20m, close to your usual",
+ href: "/insights/sleep",
+ moduleKey: "sleep",
+ },
+ {
+ kind: "vitals",
+ label: "Vitals",
+ value: "6 checked, all in your range",
+ href: "/insights",
+ },
+ {
+ kind: "cycle",
+ label: "Cycle",
+ value: "Follicular, day 12",
+ href: "/cycle",
+ moduleKey: "cycle",
+ },
+];
+
+const STATES: Record = {
+ // AI on. The old server led with the briefing's first sentence, which was
+ // the greeting.
+ "ai-on": {
+ ...BASE,
+ briefingLead: "Good morning.",
+ line: "Good morning.",
+ topSignal: {
+ sourceMetric: "bp",
+ tone: "good",
+ headline: "Blood pressure is sitting in the optimal band",
+ nudge: "",
+ delta: "↓ ~10 mmHg systolic vs the start of the window",
+ },
+ lead: {
+ text: "Your latest blood pressure is sitting in the optimal band.",
+ source: "briefing",
+ },
+ today: FACTS,
+ worthALook: [
+ {
+ kind: "dose_window",
+ title: "Medication due",
+ body: "Ramipril is due soon.",
+ status: "info",
+ actions: [
+ {
+ labelKey: "daily.action.logDose",
+ intent: "dose.log",
+ href: "/medications",
+ },
+ ],
+ moduleKey: "medications",
+ },
+ ],
+ },
+ // AI off. The old server had only the score sentence, which the hero then
+ // removed again, leaving the ring and "Nothing needs your attention".
+ "ai-off": {
+ ...BASE,
+ ai: {
+ ...DIGEST_AI_AVAILABLE,
+ briefing: {
+ available: false,
+ reason: "no_provider",
+ onDeviceAllowed: false,
+ },
+ },
+ lead: {
+ text: "Resting heart rate is at 61 bpm, above your usual range of 50 to 58 bpm.",
+ source: "signal",
+ },
+ today: [FACTS[0], FACTS[2], FACTS[4]],
+ },
+ // A quiet day: one calm sentence and no facts beyond it.
+ quiet: {
+ ...BASE,
+ lead: {
+ text: "All 6 of your latest vitals sit inside their usual range.",
+ source: "signal",
+ },
+ },
+};
+
+async function openDashboard(page: Page, digest: DailyDigest) {
+ await mockDashboardSnapshot(page, { summaries: POPULATED_SUMMARIES });
+ await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ data: digest, error: null }),
+ }),
+ );
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ const hero = page.locator('[data-slot="today-hero"]');
+ await expect(hero).toBeVisible({ timeout: 15_000 });
+ await expect(page.locator('[data-slot="dashboard-tile-strip"]')).toBeVisible({
+ timeout: 15_000,
+ });
+ return hero;
+}
+
+test.describe("Today overview geometry", () => {
+ test.beforeEach(({}, testInfo) => {
+ test.skip(
+ testInfo.project.name !== "chromium-desktop",
+ "viewport-driven spec; desktop project only",
+ );
+ });
+
+ for (const viewport of VIEWPORTS) {
+ for (const [state, digest] of Object.entries(STATES)) {
+ test(`${state} at ${viewport.width}px`, async ({ page }) => {
+ await page.setViewportSize(viewport);
+ const hero = await openDashboard(page, digest);
+
+ mkdirSync(SHOTS, { recursive: true });
+ await hero.screenshot({
+ path: join(SHOTS, `${viewport.name}-${state}.png`),
+ });
+
+ // Nothing pushes the page sideways.
+ const overflow = await page.evaluate(
+ () => document.documentElement.scrollWidth - window.innerWidth,
+ );
+ expect(overflow).toBeLessThanOrEqual(0);
+
+ // The hero spans the same column as the tile strip below it.
+ const heroBox = (await hero.boundingBox())!;
+ const stripBox = (await page
+ .locator('[data-slot="dashboard-tile-strip"]')
+ .boundingBox())!;
+ expect(Math.abs(heroBox.x - stripBox.x)).toBeLessThanOrEqual(1);
+ expect(Math.abs(heroBox.width - stripBox.width)).toBeLessThanOrEqual(1);
+
+ // The ring keeps its size: the same 168 px dial as before.
+ const ring = hero.locator('[data-slot="score-ring"]').first();
+ const ringBox = (await ring.boundingBox())!;
+ expect(Math.round(ringBox.width)).toBe(168);
+ expect(Math.round(ringBox.height)).toBe(168);
+
+ const lead = hero.locator('[data-slot="today-hero-lead"]');
+ await expect(lead).toBeVisible();
+ await expect(lead).toHaveAttribute("data-source", digest.lead!.source);
+ const leadBox = (await lead.boundingBox())!;
+
+ const facts = hero.locator('[data-slot="today-hero-fact"]');
+ const expected = digest.today.length;
+ await expect(facts).toHaveCount(expected);
+ const visible = await facts.evaluateAll(
+ (items) =>
+ items.filter((el) => (el as HTMLElement).offsetParent !== null)
+ .length,
+ );
+ expect(visible).toBe(
+ viewport.width < 768 ? Math.min(expected, 4) : expected,
+ );
+
+ // The all-clear sentence never sits under a lead or facts.
+ await expect(
+ hero.locator('[data-slot="today-hero-all-clear"]'),
+ ).toHaveCount(0);
+
+ if (expected === 0) {
+ await expect(
+ hero.locator('[data-slot="today-hero-today"]'),
+ ).toHaveCount(0);
+ // A quiet day keeps the full composition: lead beside the ring.
+ await expect(hero).toHaveAttribute("data-layout", "narrative");
+ return;
+ }
+
+ const block = hero.locator('[data-slot="today-hero-today"]');
+ const blockBox = (await block.boundingBox())!;
+ // The facts follow the lead.
+ expect(blockBox.y).toBeGreaterThanOrEqual(
+ leadBox.y + leadBox.height - 1,
+ );
+ if (viewport.width >= 768) {
+ // Beside the ring on a wide screen, never under it.
+ expect(blockBox.x + blockBox.width).toBeLessThanOrEqual(
+ ringBox.x + 1,
+ );
+ expect(blockBox.y).toBeLessThan(ringBox.y + ringBox.height);
+ } else {
+ // Above the ring on a phone, so the day reads before the dial.
+ expect(blockBox.y + blockBox.height).toBeLessThanOrEqual(
+ ringBox.y + 1,
+ );
+ }
+
+ // No fact line overflows its own box.
+ const clipped = await facts.evaluateAll(
+ (items) =>
+ items.filter((el) => {
+ const link = el.querySelector("a");
+ return link ? link.scrollWidth > link.clientWidth + 1 : false;
+ }).length,
+ );
+ expect(clipped).toBe(0);
+ });
+ }
+ }
+
+ test("the hero width does not move when the overview arrives", async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ let calls = 0;
+ await mockDashboardSnapshot(page, { summaries: POPULATED_SUMMARIES });
+ await page.route(/\/api\/daily\/digest(\?|$)/, async (route) => {
+ calls += 1;
+ // First paint: a quiet digest. Every later read carries the overview.
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: calls > 1 ? STATES["ai-on"] : STATES.quiet,
+ error: null,
+ }),
+ });
+ });
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ const hero = page.locator('[data-slot="today-hero"]');
+ await expect(hero).toBeVisible({ timeout: 15_000 });
+ const before = (await hero.boundingBox())!;
+
+ // The tab becomes visible again: the signal TanStack Query's focus
+ // manager refetches on, as `today-just-in.spec.ts` uses.
+ await page.evaluate(() =>
+ window.dispatchEvent(new Event("visibilitychange")),
+ );
+ await expect(hero.locator('[data-slot="today-hero-today"]')).toBeVisible({
+ timeout: 15_000,
+ });
+ const after = (await hero.boundingBox())!;
+ expect(Math.abs(after.x - before.x)).toBeLessThanOrEqual(1);
+ expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1);
+ });
+});
diff --git a/e2e/v1341-ui-evidence.spec.ts b/e2e/v1341-ui-evidence.spec.ts
index ef9e150f9..78d6f3ad5 100644
--- a/e2e/v1341-ui-evidence.spec.ts
+++ b/e2e/v1341-ui-evidence.spec.ts
@@ -31,6 +31,9 @@ const NARRATIVE_DIGEST: DailyDigest = {
},
topSignal: LONG_HEADLINE_BRIEFING.signalsOfDay?.[0] ?? null,
briefingLead: LONG_HEADLINE_BRIEFING.paragraph,
+ lead: { text: LONG_HEADLINE_BRIEFING.paragraph, source: "briefing" },
+ today: [],
+ restMode: null,
line: LONG_HEADLINE_BRIEFING.paragraph,
worthALook: [],
justIn: null,
@@ -104,6 +107,7 @@ test.describe("v1.34.1 deterministic UI evidence", () => {
...NARRATIVE_DIGEST,
topSignal: null,
briefingLead: null,
+ lead: null,
line: "Dein Gesundheitsscore liegt heute bei 84.",
reactionLine: null,
};
diff --git a/messages/de.json b/messages/de.json
index 4f69db510..f23bc5644 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -10983,7 +10983,27 @@
"sleepPending": "Der Schlaf von letzter Nacht fehlt noch — der heutige Wert ist vorläufig.",
"worthALook": "Einen Blick wert",
"allClear": "Heute braucht nichts deine Aufmerksamkeit.",
- "ringLink": "{metric}-Details öffnen"
+ "ringLink": "{metric}-Details öffnen",
+ "heading": "Heute",
+ "steadyWeeksOne": "Seit {count} Woche stabil",
+ "steadyWeeksFew": "Seit {count} Wochen stabil",
+ "steadyWeeksOther": "Seit {count} Wochen stabil",
+ "valueWithUnit": "{value} {unit}",
+ "rangeWithUnit": "{low} bis {high} {unit}",
+ "vital": {
+ "RESTING_HEART_RATE": "Ruhepuls",
+ "HEART_RATE_VARIABILITY": "Herzfrequenzvariabilität",
+ "RESPIRATORY_RATE": "Atemfrequenz",
+ "OXYGEN_SATURATION": "Sauerstoffsättigung",
+ "BODY_TEMPERATURE": "Körpertemperatur",
+ "SKIN_TEMPERATURE": "Hauttemperatur",
+ "BLOOD_PRESSURE_SYS": "Systolischer Blutdruck",
+ "BLOOD_PRESSURE_DIA": "Diastolischer Blutdruck",
+ "BLOOD_GLUCOSE": "Blutzucker",
+ "PULSE": "Puls",
+ "WEIGHT": "Gewicht",
+ "generic": "Ein Vitalwert"
+ }
},
"milestone": {
"metric": {
@@ -11019,6 +11039,68 @@
"body": "Ein ganzer Monat mit {metric} in deinem gewohnten Bereich. Wunderbar gleichmäßig."
}
}
+ },
+ "lead": {
+ "vitalOutside": {
+ "above": "{metric} liegt bei {value}, über deinem üblichen Bereich von {range}.",
+ "below": "{metric} liegt bei {value}, unter deinem üblichen Bereich von {range}."
+ },
+ "vitalsOutside": {
+ "aboveOne": "{metric} liegt bei {value}, über deinem üblichen Bereich von {range}, und {count} weiterer Vitalwert liegt ebenfalls außerhalb.",
+ "aboveFew": "{metric} liegt bei {value}, über deinem üblichen Bereich von {range}, und {count} weitere Vitalwerte liegen ebenfalls außerhalb.",
+ "aboveOther": "{metric} liegt bei {value}, über deinem üblichen Bereich von {range}, und {count} weitere Vitalwerte liegen ebenfalls außerhalb.",
+ "belowOne": "{metric} liegt bei {value}, unter deinem üblichen Bereich von {range}, und {count} weiterer Vitalwert liegt ebenfalls außerhalb.",
+ "belowFew": "{metric} liegt bei {value}, unter deinem üblichen Bereich von {range}, und {count} weitere Vitalwerte liegen ebenfalls außerhalb.",
+ "belowOther": "{metric} liegt bei {value}, unter deinem üblichen Bereich von {range}, und {count} weitere Vitalwerte liegen ebenfalls außerhalb."
+ },
+ "sleep": {
+ "more": "Letzte Nacht hast du {duration} geschlafen, {diff} mehr als sonst.",
+ "less": "Letzte Nacht hast du {duration} geschlafen, {diff} weniger als sonst.",
+ "usual": "Letzte Nacht hast du {duration} geschlafen, etwa so viel wie sonst.",
+ "plain": "Letzte Nacht hast du {duration} geschlafen."
+ },
+ "vitalsInRangeOne": "Dein letzter Vitalwert liegt in seinem üblichen Bereich.",
+ "vitalsInRangeFew": "Alle {count} letzten Vitalwerte liegen in ihrem üblichen Bereich.",
+ "vitalsInRangeOther": "Alle {count} letzten Vitalwerte liegen in ihrem üblichen Bereich.",
+ "vitalInRange": "{metric} liegt bei {value}, innerhalb deines üblichen Bereichs von {range}."
+ },
+ "todayFact": {
+ "restMode": {
+ "label": "Ruhemodus",
+ "value": "Tag {day}"
+ },
+ "medications": {
+ "label": "Medikamente",
+ "value": "{taken} von {scheduled} genommen"
+ },
+ "appointment": {
+ "label": "Termin",
+ "today": "Heute {time}, {what}",
+ "tomorrow": "Morgen {time}, {what}"
+ },
+ "sleep": {
+ "label": "Letzte Nacht",
+ "plain": "{duration}",
+ "usual": "{duration}, etwa wie sonst",
+ "more": "{duration}, {diff} mehr als sonst",
+ "less": "{duration}, {diff} weniger als sonst"
+ },
+ "vitals": {
+ "label": "Vitalwerte",
+ "allInOne": "{count} geprüft, im Bereich",
+ "allInFew": "{count} geprüft, alle im Bereich",
+ "allInOther": "{count} geprüft, alle im Bereich",
+ "oneOut": {
+ "above": "{metric} über deinem Bereich",
+ "below": "{metric} unter deinem Bereich"
+ },
+ "someOut": "{count} von {total} außerhalb deines Bereichs"
+ },
+ "cycle": {
+ "label": "Zyklus",
+ "value": "{phase}, Tag {day}",
+ "dayOnly": "Tag {day}"
+ }
}
},
"reportSelection": {
diff --git a/messages/en.json b/messages/en.json
index 92d60007b..491ca0e5a 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -10983,7 +10983,27 @@
"sleepPending": "Last night's sleep isn't in yet — today's read is provisional.",
"worthALook": "Worth a look",
"allClear": "Nothing needs your attention today.",
- "ringLink": "Open {metric} details"
+ "ringLink": "Open {metric} details",
+ "heading": "Today",
+ "steadyWeeksOne": "Steady for {count} week",
+ "steadyWeeksFew": "Steady for {count} weeks",
+ "steadyWeeksOther": "Steady for {count} weeks",
+ "valueWithUnit": "{value} {unit}",
+ "rangeWithUnit": "{low} to {high} {unit}",
+ "vital": {
+ "RESTING_HEART_RATE": "Resting heart rate",
+ "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "RESPIRATORY_RATE": "Respiratory rate",
+ "OXYGEN_SATURATION": "Oxygen saturation",
+ "BODY_TEMPERATURE": "Body temperature",
+ "SKIN_TEMPERATURE": "Skin temperature",
+ "BLOOD_PRESSURE_SYS": "Systolic blood pressure",
+ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
+ "BLOOD_GLUCOSE": "Blood glucose",
+ "PULSE": "Pulse",
+ "WEIGHT": "Weight",
+ "generic": "A vital"
+ }
},
"milestone": {
"metric": {
@@ -11019,6 +11039,68 @@
"body": "A full month with your {metric} in your usual range. Beautifully steady."
}
}
+ },
+ "lead": {
+ "vitalOutside": {
+ "above": "{metric} is at {value}, above your usual range of {range}.",
+ "below": "{metric} is at {value}, below your usual range of {range}."
+ },
+ "vitalsOutside": {
+ "aboveOne": "{metric} is at {value}, above your usual range of {range}, and {count} more vital is outside its range too.",
+ "aboveFew": "{metric} is at {value}, above your usual range of {range}, and {count} more vitals are outside their range too.",
+ "aboveOther": "{metric} is at {value}, above your usual range of {range}, and {count} more vitals are outside their range too.",
+ "belowOne": "{metric} is at {value}, below your usual range of {range}, and {count} more vital is outside its range too.",
+ "belowFew": "{metric} is at {value}, below your usual range of {range}, and {count} more vitals are outside their range too.",
+ "belowOther": "{metric} is at {value}, below your usual range of {range}, and {count} more vitals are outside their range too."
+ },
+ "sleep": {
+ "more": "Last night you slept {duration}, {diff} more than usual.",
+ "less": "Last night you slept {duration}, {diff} less than usual.",
+ "usual": "Last night you slept {duration}, close to your usual.",
+ "plain": "Last night you slept {duration}."
+ },
+ "vitalsInRangeOne": "Your latest vital sits inside its usual range.",
+ "vitalsInRangeFew": "All {count} of your latest vitals sit inside their usual range.",
+ "vitalsInRangeOther": "All {count} of your latest vitals sit inside their usual range.",
+ "vitalInRange": "{metric} is at {value}, inside your usual range of {range}."
+ },
+ "todayFact": {
+ "restMode": {
+ "label": "Rest mode",
+ "value": "Day {day}"
+ },
+ "medications": {
+ "label": "Medications",
+ "value": "{taken} of {scheduled} taken"
+ },
+ "appointment": {
+ "label": "Appointment",
+ "today": "Today {time}, {what}",
+ "tomorrow": "Tomorrow {time}, {what}"
+ },
+ "sleep": {
+ "label": "Last night",
+ "plain": "{duration}",
+ "usual": "{duration}, close to your usual",
+ "more": "{duration}, {diff} more than usual",
+ "less": "{duration}, {diff} less than usual"
+ },
+ "vitals": {
+ "label": "Vitals",
+ "allInOne": "{count} checked, in your range",
+ "allInFew": "{count} checked, all in your range",
+ "allInOther": "{count} checked, all in your range",
+ "oneOut": {
+ "above": "{metric} above your range",
+ "below": "{metric} below your range"
+ },
+ "someOut": "{count} of {total} outside your range"
+ },
+ "cycle": {
+ "label": "Cycle",
+ "value": "{phase}, day {day}",
+ "dayOnly": "Day {day}"
+ }
}
},
"reportSelection": {
diff --git a/messages/es.json b/messages/es.json
index 7fc4efbfb..c65502eb3 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -10983,7 +10983,27 @@
"sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.",
"worthALook": "Merece un vistazo",
"allClear": "Hoy nada necesita tu atención.",
- "ringLink": "Abrir detalles de {metric}"
+ "ringLink": "Abrir detalles de {metric}",
+ "heading": "Hoy",
+ "steadyWeeksOne": "Estable desde hace {count} semana",
+ "steadyWeeksFew": "Estable desde hace {count} semanas",
+ "steadyWeeksOther": "Estable desde hace {count} semanas",
+ "valueWithUnit": "{value} {unit}",
+ "rangeWithUnit": "{low} a {high} {unit}",
+ "vital": {
+ "RESTING_HEART_RATE": "Frecuencia cardíaca en reposo",
+ "HEART_RATE_VARIABILITY": "Variabilidad de la frecuencia cardíaca",
+ "RESPIRATORY_RATE": "Frecuencia respiratoria",
+ "OXYGEN_SATURATION": "Saturación de oxígeno",
+ "BODY_TEMPERATURE": "Temperatura corporal",
+ "SKIN_TEMPERATURE": "Temperatura de la piel",
+ "BLOOD_PRESSURE_SYS": "Presión arterial sistólica",
+ "BLOOD_PRESSURE_DIA": "Presión arterial diastólica",
+ "BLOOD_GLUCOSE": "Glucosa en sangre",
+ "PULSE": "Pulso",
+ "WEIGHT": "Peso",
+ "generic": "Un signo vital"
+ }
},
"milestone": {
"metric": {
@@ -11019,6 +11039,68 @@
"body": "Un mes entero con tu {metric} dentro de tu rango habitual. Maravillosamente estable."
}
}
+ },
+ "lead": {
+ "vitalOutside": {
+ "above": "{metric} está en {value}, por encima de tu rango habitual de {range}.",
+ "below": "{metric} está en {value}, por debajo de tu rango habitual de {range}."
+ },
+ "vitalsOutside": {
+ "aboveOne": "{metric} está en {value}, por encima de tu rango habitual de {range}, y {count} signo vital más también está fuera de su rango.",
+ "aboveFew": "{metric} está en {value}, por encima de tu rango habitual de {range}, y {count} signos vitales más también están fuera de su rango.",
+ "aboveOther": "{metric} está en {value}, por encima de tu rango habitual de {range}, y {count} signos vitales más también están fuera de su rango.",
+ "belowOne": "{metric} está en {value}, por debajo de tu rango habitual de {range}, y {count} signo vital más también está fuera de su rango.",
+ "belowFew": "{metric} está en {value}, por debajo de tu rango habitual de {range}, y {count} signos vitales más también están fuera de su rango.",
+ "belowOther": "{metric} está en {value}, por debajo de tu rango habitual de {range}, y {count} signos vitales más también están fuera de su rango."
+ },
+ "sleep": {
+ "more": "Anoche dormiste {duration}, {diff} más de lo habitual.",
+ "less": "Anoche dormiste {duration}, {diff} menos de lo habitual.",
+ "usual": "Anoche dormiste {duration}, cerca de lo habitual.",
+ "plain": "Anoche dormiste {duration}."
+ },
+ "vitalsInRangeOne": "Tu último signo vital está dentro de su rango habitual.",
+ "vitalsInRangeFew": "Tus {count} últimos signos vitales están dentro de su rango habitual.",
+ "vitalsInRangeOther": "Tus {count} últimos signos vitales están dentro de su rango habitual.",
+ "vitalInRange": "{metric} está en {value}, dentro de tu rango habitual de {range}."
+ },
+ "todayFact": {
+ "restMode": {
+ "label": "Modo descanso",
+ "value": "Día {day}"
+ },
+ "medications": {
+ "label": "Medicamentos",
+ "value": "{taken} de {scheduled} tomados"
+ },
+ "appointment": {
+ "label": "Cita",
+ "today": "Hoy {time}, {what}",
+ "tomorrow": "Mañana {time}, {what}"
+ },
+ "sleep": {
+ "label": "Anoche",
+ "plain": "{duration}",
+ "usual": "{duration}, cerca de lo habitual",
+ "more": "{duration}, {diff} más de lo habitual",
+ "less": "{duration}, {diff} menos de lo habitual"
+ },
+ "vitals": {
+ "label": "Signos vitales",
+ "allInOne": "{count} revisado, en tu rango",
+ "allInFew": "{count} revisados, todos en tu rango",
+ "allInOther": "{count} revisados, todos en tu rango",
+ "oneOut": {
+ "above": "{metric} por encima de tu rango",
+ "below": "{metric} por debajo de tu rango"
+ },
+ "someOut": "{count} de {total} fuera de tu rango"
+ },
+ "cycle": {
+ "label": "Ciclo",
+ "value": "{phase}, día {day}",
+ "dayOnly": "Día {day}"
+ }
}
},
"reportSelection": {
diff --git a/messages/fr.json b/messages/fr.json
index 989e0ab17..bf1953f36 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -10983,7 +10983,27 @@
"sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.",
"worthALook": "À regarder",
"allClear": "Rien ne requiert ton attention aujourd'hui.",
- "ringLink": "Ouvrir les détails de {metric}"
+ "ringLink": "Ouvrir les détails de {metric}",
+ "heading": "Aujourd'hui",
+ "steadyWeeksOne": "Stable depuis {count} semaine",
+ "steadyWeeksFew": "Stable depuis {count} semaines",
+ "steadyWeeksOther": "Stable depuis {count} semaines",
+ "valueWithUnit": "{value} {unit}",
+ "rangeWithUnit": "{low} à {high} {unit}",
+ "vital": {
+ "RESTING_HEART_RATE": "Fréquence cardiaque au repos",
+ "HEART_RATE_VARIABILITY": "Variabilité de la fréquence cardiaque",
+ "RESPIRATORY_RATE": "Fréquence respiratoire",
+ "OXYGEN_SATURATION": "Saturation en oxygène",
+ "BODY_TEMPERATURE": "Température corporelle",
+ "SKIN_TEMPERATURE": "Température cutanée",
+ "BLOOD_PRESSURE_SYS": "Tension systolique",
+ "BLOOD_PRESSURE_DIA": "Tension diastolique",
+ "BLOOD_GLUCOSE": "Glycémie",
+ "PULSE": "Pouls",
+ "WEIGHT": "Poids",
+ "generic": "Un signe vital"
+ }
},
"milestone": {
"metric": {
@@ -11019,6 +11039,68 @@
"body": "Un mois entier avec votre {metric} dans votre plage habituelle. Merveilleusement stable."
}
}
+ },
+ "lead": {
+ "vitalOutside": {
+ "above": "{metric} est à {value}, au-dessus de ta plage habituelle de {range}.",
+ "below": "{metric} est à {value}, en dessous de ta plage habituelle de {range}."
+ },
+ "vitalsOutside": {
+ "aboveOne": "{metric} est à {value}, au-dessus de ta plage habituelle de {range}, et {count} autre signe vital sort aussi de sa plage.",
+ "aboveFew": "{metric} est à {value}, au-dessus de ta plage habituelle de {range}, et {count} autres signes vitaux sortent aussi de leur plage.",
+ "aboveOther": "{metric} est à {value}, au-dessus de ta plage habituelle de {range}, et {count} autres signes vitaux sortent aussi de leur plage.",
+ "belowOne": "{metric} est à {value}, en dessous de ta plage habituelle de {range}, et {count} autre signe vital sort aussi de sa plage.",
+ "belowFew": "{metric} est à {value}, en dessous de ta plage habituelle de {range}, et {count} autres signes vitaux sortent aussi de leur plage.",
+ "belowOther": "{metric} est à {value}, en dessous de ta plage habituelle de {range}, et {count} autres signes vitaux sortent aussi de leur plage."
+ },
+ "sleep": {
+ "more": "La nuit dernière, tu as dormi {duration}, {diff} de plus que d'habitude.",
+ "less": "La nuit dernière, tu as dormi {duration}, {diff} de moins que d'habitude.",
+ "usual": "La nuit dernière, tu as dormi {duration}, proche de ton habitude.",
+ "plain": "La nuit dernière, tu as dormi {duration}."
+ },
+ "vitalsInRangeOne": "Ton dernier signe vital est dans sa plage habituelle.",
+ "vitalsInRangeFew": "Tes {count} derniers signes vitaux sont tous dans leur plage habituelle.",
+ "vitalsInRangeOther": "Tes {count} derniers signes vitaux sont tous dans leur plage habituelle.",
+ "vitalInRange": "{metric} est à {value}, dans ta plage habituelle de {range}."
+ },
+ "todayFact": {
+ "restMode": {
+ "label": "Mode repos",
+ "value": "Jour {day}"
+ },
+ "medications": {
+ "label": "Médicaments",
+ "value": "{taken} sur {scheduled} pris"
+ },
+ "appointment": {
+ "label": "Rendez-vous",
+ "today": "Aujourd'hui {time}, {what}",
+ "tomorrow": "Demain {time}, {what}"
+ },
+ "sleep": {
+ "label": "La nuit dernière",
+ "plain": "{duration}",
+ "usual": "{duration}, proche de ton habitude",
+ "more": "{duration}, {diff} de plus que d'habitude",
+ "less": "{duration}, {diff} de moins que d'habitude"
+ },
+ "vitals": {
+ "label": "Signes vitaux",
+ "allInOne": "{count} vérifié, dans ta plage",
+ "allInFew": "{count} vérifiés, tous dans ta plage",
+ "allInOther": "{count} vérifiés, tous dans ta plage",
+ "oneOut": {
+ "above": "{metric} au-dessus de ta plage",
+ "below": "{metric} en dessous de ta plage"
+ },
+ "someOut": "{count} sur {total} hors de ta plage"
+ },
+ "cycle": {
+ "label": "Cycle",
+ "value": "{phase}, jour {day}",
+ "dayOnly": "Jour {day}"
+ }
}
},
"reportSelection": {
diff --git a/messages/it.json b/messages/it.json
index b8d636332..986cf9376 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -10983,7 +10983,27 @@
"sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.",
"worthALook": "Da tenere d'occhio",
"allClear": "Oggi nulla richiede la tua attenzione.",
- "ringLink": "Apri i dettagli di {metric}"
+ "ringLink": "Apri i dettagli di {metric}",
+ "heading": "Oggi",
+ "steadyWeeksOne": "Stabile da {count} settimana",
+ "steadyWeeksFew": "Stabile da {count} settimane",
+ "steadyWeeksOther": "Stabile da {count} settimane",
+ "valueWithUnit": "{value} {unit}",
+ "rangeWithUnit": "da {low} a {high} {unit}",
+ "vital": {
+ "RESTING_HEART_RATE": "Frequenza cardiaca a riposo",
+ "HEART_RATE_VARIABILITY": "Variabilità della frequenza cardiaca",
+ "RESPIRATORY_RATE": "Frequenza respiratoria",
+ "OXYGEN_SATURATION": "Saturazione di ossigeno",
+ "BODY_TEMPERATURE": "Temperatura corporea",
+ "SKIN_TEMPERATURE": "Temperatura cutanea",
+ "BLOOD_PRESSURE_SYS": "Pressione sistolica",
+ "BLOOD_PRESSURE_DIA": "Pressione diastolica",
+ "BLOOD_GLUCOSE": "Glicemia",
+ "PULSE": "Polso",
+ "WEIGHT": "Peso",
+ "generic": "Un parametro vitale"
+ }
},
"milestone": {
"metric": {
@@ -11019,6 +11039,68 @@
"body": "Un mese intero con la tua {metric} nel tuo intervallo abituale. Meravigliosamente costante."
}
}
+ },
+ "lead": {
+ "vitalOutside": {
+ "above": "{metric} è a {value}, sopra il tuo intervallo abituale di {range}.",
+ "below": "{metric} è a {value}, sotto il tuo intervallo abituale di {range}."
+ },
+ "vitalsOutside": {
+ "aboveOne": "{metric} è a {value}, sopra il tuo intervallo abituale di {range}, e anche {count} altro parametro vitale è fuori dal suo intervallo.",
+ "aboveFew": "{metric} è a {value}, sopra il tuo intervallo abituale di {range}, e anche altri {count} parametri vitali sono fuori dal loro intervallo.",
+ "aboveOther": "{metric} è a {value}, sopra il tuo intervallo abituale di {range}, e anche altri {count} parametri vitali sono fuori dal loro intervallo.",
+ "belowOne": "{metric} è a {value}, sotto il tuo intervallo abituale di {range}, e anche {count} altro parametro vitale è fuori dal suo intervallo.",
+ "belowFew": "{metric} è a {value}, sotto il tuo intervallo abituale di {range}, e anche altri {count} parametri vitali sono fuori dal loro intervallo.",
+ "belowOther": "{metric} è a {value}, sotto il tuo intervallo abituale di {range}, e anche altri {count} parametri vitali sono fuori dal loro intervallo."
+ },
+ "sleep": {
+ "more": "Stanotte hai dormito {duration}, {diff} in più del solito.",
+ "less": "Stanotte hai dormito {duration}, {diff} in meno del solito.",
+ "usual": "Stanotte hai dormito {duration}, vicino al tuo solito.",
+ "plain": "Stanotte hai dormito {duration}."
+ },
+ "vitalsInRangeOne": "Il tuo ultimo parametro vitale è nel suo intervallo abituale.",
+ "vitalsInRangeFew": "Tutti i tuoi {count} ultimi parametri vitali sono nel loro intervallo abituale.",
+ "vitalsInRangeOther": "Tutti i tuoi {count} ultimi parametri vitali sono nel loro intervallo abituale.",
+ "vitalInRange": "{metric} è a {value}, nel tuo intervallo abituale di {range}."
+ },
+ "todayFact": {
+ "restMode": {
+ "label": "Modalità riposo",
+ "value": "Giorno {day}"
+ },
+ "medications": {
+ "label": "Farmaci",
+ "value": "{taken} di {scheduled} presi"
+ },
+ "appointment": {
+ "label": "Appuntamento",
+ "today": "Oggi {time}, {what}",
+ "tomorrow": "Domani {time}, {what}"
+ },
+ "sleep": {
+ "label": "Stanotte",
+ "plain": "{duration}",
+ "usual": "{duration}, vicino al solito",
+ "more": "{duration}, {diff} in più del solito",
+ "less": "{duration}, {diff} in meno del solito"
+ },
+ "vitals": {
+ "label": "Parametri vitali",
+ "allInOne": "{count} controllato, nel tuo intervallo",
+ "allInFew": "{count} controllati, tutti nel tuo intervallo",
+ "allInOther": "{count} controllati, tutti nel tuo intervallo",
+ "oneOut": {
+ "above": "{metric} sopra il tuo intervallo",
+ "below": "{metric} sotto il tuo intervallo"
+ },
+ "someOut": "{count} su {total} fuori dal tuo intervallo"
+ },
+ "cycle": {
+ "label": "Ciclo",
+ "value": "{phase}, giorno {day}",
+ "dayOnly": "Giorno {day}"
+ }
}
},
"reportSelection": {
diff --git a/messages/ko.json b/messages/ko.json
index 5dc5fadf4..f6d7a04dd 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -10983,7 +10983,27 @@
"sleepPending": "어젯밤 수면이 아직 들어오지 않아 오늘 결과는 잠정치예요.",
"worthALook": "살펴볼 만한 것",
"allClear": "오늘은 신경 쓸 일이 없어요.",
- "ringLink": "{metric} 자세히 보기"
+ "ringLink": "{metric} 자세히 보기",
+ "heading": "오늘",
+ "steadyWeeksOne": "{count}주째 안정적",
+ "steadyWeeksFew": "{count}주째 안정적",
+ "steadyWeeksOther": "{count}주째 안정적",
+ "valueWithUnit": "{value} {unit}",
+ "rangeWithUnit": "{low}~{high} {unit}",
+ "vital": {
+ "RESTING_HEART_RATE": "안정 시 심박수",
+ "HEART_RATE_VARIABILITY": "심박 변이도",
+ "RESPIRATORY_RATE": "호흡수",
+ "OXYGEN_SATURATION": "산소포화도",
+ "BODY_TEMPERATURE": "체온",
+ "SKIN_TEMPERATURE": "피부 온도",
+ "BLOOD_PRESSURE_SYS": "수축기 혈압",
+ "BLOOD_PRESSURE_DIA": "이완기 혈압",
+ "BLOOD_GLUCOSE": "혈당",
+ "PULSE": "맥박",
+ "WEIGHT": "체중",
+ "generic": "생체 지표"
+ }
},
"milestone": {
"metric": {
@@ -11019,6 +11039,68 @@
"body": "{metric} 수치가 꼬박 한 달 동안 평소 범위 안이었어요. 아주 꾸준해요."
}
}
+ },
+ "lead": {
+ "vitalOutside": {
+ "above": "{metric}이(가) {value}로 평소 범위({range})보다 높아요.",
+ "below": "{metric}이(가) {value}로 평소 범위({range})보다 낮아요."
+ },
+ "vitalsOutside": {
+ "aboveOne": "{metric}이(가) {value}로 평소 범위({range})보다 높고, 다른 지표 {count}개도 범위를 벗어났어요.",
+ "aboveFew": "{metric}이(가) {value}로 평소 범위({range})보다 높고, 다른 지표 {count}개도 범위를 벗어났어요.",
+ "aboveOther": "{metric}이(가) {value}로 평소 범위({range})보다 높고, 다른 지표 {count}개도 범위를 벗어났어요.",
+ "belowOne": "{metric}이(가) {value}로 평소 범위({range})보다 낮고, 다른 지표 {count}개도 범위를 벗어났어요.",
+ "belowFew": "{metric}이(가) {value}로 평소 범위({range})보다 낮고, 다른 지표 {count}개도 범위를 벗어났어요.",
+ "belowOther": "{metric}이(가) {value}로 평소 범위({range})보다 낮고, 다른 지표 {count}개도 범위를 벗어났어요."
+ },
+ "sleep": {
+ "more": "어젯밤 {duration} 잤어요. 평소보다 {diff} 더 잤어요.",
+ "less": "어젯밤 {duration} 잤어요. 평소보다 {diff} 덜 잤어요.",
+ "usual": "어젯밤 {duration} 잤어요. 평소와 비슷해요.",
+ "plain": "어젯밤 {duration} 잤어요."
+ },
+ "vitalsInRangeOne": "최근 생체 지표가 평소 범위 안에 있어요.",
+ "vitalsInRangeFew": "최근 생체 지표 {count}개가 모두 평소 범위 안에 있어요.",
+ "vitalsInRangeOther": "최근 생체 지표 {count}개가 모두 평소 범위 안에 있어요.",
+ "vitalInRange": "{metric}이(가) {value}로 평소 범위({range}) 안에 있어요."
+ },
+ "todayFact": {
+ "restMode": {
+ "label": "휴식 모드",
+ "value": "{day}일째"
+ },
+ "medications": {
+ "label": "복약",
+ "value": "{scheduled}회 중 {taken}회 복용"
+ },
+ "appointment": {
+ "label": "예약",
+ "today": "오늘 {time}, {what}",
+ "tomorrow": "내일 {time}, {what}"
+ },
+ "sleep": {
+ "label": "어젯밤",
+ "plain": "{duration}",
+ "usual": "{duration}, 평소와 비슷",
+ "more": "{duration}, 평소보다 {diff} 더",
+ "less": "{duration}, 평소보다 {diff} 덜"
+ },
+ "vitals": {
+ "label": "생체 지표",
+ "allInOne": "{count}개 확인, 범위 안",
+ "allInFew": "{count}개 확인, 모두 범위 안",
+ "allInOther": "{count}개 확인, 모두 범위 안",
+ "oneOut": {
+ "above": "{metric} 범위보다 높음",
+ "below": "{metric} 범위보다 낮음"
+ },
+ "someOut": "{total}개 중 {count}개 범위 밖"
+ },
+ "cycle": {
+ "label": "주기",
+ "value": "{phase}, {day}일째",
+ "dayOnly": "{day}일째"
+ }
}
},
"reportSelection": {
diff --git a/messages/pl.json b/messages/pl.json
index b967ecdc4..dd26fcc00 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -10983,7 +10983,27 @@
"sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.",
"worthALook": "Warto sprawdzić",
"allClear": "Dziś nic nie wymaga Twojej uwagi.",
- "ringLink": "Otwórz szczegóły: {metric}"
+ "ringLink": "Otwórz szczegóły: {metric}",
+ "heading": "Dziś",
+ "steadyWeeksOne": "Stabilnie od {count} tygodnia",
+ "steadyWeeksFew": "Stabilnie od {count} tygodni",
+ "steadyWeeksOther": "Stabilnie od {count} tygodni",
+ "valueWithUnit": "{value} {unit}",
+ "rangeWithUnit": "{low} do {high} {unit}",
+ "vital": {
+ "RESTING_HEART_RATE": "Tętno spoczynkowe",
+ "HEART_RATE_VARIABILITY": "Zmienność rytmu serca",
+ "RESPIRATORY_RATE": "Częstość oddechów",
+ "OXYGEN_SATURATION": "Saturacja",
+ "BODY_TEMPERATURE": "Temperatura ciała",
+ "SKIN_TEMPERATURE": "Temperatura skóry",
+ "BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe",
+ "BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe",
+ "BLOOD_GLUCOSE": "Glukoza we krwi",
+ "PULSE": "Tętno",
+ "WEIGHT": "Waga",
+ "generic": "Parametr życiowy"
+ }
},
"milestone": {
"metric": {
@@ -11019,6 +11039,68 @@
"body": "Cały miesiąc z {metric} w zwykłym zakresie. Wspaniale stabilnie."
}
}
+ },
+ "lead": {
+ "vitalOutside": {
+ "above": "{metric}: {value}, powyżej Twojego zwykłego zakresu {range}.",
+ "below": "{metric}: {value}, poniżej Twojego zwykłego zakresu {range}."
+ },
+ "vitalsOutside": {
+ "aboveOne": "{metric}: {value}, powyżej Twojego zwykłego zakresu {range}. Poza zakresem jest też {count} inny parametr.",
+ "aboveFew": "{metric}: {value}, powyżej Twojego zwykłego zakresu {range}. Poza zakresem są też {count} inne parametry.",
+ "aboveOther": "{metric}: {value}, powyżej Twojego zwykłego zakresu {range}. Poza zakresem jest też {count} innych parametrów.",
+ "belowOne": "{metric}: {value}, poniżej Twojego zwykłego zakresu {range}. Poza zakresem jest też {count} inny parametr.",
+ "belowFew": "{metric}: {value}, poniżej Twojego zwykłego zakresu {range}. Poza zakresem są też {count} inne parametry.",
+ "belowOther": "{metric}: {value}, poniżej Twojego zwykłego zakresu {range}. Poza zakresem jest też {count} innych parametrów."
+ },
+ "sleep": {
+ "more": "Ostatnia noc: {duration} snu, o {diff} dłużej niż zwykle.",
+ "less": "Ostatnia noc: {duration} snu, o {diff} krócej niż zwykle.",
+ "usual": "Ostatnia noc: {duration} snu, mniej więcej tyle co zwykle.",
+ "plain": "Ostatnia noc: {duration} snu."
+ },
+ "vitalsInRangeOne": "Twój ostatni parametr życiowy mieści się w zwykłym zakresie.",
+ "vitalsInRangeFew": "Wszystkie {count} ostatnie parametry życiowe mieszczą się w zwykłym zakresie.",
+ "vitalsInRangeOther": "Wszystkie {count} ostatnich parametrów życiowych mieści się w zwykłym zakresie.",
+ "vitalInRange": "{metric}: {value}, w Twoim zwykłym zakresie {range}."
+ },
+ "todayFact": {
+ "restMode": {
+ "label": "Tryb odpoczynku",
+ "value": "Dzień {day}"
+ },
+ "medications": {
+ "label": "Leki",
+ "value": "Przyjęto {taken} z {scheduled}"
+ },
+ "appointment": {
+ "label": "Wizyta",
+ "today": "Dziś {time}, {what}",
+ "tomorrow": "Jutro {time}, {what}"
+ },
+ "sleep": {
+ "label": "Ostatnia noc",
+ "plain": "{duration}",
+ "usual": "{duration}, jak zwykle",
+ "more": "{duration}, o {diff} dłużej niż zwykle",
+ "less": "{duration}, o {diff} krócej niż zwykle"
+ },
+ "vitals": {
+ "label": "Parametry życiowe",
+ "allInOne": "Sprawdzono {count}, w zakresie",
+ "allInFew": "Sprawdzono {count}, wszystkie w zakresie",
+ "allInOther": "Sprawdzono {count}, wszystkie w zakresie",
+ "oneOut": {
+ "above": "{metric} powyżej zakresu",
+ "below": "{metric} poniżej zakresu"
+ },
+ "someOut": "{count} z {total} poza zakresem"
+ },
+ "cycle": {
+ "label": "Cykl",
+ "value": "{phase}, dzień {day}",
+ "dayOnly": "Dzień {day}"
+ }
}
},
"reportSelection": {
diff --git a/src/app/api/daily/digest/__tests__/route.test.ts b/src/app/api/daily/digest/__tests__/route.test.ts
index 14f636b64..9ae0648ba 100644
--- a/src/app/api/daily/digest/__tests__/route.test.ts
+++ b/src/app/api/daily/digest/__tests__/route.test.ts
@@ -71,6 +71,9 @@ const DIGEST: DailyDigest = {
},
topSignal: null,
briefingLead: "Blood pressure is holding steady.",
+ lead: { text: "Blood pressure is holding steady.", source: "briefing" },
+ today: [],
+ restMode: null,
line: "Blood pressure is holding steady.",
worthALook: [],
justIn: null,
diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx
index f8159ff8c..8413cb0e3 100644
--- a/src/components/__tests__/delegated-write-affordances.test.tsx
+++ b/src/components/__tests__/delegated-write-affordances.test.tsx
@@ -355,6 +355,9 @@ const DIGEST: DailyDigest = {
score: null,
topSignal: null,
briefingLead: "A steady week so far.",
+ lead: { text: "A steady week so far.", source: "briefing" },
+ today: [],
+ restMode: null,
line: "A steady week so far.",
justIn: null,
reactionLine: null,
diff --git a/src/components/daily/__tests__/today-hero-just-in.test.tsx b/src/components/daily/__tests__/today-hero-just-in.test.tsx
index d22129481..fed5f99e9 100644
--- a/src/components/daily/__tests__/today-hero-just-in.test.tsx
+++ b/src/components/daily/__tests__/today-hero-just-in.test.tsx
@@ -42,6 +42,9 @@ function digest(over: Partial = {}): DailyDigest {
score: { value: 82, band: "green", delta: 3 },
topSignal: null,
briefingLead: "Your week is trending steady.",
+ lead: { text: "Your week is trending steady.", source: "briefing" },
+ today: [],
+ restMode: null,
line: "Your week is trending steady.",
worthALook: [],
justIn: null,
@@ -105,6 +108,7 @@ describe("TodayHero — an arrival is not a chip", () => {
digest={digest({
score: null,
briefingLead: null,
+ lead: null,
worthALook: [],
reactionLine: null,
justIn: { kind: "sleep_night", at: ARRIVED_AT },
@@ -152,6 +156,7 @@ describe("TodayHero — what earns the hero without a score", () => {
return digest({
score: null,
briefingLead: null,
+ lead: null,
reactionLine: null,
phase: "provisional",
sleepPending: true,
@@ -203,6 +208,8 @@ describe("TodayHero — the reaction line replaces the lead", () => {
digest={digest({
reactionLine: REACTION,
briefingLead: "Your week is trending steady.",
+ // The server resolved the reaction line as the lead.
+ lead: { text: REACTION, source: "reaction" },
})}
/>,
);
@@ -218,6 +225,8 @@ describe("TodayHero — the reaction line replaces the lead", () => {
digest={digest({
reactionLine: REACTION,
briefingLead: "Your week is trending steady.",
+ // The server resolved the reaction line as the lead.
+ lead: { text: REACTION, source: "reaction" },
})}
/>,
);
@@ -246,6 +255,7 @@ describe("TodayHero — the reaction line replaces the lead", () => {
digest={digest({
score: null,
briefingLead: null,
+ lead: { text: REACTION, source: "reaction" },
worthALook: [],
reactionLine: REACTION,
justIn: { kind: "weight", at: ARRIVED_AT },
diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx
index 5c8842fb1..6285070da 100644
--- a/src/components/daily/__tests__/today-hero.test.tsx
+++ b/src/components/daily/__tests__/today-hero.test.tsx
@@ -6,6 +6,7 @@ import { I18nProvider } from "@/lib/i18n/context";
import { TodayHero } from "../today-hero";
import type { DailyDigest } from "@/lib/daily/digest";
import type { PriorityItem } from "@/lib/daily/priority-item";
+import type { TodayFact } from "@/lib/daily/today-overview";
import { SCORE_VERSION } from "@/lib/analytics/score/types";
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
@@ -71,6 +72,9 @@ function digest(over: Partial = {}): DailyDigest {
delta: "+6 mmHg vs your 30-day average",
},
briefingLead: "Your week is trending steady.",
+ lead: { text: "Your week is trending steady.", source: "briefing" },
+ today: [],
+ restMode: null,
line: "Your week is trending steady.",
worthALook: [doseItem, syncItem],
justIn: null,
@@ -146,6 +150,7 @@ describe("", () => {
sleepPending: true,
score: null,
briefingLead: null,
+ lead: null,
line: "Your health score today is 82.",
worthALook: [doseItem],
})}
@@ -160,13 +165,15 @@ describe("", () => {
expect(html).not.toContain('data-slot="today-hero-briefing-link"');
});
- it("keeps the useful loaded narrative without repeating the ring's numeric score", () => {
+ it("renders the server-resolved lead without repeating the ring's numeric score", () => {
+ // The server already dropped the score sentence from the briefing (see
+ // `today-overview.test.ts`); the hero prints what it was handed.
const html = render(
,
);
@@ -178,12 +185,16 @@ describe("", () => {
expect(html).toContain('data-slot="today-hero-lead"');
});
- it("promotes the top signal when score de-duplication removes the only lead", () => {
+ it("does not repeat the top signal under a lead made from it", () => {
+ // A briefing whose only sentence repeated the score leads with the top
+ // signal's headline (resolved on the server); the muted signal line under
+ // it would say the same thing twice.
const html = render(
", () => {
sleepPending: true,
score: null,
briefingLead: null,
+ lead: null,
reactionLine: null,
line: "Your health score today is 82.",
worthALook: [doseItem],
@@ -228,6 +240,7 @@ describe("", () => {
score: null,
topSignal: null,
briefingLead: null,
+ lead: null,
line: "Nothing needs your attention today — everything's tracking normally.",
worthALook: [],
})}
@@ -246,6 +259,7 @@ describe("", () => {
score: null,
topSignal: null,
briefingLead: null,
+ lead: null,
line: "Nothing needs your attention today — everything's tracking normally.",
worthALook: [],
reactionLine: null,
@@ -273,7 +287,7 @@ describe("", () => {
expect(html).toContain('data-slot="today-hero-score"');
});
- it("shows the first-class all-clear line when a score is present but nothing is notable", () => {
+ it("draws no empty rail and no all-clear sentence once a lead carries the day", () => {
const html = render(
", () => {
/>,
);
expect(html).toContain('data-slot="today-hero"');
- expect(html).toContain('data-slot="today-hero-all-clear"');
+ expect(html).toContain('data-slot="today-hero-lead"');
+ expect(html).not.toContain('data-slot="today-hero-all-clear"');
expect(html).not.toContain('data-slot="today-hero-rail"');
});
+ it("keeps only the delta when the lead already says the headline", () => {
+ const html = render(
+ ,
+ );
+ const text = visibleText(html);
+ expect(html).toContain('data-slot="today-hero-signal"');
+ expect(text).toContain("+6 mmHg vs your 30-day average");
+ // Said once, by the lead; the signal line carries only the delta.
+ expect(text.split("touch high this morning").length - 1).toBe(1);
+ });
+
it("uses the compact score-only composition when all-clear has no narrative", () => {
const html = render(
", () => {
score: null,
topSignal: null,
briefingLead: null,
+ lead: null,
worthALook: [],
})}
primaryContent="reminders"
@@ -446,3 +481,165 @@ describe(" score basis", () => {
expect(html).not.toContain('data-slot="today-hero-score-basis"');
});
});
+
+const FACTS: TodayFact[] = [
+ {
+ kind: "rest_mode",
+ label: "Rest mode",
+ value: "Day 3",
+ href: "/illness",
+ moduleKey: "illness",
+ },
+ {
+ kind: "medications",
+ label: "Medications",
+ value: "1 of 3 taken",
+ href: "/medications",
+ moduleKey: "medications",
+ },
+ {
+ kind: "appointment",
+ label: "Appointment",
+ value: "Tomorrow 09:30, Dr. Example",
+ href: "/checkups",
+ },
+ {
+ kind: "sleep",
+ label: "Last night",
+ value: "7h 20m, close to your usual",
+ href: "/insights/sleep",
+ moduleKey: "sleep",
+ },
+ {
+ kind: "cycle",
+ label: "Cycle",
+ value: "Follicular, day 12",
+ href: "/cycle",
+ moduleKey: "cycle",
+ },
+];
+
+describe(" Today overview", () => {
+ it("renders each fact as a link to its page, in the order it was given", () => {
+ const html = render();
+ expect(html).toContain('data-slot="today-hero-today"');
+ const kinds = [
+ ...html.matchAll(/data-slot="today-hero-fact" data-kind="([a-z_]+)"/g),
+ ].map((m) => m[1]);
+ expect(kinds).toEqual([
+ "rest_mode",
+ "medications",
+ "appointment",
+ "sleep",
+ "cycle",
+ ]);
+ for (const fact of FACTS) {
+ expect(html).toContain(`href="${fact.href}"`);
+ expect(visibleText(html)).toContain(fact.value);
+ }
+ });
+
+ it("hides the fifth fact below md and never the first four", () => {
+ const html = render();
+ const items = [
+ ...html.matchAll(/
]*data-slot="today-hero-fact"[^>]*>/g),
+ ].map((m) => m[0]);
+ expect(items).toHaveLength(5);
+ items.slice(0, 4).forEach((li) => expect(li).not.toContain("hidden"));
+ expect(items[4]).toContain("hidden md:block");
+ });
+
+ it("drops the all-clear sentence when facts carry the day", () => {
+ const html = render(
+ ,
+ );
+ expect(html).not.toContain('data-slot="today-hero-all-clear"');
+ expect(html).toContain('data-layout="narrative"');
+ });
+
+ it("keeps the hero for an account whose only content is facts", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-slot="today-hero"');
+ expect(html).toContain('data-slot="today-hero-today"');
+ });
+
+ it("renders a deterministic lead without the AI signal line", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-source="signal"');
+ expect(html).not.toContain('data-slot="today-hero-signal"');
+ });
+
+ it("shows the facts in reminders mode, without the ring", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-slot="today-hero-today"');
+ expect(html).not.toContain('data-slot="today-hero-score"');
+ });
+});
+
+describe(" steady line", () => {
+ const steady = (over: Partial>) =>
+ digest({
+ score: {
+ value: 94,
+ band: "green",
+ delta: null,
+ deltaReason: "below_noise_floor",
+ scoreVersion: SCORE_VERSION,
+ composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
+ steadyWeeks: 4,
+ ...over,
+ },
+ });
+
+ it("says how long the score has held when no delta is shown", () => {
+ const html = render();
+ expect(html).toContain('data-slot="today-hero-score-steady"');
+ expect(visibleText(html)).toContain("Steady for 4 weeks");
+ expect(html).not.toContain('data-slot="today-hero-score-delta"');
+ });
+
+ it("never pairs a steady line with a delta", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-slot="today-hero-score-delta"');
+ expect(html).not.toContain('data-slot="today-hero-score-steady"');
+ });
+
+ it("says nothing without a run", () => {
+ const html = render();
+ expect(html).not.toContain('data-slot="today-hero-score-steady"');
+ });
+
+ it("uses the German plural", () => {
+ const html = render(, "de");
+ expect(visibleText(html)).toContain("Seit 4 Wochen stabil");
+ });
+});
diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx
index 47389ec15..a1544f88e 100644
--- a/src/components/daily/today-hero.tsx
+++ b/src/components/daily/today-hero.tsx
@@ -54,6 +54,8 @@ import { usePriorityItemDismiss } from "@/hooks/use-priority-item-dismiss";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
+import { pluralKey } from "@/lib/i18n/plural";
+import { MAX_TODAY_FACTS_NARROW as TODAY_FACTS_NARROW } from "@/lib/daily/today-overview";
import type { DailyDigest } from "@/lib/daily/digest";
import type { HeroPrimaryContent } from "@/lib/dashboard-layout";
import {
@@ -72,26 +74,18 @@ function formatDelta(
return t("daily.today.deltaVsBaseline", { delta: signed });
}
-/**
- * The ring already owns the headline number. AI briefing copy can still carry
- * an older lead sentence that repeats that exact score, so remove only the
- * sentence containing the displayed number and retain every other useful
- * sentence verbatim. If the score sentence was the whole lead, render no lead
- * rather than inventing replacement prose.
- */
-function withoutRepeatedScore(
- text: string | null,
- score: number | null,
-): string | null {
- if (!text || score === null) return text;
- const shownScore = String(Math.round(score));
- const scorePattern = new RegExp(
- `(^|[^0-9])${shownScore.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}([^0-9]|$)`,
- );
- const sentences = text.match(/[^.!?]+[.!?]+(?:\s+|$)|[^.!?]+$/g) ?? [text];
- const retained = sentences.filter((sentence) => !scorePattern.test(sentence));
- const result = retained.join(" ").replace(/\s+/g, " ").trim();
- return result.length > 0 ? result : null;
+/** Lowercase letters and digits only, for a wording-insensitive compare. */
+function normalise(text: string): string {
+ return text
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
+ .trim();
+}
+
+/** Whether `lead` already says what `headline` says. */
+function restates(lead: string, headline: string): boolean {
+ const h = normalise(headline);
+ return h.length > 0 && normalise(lead).includes(h);
}
export function TodayHero({
@@ -104,7 +98,7 @@ export function TodayHero({
/** Server-persisted hero choice from the dashboard layout blob. */
primaryContent?: HeroPrimaryContent;
}) {
- const { t } = useTranslations();
+ const { t, locale } = useTranslations();
// The hero is deliberately NOT mount-gated (v1.30.9: it is the LCP element
// and paints from the server-dehydrated digest on both the SSR and the
// hydration render), so nothing in it may differ between those two passes.
@@ -138,35 +132,52 @@ export function TodayHero({
const hasScore = digest.score !== null;
const hasItems = digest.worthALook.length > 0;
- // Lead precedence, warmest first: the day's reaction line REPLACES the
- // briefing lead once something landed — it never appends as a second
- // paragraph, so the hero stays exactly one lead line tall and nothing below
- // it shifts. The briefing lead is the standing read; the deterministic
- // `line` is the floor a keyless self-hoster still gets.
- const rawLead =
- digest.reactionLine ??
- digest.briefingLead ??
- // The deterministic line is a useful floor only once the ring has a real
- // score. A provisional/null ring must not pair with a fabricated score
- // sentence from that fallback.
- (hasScore ? digest.line : null);
- const lead = withoutRepeatedScore(rawLead, digest.score?.value ?? null);
+ // `?? []` / `?? null` below: the service worker's offline data cache can
+ // still hand back a digest written before the overview fields existed, and
+ // a missing list must read as "no facts", not as a crash.
+ const facts = digest.today ?? [];
+ const hasFacts = facts.length > 0;
+ // The lead is resolved on the server (`digest.lead`): the reaction line or
+ // a briefing sentence with content while AI text is available, otherwise a
+ // deterministic sentence about the day's strongest signal. Greetings and a
+ // sentence that only repeats the ring's number never reach it, so the hero
+ // renders it verbatim and decides nothing about it.
+ const lead = digest.lead?.text ?? null;
const topSignal = digest.topSignal;
- // A score-only briefing can legitimately lose its lead when the only
- // sentence repeated the number already printed inside the ring. Keep the
- // day's signal as the visible headline in that case instead of leaving the
- // hero with a muted, orphaned subtitle below an empty bold slot.
- const signalFallbackLead =
- hasScore && !lead ? topSignal?.headline?.trim() || null : null;
- const displayLead = lead ?? signalFallbackLead;
- // A score-only all-clear digest has no narrative content for the leading
- // column. Keeping the full md ring in that two-column shell left a blank
- // column beside a 168 px dial and pushed the actual all-clear read below it.
- // Treat that honest fallback as its own compact composition: the all-clear
- // copy occupies the leading column and a smaller version of the SAME score
- // ring remains the one numeric face. This branch depends only on the
- // server-delivered digest, so SSR and hydration choose it identically.
- const compactAllClear = !lead && !topSignal && !hasItems;
+ // The briefing's top signal rides under an AI lead as its supporting line,
+ // and only with what the lead does not already say: a headline the lead
+ // restates is dropped and its delta, when there is one, stands alone.
+ const signalLine =
+ topSignal && lead && digest.lead?.source !== "signal"
+ ? restates(lead, topSignal.headline)
+ ? topSignal.delta
+ ? { headline: null, delta: topSignal.delta }
+ : null
+ : { headline: topSignal.headline, delta: topSignal.delta }
+ : null;
+ // A score-only digest with nothing else to say has no narrative content for
+ // the leading column. Keeping the full md ring in that two-column shell left
+ // a blank column beside a 168 px dial and pushed the all-clear read below
+ // it, so that case keeps its compact composition: the all-clear copy leads
+ // and a smaller version of the SAME score ring stays the one numeric face.
+ // This branch depends only on the server-delivered digest, so SSR and
+ // hydration choose it identically.
+ const compactAllClear = !lead && !signalLine && !hasItems && !hasFacts;
+
+ // How long the number has held, read off the stored daily scores on the
+ // server. Said only when no delta is: a moved score is described by its
+ // delta, a still one by its duration, never both under one ring.
+ const deltaShown =
+ digest.score !== null &&
+ digest.score.delta !== null &&
+ digest.score.deltaReason === null;
+ const steadyWeeks = digest.score?.steadyWeeks ?? null;
+ const steadyLine =
+ !deltaShown && steadyWeeks !== null
+ ? t(pluralKey("daily.today.steadyWeeks", steadyWeeks, locale), {
+ count: steadyWeeks,
+ })
+ : null;
// v1.38 — what the ring's number rests on, when that is less than the
// breadth the score recommends. The hero renders the number and nothing
@@ -203,13 +214,7 @@ export function TodayHero({
//
// A filtered layout is the one explicit exception: its empty digest means
// "the hidden canonical candidates are quiet here", so retain all-clear.
- if (
- !renderFilteredAllClear &&
- !hasScore &&
- !hasItems &&
- !digest.briefingLead &&
- !digest.reactionLine
- ) {
+ if (!renderFilteredAllClear && !hasScore && !hasItems && !hasFacts && !lead) {
return null;
}
@@ -252,6 +257,45 @@ export function TodayHero({
) : null;
+ /* The Today overview — up to five statements about the day, resolved on the
+ server in priority order. A phone shows the first four: the fifth is
+ hidden below `md` rather than dropped, so the list a wide screen reads is
+ the same list, one line longer. Each line opens the page it belongs to.
+ The block never asks for anything; the rail below is the one place on the
+ hero that may. */
+ const todayBlock = hasFacts ? (
+
+ ) : null;
+
const heroShellClassName = cn(
// The tile strip's surface plus the ONE sanctioned Today atmosphere:
// `.today-hero-wash` leans a faint `--primary` mix over the theme
@@ -275,14 +319,16 @@ export function TodayHero({
className={heroShellClassName}
>
{/* Hero numeric face: the read leads large in the foreground
token, calm and legible — the day's read, not a slogan. */}
- {displayLead ? (
-
-
+ {lead || signalLine ? (
+
+ {lead ? (
+
+
+
+ ) : null}
+ {/* Top signal — present-tense headline + optional delta, one
+ muted step down so it supports the lead without
+ competing. */}
+ {signalLine ? (
+
) : null}
- {/* Top signal — present-tense headline + optional delta, one
- muted step down so it supports the lead without competing. */}
- {topSignal && lead ? (
-
- {digest.score &&
- digest.score.delta !== null &&
- digest.score.deltaReason === null ? (
+ {deltaShown && digest.score?.delta != null ? (
) : null}
+ {steadyLine ? (
+
+ {steadyLine}
+
+ ) : null}
{basisLine ? (
{
+ const today = moodDateKey(now, timezone);
+ const from = addDays(today, -PAST_DAYS);
+ const logFrom =
+ dayDiff(from, addDays(today, -BBT_WINDOW)) <= 0
+ ? from
+ : addDays(today, -BBT_WINDOW);
+
+ const [profile, cycles, dayLogRows, nightlyTemps] = await Promise.all([
+ prisma.cycleProfile.findUnique({ where: { userId } }),
+ prisma.menstrualCycle.findMany({
+ where: { userId, deletedAt: null },
+ orderBy: { startDate: "asc" },
+ }),
+ prisma.cycleDayLog.findMany({
+ where: { userId, deletedAt: null, date: { gte: logFrom } },
+ orderBy: { date: "asc" },
+ select: {
+ date: true,
+ flow: true,
+ basalBodyTempC: true,
+ temperatureExcluded: true,
+ ovulationTest: true,
+ cervicalMucus: true,
+ cervixPosition: true,
+ cervixFirmness: true,
+ cervixOpening: true,
+ intermenstrualBleeding: true,
+ },
+ }),
+ prisma.measurement.findMany({
+ where: {
+ userId,
+ deletedAt: null,
+ type: "WRIST_TEMPERATURE",
+ measuredAt: { gte: startOfLocalDayKey(addDays(today, -90), timezone) },
+ },
+ orderBy: { measuredAt: "asc" },
+ select: { measuredAt: true, value: true },
+ }),
+ ]);
+ if (!profile || cycles.length === 0) return null;
+
+ // The intent fields and the presence flags never reach a phase or a day
+ // count; the grid carries them for the calendar's own overlay only.
+ const dayLogs: CalendarDayLogRow[] = dayLogRows.map((row) => ({
+ ...row,
+ hasSymptoms: false,
+ hasNote: false,
+ sexualActivity: false,
+ pregnancyTest: null,
+ progesteroneTest: null,
+ contraceptive: null,
+ }));
+ const nights = nightlyTemps.map((m) => ({
+ date: moodDateKey(m.measuredAt, timezone),
+ valueC: m.value,
+ }));
+
+ const { prediction, days } = buildCalendar(
+ profile,
+ cycles,
+ dayLogs,
+ nights,
+ from,
+ today,
+ today,
+ goalAllowsFertileWindow(profile.goal),
+ );
+ const verdict = resolveCycleVerdict({
+ days,
+ today,
+ profile: {
+ typicalCycleLength: profile.typicalCycleLength,
+ typicalPeriodLength: profile.typicalPeriodLength,
+ lutealPhaseLength: profile.lutealPhaseLength,
+ },
+ nextPeriodStart: prediction?.nextPeriodStart ?? null,
+ lastPeriodStart:
+ cycles.filter((c) => c.startDate <= today).at(-1)?.startDate ?? null,
+ });
+
+ if (verdict.state !== "IN_CYCLE" || verdict.dayOfCycle === null) return null;
+ return { dayOfCycle: verdict.dayOfCycle, phase: verdict.phase };
+}
diff --git a/src/lib/daily/__tests__/daily-briefing-push.test.ts b/src/lib/daily/__tests__/daily-briefing-push.test.ts
index 41b3533d5..de9fa0537 100644
--- a/src/lib/daily/__tests__/daily-briefing-push.test.ts
+++ b/src/lib/daily/__tests__/daily-briefing-push.test.ts
@@ -38,6 +38,9 @@ function makeDigest(over: Partial = {}): DailyDigest {
score: { value: 82, band: "good", delta: 1 },
topSignal: null,
briefingLead: "Sleep looked solid last night.",
+ lead: { text: "Sleep looked solid last night.", source: "briefing" },
+ today: [],
+ restMode: null,
line: "Sleep looked solid last night.",
worthALook: [],
justIn: null,
diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts
index 1ae88dcbd..c45af38ce 100644
--- a/src/lib/daily/__tests__/digest.test.ts
+++ b/src/lib/daily/__tests__/digest.test.ts
@@ -110,7 +110,12 @@ describe("buildDailyDigest — composition", () => {
it("lifts score, top signal, and briefing lead from cached inputs (no recompute)", () => {
const d = buildDailyDigest(input(), t);
expect(d.generatedAt).toBe(NOW.toISOString());
- expect(d.score).toEqual({ value: 82, band: "good", delta: 3 });
+ expect(d.score).toEqual({
+ value: 82,
+ band: "good",
+ delta: 3,
+ steadyWeeks: null,
+ });
expect(d.topSignal?.headline).toBe("Blood pressure is holding steady");
expect(d.briefingLead).toBe(
"Your blood pressure is holding steady this week.",
@@ -1457,3 +1462,109 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
).toEqual(["Dr. Weiss — today", "Dentist — tomorrow"]);
});
});
+
+describe("buildDailyDigest — Today overview", () => {
+ const RHR_HIGH = {
+ type: "RESTING_HEART_RATE",
+ value: 61,
+ low: 50,
+ high: 58,
+ direction: "above" as const,
+ daysAgo: 0,
+ valueLabel: "61 bpm",
+ rangeLabel: "50 to 58 bpm",
+ moduleKey: null,
+ };
+
+ it("skips a greeting and publishes the sentence with content as the lead", () => {
+ const d = buildDailyDigest(
+ input({
+ briefing: {
+ ...briefing,
+ paragraph:
+ "Good morning. Your latest blood pressure is sitting in the optimal band.",
+ },
+ }),
+ t,
+ );
+ expect(d.briefingLead).toBe(
+ "Your latest blood pressure is sitting in the optimal band.",
+ );
+ expect(d.lead).toEqual({
+ text: "Your latest blood pressure is sitting in the optimal band.",
+ source: "briefing",
+ });
+ expect(d.line).toBe(d.briefingLead);
+ });
+
+ it("without AI, leads with the strongest signal instead of the score", () => {
+ const d = buildDailyDigest(
+ input({
+ ai: {
+ ...DIGEST_AI_AVAILABLE,
+ briefing: aiUnavailable("user_disabled"),
+ },
+ vitals: [RHR_HIGH],
+ }),
+ t,
+ );
+ expect(d.lead).toEqual({
+ text: "Resting heart rate is at 61 bpm, above your usual range of 50 to 58 bpm.",
+ source: "signal",
+ });
+ // The push line takes the same sentence, not "Your health score today is 82."
+ expect(d.line).toBe(d.lead?.text);
+ });
+
+ it("publishes Rest Mode and its fact only while the illness module is on", () => {
+ const on = buildDailyDigest(input({ restMode: { day: 2 } }), t);
+ expect(on.restMode).toEqual({ day: 2 });
+ expect(on.today[0]).toMatchObject({ kind: "rest_mode", value: "Day 2" });
+
+ const off = buildDailyDigest(
+ input({ restMode: { day: 2 }, modules: { illness: false } }),
+ t,
+ );
+ expect(off.restMode).toBeNull();
+ expect(off.today.some((f) => f.kind === "rest_mode")).toBe(false);
+ });
+
+ it("keeps an appointment on the rail and out of Today, never both", () => {
+ const visits = [
+ {
+ id: "v1",
+ kind: "ROUTINE",
+ occurredAt: "2026-07-16T13:00:00.000Z",
+ practitionerName: "Dr. Example",
+ dayOffset: 0,
+ timeLabel: "15:00",
+ },
+ ];
+ const withRail = buildDailyDigest(input({ upcomingVisits: visits }), t);
+ expect(withRail.worthALook.some((i) => i.kind === "upcoming_visit")).toBe(
+ true,
+ );
+ expect(withRail.today.some((f) => f.kind === "appointment")).toBe(false);
+
+ const railOff = buildDailyDigest(
+ input({
+ upcomingVisits: visits,
+ enabledHeroItemKinds: PRIORITY_ITEM_KINDS.filter(
+ (k) => k !== "upcoming_visit",
+ ),
+ }),
+ t,
+ );
+ expect(railOff.today).toEqual([
+ expect.objectContaining({
+ kind: "appointment",
+ value: "Today 15:00, Dr. Example",
+ }),
+ ]);
+ });
+
+ it("carries the steady run on the score", () => {
+ const d = buildDailyDigest(input({ scoreSteadyWeeks: 5 }), t);
+ expect(d.score?.steadyWeeks).toBe(5);
+ });
+});
diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts
index 631dd4b65..cc726ed42 100644
--- a/src/lib/daily/__tests__/load-digest.test.ts
+++ b/src/lib/daily/__tests__/load-digest.test.ts
@@ -21,9 +21,22 @@ vi.mock("@/lib/db", () => ({
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
arrivalReaction: { findMany: vi.fn().mockResolvedValue([]) },
encounter: { findMany: vi.fn().mockResolvedValue([]) },
+ healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
},
}));
+vi.mock("@/lib/insights/derived/coincident-deviation", () => ({
+ computeCoincidentDeviation: vi.fn(),
+}));
+
+vi.mock("@/lib/illness/rest-mode", () => ({
+ resolveRestMode: vi.fn(),
+}));
+
+vi.mock("@/lib/cycle/today-verdict", () => ({
+ readTodayCycle: vi.fn(),
+}));
+
vi.mock("@/lib/dashboard/snapshot-read", () => ({
readDashboardSnapshotCached: vi.fn(),
}));
@@ -38,6 +51,9 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({
vi.mock("@/lib/insights/derived/baseline", () => ({
readDayMeanSeries: vi.fn().mockResolvedValue({ points: [], source: "none" }),
+ loadBaselineProfile: vi
+ .fn()
+ .mockResolvedValue({ ageYears: 40, sex: "FEMALE", heightCm: 170 }),
}));
vi.mock("@/lib/analytics/intraday-pulse-io", () => ({
@@ -73,6 +89,9 @@ import {
aiCapabilityToServe,
} from "@/lib/ai/capabilities/gate";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
+import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation";
+import { resolveRestMode } from "@/lib/illness/rest-mode";
+import { readTodayCycle } from "@/lib/cycle/today-verdict";
import {
AI_AVAILABLE,
aiUnavailable,
@@ -117,6 +136,16 @@ beforeEach(() => {
vi.mocked(loadIntradayPulse).mockResolvedValue({ tension: null } as never);
vi.mocked(aiCapabilityForRecord).mockResolvedValue(AI_AVAILABLE);
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
+ vi.mocked(computeCoincidentDeviation).mockResolvedValue({
+ status: "insufficient",
+ } as never);
+ vi.mocked(resolveRestMode).mockResolvedValue({
+ active: false,
+ since: null,
+ episodeCount: 0,
+ episodes: [],
+ });
+ vi.mocked(readTodayCycle).mockResolvedValue(null);
});
describe("loadDailyDigest — milestone day", () => {
@@ -480,3 +509,187 @@ describe("loadDailyDigest — due check-ups and today's visits", () => {
expect(digest.worthALook.map((i) => i.kind)).toContain("preventive_care");
});
});
+
+describe("loadDailyDigest — Today overview inputs", () => {
+ // Visits are left off the rail here so the Today line is the one place
+ // they appear; with the rail kind on, the line steps aside (see
+ // `today-overview.test.ts`).
+ const NO_VISIT_RAIL = PRIORITY_ITEM_KINDS.filter(
+ (kind) => kind !== "upcoming_visit",
+ );
+
+ function visit(at: string, name = "GP") {
+ return {
+ id: `v-${at}`,
+ kind: "ROUTINE",
+ occurredAt: new Date(at),
+ practitioner: { name },
+ };
+ }
+
+ // 21:30Z on 17 July is 23:30 in Berlin (CEST): the last half hour of the
+ // local day. A visit at 23:45 local is still today; one at 00:30 local is
+ // tomorrow, although both fall on the same UTC date.
+ const LATE_EVENING = new Date("2026-07-17T21:30:00.000Z");
+
+ it("puts a 23:45 visit under today and a 00:30 visit under tomorrow, late in the evening", async () => {
+ vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce([
+ visit("2026-07-17T21:45:00.000Z", "Dr. Late"),
+ ] as never);
+ const today = await loadDailyDigest(USER, LATE_EVENING, {
+ enabledItemKinds: NO_VISIT_RAIL,
+ });
+ expect(today.today).toEqual([
+ expect.objectContaining({
+ kind: "appointment",
+ value: "daily.todayFact.appointment.today",
+ }),
+ ]);
+
+ vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce([
+ visit("2026-07-17T22:30:00.000Z", "Dr. Early"),
+ ] as never);
+ const tomorrow = await loadDailyDigest(USER, LATE_EVENING, {
+ enabledItemKinds: NO_VISIT_RAIL,
+ });
+ expect(tomorrow.today).toEqual([
+ expect.objectContaining({
+ kind: "appointment",
+ value: "daily.todayFact.appointment.tomorrow",
+ }),
+ ]);
+ });
+
+ it("reads the same instants on a New York clock", async () => {
+ const NY_USER = { ...USER, timezone: "America/New_York" } as User;
+ vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce([
+ visit("2026-07-17T22:30:00.000Z"),
+ ] as never);
+ // 17:30 in New York: the 18:30 visit is still today there.
+ const digest = await loadDailyDigest(NY_USER, LATE_EVENING, {
+ enabledItemKinds: NO_VISIT_RAIL,
+ });
+ expect(digest.today[0]?.value).toBe("daily.todayFact.appointment.today");
+ });
+
+ it("renders the visit time in the profile zone and hour-cycle preference", async () => {
+ const { getServerTranslator } =
+ await import("@/lib/i18n/server-translator");
+ const seen: Array | undefined> = [];
+ vi.mocked(getServerTranslator).mockReturnValueOnce({
+ t: (key: string, params?: Record) => {
+ if (key === "daily.todayFact.appointment.today") seen.push(params);
+ return key;
+ },
+ } as never);
+ vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce([
+ visit("2026-07-17T21:45:00.000Z", "Dr. Late"),
+ ] as never);
+ await loadDailyDigest(
+ { ...USER, timeFormat: "H12" } as User,
+ LATE_EVENING,
+ { enabledItemKinds: NO_VISIT_RAIL },
+ );
+ expect(seen[0]).toEqual({ time: "11:45 PM", what: "Dr. Late" });
+ });
+
+ it("passes Rest Mode through, counted on the reader's own calendar", async () => {
+ vi.mocked(resolveRestMode).mockResolvedValueOnce({
+ active: true,
+ // 22:00Z on 14 July is already 15 July in Berlin: day 3 on 17 July.
+ since: "2026-07-14T22:00:00.000Z",
+ episodeCount: 1,
+ episodes: [],
+ });
+ const digest = await loadDailyDigest(USER, NOW);
+ expect(digest.restMode).toEqual({ day: 3 });
+ expect(digest.today[0]).toMatchObject({ kind: "rest_mode" });
+ });
+
+ it("does not read Rest Mode or the cycle while their modules are off", async () => {
+ vi.mocked(resolveModuleMap).mockResolvedValueOnce({
+ illness: false,
+ cycle: false,
+ } as never);
+ const digest = await loadDailyDigest(USER, NOW);
+ expect(resolveRestMode).not.toHaveBeenCalled();
+ expect(readTodayCycle).not.toHaveBeenCalled();
+ expect(digest.restMode).toBeNull();
+ });
+
+ it("reads the cycle in the profile timezone and keeps a failed read quiet", async () => {
+ vi.mocked(readTodayCycle).mockRejectedValueOnce(new Error("boom"));
+ const digest = await loadDailyDigest(USER, NOW);
+ expect(readTodayCycle).toHaveBeenCalledWith(USER.id, "Europe/Berlin", NOW);
+ expect(digest.today.some((f) => f.kind === "cycle")).toBe(false);
+ });
+
+ it("builds the deterministic lead from the vitals read when AI is off", async () => {
+ vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({
+ ...SNAPSHOT,
+ body: {
+ ...SNAPSHOT.body,
+ briefingAi: aiUnavailable("user_disabled"),
+ },
+ } as never);
+ vi.mocked(aiCapabilityToServe).mockResolvedValue(
+ aiUnavailable("user_disabled"),
+ );
+ vi.mocked(computeCoincidentDeviation).mockResolvedValueOnce({
+ status: "ok",
+ value: {
+ fired: false,
+ contributing: [],
+ day: "2026-07-17",
+ illnessExplained: false,
+ vitals: [
+ {
+ type: "RESTING_HEART_RATE",
+ value: 61,
+ center: 54,
+ low: 50,
+ high: 58,
+ outside: true,
+ direction: "above",
+ daysAgo: 0,
+ },
+ ],
+ },
+ } as never);
+ const digest = await loadDailyDigest(USER, NOW);
+ expect(digest.lead).toEqual({
+ text: "daily.lead.vitalOutside.above",
+ source: "signal",
+ });
+ expect(digest.briefingLead).toBeNull();
+ });
+
+ it("publishes how long the score has held", async () => {
+ vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({
+ ...SNAPSHOT,
+ body: {
+ ...SNAPSHOT.body,
+ healthScore: {
+ score: 94,
+ band: "GREEN",
+ delta: null,
+ deltaReason: "below_noise_floor",
+ },
+ },
+ } as never);
+ const rows = Array.from({ length: 22 }, (_, i) => ({
+ dayKey: new Date(Date.parse("2026-07-17T12:00:00Z") - i * 86_400_000)
+ .toISOString()
+ .slice(0, 10),
+ composite: 94,
+ band: "GREEN",
+ scoreVersion: 4,
+ composition: ["BLOOD_PRESSURE"],
+ }));
+ vi.mocked(prisma.healthScoreRecord.findMany).mockResolvedValueOnce(
+ rows as never,
+ );
+ const digest = await loadDailyDigest(USER, NOW);
+ expect(digest.score?.steadyWeeks).toBe(3);
+ });
+});
diff --git a/src/lib/daily/__tests__/score-steady.test.ts b/src/lib/daily/__tests__/score-steady.test.ts
new file mode 100644
index 000000000..fa501c361
--- /dev/null
+++ b/src/lib/daily/__tests__/score-steady.test.ts
@@ -0,0 +1,79 @@
+import { describe, it, expect } from "vitest";
+
+import { steadyWeeks, type StoredScoreDay } from "@/lib/daily/score-steady";
+import { addDays } from "@/lib/cycle/day-math";
+
+const TODAY = "2026-10-04";
+
+/** `n` consecutive days ending today, all at `composite`. */
+function run(
+ n: number,
+ composite = 94,
+ over: Partial = {},
+): StoredScoreDay[] {
+ return Array.from({ length: n }, (_, i) => ({
+ dayKey: addDays(TODAY, -i),
+ composite,
+ band: "GREEN",
+ scoreVersion: 4,
+ composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
+ ...over,
+ }));
+}
+
+describe("steadyWeeks", () => {
+ it("counts whole weeks of a held score", () => {
+ expect(steadyWeeks(run(29), TODAY, 94)).toBe(4);
+ });
+
+ it("stays quiet below two weeks", () => {
+ expect(steadyWeeks(run(13), TODAY, 94)).toBeNull();
+ expect(steadyWeeks(run(15), TODAY, 94)).toBe(2);
+ });
+
+ it("tolerates two points of wobble, not three", () => {
+ const rows = run(29).map((r, i) => ({
+ ...r,
+ composite: i % 2 === 0 ? 94 : 92,
+ }));
+ expect(steadyWeeks(rows, TODAY, 94)).toBe(4);
+ const moved = run(29).map((r, i) =>
+ i === 10 ? { ...r, composite: 90 } : r,
+ );
+ expect(steadyWeeks(moved, TODAY, 94)).toBeNull();
+ });
+
+ it("ends the run at a band change, a recipe change or a new algorithm", () => {
+ const band = run(29).map((r, i) => (i >= 15 ? { ...r, band: "AMBER" } : r));
+ expect(steadyWeeks(band, TODAY, 94)).toBe(2);
+ const recipe = run(29).map((r, i) =>
+ i >= 15 ? { ...r, composition: ["BLOOD_PRESSURE"] } : r,
+ );
+ expect(steadyWeeks(recipe, TODAY, 94)).toBe(2);
+ const version = run(29).map((r, i) =>
+ i >= 15 ? { ...r, scoreVersion: 3 } : r,
+ );
+ expect(steadyWeeks(version, TODAY, 94)).toBe(2);
+ });
+
+ it("does not bridge a gap longer than three days", () => {
+ const rows = run(29).filter((_, i) => i < 15 || i > 19);
+ expect(steadyWeeks(rows, TODAY, 94)).toBe(2);
+ const short = run(29).filter((_, i) => i < 15 || i > 16);
+ expect(steadyWeeks(short, TODAY, 94)).toBe(4);
+ });
+
+ it("says nothing when the record stopped before today", () => {
+ const stale = run(40).filter((r) => r.dayKey <= addDays(TODAY, -5));
+ expect(steadyWeeks(stale, TODAY, 94)).toBeNull();
+ });
+
+ it("says nothing when the ring shows a number the record does not", () => {
+ expect(steadyWeeks(run(29), TODAY, 88)).toBeNull();
+ });
+
+ it("reads rows in any order", () => {
+ expect(steadyWeeks(run(29).reverse(), TODAY, 94)).toBe(4);
+ expect(steadyWeeks([], TODAY, 94)).toBeNull();
+ });
+});
diff --git a/src/lib/daily/__tests__/today-overview.test.ts b/src/lib/daily/__tests__/today-overview.test.ts
new file mode 100644
index 000000000..7e047203e
--- /dev/null
+++ b/src/lib/daily/__tests__/today-overview.test.ts
@@ -0,0 +1,588 @@
+import { describe, it, expect } from "vitest";
+
+import { getServerTranslator } from "@/lib/i18n/server-translator";
+import type { Locale } from "@/lib/i18n/config";
+import type { DailyBriefing } from "@/lib/ai/schema";
+import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
+import type { PriorityItem } from "@/lib/daily/priority-item";
+import {
+ buildTodayOverview,
+ firstSubstantiveSentence,
+ isGreetingOnly,
+ MAX_TODAY_FACTS,
+ type TodayOverviewInput,
+ type TodayVital,
+} from "@/lib/daily/today-overview";
+
+const t = getServerTranslator("en").t;
+
+function meds(over: Partial = {}): MedsTodayBlock {
+ return {
+ activeCount: 0,
+ scheduledToday: 0,
+ takenToday: 0,
+ skippedToday: 0,
+ nextDueAt: null,
+ nextDueOverdue: false,
+ nextDueMedicationName: null,
+ nextDueMedicationId: null,
+ ...over,
+ };
+}
+
+function vital(over: Partial = {}): TodayVital {
+ return {
+ type: "RESTING_HEART_RATE",
+ value: 54,
+ low: 50,
+ high: 58,
+ direction: "in",
+ daysAgo: 0,
+ valueLabel: "54 bpm",
+ rangeLabel: "50 to 58 bpm",
+ moduleKey: null,
+ ...over,
+ };
+}
+
+/** A quiet day with nothing at all to say. */
+function input(over: Partial = {}): TodayOverviewInput {
+ return {
+ locale: "en",
+ modules: {},
+ reactionLine: null,
+ briefing: null,
+ scoreValue: 94,
+ medsToday: meds(),
+ visits: [],
+ rail: [],
+ restMode: null,
+ sleep: null,
+ vitals: [],
+ cycle: null,
+ ...over,
+ };
+}
+
+const briefing = (paragraph: string, headline?: string): DailyBriefing => ({
+ paragraph,
+ signalsOfDay: headline
+ ? [
+ {
+ sourceMetric: "pulse",
+ tone: "info",
+ headline,
+ nudge: "",
+ delta: null,
+ },
+ ]
+ : [],
+ keyFindings: [],
+});
+
+/** Every fact the builder can produce, all at once. */
+function busyDay(over: Partial = {}): TodayOverviewInput {
+ return input({
+ restMode: { day: 3 },
+ medsToday: meds({ scheduledToday: 3, takenToday: 1 }),
+ visits: [
+ { id: "v1", dayOffset: 1, timeLabel: "09:30", what: "Dr. Example" },
+ ],
+ sleep: { minutes: 440, usualMinutes: 430 },
+ vitals: [
+ vital(),
+ vital({ type: "HEART_RATE_VARIABILITY", valueLabel: "48 ms" }),
+ ],
+ cycle: { dayOfCycle: 12, phase: "FOLLICULAR" },
+ // An AI lead keeps every fact in the list; the deterministic lead would
+ // take one of them.
+ briefing: briefing("Resting heart rate held at 54 bpm overnight."),
+ ...over,
+ });
+}
+
+describe("greeting detection", () => {
+ it.each([
+ "Good morning.",
+ "Good evening!",
+ "Hello there.",
+ "Guten Morgen.",
+ "Hallo!",
+ "Buenos días.",
+ "Bonjour !",
+ "Buongiorno.",
+ "Dzień dobry.",
+ "좋은 아침이에요.",
+ "안녕하세요.",
+ "Welcome back.",
+ "Nice.",
+ ])("treats %j as a greeting", (sentence) => {
+ expect(isGreetingOnly(sentence)).toBe(true);
+ });
+
+ it.each([
+ "Good morning, your resting heart rate is 52.",
+ "High blood pressure readings eased this week.",
+ "Morning blood pressure sat in the optimal band all week.",
+ "Your sleep landed right around your target again.",
+ ])("treats %j as content", (sentence) => {
+ expect(isGreetingOnly(sentence)).toBe(false);
+ });
+});
+
+describe("firstSubstantiveSentence", () => {
+ it("skips a greeting and leads with the sentence that says something", () => {
+ expect(
+ firstSubstantiveSentence(
+ "Good morning. Your latest blood pressure is sitting in the optimal band.",
+ 94,
+ ),
+ ).toBe("Your latest blood pressure is sitting in the optimal band.");
+ });
+
+ it("skips a sentence that only repeats the ring's number", () => {
+ expect(
+ firstSubstantiveSentence(
+ "Your health score is 94. Sleep dipped slightly last night.",
+ 94,
+ ),
+ ).toBe("Sleep dipped slightly last night.");
+ });
+
+ it("keeps decimals inside one sentence", () => {
+ expect(
+ firstSubstantiveSentence("You slept 7.5 hours, a solid night.", null),
+ ).toBe("You slept 7.5 hours, a solid night.");
+ });
+
+ it("returns null when nothing qualifies", () => {
+ expect(firstSubstantiveSentence("Good morning. Hi!", 94)).toBeNull();
+ expect(firstSubstantiveSentence("", 94)).toBeNull();
+ expect(firstSubstantiveSentence(null, 94)).toBeNull();
+ });
+});
+
+describe("lead precedence", () => {
+ it("prefers the reaction line over the briefing", () => {
+ const o = buildTodayOverview(
+ input({
+ reactionLine: "A solid night, deeper than your recent stretch.",
+ briefing: briefing("Your week is trending steady."),
+ }),
+ t,
+ );
+ expect(o.lead).toEqual({
+ text: "A solid night, deeper than your recent stretch.",
+ source: "reaction",
+ });
+ });
+
+ it("drops a greeting-only reaction line and falls through", () => {
+ const o = buildTodayOverview(
+ input({
+ reactionLine: "Good morning!",
+ briefing: briefing("Your week is trending steady."),
+ }),
+ t,
+ );
+ expect(o.lead).toEqual({
+ text: "Your week is trending steady.",
+ source: "briefing",
+ });
+ });
+
+ it("never leads with 'Good morning.' (the defect this replaces)", () => {
+ const o = buildTodayOverview(
+ input({
+ briefing: briefing(
+ "Good morning. Your latest blood pressure is sitting in the optimal band.",
+ ),
+ }),
+ t,
+ );
+ expect(o.lead?.text).toBe(
+ "Your latest blood pressure is sitting in the optimal band.",
+ );
+ expect(o.briefingLead).toBe(o.lead?.text);
+ });
+
+ it("leads with the top-signal headline when the paragraph has nothing", () => {
+ const o = buildTodayOverview(
+ input({
+ briefing: briefing("Your health score is 94.", "Pulse is settling"),
+ }),
+ t,
+ );
+ expect(o.lead).toEqual({ text: "Pulse is settling", source: "briefing" });
+ });
+
+ it("builds a deterministic lead when no AI text exists", () => {
+ const o = buildTodayOverview(
+ input({ vitals: [vital(), vital({ type: "HEART_RATE_VARIABILITY" })] }),
+ t,
+ );
+ expect(o.lead).toEqual({
+ text: "All 2 of your latest vitals sit inside their usual range.",
+ source: "signal",
+ });
+ });
+
+ it("has no lead, and no filler, when there is nothing to say", () => {
+ const o = buildTodayOverview(input(), t);
+ expect(o.lead).toBeNull();
+ expect(o.today).toEqual([]);
+ });
+});
+
+describe("deterministic lead — strongest signal", () => {
+ it("names the vital furthest outside its range, with its numbers", () => {
+ const o = buildTodayOverview(
+ input({
+ vitals: [
+ vital({ value: 60, direction: "above", valueLabel: "60 bpm" }),
+ vital({
+ type: "HEART_RATE_VARIABILITY",
+ value: 20,
+ low: 40,
+ high: 60,
+ direction: "below",
+ valueLabel: "20 ms",
+ rangeLabel: "40 to 60 ms",
+ }),
+ ],
+ }),
+ t,
+ );
+ // HRV is a full band width below; resting HR a quarter above.
+ expect(o.lead?.text).toBe(
+ "Heart-rate variability is at 20 ms, below your usual range of 40 to 60 ms, and 1 more vital is outside its range too.",
+ );
+ expect(o.lead?.source).toBe("signal");
+ });
+
+ it("says a single out-of-range vital plainly", () => {
+ const o = buildTodayOverview(
+ input({
+ vitals: [
+ vital({ value: 61, direction: "above", valueLabel: "61 bpm" }),
+ ],
+ }),
+ t,
+ );
+ expect(o.lead?.text).toBe(
+ "Resting heart rate is at 61 bpm, above your usual range of 50 to 58 bpm.",
+ );
+ });
+
+ it("puts an unusual night ahead of calm vitals", () => {
+ const o = buildTodayOverview(
+ input({
+ sleep: { minutes: 330, usualMinutes: 440 },
+ vitals: [vital(), vital({ type: "PULSE" })],
+ }),
+ t,
+ );
+ expect(o.lead?.text).toBe(
+ "Last night you slept 5h 30m, 1h 50m less than usual.",
+ );
+ // The night is the lead, so it is not repeated as a fact; the vitals stay.
+ expect(o.today.map((f) => f.kind)).toEqual(["vitals"]);
+ });
+
+ it("removes the fact it was built from, and only that one", () => {
+ const o = buildTodayOverview(
+ input({
+ vitals: [vital(), vital({ type: "PULSE" })],
+ sleep: { minutes: 440, usualMinutes: 430 },
+ }),
+ t,
+ );
+ expect(o.lead?.source).toBe("signal");
+ expect(o.today.map((f) => f.kind)).toEqual(["sleep"]);
+ });
+
+ it("reads an ordinary night when there is nothing else", () => {
+ const o = buildTodayOverview(
+ input({ sleep: { minutes: 440, usualMinutes: 430 } }),
+ t,
+ );
+ expect(o.lead?.text).toBe(
+ "Last night you slept 7h 20m, close to your usual.",
+ );
+ expect(o.today).toEqual([]);
+ });
+
+ it("ignores a vital that is not from today or yesterday", () => {
+ const o = buildTodayOverview(
+ input({
+ vitals: [vital({ daysAgo: 4, direction: "above", value: 70 })],
+ }),
+ t,
+ );
+ expect(o.lead).toBeNull();
+ expect(o.today).toEqual([]);
+ });
+});
+
+describe("AI on and off", () => {
+ it("keeps the same facts with and without AI text", () => {
+ const withAi = buildTodayOverview(busyDay({ restMode: null }), t);
+ const withoutAi = buildTodayOverview(
+ busyDay({ restMode: null, briefing: null }),
+ t,
+ );
+ expect(withAi.lead?.source).toBe("briefing");
+ expect(withoutAi.lead?.source).toBe("signal");
+ // Without AI the deterministic lead takes the vitals line; every other
+ // fact is identical.
+ expect(withAi.today.filter((f) => f.kind !== "vitals")).toEqual(
+ withoutAi.today,
+ );
+ });
+});
+
+describe("Today facts", () => {
+ it("orders facts by priority and caps them at five, never padding", () => {
+ const o = buildTodayOverview(busyDay(), t);
+ expect(o.today.map((f) => f.kind)).toEqual([
+ "rest_mode",
+ "medications",
+ "appointment",
+ "sleep",
+ "vitals",
+ ]);
+ expect(o.today).toHaveLength(MAX_TODAY_FACTS);
+ });
+
+ it("states values without asking for anything", () => {
+ const o = buildTodayOverview(busyDay(), t);
+ const byKind = Object.fromEntries(o.today.map((f) => [f.kind, f]));
+ expect(byKind.rest_mode.value).toBe("Day 3");
+ expect(byKind.medications.value).toBe("1 of 3 taken");
+ expect(byKind.appointment.value).toBe("Tomorrow 09:30, Dr. Example");
+ expect(byKind.sleep.value).toBe("7h 20m, close to your usual");
+ expect(byKind.vitals.value).toBe("2 checked, all in your range");
+ for (const fact of o.today) {
+ expect(fact.value).not.toMatch(/\b(please|log|add|record|measure)\b/i);
+ }
+ });
+
+ it("shows the cycle once there is room for it", () => {
+ const o = buildTodayOverview(busyDay({ restMode: null }), t);
+ expect(o.today.at(-1)).toMatchObject({
+ kind: "cycle",
+ value: "Follicular, day 12",
+ href: "/cycle",
+ });
+ });
+
+ it("says only the day while the cycle phase is withheld", () => {
+ const o = buildTodayOverview(
+ input({ cycle: { dayOfCycle: 4, phase: null } }),
+ t,
+ );
+ expect(o.today).toEqual([
+ expect.objectContaining({ kind: "cycle", value: "Day 4" }),
+ ]);
+ });
+
+ it("names the out-of-range vital in the vitals line", () => {
+ const o = buildTodayOverview(
+ input({
+ briefing: briefing("Your week is trending steady."),
+ vitals: [
+ vital({ direction: "above", value: 61 }),
+ vital({ type: "PULSE" }),
+ ],
+ }),
+ t,
+ );
+ expect(o.today).toEqual([
+ expect.objectContaining({
+ kind: "vitals",
+ value: "Resting heart rate above your range",
+ }),
+ ]);
+ });
+
+ it("skips medications when nothing is scheduled today", () => {
+ const o = buildTodayOverview(
+ input({ medsToday: meds({ activeCount: 2, scheduledToday: 0 }) }),
+ t,
+ );
+ expect(o.today).toEqual([]);
+ });
+});
+
+describe("module gating", () => {
+ const cases: Array<[string, TodayOverviewInput["modules"], string]> = [
+ ["illness", { illness: false }, "rest_mode"],
+ ["medications", { medications: false }, "medications"],
+ ["sleep", { sleep: false }, "sleep"],
+ ["cycle", { cycle: false }, "cycle"],
+ ];
+
+ it.each(cases)(
+ "drops the %s fact when its module is off",
+ (_name, modules, kind) => {
+ const on = buildTodayOverview(busyDay({ restMode: null }), t);
+ const all = buildTodayOverview(
+ busyDay(kind === "rest_mode" ? {} : { restMode: null }),
+ t,
+ );
+ const off = buildTodayOverview(
+ busyDay({
+ modules,
+ ...(kind === "rest_mode" ? {} : { restMode: null }),
+ }),
+ t,
+ );
+ expect([...on.today, ...all.today].some((f) => f.kind === kind)).toBe(
+ true,
+ );
+ expect(off.today.some((f) => f.kind === kind)).toBe(false);
+ },
+ );
+
+ it("never builds a sleep lead with the sleep module off", () => {
+ const o = buildTodayOverview(
+ input({
+ modules: { sleep: false },
+ sleep: { minutes: 300, usualMinutes: 440 },
+ }),
+ t,
+ );
+ expect(o.lead).toBeNull();
+ });
+
+ it("leaves out a vital whose module is off", () => {
+ const o = buildTodayOverview(
+ input({
+ modules: { glucose: false },
+ vitals: [
+ vital({
+ type: "BLOOD_GLUCOSE",
+ moduleKey: "glucose",
+ direction: "above",
+ value: 200,
+ }),
+ ],
+ }),
+ t,
+ );
+ expect(o.lead).toBeNull();
+ expect(o.today).toEqual([]);
+ });
+
+ it("keeps everything with every module on", () => {
+ const o = buildTodayOverview(busyDay({ restMode: null }), t);
+ expect(o.today.map((f) => f.kind)).toEqual([
+ "medications",
+ "appointment",
+ "sleep",
+ "vitals",
+ "cycle",
+ ]);
+ });
+});
+
+describe("appointments", () => {
+ const rail = (kind: PriorityItem["kind"]): PriorityItem[] => [
+ { kind, title: "x", status: "info", actions: [] },
+ ];
+
+ it("shows today before tomorrow", () => {
+ const o = buildTodayOverview(
+ input({
+ visits: [
+ { id: "b", dayOffset: 1, timeLabel: "08:00", what: "Dentist" },
+ { id: "a", dayOffset: 0, timeLabel: "16:15", what: "GP" },
+ ],
+ }),
+ t,
+ );
+ expect(o.today[0].value).toBe("Today 16:15, GP");
+ });
+
+ it("says nothing about the day after tomorrow", () => {
+ const o = buildTodayOverview(
+ input({
+ visits: [{ id: "a", dayOffset: 2, timeLabel: "10:00", what: "GP" }],
+ }),
+ t,
+ );
+ expect(o.today).toEqual([]);
+ });
+
+ it("counts further visits on the same day", () => {
+ const o = buildTodayOverview(
+ input({
+ visits: [
+ { id: "a", dayOffset: 0, timeLabel: "09:00", what: "GP" },
+ { id: "b", dayOffset: 0, timeLabel: "11:00", what: "Lab" },
+ ],
+ }),
+ t,
+ );
+ expect(o.today[0].value).toBe("Today 09:00, GP +1");
+ });
+
+ it("does not repeat a visit the rail already shows", () => {
+ const o = buildTodayOverview(
+ input({
+ visits: [{ id: "a", dayOffset: 0, timeLabel: "09:00", what: "GP" }],
+ rail: rail("upcoming_visit"),
+ }),
+ t,
+ );
+ expect(o.today).toEqual([]);
+ });
+
+ it("still shows it when the rail holds something else", () => {
+ const o = buildTodayOverview(
+ input({
+ visits: [{ id: "a", dayOffset: 0, timeLabel: "09:00", what: "GP" }],
+ rail: rail("dose_window"),
+ }),
+ t,
+ );
+ expect(o.today).toHaveLength(1);
+ });
+});
+
+describe("locales", () => {
+ it.each(["de", "es", "fr", "it", "pl", "ko"] as Locale[])(
+ "resolves every %s string from its own bundle",
+ (locale) => {
+ const tl = getServerTranslator(locale).t;
+ const o = buildTodayOverview(
+ busyDay({ locale, restMode: null, briefing: null }),
+ tl,
+ );
+ const en = buildTodayOverview(
+ busyDay({ restMode: null, briefing: null }),
+ t,
+ );
+ expect(o.lead?.text).toBeTruthy();
+ expect(o.lead?.text).not.toBe(en.lead?.text);
+ for (const fact of o.today) {
+ expect(fact.label).not.toMatch(/^daily\./);
+ expect(fact.value).not.toMatch(/daily\./);
+ }
+ },
+ );
+
+ it("uses the Polish few-form for two to four", () => {
+ const tl = getServerTranslator("pl").t;
+ const o = buildTodayOverview(
+ input({
+ locale: "pl",
+ vitals: [vital(), vital({ type: "PULSE" }), vital({ type: "WEIGHT" })],
+ }),
+ tl,
+ );
+ expect(o.lead?.text).toBe(
+ "Wszystkie 3 ostatnie parametry życiowe mieszczą się w zwykłym zakresie.",
+ );
+ });
+});
diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts
index ad03736fd..0379c23a7 100644
--- a/src/lib/daily/digest.ts
+++ b/src/lib/daily/digest.ts
@@ -55,13 +55,20 @@ import {
COACH_CHECKIN_ADJUST_INTENT,
} from "@/lib/daily/coach-checkin-intents";
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
+import type { Locale } from "@/lib/i18n/config";
+import {
+ buildTodayOverview,
+ type TodayCycle,
+ type TodayFact,
+ type TodayLead,
+ type TodayRestMode,
+ type TodaySleep,
+ type TodayVital,
+} from "@/lib/daily/today-overview";
/** At most three rail items — a glance, never a wall (§2.5, never padded). */
export const MAX_WORTH_A_LOOK = 3;
-/** Trim a briefing-lead sentence to a lock-screen-friendly length. */
-const MAX_LINE_LENGTH = 160;
-
const MS_PER_DAY = 86_400_000;
/**
@@ -123,6 +130,14 @@ export interface DailyDigestScore {
* broad one. Omitted by older cached digests, like its siblings above.
*/
scoreBasis?: ScoreBasis;
+ /**
+ * Whole weeks the score has held where it is (same band, within two
+ * points, computed the same way), read off the stored daily scores. Null
+ * when it has not held for two weeks, when the stored record does not
+ * reach today, or when the score moved. Optional so older cached digests
+ * stay valid.
+ */
+ steadyWeeks?: number | null;
}
/** A broken integration, deterministically derived from `IntegrationStatus`. */
@@ -170,6 +185,12 @@ export interface DailyDigestUpcomingVisit {
* builder never needs the timezone.
*/
dayOffset: number;
+ /**
+ * The start time on the person's own clock, in their profile timezone,
+ * rendered by the IO seam ("09:30"). Optional so a fixture that predates
+ * the Today overview stays valid; the overview skips a visit without it.
+ */
+ timeLabel?: string;
}
/**
@@ -370,6 +391,21 @@ export interface DailyDigestInput {
* valid; the builder treats a missing value as "nothing landed today".
*/
arrivals?: readonly DailyDigestArrival[];
+ /**
+ * The reader's locale, for the overview's count-bearing strings. Optional
+ * so a fixture that predates the overview stays valid (English then).
+ */
+ locale?: Locale;
+ /** Rest Mode, when an illness episode is active (illness module). */
+ restMode?: TodayRestMode | null;
+ /** Last night's sleep, when it is in the record (sleep module). */
+ sleepLastNight?: TodaySleep | null;
+ /** Each banded vital's standing against its personal range. */
+ vitals?: readonly TodayVital[];
+ /** Today's place in the cycle (cycle module). */
+ cycle?: TodayCycle | null;
+ /** Whole weeks the score has held; see `DailyDigestScore.steadyWeeks`. */
+ scoreSteadyWeeks?: number | null;
}
export interface DailyDigest {
@@ -387,8 +423,27 @@ export interface DailyDigest {
* fan-out on every read, exactly the warm-on-mount load the plan forbids.
*/
topSignal: DailyBriefingSignal | null;
- /** First sentence of the cached briefing paragraph, read-only. */
+ /**
+ * The first sentence of the cached briefing paragraph that says something:
+ * a greeting ("Good morning.") or a sentence that only repeats the score is
+ * skipped. Read-only.
+ */
briefingLead: string | null;
+ /**
+ * The hero's lead line, resolved: the reaction line or a briefing sentence
+ * while AI text is available, otherwise a deterministic sentence about the
+ * day's strongest signal. Null when there is nothing to say.
+ */
+ lead: TodayLead | null;
+ /**
+ * The Today overview: up to five statements about the day (Rest Mode,
+ * medications, an appointment today or tomorrow, last night, vitals,
+ * cycle), in priority order, never padded. A narrow screen shows the
+ * first four.
+ */
+ today: TodayFact[];
+ /** Whether an illness episode is active (Rest Mode), and since which day. */
+ restMode: TodayRestMode | null;
/** The push / lock-screen line (cached-AI lead with a deterministic floor). */
line: string;
/** Bounded 0–3 rail items, never padded. */
@@ -417,19 +472,6 @@ export interface DailyDigest {
ai: DailyDigestAi;
}
-/** First sentence of a paragraph, trimmed; null when empty. */
-function firstSentence(paragraph: string | null | undefined): string | null {
- if (!paragraph) return null;
- const trimmed = paragraph.trim();
- if (!trimmed) return null;
- const match = trimmed.match(/^[\s\S]*?[.!?](?:\s|$)/);
- let sentence = (match ? match[0] : trimmed).trim();
- if (sentence.length > MAX_LINE_LENGTH) {
- sentence = `${sentence.slice(0, MAX_LINE_LENGTH - 1).trimEnd()}…`;
- }
- return sentence.length > 0 ? sentence : null;
-}
-
/** Title-case a lowercase integration token for user-facing copy. */
function integrationLabel(token: string): string {
if (!token) return token;
@@ -962,19 +1004,23 @@ function buildEcgNewRecordingItem(
}
/**
- * The push / lock-screen line: prefer the warmer cached briefing lead, fall
- * back to the top signal's headline, then a deterministic score floor, then
- * the honest all-clear. NEVER a fresh AI call — every branch reads cache or a
- * deterministic string, so a keyless self-hoster still gets a first-class line.
+ * The push / lock-screen line: the warmer cached briefing lead first, then
+ * the top signal's headline, then the deterministic sentence about the day's
+ * strongest signal, then a score floor, then the honest all-clear. The
+ * reaction line is left out on purpose: it answers something that landed
+ * during the day, and the morning push is sent before anything has. NEVER a
+ * fresh AI call — every branch reads cache or a deterministic string, so a
+ * keyless self-hoster still gets a first-class line.
*/
function composeLine(
- briefingLead: string | null,
+ overview: { briefingLead: string | null; lead: TodayLead | null },
topSignal: DailyBriefingSignal | null,
score: DailyDigestScore | null,
t: Translate,
): string {
- if (briefingLead) return briefingLead;
+ if (overview.briefingLead) return overview.briefingLead;
if (topSignal?.headline) return topSignal.headline.trim();
+ if (overview.lead?.source === "signal") return overview.lead.text;
if (score) return t("daily.line.score", { score: score.value });
return t("daily.line.allClear");
}
@@ -1034,7 +1080,6 @@ export function buildDailyDigest(
// is the builder's own backstop, so the DTO cannot carry it by accident.
const briefing = input.ai.briefing.available ? input.briefing : null;
const topSignal = briefing?.signalsOfDay?.[0] ?? null;
- const briefingLead = firstSentence(briefing?.paragraph);
// The day's most recently LANDED arrival drives both reaction fields. The
// sample timestamp may be hours old (sleep synced after waking, an offline
@@ -1170,15 +1215,58 @@ export function buildDailyDigest(
(item) => !item.itemKey || !input.dismissedItemKeys.has(item.itemKey),
);
+ const rail = capWithPinned(visible, pinned, MAX_WORTH_A_LOOK);
+
+ const overview = buildTodayOverview(
+ {
+ locale: input.locale ?? "en",
+ modules: input.modules,
+ reactionLine,
+ briefing,
+ scoreValue: input.score?.value ?? null,
+ medsToday: input.medsToday,
+ visits: (input.upcomingVisits ?? []).flatMap((visit) =>
+ visit.timeLabel === undefined ||
+ Number.isNaN(new Date(visit.occurredAt).getTime())
+ ? []
+ : [
+ {
+ id: visit.id,
+ dayOffset: visit.dayOffset,
+ timeLabel: visit.timeLabel,
+ what:
+ visit.practitionerName ??
+ t(encounterKindLabelKey(visit.kind as EncounterKind)),
+ },
+ ],
+ ),
+ rail,
+ restMode: input.restMode ?? null,
+ sleep: input.sleepLastNight ?? null,
+ vitals: input.vitals ?? [],
+ cycle: input.cycle ?? null,
+ },
+ t,
+ );
+
+ const score: DailyDigestScore | null = input.score
+ ? { ...input.score, steadyWeeks: input.scoreSteadyWeeks ?? null }
+ : null;
+
return {
generatedAt: input.now.toISOString(),
phase,
sleepPending,
- score: input.score,
+ score,
topSignal,
- briefingLead,
- line: composeLine(briefingLead, topSignal, input.score, t),
- worthALook: capWithPinned(visible, pinned, MAX_WORTH_A_LOOK),
+ briefingLead: overview.briefingLead,
+ lead: overview.lead,
+ today: overview.today,
+ restMode: moduleEnabled(input.modules, "illness")
+ ? (input.restMode ?? null)
+ : null,
+ line: composeLine(overview, topSignal, input.score, t),
+ worthALook: rail,
justIn,
reactionLine,
ai: input.ai,
diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts
index dcfebbdb3..de34bc90e 100644
--- a/src/lib/daily/load-digest.ts
+++ b/src/lib/daily/load-digest.ts
@@ -68,6 +68,35 @@ import {
PRIORITY_ITEM_KINDS,
type PriorityItemKind,
} from "@/lib/daily/priority-item";
+import type { MeasurementType } from "@/generated/prisma/client";
+import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation";
+import { loadBaselineProfile } from "@/lib/insights/derived/baseline";
+import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
+import {
+ applyDisplayTransform,
+ getReadingTransform,
+ resolveUnitPreferences,
+ type UnitPreferences,
+} from "@/lib/measurements/display-transform";
+import { resolveRestMode } from "@/lib/illness/rest-mode";
+import { readTodayCycle } from "@/lib/cycle/today-verdict";
+import { addDays, dayDiff } from "@/lib/cycle/day-math";
+import { makeFormatters, resolveIntlLocale } from "@/lib/format-locale";
+import type {
+ DateFormatPreference,
+ TimeFormatPreference,
+} from "@/lib/format-locale";
+import {
+ STEADY_READ_DAYS,
+ steadyWeeks,
+ type StoredScoreDay,
+} from "@/lib/daily/score-steady";
+import type {
+ TodayCycle,
+ TodayRestMode,
+ TodaySleep,
+ TodayVital,
+} from "@/lib/daily/today-overview";
/** Integration states that mean "your action is needed to keep data flowing". */
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
@@ -206,6 +235,141 @@ interface DailyDigestExtras {
* The labels are grouped at the call site, where the locale is resolved.
*/
sameTime: Omit | null;
+ /**
+ * Each banded vital's standing against its personal range, from the
+ * coincident-deviation engine. Canonical numbers only: the labels depend on
+ * the reader's units and locale and are rendered per request.
+ */
+ vitals: ExtrasVital[];
+ /** The stored daily scores the "steady for" line is read from. */
+ scoreDays: StoredScoreDay[];
+}
+
+interface ExtrasVital {
+ type: MeasurementType;
+ value: number;
+ low: number;
+ high: number;
+ direction: "above" | "below" | "in";
+ daysAgo: number;
+}
+
+/**
+ * The vitals read for the Today overview: every banded vital's latest day
+ * against its personal range. Fault-isolated, like every extras input; an
+ * engine that cannot answer (too few banded vitals) leaves the list empty,
+ * and the overview simply has no vitals line.
+ */
+async function gatherVitals(
+ userId: string,
+ timezone: string,
+ now: Date,
+): Promise {
+ try {
+ const profile = await loadBaselineProfile(prisma, userId);
+ const result = await computeCoincidentDeviation(userId, profile, {
+ now,
+ tz: timezone,
+ });
+ if (result.status !== "ok") return [];
+ return result.value.vitals.map((v) => ({
+ type: v.type,
+ value: v.value,
+ low: v.low,
+ high: v.high,
+ direction: v.direction,
+ daysAgo: Number.isFinite(v.daysAgo) ? v.daysAgo : Number.MAX_SAFE_INTEGER,
+ }));
+ } catch {
+ return [];
+ }
+}
+
+/** The stored daily scores for the "steady for" read. Fault-isolated. */
+async function gatherScoreDays(
+ userId: string,
+ todayLocalDate: string,
+): Promise {
+ try {
+ // Calendar arithmetic on the reader's own day key, the space the stored
+ // rows are keyed in.
+ const since = addDays(todayLocalDate, -STEADY_READ_DAYS);
+ const rows = await prisma.healthScoreRecord.findMany({
+ where: { userId, dayKey: { gte: since } },
+ orderBy: { dayKey: "desc" },
+ take: STEADY_READ_DAYS + 1,
+ select: {
+ dayKey: true,
+ composite: true,
+ band: true,
+ scoreVersion: true,
+ composition: true,
+ },
+ });
+ return rows.map((row) => ({
+ dayKey: row.dayKey,
+ composite: row.composite,
+ band: String(row.band),
+ scoreVersion: row.scoreVersion,
+ composition: row.composition,
+ }));
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Display decimals per vital. The transform's own `decimals` covers the
+ * converted units (lb, °F, mmol/L); the rest are whole numbers on every
+ * device and in every chart that shows them.
+ */
+function vitalDecimals(type: string, transformDecimals: number): number {
+ switch (type) {
+ case "BODY_TEMPERATURE":
+ case "SKIN_TEMPERATURE":
+ case "WEIGHT":
+ return 1;
+ case "BLOOD_GLUCOSE":
+ return transformDecimals;
+ default:
+ return 0;
+ }
+}
+
+/** Render one vital's value and range in the reader's units and locale. */
+function toTodayVital(
+ vital: ExtrasVital,
+ units: UnitPreferences,
+ locale: Locale,
+ t: (key: string, params?: Record) => string,
+): TodayVital {
+ const transform = getReadingTransform(vital.type, units);
+ const decimals = vitalDecimals(vital.type, transform.decimals);
+ const number = new Intl.NumberFormat(resolveIntlLocale(locale), {
+ maximumFractionDigits: decimals,
+ minimumFractionDigits: decimals,
+ });
+ const shown = (raw: number) =>
+ number.format(applyDisplayTransform(raw, transform));
+ const unit = transform.displayUnit;
+ return {
+ type: vital.type,
+ value: vital.value,
+ low: vital.low,
+ high: vital.high,
+ direction: vital.direction,
+ daysAgo: vital.daysAgo,
+ valueLabel: t("daily.today.valueWithUnit", {
+ value: shown(vital.value),
+ unit,
+ }),
+ rangeLabel: t("daily.today.rangeWithUnit", {
+ low: shown(vital.low),
+ high: shown(vital.high),
+ unit,
+ }),
+ moduleKey: moduleForMeasurementType(vital.type),
+ };
}
/**
@@ -236,35 +400,40 @@ async function loadDailyDigestExtrasCached(
// revision ran them sequentially). Each is already fault-isolated
// internally (a milestone read-hiccup or a tension-read failure leaves
// its own field quiet rather than breaking the other).
- const [milestone, tensionWindow, sameTime] = await Promise.all([
- gatherFreshMilestone(userId, timezone, now),
- loadIntradayPulse(userId, timezone, todayLocalDate)
- .then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null))
- .catch(() => null),
- // The engine gates itself — it returns its `insufficient` arm while it
- // is still learning the usual day, when the account has no intraday
- // rows at all (every daily-total source), and before the day's first
- // hour has finished. Only the `ok` arm reaches the rail. Passing no
- // profile because the engine reads none; fault-isolated like its
- // siblings, since the digest is a must-not-fail path.
- computeSameTimeBaseline(userId, null, {
- type: SAME_TIME_RAIL_TYPE,
- now,
- })
- .then((d): DailyDigestExtras["sameTime"] =>
- d.status === "ok"
- ? {
- type: d.value.type,
- band: d.value.band,
- asOfHour: d.value.asOfHour,
- todayValue: Math.round(d.value.todayValue),
- typicalValue: Math.round(d.value.typicalValue),
- }
- : null,
- )
- .catch(() => null),
- ]);
- return { milestone, tensionWindow, sameTime };
+ const [milestone, tensionWindow, sameTime, vitals, scoreDays] =
+ await Promise.all([
+ gatherFreshMilestone(userId, timezone, now),
+ loadIntradayPulse(userId, timezone, todayLocalDate)
+ .then((r) =>
+ r.tension ? { partOfDay: r.tension.partOfDay } : null,
+ )
+ .catch(() => null),
+ // The engine gates itself — it returns its `insufficient` arm while it
+ // is still learning the usual day, when the account has no intraday
+ // rows at all (every daily-total source), and before the day's first
+ // hour has finished. Only the `ok` arm reaches the rail. Passing no
+ // profile because the engine reads none; fault-isolated like its
+ // siblings, since the digest is a must-not-fail path.
+ computeSameTimeBaseline(userId, null, {
+ type: SAME_TIME_RAIL_TYPE,
+ now,
+ })
+ .then((d): DailyDigestExtras["sameTime"] =>
+ d.status === "ok"
+ ? {
+ type: d.value.type,
+ band: d.value.band,
+ asOfHour: d.value.asOfHour,
+ todayValue: Math.round(d.value.todayValue),
+ typicalValue: Math.round(d.value.typicalValue),
+ }
+ : null,
+ )
+ .catch(() => null),
+ gatherVitals(userId, timezone, now),
+ gatherScoreDays(userId, todayLocalDate),
+ ]);
+ return { milestone, tensionWindow, sameTime, vitals, scoreDays };
},
annotate,
DIGEST_EXTRAS_CACHE_TTL_MS,
@@ -573,14 +742,74 @@ export async function loadDailyDigest(
* notification about something the start page no longer mentioned. A visit
* that is still PLANNED stays on the rail until its day ends.
*/
+ // The start time on the person's own clock, rendered here in their profile
+ // timezone and hour-cycle preference. Done on the server so every reader of
+ // the digest prints the same time for the same visit, whatever zone their
+ // device is in.
+ const visitTime = makeFormatters(
+ locale,
+ user.timezone,
+ (user.timeFormat ?? "AUTO") as TimeFormatPreference,
+ (user.dateFormat ?? "AUTO") as DateFormatPreference,
+ ).time;
const upcomingVisits: DailyDigestUpcomingVisit[] = visitRows.map((row) => ({
id: row.id,
kind: row.kind,
occurredAt: row.occurredAt.toISOString(),
practitionerName: row.practitioner?.name ?? null,
dayOffset: calendarDaysUntil(row.occurredAt, now, user.timezone),
+ timeLabel: visitTime(row.occurredAt),
}));
+ // Rest Mode and today's cycle day. Both are read per request rather than
+ // through the extras cell: an episode or a period logged a minute ago is not
+ // a measurement write, so nothing would evict a cached answer. Each read is
+ // skipped outright when its module is off, and fault-isolated, because the
+ // digest is a must-not-fail path.
+ const [restModeContext, cycleRead] = await Promise.all([
+ modules.illness !== false
+ ? resolveRestMode(user.id, now)
+ : Promise.resolve(null),
+ modules.cycle !== false
+ ? readTodayCycle(user.id, user.timezone, now).catch(() => null)
+ : Promise.resolve(null),
+ ]);
+ const restMode: TodayRestMode | null =
+ restModeContext?.active && restModeContext.since
+ ? {
+ day:
+ Math.max(
+ 0,
+ dayDiff(
+ todayLocalDate,
+ userDayKey(new Date(restModeContext.since), user.timezone),
+ ),
+ ) + 1,
+ }
+ : null;
+ const cycle: TodayCycle | null = cycleRead
+ ? { dayOfCycle: cycleRead.dayOfCycle, phase: cycleRead.phase }
+ : null;
+
+ // Last night, when it is in: the snapshot's sleep summary carries per-night
+ // time asleep in minutes, `latest` being the newest night and `avg30` the
+ // person's own trailing mean. "In" means the freshest night ended on the
+ // reader's local today, which is the same test the freshness phase uses.
+ const sleepSummary = snapshot.tiles.summaries?.SLEEP_DURATION;
+ const sleepLastNight: TodaySleep | null =
+ sleepLastSeenDaysAgo === 0 &&
+ sleepSummary?.latest !== null &&
+ sleepSummary?.latest !== undefined &&
+ sleepSummary.latest > 0
+ ? {
+ minutes: sleepSummary.latest,
+ usualMinutes:
+ sleepSummary.avg30 !== null && sleepSummary.avg30 > 0
+ ? sleepSummary.avg30
+ : null,
+ }
+ : null;
+
// S10 — the freshest ECG recording (device verdict + recordedAt only). The
// builder decides "new" and gates on the `insights` module. An ECG row only
// ever carries an AFib-screening verdict; the shared enum's other members
@@ -616,7 +845,7 @@ export async function loadDailyDigest(
// a 120 s poll of every open tab doesn't re-run the ~8.5k-row streak +
// per-sample tension reads on every request — see its docblock for the
// cache/invalidation contract.
- const { milestone, tensionWindow, sameTime } =
+ const { milestone, tensionWindow, sameTime, vitals, scoreDays } =
await loadDailyDigestExtrasCached(
user.id,
user.timezone,
@@ -648,6 +877,10 @@ export async function loadDailyDigest(
const resolvedLocale: Locale = locale;
const { t } = getServerTranslator(resolvedLocale);
+ const units = resolveUnitPreferences({
+ unitPreference: user.unitPreference,
+ glucoseUnit: user.glucoseUnit,
+ });
// A Coach-suggested measurement cadence stores its label as a bundle key;
// the checkups page and the dashboard card translate it, and so does this
@@ -711,6 +944,16 @@ export async function loadDailyDigest(
arrivals: arrivalRows
.map((row) => toDigestArrival(row, reactionLinesAi.available))
.filter((a): a is DailyDigestArrival => a !== null),
+ locale: resolvedLocale,
+ restMode,
+ sleepLastNight,
+ vitals: (vitals ?? []).map((v) =>
+ toTodayVital(v, units, resolvedLocale, t),
+ ),
+ cycle,
+ scoreSteadyWeeks: score
+ ? steadyWeeks(scoreDays ?? [], todayLocalDate, score.value)
+ : null,
},
t,
);
diff --git a/src/lib/daily/score-steady.ts b/src/lib/daily/score-steady.ts
new file mode 100644
index 000000000..613e07be7
--- /dev/null
+++ b/src/lib/daily/score-steady.ts
@@ -0,0 +1,77 @@
+/**
+ * "Steady for N weeks": how long the health score has held where it is.
+ *
+ * The score moves slowly on purpose (long windows against reference bands),
+ * so on most days the honest thing to say about it is not a delta but how long
+ * it has stayed put. That is a real statement only when it comes with a
+ * duration read off the record, which is what this does: it walks the stored
+ * daily scores (`HealthScoreRecord`) back from the newest and counts how long
+ * the number held.
+ *
+ * A day belongs to the run while it was computed the same way (same algorithm
+ * version, same composition), sat in the same band, and stayed within
+ * {@link STEADY_TOLERANCE} points of the newest value. A gap in the record
+ * longer than {@link STEADY_MAX_GAP_DAYS} ends the run: an absence is not
+ * evidence of steadiness.
+ */
+import { dayDiff } from "@/lib/cycle/day-math";
+
+/** Points either side of the newest value that still count as "the same". */
+export const STEADY_TOLERANCE = 2;
+
+/** Longest stretch without a stored score the run may bridge. */
+export const STEADY_MAX_GAP_DAYS = 3;
+
+/** Shortest run worth saying out loud. One week is not yet a pattern. */
+export const STEADY_MIN_WEEKS = 2;
+
+/** How far back the read looks; also the longest run it can report. */
+export const STEADY_READ_DAYS = 120;
+
+export interface StoredScoreDay {
+ dayKey: string;
+ composite: number;
+ band: string;
+ scoreVersion: number;
+ composition: readonly string[];
+}
+
+function sameComposition(a: readonly string[], b: readonly string[]): boolean {
+ return a.length === b.length && a.every((id, i) => id === b[i]);
+}
+
+/**
+ * Whole weeks the score has held, or null when it has not held for at least
+ * {@link STEADY_MIN_WEEKS}. `rows` may come in any order; `todayKey` is the
+ * reader's local day, so a record that stopped updating days ago does not
+ * claim a run that reaches today. The newest stored day must also agree with
+ * the number on screen: the ring is computed live, the record nightly, and a
+ * run that ended overnight is not "steady" next to a number that has moved.
+ */
+export function steadyWeeks(
+ rows: readonly StoredScoreDay[],
+ todayKey: string,
+ /** The number the ring shows; a run that does not end there is not told. */
+ shownValue: number,
+): number | null {
+ if (rows.length === 0) return null;
+ const sorted = [...rows].sort((a, b) =>
+ a.dayKey < b.dayKey ? 1 : a.dayKey > b.dayKey ? -1 : 0,
+ );
+ const newest = sorted[0];
+ if (dayDiff(todayKey, newest.dayKey) > STEADY_MAX_GAP_DAYS) return null;
+ if (Math.abs(newest.composite - shownValue) > STEADY_TOLERANCE) return null;
+
+ let oldest = newest;
+ for (const row of sorted.slice(1)) {
+ if (dayDiff(oldest.dayKey, row.dayKey) > STEADY_MAX_GAP_DAYS) break;
+ if (row.scoreVersion !== newest.scoreVersion) break;
+ if (!sameComposition(row.composition, newest.composition)) break;
+ if (row.band !== newest.band) break;
+ if (Math.abs(row.composite - newest.composite) > STEADY_TOLERANCE) break;
+ oldest = row;
+ }
+
+ const weeks = Math.floor(dayDiff(todayKey, oldest.dayKey) / 7);
+ return weeks >= STEADY_MIN_WEEKS ? weeks : null;
+}
diff --git a/src/lib/daily/today-overview.ts b/src/lib/daily/today-overview.ts
new file mode 100644
index 000000000..df7a78d94
--- /dev/null
+++ b/src/lib/daily/today-overview.ts
@@ -0,0 +1,594 @@
+/**
+ * The Today overview: the hero's lead line and its short list of facts about
+ * the day, composed from data the digest already gathered.
+ *
+ * Pure and deterministic, like the rest of the digest spine. It never calls a
+ * model; AI text reaches it only as the already-cached reaction line and
+ * briefing, and only while their capabilities are available (the caller has
+ * already nulled them otherwise). Everything a person without any AI provider
+ * sees here comes from their own readings.
+ *
+ * Three rules hold for every line this file writes:
+ *
+ * - A fact is a statement. No line asks the person to do anything; the
+ * worth-a-look rail is the one place on the hero allowed to.
+ * - A fact about a switched-off module does not exist. Gating happens here,
+ * on the server, never in a client.
+ * - Nothing is said twice. When the lead line is built from a fact, that
+ * fact leaves the list; an appointment already on the rail is not repeated
+ * under Today.
+ */
+import type { DailyBriefing } from "@/lib/ai/schema";
+import type { CyclePhase } from "@/lib/cycle/types";
+import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
+import type { Locale } from "@/lib/i18n/config";
+import { pluralKey } from "@/lib/i18n/plural";
+import { formatDurationMinutes } from "@/lib/i18n/duration";
+import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
+import type { ModuleKey } from "@/lib/modules/registry";
+import type { PriorityItem } from "@/lib/daily/priority-item";
+
+type Translate = (
+ key: string,
+ params?: Record,
+) => string;
+
+/** The most facts the overview ever carries (a wide screen shows them all). */
+export const MAX_TODAY_FACTS = 5;
+
+/**
+ * The most facts a narrow screen shows. The list is already in priority
+ * order, so a phone simply shows its head; the fifth line is never a
+ * different fact on a phone, only an absent one.
+ */
+export const MAX_TODAY_FACTS_NARROW = 4;
+
+export const TODAY_FACT_KINDS = [
+ "rest_mode",
+ "medications",
+ "appointment",
+ "sleep",
+ "vitals",
+ "cycle",
+] as const;
+
+export type TodayFactKind = (typeof TODAY_FACT_KINDS)[number];
+
+/** One line under "Today": a label, a value, and where it leads. */
+export interface TodayFact {
+ kind: TodayFactKind;
+ /** Localised label, resolved on the server. */
+ label: string;
+ /** Localised value, resolved on the server. */
+ value: string;
+ /** The page the fact belongs to. */
+ href: string;
+ /** The module that admitted the fact; absent for core data. */
+ moduleKey?: ModuleKey;
+}
+
+export const TODAY_LEAD_SOURCES = ["reaction", "briefing", "signal"] as const;
+
+export type TodayLeadSource = (typeof TODAY_LEAD_SOURCES)[number];
+
+/**
+ * The hero's lead line, resolved. `reaction` and `briefing` are cached model
+ * text; `signal` is the deterministic sentence built from the day's strongest
+ * signal, which is what every account without AI reads.
+ */
+export interface TodayLead {
+ text: string;
+ source: TodayLeadSource;
+}
+
+/**
+ * One vital's standing against its personal band, as the coincident-deviation
+ * engine computed it, with the two labels the copy needs already rendered in
+ * the reader's units.
+ */
+export interface TodayVital {
+ type: string;
+ /** Canonical value; only compared, never printed. */
+ value: number;
+ low: number;
+ high: number;
+ direction: "above" | "below" | "in";
+ /** Whole days between the reading and the reader's local today. */
+ daysAgo: number;
+ /** The reading in the reader's units, unit included ("61 bpm"). */
+ valueLabel: string;
+ /** The band in the reader's units ("52 to 57 bpm"). */
+ rangeLabel: string;
+ /** The module the vital belongs to, or null for core vitals. */
+ moduleKey: ModuleKey | null;
+}
+
+/** Last night's sleep, when it is in the record. */
+export interface TodaySleep {
+ /** Time asleep last night, in minutes. */
+ minutes: number;
+ /** The person's own trailing average, in minutes, when there is one. */
+ usualMinutes: number | null;
+}
+
+/** The current illness episode, when Rest Mode is on. */
+export interface TodayRestMode {
+ /** 1-based day of the episode on the reader's own calendar. */
+ day: number;
+}
+
+/** Today's place in the cycle, when the cycle module knows it. */
+export interface TodayCycle {
+ dayOfCycle: number;
+ /** Withheld (null) while the engine is still learning the cycle. */
+ phase: CyclePhase | null;
+}
+
+/** A booked visit, with its start time rendered for the reader. */
+export interface TodayVisit {
+ id: string;
+ /** 0 today, 1 tomorrow, 2 the day after (profile timezone). */
+ dayOffset: number;
+ /** Start time in the reader's zone and clock ("09:30"). */
+ timeLabel: string;
+ /** Practitioner or visit kind, already resolved. */
+ what: string;
+}
+
+export interface TodayOverviewInput {
+ locale: Locale;
+ modules: Partial>;
+ /** Reaction line for today's newest arrival, already capability-gated. */
+ reactionLine: string | null;
+ /** The cached briefing, already capability-gated. */
+ briefing: DailyBriefing | null;
+ /** The number inside the ring, so a lead never repeats it. */
+ scoreValue: number | null;
+ medsToday: MedsTodayBlock;
+ visits: readonly TodayVisit[];
+ /** The rail as it will be published, for the appointment de-duplication. */
+ rail: readonly PriorityItem[];
+ restMode: TodayRestMode | null;
+ sleep: TodaySleep | null;
+ vitals: readonly TodayVital[];
+ cycle: TodayCycle | null;
+}
+
+export interface TodayOverview {
+ lead: TodayLead | null;
+ today: TodayFact[];
+ /** The first substantive briefing sentence, for `briefingLead`. */
+ briefingLead: string | null;
+}
+
+/** Trim a lead sentence to a lock-screen-friendly length. */
+const MAX_LEAD_LENGTH = 160;
+
+/**
+ * How far last night may sit from the person's usual before the night itself
+ * becomes the day's strongest signal. An hour is a difference anyone notices
+ * the next day; less than that is ordinary variation and stays a fact.
+ */
+export const SLEEP_NOTABLE_DIFF_MINUTES = 60;
+
+/** Within this many minutes of the usual, the night reads as "close to". */
+export const SLEEP_CLOSE_DIFF_MINUTES = 20;
+
+function moduleOn(
+ modules: Partial>,
+ key: ModuleKey,
+): boolean {
+ return modules[key] !== false;
+}
+
+/** Split prose into sentences, keeping each one's own punctuation. */
+function sentences(text: string): string[] {
+ return (
+ text
+ .trim()
+ .match(/(?:[^.!?]|[.!?](?=\S))+(?:[.!?]+|$)/g)
+ ?.map((s) => s.trim())
+ .filter((s) => s.length > 0) ?? []
+ );
+}
+
+/**
+ * Opening words of a salutation, in every shipped language. Matched only at
+ * the very start of a sentence, and only on a sentence with no number in it:
+ * "Good morning, your resting heart rate is 52." carries content and stays.
+ */
+const GREETING_START =
+ /^(good\s+(morning|afternoon|evening|night|day)|hello|hi|hey|welcome|guten\s+(morgen|tag|abend)|hallo|moin|servus|willkommen|buen[oa]s?\s+(d[ií]as|tardes|noches)|hola|bienvenid[oa]|bonjour|bonsoir|salut|bienvenue|buongiorno|buon\s+pomeriggio|buonasera|ciao|benvenut[oa]|dzie[nń]\s+dobry|dobry\s+wiecz[oó]r|cze[sś][cć]|witaj|witamy|좋은\s*(아침|저녁|오후)|안녕하세요|안녕)(?![\p{L}])/iu;
+
+/** Short sentence without a number, below which nothing is said. */
+const MIN_CONTENT_WORDS = 4;
+
+/**
+ * Whether a sentence is a greeting rather than a statement about the day.
+ *
+ * The briefing is model text and often opens with "Good morning." — which,
+ * taken as the first sentence, became the whole lead and pushed the sentence
+ * that had something to say out of view. A sentence carrying a number always
+ * counts as content. Otherwise it is a greeting when it opens with one, or
+ * when it is too short to say anything (an exclamation, a sign-off).
+ */
+export function isGreetingOnly(sentence: string): boolean {
+ const s = sentence.trim();
+ if (s.length === 0) return true;
+ if (/\d/.test(s)) return false;
+ if (GREETING_START.test(s)) return true;
+ const words = s.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w));
+ return words.length < MIN_CONTENT_WORDS;
+}
+
+/** Whether a sentence repeats the number the ring already shows. */
+function repeatsScore(sentence: string, score: number | null): boolean {
+ if (score === null) return false;
+ const shown = String(Math.round(score));
+ return new RegExp(`(^|[^0-9])${shown}([^0-9]|$)`).test(sentence);
+}
+
+function clampLead(sentence: string): string {
+ return sentence.length > MAX_LEAD_LENGTH
+ ? `${sentence.slice(0, MAX_LEAD_LENGTH - 1).trimEnd()}…`
+ : sentence;
+}
+
+/**
+ * The first sentence of a paragraph that says something: not a greeting, and
+ * not a restatement of the score inside the ring. Null when no sentence
+ * qualifies, so the caller falls through to the next source rather than
+ * showing filler.
+ */
+export function firstSubstantiveSentence(
+ paragraph: string | null | undefined,
+ score: number | null,
+): string | null {
+ if (!paragraph) return null;
+ for (const sentence of sentences(paragraph)) {
+ if (isGreetingOnly(sentence)) continue;
+ if (repeatsScore(sentence, score)) continue;
+ return clampLead(sentence);
+ }
+ return null;
+}
+
+/**
+ * The model's reaction line, minus any greeting or score sentence. Unlike the
+ * briefing it may keep more than one sentence: it is written to be read whole.
+ */
+function cleanReactionLine(
+ line: string | null,
+ score: number | null,
+): string | null {
+ if (!line) return null;
+ const kept = sentences(line).filter(
+ (s) => !isGreetingOnly(s) && !repeatsScore(s, score),
+ );
+ const text = kept.join(" ").replace(/\s+/g, " ").trim();
+ return text.length > 0 ? clampLead(text) : null;
+}
+
+/** Vitals that may speak about today: module on, reading current. */
+function currentVitals(input: TodayOverviewInput): TodayVital[] {
+ return input.vitals.filter(
+ (v) =>
+ isCurrentForTodayClaim(v.daysAgo) &&
+ (v.moduleKey === null || moduleOn(input.modules, v.moduleKey)),
+ );
+}
+
+/** How far outside its band a vital sits, in band widths. */
+function outsideBy(v: TodayVital): number {
+ const width = Math.max(v.high - v.low, Number.EPSILON);
+ if (v.direction === "above") return (v.value - v.high) / width;
+ if (v.direction === "below") return (v.low - v.value) / width;
+ return 0;
+}
+
+function vitalLabel(t: Translate, type: string): string {
+ const key = `daily.today.vital.${type}`;
+ const label = t(key);
+ return label === key ? t("daily.today.vital.generic") : label;
+}
+
+function sleepDiff(sleep: TodaySleep): number | null {
+ if (sleep.usualMinutes === null) return null;
+ return Math.round(sleep.minutes - sleep.usualMinutes);
+}
+
+function duration(minutes: number, t: Translate): string {
+ return formatDurationMinutes(Math.round(minutes), t);
+}
+
+interface SignalLead {
+ text: string;
+ consumes: TodayFactKind;
+}
+
+/**
+ * The deterministic lead: one sentence about the day's strongest signal,
+ * built from numbers the server already holds. In order:
+ *
+ * 1. a vital outside its personal range (the furthest out leads);
+ * 2. a night at least an hour off the person's usual;
+ * 3. every recent vital inside its range;
+ * 4. an ordinary night.
+ *
+ * Null when none of them holds; the overview then carries the facts alone,
+ * and never a filler sentence.
+ */
+function signalLead(
+ input: TodayOverviewInput,
+ sleep: TodaySleep | null,
+ t: Translate,
+): SignalLead | null {
+ const vitals = currentVitals(input);
+ const outside = vitals
+ .filter((v) => v.direction !== "in")
+ .sort((a, b) => outsideBy(b) - outsideBy(a));
+
+ if (outside.length > 0) {
+ const v = outside[0];
+ const params = {
+ metric: vitalLabel(t, v.type),
+ value: v.valueLabel,
+ range: v.rangeLabel,
+ };
+ const text =
+ outside.length === 1
+ ? t(`daily.lead.vitalOutside.${v.direction}`, params)
+ : t(
+ pluralKey(
+ `daily.lead.vitalsOutside.${v.direction}`,
+ outside.length - 1,
+ input.locale,
+ ),
+ { ...params, count: outside.length - 1 },
+ );
+ return { text, consumes: "vitals" };
+ }
+
+ const diff = sleep ? sleepDiff(sleep) : null;
+ if (sleep && diff !== null && Math.abs(diff) >= SLEEP_NOTABLE_DIFF_MINUTES) {
+ return {
+ text: t(`daily.lead.sleep.${diff > 0 ? "more" : "less"}`, {
+ duration: duration(sleep.minutes, t),
+ diff: duration(Math.abs(diff), t),
+ }),
+ consumes: "sleep",
+ };
+ }
+
+ if (vitals.length >= 2) {
+ return {
+ text: t(
+ pluralKey("daily.lead.vitalsInRange", vitals.length, input.locale),
+ { count: vitals.length },
+ ),
+ consumes: "vitals",
+ };
+ }
+ if (vitals.length === 1) {
+ const v = vitals[0];
+ return {
+ text: t("daily.lead.vitalInRange", {
+ metric: vitalLabel(t, v.type),
+ value: v.valueLabel,
+ range: v.rangeLabel,
+ }),
+ consumes: "vitals",
+ };
+ }
+
+ if (sleep) {
+ return {
+ text:
+ diff !== null
+ ? t("daily.lead.sleep.usual", {
+ duration: duration(sleep.minutes, t),
+ })
+ : t("daily.lead.sleep.plain", {
+ duration: duration(sleep.minutes, t),
+ }),
+ consumes: "sleep",
+ };
+ }
+ return null;
+}
+
+function restModeFact(
+ input: TodayOverviewInput,
+ t: Translate,
+): TodayFact | null {
+ if (!input.restMode || !moduleOn(input.modules, "illness")) return null;
+ return {
+ kind: "rest_mode",
+ label: t("daily.todayFact.restMode.label"),
+ value: t("daily.todayFact.restMode.value", { day: input.restMode.day }),
+ href: "/illness",
+ moduleKey: "illness",
+ };
+}
+
+function medicationsFact(
+ input: TodayOverviewInput,
+ t: Translate,
+): TodayFact | null {
+ if (!moduleOn(input.modules, "medications")) return null;
+ const { scheduledToday, takenToday } = input.medsToday;
+ if (scheduledToday <= 0) return null;
+ return {
+ kind: "medications",
+ label: t("daily.todayFact.medications.label"),
+ value: t("daily.todayFact.medications.value", {
+ taken: Math.min(takenToday, scheduledToday),
+ scheduled: scheduledToday,
+ }),
+ href: "/medications",
+ moduleKey: "medications",
+ };
+}
+
+/**
+ * The next appointment today or tomorrow, and nothing further out.
+ *
+ * Skipped when the rail already carries an appointment item: the rail item
+ * stays (it is the published contract every client reads, and it keeps its
+ * place under the cap for a visit today), and one card does not name the same
+ * visit twice.
+ */
+function appointmentFact(
+ input: TodayOverviewInput,
+ t: Translate,
+): TodayFact | null {
+ if (input.rail.some((item) => item.kind === "upcoming_visit")) return null;
+ const visit = [...input.visits]
+ .filter((v) => v.dayOffset >= 0 && v.dayOffset <= 1)
+ .sort((a, b) => a.dayOffset - b.dayOffset)[0];
+ if (!visit) return null;
+ const sameDay = input.visits.filter((v) => v.dayOffset === visit.dayOffset);
+ const others = sameDay.length - 1;
+ const what = others > 0 ? `${visit.what} +${others}` : visit.what;
+ return {
+ kind: "appointment",
+ label: t("daily.todayFact.appointment.label"),
+ value: t(
+ visit.dayOffset <= 0
+ ? "daily.todayFact.appointment.today"
+ : "daily.todayFact.appointment.tomorrow",
+ { time: visit.timeLabel, what },
+ ),
+ href: "/checkups",
+ };
+}
+
+function sleepFact(sleep: TodaySleep | null, t: Translate): TodayFact | null {
+ if (!sleep) return null;
+ const diff = sleepDiff(sleep);
+ const params = { duration: duration(sleep.minutes, t) };
+ const value =
+ diff === null
+ ? t("daily.todayFact.sleep.plain", params)
+ : Math.abs(diff) <= SLEEP_CLOSE_DIFF_MINUTES
+ ? t("daily.todayFact.sleep.usual", params)
+ : t(`daily.todayFact.sleep.${diff > 0 ? "more" : "less"}`, {
+ ...params,
+ diff: duration(Math.abs(diff), t),
+ });
+ return {
+ kind: "sleep",
+ label: t("daily.todayFact.sleep.label"),
+ value,
+ href: "/insights/sleep",
+ moduleKey: "sleep",
+ };
+}
+
+function vitalsFact(input: TodayOverviewInput, t: Translate): TodayFact | null {
+ const vitals = currentVitals(input);
+ if (vitals.length === 0) return null;
+ const outside = vitals
+ .filter((v) => v.direction !== "in")
+ .sort((a, b) => outsideBy(b) - outsideBy(a));
+ let value: string;
+ if (outside.length === 0) {
+ value = t(
+ pluralKey("daily.todayFact.vitals.allIn", vitals.length, input.locale),
+ { count: vitals.length },
+ );
+ } else if (outside.length === 1) {
+ value = t(`daily.todayFact.vitals.oneOut.${outside[0].direction}`, {
+ metric: vitalLabel(t, outside[0].type),
+ });
+ } else {
+ value = t("daily.todayFact.vitals.someOut", {
+ count: outside.length,
+ total: vitals.length,
+ });
+ }
+ return {
+ kind: "vitals",
+ label: t("daily.todayFact.vitals.label"),
+ value,
+ href: "/insights",
+ };
+}
+
+function cycleFact(input: TodayOverviewInput, t: Translate): TodayFact | null {
+ if (!input.cycle || !moduleOn(input.modules, "cycle")) return null;
+ return {
+ kind: "cycle",
+ label: t("daily.todayFact.cycle.label"),
+ value: input.cycle.phase
+ ? t("daily.todayFact.cycle.value", {
+ day: input.cycle.dayOfCycle,
+ phase: t(`cycle.phase.${input.cycle.phase}`),
+ })
+ : t("daily.todayFact.cycle.dayOnly", { day: input.cycle.dayOfCycle }),
+ href: "/cycle",
+ moduleKey: "cycle",
+ };
+}
+
+/**
+ * Compose the lead line and the Today facts.
+ *
+ * Lead precedence, warmest first: the reaction line to something that landed
+ * today, then the first briefing sentence with content, then the briefing's
+ * top-signal headline, then the deterministic sentence from the strongest
+ * signal. The first two are model text and only reach this function while
+ * their capabilities are available; the deterministic sentence is what makes
+ * the card whole without them.
+ *
+ * Facts follow a fixed priority (Rest Mode, medications, an appointment today
+ * or tomorrow, last night, vitals, cycle) and are capped at
+ * {@link MAX_TODAY_FACTS}. The list is never padded: a quiet account gets as
+ * many lines as it has facts, and none at all is a valid answer.
+ */
+export function buildTodayOverview(
+ input: TodayOverviewInput,
+ t: Translate,
+): TodayOverview {
+ const sleep = moduleOn(input.modules, "sleep") ? input.sleep : null;
+
+ const briefingLead = firstSubstantiveSentence(
+ input.briefing?.paragraph,
+ input.scoreValue,
+ );
+ const reaction = cleanReactionLine(input.reactionLine, input.scoreValue);
+ const headline = input.briefing?.signalsOfDay?.[0]?.headline?.trim() || null;
+
+ let lead: TodayLead | null = null;
+ let consumed: TodayFactKind | null = null;
+ if (reaction) {
+ lead = { text: reaction, source: "reaction" };
+ } else if (briefingLead) {
+ lead = { text: briefingLead, source: "briefing" };
+ } else if (headline && !repeatsScore(headline, input.scoreValue)) {
+ lead = { text: clampLead(headline), source: "briefing" };
+ } else {
+ const signal = signalLead(input, sleep, t);
+ if (signal) {
+ lead = { text: signal.text, source: "signal" };
+ consumed = signal.consumes;
+ }
+ }
+
+ const facts = [
+ restModeFact(input, t),
+ medicationsFact(input, t),
+ appointmentFact(input, t),
+ sleepFact(sleep, t),
+ vitalsFact(input, t),
+ cycleFact(input, t),
+ ].filter(
+ (fact): fact is TodayFact => fact !== null && fact.kind !== consumed,
+ );
+
+ return {
+ lead,
+ today: facts.slice(0, MAX_TODAY_FACTS),
+ briefingLead,
+ };
+}
diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts
index 64a777261..4f7b35338 100644
--- a/src/lib/openapi/routes/daily.ts
+++ b/src/lib/openapi/routes/daily.ts
@@ -13,6 +13,12 @@ import type { ZodOpenApiObject } from "zod-openapi";
import { ARRIVAL_KINDS } from "@/lib/arrivals/types";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
+import {
+ MAX_TODAY_FACTS,
+ MAX_TODAY_FACTS_NARROW,
+ TODAY_FACT_KINDS,
+ TODAY_LEAD_SOURCES,
+} from "@/lib/daily/today-overview";
import { dismissPriorityItemSchema } from "@/lib/validations/daily";
import { dataEnvelope, recordRefusal, stdResponses } from "./shared";
import { healthScoreBasis } from "./insights/schemas";
@@ -57,7 +63,24 @@ const priorityItemSchema = z
"One 'worth a look' rail item. The single model every daily-value consumer renders through PriorityCard.",
});
-const dailyDigestResponse = z
+const todayFactSchema = z
+ .object({
+ kind: z.enum([...TODAY_FACT_KINDS]).describe("Closed fact kind."),
+ label: z.string().describe("Localised label (resolved server-side)."),
+ value: z.string().describe("Localised value (resolved server-side)."),
+ href: z.string().describe("The page the fact belongs to."),
+ moduleKey: z
+ .string()
+ .optional()
+ .describe("The module that admitted the fact; absent for core data."),
+ })
+ .meta({
+ id: "DailyTodayFact",
+ description:
+ "One statement about the day on the Today overview. A fact never asks the reader to do anything; the rail is the only place that does.",
+ });
+
+export const dailyDigestResponse = z
.object({
generatedAt: z.string().describe("ISO-8601 instant the digest was read."),
phase: z
@@ -103,6 +126,15 @@ const dailyDigestResponse = z
.describe(
"What the value rests on. Since v1.38 a score is produced from one or two areas of health as well as three, so this is what distinguishes a narrow score from a broad one. Optional so older cached digests without the field stay valid.",
),
+ steadyWeeks: z
+ .number()
+ .int()
+ .positive()
+ .nullable()
+ .optional()
+ .describe(
+ "Whole weeks the score has held where it is (same band, within two points, computed the same way), read off the stored daily scores. Null when it has not held for two weeks, when the stored record does not reach today, or when the score moved. Shown in place of a delta, never beside one.",
+ ),
})
.nullable()
.describe(
@@ -124,7 +156,38 @@ const dailyDigestResponse = z
.string()
.nullable()
.describe(
- "First sentence of the cached briefing paragraph. Null while the `briefing` capability is unavailable; `line` then falls to its deterministic floor.",
+ "The first sentence of the cached briefing paragraph that says something: a greeting or a sentence that only repeats the score is skipped. Null while the `briefing` capability is unavailable or when no sentence qualifies; `line` then falls to its deterministic floor.",
+ ),
+ lead: z
+ .object({
+ text: z.string(),
+ source: z
+ .enum([...TODAY_LEAD_SOURCES])
+ .describe(
+ "`reaction` and `briefing` are cached model text; `signal` is the deterministic sentence about the day's strongest signal.",
+ ),
+ })
+ .nullable()
+ .describe(
+ "The Today hero's lead line, resolved: the reaction line or a briefing sentence while their capabilities are available, otherwise a deterministic sentence about the day's strongest signal (a vital outside its personal range, an unusual night, vitals in range, last night). Null when there is nothing to say. When the deterministic sentence is built from a fact, that fact is left out of `today`. Additive since v1.40.",
+ ),
+ today: z
+ .array(todayFactSchema)
+ .max(MAX_TODAY_FACTS)
+ .describe(
+ `The Today overview: up to ${MAX_TODAY_FACTS} statements about the day in priority order (Rest Mode, medications taken today, an appointment today or tomorrow that is not already on the rail, last night's sleep, vitals against their personal range, cycle day), never padded. A fact about a switched-off module is absent. A narrow surface shows the first ${MAX_TODAY_FACTS_NARROW}. Additive since v1.40.`,
+ ),
+ restMode: z
+ .object({
+ day: z
+ .number()
+ .int()
+ .positive()
+ .describe("1-based day of the episode on the reader's calendar."),
+ })
+ .nullable()
+ .describe(
+ "Rest Mode: present while an illness episode is active and the illness module is on, else null. Additive since v1.40.",
),
line: z
.string()
diff --git a/tests/integration/daily-digest-today-overview.test.ts b/tests/integration/daily-digest-today-overview.test.ts
new file mode 100644
index 000000000..844e73392
--- /dev/null
+++ b/tests/integration/daily-digest-today-overview.test.ts
@@ -0,0 +1,224 @@
+/**
+ * The Today overview on the daily digest, against real Postgres.
+ *
+ * The unit suites pin the selection rules on hand-built inputs. This one runs
+ * the whole read path (the dashboard snapshot, the coincident-deviation
+ * engine, the visit and episode tables, the cycle grid, the stored daily
+ * scores) on seeded rows, and checks that what comes out is a valid
+ * `DailyDigest` under the published OpenAPI schema. An account without any
+ * AI provider is the subject on purpose: the overview has to be whole
+ * without one.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { Prisma } from "@/generated/prisma/client";
+import { loadDailyDigest } from "@/lib/daily/load-digest";
+import { dailyDigestResponse } from "@/lib/openapi/routes/daily";
+import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
+import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
+
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+const TZ = "Europe/Berlin";
+const DAY = 24 * 60 * 60 * 1000;
+// 08:00 in Berlin on a Sunday morning.
+const NOW = new Date("2026-10-04T06:00:00.000Z");
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ __resetAllCachesForTests();
+});
+
+async function seedUser(
+ id: string,
+ extra: Partial = {},
+) {
+ return getPrismaClient().user.create({
+ data: {
+ id,
+ username: id,
+ email: `${id}@example.test`,
+ timezone: TZ,
+ gender: "FEMALE",
+ dateOfBirth: new Date("1985-05-05T00:00:00.000Z"),
+ ...extra,
+ },
+ });
+}
+
+/** A month of steady resting heart rate and HRV, then an elevated morning. */
+async function seedVitals(userId: string) {
+ const rows: Prisma.MeasurementCreateManyInput[] = [];
+ for (let d = 30; d >= 1; d--) {
+ const at = new Date(NOW.getTime() - d * DAY);
+ rows.push(
+ {
+ userId,
+ type: "RESTING_HEART_RATE",
+ value: 52 + (d % 3),
+ unit: "bpm",
+ measuredAt: at,
+ source: "MANUAL",
+ },
+ {
+ userId,
+ type: "HEART_RATE_VARIABILITY",
+ value: 48 + (d % 4),
+ unit: "ms",
+ measuredAt: at,
+ source: "MANUAL",
+ },
+ );
+ }
+ rows.push(
+ {
+ userId,
+ type: "RESTING_HEART_RATE",
+ value: 66,
+ unit: "bpm",
+ measuredAt: new Date(NOW.getTime() - 60 * 60 * 1000),
+ source: "MANUAL",
+ },
+ {
+ userId,
+ type: "HEART_RATE_VARIABILITY",
+ value: 49,
+ unit: "ms",
+ measuredAt: new Date(NOW.getTime() - 60 * 60 * 1000),
+ source: "MANUAL",
+ },
+ );
+ await getPrismaClient().measurement.createMany({ data: rows });
+}
+
+async function seedDay(userId: string, cycleTrackingEnabled?: boolean) {
+ const prisma = getPrismaClient();
+ await seedVitals(userId);
+ // Unwell since Friday evening, Berlin time: day 3 on Sunday.
+ await prisma.illnessEpisode.create({
+ data: {
+ userId,
+ label: "Cold",
+ type: "INFECTION",
+ onsetAt: new Date("2026-10-02T18:00:00.000Z"),
+ },
+ });
+ // Tomorrow 09:30 in Berlin.
+ await prisma.encounter.create({
+ data: {
+ userId,
+ occurredAt: new Date("2026-10-05T07:30:00.000Z"),
+ status: "PLANNED",
+ kind: "ROUTINE",
+ },
+ });
+ // One logged period starting eleven days ago: day 12 today, phase still
+ // withheld while the engine learns.
+ await prisma.cycleProfile.create({ data: { userId, cycleTrackingEnabled } });
+ await prisma.menstrualCycle.create({
+ data: {
+ userId,
+ startDate: "2026-09-23",
+ periodEndDate: "2026-09-27",
+ tz: TZ,
+ },
+ });
+}
+
+describe("the Today overview on a real record", () => {
+ it("publishes a valid digest with a deterministic lead and module-gated facts", async () => {
+ const prisma = getPrismaClient();
+ const user = await seedUser("today-owner");
+ await seedDay(user.id);
+ const full = await prisma.user.findUniqueOrThrow({
+ where: { id: user.id },
+ });
+
+ const digest = await loadDailyDigest(full, NOW, { locale: "en" });
+
+ // The published contract holds, new fields included.
+ const parsed = dailyDigestResponse.safeParse(
+ JSON.parse(JSON.stringify(digest)),
+ );
+ expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
+
+ // No AI provider: no briefing text, and the lead is built from the
+ // morning's elevated resting heart rate.
+ expect(digest.briefingLead).toBeNull();
+ expect(digest.lead?.source).toBe("signal");
+ expect(digest.lead?.text).toMatch(
+ /^Resting heart rate is at 66 bpm, above your usual range of \d+ to \d+ bpm\.$/,
+ );
+
+ // Rest Mode passes through, counted on the Berlin calendar.
+ expect(digest.restMode).toEqual({ day: 3 });
+
+ // Tomorrow's visit is on the rail, so it is not repeated under Today;
+ // the vitals line became the lead, so it is not repeated either.
+ expect(digest.worthALook.some((i) => i.kind === "upcoming_visit")).toBe(
+ true,
+ );
+ expect(digest.today.map((f) => f.kind)).toEqual(["rest_mode", "cycle"]);
+ expect(digest.today[0].value).toBe("Day 3");
+ expect(digest.today[1].value).toBe("Day 12");
+ });
+
+ it("names the visit under Today when the rail does not carry it", async () => {
+ const prisma = getPrismaClient();
+ const user = await seedUser("today-norail");
+ await seedDay(user.id);
+ const full = await prisma.user.findUniqueOrThrow({
+ where: { id: user.id },
+ });
+
+ const digest = await loadDailyDigest(full, NOW, {
+ locale: "en",
+ enabledItemKinds: PRIORITY_ITEM_KINDS.filter(
+ (kind) => kind !== "upcoming_visit",
+ ),
+ });
+ const appointment = digest.today.find((f) => f.kind === "appointment");
+ expect(appointment?.value).toBe("Tomorrow 09:30 AM, Routine visit");
+ });
+
+ it("says nothing about switched-off modules", async () => {
+ const prisma = getPrismaClient();
+ const user = await seedUser("today-modules-off", {
+ modulePreferencesJson: { illness: false },
+ });
+ // The cycle module follows the cycle profile's own switch, not the
+ // module preference blob.
+ await seedDay(user.id, false);
+ const full = await prisma.user.findUniqueOrThrow({
+ where: { id: user.id },
+ });
+
+ const digest = await loadDailyDigest(full, NOW, { locale: "en" });
+ expect(digest.restMode).toBeNull();
+ expect(digest.today.some((f) => f.kind === "rest_mode")).toBe(false);
+ expect(digest.today.some((f) => f.kind === "cycle")).toBe(false);
+ expect(
+ dailyDigestResponse.safeParse(JSON.parse(JSON.stringify(digest))).success,
+ ).toBe(true);
+ });
+
+ it("stays valid and empty for an account with nothing recorded", async () => {
+ const prisma = getPrismaClient();
+ const user = await seedUser("today-empty");
+ const full = await prisma.user.findUniqueOrThrow({
+ where: { id: user.id },
+ });
+
+ const digest = await loadDailyDigest(full, NOW, { locale: "en" });
+ expect(digest.lead).toBeNull();
+ expect(digest.today).toEqual([]);
+ expect(digest.restMode).toBeNull();
+ expect(
+ dailyDigestResponse.safeParse(JSON.parse(JSON.stringify(digest))).success,
+ ).toBe(true);
+ });
+});
From a91e1bf0a7226d8bbe85bc62125e3bc7b6901ac4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 4 Oct 2026 20:02:48 +0200
Subject: [PATCH 04/33] Give each Coach answer one action row and keep its
chips in its column
Under an answer: copy, read aloud, try again and the time as text on the
left; tokens and model on the right, on one line down to 390 px. The
thumbs leave the web (the feedback route stays for the native client),
and so does the "What I'm looking at" disclosure: the method line and
the tables the answer read move into the steps list, and an older
message without steps lists the areas it drew on, never a raw key.
Remember becomes a bookmark icon in the question's row and confirms in
place with a toast. Follow-up chips render inside the latest answer's
column at its text size. The answer column takes the full width below
`sm` and a fixed 80 % above, so nothing moves sideways as a reply
streams. A round button fades in when the thread is scrolled away from
the end and brings it back.
---
e2e/a11y.spec.ts | 17 +-
e2e/coach-dialog.spec.ts | 7 +-
e2e/coach-results.spec.ts | 14 +-
messages/de.json | 16 +-
messages/en.json | 16 +-
messages/es.json | 16 +-
messages/fr.json | 16 +-
messages/it.json | 16 +-
messages/ko.json | 16 +-
messages/pl.json | 16 +-
.../success-affordance-guard.test.ts | 9 +-
.../__tests__/message-actions.test.tsx | 292 ++++++++
.../__tests__/message-thread.test.tsx | 164 ++---
.../scroll-to-bottom-button.test.tsx | 64 ++
.../__tests__/source-chips.test.tsx | 120 ----
.../coach-panel/__tests__/turn-steps.test.tsx | 85 +++
.../insights/coach-panel/chat-bubble.tsx | 646 +++---------------
.../insights/coach-panel/follow-up-chips.tsx | 11 +-
.../insights/coach-panel/message-actions.tsx | 356 ++++++++++
.../insights/coach-panel/message-thread.tsx | 355 +++++-----
.../coach-panel/message-token-footer.tsx | 37 -
.../insights/coach-panel/method-line.tsx | 7 +-
.../insights/coach-panel/read-aloud.tsx | 4 +-
.../coach-panel/scroll-to-bottom-button.tsx | 54 ++
.../insights/coach-panel/source-chips.tsx | 110 ---
.../insights/coach-panel/turn-steps.tsx | 148 +++-
26 files changed, 1469 insertions(+), 1143 deletions(-)
create mode 100644 src/components/insights/coach-panel/__tests__/message-actions.test.tsx
create mode 100644 src/components/insights/coach-panel/__tests__/scroll-to-bottom-button.test.tsx
delete mode 100644 src/components/insights/coach-panel/__tests__/source-chips.test.tsx
create mode 100644 src/components/insights/coach-panel/message-actions.tsx
delete mode 100644 src/components/insights/coach-panel/message-token-footer.tsx
create mode 100644 src/components/insights/coach-panel/scroll-to-bottom-button.tsx
delete mode 100644 src/components/insights/coach-panel/source-chips.tsx
diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts
index 80374f8ed..abc0c487d 100644
--- a/e2e/a11y.spec.ts
+++ b/e2e/a11y.spec.ts
@@ -1047,23 +1047,24 @@ test.describe("axe-core authenticated route and state matrix", () => {
);
});
- await test.step(`${theme} /coach open evidence disclosure and source chips`, async () => {
+ await test.step(`${theme} /coach open the areas an older answer looked at`, async () => {
await page.goto(`/coach?c=${A11Y_CONVERSATION_ID}`, {
waitUntil: "domcontentloaded",
});
- const evidence = page.locator('[data-slot="coach-evidence"]').first();
- await expect(evidence).toBeVisible({ timeout: 15_000 });
- await evidence.locator("summary").click();
- await expect(evidence).toHaveAttribute("open", "");
+ const steps = page.locator('[data-slot="coach-turn-steps"]').first();
+ await expect(steps).toBeVisible({ timeout: 15_000 });
+ const toggle = steps.locator('[data-slot="coach-turn-steps-toggle"]');
+ await toggle.click();
+ await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(
- evidence.locator('[data-slot="coach-source-chips"]'),
+ steps.locator('[data-slot="coach-turn-areas"]'),
).toBeVisible();
blocking.push(
...(await scanPaintedState(
page,
theme,
- "/coach open evidence disclosure and source chips",
- evidence,
+ "/coach open the areas an older answer looked at",
+ steps,
)),
);
});
diff --git a/e2e/coach-dialog.spec.ts b/e2e/coach-dialog.spec.ts
index 513d778e5..a5dfc1478 100644
--- a/e2e/coach-dialog.spec.ts
+++ b/e2e/coach-dialog.spec.ts
@@ -550,10 +550,11 @@ test.describe("Coach dialog", () => {
await expect(figure).toBeVisible();
await expect(table).toBeHidden();
- // The method line sits in the evidence disclosure.
- await firstBubble.locator('[data-slot="coach-evidence-summary"]').click();
+ // The method line closes the open steps list.
await expect(
- firstBubble.locator('[data-slot="coach-method-line"]'),
+ steps.locator(
+ '[data-slot="coach-turn-steps-panel"] [data-slot="coach-method-line"]',
+ ),
).toContainText(TURN_ONE_METHOD.text);
// 3. A follow-up chip sends which chip it was.
diff --git a/e2e/coach-results.spec.ts b/e2e/coach-results.spec.ts
index a0f4d67af..ca211ec63 100644
--- a/e2e/coach-results.spec.ts
+++ b/e2e/coach-results.spec.ts
@@ -289,7 +289,7 @@ test.describe("Coach result tables and charts", () => {
});
for (const theme of ["light", "dark"] as const) {
- test(`theme=${theme}: chart first, table on toggle, data used in the evidence`, async ({
+ test(`theme=${theme}: chart first, table on toggle, data used in the steps list`, async ({
page,
}) => {
await useTheme(page, theme);
@@ -342,12 +342,18 @@ test.describe("Coach result tables and charts", () => {
await table.locator('[data-slot="coach-result-view-chart"]').click();
await expect(figure).toBeVisible();
- // The table the answer only used waits in the evidence disclosure.
+ // The table the answer only used waits in the open steps list.
await expect(
bubble.locator('[data-slot="coach-result-table"][data-ref="r2"]'),
).toBeHidden();
- await bubble.locator('[data-slot="coach-evidence-summary"]').click();
- const dataUsed = bubble.locator('[data-slot="coach-data-used"]');
+ const stepsToggle = bubble.locator(
+ '[data-slot="coach-turn-steps-toggle"]',
+ );
+ await expect(stepsToggle).toHaveAttribute("aria-expanded", "false");
+ await stepsToggle.click();
+ const dataUsed = bubble.locator(
+ '[data-slot="coach-turn-steps-panel"] [data-slot="coach-data-used"]',
+ );
await expect(dataUsed).toContainText("Data used (1)");
const workouts = dataUsed.locator(
'[data-slot="coach-result-table"][data-ref="r2"]',
diff --git a/messages/de.json b/messages/de.json
index 4f69db510..973c8060c 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2877,7 +2877,6 @@
"sourcesFresh": "Aktuell",
"sourcesStale": "Veraltet",
"sourcesFooter": "Der Coach liest nur Daten, die du verbunden hast.",
- "evidenceLabel": "Worauf bezieht sich das?",
"metric": {
"bp": "Blutdruck",
"weight": "Gewicht",
@@ -2956,10 +2955,6 @@
"settingsSave": "Speichern",
"settingsSaved": "Gespeichert",
"settingsCancel": "Abbrechen",
- "feedbackHelpful": "Hilfreich",
- "feedbackUnhelpful": "Nicht ganz",
- "feedbackThanks": "Danke für das Signal.",
- "feedbackError": "Feedback konnte nicht gespeichert werden",
"dailyLimitTitle": "Heute-Limit erreicht",
"dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.",
"providerRateLimitTitle": "Provider rate-limited",
@@ -3113,7 +3108,16 @@
"errorNotPermitted": "Der Coach ist in einer Akte, die jemand mit dir teilt, nicht verfügbar.",
"errorUnavailable": "Der Coach konnte seine Einstellungen gerade nicht prüfen. Bitte versuch es gleich noch einmal.",
"errorOperatorOff": "KI ist auf diesem Server abgeschaltet, deshalb kann der Coach nicht antworten.",
- "errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten."
+ "errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten.",
+ "answer": {
+ "scrollToBottom": "Zur neuesten Nachricht",
+ "rememberDone": "Gemerkt",
+ "timeYesterday": "gestern {time}",
+ "timeEarlier": "{date}, {time}",
+ "areasDoneOne": "1 Bereich angesehen",
+ "areasDoneFew": "{count} Bereiche angesehen",
+ "areasDoneOther": "{count} Bereiche angesehen"
+ }
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
diff --git a/messages/en.json b/messages/en.json
index 92d60007b..430f4a3f8 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2877,7 +2877,6 @@
"sourcesFresh": "Fresh",
"sourcesStale": "Stale",
"sourcesFooter": "The Coach reads only the data you've connected.",
- "evidenceLabel": "What I'm looking at",
"metric": {
"bp": "Blood pressure",
"weight": "Weight",
@@ -2956,10 +2955,6 @@
"settingsSave": "Save",
"settingsSaved": "Saved",
"settingsCancel": "Cancel",
- "feedbackHelpful": "Helpful",
- "feedbackUnhelpful": "Not quite",
- "feedbackThanks": "Thanks for the signal.",
- "feedbackError": "Could not save feedback",
"dailyLimitTitle": "Daily limit reached",
"dailyLimitBody": "Daily limit reached. The budget refreshes at midnight UTC.",
"providerRateLimitTitle": "Provider rate-limited",
@@ -3113,7 +3108,16 @@
"errorNotPermitted": "The Coach is not available inside a record someone shared with you.",
"errorUnavailable": "The Coach could not check its settings just now. Please try again in a moment.",
"errorOperatorOff": "AI is switched off on this server, so the Coach cannot answer.",
- "errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules."
+ "errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules.",
+ "answer": {
+ "scrollToBottom": "Jump to the latest message",
+ "rememberDone": "Remembered",
+ "timeYesterday": "yesterday {time}",
+ "timeEarlier": "{date}, {time}",
+ "areasDoneOne": "Looked at 1 area",
+ "areasDoneFew": "Looked at {count} areas",
+ "areasDoneOther": "Looked at {count} areas"
+ }
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
diff --git a/messages/es.json b/messages/es.json
index 7fc4efbfb..6b91771c7 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2877,7 +2877,6 @@
"sourcesFresh": "Actuales",
"sourcesStale": "Desactualizados",
"sourcesFooter": "El coach solo lee los datos que has conectado.",
- "evidenceLabel": "¿En qué se basa esto?",
"metric": {
"bp": "Presión arterial",
"weight": "Peso",
@@ -2956,10 +2955,6 @@
"settingsSave": "Guardar",
"settingsSaved": "Guardado",
"settingsCancel": "Cancelar",
- "feedbackHelpful": "Útil",
- "feedbackUnhelpful": "Poco útil",
- "feedbackThanks": "Gracias por la indicación.",
- "feedbackError": "No se pudo guardar el comentario",
"dailyLimitTitle": "Límite diario alcanzado",
"dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche (UTC).",
"providerRateLimitTitle": "Proveedor con límite de tasa",
@@ -3113,7 +3108,16 @@
"errorNotPermitted": "El Coach no está disponible dentro de un registro que alguien comparte contigo.",
"errorUnavailable": "El Coach no pudo comprobar su configuración ahora. Inténtalo de nuevo en un momento.",
"errorOperatorOff": "La IA está desactivada en este servidor, así que el Coach no puede responder.",
- "errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos."
+ "errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos.",
+ "answer": {
+ "scrollToBottom": "Ir al último mensaje",
+ "rememberDone": "Guardado",
+ "timeYesterday": "ayer {time}",
+ "timeEarlier": "{date}, {time}",
+ "areasDoneOne": "1 área consultada",
+ "areasDoneFew": "{count} áreas consultadas",
+ "areasDoneOther": "{count} áreas consultadas"
+ }
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
diff --git a/messages/fr.json b/messages/fr.json
index 989e0ab17..b8ab7130a 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2877,7 +2877,6 @@
"sourcesFresh": "À jour",
"sourcesStale": "Obsolètes",
"sourcesFooter": "Le coach ne lit que les données que tu as connectées.",
- "evidenceLabel": "Sur quoi est-ce fondé ?",
"metric": {
"bp": "Tension artérielle",
"weight": "Poids",
@@ -2956,10 +2955,6 @@
"settingsSave": "Enregistrer",
"settingsSaved": "Enregistré",
"settingsCancel": "Annuler",
- "feedbackHelpful": "Utile",
- "feedbackUnhelpful": "Peu utile",
- "feedbackThanks": "Merci pour l’indication.",
- "feedbackError": "Impossible d'enregistrer le retour",
"dailyLimitTitle": "Limite quotidienne atteinte",
"dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit (UTC).",
"providerRateLimitTitle": "Fournisseur limité en débit",
@@ -3113,7 +3108,16 @@
"errorNotPermitted": "Le Coach n'est pas disponible dans un dossier que quelqu'un partage avec vous.",
"errorUnavailable": "Le Coach n'a pas pu vérifier ses réglages pour l'instant. Réessayez dans un instant.",
"errorOperatorOff": "L'IA est désactivée sur ce serveur, le Coach ne peut donc pas répondre.",
- "errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules."
+ "errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules.",
+ "answer": {
+ "scrollToBottom": "Aller au dernier message",
+ "rememberDone": "Mémorisé",
+ "timeYesterday": "hier {time}",
+ "timeEarlier": "{date}, {time}",
+ "areasDoneOne": "1 domaine consulté",
+ "areasDoneFew": "{count} domaines consultés",
+ "areasDoneOther": "{count} domaines consultés"
+ }
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
diff --git a/messages/it.json b/messages/it.json
index b8d636332..cb4d3d037 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2877,7 +2877,6 @@
"sourcesFresh": "Aggiornate",
"sourcesStale": "Datate",
"sourcesFooter": "Il coach legge solo i dati che hai collegato.",
- "evidenceLabel": "Su cosa si basa?",
"metric": {
"bp": "Pressione arteriosa",
"weight": "Peso",
@@ -2956,10 +2955,6 @@
"settingsSave": "Salva",
"settingsSaved": "Salvato",
"settingsCancel": "Annulla",
- "feedbackHelpful": "Utile",
- "feedbackUnhelpful": "Poco utile",
- "feedbackThanks": "Grazie per il segnale.",
- "feedbackError": "Impossibile salvare il feedback",
"dailyLimitTitle": "Limite giornaliero raggiunto",
"dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte (UTC).",
"providerRateLimitTitle": "Provider con rate-limit",
@@ -3113,7 +3108,16 @@
"errorNotPermitted": "Il Coach non è disponibile in una cartella che qualcuno condivide con te.",
"errorUnavailable": "Il Coach non è riuscito a controllare le sue impostazioni. Riprova tra un momento.",
"errorOperatorOff": "L'IA è disattivata su questo server, quindi il Coach non può rispondere.",
- "errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli."
+ "errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli.",
+ "answer": {
+ "scrollToBottom": "Vai all'ultimo messaggio",
+ "rememberDone": "Memorizzato",
+ "timeYesterday": "ieri {time}",
+ "timeEarlier": "{date}, {time}",
+ "areasDoneOne": "1 area consultata",
+ "areasDoneFew": "{count} aree consultate",
+ "areasDoneOther": "{count} aree consultate"
+ }
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
diff --git a/messages/ko.json b/messages/ko.json
index 5dc5fadf4..40065e832 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -2877,7 +2877,6 @@
"sourcesFresh": "최신",
"sourcesStale": "오래됨",
"sourcesFooter": "코치는 연결한 데이터만 읽어요.",
- "evidenceLabel": "내가 보고 있는 것",
"metric": {
"bp": "혈압",
"weight": "체중",
@@ -2956,10 +2955,6 @@
"settingsSave": "저장",
"settingsSaved": "저장됨",
"settingsCancel": "취소",
- "feedbackHelpful": "도움됨",
- "feedbackUnhelpful": "아쉬움",
- "feedbackThanks": "알려줘서 고마워요.",
- "feedbackError": "피드백을 저장하지 못했어요",
"dailyLimitTitle": "하루 한도 도달",
"dailyLimitBody": "하루 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.",
"providerRateLimitTitle": "제공자 요청 제한",
@@ -3113,7 +3108,16 @@
"errorNotPermitted": "다른 사람이 공유한 기록 안에서는 코치를 사용할 수 없습니다.",
"errorUnavailable": "코치가 지금 설정을 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.",
"errorOperatorOff": "이 서버에서 AI가 꺼져 있어 코치가 답변할 수 없습니다.",
- "errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다."
+ "errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다.",
+ "answer": {
+ "scrollToBottom": "최신 메시지로 이동",
+ "rememberDone": "기억함",
+ "timeYesterday": "어제 {time}",
+ "timeEarlier": "{date} {time}",
+ "areasDoneOne": "영역 1개 확인",
+ "areasDoneFew": "영역 {count}개 확인",
+ "areasDoneOther": "영역 {count}개 확인"
+ }
},
"relativeJustNow": "방금",
"relativeMinutesAgoOne": "{count}분 전",
diff --git a/messages/pl.json b/messages/pl.json
index b967ecdc4..6db3c1cc1 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2877,7 +2877,6 @@
"sourcesFresh": "Aktualne",
"sourcesStale": "Nieaktualne",
"sourcesFooter": "Coach czyta tylko dane, które podłączyłeś.",
- "evidenceLabel": "Na czym to się opiera?",
"metric": {
"bp": "Ciśnienie krwi",
"weight": "Waga",
@@ -2956,10 +2955,6 @@
"settingsSave": "Zapisz",
"settingsSaved": "Zapisano",
"settingsCancel": "Anuluj",
- "feedbackHelpful": "Przydatne",
- "feedbackUnhelpful": "Mało przydatne",
- "feedbackThanks": "Dzięki za sygnał.",
- "feedbackError": "Nie udało się zapisać opinii",
"dailyLimitTitle": "Osiągnięto dzienny limit",
"dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy (UTC).",
"providerRateLimitTitle": "Dostawca z ograniczeniem",
@@ -3113,7 +3108,16 @@
"errorNotPermitted": "Coach nie jest dostępny w kartotece, którą ktoś Ci udostępnił.",
"errorUnavailable": "Coach nie mógł teraz sprawdzić swoich ustawień. Spróbuj ponownie za chwilę.",
"errorOperatorOff": "AI jest wyłączone na tym serwerze, więc Coach nie może odpowiedzieć.",
- "errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły."
+ "errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły.",
+ "answer": {
+ "scrollToBottom": "Przejdź do najnowszej wiadomości",
+ "rememberDone": "Zapamiętano",
+ "timeYesterday": "wczoraj {time}",
+ "timeEarlier": "{date}, {time}",
+ "areasDoneOne": "Sprawdzono 1 obszar",
+ "areasDoneFew": "Sprawdzono {count} obszary",
+ "areasDoneOther": "Sprawdzono {count} obszarów"
+ }
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts
index 927aaf7b6..bd9e68c89 100644
--- a/src/__tests__/success-affordance-guard.test.ts
+++ b/src/__tests__/success-affordance-guard.test.ts
@@ -173,7 +173,14 @@ const PINNED_AFFORDANCES: Record<
CheckCircle2: 2,
"toast.success": 1,
},
- "src/components/insights/coach-panel/chat-bubble.tsx": { "text-success": 4 },
+ // The Coach message action row: the copy tick after the clipboard write
+ // resolved, and the remember icon and toast only for the self-context entry
+ // the tap wrote (`adopted`); an entry that was already there gets a neutral
+ // icon and `toast.info`.
+ "src/components/insights/coach-panel/message-actions.tsx": {
+ "text-success": 2,
+ "toast.success": 1,
+ },
// The copy tick on a result table: shown only after the clipboard write
// resolved, so it reports a copy that happened; nothing is written to the
// record.
diff --git a/src/components/insights/coach-panel/__tests__/message-actions.test.tsx b/src/components/insights/coach-panel/__tests__/message-actions.test.tsx
new file mode 100644
index 000000000..0d53a6e70
--- /dev/null
+++ b/src/components/insights/coach-panel/__tests__/message-actions.test.tsx
@@ -0,0 +1,292 @@
+/**
+ * The one action row under a Coach message, and the follow-up chips that
+ * live in the answer's column.
+ *
+ * - Assistant: copy, read aloud, try again, then the time, in one left
+ * group; the tokens and model in the right group of the same row. No
+ * feedback thumbs, no evidence disclosure.
+ * - User: copy, remember, time in one row; remember is an icon.
+ * - The time reads "14:32" today, "yesterday 14:32", or the date further
+ * back.
+ * - The chips of the latest answer render inside its column, after the
+ * action row, at the answer's text size; the memo comparator sees a new
+ * offer.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { getServerTranslator } from "@/lib/i18n/server-translator";
+import type { CoachFollowUp } from "@/lib/ai/coach/types";
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({ user: null, isAuthenticated: true, isLoading: false }),
+}));
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({ data: undefined }),
+ useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }),
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+}));
+vi.mock("@/hooks/use-coach-message-results", () => ({
+ useCoachMessageResults: () => ({
+ ref: () => {},
+ results: undefined,
+ isLoading: false,
+ isError: false,
+ refetch: () => {},
+ }),
+}));
+// SSR resolves the clipboard and speech capabilities to "absent"; claim
+// both so the full row renders and its order can be read.
+vi.mock("../read-aloud", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useClipboardSupported: () => true,
+ ReadAloudButton: () => (
+
+ ),
+ };
+});
+
+import { ChatBubble, areChatBubblePropsEqual } from "../chat-bubble";
+import { answerMetaText, messageTimeText } from "../message-actions";
+
+function render(node: React.ReactNode, locale: "en" | "de" = "en") {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+/** The markup of the element carrying `slot`, up to its matching close. */
+function slot(html: string, name: string): string {
+ const start = html.indexOf(`data-slot="${name}"`);
+ if (start < 0) return "";
+ const open = html.lastIndexOf("<", start);
+ const tag = html.slice(open + 1).match(/^[a-z]+/)![0];
+ let depth = 0;
+ const re = new RegExp(`<${tag}[\\s>]|${tag}>`, "g");
+ re.lastIndex = open;
+ for (let m = re.exec(html); m; m = re.exec(html)) {
+ depth += m[0].startsWith("") ? -1 : 1;
+ if (depth === 0) return html.slice(open, m.index + m[0].length);
+ }
+ return html.slice(open);
+}
+
+const CHIPS: CoachFollowUp[] = [
+ {
+ id: "f1",
+ kind: "previous_period",
+ labelKey: "coach.followUp.previousPeriod",
+ label: "Compare with the period before",
+ reuse: false,
+ origin: "server",
+ },
+];
+
+const ANSWER = {
+ role: "assistant" as const,
+ content: "Your blood pressure held steady.",
+ metricSource: null,
+ providerType: "openai",
+ messageId: "m2",
+ createdAt: new Date().toISOString(),
+ tokensUsed: 1234,
+ model: "gpt-4o-mini",
+ onRegenerate: () => {},
+};
+
+describe("assistant action row", () => {
+ const html = render();
+ const row = slot(html, "coach-answer-actions");
+ const left = slot(row, "coach-answer-actions-left");
+
+ it("lists copy, read aloud, try again and the time on the left, in that order", () => {
+ const order = [
+ "coach-copy-message",
+ "coach-read-aloud",
+ "coach-try-again",
+ "coach-message-time",
+ ].map((name) => left.indexOf(`data-slot="${name}"`));
+ expect(order.every((at) => at > -1)).toBe(true);
+ expect([...order].sort((a, b) => a - b)).toEqual(order);
+ });
+
+ it("puts the tokens and the model on the right of the same row", () => {
+ const meta = slot(row, "coach-answer-meta");
+ expect(meta).toContain("1,234 tokens · gpt-4o-mini");
+ expect(meta).toContain('title="gpt-4o-mini"');
+ expect(meta).toContain("truncate");
+ expect(left).not.toContain("coach-answer-meta");
+ expect(row).toContain("justify-between");
+ });
+
+ it("has no thumbs, no evidence disclosure and no separate token line", () => {
+ expect(html).not.toContain("coach-message-feedback");
+ expect(html).not.toContain("coach-evidence");
+ expect(html).not.toContain("coach-token-footer");
+ });
+
+ it("shows the time as text, the full date for screen readers", () => {
+ const time = slot(left, "coach-message-time");
+ expect(time).toMatch(/^
- {/* A reply with tables takes the column's full width, so a table or
- chart is as wide as the thread allows instead of as wide as the
- prose above it. */}
+ {/* The answer column. Below `sm` it takes the full width beside the
+ avatar (at 80 % the action row could not stay on one line at
+ 390 px); from `sm` it keeps the 80 % measure. It is always that
+ wide, whatever the answer's length, so the action row and the
+ chips never move sideways as a reply streams in; the prose bubble
+ inside shrink-wraps. A table or chart takes the column's width. */}
- {/* v1.39.4 — what the Coach read on this turn: live from the step
- frames, restored from the provenance on reload. */}
+ {/* What the Coach read on this turn, live from the step frames and
+ restored from the provenance on reload; an older message lists
+ the areas it drew on. Opened, it also says how the answer was
+ worked out and holds the tables the answer read. */}
0 ? (
+
+ ) : null
+ }
/>
{/* v1.19.1 (C3) — the live turn shows the classic typing animation
(three pulsing dots) while it is still thinking with no prose
@@ -760,8 +628,9 @@ function ChatBubbleImpl({
it swaps to the streamed prose without a layout jump. */}
{(content || safeError || inProgress) && (
)}
- {/* v1.12.0 — collapse the whole provenance block ("what was
- included") behind one disclosure, collapsed by default. The
- source chips used to render fully expanded above the
- evidence ``, so the grounding context was often
- taller than the answer itself. Folding the chips + the raw
- key-values into a single closed disclosure keeps the reply
- the focus and lets the user expand the grounding on demand —
- and removes the always-on chip row that duplicated what the
- disclosure already names. The disclosure surfaces whenever
- there is any provenance to show (chips and/or key-values). */}
- {hasProvenance && (
-
- setEvidenceOpen((e.target as HTMLDetailsElement).open)
- }
- // v1.4.27 F14 — always closed by default. The `open`
- // attribute was previously tied to a per-user pref that
- // surfaced raw values unconditionally; that pref is now
- // retired and the disclosure is a true progressive-
- // disclosure surface — the user clicks to expand.
- //
- // v1.4.27 MB3 / CF-32 — the `open` attribute is now
- // controlled from local state so the summary's
- // `aria-expanded` stays in lock-step. The native disclosure
- // semantics (Enter / Space toggle) are preserved.
- className={cn(
- "border-border/50 bg-muted/30 group rounded-md border",
- "px-2.5 py-1.5 text-xs",
- )}
- >
-
-
- {t("insights.coach.evidenceLabel")}
-
-
- {/* v1.12.0 — the source chips now live inside the
- disclosure so they expand with the rest of the
- grounding instead of always painting above the
- answer. */}
- {hasChips && metricSource && (
-
- )}
- {keyValues.length > 0 && (
-
- {keyValues.map((kv, idx) => (
-
- {/* v1.4.25 W5 — `kv.label` (e.g. "avg7 systolic")
- was rendered prefixed to every row, repeating
- framing the disclosure heading already gives.
- Drop the label and lead with the value; the
- window stays as a parenthetical tail so the row
- still answers "over what timeframe?". */}
-
- {kv.value}
- {kv.unit ? ` ${kv.unit}` : ""}
-
- {kv.window && (
-
- {" "}
- ({kv.window})
-
- )}
-
- ))}
-
- )}
- {/* v1.39.4 — how the answer was worked out, beside the key
- values it explains. */}
-
- {/* v1.39.4 — the tables the answer read but did not point at. */}
- {dataUsedCount > 0 && (
-
- )}
-
-
- )}
{/* v1.18.1 (Workstream C) — one-tap cadence-suggestion action
card. Live from the streaming hook, or restored from the
persisted message provenance on reload. Not shown on
@@ -978,42 +739,29 @@ function ChatBubbleImpl({
suggestedAction ?? metricSource?.suggestedAction ?? null;
return action ? : null;
})()}
- {/* v1.18.9 — quiet per-message token footer. The just-finished
- streaming turn reads the `done.usage` envelope; a persisted /
- reloaded turn reads the message's own `tokensUsed` + `model`.
- Skipped on in-flight, errored, and refusal turns. */}
- {!inProgress && !errorCode && providerType !== "refusal" && (
-
)}
- {/* v1.22 — per-message hover action row. Icon-only, muted until the
- bubble is hovered / focused on pointer devices; always visible on
- touch (no hover to reveal it). Copy + Read-aloud + Good / Bad
- feedback + Try-again, with the timestamp trailing. Only settled
- persisted assistant turns get the row (skipped for refusals,
- errors, in-flight stream bubbles). */}
- {!inProgress && !errorCode && providerType !== "refusal" && content && (
-
- )}
+ {/* The follow-up chips of the latest answer, in its own column. */}
+ {followUps && onFollowUp ? (
+
+ ) : null}
);
@@ -1028,107 +776,6 @@ function ChatBubbleImpl({
export const ChatBubble = memo(ChatBubbleImpl, areChatBubblePropsEqual);
ChatBubble.displayName = "ChatBubble";
-/**
- * v1.16.8 — per-message remember control under the user bubble.
- *
- * One tap stores the message text into the structured self-context via
- * `POST /api/coach/about-me/adopt` (no `question` — the server matches
- * the target field from the text itself: an allergy statement lands on
- * the allergies field, a stated condition on conditions, everything
- * else on the coach-focus slot). The stored text is visible and
- * editable under Settings → AI and rides every future Coach system
- * prompt. Settled states mirror `SelfContextAdoptOffer`: a short
- * confirmation replaces the button after an adoption or a server-side
- * dedupe; a failure surfaces a toast and the button stays tappable.
- */
-function RememberUserMessage({ content }: { content: string }) {
- const { t } = useTranslations();
- const queryClient = useQueryClient();
- const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
- // On settle the button unmounts while it holds focus, which would
- // drop keyboard focus to . The confirmation paragraph takes
- // the focus instead (`tabIndex={-1}` + programmatic focus) so the
- // reading position survives the swap.
- const statusRef = useRef(null);
- useEffect(() => {
- if (settled) statusRef.current?.focus();
- }, [settled]);
-
- const remember = useMutation({
- mutationFn: async () => {
- return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", {
- answer: content,
- });
- },
- onSuccess: (data) => {
- if (data.adopted) {
- // The adoption changed the stored self-context — refresh the
- // Settings → AI about-me read so it shows the new entry.
- void queryClient.invalidateQueries({
- queryKey: queryKeys.coachAboutMe(),
- });
- }
- setSettled(data.adopted ? "adopted" : "duplicate");
- },
- onError: () => {
- toast.error(t("insights.coach.rememberMessage.failed"));
- },
- });
-
- if (settled) {
- return (
-
- );
-}
diff --git a/src/components/insights/coach-panel/follow-up-chips.tsx b/src/components/insights/coach-panel/follow-up-chips.tsx
index 525319dcc..64e5561c6 100644
--- a/src/components/insights/coach-panel/follow-up-chips.tsx
+++ b/src/components/insights/coach-panel/follow-up-chips.tsx
@@ -2,7 +2,9 @@
/**
* v1.39.4 — up to three follow-up chips under the latest assistant reply.
- * A tap sends the chip's label as the message with
+ * They sit inside the answer's own column, on the bubble's left edge and at
+ * the answer's text size, so they read as part of the reply rather than as
+ * thread chrome. A tap sends the chip's label as the message with
* `followUp: { messageId, id }`; the server resolves what the chip asks for.
*
* The label is the server's rendering in the request locale. It is not
@@ -46,7 +48,7 @@ export function CoachFollowUpChips({
aria-label={t(COACH_FOLLOW_UP_UI_KEYS.groupLabel)}
data-slot="coach-follow-up-chips"
data-message-id={messageId}
- className="flex flex-wrap gap-2"
+ className="flex max-w-full flex-wrap gap-2"
>
{chips.map((chip) => (
{
diff --git a/src/components/insights/coach-panel/message-actions.tsx b/src/components/insights/coach-panel/message-actions.tsx
new file mode 100644
index 000000000..f88e4dc73
--- /dev/null
+++ b/src/components/insights/coach-panel/message-actions.tsx
@@ -0,0 +1,356 @@
+"use client";
+
+/**
+ * The one action row under a Coach message, for both roles.
+ *
+ * Assistant: a single line. Left, the icon actions in reading order (copy,
+ * read aloud, try again) followed by the time the answer was written; right,
+ * the tokens and model the answer cost, as meta. The right group truncates
+ * before the row would wrap, so the row is one line at 390 px too.
+ *
+ * User: copy, remember, time, flush with the bubble's right edge.
+ *
+ * On pointer devices the row stays quiet until its message is hovered or
+ * holds focus; on touch it is always visible (there is nothing to hover).
+ * `opacity-0` rather than `invisible` keeps every control focusable, and
+ * focus reveals the row.
+ */
+import { useCallback, useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import {
+ BookmarkCheck,
+ BookmarkPlus,
+ Check,
+ Copy,
+ Loader2,
+ RotateCcw,
+} from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
+import { resolveIntlLocale } from "@/lib/format-locale";
+import type { Formatters } from "@/lib/format-locale";
+import { apiPost } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
+import { stripChartTokens } from "@/lib/insights/chart-tokens";
+import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
+
+import {
+ COACH_ICON_BUTTON,
+ ReadAloudButton,
+ useClipboardSupported,
+} from "./read-aloud";
+
+type Translate = (
+ key: string,
+ params?: Record,
+) => string;
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/** The quiet-until-hover reveal, scoped to the message's own group. */
+const REVEAL = {
+ assistant: cn(
+ "sm:[@media(hover:hover)]:opacity-0",
+ "sm:[@media(hover:hover)]:group-hover/assistant-bubble:opacity-100",
+ "sm:[@media(hover:hover)]:group-focus-within/assistant-bubble:opacity-100",
+ ),
+ user: cn(
+ "sm:[@media(hover:hover)]:opacity-0",
+ "sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100",
+ "sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100",
+ ),
+} as const;
+
+/**
+ * The visible time of a message: the clock alone today, "yesterday 14:32"
+ * the day before, and the short date with the clock further back. Days are
+ * compared in the reader's display zone (the formatters carry it). Pure, so
+ * the three tiers are pinned by a unit test.
+ */
+export function messageTimeText(
+ iso: string,
+ now: number,
+ formatters: Pick,
+ t: Translate,
+): string {
+ const day = formatters.dateShortSmart(iso);
+ const time = formatters.time(iso);
+ if (day === formatters.dateShortSmart(new Date(now))) return time;
+ if (day === formatters.dateShortSmart(new Date(now - DAY_MS))) {
+ return t("insights.coach.answer.timeYesterday", { time });
+ }
+ return t("insights.coach.answer.timeEarlier", { date: day, time });
+}
+
+/**
+ * The answer's cost as one meta string: tokens with the model when both are
+ * known, tokens alone otherwise, nothing without a count. The count is the
+ * server's (`done.usage` live, `tokensUsed` on reload), formatted for the
+ * reader's locale.
+ */
+export function answerMetaText(
+ tokens: number | null | undefined,
+ model: string | null | undefined,
+ locale: string,
+ t: Translate,
+): string | null {
+ if (tokens == null) return null;
+ let count: string;
+ try {
+ count = new Intl.NumberFormat(locale).format(tokens);
+ } catch {
+ count = String(tokens);
+ }
+ return model
+ ? t("insights.coach.tokensUsedWithModel", { count, model })
+ : t("insights.coach.tokensUsed", { count });
+}
+
+function CopyMessageButton({
+ content,
+ strip,
+}: {
+ content: string;
+ strip: boolean;
+}) {
+ const { t } = useTranslations();
+ const supported = useClipboardSupported();
+ const [copied, setCopied] = useState(false);
+ const handle = useCallback(async () => {
+ // Assistant prose is copied as the bubble shows it, without chart
+ // tokens; user text is verbatim.
+ const text = strip ? stripChartTokens(content) : content;
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1500);
+ } catch {
+ toast.error(t("insights.coach.copyMessageError"));
+ }
+ }, [content, strip, t]);
+ // Absent where the Clipboard API is (plain-HTTP self-hosts) rather than
+ // error-toasting on tap.
+ if (!supported) return null;
+ const label = t("insights.coach.copyMessage");
+ return (
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+/** Re-runs the question that produced this answer. */
+function TryAgainButton({ onRegenerate }: { onRegenerate: () => void }) {
+ const { t } = useTranslations();
+ const label = t("insights.coach.regenerate");
+ return (
+
+
+
+ );
+}
+
+/**
+ * When the message was written, as text. The visible run is short ("14:32");
+ * the full date and time ride the `title` and the screen-reader text.
+ */
+function MessageTime({ iso }: { iso: string }) {
+ const { t } = useTranslations();
+ const formatters = useFormatters();
+ // "Today" is fixed when the row mounts; a thread left open past midnight
+ // keeps its labels until the next render from fresh data.
+ const [now] = useState(Date.now);
+ const full = formatters.dateTime(iso);
+ return (
+
+ );
+}
+
+/**
+ * Stores the user's message in the self-context (Settings → AI) on one tap,
+ * so it rides every future system prompt. The server picks the field from
+ * the text itself. The icon confirms in place and a toast says where it
+ * went; the button never unmounts, so keyboard focus stays on it.
+ */
+function RememberMessageButton({ content }: { content: string }) {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+ const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
+
+ const remember = useMutation({
+ mutationFn: async () =>
+ apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", {
+ answer: content,
+ }),
+ onSuccess: (data) => {
+ if (data.adopted) {
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.coachAboutMe(),
+ });
+ }
+ setSettled(data.adopted ? "adopted" : "duplicate");
+ // A new entry is a write (success); an existing one is an honest
+ // non-event (neutral), the same mapping `toastWrittenOutcome` uses.
+ // Called directly: importing the outcome module here regroups the
+ // shared chunks and costs ~16 KB gz across the client bundle.
+ (data.adopted ? toast.success : toast.info)(
+ t(
+ data.adopted
+ ? "insights.coach.rememberMessage.done"
+ : "insights.coach.rememberMessage.duplicate",
+ ),
+ );
+ },
+ onError: () => {
+ toast.error(t("insights.coach.rememberMessage.failed"));
+ },
+ });
+
+ const label = settled
+ ? t("insights.coach.answer.rememberDone")
+ : t("insights.coach.rememberMessage.action");
+ const inert = settled !== null || remember.isPending;
+ return (
+ {
+ if (!inert) remember.mutate();
+ }}
+ // aria-disabled, not disabled: a disabled button drops keyboard focus.
+ aria-disabled={inert || undefined}
+ aria-busy={remember.isPending || undefined}
+ aria-label={label}
+ title={label}
+ className={COACH_ICON_BUTTON}
+ >
+ {remember.isPending ? (
+
+ ) : settled ? (
+ // Green only for the entry this tap wrote; "already there" stays
+ // neutral.
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export interface AssistantMessageActionsProps {
+ content: string;
+ /** Read aloud and try again wait until the answer has settled. */
+ streaming: boolean;
+ createdAt?: string;
+ onRegenerate?: () => void;
+ tokens?: number | null;
+ model?: string | null;
+}
+
+export function AssistantMessageActions({
+ content,
+ streaming,
+ createdAt,
+ onRegenerate,
+ tokens,
+ model,
+}: AssistantMessageActionsProps) {
+ const { t, locale } = useTranslations();
+ const meta = answerMetaText(tokens, model, resolveIntlLocale(locale), t);
+ return (
+
+ );
+}
+
+export interface UserMessageActionsProps {
+ content: string;
+ /** Present once the message is persisted; an optimistic copy has none. */
+ messageId?: string;
+ createdAt?: string;
+}
+
+export function UserMessageActions({
+ content,
+ messageId,
+ createdAt,
+}: UserMessageActionsProps) {
+ // Only a persisted message that fits the self-context field can be kept.
+ const canRemember = !!messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS;
+ return (
+
+
+ {canRemember && }
+ {createdAt && }
+
+ );
+}
diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx
index e87246f5e..c80672533 100644
--- a/src/components/insights/coach-panel/message-thread.tsx
+++ b/src/components/insights/coach-panel/message-thread.tsx
@@ -1,6 +1,13 @@
"use client";
-import { Fragment, useEffect, useMemo, useRef, useState } from "react";
+import {
+ Fragment,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -8,9 +15,10 @@ import { scrollBehaviorForUser } from "@/lib/motion";
import { useTranslations } from "@/lib/i18n/context";
import { PlanProposalCards } from "./plan-proposal-card";
-import { ChatBubble } from "./chat-bubble";
-import { CoachFollowUpChips } from "./follow-up-chips";
+import { ChatBubble, type FollowUpOffer } from "./chat-bubble";
import { CoachMessageDatesProvider } from "./coach-results";
+import { ScrollToBottomButton } from "./scroll-to-bottom-button";
+import { focusCoachComposer } from "./composer-focus";
import {
EMPTY_LIVE_TURN_KEYS,
messageRenderKey,
@@ -50,6 +58,9 @@ export {
* list grows OR the streaming-content length changes. The user can
* scroll up to read history; we suppress auto-scroll until the next
* "new message" tick if they're not already pinned to the bottom.
+ * Away from the end, a round button fades in over the thread's foot and
+ * brings the reader back (`ScrollToBottomButton`); a reply streaming in
+ * meanwhile never pulls the view down by itself.
*
* Visual identity: user bubbles right-aligned, Dracula purple accent;
* assistant bubbles left-aligned with the gradient sparkle avatar
@@ -268,14 +279,10 @@ export function MessageThread({
const { t } = useTranslations();
const scrollerRef = useRef(null);
const wasPinnedRef = useRef(true);
-
- // v1.4.27 F14 — the evidence disclosure used to honour an opt-in
- // `coachPrefs.showEvidenceByDefault` flag that surfaced the raw
- // measurement values unconditionally. The flag created an UX trap
- // (the maintainer 2026-05-15: "literal metric values exposed under every
- // bubble") so the disclosure is now collapsed by default for every
- // reply. The user expands by click; the pref is retired in the
- // settings sheet so nothing flips it back on.
+ // The same pinned state as a render value, for the jump-to-latest button.
+ // The ref stays the source for the auto-scroll (it must not wait for a
+ // render); the state follows it at most once per frame.
+ const [pinned, setPinned] = useState(true);
const messages: CoachMessageDTO[] = useMemo(
() => conversation?.messages ?? [],
@@ -334,18 +341,63 @@ export function MessageThread({
optimisticActive && optimisticUser ? optimisticUser.content : null,
);
- const chips = latestFollowUps(messages, streaming);
+ // The chips go to the bubble of the message that offered them, so they
+ // sit in that answer's column. One object per offer, so the memoised
+ // bubble sees the same value until the offer itself changes.
+ const latest = latestFollowUps(messages, streaming);
+ const chipsMessageId = latest?.messageId ?? null;
+ const chipsArray = latest?.followUps ?? null;
+ const chips: FollowUpOffer | null = useMemo(
+ () =>
+ chipsMessageId && chipsArray
+ ? { messageId: chipsMessageId, followUps: chipsArray }
+ : null,
+ [chipsMessageId, chipsArray],
+ );
+ const chipsFor = (messageId: string | null | undefined) =>
+ onFollowUp && chips && messageId === chips.messageId
+ ? {
+ followUps: chips,
+ followUpsDisabled: !!streaming?.inProgress,
+ onFollowUp,
+ }
+ : {};
+
+ const hasThread =
+ messages.length > 0 ||
+ streamingActive ||
+ optimisticActive ||
+ interleavedItems.length > 0;
// Track scroll position so we don't yank the viewport when the user
- // is browsing earlier turns.
+ // is browsing earlier turns. Re-attached when the thread mounts (the
+ // empty state renders no scroller).
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
+ let frame = 0;
const onScroll = () => {
wasPinnedRef.current = isPinnedToBottom(el);
+ if (frame) return;
+ frame = window.requestAnimationFrame(() => {
+ frame = 0;
+ setPinned(isPinnedToBottom(el));
+ });
};
el.addEventListener("scroll", onScroll, { passive: true });
- return () => el.removeEventListener("scroll", onScroll);
+ return () => {
+ el.removeEventListener("scroll", onScroll);
+ if (frame) window.cancelAnimationFrame(frame);
+ };
+ }, [hasThread]);
+
+ const scrollToLatest = useCallback(() => {
+ const el = scrollerRef.current;
+ if (!el) return;
+ wasPinnedRef.current = true;
+ setPinned(true);
+ el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() });
+ focusCoachComposer();
}, []);
// Auto-scroll on new messages OR streaming-content growth, but only
@@ -389,12 +441,7 @@ export function MessageThread({
return () => vv.removeEventListener("resize", handleResize);
}, []);
- if (
- messages.length === 0 &&
- !streamingActive &&
- !optimisticActive &&
- interleavedItems.length === 0
- ) {
+ if (!hasThread) {
return (
+ {/* The positioning frame for the jump-to-latest button. It takes the
+ thread's place in the flex column; the scroller inside stays the
+ one scroll container. */}
*]:mx-auto [&>*]:w-full [&>*]:max-w-2xl",
- "scroll-smooth",
- // v1.18.7 — thin, rounded, subtle scrollbar (WebKit + Firefox),
- // component-scoped via Tailwind arbitrary variants so globals.css
- // stays untouched. Replaces the default boxy/angular track.
- COACH_SCROLLBAR,
- )}
+ data-slot="coach-message-thread-frame"
+ className="relative flex min-h-0 flex-1 flex-col"
>
- {[
- ...messages.map((m, idx) => {
- // v1.16.5 — a guided question renders directly above the user
- // message that answered it.
- const guidedBefore = placement.before.get(m.id);
- // v1.22 — "Try again": resolve the user message that produced this
- // assistant reply (the nearest preceding user turn) so the surface
- // can resubmit it. Null when there is none → no regenerate action.
- let precedingUserContent: string | null = null;
- if (m.role === "assistant" && onRegenerate) {
- for (let j = idx - 1; j >= 0; j--) {
- if (messages[j].role === "user") {
- precedingUserContent = messages[j].content;
- break;
+
*]:mx-auto [&>*]:w-full [&>*]:max-w-2xl",
+ "scroll-smooth",
+ // v1.18.7 — thin, rounded, subtle scrollbar (WebKit + Firefox),
+ // component-scoped via Tailwind arbitrary variants so globals.css
+ // stays untouched. Replaces the default boxy/angular track.
+ COACH_SCROLLBAR,
+ )}
+ >
+ {[
+ ...messages.map((m, idx) => {
+ // v1.16.5 — a guided question renders directly above the user
+ // message that answered it.
+ const guidedBefore = placement.before.get(m.id);
+ // v1.22 — "Try again": resolve the user message that produced this
+ // assistant reply (the nearest preceding user turn) so the surface
+ // can resubmit it. Null when there is none → no regenerate action.
+ let precedingUserContent: string | null = null;
+ if (m.role === "assistant" && onRegenerate) {
+ for (let j = idx - 1; j >= 0; j--) {
+ if (messages[j].role === "user") {
+ precedingUserContent = messages[j].content;
+ break;
+ }
}
}
- }
- const bubble = (
- 0
- ? { results: streaming.results }
- : {})}
- onRegenerate={
- precedingUserContent !== null && onRegenerate
- ? () => onRegenerate(precedingUserContent as string)
- : undefined
- }
- />
- );
- const key = messageRenderKey(liveKeys, m.id);
- return (
-
- {guidedBefore?.node}
- {m.role === "assistant" ? (
-
- {bubble}
-
- ) : (
- bubble
- )}
-
- );
- }),
- // v1.16.5 — guided question whose answer is still optimistic-only.
- ...placement.beforeOptimistic.map((i) => (
- {i.node}
- )),
- // v1.4.25 W5 — the optimistic user bubble sits between the
- // persisted history and the streaming assistant placeholder, so
- // the visible order matches the person's mental model. The send
- // hook drops it once the persisted twin lands.
- optimisticActive && optimisticUser ? (
-
- ) : null,
- streamingActive && streaming ? (
- // Same key, fragment and wrapper as its persisted copy above.
-
- {null}
-
+ const bubble = (
0
+ ? { results: streaming.results }
+ : {})}
+ {...(m.role === "assistant" ? chipsFor(m.id) : {})}
+ onRegenerate={
+ precedingUserContent !== null && onRegenerate
+ ? () => onRegenerate(precedingUserContent as string)
+ : undefined
+ }
/>
-
-
- ) : null,
- ]}
- {/* v1.39.4 — follow-up chips under the latest assistant turn only. */}
- {onFollowUp && chips ? (
-
- ) : null}
- {/* v1.16.5 — thread tail: the current guided question and/or the
+ );
+ const key = messageRenderKey(liveKeys, m.id);
+ return (
+
+ {guidedBefore?.node}
+ {m.role === "assistant" ? (
+
+ {bubble}
+
+ ) : (
+ bubble
+ )}
+
+ );
+ }),
+ // v1.16.5 — guided question whose answer is still optimistic-only.
+ ...placement.beforeOptimistic.map((i) => (
+ {i.node}
+ )),
+ // v1.4.25 W5 — the optimistic user bubble sits between the
+ // persisted history and the streaming assistant placeholder, so
+ // the visible order matches the person's mental model. The send
+ // hook drops it once the persisted twin lands.
+ optimisticActive && optimisticUser ? (
+
+ ) : null,
+ streamingActive && streaming ? (
+ // Same key, fragment and wrapper as its persisted copy above.
+
+ {null}
+
+
+
+
+ ) : null,
+ ]}
+ {/* v1.16.5 — thread tail: the current guided question and/or the
closing summary follow the last completed turn. */}
- {placement.tail.map((i) => (
- {i.node}
- ))}
- {/* Plan-proposal confirm cards for THIS conversation. The extractor
+ {placement.tail.map((i) => (
+ {i.node}
+ ))}
+ {/* Plan-proposal confirm cards for THIS conversation. The extractor
runs after the turn (memory-refresh worker), so the block appears at
the thread tail once the proposal lands rather than under a specific
bubble; the component itself slow-polls and renders nothing while
the conversation has no open proposal. */}
- {conversation ? (
-
- ) : null}
+ {conversation ? (
+
+ ) : null}
+
+
);
diff --git a/src/components/insights/coach-panel/message-token-footer.tsx b/src/components/insights/coach-panel/message-token-footer.tsx
deleted file mode 100644
index 0efc03521..000000000
--- a/src/components/insights/coach-panel/message-token-footer.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-"use client";
-
-import { useTranslations } from "@/lib/i18n/context";
-
-/**
- * v1.18.9 — quiet per-message token-usage caption under an assistant
- * bubble. Server-authoritative: it renders the count the server reported
- * (`done.usage.totalTokens` for the live turn, `CoachMessageDTO.tokensUsed`
- * on reload), never a client-side recomputation. No cost — BYOK users pay
- * the provider directly and a price table would only drift.
- *
- * Renders nothing when there is no count (user turns, refusals, in-flight
- * bubbles, and pre-feature persisted rows all carry null).
- */
-export interface MessageTokenFooterProps {
- tokens: number | null | undefined;
- /** Provider model name (e.g. `gpt-4o`); appended when present. */
- model?: string | null;
-}
-
-export function MessageTokenFooter({ tokens, model }: MessageTokenFooterProps) {
- const { t } = useTranslations();
- if (tokens == null) return null;
-
- const label = model
- ? t("insights.coach.tokensUsedWithModel", { count: tokens, model })
- : t("insights.coach.tokensUsed", { count: tokens });
-
- return (
-
- {label}
-
- );
-}
diff --git a/src/components/insights/coach-panel/method-line.tsx b/src/components/insights/coach-panel/method-line.tsx
index 60a2227b3..3b6246b8e 100644
--- a/src/components/insights/coach-panel/method-line.tsx
+++ b/src/components/insights/coach-panel/method-line.tsx
@@ -1,11 +1,10 @@
"use client";
/**
- * v1.39.4 — how an answer was worked out, as one muted line inside the
- * evidence disclosure: sources, windows, counts and aggregation, rendered
+ * v1.39.4 — how an answer was worked out, as one muted line at the end of
+ * the open steps list: sources, windows, counts and aggregation, rendered
* on the server in the request locale. Never a health value, so it sits in
- * the meta tier beside the key values it explains rather than repeating
- * them.
+ * the meta tier.
*/
import { useTranslations } from "@/lib/i18n/context";
import { COACH_METHOD_KEYS } from "@/lib/ai/coach/dialog-keys";
diff --git a/src/components/insights/coach-panel/read-aloud.tsx b/src/components/insights/coach-panel/read-aloud.tsx
index ac94adba6..2fe931eae 100644
--- a/src/components/insights/coach-panel/read-aloud.tsx
+++ b/src/components/insights/coach-panel/read-aloud.tsx
@@ -260,9 +260,9 @@ export function ReadAloudButton({ content }: { content: string }) {
className={COACH_ICON_BUTTON}
>
{speaking ? (
-
+
) : (
-
+
)}
);
diff --git a/src/components/insights/coach-panel/scroll-to-bottom-button.tsx b/src/components/insights/coach-panel/scroll-to-bottom-button.tsx
new file mode 100644
index 000000000..73aeb8af8
--- /dev/null
+++ b/src/components/insights/coach-panel/scroll-to-bottom-button.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+/**
+ * The round "jump to latest" button over the bottom of the Coach thread.
+ *
+ * It fades in once the reader has scrolled away from the end and fades out
+ * at the end. While hidden it stays mounted (so the fade has something to
+ * run on) but leaves the accessibility tree and the tab order and takes no
+ * pointer events. With reduced motion it switches without a transition.
+ */
+import { ArrowDown } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { useTranslations } from "@/lib/i18n/context";
+
+export interface ScrollToBottomButtonProps {
+ visible: boolean;
+ onClick: () => void;
+}
+
+export function ScrollToBottomButton({
+ visible,
+ onClick,
+}: ScrollToBottomButtonProps) {
+ const { t } = useTranslations();
+ const label = t("insights.coach.answer.scrollToBottom");
+ return (
+
+
+
+ );
+}
diff --git a/src/components/insights/coach-panel/source-chips.tsx b/src/components/insights/coach-panel/source-chips.tsx
deleted file mode 100644
index a36a12549..000000000
--- a/src/components/insights/coach-panel/source-chips.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-"use client";
-
-import { Link2 } from "lucide-react";
-
-import { cn } from "@/lib/utils";
-import { useTranslations } from "@/lib/i18n/context";
-
-import type { CoachProvenance } from "@/lib/ai/coach/types";
-
-/**
- * v1.4.20 phase B2b — provenance chip row.
- *
- * Renders the labels-only `metricSourceJson` envelope that the backend
- * attaches to every assistant message. We never render raw values here
- * — only metric + window labels + sample counts so that the row
- * remains queryable / analytics-safe and keeps PII out of any future
- * server-side log capture.
- *
- * The chip click is a no-op for v1.4.20; a chart deeplink will land in
- * B3 once the correlation row knows which window to focus.
- */
-export interface SourceChipsProps {
- provenance: CoachProvenance | null;
- className?: string;
-}
-
-// v1.7.0 — every provenance metric token resolves to
-// `insights.coach.metric.`. The clustered taxonomy added ~26
-// new tokens; the key is derived from the token rather than maintained
-// in a hand-listed map. The resolver falls back to the raw token when
-// a locale file lags, and `messages/en.json` carries one leaf per
-// token (propagated to all locales by the locale-integrity guard).
-function metricI18nKey(metric: CoachProvenance["metrics"][number]): string {
- return `insights.coach.metric.${metric}`;
-}
-
-const WINDOW_KEYS: Record = {
- last7days: "insights.coach.window.last7days",
- last30days: "insights.coach.window.last30days",
- last90days: "insights.coach.window.last90days",
- // v1.4.27 B7 / BL-P6-4 — year-in-review window. The i18n key
- // resolves to a localised label; absent translations fall back
- // through the resolver to the English source.
- lastYear: "insights.coach.window.lastYear",
- allTime: "insights.coach.window.allTime",
-};
-
-export function SourceChips({ provenance, className }: SourceChipsProps) {
- const { t } = useTranslations();
- if (!provenance) return null;
- const { metrics, windows, counts } = provenance;
- if (metrics.length === 0 && windows.length === 0) return null;
-
- // We pair each metric with its first window (the assistant only ever
- // surfaces a single window-per-metric in v1.4.20). When the provenance
- // envelope has no windows (e.g. refusal path with `windows: []`), we
- // still render the metric as a standalone chip so the user gets the
- // "general" tag visibly.
- const primaryWindowKey = windows[0] ?? null;
- const chips = metrics.map((metric) => {
- const metricLabel = t(metricI18nKey(metric));
- const windowLabel = primaryWindowKey
- ? t(WINDOW_KEYS[primaryWindowKey])
- : null;
- const count =
- counts && metric in counts
- ? counts[metric as keyof typeof counts]
- : undefined;
- return {
- key: `${metric}-${primaryWindowKey ?? "none"}`,
- metric,
- metricLabel,
- windowLabel,
- count,
- };
- });
-
- return (
-
- );
-}
diff --git a/src/components/insights/coach-panel/turn-steps.tsx b/src/components/insights/coach-panel/turn-steps.tsx
index 4c05a067c..16f354640 100644
--- a/src/components/insights/coach-panel/turn-steps.tsx
+++ b/src/components/insights/coach-panel/turn-steps.tsx
@@ -13,11 +13,17 @@
* server's enums and counts; a row never shows a value, a tool argument or
* model text.
*
+ * The open list also says how the answer was worked out (the method line)
+ * and holds the tables the answer read without pointing at them. An older
+ * message saved before steps existed folds into "Looked at 3 areas" and
+ * lists the areas from its provenance, without counts; a metric token the
+ * bundle has no name for is left out rather than shown as a raw key.
+ *
* Screen readers hear each completed step once, through a polite status
* region, at most one announcement per `ANNOUNCE_THROTTLE_MS` (the latest
* wins). A reloaded conversation announces nothing.
*/
-import { useEffect, useId, useRef, useState } from "react";
+import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import {
Check,
ChevronRight,
@@ -36,7 +42,13 @@ import {
coachPeriodLabelKey,
coachWindowLabelKey,
} from "@/lib/ai/coach/dialog-keys";
-import type { CoachStep } from "@/lib/ai/coach/types";
+import type {
+ CoachMethod,
+ CoachProvenanceMetric,
+ CoachStep,
+} from "@/lib/ai/coach/types";
+
+import { CoachMethodLine } from "./method-line";
export interface CoachTurnStepsProps {
steps: CoachStep[];
@@ -44,6 +56,21 @@ export interface CoachTurnStepsProps {
active: boolean;
}
+export interface CoachTurnStepsDetailProps extends CoachTurnStepsProps {
+ /**
+ * The provenance metrics, for a message saved before steps existed. Read
+ * only when `steps` is empty.
+ */
+ areas?: readonly CoachProvenanceMetric[];
+ /** How the answer was worked out; shown at the end of the open list. */
+ method?: CoachMethod | null;
+ /**
+ * The tables the answer read without pointing at them, shown last in the
+ * open list. Pass it only when there is something to show.
+ */
+ dataUsed?: ReactNode;
+}
+
/** The shortest gap between two screen-reader announcements. */
export const ANNOUNCE_THROTTLE_MS = 1500;
@@ -238,6 +265,26 @@ function StepIcon({ step, active }: { step: CoachStep; active: boolean }) {
);
}
+/**
+ * The areas an older message drew on, in the order its provenance lists
+ * them, by their display names. A token without a name in the bundle is
+ * dropped, and so is `general`, which names no area of the record.
+ */
+export function legacyAreaLabels(
+ areas: readonly CoachProvenanceMetric[] | undefined,
+ t: Translate,
+): string[] {
+ const out: string[] = [];
+ for (const area of areas ?? []) {
+ if (area === "general") continue;
+ const key = `insights.coach.metric.${area}`;
+ const label = t(key);
+ if (label === key || out.includes(label)) continue;
+ out.push(label);
+ }
+ return out;
+}
+
/** The rows under the header: one per step, in the order they started. */
export function CoachTurnStepList({
id,
@@ -250,7 +297,7 @@ export function CoachTurnStepList({
id={id}
data-slot="coach-turn-steps-list"
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
- className="border-border/60 mt-1.5 ml-1.5 flex flex-col gap-1 border-l pl-2.5"
+ className="flex flex-col gap-1"
>
{steps.map((step) => {
const { title, meta } = describeStep(step, t, tCount);
@@ -279,7 +326,66 @@ export function CoachTurnStepList({
);
}
-export function CoachTurnSteps({ steps, active }: CoachTurnStepsProps) {
+/**
+ * The open list: the steps (or an older message's areas), then the method
+ * line, then the tables the answer read. One left rule holds them together.
+ */
+export function CoachTurnStepsPanel({
+ id,
+ steps,
+ active,
+ areaLabels,
+ method,
+ dataUsed,
+}: CoachTurnStepsProps & {
+ id?: string;
+ areaLabels: string[];
+ method?: CoachMethod | null;
+ dataUsed?: ReactNode;
+}) {
+ const { t } = useTranslations();
+ return (
+
) : (
- {tCount("coach.step.headerDone", countSources(steps))}
+ {header}
)}
- {open && }
+ {open && (
+
+ )}
Date: Sun, 4 Oct 2026 20:02:49 +0200
Subject: [PATCH 05/33] Measure the Coach answer layout at 1440 and 390 px
---
e2e/coach-answer-layout.spec.ts | 336 ++++++++++++++++++++++++++++++++
1 file changed, 336 insertions(+)
create mode 100644 e2e/coach-answer-layout.spec.ts
diff --git a/e2e/coach-answer-layout.spec.ts b/e2e/coach-answer-layout.spec.ts
new file mode 100644
index 000000000..b4aec3b52
--- /dev/null
+++ b/e2e/coach-answer-layout.spec.ts
@@ -0,0 +1,336 @@
+import { mkdirSync } from "node:fs";
+import { join } from "node:path";
+import type { Locator, Page, Route, TestInfo } from "@playwright/test";
+
+import { expect, test } from "./setup/test";
+import { STORAGE_STATE_PATH } from "./setup/global-setup";
+import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
+
+/**
+ * The geometry of a Coach answer, at 1440 x 900 (desktop project) and
+ * 390 x 844 (mobile project).
+ *
+ * Contracts under test:
+ *
+ * 1. Under an answer there is exactly one action row: the icon group and
+ * the time on the left, the tokens and model on the right, both halves
+ * on one line (their centres within 2 px, the row no taller than one
+ * 44 px target), the left group starting on the bubble's left edge and
+ * the meta ending inside the answer column.
+ * 2. The follow-up chips sit in the answer's column: their left edge is
+ * the bubble's, their text is the answer's size, and a chip is at most
+ * 36 px tall beside a pointer and at least 44 px on a phone.
+ * 3. Under a question, copy and remember share one row; nothing stacks
+ * below it.
+ * 4. Scrolled away from the end, a round button appears; it brings the
+ * thread back to the end and goes away again.
+ * 5. No thumbs, no evidence disclosure, no horizontal overflow.
+ *
+ * Screenshots are attached to the report (no pixel baselines) and, with
+ * `COACH_SHOT_DIR` set, written there too.
+ */
+
+const CONVERSATION_ID = "coach-answer-layout-e2e";
+const TITLE = "Blood pressure and sleep";
+
+const ANSWERS = [
+ "Your blood pressure stayed in a steady band over the last month. The morning readings sat a little higher than the evening ones, which is common, and none of the weeks stands out from the others.",
+ "Sleep looked regular on most nights. The weekends ran about forty minutes later at both ends, so the total stayed much the same while the timing moved.",
+ "Your resting heart rate held steady across the same weeks. Nothing in it lines up with the later weekend nights, so the two seem to move independently for now.",
+ "The period before looked much the same for blood pressure. Two readings in the second week were higher than the rest, and both were taken shortly after a workout.",
+ "Taken together, the month reads calm: steady blood pressure, regular sleep and a resting heart rate without surprises. Measuring at the same time each morning keeps the trend easy to read.",
+];
+const QUESTIONS = [
+ "How was my blood pressure this month?",
+ "And my sleep?",
+ "What about my resting heart rate?",
+ "Compare blood pressure with the period before",
+ "Sum it up for me",
+];
+
+const STEPS = [
+ {
+ id: "s1",
+ tool: "get_metric_series",
+ labelKey: "coach.step.readWindow",
+ label: "Checking: Blood pressure, last 30 days",
+ domain: "bp",
+ window: "last30days",
+ status: "done",
+ count: 28,
+ },
+ {
+ id: "s2",
+ tool: "get_sleep",
+ labelKey: "coach.step.readWindow",
+ label: "Checking: Sleep, last 30 days",
+ domain: "sleep",
+ window: "last30days",
+ status: "done",
+ count: 30,
+ },
+];
+
+const FOLLOW_UPS = [
+ {
+ id: "f1",
+ kind: "previous_period",
+ labelKey: "coach.followUp.previousPeriod",
+ label: "Compare with the period before",
+ reuse: false,
+ origin: "server",
+ },
+ {
+ id: "f2",
+ kind: "related_metric",
+ labelKey: "coach.followUp.relatedMetric",
+ label: "See also: Resting HR",
+ reuse: false,
+ origin: "server",
+ },
+];
+
+function messages() {
+ // Today, a few minutes apart, so the time reads as a bare clock.
+ const base = Date.now() - 60 * 60 * 1000;
+ const at = (minute: number) =>
+ new Date(base + minute * 60 * 1000).toISOString();
+ return ANSWERS.flatMap((answer, index) => [
+ {
+ id: `${CONVERSATION_ID}-u${index + 1}`,
+ role: "user",
+ content: QUESTIONS[index],
+ createdAt: at(index * 2),
+ metricSource: null,
+ providerType: null,
+ promptVersion: null,
+ tokensUsed: null,
+ model: null,
+ },
+ {
+ id: `${CONVERSATION_ID}-a${index + 1}`,
+ role: "assistant",
+ content: answer,
+ createdAt: at(index * 2 + 1),
+ metricSource: {
+ windows: ["last30days"],
+ metrics: ["bp", "sleep"],
+ steps: STEPS,
+ ...(index === ANSWERS.length - 1 ? { followUps: FOLLOW_UPS } : {}),
+ },
+ providerType: "mock",
+ promptVersion: "e2e",
+ tokensUsed: 1234,
+ model: "gpt-4o-mini-2024-07-18",
+ },
+ ]);
+}
+
+function fulfilJson(route: Route, data: unknown) {
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ data, error: null }),
+ });
+}
+
+async function mockConversation(page: Page) {
+ const summary = {
+ id: CONVERSATION_ID,
+ title: TITLE,
+ createdAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
+ updatedAt: new Date().toISOString(),
+ messageCount: ANSWERS.length * 2,
+ fenced: false,
+ attachments: [],
+ documentTitle: null,
+ };
+ await page.route(
+ /\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
+ async (route) => {
+ const url = new URL(route.request().url());
+ if (url.pathname.startsWith(`/api/insights/chat/${CONVERSATION_ID}/`)) {
+ return fulfilJson(route, { results: [] });
+ }
+ if (url.pathname === `/api/insights/chat/${CONVERSATION_ID}`) {
+ return fulfilJson(route, {
+ ...summary,
+ attachmentCount: 0,
+ summary: null,
+ messages: messages(),
+ });
+ }
+ return fulfilJson(route, { conversations: [summary], nextCursor: null });
+ },
+ );
+ await page.route("**/api/insights/coach/nudge-status*", (route) =>
+ fulfilJson(route, { nudgedAt: null, unread: false }),
+ );
+ await page.route("**/api/coach/about-me/questions*", (route) =>
+ fulfilJson(route, { questions: [] }),
+ );
+}
+
+async function box(locator: Locator) {
+ const b = await locator.boundingBox();
+ expect(b, "element has a box").not.toBeNull();
+ return b!;
+}
+
+async function shoot(page: Page, testInfo: TestInfo, name: string) {
+ const body = await page.screenshot({ fullPage: false });
+ await testInfo.attach(name, { body, contentType: "image/png" });
+ const dir = process.env.COACH_SHOT_DIR;
+ if (dir) {
+ mkdirSync(dir, { recursive: true });
+ await page.screenshot({ path: join(dir, `${name}.png`) });
+ }
+}
+
+const VIEWPORTS = {
+ "chromium-desktop": { width: 1440, height: 900, label: "1440" },
+ "chromium-mobile": { width: 390, height: 844, label: "390" },
+} as const;
+
+test.describe("Coach answer layout", () => {
+ test.use({ storageState: STORAGE_STATE_PATH });
+
+ test("one action row, chips in the answer column, jump to the latest", async ({
+ page,
+ }, testInfo) => {
+ const viewport = VIEWPORTS[testInfo.project.name as keyof typeof VIEWPORTS];
+ test.skip(!viewport, "measured in the desktop and the mobile project");
+ const desktop = viewport.width >= 640;
+ await page.setViewportSize({
+ width: viewport.width,
+ height: viewport.height,
+ });
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await serveAiBlock(page, aiBlockAvailable());
+ await mockConversation(page);
+
+ await page.goto(`/coach?c=${CONVERSATION_ID}`, {
+ waitUntil: "domcontentloaded",
+ });
+ const thread = page.locator('[data-slot="coach-message-thread"]');
+ const answers = page.locator('[data-slot="coach-bubble-assistant"]');
+ await expect(answers).toHaveCount(ANSWERS.length, { timeout: 15_000 });
+ const last = answers.last();
+ await expect(last).toContainText(ANSWERS.at(-1)!.slice(0, 40));
+ // The thread opens at its end.
+ await expect
+ .poll(() =>
+ thread.evaluate(
+ (el) => el.scrollHeight - el.scrollTop - el.clientHeight,
+ ),
+ )
+ .toBeLessThanOrEqual(2);
+
+ if (desktop) await last.hover();
+ await shoot(page, testInfo, `answer-${viewport.label}`);
+
+ // 5. Nothing of the old furniture.
+ await expect(
+ page.locator('[data-slot^="coach-message-feedback"]'),
+ ).toHaveCount(0);
+ await expect(page.locator('[data-slot^="coach-evidence"]')).toHaveCount(0);
+ await expect(page.locator('[data-slot="coach-token-footer"]')).toHaveCount(
+ 0,
+ );
+ for (const el of [page.locator("#main-content"), thread]) {
+ const overflow = await el.evaluate((n) => n.scrollWidth - n.clientWidth);
+ expect(overflow, "no horizontal overflow").toBeLessThanOrEqual(0);
+ }
+
+ // 1. One action row under the answer.
+ const bubble = last.locator('[data-slot="coach-answer-bubble"]');
+ const column = last.locator('[data-slot="coach-answer-column"]');
+ const row = last.locator('[data-slot="coach-answer-actions"]');
+ const left = row.locator('[data-slot="coach-answer-actions-left"]');
+ const meta = row.locator('[data-slot="coach-answer-meta"]');
+ await expect(row).toHaveCount(1);
+ await expect(meta).toContainText("1,234 tokens");
+ await expect(row).toHaveCSS("opacity", "1");
+ const [bubbleBox, columnBox, rowBox, leftBox, metaBox] = await Promise.all(
+ [bubble, column, row, left, meta].map(box),
+ );
+ expect(
+ Math.abs(
+ leftBox.y + leftBox.height / 2 - (metaBox.y + metaBox.height / 2),
+ ),
+ "left group and meta share one line",
+ ).toBeLessThanOrEqual(2);
+ expect(rowBox.height, "the row is one line").toBeLessThanOrEqual(44);
+ expect(Math.abs(leftBox.x - bubbleBox.x)).toBeLessThanOrEqual(1);
+ expect(metaBox.x + metaBox.width).toBeLessThanOrEqual(
+ columnBox.x + columnBox.width + 1,
+ );
+ await expect(left.locator('[data-slot="coach-message-time"]')).toHaveCount(
+ 1,
+ );
+
+ // 2. The chips in the answer's column, at the answer's size.
+ const chips = last.locator('[data-slot="coach-follow-up-chips"]');
+ await expect(chips.locator("[data-follow-up-id]")).toHaveCount(2);
+ const chipsBox = await box(chips);
+ expect(Math.abs(chipsBox.x - bubbleBox.x)).toBeLessThanOrEqual(1);
+ expect(chipsBox.y).toBeGreaterThanOrEqual(rowBox.y + rowBox.height - 1);
+ const answerSize = await bubble.evaluate(
+ (el) => getComputedStyle(el).fontSize,
+ );
+ for (const chip of await chips.locator("[data-follow-up-id]").all()) {
+ await expect(chip).toHaveCSS("font-size", answerSize);
+ const chipBox = await box(chip);
+ if (desktop) expect(chipBox.height).toBeLessThanOrEqual(36);
+ else expect(chipBox.height).toBeGreaterThanOrEqual(44);
+ }
+
+ // 3. Copy and remember share the question's one row.
+ const question = page.locator('[data-slot="coach-bubble-user"]').last();
+ if (desktop) await question.hover();
+ const userRow = question.locator('[data-slot="coach-user-actions"]');
+ const remember = userRow.locator('[data-slot="coach-remember-message"]');
+ const copy = userRow.locator('[data-slot="coach-copy-message"]');
+ await expect(remember).toHaveAttribute("aria-label", "Remember");
+ const [rememberBox, copyBox] = await Promise.all([remember, copy].map(box));
+ expect(
+ Math.abs(
+ rememberBox.y +
+ rememberBox.height / 2 -
+ (copyBox.y + copyBox.height / 2),
+ ),
+ ).toBeLessThanOrEqual(1);
+ // The question's column holds the bubble and its one row, nothing else.
+ expect(
+ await userRow.evaluate((el) => el.parentElement?.childElementCount),
+ ).toBe(2);
+
+ // 4. Away from the end, the button appears and brings the thread back.
+ const jump = page.locator('[data-slot="coach-scroll-to-bottom"]');
+ await expect(jump).toHaveAttribute("data-visible", "false");
+ await expect(jump).toHaveCSS("opacity", "0");
+ await thread.evaluate((el) => el.scrollTo({ top: 0, behavior: "auto" }));
+ await expect(jump).toHaveAttribute("data-visible", "true");
+ await expect(jump).toHaveCSS("opacity", "1");
+ await shoot(page, testInfo, `scrolled-up-${viewport.label}`);
+ const jumpBox = await box(jump);
+ const threadBox = await box(thread);
+ expect(
+ Math.abs(
+ jumpBox.x + jumpBox.width / 2 - (threadBox.x + threadBox.width / 2),
+ ),
+ "centred over the thread",
+ ).toBeLessThanOrEqual(1);
+ await jump.click();
+ await expect
+ .poll(() =>
+ thread.evaluate(
+ (el) => el.scrollHeight - el.scrollTop - el.clientHeight,
+ ),
+ )
+ .toBeLessThanOrEqual(2);
+ await expect(jump).toHaveAttribute("data-visible", "false");
+ await expect(jump).toHaveAttribute("tabindex", "-1");
+ await expect(page.locator("#coach-composer-textarea")).toBeFocused();
+ });
+});
From d07f5796d7125a619f0f6e6088c379da3297eb7d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 4 Oct 2026 20:02:49 +0200
Subject: [PATCH 06/33] Seed the demo Coach answer with valid metric tokens,
steps and token meta
---
scripts/seed-demo.ts | 39 +++++++++++++++++++++++++++++++++++----
1 file changed, 35 insertions(+), 4 deletions(-)
diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts
index 33167d40a..60eb03e4d 100644
--- a/scripts/seed-demo.ts
+++ b/scripts/seed-demo.ts
@@ -1766,10 +1766,32 @@ async function seed() {
[convoId, userId, "Blood pressure trend", convoStart],
);
+ // The provenance uses the contract tokens (`bp`, `resting_hr`, `sleep`):
+ // the client names an area only through a known token, so a token it
+ // cannot name never reaches the page. The steps give the answer its
+ // "Looked at 3 sources" header the way a live turn would; they carry no
+ // counts, since a count is the server's tally and the seed does not
+ // run a tool.
+ const seedStep = (
+ id: string,
+ tool: string,
+ domain: string,
+ domainLabel: string,
+ ) => ({
+ id,
+ tool,
+ labelKey: "coach.step.readWindow",
+ label: `Checking: ${domainLabel}, last 30 days`,
+ domain,
+ window: "last30days",
+ status: "done",
+ });
const coachTurns: Array<{
role: string;
content: string;
metricSourceJson?: string;
+ tokensUsed?: number;
+ model?: string;
}> = [
{
role: "user",
@@ -1785,9 +1807,16 @@ async function seed() {
"paying off — worth staying consistent with the morning measurements " +
"so the trend stays easy to read.",
metricSourceJson: JSON.stringify({
- window: "last30days",
- metrics: ["bloodPressure", "restingHeartRate", "sleep"],
+ windows: ["last30days"],
+ metrics: ["bp", "resting_hr", "sleep"],
+ steps: [
+ seedStep("s1", "get_metric_series", "bp", "Blood pressure"),
+ seedStep("s2", "get_metric_series", "resting_hr", "Resting HR"),
+ seedStep("s3", "get_sleep", "sleep", "Sleep"),
+ ],
}),
+ tokensUsed: 1840,
+ model: aiModel,
},
];
for (let t = 0; t < coachTurns.length; t++) {
@@ -1795,8 +1824,8 @@ async function seed() {
const ts = new Date(convoStart.getTime() + t * 60_000);
const encrypted = Buffer.from(encryptToBytes(turn.content));
await client.query(
- `INSERT INTO coach_messages (id, conversation_id, role, encrypted_content, metric_source_json, provider_type, prompt_version, created_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
+ `INSERT INTO coach_messages (id, conversation_id, role, encrypted_content, metric_source_json, provider_type, prompt_version, tokens_used, model, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
cuid(),
convoId,
@@ -1805,6 +1834,8 @@ async function seed() {
turn.metricSourceJson ?? null,
turn.role === "assistant" ? aiProvider : null,
turn.role === "assistant" ? "demo" : null,
+ turn.tokensUsed ?? null,
+ turn.model ?? null,
ts,
],
);
From 393ac955f6cc728da8d1b7625ce178bbd90b80ec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 4 Oct 2026 20:45:21 +0200
Subject: [PATCH 07/33] Make the Coach lookback a limit on every read
The saved Coach window only seeded the snapshot and each tool's default.
A tool could still read back to all time, the availability probe reported
counts and means for history outside the window, and several blocks read
fixed windows of their own (labs 12 months, correlations 180 days, the
coarse tail, the all-time extremes) whatever the setting said.
coachPrefs.defaultWindow is now enforced as a limit through one module,
history-reach.ts: the turn context clamps the client window, the snapshot
clamps its window and gives every fixed-window block either the shorter
window or, when the block's own window cannot fit, leaves it out. Every
retrieval tool clamps its window, an earlier period or a stored table that
reaches past the limit answers outside_reach, and the probe reads only
inside the limit and names older history as outside_reach without any
figure. Follow-up chips that would read past the limit are not offered.
The default (allTime) is no limit and reads as before. MCP clients pass no
limit and keep their own window arguments.
Adds the twelve-month option, rewrites the picker text to state what is
read with the numbers the readers use, shows the same options in the
managed-profile settings, counts cluster members with a singular, and
drops the opacity on switched-off rows that put their text below WCAG AA.
---
docs/api/openapi.yaml | 12 +
messages/de.json | 24 +-
messages/en.json | 24 +-
messages/es.json | 24 +-
messages/fr.json | 24 +-
messages/it.json | 24 +-
messages/ko.json | 24 +-
messages/pl.json | 24 +-
.../__tests__/sources-rail.test.tsx | 47 +-
.../insights/coach-panel/sources-rail.tsx | 60 ++-
.../managed-record-settings-section.tsx | 60 ++-
.../coach-history-reach-guard.test.ts | 417 ++++++++++++++++++
.../__tests__/coach-history-reach.test.ts | 229 ++++++++++
.../__tests__/tools-loop-callbacks.test.ts | 2 +
.../tools-loop-miss-payloads.test.ts | 2 +
src/lib/ai/coach/__tests__/tools-loop.test.ts | 2 +
.../workout-evidence-builder.test.ts | 3 +
src/lib/ai/coach/correlations-snapshot.ts | 5 +-
src/lib/ai/coach/derived-snapshot.ts | 54 ++-
src/lib/ai/coach/eval/run-case.ts | 3 +
.../coach/follow-ups/__tests__/derive.test.ts | 13 +
src/lib/ai/coach/follow-ups/derive.ts | 37 +-
src/lib/ai/coach/glp1-snapshot.ts | 21 +-
src/lib/ai/coach/history-reach-read.ts | 18 +
src/lib/ai/coach/history-reach.ts | 200 +++++++++
src/lib/ai/coach/illness-snapshot.ts | 45 +-
src/lib/ai/coach/labs-snapshot.ts | 15 +-
src/lib/ai/coach/memory-snapshot.ts | 63 ++-
.../__tests__/loop-result-tables.test.ts | 3 +
src/lib/ai/coach/results/metric-table-tool.ts | 5 +-
src/lib/ai/coach/self-context-questions.ts | 6 +-
.../ai/coach/snapshot-blocks/glucose-block.ts | 8 +-
src/lib/ai/coach/snapshot-cache.ts | 9 +-
src/lib/ai/coach/snapshot-series.ts | 54 ++-
src/lib/ai/coach/snapshot.ts | 187 +++++---
src/lib/ai/coach/tools/availability.ts | 199 ++++++++-
src/lib/ai/coach/tools/correlations-read.ts | 33 +-
src/lib/ai/coach/tools/definitions.ts | 23 +-
src/lib/ai/coach/tools/executor.ts | 260 +++++++++--
src/lib/ai/coach/tools/inventory.ts | 54 ++-
src/lib/ai/coach/tools/loop.ts | 8 +
src/lib/ai/coach/tools/system-addendum.ts | 6 +-
src/lib/ai/coach/trajectory-snapshot.ts | 18 +-
src/lib/ai/coach/turn/context.ts | 35 +-
src/lib/ai/coach/turn/model.ts | 8 +-
src/lib/ai/coach/turn/reuse-turn.ts | 14 +
src/lib/ai/coach/turn/steps.ts | 3 +
src/lib/ai/coach/visits-snapshot.ts | 13 +-
src/lib/ai/coach/workout-evidence-builder.ts | 16 +-
.../insights/derived/adherence-storyline.ts | 6 +
src/lib/insights/derived/changepoint.ts | 2 +-
src/lib/insights/derived/index.ts | 5 +-
src/lib/insights/derived/signal-trust.ts | 2 +-
src/lib/insights/derived/trajectory.ts | 2 +
src/lib/insights/feature-blocks.ts | 9 +
src/lib/insights/features.ts | 78 +++-
src/lib/mcp/tools.ts | 5 +
src/lib/validations/coach-prefs.ts | 34 +-
src/lib/workouts/sport-context.ts | 11 +
tests/integration/coach-history-reach.test.ts | 251 +++++++++++
60 files changed, 2539 insertions(+), 304 deletions(-)
create mode 100644 src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts
create mode 100644 src/lib/ai/coach/__tests__/coach-history-reach.test.ts
create mode 100644 src/lib/ai/coach/history-reach-read.ts
create mode 100644 src/lib/ai/coach/history-reach.ts
create mode 100644 tests/integration/coach-history-reach.test.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index bb71a2e8e..0537d2cc1 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -25929,7 +25929,10 @@ components:
- last7days
- last30days
- last90days
+ - lastYear
- allTime
+ description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime`
+ (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit."
dataClusters:
maxItems: 10
type: array
@@ -32076,7 +32079,10 @@ components:
- last7days
- last30days
- last90days
+ - lastYear
- allTime
+ description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime`
+ (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit."
dataClusters:
maxItems: 10
type: array
@@ -48822,7 +48828,10 @@ components:
- last7days
- last30days
- last90days
+ - lastYear
- allTime
+ description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime`
+ (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit."
dataClusters:
maxItems: 10
type: array
@@ -77933,7 +77942,10 @@ components:
- last7days
- last30days
- last90days
+ - lastYear
- allTime
+ description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime`
+ (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit."
dataClusters:
maxItems: 10
type: array
diff --git a/messages/de.json b/messages/de.json
index 4f69db510..f48998c96 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2930,7 +2930,6 @@
"lastYear": "letzte 12 Monate",
"allTime": "gesamter Zeitraum (12 Monate im Detail)"
},
- "windowLabel": "Zeitraum",
"settingsAriaLabel": "Coach-Einstellungen",
"settingsTitle": "Coach-Einstellungen",
"settingsDescription": "Stell ein, wie der Coach mit dir spricht.",
@@ -3009,7 +3008,23 @@
"settingsClustersLabel": "Daten, die der Coach sieht",
"settingsClustersHint": "Wähle, welche Datencluster der Coach lesen darf. Mehr Daten bedeuten reichhaltigere Antworten und einen größeren Prompt.",
"settingsClusterNoData": "Noch keine Daten",
- "sourcesMemberCount": "{count} Werte",
+ "sourcesMemberCountOne": "1 Wert",
+ "sourcesMemberCountFew": "{count} Werte",
+ "sourcesMemberCountOther": "{count} Werte",
+ "lookback": {
+ "label": "Wie weit der Coach zurückblickt",
+ "option": {
+ "last7days": "{days} Tage",
+ "last30days": "{days} Tage",
+ "last90days": "{days} Tage",
+ "lastYear": "{months} Monate",
+ "allTime": "Alles"
+ },
+ "detailDays": "Der Coach liest nichts, was älter als {days} Tage ist, in keiner Antwort.",
+ "detailMonths": "Der Coach liest nichts, was älter als {months} Monate ist, in keiner Antwort.",
+ "detailAll": "Alles, was du erfasst hast: {dailyMonths} Monate tagesgenau, ältere Werte als Monatswerte bis {years} Jahre zurück, Laborwerte der letzten {labMonths} Monate.",
+ "note": "Dein Profil, aktuelle Medikamente, eine laufende Erkrankung und anstehende Termine bleiben sichtbar; Auswertungen, die mehr Verlauf brauchen, werden gekürzt oder weggelassen."
+ },
"showConversations": "Unterhaltungen anzeigen",
"hideConversations": "Unterhaltungen ausblenden",
"heroGreeting": "Frage mich etwas zu deinen Daten",
@@ -5090,11 +5105,6 @@
"brief": "Kurz",
"default": "Standard",
"detailed": "Ausführlich",
- "defaultWindow": "Standardzeitraum",
- "last7days": "Letzte 7 Tage",
- "last30days": "Letzte 30 Tage",
- "last90days": "Letzte 90 Tage",
- "allTime": "Gesamter Zeitraum",
"excludedMetrics": "Ausgeschlossene Messwerte",
"dataClusters": "Datenbereiche",
"customClusters": "Eigene Auswahl von Datenbereichen verwenden",
diff --git a/messages/en.json b/messages/en.json
index 92d60007b..5781bc0bb 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2930,7 +2930,6 @@
"lastYear": "last 12 months",
"allTime": "all time (12 months of detail)"
},
- "windowLabel": "Window",
"settingsAriaLabel": "Coach settings",
"settingsTitle": "Coach settings",
"settingsDescription": "Tune how the Coach talks with you.",
@@ -3009,7 +3008,23 @@
"settingsClustersLabel": "Data the Coach sees",
"settingsClustersHint": "Pick which clusters of your data the Coach can read. More data means richer answers and a larger prompt.",
"settingsClusterNoData": "No data yet",
- "sourcesMemberCount": "{count} metrics",
+ "sourcesMemberCountOne": "1 metric",
+ "sourcesMemberCountFew": "{count} metrics",
+ "sourcesMemberCountOther": "{count} metrics",
+ "lookback": {
+ "label": "How far back the Coach can look",
+ "option": {
+ "last7days": "{days} days",
+ "last30days": "{days} days",
+ "last90days": "{days} days",
+ "lastYear": "{months} months",
+ "allTime": "All"
+ },
+ "detailDays": "The Coach reads nothing older than {days} days, in any answer.",
+ "detailMonths": "The Coach reads nothing older than {months} months, in any answer.",
+ "detailAll": "Everything you recorded: {dailyMonths} months in daily detail, older readings as monthly values up to {years} years back, lab results from the last {labMonths} months.",
+ "note": "Your profile, current medications, an ongoing illness and upcoming visits stay visible; summaries that need more history are shortened or left out."
+ },
"showConversations": "Show conversations",
"hideConversations": "Hide conversations",
"heroGreeting": "Ask me anything about your data",
@@ -5090,11 +5105,6 @@
"brief": "Brief",
"default": "Default",
"detailed": "Detailed",
- "defaultWindow": "Default window",
- "last7days": "Last 7 days",
- "last30days": "Last 30 days",
- "last90days": "Last 90 days",
- "allTime": "All time",
"excludedMetrics": "Excluded metrics",
"dataClusters": "Data clusters",
"customClusters": "Use a custom cluster selection",
diff --git a/messages/es.json b/messages/es.json
index 7fc4efbfb..7a8074e23 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2930,7 +2930,6 @@
"lastYear": "últimos 12 meses",
"allTime": "todo el tiempo (12 meses en detalle)"
},
- "windowLabel": "Periodo",
"settingsAriaLabel": "Ajustes del coach",
"settingsTitle": "Ajustes del coach",
"settingsDescription": "Configura cómo te habla el coach.",
@@ -3009,7 +3008,23 @@
"settingsClustersLabel": "Datos que ve el Coach",
"settingsClustersHint": "Elige qué grupos de datos puede leer el Coach. Más datos significan respuestas más ricas y un prompt mayor.",
"settingsClusterNoData": "Aún no hay datos",
- "sourcesMemberCount": "{count} métricas",
+ "sourcesMemberCountOne": "1 métrica",
+ "sourcesMemberCountFew": "{count} métricas",
+ "sourcesMemberCountOther": "{count} métricas",
+ "lookback": {
+ "label": "Hasta dónde puede mirar atrás el Coach",
+ "option": {
+ "last7days": "{days} días",
+ "last30days": "{days} días",
+ "last90days": "{days} días",
+ "lastYear": "{months} meses",
+ "allTime": "Todo"
+ },
+ "detailDays": "El Coach no lee nada con más de {days} días de antigüedad, en ninguna respuesta.",
+ "detailMonths": "El Coach no lee nada con más de {months} meses de antigüedad, en ninguna respuesta.",
+ "detailAll": "Todo lo que has registrado: {dailyMonths} meses al detalle diario, las mediciones más antiguas como valores mensuales hasta {years} años atrás, los análisis de los últimos {labMonths} meses.",
+ "note": "Tu perfil, tu medicación actual, una enfermedad en curso y las próximas citas siguen visibles; los resúmenes que necesitan más historial se acortan o se omiten."
+ },
"showConversations": "Mostrar conversaciones",
"hideConversations": "Ocultar conversaciones",
"heroGreeting": "Pregúntame lo que quieras sobre tus datos",
@@ -5090,11 +5105,6 @@
"brief": "Breve",
"default": "Predeterminado",
"detailed": "Detallado",
- "defaultWindow": "Periodo predeterminado",
- "last7days": "Últimos 7 días",
- "last30days": "Últimos 30 días",
- "last90days": "Últimos 90 días",
- "allTime": "Todo el tiempo",
"excludedMetrics": "Métricas excluidas",
"dataClusters": "Grupos de datos",
"customClusters": "Usar una selección personalizada de grupos",
diff --git a/messages/fr.json b/messages/fr.json
index 989e0ab17..4d8a00087 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2930,7 +2930,6 @@
"lastYear": "12 derniers mois",
"allTime": "toute la période (12 mois en détail)"
},
- "windowLabel": "Période",
"settingsAriaLabel": "Paramètres du coach",
"settingsTitle": "Paramètres du coach",
"settingsDescription": "Configure comment le coach te parle.",
@@ -3009,7 +3008,23 @@
"settingsClustersLabel": "Données vues par le Coach",
"settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.",
"settingsClusterNoData": "Pas encore de données",
- "sourcesMemberCount": "{count} mesures",
+ "sourcesMemberCountOne": "1 mesure",
+ "sourcesMemberCountFew": "{count} mesures",
+ "sourcesMemberCountOther": "{count} mesures",
+ "lookback": {
+ "label": "Jusqu’où le Coach peut remonter",
+ "option": {
+ "last7days": "{days} jours",
+ "last30days": "{days} jours",
+ "last90days": "{days} jours",
+ "lastYear": "{months} mois",
+ "allTime": "Tout"
+ },
+ "detailDays": "Le Coach ne lit rien de plus ancien que {days} jours, dans aucune réponse.",
+ "detailMonths": "Le Coach ne lit rien de plus ancien que {months} mois, dans aucune réponse.",
+ "detailAll": "Tout ce que vous avez enregistré : {dailyMonths} mois au jour près, les mesures plus anciennes en valeurs mensuelles jusqu’à {years} ans en arrière, les analyses des {labMonths} derniers mois.",
+ "note": "Votre profil, vos médicaments actuels, une maladie en cours et vos prochains rendez-vous restent visibles ; les synthèses qui demandent plus d’historique sont raccourcies ou omises."
+ },
"showConversations": "Afficher les conversations",
"hideConversations": "Masquer les conversations",
"heroGreeting": "Pose-moi une question sur tes données",
@@ -5090,11 +5105,6 @@
"brief": "Bref",
"default": "Par défaut",
"detailed": "Détaillé",
- "defaultWindow": "Période par défaut",
- "last7days": "7 derniers jours",
- "last30days": "30 derniers jours",
- "last90days": "90 derniers jours",
- "allTime": "Depuis toujours",
"excludedMetrics": "Mesures exclues",
"dataClusters": "Groupes de données",
"customClusters": "Utiliser une sélection personnalisée de groupes",
diff --git a/messages/it.json b/messages/it.json
index b8d636332..1ab874b20 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2930,7 +2930,6 @@
"lastYear": "ultimi 12 mesi",
"allTime": "tutto il tempo (12 mesi in dettaglio)"
},
- "windowLabel": "Periodo",
"settingsAriaLabel": "Impostazioni del coach",
"settingsTitle": "Impostazioni del coach",
"settingsDescription": "Imposta come il coach ti parla.",
@@ -3009,7 +3008,23 @@
"settingsClustersLabel": "Dati che il Coach vede",
"settingsClustersHint": "Scegli quali gruppi di dati il Coach può leggere. Più dati significano risposte più ricche e un prompt più grande.",
"settingsClusterNoData": "Ancora nessun dato",
- "sourcesMemberCount": "{count} metriche",
+ "sourcesMemberCountOne": "1 metrica",
+ "sourcesMemberCountFew": "{count} metriche",
+ "sourcesMemberCountOther": "{count} metriche",
+ "lookback": {
+ "label": "Fin dove il Coach può guardare indietro",
+ "option": {
+ "last7days": "{days} giorni",
+ "last30days": "{days} giorni",
+ "last90days": "{days} giorni",
+ "lastYear": "{months} mesi",
+ "allTime": "Tutto"
+ },
+ "detailDays": "Il Coach non legge nulla di più vecchio di {days} giorni, in nessuna risposta.",
+ "detailMonths": "Il Coach non legge nulla di più vecchio di {months} mesi, in nessuna risposta.",
+ "detailAll": "Tutto ciò che hai registrato: {dailyMonths} mesi giorno per giorno, le misurazioni più vecchie come valori mensili fino a {years} anni fa, gli esami degli ultimi {labMonths} mesi.",
+ "note": "Il tuo profilo, i farmaci attuali, una malattia in corso e le prossime visite restano visibili; i riepiloghi che richiedono più storico vengono accorciati o omessi."
+ },
"showConversations": "Mostra conversazioni",
"hideConversations": "Nascondi conversazioni",
"heroGreeting": "Chiedimi qualcosa sui tuoi dati",
@@ -5090,11 +5105,6 @@
"brief": "Breve",
"default": "Predefinito",
"detailed": "Dettagliato",
- "defaultWindow": "Periodo predefinito",
- "last7days": "Ultimi 7 giorni",
- "last30days": "Ultimi 30 giorni",
- "last90days": "Ultimi 90 giorni",
- "allTime": "Tutto il periodo",
"excludedMetrics": "Metriche escluse",
"dataClusters": "Gruppi di dati",
"customClusters": "Usa una selezione personalizzata di gruppi",
diff --git a/messages/ko.json b/messages/ko.json
index 5dc5fadf4..004674702 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -2930,7 +2930,6 @@
"lastYear": "최근 12개월",
"allTime": "전체 기간(상세 12개월)"
},
- "windowLabel": "기간",
"settingsAriaLabel": "코치 설정",
"settingsTitle": "코치 설정",
"settingsDescription": "코치가 대화하는 방식을 조절해요.",
@@ -3009,7 +3008,23 @@
"settingsClustersLabel": "코치가 보는 데이터",
"settingsClustersHint": "코치가 읽을 수 있는 데이터 묶음을 고르세요. 데이터가 많을수록 답변이 풍부해지고 프롬프트도 커져요.",
"settingsClusterNoData": "아직 데이터 없음",
- "sourcesMemberCount": "지표 {count}개",
+ "sourcesMemberCountOne": "지표 1개",
+ "sourcesMemberCountFew": "지표 {count}개",
+ "sourcesMemberCountOther": "지표 {count}개",
+ "lookback": {
+ "label": "코치가 돌아볼 수 있는 기간",
+ "option": {
+ "last7days": "{days}일",
+ "last30days": "{days}일",
+ "last90days": "{days}일",
+ "lastYear": "{months}개월",
+ "allTime": "전체"
+ },
+ "detailDays": "코치는 어떤 답변에서도 {days}일보다 오래된 기록은 읽지 않아요.",
+ "detailMonths": "코치는 어떤 답변에서도 {months}개월보다 오래된 기록은 읽지 않아요.",
+ "detailAll": "기록한 모든 것: 최근 {dailyMonths}개월은 하루 단위로, 그 이전 측정값은 최대 {years}년 전까지 월별 값으로, 검사 결과는 최근 {labMonths}개월 치를 읽어요.",
+ "note": "프로필, 현재 복용 중인 약, 진행 중인 질병, 예정된 진료는 계속 보여요. 더 긴 기록이 필요한 요약은 줄이거나 빼요."
+ },
"showConversations": "대화 보기",
"hideConversations": "대화 숨기기",
"heroGreeting": "내 데이터에 대해 무엇이든 물어보세요",
@@ -5090,11 +5105,6 @@
"brief": "간략",
"default": "기본",
"detailed": "상세",
- "defaultWindow": "기본 기간",
- "last7days": "최근 7일",
- "last30days": "최근 30일",
- "last90days": "최근 90일",
- "allTime": "전체 기간",
"excludedMetrics": "제외할 지표",
"dataClusters": "데이터 그룹",
"customClusters": "그룹 직접 선택하기",
diff --git a/messages/pl.json b/messages/pl.json
index b967ecdc4..0634681e1 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2930,7 +2930,6 @@
"lastYear": "ostatnie 12 miesięcy",
"allTime": "cały okres (12 miesięcy szczegółowo)"
},
- "windowLabel": "Okres",
"settingsAriaLabel": "Ustawienia coacha",
"settingsTitle": "Ustawienia coacha",
"settingsDescription": "Ustaw, jak coach do ciebie mówi.",
@@ -3009,7 +3008,23 @@
"settingsClustersLabel": "Dane widziane przez Coacha",
"settingsClustersHint": "Wybierz, które grupy danych Coach może czytać. Więcej danych to bogatsze odpowiedzi i większy prompt.",
"settingsClusterNoData": "Brak danych",
- "sourcesMemberCount": "{count} wskaźników",
+ "sourcesMemberCountOne": "1 wskaźnik",
+ "sourcesMemberCountFew": "{count} wskaźniki",
+ "sourcesMemberCountOther": "{count} wskaźników",
+ "lookback": {
+ "label": "Jak daleko wstecz może patrzeć Coach",
+ "option": {
+ "last7days": "{days} dni",
+ "last30days": "{days} dni",
+ "last90days": "{days} dni",
+ "lastYear": "{months} miesięcy",
+ "allTime": "Wszystko"
+ },
+ "detailDays": "Coach nie czyta niczego starszego niż {days} dni, w żadnej odpowiedzi.",
+ "detailMonths": "Coach nie czyta niczego starszego niż {months} miesięcy, w żadnej odpowiedzi.",
+ "detailAll": "Wszystko, co zostało zapisane: {dailyMonths} miesięcy z dokładnością do dnia, starsze pomiary jako wartości miesięczne do {years} lat wstecz, wyniki badań z ostatnich {labMonths} miesięcy.",
+ "note": "Twój profil, obecne leki, trwająca choroba i nadchodzące wizyty pozostają widoczne; podsumowania wymagające dłuższej historii są skracane lub pomijane."
+ },
"showConversations": "Pokaż rozmowy",
"hideConversations": "Ukryj rozmowy",
"heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych",
@@ -5090,11 +5105,6 @@
"brief": "Krótka",
"default": "Domyślna",
"detailed": "Szczegółowa",
- "defaultWindow": "Domyślny okres",
- "last7days": "Ostatnie 7 dni",
- "last30days": "Ostatnie 30 dni",
- "last90days": "Ostatnie 90 dni",
- "allTime": "Cały okres",
"excludedMetrics": "Wykluczone metryki",
"dataClusters": "Grupy danych",
"customClusters": "Użyj własnego wyboru grup",
diff --git a/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx b/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx
index 4fb0299e6..f616b2b07 100644
--- a/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx
@@ -124,10 +124,53 @@ describe("", () => {
expect(html).toMatch(/data-source="cardio"[^>]*data-active="false"/);
});
- it("shows the per-cluster member-count hint", () => {
+ it("shows the per-cluster member-count hint, singular for one", () => {
const html = render();
// cardio expands to 9 sources, mood to 1 — both surface a count.
expect(html).toContain("9 metrics");
- expect(html).toContain("1 metrics");
+ expect(html).toContain("1 metric<");
+ expect(html).not.toContain("1 metrics");
+ });
+
+ it("never fades a switched-off row (contrast)", () => {
+ // `opacity-60` over muted text fell below WCAG AA in both themes. The
+ // off state is the dashed frame and the switch, the text keeps its
+ // tokens.
+ const html = render();
+ const rows = html.match(/
]*data-slot="coach-sources-row"[^>]*>/g);
+ expect(rows?.length).toBe(10);
+ for (const row of rows ?? []) expect(row).not.toMatch(/opacity/);
+ expect(html).toMatch(
+ /data-source="glucose"[^>]*data-active="false"[^>]*class="[^"]*border-dashed/,
+ );
+ });
+
+ it("names the lookback a limit and states what the default reads", () => {
+ const html = render();
+ expect(html).toContain("How far back the Coach can look");
+ // `allTime` is the default: the sentence carries the numbers the
+ // readers use (`history-reach.ts`).
+ expect(html).toContain(
+ "Everything you recorded: 12 months in daily detail, older readings as monthly values up to 10 years back, lab results from the last 12 months.",
+ );
+ expect(html).not.toContain("stay visible");
+ });
+
+ it("states the limit and what stays visible under a bounded lookback", () => {
+ prefsState.data = { ...DEFAULT_COACH_PREFS, defaultWindow: "last90days" };
+ const html = render();
+ expect(html).toContain(
+ "The Coach reads nothing older than 90 days, in any answer.",
+ );
+ expect(html).toContain("stay visible");
+ expect(html).toContain('aria-describedby="coach-sources-window-detail"');
+ });
+
+ it("offers the twelve-month limit", () => {
+ prefsState.data = { ...DEFAULT_COACH_PREFS, defaultWindow: "lastYear" };
+ const html = render();
+ expect(html).toContain(
+ "The Coach reads nothing older than 12 months, in any answer.",
+ );
});
});
diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx
index 3f286df51..5e6dc6860 100644
--- a/src/components/insights/coach-panel/sources-rail.tsx
+++ b/src/components/insights/coach-panel/sources-rail.tsx
@@ -16,6 +16,11 @@ import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
import { CLUSTER_SOURCES } from "@/lib/ai/coach/clusters";
+import {
+ COACH_HISTORY_WINDOWS,
+ HISTORY_REACH_DAYS,
+ lookbackText,
+} from "@/lib/ai/coach/history-reach";
import {
DEFAULT_COACH_CLUSTERS,
coachDataClusterEnum,
@@ -45,12 +50,12 @@ import {
const CLUSTER_OPTIONS: ReadonlyArray =
coachDataClusterEnum.options;
-const WINDOW_OPTIONS: ReadonlyArray = [
- "last7days",
- "last30days",
- "last90days",
- "allTime",
-];
+/**
+ * The lookback options, narrowest first. The choice is a limit
+ * the server enforces on every Coach read (`history-reach.ts`), so the text
+ * under the picker says what is read, with the numbers the readers use.
+ */
+const WINDOW_OPTIONS = COACH_HISTORY_WINDOWS;
export interface SourcesRailProps {
className?: string;
@@ -67,7 +72,7 @@ export interface SourcesRailProps {
}
export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
- const { t } = useTranslations();
+ const { t, tCount } = useTranslations();
const { data: prefs } = useCoachPrefs();
const save = useSaveCoachPrefs();
@@ -80,6 +85,8 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
prefs?.dataClusters ?? DEFAULT_COACH_CLUSTERS,
);
const activeWindow: CoachDefaultWindow = prefs?.defaultWindow ?? "allTime";
+ const activeText = lookbackText(activeWindow);
+ const bounded = HISTORY_REACH_DAYS[activeWindow] !== null;
const [pending, setPending] = useState(
null,
);
@@ -151,15 +158,15 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
) : null}
- {/* Window selector — persists to `coachPrefs.defaultWindow` so the
- chosen timeframe sticks across drawer opens and matches what
- the cog sheet shows. */}
+ {/* Lookback limit — persists to `coachPrefs.defaultWindow`. The line
+ under the picker states what the Coach reads at this setting, with
+ the numbers the server readers use. */}