diff --git a/Dockerfile b/Dockerfile index a06be1896..2630d356c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,6 +76,15 @@ ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV TZ=Europe/Berlin +# When the heap runs out, Node writes a diagnostic report (the JavaScript +# and native stacks, heap spaces, libuv handles) to stderr, where +# `docker compose logs` and Loki keep it; without it the log holds one +# line. Environment variables and network interfaces are left out, so no +# key, password or address from the environment reaches the log. An +# operator who sets NODE_OPTIONS replaces this value and should carry these +# flags over (docs/ops/heap-diagnostics.md). +ENV NODE_OPTIONS="--report-on-fatalerror --report-filename=stderr --report-exclude-env --report-exclude-network" + # v1.4.43 B11 — forward NEXT_PUBLIC_APP_VERSION from the builder stage # into the runner so /api/version reads the build-arg value instead of # the package.json fallback. The variable is keyed to the CI tag ref diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 55d85a0f8..7694a08e9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4203,10 +4203,13 @@ paths: With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over - those days; every returned day is complete. `type=SLEEP_DURATION` returns one row per night, paged the same way - over nights. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes - `limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the - value applied. + those days; every returned day is complete, which is why `valueMin` / `valueMax` (a filter on single readings) + are refused with 422 beside `groupBy=day`. `type=SLEEP_DURATION` returns one row per night, paged the same way + over nights; every night of the page has a row, and a day whose only sleep is daytime naps carries `value` 0 + with its naps in `napCount` / `napAsleepMinutes`. Its `dayKey` is the same day `/api/sleep/night?date=` answers + for. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes `limit` as + asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the value + applied. parameters: - in: query name: type @@ -4727,8 +4730,8 @@ paths: source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals, the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date` - omitted returns the most recent night. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, - no new measurement type." + omitted returns the most recent night holding sleep, however old. Any past date is answered, with no window cut. + A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type." parameters: - in: query name: date @@ -47896,7 +47899,9 @@ components: - naps additionalProperties: false description: "One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps - surfaced separately." + surfaced separately. A session is a nap when it holds under three hours asleep centred between 10:00 and 20:00 + local time; the main session is the longest session that is not a nap. A day whose only sleep is naps has `main: + null` and its naps." SleepRhythmResponse: type: object properties: @@ -62987,14 +62992,30 @@ components: - type: "null" description: Non-null when two writer buckets reported clearly different asleep totals for the latest night's main session. Observational only — the served summary stays the winning writer's totals. + recoveryEmpty: + anyOf: + - type: string + enum: + - general + - oura_permission + - oura_no_resilience + - type: "null" + description: "Why the recovery page has nothing to chart, resolved on the server; null when any recovery type + (ANS_CHARGE, DAY_STRAIN, WORKOUT_STRAIN, CARDIO_LOAD, AVERAGE_HEART_RATE, MAX_HEART_RATE, + ENERGY_EXPENDITURE_KJ, RESILIENCE) has a reading. `oura_permission`: Oura is connected and its grant leaves + out the scope the resilience level needs, one reconnect fixes it. `oura_no_resilience`: Oura is connected + and no level has arrived yet. `general`: anyone else. Clients render the empty note from this value and + never work it out from the connection status." required: - summaries - bmi - lastSeenByType - sleepSourceDiscrepancy + - recoveryEmpty additionalProperties: false - description: "What `?slice=summaries` answers: the per-type summaries and their freshness, and nothing thick. Four keys, - and the caller gets them from two SQL passes rather than the default slice's chain." + description: "What `?slice=summaries` answers: the per-type summaries and their freshness, the recovery page's empty + reason, and nothing thick. Five keys, and the caller gets them from two SQL passes rather than the default + slice's chain." InsightsGenerateReadEnvelope: type: object properties: @@ -88257,9 +88278,9 @@ components: type: number description: Effective weight after redistributing missing sub-scores. gap: - description: "Only on a null `consistency` / `timing`: why it is withheld. `few_nights` — fewer than three nights in the - window have a main sleep on the clock (count in `rhythmNights`); `no_main_sleep` — the latest day had - only a daytime nap. Absent otherwise." + description: "Only on a null `consistency` / `timing`: why it is withheld. `few_nights` — fewer than `rhythmMinNights` + nights in the window have a main sleep on the clock (count in `rhythmNights`); `no_main_sleep` — the + latest day had only a daytime nap. Absent otherwise." type: string enum: - few_nights @@ -88281,6 +88302,12 @@ components: maximum: 9007199254740991 description: Nights in the window with a main sleep placed on the clock — what consistency and timing are computed from. A daytime nap is not one; a short night is. + rhythmMinNights: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: 'How many `rhythmNights` consistency and timing need before they are scored; below it they carry `gap: + "few_nights"`.' series: type: array items: @@ -88295,6 +88322,7 @@ components: - subScores - windowNights - rhythmNights + - rhythmMinNights - series additionalProperties: false parameters: diff --git a/docs/integrations/google-health.md b/docs/integrations/google-health.md index 50c8890d5..0b187be94 100644 --- a/docs/integrations/google-health.md +++ b/docs/integrations/google-health.md @@ -265,7 +265,10 @@ reading: nothing left but a data type waiting for its second attempt and queued the next slice for when it is due (`delaySeconds`). - `meta["googleHealth.backfill.ended"]` appears on the slice where a data - type ended short, with the same entries. A walk stopped for lack of + type ended short: the `collection`, its measurement `type`, the `reason`, + whether the end is `final`, how many data types have ended short so far + (`endedCount`) and of those for good (`finalCount`), and their `types`. + The full entries are in the slice report's `ended`. A walk stopped for lack of progress also leaves a warning: `fetchHeartRate: 20 pages in a row reached no further back than `. - `meta["googleHealth.backfill.edgeFilterUnsupported"]` appears on the slice diff --git a/docs/ops/heap-diagnostics.md b/docs/ops/heap-diagnostics.md index f7a1f02c5..87b12052f 100644 --- a/docs/ops/heap-diagnostics.md +++ b/docs/ops/heap-diagnostics.md @@ -7,7 +7,7 @@ A Node process that reaches its heap limit stops with `JavaScript heap out of me Two background wide events, no configuration needed: - `runtime.heap.sampled` (info), every five minutes, and every minute while the heap is at or above 70 % of its limit. It goes to stdout and Loki but not to Admin > App logs, so it cannot push other events out of that page's 500-entry buffer. It carries `heap_used_mb`, `heap_total_mb`, `heap_limit_mb`, `heap_used_ratio`, `old_space_mb`, `rss_mb`, `external_mb`, `array_buffers_mb`, `active_requests`, `active_jobs`, `in_flight_dropped` and `uptime_s`. -- `runtime.heap.pressure` (warn), the first time the heap reaches 85 % of its limit. Besides the figures above it lists the 50 oldest requests and jobs in flight (`in_flight_oldest`: kind, method, route, age in seconds, whether it was a server action) and the routes most of them share (`in_flight_by_route`). Routes are reduced to their shape: no query string, secret path segments replaced by `[REDACTED]`, ids by `:id`, anything unusual by `:x`. It writes again only after the heap has dropped below 70 % and thirty minutes have passed. +- `runtime.heap.pressure` (warn), the first time the heap reaches 85 % of its limit. Besides the figures above it lists the 50 oldest requests and jobs in flight (`in_flight_oldest`: kind, method, route, age in seconds, whether it was a server action) and the routes most of them share (`in_flight_by_route`). Routes are reduced to their shape: no query string, secret path segments replaced by `[REDACTED]`, ids by `:id`, anything unusual by `:x`. It writes again only after the heap has dropped below 70 % and thirty minutes have passed. One exception: when the heap reaches 95 %, the same report is written again at once, with `pressure_ratio: 0.95`, whatever the cooldown; that tier re-arms as soon as the heap is back under 85 %. A heap that climbs to its limit shortly after an 85 % report therefore still leaves a report naming the work that pushed it over. The heap is read every five seconds, a single call that takes microseconds. Tracking requests costs one map insert and one delete per request. @@ -17,6 +17,16 @@ To find the last lines before a crash: docker compose logs app --since 2h | grep '"runtime.heap.' ``` +## The report Node writes when the heap runs out + +The image sets `NODE_OPTIONS="--report-on-fatalerror --report-filename=stderr --report-exclude-env --report-exclude-network"`. When the process runs out of heap, Node writes a diagnostic report, about 10 KB of JSON with `"trigger": "OOMError"`, to stderr just before it exits: the JavaScript stack at the failed allocation, the native stack, the heap spaces and the open handles. It lands in the container log beside the `runtime.heap.*` lines: + +```sh +docker compose logs app --since 2h | grep -A400 '"trigger": "OOMError"' +``` + +The two exclude flags keep environment variables (keys, passwords, the database URL) and network interfaces out of the report. If you set `NODE_OPTIONS` yourself, for example to raise `--max-old-space-size`, your value replaces the image's; add the four flags to it to keep the report. + ## Optional heap snapshot A snapshot shows what the heap is made of, which the events above cannot. Turn it on only while you are chasing a specific crash: diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index c39d30b5a..77759eeeb 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -79,9 +79,13 @@ Raise the limit on the container that runs the worker and re-upload: services: app: environment: - NODE_OPTIONS: --max-old-space-size=1024 + NODE_OPTIONS: --max-old-space-size=1024 --report-on-fatalerror --report-filename=stderr --report-exclude-env --report-exclude-network ``` +Your `NODE_OPTIONS` replaces the image's, so carry the four `--report-*` +flags over: they make an out-of-memory exit leave a diagnostic report in +the container log (see `docs/ops/heap-diagnostics.md`). + An import interrupted by a container restart or an out-of-memory kill does not stay stuck: the worker reconciles non-terminal import jobs at boot and every 15 minutes, flipping orphaned rows to `failed` diff --git a/messages/de.json b/messages/de.json index d54b26bea..b2667a7de 100644 --- a/messages/de.json +++ b/messages/de.json @@ -55,7 +55,9 @@ "errorIdLabel": "Fehler-ID", "errorIdCopy": "Fehler-ID kopieren", "errorIdCopied": "Fehler-ID kopiert", - "readThrottled": "Gerade zu viele Anfragen. Versuch es später noch einmal." + "readThrottled": "Gerade zu viele Anfragen. Versuch es später noch einmal.", + "sessionEnded": "Deine Sitzung ist abgelaufen. Melde dich erneut an, um weiterzumachen.", + "signInAgain": "Anmelden" }, "apiErrors": { "measurement": { @@ -4395,7 +4397,7 @@ "composition": "Zusammensetzung" }, "gap": { - "fewNights": "noch nicht genug Nächte ({count} von 3)", + "fewNights": "noch nicht genug Nächte ({count} von {needed})", "noMainSleep": "letzter Tag nur mit Nickerchen" } }, @@ -4664,9 +4666,9 @@ "description": "Wie gut sich dein Körper erholt hat: die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.", "scoreCrossLink": "Den zusammengesetzten Erholungs-Score ansehen", "emptyTitle": "Noch keine Erholungsdaten", - "empty": "Hier ist noch nichts zu sehen. Diese Seite zeigt die Werte zu Aufladung, Belastung und Trainingslast, die WHOOP und Polar aufzeichnen, und Ouras Resilienz-Stufe. Ouras Readiness erscheint als dein Erholungs-Score, oben verlinkt.", - "emptyOuraPermission": "Oura ist verbunden, aber die erteilte Berechtigung umfasst keine Herzgesundheitsdaten, und genau dahinter liegt Ouras Resilienz-Stufe. Verbinde Oura unter Einstellungen, Integrationen einmal neu und erlaube den Zugriff auf Herzgesundheit; die Stufe erscheint dann mit der nächsten Synchronisierung hier. Ouras Readiness ist bereits dein Erholungs-Score, oben verlinkt.", - "emptyOuraNoResilience": "Oura ist verbunden, aber für dieses Konto ist noch keine Resilienz-Stufe angekommen. HealthLog fragt bei jeder Synchronisierung danach, sie erscheint hier, sobald Oura eine liefert. Zeigt die Oura-App eine Stufe, nennt die Oura-Karte unter Einstellungen, Integrationen alles, was die Verbindung nicht liefern kann. Ouras Readiness ist bereits dein Erholungs-Score, oben verlinkt.", + "empty": "Diese Seite füllt sich mit den Erholungs-, Belastungs- und Trainingslastwerten von WHOOP oder Polar und mit Ouras Resilienz-Stufe.", + "emptyOuraPermission": "Ouras Resilienz-Stufe braucht Zugriff auf Herzgesundheitsdaten, also verbinde Oura einmal neu unter Integrationen und erlaube ihn.", + "emptyOuraNoResilience": "Oura ist verbunden, und seine Resilienz-Stufe erscheint hier, sobald Oura eine liefert.", "emptyAction": "Integrationen öffnen", "block": { "ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet, ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.", diff --git a/messages/en.json b/messages/en.json index 0809c12dc..3e30108aa 100644 --- a/messages/en.json +++ b/messages/en.json @@ -55,7 +55,9 @@ "errorIdLabel": "Error ID", "errorIdCopy": "Copy error ID", "errorIdCopied": "Error ID copied", - "readThrottled": "Too many requests just now. Try again later." + "readThrottled": "Too many requests just now. Try again later.", + "sessionEnded": "Your session has ended. Sign in again to continue.", + "signInAgain": "Sign in" }, "apiErrors": { "measurement": { @@ -4395,7 +4397,7 @@ "composition": "Composition" }, "gap": { - "fewNights": "not enough nights yet ({count} of 3)", + "fewNights": "not enough nights yet ({count} of {needed})", "noMainSleep": "latest day had only a nap" } }, @@ -4664,9 +4666,9 @@ "description": "How well your body has bounced back: the recovery, strain and load signals your wearable records overnight.", "scoreCrossLink": "See the composite recovery score", "emptyTitle": "No recovery data yet", - "empty": "Nothing to chart here yet. This page shows the recharge, strain and load readings that WHOOP and Polar record, and Oura's resilience level. Oura's readiness appears as your recovery score, linked above.", - "emptyOuraPermission": "Oura is connected, but the permission it was given leaves out heart-health data, and Oura's resilience level sits behind it. Reconnect Oura once under Settings, Integrations and allow heart-health access; the level then appears here with the next sync. Oura's readiness is already your recovery score, linked above.", - "emptyOuraNoResilience": "Oura is connected, but no resilience level has arrived for this account yet. HealthLog asks Oura for it on every sync, so it appears here as soon as Oura provides one. If the Oura app shows a level, the Oura card under Settings, Integrations names anything the connection cannot deliver. Oura's readiness is already your recovery score, linked above.", + "empty": "This page fills with the recharge, strain and load readings from WHOOP or Polar and with Oura's resilience level.", + "emptyOuraPermission": "Oura's resilience level needs heart-health access, so reconnect Oura once under Integrations and allow it.", + "emptyOuraNoResilience": "Oura is connected, and its resilience level appears here as soon as Oura provides one.", "emptyAction": "Open integrations", "block": { "ansChargeExplainer": "How charged your autonomic nervous system is for the day ahead, a recharge reading drawn from your overnight heart and breathing signals.", diff --git a/messages/es.json b/messages/es.json index 0c939ee0b..a7e92f85e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -55,7 +55,9 @@ "errorIdLabel": "ID de error", "errorIdCopy": "Copiar ID de error", "errorIdCopied": "ID de error copiado", - "readThrottled": "Demasiadas solicitudes ahora mismo. Vuelve a intentarlo más tarde." + "readThrottled": "Demasiadas solicitudes ahora mismo. Vuelve a intentarlo más tarde.", + "sessionEnded": "Tu sesión ha caducado. Inicia sesión de nuevo para continuar.", + "signInAgain": "Iniciar sesión" }, "apiErrors": { "measurement": { @@ -4395,7 +4397,7 @@ "composition": "Composición" }, "gap": { - "fewNights": "aún no hay suficientes noches ({count} de 3)", + "fewNights": "aún no hay suficientes noches ({count} de {needed})", "noMainSleep": "el último día solo tuvo una siesta" } }, @@ -4664,9 +4666,9 @@ "description": "Cómo se ha recuperado tu cuerpo: las señales de recuperación, esfuerzo y carga que tu wearable registra durante la noche.", "scoreCrossLink": "Ver la puntuación de recuperación combinada", "emptyTitle": "Aún no hay datos de recuperación", - "empty": "Aún no hay nada que mostrar aquí. Esta página muestra las lecturas de recarga, esfuerzo y carga que registran WHOOP y Polar, y el nivel de resiliencia de Oura. La preparación (readiness) de Oura aparece como tu puntuación de recuperación, enlazada arriba.", - "emptyOuraPermission": "Oura está conectado, pero el permiso concedido no incluye los datos de salud cardíaca, y el nivel de resiliencia de Oura depende de ellos. Vuelve a conectar Oura una vez en Ajustes, Integraciones y permite el acceso a la salud cardíaca; el nivel aparecerá aquí con la próxima sincronización. La preparación (readiness) de Oura ya es tu puntuación de recuperación, enlazada arriba.", - "emptyOuraNoResilience": "Oura está conectado, pero todavía no ha llegado ningún nivel de resiliencia para esta cuenta. HealthLog lo pide en cada sincronización, así que aparecerá aquí en cuanto Oura lo proporcione. Si la app de Oura muestra un nivel, la tarjeta de Oura en Ajustes, Integraciones indica todo lo que la conexión no puede entregar. La preparación (readiness) de Oura ya es tu puntuación de recuperación, enlazada arriba.", + "empty": "Esta página se llena con los valores de recuperación, esfuerzo y carga de WHOOP o Polar y con el nivel de resiliencia de Oura.", + "emptyOuraPermission": "El nivel de resiliencia de Oura necesita acceso a los datos de salud cardíaca, así que vuelve a conectar Oura una vez en Integraciones y permítelo.", + "emptyOuraNoResilience": "Oura está conectado y su nivel de resiliencia aparecerá aquí en cuanto Oura proporcione uno.", "emptyAction": "Abrir integraciones", "block": { "ansChargeExplainer": "Cuán cargado está tu sistema nervioso autónomo para el día: una lectura de recarga obtenida de tus señales nocturnas de corazón y respiración.", diff --git a/messages/fr.json b/messages/fr.json index 283030114..2626bfb40 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -55,7 +55,9 @@ "errorIdLabel": "ID d'erreur", "errorIdCopy": "Copier l'ID d'erreur", "errorIdCopied": "ID d'erreur copié", - "readThrottled": "Trop de requêtes pour le moment. Réessaie plus tard." + "readThrottled": "Trop de requêtes pour le moment. Réessaie plus tard.", + "sessionEnded": "Ta session a expiré. Reconnecte-toi pour continuer.", + "signInAgain": "Se connecter" }, "apiErrors": { "measurement": { @@ -4395,7 +4397,7 @@ "composition": "Composition" }, "gap": { - "fewNights": "pas encore assez de nuits ({count} sur 3)", + "fewNights": "pas encore assez de nuits ({count} sur {needed})", "noMainSleep": "le dernier jour n'a eu qu'une sieste" } }, @@ -4664,9 +4666,9 @@ "description": "À quel point votre corps a récupéré : les signaux de récupération, de charge et d'effort que votre wearable enregistre la nuit.", "scoreCrossLink": "Voir le score de récupération composite", "emptyTitle": "Pas encore de données de récupération", - "empty": "Rien à afficher ici pour l'instant. Cette page montre les mesures de recharge, d'effort et de charge qu'enregistrent WHOOP et Polar, ainsi que le niveau de résilience d'Oura. La disponibilité (readiness) d'Oura apparaît comme votre score de récupération, en lien ci-dessus.", - "emptyOuraPermission": "Oura est connecté, mais l'autorisation accordée n'inclut pas les données de santé cardiaque, dont dépend le niveau de résilience d'Oura. Reconnectez Oura une fois dans Réglages, Intégrations et autorisez l'accès à la santé cardiaque ; le niveau apparaîtra ici à la prochaine synchronisation. La disponibilité (readiness) d'Oura est déjà votre score de récupération, en lien ci-dessus.", - "emptyOuraNoResilience": "Oura est connecté, mais aucun niveau de résilience n'est encore arrivé pour ce compte. HealthLog le demande à chaque synchronisation, il apparaîtra ici dès qu'Oura en fournira un. Si l'app Oura affiche un niveau, la carte Oura dans Réglages, Intégrations indique tout ce que la connexion ne peut pas fournir. La disponibilité (readiness) d'Oura est déjà votre score de récupération, en lien ci-dessus.", + "empty": "Cette page se remplit avec les valeurs de récupération, d'effort et de charge de WHOOP ou Polar et avec le niveau de résilience d'Oura.", + "emptyOuraPermission": "Le niveau de résilience d'Oura demande l'accès aux données de santé cardiaque : reconnecte Oura une fois dans Intégrations et autorise-le.", + "emptyOuraNoResilience": "Oura est connecté, et son niveau de résilience apparaîtra ici dès qu'Oura en fournira un.", "emptyAction": "Ouvrir les intégrations", "block": { "ansChargeExplainer": "À quel point votre système nerveux autonome est rechargé pour la journée : une mesure de recharge issue de vos signaux nocturnes de cœur et de respiration.", diff --git a/messages/it.json b/messages/it.json index 739a0184e..9f6082888 100644 --- a/messages/it.json +++ b/messages/it.json @@ -55,7 +55,9 @@ "errorIdLabel": "ID errore", "errorIdCopy": "Copia ID errore", "errorIdCopied": "ID errore copiato", - "readThrottled": "Troppe richieste in questo momento. Riprova più tardi." + "readThrottled": "Troppe richieste in questo momento. Riprova più tardi.", + "sessionEnded": "La tua sessione è scaduta. Accedi di nuovo per continuare.", + "signInAgain": "Accedi" }, "apiErrors": { "measurement": { @@ -4395,7 +4397,7 @@ "composition": "Composizione" }, "gap": { - "fewNights": "notti ancora insufficienti ({count} su 3)", + "fewNights": "notti ancora insufficienti ({count} su {needed})", "noMainSleep": "l'ultimo giorno solo un pisolino" } }, @@ -4664,9 +4666,9 @@ "description": "Quanto bene il tuo corpo si è ripreso: i segnali di recupero, sforzo e carico che il tuo wearable registra durante la notte.", "scoreCrossLink": "Vedi il punteggio di recupero composito", "emptyTitle": "Ancora nessun dato di recupero", - "empty": "Qui non c'è ancora nulla da mostrare. Questa pagina mostra i valori di ricarica, sforzo e carico che registrano WHOOP e Polar, e il livello di resilienza di Oura. La prontezza (readiness) di Oura compare come punteggio di recupero, collegato qui sopra.", - "emptyOuraPermission": "Oura è collegato, ma l'autorizzazione concessa non include i dati sulla salute cardiaca, da cui dipende il livello di resilienza di Oura. Ricollega Oura una volta in Impostazioni, Integrazioni e consenti l'accesso alla salute cardiaca; il livello comparirà qui con la prossima sincronizzazione. La prontezza (readiness) di Oura è già il tuo punteggio di recupero, collegato qui sopra.", - "emptyOuraNoResilience": "Oura è collegato, ma per questo account non è ancora arrivato alcun livello di resilienza. HealthLog lo richiede a ogni sincronizzazione, quindi comparirà qui non appena Oura ne fornirà uno. Se l'app Oura mostra un livello, la scheda Oura in Impostazioni, Integrazioni indica tutto ciò che la connessione non può fornire. La prontezza (readiness) di Oura è già il tuo punteggio di recupero, collegato qui sopra.", + "empty": "Questa pagina si riempie con i valori di recupero, sforzo e carico di WHOOP o Polar e con il livello di resilienza di Oura.", + "emptyOuraPermission": "Il livello di resilienza di Oura richiede l'accesso ai dati sulla salute del cuore, quindi ricollega Oura una volta in Integrazioni e consentilo.", + "emptyOuraNoResilience": "Oura è collegato e il suo livello di resilienza apparirà qui non appena Oura ne fornirà uno.", "emptyAction": "Apri integrazioni", "block": { "ansChargeExplainer": "Quanto è carico il tuo sistema nervoso autonomo per la giornata: una lettura di ricarica ricavata dai segnali notturni di cuore e respiro.", diff --git a/messages/ko.json b/messages/ko.json index da63eea73..291d55291 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -55,7 +55,9 @@ "errorIdLabel": "오류 ID", "errorIdCopy": "오류 ID 복사", "errorIdCopied": "오류 ID 복사됨", - "readThrottled": "지금은 요청이 너무 많아요. 나중에 다시 시도해 주세요." + "readThrottled": "지금은 요청이 너무 많아요. 나중에 다시 시도해 주세요.", + "sessionEnded": "세션이 만료되었어요. 계속하려면 다시 로그인해 주세요.", + "signInAgain": "로그인" }, "apiErrors": { "measurement": { @@ -4395,7 +4397,7 @@ "composition": "구성" }, "gap": { - "fewNights": "아직 밤이 부족함 ({count}/3)", + "fewNights": "아직 밤이 부족함 ({count}/{needed})", "noMainSleep": "최근 하루는 낮잠만 있음" } }, @@ -4664,9 +4666,9 @@ "description": "몸이 얼마나 회복했는지: 웨어러블이 밤사이 기록한 회복·부하 신호예요.", "scoreCrossLink": "종합 회복 점수 보기", "emptyTitle": "아직 회복 데이터가 없어요", - "empty": "아직 여기에 표시할 내용이 없어요. 이 페이지는 WHOOP과 Polar가 기록하는 충전, 부하, 운동량 값과 Oura의 회복 탄력성 단계를 보여줘요. Oura의 준비도(Readiness)는 위에 연결된 회복 점수에 표시돼요.", - "emptyOuraPermission": "Oura는 연결되어 있지만 허용된 권한에 심장 건강 데이터가 빠져 있어요. Oura의 회복 탄력성 단계는 바로 이 데이터에 속해요. 설정, 통합에서 Oura를 한 번 다시 연결하고 심장 건강 접근을 허용하면 다음 동기화 때 여기에 단계가 표시돼요. Oura의 준비도(Readiness)는 이미 위에 연결된 회복 점수에 표시돼요.", - "emptyOuraNoResilience": "Oura가 연결되어 있지만 이 계정의 회복 탄력성 단계가 아직 도착하지 않았어요. HealthLog는 동기화할 때마다 요청하므로 Oura가 보내는 즉시 여기에 표시돼요. Oura 앱에 단계가 보인다면 설정, 통합의 Oura 카드에서 이 연결이 가져올 수 없는 항목을 확인할 수 있어요. Oura의 준비도(Readiness)는 이미 위에 연결된 회복 점수에 표시돼요.", + "empty": "이 페이지에는 WHOOP 또는 Polar의 회복, 부하, 훈련 부하 값과 Oura의 회복탄력성 수준이 표시돼요.", + "emptyOuraPermission": "Oura의 회복탄력성 수준에는 심장 건강 데이터 접근 권한이 필요하니, 연동에서 Oura를 한 번 다시 연결하고 허용해 주세요.", + "emptyOuraNoResilience": "Oura가 연결되어 있으며, Oura가 회복탄력성 수준을 제공하는 즉시 여기에 표시돼요.", "emptyAction": "통합 열기", "block": { "ansChargeExplainer": "오늘 하루를 맞을 자율신경계의 충전 상태예요. 밤사이 심장·호흡 신호에서 읽어낸 재충전 지표예요.", diff --git a/messages/pl.json b/messages/pl.json index 6a8ffff52..4735fd572 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -55,7 +55,9 @@ "errorIdLabel": "ID błędu", "errorIdCopy": "Kopiuj ID błędu", "errorIdCopied": "Skopiowano ID błędu", - "readThrottled": "Zbyt wiele zapytań w tej chwili. Spróbuj ponownie później." + "readThrottled": "Zbyt wiele zapytań w tej chwili. Spróbuj ponownie później.", + "sessionEnded": "Twoja sesja wygasła. Zaloguj się ponownie, aby kontynuować.", + "signInAgain": "Zaloguj się" }, "apiErrors": { "measurement": { @@ -4395,7 +4397,7 @@ "composition": "Struktura" }, "gap": { - "fewNights": "za mało nocy ({count} z 3)", + "fewNights": "za mało nocy ({count} z {needed})", "noMainSleep": "ostatni dzień tylko z drzemką" } }, @@ -4664,9 +4666,9 @@ "description": "Jak dobrze Twój organizm się zregenerował: sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.", "scoreCrossLink": "Zobacz łączny wynik regeneracji", "emptyTitle": "Brak danych o regeneracji", - "empty": "Na razie nie ma tu nic do pokazania. Ta strona pokazuje odczyty ładowania, wysiłku i obciążenia, które rejestrują WHOOP i Polar, oraz poziom odporności z Oura. Gotowość (readiness) z Oura pojawia się jako wynik regeneracji, link powyżej.", - "emptyOuraPermission": "Oura jest połączona, ale udzielone uprawnienie nie obejmuje danych o zdrowiu serca, od których zależy poziom odporności z Oura. Połącz Oura ponownie raz w Ustawieniach, Integracjach i zezwól na dostęp do zdrowia serca; poziom pojawi się tutaj przy następnej synchronizacji. Gotowość (readiness) z Oura jest już Twoim wynikiem regeneracji, link powyżej.", - "emptyOuraNoResilience": "Oura jest połączona, ale dla tego konta nie dotarł jeszcze żaden poziom odporności. HealthLog pyta o niego przy każdej synchronizacji, więc pojawi się tutaj, gdy tylko Oura go dostarczy. Jeśli aplikacja Oura pokazuje poziom, karta Oura w Ustawieniach, Integracjach wymienia wszystko, czego połączenie nie może dostarczyć. Gotowość (readiness) z Oura jest już Twoim wynikiem regeneracji, link powyżej.", + "empty": "Ta strona wypełnia się wartościami regeneracji, obciążenia i obciążenia treningowego z WHOOP lub Polar oraz poziomem odporności z Oura.", + "emptyOuraPermission": "Poziom odporności Oura wymaga dostępu do danych o zdrowiu serca, więc połącz Oura ponownie raz w Integracjach i zezwól na niego.", + "emptyOuraNoResilience": "Oura jest połączona, a jej poziom odporności pojawi się tutaj, gdy tylko Oura go dostarczy.", "emptyAction": "Otwórz integracje", "block": { "ansChargeExplainer": "Jak naładowany jest Twój autonomiczny układ nerwowy na dany dzień: odczyt regeneracji oparty na nocnych sygnałach serca i oddechu.", diff --git a/src/__tests__/api/analytics/since-cap.test.ts b/src/__tests__/api/analytics/since-cap.test.ts index 1b3cfbf16..86fdcb78f 100644 --- a/src/__tests__/api/analytics/since-cap.test.ts +++ b/src/__tests__/api/analytics/since-cap.test.ts @@ -35,6 +35,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { measurement: { findMany: vi.fn() }, + // The empty recovery page reads the Oura fields for its reason. + user: { findUnique: vi.fn().mockResolvedValue(null) }, }, })); diff --git a/src/__tests__/no-control-bytes-guard.test.ts b/src/__tests__/no-control-bytes-guard.test.ts new file mode 100644 index 000000000..aefe05080 --- /dev/null +++ b/src/__tests__/no-control-bytes-guard.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "@/__tests__/helpers/source-files"; + +/** + * No raw control byte in a source file, apart from tab, newline and carriage + * return. + * + * A control byte typed into a string literal is invisible in an editor, a + * diff and a review, renders as nothing or as a box, and survives copy and + * paste unseen; the same value written as an escape (`"\x01"`) is the same + * string and can be read. Three such bytes had crept in, one of them in a + * production join key. + */ +const CONTROL = /[\x00-\x08\x0b\x0c\x0e-\x1f]/; + +describe("source files carry no raw control bytes", () => { + it("every file under src/ is free of them", () => { + const src = join(process.cwd(), "src"); + const files = walkSourceFiles(src, { + floor: 3000, + extensions: [".ts", ".tsx", ".js", ".mjs", ".css", ".json", ".md"], + }).filter((file) => !file.startsWith("generated/")); + // A sweep that read nothing would agree with any tree. + expect(files.length).toBeGreaterThan(3000); + const offenders: string[] = []; + for (const file of files) { + const lines = readFileSync(join(src, file), "utf8").split("\n"); + lines.forEach((line, i) => { + if (CONTROL.test(line)) offenders.push(`${file}:${i + 1}`); + }); + } + expect(offenders).toEqual([]); + }); + + it("recognises a control byte when one is there", () => { + expect(CONTROL.test("a\u0001b")).toBe(true); + expect(CONTROL.test("tab\tnewline\ncr\r")).toBe(false); + }); +}); diff --git a/src/__tests__/query-error-error-prop-guard.test.ts b/src/__tests__/query-error-error-prop-guard.test.ts new file mode 100644 index 000000000..0a61c87f1 --- /dev/null +++ b/src/__tests__/query-error-error-prop-guard.test.ts @@ -0,0 +1,82 @@ +/** + * Every read-failure primitive is handed the error it reports. + * + * `QueryErrorRow` and `QueryErrorCard` decide what a failed read offers from + * the error itself: nothing (or the surface's refused state) for a 403, a + * note without a retry for a 429, sign-in for a 401, a retry otherwise. A + * call site that renders one without the error falls back to "failed" for + * every status, which is how a refused share showed an error card and a spent + * budget offered a retry that could only be refused again. + * + * The prop is required in the type, so the compiler already catches a missing + * one. What it cannot catch is a literal that defeats it (`error={null}`, + * `error={undefined}`), so this guard reads every opening tag under `src/` + * and refuses both. The matcher is whitespace-tolerant (tags span lines) and + * the match count is asserted, so a matcher that stops finding the tags fails + * rather than passing over nothing. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SRC = join(__dirname, ".."); + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "__tests__" || entry.name === "generated") continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) walk(path, out); + else if (entry.name.endsWith(".tsx") && !entry.name.includes(".test.")) + out.push(path); + } + return out; +} + +/** The opening tag's attribute text, from the name to its closing `>`. */ +function openingTags(source: string): string[] { + const tags: string[] = []; + const open = /<(QueryErrorRow|QueryErrorCard)(?=[\s/>])/g; + for (let m = open.exec(source); m; m = open.exec(source)) { + let depth = 0; + let i = m.index + m[0].length; + for (; i < source.length; i++) { + const ch = source[i]; + if (ch === "{") depth++; + else if (ch === "}") depth--; + else if (ch === ">" && depth === 0) break; + } + tags.push(source.slice(m.index, i + 1)); + } + return tags; +} + +/** Comments mention the primitives by name (``); skip them. */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +const sites = walk(SRC).flatMap((file) => + openingTags(stripComments(readFileSync(file, "utf8"))).map((tag) => ({ + file: relative(SRC, file), + tag, + })), +); + +describe("read-failure primitives carry their error", () => { + it("finds the call sites it guards", () => { + // About 120 at the time of writing. A drop to a handful means the + // matcher broke, not that the sites went away. + expect(sites.length).toBeGreaterThan(100); + }); + + it("passes the query's error at every site", () => { + const offenders = sites + .filter( + ({ tag }) => + !/\serror=\{/.test(tag) || + /\serror=\{\s*(null|undefined)\s*\}/.test(tag), + ) + .map(({ file, tag }) => `${file}: ${tag.split("\n")[0]}`); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/__tests__/timezone-explicit-formatter-guard.test.ts b/src/__tests__/timezone-explicit-formatter-guard.test.ts index ee2bff254..be1b66a56 100644 --- a/src/__tests__/timezone-explicit-formatter-guard.test.ts +++ b/src/__tests__/timezone-explicit-formatter-guard.test.ts @@ -216,6 +216,78 @@ describe("date formatters name the calendar they render in", () => { }); }); +/** + * A component renders dates through `useFormatters()`, never through the + * shared cache directly. + * + * The two-argument `getDateTimeFormat(locale, options)` from + * `@/lib/intl/formatter-cache` can name a zone and still be wrong: it takes + * the locale and the hour cycle from wherever the caller found them and + * knows nothing of the reader's date-order preference. The workout surfaces + * rebuilt their own formatters this way and showed "Dec 16" to a reader who + * had chosen YYYY-MM-DD. The three-argument `(locale, timeZone, options)` + * form from `@/lib/tz/intl-cache` builds day keys and clock parts, not + * display text, and stays allowed. + */ +describe("components format dates through useFormatters", () => { + const CALL = /\bgetDateTimeFormat\s*\(/g; + + function scan() { + const files = execFileSync( + "grep", + [ + "-rlE", + "getDateTimeFormat\\s*\\(", + "src/components", + "--include=*.ts", + "--include=*.tsx", + ], + { cwd: REPO_ROOT, encoding: "utf8" }, + ) + .trim() + .split("\n") + .filter(Boolean) + .filter((f) => !f.includes("__tests__")); + const offenders: string[] = []; + let calls = 0; + for (const file of files) { + const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); + CALL.lastIndex = 0; + for (let m = CALL.exec(source); m; m = CALL.exec(source)) { + const lineStart = source.lastIndexOf("\n", m.index) + 1; + if (/^\s*(?:\/\/|\*|\/\*)/.test(source.slice(lineStart, m.index))) + continue; + calls += 1; + const args = argumentList(source, m.index + m[0].length - 1); + if (topLevelArgCount(args) < 3) { + offenders.push( + `${file}:${source.slice(0, m.index).split("\n").length}`, + ); + } + } + } + return { offenders, calls }; + } + + it("still sees the cache's calls in components (the matcher matches)", () => { + expect(scan().calls).toBeGreaterThanOrEqual(1); + }); + + it("has no two-argument getDateTimeFormat in src/components", () => { + expect(scan().offenders).toEqual([]); + }); + + it("the matcher flags the two-argument form across lines", () => { + const source = + 'getDateTimeFormat(\n locale,\n { timeZone, month: "short" },\n)'; + CALL.lastIndex = 0; + const m = CALL.exec(source)!; + expect( + topLevelArgCount(argumentList(source, m.index + m[0].length - 1)), + ).toBe(2); + }); +}); + /** * A stated calendar date must not go through a zone-aware short formatter. * diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index 6c0fe99b0..512ac2331 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -293,7 +293,7 @@ export default function AchievementsPage() { // alongside `` and // `` so the three consumers never trigger // more than one network call on a cold dashboard mount. - const { data, isLoading, isError, refetch } = useAchievementsQuery({ + const { data, isLoading, isError, error, refetch } = useAchievementsQuery({ enabled: isAuthenticated && achievementsEnabled, }); @@ -326,7 +326,7 @@ export default function AchievementsPage() { title={t("achievements.title")} description={t("achievements.subtitle")} /> - refetch()} /> + refetch()} /> ); } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 5b6e2f7f1..cb2e9567c 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -24,8 +24,11 @@ export default function AdminOverviewPage() { // Many sub-sections share `useAdminSettings()` and render defaults // silently on error — the banner makes the failure visible so the // admin knows the toggles below aren't reflecting real state. - const { isError: settingsError, refetch: refetchSettings } = - useAdminSettings(); + const { + isError: settingsError, + error: settingsFailure, + refetch: refetchSettings, + } = useAdminSettings(); if (!user || user.role !== "ADMIN") return null; @@ -36,6 +39,7 @@ export default function AdminOverviewPage() { now (its own grid row, so the nav lines up with the first card). */} {settingsError && ( void refetchSettings()} /> diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index 9760ad9f1..d7d906d52 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -364,8 +364,12 @@ describe("GET /api/analytics", () => { string, { lastSeenAt: string; daysAgo: number } | null >; + recoveryEmpty: string | null; }; }; + // A weight-only account without Oura: the recovery page's empty reason + // is resolved here, not by the client. + expect(body.data.recoveryEmpty).toBe("general"); // The slim slice produced WEIGHT from the SQL pass; no chunked // findMany was called. expect(prisma.measurement.findMany).not.toHaveBeenCalled(); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 6c5f611de..f20dd1b79 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -7,6 +7,7 @@ import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { readSummariesSlice } from "@/lib/analytics/summaries-slice"; +import { loadRecoveryEmpty } from "@/lib/insights/recovery-empty-read"; import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range"; import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; @@ -129,6 +130,9 @@ export const GET = apiHandler(async (request?: Request) => { ...slim, summaries: slimGated.summaries, lastSeenByType: slimGated.lastSeenByType, + // Resolved here, from the gated summaries and the Oura connection, so + // a client renders the recovery page's empty note without deciding it. + recoveryEmpty: await loadRecoveryEmpty(user.id, slimGated.summaries), }); slimRes.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE); return slimRes; diff --git a/src/app/api/google-health/status/route.ts b/src/app/api/google-health/status/route.ts index 1fead2eda..76334bff5 100644 --- a/src/app/api/google-health/status/route.ts +++ b/src/app/api/google-health/status/route.ts @@ -3,7 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load"; -import { googleHealthBackfillEndedList } from "@/lib/google-health/sync-core"; +import { googleHealthBackfillEndedList } from "@/lib/google-health/backfill-progress"; /** * Get Google Health connection status for the current user (v1.26.0). diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index 4bff2b04b..053f1dfad 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -55,7 +55,7 @@ import { loadConnectionHealth } from "@/lib/integrations/connection-health-load" import { googleHealthBackfillEndedList, type GoogleHealthBackfillEndedEntry, -} from "@/lib/google-health/sync-core"; +} from "@/lib/google-health/backfill-progress"; import type { ConnectionHealth } from "@/lib/integrations/connection-health"; export const dynamic = "force-dynamic"; diff --git a/src/app/api/measurements/__tests__/group-by-day.test.ts b/src/app/api/measurements/__tests__/group-by-day.test.ts index a37bb293c..db8f542f9 100644 --- a/src/app/api/measurements/__tests__/group-by-day.test.ts +++ b/src/app/api/measurements/__tests__/group-by-day.test.ts @@ -2,22 +2,29 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; /** - * v1.4.37 W7c — GET /api/measurements gains two new branches for - * cumulative HK types (steps, active energy, distance, flights, - * daylight): + * GET /api/measurements has two branches for cumulative HK types (steps, + * active energy, distance, flights, daylight): * * 1. `?type=ACTIVITY_STEPS&groupBy=day` → one synthesised row per - * user-TZ day with `value` = SUM and `sampleCount` = number of - * per-sample rows behind the bucket. + * user-TZ day, built by `dayGroupedListResponse`. What the rows hold + * (whole days, the source ladder, paging, the day cut in the user's + * zone) is pinned against Postgres in + * `tests/integration/measurements-day-grouped-paging.test.ts`; this + * suite pins only which branch a request reaches. * 2. `?type=ACTIVITY_STEPS&dayKey=YYYY-MM-DD` → raw per-sample rows * for that single calendar day in the user's IANA timezone. * - * Spot metrics (BP, weight, pulse, etc.) keep the legacy - * `findMany`-only behaviour even when these params are present — - * the schema accepts the params but the route gates each branch on + * Spot metrics (BP, weight, pulse, etc.) keep the plain list even when + * these params are present: the route gates each branch on * `CUMULATIVE_HK_TYPES`. */ +vi.mock("@/lib/measurements/list/day-grouped", () => ({ + dayGroupedListResponse: vi.fn(async () => + Response.json({ data: { measurements: [], meta: {} }, error: null }), + ), +})); + vi.mock("@/lib/db", () => ({ prisma: { measurement: { @@ -32,7 +39,7 @@ vi.mock("@/lib/db", () => ({ // blob via `loadUserSourcePriority` (prisma.user.findUnique). `null` // resolves to the default ladders. user: { findUnique: vi.fn() }, - // v1.4.43 W6 — validation-failed paths write an audit breadcrumb. + // v1.4.43 — validation-failed paths write an audit breadcrumb. auditLog: { create: vi.fn().mockResolvedValue({}) }, $queryRaw: vi.fn(), }, @@ -55,6 +62,7 @@ vi.mock("next/headers", () => ({ import { GET } from "../route"; import { auditLog } from "@/lib/auth/audit"; +import { dayGroupedListResponse } from "@/lib/measurements/list/day-grouped"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; @@ -74,71 +82,10 @@ function getRequest(query: string): NextRequest { }); } -interface Group { - day_key: string; - source: string; - device_type: string | null; - unit: string; - total: number; - n: number; -} - -/** One SQL fold row: a day's readings of one source, device and unit. */ -function group( - day_key: string, - total: number, - n: number, - over: Partial = {}, -): Group { - return { - day_key, - source: "APPLE_HEALTH", - device_type: null, - unit: "steps", - total, - n, - ...over, - }; -} - -/** - * The day-grouped branch answers from SQL alone: one query picks the page's - * days, each row carrying the distinct-day total from a window count; a - * second folds those days into source groups. A page past the end has no row - * to carry the total, so only then is it counted on its own. Answer each - * query by its text. - */ -function mockDayPage( - days: string[], - total = days.length, - groups: Group[] = [], -) { - vi.mocked(prisma.$queryRaw).mockImplementation((( - strings: TemplateStringsArray, - ) => { - const text = strings.join(""); - if (text.includes("COUNT(DISTINCT")) return Promise.resolve([{ total }]); - if (text.includes("COUNT(*) OVER ()")) { - return Promise.resolve(days.map((day_key) => ({ day_key, total }))); - } - if (text.includes("SUM(")) return Promise.resolve(groups); - return Promise.resolve([]); - }) as never); -} - -/** The `$queryRaw` calls whose text contains `needle`. */ -function rawCallsWith(needle: string) { - return vi - .mocked(prisma.$queryRaw) - .mock.calls.filter(([strings]) => - (strings as unknown as string[]).join("").includes(needle), - ); -} - beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - // v1.4.43 W6 — validation-failed paths fire a best-effort audit-row + // v1.4.43 — validation-failed paths fire a best-effort audit-row // write; the route swallows rejections so the test only needs a // resolved mock to keep the catch-block silent. vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); @@ -150,176 +97,36 @@ beforeEach(() => { vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); }); -describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => { - it("collapses per-sample step rows into one row per user-TZ day with sampleCount", async () => { - // Three Apple-Watch chunks on 2026-05-15 (Europe/Berlin) summing to 5400 - // and two on 2026-05-16 summing to 7000, as SQL folds them. - mockDayPage(["2026-05-16", "2026-05-15"], 2, [ - group("2026-05-15", 5400, 3), - group("2026-05-16", 7000, 2), - ]); - +describe("GET /api/measurements — groupBy=day", () => { + it("hands a cumulative type to the day-grouped list with the request's filters", async () => { const res = await GET( getRequest( - "type=ACTIVITY_STEPS&groupBy=day&from=2026-05-15T00:00:00Z&to=2026-05-17T00:00:00Z", + "type=ACTIVITY_STEPS&groupBy=day&limit=25&offset=50&sourceEq=APPLE_HEALTH&from=2026-05-01T00:00:00Z", ), ); expect(res.status).toBe(200); - const json = (await res.json()) as { - data: { - measurements: Array<{ - type: string; - value: number; - unit: string; - dayKey: string; - sampleCount: number; - measuredAt: string; - }>; - meta: { groupBy?: string; total: number }; - }; - }; - expect(json.data.meta.groupBy).toBe("day"); - expect(json.data.measurements.length).toBe(2); - // Sort is desc by default — newer day first. - const [day16, day15] = json.data.measurements; - expect(day16.dayKey).toBe("2026-05-16"); - expect(day16.value).toBe(7000); - expect(day16.sampleCount).toBe(2); - expect(day16.unit).toBe("steps"); - expect(day15.dayKey).toBe("2026-05-15"); - expect(day15.value).toBe(5400); - expect(day15.sampleCount).toBe(3); - // The collapsed row's measuredAt anchors to local noon so the - // row sorts cleanly between same-day spot samples (canonical - // daily-timestamp contract shared with the drain helper). - expect(day15.measuredAt).toContain("2026-05-15T10:00:00"); - }); - - // #1245 — the fold covers exactly the page's days: from the start of the - // oldest page day to the end of the newest, in the user's zone, on top of - // the request's own filters. Nothing outside the page is scanned, and no - // raw row is read into the process at all. - it("folds only the page's days, in SQL", async () => { - mockDayPage(["2026-05-16", "2026-05-15"], 40); - - await GET(getRequest("type=ACTIVITY_STEPS&groupBy=day")); - - const [fold] = rawCallsWith("SUM("); - const instants = (fold ?? []) - .slice(1) - .filter((v): v is Date => v instanceof Date) - .map((d) => d.toISOString()); - // Europe/Berlin is UTC+2 in May. - expect(instants).toEqual([ - "2026-05-14T22:00:00.000Z", - "2026-05-16T22:00:00.000Z", - ]); + expect(dayGroupedListResponse).toHaveBeenCalledWith( + expect.objectContaining({ id: "user-1" }), + expect.objectContaining({ + type: "ACTIVITY_STEPS", + limit: 25, + offset: 50, + sortDir: "desc", + sourceEq: "APPLE_HEALTH", + from: new Date("2026-05-01T00:00:00Z"), + }), + ); expect(prisma.measurement.findMany).not.toHaveBeenCalled(); }); - it("folds nothing when the page holds no day", async () => { - mockDayPage([], 3); + // A day-grouped row is a whole day's sum; a value range filters single + // readings and would leave partial days behind, so the pair is refused. + it("refuses a value range beside groupBy=day", async () => { const res = await GET( - getRequest("type=ACTIVITY_STEPS&groupBy=day&limit=25&offset=25"), + getRequest("type=ACTIVITY_STEPS&groupBy=day&valueMin=100"), ); - expect(res.status).toBe(200); - const json = (await res.json()) as { - data: { measurements: unknown[]; meta: { total: number } }; - }; - expect(json.data.measurements).toEqual([]); - expect(json.data.meta.total).toBe(3); - expect(rawCallsWith("SUM(")).toHaveLength(0); - // Past the end, and only there, the total is counted on its own. - expect(rawCallsWith("COUNT(DISTINCT")).toHaveLength(1); - }); - - // v1.29.6 — a day with both an Apple Health AND a Withings write for the - // same cumulative metric must collapse to the ladder-canonical source - // before summing, exactly like the dashboard rollup tile - // (`collapseRollupRowsBySource`) does. Without the collapse, 9000 - // Apple-Health steps + 8800 Withings steps summed to a fabricated 17800 — - // a number the tile never showed. - it("collapses cross-source rows to the ladder-canonical source before summing", async () => { - mockDayPage(["2026-05-15"], 1, [ - group("2026-05-15", 8800, 1, { source: "WITHINGS" }), - group("2026-05-15", 9000, 1), - ]); - - const res = await GET( - getRequest( - "type=ACTIVITY_STEPS&groupBy=day&from=2026-05-15T00:00:00Z&to=2026-05-16T00:00:00Z", - ), - ); - expect(res.status).toBe(200); - const json = (await res.json()) as { - data: { - measurements: Array<{ - dayKey: string; - value: number; - source: string; - sampleCount: number; - }>; - meta: { droppedDuplicates?: number }; - }; - }; - expect(json.data.measurements.length).toBe(1); - const [day] = json.data.measurements; - // APPLE_HEALTH beats WITHINGS on the default `steps` ladder — the - // Withings row drops out of the sum entirely rather than adding on top. - expect(day.value).toBe(9000); - expect(day.source).toBe("APPLE_HEALTH"); - expect(day.sampleCount).toBe(1); - expect(json.data.meta.droppedDuplicates).toBe(1); - }); - - // #1245 — `limit` / `offset` page over days and reach the SQL that picks - // them; `meta.total` is the distinct-day count, not the page length, and - // no row is marked partial because every page day is read whole. - it("pages over days and reports the distinct-day total", async () => { - mockDayPage(["2026-04-20"], 40, [group("2026-04-20", 3400, 1)]); - - const res = await GET( - getRequest("type=ACTIVITY_STEPS&groupBy=day&limit=25&offset=25"), - ); - expect(res.status).toBe(200); - const json = (await res.json()) as { - data: { - measurements: Array<{ dayKey: string; partial?: boolean }>; - meta: { total: number; limit: number; offset: number }; - }; - }; - expect(json.data.meta).toMatchObject({ total: 40, limit: 25, offset: 25 }); - expect(json.data.measurements.map((m) => m.dayKey)).toEqual(["2026-04-20"]); - expect(json.data.measurements[0].partial).toBeUndefined(); - const [pageCall] = rawCallsWith("COUNT(*) OVER ()"); - expect(pageCall?.slice(1)).toEqual(expect.arrayContaining([25])); - expect(pageCall?.slice(1).filter((v) => v === 25)).toHaveLength(2); - // The total rode on the page's own rows; there was no second pass over - // the history, only the fold of the page's one day. - expect(prisma.$queryRaw).toHaveBeenCalledTimes(2); - expect(rawCallsWith("COUNT(DISTINCT")).toHaveLength(0); - }); - - // A device split inside the winning source keeps the top device's - // groups only, the same narrowing the picker applies to raw rows. - it("keeps the top-ranked device's groups within the winning source", async () => { - mockDayPage(["2026-05-15"], 1, [ - group("2026-05-15", 6000, 40, { device_type: "watch" }), - group("2026-05-15", 5500, 12, { device_type: "phone" }), - ]); - const res = await GET(getRequest("type=ACTIVITY_STEPS&groupBy=day")); - const json = (await res.json()) as { - data: { - measurements: Array<{ value: number; sampleCount: number }>; - meta: { droppedDuplicates: number }; - }; - }; - expect(json.data.measurements).toHaveLength(1); - expect(json.data.measurements[0]).toMatchObject({ - value: 6000, - sampleCount: 40, - }); - expect(json.data.meta.droppedDuplicates).toBe(12); + expect(res.status).toBe(422); + expect(dayGroupedListResponse).not.toHaveBeenCalled(); }); it("falls back to the legacy findMany path when groupBy=day is set on a non-cumulative type", async () => { @@ -351,7 +158,7 @@ describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => { }); }); -describe("GET /api/measurements — dayKey drill-down (W7c)", () => { +describe("GET /api/measurements — dayKey drill-down", () => { it("returns per-sample rows for the requested calendar day in the user's TZ", async () => { vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { @@ -402,7 +209,7 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => { ); }); - // v1.4.37 W10 H-1 — DST awareness. The drill-down window must honour + // v1.4.37 — DST awareness. The drill-down window must honour // the day's true local span (23 h on spring-forward, 25 h on // fall-back) so the row count matches what the collapsed daily row // displays. Previous shape (`canonicalDailyTimestamp ± 12h`) leaked @@ -451,24 +258,23 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => { const json = (await res.json()) as { data: { meta: { dayKey?: string } }; }; - // No dayKey echo on the legacy path — proves the W7c branch + // No dayKey echo on the legacy path — proves the drill-down branch // didn't fire for the spot metric. expect(json.data.meta.dayKey).toBeUndefined(); }); }); -describe("GET /api/measurements — schema rejections (W10 reconcile)", () => { +describe("GET /api/measurements — schema rejections", () => { // #1245 — the day-grouped mode pages over days, so an offset is a real // page request now rather than a refused combination. it("accepts offset>0 when groupBy=day is set", async () => { - mockDayPage([], 0); const res = await GET( getRequest("type=ACTIVITY_STEPS&groupBy=day&offset=25"), ); expect(res.status).toBe(200); }); - // v1.4.37 W10 H1 — same restriction for the drill-down branch; it + // v1.4.37 — same restriction for the drill-down branch; it // returns a single bounded page per dayKey, not a cursor. it("rejects offset>0 when dayKey is set", async () => { const res = await GET( @@ -478,7 +284,7 @@ describe("GET /api/measurements — schema rejections (W10 reconcile)", () => { expect(prisma.measurement.findMany).not.toHaveBeenCalled(); }); - // v1.4.37 W10 H2 — `2026-02-30` is a YYYY-MM-DD string but not a + // v1.4.37 — `2026-02-30` is a YYYY-MM-DD string but not a // real calendar date. `new Date()` silently overflows it to March // 2, so the drill-down would point at the wrong day. The validator // refine must reject it at parse time. diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 1efeef64c..7d8467d88 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -29,12 +29,6 @@ import { type AggregateGrain, } from "@/lib/measurements/range-aggregation"; import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; -import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; -import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; -import { - canonicalDailyTimestamp, - dayKeyForUserTz, -} from "@/lib/measurements/drain-per-sample-cumulative"; import { localDayWindow } from "@/lib/tz/local-day"; import { withIdempotency } from "@/lib/idempotency"; import { isP2002 } from "@/lib/prisma-errors"; @@ -50,11 +44,8 @@ import { readDailySeries, readLiveBuckets, } from "@/lib/measurements/daily-series-read"; -import { - reconstructSleepSessions, - pickMainNightAndNaps, - SESSION_GAP_MS, -} from "@/lib/analytics/sleep-night"; +import { dayGroupedListResponse } from "@/lib/measurements/list/day-grouped"; +import { sleepListResponse } from "@/lib/measurements/list/sleep-list"; import { resolveStoredTimezone, resolveUserTimezone } from "@/lib/tz/resolver"; import { isNamedSleepStageBody, @@ -72,12 +63,10 @@ import { oppositeMergeSource, } from "@/lib/measurements/cross-source-merge"; import { NextRequest } from "next/server"; -import { - Prisma, - type MeasurementType, - type MeasurementSource, - type GlucoseContext, - type SleepStage, +import type { + MeasurementType, + MeasurementSource, + GlucoseContext, } from "@/generated/prisma/client"; export const GET = apiHandler(async (request: NextRequest) => { @@ -86,7 +75,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const params = Object.fromEntries(request.nextUrl.searchParams); const parsed = listMeasurementsSchema.safeParse(params); if (!parsed.success) { - // v1.4.43 W6 — surface every Zod issue under `details.issues` so a + // v1.4.43 — surface every Zod issue under `details.issues` so a // multi-field iOS query (chart loader bug, drill-down regression) // doesn't iterate one error per round-trip. Best-effort audit- // ledger breadcrumb keyed `measurements.list.validation-failed`. @@ -133,7 +122,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const where = { userId: user.id, - // v1.4.40 W-DELETED — soft-deleted readings are invisible to every + // v1.4.40 — soft-deleted readings are invisible to every // list / count / aggregate read. The dedicated `sync/state` route // keeps surfacing them via its own filter so iOS can reconcile // tombstones; every other consumer (list view, analytics fan-out, @@ -185,7 +174,7 @@ export const GET = apiHandler(async (request: NextRequest) => { }); } - // v1.4.37 W7c — list-view drill-down to per-sample rows for a single + // v1.4.37 — list-view drill-down to per-sample rows for a single // calendar day in the user's IANA timezone. Resolves the day's UTC // bounds against `User.timezone`, then runs a plain `findMany` so the // expandable list row shows every per-sample chunk the daily collapse @@ -196,7 +185,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // drill-down opens exactly the day the row summed. const tz = await resolveStoredTimezone(user.timezone); // Resolve `[dayStart, dayEnd)` for the requested calendar day in - // the user's local zone. v1.4.37 W10 — `localDayWindow` honours + // the user's local zone. v1.4.37 — `localDayWindow` honours // DST: the previous shape (`canonicalDailyTimestamp ± 12 h`) // silently leaked or hid one hour of samples on spring-forward // (23-h day) and fall-back (25-h day) by always spanning exactly @@ -232,7 +221,7 @@ export const GET = apiHandler(async (request: NextRequest) => { }); } - // v1.4.37 W7c — collapsed "one row per day" mode for cumulative + // v1.4.37 — collapsed "one row per day" mode for cumulative // types. The list page renders the day's SUM as a single synthesised // row with a `sampleCount` chevron that drills back into the // per-sample rows via the `dayKey=…` branch above. `limit` / `offset` @@ -248,26 +237,24 @@ export const GET = apiHandler(async (request: NextRequest) => { offset, sortDir, sourceEq, - valueMin, - valueMax, from, to, }); } - // v1.4.28 FB-D2 — server-side aggregation. Gated on an explicit + // v1.4.28 — server-side aggregation. Gated on an explicit // `aggregate` param so the iOS contract (raw `MeasurementWireDTO` // shape on `GET /api/measurements`) is byte-stable for any caller // that omits the new query parameter. The chart-data client opts // in; iOS does not. // - // R4-CODE-C1 — `take` no longer applies BEFORE bucketising. The + // `take` no longer applies BEFORE bucketising. The // aggregation runs in Postgres via `date_trunc`, the bucket cap is // applied AFTER, so a 1-year `aggregate=daily` window walks every // row in the window and returns up to 365 buckets per type instead // of truncating to the first N raw rows. // - // SD-H1 — "All time" range semantics. When the chart's "All" tab + // "All time" range semantics. When the chart's "All" tab // is active, the client passes `from` = the user's earliest // measurement (or a sentinel like 1970-01-01) plus `to` = now plus // `aggregate=monthly` (or `weekly` when full history < 2 years). @@ -369,574 +356,6 @@ export const GET = apiHandler(async (request: NextRequest) => { }); }); -/** - * The day-grouped list for a cumulative type, paged over local days. - * - * Everything heavy runs in Postgres. The first query picks the page: the - * distinct local days that hold a reading, sorted and cut by `limit` / - * `offset`, each row carrying the distinct-day count of the whole filtered - * set as `meta.total` from a window count. The second folds only the page's - * days, one row per day, source, device and unit with its sum and sample - * count, and the source ladder picks among those groups exactly as it would - * among the raw rows (it keeps or drops whole source and device groups per - * day, never single rows). So a page costs its own days whatever the length - * of the history, every day is complete, and no raw row reaches the heap. - * - * Until v1.43.6 the branch read the newest `limit` raw rows (the web asked - * for 5000), bucketed them and returned every bucket. A long history came - * back as thousands of rows in one response, and every day older than the - * 5000th raw row was missing with nothing to say so. - * - * A day exists for the page when any reading of the type falls on it; the - * source collapse narrows a day's groups but never empties one, so the count - * and the folded rows agree. - */ -async function dayGroupedListResponse( - user: { id: string; timezone?: string | null }, - opts: { - type: MeasurementType; - limit: number; - offset: number; - sortDir: "asc" | "desc"; - sourceEq?: MeasurementSource; - valueMin?: number; - valueMax?: number; - from?: Date; - to?: Date; - }, -) { - const { type, limit, offset, sortDir } = opts; - // One resolver for the stored zone, shared with the drill-down and the - // sleep list: a usable IANA name or the instance default, never a value - // Postgres would refuse. The drill-down must cut the same days. - const tz = await resolveStoredTimezone(user.timezone); - - // The request's filters, as SQL: the same ones the plain list applies. - const filters = Prisma.sql` - m."user_id" = ${user.id} - AND m."type" = ${type}::measurement_type - AND m."deleted_at" IS NULL - ${opts.sourceEq ? Prisma.sql`AND m."source" = ${opts.sourceEq}::measurement_source` : Prisma.empty} - ${opts.valueMin != null ? Prisma.sql`AND m."value" >= ${opts.valueMin}` : Prisma.empty} - ${opts.valueMax != null ? Prisma.sql`AND m."value" <= ${opts.valueMax}` : Prisma.empty} - ${opts.from ? Prisma.sql`AND m."measured_at" >= ${opts.from}` : Prisma.empty} - ${opts.to ? Prisma.sql`AND m."measured_at" <= ${opts.to}` : Prisma.empty} - `; - const localDay = Prisma.sql`to_char((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${tz}, 'YYYY-MM-DD')`; - // Direction comes from the Zod enum, never from raw input. The window - // count runs before LIMIT, so every row of the page carries the - // distinct-day total of the whole filtered set. - const direction = Prisma.raw(sortDir === "asc" ? "ASC" : "DESC"); - const pageDays = await prisma.$queryRaw< - Array<{ day_key: string; total: number }> - >` - SELECT d.day_key, (COUNT(*) OVER ())::int AS total - FROM ( - SELECT ${localDay} AS day_key - FROM measurements m - WHERE ${filters} - GROUP BY 1 - ) d - ORDER BY d.day_key ${direction} - LIMIT ${limit} OFFSET ${offset} - `; - // A page past the end has no row to carry the total; only then is it - // counted on its own. - const total = - pageDays.length > 0 - ? Number(pageDays[0].total) - : offset > 0 - ? Number( - ( - await prisma.$queryRaw>` - SELECT COUNT(DISTINCT ${localDay})::int AS total - FROM measurements m - WHERE ${filters} - ` - )[0]?.total ?? 0, - ) - : 0; - const dayKeys = pageDays.map((d) => d.day_key); - - // The page's days are consecutive in the sorted day set, so the span from - // the first one's start to the last one's end holds exactly their rows. - let groups: Array<{ - day_key: string; - source: MeasurementSource; - device_type: string | null; - unit: string; - total: number; - n: number; - }> = []; - if (dayKeys.length > 0) { - const sorted = [...dayKeys].sort(); - const spanStart = localDayWindow(sorted[0], tz).dayStart; - const spanEnd = localDayWindow(sorted[sorted.length - 1], tz).dayEnd; - // Newest group first within a day, so the day's label comes from its - // latest reading, as it did when raw rows were read newest first. - groups = await prisma.$queryRaw` - SELECT - ${localDay} AS day_key, - m."source"::text AS source, - m."device_type" AS device_type, - m."unit" AS unit, - SUM(m."value")::float8 AS total, - COUNT(*)::int AS n - FROM measurements m - WHERE ${filters} - AND m."measured_at" >= ${spanStart} - AND m."measured_at" < ${spanEnd} - GROUP BY 1, 2, 3, 4 - ORDER BY 1, MAX(m."measured_at") DESC, 2, 3, 4 - `; - } - - // v1.29.6 — collapse overlapping sources to the ladder-canonical - // reading per day BEFORE summing. Without this, a day with both an - // Apple Health AND a Withings write for the same cumulative metric - // (e.g. steps) summed BOTH sources into one inflated total — a day - // the dashboard tile (which reads through the source-collapsed - // rollup, `collapseRollupRowsBySource` in - // `src/lib/rollups/measurement-read.ts`) showed correctly. Uses the - // same `metricKeyForType` ladder lookup the rollup collapse uses, so - // this list and the tile agree on which source wins. A type with no - // ladder (e.g. TIME_IN_DAYLIGHT, FALL_COUNT) has nothing to collapse - // against — every group passes through unchanged. Each group stands at - // its day's local noon, so the picker files it under the day SQL cut. - const groupRows = groups.map((g) => ({ - ...g, - type, - deviceType: g.device_type, - measuredAt: canonicalDailyTimestamp(g.day_key, tz), - })); - const metricKey = metricKeyForType(type); - const priorityJson = metricKey ? await loadUserSourcePriority(user.id) : null; - const canonicalGroups = metricKey - ? pickCanonicalSourceRows(groupRows, metricKey, priorityJson, (d) => - dayKeyForUserTz(d, tz), - ).canonicalRows - : groupRows; - - // Sum per local day; unit and source come from the day's first kept group - // so the rendered row carries a sensible label even when sources mix. - const buckets = new Map< - string, - { total: number; sampleCount: number; unit: string; source: string } - >(); - for (const g of canonicalGroups) { - const slot = buckets.get(g.day_key); - if (!slot) { - buckets.set(g.day_key, { - total: g.total, - sampleCount: g.n, - unit: g.unit, - source: g.source, - }); - } else { - slot.total += g.total; - slot.sampleCount += g.n; - } - } - const rowsScanned = groups.reduce((sum, g) => sum + g.n, 0); - const rowsKept = canonicalGroups.reduce((sum, g) => sum + g.n, 0); - - // Rows follow the page's day order from SQL. - const measurements = dayKeys.flatMap((dayKey) => { - const b = buckets.get(dayKey); - if (!b) return []; - return [ - { - // Synthetic id so the list-row key stays stable across pages - // and the row never collides with a real `Measurement.id`. - id: `day:${type}:${dayKey}`, - type, - value: b.total, - unit: b.unit, - source: b.source, - // Anchor the row's timestamp to local noon so the row sorts - // cleanly between same-day spot samples — matches the - // `canonicalDailyTimestamp` convention used by the drain. - measuredAt: canonicalDailyTimestamp(dayKey, tz).toISOString(), - notes: null, - dayKey, - sampleCount: b.sampleCount, - }, - ]; - }); - - annotate({ - action: { name: "measurement.list" }, - meta: { - total, - type, - groupBy: "day", - days: measurements.length, - rowsScanned, - droppedDuplicates: rowsScanned - rowsKept, - }, - }); - return apiSuccess({ - measurements, - meta: { - total, - limit, - offset, - groupBy: "day", - droppedDuplicates: rowsScanned - rowsKept, - }, - }); -} - -/** - * A sleep page holds at most this many nights. The nights' stage rows are - * read and reconstructed in the process, so the bound sits on the nights; - * the applied value comes back as `meta.limit`, and `meta.total` still counts - * every night, so a caller asking for more pages on rather than losing any. - * Until v1.43.6 the list read no more than a year of nights however large - * the limit, so this is a widening, never a new cut. - */ -const SLEEP_NIGHT_PAGE_MAX = 1000; - -/** - * v1.11.5 — per-night collapse for the SLEEP_DURATION list. Sleep is - * stored one row per STAGE per night (minutes); a plain list leaks every - * stage segment. This collapses the raw rows into the canonical per-night - * shape used by the chart series + dashboard tile. - * - * Two modes: - * - `dayKey` set → drill-down: return the night's stage SEGMENTS (the - * canonical source's per-stage spans) so the list row can reveal the - * hypnogram's underlying rows, mirroring the cumulative-type drill-down. - * - no `dayKey` → one synthetic row per night, value = TIME ASLEEP in - * minutes (the canonical unit), with the per-night stage breakdown, - * nap count, and awakenings in the row so the UI can caption the night - * without a second fetch. - * - * The nights are paged in Postgres (`sleepNightPage`), like the days of the - * cumulative list, and only the stage rows of the page's nights are read. - * Until v1.43.6 the read stopped at a year before `to` and the list ended - * there without saying so to the client. - */ -async function sleepListResponse( - user: { id: string; timezone?: string | null }, - opts: { - from?: Date; - to?: Date; - limit: number; - offset: number; - sortDir: "asc" | "desc"; - dayKey?: string; - }, -) { - const { from, to, offset, sortDir, dayKey } = opts; - const limit = Math.min(opts.limit, SLEEP_NIGHT_PAGE_MAX); - const [tz, priorityJson] = await Promise.all([ - resolveStoredTimezone(user.timezone), - loadUserSourcePriority(user.id), - ]); - - const page = await sleepNightPage(user.id, tz, { - from, - to, - sortDir, - ...(dayKey ? { night: dayKey, limit: 1, offset: 0 } : { limit, offset }), - }); - const nightKeys = page.nights.map((n) => n.night); - - // The page's sessions lie between the first one's start and the last one's - // end, and every other session is more than the session gap away from that - // span, so this read holds the page's sessions whole and no part of any - // other. - let rows: Array<{ - id: string; - value: number; - measuredAt: Date; - sleepStage: SleepStage | null; - source: MeasurementSource; - deviceType: string | null; - }> = []; - if (page.nights.length > 0) { - let spanStart = page.nights[0].spanStart; - let spanEnd = page.nights[0].spanEnd; - for (const n of page.nights) { - if (n.spanStart < spanStart) spanStart = n.spanStart; - if (n.spanEnd > spanEnd) spanEnd = n.spanEnd; - } - rows = await prisma.measurement.findMany({ - where: { - userId: user.id, - type: "SLEEP_DURATION", - deletedAt: null, - AND: [ - { measuredAt: { gte: spanStart, lte: spanEnd } }, - ...(from ? [{ measuredAt: { gte: from } }] : []), - ...(to ? [{ measuredAt: { lte: to } }] : []), - ], - }, - orderBy: [{ measuredAt: "asc" }, { id: "asc" }], - select: { - id: true, - value: true, - measuredAt: true, - sleepStage: true, - source: true, - // Writer-level collapse: two HealthKit apps behind one source (watch - // stages vs phone in-bed) must not blend into one night. - deviceType: true, - }, - }); - } - // Each reconstructed session is filed under the page night whose span - // holds it, and takes that night's label. Matching by the reconstruction's - // own label lost a night whenever the two named different days: the page - // counted it under one, the reconstruction filed it under the other, and - // the filter dropped it from both. - const sessions = reconstructSleepSessions(rows, tz, priorityJson).flatMap( - (session) => { - // A second of slack absorbs the sub-millisecond rounding between the - // two start computations; sessions sit hours apart, so it can never - // reach a neighbour. - const home = page.nights.find( - (n) => - n.spanStart.getTime() - 1000 <= session.start.getTime() && - session.end.getTime() <= n.spanEnd.getTime() + 1000, - ); - return home ? [{ ...session, night: home.night }] : []; - }, - ); - - // Drill-down — return the requested night's canonical stage segments. - if (dayKey) { - const { main, naps } = pickMainNightAndNaps( - sessions.filter((s) => s.night === dayKey), - ); - const dayUnit = getUnitForType("SLEEP_DURATION"); - // One row per stage segment of the main night + each nap, so the - // drill-down reveals exactly the rows that summed into the headline. - const segments = [main, ...naps] - .filter((s): s is NonNullable => s != null) - .flatMap((s) => s.segments); - const measurements = segments - .sort((a, b) => - sortDir === "asc" - ? a.start.getTime() - b.start.getTime() - : b.start.getTime() - a.start.getTime(), - ) - .map((seg, i) => ({ - id: `sleep-seg:${dayKey}:${i}`, - type: "SLEEP_DURATION", - value: Math.round(seg.minutes), - unit: dayUnit, - source: main?.source ?? naps[0]?.source ?? "MANUAL", - measuredAt: seg.end.toISOString(), - sleepStage: seg.stage, - notes: null, - })); - annotate({ - action: { name: "measurement.list" }, - meta: { - total: measurements.length, - type: "SLEEP_DURATION", - dayKey, - mode: "sleep-drill-down", - }, - }); - return apiSuccess({ - measurements, - meta: { total: measurements.length, limit, offset: 0, dayKey }, - }); - } - - // Per-night collapse — one synthetic row per wake day. The headline - // value is the MAIN night's TIME ASLEEP (the longest session that day), - // per the nap convention: naps are surfaced as a count, never folded - // into the main night's headline. - const byDay = new Map(); - for (const s of sessions) { - const list = byDay.get(s.night) ?? []; - list.push(s); - byDay.set(s.night, list); - } - - const dayUnit = getUnitForType("SLEEP_DURATION"); - // Rows follow the page's night order from SQL. - const measurements = nightKeys.flatMap((day) => { - const { main, naps } = pickMainNightAndNaps(byDay.get(day) ?? []); - if (!main) return []; - return [ - { - // Synthetic id mirrors the series route's `sleep:` shape. - id: `sleep:${day}`, - type: "SLEEP_DURATION", - value: Math.round(main.asleepMinutes), - unit: dayUnit, - source: main.source ?? "MANUAL", - measuredAt: main.end.toISOString(), - notes: null, - dayKey: day, - // Drives the drill-down chevron — number of stage segments behind - // the night, matching the cumulative-type `sampleCount` contract. - sampleCount: main.segments.length, - sleepStages: main.stages as Record, - inBedMinutes: main.inBedMinutes, - awakeMinutes: main.awakeMinutes, - napCount: naps.length, - napAsleepMinutes: naps.reduce((sum, n) => sum + n.asleepMinutes, 0), - awakenings: main.awakenings, - }, - ]; - }); - - annotate({ - action: { name: "measurement.list" }, - meta: { - total: page.total, - type: "SLEEP_DURATION", - mode: "sleep-per-night", - nights: measurements.length, - rowsScanned: rows.length, - }, - }); - return apiSuccess({ - measurements, - meta: { - total: page.total, - limit, - offset, - groupBy: "night", - }, - }); -} - -/** - * One page of sleep nights, chosen in Postgres. - * - * The stage rows are cut into sessions the way `reconstructSleepSessions` - * cuts them: ordered by start (`measured_at` minus the minutes), a new - * session wherever a row starts more than `SESSION_GAP_MS` after every - * earlier row has ended. A session counts when it holds an asleep row (a - * bare or granular asleep stage, or a stage-less one, with minutes), and its - * night is the local day its last row ends on. Nights are grouped, sorted - * and cut by `limit` / `offset`; `total` is the night count of the whole - * filtered set from the same query. `spanStart` / `spanEnd` bound the - * night's counting sessions, so the caller reads exactly their rows. - * - * This label is the list's own: the page is cut by it, a row carries it and - * a drill-down asks by it. It is taken over every writer, where the JS - * reconstruction takes the winning writer only, so the two can name - * different days (a losing phone's in-bed span running past midnight while - * the watch stops before it). The caller therefore never matches the two by - * label: it files each reconstructed session under the night whose span - * holds it, which is exact, because a reconstructed session is one of these - * sessions narrowed to its winner. - * - * With `night` set (a drill-down) the scan is cut to a window of 36 hours - * either side of that local day. Sessions are split at a three-hour gap, so - * a session ending on that day starts inside the window unless it lasted - * over a day and a half, and every session the cut truncates ends on another - * day. - */ -/** How far a drill-down's session scan reaches either side of its day. */ -const DRILL_DOWN_REACH_MS = 36 * 60 * 60 * 1000; - -async function sleepNightPage( - userId: string, - tz: string, - opts: { - from?: Date; - to?: Date; - sortDir: "asc" | "desc"; - limit: number; - offset: number; - night?: string; - }, -): Promise<{ - nights: Array<{ night: string; spanStart: Date; spanEnd: Date }>; - total: number; -}> { - const gapSeconds = SESSION_GAP_MS / 1000; - const window = opts.night ? localDayWindow(opts.night, tz) : null; - // Direction comes from the Zod enum, never from raw input. - const direction = Prisma.raw(opts.sortDir === "asc" ? "ASC" : "DESC"); - const nightsCte = Prisma.sql` - WITH r AS ( - SELECT - m."measured_at" AS e, - m."measured_at" - m."value" * interval '1 minute' AS s, - (m."value" > 0 AND (m."sleep_stage" IS NULL - OR m."sleep_stage" IN ('ASLEEP', 'REM', 'CORE', 'DEEP'))) AS asleep - FROM measurements m - WHERE m."user_id" = ${userId} - AND m."type" = 'SLEEP_DURATION'::measurement_type - AND m."deleted_at" IS NULL - ${opts.from ? Prisma.sql`AND m."measured_at" >= ${opts.from}` : Prisma.empty} - ${opts.to ? Prisma.sql`AND m."measured_at" <= ${opts.to}` : Prisma.empty} - ${ - window - ? Prisma.sql`AND m."measured_at" >= ${new Date(window.dayStart.getTime() - DRILL_DOWN_REACH_MS)} - AND m."measured_at" < ${new Date(window.dayEnd.getTime() + DRILL_DOWN_REACH_MS)}` - : Prisma.empty - } - ), o AS ( - SELECT r.*, MAX(e) OVER ( - ORDER BY s, e ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING - ) AS prev_end - FROM r - ), g AS ( - SELECT o.*, SUM( - CASE WHEN prev_end IS NULL - OR s - prev_end > make_interval(secs => ${gapSeconds}::double precision) THEN 1 ELSE 0 END - ) OVER (ORDER BY s, e ROWS UNBOUNDED PRECEDING) AS sid - FROM o - ), sess AS ( - SELECT MIN(s) AS s, MAX(e) AS e - FROM g - GROUP BY sid - HAVING BOOL_OR(asleep) - ), nights AS ( - SELECT - to_char((e AT TIME ZONE 'UTC') AT TIME ZONE ${tz}, 'YYYY-MM-DD') AS night, - MIN(s) AS span_start, - MAX(e) AS span_end - FROM sess - GROUP BY 1 - ) - `; - const rows = await prisma.$queryRaw< - Array<{ night: string; span_start: Date; span_end: Date; total: number }> - >` - ${nightsCte} - SELECT night, span_start, span_end, (COUNT(*) OVER ())::int AS total - FROM nights - ${opts.night ? Prisma.sql`WHERE night = ${opts.night}` : Prisma.empty} - ORDER BY night ${direction} - LIMIT ${opts.limit} OFFSET ${opts.offset} - `; - // A page past the end has no row to carry the total; only then is it - // counted on its own. - const total = - rows.length > 0 - ? Number(rows[0].total) - : opts.offset > 0 - ? Number( - ( - await prisma.$queryRaw>` - ${nightsCte} - SELECT COUNT(*)::int AS total FROM nights - ` - )[0]?.total ?? 0, - ) - : 0; - return { - nights: rows.map((r) => ({ - night: r.night, - spanStart: r.span_start, - spanEnd: r.span_end, - })), - total, - }; -} - /** * Does this body attribute a source to any reading — any source at all? * diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index 1d8be88a7..7b59aa155 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -341,8 +341,17 @@ describe("GET /api/measurements/series", () => { it("collapses sleep stage rows into one night point in hours (v1.11.4)", async () => { // SLEEP_DURATION is stored one row per STAGE per night (minutes). The // series must return ONE point per night carrying the TIME-ASLEEP - // total in hours, not a point per stage. + // total in hours, not a point per stage. Postgres names the night and + // its span; the stage rows of that span are reconstructed. vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { + night: "2026-06-04", + span_start: new Date("2026-06-03T15:00:00.000Z"), + span_end: new Date("2026-06-04T05:00:00.000Z"), + total: 1, + }, + ] as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { id: "s1", diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 45bb64bb1..c0e964111 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -29,7 +29,7 @@ import { annotate } from "@/lib/logging/context"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { redactForExcerpt } from "@/lib/observability/redact-payload"; import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; -import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; +import { readSleepNights } from "@/lib/measurements/list/sleep-nights"; import { VALUE_RANGES } from "@/lib/validations/measurement"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic"; @@ -56,13 +56,6 @@ import { getServerTranslator } from "@/lib/i18n/server-translator"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { READING_SPREAD, standardDeviation } from "@/lib/stats/spread"; -/** - * v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its - * window is capped to one year even when the client requests the 3650-day - * "Alle" range. Matches the slim slice's `withSleepNightTotals` sleep read. - */ -const SLEEP_SERIES_MAX_DAYS = 365; - /** * v1.28.25 — sample-dense kinds read raw rows only up to this window. * A CGM account stores ~288 BLOOD_GLUCOSE rows/day (105k+ rows/year) and @@ -220,7 +213,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const rawQuery = Object.fromEntries(request.nextUrl.searchParams); const parsed = querySchema.safeParse(rawQuery); if (!parsed.success) { - // v1.4.43 W6 — iOS chart loader hot path; multi-issue 422 + + // v1.4.43 — iOS chart loader hot path; multi-issue 422 + // audit breadcrumb keyed `measurements.series.validation-failed`. const issues = sanitiseZodIssues(parsed.error.issues); // v1.4.48 H-iOS-2 — surface the iOS-sent query shape alongside @@ -294,42 +287,21 @@ export const GET = apiHandler(async (request: NextRequest) => { // dual-source night to one canonical source via the user's `sleep` // priority ladder. // - // v1.11.4 — sleep is the one kind read row-per-stage (≈5 rows/night × - // source-count) rather than from the day-bucketed rollup, so an - // unbounded "Alle" (days = 3650) request on a multi-year dual-source - // account would walk a six-figure row set into JS. Cap the sleep read - // at SLEEP_SERIES_MAX_DAYS (365 d) — the same one-year bound the slim - // slice's `withSleepNightTotals` uses — so the window stays small - // regardless of the requested range. Other kinds read from the rollup - // tier and keep the full 3650-day range. - const sleepSince = new Date( - Date.now() - Math.min(days, SLEEP_SERIES_MAX_DAYS) * 86_400_000, - ); + // Sleep is the one kind read row-per-stage rather than from the rollup + // tier, so the nights are named in Postgres and reconstructed a chunk at + // a time (`readSleepNights`): "All" reaches as far back as the history + // does, and the stage rows in the heap stay a chunk's worth. const [tz, priorityJson] = await Promise.all([ resolveUserTimezone(user.id), loadUserSourcePriority(user.id), ]); - const rows = await prisma.measurement.findMany({ - where: { - userId: user.id, - type: "SLEEP_DURATION", - measuredAt: { gte: sleepSince }, - deletedAt: null, - }, - orderBy: { measuredAt: "asc" }, - select: { - id: true, - value: true, - measuredAt: true, - sleepStage: true, - source: true, - // Writer-level collapse: two HealthKit apps behind one source - // (watch stages vs phone in-bed) must not blend into one night. - deviceType: true, - }, + const nights = await readSleepNights(user.id, tz, priorityJson, { + from: since, + // A window of `days` days holds at most one night more than that. + maxNights: days + 1, }); unit = "h"; - points = reconstructSleepNights(rows, tz, priorityJson) + points = nights .filter((n) => n.asleepMinutes > 0) .map((n) => { const stageHours = Object.fromEntries( diff --git a/src/app/api/sleep/night/__tests__/route.test.ts b/src/app/api/sleep/night/__tests__/route.test.ts index 1e28ffd85..c5e1f8f34 100644 --- a/src/app/api/sleep/night/__tests__/route.test.ts +++ b/src/app/api/sleep/night/__tests__/route.test.ts @@ -3,7 +3,7 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { - measurement: { findMany: vi.fn() }, + measurement: { findMany: vi.fn(), findFirst: vi.fn() }, auditLog: { create: vi.fn() }, }, })); @@ -76,6 +76,7 @@ function stage( beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); }); @@ -138,12 +139,48 @@ describe("GET /api/sleep/night", () => { stage("2026-06-02T03:00:00.000Z", "CORE", 200), stage("2026-06-04T03:00:00.000Z", "CORE", 300), ] as never); + vi.mocked(prisma.measurement.findFirst).mockResolvedValue({ + measuredAt: new Date("2026-06-04T03:00:00.000Z"), + } as never); const res = await GET(req("")); const body = await res.json(); expect(body.data.night).toBe("2026-06-04"); expect(body.data.main.asleepMinutes).toBe(300); }); + it("reads a night of any age from a day and a half either side of it", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + stage("2019-03-01T06:00:00.000Z", "CORE", 420), + ] as never); + const res = await GET(req("date=2019-03-01")); + const body = await res.json(); + // Years past any trailing window, the night is still served. + expect(body.data.main.asleepMinutes).toBe(420); + const where = vi.mocked(prisma.measurement.findMany).mock.calls[0][0]! + .where as { AND: Array<{ measuredAt: { gte: Date; lte: Date } }> }; + expect(where.AND[0].measuredAt.gte.toISOString()).toBe( + "2019-02-27T12:00:00.000Z", + ); + expect(where.AND[0].measuredAt.lte.toISOString()).toBe( + "2019-03-03T12:00:00.000Z", + ); + }); + + it("answers a day of naps alone with its naps and no main night", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + // 13:00 to 14:00 UTC, nothing overnight. + stage("2026-06-04T14:00:00.000Z", "CORE", 60), + ] as never); + const res = await GET(req("date=2026-06-04")); + const body = await res.json(); + expect(body.data.night).toBe("2026-06-04"); + expect(body.data.main).toBeNull(); + expect(body.data.naps).toHaveLength(1); + expect(body.data.naps[0].asleepMinutes).toBe(60); + }); + it("surfaces a daytime nap separately from the main night", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ @@ -169,7 +206,7 @@ describe("GET /api/sleep/night", () => { expect(body.data.naps).toEqual([]); }); - // A5 — a reconstruction edge (a session of only bare ASLEEP / stage-less rows + // A reconstruction edge (a session of only bare ASLEEP / stage-less rows // alongside a granular partition for the same span) must return a valid empty // night, NEVER a 500 from the unguarded `segments[0]` access. it("returns 200 + empty night on an IN_BED/AWAKE-only reconstruction, never 500", async () => { diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index 9a856f88f..8c3f4a01d 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -17,13 +17,16 @@ * (WHOOP) — the client renders the hypnogram but labels it an approximate * layout. A real-series source (Apple Health / Withings / Fitbit) is false. * - * `date` omitted → the most recent night in the trailing-year window. + * `date` omitted → the most recent night that holds sleep, however old. + * + * Only the stage rows within a day and a half of the night are read, as the + * list's drill-down reads them (`readNightSessions`), so any past night is + * answered and a long history never reaches the heap. * * Auth: cookie session OR Bearer token (`requireAuth`). Soft-delete-filtered. */ import { NextRequest } from "next/server"; import { z } from "zod/v4"; -import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess, @@ -32,18 +35,18 @@ import { } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { - reconstructSleepSessions, pickMainNightAndNaps, type SleepSession, } from "@/lib/analytics/sleep-night"; +import { + latestSleepNight, + readNightSessions, +} from "@/lib/measurements/list/sleep-nights"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { isCalendarDateKey } from "@/lib/tz/date-only"; -/** Sleep is read row-per-stage, so the window is bounded to one year. */ -const SLEEP_NIGHT_MAX_DAYS = 365; - const querySchema = z.object({ // Optional wake-day key; when omitted the latest night is returned. date: z @@ -127,36 +130,14 @@ export const GET = apiHandler(async (request: NextRequest) => { loadUserSourcePriority(user.id), ]); - const since = new Date(Date.now() - SLEEP_NIGHT_MAX_DAYS * 86_400_000); - const rows = await prisma.measurement.findMany({ - where: { - userId: user.id, - type: "SLEEP_DURATION", - deletedAt: null, - measuredAt: { gte: since }, - }, - orderBy: { measuredAt: "asc" }, - select: { - value: true, - measuredAt: true, - sleepStage: true, - source: true, - // Writer-level collapse: two HealthKit apps behind one source (watch - // stages vs phone in-bed) must not blend into one night. - deviceType: true, - }, - }); - - const sessions = reconstructSleepSessions(rows, tz, priorityJson); - // Resolve the target wake day: the requested `date`, else the most - // recent wake day that has a scorable session. - const scorable = sessions.filter((s) => s.asleepMinutes > 0); const targetDay = - date ?? - (scorable.length > 0 ? scorable[scorable.length - 1].night : undefined); - - const daySessions = sessions.filter((s) => s.night === targetDay); - const { main, naps } = pickMainNightAndNaps(daySessions); + date ?? (await latestSleepNight(user.id, tz, priorityJson)) ?? undefined; + const { main, naps } = targetDay + ? pickMainNightAndNaps( + (await readNightSessions(user.id, targetDay, tz, priorityJson)) + .sessions, + ) + : { main: null, naps: [] }; annotate({ action: { name: "sleep.night" }, @@ -167,13 +148,10 @@ export const GET = apiHandler(async (request: NextRequest) => { }, }); - if (!main) { - return apiSuccess({ night: targetDay ?? null, main: null, naps: [] }); - } - + // A day of naps alone answers with its naps and no main night. return apiSuccess({ - night: main.night, - main: serializeSession(main), + night: targetDay ?? null, + main: main ? serializeSession(main) : null, naps: naps.map(serializeSession), }); }); diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index fdadea8ad..2b6aab91e 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -69,6 +69,7 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) { conversations, isLoading, isError, + error, refetch, hasNextPage, isFetchingNextPage, @@ -182,7 +183,7 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) { // A load failure must not masquerade as "no conversations yet" — // that reads as a data-loss scare and offers no way back. Mirror // /coach/plans and surface a retry. - refetch()} /> + refetch()} /> ) : groups.length === 0 ? (
{query.isError ? ( - query.refetch()} /> + query.refetch()} /> ) : query.isLoading ? (
void } + { + isLoading: boolean; + isError: boolean; + error: unknown; + retry: () => void; + } > = { analytics: { isLoading: analyticsQuery.isLoading, isError: analyticsQuery.isError, + error: analyticsQuery.error, retry: () => void analyticsQuery.refetch(), }, comprehensive: { isLoading: comprehensiveQuery.isLoading, isError: comprehensiveQuery.isError, + error: comprehensiveQuery.error, retry: () => void comprehensiveQuery.refetch(), }, workouts: { isLoading: workoutsProbe.isLoading, isError: workoutsProbe.isError, + error: workoutsProbe.error, retry: () => workoutsProbe.refetch(), }, nutrients: { isLoading: nutrientsModuleEnabled && nutrientsProbe.isLoading, isError: nutrientsModuleEnabled && nutrientsProbe.isError, + error: nutrientsProbe.error, retry: () => void nutrientsProbe.refetch(), }, ecg: { isLoading: ecgQuery.isLoading, isError: ecgQuery.isError, + error: ecgQuery.error, retry: () => void ecgQuery.refetch(), }, }; @@ -358,6 +368,7 @@ export default function MetricCatalogPage() { {failed.length > 0 ? ( failed.forEach((id) => probes[id].retry())} diff --git a/src/app/insights/ecg/page.tsx b/src/app/insights/ecg/page.tsx index 110be6dbe..174687e2b 100644 --- a/src/app/insights/ecg/page.tsx +++ b/src/app/insights/ecg/page.tsx @@ -39,7 +39,7 @@ export default function InsightsEcgPage() { // Reuse the SAME query cell `` reads (`insightsEcgList`) so the // page and the section share one cache entry. The page only needs the // `hasRecordings` flag to choose between the empty state and the section. - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.insightsEcgList(), queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"), enabled: isAuthenticated, @@ -55,7 +55,7 @@ export default function InsightsEcgPage() { title={t("insights.ecg.sectionTitle")} description={t("insights.subPage.ecgDescription")} > - refetch()} /> + refetch()} /> ); } diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index 8a7327dee..b8e703978 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -106,6 +106,7 @@ export default function InsightsMedikamentePage() { data: comprehensive, isLoading: isComprehensiveLoading, isError: isComprehensiveError, + error: comprehensiveError, refetch: refetchComprehensive, } = useQuery({ queryKey: queryKeys.insightsComprehensive(), @@ -179,6 +180,7 @@ export default function InsightsMedikamentePage() { explainerMetric="medications" > refetchComprehensive()} /> diff --git a/src/app/insights/nutrients/page.tsx b/src/app/insights/nutrients/page.tsx index cd48d2837..e64c2b046 100644 --- a/src/app/insights/nutrients/page.tsx +++ b/src/app/insights/nutrients/page.tsx @@ -99,6 +99,7 @@ export default function InsightsNutrientsPage() { description={t("nutrients.page.description")} > void overview.refetch()} /> diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index 305f32c63..3fc4782ad 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -45,9 +45,11 @@ export default function InsightsSchlafPage() { // to paint the same sentence three times, none of them offering a way out. // The page owns the shared state and renders one notice with a retry; the // cards no longer carry their own copy of it. - const { isError: rhythmError, refetch: refetchRhythm } = useSleepRhythm( - Boolean(user) && !isEmpty, - ); + const { + isError: rhythmError, + error: rhythmFailure, + refetch: refetchRhythm, + } = useSleepRhythm(Boolean(user) && !isEmpty); if (isEmpty) { return ( @@ -127,6 +129,7 @@ export default function InsightsSchlafPage() { */} {rhythmError ? ( void refetchRhythm()} /> diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx index 338d55b20..473db5d2e 100644 --- a/src/app/insights/workouts/[id]/page.tsx +++ b/src/app/insights/workouts/[id]/page.tsx @@ -99,7 +99,7 @@ export default function InsightsWorkoutDetailPage({
) : error ? ( // A failed query must never read as "no data" (UI-STANDARDS §6). - refetch()} /> + refetch()} /> ) : !data ? (

refetch()} /> diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index b0f76eea2..ef0df0823 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -49,6 +49,7 @@ export default function MedicationDetailPage({ data: medication, isLoading, isError, + error, refetch, } = useQuery({ queryKey: queryKeys.medicationDetail(id), @@ -83,6 +84,7 @@ export default function MedicationDetailPage({

void refetch()} /> diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index aa1593d01..2cb33b7bd 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -333,6 +333,7 @@ export default function MedicationsPageClient() { data: medications, isLoading, isError, + error, refetch: refetchMedications, } = useQuery({ queryKey: queryKeys.medications(), @@ -656,6 +657,7 @@ export default function MedicationsPageClient() {
) : isError ? ( void refetchMedications()} diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index f84bc2fbd..2fc4c7585 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -48,7 +48,7 @@ export default function NotificationsPage() { const { t } = useTranslations(); const queryClient = useQueryClient(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.notificationsPreferences(), queryFn: async () => { return apiGet("/api/notifications/preferences"); @@ -186,6 +186,7 @@ export default function NotificationsPage() { description={t("notifications.subtitle")} /> refetch()} /> diff --git a/src/app/onboarding/handover/page.tsx b/src/app/onboarding/handover/page.tsx index 7d29c8e9f..a658f0ce8 100644 --- a/src/app/onboarding/handover/page.tsx +++ b/src/app/onboarding/handover/page.tsx @@ -58,6 +58,7 @@ export default function HandoverDecisionPage() { {decision.isError ? ( void decision.refetch()} /> diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 9fae5214a..b3fa06daf 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -998,7 +998,10 @@ export default function DashboardPageClient({ ) : !mounted || digestQuery.isLoading ? ( ) : digestQuery.isError ? ( - digestQuery.refetch()} /> + digestQuery.refetch()} + /> ) : null } diff --git a/src/components/__tests__/design-consistency-guards.test.tsx b/src/components/__tests__/design-consistency-guards.test.tsx index 3187b2754..4e346c01b 100644 --- a/src/components/__tests__/design-consistency-guards.test.tsx +++ b/src/components/__tests__/design-consistency-guards.test.tsx @@ -75,7 +75,9 @@ describe(" — one canonical H1", () => { describe(" — retry affordance", () => { it("renders a retry button when onRetry is provided", () => { - const html = render( {}} />); + const html = render( + {}} />, + ); expect(html).toContain('data-slot="query-error-card"'); expect(html).toContain('role="alert"'); // The retry affordance is a real
) : query.isError ? ( void query.refetch()} /> diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index d1047e4f0..2f097e29b 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -847,7 +847,7 @@ export function BackupsSection() { const fmt = useFormatters(); const queryClient = useQueryClient(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.adminBackups(), queryFn: async () => { return apiGet("/api/admin/backups"); @@ -1243,6 +1243,7 @@ export function BackupsSection() { ) : isError ? ( void refetch()} /> diff --git a/src/components/admin/coach-feedback-section.tsx b/src/components/admin/coach-feedback-section.tsx index 9780cdcf2..dff4dafbe 100644 --- a/src/components/admin/coach-feedback-section.tsx +++ b/src/components/admin/coach-feedback-section.tsx @@ -99,6 +99,7 @@ export function CoachFeedbackSection() { ) : query.isError ? ( void query.refetch()} /> diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx index 041d6c762..cca03537f 100644 --- a/src/components/admin/host-metrics-chart.tsx +++ b/src/components/admin/host-metrics-chart.tsx @@ -144,7 +144,7 @@ export function HostMetricsChart() { const { t } = useTranslations(); const fmt = useFormatters(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.adminHostMetrics("2h"), queryFn: async () => { return apiGet("/api/admin/host-metrics?since=2h"); @@ -198,6 +198,7 @@ export function HostMetricsChart() { } /> void refetch()} /> diff --git a/src/components/admin/key-backup-card.tsx b/src/components/admin/key-backup-card.tsx index 100c41965..2b894cf6a 100644 --- a/src/components/admin/key-backup-card.tsx +++ b/src/components/admin/key-backup-card.tsx @@ -155,6 +155,7 @@ export function KeyBackupCard() { /> {status.isError ? ( void status.refetch()} /> diff --git a/src/components/admin/measurement-maintenance-card.tsx b/src/components/admin/measurement-maintenance-card.tsx index 78996fa95..6109f5a89 100644 --- a/src/components/admin/measurement-maintenance-card.tsx +++ b/src/components/admin/measurement-maintenance-card.tsx @@ -111,6 +111,7 @@ export function MeasurementMaintenanceCard() { if (status.isError) { return ( void status.refetch()} /> diff --git a/src/components/admin/offhost-backups-section.tsx b/src/components/admin/offhost-backups-section.tsx index eb0a17ae7..2339a0353 100644 --- a/src/components/admin/offhost-backups-section.tsx +++ b/src/components/admin/offhost-backups-section.tsx @@ -141,7 +141,7 @@ function AccountRow({ row }: { row: OffhostAccountRow }) { export function OffhostBackupsSection() { const { t } = useTranslations(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.adminBackups(), queryFn: async () => apiGet("/api/admin/backups"), }); @@ -170,6 +170,7 @@ export function OffhostBackupsSection() { ) : isError ? ( void refetch()} /> diff --git a/src/components/admin/pulse-day-mean-card.tsx b/src/components/admin/pulse-day-mean-card.tsx index 9a78004f4..705922bac 100644 --- a/src/components/admin/pulse-day-mean-card.tsx +++ b/src/components/admin/pulse-day-mean-card.tsx @@ -125,6 +125,7 @@ export function PulseDayMeanCard() { if (status.isError) { return ( void status.refetch()} /> diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx index fef83bcc1..e505ac556 100644 --- a/src/components/admin/recent-audit-preview.tsx +++ b/src/components/admin/recent-audit-preview.tsx @@ -38,7 +38,7 @@ interface AuditLogResponse { export function RecentAuditPreview() { const { t } = useTranslations(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.adminAuditOverview(), queryFn: async () => { return apiGet( @@ -84,6 +84,7 @@ export function RecentAuditPreview() { ) : isError ? ( void refetch()} /> diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index b2811261e..ee67f9999 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -128,7 +128,7 @@ export function FailingJobsCard({ export function SystemStatusSection() { const { t } = useTranslations(); const fmt = useFormatters(); - const { data: status, isError, refetch } = useSystemStatus(); + const { data: status, isError, error, refetch } = useSystemStatus(); return (
@@ -277,6 +277,7 @@ export function SystemStatusSection() { // transient 500 (rolling deploy, DB blip) doesn't require a // full page reload to recover. void refetch()} retryLabel={t("admin.systemStatusRetry")} diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index c2088e662..cdd053dfa 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -31,7 +31,7 @@ import { useKeyBackupStatus } from "./use-key-backup-status"; export function SystemStatusSummary() { const { t } = useTranslations(); - const { data: status, isError, refetch } = useSystemStatus(); + const { data: status, isError, error, refetch } = useSystemStatus(); const { data: keyBackup } = useKeyBackupStatus(); return ( @@ -110,6 +110,7 @@ export function SystemStatusSummary() {
) : isError ? ( void refetch()} /> diff --git a/src/components/body-sites/body-sites-section.tsx b/src/components/body-sites/body-sites-section.tsx index 5dc1228a7..dbd6b7b35 100644 --- a/src/components/body-sites/body-sites-section.tsx +++ b/src/components/body-sites/body-sites-section.tsx @@ -93,6 +93,7 @@ export function BodySitesSection({ {list.isError ? ( void list.refetch()} /> diff --git a/src/components/custom-metrics/custom-metric-detail.tsx b/src/components/custom-metrics/custom-metric-detail.tsx index c31201cf9..bf7c4c2ad 100644 --- a/src/components/custom-metrics/custom-metric-detail.tsx +++ b/src/components/custom-metrics/custom-metric-detail.tsx @@ -87,6 +87,7 @@ export function CustomMetricDetail({ const { data: metric, isError: metricError, + error: metricFailure, refetch: refetchMetric, } = useQuery({ queryKey: queryKeys.customMetricDetail(customMetricId), @@ -98,6 +99,7 @@ export function CustomMetricDetail({ data: list, isLoading, isError: listError, + error: listFailure, refetch: refetchList, } = useInfiniteQuery({ queryKey: queryKeys.customMetricEntries({ @@ -179,6 +181,7 @@ export function CustomMetricDetail({ description={description} /> { if (metricError) void refetchMetric(); diff --git a/src/components/custom-metrics/custom-metric-list.tsx b/src/components/custom-metrics/custom-metric-list.tsx index 73d0cdacf..232326b3d 100644 --- a/src/components/custom-metrics/custom-metric-list.tsx +++ b/src/components/custom-metrics/custom-metric-list.tsx @@ -45,7 +45,7 @@ export function CustomMetricList() { const [addOpen, setAddOpen] = useState(false); const [addFooterEl, setAddFooterEl] = useState(null); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.customMetrics(), queryFn: () => apiGet("/api/custom-metrics"), }); @@ -93,6 +93,7 @@ export function CustomMetricList() { ) : isError ? ( refetch()} /> diff --git a/src/components/custom-metrics/custom-metric-values-list.tsx b/src/components/custom-metrics/custom-metric-values-list.tsx index 5650f46a6..2607996b1 100644 --- a/src/components/custom-metrics/custom-metric-values-list.tsx +++ b/src/components/custom-metrics/custom-metric-values-list.tsx @@ -42,6 +42,7 @@ export function CustomMetricValuesList({ data: list, isLoading, isError, + error, refetch, fetchNextPage, hasNextPage, @@ -76,6 +77,7 @@ export function CustomMetricValuesList({ if (isError) { return ( void refetch()} /> diff --git a/src/components/cycle/cycle-ring-tile.tsx b/src/components/cycle/cycle-ring-tile.tsx index 4483a177f..f68773888 100644 --- a/src/components/cycle/cycle-ring-tile.tsx +++ b/src/components/cycle/cycle-ring-tile.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import { Droplets } from "lucide-react"; -import { ReadFailureRow } from "@/components/ui/read-failure-row"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { CycleRing } from "./cycle-ring"; @@ -71,7 +71,7 @@ export function CycleRingTile({ className }: { className?: string }) { if (calendar.isLoading && !calendar.data) return null; if (calendar.isError && !calendar.data) { return ( - void calendar.refetch()} className="border-0 bg-transparent p-0" @@ -405,6 +406,7 @@ export function CycleView() { {calendarError ? ( void calendar.refetch()} /> @@ -435,6 +437,7 @@ export function CycleView() { {insights.isError ? ( void insights.refetch()} /> @@ -454,6 +457,7 @@ export function CycleView() { {profileQuery.isError ? ( void profileQuery.refetch()} /> diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index d1857122e..6e485130d 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -24,7 +24,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { randomId } from "@/lib/random-id"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { useCalendarDate } from "@/hooks/use-calendar-date"; -import { readFailureKind } from "@/components/ui/read-failure-row"; +import { readFailureKind } from "@/lib/api/read-failure"; import { cycleDependentKeys, @@ -158,7 +158,7 @@ export function useCycleRingDial(enabled: boolean): { enabled && calendar.isError && !calendar.data && - readFailureKind(calendar.error) !== "denied", + readFailureKind(calendar.error) !== "refused", }; } diff --git a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx index aa52aac42..6dabec782 100644 --- a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx +++ b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; +import { makeFormatters } from "@/lib/format-locale"; import type { WorkoutListEntry } from "@/hooks/use-workouts"; /** @@ -17,6 +18,13 @@ vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ isAuthenticated: true }), })); +// The day label follows the reader's date order through `useFormatters()`. +const READER_FORMATTERS = makeFormatters("en", "UTC", "H24", "YMD"); +vi.mock("@/lib/i18n/context", async (importOriginal) => ({ + ...(await importOriginal()), + useFormatters: () => READER_FORMATTERS, +})); + let mockResult: { data: | { @@ -147,5 +155,8 @@ describe("", () => { expect(html).toContain("Cycling"); expect(html).toContain("30m"); expect(html).toContain("1h 0m"); + expect(html).toContain( + READER_FORMATTERS.dateWithWeekdaySmart("2026-05-15T07:00:00Z"), + ); }); }); diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index ec0f87625..3534fafdb 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -4,12 +4,11 @@ import Link from "next/link"; import { Activity } from "lucide-react"; import { formatDurationMinutes } from "@/lib/i18n/duration"; -import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts"; import { cn } from "@/lib/utils"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; -import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { Skeleton } from "@/components/ui/skeleton"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { iconForSport } from "@/lib/workouts/sport-icons"; @@ -32,16 +31,6 @@ import { iconForSport } from "@/lib/workouts/sport-icons"; * round-trip per session. */ -function formatDayLabel(iso: string, locale: string, timeZone: string): string { - const d = new Date(iso); - return getDateTimeFormat(locale, { - timeZone, - weekday: "short", - day: "2-digit", - month: "short", - }).format(d); -} - function renderRow(workout: WorkoutListEntry, sportName: string) { const Icon = iconForSport(workout.sportType); return ( @@ -61,9 +50,9 @@ function renderRow(workout: WorkoutListEntry, sportName: string) { } export function RecentWorkoutsTile() { - const { t, locale } = useTranslations(); - const timeZone = useDisplayTimezone(); - const { data, isError, refetch } = useWorkouts({ limit: 3 }); + const { t } = useTranslations(); + const fmt = useFormatters(); + const { data, isError, error, refetch } = useWorkouts({ limit: 3 }); // A query's `isLoading` is not a hydration-safe branch: TanStack reports the // optimistic mount fetch on the client's very first render and cannot on the @@ -116,6 +105,7 @@ export function RecentWorkoutsTile() { ) : isError ? ( refetch()} className="border-0 bg-transparent shadow-none" /> @@ -147,7 +137,7 @@ export function RecentWorkoutsTile() { > {renderRow(workout, sportName)} - {formatDayLabel(workout.startedAt, locale, timeZone)} + {fmt.dateWithWeekdaySmart(workout.startedAt)} {formatDurationMinutes( diff --git a/src/components/day/day-view.tsx b/src/components/day/day-view.tsx index e4eac0401..b65ab18de 100644 --- a/src/components/day/day-view.tsx +++ b/src/components/day/day-view.tsx @@ -274,6 +274,7 @@ export function DayView({ {day.isError ? ( void day.refetch()} slot="day-error" diff --git a/src/components/day/since-last-visit.tsx b/src/components/day/since-last-visit.tsx index 54887ad6b..7793771ba 100644 --- a/src/components/day/since-last-visit.tsx +++ b/src/components/day/since-last-visit.tsx @@ -17,8 +17,9 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { ReadFailureRow } from "@/components/ui/read-failure-row"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import type { EncounterDTO } from "@/lib/encounters/dto"; import { apiGet } from "@/lib/api/api-fetch"; import type { @@ -35,7 +36,7 @@ import { import { getUnitForType } from "@/lib/measurements/unit-map"; import { queryKeys } from "@/lib/query-keys"; -import { DAY_SECTION_LABEL, dayTitle, useNotableText } from "./day-sections"; +import { dayTitle, useNotableText } from "./day-sections"; import { DayLink } from "./day-link"; import { dateKeyOfInstant } from "./day-url"; import { tileKeyOf } from "./day-values-model"; @@ -195,7 +196,7 @@ export function SinceLastVisit({ if (window.isError && rows.length === 0) { return ( - + + + + {tCount("day.sinceLastVisit.count", rows.length)} + + } + /> +
-
-

- {t("day.sinceLastVisit.title", { date: lastLabel })} -

- - {tCount("day.sinceLastVisit.count", rows.length)} - -
    {visible.map((row) => (
  • ) : list.isError ? ( list.refetch()} /> diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index a80281ef9..93b4c4509 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -287,7 +287,7 @@ export function DocumentRecordLinks({ title={t("documents.detail.vaccinationsLabel")} slot="document-vaccination-links" pending={vaccinations.isPending || !seedFresh} - error={vaccinations.isError || seedError} + error={vaccinations.error || seedError} errorLabel={t("links.picker.loadError")} onRetry={() => { if (vaccinations.isError) void vaccinations.refetch(); diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index b313056cc..5bae07a45 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -1076,6 +1076,7 @@ export function DocumentsView() { ) ) : list.isError ? ( void list.refetch()} /> diff --git a/src/components/documents/episode-documents-card.tsx b/src/components/documents/episode-documents-card.tsx index 14bfa981b..cbe8235cf 100644 --- a/src/components/documents/episode-documents-card.tsx +++ b/src/components/documents/episode-documents-card.tsx @@ -77,6 +77,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) { if (list.isError) { return ( void list.refetch()} /> diff --git a/src/components/documents/sources/document-source-picker.tsx b/src/components/documents/sources/document-source-picker.tsx index 5649c5733..1ae4fc11d 100644 --- a/src/components/documents/sources/document-source-picker.tsx +++ b/src/components/documents/sources/document-source-picker.tsx @@ -552,6 +552,7 @@ export function DocumentSourcePicker({ ) : search.isError ? ( void search.refetch()} slot="document-source-error" diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx index c4b21a037..54a281648 100644 --- a/src/components/encounters/__tests__/visits-section.test.tsx +++ b/src/components/encounters/__tests__/visits-section.test.tsx @@ -297,5 +297,20 @@ describe(" since the last visit", () => { expect(html).toContain('data-slot="since-last-visit"'); expect(html).toContain('data-slot="since-last-visit-row"'); expect(upcomingCards(html)).toBe(upcomingCards(baseline) + 1); + // The card heads like every compact card: TileHeader at the small size, + // a real heading under the section's own, at the compact density. + const card = html.slice( + html.lastIndexOf( + '
    void list.refetch()} /> diff --git a/src/components/encounters/visits-section.tsx b/src/components/encounters/visits-section.tsx index 7544c3539..dde6fea21 100644 --- a/src/components/encounters/visits-section.tsx +++ b/src/components/encounters/visits-section.tsx @@ -110,6 +110,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { {list.isError ? ( void list.refetch()} /> diff --git a/src/components/illness/episode-visits-card.tsx b/src/components/illness/episode-visits-card.tsx index 087660712..ad50bd99b 100644 --- a/src/components/illness/episode-visits-card.tsx +++ b/src/components/illness/episode-visits-card.tsx @@ -52,6 +52,7 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) { if (list.isError) { return ( void list.refetch()} /> diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx index bad38802b..63a89e0e3 100644 --- a/src/components/illness/illness-episode-detail.tsx +++ b/src/components/illness/illness-episode-detail.tsx @@ -58,6 +58,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { data: episode, isLoading, isError, + error, refetch, } = useIllnessEpisode(episodeId); const resolve = useResolveEpisode(); @@ -69,6 +70,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { if (isError) { return ( void refetch()} /> diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 68f774bc1..a1a813172 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -370,6 +370,7 @@ export function IllnessView() { data: episodes, isLoading, isError, + error, refetch, } = useIllnessEpisodes(true); const resolve = useResolveEpisode(); @@ -460,6 +461,7 @@ export function IllnessView() { // A read failure is NOT an empty journal — surface the error + Retry so // an outage never reads as "no episodes yet". void refetch()} /> diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx index e36b88076..5a997be2c 100644 --- a/src/components/insights/__tests__/device-score-surfaces.test.tsx +++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx @@ -3,7 +3,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; import type { DataSummary } from "@/lib/analytics/trends"; /** @@ -46,12 +45,6 @@ function render(node: React.ReactNode) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); - // The recovery page's empty note waits for the connection status; an - // account with no integrations is the settled answer here. - queryClient.setQueryData(queryKeys.integrationsStatus(), { - threshold: 3, - integrations: [], - }); return renderToStaticMarkup( {node} @@ -204,21 +197,20 @@ describe(" data-gating", () => { describe(" data-gating", () => { it("shows the calm empty note when there is no recovery data at all", () => { - analyticsMock.mockReturnValue( - analyticsWith({ SLEEP_DURATION: summary({ count: 10 }) }), - ); + // The server resolves the empty reason beside the summaries. + const empty = analyticsWith({ SLEEP_DURATION: summary({ count: 10 }) }); + analyticsMock.mockReturnValue({ + ...empty, + data: { ...empty.data, recoveryEmpty: "general" }, + }); const html = render(); expect(html).toContain('data-slot="recovery-empty"'); // Routed through the shared (dashed bordered card), not the // old hand-rolled div — the empty title comes from the unified primitive. expect(html).toContain("border-dashed"); expect(html).not.toContain('data-slot="recovery-block-DAY_STRAIN"'); - // An Oura account sees this note too: it must say where Oura's readiness - // lives rather than tell a connected user to connect Oura. expect(html).not.toContain("Connect a WHOOP, Polar or Oura account"); - expect(html).toContain( - "Oura's readiness appears as your recovery score", - ); + expect(html).toContain("WHOOP or Polar"); }); it("renders a canonical chart block for the present signal", () => { diff --git a/src/components/insights/__tests__/recovery-empty-state.test.tsx b/src/components/insights/__tests__/recovery-empty-state.test.tsx index 06f4f5240..57036b5f0 100644 --- a/src/components/insights/__tests__/recovery-empty-state.test.tsx +++ b/src/components/insights/__tests__/recovery-empty-state.test.tsx @@ -3,16 +3,13 @@ import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; import type { DataSummary } from "@/lib/analytics/trends"; /** - * The recovery page's empty note says what is missing for THIS account. An - * Oura ring fills the page through its resilience level alone, so a connected - * ring with an empty page asks for a reconnect only when the server recorded - * that the grant leaves resilience out, and otherwise says only that no - * level has arrived. The connection facts come from the server's - * integrations read; the page only picks the sentence, and waits for it. + * The recovery page's empty note says what is missing for THIS account, and + * the reason is the server's (`recoveryEmpty` on the analytics slice): the + * page only picks the sentence for it and never reads the connection status + * itself. Every reason offers the same way on, the integrations settings. */ vi.mock("next/dynamic", () => ({ @@ -50,26 +47,24 @@ function summary(count: number): DataSummary { }; } -/** An Oura-only account: readiness, HRV and sleep, no resilience level. */ +/** An Oura-only account: readiness, HRV and sleep, no recovery type. */ const OURA_SUMMARIES = { SLEEP_DURATION: summary(30), RECOVERY_SCORE: summary(30), HRV_RMSSD: summary(30), }; -function render( - oura: Record | null, - opts: { statusLoaded?: boolean } = {}, -) { +function render(recoveryEmpty: string | null) { + analyticsMock.mockReturnValue({ + data: { summaries: OURA_SUMMARIES, recoveryEmpty }, + isLoading: false, + isEmpty: false, + error: null, + refetch: () => {}, + }); const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + defaultOptions: { queries: { retry: false, enabled: false } }, }); - if (opts.statusLoaded !== false) { - queryClient.setQueryData(queryKeys.integrationsStatus(), { - threshold: 3, - integrations: oura ? [{ integration: "oura", ...oura }] : [], - }); - } return renderToStaticMarkup( @@ -79,67 +74,24 @@ function render( ); } -function health(unavailable: Array<{ item: string; cause: string }>) { - return { - state: unavailable.length > 0 ? "limited" : "ok", - reason: unavailable.length > 0 ? "permission_limited" : "data_arriving", - unavailable, - }; -} - beforeEach(() => { analyticsMock.mockReset(); - analyticsMock.mockReturnValue({ - data: { summaries: OURA_SUMMARIES }, - isLoading: false, - isEmpty: false, - error: null, - refetch: () => {}, - }); }); describe(" empty note", () => { - it("asks for the heart-health permission when the grant withholds resilience", () => { - const html = render({ - connected: true, - health: health([{ item: "resilience", cause: "permission" }]), - }); - expect(html).toContain('data-empty-case="oura-permission"'); - expect(html).toContain("allow heart-health access"); + it.each([ + ["oura_permission", "heart-health access"], + ["oura_no_resilience", "as soon as Oura provides one"], + ["general", "WHOOP or Polar"], + ])("renders the server's %s reason with the one action", (reason, text) => { + const html = render(reason); + expect(html).toContain(`data-empty-case="${reason}"`); + expect(html).toContain(text); expect(html).toContain('href="/settings/integrations"'); }); - it("says only that no level has arrived when no withheld permission is recorded", () => { - const html = render({ - connected: true, - health: health([{ item: "spo2", cause: "permission" }]), - }); - expect(html).toContain('data-empty-case="oura-no-resilience"'); - expect(html).toContain("no resilience level has arrived"); - // The server cannot prove the grant covers resilience, so the note - // never says it does. - expect(html).not.toContain("permission covers"); - expect(html).not.toContain('href="/settings/integrations"'); - }); - - it("stays neutral when the connection carries no health block", () => { - const html = render({ connected: true }); - expect(html).toContain('data-empty-case="oura-no-resilience"'); - expect(html).not.toContain("permission covers"); - }); - - it("shows no note until the connection status has loaded", () => { - const html = render( - { connected: true, health: health([]) }, - { statusLoaded: false }, - ); - expect(html).toContain('data-slot="recovery-loading"'); + it("shows no note when the server says the page has data", () => { + const html = render(null); expect(html).not.toContain("data-empty-case"); }); - - it("keeps the general note for an account without Oura", () => { - const html = render(null); - expect(html).toContain('data-empty-case="general"'); - expect(html).toContain("WHOOP and Polar record"); - }); }); diff --git a/src/components/insights/__tests__/workout-list.test.tsx b/src/components/insights/__tests__/workout-list.test.tsx index a3dad821a..abc6e4160 100644 --- a/src/components/insights/__tests__/workout-list.test.tsx +++ b/src/components/insights/__tests__/workout-list.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; +import { makeFormatters } from "@/lib/format-locale"; import { WorkoutList } from "../workout-list"; import type { WorkoutListEntry } from "@/hooks/use-workouts"; @@ -18,6 +19,14 @@ vi.mock("@/hooks/use-auth", () => ({ useAccountOnceMounted: () => account.user, })); +// The reader's date and clock preferences reach the list through +// `useFormatters()`; this reader orders dates year first, on a 24-hour clock. +const READER_FORMATTERS = makeFormatters("en", "UTC", "H24", "YMD"); +vi.mock("@/lib/i18n/context", async (importOriginal) => ({ + ...(await importOriginal()), + useFormatters: () => READER_FORMATTERS, +})); + /** * v1.4.32 — `` unit tests. * @@ -54,6 +63,13 @@ describe("", () => { expect(html).toContain("30m"); }); + it("writes the start in the reader's own date order and clock", () => { + const html = render(); + expect(html).toContain( + `${READER_FORMATTERS.dateWithWeekdaySmart(ROW.startedAt)}, ${READER_FORMATTERS.time(ROW.startedAt)}`, + ); + }); + it("renders the distance and energy chips when present", () => { const html = render(); expect(html).toContain("5.20 km"); diff --git a/src/components/insights/coach-panel/coach-results.tsx b/src/components/insights/coach-panel/coach-results.tsx index 2565c9bb0..49a537b78 100644 --- a/src/components/insights/coach-panel/coach-results.tsx +++ b/src/components/insights/coach-panel/coach-results.tsx @@ -205,7 +205,7 @@ export function CoachResults({ const expected = hasLive ? countResultsInSection(liveWithProvenance(live ?? [], metas), section) : countResultsInSection(metas, section); - const { ref, results, isError, refetch } = useCoachMessageResults({ + const { ref, results, isError, error, refetch } = useCoachMessageResults({ conversationId, messageId, enabled: !hasLive && expected > 0, @@ -222,6 +222,7 @@ export function CoachResults({ > {!hasLive && isError ? ( @@ -23,6 +23,7 @@ export function ConversationTranscript({ id }: { id: string }) { if (isError || !data) { return ( void refetch()} slot="coach-conversations-transcript-error" diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index cedd31787..bbd356714 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -270,6 +270,7 @@ export function useCoachConversationHistory( conversations, isLoading: query.isLoading, isError: query.isError, + error: query.error, hasNextPage: query.hasNextPage, isFetchingNextPage: query.isFetchingNextPage, fetchNextPage: query.fetchNextPage, diff --git a/src/components/insights/coincident-deviation-card.tsx b/src/components/insights/coincident-deviation-card.tsx index 6d8926da7..3cdf69ec5 100644 --- a/src/components/insights/coincident-deviation-card.tsx +++ b/src/components/insights/coincident-deviation-card.tsx @@ -173,10 +173,8 @@ export function CoincidentDeviationCard({ className, }: CoincidentDeviationCardProps) { const { t } = useTranslations(); - const { data, isError, refetch } = useDerivedMetric( - COINCIDENT_METRIC, - { enabled }, - ); + const { data, isError, error, refetch } = + useDerivedMetric(COINCIDENT_METRIC, { enabled }); // A failed read says so with a retry. Without this branch the card sat on // its skeleton forever, which read as "still loading" rather than failed. @@ -192,6 +190,7 @@ export function CoincidentDeviationCard({ title={t("insights.derived.coincident.cardTitle")} /> void refetch()} /> diff --git a/src/components/insights/derived/__tests__/coach-read-strip-placeholder.test.tsx b/src/components/insights/derived/__tests__/coach-read-strip-placeholder.test.tsx index ab5660e09..afaaf8337 100644 --- a/src/components/insights/derived/__tests__/coach-read-strip-placeholder.test.tsx +++ b/src/components/insights/derived/__tests__/coach-read-strip-placeholder.test.tsx @@ -3,6 +3,8 @@ import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; +import { ApiError } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; /** * The strip sits above the chart on every metric sub-page. While its read is @@ -37,4 +39,57 @@ describe("CoachReadStrip — placeholder while the read loads", () => { expect(markup).toContain('data-state="loading"'); expect(markup).toContain('aria-busy="true"'); }); + + function renderFailed(error: Error) { + // `retryOnMount: false` keeps the seeded failure from being reset to a + // fresh load on render. + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, enabled: false, retryOnMount: false }, + }, + }); + client + .getQueryCache() + .build(client, { queryKey: queryKeys.insightsCoachRead("WEIGHT", "en") }) + .setState({ status: "error", error, fetchStatus: "idle" }); + return renderToStaticMarkup( + + + + + , + ); + } + + /** The class list of the strip body, the element after the header. */ + function bodyClass(markup: string): string | undefined { + return /data-slot="card-content" class="([^"]*)"/.exec(markup)?.[1]; + } + + it("holds the same frame and body height when the read fails", () => { + const loading = renderToStaticMarkup( + + + + + , + ); + const failed = renderFailed(new ApiError("Boom", 500)); + expect(failed).toContain('data-slot="coach-read-strip"'); + expect(failed).toContain('data-state="error"'); + expect(failed).toContain('data-slot="coach-read-strip-error"'); + expect(bodyClass(failed)).toBeDefined(); + expect(bodyClass(failed)).toBe(bodyClass(loading)); + expect(bodyClass(failed)).toContain("min-h-8"); + }); + + it("draws nothing for a refused read", () => { + expect(renderFailed(new ApiError("Forbidden", 403))).toBe(""); + }); }); diff --git a/src/components/insights/derived/__tests__/composite-score-anatomy-gap.test.tsx b/src/components/insights/derived/__tests__/composite-score-anatomy-gap.test.tsx new file mode 100644 index 000000000..60c3a089e --- /dev/null +++ b/src/components/insights/derived/__tests__/composite-score-anatomy-gap.test.tsx @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * A rhythm sub-score withheld for too few nights names the floor the server + * published (`rhythmMinNights`), not a number the sheet carries itself. + */ + +const anatomy = vi.fn(); +vi.mock("../score-anatomy-view", () => ({ + ScoreAnatomyView: (props: Record) => { + anatomy(props); + return null; + }, +})); +vi.mock("@/components/insights/insight-status-card", () => ({ + InsightStatusCard: () => null, +})); +vi.mock("../score-history", () => ({ + ScoreHistoryChart: () => null, + ScoreTrendChartDynamic: () => null, +})); +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ + t: (key: string, vars?: Record) => + vars ? `${key}:${JSON.stringify(vars)}` : key, + }), +})); +vi.mock("../use-derived-metric", () => ({ + useDerivedMetric: () => ({ + isLoading: false, + data: { + status: "ok", + value: { + score: 70, + band: "yellow", + subScores: [ + { key: "consistency", value: null, weight: 0.2, gap: "few_nights" }, + ], + rhythmNights: 2, + rhythmMinNights: 5, + }, + coverage: 1, + confidence: "high", + provenance: {}, + }, + }), +})); + +import { CompositeScoreAnatomy } from "../composite-score-anatomy"; + +describe("sleep score sheet — the few-nights gap", () => { + it("names the published floor", () => { + renderToStaticMarkup(); + const props = anatomy.mock.calls.at(-1)![0] as { + contributors: Array<{ key: string; missingLabel?: string }>; + }; + const consistency = props.contributors.find((c) => c.key === "consistency"); + expect(consistency?.missingLabel).toContain('"count":2'); + expect(consistency?.missingLabel).toContain('"needed":5'); + }); +}); diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index 0519c58a8..2f15a960c 100644 --- a/src/components/insights/derived/coach-read-strip.tsx +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -10,8 +10,9 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { metricFractionDigits } from "@/lib/measurements/value-domain"; import { apiGet } from "@/lib/api/api-fetch"; +import { readFailureKind } from "@/lib/api/read-failure"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { ReadFailureRow } from "@/components/ui/read-failure-row"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { TileHeader } from "@/components/insights/tile-header"; import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape"; @@ -60,6 +61,13 @@ export interface CoachReadStripProps { valueScale?: number; } +/** + * The strip's body holds the height of the error row's Retry button (32 px, + * 44 px on a touch screen) in every state, so the placeholder, a failure and + * a one-line read all stand the same height and the chart below never moves. + */ +const STRIP_BODY = "min-h-8 pointer-coarse:min-h-11"; + export function CoachReadStrip({ metricType, unit, @@ -97,26 +105,19 @@ export function CoachReadStrip({ // baseline line degrades to the "learning" copy), so the placeholder only // ever gives way to a card of its own height. A failed read gives way to a // notice with a retry: dropping the strip read as "nothing to say about - // today", which is not what happened. The route is a manage-level read, so - // a viewer on a read-only share is refused it; that draws nothing, and a - // spent analytics budget says so without offering a retry. + // today", which is not what happened. The notice sits inside the same card + // shell as the placeholder, so a failure does not move the chart either. + // The route is a manage-level read, so a viewer on a read-only share is + // refused it; that draws nothing, and a spent analytics budget says so + // without offering a retry. if (!mounted || !data) { if (!isAuthenticated) return null; - if (isError) { - return ( - void refetch()} - /> - ); - } + if (isError && readFailureKind(error) === "refused") return null; return ( @@ -126,8 +127,18 @@ export function CoachReadStrip({ titleAs="h2" /> - - + + {isError ? ( + void refetch()} + className="flex-row items-center justify-between rounded-none border-0 bg-transparent p-0" + /> + ) : ( + + )} ); @@ -236,12 +247,12 @@ export function CoachReadStrip({ - +
    {baselineLine ? (

    diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx index 87dfa9113..51b8819ea 100644 --- a/src/components/insights/derived/composite-score-anatomy.tsx +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -186,6 +186,7 @@ export function CompositeScoreAnatomy({ return (

    void query.refetch()} /> @@ -242,6 +243,7 @@ export function CompositeScoreAnatomy({ s.gap === "few_nights" ? t("insights.derived.composite.SLEEP_SCORE.gap.fewNights", { count: v.rhythmNights ?? 0, + needed: v.rhythmMinNights, }) : s.gap === "no_main_sleep" ? t("insights.derived.composite.SLEEP_SCORE.gap.noMainSleep") diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 1201bb12f..265c99814 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -891,6 +891,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) { subtitle={t("insights.derived.vitals.sectionSubtitle")} /> { try { @@ -63,6 +63,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) { // alert is content, not muted meta — it also has to be recoverable. return ( refetch()} /> diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index d3eb28880..c7c7ac737 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -392,6 +392,7 @@ export function HealthKitMetricPage({ explainerParams={explainerParams} > apiGet(`/api/insights/pulse/intraday?date=${dateKey}`), @@ -255,6 +255,7 @@ export function IntradayPulseChart({ ) : isError ? ( refetch()} className="border-0 bg-transparent shadow-none" /> diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index 94a111766..f1d49ed7c 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -132,7 +132,8 @@ export function MoodInsightsSections({ } = {}) { const { t } = useTranslations(); - const { data, isLoading, isPending, isError, refetch } = useMoodInsights(); + const { data, isLoading, isPending, isError, error, refetch } = + useMoodInsights(); // `isPending`, not `isLoading`: before the session resolves the read is // disabled rather than loading, and the frame must already be there. @@ -161,7 +162,7 @@ export function MoodInsightsSections({ // "rest" region (mirroring the loading convention above), rather than // stacking an identical row in all three regions down the page. return region === "rest" ? ( - refetch()} /> + refetch()} /> ) : null; } diff --git a/src/components/insights/mood/mood-prognosis-card.tsx b/src/components/insights/mood/mood-prognosis-card.tsx index e6acef61f..b3e213a0e 100644 --- a/src/components/insights/mood/mood-prognosis-card.tsx +++ b/src/components/insights/mood/mood-prognosis-card.tsx @@ -32,7 +32,7 @@ import { Compass } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; -import { ReadFailureRow } from "@/components/ui/read-failure-row"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useAuth } from "@/hooks/use-auth"; import { apiGet } from "@/lib/api/api-fetch"; import { formatDate } from "@/lib/date-format"; @@ -71,7 +71,7 @@ export function MoodPrognosisCard() { if (isError && !data) { return ( - { // Correlations are statistics, never gated on AI; a rejection is a @@ -81,6 +81,7 @@ export function MoodWhatStandsOut({ ) : null} {isError ? ( void refetch()} diff --git a/src/components/insights/nutrients/caffeine-card.tsx b/src/components/insights/nutrients/caffeine-card.tsx index 7d74d06bc..38b6126a0 100644 --- a/src/components/insights/nutrients/caffeine-card.tsx +++ b/src/components/insights/nutrients/caffeine-card.tsx @@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { Coffee } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { ReadFailureRow } from "@/components/ui/read-failure-row"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { TileHeader } from "@/components/insights/tile-header"; import { NutrientDailyBarChartDynamic } from "@/components/charts/nutrient-daily-bar-chart-dynamic"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -43,7 +43,7 @@ export function CaffeineCard() { if (isError && !data) { return ( - apiGet( @@ -39,7 +39,7 @@ export function HydrationCard() { }); if (isError) { - return ; + return ; } const todayTotal = data?.days.at(-1)?.amount ?? 0; diff --git a/src/components/insights/nutrients/micronutrients-card.tsx b/src/components/insights/nutrients/micronutrients-card.tsx index 9ddd76809..8518a3993 100644 --- a/src/components/insights/nutrients/micronutrients-card.tsx +++ b/src/components/insights/nutrients/micronutrients-card.tsx @@ -159,7 +159,7 @@ export function MicronutrientsCard() { // its "no micronutrient data" EmptyState — the same output as an account that // genuinely logs no nutrients. The sibling hydration card already routes // through QueryErrorCard; this one now matches it. - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.nutrientIntake(WINDOW_DAYS), queryFn: () => apiGet(`/api/nutrients?days=${WINDOW_DAYS}`), @@ -180,6 +180,7 @@ export function MicronutrientsCard() { {isError ? ( void refetch()} /> diff --git a/src/components/insights/overview-section-boundary.tsx b/src/components/insights/overview-section-boundary.tsx index 12ad846c4..8dea5c752 100644 --- a/src/components/insights/overview-section-boundary.tsx +++ b/src/components/insights/overview-section-boundary.tsx @@ -30,13 +30,14 @@ interface FenceProps { interface FenceState { failed: boolean; + error?: unknown; } class SectionFence extends Component { state: FenceState = { failed: false }; - static getDerivedStateFromError(): FenceState { - return { failed: true }; + static getDerivedStateFromError(error: unknown): FenceState { + return { failed: true, error }; } componentDidCatch(error: Error): void { @@ -58,9 +59,10 @@ class SectionFence extends Component { data-section={this.props.sectionId} > this.setState({ failed: false })} + onRetry={() => this.setState({ failed: false, error: undefined })} />
); diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx index 4a2718ade..060b067ab 100644 --- a/src/components/insights/recovery/recovery-section.tsx +++ b/src/components/insights/recovery/recovery-section.tsx @@ -16,11 +16,10 @@ import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { useTranslations } from "@/lib/i18n/context"; import { EmptyState } from "@/components/ui/empty-state"; import { Button } from "@/components/ui/button"; -import { - pickStatus, - useIntegrationStatuses, - type IntegrationStatusViewModel, -} from "@/components/settings/integrations/shared"; +import type { + RECOVERY_SUMMARY_TYPES, + RecoveryEmptyReason, +} from "@/lib/insights/recovery-empty"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { RestModeBanner } from "@/components/insights/rest-mode-banner"; import type { RestModeAnnotation } from "@/lib/analytics/score/types"; @@ -30,7 +29,8 @@ import { RecoveryMetricBlock } from "@/components/insights/recovery/recovery-met import { ResilienceTile } from "@/components/insights/recovery/resilience-tile"; interface BlockSpec { - type: string; + /** One of the types the server's empty decision counts, by construction. */ + type: (typeof RECOVERY_SUMMARY_TYPES)[number]; chartKey: ChartOverlayKey; statusMetric: MetricStatusMetricId; title: string; @@ -47,32 +47,14 @@ interface BlockGroup { } /** - * Why this account's recovery page is empty, from the server's view of its - * Oura connection. An Oura ring feeds this page through its resilience level - * alone. When the server records that the grant leaves resilience out, the - * note asks for one reconnect. Otherwise it says only that no level has - * arrived: the server cannot tell "the grant covers it" from "not recorded - * yet" (the scope note is cleared on every reconnect and written only by a - * sync that reached Oura), so the note never claims coverage. Everyone else - * gets the general note naming what fills the page. + * The empty note per server-resolved reason (`recoveryEmpty` on the + * analytics slice). The page never works the reason out itself. */ -type RecoveryEmptyCase = "general" | "oura-permission" | "oura-no-resilience"; - const EMPTY_COPY = { general: "insights.recovery.empty", - "oura-permission": "insights.recovery.emptyOuraPermission", - "oura-no-resilience": "insights.recovery.emptyOuraNoResilience", -} as const satisfies Record; - -function recoveryEmptyCase( - oura: IntegrationStatusViewModel | undefined, -): RecoveryEmptyCase { - if (!oura?.connected) return "general"; - const withheld = oura.health?.unavailable.some( - (u) => u.item === "resilience" && u.cause === "permission", - ); - return withheld ? "oura-permission" : "oura-no-resilience"; -} + oura_permission: "insights.recovery.emptyOuraPermission", + oura_no_resilience: "insights.recovery.emptyOuraNoResilience", +} as const satisfies Record; /** * v1.17.1 → rebuilt v1.18.1 (B1–B3) — body of the `/insights/recovery` @@ -204,28 +186,11 @@ export function RecoverySection() { }, ]; - // v1.19.2 — resilience is an ordinal band (limited … exceptional), surfaced - // as a calm dedicated tile rather than a chart block, so it counts toward the - // data gate independently of `groups` (a chart-block list). - const hasResilience = (summaries?.["RESILIENCE"]?.count ?? 0) > 0; + // Whether the page has anything to show, and why not, is the server's + // answer (`recoveryEmpty`), so web and iOS read one decision. + const emptyReason = data?.recoveryEmpty ?? null; - const hasAny = - summaries != null && - (hasResilience || - groups.some((group) => - group.blocks.some((block) => (summaries[block.type]?.count ?? 0) > 0), - )); - - // Only an empty page asks which connection could fill it, so a page with - // data never pays for the integrations read. - const statuses = useIntegrationStatuses( - !isLoading && summaries != null && !hasAny, - ); - const emptyCase = recoveryEmptyCase(pickStatus(statuses.data, "oura")); - - // The empty note waits for the connection status, so it never reads as - // the general note first and then changes under the reader. - if (isLoading || summaries == null || (!hasAny && statuses.isPending)) { + if (isLoading || summaries == null) { return (
@@ -233,22 +198,20 @@ export function RecoverySection() { ); } - if (!hasAny) { + if (emptyReason) { return ( } title={t("insights.recovery.emptyTitle")} - description={t(EMPTY_COPY[emptyCase])} + description={t(EMPTY_COPY[emptyReason])} action={ - emptyCase === "oura-permission" ? ( - - ) : undefined + } /> ); diff --git a/src/components/insights/rhythm-events-card.tsx b/src/components/insights/rhythm-events-card.tsx index 670d21a7f..6c953a550 100644 --- a/src/components/insights/rhythm-events-card.tsx +++ b/src/components/insights/rhythm-events-card.tsx @@ -91,7 +91,7 @@ export function RhythmEventsCard({ const { t } = useTranslations(); const fmt = useFormatters(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.insightsRhythmEvents(), queryFn: async () => { try { @@ -119,6 +119,7 @@ export function RhythmEventsCard({ title={t("insights.rhythmEvents.sectionTitle")} /> void refetch()} diff --git a/src/components/insights/tile-header.tsx b/src/components/insights/tile-header.tsx index a5fdf21f8..184079c13 100644 --- a/src/components/insights/tile-header.tsx +++ b/src/components/insights/tile-header.tsx @@ -58,9 +58,11 @@ interface TileHeaderProps { * owning block (the recovery blocks) or a grid of sibling tiles (dashboard * tiles, insights overview grids) both nest correctly as `h1 → h2, h2, …`; * just don't nest a `titleAs="h2"` tile inside another heading's own - * subsection. Default (unset) keeps every other tile a plain CardTitle. + * subsection. `"h3"` is the same for a card that sits inside a section + * which already owns an `h2` (the since-last-visit card under the visits + * section). Default (unset) keeps every other tile a plain CardTitle. */ - titleAs?: "h2"; + titleAs?: "h2" | "h3"; } export function TileHeader({ @@ -73,6 +75,7 @@ export function TileHeader({ id, titleAs, }: TileHeaderProps) { + const Heading = titleAs ?? "h2"; const titleClassNames = cn( size === "sm" ? "text-sm" : "text-base", titleClassName, @@ -93,16 +96,16 @@ export function TileHeader({ aria-hidden="true" /> ) : null} - {titleAs === "h2" ? ( + {titleAs ? ( // Heading semantics with the exact CardTitle visual treatment so the - // tile can carry a real `

` where it owns the subpage's heading. -

{title} -

+ ) : ( {title} diff --git a/src/components/insights/workout-detail/day-context.tsx b/src/components/insights/workout-detail/day-context.tsx index 1b69d184b..5286b8ee4 100644 --- a/src/components/insights/workout-detail/day-context.tsx +++ b/src/components/insights/workout-detail/day-context.tsx @@ -120,6 +120,7 @@ export function WorkoutDetailDayContext({ {sleep.isError ? ( // A failed read is not an absent night. @@ -169,6 +170,7 @@ export function WorkoutDetailDayContext({

{mood.isError ? ( diff --git a/src/components/insights/workout-detail/format.ts b/src/components/insights/workout-detail/format.ts index 443ab5cd9..b87139e35 100644 --- a/src/components/insights/workout-detail/format.ts +++ b/src/components/insights/workout-detail/format.ts @@ -1,14 +1,10 @@ -import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; +import { getNumberFormat } from "@/lib/intl/formatter-cache"; import { applyDisplayTransform, getQuantityTransform, paceSecondsPerDistanceUnit, type UnitPreference, } from "@/lib/measurements/display-transform"; -import { - hourCycleOptions, - type TimeFormatPreference, -} from "@/lib/format-locale"; /** * Shared formatters for the workout-detail surface. Pure helpers pulled @@ -120,28 +116,3 @@ export function formatPaceSeconds(secPerUnit: number, unit: string): string { } return `${m}:${s.toString().padStart(2, "0")} min/${unit}`; } - -export function formatDateRange( - startedAt: string, - endedAt: string, - locale: string, - timeFormat: TimeFormatPreference, - timeZone: string, -): string { - const start = new Date(startedAt); - const end = new Date(endedAt); - const dateFmt = getDateTimeFormat(locale, { - timeZone, - weekday: "long", - day: "2-digit", - month: "long", - year: "numeric", - }); - const timeFmt = getDateTimeFormat(locale, { - timeZone, - hour: "2-digit", - minute: "2-digit", - ...hourCycleOptions(timeFormat), - }); - return `${dateFmt.format(start)}, ${timeFmt.format(start)}–${timeFmt.format(end)}`; -} diff --git a/src/components/insights/workout-detail/header.tsx b/src/components/insights/workout-detail/header.tsx index b82c6e218..c4b399229 100644 --- a/src/components/insights/workout-detail/header.tsx +++ b/src/components/insights/workout-detail/header.tsx @@ -1,16 +1,12 @@ "use client"; -import { - useDisplayTimezone, - useTimeFormatPreference, - useTranslations, -} from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { MEASUREMENT_SOURCE_LIST_LABEL_KEYS } from "@/lib/i18n/source-labels"; import { iconForSport } from "@/lib/workouts/sport-icons"; import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; import { useUnitDisplay } from "@/hooks/use-unit-display"; -import { formatDuration, formatDistance, formatDateRange } from "./format"; +import { formatDuration, formatDistance } from "./format"; /** * Hero header — the at-a-glance verdict row. Sport icon badge, source @@ -35,8 +31,7 @@ function renderSportIconBadge(sportType: string) { export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { const { t, locale } = useTranslations(); - const timeFormat = useTimeFormatPreference(); - const timeZone = useDisplayTimezone(); + const fmt = useFormatters(); const { preference } = useUnitDisplay(); const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; const sportLabel = t(sportLabelKey); @@ -68,13 +63,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { {sportName}

- {formatDateRange( - workout.startedAt, - workout.endedAt, - locale, - timeFormat, - timeZone, - )} + {`${fmt.dateWithWeekdaySmart(workout.startedAt)}, ${fmt.time(workout.startedAt)}–${fmt.time(workout.endedAt)}`}

diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index 3ca14f2fc..7c152a342 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -5,17 +5,9 @@ import Link from "next/link"; import { HeartPulse, Loader2, Map as MapIcon } from "lucide-react"; import { cn } from "@/lib/utils"; -import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; +import { getNumberFormat } from "@/lib/intl/formatter-cache"; import { formatDurationMinutes } from "@/lib/i18n/duration"; -import { - useDisplayTimezone, - useTimeFormatPreference, - useTranslations, -} from "@/lib/i18n/context"; -import { - hourCycleOptions, - type TimeFormatPreference, -} from "@/lib/format-locale"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { iconForSport } from "@/lib/workouts/sport-icons"; import type { WorkoutListEntry } from "@/hooks/use-workouts"; import { Button } from "@/components/ui/button"; @@ -40,30 +32,6 @@ function formatEnergy(kcal: number, locale: string): string { }).format(Math.round(kcal)); } -function formatDate( - iso: string, - locale: string, - timeFormat: TimeFormatPreference, - timeZone: string, -): string { - const d = new Date(iso); - // Show the year whenever the workout is not from the current year, so a - // "Do., 16. Dez." from a previous year is never mistaken for this year's. - const yearOf = (value: Date) => - getDateTimeFormat(locale, { timeZone, year: "numeric" }).format(value); - const showYear = yearOf(d) !== yearOf(new Date()); - return getDateTimeFormat(locale, { - timeZone, - weekday: "short", - day: "2-digit", - month: "short", - ...(showYear ? { year: "numeric" as const } : {}), - hour: "2-digit", - minute: "2-digit", - ...hourCycleOptions(timeFormat), - }).format(d); -} - export interface WorkoutListProps { workouts: WorkoutListEntry[]; className?: string; @@ -82,8 +50,9 @@ export function WorkoutList({ onLoadMore, }: WorkoutListProps) { const { t, locale } = useTranslations(); - const timeFormat = useTimeFormatPreference(); - const timeZone = useDisplayTimezone(); + // The reader's date order, hour cycle and zone, as everywhere else; the + // year shows whenever the workout is not from this year. + const fmt = useFormatters(); const { preference } = useUnitDisplay(); return ( @@ -150,12 +119,7 @@ export function WorkoutList({ stays readable beside the stats column. */} - {formatDate( - workout.startedAt, - locale, - timeFormat, - timeZone, - )} + {`${fmt.dateWithWeekdaySmart(workout.startedAt)}, ${fmt.time(workout.startedAt)}`} diff --git a/src/components/labs/biomarker-manager.tsx b/src/components/labs/biomarker-manager.tsx index 2e44759e0..6cd1065dd 100644 --- a/src/components/labs/biomarker-manager.tsx +++ b/src/components/labs/biomarker-manager.tsx @@ -57,7 +57,7 @@ export function BiomarkerManager() { const canMerge = canManageDomain("labs"); const [mergeOpen, setMergeOpen] = useState(false); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.biomarkers(), queryFn: () => apiGet("/api/biomarkers"), }); @@ -195,6 +195,7 @@ export function BiomarkerManager() { ) : isError ? ( void refetch()} /> diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 24fdd8c7f..557d32a4c 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -191,6 +191,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { data: list, isLoading, isError: listError, + error: listFailure, refetch: refetchList, } = useInfiniteQuery({ queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }), @@ -332,6 +333,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { description={description} /> { if (markerError) void refetchMarker(); diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index e98e04ffa..12413ab3d 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -146,7 +146,7 @@ export function LabList({ // (beneath the heading); the overview rows no longer carry it. // One entry per biomarker over the whole history, grouped server-side: a // capped page of raw rows dropped every marker measured only long ago. - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.labResultsOverview(), queryFn: () => apiGet("/api/labs/overview"), }); @@ -203,6 +203,7 @@ export function LabList({ // outage never reads as "you have no lab results". return ( void refetch()} /> diff --git a/src/components/labs/lab-values-list.tsx b/src/components/labs/lab-values-list.tsx index 0040e896d..560e1427b 100644 --- a/src/components/labs/lab-values-list.tsx +++ b/src/components/labs/lab-values-list.tsx @@ -34,6 +34,7 @@ export function LabValuesList({ biomarkerId }: { biomarkerId: string }) { data: list, isLoading, isError, + error, refetch, fetchNextPage, hasNextPage, @@ -63,6 +64,7 @@ export function LabValuesList({ biomarkerId }: { biomarkerId: string }) { if (isError) { return ( void refetch()} /> diff --git a/src/components/links/entity-link-picker.tsx b/src/components/links/entity-link-picker.tsx index 8c438ed02..3c532b649 100644 --- a/src/components/links/entity-link-picker.tsx +++ b/src/components/links/entity-link-picker.tsx @@ -243,7 +243,7 @@ export function EntityLinkPicker({ onChange, searchPlaceholder, emptyLabel, - error = false, + error = null, errorLabel, onRetry, extraAction, @@ -263,9 +263,11 @@ export function EntityLinkPicker({ /** * The option read failed. Renders an error row with Retry instead of the * empty label: a failed read must never pass for "nothing to link", which - * is exactly how a refused page size once hid a whole vault. + * is exactly how a refused page size once hid a whole vault. Pass the + * failed read's error (or any truthy value for a failure without one); the + * row reads its status. */ - error?: boolean; + error?: unknown; errorLabel?: string; onRetry?: () => void; /** @@ -369,6 +371,7 @@ export function EntityLinkPicker({ ) : error ? ( void list.refetch()} selected={selected} @@ -403,7 +403,7 @@ function DosePicker({ selected, onChange, anchor, known }: PickerProps) { title={t("links.related.pick.vaccination")} slot="related-link-vaccination" pending={doses.isPending} - error={doses.isError} + error={doses.error} errorLabel={t("links.picker.loadError")} onRetry={() => void doses.refetch()} selected={selected} diff --git a/src/components/links/use-link-picker-pages.ts b/src/components/links/use-link-picker-pages.ts index cb5807b57..f95fc279e 100644 --- a/src/components/links/use-link-picker-pages.ts +++ b/src/components/links/use-link-picker-pages.ts @@ -199,9 +199,11 @@ export function useLinkPickerPages({ return mergeWindowPages(newer.data?.pages ?? [], older.data?.pages ?? []); }, [searching, search.data, newer.data, older.data]); - const activeError = searching - ? search.isError - : older.isError || (newerRequested && newer.isError); + // The failed read's error itself, not a flag: the picker's error row reads + // the status to tell a refusal or a spent budget from a real failure. + const activeError: unknown = searching + ? search.error + : (older.error ?? (newerRequested ? newer.error : null)); return { items, diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx index d8e75b1b5..a586e60c3 100644 --- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx +++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx @@ -81,7 +81,12 @@ export function VorsorgeDashboardCard() { // tell that this row had never been asked. const { canManageDomain } = useRecordCapabilities(); const canManageMeasurements = canManageDomain("measurements"); - const { data: reminders, isError, refetch } = useMeasurementReminders(); + const { + data: reminders, + isError, + error, + refetch, + } = useMeasurementReminders(); // A query's `isLoading` is not a hydration-safe branch: TanStack reports the // optimistic mount fetch on the client's very first render and cannot on the @@ -161,7 +166,7 @@ export function VorsorgeDashboardCard() { ) : isError ? ( // A read failure must not read as "no upcoming reminders" — a missed // checkup is the harm behind that fall-through (§6). - refetch()} /> + refetch()} /> ) : upcoming.length === 0 ? (

diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index e51027d23..2a07aadd0 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -243,6 +243,7 @@ export function VorsorgeSection({ data: reminders, isLoading, isError, + error, refetch, } = useMeasurementReminders(enabled); const { create, update, remove, satisfy, skip, snooze } = @@ -736,6 +737,7 @@ export function VorsorgeSection({ {!isLoading && isError && ( void refetch()} /> diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 36fe4fe3d..b910129e8 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; -import { ReadFailureRow } from "@/components/ui/read-failure-row"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { ListRow } from "@/components/ui/list-row"; import { @@ -606,7 +606,7 @@ export function MeasurementList({ ? "groupBy=day" : "raw"; - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.measurementsList({ type: typeFilter === "ALL" ? undefined : typeFilter, sourceEq, @@ -1034,7 +1034,7 @@ export function MeasurementList({ ) : isError ? ( // A read failure is NOT an empty list — surface the error + Retry so // an outage never reads as "you have no measurements". - void refetch()} /> + void refetch()} /> ) : !data?.measurements?.length ? ( <> {/* v1.4.15 phase-C5: replaces the bare-text empty rectangle. @@ -1924,7 +1924,7 @@ function DayDrillDown({ } if (error) { return ( - apiGet( @@ -128,7 +128,7 @@ export function EfficacyTab({ title={t("medications.efficacy.title")} dataSlot="medication-wirkung-section" > - refetch()} /> + refetch()} /> ); } diff --git a/src/components/medications/intake-history-list-v2.tsx b/src/components/medications/intake-history-list-v2.tsx index 5f5efb97a..ebfc211dc 100644 --- a/src/components/medications/intake-history-list-v2.tsx +++ b/src/components/medications/intake-history-list-v2.tsx @@ -201,28 +201,29 @@ export function IntakeHistoryListV2({ const offset = page * pageSize; - const { data, isLoading, isError, refetch } = useQuery({ - queryKey: queryKeys.medicationIntakeList(medicationId, { - sortBy, - sortDir, - limit: pageSize, - offset, - status: STATUS_FILTER, - }), - queryFn: async () => { - const params = new URLSearchParams({ + const { data, isLoading, isError, error, refetch } = + useQuery({ + queryKey: queryKeys.medicationIntakeList(medicationId, { sortBy, sortDir, - limit: String(pageSize), - offset: String(offset), + limit: pageSize, + offset, status: STATUS_FILTER, - }); - return apiGet( - `/api/medications/${medicationId}/intake?${params.toString()}`, - ); - }, - enabled: isAuthenticated, - }); + }), + queryFn: async () => { + const params = new URLSearchParams({ + sortBy, + sortDir, + limit: String(pageSize), + offset: String(offset), + status: STATUS_FILTER, + }); + return apiGet( + `/api/medications/${medicationId}/intake?${params.toString()}`, + ); + }, + enabled: isAuthenticated, + }); const sourceLabels: Record = useMemo( () => @@ -274,6 +275,7 @@ export function IntakeHistoryListV2({

) : isError ? ( void refetch()} /> diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index b7c5a2d55..56a5e00a5 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -14,7 +14,6 @@ import { MedicationCardBody } from "@/components/medications/card-parts/medicati import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot"; import { formatTimeWindowRange } from "@/lib/time-window-format"; import { formatDateTime, formatTime } from "@/lib/format"; -import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; import { formatDose } from "@/lib/medications/format-dose"; import { formatUnitsPerDose } from "@/lib/medications/units-per-dose"; @@ -38,7 +37,7 @@ import { LogInjectionSiteDialog } from "@/components/medications/log-injection-s import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs"; import { useAuth } from "@/hooks/use-auth"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; -import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; interface Schedule { id: string; @@ -381,22 +380,16 @@ export function MedicationCard({ : null; function formatLastTakenAt(value: string): string { - // Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and - // string-comparable for the today / yesterday / older bucketing below. - // The actual user-facing display goes through formatTime / formatDateTime. - const dayFormatter = getDateTimeFormat("en-CA", { - timeZone: userTz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }); + // Day keys (YYYY-MM-DD) in the profile zone, string-comparable for the + // today / yesterday / older bucketing below. The user-facing display goes + // through formatTime / formatDateTime. const now = new Date(); const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); - const intakeDay = dayFormatter.format(new Date(value)); - const todayDay = dayFormatter.format(now); - const yesterdayDay = dayFormatter.format(yesterday); + const intakeDay = userDayKey(new Date(value), userTz); + const todayDay = userDayKey(now, userTz); + const yesterdayDay = userDayKey(yesterday, userTz); const time = formatTime(value); if (intakeDay === todayDay) return `${t("medications.today")}, ${time}`; diff --git a/src/components/medications/medication-categories-sheet.tsx b/src/components/medications/medication-categories-sheet.tsx index fd6cdefe4..9127141fb 100644 --- a/src/components/medications/medication-categories-sheet.tsx +++ b/src/components/medications/medication-categories-sheet.tsx @@ -242,7 +242,10 @@ export function MedicationCategoriesSheet({ >
{categories.isError ? ( - void categories.refetch()} /> + void categories.refetch()} + /> ) : ( (categories.data ?? []).map((category) => ( void refetch()} /> + void refetch()} /> )} {!isLoading && !isError && items.length === 0 && ( diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index ea9e079e3..c47885781 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -75,6 +75,7 @@ export function MentalWellbeing() { data: history, isPending: historyPending, isError: historyError, + error: historyFailure, refetch: refetchHistory, } = useQuery({ queryKey: queryKeys.mentalHealthAssessments(), @@ -166,6 +167,7 @@ export function MentalWellbeing() {

{t("mentalHealth.choosePrompt")}

{historyError ? ( void refetchHistory()} diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index f9035cd54..4987f1814 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -340,7 +340,7 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { const toParam = toDay || undefined; const sourceParam = sourceFilter === "ALL" ? undefined : sourceFilter; - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.moodEntriesList({ mood: moodFilter === "ALL" ? undefined : moodFilter, source: sourceParam, @@ -714,6 +714,7 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { // A read failure is NOT an empty list — surface the error + Retry so // an outage never reads as "you have no mood entries". void refetch()} /> diff --git a/src/components/mood/mood-quick-tags.tsx b/src/components/mood/mood-quick-tags.tsx index c9fae8e23..d2a5468ac 100644 --- a/src/components/mood/mood-quick-tags.tsx +++ b/src/components/mood/mood-quick-tags.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { Plus } from "lucide-react"; -import { ReadFailureRow } from "@/components/ui/read-failure-row"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; @@ -75,7 +75,7 @@ export function MoodQuickTags({ } if (isError && !data) { return ( - void list.refetch()} /> diff --git a/src/components/records/about-me-note-manager.tsx b/src/components/records/about-me-note-manager.tsx index a5f334d93..70eb80a24 100644 --- a/src/components/records/about-me-note-manager.tsx +++ b/src/components/records/about-me-note-manager.tsx @@ -184,6 +184,7 @@ export function AboutMeNoteManager() { {query.isError && ( query.refetch()} /> diff --git a/src/components/records/ai-profile-inclusion-manager.tsx b/src/components/records/ai-profile-inclusion-manager.tsx index ba657df58..961faf4aa 100644 --- a/src/components/records/ai-profile-inclusion-manager.tsx +++ b/src/components/records/ai-profile-inclusion-manager.tsx @@ -110,6 +110,7 @@ export function AiProfileInclusionManager() { if (query.isError) { return ( void query.refetch()} /> diff --git a/src/components/records/allergy-free-text-note.tsx b/src/components/records/allergy-free-text-note.tsx index 062e45761..c974bdbef 100644 --- a/src/components/records/allergy-free-text-note.tsx +++ b/src/components/records/allergy-free-text-note.tsx @@ -133,6 +133,7 @@ export function AllergyFreeTextNote({ {query.isError ? ( <> query.refetch()} /> diff --git a/src/components/records/allergy-manager.tsx b/src/components/records/allergy-manager.tsx index 015931429..a461cbe5d 100644 --- a/src/components/records/allergy-manager.tsx +++ b/src/components/records/allergy-manager.tsx @@ -43,7 +43,7 @@ export function AllergyManager({ const [editing, setEditing] = useState(null); const [formFooterEl, setFormFooterEl] = useState(null); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.allergyList(true), queryFn: () => apiGet("/api/allergies"), }); @@ -154,6 +154,7 @@ export function AllergyManager({
) : isError ? ( void refetch()} /> diff --git a/src/components/records/conditions-manager.tsx b/src/components/records/conditions-manager.tsx index aa25fa41a..42b2f2cdb 100644 --- a/src/components/records/conditions-manager.tsx +++ b/src/components/records/conditions-manager.tsx @@ -125,6 +125,7 @@ export function ConditionsManager() {
{query.isError ? ( void query.refetch()} /> diff --git a/src/components/records/emergency-profile-manager.tsx b/src/components/records/emergency-profile-manager.tsx index 8d7716467..920fe4b40 100644 --- a/src/components/records/emergency-profile-manager.tsx +++ b/src/components/records/emergency-profile-manager.tsx @@ -91,6 +91,7 @@ export function EmergencyProfileManager() { if (query.isError) { return ( void query.refetch()} /> diff --git a/src/components/records/family-history-manager.tsx b/src/components/records/family-history-manager.tsx index e9266e439..c34c73703 100644 --- a/src/components/records/family-history-manager.tsx +++ b/src/components/records/family-history-manager.tsx @@ -33,7 +33,7 @@ export function FamilyHistoryManager() { const [editing, setEditing] = useState(null); const [formFooterEl, setFormFooterEl] = useState(null); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.familyHistoryList(), queryFn: () => apiGet("/api/family-history"), }); @@ -116,6 +116,7 @@ export function FamilyHistoryManager() {
) : isError ? ( void refetch()} /> diff --git a/src/components/records/life-circumstances-manager.tsx b/src/components/records/life-circumstances-manager.tsx index cb340a77f..56db109be 100644 --- a/src/components/records/life-circumstances-manager.tsx +++ b/src/components/records/life-circumstances-manager.tsx @@ -962,6 +962,7 @@ export function LifeCircumstancesManager() { if (query.isError) { return ( void query.refetch()} /> diff --git a/src/components/records/profile-summary.tsx b/src/components/records/profile-summary.tsx index e6e33f8d1..226e02eee 100644 --- a/src/components/records/profile-summary.tsx +++ b/src/components/records/profile-summary.tsx @@ -140,7 +140,10 @@ export function ProfileSummaryPage() {

) : null} {summary.isError ? ( - void summary.refetch()} /> + void summary.refetch()} + /> ) : null} {summary.data ? : null} diff --git a/src/components/settings/access/grants-given-card.tsx b/src/components/settings/access/grants-given-card.tsx index c27ffee1b..f7e6f5e49 100644 --- a/src/components/settings/access/grants-given-card.tsx +++ b/src/components/settings/access/grants-given-card.tsx @@ -40,7 +40,7 @@ import { export function GrantsGivenCard() { const { t } = useTranslations(); - const { data, isLoading, isError, refetch } = useAccountGrants(); + const { data, isLoading, isError, error, refetch } = useAccountGrants(); const revoke = useRevokeGrant(); const given = data?.given ?? []; @@ -62,6 +62,7 @@ export function GrantsGivenCard() { )} {isError && ( void refetch()} /> diff --git a/src/components/settings/access/grants-received-card.tsx b/src/components/settings/access/grants-received-card.tsx index f5b3bf2be..9d75f9ba3 100644 --- a/src/components/settings/access/grants-received-card.tsx +++ b/src/components/settings/access/grants-received-card.tsx @@ -54,7 +54,7 @@ import { */ export function GrantsReceivedCard() { const { t } = useTranslations(); - const { data, isLoading, isError, refetch } = useAccountGrants(); + const { data, isLoading, isError, error, refetch } = useAccountGrants(); const accept = useAcceptGrant(); const renounce = useRenounceGrant(); const switchAccount = useAccountSwitch(); @@ -80,6 +80,7 @@ export function GrantsReceivedCard() { )} {isError && ( void refetch()} /> diff --git a/src/components/settings/access/managed-profile-card.tsx b/src/components/settings/access/managed-profile-card.tsx index 60520a9f7..03dddf3f0 100644 --- a/src/components/settings/access/managed-profile-card.tsx +++ b/src/components/settings/access/managed-profile-card.tsx @@ -229,6 +229,7 @@ function ManagedProfileRow({ shows no destructive control for it. */} {guardians.isError && ( void guardians.refetch()} /> diff --git a/src/components/settings/access/managed-profile-edit-form.tsx b/src/components/settings/access/managed-profile-edit-form.tsx index 76db29edb..a4f4b4ffb 100644 --- a/src/components/settings/access/managed-profile-edit-form.tsx +++ b/src/components/settings/access/managed-profile-edit-form.tsx @@ -81,6 +81,7 @@ export function ManagedProfileEditForm({ if (profile.isError) { return ( void profile.refetch()} /> diff --git a/src/components/settings/access/managed-profile-handover.tsx b/src/components/settings/access/managed-profile-handover.tsx index 204064ac2..1c27cbf82 100644 --- a/src/components/settings/access/managed-profile-handover.tsx +++ b/src/components/settings/access/managed-profile-handover.tsx @@ -88,6 +88,7 @@ export function ManagedProfileHandover({ if (status.isError) { return ( void status.refetch()} /> diff --git a/src/components/settings/access/record-activity-card.tsx b/src/components/settings/access/record-activity-card.tsx index 7d958fd28..06fe54112 100644 --- a/src/components/settings/access/record-activity-card.tsx +++ b/src/components/settings/access/record-activity-card.tsx @@ -39,7 +39,7 @@ import { */ export function RecordActivityCard() { const { t } = useTranslations(); - const { data, isLoading, isError, refetch } = useRecordActivity(); + const { data, isLoading, isError, error, refetch } = useRecordActivity(); const entries = data?.entries ?? []; @@ -88,6 +88,7 @@ export function RecordActivityCard() { )} {isError && ( void refetch()} /> diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx index 1a0e68ec0..0a1c7897a 100644 --- a/src/components/settings/ai/ai-insights-card.tsx +++ b/src/components/settings/ai/ai-insights-card.tsx @@ -26,7 +26,7 @@ import { NativeSelect } from "@/components/ui/native-select"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -71,15 +71,11 @@ export function AiInsightsCard({ data: insightsSettings, isPending: settingsPending, isError: settingsError, + error: settingsFailure, refetch: refetchSettings, } = useQuery({ queryKey: queryKeys.insightsSettings(), - queryFn: async () => { - const res = await apiFetchRaw("/api/insights/settings"); - if (!res.ok) throw new Error("insights settings read failed"); - const json = await res.json(); - return json.data as InsightsSettings; - }, + queryFn: () => apiGet("/api/insights/settings"), enabled: isAuthenticated, }); @@ -87,15 +83,11 @@ export function AiInsightsCard({ data: userProvider, isPending: providerPending, isError: providerError, + error: providerFailure, refetch: refetchProvider, } = useQuery({ queryKey: queryKeys.userAiProvider(), - queryFn: async () => { - const res = await apiFetchRaw("/api/user/ai-provider"); - if (!res.ok) throw new Error("user AI provider read failed"); - const json = await res.json(); - return json.data as UserAIProvider; - }, + queryFn: () => apiGet("/api/user/ai-provider"), enabled: isAuthenticated, }); @@ -103,15 +95,11 @@ export function AiInsightsCard({ data: chainData, isPending: chainPending, isError: chainError, + error: chainFailure, refetch: refetchChain, } = useQuery({ queryKey: queryKeys.insightsProviderChain(), - queryFn: async () => { - const res = await apiFetchRaw("/api/insights/provider-chain"); - if (!res.ok) throw new Error("insights provider chain read failed"); - const json = await res.json(); - return json.data as ProviderChainData; - }, + queryFn: () => apiGet("/api/insights/provider-chain"), enabled: isAuthenticated, }); @@ -185,7 +173,10 @@ export function AiInsightsCard({ } /> {hasReadError ? ( - + ) : readsPending ? ( ) : ( diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index 2357a16a5..737b043c0 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -440,6 +440,7 @@ function ApiTokensCard() { const { data: tokens, isError, + error, isLoading, refetch, } = useQuery({ @@ -511,7 +512,7 @@ function ApiTokensCard() { {isError ? ( // A read failure must not read as "no active tokens" on this // security surface — the §6 fall-through the token list must avoid. - refetch()} /> + refetch()} /> ) : isLoading ? (
diff --git a/src/components/settings/coach-conversations-memory-card.tsx b/src/components/settings/coach-conversations-memory-card.tsx index f2e36d724..aeacc80c0 100644 --- a/src/components/settings/coach-conversations-memory-card.tsx +++ b/src/components/settings/coach-conversations-memory-card.tsx @@ -79,7 +79,10 @@ export function CoachConversationsMemoryCard({ description={t("settings.ai.coachConversations.description")} /> {history.isError ? ( - void history.refetch()} /> + void history.refetch()} + /> ) : visible.length === 0 ? (

{history.isLoading diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index 20403a5ca..bb3b2f94e 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -326,6 +326,7 @@ export function CoachMemorySection({ {query.isError && ( query.refetch()} /> diff --git a/src/components/settings/coach-reminders-section.tsx b/src/components/settings/coach-reminders-section.tsx index 4156eac5c..e0758ada2 100644 --- a/src/components/settings/coach-reminders-section.tsx +++ b/src/components/settings/coach-reminders-section.tsx @@ -188,6 +188,7 @@ export function CoachRemindersSection({ {query.isError && ( query.refetch()} /> diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx index 6cb9c0dd4..c5c558ef3 100644 --- a/src/components/settings/integrations/shared.tsx +++ b/src/components/settings/integrations/shared.tsx @@ -10,6 +10,7 @@ import { useQuery } from "@tanstack/react-query"; import { apiGet } from "@/lib/api/api-fetch"; +import type { GoogleHealthBackfillEndedEntry } from "@/lib/google-health/backfill-progress"; import type { ConnectionHealth } from "@/lib/integrations/connection-health"; import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict"; import { queryKeys } from "@/lib/query-keys"; @@ -58,11 +59,7 @@ export interface IntegrationStatusViewModel { * Google Health: data types whose history backfill ended before the start * of their history, with where and why. */ - backfillEnded?: Array<{ - type: string; - endsAt: string; - reason: "horizon" | "no_progress"; - }>; + backfillEnded?: GoogleHealthBackfillEndedEntry[]; /** * Polar / Oura / Strava: usable credentials resolve (BYO or the shared * app). Greys out "Connect" when false. diff --git a/src/components/settings/managed-integration-status.tsx b/src/components/settings/managed-integration-status.tsx index db3736c07..22f5de00e 100644 --- a/src/components/settings/managed-integration-status.tsx +++ b/src/components/settings/managed-integration-status.tsx @@ -73,7 +73,12 @@ export function ManagedIntegrationStatus() { }); if (statusQuery.isError) { - return void statusQuery.refetch()} />; + return ( + void statusQuery.refetch()} + /> + ); } const loading = statusQuery.isLoading || !statusQuery.data; diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index 0ea5420fd..f67c2790e 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -1115,7 +1115,12 @@ export function ManagedRecordSettingsSection({ }); if (query.isError) { - return void query.refetch()} />; + return ( + void query.refetch()} + /> + ); } return ( diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index fdffc8ad1..40b90787d 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -93,6 +93,7 @@ function McpConnectionsCard() { const { data: connections, isError, + error, isLoading, refetch, } = useQuery({ @@ -134,7 +135,7 @@ function McpConnectionsCard() {

{isError ? ( - refetch()} /> + refetch()} /> ) : isLoading ? ( ) : list.length === 0 ? ( @@ -285,6 +286,7 @@ function McpTokensCard() { const { data: tokens, isError, + error, isLoading, refetch, } = useQuery({ @@ -457,7 +459,7 @@ function McpTokensCard() { {t("settings.activeTokensTitle")}

{isError ? ( - refetch()} /> + refetch()} /> ) : isLoading ? ( ) : activeTokens.length === 0 ? ( diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx index 5987c6574..985e771ff 100644 --- a/src/components/settings/score-section.tsx +++ b/src/components/settings/score-section.tsx @@ -354,6 +354,7 @@ export function ScoreSection() { {configQuery.isError ? ( void configQuery.refetch()} /> @@ -444,6 +445,7 @@ export function ScoreSection() { {analyticsQuery.isError ? ( void analyticsQuery.refetch()} diff --git a/src/components/settings/security-activity-card.tsx b/src/components/settings/security-activity-card.tsx index 24bbd937b..58d17011e 100644 --- a/src/components/settings/security-activity-card.tsx +++ b/src/components/settings/security-activity-card.tsx @@ -68,7 +68,7 @@ export function SecurityActivityCard({ const [open, setOpen] = useState(false); const regionId = useId(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.securityActivity(), queryFn: () => apiGet<{ events: ActivityRow[] }>("/api/auth/me/security-activity"), @@ -110,6 +110,7 @@ export function SecurityActivityCard({ {isError && ( refetch()} /> diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx index 319a209d8..d2059b6db 100644 --- a/src/components/settings/security-sessions-card.tsx +++ b/src/components/settings/security-sessions-card.tsx @@ -76,7 +76,7 @@ export function SecuritySessionsCard({ const [keptGrants, setKeptGrants] = useState([]); const revokeGrant = useRevokeGrant(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.sessions(), queryFn: () => apiGet<{ sessions: SessionRow[] }>("/api/auth/me/sessions"), enabled: isAuthenticated, @@ -165,6 +165,7 @@ export function SecuritySessionsCard({ {isError && ( refetch()} /> diff --git a/src/components/settings/share-document-picker.tsx b/src/components/settings/share-document-picker.tsx index 70509f8d5..a00495378 100644 --- a/src/components/settings/share-document-picker.tsx +++ b/src/components/settings/share-document-picker.tsx @@ -154,6 +154,7 @@ export function ShareDocumentPicker({
) : list.isError ? ( list.refetch()} /> diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx index 825a2604a..7b3574d06 100644 --- a/src/components/settings/sharing-section.tsx +++ b/src/components/settings/sharing-section.tsx @@ -111,6 +111,7 @@ function ShareLinksCard() { const { data: links, isError, + error, isLoading, refetch, } = useQuery({ @@ -168,7 +169,7 @@ function ShareLinksCard() { {isError ? ( // A read failure must not read as "no active links" on a clinical // sharing surface — that is the §6 fall-through this card must avoid. - refetch()} /> + refetch()} /> ) : isLoading ? (
diff --git a/src/components/settings/trusted-devices-card.tsx b/src/components/settings/trusted-devices-card.tsx index 113a3f7fb..093f0de21 100644 --- a/src/components/settings/trusted-devices-card.tsx +++ b/src/components/settings/trusted-devices-card.tsx @@ -40,7 +40,7 @@ export function TrustedDevicesCard({ const queryClient = useQueryClient(); const [status, setStatus] = useState(null); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.trustedDevices(), queryFn: () => apiGet<{ devices: TrustedDeviceRow[] }>("/api/auth/me/trusted-devices"), @@ -105,6 +105,7 @@ export function TrustedDevicesCard({ {isError && ( refetch()} /> diff --git a/src/components/symptoms/episode-symptoms-card.tsx b/src/components/symptoms/episode-symptoms-card.tsx index b81d081f6..aa62c8988 100644 --- a/src/components/symptoms/episode-symptoms-card.tsx +++ b/src/components/symptoms/episode-symptoms-card.tsx @@ -61,6 +61,7 @@ export function EpisodeSymptomsCard({ episode }: { episode: { id: string } }) { if (definitions.isError || filed.isError) { return ( { void definitions.refetch(); diff --git a/src/components/symptoms/symptom-entry-form.tsx b/src/components/symptoms/symptom-entry-form.tsx index 3b8296bf7..1d534caf8 100644 --- a/src/components/symptoms/symptom-entry-form.tsx +++ b/src/components/symptoms/symptom-entry-form.tsx @@ -222,6 +222,7 @@ export function SymptomEntryForm({ // A failed read is not an empty list: the empty hint would invite // the person to define a symptom they may already have. void definitions.refetch()} /> diff --git a/src/components/symptoms/symptoms-section.tsx b/src/components/symptoms/symptoms-section.tsx index 2f11d5be1..4c64ee22a 100644 --- a/src/components/symptoms/symptoms-section.tsx +++ b/src/components/symptoms/symptoms-section.tsx @@ -390,6 +390,7 @@ export function SymptomsSection() { if (definitions.isError || events.isError) { return ( { void definitions.refetch(); diff --git a/src/components/timeline/readiness-sheet.tsx b/src/components/timeline/readiness-sheet.tsx index 7a7a51071..a63469e9f 100644 --- a/src/components/timeline/readiness-sheet.tsx +++ b/src/components/timeline/readiness-sheet.tsx @@ -76,6 +76,7 @@ export function TimelineReadinessSheet({
) : readiness.isError ? ( void readiness.refetch()} /> diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx index feeb92d60..0f6925265 100644 --- a/src/components/timeline/timeline-view.tsx +++ b/src/components/timeline/timeline-view.tsx @@ -464,6 +464,7 @@ export function TimelineView() { ) : timeline.isError ? ( void timeline.refetch()} /> diff --git a/src/components/ui/__tests__/query-error-primitives.test.tsx b/src/components/ui/__tests__/query-error-primitives.test.tsx new file mode 100644 index 000000000..3edb66fa3 --- /dev/null +++ b/src/components/ui/__tests__/query-error-primitives.test.tsx @@ -0,0 +1,104 @@ +/** + * A failed read is reported by what the failure means, and both read-failure + * shapes decide it the same way from the query's error: + * + * - 403: the viewer may not read this. Nothing a retry could fix; the surface + * draws its refused state, or nothing. + * - 429: the read budget is spent. A note, never a retry that would only be + * refused again. + * - 401: the session ended. Never a silent blank: the reader is offered the + * way back to sign-in. + * - anything else: a real failure, with a retry. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; + +import { ApiError } from "@/lib/api/api-fetch"; +import { readFailureKind } from "@/lib/api/read-failure"; +import { I18nProvider } from "@/lib/i18n/context"; +import { QueryErrorCard } from "../query-error-card"; +import { QueryErrorRow } from "../query-error-row"; + +function ssr(node: ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const row = (error: unknown, refused?: ReactNode) => + ssr( + {}} + slot="probe" + />, + ); + +const card = (error: unknown, refused?: ReactNode) => + ssr( + {}} + />, + ); + +describe("readFailureKind", () => { + it("names an ended session, a refusal, a spent budget and the rest", () => { + expect(readFailureKind(new ApiError("x", 401))).toBe("unauthenticated"); + expect(readFailureKind(new ApiError("x", 403))).toBe("refused"); + expect(readFailureKind(new ApiError("x", 429))).toBe("throttled"); + expect(readFailureKind(new ApiError("x", 500))).toBe("failed"); + expect(readFailureKind(new ApiError("x", 404))).toBe("failed"); + expect(readFailureKind(new TypeError("Failed to fetch"))).toBe("failed"); + expect(readFailureKind(null)).toBe("failed"); + }); +}); + +describe.each([ + ["", row], + ["", card], +])("%s", (_name, render) => { + it("draws nothing for a refused read, or the surface's refused state", () => { + expect(render(new ApiError("Forbidden", 403))).toBe(""); + expect( + render( + new ApiError("Forbidden", 403), +

Not shared with you.

, + ), + ).toBe('

Not shared with you.

'); + }); + + it("says a spent budget without offering a retry", () => { + const html = render(new ApiError("Too many", 429)); + expect(html).toContain("Too many requests just now."); + expect(html).not.toContain("retry"); + expect(html).not.toContain("Could not load"); + expect(html).not.toContain("Something on our side."); + }); + + it("offers sign-in for an ended session, never a blank", () => { + const html = render(new ApiError("Unauthorized", 401)); + expect(html).not.toBe(""); + expect(html).toContain("Your session has ended."); + expect(html).toContain('data-slot="query-error-sign-in"'); + expect(html).not.toContain("retry"); + }); + + it("offers a retry for a real failure", () => { + for (const error of [ + new ApiError("Boom", 500), + new TypeError("Failed to fetch"), + ]) { + const html = render(error); + expect(html).toContain("Could not load the"); + expect(html).toMatch(/data-slot="query-error(-row)?-retry"/); + expect(html).not.toContain("query-error-sign-in"); + } + }); +}); diff --git a/src/components/ui/__tests__/query-error-row.test.tsx b/src/components/ui/__tests__/query-error-row.test.tsx index 6e30b88dc..723f9ef33 100644 --- a/src/components/ui/__tests__/query-error-row.test.tsx +++ b/src/components/ui/__tests__/query-error-row.test.tsx @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { ApiError } from "@/lib/api/api-fetch"; import { I18nProvider } from "@/lib/i18n/context"; import { QueryErrorRow } from "../query-error-row"; +/** A real read failure; the status-specific shapes live in query-error-primitives. */ +const failed = new ApiError("Boom", 500); + function ssr(node: React.ReactNode) { return renderToStaticMarkup( {node}, @@ -12,7 +16,9 @@ function ssr(node: React.ReactNode) { describe("", () => { it("renders the compact alert row with foreground message text", () => { - const html = ssr(); + const html = ssr( + , + ); expect(html).toContain('role="alert"'); expect(html).toContain('data-slot="query-error-row"'); expect(html).toContain("Could not load vitals"); @@ -25,13 +31,17 @@ describe("", () => { }); it("falls back to the generic load-failed copy", () => { - expect(ssr()).toContain("Could not load"); + expect(ssr()).toContain("Could not load"); }); it("only renders the retry affordance when a handler is wired", () => { - expect(ssr()).not.toContain(")).not.toContain( + " {}} />); + const withRetry = ssr( + {}} />, + ); expect(withRetry).toContain("", () => { it("honours the slot overrides the converted surfaces depend on", () => { const html = ssr( {}} slot="healthkit-metric-error" @@ -54,7 +65,12 @@ describe("", () => { it("takes a custom retry label", () => { expect( ssr( - {}} retryLabel="Again" />, + {}} + retryLabel="Again" + />, ), ).toContain("Again"); }); diff --git a/src/components/ui/__tests__/read-failure-row.test.tsx b/src/components/ui/__tests__/read-failure-row.test.tsx deleted file mode 100644 index f24470030..000000000 --- a/src/components/ui/__tests__/read-failure-row.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/** - * A supplementary read that fails is reported by what the failure means. - * - * Several strips and tiles answer a failed read with a retry row. Two - * failures are not worth one: a refusal (a read-only share reaching a - * manage-level read, a grant without the section) cannot be retried into - * success and must draw nothing, and a spent read budget would only be - * refused again by an immediate retry. - */ -import { describe, expect, it } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { ApiError } from "@/lib/api/api-fetch"; -import { I18nProvider } from "@/lib/i18n/context"; -import { ReadFailureRow, readFailureKind } from "../read-failure-row"; - -function ssr(error: unknown) { - return renderToStaticMarkup( - - {}} - slot="probe" - /> - , - ); -} - -describe("readFailureKind", () => { - it("names refusals, a spent budget and everything else", () => { - expect(readFailureKind(new ApiError("x", 401))).toBe("denied"); - expect(readFailureKind(new ApiError("x", 403))).toBe("denied"); - expect(readFailureKind(new ApiError("x", 429))).toBe("throttled"); - expect(readFailureKind(new ApiError("x", 500))).toBe("failed"); - expect(readFailureKind(new ApiError("x", 404))).toBe("failed"); - expect(readFailureKind(new TypeError("Failed to fetch"))).toBe("failed"); - }); -}); - -describe("", () => { - it("draws nothing for a refused read", () => { - expect(ssr(new ApiError("Forbidden", 403))).toBe(""); - expect(ssr(new ApiError("Unauthorized", 401))).toBe(""); - }); - - it("says a spent budget without offering a retry", () => { - const html = ssr(new ApiError("Too many", 429)); - expect(html).toContain('data-slot="probe"'); - expect(html).toContain("Too many requests just now."); - expect(html).not.toContain("query-error-row-retry"); - }); - - it("offers a retry for a real failure", () => { - const html = ssr(new ApiError("Boom", 500)); - expect(html).toContain("Could not load the strip."); - expect(html).toContain('data-slot="query-error-row-retry"'); - expect(ssr(new TypeError("Failed to fetch"))).toContain( - 'data-slot="query-error-row-retry"', - ); - }); -}); diff --git a/src/components/ui/query-error-card.tsx b/src/components/ui/query-error-card.tsx index 0104b260b..8177e297c 100644 --- a/src/components/ui/query-error-card.tsx +++ b/src/components/ui/query-error-card.tsx @@ -5,7 +5,7 @@ import { AlertTriangle, RotateCw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { useTranslations } from "@/lib/i18n/context"; +import { useReadFailure } from "@/hooks/use-read-failure"; import { cn } from "@/lib/utils"; /** @@ -18,21 +18,37 @@ import { cn } from "@/lib/utils"; * the generic `common.*` strings so a caller can drop it in with just * `onRetry`; a surface with its own "couldn't load X" string passes it through * `title` / `description`. + * + * The card reads the failure itself (`error`), exactly as `QueryErrorRow` + * does: a refusal draws `refused` (nothing by default), a spent budget says + * so without a retry, an ended session offers sign-in, and only a real + * failure shows `title` / `description` with `onRetry`. */ export function QueryErrorCard({ + error, + refused = null, title, description, onRetry, retryLabel, className, }: { + /** The query's error. It decides what the card offers. */ + error: unknown; + /** What a refused (403) read draws instead of the card. */ + refused?: ReactNode; title?: ReactNode; description?: ReactNode; onRetry?: () => void; retryLabel?: string; className?: string; }) { - const { t } = useTranslations(); + const failure = useReadFailure(error, { message: title, onRetry }); + if (!failure) return <>{refused}; + const { action } = failure; + // The description belongs to a real failure; a spent budget or an ended + // session replaces the title, and the surface's own detail would not fit. + const detail = failure.kind === "failed" ? description : null; return ( diff --git a/src/components/ui/query-error-row.tsx b/src/components/ui/query-error-row.tsx index d278c6c25..38521dfd4 100644 --- a/src/components/ui/query-error-row.tsx +++ b/src/components/ui/query-error-row.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from "react"; import { AlertTriangle, RotateCw } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { useTranslations } from "@/lib/i18n/context"; +import { useReadFailure } from "@/hooks/use-read-failure"; import { cn } from "@/lib/utils"; /** @@ -23,11 +23,16 @@ import { cn } from "@/lib/utils"; * `QueryErrorRow` * - chart body → `ChartErrorState` * - * One deliberate change from the copies it replaces: the message renders in - * `text-foreground`. An alert IS the content of a failed surface, and §3 - * reserves muted for meta — both copies had it muted. + * The row reads the failure itself (`error`): a refusal draws `refused` + * (nothing by default), a spent budget says so without a retry, an ended + * session offers sign-in, and only a real failure offers `onRetry`. + * + * The message renders in `text-foreground`: an alert IS the content of a + * failed surface, and §3 reserves muted for meta. */ export function QueryErrorRow({ + error, + refused = null, message, onRetry, retryLabel, @@ -35,7 +40,11 @@ export function QueryErrorRow({ retrySlot, className, }: { - /** The failure line. Defaults to the generic `common.loadFailed`. */ + /** The query's error. It decides what the row offers. */ + error: unknown; + /** What a refused (403) read draws instead of the row. */ + refused?: ReactNode; + /** The line for a real failure. Defaults to `common.loadFailed`. */ message?: ReactNode; onRetry?: () => void; retryLabel?: string; @@ -49,7 +58,9 @@ export function QueryErrorRow({ retrySlot?: string; className?: string; }) { - const { t } = useTranslations(); + const failure = useReadFailure(error, { message, onRetry }); + if (!failure) return <>{refused}; + const { action } = failure; return ( diff --git a/src/components/ui/read-failure-row.tsx b/src/components/ui/read-failure-row.tsx deleted file mode 100644 index 7d6ab945d..000000000 --- a/src/components/ui/read-failure-row.tsx +++ /dev/null @@ -1,68 +0,0 @@ -"use client"; - -import { QueryErrorRow } from "@/components/ui/query-error-row"; -import { ApiError } from "@/lib/api/api-fetch"; -import { useTranslations } from "@/lib/i18n/context"; - -/** - * What a failed read means for the surface that made it. - * - * - `denied`: 401 / 403. The viewer may not read this, typically a shared - * or delegated record whose grant leaves the section out. Nothing failed - * that a retry could fix, and the surface is simply not theirs to see. - * - `throttled`: 429. The read budget is spent for now; an immediate retry - * would only be refused again. - * - `failed`: everything else, a real read failure worth a retry. - */ -export type ReadFailureKind = "denied" | "throttled" | "failed"; - -export function readFailureKind(error: unknown): ReadFailureKind { - if (error instanceof ApiError) { - if (error.status === 401 || error.status === 403) return "denied"; - if (error.status === 429) return "throttled"; - } - return "failed"; -} - -/** - * The compact failure row for a supplementary read, by what the failure - * means: nothing for a refusal, a notice without a retry for a spent budget, - * and `QueryErrorRow` with a retry for a real failure. - * - * For a surface the page can stand without (a strip, a tile, a block under a - * card). A primary list keeps `QueryErrorCard`. - */ -export function ReadFailureRow({ - error, - message, - onRetry, - slot, - className, -}: { - error: unknown; - message?: string; - onRetry: () => void; - slot?: string; - className?: string; -}) { - const { t } = useTranslations(); - const kind = readFailureKind(error); - if (kind === "denied") return null; - if (kind === "throttled") { - return ( - - ); - } - return ( - - ); -} diff --git a/src/components/vaccinations/vaccinations-view.tsx b/src/components/vaccinations/vaccinations-view.tsx index af9c4ced8..bf0900b48 100644 --- a/src/components/vaccinations/vaccinations-view.tsx +++ b/src/components/vaccinations/vaccinations-view.tsx @@ -48,7 +48,7 @@ export function VaccinationsView() { // measurements section rather than this one. A grant that opened only the // health background is refused there, so the offer is not made. const canPlanBooster = canWriteDomain("measurements"); - const { data, isLoading, isError, refetch } = useVaccinations(); + const { data, isLoading, isError, error, refetch } = useVaccinations(); const records = data?.vaccinations ?? []; // v1.42 (#1005) — the record's own definitions, listed below the log once // there is one. A failed read leaves the section out rather than claiming @@ -139,6 +139,7 @@ export function VaccinationsView() { // A read failure is not an empty Impfpass — surface the error + Retry so // an outage never reads as "nothing logged yet". void refetch()} /> diff --git a/src/hooks/use-coach-message-results.ts b/src/hooks/use-coach-message-results.ts index 16ab0532d..d6a42ed7d 100644 --- a/src/hooks/use-coach-message-results.ts +++ b/src/hooks/use-coach-message-results.ts @@ -26,6 +26,8 @@ export function useCoachMessageResults(args: { results: CoachResultEntry[] | undefined; isLoading: boolean; isError: boolean; + /** The failed read's error, for the error row to read. */ + error: unknown; /** Read again after a failure. */ refetch: () => void; } { @@ -72,6 +74,7 @@ export function useCoachMessageResults(args: { results: query.data, isLoading: query.isLoading, isError: query.isError, + error: query.error, refetch: () => void query.refetch(), }; } diff --git a/src/hooks/use-mood-day.ts b/src/hooks/use-mood-day.ts index f43c19e74..8106fa5d1 100644 --- a/src/hooks/use-mood-day.ts +++ b/src/hooks/use-mood-day.ts @@ -34,6 +34,8 @@ export interface UseMoodDayResult { * carried no entry. */ entries: MoodDayEntry[] | undefined; isError: boolean; + /** The failed read's error, for the error row to read. */ + error: unknown; refetch: () => void; } @@ -48,7 +50,7 @@ export function useMoodDay( date: string | undefined, enabled: boolean, ): UseMoodDayResult { - const { data, isError, refetch } = useQuery({ + const { data, isError, error, refetch } = useQuery({ queryKey: queryKeys.moodEntriesDay(date ?? ""), queryFn: () => { const params = new URLSearchParams({ @@ -66,6 +68,7 @@ export function useMoodDay( return { entries: data?.entries, isError, + error, refetch: () => void refetch(), }; } diff --git a/src/hooks/use-read-failure.ts b/src/hooks/use-read-failure.ts new file mode 100644 index 000000000..57800b410 --- /dev/null +++ b/src/hooks/use-read-failure.ts @@ -0,0 +1,62 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { + readFailureKind, + signInHref, + type ReadFailureKind, +} from "@/lib/api/read-failure"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * What a read-failure primitive shows for one failed read: `null` for a + * refusal (the caller's refused state stands in), otherwise the line and the + * one action that can help. A real failure offers the caller's retry, an + * ended session offers sign-in, a spent budget offers nothing. + * + * Shared by `QueryErrorRow` and `QueryErrorCard` so both shapes branch on + * the failure the same way; a call site never decides it. + */ +export function useReadFailure( + error: unknown, + { message, onRetry }: { message?: ReactNode; onRetry?: () => void }, +): { + kind: Exclude; + message: ReactNode; + action: { + label: string; + onClick: () => void; + kind: "retry" | "signIn"; + } | null; +} | null { + const { t } = useTranslations(); + switch (readFailureKind(error)) { + case "refused": + return null; + case "throttled": + return { + kind: "throttled", + message: t("common.readThrottled"), + action: null, + }; + case "unauthenticated": + return { + kind: "unauthenticated", + message: t("common.sessionEnded"), + action: { + label: t("common.signInAgain"), + onClick: () => window.location.assign(signInHref()), + kind: "signIn", + }, + }; + case "failed": + return { + kind: "failed", + message: message ?? t("common.loadFailed"), + action: onRetry + ? { label: t("common.retry"), onClick: onRetry, kind: "retry" } + : null, + }; + } +} diff --git a/src/hooks/use-sleep-night.ts b/src/hooks/use-sleep-night.ts index cf5837aa0..f32b3d6ab 100644 --- a/src/hooks/use-sleep-night.ts +++ b/src/hooks/use-sleep-night.ts @@ -52,6 +52,8 @@ export interface UseSleepNightResult { * night that carried nothing. */ data: SleepNightPayload | undefined; isError: boolean; + /** The failed read's error, for the error row to read. */ + error: unknown; refetch: () => void; } @@ -65,7 +67,7 @@ export function useSleepNight( date: string | undefined, enabled: boolean, ): UseSleepNightResult { - const { data, isError, refetch } = useQuery({ + const { data, isError, error, refetch } = useQuery({ queryKey: queryKeys.sleepNight(date), queryFn: () => apiGet( @@ -76,5 +78,5 @@ export function useSleepNight( refetchOnWindowFocus: false, }); - return { data, isError, refetch: () => void refetch() }; + return { data, isError, error, refetch: () => void refetch() }; } diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts index 153f4db6b..ea621035c 100644 --- a/src/lib/ai/coach/__tests__/correlations-read.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -250,8 +250,9 @@ describe("readCoachCorrelations", () => { // Next-day sleep falls as today's burden rises (8h baseline − burden). measurements.push({ type: "SLEEP_DURATION", - value: 8 - burden, - measuredAt: new Date(base + (i + 1) * 86_400_000), + // A night of hours waking at 07:00, not a midday minute-long nap. + value: (8 - burden) * 60, + measuredAt: new Date(base + (i + 1) * 86_400_000 - 5 * 3_600_000), }); } @@ -346,8 +347,9 @@ describe("readCoachCorrelations", () => { // Higher pressure today → shorter sleep tomorrow (a clean negative r). measurements.push({ type: "SLEEP_DURATION", - value: 8 - swing * 0.5, - measuredAt: new Date(base + (i + 1) * 86_400_000), + // A night of hours waking at 07:00, not a midday minute-long nap. + value: (8 - swing * 0.5) * 60, + measuredAt: new Date(base + (i + 1) * 86_400_000 - 5 * 3_600_000), }); } diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index 4f4bce2c3..f78c4cc5f 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -4,6 +4,8 @@ import { reconstructSleepNights, reconstructSleepSessions, pickMainNightAndNaps, + isDaytimeNap, + separateNightsAndNaps, summarizeSleepNights, type SleepStageRow, } from "@/lib/analytics/sleep-night"; @@ -594,6 +596,80 @@ describe("pickMainNightAndNaps", () => { }); }); +describe("the one nap rule", () => { + it("tells a nap from a night by length and time of day together", () => { + // Short and in the afternoon: a nap. + expect(isDaytimeNap(60, 14 * 60)).toBe(true); + // Short but at night: the night. + expect(isDaytimeNap(150, 2 * 60)).toBe(false); + expect(isDaytimeNap(150, 22 * 60)).toBe(false); + // A late sleeper's short night, centred at 09:00. + expect(isDaytimeNap(150, 9 * 60)).toBe(false); + // Long in the daytime (after a night shift): a main sleep. + expect(isDaytimeNap(420, 13 * 60)).toBe(false); + }); + + it("gives a day of naps alone no main night", () => { + // 14:00 to 15:00 Berlin, nothing overnight (the watch was charging). + const rows: SleepStageRow[] = [row("2026-06-04T13:00:00.000Z", "CORE", 60)]; + const sessions = reconstructSleepSessions(rows, "Europe/Berlin"); + const { main, naps } = pickMainNightAndNaps(sessions); + expect(main).toBeNull(); + expect(naps).toHaveLength(1); + expect(naps[0].daytimeNap).toBe(true); + const nights = reconstructSleepNights(rows, "Europe/Berlin"); + expect(nights[0].asleepMinutes).toBe(60); + expect(nights[0].mainSession).toBeNull(); + expect(separateNightsAndNaps(sessions)).toEqual([ + { night: "2026-06-04", main: null, naps }, + ]); + }); + + it("keeps a short night as the main sleep over a longer afternoon nap", () => { + const rows: SleepStageRow[] = [ + // 02:00 to 03:30 Berlin: a broken night, 90 minutes. + row("2026-06-04T01:30:00.000Z", "CORE", 90), + // 13:00 to 15:00 Berlin: a two-hour nap. + row("2026-06-04T13:00:00.000Z", "CORE", 120), + ]; + const sessions = reconstructSleepSessions(rows, "Europe/Berlin"); + const { main, naps } = pickMainNightAndNaps(sessions); + expect(main?.asleepMinutes).toBe(90); + expect(naps.map((n) => n.asleepMinutes)).toEqual([120]); + // The merged night places the same session on the clock. + const [night] = reconstructSleepNights(rows, "Europe/Berlin"); + expect(night.mainSession?.asleepMinutes).toBe(90); + expect(night.mainSession?.end.getTime()).toBe(main?.end.getTime()); + }); + + it("files a session under the day its last row ends on, over every writer", () => { + // The watch stops at 23:50 UTC; the phone's in-bed span runs to 00:10. + const rows: SleepStageRow[] = [ + srcRow("2026-06-03T23:50:00.000Z", "CORE", 170, "WHOOP"), + srcRow("2026-06-04T00:10:00.000Z", "IN_BED", 200, "APPLE_HEALTH"), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + const nights = reconstructSleepNights(rows, "UTC"); + expect(sessions.map((s) => s.night)).toEqual(["2026-06-04"]); + expect(nights.map((n) => n.night)).toEqual(["2026-06-04"]); + }); +}); + +describe("a zero-minute granular row", () => { + it("does not strip the bare aggregate that carries the night", () => { + // A writer's bare 6-hour night beside a REM row of no length: the REM + // row partitions nothing, so the night is the aggregate's six hours. + const rows: SleepStageRow[] = [ + row("2026-06-04T06:00:00.000Z", "ASLEEP", 360), + row("2026-06-04T06:00:00.000Z", "REM", 0), + ]; + const [session] = reconstructSleepSessions(rows, "UTC"); + expect(session.asleepMinutes).toBe(360); + expect(session.stages.ASLEEP).toBe(360); + expect(reconstructSleepNights(rows, "UTC")[0].asleepMinutes).toBe(360); + }); +}); + describe("summarizeSleepNights", () => { it("summarises per-night totals, not per-stage rows", () => { const rows: SleepStageRow[] = [ diff --git a/src/lib/analytics/score/__tests__/pillars.test.ts b/src/lib/analytics/score/__tests__/pillars.test.ts index 939558c0d..8e2363b1d 100644 --- a/src/lib/analytics/score/__tests__/pillars.test.ts +++ b/src/lib/analytics/score/__tests__/pillars.test.ts @@ -373,6 +373,38 @@ describe("reference-score pillars", () => { } }); + it("holds regularity back until 21 nights carry a midpoint, not 21 nights", () => { + const nights = days(24, 480).map((point, index) => ({ + night: point.day, + asleepMinutes: point.value, + // Four nap-only days count toward the level but have no place on the + // clock, which leaves 20 midpoints. + midpoint: index % 6 === 0 ? null : 180, + })); + const result = computeSleepPillar({ + ...live, + asOf: NOW, + ageYears: 40, + nights, + sources: ["APPLE_HEALTH"], + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.observed.label).not.toContain("regularity"); + } + const enough = computeSleepPillar({ + ...live, + asOf: NOW, + ageYears: 40, + nights: nights.map((n) => ({ ...n, midpoint: 180 })), + sources: ["APPLE_HEALTH"], + }); + if (enough.status === "ok") { + expect(enough.value.observed.label).toContain("regularity"); + } + expect(enough.status).toBe("ok"); + }); + it("scores exactly 28 sleep calendar keys", () => { const nights = days(29, 600).map((point) => ({ night: point.day, diff --git a/src/lib/analytics/score/sleep.ts b/src/lib/analytics/score/sleep.ts index bebc81462..2a36debd0 100644 --- a/src/lib/analytics/score/sleep.ts +++ b/src/lib/analytics/score/sleep.ts @@ -62,8 +62,11 @@ export function computeSleepPillar( const midpoints = nights .map((night) => night.midpoint) .filter((midpoint): midpoint is number => midpoint != null); + // The floor is on the nights that have a place on the clock: a day whose + // only sleep was a nap counts toward the level but carries no midpoint, so + // 21 nights could hold far fewer midpoints than the floor asks for. const regularity = - nights.length >= SLEEP_REGULARITY_MIN_NIGHTS + midpoints.length >= SLEEP_REGULARITY_MIN_NIGHTS ? scoreConsistency(midpoints) : null; // These are the existing Sleep Score's own declared sub-score weights, diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index ad1587147..732424e68 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -48,7 +48,7 @@ * measured on the absolute UTC instant, so it is immune to DST shifts. * * Each session is keyed by the LOCAL WAKE DAY — `userDayKey` of the session's - * LAST segment (its end instant) in the user's timezone. This matches the + * LAST row end over every writer, in the user's timezone. This matches the * Apple Health convention (a sleep session is attributed to the date you wake * up) and keeps the keying tz/DST-correct because it runs on the real instant. * @@ -58,9 +58,12 @@ * day under one key so the headline "time asleep" for a day is the day's * total. `reconstructSleepSessions` keeps each session SEPARATE, and * `pickMainNightAndNaps` applies the convention the UI needs: the MAIN night - * is the session with the most asleep minutes (normally the overnight block), - * and every other session on the same wake day is a NAP, surfaced separately - * and never folded into the main night's headline. + * is the main sleep with the most asleep minutes (normally the overnight + * block), and every other session on the same wake day is a NAP, surfaced + * separately and never folded into the main night's headline. A session is + * a nap rather than a main sleep when it is short and centred in the daytime + * (`isDaytimeNap`), so a day with an afternoon nap and no recorded night has + * no main night at all, rather than the nap standing in for one. * * Writer de-dup * ------------- @@ -127,6 +130,7 @@ */ import type { MeasurementSource, SleepStage } from "@/generated/prisma/client"; import { userDayKey } from "@/lib/tz/format"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; import { summarize, type DataSummary } from "@/lib/analytics/trends"; import { getDeviceTypeLadder, @@ -151,6 +155,17 @@ const GRANULAR_ASLEEP_STAGES: ReadonlySet = new Set([ "DEEP", ]); +/** + * Every stage that counts as asleep: the granular partition plus the bare + * `ASLEEP` aggregate. A stage-less row counts as asleep too, but it has no + * stage to list. The sleep list's SQL session cut splices this list, so the + * two decide "does this session hold sleep?" from one definition. + */ +export const ASLEEP_STAGES: readonly SleepStage[] = [ + "ASLEEP", + ...GRANULAR_ASLEEP_STAGES, +]; + /** * True when any row in the set carries a GRANULAR asleep stage * (CORE / DEEP / REM). When this holds, the bare `ASLEEP` aggregate (and any @@ -162,7 +177,10 @@ const GRANULAR_ASLEEP_STAGES: ReadonlySet = new Set([ function sawGranularStage(rows: readonly SleepStageRow[]): boolean { for (const r of rows) { const stage = r.sleepStage; - if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) return true; + // A zero-minute granular row partitions nothing: it must not strip the + // bare aggregate that does carry the night. + if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage) && r.value > 0) + return true; } return false; } @@ -199,7 +217,7 @@ function asleepMinutesOf(rows: readonly SleepStageRow[]): number { const stage = seg.stage; if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) { granular += seg.minutes; - sawGranular = true; + if (seg.minutes > 0) sawGranular = true; } else if (stage === "ASLEEP" || stage == null) { // Bare unspecified aggregate (`ASLEEP`) or a stage-less legacy/manual // row — only used when no granular stage carries the night. @@ -258,6 +276,70 @@ function inBedEnvelopeMinutes(rows: readonly SleepStageRow[]): number | null { */ export const SESSION_GAP_MS = 3 * 60 * 60 * 1000; +/** + * Below this much asleep time a session can be a nap. Three hours is where + * wearables draw the line between a nap and a main sleep (Oura types a + * period `long_sleep` from three hours on). + */ +export const MAIN_SLEEP_MIN_MINUTES = 180; + +/** + * The night window, in local minutes-of-day: a session whose midpoint falls + * from 20:00 to 10:00 is night sleep however short it is. Wide enough for a + * late chronotype (asleep 04:00 to 12:00 has its midpoint at 08:00) and for + * an early one, and clear of the afternoon where naps sit. + */ +export const NIGHT_WINDOW_START_MINUTES = 20 * 60; +export const NIGHT_WINDOW_END_MINUTES = 10 * 60; + +/** + * True for a session that is a nap rather than a main sleep: short (under + * `MAIN_SLEEP_MIN_MINUTES` asleep) AND centred in the daytime. A short night + * is still the night (insomnia, a newborn, split sleep); a long daytime sleep + * (after a night shift) is still a main sleep. The one nap rule: the nap + * convention, the sleep score's midpoint and every list read it. Pure. + */ +export function isDaytimeNap( + asleepMinutes: number, + midpointMinutes: number, +): boolean { + if (asleepMinutes >= MAIN_SLEEP_MIN_MINUTES) return false; + const atNight = + midpointMinutes >= NIGHT_WINDOW_START_MINUTES || + midpointMinutes < NIGHT_WINDOW_END_MINUTES; + return !atNight; +} + +/** + * The centre of a session's asleep span as local minutes-of-day: its wake + * instant minus half its asleep minutes, read on the sleeper's wall clock. + */ +export function asleepMidpointMinutes( + end: Date, + asleepMinutes: number, + tz: string, +): number { + const mid = new Date(end.getTime() - (asleepMinutes / 2) * 60_000); + if (tz === "UTC") return mid.getUTCHours() * 60 + mid.getUTCMinutes(); + const { hour, minute } = wallClockInTz(mid, tz); + return hour * 60 + minute; +} + +/** + * The wake day a session is filed under: the local day its last row ends on, + * over every writer in the session. One rule for every reader, the SQL page + * cut of the sleep list included, which sees every writer and cannot know the + * winning one. Filing by the winner alone named a different day whenever a + * losing writer's in-bed span ran past midnight after the winner stopped, and + * the list and the night view then disagreed about which day the night was. + */ +function wakeDayOf(rows: readonly SleepStageRow[], tz: string): string { + let end = rows[0].measuredAt; + for (const r of rows) + if (r.measuredAt.getTime() > end.getTime()) end = r.measuredAt; + return userDayKey(end, tz); +} + export interface SleepStageRow { value: number; measuredAt: Date; @@ -271,12 +353,12 @@ export interface SleepStageRow { */ source?: MeasurementSource | null; /** - * Device-type tag of the stage row (`Measurement.deviceType`, v1.4.25 - * W8c). Distinguishes WRITERS behind one source: several HealthKit apps + * Device-type tag of the stage row (`Measurement.deviceType`, v1.4.25). + * Distinguishes WRITERS behind one source: several HealthKit apps * write sleep under `source = APPLE_HEALTH` (watch granular stages, phone * coarse in-bed, vendor-app re-exports), and collapsing on source alone * blends them. Optional — callers that don't select the column (and every - * row stored before W8c) collapse per source exactly as before. + * row stored before v1.4.25) collapse per source exactly as before. */ deviceType?: string | null; } @@ -309,9 +391,9 @@ export interface SleepNight { stages: Partial>; /** * Non-null when two writer buckets reported clearly different asleep - * totals for the night's MAIN session — the same-wake-day session with + * totals for the night's MAIN session — the same-wake-day main sleep with * the most asleep minutes, normally the overnight block (see - * `sessionSourceDiscrepancy`). Sessions never cross-compare: a nap's + * `pickMainNightAndNaps` and `sessionSourceDiscrepancy`). Sessions never cross-compare: a nap's * writers are not measured against the overnight's. Observational only — * the served `asleepMinutes` stays the winning writer's total; the * annotation lets the UI mark the number with a discreet @@ -319,12 +401,13 @@ export interface SleepNight { */ sourceDiscrepancy: SleepSourceDiscrepancy | null; /** - * The night's MAIN session alone: the same-wake-day session with the most - * asleep minutes (the one `sourceDiscrepancy` reads), its wake instant and - * its own asleep minutes. A nap on the same wake day is in the night's - * totals but not here, so a reader that places the night on the clock (the - * sleep midpoint) is not dragged into the afternoon by it. Null when no - * session carries asleep minutes. + * The night's MAIN session alone, chosen by the nap convention + * (`pickMainNightAndNaps`: the main sleep with the most asleep minutes, the + * one `sourceDiscrepancy` reads), its wake instant and its own asleep + * minutes. A nap on the same wake day is in the night's totals but not + * here, so a reader that places the night on the clock (the sleep midpoint) + * is not dragged into the afternoon by it. Null when no session carries + * asleep minutes, and on a day whose only sleep is daytime naps. */ mainSession: { end: Date; asleepMinutes: number } | null; } @@ -643,13 +726,9 @@ export function reconstructSleepNights( const canonical = pickSessionWriter(session, sleepLadder, deviceLadder); const kept = session.filter((r) => writerKeyOf(r) === canonical); const pool = kept.length > 0 ? kept : session; - // The wake day is the LATEST segment END in the session (Apple Health - // attributes a session to the morning you wake up). - const wakeInstant = pool.reduce( - (max, r) => (r.measuredAt.getTime() > max.getTime() ? r.measuredAt : max), - pool[0].measuredAt, - ); - const key = userDayKey(wakeInstant, tz); + // The wake day is the session's latest row end (Apple Health attributes + // a session to the morning you wake up), over every writer. + const key = wakeDayOf(session, tz); const list = byNight.get(key) ?? []; list.push({ pool, all: session }); byNight.set(key, list); @@ -671,9 +750,9 @@ export function reconstructSleepNights( // edits. let latest = nightSessions[0].pool[0].measuredAt; const stages: Partial> = {}; - // MAIN session of the night = most asleep minutes (same per-session - // asleep total the headline sums; tie → later wake instant, matching - // `pickMainNightAndNaps`). Its FULL raw row set (every writer, + // MAIN session of the night = the main sleep (not a daytime nap) with + // the most asleep minutes (same per-session asleep total the headline + // sums; tie → later wake instant), exactly as `pickMainNightAndNaps`. Its FULL raw row set (every writer, // pre-collapse) feeds the night's source-discrepancy annotation — // per session, so a nap's writers never cross-compare against the // overnight block's. @@ -731,6 +810,10 @@ export function reconstructSleepNights( } if ( sessionAsleep > 0 && + !isDaytimeNap( + sessionAsleep, + asleepMidpointMinutes(new Date(sessionEnd), sessionAsleep, tz), + ) && (sessionAsleep > mainSessionAsleep || (sessionAsleep === mainSessionAsleep && sessionEnd > mainSessionEnd)) ) { @@ -844,6 +927,12 @@ export interface SleepSession { * "sources disagree" hint. */ sourceDiscrepancy: SleepSourceDiscrepancy | null; + /** + * `true` when the session is a daytime nap rather than a main sleep + * (`isDaytimeNap`). Decided here, once, so the nap convention, the sleep + * score and every list read the same answer. + */ + daytimeNap: boolean; } /** Resolve a stage row to its absolute span (start = end − duration). */ @@ -1079,7 +1168,7 @@ export function reconstructSleepSessions( const asleep = asleepMinutesOf(pool); const canonicalSource = sourceOfWriterKey(canonical); sessions.push({ - night: userDayKey(latest, tz), + night: wakeDayOf(session, tz), source: canonicalSource === NO_SOURCE ? null @@ -1101,21 +1190,28 @@ export function reconstructSleepSessions( // losing bucket's disagreement is visible; the served totals above // stay winner-only. sourceDiscrepancy: sessionSourceDiscrepancy(session), + daytimeNap: isDaytimeNap( + asleep, + asleepMidpointMinutes(latest, asleep, tz), + ), }); } return sessions.sort((a, b) => a.start.getTime() - b.start.getTime()); } /** - * Apply the NAP convention to a wake-day's sessions: the MAIN night is the - * session with the most asleep minutes (normally the overnight block); every - * other session on the same wake day is a nap, surfaced separately and never - * folded into the main night's headline. + * Apply the NAP convention to a wake-day's sessions. The MAIN night is the + * main-sleep session (see `isDaytimeNap`) with the most asleep minutes, + * normally the overnight block; every other scorable session on the same wake + * day is a nap, surfaced separately and never folded into the main night's + * headline. A day whose only sleep is daytime naps has no main night: `main` + * is null and the naps are all there is. * * Returns the main session plus the naps (sorted by start). When the input * holds sessions across multiple wake days, only the sessions matching the - * main session's wake day are considered naps — call once per wake day, or - * pass a single wake day's sessions. + * main session's wake day (or, without one, the latest session's) are + * considered naps — call once per wake day, or pass a single wake day's + * sessions. */ export function pickMainNightAndNaps(sessions: readonly SleepSession[]): { main: SleepSession | null; @@ -1123,14 +1219,22 @@ export function pickMainNightAndNaps(sessions: readonly SleepSession[]): { } { const scorable = sessions.filter((s) => s.asleepMinutes > 0); if (scorable.length === 0) return { main: null, naps: [] }; - // Main = most asleep minutes; tie-break on the later end (overnight wins). - const main = [...scorable].sort((a, b) => { - if (b.asleepMinutes !== a.asleepMinutes) - return b.asleepMinutes - a.asleepMinutes; - return b.end.getTime() - a.end.getTime(); - })[0]; + // Main = most asleep minutes among the main sleeps; tie-break on the later + // end (overnight wins). + const main = + scorable + .filter((s) => !s.daytimeNap) + .sort((a, b) => { + if (b.asleepMinutes !== a.asleepMinutes) + return b.asleepMinutes - a.asleepMinutes; + return b.end.getTime() - a.end.getTime(); + })[0] ?? null; + const day = + main?.night ?? + scorable.reduce((a, b) => (b.end.getTime() > a.end.getTime() ? b : a)) + .night; const naps = scorable - .filter((s) => s !== main && s.night === main.night) + .filter((s) => s !== main && s.night === day) .sort((a, b) => a.start.getTime() - b.start.getTime()); return { main, naps }; } @@ -1139,8 +1243,11 @@ export function pickMainNightAndNaps(sessions: readonly SleepSession[]): { export interface SleepDaySplit { /** Wake-day key (YYYY-MM-DD) in the user's timezone. */ night: string; - /** The overnight block — the session with the most asleep minutes. */ - main: SleepSession; + /** + * The main sleep (see `pickMainNightAndNaps`), or null on a day whose only + * sleep is daytime naps. + */ + main: SleepSession | null; /** Every other scorable session on the same wake day, sorted by start. */ naps: SleepSession[]; } @@ -1154,8 +1261,8 @@ export interface SleepDaySplit { * than growing its own idea of a night. There is exactly one nap classifier in * this codebase and it is `pickMainNightAndNaps`; this is its multi-day form. * - * Days whose sessions carry no asleep minutes are dropped (no main night to - * report). The result is sorted ascending by wake day. + * Days whose sessions carry no asleep minutes are dropped; a day of naps + * alone is kept with `main` null. The result is sorted ascending by wake day. */ export function separateNightsAndNaps( sessions: readonly SleepSession[], @@ -1169,7 +1276,7 @@ export function separateNightsAndNaps( const days: SleepDaySplit[] = []; for (const [night, daySessions] of byDay) { const { main, naps } = pickMainNightAndNaps(daySessions); - if (!main) continue; + if (!main && naps.length === 0) continue; days.push({ night, main, naps }); } return days.sort((a, b) => a.night.localeCompare(b.night)); diff --git a/src/lib/analytics/sleep-stage-composition.ts b/src/lib/analytics/sleep-stage-composition.ts index bf9489172..9424dbcd1 100644 --- a/src/lib/analytics/sleep-stage-composition.ts +++ b/src/lib/analytics/sleep-stage-composition.ts @@ -73,6 +73,8 @@ export function buildSleepStageComposition( const perNight: SleepStageCompositionNight[] = []; for (const { night, main, naps } of days) { + // A day of naps alone has no night to compose. + if (!main) continue; const nightStages: Record = {}; for (const [stage, mins] of Object.entries(main.stages)) { if (mins == null) continue; diff --git a/src/lib/api/read-failure.ts b/src/lib/api/read-failure.ts new file mode 100644 index 000000000..0a6c8b388 --- /dev/null +++ b/src/lib/api/read-failure.ts @@ -0,0 +1,33 @@ +import { ApiError } from "./api-fetch"; + +/** + * What a failed read means for the surface that made it. + * + * - `unauthenticated`: 401. The session has ended. The surface says so and + * offers the way back to sign-in; drawing nothing would leave a page that + * looks empty for a reason the reader cannot see. + * - `refused`: 403. The viewer may not read this, typically a shared or + * delegated record whose grant leaves the section out. Nothing failed that + * a retry could fix, so the surface draws its refused state, or nothing. + * - `throttled`: 429. The read budget is spent for now; an immediate retry + * would only be refused again. + * - `failed`: everything else, a real read failure worth a retry. + */ +export type ReadFailureKind = + "unauthenticated" | "refused" | "throttled" | "failed"; + +export function readFailureKind(error: unknown): ReadFailureKind { + if (error instanceof ApiError) { + if (error.status === 401) return "unauthenticated"; + if (error.status === 403) return "refused"; + if (error.status === 429) return "throttled"; + } + return "failed"; +} + +/** The sign-in page, returning to where the reader is now. */ +export function signInHref(): string { + if (typeof window === "undefined") return "/auth/login"; + const here = `${window.location.pathname}${window.location.search}`; + return `/auth/login?next=${encodeURIComponent(here)}`; +} diff --git a/src/lib/clinician-share/__tests__/exif-strip.test.ts b/src/lib/clinician-share/__tests__/exif-strip.test.ts index 5948ad966..f20f38f52 100644 --- a/src/lib/clinician-share/__tests__/exif-strip.test.ts +++ b/src/lib/clinician-share/__tests__/exif-strip.test.ts @@ -53,7 +53,10 @@ describe("stripImageMetadata — passthrough", () => { describe("stripImageMetadata — JPEG", () => { // SOI + APP0(JFIF) + APP1(Exif w/ GPS) + APP2(ICC) + COM + DQT + SOS + scan. const soi = Buffer.from([0xff, 0xd8]); - const app0 = jpegSegment(0xe0, Buffer.from("JFIF\0\0\0\0\0\0")); + const app0 = jpegSegment( + 0xe0, + Buffer.from("JFIF\0\x01\x01\0\0\x01\0\x01\0\0"), + ); const exifPayload = Buffer.concat([ Buffer.from("Exif\0\0"), // A recognisable GPS marker string we can assert is gone. diff --git a/src/lib/geo/__tests__/geolite2-fetch.test.ts b/src/lib/geo/__tests__/geolite2-fetch.test.ts index 8caed00e3..f9ea0572d 100644 --- a/src/lib/geo/__tests__/geolite2-fetch.test.ts +++ b/src/lib/geo/__tests__/geolite2-fetch.test.ts @@ -31,8 +31,8 @@ import { MAXMIND_DOWNLOAD_BASE, } from "@/lib/geo/geolite2-fetch"; -const CITY_BYTES = Buffer.from("CITY-MMDB-CONTENT-"); -const ASN_BYTES = Buffer.from("ASN-MMDB-CONTENT-"); +const CITY_BYTES = Buffer.from("CITY-MMDB-CONTENT-\x01\x02\x03"); +const ASN_BYTES = Buffer.from("ASN-MMDB-CONTENT-\x04\x05\x06"); /** Build a MaxMind-shaped tar.gz: `_/.mmdb`. */ function buildEditionTarball( diff --git a/src/lib/google-health/__tests__/backfill-bounds.test.ts b/src/lib/google-health/__tests__/backfill-bounds.test.ts index 9860b0022..7bc28ad46 100644 --- a/src/lib/google-health/__tests__/backfill-bounds.test.ts +++ b/src/lib/google-health/__tests__/backfill-bounds.test.ts @@ -9,9 +9,10 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { prismaMock, safeFetchMock } = vi.hoisted(() => ({ +const { prismaMock, safeFetchMock, annotateMock } = vi.hoisted(() => ({ prismaMock: { googleHealthConnection: { update: vi.fn() } }, safeFetchMock: vi.fn(), + annotateMock: vi.fn(), })); vi.mock("@/lib/db", () => ({ prisma: prismaMock })); vi.mock("@/lib/safe-fetch", async (importOriginal) => ({ @@ -19,7 +20,7 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => ({ safeFetch: safeFetchMock, })); vi.mock("@/lib/logging/context", () => ({ - annotate: () => {}, + annotate: annotateMock, getEvent: () => null, })); @@ -32,14 +33,16 @@ import { type GoogleHealthBackfillEnd, } from "../backfill-bounds"; import { - googleHealthBackfillEndedList, - googleHealthBackfillProgressAfterCompletion, - googleHealthBackfillRetryPending, - readGoogleHealthBackfillEnded, recordGoogleHealthBackfillEnd, runGoogleHealthCollection, runWithGoogleHealthBackfillResume, } from "../sync-core"; +import { + googleHealthBackfillEndedList, + googleHealthBackfillProgressAfterCompletion, + googleHealthBackfillRetryPending, + readGoogleHealthBackfillProgress, +} from "../backfill-progress"; import { forEachDataPointPage, GOOGLE_HEALTH_DATA_TYPES, @@ -312,13 +315,31 @@ describe("a collection ended short", () => { -1, )![0].data.backfillProgress as unknown; expect( - readGoogleHealthBackfillEnded(written).get("fetchHeartRate"), + readGoogleHealthBackfillProgress(written).ended.get("fetchHeartRate"), ).toEqual(note); // Waiting for another try is not where the history ends. expect(googleHealthBackfillEndedList(written)).toEqual([]); expect(googleHealthBackfillProgressAfterCompletion(ended)).toBeNull(); }); + it("annotates an end in one fixed shape", async () => { + await slice(stalls, new Map([["fetchWeight", HORIZON_END]])); + const notes = annotateMock.mock.calls + .map(([fields]) => fields.meta?.["googleHealth.backfill.ended"]) + .filter(Boolean); + expect(notes).toEqual([ + { + collection: "fetchHeartRate", + type: "PULSE", + reason: "no_progress", + final: false, + endedCount: 2, + finalCount: 1, + types: ["PULSE", "WEIGHT"], + }, + ]); + }); + it("is not walked again before its day is up", async () => { const walk = vi.fn(async () => 1); const { skipped } = await slice( @@ -351,6 +372,31 @@ describe("a collection ended short", () => { ).toMatchObject({ ...END, attempts: 2 }); }); + it("starts the count over when the retry got further back before it stalled again", async () => { + const further = { ...END, at: "2023-01-02T00:00:00.000Z" }; + const done = new Set(); + const { ended, collections } = await slice( + async () => { + noteGoogleHealthDeferred(); + await recordGoogleHealthBackfillEnd("fetchHeartRate", further); + return 4; + }, + new Map([ + ["fetchHeartRate", parked(1, 25)], + ]), + done, + ); + // Progress, then a new stall: parked again, not final. + expect(ended.get("fetchHeartRate")).toMatchObject({ + ...further, + attempts: 1, + }); + expect(done.has("fetchHeartRate")).toBe(false); + expect(collections).toEqual([ + expect.objectContaining({ key: "fetchHeartRate", status: "stopped" }), + ]); + }); + it("goes back to an ordinary walk when the second attempt gets somewhere", async () => { const { ended, collections } = await slice( async () => { diff --git a/src/lib/google-health/__tests__/backfill-resume-progress.test.ts b/src/lib/google-health/__tests__/backfill-resume-progress.test.ts index 69e9715e9..80b2b2486 100644 --- a/src/lib/google-health/__tests__/backfill-resume-progress.test.ts +++ b/src/lib/google-health/__tests__/backfill-resume-progress.test.ts @@ -20,12 +20,14 @@ vi.mock("@/lib/logging/context", () => ({ })); import { - readGoogleHealthBackfillCursors, recordGoogleHealthBackfillPage, runGoogleHealthCollection, runWithGoogleHealthBackfillResume, - type GoogleHealthBackfillCursor, } from "../sync-core"; +import { + readGoogleHealthBackfillProgress, + type GoogleHealthBackfillCursor, +} from "../backfill-progress"; import { googleHealthSliceBudget, runWithGoogleHealthClientOutcome, @@ -124,7 +126,7 @@ describe("the resume edge of a page-by-page collection", () => { }); // And it reads back the way it was written. expect( - readGoogleHealthBackfillCursors(last.data.backfillProgress).get( + readGoogleHealthBackfillProgress(last.data.backfillProgress).cursors.get( "fetchHeartRate", ), ).toEqual(cursors.get("fetchHeartRate")); diff --git a/src/lib/google-health/__tests__/edge-filter-fallback.test.ts b/src/lib/google-health/__tests__/edge-filter-fallback.test.ts index 69a8cfc7e..1c827912a 100644 --- a/src/lib/google-health/__tests__/edge-filter-fallback.test.ts +++ b/src/lib/google-health/__tests__/edge-filter-fallback.test.ts @@ -46,13 +46,14 @@ import { GOOGLE_HEALTH_NO_PROGRESS_PAGES } from "../backfill-bounds"; import { googleHealthBackfillCursor, noteGoogleHealthEdgeFilterUnsupported, - readGoogleHealthBackfillCursors, - readGoogleHealthBackfillEdgeFilterUnsupported, recordGoogleHealthBackfillPage, recordGoogleHealthBackfillResumePoint, runWithGoogleHealthBackfillResume, - type GoogleHealthBackfillCursor, } from "../sync-core"; +import { + readGoogleHealthBackfillProgress, + type GoogleHealthBackfillCursor, +} from "../backfill-progress"; const MINUTE = 60_000; const NOW = Date.parse("2026-01-01T00:00:00Z"); @@ -566,7 +567,8 @@ describe("the note that a collection refuses the edge filter", () => { // Slice two: starts from what slice one left on disk. server.requests = []; - const unsupported = readGoogleHealthBackfillEdgeFilterUnsupported(progress); + const unsupported = + readGoogleHealthBackfillProgress(progress).edgeFilterUnsupported; await runWithGoogleHealthBackfillResume( "u1", new Set(), @@ -576,7 +578,7 @@ describe("the note that a collection refuses the edge filter", () => { const { rows } = await walk({ resumable: true, edge: cursor.edge }); expect(rows).toHaveLength(TOTAL - 19); }, - readGoogleHealthBackfillCursors(progress), + readGoogleHealthBackfillProgress(progress).cursors, unsupported, ); expect(server.requests.some((f) => f.includes("<="))).toBe(false); @@ -616,7 +618,7 @@ describe("the note that a collection refuses the edge filter", () => { page: 4, }); expect(first.advanced.has("fetchHeartRate")).toBe(true); - const saved = readGoogleHealthBackfillCursors(lastProgress()); + const saved = readGoogleHealthBackfillProgress(lastProgress()).cursors; expect(saved.get("fetchHeartRate")).toEqual({ ...cursor, resume: { token: "4", page: 4, reached: 4 }, @@ -628,8 +630,9 @@ describe("the note that a collection refuses the edge filter", () => { const back = await run(new Map(saved), { token: "2", page: 2 }); expect(back.advanced.size).toBe(0); expect( - readGoogleHealthBackfillCursors(lastProgress()).get("fetchHeartRate") - ?.resume, + readGoogleHealthBackfillProgress(lastProgress()).cursors.get( + "fetchHeartRate", + )?.resume, ).toEqual({ token: "2", page: 2, reached: 4 }); // A page written keeps the place; the next slice is handed it. @@ -644,7 +647,7 @@ describe("the note that a collection refuses the edge filter", () => { new Map(saved), new Set(["fetchHeartRate"]), ); - const next = readGoogleHealthBackfillCursors(lastProgress()); + const next = readGoogleHealthBackfillProgress(lastProgress()).cursors; expect(next.get("fetchHeartRate")?.resume).toEqual( saved.get("fetchHeartRate")?.resume, ); @@ -663,10 +666,12 @@ describe("the note that a collection refuses the edge filter", () => { it("reads only well-formed keys", () => { expect([ - ...readGoogleHealthBackfillEdgeFilterUnsupported({ + ...readGoogleHealthBackfillProgress({ edgeFilterUnsupported: ["fetchHeartRate", 3, "x".repeat(65)], - }), + }).edgeFilterUnsupported, ]).toEqual(["fetchHeartRate"]); - expect(readGoogleHealthBackfillEdgeFilterUnsupported(null).size).toBe(0); + expect( + readGoogleHealthBackfillProgress(null).edgeFilterUnsupported.size, + ).toBe(0); }); }); diff --git a/src/lib/google-health/backfill-progress.ts b/src/lib/google-health/backfill-progress.ts new file mode 100644 index 000000000..b4fb1caaf --- /dev/null +++ b/src/lib/google-health/backfill-progress.ts @@ -0,0 +1,279 @@ +/** + * The `backfillProgress` column of a Google Health connection: how far a + * full-history backfill has got, carried from one attempt to the next, and + * where a data type's history ended short of its beginning. + * + * One schema reads the column and one function writes it. The column is + * JSON the code itself wrote, but it outlives releases, so the read is + * lenient per entry: a malformed cursor or end is dropped on its own and the + * rest of the note still counts. A collection key longer than 64 characters + * is never one of ours and is dropped as well. + */ +import { z } from "zod"; + +import type { MeasurementType } from "@/generated/prisma/client"; +import { MeasurementType as MeasurementTypes } from "@/generated/prisma/enums"; +import { + googleHealthBackfillEndIsFinal, + googleHealthBackfillEndRetryAt, + type GoogleHealthBackfillEnd, +} from "./backfill-bounds"; + +/** + * How far a full-history backfill has got with one collection. + * + * Two grains. A collection walked to the end is noted in `done`, and the next + * attempt skips it. A collection read page by page (the spot readings: heart + * rate, weight, glucose, ...) also notes, after every page it writes, the + * edge it has reached: the oldest sample written so far when the walk runs + * newest first, as Google's list does. The next attempt asks Google only for + * the samples at or beyond that edge, so a collection that does not fit into + * one attempt is worked through over several instead of being read from its + * first page every time. + * + * The edge is a sample time, not Google's page token: page tokens are not + * documented to outlive the walk that received them, a sample time is a + * filter Google documents. The one sample on the edge itself is read twice + * and overwrites its own row. + * + * Before the edge existed only `done` was kept, and a heart-rate history of + * years that one attempt could not finish (its budget, a rate limit, one + * failed page among thousands) started again from the newest page on every + * attempt and never reached the end (#1220). + * + * The upper bound (`<=`) a newest-first walk resumes with is valid filter + * grammar but has not been seen working against the live API. A collection + * whose resumed request Google refuses with a 400 is noted in + * `edgeFilterUnsupported`, and its walks from then on read without the bound + * and pass over the samples beyond the edge themselves (see + * `forEachDataPointPage`). + */ +export interface GoogleHealthBackfillCursor { + /** The edge: the sample instant (ISO 8601) the walk has written up to. */ + at: string; + /** The order the walk returned samples in: newest first, or oldest first. */ + order: "desc" | "asc"; + /** + * Pages this collection has written across every attempt so far. Kept for + * the record only: it bounds nothing. It once capped the walk at ten + * thousand pages, which a dense heart-rate history passes in under three + * years, and since it never reset the backfill stopped there for good + * (#1220). The walk is bounded by its progress and the backfill horizon + * instead (`backfill-bounds.ts`). + */ + pages: number; + /** + * Only for a collection read without the edge bound: where its last walk + * stopped in the unbounded list (see `GoogleHealthResumePoint`), and the + * furthest position any walk of it has saved. A saved place counts as the + * slice's progress only when it is further than every one before it, so a + * chain of slices that keep passing over the same pages ends. + */ + resume?: { token: string; page: number; reached: number }; +} + +/** Everything the column holds, read. */ +export interface GoogleHealthBackfillProgress { + /** Collections walked to the end. */ + done: Set; + /** Per-collection edges. */ + cursors: Map; + /** Collections Google refused the edge filter on. */ + edgeFilterUnsupported: Set; + /** + * Collections whose backfill ended short of the start of their history + * (the horizon, or a walk that stopped getting anywhere), each with where + * it ended and why. Survives the completion stamp, so the integration card + * can say where history ends. + */ + ended: Map; +} + +const MAX_KEY_LENGTH = 64; + +/** An instant Date can read, normalised to ISO 8601. */ +const instant = z + .string() + .refine((s) => !Number.isNaN(Date.parse(s))) + .transform((s) => new Date(s).toISOString()); + +/** A whole, positive count; anything else reads as zero. */ +const count = z + .unknown() + .transform((v) => + typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0, + ); + +const resumeSchema = z + .object({ token: z.string().min(1).max(4096), page: count, reached: count }) + .transform((r) => + r.page > 0 + ? { token: r.token, page: r.page, reached: Math.max(r.reached, r.page) } + : undefined, + ); + +const cursorSchema = z + .object({ + at: instant, + order: z.unknown().transform((v) => (v === "asc" ? "asc" : "desc")), + pages: count, + resume: resumeSchema.optional().catch(undefined), + }) + .transform(({ resume, ...c }): GoogleHealthBackfillCursor => + resume ? { ...c, resume } : c, + ); + +const endSchema = z + .object({ + type: z.enum(MeasurementTypes), + at: instant, + reason: z.enum(["horizon", "no_progress"]), + attempts: z.number().int().min(1).optional().catch(undefined), + endedAt: instant.optional().catch(undefined), + }) + .transform(({ attempts, endedAt, ...e }): GoogleHealthBackfillEnd => ({ + ...e, + type: e.type as MeasurementType, + ...(attempts !== undefined ? { attempts } : {}), + ...(endedAt !== undefined ? { endedAt } : {}), + })); + +const record = z.record(z.string(), z.unknown()).catch({}); + +const keyList = z + .array(z.unknown()) + .catch([]) + .transform( + (keys) => + new Set( + keys.filter( + (key): key is string => + typeof key === "string" && key.length <= MAX_KEY_LENGTH, + ), + ), + ); + +/** Entries of a keyed record that parse, by their own schema, one by one. */ +function keyedEntries(value: unknown, schema: z.ZodType): Map { + const out = new Map(); + for (const [key, raw] of Object.entries(record.parse(value))) { + if (key.length > MAX_KEY_LENGTH) continue; + const parsed = schema.safeParse(raw); + if (parsed.success) out.set(key, parsed.data); + } + return out; +} + +/** Read the `backfillProgress` column. */ +export function readGoogleHealthBackfillProgress( + value: unknown, +): GoogleHealthBackfillProgress { + const column = record.parse(value); + return { + done: keyList.parse(column.done), + cursors: keyedEntries(column.cursors, cursorSchema), + edgeFilterUnsupported: keyList.parse(column.edgeFilterUnsupported), + ended: keyedEntries(column.ended, endSchema), + }; +} + +/** The ended collections as the column stores them. */ +function endedJson(ended: Map) { + return Object.fromEntries( + [...ended].map(([key, e]) => [ + key, + { + type: e.type, + at: e.at, + reason: e.reason, + ...(e.attempts !== undefined ? { attempts: e.attempts } : {}), + ...(e.endedAt !== undefined ? { endedAt: e.endedAt } : {}), + }, + ]), + ); +} + +/** + * The column's value for a backfill in progress. The two optional parts are + * written only once there is something to say, so a backfill that never met + * them keeps the shape it always had. + */ +export function googleHealthBackfillProgressJson( + progress: GoogleHealthBackfillProgress, +) { + const { done, cursors, edgeFilterUnsupported, ended } = progress; + return { + done: [...done], + cursors: Object.fromEntries( + [...cursors].map(([key, c]) => [ + key, + { + at: c.at, + order: c.order, + pages: c.pages, + ...(c.resume ? { resume: c.resume } : {}), + }, + ]), + ), + ...(edgeFilterUnsupported.size > 0 + ? { edgeFilterUnsupported: [...edgeFilterUnsupported] } + : {}), + ...(ended.size > 0 ? { ended: endedJson(ended) } : {}), + }; +} + +/** + * The column's value to keep once the backfill is stamped complete: only + * where collections ended short for good, or nothing at all. + */ +export function googleHealthBackfillProgressAfterCompletion( + ended: Map, +): { ended: ReturnType } | null { + const final = new Map( + [...ended].filter(([, e]) => googleHealthBackfillEndIsFinal(e)), + ); + return final.size > 0 ? { ended: endedJson(final) } : null; +} + +/** + * Where each data type's imported history ends short of its beginning, and + * why, as the status routes publish it (`backfillEnded`), by type. + */ +export interface GoogleHealthBackfillEndedEntry { + type: MeasurementType; + /** The oldest instant the imported history reaches back to. */ + endsAt: string; + reason: GoogleHealthBackfillEnd["reason"]; +} + +/** + * The column's ended collections, as published: only the final ends. One + * still waiting for another attempt is not where its history ends yet. + */ +export function googleHealthBackfillEndedList( + value: unknown, +): GoogleHealthBackfillEndedEntry[] { + return [...readGoogleHealthBackfillProgress(value).ended.values()] + .filter(googleHealthBackfillEndIsFinal) + .map((e) => ({ type: e.type, endsAt: e.at, reason: e.reason })) + .sort((a, b) => a.type.localeCompare(b.type)); +} + +/** + * The collections waiting for another attempt after one ended without + * progress, and the earliest instant (epoch ms) one of them is due, or null + * when none waits. + */ +export function googleHealthBackfillRetryPending( + ended: Map, +): { keys: string[]; dueAt: number } | null { + const keys: string[] = []; + let dueAt = Infinity; + for (const [key, e] of ended) { + const at = googleHealthBackfillEndRetryAt(e); + if (at === null) continue; + keys.push(key); + dueAt = Math.min(dueAt, at); + } + return keys.length > 0 ? { keys, dueAt } : null; +} diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index e33735991..542755bc8 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -18,7 +18,6 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { prisma } from "@/lib/db"; import type { MeasurementType } from "@/generated/prisma/client"; -import { MeasurementType as MeasurementTypes } from "@/generated/prisma/enums"; import { encrypt, decrypt } from "@/lib/crypto"; import { annotate, getEvent } from "@/lib/logging/context"; import type { GoogleHealthCollectionKey } from "./collection-keys"; @@ -27,6 +26,10 @@ import { googleHealthBackfillEndRetryAt, type GoogleHealthBackfillEnd, } from "./backfill-bounds"; +import { + googleHealthBackfillProgressJson, + type GoogleHealthBackfillCursor, +} from "./backfill-progress"; import { userDayKey } from "@/lib/tz/format"; import { isP2002 } from "@/lib/prisma-errors"; import { @@ -411,60 +414,6 @@ export async function runWithGoogleHealthSyncCycle( }; } -/** - * How far a full-history backfill has got, carried from one attempt of the - * backfill to the next. - * - * Two grains. A collection walked to the end is noted in `done`, and the next - * attempt skips it. A collection read page by page (the spot readings: heart - * rate, weight, glucose, ...) also notes, after every page it writes, the - * edge it has reached: the oldest sample written so far when the walk runs - * newest first, as Google's list does. The next attempt asks Google only for - * the samples at or beyond that edge, so a collection that does not fit into - * one attempt is worked through over several instead of being read from its - * first page every time. - * - * The edge is a sample time, not Google's page token: page tokens are not - * documented to outlive the walk that received them, a sample time is a - * filter Google documents. The one sample on the edge itself is read twice - * and overwrites its own row. - * - * Before the edge existed only `done` was kept, and a heart-rate history of - * years that one attempt could not finish (its budget, a rate limit, one - * failed page among thousands) started again from the newest page on every - * attempt and never reached the end (#1220). - * - * The upper bound (`<=`) a newest-first walk resumes with is valid filter - * grammar but has not been seen working against the live API. A collection - * whose resumed request Google refuses with a 400 is noted in - * `edgeFilterUnsupported`, and its walks from then on read without the bound - * and pass over the samples beyond the edge themselves (see - * `forEachDataPointPage`). - */ -export interface GoogleHealthBackfillCursor { - /** The edge: the sample instant (ISO 8601) the walk has written up to. */ - at: string; - /** The order the walk returned samples in: newest first, or oldest first. */ - order: "desc" | "asc"; - /** - * Pages this collection has written across every attempt so far. Kept for - * the record only: it bounds nothing. It once capped the walk at ten - * thousand pages, which a dense heart-rate history passes in under three - * years, and since it never reset the backfill stopped there for good - * (#1220). The walk is bounded by its progress and the backfill horizon - * instead (`backfill-bounds.ts`). - */ - pages: number; - /** - * Only for a collection read without the edge bound: where its last walk - * stopped in the unbounded list (see `GoogleHealthResumePoint`), and the - * furthest position any walk of it has saved. A saved place counts as the - * slice's progress only when it is further than every one before it, so a - * chain of slices that keep passing over the same pages ends. - */ - resume?: { token: string; page: number; reached: number }; -} - /** What one collection came to in one backfill attempt. */ export interface GoogleHealthBackfillCollectionReport { key: string; @@ -497,209 +446,13 @@ interface BackfillResumeTracker { ended: Map; /** * Collections walked again this attempt after an earlier one ended without - * progress, with how many attempts had ended that way. + * progress, with how many attempts in a row had ended that way and the edge + * they ended at. */ - retrying: Map; + retrying: Map; } const backfillResumeStorage = new AsyncLocalStorage(); -function progressRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -/** Read the `backfillProgress` column into the set of finished collections. */ -export function readGoogleHealthBackfillDone(value: unknown): Set { - const done = progressRecord(value).done; - return new Set( - Array.isArray(done) - ? done.filter( - (key): key is string => typeof key === "string" && key.length <= 64, - ) - : [], - ); -} - -/** Read the `backfillProgress` column into the per-collection edges. */ -export function readGoogleHealthBackfillCursors( - value: unknown, -): Map { - const out = new Map(); - const cursors = progressRecord(progressRecord(value).cursors); - for (const [key, raw] of Object.entries(cursors)) { - if (key.length > 64) continue; - const c = progressRecord(raw); - const at = typeof c.at === "string" ? c.at : null; - if (!at || Number.isNaN(Date.parse(at))) continue; - const r = progressRecord(c.resume); - const count = (v: unknown): number => - typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0; - const resume = - typeof r.token === "string" && - r.token.length > 0 && - r.token.length <= 4096 && - count(r.page) > 0 - ? { - token: r.token, - page: count(r.page), - reached: Math.max(count(r.reached), count(r.page)), - } - : undefined; - out.set(key, { - at: new Date(at).toISOString(), - order: c.order === "asc" ? "asc" : "desc", - pages: count(c.pages), - ...(resume ? { resume } : {}), - }); - } - return out; -} - -/** - * Read the `backfillProgress` column into the collections whose backfill - * ended short of the start of their history (the horizon, or a walk that - * stopped getting anywhere), each with where it ended and why. Survives the - * completion stamp, so the integration card can say where history ends. - */ -export function readGoogleHealthBackfillEnded( - value: unknown, -): Map { - const out = new Map(); - const ended = progressRecord(progressRecord(value).ended); - for (const [key, raw] of Object.entries(ended)) { - if (key.length > 64) continue; - const e = progressRecord(raw); - const at = typeof e.at === "string" ? e.at : null; - if (!at || Number.isNaN(Date.parse(at))) continue; - if (e.reason !== "horizon" && e.reason !== "no_progress") continue; - if (typeof e.type !== "string" || !(e.type in MeasurementTypes)) { - continue; - } - const attempts = - typeof e.attempts === "number" && - Number.isInteger(e.attempts) && - e.attempts >= 1 - ? e.attempts - : undefined; - const endedAt = - typeof e.endedAt === "string" && !Number.isNaN(Date.parse(e.endedAt)) - ? new Date(e.endedAt).toISOString() - : undefined; - out.set(key, { - type: e.type as MeasurementType, - at: new Date(at).toISOString(), - reason: e.reason, - ...(attempts !== undefined ? { attempts } : {}), - ...(endedAt !== undefined ? { endedAt } : {}), - }); - } - return out; -} - -/** - * Where each data type's imported history ends short of its beginning, and - * why, as the status routes publish it (`backfillEnded`), by type. - */ -export interface GoogleHealthBackfillEndedEntry { - type: MeasurementType; - /** The oldest instant the imported history reaches back to. */ - endsAt: string; - reason: GoogleHealthBackfillEnd["reason"]; -} - -/** - * The `backfillProgress` column's ended collections, as published: only the - * final ends. One still waiting for another attempt is not where its history - * ends yet. - */ -export function googleHealthBackfillEndedList( - value: unknown, -): GoogleHealthBackfillEndedEntry[] { - return [...readGoogleHealthBackfillEnded(value).values()] - .filter(googleHealthBackfillEndIsFinal) - .map((e) => ({ type: e.type, endsAt: e.at, reason: e.reason })) - .sort((a, b) => a.type.localeCompare(b.type)); -} - -/** - * The `backfillProgress` value to keep once the backfill is stamped complete: - * only where collections ended short, or nothing at all. - */ -export function googleHealthBackfillProgressAfterCompletion( - ended: Map, -): { - ended: Record; -} | null { - const final = new Map( - [...ended].filter(([, e]) => googleHealthBackfillEndIsFinal(e)), - ); - return final.size > 0 ? { ended: endedJson(final) } : null; -} - -/** One ended collection as the column stores it. */ -type GoogleHealthBackfillEndJson = { - type: string; - at: string; - reason: string; - attempts?: number; - endedAt?: string; -}; - -/** The ended collections as the column stores them. */ -function endedJson( - ended: Map, -): Record { - return Object.fromEntries( - [...ended].map(([key, e]) => [ - key, - { - type: e.type, - at: e.at, - reason: e.reason, - ...(e.attempts !== undefined ? { attempts: e.attempts } : {}), - ...(e.endedAt !== undefined ? { endedAt: e.endedAt } : {}), - }, - ]), - ); -} - -/** - * The collections waiting for another attempt after one ended without - * progress, and the earliest instant (epoch ms) one of them is due, or null - * when none waits. - */ -export function googleHealthBackfillRetryPending( - ended: Map, -): { keys: string[]; dueAt: number } | null { - const keys: string[] = []; - let dueAt = Infinity; - for (const [key, e] of ended) { - const at = googleHealthBackfillEndRetryAt(e); - if (at === null) continue; - keys.push(key); - dueAt = Math.min(dueAt, at); - } - return keys.length > 0 ? { keys, dueAt } : null; -} - -/** - * Read the `backfillProgress` column into the collections Google refused the - * edge filter on. - */ -export function readGoogleHealthBackfillEdgeFilterUnsupported( - value: unknown, -): Set { - const keys = progressRecord(value).edgeFilterUnsupported; - return new Set( - Array.isArray(keys) - ? keys.filter( - (key): key is string => typeof key === "string" && key.length <= 64, - ) - : [], - ); -} - /** * Write the tracker's progress with one change applied, and apply it to the * tracker only once the write has landed: progress that is not on disk is @@ -720,26 +473,12 @@ async function persistBackfillProgress( await prisma.googleHealthConnection.update({ where: { userId: tracker.userId }, data: { - backfillProgress: { - done: [...next.done], - cursors: Object.fromEntries( - [...next.cursors].map(([key, c]) => [ - key, - { - at: c.at, - order: c.order, - pages: c.pages, - ...(c.resume ? { resume: c.resume } : {}), - }, - ]), - ), - // Only once there is something to say, so a backfill that never met - // the refusal keeps the shape it always had. - ...(unsupported.size > 0 - ? { edgeFilterUnsupported: [...unsupported] } - : {}), - ...(ended.size > 0 ? { ended: endedJson(ended) } : {}), - }, + backfillProgress: googleHealthBackfillProgressJson({ + done: next.done, + cursors: next.cursors, + edgeFilterUnsupported: unsupported, + ended, + }), }, }); if (unsupported !== tracker.edgeFilterUnsupported) { @@ -857,20 +596,40 @@ export async function recordGoogleHealthBackfillEnd( const resume = backfillResumeStorage.getStore(); if (!resume) return; const ended = new Map(resume.ended); + // Counted across attempts that end at the same edge: a walk that got + // further back before it stalled again made progress, and its count starts + // over. Only attempts in a row that reach nowhere make the end final. + const parked = resume.retrying.get(key); + const sameEdge = + parked !== undefined && Date.parse(parked.at) === Date.parse(end.at); ended.set( key, end.reason === "no_progress" ? { ...end, - // Counted across attempts: the one walked again this attempt - // carries the count it was parked with. - attempts: (resume.retrying.get(key) ?? 0) + 1, + attempts: sameEdge ? parked.attempts + 1 : 1, endedAt: new Date().toISOString(), } : end, ); + // A fixed shape, not the whole note: the slice report carries every end in + // full (`googleHealth.backfill.slice`). This names the one that just ended + // and counts the rest. It is meta, not the event's action, because the job + // names the action once the slice is over and that would replace it. + const recorded = ended.get(key)!; + const final = [...ended.values()].filter(googleHealthBackfillEndIsFinal); annotate({ - meta: { "googleHealth.backfill.ended": Object.fromEntries(ended) }, + meta: { + "googleHealth.backfill.ended": { + collection: key, + type: recorded.type, + reason: recorded.reason, + final: googleHealthBackfillEndIsFinal(recorded), + endedCount: ended.size, + finalCount: final.length, + types: [...new Set([...ended.values()].map((e) => e.type))].sort(), + }, + }, }); try { await persistBackfillProgress(resume, { @@ -1073,8 +832,12 @@ export async function runGoogleHealthCollection( if (parked) { // Its earlier attempt ended without progress and its wait is over: walk // it again from its edge. The note goes with the next write; the count - // stays with the attempt, so a second end without progress is final. - resume.retrying.set(key, parked.attempts ?? 1); + // and the edge stay with the attempt, so a second end at the same edge + // is final. + resume.retrying.set(key, { + attempts: parked.attempts ?? 1, + at: parked.at, + }); resume.ended.delete(key); } const startedAt = performance.now(); diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index 3fe6a8620..32394cbb1 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -110,6 +110,38 @@ function stubCompletion( ); } +/** + * Postgres names the nights the sleep read reconstructs; answer the night + * page query with the one night the stage rows make, and every other raw + * query with nothing. + */ +function nightPageOver(rows: Array<{ value: number; measuredAt: Date }>) { + const start = Math.min( + ...rows.map((r) => r.measuredAt.getTime() - r.value * 60_000), + ); + const end = Math.max(...rows.map((r) => r.measuredAt.getTime())); + vi.mocked(prisma.$queryRaw).mockImplementation((( + _strings: TemplateStringsArray, + ...values: unknown[] + ) => + Promise.resolve( + values.some((v) => + String((v as { sql?: string })?.sql ?? "").includes("sess AS"), + ) + ? [ + { + night: new Date(end).toLocaleDateString("sv-SE", { + timeZone: "Europe/Berlin", + }), + span_start: new Date(start), + span_end: new Date(end), + total: 1, + }, + ] + : [], + )) as never); +} + beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); @@ -167,6 +199,7 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)" vi.mocked(prisma.measurement.findMany).mockResolvedValue( stageRows as never, ); + nightPageOver(stageRows); const capture = { systemPrompt: null, userPrompt: null } as { systemPrompt: string | null; @@ -190,7 +223,7 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)" expect(prompt).not.toContain('"value": 90'); }); - it("the snapshot graded series is the deduped night total, never the ~20 h raw-stage sum (A4)", async () => { + it("the snapshot graded series is the deduped night total, never the ~20 h raw-stage sum", async () => { // The ~20.3 h symptom: a source writes BOTH a bare ASLEEP aggregate AND the // granular CORE/DEEP/REM partition for the same span. Pre-fix the graded // `series` came from `buildGradedSeriesWithRollups`, which folds bare + each @@ -249,6 +282,7 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)" vi.mocked(prisma.measurement.findMany).mockResolvedValue( stageRows as never, ); + nightPageOver(stageRows); const capture = { systemPrompt: null, userPrompt: null } as { systemPrompt: string | null; diff --git a/src/lib/insights/__tests__/recovery-empty.test.ts b/src/lib/insights/__tests__/recovery-empty.test.ts new file mode 100644 index 000000000..58ed90a3b --- /dev/null +++ b/src/lib/insights/__tests__/recovery-empty.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { resolveRecoveryEmpty } from "../recovery-empty"; + +/** + * The recovery page's empty reason is decided here, on the server, from the + * summaries and the Oura connection; clients render the answer. + */ +describe("resolveRecoveryEmpty", () => { + const none = { connected: false, scopeDenied: [] }; + + it("has no reason when any recovery type has a reading", () => { + expect(resolveRecoveryEmpty({ RESILIENCE: { count: 1 } }, none)).toBeNull(); + expect(resolveRecoveryEmpty({ DAY_STRAIN: { count: 3 } }, none)).toBeNull(); + }); + + it("does not count types the page does not chart", () => { + expect(resolveRecoveryEmpty({ RECOVERY_SCORE: { count: 30 } }, none)).toBe( + "general", + ); + }); + + it("asks for a reconnect only when the grant leaves resilience out", () => { + expect( + resolveRecoveryEmpty( + {}, + { connected: true, scopeDenied: ["resilience"] }, + ), + ).toBe("oura_permission"); + expect( + resolveRecoveryEmpty({}, { connected: true, scopeDenied: ["spo2"] }), + ).toBe("oura_no_resilience"); + }); +}); diff --git a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts index be4e5291e..cfbc2e8eb 100644 --- a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts +++ b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts @@ -113,10 +113,12 @@ function seedSymptomDrivesSleep() { functionalImpact: burden, symptomLinks: [], }); + // A night of five to eight hours, waking at 07:00 UTC: a main sleep, not + // a midday nap, so the nap rule leaves it the night. measurements.push({ type: "SLEEP_DURATION", - value: 8 - burden, - measuredAt: new Date(BASE + (i + 1) * 86_400_000), + value: (8 - burden) * 60, + measuredAt: new Date(BASE + (i + 1) * 86_400_000 - 5 * 3_600_000), }); } measurementFindMany.mockResolvedValue(measurements); diff --git a/src/lib/insights/derived/__tests__/derived-assessment.test.ts b/src/lib/insights/derived/__tests__/derived-assessment.test.ts index 317551cb2..cd8d00ebf 100644 --- a/src/lib/insights/derived/__tests__/derived-assessment.test.ts +++ b/src/lib/insights/derived/__tests__/derived-assessment.test.ts @@ -76,6 +76,7 @@ const sleepScore: SleepScoreValue = { ], windowNights: 20, rhythmNights: 20, + rhythmMinNights: 3, series: [78, 82], }; diff --git a/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts b/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts index 76dc6704c..302bd93d3 100644 --- a/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts @@ -24,7 +24,6 @@ import { mapSleep, type OuraSleep } from "@/lib/oura/client"; import { computeSleepScore, reconstructNights, - isDaytimeNap, rhythmGap, scoreConsistency, } from "../sleep-score"; @@ -174,18 +173,6 @@ describe("Sleep Score rhythm on an Oura month", () => { expect(sub("timing").gap).toBeUndefined(); }); - it("tells a nap from a night by length and time of day together", () => { - // Short and in the afternoon: a nap. - expect(isDaytimeNap(60, 14 * 60)).toBe(true); - // Short but at night: the night. - expect(isDaytimeNap(150, 2 * 60)).toBe(false); - expect(isDaytimeNap(150, 22 * 60)).toBe(false); - // A late sleeper's short night, centred at 09:00. - expect(isDaytimeNap(150, 9 * 60)).toBe(false); - // Long in the daytime (after a night shift): a main sleep. - expect(isDaytimeNap(420, 13 * 60)).toBe(false); - }); - it("scores the consistency of a steady sleeper, not 0", async () => { const result = await computeSleepScore("u1", PROFILE, { now: NOW }); expect(result.status).toBe("ok"); diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index c9a7a6bf1..15db6a577 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -38,11 +38,11 @@ import type { SleepStage, } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; -import { wallClockInTz } from "@/lib/tz/wall-clock"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { shiftDateKey } from "@/lib/tz/format"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { + asleepMidpointMinutes, reconstructSleepNights, type SleepStageRow, } from "@/lib/analytics/sleep-night"; @@ -115,6 +115,11 @@ export interface SleepScoreValue { * `RHYTHM_MIN_NIGHTS` both are withheld with `gap: "few_nights"`. */ rhythmNights: number; + /** + * `RHYTHM_MIN_NIGHTS`, published so a client names the floor it shows + * ("2 of 3 nights") from the server instead of carrying its own copy. + */ + rhythmMinNights: number; /** * v1.42 — the score of every scorable night in the window (oldest → newest, * capped to `SPARKLINE_MAX_POINTS`), so a surface can show the score's @@ -434,34 +439,16 @@ export function reconstructNights( : n.asleepMinutes > 0 && awakeMinutes > 0 ? n.asleepMinutes + awakeMinutes : null; - // Midpoint = centre of the MAIN session's asleep span: its wake instant - // minus half its own asleep minutes. Not the whole night's: a nap on the - // same wake day moves the night's latest instant to the end of the nap and - // adds its minutes, which put the midpoint of a 23:00 to 07:00 night with a - // 14:00 nap at about 11:00 and read every such day as a ~7-hour shift in - // consistency and timing. - // - // And only when that main session is a main sleep, not a daytime nap. On - // a day the ring or watch recorded no night (left on the charger, taken - // off), the day's largest session is an afternoon nap, and its 14:00 - // midpoint sat ten hours from every other night: a single such day in a - // month pushed the midpoint spread past the 90-minute full scale and read - // the whole rhythm as Consistency 0. Such a day still counts its minutes; - // it just has no place on the clock. A short session at night is still - // the night (insomnia, a newborn, split sleep), so it keeps its place. + // Midpoint = centre of the MAIN session's asleep span. Not the whole + // night's: a nap on the same wake day would drag it into the afternoon. + // The main session is the nap convention's (`pickMainNightAndNaps`), so + // a day whose only sleep is a daytime nap has none: it still counts its + // minutes, it just has no place on the clock, exactly as the night view + // and the list show it without a main night. const main = n.mainSession; - const mainMidpoint = main - ? minutesOfDay( - new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000), - tz, - ) + const midpoint = main + ? asleepMidpointMinutes(main.end, main.asleepMinutes, tz) : null; - const midpoint = - main && - mainMidpoint != null && - !isDaytimeNap(main.asleepMinutes, mainMidpoint) - ? mainMidpoint - : null; return { night: n.night, asleepMinutes: n.asleepMinutes, @@ -475,44 +462,6 @@ export function reconstructNights( }); } -/** - * Below this much asleep time a session can be a nap. Three hours is where - * wearables draw the line between a nap and a main sleep (Oura types a - * period `long_sleep` from three hours on). - */ -const MAIN_SLEEP_MIN_MINUTES = 180; - -/** - * The night window, in local minutes-of-day: a session whose midpoint falls - * from 20:00 to 10:00 is night sleep however short it is. Wide enough for a - * late chronotype (asleep 04:00 to 12:00 has its midpoint at 08:00) and for - * an early one, and clear of the afternoon where naps sit. - */ -const NIGHT_WINDOW_START = 20 * 60; -const NIGHT_WINDOW_END = 10 * 60; - -/** - * True for a session that is a nap rather than a night: short (under - * `MAIN_SLEEP_MIN_MINUTES` asleep) AND centred in the daytime. A short night - * is still the night; a long daytime sleep (after a night shift) is still a - * main sleep. Pure. - */ -export function isDaytimeNap( - asleepMinutes: number, - midpointMinutes: number, -): boolean { - if (asleepMinutes >= MAIN_SLEEP_MIN_MINUTES) return false; - const atNight = - midpointMinutes >= NIGHT_WINDOW_START || midpointMinutes < NIGHT_WINDOW_END; - return !atNight; -} - -function minutesOfDay(d: Date, tz: string): number { - if (tz === "UTC") return d.getUTCHours() * 60 + d.getUTCMinutes(); - const { hour, minute } = wallClockInTz(d, tz); - return hour * 60 + minute; -} - // ── compute ───────────────────────────────────────────────────────────── export interface SleepScoreOpts { @@ -696,6 +645,7 @@ export async function computeSleepScore( subScores, windowNights: scorableNights.length, rhythmNights: midpoints.length, + rhythmMinNights: RHYTHM_MIN_NIGHTS, series, }, coverage, diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 1da61d7e5..7df75048c 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -71,10 +71,7 @@ import { summarizeSeries, } from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; -import { - reconstructSleepNights, - type SleepStageRow, -} from "@/lib/analytics/sleep-night"; +import { readSleepNights } from "@/lib/measurements/list/sleep-nights"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver"; import { @@ -295,31 +292,16 @@ export async function generateMetricStatus(args: { const points: Array<{ measuredAt: Date; value: number }> = []; let measurements: Array<{ measuredAt: Date }> = []; if (isSleep) { - const [priorityJson, sleepRows] = await Promise.all([ - loadUserSourcePriority(args.userId), - prisma.measurement.findMany({ - where: { - userId: args.userId, - type: "SLEEP_DURATION", - deletedAt: null, - }, - orderBy: { measuredAt: "desc" }, - take: 365 * 6, // ≈ 5 stage rows/night × source-count for a year - // Writer-level collapse: two HealthKit apps behind one source - // (watch stages vs phone in-bed) must not blend into one night. - select: { - value: true, - measuredAt: true, - sleepStage: true, - source: true, - deviceType: true, - }, - }), - ]); - const nights = reconstructSleepNights( - sleepRows as SleepStageRow[], - userTz, - priorityJson, + // The latest 365 nights, the sleep twin of the 365 latest readings the + // other types read. A cut on stage rows instead (2190 of them) reached + // back a few weeks on a stage-rich watch and began mid-night. + const nights = ( + await readSleepNights( + args.userId, + userTz, + await loadUserSourcePriority(args.userId), + { maxNights: 365, newest: true }, + ) ).filter((n) => n.asleepMinutes > 0); for (const n of nights) { points.push({ measuredAt: n.measuredAt, value: n.asleepMinutes }); diff --git a/src/lib/insights/recovery-empty-read.ts b/src/lib/insights/recovery-empty-read.ts new file mode 100644 index 000000000..75c22ae82 --- /dev/null +++ b/src/lib/insights/recovery-empty-read.ts @@ -0,0 +1,27 @@ +import { prisma } from "@/lib/db"; +import { readOuraScopeDenied } from "@/lib/oura/credentials"; +import { + hasRecoveryData, + resolveRecoveryEmpty, + type RecoveryEmptyReason, +} from "./recovery-empty"; + +/** + * The recovery page's empty reason for one account. Reads the Oura + * connection only when no recovery type has a reading, so a page with data + * never pays for it. + */ +export async function loadRecoveryEmpty( + userId: string, + summaries: Record, +): Promise { + if (hasRecoveryData(summaries)) return null; + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { ouraAccessTokenEncrypted: true, ouraScopeDenied: true }, + }); + return resolveRecoveryEmpty(summaries, { + connected: !!user?.ouraAccessTokenEncrypted, + scopeDenied: readOuraScopeDenied(user?.ouraScopeDenied), + }); +} diff --git a/src/lib/insights/recovery-empty.ts b/src/lib/insights/recovery-empty.ts new file mode 100644 index 000000000..c824d300e --- /dev/null +++ b/src/lib/insights/recovery-empty.ts @@ -0,0 +1,60 @@ +/** + * Why the recovery page is empty, decided once on the server. + * + * The page charts the device-native recovery signals below. When none of + * them has a reading, the analytics slice publishes the reason beside the + * summaries (`recoveryEmpty`), and every client renders from it rather than + * working it out from the connection status itself: + * + * - `oura_permission`: Oura is connected and the stored grant leaves out + * the scope its resilience level sits behind; one reconnect fixes it. + * - `oura_no_resilience`: Oura is connected and no level has arrived. The + * server cannot tell "the grant covers it" from "not recorded yet" (the + * scope note is cleared on every reconnect and written only by a sync + * that reached Oura), so this case never claims coverage. + * - `general`: anyone else. + * + * `null` means the page has something to chart. + * + * Pure, so the page can share the type list it charts from. + */ + +/** Every summary type the recovery page charts or shows as a tile. */ +export const RECOVERY_SUMMARY_TYPES = [ + "ANS_CHARGE", + "DAY_STRAIN", + "WORKOUT_STRAIN", + "CARDIO_LOAD", + "AVERAGE_HEART_RATE", + "MAX_HEART_RATE", + "ENERGY_EXPENDITURE_KJ", + "RESILIENCE", +] as const; + +export const RECOVERY_EMPTY_REASONS = [ + "general", + "oura_permission", + "oura_no_resilience", +] as const; + +export type RecoveryEmptyReason = (typeof RECOVERY_EMPTY_REASONS)[number]; + +/** True when any recovery type has a reading. */ +export function hasRecoveryData( + summaries: Record, +): boolean { + return RECOVERY_SUMMARY_TYPES.some( + (type) => (summaries[type]?.count ?? 0) > 0, + ); +} + +export function resolveRecoveryEmpty( + summaries: Record, + oura: { connected: boolean; scopeDenied: readonly string[] }, +): RecoveryEmptyReason | null { + if (hasRecoveryData(summaries)) return null; + if (!oura.connected) return "general"; + return oura.scopeDenied.includes("resilience") + ? "oura_permission" + : "oura_no_resilience"; +} diff --git a/src/lib/jobs/__tests__/google-health-backfill.test.ts b/src/lib/jobs/__tests__/google-health-backfill.test.ts index d7c802d29..3fd42cdc0 100644 --- a/src/lib/jobs/__tests__/google-health-backfill.test.ts +++ b/src/lib/jobs/__tests__/google-health-backfill.test.ts @@ -63,12 +63,13 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => { return { ...actual, safeFetch: safeFetchMock }; }); -vi.mock("@/lib/google-health/sync-core", () => ({ - GOOGLE_HEALTH_INTEGRATION_KEY: "google-health", - readGoogleHealthBackfillDone: () => new Set(resumeState.doneBefore), - readGoogleHealthBackfillCursors: () => new Map(), - readGoogleHealthBackfillEdgeFilterUnsupported: () => new Set(), - readGoogleHealthBackfillEnded: () => new Map(), +vi.mock("@/lib/google-health/backfill-progress", () => ({ + readGoogleHealthBackfillProgress: () => ({ + done: new Set(resumeState.doneBefore), + cursors: new Map(), + edgeFilterUnsupported: new Set(), + ended: new Map(), + }), googleHealthBackfillProgressAfterCompletion: (ended: Map) => ended.size > 0 ? { ended: Object.fromEntries(ended) } : null, googleHealthBackfillRetryPending: ( @@ -79,6 +80,10 @@ vi.mock("@/lib/google-health/sync-core", () => ({ .map(([key]) => key); return keys.length > 0 ? { keys, dueAt: resumeState.retryDueAt } : null; }, +})); + +vi.mock("@/lib/google-health/sync-core", () => ({ + GOOGLE_HEALTH_INTEGRATION_KEY: "google-health", runWithGoogleHealthBackfillResume: async ( _userId: string, done: Set, @@ -124,6 +129,7 @@ vi.mock("@/lib/google-health/sync", () => ({ import { GOOGLE_HEALTH_BACKFILL_MAX_SLICE, GOOGLE_HEALTH_BACKFILL_THROTTLED_DELAY_SECONDS, + googleHealthBackfillRetryKey, runGoogleHealthBackfillForUser, } from "../google-health-backfill"; import { INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS } from "@/lib/jobs/integration-backfill-admission"; @@ -395,6 +401,46 @@ describe("runGoogleHealthBackfillForUser — slices", () => { }); }); + it("keys a waiting retry on when it is due, so a boot's slice cannot start a second chain", async () => { + resumeState.doneBefore = GOOGLE_HEALTH_COLLECTION_KEYS.filter( + (k) => k !== "fetchHeartRate", + ); + resumeState.parks = ["fetchHeartRate"]; + resumeState.retryDueAt = Date.now() + 20 * 3_600_000; + syncUserGoogleHealthMock.mockResolvedValue({ imported: 0, failed: false }); + + // The slice that parked the collection, then a boot's first slice meeting + // the same wait from the same note. + await runGoogleHealthBackfillForUser("u1", undefined, 7); + await runGoogleHealthBackfillForUser("u1", undefined, 0); + + const keys = bossSend.mock.calls.map( + (call) => (call[2] as { singletonKey: string }).singletonKey, + ); + expect(keys).toEqual([ + googleHealthBackfillRetryKey("u1", resumeState.retryDueAt), + googleHealthBackfillRetryKey("u1", resumeState.retryDueAt), + ]); + expect(keys[0]).toBe( + `google-health-backfill|u1|retry-${new Date(resumeState.retryDueAt).toISOString()}`, + ); + }); + + it("sends a retry that is already due as an ordinary next slice", async () => { + resumeState.doneBefore = GOOGLE_HEALTH_COLLECTION_KEYS.filter( + (k) => k !== "fetchHeartRate", + ); + resumeState.parks = ["fetchHeartRate"]; + resumeState.retryDueAt = Date.now() - 1_000; + syncUserGoogleHealthMock.mockResolvedValue({ imported: 0, failed: false }); + + await runGoogleHealthBackfillForUser("u1", undefined, 7); + expect(bossSend.mock.calls[0]![2]).toMatchObject({ + singletonKey: "google-health-backfill|u1|slice-8", + startAfter: INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS, + }); + }); + it("does not stamp over a parked collection when the retry cannot be queued", async () => { resumeState.doneBefore = GOOGLE_HEALTH_COLLECTION_KEYS.filter( (k) => k !== "fetchHeartRate", diff --git a/src/lib/jobs/feedback-aggregator.ts b/src/lib/jobs/feedback-aggregator.ts index 6e4d7ec8b..f1e18452e 100644 --- a/src/lib/jobs/feedback-aggregator.ts +++ b/src/lib/jobs/feedback-aggregator.ts @@ -110,7 +110,7 @@ export function buildFeedbackBuckets( row.metricSourceType, row.providerType, row.promptVersion, - ].join(""); + ].join("\x01"); const existing = map.get(key); if (existing) { if (row.helpful) existing.helpful += 1; diff --git a/src/lib/jobs/google-health-backfill.ts b/src/lib/jobs/google-health-backfill.ts index b5e94201f..82b30ed07 100644 --- a/src/lib/jobs/google-health-backfill.ts +++ b/src/lib/jobs/google-health-backfill.ts @@ -17,18 +17,19 @@ import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { enqueueIntegrationBackfillAdmission, + INTEGRATION_BACKFILL_ADMISSION_QUEUE, INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS, + integrationBackfillAdmissionSendOptions, + integrationBackfillIdentity, integrationBackfillSourceOptions, + type IntegrationBackfillAdmissionPayload, } from "@/lib/jobs/integration-backfill-admission"; +import { runWithGoogleHealthBackfillResume } from "@/lib/google-health/sync-core"; import { googleHealthBackfillProgressAfterCompletion, - readGoogleHealthBackfillCursors, - readGoogleHealthBackfillDone, - readGoogleHealthBackfillEdgeFilterUnsupported, - readGoogleHealthBackfillEnded, googleHealthBackfillRetryPending, - runWithGoogleHealthBackfillResume, -} from "@/lib/google-health/sync-core"; + readGoogleHealthBackfillProgress, +} from "@/lib/google-health/backfill-progress"; import { googleHealthBackfillEndIsFinal } from "@/lib/google-health/backfill-bounds"; import { isGoogleHealthParked, @@ -104,6 +105,23 @@ export const GOOGLE_HEALTH_BACKFILL_MAX_SLICE = ) + 2; +/** + * The singleton key of a slice that waits for a parked collection's retry: + * the import's identity and the instant the retry is due. The instant comes + * from the stored note, so every slice that reads the same note derives the + * same key. + */ +export function googleHealthBackfillRetryKey( + userId: string, + dueAt: number, +): string { + const identity = integrationBackfillIdentity({ + kind: "google-health-backfill", + data: { userId, enqueuedAt: "" }, + }); + return `${identity}|retry-${new Date(dueAt).toISOString()}`; +} + /** Test seam: a slice's budget, when not the production one. */ export interface GoogleHealthBackfillSliceOptions { sliceMs?: number; @@ -198,14 +216,12 @@ export async function runGoogleHealthBackfillForUser( }); return { imported: 0 }; } - const done = readGoogleHealthBackfillDone(connection?.backfillProgress); - const cursors = readGoogleHealthBackfillCursors(connection?.backfillProgress); - const edgeFilterUnsupported = readGoogleHealthBackfillEdgeFilterUnsupported( - connection?.backfillProgress, - ); - const endedBefore = readGoogleHealthBackfillEnded( - connection?.backfillProgress, - ); + const { + done, + cursors, + edgeFilterUnsupported, + ended: endedBefore, + } = readGoogleHealthBackfillProgress(connection?.backfillProgress); // A collection that ended short for good has read all it is going to, so // it counts as finished alongside the ones read to the end. One parked // after a first end without progress does not: it waits for another try. @@ -285,23 +301,36 @@ export async function runGoogleHealthBackfillForUser( ); } - const sendNextSlice = async (delaySeconds: number): Promise => { + /** + * Queue the next slice. A slice that waits for a parked collection's retry + * goes under a key named after when the retry is due, instead of its slice + * number: every slice that meets the same wait, a boot's included, names the + * same job, and the queue keeps one. Under the slice number a boot during + * the day-long wait started a second chain beside the waiting one. + */ + const sendNextSlice = async ( + delaySeconds: number, + retryDueAt?: number, + ): Promise => { if (slice + 1 > GOOGLE_HEALTH_BACKFILL_MAX_SLICE) return false; const boss = getGlobalBoss(); - const sent = boss - ? await enqueueIntegrationBackfillAdmission( - boss, - { - kind: "google-health-backfill", - data: { - userId, - enqueuedAt: new Date().toISOString(), - slice: slice + 1, - }, - }, - delaySeconds, - ) - : null; + if (!boss) return false; + const payload: IntegrationBackfillAdmissionPayload = { + kind: "google-health-backfill", + data: { + userId, + enqueuedAt: new Date().toISOString(), + slice: slice + 1, + }, + }; + const sent = + retryDueAt === undefined + ? await enqueueIntegrationBackfillAdmission(boss, payload, delaySeconds) + : await boss.send(INTEGRATION_BACKFILL_ADMISSION_QUEUE, payload, { + ...integrationBackfillAdmissionSendOptions(payload), + singletonKey: googleHealthBackfillRetryKey(userId, retryDueAt), + startAfter: delaySeconds, + }); return Boolean(sent); }; @@ -315,11 +344,18 @@ export async function runGoogleHealthBackfillForUser( (key) => done.has(key) || pending.keys.includes(key), ); if (pending && (onlyRetriesLeft || !failed)) { + const waitSeconds = Math.ceil((pending.dueAt - Date.now()) / 1000); const delaySeconds = Math.max( INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS, - Math.ceil((pending.dueAt - Date.now()) / 1000), + waitSeconds, ); - if (await sendNextSlice(delaySeconds)) { + // A retry already due is an ordinary next slice; only a wait is keyed on + // when it ends. + const delayed = + waitSeconds > INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS; + if ( + await sendNextSlice(delaySeconds, delayed ? pending.dueAt : undefined) + ) { annotate({ action: { name: "google_health.backfill.retry_scheduled", @@ -328,9 +364,10 @@ export async function runGoogleHealthBackfillForUser( }); return { imported, continued: true }; } - // Not queued (most often one is already waiting under the same slice - // key, sent by an earlier boot's slice). Nothing is lost: the marker is - // not stamped, so the next boot offers the backfill again. + // Not queued: most often the same retry is already waiting, sent by the + // slice that parked the collection, and this is a boot's slice meeting + // the same wait. Nothing is lost either way: the marker is not stamped, + // so the next boot offers the backfill again. annotate({ action: { name: "google_health.backfill.retry_not_queued", diff --git a/src/lib/measurements/list/day-grouped.ts b/src/lib/measurements/list/day-grouped.ts new file mode 100644 index 000000000..cd40b9d80 --- /dev/null +++ b/src/lib/measurements/list/day-grouped.ts @@ -0,0 +1,233 @@ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; +import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; +import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; +import { + canonicalDailyTimestamp, + dayKeyForUserTz, +} from "@/lib/measurements/drain-per-sample-cumulative"; +import { localDayWindow } from "@/lib/tz/local-day"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { resolveStoredTimezone } from "@/lib/tz/resolver"; +import { + Prisma, + type MeasurementSource, + type MeasurementType, +} from "@/generated/prisma/client"; +import { pageWithWindowTotal } from "./paging"; + +/** + * The day-grouped list for a cumulative type, paged over local days. + * + * Everything heavy runs in Postgres. The first query picks the page: the + * distinct local days that hold a reading, sorted and cut by `limit` / + * `offset`, each row carrying the distinct-day count of the whole filtered + * set as `meta.total` from a window count. The second folds only the page's + * days, one row per day, source, device and unit with its sum and sample + * count, and the source ladder picks among those groups exactly as it would + * among the raw rows (it keeps or drops whole source and device groups per + * day, never single rows). So a page costs its own days whatever the length + * of the history, every day is complete, and no raw row reaches the heap. + * + * Until v1.43.6 the branch read the newest `limit` raw rows (the web asked + * for 5000), bucketed them and returned every bucket. A long history came + * back as thousands of rows in one response, and every day older than the + * 5000th raw row was missing with nothing to say so. + * + * A day exists for the page when any reading of the type falls on it; the + * source collapse narrows a day's groups but never empties one, so the count + * and the folded rows agree. A value range would filter single readings and + * leave partial days behind, so the validator refuses `valueMin` / + * `valueMax` beside `groupBy=day`. + * + * Every page reads the filtered history to find its days. After the nightly + * drain a cumulative type holds about one row per day and source, so the + * scan is the index-only walk over days it would be anyway. + */ +export async function dayGroupedListResponse( + user: { id: string; timezone?: string | null }, + opts: { + type: MeasurementType; + limit: number; + offset: number; + sortDir: "asc" | "desc"; + sourceEq?: MeasurementSource; + from?: Date; + to?: Date; + }, +) { + const { type, limit, offset, sortDir } = opts; + // One resolver for the stored zone, shared with the drill-down and the + // sleep list: a usable IANA name or the instance default, never a value + // Postgres would refuse. The drill-down must cut the same days. + const tz = await resolveStoredTimezone(user.timezone); + + // The request's filters, as SQL: the same ones the plain list applies. + const filters = Prisma.sql` + m."user_id" = ${user.id} + AND m."type" = ${type}::measurement_type + AND m."deleted_at" IS NULL + ${opts.sourceEq ? Prisma.sql`AND m."source" = ${opts.sourceEq}::measurement_source` : Prisma.empty} + ${opts.from ? Prisma.sql`AND m."measured_at" >= ${opts.from}` : Prisma.empty} + ${opts.to ? Prisma.sql`AND m."measured_at" <= ${opts.to}` : Prisma.empty} + `; + const localDay = Prisma.sql`to_char((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${tz}, 'YYYY-MM-DD')`; + // Direction comes from the Zod enum, never from raw input. The window + // count runs before LIMIT, so every row of the page carries the + // distinct-day total of the whole filtered set. + const direction = Prisma.raw(sortDir === "asc" ? "ASC" : "DESC"); + const { rows: pageDays, total } = await pageWithWindowTotal( + await prisma.$queryRaw>` + SELECT d.day_key, (COUNT(*) OVER ())::int AS total + FROM ( + SELECT ${localDay} AS day_key + FROM measurements m + WHERE ${filters} + GROUP BY 1 + ) d + ORDER BY d.day_key ${direction} + LIMIT ${limit} OFFSET ${offset} + `, + offset, + async () => + Number( + ( + await prisma.$queryRaw>` + SELECT COUNT(DISTINCT ${localDay})::int AS total + FROM measurements m + WHERE ${filters} + ` + )[0]?.total ?? 0, + ), + ); + const dayKeys = pageDays.map((d) => d.day_key); + + // The page's days are consecutive in the sorted day set, so the span from + // the first one's start to the last one's end holds exactly their rows. + let groups: Array<{ + day_key: string; + source: MeasurementSource; + device_type: string | null; + unit: string; + total: number; + n: number; + }> = []; + if (dayKeys.length > 0) { + const sorted = [...dayKeys].sort(); + const spanStart = localDayWindow(sorted[0], tz).dayStart; + const spanEnd = localDayWindow(sorted[sorted.length - 1], tz).dayEnd; + // Newest group first within a day, so the day's label comes from its + // latest reading, as it did when raw rows were read newest first. + groups = await prisma.$queryRaw` + SELECT + ${localDay} AS day_key, + m."source"::text AS source, + m."device_type" AS device_type, + m."unit" AS unit, + SUM(m."value")::float8 AS total, + COUNT(*)::int AS n + FROM measurements m + WHERE ${filters} + AND m."measured_at" >= ${spanStart} + AND m."measured_at" < ${spanEnd} + GROUP BY 1, 2, 3, 4 + ORDER BY 1, MAX(m."measured_at") DESC, 2, 3, 4 + `; + } + + // v1.29.6 — collapse overlapping sources to the ladder-canonical + // reading per day BEFORE summing. Without this, a day with both an + // Apple Health AND a Withings write for the same cumulative metric + // (e.g. steps) summed BOTH sources into one inflated total — a day + // the dashboard tile (which reads through the source-collapsed + // rollup, `collapseRollupRowsBySource` in + // `src/lib/rollups/measurement-read.ts`) showed correctly. Uses the + // same `metricKeyForType` ladder lookup the rollup collapse uses, so + // this list and the tile agree on which source wins. A type with no + // ladder (e.g. TIME_IN_DAYLIGHT, FALL_COUNT) has nothing to collapse + // against — every group passes through unchanged. Each group stands at + // its day's local noon, so the picker files it under the day SQL cut. + const groupRows = groups.map((g) => ({ + ...g, + type, + deviceType: g.device_type, + measuredAt: canonicalDailyTimestamp(g.day_key, tz), + })); + const metricKey = metricKeyForType(type); + const priorityJson = metricKey ? await loadUserSourcePriority(user.id) : null; + const canonicalGroups = metricKey + ? pickCanonicalSourceRows(groupRows, metricKey, priorityJson, (d) => + dayKeyForUserTz(d, tz), + ).canonicalRows + : groupRows; + + // Sum per local day; unit and source come from the day's first kept group + // so the rendered row carries a sensible label even when sources mix. + const buckets = new Map< + string, + { total: number; sampleCount: number; unit: string; source: string } + >(); + for (const g of canonicalGroups) { + const slot = buckets.get(g.day_key); + if (!slot) { + buckets.set(g.day_key, { + total: g.total, + sampleCount: g.n, + unit: g.unit, + source: g.source, + }); + } else { + slot.total += g.total; + slot.sampleCount += g.n; + } + } + const rowsScanned = groups.reduce((sum, g) => sum + g.n, 0); + const rowsKept = canonicalGroups.reduce((sum, g) => sum + g.n, 0); + + // Rows follow the page's day order from SQL. + const measurements = dayKeys.flatMap((dayKey) => { + const b = buckets.get(dayKey); + if (!b) return []; + return [ + { + // Synthetic id so the list-row key stays stable across pages + // and the row never collides with a real `Measurement.id`. + id: `day:${type}:${dayKey}`, + type, + value: b.total, + unit: b.unit, + source: b.source, + // Anchor the row's timestamp to local noon so the row sorts + // cleanly between same-day spot samples — matches the + // `canonicalDailyTimestamp` convention used by the drain. + measuredAt: canonicalDailyTimestamp(dayKey, tz).toISOString(), + notes: null, + dayKey, + sampleCount: b.sampleCount, + }, + ]; + }); + + annotate({ + action: { name: "measurement.list" }, + meta: { + total, + type, + groupBy: "day", + days: measurements.length, + rowsScanned, + droppedDuplicates: rowsScanned - rowsKept, + }, + }); + return apiSuccess({ + measurements, + meta: { + total, + limit, + offset, + groupBy: "day", + droppedDuplicates: rowsScanned - rowsKept, + }, + }); +} diff --git a/src/lib/measurements/list/paging.ts b/src/lib/measurements/list/paging.ts new file mode 100644 index 000000000..f96ccd262 --- /dev/null +++ b/src/lib/measurements/list/paging.ts @@ -0,0 +1,17 @@ +/** + * A page cut in SQL that carries the size of the whole set. + * + * The page query selects `(COUNT(*) OVER ())::int AS total` beside its rows: + * the window count runs before `LIMIT`, so every row of the page carries the + * count of the whole filtered set and one round trip answers both. A page + * past the end has no row to carry it; only then is the set counted on its + * own, by `countAll`. + */ +export async function pageWithWindowTotal( + rows: T[], + offset: number, + countAll: () => Promise, +): Promise<{ rows: T[]; total: number }> { + if (rows.length > 0) return { rows, total: Number(rows[0].total) }; + return { rows, total: offset > 0 ? await countAll() : 0 }; +} diff --git a/src/lib/measurements/list/sleep-list.ts b/src/lib/measurements/list/sleep-list.ts new file mode 100644 index 000000000..32b205de8 --- /dev/null +++ b/src/lib/measurements/list/sleep-list.ts @@ -0,0 +1,169 @@ +/** + * The SLEEP_DURATION list: one row per night, or one night's stage segments. + * + * Sleep is stored one row per STAGE per night (minutes); a plain list would + * leak every stage segment as its own "value". The list collapses the raw rows + * into the canonical per-night shape used by the chart series and the + * dashboard tile. + * + * Two modes: + * - `dayKey` set → drill-down: the night's stage SEGMENTS (the canonical + * source's per-stage spans), read exactly as the night view reads them. + * - no `dayKey` → one synthetic row per night, value = the main sleep's TIME + * ASLEEP in minutes (the canonical unit), with the per-night stage + * breakdown, nap count and awakenings in the row so the UI can caption the + * night without a second fetch. + * + * The nights are paged in Postgres (`sleepNightPage`) and only the stage rows + * of the page's nights are read. Every page night gets a row, so a page is as + * long as its limit and `meta.total` counts exactly the rows a full walk sees. + * A day whose only sleep is daytime naps has no main night: its row carries + * 0 minutes asleep and its naps, as `/api/sleep/night` answers the same day + * with `main: null` and the naps. + */ +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; +import { getUnitForType } from "@/lib/validations/measurement"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { resolveStoredTimezone } from "@/lib/tz/resolver"; +import { + pickMainNightAndNaps, + reconstructSleepSessions, + type SleepSession, +} from "@/lib/analytics/sleep-night"; +import { + readNightSessions, + readNightSpanRows, + sleepNightPage, +} from "./sleep-nights"; + +/** + * A sleep page holds at most this many nights. The nights' stage rows are + * read and reconstructed in the process, so the bound sits on the nights; + * the applied value comes back as `meta.limit`, and `meta.total` still counts + * every night, so a caller asking for more pages on rather than losing any. + */ +export const SLEEP_NIGHT_PAGE_MAX = 1000; + +export async function sleepListResponse( + user: { id: string; timezone?: string | null }, + opts: { + from?: Date; + to?: Date; + limit: number; + offset: number; + sortDir: "asc" | "desc"; + dayKey?: string; + }, +) { + const { from, to, offset, sortDir, dayKey } = opts; + const limit = Math.min(opts.limit, SLEEP_NIGHT_PAGE_MAX); + const [tz, priorityJson] = await Promise.all([ + resolveStoredTimezone(user.timezone), + loadUserSourcePriority(user.id), + ]); + const dayUnit = getUnitForType("SLEEP_DURATION"); + + if (dayKey) { + const { sessions } = await readNightSessions( + user.id, + dayKey, + tz, + priorityJson, + { from, to }, + ); + const { main, naps } = pickMainNightAndNaps(sessions); + // One row per stage segment of the main night + each nap, so the + // drill-down reveals exactly the rows that summed into the row. + const measurements = [main, ...naps] + .filter((s): s is SleepSession => s != null) + .flatMap((s) => s.segments) + .sort((a, b) => + sortDir === "asc" + ? a.start.getTime() - b.start.getTime() + : b.start.getTime() - a.start.getTime(), + ) + .map((seg, i) => ({ + id: `sleep-seg:${dayKey}:${i}`, + type: "SLEEP_DURATION", + value: Math.round(seg.minutes), + unit: dayUnit, + source: main?.source ?? naps[0]?.source ?? "MANUAL", + measuredAt: seg.end.toISOString(), + sleepStage: seg.stage, + notes: null, + })); + annotate({ + action: { name: "measurement.list" }, + meta: { + total: measurements.length, + type: "SLEEP_DURATION", + dayKey, + mode: "sleep-drill-down", + }, + }); + return apiSuccess({ + measurements, + meta: { total: measurements.length, limit, offset: 0, dayKey }, + }); + } + + const page = await sleepNightPage(user.id, tz, { + from, + to, + sortDir, + limit, + offset, + }); + const rows = await readNightSpanRows(user.id, page.nights, { from, to }); + const byDay = new Map(); + for (const s of reconstructSleepSessions(rows, tz, priorityJson)) { + const list = byDay.get(s.night) ?? []; + list.push(s); + byDay.set(s.night, list); + } + + // Rows follow the page's night order from SQL. + const measurements = page.nights.map(({ night, spanEnd }) => { + const { main, naps } = pickMainNightAndNaps(byDay.get(night) ?? []); + const lastNap = naps.at(-1); + return { + // Synthetic id mirrors the series route's `sleep:` shape. + id: `sleep:${night}`, + type: "SLEEP_DURATION", + value: main ? Math.round(main.asleepMinutes) : 0, + unit: dayUnit, + source: main?.source ?? naps[0]?.source ?? "MANUAL", + measuredAt: (main?.end ?? lastNap?.end ?? spanEnd).toISOString(), + notes: null, + dayKey: night, + // Drives the drill-down chevron: the stage segments behind the row, + // the main night's and the naps', exactly what the drill-down returns. + sampleCount: [main, ...naps].reduce( + (sum, s) => sum + (s?.segments.length ?? 0), + 0, + ), + sleepStages: (main?.stages ?? {}) as Record, + inBedMinutes: main?.inBedMinutes ?? null, + awakeMinutes: main?.awakeMinutes ?? null, + napCount: naps.length, + napAsleepMinutes: naps.reduce((sum, n) => sum + n.asleepMinutes, 0), + awakenings: main?.awakenings ?? 0, + }; + }); + + annotate({ + action: { name: "measurement.list" }, + meta: { + total: page.total, + type: "SLEEP_DURATION", + mode: "sleep-per-night", + nights: measurements.length, + rowsScanned: rows.length, + }, + }); + return apiSuccess({ + measurements, + meta: { total: page.total, limit, offset, groupBy: "night" }, + }); +} diff --git a/src/lib/measurements/list/sleep-nights.ts b/src/lib/measurements/list/sleep-nights.ts new file mode 100644 index 000000000..ee6f3608a --- /dev/null +++ b/src/lib/measurements/list/sleep-nights.ts @@ -0,0 +1,321 @@ +/** + * Sleep nights read from Postgres, a page or a night at a time. + * + * Sleep is stored one row per stage, so a long history is a large row set: + * every reader here keeps the stage rows that reach the process to the + * nights it was asked about, and the reconstruction itself stays the one in + * `sleep-night.ts`. The SQL below only decides which nights exist and where + * their sessions lie; what a night holds is always the JS reconstruction's. + */ +import { prisma } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; +import type { MeasurementSource, SleepStage } from "@/generated/prisma/client"; +import { + ASLEEP_STAGES, + SESSION_GAP_MS, + reconstructSleepNights, + reconstructSleepSessions, + type SleepNight, + type SleepSession, +} from "@/lib/analytics/sleep-night"; +import { localDayWindow } from "@/lib/tz/local-day"; +import { pageWithWindowTotal } from "./paging"; + +/** A stage row as every sleep reader selects it. */ +export interface SleepRow { + id: string; + value: number; + measuredAt: Date; + sleepStage: SleepStage | null; + source: MeasurementSource; + deviceType: string | null; +} + +/** The read bounds a list request passes through, both inclusive. */ +export interface SleepReadBounds { + from?: Date; + to?: Date; +} + +/** + * How far a one-night read reaches either side of its day. Sessions split at + * a three-hour gap, so a session ending on that day starts inside the reach + * unless it lasted over a day and a half, and every session the cut + * truncates ends on another day. + */ +export const NIGHT_READ_REACH_MS = 36 * 60 * 60 * 1000; + +/** + * The asleep stages as a SQL list. A whitelist splice: each value is asserted + * against the closed enum shape before it is joined, so nothing from a + * request can reach it, and the list is `ASLEEP_STAGES`, the very set the + * JS reconstruction counts as asleep. + */ +const ASLEEP_STAGE_SQL = Prisma.raw( + ASLEEP_STAGES.map((stage) => { + if (!/^[A-Z_]+$/.test(stage)) throw new Error(`bad sleep stage ${stage}`); + return `'${stage}'`; + }).join(", "), +); + +/** + * Stage rows between two instants (inclusive), oldest first, narrowed by the + * request's own bounds. + */ +export async function readSleepRows( + userId: string, + gte: Date, + lte: Date, + bounds: SleepReadBounds = {}, +): Promise { + return prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION", + deletedAt: null, + AND: [ + { measuredAt: { gte, lte } }, + ...(bounds.from ? [{ measuredAt: { gte: bounds.from } }] : []), + ...(bounds.to ? [{ measuredAt: { lte: bounds.to } }] : []), + ], + }, + orderBy: [{ measuredAt: "asc" }, { id: "asc" }], + select: { + id: true, + value: true, + measuredAt: true, + sleepStage: true, + source: true, + // Writer-level collapse: two HealthKit apps behind one source (watch + // stages vs phone in-bed) must not blend into one night. + deviceType: true, + }, + }); +} + +/** + * The sessions of one wake day: the stage rows within `NIGHT_READ_REACH_MS` + * of the day, reconstructed, and the sessions filed under it kept. + */ +export async function readNightSessions( + userId: string, + day: string, + tz: string, + priorityJson: unknown, + bounds: SleepReadBounds = {}, +): Promise<{ sessions: SleepSession[]; rows: SleepRow[] }> { + const { dayStart, dayEnd } = localDayWindow(day, tz); + const rows = await readSleepRows( + userId, + new Date(dayStart.getTime() - NIGHT_READ_REACH_MS), + new Date(dayEnd.getTime() + NIGHT_READ_REACH_MS), + bounds, + ); + const sessions = reconstructSleepSessions(rows, tz, priorityJson).filter( + (s) => s.night === day, + ); + return { sessions, rows }; +} + +/** + * The wake day of the most recent night that holds sleep, or null. Found + * from the latest asleep row: the night holding it is the newest one, and + * its sessions lie within the one-night reach of that row. + */ +export async function latestSleepNight( + userId: string, + tz: string, + priorityJson: unknown, +): Promise { + const latest = await prisma.measurement.findFirst({ + where: { + userId, + type: "SLEEP_DURATION", + deletedAt: null, + value: { gt: 0 }, + OR: [{ sleepStage: null }, { sleepStage: { in: [...ASLEEP_STAGES] } }], + }, + orderBy: { measuredAt: "desc" }, + select: { measuredAt: true }, + }); + if (!latest) return null; + const at = latest.measuredAt.getTime(); + const rows = await readSleepRows( + userId, + new Date(at - NIGHT_READ_REACH_MS), + new Date(at + NIGHT_READ_REACH_MS), + ); + const scorable = reconstructSleepSessions(rows, tz, priorityJson).filter( + (s) => s.asleepMinutes > 0, + ); + return scorable.at(-1)?.night ?? null; +} + +/** One night of a page: its wake day and the span its sessions cover. */ +export interface SleepNightSpan { + night: string; + spanStart: Date; + spanEnd: Date; +} + +/** + * One page of sleep nights, chosen in Postgres. + * + * The stage rows are cut into sessions the way `reconstructSleepSessions` + * cuts them: ordered by start (`measured_at` minus the minutes), a new + * session wherever a row starts more than `SESSION_GAP_MS` after every + * earlier row has ended. A session counts when it holds an asleep row (an + * `ASLEEP_STAGES` stage, or a stage-less row, with minutes), and its night is + * the local day its last row ends on: the reconstruction's own rule, so a + * page night and a reconstructed night name the same day. Nights are grouped, + * sorted and cut by `limit` / `offset`; `total` counts the whole filtered set. + * `spanStart` / `spanEnd` bound the night's counting sessions, so the caller + * reads exactly their rows. + * + * Before the cut, rows are folded into hour buckets by their end: min start, + * max end, any asleep. That cannot move a session boundary. Two rows whose + * ends lie less than the session gap apart always share a session (the later + * one starts before its own end, which is within the gap of the earlier end), + * so a bucket narrower than the gap only ever holds rows of one session, and + * its bounds are that group's bounds. It cuts the rows the window functions + * sort from every stage to a handful per night. + */ +export async function sleepNightPage( + userId: string, + tz: string, + opts: SleepReadBounds & { + sortDir: "asc" | "desc"; + limit: number; + offset: number; + }, +): Promise<{ nights: SleepNightSpan[]; total: number }> { + const gapSeconds = SESSION_GAP_MS / 1000; + // Direction comes from the Zod enum, never from raw input. + const direction = Prisma.raw(opts.sortDir === "asc" ? "ASC" : "DESC"); + const nightsCte = Prisma.sql` + WITH r AS ( + SELECT + MIN(m."measured_at" - m."value" * interval '1 minute') AS s, + MAX(m."measured_at") AS e, + BOOL_OR(m."value" > 0 AND (m."sleep_stage" IS NULL + OR m."sleep_stage" IN (${ASLEEP_STAGE_SQL}))) AS asleep + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."type" = 'SLEEP_DURATION'::measurement_type + AND m."deleted_at" IS NULL + ${opts.from ? Prisma.sql`AND m."measured_at" >= ${opts.from}` : Prisma.empty} + ${opts.to ? Prisma.sql`AND m."measured_at" <= ${opts.to}` : Prisma.empty} + GROUP BY date_bin(interval '1 hour', m."measured_at", TIMESTAMPTZ '2000-01-01 00:00:00+00') + ), o AS ( + SELECT r.*, MAX(e) OVER ( + ORDER BY s, e ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ) AS prev_end + FROM r + ), g AS ( + SELECT o.*, SUM( + CASE WHEN prev_end IS NULL + OR s - prev_end > make_interval(secs => ${gapSeconds}::double precision) THEN 1 ELSE 0 END + ) OVER (ORDER BY s, e ROWS UNBOUNDED PRECEDING) AS sid + FROM o + ), sess AS ( + SELECT MIN(s) AS s, MAX(e) AS e + FROM g + GROUP BY sid + HAVING BOOL_OR(asleep) + ), nights AS ( + SELECT + to_char((e AT TIME ZONE 'UTC') AT TIME ZONE ${tz}, 'YYYY-MM-DD') AS night, + MIN(s) AS span_start, + MAX(e) AS span_end + FROM sess + GROUP BY 1 + ) + `; + const { rows, total } = await pageWithWindowTotal( + await prisma.$queryRaw< + Array<{ night: string; span_start: Date; span_end: Date; total: number }> + >` + ${nightsCte} + SELECT night, span_start, span_end, (COUNT(*) OVER ())::int AS total + FROM nights + ORDER BY night ${direction} + LIMIT ${opts.limit} OFFSET ${opts.offset} + `, + opts.offset, + async () => + Number( + ( + await prisma.$queryRaw>` + ${nightsCte} + SELECT COUNT(*)::int AS total FROM nights + ` + )[0]?.total ?? 0, + ), + ); + return { + nights: rows.map((r) => ({ + night: r.night, + spanStart: r.span_start, + spanEnd: r.span_end, + })), + total, + }; +} + +/** + * The stage rows of a set of page nights, read in one span. The nights are + * consecutive in the sorted night set, so the span from the first one's start + * to the last one's end holds their sessions whole; any other session in it + * holds no sleep. + */ +export async function readNightSpanRows( + userId: string, + nights: readonly SleepNightSpan[], + bounds: SleepReadBounds = {}, +): Promise { + if (nights.length === 0) return []; + let spanStart = nights[0].spanStart; + let spanEnd = nights[0].spanEnd; + for (const n of nights) { + if (n.spanStart < spanStart) spanStart = n.spanStart; + if (n.spanEnd > spanEnd) spanEnd = n.spanEnd; + } + return readSleepRows(userId, spanStart, spanEnd, bounds); +} + +/** How many nights one chunked read reconstructs at a time. */ +const NIGHT_CHUNK = 90; + +/** + * Every night in a window as `reconstructSleepNights` builds it, oldest + * first, without holding the window's stage rows at once: Postgres names the + * nights and their spans, and the rows are read and reconstructed ninety + * nights at a time. Any window length costs a bounded heap, so a reader never + * has to cut a long range short to stay safe. `maxNights` keeps the oldest + * nights of the window, or with `newest` the most recent ones. + */ +export async function readSleepNights( + userId: string, + tz: string, + priorityJson: unknown, + bounds: SleepReadBounds & { maxNights: number; newest?: boolean }, +): Promise { + const page = await sleepNightPage(userId, tz, { + from: bounds.from, + to: bounds.to, + sortDir: bounds.newest ? "desc" : "asc", + limit: bounds.maxNights, + offset: 0, + }); + const nights = bounds.newest ? page.nights.reverse() : page.nights; + const out: SleepNight[] = []; + for (let i = 0; i < nights.length; i += NIGHT_CHUNK) { + const chunk = nights.slice(i, i + NIGHT_CHUNK); + const keys = new Set(chunk.map((n) => n.night)); + const rows = await readNightSpanRows(userId, chunk, bounds); + for (const night of reconstructSleepNights(rows, tz, priorityJson)) { + if (keys.has(night.night)) out.push(night); + } + } + return out; +} diff --git a/src/lib/observability/__tests__/heap-monitor.test.ts b/src/lib/observability/__tests__/heap-monitor.test.ts index 4d99544dd..9e686a52d 100644 --- a/src/lib/observability/__tests__/heap-monitor.test.ts +++ b/src/lib/observability/__tests__/heap-monitor.test.ts @@ -23,7 +23,9 @@ import { walkSourceFiles } from "@/__tests__/helpers/source-files"; import { HEAP_CHECK_INTERVAL_MS, + HEAP_CRITICAL_RATIO, HEAP_PRESSURE_COOLDOWN_MS, + HEAP_PRESSURE_RATIO, HEAP_PRESSURE_LIST_MAX, HEAP_SAMPLE_INTERVAL_ELEVATED_MS, HEAP_SAMPLE_INTERVAL_MS, @@ -175,6 +177,41 @@ describe("heap pressure report", () => { expect(h.named(HEAP_TASK_NAMES.pressure)).toHaveLength(2); }); + it("reports again at 95 % inside the cooldown, once per climb", () => { + const h = harness(); + h.state.used = 900; + h.run(60_000); + // Ten minutes later, well inside the cooldown, the heap nears its limit. + h.state.used = 800; + h.run(10 * 60_000); + h.state.used = 960; + h.run(60_000); + const pressure = h.named(HEAP_TASK_NAMES.pressure); + expect(pressure.map((p) => p.meta.pressure_ratio)).toEqual([ + HEAP_PRESSURE_RATIO, + HEAP_CRITICAL_RATIO, + ]); + expect(pressure[1].meta).toHaveProperty("in_flight_oldest"); + + // Hovering at 95 % writes nothing more; back under 85 % re-arms it. + h.run(5 * 60_000); + expect(h.named(HEAP_TASK_NAMES.pressure)).toHaveLength(2); + h.state.used = 840; + h.run(HEAP_CHECK_INTERVAL_MS); + h.state.used = 970; + h.run(HEAP_CHECK_INTERVAL_MS); + expect(h.named(HEAP_TASK_NAMES.pressure)).toHaveLength(3); + }); + + it("writes one report at the tier a sudden jump reaches", () => { + const h = harness(); + h.state.used = 990; + h.run(60_000); + const pressure = h.named(HEAP_TASK_NAMES.pressure); + expect(pressure).toHaveLength(1); + expect(pressure[0].meta.pressure_ratio).toBe(HEAP_CRITICAL_RATIO); + }); + it("writes no snapshot unless opted in, and at most one when it is", () => { const off = harness(); off.state.used = 900; diff --git a/src/lib/observability/heap-monitor.ts b/src/lib/observability/heap-monitor.ts index e05d4eb94..362a9f7ae 100644 --- a/src/lib/observability/heap-monitor.ts +++ b/src/lib/observability/heap-monitor.ts @@ -9,6 +9,7 @@ import { import { envFlag, envOr } from "@/lib/env"; import { WideEventBuilder } from "@/lib/logging/event-builder"; import { emitIfSampled } from "@/lib/logging/transports"; +import { megabytes } from "@/lib/support/heap-limit"; import { inFlightRegistry, type InFlightRegistry } from "./in-flight"; @@ -32,7 +33,11 @@ import { inFlightRegistry, type InFlightRegistry } from "./in-flight"; * flight and the routes most of them share (see `in-flight.ts`). It * re-arms only after the heap has fallen back under 70 % AND thirty minutes * have passed, so a heap hovering at the line writes one report, not one - * every check. + * every check. A second tier at 95 % writes the same report with no + * cooldown at all, and re-arms as soon as the heap is back under 85 %: + * a heap that climbs to its limit within the half hour after an 85 % + * report would otherwise die with only that older report on record, the + * requests that pushed it over missing from it. * - `runtime.heap.snapshot`, at `warn`, only with the opt-in below: where * the snapshot was written, or that writing it failed. * @@ -63,6 +68,7 @@ export const HEAP_SAMPLE_INTERVAL_MS = 5 * 60_000; export const HEAP_SAMPLE_INTERVAL_ELEVATED_MS = 60_000; export const HEAP_ELEVATED_RATIO = 0.7; export const HEAP_PRESSURE_RATIO = 0.85; +export const HEAP_CRITICAL_RATIO = 0.95; export const HEAP_PRESSURE_COOLDOWN_MS = 30 * 60_000; export const HEAP_PRESSURE_LIST_MAX = 50; @@ -101,10 +107,6 @@ export interface HeapMonitorDeps { writeSnapshot: () => SnapshotOutcome; } -function megabytes(bytes: number): number { - return Math.round(bytes / 1024 / 1024); -} - function defaultReadHeap(): { used: number; limit: number } { const stats = getHeapStatistics(); return { used: stats.used_heap_size, limit: stats.heap_size_limit }; @@ -205,6 +207,7 @@ export function writeDiagnosticHeapSnapshot(): SnapshotOutcome { export class HeapMonitor { private lastSampleAt: number | null = null; private pressureArmed = true; + private criticalArmed = true; private lastPressureAt: number | null = null; private snapshotTaken = false; @@ -225,11 +228,29 @@ export class HeapMonitor { this.pressureArmed = true; } + if (!this.criticalArmed && ratio < HEAP_PRESSURE_RATIO) { + this.criticalArmed = true; + } + if (this.pressureArmed && ratio >= HEAP_PRESSURE_RATIO) { this.pressureArmed = false; this.lastPressureAt = at; this.lastSampleAt = at; - this.reportPressure(at); + // A heap that jumps straight past 95 % gets one report, at the tier + // it reached. + const critical = ratio >= HEAP_CRITICAL_RATIO; + if (critical) this.criticalArmed = false; + this.reportPressure( + at, + critical ? HEAP_CRITICAL_RATIO : HEAP_PRESSURE_RATIO, + ); + return; + } + + if (this.criticalArmed && ratio >= HEAP_CRITICAL_RATIO) { + this.criticalArmed = false; + this.lastSampleAt = at; + this.reportPressure(at, HEAP_CRITICAL_RATIO); return; } @@ -245,7 +266,7 @@ export class HeapMonitor { } } - private reportPressure(at: number): void { + private reportPressure(at: number, tier: number): void { const { registry, emit, readFigures } = this.deps; const inFlight = registry.report(at, HEAP_PRESSURE_LIST_MAX); const willSnapshot = this.deps.snapshotEnabled && !this.snapshotTaken; @@ -253,7 +274,7 @@ export class HeapMonitor { // process over, the report is already on stdout. emit(HEAP_TASK_NAMES.pressure, "warn", { ...readFigures(), - pressure_ratio: HEAP_PRESSURE_RATIO, + pressure_ratio: tier, in_flight_oldest: inFlight.oldest, in_flight_by_route: inFlight.by_route, snapshot: willSnapshot ? "pending" : "disabled", diff --git a/src/lib/openapi/routes/analytics.ts b/src/lib/openapi/routes/analytics.ts index bbdda4373..5bbe0f52a 100644 --- a/src/lib/openapi/routes/analytics.ts +++ b/src/lib/openapi/routes/analytics.ts @@ -18,6 +18,7 @@ * to the derived contract cannot leave this document behind. */ import { z } from "zod/v4"; +import { recoveryEmptySchema } from "./recovery-empty"; import type { ZodOpenApiObject } from "zod-openapi"; import { SCORE_PILLAR_IDS } from "@/lib/analytics/score/types"; @@ -140,11 +141,12 @@ const analyticsSummariesSlice = z ), lastSeenByType, sleepSourceDiscrepancy, + recoveryEmpty: recoveryEmptySchema, }) .meta({ id: "AnalyticsSummariesSlice", description: - "What `?slice=summaries` answers: the per-type summaries and their freshness, and nothing thick. Four keys, and the caller gets them from two SQL passes rather than the default slice's chain.", + "What `?slice=summaries` answers: the per-type summaries and their freshness, the recovery page's empty reason, and nothing thick. Five keys, and the caller gets them from two SQL passes rather than the default slice's chain.", }); // ── Correlations ───────────────────────────────────────────────────── diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index c18ba6769..d9f79fc7f 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -5,6 +5,7 @@ * Schemas come from `src/lib/validations/*` where shared with the * runtime request parsing, so the wire contract stays single-source. */ +import { recoveryEmptySchema } from "../recovery-empty"; import { trendSentiment, trendVerdict } from "../trend-verdict"; import { z } from "zod/v4"; import { measurementSourceEnum, measurementTypeEnum } from "../shared"; @@ -324,7 +325,7 @@ export const sleepScoreValue = z .enum(["few_nights", "no_main_sleep"]) .optional() .describe( - "Only on a null `consistency` / `timing`: why it is withheld. `few_nights` — fewer than three nights in the window have a main sleep on the clock (count in `rhythmNights`); `no_main_sleep` — the latest day had only a daytime nap. Absent otherwise.", + "Only on a null `consistency` / `timing`: why it is withheld. `few_nights` — fewer than `rhythmMinNights` nights in the window have a main sleep on the clock (count in `rhythmNights`); `no_main_sleep` — the latest day had only a daytime nap. Absent otherwise.", ), }), ) @@ -336,6 +337,12 @@ export const sleepScoreValue = z .describe( "Nights in the window with a main sleep placed on the clock — what consistency and timing are computed from. A daytime nap is not one; a short night is.", ), + rhythmMinNights: z + .number() + .int() + .describe( + 'How many `rhythmNights` consistency and timing need before they are scored; below it they carry `gap: "few_nights"`.', + ), series: z .array(z.number()) .describe( @@ -2032,6 +2039,7 @@ export const analyticsSummariesSliceResponse = z ), lastSeenByType: z.record(z.string(), lastSeenSlot), sleepSourceDiscrepancy, + recoveryEmpty: recoveryEmptySchema, }) .meta({ id: "AnalyticsSummariesSlice", diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 35e46830c..ab61bae48 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -465,7 +465,7 @@ const sleepNightResponse = z .meta({ id: "SleepNightResource", description: - "One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps surfaced separately.", + "One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps surfaced separately. A session is a nap when it holds under three hours asleep centred between 10:00 and 20:00 local time; the main session is the longest session that is not a nap. A day whose only sleep is naps has `main: null` and its naps.", }); // ── Sleep rhythm (sleep-debt + chronotype, v1.17.0) ────────────────── @@ -804,7 +804,7 @@ export const measurementPaths: NonNullable = { summary: "List measurements", description: "Filter by type + date range. Response is paged via offset/limit; default limit 100.\n\n" + - "With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over those days; every returned day is complete. `type=SLEEP_DURATION` returns one row per night, paged the same way over nights. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes `limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the value applied.", + "With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over those days; every returned day is complete, which is why `valueMin` / `valueMax` (a filter on single readings) are refused with 422 beside `groupBy=day`. `type=SLEEP_DURATION` returns one row per night, paged the same way over nights; every night of the page has a row, and a day whose only sleep is daytime naps carries `value` 0 with its naps in `napCount` / `napAsleepMinutes`. Its `dayKey` is the same day `/api/sleep/night?date=` answers for. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes `limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the value applied.", requestParams: { query: listMeasurementsSchema, }, @@ -1118,7 +1118,7 @@ export const measurementPaths: NonNullable = { tags: ["Measurements"], summary: "Reconstructed sleep night (hypnogram source, v1.11.5)", description: - "Returns one night's reconstructed sleep session for the phase-progression (hypnogram) view: the canonical source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals, the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date` omitted returns the most recent night. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.", + "Returns one night's reconstructed sleep session for the phase-progression (hypnogram) view: the canonical source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals, the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date` omitted returns the most recent night holding sleep, however old. Any past date is answered, with no window cut. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.", requestParams: { query: z.object({ date: z diff --git a/src/lib/openapi/routes/recovery-empty.ts b/src/lib/openapi/routes/recovery-empty.ts new file mode 100644 index 000000000..ae89f5fc2 --- /dev/null +++ b/src/lib/openapi/routes/recovery-empty.ts @@ -0,0 +1,10 @@ +import { z } from "zod/v4"; +import { RECOVERY_EMPTY_REASONS } from "@/lib/insights/recovery-empty"; + +/** Why the recovery page is empty; shared by both slim-slice schemas. */ +export const recoveryEmptySchema = z + .enum(RECOVERY_EMPTY_REASONS) + .nullable() + .describe( + "Why the recovery page has nothing to chart, resolved on the server; null when any recovery type (ANS_CHARGE, DAY_STRAIN, WORKOUT_STRAIN, CARDIO_LOAD, AVERAGE_HEART_RATE, MAX_HEART_RATE, ENERGY_EXPENDITURE_KJ, RESILIENCE) has a reading. `oura_permission`: Oura is connected and its grant leaves out the scope the resilience level needs, one reconnect fixes it. `oura_no_resilience`: Oura is connected and no level has arrived yet. `general`: anyone else. Clients render the empty note from this value and never work it out from the connection status.", + ); diff --git a/src/lib/support/heap-limit.ts b/src/lib/support/heap-limit.ts index e11c9656e..80f0dcfc6 100644 --- a/src/lib/support/heap-limit.ts +++ b/src/lib/support/heap-limit.ts @@ -14,9 +14,14 @@ import { getHeapStatistics } from "node:v8"; /** Below this, the heap is flagged. Between the 1g (524) and 1536m (792) heaps. */ export const LOW_HEAP_LIMIT_MB = 700; +/** Bytes as whole megabytes (MiB), the unit every heap figure is shown in. */ +export function megabytes(bytes: number): number { + return Math.round(bytes / 1024 / 1024); +} + /** V8's heap limit for this process, in megabytes. */ export function heapLimitMb(): number { - return Math.round(getHeapStatistics().heap_size_limit / 1024 / 1024); + return megabytes(getHeapStatistics().heap_size_limit); } export function isHeapLimitLow(limitMb: number): boolean { diff --git a/src/lib/support/report.ts b/src/lib/support/report.ts index 15ac11743..1160b0327 100644 --- a/src/lib/support/report.ts +++ b/src/lib/support/report.ts @@ -25,6 +25,7 @@ import { heapLimitMb, isHeapLimitLow, lowHeapLimitAdvice, + megabytes, } from "@/lib/support/heap-limit"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc"; @@ -692,8 +693,8 @@ function or(value: string | number | null | undefined, fallback = "unknown") { : String(value); } -function megabytes(bytes: number | null): string { - return bytes === null ? "unknown" : `${Math.round(bytes / 1024 / 1024)} MB`; +function sizeLine(bytes: number | null): string { + return bytes === null ? "unknown" : `${megabytes(bytes)} MB`; } function runLine(run: QueueRunFact | null): string { @@ -741,7 +742,7 @@ export function renderSupportReport(facts: SupportReportFacts): string { section("Database"); row("PostgreSQL", facts.database.version); row("Server time zone", facts.database.timezone); - row("Size", megabytes(facts.database.sizeBytes)); + row("Size", sizeLine(facts.database.sizeBytes)); row( "Last migration", facts.database.lastMigration diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 24eb63e4e..a68336186 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -589,7 +589,7 @@ export const listMeasurementsSchema = z // Omitting `aggregate` keeps the raw wire shape (iOS contract); the // chart-data client must opt in explicitly. aggregate: z.enum(["raw", "daily", "weekly", "monthly"]).optional(), - // v1.4.36 W1 — opt-in source switch for daily-aggregate reads. When + // v1.4.36 — opt-in source switch for daily-aggregate reads. When // `source=rollup` + `aggregate=daily`, the route reads from the // persistent `measurement_rollups` DAY buckets instead of running a // live `date_trunc` GROUP BY scan over the raw `measurements` table. @@ -612,7 +612,7 @@ export const listMeasurementsSchema = z // `value: { gte, lte }` range. Server-authoritative; validated min<=max. valueMin: z.coerce.number().finite().optional(), valueMax: z.coerce.number().finite().optional(), - // v1.4.37 W7c — list-view "one row per day" mode for cumulative + // v1.4.37 — list-view "one row per day" mode for cumulative // types (steps, active energy, distance, flights, daylight). When // `groupBy=day` is set and `type` is a cumulative HK type, the route // returns one synthesised row per user-TZ day with `value` = SUM @@ -623,18 +623,18 @@ export const listMeasurementsSchema = z // picks the page's local days in Postgres and reports the distinct-day // count as `meta.total` (#1245). groupBy: z.enum(["day"]).optional(), - // v1.4.37 W7c — drill-down to per-sample rows for a single day in + // v1.4.37 — drill-down to per-sample rows for a single day in // the user's IANA timezone. Format `YYYY-MM-DD`; the route resolves // the day boundary against the user's `User.timezone`. Used by the // expandable list row to reveal the chunks that contributed to the // collapsed daily total. // - // v1.4.37 W10 — a drill-down returns one bounded page rather than a + // v1.4.37 — a drill-down returns one bounded page rather than a // cursor, so it refuses a non-zero `offset`. dayKey: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/, "dayKey must be YYYY-MM-DD") - // v1.4.37 W10 — a `YYYY-MM-DD` string can satisfy the regex while + // v1.4.37 — a `YYYY-MM-DD` string can satisfy the regex while // still being an impossible calendar date (`2026-02-30`, // `2026-13-01`). `new Date("2026-02-30T00:00:00Z")` silently // overflows to March 2, so the drill-down would return rows from @@ -665,6 +665,19 @@ export const listMeasurementsSchema = z "limit must be <= 1000 when dayKey is set; drill-down responses are capped at 1000 rows", path: ["limit"], }) + // A value range filters single readings, and a day-grouped row is the sum + // of a whole day: filtering its readings would return partial days summed + // as if they were whole. The combination has no honest answer, so it is + // refused rather than served. + .refine( + ({ groupBy, valueMin, valueMax }) => + groupBy !== "day" || (valueMin == null && valueMax == null), + { + message: + "valueMin / valueMax filter single readings and cannot be combined with groupBy=day", + path: ["groupBy"], + }, + ) // v1.18.5 — an inverted value range (`valueMin > valueMax`) is a client // bug, not an empty result set; reject it at the validator with a 422 so // the filter UI can surface the mismatch instead of silently returning diff --git a/src/types/analytics.ts b/src/types/analytics.ts index f0628c71d..16e9e91bc 100644 --- a/src/types/analytics.ts +++ b/src/types/analytics.ts @@ -33,6 +33,7 @@ * surface. Three named shapes capture the contract; one swiss-army * shape loses it. */ +import type { RecoveryEmptyReason } from "@/lib/insights/recovery-empty"; import type { DataSummary } from "@/lib/analytics/trends"; import type { SleepSourceDiscrepancy } from "@/lib/analytics/sleep-night"; import type { CorrelationResult } from "@/lib/insights/correlations"; @@ -53,6 +54,11 @@ export interface SubPageAnalyticsData { string, { lastSeenAt: string; daysAgo: number } | null >; + /** + * Why the recovery page has nothing to chart, resolved on the server + * (`resolveRecoveryEmpty`); null when it has. Slim slice only. + */ + recoveryEmpty?: RecoveryEmptyReason | null; } /** diff --git a/tests/integration/google-health-backfill-progress-bound.test.ts b/tests/integration/google-health-backfill-progress-bound.test.ts index df945ae9f..ac40e43d1 100644 --- a/tests/integration/google-health-backfill-progress-bound.test.ts +++ b/tests/integration/google-health-backfill-progress-bound.test.ts @@ -331,14 +331,29 @@ describe("Google Health backfill bounded by progress, not pages (#1220)", () => expect(fake.heartRateRequests).toBeLessThanOrEqual(10 + 2 * slices); }, 600_000); - it("parks a walk Google keeps answering with the same page, tries it again a day later, and only then ends it", async () => { + it("parks a walk Google keeps answering with the same page, and ends it only after a day brings no progress", async () => { await seed({ total: 5_000, spacing: 60_000, perPage: 1000 }); fake.loop = true; const { GOOGLE_HEALTH_NO_PROGRESS_PAGES } = await import("@/lib/google-health/backfill-bounds"); const { googleHealthBackfillEndedList } = - await import("@/lib/google-health/sync-core"); + await import("@/lib/google-health/backfill-progress"); const walk = 1 + GOOGLE_HEALTH_NO_PROGRESS_PAGES; + const prisma = getPrismaClient(); + /** Let a day pass for the parked walk. */ + const dayLater = async () => { + const progress = (await connection()).backfillProgress as { + ended: Record; + }; + progress.ended.fetchHeartRate!.endedAt = new Date( + Date.now() - 25 * HOUR, + ).toISOString(); + await prisma.googleHealthConnection.update({ + where: { userId: USER }, + data: { backfillProgress: progress }, + }); + return progress.ended.fetchHeartRate!; + }; // First attempt: the first page and the twenty that repeat it. Not // complete, nothing published, and the next slice a day out. @@ -354,23 +369,26 @@ describe("Google Health backfill bounded by progress, not pages (#1220)", () => expect(fake.heartRateRequests).toBe(walk); expect((await connection()).backfillCompletedAt).toBeNull(); - // A day later the walk goes on from its edge, gets one page further, - // meets the same wall, and this time the history ends there. - const prisma = getPrismaClient(); - const progress = c.backfillProgress as { - ended: Record; - }; - progress.ended.fetchHeartRate!.endedAt = new Date( - Date.now() - 25 * HOUR, - ).toISOString(); - await prisma.googleHealthConnection.update({ - where: { userId: USER }, - data: { backfillProgress: progress }, - }); + // A day later the walk goes on from its edge and gets one page further + // before the wall. That is progress: it parks again, counting afresh, + // rather than ending a history that is still coming in. + expect((await dayLater()).attempts ?? 1).toBe(1); await runChain(5, sent.slices.at(-1)!); - expect(fake.heartRateRequests).toBe(2 * walk); c = await connection(); + expect(c.backfillCompletedAt).toBeNull(); + expect(await pulseRows()).toBe(1999); + expect((await dayLater()).attempts ?? 1).toBe(1); + + // Another day, and nothing lies past the edge any more: the walk meets + // the wall where it stood, and this time the history ends there. + fake.total = 1999; + await runChain(5, sent.slices.at(-1)!); + // No page of this attempt is new, the first included. + expect(fake.heartRateRequests).toBe( + 2 * walk + GOOGLE_HEALTH_NO_PROGRESS_PAGES, + ); + c = await connection(); expect(c.backfillCompletedAt).toBeInstanceOf(Date); expect(googleHealthBackfillEndedList(c.backfillProgress)).toEqual([ { @@ -413,7 +431,7 @@ describe("Google Health backfill bounded by progress, not pages (#1220)", () => await runChain(5); const { googleHealthBackfillEndedList } = - await import("@/lib/google-health/sync-core"); + await import("@/lib/google-health/backfill-progress"); const c = await connection(); expect(c.backfillCompletedAt).toBeInstanceOf(Date); const ended = googleHealthBackfillEndedList(c.backfillProgress); diff --git a/tests/integration/measurements-day-grouped-paging.test.ts b/tests/integration/measurements-day-grouped-paging.test.ts index 738a86f6d..17adde8f9 100644 --- a/tests/integration/measurements-day-grouped-paging.test.ts +++ b/tests/integration/measurements-day-grouped-paging.test.ts @@ -239,4 +239,56 @@ describe("GET /api/measurements?groupBy=day paging (#1245)", () => { expect(windowed.data.meta.total).toBe(inWindow); expect(windowed.data.measurements).toHaveLength(PAGE); }, 240_000); + + // The fold's per-day narrowing, pinned against Postgres rather than a mock + // that answers SQL by its text: a day is cut in the user's zone, and within + // the winning source the top-ranked device's groups stand alone. + it("cuts days in the user's zone and keeps the winning source's top device", async () => { + const user = await seedSession("day-paging-device-user"); + const key = dayKeyAgo(5); + const at = (iso: string) => new Date(`${key}T${iso}Z`); + const base = { + userId: user.id, + type: "ACTIVITY_STEPS" as const, + unit: "steps", + source: "APPLE_HEALTH" as const, + }; + await getPrismaClient().measurement.createMany({ + data: [ + // The watch and the phone both counted the morning. + { + ...base, + value: 6000, + deviceType: "watch", + measuredAt: at("08:00:00"), + externalId: "w1", + }, + { + ...base, + value: 5500, + deviceType: "phone", + measuredAt: at("08:05:00"), + externalId: "p1", + }, + // 23:30 UTC is already the next day in Berlin. + { + ...base, + value: 700, + deviceType: "watch", + measuredAt: at("23:30:00"), + externalId: "w2", + }, + ], + }); + const body = (await ( + await callList(`type=ACTIVITY_STEPS&groupBy=day&limit=${PAGE}`) + ).json()) as ListEnvelope & { + data: { meta: { droppedDuplicates?: number } }; + }; + const byDay = new Map(body.data.measurements.map((r) => [r.dayKey, r])); + expect(byDay.get(key)?.value).toBe(6000); + expect(byDay.get(dayKeyAgo(4))?.value).toBe(700); + expect(body.data.meta.total).toBe(2); + expect(body.data.meta.droppedDuplicates).toBe(1); + }, 240_000); }); diff --git a/tests/integration/sleep-night-sql-parity.test.ts b/tests/integration/sleep-night-sql-parity.test.ts new file mode 100644 index 000000000..129ec0697 --- /dev/null +++ b/tests/integration/sleep-night-sql-parity.test.ts @@ -0,0 +1,307 @@ +/** + * The sleep list pages its nights in SQL (`sleepNightPage`) and fills them + * from the JS reconstruction (`reconstructSleepSessions`). The two cut the + * same stage rows into sessions and name each session's night by the same + * rule, or the list drops, doubles or mislabels nights. This suite runs both + * over one fixture set of awkward shapes and holds them to the same nights, + * then holds the list, the night view and the series to one answer per day. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +type Stage = "IN_BED" | "AWAKE" | "ASLEEP" | "REM" | "CORE" | "DEEP" | null; + +interface Seg { + /** Segment end, minutes after the fixture day's UTC midnight. */ + end: number; + minutes: number; + stage: Stage; + source?: "APPLE_HEALTH" | "WHOOP" | "WITHINGS"; + deviceType?: string | null; +} + +const H = 60; + +/** A run of contiguous segments of `step` minutes cycling through stages. */ +function run( + start: number, + end: number, + step: number, + stages: Stage[], + extra: Partial = {}, +): Seg[] { + const out: Seg[] = []; + let i = 0; + for (let t = start + step; t <= end; t += step) { + out.push({ + end: t, + minutes: step, + stage: stages[i % stages.length], + ...extra, + }); + i += 1; + } + return out; +} + +const NIGHT: Stage[] = ["CORE", "DEEP", "CORE", "REM", "AWAKE"]; + +/** + * Ten shapes, one per day modulo ten, minutes relative to the day's UTC + * midnight (negative = the evening before). + */ +function shape(i: number): Seg[] { + switch (i % 10) { + case 0: + // A dense watch night in five-minute segments. + return run(-1 * H, 7 * H, 5, NIGHT, { deviceType: "watch" }); + case 1: + // The watch stops before midnight, the phone's in-bed runs past it. + return [ + ...run(-6 * H, -10, 10, NIGHT, { deviceType: "watch" }), + { end: 20, minutes: 6 * H + 20, stage: "IN_BED", deviceType: "phone" }, + ]; + case 2: + // An afternoon nap and no night. + return run(13 * H, 14 * H, 15, ["CORE", "REM"]); + case 3: + // A night and a nap. + return [ + ...run(-2 * H, 6 * H, 30, NIGHT), + ...run(14 * H, 14 * H + 40, 20, ["CORE"]), + ]; + case 4: + // A split night, three and a half hours apart: two sessions. + return [ + ...run(-2 * H, 1 * H, 30, NIGHT), + ...run(4 * H + 30, 7 * H, 30, NIGHT), + ]; + case 5: + // Exactly the session gap apart: one session. + return [ + ...run(-2 * H, 1 * H, 30, NIGHT), + ...run(4 * H, 7 * H, 30, NIGHT), + ]; + case 6: + // One long stage block. + return [ + { end: 3 * H, minutes: 4 * H, stage: "CORE" }, + { end: 5 * H, minutes: 2 * H, stage: "REM" }, + ]; + case 7: + // A bare aggregate with its granular twin, and a zero-minute granular + // row from a second writer beside a bare night. + return [ + { end: 6 * H, minutes: 7 * H, stage: "ASLEEP" }, + ...run(-1 * H, 6 * H, 60, ["CORE", "DEEP", "REM"]), + { end: 6 * H, minutes: 0, stage: "REM", source: "WITHINGS" }, + { end: 6 * H, minutes: 6 * H, stage: "ASLEEP", source: "WITHINGS" }, + ]; + case 8: + // An in-bed-only evening and a stage-less night. + return [ + { end: -8 * H, minutes: 40, stage: "IN_BED" }, + { end: -8 * H + 30, minutes: 20, stage: "AWAKE" }, + { end: 6 * H, minutes: 6 * H, stage: null }, + ]; + default: + // A summary-shaped writer (every stage on one instant) beside a + // partial timed night. + return [ + { end: 7 * H, minutes: 200, stage: "CORE", source: "WHOOP" }, + { end: 7 * H, minutes: 90, stage: "DEEP", source: "WHOOP" }, + { end: 7 * H, minutes: 80, stage: "REM", source: "WHOOP" }, + ...run(2 * H, 7 * H, 30, NIGHT), + ]; + } +} + +const BASE = Date.UTC(2024, 2, 1); // three years back, across two DST turns +const DAYS = 120; + +async function seed(timezone: string) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: `parity-${timezone.replace("/", "-")}`.toLowerCase(), + email: `parity-${timezone.length}@example.test`, + role: "USER", + timezone, + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + const rows = []; + for (let d = 0; d < DAYS; d++) { + const midnight = BASE + d * 86_400_000; + for (const [k, seg] of shape(d).entries()) { + rows.push({ + userId: user.id, + type: "SLEEP_DURATION" as const, + value: seg.minutes, + unit: "minutes", + source: seg.source ?? ("APPLE_HEALTH" as const), + deviceType: seg.deviceType ?? null, + measuredAt: new Date(midnight + seg.end * 60_000), + externalId: `p-${d}-${k}`, + sleepStage: seg.stage, + }); + } + } + await prisma.measurement.createMany({ data: rows }); + return user; +} + +interface ListRow { + dayKey: string; + value: number; + napCount: number; + napAsleepMinutes: number; +} + +async function list(query: string) { + const { GET } = await import("@/app/api/measurements/route"); + const res = await GET( + new NextRequest(`http://localhost/api/measurements?${query}`), + ); + expect(res.status).toBe(200); + return (await res.json()) as { + data: { measurements: ListRow[]; meta: { total: number } }; + }; +} + +async function nightView(date: string) { + const { GET } = await import("@/app/api/sleep/night/route"); + const res = await GET( + new NextRequest(`http://localhost/api/sleep/night?date=${date}`), + ); + expect(res.status).toBe(200); + return (await res.json()) as { + data: { + night: string | null; + main: { asleepMinutes: number } | null; + naps: Array<{ asleepMinutes: number }>; + }; + }; +} + +describe("sleep nights: SQL page and JS reconstruction agree", () => { + for (const tz of ["UTC", "Europe/Berlin"]) { + it(`names the same nights over every shape (${tz})`, async () => { + const user = await seed(tz); + const { sleepNightPage, readSleepRows } = + await import("@/lib/measurements/list/sleep-nights"); + const { reconstructSleepSessions } = + await import("@/lib/analytics/sleep-night"); + const page = await sleepNightPage(user.id, tz, { + sortDir: "asc", + limit: 10_000, + offset: 0, + }); + const rows = await readSleepRows( + user.id, + new Date(BASE - 2 * 86_400_000), + new Date(BASE + (DAYS + 2) * 86_400_000), + ); + const scorable = reconstructSleepSessions(rows, tz, null).filter( + (s) => s.asleepMinutes > 0, + ); + const jsNights = [...new Set(scorable.map((s) => s.night))].sort(); + expect(page.nights.map((n) => n.night)).toEqual(jsNights); + expect(page.total).toBe(jsNights.length); + // Every reconstructed session lies inside its night's SQL span. + const span = new Map(page.nights.map((n) => [n.night, n])); + for (const s of scorable) { + const n = span.get(s.night)!; + expect(s.start.getTime()).toBeGreaterThanOrEqual( + n.spanStart.getTime() - 1000, + ); + expect(s.end.getTime()).toBeLessThanOrEqual(n.spanEnd.getTime() + 1000); + } + }, 240_000); + } + + it("gives every page night a row the night view answers for the same day", async () => { + await seed("Europe/Berlin"); + const first = await list("type=SLEEP_DURATION&limit=7&sortDir=asc"); + const total = first.data.meta.total; + const rows: ListRow[] = []; + for (let offset = 0; offset < total; offset += 7) { + const page = await list( + `type=SLEEP_DURATION&limit=7&offset=${offset}&sortDir=asc`, + ); + // No short page before the last one. + expect(page.data.measurements).toHaveLength(Math.min(7, total - offset)); + rows.push(...page.data.measurements); + } + expect(rows).toHaveLength(total); + let napOnly = 0; + for (const row of rows) { + const view = await nightView(row.dayKey); + expect(view.data.night).toBe(row.dayKey); + expect(view.data.main?.asleepMinutes ?? 0).toBe(row.value); + expect(view.data.naps).toHaveLength(row.napCount); + if (!view.data.main) { + napOnly += 1; + expect(row.napCount).toBeGreaterThan(0); + } + } + // The afternoon-nap days have no main night and still have their row. + expect(napOnly).toBe(DAYS / 10); + }, 480_000); + + it("reaches the oldest night from the series' All range", async () => { + await seed("Europe/Berlin"); + const { GET } = await import("@/app/api/measurements/series/route"); + const res = await GET( + new NextRequest( + "http://localhost/api/measurements/series?kind=sleep&days=3650", + ), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { points: Array<{ id: string }> }; + }; + const oldest = (await list("type=SLEEP_DURATION&limit=1&sortDir=asc")).data + .measurements[0].dayKey; + // Years old, past any one-year cut. + expect(body.data.points[0].id).toBe(`sleep:${oldest}`); + expect(oldest < "2024-03-03").toBe(true); + }, 240_000); +});