mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #612 from MBombeck/release/v1.32.15
release/v1.32.15 — sync Polar training sessions into workouts
This commit is contained in:
21 files changed
+1517
-13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<number> {
|
||||
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,
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("@/lib/integrations/status")>()),
|
||||
recordSyncFailure,
|
||||
}));
|
||||
vi.mock("../client", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../client")>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<PolarExercise[]> {
|
||||
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<string, unknown>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, WorkoutSportType> = 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));
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user