fix(workouts): resolve duplicate clusters the same way on every surface

The cross-source picker anchors each duplicate cluster on its first start,
so which cluster a workout lands in depends on the workouts around it. The
detail route still read the old fixed five-minute clock slot, and every
other reader handed the picker only the rows of its own range. The detail
of a twin that straddled a slot boundary declared itself canonical while
the list hid it, and runs at 09:58, 10:02 and 10:06 were grouped one way by
a read starting at 09:55 and another by a read starting at 10:00.

Every reader that decides which workout is canonical (list, detail and its
previous session, activity calendar, day view, doctor report, Coach,
briefing and correlation blocks, strain, per-sport context, the activity
insight's history, the batch write) now reads through one windowed reader.
It reads a duplicate window past the end of the range and reaches back
until every sport in the range starts at a workout that is an anchor
whatever came before it, then picks over everything read and keeps the
rows that start inside the range. The per-sport baseline and the insight
history leave out the whole cluster of the current session instead of the
row alone, which left its twin behind as a session of its own. The batch
write clusters its rows together with the stored workouts around them and
drops only a row that loses to another row of the same batch.

A property test checks the windowed read against a whole-history read for
random dense histories and ranges; route tests pin list, detail, deep link,
previous session and the batch write against the database.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 18:21:02 +02:00
1 parent 3decab47a0
commit 466dfb3785
33 files changed
+1486 -472

No files matched your search

+5 -3
View File
@@ -4985,9 +4985,11 @@ paths:
- Measurements
summary: List workouts (v1.4.32)
description: "Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user
source-priority ladder and buckets by `(startedAt 5 min slot, sportType)` — twin workouts (Apple Watch +
Withings ScanWatch) collapse to a single canonical row per cluster. Field names mirror the iOS handoff contract:
`distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`."
source-priority ladder and clusters workouts of one sport whose starts lie less than five minutes after the
cluster's first start — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per
cluster. A twin just outside `since` / `until` still decides which row inside the range is canonical, so the
list agrees with `canonicalId` on the detail. Field names mirror the iOS handoff contract: `distanceM`,
`activeEnergyKcal`, `avgHr`, `maxHr`."
parameters:
- in: query
name: limit