Compare commits

...
14 Commits
Author SHA1 Message Date
Marc-André Bombeck bef3b9dd3f Merge pull request #517 from MBombeck/release/v1.29.2
release/v1.29.2
2026-07-17 14:46:26 +02:00
Marc-André Bombeck 2b5fb617ba chore(release): v1.29.2 — WHOOP workout sport, intraday history, Today dismiss, vitals info-tip
WHOOP workouts map to canonical sports (bike rides show as cycling) with a history backfill and a richer cross-source merge; the intraday heart-rate view gains a day navigator with 90-day dense retention and an hourly fallback for older days; milestone/ECG/tension cards can be dismissed from Today; vitals context moves into an (i) popover and the VO2max heading no longer clips; Oura cycle phases feed the cycle tracker where available.
2026-07-17 14:35:06 +02:00
Marc-André Bombeck 7445925d58 fix(insights): move vitals method context into an (i) popover; unclip VO2max
The always-on "typical range = median ± MAD" caption was dropped from
the vitals tiles because it repeated across every baseline tile — but
that same inline caption, still rendered unconditionally on the four
derived re-frame tiles (cardio fitness, vascular age, HRV, BMI), was
squeezing the header row hard enough to clip the heading next to it:
measured on the cardio-fitness tile, the "VO2 max" title's rendered
width collapsed from 64px to 15px with the caption in the row, cutting
it down to "V.". Confirmed and re-measured with a Playwright pass over
the actual TileHeader/SparklineDeltaTile markup before touching the CSS.

Replace the inline caption with a small (i) trigger (VitalsInfoTip) on
the tile heading's trailing edge — a click-opened popover carrying the
same method/caveat text plus the cited standard, matching the existing
click-to-open info-tip pattern used elsewhere (touch-friendly, unlike a
hover tooltip). Restore it on the VITALS_BASELINE tiles too, since a
compact icon repeated across tiles doesn't clutter the way the full
sentence did. The heading now keeps its full width regardless of how
long the method copy runs.
2026-07-17 14:29:07 +02:00
Marc-André Bombeck 270f33edb2 feat(daily): let the Today rail dismiss milestone / ECG / tension cards
The worth-a-look rail mixed actionable items (an overdue dose, a broken
sync) with observational ones (a milestone, a new ECG pointer, a tension
window) that have nothing to act on beyond "seen it" — there was no way
to clear them short of them expiring on their own.

Add server-side dismissal scoped to the three observational kinds only:
a new DismissedPriorityItem table (userId + itemKey, composite-unique),
a POST /api/daily/digest/dismiss route, and a deterministic itemKey the
digest builder stamps per kind (metric + reach day for a milestone, the
recording's own timestamp for an ECG, the local day + part of day for a
tension window) so a dismissal never bleeds into a later day's instance.
The actionable kinds never carry an itemKey, so the dismiss affordance
and the endpoint are both structurally unreachable for them — an
itemKey not namespaced under a dismissible kind 422s before any lookup.
2026-07-17 14:29:07 +02:00
Marc-André Bombeck 4f198ec25f feat(insights): day navigator on the intraday pulse chart; 90-day dense retention with hourly fallback
Adds prev/next-day paging to the intraday pulse "shape of the day" chart
(TileHeader right slot, next disabled at today) so a user can step back
through prior days on the existing date-scoped route.

Raises DENSE_INTRADAY_RETENTION_DAYS from 14 to 90 — a self-hoster's own
storage is the trade-off for three months of full 10-minute-resolution
history instead of two weeks. Days outside the window keep folding to
hourly-mean stats: rows as before; loadIntradayPulse now falls back to
that hourly tier when a day's raw samples are gone, so the navigator
shows a coarser shape instead of an empty chart. The tension-window
read is suppressed on hourly days — it needs per-sample resolution to
stay honest. A new resolution field on the DTO ("tenMin" | "hourly")
drives a small note in the chart.
2026-07-17 14:29:07 +02:00
Marc-André Bombeck 3aa11b0531 feat(oura): feed Cycle Insights phases into the cycle tracker
Pull Oura's daily_cycle_phases collection (undocumented in the published
OpenAPI spec, but confirmed live and auth-gated identically to every other
daily_* collection) on the existing hourly Oura poll. A luteal→follicular
transition, or a literal "menstrual" phase day, writes a single-day
CycleDayLog(flow=LIGHT, source=OURA) — the same conservative "logged but
unspecified intensity" marker the HealthKit unspecified-flow mapping uses.

Manual entries always win: the write only ever creates a day that has no
existing CycleDayLog row at all, regardless of source, and never updates a
row it did not create itself. No MenstrualCycle span is synthesised, mirroring
the Apple Health importer precedent. A 403/404 on the endpoint (the expected
outcome for most connections — Oura's reproductive-health resources are all
absent from its public spec) is swallowed without touching the rest of the
Oura connection's sync status.

No migration: reuses the existing CycleDayLog.source column and the OURA
value already present on MeasurementSource.
2026-07-17 14:29:07 +02:00
Marc-André Bombeck b3d586066a fix(workouts): backfill richer fields on the canonical-picker winner
pickCanonicalWorkout() picked one whole row per cluster by the source
ladder and dropped the rest, so a higher-priority twin with a null HR
(e.g. a Withings summary) discarded a lower-priority twin's real reading
(e.g. WHOOP's HR strap) instead of keeping it. The ladder winner is now
the base row, backfilled with avgHeartRate, maxHeartRate,
totalEnergyKcal, totalDistanceM, and elevationM from the ladder-highest
member that actually has each field — filling gaps, never overwriting a
value the base row already carries.

The base row also adopts a specific sport type from a cluster member
when its own is generic ("other" / unrecognised). Reaching that case
needs clustering to accept a generic↔specific sport pairing within the
proximity window, alongside the existing exact-match rule — two
different SPECIFIC sports (Walking vs Running) still never cluster, so
the core invariant holds.
2026-07-17 14:28:56 +02:00
Marc-André Bombeck 86e92f38ca fix(db): backfill non-canonical WHOOP workout sport_type values
Existing WHOOP Workout rows still carry whatever the pre-fix write path
stored — a whoop_sport_<id> placeholder or WHOOP's raw sport_name — so
the sport-map fix only corrects new syncs. This one-shot UPDATE
re-derives every existing WHOOP row's sport_type from the same table
sport-map.ts uses, generated directly from that table so the historical
data and the live mapper can't drift apart. Unmatched values default to
"other", matching the mapper's fallback. Idempotent: the WHERE clause
skips rows already on a canonical value, so a re-run touches zero rows.
2026-07-17 14:28:56 +02:00
Marc-André Bombeck 43884569d3 fix(whoop): map sport_id/sport_name to the canonical WorkoutSportType
WHOOP was the only workout integration that never normalised its sport,
unlike the Fitbit and Google Health mappers — it wrote WHOOP's raw
sport_name or a literal whoop_sport_<id> placeholder straight into
Workout.sportType. Confirmed in prod: a user's WHOOP-tracked cycling
workouts never rendered as cycling on /insights/workouts because nothing
in the UI could map the non-canonical string to an icon or label.

sport-map.ts adds a sport_id/sport_name table sourced from WHOOP's public
Workout-object reference and mirrors the Fitbit/Google mapper shape:
sport_id is preferred (retired past 2025-09-01 but still exact when
present), sport_name is normalised before the fallback lookup, and
anything unrecognised resolves to "other" — never a placeholder. The raw
WHOOP fields stay in Workout.metadata for traceability.

Adds a shared table-driven test that checks Fitbit, Google Health, and
WHOOP sport mappers together so the three integrations can't silently
drift out of parity on common sports.
2026-07-17 14:28:56 +02:00
Marc-André Bombeck 63cdd709af Merge pull request #516 from MBombeck/release/v1.29.1
release/v1.29.1
2026-07-17 11:25:00 +02:00
Marc-André Bombeck 99d80f8512 chore(release): v1.29.1 — polish and fixes from the daily view
Today refreshes right after a dose; a calmer Today hero; the Coach names the attached document; a fresh install starts with tracking on; the vitals explainer line is removed; the illness journal gains an editable end and a settled course line; the morning refresh now triggers from every sleep source and records each skip.
2026-07-17 11:12:30 +02:00
Marc-André BombeckandClaude Opus 4.8 2da6a192a7 feat(polish): default tracking modules on; illness backdate + course fixes
A clean install now defaults every tracking module on (weight, blood
pressure, glucose, sleep, mood, medications, labs, documents, insights,
mental wellbeing, cycle, illness, environment, workouts) so the app is
usable without enabling each surface by hand. Only the two channels that
open an egress or attack surface stay opt-in — the remote MCP endpoint and
the micronutrient sync; the document AI-read and the daily-briefing push
remain separately gated and off. The change touches defaults only:
accounts with a saved preference resolve exactly as before, the new default
applies solely where no preference exists.

Drop the "typical range = median ± MAD" method caption from the vitals
baseline tiles — it repeated across every tile and read as out of place at
the top of the vitals surface. The dedicated bands and composite scores
keep their method copy.

Illness episodes: the capture sheet now carries an editable end date
alongside the start, so a whole past episode can be recorded after the
fact and an open-ended course can be resolved (or re-opened) later. The
end is bounded to on-or-after the start.

Fix the per-episode "how it progressed" card rendering an empty body when
the engine returned a valid result with no notable findings — it now shows
a calm, honest line instead of looking as though nothing was recorded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
2026-07-17 11:02:57 +02:00
Marc-André Bombeck f6e6593bcb fix(coach): name the attached document(s) in the fenced banner; keep the attach menu within its popover
The fenced-conversation banner said a generic "N documents attached", which
gave no hint what a thread was about when returning to it later. It now names
the attached document(s): one document shows its title, two show both, three or
more show the first two plus an "and N more" tail. Reuses the existing
"Chatting about: …" doc-scope phrasing and the attachment DTO titles; the title
truncates within the pill so a long name never breaks the banner. Falls back to
a plain count only when no title has resolved yet.

Also constrain the composer attach menu: a long localised label — e.g. "Neues
Dokument hochladen" — could run past the popover edge. The menu items now shrink
and truncate within the menu width, mirroring the vault picker items.

Adds a "chattingAboutMore" string across all six locales.
2026-07-17 11:02:47 +02:00
Marc-André BombeckandClaude Opus 4.8 65f90160c7 fix(daily): refresh the Today digest after a dose, drop the hero ring cluster
The Today digest went stale after logging a dose: nothing invalidated the
`["daily","digest"]` query, and it reads meds from the same server snapshot
the intake routes already hard-evict — so the "dose due" rail item lingered
until a hard reload. Add the digest key to the medication-dependent bundle and
force the inactive digest (unmounted while the user is on the medication card)
to refetch alongside the dashboard snapshot; the server snapshot eviction seam
is reused, so the refetch returns post-write dose state immediately.

Remove the score-ring cluster from the Today hero — it read as uneven and
wasted tile space. The health-score ring is the hero's only ring now; the
score-ring selection contract stays server-side for the native client. Tighten
the hero's vertical rhythm so the worth-a-look rail sits close under the lead
instead of floating below a half-empty column.

Wire the remaining sleep transports into the morning-refresh trigger: Google
Health, Fitbit, Oura, and Polar now enqueue the debounced morning refresh on a
last-night segment landing, matching the Withings / WHOOP / Apple seams — those
sources had no trigger at all. Make the trigger observable: each early-return
gate now annotates a distinct `daily.morning_refresh.skipped` reason (the
not-last-night gate also carries the samples' resolved local dates, today's key
and the tz) so a real landing shows exactly which gate fired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
2026-07-17 11:00:22 +02:00
73 changed files with 3779 additions and 391 deletions

No files matched your search

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