mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
The classic 1.2 sleep log and the activities list emit local wall-clock ISO strings without an offset. `parseLocalInstant` parsed them with a bare `new Date(iso)`, which reads an offset-less string in the process zone (UTC in production). A non-UTC user's sleep-segment `measuredAt` (the segment end) was therefore shifted by their UTC offset, and a near-midnight segment could flip onto the wrong wake-day in the night reconstruction. Resolve offset-less timestamps against the user's stored timezone via a new `zonedWallClockToUtc` helper (the inverse of `wallClockInTz`, DST-safe via the same two-pass converge as `startOfLocalDayInTz`). Thread the resolved zone through the sleep and workout sync paths. Strings that already carry an offset or `Z` stay absolute and parse as-is; an absent zone keeps the prior host-local fallback.
123 lines
4.0 KiB
TypeScript
123 lines
4.0 KiB
TypeScript
/**
|
|
* Fitbit Web API exercise-session sync.
|
|
*
|
|
* Reads exercise sessions from the classic `activities/list` endpoint
|
|
* (`afterDate` + ascending offset/limit pagination) and upserts each into the
|
|
* `Workout` table as `source = FITBIT`, keyed on `(userId, source, externalId)`
|
|
* (externalId = the Fitbit `logId`) 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 per the user's source ladder (FITBIT is
|
|
* already in the default ladder, ranked just below WHOOP). No ingest-time
|
|
* collapse for a server-owned source pair.
|
|
*
|
|
* The page-walk is bounded (the 150 req/h budget is tight): a small page cap
|
|
* covers the incremental window comfortably; a deep backfill is bounded by the
|
|
* backfill horizon `afterDate`. A per-endpoint 403 soft-skips the resource.
|
|
*/
|
|
import {
|
|
fetchActivityList,
|
|
mapWorkout,
|
|
readActivityList,
|
|
type FitbitMappedWorkout,
|
|
} from "./client";
|
|
import {
|
|
getValidToken,
|
|
handleCollectionFetchError,
|
|
type FitbitResourceSyncOptions,
|
|
} from "./sync";
|
|
import { prisma } from "@/lib/db";
|
|
import { annotate, getEvent } from "@/lib/logging/context";
|
|
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
|
|
|
/** Activities per page on the `activities/list` walk. */
|
|
const WORKOUT_PAGE_SIZE = 100;
|
|
/** Hard ceiling on pages walked per cycle (rate-budget guard). */
|
|
const WORKOUT_MAX_PAGES = 12;
|
|
|
|
export async function syncUserWorkout(
|
|
userId: string,
|
|
opts: FitbitResourceSyncOptions = {},
|
|
): Promise<number> {
|
|
const tokenInfo = await getValidToken(userId);
|
|
if (!tokenInfo) return 0;
|
|
|
|
// `startTime` on the activities list is offset-less local wall-clock; anchor
|
|
// it to the user's stored zone rather than the process zone.
|
|
const tz = await resolveUserTimezone(userId);
|
|
|
|
const afterDate =
|
|
opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
|
|
const entries: ReturnType<typeof readActivityList> = [];
|
|
let offset = 0;
|
|
let pages = 0;
|
|
while (pages < WORKOUT_MAX_PAGES) {
|
|
let body: unknown;
|
|
try {
|
|
body = await fetchActivityList(tokenInfo.accessToken, afterDate, {
|
|
limit: WORKOUT_PAGE_SIZE,
|
|
offset,
|
|
});
|
|
} catch (err) {
|
|
return handleCollectionFetchError("fetchExercise", userId, err);
|
|
}
|
|
const page = readActivityList(body);
|
|
entries.push(...page);
|
|
pages += 1;
|
|
if (page.length < WORKOUT_PAGE_SIZE) break;
|
|
offset += WORKOUT_PAGE_SIZE;
|
|
}
|
|
|
|
let imported = 0;
|
|
for (const entry of entries) {
|
|
const w: FitbitMappedWorkout | null = mapWorkout(entry, tz);
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
// `markSynced` is owned by the orchestrator (`syncUserFitbit`).
|
|
annotate({ action: { name: "fitbit.workout.sync", details: { imported } } });
|
|
return imported;
|
|
}
|