From 43884569d38f05f01c9ee1440f077de8220a46ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 13:56:45 +0200 Subject: [PATCH 1/8] fix(whoop): map sport_id/sport_name to the canonical WorkoutSportType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHOOP was the only workout integration that never normalised its sport, unlike the Fitbit and Google Health mappers — it wrote WHOOP's raw sport_name or a literal whoop_sport_ placeholder straight into Workout.sportType. Confirmed in prod: a user's WHOOP-tracked cycling workouts never rendered as cycling on /insights/workouts because nothing in the UI could map the non-canonical string to an icon or label. sport-map.ts adds a sport_id/sport_name table sourced from WHOOP's public Workout-object reference and mirrors the Fitbit/Google mapper shape: sport_id is preferred (retired past 2025-09-01 but still exact when present), sport_name is normalised before the fallback lookup, and anything unrecognised resolves to "other" — never a placeholder. The raw WHOOP fields stay in Workout.metadata for traceability. Adds a shared table-driven test that checks Fitbit, Google Health, and WHOOP sport mappers together so the three integrations can't silently drift out of parity on common sports. --- src/lib/whoop/__tests__/sport-map.test.ts | 107 ++++++++ src/lib/whoop/__tests__/sync-workout.test.ts | 121 +++++++++ src/lib/whoop/mapping.md | 26 +- src/lib/whoop/sport-map.ts | 250 ++++++++++++++++++ src/lib/whoop/sync-workout.ts | 22 +- .../__tests__/sport-map-parity.test.ts | 129 +++++++++ 6 files changed, 642 insertions(+), 13 deletions(-) create mode 100644 src/lib/whoop/__tests__/sport-map.test.ts create mode 100644 src/lib/whoop/__tests__/sync-workout.test.ts create mode 100644 src/lib/whoop/sport-map.ts create mode 100644 src/lib/workouts/__tests__/sport-map-parity.test.ts diff --git a/src/lib/whoop/__tests__/sport-map.test.ts b/src/lib/whoop/__tests__/sport-map.test.ts new file mode 100644 index 000000000..ba405e681 --- /dev/null +++ b/src/lib/whoop/__tests__/sport-map.test.ts @@ -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_` 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_ 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_ 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); + }); +}); diff --git a/src/lib/whoop/__tests__/sync-workout.test.ts b/src/lib/whoop/__tests__/sync-workout.test.ts new file mode 100644 index 000000000..f29596bbd --- /dev/null +++ b/src/lib/whoop/__tests__/sync-workout.test.ts @@ -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("../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 { + 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_ 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"); + }); +}); diff --git a/src/lib/whoop/mapping.md b/src/lib/whoop/mapping.md index e327438d3..9f4ad5231 100644 --- a/src/lib/whoop/mapping.md +++ b/src/lib/whoop/mapping.md @@ -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_` 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` diff --git a/src/lib/whoop/sport-map.ts b/src/lib/whoop/sport-map.ts new file mode 100644 index 000000000..e1951968e --- /dev/null +++ b/src/lib/whoop/sport-map.ts @@ -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_` 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_` 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_` 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 = + new Map(WHOOP_SPORT_TABLE.map((row) => [row.id, row.canonical])); + +export const WHOOP_SPORT_NAME_MAP: ReadonlyMap = + 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_` 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"; +} diff --git a/src/lib/whoop/sync-workout.ts b/src/lib/whoop/sync-workout.ts index 560bf2e48..14cdfea06 100644 --- a/src/lib/whoop/sync-workout.ts +++ b/src/lib/whoop/sync-workout.ts @@ -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, diff --git a/src/lib/workouts/__tests__/sport-map-parity.test.ts b/src/lib/workouts/__tests__/sport-map-parity.test.ts new file mode 100644 index 000000000..da5456906 --- /dev/null +++ b/src/lib/workouts/__tests__/sport-map-parity.test.ts @@ -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 = { + 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 = { + fitbit: "biking", + googleHealth: "BIKING", + whoop: "Cycling", + }; + const result = mapper(cyclingLabels[name]!); + expect(result).toBe("cycling"); + } + }); +}); From 86e92f38ca4061103e2beaee27133c28654e35a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 13:56:54 +0200 Subject: [PATCH 2/8] fix(db): backfill non-canonical WHOOP workout sport_type values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing WHOOP Workout rows still carry whatever the pre-fix write path stored — a whoop_sport_ placeholder or WHOOP's raw sport_name — so the sport-map fix only corrects new syncs. This one-shot UPDATE re-derives every existing WHOOP row's sport_type from the same table sport-map.ts uses, generated directly from that table so the historical data and the live mapper can't drift apart. Unmatched values default to "other", matching the mapper's fallback. Idempotent: the WHERE clause skips rows already on a canonical value, so a re-run touches zero rows. --- .../migration.sql | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 prisma/migrations/0247_backfill_whoop_workout_sport/migration.sql 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' + ); From b3d586066acabf16427aa0b2cc635bdca6aeebeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 13:57:03 +0200 Subject: [PATCH 3/8] fix(workouts): backfill richer fields on the canonical-picker winner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pickCanonicalWorkout() picked one whole row per cluster by the source ladder and dropped the rest, so a higher-priority twin with a null HR (e.g. a Withings summary) discarded a lower-priority twin's real reading (e.g. WHOOP's HR strap) instead of keeping it. The ladder winner is now the base row, backfilled with avgHeartRate, maxHeartRate, totalEnergyKcal, totalDistanceM, and elevationM from the ladder-highest member that actually has each field — filling gaps, never overwriting a value the base row already carries. The base row also adopts a specific sport type from a cluster member when its own is generic ("other" / unrecognised). Reaching that case needs clustering to accept a generic↔specific sport pairing within the proximity window, alongside the existing exact-match rule — two different SPECIFIC sports (Walking vs Running) still never cluster, so the core invariant holds. --- .../__tests__/pick-canonical-workout.test.ts | 124 ++++++++++++ src/lib/sources/pick-canonical-workout.ts | 181 ++++++++++++++++-- 2 files changed, 286 insertions(+), 19 deletions(-) diff --git a/src/lib/sources/__tests__/pick-canonical-workout.test.ts b/src/lib/sources/__tests__/pick-canonical-workout.test.ts index da22f41cc..e42ed8ef6 100644 --- a/src/lib/sources/__tests__/pick-canonical-workout.test.ts +++ b/src/lib/sources/__tests__/pick-canonical-workout.test.ts @@ -28,11 +28,13 @@ const row = ( source: WorkoutPickerRow["source"], startedAt: string, sportType: WorkoutPickerRow["sportType"] = "running", + extra: Partial = {}, ): 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 + }); +}); diff --git a/src/lib/sources/pick-canonical-workout.ts b/src/lib/sources/pick-canonical-workout.ts index 371765c3c..e40f6df6d 100644 --- a/src/lib/sources/pick-canonical-workout.ts +++ b/src/lib/sources/pick-canonical-workout.ts @@ -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( + 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( * 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( // 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( 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( 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, }); } From 3aa11b0531ab640cba2f60c54b2a19e548bf533c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 13:51:34 +0200 Subject: [PATCH 4/8] feat(oura): feed Cycle Insights phases into the cycle tracker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull Oura's daily_cycle_phases collection (undocumented in the published OpenAPI spec, but confirmed live and auth-gated identically to every other daily_* collection) on the existing hourly Oura poll. A luteal→follicular transition, or a literal "menstrual" phase day, writes a single-day CycleDayLog(flow=LIGHT, source=OURA) — the same conservative "logged but unspecified intensity" marker the HealthKit unspecified-flow mapping uses. Manual entries always win: the write only ever creates a day that has no existing CycleDayLog row at all, regardless of source, and never updates a row it did not create itself. No MenstrualCycle span is synthesised, mirroring the Apple Health importer precedent. A 403/404 on the endpoint (the expected outcome for most connections — Oura's reproductive-health resources are all absent from its public spec) is swallowed without touching the rest of the Oura connection's sync status. No migration: reuses the existing CycleDayLog.source column and the OURA value already present on MeasurementSource. --- src/lib/oura/__tests__/client.test.ts | 100 +++++++++++++ src/lib/oura/__tests__/cycle-sync.test.ts | 166 ++++++++++++++++++++++ src/lib/oura/__tests__/sync.test.ts | 51 +++++++ src/lib/oura/client.ts | 111 +++++++++++++++ src/lib/oura/cycle-sync.ts | 126 ++++++++++++++++ src/lib/oura/sync.ts | 23 +++ 6 files changed, 577 insertions(+) create mode 100644 src/lib/oura/__tests__/cycle-sync.test.ts create mode 100644 src/lib/oura/cycle-sync.ts diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 6a44695ae..1d51c364d 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -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"]); + }); +}); diff --git a/src/lib/oura/__tests__/cycle-sync.test.ts b/src/lib/oura/__tests__/cycle-sync.test.ts new file mode 100644 index 000000000..ef732bbf4 --- /dev/null +++ b/src/lib/oura/__tests__/cycle-sync.test.ts @@ -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(); + 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" }), + }), + ); + }); +}); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index a10533017..7738bc43a 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -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"); + }); }); diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 2081bfbc5..460fddfa4 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -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 { + return fetchCollection( + "/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(); + 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); +} diff --git a/src/lib/oura/cycle-sync.ts b/src/lib/oura/cycle-sync.ts new file mode 100644 index 000000000..a750f3c0a --- /dev/null +++ b/src/lib/oura/cycle-sync.ts @@ -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 { + 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; +} diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index b91d09197..dbafc4a37 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -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. From 4f198ec25f98494fe21dfed875e013196a2906d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 14:20:19 +0200 Subject: [PATCH 5/8] feat(insights): day navigator on the intraday pulse chart; 90-day dense retention with hourly fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds prev/next-day paging to the intraday pulse "shape of the day" chart (TileHeader right slot, next disabled at today) so a user can step back through prior days on the existing date-scoped route. Raises DENSE_INTRADAY_RETENTION_DAYS from 14 to 90 — a self-hoster's own storage is the trade-off for three months of full 10-minute-resolution history instead of two weeks. Days outside the window keep folding to hourly-mean stats: rows as before; loadIntradayPulse now falls back to that hourly tier when a day's raw samples are gone, so the navigator shows a coarser shape instead of an empty chart. The tension-window read is suppressed on hourly days — it needs per-sample resolution to stay honest. A new resolution field on the DTO ("tenMin" | "hourly") drives a small note in the chart. --- messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/it.json | 6 +- messages/pl.json | 6 +- src/app/api/insights/pulse/intraday/route.ts | 7 +- .../insights/intraday-pulse-chart.tsx | 81 +++++++++++++++++-- .../__tests__/intraday-pulse.test.ts | 45 +++++++++++ src/lib/analytics/intraday-pulse-io.ts | 81 ++++++++++++++++--- src/lib/analytics/intraday-pulse.ts | 70 ++++++++++++++++ .../measurements/dense-intraday-retention.ts | 15 +++- src/lib/tz/__tests__/format.test.ts | 32 ++++++++ src/lib/tz/format.ts | 15 ++++ .../dense-intraday-hourly-rebuild.test.ts | 2 +- .../dense-intraday-retention.test.ts | 2 +- 16 files changed, 354 insertions(+), 32 deletions(-) create mode 100644 src/lib/tz/__tests__/format.test.ts diff --git a/messages/de.json b/messages/de.json index aa4bcd595..da0e2d9ae 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3869,8 +3869,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..6d7047b16 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3869,8 +3869,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..79e7d5692 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3869,8 +3869,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..5d723b8ee 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3869,8 +3869,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..02336401e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3869,8 +3869,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..eddaff186 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3869,8 +3869,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/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/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx index 843d3af27..563d3edbd 100644 --- a/src/components/insights/intraday-pulse-chart.tsx +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -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(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 (
- + + + + {dayLabel} + + +
+ } + />

{caption}

{isLoading ? ( diff --git a/src/lib/analytics/__tests__/intraday-pulse.test.ts b/src/lib/analytics/__tests__/intraday-pulse.test.ts index a03c991fa..bed84682e 100644 --- a/src/lib/analytics/__tests__/intraday-pulse.test.ts +++ b/src/lib/analytics/__tests__/intraday-pulse.test.ts @@ -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"); diff --git a/src/lib/analytics/intraday-pulse-io.ts b/src/lib/analytics/intraday-pulse-io.ts index f28f2f3af..df5089d6b 100644 --- a/src/lib/analytics/intraday-pulse-io.ts +++ b/src/lib/analytics/intraday-pulse-io.ts @@ -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, }; } diff --git a/src/lib/analytics/intraday-pulse.ts b/src/lib/analytics/intraday-pulse.ts index 8eea76598..3a517aa93 100644 --- a/src/lib/analytics/intraday-pulse.ts +++ b/src/lib/analytics/intraday-pulse.ts @@ -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, + dayKey: string, + localOf: LocalResolver, +): IntradayHrBucket[] { + const acc = new Map(); + 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, diff --git a/src/lib/measurements/dense-intraday-retention.ts b/src/lib/measurements/dense-intraday-retention.ts index 5d3b593e4..47e083eac 100644 --- a/src/lib/measurements/dense-intraday-retention.ts +++ b/src/lib/measurements/dense-intraday-retention.ts @@ -138,11 +138,18 @@ export const DENSE_INTRADAY_RETENTION_TYPES: ReadonlySet = * 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:"; diff --git a/src/lib/tz/__tests__/format.test.ts b/src/lib/tz/__tests__/format.test.ts new file mode 100644 index 000000000..c865685e6 --- /dev/null +++ b/src/lib/tz/__tests__/format.test.ts @@ -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"); + }); +}); diff --git a/src/lib/tz/format.ts b/src/lib/tz/format.ts index 36ad8cc95..bf1ac4035 100644 --- a/src/lib/tz/format.ts +++ b/src/lib/tz/format.ts @@ -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 diff --git a/tests/integration/dense-intraday-hourly-rebuild.test.ts b/tests/integration/dense-intraday-hourly-rebuild.test.ts index 450bc9672..ad2435af3 100644 --- a/tests/integration/dense-intraday-hourly-rebuild.test.ts +++ b/tests/integration/dense-intraday-hourly-rebuild.test.ts @@ -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); diff --git a/tests/integration/dense-intraday-retention.test.ts b/tests/integration/dense-intraday-retention.test.ts index 420d1dcad..329d129dd 100644 --- a/tests/integration/dense-intraday-retention.test.ts +++ b/tests/integration/dense-intraday-retention.test.ts @@ -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); From 270f33edb2f9a679189628d4f140115f2ceff0c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 14:26:44 +0200 Subject: [PATCH 6/8] feat(daily): let the Today rail dismiss milestone / ECG / tension cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worth-a-look rail mixed actionable items (an overdue dose, a broken sync) with observational ones (a milestone, a new ECG pointer, a tension window) that have nothing to act on beyond "seen it" — there was no way to clear them short of them expiring on their own. Add server-side dismissal scoped to the three observational kinds only: a new DismissedPriorityItem table (userId + itemKey, composite-unique), a POST /api/daily/digest/dismiss route, and a deterministic itemKey the digest builder stamps per kind (metric + reach day for a milestone, the recording's own timestamp for an ECG, the local day + part of day for a tension window) so a dismissal never bleeds into a later day's instance. The actionable kinds never carry an itemKey, so the dismiss affordance and the endpoint are both structurally unreachable for them — an itemKey not namespaced under a dismissible kind 422s before any lookup. --- docs/api/openapi.yaml | 64 +++++++ .../migration.sql | 30 ++++ prisma/schema.prisma | 30 ++++ .../digest/dismiss/__tests__/route.test.ts | 164 ++++++++++++++++++ src/app/api/daily/digest/dismiss/route.ts | 82 +++++++++ .../daily/__tests__/priority-card.test.tsx | 57 ++++++ src/components/daily/priority-card.tsx | 56 +++++- src/components/daily/today-hero.tsx | 3 + src/hooks/use-priority-item-dismiss.ts | 29 ++++ src/lib/daily/__tests__/digest.test.ts | 95 ++++++++++ .../daily/__tests__/priority-item-key.test.ts | 79 +++++++++ src/lib/daily/digest.ts | 42 ++++- src/lib/daily/load-digest.ts | 28 +++ src/lib/daily/priority-item-key.ts | 41 +++++ src/lib/daily/priority-item.ts | 46 +++++ src/lib/openapi/routes/daily.ts | 36 ++++ src/lib/validations/daily.ts | 30 ++++ 17 files changed, 903 insertions(+), 9 deletions(-) create mode 100644 prisma/migrations/0248_dismissed_priority_items/migration.sql create mode 100644 src/app/api/daily/digest/dismiss/__tests__/route.test.ts create mode 100644 src/app/api/daily/digest/dismiss/route.ts create mode 100644 src/hooks/use-priority-item-dismiss.ts create mode 100644 src/lib/daily/__tests__/priority-item-key.test.ts create mode 100644 src/lib/daily/priority-item-key.ts create mode 100644 src/lib/validations/daily.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1b0b02a91..0a6c2842d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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/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/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/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")} + > +