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