From 6ad0a8a5cf7af50e4cbdb756833ec2dc21d2bfd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 22:56:01 +0200 Subject: [PATCH] feat(fitbit): sync activity, sleep stages, and workouts from Google Health Extend the Fitbit / Google Health provider with the remaining sync resources, driven by the existing syncUserFitbit orchestrator. Activity (daily cumulative): steps -> ACTIVITY_STEPS, distance -> WALKING_RUNNING_DISTANCE, the active-energy portion -> ACTIVE_ENERGY_BURNED, floors -> FLIGHTS_CLIMBED, and VO2 max -> VO2_MAX. Each daily total mints a stats:: externalId so a re-fetched day overwrites in place rather than duplicating, matching the Apple-Health daily-total contract. The running totals preserve a legitimate zero (a rest day is real data); VO2 max stays strictly positive (daily latest-wins). Sleep: map a session's per-stage segments to per-stage SLEEP_DURATION rows (minutes; measuredAt = the stage end), harmonised onto the shared SleepStage enum (light -> CORE, deep -> DEEP, rem -> REM, awake/restless -> AWAKE, in-bed -> IN_BED, classic asleep -> ASLEEP) so the night-total and hypnogram readers consume the same shape they already read for WHOOP and Apple. Workouts: map exercise sessions to Workout rows keyed (userId, FITBIT, externalId), with sport-type, start/end, duration, energy, distance, and HR. Cross-source twins stay distinct at ingest; the read-time canonical picker collapses them via the source ladder (Fitbit already ranked below WHOOP). All three join the orchestrator's resource list and ride the per-resource 403 soft-skip so an independently-granted Restricted bundle that the user withheld skips only its own resource. Rollups recompute and status-insight caches invalidate through the shared upsert. No new MeasurementType, no migration, no new dependency. --- src/lib/fitbit/__tests__/client.test.ts | 208 +++++++ .../fitbit/__tests__/sync-user-fitbit.test.ts | 44 +- src/lib/fitbit/client.ts | 574 ++++++++++++++++++ src/lib/fitbit/mapping.md | 56 +- src/lib/fitbit/sync-activity.ts | 130 ++++ src/lib/fitbit/sync-sleep.ts | 78 +++ src/lib/fitbit/sync-workout.ts | 110 ++++ src/lib/fitbit/sync.ts | 28 +- ...fitbit-activity-sleep-workout-sync.test.ts | 272 +++++++++ 9 files changed, 1485 insertions(+), 15 deletions(-) create mode 100644 src/lib/fitbit/sync-activity.ts create mode 100644 src/lib/fitbit/sync-sleep.ts create mode 100644 src/lib/fitbit/sync-workout.ts create mode 100644 tests/integration/fitbit-activity-sleep-workout-sync.test.ts diff --git a/src/lib/fitbit/__tests__/client.test.ts b/src/lib/fitbit/__tests__/client.test.ts index 63b03b70e..7df86bb81 100644 --- a/src/lib/fitbit/__tests__/client.test.ts +++ b/src/lib/fitbit/__tests__/client.test.ts @@ -8,12 +8,21 @@ import { fetchDataPoints, fetchProfile, getAuthorizationUrl, + mapActiveCalories, mapBodyFat, + mapDistance, + mapFitbitSleepStage, + mapFitbitSportType, + mapFloors, mapHeartRateVariability, mapHeightCm, mapOxygenSaturation, mapRestingHeartRate, + mapSleepSession, + mapSteps, + mapVo2Max, mapWeight, + mapWorkout, refreshAccessToken, resolveFitbitUserId, } from "../client"; @@ -326,3 +335,202 @@ describe("FITBIT_FIELD_MAP ↔ FITBIT_DATA_TYPES casing parity", () => { } }); }); + +describe("activity mappers (cumulative daily)", () => { + const day = { year: 2026, month: 5, day: 10 }; + + it("maps steps with a stats:-shaped daily externalId and preserves a zero", () => { + const out = mapSteps({ steps: { count: 8421, date: day } }); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ + type: "ACTIVITY_STEPS", + value: 8421, + unit: "steps", + cumulativeDaily: true, + }); + // fieldTag carries :; the sync layer prefixes `stats:`. + expect(out[0]!.fieldTag).toBe("steps:2026-05-10"); + // measuredAt anchored at the civil day (noon UTC) so it day-keys cleanly. + expect(out[0]!.measuredAt.toISOString()).toBe("2026-05-10T12:00:00.000Z"); + + // A rest day records 0 steps — that is real data, not a gap. + const rest = mapSteps({ steps: { count: 0, date: day } }); + expect(rest).toHaveLength(1); + expect(rest[0]!.value).toBe(0); + }); + + it("maps distance in metres, converting km → m when reported in km", () => { + expect(mapDistance({ distance: { meters: 6200, date: day } })[0]).toMatchObject({ + type: "WALKING_RUNNING_DISTANCE", + value: 6200, + unit: "m", + }); + // km path multiplies by 1000. + expect( + mapDistance({ distance: { kilometers: 6.2, date: day } })[0]!.value, + ).toBe(6200); + }); + + it("maps the ACTIVE-calories portion into ACTIVE_ENERGY_BURNED", () => { + const out = mapActiveCalories({ + active_calories: { active_kilocalories: 540, date: day }, + }); + expect(out[0]).toMatchObject({ + type: "ACTIVE_ENERGY_BURNED", + value: 540, + unit: "kcal", + }); + }); + + it("maps floors and preserves a zero", () => { + expect(mapFloors({ floors: { count: 12, date: day } })[0]).toMatchObject({ + type: "FLIGHTS_CLIMBED", + value: 12, + unit: "flights", + }); + expect(mapFloors({ floors: { count: 0, date: day } })[0]!.value).toBe(0); + }); + + it("maps VO2 max (strictly positive, daily latest-wins) and drops a zero", () => { + const out = mapVo2Max({ + vo2_max: { milliliters_per_kilogram_per_minute: 47.3, date: day }, + }); + expect(out[0]).toMatchObject({ type: "VO2_MAX", value: 47.3 }); + // VO2 max of 0 is garbage — dropped (unlike the running totals). + expect( + mapVo2Max({ vo2_max: { milliliters_per_kilogram_per_minute: 0, date: day } }), + ).toHaveLength(0); + }); +}); + +describe("sleep-stage mapping", () => { + it("harmonises Google stage labels onto the shared SleepStage enum", () => { + expect(mapFitbitSleepStage("light")).toBe("CORE"); // Fitbit light ↔ Apple core + expect(mapFitbitSleepStage("deep")).toBe("DEEP"); + expect(mapFitbitSleepStage("rem")).toBe("REM"); + expect(mapFitbitSleepStage("awake")).toBe("AWAKE"); + expect(mapFitbitSleepStage("wake")).toBe("AWAKE"); + expect(mapFitbitSleepStage("restless")).toBe("AWAKE"); + expect(mapFitbitSleepStage("in-bed")).toBe("IN_BED"); + expect(mapFitbitSleepStage("asleep")).toBe("ASLEEP"); + // Unknown / non-string → null (skipped, never mis-bucketed). + expect(mapFitbitSleepStage("snoring")).toBeNull(); + expect(mapFitbitSleepStage(42)).toBeNull(); + }); + + it("maps a session into per-stage SLEEP_DURATION rows with measuredAt = stage END", () => { + const session = { + sleep: { + startTime: "2026-05-10T22:00:00.000Z", + endTime: "2026-05-11T06:00:00.000Z", + segments: [ + { + stage: "light", + startTime: "2026-05-10T22:00:00.000Z", + endTime: "2026-05-10T22:30:00.000Z", + }, + { + stage: "deep", + startTime: "2026-05-10T22:30:00.000Z", + endTime: "2026-05-10T23:30:00.000Z", + }, + { + stage: "light", + startTime: "2026-05-10T23:30:00.000Z", + endTime: "2026-05-11T00:15:00.000Z", + }, + { + stage: "rem", + startTime: "2026-05-11T00:15:00.000Z", + endTime: "2026-05-11T01:00:00.000Z", + }, + ], + }, + }; + const out = mapSleepSession(session); + const byStage = Object.fromEntries(out.map((m) => [m.sleepStage, m])); + + // CORE = the two light segments summed (30 + 45 = 75 min). + expect(byStage.CORE!.value).toBe(75); + expect(byStage.CORE!.type).toBe("SLEEP_DURATION"); + expect(byStage.CORE!.unit).toBe("minutes"); + // measuredAt is the LATEST end for that stage (the second light segment). + expect(byStage.CORE!.measuredAt.toISOString()).toBe( + "2026-05-11T00:15:00.000Z", + ); + expect(byStage.DEEP!.value).toBe(60); + expect(byStage.REM!.value).toBe(45); + + // externalId field-tag is session-anchored so a re-score overwrites in place. + expect(byStage.DEEP!.fieldTag).toBe( + "2026-05-11T06:00:00.000Z:sleep_deep", + ); + }); + + it("yields nothing for a session with no parseable segments", () => { + expect(mapSleepSession({ sleep: {} })).toHaveLength(0); + expect(mapSleepSession({})).toHaveLength(0); + }); +}); + +describe("workout mapping", () => { + it("maps an exercise session into a Workout shape with a stable externalId", () => { + const w = mapWorkout({ + exercise: { + session_id: "ex-123", + activity_type: "run", + startTime: "2026-05-10T07:00:00.000Z", + endTime: "2026-05-10T07:45:00.000Z", + active_kilocalories: 410, + distance: { meters: 7800 }, + average_heart_rate: { beats_per_minute: 148 }, + maximum_heart_rate: { beats_per_minute: 172 }, + }, + }); + expect(w).not.toBeNull(); + expect(w).toMatchObject({ + externalId: "ex-123", + sportType: "running", + durationSec: 45 * 60, + totalEnergyKcal: 410, + totalDistanceM: 7800, + avgHeartRate: 148, + maxHeartRate: 172, + }); + expect(w!.startedAt.toISOString()).toBe("2026-05-10T07:00:00.000Z"); + }); + + it("falls back to a start-anchored externalId + 'other' sport when fields are absent", () => { + const w = mapWorkout({ + exercise: { + startTime: "2026-05-10T07:00:00.000Z", + endTime: "2026-05-10T07:30:00.000Z", + }, + }); + expect(w!.externalId).toBe("exercise:2026-05-10T07:00:00.000Z"); + expect(w!.sportType).toBe("other"); + expect(w!.totalEnergyKcal).toBeNull(); + expect(w!.avgHeartRate).toBeNull(); + }); + + it("returns null for a session with no usable time span", () => { + expect(mapWorkout({ exercise: { startTime: "2026-05-10T07:00:00.000Z" } })).toBeNull(); + expect( + mapWorkout({ + exercise: { + startTime: "2026-05-10T07:30:00.000Z", + endTime: "2026-05-10T07:00:00.000Z", // end before start + }, + }), + ).toBeNull(); + expect(mapWorkout({})).toBeNull(); + }); + + it("resolves Google activity types to canonical sport labels", () => { + expect(mapFitbitSportType("Walk")).toBe("walking"); + expect(mapFitbitSportType("biking")).toBe("cycling"); + expect(mapFitbitSportType("weights")).toBe("strength"); + expect(mapFitbitSportType("unknown-sport")).toBe("other"); + expect(mapFitbitSportType("")).toBe("other"); + }); +}); diff --git a/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts b/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts index ab2e10fca..accde7399 100644 --- a/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts +++ b/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts @@ -16,6 +16,9 @@ const { recordSyncFailure, isReauthRequired, syncUserMetrics, + syncUserActivity, + syncUserSleep, + syncUserWorkout, } = vi.hoisted(() => ({ recordSyncSuccess: vi.fn<(...a: unknown[]) => Promise>(async () => {}), recordSyncFailure: vi.fn<(...a: unknown[]) => Promise>(async () => {}), @@ -23,6 +26,9 @@ const { async () => false, ), syncUserMetrics: vi.fn<(...a: unknown[]) => Promise>(), + syncUserActivity: vi.fn<(...a: unknown[]) => Promise>(), + syncUserSleep: vi.fn<(...a: unknown[]) => Promise>(), + syncUserWorkout: vi.fn<(...a: unknown[]) => Promise>(), })); vi.mock("@/lib/db", () => ({ prisma: {} })); @@ -50,6 +56,15 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({ vi.mock("../sync-metrics", () => ({ syncUserMetrics: (...a: unknown[]) => syncUserMetrics(...a), })); +vi.mock("../sync-activity", () => ({ + syncUserActivity: (...a: unknown[]) => syncUserActivity(...a), +})); +vi.mock("../sync-sleep", () => ({ + syncUserSleep: (...a: unknown[]) => syncUserSleep(...a), +})); +vi.mock("../sync-workout", () => ({ + syncUserWorkout: (...a: unknown[]) => syncUserWorkout(...a), +})); import { handleCollectionFetchError, syncUserFitbit } from "../sync"; import { FitbitApiError } from "../response-classifier"; @@ -71,11 +86,22 @@ async function softSkip403(...args: unknown[]): Promise { beforeEach(() => { vi.clearAllMocks(); isReauthRequired.mockResolvedValue(false); + // The W5 resources default to a quiet success (nothing new) so a test that + // exercises only the metrics resource doesn't trip the all-soft-skipped guard. + syncUserActivity.mockResolvedValue(0); + syncUserSleep.mockResolvedValue(0); + syncUserWorkout.mockResolvedValue(0); }); describe("syncUserFitbit — all-403 looks-healthy guard", () => { - it("does NOT stamp success when the resource soft-skipped and nothing imported", async () => { + it("does NOT stamp success when EVERY resource soft-skipped and nothing imported", async () => { + // A genuine grant-revoke 403s every Restricted bundle — each resource + // soft-skips. Only when ALL of them soft-skip and nothing imported does the + // guard refuse to stamp success. syncUserMetrics.mockImplementation(softSkip403); + syncUserActivity.mockImplementation(softSkip403); + syncUserSleep.mockImplementation(softSkip403); + syncUserWorkout.mockImplementation(softSkip403); const total = await syncUserFitbit("user1"); @@ -86,8 +112,22 @@ describe("syncUserFitbit — all-403 looks-healthy guard", () => { expect(recordSyncFailure).not.toHaveBeenCalled(); }); - it("stamps success on a cycle that imported rows", async () => { + it("stamps success when at least one resource synced even though others soft-skip", async () => { + // A partial grant: metrics imports, the rest 403 (bundles not granted). syncUserMetrics.mockResolvedValue(5); + syncUserActivity.mockImplementation(softSkip403); + syncUserSleep.mockImplementation(softSkip403); + syncUserWorkout.mockImplementation(softSkip403); + + const total = await syncUserFitbit("user1"); + + expect(total).toBe(5); + expect(recordSyncSuccess).toHaveBeenCalledWith("user1", "fitbit"); + }); + + it("stamps success on a cycle that imported rows", async () => { + syncUserMetrics.mockResolvedValue(3); + syncUserActivity.mockResolvedValue(2); const total = await syncUserFitbit("user1"); diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index 8f9f47b3d..5fb07e5ab 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -315,6 +315,28 @@ export const FITBIT_DATA_TYPES = { filter: "daily_sleep_temperature_derivations", timeField: "date", }, + // ── Activity bundle (W5) — daily cumulative totals ───────────── + // Scope: `googlehealth.activity_and_fitness.readonly`. Each is a per-day + // summary (`timeField: "date"`); the externalId carries the `stats:` prefix + // so a re-fetched day overwrites in place (mirrors the Apple-Health + // `stats::` daily-total overwrite contract). + steps: { path: "steps", filter: "steps", timeField: "date" }, + distance: { path: "distance", filter: "distance", timeField: "date" }, + activeCalories: { + path: "active-calories", + filter: "active_calories", + timeField: "date", + }, + floors: { path: "floors", filter: "floors", timeField: "date" }, + vo2Max: { path: "vo2-max", filter: "vo2_max", timeField: "date" }, + // ── Sleep bundle (W5) ────────────────────────────────────────── + // Scope: `googlehealth.sleep.readonly`. A sleep session carries a start + + // end + a per-stage breakdown; mapped to per-stage SLEEP_DURATION rows. + sleep: { path: "sleep", filter: "sleep", timeField: "sample" }, + // ── Exercise bundle (W5) ─────────────────────────────────────── + // Scope: `googlehealth.activity_and_fitness.readonly`. An exercise session + // → a `Workout` row (NOT a Measurement). + exercise: { path: "exercise", filter: "exercise", timeField: "sample" }, } as const satisfies Record; export type FitbitDataTypeKey = keyof typeof FITBIT_DATA_TYPES; @@ -439,8 +461,26 @@ export interface FitbitMappedMeasurement { measuredAt: Date; /** Disambiguator appended to the per-point anchor to form the externalId. */ fieldTag: string; + /** Per-stage sleep rows carry the SleepStage; everything else omits it. */ + sleepStage?: FitbitSleepStage; + /** + * `true` when the externalId carries the `stats:` daily-total prefix (the + * cumulative activity metrics). The sync layer stamps the externalId; this + * flag lets it pick the right shape (`stats::` vs the + * `:` spot shape) without re-deriving the grain. + */ + cumulativeDaily?: boolean; } +/** HealthLog `SleepStage` values a Fitbit sleep stage maps onto. */ +export type FitbitSleepStage = + | "IN_BED" + | "AWAKE" + | "ASLEEP" + | "REM" + | "CORE" + | "DEEP"; + /** * Pull the first finite number out of a list of candidate value paths on a * `DataPoint`. The Google value-field JSON is undocumented, so each mapper hands @@ -748,4 +788,538 @@ export const FITBIT_FIELD_MAP: Record< filter: "daily_sleep_temperature_derivations", note: "overnight wrist-temp; confirm absolute-vs-baseline at build", }, + // ── Activity bundle (W5) — daily cumulative ───────────────────── + steps: { + type: "ACTIVITY_STEPS", + unit: "steps", + path: "steps", + filter: "steps", + note: "daily total; stats: externalId overwrites on re-fetch; 0 is a valid rest day", + }, + distance: { + type: "WALKING_RUNNING_DISTANCE", + unit: "m", + path: "distance", + filter: "distance", + note: "daily total metres; stats: externalId overwrites on re-fetch", + }, + activeCalories: { + type: "ACTIVE_ENERGY_BURNED", + unit: "kcal", + path: "active-calories", + filter: "active_calories", + note: "ACTIVE portion only (NOT total caloriesOut); stats: externalId overwrites on re-fetch", + }, + floors: { + type: "FLIGHTS_CLIMBED", + unit: "flights", + path: "floors", + filter: "floors", + note: "daily total floors; stats: externalId overwrites on re-fetch", + }, + vo2Max: { + type: "VO2_MAX", + unit: "mL/(kg·min)", + path: "vo2-max", + filter: "vo2_max", + note: "daily latest-wins; daily-anchor externalId overwrites on re-fetch", + }, + sleep: { + type: "SLEEP_DURATION", + unit: "minutes", + path: "sleep", + filter: "sleep", + note: "per-stage rows (IN_BED/AWAKE/REM/CORE/DEEP); measuredAt = stage END", + }, + exercise: { + type: "Workout", + unit: "—", + path: "exercise", + filter: "exercise", + note: "exercise session → Workout row (NOT a Measurement); cross-source dedup at read time", + }, }; + +// ─── W5 mappers: activity (cumulative), sleep stages, workouts ── + +/** Finite + non-negative guard — cumulative metrics admit a legitimate 0. */ +function nonNegative(n: unknown): n is number { + return typeof n === "number" && Number.isFinite(n) && n >= 0; +} + +/** + * Pull the first finite NON-negative number out of a list of candidate value + * paths. Unlike `firstNumber` (strictly positive), this admits a legitimate + * zero — a day of rest still records 0 steps / 0 floors / 0 active kcal, and + * dropping the zero would leave a hole the chart misreads as missing data. + */ +function firstNonNegativeNumber( + point: FitbitDataPoint, + paths: string[], +): number | null { + for (const path of paths) { + const v = readPath(point, path); + if (nonNegative(v)) return v; + } + return null; +} + +/** + * Map one daily cumulative-activity data point into a single Measurement + * reading. The externalId is the `stats:`-prefixed daily-total shape so a + * re-fetched day overwrites in place rather than minting a duplicate — the same + * overwrite contract the Apple-Health `stats::` daily totals + * use. A zero is preserved (a rest day is real data, not a gap). `latestWins` + * (VO2 max) carries the same daily anchor; it is daily latest-wins rather than a + * running sum, but the per-day overwrite key is identical. + */ +function mapDailyCumulative( + point: FitbitDataPoint, + dataType: FitbitDataType, + spec: { + type: string; + unit: string; + fieldTag: string; + valuePaths: string[]; + factor?: number; + /** VO2 max is daily latest-wins, not a running total — still one row/day. */ + latestWins?: boolean; + }, +): FitbitMappedMeasurement[] { + // VO2 max is strictly positive; the running totals admit a legitimate zero. + let value = spec.latestWins + ? firstNumber(point, spec.valuePaths) + : firstNonNegativeNumber(point, spec.valuePaths); + if (value === null) return []; + if (spec.factor) value = value * spec.factor; + const dayKey = externalAnchor(point, dataType); // YYYY-MM-DD for a daily type + return [ + { + type: spec.type, + value: round2(value), + unit: spec.unit, + measuredAt: resolveMeasuredAt(point, dataType, new Date()), + // `stats::` — the sync layer reads `cumulativeDaily` + // to assemble the externalId, matching the Apple-Health daily-total shape. + fieldTag: `${spec.fieldTag}:${dayKey}`, + cumulativeDaily: true, + }, + ]; +} + +export function mapSteps(point: FitbitDataPoint): FitbitMappedMeasurement[] { + const dt = FITBIT_DATA_TYPES.steps; + return mapDailyCumulative(point, dt, { + type: "ACTIVITY_STEPS", + unit: "steps", + fieldTag: "steps", + valuePaths: [...valuePaths(dt.filter, "count"), ...valuePaths(dt.filter, "steps")], + }); +} + +export function mapDistance(point: FitbitDataPoint): FitbitMappedMeasurement[] { + const dt = FITBIT_DATA_TYPES.distance; + // Google may report metres or kilometres; prefer the explicit-metres field, + // else convert km → m. Both pass the non-negative guard. + const meters = firstNonNegativeNumber( + point, + valuePaths(dt.filter, "meters"), + ); + if (meters !== null) { + return [ + { + type: "WALKING_RUNNING_DISTANCE", + value: round2(meters), + unit: "m", + measuredAt: resolveMeasuredAt(point, dt, new Date()), + fieldTag: `distance:${externalAnchor(point, dt)}`, + cumulativeDaily: true, + }, + ]; + } + return mapDailyCumulative(point, dt, { + type: "WALKING_RUNNING_DISTANCE", + unit: "m", + fieldTag: "distance", + valuePaths: valuePaths(dt.filter, "kilometers"), + factor: 1000, + }); +} + +export function mapActiveCalories( + point: FitbitDataPoint, +): FitbitMappedMeasurement[] { + const dt = FITBIT_DATA_TYPES.activeCalories; + // ACTIVE energy only — NOT total caloriesOut (which folds in BMR). The + // candidate paths target the active-portion field explicitly. + return mapDailyCumulative(point, dt, { + type: "ACTIVE_ENERGY_BURNED", + unit: "kcal", + fieldTag: "active_calories", + valuePaths: [ + ...valuePaths(dt.filter, "active_kilocalories"), + ...valuePaths(dt.filter, "kilocalories"), + ...valuePaths(dt.filter, "calories"), + ], + }); +} + +export function mapFloors(point: FitbitDataPoint): FitbitMappedMeasurement[] { + const dt = FITBIT_DATA_TYPES.floors; + return mapDailyCumulative(point, dt, { + type: "FLIGHTS_CLIMBED", + unit: "flights", + fieldTag: "floors", + valuePaths: [...valuePaths(dt.filter, "count"), ...valuePaths(dt.filter, "floors")], + }); +} + +export function mapVo2Max(point: FitbitDataPoint): FitbitMappedMeasurement[] { + const dt = FITBIT_DATA_TYPES.vo2Max; + return mapDailyCumulative(point, dt, { + type: "VO2_MAX", + unit: "mL/(kg·min)", + fieldTag: "vo2_max", + valuePaths: [ + ...valuePaths(dt.filter, "milliliters_per_kilogram_per_minute"), + ...valuePaths(dt.filter, "value"), + ], + latestWins: true, // strictly positive, one daily reading; not a running sum + }); +} + +// ── Sleep ────────────────────────────────────────────────────── +// +// A Google Health sleep session carries a list of per-stage segments, each +// with a stage label + a start + end. HealthLog stores one SLEEP_DURATION row +// per stage with `measuredAt = stage END` (so the night-total + hypnogram +// readers consume the same enum WHOOP / Apple write). The stage labels are +// harmonised onto the shared `SleepStage` enum. + +/** Google Health sleep stage label → HealthLog `SleepStage`. */ +const FITBIT_SLEEP_STAGE_MAP: Record = { + // Canonical Google Health stage names (snake / lower variants accepted). + in_bed: "IN_BED", + inbed: "IN_BED", + awake: "AWAKE", + wake: "AWAKE", + light: "CORE", // Fitbit "light" ↔ Apple "core" (same shallow-NREM band) + core: "CORE", + rem: "REM", + deep: "DEEP", + // Fitbit's classic (non-stages) sleep log uses asleep/restless/wake. + asleep: "ASLEEP", + restless: "AWAKE", +} as const; + +/** + * Normalise a raw Google sleep-stage label to a `SleepStage`, or null for an + * unknown label (skipped rather than mis-bucketed). + */ +export function mapFitbitSleepStage(raw: unknown): FitbitSleepStage | null { + if (typeof raw !== "string") return null; + const key = raw.trim().toLowerCase().replace(/[\s-]+/g, "_"); + return FITBIT_SLEEP_STAGE_MAP[key] ?? FITBIT_SLEEP_STAGE_MAP[key.replace(/_/g, "")] ?? null; +} + +/** One sleep-stage segment pulled defensively off a Google sleep session. */ +interface FitbitSleepSegment { + stage: string; + startTime?: string; + endTime?: string; +} + +/** Minutes between two ISO instants, or null if either is unparseable. */ +function minutesBetween(startIso?: string, endIso?: string): number | null { + if (!startIso || !endIso) return null; + const s = new Date(startIso).getTime(); + const e = new Date(endIso).getTime(); + if (Number.isNaN(s) || Number.isNaN(e) || e <= s) return null; + return (e - s) / 60_000; +} + +/** + * Read the per-stage segments off a Google sleep `DataPoint`, tolerating the + * undocumented JSON shape. Candidate locations: `sleep.segments`, + * `sleep.stages`, `sleep.levels.data`, or a bare top-level `segments`/`stages`. + */ +function readSleepSegments(point: FitbitDataPoint): FitbitSleepSegment[] { + const candidates = [ + readPath(point, "sleep.segments"), + readPath(point, "sleep.stages"), + readPath(point, "sleep.levels.data"), + readPath(point, "segments"), + readPath(point, "stages"), + readPath(point, "levels.data"), + ]; + for (const c of candidates) { + if (!Array.isArray(c)) continue; + const out: FitbitSleepSegment[] = []; + for (const raw of c) { + if (!raw || typeof raw !== "object") continue; + const o = raw as Record; + const stage = + (typeof o.stage === "string" && o.stage) || + (typeof o.level === "string" && o.level) || + (typeof o.type === "string" && o.type) || + ""; + const startTime = + (typeof o.startTime === "string" && o.startTime) || + (typeof o.start_time === "string" && o.start_time) || + (typeof o.dateTime === "string" && o.dateTime) || + undefined; + const endTime = + (typeof o.endTime === "string" && o.endTime) || + (typeof o.end_time === "string" && o.end_time) || + undefined; + if (stage) out.push({ stage, startTime, endTime }); + } + if (out.length > 0) return out; + } + return []; +} + +/** + * The stable session anchor for a sleep `DataPoint`'s externalId. The session + * end (or start) ISO instant is unique per night, so per-stage rows key as + * `:sleep_` — a re-scored night overwrites in place. + */ +function sleepSessionAnchor(point: FitbitDataPoint): string { + const end = + readPath(point, "sleep.endTime") ?? + readPath(point, "sleep.end_time") ?? + readPath(point, "endTime") ?? + readPath(point, "end_time"); + if (typeof end === "string") { + const d = new Date(end); + if (!Number.isNaN(d.getTime())) return d.toISOString(); + } + const start = + readPath(point, "sleep.startTime") ?? + readPath(point, "sleep.start_time") ?? + readPath(point, "startTime"); + if (typeof start === "string") { + const d = new Date(start); + if (!Number.isNaN(d.getTime())) return d.toISOString(); + } + return new Date(0).toISOString(); +} + +/** + * Map one Google sleep session into per-stage `SLEEP_DURATION` rows. Each + * stage's segments are summed into one row carrying the stage's `SleepStage`, + * `measuredAt = the latest segment END for that stage`, and a stage-scoped + * fieldTag so the (up to six) stage rows for one night stay distinct under the + * dedup key. Unknown stage labels are skipped; a session with no parseable + * segment yields nothing. + */ +export function mapSleepSession( + point: FitbitDataPoint, +): FitbitMappedMeasurement[] { + const segments = readSleepSegments(point); + if (segments.length === 0) return []; + + // Accumulate per-stage minutes + track the latest end instant for each stage. + const perStage = new Map< + FitbitSleepStage, + { minutes: number; lastEnd: Date } + >(); + for (const seg of segments) { + const stage = mapFitbitSleepStage(seg.stage); + if (!stage) continue; + const mins = minutesBetween(seg.startTime, seg.endTime); + if (mins === null) continue; + const end = new Date(seg.endTime as string); + const prev = perStage.get(stage); + if (prev) { + prev.minutes += mins; + if (end > prev.lastEnd) prev.lastEnd = end; + } else { + perStage.set(stage, { minutes: mins, lastEnd: end }); + } + } + + const anchor = sleepSessionAnchor(point); + const out: FitbitMappedMeasurement[] = []; + for (const [stage, agg] of perStage) { + if (!(agg.minutes > 0)) continue; + out.push({ + type: "SLEEP_DURATION", + value: round2(agg.minutes), + unit: "minutes", + measuredAt: agg.lastEnd, + fieldTag: `${anchor}:sleep_${stage.toLowerCase()}`, + sleepStage: stage, + }); + } + return out; +} + +// ── Workouts (exercise sessions) ─────────────────────────────── + +/** + * Google Health exercise-activity-type → HealthLog `WorkoutSportType`. Unknown + * types fall through to a generic label; the column is free-text so an unmapped + * type still persists (just not under a canonical sport bucket). + */ +const FITBIT_EXERCISE_TYPE_MAP: Record = { + walk: "walking", + walking: "walking", + run: "running", + running: "running", + treadmill: "running", + bike: "cycling", + biking: "cycling", + cycling: "cycling", + spinning: "cycling", + hike: "hiking", + hiking: "hiking", + swim: "swimming", + swimming: "swimming", + rowing: "rowing", + elliptical: "elliptical", + stairclimber: "stairClimber", + yoga: "yoga", + pilates: "mindAndBody", + weights: "strength", + strength: "strength", + workout: "strength", + hiit: "hiit", + interval_workout: "hiit", + dance: "dance", + golf: "golf", + tennis: "tennis", + basketball: "basketball", + soccer: "soccer", + bootcamp: "crossTraining", + circuit_training: "crossTraining", + sport: "mixedCardio", +} as const; + +/** Resolve a Google exercise activity type to a canonical sport label. */ +export function mapFitbitSportType(raw: unknown): string { + if (typeof raw !== "string" || raw.trim() === "") return "other"; + const key = raw.trim().toLowerCase().replace(/[\s-]+/g, "_"); + return ( + FITBIT_EXERCISE_TYPE_MAP[key] ?? + FITBIT_EXERCISE_TYPE_MAP[key.replace(/_/g, "")] ?? + "other" + ); +} + +/** One mapped Fitbit exercise session destined for a `Workout` row. */ +export interface FitbitMappedWorkout { + externalId: string; + sportType: string; + startedAt: Date; + endedAt: Date; + durationSec: number; + totalEnergyKcal: number | null; + totalDistanceM: number | null; + avgHeartRate: number | null; + maxHeartRate: number | null; + minHeartRate: number | null; +} + +/** Read a finite number off a list of candidate paths (any sign), or null. */ +function readNumber(point: FitbitDataPoint, paths: string[]): number | null { + for (const path of paths) { + const v = readPath(point, path); + if (typeof v === "number" && Number.isFinite(v)) return v; + } + return null; +} + +/** Read an ISO/string instant off a list of candidate paths, or null. */ +function readInstant(point: FitbitDataPoint, paths: string[]): Date | null { + for (const path of paths) { + const v = readPath(point, path); + if (typeof v === "string") { + const d = new Date(v); + if (!Number.isNaN(d.getTime())) return d; + } + } + return null; +} + +/** + * Map one Google exercise session `DataPoint` into a `Workout` shape. Returns + * null when there is no usable start/end (a session with no time span is not a + * workout). The externalId anchors on the session id when present, else on the + * start instant, so a re-fetch overwrites the same `Workout` row in place. + * Energy is the active session energy in kcal; HR fields are optional. + */ +export function mapWorkout(point: FitbitDataPoint): FitbitMappedWorkout | null { + const f = FITBIT_DATA_TYPES.exercise.filter; + const startedAt = readInstant(point, [ + `${f}.startTime`, + `${f}.start_time`, + `${f}.sample_time.physical_time`, + "startTime", + "start_time", + ]); + const endedAt = readInstant(point, [ + `${f}.endTime`, + `${f}.end_time`, + "endTime", + "end_time", + ]); + if (!startedAt || !endedAt || endedAt <= startedAt) return null; + + const durationSec = Math.round((endedAt.getTime() - startedAt.getTime()) / 1000); + + const sessionId = + (typeof readPath(point, `${f}.session_id`) === "string" && + (readPath(point, `${f}.session_id`) as string)) || + (typeof readPath(point, `${f}.id`) === "string" && + (readPath(point, `${f}.id`) as string)) || + (typeof readPath(point, "name") === "string" && + (readPath(point, "name") as string)) || + null; + const externalId = sessionId ?? `exercise:${startedAt.toISOString()}`; + + const sportRaw = + readPath(point, `${f}.activity_type`) ?? + readPath(point, `${f}.exercise_type`) ?? + readPath(point, `${f}.type`) ?? + readPath(point, "activityType"); + + const energyKcal = readNumber(point, [ + `${f}.active_kilocalories`, + `${f}.calories`, + `${f}.energy.kilocalories`, + ]); + const distanceM = readNumber(point, [ + `${f}.distance.meters`, + `${f}.distance_meters`, + `${f}.distance`, + ]); + const avgHr = readNumber(point, [ + `${f}.average_heart_rate.beats_per_minute`, + `${f}.average_heart_rate`, + `${f}.heart_rate.average`, + ]); + const maxHr = readNumber(point, [ + `${f}.maximum_heart_rate.beats_per_minute`, + `${f}.max_heart_rate`, + `${f}.heart_rate.maximum`, + ]); + const minHr = readNumber(point, [ + `${f}.minimum_heart_rate.beats_per_minute`, + `${f}.min_heart_rate`, + `${f}.heart_rate.minimum`, + ]); + + return { + externalId, + sportType: mapFitbitSportType(sportRaw), + startedAt, + endedAt, + durationSec, + totalEnergyKcal: energyKcal !== null ? Math.round(energyKcal) : null, + totalDistanceM: distanceM !== null && distanceM >= 0 ? round2(distanceM) : null, + avgHeartRate: avgHr !== null && avgHr > 0 ? Math.round(avgHr) : null, + maxHeartRate: maxHr !== null && maxHr > 0 ? Math.round(maxHr) : null, + minHeartRate: minHr !== null && minHr > 0 ? Math.round(minHr) : null, + }; +} diff --git a/src/lib/fitbit/mapping.md b/src/lib/fitbit/mapping.md index a557be31b..da9c1f6b3 100644 --- a/src/lib/fitbit/mapping.md +++ b/src/lib/fitbit/mapping.md @@ -44,16 +44,54 @@ all positive — a zero/NaN is a garbage/empty reading and is dropped). `basal-body-temperature` → `BODY_TEMPERATURE`. These light up for free when Google ships the data types. -## Later waves (W5) +## Activity bundle (W5) — daily cumulative -`steps` → `ACTIVITY_STEPS`, `distance` → `WALKING_RUNNING_DISTANCE`, -`total-calories` → `ACTIVE_ENERGY_BURNED`, `floors` → `FLIGHTS_CLIMBED`, -`vo2-max`/`run-vo2-max` → `VO2_MAX`, `sleep` → per-stage `SLEEP_DURATION`, -`exercise` → `Workout` rows. `active-zone-minutes` is **skipped** at launch (no -slot; not blocking). +Scope: `googlehealth.activity_and_fitness.readonly`. Each is a per-day summary +(one value per calendar day). The externalId carries the `stats:` daily-total +prefix — `stats::` — so a re-fetched day **overwrites** in +place rather than minting a duplicate, matching the Apple-Health +`stats::` daily-total overwrite contract. The running totals +(steps/distance/floors/active-energy) **preserve a 0** (a rest day is real data, +not a gap); VO2 max stays strictly positive (daily latest-wins). + +| Data type (path / filter) | MeasurementType | Unit | fieldTag | Note | +|---|---|---|---|---| +| `steps` / `steps` | `ACTIVITY_STEPS` | steps | `steps` | daily total; 0 valid | +| `distance` / `distance` | `WALKING_RUNNING_DISTANCE` | m | `distance` | metres (km → m when reported in km) | +| `active-calories` / `active_calories` | `ACTIVE_ENERGY_BURNED` | kcal | `active_calories` | **ACTIVE portion only** — NOT total caloriesOut (which folds in BMR) | +| `floors` / `floors` | `FLIGHTS_CLIMBED` | flights | `floors` | daily total; 0 valid | +| `vo2-max` / `vo2_max` | `VO2_MAX` | mL/(kg·min) | `vo2_max` | daily latest-wins; strictly positive | + +## Sleep bundle (W5) + +Scope: `googlehealth.sleep.readonly`. A sleep session carries per-stage segments +(stage label + start + end). HealthLog stores one `SLEEP_DURATION` row per stage +(summed minutes), `measuredAt = the stage's latest END instant`, harmonised onto +the shared `SleepStage` enum the night-total + hypnogram readers consume. +externalId = `:sleep_` (session end ISO instant), so a +re-scored night overwrites in place. Stage map: `light → CORE` (Fitbit "light" ↔ +Apple "core" shallow-NREM band), `deep → DEEP`, `rem → REM`, `awake`/`wake`/ +`restless → AWAKE`, `in_bed → IN_BED`, classic `asleep → ASLEEP`. Unknown stage +labels are skipped, not mis-bucketed. + +## Exercise bundle (W5) — Workouts + +Scope: `googlehealth.activity_and_fitness.readonly`. Each exercise session → one +`Workout` row (NOT a Measurement), keyed `(userId, source: "FITBIT", +externalId)` where externalId is the session id (or `exercise:` when +absent). Fields: sportType (Google activity type → canonical `WorkoutSportType`, +`other` fallback), startedAt/endedAt, durationSec, totalEnergyKcal (active +session energy), totalDistanceM, avg/max/min HR (optional). A Fitbit run and the +same run via Apple Health / WHOOP stay distinct rows; the read-time +`pickCanonicalWorkoutRows` picker collapses the cross-source twin (FITBIT ranks +just below WHOOP in the default ladder). `active-zone-minutes` is **skipped** at +launch (no slot; not blocking). ## Idempotency -`(userId, type, source: "FITBIT", externalId)` unique. `externalId = -:` — a re-fetch of the same window (daily summaries re-roll -after the fact, so the incremental overlap is 24 h) overwrites in place. +`(userId, type, source: "FITBIT", externalId)` unique. Spot/daily metric rows: +`externalId = :`. Daily cumulative activity rows: +`externalId = stats::` (Apple-Health overwrite shape). +Workouts: `(userId, source: "FITBIT", externalId)` on the `Workout` table. A +re-fetch of the same window (daily summaries re-roll after the fact, so the +incremental overlap is 24 h) overwrites in place. diff --git a/src/lib/fitbit/sync-activity.ts b/src/lib/fitbit/sync-activity.ts new file mode 100644 index 000000000..6df245794 --- /dev/null +++ b/src/lib/fitbit/sync-activity.ts @@ -0,0 +1,130 @@ +/** + * Fitbit / Google Health activity-bundle sync (v1.12.0, W5). + * + * Reads the daily cumulative-activity data types from the + * `activity_and_fitness.readonly` Restricted bundle and upserts each mapped + * daily total as `source = FITBIT`: + * + * - steps → ACTIVITY_STEPS (count) + * - distance → WALKING_RUNNING_DISTANCE (metres) + * - active-calories → ACTIVE_ENERGY_BURNED (kcal — ACTIVE portion only) + * - floors → FLIGHTS_CLIMBED (count) + * - vo2-max → VO2_MAX (mL/(kg·min); daily latest-wins) + * + * These are per-day summaries (one value per calendar day). The externalId is + * minted with the `stats:` daily-total prefix — `stats::` + * — so a re-fetched day OVERWRITES the existing row rather than minting a + * duplicate, matching the Apple-Health `stats::` overwrite + * contract. A day of rest legitimately records 0 steps / 0 floors / 0 active + * kcal, so the cumulative mappers preserve a zero (dropping it would leave a + * hole the chart misreads as missing data); VO2 max stays strictly positive. + * + * A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection + * connected) — the six Restricted bundles are granted independently. + */ +import { + FITBIT_DATA_TYPES, + type FitbitDataType, + type FitbitMappedMeasurement, + fetchDataPoints, + mapActiveCalories, + mapDistance, + mapFloors, + mapSteps, + mapVo2Max, +} from "./client"; +import { + getValidToken, + handleCollectionFetchError, + incrementalStart, + markSynced, + upsertFitbitMeasurements, + type FitbitMeasurementUpsert, +} from "./sync"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; + +/** One mappable activity metric: its data-type encoding + the per-point mapper + a verb. */ +interface ActivityResource { + dataType: FitbitDataType; + map: (point: Record) => FitbitMappedMeasurement[]; + verb: string; +} + +const ACTIVITY_RESOURCES: ActivityResource[] = [ + { dataType: FITBIT_DATA_TYPES.steps, map: mapSteps, verb: "fetchSteps" }, + { + dataType: FITBIT_DATA_TYPES.distance, + map: mapDistance, + verb: "fetchDistance", + }, + { + dataType: FITBIT_DATA_TYPES.activeCalories, + map: mapActiveCalories, + verb: "fetchActiveCalories", + }, + { dataType: FITBIT_DATA_TYPES.floors, map: mapFloors, verb: "fetchFloors" }, + { dataType: FITBIT_DATA_TYPES.vo2Max, map: mapVo2Max, verb: "fetchVo2Max" }, +]; + +/** + * Assemble the stored externalId for a mapped activity reading. A cumulative + * daily metric carries the `cumulativeDaily` flag from its mapper — its + * externalId gets the `stats:` daily-total prefix so a re-fetched day overwrites + * in place (the mapper's `fieldTag` is already the `:` form). + */ +function externalIdFor(m: FitbitMappedMeasurement): string { + return m.cumulativeDaily ? `stats:${m.fieldTag}` : m.fieldTag; +} + +export async function syncUserActivity( + userId: string, + opts: { fullSync?: boolean } = {}, +): Promise { + const tokenInfo = await getValidToken(userId); + if (!tokenInfo) return 0; + + const connection = await prisma.fitbitConnection.findUnique({ + where: { userId }, + select: { lastSyncedAt: true }, + }); + if (!connection) return 0; + + const start = incrementalStart(connection.lastSyncedAt, { + fullSync: opts.fullSync, + }); + + let imported = 0; + for (const resource of ACTIVITY_RESOURCES) { + let points: Record[]; + try { + points = await fetchDataPoints( + resource.dataType, + tokenInfo.accessToken, + resource.verb, + { start }, + ); + } catch (err) { + imported += await handleCollectionFetchError(resource.verb, userId, err); + continue; + } + + const readings: FitbitMeasurementUpsert[] = []; + for (const point of points) { + for (const m of resource.map(point)) { + readings.push({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + externalId: externalIdFor(m), + }); + } + } + imported += await upsertFitbitMeasurements(userId, readings); + } + + await markSynced(userId); + annotate({ action: { name: "fitbit.activity.sync", details: { imported } } }); + return imported; +} diff --git a/src/lib/fitbit/sync-sleep.ts b/src/lib/fitbit/sync-sleep.ts new file mode 100644 index 000000000..524316d50 --- /dev/null +++ b/src/lib/fitbit/sync-sleep.ts @@ -0,0 +1,78 @@ +/** + * Fitbit / Google Health sleep-bundle sync (v1.12.0, W5). + * + * Reads sleep sessions from the `sleep.readonly` Restricted bundle and maps + * each into per-stage `SLEEP_DURATION` rows via `mapSleepSession` (minutes per + * stage; `measuredAt = the stage's END instant; harmonised onto the shared + * `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP that the night-total + + * hypnogram readers already consume for WHOOP / Apple). Upserts as + * `source = FITBIT`. + * + * Per-stage rows carry the `sleepStage` axis so the (up to six) stage rows for + * one night stay distinct under the `(userId, type, source, externalId)` dedup + * key. externalId = `:sleep_` — a re-scored night + * overwrites in place. A 24 h overlap covers Google's after-the-fact re-score. + * + * A per-data-class 403 soft-skips the resource — the sleep bundle is granted + * independently of activity / metrics in the Google consent flow. + */ +import { FITBIT_DATA_TYPES, fetchDataPoints, mapSleepSession } from "./client"; +import { + getValidToken, + handleCollectionFetchError, + incrementalStart, + markSynced, + upsertFitbitMeasurements, + type FitbitMeasurementUpsert, +} from "./sync"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; + +export async function syncUserSleep( + userId: string, + opts: { fullSync?: boolean } = {}, +): Promise { + const tokenInfo = await getValidToken(userId); + if (!tokenInfo) return 0; + + const connection = await prisma.fitbitConnection.findUnique({ + where: { userId }, + select: { lastSyncedAt: true }, + }); + if (!connection) return 0; + + const start = incrementalStart(connection.lastSyncedAt, { + fullSync: opts.fullSync, + }); + + let points: Record[]; + try { + points = await fetchDataPoints( + FITBIT_DATA_TYPES.sleep, + tokenInfo.accessToken, + "fetchSleep", + { start, pageSize: 25 }, + ); + } catch (err) { + return handleCollectionFetchError("fetchSleep", userId, err); + } + + const readings: FitbitMeasurementUpsert[] = []; + for (const point of points) { + for (const m of mapSleepSession(point)) { + readings.push({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + externalId: m.fieldTag, + sleepStage: m.sleepStage ?? null, + }); + } + } + + const imported = await upsertFitbitMeasurements(userId, readings); + await markSynced(userId); + annotate({ action: { name: "fitbit.sleep.sync", details: { imported } } }); + return imported; +} diff --git a/src/lib/fitbit/sync-workout.ts b/src/lib/fitbit/sync-workout.ts new file mode 100644 index 000000000..9970e75d8 --- /dev/null +++ b/src/lib/fitbit/sync-workout.ts @@ -0,0 +1,110 @@ +/** + * Fitbit / Google Health exercise-session sync (v1.12.0, W5). + * + * Reads exercise sessions from the `activity_and_fitness.readonly` Restricted + * bundle and upserts each into the `Workout` table as `source = FITBIT`, keyed + * on `(userId, source, externalId)` so a re-fetch overwrites in place. + * + * A Fitbit run and the same run via Apple Health (or WHOOP) stay distinct + * `Workout` rows (different `source`); the read-time `pickCanonicalWorkoutRows` + * picker collapses the cross-source twin at read time per the user's source + * ladder (FITBIT is already in the default ladder, ranked just below WHOOP). + * There is no ingest-time collapse for a server-owned source pair. + * + * A per-data-class 403 soft-skips the resource — the activity/fitness bundle is + * granted independently in the Google consent flow. + */ +import { + FITBIT_DATA_TYPES, + fetchDataPoints, + mapWorkout, + type FitbitMappedWorkout, +} from "./client"; +import { + getValidToken, + handleCollectionFetchError, + incrementalStart, + markSynced, +} from "./sync"; +import { prisma } from "@/lib/db"; +import { annotate, getEvent } from "@/lib/logging/context"; + +export async function syncUserWorkout( + userId: string, + opts: { fullSync?: boolean } = {}, +): Promise { + const tokenInfo = await getValidToken(userId); + if (!tokenInfo) return 0; + + const connection = await prisma.fitbitConnection.findUnique({ + where: { userId }, + select: { lastSyncedAt: true }, + }); + if (!connection) return 0; + + const start = incrementalStart(connection.lastSyncedAt, { + fullSync: opts.fullSync, + }); + + let points: Record[]; + try { + points = await fetchDataPoints( + FITBIT_DATA_TYPES.exercise, + tokenInfo.accessToken, + "fetchExercise", + { start, pageSize: 25 }, + ); + } catch (err) { + return handleCollectionFetchError("fetchExercise", userId, err); + } + + let imported = 0; + for (const point of points) { + const w: FitbitMappedWorkout | null = mapWorkout(point); + if (!w) continue; // no usable time span — not a workout + + try { + await prisma.workout.upsert({ + where: { + userId_source_externalId: { + userId, + source: "FITBIT", + externalId: w.externalId, + }, + }, + create: { + userId, + source: "FITBIT", + externalId: w.externalId, + sportType: w.sportType, + startedAt: w.startedAt, + endedAt: w.endedAt, + durationSec: w.durationSec, + totalEnergyKcal: w.totalEnergyKcal, + totalDistanceM: w.totalDistanceM, + avgHeartRate: w.avgHeartRate, + maxHeartRate: w.maxHeartRate, + minHeartRate: w.minHeartRate, + }, + update: { + sportType: w.sportType, + startedAt: w.startedAt, + endedAt: w.endedAt, + durationSec: w.durationSec, + totalEnergyKcal: w.totalEnergyKcal, + totalDistanceM: w.totalDistanceM, + avgHeartRate: w.avgHeartRate, + maxHeartRate: w.maxHeartRate, + minHeartRate: w.minHeartRate, + }, + }); + imported++; + } catch (err) { + getEvent()?.addWarning(`fitbit: failed to upsert workout: ${err}`); + } + } + + await markSynced(userId); + annotate({ action: { name: "fitbit.workout.sync", details: { imported } } }); + return imported; +} diff --git a/src/lib/fitbit/sync.ts b/src/lib/fitbit/sync.ts index 1bae89550..ccf1e26de 100644 --- a/src/lib/fitbit/sync.ts +++ b/src/lib/fitbit/sync.ts @@ -214,6 +214,12 @@ export interface FitbitMeasurementUpsert { unit: string; measuredAt: Date; externalId: string; + /** + * Per-stage sleep rows carry the `SleepStage` axis so the (up to six) stage + * rows for one night stay distinct under the dedup key; every other row omits + * it (null). W5 (sleep) is the only producer. + */ + sleepStage?: "IN_BED" | "AWAKE" | "ASLEEP" | "REM" | "CORE" | "DEEP" | null; } /** @@ -255,11 +261,13 @@ export async function upsertFitbitMeasurements( unit: r.unit, measuredAt: r.measuredAt, externalId: r.externalId, + sleepStage: r.sleepStage ?? null, }, update: { value: r.value, unit: r.unit, measuredAt: r.measuredAt, + sleepStage: r.sleepStage ?? null, // Surface the server-side mutation to the iOS LWW reconciler. syncVersion: { increment: 1 }, }, @@ -310,8 +318,9 @@ export async function markSynced(userId: string): Promise { * Parks immediately when the connection is at `error_reauth` (the user must * reconnect first) — returns 0, matching the WHOOP / Withings no-op contract. * - * W3 wires the health-metrics resource; activity / sleep / workout join the - * resource list in W5. + * Resources: health-metrics (W3), daily-cumulative activity, per-stage sleep, + * and exercise workouts (W5). Each runs independently so a per-resource 403 + * (a Restricted bundle the user did not grant) soft-skips only that resource. */ export async function syncUserFitbit( userId: string, @@ -324,9 +333,20 @@ export async function syncUserFitbit( return 0; } - const { syncUserMetrics } = await import("./sync-metrics"); + const [{ syncUserMetrics }, { syncUserActivity }, { syncUserSleep }, { syncUserWorkout }] = + await Promise.all([ + import("./sync-metrics"), + import("./sync-activity"), + import("./sync-sleep"), + import("./sync-workout"), + ]); - const resources = [syncUserMetrics]; + const resources = [ + syncUserMetrics, + syncUserActivity, + syncUserSleep, + syncUserWorkout, + ]; const tracker: SoftSkipTracker = { count: 0 }; let total = 0; diff --git a/tests/integration/fitbit-activity-sleep-workout-sync.test.ts b/tests/integration/fitbit-activity-sleep-workout-sync.test.ts new file mode 100644 index 000000000..e32b57fdf --- /dev/null +++ b/tests/integration/fitbit-activity-sleep-workout-sync.test.ts @@ -0,0 +1,272 @@ +/** + * v1.12.0 (W5) — Fitbit activity / sleep / workout sync end-to-end. + * + * Drives the three W5 resource syncs against a mocked Google Health + * `dataPoints.list` and the real Postgres testcontainer. Asserts: + * - Daily cumulative activity rows mint a `stats:`-prefixed externalId and a + * re-fetched day OVERWRITES the row in place (no duplicate) — the + * Apple-Health daily-total overwrite contract. + * - Sleep sessions upsert per-stage SLEEP_DURATION rows carrying the shared + * SleepStage enum, distinct per stage under the dedup key. + * - A Fitbit exercise session writes a Workout row keyed + * (userId, source=FITBIT, externalId); a Fitbit run and an Apple-Health twin + * both persist, and the read-time canonical picker collapses them to the + * ladder winner (APPLE_HEALTH > WHOOP > FITBIT). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Pin a deterministic 32-byte key before any `@/lib/crypto` import. +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: vi.fn(() => null), +})); + +const TEST_USER_ID = "user-fitbit-w5-sync"; + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: TEST_USER_ID, + username: "fitbit-w5-sync", + email: "fitbit-w5-sync@example.test", + }, + }); + await prisma.fitbitConnection.create({ + data: { + userId: TEST_USER_ID, + fitbitUserId: "gh-w5", + accessToken: encrypt("access-token"), + refreshToken: encrypt("refresh-token"), + tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + scope: + "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly https://www.googleapis.com/auth/googlehealth.sleep.readonly", + }, + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +/** Stub fetch: match the kebab path segment → its seeded data points. */ +function stubGoogleHealth(byPathSegment: Record) { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + const match = /dataTypes\/([^/?]+)\/dataPoints/.exec(url); + const seg = match?.[1] ?? ""; + const dataPoints = byPathSegment[seg] ?? []; + return { status: 200, json: async () => ({ dataPoints }) }; + }), + ); +} + +const DAY = { year: 2026, month: 5, day: 10 }; + +describe("syncUserActivity — cumulative overwrite", () => { + it("mints a stats: externalId and overwrites the same day on re-sync (no duplicate)", async () => { + stubGoogleHealth({ steps: [{ steps: { count: 8000, date: DAY } }] }); + const { syncUserActivity } = await import("@/lib/fitbit/sync-activity"); + const first = await syncUserActivity(TEST_USER_ID); + expect(first).toBe(1); + + const prisma = getPrismaClient(); + let rows = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS", source: "FITBIT" }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]!.value).toBe(8000); + expect(rows[0]!.unit).toBe("steps"); + // The daily-total overwrite shape: stats::. + expect(rows[0]!.externalId).toBe("stats:steps:2026-05-10"); + + // Re-fetch the same day with a corrected total → overwrite, not duplicate. + stubGoogleHealth({ steps: [{ steps: { count: 9123, date: DAY } }] }); + await syncUserActivity(TEST_USER_ID); + + rows = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS", source: "FITBIT" }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]!.value).toBe(9123); + expect(rows[0]!.syncVersion).toBeGreaterThanOrEqual(2); + }); + + it("preserves a 0-step rest day rather than dropping it as a gap", async () => { + stubGoogleHealth({ steps: [{ steps: { count: 0, date: DAY } }] }); + const { syncUserActivity } = await import("@/lib/fitbit/sync-activity"); + const imported = await syncUserActivity(TEST_USER_ID); + expect(imported).toBe(1); + + const prisma = getPrismaClient(); + const rows = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS", source: "FITBIT" }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]!.value).toBe(0); + }); +}); + +describe("syncUserSleep — per-stage upsert", () => { + it("writes one SLEEP_DURATION row per stage carrying the SleepStage enum", async () => { + const session = { + sleep: { + startTime: "2026-05-10T22:00:00.000Z", + endTime: "2026-05-11T06:00:00.000Z", + segments: [ + { + stage: "light", + startTime: "2026-05-10T22:00:00.000Z", + endTime: "2026-05-10T23:00:00.000Z", + }, + { + stage: "deep", + startTime: "2026-05-10T23:00:00.000Z", + endTime: "2026-05-11T00:30:00.000Z", + }, + { + stage: "rem", + startTime: "2026-05-11T00:30:00.000Z", + endTime: "2026-05-11T01:15:00.000Z", + }, + ], + }, + }; + stubGoogleHealth({ sleep: [session] }); + const { syncUserSleep } = await import("@/lib/fitbit/sync-sleep"); + const imported = await syncUserSleep(TEST_USER_ID); + expect(imported).toBe(3); + + const prisma = getPrismaClient(); + const rows = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, type: "SLEEP_DURATION", source: "FITBIT" }, + orderBy: { sleepStage: "asc" }, + }); + expect(rows).toHaveLength(3); + const byStage = Object.fromEntries(rows.map((r) => [r.sleepStage, r])); + expect(byStage.CORE!.value).toBe(60); // light → CORE, 60 min + expect(byStage.DEEP!.value).toBe(90); + expect(byStage.REM!.value).toBe(45); + // Each stage row is distinct under the dedup key. + expect(new Set(rows.map((r) => r.externalId)).size).toBe(3); + + // Re-score the same night → overwrite in place (still 3 rows). + await syncUserSleep(TEST_USER_ID); + const again = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, type: "SLEEP_DURATION", source: "FITBIT" }, + }); + expect(again).toHaveLength(3); + }); +}); + +describe("syncUserWorkout — Workout rows + cross-source dedup", () => { + it("writes a Workout row keyed (userId, FITBIT, externalId)", async () => { + stubGoogleHealth({ + exercise: [ + { + exercise: { + session_id: "ex-1", + activity_type: "run", + startTime: "2026-05-10T07:00:00.000Z", + endTime: "2026-05-10T07:40:00.000Z", + active_kilocalories: 380, + distance: { meters: 7000 }, + average_heart_rate: { beats_per_minute: 150 }, + }, + }, + ], + }); + const { syncUserWorkout } = await import("@/lib/fitbit/sync-workout"); + const imported = await syncUserWorkout(TEST_USER_ID); + expect(imported).toBe(1); + + const prisma = getPrismaClient(); + const workouts = await prisma.workout.findMany({ + where: { userId: TEST_USER_ID, source: "FITBIT" }, + }); + expect(workouts).toHaveLength(1); + expect(workouts[0]).toMatchObject({ + externalId: "ex-1", + sportType: "running", + durationSec: 40 * 60, + totalEnergyKcal: 380, + totalDistanceM: 7000, + avgHeartRate: 150, + }); + + // Re-fetch → overwrite in place, still one row. + await syncUserWorkout(TEST_USER_ID); + expect( + await prisma.workout.count({ + where: { userId: TEST_USER_ID, source: "FITBIT" }, + }), + ).toBe(1); + }); + + it("keeps a Fitbit + Apple-Health twin as two rows; the read-time picker collapses them", async () => { + const prisma = getPrismaClient(); + // A pre-existing Apple-Health run at the same wall-clock time. + await prisma.workout.create({ + data: { + userId: TEST_USER_ID, + source: "APPLE_HEALTH", + externalId: "hk-run-1", + sportType: "running", + startedAt: new Date("2026-05-10T07:00:30.000Z"), + endedAt: new Date("2026-05-10T07:40:00.000Z"), + durationSec: 2370, + }, + }); + + stubGoogleHealth({ + exercise: [ + { + exercise: { + session_id: "ex-1", + activity_type: "run", + startTime: "2026-05-10T07:00:00.000Z", + endTime: "2026-05-10T07:40:00.000Z", + }, + }, + ], + }); + const { syncUserWorkout } = await import("@/lib/fitbit/sync-workout"); + await syncUserWorkout(TEST_USER_ID); + + // Both rows persist at ingest (no server-owned-pair collapse on write). + const allRows = await prisma.workout.findMany({ + where: { userId: TEST_USER_ID }, + orderBy: { source: "asc" }, + }); + expect(allRows).toHaveLength(2); + expect(allRows.map((r) => r.source).sort()).toEqual([ + "APPLE_HEALTH", + "FITBIT", + ]); + + // Read-time canonical picker collapses the twin to the ladder winner. + const { pickCanonicalWorkoutRows } = await import( + "@/lib/measurements/pick-canonical-workout-rows" + ); + const canonical = pickCanonicalWorkoutRows( + allRows.map((r) => ({ + startedAt: r.startedAt, + sportType: r.sportType, + source: r.source, + })), + null, + ); + expect(canonical).toHaveLength(1); + // APPLE_HEALTH ranks above FITBIT in the default ladder. + expect(canonical[0]!.source).toBe("APPLE_HEALTH"); + }); +});