mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bef3b9dd3f | ||
|
|
2b5fb617ba | ||
|
|
7445925d58 | ||
|
|
270f33edb2 | ||
|
|
4f198ec25f | ||
|
|
3aa11b0531 | ||
|
|
b3d586066a | ||
|
|
86e92f38ca | ||
|
|
43884569d3 | ||
|
|
63cdd709af | ||
|
|
99d80f8512 | ||
|
|
2da6a192a7 | ||
|
|
f6e6593bcb | ||
|
|
65f90160c7 |
No files matched your search
@@ -2,6 +2,59 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.29.2] — 2026-07-17
|
||||
|
||||
- **WHOOP workouts carry their real sport.** WHOOP was the only source that
|
||||
didn't translate its activity into the app's own sport names, so
|
||||
WHOOP-tracked rides and runs landed unlabelled instead of as cycling or
|
||||
running. New and existing WHOOP workouts are now mapped, so your bike rides
|
||||
show up as cycling — history included. When the same session comes in from two
|
||||
sources, the canonical row now keeps the richest reading (heart rate, energy,
|
||||
distance) instead of dropping it.
|
||||
- **Scroll back through the day's heart rate.** The intraday heart-rate view
|
||||
gains a day navigator to step through previous days. The fine-grained history
|
||||
now stays for 90 days (was 14); older days show the coarser hourly shape.
|
||||
- **Clear a card from Today.** A milestone, a new ECG, or a tension note can be
|
||||
dismissed from the Today rail once you've seen it; the section tightens when
|
||||
the last one is gone. The cards that need an action stay until you act.
|
||||
- **Vitals context moves into an info tap.** The vitals tiles carry an (i) with
|
||||
the context — your reading against your personal range, and against your age
|
||||
where that applies — instead of a permanent caption. The VO₂ max heading no
|
||||
longer gets cut off.
|
||||
- **Oura cycle phases feed the cycle tracker.** Where your Oura connection
|
||||
provides cycle-phase data, it now fills the cycle tracker as a background
|
||||
source; your own entries always win.
|
||||
|
||||
Two migrations (0247, 0248). No breaking changes.
|
||||
|
||||
## [1.29.1] — 2026-07-17
|
||||
|
||||
A round of fixes and small refinements from living with the daily view.
|
||||
|
||||
- **Today refreshes right after a dose.** Logging or undoing a medication now
|
||||
updates the day's read immediately — the medication line no longer lingers on
|
||||
a stale count until the next reload.
|
||||
- **A calmer Today hero.** The extra score rings are gone; the hero keeps the
|
||||
one health-score ring and a tighter layout.
|
||||
- **The Coach names the document you attached.** Asking about a document now
|
||||
shows its title in the chat instead of a plain "1 document" count, and the
|
||||
attach menu stays within its popover on long labels.
|
||||
- **A fresh install starts with tracking on.** New installs now enable the
|
||||
tracking modules by default — you can still switch any of them off. The
|
||||
external-AI options and the daily push stay off until you turn them on.
|
||||
- **Insights vitals lose the explainer line.** The "your personal normal range
|
||||
is the median…" caption is removed from the baseline tiles.
|
||||
- **Illness journal: set and edit the end.** You can mark an episode's end date,
|
||||
clear it back to ongoing, and backdate the start. When nothing strayed from
|
||||
your personal range, the course section now reads a short settled line instead
|
||||
of appearing empty.
|
||||
- **The morning refresh covers every sleep source.** The refresh that folds last
|
||||
night's sleep into the day now triggers from Google Health, Fitbit, Oura, and
|
||||
Polar as well, not only Withings, WHOOP, and Apple Health. Each skipped refresh
|
||||
is now recorded so a stale morning read can be traced to its cause.
|
||||
|
||||
No new data is collected and there is no migration.
|
||||
|
||||
## [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
|
||||
|
||||
+65
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.29.0
|
||||
version: 1.29.2
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -10848,6 +10848,42 @@ paths:
|
||||
"403": *a5
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/daily/digest/dismiss:
|
||||
post:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Dismiss a Today rail item
|
||||
description: "\"Dismiss / mark seen\" for the Today rail's OBSERVATIONAL PriorityItem kinds only — milestone,
|
||||
ecg_new_recording, tension_window. The ACTIONABLE kinds (dose_window, sync_issue, preventive_care,
|
||||
coach_checkin) are structurally unreachable: itemKey must be namespaced under one of the three dismissible kinds
|
||||
or the request 422s before any lookup runs. Persisted server-side so the dismissal survives reload / a second
|
||||
device; an upsert, so a repeat dismiss of the same instance is a no-op. Requires the insights module. Cookie or
|
||||
Bearer auth."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
itemKey:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 200
|
||||
required:
|
||||
- itemKey
|
||||
additionalProperties: false
|
||||
responses:
|
||||
"200":
|
||||
description: The item is dismissed (idempotent).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DismissPriorityItemEnvelope"
|
||||
"401": *a1
|
||||
"403": *a5
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
components:
|
||||
schemas:
|
||||
EncryptedExportRequest:
|
||||
@@ -30150,6 +30186,10 @@ components:
|
||||
- ecg_new_recording
|
||||
- tension_window
|
||||
description: Closed rail-item kind.
|
||||
itemKey:
|
||||
description: Stable dismiss identity, namespaced `<kind>:...`. Present only on the observational kinds (milestone /
|
||||
ecg_new_recording / tension_window) — absent on the actionable kinds, which are never dismissible.
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
description: Localised headline (resolved server-side).
|
||||
@@ -30193,6 +30233,30 @@ components:
|
||||
- actions
|
||||
additionalProperties: false
|
||||
description: One 'worth a look' rail item. The single model every daily-value consumer renders through PriorityCard.
|
||||
DismissPriorityItemEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
dismissed:
|
||||
type: boolean
|
||||
const: true
|
||||
required:
|
||||
- dismissed
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MedicationScheduleOutput:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+14
-5
@@ -2184,6 +2184,7 @@
|
||||
"docScope": {
|
||||
"badge": "Dokument",
|
||||
"chattingAbout": "Chat über: {title}",
|
||||
"chattingAboutMore": "Chat über: {titles} und {count} weitere",
|
||||
"notIndexedHint": "Lies dieses Dokument zuerst im Archiv mit KI ein, damit der Coach dazu antworten kann."
|
||||
},
|
||||
"actionsMenu": "Aktionen",
|
||||
@@ -3396,6 +3397,7 @@
|
||||
"sectionSubtitle": "Ruhewerte von Herz-Kreislauf und Atmung im Vergleich zu deiner eigenen Baseline",
|
||||
"loadingLabel": "Vitalwerte werden geladen",
|
||||
"loadError": "Deine Vitalwerte konnten gerade nicht geladen werden.",
|
||||
"infoLabel": "Wie wird das berechnet?",
|
||||
"building": "Typischer Bereich entsteht — {count} von {target} Tagen",
|
||||
"typicalRange": "Typisch für dich: {low}–{high}",
|
||||
"fitnessYounger": "Ausdauer wie ~{years} J. jünger",
|
||||
@@ -3868,8 +3870,12 @@
|
||||
"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.",
|
||||
"hourlyNote": "Stündlicher Durchschnitt für diesen Tag — aktuelle Tage zeigen das volle 10-Minuten-Detail.",
|
||||
"empty": "Für diesen Tag noch nicht genug Pulswerte, um den Tagesverlauf zu zeigen.",
|
||||
"baseline": "Ruhe",
|
||||
"today": "Heute",
|
||||
"previousDay": "Vorheriger Tag",
|
||||
"nextDay": "Nächster Tag",
|
||||
"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.",
|
||||
@@ -7692,7 +7698,10 @@
|
||||
"notePlaceholder": "Was erwähnenswert ist (verschlüsselt).",
|
||||
"saveError": "Die Episode konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"parent": "Teil von",
|
||||
"parentNone": "Nicht verknüpft"
|
||||
"parentNone": "Nicht verknüpft",
|
||||
"resolved": "Beendet am",
|
||||
"resolvedHelp": "Leer lassen, solange die Episode noch andauert.",
|
||||
"markOngoing": "Noch andauernd"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "{date} erfassen",
|
||||
@@ -7744,7 +7753,8 @@
|
||||
"direction": {
|
||||
"above": "über deinem Üblichen",
|
||||
"below": "unter deinem Üblichen"
|
||||
}
|
||||
},
|
||||
"settled": "Während dieser Episode wich nichts Auffälliges von deinem üblichen Bereich ab."
|
||||
},
|
||||
"insights": {
|
||||
"title": "Dein Krankheitsverlauf",
|
||||
@@ -8608,8 +8618,7 @@
|
||||
"readFullBriefing": "Vollständiges Briefing lesen",
|
||||
"worthALook": "Einen Blick wert",
|
||||
"allClear": "Heute braucht nichts deine Aufmerksamkeit.",
|
||||
"ringLink": "{metric}-Details öffnen",
|
||||
"ringDosesAria": "{taken} von {scheduled} Dosen heute genommen"
|
||||
"ringLink": "{metric}-Details öffnen"
|
||||
},
|
||||
"milestone": {
|
||||
"metric": {
|
||||
|
||||
+14
-5
@@ -2184,6 +2184,7 @@
|
||||
"docScope": {
|
||||
"badge": "Document",
|
||||
"chattingAbout": "Chatting about: {title}",
|
||||
"chattingAboutMore": "Chatting about: {titles} and {count} more",
|
||||
"notIndexedHint": "Read this document with AI first in the vault so the Coach can answer about it."
|
||||
},
|
||||
"actionsMenu": "Actions",
|
||||
@@ -3396,6 +3397,7 @@
|
||||
"sectionSubtitle": "Resting cardiovascular and respiratory signals against your own baseline",
|
||||
"loadingLabel": "Loading your vitals",
|
||||
"loadError": "Could not load your vitals right now.",
|
||||
"infoLabel": "How is this calculated?",
|
||||
"building": "Building your typical range — {count} of {target} days",
|
||||
"typicalRange": "Typical for you: {low}–{high}",
|
||||
"fitnessYounger": "Cardio fitness of someone ~{years} yr younger",
|
||||
@@ -3868,8 +3870,12 @@
|
||||
"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.",
|
||||
"hourlyNote": "Showing this day's hourly average — recent days show the full 10-minute detail.",
|
||||
"empty": "Not enough heart-rate readings for this day to show its shape yet.",
|
||||
"baseline": "Resting",
|
||||
"today": "Today",
|
||||
"previousDay": "Previous day",
|
||||
"nextDay": "Next day",
|
||||
"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.",
|
||||
@@ -7692,7 +7698,10 @@
|
||||
"notePlaceholder": "Anything worth remembering (encrypted).",
|
||||
"saveError": "Could not save the episode. Please try again.",
|
||||
"parent": "Part of",
|
||||
"parentNone": "Not linked"
|
||||
"parentNone": "Not linked",
|
||||
"resolved": "Ended on",
|
||||
"resolvedHelp": "Leave empty while the episode is still ongoing.",
|
||||
"markOngoing": "Still ongoing"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Log {date}",
|
||||
@@ -7744,7 +7753,8 @@
|
||||
"direction": {
|
||||
"above": "above your usual",
|
||||
"below": "below your usual"
|
||||
}
|
||||
},
|
||||
"settled": "Nothing notable stood out from your usual range during this episode."
|
||||
},
|
||||
"insights": {
|
||||
"title": "Your illness history",
|
||||
@@ -8608,8 +8618,7 @@
|
||||
"readFullBriefing": "Read the full briefing",
|
||||
"worthALook": "Worth a look",
|
||||
"allClear": "Nothing needs your attention today.",
|
||||
"ringLink": "Open {metric} details",
|
||||
"ringDosesAria": "{taken} of {scheduled} doses taken today"
|
||||
"ringLink": "Open {metric} details"
|
||||
},
|
||||
"milestone": {
|
||||
"metric": {
|
||||
|
||||
+14
-5
@@ -2184,6 +2184,7 @@
|
||||
"docScope": {
|
||||
"badge": "Documento",
|
||||
"chattingAbout": "Hablando sobre: {title}",
|
||||
"chattingAboutMore": "Hablando sobre: {titles} y {count} más",
|
||||
"notIndexedHint": "Primero lee este documento con IA en el archivo para que el Coach pueda responder sobre él."
|
||||
},
|
||||
"actionsMenu": "Acciones",
|
||||
@@ -3396,6 +3397,7 @@
|
||||
"sectionSubtitle": "Señales cardiovasculares y respiratorias en reposo frente a tu propia línea base",
|
||||
"loadingLabel": "Cargando tus constantes",
|
||||
"loadError": "No se pudieron cargar tus constantes en este momento.",
|
||||
"infoLabel": "¿Cómo se calcula esto?",
|
||||
"building": "Creando tu rango habitual — {count} de {target} días",
|
||||
"typicalRange": "Habitual para ti: {low}–{high}",
|
||||
"fitnessYounger": "Forma cardiovascular de alguien ~{years} años más joven",
|
||||
@@ -3868,8 +3870,12 @@
|
||||
"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.",
|
||||
"hourlyNote": "Mostrando el promedio por hora de este día — los días recientes muestran el detalle completo de 10 minutos.",
|
||||
"empty": "Aún no hay suficientes lecturas para este día para mostrar su forma.",
|
||||
"baseline": "Reposo",
|
||||
"today": "Hoy",
|
||||
"previousDay": "Día anterior",
|
||||
"nextDay": "Día siguiente",
|
||||
"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.",
|
||||
@@ -7692,7 +7698,10 @@
|
||||
"notePlaceholder": "Algo que valga la pena recordar (cifrado).",
|
||||
"saveError": "No se pudo guardar el episodio. Inténtalo de nuevo.",
|
||||
"parent": "Parte de",
|
||||
"parentNone": "Sin vincular"
|
||||
"parentNone": "Sin vincular",
|
||||
"resolved": "Finalizó el",
|
||||
"resolvedHelp": "Déjalo vacío mientras el episodio siga en curso.",
|
||||
"markOngoing": "Aún en curso"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Registrar {date}",
|
||||
@@ -7744,7 +7753,8 @@
|
||||
"direction": {
|
||||
"above": "por encima de lo habitual",
|
||||
"below": "por debajo de lo habitual"
|
||||
}
|
||||
},
|
||||
"settled": "Durante este episodio nada destacó respecto a tu rango habitual."
|
||||
},
|
||||
"insights": {
|
||||
"title": "Tu historial de enfermedades",
|
||||
@@ -8608,8 +8618,7 @@
|
||||
"readFullBriefing": "Leer el informe completo",
|
||||
"worthALook": "Merece un vistazo",
|
||||
"allClear": "Hoy nada necesita tu atención.",
|
||||
"ringLink": "Abrir detalles de {metric}",
|
||||
"ringDosesAria": "{taken} de {scheduled} dosis tomadas hoy"
|
||||
"ringLink": "Abrir detalles de {metric}"
|
||||
},
|
||||
"milestone": {
|
||||
"metric": {
|
||||
|
||||
+14
-5
@@ -2184,6 +2184,7 @@
|
||||
"docScope": {
|
||||
"badge": "Document",
|
||||
"chattingAbout": "Discussion sur : {title}",
|
||||
"chattingAboutMore": "Discussion sur : {titles} et {count} de plus",
|
||||
"notIndexedHint": "Lis d'abord ce document avec l'IA dans le coffre pour que le Coach puisse y répondre."
|
||||
},
|
||||
"actionsMenu": "Actions",
|
||||
@@ -3396,6 +3397,7 @@
|
||||
"sectionSubtitle": "Signaux cardiovasculaires et respiratoires au repos par rapport à ta propre référence",
|
||||
"loadingLabel": "Chargement de vos constantes",
|
||||
"loadError": "Impossible de charger vos constantes pour le moment.",
|
||||
"infoLabel": "Comment ce résultat est-il calculé ?",
|
||||
"building": "Construction de votre plage habituelle — {count} sur {target} jours",
|
||||
"typicalRange": "Habituel pour vous : {low}–{high}",
|
||||
"fitnessYounger": "Forme cardio d'une personne ~{years} ans plus jeune",
|
||||
@@ -3868,8 +3870,12 @@
|
||||
"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.",
|
||||
"hourlyNote": "Moyenne horaire affichée pour cette journée — les jours récents affichent le détail complet par tranches de 10 minutes.",
|
||||
"empty": "Pas encore assez de mesures pour cette journée pour en afficher le rythme.",
|
||||
"baseline": "Repos",
|
||||
"today": "Aujourd'hui",
|
||||
"previousDay": "Jour précédent",
|
||||
"nextDay": "Jour suivant",
|
||||
"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.",
|
||||
@@ -7692,7 +7698,10 @@
|
||||
"notePlaceholder": "Ce qui mérite d'être noté (chiffré).",
|
||||
"saveError": "Impossible d'enregistrer l'épisode. Veuillez réessayer.",
|
||||
"parent": "Fait partie de",
|
||||
"parentNone": "Non lié"
|
||||
"parentNone": "Non lié",
|
||||
"resolved": "Terminée le",
|
||||
"resolvedHelp": "Laissez vide tant que l'épisode est en cours.",
|
||||
"markOngoing": "Toujours en cours"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Enregistrer {date}",
|
||||
@@ -7744,7 +7753,8 @@
|
||||
"direction": {
|
||||
"above": "au-dessus de ton habituel",
|
||||
"below": "en dessous de ton habituel"
|
||||
}
|
||||
},
|
||||
"settled": "Rien de notable ne s'est écarté de ta plage habituelle pendant cet épisode."
|
||||
},
|
||||
"insights": {
|
||||
"title": "Ton historique de maladies",
|
||||
@@ -8608,8 +8618,7 @@
|
||||
"readFullBriefing": "Lire le briefing complet",
|
||||
"worthALook": "À regarder",
|
||||
"allClear": "Rien ne requiert ton attention aujourd'hui.",
|
||||
"ringLink": "Ouvrir les détails de {metric}",
|
||||
"ringDosesAria": "{taken} des {scheduled} doses prises aujourd'hui"
|
||||
"ringLink": "Ouvrir les détails de {metric}"
|
||||
},
|
||||
"milestone": {
|
||||
"metric": {
|
||||
|
||||
+14
-5
@@ -2184,6 +2184,7 @@
|
||||
"docScope": {
|
||||
"badge": "Documento",
|
||||
"chattingAbout": "Stai parlando di: {title}",
|
||||
"chattingAboutMore": "Stai parlando di: {titles} e altri {count}",
|
||||
"notIndexedHint": "Leggi prima questo documento con l'IA nell'archivio così il Coach può rispondere."
|
||||
},
|
||||
"actionsMenu": "Azioni",
|
||||
@@ -3396,6 +3397,7 @@
|
||||
"sectionSubtitle": "Segnali cardiovascolari e respiratori a riposo rispetto alla tua baseline personale",
|
||||
"loadingLabel": "Caricamento dei tuoi parametri vitali",
|
||||
"loadError": "Al momento non è stato possibile caricare i tuoi parametri vitali.",
|
||||
"infoLabel": "Come viene calcolato questo dato?",
|
||||
"building": "Creazione del tuo intervallo abituale — {count} di {target} giorni",
|
||||
"typicalRange": "Abituale per te: {low}–{high}",
|
||||
"fitnessYounger": "Forma cardio di chi ha ~{years} anni in meno",
|
||||
@@ -3868,8 +3870,12 @@
|
||||
"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.",
|
||||
"hourlyNote": "Media oraria per questo giorno — i giorni recenti mostrano il dettaglio completo a 10 minuti.",
|
||||
"empty": "Non ci sono ancora abbastanza misurazioni per questo giorno per mostrarne l'andamento.",
|
||||
"baseline": "Riposo",
|
||||
"today": "Oggi",
|
||||
"previousDay": "Giorno precedente",
|
||||
"nextDay": "Giorno successivo",
|
||||
"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.",
|
||||
@@ -7692,7 +7698,10 @@
|
||||
"notePlaceholder": "Qualcosa da ricordare (cifrato).",
|
||||
"saveError": "Impossibile salvare l'episodio. Riprova.",
|
||||
"parent": "Parte di",
|
||||
"parentNone": "Non collegato"
|
||||
"parentNone": "Non collegato",
|
||||
"resolved": "Conclusa il",
|
||||
"resolvedHelp": "Lascia vuoto finché l'episodio è ancora in corso.",
|
||||
"markOngoing": "Ancora in corso"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Registra {date}",
|
||||
@@ -7744,7 +7753,8 @@
|
||||
"direction": {
|
||||
"above": "sopra il tuo solito",
|
||||
"below": "sotto il tuo solito"
|
||||
}
|
||||
},
|
||||
"settled": "Durante questo episodio nulla si è discostato in modo evidente dal tuo intervallo abituale."
|
||||
},
|
||||
"insights": {
|
||||
"title": "La tua storia di malattie",
|
||||
@@ -8608,8 +8618,7 @@
|
||||
"readFullBriefing": "Leggi il briefing completo",
|
||||
"worthALook": "Da tenere d'occhio",
|
||||
"allClear": "Oggi nulla richiede la tua attenzione.",
|
||||
"ringLink": "Apri i dettagli di {metric}",
|
||||
"ringDosesAria": "{taken} di {scheduled} dosi assunte oggi"
|
||||
"ringLink": "Apri i dettagli di {metric}"
|
||||
},
|
||||
"milestone": {
|
||||
"metric": {
|
||||
|
||||
+14
-5
@@ -2184,6 +2184,7 @@
|
||||
"docScope": {
|
||||
"badge": "Dokument",
|
||||
"chattingAbout": "Rozmowa o: {title}",
|
||||
"chattingAboutMore": "Rozmowa o: {titles} i {count} więcej",
|
||||
"notIndexedHint": "Najpierw wczytaj ten dokument z pomocą AI w archiwum, aby Coach mógł na jego temat odpowiadać."
|
||||
},
|
||||
"actionsMenu": "Działania",
|
||||
@@ -3396,6 +3397,7 @@
|
||||
"sectionSubtitle": "Spoczynkowe sygnały sercowo-naczyniowe i oddechowe na tle Twojej własnej linii bazowej",
|
||||
"loadingLabel": "Wczytywanie parametrów życiowych",
|
||||
"loadError": "Nie udało się teraz wczytać Twoich parametrów życiowych.",
|
||||
"infoLabel": "Jak to jest obliczane?",
|
||||
"building": "Budowanie Twojego typowego zakresu — {count} z {target} dni",
|
||||
"typicalRange": "Typowe dla Ciebie: {low}–{high}",
|
||||
"fitnessYounger": "Kondycja jak u osoby ~{years} lat młodszej",
|
||||
@@ -3868,8 +3870,12 @@
|
||||
"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.",
|
||||
"hourlyNote": "Pokazano średnią godzinową dla tego dnia — ostatnie dni pokazują pełny, 10-minutowy szczegół.",
|
||||
"empty": "Za mało pomiarów z tego dnia, aby pokazać jego przebieg.",
|
||||
"baseline": "Spoczynek",
|
||||
"today": "Dziś",
|
||||
"previousDay": "Poprzedni dzień",
|
||||
"nextDay": "Następny dzień",
|
||||
"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.",
|
||||
@@ -7692,7 +7698,10 @@
|
||||
"notePlaceholder": "Cokolwiek wartego zapamiętania (zaszyfrowane).",
|
||||
"saveError": "Nie udało się zapisać epizodu. Spróbuj ponownie.",
|
||||
"parent": "Część",
|
||||
"parentNone": "Niepowiązane"
|
||||
"parentNone": "Niepowiązane",
|
||||
"resolved": "Zakończono",
|
||||
"resolvedHelp": "Pozostaw puste, dopóki epizod nadal trwa.",
|
||||
"markOngoing": "Nadal trwa"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Zapisz {date}",
|
||||
@@ -7744,7 +7753,8 @@
|
||||
"direction": {
|
||||
"above": "powyżej Twojej normy",
|
||||
"below": "poniżej Twojej normy"
|
||||
}
|
||||
},
|
||||
"settled": "Podczas tego epizodu nic istotnego nie odbiegało od twojego zwykłego zakresu."
|
||||
},
|
||||
"insights": {
|
||||
"title": "Twoja historia chorób",
|
||||
@@ -8608,8 +8618,7 @@
|
||||
"readFullBriefing": "Przeczytaj pełny briefing",
|
||||
"worthALook": "Warto sprawdzić",
|
||||
"allClear": "Dziś nic nie wymaga Twojej uwagi.",
|
||||
"ringLink": "Otwórz szczegóły: {metric}",
|
||||
"ringDosesAria": "Przyjęto {taken} z {scheduled} dawek dzisiaj"
|
||||
"ringLink": "Otwórz szczegóły: {metric}"
|
||||
},
|
||||
"milestone": {
|
||||
"metric": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.29.0",
|
||||
"version": "1.29.2",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
-- v1.29.x — backfill non-canonical WHOOP `Workout.sport_type` values.
|
||||
--
|
||||
-- Root cause (confirmed from prod data): WHOOP was the only workout
|
||||
-- integration that never normalised its sport to HealthLog's canonical
|
||||
-- `WorkoutSportType` enum (see `workoutSportTypeEnum` in
|
||||
-- src/lib/validations/workout.ts). `upsertWhoopWorkout()` in
|
||||
-- src/lib/whoop/sync-workout.ts wrote WHOOP's raw `sport_name` verbatim, or
|
||||
-- a literal `whoop_sport_<sport_id>` placeholder when WHOOP sent only the
|
||||
-- (now-retired) numeric id — so existing WHOOP rows carry values the
|
||||
-- `/insights/workouts` list can't map to an icon/label (a WHOOP-tracked bike
|
||||
-- ride never rendered as "cycling"). `mapWhoopSportType()`
|
||||
-- (src/lib/whoop/sport-map.ts) fixes the write path going forward; this
|
||||
-- migration re-derives every EXISTING WHOOP `Workout` row from the same
|
||||
-- table so historical data and the live mapper never drift apart.
|
||||
--
|
||||
-- The WHEN arms below were generated FROM `WHOOP_SPORT_TABLE` in
|
||||
-- src/lib/whoop/sport-map.ts (one JS-side script run against that exact
|
||||
-- table, not hand-transcribed) and cover both shapes a pre-fix row could
|
||||
-- carry:
|
||||
-- - the literal `whoop_sport_<id>` placeholder (id present, no name), and
|
||||
-- - the raw `sport_name` WHOOP sent (name present), normalised the same
|
||||
-- way the SQL expression below normalises the stored column: lowercase,
|
||||
-- any run of characters outside `[a-z0-9_]` collapsed to a single `_`.
|
||||
-- (`normaliseSportKey()` in sport-map.ts mirrors this exact rule so a
|
||||
-- future re-generation of this list stays consistent with the SQL.)
|
||||
--
|
||||
-- Anything neither arm recognises falls to `ELSE 'other'` — same fallback
|
||||
-- `mapWhoopSportType()` uses, never leaves a row on a `whoop_sport_<n>`
|
||||
-- placeholder.
|
||||
--
|
||||
-- Idempotent: the WHERE clause excludes rows whose `sport_type` is ALREADY
|
||||
-- one of the 20 canonical values, so a re-run touches zero rows once the
|
||||
-- first run has completed. Safe to re-run after a partial failure.
|
||||
UPDATE "workouts"
|
||||
SET "sport_type" = CASE
|
||||
lower(regexp_replace(trim("sport_type"), '[^a-zA-Z0-9_]+', '_', 'g'))
|
||||
WHEN 'whoop_sport__1' THEN 'other'
|
||||
WHEN 'whoop_sport_0' THEN 'running'
|
||||
WHEN 'whoop_sport_1' THEN 'cycling'
|
||||
WHEN 'whoop_sport_16' THEN 'other'
|
||||
WHEN 'whoop_sport_17' THEN 'basketball'
|
||||
WHEN 'whoop_sport_18' THEN 'rowing'
|
||||
WHEN 'whoop_sport_19' THEN 'other'
|
||||
WHEN 'whoop_sport_20' THEN 'other'
|
||||
WHEN 'whoop_sport_21' THEN 'other'
|
||||
WHEN 'whoop_sport_22' THEN 'golf'
|
||||
WHEN 'whoop_sport_24' THEN 'other'
|
||||
WHEN 'whoop_sport_25' THEN 'other'
|
||||
WHEN 'whoop_sport_27' THEN 'other'
|
||||
WHEN 'whoop_sport_28' THEN 'other'
|
||||
WHEN 'whoop_sport_29' THEN 'other'
|
||||
WHEN 'whoop_sport_30' THEN 'soccer'
|
||||
WHEN 'whoop_sport_31' THEN 'other'
|
||||
WHEN 'whoop_sport_32' THEN 'tennis'
|
||||
WHEN 'whoop_sport_33' THEN 'swimming'
|
||||
WHEN 'whoop_sport_34' THEN 'tennis'
|
||||
WHEN 'whoop_sport_35' THEN 'running'
|
||||
WHEN 'whoop_sport_36' THEN 'other'
|
||||
WHEN 'whoop_sport_37' THEN 'swimming'
|
||||
WHEN 'whoop_sport_38' THEN 'strength'
|
||||
WHEN 'whoop_sport_39' THEN 'hiit'
|
||||
WHEN 'whoop_sport_42' THEN 'dance'
|
||||
WHEN 'whoop_sport_43' THEN 'mindAndBody'
|
||||
WHEN 'whoop_sport_44' THEN 'yoga'
|
||||
WHEN 'whoop_sport_45' THEN 'strength'
|
||||
WHEN 'whoop_sport_47' THEN 'other'
|
||||
WHEN 'whoop_sport_48' THEN 'crossTraining'
|
||||
WHEN 'whoop_sport_49' THEN 'mixedCardio'
|
||||
WHEN 'whoop_sport_51' THEN 'crossTraining'
|
||||
WHEN 'whoop_sport_52' THEN 'hiking'
|
||||
WHEN 'whoop_sport_53' THEN 'other'
|
||||
WHEN 'whoop_sport_55' THEN 'rowing'
|
||||
WHEN 'whoop_sport_56' THEN 'hiit'
|
||||
WHEN 'whoop_sport_57' THEN 'cycling'
|
||||
WHEN 'whoop_sport_59' THEN 'strength'
|
||||
WHEN 'whoop_sport_60' THEN 'crossTraining'
|
||||
WHEN 'whoop_sport_61' THEN 'rowing'
|
||||
WHEN 'whoop_sport_62' THEN 'mixedCardio'
|
||||
WHEN 'whoop_sport_63' THEN 'walking'
|
||||
WHEN 'whoop_sport_64' THEN 'other'
|
||||
WHEN 'whoop_sport_65' THEN 'elliptical'
|
||||
WHEN 'whoop_sport_66' THEN 'stairClimber'
|
||||
WHEN 'whoop_sport_70' THEN 'mindAndBody'
|
||||
WHEN 'whoop_sport_71' THEN 'other'
|
||||
WHEN 'whoop_sport_73' THEN 'other'
|
||||
WHEN 'whoop_sport_74' THEN 'other'
|
||||
WHEN 'whoop_sport_75' THEN 'other'
|
||||
WHEN 'whoop_sport_76' THEN 'other'
|
||||
WHEN 'whoop_sport_77' THEN 'other'
|
||||
WHEN 'whoop_sport_82' THEN 'other'
|
||||
WHEN 'whoop_sport_83' THEN 'stairClimber'
|
||||
WHEN 'whoop_sport_84' THEN 'hiit'
|
||||
WHEN 'whoop_sport_85' THEN 'other'
|
||||
WHEN 'whoop_sport_86' THEN 'other'
|
||||
WHEN 'whoop_sport_87' THEN 'other'
|
||||
WHEN 'whoop_sport_88' THEN 'other'
|
||||
WHEN 'whoop_sport_89' THEN 'other'
|
||||
WHEN 'whoop_sport_90' THEN 'other'
|
||||
WHEN 'whoop_sport_91' THEN 'other'
|
||||
WHEN 'whoop_sport_92' THEN 'other'
|
||||
WHEN 'whoop_sport_93' THEN 'other'
|
||||
WHEN 'whoop_sport_94' THEN 'crossTraining'
|
||||
WHEN 'whoop_sport_95' THEN 'other'
|
||||
WHEN 'whoop_sport_96' THEN 'hiit'
|
||||
WHEN 'whoop_sport_97' THEN 'cycling'
|
||||
WHEN 'whoop_sport_98' THEN 'hiit'
|
||||
WHEN 'whoop_sport_99' THEN 'other'
|
||||
WHEN 'whoop_sport_100' THEN 'other'
|
||||
WHEN 'whoop_sport_101' THEN 'tennis'
|
||||
WHEN 'whoop_sport_102' THEN 'other'
|
||||
WHEN 'whoop_sport_103' THEN 'hiit'
|
||||
WHEN 'whoop_sport_104' THEN 'other'
|
||||
WHEN 'whoop_sport_105' THEN 'other'
|
||||
WHEN 'whoop_sport_106' THEN 'tennis'
|
||||
WHEN 'whoop_sport_107' THEN 'mindAndBody'
|
||||
WHEN 'whoop_sport_108' THEN 'other'
|
||||
WHEN 'whoop_sport_109' THEN 'other'
|
||||
WHEN 'whoop_sport_110' THEN 'crossTraining'
|
||||
WHEN 'whoop_sport_111' THEN 'other'
|
||||
WHEN 'whoop_sport_112' THEN 'other'
|
||||
WHEN 'whoop_sport_113' THEN 'other'
|
||||
WHEN 'whoop_sport_121' THEN 'other'
|
||||
WHEN 'whoop_sport_123' THEN 'strength'
|
||||
WHEN 'whoop_sport_125' THEN 'other'
|
||||
WHEN 'whoop_sport_126' THEN 'cycling'
|
||||
WHEN 'whoop_sport_127' THEN 'hiit'
|
||||
WHEN 'whoop_sport_128' THEN 'mindAndBody'
|
||||
WHEN 'whoop_sport_230' THEN 'tennis'
|
||||
WHEN 'whoop_sport_231' THEN 'tennis'
|
||||
WHEN 'whoop_sport_232' THEN 'basketball'
|
||||
WHEN 'whoop_sport_233' THEN 'other'
|
||||
WHEN 'whoop_sport_234' THEN 'golf'
|
||||
WHEN 'whoop_sport_235' THEN 'other'
|
||||
WHEN 'whoop_sport_236' THEN 'other'
|
||||
WHEN 'whoop_sport_237' THEN 'other'
|
||||
WHEN 'whoop_sport_238' THEN 'other'
|
||||
WHEN 'whoop_sport_239' THEN 'other'
|
||||
WHEN 'whoop_sport_240' THEN 'other'
|
||||
WHEN 'whoop_sport_248' THEN 'crossTraining'
|
||||
WHEN 'whoop_sport_249' THEN 'tennis'
|
||||
WHEN 'whoop_sport_250' THEN 'crossTraining'
|
||||
WHEN 'whoop_sport_251' THEN 'other'
|
||||
WHEN 'whoop_sport_252' THEN 'walking'
|
||||
WHEN 'whoop_sport_253' THEN 'running'
|
||||
WHEN 'whoop_sport_254' THEN 'other'
|
||||
WHEN 'whoop_sport_255' THEN 'other'
|
||||
WHEN 'whoop_sport_258' THEN 'mindAndBody'
|
||||
WHEN 'whoop_sport_259' THEN 'yoga'
|
||||
WHEN 'whoop_sport_261' THEN 'stairClimber'
|
||||
WHEN 'whoop_sport_262' THEN 'other'
|
||||
WHEN 'whoop_sport_263' THEN 'other'
|
||||
WHEN 'whoop_sport_264' THEN 'other'
|
||||
WHEN 'whoop_sport_266' THEN 'walking'
|
||||
WHEN 'whoop_sport_267' THEN 'other'
|
||||
WHEN 'whoop_sport_268' THEN 'other'
|
||||
WHEN 'whoop_sport_269' THEN 'other'
|
||||
WHEN 'whoop_sport_270' THEN 'other'
|
||||
WHEN 'whoop_sport_272' THEN 'other'
|
||||
WHEN 'activity' THEN 'other'
|
||||
WHEN 'running' THEN 'running'
|
||||
WHEN 'cycling' THEN 'cycling'
|
||||
WHEN 'baseball' THEN 'other'
|
||||
WHEN 'basketball' THEN 'basketball'
|
||||
WHEN 'rowing' THEN 'rowing'
|
||||
WHEN 'fencing' THEN 'other'
|
||||
WHEN 'field_hockey' THEN 'other'
|
||||
WHEN 'football' THEN 'other'
|
||||
WHEN 'golf' THEN 'golf'
|
||||
WHEN 'ice_hockey' THEN 'other'
|
||||
WHEN 'lacrosse' THEN 'other'
|
||||
WHEN 'rugby' THEN 'other'
|
||||
WHEN 'sailing' THEN 'other'
|
||||
WHEN 'skiing' THEN 'other'
|
||||
WHEN 'soccer' THEN 'soccer'
|
||||
WHEN 'softball' THEN 'other'
|
||||
WHEN 'squash' THEN 'tennis'
|
||||
WHEN 'swimming' THEN 'swimming'
|
||||
WHEN 'tennis' THEN 'tennis'
|
||||
WHEN 'track_field' THEN 'running'
|
||||
WHEN 'volleyball' THEN 'other'
|
||||
WHEN 'water_polo' THEN 'swimming'
|
||||
WHEN 'wrestling' THEN 'strength'
|
||||
WHEN 'boxing' THEN 'hiit'
|
||||
WHEN 'dance' THEN 'dance'
|
||||
WHEN 'pilates' THEN 'mindAndBody'
|
||||
WHEN 'yoga' THEN 'yoga'
|
||||
WHEN 'weightlifting' THEN 'strength'
|
||||
WHEN 'cross_country_skiing' THEN 'other'
|
||||
WHEN 'functional_fitness' THEN 'crossTraining'
|
||||
WHEN 'duathlon' THEN 'mixedCardio'
|
||||
WHEN 'gymnastics' THEN 'crossTraining'
|
||||
WHEN 'hiking_rucking' THEN 'hiking'
|
||||
WHEN 'horseback_riding' THEN 'other'
|
||||
WHEN 'kayaking' THEN 'rowing'
|
||||
WHEN 'martial_arts' THEN 'hiit'
|
||||
WHEN 'mountain_biking' THEN 'cycling'
|
||||
WHEN 'powerlifting' THEN 'strength'
|
||||
WHEN 'rock_climbing' THEN 'crossTraining'
|
||||
WHEN 'paddleboarding' THEN 'rowing'
|
||||
WHEN 'triathlon' THEN 'mixedCardio'
|
||||
WHEN 'walking' THEN 'walking'
|
||||
WHEN 'surfing' THEN 'other'
|
||||
WHEN 'elliptical' THEN 'elliptical'
|
||||
WHEN 'stairmaster' THEN 'stairClimber'
|
||||
WHEN 'meditation' THEN 'mindAndBody'
|
||||
WHEN 'other' THEN 'other'
|
||||
WHEN 'diving' THEN 'other'
|
||||
WHEN 'operations_tactical' THEN 'other'
|
||||
WHEN 'operations_medical' THEN 'other'
|
||||
WHEN 'operations_flying' THEN 'other'
|
||||
WHEN 'operations_water' THEN 'other'
|
||||
WHEN 'ultimate' THEN 'other'
|
||||
WHEN 'climber' THEN 'stairClimber'
|
||||
WHEN 'jumping_rope' THEN 'hiit'
|
||||
WHEN 'australian_football' THEN 'other'
|
||||
WHEN 'skateboarding' THEN 'other'
|
||||
WHEN 'coaching' THEN 'other'
|
||||
WHEN 'ice_bath' THEN 'other'
|
||||
WHEN 'commuting' THEN 'other'
|
||||
WHEN 'gaming' THEN 'other'
|
||||
WHEN 'snowboarding' THEN 'other'
|
||||
WHEN 'motocross' THEN 'other'
|
||||
WHEN 'caddying' THEN 'other'
|
||||
WHEN 'obstacle_course_racing' THEN 'crossTraining'
|
||||
WHEN 'motor_racing' THEN 'other'
|
||||
WHEN 'hiit' THEN 'hiit'
|
||||
WHEN 'spin' THEN 'cycling'
|
||||
WHEN 'jiu_jitsu' THEN 'hiit'
|
||||
WHEN 'manual_labor' THEN 'other'
|
||||
WHEN 'cricket' THEN 'other'
|
||||
WHEN 'pickleball' THEN 'tennis'
|
||||
WHEN 'inline_skating' THEN 'other'
|
||||
WHEN 'box_fitness' THEN 'hiit'
|
||||
WHEN 'spikeball' THEN 'other'
|
||||
WHEN 'wheelchair_pushing' THEN 'other'
|
||||
WHEN 'paddle_tennis' THEN 'tennis'
|
||||
WHEN 'barre' THEN 'mindAndBody'
|
||||
WHEN 'stage_performance' THEN 'other'
|
||||
WHEN 'high_stress_work' THEN 'other'
|
||||
WHEN 'parkour' THEN 'crossTraining'
|
||||
WHEN 'gaelic_football' THEN 'other'
|
||||
WHEN 'hurling_camogie' THEN 'other'
|
||||
WHEN 'circus_arts' THEN 'other'
|
||||
WHEN 'massage_therapy' THEN 'other'
|
||||
WHEN 'strength_trainer' THEN 'strength'
|
||||
WHEN 'watching_sports' THEN 'other'
|
||||
WHEN 'assault_bike' THEN 'cycling'
|
||||
WHEN 'kickboxing' THEN 'hiit'
|
||||
WHEN 'stretching' THEN 'mindAndBody'
|
||||
WHEN 'table_tennis' THEN 'tennis'
|
||||
WHEN 'badminton' THEN 'tennis'
|
||||
WHEN 'netball' THEN 'basketball'
|
||||
WHEN 'sauna' THEN 'other'
|
||||
WHEN 'disc_golf' THEN 'golf'
|
||||
WHEN 'yard_work' THEN 'other'
|
||||
WHEN 'air_compression' THEN 'other'
|
||||
WHEN 'percussive_massage' THEN 'other'
|
||||
WHEN 'paintball' THEN 'other'
|
||||
WHEN 'ice_skating' THEN 'other'
|
||||
WHEN 'handball' THEN 'other'
|
||||
WHEN 'f45_training' THEN 'crossTraining'
|
||||
WHEN 'padel' THEN 'tennis'
|
||||
WHEN 'barry_s' THEN 'crossTraining'
|
||||
WHEN 'dedicated_parenting' THEN 'other'
|
||||
WHEN 'stroller_walking' THEN 'walking'
|
||||
WHEN 'stroller_jogging' THEN 'running'
|
||||
WHEN 'toddlerwearing' THEN 'other'
|
||||
WHEN 'babywearing' THEN 'other'
|
||||
WHEN 'barre3' THEN 'mindAndBody'
|
||||
WHEN 'hot_yoga' THEN 'yoga'
|
||||
WHEN 'stadium_steps' THEN 'stairClimber'
|
||||
WHEN 'polo' THEN 'other'
|
||||
WHEN 'musical_performance' THEN 'other'
|
||||
WHEN 'kite_boarding' THEN 'other'
|
||||
WHEN 'dog_walking' THEN 'walking'
|
||||
WHEN 'water_skiing' THEN 'other'
|
||||
WHEN 'wakeboarding' THEN 'other'
|
||||
WHEN 'cooking' THEN 'other'
|
||||
WHEN 'cleaning' THEN 'other'
|
||||
WHEN 'public_speaking' THEN 'other'
|
||||
ELSE 'other'
|
||||
END
|
||||
WHERE "source" = 'WHOOP'
|
||||
AND "sport_type" NOT IN (
|
||||
'walking', 'running', 'cycling', 'hiking', 'swimming', 'rowing',
|
||||
'elliptical', 'stairClimber', 'yoga', 'mindAndBody', 'strength', 'hiit',
|
||||
'dance', 'golf', 'tennis', 'basketball', 'soccer', 'crossTraining',
|
||||
'mixedCardio', 'other'
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Server-side "dismiss / mark seen" ledger for the Today rail's OBSERVATIONAL
|
||||
-- PriorityItem kinds (milestone, ecg_new_recording, tension_window). The
|
||||
-- ACTIONABLE kinds (dose_window, sync_issue, preventive_care, coach_checkin)
|
||||
-- never write a row here — they clear on their own once the user acts.
|
||||
--
|
||||
-- `item_key` is a deterministic identity for the candidate INSTANCE the
|
||||
-- digest builder already computes (kind + metric + reach day for a
|
||||
-- milestone, the recording's own timestamp for an ECG, the local day for a
|
||||
-- tension window) — not the bare kind — so dismissing today's milestone never
|
||||
-- suppresses tomorrow's.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dismissed_priority_items" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"item_key" TEXT NOT NULL,
|
||||
"dismissed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "dismissed_priority_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex — the dismiss upsert's natural key + the digest read's
|
||||
-- `userId + itemKey IN (...)` lookup.
|
||||
CREATE UNIQUE INDEX "dismissed_priority_items_user_id_item_key_key"
|
||||
ON "dismissed_priority_items" ("user_id", "item_key");
|
||||
|
||||
-- AddForeignKey — deleting a user cascades its dismissal rows away.
|
||||
ALTER TABLE "dismissed_priority_items"
|
||||
ADD CONSTRAINT "dismissed_priority_items_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -719,6 +719,8 @@ model User {
|
||||
// ships with v1.4.25 so the iOS app can build its query path
|
||||
// against a stable contract.
|
||||
personalRecords PersonalRecord[]
|
||||
// Today rail dismiss ledger — observational PriorityItem kinds only.
|
||||
dismissedPriorityItems DismissedPriorityItem[]
|
||||
// v1.4.25 W19b — per-pen / per-vial inventory entities, the
|
||||
// companion to the running-sum MedicationInventoryEvent ledger.
|
||||
// Carries the EMA §6.3 30-day in-use clock per opened pen.
|
||||
@@ -2100,6 +2102,34 @@ model PersonalRecord {
|
||||
@@map("personal_records")
|
||||
}
|
||||
|
||||
/// The Today rail's server-side "dismiss / mark seen" ledger — OBSERVATIONAL
|
||||
/// `PriorityItem` kinds only (`milestone`, `ecg_new_recording`,
|
||||
/// `tension_window`). The ACTIONABLE kinds (dose_window, sync_issue,
|
||||
/// preventive_care, coach_checkin) never write a row here — they clear on
|
||||
/// their own once the user acts, so a dismiss affordance on them would just
|
||||
/// duplicate the existing resolution path.
|
||||
///
|
||||
/// `itemKey` is a deterministic identity for the candidate INSTANCE the
|
||||
/// digest builder already computes (kind + metric + reach day for a
|
||||
/// milestone, the recording's own timestamp for an ECG, the local day for a
|
||||
/// tension window) — not the bare `kind` — so dismissing today's milestone
|
||||
/// never suppresses tomorrow's; the underlying item's own freshness window
|
||||
/// (reached-once / 24h / one-per-day) is what actually retires the row's
|
||||
/// relevance, this table only fast-forwards that for the rest of today.
|
||||
model DismissedPriorityItem {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
itemKey String @map("item_key")
|
||||
dismissedAt DateTime @default(now()) @map("dismissed_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// The dismiss upsert's natural key + the digest read's
|
||||
/// `userId + itemKey IN (...)` lookup.
|
||||
@@unique([userId, itemKey])
|
||||
@@map("dismissed_priority_items")
|
||||
}
|
||||
|
||||
// ─── Medications ───────────────────────────────────────────
|
||||
|
||||
/// Treatment-class discriminator (v1.4.25 W4d). GLP1 turns on the
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.29.0";
|
||||
/* @sw-version-fallback */ "v1.29.2";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* `POST /api/daily/digest/dismiss` — the Today rail dismiss surface.
|
||||
*
|
||||
* Under test: cookie/Bearer auth narrows the user, the `insights` module gate
|
||||
* returns a 403 `module.disabled` envelope when off, an `itemKey` that isn't
|
||||
* namespaced under a dismissible kind 422s BEFORE any DB write, and the happy
|
||||
* path upserts the `DismissedPriorityItem` row scoped to the caller.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
dismissedPriorityItem: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
remaining: 39,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return { ...actual, annotate: vi.fn() };
|
||||
});
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
const callPost = POST as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(body: unknown): NextRequest {
|
||||
return new NextRequest(new URL("http://localhost/api/daily/digest/dismiss"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
});
|
||||
|
||||
describe("POST /api/daily/digest/dismiss", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null as never);
|
||||
const res = await callPost(
|
||||
makeReq({ itemKey: "milestone:record_first:WEIGHT:2026-07-16" }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the 403 module.disabled envelope when insights is off", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: apiError('Module "insights" is not enabled', 403, {
|
||||
errorCode: "module.disabled",
|
||||
module: "insights",
|
||||
}),
|
||||
});
|
||||
const res = await callPost(
|
||||
makeReq({ itemKey: "milestone:record_first:WEIGHT:2026-07-16" }),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s an itemKey that isn't namespaced under a dismissible kind — an actionable item can never be dismissed", async () => {
|
||||
const res = await callPost(makeReq({ itemKey: "dose_window:anything" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s a missing itemKey", async () => {
|
||||
const res = await callPost(makeReq({}));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("422s an unknown extra field (strict schema)", async () => {
|
||||
const res = await callPost(
|
||||
makeReq({
|
||||
itemKey: "milestone:record_first:WEIGHT:2026-07-16",
|
||||
userId: "someone-else",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("upserts the dismissal scoped to the caller, for each dismissible kind", async () => {
|
||||
for (const itemKey of [
|
||||
"milestone:record_first:WEIGHT:2026-07-16",
|
||||
"ecg_new_recording:2026-07-16T08:00:00.000Z",
|
||||
"tension_window:2026-07-16:afternoon",
|
||||
]) {
|
||||
vi.mocked(prisma.dismissedPriorityItem.upsert).mockClear();
|
||||
const res = await callPost(makeReq({ itemKey }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({ dismissed: true });
|
||||
expect(prisma.dismissedPriorityItem.upsert).toHaveBeenCalledWith({
|
||||
where: { userId_itemKey: { userId: "user-1", itemKey } },
|
||||
update: {},
|
||||
create: { userId: "user-1", itemKey },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 429 when the caller's dismiss rate limit is exhausted", async () => {
|
||||
const { checkRateLimit } = await import("@/lib/rate-limit");
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await callPost(
|
||||
makeReq({ itemKey: "milestone:record_first:WEIGHT:2026-07-16" }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* POST /api/daily/digest/dismiss
|
||||
*
|
||||
* "Dismiss / mark seen" for the Today rail's OBSERVATIONAL `PriorityItem`
|
||||
* kinds only — `milestone`, `ecg_new_recording`, `tension_window`. The
|
||||
* ACTIONABLE kinds (`dose_window`, `sync_issue`, `preventive_care`,
|
||||
* `coach_checkin`) are never reachable here: `dismissPriorityItemSchema`
|
||||
* rejects any `itemKey` that doesn't carry one of the three dismissible
|
||||
* kind-prefixes before a lookup ever runs (§`isDismissibleItemKey`).
|
||||
*
|
||||
* Persisted server-side (`DismissedPriorityItem`, composite-unique on
|
||||
* `userId` + `itemKey`) so the dismissal survives reload / a second device —
|
||||
* an upsert, so a repeat dismiss of the same instance is a no-op rather than
|
||||
* a conflict. `userId` is narrowed from `requireAuth()` only; the body never
|
||||
* carries one. Cookie OR Bearer, mirroring the digest GET route's own auth
|
||||
* policy, and gated on the same `insights` module (the daily rail's host
|
||||
* surface).
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { dismissPriorityItemSchema } from "@/lib/validations/daily";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// A cheap, owner-scoped upsert; the limit only caps a runaway client loop.
|
||||
const DISMISS_RATE_LIMIT = 40;
|
||||
const DISMISS_WINDOW_MS = 60_000;
|
||||
|
||||
export const POST = apiHandler(async (req: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "insights");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`daily-digest-dismiss:${user.id}`,
|
||||
DISMISS_RATE_LIMIT,
|
||||
DISMISS_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = dismissPriorityItemSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { itemKey } = parsed.data;
|
||||
|
||||
await prisma.dismissedPriorityItem.upsert({
|
||||
where: { userId_itemKey: { userId: user.id, itemKey } },
|
||||
update: {},
|
||||
create: { userId: user.id, itemKey },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "daily.digest.item.dismissed" },
|
||||
meta: { daily_digest_item_kind: itemKey.split(":")[0] },
|
||||
});
|
||||
|
||||
return apiSuccess({ dismissed: true });
|
||||
});
|
||||
@@ -10,7 +10,11 @@
|
||||
* 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`.
|
||||
* literal is a 422 via `returnAllZodIssues`. The frontend day navigator
|
||||
* pages this same route backward through prior days — a day outside
|
||||
* `DENSE_INTRADAY_RETENTION_DAYS` (`dense-intraday-retention.ts`) reads back
|
||||
* at the coarser hourly grain instead of an empty series; the response's
|
||||
* `resolution` field tells the caller which.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -54,6 +58,7 @@ export const GET = apiHandler(async (req: Request) => {
|
||||
buckets: result.series.length,
|
||||
baseline_source: result.baselineSource,
|
||||
tension: result.tension !== null,
|
||||
resolution: result.resolution,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+5
-29
@@ -27,11 +27,8 @@ 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";
|
||||
@@ -235,31 +232,10 @@ 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]);
|
||||
// v1.29.1 — the v1.29.0 selected-score-ring cluster is removed from the web
|
||||
// hero (Marc, live-use: uneven, wasted tile space). The snapshot still
|
||||
// resolves `scoreRings` server-side for the iOS selection contract; the web
|
||||
// hero just no longer renders them, so no client-side ring ordering here.
|
||||
|
||||
const analyticsSlimQuery = useAnalyticsQuery({
|
||||
slice: "summaries",
|
||||
@@ -812,7 +788,7 @@ export default function DashboardPageClient() {
|
||||
(!mounted || digestQuery.isLoading ? (
|
||||
<TodayHeroSkeleton />
|
||||
) : digestQuery.data ? (
|
||||
<TodayHero digest={digestQuery.data} rings={heroScoreRings} />
|
||||
<TodayHero digest={digestQuery.data} />
|
||||
) : null)}
|
||||
|
||||
{/* v1.18.6 — the spotlight tour launcher moved to the app-shell
|
||||
|
||||
@@ -134,4 +134,61 @@ describe("<PriorityCard>", () => {
|
||||
);
|
||||
expect(html).toContain("View check-ups");
|
||||
});
|
||||
|
||||
describe("dismiss affordance — observational kinds only", () => {
|
||||
const DISMISSIBLE: PriorityItemKind[] = [
|
||||
"milestone",
|
||||
"ecg_new_recording",
|
||||
"tension_window",
|
||||
];
|
||||
const ACTIONABLE: PriorityItemKind[] = [
|
||||
"dose_window",
|
||||
"sync_issue",
|
||||
"preventive_care",
|
||||
"coach_checkin",
|
||||
];
|
||||
|
||||
it("renders the dismiss button on every observational kind that carries an itemKey", () => {
|
||||
for (const kind of DISMISSIBLE) {
|
||||
const html = render(
|
||||
<PriorityCard
|
||||
item={item({ kind, itemKey: `${kind}:instance-1` })}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="priority-card-dismiss"');
|
||||
}
|
||||
});
|
||||
|
||||
it("never renders the dismiss button on an actionable kind, even if a caller wires onDismiss", () => {
|
||||
for (const kind of ACTIONABLE) {
|
||||
const html = render(
|
||||
<PriorityCard
|
||||
item={item({ kind, itemKey: `${kind}:instance-1` })}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="priority-card-dismiss"');
|
||||
}
|
||||
});
|
||||
|
||||
it("does not render the dismiss button when the item carries no itemKey (server didn't stamp one)", () => {
|
||||
const html = render(
|
||||
<PriorityCard
|
||||
item={item({ kind: "milestone" })}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="priority-card-dismiss"');
|
||||
});
|
||||
|
||||
it("does not render the dismiss button when the caller passes no onDismiss handler", () => {
|
||||
const html = render(
|
||||
<PriorityCard
|
||||
item={item({ kind: "milestone", itemKey: "milestone:instance-1" })}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="priority-card-dismiss"');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ 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.
|
||||
@@ -141,34 +140,13 @@ 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", () => {
|
||||
// v1.29.1 — the v1.29.0 selected-score-ring cluster was removed from the web
|
||||
// hero (Marc, live-use: uneven, wasted tile space). Only the main
|
||||
// health-score ring paints now; the cluster's data-slots are gone.
|
||||
it("renders no score-ring cluster — only the health-score ring", () => {
|
||||
const html = render(<TodayHero digest={digest()} />);
|
||||
expect(html).not.toContain('data-slot="today-hero-ring-cluster"');
|
||||
expect(html).not.toContain('data-slot="today-hero-ring"');
|
||||
// The health-score ring alone still paints, exactly as before.
|
||||
expect(html).toContain('data-slot="today-hero-score"');
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
MessageCircle,
|
||||
Pill,
|
||||
RefreshCw,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -35,11 +36,12 @@ import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
PriorityItem,
|
||||
PriorityItemAction,
|
||||
PriorityItemKind,
|
||||
PriorityItemStatus,
|
||||
import {
|
||||
isDismissibleKind,
|
||||
type PriorityItem,
|
||||
type PriorityItemAction,
|
||||
type PriorityItemKind,
|
||||
type PriorityItemStatus,
|
||||
} from "@/lib/daily/priority-item";
|
||||
|
||||
/** Deterministic glyph per kind — the icon the serialisable item cannot hold. */
|
||||
@@ -93,13 +95,27 @@ export interface PriorityCardProps {
|
||||
* links and do not call this.
|
||||
*/
|
||||
onAction?: (intent: string) => void;
|
||||
/**
|
||||
* Dismiss handler for the OBSERVATIONAL kinds only (`milestone`,
|
||||
* `ecg_new_recording`, `tension_window`). Receives the item's own
|
||||
* `itemKey`. The card renders the affordance ONLY when the kind is
|
||||
* dismissible AND the item actually carries a key — an actionable item (no
|
||||
* `itemKey` by construction) never shows one, no matter what the caller
|
||||
* passes.
|
||||
*/
|
||||
onDismiss?: (itemKey: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Tap-target floor per P1: `min-h-11` on touch, `min-h-9` on pointer. */
|
||||
const ACTION_SIZE = "min-h-11 sm:min-h-9";
|
||||
|
||||
export function PriorityCard({ item, onAction, className }: PriorityCardProps) {
|
||||
export function PriorityCard({
|
||||
item,
|
||||
onAction,
|
||||
onDismiss,
|
||||
className,
|
||||
}: PriorityCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const Icon = KIND_ICON[item.kind];
|
||||
const actions = item.actions.slice(0, 3);
|
||||
@@ -111,6 +127,13 @@ export function PriorityCard({ item, onAction, className }: PriorityCardProps) {
|
||||
// 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";
|
||||
// Dismiss is offered ONLY on the observational kinds, and only once the
|
||||
// server has actually stamped an identity onto this instance — the
|
||||
// actionable kinds never carry an `itemKey`, so this is structurally false
|
||||
// for them regardless of what the caller wires up. Held as its own const
|
||||
// (rather than re-checked inline) so the narrowed `string` type carries
|
||||
// into the click handler below without a non-null assertion.
|
||||
const dismissKey = isDismissibleKind(item.kind) ? item.itemKey : undefined;
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -126,7 +149,26 @@ export function PriorityCard({ item, onAction, className }: PriorityCardProps) {
|
||||
style={hue ? ({ "--tile-hue": hue } as React.CSSProperties) : undefined}
|
||||
>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<TileHeader icon={Icon} title={item.title} size="sm" />
|
||||
<TileHeader
|
||||
icon={Icon}
|
||||
title={item.title}
|
||||
size="sm"
|
||||
right={
|
||||
dismissKey && onDismiss ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="priority-card-dismiss"
|
||||
className="text-muted-foreground hover:text-foreground -my-2 -mr-2 min-h-11 min-w-11 shrink-0 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
|
||||
onClick={() => onDismiss(dismissKey)}
|
||||
aria-label={t("common.dismiss")}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{item.body ? (
|
||||
<div className="text-foreground text-sm">
|
||||
<ProseBlocks text={item.body} strip={false} linkify={false} />
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
* - 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;
|
||||
* - v1.29.1 (Marc, live-use): the v1.29.0 selected-score-ring cluster is
|
||||
* removed from the web hero — it read as uneven and wasted tile space.
|
||||
* The health `ScoreRing` is the hero's only ring now. The score-ring
|
||||
* SELECTION contract stays server-side (`selectedScoreRings` on the
|
||||
* snapshot still feeds iOS); the web hero simply stops rendering it;
|
||||
* - 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;
|
||||
@@ -38,55 +39,18 @@ 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 { usePriorityItemDismiss } from "@/hooks/use-priority-item-dismiss";
|
||||
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,
|
||||
} 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(
|
||||
delta: number,
|
||||
@@ -98,24 +62,10 @@ function formatDelta(
|
||||
return t("daily.today.deltaVsBaseline", { delta: signed });
|
||||
}
|
||||
|
||||
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[];
|
||||
}) {
|
||||
export function TodayHero({ digest }: { digest: DailyDigest }) {
|
||||
const { t } = useTranslations();
|
||||
const { keep, letGo } = useCoachCheckinAction();
|
||||
const dismissItem = usePriorityItemDismiss();
|
||||
|
||||
// The coach check-in card's keep / let-go intents carry the plan id after the
|
||||
// ":" (a closed two-intent allowlist); adjust is an href handled by the card
|
||||
@@ -161,10 +111,15 @@ export function TodayHero({
|
||||
"p-4 md:p-6",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4 md:gap-5">
|
||||
{/* v1.29.1 — tightened after the v1.29.0 ring cluster was removed: the
|
||||
hero read as half-empty (short lead → gap → rail). Compact section
|
||||
gaps let the worth-a-look rail sit close under the lead so the card
|
||||
reads filled, not padded out. */}
|
||||
<div className="flex flex-col gap-3 md:gap-4">
|
||||
{/* The day's read — the numeric face on the trailing edge, the
|
||||
narrative lead leading. */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-6">
|
||||
narrative lead leading. On md+ the lead sits flush-top with the
|
||||
score ring (items-start) rather than floating centred beside it. */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between md:gap-6">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
{/* Hero numeric face: the read leads large in the foreground
|
||||
token, calm and legible — the day's read, not a slogan. */}
|
||||
@@ -240,62 +195,6 @@ export function TodayHero({
|
||||
</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. */}
|
||||
@@ -323,6 +222,7 @@ export function TodayHero({
|
||||
key={`${item.kind}-${i}`}
|
||||
item={item}
|
||||
onAction={handleAction}
|
||||
onDismiss={(itemKey) => dismissItem.mutate(itemKey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,25 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
|
||||
// the engine on thin data — never a recovery claim, never a second gap.
|
||||
const sleep = value.sleepContext;
|
||||
|
||||
// The engine can return `ok` with NO findings — a mild episode where nothing
|
||||
// strayed notably from the personal band, still active with no recovery gap,
|
||||
// and no red flag. Without this the card body rendered empty under its "how
|
||||
// it progressed" heading, reading as if nothing was recorded at all. Show a
|
||||
// calm, honest line instead (retrospective, never a fabricated finding).
|
||||
const hasContent =
|
||||
value.redFlags.length > 0 ||
|
||||
gap !== null ||
|
||||
value.preOnset.length > 0 ||
|
||||
value.nadir.length > 0;
|
||||
|
||||
if (!hasContent) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("illness.correlation.settled")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{value.redFlags.length > 0 ? (
|
||||
|
||||
@@ -83,6 +83,10 @@ export function NewEpisodeSheet({
|
||||
const [type, setType] = useState<IllnessType>("INFECTION");
|
||||
const [lifecycle, setLifecycle] = useState<IllnessLifecycle>("ACUTE");
|
||||
const [onset, setOnset] = useState(today);
|
||||
// Resolved/end date — "" means the episode is still ongoing (open-ended).
|
||||
// Backdating both onset and this end date lets a whole past episode be
|
||||
// recorded after the fact.
|
||||
const [resolved, setResolved] = useState("");
|
||||
const [parentId, setParentId] = useState<string>(NONE);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
@@ -96,6 +100,9 @@ export function NewEpisodeSheet({
|
||||
setType(editEpisode?.type ?? "INFECTION");
|
||||
setLifecycle(editEpisode?.lifecycle ?? "ACUTE");
|
||||
setOnset(editEpisode ? editEpisode.onsetAt.slice(0, 10) : today);
|
||||
setResolved(
|
||||
editEpisode?.resolvedAt ? editEpisode.resolvedAt.slice(0, 10) : "",
|
||||
);
|
||||
setParentId(editEpisode?.parentConditionId ?? NONE);
|
||||
setNote(editEpisode?.note ?? "");
|
||||
} else if (!open && wasOpen) {
|
||||
@@ -104,9 +111,17 @@ export function NewEpisodeSheet({
|
||||
|
||||
const showParent = PARENTABLE.includes(lifecycle);
|
||||
|
||||
// The end date can never precede the start; the field is min-bounded to the
|
||||
// onset, and this guard belts-and-braces the same invariant the server holds.
|
||||
const endBeforeStart = resolved !== "" && resolved < onset;
|
||||
|
||||
async function handleSave() {
|
||||
if (label.trim() === "") return;
|
||||
if (label.trim() === "" || endBeforeStart) return;
|
||||
const onsetAt = new Date(`${onset}T12:00:00`).toISOString();
|
||||
// "" ⇒ ongoing (null clears any stored end). Anchor to local noon so the
|
||||
// UTC instant lands on the intended calendar day regardless of offset.
|
||||
const resolvedAt =
|
||||
resolved === "" ? null : new Date(`${resolved}T12:00:00`).toISOString();
|
||||
const parentConditionId = showParent && parentId !== NONE ? parentId : null;
|
||||
try {
|
||||
if (isEdit && editEpisode) {
|
||||
@@ -117,6 +132,7 @@ export function NewEpisodeSheet({
|
||||
type,
|
||||
lifecycle,
|
||||
onsetAt,
|
||||
resolvedAt,
|
||||
parentConditionId,
|
||||
note: note.trim() === "" ? null : note,
|
||||
},
|
||||
@@ -126,9 +142,8 @@ export function NewEpisodeSheet({
|
||||
label: label.trim(),
|
||||
type,
|
||||
lifecycle,
|
||||
// Anchor to local noon so the UTC instant lands on the intended
|
||||
// calendar day regardless of the viewer's offset.
|
||||
onsetAt,
|
||||
resolvedAt,
|
||||
parentConditionId,
|
||||
note: note.trim() === "" ? null : note,
|
||||
});
|
||||
@@ -154,7 +169,7 @@ export function NewEpisodeSheet({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={pending || label.trim() === ""}
|
||||
disabled={pending || label.trim() === "" || endBeforeStart}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
@@ -240,6 +255,33 @@ export function NewEpisodeSheet({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="illness-resolved">{t("illness.new.resolved")}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<DateField
|
||||
id="illness-resolved"
|
||||
value={resolved}
|
||||
min={onset}
|
||||
max={today}
|
||||
onChange={setResolved}
|
||||
className="max-w-44"
|
||||
/>
|
||||
{resolved !== "" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setResolved("")}
|
||||
>
|
||||
{t("illness.new.markOngoing")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("illness.new.resolvedHelp")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="illness-note">{t("illness.new.note")}</Label>
|
||||
<Textarea
|
||||
|
||||
@@ -59,6 +59,9 @@ export interface IllnessEpisodeCreateInput {
|
||||
type: IllnessType;
|
||||
lifecycle?: IllnessLifecycle;
|
||||
onsetAt?: string;
|
||||
/** Optional resolved/end instant — set to backdate an already-recovered
|
||||
* episode captured after the fact; omit/null for an ongoing episode. */
|
||||
resolvedAt?: string | null;
|
||||
parentConditionId?: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { CoachConversation } from "../coach-conversation";
|
||||
* When the Coach is fenced — here seeded via the `/coach?doc=<id>` hand-off,
|
||||
* which stages ONE document as a pending first-turn attachment before any
|
||||
* thread exists — the shared `<CoachConversation>` surface paints the
|
||||
* `coach-doc-scope` banner: a paperclip + "N documents attached" count and the
|
||||
* `coach-doc-scope` banner: a paperclip + the attached document name(s) and the
|
||||
* honest fencing line (the coach runs WITHOUT access to health data and reads
|
||||
* only the attached documents). While a staged attachment is still being
|
||||
* content-indexed the banner adds the "still being indexed" hint. On a normal
|
||||
@@ -73,8 +73,8 @@ describe("<CoachConversation> fenced banner (S7)", () => {
|
||||
makeClient(true),
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-doc-scope"');
|
||||
// Exactly one document staged → the singular count.
|
||||
expect(html).toContain("1 document attached");
|
||||
// Exactly one document staged → the banner names it, not a generic count.
|
||||
expect(html).toContain(`Chatting about: ${TITLE}`);
|
||||
// The honest fencing line is always present on a fenced conversation.
|
||||
expect(html).toContain(FENCING_LINE);
|
||||
// Indexed → no "still being indexed" hint.
|
||||
|
||||
@@ -954,20 +954,35 @@ export function CoachConversation({
|
||||
// ONLY the attached documents, and counts the attachments. Rendered in BOTH
|
||||
// surfaces (page + drawer) above the thread so the fence is always visible.
|
||||
// Null on a normal health thread.
|
||||
const attachedCount = attachmentPills.length;
|
||||
// v1.29.1 — name the attached document(s) in the banner so returning to a
|
||||
// fenced thread later is clear about what it is about, instead of a generic
|
||||
// "N documents attached". One doc → its title; two → both, comma-joined
|
||||
// through the reused `docScope.chattingAbout` phrasing; three or more → the
|
||||
// first two plus an "and N more" tail. Falls back to the plain count only when
|
||||
// no title has resolved yet (a sticky-fenced thread whose attachment is still
|
||||
// in flight), so the banner is never empty.
|
||||
const attachedTitles = attachmentPills.map((pill) => pill.title);
|
||||
const attachedCount = attachedTitles.length;
|
||||
const docScopeText =
|
||||
attachedCount === 0
|
||||
? t("insights.coach.attach.documentsAttachedOther", { count: 0 })
|
||||
: attachedCount <= 2
|
||||
? t("insights.coach.docScope.chattingAbout", {
|
||||
title: attachedTitles.join(", "),
|
||||
})
|
||||
: t("insights.coach.docScope.chattingAboutMore", {
|
||||
titles: attachedTitles.slice(0, 2).join(", "),
|
||||
count: attachedCount - 2,
|
||||
});
|
||||
const docScopeBanner = fenced ? (
|
||||
<div
|
||||
data-slot="coach-doc-scope"
|
||||
className="border-border/70 bg-muted/20 flex shrink-0 flex-col gap-1 border-b px-4 py-2 sm:px-6"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs">
|
||||
<span className="bg-primary/15 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 font-medium">
|
||||
<Paperclip className="size-3" aria-hidden="true" />
|
||||
{attachedCount === 1
|
||||
? t("insights.coach.attach.documentsAttachedOne")
|
||||
: t("insights.coach.attach.documentsAttachedOther", {
|
||||
count: attachedCount,
|
||||
})}
|
||||
<span className="bg-primary/15 text-primary inline-flex min-w-0 items-center gap-1 rounded-full px-2 py-0.5 font-medium">
|
||||
<Paperclip className="size-3 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{docScopeText}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[11px] leading-snug">
|
||||
|
||||
@@ -595,13 +595,19 @@ export function CoachInput({
|
||||
<Paperclip className="size-5 sm:size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" side="top" className="w-56">
|
||||
{/* v1.29.1 — the item labels shrink + truncate within the popover width
|
||||
(`min-w-0` + a `truncate` span) so a long localised label — e.g.
|
||||
"Neues Dokument hochladen" — can never overflow past the menu edge.
|
||||
Mirrors the vault picker items' width handling. */}
|
||||
<DropdownMenuContent align="start" side="top" className="w-60">
|
||||
<DropdownMenuItem
|
||||
data-slot="coach-input-attach-vault"
|
||||
onSelect={() => onPickFromVault?.()}
|
||||
>
|
||||
<FolderOpen className="size-4" aria-hidden="true" />
|
||||
{t("insights.coach.attach.menuChooseFromDocuments")}
|
||||
<FolderOpen className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t("insights.coach.attach.menuChooseFromDocuments")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-slot="coach-input-attach-upload"
|
||||
@@ -611,8 +617,10 @@ export function CoachInput({
|
||||
setTimeout(() => attachFileInputRef.current?.click(), 0)
|
||||
}
|
||||
>
|
||||
<Upload className="size-4" aria-hidden="true" />
|
||||
{t("insights.coach.attach.menuUploadNew")}
|
||||
<Upload className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t("insights.coach.attach.menuUploadNew")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { SparklineDeltaTile } from "./sparkline-delta-tile";
|
||||
import { LearnMoreLink } from "@/components/ui/learn-more-link";
|
||||
import { CoverageMeter } from "./coverage-meter";
|
||||
import { ProvenanceExplainer } from "./provenance-explainer";
|
||||
import { VitalsInfoTip } from "./vitals-info-tip";
|
||||
import { METRIC_PROVENANCE } from "./standards";
|
||||
import { type DerivedBatchRead } from "./use-derived-metric";
|
||||
import {
|
||||
@@ -169,10 +169,16 @@ function tileOrder(layout: InsightsLayout | undefined, tileId: string): number {
|
||||
return resolveTileLayout(layout, tileId).order;
|
||||
}
|
||||
|
||||
/** The provenance ⓘ explainer for one derived metric, wired from the map. */
|
||||
/**
|
||||
* The (i) info-tip trigger for one derived metric, wired from the map.
|
||||
* `provenance` (inputs / source / window / asOf) isn't rendered here —
|
||||
* `VitalsInfoTip` carries only the method/caveat + cited standard, mirroring
|
||||
* what `ProvenanceExplainer` itself renders today — but the param is kept so
|
||||
* every call site (which already reads `data.provenance` off the derived
|
||||
* batch) doesn't need touching if a future pass restores the detail.
|
||||
*/
|
||||
function MetricProvenance({
|
||||
metric,
|
||||
provenance,
|
||||
}: {
|
||||
metric: keyof typeof METRIC_PROVENANCE;
|
||||
provenance: DerivedProvenance;
|
||||
@@ -189,13 +195,7 @@ function MetricProvenance({
|
||||
{t(meta.methodKey)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<ProvenanceExplainer
|
||||
provenance={provenance}
|
||||
method={method}
|
||||
standard={meta.standard}
|
||||
/>
|
||||
);
|
||||
return <VitalsInfoTip method={method} standard={meta.standard} />;
|
||||
}
|
||||
|
||||
interface TileProps {
|
||||
@@ -288,6 +288,13 @@ function BaselineTile({
|
||||
framing={framing}
|
||||
directionSentiment={vitalSentiment(type)}
|
||||
provenance={
|
||||
// v1.29.1 muted this on VITALS_BASELINE tiles because the always-on
|
||||
// "typical range = median ± MAD" caption repeated across every vital
|
||||
// baseline tile and read as clutter. v1.29.2 restores the context —
|
||||
// now behind the compact (i) trigger (`VitalsInfoTip`) instead of a
|
||||
// full-width caption, so every baseline tile carries it again
|
||||
// without the repetition or the header-row squeeze that clipped the
|
||||
// cardio-fitness heading.
|
||||
<MetricProvenance metric={metric} provenance={data.provenance} />
|
||||
}
|
||||
footer={<LearnMoreLink concept={tileId} />}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProvenanceStandard } from "./provenance-explainer";
|
||||
|
||||
/**
|
||||
* v1.29.2 QoL — the vitals-dashboard tile's (i) info affordance.
|
||||
*
|
||||
* v1.29.1 dropped the always-on "your personal normal range is the
|
||||
* median…" method caption from every `VITALS_BASELINE` tile because it
|
||||
* repeated verbatim across up to six tiles and read as clutter at the top
|
||||
* of the vitals surface — and, on the narrower tiles, squeezed the heading
|
||||
* itself (the VO₂max/cardio-fitness clip). Rather than reintroduce the
|
||||
* caption at full length, the SAME method/caveat/standard content moves
|
||||
* behind a small (i) trigger: a click-opened `Popover` (not a hover
|
||||
* tooltip — the vitals grid sees most of its use on touch), so the context
|
||||
* is one tap away instead of permanently occupying header space.
|
||||
*
|
||||
* This is a DELIBERATE, narrowly-scoped exception to the v1.22 "no
|
||||
* icon-only disclosure" direction (`ProvenanceExplainer`'s own docstring):
|
||||
* that decision targeted the always-visible-caption pattern on OTHER
|
||||
* surfaces (coincident-deviation, the score-anatomy pages), which stay
|
||||
* unchanged. The vitals grid is dense enough, and the caption repetitive
|
||||
* enough, that the same content is better one tap away here.
|
||||
*/
|
||||
export interface VitalsInfoTipProps {
|
||||
/** Plain-language method (+ optional caveat) description — text children only. */
|
||||
method: React.ReactNode;
|
||||
/** Optional cited standard, rendered as a plain external link. */
|
||||
standard?: ProvenanceStandard;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VitalsInfoTip({
|
||||
method,
|
||||
standard,
|
||||
className,
|
||||
}: VitalsInfoTipProps) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="vitals-info-trigger"
|
||||
aria-label={t("insights.derived.vitals.infoLabel")}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
|
||||
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
|
||||
"-mx-2 -my-3",
|
||||
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-slot="vitals-info-body"
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="max-w-xs space-y-1.5"
|
||||
>
|
||||
<p className="text-muted-foreground text-xs leading-snug">{method}</p>
|
||||
{standard ? (
|
||||
<a
|
||||
href={standard.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary block text-xs leading-snug hover:underline"
|
||||
>
|
||||
{standard.name}
|
||||
</a>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Activity } from "lucide-react";
|
||||
import { Activity, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
@@ -18,11 +18,13 @@ import {
|
||||
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 { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { userDayKey, shiftDateKey } from "@/lib/tz/format";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type {
|
||||
IntradayHrBucket,
|
||||
IntradayResolution,
|
||||
TensionWindow,
|
||||
} from "@/lib/analytics/intraday-pulse";
|
||||
|
||||
@@ -34,6 +36,7 @@ interface IntradayPulseDto {
|
||||
baseline: number | null;
|
||||
baselineSource: "resting" | "proxy" | "none";
|
||||
tension: TensionWindow | null;
|
||||
resolution: IntradayResolution;
|
||||
}
|
||||
|
||||
/** Minutes-since-midnight → "HH:mm" wall-clock label. */
|
||||
@@ -53,6 +56,14 @@ const AXIS_TICKS = [0, 360, 720, 1080, 1440];
|
||||
* 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.
|
||||
*
|
||||
* v1.29.x — a day navigator (`TileHeader`'s `right` slot, per UI-STANDARDS
|
||||
* §11) pages the same route backward through prior days; "next" is disabled
|
||||
* at today, so the view can never step into the future. A day outside the
|
||||
* dense retention window (`DENSE_INTRADAY_RETENTION_DAYS`) reads back at the
|
||||
* coarser hourly grain instead of an empty chart — `resolution` on the DTO
|
||||
* tells the caption which, and the tension read is always absent on an
|
||||
* hourly day (it needs per-sample resolution to stay honest).
|
||||
*/
|
||||
export function IntradayPulseChart({
|
||||
userTimezone,
|
||||
@@ -61,8 +72,24 @@ export function IntradayPulseChart({
|
||||
}) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const tz = userTimezone ?? "Europe/Berlin";
|
||||
const dateKey = userDayKey(new Date(), tz);
|
||||
const todayKey = userDayKey(new Date(), tz);
|
||||
// `null` tracks "today" live (so a session left open across midnight keeps
|
||||
// following the current day); a non-null value pins the viewed day once
|
||||
// the user has navigated away from today.
|
||||
const [selectedDateKey, setSelectedDateKey] = useState<string | null>(null);
|
||||
const dateKey = selectedDateKey ?? todayKey;
|
||||
const isToday = dateKey === todayKey;
|
||||
|
||||
const goToPreviousDay = () => setSelectedDateKey(shiftDateKey(dateKey, -1));
|
||||
const goToNextDay = () => {
|
||||
const next = shiftDateKey(dateKey, 1);
|
||||
// Defence in depth alongside the button's `disabled` — never step past
|
||||
// today regardless of how the handler is reached.
|
||||
if (next > todayKey) return;
|
||||
setSelectedDateKey(next === todayKey ? null : next);
|
||||
};
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.insightsPulseIntraday(dateKey),
|
||||
@@ -85,7 +112,13 @@ export function IntradayPulseChart({
|
||||
|
||||
const caption = data?.tension
|
||||
? t(`insights.intradayPulse.tension.${data.tension.partOfDay}`)
|
||||
: t("insights.intradayPulse.caption");
|
||||
: data?.resolution === "hourly"
|
||||
? t("insights.intradayPulse.hourlyNote")
|
||||
: t("insights.intradayPulse.caption");
|
||||
|
||||
const dayLabel = isToday
|
||||
? t("insights.intradayPulse.today")
|
||||
: fmt.dateShort(new Date(`${dateKey}T12:00:00.000Z`));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -98,7 +131,41 @@ export function IntradayPulseChart({
|
||||
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")} />
|
||||
<TileHeader
|
||||
icon={Activity}
|
||||
title={t("insights.intradayPulse.title")}
|
||||
right={
|
||||
<div
|
||||
data-slot="intraday-pulse-day-nav"
|
||||
className="flex items-center gap-0.5"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:size-8"
|
||||
onClick={goToPreviousDay}
|
||||
aria-label={t("insights.intradayPulse.previousDay")}
|
||||
>
|
||||
<ChevronLeft className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<span className="text-muted-foreground min-w-14 text-center text-xs tabular-nums">
|
||||
{dayLabel}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:size-8"
|
||||
onClick={goToNextDay}
|
||||
disabled={isToday}
|
||||
aria-label={t("insights.intradayPulse.nextDay")}
|
||||
>
|
||||
<ChevronRight className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs leading-snug">{caption}</p>
|
||||
|
||||
{isLoading ? (
|
||||
|
||||
@@ -176,6 +176,38 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forces the inactive Today digest to refetch so the dose-window item clears without a reload (v1.29.1)", async () => {
|
||||
// The Today hero's digest reads meds from the same server snapshot the
|
||||
// intake route hard-evicts, but it lives on the dashboard — unmounted while
|
||||
// the user is on the medication card. Without an explicit inactive refetch
|
||||
// the digest's "dose due" rail item lingered until a hard reload.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ data: { id: "evt-1" } }),
|
||||
text: vi
|
||||
.fn()
|
||||
.mockResolvedValue(JSON.stringify({ data: { id: "evt-1" } })),
|
||||
}),
|
||||
);
|
||||
const queryClient = fakeQueryClient();
|
||||
|
||||
await runRecordIntake({
|
||||
medication,
|
||||
skipped: false,
|
||||
t,
|
||||
queryClient,
|
||||
setIntakeLoading: vi.fn(),
|
||||
undoIntake: vi.fn(),
|
||||
});
|
||||
|
||||
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.dailyDigest(),
|
||||
refetchType: "inactive",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the displayed dose's scheduledFor on the POST so the server marks THAT slot (v1.12.3)", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -16,20 +16,32 @@ import { apiDelete, apiPost } from "@/lib/api/api-fetch";
|
||||
* Invalidate every read that reflects a dose's taken/due state after an
|
||||
* intake write. `invalidateKeys` invalidates with the default
|
||||
* `refetchType: "active"`, which only refetches mounted queries — so the
|
||||
* dashboard snapshot, inactive while the user is on the medication card or
|
||||
* detail page, is marked stale but never refetched. On navigating back the
|
||||
* snapshot remounts under `refetchOnMount: false`, so the pre-write cache is
|
||||
* served and the "due" prompt lingers until a hard reload. Force the inactive
|
||||
* snapshot to refetch so the dashboard clears as soon as the dose is recorded.
|
||||
* dashboard snapshot AND the Today digest, both inactive while the user is on
|
||||
* the medication card or detail page, are marked stale but never refetched. On
|
||||
* navigating back each remounts under `refetchOnMount: false`, so the pre-write
|
||||
* cache is served and the "due" prompt (snapshot dose tally) plus the digest's
|
||||
* dose-window rail item both linger until a hard reload. Force the inactive
|
||||
* queries to refetch so the dashboard clears as soon as the dose is recorded.
|
||||
*
|
||||
* v1.29.1 — the digest joins the forced-inactive refetch. Its `medsToday`
|
||||
* reads the SAME server snapshot cell the intake route already hard-evicts, so
|
||||
* the refetch returns post-write dose state immediately (no server change
|
||||
* needed — the eviction seam is reused).
|
||||
*/
|
||||
async function invalidateMedicationReads(
|
||||
queryClient: QueryClient,
|
||||
): Promise<void> {
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.dashboardSnapshot(),
|
||||
refetchType: "inactive",
|
||||
});
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.dashboardSnapshot(),
|
||||
refetchType: "inactive",
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.dailyDigest(),
|
||||
refetchType: "inactive",
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
type Translator = (
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Today rail — "dismiss / mark seen" for the OBSERVATIONAL `PriorityItem`
|
||||
* kinds (`milestone`, `ecg_new_recording`, `tension_window`). Posts the
|
||||
* item's own `itemKey` to `POST /api/daily/digest/dismiss` (server persists
|
||||
* it so the dismissal survives reload / a second device), then invalidates
|
||||
* the daily digest so the dismissed card leaves the rail immediately — the
|
||||
* same invalidate-on-mutate pattern `useCoachCheckinAction` uses. Keys route
|
||||
* through the centralised factory — no bare arrays.
|
||||
*/
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export function usePriorityItemDismiss() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (itemKey: string) => {
|
||||
await apiPost("/api/daily/digest/dismiss", { itemKey });
|
||||
return itemKey;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -317,6 +317,14 @@ describe("dependent-key bundles", () => {
|
||||
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
|
||||
expect(keyStrings).toContain(JSON.stringify(["dashboard", "snapshot"]));
|
||||
});
|
||||
|
||||
// v1.29.1 — the Today digest reads `medsToday` from the same server
|
||||
// snapshot the intake routes hard-evict, but nothing invalidated the client
|
||||
// query, so the digest's dose-window rail item lingered until a hard reload.
|
||||
it("medicationDependentKeys bundles the daily digest (v1.29.1)", () => {
|
||||
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
|
||||
expect(keyStrings).toContain(JSON.stringify(["daily", "digest"]));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
BUCKET_MINUTES,
|
||||
ELEVATION_MARGIN_BPM,
|
||||
MIN_STEP_COVERAGE_BUCKETS,
|
||||
computeHourlyMeanSeries,
|
||||
computeTenMinuteMeanSeries,
|
||||
detectTensionWindow,
|
||||
makeLocalResolver,
|
||||
@@ -114,6 +115,50 @@ describe("computeTenMinuteMeanSeries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeHourlyMeanSeries", () => {
|
||||
it("means folded stats: rows landing in the same local hour", () => {
|
||||
const series = computeHourlyMeanSeries(
|
||||
[
|
||||
sample("2026-05-01T10:30:00Z", 70), // hour 10
|
||||
sample("2026-05-01T10:45:00Z", 80), // same hour 10 (defensive dup)
|
||||
sample("2026-05-01T14:30:00Z", 90), // hour 14
|
||||
],
|
||||
"2026-05-01",
|
||||
utcOf,
|
||||
);
|
||||
expect(series).toEqual([
|
||||
{ startMinute: 600, mean: 75, count: 2 },
|
||||
{ startMinute: 840, mean: 90, count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes samples from other local days (no next-day bleed)", () => {
|
||||
const series = computeHourlyMeanSeries(
|
||||
[
|
||||
sample("2026-05-01T23:30:00Z", 70), // stays on 05-01, hour 23
|
||||
sample("2026-05-02T00:30:00Z", 90), // 05-02, hour 0
|
||||
],
|
||||
"2026-05-01",
|
||||
utcOf,
|
||||
);
|
||||
expect(series).toEqual([{ startMinute: 1380, 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 → hour 22, prior day.
|
||||
const s = [sample("2026-05-01T02:30:00Z", 72)];
|
||||
expect(computeHourlyMeanSeries(s, "2026-05-01", nyOf)).toEqual([]);
|
||||
expect(computeHourlyMeanSeries(s, "2026-04-30", nyOf)).toEqual([
|
||||
{ startMinute: 1320, mean: 72, count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty series for a day with no folded rows", () => {
|
||||
expect(computeHourlyMeanSeries([], "2026-05-01", utcOf)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("partOfDayForMinute", () => {
|
||||
it("labels the coarse part of day", () => {
|
||||
expect(partOfDayForMinute(5 * 60)).toBe("night");
|
||||
|
||||
@@ -20,11 +20,14 @@ import { percentile } from "@/lib/insights/strain-score";
|
||||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||||
import {
|
||||
BUCKET_MINUTES,
|
||||
HOURLY_BUCKET_MINUTES,
|
||||
MIN_BASELINE_DAYS,
|
||||
computeHourlyMeanSeries,
|
||||
computeTenMinuteMeanSeries,
|
||||
detectTensionWindow,
|
||||
makeLocalResolver,
|
||||
type IntradayHrBucket,
|
||||
type IntradayResolution,
|
||||
type LocalResolver,
|
||||
type TensionWindow,
|
||||
type WorkoutInterval,
|
||||
@@ -47,6 +50,15 @@ export interface IntradayPulseResult {
|
||||
baseline: number | null;
|
||||
baselineSource: "resting" | "proxy" | "none";
|
||||
tension: TensionWindow | null;
|
||||
/**
|
||||
* S11 day navigator — `"tenMin"` when `series` was folded from live raw
|
||||
* per-sample rows, `"hourly"` when the day fell outside
|
||||
* `DENSE_INTRADAY_RETENTION_DAYS` and `series` is the coarser fallback
|
||||
* read off the folded `stats:` hourly-mean tier instead. `tension` is
|
||||
* always `null` on an `"hourly"` day — see `detectTensionWindow`'s
|
||||
* docblock for why a tension read needs per-sample resolution.
|
||||
*/
|
||||
resolution: IntradayResolution;
|
||||
}
|
||||
|
||||
/** Median (p50), or null on an empty series. */
|
||||
@@ -147,7 +159,7 @@ export async function loadIntradayPulse(
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
take: MAX_DAY_PULSE_ROWS,
|
||||
select: { value: true, measuredAt: true },
|
||||
select: { value: true, measuredAt: true, externalId: true },
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
@@ -171,12 +183,49 @@ export async function loadIntradayPulse(
|
||||
resolveBaseline(userId),
|
||||
]);
|
||||
|
||||
const series = computeTenMinuteMeanSeries(
|
||||
pulseRows.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
// Split live PULSE rows into raw per-sample rows and already-folded
|
||||
// `stats:` hourly-mean rows (the dense-intraday-retention fold's output —
|
||||
// see `dense-intraday-retention.ts`). A day's raw rows are folded and
|
||||
// tombstoned atomically as a whole, so in practice a day is either wholly
|
||||
// raw or wholly folded; the split still guards against ever mixing the two
|
||||
// grains into one mislabeled series.
|
||||
const rawPulseRows = pulseRows.filter(
|
||||
(r) => !r.externalId?.startsWith("stats:"),
|
||||
);
|
||||
const foldedPulseRows = pulseRows.filter((r) =>
|
||||
r.externalId?.startsWith("stats:"),
|
||||
);
|
||||
|
||||
const tenMinSeries = computeTenMinuteMeanSeries(
|
||||
rawPulseRows.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
dateKey,
|
||||
localOf,
|
||||
);
|
||||
|
||||
// Day-navigator fallback (S11 / v1.29.x): a day outside
|
||||
// `DENSE_INTRADAY_RETENTION_DAYS` has its raw rows tombstoned by the
|
||||
// retention fold, so `tenMinSeries` comes back empty even though the day's
|
||||
// shape survives at hour resolution in the folded `stats:` tier. Fall back
|
||||
// to that coarser read rather than rendering an empty chart. A day with
|
||||
// genuinely no data at all (never synced) stays on the empty "tenMin"
|
||||
// series — `foldedPulseRows` is empty there too, so `hourlySeries` is
|
||||
// empty and the branch below is skipped.
|
||||
const useHourlyFallback = tenMinSeries.length === 0;
|
||||
const hourlySeries = useHourlyFallback
|
||||
? computeHourlyMeanSeries(
|
||||
foldedPulseRows.map((r) => ({
|
||||
measuredAt: r.measuredAt,
|
||||
value: r.value,
|
||||
})),
|
||||
dateKey,
|
||||
localOf,
|
||||
)
|
||||
: [];
|
||||
|
||||
const resolution: IntradayResolution =
|
||||
useHourlyFallback && hourlySeries.length > 0 ? "hourly" : "tenMin";
|
||||
const series = resolution === "hourly" ? hourlySeries : tenMinSeries;
|
||||
|
||||
// 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.
|
||||
@@ -197,22 +246,30 @@ export async function loadIntradayPulse(
|
||||
.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: [],
|
||||
});
|
||||
// Tension needs per-sample resolution (see `detectTensionWindow`'s
|
||||
// docblock) — never compute it against the hourly fallback, regardless of
|
||||
// what the (independently folded) step tier happens to report.
|
||||
const tension =
|
||||
resolution === "tenMin"
|
||||
? detectTensionWindow({
|
||||
buckets: series,
|
||||
baseline: baseline.baseline,
|
||||
baselineMature: baseline.mature,
|
||||
stepBuckets,
|
||||
workouts,
|
||||
hrvConfirmMinutes: [],
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
dateKey,
|
||||
timezone,
|
||||
bucketMinutes: BUCKET_MINUTES,
|
||||
bucketMinutes:
|
||||
resolution === "hourly" ? HOURLY_BUCKET_MINUTES : BUCKET_MINUTES,
|
||||
series,
|
||||
baseline: baseline.baseline,
|
||||
baselineSource: baseline.source,
|
||||
tension,
|
||||
resolution,
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,26 @@ import { formatInUserTz } from "@/lib/tz/format";
|
||||
/** Intraday bucket width, in minutes. Ten minutes reveals the day's shape. */
|
||||
export const BUCKET_MINUTES = 10;
|
||||
|
||||
/**
|
||||
* S11 day-navigator fallback bucket width, in minutes. Days older than
|
||||
* `DENSE_INTRADAY_RETENTION_DAYS` (`dense-intraday-retention.ts`) have their
|
||||
* raw per-sample rows folded to one `stats:` hourly-mean row per local hour;
|
||||
* `computeHourlyMeanSeries` re-buckets those at this coarser grain instead of
|
||||
* pretending they carry 10-minute resolution.
|
||||
*/
|
||||
export const HOURLY_BUCKET_MINUTES = 60;
|
||||
|
||||
/**
|
||||
* The resolution the intraday series was actually computed at — surfaced on
|
||||
* the DTO so the chart can label a folded, older day honestly instead of
|
||||
* implying every day carries the same 10-minute detail. `"tenMin"` reads
|
||||
* raw per-sample rows; `"hourly"` reads the folded `stats:` tier
|
||||
* (`loadIntradayPulse`'s fallback for a day outside the dense retention
|
||||
* window). The tension-window detector requires `"tenMin"` resolution — see
|
||||
* `detectTensionWindow`'s docblock.
|
||||
*/
|
||||
export type IntradayResolution = "tenMin" | "hourly";
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -185,6 +205,48 @@ export function computeTenMinuteMeanSeries(
|
||||
.sort((a, b) => a.startMinute - b.startMinute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a day's already-folded `stats:` hourly-mean rows into ascending
|
||||
* per-local-hour buckets — the day-navigator's fallback shape for a day
|
||||
* outside the dense retention window, where the raw per-sample rows are
|
||||
* long tombstoned and only the hourly means survive live. Only samples
|
||||
* whose LOCAL day matches `dayKey` participate, same as
|
||||
* `computeTenMinuteMeanSeries`. Bucket `count` reflects how many `stats:`
|
||||
* rows landed in that local hour — normally exactly one (the dense-tier
|
||||
* fold mints at most one hourly row per hour), so the density gate in
|
||||
* `detectTensionWindow` would reject every bucket outright; callers must
|
||||
* not feed this series into tension detection regardless (see
|
||||
* `IntradayResolution`).
|
||||
*/
|
||||
export function computeHourlyMeanSeries(
|
||||
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 / HOURLY_BUCKET_MINUTES) *
|
||||
HOURLY_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);
|
||||
@@ -226,6 +288,14 @@ export interface DetectTensionInput {
|
||||
* 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.
|
||||
*
|
||||
* Callers must only invoke this against `"tenMin"`-resolution buckets
|
||||
* (`computeTenMinuteMeanSeries`) — never the `"hourly"` day-navigator
|
||||
* fallback (`computeHourlyMeanSeries`). A tension read needs per-sample
|
||||
* resolution to sustain the run/density gates honestly; an hourly mean would
|
||||
* either starve every bucket via the density gate (misleadingly silent) or,
|
||||
* worse, pass it on a coincidence of the hourly average. `loadIntradayPulse`
|
||||
* enforces this by only calling `detectTensionWindow` for `"tenMin"` days.
|
||||
*/
|
||||
export function detectTensionWindow(
|
||||
input: DetectTensionInput,
|
||||
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
} from "@/lib/daily/coach-checkin-intents";
|
||||
import type { Milestone } from "@/lib/daily/milestones";
|
||||
import type { PriorityItem } from "@/lib/daily/priority-item";
|
||||
import {
|
||||
ecgItemKey,
|
||||
milestoneItemKey,
|
||||
tensionWindowItemKey,
|
||||
} from "@/lib/daily/priority-item-key";
|
||||
|
||||
const t = getServerTranslator("en").t;
|
||||
const NOW = new Date("2026-07-16T09:00:00.000Z");
|
||||
@@ -62,6 +67,8 @@ function input(over: Partial<DailyDigestInput> = {}): DailyDigestInput {
|
||||
preventiveDue: [],
|
||||
coachPlans: [],
|
||||
tensionWindow: null,
|
||||
todayLocalDate: "2026-07-16",
|
||||
dismissedItemKeys: new Set<string>(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -483,6 +490,25 @@ describe("S12 — the milestone reward card", () => {
|
||||
expect(item?.title).not.toMatch(forbidden);
|
||||
expect(item?.body ?? "").not.toMatch(forbidden);
|
||||
});
|
||||
|
||||
it("stamps a deterministic dismiss key namespaced by kind", () => {
|
||||
const item = milestone(
|
||||
buildDailyDigest(input({ milestone: RECORD_MILESTONE }), t),
|
||||
);
|
||||
expect(item?.itemKey).toBe(milestoneItemKey(RECORD_MILESTONE));
|
||||
expect(item?.itemKey).toMatch(/^milestone:/);
|
||||
});
|
||||
|
||||
it("is dropped from the rail once its dismiss key is in the dismissed set", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
milestone: RECORD_MILESTONE,
|
||||
dismissedItemKeys: new Set([milestoneItemKey(RECORD_MILESTONE)]),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(milestone(d)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDailyDigest — S11 tension_window item", () => {
|
||||
@@ -535,6 +561,33 @@ describe("buildDailyDigest — S11 tension_window item", () => {
|
||||
expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK);
|
||||
expect(tension(d)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stamps a dismiss key namespaced by kind, folding in the local day + part of day", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
tensionWindow: { partOfDay: "afternoon" },
|
||||
todayLocalDate: "2026-07-16",
|
||||
}),
|
||||
t,
|
||||
);
|
||||
const item = tension(d);
|
||||
expect(item?.itemKey).toBe(tensionWindowItemKey("2026-07-16", "afternoon"));
|
||||
expect(item?.itemKey).toMatch(/^tension_window:/);
|
||||
});
|
||||
|
||||
it("is dropped from the rail once its dismiss key is in the dismissed set", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
tensionWindow: { partOfDay: "afternoon" },
|
||||
todayLocalDate: "2026-07-16",
|
||||
dismissedItemKeys: new Set([
|
||||
tensionWindowItemKey("2026-07-16", "afternoon"),
|
||||
]),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(tension(d)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDailyDigest — ecg_new_recording (S10)", () => {
|
||||
@@ -654,4 +707,46 @@ describe("buildDailyDigest — ecg_new_recording (S10)", () => {
|
||||
const serialised = JSON.stringify(ecgItem(d));
|
||||
expect(serialised).not.toMatch(/waveform|sample|signal|voltage|microvolt/i);
|
||||
});
|
||||
|
||||
it("stamps a dismiss key namespaced by kind, folding in the recording's own timestamp", () => {
|
||||
const recordedAt = new Date(NOW.getTime() - 60 * 60 * 1000);
|
||||
const d = buildDailyDigest(
|
||||
input({ latestEcg: { recordedAt, deviceVerdict: "IRREGULAR" } }),
|
||||
t,
|
||||
);
|
||||
const item = ecgItem(d);
|
||||
expect(item?.itemKey).toBe(ecgItemKey(recordedAt));
|
||||
expect(item?.itemKey).toMatch(/^ecg_new_recording:/);
|
||||
});
|
||||
|
||||
it("is dropped from the rail once its dismiss key is in the dismissed set", () => {
|
||||
const recordedAt = new Date(NOW.getTime() - 60 * 60 * 1000);
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
latestEcg: { recordedAt, deviceVerdict: "IRREGULAR" },
|
||||
dismissedItemKeys: new Set([ecgItemKey(recordedAt)]),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(ecgItem(d)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDailyDigest — dismiss filtering never touches actionable kinds", () => {
|
||||
it("ignores a dismissed key that happens to collide with an actionable item's shape (they never carry one)", () => {
|
||||
// Actionable items never stamp an `itemKey`, so filtering can never match
|
||||
// them regardless of what the dismissed set contains — a dose-window item
|
||||
// stays on the rail even with an arbitrary set of dismissed keys.
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
|
||||
dismissedItemKeys: new Set(["dose_window:anything", "sync_issue:x"]),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(true);
|
||||
expect(
|
||||
d.worthALook.find((i) => i.kind === "dose_window")?.itemKey,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,23 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: () => userFindUniqueMock() } },
|
||||
}));
|
||||
|
||||
// v1.29.1 — every gate now annotates a distinct `reason`; the spy captures the
|
||||
// wide-event so each early return is observable in prod.
|
||||
const annotateMock = vi.fn();
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: (event: unknown) => annotateMock(event),
|
||||
}));
|
||||
|
||||
/** The `meta` of the single wide-event the trigger emitted this call. */
|
||||
function lastAnnotateMeta(): Record<string, unknown> {
|
||||
const call = annotateMock.mock.calls.at(-1);
|
||||
return (call?.[0] as { meta?: Record<string, unknown> })?.meta ?? {};
|
||||
}
|
||||
function lastAnnotateName(): string | undefined {
|
||||
const call = annotateMock.mock.calls.at(-1);
|
||||
return (call?.[0] as { action?: { name?: string } })?.action?.name;
|
||||
}
|
||||
|
||||
import {
|
||||
isLastNightLocal,
|
||||
maybeEnqueueMorningRefresh,
|
||||
@@ -84,6 +101,7 @@ describe("maybeEnqueueMorningRefresh", () => {
|
||||
resolveModuleMapMock.mockResolvedValue({});
|
||||
userFindUniqueMock.mockReset();
|
||||
userFindUniqueMock.mockResolvedValue({ morningDigestRefreshedOn: null });
|
||||
annotateMock.mockReset();
|
||||
});
|
||||
|
||||
it("enqueues exactly one debounced refresh for many last-night samples", async () => {
|
||||
@@ -101,30 +119,47 @@ describe("maybeEnqueueMorningRefresh", () => {
|
||||
expect((opts as { singletonKey: string }).singletonKey).toBe(
|
||||
"morning-refresh:u1:2026-07-16",
|
||||
);
|
||||
// The success path stays observable too.
|
||||
expect(lastAnnotateName()).toBe("daily.morning_refresh.triggered");
|
||||
});
|
||||
|
||||
it("does NOT trigger for an old/backfilled sleep only", async () => {
|
||||
it("does NOT trigger for an old/backfilled sleep only, and annotates the gate", async () => {
|
||||
await maybeEnqueueMorningRefresh("u1", [oldNight, oldNight], NOW);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
expect(lastAnnotateName()).toBe("daily.morning_refresh.skipped");
|
||||
const meta = lastAnnotateMeta();
|
||||
expect(meta.reason).toBe("not_last_night");
|
||||
// The diagnostic payload: today's local key, tz, and the samples' resolved
|
||||
// local dates so a real landing shows exactly why the gate rejected them.
|
||||
expect(meta.today).toBe("2026-07-16");
|
||||
expect(meta.tz).toBe("America/Los_Angeles");
|
||||
// 06:00Z on the 10th = 23:00 on the 9th in LA (UTC-7).
|
||||
expect(meta.sample_local_dates).toEqual(["2026-07-09", "2026-07-09"]);
|
||||
});
|
||||
|
||||
it("no-ops when the sleep module is off", async () => {
|
||||
it("no-ops when the sleep module is off, and annotates the gate", async () => {
|
||||
resolveModuleMapMock.mockResolvedValue({ sleep: false });
|
||||
await maybeEnqueueMorningRefresh("u1", [lastNight], NOW);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
expect(lastAnnotateName()).toBe("daily.morning_refresh.skipped");
|
||||
expect(lastAnnotateMeta().reason).toBe("sleep_module_off");
|
||||
});
|
||||
|
||||
it("skips the enqueue when the day is already finalised (marker == today)", async () => {
|
||||
it("skips the enqueue when the day is already finalised (marker == today), and annotates the gate", async () => {
|
||||
userFindUniqueMock.mockResolvedValue({
|
||||
morningDigestRefreshedOn: "2026-07-16",
|
||||
});
|
||||
await maybeEnqueueMorningRefresh("u1", [lastNight], NOW);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
expect(lastAnnotateName()).toBe("daily.morning_refresh.skipped");
|
||||
expect(lastAnnotateMeta().reason).toBe("already_refreshed");
|
||||
});
|
||||
|
||||
it("no-ops with no sleep samples", async () => {
|
||||
it("no-ops with no sleep samples, and annotates the gate", async () => {
|
||||
await maybeEnqueueMorningRefresh("u1", [], NOW);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
expect(lastAnnotateName()).toBe("daily.morning_refresh.skipped");
|
||||
expect(lastAnnotateMeta().reason).toBe("no_sleep_rows");
|
||||
});
|
||||
|
||||
it("never throws when a dependency fails", async () => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
ecgItemKey,
|
||||
milestoneItemKey,
|
||||
tensionWindowItemKey,
|
||||
} from "@/lib/daily/priority-item-key";
|
||||
import { isDismissibleItemKey } from "@/lib/daily/priority-item";
|
||||
|
||||
describe("priority-item-key — deterministic dismiss identities", () => {
|
||||
it("milestoneItemKey folds kind + metric + reach day, namespaced `milestone:`", () => {
|
||||
const key = milestoneItemKey({
|
||||
kind: "record_first",
|
||||
metricType: "WEIGHT",
|
||||
sinceDate: "2026-07-16",
|
||||
});
|
||||
expect(key).toBe("milestone:record_first:WEIGHT:2026-07-16");
|
||||
});
|
||||
|
||||
it("milestoneItemKey differs across metric types and reach days (never collides across instances)", () => {
|
||||
const a = milestoneItemKey({
|
||||
kind: "record_first",
|
||||
metricType: "WEIGHT",
|
||||
sinceDate: "2026-07-16",
|
||||
});
|
||||
const b = milestoneItemKey({
|
||||
kind: "record_first",
|
||||
metricType: "RESTING_HEART_RATE",
|
||||
sinceDate: "2026-07-16",
|
||||
});
|
||||
const c = milestoneItemKey({
|
||||
kind: "record_first",
|
||||
metricType: "WEIGHT",
|
||||
sinceDate: "2026-07-17",
|
||||
});
|
||||
expect(new Set([a, b, c]).size).toBe(3);
|
||||
});
|
||||
|
||||
it("ecgItemKey folds the recording's own ISO timestamp, namespaced `ecg_new_recording:`", () => {
|
||||
const recordedAt = new Date("2026-07-16T08:03:00.000Z");
|
||||
expect(ecgItemKey(recordedAt)).toBe(
|
||||
"ecg_new_recording:2026-07-16T08:03:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("tensionWindowItemKey folds the local day + part of day, namespaced `tension_window:`", () => {
|
||||
expect(tensionWindowItemKey("2026-07-16", "afternoon")).toBe(
|
||||
"tension_window:2026-07-16:afternoon",
|
||||
);
|
||||
// A different part of day on the same local day is a distinct instance.
|
||||
expect(tensionWindowItemKey("2026-07-16", "afternoon")).not.toBe(
|
||||
tensionWindowItemKey("2026-07-16", "evening"),
|
||||
);
|
||||
});
|
||||
|
||||
it("every emitted key satisfies isDismissibleItemKey (prefix round-trips)", () => {
|
||||
expect(
|
||||
isDismissibleItemKey(
|
||||
milestoneItemKey({
|
||||
kind: "record_first",
|
||||
metricType: "WEIGHT",
|
||||
sinceDate: "2026-07-16",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isDismissibleItemKey(ecgItemKey(new Date("2026-07-16T08:00:00.000Z"))),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isDismissibleItemKey(tensionWindowItemKey("2026-07-16", "morning")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("isDismissibleItemKey rejects an actionable-shaped key", () => {
|
||||
expect(isDismissibleItemKey("dose_window:anything")).toBe(false);
|
||||
expect(isDismissibleItemKey("sync_issue:withings")).toBe(false);
|
||||
expect(isDismissibleItemKey("")).toBe(false);
|
||||
});
|
||||
});
|
||||
+40
-2
@@ -27,6 +27,11 @@ import {
|
||||
MAX_PRIORITY_ACTIONS,
|
||||
type PriorityItem,
|
||||
} from "@/lib/daily/priority-item";
|
||||
import {
|
||||
ecgItemKey,
|
||||
milestoneItemKey,
|
||||
tensionWindowItemKey,
|
||||
} from "@/lib/daily/priority-item-key";
|
||||
import {
|
||||
milestoneCopy,
|
||||
milestoneHref,
|
||||
@@ -184,6 +189,19 @@ export interface DailyDigestInput {
|
||||
* builder treats a missing value as "no recent recording".
|
||||
*/
|
||||
latestEcg?: DailyDigestEcg | null;
|
||||
/**
|
||||
* The caller's local calendar day (profile tz), used ONLY to stamp the
|
||||
* tension-window item's dismiss key (`tensionWindowItemKey`) — the same day
|
||||
* space `loadIntradayPulse` already computed the window against.
|
||||
*/
|
||||
todayLocalDate: string;
|
||||
/**
|
||||
* Item keys the user has already dismissed (`DismissedPriorityItem`),
|
||||
* scoped to today's OWN candidate keys by the IO seam — never a full
|
||||
* history read. A dismissed item is dropped from `worthALook` entirely,
|
||||
* never rendered with its actions suppressed.
|
||||
*/
|
||||
dismissedItemKeys: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface DailyDigest {
|
||||
@@ -323,6 +341,7 @@ function buildMilestoneItem(
|
||||
const { title, body } = milestoneCopy(milestone, t);
|
||||
return {
|
||||
kind: "milestone",
|
||||
itemKey: milestoneItemKey(milestone),
|
||||
title,
|
||||
body,
|
||||
status: "success",
|
||||
@@ -348,12 +367,14 @@ function buildMilestoneItem(
|
||||
function buildTensionWindowItem(
|
||||
window: DailyDigestTensionWindow | null,
|
||||
modules: DigestModuleMap,
|
||||
todayLocalDate: string,
|
||||
t: Translate,
|
||||
): PriorityItem | null {
|
||||
if (!moduleEnabled(modules, "insights")) return null;
|
||||
if (!window) return null;
|
||||
return {
|
||||
kind: "tension_window",
|
||||
itemKey: tensionWindowItemKey(todayLocalDate, window.partOfDay),
|
||||
title: t("daily.item.tensionWindow.title"),
|
||||
body: t(`daily.item.tensionWindow.body.${window.partOfDay}`),
|
||||
status: "info",
|
||||
@@ -497,6 +518,7 @@ function buildEcgNewRecordingItem(
|
||||
|
||||
return {
|
||||
kind: "ecg_new_recording",
|
||||
itemKey: ecgItemKey(ecg.recordedAt),
|
||||
title: t("daily.item.ecgNewRecording.title"),
|
||||
body,
|
||||
status: "info",
|
||||
@@ -584,7 +606,12 @@ export function buildDailyDigest(
|
||||
// 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);
|
||||
const tension = buildTensionWindowItem(
|
||||
input.tensionWindow,
|
||||
input.modules,
|
||||
input.todayLocalDate,
|
||||
t,
|
||||
);
|
||||
if (tension) worthALook.push(tension);
|
||||
|
||||
// Defence-in-depth: no card ever exceeds the P1 action cap.
|
||||
@@ -592,6 +619,17 @@ export function buildDailyDigest(
|
||||
item.actions = item.actions.slice(0, MAX_PRIORITY_ACTIONS);
|
||||
}
|
||||
|
||||
// Drop anything the user already dismissed (observational kinds only — an
|
||||
// actionable item never carries an `itemKey`, so it can never match here).
|
||||
// Filtered BEFORE the bounded slice, so a dismissal reliably makes room for
|
||||
// the next candidate rather than leaving a gap.
|
||||
const visible =
|
||||
input.dismissedItemKeys.size === 0
|
||||
? worthALook
|
||||
: worthALook.filter(
|
||||
(item) => !item.itemKey || !input.dismissedItemKeys.has(item.itemKey),
|
||||
);
|
||||
|
||||
return {
|
||||
generatedAt: input.now.toISOString(),
|
||||
phase,
|
||||
@@ -600,6 +638,6 @@ export function buildDailyDigest(
|
||||
topSignal,
|
||||
briefingLead,
|
||||
line: composeLine(briefingLead, topSignal, input.score, t),
|
||||
worthALook: worthALook.slice(0, MAX_WORTH_A_LOOK),
|
||||
worthALook: visible.slice(0, MAX_WORTH_A_LOOK),
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,11 @@ import {
|
||||
type DailyDigestSyncIssue,
|
||||
type DailyDigestTensionWindow,
|
||||
} from "@/lib/daily/digest";
|
||||
import {
|
||||
ecgItemKey,
|
||||
milestoneItemKey,
|
||||
tensionWindowItemKey,
|
||||
} from "@/lib/daily/priority-item-key";
|
||||
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
|
||||
import { detectStreak, type StreakPoint } from "@/lib/insights/streak-detector";
|
||||
@@ -316,6 +321,27 @@ export async function loadDailyDigest(
|
||||
.catch(() => null)
|
||||
: null;
|
||||
|
||||
// Dismiss ledger (P — Today rail dismiss). Only the OBSERVATIONAL kinds
|
||||
// (milestone / ecg_new_recording / tension_window) can ever be dismissed, so
|
||||
// only their candidate keys are worth looking up — never a full per-user
|
||||
// history read. At most 3 keys, so this is a single indexed `IN (...)`.
|
||||
const candidateItemKeys = [
|
||||
milestone ? milestoneItemKey(milestone) : null,
|
||||
latestEcg ? ecgItemKey(latestEcg.recordedAt) : null,
|
||||
tensionWindow
|
||||
? tensionWindowItemKey(todayLocalDate, tensionWindow.partOfDay)
|
||||
: null,
|
||||
].filter((k): k is string => k !== null);
|
||||
|
||||
const dismissedRows =
|
||||
candidateItemKeys.length > 0
|
||||
? await prisma.dismissedPriorityItem.findMany({
|
||||
where: { userId: user.id, itemKey: { in: candidateItemKeys } },
|
||||
select: { itemKey: true },
|
||||
})
|
||||
: [];
|
||||
const dismissedItemKeys = new Set(dismissedRows.map((r) => r.itemKey));
|
||||
|
||||
const { t } = getServerTranslator(resolveLocale(locale));
|
||||
|
||||
const digest = buildDailyDigest(
|
||||
@@ -333,6 +359,8 @@ export async function loadDailyDigest(
|
||||
milestone,
|
||||
tensionWindow,
|
||||
latestEcg,
|
||||
todayLocalDate,
|
||||
dismissedItemKeys,
|
||||
},
|
||||
t,
|
||||
);
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
* The nightly insight pre-pass (`insight-pregenerate`, 04:30) runs before last
|
||||
* night's sleep has synced, so the morning digest + health score reference the
|
||||
* day WITHOUT the current sleep. This trigger closes that gap the event-driven
|
||||
* way: when a completed sleep segment for LAST NIGHT lands from any of the
|
||||
* three sleep transports (Withings sync-sleep / the WHOOP sync / the
|
||||
* Apple-Health measurement batch), it enqueues a debounced
|
||||
* way: when a completed sleep segment for LAST NIGHT lands from any sleep
|
||||
* transport (Withings sync-sleep / the WHOOP sync / the Apple-Health
|
||||
* measurement batch / Google Health / Fitbit / Oura / Polar), it enqueues a debounced
|
||||
* `morning-digest-refresh` job that re-runs the sleep-dependent generation and
|
||||
* flips the day `provisional → final`.
|
||||
*
|
||||
* There is no single canonical sleep-persist function in the tree — each
|
||||
* transport upserts through its own helper — so the three write seams each call
|
||||
* `maybeEnqueueMorningRefresh` with the measuredAt of every sleep row they just
|
||||
* transport upserts through its own helper — so every write seam calls
|
||||
* `maybeEnqueueMorningRefresh` with the measuredAt of every sleep row it just
|
||||
* wrote. The debounce (queue `singletonKey` + the `User.morningDigestRefreshedOn`
|
||||
* marker) collapses the many samples of one night, and across all three
|
||||
* sources, into ONE refresh per user per local morning.
|
||||
* marker) collapses the many samples of one night, and across all sources,
|
||||
* into ONE refresh per user per local morning.
|
||||
*
|
||||
* Timezone correctness is load-bearing: "last night" is judged in the user's
|
||||
* PROFILE timezone, never UTC, so a 30-day backfill re-sync never re-triggers
|
||||
@@ -76,24 +76,64 @@ export async function maybeEnqueueMorningRefresh(
|
||||
now: Date = new Date(),
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (sleepMeasuredAts.length === 0) return;
|
||||
// Gate 1 — nothing to refresh on. Every early return annotates a distinct
|
||||
// `reason` so a real landing shows exactly which gate fired (the gates were
|
||||
// silent before, so a "insight stuck on the nightly stamp" report could not
|
||||
// be traced to a gate without a code change).
|
||||
if (sleepMeasuredAts.length === 0) {
|
||||
annotate({
|
||||
action: { name: "daily.morning_refresh.skipped" },
|
||||
meta: { reason: "no_sleep_rows" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const modules = await resolveModuleMap(userId);
|
||||
if (modules.sleep === false) return;
|
||||
if (modules.sleep === false) {
|
||||
annotate({
|
||||
action: { name: "daily.morning_refresh.skipped" },
|
||||
meta: { reason: "sleep_module_off" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tz = await resolveUserTimezone(userId);
|
||||
const todayKey = userDayKey(now, tz);
|
||||
const hasLastNight = sleepMeasuredAts.some((at) =>
|
||||
isLastNightLocal(at, now, tz),
|
||||
);
|
||||
if (!hasLastNight) return;
|
||||
if (!hasLastNight) {
|
||||
// The most diagnostic gate: carry the samples' resolved LOCAL dates (up
|
||||
// to 5) alongside today's key + tz, so the next real landing tells us
|
||||
// exactly why `isLastNightLocal` rejected every segment (a tz mismatch, a
|
||||
// backfill of only-old nights, or a clock-skewed future sample).
|
||||
annotate({
|
||||
action: { name: "daily.morning_refresh.skipped" },
|
||||
meta: {
|
||||
reason: "not_last_night",
|
||||
today: todayKey,
|
||||
tz,
|
||||
sample_local_dates: sleepMeasuredAts
|
||||
.slice(0, 5)
|
||||
.map((at) => userDayKey(at, tz)),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const localDate = userDayKey(now, tz);
|
||||
const localDate = todayKey;
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { morningDigestRefreshedOn: true },
|
||||
});
|
||||
if (row?.morningDigestRefreshedOn === localDate) return;
|
||||
if (row?.morningDigestRefreshedOn === localDate) {
|
||||
annotate({
|
||||
action: { name: "daily.morning_refresh.skipped" },
|
||||
meta: { reason: "already_refreshed", local_date: localDate },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await enqueueMorningDigestRefresh({ userId, localDate });
|
||||
annotate({
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Deterministic identity keys for the dismissible (observational) Today rail
|
||||
* items — `milestone`, `ecg_new_recording`, `tension_window`. Pure string
|
||||
* formatting, no DB, no clock: both the digest composer (`./digest.ts`, which
|
||||
* stamps the key onto the emitted `PriorityItem`) and the IO seam
|
||||
* (`./load-digest.ts`, which needs the SAME keys to look up which of today's
|
||||
* candidates are already dismissed) import from here so the format is one
|
||||
* source of truth.
|
||||
*
|
||||
* Each key is namespaced `<kind>:...` (see `isDismissibleItemKey` in
|
||||
* `./priority-item.ts`) and folds in enough of the underlying candidate's own
|
||||
* identity that a fresh instance the NEXT day never collides with today's
|
||||
* dismissed one — a milestone by its reach day, an ECG recording by its own
|
||||
* timestamp, a tension window by the local day it was detected for.
|
||||
*/
|
||||
import type { Milestone } from "@/lib/daily/milestones";
|
||||
|
||||
/** `milestone:<kind>:<metricType>:<sinceDate>` — one key per durable state reached. */
|
||||
export function milestoneItemKey(
|
||||
milestone: Pick<Milestone, "kind" | "metricType" | "sinceDate">,
|
||||
): string {
|
||||
return `milestone:${milestone.kind}:${milestone.metricType}:${milestone.sinceDate}`;
|
||||
}
|
||||
|
||||
/** `ecg_new_recording:<recordedAt ISO>` — the recording's own timestamp is unique. */
|
||||
export function ecgItemKey(recordedAt: Date): string {
|
||||
return `ecg_new_recording:${recordedAt.toISOString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `tension_window:<localDayKey>:<partOfDay>` — at most one per user per day.
|
||||
* `partOfDay` mirrors `DailyDigestTensionWindow["partOfDay"]` (`./digest.ts`)
|
||||
* by value rather than by import, so this module stays a leaf the digest
|
||||
* composer can depend on without a cycle.
|
||||
*/
|
||||
export function tensionWindowItemKey(
|
||||
localDayKey: string,
|
||||
partOfDay: "morning" | "afternoon" | "evening" | "night",
|
||||
): string {
|
||||
return `tension_window:${localDayKey}:${partOfDay}`;
|
||||
}
|
||||
@@ -40,6 +40,45 @@ export const PRIORITY_ITEM_KINDS = [
|
||||
|
||||
export type PriorityItemKind = (typeof PRIORITY_ITEM_KINDS)[number];
|
||||
|
||||
/**
|
||||
* The OBSERVATIONAL kinds — a durable-state reward, a new-ECG pointer, an
|
||||
* elevated-at-rest window. These carry no pending action; the user can only
|
||||
* acknowledge them, so they're the only kinds the Today rail lets you
|
||||
* dismiss. The remaining kinds (`dose_window`, `sync_issue`,
|
||||
* `preventive_care`, `coach_checkin`) are ACTIONABLE — they clear on their
|
||||
* own once the user acts (logs the dose, reconnects the integration, …) and
|
||||
* are never dismissible; offering a dismiss on them would just let a user
|
||||
* silence a still-open action item.
|
||||
*/
|
||||
export const DISMISSIBLE_PRIORITY_ITEM_KINDS = [
|
||||
"milestone",
|
||||
"ecg_new_recording",
|
||||
"tension_window",
|
||||
] as const satisfies readonly PriorityItemKind[];
|
||||
|
||||
export type DismissiblePriorityItemKind =
|
||||
(typeof DISMISSIBLE_PRIORITY_ITEM_KINDS)[number];
|
||||
|
||||
/** Whether `kind` is one of the dismissible observational kinds. */
|
||||
export function isDismissibleKind(
|
||||
kind: PriorityItemKind,
|
||||
): kind is DismissiblePriorityItemKind {
|
||||
return (DISMISSIBLE_PRIORITY_ITEM_KINDS as readonly string[]).includes(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dismiss ledger's natural key is namespaced by kind (`<kind>:...`), so
|
||||
* the prefix alone tells the server the key names a dismissible instance —
|
||||
* no separate `kind` field needs to cross the wire. Shared by the digest
|
||||
* builder (which stamps the key) and the dismiss route's Zod schema (which
|
||||
* rejects anything else structurally, before a lookup ever runs).
|
||||
*/
|
||||
export function isDismissibleItemKey(itemKey: string): boolean {
|
||||
return DISMISSIBLE_PRIORITY_ITEM_KINDS.some((kind) =>
|
||||
itemKey.startsWith(`${kind}:`),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic status — meaning, not decoration. Maps to a `text-<status>` plus a
|
||||
* `bg-<status>/10` wash in the card (§3 status-colour tier), never raw palette.
|
||||
@@ -68,6 +107,13 @@ export interface PriorityItemAction {
|
||||
*/
|
||||
export interface PriorityItem {
|
||||
kind: PriorityItemKind;
|
||||
/**
|
||||
* Stable identity for THIS item instance, namespaced `<kind>:...` — present
|
||||
* only on the dismissible observational kinds (see
|
||||
* `DISMISSIBLE_PRIORITY_ITEM_KINDS`). The client only offers a dismiss
|
||||
* affordance when this is set; the actionable kinds never carry one.
|
||||
*/
|
||||
itemKey?: string;
|
||||
/** i18n-resolved headline (server-side). */
|
||||
title: string;
|
||||
/** Optional grounded one-liner — a plain string rendered via `ProseBlocks`. */
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "./sync";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
|
||||
export async function syncUserSleep(
|
||||
userId: string,
|
||||
@@ -78,6 +79,16 @@ export async function syncUserSleep(
|
||||
})
|
||||
).imported;
|
||||
// `markSynced` is owned by the orchestrator (`syncUserFitbit`).
|
||||
|
||||
// S4 — trigger the debounced morning refresh on a last-night segment landing
|
||||
// (mirrors the Withings / WHOOP / Apple seams).
|
||||
void maybeEnqueueMorningRefresh(
|
||||
userId,
|
||||
readings
|
||||
.filter((r) => r.type === "SLEEP_DURATION")
|
||||
.map((r) => r.measuredAt),
|
||||
).catch(() => {});
|
||||
|
||||
annotate({ action: { name: "fitbit.sleep.sync", details: { imported } } });
|
||||
return imported;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* v1.29.1 — pins the S4 morning-digest-refresh wiring on the Google Health
|
||||
* sleep transport. Google Health is a first-class sleep source, but the
|
||||
* sleep-arrival trigger (`maybeEnqueueMorningRefresh`) was only hooked into
|
||||
* Withings / WHOOP / Apple, so a Google-Health user's morning refresh never
|
||||
* fired and their day stayed stuck at the 04:30 nightly pre-pass.
|
||||
*
|
||||
* This asserts a Google-sourced last-night sleep segment landing enqueues the
|
||||
* debounced refresh EXACTLY once, with the segment's `measuredAt` — mirroring
|
||||
* the existing transport trigger contract. The debounce itself (one refresh
|
||||
* per user per local morning) is covered in `morning-refresh-trigger.test.ts`.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sleepMeasuredAt = new Date("2026-06-02T05:30:00.000Z");
|
||||
|
||||
const {
|
||||
fetchDataPointsMock,
|
||||
mapSleepSessionDetailedMock,
|
||||
upsertMock,
|
||||
maybeEnqueueMorningRefreshMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchDataPointsMock: vi.fn(),
|
||||
mapSleepSessionDetailedMock: vi.fn(),
|
||||
upsertMock: vi.fn(),
|
||||
maybeEnqueueMorningRefreshMock: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../client", () => ({
|
||||
GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE: 100,
|
||||
GOOGLE_HEALTH_DATA_TYPES: { sleep: "sleep" },
|
||||
fetchDataPoints: fetchDataPointsMock,
|
||||
mapSleepSessionDetailed: mapSleepSessionDetailedMock,
|
||||
}));
|
||||
|
||||
vi.mock("../sync", () => ({
|
||||
getValidToken: vi.fn(async () => ({ accessToken: "tok" })),
|
||||
handleCollectionFetchError: vi.fn(() => 0),
|
||||
noteHardFailure: vi.fn(),
|
||||
replaceStaleGoogleHealthSleep: vi.fn(async () => 0),
|
||||
upsertGoogleHealthMeasurements: upsertMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
|
||||
maybeEnqueueMorningRefresh: maybeEnqueueMorningRefreshMock,
|
||||
}));
|
||||
|
||||
import { syncUserSleep } from "../sync-sleep";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchDataPointsMock.mockResolvedValue([{ raw: "point" }]);
|
||||
mapSleepSessionDetailedMock.mockReturnValue({
|
||||
rows: [
|
||||
{
|
||||
type: "SLEEP_DURATION",
|
||||
value: 480,
|
||||
unit: "min",
|
||||
measuredAt: sleepMeasuredAt,
|
||||
fieldTag: "anchor:sleep:a",
|
||||
sleepStage: null,
|
||||
},
|
||||
],
|
||||
windowStart: new Date("2026-06-01T22:00:00.000Z"),
|
||||
windowEnd: sleepMeasuredAt,
|
||||
});
|
||||
upsertMock.mockResolvedValue({ imported: 1 });
|
||||
});
|
||||
|
||||
describe("google-health syncUserSleep — S4 morning-refresh trigger", () => {
|
||||
it("enqueues exactly one morning refresh with the sleep segment's measuredAt", async () => {
|
||||
await syncUserSleep("user-1");
|
||||
|
||||
expect(maybeEnqueueMorningRefreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(maybeEnqueueMorningRefreshMock).toHaveBeenCalledWith("user-1", [
|
||||
sleepMeasuredAt,
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes no sleep timestamps when the night carried no SLEEP_DURATION rows", async () => {
|
||||
mapSleepSessionDetailedMock.mockReturnValue({
|
||||
rows: [
|
||||
{
|
||||
type: "HEART_RATE",
|
||||
value: 55,
|
||||
unit: "bpm",
|
||||
measuredAt: sleepMeasuredAt,
|
||||
fieldTag: "hr:a",
|
||||
sleepStage: null,
|
||||
},
|
||||
],
|
||||
windowStart: new Date("2026-06-01T22:00:00.000Z"),
|
||||
windowEnd: sleepMeasuredAt,
|
||||
});
|
||||
|
||||
await syncUserSleep("user-1");
|
||||
|
||||
// Still called once (the trigger self-gates on an empty array), but with no
|
||||
// sleep timestamps — so it no-ops rather than refreshing on non-sleep rows.
|
||||
expect(maybeEnqueueMorningRefreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(maybeEnqueueMorningRefreshMock).toHaveBeenCalledWith("user-1", []);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "./sync";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
|
||||
export async function syncUserSleep(
|
||||
userId: string,
|
||||
@@ -113,6 +114,18 @@ export async function syncUserSleep(
|
||||
})
|
||||
).imported;
|
||||
// `markSynced` is owned by the orchestrator (`syncUserGoogleHealth`).
|
||||
|
||||
// S4 — trigger the debounced morning refresh on a last-night segment landing
|
||||
// (mirrors the Withings / WHOOP / Apple seams). Google Health is a first-
|
||||
// class sleep transport, so without this a Google-Health user's morning
|
||||
// refresh never fired and their day stayed stuck at the 04:30 pre-pass.
|
||||
void maybeEnqueueMorningRefresh(
|
||||
userId,
|
||||
readings
|
||||
.filter((r) => r.type === "SLEEP_DURATION")
|
||||
.map((r) => r.measuredAt),
|
||||
).catch(() => {});
|
||||
|
||||
annotate({
|
||||
action: { name: "googleHealth.sleep.sync", details: { imported } },
|
||||
});
|
||||
|
||||
@@ -138,11 +138,18 @@ export const DENSE_INTRADAY_RETENTION_TYPES: ReadonlySet<MeasurementType> =
|
||||
* fold to hourly-mean rows and the raw rows tombstone; rows inside the
|
||||
* window stay raw so the Stress engine has its intra-day inputs.
|
||||
*
|
||||
* 14 days covers the Stress engine's 7-day reference window plus a margin
|
||||
* for late watch syncs and a backfilled gap, while keeping the raw
|
||||
* per-sample volume bounded to roughly two weeks of samples per dense type.
|
||||
* 90 days comfortably covers the Stress engine's 7-day reference window and
|
||||
* lets the intraday pulse day-navigator (S11) page back through a full
|
||||
* quarter of true 10-minute-resolution days before it hits the coarser
|
||||
* hourly fold. Raised from the original 14-day bound deliberately: a
|
||||
* self-hoster's own Postgres volume is the trade-off, not a shared resource,
|
||||
* so trading roughly six extra weeks of raw per-sample rows per dense type
|
||||
* for three months of full-resolution history is the right default. Days
|
||||
* outside the window still fold to hourly means rather than disappearing —
|
||||
* `loadIntradayPulse` falls back to that hourly tier so the navigator never
|
||||
* renders an empty chart for an older day.
|
||||
*/
|
||||
export const DENSE_INTRADAY_RETENTION_DAYS = 14;
|
||||
export const DENSE_INTRADAY_RETENTION_DAYS = 90;
|
||||
|
||||
/** The stats externalId prefix marks an already-collapsed row. */
|
||||
const DAILY_STATS_PREFIX = "stats:";
|
||||
|
||||
@@ -353,23 +353,18 @@ describe("resolveModuleEnabled — mcp module (opt-in, default-OFF)", () => {
|
||||
});
|
||||
|
||||
describe("registry — opt-in marker", () => {
|
||||
// The opt-in modules ship dark because they open an egress/attack surface or
|
||||
// are highly sensitive: `mcp` (remote external-assistant endpoint),
|
||||
// `environment` (outbound weather fetch tied to a coarse location),
|
||||
// `inboundDocuments` (sends an uploaded clinical document to the OCR/vision
|
||||
// provider), `mentalHealth` (a depression / anxiety self-assessment — at
|
||||
// least as sensitive as mood, surfaced only on explicit opt-in), and
|
||||
// v1.29.1 — a fresh install defaults every TRACKING module on so the app is
|
||||
// usable without enabling each one. Only two modules stay opt-in (ship dark),
|
||||
// because each opens a channel that must be turned on deliberately:
|
||||
// `mcp` (the remote external-assistant endpoint — a new attack surface) and
|
||||
// `nutrients` (supplement-pattern intake synced from the phone's health
|
||||
// store — the device-side HealthKit permission is not visible consent to
|
||||
// server/AI-side storage, so ingest refuses while off). Every other
|
||||
// module is the default-on disabled-allowlist.
|
||||
const OPT_IN_KEYS = new Set([
|
||||
"mcp",
|
||||
"environment",
|
||||
"inboundDocuments",
|
||||
"mentalHealth",
|
||||
"nutrients",
|
||||
]);
|
||||
// server/AI-side storage, so ingest refuses while off). Everything else —
|
||||
// including `environment`, `inboundDocuments`, and `mentalHealth` — is the
|
||||
// default-on disabled-allowlist now; their egress channels (the weather
|
||||
// fetch, the document AI-read, the AI Coach exposure) are separately gated
|
||||
// and stay off until explicitly configured.
|
||||
const OPT_IN_KEYS = new Set(["mcp", "nutrients"]);
|
||||
|
||||
it("marks only the egress-surface modules opt-in (every other is default-on)", () => {
|
||||
for (const key of MODULE_KEYS) {
|
||||
@@ -379,35 +374,36 @@ describe("registry — opt-in marker", () => {
|
||||
});
|
||||
|
||||
describe("resolveModuleEnabled — inboundDocuments module (document vault)", () => {
|
||||
// Parked in code from v1.25.3; the document vault re-enabled it. Normal
|
||||
// two-layer opt-in resolution applies again: dark by default, ON only on
|
||||
// an explicit per-user opt-in, and an operator `false` still wins.
|
||||
// v1.29.1 — default-on now (a fresh install can store + organise documents
|
||||
// out of the box). The AI READ of a document is a separate per-user opt-in
|
||||
// (`documentsAutoAiRead`), so default-on carries no silent egress. Normal
|
||||
// disabled-allowlist resolution: ON unless an explicit `false`, operator wins.
|
||||
it("is no longer flagged as code-disabled in the registry", () => {
|
||||
expect(isCodeDisabledModule("inboundDocuments")).toBe(false);
|
||||
});
|
||||
|
||||
it("is OFF when no preference is recorded (opt-in ships dark)", () => {
|
||||
it("is ON when no preference is recorded (default-on like its siblings)", () => {
|
||||
expect(
|
||||
resolveModuleEnabled("inboundDocuments", inputs(), false, ALL_AVAILABLE),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is ON when the user opted in", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"inboundDocuments",
|
||||
inputs({ modulePreferences: { inboundDocuments: true } }),
|
||||
false,
|
||||
ALL_AVAILABLE,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("stays OFF when the operator disabled it, even with a user opt-in", () => {
|
||||
it("is OFF when the user opted out", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"inboundDocuments",
|
||||
inputs({ modulePreferences: { inboundDocuments: true } }),
|
||||
inputs({ modulePreferences: { inboundDocuments: false } }),
|
||||
false,
|
||||
ALL_AVAILABLE,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("stays OFF when the operator disabled it, even without a user opt-out", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"inboundDocuments",
|
||||
inputs(),
|
||||
false,
|
||||
operator({ inboundDocuments: false }),
|
||||
),
|
||||
|
||||
+32
-31
@@ -118,11 +118,11 @@ export const MODULE_KEYS = [
|
||||
// re-enabling finds the rows intact.
|
||||
"medications",
|
||||
"doctorReport",
|
||||
// v1.25.0 (W-ENV) — environmental-context module. Like `mcp`, it is OPT-IN
|
||||
// (off by default): it performs an outbound weather fetch tied to where the
|
||||
// user physically is (a coarse home / travel location), so opt-in is the
|
||||
// right privacy default. With it off no job runs, no row is written, and no
|
||||
// egress happens. The `optIn` marker inverts the per-user default.
|
||||
// v1.25.0 (W-ENV) — environmental-context module. Default-on since v1.29.1
|
||||
// (a fresh install is usable without a per-module opt-in); the outbound
|
||||
// weather fetch only runs once the user configures a home / travel location
|
||||
// on the Environment settings surface, so default-on carries no silent
|
||||
// egress. No `optIn` marker → the ordinary default-on disabled-allowlist.
|
||||
"environment",
|
||||
// v1.22.0 — the remote Model Context Protocol endpoint (`/mcp`). Unlike
|
||||
// every other module this is OPT-IN (off by default): it exposes a new
|
||||
@@ -133,19 +133,17 @@ export const MODULE_KEYS = [
|
||||
// reuses the existing module machinery unchanged.
|
||||
"mcp",
|
||||
// v1.25.0 (W-DOCS-IN) — inbound clinical documents (`/documents/inbound`).
|
||||
// Like `mcp` this is OPT-IN (off by default): ingesting a doctor report /
|
||||
// discharge letter sends the document to the configured OCR/vision provider,
|
||||
// so the surface ships dark and the user turns it on deliberately. The
|
||||
// `optIn` marker on its registry entry inverts the per-user default;
|
||||
// everything else reuses the existing module machinery unchanged.
|
||||
// Default-on since v1.29.1: a fresh install can store + organise documents
|
||||
// in the vault out of the box. The AI READ of a document (the egress to the
|
||||
// OCR/vision provider) stays a SEPARATE per-user opt-in
|
||||
// (`documentsAutoAiRead`), so the module being on never egresses on its own.
|
||||
// No `optIn` marker → the ordinary default-on disabled-allowlist.
|
||||
"inboundDocuments",
|
||||
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7), beside mood
|
||||
// tracking. OPT-IN (off by default): a depression / anxiety self-assessment
|
||||
// is at least as sensitive as mood, so it ships dark and the user turns it on
|
||||
// deliberately. The `optIn` marker inverts the per-user default; with it off
|
||||
// the `/mental-wellbeing` surface is hidden from nav and the
|
||||
// assessment routes refuse server-side. Item answers are always encrypted and
|
||||
// always excluded from the AI Coach / MCP regardless of this toggle.
|
||||
// v1.25.0 — mental-health screeners (WHO-5 / PHQ-9 / GAD-7), beside mood
|
||||
// tracking. Default-on since v1.29.1: the `/mental-wellbeing` check-in is
|
||||
// available on a fresh install alongside mood. Item answers are always
|
||||
// encrypted and always excluded from the AI Coach / MCP regardless of this
|
||||
// toggle. No `optIn` marker → the ordinary default-on disabled-allowlist.
|
||||
"mentalHealth",
|
||||
// v1.28 — micronutrient-intake sync (vitamins, minerals, water,
|
||||
// caffeine as day totals from Apple Health). OPT-IN (off by default):
|
||||
@@ -296,11 +294,13 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
|
||||
labelKey: "modules.environment.label",
|
||||
descriptionKey: "modules.environment.description",
|
||||
category: "device",
|
||||
// Off by default: performs an outbound weather fetch tied to a coarse
|
||||
// location, so it ships dark and the user turns it on deliberately. The
|
||||
// home location + travel overrides + backfill live on the dedicated
|
||||
// Environment settings surface (rendered when the module is on).
|
||||
optIn: true,
|
||||
// Default-on tracking module (v1.29.1): a fresh install ships the
|
||||
// environmental-context surface usable without a separate opt-in. No
|
||||
// egress happens until the user configures a home location on the
|
||||
// dedicated Environment settings surface, so default-on carries no
|
||||
// silent outbound fetch. The MCP endpoint + the auto-AI-read of
|
||||
// documents stay opt-in — those are the surfaces that open an egress /
|
||||
// attack channel the moment they are on.
|
||||
},
|
||||
mcp: {
|
||||
key: "mcp",
|
||||
@@ -316,21 +316,22 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
|
||||
labelKey: "modules.inboundDocuments.label",
|
||||
descriptionKey: "modules.inboundDocuments.description",
|
||||
category: "tracking",
|
||||
// Off by default: ingesting a clinical document egresses it to the
|
||||
// configured OCR/vision provider, so the user turns it on deliberately.
|
||||
optIn: true,
|
||||
// Default-on tracking module (v1.29.1): a fresh install can store and
|
||||
// organise clinical documents in the vault out of the box. The AI READ
|
||||
// of a document (the egress to the OCR/vision provider) is a SEPARATE
|
||||
// per-user opt-in (`documentsAutoAiRead`), so the module being on never
|
||||
// sends a document anywhere on its own.
|
||||
},
|
||||
mentalHealth: {
|
||||
key: "mentalHealth",
|
||||
labelKey: "modules.mentalHealth.label",
|
||||
descriptionKey: "modules.mentalHealth.description",
|
||||
category: "tracking",
|
||||
// Off by default: a depression / anxiety screener is highly sensitive, so
|
||||
// the surface ships dark and the user opts in deliberately. Turning it on
|
||||
// reveals the `/mental-wellbeing` check-in and lets the screener
|
||||
// total ride the doctor-report export; item answers stay encrypted and
|
||||
// off the AI Coach / MCP regardless.
|
||||
optIn: true,
|
||||
// Default-on tracking module (v1.29.1): the `/mental-wellbeing` check-in
|
||||
// (WHO-5 / PHQ-9 / GAD-7) is available on a fresh install alongside mood.
|
||||
// Item answers stay encrypted at rest and remain excluded from the AI
|
||||
// Coach / MCP regardless of this toggle — the screener total may ride the
|
||||
// doctor-report export the user assembles.
|
||||
},
|
||||
nutrients: {
|
||||
key: "nutrients",
|
||||
|
||||
@@ -12,11 +12,18 @@ import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
|
||||
import { dismissPriorityItemSchema } from "@/lib/validations/daily";
|
||||
import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared";
|
||||
|
||||
const priorityItemSchema = z
|
||||
.object({
|
||||
kind: z.enum([...PRIORITY_ITEM_KINDS]).describe("Closed rail-item kind."),
|
||||
itemKey: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Stable dismiss identity, namespaced `<kind>:...`. Present only on the observational kinds (milestone / ecg_new_recording / tension_window) — absent on the actionable kinds, which are never dismissible.",
|
||||
),
|
||||
title: z.string().describe("Localised headline (resolved server-side)."),
|
||||
body: z
|
||||
.string()
|
||||
@@ -116,4 +123,33 @@ export const dailyPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/daily/digest/dismiss": {
|
||||
post: {
|
||||
tags: ["Insights"],
|
||||
summary: "Dismiss a Today rail item",
|
||||
description:
|
||||
'"Dismiss / mark seen" for the Today rail\'s OBSERVATIONAL PriorityItem kinds only — milestone, ecg_new_recording, tension_window. The ACTIONABLE kinds (dose_window, sync_issue, preventive_care, coach_checkin) are structurally unreachable: itemKey must be namespaced under one of the three dismissible kinds or the request 422s before any lookup runs. Persisted server-side so the dismissal survives reload / a second device; an upsert, so a repeat dismiss of the same instance is a no-op. Requires the insights module. Cookie or Bearer auth.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: dismissPriorityItemSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The item is dismissed (idempotent).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ dismissed: z.literal(true) }),
|
||||
"DismissPriorityItemEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...moduleDisabledResponse,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
OURA_OAUTH_SCOPE,
|
||||
RESILIENCE_LEVELS,
|
||||
derivePeriodDaysFromCyclePhases,
|
||||
exchangeCode,
|
||||
fetchCardiovascularAge,
|
||||
fetchDailyCyclePhases,
|
||||
fetchReadiness,
|
||||
fetchResilience,
|
||||
fetchVo2Max,
|
||||
@@ -524,3 +526,101 @@ describe("OURA_OAUTH_SCOPE", () => {
|
||||
expect(OURA_OAUTH_SCOPE).toBe("daily personal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchDailyCyclePhases", () => {
|
||||
it("reads the daily_cycle_phases collection path", async () => {
|
||||
const fetchMock = installFetchMock([
|
||||
{
|
||||
status: 200,
|
||||
body: {
|
||||
data: [{ id: "1", day: "2026-06-10", phase: "follicular" }],
|
||||
next_token: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const r = await fetchDailyCyclePhases("tok", {
|
||||
startDate: "2026-06-01",
|
||||
endDate: "2026-06-10",
|
||||
});
|
||||
expect(r).toHaveLength(1);
|
||||
const [url] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toContain("/v2/usercollection/daily_cycle_phases");
|
||||
});
|
||||
|
||||
it("propagates a 403 (the undocumented / scope-gated case) as an OuraApiError", async () => {
|
||||
installFetchMock([{ status: 403, body: { detail: "forbidden" } }]);
|
||||
await expect(
|
||||
fetchDailyCyclePhases("tok", {
|
||||
startDate: "2026-06-01",
|
||||
endDate: "2026-06-10",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(OuraApiError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("derivePeriodDaysFromCyclePhases", () => {
|
||||
it("marks a literal 'menstrual' phase day directly", () => {
|
||||
const days = derivePeriodDaysFromCyclePhases([
|
||||
{ day: "2026-06-05", phase: "menstrual" },
|
||||
{ day: "2026-06-10", phase: "follicular" },
|
||||
]);
|
||||
expect(days).toEqual(["2026-06-05"]);
|
||||
});
|
||||
|
||||
it("marks the single day a luteal phase transitions into a follicular phase", () => {
|
||||
const days = derivePeriodDaysFromCyclePhases([
|
||||
{ day: "2026-06-08", phase: "luteal" },
|
||||
{ day: "2026-06-09", phase: "luteal" },
|
||||
{ day: "2026-06-10", phase: "follicular" },
|
||||
{ day: "2026-06-11", phase: "follicular" },
|
||||
]);
|
||||
expect(days).toEqual(["2026-06-10"]);
|
||||
});
|
||||
|
||||
it("does NOT mark a luteal→follicular transition across a gap in the series", () => {
|
||||
const days = derivePeriodDaysFromCyclePhases([
|
||||
{ day: "2026-06-08", phase: "luteal" },
|
||||
// 2026-06-09 missing — not calendar-adjacent.
|
||||
{ day: "2026-06-10", phase: "follicular" },
|
||||
]);
|
||||
expect(days).toEqual([]);
|
||||
});
|
||||
|
||||
it("is order-independent — sorts records by day before scanning", () => {
|
||||
const days = derivePeriodDaysFromCyclePhases([
|
||||
{ day: "2026-06-10", phase: "follicular" },
|
||||
{ day: "2026-06-09", phase: "luteal" },
|
||||
]);
|
||||
expect(days).toEqual(["2026-06-10"]);
|
||||
});
|
||||
|
||||
it("ignores an unrecognised / missing phase string (never guesses)", () => {
|
||||
const days = derivePeriodDaysFromCyclePhases([
|
||||
{ day: "2026-06-08", phase: "luteal" },
|
||||
{ day: "2026-06-09", phase: "unknown_future_phase" },
|
||||
{ day: "2026-06-10", phase: "follicular" },
|
||||
]);
|
||||
// The unrecognised phase still updates the "previous phase" tracker (to
|
||||
// a value that is neither luteal nor follicular), so the 06-09→06-10
|
||||
// step is no longer a luteal→follicular transition and nothing fires.
|
||||
expect(days).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for an empty or all-ovulatory/all-luteal series (no transition, no direct hit)", () => {
|
||||
expect(derivePeriodDaysFromCyclePhases([])).toEqual([]);
|
||||
expect(
|
||||
derivePeriodDaysFromCyclePhases([
|
||||
{ day: "2026-06-08", phase: "luteal" },
|
||||
{ day: "2026-06-09", phase: "luteal" },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips records with a missing/empty day string", () => {
|
||||
const days = derivePeriodDaysFromCyclePhases([
|
||||
{ day: "", phase: "menstrual" },
|
||||
{ day: "2026-06-10", phase: "menstrual" },
|
||||
] as never);
|
||||
expect(days).toEqual(["2026-06-10"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* `syncUserOuraCyclePhases` — Oura Cycle Insights → CycleDayLog, subordinate
|
||||
* to any existing row. The core invariant under test: a day that already
|
||||
* carries ANY CycleDayLog row (regardless of source) is never touched —
|
||||
* Oura only ever fills a genuinely empty day.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchCyclePhasesMock, isCycleAvailableMock, findUserMock, createMock } =
|
||||
vi.hoisted(() => ({
|
||||
fetchCyclePhasesMock: vi.fn(),
|
||||
isCycleAvailableMock: vi.fn(),
|
||||
findUserMock: vi.fn(),
|
||||
createMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: findUserMock },
|
||||
cycleDayLog: { create: createMock },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cycle/gate", () => ({
|
||||
isCycleAvailableForUser: isCycleAvailableMock,
|
||||
}));
|
||||
|
||||
vi.mock("../client", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../client")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchDailyCyclePhases: fetchCyclePhasesMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { syncUserOuraCyclePhases } from "../cycle-sync";
|
||||
|
||||
function uniqueViolation() {
|
||||
const err = new Error("Unique constraint failed") as Error & {
|
||||
code: string;
|
||||
};
|
||||
err.code = "P2002";
|
||||
return err;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCyclePhasesMock.mockReset().mockResolvedValue([]);
|
||||
isCycleAvailableMock.mockReset().mockResolvedValue(true);
|
||||
findUserMock.mockReset().mockResolvedValue({ timezone: "Europe/Berlin" });
|
||||
createMock.mockReset().mockResolvedValue({ id: "cdl-1" });
|
||||
});
|
||||
|
||||
describe("syncUserOuraCyclePhases", () => {
|
||||
it("no-ops without touching the DB when the collection is empty", async () => {
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
expect(created).toBe(0);
|
||||
expect(isCycleAvailableMock).not.toHaveBeenCalled();
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no-ops when the series carries no derivable period day", async () => {
|
||||
fetchCyclePhasesMock.mockResolvedValue([
|
||||
{ day: "2026-06-08", phase: "luteal" },
|
||||
{ day: "2026-06-09", phase: "luteal" },
|
||||
]);
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
expect(created).toBe(0);
|
||||
expect(isCycleAvailableMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects the two-layer cycle gate — no write when disabled", async () => {
|
||||
fetchCyclePhasesMock.mockResolvedValue([
|
||||
{ day: "2026-06-05", phase: "menstrual" },
|
||||
]);
|
||||
isCycleAvailableMock.mockResolvedValue(false);
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
expect(created).toBe(0);
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a source=OURA flow=LIGHT row for a detected period day", async () => {
|
||||
fetchCyclePhasesMock.mockResolvedValue([
|
||||
{ day: "2026-06-05", phase: "menstrual" },
|
||||
]);
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
expect(created).toBe(1);
|
||||
expect(createMock).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: "u1",
|
||||
date: "2026-06-05",
|
||||
tz: "Europe/Berlin",
|
||||
source: "OURA",
|
||||
flow: "LIGHT",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("skips a day that already has ANY existing row (manual wins) — unique-violation swallowed silently", async () => {
|
||||
fetchCyclePhasesMock.mockResolvedValue([
|
||||
{ day: "2026-06-05", phase: "menstrual" },
|
||||
]);
|
||||
createMock.mockRejectedValue(uniqueViolation());
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
expect(created).toBe(0);
|
||||
});
|
||||
|
||||
it("never calls .update — a pre-existing row (its own or another source's) is left exactly as-is", async () => {
|
||||
fetchCyclePhasesMock.mockResolvedValue([
|
||||
{ day: "2026-06-05", phase: "menstrual" },
|
||||
{ day: "2026-06-06", phase: "menstrual" },
|
||||
]);
|
||||
createMock
|
||||
.mockRejectedValueOnce(uniqueViolation()) // 06-05 already logged (manual)
|
||||
.mockResolvedValueOnce({ id: "cdl-2" }); // 06-06 genuinely empty
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
expect(created).toBe(1);
|
||||
expect(createMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("propagates a non-unique-violation write error without crashing the whole batch", async () => {
|
||||
fetchCyclePhasesMock.mockResolvedValue([
|
||||
{ day: "2026-06-05", phase: "menstrual" },
|
||||
{ day: "2026-06-06", phase: "menstrual" },
|
||||
]);
|
||||
createMock
|
||||
.mockRejectedValueOnce(new Error("connection reset"))
|
||||
.mockResolvedValueOnce({ id: "cdl-2" });
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
// The failing day is skipped (warned, not thrown); the healthy sibling
|
||||
// still writes.
|
||||
expect(created).toBe(1);
|
||||
});
|
||||
|
||||
it("propagates the Oura fetch error to the caller (the sync layer decides how to swallow it)", async () => {
|
||||
fetchCyclePhasesMock.mockRejectedValue(new Error("403 forbidden"));
|
||||
await expect(syncUserOuraCyclePhases("u1", "tok", 7)).rejects.toThrow(
|
||||
"403 forbidden",
|
||||
);
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to a null tz when the user row / timezone is unresolvable", async () => {
|
||||
fetchCyclePhasesMock.mockResolvedValue([
|
||||
{ day: "2026-06-05", phase: "menstrual" },
|
||||
]);
|
||||
findUserMock.mockResolvedValue(null);
|
||||
await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
expect(createMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ tz: null }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates one row per distinct detected day from a luteal→follicular transition", async () => {
|
||||
fetchCyclePhasesMock.mockResolvedValue([
|
||||
{ day: "2026-06-08", phase: "luteal" },
|
||||
{ day: "2026-06-09", phase: "follicular" },
|
||||
]);
|
||||
const created = await syncUserOuraCyclePhases("u1", "tok", 7);
|
||||
expect(created).toBe(1);
|
||||
expect(createMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ date: "2026-06-09", flow: "LIGHT" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
fetchVo2MaxMock,
|
||||
fetchCardioAgeMock,
|
||||
fetchResilienceMock,
|
||||
fetchCyclePhasesMock,
|
||||
refreshMock,
|
||||
upsertMock,
|
||||
updateManyMock,
|
||||
@@ -31,6 +32,7 @@ const {
|
||||
fetchVo2MaxMock: vi.fn(),
|
||||
fetchCardioAgeMock: vi.fn(),
|
||||
fetchResilienceMock: vi.fn(),
|
||||
fetchCyclePhasesMock: vi.fn(),
|
||||
refreshMock: vi.fn(),
|
||||
upsertMock: vi.fn(),
|
||||
updateManyMock: vi.fn(),
|
||||
@@ -78,6 +80,7 @@ vi.mock("../client", async (importOriginal) => {
|
||||
fetchVo2Max: fetchVo2MaxMock,
|
||||
fetchCardiovascularAge: fetchCardioAgeMock,
|
||||
fetchResilience: fetchResilienceMock,
|
||||
fetchDailyCyclePhases: fetchCyclePhasesMock,
|
||||
refreshAccessToken: refreshMock,
|
||||
};
|
||||
});
|
||||
@@ -107,6 +110,7 @@ beforeEach(() => {
|
||||
fetchVo2MaxMock.mockReset().mockResolvedValue([]);
|
||||
fetchCardioAgeMock.mockReset().mockResolvedValue([]);
|
||||
fetchResilienceMock.mockReset().mockResolvedValue([]);
|
||||
fetchCyclePhasesMock.mockReset().mockResolvedValue([]);
|
||||
refreshMock.mockReset();
|
||||
upsertMock.mockReset().mockResolvedValue({});
|
||||
updateManyMock.mockReset().mockResolvedValue({ count: 0 });
|
||||
@@ -479,4 +483,51 @@ describe("syncUserOura", () => {
|
||||
expect(recordFailureMock).toHaveBeenCalled();
|
||||
expect(recordSuccessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("degrades gracefully when daily_cycle_phases 403s — the measurement sync still succeeds (F4/cycle)", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
fetchReadinessMock.mockResolvedValue([
|
||||
{ id: "1", day: "2026-06-10", score: 70 },
|
||||
]);
|
||||
// The endpoint is undocumented / gated for most connections — a 403 here
|
||||
// must never surface as a whole-connection reauth prompt.
|
||||
fetchCyclePhasesMock.mockRejectedValue(
|
||||
new OuraApiError({
|
||||
verb: "fetchDailyCyclePhases",
|
||||
classification: "reauth_required",
|
||||
httpStatus: 403,
|
||||
reason: "http_403",
|
||||
}),
|
||||
);
|
||||
const imported = await syncUserOura("u1");
|
||||
expect(imported).toBe(1);
|
||||
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura");
|
||||
expect(recordFailureMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-reads the connection for the cycle-phases fetch (picks up a rotated token)", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
await syncUserOura("u1");
|
||||
expect(fetchCyclePhasesMock).toHaveBeenCalledWith(
|
||||
CONN.accessToken,
|
||||
expect.objectContaining({
|
||||
startDate: expect.any(String),
|
||||
endDate: expect.any(String),
|
||||
}),
|
||||
);
|
||||
// Once for the measurement-sync token, once more for the cycle-phases
|
||||
// fetch's fresh read.
|
||||
expect(getConnMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("a cycle-phases fetch error never blocks morning-refresh / rollup tail work", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
fetchDailySleepMock.mockResolvedValue([
|
||||
{ id: "s", day: "2026-06-10", score: 80 },
|
||||
]);
|
||||
fetchCyclePhasesMock.mockRejectedValue(new Error("boom"));
|
||||
const imported = await syncUserOura("u1");
|
||||
expect(imported).toBe(1);
|
||||
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura");
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
|
||||
import { dayDiff } from "@/lib/cycle/day-math";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { OuraApiError, classifyOuraResponse } from "./response-classifier";
|
||||
|
||||
@@ -256,6 +257,37 @@ export interface OuraResilience {
|
||||
level?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Oura `daily_cycle_phases` — the per-day Cycle Insights phase estimate
|
||||
* (temperature-deviation-derived). UNDOCUMENTED in the published OpenAPI
|
||||
* spec at `cloud.ouraring.com/v2/docs` as of this writing — the route was
|
||||
* confirmed live against the production API (`GET
|
||||
* /v2/usercollection/daily_cycle_phases` 400s "Token is missing" identically
|
||||
* to every documented `daily_*` collection when called unauthenticated, vs a
|
||||
* genuine 404 for an unknown path) but sits outside the spec dump alongside
|
||||
* every other Oura reproductive-health resource (`period_start`,
|
||||
* `pregnancy`, `fertile_window`, `ovulation_confirmed`, `blood_glucose` — all
|
||||
* listed as valid webhook subscription data types, none with a documented
|
||||
* read schema). These look gated behind an approval Oura does not extend to
|
||||
* a self-registered OAuth app by default, so a 403/404 here is the EXPECTED
|
||||
* outcome for most connections, not an anomaly — see `syncUserOuraCyclePhases`
|
||||
* in `./cycle-sync` for how that is swallowed without touching the rest of
|
||||
* the Oura connection's health status. Re-verify the `phase` field name +
|
||||
* value set against `cloud.ouraring.com/v2/docs` once Oura documents it.
|
||||
*/
|
||||
export interface OuraCyclePhase {
|
||||
id?: string;
|
||||
day: string;
|
||||
/**
|
||||
* Oura's public Cycle Insights copy describes exactly two ring-observable
|
||||
* phases (`follicular` / `luteal`, temperature-derived); some algorithm
|
||||
* versions may additionally emit `menstrual` directly. Kept as a loose
|
||||
* string — an unrecognised / missing value is skipped, never guessed (see
|
||||
* `derivePeriodDaysFromCyclePhases`).
|
||||
*/
|
||||
phase?: string | null;
|
||||
}
|
||||
|
||||
export interface OuraDailyActivity {
|
||||
id: string;
|
||||
day: string;
|
||||
@@ -429,6 +461,19 @@ export function fetchResilience(
|
||||
);
|
||||
}
|
||||
|
||||
/** See the `OuraCyclePhase` docstring for the endpoint's documentation gap. */
|
||||
export function fetchDailyCyclePhases(
|
||||
accessToken: string,
|
||||
query: DateRangeQuery,
|
||||
): Promise<OuraCyclePhase[]> {
|
||||
return fetchCollection<OuraCyclePhase>(
|
||||
"/v2/usercollection/daily_cycle_phases",
|
||||
accessToken,
|
||||
"fetchDailyCyclePhases",
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Field → Measurement mapping ───────────────────────────────
|
||||
|
||||
const SEC_TO_MIN = 1 / 60;
|
||||
@@ -832,3 +877,69 @@ export function mapResilience(r: OuraResilience): MappedMeasurement[] {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Cycle-phase → CycleDayLog mapping (v1.29.x) ───────────────────
|
||||
//
|
||||
// `daily_cycle_phases` does NOT feed `Measurement` like every collection
|
||||
// above — it feeds the cycle tracker's `CycleDayLog` via
|
||||
// `syncUserOuraCyclePhases` in `./cycle-sync`, which owns the write-side
|
||||
// "manual always wins" guard. This module only derives WHICH days look like
|
||||
// a period day; it never touches the database.
|
||||
|
||||
const OURA_MENSTRUAL_PHASE = "menstrual";
|
||||
const OURA_FOLLICULAR_PHASE = "follicular";
|
||||
const OURA_LUTEAL_PHASE = "luteal";
|
||||
|
||||
/**
|
||||
* Derive the (sparse) set of `YYYY-MM-DD` days that look like a period day
|
||||
* from a `daily_cycle_phases` series. The endpoint reports only the PHASE,
|
||||
* not menstrual flow directly, so a period day is inferred two ways:
|
||||
*
|
||||
* 1. A record whose `phase` is literally `"menstrual"` — some algorithm
|
||||
* versions / accounts may emit this directly.
|
||||
* 2. The single day a `"luteal"` phase is immediately (calendar-adjacent)
|
||||
* followed by a `"follicular"` phase. Oura's own definition of the
|
||||
* follicular phase is "from the first day of menstruation to
|
||||
* ovulation", so this transition day is the best period-start signal
|
||||
* the two-phase (follicular/luteal) shape carries.
|
||||
*
|
||||
* Records are sorted by `day` internally (the collection's page order is not
|
||||
* guaranteed to be chronological). Returns bare day strings — the caller
|
||||
* writes each as a single-day conservative `flow: LIGHT` marker (the same
|
||||
* "logged but unspecified intensity" boundary the HealthKit `unspecified`
|
||||
* flow mapping uses), never a multi-day bleed span this endpoint gives no
|
||||
* evidence for.
|
||||
*/
|
||||
export function derivePeriodDaysFromCyclePhases(
|
||||
records: readonly OuraCyclePhase[],
|
||||
): string[] {
|
||||
const sorted = [...records]
|
||||
.filter((r) => typeof r.day === "string" && r.day.length > 0)
|
||||
.sort((a, b) => dayDiff(a.day, b.day));
|
||||
|
||||
const days = new Set<string>();
|
||||
let prevPhase: string | null = null;
|
||||
let prevDay: string | null = null;
|
||||
|
||||
for (const r of sorted) {
|
||||
const phase = typeof r.phase === "string" ? r.phase.toLowerCase() : null;
|
||||
|
||||
if (phase === OURA_MENSTRUAL_PHASE) {
|
||||
days.add(r.day);
|
||||
} else if (
|
||||
phase === OURA_FOLLICULAR_PHASE &&
|
||||
prevPhase === OURA_LUTEAL_PHASE &&
|
||||
prevDay !== null &&
|
||||
dayDiff(r.day, prevDay) === 1
|
||||
) {
|
||||
days.add(r.day);
|
||||
}
|
||||
|
||||
if (phase) {
|
||||
prevPhase = phase;
|
||||
prevDay = r.day;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(days);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* v1.29.x — Oura Cycle Insights (`daily_cycle_phases`) → HealthLog's cycle
|
||||
* tracker, as an ADDITIONAL, SUBORDINATE source.
|
||||
*
|
||||
* Every other Oura collection (see `./sync`) writes `Measurement` rows,
|
||||
* which support multiple source rows per `(type, day)` and resolve a
|
||||
* canonical reading at READ time (`src/lib/analytics/source-priority.ts`).
|
||||
* `CycleDayLog` has no such ladder — it is ONE canonical row per
|
||||
* `(userId, date)` (`prisma/schema.prisma` — "the HealthKit sync unit + the
|
||||
* manual log-day surface"), and the shared write helper
|
||||
* (`src/lib/cycle/day-log-write.ts`) partial-merges a re-post's fields over
|
||||
* whatever is already stored REGARDLESS of source. Calling it here would let
|
||||
* an hourly Oura poll silently demote a user's own logged flow (e.g. a
|
||||
* manual HEAVY re-tagged LIGHT on the next tick) — the exact opposite of
|
||||
* "manual wins".
|
||||
*
|
||||
* So this module never touches the shared upsert helper. It only ever
|
||||
* CREATEs a day that has NO existing `CycleDayLog` row at all (any source),
|
||||
* and never updates a row it did not itself create. A day already owned by
|
||||
* this module (source = OURA) is left alone on re-sync too — the marker is
|
||||
* a fixed single-value hint (`flow: LIGHT`), not a corrigible reading, so
|
||||
* there is nothing to refresh. The whole path degenerates to a cheap
|
||||
* "create if absent, otherwise no-op".
|
||||
*
|
||||
* Gated behind the SAME two-layer cycle-module gate every other cycle
|
||||
* writer respects (`isCycleAvailableForUser`) so an operator-disabled
|
||||
* instance, or a user who turned cycle tracking off, never accumulates rows
|
||||
* from a background Oura poll. No `MenstrualCycle` span is ever created or
|
||||
* mutated here — mirrors the Apple Health importer precedent
|
||||
* (`src/lib/cycle/import-accumulator.ts`), which attributes day-logs to an
|
||||
* EXISTING cycle but never synthesises a new one from flow data either;
|
||||
* cycle-span creation stays an explicit user action
|
||||
* (`POST /api/cycle/period`).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
|
||||
import {
|
||||
fetchDailyCyclePhases,
|
||||
derivePeriodDaysFromCyclePhases,
|
||||
} from "./client";
|
||||
|
||||
/** The conservative "logged but unspecified intensity" flow level — mirrors
|
||||
* the HealthKit `unspecified` → `LIGHT` boundary documented in
|
||||
* `healthkit-mapping.ts`: a detected-but-unmeasured bleeding day is at least
|
||||
* light flow, never invented as MEDIUM/HEAVY. */
|
||||
const OURA_PERIOD_FLOW = "LIGHT" as const;
|
||||
|
||||
/** Prisma P2002 (unique constraint) detector, mirrored locally from
|
||||
* `day-log-write.ts` rather than imported — a two-line duplicate is cheaper
|
||||
* than coupling this module to that helper's internals. */
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code?: unknown }).code === "P2002"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync one user's Oura `daily_cycle_phases` window into `CycleDayLog`.
|
||||
* Returns the count of days newly created.
|
||||
*
|
||||
* Every error the Oura fetch throws propagates to the caller — this
|
||||
* endpoint sits outside the scope most self-registered Oura apps are
|
||||
* granted (see the `OuraCyclePhase` docstring in `./client`), so a 403/404
|
||||
* here is the COMMON case, not an anomaly. `./sync` swallows it without
|
||||
* touching the rest of the Oura connection's health status.
|
||||
*
|
||||
* The Oura fetch runs BEFORE the cycle-module gate / any database access on
|
||||
* purpose: the overwhelmingly common outcome (no access to this endpoint, or
|
||||
* no transition in the window) resolves without a single query.
|
||||
*/
|
||||
export async function syncUserOuraCyclePhases(
|
||||
userId: string,
|
||||
accessToken: string,
|
||||
lookbackDays: number,
|
||||
): Promise<number> {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - lookbackDays * 24 * 60 * 60 * 1000);
|
||||
const records = await fetchDailyCyclePhases(accessToken, {
|
||||
startDate: start.toISOString().slice(0, 10),
|
||||
endDate: now.toISOString().slice(0, 10),
|
||||
});
|
||||
if (records.length === 0) return 0;
|
||||
|
||||
const periodDays = derivePeriodDaysFromCyclePhases(records);
|
||||
if (periodDays.length === 0) return 0;
|
||||
|
||||
const available = await isCycleAvailableForUser(userId);
|
||||
if (!available) return 0;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const tz = user?.timezone ?? null;
|
||||
|
||||
let created = 0;
|
||||
for (const day of periodDays) {
|
||||
try {
|
||||
await prisma.cycleDayLog.create({
|
||||
data: {
|
||||
userId,
|
||||
date: day,
|
||||
tz,
|
||||
source: "OURA",
|
||||
flow: OURA_PERIOD_FLOW,
|
||||
},
|
||||
});
|
||||
created += 1;
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) {
|
||||
// A row already exists for this date — the user's own entry, an
|
||||
// Apple Health import, or this module's own prior write. Never
|
||||
// overwrite: Oura is a subordinate signal here.
|
||||
continue;
|
||||
}
|
||||
getEvent()?.addWarning(
|
||||
`oura: cycle day-log create failed for ${userId} ${day}: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
recomputeBucketsForMeasurement,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
import {
|
||||
sweepStaleSleepSegments,
|
||||
type SleepSegmentSweep,
|
||||
@@ -77,6 +78,7 @@ import {
|
||||
storeOuraTokens,
|
||||
} from "./credentials";
|
||||
import { OuraApiError, classifyOuraError } from "./response-classifier";
|
||||
import { syncUserOuraCyclePhases } from "./cycle-sync";
|
||||
|
||||
/** Default lookback window (days) for an incremental sync. Oura finalises a
|
||||
* night's scores hours after wake; 7 days re-fetches a handful of records (the
|
||||
@@ -369,6 +371,38 @@ export async function syncUserOura(
|
||||
// sibling collection failed — one bad collection must not blank the source.
|
||||
const imported = await upsertOuraMeasurements(userId, result.readings);
|
||||
|
||||
// v1.29.x — best-effort Cycle Insights import. Fully isolated from the
|
||||
// measurement sync's status ledger on purpose: `daily_cycle_phases` sits
|
||||
// outside the scope most self-registered Oura apps are granted (see the
|
||||
// `OuraCyclePhase` docstring in `./client`), so a 403/404 here is the
|
||||
// COMMON case for a typical connection. Re-reads the connection so a
|
||||
// reactive refresh `fetchAll` just performed is picked up rather than the
|
||||
// (now possibly stale) token captured before it ran.
|
||||
try {
|
||||
const freshConn = await getOuraConnection(userId);
|
||||
if (freshConn) {
|
||||
await syncUserOuraCyclePhases(
|
||||
userId,
|
||||
freshConn.accessToken,
|
||||
opts.lookbackDays ?? OURA_SYNC_LOOKBACK_DAYS,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`oura: cycle-phases import skipped for ${userId}: ${err}`,
|
||||
);
|
||||
}
|
||||
|
||||
// S4 — trigger the debounced morning refresh on a last-night segment landing
|
||||
// (mirrors the Withings / WHOOP / Apple seams). Fires whether or not a
|
||||
// sibling collection failed, since the sleep readings were still imported.
|
||||
void maybeEnqueueMorningRefresh(
|
||||
userId,
|
||||
result.readings
|
||||
.filter((r) => r.type === "SLEEP_DURATION")
|
||||
.map((r) => r.measuredAt),
|
||||
).catch(() => {});
|
||||
|
||||
if (result.failures.length > 0) {
|
||||
// Partial failure: keep the cycle honest. Record the failure and do NOT
|
||||
// stamp success, so the freshness surface reflects that some collections
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
recomputeBucketsForMeasurement,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
import {
|
||||
fetchActivities,
|
||||
fetchCardioLoads,
|
||||
@@ -161,6 +162,16 @@ export async function syncUserPolar(userId: string): Promise<number> {
|
||||
await sweepStaleSleepSegments(userId, "POLAR", sleepSweeps);
|
||||
|
||||
const imported = await upsertPolarMeasurements(userId, readings);
|
||||
|
||||
// S4 — trigger the debounced morning refresh on a last-night segment landing
|
||||
// (mirrors the Withings / WHOOP / Apple seams).
|
||||
void maybeEnqueueMorningRefresh(
|
||||
userId,
|
||||
readings
|
||||
.filter((r) => r.type === "SLEEP_DURATION")
|
||||
.map((r) => r.measuredAt),
|
||||
).catch(() => {});
|
||||
|
||||
await recordSyncSuccess(userId, "polar");
|
||||
return imported;
|
||||
}
|
||||
|
||||
@@ -158,6 +158,15 @@ export const moodDependentKeys = [
|
||||
* minutes. The intake routes already hard-evict the server-side
|
||||
* snapshot bucket, so the refetch this triggers returns post-write data
|
||||
* immediately.
|
||||
*
|
||||
* v1.29.1 — `dailyDigest` joins the bundle. The Today hero's digest reads
|
||||
* `medsToday` from the same server snapshot the intake routes hard-evict,
|
||||
* but nothing invalidated the client query, so the digest's "dose due"
|
||||
* rail item lingered until a hard reload. Like the snapshot, the digest
|
||||
* query runs with refetchOnMount/WindowFocus tuned off, so the intake
|
||||
* seam ALSO forces an inactive refetch (it is unmounted while the user is
|
||||
* on the medication card); this bundle membership covers the case where
|
||||
* the dashboard is the active surface.
|
||||
*/
|
||||
export const medicationDependentKeys = [
|
||||
queryKeys.medications(),
|
||||
@@ -166,6 +175,7 @@ export const medicationDependentKeys = [
|
||||
queryKeys.insightsTargets(),
|
||||
queryKeys.gamificationAchievements(),
|
||||
queryKeys.dashboardSnapshot(),
|
||||
queryKeys.dailyDigest(),
|
||||
["dashboard-medication-compliance"] as const,
|
||||
["compliance-chart-inline"] as const,
|
||||
];
|
||||
|
||||
@@ -28,11 +28,13 @@ const row = (
|
||||
source: WorkoutPickerRow["source"],
|
||||
startedAt: string,
|
||||
sportType: WorkoutPickerRow["sportType"] = "running",
|
||||
extra: Partial<WorkoutPickerRow> = {},
|
||||
): WorkoutPickerRow => ({
|
||||
id,
|
||||
source,
|
||||
startedAt: D(startedAt),
|
||||
sportType,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("DEFAULT_WORKOUT_SOURCE_PRIORITY", () => {
|
||||
@@ -213,3 +215,125 @@ describe("pickCanonicalWorkout — determinism", () => {
|
||||
expect(result.canonical[0].id).toBe("a-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCanonicalWorkout — field-merge (v1.29.x, WHOOP HR-loss fix)", () => {
|
||||
it("backfills avgHeartRate/maxHeartRate from a lower-priority twin when the winner's is null", () => {
|
||||
// Regression fixture from the brief: a WHOOP cycling row carries live
|
||||
// HR; a higher-priority Apple Health twin for the SAME ride has no HR
|
||||
// (e.g. a manually-logged / GPS-only Apple entry). Before the merge,
|
||||
// the picker kept the Apple row whole and threw the WHOOP HR away.
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "cycling", {
|
||||
avgHeartRate: null,
|
||||
maxHeartRate: null,
|
||||
}),
|
||||
row("whoop-1", "WHOOP", "2026-05-14T07:31:00.000Z", "cycling", {
|
||||
avgHeartRate: 142,
|
||||
maxHeartRate: 168,
|
||||
}),
|
||||
]);
|
||||
expect(result.canonical).toHaveLength(1);
|
||||
const winner = result.canonical[0];
|
||||
expect(winner.id).toBe("apple-1"); // ladder winner stays the base row
|
||||
expect(winner.avgHeartRate).toBe(142);
|
||||
expect(winner.maxHeartRate).toBe(168);
|
||||
expect(result.clusters[0].picked.avgHeartRate).toBe(142);
|
||||
});
|
||||
|
||||
it("never overwrites a field the base row already has", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "cycling", {
|
||||
avgHeartRate: 120,
|
||||
}),
|
||||
row("whoop-1", "WHOOP", "2026-05-14T07:31:00.000Z", "cycling", {
|
||||
avgHeartRate: 142,
|
||||
}),
|
||||
]);
|
||||
expect(result.canonical[0].avgHeartRate).toBe(120);
|
||||
});
|
||||
|
||||
it("backfills totalEnergyKcal, totalDistanceM, and elevationM independently", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "cycling", {
|
||||
totalEnergyKcal: null,
|
||||
totalDistanceM: 15000,
|
||||
elevationM: null,
|
||||
}),
|
||||
row("whoop-1", "WHOOP", "2026-05-14T07:31:00.000Z", "cycling", {
|
||||
totalEnergyKcal: 480,
|
||||
totalDistanceM: 14800,
|
||||
elevationM: 220,
|
||||
}),
|
||||
]);
|
||||
const winner = result.canonical[0];
|
||||
expect(winner.totalEnergyKcal).toBe(480); // backfilled
|
||||
expect(winner.totalDistanceM).toBe(15000); // base row's own value kept
|
||||
expect(winner.elevationM).toBe(220); // backfilled
|
||||
});
|
||||
|
||||
it("prefers the ladder-highest member that HAS a field over a lower one, when the base row lacks it", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "cycling", {
|
||||
avgHeartRate: null,
|
||||
}),
|
||||
row("whoop-1", "WHOOP", "2026-05-14T07:30:30.000Z", "cycling", {
|
||||
avgHeartRate: 142,
|
||||
}),
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:31:00.000Z", "cycling", {
|
||||
avgHeartRate: 99,
|
||||
}),
|
||||
]);
|
||||
// WHOOP outranks WITHINGS on the default ladder — its HR wins the
|
||||
// backfill even though WITHINGS also had one.
|
||||
expect(result.canonical[0].avgHeartRate).toBe(142);
|
||||
});
|
||||
|
||||
it("adopts a specific sport type from a member when the base row's own sportType is generic", () => {
|
||||
// The confirmed bug's downstream symptom: before the WHOOP sport-map
|
||||
// fix, a WHOOP cycling workout could carry sportType "other" (or a
|
||||
// non-canonical raw label) while a lower-priority twin correctly
|
||||
// tagged "cycling". The merge adopts the specific sport so the
|
||||
// cluster still surfaces as a bike ride.
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "other"),
|
||||
row("whoop-1", "WHOOP", "2026-05-14T07:31:00.000Z", "cycling"),
|
||||
]);
|
||||
expect(result.canonical[0].sportType).toBe("cycling");
|
||||
});
|
||||
|
||||
it("keeps the base row's own specific sportType even when a member disagrees", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "cycling"),
|
||||
row("whoop-1", "WHOOP", "2026-05-14T07:31:00.000Z", "cycling"),
|
||||
]);
|
||||
expect(result.canonical[0].sportType).toBe("cycling");
|
||||
});
|
||||
|
||||
it("does not run the merge on a single-element cluster", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "cycling", {
|
||||
avgHeartRate: null,
|
||||
}),
|
||||
]);
|
||||
expect(result.canonical[0].avgHeartRate).toBeNull();
|
||||
});
|
||||
|
||||
it("end-to-end: a WHOOP-cycling+HR row clustered with a generic no-HR twin yields a cycling canonical row WITH the HR", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "other", {
|
||||
avgHeartRate: null,
|
||||
totalEnergyKcal: null,
|
||||
}),
|
||||
row("whoop-1", "WHOOP", "2026-05-14T07:31:00.000Z", "cycling", {
|
||||
avgHeartRate: 145,
|
||||
totalEnergyKcal: 512,
|
||||
}),
|
||||
]);
|
||||
expect(result.canonical).toHaveLength(1);
|
||||
const winner = result.canonical[0];
|
||||
expect(winner.source).toBe("APPLE_HEALTH"); // ladder still picks Apple as the base
|
||||
expect(winner.sportType).toBe("cycling"); // adopted from WHOOP
|
||||
expect(winner.avgHeartRate).toBe(145); // backfilled from WHOOP
|
||||
expect(winner.totalEnergyKcal).toBe(512); // backfilled from WHOOP
|
||||
});
|
||||
});
|
||||
@@ -21,14 +21,31 @@
|
||||
* ScanWatch passthrough lag plus a small clock-skew buffer).
|
||||
* Sport type must match across the cluster; a "Walking" workout
|
||||
* and a "Running" workout starting at the same instant stay
|
||||
* distinct rows so the cardio-vs-strength split stays clean.
|
||||
* distinct rows so the cardio-vs-strength split stays clean. Once
|
||||
* an integration writes a canonical `sportType` (every source does,
|
||||
* as of the WHOOP fix below), a cross-source pair of the SAME
|
||||
* workout always shares a sport bucket and clusters correctly —
|
||||
* before that fix a WHOOP "whoop_sport_1" row could never cluster
|
||||
* with an Apple Health "cycling" row.
|
||||
* 2. Per cluster, walk the per-user source-priority ladder for
|
||||
* workouts (default `APPLE_HEALTH > WITHINGS > MANUAL > IMPORT`)
|
||||
* and keep the first source that has a row in the cluster. All
|
||||
* other rows from the same cluster are dropped from the
|
||||
* canonical list.
|
||||
* 3. Single-element clusters (no near-duplicate) pass through
|
||||
* unchanged regardless of source.
|
||||
* and keep the first source that has a row in the cluster as the
|
||||
* BASE of the canonical row. All other rows from the same cluster
|
||||
* are dropped from the canonical list.
|
||||
* 3. Field-merge (multi-element clusters only): the base row is
|
||||
* backfilled with any of `avgHeartRate`, `maxHeartRate`,
|
||||
* `totalEnergyKcal`, `totalDistanceM`, `elevationM` it is itself
|
||||
* missing, from the ladder-highest cluster member that HAS that
|
||||
* field — so a higher-priority twin with a null HR (e.g. a
|
||||
* Withings summary) doesn't discard the HR a lower-priority twin
|
||||
* (e.g. WHOOP) actually recorded. The same step adopts a specific
|
||||
* sport type from a member when the base row's own sportType is
|
||||
* generic (`"other"` / unrecognised). Every OTHER field on the
|
||||
* base row (id, source, startedAt, route, metadata, ...) is
|
||||
* untouched — the merge only ever fills a gap, never overwrites a
|
||||
* value the base row already has.
|
||||
* 4. Single-element clusters (no near-duplicate) pass through
|
||||
* unchanged regardless of source — no merge step runs.
|
||||
*
|
||||
* Rationale for the default ladder
|
||||
* ────────────────────────────────
|
||||
@@ -55,14 +72,18 @@
|
||||
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
|
||||
import type { WorkoutSportType } from "@/lib/validations/workout";
|
||||
import {
|
||||
workoutSportTypeEnum,
|
||||
type WorkoutSportType,
|
||||
} from "@/lib/validations/workout";
|
||||
|
||||
/**
|
||||
* Minimum row shape the helper consults. Exposed as an interface so the
|
||||
* Prisma `Workout` model and the not-yet-existing ingest DTO both
|
||||
* compose with the picker without a transform step. Anything else on
|
||||
* the row (totals, HR aggregates, route blob, metadata) flows through
|
||||
* untouched — the picker filters but never mutates.
|
||||
* the row (route blob, metadata, ...) flows through untouched — ONLY the
|
||||
* fields declared below are eligible for the step-3 field-merge, and even
|
||||
* those are backfilled (fills a gap on the base row), never overwritten.
|
||||
*/
|
||||
export interface WorkoutPickerRow {
|
||||
/** Stable identifier — `id` for DB rows, externalId+source for
|
||||
@@ -72,8 +93,108 @@ export interface WorkoutPickerRow {
|
||||
startedAt: Date;
|
||||
source: MeasurementSource;
|
||||
/** Canonical sport string. Workouts of different sport types never
|
||||
* collapse into one cluster — see step 1 of the algorithm. */
|
||||
* collapse into one cluster — see step 1 of the algorithm. May be
|
||||
* adopted from a losing cluster member by the step-3 merge when the
|
||||
* base row's own value is generic — see `mergeClusterFields()`. */
|
||||
sportType: WorkoutSportType | string;
|
||||
/** Backfillable richer fields — present when the source carries live
|
||||
* sensor data (HR strap, GPS), absent/null on a coarser summary from
|
||||
* another source recording the same workout. */
|
||||
avgHeartRate?: number | null;
|
||||
maxHeartRate?: number | null;
|
||||
totalEnergyKcal?: number | null;
|
||||
totalDistanceM?: number | null;
|
||||
elevationM?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The numeric fields step 3 of the algorithm may backfill onto the base
|
||||
* row. Listed explicitly (not derived via `keyof`) so each field's copy
|
||||
* stays independently type-checked in `mergeClusterFields()`.
|
||||
*/
|
||||
const MERGEABLE_NUMERIC_FIELDS = [
|
||||
"avgHeartRate",
|
||||
"maxHeartRate",
|
||||
"totalEnergyKcal",
|
||||
"totalDistanceM",
|
||||
"elevationM",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* `true` for a specific canonical sport bucket; `false` for the generic
|
||||
* `"other"` bucket or a string outside `workoutSportTypeEnum` entirely (a
|
||||
* not-yet-normalised integration's raw label). Drives the sport-type
|
||||
* adoption half of the merge.
|
||||
*/
|
||||
function isSpecificSportType(value: string): boolean {
|
||||
return (
|
||||
value !== "other" &&
|
||||
(workoutSportTypeEnum.options as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** Ladder position of `source`, or `ladder.length` (last) when the source
|
||||
* isn't on the ladder at all — an unranked source only wins a merge field
|
||||
* when no ranked member has it. */
|
||||
function ladderRank(
|
||||
source: MeasurementSource,
|
||||
ladder: readonly MeasurementSource[],
|
||||
): number {
|
||||
const idx = ladder.indexOf(source);
|
||||
return idx === -1 ? ladder.length : idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of the algorithm: backfill the ladder-winning base row's missing
|
||||
* richer fields, and its generic sport type, from other cluster members.
|
||||
*
|
||||
* Deterministic: for each candidate field, the ladder-highest member that
|
||||
* HAS a non-null value wins that field. `members` is already
|
||||
* startedAt/id-sorted by the caller, so a same-rank tie resolves to
|
||||
* whichever member sorts first.
|
||||
*
|
||||
* A single-element cluster returns `winning` unchanged — nothing to merge.
|
||||
*/
|
||||
function mergeClusterFields<T extends WorkoutPickerRow>(
|
||||
winning: T,
|
||||
members: readonly T[],
|
||||
ladder: readonly MeasurementSource[],
|
||||
): T {
|
||||
if (members.length <= 1) return winning;
|
||||
|
||||
const merged: T = { ...winning };
|
||||
|
||||
for (const field of MERGEABLE_NUMERIC_FIELDS) {
|
||||
if (merged[field] != null) continue;
|
||||
let best: T | undefined;
|
||||
let bestRank = Infinity;
|
||||
for (const member of members) {
|
||||
const value = member[field];
|
||||
if (value == null) continue;
|
||||
const rank = ladderRank(member.source, ladder);
|
||||
if (rank < bestRank) {
|
||||
bestRank = rank;
|
||||
best = member;
|
||||
}
|
||||
}
|
||||
if (best) merged[field] = best[field];
|
||||
}
|
||||
|
||||
if (!isSpecificSportType(merged.sportType)) {
|
||||
let best: T | undefined;
|
||||
let bestRank = Infinity;
|
||||
for (const member of members) {
|
||||
if (!isSpecificSportType(member.sportType)) continue;
|
||||
const rank = ladderRank(member.source, ladder);
|
||||
if (rank < bestRank) {
|
||||
bestRank = rank;
|
||||
best = member;
|
||||
}
|
||||
}
|
||||
if (best) merged.sportType = best.sportType;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,8 +270,12 @@ export function pickCanonicalWorkout<T extends WorkoutPickerRow>(
|
||||
* without re-running the picker.
|
||||
*/
|
||||
clusters: Array<{
|
||||
/** Every row that clustered together, UNMERGED — the raw inputs the
|
||||
* merge step read from. */
|
||||
members: T[];
|
||||
pickedSource: MeasurementSource;
|
||||
/** The ladder winner AFTER the step-3 field-merge — the same object
|
||||
* that landed in `canonical` for this cluster. */
|
||||
picked: T;
|
||||
}>;
|
||||
} {
|
||||
@@ -175,11 +300,19 @@ export function pickCanonicalWorkout<T extends WorkoutPickerRow>(
|
||||
|
||||
// Build clusters keyed by sport type (a Walking + Running pair
|
||||
// starting at the same instant must stay distinct — different
|
||||
// workouts entirely).
|
||||
// workouts entirely) — WITH one relaxation: a GENERIC sport
|
||||
// ("other" / an unrecognised raw string) is treated as compatible
|
||||
// with any SPECIFIC sport, so a thinly-classified row from one
|
||||
// source (an unmapped import, a not-yet-normalised integration) can
|
||||
// still cluster with a specific-sport twin from another source
|
||||
// recording the SAME real-world workout — the merge step below then
|
||||
// adopts the specific sport onto the canonical row. Two DIFFERENT
|
||||
// specific sports (Walking vs Running) never become compatible this
|
||||
// way — the core invariant is unchanged.
|
||||
const clusters: T[][] = [];
|
||||
for (const row of sorted) {
|
||||
// Find an existing cluster of the same sport type whose latest
|
||||
// member is within the proximity window. Walking from the most
|
||||
// Find an existing cluster whose latest member is within the
|
||||
// proximity window and sport-compatible. Walking from the most
|
||||
// recent cluster backward is O(k) per row where k is the open
|
||||
// cluster count; for the workout volume HealthLog ingests (single
|
||||
// user, tens of workouts per week) this is well below the
|
||||
@@ -188,16 +321,25 @@ export function pickCanonicalWorkout<T extends WorkoutPickerRow>(
|
||||
for (let i = clusters.length - 1; i >= 0; i--) {
|
||||
const cluster = clusters[i];
|
||||
const head = cluster[0];
|
||||
if (head.sportType !== row.sportType) continue;
|
||||
const sameSport = head.sportType === row.sportType;
|
||||
if (!sameSport) {
|
||||
const genericPairing =
|
||||
!isSpecificSportType(head.sportType) ||
|
||||
!isSpecificSportType(row.sportType);
|
||||
if (!genericPairing) continue;
|
||||
}
|
||||
const last = cluster[cluster.length - 1];
|
||||
if (row.startedAt.getTime() - last.startedAt.getTime() <= proximityMs) {
|
||||
cluster.push(row);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
// Sorted input + outside-window: every earlier cluster of the
|
||||
// same sport is even further away, no point continuing.
|
||||
if (head.sportType === row.sportType) break;
|
||||
// Sorted input + outside-window, exact sport match: every
|
||||
// earlier cluster of the same sport is even further away, no
|
||||
// point continuing. A generic↔specific pairing has no such
|
||||
// monotonic guarantee once different-sport clusters interleave
|
||||
// in time, so keep scanning rather than abort the whole search.
|
||||
if (sameSport) break;
|
||||
}
|
||||
if (!placed) {
|
||||
clusters.push([row]);
|
||||
@@ -247,11 +389,12 @@ export function pickCanonicalWorkout<T extends WorkoutPickerRow>(
|
||||
continue;
|
||||
}
|
||||
const winning = cluster.find((row) => row.source === pickedSource)!;
|
||||
canonical.push(winning);
|
||||
const merged = mergeClusterFields(winning, cluster, ladder);
|
||||
canonical.push(merged);
|
||||
clusterRecords.push({
|
||||
members: cluster,
|
||||
pickedSource,
|
||||
picked: winning,
|
||||
picked: merged,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
describe("shiftDateKey", () => {
|
||||
it("steps a day key backward and forward", () => {
|
||||
expect(shiftDateKey("2026-06-15", -1)).toBe("2026-06-14");
|
||||
expect(shiftDateKey("2026-06-15", 1)).toBe("2026-06-16");
|
||||
});
|
||||
|
||||
it("rolls over a month boundary", () => {
|
||||
expect(shiftDateKey("2026-06-01", -1)).toBe("2026-05-31");
|
||||
expect(shiftDateKey("2026-05-31", 1)).toBe("2026-06-01");
|
||||
});
|
||||
|
||||
it("rolls over a year boundary", () => {
|
||||
expect(shiftDateKey("2026-01-01", -1)).toBe("2025-12-31");
|
||||
expect(shiftDateKey("2025-12-31", 1)).toBe("2026-01-01");
|
||||
});
|
||||
|
||||
it("is unaffected by DST transitions (pure UTC-anchored calendar math)", () => {
|
||||
// Europe/Berlin spring-forward day 2026-03-29 — the day key itself is
|
||||
// just a calendar date, so stepping across it is a plain +/-1 day.
|
||||
expect(shiftDateKey("2026-03-29", 1)).toBe("2026-03-30");
|
||||
expect(shiftDateKey("2026-03-29", -1)).toBe("2026-03-28");
|
||||
});
|
||||
|
||||
it("supports multi-day deltas", () => {
|
||||
expect(shiftDateKey("2026-06-15", 7)).toBe("2026-06-22");
|
||||
expect(shiftDateKey("2026-06-15", -30)).toBe("2026-05-16");
|
||||
});
|
||||
});
|
||||
@@ -191,6 +191,21 @@ export function userDayKey(date: Date, tz: string): string {
|
||||
return formatInUserTz(date, tz, "date");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift a `YYYY-MM-DD` day key by `deltaDays` (negative steps back). Pure
|
||||
* calendar-day arithmetic, UTC-anchored so it never touches a timezone
|
||||
* offset or DST edge — the key is already a resolved local calendar date,
|
||||
* not an instant, so incrementing it needs no `tz` argument. Used by the
|
||||
* intraday-pulse day navigator (S11) to page `?date=` a day at a time
|
||||
* without re-deriving the viewer's timezone for a pure string increment.
|
||||
*/
|
||||
export function shiftDateKey(dateKey: string, deltaDays: number): string {
|
||||
const [y, m, d] = dateKey.split("-").map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
dt.setUTCDate(dt.getUTCDate() + deltaDays);
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall-clock hour (0–23) an observer in `tz` reads off the clock at
|
||||
* `date`. Used for the time-of-day greeting so a traveller whose device
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Request schema for the Today rail's dismiss surface
|
||||
* (`POST /api/daily/digest/dismiss`).
|
||||
*
|
||||
* Lives outside the route file so the OpenAPI registry can import it without
|
||||
* touching the route module. `itemKey` is namespaced `<kind>:...`
|
||||
* (`isDismissibleItemKey` in `@/lib/daily/priority-item`) — the prefix alone
|
||||
* proves the key names one of the OBSERVATIONAL kinds
|
||||
* (`milestone` / `ecg_new_recording` / `tension_window`); anything else 422s
|
||||
* before a lookup ever runs, so an actionable item can never be dismissed
|
||||
* through this endpoint no matter what a client sends.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { isDismissibleItemKey } from "@/lib/daily/priority-item";
|
||||
|
||||
/** Generous ceiling on the longest real key (an ISO timestamp is ~24 chars). */
|
||||
const MAX_ITEM_KEY_LENGTH = 200;
|
||||
|
||||
export const dismissPriorityItemSchema = z
|
||||
.object({
|
||||
itemKey: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(MAX_ITEM_KEY_LENGTH)
|
||||
.refine(isDismissibleItemKey, {
|
||||
message: "itemKey does not name a dismissible rail item",
|
||||
}),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
WHOOP_SPORT_ID_MAP,
|
||||
WHOOP_SPORT_NAME_MAP,
|
||||
WHOOP_SPORT_TABLE,
|
||||
mapWhoopSportType,
|
||||
normaliseSportKey,
|
||||
} from "../sport-map";
|
||||
import { workoutSportTypeEnum } from "@/lib/validations/workout";
|
||||
|
||||
/**
|
||||
* `mapWhoopSportType()` fixes the confirmed prod bug: a WHOOP cycling
|
||||
* workout wrote a non-canonical `sportType` (`whoop_sport_<id>` or the raw
|
||||
* `sport_name`) and never rendered as "cycling" on `/insights/workouts`.
|
||||
*/
|
||||
|
||||
describe("WHOOP_SPORT_TABLE", () => {
|
||||
it("maps every documented entry to a value in workoutSportTypeEnum", () => {
|
||||
const valid = new Set(workoutSportTypeEnum.options);
|
||||
for (const entry of WHOOP_SPORT_TABLE) {
|
||||
expect(valid.has(entry.canonical)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("has no duplicate sport_id", () => {
|
||||
const ids = WHOOP_SPORT_TABLE.map((r) => r.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normaliseSportKey", () => {
|
||||
it("lowercases and collapses whitespace / punctuation to a single underscore", () => {
|
||||
expect(normaliseSportKey("Running")).toBe("running");
|
||||
expect(normaliseSportKey("Track & Field")).toBe("track_field");
|
||||
expect(normaliseSportKey("Hiking/Rucking")).toBe("hiking_rucking");
|
||||
expect(normaliseSportKey("hiking_rucking")).toBe("hiking_rucking");
|
||||
expect(normaliseSportKey(" Hot Yoga ")).toBe("hot_yoga");
|
||||
});
|
||||
|
||||
it("leaves an existing whoop_sport_<id> placeholder untouched (case aside)", () => {
|
||||
expect(normaliseSportKey("whoop_sport_1")).toBe("whoop_sport_1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapWhoopSportType — the confirmed cycling bug", () => {
|
||||
it("resolves WHOOP's cycling sport_id (1) to the canonical bucket", () => {
|
||||
expect(mapWhoopSportType(1)).toBe("cycling");
|
||||
});
|
||||
|
||||
it("resolves WHOOP's cycling sport_name to the canonical bucket", () => {
|
||||
expect(mapWhoopSportType(undefined, "Cycling")).toBe("cycling");
|
||||
expect(mapWhoopSportType(undefined, "cycling")).toBe("cycling");
|
||||
});
|
||||
|
||||
it("resolves Mountain Biking and Spin sport_ids to cycling too", () => {
|
||||
expect(mapWhoopSportType(57)).toBe("cycling");
|
||||
expect(mapWhoopSportType(97)).toBe("cycling");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapWhoopSportType — id preferred over name", () => {
|
||||
it("prefers sport_id when both fields are present and disagree", () => {
|
||||
// Contrived: id says running, name says something unmapped — id wins
|
||||
// because it needs no normalisation guesswork.
|
||||
expect(
|
||||
mapWhoopSportType(0, "some future sport WHOOP hasn't announced"),
|
||||
).toBe("running");
|
||||
});
|
||||
|
||||
it("falls back to sport_name when sport_id is absent (the post-2025-09-01 shape)", () => {
|
||||
expect(mapWhoopSportType(undefined, "Hiking/Rucking")).toBe("hiking");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapWhoopSportType — unmapped input never falls through to a placeholder", () => {
|
||||
it("defaults to other for an unknown sport_id", () => {
|
||||
expect(mapWhoopSportType(999_999)).toBe("other");
|
||||
});
|
||||
|
||||
it("defaults to other for an unknown sport_name", () => {
|
||||
expect(mapWhoopSportType(undefined, "quantum tunnelling")).toBe("other");
|
||||
});
|
||||
|
||||
it("defaults to other when neither field is present", () => {
|
||||
expect(mapWhoopSportType(undefined, undefined)).toBe("other");
|
||||
});
|
||||
|
||||
it("never returns a whoop_sport_<n> string for any input", () => {
|
||||
for (const id of [-1, 0, 1, 999_999]) {
|
||||
expect(mapWhoopSportType(id)).not.toMatch(/^whoop_sport_/);
|
||||
}
|
||||
expect(mapWhoopSportType(undefined, "totally unknown")).not.toMatch(
|
||||
/^whoop_sport_/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WHOOP_SPORT_ID_MAP / WHOOP_SPORT_NAME_MAP", () => {
|
||||
it("expose the same size as the source table (id map)", () => {
|
||||
expect(WHOOP_SPORT_ID_MAP.size).toBe(WHOOP_SPORT_TABLE.length);
|
||||
});
|
||||
|
||||
it("expose the same size as the source table (name map — every name is unique after normalisation)", () => {
|
||||
expect(WHOOP_SPORT_NAME_MAP.size).toBe(WHOOP_SPORT_TABLE.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* `upsertWhoopWorkout` — canonical sport mapping + metadata traceability.
|
||||
*
|
||||
* Regression coverage for the confirmed prod bug: a WHOOP cycling workout
|
||||
* wrote `Workout.sportType = "whoop_sport_1"` (or the raw `sport_name`)
|
||||
* instead of the canonical `"cycling"`, so it never rendered as cycling on
|
||||
* `/insights/workouts`. `upsertWhoopWorkout` must now route through
|
||||
* `mapWhoopSportType()` and keep the raw WHOOP fields in `metadata` for
|
||||
* traceability.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../client", () => ({
|
||||
fetchWorkouts: vi.fn(),
|
||||
fetchWorkoutById: vi.fn(),
|
||||
KJ_TO_KCAL: 0.239006,
|
||||
}));
|
||||
|
||||
vi.mock("../sync", async () => {
|
||||
const actual = await vi.importActual<typeof import("../sync")>("../sync");
|
||||
return {
|
||||
...actual,
|
||||
getValidToken: vi.fn(),
|
||||
markResourceSynced: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const workoutUpsert = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
whoopConnection: { findUnique: vi.fn() },
|
||||
workout: { upsert: (...a: unknown[]) => workoutUpsert(...a) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({ addWarning: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { upsertWhoopWorkout } from "../sync-workout";
|
||||
import type { WhoopWorkout } from "../client";
|
||||
|
||||
function scoredWorkout(overrides: Partial<WhoopWorkout>): WhoopWorkout {
|
||||
return {
|
||||
id: "w-1",
|
||||
user_id: 1,
|
||||
created_at: "2026-06-14T07:00:00.000Z",
|
||||
updated_at: "2026-06-14T08:00:00.000Z",
|
||||
start: "2026-06-14T07:00:00.000Z",
|
||||
end: "2026-06-14T08:00:00.000Z",
|
||||
score_state: "SCORED",
|
||||
score: {
|
||||
strain: 12.3,
|
||||
average_heart_rate: 130,
|
||||
max_heart_rate: 165,
|
||||
kilojoule: 1500,
|
||||
percent_recorded: 99,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
workoutUpsert.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("upsertWhoopWorkout — canonical sportType", () => {
|
||||
it("writes canonical 'cycling' for sport_id 1, never whoop_sport_1", async () => {
|
||||
await upsertWhoopWorkout("user-1", scoredWorkout({ sport_id: 1 }));
|
||||
const row = workoutUpsert.mock.calls[0]![0].create;
|
||||
expect(row.sportType).toBe("cycling");
|
||||
});
|
||||
|
||||
it("writes canonical 'cycling' when WHOOP sends sport_name instead of sport_id", async () => {
|
||||
await upsertWhoopWorkout(
|
||||
"user-1",
|
||||
scoredWorkout({ sport_id: undefined, sport_name: "cycling" }),
|
||||
);
|
||||
const row = workoutUpsert.mock.calls[0]![0].create;
|
||||
expect(row.sportType).toBe("cycling");
|
||||
});
|
||||
|
||||
it("falls back to 'other' for an unrecognised sport, never a whoop_sport_<n> placeholder", async () => {
|
||||
await upsertWhoopWorkout(
|
||||
"user-1",
|
||||
scoredWorkout({ sport_id: 999_999, sport_name: undefined }),
|
||||
);
|
||||
const row = workoutUpsert.mock.calls[0]![0].create;
|
||||
expect(row.sportType).toBe("other");
|
||||
expect(row.sportType).not.toMatch(/^whoop_sport_/);
|
||||
});
|
||||
|
||||
it("uses the same mapped sportType on the update branch (re-sync / re-score)", async () => {
|
||||
await upsertWhoopWorkout("user-1", scoredWorkout({ sport_id: 1 }));
|
||||
const update = workoutUpsert.mock.calls[0]![0].update;
|
||||
expect(update.sportType).toBe("cycling");
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertWhoopWorkout — raw sport fields kept in metadata for traceability", () => {
|
||||
it("stamps whoopSportId and whoopSportName onto metadata", async () => {
|
||||
await upsertWhoopWorkout(
|
||||
"user-1",
|
||||
scoredWorkout({ sport_id: 1, sport_name: "cycling" }),
|
||||
);
|
||||
const row = workoutUpsert.mock.calls[0]![0].create;
|
||||
expect(row.metadata.whoopSportId).toBe(1);
|
||||
expect(row.metadata.whoopSportName).toBe("cycling");
|
||||
});
|
||||
|
||||
it("omits whoopSportId / whoopSportName from metadata when WHOOP sent neither", async () => {
|
||||
await upsertWhoopWorkout(
|
||||
"user-1",
|
||||
scoredWorkout({ sport_id: undefined, sport_name: undefined }),
|
||||
);
|
||||
const row = workoutUpsert.mock.calls[0]![0].create;
|
||||
expect(row.metadata).not.toHaveProperty("whoopSportId");
|
||||
expect(row.metadata).not.toHaveProperty("whoopSportName");
|
||||
});
|
||||
});
|
||||
@@ -53,15 +53,31 @@ plain integer, stored as-is.
|
||||
|
||||
## Workout (`/v2/activity/workout`) — into `Workout`, not `Measurement`
|
||||
|
||||
| Source field | Destination | Unit | Note |
|
||||
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ----- | ----------------------------------------------------------------------------- |
|
||||
| `workout.score.strain` | `Workout.metadata` (`WORKOUT_STRAIN` type exists for the rare detached case) | score | Tied to the workout row so a phantom strain row never survives workout dedup. |
|
||||
| `workout.score.kilojoule` | `Workout.totalEnergyKcal` | kcal | kJ→kcal (`÷ 4.184`). |
|
||||
| `workout.score.{zone_durations,percent_recorded,distance_meter,altitude_*}` | `Workout.metadata` | — | HR-zone durations + recording quality + altitude. |
|
||||
| Source field | Destination | Unit | Note |
|
||||
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `workout.sport_id` / `workout.sport_name` | `Workout.sportType` | — | Canonical `WorkoutSportType` via `mapWhoopSportType()` (`sport-map.ts`) — see below. Raw values also kept in `Workout.metadata` (`whoopSportId` / `whoopSportName`). |
|
||||
| `workout.score.strain` | `Workout.metadata` (`WORKOUT_STRAIN` type exists for the rare detached case) | score | Tied to the workout row so a phantom strain row never survives workout dedup. |
|
||||
| `workout.score.kilojoule` | `Workout.totalEnergyKcal` | kcal | kJ→kcal (`÷ 4.184`). |
|
||||
| `workout.score.{zone_durations,percent_recorded,distance_meter,altitude_*}` | `Workout.metadata` | — | HR-zone durations + recording quality + altitude. |
|
||||
|
||||
Workout ingest + dedup lands in W3/W6 (the E-slice); this module only owns the
|
||||
fetch + the score→energy conversion factor (`KJ_TO_KCAL`).
|
||||
|
||||
### Sport mapping (`sport-map.ts`)
|
||||
|
||||
WHOOP's `sport_id` (int, documented as retired past 2025-09-01) and
|
||||
`sport_name` (string, its replacement) both resolve to a canonical
|
||||
`WorkoutSportType` via `WHOOP_SPORT_TABLE` in `sport-map.ts` — ~120 entries
|
||||
sourced from WHOOP's public Workout-object reference
|
||||
(https://developer.whoop.com/docs/developing/user-data/workout/). `sport_id`
|
||||
is preferred when present (exact key, no normalisation guess); `sport_name`
|
||||
is normalised (lowercased, non-alphanumeric runs collapsed to `_`) before the
|
||||
fallback lookup. Anything neither table recognises resolves to `"other"` —
|
||||
the mapper never emits a `whoop_sport_<n>` placeholder. The one-shot backfill
|
||||
at `prisma/migrations/0247_backfill_whoop_workout_sport` re-derives every
|
||||
pre-existing WHOOP `Workout` row from the same table so the historical data
|
||||
and the live mapper never drift apart.
|
||||
|
||||
## Body / profile (single objects, no pagination)
|
||||
|
||||
Ingested by `sync-body.ts` (`syncUserBody`), wired into the `syncUserWhoop`
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* WHOOP `sport_id` / `sport_name` → HealthLog canonical `WorkoutSportType`.
|
||||
*
|
||||
* WHOOP is the only workout integration that shipped WITHOUT this mapping —
|
||||
* Fitbit (`FITBIT_EXERCISE_TYPE_MAP` in `src/lib/fitbit/client.ts`) and
|
||||
* Google Health (`GOOGLE_HEALTH_EXERCISE_TYPE_MAP` in
|
||||
* `src/lib/google-health/mappers.ts`) both normalise at ingest time. Before
|
||||
* this module, `sync-workout.ts` wrote WHOOP's raw `sport_name` or a literal
|
||||
* `whoop_sport_<id>` placeholder straight into `Workout.sportType`, so WHOOP
|
||||
* rows never matched a canonical bucket and the `/insights/workouts` list
|
||||
* couldn't icon/label them (a user's WHOOP-tracked rides rendered generic,
|
||||
* not "cycling").
|
||||
*
|
||||
* `WHOOP_SPORT_TABLE` is the single source of truth — both
|
||||
* `WHOOP_SPORT_ID_MAP` and `WHOOP_SPORT_NAME_MAP` derive from it, and the
|
||||
* one-shot data migration `prisma/migrations/0247_backfill_whoop_workout_sport`
|
||||
* was generated FROM this same table (see the migration file header) so the
|
||||
* historical backfill and the live mapper never drift apart.
|
||||
*
|
||||
* Source: WHOOP's public "Workout" object reference
|
||||
* (https://developer.whoop.com/docs/developing/user-data/workout/),
|
||||
* captured 2026-07-17. That page documents:
|
||||
* - `sport_id` (int) — "ID of the WHOOP Sport performed during the
|
||||
* workout. Will not exist past 09/01/2025." A ~120-row id → display-name
|
||||
* table ships alongside it (Running, Cycling, Hiking/Rucking, ...).
|
||||
* - `sport_name` (string) — "Name of the WHOOP Sport performed during the
|
||||
* workout", the field that replaces `sport_id` going forward. The docs
|
||||
* show exactly one example value (`"running"`, lowercase) and don't pin
|
||||
* the exact separator convention WHOOP uses for multi-word sports (e.g.
|
||||
* "Hiking/Rucking"), so `sport_name` lookups run through
|
||||
* `normaliseSportKey()` — lowercase, non-alphanumeric runs collapsed to
|
||||
* a single `_` — before the table lookup, making the match resilient to
|
||||
* "Hiking/Rucking" / "hiking/rucking" / "hiking_rucking" all resolving
|
||||
* to the same row.
|
||||
*
|
||||
* `mapWhoopSportType()` prefers `sport_id` when present — an exact integer
|
||||
* key needs no normalisation guess — and falls back to the normalised
|
||||
* `sport_name` (the only field WHOOP still sends post-2025-09-01). Anything
|
||||
* neither table recognises (a WHOOP-added sport, a malformed value) resolves
|
||||
* to `"other"`; the function NEVER returns a `whoop_sport_<n>` placeholder.
|
||||
*
|
||||
* Bucket choices for sports with no clean 1:1 canonical match are a judgment
|
||||
* call, applied consistently:
|
||||
* - combat / interval-format classes (Boxing, Martial Arts, Jiu Jitsu,
|
||||
* Kickboxing, Jumping Rope) → `"hiit"` (typical round-based structure).
|
||||
* - functional / mixed-modal training (Functional Fitness, Gymnastics,
|
||||
* Rock Climbing, Obstacle Course Racing, Parkour, F45, Barry's) →
|
||||
* `"crossTraining"`.
|
||||
* - paddle sports (Kayaking, Paddleboarding) → `"rowing"` (closest
|
||||
* upper-body cardio bucket).
|
||||
* - racket / paddle-court sports (Squash, Pickleball, Table Tennis,
|
||||
* Badminton, Paddle Tennis, Padel) → `"tennis"`.
|
||||
* - multi-discipline endurance events (Duathlon, Triathlon) →
|
||||
* `"mixedCardio"`.
|
||||
* - team / niche sports with no matching bucket (Baseball, Cricket, Ice
|
||||
* Hockey, Sailing, Skiing, ...) → `"other"`, same as an unmapped Fitbit
|
||||
* or Google Health activity — the row still persists, just outside a
|
||||
* canonical sport bucket.
|
||||
*/
|
||||
import type { WorkoutSportType } from "@/lib/validations/workout";
|
||||
|
||||
interface WhoopSportTableRow {
|
||||
/** WHOOP's documented `sport_id`. Retired past 2025-09-01 but historical
|
||||
* rows (and the 0247 backfill) still need it. */
|
||||
id: number;
|
||||
/** WHOOP's documented display name — normalised before lookup. */
|
||||
name: string;
|
||||
canonical: WorkoutSportType;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHOOP `sport_id` → display name → canonical bucket. Ordered by `id`
|
||||
* ascending, matching the developer-docs table. Keep this list — and ONLY
|
||||
* this list — in sync when WHOOP adds a sport; both maps below and the 0247
|
||||
* migration's generator script derive from it.
|
||||
*/
|
||||
export const WHOOP_SPORT_TABLE: readonly WhoopSportTableRow[] = [
|
||||
{ id: -1, name: "Activity", canonical: "other" },
|
||||
{ id: 0, name: "Running", canonical: "running" },
|
||||
{ id: 1, name: "Cycling", canonical: "cycling" },
|
||||
{ id: 16, name: "Baseball", canonical: "other" },
|
||||
{ id: 17, name: "Basketball", canonical: "basketball" },
|
||||
{ id: 18, name: "Rowing", canonical: "rowing" },
|
||||
{ id: 19, name: "Fencing", canonical: "other" },
|
||||
{ id: 20, name: "Field Hockey", canonical: "other" },
|
||||
{ id: 21, name: "Football", canonical: "other" },
|
||||
{ id: 22, name: "Golf", canonical: "golf" },
|
||||
{ id: 24, name: "Ice Hockey", canonical: "other" },
|
||||
{ id: 25, name: "Lacrosse", canonical: "other" },
|
||||
{ id: 27, name: "Rugby", canonical: "other" },
|
||||
{ id: 28, name: "Sailing", canonical: "other" },
|
||||
{ id: 29, name: "Skiing", canonical: "other" },
|
||||
{ id: 30, name: "Soccer", canonical: "soccer" },
|
||||
{ id: 31, name: "Softball", canonical: "other" },
|
||||
{ id: 32, name: "Squash", canonical: "tennis" },
|
||||
{ id: 33, name: "Swimming", canonical: "swimming" },
|
||||
{ id: 34, name: "Tennis", canonical: "tennis" },
|
||||
{ id: 35, name: "Track & Field", canonical: "running" },
|
||||
{ id: 36, name: "Volleyball", canonical: "other" },
|
||||
{ id: 37, name: "Water Polo", canonical: "swimming" },
|
||||
{ id: 38, name: "Wrestling", canonical: "strength" },
|
||||
{ id: 39, name: "Boxing", canonical: "hiit" },
|
||||
{ id: 42, name: "Dance", canonical: "dance" },
|
||||
{ id: 43, name: "Pilates", canonical: "mindAndBody" },
|
||||
{ id: 44, name: "Yoga", canonical: "yoga" },
|
||||
{ id: 45, name: "Weightlifting", canonical: "strength" },
|
||||
{ id: 47, name: "Cross Country Skiing", canonical: "other" },
|
||||
{ id: 48, name: "Functional Fitness", canonical: "crossTraining" },
|
||||
{ id: 49, name: "Duathlon", canonical: "mixedCardio" },
|
||||
{ id: 51, name: "Gymnastics", canonical: "crossTraining" },
|
||||
{ id: 52, name: "Hiking/Rucking", canonical: "hiking" },
|
||||
{ id: 53, name: "Horseback Riding", canonical: "other" },
|
||||
{ id: 55, name: "Kayaking", canonical: "rowing" },
|
||||
{ id: 56, name: "Martial Arts", canonical: "hiit" },
|
||||
{ id: 57, name: "Mountain Biking", canonical: "cycling" },
|
||||
{ id: 59, name: "Powerlifting", canonical: "strength" },
|
||||
{ id: 60, name: "Rock Climbing", canonical: "crossTraining" },
|
||||
{ id: 61, name: "Paddleboarding", canonical: "rowing" },
|
||||
{ id: 62, name: "Triathlon", canonical: "mixedCardio" },
|
||||
{ id: 63, name: "Walking", canonical: "walking" },
|
||||
{ id: 64, name: "Surfing", canonical: "other" },
|
||||
{ id: 65, name: "Elliptical", canonical: "elliptical" },
|
||||
{ id: 66, name: "Stairmaster", canonical: "stairClimber" },
|
||||
{ id: 70, name: "Meditation", canonical: "mindAndBody" },
|
||||
{ id: 71, name: "Other", canonical: "other" },
|
||||
{ id: 73, name: "Diving", canonical: "other" },
|
||||
{ id: 74, name: "Operations - Tactical", canonical: "other" },
|
||||
{ id: 75, name: "Operations - Medical", canonical: "other" },
|
||||
{ id: 76, name: "Operations - Flying", canonical: "other" },
|
||||
{ id: 77, name: "Operations - Water", canonical: "other" },
|
||||
{ id: 82, name: "Ultimate", canonical: "other" },
|
||||
{ id: 83, name: "Climber", canonical: "stairClimber" },
|
||||
{ id: 84, name: "Jumping Rope", canonical: "hiit" },
|
||||
{ id: 85, name: "Australian Football", canonical: "other" },
|
||||
{ id: 86, name: "Skateboarding", canonical: "other" },
|
||||
{ id: 87, name: "Coaching", canonical: "other" },
|
||||
{ id: 88, name: "Ice Bath", canonical: "other" },
|
||||
{ id: 89, name: "Commuting", canonical: "other" },
|
||||
{ id: 90, name: "Gaming", canonical: "other" },
|
||||
{ id: 91, name: "Snowboarding", canonical: "other" },
|
||||
{ id: 92, name: "Motocross", canonical: "other" },
|
||||
{ id: 93, name: "Caddying", canonical: "other" },
|
||||
{ id: 94, name: "Obstacle Course Racing", canonical: "crossTraining" },
|
||||
{ id: 95, name: "Motor Racing", canonical: "other" },
|
||||
{ id: 96, name: "HIIT", canonical: "hiit" },
|
||||
{ id: 97, name: "Spin", canonical: "cycling" },
|
||||
{ id: 98, name: "Jiu Jitsu", canonical: "hiit" },
|
||||
{ id: 99, name: "Manual Labor", canonical: "other" },
|
||||
{ id: 100, name: "Cricket", canonical: "other" },
|
||||
{ id: 101, name: "Pickleball", canonical: "tennis" },
|
||||
{ id: 102, name: "Inline Skating", canonical: "other" },
|
||||
{ id: 103, name: "Box Fitness", canonical: "hiit" },
|
||||
{ id: 104, name: "Spikeball", canonical: "other" },
|
||||
{ id: 105, name: "Wheelchair Pushing", canonical: "other" },
|
||||
{ id: 106, name: "Paddle Tennis", canonical: "tennis" },
|
||||
{ id: 107, name: "Barre", canonical: "mindAndBody" },
|
||||
{ id: 108, name: "Stage Performance", canonical: "other" },
|
||||
{ id: 109, name: "High Stress Work", canonical: "other" },
|
||||
{ id: 110, name: "Parkour", canonical: "crossTraining" },
|
||||
{ id: 111, name: "Gaelic Football", canonical: "other" },
|
||||
{ id: 112, name: "Hurling/Camogie", canonical: "other" },
|
||||
{ id: 113, name: "Circus Arts", canonical: "other" },
|
||||
{ id: 121, name: "Massage Therapy", canonical: "other" },
|
||||
{ id: 123, name: "Strength Trainer", canonical: "strength" },
|
||||
{ id: 125, name: "Watching Sports", canonical: "other" },
|
||||
{ id: 126, name: "Assault Bike", canonical: "cycling" },
|
||||
{ id: 127, name: "Kickboxing", canonical: "hiit" },
|
||||
{ id: 128, name: "Stretching", canonical: "mindAndBody" },
|
||||
{ id: 230, name: "Table Tennis", canonical: "tennis" },
|
||||
{ id: 231, name: "Badminton", canonical: "tennis" },
|
||||
{ id: 232, name: "Netball", canonical: "basketball" },
|
||||
{ id: 233, name: "Sauna", canonical: "other" },
|
||||
{ id: 234, name: "Disc Golf", canonical: "golf" },
|
||||
{ id: 235, name: "Yard Work", canonical: "other" },
|
||||
{ id: 236, name: "Air Compression", canonical: "other" },
|
||||
{ id: 237, name: "Percussive Massage", canonical: "other" },
|
||||
{ id: 238, name: "Paintball", canonical: "other" },
|
||||
{ id: 239, name: "Ice Skating", canonical: "other" },
|
||||
{ id: 240, name: "Handball", canonical: "other" },
|
||||
{ id: 248, name: "F45 Training", canonical: "crossTraining" },
|
||||
{ id: 249, name: "Padel", canonical: "tennis" },
|
||||
{ id: 250, name: "Barry's", canonical: "crossTraining" },
|
||||
{ id: 251, name: "Dedicated Parenting", canonical: "other" },
|
||||
{ id: 252, name: "Stroller Walking", canonical: "walking" },
|
||||
{ id: 253, name: "Stroller Jogging", canonical: "running" },
|
||||
{ id: 254, name: "Toddlerwearing", canonical: "other" },
|
||||
{ id: 255, name: "Babywearing", canonical: "other" },
|
||||
{ id: 258, name: "Barre3", canonical: "mindAndBody" },
|
||||
{ id: 259, name: "Hot Yoga", canonical: "yoga" },
|
||||
{ id: 261, name: "Stadium Steps", canonical: "stairClimber" },
|
||||
{ id: 262, name: "Polo", canonical: "other" },
|
||||
{ id: 263, name: "Musical Performance", canonical: "other" },
|
||||
{ id: 264, name: "Kite Boarding", canonical: "other" },
|
||||
{ id: 266, name: "Dog Walking", canonical: "walking" },
|
||||
{ id: 267, name: "Water Skiing", canonical: "other" },
|
||||
{ id: 268, name: "Wakeboarding", canonical: "other" },
|
||||
{ id: 269, name: "Cooking", canonical: "other" },
|
||||
{ id: 270, name: "Cleaning", canonical: "other" },
|
||||
{ id: 272, name: "Public Speaking", canonical: "other" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Lowercase, collapse any run of characters that aren't `[a-z0-9_]` into a
|
||||
* single `_`, then trim leading/trailing `_`. Applied to BOTH the
|
||||
* documented display name (at table-build time) and the incoming
|
||||
* `sport_name` (at lookup time) so "Hiking/Rucking", "hiking/rucking", and
|
||||
* "hiking_rucking" all resolve to the same key. Existing underscores pass
|
||||
* through untouched — load-bearing for the `whoop_sport_<id>` legacy
|
||||
* placeholder values the 0247 backfill also has to recognise.
|
||||
*/
|
||||
export function normaliseSportKey(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
}
|
||||
|
||||
export const WHOOP_SPORT_ID_MAP: ReadonlyMap<number, WorkoutSportType> =
|
||||
new Map(WHOOP_SPORT_TABLE.map((row) => [row.id, row.canonical]));
|
||||
|
||||
export const WHOOP_SPORT_NAME_MAP: ReadonlyMap<string, WorkoutSportType> =
|
||||
new Map(
|
||||
WHOOP_SPORT_TABLE.map((row) => [
|
||||
normaliseSportKey(row.name),
|
||||
row.canonical,
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Resolve a WHOOP workout's sport to a canonical `WorkoutSportType`. Prefers
|
||||
* `sport_id` (exact integer key, no normalisation guesswork) when present,
|
||||
* then falls back to the normalised `sport_name` — the only field WHOOP
|
||||
* still sends since `sport_id` was retired 2025-09-01. Defaults to
|
||||
* `"other"`; never returns a `whoop_sport_<n>` placeholder.
|
||||
*/
|
||||
export function mapWhoopSportType(
|
||||
sportId?: number,
|
||||
sportName?: string,
|
||||
): WorkoutSportType {
|
||||
if (typeof sportId === "number") {
|
||||
const byId = WHOOP_SPORT_ID_MAP.get(sportId);
|
||||
if (byId) return byId;
|
||||
}
|
||||
if (typeof sportName === "string" && sportName.trim() !== "") {
|
||||
const byName = WHOOP_SPORT_NAME_MAP.get(normaliseSportKey(sportName));
|
||||
if (byName) return byName;
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
@@ -12,6 +12,12 @@
|
||||
* A WHOOP run and the same run via Apple Health remain two distinct `Workout`
|
||||
* rows (different `source`); the read-time `pickCanonicalWorkoutRows` picker
|
||||
* (E-slice, W6) collapses the cross-source twin at read time.
|
||||
*
|
||||
* `sportType` writes the canonical `WorkoutSportType` bucket via
|
||||
* `mapWhoopSportType()` (`./sport-map.ts`) — mirroring the Fitbit / Google
|
||||
* Health mappers — so a WHOOP row clusters and icons the same as any other
|
||||
* source's. Raw `sport_id` / `sport_name` still live in `Workout.metadata`
|
||||
* for traceability.
|
||||
*/
|
||||
import {
|
||||
fetchWorkouts,
|
||||
@@ -27,17 +33,11 @@ import {
|
||||
resolveResourceCursor,
|
||||
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
} from "./sync";
|
||||
import { mapWhoopSportType } from "./sport-map";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
/** WHOOP reports a numeric `sport_id`; fall back to a generic label. */
|
||||
function sportLabel(sportId: number | undefined, sportName?: string): string {
|
||||
if (sportName) return sportName;
|
||||
if (typeof sportId === "number") return `whoop_sport_${sportId}`;
|
||||
return "workout";
|
||||
}
|
||||
|
||||
export async function syncUserWorkout(
|
||||
userId: string,
|
||||
opts: { fullSync?: boolean } = {},
|
||||
@@ -140,10 +140,16 @@ export async function upsertWhoopWorkout(
|
||||
...(w.score.zone_durations
|
||||
? { zoneDurations: w.score.zone_durations }
|
||||
: {}),
|
||||
// Raw WHOOP sport fields, kept for traceability once mapWhoopSportType()
|
||||
// has resolved `Workout.sportType` to a canonical bucket — a support
|
||||
// request about a misclassified sport can trace back to exactly what
|
||||
// WHOOP sent without re-fetching the workout.
|
||||
...(w.sport_id != null ? { whoopSportId: w.sport_id } : {}),
|
||||
...(w.sport_name ? { whoopSportName: w.sport_name } : {}),
|
||||
};
|
||||
|
||||
const row = {
|
||||
sportType: sportLabel(w.sport_id, w.sport_name),
|
||||
sportType: mapWhoopSportType(w.sport_id, w.sport_name),
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationSec,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Cross-integration sport-mapping parity.
|
||||
*
|
||||
* Every workout integration normalises its raw sport label to HealthLog's
|
||||
* canonical `WorkoutSportType` at ingest time: Fitbit
|
||||
* (`mapFitbitSportType()`), Google Health (`mapGoogleHealthSportType()`),
|
||||
* and — as of the WHOOP-cycling fix — WHOOP (`mapWhoopSportType()`). This
|
||||
* table-driven suite checks all three mappers together so a future
|
||||
* integration or a re-tuned mapping can't silently drift out of parity:
|
||||
* every mapper must (a) only ever return a value from
|
||||
* `workoutSportTypeEnum`, (b) default unmapped/garbage input to `"other"`,
|
||||
* never throw or return the raw input, and (c) agree on the handful of
|
||||
* sports every wearable reports (walking, running, cycling, swimming,
|
||||
* strength, yoga, hiit).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { mapFitbitSportType } from "@/lib/fitbit/client";
|
||||
import { mapGoogleHealthSportType } from "@/lib/google-health/mappers";
|
||||
import { mapWhoopSportType } from "@/lib/whoop/sport-map";
|
||||
import { workoutSportTypeEnum } from "@/lib/validations/workout";
|
||||
|
||||
type Mapper = (raw: string) => string;
|
||||
|
||||
const MAPPERS: Record<string, Mapper> = {
|
||||
fitbit: (raw) => mapFitbitSportType(raw),
|
||||
googleHealth: (raw) => mapGoogleHealthSportType(raw),
|
||||
// WHOOP's mapper takes (sportId, sportName) — exercise it via sport_name
|
||||
// to line up with the other two integrations' string-in shape.
|
||||
whoop: (raw) => mapWhoopSportType(undefined, raw),
|
||||
};
|
||||
|
||||
describe("sport-map parity — every mapper only emits canonical values", () => {
|
||||
const valid = new Set(workoutSportTypeEnum.options);
|
||||
|
||||
it.each(Object.entries(MAPPERS))(
|
||||
"%s: garbage input resolves to 'other', never throws, never echoes the raw string",
|
||||
(_name, mapper) => {
|
||||
for (const garbage of ["", " ", "not-a-real-sport", "🚴", "NaN"]) {
|
||||
expect(() => mapper(garbage)).not.toThrow();
|
||||
const result = mapper(garbage);
|
||||
expect(
|
||||
valid.has(result as (typeof workoutSportTypeEnum.options)[number]),
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Common-sport agreement table. Each row is one real-world activity every
|
||||
* wearable reports; `labels` gives each mapper its OWN vocabulary for that
|
||||
* activity (integrations don't share a wire format), and `expected` is the
|
||||
* canonical bucket all three must land on.
|
||||
*/
|
||||
const COMMON_SPORTS: Array<{
|
||||
expected: string;
|
||||
labels: { fitbit: string; googleHealth: string; whoop: string };
|
||||
}> = [
|
||||
{
|
||||
expected: "walking",
|
||||
labels: { fitbit: "walking", googleHealth: "WALKING", whoop: "Walking" },
|
||||
},
|
||||
{
|
||||
expected: "running",
|
||||
labels: { fitbit: "running", googleHealth: "RUNNING", whoop: "Running" },
|
||||
},
|
||||
{
|
||||
expected: "cycling",
|
||||
labels: { fitbit: "biking", googleHealth: "BIKING", whoop: "Cycling" },
|
||||
},
|
||||
{
|
||||
expected: "swimming",
|
||||
labels: { fitbit: "swimming", googleHealth: "SWIMMING", whoop: "Swimming" },
|
||||
},
|
||||
{
|
||||
expected: "strength",
|
||||
labels: {
|
||||
fitbit: "weights",
|
||||
googleHealth: "STRENGTH_TRAINING",
|
||||
whoop: "Weightlifting",
|
||||
},
|
||||
},
|
||||
{
|
||||
expected: "yoga",
|
||||
labels: { fitbit: "yoga", googleHealth: "YOGA", whoop: "Yoga" },
|
||||
},
|
||||
{
|
||||
expected: "hiit",
|
||||
labels: { fitbit: "hiit", googleHealth: "HIIT", whoop: "HIIT" },
|
||||
},
|
||||
{
|
||||
expected: "golf",
|
||||
labels: { fitbit: "golf", googleHealth: "GOLF", whoop: "Golf" },
|
||||
},
|
||||
{
|
||||
expected: "tennis",
|
||||
labels: { fitbit: "tennis", googleHealth: "TENNIS", whoop: "Tennis" },
|
||||
},
|
||||
{
|
||||
expected: "rowing",
|
||||
labels: { fitbit: "rowing", googleHealth: "ROWING", whoop: "Rowing" },
|
||||
},
|
||||
];
|
||||
|
||||
describe("sport-map parity — Fitbit / Google Health / WHOOP agree on common sports", () => {
|
||||
it.each(COMMON_SPORTS)(
|
||||
"$expected: all three integrations map to the same canonical bucket",
|
||||
({ expected, labels }) => {
|
||||
expect(mapFitbitSportType(labels.fitbit)).toBe(expected);
|
||||
expect(mapGoogleHealthSportType(labels.googleHealth)).toBe(expected);
|
||||
expect(mapWhoopSportType(undefined, labels.whoop)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("sport-map parity — the confirmed bug, guarded across integrations", () => {
|
||||
it("a cycling workout NEVER lands on a non-canonical / provider-specific string", () => {
|
||||
for (const [name, mapper] of Object.entries(MAPPERS)) {
|
||||
const cyclingLabels: Record<string, string> = {
|
||||
fitbit: "biking",
|
||||
googleHealth: "BIKING",
|
||||
whoop: "Cycling",
|
||||
};
|
||||
const result = mapper(cyclingLabels[name]!);
|
||||
expect(result).toBe("cycling");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -195,7 +195,7 @@ describe("runDenseIntradayHourlyRebuild (real Postgres)", () => {
|
||||
|
||||
it("never touches days inside the retention window", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
// A daily row anchored 2 days ago — inside the default 14-day window.
|
||||
// A daily row anchored 2 days ago — inside the default 90-day window.
|
||||
// (Synthetic: the real fold never produces one this recent, but the
|
||||
// window bound must hold regardless of how the row got there.)
|
||||
const recentNoon = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
||||
|
||||
@@ -356,7 +356,7 @@ describe("runDenseIntradayRetention (real Postgres)", () => {
|
||||
|
||||
const summary = await runDenseIntradayRetention(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
// Default 14-day window — both rows are inside it.
|
||||
// Default 90-day window — both rows are inside it.
|
||||
log: () => {},
|
||||
});
|
||||
expect(summary.totals.daysConsolidated).toBe(0);
|
||||
|
||||
Reference in new issue
Block a user