Merge branch 'fix/v1437-review' into release/v1.43.7

# Conflicts:
#	src/app/api/analytics/route.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 06:20:42 +02:00
commit 45c027aee2
217 files changed
+3636 -2127

No files matched your search

+9
View File
@@ -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
+40 -12
View File
@@ -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:
+4 -1
View File
@@ -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 <instant>`.
- `meta["googleHealth.backfill.edgeFilterUnsupported"]` appears on the slice
+11 -1
View File
@@ -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:
+5 -1
View File
@@ -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`
+7 -5
View File
@@ -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.",
+7 -5
View File
@@ -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.",
+7 -5
View File
@@ -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.",
+7 -5
View File
@@ -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.",
+7 -5
View File
@@ -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.",
+7 -5
View File
@@ -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": "오늘 하루를 맞을 자율신경계의 충전 상태예요. 밤사이 심장·호흡 신호에서 읽어낸 재충전 지표예요.",
+7 -5
View File
@@ -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.",
@@ -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) },
},
}));
@@ -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);
});
});
@@ -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 (`<QueryErrorCard>`); 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([]);
});
});
@@ -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.
*
+2 -2
View File
@@ -293,7 +293,7 @@ export default function AchievementsPage() {
// alongside `<RecentAchievementsCard>` and
// `<AchievementUnlockNotifier>` 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")}
/>
<QueryErrorCard onRetry={() => refetch()} />
<QueryErrorCard error={error} onRetry={() => refetch()} />
</div>
);
}
+6 -2
View File
@@ -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 && (
<QueryErrorRow
error={settingsFailure}
message={t("admin.adminSettingsLoadError")}
onRetry={() => void refetchSettings()}
/>
@@ -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();
+4
View File
@@ -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;
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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";
@@ -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> = {},
): 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.
+14 -595
View File
@@ -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<Array<{ total: number }>>`
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<typeof s> => 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<string, typeof sessions>();
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:<night>` 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<string, number>,
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<Array<{ total: number }>>`
${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?
*
@@ -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",
+11 -39
View File
@@ -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(
@@ -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 () => {
+19 -41
View File
@@ -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),
});
});
+2 -1
View File
@@ -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.
<QueryErrorCard onRetry={() => refetch()} />
<QueryErrorCard error={error} onRetry={() => refetch()} />
) : groups.length === 0 ? (
<div
data-slot="coach-conversations-empty"
+1 -1
View File
@@ -240,7 +240,7 @@ function CoachPlansBody({ readOnly }: { readOnly: boolean }) {
)}
>
{query.isError ? (
<QueryErrorCard onRetry={() => query.refetch()} />
<QueryErrorCard error={query.error} onRetry={() => query.refetch()} />
) : query.isLoading ? (
<div
data-slot="coach-plans-loading"
+12 -1
View File
@@ -278,31 +278,41 @@ export default function MetricCatalogPage() {
// the whole page, and never reads as an honest "Not tracked yet".
const probes: Record<
ProbeId,
{ isLoading: boolean; isError: boolean; retry: () => 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() {
<CardContent className="space-y-2">
{failed.length > 0 ? (
<QueryErrorRow
error={probes[failed[0]].error}
slot="catalog-group-error"
message={t("metricCatalog.loadError")}
onRetry={() => failed.forEach((id) => probes[id].retry())}
+2 -2
View File
@@ -39,7 +39,7 @@ export default function InsightsEcgPage() {
// Reuse the SAME query cell `<EcgSection>` 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")}
>
<QueryErrorCard onRetry={() => refetch()} />
<QueryErrorCard error={error} onRetry={() => refetch()} />
</SubPageShell>
);
}
+2
View File
@@ -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"
>
<QueryErrorCard
error={comprehensiveError}
description={t("insights.loadError")}
onRetry={() => refetchComprehensive()}
/>
+1
View File
@@ -99,6 +99,7 @@ export default function InsightsNutrientsPage() {
description={t("nutrients.page.description")}
>
<QueryErrorCard
error={overview.error}
title={t("nutrients.page.loadError")}
onRetry={() => void overview.refetch()}
/>
+6 -3
View File
@@ -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 ? (
<QueryErrorCard
error={rhythmFailure}
description={t("insights.sleep.rhythm.loadError")}
onRetry={() => void refetchRhythm()}
/>
+1 -1
View File
@@ -99,7 +99,7 @@ export default function InsightsWorkoutDetailPage({
</div>
) : error ? (
// A failed query must never read as "no data" (UI-STANDARDS §6).
<QueryErrorCard onRetry={() => refetch()} />
<QueryErrorCard error={error} onRetry={() => refetch()} />
) : !data ? (
<p
data-slot="workout-detail-error"
@@ -60,6 +60,7 @@ export default function InsightsWorkoutsPageClient() {
isLoading,
isEmpty,
isError,
error,
isFetchNextPageError,
isFetchingNextPage,
hasNextPage,
@@ -111,6 +112,7 @@ export default function InsightsWorkoutsPageClient() {
nothing to aggregate. */}
{isError ? (
<QueryErrorCard
error={error}
title={t("insights.workouts.loadError")}
onRetry={() => refetch()}
/>
+2
View File
@@ -49,6 +49,7 @@ export default function MedicationDetailPage({
data: medication,
isLoading,
isError,
error,
refetch,
} = useQuery<MedicationDetailSnapshot>({
queryKey: queryKeys.medicationDetail(id),
@@ -83,6 +84,7 @@ export default function MedicationDetailPage({
<BackLink href="/medications" label={t("medications.back")} />
<div data-slot="medication-detail-error-card">
<QueryErrorCard
error={error}
description={t("medications.detail.shell.loadFailed")}
onRetry={() => void refetch()}
/>
+2
View File
@@ -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() {
</div>
) : isError ? (
<QueryErrorCard
error={error}
description={t("medications.loadFailed")}
retryLabel={t("medications.retryLoad")}
onRetry={() => void refetchMedications()}
+2 -1
View File
@@ -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<PreferencesData>("/api/notifications/preferences");
@@ -186,6 +186,7 @@ export default function NotificationsPage() {
description={t("notifications.subtitle")}
/>
<QueryErrorCard
error={error}
description={t("notifications.loadError")}
onRetry={() => refetch()}
/>
+1
View File
@@ -58,6 +58,7 @@ export default function HandoverDecisionPage() {
{decision.isError ? (
<QueryErrorCard
error={decision.error}
title={t("recordSharing.handoverDecision.loadError")}
onRetry={() => void decision.refetch()}
/>
+4 -1
View File
@@ -998,7 +998,10 @@ export default function DashboardPageClient({
) : !mounted || digestQuery.isLoading ? (
<TodayHeroSkeleton />
) : digestQuery.isError ? (
<QueryErrorCard onRetry={() => digestQuery.refetch()} />
<QueryErrorCard
error={digestQuery.error}
onRetry={() => digestQuery.refetch()}
/>
) : null
}
@@ -75,7 +75,9 @@ describe("<PageHeader> — one canonical H1", () => {
describe("<QueryErrorCard> — retry affordance", () => {
it("renders a retry button when onRetry is provided", () => {
const html = render(<QueryErrorCard onRetry={() => {}} />);
const html = render(
<QueryErrorCard error={new Error("boom")} onRetry={() => {}} />,
);
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain('role="alert"');
// The retry affordance is a real <button> so a failed read is
@@ -85,7 +87,7 @@ describe("<QueryErrorCard> — retry affordance", () => {
});
it("omits the retry button when no onRetry is supplied", () => {
const html = render(<QueryErrorCard />);
const html = render(<QueryErrorCard error={new Error("boom")} />);
expect(html).toContain('data-slot="query-error-card"');
expect(html).not.toContain("<button");
});
@@ -26,7 +26,10 @@ import en from "../../../messages/en.json";
* rather than by driving a real fetch.
*/
vi.mock("@/lib/api/api-fetch", () => ({
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
// `ApiError` stays real: the error primitives read its status.
ApiError: (await importOriginal<typeof import("@/lib/api/api-fetch")>())
.ApiError,
apiGet: () => new Promise(() => {}),
apiPost: () => new Promise(() => {}),
}));
@@ -25,7 +25,9 @@ function markup(node: ReactNode): string {
);
}
function boundaryWith(fallback: (retry: () => void) => ReactNode) {
function boundaryWith(
fallback: (retry: () => void, error: unknown) => ReactNode,
) {
const boundary = new Boundary({
renderFallback: fallback,
children: <p>card body</p>,
@@ -53,18 +55,27 @@ describe("AdminCardBoundary", () => {
});
it("turns a thrown render into the fallback, not a page failure", () => {
expect(Boundary.getDerivedStateFromError()).toEqual({ failed: true });
const thrown = new Error("render failed");
expect(Boundary.getDerivedStateFromError(thrown)).toEqual({
failed: true,
error: thrown,
});
const fallback = vi.fn(() => <p data-slot="fallback">fallback</p>);
const fallback = vi.fn<(retry: () => void, error: unknown) => ReactNode>(
() => <p data-slot="fallback">fallback</p>,
);
const boundary = boundaryWith(fallback);
boundary.setState(Boundary.getDerivedStateFromError());
boundary.setState(Boundary.getDerivedStateFromError(thrown));
expect(markup(boundary.render())).toContain('data-slot="fallback"');
expect(fallback).toHaveBeenCalledWith(boundary.retry);
// The fallback is handed what was thrown, so its error card reads it.
expect(fallback).toHaveBeenCalledWith(boundary.retry, thrown);
});
it("paints the translated error card with a keyboard-reachable retry", () => {
const html = markup(<AdminCardError onRetry={() => {}} />);
const html = markup(
<AdminCardError error={new Error("render failed")} onRetry={() => {}} />,
);
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain('role="alert"');
+27 -7
View File
@@ -18,17 +18,28 @@ import { useTranslations } from "@/lib/i18n/context";
*/
export function AdminCardBoundary({ children }: { children: ReactNode }) {
return (
<Boundary renderFallback={(retry) => <AdminCardError onRetry={retry} />}>
<Boundary
renderFallback={(retry, error) => (
<AdminCardError error={error} onRetry={retry} />
)}
>
{children}
</Boundary>
);
}
/** What a failed card paints in its place. */
export function AdminCardError({ onRetry }: { onRetry: () => void }) {
export function AdminCardError({
error,
onRetry,
}: {
error: unknown;
onRetry: () => void;
}) {
const { t } = useTranslations();
return (
<QueryErrorCard
error={error}
title={t("admin.cardError.title")}
description={t("admin.cardError.description")}
onRetry={onRetry}
@@ -37,12 +48,13 @@ export function AdminCardError({ onRetry }: { onRetry: () => void }) {
}
export interface BoundaryProps {
renderFallback: (retry: () => void) => ReactNode;
renderFallback: (retry: () => void, error: unknown) => ReactNode;
children: ReactNode;
}
interface BoundaryState {
failed: boolean;
error?: unknown;
attempt: number;
}
@@ -50,8 +62,10 @@ interface BoundaryState {
export class Boundary extends Component<BoundaryProps, BoundaryState> {
state: BoundaryState = { failed: false, attempt: 0 };
static getDerivedStateFromError(): Pick<BoundaryState, "failed"> {
return { failed: true };
static getDerivedStateFromError(
error: unknown,
): Pick<BoundaryState, "failed" | "error"> {
return { failed: true, error };
}
componentDidCatch(error: Error): void {
@@ -64,11 +78,17 @@ export class Boundary extends Component<BoundaryProps, BoundaryState> {
}
retry = (): void => {
this.setState((s) => ({ failed: false, attempt: s.attempt + 1 }));
this.setState((s) => ({
failed: false,
error: undefined,
attempt: s.attempt + 1,
}));
};
render(): ReactNode {
if (this.state.failed) return this.props.renderFallback(this.retry);
if (this.state.failed) {
return this.props.renderFallback(this.retry, this.state.error);
}
// A new key on retry remounts the card, so it starts from a clean state
// rather than re-rendering the tree that threw.
return <Fragment key={this.state.attempt}>{this.props.children}</Fragment>;
@@ -104,6 +104,7 @@ export function AiQualitySection() {
</div>
) : query.isError ? (
<QueryErrorRow
error={query.error}
message={t("admin.aiQuality.loadError")}
onRetry={() => void query.refetch()}
/>
+2 -1
View File
@@ -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<BackupsList>("/api/admin/backups");
@@ -1243,6 +1243,7 @@ export function BackupsSection() {
</div>
) : isError ? (
<QueryErrorRow
error={error}
message={t("admin.section.backups.loadError")}
onRetry={() => void refetch()}
/>
@@ -99,6 +99,7 @@ export function CoachFeedbackSection() {
</div>
) : query.isError ? (
<QueryErrorRow
error={query.error}
message={t("admin.coachFeedback.loadError")}
onRetry={() => void query.refetch()}
/>
+2 -1
View File
@@ -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<HostMetricsApiResponse>("/api/admin/host-metrics?since=2h");
@@ -198,6 +198,7 @@ export function HostMetricsChart() {
}
/>
<QueryErrorRow
error={error}
message={t("admin.hostMetrics.loadError")}
onRetry={() => void refetch()}
/>
+1
View File
@@ -155,6 +155,7 @@ export function KeyBackupCard() {
/>
{status.isError ? (
<QueryErrorRow
error={status.error}
message={t("admin.keyBackup.loadError")}
onRetry={() => void status.refetch()}
/>
@@ -111,6 +111,7 @@ export function MeasurementMaintenanceCard() {
if (status.isError) {
return (
<QueryErrorCard
error={status.error}
title={t("admin.section.maintenance.loadError")}
onRetry={() => void status.refetch()}
/>
@@ -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<BackupsList>("/api/admin/backups"),
});
@@ -170,6 +170,7 @@ export function OffhostBackupsSection() {
</div>
) : isError ? (
<QueryErrorRow
error={error}
message={t("admin.section.backups.offhost.loadError")}
onRetry={() => void refetch()}
/>
@@ -125,6 +125,7 @@ export function PulseDayMeanCard() {
if (status.isError) {
return (
<QueryErrorCard
error={status.error}
title={t("admin.section.pulseDayMean.loadError")}
onRetry={() => void status.refetch()}
/>
@@ -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<AuditLogResponse>(
@@ -84,6 +84,7 @@ export function RecentAuditPreview() {
</div>
) : isError ? (
<QueryErrorRow
error={error}
message={t("admin.overview.auditLoadError")}
onRetry={() => void refetch()}
/>
@@ -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 (
<div className="space-y-6">
@@ -277,6 +277,7 @@ export function SystemStatusSection() {
// transient 500 (rolling deploy, DB blip) doesn't require a
// full page reload to recover.
<QueryErrorRow
error={error}
message={t("admin.systemStatusLoadError")}
onRetry={() => void refetch()}
retryLabel={t("admin.systemStatusRetry")}
@@ -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() {
</div>
) : isError ? (
<QueryErrorRow
error={error}
message={t("admin.overview.snapshotLoadError")}
onRetry={() => void refetch()}
/>
@@ -93,6 +93,7 @@ export function BodySitesSection({
{list.isError ? (
<QueryErrorCard
error={list.error}
title={t("bodySites.loadError")}
onRetry={() => void list.refetch()}
/>
@@ -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}
/>
<QueryErrorCard
error={metricError ? metricFailure : listFailure}
description={t("customMetrics.loadError")}
onRetry={() => {
if (metricError) void refetchMetric();
@@ -45,7 +45,7 @@ export function CustomMetricList() {
const [addOpen, setAddOpen] = useState(false);
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.customMetrics(),
queryFn: () => apiGet<CustomMetricListResponse>("/api/custom-metrics"),
});
@@ -93,6 +93,7 @@ export function CustomMetricList() {
</Card>
) : isError ? (
<QueryErrorCard
error={error}
description={t("customMetrics.loadError")}
onRetry={() => refetch()}
/>
@@ -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 (
<QueryErrorCard
error={error}
description={t("customMetrics.loadError")}
onRetry={() => void refetch()}
/>
+2 -2
View File
@@ -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 (
<ReadFailureRow
<QueryErrorRow
error={calendar.error}
slot="wellness-cycle-tile-error"
message={t("cycle.wellnessRing.loadError")}
+4
View File
@@ -362,6 +362,7 @@ export function CycleView() {
// Inside the calendar card already — a borderless row keeps
// it in line with the plain loading line above it (§3/§6).
<QueryErrorRow
error={calendar.error}
message={t("cycle.loadError")}
onRetry={() => void calendar.refetch()}
className="border-0 bg-transparent p-0"
@@ -405,6 +406,7 @@ export function CycleView() {
<TabsContent value="predictions" className="mt-4 space-y-4">
{calendarError ? (
<QueryErrorCard
error={calendar.error}
title={t("cycle.loadError")}
onRetry={() => void calendar.refetch()}
/>
@@ -435,6 +437,7 @@ export function CycleView() {
<TabsContent value="insights" className="mt-4 space-y-4">
{insights.isError ? (
<QueryErrorCard
error={insights.error}
title={t("cycle.loadError")}
onRetry={() => void insights.refetch()}
/>
@@ -454,6 +457,7 @@ export function CycleView() {
<TabsContent value="settings" className="mt-4">
{profileQuery.isError ? (
<QueryErrorCard
error={profileQuery.error}
title={t("cycle.loadError")}
onRetry={() => void profileQuery.refetch()}
/>
+2 -2
View File
@@ -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",
};
}
@@ -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<typeof import("@/lib/i18n/context")>()),
useFormatters: () => READER_FORMATTERS,
}));
let mockResult: {
data:
| {
@@ -147,5 +155,8 @@ describe("<RecentWorkoutsTile>", () => {
expect(html).toContain("Cycling");
expect(html).toContain("30m");
expect(html).toContain("1h 0m");
expect(html).toContain(
READER_FORMATTERS.dateWithWeekdaySmart("2026-05-15T07:00:00Z"),
);
});
});
@@ -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() {
</div>
) : isError ? (
<QueryErrorCard
error={error}
onRetry={() => refetch()}
className="border-0 bg-transparent shadow-none"
/>
@@ -147,7 +137,7 @@ export function RecentWorkoutsTile() {
>
{renderRow(workout, sportName)}
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatDayLabel(workout.startedAt, locale, timeZone)}
{fmt.dateWithWeekdaySmart(workout.startedAt)}
</span>
<span className="ml-1 shrink-0 text-xs font-medium tabular-nums">
{formatDurationMinutes(
+1
View File
@@ -274,6 +274,7 @@ export function DayView({
{day.isError ? (
<QueryErrorRow
error={day.error}
message={t("day.loadFailed")}
onRetry={() => void day.refetch()}
slot="day-error"
+20 -16
View File
@@ -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 (
<ReadFailureRow
<QueryErrorRow
error={window.error}
slot="since-last-visit-error"
message={t("day.sinceLastVisit.loadError")}
@@ -212,24 +213,27 @@ export function SinceLastVisit({
const nextDay = dateKeyOfInstant(next.occurredAt, timeZone);
return (
<Card className="gap-3 py-3 md:gap-3 md:py-4">
<Card className="gap-2 py-3 md:py-4">
<CardHeader>
<TileHeader
size="sm"
icon={Stethoscope}
id={`since-last-visit-${next.id}`}
titleAs="h3"
title={t("day.sinceLastVisit.title", { date: lastLabel })}
right={
<span className="text-muted-foreground text-xs">
{tCount("day.sinceLastVisit.count", rows.length)}
</span>
}
/>
</CardHeader>
<CardContent>
<section
data-slot="since-last-visit"
aria-labelledby={`since-last-visit-${next.id}`}
className="space-y-2"
>
<div className="flex items-baseline justify-between gap-3">
<h3
id={`since-last-visit-${next.id}`}
className={DAY_SECTION_LABEL}
>
{t("day.sinceLastVisit.title", { date: lastLabel })}
</h3>
<span className="text-muted-foreground shrink-0 text-xs">
{tCount("day.sinceLastVisit.count", rows.length)}
</span>
</div>
<ul className="border-border divide-border divide-y rounded-lg border px-3.5">
{visible.map((row) => (
<li
@@ -135,6 +135,7 @@ export function DocumentLinkPicker({
</div>
) : list.isError ? (
<QueryErrorRow
error={list.error}
message={t("documents.list.loadError")}
onRetry={() => list.refetch()}
/>
@@ -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();
@@ -1076,6 +1076,7 @@ export function DocumentsView() {
)
) : list.isError ? (
<QueryErrorCard
error={list.error}
title={t("documents.list.loadError")}
onRetry={() => void list.refetch()}
/>
@@ -77,6 +77,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
if (list.isError) {
return (
<QueryErrorCard
error={list.error}
title={t("documents.episodeCard.loadError")}
onRetry={() => void list.refetch()}
/>
@@ -552,6 +552,7 @@ export function DocumentSourcePicker({
</div>
) : search.isError ? (
<QueryErrorRow
error={search.error}
message={sourceErrorMessage(t, errorCodeOf(search.error))}
onRetry={() => void search.refetch()}
slot="document-source-error"
@@ -297,5 +297,20 @@ describe("<VisitsSection> 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(
'<div data-slot="card"',
html.indexOf("since-last-visit-"),
),
);
expect(card).toMatch(
/^<div data-slot="card" class="[^"]*gap-2 py-3 md:py-4/,
);
expect(card).toContain('data-slot="tile-header"');
expect(card).toMatch(
/<h3 id="since-last-visit-[^"]+" data-slot="card-title" class="[^"]*text-sm/,
);
});
});
@@ -96,6 +96,7 @@ export function ProceduresSection({ enabled = true }: { enabled?: boolean }) {
{list.isError ? (
<QueryErrorCard
error={list.error}
title={t("encounters.procedures.loadError")}
onRetry={() => void list.refetch()}
/>
@@ -110,6 +110,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
{list.isError ? (
<QueryErrorCard
error={list.error}
title={t("encounters.loadError")}
onRetry={() => void list.refetch()}
/>
@@ -52,6 +52,7 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
if (list.isError) {
return (
<QueryErrorCard
error={list.error}
title={t("encounters.loadError")}
onRetry={() => void list.refetch()}
/>
@@ -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 (
<QueryErrorCard
error={error}
title={t("illness.loadError")}
onRetry={() => void refetch()}
/>
+2
View File
@@ -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".
<QueryErrorCard
error={error}
title={t("illness.listLoadError")}
onRetry={() => void refetch()}
/>
@@ -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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
@@ -204,21 +197,20 @@ describe("<SleepQualitySection> data-gating", () => {
describe("<RecoverySection> 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(<RecoverySection />);
expect(html).toContain('data-slot="recovery-empty"');
// Routed through the shared <EmptyState> (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&#x27;s readiness appears as your recovery score",
);
expect(html).toContain("WHOOP or Polar");
});
it("renders a canonical chart block for the present signal", () => {
@@ -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<string, unknown> | 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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
@@ -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("<RecoverySection> 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");
});
});
@@ -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<typeof import("@/lib/i18n/context")>()),
useFormatters: () => READER_FORMATTERS,
}));
/**
* v1.4.32 — `<WorkoutList>` unit tests.
*
@@ -54,6 +63,13 @@ describe("<WorkoutList>", () => {
expect(html).toContain("30m");
});
it("writes the start in the reader's own date order and clock", () => {
const html = render(<WorkoutList workouts={[ROW]} />);
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(<WorkoutList workouts={[ROW]} />);
expect(html).toContain("5.20 km");
@@ -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 ? (
<QueryErrorRow
error={error}
slot="coach-results-error"
retrySlot="coach-results-retry"
message={t(COACH_RESULT_UI_KEYS.loadFailed)}
@@ -12,7 +12,7 @@ import { useCoachConversation } from "./use-coach";
*/
export function ConversationTranscript({ id }: { id: string }) {
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useCoachConversation(id);
const { data, isLoading, isError, error, refetch } = useCoachConversation(id);
if (isLoading) {
return (
<p className="text-muted-foreground px-3 pb-3 text-xs">
@@ -23,6 +23,7 @@ export function ConversationTranscript({ id }: { id: string }) {
if (isError || !data) {
return (
<QueryErrorRow
error={error}
className="mx-3 mb-3"
onRetry={() => void refetch()}
slot="coach-conversations-transcript-error"
@@ -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,
@@ -173,10 +173,8 @@ export function CoincidentDeviationCard({
className,
}: CoincidentDeviationCardProps) {
const { t } = useTranslations();
const { data, isError, refetch } = useDerivedMetric<CoincidentDeviationValue>(
COINCIDENT_METRIC,
{ enabled },
);
const { data, isError, error, refetch } =
useDerivedMetric<CoincidentDeviationValue>(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")}
/>
<QueryErrorRow
error={error}
slot="coincident-deviation-error"
onRetry={() => void refetch()}
/>
@@ -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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<CoachReadStrip metricType="WEIGHT" unit="kg" />
</I18nProvider>
</QueryClientProvider>,
);
}
/** 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(
<QueryClientProvider
client={
new QueryClient({
defaultOptions: { queries: { retry: false, enabled: false } },
})
}
>
<I18nProvider initialLocale="en">
<CoachReadStrip metricType="WEIGHT" unit="kg" />
</I18nProvider>
</QueryClientProvider>,
);
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("");
});
});
@@ -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<string, unknown>) => {
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<string, string | number>) =>
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(<CompositeScoreAnatomy metric="SLEEP_SCORE" />);
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');
});
});
@@ -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 (
<ReadFailureRow
error={error}
slot="coach-read-strip-error"
message={t("insights.coach.readStrip.loadError")}
onRetry={() => void refetch()}
/>
);
}
if (isError && readFailureKind(error) === "refused") return null;
return (
<Card
data-slot="coach-read-strip"
data-state="loading"
aria-busy="true"
data-state={isError ? "error" : "loading"}
aria-busy={isError ? undefined : "true"}
className="gap-2 py-3 md:py-4"
>
<CardHeader>
@@ -126,8 +127,18 @@ export function CoachReadStrip({
titleAs="h2"
/>
</CardHeader>
<CardContent>
<Skeleton className="h-5.5 w-3/4" />
<CardContent className={STRIP_BODY}>
{isError ? (
<QueryErrorRow
error={error}
slot="coach-read-strip-error"
message={t("insights.coach.readStrip.loadError")}
onRetry={() => void refetch()}
className="flex-row items-center justify-between rounded-none border-0 bg-transparent p-0"
/>
) : (
<Skeleton className="h-5.5 w-3/4" />
)}
</CardContent>
</Card>
);
@@ -236,12 +247,12 @@ export function CoachReadStrip({
<TileHeader
icon={Sparkles}
title={t("insights.coach.readStrip.label")}
// L3 — top-level section on every metric sub-page, sibling of the
// Top-level section on every metric sub-page, sibling of the
// already-`h2` stat strip directly under the page `h1`.
titleAs="h2"
/>
</CardHeader>
<CardContent>
<CardContent className={STRIP_BODY}>
<div className="min-w-0 space-y-1 text-sm leading-relaxed">
{baselineLine ? (
<p data-slot="coach-read-baseline" className="text-pretty">
@@ -186,6 +186,7 @@ export function CompositeScoreAnatomy({
return (
<div className={className}>
<QueryErrorRow
error={query.error}
slot="composite-anatomy-error"
onRetry={() => 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")
@@ -891,6 +891,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
subtitle={t("insights.derived.vitals.sectionSubtitle")}
/>
<QueryErrorRow
error={batch.error}
slot="vitals-dashboard-error"
retrySlot="vitals-dashboard-retry"
message={t("insights.derived.vitals.loadError")}
+2 -1
View File
@@ -45,7 +45,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.insightsEcgDetail(recordingId, false),
queryFn: async () => {
try {
@@ -63,6 +63,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
// alert is content, not muted meta — it also has to be recoverable.
return (
<QueryErrorCard
error={error}
title={t("insights.ecg.loadError")}
onRetry={() => refetch()}
/>
@@ -392,6 +392,7 @@ export function HealthKitMetricPage({
explainerParams={explainerParams}
>
<QueryErrorRow
error={error}
slot="healthkit-metric-error"
retrySlot="healthkit-metric-retry"
message={t("insights.subPage.loadError")}
@@ -120,7 +120,7 @@ export function IntradayPulseChart({
setSelectedDateKey(next === todayKey ? null : next);
};
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.insightsPulseIntraday(dateKey),
queryFn: () =>
apiGet<IntradayPulseDto>(`/api/insights/pulse/intraday?date=${dateKey}`),
@@ -255,6 +255,7 @@ export function IntradayPulseChart({
<Skeleton className="h-[180px] w-full rounded-lg" />
) : isError ? (
<QueryErrorCard
error={error}
onRetry={() => refetch()}
className="border-0 bg-transparent shadow-none"
/>
@@ -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" ? (
<QueryErrorRow onRetry={() => refetch()} />
<QueryErrorRow error={error} onRetry={() => refetch()} />
) : null;
}
@@ -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 (
<ReadFailureRow
<QueryErrorRow
error={error}
slot="mood-prognosis-error"
message={t("insights.mood.prognosis.loadError")}
@@ -45,7 +45,7 @@ export function MoodWhatStandsOut({
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.insightsCorrelations(),
queryFn: async () => {
// Correlations are statistics, never gated on AI; a rejection is a
@@ -81,6 +81,7 @@ export function MoodWhatStandsOut({
) : null}
{isError ? (
<QueryErrorRow
error={error}
slot="mood-discovery-error"
message={t("insights.pattern.loadError")}
onRetry={() => void refetch()}
@@ -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 (
<ReadFailureRow
<QueryErrorRow
error={error}
slot="nutrients-caffeine-error"
message={t("nutrients.caffeine.loadError")}
@@ -30,7 +30,7 @@ export function HydrationCard() {
const { t } = useTranslations();
const fmt = useFormatters();
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.nutrientDaily("water", WINDOW_DAYS),
queryFn: () =>
apiGet<NutrientDailySeries>(
@@ -39,7 +39,7 @@ export function HydrationCard() {
});
if (isError) {
return <QueryErrorCard onRetry={refetch} />;
return <QueryErrorCard error={error} onRetry={refetch} />;
}
const todayTotal = data?.days.at(-1)?.amount ?? 0;
@@ -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<NutrientIntakeOverview>(`/api/nutrients?days=${WINDOW_DAYS}`),
@@ -180,6 +180,7 @@ export function MicronutrientsCard() {
<CardContent className="space-y-3">
{isError ? (
<QueryErrorCard
error={error}
title={t("nutrients.micronutrients.loadError")}
onRetry={() => void refetch()}
/>
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff. Show more