Merge pull request #517 from MBombeck/release/v1.29.2

release/v1.29.2
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-17 14:46:26 +02:00
commit bef3b9dd3f
53 files changed
+3193 -94

No files matched your search

+25
View File
@@ -2,6 +2,31 @@
## [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.
+65 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.29.1
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:
+6 -1
View File
@@ -3397,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",
@@ -3869,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.",
+6 -1
View File
@@ -3397,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",
@@ -3869,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.",
+6 -1
View File
@@ -3397,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",
@@ -3869,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.",
+6 -1
View File
@@ -3397,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",
@@ -3869,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.",
+6 -1
View File
@@ -3397,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",
@@ -3869,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.",
+6 -1
View File
@@ -3397,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",
@@ -3869,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.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.29.1",
"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.1";
/* @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,
},
});
@@ -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"');
});
});
});
+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} />
+3
View File
@@ -42,6 +42,7 @@ import type { ScoreBand } from "@/components/insights/derived/band-tokens";
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 { DailyDigest } from "@/lib/daily/digest";
@@ -64,6 +65,7 @@ function formatDelta(
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
@@ -220,6 +222,7 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
key={`${item.kind}-${i}`}
item={item}
onAction={handleAction}
onDismiss={(itemKey) => dismissItem.mutate(itemKey)}
/>
))}
</div>
@@ -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,14 +288,14 @@ function BaselineTile({
framing={framing}
directionSentiment={vitalSentiment(type)}
provenance={
// v1.29.1 — the VITALS_BASELINE "typical range = median ± MAD" method
// caption is suppressed on its own tiles: it repeated across every
// vital baseline tile and read as out-of-place clutter at the top of
// the vitals surface. The dedicated bands (wrist-temp, stair speed,
// 6-min walk) keep their method copy — only VITALS_BASELINE is muted.
metric === "VITALS_BASELINE" ? undefined : (
<MetricProvenance metric={metric} provenance={data.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 ? (
+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() });
},
});
}
@@ -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();
});
});
@@ -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,
);
+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`. */
@@ -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:";
+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;
}
+23
View File
@@ -78,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
@@ -370,6 +371,28 @@ 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.
@@ -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);