mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Fitbit re-scores a night after the fact, and the per-segment externalId carried a positional index plus the stage label. Both moved on a re-score: renumbered segments minted fresh keys, the previous scoring's rows stayed live because nothing swept them, and the night total counted both copies from then on. Key the segment on the stable session anchor plus its own start instant (`<logId>:sleep:<segment-start>`), and add the replace-by-window sweep every other segment-writing provider already has. The two ship together with the natural-key rescue that has been sitting in the Fitbit upsert unused: the sweep tombstones the old-keyed rows first, so the rescue re-keys those very rows in place instead of inserting a parallel copy or losing the insert to `skipDuplicates` against the natural unique index. A one-shot repair queue re-reads the bounded sleep history once per connection so no user has to do anything; it stamps `sleepRepairedAt` (migration 0270) only on a clean run, never touches the sync watermark, and reports the rows it removed. Nights older than the 365-day backfill horizon keep their old keys and whatever duplicates they already hold — they can no longer gain new ones. Alongside: `fitbitGet` now honours a `Retry-After` inside a 120 s cap by waiting once and retrying once, and fails fast above it so a long hold cannot starve the rest of the poll cohort or outlive a job's expiration. The measurement upsert skips a write whose payload is unchanged, which takes the hourly overlap off the delta feed for every Fitbit resource.
121 lines
4.4 KiB
TypeScript
121 lines
4.4 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 a fieldTag keyed on the STABLE
|
|
* session anchor plus the segment's own start — `<logId>:sleep:<segment-start>`
|
|
* — so a re-scored night overwrites in place instead of minting parallel rows
|
|
* the night total would then double-count. A 24 h overlap covers Fitbit's
|
|
* after-the-fact re-score, and `replaceStaleFitbitSleep` clears anything an
|
|
* earlier scoring left in the night's window before the fresh set upserts.
|
|
*
|
|
* A per-endpoint 403 soft-skips the resource — the `sleep` scope is granted
|
|
* independently in the consent flow.
|
|
*/
|
|
import {
|
|
FITBIT_SLEEP_RANGE_DAYS,
|
|
fetchSleepRange,
|
|
mapSleepSessionDetailed,
|
|
readSleepSessions,
|
|
} from "./client";
|
|
import {
|
|
chunkDateRanges,
|
|
getValidToken,
|
|
handleCollectionFetchError,
|
|
replaceStaleFitbitSleep,
|
|
upsertFitbitMeasurements,
|
|
} from "./sync-core";
|
|
import type {
|
|
FitbitMeasurementUpsert,
|
|
FitbitResourceSyncOptions,
|
|
FitbitSleepReplaceWindow,
|
|
} from "./sync-core";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
|
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
|
|
|
export async function syncUserSleep(
|
|
userId: string,
|
|
opts: FitbitResourceSyncOptions = {},
|
|
): Promise<number> {
|
|
const tokenInfo = await getValidToken(userId);
|
|
if (!tokenInfo) return 0;
|
|
|
|
// The classic 1.2 sleep log emits offset-less local wall-clock timestamps;
|
|
// anchor them against the user's stored zone so a near-midnight segment END
|
|
// lands on the correct wake-day rather than being shifted by the process zone.
|
|
const tz = await resolveUserTimezone(userId);
|
|
|
|
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[] = [];
|
|
const replaceWindows: FitbitSleepReplaceWindow[] = [];
|
|
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)) {
|
|
const mapped = mapSleepSessionDetailed(session, tz);
|
|
if (mapped.rows.length === 0) continue;
|
|
for (const m of mapped.rows) {
|
|
readings.push({
|
|
type: m.type,
|
|
value: m.value,
|
|
unit: m.unit,
|
|
measuredAt: m.measuredAt,
|
|
externalId: m.fieldTag,
|
|
sleepStage: m.sleepStage ?? null,
|
|
});
|
|
}
|
|
// Clean any stale rows a prior scoring left in this night's window before
|
|
// the fresh set upserts — so a re-scored night reads its true total rather
|
|
// than the sum of the old and the re-scored copies.
|
|
replaceWindows.push({
|
|
windowStart: mapped.windowStart,
|
|
windowEnd: mapped.windowEnd,
|
|
keepIds: mapped.rows.map((m) => m.fieldTag),
|
|
});
|
|
}
|
|
}
|
|
|
|
// BEFORE the upsert: the sweep tombstones the old-keyed rows, and the upsert's
|
|
// natural-key rescue then re-keys those very tombstones in place. Reversing
|
|
// the order would tombstone the rows the upsert just wrote.
|
|
const removed = await replaceStaleFitbitSleep(userId, replaceWindows);
|
|
|
|
const { imported, inserted } = await upsertFitbitMeasurements(
|
|
userId,
|
|
readings,
|
|
{
|
|
deferRollup: opts.deferRollup,
|
|
},
|
|
);
|
|
// `markSynced` is owned by the orchestrator (`syncUserFitbit`).
|
|
|
|
// S4 — trigger the debounced morning refresh on a last-night segment landing
|
|
// (mirrors the Withings / WHOOP / Apple seams).
|
|
void maybeEnqueueMorningRefresh(
|
|
userId,
|
|
inserted
|
|
.filter((r) => r.type === "SLEEP_DURATION")
|
|
.map((r) => r.measuredAt),
|
|
).catch(() => {});
|
|
|
|
annotate({
|
|
action: { name: "fitbit.sleep.sync", details: { imported, removed } },
|
|
});
|
|
return imported;
|
|
}
|