From 393ac955f6cc728da8d1b7625ce178bbd90b80ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 20:45:21 +0200 Subject: [PATCH] Make the Coach lookback a limit on every read The saved Coach window only seeded the snapshot and each tool's default. A tool could still read back to all time, the availability probe reported counts and means for history outside the window, and several blocks read fixed windows of their own (labs 12 months, correlations 180 days, the coarse tail, the all-time extremes) whatever the setting said. coachPrefs.defaultWindow is now enforced as a limit through one module, history-reach.ts: the turn context clamps the client window, the snapshot clamps its window and gives every fixed-window block either the shorter window or, when the block's own window cannot fit, leaves it out. Every retrieval tool clamps its window, an earlier period or a stored table that reaches past the limit answers outside_reach, and the probe reads only inside the limit and names older history as outside_reach without any figure. Follow-up chips that would read past the limit are not offered. The default (allTime) is no limit and reads as before. MCP clients pass no limit and keep their own window arguments. Adds the twelve-month option, rewrites the picker text to state what is read with the numbers the readers use, shows the same options in the managed-profile settings, counts cluster members with a singular, and drops the opacity on switched-off rows that put their text below WCAG AA. --- docs/api/openapi.yaml | 12 + messages/de.json | 24 +- messages/en.json | 24 +- messages/es.json | 24 +- messages/fr.json | 24 +- messages/it.json | 24 +- messages/ko.json | 24 +- messages/pl.json | 24 +- .../__tests__/sources-rail.test.tsx | 47 +- .../insights/coach-panel/sources-rail.tsx | 60 ++- .../managed-record-settings-section.tsx | 60 ++- .../coach-history-reach-guard.test.ts | 417 ++++++++++++++++++ .../__tests__/coach-history-reach.test.ts | 229 ++++++++++ .../__tests__/tools-loop-callbacks.test.ts | 2 + .../tools-loop-miss-payloads.test.ts | 2 + src/lib/ai/coach/__tests__/tools-loop.test.ts | 2 + .../workout-evidence-builder.test.ts | 3 + src/lib/ai/coach/correlations-snapshot.ts | 5 +- src/lib/ai/coach/derived-snapshot.ts | 54 ++- src/lib/ai/coach/eval/run-case.ts | 3 + .../coach/follow-ups/__tests__/derive.test.ts | 13 + src/lib/ai/coach/follow-ups/derive.ts | 37 +- src/lib/ai/coach/glp1-snapshot.ts | 21 +- src/lib/ai/coach/history-reach-read.ts | 18 + src/lib/ai/coach/history-reach.ts | 200 +++++++++ src/lib/ai/coach/illness-snapshot.ts | 45 +- src/lib/ai/coach/labs-snapshot.ts | 15 +- src/lib/ai/coach/memory-snapshot.ts | 63 ++- .../__tests__/loop-result-tables.test.ts | 3 + src/lib/ai/coach/results/metric-table-tool.ts | 5 +- src/lib/ai/coach/self-context-questions.ts | 6 +- .../ai/coach/snapshot-blocks/glucose-block.ts | 8 +- src/lib/ai/coach/snapshot-cache.ts | 9 +- src/lib/ai/coach/snapshot-series.ts | 54 ++- src/lib/ai/coach/snapshot.ts | 187 +++++--- src/lib/ai/coach/tools/availability.ts | 199 ++++++++- src/lib/ai/coach/tools/correlations-read.ts | 33 +- src/lib/ai/coach/tools/definitions.ts | 23 +- src/lib/ai/coach/tools/executor.ts | 260 +++++++++-- src/lib/ai/coach/tools/inventory.ts | 54 ++- src/lib/ai/coach/tools/loop.ts | 8 + src/lib/ai/coach/tools/system-addendum.ts | 6 +- src/lib/ai/coach/trajectory-snapshot.ts | 18 +- src/lib/ai/coach/turn/context.ts | 35 +- src/lib/ai/coach/turn/model.ts | 8 +- src/lib/ai/coach/turn/reuse-turn.ts | 14 + src/lib/ai/coach/turn/steps.ts | 3 + src/lib/ai/coach/visits-snapshot.ts | 13 +- src/lib/ai/coach/workout-evidence-builder.ts | 16 +- .../insights/derived/adherence-storyline.ts | 6 + src/lib/insights/derived/changepoint.ts | 2 +- src/lib/insights/derived/index.ts | 5 +- src/lib/insights/derived/signal-trust.ts | 2 +- src/lib/insights/derived/trajectory.ts | 2 + src/lib/insights/feature-blocks.ts | 9 + src/lib/insights/features.ts | 78 +++- src/lib/mcp/tools.ts | 5 + src/lib/validations/coach-prefs.ts | 34 +- src/lib/workouts/sport-context.ts | 11 + tests/integration/coach-history-reach.test.ts | 251 +++++++++++ 60 files changed, 2539 insertions(+), 304 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts create mode 100644 src/lib/ai/coach/__tests__/coach-history-reach.test.ts create mode 100644 src/lib/ai/coach/history-reach-read.ts create mode 100644 src/lib/ai/coach/history-reach.ts create mode 100644 tests/integration/coach-history-reach.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bb71a2e8e..0537d2cc1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -25929,7 +25929,10 @@ components: - last7days - last30days - last90days + - lastYear - allTime + description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime` + (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit." dataClusters: maxItems: 10 type: array @@ -32076,7 +32079,10 @@ components: - last7days - last30days - last90days + - lastYear - allTime + description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime` + (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit." dataClusters: maxItems: 10 type: array @@ -48822,7 +48828,10 @@ components: - last7days - last30days - last90days + - lastYear - allTime + description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime` + (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit." dataClusters: maxItems: 10 type: array @@ -77933,7 +77942,10 @@ components: - last7days - last30days - last90days + - lastYear - allTime + description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime` + (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit." dataClusters: maxItems: 10 type: array diff --git a/messages/de.json b/messages/de.json index 4f69db510..f48998c96 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2930,7 +2930,6 @@ "lastYear": "letzte 12 Monate", "allTime": "gesamter Zeitraum (12 Monate im Detail)" }, - "windowLabel": "Zeitraum", "settingsAriaLabel": "Coach-Einstellungen", "settingsTitle": "Coach-Einstellungen", "settingsDescription": "Stell ein, wie der Coach mit dir spricht.", @@ -3009,7 +3008,23 @@ "settingsClustersLabel": "Daten, die der Coach sieht", "settingsClustersHint": "Wähle, welche Datencluster der Coach lesen darf. Mehr Daten bedeuten reichhaltigere Antworten und einen größeren Prompt.", "settingsClusterNoData": "Noch keine Daten", - "sourcesMemberCount": "{count} Werte", + "sourcesMemberCountOne": "1 Wert", + "sourcesMemberCountFew": "{count} Werte", + "sourcesMemberCountOther": "{count} Werte", + "lookback": { + "label": "Wie weit der Coach zurückblickt", + "option": { + "last7days": "{days} Tage", + "last30days": "{days} Tage", + "last90days": "{days} Tage", + "lastYear": "{months} Monate", + "allTime": "Alles" + }, + "detailDays": "Der Coach liest nichts, was älter als {days} Tage ist, in keiner Antwort.", + "detailMonths": "Der Coach liest nichts, was älter als {months} Monate ist, in keiner Antwort.", + "detailAll": "Alles, was du erfasst hast: {dailyMonths} Monate tagesgenau, ältere Werte als Monatswerte bis {years} Jahre zurück, Laborwerte der letzten {labMonths} Monate.", + "note": "Dein Profil, aktuelle Medikamente, eine laufende Erkrankung und anstehende Termine bleiben sichtbar; Auswertungen, die mehr Verlauf brauchen, werden gekürzt oder weggelassen." + }, "showConversations": "Unterhaltungen anzeigen", "hideConversations": "Unterhaltungen ausblenden", "heroGreeting": "Frage mich etwas zu deinen Daten", @@ -5090,11 +5105,6 @@ "brief": "Kurz", "default": "Standard", "detailed": "Ausführlich", - "defaultWindow": "Standardzeitraum", - "last7days": "Letzte 7 Tage", - "last30days": "Letzte 30 Tage", - "last90days": "Letzte 90 Tage", - "allTime": "Gesamter Zeitraum", "excludedMetrics": "Ausgeschlossene Messwerte", "dataClusters": "Datenbereiche", "customClusters": "Eigene Auswahl von Datenbereichen verwenden", diff --git a/messages/en.json b/messages/en.json index 92d60007b..5781bc0bb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2930,7 +2930,6 @@ "lastYear": "last 12 months", "allTime": "all time (12 months of detail)" }, - "windowLabel": "Window", "settingsAriaLabel": "Coach settings", "settingsTitle": "Coach settings", "settingsDescription": "Tune how the Coach talks with you.", @@ -3009,7 +3008,23 @@ "settingsClustersLabel": "Data the Coach sees", "settingsClustersHint": "Pick which clusters of your data the Coach can read. More data means richer answers and a larger prompt.", "settingsClusterNoData": "No data yet", - "sourcesMemberCount": "{count} metrics", + "sourcesMemberCountOne": "1 metric", + "sourcesMemberCountFew": "{count} metrics", + "sourcesMemberCountOther": "{count} metrics", + "lookback": { + "label": "How far back the Coach can look", + "option": { + "last7days": "{days} days", + "last30days": "{days} days", + "last90days": "{days} days", + "lastYear": "{months} months", + "allTime": "All" + }, + "detailDays": "The Coach reads nothing older than {days} days, in any answer.", + "detailMonths": "The Coach reads nothing older than {months} months, in any answer.", + "detailAll": "Everything you recorded: {dailyMonths} months in daily detail, older readings as monthly values up to {years} years back, lab results from the last {labMonths} months.", + "note": "Your profile, current medications, an ongoing illness and upcoming visits stay visible; summaries that need more history are shortened or left out." + }, "showConversations": "Show conversations", "hideConversations": "Hide conversations", "heroGreeting": "Ask me anything about your data", @@ -5090,11 +5105,6 @@ "brief": "Brief", "default": "Default", "detailed": "Detailed", - "defaultWindow": "Default window", - "last7days": "Last 7 days", - "last30days": "Last 30 days", - "last90days": "Last 90 days", - "allTime": "All time", "excludedMetrics": "Excluded metrics", "dataClusters": "Data clusters", "customClusters": "Use a custom cluster selection", diff --git a/messages/es.json b/messages/es.json index 7fc4efbfb..7a8074e23 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2930,7 +2930,6 @@ "lastYear": "últimos 12 meses", "allTime": "todo el tiempo (12 meses en detalle)" }, - "windowLabel": "Periodo", "settingsAriaLabel": "Ajustes del coach", "settingsTitle": "Ajustes del coach", "settingsDescription": "Configura cómo te habla el coach.", @@ -3009,7 +3008,23 @@ "settingsClustersLabel": "Datos que ve el Coach", "settingsClustersHint": "Elige qué grupos de datos puede leer el Coach. Más datos significan respuestas más ricas y un prompt mayor.", "settingsClusterNoData": "Aún no hay datos", - "sourcesMemberCount": "{count} métricas", + "sourcesMemberCountOne": "1 métrica", + "sourcesMemberCountFew": "{count} métricas", + "sourcesMemberCountOther": "{count} métricas", + "lookback": { + "label": "Hasta dónde puede mirar atrás el Coach", + "option": { + "last7days": "{days} días", + "last30days": "{days} días", + "last90days": "{days} días", + "lastYear": "{months} meses", + "allTime": "Todo" + }, + "detailDays": "El Coach no lee nada con más de {days} días de antigüedad, en ninguna respuesta.", + "detailMonths": "El Coach no lee nada con más de {months} meses de antigüedad, en ninguna respuesta.", + "detailAll": "Todo lo que has registrado: {dailyMonths} meses al detalle diario, las mediciones más antiguas como valores mensuales hasta {years} años atrás, los análisis de los últimos {labMonths} meses.", + "note": "Tu perfil, tu medicación actual, una enfermedad en curso y las próximas citas siguen visibles; los resúmenes que necesitan más historial se acortan o se omiten." + }, "showConversations": "Mostrar conversaciones", "hideConversations": "Ocultar conversaciones", "heroGreeting": "Pregúntame lo que quieras sobre tus datos", @@ -5090,11 +5105,6 @@ "brief": "Breve", "default": "Predeterminado", "detailed": "Detallado", - "defaultWindow": "Periodo predeterminado", - "last7days": "Últimos 7 días", - "last30days": "Últimos 30 días", - "last90days": "Últimos 90 días", - "allTime": "Todo el tiempo", "excludedMetrics": "Métricas excluidas", "dataClusters": "Grupos de datos", "customClusters": "Usar una selección personalizada de grupos", diff --git a/messages/fr.json b/messages/fr.json index 989e0ab17..4d8a00087 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2930,7 +2930,6 @@ "lastYear": "12 derniers mois", "allTime": "toute la période (12 mois en détail)" }, - "windowLabel": "Période", "settingsAriaLabel": "Paramètres du coach", "settingsTitle": "Paramètres du coach", "settingsDescription": "Configure comment le coach te parle.", @@ -3009,7 +3008,23 @@ "settingsClustersLabel": "Données vues par le Coach", "settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.", "settingsClusterNoData": "Pas encore de données", - "sourcesMemberCount": "{count} mesures", + "sourcesMemberCountOne": "1 mesure", + "sourcesMemberCountFew": "{count} mesures", + "sourcesMemberCountOther": "{count} mesures", + "lookback": { + "label": "Jusqu’où le Coach peut remonter", + "option": { + "last7days": "{days} jours", + "last30days": "{days} jours", + "last90days": "{days} jours", + "lastYear": "{months} mois", + "allTime": "Tout" + }, + "detailDays": "Le Coach ne lit rien de plus ancien que {days} jours, dans aucune réponse.", + "detailMonths": "Le Coach ne lit rien de plus ancien que {months} mois, dans aucune réponse.", + "detailAll": "Tout ce que vous avez enregistré : {dailyMonths} mois au jour près, les mesures plus anciennes en valeurs mensuelles jusqu’à {years} ans en arrière, les analyses des {labMonths} derniers mois.", + "note": "Votre profil, vos médicaments actuels, une maladie en cours et vos prochains rendez-vous restent visibles ; les synthèses qui demandent plus d’historique sont raccourcies ou omises." + }, "showConversations": "Afficher les conversations", "hideConversations": "Masquer les conversations", "heroGreeting": "Pose-moi une question sur tes données", @@ -5090,11 +5105,6 @@ "brief": "Bref", "default": "Par défaut", "detailed": "Détaillé", - "defaultWindow": "Période par défaut", - "last7days": "7 derniers jours", - "last30days": "30 derniers jours", - "last90days": "90 derniers jours", - "allTime": "Depuis toujours", "excludedMetrics": "Mesures exclues", "dataClusters": "Groupes de données", "customClusters": "Utiliser une sélection personnalisée de groupes", diff --git a/messages/it.json b/messages/it.json index b8d636332..1ab874b20 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2930,7 +2930,6 @@ "lastYear": "ultimi 12 mesi", "allTime": "tutto il tempo (12 mesi in dettaglio)" }, - "windowLabel": "Periodo", "settingsAriaLabel": "Impostazioni del coach", "settingsTitle": "Impostazioni del coach", "settingsDescription": "Imposta come il coach ti parla.", @@ -3009,7 +3008,23 @@ "settingsClustersLabel": "Dati che il Coach vede", "settingsClustersHint": "Scegli quali gruppi di dati il Coach può leggere. Più dati significano risposte più ricche e un prompt più grande.", "settingsClusterNoData": "Ancora nessun dato", - "sourcesMemberCount": "{count} metriche", + "sourcesMemberCountOne": "1 metrica", + "sourcesMemberCountFew": "{count} metriche", + "sourcesMemberCountOther": "{count} metriche", + "lookback": { + "label": "Fin dove il Coach può guardare indietro", + "option": { + "last7days": "{days} giorni", + "last30days": "{days} giorni", + "last90days": "{days} giorni", + "lastYear": "{months} mesi", + "allTime": "Tutto" + }, + "detailDays": "Il Coach non legge nulla di più vecchio di {days} giorni, in nessuna risposta.", + "detailMonths": "Il Coach non legge nulla di più vecchio di {months} mesi, in nessuna risposta.", + "detailAll": "Tutto ciò che hai registrato: {dailyMonths} mesi giorno per giorno, le misurazioni più vecchie come valori mensili fino a {years} anni fa, gli esami degli ultimi {labMonths} mesi.", + "note": "Il tuo profilo, i farmaci attuali, una malattia in corso e le prossime visite restano visibili; i riepiloghi che richiedono più storico vengono accorciati o omessi." + }, "showConversations": "Mostra conversazioni", "hideConversations": "Nascondi conversazioni", "heroGreeting": "Chiedimi qualcosa sui tuoi dati", @@ -5090,11 +5105,6 @@ "brief": "Breve", "default": "Predefinito", "detailed": "Dettagliato", - "defaultWindow": "Periodo predefinito", - "last7days": "Ultimi 7 giorni", - "last30days": "Ultimi 30 giorni", - "last90days": "Ultimi 90 giorni", - "allTime": "Tutto il periodo", "excludedMetrics": "Metriche escluse", "dataClusters": "Gruppi di dati", "customClusters": "Usa una selezione personalizzata di gruppi", diff --git a/messages/ko.json b/messages/ko.json index 5dc5fadf4..004674702 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2930,7 +2930,6 @@ "lastYear": "최근 12개월", "allTime": "전체 기간(상세 12개월)" }, - "windowLabel": "기간", "settingsAriaLabel": "코치 설정", "settingsTitle": "코치 설정", "settingsDescription": "코치가 대화하는 방식을 조절해요.", @@ -3009,7 +3008,23 @@ "settingsClustersLabel": "코치가 보는 데이터", "settingsClustersHint": "코치가 읽을 수 있는 데이터 묶음을 고르세요. 데이터가 많을수록 답변이 풍부해지고 프롬프트도 커져요.", "settingsClusterNoData": "아직 데이터 없음", - "sourcesMemberCount": "지표 {count}개", + "sourcesMemberCountOne": "지표 1개", + "sourcesMemberCountFew": "지표 {count}개", + "sourcesMemberCountOther": "지표 {count}개", + "lookback": { + "label": "코치가 돌아볼 수 있는 기간", + "option": { + "last7days": "{days}일", + "last30days": "{days}일", + "last90days": "{days}일", + "lastYear": "{months}개월", + "allTime": "전체" + }, + "detailDays": "코치는 어떤 답변에서도 {days}일보다 오래된 기록은 읽지 않아요.", + "detailMonths": "코치는 어떤 답변에서도 {months}개월보다 오래된 기록은 읽지 않아요.", + "detailAll": "기록한 모든 것: 최근 {dailyMonths}개월은 하루 단위로, 그 이전 측정값은 최대 {years}년 전까지 월별 값으로, 검사 결과는 최근 {labMonths}개월 치를 읽어요.", + "note": "프로필, 현재 복용 중인 약, 진행 중인 질병, 예정된 진료는 계속 보여요. 더 긴 기록이 필요한 요약은 줄이거나 빼요." + }, "showConversations": "대화 보기", "hideConversations": "대화 숨기기", "heroGreeting": "내 데이터에 대해 무엇이든 물어보세요", @@ -5090,11 +5105,6 @@ "brief": "간략", "default": "기본", "detailed": "상세", - "defaultWindow": "기본 기간", - "last7days": "최근 7일", - "last30days": "최근 30일", - "last90days": "최근 90일", - "allTime": "전체 기간", "excludedMetrics": "제외할 지표", "dataClusters": "데이터 그룹", "customClusters": "그룹 직접 선택하기", diff --git a/messages/pl.json b/messages/pl.json index b967ecdc4..0634681e1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2930,7 +2930,6 @@ "lastYear": "ostatnie 12 miesięcy", "allTime": "cały okres (12 miesięcy szczegółowo)" }, - "windowLabel": "Okres", "settingsAriaLabel": "Ustawienia coacha", "settingsTitle": "Ustawienia coacha", "settingsDescription": "Ustaw, jak coach do ciebie mówi.", @@ -3009,7 +3008,23 @@ "settingsClustersLabel": "Dane widziane przez Coacha", "settingsClustersHint": "Wybierz, które grupy danych Coach może czytać. Więcej danych to bogatsze odpowiedzi i większy prompt.", "settingsClusterNoData": "Brak danych", - "sourcesMemberCount": "{count} wskaźników", + "sourcesMemberCountOne": "1 wskaźnik", + "sourcesMemberCountFew": "{count} wskaźniki", + "sourcesMemberCountOther": "{count} wskaźników", + "lookback": { + "label": "Jak daleko wstecz może patrzeć Coach", + "option": { + "last7days": "{days} dni", + "last30days": "{days} dni", + "last90days": "{days} dni", + "lastYear": "{months} miesięcy", + "allTime": "Wszystko" + }, + "detailDays": "Coach nie czyta niczego starszego niż {days} dni, w żadnej odpowiedzi.", + "detailMonths": "Coach nie czyta niczego starszego niż {months} miesięcy, w żadnej odpowiedzi.", + "detailAll": "Wszystko, co zostało zapisane: {dailyMonths} miesięcy z dokładnością do dnia, starsze pomiary jako wartości miesięczne do {years} lat wstecz, wyniki badań z ostatnich {labMonths} miesięcy.", + "note": "Twój profil, obecne leki, trwająca choroba i nadchodzące wizyty pozostają widoczne; podsumowania wymagające dłuższej historii są skracane lub pomijane." + }, "showConversations": "Pokaż rozmowy", "hideConversations": "Ukryj rozmowy", "heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych", @@ -5090,11 +5105,6 @@ "brief": "Krótka", "default": "Domyślna", "detailed": "Szczegółowa", - "defaultWindow": "Domyślny okres", - "last7days": "Ostatnie 7 dni", - "last30days": "Ostatnie 30 dni", - "last90days": "Ostatnie 90 dni", - "allTime": "Cały okres", "excludedMetrics": "Wykluczone metryki", "dataClusters": "Grupy danych", "customClusters": "Użyj własnego wyboru grup", diff --git a/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx b/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx index 4fb0299e6..f616b2b07 100644 --- a/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx +++ b/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx @@ -124,10 +124,53 @@ describe("", () => { expect(html).toMatch(/data-source="cardio"[^>]*data-active="false"/); }); - it("shows the per-cluster member-count hint", () => { + it("shows the per-cluster member-count hint, singular for one", () => { const html = render(); // cardio expands to 9 sources, mood to 1 — both surface a count. expect(html).toContain("9 metrics"); - expect(html).toContain("1 metrics"); + expect(html).toContain("1 metric<"); + expect(html).not.toContain("1 metrics"); + }); + + it("never fades a switched-off row (contrast)", () => { + // `opacity-60` over muted text fell below WCAG AA in both themes. The + // off state is the dashed frame and the switch, the text keeps its + // tokens. + const html = render(); + const rows = html.match(/]*data-slot="coach-sources-row"[^>]*>/g); + expect(rows?.length).toBe(10); + for (const row of rows ?? []) expect(row).not.toMatch(/opacity/); + expect(html).toMatch( + /data-source="glucose"[^>]*data-active="false"[^>]*class="[^"]*border-dashed/, + ); + }); + + it("names the lookback a limit and states what the default reads", () => { + const html = render(); + expect(html).toContain("How far back the Coach can look"); + // `allTime` is the default: the sentence carries the numbers the + // readers use (`history-reach.ts`). + expect(html).toContain( + "Everything you recorded: 12 months in daily detail, older readings as monthly values up to 10 years back, lab results from the last 12 months.", + ); + expect(html).not.toContain("stay visible"); + }); + + it("states the limit and what stays visible under a bounded lookback", () => { + prefsState.data = { ...DEFAULT_COACH_PREFS, defaultWindow: "last90days" }; + const html = render(); + expect(html).toContain( + "The Coach reads nothing older than 90 days, in any answer.", + ); + expect(html).toContain("stay visible"); + expect(html).toContain('aria-describedby="coach-sources-window-detail"'); + }); + + it("offers the twelve-month limit", () => { + prefsState.data = { ...DEFAULT_COACH_PREFS, defaultWindow: "lastYear" }; + const html = render(); + expect(html).toContain( + "The Coach reads nothing older than 12 months, in any answer.", + ); }); }); diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index 3f286df51..5e6dc6860 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -16,6 +16,11 @@ import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs"; import { CLUSTER_SOURCES } from "@/lib/ai/coach/clusters"; +import { + COACH_HISTORY_WINDOWS, + HISTORY_REACH_DAYS, + lookbackText, +} from "@/lib/ai/coach/history-reach"; import { DEFAULT_COACH_CLUSTERS, coachDataClusterEnum, @@ -45,12 +50,12 @@ import { const CLUSTER_OPTIONS: ReadonlyArray = coachDataClusterEnum.options; -const WINDOW_OPTIONS: ReadonlyArray = [ - "last7days", - "last30days", - "last90days", - "allTime", -]; +/** + * The lookback options, narrowest first. The choice is a limit + * the server enforces on every Coach read (`history-reach.ts`), so the text + * under the picker says what is read, with the numbers the readers use. + */ +const WINDOW_OPTIONS = COACH_HISTORY_WINDOWS; export interface SourcesRailProps { className?: string; @@ -67,7 +72,7 @@ export interface SourcesRailProps { } export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) { - const { t } = useTranslations(); + const { t, tCount } = useTranslations(); const { data: prefs } = useCoachPrefs(); const save = useSaveCoachPrefs(); @@ -80,6 +85,8 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) { prefs?.dataClusters ?? DEFAULT_COACH_CLUSTERS, ); const activeWindow: CoachDefaultWindow = prefs?.defaultWindow ?? "allTime"; + const activeText = lookbackText(activeWindow); + const bounded = HISTORY_REACH_DAYS[activeWindow] !== null; const [pending, setPending] = useState( null, ); @@ -151,15 +158,15 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) { ) : null} - {/* Window selector — persists to `coachPrefs.defaultWindow` so the - chosen timeframe sticks across drawer opens and matches what - the cog sheet shows. */} + {/* Lookback limit — persists to `coachPrefs.defaultWindow`. The line + under the picker states what the Coach reads at this setting, with + the numbers the server readers use. */}
+

+ {t( + `insights.coach.lookback.${activeText.detailKey}`, + activeText.detailParams, + )} + {bounded ? ` ${t("insights.coach.lookback.note")}` : null} +

{/* Persisted cluster toggles — the same set the settings cog @@ -201,11 +223,15 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) { data-slot="coach-sources-row" data-source={cluster} data-active={checked ? "true" : "false"} + // A switched-off row is told apart by its frame (dashed, no + // fill) and its switch, never by fading the text: opacity over + // muted text fell below WCAG AA in both themes. className={cn( - "border-border/60 bg-muted/30 flex items-center gap-2.5", - "rounded-lg border px-3", + "flex items-center gap-2.5 rounded-lg border px-3", "min-h-11 py-2", - !checked && "opacity-60", + checked + ? "border-border/60 bg-muted/30" + : "border-border border-dashed bg-transparent", )} >
@@ -216,9 +242,7 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) { {t(`insights.coach.cluster.${cluster}.label`)} - {t("insights.coach.sourcesMemberCount", { - count: memberCount, - })} + {tCount("insights.coach.sourcesMemberCount", memberCount)}
{pending === cluster ? ( diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index fe98c4f5a..0b9fbeb5e 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -34,6 +34,12 @@ import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; +import { + COACH_HISTORY_WINDOWS, + HISTORY_REACH_DAYS, + lookbackText, +} from "@/lib/ai/coach/history-reach"; +import type { CoachDefaultWindow } from "@/lib/validations/coach-prefs"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Switch } from "@/components/ui/switch"; import { SettingsCardActions } from "./_card-actions"; @@ -776,6 +782,12 @@ function CoachSettingsForm({ const preferences = asRecord(settings.preferences); const excluded = new Set(asStringArray(preferences.excludeMetrics)); const dataClusters = new Set(asStringArray(preferences.dataClusters)); + const savedWindow = asString(preferences.defaultWindow, "allTime"); + const [lookback, setLookback] = useState( + (COACH_HISTORY_WINDOWS as readonly string[]).includes(savedWindow) + ? (savedWindow as CoachDefaultWindow) + : "allTime", + ); function submit(event: FormEvent) { event.preventDefault(); @@ -847,28 +859,44 @@ function CoachSettingsForm({ -