diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 3b509b723..649ff97ca 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -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:
diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md
index 4cfe63778..94f0c047d 100644
--- a/docs/ops/deploy.md
+++ b/docs/ops/deploy.md
@@ -78,6 +78,12 @@ roll back:
dev --create-only --name vX_Y_Z_rollback_
`), 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
diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts
index a4a051e4a..b933afd30 100644
--- a/e2e/coach-layout.spec.ts
+++ b/e2e/coach-layout.spec.ts
@@ -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 {
+ 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) => {
diff --git a/messages/de.json b/messages/de.json
index f15838457..94c4d89c1 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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": {
diff --git a/messages/en.json b/messages/en.json
index b4193d4af..92aff03a8 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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": {
diff --git a/messages/es.json b/messages/es.json
index 7e14e6e10..9f21ada5b 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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": {
diff --git a/messages/fr.json b/messages/fr.json
index 7b47b0537..3bc9a18f4 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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": {
diff --git a/messages/it.json b/messages/it.json
index 576e2f6a0..ae8c7982b 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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": {
diff --git a/messages/ko.json b/messages/ko.json
index 1787f8272..80d038191 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -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": {
diff --git a/messages/pl.json b/messages/pl.json
index 46ab15664..809bc6c8e 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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": {
diff --git a/src/app/api/auth/me/cycle-prefs/route.ts b/src/app/api/auth/me/cycle-prefs/route.ts
index 81f145a57..738408f41 100644
--- a/src/app/api/auth/me/cycle-prefs/route.ts
+++ b/src/app/api/auth/me/cycle-prefs/route.ts
@@ -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,
diff --git a/src/app/api/insights/provider-chain/__tests__/route.test.ts b/src/app/api/insights/provider-chain/__tests__/route.test.ts
index efb66afab..e66ca14e8 100644
--- a/src/app/api/insights/provider-chain/__tests__/route.test.ts
+++ b/src/app/api/insights/provider-chain/__tests__/route.test.ts
@@ -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)();
+ 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 },
+ ]);
+ });
});
diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts
index 477cb8159..8ced0a544 100644
--- a/src/app/api/insights/provider-chain/route.ts
+++ b/src/app/api/insights/provider-chain/route.ts
@@ -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),
})),
});
});
diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx
index 6285070da..6e6810c40 100644
--- a/src/components/daily/__tests__/today-hero.test.tsx
+++ b/src/components/daily/__tests__/today-hero.test.tsx
@@ -185,6 +185,37 @@ describe("", () => {
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).lead;
+ const html = render();
+ expect(visibleText(html)).toContain("The new reading fits your week.");
+
+ const briefingOnly = digest();
+ delete (briefingOnly as Partial).lead;
+ expect(visibleText(render())).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(
+ ,
+ );
+ 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(" 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(
+ ,
+ );
+ expect(visibleText(html)).toContain("Steady for at least 17 weeks");
+ });
+
it("uses the German plural", () => {
const html = render(, "de");
expect(visibleText(html)).toContain("Seit 4 Wochen stabil");
diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx
index 7af933101..1eb83b7c3 100644
--- a/src/components/daily/today-hero.tsx
+++ b/src/components/daily/today-hero.tsx
@@ -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
diff --git a/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx b/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx
index 91d922e95..2d4a8af6a 100644
--- a/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx
@@ -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());
diff --git a/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx b/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx
index 2441bfc05..bb595f401 100644
--- a/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx
@@ -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("", () => {
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(, {
+ ...CHAIN,
+ activeProvider: "admin-codex" as ProviderChainData["activeProvider"],
+ });
+ const select = html.match(
+ /