Skip to content

Activity-file import: a second file imported for the same day OVERWRITES the day's step count instead of adding to it #1058

Closed

Description

@pipiche38

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

  1. Import a .fit for a morning walk (say 3,000 steps) → dailyMetric.steps = 3000 ✅
  2. Import a .fit for 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

Suggested fix

Sum activity-file steps across the day rather than overwriting. Options, roughly in order of preference:

  1. 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 naive steps = steps + excluded.steps would double-count a re-imported file,
    which the current overwrite at least handles correctly).
  2. Persist steps per workout and derive the day total. Needs a steps column on workout — 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

  1. ryanbr commented on Aug 5, 2026

    @ryanbr
    Owner

    Confirmed and reproduced against the code — thanks, this is a precise report. Every claim checks out: the whole-day DailyMetric(steps:) upsert at DataSourcesView.swift:576-593, the steps = excluded.steps overwrite at MetricsCache.swift:408, and workout carrying no per-session steps column.

    Two things to add:

    It's on Android too. ingest/ActivityFileImporter.kt does the same day-level upsertDailyMetrics(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:

    • add steps to the workout table (versioned GRDB migration + Room twin);
    • store per-session steps at import;
    • write the day's DailyMetric.steps as SUM(steps) over that day's activity-file workouts.

    Re-import stays idempotent because upsertWorkouts is ON 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's max because 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).

  2. added a commit that references this issue on Aug 5, 2026
    5354835
  3. added a commit that references this issue on Aug 17, 2026
    cbbd0b2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions