Merge branch 'fix/v1402-review' into release/v1.40.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-05 08:42:28 +02:00
commit a1ec3b1264
52 files changed
+1210 -275

No files matched your search

+8 -2
View File
@@ -72483,12 +72483,17 @@ components:
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.
reach today, or when the score on screen differs from the newest stored day in value, band,
algorithm version or composition. Shown in place of a delta, never beside one.
anyOf:
- type: integer
exclusiveMinimum: 0
maximum: 9007199254740991
- type: "null"
steadyAtLeast:
description: "True when the run reaches back past everything the read covered (120 days): `steadyWeeks` is then a lower
bound, said as \"at least\", and the run's start is unknown. Absent on older cached digests."
type: boolean
required:
- value
- band
@@ -72583,7 +72588,8 @@ components:
since v1.40."
line:
type: string
description: "Push / lock-screen line: cached-AI lead with a deterministic floor."
description: "Push / lock-screen line: cached-AI lead with a deterministic floor. Carries no number (no reading, dose or
score), since it is shown on a locked phone; the in-app `lead` keeps its figures."
worthALook:
type: array
items:
+6
View File
@@ -78,6 +78,12 @@ roll back:
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
4. Rolling back from v1.40.2 or later to v1.40.1 or earlier: an account
that saved the Coach lookback "Last year" reads its whole Coach
preference blob as defaults on the older version, metric exclusions
included, until the setting is saved again. Ask affected accounts to
pick another lookback before the rollback, or re-save their exclusions
after it.
## Catalog templates
+62
View File
@@ -238,6 +238,39 @@ async function shot(page: Page, testInfo: TestInfo, name: string) {
if (dir) await page.screenshot({ path: `${dir}/${name}.png` });
}
/**
* Record every mount of the conversations sheet from the first byte on.
* Reads the added nodes rather than the live DOM, so a sheet that mounts and
* unmounts again before the observer's callback runs is still counted.
*/
async function watchSheetMounts(page: Page) {
await page.addInitScript(() => {
const w = window as unknown as { __coachSheetSeen?: boolean };
w.__coachSheetSeen = false;
const sheet = '[role="dialog"][data-slot="coach-conversations-panel"]';
new MutationObserver((records) => {
for (const record of records) {
for (const node of record.addedNodes) {
if (
node instanceof Element &&
(node.matches(sheet) || node.querySelector(sheet))
) {
w.__coachSheetSeen = true;
}
}
}
}).observe(document, { childList: true, subtree: true });
});
}
async function sheetMounted(page: Page): Promise<boolean> {
return page.evaluate(
() =>
(window as unknown as { __coachSheetSeen?: boolean }).__coachSheetSeen ===
true,
);
}
async function openCoach(page: Page, path = "/coach?c=frame-bp") {
await page.goto(path, { waitUntil: "domcontentloaded" });
await expect(
@@ -460,11 +493,18 @@ test.describe("Coach page frame", () => {
await page.addInitScript(() => {
window.localStorage.setItem("healthlog.coach.panelOpen", "false");
});
// The hydration render does not know the viewport yet; the phone sheet
// must not mount on a desktop even for a moment.
await watchSheetMounts(page);
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");
expect(
await sheetMounted(page),
"the phone sheet flashed on a desktop deep link",
).toBe(false);
await expect(
popover.locator('[data-slot="coach-settings-data"]'),
).toBeFocused();
@@ -523,6 +563,28 @@ test.describe("Coach page frame", () => {
});
}
test("390: /coach?settings=data opens the sheet and the settings on what the Coach sees", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockCoach(page);
await watchSheetMounts(page);
await page.goto("/coach?settings=data", { waitUntil: "domcontentloaded" });
// The settings bottom sheet opens over the conversations sheet and
// hides it from the accessibility tree, so the sheet is found by slot.
await expect(page.locator('[data-slot="coach-settings-data"]')).toBeVisible(
{ timeout: 15_000 },
);
await expect(
page.locator('[role="dialog"][data-slot="coach-conversations-panel"]'),
).toBeVisible();
// The positive control for the desktop case: the same watcher sees the
// sheet mount where it belongs.
expect(await sheetMounted(page)).toBe(true);
await expect(page).toHaveURL(/\/coach$/);
});
test("390: the panel is a sheet, the gear a bottom sheet, and nothing scrolls sideways", async ({
page,
}, testInfo) => {
+11 -12
View File
@@ -2922,13 +2922,6 @@
"skin_temp": "Hauttemperatur",
"workouts": "Workouts"
},
"window": {
"last7days": "letzte 7 Tage",
"last30days": "letzte 30 Tage",
"last90days": "letzte 90 Tage",
"lastYear": "letzte 12 Monate",
"allTime": "gesamter Zeitraum (12 Monate im Detail)"
},
"settingsAriaLabel": "Coach-Einstellungen",
"settingsTitle": "Coach-Einstellungen",
"settingsDescription": "Stell ein, wie der Coach mit dir spricht.",
@@ -3019,7 +3012,7 @@
"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."
"note": "Dein Profil, aktuelle Medikamente, eine laufende Erkrankung und anstehende Termine bleiben sichtbar; Auswertungen, die mehr Verlauf brauchen, werden gekürzt oder weggelassen. Antworten, die der Coach früher in älteren Unterhaltungen gegeben hat, können noch ältere Werte nennen."
},
"showConversations": "Unterhaltungen anzeigen",
"hideConversations": "Unterhaltungen ausblenden",
@@ -3138,7 +3131,10 @@
"noProvider": "Kein Anbieter verfügbar",
"saving": "Wird gespeichert…",
"saveFailed": "Die Änderung ließ sich nicht speichern.",
"allSettings": "Alle KI-Einstellungen"
"allSettings": "Alle KI-Einstellungen",
"providerNotActive": "{provider} konnte nicht übernehmen: Es fehlt eine funktionierende Anmeldung oder ein Schlüssel. Richte das in den KI-Einstellungen ein.",
"operatorCodex": "ChatGPT (vom Betreiber bereitgestellt)",
"operatorManaged": "Diese Verbindung verwaltet der Betreiber deines Servers. Sobald einer deiner eigenen Anbieter eingerichtet ist, antwortet er zuerst."
},
"answer": {
"scrollToBottom": "Zur neuesten Nachricht",
@@ -10928,8 +10924,8 @@
},
"daily": {
"line": {
"score": "Dein Gesundheitsscore heute liegt bei {score}.",
"allClear": "Heute braucht nichts deine Aufmerksamkeit."
"allClear": "Heute braucht nichts deine Aufmerksamkeit.",
"scoreReady": "Dein Gesundheitsscore für heute ist da."
},
"push": {
"title": "Dein Morgenüberblick",
@@ -11035,7 +11031,10 @@
"PULSE": "Puls",
"WEIGHT": "Gewicht",
"generic": "Ein Vitalwert"
}
},
"steadyAtLeastWeeksOne": "Seit mindestens {count} Woche stabil",
"steadyAtLeastWeeksFew": "Seit mindestens {count} Wochen stabil",
"steadyAtLeastWeeksOther": "Seit mindestens {count} Wochen stabil"
},
"milestone": {
"metric": {
+11 -12
View File
@@ -2922,13 +2922,6 @@
"skin_temp": "Skin temperature",
"workouts": "Workouts"
},
"window": {
"last7days": "last 7 days",
"last30days": "last 30 days",
"last90days": "last 90 days",
"lastYear": "last 12 months",
"allTime": "all time (12 months of detail)"
},
"settingsAriaLabel": "Coach settings",
"settingsTitle": "Coach settings",
"settingsDescription": "Tune how the Coach talks with you.",
@@ -3019,7 +3012,7 @@
"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."
"note": "Your profile, current medications, an ongoing illness and upcoming visits stay visible; summaries that need more history are shortened or left out. Answers the Coach gave earlier in older conversations may still quote older values."
},
"showConversations": "Show conversations",
"hideConversations": "Hide conversations",
@@ -3138,7 +3131,10 @@
"noProvider": "No provider available",
"saving": "Saving…",
"saveFailed": "That change could not be saved.",
"allSettings": "All AI settings"
"allSettings": "All AI settings",
"providerNotActive": "{provider} could not take over: it has no working sign-in or key. Set it up in AI settings.",
"operatorCodex": "ChatGPT (shared by the operator)",
"operatorManaged": "Your server's operator manages this connection. Once one of your own providers is set up, it answers first."
},
"answer": {
"scrollToBottom": "Jump to the latest message",
@@ -10928,8 +10924,8 @@
},
"daily": {
"line": {
"score": "Your health score today is {score}.",
"allClear": "Nothing needs your attention today."
"allClear": "Nothing needs your attention today.",
"scoreReady": "Your health score for today is ready."
},
"push": {
"title": "Your morning briefing",
@@ -11035,7 +11031,10 @@
"PULSE": "Pulse",
"WEIGHT": "Weight",
"generic": "A vital"
}
},
"steadyAtLeastWeeksOne": "Steady for at least {count} week",
"steadyAtLeastWeeksFew": "Steady for at least {count} weeks",
"steadyAtLeastWeeksOther": "Steady for at least {count} weeks"
},
"milestone": {
"metric": {
+11 -12
View File
@@ -2922,13 +2922,6 @@
"skin_temp": "Temperatura de la piel",
"workouts": "Entrenamientos"
},
"window": {
"last7days": "Últimos 7 días",
"last30days": "Últimos 30 días",
"last90days": "Últimos 90 días",
"lastYear": "últimos 12 meses",
"allTime": "todo el tiempo (12 meses en detalle)"
},
"settingsAriaLabel": "Ajustes del coach",
"settingsTitle": "Ajustes del coach",
"settingsDescription": "Configura cómo te habla el coach.",
@@ -3019,7 +3012,7 @@
"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."
"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. Las respuestas que el Coach dio antes en conversaciones anteriores pueden seguir citando valores más antiguos."
},
"showConversations": "Mostrar conversaciones",
"hideConversations": "Ocultar conversaciones",
@@ -3138,7 +3131,10 @@
"noProvider": "Ningún proveedor disponible",
"saving": "Guardando…",
"saveFailed": "No se pudo guardar el cambio.",
"allSettings": "Todos los ajustes de IA"
"allSettings": "Todos los ajustes de IA",
"providerNotActive": "{provider} no pudo encargarse: no tiene un inicio de sesión ni una clave que funcionen. Configúralo en los ajustes de IA.",
"operatorCodex": "ChatGPT (compartido por el operador)",
"operatorManaged": "El operador de tu servidor gestiona esta conexión. Cuando configures uno de tus propios proveedores, responderá primero."
},
"answer": {
"scrollToBottom": "Ir al último mensaje",
@@ -10928,8 +10924,8 @@
},
"daily": {
"line": {
"score": "Tu puntuación de salud hoy es {score}.",
"allClear": "Hoy nada necesita tu atención."
"allClear": "Hoy nada necesita tu atención.",
"scoreReady": "Tu puntuación de salud de hoy está lista."
},
"push": {
"title": "Tu resumen matinal",
@@ -11035,7 +11031,10 @@
"PULSE": "Pulso",
"WEIGHT": "Peso",
"generic": "Un signo vital"
}
},
"steadyAtLeastWeeksOne": "Estable desde hace al menos {count} semana",
"steadyAtLeastWeeksFew": "Estable desde hace al menos {count} semanas",
"steadyAtLeastWeeksOther": "Estable desde hace al menos {count} semanas"
},
"milestone": {
"metric": {
+11 -12
View File
@@ -2922,13 +2922,6 @@
"skin_temp": "Température cutanée",
"workouts": "Entraînements"
},
"window": {
"last7days": "7 derniers jours",
"last30days": "30 derniers jours",
"last90days": "90 derniers jours",
"lastYear": "12 derniers mois",
"allTime": "toute la période (12 mois en détail)"
},
"settingsAriaLabel": "Paramètres du coach",
"settingsTitle": "Paramètres du coach",
"settingsDescription": "Configure comment le coach te parle.",
@@ -3019,7 +3012,7 @@
"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."
"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. Les réponses données plus tôt par le Coach dans d’anciennes conversations peuvent encore citer des valeurs plus anciennes."
},
"showConversations": "Afficher les conversations",
"hideConversations": "Masquer les conversations",
@@ -3138,7 +3131,10 @@
"noProvider": "Aucun fournisseur disponible",
"saving": "Enregistrement…",
"saveFailed": "Impossible d’enregistrer cette modification.",
"allSettings": "Tous les paramètres IA"
"allSettings": "Tous les paramètres IA",
"providerNotActive": "{provider} n'a pas pu prendre le relais : aucune connexion ni clé valide. Configurez-le dans les réglages IA.",
"operatorCodex": "ChatGPT (partagé par l'opérateur)",
"operatorManaged": "L'opérateur de votre serveur gère cette connexion. Dès qu'un de vos propres fournisseurs est configuré, c'est lui qui répond en premier."
},
"answer": {
"scrollToBottom": "Aller au dernier message",
@@ -10928,8 +10924,8 @@
},
"daily": {
"line": {
"score": "Ton score de santé aujourd'hui est de {score}.",
"allClear": "Rien ne requiert ton attention aujourd'hui."
"allClear": "Rien ne requiert ton attention aujourd'hui.",
"scoreReady": "Ton score de santé du jour est prêt."
},
"push": {
"title": "Votre résumé du matin",
@@ -11035,7 +11031,10 @@
"PULSE": "Pouls",
"WEIGHT": "Poids",
"generic": "Un signe vital"
}
},
"steadyAtLeastWeeksOne": "Stable depuis au moins {count} semaine",
"steadyAtLeastWeeksFew": "Stable depuis au moins {count} semaines",
"steadyAtLeastWeeksOther": "Stable depuis au moins {count} semaines"
},
"milestone": {
"metric": {
+11 -12
View File
@@ -2922,13 +2922,6 @@
"skin_temp": "Temperatura cutanea",
"workouts": "Allenamenti"
},
"window": {
"last7days": "Ultimi 7 giorni",
"last30days": "Ultimi 30 giorni",
"last90days": "Ultimi 90 giorni",
"lastYear": "ultimi 12 mesi",
"allTime": "tutto il tempo (12 mesi in dettaglio)"
},
"settingsAriaLabel": "Impostazioni del coach",
"settingsTitle": "Impostazioni del coach",
"settingsDescription": "Imposta come il coach ti parla.",
@@ -3019,7 +3012,7 @@
"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."
"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. Le risposte date in precedenza dal Coach nelle conversazioni più vecchie possono ancora citare valori più vecchi."
},
"showConversations": "Mostra conversazioni",
"hideConversations": "Nascondi conversazioni",
@@ -3138,7 +3131,10 @@
"noProvider": "Nessun fornitore disponibile",
"saving": "Salvataggio…",
"saveFailed": "Impossibile salvare la modifica.",
"allSettings": "Tutte le impostazioni IA"
"allSettings": "Tutte le impostazioni IA",
"providerNotActive": "{provider} non è subentrato: manca un accesso o una chiave funzionante. Configuralo nelle impostazioni IA.",
"operatorCodex": "ChatGPT (condiviso dall'operatore)",
"operatorManaged": "Questa connessione è gestita dall'operatore del tuo server. Quando configuri uno dei tuoi fornitori, risponde per primo."
},
"answer": {
"scrollToBottom": "Vai all'ultimo messaggio",
@@ -10928,8 +10924,8 @@
},
"daily": {
"line": {
"score": "Il tuo punteggio di salute oggi è {score}.",
"allClear": "Oggi nulla richiede la tua attenzione."
"allClear": "Oggi nulla richiede la tua attenzione.",
"scoreReady": "Il tuo punteggio di salute di oggi è pronto."
},
"push": {
"title": "Il tuo riepilogo mattutino",
@@ -11035,7 +11031,10 @@
"PULSE": "Polso",
"WEIGHT": "Peso",
"generic": "Un parametro vitale"
}
},
"steadyAtLeastWeeksOne": "Stabile da almeno {count} settimana",
"steadyAtLeastWeeksFew": "Stabile da almeno {count} settimane",
"steadyAtLeastWeeksOther": "Stabile da almeno {count} settimane"
},
"milestone": {
"metric": {
+11 -12
View File
@@ -2922,13 +2922,6 @@
"skin_temp": "피부 온도",
"workouts": "운동"
},
"window": {
"last7days": "최근 7일",
"last30days": "최근 30일",
"last90days": "최근 90일",
"lastYear": "최근 12개월",
"allTime": "전체 기간(상세 12개월)"
},
"settingsAriaLabel": "코치 설정",
"settingsTitle": "코치 설정",
"settingsDescription": "코치가 대화하는 방식을 조절해요.",
@@ -3019,7 +3012,7 @@
"detailDays": "코치는 어떤 답변에서도 {days}일보다 오래된 기록은 읽지 않아요.",
"detailMonths": "코치는 어떤 답변에서도 {months}개월보다 오래된 기록은 읽지 않아요.",
"detailAll": "기록한 모든 것: 최근 {dailyMonths}개월은 하루 단위로, 그 이전 측정값은 최대 {years}년 전까지 월별 값으로, 검사 결과는 최근 {labMonths}개월 치를 읽어요.",
"note": "프로필, 현재 복용 중인 약, 진행 중인 질병, 예정된 진료는 계속 보여요. 더 긴 기록이 필요한 요약은 줄이거나 빼요."
"note": "프로필, 현재 복용 중인 약, 진행 중인 질병, 예정된 진료는 계속 보여요. 더 긴 기록이 필요한 요약은 줄이거나 빼요. 예전 대화에서 코치가 이미 한 답변에는 더 오래된 값이 남아 있을 수 있어요."
},
"showConversations": "대화 보기",
"hideConversations": "대화 숨기기",
@@ -3138,7 +3131,10 @@
"noProvider": "사용할 수 있는 제공자가 없어요",
"saving": "저장 중…",
"saveFailed": "변경 사항을 저장하지 못했어요.",
"allSettings": "모든 AI 설정"
"allSettings": "모든 AI 설정",
"providerNotActive": "{provider}(으)로 전환하지 못했습니다. 작동하는 로그인이나 키가 없습니다. AI 설정에서 설정하세요.",
"operatorCodex": "ChatGPT (운영자 공유)",
"operatorManaged": "이 연결은 서버 운영자가 관리합니다. 내 공급자를 하나라도 설정하면 그 공급자가 먼저 답합니다."
},
"answer": {
"scrollToBottom": "최신 메시지로 이동",
@@ -10928,8 +10924,8 @@
},
"daily": {
"line": {
"score": "오늘 건강 점수는 {score}점이에요.",
"allClear": "오늘은 신경 쓸 일이 없어요."
"allClear": "오늘은 신경 쓸 일이 없어요.",
"scoreReady": "오늘의 건강 점수가 준비됐어요."
},
"push": {
"title": "아침 브리핑",
@@ -11035,7 +11031,10 @@
"PULSE": "맥박",
"WEIGHT": "체중",
"generic": "생체 지표"
}
},
"steadyAtLeastWeeksOne": "최소 {count}주째 안정적",
"steadyAtLeastWeeksFew": "최소 {count}주째 안정적",
"steadyAtLeastWeeksOther": "최소 {count}주째 안정적"
},
"milestone": {
"metric": {
+11 -12
View File
@@ -2922,13 +2922,6 @@
"skin_temp": "Temperatura skóry",
"workouts": "Treningi"
},
"window": {
"last7days": "Ostatnie 7 dni",
"last30days": "Ostatnie 30 dni",
"last90days": "Ostatnie 90 dni",
"lastYear": "ostatnie 12 miesięcy",
"allTime": "cały okres (12 miesięcy szczegółowo)"
},
"settingsAriaLabel": "Ustawienia coacha",
"settingsTitle": "Ustawienia coacha",
"settingsDescription": "Ustaw, jak coach do ciebie mówi.",
@@ -3019,7 +3012,7 @@
"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."
"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. Odpowiedzi, których Coach udzielił wcześniej w starszych rozmowach, mogą nadal przytaczać starsze wartości."
},
"showConversations": "Pokaż rozmowy",
"hideConversations": "Ukryj rozmowy",
@@ -3138,7 +3131,10 @@
"noProvider": "Brak dostępnego dostawcy",
"saving": "Zapisywanie…",
"saveFailed": "Nie udało się zapisać zmiany.",
"allSettings": "Wszystkie ustawienia AI"
"allSettings": "Wszystkie ustawienia AI",
"providerNotActive": "{provider} nie mógł przejąć odpowiedzi: brak działającego logowania lub klucza. Skonfiguruj go w ustawieniach AI.",
"operatorCodex": "ChatGPT (udostępniony przez operatora)",
"operatorManaged": "Tym połączeniem zarządza operator Twojego serwera. Gdy skonfigurujesz własnego dostawcę, to on odpowie jako pierwszy."
},
"answer": {
"scrollToBottom": "Przejdź do najnowszej wiadomości",
@@ -10928,8 +10924,8 @@
},
"daily": {
"line": {
"score": "Twój dzisiejszy wynik zdrowia to {score}.",
"allClear": "Dziś nic nie wymaga Twojej uwagi."
"allClear": "Dziś nic nie wymaga Twojej uwagi.",
"scoreReady": "Twój dzisiejszy wynik zdrowia jest gotowy."
},
"push": {
"title": "Twoje poranne podsumowanie",
@@ -11035,7 +11031,10 @@
"PULSE": "Tętno",
"WEIGHT": "Waga",
"generic": "Parametr życiowy"
}
},
"steadyAtLeastWeeksOne": "Stabilnie od co najmniej {count} tygodnia",
"steadyAtLeastWeeksFew": "Stabilnie od co najmniej {count} tygodni",
"steadyAtLeastWeeksOther": "Stabilnie od co najmniej {count} tygodni"
},
"milestone": {
"metric": {
+4
View File
@@ -28,6 +28,7 @@ import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
import { toCycleProfileDTO } from "@/lib/cycle/dto";
import { cyclePrefsSchema } from "@/lib/validations/cycle";
@@ -118,6 +119,9 @@ export const PATCH = apiHandler(async (req: Request) => {
});
const resolved = isCycleEnabled(user.gender, updated);
// The typical lengths, the prediction switch and tracking itself all feed
// the dashboard's cycle line, which is cached in the digest extras cell.
invalidateUserHealthContext(user.id);
await auditLog("user.cycle-prefs.update", {
userId: user.id,
@@ -26,6 +26,12 @@ vi.mock("@/lib/api-handler", () => ({
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: vi.fn(),
probeProviderChain: vi.fn(async () => ({
entries: [],
localOcrEnabled: false,
managedBy: null,
availableTypes: ["codex", "openai", "admin-openai"],
})),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
@@ -46,7 +52,7 @@ vi.mock("@/lib/db", () => ({
}));
import { GET } from "../route";
import { resolveProviderChain } from "@/lib/ai/provider";
import { probeProviderChain, resolveProviderChain } from "@/lib/ai/provider";
import { getLastWorkingProvider } from "@/lib/ai/provider-runner";
import { prisma } from "@/lib/db";
@@ -176,4 +182,37 @@ describe("GET /api/insights/provider-chain", () => {
true,
);
});
it("marks an entry without a credential as unavailable, enabled or not", async () => {
// The Coach's provider select offers only available entries; an entry
// marked available without a key was saved to the front of the chain
// and never answered.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
aiProviderChain: [
{ providerType: "codex", priority: 1, enabled: true },
{ providerType: "anthropic", priority: 2, enabled: true },
{ providerType: "openai", priority: 3, enabled: false },
{ providerType: "local", priority: 4, enabled: true },
],
} as never);
vi.mocked(probeProviderChain).mockResolvedValueOnce({
entries: [{ providerType: "codex", vision: false }],
localOcrEnabled: false,
managedBy: "user",
availableTypes: ["codex", "openai"],
});
vi.mocked(resolveProviderChain).mockResolvedValue([
{ providerType: "codex", instance: { type: "codex" } as never },
]);
vi.mocked(getLastWorkingProvider).mockReturnValue(null);
const res = await (GET as () => Promise<Response>)();
const body = (await res.json()) as Envelope;
expect(body.data?.configuredChain).toEqual([
{ providerType: "codex", enabled: true, available: true },
{ providerType: "anthropic", enabled: true, available: false },
{ providerType: "openai", enabled: false, available: true },
{ providerType: "local", enabled: true, available: false },
]);
});
});
+12 -8
View File
@@ -7,7 +7,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { resolveProviderChain } from "@/lib/ai/provider";
import { probeProviderChain, resolveProviderChain } from "@/lib/ai/provider";
import {
forgetLastWorkingProvider,
getLastWorkingProvider,
@@ -26,11 +26,11 @@ import { providerChainPutSchema } from "@/lib/validations/ai-provider";
* in-process cache, when one is set. Surfaces "Active: codex
* (cached: openai)" so a user can see the runner has rerouted to
* a fallback without digging through logs.
* - `configuredChain`: ordered list of `{providerType, available}`
* entries. `available` is always true here since the chain
* resolver only returns instantiable entries; the field is wired
* for the v1.4.17 UX where unconfigured slots should still render
* in the list with a "needs setup" pill.
* - `configuredChain`: ordered list of `{providerType, enabled,
* available}` entries. `available` is the presence probe's own
* answer for that entry's credential (`probeProviderChain`), enabled
* or not: an entry without a key, address or sign-in is listed but
* cannot answer, so a picker must not offer it.
*
* v1.4.16 phase B2 added the matching `PUT` for the new dropdown-driven
* settings UX — the user reorders/toggles entries in the section and
@@ -58,7 +58,11 @@ export const GET = apiHandler(async () => {
});
const persisted = parseProviderChain(userRow?.aiProviderChain ?? null);
const resolved = await resolveProviderChain(user.id);
const [resolved, presence] = await Promise.all([
resolveProviderChain(user.id),
probeProviderChain(user.id),
]);
const availableTypes = new Set(presence.availableTypes ?? []);
const cached = getLastWorkingProvider(user.id);
annotate({
@@ -77,7 +81,7 @@ export const GET = apiHandler(async () => {
configuredChain: persisted.map((entry) => ({
providerType: entry.providerType,
enabled: entry.enabled,
available: true,
available: availableTypes.has(entry.providerType),
})),
});
});
@@ -185,6 +185,37 @@ describe("<TodayHero>", () => {
expect(html).toContain('data-slot="today-hero-lead"');
});
it("falls back to the stored lines for a digest cached before the lead existed", () => {
// The service worker's offline cache can hand back a digest written by
// the previous version, which carries no `lead` field at all.
const cached = digest({ reactionLine: "The new reading fits your week." });
delete (cached as Partial<DailyDigest>).lead;
const html = render(<TodayHero digest={cached} />);
expect(visibleText(html)).toContain("The new reading fits your week.");
const briefingOnly = digest();
delete (briefingOnly as Partial<DailyDigest>).lead;
expect(visibleText(render(<TodayHero digest={briefingOnly} />))).toContain(
"Your week is trending steady.",
);
});
it("does not resurrect a line the server left out of the lead", () => {
// A current digest with `lead: null` decided there is nothing to say;
// its reaction line (e.g. one that only repeated the score) stays out.
const html = render(
<TodayHero
digest={digest({
lead: null,
briefingLead: null,
topSignal: null,
reactionLine: "Score 82 today.",
})}
/>,
);
expect(visibleText(html)).not.toContain("Score 82 today.");
});
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
@@ -638,6 +669,13 @@ describe("<TodayHero> steady line", () => {
expect(html).not.toContain('data-slot="today-hero-score-steady"');
});
it("says at least when the run reaches past what was read", () => {
const html = render(
<TodayHero digest={steady({ steadyWeeks: 17, steadyAtLeast: true })} />,
);
expect(visibleText(html)).toContain("Steady for at least 17 weeks");
});
it("uses the German plural", () => {
const html = render(<TodayHero digest={steady({})} />, "de");
expect(visibleText(html)).toContain("Seit 4 Wochen stabil");
+14 -5
View File
@@ -141,8 +141,13 @@ export function TodayHero({
// 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;
// renders it verbatim and decides nothing about it. A digest the service
// worker cached before the field existed has no `lead` at all; it falls
// back to the lines that version led with.
const lead =
digest.lead === undefined
? (digest.reactionLine ?? digest.briefingLead ?? null)
: (digest.lead?.text ?? null);
const topSignal = digest.topSignal;
// 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
@@ -174,9 +179,13 @@ export function TodayHero({
const steadyWeeks = digest.score?.steadyWeeks ?? null;
const steadyLine =
!deltaShown && steadyWeeks !== null
? t(pluralKey("daily.today.steadyWeeks", steadyWeeks, locale), {
count: steadyWeeks,
})
? t(
// A run that reaches past what was read has no known start.
digest.score?.steadyAtLeast
? pluralKey("daily.today.steadyAtLeastWeeks", steadyWeeks, locale)
: pluralKey("daily.today.steadyWeeks", steadyWeeks, locale),
{ count: steadyWeeks },
)
: null;
// v1.38 — what the ring's number rests on, when that is less than the
@@ -10,6 +10,7 @@ import {
modelControlFor,
reasoningFieldFor,
selectableProviders,
switchDidNotTake,
} from "../coach-model-picker";
const CHAIN: ProviderChainData["configuredChain"] = [
@@ -72,6 +73,23 @@ describe("selectableProviders", () => {
});
});
describe("switchDidNotTake", () => {
it("is quiet when the chosen provider leads the chain the server resolves", () => {
expect(switchDidNotTake("openai", { activeProvider: "openai" })).toBe(
false,
);
});
it("speaks up when the server still answers with another provider", () => {
// A provider without a working key was saved to the front of the chain;
// the resolver skipped it and the select would jump back silently.
expect(switchDidNotTake("anthropic", { activeProvider: "openai" })).toBe(
true,
);
expect(switchDidNotTake("anthropic", { activeProvider: null })).toBe(true);
});
});
describe("modelControlFor", () => {
it("offers the presets for the provider whose key the person saved", () => {
const control = modelControlFor("openai", userProvider());
@@ -43,11 +43,11 @@ const PROVIDER: UserAIProvider = {
compatReasoningEffort: null,
};
function render(node: React.ReactNode) {
function render(node: React.ReactNode, chain: ProviderChainData = CHAIN) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
client.setQueryData(queryKeys.insightsProviderChain(), CHAIN);
client.setQueryData(queryKeys.insightsProviderChain(), chain);
client.setQueryData(queryKeys.userAiProvider(), PROVIDER);
client.setQueryData(queryKeys.coachPrefs(), DEFAULT_COACH_PREFS);
return renderToStaticMarkup(
@@ -81,6 +81,23 @@ describe("<CoachSettingsBody>", () => {
expect(select).not.toContain('value="codex"');
});
it("shows the operator's central Codex as the active provider when it answers", () => {
// `admin-codex` is never a chain entry, so no option matched it and the
// select showed its first option as the one answering.
const html = render(<CoachSettingsBody />, {
...CHAIN,
activeProvider: "admin-codex" as ProviderChainData["activeProvider"],
});
const select = html.match(
/<select[^>]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/,
)?.[0];
expect(select).toMatch(
/<option value="admin-codex" disabled="" selected="">ChatGPT \(shared by the operator\)<\/option>/,
);
expect(select).not.toMatch(/value="local"[^>]*selected/);
expect(html).toContain('data-slot="coach-quick-provider-operator"');
});
it("shows the model and reasoning controls the active provider owns", () => {
const html = render(<CoachSettingsBody />);
expect(html).toContain('data-slot="coach-quick-model"');
@@ -90,6 +107,17 @@ describe("<CoachSettingsBody>", () => {
expect(html).not.toMatch(/type="password"/);
});
it("locks the reasoning select while a change is saving", async () => {
// Two writes in flight at once could land out of order and lose one;
// the provider and model selects already lock, the reasoning one too.
const source = (await import("node:fs")).readFileSync(
new URL("../coach-model-picker.tsx", import.meta.url),
"utf8",
);
const field = source.match(/<ReasoningEffortField[\s\S]*?\/>/)?.[0];
expect(field).toMatch(/disabled=\{busy\}/);
});
it("names the data section so a deep link can land on it", () => {
const html = render(<CoachSettingsBody />);
expect(html).toMatch(
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { sheetShown } from "../conversations-panel";
/**
* `/coach?settings=data` opens the phone sheet, and only the phone sheet.
* The server and hydration renders take the narrow layout (the viewport is
* not known yet), so a deep link that opened the sheet from the first
* render flashed it on a desktop before the docked panel replaced it.
*/
describe("sheetShown", () => {
const deepLink = { chosen: false, deepLinkPending: true };
it("keeps the sheet shut while the viewport is not known", () => {
expect(sheetShown({ ...deepLink, hydrated: false, docked: false })).toBe(
false,
);
});
it("never opens the sheet for a deep link on a docked viewport", () => {
expect(sheetShown({ ...deepLink, hydrated: true, docked: true })).toBe(
false,
);
});
it("opens the sheet for a deep link on a phone once hydrated", () => {
expect(sheetShown({ ...deepLink, hydrated: true, docked: false })).toBe(
true,
);
});
it("follows the person's own choice otherwise", () => {
const base = { deepLinkPending: false, hydrated: true, docked: false };
expect(sheetShown({ ...base, chosen: true })).toBe(true);
expect(sheetShown({ ...base, chosen: false })).toBe(false);
});
});
@@ -57,8 +57,10 @@ describe("<ScrollToBottomButton>", () => {
expect(src).toContain(
"<ScrollToBottomButton visible={!pinned} onClick={scrollToLatest} />",
);
// Focus leaves the button BEFORE the state that hides it changes: a
// focused control under aria-hidden is announced as nothing.
expect(src).toMatch(
/scrollToLatest = useCallback\([\s\S]*?wasPinnedRef\.current = true;[\s\S]*?focusCoachComposer\(\);/,
/scrollToLatest = useCallback\(\(\) => \{[^}]*?focusCoachComposer\(\);[^}]*?setPinned\(true\);/,
);
});
});
@@ -70,6 +70,31 @@ export function selectableProviders(
.map((entry) => entry.providerType);
}
/**
* True when a saved switch did not take: the chain the server resolves after
* the save is not led by the provider the person chose. A provider whose key
* stops working between the read and the save lands here.
*/
export function switchDidNotTake(
chosen: ProviderType,
fresh: Pick<ProviderChainData, "activeProvider">,
): boolean {
return fresh.activeProvider !== chosen;
}
/**
* The provider the server answers with when the select has no option for it:
* the operator's central Codex (never a chain entry), or an entry that answers
* without being offered. Shown as a disabled option so the select tells the
* truth instead of showing its first option as active.
*/
export function activeOutsideOptions(
active: string | null,
options: readonly string[],
): string | null {
return active !== null && !options.includes(active) ? active : null;
}
export type ModelControl =
| { kind: "preset"; presets: readonly string[]; value: string | null }
| { kind: "gateway"; value: string | null }
@@ -139,6 +164,7 @@ export function CoachModelPicker() {
});
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const onSaved = () => {
setError(null);
@@ -152,11 +178,26 @@ export function CoachModelPicker() {
);
const switchProvider = useMutation({
mutationFn: (provider: ProviderType) =>
apiPut("/api/insights/provider-chain", {
mutationFn: async (provider: ProviderType) => {
setNotice(null);
await apiPut("/api/insights/provider-chain", {
chain: chainWithFirst(chainQuery.data?.configuredChain ?? [], provider),
}),
onSuccess: onSaved,
});
// Read back what the server now resolves, so a switch that did not
// take is said rather than shown as the select jumping back.
return apiGet<ProviderChainData>("/api/insights/provider-chain");
},
onSuccess: (fresh, provider) => {
queryClient.setQueryData(queryKeys.insightsProviderChain(), fresh);
onSaved();
if (switchDidNotTake(provider, fresh)) {
setNotice(
t("insights.coach.frame.providerNotActive", {
provider: t(`settings.ai.providerChain.types.${provider}`),
}),
);
}
},
onError: onFailed,
});
const patchProvider = useMutation({
@@ -173,6 +214,8 @@ export function CoachModelPicker() {
const model = modelControlFor(active, userProvider);
const reasoning = reasoningFieldFor(active, userProvider);
const busy = switchProvider.isPending || patchProvider.isPending;
// The server may answer with `admin-codex`, which the UI's type omits.
const unlisted = activeOutsideOptions(active as string | null, options);
if (chainQuery.isError || providerQuery.isError) {
return (
@@ -231,12 +274,27 @@ export function CoachModelPicker() {
{t("insights.coach.frame.noProvider")}
</option>
) : null}
{unlisted ? (
<option value={unlisted} disabled>
{unlisted === "admin-codex"
? t("insights.coach.frame.operatorCodex")
: t(`settings.ai.providerChain.types.${unlisted}`)}
</option>
) : null}
{options.map((p) => (
<option key={p} value={p}>
{t(`settings.ai.providerChain.types.${p}`)}
</option>
))}
</NativeSelect>
{unlisted === "admin-codex" ? (
<p
data-slot="coach-quick-provider-operator"
className="text-muted-foreground mt-1 text-xs"
>
{t("insights.coach.frame.operatorManaged")}
</p>
) : null}
</div>
{model.kind === "preset" ? (
@@ -286,6 +344,7 @@ export function CoachModelPicker() {
id="coach-quick-reasoning"
noColon
value={reasoning.value}
disabled={busy}
onChange={(next) => patchProvider.mutate({ [reasoning.field]: next })}
/>
) : null}
@@ -295,6 +354,15 @@ export function CoachModelPicker() {
{t("insights.coach.frame.saving")}
</p>
) : null}
{notice ? (
<p
role="status"
data-slot="coach-quick-provider-notice"
className="text-muted-foreground text-xs"
>
{notice}
</p>
) : null}
{error ? (
<p role="alert" className="text-destructive text-xs">
{error}
@@ -1,6 +1,6 @@
"use client";
import { useRef, useState } from "react";
import { useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import {
PanelRightClose,
@@ -48,6 +48,25 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
*/
export const COACH_PANEL_ID = "coach-conversations-panel";
const noSubscription = () => () => {};
/**
* Whether the phone sheet is open. A settings deep link opens it only once
* the viewport is known: the server and hydration renders take the narrow
* (sheet) layout, so opening it from the first render flashed the sheet on
* a desktop before the docked panel replaced it.
*/
export function sheetShown(state: {
chosen: boolean;
deepLinkPending: boolean;
hydrated: boolean;
docked: boolean;
}): boolean {
return (
state.chosen || (state.deepLinkPending && state.hydrated && !state.docked)
);
}
export interface ConversationsPanelProps {
activeId: string | null;
onSelect: (id: string) => void;
@@ -75,7 +94,26 @@ export function ConversationsPanel({
// 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 hydrated = useSyncExternalStore(
noSubscription,
() => true,
() => false,
);
const [deepLinkPending, setDeepLinkPending] = useState(openSettingsOnData);
const [sheetChosen, setSheetChosen] = useState(false);
const sheetOpen = sheetShown({
chosen: sheetChosen,
deepLinkPending,
hydrated,
docked,
});
// On a docked viewport the deep link is served by `revealed`; it must not
// open the sheet later if the window narrows.
if (deepLinkPending && hydrated && docked) setDeepLinkPending(false);
const setSheetOpen = (next: boolean) => {
setDeepLinkPending(false);
setSheetChosen(next);
};
const [settingsOpen, setSettingsOpen] = useState(openSettingsOnData);
const [settingsOnData, setSettingsOnData] = useState(openSettingsOnData);
@@ -394,10 +394,12 @@ export function MessageThread({
const scrollToLatest = useCallback(() => {
const el = scrollerRef.current;
if (!el) return;
// Focus first: pinning hides this button (aria-hidden), and focus must
// not sit on a control that has left the accessibility tree.
focusCoachComposer();
wasPinnedRef.current = true;
setPinned(true);
el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() });
focusCoachComposer();
}, []);
// Auto-scroll on new messages OR streaming-content growth, but only
@@ -14,11 +14,14 @@ import type { ReasoningEffort } from "./shared";
export function ReasoningEffortField({
id,
disabled = false,
value,
onChange,
noColon = false,
}: {
id: string;
/** Locks the select while a write is in flight. */
disabled?: boolean;
value: ReasoningEffort | null;
onChange: (next: ReasoningEffort | null) => void;
/** The Coach's quick settings label their fields without a colon. */
@@ -33,6 +36,7 @@ export function ReasoningEffortField({
<NativeSelect
id={id}
value={value ?? ""}
disabled={disabled}
onChange={(e) =>
onChange(
e.target.value === "" ? null : (e.target.value as ReasoningEffort),
@@ -90,6 +90,7 @@ describe("probeProviderChain", () => {
entries: [],
localOcrEnabled: false,
managedBy: null,
availableTypes: [],
});
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
@@ -123,6 +124,22 @@ describe("probeProviderChain", () => {
expect(presence.managedBy).toBe("user");
});
it("names every type whose credential is present, enabled in the chain or not", async () => {
load(
row({
aiProviderChain: [
{ providerType: "codex", enabled: true, priority: 0 },
{ providerType: "openai", enabled: false, priority: 1 },
{ providerType: "anthropic", enabled: true, priority: 2 },
],
aiOpenaiKeyEncrypted: "enc",
}),
);
const presence = await probeProviderChain("u1", OWNER);
// Codex is not signed in and no Anthropic key is saved: neither can answer.
expect(presence.availableTypes).toEqual(["openai"]);
});
it("marks which entries can read an image", async () => {
load(
row({
@@ -162,6 +179,7 @@ describe("probeProviderChain", () => {
entries: [{ providerType: "admin-openai", vision: true }],
localOcrEnabled: false,
managedBy: "server",
availableTypes: ["admin-openai"],
});
});
+6
View File
@@ -60,6 +60,12 @@ export interface ProviderPresence {
/** The person's in-browser OCR opt-in, which lets a text-only provider read a document. */
localOcrEnabled: boolean;
managedBy: AiProviderManagedBy | null;
/**
* Every chain tag whose credential is present, whether or not the person
* enabled it in their chain: what a chain editor may offer as able to
* answer. Absent reads as none, so a hand-built presence offers nothing.
*/
availableTypes?: readonly string[];
/**
* The record's `aiResponseTimeoutSeconds`, read off the same row. Not a
* presence fact, but the account payload publishes the effective timeout
@@ -178,7 +178,11 @@ vi.mock("@/lib/ai/coach/persistence", () => ({
}));
vi.mock("@/lib/db", () => ({ prisma: {} }));
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
import {
admittedPriorResults,
executeCoachTool,
} from "@/lib/ai/coach/tools/executor";
import { renderPriorResultRefs } from "@/lib/ai/coach/chat-request-builder";
import {
COACH_TOOL_DEFS,
COACH_TOOL_NAMES,
@@ -324,6 +328,44 @@ describe("every Coach tool honours the lookback limit", () => {
}
});
describe("earlier tables answer to the lookback limit", () => {
beforeEach(() => {
vi.useFakeTimers({ now: NOW, toFake: ["Date"] });
});
it("names no earlier table whose range lies beyond the limit", async () => {
// The context line carries the table's window and row count; an
// all-time table stored before the limit was set must not be listed.
const admitted = await admittedPriorResults({
userId: "u1",
prefs: { excludeMetrics: [] },
scope: undefined,
reach: REACH,
prior: PRIOR,
});
const refs = admitted.flatMap((turn) => turn.results.map((r) => r.ref));
expect(refs).toEqual(["r2"]);
const context = renderPriorResultRefs(admitted);
expect(context).toContain("last7days");
expect(context).not.toContain("allTime");
});
it("drops an earlier year-ago table that does not fit", async () => {
const yearAgo = {
...storedTable("r3", "last7days"),
source: { ...storedTable("r3", "last7days").source, period: "yearAgo" },
} as PriorResultTurn["results"][number];
const admitted = await admittedPriorResults({
userId: "u1",
prefs: { excludeMetrics: [] },
scope: undefined,
reach: REACH,
prior: [{ messageId: "m-y", turnIndex: 3, results: [yearAgo] }],
});
expect(admitted).toEqual([]);
});
});
// ── The source ─────────────────────────────────────────────────────────
const ROOT = join(__dirname, "../../../../..");
@@ -422,6 +464,14 @@ describe("the source hands the limit on", () => {
for (const args of calls) expect(args).not.toMatch(/\breach\b/);
});
it("passes the limit when it lists earlier tables for the model", () => {
const calls = walk(COACH_DIR).flatMap((file) =>
callArguments(readFileSync(file, "utf8"), "admittedPriorResults"),
);
expect(calls.length).toBeGreaterThan(0);
for (const args of calls) expect(args).toMatch(/\breach\b/);
});
it("passes the limit from the chat loop and nowhere from MCP", () => {
const loop = callArguments(
readFileSync(join(COACH_DIR, "tools/loop.ts"), "utf8"),
@@ -304,3 +304,42 @@ describe("deriveTension (pure)", () => {
expect(fired!.clinicalOverride).toBe(true);
});
});
describe("buildDerivedSnapshotBlock under the lookback limit", () => {
it("leaves out a metric whose own window does not fit, never shortening it", async () => {
// Readiness reads 30 days. Under a 14-day limit it is left out rather
// than computed over 14 days, where it would disagree with the ring.
compute.mockImplementation(async (args?: { metric?: string }) =>
args?.metric === "READINESS" || args?.metric === "STRESS_SCORE"
? ok({ score: 60, band: "yellow" })
: insufficient,
);
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW, "UTC", {
window: "last30days",
days: 14,
});
const computed = compute.mock.calls.map(
([args]) => (args as { metric: string }).metric,
);
expect(computed).not.toContain("READINESS");
expect(computed).not.toContain("SLEEP_SCORE");
expect(computed).not.toContain("VASCULAR_AGE_DELTA");
expect(computed).toContain("STRESS_SCORE");
for (const [args] of compute.mock.calls) {
const windowDays = (args as { windowDays?: number }).windowDays;
expect(windowDays === undefined || windowDays >= 14).toBe(true);
}
expect(block?.READINESS).toBeUndefined();
expect(block?.STRESS_SCORE).toBeDefined();
});
it("computes nothing under a 7-day limit, where no metric fits", async () => {
compute.mockResolvedValue(ok({ score: 60, band: "yellow" }));
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW, "UTC", {
window: "last7days",
days: 7,
});
expect(compute).not.toHaveBeenCalled();
expect(block).toBeNull();
});
});
@@ -332,3 +332,66 @@ describe("buildGlp1SnapshotBlock injection days in the user's zone", () => {
});
});
});
describe("buildGlp1SnapshotBlock under the lookback limit", () => {
it("leaves dose changes before the limit out and keeps the current dose", async () => {
const now = new Date("2026-10-04T12:00:00.000Z");
prismaMock.medication.findMany.mockResolvedValue([
fakeMedication({
doseChanges: [
{
doseValue: 2.5,
doseUnit: "mg",
effectiveFrom: new Date("2026-06-01T00:00:00.000Z"),
note: "Nausea in the first week",
},
{
doseValue: 5,
doseUnit: "mg",
effectiveFrom: new Date("2026-08-01T00:00:00.000Z"),
note: "Stepped up after review",
},
],
}),
]);
const out = await buildGlp1SnapshotBlock("user-1", now, "UTC", {
window: "last30days",
days: 30,
});
const med = out?.medications[0];
// The current dose is therapy state, not history.
expect(med?.currentDose).toMatchObject({ value: 5, since: "2026-08-01" });
// Both changes lie before the 30-day floor: neither they nor their
// notes reach the prompt.
expect(med?.doseHistory).toEqual([]);
expect(JSON.stringify(out)).not.toContain("Nausea");
expect(JSON.stringify(out)).not.toContain("Stepped up");
});
it("keeps the dose changes inside the limit", async () => {
const now = new Date("2026-10-04T12:00:00.000Z");
prismaMock.medication.findMany.mockResolvedValue([
fakeMedication({
doseChanges: [
{
doseValue: 5,
doseUnit: "mg",
effectiveFrom: new Date("2026-08-01T00:00:00.000Z"),
note: null,
},
{
doseValue: 7.5,
doseUnit: "mg",
effectiveFrom: new Date("2026-09-20T00:00:00.000Z"),
note: null,
},
],
}),
]);
const out = await buildGlp1SnapshotBlock("user-1", now, "UTC", {
window: "last30days",
days: 30,
});
expect(out?.medications[0].doseHistory.map((d) => d.value)).toEqual([7.5]);
});
});
+22 -28
View File
@@ -24,8 +24,7 @@ import {
import { prisma } from "@/lib/db";
import {
UNBOUNDED_REACH,
capDays,
isBounded,
fitsReach,
type CoachHistoryReach,
} from "./history-reach";
@@ -37,10 +36,10 @@ import {
const RECOVERY_DEDUP_WINDOW_DAYS = 14;
/**
* The window each engine reads when called without one (its own
* `DEFAULT_WINDOW_DAYS`). Under a lookback limit each metric is computed over
* the shorter of the two, and an engine that then lacks history reports
* insufficient, which drops the line.
* The window each engine reads (its own `DEFAULT_WINDOW_DAYS`). Under a
* lookback limit a metric whose window does not fit inside it is left out,
* never computed over a shortened window: a 7-day readiness would disagree
* with the 30-day ring the dashboard shows under the same name.
*/
const DERIVED_DEFAULT_WINDOW_DAYS: Readonly<
Partial<Record<DerivedMetricId, number>>
@@ -257,17 +256,12 @@ export async function buildDerivedSnapshotBlock(
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<DerivedSnapshotBlock | null> {
const block: Record<string, DerivedSnapshotEntry> = {};
// Unlimited: no window is passed, so every engine keeps its own default.
const limitedDays = (metric: DerivedMetricId) =>
isBounded(reach)
? {
windowDays: capDays(
DERIVED_DEFAULT_WINDOW_DAYS[metric] ??
COINCIDENT_DEFAULT_WINDOW_DAYS,
reach,
),
}
: {};
// Every engine keeps its own window; one that does not fit is not run.
const fits = (metric: DerivedMetricId) =>
fitsReach(
DERIVED_DEFAULT_WINDOW_DAYS[metric] ?? COINCIDENT_DEFAULT_WINDOW_DAYS,
reach,
);
// v1.21.0 (C3 / D3) — the coincident-deviation flag, fired-only. Computed
// off the one shared profile alongside the scores; fail-soft to null so a
@@ -275,20 +269,20 @@ export async function buildDerivedSnapshotBlock(
// FIRED (≥2 vitals out of band today) — a quiet day adds no entry, keeping
// the snapshot noise-free. D2-8: pass the user's tz so the "today" grouping
// matches the user's calendar day, not UTC's.
const coincidentPromise = computeCoincidentDeviation(userId, profile, {
now,
...(tz ? { tz } : {}),
...(isBounded(reach)
? { windowDays: capDays(COINCIDENT_DEFAULT_WINDOW_DAYS, reach) }
: {}),
}).catch(() => null);
// Its 30-day baseline is left out, like any block, under a shorter limit.
const coincidentPromise = fitsReach(COINCIDENT_DEFAULT_WINDOW_DAYS, reach)
? computeCoincidentDeviation(userId, profile, {
now,
...(tz ? { tz } : {}),
}).catch(() => null)
: Promise.resolve(null);
// The metrics are independent passthrough reads off the one shared profile —
// no ordering dependency — so compute them concurrently. Per-metric fault
// isolation: a transient failure on one must never sink the whole Coach turn,
// so each compute resolves to null on throw rather than rejecting the batch.
const computed = await Promise.all(
SNAPSHOT_METRICS.map(async (metric) => {
SNAPSHOT_METRICS.filter(fits).map(async (metric) => {
try {
return {
metric,
@@ -297,7 +291,6 @@ export async function buildDerivedSnapshotBlock(
userId,
profile,
now,
...limitedDays(metric),
}),
};
} catch {
@@ -351,9 +344,10 @@ export async function buildDerivedSnapshotBlock(
source: "WHOOP",
deletedAt: null,
measuredAt: {
// Both lines exist only when their windows fit, so this 14-day
// probe lies inside the limit too.
gte: new Date(
now.getTime() -
capDays(RECOVERY_DEDUP_WINDOW_DAYS, reach) * 24 * 60 * 60 * 1000,
now.getTime() - RECOVERY_DEDUP_WINDOW_DAYS * 24 * 60 * 60 * 1000,
),
lte: now,
},
+10 -4
View File
@@ -225,9 +225,10 @@ export async function buildGlp1SnapshotBlock(
now: Date = new Date(),
tz: string = DEFAULT_TIMEZONE,
/**
* The Coach's lookback limit. The therapy plan (dose, titration, pens) is
* current state and stays; the last injection and the side-effect tags are
* entries and are read from inside the limit only.
* The Coach's lookback limit. The therapy plan (current dose, schedule,
* pens) is current state and stays; the last injection, the side-effect
* tags and the dose history (each change with its note) are entries and
* are read from inside the limit only.
*/
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<Glp1SnapshotBlock | null> {
@@ -309,7 +310,12 @@ export async function buildGlp1SnapshotBlock(
const genericSafe = sanitizeForPrompt(generic, 80);
const latestChange = med.doseChanges[med.doseChanges.length - 1] ?? null;
const doseHistory: DoseHistoryEntry[] = med.doseChanges.map((dc) => {
// A dose change is an entry like any other: one made before the limit
// is left out with its note. The dose in force stays as `currentDose`.
const changesInReach = med.doseChanges.filter(
(dc) => floor === null || dc.effectiveFrom.getTime() >= floor.getTime(),
);
const doseHistory: DoseHistoryEntry[] = changesInReach.map((dc) => {
const decryptedNote = readNote(dc.noteEncrypted, dc.note);
return {
value: dc.doseValue,
@@ -50,6 +50,7 @@ import {
type CoachToolTurnContext,
} from "@/lib/ai/coach/tools/executor";
import { createResultRefAllocator } from "../refs";
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
import { findUnverifiedCoachNumbers } from "@/lib/ai/coach/coach-prose-grounding";
const NOW = new Date("2026-09-27T10:00:00Z");
@@ -544,6 +545,7 @@ describe("admittedPriorResults", () => {
it("drops a metric the person excluded and keeps what no exclusion names", async () => {
const out = await admittedPriorResults({
userId: "u1",
reach: UNBOUNDED_REACH,
prefs: { excludeMetrics: ["steps"] },
scope: undefined,
prior,
@@ -556,6 +558,7 @@ describe("admittedPriorResults", () => {
it("keeps to the conversation's scope", async () => {
const out = await admittedPriorResults({
userId: "u1",
reach: UNBOUNDED_REACH,
prefs: { excludeMetrics: [] },
scope: { sources: ["steps"] },
prior,
@@ -572,6 +575,7 @@ describe("admittedPriorResults", () => {
resolveModuleMap.mockResolvedValue({ mood: false });
const out = await admittedPriorResults({
userId: "u1",
reach: UNBOUNDED_REACH,
prefs: { excludeMetrics: [] },
scope: undefined,
prior: [
+15 -1
View File
@@ -919,11 +919,17 @@ export function scopeAdmits(
* (`coachExclusions`, `admitCoachSources`), so a metric the person excluded
* after a table was stored is neither listed for the model nor sent to it.
* Tables of other domains (labs) answer to their module when read.
*
* The lookback limit applies the same way: a table whose range reaches past
* it (stored before the limit was set or narrowed) is not listed, because
* its line hands the model the window and the row count of history the
* Coach may no longer read. `show_result` refuses it on the same test.
*/
export async function admittedPriorResults(args: {
userId: string;
prefs: Pick<CoachPrefs, "excludeMetrics">;
scope: CoachScope | undefined;
reach: CoachHistoryReach;
prior: readonly PriorResultTurn[];
}): Promise<PriorResultTurn[]> {
if (args.prior.length === 0) return [];
@@ -943,7 +949,15 @@ export async function admittedPriorResults(args: {
return args.prior
.map((turn) => ({
...turn,
results: turn.results.filter((meta) => admits(meta.source.domain)),
results: turn.results.filter(
(meta) =>
admits(meta.source.domain) &&
tableRangeWithinReach(
meta.source.window,
meta.source.period ?? "current",
args.reach,
),
),
}))
.filter((turn) => turn.results.length > 0);
}
+4 -2
View File
@@ -222,13 +222,15 @@ export async function runTurnModel(args: {
effectiveScope,
ctx.reach,
);
// Earlier tables whose metric the person has since excluded are
// neither named for the model nor reachable through show_result. The
// Earlier tables whose metric the person has since excluded, or whose
// range lies beyond the lookback limit, are neither named for the
// model nor reachable through show_result. The
// scope is the one every tool of the turn reads under.
const priorResults = await admittedPriorResults({
userId,
prefs: ctx.coachPrefs,
scope: inventory.probeScope,
reach: ctx.reach,
prior: args.priorResults ?? [],
});
const requestWith = (tableRules: boolean) =>
+39 -25
View File
@@ -16,7 +16,11 @@ import {
} from "./codex-oauth";
import { isPublicUrl } from "@/lib/validations/notifications";
import { isLocalAiHostAllowed } from "./local-host-allowlist";
import { parseProviderChain, type ProviderChainType } from "./provider-chain";
import {
parseProviderChain,
PROVIDER_CHAIN_TYPES,
type ProviderChainType,
} from "./provider-chain";
import type { ProviderChainResolved } from "./provider-runner";
import {
providerCredentialPolicy,
@@ -917,6 +921,7 @@ const NO_PRESENCE: ProviderPresence = Object.freeze({
entries: [],
localOcrEnabled: false,
managedBy: null,
availableTypes: [],
});
/**
@@ -995,6 +1000,7 @@ export function probeProviderChain(
// A guardian's in-browser OCR is not the record's opt-in to use.
localOcrEnabled: false,
managedBy: adminKey ? "server" : null,
availableTypes: adminKey ? ["admin-openai"] : [],
responseTimeoutSeconds: row.aiResponseTimeoutSeconds,
};
}
@@ -1003,36 +1009,43 @@ export function probeProviderChain(
row.codexConnectionStatus === "connected" &&
!!row.codexAccessTokenEncrypted &&
!!row.codexRefreshTokenEncrypted;
const credentialPresent = (providerType: ProviderChainType): boolean => {
switch (providerType) {
case "codex":
return codexConnected;
case "openai":
return !!row.aiOpenaiKeyEncrypted;
case "anthropic":
return !!row.aiAnthropicKeyEncrypted;
case "local":
return !!row.aiBaseUrl;
case "openai-compatible":
return !!(
row.aiCompatBaseUrl &&
(row.aiCompatModel || row.aiModel)
);
case "admin-openai":
return adminKey;
default:
// `admin-codex` is never resolved from a stored chain entry.
return false;
}
};
const centralCodex =
row.useCentralCodex && appSettingsCentralCodexConnected(settings);
const entries: ProviderEntryPresence[] = [];
for (const entry of parseProviderChain(row.aiProviderChain ?? null)) {
if (!entry.enabled) continue;
const present = (() => {
switch (entry.providerType) {
case "codex":
return codexConnected;
case "openai":
return !!row.aiOpenaiKeyEncrypted;
case "anthropic":
return !!row.aiAnthropicKeyEncrypted;
case "local":
return !!row.aiBaseUrl;
case "openai-compatible":
return !!(
row.aiCompatBaseUrl &&
(row.aiCompatModel || row.aiModel)
);
case "admin-openai":
return adminKey;
default:
// `admin-codex` is never resolved from a stored chain entry.
return false;
}
})();
if (present) entries.push(vision(entry.providerType));
if (credentialPresent(entry.providerType)) {
entries.push(vision(entry.providerType));
}
}
if (row.useCentralCodex && appSettingsCentralCodexConnected(settings)) {
if (centralCodex) {
entries.push(vision("admin-codex"));
}
const availableTypes: string[] = PROVIDER_CHAIN_TYPES.filter((type) =>
type === "admin-codex" ? centralCodex : credentialPresent(type),
);
if (entries.length === 0 && userRowHasProviderCredential(row, adminKey)) {
// The legacy single-provider fallback, tagged as the operator's slot
// the way every consumer of it tags it (the consent gate included).
@@ -1051,6 +1064,7 @@ export function probeProviderChain(
entries,
localOcrEnabled: row.labsLocalOcrEnabled,
managedBy: entries.length > 0 ? managedBy : null,
availableTypes,
responseTimeoutSeconds: row.aiResponseTimeoutSeconds,
};
},
@@ -501,13 +501,12 @@ describe("a settings change is never narrated as a health event", () => {
expect(digest.score).not.toBeNull();
expect(digest.score!.delta).toBeNull();
expect(digest.score!.deltaReason).toBe("config_changed");
// The push body is `digest.line` verbatim. It states today's number
// and nothing about its movement, which is the only honest sentence
// available across a recipe change.
expect(digest.line).toBe(
`Your health score today is ${snapshot.healthScore!.score}.`,
);
expect(digest.line).not.toMatch(/[-−]\s?\d/);
// The push body is `digest.line` verbatim. It says the score is ready
// and nothing about its value or movement: no number reaches a locked
// phone, and no subtraction crosses a recipe change.
expect(snapshot.healthScore).not.toBeNull();
expect(digest.line).toBe("Your health score for today is ready.");
expect(digest.line).not.toMatch(/\d/);
// The rail is the other place a drop could reach a person. Nothing
// in it may be narrating the score either.
expect(digest.worthALook.map((item) => item.kind)).not.toContain(
+8 -3
View File
@@ -41,8 +41,13 @@ export async function readTodayCycle(
? from
: addDays(today, -BBT_WINDOW);
const [profile, cycles, dayLogRows, nightlyTemps] = await Promise.all([
prisma.cycleProfile.findUnique({ where: { userId } }),
// A record that never set up cycle tracking has no profile: one point read
// and done, rather than three history reads that cannot be used.
const profile = await prisma.cycleProfile.findUnique({ where: { userId } });
if (!profile) return null;
// The whole history, as the calendar route reads it: the forecast rests on
// every observed cycle, and this read must agree with the cycle page.
const [cycles, dayLogRows, nightlyTemps] = await Promise.all([
prisma.menstrualCycle.findMany({
where: { userId, deletedAt: null },
orderBy: { startDate: "asc" },
@@ -74,7 +79,7 @@ export async function readTodayCycle(
select: { measuredAt: true, value: true },
}),
]);
if (!profile || cycles.length === 0) return null;
if (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.
@@ -105,10 +105,10 @@ describe("buildDailyBriefingPush", () => {
const digest = makeDigest({
briefingLead: null,
topSignal: null,
line: "Your health score today is 82.",
line: "Your health score for today is ready.",
});
const { body } = buildDailyBriefingPush(digest, t);
expect(body).toBe("Your health score today is 82.");
expect(body).toBe("Your health score for today is ready.");
});
it("provisional: appends the honest sleep-pending wording", () => {
+61 -6
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { buildDailyBriefingPush } from "@/lib/daily/daily-briefing-push";
import type { DailyBriefing } from "@/lib/ai/schema";
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
import {
@@ -115,6 +116,7 @@ describe("buildDailyDigest — composition", () => {
band: "good",
delta: 3,
steadyWeeks: null,
steadyAtLeast: false,
});
expect(d.topSignal?.headline).toBe("Blood pressure is holding steady");
expect(d.briefingLead).toBe(
@@ -135,11 +137,54 @@ describe("buildDailyDigest — composition", () => {
expect(d.line).toBe("Blood pressure is holding steady");
});
it("falls back to a deterministic score floor when no briefing exists", () => {
it("falls back to a score floor without the number when no briefing exists", () => {
const d = buildDailyDigest(input({ briefing: null }), t);
expect(d.topSignal).toBeNull();
expect(d.briefingLead).toBeNull();
expect(d.line).toBe("Your health score today is 82.");
expect(d.line).toBe("Your health score for today is ready.");
});
it("keeps concrete values off the lock screen and in the app", () => {
// The briefing's first sentence names a reading. In the app it leads the
// hero with its number; the push line, shown on a locked phone, skips
// it for the next sentence source that carries none.
const d = buildDailyDigest(
input({
briefing: {
...briefing,
paragraph:
"Your blood pressure averaged 128/82 this week. Sleep dipped slightly last night.",
signalsOfDay: [
{
...briefing.signalsOfDay![0],
headline: "Resting pulse at 52 bpm",
},
],
},
}),
t,
);
expect(d.lead?.text).toBe("Your blood pressure averaged 128/82 this week.");
expect(d.line).not.toMatch(/\d/);
expect(d.line).toBe("Your health score for today is ready.");
// The push is built from that line, final or provisional.
expect(buildDailyBriefingPush(d, t).body).not.toMatch(/\d/);
expect(
buildDailyBriefingPush({ ...d, sleepPending: true }, t).body,
).not.toMatch(/\d/);
});
it("uses a numberless headline for the push when the lead has a number", () => {
const d = buildDailyDigest(
input({
briefing: {
...briefing,
paragraph: "Your resting heart rate is 52 this morning.",
},
}),
t,
);
expect(d.line).toBe("Blood pressure is holding steady");
});
it("degrades to the honest all-clear line with neither briefing nor score", () => {
@@ -1328,7 +1373,7 @@ describe("buildDailyDigest — AI text follows its capability", () => {
expect(d.briefingLead).toBeNull();
expect(d.topSignal).toBeNull();
// The push line falls to the deterministic floor, never to model text.
expect(d.line).toBe("Your health score today is 82.");
expect(d.line).toBe("Your health score for today is ready.");
expect(d.ai.briefing.reason).toBe(reason);
},
);
@@ -1512,8 +1557,9 @@ describe("buildDailyDigest — Today overview", () => {
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);
// The lead names the reading in the app. The push line is shown on a
// locked phone and carries no value, so it does not take that sentence.
expect(d.line).toBe("Your health score for today is ready.");
});
it("publishes Rest Mode and its fact only while the illness module is on", () => {
@@ -1564,7 +1610,16 @@ describe("buildDailyDigest — Today overview", () => {
});
it("carries the steady run on the score", () => {
const d = buildDailyDigest(input({ scoreSteadyWeeks: 5 }), t);
const d = buildDailyDigest(
input({ scoreSteady: { weeks: 5, atLeast: false } }),
t,
);
expect(d.score?.steadyWeeks).toBe(5);
expect(d.score?.steadyAtLeast).toBe(false);
const open = buildDailyDigest(
input({ scoreSteady: { weeks: 17, atLeast: true } }),
t,
);
expect(open.score?.steadyAtLeast).toBe(true);
});
});
@@ -81,6 +81,7 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
import { prisma } from "@/lib/db";
@@ -617,6 +618,18 @@ describe("loadDailyDigest — Today overview inputs", () => {
expect(digest.restMode).toBeNull();
});
it("reads the cycle once per extras cell, and again after a cycle write", async () => {
// The digest polls every 120 s. The cycle read (the whole cycle history,
// 90 days of day logs and wrist temperature) rides the cached cell, which
// a cycle write hard-evicts.
await loadDailyDigest(USER, NOW);
await loadDailyDigest(USER, NOW);
expect(readTodayCycle).toHaveBeenCalledTimes(1);
invalidateUserHealthContext(USER.id);
await loadDailyDigest(USER, NOW);
expect(readTodayCycle).toHaveBeenCalledTimes(2);
});
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);
+53 -2
View File
@@ -1,6 +1,10 @@
import { describe, it, expect } from "vitest";
import { steadyWeeks, type StoredScoreDay } from "@/lib/daily/score-steady";
import {
STEADY_READ_DAYS,
steadyRun,
type StoredScoreDay,
} from "@/lib/daily/score-steady";
import { addDays } from "@/lib/cycle/day-math";
const TODAY = "2026-10-04";
@@ -21,7 +25,16 @@ function run(
}));
}
describe("steadyWeeks", () => {
/** The run's weeks against a ring showing `value` in the green band. */
function steadyWeeks(
rows: StoredScoreDay[],
today: string,
value: number,
): number | null {
return steadyRun(rows, today, { value, band: "GREEN" })?.weeks ?? null;
}
describe("steadyRun", () => {
it("counts whole weeks of a held score", () => {
expect(steadyWeeks(run(29), TODAY, 94)).toBe(4);
});
@@ -76,4 +89,42 @@ describe("steadyWeeks", () => {
expect(steadyWeeks(run(29).reverse(), TODAY, 94)).toBe(4);
expect(steadyWeeks([], TODAY, 94)).toBeNull();
});
it("says nothing when the ring's band is not the record's", () => {
// Same number, but the worst pillar pulled the live band down overnight:
// the score did not hold where it is.
expect(steadyRun(run(29), TODAY, { value: 94, band: "yellow" })).toBeNull();
// Case is the enum's, not a difference.
expect(steadyRun(run(29), TODAY, { value: 94, band: "green" })?.weeks).toBe(
4,
);
});
it("says nothing when the ring is computed another way than the record", () => {
expect(
steadyRun(run(29), TODAY, { value: 94, band: "GREEN", scoreVersion: 5 }),
).toBeNull();
expect(
steadyRun(run(29), TODAY, {
value: 94,
band: "GREEN",
composition: ["BLOOD_PRESSURE"],
}),
).toBeNull();
});
it("says at least, not since, when the run reaches past what was read", () => {
const all = run(STEADY_READ_DAYS + 1);
const out = steadyRun(all, TODAY, { value: 94, band: "GREEN" });
expect(out).toEqual({
weeks: Math.floor(STEADY_READ_DAYS / 7),
atLeast: true,
});
// A run that ended inside the read has a start and says so.
const ended = all.map((r, i) => (i >= 40 ? { ...r, composite: 80 } : r));
expect(steadyRun(ended, TODAY, { value: 94, band: "GREEN" })).toEqual({
weeks: 5,
atLeast: false,
});
});
});
@@ -149,6 +149,37 @@ describe("firstSubstantiveSentence", () => {
).toBe("Sleep dipped slightly last night.");
});
it("keeps a sentence where the score's digits are part of another figure", () => {
// Score 52, and a sleep duration that happens to end in 52 minutes.
expect(
firstSubstantiveSentence("You slept 7 h 52 min, a solid night.", 52),
).toBe("You slept 7 h 52 min, a solid night.");
expect(
firstSubstantiveSentence("Your resting heart rate is 52 bpm.", 52),
).toBe("Your resting heart rate is 52 bpm.");
expect(
firstSubstantiveSentence("You walked 52 minutes more than usual.", 52),
).toBe("You walked 52 minutes more than usual.");
});
it("still skips the score sentence in every shipped language", () => {
for (const sentence of [
"Your health score is 52.",
"Dein Gesundheitsscore heute liegt bei 52.",
"Tu puntuación de salud hoy es 52.",
"Ton score de santé aujourd'hui est de 52.",
"Il tuo punteggio di salute oggi è 52.",
"오늘 건강 점수는 52점이에요.",
"Twój dzisiejszy wynik zdrowia to 52.",
"You are at 52/100 today.",
]) {
expect(
firstSubstantiveSentence(`${sentence} Sleep dipped last night.`, 52),
sentence,
).toBe("Sleep dipped last night.");
}
});
it("keeps decimals inside one sentence", () => {
expect(
firstSubstantiveSentence("You slept 7.5 hours, a solid night.", null),
+5 -4
View File
@@ -16,10 +16,11 @@
* `ok` ledger row; the frequency-cap check suppresses the second — one push per
* user per local day, no "you haven't opened the app" second push, ever.
*
* Privacy + calm posture (load-bearing): the body is `digest.line`, which is
* non-clinical by construction (a briefing lead sentence, the top signal's
* headline, a 0–100 score statement, or the honest all-clear — never a raw BP /
* glucose figure). It is dispatched NON-URGENT, so it never escalates to a
* Privacy + calm posture (load-bearing): the body is `digest.line`, which
* carries no number by construction (a briefing lead sentence or the top
* signal's headline without a figure in it, "your score is ready", or the
* honest all-clear — never a reading, a dose or the score itself, since the
* push lands on a locked phone). It is dispatched NON-URGENT, so it never escalates to a
* Focus-bypassing / time-sensitive delivery — a concerning reading always stays
* on `MEASUREMENT_ANOMALY`, never here. It fires ONLY inside a local morning
* window, which is the quiet-hours guarantee for this nudge.
+30 -7
View File
@@ -56,6 +56,7 @@ import {
} 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 type { SteadyRun } from "@/lib/daily/score-steady";
import {
buildTodayOverview,
type TodayCycle,
@@ -138,6 +139,11 @@ export interface DailyDigestScore {
* stay valid.
*/
steadyWeeks?: number | null;
/**
* True when the run reaches back past everything the read covered, so
* `steadyWeeks` is a lower bound ("at least"), not the run's start.
*/
steadyAtLeast?: boolean;
}
/** A broken integration, deterministically derived from `IntegrationStatus`. */
@@ -404,8 +410,8 @@ export interface DailyDigestInput {
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;
/** How long the score has held; see `DailyDigestScore.steadyWeeks`. */
scoreSteady?: SteadyRun | null;
}
export interface DailyDigest {
@@ -1003,6 +1009,9 @@ function buildEcgNewRecordingItem(
};
}
/** Whether a sentence carries a figure: a reading, a dose, a score. */
const HAS_NUMBER = /\p{Nd}/u;
/**
* 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
@@ -1011,6 +1020,11 @@ function buildEcgNewRecordingItem(
* 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.
*
* The line is shown on a locked phone, so it carries no number: a candidate
* with a figure in it is passed over for the next, and the score floor says
* the score is ready rather than what it is. The hero's lead keeps its
* numbers; it is read inside the app.
*/
function composeLine(
overview: { briefingLead: string | null; lead: TodayLead | null },
@@ -1018,10 +1032,15 @@ function composeLine(
score: DailyDigestScore | null,
t: Translate,
): string {
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 });
const candidates = [
overview.briefingLead,
topSignal?.headline?.trim() || null,
overview.lead?.source === "signal" ? overview.lead.text : null,
];
for (const candidate of candidates) {
if (candidate && !HAS_NUMBER.test(candidate)) return candidate;
}
if (score) return t("daily.line.scoreReady");
return t("daily.line.allClear");
}
@@ -1250,7 +1269,11 @@ export function buildDailyDigest(
);
const score: DailyDigestScore | null = input.score
? { ...input.score, steadyWeeks: input.scoreSteadyWeeks ?? null }
? {
...input.score,
steadyWeeks: input.scoreSteady?.weeks ?? null,
steadyAtLeast: input.scoreSteady?.atLeast ?? false,
}
: null;
return {
+51 -29
View File
@@ -79,7 +79,7 @@ import {
type UnitPreferences,
} from "@/lib/measurements/display-transform";
import { resolveRestMode } from "@/lib/illness/rest-mode";
import { readTodayCycle } from "@/lib/cycle/today-verdict";
import { readTodayCycle, type TodayCycleRead } from "@/lib/cycle/today-verdict";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import { makeFormatters, resolveIntlLocale } from "@/lib/format-locale";
import type {
@@ -88,7 +88,7 @@ import type {
} from "@/lib/format-locale";
import {
STEADY_READ_DAYS,
steadyWeeks,
steadyRun,
type StoredScoreDay,
} from "@/lib/daily/score-steady";
import type {
@@ -243,6 +243,13 @@ interface DailyDigestExtras {
vitals: ExtrasVital[];
/** The stored daily scores the "steady for" line is read from. */
scoreDays: StoredScoreDay[];
/**
* Today's place in the cycle, read only while the cycle module is on. A
* cycle write hard-evicts the cell (`invalidateUserHealthContext`), and a
* module switch does too (`invalidateUserHealthScore`), so a cached
* answer never outlives the rows or the switch it was read under.
*/
cycle: TodayCycleRead | null;
}
interface ExtrasVital {
@@ -391,6 +398,7 @@ async function loadDailyDigestExtrasCached(
timezone: string,
todayLocalDate: string,
now: Date,
cycleOn: boolean,
): Promise<DailyDigestExtras> {
return cachedSwr(
caches.analytics as ServerCache<DailyDigestExtras>,
@@ -400,7 +408,7 @@ 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, vitals, scoreDays] =
const [milestone, tensionWindow, sameTime, vitals, scoreDays, cycle] =
await Promise.all([
gatherFreshMilestone(userId, timezone, now),
loadIntradayPulse(userId, timezone, todayLocalDate)
@@ -432,8 +440,13 @@ async function loadDailyDigestExtrasCached(
.catch(() => null),
gatherVitals(userId, timezone, now),
gatherScoreDays(userId, todayLocalDate),
// The whole cycle history, 90 days of day logs and wrist
// temperature: too much to read on every 120 s poll.
cycleOn
? readTodayCycle(userId, timezone, now).catch(() => null)
: Promise.resolve(null),
]);
return { milestone, tensionWindow, sameTime, vitals, scoreDays };
return { milestone, tensionWindow, sameTime, vitals, scoreDays, cycle };
},
annotate,
DIGEST_EXTRAS_CACHE_TTL_MS,
@@ -761,19 +774,12 @@ export async function loadDailyDigest(
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),
]);
// Rest Mode is read per request; today's cycle day rides the extras cell
// below, which every cycle write hard-evicts. The read is skipped outright
// when its module is off, and fault-isolated, because the digest is a
// must-not-fail path.
const restModeContext =
modules.illness !== false ? await resolveRestMode(user.id, now) : null;
const restMode: TodayRestMode | null =
restModeContext?.active && restModeContext.since
? {
@@ -787,9 +793,6 @@ export async function loadDailyDigest(
) + 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
@@ -845,13 +848,23 @@ 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, vitals, scoreDays } =
await loadDailyDigestExtrasCached(
user.id,
user.timezone,
todayLocalDate,
now,
);
const {
milestone,
tensionWindow,
sameTime,
vitals,
scoreDays,
cycle: cycleRead,
} = await loadDailyDigestExtrasCached(
user.id,
user.timezone,
todayLocalDate,
now,
modules.cycle !== false,
);
const cycle: TodayCycle | null = cycleRead
? { dayOfCycle: cycleRead.dayOfCycle, phase: cycleRead.phase }
: null;
// Dismiss ledger (P — Today rail dismiss). Only the OBSERVATIONAL kinds
// (milestone / ecg_new_recording / tension_window) can ever be dismissed, so
@@ -951,8 +964,17 @@ export async function loadDailyDigest(
toTodayVital(v, units, resolvedLocale, t),
),
cycle,
scoreSteadyWeeks: score
? steadyWeeks(scoreDays ?? [], todayLocalDate, score.value)
scoreSteady: score
? steadyRun(scoreDays ?? [], todayLocalDate, {
value: score.value,
band: score.band,
...(score.scoreVersion !== undefined
? { scoreVersion: score.scoreVersion }
: {}),
...(score.composition !== undefined
? { composition: score.composition }
: {}),
})
: null,
},
t,
+71 -16
View File
@@ -10,11 +10,12 @@
*
* 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
* {@link STEADY_TOLERANCE} points of the newest value, and the newest stored
* day must match the score on screen the same way. 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";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
/** Points either side of the newest value that still count as "the same". */
export const STEADY_TOLERANCE = 2;
@@ -40,38 +41,92 @@ function sameComposition(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((id, i) => id === b[i]);
}
/** The score the ring shows, as the run is compared against it. */
export interface ShownScore {
value: number;
band: string;
/** Absent on an older snapshot; then only value and band are compared. */
scoreVersion?: number;
composition?: readonly string[];
}
/** How long the score has held. */
export interface SteadyRun {
/** Whole weeks the run covers, counted over stored days that were read. */
weeks: number;
/**
* True when the run reaches the start of what was read
* ({@link STEADY_READ_DAYS}): the score has held at least `weeks`, and when
* it started is not known. Said as "at least", never as a start.
*/
atLeast: boolean;
}
function sameBand(a: string, b: string): boolean {
return a.toLowerCase() === b.toLowerCase();
}
/**
* Whole weeks the score has held, or null when it has not held for at least
* How long 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.
* the score on screen in value, band and, where the screen says, algorithm
* version and composition: the ring is computed live, the record nightly,
* and a run that ended overnight is not "steady" next to a number, or a band,
* that has moved.
*/
export function steadyWeeks(
export function steadyRun(
rows: readonly StoredScoreDay[],
todayKey: string,
/** The number the ring shows; a run that does not end there is not told. */
shownValue: number,
): number | null {
/** The score the ring shows; a run that does not end there is not told. */
shown: ShownScore,
): SteadyRun | 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;
if (Math.abs(newest.composite - shown.value) > STEADY_TOLERANCE) return null;
if (!sameBand(newest.band, shown.band)) return null;
if (
shown.scoreVersion !== undefined &&
newest.scoreVersion !== shown.scoreVersion
) {
return null;
}
if (
shown.composition !== undefined &&
!sameComposition(newest.composition, shown.composition)
) {
return null;
}
let oldest = newest;
let broke = false;
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;
if (
dayDiff(oldest.dayKey, row.dayKey) > STEADY_MAX_GAP_DAYS ||
row.scoreVersion !== newest.scoreVersion ||
!sameComposition(row.composition, newest.composition) ||
!sameBand(row.band, newest.band) ||
Math.abs(row.composite - newest.composite) > STEADY_TOLERANCE
) {
broke = true;
break;
}
oldest = row;
}
const weeks = Math.floor(dayDiff(todayKey, oldest.dayKey) / 7);
return weeks >= STEADY_MIN_WEEKS ? weeks : null;
if (weeks < STEADY_MIN_WEEKS) return null;
// Nothing ended the run inside what was read, and what was read ends
// within a bridgeable gap of the read's own start: the run may go back
// further than anything here can tell.
const atLeast =
!broke &&
dayDiff(oldest.dayKey, addDays(todayKey, -STEADY_READ_DAYS)) <=
STEADY_MAX_GAP_DAYS;
return { weeks, atLeast };
}
+22 -1
View File
@@ -221,11 +221,32 @@ export function isGreetingOnly(sentence: string): boolean {
return words.length < MIN_CONTENT_WORDS;
}
/**
* Words that name the health score in every shipped language, and the
* "out of 100" forms. A sentence repeats the ring only when it talks about
* the score; a figure that merely shares its digits ("7 h 52 min" next to a
* score of 52) is content.
*/
const SCORE_CONTEXT =
/(?<![\p{L}])(scores?|gesundheitsscore|punteggio|puntuaci[oó]n|puntaje|wynik(u)?)(?![\p{L}])|점수|\/\s*100(?!\d)|(out of|von|sur|su|de|na|z)\s+100(?!\d)/iu;
/** A unit right after a number makes it a measurement, not the score. */
const UNIT_AFTER =
/^\s*(h|hrs?|hours?|min(s|utes?|uten?|utos?|uti|ut)?|sec|s|ms|bpm|%|kg|lbs?|mmhg|mg|mmol|cm|km|m|steps?|schritte|std|stunden?|horas?|heures?|ore|godz(in)?|시간|분|°)(?![\p{L}])/iu;
/** Whether a sentence repeats the number the ring already shows. */
function repeatsScore(sentence: string, score: number | null): boolean {
if (score === null) return false;
if (!SCORE_CONTEXT.test(sentence)) return false;
const shown = String(Math.round(score));
return new RegExp(`(^|[^0-9])${shown}([^0-9]|$)`).test(sentence);
// The number on its own: not part of a longer figure, a decimal or a time,
// and not followed by a unit.
const standalone = new RegExp(`(?<![\\d.,:])${shown}(?![\\d]|[.,:]\\d)`, "g");
for (const match of sentence.matchAll(standalone)) {
const after = sentence.slice((match.index ?? 0) + shown.length);
if (!UNIT_AFTER.test(after)) return true;
}
return false;
}
function clampLead(sentence: string): string {
@@ -181,6 +181,34 @@ describe("buildMedsTodayBlock — projection contract", () => {
});
});
describe("buildMedsTodayBlock — the 25-hour day", () => {
it("covers the last local hour on the day the clocks go back", async () => {
// 2026-10-25 in Berlin runs 25 hours: CEST midnight is 22:00Z the
// evening before, the next CET midnight 23:00Z. A dose at 23:30 local
// (22:30Z) lies inside the day and must be counted.
const dstNow = new Date("2026-10-25T20:00:00.000Z");
await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, dstNow);
const arg = projectTodayIntakesAndRecompute.mock.calls[0][0] as {
todayStart: Date;
todayEnd: Date;
};
expect(arg.todayStart.toISOString()).toBe("2026-10-24T22:00:00.000Z");
expect(arg.todayEnd.toISOString()).toBe("2026-10-25T23:00:00.000Z");
const where = capturedTodayWhere as {
scheduledFor: { gte: Date; lt: Date };
};
expect(where.scheduledFor.lt.toISOString()).toBe(
"2026-10-25T23:00:00.000Z",
);
expect(
where.scheduledFor.lt.getTime() >
new Date("2026-10-25T22:30:00.000Z").getTime(),
).toBe(true);
});
});
describe("buildMedsTodayBlock — tally", () => {
it("counts scheduled / taken / skipped with the summary-tile semantics", async () => {
todayRows = [
+5 -4
View File
@@ -104,10 +104,11 @@ export async function buildMedsTodayBlock(
): Promise<MedsTodayBlock> {
const { start: todayStart, end: todayEnd } = getUserTodayBounds(now, userTz);
// The projector + tally read use an exclusive upper bound (`lt`),
// matching the summary route's window convention.
const todayEndExclusive = new Date(
todayStart.getTime() + 24 * 60 * 60 * 1000,
);
// matching the summary route's window convention. `todayEnd` is the
// inclusive last millisecond of the real local day, so +1 ms is the next
// day's start: 23 or 25 hours on a DST day, where a fixed 24 h missed the
// last hour of the 25-hour day.
const todayEndExclusive = new Date(todayEnd.getTime() + 1);
// Projection FIRST so the tally read below sees the freshly minted
// pending rows. Idempotent — `skipDuplicates` + the
@@ -231,17 +231,25 @@ export async function computeCoincidentDeviation(
const todayKey = userDayKey(now, tz);
// Fan the baseline engine across the supported vitals. Each baseline read
// shares the one coverage probe (no per-vital re-probe).
for (const type of VITALS_BASELINE_TYPES) {
const baseline = await computeVitalsBaseline(userId, profile, {
type,
windowDays,
now,
coverage,
});
// shares the one coverage probe (no per-vital re-probe). The types are
// independent, so their reads run together; the results are folded in the
// registry's order, so the output does not depend on which finished first.
const perType = await Promise.all(
VITALS_BASELINE_TYPES.map(async (type) => {
const baseline = await computeVitalsBaseline(userId, profile, {
type,
windowDays,
now,
coverage,
});
if (baseline.status !== "ok") return { type, baseline, latest: null };
const latest = await readLatestDayMean(userId, type, windowDays, now, tz);
return { type, baseline, latest };
}),
);
for (const { type, baseline, latest } of perType) {
if (baseline.status !== "ok") continue;
if (baseline.provenance.source === "DAY") anyDaySource = true;
const latest = await readLatestDayMean(userId, type, windowDays, now, tz);
if (!latest) continue;
if (latest.day > latestDay) latestDay = latest.day;
if (baseline.coverage.historyDays > maxHistoryDays) {
+8 -2
View File
@@ -133,7 +133,13 @@ export const dailyDigestResponse = z
.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.",
"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 on screen differs from the newest stored day in value, band, algorithm version or composition. Shown in place of a delta, never beside one.",
),
steadyAtLeast: z
.boolean()
.optional()
.describe(
'True when the run reaches back past everything the read covered (120 days): `steadyWeeks` is then a lower bound, said as "at least", and the run\'s start is unknown. Absent on older cached digests.',
),
})
.nullable()
@@ -192,7 +198,7 @@ export const dailyDigestResponse = z
line: z
.string()
.describe(
"Push / lock-screen line: cached-AI lead with a deterministic floor.",
"Push / lock-screen line: cached-AI lead with a deterministic floor. Carries no number (no reading, dose or score), since it is shown on a locked phone; the in-app `lead` keeps its figures.",
),
worthALook: z
.array(priorityItemSchema)
@@ -154,13 +154,30 @@ describe("parseCoachPrefs", () => {
expect(out.dataClusters).toEqual([]);
});
it("falls back to defaults when dataClusters carries an unknown cluster", () => {
// Shape drift — an unknown cluster string fails the enum, the whole
// parse fails, and parseCoachPrefs returns the legacy defaults
// (dataClusters undefined → legacy cluster expansion).
it("keeps the known clusters when dataClusters carries an unknown one", () => {
// Shape drift: a cluster a newer version added. Falling back to the
// legacy default set would widen what the Coach reads past what the
// person chose; dropping only the unknown entry narrows it.
const out = parseCoachPrefs({ dataClusters: ["cardio", "astrology"] });
expect(out).toEqual(DEFAULT_COACH_PREFS);
expect(out.dataClusters).toBeUndefined();
expect(out.dataClusters).toEqual(["cardio"]);
});
it("lets an invalid field fall back alone and keeps the exclusions", () => {
// A window value this version does not know (written by a newer one,
// read after a rollback) must not take the metric exclusions with it.
const out = parseCoachPrefs({
tone: "neutral",
defaultWindow: "lastDecade",
excludeMetrics: ["mood", "weight"],
});
expect(out.defaultWindow).toBe("allTime");
expect(out.excludeMetrics).toEqual(["mood", "weight"]);
expect(out.tone).toBe("neutral");
});
it("keeps the known exclusions when the list carries an unknown metric", () => {
const out = parseCoachPrefs({ excludeMetrics: ["mood", "aura"] });
expect(out.excludeMetrics).toEqual(["mood"]);
});
it("DEFAULT_COACH_CLUSTERS preserves the legacy five domains' clusters", () => {
+37 -6
View File
@@ -216,13 +216,44 @@ export const DEFAULT_COACH_PREFS: CoachPrefs = {
/**
* Parse a row's `coachPrefsJson` Json blob into a typed `CoachPrefs`,
* falling back to defaults when the row is null OR the persisted shape
* has drifted (a forward-compat field rename, an admin-side hand-edit,
* etc.). Keeps the call sites at `src/lib/ai/coach/snapshot.ts` and
* `src/lib/ai/coach/system-prompt.ts` free of the null/parse plumbing.
* falling back to defaults when the row is null. Keeps the call sites at
* `src/lib/ai/coach/snapshot.ts` and `src/lib/ai/coach/system-prompt.ts`
* free of the null/parse plumbing.
*
* A drifted shape (a value a newer version wrote and this one reads after a
* rollback, a hand-edit) is salvaged field by field: an invalid field falls
* back to its own default and every valid one stays. Parsing the blob as a
* whole used to drop everything on one bad field, metric exclusions
* included, which turned an unknown lookback value into a privacy loss. The
* two lists keep their known entries, so an unknown metric or cluster
* narrows what the Coach reads rather than widening it.
*/
export function parseCoachPrefs(raw: unknown): CoachPrefs {
if (raw == null) return DEFAULT_COACH_PREFS;
const parsed = coachPrefsSchema.safeParse(raw);
return parsed.success ? parsed.data : DEFAULT_COACH_PREFS;
const whole = coachPrefsSchema.safeParse(raw);
if (whole.success) return whole.data;
if (typeof raw !== "object" || Array.isArray(raw)) {
return DEFAULT_COACH_PREFS;
}
const source = raw as Record<string, unknown>;
const salvaged: Record<string, unknown> = {};
for (const [key, field] of Object.entries(coachPrefsSchema.shape)) {
if (!(key in source)) continue;
const value = knownItemsOnly(key, source[key]);
if (field.safeParse(value).success) salvaged[key] = value;
}
if (Object.keys(salvaged).length === 0) return DEFAULT_COACH_PREFS;
return coachPrefsSchema.parse(salvaged);
}
/** The entries of a list preference this version knows; other values as is. */
function knownItemsOnly(key: string, value: unknown): unknown {
if (!Array.isArray(value)) return value;
const item =
key === "excludeMetrics"
? coachExcludeMetricEnum
: key === "dataClusters"
? coachDataClusterEnum
: null;
return item ? value.filter((entry) => item.safeParse(entry).success) : value;
}