diff --git a/CHANGELOG.md b/CHANGELOG.md index 079773f79..8cdfad400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1b0b02a91..f26730199 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 `:...`. 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: diff --git a/messages/de.json b/messages/de.json index aa4bcd595..0e7bda121 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index af5e01480..c2195f3dc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 09e524701..a29101256 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index a37d14cea..d3726d425 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index 6dbf5208e..b07a3cf88 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/pl.json b/messages/pl.json index beb3947b1..592f41484 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", diff --git a/package.json b/package.json index aecdc5b15..0006031b8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/0247_backfill_whoop_workout_sport/migration.sql b/prisma/migrations/0247_backfill_whoop_workout_sport/migration.sql new file mode 100644 index 000000000..d166b75ad --- /dev/null +++ b/prisma/migrations/0247_backfill_whoop_workout_sport/migration.sql @@ -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_` 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_` 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_` +-- 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' + ); diff --git a/prisma/migrations/0248_dismissed_priority_items/migration.sql b/prisma/migrations/0248_dismissed_priority_items/migration.sql new file mode 100644 index 000000000..fb6b95de8 --- /dev/null +++ b/prisma/migrations/0248_dismissed_priority_items/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ba24b0d8e..3dafaa7b7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/public/sw.js b/public/sw.js index 50c7c0d12..4d02ccf2d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/app/api/daily/digest/dismiss/__tests__/route.test.ts b/src/app/api/daily/digest/dismiss/__tests__/route.test.ts new file mode 100644 index 000000000..1c291db9e --- /dev/null +++ b/src/app/api/daily/digest/dismiss/__tests__/route.test.ts @@ -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(); + 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; +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(); + }); +}); diff --git a/src/app/api/daily/digest/dismiss/route.ts b/src/app/api/daily/digest/dismiss/route.ts new file mode 100644 index 000000000..4a0ba5d84 --- /dev/null +++ b/src/app/api/daily/digest/dismiss/route.ts @@ -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 }); +}); diff --git a/src/app/api/insights/pulse/intraday/route.ts b/src/app/api/insights/pulse/intraday/route.ts index c0f149592..9dc332948 100644 --- a/src/app/api/insights/pulse/intraday/route.ts +++ b/src/app/api/insights/pulse/intraday/route.ts @@ -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, }, }); diff --git a/src/components/daily/__tests__/priority-card.test.tsx b/src/components/daily/__tests__/priority-card.test.tsx index 7db766635..d588ae69f 100644 --- a/src/components/daily/__tests__/priority-card.test.tsx +++ b/src/components/daily/__tests__/priority-card.test.tsx @@ -134,4 +134,61 @@ describe("", () => { ); 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( + {}} + />, + ); + 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( + {}} + />, + ); + 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( + {}} + />, + ); + 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( + , + ); + expect(html).not.toContain('data-slot="priority-card-dismiss"'); + }); + }); }); diff --git a/src/components/daily/priority-card.tsx b/src/components/daily/priority-card.tsx index 71008f3f4..fab425ef5 100644 --- a/src/components/daily/priority-card.tsx +++ b/src/components/daily/priority-card.tsx @@ -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 ( - + onDismiss(dismissKey)} + aria-label={t("common.dismiss")} + > +