Files
HealthLog/src/lib/fitbit/mapping.md
T
Marc-André Bombeck 61573301c3 fix(fitbit): single sync watermark, reject empty refresh token, correct interval time-anchor, mark provider experimental
Snapshot the incremental watermark once per sync cycle and thread the same
lower bound to every resource (metrics, activity, sleep, workout), stamping
lastSyncedAt once at the end. Previously each resource read lastSyncedAt then
stamped now() independently, so the first resource advanced the watermark and
later resources only fetched the last overlap window — silently dropping the
gap after an outage longer than the overlap. The full/backfill path is
unaffected (it passes no lower bound).

Reject an OAuth callback whose token response carries no refresh_token instead
of persisting an empty string. An empty refresh token bricks every future
token refresh; the callback now errors out cleanly so the user re-consents
rather than landing a dead connection.

Model the INTERVAL data types (steps/distance/calories/floors daily totals,
sleep and exercise sessions) on Google Health's interval.start_time /
interval.civil_start_time anchor instead of a sample_time / bare date. The
incremental filter for sleep/exercise previously targeted
sample_time.physical_time, which 400s/empties for interval types and stalls
incremental sync. Daily-summary metrics and spot readings keep their existing
date / sample anchors. Shape mismatches still fail safe (return []/null).

Mark the Google Health (Fitbit & Pixel) settings card as experimental with a
badge and a short note that data coverage is still being verified, in all six
locales.

(cherry picked from commit 3f116802149eebe9144729e2779f7f4405f248f2)
2026-06-05 01:05:47 +02:00

7.0 KiB
Raw Blame History

Fitbit / Google Health field → Measurement mapping

Single source of truth for the Fitbit (Google Health API v4) data-type → HealthLog Measurement mapping. Keep this in sync with FITBIT_FIELD_MAP and the per-type mappers in client.ts. Every Fitbit row ingests server-side with source = FITBIT and externalId = <anchor>:<fieldTag> (the anchor is the data point's sample time for spot readings, its civil date for daily summaries, or its interval.start_time for INTERVAL types — steps/distance/calories/floors, sleep, exercise; the field-tag disambiguates the metric).

Time anchor by grain (design §A.1)

Google Health exposes three time-anchor shapes. The incremental filter and the read-time measuredAt/externalId resolution must target the right one or the incremental sync stalls (a sample_time filter 400s/empties for an INTERVAL type):

  • sample — spot reading; anchor {filter}.sample_time.physical_time.
  • date — daily-summary metric (SpO2, HRV, RHR, respiratory rate, wrist temp, VO2 max); anchor {filter}.date (a civil date / {year,month,day}).
  • interval — INTERVAL type (steps/distance/calories/floors daily totals, plus sleep + exercise sessions); anchor {filter}.interval.start_time (a physical instant) with {filter}.interval.civil_start_time as the civil fallback.

Transport docs: developers.google.com/health (v4, post-Fitbit-Web-API). The per-type value-field JSON is NOT fully published — the mappers read a small set of candidate value paths defensively (firstNumber walks each shape and takes the first finite-positive hit). Re-verify against a live test account at build and tighten the extractors. Design: .planning/v1.12.0-google-health-design.md.

Casing gotcha (design §A.1)

The data-type id is kebab-case in the request path (body-fat) and snake_case in the filter predicate (body_fat). FITBIT_DATA_TYPES pins both forms per type so a fetcher can never encode the wrong one.

Health-metrics bundle (W3)

Scope: googlehealth.health_metrics_and_measurements.readonly.

Data type (path / filter) MeasurementType Unit fieldTag Grain Note
weight / weight WEIGHT kg weight sample Picker ranks a real Withings scale above Fitbit.
body-fat / body_fat BODY_FAT % body_fat sample
daily-oxygen-saturation / daily_oxygen_saturation OXYGEN_SATURATION % spo2 daily Already 0–100.
daily-heart-rate-variability / daily_heart_rate_variability HEART_RATE_VARIABILITY ms hrv daily Decision: SDNN slot (Apple-comparable), NOT HRV_RMSSD. Fitbit reports an RMSSD-style nightly HRV — confirm the estimator at build and reconsider HRV_RMSSD if warranted.
daily-resting-heart-rate / daily_resting_heart_rate RESTING_HEART_RATE bpm rhr daily
daily-respiratory-rate / daily_respiratory_rate RESPIRATORY_RATE breaths/min resp_rate daily
heart-rate / heart_rate PULSE bpm hr sample Intraday spot HR.
daily-sleep-temperature-derivations / daily_sleep_temperature_derivations WRIST_TEMPERATURE celsius wrist_temp daily Closest semantic slot to Apple sleeping-wrist-temp. Confirm absolute-vs-baseline at build; the positive guard rejects a baseline-delta reading.
height / height User.heightCm cm — sample Profile seed — written to User.heightCm ONLY when null; never minted as a Measurement (WHOOP mapBody pattern). m→cm when reported in metres.

Every value passes a finite + strictly-positive guard (the launch metrics are all positive — a zero/NaN is a garbage/empty reading and is dropped).

Deferred (Google Health Q3-2026 roadmap — slots exist, NOT in launch set)

blood-glucose → BLOOD_GLUCOSE, blood-pressure → BLOOD_PRESSURE_SYS+_DIA, basal-body-temperature → BODY_TEMPERATURE. These light up for free when Google ships the data types.

Activity bundle (W5) — daily cumulative

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. 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.