Merge pull request #515 from MBombeck/release/v1.29.0

v1.29.0 — A daily health companion
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-17 09:56:24 +02:00
commit f8c0ea5120
50 files changed
+3512 -71

No files matched your search

+31 -1
View File
@@ -2,7 +2,37 @@
## [Unreleased]
## [1.28.56] — 2026-07-17 — Documents, closer to hand
## [1.29.0] — 2026-07-17 — A daily health companion
This release draws a line under a run of work that turns HealthLog from a place
you record your health into one that meets you each day — and it completes that
picture with the pieces that make it feel whole.
Over the last releases the dashboard gained a **Today** view that leads with the
day's read, a health score that refreshes the moment last night's sleep syncs, a
coach check-in when a plan is due for a look-back, and an optional morning note.
Documents came closer to hand — the Coach opens beside them, you can bring
several into a conversation, and lab values move between a document and your
labs. This release adds the layer that ties it together:
- **Your heart's rhythm, in context.** ECG recordings are no longer a page on
their own — they're referenced where your resting heart rate and rhythm are
discussed, always as the reading your device produced, never a reading of the
app's own. A new recording surfaces gently in Today.
- **The shape of your day.** Heart rate is now drawn across the day, not just as
a daily number — so you can see it settle and climb. When it stays elevated
while you're at rest and not moving, Today notes it plainly as possible
tension, carefully and never as a verdict.
- **Quiet milestones.** Reaching a steady stretch in your target range, or a new
personal best, is marked with a calm moment — states you've reached, not
streaks to keep, so there's nothing to break and nothing to nag.
- **One piece throughout.** The Today view brings back the health rings you
choose, each surface carries its metric's own calm colour, and the whole thing
reads as one design rather than a stack of features.
Nothing new generates when you open the app — every daily surface reads the
insight prepared overnight. No new data is collected; this makes more of what's
already yours.
Four changes bring documents and your record closer together:
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.28.56
version: 1.29.0
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+80 -2
View File
@@ -3856,6 +3856,25 @@
},
"waveform": {
"ariaLabel": "EKG-Streifen aufgezeichnet {date}, Dauer {duration}, durchschnittliche Herzfrequenz {heartRate}, aufgezeichnetes Ergebnis: {result}."
},
"crossLink": {
"title": "EKG-Aufzeichnungen",
"recordingsOne": "1 Aufzeichnung vorhanden.",
"recordingsMany": "{count} Aufzeichnungen vorhanden.",
"latestResult": "Letztes Geräte-Ergebnis: {result}.",
"cta": "Aufzeichnungen ansehen"
}
},
"intradayPulse": {
"title": "Der Verlauf deines Tages",
"caption": "Dein Puls über den Tag, gemittelt in 10-Minuten-Schritten.",
"empty": "Heute noch nicht genug Pulswerte, um den Tagesverlauf zu zeigen.",
"baseline": "Ruhe",
"tension": {
"morning": "Eine anhaltend erhöhte Ruhephase heute Vormittag — möglicherweise etwas Anspannung.",
"afternoon": "Eine anhaltend erhöhte Ruhephase heute Nachmittag — möglicherweise etwas Anspannung.",
"evening": "Eine anhaltend erhöhte Ruhephase heute Abend — möglicherweise etwas Anspannung.",
"night": "Eine anhaltend erhöhte Ruhephase in der Nacht — möglicherweise etwas Anspannung."
}
}
},
@@ -8549,6 +8568,25 @@
"title": "Zeit, einen Plan anzuschauen",
"body": "Es ist etwa eine Woche her, seit du diesen Plan gesetzt hast — behalte ihn, passe ihn an oder lass ihn los. Ganz ohne Druck.",
"bodyPlan": "Vor etwa einer Woche hast du dir vorgenommen: „{plan}“. Behalte es, passe es an oder lass es los — ganz ohne Druck."
},
"tensionWindow": {
"title": "Vielleicht eine angespannte Phase",
"body": {
"morning": "Dein Puls lag heute Vormittag über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung.",
"afternoon": "Dein Puls lag heute Nachmittag über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung.",
"evening": "Dein Puls lag heute Abend über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung.",
"night": "Dein Puls lag in der Nacht über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung."
}
},
"ecgNewRecording": {
"title": "Neue EKG-Aufzeichnung",
"body": "Dein Gerät hat ein neues EKG aufgezeichnet — bereit zum Ansehen.",
"bodyVerdict": "Dein Gerät hat ein neues EKG aufgezeichnet und meldet: {verdict}.",
"verdict": {
"irregular": "einen möglichen unregelmäßigen Rhythmus",
"notDetected": "keinen unregelmäßigen Rhythmus",
"inconclusive": "ein nicht eindeutiges Ergebnis"
}
}
},
"action": {
@@ -8557,7 +8595,10 @@
"viewCheckups": "Vorsorge ansehen",
"checkinKeep": "Behalten",
"checkinAdjust": "Anpassen",
"checkinLetGo": "Loslassen"
"checkinLetGo": "Loslassen",
"viewMilestone": "Ansehen",
"viewPulse": "Puls ansehen",
"viewEcg": "Aufzeichnung ansehen"
},
"today": {
"scoreLabel": "Gesundheitsscore",
@@ -8566,7 +8607,44 @@
"sleepPending": "Der Schlaf von letzter Nacht fehlt noch – der heutige Wert ist vorläufig.",
"readFullBriefing": "Vollständiges Briefing lesen",
"worthALook": "Einen Blick wert",
"allClear": "Heute braucht nichts deine Aufmerksamkeit."
"allClear": "Heute braucht nichts deine Aufmerksamkeit.",
"ringLink": "{metric}-Details öffnen",
"ringDosesAria": "{taken} von {scheduled} Dosen heute genommen"
},
"milestone": {
"metric": {
"restingHeartRate": "Ruhepuls",
"heartRateVariability": "Herzfrequenzvariabilität",
"respiratoryRate": "Atemfrequenz",
"weight": "Gewicht",
"generic": "dieser Wert"
},
"record": {
"title": "Neue Bestmarke: {metric}",
"body": "Heute ein persönlicher Bestwert für {metric} — ganz ruhig festgehalten, du musst nichts tun."
},
"returnToBaseline": {
"title": "Wieder im Bereich: {metric}",
"body": "{metric} liegt wieder in deinem gewohnten Bereich. Was auch immer es war — es ist vorbei."
},
"sustainedInRange": {
"week1": {
"title": "Eine Woche im Bereich",
"body": "{metric} liegt seit einer Woche in deinem gewohnten Bereich. Schön gleichmäßig."
},
"week2": {
"title": "Zwei Wochen im Bereich",
"body": "{metric} bleibt seit zwei Wochen in deinem gewohnten Bereich. Ruhig und stetig."
},
"week3": {
"title": "Drei Wochen im Bereich",
"body": "Drei Wochen, in denen {metric} in deinem gewohnten Bereich liegt. Eine echte ruhige Strecke."
},
"week4": {
"title": "Ein Monat im Bereich",
"body": "Ein ganzer Monat mit {metric} in deinem gewohnten Bereich. Wunderbar gleichmäßig."
}
}
}
}
}
+80 -2
View File
@@ -3856,6 +3856,25 @@
},
"waveform": {
"ariaLabel": "ECG strip recorded {date}, duration {duration}, average heart rate {heartRate}, recorded result: {result}."
},
"crossLink": {
"title": "ECG recordings",
"recordingsOne": "1 recording on file.",
"recordingsMany": "{count} recordings on file.",
"latestResult": "Latest device result: {result}.",
"cta": "View recordings"
}
},
"intradayPulse": {
"title": "Your day's shape",
"caption": "Your heart rate through the day, averaged in 10-minute steps.",
"empty": "Not enough heart-rate readings today to show the day's shape yet.",
"baseline": "Resting",
"tension": {
"morning": "A sustained elevated-at-rest stretch this morning — possibly some tension.",
"afternoon": "A sustained elevated-at-rest stretch this afternoon — possibly some tension.",
"evening": "A sustained elevated-at-rest stretch this evening — possibly some tension.",
"night": "A sustained elevated-at-rest stretch overnight — possibly some tension."
}
}
},
@@ -8549,6 +8568,25 @@
"title": "Time to check in on a plan",
"body": "It's been about a week since you set this plan — keep it, adjust it, or let it go. No pressure either way.",
"bodyPlan": "About a week ago you set: “{plan}”. Keep it, adjust it, or let it go — no pressure either way."
},
"tensionWindow": {
"title": "Possible tension today",
"body": {
"morning": "Your heart rate sat above its resting baseline for a sustained stretch this morning while you were still — possibly some tension.",
"afternoon": "Your heart rate sat above its resting baseline for a sustained stretch this afternoon while you were still — possibly some tension.",
"evening": "Your heart rate sat above its resting baseline for a sustained stretch this evening while you were still — possibly some tension.",
"night": "Your heart rate sat above its resting baseline for a sustained stretch overnight while you were still — possibly some tension."
}
},
"ecgNewRecording": {
"title": "New ECG recording",
"body": "Your device recorded a new ECG — it's ready to view.",
"bodyVerdict": "Your device recorded a new ECG and reported: {verdict}.",
"verdict": {
"irregular": "a possible irregular rhythm",
"notDetected": "no irregular rhythm",
"inconclusive": "an inconclusive result"
}
}
},
"action": {
@@ -8557,7 +8595,10 @@
"viewCheckups": "View check-ups",
"checkinKeep": "Keep it",
"checkinAdjust": "Adjust",
"checkinLetGo": "Let it go"
"checkinLetGo": "Let it go",
"viewMilestone": "View",
"viewPulse": "View pulse",
"viewEcg": "View recording"
},
"today": {
"scoreLabel": "Health score",
@@ -8566,7 +8607,44 @@
"sleepPending": "Last night's sleep isn't in yet — today's read is provisional.",
"readFullBriefing": "Read the full briefing",
"worthALook": "Worth a look",
"allClear": "Nothing needs your attention today."
"allClear": "Nothing needs your attention today.",
"ringLink": "Open {metric} details",
"ringDosesAria": "{taken} of {scheduled} doses taken today"
},
"milestone": {
"metric": {
"restingHeartRate": "resting heart rate",
"heartRateVariability": "heart-rate variability",
"respiratoryRate": "respiratory rate",
"weight": "weight",
"generic": "this metric"
},
"record": {
"title": "A new {metric} best",
"body": "A personal best for your {metric} today — quietly noted, nothing to do."
},
"returnToBaseline": {
"title": "Back in range: {metric}",
"body": "Your {metric} has settled back inside your usual range. Whatever that was, it has passed."
},
"sustainedInRange": {
"week1": {
"title": "A week in range",
"body": "Your {metric} has held inside your usual range for a week now. Nice and steady."
},
"week2": {
"title": "Two weeks in range",
"body": "Your {metric} has stayed in your usual range for two weeks. Calm and steady."
},
"week3": {
"title": "Three weeks in range",
"body": "Three weeks of your {metric} sitting in your usual range. A real stretch of steadiness."
},
"week4": {
"title": "A month in range",
"body": "A full month with your {metric} in your usual range. Beautifully steady."
}
}
}
}
}
+80 -2
View File
@@ -3856,6 +3856,25 @@
},
"waveform": {
"ariaLabel": "Tira de ECG registrada el {date}, duración {duration}, frecuencia cardíaca media {heartRate}, resultado registrado: {result}."
},
"crossLink": {
"title": "Registros de ECG",
"recordingsOne": "1 registro guardado.",
"recordingsMany": "{count} registros guardados.",
"latestResult": "Último resultado del dispositivo: {result}.",
"cta": "Ver registros"
}
},
"intradayPulse": {
"title": "La forma de tu día",
"caption": "Tu frecuencia cardíaca a lo largo del día, promediada en tramos de 10 minutos.",
"empty": "Aún no hay suficientes lecturas de hoy para mostrar la forma del día.",
"baseline": "Reposo",
"tension": {
"morning": "Un tramo elevado en reposo esta mañana; posiblemente algo de tensión.",
"afternoon": "Un tramo elevado en reposo esta tarde; posiblemente algo de tensión.",
"evening": "Un tramo elevado en reposo al anochecer; posiblemente algo de tensión.",
"night": "Un tramo elevado en reposo por la noche; posiblemente algo de tensión."
}
}
},
@@ -8549,6 +8568,25 @@
"title": "Es buen momento para revisar un plan",
"body": "Ha pasado alrededor de una semana desde que definiste este plan: consérvalo, ajústalo o déjalo ir. Sin presión.",
"bodyPlan": "Hace aproximadamente una semana te propusiste: «{plan}». Consérvalo, ajústalo o déjalo ir, sin presión."
},
"tensionWindow": {
"title": "Quizá algo de tensión hoy",
"body": {
"morning": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato esta mañana mientras estabas en calma; posiblemente algo de tensión.",
"afternoon": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato esta tarde mientras estabas en calma; posiblemente algo de tensión.",
"evening": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato al anochecer mientras estabas en calma; posiblemente algo de tensión.",
"night": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato por la noche mientras estabas en calma; posiblemente algo de tensión."
}
},
"ecgNewRecording": {
"title": "Nuevo registro de ECG",
"body": "Tu dispositivo registró un nuevo ECG: está listo para verlo.",
"bodyVerdict": "Tu dispositivo registró un nuevo ECG e informó: {verdict}.",
"verdict": {
"irregular": "un posible ritmo irregular",
"notDetected": "ningún ritmo irregular",
"inconclusive": "un resultado no concluyente"
}
}
},
"action": {
@@ -8557,7 +8595,10 @@
"viewCheckups": "Ver revisiones",
"checkinKeep": "Conservar",
"checkinAdjust": "Ajustar",
"checkinLetGo": "Dejarlo ir"
"checkinLetGo": "Dejarlo ir",
"viewMilestone": "Ver",
"viewPulse": "Ver pulso",
"viewEcg": "Ver registro"
},
"today": {
"scoreLabel": "Puntuación de salud",
@@ -8566,7 +8607,44 @@
"sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.",
"readFullBriefing": "Leer el informe completo",
"worthALook": "Merece un vistazo",
"allClear": "Hoy nada necesita tu atención."
"allClear": "Hoy nada necesita tu atención.",
"ringLink": "Abrir detalles de {metric}",
"ringDosesAria": "{taken} de {scheduled} dosis tomadas hoy"
},
"milestone": {
"metric": {
"restingHeartRate": "frecuencia cardíaca en reposo",
"heartRateVariability": "variabilidad de la frecuencia cardíaca",
"respiratoryRate": "frecuencia respiratoria",
"weight": "peso",
"generic": "este valor"
},
"record": {
"title": "Nuevo récord: {metric}",
"body": "Hoy has alcanzado tu mejor marca de {metric}. Anotado con calma, sin nada que hacer."
},
"returnToBaseline": {
"title": "De nuevo en tu rango: {metric}",
"body": "Tu {metric} ha vuelto a tu rango habitual. Fuera lo que fuera, ya pasó."
},
"sustainedInRange": {
"week1": {
"title": "Una semana en rango",
"body": "Tu {metric} lleva una semana dentro de tu rango habitual. Con buena estabilidad."
},
"week2": {
"title": "Dos semanas en rango",
"body": "Tu {metric} lleva dos semanas dentro de tu rango habitual. Tranquilo y constante."
},
"week3": {
"title": "Tres semanas en rango",
"body": "Tres semanas con tu {metric} dentro de tu rango habitual. Un buen tramo de calma."
},
"week4": {
"title": "Un mes en rango",
"body": "Un mes entero con tu {metric} dentro de tu rango habitual. Maravillosamente estable."
}
}
}
}
}
+80 -2
View File
@@ -3856,6 +3856,25 @@
},
"waveform": {
"ariaLabel": "Tracé ECG enregistré le {date}, durée {duration}, fréquence cardiaque moyenne {heartRate}, résultat enregistré : {result}."
},
"crossLink": {
"title": "Enregistrements ECG",
"recordingsOne": "1 enregistrement disponible.",
"recordingsMany": "{count} enregistrements disponibles.",
"latestResult": "Dernier résultat de l’appareil : {result}.",
"cta": "Voir les enregistrements"
}
},
"intradayPulse": {
"title": "Le rythme de votre journée",
"caption": "Votre fréquence cardiaque au fil de la journée, moyennée par tranches de 10 minutes.",
"empty": "Pas encore assez de mesures aujourd'hui pour afficher le rythme de la journée.",
"baseline": "Repos",
"tension": {
"morning": "Une période élevée au repos ce matin — peut-être un peu de tension.",
"afternoon": "Une période élevée au repos cet après-midi — peut-être un peu de tension.",
"evening": "Une période élevée au repos ce soir — peut-être un peu de tension.",
"night": "Une période élevée au repos cette nuit — peut-être un peu de tension."
}
}
},
@@ -8549,6 +8568,25 @@
"title": "Il est temps de revoir un plan",
"body": "Cela fait environ une semaine que vous avez défini ce plan : gardez-le, ajustez-le ou laissez-le. Sans pression.",
"bodyPlan": "Il y a environ une semaine, vous vous étiez fixé : « {plan} ». Gardez-le, ajustez-le ou laissez-le, sans pression."
},
"tensionWindow": {
"title": "Peut-être un peu de tension",
"body": {
"morning": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment ce matin alors que vous étiez au calme — peut-être un peu de tension.",
"afternoon": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment cet après-midi alors que vous étiez au calme — peut-être un peu de tension.",
"evening": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment ce soir alors que vous étiez au calme — peut-être un peu de tension.",
"night": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment cette nuit alors que vous étiez au calme — peut-être un peu de tension."
}
},
"ecgNewRecording": {
"title": "Nouvel enregistrement ECG",
"body": "Votre appareil a enregistré un nouvel ECG : prêt à être consulté.",
"bodyVerdict": "Votre appareil a enregistré un nouvel ECG et a signalé : {verdict}.",
"verdict": {
"irregular": "un possible rythme irrégulier",
"notDetected": "aucun rythme irrégulier",
"inconclusive": "un résultat non concluant"
}
}
},
"action": {
@@ -8557,7 +8595,10 @@
"viewCheckups": "Voir les examens",
"checkinKeep": "Garder",
"checkinAdjust": "Ajuster",
"checkinLetGo": "Laisser tomber"
"checkinLetGo": "Laisser tomber",
"viewMilestone": "Voir",
"viewPulse": "Voir le pouls",
"viewEcg": "Voir l’enregistrement"
},
"today": {
"scoreLabel": "Score de santé",
@@ -8566,7 +8607,44 @@
"sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.",
"readFullBriefing": "Lire le briefing complet",
"worthALook": "À regarder",
"allClear": "Rien ne requiert ton attention aujourd'hui."
"allClear": "Rien ne requiert ton attention aujourd'hui.",
"ringLink": "Ouvrir les détails de {metric}",
"ringDosesAria": "{taken} des {scheduled} doses prises aujourd'hui"
},
"milestone": {
"metric": {
"restingHeartRate": "fréquence cardiaque au repos",
"heartRateVariability": "variabilité de la fréquence cardiaque",
"respiratoryRate": "fréquence respiratoire",
"weight": "poids",
"generic": "cette valeur"
},
"record": {
"title": "Nouveau record : {metric}",
"body": "Vous avez atteint un record personnel de {metric} aujourd'hui. Noté en toute tranquillité, rien à faire."
},
"returnToBaseline": {
"title": "De retour dans votre plage : {metric}",
"body": "Votre {metric} est revenue dans votre plage habituelle. Quoi que ce fût, c'est passé."
},
"sustainedInRange": {
"week1": {
"title": "Une semaine dans la plage",
"body": "Votre {metric} reste dans votre plage habituelle depuis une semaine. Bien régulier."
},
"week2": {
"title": "Deux semaines dans la plage",
"body": "Votre {metric} reste dans votre plage habituelle depuis deux semaines. Calme et régulier."
},
"week3": {
"title": "Trois semaines dans la plage",
"body": "Trois semaines avec votre {metric} dans votre plage habituelle. Une vraie période de stabilité."
},
"week4": {
"title": "Un mois dans la plage",
"body": "Un mois entier avec votre {metric} dans votre plage habituelle. Merveilleusement stable."
}
}
}
}
}
+80 -2
View File
@@ -3856,6 +3856,25 @@
},
"waveform": {
"ariaLabel": "Tracciato ECG registrato il {date}, durata {duration}, frequenza cardiaca media {heartRate}, risultato registrato: {result}."
},
"crossLink": {
"title": "Registrazioni ECG",
"recordingsOne": "1 registrazione disponibile.",
"recordingsMany": "{count} registrazioni disponibili.",
"latestResult": "Ultimo risultato del dispositivo: {result}.",
"cta": "Vedi registrazioni"
}
},
"intradayPulse": {
"title": "L'andamento della tua giornata",
"caption": "La tua frequenza cardiaca durante il giorno, mediata in intervalli di 10 minuti.",
"empty": "Non ci sono ancora abbastanza misurazioni di oggi per mostrare l'andamento della giornata.",
"baseline": "Riposo",
"tension": {
"morning": "Un tratto elevato a riposo stamattina — forse un po' di tensione.",
"afternoon": "Un tratto elevato a riposo questo pomeriggio — forse un po' di tensione.",
"evening": "Un tratto elevato a riposo stasera — forse un po' di tensione.",
"night": "Un tratto elevato a riposo durante la notte — forse un po' di tensione."
}
}
},
@@ -8549,6 +8568,25 @@
"title": "È il momento di rivedere un piano",
"body": "È passata circa una settimana da quando hai definito questo piano: mantienilo, modificalo o lascialo andare. Senza pressioni.",
"bodyPlan": "Circa una settimana fa ti eri proposto: «{plan}». Mantienilo, modificalo o lascialo andare, senza pressioni."
},
"tensionWindow": {
"title": "Forse un po' di tensione oggi",
"body": {
"morning": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato stamattina mentre eri fermo — forse un po' di tensione.",
"afternoon": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato questo pomeriggio mentre eri fermo — forse un po' di tensione.",
"evening": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato stasera mentre eri fermo — forse un po' di tensione.",
"night": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato durante la notte mentre eri fermo — forse un po' di tensione."
}
},
"ecgNewRecording": {
"title": "Nuova registrazione ECG",
"body": "Il tuo dispositivo ha registrato un nuovo ECG: è pronto da vedere.",
"bodyVerdict": "Il tuo dispositivo ha registrato un nuovo ECG e ha segnalato: {verdict}.",
"verdict": {
"irregular": "un possibile ritmo irregolare",
"notDetected": "nessun ritmo irregolare",
"inconclusive": "un risultato non conclusivo"
}
}
},
"action": {
@@ -8557,7 +8595,10 @@
"viewCheckups": "Vedi controlli",
"checkinKeep": "Mantieni",
"checkinAdjust": "Modifica",
"checkinLetGo": "Lascia andare"
"checkinLetGo": "Lascia andare",
"viewMilestone": "Vedi",
"viewPulse": "Vedi battito",
"viewEcg": "Vedi registrazione"
},
"today": {
"scoreLabel": "Punteggio di salute",
@@ -8566,7 +8607,44 @@
"sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.",
"readFullBriefing": "Leggi il briefing completo",
"worthALook": "Da tenere d'occhio",
"allClear": "Oggi nulla richiede la tua attenzione."
"allClear": "Oggi nulla richiede la tua attenzione.",
"ringLink": "Apri i dettagli di {metric}",
"ringDosesAria": "{taken} di {scheduled} dosi assunte oggi"
},
"milestone": {
"metric": {
"restingHeartRate": "frequenza cardiaca a riposo",
"heartRateVariability": "variabilità della frequenza cardiaca",
"respiratoryRate": "frequenza respiratoria",
"weight": "peso",
"generic": "questo valore"
},
"record": {
"title": "Nuovo record: {metric}",
"body": "Oggi hai raggiunto il tuo record personale di {metric}. Annotato con calma, senza nulla da fare."
},
"returnToBaseline": {
"title": "Di nuovo nel tuo intervallo: {metric}",
"body": "La tua {metric} è tornata nel tuo intervallo abituale. Qualunque cosa fosse, è passata."
},
"sustainedInRange": {
"week1": {
"title": "Una settimana nell'intervallo",
"body": "La tua {metric} resta nel tuo intervallo abituale da una settimana. Bella costante."
},
"week2": {
"title": "Due settimane nell'intervallo",
"body": "La tua {metric} resta nel tuo intervallo abituale da due settimane. Calma e costante."
},
"week3": {
"title": "Tre settimane nell'intervallo",
"body": "Tre settimane con la tua {metric} nel tuo intervallo abituale. Un bel tratto di stabilità."
},
"week4": {
"title": "Un mese nell'intervallo",
"body": "Un mese intero con la tua {metric} nel tuo intervallo abituale. Meravigliosamente costante."
}
}
}
}
}
+80 -2
View File
@@ -3856,6 +3856,25 @@
},
"waveform": {
"ariaLabel": "Zapis EKG zarejestrowany {date}, czas trwania {duration}, średnie tętno {heartRate}, zarejestrowany wynik: {result}."
},
"crossLink": {
"title": "Zapisy EKG",
"recordingsOne": "1 zapis w kartotece.",
"recordingsMany": "Zapisy w kartotece: {count}.",
"latestResult": "Ostatni wynik urządzenia: {result}.",
"cta": "Zobacz zapisy"
}
},
"intradayPulse": {
"title": "Przebieg Twojego dnia",
"caption": "Twoje tętno w ciągu dnia, uśrednione w 10-minutowych odcinkach.",
"empty": "Za mało dzisiejszych pomiarów, aby pokazać przebieg dnia.",
"baseline": "Spoczynek",
"tension": {
"morning": "Utrzymujący się podwyższony spoczynek rano — możliwe napięcie.",
"afternoon": "Utrzymujący się podwyższony spoczynek po południu — możliwe napięcie.",
"evening": "Utrzymujący się podwyższony spoczynek wieczorem — możliwe napięcie.",
"night": "Utrzymujący się podwyższony spoczynek w nocy — możliwe napięcie."
}
}
},
@@ -8549,6 +8568,25 @@
"title": "Czas przyjrzeć się planowi",
"body": "Minął mniej więcej tydzień, odkąd ustaliłeś ten plan — zachowaj go, dostosuj lub odpuść. Bez presji.",
"bodyPlan": "Około tygodnia temu postanowiłeś: „{plan}”. Zachowaj to, dostosuj lub odpuść — bez presji."
},
"tensionWindow": {
"title": "Może chwila napięcia dzisiaj",
"body": {
"morning": "Twoje tętno przez dłuższą chwilę rano utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie.",
"afternoon": "Twoje tętno przez dłuższą chwilę po południu utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie.",
"evening": "Twoje tętno przez dłuższą chwilę wieczorem utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie.",
"night": "Twoje tętno przez dłuższą chwilę w nocy utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie."
}
},
"ecgNewRecording": {
"title": "Nowy zapis EKG",
"body": "Twoje urządzenie zarejestrowało nowe EKG — gotowe do podglądu.",
"bodyVerdict": "Twoje urządzenie zarejestrowało nowe EKG i zgłosiło: {verdict}.",
"verdict": {
"irregular": "możliwy nieregularny rytm",
"notDetected": "brak nieregularnego rytmu",
"inconclusive": "wynik niejednoznaczny"
}
}
},
"action": {
@@ -8557,7 +8595,10 @@
"viewCheckups": "Zobacz badania",
"checkinKeep": "Zachowaj",
"checkinAdjust": "Dostosuj",
"checkinLetGo": "Odpuść"
"checkinLetGo": "Odpuść",
"viewMilestone": "Zobacz",
"viewPulse": "Zobacz tętno",
"viewEcg": "Zobacz zapis"
},
"today": {
"scoreLabel": "Wynik zdrowia",
@@ -8566,7 +8607,44 @@
"sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.",
"readFullBriefing": "Przeczytaj pełny briefing",
"worthALook": "Warto sprawdzić",
"allClear": "Dziś nic nie wymaga Twojej uwagi."
"allClear": "Dziś nic nie wymaga Twojej uwagi.",
"ringLink": "Otwórz szczegóły: {metric}",
"ringDosesAria": "Przyjęto {taken} z {scheduled} dawek dzisiaj"
},
"milestone": {
"metric": {
"restingHeartRate": "tętno spoczynkowe",
"heartRateVariability": "zmienność rytmu serca",
"respiratoryRate": "częstość oddechów",
"weight": "waga",
"generic": "ta wartość"
},
"record": {
"title": "Nowy rekord: {metric}",
"body": "Dziś osiągnięto Twój najlepszy wynik dla {metric}. Spokojnie odnotowane, nic nie musisz robić."
},
"returnToBaseline": {
"title": "Znów w Twoim zakresie: {metric}",
"body": "Twoja {metric} wróciła do zwykłego zakresu. Cokolwiek to było, minęło."
},
"sustainedInRange": {
"week1": {
"title": "Tydzień w zakresie",
"body": "Twoja {metric} od tygodnia mieści się w zwykłym zakresie. Ładnie i spokojnie."
},
"week2": {
"title": "Dwa tygodnie w zakresie",
"body": "Twoja {metric} od dwóch tygodni mieści się w zwykłym zakresie. Spokojnie i stabilnie."
},
"week3": {
"title": "Trzy tygodnie w zakresie",
"body": "Trzy tygodnie z {metric} w zwykłym zakresie. Naprawdę spokojny odcinek."
},
"week4": {
"title": "Miesiąc w zakresie",
"body": "Cały miesiąc z {metric} w zwykłym zakresie. Wspaniale stabilnie."
}
}
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.28.56",
"version": "1.29.0",
"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.28.56";
/* @sw-version-fallback */ "v1.29.0";
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/*`
@@ -138,8 +138,10 @@ describe("TrendCardSkeleton", () => {
// Same outer chrome + min-height floor as the real TrendCard slot.
expect(html).toContain("rounded-xl");
expect(html).toContain("min-h-[8rem]");
// Reduced motion honoured via the Skeleton primitive.
expect(html).toContain("motion-reduce:animate-none");
// Reduced motion honoured via the Skeleton primitive's shimmer class
// (its prefers-reduced-motion guard lives beside the keyframes in
// globals.css).
expect(html).toContain("skeleton-shimmer");
});
});
+1 -1
View File
@@ -34,7 +34,7 @@ import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/digest";
import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/coach-checkin-intents";
import { coachPlanPatchSchema } from "@/lib/validations/coach-plan";
interface RouteCtx {
@@ -112,6 +112,12 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
"src/app/api/insights/health-status/route.ts",
"src/app/api/insights/breathing-screening/route.ts",
"src/app/api/insights/labs-changes/route.ts",
// S11 — the intraday pulse / tension read. Deterministic compute over the
// day's raw PULSE / steps / workouts (10-minute mean shape + the cautious
// elevated-at-rest window); it carries no assistant prose and gates on the
// `insights` module, not the Coach surface. Same posture as health-status /
// labs-changes directly above — disabling Coach must not wedge this read.
"src/app/api/insights/pulse/intraday/route.ts",
// v1.29.x (S7) — the FENCED multi-document coach chat + its attach/detach
// mutations. These live under /insights/chat for URL grouping but are
// DOCUMENT-surface routes: they gate on the `inboundDocuments` module
@@ -0,0 +1,63 @@
/**
* GET /api/insights/pulse/intraday?date=YYYY-MM-DD
*
* The on-demand read for the intraday pulse / tension layer (S11). Returns one
* local day's 10-minute mean heart-rate shape plus, when every confidence gate
* holds, a single cautious elevated-at-rest ("tension") window. Computed from
* raw samples via the read-swap pattern — NOT persisted as 10-min rollups for
* all history.
*
* Cookie OR Bearer auth via `requireAuth()`; `userId` is narrowed from the
* resolved session — never a body field. Gated on the `insights` module. The
* `date` defaults to the user's local today; anything but a `YYYY-MM-DD`
* literal is a 422 via `returnAllZodIssues`.
*/
import { z } from "zod";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { annotate } from "@/lib/logging/context";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
export const dynamic = "force-dynamic";
const querySchema = z.object({
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
});
export const GET = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
const url = new URL(req.url);
const parsed = querySchema.safeParse({
date: url.searchParams.get("date") ?? undefined,
});
if (!parsed.success) return returnAllZodIssues(parsed.error);
const timezone = await resolveUserTimezone(user.id);
const dateKey = parsed.data.date ?? userDayKey(new Date(), timezone);
const result = await loadIntradayPulse(user.id, timezone, dateKey);
annotate({
action: { name: "insights.pulse.intraday" },
meta: {
buckets: result.series.length,
baseline_source: result.baselineSource,
tension: result.tension !== null,
},
});
const response = apiSuccess(result);
response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE);
return response;
});
+107
View File
@@ -897,6 +897,113 @@
}
}
/*
* ── v1.29.0 cohesion pass — the daily-value system's finishing layer ──
*
* Four small surface treatments, all expressed through the existing
* token/motion vocabulary (no new palette, no new easing):
*
* `.today-hero-wash` — the ONE sanctioned Today atmosphere: a faint
* `--primary` mix over the theme `--card` in the
* `.wellness-tile` pattern, leaning toward the
* ring corner. Softer than the insights
* `.hero-gradient` (8% → 0 vs 18/12) so the
* promoted hero reads as the calm first surface,
* not a banner. Static — nothing to motion-gate.
* `.metric-accent` — per-metric identity edge for rail cards and
* cross-link pointers. Reads `--tile-hue` (set
* inline from the SAME `--tile-*` vocabulary the
* wellness tiles use; `--primary` for the med /
* coach family) and paints a soft inset edge on
* the reading side via a ::before overlay, so it
* composes with the card's own `shadow-sm` and
* the semantic status washes (which keep the
* background). No layout width is added; the
* inset shadow follows the border radius.
* `.milestone-reached` — the S12 quiet celebration: a soft `--success`
* halo over the theme `--card` plus a one-shot
* spring reveal on the shared `--ring-spring`
* curve. No confetti, no loop; reduced-motion
* paints the settled card at once.
* `.skeleton-shimmer` — the loading language upgrade: a single soft
* `--foreground`-mix sweep across the muted
* skeleton base instead of the whole-block
* `animate-pulse` throb. Load-terminating by
* construction (skeletons unmount with the data);
* reduced-motion collapses to the static base.
*/
.today-hero-wash {
background: radial-gradient(
120% 150% at 100% 0%,
color-mix(in srgb, var(--primary) 8%, var(--card)) 0%,
var(--card) 60%
);
}
.metric-accent {
position: relative;
}
.metric-accent::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
box-shadow: inset 3px 0 0 0
color-mix(in srgb, var(--tile-hue, var(--primary)) 55%, transparent);
}
.milestone-reached {
background: radial-gradient(
130% 130% at 85% 0%,
color-mix(in srgb, var(--success) 14%, var(--card)) 0%,
color-mix(in srgb, var(--success) 4%, var(--card)) 60%,
var(--card) 100%
);
animation: milestone-reached-in 640ms var(--ring-spring) both;
}
@keyframes milestone-reached-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.milestone-reached {
animation: none;
}
}
@keyframes skeleton-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
.skeleton-shimmer {
background-image: linear-gradient(
100deg,
transparent 36%,
color-mix(in srgb, var(--foreground) 6%, transparent) 50%,
transparent 64%
);
background-size: 250% 100%;
background-repeat: no-repeat;
animation: skeleton-shimmer 1.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.skeleton-shimmer {
animation: none;
background-image: none;
}
}
/* ── Global reduced-motion backstop ──────────────────────────────────
* Defensive catch-all so any animation/transition we forget to gate
* per-component still collapses to near-instant when the user has asked
+27
View File
@@ -17,8 +17,24 @@ import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip";
import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { EcgCrossLink } from "@/components/insights/ecg-cross-link";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { TileHeader } from "@/components/insights/tile-header";
import dynamic from "next/dynamic";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
// Load the intraday area chart through the shared chart-runtime boundary (the
// one recharts chunk), not as a direct import — a direct `from "recharts"`
// import would mint a second recharts fingerprint chunk and trip the bundle
// budget. Same `dynamic(() => import("@/components/charts/chart-runtime"))`
// pattern every other chart uses.
const IntradayPulseChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.IntradayPulseChart,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
@@ -174,6 +190,11 @@ export default function InsightsPulsPage() {
onVisibleStats={onVisibleStats}
/>
{/* S11 — the intraday "shape of the day" layer: 10-minute mean heart
rate with the resting baseline and any cautious elevated-at-rest
window. Computed on demand from raw for today; non-diagnostic. */}
<IntradayPulseChart userTimezone={user?.timezone} />
<MetricTargetSummary slug="pulse" />
{/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off
@@ -188,6 +209,12 @@ export default function InsightsPulsPage() {
icon={<Heart className="h-5 w-5" />}
/>
{/* S10 — device-attributed pointer into the ECG viewer, in the
resting-HR / pulse context. Un-mounts when the user has no ECG
recordings; surfaces only that recordings exist + the device's own
latest result, never a HealthLog interpretation. */}
<EcgCrossLink enabled={isAuthenticated} />
{/* VO₂ max is a cardio-fitness metric (Apple's Health app surfaces it
under "Heart"), so the pulse page links across to its dedicated
`/insights/cardio-fitness` page — the single surface that carries
+30 -1
View File
@@ -27,8 +27,11 @@ import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { cn } from "@/lib/utils";
import {
resolveDashboardLayout,
resolveHeroRingOrder,
HEALTH_SCORE_RING_ID,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import type { DashboardScoreRing } from "@/lib/dashboard/score-rings";
import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics";
import dynamic from "next/dynamic";
import Link from "next/link";
@@ -232,6 +235,32 @@ export default function DashboardPageClient() {
const insightsEnabled = useModuleEnabled("insights");
const digestQuery = useDailyDigest(isAuthenticated && insightsEnabled);
// v1.29.0 — the Settings-picked hero score rings, resurfaced on the Today
// hero. The snapshot resolves the selection server-side (data- and
// module-gated, selection order preserved); `resolveHeroRingOrder`
// reconciles the user's persisted drag order against what actually
// resolved — the SAME reconciler the Settings picker uses. The
// health-score ring stays the hero's fixed anchor, so its position in the
// stored order is skipped here; the legacy non-snapshot path carries no
// resolved rings and degrades to the health ring alone.
const heroScoreRings = useMemo<DashboardScoreRing[]>(() => {
const snap = snapshotQuery.data;
const rings = snap?.scoreRings ?? [];
if (rings.length === 0) return [];
const order = resolveHeroRingOrder(
snap?.layout?.heroRingOrder,
rings.map((r) => r.id),
);
const byId = new Map(rings.map((r) => [r.id, r]));
const ordered: DashboardScoreRing[] = [];
for (const id of order) {
if (id === HEALTH_SCORE_RING_ID) continue;
const ring = byId.get(id);
if (ring) ordered.push(ring);
}
return ordered;
}, [snapshotQuery.data]);
const analyticsSlimQuery = useAnalyticsQuery({
slice: "summaries",
enabled: !snapshotEnabled && isAuthenticated,
@@ -783,7 +812,7 @@ export default function DashboardPageClient() {
(!mounted || digestQuery.isLoading ? (
<TodayHeroSkeleton />
) : digestQuery.data ? (
<TodayHero digest={digestQuery.data} />
<TodayHero digest={digestQuery.data} rings={heroScoreRings} />
) : null)}
{/* v1.18.6 — the spotlight tour launcher moved to the app-shell
@@ -122,6 +122,6 @@ describe("<HostMetricsChart>", () => {
const html = render(<HostMetricsChart />);
expect(html).toContain("admin-host-metrics-heading");
expect(html).toContain("Host load");
expect(html).toContain("animate-pulse");
expect(html).toContain("skeleton-shimmer");
});
});
+1
View File
@@ -42,3 +42,4 @@ export { DoseStrengthCurve } from "@/components/medications/dose-strength-curve"
export { DrugLevelChart } from "@/components/medications/drug-level-chart";
export { EfficacyChart } from "@/components/medications/detail/efficacy/efficacy-chart";
export { AssessmentHistoryChart } from "@/components/mental-health/assessment-history-chart";
export { IntradayPulseChart } from "@/components/insights/intraday-pulse-chart";
@@ -60,6 +60,31 @@ describe("<PriorityCard>", () => {
expect(info).toContain("bg-info/10");
});
it("carries the metric-family accent for metric-flavoured kinds only", () => {
const tension = render(
<PriorityCard item={item({ kind: "tension_window" })} />,
);
expect(tension).toContain("metric-accent");
expect(tension).toContain("--tile-hue:var(--tile-stress)");
const ecg = render(
<PriorityCard item={item({ kind: "ecg_new_recording" })} />,
);
expect(ecg).toContain("--tile-hue:var(--tile-strain)");
// Utility kinds carry no metric identity — status alone speaks.
const sync = render(<PriorityCard item={item({ kind: "sync_issue" })} />);
expect(sync).not.toContain("metric-accent");
});
it("gives the milestone card the quiet reached treatment instead of the generic fade-in", () => {
const html = render(
<PriorityCard item={item({ kind: "milestone", status: "success" })} />,
);
expect(html).toContain("milestone-reached");
expect(html).not.toContain("animate-insight-in");
// The success wash still carries the semantic border.
expect(html).toContain("border-success/25");
});
it("renders a navigation action as a link carrying the href", () => {
const html = render(
<PriorityCard
@@ -6,6 +6,7 @@ import { I18nProvider } from "@/lib/i18n/context";
import { TodayHero } from "../today-hero";
import type { DailyDigest } from "@/lib/daily/digest";
import type { PriorityItem } from "@/lib/daily/priority-item";
import type { DashboardScoreRing } from "@/lib/dashboard/score-rings";
// The hero now wires the coach check-in card's keep / let-go taps through
// `useCoachCheckinAction`, so it needs a QueryClient in the tree.
@@ -140,6 +141,38 @@ describe("<TodayHero>", () => {
expect(html).toBe("");
});
it("renders the selected score rings as a cluster in the caller's order", () => {
const rings: DashboardScoreRing[] = [
{ id: "SLEEP_SCORE", score: 74, band: "yellow" },
{
id: "MED_COMPLIANCE",
score: 33,
band: "yellow",
doses: { taken: 1, scheduled: 3 },
},
];
const html = render(<TodayHero digest={digest()} rings={rings} />);
expect(html).toContain('data-slot="today-hero-ring-cluster"');
// Caller order (= the user's resolved hero ring order) is preserved.
const sleepAt = html.indexOf('data-ring="SLEEP_SCORE"');
const medsAt = html.indexOf('data-ring="MED_COMPLIANCE"');
expect(sleepAt).toBeGreaterThan(-1);
expect(medsAt).toBeGreaterThan(sleepAt);
// Each ring links to its existing detail surface.
expect(html).toContain('href="/insights/scores/sleep"');
expect(html).toContain('href="/medications"');
// The dose ring shows today's tally, not a mystery percentage.
expect(html).toContain("1/3");
expect(html).toContain("1 of 3 doses taken today");
});
it("renders no ring cluster when the selection is empty", () => {
const html = render(<TodayHero digest={digest()} />);
expect(html).not.toContain('data-slot="today-hero-ring-cluster"');
// The health-score ring alone still paints, exactly as before.
expect(html).toContain('data-slot="today-hero-score"');
});
it("shows the first-class all-clear line when a score is present but nothing is notable", () => {
const html = render(
<TodayHero
+33 -1
View File
@@ -64,6 +64,27 @@ const STATUS_WASH: Record<PriorityItemStatus, string> = {
destructive: "bg-destructive/10 border-destructive/25",
};
/**
* v1.29.0 cohesion — per-kind metric identity, drawn from the SAME `--tile-*`
* hue vocabulary the wellness tiles paint (plus `--primary` for the med and
* coach families, the tone every medication / coach surface in Insights
* already carries). Rendered as the `.metric-accent` inset edge — a quiet
* identity mark, never a fill — so a pulse card, an ECG card, and a dose card
* each read as their metric family while staying one card system.
*
* Deliberately partial: `preventive_care` and `sync_issue` are utility items
* with no metric family, and `milestone` belongs to the celebration layer
* (`.milestone-reached`, `--success`) rather than to one metric — those three
* stay accent-less. Values are token references only (`var(--…)`), the
* `RingTile` / `TILE_HUE` pattern.
*/
const KIND_HUE: Partial<Record<PriorityItemKind, string>> = {
dose_window: "var(--primary)",
coach_checkin: "var(--primary)",
ecg_new_recording: "var(--tile-strain)",
tension_window: "var(--tile-stress)",
};
export interface PriorityCardProps {
item: PriorityItem;
/**
@@ -82,16 +103,27 @@ export function PriorityCard({ item, onAction, className }: PriorityCardProps) {
const { t } = useTranslations();
const Icon = KIND_ICON[item.kind];
const actions = item.actions.slice(0, 3);
const hue = KIND_HUE[item.kind];
// S12 — the quiet "reached" moment: the milestone card swaps the generic
// fade-in for the `.milestone-reached` treatment (a soft `--success` halo
// over the theme card + a one-shot spring reveal on `--ring-spring`;
// reduced-motion fallback lives beside the keyframes in globals.css). The
// halo's background wins over the status wash's flat `bg-success/10`
// (unlayered CSS beats utilities); the wash keeps the success border.
const isMilestone = item.kind === "milestone";
return (
<Card
data-slot="priority-card"
data-kind={item.kind}
className={cn(
"animate-insight-in gap-2 py-3 md:gap-2 md:py-4",
isMilestone ? "milestone-reached" : "animate-insight-in",
"gap-2 py-3 md:gap-2 md:py-4",
item.status ? STATUS_WASH[item.status] : null,
hue ? "metric-accent" : null,
className,
)}
style={hue ? ({ "--tile-hue": hue } as React.CSSProperties) : undefined}
>
<CardContent className="flex flex-col gap-2">
<TileHeader icon={Icon} title={item.title} size="sm" />
+7 -6
View File
@@ -9,18 +9,19 @@ import { Skeleton } from "@/components/ui/skeleton";
* two-card rail row — so the swap to the loaded hero happens in place
* with zero layout shift.
*
* The shimmer rides the `<Skeleton>` primitive (`animate-pulse`), which is
* reduced-motion safe by construction (`motion-reduce:animate-none`).
* Always `aria-hidden`: the tile-strip skeleton alongside carries the
* page's loading semantics, so a second announcement here would double up
* for screen readers.
* The shimmer rides the `<Skeleton>` primitive (`skeleton-shimmer`), which
* is reduced-motion safe via its own CSS guard in `globals.css`. The shell
* carries the same `.today-hero-wash` atmosphere as the loaded hero so the
* swap changes content, never the surface. Always `aria-hidden`: the
* tile-strip skeleton alongside carries the page's loading semantics, so a
* second announcement here would double up for screen readers.
*/
export function TodayHeroSkeleton() {
return (
<div
aria-hidden="true"
data-slot="today-hero-skeleton"
className="bg-card border-border relative isolate overflow-hidden rounded-xl border p-4 md:p-6"
className="bg-card today-hero-wash border-border relative isolate overflow-hidden rounded-xl border p-4 md:p-6"
>
<div className="flex flex-col gap-4 md:gap-5">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-6">
+142 -17
View File
@@ -14,6 +14,10 @@
* - the day's read: the health `ScoreRing` (`flat`, md) with its
* server-computed band and an honest provisional/final face — a null
* score paints the ring's own provisional state, never a zero;
* - the selected score rings (v1.29.0): the user's Settings-picked hero
* rings (max 3), resolved + gated by the dashboard snapshot and ordered
* by `resolveHeroRingOrder`, as a calm sm-ring cluster beneath the
* health ring — the picker configures the web hero again;
* - the briefing lead in plain language (via `ProseBlocks`, no markdown)
* with a "read the full briefing" affordance, plus the top signal's
* present-tense headline + delta when the digest carries one;
@@ -34,16 +38,54 @@ import { Moon } from "lucide-react";
import { ScoreRing } from "@/components/insights/derived/score-ring";
import type { ScoreBand } from "@/components/insights/derived/band-tokens";
import type { RingHue } from "@/components/insights/derived/ring-hues";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { PriorityCard } from "@/components/daily/priority-card";
import { useCoachCheckinAction } from "@/hooks/use-coach-checkin";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { ScoreRingId } from "@/lib/dashboard-layout";
import type { DashboardScoreRing } from "@/lib/dashboard/score-rings";
import type { DailyDigest } from "@/lib/daily/digest";
import {
COACH_CHECKIN_KEEP_INTENT,
COACH_CHECKIN_LETGO_INTENT,
type DailyDigest,
} from "@/lib/daily/digest";
} from "@/lib/daily/coach-checkin-intents";
/**
* v1.29.0 — the selected score rings return to the hero. The maps mirror the
* retired legacy-hero renderer verbatim so every ring paints EXACTLY the hue,
* label, and destination its insights sibling owns:
*
* - hue: the wellness-strip vocabulary (readiness green / recovery cyan /
* sleep purple); `MED_COMPLIANCE` rides `meds` = `--primary`, the one
* constant tone every medication surface paints — never a band gradient
* that would flash yellow over pending morning doses.
* - label: the existing score labels; the dose ring names today's tally.
* - href: each ring opens the SAME detail surface the wellness strip /
* medications page already own — the picker configures real navigation,
* not decoration.
*/
const RING_HUE_BY_ID: Partial<Record<ScoreRingId, RingHue>> = {
READINESS: "readiness",
RECOVERY_SCORE: "recovery",
SLEEP_SCORE: "sleep",
MED_COMPLIANCE: "meds",
};
const RING_LABEL_KEY: Record<ScoreRingId, string> = {
READINESS: "insights.derived.composite.READINESS.title",
RECOVERY_SCORE: "insights.derived.scores.recovery",
SLEEP_SCORE: "insights.derived.composite.SLEEP_SCORE.title",
MED_COMPLIANCE: "dashboard.hero.ringDoses",
};
const RING_HREF: Record<ScoreRingId, string> = {
READINESS: "/insights/scores/readiness",
RECOVERY_SCORE: "/insights/scores/recovery",
SLEEP_SCORE: "/insights/scores/sleep",
MED_COMPLIANCE: "/medications",
};
/** Format the server-computed score delta as a signed, muted chip string. */
function formatDelta(
@@ -56,7 +98,22 @@ function formatDelta(
return t("daily.today.deltaVsBaseline", { delta: signed });
}
export function TodayHero({ digest }: { digest: DailyDigest }) {
export function TodayHero({
digest,
rings = [],
}: {
digest: DailyDigest;
/**
* v1.29.0 — the user's selected hero score rings (Settings → Dashboard),
* resolved server-side by the dashboard snapshot (data- and module-gated)
* and ordered by the caller via `resolveHeroRingOrder`, so the Settings
* picker's selection + drag order configure the web hero again. The
* health-score ring stays the hero's fixed anchor; this cluster renders
* beneath it. Optional and additive — an empty selection (or the legacy
* non-snapshot path) keeps the hero exactly as before.
*/
rings?: DashboardScoreRing[];
}) {
const { t } = useTranslations();
const { keep, letGo } = useCoachCheckinAction();
@@ -95,11 +152,12 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
data-slot="today-hero"
data-phase={digest.phase}
className={cn(
// Same surface as the tile strip + chart cards: plain `bg-card`
// with the shared border/radius/padding — the calm token system,
// no gradient, no glow. The hero reads as one of the dashboard's
// own tiles, just larger.
"bg-card border-border relative isolate overflow-hidden rounded-xl border",
// The tile strip's surface plus the ONE sanctioned Today atmosphere:
// `.today-hero-wash` leans a faint `--primary` mix over the theme
// `--card` toward the ring corner (the `.wellness-tile` color-mix
// pattern, softer than the insights hero) so the promoted day's read
// carries a quiet identity without a banner gradient or glow.
"bg-card today-hero-wash border-border relative isolate overflow-hidden rounded-xl border",
"p-4 md:p-6",
)}
>
@@ -150,15 +208,26 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
at the same footprint, so the column never collapses. */}
<div className="flex shrink-0 flex-col items-center gap-1 md:items-end">
<div data-slot="today-hero-score">
<ScoreRing
score={digest.score?.value ?? null}
band={
digest.score ? (digest.score.band as ScoreBand) : undefined
}
size="md"
flat
label={t("daily.today.scoreLabel")}
/>
{/* The ring opens the Insights overview — the destination the
health-score card owns — matching the cluster rings' tap
behaviour, so every hero ring is a door, not a poster. */}
<Link
href="/insights"
aria-label={t("daily.today.ringLink", {
metric: t("daily.today.scoreLabel"),
})}
className="focus-visible:ring-ring/50 block rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
<ScoreRing
score={digest.score?.value ?? null}
band={
digest.score ? (digest.score.band as ScoreBand) : undefined
}
size="md"
flat
label={t("daily.today.scoreLabel")}
/>
</Link>
</div>
{digest.score && digest.score.delta !== null ? (
<span
@@ -171,6 +240,62 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
</div>
</div>
{/* v1.29.0 — the selected score rings, resurfaced. A calm inline
cluster beneath the health ring (right-aligned with it on md+,
centred on mobile), honouring the user's Settings selection and
drag order. Each ring is the shared `ScoreRing` primitive in its
wellness hue, `flat` (no sweep) so the cluster paints at once,
and links to the metric's existing detail surface. Selection
empty → the row simply isn't there — the hero reads as before. */}
{rings.length > 0 ? (
<div
data-slot="today-hero-ring-cluster"
className="flex flex-wrap items-start justify-center gap-x-4 gap-y-3 md:justify-end"
>
{rings.map((ring) => (
<Link
key={ring.id}
href={RING_HREF[ring.id]}
data-slot="today-hero-ring"
data-ring={ring.id}
aria-label={t("daily.today.ringLink", {
metric: t(RING_LABEL_KEY[ring.id]),
})}
className="focus-visible:ring-ring/50 rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
{ring.id === "MED_COMPLIANCE" && ring.doses ? (
// Dose ring — today's tally ("1/3") over the constant
// med-family arc; the arc sweeps on the 0..100 progress.
// `band="green"` stays the stable data-band anchor — a
// pending morning dose is not an alert state.
<ScoreRing
score={ring.score}
band="green"
hue="meds"
valueText={`${ring.doses.taken}/${ring.doses.scheduled}`}
ariaLabel={t("daily.today.ringDosesAria", {
taken: ring.doses.taken,
scheduled: ring.doses.scheduled,
})}
size="sm"
flat
label={t(RING_LABEL_KEY[ring.id])}
/>
) : (
<ScoreRing
score={ring.score}
band={ring.band}
size="sm"
flat
hue={RING_HUE_BY_ID[ring.id]}
label={t(RING_LABEL_KEY[ring.id])}
/>
)}
</Link>
))}
</div>
) : null}
{/* Freshness note (plan §2.4) — provisional day, last night's sleep
not yet folded in. Muted, non-blocking, refreshes in place when
the morning job lands. */}
@@ -247,7 +247,7 @@ describe("<DailyBriefing>", () => {
it("renders the shimmer skeleton when loading", () => {
const html = render(<DailyBriefing briefing={null} loading />);
expect(html).toMatch(/data-slot="daily-briefing-skeleton"/);
expect(html).toContain("animate-pulse");
expect(html).toContain("skeleton-shimmer");
// Empty state must NOT render while loading.
expect(html).not.toContain('data-slot="daily-briefing-empty"');
});
@@ -0,0 +1,81 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* S10 — `<EcgCrossLink>` unit tests.
*
* The load-bearing behaviour under test:
* - data-availability gating: the pointer un-mounts entirely when the user
* has no ECG recordings;
* - the NON-DIAGNOSTIC framing: it surfaces only that recordings exist and
* the RECORDING DEVICE's own latest result, attributed to the device — no
* HealthLog interpretation, no waveform.
*
* `useAuth` + TanStack Query are mocked and assertions run through SSR, exactly
* like the sibling `ecg-section` test.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: vi.fn(() => ({ isAuthenticated: true, user: null })),
}));
const useQueryMock = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => useQueryMock(opts),
}));
const { EcgCrossLink } = await import("../ecg-cross-link");
interface Item {
id: string;
classification: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
}
function render(
data: { recordings: Item[]; hasRecordings: boolean } | undefined,
) {
useQueryMock.mockReturnValue({ data });
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<EcgCrossLink />
</I18nProvider>,
);
}
describe("<EcgCrossLink>", () => {
it("renders nothing before the payload resolves", () => {
expect(render(undefined)).toBe("");
});
it("renders nothing when the user has no recordings (gate)", () => {
expect(render({ recordings: [], hasRecordings: false })).toBe("");
});
it("links into the ECG viewer and attributes the latest result to the device", () => {
const html = render({
recordings: [
{ id: "a", classification: "IRREGULAR" },
{ id: "b", classification: "NOT_DETECTED" },
],
hasRecordings: true,
});
expect(html).toContain('data-slot="ecg-cross-link"');
expect(html).toContain('href="/insights#ecg"');
expect(html).toContain("2 recordings on file.");
// The latest (first) recording's DEVICE result, attributed to the device.
expect(html).toContain("Latest device result:");
// Never a HealthLog interpretation.
expect(html).not.toMatch(/we (detected|found)|our (reading|analysis)/i);
});
it("omits the result line when the latest recording has no device verdict", () => {
const html = render({
recordings: [{ id: "a", classification: null }],
hasRecordings: true,
});
expect(html).toContain("1 recording on file.");
expect(html).not.toContain("Latest device result:");
});
});
@@ -127,7 +127,7 @@ describe("<TrendAnnotation>", () => {
<TrendAnnotation metric="bp" annotation={null} status="pending" />,
);
expect(html).toMatch(/data-slot="trend-annotation-pending"/);
expect(html).toContain("animate-pulse");
expect(html).toContain("skeleton-shimmer");
// Empty hint and filled prose MUST be absent during pending.
expect(html).not.toContain('data-slot="trend-annotation-empty"');
expect(html).not.toContain('data-slot="trend-annotation"');
+111
View File
@@ -0,0 +1,111 @@
"use client";
import Link from "next/link";
import { ArrowRight, HeartPulse } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { TileHeader } from "@/components/insights/tile-header";
/**
* S10 — ECG cross-link, the pointer from the resting-HR / pulse context into
* the ECG viewer (`/insights#ecg`).
*
* NON-DIAGNOSTIC (load-bearing, mirrors `EcgSection` / `RhythmEventsCard`): the
* card surfaces ONLY that recordings exist + the RECORDING DEVICE's OWN latest
* result, attributed to the device. HealthLog never reads or interprets the
* waveform, and this pointer never touches it — the list route returns metadata
* only. All copy renders as plain React text children (no markdown library).
*
* Data-availability-gated: the card un-mounts entirely (`return null`) when the
* user has no recordings, so a pulse page without any ECG never paints it. It
* reuses the SAME query cell as `EcgSection` (`insightsEcgList`), so the two
* surfaces share one cache entry with an identical response shape.
*/
type EcgClassification = "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
interface EcgRecordingListItem {
id: string;
classification: EcgClassification;
}
interface EcgListResponse {
recordings: EcgRecordingListItem[];
hasRecordings: boolean;
}
/** The device verdict, surfaced verbatim and attributed to the device. */
const RESULT_LABEL_KEYS: Record<string, string> = {
IRREGULAR: "insights.ecg.result.irregular",
NOT_DETECTED: "insights.ecg.result.notDetected",
INCONCLUSIVE: "insights.ecg.result.inconclusive",
};
interface EcgCrossLinkProps {
enabled?: boolean;
className?: string;
}
export function EcgCrossLink({ enabled = true, className }: EcgCrossLinkProps) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data } = useQuery({
queryKey: queryKeys.insightsEcgList(),
queryFn: async () => {
try {
return await apiGet<EcgListResponse>("/api/insights/ecg");
} catch {
throw new Error(t("insights.ecg.loadError"));
}
},
enabled: enabled && isAuthenticated,
});
// Data-availability gate — never paint an empty pointer.
if (!data || !data.hasRecordings || data.recordings.length === 0) return null;
const count = data.recordings.length;
const latestClassification = data.recordings[0]?.classification ?? null;
const resultKey = latestClassification
? RESULT_LABEL_KEYS[latestClassification]
: null;
const countText =
count === 1
? t("insights.ecg.crossLink.recordingsOne")
: t("insights.ecg.crossLink.recordingsMany", { count });
const resultText = resultKey
? t("insights.ecg.crossLink.latestResult", { result: t(resultKey) })
: null;
return (
<Link
href="/insights#ecg"
data-slot="ecg-cross-link"
className={cn(
// `.metric-accent` — the heart-family identity edge (`--tile-strain`,
// the wellness vocabulary's cardiovascular hue), the same mark the
// `ecg_new_recording` rail card carries, so the two ECG pointers
// read as one family.
"bg-card hover:bg-accent/40 focus-visible:ring-ring/50 metric-accent block space-y-1.5 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
style={{ "--tile-hue": "var(--tile-strain)" } as React.CSSProperties}
>
<TileHeader icon={HeartPulse} title={t("insights.ecg.crossLink.title")} />
<span className="text-muted-foreground block text-xs leading-snug">
{resultText ? `${countText} ${resultText}` : countText}
</span>
<span className="text-primary inline-flex shrink-0 items-center gap-1 text-xs font-medium">
{t("insights.ecg.crossLink.cta")}
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</span>
</Link>
);
}
+2 -1
View File
@@ -118,9 +118,10 @@ export function EcgSection({ enabled = true, className }: EcgSectionProps) {
return (
<section
id="ecg"
data-slot="ecg-section"
aria-label={t("insights.ecg.sectionTitle")}
className={cn("space-y-3", className)}
className={cn("scroll-mt-24 space-y-3", className)}
>
<SectionHeading icon={Activity} title={t("insights.ecg.sectionTitle")} />
<div
@@ -0,0 +1,206 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Activity } from "lucide-react";
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceLine,
ReferenceArea,
} from "recharts";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { userDayKey } from "@/lib/tz/format";
import { TileHeader } from "@/components/insights/tile-header";
import type {
IntradayHrBucket,
TensionWindow,
} from "@/lib/analytics/intraday-pulse";
/** Slim DTO the intraday route returns (type-only — no server code bundled). */
interface IntradayPulseDto {
dateKey: string;
bucketMinutes: number;
series: IntradayHrBucket[];
baseline: number | null;
baselineSource: "resting" | "proxy" | "none";
tension: TensionWindow | null;
}
/** Minutes-since-midnight → "HH:mm" wall-clock label. */
function minuteLabel(minute: number): string {
const h = Math.floor(minute / 60);
const m = minute % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
const AXIS_TICKS = [0, 360, 720, 1080, 1440];
/**
* S11 — the intraday pulse "shape of the day" layer inside the pulse insight.
*
* Charts one local day's 10-minute mean heart rate (computed on demand from
* raw), with the personal resting baseline drawn as a reference line and any
* detected elevated-at-rest ("tension") window softly shaded. Copy is cautious
* and non-diagnostic — "possible tension", never a stress score. Empty / no-
* baseline days render an honest one-liner rather than an empty grid.
*/
export function IntradayPulseChart({
userTimezone,
}: {
userTimezone?: string;
}) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const tz = userTimezone ?? "Europe/Berlin";
const dateKey = userDayKey(new Date(), tz);
const { data, isLoading } = useQuery({
queryKey: queryKeys.insightsPulseIntraday(dateKey),
queryFn: () =>
apiGet<IntradayPulseDto>(`/api/insights/pulse/intraday?date=${dateKey}`),
enabled: isAuthenticated,
staleTime: 60_000,
refetchOnWindowFocus: false,
});
const chartData = useMemo(
() =>
(data?.series ?? []).map((b) => ({
minute: b.startMinute,
label: minuteLabel(b.startMinute),
bpm: b.mean,
})),
[data?.series],
);
const caption = data?.tension
? t(`insights.intradayPulse.tension.${data.tension.partOfDay}`)
: t("insights.intradayPulse.caption");
return (
<div
data-slot="intraday-pulse-chart"
// `.metric-accent` — the tension layer's identity edge on the CARD
// shell only (`--tile-stress`, the wellness vocabulary's autonomic
// hue, per the plan's §3.6 hue-family call); the chart inside stays
// untouched. The same hue marks the `tension_window` rail card, so
// the two S11 surfaces read as one family.
className="bg-card metric-accent space-y-1.5 rounded-xl border p-4"
style={{ "--tile-hue": "var(--tile-stress)" } as React.CSSProperties}
>
<TileHeader icon={Activity} title={t("insights.intradayPulse.title")} />
<p className="text-muted-foreground text-xs leading-snug">{caption}</p>
{isLoading ? (
<div className="bg-muted/40 h-44 animate-pulse rounded-lg motion-reduce:animate-none" />
) : chartData.length === 0 ? (
<div className="text-muted-foreground flex h-44 items-center justify-center rounded-lg border border-dashed text-sm">
{t("insights.intradayPulse.empty")}
</div>
) : (
<div className="touch-pan-y">
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={chartData}>
<defs>
<linearGradient
id="intradayPulseFill"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--chart-1)"
stopOpacity={0.3}
/>
<stop
offset="100%"
stopColor="var(--chart-1)"
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="minute"
type="number"
domain={[0, 1440]}
ticks={AXIS_TICKS}
tickFormatter={minuteLabel}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
/>
<YAxis
domain={["dataMin - 10", "dataMax + 10"]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
unit=" bpm"
width={52}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
labelFormatter={(minute) => minuteLabel(Number(minute))}
formatter={(value) => [`${value} bpm`, t("charts.pulse")]}
/>
{data?.tension ? (
<ReferenceArea
x1={data.tension.startMinute}
x2={data.tension.endMinute}
fill="var(--warning)"
fillOpacity={0.14}
ifOverflow="extendDomain"
/>
) : null}
{data?.baseline != null ? (
<ReferenceLine
y={data.baseline}
stroke="var(--muted-foreground)"
strokeDasharray="5 5"
strokeOpacity={0.7}
label={{
value: t("insights.intradayPulse.baseline"),
position: "insideTopLeft",
fill: "var(--muted-foreground)",
fontSize: 10,
}}
/>
) : null}
<Area
type="monotone"
dataKey="bpm"
stroke="var(--chart-1)"
strokeWidth={2}
fill="url(#intradayPulseFill)"
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
@@ -10,13 +10,13 @@ describe("<Skeleton>", () => {
expect(html).toContain('aria-hidden="true"');
});
it("preserves caller classes alongside the pulse animation", () => {
it("preserves caller classes alongside the shimmer animation", () => {
const html = renderToStaticMarkup(
<Skeleton className="h-8 rounded-full" />,
);
// The pulse class drives the visual shimmer; honour reduce-motion.
expect(html).toContain("animate-pulse");
expect(html).toContain("motion-reduce:animate-none");
// The shimmer class drives the loading sweep; its reduced-motion
// fallback lives in globals.css next to the keyframes.
expect(html).toContain("skeleton-shimmer");
// Caller's classes should survive the merge.
expect(html).toContain("h-8");
expect(html).toContain("rounded-full");
+10 -8
View File
@@ -3,10 +3,15 @@ import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Pulsing placeholder used while data is loading. Wrap text-equivalent
* shapes so the page does not jump when real content arrives. Honours
* `prefers-reduced-motion` automatically because Tailwind's
* `animate-pulse` is disabled by `motion-reduce:animate-none`.
* Shimmering placeholder used while data is loading. Wrap text-equivalent
* shapes so the page does not jump when real content arrives.
*
* v1.29.0 — the whole-block `animate-pulse` throb is upgraded to a single
* soft `skeleton-shimmer` sweep (a `--foreground`-mix gradient gliding
* across the muted base, defined in `globals.css`), so every loading
* surface shares one calm loading language. Honours
* `prefers-reduced-motion` via the shimmer's own CSS guard, which
* collapses it to the static muted block.
*
* Pattern: render the same layout the loaded UI will render, with each
* dynamic block replaced by a `<Skeleton className="h-X w-Y" />`.
@@ -20,10 +25,7 @@ function Skeleton({
data-slot="skeleton"
role="presentation"
aria-hidden="true"
className={cn(
"bg-muted/60 animate-pulse rounded-md motion-reduce:animate-none",
className,
)}
className={cn("bg-muted/60 skeleton-shimmer rounded-md", className)}
{...props}
/>
);
+1 -1
View File
@@ -22,7 +22,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiPatch } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/digest";
import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/coach-checkin-intents";
export function useCoachCheckinAction() {
const queryClient = useQueryClient();
@@ -261,4 +261,39 @@ describe("core-vitals grounding extension", () => {
);
expect(found.map((f) => f.value)).toContain(195);
});
// S10 — the ECG device-verdict descriptor feeds the narrative; its
// server-computed counts must be admitted so a device-attributed sentence
// ("your device logged 5 ECG recordings") is not stripped by the gate, while
// a count the block never produced still trips it.
const ecgFeatures = {
ecg: {
recordingCount: 5,
deviceVerdicts: { irregular: 2, notDetected: 3, inconclusive: 0 },
latestDeviceVerdict: "IRREGULAR",
latestRecordedDaysAgo: 4,
latestAverageHeartRate: 61,
},
} as never;
it("grounds a restated ECG recording count + latest device figures", () => {
const found = findUngroundedBriefingNumbers(
{
paragraph:
"Your device logged 5 ECG recordings this month; the latest, 4 days ago, averaged 61 bpm.",
},
signals,
ecgFeatures,
);
expect(found).toEqual([]);
});
it("flags an ECG count the descriptor never produced", () => {
const found = findUngroundedBriefingNumbers(
{ paragraph: "Your device logged 12 ECG recordings this month." },
signals,
ecgFeatures,
);
expect(found.map((f) => f.value)).toContain(12);
});
});
@@ -52,6 +52,22 @@ describe("scope-hardened system prompt", () => {
expect(prompt).toMatch(/verschreibst NICHT/);
});
it("English prompt keeps ECG device-verdict-only and forbids interpretation", () => {
const prompt = getStrictInsightsSystemPrompt("en");
// Attribution + existence allowed; interpretation of the trace forbidden.
expect(prompt).toMatch(/ECG recordings are DEVICE-VERDICT ONLY/);
expect(prompt).toMatch(/never read morphology/i);
expect(prompt).toMatch(/never measure intervals/i);
expect(prompt).toMatch(/attribute its result to the device/i);
});
it("German prompt keeps ECG device-verdict-only and forbids interpretation", () => {
const prompt = getStrictInsightsSystemPrompt("de");
expect(prompt).toMatch(/EKG-Aufzeichnungen sind NUR das GERÄTE-URTEIL/);
expect(prompt).toMatch(/keine Morphologie/i);
expect(prompt).toMatch(/dem Gerät zuschreiben/i);
});
it("English prompt instructs the doctor-consult call-to-action", () => {
const prompt = getStrictInsightsSystemPrompt("en");
expect(prompt).toMatch(/consult your doctor/i);
+14
View File
@@ -153,6 +153,20 @@ function authoritativeValues(
push(w.latest.daysAgo);
}
}
// S10 — ECG device-verdict descriptor. Admit the pre-computed counts + the
// latest recording's device-reported HR / recency so a briefing that
// references the recordings ("your device logged 2 ECG recordings this
// month") stays grounded. Only server-computed figures are added; the
// waveform never reaches the payload, so no trace-derived number exists.
const ecg = features.ecg;
if (ecg) {
push(ecg.recordingCount);
push(ecg.deviceVerdicts.irregular);
push(ecg.deviceVerdicts.notDetected);
push(ecg.deviceVerdicts.inconclusive);
push(ecg.latestRecordedDaysAgo);
push(ecg.latestAverageHeartRate);
}
// v1.25.1 — clinical-depth aggregate blocks (grip / waist / pain) wired
// into the briefing. Admit their pre-computed figures so the prose may cite
// them, same posture as the glucose / labs / workout blocks above.
+33 -2
View File
@@ -397,7 +397,22 @@ gib die obige Verweigerung zurück.`,
typical mid-titration. Worth mentioning at the next visit if
it persists." This is a SAFETY contract; treat any
dose-prescriptive instinct as a sign the response is
out-of-bounds.`,
out-of-bounds.
14. ECG recordings are DEVICE-VERDICT ONLY. When the snapshot
carries an "ecg" block, it holds ONLY counts, the RECORDING
DEVICE's own verdicts (deviceVerdicts / latestDeviceVerdict:
irregular / notDetected / inconclusive), and the latest
recording's recency + average heart rate. You NEVER see the
waveform and you NEVER interpret one. You may note that a
recording exists and attribute its result to the device —
"your device logged an ECG on <date> and flagged a possible
irregular result" — and, for a non-normal device verdict,
suggest discussing that recording with a clinician. You must
NEVER present the verdict as your own, never read morphology,
never measure intervals or beats, never assign a rhythm
diagnosis, and never state or imply that HealthLog analysed the
trace. If the "ecg" block is absent, stay silent about ECG —
do not mention its absence or suggest recording one.`,
de: `GRUNDREGELN — NULL HALLUZINATIONEN
1. Jede Aussage in "summary" muss auf einer Zahl beruhen, die im
übergebenen Datenpaket sichtbar ist. Lässt sich die Aussage nicht
@@ -601,7 +616,23 @@ gib die obige Verweigerung zurück.`,
mid-titration Phase. Lohnt sich beim nächsten Termin
anzusprechen, falls es darüber hinaus persistiert." Das ist
ein SICHERHEITS-Vertrag; behandle jeden dosis-präskriptiven
Impuls als Signal, dass die Antwort außerhalb des Skopus liegt.`,
Impuls als Signal, dass die Antwort außerhalb des Skopus liegt.
14. EKG-Aufzeichnungen sind NUR das GERÄTE-URTEIL. Enthält der
Snapshot einen "ecg"-Block, trägt er AUSSCHLIESSLICH Zählungen,
die EIGENEN Urteile des AUFZEICHNUNGSGERÄTS (deviceVerdicts /
latestDeviceVerdict: irregular / notDetected / inconclusive)
sowie Aktualität und durchschnittliche Herzfrequenz der letzten
Aufzeichnung. Du siehst NIE die Kurve und interpretierst sie
NIE. Du darfst erwähnen, dass eine Aufzeichnung existiert, und
das Ergebnis dem Gerät zuschreiben — "dein Gerät hat am <Datum>
ein EKG aufgezeichnet und ein mögliches unregelmäßiges Ergebnis
markiert" — und bei einem nicht-normalen Geräte-Urteil anregen,
diese Aufzeichnung mit einer Ärztin zu besprechen. Du darfst das
Urteil NIEMALS als dein eigenes ausgeben, keine Morphologie
lesen, keine Intervalle oder Schläge vermessen, keine
Rhythmus-Diagnose stellen und niemals andeuten, dass HealthLog
die Kurve analysiert hat. Fehlt der "ecg"-Block, schweige zum
Thema EKG — erwähne weder sein Fehlen noch rege eine Aufzeichnung an.`,
},
{
id: "guidelineTargets",
@@ -0,0 +1,244 @@
/**
* S11 — intraday pulse shape + elevated-at-rest ("tension") detection.
*
* The signal-correctness bar for this slice is high (the launch posture is
* "says less than it could"), so these pin the two things that are easy to get
* embarrassingly wrong: the 10-minute bucketing (tz-correct day windows, sparse
* data) and the tension gate refusing to flag a workout, a walk, or a short
* spike — only a sustained, low-movement, above-baseline stretch fires.
*/
import { describe, it, expect } from "vitest";
import {
BUCKET_MINUTES,
ELEVATION_MARGIN_BPM,
MIN_STEP_COVERAGE_BUCKETS,
computeTenMinuteMeanSeries,
detectTensionWindow,
makeLocalResolver,
partOfDayForMinute,
type IntradayHrBucket,
type IntradaySample,
type DetectTensionInput,
} from "../intraday-pulse";
const utcOf = makeLocalResolver("UTC");
function sample(iso: string, value: number): IntradaySample {
return { measuredAt: new Date(iso), value };
}
/** A dense low-movement step map with the required coverage, all zeros. */
function zeroSteps(...extraMinutes: number[]): Map<number, number> {
const m = new Map<number, number>();
// MIN_STEP_COVERAGE_BUCKETS distinct early-morning buckets + any extras.
for (let i = 0; i < MIN_STEP_COVERAGE_BUCKETS; i++)
m.set(i * BUCKET_MINUTES, 0);
for (const min of extraMinutes) m.set(min, 0);
return m;
}
/** A run of identical elevated buckets starting at `startMinute`. */
function elevatedRun(
startMinute: number,
count: number,
mean = 80,
sampleCount = 5,
): IntradayHrBucket[] {
return Array.from({ length: count }, (_, i) => ({
startMinute: startMinute + i * BUCKET_MINUTES,
mean,
count: sampleCount,
}));
}
function tensionInput(
over: Partial<DetectTensionInput> = {},
): DetectTensionInput {
return {
buckets: [],
baseline: 60,
baselineMature: true,
stepBuckets: zeroSteps(),
workouts: [],
...over,
};
}
describe("computeTenMinuteMeanSeries", () => {
it("means samples inside each 10-minute bucket and sorts ascending", () => {
const series = computeTenMinuteMeanSeries(
[
sample("2026-05-01T10:00:00Z", 70),
sample("2026-05-01T10:03:00Z", 80), // same bucket 600
sample("2026-05-01T10:12:00Z", 90), // bucket 610
],
"2026-05-01",
utcOf,
);
expect(series).toEqual([
{ startMinute: 600, mean: 75, count: 2 },
{ startMinute: 610, mean: 90, count: 1 },
]);
});
it("keeps a lone-sample bucket (count reflects density for the gate)", () => {
const series = computeTenMinuteMeanSeries(
[sample("2026-05-01T08:05:00Z", 66)],
"2026-05-01",
utcOf,
);
expect(series).toEqual([{ startMinute: 480, mean: 66, count: 1 }]);
});
it("excludes samples from other local days (no next-day bleed)", () => {
const series = computeTenMinuteMeanSeries(
[
sample("2026-05-01T23:55:00Z", 70), // stays on 05-01
sample("2026-05-02T00:05:00Z", 90), // 05-02
],
"2026-05-01",
utcOf,
);
expect(series).toEqual([{ startMinute: 1430, mean: 70, count: 1 }]);
});
it("buckets on the USER's local day, not UTC (tz-correct windows)", () => {
const nyOf = makeLocalResolver("America/New_York");
// 02:30Z on 05-01 is 22:30 EDT on 04-30 → minute 1350, prior day.
const s = [sample("2026-05-01T02:30:00Z", 72)];
expect(computeTenMinuteMeanSeries(s, "2026-05-01", nyOf)).toEqual([]);
expect(computeTenMinuteMeanSeries(s, "2026-04-30", nyOf)).toEqual([
{ startMinute: 1350, mean: 72, count: 1 },
]);
});
});
describe("partOfDayForMinute", () => {
it("labels the coarse part of day", () => {
expect(partOfDayForMinute(5 * 60)).toBe("night");
expect(partOfDayForMinute(9 * 60)).toBe("morning");
expect(partOfDayForMinute(15 * 60)).toBe("afternoon");
expect(partOfDayForMinute(20 * 60)).toBe("evening");
});
});
describe("detectTensionWindow — fires only for sustained elevated-at-rest", () => {
it("fires for a sustained above-baseline low-movement stretch", () => {
const win = detectTensionWindow(
tensionInput({ buckets: elevatedRun(600, 4) }),
);
expect(win).not.toBeNull();
expect(win?.startMinute).toBe(600);
expect(win?.endMinute).toBe(640);
expect(win?.partOfDay).toBe("morning");
expect(win?.meanHr).toBe(80);
expect(win?.baseline).toBe(60);
});
it("does NOT flag a workout — high HR during exercise is not tension", () => {
const win = detectTensionWindow(
tensionInput({
buckets: elevatedRun(600, 4),
workouts: [{ startMinute: 595, endMinute: 645 }],
}),
);
expect(win).toBeNull();
});
it("does NOT flag when steps are high — a walk is movement, not tension", () => {
const win = detectTensionWindow(
tensionInput({
buckets: elevatedRun(600, 4),
stepBuckets: new Map([
...zeroSteps(),
[600, 200],
[610, 210],
[620, 190],
[630, 220],
]),
}),
);
expect(win).toBeNull();
});
it("does NOT flag heart rate that never clears the elevation margin", () => {
const justUnder = 60 + ELEVATION_MARGIN_BPM - 1; // below threshold
const win = detectTensionWindow(
tensionInput({ buckets: elevatedRun(600, 4, justUnder) }),
);
expect(win).toBeNull();
});
it("does NOT flag a short spike below the sustained-minutes floor", () => {
// Only two consecutive elevated buckets (20 min) — under the 30-min floor.
const win = detectTensionWindow(
tensionInput({ buckets: elevatedRun(600, 2) }),
);
expect(win).toBeNull();
});
it("breaks the run on a sparse (sub-density) bucket", () => {
const buckets: IntradayHrBucket[] = [
{ startMinute: 600, mean: 80, count: 5 },
{ startMinute: 610, mean: 80, count: 1 }, // gap — filtered out
{ startMinute: 620, mean: 80, count: 5 },
{ startMinute: 630, mean: 80, count: 5 },
{ startMinute: 640, mean: 80, count: 5 },
];
const win = detectTensionWindow(tensionInput({ buckets }));
// 600 stands alone (next valid 620 is not adjacent); 620-640 is the run.
expect(win?.startMinute).toBe(620);
expect(win?.endMinute).toBe(650);
});
it("stays silent until the baseline is mature", () => {
expect(
detectTensionWindow(
tensionInput({ buckets: elevatedRun(600, 4), baselineMature: false }),
),
).toBeNull();
});
it("stays silent with no baseline at all", () => {
expect(
detectTensionWindow(
tensionInput({ buckets: elevatedRun(600, 4), baseline: null }),
),
).toBeNull();
});
it("stays silent without enough step coverage to trust low movement", () => {
const win = detectTensionWindow(
tensionInput({
buckets: elevatedRun(600, 4),
stepBuckets: new Map([
[0, 0],
[10, 0],
]), // below MIN_STEP_COVERAGE_BUCKETS
}),
);
expect(win).toBeNull();
});
it("reports the longest qualifying run when several exist", () => {
const buckets = [
...elevatedRun(120, 3), // 30 min
{ startMinute: 150, mean: 60, count: 5 }, // break
...elevatedRun(600, 5), // 50 min — the winner
];
const win = detectTensionWindow(tensionInput({ buckets }));
expect(win?.startMinute).toBe(600);
expect(win?.endMinute).toBe(650);
});
it("marks hrvConfirmed when intraday HRV confirms within the window", () => {
const win = detectTensionWindow(
tensionInput({
buckets: elevatedRun(600, 4),
hrvConfirmMinutes: [610],
}),
);
expect(win?.hrvConfirmed).toBe(true);
});
});
+218
View File
@@ -0,0 +1,218 @@
/**
* S11 — IO seam for the intraday pulse / tension layer.
*
* Loads ONE local day's raw signals on demand (read-swap; never persisted as
* 10-min rollups) and hands them to the pure `intraday-pulse` computations:
*
* - raw PULSE for the day → the 10-minute mean shape;
* - the personal resting baseline (RESTING_HEART_RATE, else the
* low-percentile PULSE proxy) → the elevation line;
* - intraday step samples → the mandatory low-movement gate;
* - workouts overlapping the day → exercise never reads as tension;
* - intraday HRV (WHOOP RMSSD) below the personal median → an optional
* confirming flag.
*
* Shared by `GET /api/insights/pulse/intraday` (the chart) and the daily
* digest's `tension_window` builder, so the signal is computed one way.
*/
import { prisma } from "@/lib/db";
import { percentile } from "@/lib/insights/strain-score";
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
import {
BUCKET_MINUTES,
MIN_BASELINE_DAYS,
computeTenMinuteMeanSeries,
detectTensionWindow,
makeLocalResolver,
type IntradayHrBucket,
type LocalResolver,
type TensionWindow,
type WorkoutInterval,
} from "@/lib/analytics/intraday-pulse";
/** UTC padding around the local calendar day so the query is a safe superset. */
const WINDOW_LEAD_MS = 15 * 60 * 60 * 1000; // covers UTC−14…+12 day starts
const WINDOW_TRAIL_MS = 39 * 60 * 60 * 1000;
/** Defensive read caps — dense Apple-Health accounts emit ~1 sample/second. */
const MAX_DAY_PULSE_ROWS = 6000;
const MAX_DAY_STEP_ROWS = 2000;
/** The DTO both the route and the digest read. */
export interface IntradayPulseResult {
dateKey: string;
timezone: string;
bucketMinutes: number;
series: IntradayHrBucket[];
baseline: number | null;
baselineSource: "resting" | "proxy" | "none";
tension: TensionWindow | null;
}
/** Median (p50), or null on an empty series. */
function median(values: number[]): number | null {
if (values.length === 0) return null;
return Math.round(percentile(values, 50) * 10) / 10;
}
/**
* Resolve the personal resting baseline and its maturity. Prefers the clean
* RESTING_HEART_RATE rows; falls back to the low-percentile PULSE proxy. The
* baseline is the median of the resolved daily series — robust to the odd
* outlier — and is "mature" only once at least `MIN_BASELINE_DAYS` distinct
* daily points exist.
*/
async function resolveBaseline(userId: string): Promise<{
baseline: number | null;
mature: boolean;
source: "resting" | "proxy" | "none";
}> {
const restingRows = await prisma.measurement.findMany({
where: { userId, type: "RESTING_HEART_RATE", deletedAt: null },
orderBy: { measuredAt: "desc" },
take: 90,
select: { value: true, measuredAt: true },
});
// Only reach for the heavier PULSE-history read when resting rows are thin.
const pulseHistory =
restingRows.length >= MIN_BASELINE_DAYS
? []
: await prisma.measurement.findMany({
where: { userId, type: "PULSE", deletedAt: null },
orderBy: { measuredAt: "desc" },
take: 365,
select: { value: true, measuredAt: true },
});
const resolved = resolveRestingPulseSeries({
restingSamples: restingRows.map((r) => ({
measuredAt: r.measuredAt,
value: r.value,
})),
pulseSamples: pulseHistory.map((r) => ({
measuredAt: r.measuredAt,
value: r.value,
})),
});
const recent = resolved.series.slice(-30).map((p) => p.value);
return {
baseline: median(recent),
mature: resolved.series.length >= MIN_BASELINE_DAYS,
source: resolved.which,
};
}
/** Clamp a workout's local span to [0, 1440) minutes on `dateKey`, or null. */
function workoutToInterval(
startedAt: Date,
endedAt: Date,
dateKey: string,
localOf: LocalResolver,
): WorkoutInterval | null {
const s = localOf(startedAt);
const e = localOf(endedAt);
// Reject spans entirely outside the day (both ends on another calendar day
// on the same side); otherwise clamp an overhanging end into the day.
const startMinute =
s.dayKey === dateKey ? s.minuteOfDay : s.dayKey < dateKey ? 0 : -1;
const endMinute =
e.dayKey === dateKey ? e.minuteOfDay : e.dayKey > dateKey ? 24 * 60 : -1;
if (startMinute < 0 || endMinute < 0 || endMinute <= startMinute) return null;
return { startMinute, endMinute };
}
/**
* Compute the intraday pulse shape + (cautious) tension window for one local
* day. Deterministic given the DB state; performs no AI/provider call.
*/
export async function loadIntradayPulse(
userId: string,
timezone: string,
dateKey: string,
): Promise<IntradayPulseResult> {
const localOf = makeLocalResolver(timezone);
const anchorMs = new Date(`${dateKey}T00:00:00.000Z`).getTime();
const start = new Date(anchorMs - WINDOW_LEAD_MS);
const end = new Date(anchorMs + WINDOW_TRAIL_MS);
const [pulseRows, stepRows, workoutRows, baseline] = await Promise.all([
prisma.measurement.findMany({
where: {
userId,
type: "PULSE",
deletedAt: null,
measuredAt: { gte: start, lte: end },
},
orderBy: { measuredAt: "asc" },
take: MAX_DAY_PULSE_ROWS,
select: { value: true, measuredAt: true },
}),
prisma.measurement.findMany({
where: {
userId,
type: "ACTIVITY_STEPS",
deletedAt: null,
measuredAt: { gte: start, lte: end },
},
orderBy: { measuredAt: "asc" },
take: MAX_DAY_STEP_ROWS,
select: { value: true, measuredAt: true, externalId: true },
}),
prisma.workout.findMany({
where: {
userId,
startedAt: { lt: end },
endedAt: { gt: start },
},
select: { startedAt: true, endedAt: true },
}),
resolveBaseline(userId),
]);
const series = computeTenMinuteMeanSeries(
pulseRows.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
dateKey,
localOf,
);
// Intraday step buckets — EXCLUDE the consolidated `stats:` daily totals, or a
// single day-sum row would dump the whole day's steps into one bucket and
// wrongly mark an at-rest stretch as moving.
const stepBuckets = new Map<number, number>();
for (const row of stepRows) {
if (row.externalId?.startsWith("stats:")) continue;
const local = localOf(row.measuredAt);
if (local.dayKey !== dateKey) continue;
const startMinute =
Math.floor(local.minuteOfDay / BUCKET_MINUTES) * BUCKET_MINUTES;
stepBuckets.set(
startMinute,
(stepBuckets.get(startMinute) ?? 0) + row.value,
);
}
const workouts = workoutRows
.map((w) => workoutToInterval(w.startedAt, w.endedAt, dateKey, localOf))
.filter((w): w is WorkoutInterval => w !== null);
const tension = detectTensionWindow({
buckets: series,
baseline: baseline.baseline,
baselineMature: baseline.mature,
stepBuckets,
workouts,
hrvConfirmMinutes: [],
});
return {
dateKey,
timezone,
bucketMinutes: BUCKET_MINUTES,
series,
baseline: baseline.baseline,
baselineSource: baseline.source,
tension,
};
}
+290
View File
@@ -0,0 +1,290 @@
/**
* S11 — intraday pulse shape + the elevated-at-rest ("tension") window.
*
* Two deterministic, IO-free computations that reveal the SHAPE of a single
* day's heart rate and, cautiously, whether a stretch of it looks like tension
* rather than exercise:
*
* 1. `computeTenMinuteMeanSeries` — folds the day's raw PULSE samples into
* 10-minute means in the user's local time. Computed ON DEMAND for the
* viewed day (read-swap; the same fold the hourly-mean retention uses),
* never persisted as 10-min rollups for all history.
* 2. `detectTensionWindow` — the differentiator. The tension signal is NOT
* raw HR (that conflates a workout with stress): it is heart rate
* SUSTAINED above the user's personal RESTING baseline WHILE movement is
* low — the "elevated-at-rest window". A workout-overlapping or
* high-step bucket can never qualify, so a hard run does not read as
* tension. Where intraday HRV exists (WHOOP), it is surfaced as a
* confirming flag but is never required.
*
* The launch posture is deliberately conservative — "says less than it could".
* A window is only reported when baseline maturity, per-bucket sample density,
* a mandatory low-movement gate, and a minimum sustained duration ALL hold.
* Silence over speculation. This is descriptive context, never a clinical
* stress diagnosis.
*
* Pure & deterministic — unit-tested in `__tests__/intraday-pulse.test.ts`.
*/
import { formatInUserTz } from "@/lib/tz/format";
/** Intraday bucket width, in minutes. Ten minutes reveals the day's shape. */
export const BUCKET_MINUTES = 10;
/**
* A 10-minute bucket needs at least this many raw samples before it is
* TRUSTED as a mean. A lone reading is a point, not a bucket — trusting it
* would let a single spike masquerade as a sustained stretch. Sub-floor
* buckets are treated as gaps that BREAK a candidate run (density gate).
*/
export const MIN_SAMPLES_PER_BUCKET = 2;
/**
* How far above the resting baseline a bucket's mean must sit to count as
* "elevated". Twelve bpm keeps normal ambient drift (standing, a warm room,
* digestion) below the line — only a genuine, sustained lift clears it. The
* bar is deliberately high; under-flagging is the intended failure mode.
*/
export const ELEVATION_MARGIN_BPM = 12;
/**
* Steps within a 10-minute bucket at or above this count mark the bucket as
* MOVING — a walk the raw HR would otherwise read as an elevated-at-rest
* stretch. Sixty steps in ten minutes is a slow amble; anything at or above
* it disqualifies the bucket from the tension window.
*/
export const STEP_MOVEMENT_THRESHOLD = 60;
/**
* A day needs at least this many step buckets before a low / absent step
* reading can be TRUSTED as "genuinely not moving" rather than "steps simply
* weren't recorded". Below this coverage the low-movement gate cannot be
* satisfied and no window is reported — the movement gate is mandatory, and a
* missing step stream is silence, not evidence of rest.
*/
export const MIN_STEP_COVERAGE_BUCKETS = 6;
/**
* The minimum run of consecutive qualifying buckets before a window is
* reported: three 10-minute buckets = 30 sustained minutes. A single elevated
* bucket (a flight of stairs, a phone call) is noise; half an hour of
* elevated-at-rest heart rate is a shape worth a cautious word.
*/
export const MIN_SUSTAINED_BUCKETS = 3;
/**
* Minimum number of resting-baseline days before the baseline is mature enough
* to judge a single day against. A baseline built from two readings is not a
* personal norm yet — until it matures, no window is reported.
*/
export const MIN_BASELINE_DAYS = 7;
/** A timestamped heart-rate sample (raw PULSE). */
export interface IntradaySample {
measuredAt: Date;
value: number;
}
/** A 10-minute mean bucket, anchored on its start minute-of-local-day. */
export interface IntradayHrBucket {
/** Minutes since local midnight at the bucket's start (0, 10, 20, …). */
startMinute: number;
/** Mean of the raw samples that fell in the bucket. */
mean: number;
/** Raw sample count — buckets below `MIN_SAMPLES_PER_BUCKET` are gaps. */
count: number;
}
/** A workout's local-day span, in minutes since local midnight. */
export interface WorkoutInterval {
startMinute: number;
endMinute: number;
}
/** Coarse part-of-day label a window's midpoint falls in. */
export type PartOfDay = "morning" | "afternoon" | "evening" | "night";
/** A detected elevated-at-rest window — a cautious tension marker. */
export interface TensionWindow {
/** Window start / end, minutes since local midnight. */
startMinute: number;
endMinute: number;
/** Part of day the window's midpoint falls in (drives the copy). */
partOfDay: PartOfDay;
/** Mean HR across the window's buckets. */
meanHr: number;
/** The resting baseline the window was judged against. */
baseline: number;
/** True when intraday HRV independently confirmed the stretch (WHOOP). */
hrvConfirmed: boolean;
}
/** Local wall-clock projection of an instant: day key + minute-of-day. */
export interface LocalWallClock {
dayKey: string;
minuteOfDay: number;
}
/** Resolves an instant to the viewer's local day key + minute-of-day. */
export type LocalResolver = (d: Date) => LocalWallClock;
/**
* Build a `LocalResolver` for a timezone from the shared tz formatter. Kept
* here so the pure computations stay tz-correct without every caller
* re-deriving the wall-clock math; tests inject their own resolver for
* deterministic, tz-free fixtures.
*/
export function makeLocalResolver(tz: string): LocalResolver {
return (d: Date) => {
// "YYYY-MM-DD HH:mm" in the target zone — one formatter, no drift.
const wall = formatInUserTz(d, tz, "datetime");
const [dayKey, clock] = wall.split(" ");
const [hh, mm] = clock.split(":");
return {
dayKey,
minuteOfDay: parseInt(hh, 10) * 60 + parseInt(mm, 10),
};
};
}
/** Round to one decimal — keeps the mean readable without false precision. */
function round1(n: number): number {
return Math.round(n * 10) / 10;
}
/**
* Fold a day's raw PULSE samples into ascending 10-minute mean buckets. Only
* samples whose LOCAL day matches `dayKey` participate, so a 23:55-local
* reading never bleeds into the next day's shape. Empty or sparse buckets are
* simply absent — the series is the buckets that exist, not a padded grid.
*/
export function computeTenMinuteMeanSeries(
samples: ReadonlyArray<IntradaySample>,
dayKey: string,
localOf: LocalResolver,
): IntradayHrBucket[] {
const acc = new Map<number, { sum: number; count: number }>();
for (const s of samples) {
const local = localOf(s.measuredAt);
if (local.dayKey !== dayKey) continue;
const startMinute =
Math.floor(local.minuteOfDay / BUCKET_MINUTES) * BUCKET_MINUTES;
const bucket = acc.get(startMinute);
if (bucket) {
bucket.sum += s.value;
bucket.count += 1;
} else {
acc.set(startMinute, { sum: s.value, count: 1 });
}
}
return [...acc.entries()]
.map(([startMinute, { sum, count }]) => ({
startMinute,
mean: round1(sum / count),
count,
}))
.sort((a, b) => a.startMinute - b.startMinute);
}
/** Coarse part-of-day for a minute-of-day (drives the cautious copy). */
export function partOfDayForMinute(minute: number): PartOfDay {
const hour = Math.floor(minute / 60);
if (hour < 6) return "night";
if (hour < 12) return "morning";
if (hour < 18) return "afternoon";
return "evening";
}
/** Whether a 10-minute bucket [start, start+10) intersects any workout. */
function overlapsWorkout(
startMinute: number,
workouts: ReadonlyArray<WorkoutInterval>,
): boolean {
const end = startMinute + BUCKET_MINUTES;
return workouts.some((w) => w.startMinute < end && w.endMinute > startMinute);
}
export interface DetectTensionInput {
/** The day's 10-minute HR buckets (any order; re-sorted internally). */
buckets: ReadonlyArray<IntradayHrBucket>;
/** Personal resting baseline (bpm), or null when unknown. */
baseline: number | null;
/** Whether the baseline is built from enough history to trust. */
baselineMature: boolean;
/** Steps per 10-minute bucket, keyed by bucket start minute. */
stepBuckets: ReadonlyMap<number, number>;
/** Workout spans (local minutes) — buckets overlapping one never qualify. */
workouts: ReadonlyArray<WorkoutInterval>;
/** Bucket start-minutes where intraday HRV independently confirms (WHOOP). */
hrvConfirmMinutes?: ReadonlyArray<number>;
}
/**
* Detect at most ONE elevated-at-rest window for the day, or null. A bucket
* qualifies only when it is (a) elevated ≥ `baseline + ELEVATION_MARGIN_BPM`,
* (b) NOT overlapping a workout, and (c) low-movement — its step count is below
* `STEP_MOVEMENT_THRESHOLD` AND the day has enough step coverage to trust that
* reading. The longest run of ≥ `MIN_SUSTAINED_BUCKETS` consecutive qualifying
* buckets becomes the window (earliest wins a tie). Every gate must hold; when
* any fails, the honest answer is no window.
*/
export function detectTensionWindow(
input: DetectTensionInput,
): TensionWindow | null {
const { baseline, baselineMature, stepBuckets, workouts } = input;
// Gate 1 — baseline maturity. No personal norm yet ⇒ say nothing.
if (baseline == null || !baselineMature) return null;
// Gate 2 — movement is MANDATORY and step-based. Without enough step
// coverage a low/absent reading is "not recorded", not "not moving", so the
// low-movement gate can never be satisfied and no window is reported.
if (stepBuckets.size < MIN_STEP_COVERAGE_BUCKETS) return null;
const threshold = baseline + ELEVATION_MARGIN_BPM;
const hrvMinutes = new Set(input.hrvConfirmMinutes ?? []);
// Only density-passing buckets participate; a sparse bucket is a gap that
// breaks a run (we cannot assert "sustained" across missing data).
const valid = [...input.buckets]
.filter((b) => b.count >= MIN_SAMPLES_PER_BUCKET)
.sort((a, b) => a.startMinute - b.startMinute);
const qualifies = (b: IntradayHrBucket): boolean => {
if (b.mean < threshold) return false;
if (overlapsWorkout(b.startMinute, workouts)) return false;
const steps = stepBuckets.get(b.startMinute) ?? 0;
return steps < STEP_MOVEMENT_THRESHOLD;
};
// Longest run of CONSECUTIVE qualifying buckets (consecutive = adjacent
// 10-minute slots; any non-qualifying or missing slot breaks the run).
let best: IntradayHrBucket[] = [];
let run: IntradayHrBucket[] = [];
for (const b of valid) {
const prev = run[run.length - 1];
const consecutive =
prev && b.startMinute === prev.startMinute + BUCKET_MINUTES;
if (qualifies(b)) {
run = consecutive ? [...run, b] : [b];
} else {
run = [];
}
if (run.length > best.length) best = run;
}
if (best.length < MIN_SUSTAINED_BUCKETS) return null;
const startMinute = best[0].startMinute;
const endMinute = best[best.length - 1].startMinute + BUCKET_MINUTES;
const meanHr = round1(best.reduce((sum, b) => sum + b.mean, 0) / best.length);
const hrvConfirmed = best.some((b) => hrvMinutes.has(b.startMinute));
return {
startMinute,
endMinute,
partOfDay: partOfDayForMinute(Math.floor((startMinute + endMinute) / 2)),
meanHr,
baseline,
hrvConfirmed,
};
}
+245 -3
View File
@@ -5,14 +5,18 @@ import type { DailyBriefing } from "@/lib/ai/schema";
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
import {
buildDailyDigest,
COACH_CHECKIN_KEEP_INTENT,
COACH_CHECKIN_LETGO_INTENT,
COACH_CHECKIN_RESURFACE_DAYS,
COACH_CHECKIN_REVIEW_DAYS,
MAX_WORTH_A_LOOK,
type DailyDigestCoachPlan,
type DailyDigestInput,
} from "@/lib/daily/digest";
import {
COACH_CHECKIN_KEEP_INTENT,
COACH_CHECKIN_LETGO_INTENT,
COACH_CHECKIN_REVIEW_DAYS,
} from "@/lib/daily/coach-checkin-intents";
import type { Milestone } from "@/lib/daily/milestones";
import type { PriorityItem } from "@/lib/daily/priority-item";
const t = getServerTranslator("en").t;
const NOW = new Date("2026-07-16T09:00:00.000Z");
@@ -57,6 +61,7 @@ function input(over: Partial<DailyDigestInput> = {}): DailyDigestInput {
syncIssues: [],
preventiveDue: [],
coachPlans: [],
tensionWindow: null,
...over,
};
}
@@ -413,3 +418,240 @@ describe("buildDailyDigest — coach check-in (S3)", () => {
expect(d.worthALook[0].kind).toBe("dose_window");
});
});
const milestone = (
d: ReturnType<typeof buildDailyDigest>,
): PriorityItem | undefined => d.worthALook.find((i) => i.kind === "milestone");
const RECORD_MILESTONE: Milestone = {
kind: "record_first",
metricType: "RESTING_HEART_RATE",
sinceDate: "2026-07-16",
copyKey: "daily.milestone.record",
};
describe("S12 — the milestone reward card", () => {
it("emits ONE calm success card when a milestone was freshly reached", () => {
const d = buildDailyDigest(input({ milestone: RECORD_MILESTONE }), t);
const item = milestone(d);
expect(item).toBeDefined();
expect(item?.status).toBe("success");
expect(item?.title.length).toBeGreaterThan(0);
expect(item?.body?.length).toBeGreaterThan(0);
// Single calm action deep-linking into the metric's insight.
expect(item?.actions).toHaveLength(1);
expect(item?.actions[0].intent).toBe("milestone.view");
expect(item?.actions[0].href).toBe("/insights/resting-pulse");
// One per day — never two milestone cards.
expect(d.worthALook.filter((i) => i.kind === "milestone")).toHaveLength(1);
});
it("shows nothing when no milestone was reached today (data-gated)", () => {
expect(
milestone(buildDailyDigest(input({ milestone: null }), t)),
).toBeUndefined();
expect(milestone(buildDailyDigest(input(), t))).toBeUndefined();
});
it("is suppressed when the insights module is off (module-gated)", () => {
const d = buildDailyDigest(
input({ milestone: RECORD_MILESTONE, modules: { insights: false } }),
t,
);
expect(milestone(d)).toBeUndefined();
});
it("sits just below an overdue dose and above ambient items", () => {
const d = buildDailyDigest(
input({
milestone: RECORD_MILESTONE,
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
syncIssues: [{ integration: "withings", state: "error_reauth" }],
}),
t,
);
expect(d.worthALook[0].kind).toBe("dose_window");
expect(d.worthALook[1].kind).toBe("milestone");
expect(d.worthALook[2].kind).toBe("sync_issue");
});
it("carries no streak / loss vocabulary in its copy", () => {
const item = milestone(
buildDailyDigest(input({ milestone: RECORD_MILESTONE }), t),
);
const forbidden = /streak|flame|broke|broken|lost|missed|fail/i;
expect(item?.title).not.toMatch(forbidden);
expect(item?.body ?? "").not.toMatch(forbidden);
});
});
describe("buildDailyDigest — S11 tension_window item", () => {
function tension(d: ReturnType<typeof buildDailyDigest>) {
return d.worthALook.find((i) => i.kind === "tension_window");
}
it("emits a calm, non-diagnostic tension card when a window is present", () => {
const d = buildDailyDigest(
input({ tensionWindow: { partOfDay: "afternoon" } }),
t,
);
const item = tension(d);
expect(item).toBeDefined();
expect(item?.status).toBe("info");
expect(item?.actions[0].intent).toBe("pulse.view");
expect(item?.actions[0].href).toBe("/insights/pulse");
expect(item?.body).toContain("afternoon");
});
it("emits nothing when there is no window (honest-absent)", () => {
const d = buildDailyDigest(input({ tensionWindow: null }), t);
expect(tension(d)).toBeUndefined();
});
it("stays silent when the insights module is off", () => {
const d = buildDailyDigest(
input({
modules: { insights: false },
tensionWindow: { partOfDay: "morning" },
}),
t,
);
expect(tension(d)).toBeUndefined();
});
it("yields the bounded rail to time-sensitive actions first", () => {
const d = buildDailyDigest(
input({
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
syncIssues: [
{ integration: "withings", state: "error_reauth" },
{ integration: "moodlog", state: "parked" },
],
tensionWindow: { partOfDay: "evening" },
}),
t,
);
// dose + 2 sync fill the cap; the calm tension marker waits.
expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK);
expect(tension(d)).toBeUndefined();
});
});
describe("buildDailyDigest — ecg_new_recording (S10)", () => {
const ecgItem = (d: ReturnType<typeof buildDailyDigest>) =>
d.worthALook.find((i) => i.kind === "ecg_new_recording");
it("emits ONE calm item for a recording within the last day", () => {
const d = buildDailyDigest(
input({
latestEcg: {
recordedAt: new Date(NOW.getTime() - 3 * 60 * 60 * 1000),
deviceVerdict: "NOT_DETECTED",
},
}),
t,
);
const item = ecgItem(d);
expect(item).toBeDefined();
expect(item?.status).toBe("info");
expect(item?.moduleKey).toBe("insights");
// Single action, deep-linking the ECG viewer.
expect(item?.actions).toHaveLength(1);
expect(item?.actions[0].intent).toBe("ecg.view");
expect(item?.actions[0].href).toBe("/insights#ecg");
});
it("attributes the verdict to the DEVICE (never a HealthLog reading)", () => {
const d = buildDailyDigest(
input({
latestEcg: {
recordedAt: new Date(NOW.getTime() - 60 * 60 * 1000),
deviceVerdict: "IRREGULAR",
},
}),
t,
);
const item = ecgItem(d);
// Copy leads with the device as the actor, echoes only its verdict, and
// never claims HealthLog interpreted the trace.
expect(item?.body).toContain("Your device recorded");
expect(item?.body).toContain("possible irregular rhythm");
expect(item?.body).not.toMatch(/we (detected|found|think)|HealthLog/i);
});
it("does not emit for an OLD recording (outside the last-day window)", () => {
const d = buildDailyDigest(
input({
latestEcg: {
recordedAt: new Date(NOW.getTime() - 2 * DAY),
deviceVerdict: "IRREGULAR",
},
}),
t,
);
expect(ecgItem(d)).toBeUndefined();
});
it("does not emit a future-dated recording (clock-skew guard)", () => {
const d = buildDailyDigest(
input({
latestEcg: {
recordedAt: new Date(NOW.getTime() + 60 * 60 * 1000),
deviceVerdict: "NOT_DETECTED",
},
}),
t,
);
expect(ecgItem(d)).toBeUndefined();
});
it("does not emit when the insights module is off", () => {
const d = buildDailyDigest(
input({
modules: { insights: false },
latestEcg: {
recordedAt: new Date(NOW.getTime() - 60 * 60 * 1000),
deviceVerdict: "IRREGULAR",
},
}),
t,
);
expect(ecgItem(d)).toBeUndefined();
});
it("emits nothing when there is no recent recording", () => {
const d = buildDailyDigest(input({ latestEcg: null }), t);
expect(ecgItem(d)).toBeUndefined();
});
it("uses the calm, verdict-less body when the device gave no verdict", () => {
const d = buildDailyDigest(
input({
latestEcg: {
recordedAt: new Date(NOW.getTime() - 60 * 60 * 1000),
deviceVerdict: null,
},
}),
t,
);
const item = ecgItem(d);
expect(item?.body).toBe(
"Your device recorded a new ECG — it's ready to view.",
);
});
it("carries no waveform / sample data on the item or its input DTO", () => {
const latestEcg = {
recordedAt: new Date(NOW.getTime() - 60 * 60 * 1000),
deviceVerdict: "NOT_DETECTED" as const,
};
// The input DTO is verdict + recordedAt only — no waveform channel exists.
expect(Object.keys(latestEcg).sort()).toEqual([
"deviceVerdict",
"recordedAt",
]);
const d = buildDailyDigest(input({ latestEcg }), t);
const serialised = JSON.stringify(ecgItem(d));
expect(serialised).not.toMatch(/waveform|sample|signal|voltage|microvolt/i);
});
});
+242
View File
@@ -0,0 +1,242 @@
import { describe, it, expect } from "vitest";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { detectStreak, type StreakPoint } from "@/lib/insights/streak-detector";
import {
MILESTONE_KINDS,
milestoneCopy,
milestoneFromRecord,
milestoneHref,
milestonesFromStreak,
selectFreshMilestone,
SUSTAINED_WEEK_THRESHOLDS,
type Milestone,
} from "@/lib/daily/milestones";
const t = getServerTranslator("en").t;
/** Build a consecutive-day UTC series ending at `endDay` (values[last] === endDay). */
function seriesEndingAt(values: number[], endDay: string): StreakPoint[] {
const endSerial =
Date.UTC(...(dayParts(endDay) as [number, number, number])) / 86_400_000;
return values.map((value, i) => ({
day: keyFromSerial(endSerial - (values.length - 1 - i)),
value,
}));
}
function dayParts(key: string): number[] {
const [y, m, d] = key.split("-").map(Number);
return [y, m - 1, d];
}
function keyFromSerial(serial: number): string {
return new Date(serial * 86_400_000).toISOString().slice(0, 10);
}
function shift(day: string, delta: number): string {
const serial =
Date.UTC(...(dayParts(day) as [number, number, number])) / 86_400_000;
return keyFromSerial(serial + delta);
}
const TODAY = "2026-07-16";
describe("milestonesFromStreak — folds the real detectStreak engine", () => {
it("emits a fresh sustained_in_range milestone the day a week boundary is reached", () => {
// Exactly 7 consecutive in-band days ending today → the 1-week durable
// state is REACHED today. Uses the real detectStreak result, not a stub.
const series = seriesEndingAt([60, 60, 60, 60, 60, 60, 60], TODAY);
const result = detectStreak(series);
expect(result.inBand).toBe(true);
expect(result.streakDays).toBe(7);
const milestones = milestonesFromStreak(
"RESTING_HEART_RATE",
result,
TODAY,
);
const week1 = milestones.find((m) => m.copyKey.endsWith("week1"));
expect(week1).toBeDefined();
expect(week1?.kind).toBe("sustained_in_range");
expect(week1?.sinceDate).toBe(TODAY); // reached today → fresh
// Reached-once: the same durable state is NOT fresh the next day.
expect(selectFreshMilestone(milestones, TODAY)?.copyKey).toContain("week1");
expect(selectFreshMilestone(milestones, shift(TODAY, 1))).toBeNull();
});
it("does not re-fire a week boundary reached on an earlier day", () => {
// 8 in-band days: the 1-week state was reached YESTERDAY (streak 8 > 7).
const series = seriesEndingAt([60, 60, 60, 60, 60, 60, 60, 60], TODAY);
const result = detectStreak(series);
expect(result.streakDays).toBe(8);
const milestones = milestonesFromStreak(
"RESTING_HEART_RATE",
result,
TODAY,
);
const week1 = milestones.find((m) => m.copyKey.endsWith("week1"));
expect(week1?.sinceDate).toBe(shift(TODAY, -1)); // reached yesterday
expect(selectFreshMilestone(milestones, TODAY)).toBeNull(); // not fresh today
});
it("emits a fresh return_to_baseline the day the return settles (daysInside === MIN_IN_RUN)", () => {
// Prior out-of-band run (90s) then a settled 2-day in-band run ending today.
const series = seriesEndingAt(
[60, 60, 60, 60, 60, 90, 90, 90, 60, 60],
TODAY,
);
const result = detectStreak(series);
expect(result.returnEvent).toBeDefined();
expect(result.returnEvent?.daysInside).toBe(2);
const milestones = milestonesFromStreak(
"RESTING_HEART_RATE",
result,
TODAY,
);
const ret = milestones.find((m) => m.kind === "return_to_baseline");
expect(ret).toBeDefined();
expect(ret?.sinceDate).toBe(TODAY);
expect(selectFreshMilestone(milestones, TODAY)?.kind).toBe(
"return_to_baseline",
);
// Reached-once: gone the next day.
expect(selectFreshMilestone(milestones, shift(TODAY, 1))).toBeNull();
});
it("emits NOTHING while the metric is currently out of band — no 'broken' state", () => {
// Latest placement is out-of-band (a lapse). A lapse is invisible: no
// milestone at all, and certainly no negative one.
const series = seriesEndingAt([60, 60, 60, 60, 60, 60, 60, 90, 90], TODAY);
const result = detectStreak(series);
expect(result.inBand).toBe(false);
expect(milestonesFromStreak("RESTING_HEART_RATE", result, TODAY)).toEqual(
[],
);
});
it("omits quietly for an empty series (no band, no guess)", () => {
const result = detectStreak([]);
expect(result.latestPlacement).toBeNull();
expect(milestonesFromStreak("WEIGHT", result, TODAY)).toEqual([]);
});
it("emits no milestone from an in-band run shorter than a week", () => {
// A short in-band run (3 days) has reached no durable week boundary yet.
const series = seriesEndingAt([60, 60, 60], TODAY);
const result = detectStreak(series);
expect(result.inBand).toBe(true);
expect(result.streakDays).toBe(3);
expect(milestonesFromStreak("WEIGHT", result, TODAY)).toEqual([]);
});
});
describe("milestoneFromRecord — folds a PersonalRecord achieved day", () => {
it("is fresh only on the day the record was achieved", () => {
const m = milestoneFromRecord("HEART_RATE_VARIABILITY", TODAY);
expect(m.kind).toBe("record_first");
expect(selectFreshMilestone([m], TODAY)?.kind).toBe("record_first");
expect(selectFreshMilestone([m], shift(TODAY, 1))).toBeNull();
});
});
describe("selectFreshMilestone — reached-once, one per day, most meaningful", () => {
it("prefers a personal best, then a return, then a sustained state", () => {
const record: Milestone = milestoneFromRecord("RESTING_HEART_RATE", TODAY);
const ret: Milestone = {
kind: "return_to_baseline",
metricType: "WEIGHT",
sinceDate: TODAY,
copyKey: "daily.milestone.returnToBaseline",
};
const sustained: Milestone = {
kind: "sustained_in_range",
metricType: "WEIGHT",
sinceDate: TODAY,
copyKey: "daily.milestone.sustainedInRange.week1",
};
expect(selectFreshMilestone([sustained, ret, record], TODAY)?.kind).toBe(
"record_first",
);
expect(selectFreshMilestone([sustained, ret], TODAY)?.kind).toBe(
"return_to_baseline",
);
expect(selectFreshMilestone([sustained], TODAY)?.kind).toBe(
"sustained_in_range",
);
});
it("returns null when no candidate was reached today", () => {
const stale: Milestone = milestoneFromRecord("WEIGHT", shift(TODAY, -3));
expect(selectFreshMilestone([stale], TODAY)).toBeNull();
expect(selectFreshMilestone([], TODAY)).toBeNull();
});
});
describe("copy is calm and never negative", () => {
const cases: Milestone[] = [
milestoneFromRecord("RESTING_HEART_RATE", TODAY),
{
kind: "return_to_baseline",
metricType: "RESTING_HEART_RATE",
sinceDate: TODAY,
copyKey: "daily.milestone.returnToBaseline",
},
...SUSTAINED_WEEK_THRESHOLDS.map((w): Milestone => ({
kind: "sustained_in_range",
metricType: "RESTING_HEART_RATE",
sinceDate: TODAY,
copyKey: `daily.milestone.sustainedInRange.week${w}`,
})),
];
it("resolves a real title + body with the metric name interpolated, no leftover placeholder", () => {
for (const m of cases) {
const { title, body } = milestoneCopy(m, t);
expect(title.length).toBeGreaterThan(0);
expect(body.length).toBeGreaterThan(0);
expect(title).not.toContain("{metric}");
expect(body).not.toContain("{metric}");
expect(body.toLowerCase()).toContain("resting heart rate");
}
});
it("uses no streak / loss / failure vocabulary anywhere", () => {
const forbidden =
/streak|flame|broke|broken|lost|don'?t lose|missed|fail|penalt/i;
for (const m of cases) {
const { title, body } = milestoneCopy(m, t);
expect(title).not.toMatch(forbidden);
expect(body).not.toMatch(forbidden);
}
});
it("every milestone deep-links to an insight surface", () => {
for (const m of cases) {
expect(milestoneHref(m)).toMatch(/^\/insights/);
}
});
});
describe("the type carries no running counter", () => {
it("a Milestone has only kind / metricType / sinceDate / copyKey", () => {
const m = milestoneFromRecord("WEIGHT", TODAY);
expect(Object.keys(m).sort()).toEqual([
"copyKey",
"kind",
"metricType",
"sinceDate",
]);
});
it("exposes exactly the three durable kinds", () => {
expect([...MILESTONE_KINDS]).toEqual([
"record_first",
"return_to_baseline",
"sustained_in_range",
]);
});
});
+28
View File
@@ -0,0 +1,28 @@
/**
* Coach check-in constants shared by BOTH the server-side digest builder and
* the client Today surface + hooks.
*
* They live in this tiny, dependency-free module on purpose: `digest.ts` (their
* former home) transitively reaches the database through the milestone/streak
* baseline engine, so importing a constant from it would drag the whole server
* builder — and the Postgres driver — into a client bundle (a Turbopack
* `Can't resolve 'net'/'tls'/'dns'` build failure). Keeping the constants here
* lets the Today hero and the check-in hook import them with zero server reach.
*/
/**
* Days after a plan's activation / last review it becomes due for a look-back.
* One source of truth for the digest builder AND the PATCH route that re-arms it.
*/
export const COACH_CHECKIN_REVIEW_DAYS = 7;
/**
* Closed allowlist of the check-in card's two MUTATING intents. The generic,
* id-less `PriorityCard` forwards only a single `intent` string, so the target
* plan id is appended after the ":" — the Today handler recovers it and PATCHes
* the existing plan-lifecycle route. "Adjust" is navigation (an `href` into the
* coach), so it carries no plan id and never mutates.
*/
export const COACH_CHECKIN_KEEP_INTENT = "coach.checkin.keep";
export const COACH_CHECKIN_LETGO_INTENT = "coach.checkin.letGo";
export const COACH_CHECKIN_ADJUST_INTENT = "coach.checkin.adjust";
+196 -4
View File
@@ -27,6 +27,17 @@ import {
MAX_PRIORITY_ACTIONS,
type PriorityItem,
} from "@/lib/daily/priority-item";
import {
milestoneCopy,
milestoneHref,
type Milestone,
} from "@/lib/daily/milestones";
import {
COACH_CHECKIN_REVIEW_DAYS,
COACH_CHECKIN_KEEP_INTENT,
COACH_CHECKIN_LETGO_INTENT,
COACH_CHECKIN_ADJUST_INTENT,
} from "@/lib/daily/coach-checkin-intents";
/** At most three rail items — a glance, never a wall (§2.5, never padded). */
export const MAX_WORTH_A_LOOK = 3;
@@ -42,7 +53,7 @@ const MS_PER_DAY = 86_400_000;
* ones the coach explicitly dated. The PATCH-to-`active` route defaults
* `reviewDate` to `+COACH_CHECKIN_REVIEW_DAYS`; this constant is the one source.
*/
export const COACH_CHECKIN_REVIEW_DAYS = 7;
// COACH_CHECKIN_REVIEW_DAYS lives in ./coach-checkin-intents (client-safe).
/**
* After a check-in comes due, it resurfaces for at most this many days before
@@ -60,9 +71,9 @@ export const COACH_CHECKIN_RESURFACE_DAYS = 14;
* and PATCHes the existing plan-lifecycle route. "Adjust" is navigation (an
* `href` into the coach), so it carries no plan id and never mutates here.
*/
export const COACH_CHECKIN_KEEP_INTENT = "coach.checkin.keep";
export const COACH_CHECKIN_LETGO_INTENT = "coach.checkin.letGo";
export const COACH_CHECKIN_ADJUST_INTENT = "coach.checkin.adjust";
// The check-in intents live in ./coach-checkin-intents (client-safe) and are
// imported above, so the client Today surface can use them without pulling this
// server-side builder into its bundle.
type Translate = ServerTranslator["t"];
@@ -92,6 +103,29 @@ export interface DailyDigestPreventiveDue {
label: string;
}
/**
* S11 — a confident elevated-at-rest ("tension") window for the day, already
* detected server-side (`loadIntradayPulse`) under its full confidence gate.
* The builder only formats it; the signal correctness lives in the analytics
* layer, and a null here means the honest "no window" — nothing is emitted.
*/
export interface DailyDigestTensionWindow {
/** Part of day the window's midpoint fell in — drives the cautious copy. */
partOfDay: "morning" | "afternoon" | "evening" | "night";
}
/**
* S10 — the latest ECG recording, for the `ecg_new_recording` rail item. Only
* ever the DEVICE's verdict + when it was recorded — NEVER the waveform (the
* DTO carries no samples). The builder decides "new" from `recordedAt`; the
* card attributes any verdict to the recording device.
*/
export interface DailyDigestEcg {
recordedAt: Date;
/** The recording device's OWN verdict, or null when unclassified. */
deviceVerdict: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
}
/**
* A standing coach plan the IO seam offers as a check-in candidate (§2.3). The
* builder computes due-ness deterministically from the plain columns — it never
@@ -135,6 +169,21 @@ export interface DailyDigestInput {
preventiveDue: DailyDigestPreventiveDue[];
/** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */
coachPlans: DailyDigestCoachPlan[];
/**
* S12 — the single freshly-reached durable milestone for today, or null. The
* IO seam gathers candidates from the existing streak / personal-record
* engines and applies the reached-once gate (`selectFreshMilestone`), so the
* builder only ever sees a milestone worth celebrating today.
*/
milestone?: Milestone | null;
/** S11 — the day's detected elevated-at-rest window, or null (honest-absent). */
tensionWindow: DailyDigestTensionWindow | null;
/**
* S10 — the freshest ECG recording (device verdict + recordedAt only), or
* null. Optional so consumers that predate the ECG weave stay valid; the
* builder treats a missing value as "no recent recording".
*/
latestEcg?: DailyDigestEcg | null;
}
export interface DailyDigest {
@@ -256,6 +305,69 @@ function buildPreventiveCareItem(
};
}
/**
* S12 — the calm reward card. Emits ONE `milestone` PriorityItem when a durable
* state was reached TODAY (the IO seam already applied the reached-once gate).
* Gated on the `insights` module — the daily narrative layer that hosts it.
* Celebratory-but-quiet: `success` status, a single "view" action into the
* metric's insight, and copy that marks arrival at a state, never a maintained
* count. Shown the day reached and never again — never a "you broke it" note.
*/
function buildMilestoneItem(
milestone: Milestone | null | undefined,
modules: DigestModuleMap,
t: Translate,
): PriorityItem | null {
if (!milestone) return null;
if (!moduleEnabled(modules, "insights")) return null;
const { title, body } = milestoneCopy(milestone, t);
return {
kind: "milestone",
title,
body,
status: "success",
actions: [
{
labelKey: "daily.action.viewMilestone",
intent: "milestone.view",
href: milestoneHref(milestone),
},
],
moduleKey: "insights",
};
}
/**
* S11 — the elevated-at-rest ("tension") card. Emitted at most once per day
* (the window is already the day's single most confident stretch), gated on
* the `insights` module and on a non-null window (the analytics layer stays
* silent unless every confidence gate holds). Cautious, non-diagnostic copy:
* "possible tension", never a clinical stress verdict. The one action deep-
* links into the pulse insight where the intraday shape is charted.
*/
function buildTensionWindowItem(
window: DailyDigestTensionWindow | null,
modules: DigestModuleMap,
t: Translate,
): PriorityItem | null {
if (!moduleEnabled(modules, "insights")) return null;
if (!window) return null;
return {
kind: "tension_window",
title: t("daily.item.tensionWindow.title"),
body: t(`daily.item.tensionWindow.body.${window.partOfDay}`),
status: "info",
actions: [
{
labelKey: "daily.action.viewPulse",
intent: "pulse.view",
href: "/insights/pulse",
},
],
moduleKey: "insights",
};
}
/**
* The instant a plan's check-in came due (§2.3). A coach-pinned `reviewDate`
* wins. Otherwise, a `reviewed` plan lost its `reviewDate` to the daily sweep
@@ -336,6 +448,69 @@ function buildCoachCheckinItem(
};
}
/**
* S10 — how recently an ECG recording must have landed to count as "new" for
* the rail (§3.5.3). A calendar-day window: a recording synced within the last
* day surfaces once, then retires on its own (no persisted "seen" marker, no
* migration). The 24h window is itself the one/day cap — at most one recording
* is the freshest, and it drops off the rail after a day.
*/
const ECG_NEW_WINDOW_MS = MS_PER_DAY;
/** Device verdict → the calm, device-attributed verdict key for the card body. */
const ECG_VERDICT_KEYS: Record<
NonNullable<DailyDigestEcg["deviceVerdict"]>,
string
> = {
IRREGULAR: "daily.item.ecgNewRecording.verdict.irregular",
NOT_DETECTED: "daily.item.ecgNewRecording.verdict.notDetected",
INCONCLUSIVE: "daily.item.ecgNewRecording.verdict.inconclusive",
};
/**
* The one ECG "new recording" item (§3.5.3). Gated on the `insights` module
* (the ECG viewer's own gate). Fires ONLY when the freshest recording landed
* within the last day — a calm "a new ECG recording is ready to view" pointer
* into the viewer. NON-DIAGNOSTIC: the body echoes ONLY the RECORDING DEVICE's
* verdict, attributed to the device; HealthLog never interprets the trace (the
* DTO carries no waveform). The 24h window caps it at one/day and retires it on
* its own without a persisted seen-marker.
*/
function buildEcgNewRecordingItem(
ecg: DailyDigestEcg | null | undefined,
modules: DigestModuleMap,
now: Date,
t: Translate,
): PriorityItem | null {
if (!moduleEnabled(modules, "insights")) return null;
if (!ecg) return null;
const age = now.getTime() - ecg.recordedAt.getTime();
// Skip a future-dated row (clock skew) and anything older than the window.
if (age < 0 || age > ECG_NEW_WINDOW_MS) return null;
const verdictKey = ecg.deviceVerdict
? ECG_VERDICT_KEYS[ecg.deviceVerdict]
: null;
const body = verdictKey
? t("daily.item.ecgNewRecording.bodyVerdict", { verdict: t(verdictKey) })
: t("daily.item.ecgNewRecording.body");
return {
kind: "ecg_new_recording",
title: t("daily.item.ecgNewRecording.title"),
body,
status: "info",
actions: [
{
labelKey: "daily.action.viewEcg",
intent: "ecg.view",
href: "/insights#ecg",
},
],
moduleKey: "insights",
};
}
/**
* The push / lock-screen line: prefer the warmer cached briefing lead, fall
* back to the top signal's headline, then a deterministic score floor, then
@@ -384,6 +559,11 @@ export function buildDailyDigest(
const worthALook: PriorityItem[] = [];
const dose = buildDoseWindowItem(input.medsToday, input.modules, t);
if (dose) worthALook.push(dose);
// A freshly-reached milestone is rare and one-per-day — surface it ahead of
// the ambient sync / check-in items so the calm reward is not buried, but
// below an overdue dose (the one genuinely time-critical daily action).
const milestone = buildMilestoneItem(input.milestone, input.modules, t);
if (milestone) worthALook.push(milestone);
worthALook.push(...buildSyncIssueItems(input.syncIssues, t));
const checkin = buildCoachCheckinItem(
input.coachPlans,
@@ -392,8 +572,20 @@ export function buildDailyDigest(
t,
);
if (checkin) worthALook.push(checkin);
const ecg = buildEcgNewRecordingItem(
input.latestEcg,
input.modules,
input.now,
t,
);
if (ecg) worthALook.push(ecg);
const preventive = buildPreventiveCareItem(input.preventiveDue, t);
if (preventive) worthALook.push(preventive);
// S11 — the calm, informational tension marker sits last: it is context, not
// an action that expires, so a time-sensitive dose / sync / check-in wins the
// bounded rail ahead of it.
const tension = buildTensionWindowItem(input.tensionWindow, input.modules, t);
if (tension) worthALook.push(tension);
// Defence-in-depth: no card ever exceeds the P1 action cap.
for (const item of worthALook) {
+150
View File
@@ -26,10 +26,23 @@ import {
buildDailyDigest,
type DailyDigest,
type DailyDigestCoachPlan,
type DailyDigestEcg,
type DailyDigestPreventiveDue,
type DailyDigestScore,
type DailyDigestSyncIssue,
type DailyDigestTensionWindow,
} from "@/lib/daily/digest";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { detectStreak, type StreakPoint } from "@/lib/insights/streak-detector";
import {
MILESTONE_SALIENT_TYPES,
milestoneFromRecord,
milestonesFromStreak,
selectFreshMilestone,
type Milestone,
} from "@/lib/daily/milestones";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
/** Integration states that mean "your action is needed to keep data flowing". */
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
@@ -43,6 +56,88 @@ const CHECKIN_PLAN_STATES = ["active", "reviewed"] as const;
/** Cap on plans scanned for the (one/day) check-in candidate. */
const CHECKIN_PLAN_READ_LIMIT = 50;
/** Trailing window the milestone streak read uses (matches score-narrative). */
const MILESTONE_STREAK_WINDOW_DAYS = 30;
/** How far back to scan for a just-set personal best (a couple of days is ample). */
const MILESTONE_RECORD_LOOKBACK_MS = 2 * 24 * 60 * 60 * 1000;
/** UTC-ISO day key — the space the streak series + rollup tier already emit. */
function utcDayKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
/**
* S12 — gather today's single freshly-reached milestone (or null) from the
* engines that ALREADY exist. Two cheap, fail-soft reads reused verbatim:
* - `detectStreak` over the salient vitals' day-mean series (the exact
* `score-narrative` pattern) → return-to-range + sustained-in-range states;
* - a bounded `PersonalRecord` scan (the `pr-detection` output) → new bests.
* The reached-once gate (`selectFreshMilestone`) admits at most one, only on the
* day it was reached. Any failure leaves the reward quiet rather than guessing.
*/
async function gatherFreshMilestone(
userId: string,
now: Date,
): Promise<Milestone | null> {
const todayKey = utcDayKey(now);
const candidates: Milestone[] = [];
const coverage = await probeRollupCoverage(userId).catch(() => null);
if (coverage) {
const perMetric = await Promise.all(
MILESTONE_SALIENT_TYPES.map(async (type) => {
try {
const { points } = await readDayMeanSeries(
userId,
type,
MILESTONE_STREAK_WINDOW_DAYS,
now,
coverage,
);
if (points.length === 0) return [] as Milestone[];
const series: StreakPoint[] = points.map((p) => ({
day: p.day,
value: p.mean,
}));
const latestDayKey = points[points.length - 1].day;
return milestonesFromStreak(type, detectStreak(series), latestDayKey);
} catch {
return [] as Milestone[];
}
}),
);
candidates.push(...perMetric.flat());
}
try {
const records = await prisma.personalRecord.findMany({
where: {
userId,
metricType: { in: [...MILESTONE_SALIENT_TYPES] },
achievedAt: {
gte: new Date(now.getTime() - MILESTONE_RECORD_LOOKBACK_MS),
},
},
select: { metricType: true, achievedAt: true },
orderBy: { achievedAt: "desc" },
take: 10,
});
for (const record of records) {
candidates.push(
milestoneFromRecord(record.metricType, utcDayKey(record.achievedAt)),
);
}
} catch {
// A read hiccup just leaves the reward quiet — never a fabricated one.
}
return selectFreshMilestone(candidates, todayKey);
}
/** S10 — how recently an ECG recording counts as "new" for the rail read. */
const ECG_NEW_WINDOW_MS = 24 * 60 * 60 * 1000;
interface CoachPlanRow {
id: string;
status: string;
@@ -92,6 +187,7 @@ export async function loadDailyDigest(
syncRows,
dueReminders,
planRows,
ecgRow,
] = await Promise.all([
readDashboardSnapshotCached(user),
resolveModuleMap(user.id),
@@ -133,6 +229,18 @@ export async function loadDailyDigest(
orderBy: { updatedAt: "desc" },
take: CHECKIN_PLAN_READ_LIMIT,
}),
// S10 — the freshest ECG recording within the last-day window, for the
// `ecg_new_recording` rail item. Descriptors only (verdict + recordedAt) —
// the encrypted waveform is never selected, so no decrypt and no trace
// crosses into the digest. The builder gates on the `insights` module.
prisma.ecgRecording.findFirst({
where: {
userId: user.id,
recordedAt: { gte: new Date(now.getTime() - ECG_NEW_WINDOW_MS) },
},
orderBy: { recordedAt: "desc" },
select: { recordedAt: true, rhythmClassification: true },
}),
]);
const score: DailyDigestScore | null = snapshot.healthScore
@@ -165,6 +273,24 @@ export async function loadDailyDigest(
label: row.label,
}));
// S10 — the freshest ECG recording (device verdict + recordedAt only). The
// builder decides "new" and gates on the `insights` module. An ECG row only
// ever carries an AFib-screening verdict; the shared enum's other members
// (walking-steadiness / event codes) never apply, so anything else maps to
// null rather than leaking into the device-verdict copy.
const verdict = ecgRow?.rhythmClassification;
const latestEcg: DailyDigestEcg | null = ecgRow
? {
recordedAt: ecgRow.recordedAt,
deviceVerdict:
verdict === "IRREGULAR" ||
verdict === "NOT_DETECTED" ||
verdict === "INCONCLUSIVE"
? verdict
: null,
}
: null;
// Only decrypt plan prose for a coach-enabled account; the builder gates on
// the module too, so a disabled coach never surfaces a check-in either way.
const coachEnabled = modules.coach !== false;
@@ -172,6 +298,24 @@ export async function loadDailyDigest(
? planRows.map((row) => toCoachPlanCandidate(row as CoachPlanRow))
: [];
// S12 — the calm reward layer. Only gather when the insights module (the
// narrative layer that hosts the milestone card) is on; the builder gates on
// it too, so a disabled account never surfaces one either way.
const insightsEnabled = modules.insights !== false;
const milestone = insightsEnabled
? await gatherFreshMilestone(user.id, now)
: null;
// S11 — the day's elevated-at-rest window, computed on demand from raw for
// TODAY only (read-swap, one bounded day-read; never persisted). Gated on the
// insights module and fault-isolated: a tension-read failure must never break
// the digest (a hot, must-not-fail path), it just omits the calm marker.
const tensionWindow: DailyDigestTensionWindow | null = insightsEnabled
? await loadIntradayPulse(user.id, user.timezone, todayLocalDate)
.then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null))
.catch(() => null)
: null;
const { t } = getServerTranslator(resolveLocale(locale));
const digest = buildDailyDigest(
@@ -186,6 +330,9 @@ export async function loadDailyDigest(
syncIssues,
preventiveDue,
coachPlans,
milestone,
tensionWindow,
latestEcg,
},
t,
);
@@ -200,6 +347,9 @@ export async function loadDailyDigest(
daily_digest_has_checkin: digest.worthALook.some(
(i) => i.kind === "coach_checkin",
),
daily_digest_has_milestone: digest.worthALook.some(
(i) => i.kind === "milestone",
),
},
});
+226
View File
@@ -0,0 +1,226 @@
/**
* P2 — `Milestone`, the durable-states reward primitive.
*
* A milestone is "a state REACHED" — a durable achievement the record now
* carries: a metric that settled back inside the user's own range, a run of
* weeks in range, a fresh personal best. It is deliberately NOT a streak.
*
* The distinction is written into the type on purpose: a milestone carries a
* `sinceDate` (the day the durable state was reached) and NOTHING that counts
* days still being maintained — no `currentRun`, no `daysHeld`, no counter a
* future tuning pass could quietly turn into a flame. A missed day is invisible
* here; there is no "you broke it" state to reach, and none is representable.
*
* This module is a THIN wrapper over the engines that already exist — it adds
* no second detection framework:
* - `return_to_baseline` + `sustained_in_range` fold a `StreakResult` from
* the pure `detectStreak` engine (`@/lib/insights/streak-detector`).
* - `record_first` folds a `PersonalRecord` row (the `pr-detection` infra).
*
* Reached-once WITHOUT new storage: a milestone's `sinceDate` is a stable
* historical fact. `selectFreshMilestone` admits it only on the day it was
* reached (`sinceDate === todayKey`); the next day it is no longer fresh and
* never resurfaces. No "which milestones were shown" table is needed — the
* reached-date IS the once-guarantee.
*
* Pure: no DB, no clock, no network. Day keys are UTC-ISO (`YYYY-MM-DD`), the
* SAME space `readDayMeanSeries` and the rollup tier already emit, so a
* milestone's reach-day and "today" compare in one consistent space.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type { ServerTranslator } from "@/lib/i18n/server-translator";
import { MIN_IN_RUN, type StreakResult } from "@/lib/insights/streak-detector";
/** Closed set of durable-state kinds. Grows by PR, never with a counter field. */
export const MILESTONE_KINDS = [
"record_first",
"return_to_baseline",
"sustained_in_range",
] as const;
export type MilestoneKind = (typeof MILESTONE_KINDS)[number];
/**
* One durable state reached. `sinceDate` is the UTC-ISO day the state was
* reached; `copyKey` is the BASE i18n key under `daily.milestone.*` (the card
* appends `.title` / `.body`). There is intentionally no numeric run field.
*/
export interface Milestone {
kind: MilestoneKind;
metricType: MeasurementType;
/** UTC-ISO (`YYYY-MM-DD`) day the durable state was REACHED. */
sinceDate: string;
/** Base i18n key under `daily.milestone.*`; copy celebrates arrival. */
copyKey: string;
}
/**
* The vitals a milestone can celebrate — mirrors `score-narrative`'s
* `RETURN_SALIENT_TYPES`. All four are core vitals with no toggleable module,
* so a milestone here never leaks a disabled-module surface.
*/
export const MILESTONE_SALIENT_TYPES: readonly MeasurementType[] = [
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"RESPIRATORY_RATE",
"WEIGHT",
] as const;
/**
* Durable sustained-in-range states, in WEEKS. A CLOSED, bounded set: reaching
* week 4 is the last one. There is deliberately no escalation beyond it and no
* running day count — each threshold is a one-time state, so nothing here can
* be tuned into an ever-climbing streak.
*/
export const SUSTAINED_WEEK_THRESHOLDS = [1, 2, 3, 4] as const;
/** Per-metric localised-name key + its insight deep-link. */
const METRIC_NAME_KEY: Partial<Record<MeasurementType, string>> = {
RESTING_HEART_RATE: "daily.milestone.metric.restingHeartRate",
HEART_RATE_VARIABILITY: "daily.milestone.metric.heartRateVariability",
RESPIRATORY_RATE: "daily.milestone.metric.respiratoryRate",
WEIGHT: "daily.milestone.metric.weight",
};
const METRIC_HREF: Partial<Record<MeasurementType, string>> = {
RESTING_HEART_RATE: "/insights/resting-pulse",
HEART_RATE_VARIABILITY: "/insights/hrv",
RESPIRATORY_RATE: "/insights/respiratory-rate",
WEIGHT: "/insights/weight",
};
/** The insight surface a milestone's action deep-links to. */
export function milestoneHref(milestone: Milestone): string {
return METRIC_HREF[milestone.metricType] ?? "/insights";
}
// ── pure UTC day-key arithmetic ─────────────────────────────────────────────
function dayKeyToSerial(key: string): number {
const [year, month, day] = key.split("-").map(Number);
return Date.UTC(year, month - 1, day) / 86_400_000;
}
function serialToDayKey(serial: number): string {
return new Date(serial * 86_400_000).toISOString().slice(0, 10);
}
/** Shift a UTC-ISO day key by a whole number of days. */
function shiftDayKey(key: string, deltaDays: number): string {
return serialToDayKey(dayKeyToSerial(key) + deltaDays);
}
/**
* Fold a `StreakResult` into the durable milestones it represents. Reuses the
* `detectStreak` engine's output verbatim — this adds no detection logic.
*
* `return_to_baseline`: the metric settled BACK inside its own range. The
* return is reached the moment the in-band run first holds `MIN_IN_RUN` days,
* so the reach-day is that many days back from the latest reading. This only
* ever narrates the calm ARRIVAL back inside the range — there is no message
* for leaving it.
*
* `sustained_in_range`: one durable state per week boundary the in-band run has
* passed, each stamped with the day it was reached. Emitted only while the
* metric IS currently in-band; a lapse simply stops producing NEW milestones —
* it is never marked, never mourned.
*/
export function milestonesFromStreak(
metricType: MeasurementType,
result: StreakResult,
latestDayKey: string,
): Milestone[] {
const out: Milestone[] = [];
if (result.returnEvent) {
const reachedDay = shiftDayKey(
latestDayKey,
-(result.returnEvent.daysInside - MIN_IN_RUN),
);
out.push({
kind: "return_to_baseline",
metricType,
sinceDate: reachedDay,
copyKey: "daily.milestone.returnToBaseline",
});
}
if (result.inBand && result.latestPlacement === "in") {
for (const weeks of SUSTAINED_WEEK_THRESHOLDS) {
const days = weeks * 7;
if (result.streakDays < days) continue;
const reachedDay = shiftDayKey(latestDayKey, -(result.streakDays - days));
out.push({
kind: "sustained_in_range",
metricType,
sinceDate: reachedDay,
copyKey: `daily.milestone.sustainedInRange.week${weeks}`,
});
}
}
return out;
}
/** Fold a personal-record row (its achieved day) into a `record_first` milestone. */
export function milestoneFromRecord(
metricType: MeasurementType,
achievedDayKey: string,
): Milestone {
return {
kind: "record_first",
metricType,
sinceDate: achievedDayKey,
copyKey: "daily.milestone.record",
};
}
/**
* Priority when several milestones land on the same day: a new personal best
* first (the brightest arrival), then a return to range (closes a worry), then
* a sustained state. The one/day cap picks the top of this order.
*/
const KIND_PRIORITY: Record<MilestoneKind, number> = {
record_first: 0,
return_to_baseline: 1,
sustained_in_range: 2,
};
/**
* The reached-once gate: admit only milestones REACHED today, and only ONE —
* the most meaningful. A milestone reached on an earlier day is a settled fact,
* not fresh news, so it never resurfaces; a day with no reached state yields
* `null` (an empty rail entry, never an invented one).
*/
export function selectFreshMilestone(
candidates: Milestone[],
todayKey: string,
): Milestone | null {
const fresh = candidates.filter((m) => m.sinceDate === todayKey);
if (fresh.length === 0) return null;
fresh.sort(
(a, b) =>
KIND_PRIORITY[a.kind] - KIND_PRIORITY[b.kind] ||
(a.metricType < b.metricType ? -1 : a.metricType > b.metricType ? 1 : 0),
);
return fresh[0];
}
type Translate = ServerTranslator["t"];
/**
* Resolve a milestone's already-localised card copy. The metric name is
* interpolated so one copy key serves every salient vital. Copy celebrates the
* arrival at a durable state — never a maintained count, never a loss.
*/
export function milestoneCopy(
milestone: Milestone,
t: Translate,
): { title: string; body: string } {
const metric = t(
METRIC_NAME_KEY[milestone.metricType] ?? "daily.milestone.metric.generic",
);
return {
title: t(`${milestone.copyKey}.title`, { metric }),
body: t(`${milestone.copyKey}.body`, { metric }),
};
}
@@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({
labResult: { findMany: vi.fn() },
measurementReminder: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
ecgRecording: { findMany: vi.fn() },
$queryRawUnsafe: vi.fn(),
},
}));
@@ -63,6 +64,7 @@ const prismaMock = prisma as unknown as {
labResult: { findMany: ReturnType<typeof vi.fn> };
measurementReminder: { findMany: ReturnType<typeof vi.fn> };
workout: { findMany: ReturnType<typeof vi.fn> };
ecgRecording: { findMany: ReturnType<typeof vi.fn> };
};
const dayMs = 24 * 60 * 60 * 1000;
@@ -100,6 +102,7 @@ beforeEach(() => {
prismaMock.labResult.findMany.mockResolvedValue([]);
prismaMock.measurementReminder.findMany.mockResolvedValue([]);
prismaMock.workout.findMany.mockResolvedValue([]);
prismaMock.ecgRecording.findMany.mockResolvedValue([]);
});
describe("extractFeatures — v1.4.36 W3 bucketed payload", () => {
@@ -433,6 +436,8 @@ describe("extractFeatures — v1.22 integration blocks are briefing-scoped", ()
expect(prismaMock.labResult.findMany).not.toHaveBeenCalled();
expect(prismaMock.measurementReminder.findMany).not.toHaveBeenCalled();
expect(prismaMock.workout.findMany).not.toHaveBeenCalled();
// S10 — the ECG descriptor rides the same briefing-scoped gate.
expect(prismaMock.ecgRecording.findMany).not.toHaveBeenCalled();
});
it("runs the extra reads on the wide briefing window (sinceDays: 400)", async () => {
@@ -440,5 +445,53 @@ describe("extractFeatures — v1.22 integration blocks are briefing-scoped", ()
expect(prismaMock.labResult.findMany).toHaveBeenCalled();
expect(prismaMock.measurementReminder.findMany).toHaveBeenCalled();
expect(prismaMock.workout.findMany).toHaveBeenCalled();
expect(prismaMock.ecgRecording.findMany).toHaveBeenCalled();
});
});
describe("extractFeatures — S10 ECG device-verdict descriptor", () => {
const DAY = 86_400_000;
it("emits a device-verdict descriptor and NEVER a waveform", async () => {
const now = Date.now();
prismaMock.ecgRecording.findMany.mockResolvedValue([
{
recordedAt: new Date(now - 2 * DAY),
rhythmClassification: "IRREGULAR",
averageHeartRate: 72,
},
{
recordedAt: new Date(now - 10 * DAY),
rhythmClassification: "NOT_DETECTED",
averageHeartRate: 61,
},
]);
const f = await extractFeatures("user-1", false);
expect(f.ecg?.recordingCount).toBe(2);
expect(f.ecg?.deviceVerdicts).toEqual({
irregular: 1,
notDetected: 1,
inconclusive: 0,
});
// The LATEST recording's device verdict + HR (newest-first ordering).
expect(f.ecg?.latestDeviceVerdict).toBe("IRREGULAR");
expect(f.ecg?.latestAverageHeartRate).toBe(72);
// The waveform never reaches the payload — only descriptors.
expect(JSON.stringify(f.ecg)).not.toMatch(
/waveform|sample|signal|voltage|microvolt/i,
);
});
it("omits the ECG block entirely when the user has no recordings", async () => {
const f = await extractFeatures("user-1", false);
expect(f.ecg).toBeUndefined();
});
it("only ever reads descriptor columns, never the encrypted waveform", async () => {
await extractFeatures("user-1", false);
const call = prismaMock.ecgRecording.findMany.mock.calls[0]?.[0];
expect(call?.select?.waveformEncrypted).toBeUndefined();
expect(call?.select?.recordedAt).toBe(true);
expect(call?.select?.rhythmClassification).toBe(true);
});
});
+68
View File
@@ -114,6 +114,12 @@ const LABS_LOOKBACK_MONTHS = 12;
/** Trailing window (days) for the workout aggregate. */
const WORKOUT_WINDOW_DAYS = 90;
/** Trailing window (days) for the ECG recording descriptor. */
const ECG_WINDOW_DAYS = 90;
/** Defensive cap on ECG rows read for the descriptor (a bound, not a ceiling). */
const ECG_MAX_ROWS = 500;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
@@ -347,6 +353,68 @@ export async function readWorkoutsBlock(
return { last7: tally(7), last30: tally(30), latest };
}
/**
* S10 — ECG recording descriptor for the briefing narrative (device-verdict
* only). A bounded, non-diagnostic snapshot of the user's on-device ECG
* recordings over the trailing window: how many exist, the distribution of the
* RECORDING DEVICE's OWN verdicts (never HealthLog's), and the latest
* recording's device verdict + average heart rate. The waveform is NEVER read
* (no decrypt on this path) and NEVER enters the payload — the trace does not
* cross into the prompt, so the model cannot interpret morphology. The prompt
* (insight-generator rule) constrains any mention to the device's attribution;
* the grounding gate constrains any restated count. Returns `undefined` when no
* recordings fall in the window, so the block is omitted rather than emitting an
* empty shape.
*/
export async function readEcgBriefingBlock(
userId: string,
now: number,
): Promise<AggregatedFeatures["ecg"] | undefined> {
const since = new Date(now - ECG_WINDOW_DAYS * MS_PER_DAY);
const rows = await prisma.ecgRecording.findMany({
where: { userId, recordedAt: { gte: since } },
orderBy: { recordedAt: "desc" },
take: ECG_MAX_ROWS,
// Descriptors only — the encrypted waveform blob is never selected, so no
// decrypt happens and no trace can reach the narrative.
select: {
recordedAt: true,
rhythmClassification: true,
averageHeartRate: true,
},
});
if (rows.length === 0) return undefined;
const deviceVerdicts = { irregular: 0, notDetected: 0, inconclusive: 0 };
for (const r of rows) {
if (r.rhythmClassification === "IRREGULAR") deviceVerdicts.irregular += 1;
else if (r.rhythmClassification === "NOT_DETECTED")
deviceVerdicts.notDetected += 1;
else if (r.rhythmClassification === "INCONCLUSIVE")
deviceVerdicts.inconclusive += 1;
}
// An ECG row only ever carries an AFib-screening verdict; the shared enum's
// other members (walking-steadiness / event codes) never apply here, so
// anything else narrows to null.
const latest = rows[0];
const latestVerdict = latest.rhythmClassification;
return {
recordingCount: rows.length,
deviceVerdicts,
latestDeviceVerdict:
latestVerdict === "IRREGULAR" ||
latestVerdict === "NOT_DETECTED" ||
latestVerdict === "INCONCLUSIVE"
? latestVerdict
: null,
latestRecordedDaysAgo: Math.round(
(now - latest.recordedAt.getTime()) / MS_PER_DAY,
),
latestAverageHeartRate: latest.averageHeartRate,
};
}
/**
* Read every BUCKETED_TYPES × BUCKET_WINDOWS combination from the
* persistent rollup table and project to the wire shape. Empty
+34 -1
View File
@@ -33,6 +33,7 @@ import {
import {
readAllTimeExtremes,
readBucketedSeries,
readEcgBriefingBlock,
readLabsBriefingBlock,
readPreventiveCareBlock,
readWorkoutsBlock,
@@ -321,6 +322,33 @@ export interface AggregatedFeatures {
distanceKm: number | null;
} | null;
};
/**
* S10 — ECG recording descriptor (device-verdict only, NON-DIAGNOSTIC). A
* bounded, existence-and-count read of the user's on-device ECG recordings so
* the nightly narrative can reference them FACTUALLY and attributed to the
* device. `deviceVerdicts` / `latestDeviceVerdict` are the RECORDING DEVICE's
* OWN classifications — HealthLog never re-classifies, never reads the
* waveform (it never enters this payload), and never emits a verdict of its
* own. The prompt constrains any mention to the device's attribution; the
* grounding gate constrains any restated count. Omitted when no recordings
* fall in the window.
*/
ecg?: {
/** Number of ECG recordings in the trailing window. */
recordingCount: number;
/** Distribution of the DEVICE's own verdicts across the window. */
deviceVerdicts: {
irregular: number;
notDetected: number;
inconclusive: number;
};
/** The latest recording's DEVICE verdict, or null when unclassified. */
latestDeviceVerdict: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
/** Days since the latest recording. */
latestRecordedDaysAgo: number;
/** The latest recording's device-reported average HR, or null. */
latestAverageHeartRate: number | null;
};
}
/**
@@ -1316,14 +1344,19 @@ export async function extractFeatures(
(typeof sinceDays === "number" &&
sinceDays >= INTEGRATION_BLOCK_MIN_WINDOW_DAYS);
if (includeIntegrations) {
const [labs, preventiveCare, workouts] = await Promise.all([
const [labs, preventiveCare, workouts, ecg] = await Promise.all([
readLabsBriefingBlock(userId, now),
readPreventiveCareBlock(userId, now),
readWorkoutsBlock(userId, now),
// S10 — ECG device-verdict descriptor (never the waveform). Weaves the
// ECG silo into the narrative; the model may reference it, attributed to
// the device, but never interprets the trace (it never sees one).
readEcgBriefingBlock(userId, now),
]);
if (labs) features.labs = labs;
if (preventiveCare) features.preventiveCare = preventiveCare;
if (workouts) features.workouts = workouts;
if (ecg) features.ecg = ecg;
}
// v1.4.36 W3 T1 — "raw" mode no longer dumps every measurement row
+9
View File
@@ -84,6 +84,15 @@ export const insightsKeys = {
insightsEcgList: () => ["insights", "ecg"] as const,
insightsEcgDetail: (id: string, full: boolean) =>
["insights", "ecg", id, full ? "full" : "overview"] as const,
/**
* S11 — the intraday pulse / tension layer (`/api/insights/pulse/intraday`).
* Keyed by the viewed local day so each day's 10-minute-mean shape caches
* independently and yesterday's curve never poisons today's. Under the
* `["insights"]` prefix so a fresh PULSE write busts it via the standard
* insights invalidation fan-out.
*/
insightsPulseIntraday: (dateKey: string) =>
["insights", "pulse-intraday", dateKey] as const,
/**
* v1.10.0 — derived-wellness metrics. One generic route
* (`/api/insights/derived?metric=…[&type=…]`) backs the vitals