Files
HealthLog/src/lib/fitbit/sync-sleep.ts
T
Marc-André BombeckandClaude Opus 4.8 65f90160c7 fix(daily): refresh the Today digest after a dose, drop the hero ring cluster
The Today digest went stale after logging a dose: nothing invalidated the
`["daily","digest"]` query, and it reads meds from the same server snapshot
the intake routes already hard-evict — so the "dose due" rail item lingered
until a hard reload. Add the digest key to the medication-dependent bundle and
force the inactive digest (unmounted while the user is on the medication card)
to refetch alongside the dashboard snapshot; the server snapshot eviction seam
is reused, so the refetch returns post-write dose state immediately.

Remove the score-ring cluster from the Today hero — it read as uneven and
wasted tile space. The health-score ring is the hero's only ring now; the
score-ring selection contract stays server-side for the native client. Tighten
the hero's vertical rhythm so the worth-a-look rail sits close under the lead
instead of floating below a half-empty column.

Wire the remaining sleep transports into the morning-refresh trigger: Google
Health, Fitbit, Oura, and Polar now enqueue the debounced morning refresh on a
last-night segment landing, matching the Withings / WHOOP / Apple seams — those
sources had no trigger at all. Make the trigger observable: each early-return
gate now annotates a distinct `daily.morning_refresh.skipped` reason (the
not-last-night gate also carries the samples' resolved local dates, today's key
and the tz) so a real landing shows exactly which gate fired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
2026-07-17 11:00:22 +02:00

95 lines
3.3 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";
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[] = [];
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, tz)) {
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`).
// S4 — trigger the debounced morning refresh on a last-night segment landing
// (mirrors the Withings / WHOOP / Apple seams).
void maybeEnqueueMorningRefresh(
userId,
readings
.filter((r) => r.type === "SLEEP_DURATION")
.map((r) => r.measuredAt),
).catch(() => {});
annotate({ action: { name: "fitbit.sleep.sync", details: { imported } } });
return imported;
}