mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
The shipped Fitbit integration spoke the Google Health API, whose Restricted scopes need brand verification plus an annual CASA assessment before leaving testing mode — a wall most self-hosters cannot clear. Retarget the transport to the classic api.fitbit.com Web API: a self-hoster registers an app at dev.fitbit.com in minutes, no CASA. Forks only the transport; the upsert/dedup/rollup tail, source-priority, backfill, poll worker, and status ledger are reused unchanged. - OAuth: www.fitbit.com/oauth2/authorize + api.fitbit.com/oauth2/token, PKCE (S256), classic self-serve scopes. - Refresh tokens rotate (one-time use): persist the new token every refresh, with a defensive keep-existing guard on a malformed reply. - Per-endpoint date-range fetchers chunked to the API range caps; the backfill horizon and poll concurrency are tuned for the 150 req/h-per-user budget. - Verified classic-shape extractors for weight, body fat, SpO2, HRV (dailyRmssd), resting HR, respiratory rate, steps, distance, active calories, floors, VO2 max (range midpoint), per-segment sleep stages, and workouts. - Skips intraday HR (needs a per-app grant; daily resting HR covers it) and skin temperature (a baseline delta with no honest absolute slot).
78 lines
2.6 KiB
TypeScript
78 lines
2.6 KiB
TypeScript
/**
|
|
* Fitbit Web API sleep sync.
|
|
*
|
|
* Reads sleep sessions from the classic `1.2/sleep/date` log and maps each into
|
|
* per-SEGMENT `SLEEP_DURATION` rows via `mapSleepSession` (one row per stage
|
|
* segment; `measuredAt = segment START + seconds = segment END`; harmonised onto
|
|
* the shared `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP the night-total
|
|
* + hypnogram readers already consume for WHOOP / Apple). The 1.2 log carries a
|
|
* real per-segment series, so the rows lay each block at its true clock time (a
|
|
* MEASURED timeline, not reconstructed). Upserts as `source = FITBIT`.
|
|
*
|
|
* Each segment carries the `sleepStage` axis and an indexed fieldTag so the
|
|
* several segments of one stage stay distinct under the dedup key. externalId =
|
|
* `<logId>:sleep_<stage>:<i>` — a re-scored night overwrites in place. A 24 h
|
|
* overlap covers Fitbit's after-the-fact re-score.
|
|
*
|
|
* A per-endpoint 403 soft-skips the resource — the `sleep` scope is granted
|
|
* independently in the consent flow.
|
|
*/
|
|
import {
|
|
FITBIT_SLEEP_RANGE_DAYS,
|
|
fetchSleepRange,
|
|
mapSleepSession,
|
|
readSleepSessions,
|
|
} from "./client";
|
|
import {
|
|
chunkDateRanges,
|
|
getValidToken,
|
|
handleCollectionFetchError,
|
|
upsertFitbitMeasurements,
|
|
type FitbitMeasurementUpsert,
|
|
type FitbitResourceSyncOptions,
|
|
} from "./sync";
|
|
import { annotate } from "@/lib/logging/context";
|
|
|
|
export async function syncUserSleep(
|
|
userId: string,
|
|
opts: FitbitResourceSyncOptions = {},
|
|
): Promise<number> {
|
|
const tokenInfo = await getValidToken(userId);
|
|
if (!tokenInfo) return 0;
|
|
|
|
const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
const end = opts.end ?? new Date();
|
|
const windows = chunkDateRanges(start, end, FITBIT_SLEEP_RANGE_DAYS);
|
|
|
|
const readings: FitbitMeasurementUpsert[] = [];
|
|
for (const w of windows) {
|
|
let body: unknown;
|
|
try {
|
|
body = await fetchSleepRange(tokenInfo.accessToken, w.start, w.end);
|
|
} catch (err) {
|
|
return handleCollectionFetchError("fetchSleep", userId, err);
|
|
}
|
|
for (const session of readSleepSessions(body)) {
|
|
for (const m of mapSleepSession(session)) {
|
|
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, {
|
|
deferRollup: opts.deferRollup,
|
|
})
|
|
).imported;
|
|
// `markSynced` is owned by the orchestrator (`syncUserFitbit`).
|
|
annotate({ action: { name: "fitbit.sleep.sync", details: { imported } } });
|
|
return imported;
|
|
}
|