Repository navigation
Description
Summary
Importing an activity file (.fit/.gpx/.tcx) persists its step count as a whole-day
dailyMetric.steps row keyed on (deviceId, day). The upsert overwrites unconditionally, so importing
a second activity file for the same day replaces the first file's step count rather than summing.
Two walks on one day report only the second walk's steps.
Where
Strand/Screens/DataSourcesView.swift (activity-file import) builds a whole-day DailyMetric from a
single activity:
if let steps = activity.steps, steps > 0 {
let day = Repository.localDayKey(activity.start)
let metric = DailyMetric(day: day, /* every other field nil */ steps: steps)
try? await store.upsertDailyMetrics([metric], deviceId: ActivityFileImporter.sourceId)
}Packages/WhoopStore/Sources/WhoopStore/MetricsCache.swift (~line 393) then overwrites on conflict:
ON CONFLICT(deviceId, day) DO UPDATE SET
...
steps = excluded.steps,Both rows share deviceId = "activity-file", so any two activity files on the same local day collide.
Reproduce
- Import a
.fitfor a morning walk (say 3,000 steps) →dailyMetric.steps= 3000 ✅ - Import a
.fitfor an evening walk the same day (say 8,834 steps) →dailyMetric.steps= 8834 ❌
6a6f8df964ea7178d07a7289.fit.zip
Expected 11,834 (or at minimum, not a silent loss of the morning walk).
Verified on a real DB: a single imported walk stores correctly —
deviceId day steps
activity-file 2026-08-02 8834 <- Suunto .fit, matches the device exactly
The parser side is correct and not at fault: FitParser reads FIT field 10 total_cycles and
doubles it to steps for foot sports (#568). The loss is purely in the day-level persistence.
Why it matters
- Silent data loss. No warning; the day simply under-reports, and the more active day (two sessions)
under-reports worse than a one-session day. - It contradicts how steps behave elsewhere:
AppleHealthAggregatordeliberately aggregates per day
(taking the max across sources, Morning recap: dedup on the reported night, not the calendar day (fixes #567 midnight re-fire) #589) rather than letting one sample clobber the day.
Suggested fix
Sum activity-file steps across the day rather than overwriting. Options, roughly in order of preference:
- Aggregate before the upsert — recompute the day's activity-file total from all imported workouts
on that day and write that. Keeps the storage contract unchanged and is idempotent on re-import of the
same file (important: a naivesteps = steps + excluded.stepswould double-count a re-imported file,
which the current overwrite at least handles correctly). - Persist steps per workout and derive the day total. Needs a
stepscolumn onworkout— see the
enhancement below, which subsumes this.
Whichever is chosen, please pin it with a test: import two same-day activity files and assert the day
total, plus re-import one of them and assert the total is unchanged (idempotency).
Related enhancement (separate, if preferred)
workout has no steps column, so a step count cannot be attributed to a specific session — only to a
day. Adding it would:
- make the fix above trivially correct and idempotent (day total = sum over that day's workouts);
- let a windowed step estimate be validated against ground truth automatically. This is directly
useful for the Oura work: the ring exposes no decodable step count (docs/OURA_PROTOCOL.md§6.13), so
NOOP's MET-derived walking-equivalent estimate can only be checked against an imported reference. Today
that comparison has to be done by hand each time because the reference is stored per-day while the
estimate is per-window.
Needs a versioned migration + a Room twin to keep the cross-platform schema contract.
Activity
ryanbr commentedon Aug 5, 2026
Confirmed and reproduced against the code — thanks, this is a precise report. Every claim checks out: the whole-day
DailyMetric(steps:)upsert atDataSourcesView.swift:576-593, thesteps = excluded.stepsoverwrite atMetricsCache.swift:408, andworkoutcarrying no per-session steps column.Two things to add:
It's on Android too.
ingest/ActivityFileImporter.ktdoes the same day-levelupsertDailyMetrics(DailyMetric(day, steps)), so this collides identically there. Any fix lands on both platforms under the schema-parity contract.Option 1 and Option 2 aren't separable. "Recompute the day's total from all imported workouts" has no source data to recompute from — per-session steps aren't persisted anywhere today, and the raw activity is discarded after import. So the genuinely idempotent fix is the steps-column enhancement:
stepsto theworkouttable (versioned GRDB migration + Room twin);DailyMetric.stepsasSUM(steps)over that day'sactivity-fileworkouts.Re-import stays idempotent because
upsertWorkoutsisON CONFLICT(...) DO UPDATE— a re-imported file replaces its own row's steps, so the re-summed total is unchanged.One caution for the implementation: don't reuse
AppleHealthAggregator's.max()(#589). That'smaxbecause iPhone+Watch double-report the same steps (cross-source dedup); activity files are distinct sessions, so this one is a sum. Same "aggregate, don't clobber" spirit, opposite function.I'll put up a cross-platform PR with the two tests you specified (two same-day files → sum; re-import one → unchanged).
fix: sum activity-file steps across a day instead of overwriting (#1088)
fix: sum activity-file steps across a day instead of overwriting (rya…
fix(workouts): carry per-session steps through every row rebuild (#1444)
chore(workouts): require steps explicitly at every WorkoutRow constru…