feat(workouts): pick canonical rows across Apple Watch and Withings sources

R-F T1.1 close-out for the dashboard "Recent workouts" tile. Same
intent as pickCanonicalSourceRows() for measurements, applied to
workouts. A user wearing an Apple Watch + a Withings ScanWatch
records the same run via each integration; without the picker the
dashboard would show the run twice.

Bucket key = (5-min-rounded startedAt, sportType). The 5-minute slot
absorbs the clock skew between HK and Withings without collapsing
two back-to-back runs into one bucket (a user does not run twice in
a five-minute window in practice).

Source-priority ladder ride the existing measurement "steps" key —
unifying on one Settings UI rather than introducing a new "workouts"
metric key. Default ladder is APPLE_HEALTH > WITHINGS > MANUAL >
IMPORT per the source-priority validator's canonical default.
Metric-aware tunes (route → Apple wins, HR zones → Withings wins)
defer to v1.5.x per R-F open question #4.

iOS contract: helper is read-side only — no API surface change. The
workout batch ingest route (verified in v1.4.25 W8d) keeps the
existing rate-limit + idempotency + 5 MB body cap + 100-workout
batch cap. /api/workouts/batch is the iOS endpoint going into v1.5.

Picker exercises empty-input, single-row, cross-source overlap,
sport-discriminator-keeps-distinct, > 5-minute-distinct, user-
supplied ladder honoured, manual-only fallback, and an unrecognised
ladder fallthrough.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 12:11:20 +02:00
1 parent 11325e31a0
commit 57abfbfb90
2 files changed
+337

No files matched your search

@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest";
import { pickCanonicalWorkoutRows } from "../pick-canonical-workout-rows";
interface RowFixture {
id: string;
startedAt: Date;
sportType: string;
source: "APPLE_HEALTH" | "WITHINGS" | "MANUAL" | "IMPORT";
}
describe("pickCanonicalWorkoutRows", () => {
it("returns the empty list unchanged for an empty input", () => {
expect(pickCanonicalWorkoutRows([])).toEqual([]);
});
it("keeps a single workout regardless of source", () => {
const rows: RowFixture[] = [
{
id: "w-1",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "WITHINGS",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual(["w-1"]);
});
it("prefers APPLE_HEALTH over WITHINGS when both source the same workout", () => {
const rows: RowFixture[] = [
{
id: "apple",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "withings",
startedAt: new Date("2026-05-16T08:01:30Z"), // 90 s apart — same 5-min slot
sportType: "running",
source: "WITHINGS",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual(["apple"]);
});
it("does NOT collapse two distinct runs whose starts are > 5 min apart", () => {
const rows: RowFixture[] = [
{
id: "first",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "second",
startedAt: new Date("2026-05-16T08:06:00Z"), // 6 min after start — separate bucket
sportType: "running",
source: "APPLE_HEALTH",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual([
"first",
"second",
]);
});
it("keeps workouts of different sports that start in the same minute", () => {
const rows: RowFixture[] = [
{
id: "run",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "cycle",
startedAt: new Date("2026-05-16T08:01:00Z"),
sportType: "cycling",
source: "APPLE_HEALTH",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id).sort()).toEqual([
"cycle",
"run",
]);
});
it("preserves input order on the output", () => {
const rows: RowFixture[] = [
{
id: "morning",
startedAt: new Date("2026-05-16T06:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "evening",
startedAt: new Date("2026-05-16T18:00:00Z"),
sportType: "cycling",
source: "APPLE_HEALTH",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual([
"morning",
"evening",
]);
});
it("honours a user-supplied ladder that promotes WITHINGS over APPLE_HEALTH", () => {
const rows: RowFixture[] = [
{
id: "apple",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "withings",
startedAt: new Date("2026-05-16T08:01:00Z"),
sportType: "running",
source: "WITHINGS",
},
];
const userPriority = {
metricPriority: {
steps: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "IMPORT"],
},
};
expect(
pickCanonicalWorkoutRows(rows, userPriority).map((r) => r.id),
).toEqual(["withings"]);
});
it("keeps the manual entry when no priority-ladder source claims the slot", () => {
// Edge case: a user with both APPLE_HEALTH and WITHINGS still
// manually recorded an "elliptical" workout the integrations
// don't track. Manual wins by default (only entry in bucket).
const rows: RowFixture[] = [
{
id: "manual",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "elliptical",
source: "MANUAL",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual(["manual"]);
});
it("falls through to keeping every row when sources aren't on the ladder at all", () => {
// Theoretical safety net — no real source emits a value outside
// the canonical enum, but the picker MUST NEVER drop signal it
// can't classify. Forcing a ladder of a single source the bucket
// doesn't carry exercises the fallback path.
const rows: RowFixture[] = [
{
id: "a",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "b",
startedAt: new Date("2026-05-16T08:01:00Z"),
sportType: "running",
source: "WITHINGS",
},
];
const oddPriority = {
metricPriority: {
steps: ["IMPORT"],
},
};
expect(
pickCanonicalWorkoutRows(rows, oddPriority)
.map((r) => r.id)
.sort(),
).toEqual(["a", "b"]);
});
});
@@ -0,0 +1,157 @@
/**
* v1.4.30 — cross-source canonical-row picker for `Workout` rows.
*
* Same intent as `pickCanonicalSourceRows()` in
* `src/lib/analytics/source-priority.ts`, applied to workouts. A user
* wearing an Apple Watch + a Withings ScanWatch records the same run
* once via each integration; the dashboard "Recent workouts" tile
* would otherwise show the run twice.
*
* Algorithm (v1.4.30 — matches the measurement ladder per R-F open
* question #4):
* 1. Bucket workouts by the rounded start timestamp + sport-type
* (`<5-minute slot>:<sport>`). Two HKWorkout entries for the same
* 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.
* 3. Rows whose source isn't in the ladder pass through untouched
* (fallback intent — never silently drop signal).
*
* Determinism precondition: input rows MUST be in deterministic order
* (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.
*/
import type {
MeasurementSource,
} from "@/generated/prisma/client";
import {
getSourceLadder,
parseSourcePriority,
} from "@/lib/validations/source-priority";
/**
* Minimum row shape — same posture as `SourcePickerRow`. The helper
* only consults the three fields; downstream callers can read every
* other column they fetched alongside.
*/
export interface WorkoutPickerRow {
startedAt: Date;
sportType: string;
source: MeasurementSource;
}
/**
* Bucket-slot length. Two workouts whose `startedAt` falls in the
* same 5-minute slot AND share the same sportType are treated as the
* "same" workout from different sources. Tightens further than the
* 1-minute clock skew between HK + Withings would suggest because
* iOS posts HK `startDate` rounded to the second but Withings stamps
* its `startdate` independently — five minutes gives both heads
* enough slack on a heavy-traffic morning sync without crossing the
* "two back-to-back runs" boundary (a user does NOT do two ten-minute
* runs five minutes apart in practice).
*/
const BUCKET_SLOT_MS = 5 * 60 * 1000;
function bucketKey(row: WorkoutPickerRow): string {
const slot = Math.floor(row.startedAt.getTime() / BUCKET_SLOT_MS);
return `${slot}:${row.sportType}`;
}
/**
* Pick canonical rows across sources for a user's workouts.
*
* @param rows input rows (deterministic order required)
* @param userPriorityJson the user's `sourcePriorityJson` blob — same
* shape the measurement picker consults.
* Falls back to the canonical default ladder
* when null / unparseable.
* @returns the subset of input rows that survives the picker. Insertion
* order preserved.
*/
export function pickCanonicalWorkoutRows<T extends WorkoutPickerRow>(
rows: readonly T[],
userPriorityJson: unknown = null,
): 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 buckets = new Map<
string,
{
rows: T[];
sources: Set<MeasurementSource>;
}
>();
for (const row of rows) {
const key = bucketKey(row);
const slot = buckets.get(key) ?? { rows: [], sources: new Set() };
slot.rows.push(row);
slot.sources.add(row.source);
buckets.set(key, slot);
}
const canonical: T[] = [];
for (const [, slot] of buckets) {
if (slot.rows.length === 1) {
// No cross-source overlap → keep the single row regardless of
// ladder membership.
canonical.push(slot.rows[0]);
continue;
}
let picked: MeasurementSource | undefined;
for (const source of sourceLadder) {
if (slot.sources.has(source)) {
picked = source;
break;
}
}
if (!picked) {
// None of the bucket's sources are listed in the ladder — never
// silently drop. Keep every row in the bucket; downstream
// callers can choose to surface a "duplicate from unrecognised
// sources" diagnostic.
canonical.push(...slot.rows);
continue;
}
for (const row of slot.rows) {
if (row.source === picked) canonical.push(row);
}
}
// Re-sort to preserve input order — the bucket map iterates in
// insertion order but the per-bucket fan-out can interleave rows
// from later buckets ahead of earlier-bucket survivors. Caller
// wants the same `startedAt ASC` order it passed in.
canonical.sort((a, b) => {
const delta = a.startedAt.getTime() - b.startedAt.getTime();
if (delta !== 0) return delta;
// Stable-tiebreak on sportType so the output is deterministic
// even when two surviving rows share startedAt (rare; manual
// entries on the same second).
return a.sportType.localeCompare(b.sportType);
});
return canonical;
}