diff --git a/CHANGELOG.md b/CHANGELOG.md index 4617fa8ad..8255a1cf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## [Unreleased] +## [1.32.15] — 2026-07-24 + +Your Polar training sessions now sync into HealthLog, alongside the recovery, +sleep, and daily activity that Polar already brought in. Thanks to +@StefanIndustries, whose report asked for this after the vitals sync landed. + +- **Polar exercises arrive as workouts.** Every run, ride, swim, or gym session + you upload to Polar Flow shows up in your workout history with its sport, + start time, duration, distance, calories, and heart rate. The raw Polar sport + label and training load ride along in the background so nothing is lost. The + sync reads your last 30 days of uploads once an hour, so a session synced + late from your watch is still caught. +- **A Polar session does not double up with your other sources.** If the same + workout also reaches HealthLog through Apple Health or another connection, the + two are matched and only one is shown. A Polar watch counts as a direct + heart-rate recording, so it ranks above a Strava re-upload of the same run but + below your primary on-wrist wearables. You can change that order under + Settings, Sources. +- **The two Polar halves stay independent.** A hiccup fetching your training + sessions no longer holds back the recovery and sleep sync, and the reverse + holds too, so a problem on one side never masks the other. + ## [1.32.14] — 2026-07-24 When the Coach removes a number it could not check against your data, the reply diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d3b114d83..f235548b0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.14 + version: 1.32.15 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index 168a8cd97..4eccd9736 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5545,7 +5545,7 @@ "nightscoutDisconnectTitle": "Nightscout trennen?", "nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf.", "polar": "Polar", - "polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und tägliche Aktivität zu synchronisieren.", + "polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen, tägliche Aktivität und Trainingseinheiten zu synchronisieren.", "polarViewData": "Schlaf & Erholung ansehen", "polarHelp": "Melde dich bei Polar an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.", "polarCredentials": "API-Zugangsdaten", diff --git a/messages/en.json b/messages/en.json index 257b7cfbe..d15eec67f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5535,7 +5535,7 @@ "nightscoutDisconnectTitle": "Disconnect Nightscout?", "nightscoutDisconnectDescription": "Future syncs stop. Glucose readings already imported stay in your history.", "polar": "Polar", - "polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, and daily activity.", + "polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, daily activity, and training sessions.", "polarViewData": "View your sleep & recovery", "polarHelp": "Sign in with Polar to grant access. Data is pulled in the background once an hour.", "polarCredentials": "API Credentials", diff --git a/messages/es.json b/messages/es.json index 8ef65c75d..846f043ad 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5547,7 +5547,7 @@ "nightscoutDisconnectTitle": "¿Desconectar Nightscout?", "nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial.", "polar": "Polar", - "polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, fases de sueño y actividad diaria.", + "polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, fases de sueño, actividad diaria y sesiones de entrenamiento.", "polarViewData": "Ver tu sueño y recuperación", "polarHelp": "Inicia sesión en Polar para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.", "polarCredentials": "Credenciales de API", diff --git a/messages/fr.json b/messages/fr.json index a40154fb5..0f4af6e6e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5549,7 +5549,7 @@ "nightscoutDisconnectTitle": "Déconnecter Nightscout ?", "nightscoutDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures de glycémie déjà importées restent dans votre historique.", "polar": "Polar", - "polarDescription": "Connecte ton compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité.", + "polarDescription": "Connecte ton compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil, l'activité et les séances d'entraînement.", "polarViewData": "Voir votre sommeil et récupération", "polarHelp": "Connectez-vous à Polar pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.", "polarCredentials": "Identifiants API", diff --git a/messages/it.json b/messages/it.json index dfeb62985..28a474487 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5539,7 +5539,7 @@ "nightscoutDisconnectTitle": "Disconnettere Nightscout?", "nightscoutDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture della glicemia già importate restano nella tua cronologia.", "polar": "Polar", - "polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno e l'attività giornaliera.", + "polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno, l'attività giornaliera e le sessioni di allenamento.", "polarViewData": "Vedi sonno e recupero", "polarHelp": "Accedi a Polar per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.", "polarCredentials": "Credenziali API", diff --git a/messages/pl.json b/messages/pl.json index 33b15221a..d3cc56145 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5533,7 +5533,7 @@ "nightscoutDisconnectTitle": "Rozłączyć Nightscout?", "nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii.", "polar": "Polar", - "polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i dzienną aktywność.", + "polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu, dzienną aktywność i treningi.", "polarViewData": "Zobacz sen i regenerację", "polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.", "polarCredentials": "Dane API", diff --git a/package.json b/package.json index 07988043f..71c76cbdb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.14", + "version": "1.32.15", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index b612ef307..da0f72c09 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.14"; + /* @sw-version-fallback */ "v1.32.15"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts b/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts new file mode 100644 index 000000000..65ab44cd4 --- /dev/null +++ b/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { syncUserPolar, syncUserPolarWorkouts } = vi.hoisted(() => ({ + syncUserPolar: vi.fn(), + syncUserPolarWorkouts: vi.fn(), +})); + +vi.mock("@/lib/polar/sync", () => ({ syncUserPolar })); +vi.mock("@/lib/polar/sync-workouts", () => ({ syncUserPolarWorkouts })); + +import { syncUserPolarLegs } from "../polar-sync"; + +beforeEach(() => { + vi.clearAllMocks(); + syncUserPolar.mockResolvedValue(0); + syncUserPolarWorkouts.mockResolvedValue(0); +}); + +describe("syncUserPolarLegs — composite independence on the shared polar ledger", () => { + it("runs both legs and returns the summed count when both pass", async () => { + syncUserPolar.mockResolvedValue(5); + syncUserPolarWorkouts.mockResolvedValue(2); + await expect(syncUserPolarLegs("u1")).resolves.toBe(7); + expect(syncUserPolar).toHaveBeenCalledWith("u1"); + expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1"); + }); + + it("still attempts the workout leg when the vitals leg fails (vice-versa isolation)", async () => { + syncUserPolar.mockRejectedValue(new Error("vitals down")); + syncUserPolarWorkouts.mockResolvedValue(3); + // The vitals error is rethrown (attributable), but only AFTER the workout + // leg was attempted — a vitals failure never starves the workout leg. + await expect(syncUserPolarLegs("u1")).rejects.toThrow("vitals down"); + expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1"); + }); + + it("leaves the vitals leg's result intact when the workout leg fails", async () => { + syncUserPolar.mockResolvedValue(4); + syncUserPolarWorkouts.mockRejectedValue(new Error("workouts down")); + // The vitals leg ran to completion (its own success already recorded on + // the shared ledger); the workout error is surfaced without undoing it. + await expect(syncUserPolarLegs("u1")).rejects.toThrow("workouts down"); + expect(syncUserPolar).toHaveBeenCalledWith("u1"); + }); + + it("rethrows the FIRST (vitals) error when both legs fail", async () => { + syncUserPolar.mockRejectedValue(new Error("vitals first")); + syncUserPolarWorkouts.mockRejectedValue(new Error("workouts second")); + await expect(syncUserPolarLegs("u1")).rejects.toThrow("vitals first"); + expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1"); + }); +}); diff --git a/src/lib/jobs/reminder/polar-sync.ts b/src/lib/jobs/reminder/polar-sync.ts index 2ae9c78df..b73efee48 100644 --- a/src/lib/jobs/reminder/polar-sync.ts +++ b/src/lib/jobs/reminder/polar-sync.ts @@ -1,20 +1,58 @@ /** * v1.17.0 (F4) — Polar poll-cohort sync handler. + * v1.32.15 — the per-user arm now runs BOTH Polar legs: vitals + * (`syncUserPolar`) then exercises/workouts (`syncUserPolarWorkouts`). * * Poll-only: Polar AccessLink has a webhook, but HealthLog runs the simpler * poll model (matching Nightscout). One hourly cron tick carries no `userId` * and the handler iterates every user with a stored Polar token, re-syncing - * each via `syncUserPolar`. One user's revoked grant / outage is warned and the - * pass continues. The shared control flow lives in `./poll-cohort`. + * each via `syncUserPolarLegs`. One user's revoked grant / outage is warned and + * the pass continues. The shared control flow lives in `./poll-cohort`. * * The queue name, cron schedule, and boss.work registration live in - * reminder-worker.ts. + * reminder-worker.ts — no new queue: the workout leg rides the existing hourly + * `polar-sync` tick (one extra request per connected user per hour). */ import { syncUserPolar } from "@/lib/polar/sync"; +import { syncUserPolarWorkouts } from "@/lib/polar/sync-workouts"; import { makePollCohortHandler, type PollCohortPayload } from "./poll-cohort"; export type PolarSyncPayload = PollCohortPayload; +/** + * Run both Polar legs for one user with INDEPENDENT attribution on the shared + * `polar` ledger. Sequentially awaited (not `Promise.all`) so a workouts error + * stays attributable. Each leg records its own failure; the vitals leg owns the + * single success write. A failure in either leg is isolated — both legs are + * always attempted, so a workouts-leg failure can never mask a vitals success + * and vice versa — then the FIRST error is rethrown so the cohort handler warns + * and skips this user's success accounting without losing the error. + */ +export async function syncUserPolarLegs(userId: string): Promise { + let total = 0; + let firstError: unknown; + let hadError = false; + + try { + total += await syncUserPolar(userId); + } catch (err) { + firstError = err; + hadError = true; + } + + try { + total += await syncUserPolarWorkouts(userId); + } catch (err) { + if (!hadError) { + firstError = err; + hadError = true; + } + } + + if (hadError) throw firstError; + return total; +} + export const handlePolarSync = makePollCohortHandler({ taskName: "job.polar_sync", findCohort: async (prisma) => { @@ -24,5 +62,5 @@ export const handlePolarSync = makePollCohortHandler({ }); return users.map((u) => u.id); }, - syncUser: syncUserPolar, + syncUser: syncUserPolarLegs, }); diff --git a/src/lib/polar/__tests__/client-exercises.test.ts b/src/lib/polar/__tests__/client-exercises.test.ts new file mode 100644 index 000000000..cf41fa331 --- /dev/null +++ b/src/lib/polar/__tests__/client-exercises.test.ts @@ -0,0 +1,260 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + combinePolarStart, + fetchExercises, + mapExercise, + normaliseExerciseList, + parseIsoDuration, + type PolarExercise, +} from "../client"; +import { PolarApiError } from "../response-classifier"; + +function installFetchMock(pages: Array<{ status: number; body?: unknown }>) { + let i = 0; + const fetchMock = vi.fn(async (...args: [string, RequestInit?]) => { + void args; + const page = pages[Math.min(i, pages.length - 1)]!; + i += 1; + return { + status: page.status, + json: async () => page.body ?? null, + }; + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("fetchExercises — request path", () => { + // #568 regression guard: the exercises collection GET carries NO userId in + // the path (the user is identified by the Bearer token), exactly the shape + // the v1.31.2 vitals-endpoint fix established. Assert the URL literally. + it("hits GET /v3/exercises with Bearer auth and no userId in the path", async () => { + const fetchMock = installFetchMock([{ status: 204 }]); + await fetchExercises("tok"); + expect(fetchMock).toHaveBeenCalledWith( + "https://www.polaraccesslink.com/v3/exercises", + expect.objectContaining({ method: "GET" }), + ); + const url = fetchMock.mock.calls[0]![0]; + expect(url).toBe("https://www.polaraccesslink.com/v3/exercises"); + expect(url).not.toContain("/users/"); + const [, init] = fetchMock.mock.calls[0]!; + const headers = init!.headers as Record; + expect(headers.Authorization).toBe("Bearer tok"); + }); + + it("returns [] on a 204 No Content", async () => { + installFetchMock([{ status: 204 }]); + expect(await fetchExercises("tok")).toEqual([]); + }); + + it("returns [] on a non-JSON 200 body", async () => { + installFetchMock([{ status: 200, body: null }]); + expect(await fetchExercises("tok")).toEqual([]); + }); + + it("classifies a 401 as a reauth-required PolarApiError", async () => { + installFetchMock([{ status: 401 }]); + await expect(fetchExercises("tok")).rejects.toMatchObject({ + classification: "reauth_required", + httpStatus: 401, + }); + }); + + it("classifies a 5xx as transient", async () => { + installFetchMock([{ status: 503 }]); + await expect(fetchExercises("tok")).rejects.toMatchObject({ + classification: "transient", + }); + }); + + // Assumption #4: a scope gap on exercises surfaces as a 403, which the shared + // classifier lifts to reauth_required — a soft/recoverable integration error + // the workout sync records rather than hard-crashing the whole Polar sync. + it("classifies a 403 scope error as a soft reauth-required error, not a crash", async () => { + installFetchMock([{ status: 403 }]); + const err = await fetchExercises("tok").catch((e) => e); + expect(err).toBeInstanceOf(PolarApiError); + expect(err.classification).toBe("reauth_required"); + expect(err.httpStatus).toBe(403); + }); +}); + +describe("normaliseExerciseList — pagination-absence tolerance (assumption #3)", () => { + const one = { id: 1 }; + it("accepts a bare array", () => { + expect(normaliseExerciseList([one])).toEqual([one]); + }); + it("accepts a paged envelope under `exercises`", () => { + expect(normaliseExerciseList({ exercises: [one], page: 1 })).toEqual([one]); + }); + it("accepts alternate envelope keys (data / items)", () => { + expect(normaliseExerciseList({ data: [one] })).toEqual([one]); + expect(normaliseExerciseList({ items: [one] })).toEqual([one]); + }); + it("returns [] for an unrecognised shape", () => { + expect(normaliseExerciseList({ nope: [one] })).toEqual([]); + expect(normaliseExerciseList(null)).toEqual([]); + expect(normaliseExerciseList(42)).toEqual([]); + }); +}); + +describe("fetchExercises — body shape (assumption #3)", () => { + it("unwraps a paged { exercises: [...] } envelope", async () => { + installFetchMock([{ status: 200, body: { exercises: [{ id: "a" }] } }]); + const r = await fetchExercises("tok"); + expect(r).toHaveLength(1); + expect(r[0]!.id).toBe("a"); + }); + it("returns the bare top-level array", async () => { + installFetchMock([{ status: 200, body: [{ id: "a" }, { id: "b" }] }]); + expect(await fetchExercises("tok")).toHaveLength(2); + }); +}); + +describe("parseIsoDuration", () => { + it("parses hours/minutes/seconds", () => { + expect(parseIsoDuration("PT2H44M30S")).toBe(2 * 3600 + 44 * 60 + 30); + expect(parseIsoDuration("PT45M")).toBe(2700); + expect(parseIsoDuration("PT30S")).toBe(30); + expect(parseIsoDuration("PT1H")).toBe(3600); + }); + it("parses fractional seconds and days", () => { + expect(parseIsoDuration("PT1M2.5S")).toBe(63); // rounded + expect(parseIsoDuration("P1DT2H")).toBe(86400 + 7200); + }); + it("returns 0 for malformed / empty input", () => { + expect(parseIsoDuration("nonsense")).toBe(0); + expect(parseIsoDuration("")).toBe(0); + expect(parseIsoDuration(null)).toBe(0); + expect(parseIsoDuration(undefined)).toBe(0); + }); +}); + +describe("combinePolarStart — UTC-offset composition (assumption #2)", () => { + it("applies a POSITIVE offset to a naive local wall-clock", () => { + // 07:00 local at UTC+2 → 05:00 UTC. + const d = combinePolarStart("2026-06-10T07:00:00", 120)!; + expect(d.toISOString()).toBe("2026-06-10T05:00:00.000Z"); + }); + it("applies a NEGATIVE offset to a naive local wall-clock", () => { + // 07:00 local at UTC-5 → 12:00 UTC. + const d = combinePolarStart("2026-06-10T07:00:00", -300)!; + expect(d.toISOString()).toBe("2026-06-10T12:00:00.000Z"); + }); + it("treats a missing offset as UTC wall-clock", () => { + const d = combinePolarStart("2026-06-10T07:00:00", null)!; + expect(d.toISOString()).toBe("2026-06-10T07:00:00.000Z"); + }); + it("uses a zone-qualified start directly and does NOT re-apply the offset", () => { + // Already carries +02:00 → 05:00 UTC; the offset field must be ignored. + const d = combinePolarStart("2026-06-10T07:00:00+02:00", 120)!; + expect(d.toISOString()).toBe("2026-06-10T05:00:00.000Z"); + }); + it("handles a trailing-Z start directly", () => { + const d = combinePolarStart("2026-06-10T07:00:00Z", 120)!; + expect(d.toISOString()).toBe("2026-06-10T07:00:00.000Z"); + }); + it("returns null for a missing / unparseable start", () => { + expect(combinePolarStart(null, 0)).toBeNull(); + expect(combinePolarStart("", 0)).toBeNull(); + expect(combinePolarStart("not-a-date", 0)).toBeNull(); + }); +}); + +describe("mapExercise", () => { + const full: PolarExercise = { + id: 987654321, + start_time: "2026-06-10T07:00:00", + start_time_utc_offset: 120, + duration: "PT45M", + sport: "RUNNING", + detailed_sport_info: "TRAIL_RUNNING", + calories: 512, + distance: 9200.5, + heart_rate: { average: 148, maximum: 172 }, + training_load: 145.2, + device: "Polar Vantage", + device_id: "dev-1", + }; + + it("maps a full exercise into the exact Workout row shape", () => { + const row = mapExercise(full)!; + expect(row.externalId).toBe("987654321"); + expect(row.sportType).toBe("running"); + expect(row.startedAt.toISOString()).toBe("2026-06-10T05:00:00.000Z"); + expect(row.durationSec).toBe(2700); + expect(row.endedAt.toISOString()).toBe("2026-06-10T05:45:00.000Z"); + expect(row.totalEnergyKcal).toBe(512); + expect(row.totalDistanceM).toBe(9200.5); + expect(row.avgHeartRate).toBe(148); + expect(row.maxHeartRate).toBe(172); + expect(row.elevationM).toBeNull(); + expect(row.metadata).toMatchObject({ + polarSport: "RUNNING", + polarDetailedSportInfo: "TRAIL_RUNNING", + polarDevice: "Polar Vantage", + polarDeviceId: "dev-1", + trainingLoad: 145.2, + startTimeUtcOffset: 120, + }); + }); + + // Assumption #1: the id may arrive as a string OR a number — coerce both to a + // stable string externalId, never parse it. + it("coerces a numeric id and a string id to the same stable externalId", () => { + expect(mapExercise({ ...full, id: 42 })!.externalId).toBe("42"); + expect(mapExercise({ ...full, id: "42" })!.externalId).toBe("42"); + expect(mapExercise({ ...full, id: "abc-123" })!.externalId).toBe("abc-123"); + }); + + it("nulls optional numeric fields when absent", () => { + const row = mapExercise({ + id: "x", + start_time: "2026-06-10T07:00:00", + start_time_utc_offset: 0, + duration: "PT30M", + sport: "STRENGTH_TRAINING", + })!; + expect(row.totalEnergyKcal).toBeNull(); + expect(row.totalDistanceM).toBeNull(); + expect(row.avgHeartRate).toBeNull(); + expect(row.maxHeartRate).toBeNull(); + expect(row.sportType).toBe("strength"); + }); + + it("treats a malformed duration as 0 and never lands endedAt before startedAt", () => { + const row = mapExercise({ ...full, duration: "garbage" })!; + expect(row.durationSec).toBe(0); + expect(row.endedAt.getTime()).toBe(row.startedAt.getTime()); + }); + + it("skips an exercise with no id", () => { + expect(mapExercise({ ...full, id: null })).toBeNull(); + expect(mapExercise({ ...full, id: undefined })).toBeNull(); + }); + + it("skips an exercise with an unparseable start", () => { + expect(mapExercise({ ...full, start_time: "nope" })).toBeNull(); + expect(mapExercise({ ...full, start_time: null })).toBeNull(); + }); + + it("drops negative numerics to null (non-negative guard)", () => { + const row = mapExercise({ + ...full, + calories: -5, + distance: -1, + heart_rate: { average: -1, maximum: -1 }, + })!; + expect(row.totalEnergyKcal).toBeNull(); + expect(row.totalDistanceM).toBeNull(); + expect(row.avgHeartRate).toBeNull(); + expect(row.maxHeartRate).toBeNull(); + }); +}); diff --git a/src/lib/polar/__tests__/dedup.test.ts b/src/lib/polar/__tests__/dedup.test.ts new file mode 100644 index 000000000..0352b40be --- /dev/null +++ b/src/lib/polar/__tests__/dedup.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { DEFAULT_WORKOUT_SOURCE_PRIORITY } from "@/lib/sources/pick-canonical-workout"; +import { DEFAULT_SOURCE_PRIORITY } from "@/lib/validations/source-priority"; + +/** + * v1.32.15 — Polar rides the SAME source-agnostic workout dedup engine as every + * other source: placing POLAR on the ladders is all that is needed for an + * Apple-Watch + Polar twin of one run to collapse to a single canonical row. + * No Polar-specific dedup path exists (nor should it). + */ +const D = (iso: string) => new Date(iso); + +describe("POLAR workout dedup — read-time canonical picker", () => { + it("collapses an Apple-Health + Polar twin of the same run to the device row", () => { + const rows = [ + { + startedAt: D("2026-07-01T06:30:00Z"), + sportType: "running", + source: "APPLE_HEALTH" as const, + }, + // Same run captured by the Polar watch a minute later — same sport, same + // 5-minute slot. + { + startedAt: D("2026-07-01T06:31:00Z"), + sportType: "running", + source: "POLAR" as const, + }, + ]; + const canonical = pickCanonicalWorkoutRows(rows); + expect(canonical).toHaveLength(1); + // Apple Health (phone-aggregated) outranks Polar in the steps ladder. + expect(canonical[0].source).toBe("APPLE_HEALTH"); + }); + + it("keeps a Polar-only run (no competing source) untouched", () => { + const rows = [ + { + startedAt: D("2026-07-02T18:00:00Z"), + sportType: "cycling", + source: "POLAR" as const, + }, + ]; + const canonical = pickCanonicalWorkoutRows(rows); + expect(canonical).toHaveLength(1); + expect(canonical[0].source).toBe("POLAR"); + }); + + it("ranks POLAR above STRAVA when both cover the same workout", () => { + const rows = [ + { + startedAt: D("2026-07-03T07:00:30Z"), + sportType: "running", + source: "STRAVA" as const, + }, + { + startedAt: D("2026-07-03T07:00:00Z"), + sportType: "running", + source: "POLAR" as const, + }, + ]; + const canonical = pickCanonicalWorkoutRows(rows); + expect(canonical).toHaveLength(1); + // Polar is a device-native HR capture; Strava is often a re-upload. + expect(canonical[0].source).toBe("POLAR"); + }); +}); + +describe("POLAR sits in the workout source ladders", () => { + it("is present in the workout default ladder, below WITHINGS, above STRAVA", () => { + const polar = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("POLAR"); + const withings = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("WITHINGS"); + const strava = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("STRAVA"); + expect(polar).toBeGreaterThan(withings); + expect(polar).toBeLessThan(strava); + }); + + it("is present in the `steps` ladder the read picker resolves against, above STRAVA", () => { + const steps = DEFAULT_SOURCE_PRIORITY.steps; + const polar = steps.indexOf("POLAR"); + const apple = steps.indexOf("APPLE_HEALTH"); + const strava = steps.indexOf("STRAVA"); + expect(polar).toBeGreaterThan(apple); + expect(polar).toBeLessThan(strava); + }); +}); diff --git a/src/lib/polar/__tests__/parity.test.ts b/src/lib/polar/__tests__/parity.test.ts new file mode 100644 index 000000000..4dbae7f36 --- /dev/null +++ b/src/lib/polar/__tests__/parity.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; + +import { mapExercise, type PolarExercise } from "../client"; +import { + mapActivity as mapStravaActivity, + type StravaSummaryActivity, + type StravaDetailedActivity, +} from "@/lib/strava/client"; + +/** + * Sensitive-cohort parity guard (mirrors the google-parity precedent): the SAME + * logical workout fed through the Polar mapper and the Strava mapper must land + * as a downstream-indistinguishable `Workout` row — same canonical sport + * bucket, same units (metres / kcal / seconds), same HR, same start instant, + * same null-vs-zero conventions. This pins the contract "a Polar row is + * indistinguishable downstream from any other provider's row". + * + * The logical workout: a 45-minute road run starting 2026-07-01T06:30:00Z, + * 10 000 m, 500 kcal, avg HR 150, max HR 175. + */ +const START_ISO = "2026-07-01T06:30:00Z"; +const DURATION_SEC = 45 * 60; +const DISTANCE_M = 10_000; +const CALORIES = 500; +const AVG_HR = 150; +const MAX_HR = 175; + +const polar: PolarExercise = { + id: 12345, + // Naive local wall-clock + zero offset === the same UTC instant as START_ISO. + start_time: "2026-07-01T06:30:00", + start_time_utc_offset: 0, + duration: "PT45M", + sport: "RUNNING", + detailed_sport_info: "ROAD_RUNNING", + calories: CALORIES, + distance: DISTANCE_M, + heart_rate: { average: AVG_HR, maximum: MAX_HR }, +}; + +const stravaSummary: StravaSummaryActivity = { + id: 67890, + sport_type: "Run", + start_date: START_ISO, + moving_time: DURATION_SEC, + elapsed_time: DURATION_SEC, + distance: DISTANCE_M, + average_heartrate: AVG_HR, + max_heartrate: MAX_HR, +}; +const stravaDetail: StravaDetailedActivity = { + ...stravaSummary, + calories: CALORIES, +}; + +describe("Polar ↔ Strava mapper parity", () => { + const p = mapExercise(polar)!; + const s = mapStravaActivity(stravaSummary, stravaDetail)!; + + it("produces the same canonical sport bucket", () => { + expect(p.sportType).toBe("running"); + expect(p.sportType).toBe(s.sportType); + }); + + it("produces the same start instant", () => { + expect(p.startedAt.getTime()).toBe(Date.parse(START_ISO)); + expect(p.startedAt.getTime()).toBe(s.startedAt.getTime()); + }); + + it("produces the same duration in seconds and a non-inverted window", () => { + expect(p.durationSec).toBe(DURATION_SEC); + expect(p.durationSec).toBe(s.durationSec); + expect(p.endedAt.getTime()).toBeGreaterThanOrEqual(p.startedAt.getTime()); + expect(s.endedAt.getTime()).toBeGreaterThanOrEqual(s.startedAt.getTime()); + }); + + it("produces the same distance in metres", () => { + expect(p.totalDistanceM).toBe(DISTANCE_M); + expect(p.totalDistanceM).toBe(s.totalDistanceM); + }); + + it("produces the same energy in kcal", () => { + expect(p.totalEnergyKcal).toBe(CALORIES); + expect(p.totalEnergyKcal).toBe(s.totalEnergyKcal); + }); + + it("produces the same heart-rate fields", () => { + expect(p.avgHeartRate).toBe(AVG_HR); + expect(p.maxHeartRate).toBe(MAX_HR); + expect(p.avgHeartRate).toBe(s.avgHeartRate); + expect(p.maxHeartRate).toBe(s.maxHeartRate); + }); + + it("shares the null-vs-absent convention for a field neither summary carries", () => { + // Polar has no elevation on the exercise summary → null; a Strava activity + // with no elevation gain also maps elevation to null. Both use `null`, not + // 0, for "not present". + expect(p.elevationM).toBeNull(); + const sNoElev = mapStravaActivity(stravaSummary, stravaDetail)!; + expect(sNoElev.elevationM).toBeNull(); + }); +}); diff --git a/src/lib/polar/__tests__/sport-map.test.ts b/src/lib/polar/__tests__/sport-map.test.ts new file mode 100644 index 000000000..2d3bb61bc --- /dev/null +++ b/src/lib/polar/__tests__/sport-map.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { + POLAR_SPORT_TABLE, + mapPolarSportType, + normalisePolarSportKey, +} from "../sport-map"; +import { workoutSportTypeEnum } from "@/lib/validations/workout"; + +describe("POLAR_SPORT_TABLE", () => { + it("maps every table entry into a canonical WorkoutSportType", () => { + const valid = new Set(workoutSportTypeEnum.options as readonly string[]); + for (const row of POLAR_SPORT_TABLE) { + expect(valid.has(row.canonical)).toBe(true); + } + }); + + it("has no duplicate normalised keys", () => { + const keys = POLAR_SPORT_TABLE.map((r) => normalisePolarSportKey(r.name)); + expect(new Set(keys).size).toBe(keys.length); + }); +}); + +describe("mapPolarSportType", () => { + it("prefers detailed_sport_info over the coarse sport", () => { + // Coarse says cycling, detailed says trail running — detailed wins. + expect(mapPolarSportType("TRAIL_RUNNING", "CYCLING")).toBe("running"); + }); + + it("falls back to the coarse sport when detailed is absent", () => { + expect(mapPolarSportType(null, "CYCLING")).toBe("cycling"); + expect(mapPolarSportType(undefined, "SWIMMING")).toBe("swimming"); + expect(mapPolarSportType("", "STRENGTH_TRAINING")).toBe("strength"); + }); + + it("falls back to the coarse sport when detailed is unrecognised", () => { + // An unknown granular value still gets a chance at the coarse mapping. + expect(mapPolarSportType("SOME_NEW_POLAR_SPORT", "RUNNING")).toBe( + "running", + ); + }); + + it("defaults to other for an unknown pair", () => { + expect(mapPolarSportType("NOPE", "ALSO_NOPE")).toBe("other"); + expect(mapPolarSportType(null, null)).toBe("other"); + expect(mapPolarSportType(undefined, undefined)).toBe("other"); + }); + + it("applies the documented bucket judgment calls consistently with Strava", () => { + // Paddle → rowing, racket → tennis, functional/climbing → crossTraining, + // mobility → mindAndBody, e-bike → cycling, snow → other. + expect(mapPolarSportType("KAYAKING", null)).toBe("rowing"); + expect(mapPolarSportType("PADEL", null)).toBe("tennis"); + expect(mapPolarSportType("FUNCTIONAL_TRAINING", null)).toBe( + "crossTraining", + ); + expect(mapPolarSportType("CLIMBING", null)).toBe("crossTraining"); + expect(mapPolarSportType("PILATES", null)).toBe("mindAndBody"); + expect(mapPolarSportType("E_BIKE", null)).toBe("cycling"); + expect(mapPolarSportType("ALPINE_SKIING", null)).toBe("other"); + }); + + it("is resilient to casing / separator variants", () => { + expect(mapPolarSportType("trail running", null)).toBe("running"); + expect(mapPolarSportType("Trail-Running", null)).toBe("running"); + }); +}); diff --git a/src/lib/polar/__tests__/sync-workouts.test.ts b/src/lib/polar/__tests__/sync-workouts.test.ts new file mode 100644 index 000000000..12b6d3fd1 --- /dev/null +++ b/src/lib/polar/__tests__/sync-workouts.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PolarWorkoutRow } from "../client"; + +const { + createManyAndReturn, + update, + emitInsertedWorkoutArrival, + getPolarConnection, + fetchExercises, + recordSyncFailure, +} = vi.hoisted(() => ({ + createManyAndReturn: vi.fn(), + update: vi.fn(), + emitInsertedWorkoutArrival: vi.fn(async () => {}), + getPolarConnection: vi.fn(), + fetchExercises: vi.fn(), + recordSyncFailure: vi.fn(async () => {}), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { workout: { createManyAndReturn, update } }, +})); +vi.mock("@/lib/arrivals/workout-emit", () => ({ + emitInsertedWorkoutArrival, +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }), +})); +vi.mock("../credentials", () => ({ getPolarConnection })); +vi.mock("@/lib/integrations/status", async (importOriginal) => ({ + ...(await importOriginal()), + recordSyncFailure, +})); +vi.mock("../client", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchExercises }; +}); + +import { syncUserPolarWorkouts, upsertPolarWorkouts } from "../sync-workouts"; +import { PolarApiError } from "../response-classifier"; + +const CONN = { accessToken: "tok", polarUserId: "42" }; + +const startedAt = new Date("2026-07-19T08:00:00.000Z"); +const row: PolarWorkoutRow = { + externalId: "polar-1", + sportType: "running", + startedAt, + endedAt: new Date("2026-07-19T09:00:00.000Z"), + durationSec: 3600, + totalEnergyKcal: 500, + totalDistanceM: 10_000, + avgHeartRate: 145, + maxHeartRate: 170, + elevationM: null, + metadata: {}, +}; + +beforeEach(() => { + vi.clearAllMocks(); + update.mockResolvedValue({ id: "existing" }); + getPolarConnection.mockResolvedValue(CONN); + fetchExercises.mockResolvedValue([]); +}); + +describe("upsertPolarWorkouts — exact inserted identity", () => { + it("emits the exact row returned by the insert statement, tagged 'polar'", async () => { + const inserted = { id: "new-workout", startedAt }; + createManyAndReturn.mockResolvedValue([inserted]); + + await expect(upsertPolarWorkouts("user-1", [row])).resolves.toBe(1); + + expect(emitInsertedWorkoutArrival).toHaveBeenCalledWith( + "user-1", + inserted, + "polar", + ); + expect(update).not.toHaveBeenCalled(); + }); + + it("updates a duplicate (userId, POLAR, externalId) in place without emitting", async () => { + createManyAndReturn.mockResolvedValue([]); + + await expect(upsertPolarWorkouts("user-1", [row])).resolves.toBe(1); + + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId_source_externalId: { + userId: "user-1", + source: "POLAR", + externalId: "polar-1", + }, + }, + data: expect.objectContaining({ sportType: "running", startedAt }), + }), + ); + expect(emitInsertedWorkoutArrival).not.toHaveBeenCalled(); + }); + + it("logs a failing row, continues the batch, and rethrows the FIRST error", async () => { + const second = { ...row, externalId: "polar-2" }; + createManyAndReturn + .mockRejectedValueOnce(new Error("boom-1")) + .mockResolvedValueOnce([{ id: "new-2", startedAt }]); + + await expect(upsertPolarWorkouts("user-1", [row, second])).rejects.toThrow( + "boom-1", + ); + // The second row was still attempted after the first failed. + expect(createManyAndReturn).toHaveBeenCalledTimes(2); + }); + + it("no-ops on an empty batch", async () => { + await expect(upsertPolarWorkouts("user-1", [])).resolves.toBe(0); + expect(createManyAndReturn).not.toHaveBeenCalled(); + }); +}); + +describe("syncUserPolarWorkouts", () => { + it("no-ops cleanly for an unconnected user", async () => { + getPolarConnection.mockResolvedValue(null); + await expect(syncUserPolarWorkouts("u1")).resolves.toBe(0); + expect(fetchExercises).not.toHaveBeenCalled(); + expect(createManyAndReturn).not.toHaveBeenCalled(); + }); + + it("fetches, maps, and upserts exercises as source POLAR", async () => { + fetchExercises.mockResolvedValue([ + { + id: 987, + start_time: "2026-07-19T10:00:00", + start_time_utc_offset: 0, + duration: "PT1H", + sport: "RUNNING", + detailed_sport_info: "ROAD_RUNNING", + calories: 500, + distance: 10000, + heart_rate: { average: 145, maximum: 170 }, + }, + ]); + createManyAndReturn.mockResolvedValue([{ id: "w1", startedAt }]); + + await expect(syncUserPolarWorkouts("u1")).resolves.toBe(1); + const arg = createManyAndReturn.mock.calls[0]![0]; + expect(arg.data.source).toBe("POLAR"); + expect(arg.data.externalId).toBe("987"); + expect(arg.data.sportType).toBe("running"); + }); + + it("skips an unmappable exercise without throwing", async () => { + fetchExercises.mockResolvedValue([{ id: null }, { id: "x" }]); + createManyAndReturn.mockResolvedValue([{ id: "w", startedAt }]); + // Only the valid one is upserted; the id-less one is skipped. + // The valid row has no start_time → also skipped, so nothing is written. + await expect(syncUserPolarWorkouts("u1")).resolves.toBe(0); + expect(createManyAndReturn).not.toHaveBeenCalled(); + }); + + it("records a failure on a fetch error and rethrows, never masking with success", async () => { + fetchExercises.mockRejectedValue( + new PolarApiError({ + verb: "fetchExercises", + classification: "transient", + httpStatus: 503, + reason: "http_503", + }), + ); + await expect(syncUserPolarWorkouts("u1")).rejects.toBeInstanceOf( + PolarApiError, + ); + expect(recordSyncFailure).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "u1", + integration: "polar", + kind: "transient", + errorCode: "503", + }), + ); + }); + + // Assumption #4: a 403 scope gap is a soft/recoverable ledger error, not a + // hard crash — recorded as reauth_required and rethrown for cohort isolation. + it("records a 403 scope error as a soft reauth_required failure", async () => { + fetchExercises.mockRejectedValue( + new PolarApiError({ + verb: "fetchExercises", + classification: "reauth_required", + httpStatus: 403, + reason: "http_403", + }), + ); + await expect(syncUserPolarWorkouts("u1")).rejects.toBeInstanceOf( + PolarApiError, + ); + expect(recordSyncFailure).toHaveBeenCalledWith( + expect.objectContaining({ + integration: "polar", + kind: "reauth_required", + errorCode: "403", + }), + ); + }); + + it("never records success on the shared ledger (vitals leg owns the single success write)", async () => { + const status = await import("@/lib/integrations/status"); + const spy = vi.spyOn(status, "recordSyncSuccess"); + fetchExercises.mockResolvedValue([]); + await syncUserPolarWorkouts("u1"); + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 5679d7a8c..de3a4dc94 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -24,7 +24,10 @@ import { getEvent } from "@/lib/logging/context"; import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { safeFetch } from "@/lib/safe-fetch"; import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; +import type { Prisma } from "@/generated/prisma/client"; +import type { WorkoutSportType } from "@/lib/validations/workout"; import { PolarApiError, classifyPolarResponse } from "./response-classifier"; +import { mapPolarSportType } from "./sport-map"; const POLAR_API_BASE = "https://www.polaraccesslink.com"; const POLAR_OAUTH_AUTH_URL = "https://flow.polar.com/oauth2/authorization"; @@ -651,3 +654,272 @@ export function mapSpo2(r: PolarSpo2): MappedMeasurement[] { }, ]; } + +// ─── Exercises (workouts) ────────────────────────────────────── + +/** + * One Polar AccessLink exercise (`GET /v3/exercises`) — the stateless list of + * the last 30 days of uploads. The transaction model is DEPRECATED; this GET + * carries NO userId in the path (the user is identified by the Bearer token), + * exactly the shape the v1.31.2 vitals-endpoint fix established. + * + * Field types are conservative on purpose — the live payload's exact per-field + * nullability and the `id` type are not machine-verified, so `id` accepts + * string OR number (coerced to a stable string externalId) and every optional + * numeric is guarded before use. + */ +export interface PolarExercise { + /** Opaque exercise id. Polar's docs leave the type ambiguous; accept either + * and coerce to a stable string externalId (never parsed). */ + id?: string | number | null; + /** ISO-8601 local start time, e.g. `"2026-06-10T07:00:00"`. May or may not + * carry a zone suffix — handled defensively at map time. */ + start_time?: string | null; + /** Minutes east of UTC for `start_time`. Combined with `start_time` to + * recover the true UTC instant. */ + start_time_utc_offset?: number | null; + /** ISO-8601 duration, e.g. `"PT2H44M30S"`. */ + duration?: string | null; + /** Coarse sport enum, e.g. `"RUNNING"`. */ + sport?: string | null; + /** Granular sport enum, e.g. `"TRAIL_RUNNING"`. */ + detailed_sport_info?: string | null; + calories?: number | null; + /** Distance in METRES. */ + distance?: number | null; + heart_rate?: { + average?: number | null; + maximum?: number | null; + } | null; + training_load?: number | null; + device?: string | null; + device_id?: string | null; +} + +/** + * Candidate envelope keys a paged `/v3/exercises` response might nest the list + * under. Polar's pagination shape on this endpoint is not verified; the fetcher + * tolerates a bare array OR any of these envelope shapes so a paged response + * degrades to "the rows on this page" rather than an empty result. The 30-day + * window is small enough that a single page covers a normal user; if Polar + * paginates, the hourly poll re-reads the whole window every tick so nothing is + * lost between pages. + */ +const EXERCISE_LIST_KEYS = ["exercises", "data", "items", "records"] as const; + +/** + * Fetch the user's exercises (`GET /v3/exercises`). Summary only — the + * `samples` / `zones` / `route` inline params are deliberately omitted (route / + * per-sample import is out of scope). Returns `[]` on 204 No Content, a bare + * array, or a paged envelope whose list key is unrecognised. Throws a + * classified `PolarApiError` on a non-2xx — including a 403, which the workout + * sync classifies as a soft/recoverable integration error so a scope gap on + * exercises never hard-fails the whole Polar sync. + */ +export async function fetchExercises( + accessToken: string, +): Promise { + const start = performance.now(); + const res = await safeFetch(`${POLAR_API_BASE}/v3/exercises`, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }); + const verdict = classifyPolarResponse(res.status); + getEvent()?.addExternalCall({ + service: "polar", + method: "fetchExercises", + duration_ms: Math.round(performance.now() - start), + status: res.status, + error: verdict.classification === "success" ? undefined : verdict.reason, + }); + if (verdict.classification !== "success") { + throw new PolarApiError({ + verb: "fetchExercises", + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + }); + } + // 204 No Content — a window with no uploads. Polar returns an empty body. + if (res.status === 204) return []; + const json = (await res.json().catch(() => null)) as unknown; + return normaliseExerciseList(json); +} + +/** Coerce a `/v3/exercises` body into a `PolarExercise[]`: a bare array, a + * paged envelope (list under one of `EXERCISE_LIST_KEYS`), or `[]` otherwise. */ +export function normaliseExerciseList(json: unknown): PolarExercise[] { + if (!json) return []; + if (Array.isArray(json)) return json as PolarExercise[]; + if (typeof json === "object") { + const obj = json as Record; + for (const key of EXERCISE_LIST_KEYS) { + const list = obj[key]; + if (Array.isArray(list)) return list as PolarExercise[]; + } + } + return []; +} + +// ─── Exercise → Workout mapping ──────────────────────────────── + +/** The `Workout`-row payload a mapped Polar exercise produces. Field names + * mirror `prisma.workout` columns; the sync layer stamps `source = POLAR` + + * `externalId` and upserts on `(userId, source, externalId)`. Mirrors + * `StravaWorkoutRow`. */ +export interface PolarWorkoutRow { + externalId: string; + sportType: WorkoutSportType; + startedAt: Date; + endedAt: Date; + durationSec: number; + totalEnergyKcal: number | null; + totalDistanceM: number | null; + avgHeartRate: number | null; + maxHeartRate: number | null; + elevationM: number | null; + metadata: Prisma.InputJsonValue; +} + +function nonNegInt(n: number | null | undefined): number | null { + if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; + return Math.round(n); +} + +function nonNegFloat(n: number | null | undefined): number | null { + if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; + return n; +} + +/** True when an ISO-8601 datetime string already carries a zone designator + * (`Z` or `±HH:MM` / `±HHMM`). Drives the two branches of start-time + * composition. */ +function hasZoneDesignator(iso: string): boolean { + return /(?:Z|[+-]\d{2}:?\d{2})$/.test(iso.trim()); +} + +/** + * Combine Polar's `start_time` + `start_time_utc_offset` into the true UTC + * instant. + * + * Two cases, because whether `start_time` carries a zone suffix is not verified + * against the live payload: + * - It already has a zone (`…Z` / `…+02:00`): `Date.parse` yields the correct + * instant directly; the offset is NOT applied again. + * - No zone (a naive local wall-clock, the documented shape): interpret the + * wall-clock AS UTC (append `Z`), then subtract the offset minutes (east of + * UTC is positive) to recover the true instant. A missing / non-finite + * offset falls back to 0 (treat the wall-clock as UTC). + * + * Returns null for a missing / unparseable start (the row is skipped, mirroring + * Strava's NaN-start guard). + */ +export function combinePolarStart( + startTime: string | null | undefined, + offsetMinutes: number | null | undefined, +): Date | null { + if (typeof startTime !== "string" || startTime.trim() === "") return null; + const trimmed = startTime.trim(); + if (hasZoneDesignator(trimmed)) { + const ms = Date.parse(trimmed); + return Number.isNaN(ms) ? null : new Date(ms); + } + const asUtcMs = Date.parse(`${trimmed}Z`); + if (Number.isNaN(asUtcMs)) return null; + const offset = + typeof offsetMinutes === "number" && Number.isFinite(offsetMinutes) + ? offsetMinutes + : 0; + return new Date(asUtcMs - offset * 60_000); +} + +const ISO_DURATION_RE = + /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i; + +/** + * Parse an ISO-8601 duration (`PT2H44M30S`, `PT45S`, `P1DT2H`, fractional + * seconds allowed) to whole seconds. Malformed / empty input → 0, mirroring + * Strava's `durationSec ?? 0` fallback so a bad duration never produces a + * negative window. Hand-rolled — no new dependency. + */ +export function parseIsoDuration(raw: string | null | undefined): number { + if (typeof raw !== "string") return 0; + const m = ISO_DURATION_RE.exec(raw.trim()); + if (!m) return 0; + const [, days, hours, minutes, seconds] = m; + const total = + (days ? parseFloat(days) * 86_400 : 0) + + (hours ? parseFloat(hours) * 3_600 : 0) + + (minutes ? parseFloat(minutes) * 60 : 0) + + (seconds ? parseFloat(seconds) : 0); + if (!Number.isFinite(total) || total < 0) return 0; + return Math.round(total); +} + +/** + * Map one Polar exercise into a `Workout` row. `detailed_sport_info` is + * preferred over the coarse `sport`, then mapped through `mapPolarSportType()` + * to a canonical `WorkoutSportType` — the raw Polar labels are never written to + * `sportType` directly (kept in `metadata` for provenance). `startedAt` is the + * UTC instant recovered from `start_time` + `start_time_utc_offset`; `endedAt` + * is `startedAt + durationSec` (Polar exposes no explicit end). `training_load` + * rides `metadata` tied to this row (the WHOOP per-workout-strain precedent), + * never a free-floating Measurement that would survive read-time dedup. + * + * Returns null for an exercise with no id or no parseable start instant + * (nothing to store) — the sync layer skips it, never throws per-row. + */ +export function mapExercise(ex: PolarExercise): PolarWorkoutRow | null { + if (ex.id === null || ex.id === undefined) return null; + const externalId = String(ex.id); + if (externalId.trim() === "") return null; + + const startedAt = combinePolarStart(ex.start_time, ex.start_time_utc_offset); + if (!startedAt) return null; + + const durationSec = parseIsoDuration(ex.duration); + const endedAt = new Date(startedAt.getTime() + durationSec * 1000); + + const sportType = mapPolarSportType(ex.detailed_sport_info, ex.sport); + + const avgHeartRate = nonNegInt(ex.heart_rate?.average); + const maxHeartRate = nonNegInt(ex.heart_rate?.maximum); + + const metadata: Prisma.InputJsonValue = { + // Raw pre-mapping labels, kept for provenance/debugging — never used as the + // canonical `sportType` (see `mapPolarSportType()`). + ...(ex.sport ? { polarSport: ex.sport } : {}), + ...(ex.detailed_sport_info + ? { polarDetailedSportInfo: ex.detailed_sport_info } + : {}), + ...(ex.device ? { polarDevice: ex.device } : {}), + ...(ex.device_id ? { polarDeviceId: ex.device_id } : {}), + ...(typeof ex.training_load === "number" && + Number.isFinite(ex.training_load) + ? { trainingLoad: ex.training_load } + : {}), + ...(typeof ex.start_time_utc_offset === "number" && + Number.isFinite(ex.start_time_utc_offset) + ? { startTimeUtcOffset: ex.start_time_utc_offset } + : {}), + }; + + return { + externalId, + sportType, + startedAt, + endedAt, + durationSec, + totalEnergyKcal: nonNegFloat(ex.calories), + totalDistanceM: nonNegFloat(ex.distance), + avgHeartRate, + maxHeartRate, + // Not on the exercise summary — deriving it would require a GPX fetch + // (out of scope). Left null. + elevationM: null, + metadata, + }; +} diff --git a/src/lib/polar/sport-map.ts b/src/lib/polar/sport-map.ts new file mode 100644 index 000000000..aee8b68ee --- /dev/null +++ b/src/lib/polar/sport-map.ts @@ -0,0 +1,212 @@ +/** + * Polar exercise sport (`sport` coarse enum, `detailed_sport_info` granular + * enum) → HealthLog canonical `WorkoutSportType`. + * + * Polar is a WORKOUT source that ships two sport fields on every exercise: a + * coarse `sport` (e.g. `RUNNING`, `CYCLING`) and a granular + * `detailed_sport_info` (e.g. `TRAIL_RUNNING`, `INDOOR_CYCLING`). The mapper + * prefers the granular value, falls back to the coarse one, then to `"other"`, + * so a row always lands in a canonical bucket the read-time picker can cluster + * — the raw labels are kept in `Workout.metadata` for provenance, never written + * to `sportType` directly. This mirrors the Strava / WHOOP / Fitbit / Google + * Health mappers; shipping a workout source WITHOUT this normalisation is the + * exact Strava launch bug (`src/lib/strava/sport-map.ts:1-58`) where generic + * rows collapsed same-source back-to-back sessions. + * + * `POLAR_SPORT_TABLE` is the single source of truth — `POLAR_SPORT_MAP` derives + * from it. Keep this list — and only this list — in sync when Polar adds a + * sport. Both the coarse and granular Polar enum values are listed under the + * same normalised key space, so either field resolves through one lookup. + * + * Bucket choices for sports with no clean 1:1 canonical match follow the SAME + * judgment calls the Strava table documents, so a Polar row and a Strava row of + * the same real-world sport land in the same bucket: + * - paddle sports (canoe, kayak, stand-up paddling) → `"rowing"`. + * - racket / paddle-court sports (badminton, padel, squash, table tennis, + * tennis, pickleball, racquetball) → `"tennis"`. + * - functional / mixed-modal + climbing (functional training, crossfit, + * climbing) → `"crossTraining"`. + * - recovery / mobility work (pilates, stretching, mobility, physiotherapy) + * → `"mindAndBody"`. + * - e-bike / mountain-bike / indoor cycling with no dedicated bucket → + * `"cycling"`. + * - snow-sliding sports (alpine / cross-country / backcountry ski, snowboard, + * roller ski, skating) → `"other"`, same as Strava/WHOOP. + * - team / niche sports with no matching bucket (ice hockey, floorball, + * handball, volleyball, martial arts, boxing, water sports, surfing, + * sailing, skateboarding, triathlon, group exercise, generic + * indoor/outdoor) → `"other"` — the row still persists, just outside a + * canonical sport bucket, matching an unmapped Strava/Fitbit activity. + * + * The Polar sport enum is broad and not published as a single machine-readable + * list, so this table covers the documented profiles; any value it hasn't + * caught up with lands on `"other"` (the row persists), which is the defensive + * default the design mandates. + */ +import type { WorkoutSportType } from "@/lib/validations/workout"; + +interface PolarSportTableRow { + /** A Polar `sport` or `detailed_sport_info` enum value (UPPER_SNAKE_CASE). */ + name: string; + canonical: WorkoutSportType; +} + +/** + * Polar sport value → canonical bucket. Grouped by canonical bucket for + * readability; both the coarse `sport` values and the common + * `detailed_sport_info` values share this one key space (the normaliser folds + * case + strips separators so `"TRAIL_RUNNING"` and `"trailrunning"` collide). + */ +export const POLAR_SPORT_TABLE: readonly PolarSportTableRow[] = [ + // ── running ── + { name: "RUNNING", canonical: "running" }, + { name: "JOGGING", canonical: "running" }, + { name: "ROAD_RUNNING", canonical: "running" }, + { name: "TRACK_AND_FIELD_RUNNING", canonical: "running" }, + { name: "TRAIL_RUNNING", canonical: "running" }, + { name: "TREADMILL_RUNNING", canonical: "running" }, + { name: "ULTRA_RUNNING", canonical: "running" }, + // ── cycling ── + { name: "CYCLING", canonical: "cycling" }, + { name: "ROAD_BIKING", canonical: "cycling" }, + { name: "INDOOR_CYCLING", canonical: "cycling" }, + { name: "MOUNTAIN_BIKING", canonical: "cycling" }, + { name: "GRAVEL_CYCLING", canonical: "cycling" }, + { name: "E_BIKE", canonical: "cycling" }, + { name: "HANDCYCLING", canonical: "cycling" }, + // ── walking ── + { name: "WALKING", canonical: "walking" }, + { name: "NORDIC_WALKING", canonical: "walking" }, + // ── hiking ── + { name: "HIKING", canonical: "hiking" }, + { name: "TREKKING", canonical: "hiking" }, + { name: "MOUNTAIN_HIKING", canonical: "hiking" }, + { name: "SNOWSHOE_TREKKING", canonical: "hiking" }, + // ── swimming ── + { name: "SWIMMING", canonical: "swimming" }, + { name: "POOL_SWIMMING", canonical: "swimming" }, + { name: "OPEN_WATER_SWIMMING", canonical: "swimming" }, + // ── rowing / paddle ── + { name: "ROWING", canonical: "rowing" }, + { name: "INDOOR_ROWING", canonical: "rowing" }, + { name: "KAYAKING", canonical: "rowing" }, + { name: "CANOEING", canonical: "rowing" }, + { name: "STAND_UP_PADDLING", canonical: "rowing" }, + { name: "PADDLING", canonical: "rowing" }, + // ── elliptical / stair ── + { name: "ELLIPTICAL", canonical: "elliptical" }, + { name: "CROSS_TRAINER", canonical: "elliptical" }, + { name: "STAIR_CLIMBING", canonical: "stairClimber" }, + { name: "STEP_MACHINE", canonical: "stairClimber" }, + // ── strength ── + { name: "STRENGTH_TRAINING", canonical: "strength" }, + { name: "CORE", canonical: "strength" }, + // ── HIIT ── + { name: "HIIT", canonical: "hiit" }, + { name: "HIGH_INTENSITY_INTERVAL_TRAINING", canonical: "hiit" }, + { name: "CIRCUIT_TRAINING", canonical: "hiit" }, + // ── functional / cross-training + climbing ── + { name: "FUNCTIONAL_TRAINING", canonical: "crossTraining" }, + { name: "CROSSFIT", canonical: "crossTraining" }, + { name: "CLIMBING", canonical: "crossTraining" }, + { name: "INDOOR_CLIMBING", canonical: "crossTraining" }, + { name: "ROCK_CLIMBING", canonical: "crossTraining" }, + // ── yoga / mind & body ── + { name: "YOGA", canonical: "yoga" }, + { name: "PILATES", canonical: "mindAndBody" }, + { name: "STRETCHING", canonical: "mindAndBody" }, + { name: "MOBILITY_STATIC", canonical: "mindAndBody" }, + { name: "MOBILITY_DYNAMIC", canonical: "mindAndBody" }, + { name: "PHYSICAL_THERAPY", canonical: "mindAndBody" }, + // ── dance ── + { name: "DANCE", canonical: "dance" }, + { name: "DANCING", canonical: "dance" }, + { name: "ZUMBA", canonical: "dance" }, + { name: "AEROBICS", canonical: "dance" }, + // ── golf ── + { name: "GOLF", canonical: "golf" }, + // ── racket / paddle-court ── + { name: "TENNIS", canonical: "tennis" }, + { name: "TABLE_TENNIS", canonical: "tennis" }, + { name: "BADMINTON", canonical: "tennis" }, + { name: "SQUASH", canonical: "tennis" }, + { name: "PADEL", canonical: "tennis" }, + { name: "PICKLEBALL", canonical: "tennis" }, + { name: "RACQUETBALL", canonical: "tennis" }, + // ── basketball / soccer ── + { name: "BASKETBALL", canonical: "basketball" }, + { name: "SOCCER", canonical: "soccer" }, + { name: "FOOTBALL", canonical: "soccer" }, + // ── other: snow-sliding ── + { name: "ALPINE_SKIING", canonical: "other" }, + { name: "CROSS_COUNTRY_SKIING_CLASSIC", canonical: "other" }, + { name: "CROSS_COUNTRY_SKIING_FREESTYLE", canonical: "other" }, + { name: "BACKCOUNTRY_SKIING", canonical: "other" }, + { name: "SNOWBOARDING", canonical: "other" }, + { name: "ROLLER_SKIING_CLASSIC", canonical: "other" }, + { name: "ROLLER_SKIING_FREESTYLE", canonical: "other" }, + { name: "SKATING", canonical: "other" }, + { name: "ICE_SKATING", canonical: "other" }, + // ── other: team / niche / catch-all ── + { name: "VOLLEYBALL", canonical: "other" }, + { name: "ICE_HOCKEY", canonical: "other" }, + { name: "FLOORBALL", canonical: "other" }, + { name: "HANDBALL", canonical: "other" }, + { name: "MARTIAL_ARTS", canonical: "other" }, + { name: "BOXING", canonical: "other" }, + { name: "KICKBOXING", canonical: "other" }, + { name: "WATER_SPORTS", canonical: "other" }, + { name: "SURFING", canonical: "other" }, + { name: "WINDSURFING", canonical: "other" }, + { name: "KITESURFING", canonical: "other" }, + { name: "SAILING", canonical: "other" }, + { name: "SKATEBOARDING", canonical: "other" }, + { name: "TRIATHLON", canonical: "other" }, + { name: "GROUP_EXERCISE", canonical: "other" }, + { name: "OTHER_INDOOR", canonical: "other" }, + { name: "OTHER_OUTDOOR", canonical: "other" }, +] as const; + +/** + * Lowercase and strip every character outside `[a-z0-9]`. Polar's sport values + * are UPPER_SNAKE_CASE with underscores (`"TRAIL_RUNNING"`), so this folds case + * and drops the separators; applied to BOTH the table names (at build time) and + * the incoming raw value (at lookup time), so a stray space / hyphen / casing + * variant still resolves to the same key. + */ +export function normalisePolarSportKey(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, ""); +} + +export const POLAR_SPORT_MAP: ReadonlyMap = new Map( + POLAR_SPORT_TABLE.map((row) => [ + normalisePolarSportKey(row.name), + row.canonical, + ]), +); + +/** + * Resolve a Polar exercise's sport to a canonical `WorkoutSportType`. Prefers + * the granular `detailed_sport_info`; when that is absent, blank, or + * unrecognised, falls back to the coarse `sport`; defaults to `"other"` for + * anything absent, blank, or unrecognised (a Polar-added sport this table + * hasn't caught up with yet). NEVER returns a raw Polar label. + */ +export function mapPolarSportType( + detailed: string | null | undefined, + coarse: string | null | undefined, +): WorkoutSportType { + const fromDetailed = lookup(detailed); + if (fromDetailed) return fromDetailed; + const fromCoarse = lookup(coarse); + if (fromCoarse) return fromCoarse; + return "other"; +} + +function lookup(raw: string | null | undefined): WorkoutSportType | undefined { + if (typeof raw !== "string" || raw.trim() === "") return undefined; + return POLAR_SPORT_MAP.get(normalisePolarSportKey(raw)); +} diff --git a/src/lib/polar/sync-workouts.ts b/src/lib/polar/sync-workouts.ts new file mode 100644 index 000000000..5893e31d8 --- /dev/null +++ b/src/lib/polar/sync-workouts.ts @@ -0,0 +1,172 @@ +/** + * v1.32.15 — Polar AccessLink exercise (workout) sync. + * + * Pulls the user's exercises from the stateless `GET /v3/exercises` list (last + * 30 days of uploads, no transaction / no commit / no userId in the path) and + * upserts each into the `Workout` table as `source = POLAR`, keyed on + * `(userId, source, externalId)` so a re-fetch / Polar-side re-analysis + * overwrites in place. Polar exercises are WORKOUT rows, not `Measurement` + * rows, so this file is deliberately SEPARATE from the vitals sync + * (`./sync.ts`) — isolation for a sensitive cohort, mirroring WHOOP's split + * `sync.ts` / `sync-workout.ts`. + * + * Cross-source dedup is NOT done here: a Polar run and the same run via Apple + * Health remain two distinct `Workout` rows (different `source`); the read-time + * `pickCanonicalWorkoutRows` picker collapses the cross-source twin via the + * user's source-priority ladder. One engine, no parallel path. + * + * Idempotency: every tick re-reads the whole 30-day window; the unique index + * makes a re-ingest a no-op-shaped overwrite. There is no commit / acknowledge + * step to fail (the deprecated transaction model had one), so ingest is the + * only write and it is idempotent — no cursor, no backfill queue (the API caps + * history at 30 days, so first-connect backfill IS the first regular tick). + * + * Ledger: workouts and vitals share the single `polar` integration ledger. This + * leg records only its OWN FAILURE (and rethrows so the cohort handler can + * attribute it); the single success write stays the vitals leg's job so a fully + * successful pass records success exactly once. A workouts-leg failure records a + * transient/reauth failure on the shared ledger but never touches the vitals + * success timestamp (`recordSyncFailure` leaves `lastSuccessAt` intact). + */ +import { prisma } from "@/lib/db"; +import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { recordSyncFailure, toFailureKind } from "@/lib/integrations/status"; +import { fetchExercises, mapExercise, type PolarWorkoutRow } from "./client"; +import { getPolarConnection } from "./credentials"; +import { PolarApiError, classifyPolarError } from "./response-classifier"; + +async function recordPolarWorkoutFailure( + userId: string, + err: unknown, +): Promise { + await recordSyncFailure({ + userId, + integration: "polar", + // Same classification the vitals leg uses (`classifyPolarFailure` in + // `./sync`); resolved directly off the shared classifier so this leg does + // not have to import the heavier vitals-sync module. + kind: toFailureKind(classifyPolarError(err)), + message: err instanceof Error ? err.message : String(err), + errorCode: + err instanceof PolarApiError && err.httpStatus != null + ? String(err.httpStatus) + : undefined, + }); +} + +/** + * Sync one user's Polar exercises into `Workout`. Returns the count of rows + * written. A user with no Polar connection is a clean no-op (returns 0, touches + * no status row). + * + * A 403 on the exercises read (a scope gap under `accesslink.read_all`) is + * classified through the shared response classifier as `reauth_required` — a + * SOFT/recoverable integration error surfaced on the status ledger, NOT a hard + * crash: it is recorded and rethrown so the composite cohort pass isolates this + * user and the vitals leg's own result is untouched. + */ +export async function syncUserPolarWorkouts(userId: string): Promise { + const conn = await getPolarConnection(userId); + if (!conn) return 0; + + let rows: PolarWorkoutRow[]; + try { + const exercises = await fetchExercises(conn.accessToken); + rows = []; + for (const ex of exercises) { + const row = mapExercise(ex); + // Skip an unmappable exercise (no id / unparseable start); never throw + // per-row — one bad record must not fail the whole leg. + if (row) rows.push(row); + } + } catch (err) { + await recordPolarWorkoutFailure(userId, err); + throw err; + } + + let imported: number; + try { + imported = await upsertPolarWorkouts(userId, rows); + } catch (err) { + await recordPolarWorkoutFailure(userId, err); + throw err; + } + + return imported; +} + +/** + * Upsert a batch of mapped Polar exercises into `Workout`, keyed on + * `(userId, source = POLAR, externalId)` so a re-fetch overwrites in place. A + * line-for-line mirror of `upsertStravaWorkouts`: `createManyAndReturn` + + * `skipDuplicates`, update-in-place on conflict, arrival emit on fresh inserts + * only. Failed rows are logged while the rest of the batch is attempted, then + * the FIRST error is rethrown so the caller cannot treat a partially persisted + * batch as a clean pass. + */ +export async function upsertPolarWorkouts( + userId: string, + rows: readonly PolarWorkoutRow[], +): Promise { + if (rows.length === 0) return 0; + + let imported = 0; + let firstError: unknown; + let hadError = false; + for (const r of rows) { + const data = { + sportType: r.sportType, + startedAt: r.startedAt, + endedAt: r.endedAt, + durationSec: r.durationSec, + totalEnergyKcal: r.totalEnergyKcal, + totalDistanceM: r.totalDistanceM, + avgHeartRate: r.avgHeartRate, + maxHeartRate: r.maxHeartRate, + elevationM: r.elevationM, + metadata: r.metadata, + }; + try { + const [inserted] = await prisma.workout.createManyAndReturn({ + data: { + userId, + source: "POLAR", + externalId: r.externalId, + ...data, + }, + skipDuplicates: true, + select: { id: true, startedAt: true }, + }); + if (inserted) { + void emitInsertedWorkoutArrival(userId, inserted, "polar").catch( + () => {}, + ); + } else { + await prisma.workout.update({ + where: { + userId_source_externalId: { + userId, + source: "POLAR", + externalId: r.externalId, + }, + }, + data, + }); + } + imported += 1; + } catch (err) { + getEvent()?.addWarning(`polar: failed to upsert workout: ${err}`); + if (!hadError) { + firstError = err; + hadError = true; + } + } + } + if (hadError) throw firstError; + + annotate({ + action: { name: "polar.workout.ingest", details: { imported } }, + }); + return imported; +} diff --git a/src/lib/sources/pick-canonical-workout.ts b/src/lib/sources/pick-canonical-workout.ts index f7b13f399..9f1da7274 100644 --- a/src/lib/sources/pick-canonical-workout.ts +++ b/src/lib/sources/pick-canonical-workout.ts @@ -263,6 +263,12 @@ export const DEFAULT_WORKOUT_SOURCE_PRIORITY: readonly MeasurementSource[] = [ // session. "FITBIT", "WITHINGS", + // v1.32.15 — Polar ranks below Withings and above Strava: a Polar watch is a + // device-native HR capture (unlike a Strava re-upload with degraded/absent + // HR), so a Polar exercise outranks a Strava twin of the same session but + // sits below the primary on-wrist wearables. Matches Polar's slot in the + // read-time `steps` ladder (`DEFAULT_SOURCE_PRIORITY.steps`). + "POLAR", // v1.28.x — Strava ranks below the device-native captures (Apple/WHOOP/ // Fitbit/Withings on-wrist HR) but above MANUAL/IMPORT: a Strava activity is // often itself a re-upload of an Apple/Garmin recording, so its HR/calories