mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
f44dccb828
commit
6ad0a8a5cf
9 files changed
+1485
-15
No files matched your search
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user