mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
52 files changed
+1735
-313
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user