Merge pull request #526 from MBombeck/release/v1.30.2

release/v1.30.2
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-18 05:26:28 +02:00
commit 3bd550cb21
52 files changed
+1735 -313

No files matched your search

+11
View File
@@ -2,6 +2,17 @@
## [Unreleased]
## [1.30.2] — 2026-07-18
More audit-backlog fixes.
- **Coach history is fully reachable.** The conversation list pages through your whole history and search runs on the server (by title), instead of only the first ~20 loaded chats.
- **Navigation and flows.** A document links to the lab values it produced; the two "Recovery" pages are named distinctly and cross-linked; mood, mental wellbeing, and mood insights link to each other; the nutrients card moved out of "Source priority"; the onboarding checklist and tour no longer point at dead ends; asking the coach from a metric or a plan carries that context in.
- **Correctness.** Correlations, the intraday chart, achievements, and the nutrients overview now bucket days by your own timezone and lead with the most recent data; a multi-source day is no longer counted twice.
- **Performance.** The workouts list caches its de-duplication per filter, so paging through a long history is quick.
No migration. No breaking changes.
## [1.30.1] — 2026-07-18
Polish and small fixes.
+13 -3
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.30.1
version: 1.30.2
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -5068,8 +5068,10 @@ paths:
description: v1.18.0 — cursor-paginated list of the caller's Coach conversations for the history rail, most-recent
activity first. Metadata only (id, title, timestamps, message count); message bodies are not decrypted here.
`limit` defaults to 20, capped at 50; pass the returned `nextCursor` back as `cursor` for the next page (null at
the end). Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer;
the owner is narrowed from the session.
the end). v1.30.2 — optional `q` narrows the page to conversations whose TITLE contains the text
(case-insensitive substring, capped at 200 chars); message bodies are encrypted at rest and are not searched.
Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner
is narrowed from the session.
parameters:
- name: cursor
in: query
@@ -5086,6 +5088,14 @@ paths:
maximum: 50
default: 20
description: Page size. Defaults to 20, capped at 50.
- name: q
in: query
required: false
schema:
type: string
maxLength: 200
description: v1.30.2 — case-insensitive substring filter over the conversation TITLE only (message bodies are encrypted
and not searched). Omit for the unfiltered list.
responses:
"200":
description: A page of the caller's conversations.
+21 -9
View File
@@ -228,7 +228,7 @@
"logout": "Abmelden",
"login": "Anmelden",
"notifications": "Benachrichtigungen",
"theme": "Darstellung",
"theme": "Design",
"themeSystem": "System",
"themeDark": "Dunkel",
"themeLight": "Hell",
@@ -599,12 +599,13 @@
},
"watchdog": {
"title": "Automatische Überwachung",
"description": "HealthLog beobachtet außerdem still im Hintergrund — Benachrichtigungen zu unregelmäßigem Rhythmus und Atemstörungen von einer verbundenen Uhr sowie Warnungen bei Abweichungen von deiner Baseline — und zeigt eine Karte nur, wenn etwas auffällt. Keine Karte ist ein gutes Zeichen, kein fehlendes Feature."
"description": "HealthLog beobachtet außerdem still im Hintergrund — Benachrichtigungen zu unregelmäßigem Rhythmus und Atemstörungen von einer verbundenen Uhr, Warnungen bei Abweichungen von deiner Baseline sowie auffällige Veränderungen zwischen deinen letzten beiden Laborwerten — und zeigt eine Karte nur, wenn etwas auffällt. Keine Karte ist ein gutes Zeichen, kein fehlendes Feature."
}
},
"mood": {
"title": "Stimmung",
"subtitle": "Stimmungsverlauf und Einträge",
"mentalWellbeingLink": "Check-in zum seelischen Wohlbefinden machen",
"addEntry": "Hinzufügen",
"editEntry": "Eintrag bearbeiten",
"moodLevel": "Stimmung",
@@ -2263,6 +2264,8 @@
"historyEmpty": "Deine Unterhaltungen erscheinen hier. Stell eine Frage, um zu starten.",
"historyDeleteAria": "Unterhaltung löschen",
"historyDeleted": "Unterhaltung gelöscht",
"historySearchEmpty": "Keine Unterhaltungen entsprechen deiner Suche.",
"historyLoadingMore": "Mehr wird geladen…",
"sourcesTitle": "Worauf ich zugreife",
"sourcesFresh": "Aktuell",
"sourcesStale": "Veraltet",
@@ -2894,7 +2897,7 @@
"sleep": {
"title": "Noch keine Schlafwerte",
"description": "Sobald Schlafdaten vorliegen (manuell oder via Withings / Apple Health), erscheint hier der nächtliche Stadien-Verlauf.",
"cta": "Withings verbinden"
"cta": "Quelle verbinden"
}
},
"navWorkouts": "Workouts",
@@ -3262,6 +3265,8 @@
},
"navWalkingSpeed": "Gehgeschwindigkeit",
"mood": {
"logLink": "Stimmung eintragen",
"mentalWellbeingLink": "Check-in zum seelischen Wohlbefinden machen",
"heatmapTitle": "Stimmungskalender",
"heatmapNoEntry": "Kein Eintrag",
"legendGreat": "Super",
@@ -3550,7 +3555,8 @@
"sectionSubtitle": "Zusammengesetzte Werte für Erholung, Stress und Belastung aus deinen verbundenen Geräten",
"loadError": "Deine Wellness-Werte konnten gerade nicht geladen werden.",
"loadingLabel": "Deine Gesundheitswerte werden geladen",
"recovery": "Erholung",
"recovery": "Erholungs-Score",
"recoveryCrossLink": "Erholungs- und Belastungsdaten deines Wearables ansehen",
"stress": "Stress",
"strain": "Belastung"
},
@@ -3721,6 +3727,7 @@
"recovery": {
"title": "Erholung",
"description": "Wie gut sich dein Körper erholt hat – die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.",
"scoreCrossLink": "Den zusammengesetzten Erholungs-Score ansehen",
"emptyTitle": "Noch keine Erholungsdaten",
"empty": "Noch keine Erholungsdaten. Verbinde ein WHOOP-, Polar- oder Oura-Konto, dann füllen sich diese Werte, sobald dein Gerät synchronisiert.",
"block": {
@@ -4009,7 +4016,7 @@
},
"wrapUp": {
"title": "Das war die Tour",
"body": "Du kannst jedes Modul jederzeit einzeln erneut ansehen („Diese Tour zeigen“ auf jeder Seite) oder die komplette Tour unter Einstellungen → Erweitert neu starten."
"body": "Diese Führung läuft einmalig – sie öffnet sich nicht von selbst erneut. Alles Gezeigte bleibt erhalten, und du kannst Funktionen jederzeit unter Einstellungen → Module ein- oder ausschalten."
}
}
},
@@ -4084,7 +4091,8 @@
"learning": "Manche Einblicke – Glukose, Schlafrhythmus, Blutdrucktrends, Zyklus – schärfen sich in den ersten ein bis zwei Wochen, sobald sich deine Daten ansammeln. Leere Kacheln heißen jetzt nur, dass wir deine Basislinie noch lernen.",
"connectCta": "Gerät verbinden",
"logCta": "Ersten Wert eintragen",
"importCta": "Bestehende Daten importieren"
"importCta": "Bestehende Daten importieren",
"modulesCta": "Module auswählen"
},
"welcomeBack": {
"title": "Willkommen zurück",
@@ -4549,7 +4557,7 @@
"description": "Strukturierte Datensätze zu Allergien und Familienanamnese."
},
"environment": {
"title": "Umwelt",
"title": "Standort & Wetter",
"subtitle": "Wetter, Tageslicht und Standort-Kontext.",
"disabledHint": "Aktiviere das Umwelt-Modul, um einen Heimatort festzulegen und tägliches Wetter und Tageslicht abzurufen.",
"searchPlaceholder": "Stadt suchen",
@@ -7959,6 +7967,7 @@
"mentalHealth": {
"pageTitle": "Seelisches Wohlbefinden",
"pageDescription": "Vier validierte, freiwillige Selbsttests — PHQ-9 (Stimmung), GAD-7 (Sorgen), WHO-5 (Wohlbefinden) und SCI (Schlaf) — ergänzend zu deinem Stimmungstagebuch. Aussagekräftig ist der Verlauf über die Zeit, nicht ein einzelner Wert.",
"moodLink": "Deinen Stimmungsverlauf ansehen",
"attributionLabel": "Quelle",
"attributionShow": "Quelle und Lizenz für {instrument}",
"choosePrompt": "Check-in auswählen",
@@ -8404,7 +8413,8 @@
"noConditions": "Mit keiner Erkrankung verknüpft",
"previewUnavailable": "Keine Inline-Vorschau für diesen Dateityp",
"uploadedMeta": "Hinzugefügt am {date} · {size}",
"share": "Teilen"
"share": "Teilen",
"labValuesLink": "{count} Laborwerte ansehen"
},
"dropOverlay": {
"title": "Zum Hochladen ablegen",
@@ -8666,7 +8676,9 @@
"coachCheckin": {
"title": "Zeit, einen Plan anzuschauen",
"body": "Es ist etwa eine Woche her, seit du diesen Plan gesetzt hast — behalte ihn, passe ihn an oder lass ihn los. Ganz ohne Druck.",
"bodyPlan": "Vor etwa einer Woche hast du dir vorgenommen: „{plan}“. Behalte es, passe es an oder lass es los — ganz ohne Druck."
"bodyPlan": "Vor etwa einer Woche hast du dir vorgenommen: „{plan}“. Behalte es, passe es an oder lass es los — ganz ohne Druck.",
"adjustPrompt": "Ich möchte meinen Check-in-Plan anpassen.",
"adjustPromptPlan": "Ich möchte diesen Plan anpassen: „{plan}“"
},
"tensionWindow": {
"title": "Vielleicht eine angespannte Phase",
+21 -9
View File
@@ -228,7 +228,7 @@
"logout": "Sign out",
"login": "Sign in",
"notifications": "Notifications",
"theme": "Appearance",
"theme": "Theme",
"themeSystem": "System",
"themeDark": "Dark",
"themeLight": "Light",
@@ -599,12 +599,13 @@
},
"watchdog": {
"title": "Automatic screening",
"description": "HealthLog also watches quietly in the background — irregular-rhythm and breathing-disturbance notifications from a connected watch, plus baseline-drift alerts — and surfaces a card only when something stands out. No card showing is a good sign, not a missing feature."
"description": "HealthLog also watches quietly in the background — irregular-rhythm and breathing-disturbance notifications from a connected watch, baseline-drift health-status alerts, and notable shifts between your two most recent lab panels — and surfaces a card only when something stands out. No card showing is a good sign, not a missing feature."
}
},
"mood": {
"title": "Mood",
"subtitle": "Mood tracker and entries",
"mentalWellbeingLink": "Take a mental-wellbeing check-in",
"addEntry": "Add",
"editEntry": "Edit entry",
"moodLevel": "Mood",
@@ -2263,6 +2264,8 @@
"historyEmpty": "Your conversations will appear here. Ask anything to start.",
"historyDeleteAria": "Delete conversation",
"historyDeleted": "Conversation deleted",
"historySearchEmpty": "No conversations match your search.",
"historyLoadingMore": "Loading more…",
"sourcesTitle": "What I can see",
"sourcesFresh": "Fresh",
"sourcesStale": "Stale",
@@ -2894,7 +2897,7 @@
"sleep": {
"title": "No sleep entries yet",
"description": "Once sleep data lands (manual or via Withings / Apple Health), the per-night stage breakdown will appear here.",
"cta": "Connect Withings"
"cta": "Connect a source"
}
},
"navWorkouts": "Workouts",
@@ -3262,6 +3265,8 @@
},
"navWalkingSpeed": "Walking Speed",
"mood": {
"logLink": "Log a mood entry",
"mentalWellbeingLink": "Take a mental-wellbeing check-in",
"heatmapTitle": "Mood calendar",
"heatmapNoEntry": "No entry",
"legendGreat": "Great",
@@ -3550,7 +3555,8 @@
"sectionSubtitle": "Composite recovery, stress and strain read from your connected devices",
"loadError": "Could not load your wellness scores right now.",
"loadingLabel": "Loading your health scores",
"recovery": "Recovery",
"recovery": "Recovery score",
"recoveryCrossLink": "See your wearable's recovery & strain data",
"stress": "Stress",
"strain": "Strain"
},
@@ -3721,6 +3727,7 @@
"recovery": {
"title": "Recovery",
"description": "How well your body has bounced back — the recovery, strain and load signals your wearable records overnight.",
"scoreCrossLink": "See the composite recovery score",
"emptyTitle": "No recovery data yet",
"empty": "No recovery data yet. Connect a WHOOP, Polar or Oura account and these scores fill in once your device syncs.",
"block": {
@@ -4009,7 +4016,7 @@
},
"wrapUp": {
"title": "That’s the tour",
"body": "You can revisit any module on its own at any time (“Show this tour” on every page) or restart the whole tour under Settings → Advanced."
"body": "This walkthrough runs once — it won't reopen on its own. Everything it covered stays put, and you can turn features on or off any time under Settings → Modules."
}
}
},
@@ -4084,7 +4091,8 @@
"learning": "Some insights — glucose, sleep rhythm, blood-pressure trends, cycle — sharpen over the first week or two as your data builds up. Empty tiles now just mean we're still learning your baseline.",
"connectCta": "Connect a device",
"logCta": "Log your first measurement",
"importCta": "Import existing history"
"importCta": "Import existing history",
"modulesCta": "Choose your modules"
},
"welcomeBack": {
"title": "Welcome back",
@@ -4549,7 +4557,7 @@
"description": "Structured allergies and family history records."
},
"environment": {
"title": "Environment",
"title": "Location & weather",
"subtitle": "Weather, daylight, and location context.",
"disabledHint": "Turn on the Environment module to set a home location and pull in daily weather and daylight.",
"searchPlaceholder": "Search for a city",
@@ -7959,6 +7967,7 @@
"mentalHealth": {
"pageTitle": "Mental wellbeing",
"pageDescription": "Validated, opt-in self-checks — PHQ-9 (mood), GAD-7 (worry), WHO-5 (well-being) and SCI (sleep) — that sit beside your mood tracking. The value is the trend over time, not any single number.",
"moodLink": "View your mood history",
"attributionLabel": "Attribution",
"attributionShow": "Source and licence for {instrument}",
"choosePrompt": "Choose a check-in",
@@ -8404,7 +8413,8 @@
"noConditions": "Not linked to a condition",
"previewUnavailable": "No inline preview for this file type",
"uploadedMeta": "Added {date} · {size}",
"share": "Share"
"share": "Share",
"labValuesLink": "View {count} lab values"
},
"dropOverlay": {
"title": "Drop files to upload",
@@ -8666,7 +8676,9 @@
"coachCheckin": {
"title": "Time to check in on a plan",
"body": "It's been about a week since you set this plan — keep it, adjust it, or let it go. No pressure either way.",
"bodyPlan": "About a week ago you set: “{plan}”. Keep it, adjust it, or let it go — no pressure either way."
"bodyPlan": "About a week ago you set: “{plan}”. Keep it, adjust it, or let it go — no pressure either way.",
"adjustPrompt": "I'd like to adjust my check-in plan.",
"adjustPromptPlan": "I'd like to adjust this plan: “{plan}”"
},
"tensionWindow": {
"title": "Possible tension today",
+21 -9
View File
@@ -228,7 +228,7 @@
"logout": "Cerrar sesión",
"login": "Iniciar sesión",
"notifications": "Notificaciones",
"theme": "Apariencia",
"theme": "Tema",
"themeSystem": "Sistema",
"themeDark": "Oscuro",
"themeLight": "Claro",
@@ -599,12 +599,13 @@
},
"watchdog": {
"title": "Supervisión automática",
"description": "HealthLog también vigila en segundo plano — notificaciones de ritmo irregular y alteraciones respiratorias de un reloj conectado, además de alertas de desviación de tu valor de referencia — y solo muestra una tarjeta cuando algo destaca. Que no aparezca ninguna tarjeta es una buena señal, no una función ausente."
"description": "HealthLog también vigila en segundo plano — notificaciones de ritmo irregular y alteraciones respiratorias de un reloj conectado, alertas de desviación de tu valor de referencia, y cambios destacables entre tus dos últimos paneles de laboratorio — y solo muestra una tarjeta cuando algo destaca. Que no aparezca ninguna tarjeta es una buena señal, no una función ausente."
}
},
"mood": {
"title": "Estado de ánimo",
"subtitle": "Tendencia y registros de estado de ánimo",
"mentalWellbeingLink": "Hacer un chequeo de bienestar mental",
"addEntry": "Añadir",
"editEntry": "Editar entrada",
"moodLevel": "Estado de ánimo",
@@ -2263,6 +2264,8 @@
"historyEmpty": "Tus conversaciones aparecen aquí. Lanza una pregunta para empezar.",
"historyDeleteAria": "Eliminar conversación",
"historyDeleted": "Conversación eliminada",
"historySearchEmpty": "Ninguna conversación coincide con tu búsqueda.",
"historyLoadingMore": "Cargando más…",
"sourcesTitle": "A qué accedo",
"sourcesFresh": "Actuales",
"sourcesStale": "Desactualizados",
@@ -2894,7 +2897,7 @@
"sleep": {
"title": "Aún no hay registros de sueño",
"description": "Una vez disponibles los datos de sueño (manual o vía Withings / Apple Health), aquí aparecerá el desglose por fases.",
"cta": "Conectar Withings"
"cta": "Conectar una fuente"
}
},
"navWorkouts": "Entrenamientos",
@@ -3262,6 +3265,8 @@
},
"navWalkingSpeed": "Velocidad al andar",
"mood": {
"logLink": "Registrar un estado de ánimo",
"mentalWellbeingLink": "Hacer un chequeo de bienestar mental",
"heatmapTitle": "Calendario de ánimo",
"heatmapNoEntry": "Sin registro",
"legendGreat": "Genial",
@@ -3550,7 +3555,8 @@
"sectionSubtitle": "Valores combinados de recuperación, estrés y esfuerzo leídos de tus dispositivos conectados",
"loadError": "No se pudieron cargar tus puntuaciones de bienestar en este momento.",
"loadingLabel": "Cargando tus indicadores de salud",
"recovery": "Recuperación",
"recovery": "Puntuación de recuperación",
"recoveryCrossLink": "Ver los datos de recuperación y esfuerzo de tu wearable",
"stress": "Estrés",
"strain": "Esfuerzo"
},
@@ -3721,6 +3727,7 @@
"recovery": {
"title": "Recuperación",
"description": "Cómo se ha recuperado tu cuerpo: las señales de recuperación, esfuerzo y carga que tu wearable registra durante la noche.",
"scoreCrossLink": "Ver la puntuación de recuperación combinada",
"emptyTitle": "Aún no hay datos de recuperación",
"empty": "Aún no hay datos de recuperación. Conecta una cuenta de WHOOP, Polar u Oura y estas puntuaciones se completarán cuando tu dispositivo sincronice.",
"block": {
@@ -4009,7 +4016,7 @@
},
"wrapUp": {
"title": "Esto es la visita",
"body": "Puedes volver a ver cualquier módulo por separado cuando quieras («Mostrar esta visita» en cada página) o reiniciar la visita completa en Ajustes → Avanzado."
"body": "Esta visita guiada se muestra una sola vez, no volverá a abrirse por sí sola. Todo lo que has visto sigue disponible, y puedes activar o desactivar funciones cuando quieras en Ajustes → Módulos."
}
}
},
@@ -4084,7 +4091,8 @@
"learning": "Algunas conclusiones —glucosa, ritmo de sueño, tendencias de presión arterial, ciclo— se afinan durante la primera semana o dos a medida que se acumulan tus datos. Las casillas vacías ahora solo significan que aún estamos aprendiendo tu línea base.",
"connectCta": "Conectar un dispositivo",
"logCta": "Registra tu primera medición",
"importCta": "Importar historial existente"
"importCta": "Importar historial existente",
"modulesCta": "Elige tus módulos"
},
"welcomeBack": {
"title": "Bienvenido de nuevo",
@@ -4549,7 +4557,7 @@
"description": "Registros estructurados de alergias y antecedentes familiares."
},
"environment": {
"title": "Entorno",
"title": "Ubicación y clima",
"subtitle": "Contexto de tiempo, luz diurna y ubicación.",
"disabledHint": "Activa el módulo Entorno para definir una ubicación principal e incorporar el tiempo y las horas de luz diarias.",
"searchPlaceholder": "Buscar una ciudad",
@@ -7959,6 +7967,7 @@
"mentalHealth": {
"pageTitle": "Bienestar mental",
"pageDescription": "Cuatro autoevaluaciones validadas y opcionales — PHQ-9 (estado de ánimo), GAD-7 (preocupación), WHO-5 (bienestar) y SCI (sueño) — junto a tu registro de ánimo. Lo valioso es la tendencia en el tiempo, no un único número.",
"moodLink": "Ver tu historial de estado de ánimo",
"attributionLabel": "Fuente",
"attributionShow": "Fuente y licencia de {instrument}",
"choosePrompt": "Elige una revisión",
@@ -8404,7 +8413,8 @@
"noConditions": "Sin vincular a una afección",
"previewUnavailable": "Sin vista previa integrada para este tipo de archivo",
"uploadedMeta": "Añadido el {date} · {size}",
"share": "Compartir"
"share": "Compartir",
"labValuesLink": "Ver {count} valores de laboratorio"
},
"dropOverlay": {
"title": "Suelta los archivos para subirlos",
@@ -8666,7 +8676,9 @@
"coachCheckin": {
"title": "Es buen momento para revisar un plan",
"body": "Ha pasado alrededor de una semana desde que definiste este plan: consérvalo, ajústalo o déjalo ir. Sin presión.",
"bodyPlan": "Hace aproximadamente una semana te propusiste: «{plan}». Consérvalo, ajústalo o déjalo ir, sin presión."
"bodyPlan": "Hace aproximadamente una semana te propusiste: «{plan}». Consérvalo, ajústalo o déjalo ir, sin presión.",
"adjustPrompt": "Me gustaría ajustar mi plan de seguimiento.",
"adjustPromptPlan": "Me gustaría ajustar este plan: «{plan}»"
},
"tensionWindow": {
"title": "Quizá algo de tensión hoy",
+21 -9
View File
@@ -228,7 +228,7 @@
"logout": "Se déconnecter",
"login": "Se connecter",
"notifications": "Notifications",
"theme": "Apparence",
"theme": "Thème",
"themeSystem": "Système",
"themeDark": "Sombre",
"themeLight": "Clair",
@@ -599,12 +599,13 @@
},
"watchdog": {
"title": "Surveillance automatique",
"description": "HealthLog surveille aussi discrètement en arrière-plan — notifications de rythme irrégulier et de perturbations respiratoires envoyées par une montre connectée, ainsi que des alertes d'écart par rapport à ta valeur de référence — et n'affiche une carte que si quelque chose se démarque. L'absence de carte est bon signe, pas une fonctionnalité manquante."
"description": "HealthLog surveille aussi discrètement en arrière-plan — notifications de rythme irrégulier et de perturbations respiratoires envoyées par une montre connectée, alertes d'écart par rapport à ta valeur de référence, ainsi que des changements notables entre tes deux derniers bilans de laboratoire — et n'affiche une carte que si quelque chose se démarque. L'absence de carte est bon signe, pas une fonctionnalité manquante."
}
},
"mood": {
"title": "Humeur",
"subtitle": "Tendance et entrées d’humeur",
"mentalWellbeingLink": "Faire un bilan de bien-être mental",
"addEntry": "Ajouter",
"editEntry": "Modifier l’entrée",
"moodLevel": "Humeur",
@@ -2263,6 +2264,8 @@
"historyEmpty": "Tes conversations s’affichent ici. Lance une question pour commencer.",
"historyDeleteAria": "Supprimer la conversation",
"historyDeleted": "Conversation supprimée",
"historySearchEmpty": "Aucune conversation ne correspond à ta recherche.",
"historyLoadingMore": "Chargement…",
"sourcesTitle": "Ce à quoi j’accède",
"sourcesFresh": "À jour",
"sourcesStale": "Obsolètes",
@@ -2894,7 +2897,7 @@
"sleep": {
"title": "Aucune donnée de sommeil",
"description": "Dès que des données de sommeil sont disponibles (manuel ou via Withings / Apple Health), la répartition par stade apparaîtra ici.",
"cta": "Connecter Withings"
"cta": "Connecter une source"
}
},
"navWorkouts": "Entraînements",
@@ -3262,6 +3265,8 @@
},
"navWalkingSpeed": "Vitesse de marche",
"mood": {
"logLink": "Enregistrer une humeur",
"mentalWellbeingLink": "Faire un bilan de bien-être mental",
"heatmapTitle": "Calendrier d'humeur",
"heatmapNoEntry": "Aucune entrée",
"legendGreat": "Excellent",
@@ -3550,7 +3555,8 @@
"sectionSubtitle": "Valeurs combinées de récupération, de stress et d'effort issues de tes appareils connectés",
"loadError": "Impossible de charger vos scores de bien-être pour le moment.",
"loadingLabel": "Chargement de tes indicateurs de santé",
"recovery": "Récupération",
"recovery": "Score de récupération",
"recoveryCrossLink": "Voir les données de récupération et d'effort de votre wearable",
"stress": "Stress",
"strain": "Effort"
},
@@ -3721,6 +3727,7 @@
"recovery": {
"title": "Récupération",
"description": "À quel point votre corps a récupéré : les signaux de récupération, de charge et d'effort que votre wearable enregistre la nuit.",
"scoreCrossLink": "Voir le score de récupération composite",
"emptyTitle": "Pas encore de données de récupération",
"empty": "Pas encore de données de récupération. Connectez un compte WHOOP, Polar ou Oura et ces scores se rempliront dès que votre appareil se synchronise.",
"block": {
@@ -4009,7 +4016,7 @@
},
"wrapUp": {
"title": "C’est la fin de la visite",
"body": "Vous pouvez revoir chaque module séparément à tout moment (« Afficher cette visite » sur chaque page) ou relancer la visite complète dans Réglages → Avancé."
"body": "Cette visite guidée ne s'affiche qu'une fois — elle ne se rouvrira pas d'elle-même. Tout ce qu'elle a montré reste en place, et vous pouvez activer ou désactiver des fonctionnalités à tout moment dans Réglages → Modules."
}
}
},
@@ -4084,7 +4091,8 @@
"learning": "Certaines analyses — glycémie, rythme de sommeil, tendances de tension, cycle — s'affinent au cours de la première semaine ou deux à mesure que vos données s'accumulent. Des tuiles vides signifient simplement que nous apprenons encore votre référence.",
"connectCta": "Connecter un appareil",
"logCta": "Enregistrez votre première mesure",
"importCta": "Importer un historique existant"
"importCta": "Importer un historique existant",
"modulesCta": "Choisir vos modules"
},
"welcomeBack": {
"title": "Bon retour",
@@ -4549,7 +4557,7 @@
"description": "Dossiers structurés des allergies et des antécédents familiaux."
},
"environment": {
"title": "Environnement",
"title": "Localisation et météo",
"subtitle": "Contexte météo, lumière du jour et lieu.",
"disabledHint": "Activez le module Environnement pour définir un lieu de référence et intégrer la météo et la durée du jour quotidiennes.",
"searchPlaceholder": "Rechercher une ville",
@@ -7959,6 +7967,7 @@
"mentalHealth": {
"pageTitle": "Bien-être mental",
"pageDescription": "Quatre auto-évaluations validées et facultatives — PHQ-9 (humeur), GAD-7 (inquiétude), WHO-5 (bien-être) et SCI (sommeil) — en complément de ton suivi de l'humeur. Ce qui compte, c'est la tendance dans le temps, pas un chiffre isolé.",
"moodLink": "Voir ton historique d'humeur",
"attributionLabel": "Source",
"attributionShow": "Source et licence pour {instrument}",
"choosePrompt": "Choisis un bilan",
@@ -8404,7 +8413,8 @@
"noConditions": "Lié à aucune affection",
"previewUnavailable": "Pas d'aperçu intégré pour ce type de fichier",
"uploadedMeta": "Ajouté le {date} · {size}",
"share": "Partager"
"share": "Partager",
"labValuesLink": "Voir {count} valeurs de laboratoire"
},
"dropOverlay": {
"title": "Déposez les fichiers pour les téléverser",
@@ -8666,7 +8676,9 @@
"coachCheckin": {
"title": "Il est temps de revoir un plan",
"body": "Cela fait environ une semaine que vous avez défini ce plan : gardez-le, ajustez-le ou laissez-le. Sans pression.",
"bodyPlan": "Il y a environ une semaine, vous vous étiez fixé : « {plan} ». Gardez-le, ajustez-le ou laissez-le, sans pression."
"bodyPlan": "Il y a environ une semaine, vous vous étiez fixé : « {plan} ». Gardez-le, ajustez-le ou laissez-le, sans pression.",
"adjustPrompt": "J'aimerais ajuster mon plan de suivi.",
"adjustPromptPlan": "J'aimerais ajuster ce plan : « {plan} »"
},
"tensionWindow": {
"title": "Peut-être un peu de tension",
+21 -9
View File
@@ -228,7 +228,7 @@
"logout": "Esci",
"login": "Accedi",
"notifications": "Notifiche",
"theme": "Aspetto",
"theme": "Tema",
"themeSystem": "Sistema",
"themeDark": "Scuro",
"themeLight": "Chiaro",
@@ -599,12 +599,13 @@
},
"watchdog": {
"title": "Monitoraggio automatico",
"description": "HealthLog osserva anche silenziosamente in background — notifiche di ritmo irregolare e disturbi respiratori da un orologio connesso, oltre ad avvisi di scostamento dalla tua base di riferimento — mostrando una scheda solo quando qualcosa emerge. Nessuna scheda visibile è un buon segno, non una funzione mancante."
"description": "HealthLog osserva anche silenziosamente in background — notifiche di ritmo irregolare e disturbi respiratori da un orologio connesso, avvisi di scostamento dalla tua base di riferimento, e variazioni degne di nota tra i tuoi due ultimi pannelli di laboratorio — mostrando una scheda solo quando qualcosa emerge. Nessuna scheda visibile è un buon segno, non una funzione mancante."
}
},
"mood": {
"title": "Umore",
"subtitle": "Andamento e voci dell’umore",
"mentalWellbeingLink": "Fai un check-in sul benessere mentale",
"addEntry": "Aggiungi",
"editEntry": "Modifica voce",
"moodLevel": "Umore",
@@ -2263,6 +2264,8 @@
"historyEmpty": "Le tue conversazioni compaiono qui. Fai una domanda per iniziare.",
"historyDeleteAria": "Elimina conversazione",
"historyDeleted": "Conversazione eliminata",
"historySearchEmpty": "Nessuna conversazione corrisponde alla tua ricerca.",
"historyLoadingMore": "Caricamento…",
"sourcesTitle": "A cosa accedo",
"sourcesFresh": "Aggiornate",
"sourcesStale": "Datate",
@@ -2894,7 +2897,7 @@
"sleep": {
"title": "Nessuna voce di sonno",
"description": "Quando saranno disponibili dati sul sonno (manuale o tramite Withings / Apple Health), qui apparirà la suddivisione per fase.",
"cta": "Collega Withings"
"cta": "Collega una fonte"
}
},
"navWorkouts": "Allenamenti",
@@ -3262,6 +3265,8 @@
},
"navWalkingSpeed": "Velocità di cammino",
"mood": {
"logLink": "Registra un umore",
"mentalWellbeingLink": "Fai un check-in sul benessere mentale",
"heatmapTitle": "Calendario dell'umore",
"heatmapNoEntry": "Nessun dato",
"legendGreat": "Ottimo",
@@ -3550,7 +3555,8 @@
"sectionSubtitle": "Valori combinati di recupero, stress e sforzo letti dai tuoi dispositivi connessi",
"loadError": "Al momento non è stato possibile caricare i tuoi punteggi di benessere.",
"loadingLabel": "Caricamento dei tuoi indicatori di salute",
"recovery": "Recupero",
"recovery": "Punteggio di recupero",
"recoveryCrossLink": "Vedi i dati di recupero e sforzo del tuo wearable",
"stress": "Stress",
"strain": "Sforzo"
},
@@ -3721,6 +3727,7 @@
"recovery": {
"title": "Recupero",
"description": "Quanto bene il tuo corpo si è ripreso: i segnali di recupero, sforzo e carico che il tuo wearable registra durante la notte.",
"scoreCrossLink": "Vedi il punteggio di recupero composito",
"emptyTitle": "Ancora nessun dato di recupero",
"empty": "Ancora nessun dato di recupero. Collega un account WHOOP, Polar o Oura e questi punteggi si popoleranno appena il dispositivo si sincronizza.",
"block": {
@@ -4009,7 +4016,7 @@
},
"wrapUp": {
"title": "Questa è la visita guidata",
"body": "Puoi rivedere ogni modulo singolarmente in qualsiasi momento («Mostra questa visita» su ogni pagina) o riavviare l’intera visita da Impostazioni → Avanzate."
"body": "Questa visita guidata viene mostrata una sola volta: non si riaprirà da sola. Tutto ciò che hai visto resta al suo posto e puoi attivare o disattivare le funzioni quando vuoi da Impostazioni → Moduli."
}
}
},
@@ -4084,7 +4091,8 @@
"learning": "Alcune analisi — glicemia, ritmo del sonno, tendenze della pressione, ciclo — si affinano nella prima settimana o due man mano che i tuoi dati si accumulano. Le caselle vuote ora indicano solo che stiamo ancora imparando il tuo riferimento.",
"connectCta": "Collega un dispositivo",
"logCta": "Registra la tua prima misurazione",
"importCta": "Importa la cronologia esistente"
"importCta": "Importa la cronologia esistente",
"modulesCta": "Scegli i tuoi moduli"
},
"welcomeBack": {
"title": "Bentornato",
@@ -4549,7 +4557,7 @@
"description": "Record strutturati di allergie e anamnesi familiare."
},
"environment": {
"title": "Ambiente",
"title": "Posizione e meteo",
"subtitle": "Contesto meteo, luce del giorno e posizione.",
"disabledHint": "Attiva il modulo Ambiente per impostare una posizione principale e integrare il meteo e le ore di luce giornaliere.",
"searchPlaceholder": "Cerca una città",
@@ -7959,6 +7967,7 @@
"mentalHealth": {
"pageTitle": "Benessere mentale",
"pageDescription": "Quattro autovalutazioni validate e facoltative — PHQ-9 (umore), GAD-7 (preoccupazione), WHO-5 (benessere) e SCI (sonno) — accanto al tuo diario dell'umore. Ciò che conta è l'andamento nel tempo, non un singolo numero.",
"moodLink": "Vedi il tuo andamento dell'umore",
"attributionLabel": "Fonte",
"attributionShow": "Fonte e licenza di {instrument}",
"choosePrompt": "Scegli un check-in",
@@ -8404,7 +8413,8 @@
"noConditions": "Non collegato a una patologia",
"previewUnavailable": "Nessuna anteprima integrata per questo tipo di file",
"uploadedMeta": "Aggiunto il {date} · {size}",
"share": "Condividi"
"share": "Condividi",
"labValuesLink": "Vedi {count} valori di laboratorio"
},
"dropOverlay": {
"title": "Rilascia i file per caricarli",
@@ -8666,7 +8676,9 @@
"coachCheckin": {
"title": "È il momento di rivedere un piano",
"body": "È passata circa una settimana da quando hai definito questo piano: mantienilo, modificalo o lascialo andare. Senza pressioni.",
"bodyPlan": "Circa una settimana fa ti eri proposto: «{plan}». Mantienilo, modificalo o lascialo andare, senza pressioni."
"bodyPlan": "Circa una settimana fa ti eri proposto: «{plan}». Mantienilo, modificalo o lascialo andare, senza pressioni.",
"adjustPrompt": "Vorrei modificare il mio piano di controllo.",
"adjustPromptPlan": "Vorrei modificare questo piano: «{plan}»"
},
"tensionWindow": {
"title": "Forse un po' di tensione oggi",
+21 -9
View File
@@ -228,7 +228,7 @@
"logout": "Wyloguj",
"login": "Zaloguj",
"notifications": "Powiadomienia",
"theme": "Wygląd",
"theme": "Motyw",
"themeSystem": "Systemowy",
"themeDark": "Ciemny",
"themeLight": "Jasny",
@@ -599,12 +599,13 @@
},
"watchdog": {
"title": "Automatyczny nadzór",
"description": "HealthLog dyskretnie obserwuje też dane w tle — powiadomienia o nieregularnym rytmie i zaburzeniach oddychania z podłączonego zegarka, a także alerty o odchyleniu od Twojej wartości bazowej — i pokazuje kartę tylko wtedy, gdy coś zwraca uwagę. Brak karty to dobry znak, a nie brakująca funkcja."
"description": "HealthLog dyskretnie obserwuje też dane w tle — powiadomienia o nieregularnym rytmie i zaburzeniach oddychania z podłączonego zegarka, alerty o odchyleniu od Twojej wartości bazowej oraz zauważalne zmiany między dwoma ostatnimi panelami badań laboratoryjnych — i pokazuje kartę tylko wtedy, gdy coś zwraca uwagę. Brak karty to dobry znak, a nie brakująca funkcja."
}
},
"mood": {
"title": "Nastrój",
"subtitle": "Trend i wpisy nastroju",
"mentalWellbeingLink": "Wykonaj sprawdzian dobrostanu psychicznego",
"addEntry": "Dodaj",
"editEntry": "Edytuj wpis",
"moodLevel": "Nastrój",
@@ -2263,6 +2264,8 @@
"historyEmpty": "Twoje rozmowy pojawią się tutaj. Zadaj pytanie, aby zacząć.",
"historyDeleteAria": "Usuń rozmowę",
"historyDeleted": "Rozmowa usunięta",
"historySearchEmpty": "Żadna rozmowa nie pasuje do wyszukiwania.",
"historyLoadingMore": "Wczytywanie…",
"sourcesTitle": "Do czego mam dostęp",
"sourcesFresh": "Aktualne",
"sourcesStale": "Nieaktualne",
@@ -2894,7 +2897,7 @@
"sleep": {
"title": "Brak danych o śnie",
"description": "Gdy pojawią się dane o śnie (ręcznie lub przez Withings / Apple Health), wyświetli się tu rozkład faz na noc.",
"cta": "Połącz Withings"
"cta": "Połącz źródło"
}
},
"navWorkouts": "Treningi",
@@ -3262,6 +3265,8 @@
},
"navWalkingSpeed": "Prędkość chodu",
"mood": {
"logLink": "Zapisz nastrój",
"mentalWellbeingLink": "Wykonaj sprawdzian dobrostanu psychicznego",
"heatmapTitle": "Kalendarz nastroju",
"heatmapNoEntry": "Brak wpisu",
"legendGreat": "Świetnie",
@@ -3550,7 +3555,8 @@
"sectionSubtitle": "Złożone wartości regeneracji, stresu i obciążenia odczytane z Twoich połączonych urządzeń",
"loadError": "Nie udało się teraz wczytać Twoich wyników dobrostanu.",
"loadingLabel": "Wczytywanie Twoich wskaźników zdrowia",
"recovery": "Regeneracja",
"recovery": "Wynik regeneracji",
"recoveryCrossLink": "Zobacz dane regeneracji i obciążenia z Twojego urządzenia",
"stress": "Stres",
"strain": "Obciążenie"
},
@@ -3721,6 +3727,7 @@
"recovery": {
"title": "Regeneracja",
"description": "Jak dobrze Twój organizm się zregenerował – sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.",
"scoreCrossLink": "Zobacz łączny wynik regeneracji",
"emptyTitle": "Brak danych o regeneracji",
"empty": "Brak danych o regeneracji. Połącz konto WHOOP, Polar lub Oura, a te wyniki uzupełnią się po synchronizacji urządzenia.",
"block": {
@@ -4009,7 +4016,7 @@
},
"wrapUp": {
"title": "To koniec przewodnika",
"body": "Każdy moduł możesz obejrzeć osobno w dowolnej chwili („Pokaż ten przewodnik” na każdej stronie) lub uruchomić cały przewodnik ponownie w Ustawienia → Zaawansowane."
"body": "Ten przewodnik pokazuje się tylko raz — nie otworzy się ponownie sam. Wszystko, co pokazał, zostaje na miejscu, a funkcje możesz włączać i wyłączać w dowolnym momencie w Ustawienia → Moduły."
}
}
},
@@ -4084,7 +4091,8 @@
"learning": "Niektóre wnioski — glukoza, rytm snu, trendy ciśnienia, cykl — wyostrzają się w ciągu pierwszego tygodnia lub dwóch, w miarę gromadzenia danych. Puste kafelki oznaczają teraz tylko, że wciąż poznajemy Twój punkt odniesienia.",
"connectCta": "Połącz urządzenie",
"logCta": "Zapisz pierwszy pomiar",
"importCta": "Importuj istniejącą historię"
"importCta": "Importuj istniejącą historię",
"modulesCta": "Wybierz swoje moduły"
},
"welcomeBack": {
"title": "Witaj ponownie",
@@ -4549,7 +4557,7 @@
"description": "Ustrukturyzowane rekordy alergii i wywiadu rodzinnego."
},
"environment": {
"title": "Środowisko",
"title": "Lokalizacja i pogoda",
"subtitle": "Kontekst pogody, światła dziennego i lokalizacji.",
"disabledHint": "Włącz moduł Środowisko, aby ustawić lokalizację domową i pobierać dzienną pogodę oraz długość dnia.",
"searchPlaceholder": "Szukaj miasta",
@@ -7959,6 +7967,7 @@
"mentalHealth": {
"pageTitle": "Dobrostan psychiczny",
"pageDescription": "Cztery zwalidowane, dobrowolne testy — PHQ-9 (nastrój), GAD-7 (lęk), WHO-5 (samopoczucie) i SCI (sen) — obok Twojego dziennika nastroju. Liczy się trend w czasie, a nie pojedynczy wynik.",
"moodLink": "Zobacz historię swojego nastroju",
"attributionLabel": "Źródło",
"attributionShow": "Źródło i licencja: {instrument}",
"choosePrompt": "Wybierz sprawdzenie",
@@ -8404,7 +8413,8 @@
"noConditions": "Niepowiązany ze schorzeniem",
"previewUnavailable": "Brak podglądu dla tego typu pliku",
"uploadedMeta": "Dodano {date} · {size}",
"share": "Udostępnij"
"share": "Udostępnij",
"labValuesLink": "Zobacz {count} wyników laboratoryjnych"
},
"dropOverlay": {
"title": "Upuść pliki, aby je przesłać",
@@ -8666,7 +8676,9 @@
"coachCheckin": {
"title": "Czas przyjrzeć się planowi",
"body": "Minął mniej więcej tydzień, odkąd ustaliłeś ten plan — zachowaj go, dostosuj lub odpuść. Bez presji.",
"bodyPlan": "Około tygodnia temu postanowiłeś: „{plan}”. Zachowaj to, dostosuj lub odpuść — bez presji."
"bodyPlan": "Około tygodnia temu postanowiłeś: „{plan}”. Zachowaj to, dostosuj lub odpuść — bez presji.",
"adjustPrompt": "Chciałbym dostosować mój plan kontrolny.",
"adjustPromptPlan": "Chciałbym dostosować ten plan: „{plan}”"
},
"tensionWindow": {
"title": "Może chwila napięcia dzisiaj",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.30.1",
"version": "1.30.2",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.30.1";
/* @sw-version-fallback */ "v1.30.2";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* v1.30.2 (QoL H1) — `GET /api/insights/chat` gained an optional `q` search
* param so the history rail's search reaches the caller's FULL conversation
* set (title-only, server-side) instead of filtering only the loaded page.
* This suite pins the route's parsing: trimmed, length-capped, and omitted
* (not an empty string) when absent — `listConversations` treats `undefined`
* and `""` identically, but keeping the route from ever forwarding a bare
* empty string documents the contract at the call site.
*/
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
}));
const { requireModuleEnabled, requireAssistantSurface } = vi.hoisted(() => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
requireAssistantSurface: vi.fn(async () => undefined),
}));
vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled }));
vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface }));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number, meta?: unknown) => ({
data: null,
error,
status,
meta,
}),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
AllProvidersFailedError: class extends Error {},
runRawCompletionWithFallback: vi.fn(),
runStreamingRawCompletionWithFallback: vi.fn(),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: vi.fn(),
resolveProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() }));
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
vi.mock("@/lib/ai/coach/types", () => ({
coachChatRequestSchema: { safeParse: () => ({ success: false }) },
}));
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [],
MAX_ROUNDS: 3,
buildCoachDataInventory: vi.fn(),
renderDataInventory: vi.fn(),
renderFocusHint: vi.fn(() => ""),
buildToolModeAddendum: vi.fn(),
runCoachToolLoop: vi.fn(),
}));
interface ListConversationsCallArgs {
userId: string;
cursor?: string | null;
limit?: number;
q?: string;
}
// `vi.mock` factories are hoisted above every top-level declaration in the
// file, so a plain `const listConversations = vi.fn(...)` referenced inside
// the factory below would hit the temporal dead zone. `vi.hoisted` runs
// before that hoisting and is the documented escape hatch.
const { listConversations } = vi.hoisted(() => ({
listConversations: vi.fn(async (params: ListConversationsCallArgs) => {
void params; // typed purely so `.mock.calls[n][0]` below is not `never`
return { conversations: [], nextCursor: null };
}),
}));
vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: vi.fn(),
createConversation: vi.fn(),
fetchConversationWithMessages: vi.fn(),
listConversations,
}));
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
enqueueCoachMemoryRefresh: vi.fn(),
}));
vi.mock("@/lib/ai/coach/facts", () => ({ storeDeterministicFacts: vi.fn() }));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(),
reserveBudget: vi.fn(),
reconcileSpend: vi.fn(),
resolveDailyCap: vi.fn(() => 200_000),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(),
}));
vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot: vi.fn() }));
vi.mock("@/lib/ai/coach/keyvalues", () => ({
parseKeyValuesSentinel: vi.fn(),
}));
vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn() }));
vi.mock("@/lib/sse/create-stream", () => ({ createSseStream: vi.fn() }));
import { GET } from "../route";
type Envelope = { data: unknown; error: string | null; status: number };
const get = GET as unknown as (req: Request) => Promise<Envelope>;
function listReq(qs = ""): Request {
return new Request(`http://localhost/api/insights/chat${qs}`, {
method: "GET",
});
}
describe("GET /api/insights/chat — q search param", () => {
beforeEach(() => {
vi.clearAllMocks();
requireModuleEnabled.mockResolvedValue({ enabled: true });
requireAssistantSurface.mockResolvedValue(undefined);
listConversations.mockResolvedValue({
conversations: [],
nextCursor: null,
});
});
it("omits q from listConversations when absent", async () => {
await get(listReq());
expect(listConversations).toHaveBeenCalledWith(
expect.objectContaining({ userId: "u1", q: undefined }),
);
});
it("trims and forwards q", async () => {
await get(listReq("?q=%20blood%20pressure%20"));
expect(listConversations).toHaveBeenCalledWith(
expect.objectContaining({ q: "blood pressure" }),
);
});
it("caps an oversized q at 200 chars", async () => {
const long = "a".repeat(500);
await get(listReq(`?q=${long}`));
const call = listConversations.mock.calls[0][0];
expect(call.q).toHaveLength(200);
});
it("treats a whitespace-only q the same as absent (undefined, not empty string)", async () => {
await get(listReq("?q=%20%20%20"));
const call = listConversations.mock.calls[0][0];
// Trimmed to "" — the route still passes the (falsy) trimmed string
// through; listConversations treats "" the same as undefined.
expect(call.q).toBe("");
});
it("still forwards cursor + limit unchanged alongside q", async () => {
await get(listReq("?cursor=abc&limit=10&q=weight"));
expect(listConversations).toHaveBeenCalledWith(
expect.objectContaining({ cursor: "abc", limit: 10, q: "weight" }),
);
});
});
+17 -2
View File
@@ -1435,13 +1435,25 @@ function streamProviderError(args: { code: string }): Response {
// the conversationId existence check + 20-turn cap.
export const POST = apiHandler(handleChatRequest);
/** v1.30.2 (QoL H1) — hard cap on the `?q=` search string length. */
const LIST_QUERY_MAX_LEN = 200;
/**
* GET /api/insights/chat?cursor=<id>&limit=<n>
* GET /api/insights/chat?cursor=<id>&limit=<n>&q=<text>
*
* Cursor-paginated list of the caller's conversations for the rail.
* Default limit 20, hard cap 50. Cursor is the id of the last item
* on the previous page; callers receive `{ nextCursor: null }` when
* they have reached the end.
*
* v1.30.2 (QoL H1) — optional `q` narrows the page to conversations whose
* TITLE contains the text (case-insensitive substring). This makes the
* history rail's search reach the caller's FULL conversation set instead
* of only the loaded page; the client re-issues the cursor walk under the
* new `q` whenever the search box changes rather than filtering client-
* side. Message bodies are encrypted at rest and are NOT searched — a
* decrypt-and-scan over every message would be prohibitively expensive
* for a live keystroke search and is out of scope for this pass.
*/
export const GET = apiHandler(async (request: NextRequest) => {
const auth = await requireAuth();
@@ -1456,11 +1468,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
const cursor = url.searchParams.get("cursor");
const limitRaw = url.searchParams.get("limit");
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined;
const qRaw = url.searchParams.get("q");
const q = qRaw ? qRaw.trim().slice(0, LIST_QUERY_MAX_LEN) : undefined;
const page = await listConversations({
userId: auth.user.id,
cursor,
limit: Number.isFinite(limit) ? (limit as number) : undefined,
q,
// v1.28.51 (Documents R3, Design A) — the rail now surfaces BOTH health
// threads and doc-scoped threads (the DTO carries `documentId` +
// `documentTitle` so the client badges the fenced ones). Omitting the
@@ -1472,7 +1487,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({
action: { name: "insights.coach.list" },
meta: { count: page.conversations.length },
meta: { count: page.conversations.length, hasQuery: Boolean(q) },
});
return apiSuccess(page);
@@ -196,6 +196,27 @@ describe("GET /api/insights/correlations", () => {
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
// v1.30 (DATAINT M1) — `asc` + `take` truncated the RECENT end of the
// 180-day window on a dense account; the read must order `desc` so the
// cap keeps the newest rows (re-sorted `asc` in JS before series-building).
it("reads the measurement + mood window ordered desc so the cap keeps the newest rows", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq());
expect(prisma.measurement.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: { measuredAt: "desc" },
take: 20000,
}),
);
expect(prisma.moodEntry.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: { moodLoggedAt: "desc" },
take: 5000,
}),
);
});
// v1.18.0 (B2) — the route now also requires the `insights` module.
it("returns 403 + module.disabled when the insights module is off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+30 -5
View File
@@ -101,7 +101,17 @@ export const GET = apiHandler(async () => {
// `buildMeasurementDailySeries`'s per-type grain resolution below (source
// collapse for cumulative types, per-night reconstruction for sleep); the
// rest of the channels never look at them.
const [measurements, moodEntries, priorityJson] = await Promise.all([
// v1.30 (PERFAUDIT M1) — `orderBy asc` + `take` truncates from the START
// of the window. A dense account (unconsolidated step chunks alone can
// run ~200 rows/day → 36 000 rows over 180 days) can exceed the cap, and
// an ascending order then drops the NEWEST rows — exactly backwards for
// `discoverEmergingCorrelations`, whose entire job is the recent window.
// Order DESC so the cap keeps the most-recent rows, then re-sort ASC in
// JS before folding into per-type series (every downstream day-bucketing
// pass is order-insensitive, but callers document an ascending contract).
const MEASUREMENT_READ_CAP = 20000;
const MOOD_READ_CAP = 5000;
const [measurementsDesc, moodEntriesDesc, priorityJson] = await Promise.all([
prisma.measurement.findMany({
where: {
userId: user.id,
@@ -109,8 +119,8 @@ export const GET = apiHandler(async () => {
measuredAt: { gte: since },
type: { in: [...behaviourTypes, ...outcomeTypes] },
},
orderBy: { measuredAt: "asc" },
take: 20000,
orderBy: { measuredAt: "desc" },
take: MEASUREMENT_READ_CAP,
select: {
type: true,
value: true,
@@ -122,12 +132,20 @@ export const GET = apiHandler(async () => {
}),
prisma.moodEntry.findMany({
where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "asc" },
take: 5000,
orderBy: { moodLoggedAt: "desc" },
take: MOOD_READ_CAP,
select: { score: true, moodLoggedAt: true },
}),
loadUserSourcePriority(user.id),
]);
const measurementsCapped = measurementsDesc.length >= MEASUREMENT_READ_CAP;
const moodCapped = moodEntriesDesc.length >= MOOD_READ_CAP;
const measurements = [...measurementsDesc].sort(
(a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(),
);
const moodEntries = [...moodEntriesDesc].sort(
(a, b) => a.moodLoggedAt.getTime() - b.moodLoggedAt.getTime(),
);
const measurementsByType = new Map<string, MeasurementSeriesRow[]>();
for (const m of measurements) {
@@ -221,6 +239,13 @@ export const GET = apiHandler(async () => {
pairs_tested: result.pairsTested,
discovered: result.discovered.length,
fdr_q: result.fdrQ,
// PERFAUDIT M1 — surfaces when a dense account's window exceeded the
// read cap. The cap now falls on the OLDEST rows (desc + take), so a
// capped read still covers the recent window `discoverEmergingCorrelations`
// needs; this only tells a dashboard the retrospective scan's older
// half of the window may be thin.
measurements_capped: measurementsCapped,
mood_entries_capped: moodCapped,
// FDREXTEND — per-channel day-counts so a dashboard can see whether the
// two sparse new channels reached the n ≥ 20 floor or degraded to absent.
compliance_days: complianceSeries.points.length,
@@ -132,6 +132,32 @@ describe("GET /api/nutrients", () => {
]);
});
it("counts a multi-source day once (DATAINT M3 regression)", async () => {
// v1.29 migration 0249 lets a day carry one row per source. A non-latest
// day logged via both APPLE_HEALTH and MANUAL must still count as ONE
// day, not two.
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
{ nutrient: "water", unit: "ml", day: "2026-07-13", amount: 900 },
{ nutrient: "water", unit: "ml", day: "2026-07-12", amount: 600 },
{ nutrient: "water", unit: "ml", day: "2026-07-12", amount: 400 },
{ nutrient: "water", unit: "ml", day: "2026-07-11", amount: 500 },
] as never);
const res = await GET(getReq("?days=7"));
const body = (await res.json()) as OverviewResponse;
const water = body.data.nutrients.find((n) => n.nutrient === "water");
expect(water).toEqual({
nutrient: "water",
unit: "ml",
latestDay: "2026-07-13",
latestAmount: 900,
// Three distinct days (13th, 12th, 11th) — the buggy version counted
// four, since the 12th's second source row didn't match the pinned
// `latestDay` (the 13th) and opened a phantom extra day.
daysWithData: 3,
});
});
it("scopes the query to the authenticated user and the window floor", async () => {
await GET(getReq("?days=14"));
expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledWith(
+33 -17
View File
@@ -17,6 +17,7 @@ import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NUTRIENT_CODES, isNutrientCode } from "@/lib/nutrients/catalog";
import { nutrientOverviewQuerySchema } from "@/lib/validations/nutrients";
import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
@@ -36,12 +37,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const { days } = parsed.data;
// Window floor as a UTC day key. The stored `day` is a local-timezone
// key, so the UTC floor is off by at most one calendar day at the
// window edge — fine for a "last N days" summary card.
const since = new Date(Date.now() - (days - 1) * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
// v1.30 (DATAINT L4) — window floor anchored on the caller's own local
// "today" (`User.timezone`), mirroring the sibling `GET
// /api/nutrients/daily` route. The stored `day` is a local-timezone key,
// so a UTC floor was off by up to a calendar day at the window edge for
// any non-UTC user.
const userTz = user.timezone || DEFAULT_TIMEZONE;
const todayKey = userDayKey(new Date(), userTz);
const since = shiftDateKey(todayKey, -(days - 1));
const rows = await prisma.nutrientIntakeDay.findMany({
where: { userId: user.id, day: { gte: since } },
@@ -50,14 +53,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
// v1.29 — `source` joined the PK (migration 0249): a day can now carry
// an APPLE_HEALTH row AND a MANUAL row, so the fold sums amounts
// WITHIN (nutrient, day) before folding across days. Rows arrive
// day-DESC inside each nutrient, so the first DAY seen per code is
// the latest; a second row for that same day (the other source) adds
// to the running total instead of opening a new "day".
// an APPLE_HEALTH row AND a MANUAL row. Rows arrive day-DESC inside each
// nutrient, so the first DAY seen per code is the latest; a second row
// for that same day (the other source) adds to the running total instead
// of opening a new "day".
//
// v1.30 (DATAINT M3) — `daysWithData` used to increment on every row
// whose `day` differed from the PINNED `latestDay` field, so a second
// source's row on an already-counted OLDER day (e.g. water logged via
// both APPLE_HEALTH and MANUAL on the same non-latest day) opened a
// second "day" for that same calendar date. `daysSeen` is a per-nutrient
// set of distinct day keys — every row adds to it, so a repeated day
// (any number of source rows) is counted exactly once.
const byCode = new Map<
string,
{ unit: string; latestDay: string; latestAmount: number; days: number }
{
unit: string;
latestDay: string;
latestAmount: number;
daysSeen: Set<string>;
}
>();
for (const row of rows) {
const existing = byCode.get(row.nutrient);
@@ -66,12 +81,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
unit: row.unit,
latestDay: row.day,
latestAmount: row.amount,
days: 1,
daysSeen: new Set([row.day]),
});
} else if (row.day === existing.latestDay) {
continue;
}
existing.daysSeen.add(row.day);
if (row.day === existing.latestDay) {
existing.latestAmount += row.amount;
} else {
existing.days += 1;
}
}
@@ -86,7 +102,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
unit: summary.unit,
latestDay: summary.latestDay,
latestAmount: summary.latestAmount,
daysWithData: summary.days,
daysWithData: summary.daysSeen.size,
};
});
+103 -51
View File
@@ -30,10 +30,18 @@
* slice the canonical projection. A naive `skip` + per-window dedup
* double-counts boundary clusters across pages because a cluster
* whose first member sits in page N-1 and whose later member sits in
* page N would be dropped on N-1 and surface again on N. Workout
* volume for HealthLog is bounded (single user, tens of workouts per
* week — single-digit MB at the table's busiest), so reading the
* full filtered set per request is well within budget.
* page N would be dropped on N-1 and surface again on N.
*
* The canonical projection (the deduped, sorted row set) is cached
* keyed on the FILTER (`userId|since|until|sportType`) — deliberately
* WITHOUT `offset`/`limit` — so every page of a drain shares one cache
* entry: the first page's request builds it (one `findMany` + one
* dedup pass), every later page in the same TTL window slices the
* already-built array for free. Before this, each page carried its own
* cache key, so a full historical drain (the iOS app's "drain
* historical workouts" path, potentially thousands of rows on a
* 10-year Apple Health import) re-ran the full read + dedup once per
* page — O(pages × rows) instead of O(rows).
*
* Response shape (iOS contract):
* - `workouts: WorkoutListEntry[]` — each entry exposes
@@ -68,6 +76,32 @@ interface WorkoutsParams {
readonly sportType: string | null;
}
type WorkoutFilterParams = Pick<
WorkoutsParams,
"since" | "until" | "sportType"
>;
interface CanonicalWorkoutRow {
id: string;
source: string;
externalId: string | null;
sportType: string | null;
startedAt: Date | null;
endedAt: Date | null;
durationSec: number | null;
totalDistanceM: number | null;
totalEnergyKcal: number | null;
avgHeartRate: number | null;
maxHeartRate: number | null;
createdAt: Date;
}
/** The cached, deduped, most-recent-first row set for one filter combination. */
interface WorkoutsProjection {
canonical: CanonicalWorkoutRow[];
rawCount: number;
}
interface WorkoutsResult {
workouts: Array<{
id: string;
@@ -90,11 +124,11 @@ interface WorkoutsResult {
};
}
async function buildWorkoutsResponse(
async function buildWorkoutsProjection(
userId: string,
params: WorkoutsParams,
): Promise<WorkoutsResult> {
const { limit, offset, since, until, sportType } = params;
params: WorkoutFilterParams,
): Promise<WorkoutsProjection> {
const { since, until, sportType } = params;
const where: Record<string, unknown> = { userId };
if (since || until) {
@@ -115,29 +149,34 @@ async function buildWorkoutsResponse(
where.sportType = sportType;
}
const rows = await prisma.workout.findMany({
where,
orderBy: [{ startedAt: "desc" }, { id: "asc" }],
select: {
id: true,
source: true,
externalId: true,
sportType: true,
startedAt: true,
endedAt: true,
durationSec: true,
totalDistanceM: true,
totalEnergyKcal: true,
avgHeartRate: true,
maxHeartRate: true,
createdAt: true,
},
});
// Independent reads — the sourcePriorityJson lookup doesn't depend on the
// row read, so run them concurrently rather than paying two sequential
// round trips.
const [rows, userRow] = await Promise.all([
prisma.workout.findMany({
where,
orderBy: [{ startedAt: "desc" }, { id: "asc" }],
select: {
id: true,
source: true,
externalId: true,
sportType: true,
startedAt: true,
endedAt: true,
durationSec: true,
totalDistanceM: true,
totalEnergyKcal: true,
avgHeartRate: true,
maxHeartRate: true,
createdAt: true,
},
}),
prisma.user.findUnique({
where: { id: userId },
select: { sourcePriorityJson: true },
}),
]);
const userRow = await prisma.user.findUnique({
where: { id: userId },
select: { sourcePriorityJson: true },
});
const canonical = pickCanonicalWorkoutRows(
rows,
userRow?.sourcePriorityJson ?? null,
@@ -154,27 +193,37 @@ async function buildWorkoutsResponse(
return a.id.localeCompare(b.id);
});
const page = canonical.slice(offset, offset + limit).map((row) => ({
id: row.id,
sportType: row.sportType,
startedAt: row.startedAt,
endedAt: row.endedAt,
durationSec: row.durationSec,
distanceM: row.totalDistanceM,
activeEnergyKcal: row.totalEnergyKcal,
avgHr: row.avgHeartRate,
maxHr: row.maxHeartRate,
source: row.source,
externalId: row.externalId,
}));
return { canonical, rawCount: rows.length };
}
function sliceWorkoutsProjection(
projection: WorkoutsProjection,
limit: number,
offset: number,
): WorkoutsResult {
const page = projection.canonical
.slice(offset, offset + limit)
.map((row) => ({
id: row.id,
sportType: row.sportType,
startedAt: row.startedAt,
endedAt: row.endedAt,
durationSec: row.durationSec,
distanceM: row.totalDistanceM,
activeEnergyKcal: row.totalEnergyKcal,
avgHr: row.avgHeartRate,
maxHr: row.maxHeartRate,
source: row.source,
externalId: row.externalId,
}));
return {
workouts: page,
meta: {
total: canonical.length,
total: projection.canonical.length,
limit,
offset,
droppedDuplicates: rows.length - canonical.length,
droppedDuplicates: projection.rawCount - projection.canonical.length,
},
};
}
@@ -207,15 +256,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
const until = searchParams.get("until");
const sportType = searchParams.get("sportType");
const params: WorkoutsParams = { limit, offset, since, until, sportType };
const cacheKey = `${user.id}|${limit}|${offset}|${since ?? ""}|${until ?? ""}|${sportType ?? ""}`;
// Deliberately excludes `limit`/`offset` — every page of one filter
// combination shares this cached projection (see the docblock above).
const projectionKey = `${user.id}|${since ?? ""}|${until ?? ""}|${sportType ?? ""}`;
const result = await cached(
caches.workouts as ServerCache<WorkoutsResult>,
cacheKey,
() => buildWorkoutsResponse(user.id, params),
const projection = await cached(
caches.workouts as ServerCache<WorkoutsProjection>,
projectionKey,
() => buildWorkoutsProjection(user.id, { since, until, sportType }),
annotate,
);
const result = sliceWorkoutsProjection(projection, limit, offset);
return apiSuccess(result);
});
+70 -17
View File
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Loader2,
MessagesSquare,
Paperclip,
Search,
@@ -18,13 +19,15 @@ import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread";
import {
useCoachConversations,
useCoachConversationHistory,
useDeleteCoachConversationWithUndo,
} from "@/components/insights/coach-panel/use-coach";
import type { CoachConversationDTO } from "@/lib/ai/coach/types";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { cn } from "@/lib/utils";
@@ -37,8 +40,14 @@ import { cn } from "@/lib/utils";
* sibling of the Coach page itself: a search field at the top, then the
* recent conversations grouped by recency (Today / Yesterday / This week
* / Earlier). Selecting a row routes to `/coach?c=<id>` — the existing
* deep-link open mechanism — so there is NO new API and NO new state;
* the page is a thin read over `useCoachConversations()`.
* deep-link open mechanism. Reuses the SAME `/coach?c=<id>` open
* mechanism the rail uses.
*
* v1.30.2 (QoL H1) — the page is a thin read over
* `useCoachConversationHistory()` (cursor-paginated + server-searched, see
* the route's doc comment), which the drawer's `<HistoryRail>` also
* consumes — the two surfaces can no longer drift onto different
* pagination behaviour.
*
* Gating mirrors `/coach`: the operator master flag OR a per-user opt-out
* hides the Coach entirely and redirects back to `/insights` rather than
@@ -101,21 +110,42 @@ function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] {
function CoachConversationsBody() {
const { t } = useTranslations();
const router = useRouter();
const { conversations, isLoading, isError, refetch } =
useCoachConversations();
const [filter, setFilter] = useState<string>("");
// v1.30.2 (QoL H1) — search now drives the server-side title query;
// debounce the keystroke draft instead of re-issuing a request per key.
const debouncedFilter = useDebouncedValue(filter, 200);
const {
conversations,
isLoading,
isError,
refetch,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useCoachConversationHistory({ search: debouncedFilter });
const { pendingDeleteIds, requestDelete, undoDelete } =
useDeleteCoachConversationWithUndo();
const [filter, setFilter] = useState<string>("");
// Mirror the rail's title substring filter.
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
const visible = conversations.filter((c) => !pendingDeleteIds.has(c.id));
if (!q) return visible;
return visible.filter((c) => c.title.toLowerCase().includes(q));
}, [conversations, filter, pendingDeleteIds]);
// pendingDeleteIds is a client-only hide filter for the just-armed undo
// window — the server already resolved the rest of `visible` against the
// search term.
const visible = useMemo(
() => conversations.filter((c) => !pendingDeleteIds.has(c.id)),
[conversations, pendingDeleteIds],
);
const isSearching = debouncedFilter.trim().length > 0;
const groups = useMemo(() => groupByRecency(filtered), [filtered]);
const groups = useMemo(() => groupByRecency(visible), [visible]);
// Same callback-ref pattern as the rail: a `useState` setter (not a plain
// `useRef`) so the sentinel hook's `root` re-fires its effect once the
// real scroll-container node exists (null on the very first render).
const [listNode, setListNode] = useState<HTMLDivElement | null>(null);
const sentinelRef = useLoadMoreSentinel({
enabled: hasNextPage && !isFetchingNextPage,
onLoadMore: fetchNextPage,
root: listNode,
});
function handleSelect(id: string) {
// Reuse the existing `?c=<id>` open mechanism on the Coach page.
@@ -175,20 +205,21 @@ function CoachConversationsBody() {
</header>
<div
ref={setListNode}
data-slot="coach-conversations-list"
className={cn(
"-mx-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1",
COACH_SCROLLBAR,
)}
>
{isLoading && conversations.length === 0 ? (
{isLoading && visible.length === 0 ? (
<p
data-slot="coach-conversations-loading"
className="text-muted-foreground px-1 py-3 text-sm"
>
{t("common.loading")}
</p>
) : isError && conversations.length === 0 ? (
) : isError && visible.length === 0 ? (
// A load failure must not masquerade as "no conversations yet" —
// that reads as a data-loss scare and offers no way back. Mirror
// /coach/plans and surface a retry.
@@ -202,7 +233,9 @@ function CoachConversationsBody() {
<MessagesSquare className="size-6" aria-hidden="true" />
</span>
<p className="text-muted-foreground max-w-xs text-sm leading-relaxed">
{t("insights.coach.historyEmpty")}
{isSearching
? t("insights.coach.historySearchEmpty")
: t("insights.coach.historyEmpty")}
</p>
</div>
) : (
@@ -278,6 +311,26 @@ function CoachConversationsBody() {
</section>
))
)}
{/* v1.30.2 (QoL H1) — same IntersectionObserver sentinel as the
drawer rail: scrolling this div into view pulls the next cursor
page. Empty + aria-hidden; the status row below is the a11y
signal. */}
{visible.length > 0 && hasNextPage ? (
<div ref={sentinelRef} aria-hidden="true" className="h-px" />
) : null}
{isFetchingNextPage ? (
<div
data-slot="coach-conversations-loading-more"
role="status"
className="text-muted-foreground flex items-center justify-center gap-2 py-3 text-xs"
>
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{t("insights.coach.historyLoadingMore")}
</div>
) : null}
</div>
</div>
);
+42 -2
View File
@@ -6,11 +6,13 @@ import { useQuery } from "@tanstack/react-query";
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
import type { CoachNudgeStatus } from "@/components/insights/layout-coach-fab";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { coachScopeSourceSchema } from "@/lib/ai/coach/types";
/**
* v1.12.0 (Coach v2 #6) — full-page Coach conversation.
@@ -57,6 +59,23 @@ import { apiGet } from "@/lib/api/api-fetch";
* most-recent thread so the user lands on what the Coach said.
* The search-param read sits in a Suspense child so the client-bailout
* never de-opts the route.
*
* 2026-07-17 UX-flows audit — the route understood only `?c=` / `?doc=`, so
* every other cross-surface hand-off (a metric card's "ask about this", the
* Today check-in's "Adjust", a workout detail's coach button) dropped its
* context at the URL boundary and landed on a blank composer (F1-2 / F4-1 /
* F6-1). Two additive params close that gap, both seeding the SAME props
* `<CoachConversation>` already exposes for the drawer's suggested-prompt
* chips — no new plumbing inside the conversation surface itself:
* - `?scope=<CoachScopeSource>` — narrows the snapshot the FIRST turn of a
* fresh conversation reads (validated against the closed enum; an
* unrecognised value is silently dropped rather than reaching the chat
* route with a free-form string).
* - `?ask=<text>` — seeds the composer prefill (mirrors `prefill` on the
* in-app `askCoach()` launch call). The user still reviews/sends it —
* this is a prefill, not an auto-send.
* Both are ignored once `?c=` or `?doc=` pin an existing/scoped thread — scope
* only ever applies to a fresh conversation's first turn.
*/
function CoachPageBody() {
const searchParams = useSearchParams();
@@ -71,13 +90,30 @@ function CoachPageBody() {
const rawDoc = searchParams.get("doc");
const seedDocumentId = deepLinkedId === null && rawDoc ? rawDoc : null;
// A fresh conversation only — an existing thread (`?c=`) or a doc-scoped
// chat (`?doc=`) keeps its own established scope.
const freshChat = deepLinkedId === null && seedDocumentId === null;
const rawScope = freshChat ? searchParams.get("scope") : null;
const scopeResult = rawScope
? coachScopeSourceSchema.safeParse(rawScope)
: null;
const launchScope: CoachLaunchScope | null = scopeResult?.success
? { metric: scopeResult.data }
: null;
const seedPrefill = freshChat ? searchParams.get("ask") : null;
// C1 exception — an unread coach-initiated message opens the most-recent
// conversation (which holds that proactive turn). Only consulted when the
// entry did not pin a specific thread or ask for a fresh chat.
// A `?doc=` open is an explicit fresh doc-scoped chat — never override it by
// resuming the most-recent thread on an unread nudge.
// resuming the most-recent thread on an unread nudge. A `?scope=`/`?ask=`
// hand-off is likewise an explicit fresh-chat request.
const exceptionEligible =
deepLinkedId === null && rawC !== "new" && seedDocumentId === null;
deepLinkedId === null &&
rawC !== "new" &&
seedDocumentId === null &&
launchScope === null &&
!seedPrefill;
const { data: nudge } = useQuery({
queryKey: queryKeys.coachNudgeStatus(),
queryFn: async (): Promise<CoachNudgeStatus> =>
@@ -95,6 +131,10 @@ function CoachPageBody() {
// v1.28.51 — seed the document scope for a `?doc=<id>` open so the first
// turn is created + sent through the hardened fenced document endpoint.
initialDocumentId={seedDocumentId}
// 2026-07-17 UX-flows audit F1-2/F4-1/F6-1 — seed the scope/prefill a
// cross-surface hand-off carried in the URL.
launchScope={launchScope}
prefill={seedPrefill}
// Default is the new-chat hero; only resume most-recent for the
// unread coach-initiated exception.
autoOpenMostRecent={hasUnreadCoachMessage}
+28 -2
View File
@@ -3,7 +3,7 @@
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Smile } from "lucide-react";
import { ArrowRight, Smile } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -41,7 +41,7 @@ interface ComprehensiveMoodData {
}
export default function InsightsStimmungPage() {
const { isAuthenticated } = useAuth();
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
@@ -101,6 +101,32 @@ export default function InsightsStimmungPage() {
The heatmap and the assessment are lifted out of `MoodInsightsSections`
into their own regions so they land in this exact order; all three
regions share one `moodInsights` query (TanStack dedups the fetch). */}
{/* 2026-07-17 UX/IA audit M9 — mood, the mental-wellbeing screeners, and
this insights page form one mental-health domain; this cross-links
into the other two so the trend a user reads here can lead
somewhere. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
<Link
href="/mood"
data-slot="insights-mood-log-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.mood.logLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
{user?.modules?.mentalHealth === true ? (
<Link
href="/mental-wellbeing"
data-slot="insights-mood-mental-wellbeing-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.mood.mentalWellbeingLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
</div>
<MoodInsightsSections region="heatmap" />
{/* No `<MetricRangeControls>` here: mood is event-driven, not a
+16 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { Loader2 } from "lucide-react";
import Link from "next/link";
import { ArrowRight, Loader2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
@@ -46,6 +47,20 @@ export default function InsightsRecoveryPage() {
explainerMetric="recoveryPage"
coachLaunch
>
{/* 2026-07-17 UX/IA audit M4 — reverse direction of the cross-link added
to the score-anatomy page: this composite wearable page shares the
"Recovery" nav label with the RECOVERY_SCORE anatomy view at
`/insights/scores/recovery`, so a user who arrived here looking for
the score ring / contributor breakdown gets a pointer instead of a
dead end. */}
<Link
href="/insights/scores/recovery"
data-slot="recovery-anatomy-cross-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -mt-2 inline-flex items-center gap-1.5 text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.recovery.scoreCrossLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
<RecoverySection />
</SubPageShell>
);
+18
View File
@@ -1,6 +1,8 @@
"use client";
import { useParams } from "next/navigation";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { BackLink } from "@/components/ui/back-link";
@@ -94,6 +96,22 @@ export default function CompositeScorePage() {
}
coachLaunch
>
{/* 2026-07-17 UX/IA audit M4 — this anatomy view and the `/insights/recovery`
composite-wearable page both used to render as plain "Recovery", a
same-name/different-content trap: the score ring here promised one
thing, the nav pill led somewhere else with no pointer back. The
label above is now "Recovery score" (matches `measurements.typeRecoveryScore`);
this line closes the loop with an explicit cross-link. */}
{metric === "RECOVERY_SCORE" ? (
<Link
href="/insights/recovery"
data-slot="recovery-score-cross-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -mt-2 inline-flex items-center gap-1.5 text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.derived.scores.recoveryCrossLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
<CompositeScoreAnatomy metric={metric} />
</SubPageShell>
);
+5 -1
View File
@@ -78,7 +78,11 @@ export default function InsightsWorkoutDetailPage({
<WorkoutDetailStats workout={data} />
<WorkoutDetailRoute workout={data} />
<WorkoutDetailHRChart workout={data} />
<CoachLaunchButton />
{/* 2026-07-17 UX-flows audit F6-1 — the button used to open an
unscoped chat; the user had to restate which session they
meant. `workouts` narrows the snapshot the first turn reads
(pairs with the coach-scope param added for F4-1). */}
<CoachLaunchButton scope={{ metric: "workouts" }} />
</>
)}
</SubPageShell>
+17 -1
View File
@@ -13,7 +13,7 @@ import { PageHeader } from "@/components/ui/page-header";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Plus, Wrench } from "lucide-react";
import { ArrowRight, Plus, Wrench } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslations } from "@/lib/i18n/context";
@@ -120,6 +120,22 @@ export default function MoodPage() {
</ResponsiveSheet>
<MoodList onAddFirst={() => setDialogOpen(true)} />
{/* 2026-07-17 UX/IA audit M9 — mood tracking, the mental-wellbeing
screeners, and the mood insights page form one mental-health
domain but used to be three unconnected islands. A quiet pointer
here (module-gated — no nav change) rather than a merge: capture
vs. screener stay two defensible surfaces, just cross-linked. */}
{user?.modules?.mentalHealth === true ? (
<Link
href="/mental-wellbeing"
data-slot="mood-mental-wellbeing-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("mood.mentalWellbeingLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
</div>
);
}
@@ -18,8 +18,10 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
Check,
Download,
FlaskConical,
Pencil,
Plus,
Share2,
@@ -32,6 +34,7 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
@@ -230,6 +233,21 @@ export function DocumentDetailSheet({
});
const doc = detail.data;
// 2026-07-17 UX/IA audit M5 / F5-5 — the document → lab-values direction
// had no UI link at all: an auto-staged (PENDING) or OCR-confirmed
// (APPROVED, see `linkOcrLabsToVaultDocument`) OBSERVATION fact already
// ties this document to specific lab rows, but nothing on the sheet said
// so. There is no per-value route yet (`/labs` has no `?analyte=`
// deep-link — see the labs page's own "no per-value relation" comment),
// so this links to the labs list rather than a single biomarker; still a
// real jump where there was none before.
const labFactCount =
doc?.facts.filter(
(fact) => fact.factType === "OBSERVATION" && fact.status !== "REJECTED",
).length ?? 0;
const { user } = useAuth();
const labsModuleEnabled = user?.modules?.labs !== false;
const episodes = useIllnessEpisodes(true);
// AI layer — capability drives the vision/text transport; the mutations run
@@ -792,6 +810,18 @@ export function DocumentDetailSheet({
</div>
</div>
{labsModuleEnabled && labFactCount > 0 ? (
<Link
href="/labs"
data-slot="document-detail-lab-values-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 self-start rounded-md text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<FlaskConical className="size-4" aria-hidden="true" />
{t("documents.detail.labValuesLink", { count: labFactCount })}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
<p className="text-muted-foreground text-xs">
{t("documents.detail.uploadedMeta", {
date: format.date(doc.createdAt),
@@ -5,6 +5,7 @@ import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
@@ -31,6 +32,13 @@ export interface CoachLaunchButtonProps {
label?: string;
/** Optional prefill seed for the next Coach turn. */
prefill?: string;
/**
* 2026-07-17 UX-flows audit F6-1 — optional scope so the conversation this
* button opens narrows to the relevant source(s) instead of starting from
* a blank, unscoped chat. Passed straight through to `askCoach`'s second
* argument (mirrors every other ambient-scope call site).
*/
scope?: CoachLaunchScope;
/** Optional className passthrough for inline overrides. */
className?: string;
/**
@@ -49,6 +57,7 @@ export interface CoachLaunchButtonProps {
export function CoachLaunchButton({
label,
prefill,
scope,
className,
variant = "inline",
}: CoachLaunchButtonProps) {
@@ -83,7 +92,7 @@ export function CoachLaunchButton({
data-slot="coach-launch-icon"
aria-label={accessibleLabel}
title={accessibleLabel}
onClick={() => launch.askCoach(prefill ?? null)}
onClick={() => launch.askCoach(prefill ?? null, scope)}
className={cn("text-muted-foreground hover:text-foreground", className)}
>
<Sparkles className="size-4" aria-hidden="true" />
@@ -101,7 +110,7 @@ export function CoachLaunchButton({
variant="outline"
size="sm"
data-slot="coach-launch-inline"
onClick={() => launch.askCoach(prefill ?? null)}
onClick={() => launch.askCoach(prefill ?? null, scope)}
className={cn("inline-flex h-10 gap-2 self-end", className)}
>
<Sparkles className="size-4" aria-hidden="true" />
@@ -3,23 +3,46 @@ import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { HistoryRail } from "../history-rail";
import type { CoachConversationsPage } from "@/lib/ai/coach/types";
/**
* v1.4.20 phase B2b — history-rail render snapshot.
* v1.30.2 (QoL H1) — the rail now reads `useCoachConversationHistory`
* (`useInfiniteQuery`), so the seeded cache shape moved from a single
* `CoachConversationsPage` under the flat `coachConversations()` key to the
* `{ pages, pageParams }` infinite-query shape under
* `queryKeys.coachConversationHistory(search)`.
*
* The rail mounts a TanStack Query subscription so we have to wrap it
* in a `<QueryClientProvider>`. We seed the cache with a synthetic
* conversations page so the rail renders the populated state in one
* pass — server-side rendering doesn't drive a real network call.
* in a `<QueryClientProvider>`. We seed the cache with synthetic page(s)
* so the rail renders the populated state in one pass — server-side
* rendering doesn't drive a real network call.
*/
function makeClientWithConversations(page: CoachConversationsPage) {
function makeClientWithConversations(
page: CoachConversationsPage,
search = "",
) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
client.setQueryData(["coachConversations"], page);
client.setQueryData(queryKeys.coachConversationHistory(search), {
pages: [page],
pageParams: [null],
});
return client;
}
function makeClientWithPages(pages: CoachConversationsPage[], search = "") {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
client.setQueryData(queryKeys.coachConversationHistory(search), {
pages,
pageParams: pages.map((_, i) => (i === 0 ? null : `cursor-${i}`)),
});
return client;
}
@@ -152,4 +175,55 @@ describe("<HistoryRail>", () => {
const buttons = html.match(/data-slot="coach-history-delete"/g) ?? [];
expect(buttons.length).toBe(2);
});
// v1.30.2 (QoL H1) — infinite history: reachability beyond the first page.
it("renders every conversation across multiple cached pages (not just page one)", () => {
const client = makeClientWithPages([
samplePage,
{
conversations: [
{
id: "c3",
title: "A much older thread",
createdAt: "2026-01-01T08:00:00.000Z",
updatedAt: "2026-01-01T08:01:00.000Z",
messageCount: 1,
fenced: false,
},
],
nextCursor: null,
},
]);
const html = render(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
const rows = html.match(/data-slot="coach-history-item"/g) ?? [];
expect(rows.length).toBe(3);
expect(html).toContain("A much older thread");
});
it("renders the load-more sentinel when a cached page still carries a nextCursor", () => {
const client = makeClientWithConversations({
...samplePage,
nextCursor: "c2",
});
const html = render(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
// The sentinel div is aria-hidden — IntersectionObserver never fires
// during a static SSR render, but the element itself must be present
// for the rail to ever pull the next page in a real browser.
expect(html).toMatch(/aria-hidden="true"[^>]*class="h-px"/);
});
it("does not render the load-more sentinel once the caller has reached the end", () => {
const client = makeClientWithConversations(samplePage); // nextCursor: null
const html = render(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
expect(html).not.toMatch(/class="h-px"/);
});
});
@@ -1,7 +1,14 @@
"use client";
import { useMemo, useState } from "react";
import { MessagesSquare, Paperclip, Search, Trash2 } from "lucide-react";
import {
Loader2,
MessagesSquare,
Paperclip,
RotateCw,
Search,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -9,10 +16,12 @@ import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel";
import { COACH_SCROLLBAR } from "./message-thread";
import {
useCoachConversations,
useCoachConversationHistory,
useDeleteCoachConversationWithUndo,
} from "./use-coach";
@@ -20,9 +29,10 @@ import {
* v1.4.20 phase B2b — conversation history rail.
*
* Lives in the left column of the Coach drawer on `lg+`. Renders the
* paginated list returned by `GET /api/insights/chat`, lets the user
* filter by title (substring match), select one to resume, and delete
* with a confirm-then-go flow.
* caller's FULL conversation history (v1.30.2 — cursor-paginated via
* `useInfiniteQuery`, loaded incrementally as the list scrolls near its
* end), lets the user search by title (server-side, debounced 200 ms),
* select one to resume, and delete with an undo-able confirm.
*
* Selection / activeId is a controlled prop so the drawer owns the
* authoritative state across the message thread + composer.
@@ -55,17 +65,41 @@ export function HistoryRail({
hideHeading = false,
}: HistoryRailProps) {
const { t } = useTranslations();
const { conversations, isLoading } = useCoachConversations();
const [filter, setFilter] = useState<string>("");
// v1.30.2 (QoL H1) — the search box now drives a SERVER-side query
// (title-only substring, see the route doc comment), so debounce the
// keystroke draft rather than re-issuing a request per character.
const debouncedFilter = useDebouncedValue(filter, 200);
const {
conversations,
isLoading,
isError,
refetch,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useCoachConversationHistory({ search: debouncedFilter });
const { pendingDeleteIds, requestDelete, undoDelete } =
useDeleteCoachConversationWithUndo();
const [filter, setFilter] = useState<string>("");
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
const visible = conversations.filter((c) => !pendingDeleteIds.has(c.id));
if (!q) return visible;
return visible.filter((c) => c.title.toLowerCase().includes(q));
}, [conversations, filter, pendingDeleteIds]);
// pendingDeleteIds is a client-only hide filter for the just-armed undo
// window — the server-side search already resolved the rest of `visible`.
const visible = useMemo(
() => conversations.filter((c) => !pendingDeleteIds.has(c.id)),
[conversations, pendingDeleteIds],
);
const isSearching = debouncedFilter.trim().length > 0;
// A `useState`-backed callback ref (not a plain `useRef`) so the
// container's assignment itself triggers a re-render — the sentinel
// hook's effect depends on `root` and must re-run once the real DOM
// node exists (it's null during the very first render pass).
const [listNode, setListNode] = useState<HTMLDivElement | null>(null);
const sentinelRef = useLoadMoreSentinel({
enabled: hasNextPage && !isFetchingNextPage,
onLoadMore: fetchNextPage,
root: listNode,
});
// v1.30.1 M5 — a single tap hides the row and schedules the real
// delete; the toast's Undo action cancels it within the grace
@@ -126,6 +160,7 @@ export function HistoryRail({
/>
</div>
<div
ref={setListNode}
data-slot="coach-history-list"
className={cn(
"-mx-1 flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-1",
@@ -133,22 +168,37 @@ export function HistoryRail({
COACH_SCROLLBAR,
)}
>
{isLoading && conversations.length === 0 ? (
{isLoading && visible.length === 0 ? (
<p
data-slot="coach-history-loading"
className="text-muted-foreground px-2 py-3 text-sm"
>
{t("common.loading")}
</p>
) : filtered.length === 0 ? (
) : isError && visible.length === 0 ? (
// A load failure must not masquerade as "no conversations yet" —
// offer a retry instead of the empty copy.
<div
data-slot="coach-history-error"
className="text-muted-foreground flex flex-col items-start gap-2 px-2 py-3 text-sm"
>
<p>{t("common.loadFailed")}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RotateCw className="size-3.5" aria-hidden="true" />
{t("common.retry")}
</Button>
</div>
) : visible.length === 0 ? (
<p
data-slot="coach-history-empty"
className="text-muted-foreground px-2 py-3 text-sm leading-relaxed"
>
{t("insights.coach.historyEmpty")}
{isSearching
? t("insights.coach.historySearchEmpty")
: t("insights.coach.historyEmpty")}
</p>
) : (
filtered.map((c) => {
visible.map((c) => {
const isActive = c.id === activeId;
return (
<div
@@ -214,6 +264,26 @@ export function HistoryRail({
);
})
)}
{/* v1.30.2 (QoL H1) — IntersectionObserver sentinel: scrolling this
div into the rail's own scrollport pulls the next cursor page.
Empty + aria-hidden — the loading row right below it is the
a11y-visible signal. */}
{visible.length > 0 && hasNextPage ? (
<div ref={sentinelRef} aria-hidden="true" className="h-px" />
) : null}
{isFetchingNextPage ? (
<div
data-slot="coach-history-loading-more"
role="status"
className="text-muted-foreground flex items-center justify-center gap-2 px-2 py-3 text-xs"
>
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{t("insights.coach.historyLoadingMore")}
</div>
) : null}
</div>
</div>
);
@@ -1,7 +1,12 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import type {
CoachConversationAttachmentDTO,
@@ -41,10 +46,13 @@ const QUERY_KEYS = {
};
/**
* List of conversations for the rail. Cursor pagination is exposed via
* `loadMore` — the rail can call it when the user scrolls past the
* end. v1.4.20 lands with default-page-only; cursors are pre-wired so
* v1.4.21 / v1.5 can add infinite scroll without reshaping the hook.
* The single (first) page of conversations. Used ONLY for the
* auto-open-most-recent-thread behaviour on mount
* (`coach-conversation.tsx`), which never needs more than the rail's
* server-authoritative head. Every surface that lets the user BROWSE or
* SEARCH history (the drawer rail, the standalone conversations page) reads
* `useCoachConversationHistory` below instead — it walks the full cursor
* chain via `useInfiniteQuery` rather than stopping at page one.
*/
export function useCoachConversations(enabled = true) {
const query = useQuery({
@@ -67,6 +75,76 @@ export function useCoachConversations(enabled = true) {
};
}
const HISTORY_PAGE_LIMIT = 20;
export interface UseCoachConversationHistoryOptions {
/**
* Server-side title search (see `GET /api/insights/chat?q=`). The caller
* debounces the raw input (`useDebouncedValue`) before passing it here —
* every distinct value keys its own cache page chain, so a fast typist
* never sees a stale search's tail page bleed into a fresh one.
*/
search?: string;
enabled?: boolean;
}
/**
* v1.30.2 (QoL H1) — the full, paginated + server-searched conversation
* history. Replaces the old "first page only, client-side substring
* filter" behaviour: `useInfiniteQuery` walks the cursor chain
* (`nextCursor`) so every conversation the caller has ever had is
* reachable via `fetchNextPage`, and `search` narrows the SERVER'S query
* (title-only — see the route doc comment) rather than filtering an
* already-truncated client array.
*
* Shared by the drawer's `<HistoryRail>` and the standalone
* `/coach/conversations` page so the two surfaces can never drift onto
* different pagination behaviour again.
*/
export function useCoachConversationHistory(
options: UseCoachConversationHistoryOptions = {},
) {
const { search = "", enabled = true } = options;
const trimmedSearch = search.trim();
const query = useInfiniteQuery({
queryKey: queryKeys.coachConversationHistory(trimmedSearch),
queryFn: async ({
pageParam,
}: {
pageParam: string | null;
}): Promise<CoachConversationsPage> => {
const params = new URLSearchParams();
if (pageParam) params.set("cursor", pageParam);
params.set("limit", String(HISTORY_PAGE_LIMIT));
if (trimmedSearch) params.set("q", trimmedSearch);
return apiGet<CoachConversationsPage>(
`/api/insights/chat?${params.toString()}`,
{ headers: { Accept: "application/json" } },
);
},
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
enabled,
staleTime: 30 * 1000,
});
const conversations = useMemo(
() => query.data?.pages.flatMap((page) => page.conversations) ?? [],
[query.data],
);
return {
conversations,
isLoading: query.isLoading,
isError: query.isError,
hasNextPage: query.hasNextPage,
isFetchingNextPage: query.isFetchingNextPage,
fetchNextPage: query.fetchNextPage,
refetch: query.refetch,
};
}
/**
* Fetch one conversation with every message decrypted server-side.
* `id === null` disables the query so the drawer can mount without a
@@ -28,7 +28,9 @@
* the page someone arrives at to take stock.
*/
import { useMemo, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowRight } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
@@ -143,6 +145,19 @@ export function MentalWellbeing() {
</ul>
</section>
{/* 2026-07-17 UX/IA audit M9 — mood tracking, this screener surface,
and the mood insights page form one mental-health domain but used
to be three unconnected islands. A quiet pointer to mood history,
mirroring the one added on `/mood` pointing back here. */}
<Link
href="/mood"
data-slot="mental-wellbeing-mood-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -mt-2 inline-flex items-center gap-1.5 self-start text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("mentalHealth.moodLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
{/* Per-instrument detail, opened from a card body: last result,
trend chart, dated history, Start — the Verlauf lives here,
not on the landing. */}
@@ -15,7 +15,11 @@ const integrationStatusState = vi.hoisted(() => ({
data: undefined as
| {
threshold: number;
integrations: Array<{ integration: string; configured?: boolean }>;
integrations: Array<{
integration: string;
configured?: boolean;
connected?: boolean;
}>;
}
| undefined,
}));
@@ -88,4 +92,23 @@ describe("<SourceCardGrid> Withings card", () => {
expect(html).not.toContain('data-testid="source-card-withings-setup"');
expect(html).toContain("Connect Withings");
});
// 2026-07-17 UX-onboarding audit M6 — the connect CTA opens OAuth in a
// new tab; the callback flips the connection in THAT tab while this
// wizard tab used to render the same unchanged button forever. Once
// `useIntegrationStatuses` (refetches on window focus) reports
// `connected: true`, the card must swap to a badge instead of a dead
// "Connect Withings" button that would just reopen a redundant flow.
it("shows a connected badge instead of the connect button once Withings is linked", () => {
integrationStatusState.data = {
threshold: 3,
integrations: [
{ integration: "withings", configured: true, connected: true },
],
};
const html = render();
expect(html).toContain('data-testid="source-card-withings-connected"');
expect(html).not.toContain('href="/api/withings/connect"');
expect(html).not.toContain("Connect Withings");
});
});
+31 -8
View File
@@ -3,7 +3,14 @@
import Link from "next/link";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, FileUp, PlusCircle, Plug, Sparkles } from "lucide-react";
import {
CheckCircle2,
FileUp,
PlusCircle,
Plug,
Settings2,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
@@ -220,13 +227,29 @@ export function DoneScreen() {
</Button>
</div>
<Link
href="/settings/export"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline-offset-4 hover:underline"
>
<FileUp className="size-3.5" />
{t("onboarding.done.importCta")}
</Link>
{/* 2026-07-17 UX-onboarding audit M5 — the module system (default-on
set + opt-ins like nutrients / mental health / documents) is
otherwise undiscoverable during first-run: the wizard never
mentions it, and the only pointer used to be one tour stop.
A quiet link here, beside the existing import link, gives every
new account one honest pointer to Settings → Modules before they
leave the wizard. */}
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5">
<Link
href="/settings/export"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline-offset-4 hover:underline"
>
<FileUp className="size-3.5" />
{t("onboarding.done.importCta")}
</Link>
<Link
href="/settings/modules"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline-offset-4 hover:underline"
>
<Settings2 className="size-3.5" />
{t("onboarding.done.modulesCta")}
</Link>
</div>
</section>
);
}
@@ -249,13 +249,19 @@ export function GettingStartedChecklist() {
enabled: checklistRelevant,
});
// v1.5 perf audit: skip these two fetches once the user is past
// onboarding. The checklist hides itself anyway via shouldShowChecklist
// when onboardingCompletedAt != null AND measurementCount >= 5;
// v1.5 perf audit: skip these two fetches once the card can't render at
// all (`checklistRelevant`, same gate the meds/AI queries above use).
// withings/status and notifications/preferences are unique to this
// component and were burning ~950 ms of network on every dashboard load
// for established users. See docs/audit/v15-performance.md.
const onboardingPending = !!user && user.onboardingCompletedAt == null;
//
// 2026-07-17 UX audit M1 — this used to read a separate `onboardingPending`
// (`onboardingCompletedAt == null`) instead of `checklistRelevant`, so a
// user who finished the wizard but still had under 5 measurements kept
// seeing the card (`shouldShowChecklist` allows that) while these two
// queries stayed disabled forever: "Connect a data source" and
// "Configure notifications" could never flip done even after the user
// actually connected Withings or a push channel post-wizard.
// v1.17.0 — any connected data source satisfies the step, so we read
// the consolidated integrations envelope (Withings / WHOOP / Fitbit /
@@ -265,7 +271,7 @@ export function GettingStartedChecklist() {
queryFn: async () => {
return apiGet("/api/integrations/status");
},
enabled: onboardingPending,
enabled: checklistRelevant,
});
const { data: notificationsData } = useQuery<NotificationsPreferences>({
@@ -273,7 +279,7 @@ export function GettingStartedChecklist() {
queryFn: async () => {
return apiGet("/api/notifications/preferences");
},
enabled: onboardingPending,
enabled: checklistRelevant,
});
const medicationCount = medsData?.length ?? 0;
+31 -1
View File
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
CheckCircle2,
ClipboardCheck,
FileUp,
Plug,
@@ -119,6 +120,16 @@ export function SourceCardGrid() {
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
const withingsConfigured =
pickStatus(integrationStatus, "withings")?.configured ?? false;
// 2026-07-17 UX-onboarding audit M6 — the connect CTA opens the OAuth
// handshake in a new tab; the callback lands THAT tab on
// Settings → Integrations while this wizard tab never learned the
// connection succeeded. `useIntegrationStatuses` already refetches on
// window focus, so returning to this tab after the OAuth round trip
// picks up the fresh `connected` flag for free — the only missing piece
// was rendering it as a badge instead of leaving the button looking
// untouched.
const withingsConnected =
pickStatus(integrationStatus, "withings")?.connected ?? false;
async function advance() {
if (advancing) return;
@@ -164,6 +175,8 @@ export function SourceCardGrid() {
}
withingsCtaLabel={t("onboarding.source.withings.cta")}
withingsConfigured={withingsConfigured}
withingsConnected={withingsConnected}
withingsConnectedLabel={t("settings.integrationPill.connected")}
withingsSetupCtaLabel={t("onboarding.source.withings.setupCta")}
withingsSetupHint={t("onboarding.source.withings.setupHint")}
/>
@@ -260,6 +273,13 @@ interface SourceCardItemProps {
* credentials saved — i.e. whether `/api/withings/connect` can
* actually succeed right now. */
withingsConfigured: boolean;
/** Whether Withings is already connected (OAuth completed in the tab the
* connect CTA opened). Replaces the connect button with a badge so
* returning to this wizard tab shows the outcome instead of an
* unchanged card. */
withingsConnected: boolean;
/** Localised "Connected" label for the badge above. */
withingsConnectedLabel: string;
/** CTA label shown instead of "Connect Withings" when unconfigured. */
withingsSetupCtaLabel: string;
/** One-sentence explanation of the BYO credential prerequisite. */
@@ -275,6 +295,8 @@ function SourceCardItem({
recommendedLabel,
withingsCtaLabel,
withingsConfigured,
withingsConnected,
withingsConnectedLabel,
withingsSetupCtaLabel,
withingsSetupHint,
}: SourceCardItemProps) {
@@ -325,7 +347,15 @@ function SourceCardItem({
>
{headerRow}
{body}
{withingsConfigured ? (
{withingsConnected ? (
<div
className="text-primary flex items-center gap-1.5 pt-1 text-sm font-medium"
data-testid="source-card-withings-connected"
>
<CheckCircle2 className="size-3.5" aria-hidden="true" />
{withingsConnectedLabel}
</div>
) : withingsConfigured ? (
<div className="flex items-center gap-2 pt-1">
<Button asChild variant="outline" size="sm">
<a
@@ -19,6 +19,13 @@
*
* `parseOAuthOutcome` / `oauthReasonKey` are re-exported from
* `connections-panel.tsx` so existing unit-test imports keep resolving.
*
* 2026-07-17 UX/IA audit M7 — `<NutrientIntakeCard>` (v1.28) moved here from
* Settings → Sources. It is a read-only sync inventory (what the Apple
* Health nutrients opt-in has stored), not a which-connection-wins ranking —
* "Source priority" describes the latter only. Matches the standing
* providers→Integrations rule; the card still renders nothing while the
* `nutrients` module is off.
*/
import Link from "next/link";
@@ -28,6 +35,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
import { NutrientIntakeCard } from "@/components/settings/nutrient-intake-card";
import { Button } from "@/components/ui/button";
export {
@@ -55,6 +63,15 @@ export function IntegrationsSection() {
)}
/>
<ConnectionsPanel />
{/* 2026-07-17 UX/IA audit M7 — the opt-in nutrients sync inventory
(moved from Settings → Sources, where "source priority" only
ever meant which-connection-wins ranking, not a read-only sync
list). The card carries its own header + renders nothing while
the `nutrients` module is off, so it slots in as a sibling card
under the same Connections group rather than a new labelled
section that could paint an orphaned heading over nothing. */}
<NutrientIntakeCard />
</section>
{/* Delivery channels ("where it's delivered"). The existing channels
+5 -4
View File
@@ -45,7 +45,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NutrientIntakeCard } from "@/components/settings/nutrient-intake-card";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
@@ -585,9 +584,11 @@ export function SourcesSection() {
</p>
</SettingsCard>
{/* v1.28 — read-only synced-nutrient list for the opt-in `nutrients`
module (renders nothing while the module is off). */}
<NutrientIntakeCard />
{/* v1.28 shipped `<NutrientIntakeCard>` here; the 2026-07-17 UX/IA
audit (M7) moved it to Settings → Integrations — it is a read-only
Apple-Health sync inventory, not a which-connection-wins ranking,
so it never belonged on the "source priority" ladder. See
`<IntegrationsSection>` for the card's new home. */}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { useEffect, useState } from "react";
/**
* Debounces a fast-changing value (typically a search-input draft) so a
* dependent effect / query key only reacts once the value has settled for
* `delayMs`. Mirrors the inline 200 ms pattern the Dokumente vault search
* already used (`documents-view.tsx`), centralised here so other search
* surfaces (v1.30.2 — the Coach conversation history) don't hand-roll the
* same timeout.
*/
export function useDebouncedValue<T>(value: T, delayMs = 200): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const handle = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(handle);
}, [value, delayMs]);
return debounced;
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useEffect, useRef, type RefObject } from "react";
/**
* IntersectionObserver-driven "load more" sentinel for a scrollable list
* backed by `useInfiniteQuery`. Mount the returned ref on an empty element
* at the end of the rendered list; once it scrolls into `root` (the list's
* own scroll container — pass `null` to watch the viewport instead),
* `onLoadMore` fires. Gated by `enabled` so an in-flight fetch or an
* exhausted list (no `nextCursor`) never re-triggers.
*
* v1.30.2 — extracted for the Coach conversation history (rail + the
* standalone `/coach/conversations` page), which both need the same
* scroll-to-load-more behaviour the Dokumente vault timeline gets from its
* `@tanstack/react-virtual` windowing; a virtualizer would be overkill here
* (conversation counts don't approach document-vault scale), so this is the
* plain-DOM equivalent of that same "pull the next page near the end of the
* window" contract.
*/
export function useLoadMoreSentinel({
enabled,
onLoadMore,
root = null,
}: {
/** True only when there IS a next page and no fetch is already in flight. */
enabled: boolean;
onLoadMore: () => void;
/** Scroll container to observe within; `null` watches the viewport. */
root?: Element | null;
}): RefObject<HTMLDivElement | null> {
const sentinelRef = useRef<HTMLDivElement | null>(null);
// Latest-callback-wins ref so the observer callback never captures a stale
// `onLoadMore` closure without having to re-create the observer on every
// render.
const onLoadMoreRef = useRef(onLoadMore);
useEffect(() => {
onLoadMoreRef.current = onLoadMore;
}, [onLoadMore]);
useEffect(() => {
if (!enabled) return;
const el = sentinelRef.current;
if (!el) return;
if (typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
onLoadMoreRef.current();
}
},
{ root, rootMargin: "200px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [enabled, root]);
return sentinelRef;
}
@@ -103,6 +103,32 @@ describe("listConversations — filter + DTO", () => {
expect(where.attachments).toEqual({ some: { documentId: "doc-9" } });
});
// v1.30.2 (QoL H1) — server-side title search for the full-history rail.
it("applies a case-insensitive title-contains filter when q is given", async () => {
findMany.mockResolvedValue([]);
await listConversations({ userId: "user-1", q: "blood pressure" });
const where = findMany.mock.calls[0][0].where;
expect(where.userId).toBe("user-1");
expect(where.title).toEqual({
contains: "blood pressure",
mode: "insensitive",
});
});
it("trims q and omits the title filter for an empty/whitespace-only query", async () => {
findMany.mockResolvedValue([]);
await listConversations({ userId: "user-1", q: " " });
const where = findMany.mock.calls[0][0].where;
expect("title" in where).toBe(false);
});
it("omits the title filter entirely when q is not given (existing behaviour unchanged)", async () => {
findMany.mockResolvedValue([]);
await listConversations({ userId: "user-1" });
const where = findMany.mock.calls[0][0].where;
expect("title" in where).toBe(false);
});
it("falls back to filename when the first attachment has no title", async () => {
findMany.mockResolvedValue([
{
+12
View File
@@ -435,6 +435,16 @@ export interface ListConversationsParams {
* live join row). `userId` stays narrowed regardless.
*/
attachedDocumentId?: string;
/**
* v1.30.2 (QoL H1) — optional server-side title search for the history
* rail + the standalone conversations page. Case-insensitive substring
* match against `title` only — the only plaintext column on the row;
* `CoachMessage.encryptedContent` cannot be searched without decrypting
* every message, so message BODIES are out of scope for this pass (see
* the route's doc comment). Trimmed empty string is treated as "no
* filter", matching the pre-existing cursor/limit handling style.
*/
q?: string;
}
/**
@@ -449,12 +459,14 @@ export async function listConversations(
nextCursor: string | null;
}> {
const limit = Math.min(Math.max(params.limit ?? 20, 1), 50);
const q = params.q?.trim();
const rows = await prisma.coachConversation.findMany({
where: {
userId: params.userId,
...(params.attachedDocumentId
? { attachments: { some: { documentId: params.attachedDocumentId } } }
: {}),
...(q ? { title: { contains: q, mode: "insensitive" } } : {}),
},
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: limit + 1,
@@ -0,0 +1,157 @@
/**
* Unit coverage for `loadIntradayPulse` — the two DATAINT-audit fixes:
*
* - M2: the PULSE/step reads are bounded to the exact local-day window
* (`localDayWindow`), not the ±15h/39h padded superset a dense account's
* read cap could be entirely consumed by (the previous evening's
* samples starving the viewed day's afternoon).
* - M5: the resting-baseline PULSE-proxy fallback buckets by the CALLER's
* timezone, not a hardcoded Berlin default — `resolveBaseline` now
* threads `timezone` into `resolveRestingPulseSeries`'s `dayKeyOf`.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
const findManyMock = vi.fn();
const workoutFindManyMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: (...args: unknown[]) => findManyMock(...args) },
workout: { findMany: (...args: unknown[]) => workoutFindManyMock(...args) },
},
}));
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import {
resolveRestingPulseSeries,
type PulseSample,
} from "@/lib/analytics/resting-pulse";
import { toBerlinDayKey } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { percentile } from "@/lib/insights/strain-score";
import { localDayWindow } from "@/lib/measurements/consolidation-tz";
/** Mirrors the private `median()` helper in `intraday-pulse-io.ts`. */
function median(values: number[]): number | null {
if (values.length === 0) return null;
return Math.round(percentile(values, 50) * 10) / 10;
}
describe("loadIntradayPulse", () => {
beforeEach(() => {
findManyMock.mockReset();
workoutFindManyMock.mockReset();
workoutFindManyMock.mockResolvedValue([]);
});
it("M2 — queries PULSE/step rows against the exact local-day window, not the padded lead/trail superset", async () => {
const tz = "America/New_York";
const dateKey = "2026-06-15";
const { dayStart, dayEnd } = localDayWindow(dateKey, tz);
findManyMock.mockResolvedValue([]);
await loadIntradayPulse("user-1", tz, dateKey);
const pulseCall = findManyMock.mock.calls.find(
(call) => call[0]?.where?.type === "PULSE",
);
const stepCall = findManyMock.mock.calls.find(
(call) => call[0]?.where?.type === "ACTIVITY_STEPS",
);
expect(pulseCall).toBeDefined();
expect(stepCall).toBeDefined();
expect(pulseCall![0].where.measuredAt).toEqual({
gte: dayStart,
lt: dayEnd,
});
expect(stepCall![0].where.measuredAt).toEqual({
gte: dayStart,
lt: dayEnd,
});
});
it("M5 — the PULSE-proxy baseline buckets by the caller's timezone, not Berlin", async () => {
const tz = "Pacific/Kiritimati"; // UTC+14, no DST — deterministic offset
const dateKey = "2026-06-15";
// Two UTC instants that land on DIFFERENT Kiritimati calendar days but
// the SAME Berlin calendar day (Kiritimati is ~13h ahead of Berlin in
// June) — a clean tz-bucketing fork.
const dayA = [
new Date("2026-06-10T09:00:00.000Z"), // Kiritimati: 2026-06-10T23:00 (day A)
new Date("2026-06-10T10:00:00.000Z"),
new Date("2026-06-10T11:00:00.000Z"),
];
const dayB = [
new Date("2026-06-10T12:00:00.000Z"), // Kiritimati: 2026-06-11T02:00 (day B)
new Date("2026-06-10T13:00:00.000Z"),
new Date("2026-06-10T14:00:00.000Z"),
];
expect(userDayKey(dayA[0], tz)).not.toBe(userDayKey(dayB[0], tz));
// Sanity: under the OLD hardcoded-Berlin bug all six samples fall on the
// SAME Berlin day — proving the two tz groupings genuinely diverge.
const berlinDays = new Set(
[...dayA, ...dayB].map((d) => toBerlinDayKey(d)),
);
expect(berlinDays.size).toBe(1);
const pulseHistory: Array<{ value: number; measuredAt: Date }> = [
...dayA.map((measuredAt, i) => ({ value: 50 + i, measuredAt })),
...dayB.map((measuredAt, i) => ({ value: 80 + i, measuredAt })),
];
findManyMock.mockImplementation(
async (args: { where: { type: string } }) => {
if (args.where.type === "PULSE") return [];
if (args.where.type === "RESTING_HEART_RATE") return [];
return [];
},
);
// `resolveBaseline` fires a SECOND PULSE read (take: 365, no window) when
// resting rows are thin — swap the mock to serve it on the second PULSE
// call specifically by checking call order isn't reliable, so key off
// `select`/`take` shape instead.
findManyMock.mockImplementation(
async (args: {
where: { type: string; measuredAt?: unknown };
take?: number;
}) => {
if (args.where.type === "RESTING_HEART_RATE") return [];
if (args.where.type === "PULSE" && args.take === 365) {
return pulseHistory;
}
// The view-day-windowed PULSE / ACTIVITY_STEPS reads — irrelevant here.
return [];
},
);
const result = await loadIntradayPulse("user-1", tz, dateKey);
expect(result.baselineSource).toBe("proxy");
const expected = resolveRestingPulseSeries({
restingSamples: [],
pulseSamples: pulseHistory as PulseSample[],
dayKeyOf: (d) => userDayKey(d, tz),
});
const expectedBaseline = median(
expected.series.slice(-30).map((p) => p.value),
);
expect(result.baseline).toBe(expectedBaseline);
// Regression guard: the OLD hardcoded-Berlin bucketing would have folded
// every sample into ONE day (see the `berlinDays.size === 1` sanity
// check above) and produced a materially different proxy value — assert
// the fixed result does NOT match that wrong grouping's outcome.
const buggyBerlin = resolveRestingPulseSeries({
restingSamples: [],
pulseSamples: pulseHistory as PulseSample[],
dayKeyOf: (d) => toBerlinDayKey(d),
});
const buggyBaseline = median(
buggyBerlin.series.slice(-30).map((p) => p.value),
);
expect(result.baseline).not.toBe(buggyBaseline);
});
});
+29 -4
View File
@@ -18,6 +18,8 @@
import { prisma } from "@/lib/db";
import { percentile } from "@/lib/insights/strain-score";
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
import { localDayWindow } from "@/lib/measurements/consolidation-tz";
import { userDayKey } from "@/lib/tz/format";
import {
BUCKET_MINUTES,
HOURLY_BUCKET_MINUTES,
@@ -73,8 +75,18 @@ function median(values: number[]): number | null {
* baseline is the median of the resolved daily series — robust to the odd
* outlier — and is "mature" only once at least `MIN_BASELINE_DAYS` distinct
* daily points exist.
*
* `timezone` buckets the PULSE-proxy fallback in the USER's local day
* (DATAINT M5) — the caller previously omitted it, so
* `deriveRestingProxyFromPulse` defaulted to Berlin-day buckets for every
* user. A non-Berlin user's late-evening (often workout-heavy) samples then
* folded into the wrong day's bucket, shifting the daily floor the tension
* detector's `baseline + 12 bpm` threshold judges the day against.
*/
async function resolveBaseline(userId: string): Promise<{
async function resolveBaseline(
userId: string,
timezone: string,
): Promise<{
baseline: number | null;
mature: boolean;
source: "resting" | "proxy" | "none";
@@ -106,6 +118,7 @@ async function resolveBaseline(userId: string): Promise<{
measuredAt: r.measuredAt,
value: r.value,
})),
dayKeyOf: (d) => userDayKey(d, timezone),
});
const recent = resolved.series.slice(-30).map((p) => p.value);
@@ -148,6 +161,18 @@ export async function loadIntradayPulse(
const anchorMs = new Date(`${dateKey}T00:00:00.000Z`).getTime();
const start = new Date(anchorMs - WINDOW_LEAD_MS);
const end = new Date(anchorMs + WINDOW_TRAIL_MS);
// DATAINT M2 — PULSE/step reads are bounded to the EXACT local-day window
// (DST-aware; 23/24/25h), not the ±15h/39h padded superset. The prior
// padded read meant `take: MAX_DAY_*_ROWS` (an ascending cap) could be
// entirely consumed by the PREVIOUS local day's samples (e.g. a
// workout-heavy evening) before reaching the viewed day at all on a dense
// account — the cap now only ever spends against rows that are actually
// inside `dateKey`. `computeTenMinuteMeanSeries` / the step-bucket loop
// already re-filter by `dayKey` defensively, so this is a pure narrowing.
// The workout-overlap read keeps the wider padded window: it has no `take`
// cap, so there is no truncation risk, and a workout can legitimately
// start the local-day before or end the local-day after.
const { dayStart, dayEnd } = localDayWindow(dateKey, timezone);
const [pulseRows, stepRows, workoutRows, baseline] = await Promise.all([
prisma.measurement.findMany({
@@ -155,7 +180,7 @@ export async function loadIntradayPulse(
userId,
type: "PULSE",
deletedAt: null,
measuredAt: { gte: start, lte: end },
measuredAt: { gte: dayStart, lt: dayEnd },
},
orderBy: { measuredAt: "asc" },
take: MAX_DAY_PULSE_ROWS,
@@ -166,7 +191,7 @@ export async function loadIntradayPulse(
userId,
type: "ACTIVITY_STEPS",
deletedAt: null,
measuredAt: { gte: start, lte: end },
measuredAt: { gte: dayStart, lt: dayEnd },
},
orderBy: { measuredAt: "asc" },
take: MAX_DAY_STEP_ROWS,
@@ -180,7 +205,7 @@ export async function loadIntradayPulse(
},
select: { startedAt: true, endedAt: true },
}),
resolveBaseline(userId),
resolveBaseline(userId, timezone),
]);
// Split live PULSE rows into raw per-sample rows and already-folded
+8 -1
View File
@@ -335,7 +335,14 @@ describe("buildDailyDigest — coach check-in (S3)", () => {
expect(item?.body).toContain("every morning → weigh in");
// Keep / let-go carry the plan id; adjust is a plain navigation href.
expect(item?.actions[0].intent).toBe(`${COACH_CHECKIN_KEEP_INTENT}:p1`);
expect(item?.actions[1].href).toBe("/coach");
// 2026-07-17 UX-flows audit F1-2 — adjust used to be a bare `/coach` link
// dropping all plan context; it now carries `?ask=` seeding the coach
// composer with the plan's own words, so the target is the coach route
// with the plan text echoed in the query, not a blank chat.
expect(item?.actions[1].href).toContain("/coach?ask=");
expect(item?.actions[1].href).toContain(
encodeURIComponent("every morning → weigh in"),
);
expect(item?.actions[2].intent).toBe(`${COACH_CHECKIN_LETGO_INTENT}:p1`);
});
+13 -1
View File
@@ -451,6 +451,18 @@ function buildCoachCheckinItem(
? t("daily.item.coachCheckin.bodyPlan", { plan: plan.planText })
: t("daily.item.coachCheckin.body");
// 2026-07-17 UX-flows audit F1-2 — "Adjust" used to be a bare `/coach`
// link carrying no plan context: keep/let-go both thread the plan id
// into their intent, but adjust dropped the user into an empty new chat
// with no memory of which plan prompted the tap. The coach route's
// full-page surface now reads `?ask=` and seeds it as the composer
// prefill for a fresh conversation (`CoachConversation`'s `prefill` prop,
// same mechanism the drawer's suggested-prompt chips use) — the user
// lands with the plan already named instead of a blank composer.
const adjustPrompt = plan.planText
? t("daily.item.coachCheckin.adjustPromptPlan", { plan: plan.planText })
: t("daily.item.coachCheckin.adjustPrompt");
return {
kind: "coach_checkin",
title: t("daily.item.coachCheckin.title"),
@@ -464,7 +476,7 @@ function buildCoachCheckinItem(
{
labelKey: "daily.action.checkinAdjust",
intent: COACH_CHECKIN_ADJUST_INTENT,
href: "/coach",
href: `/coach?ask=${encodeURIComponent(adjustPrompt)}`,
},
{
labelKey: "daily.action.checkinLetGo",
@@ -73,6 +73,22 @@ describe("getMissFreeDayKeys", () => {
]);
expect(keys).toEqual(["2026-05-01"]);
});
it("buckets by the CALLER's timezone when supplied (DATAINT M4)", () => {
// 2026-05-01T23:30:00Z: Berlin (CEST, UTC+2) reads 2026-05-02T01:30 —
// the NEXT day. A resolved slot at that instant must key to 05-01 for
// an America/New_York (UTC-4 in May) caller, not 05-02.
const lateEvent: CareIntakeEventRecord = {
scheduledFor: new Date("2026-05-01T23:30:00Z"),
takenAt: new Date("2026-05-01T23:35:00Z"),
skipped: false,
autoMissed: false,
};
const berlinDefault = getMissFreeDayKeys([lateEvent]);
const newYork = getMissFreeDayKeys([lateEvent], "America/New_York");
expect(berlinDefault).toEqual(["2026-05-02"]);
expect(newYork).toEqual(["2026-05-01"]);
});
});
describe("getWeeklyConsistency", () => {
@@ -268,6 +268,61 @@ describe("getHiddenMetrics", () => {
});
});
describe("getEngagementMetrics — tz threading (DATAINT M4)", () => {
it("buckets entry-day-streak by the CALLER's timezone, not a hardcoded Berlin default", () => {
// Two instants 2.5h apart in UTC. Under Berlin (CEST, UTC+2 in June)
// they straddle a MIDNIGHT boundary and land on two DIFFERENT
// consecutive local days (streak 2). Under Kiritimati (UTC+14) both
// fall on the SAME local day (streak 1). If `tz` were silently ignored
// (still hardcoded Berlin), both calls would report streak 2.
const measurements = [
{ type: "WEIGHT", measuredAt: new Date("2026-06-10T21:00:00.000Z") },
{ type: "PULSE", measuredAt: new Date("2026-06-10T23:30:00.000Z") },
];
const berlinDefault = getEngagementMetrics({
measurements,
moodEntries: [],
intakeEvents: [],
});
const kiritimati = getEngagementMetrics({
measurements,
moodEntries: [],
intakeEvents: [],
tz: "Pacific/Kiritimati",
});
expect(berlinDefault.entryDayStreak).toBe(2);
expect(kiritimati.entryDayStreak).toBe(1);
});
});
describe("getHiddenMetrics — tz threading (DATAINT M4)", () => {
it("classifies night-owl/early-bird hours against the caller's own timezone", () => {
// 2026-04-15T00:30:00Z: Berlin (CEST, UTC+2) reads 02:30 → night-owl.
// The same instant in America/Los_Angeles (PDT, UTC-7) reads 17:30 the
// PREVIOUS day → neither night-owl nor early-bird. If `tz` were still
// hardcoded Berlin, both calls would report the same night-owl count.
const measurements = [
{ type: "WEIGHT", measuredAt: new Date("2026-04-15T00:30:00Z") },
];
const berlin = getHiddenMetrics({
measurements,
moodEntries: [],
intakeEvents: [],
auditEvents: [],
});
const losAngeles = getHiddenMetrics({
measurements,
moodEntries: [],
intakeEvents: [],
auditEvents: [],
tz: "America/Los_Angeles",
});
expect(berlin.nightOwlCount).toBe(1);
expect(losAngeles.nightOwlCount).toBe(0);
expect(losAngeles.earlyBirdCount).toBe(0);
});
});
describe("getEarnabilityFlags", () => {
it("flags categories the user has data for", () => {
expect(
+68 -43
View File
@@ -22,7 +22,6 @@ import {
calculateLongestStreak,
evaluateAchievementsWithCompletionDates,
moduleForMetric,
toBerlinDayKey,
type AchievementMetrics,
type AchievementProgress,
} from "@/lib/gamification/achievements";
@@ -41,6 +40,7 @@ import {
} from "@/lib/analytics/classifications";
import { classifyIntakeTiming } from "@/lib/analytics/compliance";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
export type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
@@ -74,22 +74,23 @@ type MedicationScheduleRecord = {
/**
* v1.28.25 — one SQL-aggregated vitals bucket: every non-deleted, non-import
* row of `type` inside the Berlin-local hour `hour` of day `dayKey`, folded
* to a row count + value sum by the database. The vitals window is
* rollout-date → now and grows forever, and PULSE can be per-sample heart
* rate (six figures on a long-tenured wearable account) — reading raw rows
* here was the scale hazard. Every consumer's semantics survive the fold:
* green-day classification needs the per-day per-type MEAN (= Σsum / Σcount
* across the day's hour buckets — identical to the raw mean up to
* floating-point summation order), the count badges need row counts, the
* engagement / consistency series need day presence, and the hidden
* row of `type` inside the badge-owner's LOCAL hour `hour` of day `dayKey`
* (DATAINT M4 — was hardcoded Berlin; the query now binds the caller's own
* `User.timezone`), folded to a row count + value sum by the database. The
* vitals window is rollout-date → now and grows forever, and PULSE can be
* per-sample heart rate (six figures on a long-tenured wearable account) —
* reading raw rows here was the scale hazard. Every consumer's semantics
* survive the fold: green-day classification needs the per-day per-type MEAN
* (= Σsum / Σcount across the day's hour buckets — identical to the raw mean
* up to floating-point summation order), the count badges need row counts,
* the engagement / consistency series need day presence, and the hidden
* night-owl / early-bird / leap-day tallies need per-hour row counts.
*/
type VitalsHourBucket = {
type: "WEIGHT" | "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA" | "PULSE";
/** Berlin-local YYYY-MM-DD. */
/** User-local YYYY-MM-DD. */
dayKey: string;
/** Berlin-local hour 0–23. */
/** User-local hour 0–23. */
hour: number;
/** Number of raw rows folded into this bucket. */
rowCount: number;
@@ -102,21 +103,22 @@ function maxDate(a: Date, b: Date): Date {
}
/**
* v1.18.1 P4 — every Berlin-local day key covered by an illness episode
* (onset day through the resolved day, or through `now` while ongoing),
* inclusive on both ends. These are the days a streak is allowed to lapse
* across without breaking (the Rest Mode freeze). Uses the same
* `toBerlinDayKey` day anchoring every other streak series uses, so the
* v1.18.1 P4 — every local day key covered by an illness episode (onset day
* through the resolved day, or through `now` while ongoing), inclusive on
* both ends. These are the days a streak is allowed to lapse across without
* breaking (the Rest Mode freeze). Uses the same `tz` day anchoring every
* other streak series uses (DATAINT M4 — was hardcoded Berlin), so the
* frozen days line up with the qualifying days exactly.
*/
function collectIllnessDayKeys(
episodes: Array<{ onsetAt: Date; resolvedAt: Date | null }>,
now: Date,
tz: string,
): Set<string> {
const frozen = new Set<string>();
for (const ep of episodes) {
const startSerial = dayKeyToSerial(toBerlinDayKey(ep.onsetAt));
const endSerial = dayKeyToSerial(toBerlinDayKey(ep.resolvedAt ?? now));
const startSerial = dayKeyToSerial(userDayKey(ep.onsetAt, tz));
const endSerial = dayKeyToSerial(userDayKey(ep.resolvedAt ?? now, tz));
for (let serial = startSerial; serial <= endSerial; serial++) {
frozen.add(serialToDayKey(serial));
}
@@ -188,14 +190,17 @@ function toDaySeries(dayKeys: string[]): {
};
}
function getEventDaySeries(dates: Date[]): {
function getEventDaySeries(
dates: Date[],
tz: string,
): {
dayKeys: string[];
firstDateByDay: Map<string, Date>;
} {
const firstDateByDay = new Map<string, Date>();
for (const date of dates) {
const dayKey = toBerlinDayKey(date);
const dayKey = userDayKey(date, tz);
const existing = firstDateByDay.get(dayKey);
if (!existing || date < existing) {
@@ -288,7 +293,10 @@ function getOnTimePerfectDaySeries(
const eventsByDay = new Map<string, IntakeEventRecord[]>();
for (const event of intakeEvents) {
const dayKey = toBerlinDayKey(event.scheduledFor);
// DATAINT M4 — was `toBerlinDayKey`; this function already threads `tz`
// for the schedule-window classification below, but the day-BUCKETING
// key itself was still hardcoded Berlin.
const dayKey = userDayKey(event.scheduledFor, tz);
const list = eventsByDay.get(dayKey) ?? [];
list.push(event);
eventsByDay.set(dayKey, list);
@@ -392,6 +400,7 @@ function getExpectedIntakesForDay(
function getIntakeIssueMetrics(
intakeEvents: IntakeEventRecord[],
schedulesByMedicationId: Map<string, MedicationScheduleRecord[]>,
tz: string,
) {
const skippedIntakeDates = intakeEvents
.filter((event) => event.skipped)
@@ -408,7 +417,7 @@ function getIntakeIssueMetrics(
continue;
}
const dayKey = toBerlinDayKey(event.scheduledFor);
const dayKey = userDayKey(event.scheduledFor, tz);
const byDay = eventsByMedicationDay.get(event.medicationId) ?? new Map();
const list = byDay.get(dayKey) ?? [];
list.push(event);
@@ -470,11 +479,12 @@ function getCompliance80DaySeries(
intakeEvents: IntakeEventRecord[],
startDate: Date,
endDate: Date,
tz: string,
) {
const perDayCounts = new Map<number, { logged: number; taken: number }>();
for (const event of intakeEvents) {
const serial = dayKeyToSerial(toBerlinDayKey(event.scheduledFor));
const serial = dayKeyToSerial(userDayKey(event.scheduledFor, tz));
const current = perDayCounts.get(serial) ?? { logged: 0, taken: 0 };
const logged = event.skipped || event.takenAt !== null;
@@ -486,8 +496,8 @@ function getCompliance80DaySeries(
perDayCounts.set(serial, current);
}
const startSerial = dayKeyToSerial(toBerlinDayKey(startDate));
const endSerial = dayKeyToSerial(toBerlinDayKey(endDate));
const startSerial = dayKeyToSerial(userDayKey(startDate, tz));
const endSerial = dayKeyToSerial(userDayKey(endDate, tz));
let rollingLogged = 0;
let rollingTaken = 0;
@@ -538,6 +548,14 @@ export async function buildAchievementsResult(
const now = new Date();
const userId = user.id;
const startDate = maxDate(user.createdAt, GAMIFICATION_ROLLOUT_AT);
// v1.30 (DATAINT M4) — every day-key derivation below anchors on the
// BADGE OWNER's own timezone, not a hardcoded Berlin default. Before this,
// a non-CET user's evening entries could fold into the wrong calendar day
// (or merge two real local days into one), silently breaking every
// day-streak badge (login, entry, weekend, miss-free, sleep-log, on-time,
// compliance-80) plus the hidden night-owl / early-bird / leap-day
// triggers for every non-CET account.
const tz = user.timezone || DEFAULT_TIMEZONE;
const [
vitalsBuckets,
@@ -550,7 +568,7 @@ export async function buildAchievementsResult(
healthProfile,
illnessEpisodes,
] = await Promise.all([
// v1.28.25 — vitals folded to (type, Berlin day, Berlin hour) buckets in
// v1.28.25 — vitals folded to (type, local day, local hour) buckets in
// SQL instead of one row per sample. The window (rollout → now) grows
// forever and PULSE can be per-sample heart rate, so the raw read ran to
// six figures on long-tenured wearable accounts; the bucket set is
@@ -561,18 +579,21 @@ export async function buildAchievementsResult(
// (never user input), source ≠ IMPORT, and soft-deleted rows stay
// excluded (v1.4.41 W-DELETED-2) so deleting a row immediately rolls
// back any streak / count badge it earned. `AT TIME ZONE 'UTC' AT TIME
// ZONE 'Europe/Berlin'` re-tags the naive UTC `measured_at` and converts
// it to the same Berlin wall clock `toBerlinDayKey` / `berlinHour`
// derive via Intl. Ordering pins day → hour → type for determinism.
// ZONE ${tz}` re-tags the naive UTC `measured_at` and converts it to the
// BADGE OWNER's own wall clock (DATAINT M4 — was hardcoded
// 'Europe/Berlin'; `tz` is bound as a query parameter, never spliced, and
// is only ever a value `PUT /api/auth/me/timezone` already validated
// against `Intl`'s IANA zone list before it reached the column).
// Ordering pins day → hour → type for determinism.
prisma.$queryRaw<VitalsHourBucket[]>`
SELECT
m."type"::text AS "type",
to_char(
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE 'Europe/Berlin',
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${tz},
'YYYY-MM-DD'
) AS "dayKey",
EXTRACT(
HOUR FROM (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE 'Europe/Berlin'
HOUR FROM (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${tz}
)::int AS "hour",
COUNT(*)::int AS "rowCount",
SUM(m."value")::double precision AS "valueSum"
@@ -708,12 +729,12 @@ export async function buildAchievementsResult(
: Promise.resolve([]),
]);
// v1.18.1 P4 — Rest Mode streak-freeze. Build the set of Berlin-local day
// keys covered by an illness episode (onset → resolved, or → now while
// v1.18.1 P4 — Rest Mode streak-freeze. Build the set of local-day keys
// covered by an illness episode (onset → resolved, or → now while
// ongoing). A day-streak that lapses only across these days is bridged
// rather than broken (see `bridgeFrozenStreakGaps`): being unwell pauses a
// streak, it does not fail it.
const frozenIllnessDayKeys = collectIllnessDayKeys(illnessEpisodes, now);
const frozenIllnessDayKeys = collectIllnessDayKeys(illnessEpisodes, now, tz);
const freeze = (dayKeys: string[]): string[] =>
bridgeFrozenStreakGaps(dayKeys, frozenIllnessDayKeys);
@@ -723,6 +744,7 @@ export async function buildAchievementsResult(
const intakeIssueMetrics = getIntakeIssueMetrics(
intakeEvents,
schedulesByMedicationId,
tz,
);
const takenIntakeDates = intakeEvents
@@ -735,12 +757,12 @@ export async function buildAchievementsResult(
const passwordLoginDates = auditEvents
.filter((event) => event.action === "auth.login.password")
.map((event) => event.createdAt);
const loginDaySeries = getEventDaySeries([
...passkeyLoginDates,
...passwordLoginDates,
]);
const loginDaySeries = getEventDaySeries(
[...passkeyLoginDates, ...passwordLoginDates],
tz,
);
// v1.28.25 — the buckets already carry the Berlin day-key + hour straight
// v1.28.25 — the buckets already carry the local day-key + hour straight
// from SQL (superseding the v1.18.11 Intl-once optimisation: no Intl pass
// over the vitals remains at all). The parallel arrays thread into every
// consumer exactly as the per-row keys did; day-presence consumers dedup
@@ -762,21 +784,23 @@ export async function buildAchievementsResult(
const onTimeSeries = getOnTimePerfectDaySeries(
intakeEvents,
schedulesByMedicationId,
user.timezone,
tz,
);
const complianceSeries = getCompliance80DaySeries(
intakeEvents,
startDate,
now,
tz,
);
// v1.16.1 — care-routine series. Miss-free days and sleep-logging
// days reuse the standard day-series plumbing; the weekly measurement
// consistency folds distinct active vitals days into Monday-anchored
// weeks (≥5 active days each, 4 consecutive weeks for the badge).
const missFreeSeries = toDaySeries(getMissFreeDayKeys(intakeEvents));
const missFreeSeries = toDaySeries(getMissFreeDayKeys(intakeEvents, tz));
const sleepSeries = getEventDaySeries(
sleepMeasurements.map((m) => m.measuredAt),
tz,
);
const weeklyConsistency = getWeeklyConsistency(
// Bucket day-keys — `getWeeklyConsistency` dedups to distinct days, so
@@ -799,11 +823,12 @@ export async function buildAchievementsResult(
moodEntries,
intakeEvents,
auditEvents,
// The SQL-derived Berlin day-keys + hours ride in parallel to the
// The SQL-derived local day-keys + hours ride in parallel to the
// weighted bucket records — the expansion passes never touch a bucket's
// representative `measuredAt`.
measurementDayKeys: vitalsDayKeys,
measurementHours: vitalsHours,
tz,
});
const earnability = getEarnabilityFlags({
hasMedication: medications.length > 0,
+11 -3
View File
@@ -24,7 +24,7 @@
* boring, clinically useful habit of measuring most days of the
* week, week after week, without demanding a perfect daily streak.
*/
import { toBerlinDayKey } from "./achievements";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
export interface CareIntakeEventRecord {
scheduledFor: Date;
@@ -37,11 +37,19 @@ export interface CareIntakeEventRecord {
* Qualifying day keys for the miss-free streak, sorted ascending.
* Feed the result to `calculateLongestStreak` /
* `findStreakCompletionDate` like every other day series.
*
* `tz` defaults to `DEFAULT_TIMEZONE` ("Europe/Berlin") so every existing
* caller/fixture that never set one keeps its prior behaviour byte-for-
* byte (DATAINT M4) — the achievements builder now threads the badge
* owner's real `User.timezone` in.
*/
export function getMissFreeDayKeys(events: CareIntakeEventRecord[]): string[] {
export function getMissFreeDayKeys(
events: CareIntakeEventRecord[],
tz: string = DEFAULT_TIMEZONE,
): string[] {
const byDay = new Map<string, { resolved: number; missed: number }>();
for (const event of events) {
const dayKey = toBerlinDayKey(event.scheduledFor);
const dayKey = userDayKey(event.scheduledFor, tz);
const bucket = byDay.get(dayKey) ?? { resolved: 0, missed: 0 };
if (event.autoMissed) {
bucket.missed += 1;
+50 -47
View File
@@ -7,35 +7,27 @@
* `evaluateAchievementsWithCompletionDates` evaluates the new
* achievements with no further plumbing.
*/
import {
calculateLongestStreak,
toBerlinDayKey,
type EarnabilityFlags,
} from "./achievements";
import { calculateLongestStreak, type EarnabilityFlags } from "./achievements";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
import { wallClockInTz } from "@/lib/tz/wall-clock";
const BERLIN_TIMEZONE = "Europe/Berlin";
const BERLIN_HOUR_FORMATTER = new Intl.DateTimeFormat("en-US", {
timeZone: BERLIN_TIMEZONE,
hour: "2-digit",
hour12: false,
});
function berlinHour(date: Date): number {
const value = BERLIN_HOUR_FORMATTER.format(date);
// formatToParts is more reliable across runtimes — split + parseInt
// works because the formatter uses 24-hour with no extra tokens.
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? 0 : parsed;
/**
* v1.30 (DATAINT M4) — day-key / hour derivation now takes the CALLER's
* timezone (`User.timezone`), defaulting to `DEFAULT_TIMEZONE` ("Europe/
* Berlin") when omitted so every existing fixture/caller that never set a
* `tz` keeps its prior (Berlin-anchored) behaviour byte-for-byte. Before
* this, every derivation hardcoded Berlin regardless of the achievement
* owner's real timezone — a non-CET user's evening entries could fold
* into the wrong calendar day (or merge two real days into one), silently
* breaking day-streak badges (`entryDayStreak`, `weekendStreakCount`) and
* the hidden night-owl / early-bird triggers for every non-CET account.
*/
function hourInTz(date: Date, tz: string): number {
return wallClockInTz(date, tz).hour;
}
const BERLIN_WEEKDAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
timeZone: BERLIN_TIMEZONE,
weekday: "short",
});
function berlinWeekday(date: Date): string {
return BERLIN_WEEKDAY_FORMATTER.format(date);
function isSaturdayInTz(date: Date, tz: string): boolean {
return wallClockInTz(date, tz).weekday === 6;
}
export interface MoodEntryRecord {
@@ -165,30 +157,33 @@ export function getEngagementMetrics(input: {
moodEntries: MoodEntryRecord[];
intakeEvents: IntakeEventRecord[];
/**
* v1.18.11 (W5 perf) — Berlin day-keys for `measurements`, precomputed
* once by the caller and passed in parallel to the array. When present the
* measurement rows reuse these instead of re-deriving a `toBerlinDayKey`
* per row; mood / intake timestamps (far fewer) still derive inline. Falls
* back to per-row derivation when absent — byte-identical result either way.
* v1.18.11 (W5 perf) — day-keys for `measurements`, precomputed once by
* the caller and passed in parallel to the array. When present the
* measurement rows reuse these instead of re-deriving a day key per row;
* mood / intake timestamps (far fewer) still derive inline. Falls back to
* per-row derivation when absent — byte-identical result either way.
*/
measurementDayKeys?: string[];
/** v1.30 (DATAINT M4) — day-key timezone; defaults to Berlin (see file docblock). */
tz?: string;
}): {
consistentMonthCount: number;
entryDayStreak: number;
weekendStreakCount: number;
} {
const tz = input.tz ?? DEFAULT_TIMEZONE;
const dayKeyAccum: string[] = [];
for (let i = 0; i < input.measurements.length; i++) {
dayKeyAccum.push(
input.measurementDayKeys?.[i] ??
toBerlinDayKey(input.measurements[i].measuredAt),
userDayKey(input.measurements[i].measuredAt, tz),
);
}
for (const e of input.moodEntries)
dayKeyAccum.push(toBerlinDayKey(e.moodLoggedAt));
dayKeyAccum.push(userDayKey(e.moodLoggedAt, tz));
for (const i of input.intakeEvents) {
if (i.takenAt) dayKeyAccum.push(toBerlinDayKey(i.takenAt));
else if (i.skipped) dayKeyAccum.push(toBerlinDayKey(i.scheduledFor));
if (i.takenAt) dayKeyAccum.push(userDayKey(i.takenAt, tz));
else if (i.skipped) dayKeyAccum.push(userDayKey(i.scheduledFor, tz));
}
if (dayKeyAccum.length === 0) {
@@ -238,20 +233,20 @@ export function getEngagementMetrics(input: {
let weekendStreakCount = 0;
let weekendRun = 0;
// Walk one Saturday at a time. Use a simple `Date` cursor; the
// Berlin TZ formatter resolves the weekday so DST shifts are safe.
// caller's tz formatter resolves the weekday so DST shifts are safe.
const cursor = parseDayKey(firstDay);
const end = parseDayKey(lastDay);
// Snap cursor to the next Saturday at 12:00 UTC (avoids DST edges).
cursor.setUTCHours(12, 0, 0, 0);
while (berlinWeekday(cursor) !== "Sat") {
while (!isSaturdayInTz(cursor, tz)) {
cursor.setUTCDate(cursor.getUTCDate() + 1);
if (cursor > end) break;
}
while (cursor <= end) {
const sat = toBerlinDayKey(cursor);
const sat = userDayKey(cursor, tz);
const sunDate = new Date(cursor);
sunDate.setUTCDate(sunDate.getUTCDate() + 1);
const sun = toBerlinDayKey(sunDate);
const sun = userDayKey(sunDate, tz);
if (daySet.has(sat) && daySet.has(sun)) {
weekendRun += 1;
weekendStreakCount = Math.max(weekendStreakCount, weekendRun);
@@ -290,6 +285,8 @@ export function getHiddenMetrics(input: {
*/
measurementDayKeys?: string[];
measurementHours?: number[];
/** v1.30 (DATAINT M4) — day-key timezone; defaults to Berlin (see file docblock). */
tz?: string;
}): {
nightOwlCount: number;
earlyBirdCount: number;
@@ -297,6 +294,7 @@ export function getHiddenMetrics(input: {
doctorPdfCount: number;
localeFlipCount: number;
} {
const tz = input.tz ?? DEFAULT_TIMEZONE;
let nightOwl = 0;
let earlyBird = 0;
let leapDay = 0;
@@ -314,16 +312,16 @@ export function getHiddenMetrics(input: {
for (let i = 0; i < input.measurements.length; i++) {
const m = input.measurements[i];
tally(
input.measurementHours?.[i] ?? berlinHour(m.measuredAt),
input.measurementDayKeys?.[i] ?? toBerlinDayKey(m.measuredAt),
input.measurementHours?.[i] ?? hourInTz(m.measuredAt, tz),
input.measurementDayKeys?.[i] ?? userDayKey(m.measuredAt, tz),
m.count ?? 1,
);
}
for (const e of input.moodEntries) {
tally(berlinHour(e.moodLoggedAt), toBerlinDayKey(e.moodLoggedAt));
tally(hourInTz(e.moodLoggedAt, tz), userDayKey(e.moodLoggedAt, tz));
}
for (const i of input.intakeEvents) {
if (i.takenAt) tally(berlinHour(i.takenAt), toBerlinDayKey(i.takenAt));
if (i.takenAt) tally(hourInTz(i.takenAt, tz), userDayKey(i.takenAt, tz));
}
let doctorPdfCount = 0;
@@ -412,22 +410,26 @@ export function buildExpansionMetricValues(input: {
* hours derive from `measuredAt` exactly as before.
*/
measurementHours?: number[];
/** v1.30 (DATAINT M4) — day-key timezone; defaults to Berlin (see file docblock). */
tz?: string;
}): ExpansionMetrics {
const tz = input.tz ?? DEFAULT_TIMEZONE;
const counts = countMeasurementsByType(input.measurements);
const mood = getMoodMetrics(input.moodEntries);
// The hidden pass also needs the per-row Berlin hour; derive it once here
// (only when the caller supplied day-keys, i.e. on the hot achievements
// path) so a single hour pass replaces the per-pass re-derivation.
// The hidden pass also needs the per-row hour; derive it once here (only
// when the caller supplied day-keys, i.e. on the hot achievements path) so
// a single hour pass replaces the per-pass re-derivation.
const measurementHours =
input.measurementHours ??
(input.measurementDayKeys
? input.measurements.map((m) => berlinHour(m.measuredAt))
? input.measurements.map((m) => hourInTz(m.measuredAt, tz))
: undefined);
const engagement = getEngagementMetrics({
measurements: input.measurements,
moodEntries: input.moodEntries,
intakeEvents: input.intakeEvents,
measurementDayKeys: input.measurementDayKeys,
tz,
});
const hidden = getHiddenMetrics({
measurements: input.measurements,
@@ -436,6 +438,7 @@ export function buildExpansionMetricValues(input: {
auditEvents: input.auditEvents,
measurementDayKeys: input.measurementDayKeys,
measurementHours,
tz,
});
return {
+8 -5
View File
@@ -209,11 +209,14 @@ const ECG_ENTRY: CatalogEntry = {
/**
* Discoverability audit finding A5 — the alert-style watchdogs (rhythm-
* event notifications, breathing-disturbance screening, baseline-drift
* health status) are CORRECT to stay unmounted when there is nothing to
* flag ("no alert" must not render an alarming empty shell), but nothing
* anywhere told the user they were running. This is the "single line"
* fix the audit recommended in place of per-card empty states: an
* always-shown info row, no present/absent gate, no CTA.
* health status, and the labs "what changed since your last panel" card)
* are CORRECT to stay unmounted when there is nothing to flag ("no
* alert" must not render an alarming empty shell), but nothing anywhere
* told the user they were running. This is the "single line" fix the
* audit recommended in place of per-card empty states: an always-shown
* info row, no present/absent gate, no CTA. The description copy covers
* all four watchdogs — extend it (not a new catalog row) if a fifth one
* ships.
*/
const WATCHDOG_ENTRY: CatalogEntry = {
id: "watchdog",
+9 -1
View File
@@ -642,7 +642,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "List the caller's Coach conversations",
description:
'v1.18.0 — cursor-paginated list of the caller\'s Coach conversations for the history rail, most-recent activity first. Metadata only (id, title, timestamps, message count); message bodies are not decrypted here. `limit` defaults to 20, capped at 50; pass the returned `nextCursor` back as `cursor` for the next page (null at the end). Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is narrowed from the session.',
'v1.18.0 — cursor-paginated list of the caller\'s Coach conversations for the history rail, most-recent activity first. Metadata only (id, title, timestamps, message count); message bodies are not decrypted here. `limit` defaults to 20, capped at 50; pass the returned `nextCursor` back as `cursor` for the next page (null at the end). v1.30.2 — optional `q` narrows the page to conversations whose TITLE contains the text (case-insensitive substring, capped at 200 chars); message bodies are encrypted at rest and are not searched. Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is narrowed from the session.',
parameters: [
{
name: "cursor",
@@ -659,6 +659,14 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
schema: { type: "integer", minimum: 1, maximum: 50, default: 20 },
description: "Page size. Defaults to 20, capped at 50.",
},
{
name: "q",
in: "query",
required: false,
schema: { type: "string", maxLength: 200 },
description:
"v1.30.2 — case-insensitive substring filter over the conversation TITLE only (message bodies are encrypted and not searched). Omit for the unfiltered list.",
},
],
responses: {
"200": {
+12
View File
@@ -20,6 +20,18 @@ export const coachKeys = {
* share one factory-routed key.
*/
coachConversations: () => ["coachConversations"] as const,
/**
* v1.30.2 — the full, paginated + searchable rail history
* (`useInfiniteQuery` over `GET /api/insights/chat?cursor&q`). Nested under
* the `coachConversations` prefix (not a sibling key) so the existing
* delete-mutation + post-turn invalidation (`coachConversations()`) still
* reaches every cached search variant of this infinite list — TanStack's
* default `invalidateQueries` match is prefix-based, so no extra
* invalidation call site was needed. `search` is the trimmed query text;
* empty string keys the unfiltered ("browse everything") variant.
*/
coachConversationHistory: (search = "") =>
["coachConversations", "history", search.trim()] as const,
/**
* v1.18.7 — one decrypted Coach conversation (`GET /api/insights/chat/[id]`).
* Keyed on the conversation id so two open threads never share a cache slot;