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:<tag>:<YYYY-MM-DD> 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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 22:57:44 +02:00
1 parent f44dccb828
commit 6ad0a8a5cf
9 files changed
+1485 -15

No files matched your search

+208
View File
@@ -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 <tag>:<YYYY-MM-DD>; 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");
});
});
@@ -16,6 +16,9 @@ const {
recordSyncFailure,
isReauthRequired,
syncUserMetrics,
syncUserActivity,
syncUserSleep,
syncUserWorkout,
} = vi.hoisted(() => ({
recordSyncSuccess: vi.fn<(...a: unknown[]) => Promise<void>>(async () => {}),
recordSyncFailure: vi.fn<(...a: unknown[]) => Promise<void>>(async () => {}),
@@ -23,6 +26,9 @@ const {
async () => false,
),
syncUserMetrics: vi.fn<(...a: unknown[]) => Promise<number>>(),
syncUserActivity: vi.fn<(...a: unknown[]) => Promise<number>>(),
syncUserSleep: vi.fn<(...a: unknown[]) => Promise<number>>(),
syncUserWorkout: vi.fn<(...a: unknown[]) => Promise<number>>(),
}));
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<number> {
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");
+574
View File
@@ -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:<HK>:<YYYY-MM-DD>` 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<string, FitbitDataType>;
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:<type-tag>:<YYYY-MM-DD>` vs the
* `<anchor>:<fieldTag>` 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:<HK>:<YYYY-MM-DD>` 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:<type-tag>:<YYYY-MM-DD>` — 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<string, FitbitSleepStage> = {
// 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<string, unknown>;
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
* `<session-anchor>:sleep_<stage>` — 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<string, string> = {
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,
};
}
+47 -9
View File
@@ -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:<fieldTag>:<YYYY-MM-DD>` — so a re-fetched day **overwrites** in
place rather than minting a duplicate, matching the Apple-Health
`stats:<HK>:<YYYY-MM-DD>` 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 = `<session-anchor>:sleep_<stage>` (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:<startISO>` 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 =
<anchor>:<fieldTag>` — 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 = <anchor>:<fieldTag>`. Daily cumulative activity rows:
`externalId = stats:<fieldTag>:<YYYY-MM-DD>` (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.
+130
View File
@@ -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:<fieldTag>:<YYYY-MM-DD>`
* — so a re-fetched day OVERWRITES the existing row rather than minting a
* duplicate, matching the Apple-Health `stats:<HK>:<YYYY-MM-DD>` 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<string, unknown>) => 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 `<tag>:<YYYY-MM-DD>` form).
*/
function externalIdFor(m: FitbitMappedMeasurement): string {
return m.cumulativeDaily ? `stats:${m.fieldTag}` : m.fieldTag;
}
export async function syncUserActivity(
userId: string,
opts: { fullSync?: boolean } = {},
): Promise<number> {
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<string, unknown>[];
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;
}
+78
View File
@@ -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 = `<session-anchor>:sleep_<stage>` — 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<number> {
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<string, unknown>[];
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;
}
+110
View File
@@ -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<number> {
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<string, unknown>[];
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;
}
+24 -4
View File
@@ -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<void> {
* 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;
@@ -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<string, unknown[]>) {
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:<tag>:<YYYY-MM-DD>.
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");
});
});