mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user