Merge pull request #527 from MBombeck/release/v1.30.3

release/v1.30.3
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-18 07:03:42 +02:00
commit 82fc2d7b78
55 files changed
+1876 -459

No files matched your search

+10
View File
@@ -2,6 +2,16 @@
## [Unreleased]
## [1.30.3] — 2026-07-18
Follow-up fixes from a full quality re-check of the app.
- **Correctness reaches every surface.** The timezone, window, and grain fixes now also cover the coach's correlations, the per-metric cards, and the period narrative (they had kept an older copy). Daily insight cards roll over at your own midnight, not a fixed one. Personal records that a second device had inflated are re-derived on the next start, so a genuine record can be set again.
- **The metrics catalog** shows a loading state and a retry on error, instead of briefly reading as "not tracked".
- **Small.** One shared info popover, larger tap targets on the view toggles, a notch-safe height fix on the coach history page, and a few wording and translation gaps closed (a body-composition caveat, study citations, the backup scope note).
No breaking changes.
## [1.30.2] — 2026-07-18
More audit-backlog fixes.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.30.2
version: 1.30.3
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+7 -6
View File
@@ -592,6 +592,7 @@
"statusPresent": "Erfasst",
"statusAbsent": "Noch nicht erfasst",
"statusModuleOff": "Aus",
"loadError": "Erfassungsstatus konnte nicht geladen werden.",
"connectCta": "Quelle verbinden",
"viewCta": "Ansehen",
"ecg": {
@@ -599,7 +600,7 @@
},
"watchdog": {
"title": "Automatische Überwachung",
"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."
"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. Ist mindestens eine dieser Quellen verbunden, ist keine Karte ein gutes Zeichen, kein fehlendes Feature; ist noch keine verbunden, wird schlicht noch nichts überwacht."
}
},
"mood": {
@@ -2665,11 +2666,11 @@
"fatFreeMassTitle": "Fettfreie Masse",
"fatFreeMassBody": "Die fettfreie Masse ist alles in deinem Körper, das kein Fett ist — Muskeln, Knochen, Organe und Wasser. Sie zu halten oder aufzubauen, während das Gewicht sinkt, deutet meist auf eine gesündere Körperzusammensetzung hin.",
"fatMassTitle": "Fettmasse",
"fatMassBody": "Die Fettmasse ist das Gesamtgewicht des Körperfetts. Etwas Fett ist für Hormone und Energiespeicher unverzichtbar, doch zu viel — besonders am Bauch — erhöht das langfristige Gesundheitsrisiko.",
"fatMassBody": "Die Fettmasse ist das Gesamtgewicht des Körperfetts. Etwas Fett ist für Hormone und Energiespeicher unverzichtbar, doch zu viel — besonders am Bauch — erhöht das langfristige Gesundheitsrisiko (WHO 2024).",
"muscleMassTitle": "Muskelmasse",
"muscleMassBody": "Die Muskelmasse ist das geschätzte Gewicht der Muskeln in deinem Körper. Mehr Muskeln unterstützen Kraft, Stoffwechsel und Beweglichkeit und müssen mit zunehmendem Alter aktiv erhalten werden.",
"visceralFatTitle": "Viszeralfett",
"visceralFatBody": "Viszeralfett ist das Fett, das tief im Bauchraum um die Organe gespeichert wird. Anders als das Unterhautfett ist ein hoher Wert eng mit Herzkrankheiten und Typ-2-Diabetes verbunden, daher ist niedriger meist besser.",
"visceralFatBody": "Viszeralfett ist das Fett, das tief im Bauchraum um die Organe gespeichert wird. Anders als das Unterhautfett ist ein hoher Wert eng mit Herzkrankheiten und Typ-2-Diabetes verbunden (AHA, Circulation 2021), daher ist niedriger meist besser. Die Skala zeigt einen einheitenlosen Wert (etwa 1–12 gilt als gesunder Bereich, ab 13 als erhöht); das ist eine andere Skala als die bildgebenden cm²-Schwellenwerte, die in der Klinik verwendet werden, und nicht mit ihnen austauschbar.",
"leanBodyMassTitle": "Magermasse",
"leanBodyMassBody": "Die Magermasse ist dein Gesamtgewicht abzüglich des Körperfetts — Muskeln, Knochen, Organe und Wasser zusammen. Sie neben dem Gewicht zu verfolgen zeigt, ob Veränderungen aus Muskeln oder aus Fett stammen.",
"flightsClimbedTitle": "Etagen gestiegen",
@@ -2699,7 +2700,7 @@
"audioEventsTitle": "Laute Geräuschereignisse",
"audioEventsBody": "Laute Geräuschereignisse markieren Momente, in denen der Lärm um dich herum ein Niveau erreicht hat, das bei ausreichender Belastung das Gehör schädigen kann. Häufige Ereignisse sind ein Hinweis, eine ruhigere Umgebung oder Gehörschutz zu suchen.",
"daylightTitle": "Zeit im Tageslicht",
"daylightBody": "Die Zeit im Tageslicht ist, wie lange du dich täglich im Freien im Licht aufhältst. Regelmäßiges Tageslicht unterstützt einen gesunden Schlaf-Wach-Rhythmus und die Stimmung, und Zeit im Freien ist mit der Augengesundheit verbunden.",
"daylightBody": "Die Zeit im Tageslicht ist, wie lange du dich täglich im Freien im Licht aufhältst. Regelmäßiges Tageslicht unterstützt einen gesunden Schlaf-Wach-Rhythmus und die Stimmung (CDC 2024), und Zeit im Freien ist mit der Augengesundheit verbunden.",
"bloodGlucoseTitle": "Blutzucker",
"bloodGlucoseBody": "Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein; dauerhaft hohe Werte können auf Diabetes oder Prädiabetes hinweisen.",
"skinTemperatureTitle": "Hauttemperatur",
@@ -2711,7 +2712,7 @@
"wristTemperatureTitle": "Handgelenktemperatur",
"wristTemperatureBody": "Die Handgelenktemperatur wird im Schlaf gemessen und als Abweichung von deinem persönlichen Ausgangswert angegeben, nicht als absoluter Wert. Ein gleichmäßiger Wert ist normal, ein anhaltender Anstieg kann auf Krankheit, intensives Training oder eine Zyklusveränderung hindeuten.",
"fallsTitle": "Stürze",
"fallsBody": "Hier werden die von deinem Wearable erkannten schweren Stürze gezählt. Null ist das Ziel; eine steigende Zahl ist ein Hinweis auf ein erhöhtes Sturzrisiko, das du — besonders im höheren Alter — mit einer Ärztin oder einem Arzt besprechen solltest.",
"fallsBody": "Hier werden die von deinem Wearable erkannten schweren Stürze gezählt. Null ist das Ziel; eine steigende Zahl ist ein Hinweis auf ein erhöhtes Sturzrisiko, das du — besonders im höheren Alter — mit einer Ärztin oder einem Arzt besprechen solltest (CDC STEADI 2024).",
"sixMinuteWalkTitle": "6-Minuten-Gehstrecke",
"sixMinuteWalkBody": "Dies ist eine Schätzung, wie weit du in sechs Minuten gehen könntest — ein etablierter klinischer Test für Mobilität und kardiopulmonale Ausdauer. Der Wearable-Wert ist ein Näherungswert, nicht der überwachte klinische Test; aussagekräftig ist der gleichbleibende oder steigende Trend.",
"stairAscentSpeedTitle": "Treppensteig-Tempo",
@@ -4397,7 +4398,7 @@
"fullBackup": {
"title": "Voll-Backup",
"description": "Alle deine Daten in einer JSON-Datei — Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese und Workouts. Gleiches Format wie das wöchentliche Auto-Backup.",
"scopeNote": "Die Originaldateien der Dokumente sowie GPS-Routen und Einzelmesswerte von Workouts sind nicht enthalten — lade diese separat aus dem Dokumententresor bzw. dem Workout-Export herunter.",
"scopeNote": "Dokumentmetadaten und die KI-Zusammenfassung, falls vorhanden, sind als Klartext enthalten; die Originaldateien der Dokumente sowie GPS-Routen und Einzelmesswerte von Workouts sind es nicht — lade diese separat aus dem Dokumententresor bzw. dem Workout-Export herunter.",
"encryptToggle": "Mit einer Passphrase verschlüsseln",
"passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)",
"passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.",
+3 -2
View File
@@ -592,6 +592,7 @@
"statusPresent": "Tracked",
"statusAbsent": "Not tracked yet",
"statusModuleOff": "Off",
"loadError": "Could not load your tracking status.",
"connectCta": "Connect a source",
"viewCta": "View",
"ecg": {
@@ -599,7 +600,7 @@
},
"watchdog": {
"title": "Automatic screening",
"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."
"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. With at least one of those sources connected, no card showing is a good sign, not a missing feature; with none connected yet, there's simply nothing being watched."
}
},
"mood": {
@@ -4397,7 +4398,7 @@
"fullBackup": {
"title": "Full backup",
"description": "Every record you own in one JSON file — measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, and workouts. Same shape as the weekly auto-backup.",
"scopeNote": "Original document files and workout GPS/sample data are not included — download those separately from the vault or the workout export.",
"scopeNote": "Document metadata and the AI-generated summary, if one exists, are included as plain text; original document files and workout GPS/sample data are not — download those separately from the vault or the workout export.",
"encryptToggle": "Encrypt with a passphrase",
"passphrasePlaceholder": "Passphrase (min. 12 characters)",
"passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.",
+7 -6
View File
@@ -592,6 +592,7 @@
"statusPresent": "Registrado",
"statusAbsent": "Aún sin registrar",
"statusModuleOff": "Desactivado",
"loadError": "No se pudo cargar el estado de seguimiento.",
"connectCta": "Conectar una fuente",
"viewCta": "Ver",
"ecg": {
@@ -599,7 +600,7 @@
},
"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, 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."
"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. Si al menos una de esas fuentes está conectada, que no aparezca ninguna tarjeta es una buena señal, no una función ausente; si todavía no hay ninguna conectada, sencillamente no se está vigilando nada."
}
},
"mood": {
@@ -2665,11 +2666,11 @@
"fatFreeMassTitle": "Masa libre de grasa",
"fatFreeMassBody": "La masa libre de grasa es todo lo que en tu cuerpo no es grasa: músculo, hueso, órganos y agua. Mantenerla o aumentarla mientras pierdes peso suele indicar una composición corporal más sana.",
"fatMassTitle": "Masa grasa",
"fatMassBody": "La masa grasa es el peso total de la grasa de tu cuerpo. Algo de grasa es esencial para las hormonas y la reserva de energía, pero un exceso —sobre todo en el abdomen— eleva el riesgo para la salud a largo plazo.",
"fatMassBody": "La masa grasa es el peso total de la grasa de tu cuerpo. Algo de grasa es esencial para las hormonas y la reserva de energía, pero un exceso —sobre todo en el abdomen— eleva el riesgo para la salud a largo plazo (WHO 2024).",
"muscleMassTitle": "Masa muscular",
"muscleMassBody": "La masa muscular es el peso estimado del músculo de tu cuerpo. Más músculo favorece la fuerza, el metabolismo y la movilidad, y suele requerir esfuerzo activo para conservarse con la edad.",
"visceralFatTitle": "Grasa visceral",
"visceralFatBody": "La grasa visceral es la grasa almacenada en lo profundo del abdomen, alrededor de los órganos. A diferencia de la grasa bajo la piel, un nivel alto se asocia estrechamente con enfermedad cardíaca y diabetes tipo 2, por lo que cuanto más baja, mejor.",
"visceralFatBody": "La grasa visceral es la grasa almacenada en lo profundo del abdomen, alrededor de los órganos. A diferencia de la grasa bajo la piel, un nivel alto se asocia estrechamente con enfermedad cardíaca y diabetes tipo 2 (AHA, Circulation 2021), por lo que cuanto más baja, mejor. La escala muestra una puntuación sin unidades (aproximadamente 1–12 es el rango saludable, 13 o más se considera elevado); es una escala distinta de los umbrales en cm² por imagen que usan los profesionales clínicos y no es intercambiable con ellos.",
"leanBodyMassTitle": "Masa magra corporal",
"leanBodyMassBody": "La masa magra corporal es tu peso total menos la grasa: músculo, hueso, órganos y agua en conjunto. Seguirla junto al peso muestra si los cambios vienen del músculo o de la grasa.",
"flightsClimbedTitle": "Pisos subidos",
@@ -2699,7 +2700,7 @@
"audioEventsTitle": "Eventos de sonido fuerte",
"audioEventsBody": "Los eventos de sonido fuerte señalan momentos en que el ruido a tu alrededor alcanzó un nivel que puede dañar la audición con suficiente exposición. Los eventos frecuentes son una señal para buscar un entorno más silencioso o protección auditiva.",
"daylightTitle": "Tiempo a la luz del día",
"daylightBody": "El tiempo a la luz del día es cuánto pasas bajo luz exterior cada día. La luz diurna regular favorece un ritmo sueño-vigilia y un estado de ánimo saludables, y el tiempo al aire libre se asocia con la salud ocular.",
"daylightBody": "El tiempo a la luz del día es cuánto pasas bajo luz exterior cada día. La luz diurna regular favorece un ritmo sueño-vigilia y un estado de ánimo saludables (CDC 2024), y el tiempo al aire libre se asocia con la salud ocular.",
"bloodGlucoseTitle": "Glucosa en sangre",
"bloodGlucoseBody": "La glucosa en sangre es la cantidad de azúcar en tu sangre, la principal fuente de energía del cuerpo. Sube tras comer y se estabiliza entre comidas; valores que se mantienen altos con el tiempo pueden indicar diabetes o prediabetes.",
"skinTemperatureTitle": "Temperatura de la piel",
@@ -2711,7 +2712,7 @@
"wristTemperatureTitle": "Temperatura de muñeca",
"wristTemperatureBody": "La temperatura de muñeca se mide mientras duermes y se indica como la diferencia respecto a tu valor de referencia, no como un valor absoluto. Un valor estable es normal, mientras que una subida sostenida puede indicar enfermedad, entrenamiento intenso o un cambio del ciclo.",
"fallsTitle": "Caídas",
"fallsBody": "Esto cuenta las caídas fuertes que detecta tu wearable. El objetivo es cero; un número creciente es una señal de riesgo de movilidad que conviene comentar con un profesional, sobre todo en personas mayores.",
"fallsBody": "Esto cuenta las caídas fuertes que detecta tu wearable. El objetivo es cero; un número creciente es una señal de riesgo de movilidad que conviene comentar con un profesional, sobre todo en personas mayores (CDC STEADI 2024).",
"sixMinuteWalkTitle": "Distancia en marcha de 6 minutos",
"sixMinuteWalkBody": "Es una estimación de cuánto podrías caminar en seis minutos, una prueba clínica clásica de movilidad y resistencia cardiopulmonar. El valor del wearable es una aproximación, no la prueba clínica supervisada; lo útil es la tendencia estable o en aumento.",
"stairAscentSpeedTitle": "Velocidad subiendo escaleras",
@@ -4397,7 +4398,7 @@
"fullBackup": {
"title": "Copia completa",
"description": "Todos tus datos en un único archivo JSON: mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares y entrenamientos. Mismo formato que la copia automática semanal.",
"scopeNote": "Los archivos originales de los documentos y los datos de rutas GPS/muestras de los entrenamientos no están incluidos — descárgalos por separado desde el archivo de documentos o la exportación de entrenamientos.",
"scopeNote": "Los metadatos de los documentos y el resumen de IA, si existe, se incluyen en texto plano; los archivos originales de los documentos y los datos de rutas GPS/muestras de los entrenamientos no están incluidos — descárgalos por separado desde el archivo de documentos o la exportación de entrenamientos.",
"encryptToggle": "Cifrar con una contraseña",
"passphrasePlaceholder": "Contraseña (mín. 12 caracteres)",
"passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.",
+7 -6
View File
@@ -592,6 +592,7 @@
"statusPresent": "Suivi",
"statusAbsent": "Pas encore suivi",
"statusModuleOff": "Désactivé",
"loadError": "Impossible de charger l'état de suivi.",
"connectCta": "Connecter une source",
"viewCta": "Voir",
"ecg": {
@@ -599,7 +600,7 @@
},
"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, 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."
"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. Si au moins une de ces sources est connectée, l'absence de carte est bon signe, pas une fonctionnalité manquante ; si aucune ne l'est encore, c'est simplement qu'il n'y a rien à surveiller."
}
},
"mood": {
@@ -2665,11 +2666,11 @@
"fatFreeMassTitle": "Masse non grasse",
"fatFreeMassBody": "La masse non grasse est tout ce qui, dans votre corps, n'est pas de la graisse : muscle, os, organes et eau. La maintenir ou la développer tout en perdant du poids signale en général une composition corporelle plus saine.",
"fatMassTitle": "Masse grasse",
"fatMassBody": "La masse grasse est le poids total de la graisse de votre corps. Une part de graisse est indispensable aux hormones et au stockage d'énergie, mais un excès — surtout au niveau de l'abdomen — augmente le risque pour la santé à long terme.",
"fatMassBody": "La masse grasse est le poids total de la graisse de votre corps. Une part de graisse est indispensable aux hormones et au stockage d'énergie, mais un excès — surtout au niveau de l'abdomen — augmente le risque pour la santé à long terme (WHO 2024).",
"muscleMassTitle": "Masse musculaire",
"muscleMassBody": "La masse musculaire est le poids estimé des muscles de votre corps. Plus de muscle soutient la force, le métabolisme et la mobilité, et demande généralement un effort actif pour être préservé avec l'âge.",
"visceralFatTitle": "Graisse viscérale",
"visceralFatBody": "La graisse viscérale est la graisse stockée au plus profond de l'abdomen, autour des organes. Contrairement à la graisse sous la peau, un niveau élevé est étroitement lié aux maladies cardiaques et au diabète de type 2 : plus elle est basse, mieux c'est.",
"visceralFatBody": "La graisse viscérale est la graisse stockée au plus profond de l'abdomen, autour des organes. Contrairement à la graisse sous la peau, un niveau élevé est étroitement lié aux maladies cardiaques et au diabète de type 2 (AHA, Circulation 2021) : plus elle est basse, mieux c'est. L'échelle indique une valeur sans unité (environ 1 à 12 correspond à la plage saine, 13 et plus étant élevé) ; il s'agit d'une échelle distincte des seuils en cm² obtenus par imagerie qu'utilisent les cliniciens, et elle n'est pas interchangeable avec eux.",
"leanBodyMassTitle": "Masse maigre",
"leanBodyMassBody": "La masse maigre est votre poids total moins la graisse corporelle : muscle, os, organes et eau réunis. La suivre en parallèle du poids montre si les variations viennent du muscle ou de la graisse.",
"flightsClimbedTitle": "Étages montés",
@@ -2699,7 +2700,7 @@
"audioEventsTitle": "Événements sonores forts",
"audioEventsBody": "Les événements sonores forts signalent les moments où le bruit autour de vous a atteint un niveau pouvant nuire à l'audition en cas d'exposition suffisante. Des événements fréquents invitent à rechercher un environnement plus calme ou une protection auditive.",
"daylightTitle": "Temps à la lumière du jour",
"daylightBody": "Le temps à la lumière du jour correspond à la durée passée en lumière extérieure chaque jour. Une lumière naturelle régulière soutient un rythme veille-sommeil et une humeur sains, et le temps passé dehors est lié à la santé oculaire.",
"daylightBody": "Le temps à la lumière du jour correspond à la durée passée en lumière extérieure chaque jour. Une lumière naturelle régulière soutient un rythme veille-sommeil et une humeur sains (CDC 2024), et le temps passé dehors est lié à la santé oculaire.",
"bloodGlucoseTitle": "Glycémie",
"bloodGlucoseBody": "La glycémie est la quantité de sucre dans votre sang, principale source d'énergie de l'organisme. Elle augmente après les repas et se stabilise entre eux ; des valeurs durablement élevées peuvent signaler un diabète ou un prédiabète.",
"skinTemperatureTitle": "Température cutanée",
@@ -2711,7 +2712,7 @@
"wristTemperatureTitle": "Température du poignet",
"wristTemperatureBody": "La température du poignet est mesurée pendant votre sommeil et indiquée comme l'écart par rapport à votre valeur de référence, et non comme une valeur absolue. Une valeur stable est normale, tandis qu'une hausse durable peut signaler une maladie, un entraînement intense ou un changement de cycle.",
"fallsTitle": "Chutes",
"fallsBody": "Ceci compte les chutes lourdes détectées par votre wearable. L'objectif est zéro ; un nombre en hausse est un signal de risque de mobilité à évoquer avec un professionnel, surtout pour les personnes âgées.",
"fallsBody": "Ceci compte les chutes lourdes détectées par votre wearable. L'objectif est zéro ; un nombre en hausse est un signal de risque de mobilité à évoquer avec un professionnel, surtout pour les personnes âgées (CDC STEADI 2024).",
"sixMinuteWalkTitle": "Distance de marche de 6 minutes",
"sixMinuteWalkBody": "C'est une estimation de la distance que vous pourriez parcourir en six minutes, un test clinique classique de mobilité et d'endurance cardiopulmonaire. La valeur du wearable est une approximation, pas le test clinique encadré ; c'est la tendance stable ou en hausse qui compte.",
"stairAscentSpeedTitle": "Vitesse en montée d'escaliers",
@@ -4397,7 +4398,7 @@
"fullBackup": {
"title": "Sauvegarde complète",
"description": "Toutes vos données dans un seul fichier JSON : mesures, médicaments, humeur, cycle, analyses de laboratoire, épisodes de maladie, allergies, antécédents familiaux et entraînements. Même format que la sauvegarde automatique hebdomadaire.",
"scopeNote": "Les fichiers originaux des documents ainsi que les tracés GPS/données d'échantillons des entraînements ne sont pas inclus — téléchargez-les séparément depuis le coffre de documents ou l'export des entraînements.",
"scopeNote": "Les métadonnées des documents et le résumé IA, s'il existe, sont inclus en texte clair ; les fichiers originaux des documents ainsi que les tracés GPS/données d'échantillons des entraînements ne le sont pas — téléchargez-les séparément depuis le coffre de documents ou l'export des entraînements.",
"encryptToggle": "Chiffrer avec une phrase secrète",
"passphrasePlaceholder": "Phrase secrète (12 caractères min.)",
"passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.",
+7 -6
View File
@@ -592,6 +592,7 @@
"statusPresent": "Monitorato",
"statusAbsent": "Non ancora monitorato",
"statusModuleOff": "Disattivato",
"loadError": "Impossibile caricare lo stato di monitoraggio.",
"connectCta": "Collega una fonte",
"viewCta": "Visualizza",
"ecg": {
@@ -599,7 +600,7 @@
},
"watchdog": {
"title": "Monitoraggio automatico",
"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."
"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. Se almeno una di queste fonti è collegata, nessuna scheda visibile è un buon segno, non una funzione mancante; se non ne è ancora collegata nessuna, semplicemente non c'è ancora nulla sotto controllo."
}
},
"mood": {
@@ -2665,11 +2666,11 @@
"fatFreeMassTitle": "Massa magra",
"fatFreeMassBody": "La massa magra è tutto ciò che nel corpo non è grasso: muscoli, ossa, organi e acqua. Mantenerla o aumentarla mentre si perde peso indica di solito una composizione corporea più sana.",
"fatMassTitle": "Massa grassa",
"fatMassBody": "La massa grassa è il peso totale del grasso nel corpo. Un po' di grasso è essenziale per gli ormoni e le riserve di energia, ma un eccesso — soprattutto sull'addome — aumenta il rischio per la salute a lungo termine.",
"fatMassBody": "La massa grassa è il peso totale del grasso nel corpo. Un po' di grasso è essenziale per gli ormoni e le riserve di energia, ma un eccesso — soprattutto sull'addome — aumenta il rischio per la salute a lungo termine (WHO 2024).",
"muscleMassTitle": "Massa muscolare",
"muscleMassBody": "La massa muscolare è il peso stimato dei muscoli del corpo. Più muscolo sostiene forza, metabolismo e mobilità, e di solito richiede impegno attivo per essere mantenuto con l'età.",
"visceralFatTitle": "Grasso viscerale",
"visceralFatBody": "Il grasso viscerale è il grasso depositato in profondità nell'addome, intorno agli organi. A differenza del grasso sottocutaneo, un livello elevato è strettamente legato a malattie cardiache e diabete di tipo 2, quindi più basso è in genere meglio.",
"visceralFatBody": "Il grasso viscerale è il grasso depositato in profondità nell'addome, intorno agli organi. A differenza del grasso sottocutaneo, un livello elevato è strettamente legato a malattie cardiache e diabete di tipo 2 (AHA, Circulation 2021), quindi più basso è in genere meglio. La scala mostra un valore senza unità di misura (circa 1–12 è l'intervallo salutare, 13 e oltre è elevato); si tratta di una scala distinta dalle soglie in cm² ottenute per immagini che i clinici utilizzano, e non è intercambiabile con esse.",
"leanBodyMassTitle": "Massa magra corporea",
"leanBodyMassBody": "La massa magra corporea è il peso totale meno il grasso: muscoli, ossa, organi e acqua insieme. Seguirla accanto al peso mostra se i cambiamenti derivano dal muscolo o dal grasso.",
"flightsClimbedTitle": "Piani saliti",
@@ -2699,7 +2700,7 @@
"audioEventsTitle": "Eventi sonori intensi",
"audioEventsBody": "Gli eventi sonori intensi segnalano i momenti in cui il rumore intorno a te ha raggiunto un livello che può danneggiare l'udito con esposizione sufficiente. Eventi frequenti sono un invito a cercare un ambiente più silenzioso o protezioni acustiche.",
"daylightTitle": "Tempo alla luce del giorno",
"daylightBody": "Il tempo alla luce del giorno è quanto trascorri sotto la luce esterna ogni giorno. La luce diurna regolare sostiene un sano ritmo sonno-veglia e l'umore, e il tempo all'aperto è collegato alla salute degli occhi.",
"daylightBody": "Il tempo alla luce del giorno è quanto trascorri sotto la luce esterna ogni giorno. La luce diurna regolare sostiene un sano ritmo sonno-veglia e l'umore (CDC 2024), e il tempo all'aperto è collegato alla salute degli occhi.",
"bloodGlucoseTitle": "Glicemia",
"bloodGlucoseBody": "La glicemia è la quantità di zucchero nel sangue, la principale fonte di energia del corpo. Sale dopo i pasti e si stabilizza tra un pasto e l'altro; valori che restano alti nel tempo possono indicare diabete o prediabete.",
"skinTemperatureTitle": "Temperatura cutanea",
@@ -2711,7 +2712,7 @@
"wristTemperatureTitle": "Temperatura del polso",
"wristTemperatureBody": "La temperatura del polso viene misurata mentre dormi ed è indicata come differenza dal tuo valore di riferimento, non come valore assoluto. Un valore stabile è normale, mentre un aumento prolungato può indicare malattia, allenamento intenso o un cambiamento del ciclo.",
"fallsTitle": "Cadute",
"fallsBody": "Qui vengono contate le cadute forti rilevate dal tuo wearable. L'obiettivo è zero; un numero in aumento è un segnale di rischio di mobilità da segnalare a un medico, soprattutto per le persone anziane.",
"fallsBody": "Qui vengono contate le cadute forti rilevate dal tuo wearable. L'obiettivo è zero; un numero in aumento è un segnale di rischio di mobilità da segnalare a un medico, soprattutto per le persone anziane (CDC STEADI 2024).",
"sixMinuteWalkTitle": "Distanza nel cammino di 6 minuti",
"sixMinuteWalkBody": "È una stima di quanto potresti camminare in sei minuti, un classico test clinico di mobilità e resistenza cardiopolmonare. Il valore del wearable è un'approssimazione, non il test clinico supervisionato; ciò che conta è la tendenza stabile o in aumento.",
"stairAscentSpeedTitle": "Velocità in salita scale",
@@ -4397,7 +4398,7 @@
"fullBackup": {
"title": "Backup completo",
"description": "Tutti i tuoi dati in un unico file JSON: misurazioni, farmaci, umore, ciclo, esami di laboratorio, episodi di malattia, allergie, anamnesi familiare e allenamenti. Stesso formato del backup automatico settimanale.",
"scopeNote": "I file originali dei documenti e i dati di percorso GPS/campionamento degli allenamenti non sono inclusi — scaricali separatamente dall'archivio documenti o dall'esportazione degli allenamenti.",
"scopeNote": "I metadati dei documenti e il riepilogo IA, se presente, sono inclusi in chiaro; i file originali dei documenti e i dati di percorso GPS/campionamento degli allenamenti non lo sono — scaricali separatamente dall'archivio documenti o dall'esportazione degli allenamenti.",
"encryptToggle": "Cifra con una passphrase",
"passphrasePlaceholder": "Passphrase (min. 12 caratteri)",
"passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.",
+7 -6
View File
@@ -592,6 +592,7 @@
"statusPresent": "Śledzone",
"statusAbsent": "Jeszcze nieśledzone",
"statusModuleOff": "Wyłączone",
"loadError": "Nie udało się załadować statusu śledzenia.",
"connectCta": "Połącz źródło",
"viewCta": "Zobacz",
"ecg": {
@@ -599,7 +600,7 @@
},
"watchdog": {
"title": "Automatyczny nadzór",
"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."
"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ę. Jeśli podłączone jest co najmniej jedno z tych źródeł, brak karty to dobry znak, a nie brakująca funkcja; jeśli żadne nie jest jeszcze podłączone, po prostu nic nie jest jeszcze monitorowane."
}
},
"mood": {
@@ -2665,11 +2666,11 @@
"fatFreeMassTitle": "Masa beztłuszczowa",
"fatFreeMassBody": "Masa beztłuszczowa to wszystko w ciele, co nie jest tłuszczem — mięśnie, kości, narządy i woda. Utrzymanie lub jej budowanie przy spadku wagi zwykle wskazuje na zdrowszy skład ciała.",
"fatMassTitle": "Masa tłuszczowa",
"fatMassBody": "Masa tłuszczowa to łączna waga tłuszczu w ciele. Pewna ilość tłuszczu jest niezbędna dla hormonów i magazynowania energii, ale jego nadmiar — zwłaszcza w okolicy brzucha — zwiększa długoterminowe ryzyko zdrowotne.",
"fatMassBody": "Masa tłuszczowa to łączna waga tłuszczu w ciele. Pewna ilość tłuszczu jest niezbędna dla hormonów i magazynowania energii, ale jego nadmiar — zwłaszcza w okolicy brzucha — zwiększa długoterminowe ryzyko zdrowotne (WHO 2024).",
"muscleMassTitle": "Masa mięśniowa",
"muscleMassBody": "Masa mięśniowa to szacowana waga mięśni w ciele. Więcej mięśni wspiera siłę, metabolizm i sprawność, a ich utrzymanie z wiekiem zwykle wymaga aktywnego wysiłku.",
"visceralFatTitle": "Tłuszcz trzewny",
"visceralFatBody": "Tłuszcz trzewny to tłuszcz gromadzony głęboko w jamie brzusznej wokół narządów. W odróżnieniu od tłuszczu podskórnego jego wysoki poziom jest ściśle powiązany z chorobami serca i cukrzycą typu 2, więc im niżej, tym lepiej.",
"visceralFatBody": "Tłuszcz trzewny to tłuszcz gromadzony głęboko w jamie brzusznej wokół narządów. W odróżnieniu od tłuszczu podskórnego jego wysoki poziom jest ściśle powiązany z chorobami serca i cukrzycą typu 2 (AHA, Circulation 2021), więc im niżej, tym lepiej. Skala pokazuje wartość bezjednostkową (mniej więcej 1–12 to zakres zdrowy, 13 i więcej to wartość podwyższona); to inna skala niż progi w cm² z badań obrazowych stosowane przez klinicystów i nie należy jej traktować z nimi zamiennie.",
"leanBodyMassTitle": "Beztłuszczowa masa ciała",
"leanBodyMassBody": "Beztłuszczowa masa ciała to całkowita waga pomniejszona o tłuszcz — mięśnie, kości, narządy i woda razem. Śledzenie jej obok wagi pokazuje, czy zmiany wynikają z mięśni, czy z tłuszczu.",
"flightsClimbedTitle": "Pokonane piętra",
@@ -2699,7 +2700,7 @@
"audioEventsTitle": "Zdarzenia głośnego dźwięku",
"audioEventsBody": "Zdarzenia głośnego dźwięku oznaczają momenty, gdy hałas wokół ciebie osiągnął poziom mogący przy wystarczającej ekspozycji uszkodzić słuch. Częste zdarzenia to sygnał, by poszukać cichszego otoczenia lub ochrony słuchu.",
"daylightTitle": "Czas w świetle dziennym",
"daylightBody": "Czas w świetle dziennym to ilość czasu spędzanego w świetle na zewnątrz każdego dnia. Regularne światło dzienne wspiera zdrowy rytm snu i czuwania oraz nastrój, a czas na świeżym powietrzu wiąże się ze zdrowiem oczu.",
"daylightBody": "Czas w świetle dziennym to ilość czasu spędzanego w świetle na zewnątrz każdego dnia. Regularne światło dzienne wspiera zdrowy rytm snu i czuwania oraz nastrój (CDC 2024), a czas na świeżym powietrzu wiąże się ze zdrowiem oczu.",
"bloodGlucoseTitle": "Poziom glukozy we krwi",
"bloodGlucoseBody": "Poziom glukozy we krwi to ilość cukru we krwi, głównego źródła energii organizmu. Rośnie po jedzeniu i ustala się między posiłkami; wartości utrzymujące się wysoko mogą sygnalizować cukrzycę lub stan przedcukrzycowy.",
"skinTemperatureTitle": "Temperatura skóry",
@@ -2711,7 +2712,7 @@
"wristTemperatureTitle": "Temperatura nadgarstka",
"wristTemperatureBody": "Temperatura nadgarstka jest mierzona podczas snu i podawana jako różnica względem Twojej wartości bazowej, a nie jako wartość bezwzględna. Stabilny odczyt jest normalny, a utrzymujący się wzrost może wskazywać na chorobę, intensywny trening lub zmianę cyklu.",
"fallsTitle": "Upadki",
"fallsBody": "Tutaj liczone są poważne upadki wykryte przez urządzenie noszone. Celem jest zero; rosnąca liczba to sygnał ryzyka związanego z mobilnością, który warto omówić z lekarzem, zwłaszcza u osób starszych.",
"fallsBody": "Tutaj liczone są poważne upadki wykryte przez urządzenie noszone. Celem jest zero; rosnąca liczba to sygnał ryzyka związanego z mobilnością, który warto omówić z lekarzem, zwłaszcza u osób starszych (CDC STEADI 2024).",
"sixMinuteWalkTitle": "Dystans marszu 6-minutowego",
"sixMinuteWalkBody": "To szacunek, jak daleko mógłbyś przejść w sześć minut — klasyczny test kliniczny mobilności i wydolności krążeniowo-oddechowej. Wartość z urządzenia noszonego to przybliżenie, a nie nadzorowany test kliniczny; liczy się stały lub rosnący trend.",
"stairAscentSpeedTitle": "Prędkość wchodzenia po schodach",
@@ -4397,7 +4398,7 @@
"fullBackup": {
"title": "Pełna kopia zapasowa",
"description": "Wszystkie Twoje dane w jednym pliku JSON — pomiary, leki, nastrój, cykl, wyniki badań laboratoryjnych, epizody chorobowe, alergie, wywiad rodzinny i treningi. Taki sam format jak cotygodniowa automatyczna kopia.",
"scopeNote": "Oryginalne pliki dokumentów oraz dane tras GPS/próbek treningów nie są uwzględnione — pobierz je osobno z archiwum dokumentów lub eksportu treningów.",
"scopeNote": "Metadane dokumentów oraz podsumowanie AI, jeśli istnieje, są uwzględnione jako czysty tekst; oryginalne pliki dokumentów oraz dane tras GPS/próbek treningów nie są uwzględnione — pobierz je osobno z archiwum dokumentów lub eksportu treningów.",
"encryptToggle": "Zaszyfruj hasłem-frazą",
"passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)",
"passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.30.2",
"version": "1.30.3",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.30.2";
/* @sw-version-fallback */ "v1.30.3";
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/*`
@@ -305,6 +305,40 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
expect(body.data.bpClassification?.category).toBe("Normal");
});
// v1.30.3 (QA F7 — completes the "caller has it" pairing-tz fix across
// every `computeBpInTargetPct` call site, not just the fast-path).
it("pairs bpPctInTarget on the session user's own tz, not a hardcoded Berlin day", async () => {
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, timezone: "America/New_York" },
} as never);
// 2026-05-14T21:50Z = 23:50 Berlin (05-14) / 17:50 New York (05-14).
// 2026-05-14T22:10Z = 00:10 Berlin (05-15, the NEXT Berlin day) /
// 18:10 New York (05-14, the SAME New York day). >5-min gap, so only
// the same-calendar-day fallback can pair them — and only New York
// agrees with itself.
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
bpRawRows: {
sys: [{ measuredAt: new Date("2026-05-14T21:50:00Z"), value: 125 }],
dia: [{ measuredAt: new Date("2026-05-14T22:10:00Z"), value: 75 }],
},
dailyByType: {},
firstMeasurementAt: new Date("2026-05-14T21:50:00Z"),
totalMeasurements: 2,
},
);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: { bpPctInTarget: number | null };
};
// A Berlin-day pairing would reject this pair (different Berlin
// calendar days) and read null; the user's own tz accepts it.
expect(body.data.bpPctInTarget).toBe(100);
});
it("consumes the mood-rollup tier and skips the raw findMany when DAY rows exist", async () => {
// v1.4.40 W-INSIGHTS — rollup-tier read swap parity. Three DAY rows
// populated → the route reads them directly and never touches the
+33 -4
View File
@@ -100,6 +100,11 @@ type AuthedUser = AuthContext["user"];
export async function buildComprehensiveResponse(user: AuthedUser) {
const userId = user.id;
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
// v1.30.3 (QA F8) — the one shared per-user tz for every day-bucketing
// pass in this route (resting-pulse proxy, medication-continuity, BP
// pairing). Was previously re-derived ad hoc (or hardcoded UTC) at each
// call site.
const userTz = user.timezone || "Europe/Berlin";
// Fetch user profile (height + DOB drive BMI + BP targets).
const dbUser = await prisma.user.findUnique({
@@ -221,10 +226,11 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// sys+dia on the 5-minute window ONLY, dropping readings whose sys/dia
// timestamps drift past 5 min on Withings / Apple-Health imports, which
// skewed the share iOS renders on the Home Health Score). The helper
// adds the same-Berlin-day pairing fallback so those readings pair, and
// keeps the ESH-2023 band + 90/50 hypotension floor (both sys AND dia at
// or below the age-band ceiling). Window is the aggregator's 90-day raw
// pull — a "recent control" figure, not the all-time headline.
// adds the same-local-day pairing fallback (v1.30.3 QA F7 — keyed on
// the user's OWN tz, not a hardcoded Berlin day) so those readings
// pair, and keeps the ESH-2023 band + 90/50 hypotension floor (both sys
// AND dia at or below the age-band ceiling). Window is the aggregator's
// 90-day raw pull — a "recent control" figure, not the all-time headline.
let bpPctInTarget: number | null = null;
if (bpTargets) {
bpPctInTarget =
@@ -232,6 +238,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
aggregate.bpRawRows.sys,
aggregate.bpRawRows.dia,
bpTargets,
userTz,
)?.pct ?? null;
}
@@ -274,6 +281,20 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// Day keys follow this route's UTC-bucket convention (the per-user-tz
// bucketing is the documented v1.5 follow-up) so the resting series pairs
// against the same UTC day keys as the mood series.
//
// v1.30.3 (QA F8) — investigated switching this to `userDayKey(d, userTz)`
// to match the intraday page's resting-proxy bucketing (`71d7ca78c`), but
// `dailyMoodEntries` below (this series' ONLY pairing partner, joined by
// an exact YYYY-MM-DD key in `pairMoodWithDaily`) is itself UTC-anchored —
// it reads `moodRollupDayRows[].bucketStart.toISOString().slice(0, 10)`,
// a real UTC aggregation boundary, not just a display label. Moving ONLY
// this side to the user's tz would not fix anything: it would swap
// "evening readings shift a day relative to the true local day" for
// "evening readings mismatch their mood pairing partner, which used to
// line up precisely because both sides shared the same UTC convention."
// A genuine fix needs the mood-rollup tier itself to bucket per-user-tz
// (the same v1.5 follow-up the comment above already flags) — out of
// scope for this pass. Left UTC on both sides intentionally.
const restingPulseRows = await prisma.measurement.findMany({
where: {
userId,
@@ -437,6 +458,14 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
if (expectedBpIntakesPerDay > 0) {
// Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass.
// v1.30.3 (QA F8) — `sysDaily` is UTC `date_trunc`-bucketed by the
// aggregator (`comprehensive-aggregator.ts`), so `takenByDay` below
// MUST key on the same UTC day, not the user's own tz — pairing the
// two on different day spaces would silently drop pairs rather than
// fix anything. Making this whole correlation user-tz-consistent
// needs the aggregator's `dailyByType` to bucket in the user's tz
// first (a bigger, already-flagged follow-up); until then this stays
// UTC to match its only pairing partner.
const sysByDay = new Map<string, number>();
for (const row of sysDaily) {
sysByDay.set(row.day, row.value);
+18 -69
View File
@@ -42,9 +42,9 @@ import {
fetchComplianceSeries,
fetchEnvironmentSeries,
fetchLabDraws,
fetchMeasurementWindowSeries,
fetchMoodWindowSeries,
fetchSymptomSeries,
toDailyMeans,
type MeasurementSeriesRow,
} from "@/lib/insights/correlation-channel-series";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
@@ -97,75 +97,24 @@ export const GET = apiHandler(async () => {
DISCOVERY_OUTCOMES,
) as MeasurementType[];
// v1.29.6 — `source` + `deviceType` + `sleepStage` feed
// `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.
// 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,
deletedAt: null,
measuredAt: { gte: since },
type: { in: [...behaviourTypes, ...outcomeTypes] },
},
orderBy: { measuredAt: "desc" },
take: MEASUREMENT_READ_CAP,
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
sleepStage: true,
},
}),
prisma.moodEntry.findMany({
where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "desc" },
take: MOOD_READ_CAP,
select: { score: true, moodLoggedAt: true },
}),
// v1.30.3 (QA F1) — the fetch + desc/cap/resort discipline now lives in
// `fetchMeasurementWindowSeries` / `fetchMoodWindowSeries`
// (`correlation-channel-series.ts`), shared with the Coach tool, the
// per-metric card, and the period narrative so a fourth independently-
// maintained copy can't drift the way the period-narrative one did.
const [
{ byType: measurementsByType, measurementsCapped },
moodWindow,
priorityJson,
] = await Promise.all([
fetchMeasurementWindowSeries(user.id, since, [
...behaviourTypes,
...outcomeTypes,
]),
fetchMoodWindowSeries(user.id, tz, since),
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) {
const list = measurementsByType.get(m.type) ?? [];
list.push({
value: m.value,
at: m.measuredAt,
source: m.source,
deviceType: m.deviceType,
sleepStage: m.sleepStage,
});
measurementsByType.set(m.type, list);
}
// MOOD's daily-mean series is shared between its behaviour and outcome
// roles (computed once).
const moodDaily = toDailyMeans(
moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })),
tz,
);
const { moodDaily, moodCapped } = moodWindow;
// v1.21.0 (FDREXTEND) — build the two non-measurement, non-mood channels from
// their own sources. Each degrades to an empty series when the user has no
@@ -247,6 +247,99 @@ describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => {
expect(json.data.meta.droppedDuplicates).toBe(1);
});
// v1.30.3 (QA F6) — a capped read's oldest bucket sits mid-day: every
// sample on that calendar day BEFORE the cutoff row was excluded, so the
// bucket's SUM understates the real day total. The row must carry a
// `partial: true` marker instead of rendering as if it were complete.
it("flags the oldest bucket partial when the raw-row read hits the cap", async () => {
// limit=3: the DESC-ordered read returns exactly 3 rows (the cap), so
// the oldest of the three (2026-05-14) is a truncated day — its bucket
// could be missing earlier same-day samples the cap cut off.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "m-1",
type: "ACTIVITY_STEPS",
value: 3400,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-15T11:00:00.000Z"),
notes: null,
},
{
id: "m-2",
type: "ACTIVITY_STEPS",
value: 2500,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-15T07:00:00.000Z"),
notes: null,
},
{
id: "m-3",
type: "ACTIVITY_STEPS",
value: 800,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-14T16:00:00.000Z"),
notes: null,
},
] as never);
const res = await GET(
getRequest("type=ACTIVITY_STEPS&groupBy=day&limit=3"),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
measurements: Array<{
dayKey: string;
value: number;
partial?: boolean;
}>;
meta: { capped?: boolean };
};
};
const day15 = json.data.measurements.find((m) => m.dayKey === "2026-05-15");
const day14 = json.data.measurements.find((m) => m.dayKey === "2026-05-14");
expect(day15?.partial).toBeUndefined();
expect(day14?.partial).toBe(true);
});
it("flags no bucket partial when the read comes in under the cap (whole window covered)", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "m-1",
type: "ACTIVITY_STEPS",
value: 3400,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-15T11:00:00.000Z"),
notes: null,
},
{
id: "m-3",
type: "ACTIVITY_STEPS",
value: 800,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-14T16:00:00.000Z"),
notes: null,
},
] as never);
// Default limit (100) is nowhere near hit by 2 rows.
const res = await GET(getRequest("type=ACTIVITY_STEPS&groupBy=day"));
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
measurements: Array<{ dayKey: string; partial?: boolean }>;
};
};
expect(json.data.measurements.every((m) => m.partial === undefined)).toBe(
true,
);
});
it("falls back to the legacy findMany path when groupBy=day is set on a non-cumulative type", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
+21
View File
@@ -300,6 +300,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
}
// v1.30.3 (QA F6) — the read above is DESC + take(limit), so once a
// dense account's in-window row count crosses the cap, the OLDEST
// returned row sits mid-day: every earlier sample on that same
// calendar day was cut off, and its bucket's SUM understates the real
// day total with no marker. Flag exactly that one bucket — the day
// containing the cutoff row (the minimum dayKey among the buckets
// actually built) — `partial: true` only when the cap was truly hit;
// a read that came in under the cap covers its whole window and no
// day is a truncation artifact.
const cappedRead = rows.length === limit;
let oldestDayKey: string | null = null;
if (cappedRead) {
for (const key of buckets.keys()) {
if (oldestDayKey === null || key < oldestDayKey) oldestDayKey = key;
}
}
const measurements = Array.from(buckets.values())
.map((b) => ({
// Synthetic id so the list-row key stays stable across pages
@@ -316,6 +333,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
notes: null,
dayKey: b.dayKey,
sampleCount: b.sampleCount,
...(b.dayKey === oldestDayKey ? { partial: true } : {}),
}))
.sort((a, b) => {
const cmp = a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0;
@@ -330,6 +348,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
groupBy: "day",
rowsScanned: rows.length,
droppedDuplicates: rows.length - canonicalRows.length,
// QA F6 — surfaces when the oldest bucket in this page is a
// partial-day sum (the read hit the cap).
capped: cappedRead,
},
});
return apiSuccess({
@@ -46,6 +46,9 @@ vi.mock("@/lib/idempotency", () => ({
fn(...args),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserDashboardSnapshot: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -65,6 +68,7 @@ import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -315,6 +319,40 @@ describe("POST /api/nutrients/batch — write semantics", () => {
expect(prisma.nutrientIntakeDay.update).not.toHaveBeenCalled();
});
// v1.30.3 (QA F9) — a landed row must evict the dashboard snapshot so
// the water/nutrient tile reflects the new total on the very next read,
// matching the manual water POST's own posture (`nutrients/water/route.ts`).
it("evicts the dashboard snapshot when a row lands", async () => {
const day = recentDay();
const res = await POST(
postReq({
entries: [{ day, nutrient: "vitamin_d", unit: "ug", amount: 22.5 }],
}),
);
expect(res.status).toBe(200);
expect(invalidateUserDashboardSnapshot).toHaveBeenCalledTimes(1);
expect(invalidateUserDashboardSnapshot).toHaveBeenCalledWith("user-1");
});
it("does NOT evict the dashboard snapshot when nothing lands (every entry skipped)", async () => {
const res = await POST(
postReq({
entries: [
{
day: recentDay(),
nutrient: "vitamin_d",
unit: "mg", // wrong unit vs the catalog's canonical "ug" — skipped
amount: 22.5,
},
],
}),
);
const body = (await res.json()) as BatchResponse;
expect(body.data.inserted).toBe(0);
expect(body.data.updated).toBe(0);
expect(invalidateUserDashboardSnapshot).not.toHaveBeenCalled();
});
it("reports updated (never duplicate) when the composite key already exists", async () => {
const day = recentDay();
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
+8
View File
@@ -40,6 +40,7 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
import {
MAX_NUTRIENT_ENTRIES_PER_BATCH,
@@ -324,6 +325,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
skipped: skipped.length,
},
});
// v1.30.3 (QA F9) — a landed row changes the dashboard's water/nutrient
// tile; without this the snapshot kept serving the pre-sync figure
// until the analytics fresh TTL lapsed (~3 min). Mirrors the manual
// water POST's own hard-evict (`nutrients/water/route.ts`) so an
// Apple-Health-synced total reflects on the very next dashboard read,
// same as a manually logged one.
invalidateUserDashboardSnapshot(user.id);
}
annotate({
+5 -2
View File
@@ -364,8 +364,11 @@ export default function CoachConversationsPage() {
data-slot="coach-conversations-page"
// Match `/coach`'s full-bleed sizing: cancel the AuthShell padding
// and claim the viewport height below the top bar (minus the
// mobile-only BottomNav band).
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
// mobile-only BottomNav band). L4 fix
// (`.planning/audits/2026-07-18-qa-ui.md`) — this had dropped the
// `safe-area-inset-top` term `/coach` subtracts, overshooting by
// ~50px on notched devices; restored to match exactly.
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
>
<CoachConversationsBody />
</div>
+73
View File
@@ -21,6 +21,8 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { TileHeader } from "@/components/insights/tile-header";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { useAuth } from "@/hooks/use-auth";
@@ -105,6 +107,29 @@ function StatusBadge({ present }: { present: boolean }) {
);
}
/**
* Row placeholder shown while the availability probes are still
* in flight — same anatomy as a loaded `CatalogRow` (name line + a
* trailing action-sized block) so the group cards don't reflow when
* the real status lands. Never renders the "Not tracked yet" copy or
* a CTA — an in-flight probe is not the same thing as a confirmed
* absence (M1, `.planning/audits/2026-07-18-qa-ui.md`).
*/
function CatalogRowSkeleton() {
return (
<div
data-slot="catalog-row-skeleton"
className="flex items-start justify-between gap-3 py-2"
>
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-8 w-20 shrink-0" />
</div>
);
}
function CatalogRow({
entry,
present,
@@ -218,6 +243,51 @@ export default function MetricCatalogPage() {
return hasMetricData(entry.metric, availability);
}
// M1 fix (`.planning/audits/2026-07-18-qa-ui.md`) — every probe backing
// `availability` is async; while any of them is still in flight,
// `hasMetricData()` reads an undefined summary and reports "absent" for
// every metric. Gate the rows on the probes' aggregate loading/error
// state instead of letting a cold load or a failed batch masquerade as
// an honest "Not tracked yet".
const isLoading = Boolean(
isAuthenticated &&
(analyticsQuery.isLoading ||
comprehensiveQuery.isLoading ||
workoutsProbe.isLoading ||
ecgQuery.isLoading ||
(nutrientsModuleEnabled && nutrientsProbe.isLoading)),
);
const isError = Boolean(
analyticsQuery.isError ||
comprehensiveQuery.isError ||
workoutsProbe.isError ||
ecgQuery.isError ||
(nutrientsModuleEnabled && nutrientsProbe.isError),
);
function retryAll() {
void analyticsQuery.refetch();
void comprehensiveQuery.refetch();
workoutsProbe.refetch();
void ecgQuery.refetch();
if (nutrientsModuleEnabled) void nutrientsProbe.refetch();
}
if (isError) {
return (
<SubPageShell
title={t("metricCatalog.title")}
description={t("metricCatalog.description")}
>
<QueryErrorCard
description={t("metricCatalog.loadError")}
onRetry={retryAll}
/>
</SubPageShell>
);
}
return (
<SubPageShell
title={t("metricCatalog.title")}
@@ -278,6 +348,9 @@ export default function MetricCatalogPage() {
</div>
);
}
if (isLoading && entry.kind !== "info") {
return <CatalogRowSkeleton key={entry.id} />;
}
return (
<CatalogRow
key={entry.id}
+17 -35
View File
@@ -1,14 +1,6 @@
"use client";
import { Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { InfoPopover } from "@/components/ui/info-popover";
/**
* v1.18.1 — the cycle log-sheet field-explainer affordance.
@@ -16,10 +8,14 @@ import { cn } from "@/lib/utils";
* A small "?" / info icon that puts a factual, descriptive-only one-liner
* (what a clinical field means — BBT, cervical mucus, an LH surge) right at
* the point of capture, so a first-time user never has to leave the sheet to
* understand a term. Mirrors the mood surface's `MoodExplainerIcon` precedent
* (self-contained `TooltipProvider`, a real focusable `<button>` trigger with
* an `aria-label`, keyboard- and SR-reachable via Radix) but bumps the hit
* area to ≥44 px for touch, since the cycle sheet is a thumb-first surface.
* understand a term.
*
* L2 (`.planning/audits/2026-07-18-qa-ui.md`) — this used to be a
* self-contained hover `Tooltip`, which never opens reliably on tap; the
* cycle sheet is a thumb-first surface, so it now wraps the click-opened
* `InfoPopover` (the app's one (i) info-affordance since the v1.28.17
* merge) instead, keeping the `cycle-field-info` data-slot the sheet
* already keys off of.
*
* Copy stays descriptive-only — never diagnosis or medical advice — matching
* the phase-education honesty gate.
@@ -31,31 +27,17 @@ export function FieldInfo({
}: {
/** Accessible name for the trigger (what the icon explains). */
label: string;
/** The explanation shown in the tooltip. */
/** The explanation shown in the popover. */
detail: string;
className?: string;
}) {
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger
type="button"
aria-label={label}
data-slot="cycle-field-info"
className={cn(
// size-6 icon inside an 11-unit (44 px) hit box so touch targets
// clear the WCAG 2.5.8 / Apple HIG minimum without enlarging the
// visible glyph.
"text-muted-foreground hover:text-foreground focus-visible:ring-ring -m-2.5 inline-flex size-11 shrink-0 items-center justify-center rounded-full p-2.5 transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<Info className="size-4" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent className="max-w-[16rem] text-xs leading-snug">
{detail}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<InfoPopover
content={detail}
label={label}
align="start"
triggerDataSlot="cycle-field-info"
className={className}
/>
);
}
@@ -122,7 +122,9 @@ export function HistoryRail({
data-slot="coach-history-rail"
// v1.18.1 (W-COACH-UI C1/C3) — a touch more horizontal padding so
// the list rows clear the rail edge and the search field breathes.
className={cn("flex h-full min-h-0 flex-col gap-2.5 p-3.5", className)}
// L5 fix (`.planning/audits/2026-07-18-qa-ui.md`) — `gap-2.5 p-3.5`
// was off the `1/1.5/2/3/4/6/8/10` spacing scale.
className={cn("flex h-full min-h-0 flex-col gap-2 p-3", className)}
>
{/* v1.4.33 — promote the rail label from a `<span>` to a real
`<h3>` so the drawer carries a semantic outline on desktop
@@ -239,7 +241,7 @@ export function HistoryRail({
) : null}
<span className="truncate">{c.title}</span>
</span>
<span className="text-muted-foreground block text-[11px]">
<span className="text-muted-foreground block text-xs">
{formatRelativeTime(c.updatedAt, t)}
</span>
</button>
@@ -1,15 +1,9 @@
"use client";
import { useState } from "react";
import { HelpCircle } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { InfoPopover } from "@/components/ui/info-popover";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* Small `?` affordance that explains what the adherence percentage
@@ -19,45 +13,24 @@ import { cn } from "@/lib/utils";
* dashboard compliance card header and the per-medication bars
* (`medication-compliance-bars.tsx`).
*
* Pattern follows `health-score-delta-explainer.tsx` (icon-only
* trigger, 44 px hit target collapsed back to the row's stride via
* negative margins). A click-opened Popover is used on every viewport
* — unlike a hover Tooltip it stays reachable on touch devices, where
* the medication cards see most of their use.
* Thin wrapper over `InfoPopover` (L1, `.planning/audits/2026-07-18-qa-ui.md`)
* — this used to be a verbatim structural copy of the primitive; it now
* only supplies the `HelpCircle` glyph, `align="start"`, and the
* `compliance-info-*` data-slots the two call sites already key off of.
*/
export function ComplianceInfoTip({ className }: { className?: string }) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
data-slot="compliance-info-trigger"
aria-label={t("medications.complianceInfoLabel")}
aria-expanded={open}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
"-mx-2 -my-3",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<HelpCircle className="h-3 w-3" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent
data-slot="compliance-info-body"
align="start"
sideOffset={6}
className="max-w-xs"
>
<p className="text-muted-foreground text-xs leading-snug">
{t("medications.complianceInfo")}
</p>
</PopoverContent>
</Popover>
<InfoPopover
content={t("medications.complianceInfo")}
label={t("medications.complianceInfoLabel")}
icon={HelpCircle}
iconClassName="h-3 w-3"
align="start"
triggerDataSlot="compliance-info-trigger"
bodyDataSlot="compliance-info-body"
className={className}
/>
);
}
@@ -7,8 +7,10 @@ import { cn } from "@/lib/utils";
* the icon-only `MedicationViewToggle` chrome (bordered pill group, filled
* active segment, ghost inactive) but carries text labels. `aria-pressed`
* announces the state per segment; the group is named for screen readers.
* Segments keep the 40-px mobile tap floor, shrinking to the 32-px control
* height on desktop.
* Segments keep the 44-px mobile tap floor, shrinking to the 36-px control
* height on desktop — matching the floor `ViewToggle` and the sibling
* intake / history-rail controls raised in this release window (L3,
* `.planning/audits/2026-07-18-qa-ui.md`).
*/
export interface SegmentedToggleOption<T extends string> {
value: T;
@@ -49,7 +51,7 @@ export function SegmentedToggle<T extends string>({
aria-pressed={active}
data-slot={`${dataSlot ?? "segment"}-${optionValue}`}
className={cn(
"focus-visible:ring-ring inline-flex min-h-10 items-center justify-center rounded-[5px] px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:min-h-8",
"focus-visible:ring-ring inline-flex min-h-11 items-center justify-center rounded-[5px] px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:min-h-9",
active
? "bg-secondary text-secondary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
+22 -5
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Info } from "lucide-react";
import { Info, type LucideIcon } from "lucide-react";
import {
Popover,
@@ -43,6 +43,18 @@ export interface InfoPopoverProps {
*/
label?: string;
className?: string;
/**
* Trigger glyph. Defaults to `Info` (h-3.5 w-3.5); the compliance-tip
* wrapper passes `HelpCircle` at `h-3 w-3` to keep its pre-merge look.
*/
icon?: LucideIcon;
iconClassName?: string;
/** Popover alignment relative to the trigger. Defaults to `"end"`. */
align?: "start" | "center" | "end";
/** `data-slot` override for the trigger button. */
triggerDataSlot?: string;
/** `data-slot` override for the popover body. */
bodyDataSlot?: string;
}
export function InfoPopover({
@@ -50,6 +62,11 @@ export function InfoPopover({
link,
label,
className,
icon: Icon = Info,
iconClassName = "h-3.5 w-3.5",
align = "end",
triggerDataSlot = "info-popover-trigger",
bodyDataSlot = "info-popover-body",
}: InfoPopoverProps) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
@@ -59,7 +76,7 @@ export function InfoPopover({
<PopoverTrigger asChild>
<button
type="button"
data-slot="info-popover-trigger"
data-slot={triggerDataSlot}
aria-label={label ?? t("common.moreInfo")}
aria-expanded={open}
className={cn(
@@ -70,12 +87,12 @@ export function InfoPopover({
className,
)}
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
<Icon className={iconClassName} aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent
data-slot="info-popover-body"
align="end"
data-slot={bodyDataSlot}
align={align}
sideOffset={6}
className="max-w-xs space-y-1.5"
>
+5 -4
View File
@@ -25,9 +25,10 @@ export interface ViewToggleProps<T extends string> {
* two-segment control: the active segment carries the filled surface, the
* inactive one stays ghost. `aria-pressed` announces the state per segment;
* the group is named for screen readers. Segments keep the 44-px mobile tap
* floor (`size-11` via `size-10` + the `sm:size-8` desktop step), shrinking
* to the standard 36-px control height on desktop like a neighbouring Add
* button.
* floor (`size-11`), shrinking to the 36-px control height on desktop
* (`sm:size-9`) like a neighbouring Add button — the same floor the intake
* kebab and history-rail delete controls raised in this release window
* (L3, `.planning/audits/2026-07-18-qa-ui.md`).
*
* Previously forked between the medications module
* (`medications/medication-view-toggle.tsx`) and the Vorsorge/Illness/Labs
@@ -62,7 +63,7 @@ export function ViewToggle<T extends string>({
title={label}
data-slot={`${dataSlotPrefix}-${value}`}
className={cn(
"focus-visible:ring-ring inline-flex size-10 items-center justify-center rounded-[5px] transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:size-8",
"focus-visible:ring-ring inline-flex size-11 items-center justify-center rounded-[5px] transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:size-9",
active
? "bg-secondary text-secondary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
+39 -55
View File
@@ -24,6 +24,7 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import {
discoverCorrelations,
@@ -41,9 +42,9 @@ import {
buildMeasurementDailySeries,
fetchComplianceSeries,
fetchLabDraws,
fetchMeasurementWindowSeries,
fetchMoodWindowSeries,
fetchSymptomSeries,
toDailyMeans,
type MeasurementSeriesRow,
} from "@/lib/insights/correlation-channel-series";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import {
@@ -170,59 +171,30 @@ export async function readCoachCorrelations(
DISCOVERY_OUTCOMES,
) as MeasurementType[];
// v1.29.6 — `source` + `deviceType` + `sleepStage` feed
// `buildMeasurementDailySeries`'s per-type grain resolution below (see
// the insights route's identical comment for why).
const [measurements, moodEntries, coincidentDerived, priorityJson] =
await Promise.all([
prisma.measurement.findMany({
where: {
userId,
deletedAt: null,
measuredAt: { gte: since },
type: { in: [...behaviourTypes, ...outcomeTypes] },
},
orderBy: { measuredAt: "asc" },
take: 20000,
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
sleepStage: true,
},
}),
prisma.moodEntry.findMany({
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "asc" },
take: 5000,
select: { score: true, moodLoggedAt: true },
}),
// Coincident-deviation is its own derived metric — fail-soft to null so a
// baseline hiccup never sinks the whole correlations read. D2-8: pass the
// user's tz so the "today" grouping matches the user's calendar day, not
// UTC's, before the fired flag is narrated as "out of band TODAY".
computeCoincidentDeviation(userId, profile, { tz }).catch(() => null),
loadUserSourcePriority(userId),
]);
const byType = new Map<string, MeasurementSeriesRow[]>();
for (const m of measurements) {
const list = byType.get(m.type) ?? [];
list.push({
value: m.value,
at: m.measuredAt,
source: m.source,
deviceType: m.deviceType,
sleepStage: m.sleepStage,
});
byType.set(m.type, list);
}
const moodDaily = toDailyMeans(
moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })),
tz,
);
// v1.30.3 (QA F1) — the fetch + desc/cap/resort discipline (a dense
// account's cap must fall on the OLDEST rows, never the newest — the
// emerging-correlations pass below is entirely about the recent window)
// now lives in `fetchMeasurementWindowSeries` / `fetchMoodWindowSeries`
// (`correlation-channel-series.ts`), shared with the route, the
// per-metric card, and the period narrative.
const [
{ byType, measurementsCapped },
{ moodDaily, moodCapped },
coincidentDerived,
priorityJson,
] = await Promise.all([
fetchMeasurementWindowSeries(userId, since, [
...behaviourTypes,
...outcomeTypes,
]),
fetchMoodWindowSeries(userId, tz, since),
// Coincident-deviation is its own derived metric — fail-soft to null so a
// baseline hiccup never sinks the whole correlations read. D2-8: pass the
// user's tz so the "today" grouping matches the user's calendar day, not
// UTC's, before the fired flag is narrated as "out of band TODAY".
computeCoincidentDeviation(userId, profile, { tz }).catch(() => null),
loadUserSourcePriority(userId),
]);
const seriesPoints = (key: string) =>
key === "MOOD"
? moodDaily
@@ -263,6 +235,18 @@ export async function readCoachCorrelations(
}
}
// QA F1 — surfaces when a dense account's window exceeded the read cap,
// mirroring the route's identical annotation. The cap now falls on the
// OLDEST rows (desc + take), so a capped read still covers the recent
// window the emerging-correlations pass below needs.
annotate({
action: { name: "coach.correlations.read" },
meta: {
measurements_capped: measurementsCapped,
mood_entries_capped: moodCapped,
},
});
const discovery = discoverCorrelations(series);
const drivers: CoachCorrelationDriver[] = discovery.discovered.map((d) => ({
behaviour: humanise(d.behaviour),
@@ -422,6 +422,42 @@ describe("computeBpInTargetFastPath", () => {
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
});
// v1.30.3 (QA F7) — the guard force-routes a non-near-UTC user to the
// live path specifically so their pairing is computed correctly; before
// this fix the live path's same-day fallback still hardcoded the Berlin
// day, undoing exactly the protection the guard exists to provide.
it("pairs on the user's OWN tz on the live path it was force-routed to (Tokyo)", async () => {
const coverage = new Map<string, boolean>([
["BLOOD_PRESSURE_SYS", true],
["BLOOD_PRESSURE_DIA", true],
]);
FULLY_COVERED.mockReturnValue(true);
PROBE.mockResolvedValue(coverage);
// 2026-05-14T21:50Z = 23:50 Berlin (05-14) / 06:50 Tokyo (05-15).
// 2026-05-14T22:10Z = 00:10 Berlin (05-15, the NEXT Berlin day) /
// 07:10 Tokyo (05-15, the SAME Tokyo day). >5-min gap, so only the
// same-calendar-day fallback can pair them — and only Tokyo agrees.
QUERY_RAW.mockResolvedValueOnce([
{ measured_at: new Date("2026-05-14T21:50:00.000Z"), value: 125 },
]).mockResolvedValueOnce([
{ measured_at: new Date("2026-05-14T22:10:00.000Z"), value: 75 },
]);
const result = await computeBpInTargetFastPath({
userId: "user-tokyo-pair",
targets: TARGETS_UNDER_65,
now: new Date("2026-05-17T12:00:00.000Z"),
coverage,
userTz: "Asia/Tokyo",
});
expect(result.path).toBe("live");
// A Berlin-day pairing would reject this pair (different Berlin
// calendar days) and every window would read null/no-pairs.
expect(result.last30Days).toEqual({ pct: 100, pairs: 1 });
});
it("defaults to near-UTC when the caller omits userTz (legacy compat)", async () => {
const coverage = new Map<string, boolean>([
["BLOOD_PRESSURE_SYS", true],
@@ -119,6 +119,36 @@ describe("computeBpInTargetPct", () => {
expect(result).toEqual({ pct: 100, pairs: 1 });
});
/**
* v1.30.3 (QA F7) — the same-day pairing fallback used to hardcode the
* Berlin calendar day regardless of the caller's actual user. A New
* York user logging SYS at 17:50 and DIA at 18:10 local time (>5-min
* session gap) sits on 2026-05-01 in New York but straddles 23:50 →
* 00:10 UTC+2 Berlin — two DIFFERENT Berlin days — so the legitimate
* same-local-day pair was rejected. Passing the user's own tz fixes it;
* omitting the tz (legacy callers) keeps the old Berlin-only behaviour.
*/
it("pairs on the user's own tz, not a hardcoded Berlin day", () => {
// 2026-05-01T21:50Z = 17:50 New York (EDT) = 23:50 Berlin (CEST).
// 2026-05-01T22:10Z = 18:10 New York (EDT) = 00:10 Berlin — the NEXT
// Berlin calendar day (2026-05-02).
const sys = [reading("2026-05-01T21:50:00Z", 125)];
const dia = [reading("2026-05-01T22:10:00Z", 75)];
// Without a tz, the legacy Berlin-day fallback rejects the pair.
expect(computeBpInTargetPct(sys, dia, TARGETS_UNDER_65)).toBeNull();
// With the user's own tz, both readings fall on the same New York
// calendar day (2026-05-01) and the pair is accepted.
const result = computeBpInTargetPct(
sys,
dia,
TARGETS_UNDER_65,
"America/New_York",
);
expect(result).toEqual({ pct: 100, pairs: 1 });
});
/**
* Regression: the old denominator of `sysData.length` made the tile
* report 0 % when sys+dia readings shared a calendar day but were
+21 -3
View File
@@ -187,7 +187,7 @@ export async function computeBpInTargetFastPath(input: {
if (userNearUtc && sysCovered && diaCovered) {
return computeFromRollups(userId, targets, now, tzGuard);
}
return computeFromLive(userId, targets, now, tzGuard);
return computeFromLive(userId, targets, now, tzGuard, userTz);
}
/**
@@ -454,6 +454,15 @@ async function computeFromLive(
targets: BpTargets,
now: Date,
tzGuard: "near-utc" | "non-utc-live-fallback",
/**
* v1.30.3 (QA F7) — the user's own IANA tz, threaded into the pairing
* fallback (`computeBpInTargetWindows` / `collectBpPairs`). This is
* exactly the guard's protected population: a non-near-UTC user gets
* force-routed HERE specifically so their pairs are computed correctly
* — keying the fallback on Berlin instead of their own tz undid that
* protection.
*/
userTz: string | undefined,
): Promise<BpInTargetEnvelope> {
const DAY_MS = 24 * 60 * 60 * 1000;
const bpInTargetSince = new Date(now.getTime() - 365 * DAY_MS);
@@ -504,11 +513,20 @@ async function computeFromLive(
},
});
const windows = computeBpInTargetWindows(sysData, diaData, targets, now);
// v1.30.3 (QA F7) — `undefined` falls through to each helper's own
// Berlin default (unchanged behaviour for legacy callers that never
// pass a userTz); a real tz here overrides it.
const windows = computeBpInTargetWindows(
sysData,
diaData,
targets,
now,
userTz,
);
// v1.15.12 A1 — graded BP pillar score from the recency-weighted
// representative of every accepted per-event pair over the read window.
const gradedScore = gradeBpScoreFromSeries({
pairs: collectBpPairs(sysData, diaData),
pairs: collectBpPairs(sysData, diaData, userTz),
target: targets,
now,
});
+29 -16
View File
@@ -60,7 +60,7 @@ export interface BpReading {
export const SYS_HYPOTENSION_FLOOR = 90;
export const DIA_HYPOTENSION_FLOOR = 50;
import { toBerlinDayKey } from "@/lib/tz/resolver";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/resolver";
/**
* Pair a systolic reading with its closest diastolic by absolute time
@@ -120,8 +120,14 @@ export function isBpReadingInTarget(
* delta.
* 2. Accept the pair if `deltaMs <= 5 minutes` (legacy bound) — same
* session.
* 3. Otherwise accept the pair if both share the same Berlin
* calendar day (handles imports rounded to the hour or to noon).
* 3. Otherwise accept the pair if both share the same calendar day IN
* THE USER'S OWN TZ (handles imports rounded to the hour or to
* noon). v1.30.3 (QA F7) — this used to hardcode the Berlin day
* regardless of the caller's actual user, rejecting legitimate
* same-local-day pairs for a non-Berlin user whose two readings
* straddle Berlin midnight but not their own (e.g. 23:50/00:10
* Berlin from a New York evening reading pair) — the in-target %
* then ran on fewer pairs than it should have.
* 4. Discard otherwise.
*
* Denominator is the number of accepted pairs, NOT `sysData.length` —
@@ -135,6 +141,9 @@ export function computeBpInTargetPct(
sysSeries: BpReading[],
diaSeries: BpReading[],
targets: BpTargets,
/** v1.30.3 (QA F7) — the user's own IANA tz; defaults to Berlin for
* legacy callers that haven't threaded it through yet. */
tz: string = DEFAULT_TIMEZONE,
): { pct: number; pairs: number } | null {
if (sysSeries.length === 0 || diaSeries.length === 0) return null;
@@ -147,11 +156,11 @@ export function computeBpInTargetPct(
if (!match) continue;
const sameSession = match.deltaMs <= SAME_SESSION_MS;
const sameBerlinDay =
const sameLocalDay =
!sameSession &&
toBerlinDayKey(sys.measuredAt) === toBerlinDayKey(match.dia.measuredAt);
userDayKey(sys.measuredAt, tz) === userDayKey(match.dia.measuredAt, tz);
if (!sameSession && !sameBerlinDay) continue;
if (!sameSession && !sameLocalDay) continue;
pairs += 1;
if (isBpReadingInTarget(sys.value, match.dia.value, targets)) {
@@ -177,6 +186,8 @@ export function computeBpInTargetPct(
export function collectBpPairs(
sysSeries: BpReading[],
diaSeries: BpReading[],
/** v1.30.3 (QA F7) — see `computeBpInTargetPct`'s identical parameter. */
tz: string = DEFAULT_TIMEZONE,
): Array<{ at: Date; sys: number; dia: number }> {
if (sysSeries.length === 0 || diaSeries.length === 0) return [];
const SAME_SESSION_MS = 5 * 60 * 1000;
@@ -185,10 +196,10 @@ export function collectBpPairs(
const match = findClosestDia(sys, diaSeries);
if (!match) continue;
const sameSession = match.deltaMs <= SAME_SESSION_MS;
const sameBerlinDay =
const sameLocalDay =
!sameSession &&
toBerlinDayKey(sys.measuredAt) === toBerlinDayKey(match.dia.measuredAt);
if (!sameSession && !sameBerlinDay) continue;
userDayKey(sys.measuredAt, tz) === userDayKey(match.dia.measuredAt, tz);
if (!sameSession && !sameLocalDay) continue;
out.push({ at: sys.measuredAt, sys: sys.value, dia: match.dia.value });
}
return out;
@@ -234,6 +245,8 @@ export function computeBpInTargetWindows(
diaSeries: BpReading[],
targets: BpTargets,
now: Date = new Date(),
/** v1.30.3 (QA F7) — see `computeBpInTargetPct`'s identical parameter. */
tz: string = DEFAULT_TIMEZONE,
): {
last7Days: { pct: number; pairs: number } | null;
last30Days: { pct: number; pairs: number } | null;
@@ -322,7 +335,7 @@ export function computeBpInTargetWindows(
// v1.17 W1b — oldest accepted pair inside the 90-day window for the
// effective-span label. `collectBpPairs` applies the same pairing rules
// as `computeBpInTargetPct`, so the anchor matches the counted pairs.
const pairsLast90 = collectBpPairs(sysLast90, diaLast90);
const pairsLast90 = collectBpPairs(sysLast90, diaLast90, tz);
const last90EarliestAt =
pairsLast90.length === 0
? null
@@ -332,16 +345,16 @@ export function computeBpInTargetWindows(
);
return {
last7Days: computeBpInTargetPct(sysLast7, diaLast7, targets),
last30Days: computeBpInTargetPct(sysLast30, diaLast30, targets),
last7Days: computeBpInTargetPct(sysLast7, diaLast7, targets, tz),
last30Days: computeBpInTargetPct(sysLast30, diaLast30, targets, tz),
// v1.17 W1d — canonical headline / score / coach window.
last90Days: computeBpInTargetPct(sysLast90, diaLast90, targets),
last90Days: computeBpInTargetPct(sysLast90, diaLast90, targets, tz),
last90EarliestAt,
// v1.4.19 A1 — independent aggregate over EVERY paired reading. The
// analytics route now routes the dashboard tile's headline through
// this so it stops mirroring `last30Days`.
allTime: computeBpInTargetPct(sysSeries, diaSeries, targets),
priorMonth: computeBpInTargetPct(sysPriorMonth, diaPriorMonth, targets),
priorYear: computeBpInTargetPct(sysPriorYear, diaPriorYear, targets),
allTime: computeBpInTargetPct(sysSeries, diaSeries, targets, tz),
priorMonth: computeBpInTargetPct(sysPriorMonth, diaPriorMonth, targets, tz),
priorYear: computeBpInTargetPct(sysPriorYear, diaPriorYear, targets, tz),
};
}
@@ -5,6 +5,8 @@ vi.mock("@/lib/db", () => ({
biomarker: { findFirst: vi.fn() },
labResult: { findMany: vi.fn(), count: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
// v1.30.3 (QA F5) — `resolveUserTimezone` reads this directly.
user: { findUnique: vi.fn() },
},
}));
@@ -75,6 +77,9 @@ beforeEach(() => {
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date("2026-06-20T09:00:00.000Z"),
} as never);
// Default: no stored timezone → resolveUserTimezone falls to the
// Europe/Berlin server default, matching every other fixture's assumption.
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
});
describe("generateBiomarkerStatus — empty-data guard", () => {
@@ -180,6 +185,40 @@ describe("generateBiomarkerStatus — generation path", () => {
});
});
describe("generateBiomarkerStatus — per-user tz (QA F5)", () => {
it("day-keys the reading series in the USER's own tz, not Berlin's", async () => {
// 2026-06-20T23:30Z is 2026-06-21 01:30 in Berlin (UTC+2, DST) but still
// 2026-06-20 19:30 in New York (UTC-4, DST) — the two zones disagree on
// the calendar day. Before the fix every biomarker series day-keyed in
// Berlin regardless of the user's stored zone.
const reading = {
id: "r-ny",
value: 95,
takenAt: new Date("2026-06-20T23:30:00.000Z"),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "America/New_York",
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never);
vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never);
const capture = { userPrompt: "" };
stubCompletion('{"summary":"Stable."}', capture);
await generateBiomarkerStatus({
biomarkerId: MARKER.id,
userId: "u-ny",
locale: "en",
});
const match = capture.userPrompt.match(/\{[\s\S]*\}/);
const snapshot = JSON.parse(match![0]);
expect(snapshot.series[0].day).toBe("2026-06-20");
expect(snapshot.series[0].day).not.toBe("2026-06-21");
});
});
describe("generateBiomarkerStatus — input gate (regenerate only on a new reading)", () => {
it("re-stamps the cached text WITHOUT calling the provider when the latest reading is unchanged", async () => {
const reading = { id: "r1", value: 95, takenAt: TAKEN_AT };
@@ -103,6 +103,49 @@ describe("generateBmiStatusForUser — graded payload", () => {
});
});
describe("generateBmiStatusForUser — per-user tz (QA F5)", () => {
it("rolls the cache over at the USER's own midnight, not Berlin's", async () => {
// A moment that is already "tomorrow" in Berlin (UTC+2 summer) but still
// "today" in Los Angeles (UTC-7 summer) — the two zones disagree on the
// calendar day. Before the fix every card's cache day-key was Berlin-
// pinned regardless of the user's stored zone.
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-20T23:30:00.000Z")); // Berlin: 06-21 01:30
const laToday = "2026-06-20"; // Los Angeles: 06-20 16:30
try {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
timezone: "America/Los_Angeles",
} as never);
// A cached row keyed to the LA calendar day should be served as a
// fresh hit even though it is already "tomorrow" in Berlin.
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: JSON.stringify({
dateKey: laToday,
locale: "en",
text: "LA-anchored cached BMI text.",
model: "x",
}),
} as never);
const result = await generateBmiStatusForUser("user-la", {
locale: "en",
});
// If the day-key were still Berlin-pinned, this LA-dated row would
// MISS (today in Berlin is 06-21) and a real generation would run
// instead, serving the deterministic/no-key fallback rather than
// this cached text.
expect(result.cached).toBe(true);
expect(result.text).toBe("LA-anchored cached BMI text.");
expect(runStatusCompletion).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
describe("generateBmiStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
@@ -1,7 +1,19 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { SleepStage } from "@/generated/prisma/client";
const measurementFindMany = vi.fn();
const moodFindMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: (a: unknown) => measurementFindMany(a) },
moodEntry: { findMany: (a: unknown) => moodFindMany(a) },
},
}));
import {
buildMeasurementDailySeries,
fetchMeasurementWindowSeries,
fetchMoodWindowSeries,
toDailyMeans,
type MeasurementSeriesRow,
} from "@/lib/insights/correlation-channel-series";
@@ -130,3 +142,99 @@ describe("buildMeasurementDailySeries — grain consistency (v1.29.6)", () => {
expect(points).toEqual([{ day: "2026-06-04", value: 62 }]);
});
});
describe("fetchMeasurementWindowSeries — desc+cap+resort (v1.30.3 QA F1/F2/F3)", () => {
beforeEach(() => {
measurementFindMany.mockReset();
moodFindMany.mockReset();
});
it("orders the read DESC so a capped window keeps the NEWEST rows, then resorts ASC before grouping", async () => {
const since = new Date("2026-01-01T00:00:00.000Z");
// Simulate a dense account hitting the cap: the mocked DESC read
// returns exactly MEASUREMENT_READ_CAP (20000) rows, newest first —
// the shape a real `orderBy: desc, take: 20000` would produce once the
// in-window count crosses the cap.
const CAP = 20000;
const rowsDesc = Array.from({ length: CAP }, (_, i) => ({
type: "PULSE",
value: 60 + (i % 5),
// i=0 is the NEWEST (closest to now); i=CAP-1 is the oldest kept row.
measuredAt: new Date(Date.now() - i * 60_000),
source: "APPLE_HEALTH",
deviceType: null,
sleepStage: null,
}));
measurementFindMany.mockResolvedValue(rowsDesc);
const { byType, measurementsCapped } = await fetchMeasurementWindowSeries(
"u1",
since,
["PULSE"],
);
expect(measurementsCapped).toBe(true);
expect(measurementFindMany).toHaveBeenCalledWith(
expect.objectContaining({ orderBy: { measuredAt: "desc" }, take: CAP }),
);
const pulseRows = byType.get("PULSE")!;
expect(pulseRows).toHaveLength(CAP);
// Resorted ASCENDING before being handed to callers — the oldest row
// in the (capped, most-recent) window comes first.
for (let i = 1; i < pulseRows.length; i++) {
expect(pulseRows[i].at.getTime()).toBeGreaterThanOrEqual(
pulseRows[i - 1].at.getTime(),
);
}
// The NEWEST row (i=0 in the desc mock) must have survived the cap —
// a naive `orderBy: asc, take: N` would have dropped it instead.
const newest = pulseRows[pulseRows.length - 1];
expect(newest.at.getTime()).toBe(rowsDesc[0].measuredAt.getTime());
});
it("reports measurementsCapped:false when the read comes in under the cap", async () => {
measurementFindMany.mockResolvedValue([
{
type: "PULSE",
value: 60,
measuredAt: new Date(),
source: "MANUAL",
deviceType: null,
sleepStage: null,
},
]);
const { measurementsCapped } = await fetchMeasurementWindowSeries(
"u1",
new Date(),
["PULSE"],
);
expect(measurementsCapped).toBe(false);
});
});
describe("fetchMoodWindowSeries — desc+cap+resort", () => {
beforeEach(() => {
measurementFindMany.mockReset();
moodFindMany.mockReset();
});
it("orders the mood read DESC so a capped window keeps the NEWEST entries", async () => {
const CAP = 5000;
const rowsDesc = Array.from({ length: CAP }, (_, i) => ({
score: 3 + (i % 3),
moodLoggedAt: new Date(Date.now() - i * 60_000),
}));
moodFindMany.mockResolvedValue(rowsDesc);
const { moodCapped } = await fetchMoodWindowSeries(
"u1",
"UTC",
new Date("2026-01-01T00:00:00.000Z"),
);
expect(moodCapped).toBe(true);
expect(moodFindMany).toHaveBeenCalledWith(
expect.objectContaining({ orderBy: { moodLoggedAt: "desc" }, take: CAP }),
);
});
});
@@ -6,9 +6,15 @@ vi.mock("@/lib/db", () => ({
},
}));
// Berlin day key is deterministic input to the cache key; pin it.
// The user-tz day key is deterministic input to the cache key; pin it, but
// keep `resolveUserTimezone` spy-able so QA F5 (tz threading, not a
// hardcoded Berlin day) has a wiring assertion below.
const { resolveUserTimezoneMock } = vi.hoisted(() => ({
resolveUserTimezoneMock: vi.fn(async () => "Europe/Berlin"),
}));
vi.mock("@/lib/tz/resolver", () => ({
toBerlinDayKey: () => "2026-06-21",
resolveUserTimezone: resolveUserTimezoneMock,
userDayKey: () => "2026-06-21",
}));
vi.mock("@/lib/logging/context", () => ({
@@ -31,6 +37,7 @@ beforeEach(() => {
vi.mocked(prisma.measurement.groupBy).mockResolvedValue(
fingerprintRows(10) as never,
);
resolveUserTimezoneMock.mockResolvedValue("Europe/Berlin");
});
describe("getCachedFeatures (P3)", () => {
@@ -198,6 +205,21 @@ describe("getCachedFeatures (P3)", () => {
expect(compute).toHaveBeenCalledTimes(2);
});
it("resolves the day-key in the CALLING USER's own tz, not a hardcoded Berlin (QA F5)", async () => {
const compute = vi.fn().mockResolvedValue({ marker: "z" });
await withFeatureCacheScope(() =>
getCachedFeatures({
userId: "u-ny",
includeRaw: false,
sinceDays: 400,
compute,
}),
);
expect(resolveUserTimezoneMock).toHaveBeenCalledWith("u-ny");
});
it("recomputes on a fingerprint probe failure rather than serving a stale object", async () => {
const compute = vi
.fn()
@@ -210,6 +210,46 @@ describe("generatePulseStatusForUser — A2 resting-target in-target %", () => {
});
});
describe("generatePulseStatusForUser — per-user tz (QA F5)", () => {
it("rolls the cache over at the USER's own midnight, not Berlin's", async () => {
// A moment that is already "tomorrow" in Berlin (UTC+2 summer) but still
// "today" in New York (UTC-4 summer). Before the fix, `todayKey` was
// computed from `toBerlinDayKey(new Date())` BEFORE the user's profile
// (and its timezone) was even read.
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-20T23:30:00.000Z")); // Berlin: 06-21 01:30
const nyToday = "2026-06-20"; // New York: 06-20 19:30
try {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
timezone: "America/New_York",
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: JSON.stringify({
dateKey: nyToday,
locale: "en",
text: "NY-anchored cached pulse text.",
model: "x",
}),
} as never);
const result = await generatePulseStatusForUser("user-ny", {
locale: "en",
});
// If the day-key were still Berlin-pinned, this NY-dated row would
// MISS (today in Berlin is 06-21) and a real generation would run.
expect(result.cached).toBe(true);
expect(result.text).toBe("NY-anchored cached pulse text.");
expect(runStatusCompletion).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
describe("generatePulseStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
+8 -3
View File
@@ -42,7 +42,7 @@ import {
} from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
export interface BiomarkerStatusResult {
hasProvider: boolean;
@@ -148,7 +148,12 @@ export async function generateBiomarkerStatus(args: {
const readOnly = args.readOnly === true;
const scope = biomarkerStatusScope(marker.id);
const cacheAction = statusCacheAction(scope, locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. Has to
// happen ahead of the cache read below (the earliest possible return
// path).
const userTz = await resolveUserTimezone(args.userId);
const todayKey = userDayKey(new Date(), userTz);
const cached = await readFreshStatusText({
userId: args.userId,
@@ -302,7 +307,7 @@ export async function generateBiomarkerStatus(args: {
},
summary,
series: ascending.map((r) => ({
day: toBerlinDayKey(r.takenAt),
day: userDayKey(r.takenAt, userTz),
value: r.value,
})),
};
+11 -11
View File
@@ -64,11 +64,7 @@ import {
type StatusCardResult,
} from "@/lib/insights/status-card-generation";
import { annotate } from "@/lib/logging/context";
import {
resolveUserTimezone,
toBerlinDayKey,
userDayKey,
} from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
@@ -164,7 +160,12 @@ export async function prepareBloodPressureStatusForUser(
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("blood-pressure", locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. Has to
// happen ahead of the cache read below (the earliest possible return
// path), not just the later day-bucketing reads.
const userTz = await resolveUserTimezone(userId);
const todayKey = userDayKey(new Date(), userTz);
const cached = await readFreshStatusText({
userId,
@@ -250,11 +251,10 @@ export async function prepareBloodPressureStatusForUser(
const now = new Date();
// v1.7.0 SB-SCHED-2 / v1.2.5 (M-TZ3) — resolve the user timezone once,
// up front, so BOTH the BP-status compliance gate AND every day-bucketing
// pass below (applyPayloadBudget, pairDailyBuckets, the continuity series)
// key on the user's own calendar day, not Berlin's.
const userTz = await resolveUserTimezone(userId);
// v1.7.0 SB-SCHED-2 / v1.2.5 (M-TZ3) — BOTH the BP-status compliance gate
// AND every day-bucketing pass below (applyPayloadBudget, pairDailyBuckets,
// the continuity series) key on the user's own calendar day, not Berlin's.
// `userTz` was already resolved above for the cache day-key.
const weightSeries = applyPayloadBudget(
measurements
+8 -4
View File
@@ -45,7 +45,7 @@ import {
type StatusCardResult,
} from "@/lib/insights/status-card-generation";
import { annotate } from "@/lib/logging/context";
import { DEFAULT_TIMEZONE, toBerlinDayKey } from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
/**
* Public entry — unchanged signature. Prepares the card (cache-read,
@@ -102,7 +102,12 @@ export async function prepareBmiStatusForUser(
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("bmi", locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. Has to
// happen ahead of the cache read below (the earliest possible return
// path), not just the later day-bucketing reads.
const userTz = await resolveUserTimezone(userId);
const todayKey = userDayKey(new Date(), userTz);
const cached = await readFreshStatusText({
userId,
@@ -162,7 +167,6 @@ export async function prepareBmiStatusForUser(
where: { id: userId },
select: {
heightCm: true,
timezone: true,
},
});
@@ -236,7 +240,7 @@ export async function prepareBmiStatusForUser(
const now = new Date();
// v1.2.5 (M-TZ3) — bucket day windows on the user's own calendar so a
// near-midnight reading lands on the user's day for non-Berlin self-hosters.
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
// `userTz` was already resolved above for the cache day-key.
const heightFactor = (user.heightCm / 100) ** 2;
const bmiPoints = measurements.map((measurement) => ({
@@ -256,6 +256,114 @@ function tzDayKey(at: Date, tz: string): string {
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
const MEASUREMENT_READ_CAP = 20000;
const MOOD_READ_CAP = 5000;
/** One measurement-window fetch's result: per-type raw rows + the cap flag. */
export interface MeasurementWindowFetch {
/** Raw rows shaped for `buildMeasurementDailySeries`, grouped by type. */
byType: Map<string, MeasurementSeriesRow[]>;
/** True when the read hit the cap — the window may be missing OLDER rows. */
measurementsCapped: boolean;
}
/**
* v1.30.3 (QA F1/F2/F3) — shared measurement-window fetch for every
* correlation-adjacent surface: the `/api/insights/correlations` route, the
* Coach `get_correlations` tool, the per-metric assessment card, and the
* period narrative. Hoisted here so a fourth independently-maintained copy
* can't drift the way the period-narrative one did.
*
* Orders DESC + caps at {@link MEASUREMENT_READ_CAP}, then re-sorts ASC in
* JS so a dense account's cap falls on the OLDEST rows, never the newest —
* the inverse of a naive `orderBy asc, take N`, which silently drops the
* NEWEST reads once an account's in-window row count crosses the cap
* (exactly backwards for a recent-window read, e.g. the Coach's emerging-
* correlations pass or a current-vs-prior period narrative). Selects
* `source` / `deviceType` / `sleepStage` unconditionally so every caller can
* feed `buildMeasurementDailySeries`'s per-type grain resolution (source
* collapse for cumulative types, per-night reconstruction for sleep)
* without a second query.
*/
export async function fetchMeasurementWindowSeries(
userId: string,
since: Date,
types: MeasurementType[],
): Promise<MeasurementWindowFetch> {
const rowsDesc = await prisma.measurement.findMany({
where: {
userId,
deletedAt: null,
measuredAt: { gte: since },
type: { in: types },
},
orderBy: { measuredAt: "desc" },
take: MEASUREMENT_READ_CAP,
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
sleepStage: true,
},
});
const measurementsCapped = rowsDesc.length >= MEASUREMENT_READ_CAP;
const byType = new Map<string, MeasurementSeriesRow[]>();
for (const m of [...rowsDesc].sort(
(a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(),
)) {
const list = byType.get(m.type) ?? [];
list.push({
value: m.value,
at: m.measuredAt,
source: m.source,
deviceType: m.deviceType,
sleepStage: m.sleepStage,
});
byType.set(m.type, list);
}
return { byType, measurementsCapped };
}
/** One mood-window fetch's result: the daily-mean series + the cap flag. */
export interface MoodWindowFetch {
moodDaily: DailySeriesPoint[];
/** True when the read hit the cap — the window may be missing OLDER rows. */
moodCapped: boolean;
}
/**
* v1.30.3 (QA F1/F2/F3) — shared mood-window fetch, same desc+cap+resort
* discipline as {@link fetchMeasurementWindowSeries}. Callers that also need
* per-entry factor tag-links (the period narrative's RATED-factor channels)
* read `MoodEntry` themselves for the extra `tagLinks` select, but MUST
* apply the same desc+resort discipline — this helper only covers the
* plain-score case the route / coach tool / per-metric card share.
*/
export async function fetchMoodWindowSeries(
userId: string,
tz: string,
since: Date,
): Promise<MoodWindowFetch> {
const rowsDesc = await prisma.moodEntry.findMany({
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "desc" },
take: MOOD_READ_CAP,
select: { score: true, moodLoggedAt: true },
});
const moodCapped = rowsDesc.length >= MOOD_READ_CAP;
const rows = [...rowsDesc].sort(
(a, b) => a.moodLoggedAt.getTime() - b.moodLoggedAt.getTime(),
);
const moodDaily = toDailyMeans(
rows.map((e) => ({ value: e.score, at: e.moodLoggedAt })),
tz,
);
return { moodDaily, moodCapped };
}
/**
* v1.29.6 — collapse rows to per-day MEANS keyed in the user's tz. This is
* the correct grain for spot metrics (BP, glucose, HRV, resting HR, weight,
@@ -0,0 +1,111 @@
/**
* v1.30.3 (QA F5) — the derived-score AI warm layer must roll its cache over
* at the USER's own midnight (not Berlin's) and label the prompt's date line
* with the user's actual tz (not a hardcoded "(Europe/Berlin)").
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/status-cache", () => ({
readFreshStatusText: vi.fn(),
resolveReadOnlyStatusMiss: vi.fn(),
statusCacheAction: (scope: string, locale: string) =>
`insights.${scope}-status.${locale}`,
computeStatusInputFingerprint: vi.fn().mockResolvedValue("hash"),
gateUnchangedStatusInput: vi.fn().mockResolvedValue(null),
}));
const findUnique = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: (a: unknown) => findUnique(a) } },
}));
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
resolveDerivedAssessment,
generateDerivedScoreAssessment,
} from "../derived-assessment-ai";
import type { Derived } from "../types";
const RECOVERY_VALUE = {
score: 70,
band: "green" as const,
trendDelta: null,
daysInWindow: 10,
asOf: "2026-06-20T12:00:00.000Z",
series: [],
};
function okDerived(): Derived<unknown> {
return {
status: "ok",
value: RECOVERY_VALUE,
coverage: {
requiredInputs: 1,
presentInputs: 1,
historyDays: 30,
missing: [],
},
confidence: { score: 90, band: "high" },
provenance: {
inputs: ["RECOVERY_SCORE"],
source: "DAY",
windowDays: 30,
computedAt: "x",
},
};
}
beforeEach(() => {
vi.resetAllMocks();
findUnique.mockResolvedValue({ timezone: "America/New_York" });
});
describe("resolveDerivedAssessment — per-user tz (QA F5)", () => {
it("resolves the user's own tz before reading the cache", async () => {
vi.mocked(readFreshStatusText).mockResolvedValue(null);
await resolveDerivedAssessment({
metric: "RECOVERY_SCORE",
userId: "u-ny",
derived: okDerived(),
locale: "en",
now: new Date("2026-06-20T23:30:00.000Z"), // Berlin: 06-21 already
});
expect(findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "u-ny" } }),
);
// The cache read must have run with the NY calendar day (06-20), not the
// Berlin one (06-21) — the fixed pre-fix behaviour.
const call = vi.mocked(readFreshStatusText).mock.calls[0][0] as {
todayKey: string;
};
expect(call.todayKey).toBe("2026-06-20");
});
});
describe("generateDerivedScoreAssessment — per-user tz label (QA F5)", () => {
it("labels the prompt's date line with the user's actual tz, not a hardcoded Berlin", async () => {
let capturedPrompt = "";
vi.mocked(runStatusCompletion).mockImplementation(async (args) => {
capturedPrompt = (args as { userPrompt: string }).userPrompt;
return { kind: "none" } as never;
});
await generateDerivedScoreAssessment({
metric: "RECOVERY_SCORE",
userId: "u-ny",
derived: okDerived(),
locale: "en",
now: new Date("2026-06-20T23:30:00.000Z"),
});
expect(capturedPrompt).toContain("(America/New_York)");
expect(capturedPrompt).not.toContain("(Europe/Berlin)");
});
});
@@ -41,7 +41,7 @@ import {
gateUnchangedStatusInput,
} from "@/lib/insights/status-cache";
import type { MeasurementType } from "@/generated/prisma/client";
import { toBerlinDayKey } from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { annotate } from "@/lib/logging/context";
import type { Locale } from "@/lib/i18n/config";
import {
@@ -112,6 +112,8 @@ function scoreUserPrompt(
todayKey: string,
locale: SupportedLocale,
openerHint: string,
/** v1.30.3 (QA F5) — the user's own IANA tz; was hardcoded "Europe/Berlin". */
tz: string,
): string {
const snapshot = JSON.stringify(
{ promptVersion: PROMPT_VERSION, generatedForDay: todayKey, band, signal },
@@ -119,13 +121,13 @@ function scoreUserPrompt(
2,
);
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
return `Date: ${todayKey} (${tz})
OPENER HINT: ${openerHint}
Write an assessment of ${signal.metric} today across the four beats — the standing, what helped AND what hurt it, what the band means for the day, and one grounded nudge. Aim for 3–5 sentences, roughly 45–75 words (this overrides the shorter base length cap). Connected prose, not a checklist.
${snapshot}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
return `Datum: ${todayKey} (${tz})
OPENER-HINWEIS: ${openerHint}
Schreibe eine Einschätzung zu ${signal.metric} heute über die vier Beats — die Einordnung, was geholfen UND was gebremst hat, was das Band für den Tag bedeutet und ein gegroundeter Anstoß. Ziel sind 3–5 Sätze, rund 45–75 Wörter (das übersteuert die kürzere Basis-Längenvorgabe). Zusammenhängende Prosa, keine Checkliste.
@@ -161,7 +163,10 @@ export async function resolveDerivedAssessment(args: {
const scope = derivedScoreScope(args.metric);
const cacheAction = statusCacheAction(scope, locale);
const todayKey = toBerlinDayKey(now);
// v1.30.3 (QA F5) — roll the cache over at the user's own midnight, not
// Berlin's.
const userTz = await resolveUserTimezone(args.userId);
const todayKey = userDayKey(now, userTz);
// Fresh AI text for today → serve it (warmer prose overrides the template).
const fresh = await readFreshStatusText({
@@ -215,7 +220,11 @@ export async function generateDerivedScoreAssessment(args: {
const scope = derivedScoreScope(args.metric);
const cacheAction = statusCacheAction(scope, locale);
const todayKey = toBerlinDayKey(now);
// v1.30.3 (QA F5) — roll the cache over at the user's own midnight, not
// Berlin's; also threads into the prompt's date label below (was
// hardcoded "(Europe/Berlin)" regardless of the user's actual zone).
const userTz = await resolveUserTimezone(args.userId);
const todayKey = userDayKey(now, userTz);
// v1.22 (W6) — opener-archetype rotation + a day-rotated seed so a score
// assessment varies across scores and across days instead of riding the
@@ -254,7 +263,14 @@ export async function generateDerivedScoreAssessment(args: {
cacheAction,
consentSurface: "insights",
systemPrompt: scoreSystemPrompt(locale),
userPrompt: scoreUserPrompt(signal, band, todayKey, locale, openerHint),
userPrompt: scoreUserPrompt(
signal,
band,
todayKey,
locale,
openerHint,
userTz,
),
// v1.22 (W6) — nudged up from 0.45 for variety; the grounding gate below
// catches any number the warmer sampling might drift onto.
temperature: 0.55,
+12 -5
View File
@@ -29,8 +29,9 @@
* `getCachedFeatures` falls straight through to the computer — no global
* state, no cross-user leak, no stale-across-requests hazard.
* - The key is `(userId, day, includeRaw, sinceDays, inputHash)`. `day` is
* the Berlin calendar day so a scope that straddles midnight recomputes
* rather than serving yesterday's window. `inputHash` is a cheap
* the user's own calendar day (v1.30.3 QA F5 — was Berlin-pinned for
* every user) so a scope that straddles the user's local midnight
* recomputes rather than serving yesterday's window. `inputHash` is a cheap
* count/newest fingerprint of the salient measurement + mood inputs, so a
* mid-scope ingest (rare, but possible on a long-running warm) recomputes
* instead of reusing a pre-ingest snapshot.
@@ -43,7 +44,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { toBerlinDayKey } from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { annotate } from "@/lib/logging/context";
/** One memoised feature read, with the input fingerprint it was keyed on. */
@@ -154,8 +155,14 @@ export async function getCachedFeatures<T>(args: {
return args.compute();
}
const inputHash = await computeFeatureInputHash(args.userId);
const dayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — the day-key rolls over at the USER'S own midnight, not
// Berlin's, so a Coach/briefing scope spanning the user's local midnight
// recomputes at the right instant for that user.
const [inputHash, userTz] = await Promise.all([
computeFeatureInputHash(args.userId),
resolveUserTimezone(args.userId),
]);
const dayKey = userDayKey(new Date(), userTz);
const key = `${args.userId}|${dayKey}|${args.includeRaw ? "raw" : "agg"}|${args.sinceDays}`;
const hit = current.get(key);
+11 -10
View File
@@ -44,11 +44,7 @@ import {
type StatusCardResult,
} from "@/lib/insights/status-card-generation";
import { annotate } from "@/lib/logging/context";
import {
resolveUserTimezone,
toBerlinDayKey,
userDayKey,
} from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
// Derived from canonical enum so a new measurement type is auto-included
// in the AI general-status fetch (V3 audit: enum drift cousins).
@@ -104,7 +100,12 @@ export async function prepareGeneralStatusForUser(
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("general", locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. Has to
// happen ahead of the cache read below (the earliest possible return
// path), not just the later day-bucketing reads.
const userTz = await resolveUserTimezone(userId);
const todayKey = userDayKey(new Date(), userTz);
const cached = await readFreshStatusText({
userId,
@@ -191,10 +192,10 @@ export async function prepareGeneralStatusForUser(
const now = new Date();
// v1.2.5 (M-TZ3) — resolve the user's timezone so every day-bucketing
// pass below keys on the user's own calendar (a near-midnight reading
// lands on the user's day for non-Berlin self-hosters).
const userTz = await resolveUserTimezone(userId);
// v1.2.5 (M-TZ3) — every day-bucketing pass below keys on the user's own
// calendar (a near-midnight reading lands on the user's day for
// non-Berlin self-hosters). `userTz` was already resolved above for the
// cache day-key.
// `dailyByType` keeps the `applyPayloadBudget` daily buckets for the
// downstream BP in-target pairing; the prompt embeds the compact
@@ -12,7 +12,7 @@ import {
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { getMedicationCategories } from "@/lib/medication-category";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
import { getNoKeyMedicationComplianceStatusText } from "@/lib/insights/no-key-fallbacks";
@@ -47,7 +47,6 @@ import {
type PreparedStatusCard,
} from "@/lib/insights/status-card-generation";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
// 360 daily days + 24 monthly windows ≈ 1080 days of intake history.
const COMPLIANCE_HISTORY_DAYS = 360 + 24 * 30;
@@ -113,7 +112,12 @@ export async function prepareMedicationComplianceStatusForUser(
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("medication-compliance", locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. Has to
// happen ahead of the cache read below (the earliest possible return
// path), not just the later day-bucketing reads.
const userTz = await resolveUserTimezone(userId);
const todayKey = userDayKey(new Date(), userTz);
// This route carries a richer cached envelope (`summary` +
// `medications`) than the standard `text`-only generators, so it
@@ -264,9 +268,9 @@ export async function prepareMedicationComplianceStatusForUser(
},
});
// v1.7.0 SB-SCHED-2 — resolve the user timezone once so the
// compliance-pillar denominators route through the canonical engine.
const userTz = await resolveUserTimezone(userId);
// v1.7.0 SB-SCHED-2 — the compliance-pillar denominators route through
// the canonical engine on the user's own tz. `userTz` was already
// resolved above for the cache day-key.
const medicationSnapshots = medications.map((medication) => {
const events = medicationEvents.filter(
+13 -47
View File
@@ -31,8 +31,8 @@ import {
} from "@/lib/insights/correlation-discovery";
import {
buildMeasurementDailySeries,
toDailyMeans,
type MeasurementSeriesRow,
fetchMeasurementWindowSeries,
fetchMoodWindowSeries,
} from "@/lib/insights/correlation-channel-series";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import type { RelevantCorrelation } from "@/lib/insights/assessment-context";
@@ -96,54 +96,20 @@ export async function getRelevantCorrelationsForMetric(
DISCOVERY_OUTCOMES,
) as MeasurementType[];
// v1.29.6 — `source` + `deviceType` + `sleepStage` feed
// `buildMeasurementDailySeries`'s per-type grain resolution below (see
// the route's identical comment for why).
const [measurements, moodEntries, priorityJson] = await Promise.all([
prisma.measurement.findMany({
where: {
userId,
deletedAt: null,
measuredAt: { gte: since },
type: { in: [...behaviourTypes, ...outcomeTypes] },
},
orderBy: { measuredAt: "asc" },
take: 20000,
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
sleepStage: true,
},
}),
prisma.moodEntry.findMany({
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "asc" },
take: 5000,
select: { score: true, moodLoggedAt: true },
}),
// v1.30.3 (QA F1) — the fetch + desc/cap/resort discipline (a dense
// account's cap must fall on the OLDEST rows, never the newest) now
// lives in `fetchMeasurementWindowSeries` / `fetchMoodWindowSeries`
// (`correlation-channel-series.ts`), shared with the route, the Coach
// tool, and the period narrative.
const [{ byType }, { moodDaily }, priorityJson] = await Promise.all([
fetchMeasurementWindowSeries(userId, since, [
...behaviourTypes,
...outcomeTypes,
]),
fetchMoodWindowSeries(userId, tz, since),
loadUserSourcePriority(userId),
]);
const byType = new Map<string, MeasurementSeriesRow[]>();
for (const m of measurements) {
const list = byType.get(m.type) ?? [];
list.push({
value: m.value,
at: m.measuredAt,
source: m.source,
deviceType: m.deviceType,
sleepStage: m.sleepStage,
});
byType.set(m.type, list);
}
const moodDaily = toDailyMeans(
moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })),
tz,
);
const points = (key: string) =>
key === "MOOD"
? moodDaily
+10 -6
View File
@@ -73,7 +73,7 @@ import {
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import {
readFreshStatusText,
refreshUnchangedStatusInsight,
@@ -83,7 +83,6 @@ import {
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
export interface MetricStatusResult {
hasProvider: boolean;
@@ -150,7 +149,12 @@ export async function generateMetricStatus(args: {
const readOnly = args.readOnly === true;
const scope = metricStatusScope(meta.id);
const cacheAction = statusCacheAction(scope, locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. Has to
// happen ahead of the cache read below (the earliest possible return
// path), not just the later day-bucketing reads.
const userTz = await resolveUserTimezone(args.userId);
const todayKey = userDayKey(new Date(), userTz);
const cached = await readFreshStatusText({
userId: args.userId,
@@ -239,9 +243,9 @@ export async function generateMetricStatus(args: {
// to SLEEP_DURATION only so no other metric's status path changes.
const isSleep = meta.measurementType === "SLEEP_DURATION";
// v1.2.5 (M-TZ3) — resolve the user's timezone up front so the day
// bucketing below keys on the user's own calendar regardless of branch.
const userTz = await resolveUserTimezone(args.userId);
// v1.2.5 (M-TZ3) — the day bucketing below keys on the user's own
// calendar regardless of branch. `userTz` was already resolved above for
// the cache day-key.
const points: Array<{ measuredAt: Date; value: number }> = [];
let measurements: Array<{ measuredAt: Date }> = [];
+9 -5
View File
@@ -64,7 +64,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { createCustomLabelResolver } from "@/lib/mood/custom-tags";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone, toBerlinDayKey } from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
/**
* Drop the ranking-only `pooledSd` before a tag-influence row enters the
@@ -149,7 +149,12 @@ export async function prepareMoodStatusForUser(
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("mood", locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. Has to
// happen ahead of the cache read below (the earliest possible return
// path), not just the later day-bucketing reads.
const userTz = await resolveUserTimezone(userId);
const todayKey = userDayKey(new Date(), userTz);
const cached = await readFreshStatusText({
userId,
@@ -291,9 +296,8 @@ export async function prepareMoodStatusForUser(
const now = new Date();
// v1.2.5 (M-TZ3) — resolve the user's timezone so every day-bucketing
// pass below keys on the user's own calendar day.
const userTz = await resolveUserTimezone(userId);
// v1.2.5 (M-TZ3) — every day-bucketing pass below keys on the user's own
// calendar day. `userTz` was already resolved above for the cache day-key.
const moodPoints = entries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
@@ -0,0 +1,134 @@
/**
* v1.30.3 (QA F2/F3) — `buildPeriodNarrativeContext` (the DB wrapper) must:
* - order its measurement read DESC + cap, resorting ASC before folding,
* so a dense account's cap falls on the OLDEST rows and never drops
* the CURRENT period (F2 — the asc+take(20000) cap used to drop the
* current period first, the worst possible direction for a
* current-vs-prior surface);
* - route every type through `buildMeasurementDailySeries`'s per-type
* grain (sleep = per-night SUM, cumulative = source-collapsed SUM),
* not the local `toDailyMeans` twin that used to blindly average
* every type including sleep-stage segments (F3).
*
* This exercises the real DB wrapper (mocked prisma), not just the pure
* `assemblePeriodNarrativeContext` core the sibling test file covers.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const userFindUnique = vi.fn();
const measurementFindMany = vi.fn();
const moodFindMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: (a: unknown) => userFindUnique(a) },
measurement: { findMany: (a: unknown) => measurementFindMany(a) },
moodEntry: { findMany: (a: unknown) => moodFindMany(a) },
},
}));
import { buildPeriodNarrativeContext } from "../period-narrative";
const DAY_MS = 24 * 60 * 60 * 1000;
beforeEach(() => {
userFindUnique.mockReset().mockResolvedValue({
timezone: "UTC",
sourcePriorityJson: null,
});
measurementFindMany.mockReset();
moodFindMany.mockReset().mockResolvedValue([]);
});
describe("buildPeriodNarrativeContext — grain (QA F3)", () => {
it("sums a night's per-stage segments into the SLEEP_DURATION delta instead of averaging them", async () => {
// `now` deep enough into the week that a full current + prior 7-day
// span (plus the +1 day lag slack) has clean, unambiguous UTC days.
const now = new Date("2026-06-30T12:00:00.000Z");
// WEIGHT + PULSE: 14 daily readings each (current + prior week) so both
// clear the >=3-covered-day floor and the >=2-metrics-covered gate.
const dailyMetricRows: Array<{
type: string;
value: number;
measuredAt: Date;
}> = [];
for (let i = 0; i < 14; i++) {
const at = new Date(now.getTime() - i * DAY_MS - 6 * 60 * 60 * 1000);
dailyMetricRows.push({ type: "WEIGHT", value: 80, measuredAt: at });
dailyMetricRows.push({ type: "PULSE", value: 60, measuredAt: at });
}
// One night THIS week: CORE 240 + DEEP 90 + REM 80 = 410 minutes total
// asleep. `measuredAt` is the END of each segment (reconstructor derives
// the start from `measuredAt - value minutes`), matching
// `sleep-night.test.ts` / `correlation-channel-series.test.ts`.
const nightEnd = new Date(now.getTime() - 1 * DAY_MS);
const sleepRows = [
{
type: "SLEEP_DURATION",
value: 240,
measuredAt: new Date(nightEnd.getTime() - 3 * 60 * 60 * 1000),
source: "APPLE_HEALTH",
deviceType: null,
sleepStage: "CORE",
},
{
type: "SLEEP_DURATION",
value: 90,
measuredAt: new Date(nightEnd.getTime() - 1.5 * 60 * 60 * 1000),
source: "APPLE_HEALTH",
deviceType: null,
sleepStage: "DEEP",
},
{
type: "SLEEP_DURATION",
value: 80,
measuredAt: nightEnd,
source: "APPLE_HEALTH",
deviceType: null,
sleepStage: "REM",
},
];
measurementFindMany.mockResolvedValue([
...dailyMetricRows.map((r) => ({
...r,
source: "MANUAL",
deviceType: null,
sleepStage: null,
})),
...sleepRows,
]);
const result = await buildPeriodNarrativeContext("u1", {
period: "week",
now,
});
expect(result.status).toBe("ready");
if (result.status !== "ready") return;
const sleepDelta = result.metricDeltas.find(
(d) => d.type === "SLEEP_DURATION",
);
expect(sleepDelta).toBeDefined();
// The pre-fix local `toDailyMeans` twin would have averaged the three
// segment durations: (240 + 90 + 80) / 3 ≈ 137 — a number with no
// clinical meaning. The correct grain is the night's TOTAL (410).
expect(sleepDelta!.current).toBe(410);
expect(sleepDelta!.current).not.toBeCloseTo((240 + 90 + 80) / 3, 0);
});
it("orders the measurement read DESC + cap so the CURRENT period survives a capped window (QA F2)", async () => {
const now = new Date("2026-06-30T12:00:00.000Z");
measurementFindMany.mockResolvedValue([]);
await buildPeriodNarrativeContext("u1", { period: "week", now });
expect(measurementFindMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: { measuredAt: "desc" },
take: 20000,
}),
);
});
});
+78 -64
View File
@@ -31,6 +31,7 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import {
discoverCorrelations,
@@ -43,8 +44,16 @@ import {
} from "@/lib/insights/correlation-discovery";
import { buildBaselineBand, median } from "@/lib/insights/derived/baseline";
import { VITALS_BASELINE_TYPES } from "@/lib/insights/derived/registry";
import {
buildMeasurementDailySeries,
fetchMeasurementWindowSeries,
toDailyMeans,
} from "@/lib/insights/correlation-channel-series";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Cap on the mood-entry window read, mirroring the route / Coach tool. */
const MOOD_READ_CAP = 5000;
/** The two supported narrative periods and their length in days. */
export const PERIOD_DAYS = { week: 7, month: 30 } as const;
@@ -181,25 +190,6 @@ function tzDayKey(at: Date, tz: string): string {
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
/** Collapse raw readings to per-day means, day-keyed in the user's tz. Pure. */
function toDailyMeans(
rows: Array<{ value: number; at: Date }>,
tz: string,
): DailySeriesPoint[] {
const byDay = new Map<string, { sum: number; count: number }>();
for (const r of rows) {
if (!Number.isFinite(r.value)) continue;
const day = tzDayKey(r.at, tz);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += r.value;
acc.count += 1;
byDay.set(day, acc);
}
return [...byDay.entries()]
.map(([day, acc]) => ({ day, value: acc.sum / acc.count }))
.sort((a, b) => (a.day < b.day ? -1 : 1));
}
/**
* A RATED mood factor link the period read pulls alongside the score.
* Carries the scale + `inverse` so the series build can apply the
@@ -548,58 +538,82 @@ export async function buildPeriodNarrativeContext(
]),
) as MeasurementType[];
const [measurements, moodEntries] = await Promise.all([
prisma.measurement.findMany({
where: {
userId,
deletedAt: null,
measuredAt: { gte: since },
type: { in: measurementTypes },
},
orderBy: { measuredAt: "asc" },
take: 20000,
select: { type: true, value: true, measuredAt: true },
}),
prisma.moodEntry.findMany({
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "asc" },
take: 5000,
select: {
score: true,
moodLoggedAt: true,
// v1.14.0 — pull RATED-factor links so each factor the user scores
// (work / sleep-quality / stress …) joins the discovery matrix as a
// `FACTOR:<key>` channel. One extra select on the existing mood read
// — no new round-trip. BINARY links carry a null `rating` and are
// dropped below.
tagLinks: {
where: { moodTag: { kind: "RATED" }, rating: { not: null } },
select: {
rating: true,
moodTag: {
select: {
key: true,
scaleMin: true,
scaleMax: true,
inverse: true,
// v1.30.3 (QA F2/F3) — the fetch + desc/cap/resort discipline AND the
// per-type grain resolution (source-collapse sum for cumulative types,
// per-night reconstruction for sleep — not a blind per-row MEAN) now
// route through the shared `fetchMeasurementWindowSeries` +
// `buildMeasurementDailySeries` (`correlation-channel-series.ts`), the
// same helpers the route / Coach tool / per-metric card share. Before
// this fix the local `toDailyMeans` twin folded EVERY type through a
// blind per-row mean: a month's current-vs-prior period comparison for
// `SLEEP_DURATION` averaged per-stage segment durations (~45 min)
// instead of summing a night's total time asleep, and `ACTIVITY_STEPS`
// blended per-sample chunk means with drained daily totals — and the
// `asc, take 20000` cap dropped the CURRENT period first on a dense
// account, the worst possible direction for a current-vs-prior surface.
const [{ byType, measurementsCapped }, moodEntriesDesc, priorityJson] =
await Promise.all([
fetchMeasurementWindowSeries(userId, since, measurementTypes),
prisma.moodEntry.findMany({
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "desc" },
take: MOOD_READ_CAP,
select: {
score: true,
moodLoggedAt: true,
// v1.14.0 — pull RATED-factor links so each factor the user scores
// (work / sleep-quality / stress …) joins the discovery matrix as a
// `FACTOR:<key>` channel. One extra select on the existing mood read
// — no new round-trip. BINARY links carry a null `rating` and are
// dropped below.
tagLinks: {
where: { moodTag: { kind: "RATED" }, rating: { not: null } },
select: {
rating: true,
moodTag: {
select: {
key: true,
scaleMin: true,
scaleMax: true,
inverse: true,
},
},
},
},
},
},
}),
]);
}),
loadUserSourcePriority(userId),
]);
// This surface reads mood alongside its RATED-factor tag-links (a select
// the shared `fetchMoodWindowSeries` helper does not carry), so the
// desc+cap+resort discipline is applied in place here rather than
// through that helper — same discipline, bespoke select.
const moodCapped = moodEntriesDesc.length >= MOOD_READ_CAP;
const moodEntries = [...moodEntriesDesc].sort(
(a, b) => a.moodLoggedAt.getTime() - b.moodLoggedAt.getTime(),
);
const rawByType = new Map<string, Array<{ value: number; at: Date }>>();
for (const m of measurements) {
const list = rawByType.get(m.type) ?? [];
list.push({ value: m.value, at: m.measuredAt });
rawByType.set(m.type, list);
}
// QA F2 — surfaces when a dense account's window exceeded the read cap,
// mirroring the route's identical annotation. The cap now falls on the
// OLDEST rows (desc + take), so a capped read still covers the CURRENT
// period this current-vs-prior surface needs.
annotate({
action: { name: "insights.period-narrative.read" },
meta: {
period,
measurements_capped: measurementsCapped,
mood_entries_capped: moodCapped,
},
});
const seriesByMetric = new Map<string, DailySeriesPoint[]>();
for (const [type, rows] of rawByType) {
seriesByMetric.set(type, toDailyMeans(rows, tz));
for (const type of measurementTypes) {
const rows = byType.get(type);
if (!rows || rows.length === 0) continue;
seriesByMetric.set(
type,
buildMeasurementDailySeries(type, rows, tz, priorityJson),
);
}
const moodPoints = toDailyMeans(
moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })),
+9 -9
View File
@@ -51,11 +51,7 @@ import {
type StatusCardResult,
} from "@/lib/insights/status-card-generation";
import { annotate } from "@/lib/logging/context";
import {
toBerlinDayKey,
userDayKey,
DEFAULT_TIMEZONE,
} from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
/**
* Public entry — unchanged signature. Prepares the card (cache-read,
@@ -112,7 +108,12 @@ export async function preparePulseStatusForUser(
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("pulse", locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. This has to
// happen ahead of the cache read below (the earliest possible return path),
// not just the later day-bucketing reads.
const userTz = await resolveUserTimezone(userId);
const todayKey = userDayKey(new Date(), userTz);
const cached = await readFreshStatusText({
userId,
@@ -172,7 +173,6 @@ export async function preparePulseStatusForUser(
select: {
dateOfBirth: true,
gender: true,
timezone: true,
},
});
@@ -180,8 +180,8 @@ export async function preparePulseStatusForUser(
// own day boundary so this surface aligns with the targets route, which
// passes `userDayKey(d, userTz)`. Without it the proxy bucketed on
// Berlin-day here and on the user's TZ there, drifting the resting
// estimate for non-Berlin users.
const userTz = user?.timezone ?? DEFAULT_TIMEZONE;
// estimate for non-Berlin users. `userTz` was already resolved above for
// the cache day-key.
// v1.4.28 FB-D2 — cap the snapshot input. The downstream
// `applyPayloadBudget` trims further but the unbounded findMany was
+9 -5
View File
@@ -56,7 +56,7 @@ import {
type StatusCardResult,
} from "@/lib/insights/status-card-generation";
import { annotate } from "@/lib/logging/context";
import { resolveUserTimezone, toBerlinDayKey } from "@/lib/tz/resolver";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
@@ -158,7 +158,12 @@ export async function prepareWeightStatusForUser(
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("weight", locale);
const todayKey = toBerlinDayKey(new Date());
// v1.30.3 (QA F5) — resolve the user's own tz BEFORE the day-key so the
// cache rolls over at the user's local midnight, not Berlin's. Has to
// happen ahead of the cache read below (the earliest possible return
// path), not just the later day-bucketing reads.
const userTz = await resolveUserTimezone(userId);
const todayKey = userDayKey(new Date(), userTz);
// Serve today's real assessment when present. The shared reader
// rejects timeout stubs, so a single stall no longer pins the
@@ -283,9 +288,8 @@ export async function prepareWeightStatusForUser(
const now = new Date();
// v1.2.5 (M-TZ3) — resolve the user's timezone so every day-bucketing
// pass below keys on the user's own calendar day.
const userTz = await resolveUserTimezone(userId);
// v1.2.5 (M-TZ3) — every day-bucketing pass below keys on the user's own
// calendar day. `userTz` was already resolved above for the cache day-key.
const weightSeries = applyPayloadBudget(
measurements
@@ -0,0 +1,48 @@
/**
* v1.30.3 (QA F4) — cumulative-PersonalRecord rederivation queue
* registration guard.
*
* Same source-text-grep approach as the step-consolidation-repair guard:
* assert the queue is registered in `allQueues`, a `boss.work` handler is
* wired against it, and the boot-discovery enqueue helper fires — without
* booting pg-boss + Prisma. An unregistered queue silently never drains
* (the v1.4.37 dead-queue class).
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const REGISTRAR_PATH = join(__dirname, "..", "reminder", "register-rollup.ts");
const source = readFileSync(REGISTRAR_PATH, "utf8");
describe("reminder-worker — cumulative-pr-rederive wiring", () => {
it("imports the queue symbols from the rederivation module", () => {
expect(source).toMatch(
/from\s*["']@\/lib\/personal-records\/cumulative-pr-rederivation["']/,
);
expect(source).toMatch(/\bCUMULATIVE_PR_REDERIVE_QUEUE\b/);
expect(source).toMatch(/\brunCumulativePrRederivationForUser\b/);
expect(source).toMatch(/\benqueueBootTimeCumulativePrRederivation\b/);
});
it("registers the queue in the allQueues loop", () => {
const allQueuesMatch = source.match(
/const allQueues\s*=\s*\[([\s\S]*?)\];/,
);
expect(allQueuesMatch).not.toBeNull();
expect(allQueuesMatch![1]).toMatch(/\bCUMULATIVE_PR_REDERIVE_QUEUE\b/);
});
it("registers a boss.work handler against the queue", () => {
expect(source).toMatch(
/boss\.work[\s\S]{0,200}CUMULATIVE_PR_REDERIVE_QUEUE[\s\S]{0,400}runCumulativePrRederivationForUser/,
);
});
it("fires the boot-discovery enqueue helper", () => {
expect(source).toMatch(
/await enqueueBootTimeCumulativePrRederivation\([^)]*\)/,
);
});
});
+63
View File
@@ -67,6 +67,13 @@ import {
enqueueBootTimeStepConsolidationRepair,
type StepConsolidationRepairPayload,
} from "@/lib/jobs/step-consolidation-repair";
import {
CUMULATIVE_PR_REDERIVE_QUEUE,
CUMULATIVE_PR_REDERIVE_CONCURRENCY,
runCumulativePrRederivationForUser,
enqueueBootTimeCumulativePrRederivation,
type CumulativePrRederivePayload,
} from "@/lib/personal-records/cumulative-pr-rederivation";
import {
MEAN_CONSOLIDATION_QUEUE,
MEAN_CONSOLIDATION_CONCURRENCY,
@@ -132,6 +139,10 @@ const allQueues = [
// v1.28.37 — one-shot repair for the provider step rows the pre-fix
// consolidation swept. Boot-discovery driven, self-converging.
STEP_CONSOLIDATION_REPAIR_QUEUE,
// v1.30.3 — one-shot repair for the cumulative-type PersonalRecord rows
// the pre-fix multi-source SUM inflated. Boot-discovery driven,
// self-converging (QA F4).
CUMULATIVE_PR_REDERIVE_QUEUE,
// v1.7.0 — daily-mean consolidation for high-frequency spot HealthKit
// metrics (walking speed/step length, respiratory rate, audio exposure).
MEAN_CONSOLIDATION_QUEUE,
@@ -248,6 +259,27 @@ export async function registerRollupQueues(
},
);
// v1.30.3 — cumulative-PersonalRecord rederivation worker (QA F4). The
// boot enqueue helper below sends one job per user still holding a
// suspect pre-fix inflated cumulative-type record; this handler deletes
// it and re-runs detection silently so the honest re-derived best takes
// its place. Serial concurrency so the repair never crowds the
// dashboard request pool.
await boss.work<CumulativePrRederivePayload>(
CUMULATIVE_PR_REDERIVE_QUEUE,
{ localConcurrency: CUMULATIVE_PR_REDERIVE_CONCURRENCY },
async (jobs) => {
for (const job of jobs) {
const { userId } = job.data;
const summary = await runCumulativePrRederivationForUser(userId);
workerLog(
"info",
`[cumulative-pr-rederive] user=${userId} rowsDeleted=${summary.rowsDeleted} rowsReinserted=${summary.rowsReinserted}`,
);
}
},
);
// v1.7.0 — daily-mean consolidation worker. The boot enqueue helper
// below sends one job per user holding live per-sample high-frequency
// mean-type rows; this handler collapses each completed day to its
@@ -771,4 +803,35 @@ export async function enqueueRollupBootDiscovery(): Promise<void> {
err,
);
}
// v1.30.3 (QA F4) — cumulative-PersonalRecord rederivation. Finds every
// account still holding a suspect measurement-driven cumulative-type
// record created before the source-collapse fix landed and enqueues one
// repair job per account. Self-converging: a deleted-and-redetected
// record's `createdAt` sits after the fix cutoff, so the account drops
// out of the discovery predicate for good.
try {
// Stage 6.
const { enqueued, skipped, error } =
await enqueueBootTimeCumulativePrRederivation(
BOOT_BACKFILL_STAGGER_SECONDS * 6,
);
if (error) {
workerLog(
"error",
`[cumulative-pr-rederive] boot discovery failed: ${error}`,
);
} else {
workerLog(
"info",
`[cumulative-pr-rederive] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
);
}
} catch (err) {
workerLog(
"error",
"[cumulative-pr-rederive] boot discovery threw an unexpected error",
err,
);
}
}
@@ -0,0 +1,166 @@
/**
* v1.30.3 (QA F4) — the one-shot repair for the pre-fix inflated
* cumulative-type PersonalRecord rows: deletes the suspect row(s) for a
* user and re-runs detection silently so the honest re-derived best takes
* its place, plus the boot-discovery enqueue that finds affected accounts.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
const deleteMany = vi.fn();
const findMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
personalRecord: {
deleteMany: (a: unknown) => deleteMany(a),
findMany: (a: unknown) => findMany(a),
},
},
}));
const detectPersonalRecordsForUser = vi.fn();
vi.mock("@/lib/personal-records/pr-detection-worker", () => ({
detectPersonalRecordsForUser: (...args: unknown[]) =>
detectPersonalRecordsForUser(...args),
}));
const send = vi.fn();
const getGlobalBoss = vi.fn();
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: () => getGlobalBoss(),
}));
const annotate = vi.fn();
vi.mock("@/lib/logging/context", () => ({
annotate: (...a: unknown[]) => annotate(...a),
}));
import {
runCumulativePrRederivationForUser,
enqueueBootTimeCumulativePrRederivation,
CUMULATIVE_PR_FIX_CUTOFF,
} from "@/lib/personal-records/cumulative-pr-rederivation";
describe("runCumulativePrRederivationForUser", () => {
beforeEach(() => {
deleteMany.mockReset();
detectPersonalRecordsForUser.mockReset();
annotate.mockReset();
});
it("deletes suspect rows created before the fix cutoff and re-derives silently", async () => {
deleteMany.mockResolvedValue({ count: 1 });
detectPersonalRecordsForUser.mockResolvedValue({
inserted: 1,
ties: 0,
scanned: 6,
silent: true,
});
const summary = await runCumulativePrRederivationForUser("u1");
expect(deleteMany).toHaveBeenCalledWith({
where: {
userId: "u1",
metricSlot: null,
metricType: { in: expect.arrayContaining(["ACTIVITY_STEPS"]) },
createdAt: { lt: CUMULATIVE_PR_FIX_CUTOFF },
},
});
// Re-derivation always runs silent — this is data hygiene, not a new
// achievement, so no push should fire for it.
expect(detectPersonalRecordsForUser).toHaveBeenCalledWith("u1", {
silent: true,
});
expect(summary).toEqual({ rowsDeleted: 1, rowsReinserted: 1 });
});
it("skips re-derivation entirely when nothing was deleted", async () => {
deleteMany.mockResolvedValue({ count: 0 });
const summary = await runCumulativePrRederivationForUser("u2");
expect(detectPersonalRecordsForUser).not.toHaveBeenCalled();
expect(summary).toEqual({ rowsDeleted: 0, rowsReinserted: 0 });
});
it("counts a tie as a reinserted row (the honest re-derived value can equal a still-live sibling metric's best)", async () => {
deleteMany.mockResolvedValue({ count: 2 });
detectPersonalRecordsForUser.mockResolvedValue({
inserted: 0,
ties: 1,
scanned: 6,
silent: true,
});
const summary = await runCumulativePrRederivationForUser("u3");
expect(summary).toEqual({ rowsDeleted: 2, rowsReinserted: 1 });
});
});
describe("enqueueBootTimeCumulativePrRederivation", () => {
beforeEach(() => {
findMany.mockReset();
send.mockReset();
getGlobalBoss.mockReset();
});
it("no-ops when no boss is attached (test / scriptless context)", async () => {
getGlobalBoss.mockReturnValue(null);
const result = await enqueueBootTimeCumulativePrRederivation();
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
expect(findMany).not.toHaveBeenCalled();
});
it("finds zero affected users and enqueues nothing", async () => {
getGlobalBoss.mockReturnValue({ send });
findMany.mockResolvedValue([]);
const result = await enqueueBootTimeCumulativePrRederivation();
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
expect(send).not.toHaveBeenCalled();
});
it("enqueues one job per distinct affected user with a coalescing singletonKey", async () => {
getGlobalBoss.mockReturnValue({ send });
findMany.mockResolvedValue([{ userId: "u1" }, { userId: "u2" }]);
send.mockResolvedValue("job-id");
const result = await enqueueBootTimeCumulativePrRederivation(30);
expect(result).toEqual({ enqueued: 2, skipped: 0, error: null });
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
metricSlot: null,
createdAt: { lt: CUMULATIVE_PR_FIX_CUTOFF },
}),
distinct: ["userId"],
}),
);
expect(send).toHaveBeenCalledWith(
"cumulative-pr-rederive",
expect.objectContaining({ userId: "u1" }),
expect.objectContaining({
singletonKey: "cumulative-pr-rederive|u1",
startAfter: 30,
}),
);
});
it("counts a coalesced send (pg-boss returns null) as skipped, not an error", async () => {
getGlobalBoss.mockReturnValue({ send });
findMany.mockResolvedValue([{ userId: "u1" }]);
send.mockResolvedValue(null);
const result = await enqueueBootTimeCumulativePrRederivation();
expect(result).toEqual({ enqueued: 0, skipped: 1, error: null });
});
it("returns the error message instead of throwing on a discovery failure", async () => {
getGlobalBoss.mockReturnValue({ send });
findMany.mockRejectedValue(new Error("db down"));
const result = await enqueueBootTimeCumulativePrRederivation();
expect(result.enqueued).toBe(0);
expect(result.error).toBe("db down");
});
});
@@ -0,0 +1,184 @@
/**
* v1.30.3 (QA F4) — one-shot repair for the `PersonalRecord` rows the
* pre-fix cumulative-day picker double-counted.
*
* Background: before `8902d3985` (v1.29.6), `findBestCumulativeDay`
* (`pr-detection-worker.ts`) summed EVERY row on a calendar day regardless
* of source — a day with both an Apple Health AND a Withings write for the
* same cumulative metric (steps, active energy, flights, distance,
* daylight, falls) summed both sources into one inflated total and wrote
* it as the all-time "best". The fix (source-collapse via
* `pickCanonicalSourceRows` before summing) stops the inflation going
* forward, but the worker's improvement gate only ever compares a
* freshly-computed day against the STORED best — an already-inflated
* stored row can never be beaten again, so the false record silently
* outranks every honest day forever (PR list, digest milestone, doctor
* report), and the affected user can never set a genuine record on that
* metric again.
*
* This job deletes the suspect rows (measurement-driven — `metricSlot`
* null — one of `CUMULATIVE_HK_TYPES`, created BEFORE the fix landed) and
* immediately re-runs detection for the affected user with `silent: true`
* so the (already-fixed) worker inserts the honest re-derived best without
* firing a "new record" push for what is data hygiene, not an achievement.
*
* Converges to zero without a marker column: the cutoff is the fixed
* instant the source-collapse fix went live upstream (safely after the
* `8902d3985` merge). Any row created before it predates the bug fix and
* is suspect; any row this job (or the ordinary worker) writes going
* forward is always created after that instant, so it can never re-match
* the discovery predicate. A re-run (reboot mid-walk, or an install that
* upgrades straight past this release) simply finds fewer/no affected
* users each time.
*
* The queue name MUST be registered in `allQueues` in
* `src/lib/jobs/reminder/register-rollup.ts` or pg-boss never provisions
* it and the boot enqueue silently never drains (the v1.4.37 dead-queue
* class).
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { detectPersonalRecordsForUser } from "@/lib/personal-records/pr-detection-worker";
import type { MeasurementType } from "@/generated/prisma/client";
export const CUMULATIVE_PR_REDERIVE_QUEUE = "cumulative-pr-rederive";
/**
* Serial concurrency — a repair walks one account's PersonalRecord rows
* and re-runs full detection; matches the other one-shot repair jobs
* (`STEP_CONSOLIDATION_REPAIR_CONCURRENCY`).
*/
export const CUMULATIVE_PR_REDERIVE_CONCURRENCY = 1;
/**
* The instant the cumulative-day source-collapse fix (`8902d3985`,
* v1.29.6) went live. Any measurement-driven `PersonalRecord` row for a
* cumulative type `createdAt` STRICTLY BEFORE this instant was written by
* the pre-fix multi-source SUM and is suspect. Hardcoded (not derived from
* git/commit metadata at runtime) so the predicate is a compile-time
* constant every self-hosted install evaluates identically regardless of
* when it upgrades to this release.
*/
export const CUMULATIVE_PR_FIX_CUTOFF = new Date("2026-07-18T00:00:00.000Z");
const CUMULATIVE_TYPES_ARRAY: MeasurementType[] = [
...CUMULATIVE_HK_TYPES,
] as MeasurementType[];
export interface CumulativePrRederivePayload {
userId: string;
enqueuedAt: string;
}
export interface CumulativePrRederiveSummary {
/** Suspect rows deleted for this user. */
rowsDeleted: number;
/** Records the (already-fixed) worker re-inserted after the delete. */
rowsReinserted: number;
}
/**
* Per-user repair handler. Deletes the suspect measurement-driven
* cumulative-type `PersonalRecord` rows for `userId`, then re-runs
* detection silently so the honest re-derived best (if any — the warm-up
* gate can still apply) replaces it. Safe to re-run: a second pass finds
* zero suspect rows once the honest one has replaced them (its
* `createdAt` is always after the cutoff).
*/
export async function runCumulativePrRederivationForUser(
userId: string,
): Promise<CumulativePrRederiveSummary> {
const { count: rowsDeleted } = await prisma.personalRecord.deleteMany({
where: {
userId,
metricSlot: null,
metricType: { in: CUMULATIVE_TYPES_ARRAY },
createdAt: { lt: CUMULATIVE_PR_FIX_CUTOFF },
},
});
let rowsReinserted = 0;
if (rowsDeleted > 0) {
const result = await detectPersonalRecordsForUser(userId, {
silent: true,
});
rowsReinserted = result.inserted + result.ties;
}
annotate({
action: {
name: "personal-record.cumulative.rederive",
details: { rows_deleted: rowsDeleted, rows_reinserted: rowsReinserted },
},
});
return { rowsDeleted, rowsReinserted };
}
/**
* Boot-time discovery. Finds every account still holding a suspect
* measurement-driven cumulative-type `PersonalRecord` row (created before
* the fix cutoff) and enqueues one repair job per account.
*
* Self-converging: once a suspect row is deleted and (when the warm-up
* gate clears) replaced by an honestly re-derived one, its `createdAt`
* sits after the cutoff and the account drops off the discovery list for
* good. Best-effort: errors return through the result so worker boot never
* fails on a repair miss.
*/
export async function enqueueBootTimeCumulativePrRederivation(
startAfterSeconds: number = 0,
): Promise<{ enqueued: number; skipped: number; error: string | null }> {
const boss = getGlobalBoss();
if (!boss) {
return { enqueued: 0, skipped: 0, error: null };
}
try {
const affected = await prisma.personalRecord.findMany({
where: {
metricSlot: null,
metricType: { in: CUMULATIVE_TYPES_ARRAY },
createdAt: { lt: CUMULATIVE_PR_FIX_CUTOFF },
},
distinct: ["userId"],
select: { userId: true },
});
if (affected.length === 0) {
return { enqueued: 0, skipped: 0, error: null };
}
let enqueued = 0;
let skipped = 0;
for (const { userId } of affected) {
const payload: CumulativePrRederivePayload = {
userId,
enqueuedAt: new Date().toISOString(),
};
const jobId = await boss.send(CUMULATIVE_PR_REDERIVE_QUEUE, payload, {
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
// Coalesce: if a repair for this user is already queued and we
// restart, pg-boss returns null instead of duplicating.
singletonKey: `cumulative-pr-rederive|${userId}`,
...(startAfterSeconds > 0 ? { startAfter: startAfterSeconds } : {}),
});
if (jobId) {
enqueued += 1;
} else {
skipped += 1;
}
}
return { enqueued, skipped, error: null };
} catch (err) {
return {
enqueued: 0,
skipped: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}