From 8bb0719c3aae10daabe47767677d4c92404a697e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 10:05:18 +0200 Subject: [PATCH 1/3] feat(polar): sync Polar exercises into Workout rows Pull the athlete's exercises from the stateless GET /v3/exercises list (last 30 days of uploads, no transaction, no userId in the path) and upsert each into the Workout table as source POLAR, keyed on (userId, source, externalId) so a re-fetch or Polar-side re-analysis overwrites in place. A new table-driven sport map normalises the raw Polar sport labels into the canonical WorkoutSportType bucket at ingest, mirroring the Strava/WHOOP mappers; the raw labels and training load ride metadata for provenance. The exercise leg rides the existing hourly polar-sync cohort tick alongside the vitals leg, with independent attribution on the shared polar ledger: each leg is attempted and records its own failure, and neither can mask the other's result. Polar takes its place in the workout source ladder between Withings and Strava. No migration and no OpenAPI change: MeasurementSource.POLAR and the full workouts source enum already exist. Refs #568 --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- package.json | 2 +- src/lib/jobs/reminder/polar-sync.ts | 46 +++- src/lib/polar/client.ts | 272 ++++++++++++++++++++++ src/lib/polar/sport-map.ts | 213 +++++++++++++++++ src/lib/polar/sync-workouts.ts | 170 ++++++++++++++ src/lib/sources/pick-canonical-workout.ts | 6 + 12 files changed, 710 insertions(+), 11 deletions(-) create mode 100644 src/lib/polar/sport-map.ts create mode 100644 src/lib/polar/sync-workouts.ts 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/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/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..6fccb39e9 --- /dev/null +++ b/src/lib/polar/sport-map.ts @@ -0,0 +1,213 @@ +/** + * 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: "EBIKE", 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..5e6b6a887 --- /dev/null +++ b/src/lib/polar/sync-workouts.ts @@ -0,0 +1,170 @@ +/** + * 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 } from "@/lib/integrations/status"; +import { fetchExercises, mapExercise, type PolarWorkoutRow } from "./client"; +import { getPolarConnection } from "./credentials"; +import { PolarApiError } from "./response-classifier"; +import { classifyPolarFailure } from "./sync"; + +async function recordPolarWorkoutFailure( + userId: string, + err: unknown, +): Promise { + await recordSyncFailure({ + userId, + integration: "polar", + kind: classifyPolarFailure(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 From aa850acab8d9f4cbc317f3f1ed253a2b7ede2d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 10:10:05 +0200 Subject: [PATCH 2/3] test(polar): cover exercise sync, mapper parity, and the shared-ledger legs Parity suite proves a Polar exercise and an equivalent Strava activity map to a downstream-indistinguishable Workout row (sport bucket, start instant, duration, distance, calories, HR, null-vs-zero). Regression guard asserts the exercises request hits GET /v3/exercises with no userId path segment. Dedup ladder tests mirror the Strava suite (overwrite-in-place, Apple+Polar twin collapses, Polar above Strava). Composite test proves a failure in one Polar leg leaves the other leg's result intact on the shared ledger. Defensive-shape tests cover the four flagged assumptions: numeric-vs-string id, UTC-offset composition (positive and negative), bare-array-vs-paged body, and a 403 scope error as a soft error. Refs #568 --- .../__tests__/polar-sync-legs.test.ts | 52 ++++ .../polar/__tests__/client-exercises.test.ts | 260 ++++++++++++++++++ src/lib/polar/__tests__/dedup.test.ts | 87 ++++++ src/lib/polar/__tests__/parity.test.ts | 102 +++++++ src/lib/polar/__tests__/sport-map.test.ts | 67 +++++ src/lib/polar/__tests__/sync-workouts.test.ts | 214 ++++++++++++++ src/lib/polar/sport-map.ts | 1 - src/lib/polar/sync-workouts.ts | 10 +- 8 files changed, 788 insertions(+), 5 deletions(-) create mode 100644 src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts create mode 100644 src/lib/polar/__tests__/client-exercises.test.ts create mode 100644 src/lib/polar/__tests__/dedup.test.ts create mode 100644 src/lib/polar/__tests__/parity.test.ts create mode 100644 src/lib/polar/__tests__/sport-map.test.ts create mode 100644 src/lib/polar/__tests__/sync-workouts.test.ts 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/polar/__tests__/client-exercises.test.ts b/src/lib/polar/__tests__/client-exercises.test.ts new file mode 100644 index 000000000..71d9ed423 --- /dev/null +++ b/src/lib/polar/__tests__/client-exercises.test.ts @@ -0,0 +1,260 @@ +import { afterEach, beforeEach, 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/sport-map.ts b/src/lib/polar/sport-map.ts index 6fccb39e9..aee8b68ee 100644 --- a/src/lib/polar/sport-map.ts +++ b/src/lib/polar/sport-map.ts @@ -73,7 +73,6 @@ export const POLAR_SPORT_TABLE: readonly PolarSportTableRow[] = [ { name: "MOUNTAIN_BIKING", canonical: "cycling" }, { name: "GRAVEL_CYCLING", canonical: "cycling" }, { name: "E_BIKE", canonical: "cycling" }, - { name: "EBIKE", canonical: "cycling" }, { name: "HANDCYCLING", canonical: "cycling" }, // ── walking ── { name: "WALKING", canonical: "walking" }, diff --git a/src/lib/polar/sync-workouts.ts b/src/lib/polar/sync-workouts.ts index 5e6b6a887..5893e31d8 100644 --- a/src/lib/polar/sync-workouts.ts +++ b/src/lib/polar/sync-workouts.ts @@ -31,11 +31,10 @@ import { prisma } from "@/lib/db"; import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit"; import { annotate, getEvent } from "@/lib/logging/context"; -import { recordSyncFailure } from "@/lib/integrations/status"; +import { recordSyncFailure, toFailureKind } from "@/lib/integrations/status"; import { fetchExercises, mapExercise, type PolarWorkoutRow } from "./client"; import { getPolarConnection } from "./credentials"; -import { PolarApiError } from "./response-classifier"; -import { classifyPolarFailure } from "./sync"; +import { PolarApiError, classifyPolarError } from "./response-classifier"; async function recordPolarWorkoutFailure( userId: string, @@ -44,7 +43,10 @@ async function recordPolarWorkoutFailure( await recordSyncFailure({ userId, integration: "polar", - kind: classifyPolarFailure(err), + // 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 From 73594d35f79fdbc101ff749ff35128a24c88431b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 10:18:13 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=20v1.32.15=20=E2=80=94=20Po?= =?UTF-8?q?lar=20training=20sessions=20sync=20into=20workouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #568 --- CHANGELOG.md | 22 +++++++++++++++++++ docs/api/openapi.yaml | 2 +- public/sw.js | 2 +- .../polar/__tests__/client-exercises.test.ts | 2 +- 4 files changed, 25 insertions(+), 3 deletions(-) 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/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/polar/__tests__/client-exercises.test.ts b/src/lib/polar/__tests__/client-exercises.test.ts index 71d9ed423..cf41fa331 100644 --- a/src/lib/polar/__tests__/client-exercises.test.ts +++ b/src/lib/polar/__tests__/client-exercises.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { combinePolarStart,