Initial backfill
Imports the trailing year so goals, streaks, and trends have history from day one. Chunked, resumable, and re-runnable when the payload gains fields.
- Window
- 365 days
- Chunk
- 15 days per request
- Retries
- 3, with 1 s / 2 s backoff
Steps come from HealthKit on the phone, from Oura's cloud, and never from WHOOP. This is how those readings reach Supabase, how a day's canonical number is chosen, and what still can't be guaranteed.
HKSync.backfillDayCountbackfillWindowDayssyncRecentDays()foregroundSyncMinIntervalSTALE_AFTER_HOURSpickHighest()Every path into Protocol reads HealthKit with the same per-day summary function and posts to the same route. What differs is how many days each mode reads and what wakes it. Hold these three in mind and the rest of the document is detail.
Imports the trailing year so goals, streaks, and trends have history from day one. Chunked, resumable, and re-runnable when the payload gains fields.
A cheap single-day read keeps today live. A 14-day rolling re-read on cold launch and on the first sync after midnight settles closed days to their true totals.
Foreground: launch, re-activation, pull to refresh. Background: HealthKit observers when new samples land, and a server-sent silent push when a device goes quiet.
Four wake sources on one clock. The backfill isn't on this chart because it runs once, on the day Apple Health is connected.
To scale. The year strip is the backfill's territory; the zoomed strip is where the ongoing modes live. Anything older than 14 days is only ever touched by a backfill re-run.
Oura and WHOOP are pulled. The server holds OAuth tokens and an hourly Vercel cron fetches their APIs whether or not the phone is awake. Apple Health has no cloud API at all. HealthKit is readable only on the device, by the app, while the app has runtime. So Apple Health steps are pushed: the iOS app reads HealthKit and POSTs a day summary to the web API, and the server's only lever is to ask the phone, politely, to do that.
WHOOP does not expose steps through its API. Its sync writes steps: null on every activity row, so for steps the real contest is Apple Health versus Oura.
Two ingest paths, one merge. Apple Health is device-push; Oura is server-pull. Both land as per-source rows before the scorecard picks a winner.
Six things cause the iOS app to read HealthKit and post. They share one endpoint and one per-day summary function; they differ in which days they read and how often they may fire.
| Trigger | What runs | Days read | Throttle | Where |
|---|---|---|---|---|
| First permission grantOnboarding, the standalone Apple Health gate, or the Integrations tab | backfillIfNeeded() | 365 | Once per user per backfill version | HealthKitManager.connectThenSyncInBackground |
Cold launchMainTabView's .task when already authorized | resumeIfAuthorized() → re-register observers → syncRecentDays(14) | 14 | None (once per launch) | HealthKitManager.resumeIfAuthorized |
| Return to foregroundAny warm re-activation: app switcher, tapping the icon | syncOnForeground() → syncToday() | 1up to 14 on a day rollover | Skipped if last sync < 120 s ago | ProtocolApp scenePhase → HKSync.syncOnForeground |
| Pull to refreshToday tab | syncRecentDays(3), plus a server-side /api/sync/all?days=2 for Oura and WHOOP | 3 | User-initiated | TodayView.refreshable |
| HealthKit background deliveryiOS wakes the app when a new stepCount sample lands | HKObserverQuery → syncToday() | 1 | frequency: .immediate; iOS decides actual cadence | HealthKitManager.registerBackgroundDelivery |
Silent push wakeServer cron sends content-available to stale devices | didReceiveRemoteNotification → syncToday() | 1up to 14 on a day rollover | Server: stale ≥ 4 h, ≥ 4 h between wakes, 24 h after 3 unanswered | AppDelegate + lib/push/silentSyncWake.ts |
The day-rollover promotion. syncToday() checks how many calendar days have closed since the last successful sync. If any have, it promotes itself to syncRecentDays(min(closed + 1, 14)) so yesterday settles to its true final total instead of freezing at whatever partial count the last observer callback saw. This fires at most once per day; the rest of the day stays on the cheap single-day read.
After the first HealthKit permission grant the app imports the trailing year, matching the 365-day window Oura and WHOOP get on connect. It runs behind the UI so onboarding proceeds immediately; the Today tab shows an “Importing…” card driven by HKSync.backfillProgress.
summary(for:) function, which produces the full day payload (steps plus every other metric). Days with no HealthKit data are dropped.{ days: [...] }. Three attempts with 1 s, then 2 s backoff. If all three fail, the cursor stays put and the next launch resumes this exact window.lastSyncedAt is stamped.| Constraint | Value | Why |
|---|---|---|
| Window per request | 15 days | The server runs updateDailyScorecard per day inside a 60 s function cap; a 30-day window could breach it and fail the chunk. |
| Server batch ceiling | 120 days | MAX_BATCH_DAYS rejects larger payloads with 400. |
| Retries per window | 3 | Backoff 1 s, 2 s. Leaves the cursor untouched on final failure. |
| Completion flag | per user id | A device-wide flag once caused a second account on the same phone to skip its own import. |
| Current backfill version | 8 | Bumping re-runs the idempotent import when the payload gains fields. v8 added sleep timing and hourly glucose; steps have been in the payload since v1. |
Doc drift to be aware of. A comment on HealthKitManager.connectAndSync still says “90-day backfill”, and apps/ios/ARCHITECTURE.md shows an older 8-field payload. The code constant is 365 and the payload is the rich day summary described below. Trust HKSync.swift.
After the backfill, two shapes of sync keep steps current.
syncToday() reads only the in-progress day and posts it as a single-day body. Fired by HealthKit observers, by every foreground re-activation (subject to the 120 s throttle), and by the silent push.
Profile characteristics (age, sex, height) ride along on each call because they're cheap and idempotent.
syncRecentDays(14) re-reads today and the previous 13 days and posts them as one batch. Fired on every cold launch, and by syncToday() itself on the first sync after a calendar-day rollover. Pull-to-refresh uses a 3-day version.
Its job is to correct days that were frozen mid-count and to fill short gaps where the app never ran. Long gaps are the backfill's job, not this path's.
The reason the rolling window exists is worth stating plainly: the server overwrites the Apple Health row for a day on each post. It does not keep the higher of the old and new value. So a day is correct only if it was read after it closed. Re-reading yesterday on the first sync of each morning is what makes yesterday's total trustworthy.
syncToday(), throttled to once per 120 s so tab switches and sheet dismissals don't hammer HealthKit. Posts .healthDataSynced only when data actually changed, so the Today view reloads on real movement rather than on every app switch.enableBackgroundDelivery(frequency: .immediate) plus an HKObserverQuery per read type. When a new sample lands, iOS grants brief runtime and the observer calls syncToday(). Requires the healthkit.background-delivery entitlement, which the app has.remote-notification in UIBackgroundModes, which the app also has. Roughly 30 s of runtime.Observers die silently. HKObserverQuery registrations live in process memory. After iOS terminates the app for memory pressure, a reboot, or a force-quit, they are gone until the next launch, and nothing on the server can tell. This is the gap the silent push was built to cover. It is also why the daily email's stale-sync banner still tells Apple Health users to leave the app running rather than swiping it away.
An hourly Vercel cron at minute 20 finds Apple Health users whose data has gone stale and sends each device an invisible content-available push. The payload is exactly { "aps": { "content-available": 1 } }, with no alert, sound, or badge. Its only purpose is to hand the app runtime.
data_source_connections.source = 'apple_health' connected, a row in device_push_tokens, and last_synced_at at least 4 hours old. Oura-only and WHOOP-only devices are never woken; the cron already has their data.last_synced_at past every prior wake and normal spacing resumes.push_sends row per user records the campaign for the admin history.didReceiveRemoteNotification checks HealthKit is available and authorized, runs syncToday() (which promotes to the rolling window if days rolled over), and returns .newData only if lastSyncedAt actually moved. iOS uses that verdict to decide how generously to wake the app in future, so the app reports honestly.One answered sync resets everything: last_synced_at moves past the prior wakes, the unanswered count returns to zero, and 4-hour spacing resumes.
| Constant | Value | Defined in |
|---|---|---|
STALE_AFTER_HOURS | 4 | apps/web/src/lib/push/silentSyncWake.ts |
MIN_HOURS_BETWEEN_WAKES | 4 | |
BACKOFF_AFTER_UNANSWERED | 3 | |
BACKOFF_HOURS | 24 | |
WAKE_HISTORY_DAYS | 7 | |
| Cron schedule | 20 * * * * | vercel.json |
Push registration is deliberately decoupled from alert permission. The app registers for remote notifications even when the user declined alerts, because a device token is all a silent push needs. Declining a notification prompt should not cost someone their health sync.
The measurement that motivated it (live accounts, 2026-08-05): Oura and WHOOP were each 7 of 7 synced within 24 hours with a median of 0.2 h since last sync. Apple Health was 3 of 17, with a median of 85.2 h, about 3.5 days. The silent wake raises the floor. It is not a guarantee: iOS drops background pushes in Low Power Mode, deprioritizes rarely opened apps, and never delivers one to an app the user force-quit.
Every sync path calls the same summary(for: date) function. For steps it issues one HKStatisticsQuery on stepCount with .cumulativeSum, bounded to the calendar day in the device's current time zone.
// HKSync.summary(for:) — the steps read, simplified let startOfDay = Calendar.current.startOfDay(for: date) let endOfDay = cal.date(byAdding: .day, value: 1, to: startOfDay)! let dayPredicate = HKQuery.predicateForSamples( withStart: startOfDay, end: endOfDay, options: .strictStartDate) async let steps = readSum(store, type: .init(.stepCount), unit: .count(), predicate: dayPredicate) // readSum → HKStatisticsQuery(options: .cumulativeSum) // sum > 0 ? sum : nil // a zero day is sent as nil
.separateBySource, HealthKit applies its own overlap resolution across the user's devices, and the sum matches the number the Health app shows. Protocol never sees per-device step samples and does not try to dedupe them itself..strictStartDate: a sample counts once, on the day it starts, so multi-day interval samples are never double-counted across days.steps ≤ 0. A missing read looks like a sync gap, not a real zero.YYYY-MM-DD from Calendar.current. The server trusts it; it falls back to today in US Eastern only if the string is malformed.Steps live in a per-source table, then in a merged daily row, and are finally re-checked at read time on the web dashboard. Nothing is stored at the sample level; the unit of storage is a day.
One day, or { days: [...] } up to 120. JWT-authenticated as the session user.
| date | steps | … |
|---|---|---|
| 2026-09-02 | 11842 | +22 fields |
| 2026-09-03 | 3096 | +22 fields |
activity_recordsUpsert on (user_id, date, source). Apple and Oura never overwrite each other.
| date | source | steps |
|---|---|---|
| 2026-09-02 | apple_health | 11842 |
| 2026-09-02 | oura | 8902 |
| 2026-09-02 | whoop | null |
daily_scorecardsupdateDailyScorecard re-merges every day the post touched. One row per (user_id, date).
| date | steps | read by |
|---|---|---|
| 2026-09-02 | 11842 | web · iOS · email |
The iOS app posts either one day or a batch, authenticated with the user's Supabase JWT. The user is always the session user; there is no shared-secret mode.
// POST /api/sync/apple-health (steps-relevant fields shown) { "days": [ { "date": "2026-09-02", "steps": 11842, "active_calories": 612, "basal_calories": 1710, ... }, { "date": "2026-09-03", "steps": 3096, "active_calories": 148, ... } ] }
One row per user, per day, per source. The Apple Health handler upserts with source = 'apple_health'; the Oura sync upserts the same shape with source = 'oura'. WHOOP writes rows too, always with null steps.
create table activity_records ( id uuid primary key, user_id uuid not null references auth.users(id), date date not null, source text not null check (source in ('oura','apple_health','whoop','garmin','manual')), steps integer, active_calories integer, total_calories integer, active_minutes integer, water_oz numeric, score integer, raw_data jsonb, -- { "method": "native_ios" } for Apple Health created_at, updated_at timestamptz, unique(user_id, date, source) ); -- RLS: users own their rows. The ingest route writes with the service role.
After every day it writes, the ingest route calls updateDailyScorecard(userId, date). That function reads all of the day's source rows, applies the per-metric rules, and upserts one row on (user_id, date). Its steps column is what the web dashboard, iOS Today tab, rings, streaks, daily email, and coach all read. The route then recomputes weekly workout counts and per-metric streaks, and stamps data_source_connections.last_synced_at for apple_health, which is the timestamp the silent-wake cron and the stale banner watch.
getDashboardData re-reads today's activity_records and applies the same highest-value rule via pickHighestRecord. If a source row landed after the last scorecard merge, the dashboard shows it without waiting. It also derives activitySource, the label shown next to the step count. iOS does not re-resolve values; it displays the server-merged scorecard and mirrors the priority order only to pick the source label.
| Table | Role for steps | Key |
|---|---|---|
activity_records | Per-source day totals. Apple Health and Oura each own their row. | (user_id, date, source) |
daily_scorecards | Canonical merged day. steps is the number every surface shows. | (user_id, date) |
user_metric_source_preferences | Optional per-metric pin: metric = 'steps', preferred_source or 'auto'. | (user_id, metric) |
data_source_connections | Connected flag and last_synced_at per source. Drives staleness. | (user_id, source) |
device_push_tokens | APNs tokens plus environment, upserted by the app over RLS. | token |
push_sends | Wake history for the silent_sync_wake campaign; used to count unanswered wakes. | append-only |
Most metrics use a fixed priority list and take the first source with data. Sleep is Oura first; HRV is WHOOP first. Steps and active calories are the exception: the highest reported value wins. The reasoning is that a phone or watch that counted 12,000 steps has a more complete view than a ring that saw 8,000, and a source that reports zero almost always missed the day rather than observed stillness.
Bars share one scale (12,418 = full width). The filled bar is the value written to daily_scorecards.steps.
// lib/scorecard/updateScorecard.ts — the write-time rule function pickHighest(records, field, prefKey, defaultOrder) { const nonZero = records.filter(r => Number(r[field] ?? 0) > 0); const pool = nonZero.length > 0 ? nonZero : records; if (prefs[prefKey]) return pick(pool, field, buildOrder(defaultOrder, prefKey)); return max over pool[field]; } steps = pickHighest(activities, 'steps', 'steps', ['apple_health', 'garmin', 'whoop', 'oura']);
| Situation | Apple Health | Oura | Preference | Scorecard steps | Attributed to |
|---|---|---|---|---|---|
| Both synced, phone counted more | 12,418 | 8,902 | auto | 12,418 | apple_health |
| Ring counted more (phone left at desk) | 4,110 | 9,377 | auto | 9,377 | oura |
| Apple Health hasn't synced today | — | 6,540 | auto | 6,540 | oura |
| Apple row exists with 0 (filtered) | 0 | 6,540 | auto | 6,540 | oura |
| User pinned Oura for steps | 12,418 | 8,902 | oura | 8,902 | oura |
| Only Apple Health connected | 7,206 | — | auto | 7,206 | apple_health |
| WHOOP only | — | — | auto | null | no steps |
The same rule runs in two places on purpose. The write-time merge makes the stored scorecard correct for iOS and email, which never re-derive. The read-time pass on web makes today feel live. Both are registered in the cross-platform parity doc (section 7, and the resolved drift item DR-3 from 2026-07-14, when the write-time side was brought into line with the dashboard).
User control. Settings shows a per-metric source picker only for metrics where two or more connected sources appear in the priority list. For a user with Apple Health and Oura, “Steps” appears with options Auto, Apple Health, Oura. Saving writes user_metric_source_preferences and the next merge honours it.
| apple_health | oura | whoop | |
|---|---|---|---|
| Steps available | Yes | Yes, from daily_activity | No. API has no steps; rows carry steps: null |
| Direction | Device push (app reads HealthKit, POSTs) | Server pull (stored OAuth token) | Server pull (stored OAuth token) |
| Scheduled cadence | None the server controls. Observers, foreground, and the hourly silent wake for stale devices. | Hourly cron at :50, last 7 days, every connected user | Same hourly cron |
| Connect backfill | 365 days, on device, in 15-day chunks | 365 days via /api/sync/oura?days=365 fired from the OAuth callback | 365 days |
| Pull to refresh | 3-day device sync | 2-day server pull | 2-day server pull |
| Day attribution | Device-local calendar day | Oura's day field, user-local | Local date of the record |
| Freshness observed | 3 of 17 within 24 h before the silent wake shipped | 7 of 7 within 24 h | 7 of 7 within 24 h |
| Default rank for steps | 1st | 4th (Garmin and WHOOP sit between, neither live for steps) | n/a |
| Effective rule | Highest non-zero value wins unless pinned | Never contributes | |
For someone wearing an Apple Watch and an Oura ring, the practical outcome is: Apple Health usually wins because the phone plus watch see more of the day, Oura fills in when the app hasn't posted yet, and the number can go up when Apple's row finally lands but will not go down.
STALE_SYNC_HOURS).last_synced_at going stale is the only symptom./api/sync/all still says “Apple Health is push-only (iOS shortcut)”. The Shortcut path was removed in July 2026; the native app is the only ingest and JWT is the only auth.| Concern | File |
|---|---|
| HealthKit permissions, observers, background delivery, launch resume | apps/ios/Protocol/Protocol/Core/HealthKit/HealthKitManager.swift |
| Backfill, today sync, foreground throttle, rolling window, per-day summary | apps/ios/Protocol/Protocol/Core/HealthKit/HKSync.swift |
| Scene-phase foreground trigger, silent push handler | apps/ios/Protocol/Protocol/App/ProtocolApp.swift |
| APNs registration and token upsert | apps/ios/Protocol/Protocol/Core/Push/PushManager.swift |
| Pull-to-refresh, iOS source label resolution | apps/ios/Protocol/Protocol/Features/Today/TodayView.swift |
| Background modes and HealthKit entitlements | apps/ios/Protocol/Protocol/Info.plist · Protocol.entitlements |
| Apple Health ingest route | apps/web/src/app/api/sync/apple-health/route.ts |
| Canonical merge | apps/web/src/lib/scorecard/updateScorecard.ts |
Priority map, pickHighestRecord, pickBestRecord | apps/web/src/lib/data-source-priority.ts |
| Read-time freshness pass and source label | apps/web/src/lib/dashboard/getDashboardData.ts |
| Silent wake selection and constants | apps/web/src/lib/push/silentSyncWake.ts |
| APNs dispatch, background payload | apps/web/src/lib/push/apns.ts |
| Silent wake cron route | apps/web/src/app/api/cron/silent-sync-wake/route.ts |
| Oura pull | apps/web/src/lib/oura/sync.ts · app/api/sync/{oura,all}/route.ts |
| Cron schedules | vercel.json |
| Schema | supabase/migrations/20260301000001_initial_schema.sql · 20260301000008_data_source_priority.sql · 20260301000009_user_metric_source_preferences.sql |
| Settings picker | apps/web/src/components/settings/DataSourcePreferences.tsx |
| Parity registry (section 7) | docs/cross-platform-parity.md |