diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c5aa44f6..33373c2c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cdf406dea..d94e3dc64 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.56 + version: 1.29.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index c8907407d..43373eedb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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." + } + } } } } diff --git a/messages/en.json b/messages/en.json index 11f67878f..937805e02 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." + } + } } } } diff --git a/messages/es.json b/messages/es.json index 79883e68e..e56efdbf9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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." + } + } } } } diff --git a/messages/fr.json b/messages/fr.json index 85d4f3acf..9f16448a4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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." + } + } } } } diff --git a/messages/it.json b/messages/it.json index b0702e182..b3a7cc930 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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." + } + } } } } diff --git a/messages/pl.json b/messages/pl.json index b057686a9..d1d1ae6ea 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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." + } + } } } } diff --git a/package.json b/package.json index a497fa982..4963af024 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index 21f679d40..778a756b2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.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/*` diff --git a/src/app/__tests__/dashboard-chart-reveal.test.tsx b/src/app/__tests__/dashboard-chart-reveal.test.tsx index 454c7dc9c..9bc8ebada 100644 --- a/src/app/__tests__/dashboard-chart-reveal.test.tsx +++ b/src/app/__tests__/dashboard-chart-reveal.test.tsx @@ -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"); }); }); diff --git a/src/app/api/coach/plans/[id]/route.ts b/src/app/api/coach/plans/[id]/route.ts index f21351a5c..02a13b6df 100644 --- a/src/app/api/coach/plans/[id]/route.ts +++ b/src/app/api/coach/plans/[id]/route.ts @@ -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 { diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index be689bb62..2fdfb5659 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -112,6 +112,12 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray = [ "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 diff --git a/src/app/api/insights/pulse/intraday/route.ts b/src/app/api/insights/pulse/intraday/route.ts new file mode 100644 index 000000000..c0f149592 --- /dev/null +++ b/src/app/api/insights/pulse/intraday/route.ts @@ -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; +}); diff --git a/src/app/globals.css b/src/app/globals.css index 68abd833c..067640131 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index cb3963e16..4ab7decbc 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -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: () => }, +); 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. */} + + {/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off @@ -188,6 +209,12 @@ export default function InsightsPulsPage() { icon={} /> + {/* 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. */} + + {/* 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 diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 3ac4cbde1..e05e2a2dd 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -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(() => { + 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 ? ( ) : digestQuery.data ? ( - + ) : null)} {/* v1.18.6 — the spotlight tour launcher moved to the app-shell diff --git a/src/components/admin/__tests__/host-metrics-chart.test.tsx b/src/components/admin/__tests__/host-metrics-chart.test.tsx index b436736fa..00c3143ca 100644 --- a/src/components/admin/__tests__/host-metrics-chart.test.tsx +++ b/src/components/admin/__tests__/host-metrics-chart.test.tsx @@ -122,6 +122,6 @@ describe("", () => { const html = render(); expect(html).toContain("admin-host-metrics-heading"); expect(html).toContain("Host load"); - expect(html).toContain("animate-pulse"); + expect(html).toContain("skeleton-shimmer"); }); }); diff --git a/src/components/charts/chart-runtime.ts b/src/components/charts/chart-runtime.ts index bcc70bfc1..e17e14850 100644 --- a/src/components/charts/chart-runtime.ts +++ b/src/components/charts/chart-runtime.ts @@ -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"; diff --git a/src/components/daily/__tests__/priority-card.test.tsx b/src/components/daily/__tests__/priority-card.test.tsx index 9ba0dd84c..7db766635 100644 --- a/src/components/daily/__tests__/priority-card.test.tsx +++ b/src/components/daily/__tests__/priority-card.test.tsx @@ -60,6 +60,31 @@ describe("", () => { expect(info).toContain("bg-info/10"); }); + it("carries the metric-family accent for metric-flavoured kinds only", () => { + const tension = render( + , + ); + expect(tension).toContain("metric-accent"); + expect(tension).toContain("--tile-hue:var(--tile-stress)"); + const ecg = render( + , + ); + expect(ecg).toContain("--tile-hue:var(--tile-strain)"); + // Utility kinds carry no metric identity — status alone speaks. + const sync = render(); + expect(sync).not.toContain("metric-accent"); + }); + + it("gives the milestone card the quiet reached treatment instead of the generic fade-in", () => { + const html = render( + , + ); + 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( ", () => { 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(); + 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(); + 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( = { 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> = { + 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 ( diff --git a/src/components/daily/today-hero-skeleton.tsx b/src/components/daily/today-hero-skeleton.tsx index 6a9a63eb7..1abae7136 100644 --- a/src/components/daily/today-hero-skeleton.tsx +++ b/src/components/daily/today-hero-skeleton.tsx @@ -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 `` 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 `` 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 (