fix(workouts): one canonical-workout picker with one source order

Three pickers decided which of two overlapping workouts to keep: the
read-time picker the list, the detail page and the Coach use, a
write-time batch picker with its own 90-second window, and an unwired
field-merge picker. They used different source orders (the `steps`
ladder, or a separate workout default when no order was saved), so the
batch route could keep a row the list then ranked below another.

The batch route now runs the read-time picker over the rows it is about
to insert, with the ladder from `workoutSourceLadder`, and reports what
it hides as duplicates. A session HealthKit hands over twice under two
ids is stored once, as the list already showed it. The write-time and
the unwired picker are removed.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 04:37:39 +02:00
1 parent 2861075676
commit e355c6120f
8 files changed
+109 -71

No files matched your search

@@ -14,11 +14,9 @@
* logical run start within a minute of each other on both
* sources; the rounding gives the cross-source matcher slack
* without collapsing distinct back-to-back workouts.
* 2. For each bucket, walk the SOURCE priority ladder
* (`APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT` per the existing
* source-priority validator's default) and pick the FIRST
* source that has any row in that bucket. Keep every row from
* the winning source; drop the rest.
* 2. For each bucket, walk the source ladder (`workoutSourceLadder`)
* and pick the FIRST source that has any row in that bucket. Keep
* every row from the winning source; drop the rest.
* 3. Rows whose source isn't in the ladder pass through untouched
* (fallback intent — never silently drop signal).
*
@@ -26,10 +24,10 @@
* (typically `ORDER BY startedAt ASC, id ASC` from the caller's
* Prisma read). The output preserves input order.
*
* Future v1.5.x tune: metric-aware picker per R-F open question #4 —
* route → Apple wins, HR zones → Withings wins. Out of scope for
* v1.4.30; the existing ladder ships first so the dashboard tile is
* unambiguous.
* This is the one workout picker. The reads use it, and so does the batch
* write route before it inserts: a write-time picker with its own window
* and a different default ladder kept or dropped a different row than the
* list then showed.
*/
import type { MeasurementSource } from "@/generated/prisma/client";
@@ -249,6 +247,17 @@ function enrichWinnersFromWhoop<T extends WorkoutPickerRow>(
return enriched;
}
/**
* The source order for workouts: the `steps` metric's ladder, the
* canonical-activity bucket the Sources settings configure, falling back to
* its default when the user saved none or it does not parse.
*/
export function workoutSourceLadder(
userPriorityJson: unknown,
): readonly MeasurementSource[] {
return getSourceLadder(parseSourcePriority(userPriorityJson), "steps");
}
/**
* Pick canonical rows across sources for a user's workouts.
*
@@ -266,14 +275,7 @@ export function pickCanonicalWorkoutRows<T extends WorkoutPickerRow>(
): T[] {
if (rows.length === 0) return [];
// Workouts ride the `steps` metric's source ladder — the
// canonical-activity bucket. The user's iOS Settings → Sources UI
// configures the same ladder for the workout dashboard tile;
// unifying on "steps" keeps a single configuration surface rather
// than introducing a new `workouts` metric key. Future ladders for
// workout-specific tunes (route-vs-zones) ride a v1.5.x change.
const resolved = parseSourcePriority(userPriorityJson);
const sourceLadder = getSourceLadder(resolved, "steps");
const sourceLadder = workoutSourceLadder(userPriorityJson);
const buckets = new Map<
string,
+8 -6
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { DEFAULT_WORKOUT_SOURCE_PRIORITY } from "@/lib/sources/pick-canonical-workout";
import {
pickCanonicalWorkoutRows,
workoutSourceLadder,
} from "@/lib/measurements/pick-canonical-workout-rows";
import { DEFAULT_SOURCE_PRIORITY } from "@/lib/validations/source-priority";
/**
@@ -68,10 +70,10 @@ describe("POLAR workout dedup — read-time canonical picker", () => {
});
describe("POLAR sits in the workout source ladders", () => {
it("is present in the workout default ladder, below WITHINGS, above STRAVA", () => {
const polar = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("POLAR");
const withings = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("WITHINGS");
const strava = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("STRAVA");
it("is present in the workout ladder, below WITHINGS, above STRAVA", () => {
const polar = workoutSourceLadder(null).indexOf("POLAR");
const withings = workoutSourceLadder(null).indexOf("WITHINGS");
const strava = workoutSourceLadder(null).indexOf("STRAVA");
expect(polar).toBeGreaterThan(withings);
expect(polar).toBeLessThan(strava);
});
+8 -6
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { DEFAULT_WORKOUT_SOURCE_PRIORITY } from "@/lib/sources/pick-canonical-workout";
import {
pickCanonicalWorkoutRows,
workoutSourceLadder,
} from "@/lib/measurements/pick-canonical-workout-rows";
import { DEFAULT_SOURCE_PRIORITY } from "@/lib/validations/source-priority";
/**
@@ -67,10 +69,10 @@ describe("STRAVA workout dedup — read-time canonical picker", () => {
});
describe("STRAVA sits in the workout source ladders", () => {
it("is present in the workout default ladder, below the wearables, above MANUAL", () => {
const strava = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("STRAVA");
const withings = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("WITHINGS");
const manual = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("MANUAL");
it("is present in the workout ladder, below the wearables, above MANUAL", () => {
const strava = workoutSourceLadder(null).indexOf("STRAVA");
const withings = workoutSourceLadder(null).indexOf("WITHINGS");
const manual = workoutSourceLadder(null).indexOf("MANUAL");
expect(strava).toBeGreaterThan(withings);
expect(strava).toBeLessThan(manual);
});
+4 -5
View File
@@ -7,11 +7,10 @@
* to write `summary.sport_type ?? summary.type ?? detail?.sport_type ??
* "workout"` straight into `Workout.sportType`, so a Strava "Run" or
* "VirtualRide" never matched a canonical bucket — `/insights/workouts`
* rendered every Strava row generic, and the read-time picker in
* `src/lib/sources/pick-canonical-workout.ts` treats any two GENERIC rows
* as sport-compatible, so a same-source brick session (a Strava "Ride"
* ending 15:00 immediately followed by a Strava "Run" starting 15:03)
* clustered into one and silently dropped a leg. Fitbit
* rendered every Strava row generic, and a picker that treats two generic
* rows as the same sport clusters a same-source brick session (a Strava
* "Ride" ending 15:00 immediately followed by a Strava "Run" starting
* 15:03) into one and silently drops a leg. Fitbit
* (`mapFitbitSportType()` in `src/lib/fitbit/client.ts`), Google Health
* (`mapGoogleHealthSportType()` in `src/lib/google-health/mappers.ts`), and
* WHOOP (`mapWhoopSportType()` in `src/lib/whoop/sport-map.ts`) all