From 2a556b47c3ffc404e131689d0456217b035f73b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 07:45:05 +0200 Subject: [PATCH 01/17] fix(coach): offer only providers that can answer and say when a switch does not take The provider-chain summary marked every entry available, so the Coach's provider select offered entries without a key or sign-in. Choosing one saved it to the front of the chain, the resolver skipped it, and the select jumped back without a word. Availability now comes from the presence probe, which names every chain type whose credential is present. After a switch the picker reads the chain back and says so when the server still answers with another provider. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- .../provider-chain/__tests__/route.test.ts | 41 +++++++++++- src/app/api/insights/provider-chain/route.ts | 20 +++--- .../__tests__/coach-model-picker.test.tsx | 18 ++++++ .../coach-panel/coach-model-picker.tsx | 45 +++++++++++-- .../ai/__tests__/provider-presence.test.ts | 18 ++++++ src/lib/ai/capabilities/resolve.ts | 6 ++ src/lib/ai/provider.ts | 64 +++++++++++-------- 14 files changed, 188 insertions(+), 45 deletions(-) diff --git a/messages/de.json b/messages/de.json index 334ca82d0..e129a0e77 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3137,7 +3137,8 @@ "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." }, "answer": { "scrollToBottom": "Zur neuesten Nachricht", diff --git a/messages/en.json b/messages/en.json index 6287e5518..936b669a6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3137,7 +3137,8 @@ "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." }, "answer": { "scrollToBottom": "Jump to the latest message", diff --git a/messages/es.json b/messages/es.json index d276a21c1..79a461607 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3137,7 +3137,8 @@ "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." }, "answer": { "scrollToBottom": "Ir al último mensaje", diff --git a/messages/fr.json b/messages/fr.json index bdc8fe468..cbccefe22 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3137,7 +3137,8 @@ "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." }, "answer": { "scrollToBottom": "Aller au dernier message", diff --git a/messages/it.json b/messages/it.json index f58556352..6e50a69ab 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3137,7 +3137,8 @@ "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." }, "answer": { "scrollToBottom": "Vai all'ultimo messaggio", diff --git a/messages/ko.json b/messages/ko.json index 9fb976c03..4ecc63940 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3137,7 +3137,8 @@ "noProvider": "사용할 수 있는 제공자가 없어요", "saving": "저장 중…", "saveFailed": "변경 사항을 저장하지 못했어요.", - "allSettings": "모든 AI 설정" + "allSettings": "모든 AI 설정", + "providerNotActive": "{provider}(으)로 전환하지 못했습니다. 작동하는 로그인이나 키가 없습니다. AI 설정에서 설정하세요." }, "answer": { "scrollToBottom": "최신 메시지로 이동", diff --git a/messages/pl.json b/messages/pl.json index 98c255698..b324f39b2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3137,7 +3137,8 @@ "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." }, "answer": { "scrollToBottom": "Przejdź do najnowszej wiadomości", 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/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/coach-model-picker.tsx b/src/components/insights/coach-panel/coach-model-picker.tsx index 079d99c96..da9c631b3 100644 --- a/src/components/insights/coach-panel/coach-model-picker.tsx +++ b/src/components/insights/coach-panel/coach-model-picker.tsx @@ -70,6 +70,18 @@ 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, +): boolean { + return fresh.activeProvider !== chosen; +} + export type ModelControl = | { kind: "preset"; presets: readonly string[]; value: string | null } | { kind: "gateway"; value: string | null } @@ -139,6 +151,7 @@ export function CoachModelPicker() { }); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); const onSaved = () => { setError(null); @@ -152,11 +165,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("/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({ @@ -294,6 +322,15 @@ export function CoachModelPicker() { {t("insights.coach.frame.saving")}

) : null} + {notice ? ( +

+ {notice} +

+ ) : null} {error ? (

{error} diff --git a/src/lib/ai/__tests__/provider-presence.test.ts b/src/lib/ai/__tests__/provider-presence.test.ts index 502b4f1e6..5662de426 100644 --- a/src/lib/ai/__tests__/provider-presence.test.ts +++ b/src/lib/ai/__tests__/provider-presence.test.ts @@ -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"], }); }); diff --git a/src/lib/ai/capabilities/resolve.ts b/src/lib/ai/capabilities/resolve.ts index 5b97aee38..0152a7ac7 100644 --- a/src/lib/ai/capabilities/resolve.ts +++ b/src/lib/ai/capabilities/resolve.ts @@ -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 diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index a88e6e5ad..4f28958f1 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -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, }; }, From 0c1dd1e96781fa41634d889d5673b03104d6014c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 07:46:29 +0200 Subject: [PATCH 02/17] fix(coach): show the operator's central Codex when it answers The operator's shared Codex is appended to the resolved chain but is never a chain entry, so the Coach's provider select had no option for it and showed its first option as the one answering. The answering provider now appears as a disabled option, with a note that the operator manages the connection. --- messages/de.json | 4 ++- messages/en.json | 4 ++- messages/es.json | 4 ++- messages/fr.json | 4 ++- messages/it.json | 4 ++- messages/ko.json | 4 ++- messages/pl.json | 4 ++- .../__tests__/coach-settings-overlay.test.tsx | 21 +++++++++++-- .../coach-panel/coach-model-picker.tsx | 30 +++++++++++++++++++ 9 files changed, 70 insertions(+), 9 deletions(-) diff --git a/messages/de.json b/messages/de.json index e129a0e77..9beba5251 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3138,7 +3138,9 @@ "saving": "Wird gespeichert…", "saveFailed": "Die Änderung ließ sich nicht speichern.", "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." + "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", diff --git a/messages/en.json b/messages/en.json index 936b669a6..cba184481 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3138,7 +3138,9 @@ "saving": "Saving…", "saveFailed": "That change could not be saved.", "allSettings": "All AI settings", - "providerNotActive": "{provider} could not take over: it has no working sign-in or key. Set it up in 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", diff --git a/messages/es.json b/messages/es.json index 79a461607..c4f020aae 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3138,7 +3138,9 @@ "saving": "Guardando…", "saveFailed": "No se pudo guardar el cambio.", "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." + "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", diff --git a/messages/fr.json b/messages/fr.json index cbccefe22..b41d732f5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3138,7 +3138,9 @@ "saving": "Enregistrement…", "saveFailed": "Impossible d’enregistrer cette modification.", "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." + "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", diff --git a/messages/it.json b/messages/it.json index 6e50a69ab..71753185c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3138,7 +3138,9 @@ "saving": "Salvataggio…", "saveFailed": "Impossibile salvare la modifica.", "allSettings": "Tutte le impostazioni IA", - "providerNotActive": "{provider} non è subentrato: manca un accesso o una chiave funzionante. Configuralo nelle 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", diff --git a/messages/ko.json b/messages/ko.json index 4ecc63940..3751c5beb 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3138,7 +3138,9 @@ "saving": "저장 중…", "saveFailed": "변경 사항을 저장하지 못했어요.", "allSettings": "모든 AI 설정", - "providerNotActive": "{provider}(으)로 전환하지 못했습니다. 작동하는 로그인이나 키가 없습니다. AI 설정에서 설정하세요." + "providerNotActive": "{provider}(으)로 전환하지 못했습니다. 작동하는 로그인이나 키가 없습니다. AI 설정에서 설정하세요.", + "operatorCodex": "ChatGPT (운영자 공유)", + "operatorManaged": "이 연결은 서버 운영자가 관리합니다. 내 공급자를 하나라도 설정하면 그 공급자가 먼저 답합니다." }, "answer": { "scrollToBottom": "최신 메시지로 이동", diff --git a/messages/pl.json b/messages/pl.json index b324f39b2..ae330d3b8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3138,7 +3138,9 @@ "saving": "Zapisywanie…", "saveFailed": "Nie udało się zapisać zmiany.", "allSettings": "Wszystkie ustawienia AI", - "providerNotActive": "{provider} nie mógł przejąć odpowiedzi: brak działającego logowania lub klucza. Skonfiguruj go w ustawieniach 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", 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..ae0c02509 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( + /]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/, + )?.[0]; + expect(select).toMatch( + / ) : null} + {unlisted ? ( + + ) : null} {options.map((p) => ( ))} + {unlisted === "admin-codex" ? ( +

+ {t("insights.coach.frame.operatorManaged")} +

+ ) : null} {model.kind === "preset" ? ( From 1158ae9ffd4bb1c5687f89ac0427c06481911a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 07:47:31 +0200 Subject: [PATCH 03/17] fix(coach): keep earlier tables beyond the lookback limit out of the context The earlier-tables block names each stored table with its window and row count. The list was filtered by module and exclusion only, so a table stored before the limit was set or narrowed still told the model how much history it covered. It now answers to the same range test show_result uses, and the history-reach guard checks both the filter and that the call site passes the limit. --- .../coach-history-reach-guard.test.ts | 52 ++++++++++++++++++- .../__tests__/executor-results.test.ts | 4 ++ src/lib/ai/coach/tools/executor.ts | 16 +++++- src/lib/ai/coach/turn/model.ts | 6 ++- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts b/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts index 231b435b4..7b0a50e26 100644 --- a/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts +++ b/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts @@ -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"), diff --git a/src/lib/ai/coach/results/__tests__/executor-results.test.ts b/src/lib/ai/coach/results/__tests__/executor-results.test.ts index e7d2fe1f6..b793deb6f 100644 --- a/src/lib/ai/coach/results/__tests__/executor-results.test.ts +++ b/src/lib/ai/coach/results/__tests__/executor-results.test.ts @@ -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: [ diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index 9e32436e9..b1f4f94e3 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -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; scope: CoachScope | undefined; + reach: CoachHistoryReach; prior: readonly PriorResultTurn[]; }): Promise { 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); } diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts index 2ab0aa91b..20d98aff9 100644 --- a/src/lib/ai/coach/turn/model.ts +++ b/src/lib/ai/coach/turn/model.ts @@ -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) => From 169c7bdfe623a22ce2af861aa67a99f1dba8f62d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 07:48:37 +0200 Subject: [PATCH 04/17] fix(coach): salvage Coach preferences field by field parseCoachPrefs parsed the stored blob as a whole and returned the defaults on any invalid field. A lookback value this version does not know therefore dropped the metric exclusions with it. Each field now falls back on its own, and the exclusion and cluster lists keep their known entries, so drift narrows what the Coach reads instead of widening it. The deploy runbook's rollback section notes the one case this cannot cover: v1.40.1 and earlier still parse the blob whole, so an account that saved the new "Last year" lookback loses its exclusions there. --- docs/ops/deploy.md | 6 +++ .../validations/__tests__/coach-prefs.test.ts | 29 ++++++++++--- src/lib/validations/coach-prefs.ts | 43 ++++++++++++++++--- 3 files changed, 66 insertions(+), 12 deletions(-) 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/src/lib/validations/__tests__/coach-prefs.test.ts b/src/lib/validations/__tests__/coach-prefs.test.ts index cb3f8c6bc..801a347f4 100644 --- a/src/lib/validations/__tests__/coach-prefs.test.ts +++ b/src/lib/validations/__tests__/coach-prefs.test.ts @@ -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", () => { diff --git a/src/lib/validations/coach-prefs.ts b/src/lib/validations/coach-prefs.ts index c74132f41..b1ea83a90 100644 --- a/src/lib/validations/coach-prefs.ts +++ b/src/lib/validations/coach-prefs.ts @@ -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; + const salvaged: Record = {}; + 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; } From d0b680c27786af6c9c51d70120ec6dc5c413cde6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 07:48:58 +0200 Subject: [PATCH 05/17] fix(dashboard): count the last hour of a 25-hour day in today's doses buildMedsTodayBlock ended the day at local midnight plus 24 hours. On the day the clocks go back that stops an hour early, so a dose between 23:00 and midnight was neither projected nor counted in the doses tally. The exclusive end is now the next local day's start, as the digest loader already computes it. --- .../dashboard/__tests__/meds-today.test.ts | 28 +++++++++++++++++++ src/lib/dashboard/meds-today.ts | 9 +++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/lib/dashboard/__tests__/meds-today.test.ts b/src/lib/dashboard/__tests__/meds-today.test.ts index 6beccb201..35f2864a0 100644 --- a/src/lib/dashboard/__tests__/meds-today.test.ts +++ b/src/lib/dashboard/__tests__/meds-today.test.ts @@ -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 = [ diff --git a/src/lib/dashboard/meds-today.ts b/src/lib/dashboard/meds-today.ts index 7cefb726d..017008912 100644 --- a/src/lib/dashboard/meds-today.ts +++ b/src/lib/dashboard/meds-today.ts @@ -104,10 +104,11 @@ export async function buildMedsTodayBlock( ): Promise { 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 From 35edc3387172b21eaaa54d79fe0840f73ba645de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 07:49:28 +0200 Subject: [PATCH 06/17] fix(coach): lock the reasoning select while a change saves The provider and model selects in the Coach's settings already lock while a write is in flight; the reasoning select did not, so a second PATCH could race the first and one change could be lost. --- .../__tests__/coach-settings-overlay.test.tsx | 11 +++++++++++ .../insights/coach-panel/coach-model-picker.tsx | 1 + src/components/settings/ai/reasoning-effort-field.tsx | 4 ++++ 3 files changed, 16 insertions(+) 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 ae0c02509..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 @@ -107,6 +107,17 @@ describe("", () => { 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(//)?.[0]; + expect(field).toMatch(/disabled=\{busy\}/); + }); + it("names the data section so a deep link can land on it", () => { const html = render(); expect(html).toMatch( diff --git a/src/components/insights/coach-panel/coach-model-picker.tsx b/src/components/insights/coach-panel/coach-model-picker.tsx index 81bee0d00..bc3bd2424 100644 --- a/src/components/insights/coach-panel/coach-model-picker.tsx +++ b/src/components/insights/coach-panel/coach-model-picker.tsx @@ -343,6 +343,7 @@ export function CoachModelPicker() { patchProvider.mutate({ [reasoning.field]: next })} /> ) : null} diff --git a/src/components/settings/ai/reasoning-effort-field.tsx b/src/components/settings/ai/reasoning-effort-field.tsx index b9bae533f..0092183ef 100644 --- a/src/components/settings/ai/reasoning-effort-field.tsx +++ b/src/components/settings/ai/reasoning-effort-field.tsx @@ -14,10 +14,13 @@ import type { ReasoningEffort } from "./shared"; export function ReasoningEffortField({ id, + disabled = false, value, onChange, }: { id: string; + /** Locks the select while a write is in flight. */ + disabled?: boolean; value: ReasoningEffort | null; onChange: (next: ReasoningEffort | null) => void; }) { @@ -28,6 +31,7 @@ export function ReasoningEffortField({ onChange( e.target.value === "" ? null : (e.target.value as ReasoningEffort), From 94e0da79535b9ed15e05f6e50c5d1d7f903ff196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 07:49:52 +0200 Subject: [PATCH 07/17] chore(i18n): drop the orphaned Coach window labels The insights.coach.window labels lost their last caller with the source chips. No t() call, literal or templated, reads them in any locale. --- messages/de.json | 7 ------- messages/en.json | 7 ------- messages/es.json | 7 ------- messages/fr.json | 7 ------- messages/it.json | 7 ------- messages/ko.json | 7 ------- messages/pl.json | 7 ------- 7 files changed, 49 deletions(-) diff --git a/messages/de.json b/messages/de.json index 9beba5251..a6c5331b2 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.", diff --git a/messages/en.json b/messages/en.json index cba184481..82df6a645 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.", diff --git a/messages/es.json b/messages/es.json index c4f020aae..8395d0438 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.", diff --git a/messages/fr.json b/messages/fr.json index b41d732f5..1a29b6350 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.", diff --git a/messages/it.json b/messages/it.json index 71753185c..71b97e279 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.", diff --git a/messages/ko.json b/messages/ko.json index 3751c5beb..b41a58381 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": "코치가 대화하는 방식을 조절해요.", diff --git a/messages/pl.json b/messages/pl.json index ae330d3b8..8dd662fd3 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.", From 3b283507b5808505a8bb7951926772bf51a29a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 07:51:10 +0200 Subject: [PATCH 08/17] fix(dashboard): lead with the stored lines for a digest cached before the lead The hero reads only digest.lead. A digest the service worker cached before the update carries no such field, so the hero lost its lead line until the next fetch. Such a digest now falls back to the reaction line or the briefing lead it was built to show; a current digest whose lead is null stays silent. --- .../daily/__tests__/today-hero.test.tsx | 31 +++++++++++++++++++ src/components/daily/today-hero.tsx | 9 ++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 6285070da..648a838cd 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 diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index a1544f88e..bdd649bb3 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 From 308e154978ecb7a424ea16ab185afea372ce16bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 07:51:38 +0200 Subject: [PATCH 09/17] fix(coach): move focus to the composer before hiding the jump button Jumping to the latest message pinned the thread first, which hides the button behind aria-hidden, and only then moved focus to the composer. Focus now leaves the button before its state changes. --- .../coach-panel/__tests__/scroll-to-bottom-button.test.tsx | 4 +++- src/components/insights/coach-panel/message-thread.tsx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/insights/coach-panel/__tests__/scroll-to-bottom-button.test.tsx b/src/components/insights/coach-panel/__tests__/scroll-to-bottom-button.test.tsx index 8472ec2f6..f5a0fcc2a 100644 --- a/src/components/insights/coach-panel/__tests__/scroll-to-bottom-button.test.tsx +++ b/src/components/insights/coach-panel/__tests__/scroll-to-bottom-button.test.tsx @@ -57,8 +57,10 @@ describe("", () => { expect(src).toContain( "", ); + // 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\);/, ); }); }); diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index c80672533..3d2981f04 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -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 From 8dc34c769128d6e66ed5bb1bcb631d0bc1399bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 07:52:15 +0200 Subject: [PATCH 10/17] fix(coach): keep GLP-1 dose changes before the lookback limit out The GLP-1 block sent every dose change with its decrypted note whatever the lookback limit said. Dose changes are entries, so only those inside the limit reach the prompt; the dose in force stays as the current dose. --- .../ai/coach/__tests__/glp1-snapshot.test.ts | 63 +++++++++++++++++++ src/lib/ai/coach/glp1-snapshot.ts | 14 +++-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts index 4836ac307..ab8fcbe7d 100644 --- a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts @@ -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]); + }); +}); diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index 8ea49b05b..2edec8159 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -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 { @@ -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, From 87cf32bc67b7fb0e2918c1d5d9e25b40c5cefc0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 07:53:31 +0200 Subject: [PATCH 11/17] fix(coach): leave derived scores out when their window does not fit the limit The derived block shortened each engine's window to the lookback limit, so a 7-day limit produced a readiness computed over 7 days instead of the 30 the ring on the dashboard uses, under the same name. A block whose own window does not fit is now left out, as the lookback rule says, and the coincident-deviation flag follows its 30-day baseline the same way. --- .../coach/__tests__/derived-snapshot.test.ts | 39 +++++++++++++++ src/lib/ai/coach/derived-snapshot.ts | 50 ++++++++----------- 2 files changed, 61 insertions(+), 28 deletions(-) diff --git a/src/lib/ai/coach/__tests__/derived-snapshot.test.ts b/src/lib/ai/coach/__tests__/derived-snapshot.test.ts index 83132cc46..05a39557d 100644 --- a/src/lib/ai/coach/__tests__/derived-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/derived-snapshot.test.ts @@ -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(); + }); +}); diff --git a/src/lib/ai/coach/derived-snapshot.ts b/src/lib/ai/coach/derived-snapshot.ts index 61d46ee44..54a1fcd2f 100644 --- a/src/lib/ai/coach/derived-snapshot.ts +++ b/src/lib/ai/coach/derived-snapshot.ts @@ -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> @@ -257,17 +256,12 @@ export async function buildDerivedSnapshotBlock( reach: CoachHistoryReach = UNBOUNDED_REACH, ): Promise { const block: Record = {}; - // 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, }, From 13ac27c22dd14afecb24ef28831156a224197728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 07:53:48 +0200 Subject: [PATCH 12/17] docs(coach): say that older answers may still quote older values The lookback limit governs what the Coach reads from now on. Answers it gave earlier in an older conversation stay as written, so the note under the limit now says they may still quote values from before it. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/pl.json | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/messages/de.json b/messages/de.json index a6c5331b2..6884f4c63 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3011,7 +3011,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", diff --git a/messages/en.json b/messages/en.json index 82df6a645..cb85ff501 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3011,7 +3011,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", diff --git a/messages/es.json b/messages/es.json index 8395d0438..81ede2572 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3011,7 +3011,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", diff --git a/messages/fr.json b/messages/fr.json index 1a29b6350..44ed03a77 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3011,7 +3011,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", diff --git a/messages/it.json b/messages/it.json index 71b97e279..ab994a606 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3011,7 +3011,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", diff --git a/messages/ko.json b/messages/ko.json index b41a58381..451dcdd77 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3011,7 +3011,7 @@ "detailDays": "코치는 어떤 답변에서도 {days}일보다 오래된 기록은 읽지 않아요.", "detailMonths": "코치는 어떤 답변에서도 {months}개월보다 오래된 기록은 읽지 않아요.", "detailAll": "기록한 모든 것: 최근 {dailyMonths}개월은 하루 단위로, 그 이전 측정값은 최대 {years}년 전까지 월별 값으로, 검사 결과는 최근 {labMonths}개월 치를 읽어요.", - "note": "프로필, 현재 복용 중인 약, 진행 중인 질병, 예정된 진료는 계속 보여요. 더 긴 기록이 필요한 요약은 줄이거나 빼요." + "note": "프로필, 현재 복용 중인 약, 진행 중인 질병, 예정된 진료는 계속 보여요. 더 긴 기록이 필요한 요약은 줄이거나 빼요. 예전 대화에서 코치가 이미 한 답변에는 더 오래된 값이 남아 있을 수 있어요." }, "showConversations": "대화 보기", "hideConversations": "대화 숨기기", diff --git a/messages/pl.json b/messages/pl.json index 8dd662fd3..f9d4baca9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3011,7 +3011,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", From 9e647051d59f6d98a45ac6330fd6d0a9a295df0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 07:57:51 +0200 Subject: [PATCH 13/17] fix(dashboard): tell a steady score only when its band held, and say at least at the read's edge The steady line compared the newest stored score with the number on screen but not with its band, version or composition, so a score whose band moved overnight at the same number was still called steady. A run that reached back past the 120 days read was also told as if it started there. The newest stored day now has to match the ring in value, band and, where the digest carries them, algorithm version and composition. A run the read cannot see the start of is marked as open (steadyAtLeast on the digest score) and the hero says "at least" in seven locales. --- docs/api/openapi.yaml | 7 +- messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/ko.json | 5 +- messages/pl.json | 5 +- .../daily/__tests__/today-hero.test.tsx | 7 ++ src/components/daily/today-hero.tsx | 10 ++- src/lib/daily/__tests__/digest.test.ts | 12 ++- src/lib/daily/__tests__/score-steady.test.ts | 55 +++++++++++- src/lib/daily/digest.ts | 16 +++- src/lib/daily/load-digest.ts | 15 +++- src/lib/daily/score-steady.ts | 87 +++++++++++++++---- src/lib/openapi/routes/daily.ts | 8 +- 16 files changed, 215 insertions(+), 37 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3b509b723..d2b4000fa 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 diff --git a/messages/de.json b/messages/de.json index 6884f4c63..3091393ce 100644 --- a/messages/de.json +++ b/messages/de.json @@ -11030,7 +11030,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 cb85ff501..1339fe560 100644 --- a/messages/en.json +++ b/messages/en.json @@ -11030,7 +11030,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 81ede2572..6f58f002e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -11030,7 +11030,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 44ed03a77..4d9b4945b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -11030,7 +11030,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 ab994a606..4551242b1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -11030,7 +11030,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 451dcdd77..dd9af50b9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -11030,7 +11030,10 @@ "PULSE": "맥박", "WEIGHT": "체중", "generic": "생체 지표" - } + }, + "steadyAtLeastWeeksOne": "최소 {count}주째 안정적", + "steadyAtLeastWeeksFew": "최소 {count}주째 안정적", + "steadyAtLeastWeeksOther": "최소 {count}주째 안정적" }, "milestone": { "metric": { diff --git a/messages/pl.json b/messages/pl.json index f9d4baca9..b304b7b5c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -11030,7 +11030,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/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 648a838cd..6e6810c40 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -669,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 bdd649bb3..ae474ff29 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -179,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/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index c45af38ce..89ac1f62b 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -115,6 +115,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( @@ -1564,7 +1565,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); }); }); diff --git a/src/lib/daily/__tests__/score-steady.test.ts b/src/lib/daily/__tests__/score-steady.test.ts index fa501c361..40f17caeb 100644 --- a/src/lib/daily/__tests__/score-steady.test.ts +++ b/src/lib/daily/__tests__/score-steady.test.ts @@ -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, + }); + }); }); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 0379c23a7..59c46ffe8 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -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 { @@ -1250,7 +1256,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 { diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index de34bc90e..df1943241 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -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 { @@ -951,8 +951,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, diff --git a/src/lib/daily/score-steady.ts b/src/lib/daily/score-steady.ts index 613e07be7..31d404ad0 100644 --- a/src/lib/daily/score-steady.ts +++ b/src/lib/daily/score-steady.ts @@ -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 }; } diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts index 4f7b35338..df379a80f 100644 --- a/src/lib/openapi/routes/daily.ts +++ b/src/lib/openapi/routes/daily.ts @@ -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() From bd8e05e48444422e5e197046fe93f2d0205eb1a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 07:58:47 +0200 Subject: [PATCH 14/17] fix(dashboard): drop a lead sentence only when it repeats the score as the score The lead filter dropped any sentence containing the score's digits, so a score of 52 threw out "You slept 7 h 52 min" and a resting heart rate of 52 bpm. A sentence now repeats the ring only when it names the score (in any shipped language, or as "out of 100") and carries the number on its own, not as part of a time, a decimal or a measurement with a unit. --- .../daily/__tests__/today-overview.test.ts | 31 +++++++++++++++++++ src/lib/daily/today-overview.ts | 23 +++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/lib/daily/__tests__/today-overview.test.ts b/src/lib/daily/__tests__/today-overview.test.ts index 7e047203e..db11291ec 100644 --- a/src/lib/daily/__tests__/today-overview.test.ts +++ b/src/lib/daily/__tests__/today-overview.test.ts @@ -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), diff --git a/src/lib/daily/today-overview.ts b/src/lib/daily/today-overview.ts index df7a78d94..9f12c6731 100644 --- a/src/lib/daily/today-overview.ts +++ b/src/lib/daily/today-overview.ts @@ -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 = + /(? Date: Mon, 5 Oct 2026 08:02:19 +0200 Subject: [PATCH 15/17] fix(dashboard): keep numbers out of the morning push line The digest line is the body of the morning push and lands on a locked phone. It took the briefing's first sentence, the top signal's headline or the deterministic signal sentence verbatim, so a resting heart rate or a blood pressure average could sit on the lock screen, and its score floor stated the score. A candidate with a figure in it is now passed over for the next, and the floor says the score is ready instead of what it is. The hero's lead in the app keeps its numbers. Tests that pinned the numeric line are turned around, and the push builder is checked on a digest whose lead carries a reading. --- docs/api/openapi.yaml | 3 +- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/ko.json | 4 +- messages/pl.json | 4 +- ...onfig-change-is-not-a-health-event.test.ts | 13 ++--- .../__tests__/daily-briefing-push.test.ts | 4 +- src/lib/daily/__tests__/digest.test.ts | 55 +++++++++++++++++-- src/lib/daily/daily-briefing-push.ts | 9 +-- src/lib/daily/digest.ts | 21 +++++-- src/lib/openapi/routes/daily.ts | 2 +- 14 files changed, 97 insertions(+), 38 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d2b4000fa..649ff97ca 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -72588,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/messages/de.json b/messages/de.json index 3091393ce..11191b8b7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10923,8 +10923,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", diff --git a/messages/en.json b/messages/en.json index 1339fe560..e896dddb1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10923,8 +10923,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", diff --git a/messages/es.json b/messages/es.json index 6f58f002e..48292e051 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10923,8 +10923,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", diff --git a/messages/fr.json b/messages/fr.json index 4d9b4945b..65ebe47b9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10923,8 +10923,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", diff --git a/messages/it.json b/messages/it.json index 4551242b1..9b28b906f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10923,8 +10923,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", diff --git a/messages/ko.json b/messages/ko.json index dd9af50b9..b61d68e7b 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10923,8 +10923,8 @@ }, "daily": { "line": { - "score": "오늘 건강 점수는 {score}점이에요.", - "allClear": "오늘은 신경 쓸 일이 없어요." + "allClear": "오늘은 신경 쓸 일이 없어요.", + "scoreReady": "오늘의 건강 점수가 준비됐어요." }, "push": { "title": "아침 브리핑", diff --git a/messages/pl.json b/messages/pl.json index b304b7b5c..9dce0db36 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10923,8 +10923,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", diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index d031de698..d2af8a35f 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -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( diff --git a/src/lib/daily/__tests__/daily-briefing-push.test.ts b/src/lib/daily/__tests__/daily-briefing-push.test.ts index de9fa0537..c1464ebf5 100644 --- a/src/lib/daily/__tests__/daily-briefing-push.test.ts +++ b/src/lib/daily/__tests__/daily-briefing-push.test.ts @@ -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", () => { diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index 89ac1f62b..5bbcadf50 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -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 { @@ -136,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", () => { @@ -1329,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); }, ); @@ -1513,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", () => { diff --git a/src/lib/daily/daily-briefing-push.ts b/src/lib/daily/daily-briefing-push.ts index a64266730..003854f8d 100644 --- a/src/lib/daily/daily-briefing-push.ts +++ b/src/lib/daily/daily-briefing-push.ts @@ -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. diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 59c46ffe8..7a9c6cdb8 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -1009,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 @@ -1017,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 }, @@ -1024,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"); } diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts index df379a80f..86e057173 100644 --- a/src/lib/openapi/routes/daily.ts +++ b/src/lib/openapi/routes/daily.ts @@ -198,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) From 4bb03600b32a550c40e94356f50524dde0aace83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 08:20:02 +0200 Subject: [PATCH 16/17] perf(dashboard): cache today's cycle read and run the vitals reads together The digest is polled every 120 seconds. Its cycle line read the whole cycle history, 90 days of day logs and wrist temperature on every poll, outside any cache, and the coincident-deviation engine walked its eleven vitals one after another, two queries each. The cycle read now rides the digest extras cell, which every cycle write already hard-evicts; the cycle preferences route evicts it too, and a module switch does through the health-score invalidator. A record without a cycle profile stops after one point read. The per-vital reads run together and are folded in registry order, so the result does not depend on which finished first. On a seeded four-year account (1.25M measurements, 52 cycles) under a 792 MB heap, the warm digest went from 9.9 to 5.0 ms and the vitals engine from 11.1 to 4.6 ms (medians of four interleaved rounds). --- src/app/api/auth/me/cycle-prefs/route.ts | 4 ++ src/lib/cycle/today-verdict.ts | 11 +++- src/lib/daily/__tests__/load-digest.test.ts | 13 ++++ src/lib/daily/load-digest.ts | 65 +++++++++++-------- .../insights/derived/coincident-deviation.ts | 26 +++++--- 5 files changed, 81 insertions(+), 38 deletions(-) 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/lib/cycle/today-verdict.ts b/src/lib/cycle/today-verdict.ts index 1ca4c1984..be98e93d4 100644 --- a/src/lib/cycle/today-verdict.ts +++ b/src/lib/cycle/today-verdict.ts @@ -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. diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index cc726ed42..498cae53f 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -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); diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index df1943241..95c609b3a 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -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 { @@ -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 { return cachedSwr( caches.analytics as ServerCache, @@ -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 diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index 152c4401f..83f75301e 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -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) { From 4db7fc511354d316919509f44bf2de632f164d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 08:21:52 +0200 Subject: [PATCH 17/17] fix(coach): open the deep-linked sheet only once the viewport is known /coach?settings=data opened the conversations sheet from the first render. The server and hydration renders do not know the viewport and take the narrow layout, so on a desktop the sheet was open until the docked panel replaced it; only the batching of the hydration re-render kept it off the screen. The deep link now opens the sheet only once hydrated on a viewport that does not dock the panel; on a docked one the panel is revealed as before. The desktop deep-link spec records every mount of the sheet from the first byte and expects none; a phone deep-link spec covers the sheet path and serves as the watcher's positive control. --- e2e/coach-layout.spec.ts | 62 +++++++++++++++++++ .../conversations-panel-sheet.test.ts | 37 +++++++++++ .../coach-panel/conversations-panel.tsx | 42 ++++++++++++- 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 src/components/insights/coach-panel/__tests__/conversations-panel-sheet.test.ts 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/src/components/insights/coach-panel/__tests__/conversations-panel-sheet.test.ts b/src/components/insights/coach-panel/__tests__/conversations-panel-sheet.test.ts new file mode 100644 index 000000000..969157191 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/conversations-panel-sheet.test.ts @@ -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); + }); +}); diff --git a/src/components/insights/coach-panel/conversations-panel.tsx b/src/components/insights/coach-panel/conversations-panel.tsx index b9ec5df7e..c3082381b 100644 --- a/src/components/insights/coach-panel/conversations-panel.tsx +++ b/src/components/insights/coach-panel/conversations-panel.tsx @@ -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);