diff --git a/CHANGELOG.md b/CHANGELOG.md index fbb655259..d35a800f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b3bfdcd2a..341dbebc0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/messages/de.json b/messages/de.json index 13fe46be9..ae466c900 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 82501a4bb..ec91a82a7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index fc6cbd9c5..e4a444d59 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index e5839646e..f792f1c31 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 744b0e761..9aa2d1297 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 5e5e0bb6c..cbc2aa405 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/package.json b/package.json index b5ac1467b..916fe3bff 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index edd2f79b5..42aec4b9a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/app/api/insights/chat/__tests__/route-list-query.test.ts b/src/app/api/insights/chat/__tests__/route-list-query.test.ts new file mode 100644 index 000000000..c5165fdb7 --- /dev/null +++ b/src/app/api/insights/chat/__tests__/route-list-query.test.ts @@ -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: 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; + +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" }), + ); + }); +}); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 7462ed30a..ec846ba12 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -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=&limit= + * GET /api/insights/chat?cursor=&limit=&q= * * 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); diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index a584897c9..3f4b1ed4e 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -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); diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index e741f1607..b4e1899fa 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -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(); 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, diff --git a/src/app/api/nutrients/__tests__/route.test.ts b/src/app/api/nutrients/__tests__/route.test.ts index a3ae3fac1..5e8ae5db3 100644 --- a/src/app/api/nutrients/__tests__/route.test.ts +++ b/src/app/api/nutrients/__tests__/route.test.ts @@ -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( diff --git a/src/app/api/nutrients/route.ts b/src/app/api/nutrients/route.ts index 29b87314b..2cd5d8824 100644 --- a/src/app/api/nutrients/route.ts +++ b/src/app/api/nutrients/route.ts @@ -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; + } >(); 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, }; }); diff --git a/src/app/api/workouts/route.ts b/src/app/api/workouts/route.ts index f05224994..c58e37537 100644 --- a/src/app/api/workouts/route.ts +++ b/src/app/api/workouts/route.ts @@ -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 { - const { limit, offset, since, until, sportType } = params; + params: WorkoutFilterParams, +): Promise { + const { since, until, sportType } = params; const where: Record = { 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, - cacheKey, - () => buildWorkoutsResponse(user.id, params), + const projection = await cached( + caches.workouts as ServerCache, + projectionKey, + () => buildWorkoutsProjection(user.id, { since, until, sportType }), annotate, ); + const result = sliceWorkoutsProjection(projection, limit, offset); + return apiSuccess(result); }); diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 2201a3fcc..6c1f7aaa9 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -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=` — 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=` 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 `` 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(""); + // 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(""); - // 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(null); + const sentinelRef = useLoadMoreSentinel({ + enabled: hasNextPage && !isFetchingNextPage, + onLoadMore: fetchNextPage, + root: listNode, + }); function handleSelect(id: string) { // Reuse the existing `?c=` open mechanism on the Coach page. @@ -175,20 +205,21 @@ function CoachConversationsBody() {
- {isLoading && conversations.length === 0 ? ( + {isLoading && visible.length === 0 ? (

{t("common.loading")}

- ) : 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() {
) : ( @@ -278,6 +311,26 @@ function CoachConversationsBody() { )) )} + {/* 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 ? ( + ); diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 96c077437..4e6b1c169 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -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 + * `` already exposes for the drawer's suggested-prompt + * chips — no new plumbing inside the conversation surface itself: + * - `?scope=` — 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=` — 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 => @@ -95,6 +131,10 @@ function CoachPageBody() { // v1.28.51 — seed the document scope for a `?doc=` 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} diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index 2f9223886..6573fff92 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -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. */} +
+ + {t("insights.mood.logLink")} +
+ {/* No `` here: mood is event-driven, not a diff --git a/src/app/insights/recovery/page.tsx b/src/app/insights/recovery/page.tsx index 7db6abea0..9cd494b8e 100644 --- a/src/app/insights/recovery/page.tsx +++ b/src/app/insights/recovery/page.tsx @@ -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. */} + + {t("insights.recovery.scoreCrossLink")} +