Files
HealthLog/src/lib/workouts/canonical-rows.ts
T
Marc-André Bombeck 62eb431d66 release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0

* test: stabilize water capture re-entry
2026-07-22 04:14:15 +02:00

309 lines
13 KiB
TypeScript

/**
* v1.4.42 W5 — write-time cross-source canonical-row picker for
* incoming `Workout` ingest payloads.
*
* Why this exists alongside the read-time helper
* ──────────────────────────────────────────────
*
* Two related-but-distinct dedup paths exist in the codebase:
*
* - `pickCanonicalWorkoutRows()` in `src/lib/measurements/` (v1.4.30)
* — READ-time picker invoked by `GET /api/workouts` /
* `GET /api/workouts/[id]`. It walks DB rows, looks up the user's
* persisted `sourcePriorityJson`, buckets by 5-minute slot, and
* filters the read result so the dashboard tile shows one row per
* logical workout.
*
* - This helper (`dedupeWorkoutBatch`, v1.4.42) — WRITE-time picker
* invoked by `POST /api/workouts/batch` BEFORE
* `prisma.workout.createMany`. The name intentionally diverges
* from the read-time picker to make the auto-completion path
* unambiguous (write-time payload-internal dedup vs. read-time
* cross-batch dedup with user-priority resolution).
* The v1.5 iOS sprint will ingest `HKWorkoutType` data in batches
* against `/api/workouts/batch`; Apple Watch + Withings ScanWatch
* paired to the same iPhone frequently capture the same workout
* simultaneously (e.g. a Sunday-morning run) and the iOS app may
* post both rows in one batch. Without write-time dedup, both
* rows would persist (their `externalId`s differ, so the
* `(userId, source, externalId)` unique index doesn't fire) and
* the read-time picker would carry the duplicate forward at every
* downstream read.
*
* The two pickers are layered, not redundant:
* - Write-time picker drops PAYLOAD-internal duplicates inside a
* single batch so we don't pay the storage cost.
* - Read-time picker still runs to collapse cross-batch duplicates
* (e.g. Apple Watch row from Monday's batch + ScanWatch row from
* Tuesday's backfill batch).
*
* Algorithm
* ─────────
*
* 1. Group by `(userId, activityType, startedAt ± 90 s window)`.
* The 90-second window is tighter than the read-time picker's
* 5-minute slot because a single batch from one iOS client has
* no cross-device clock skew to absorb — both rows are stamped
* with HealthKit's `startDate`, which Apple aggregates to the
* same instant across paired sensors. A 90-second cushion
* covers HK's documented ±60s smoothing plus a small buffer.
* 2. Groups containing one source are not duplicates and remain
* unchanged. For a cross-source group, select the winning source
* in order `APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT`, then keep
* every row from that source. The literal source ladder comes from
* `DEFAULT_WORKOUT_SOURCE_PRIORITY` so both pickers share one source
* of truth.
* 3. When two sources share a priority tier, prefer the source whose
* best candidate has higher `caloriesKcal`, then earliest `createdAt`,
* then input order. These tie-breakers choose a source; they never
* collapse multiple workouts recorded by that winning source.
*
* Purity contract
* ───────────────
*
* Zero Prisma imports, zero IO, zero user lookups. The helper takes
* an array, returns an array. Testable in isolation; safely invoked
* from a hot ingest path with no DB round-trip cost.
*
* Source-priority parity with the read-time picker
* ────────────────────────────────────────────────
*
* CLOSED in v1.4.43 (W9-WORKOUTS-PRIORITY). The read-time picker
* resolves the per-user `sourcePriorityJson` (from
* `User.sourcePriorityJson`) for every read so a user who promoted
* MANUAL above APPLE_HEALTH in Settings → Sources sees their Manual
* rows surface above the Apple Watch ones. This helper now mirrors
* that contract — the optional `userPriorityJson` argument feeds
* through `parseSourcePriority()` + `getSourceLadder(..., "steps")`,
* the same chain the read-time picker walks, so a customised user
* keeps their preferred source at BOTH write- and read-time.
*
* When the argument is omitted (or null/undefined) the helper falls
* back to the canonical `DEFAULT_WORKOUT_SOURCE_PRIORITY` ladder so
* callers that don't yet have the user blob handy stay on the
* v1.4.42 behaviour. The batch route at
* `src/app/api/workouts/batch/route.ts` does one indexed
* `User.sourcePriorityJson` lookup at the start of the handler and
* passes the blob through here; the cost is a single `findUnique`
* per batch, well under the cost of letting a customised user's
* preferred row drop at write-time.
*/
import { DEFAULT_WORKOUT_SOURCE_PRIORITY } from "@/lib/sources/pick-canonical-workout";
import {
getSourceLadder,
parseSourcePriority,
} from "@/lib/validations/source-priority";
import type { MeasurementSource } from "@/generated/prisma/client";
/**
* Minimum row shape consulted by the picker. Field names mirror the
* batch-route's prepared `Prisma.WorkoutCreateManyInput` plus a
* `createdAt` slot for the deterministic tie-breaker. Anything else
* the caller carried alongside the row (route geometry, HR series,
* metadata) survives untouched — the picker filters but never
* mutates and is generic in `T` so call sites keep their own
* carrier type.
*
* Naming aligned with the v1.5 iOS contract:
* - `activityType` — the sport type (`"running"`, `"cycling"`,
* ...). Matches `Workout.sportType` at the DB layer and
* `WorkoutSportType` at the validator layer; cross-source
* overlap requires identical sport so a "Walking" + "Running"
* pair at the same instant stays as two distinct rows.
* - `caloriesKcal` — the active-energy aggregate. The v1.5 iOS
* contract uses `caloriesKcal` for parity with HealthKit's
* `HKQuantityTypeIdentifierActiveEnergyBurned`; the server's
* write path maps it to `Workout.totalEnergyKcal`.
* - `createdAt` — when the row was minted. The batch path doesn't
* have this yet (it's set by Prisma's `@default(now())` at
* insert), so the helper falls back to insertion order via the
* `index` field when `createdAt` is undefined on every member.
*/
export interface WorkoutRow {
userId: string;
activityType: string;
startedAt: Date;
source: MeasurementSource;
caloriesKcal?: number | null;
/**
* Optional row-mint timestamp. The write-time picker is called
* BEFORE `createMany`, so prepared rows usually have no
* `createdAt` yet — undefined falls through to the next
* tie-breaker (insertion order via the `index` field).
*/
createdAt?: Date | null;
/**
* Stable per-batch ordinal. Set by the caller (e.g. the position
* of the entry in the inbound payload) so two rows with identical
* priority + calories + createdAt still resolve deterministically.
* Optional; defaults to 0 so single-row groups never need it.
*/
index?: number;
}
/**
* Cross-source dedup window. Within a single ingest batch from one
* iOS client, two rows whose `startedAt` falls within ±90 s AND
* share the same `activityType` AND belong to the same user are
* treated as the same logical workout. The window is symmetric:
* the comparison uses `|a.startedAt - b.startedAt| ≤ 90 000 ms`.
*/
export const WORKOUT_DEDUP_WINDOW_MS = 90 * 1000;
/**
* Source-priority rank lookup. Lower rank = higher priority. A
* source absent from the ladder gets a rank past the end of the
* array — the picker still keeps that row when no ladder-listed
* row competes for the slot (single-source group), but loses to
* any ladder-listed competitor.
*
* The ladder is resolved per-call (from the user's
* `sourcePriorityJson` or the canonical default), so the rank
* function is parameterised on the ladder rather than reading a
* module-level constant.
*/
function sourceRank(
source: MeasurementSource,
ladder: readonly MeasurementSource[],
): number {
const idx = ladder.indexOf(source);
return idx === -1 ? ladder.length : idx;
}
/**
* Comparator: returns negative when `a` should win over `b`.
* Priority order: source rank → higher calories → earliest
* createdAt → earliest input index.
*/
function compareCandidates<T extends WorkoutRow>(
a: T,
b: T,
ladder: readonly MeasurementSource[],
): number {
const sourceDelta =
sourceRank(a.source, ladder) - sourceRank(b.source, ladder);
if (sourceDelta !== 0) return sourceDelta;
// Tie-breaker 1 — higher caloriesKcal wins (more sensor data).
// `null` / `undefined` calories sort below any numeric value.
const aCal = a.caloriesKcal ?? -1;
const bCal = b.caloriesKcal ?? -1;
if (aCal !== bCal) return bCal - aCal;
// Tie-breaker 2 — earliest `createdAt` wins (stable across
// re-runs). Undefined sorts below any concrete date so a
// pre-insert row (no createdAt yet) doesn't beat an already-
// persisted row carrying a real timestamp.
const aCreated = a.createdAt?.getTime() ?? Number.POSITIVE_INFINITY;
const bCreated = b.createdAt?.getTime() ?? Number.POSITIVE_INFINITY;
if (aCreated !== bCreated) return aCreated - bCreated;
// Tie-breaker 3 — input order. Guarantees determinism even when
// every other field ties (rare; same source, same calories,
// same / no createdAt). Default of 0 keeps single-row groups
// untouched.
return (a.index ?? 0) - (b.index ?? 0);
}
/**
* Pick canonical rows across sources for an inbound ingest batch.
*
* Group by `(userId, activityType, startedAt ± 90 s)`; a group with
* multiple sources keeps every row from its highest-priority source.
* Single-source overlaps pass through unchanged. The returned
* array preserves the input order of the surviving rows so a
* caller that pre-sorted by `startedAt ASC` keeps that ordering
* downstream.
*
* Implementation walks the input once with a union-find-style
* pass: each row joins an existing group whose anchor falls
* within the dedup window AND matches `(userId, activityType)`,
* or seeds a new group. The anchor is the FIRST row that landed
* in the group; subsequent rows compare against the anchor so the
* window can't drift across an arbitrarily long chain of
* within-window-of-neighbour rows. This is O(n²) worst case but
* a typical batch is ≤ 100 entries so the constant is fine.
*/
export function dedupeWorkoutBatch<T extends WorkoutRow>(
rows: readonly T[],
userPriorityJson?: unknown,
): T[] {
if (rows.length === 0) return [];
// Resolve the source ladder from the user's persisted preference
// when one was supplied. The read-time picker
// (`pickCanonicalWorkoutRows`) keys workouts off the `steps` metric
// ladder — the canonical-activity bucket — so both pickers consult
// the same per-user configuration surface. When no preference is
// supplied (legacy callers, tests that don't care, or a route that
// hasn't been wired yet), fall back to the canonical default
// ladder so behaviour matches the v1.4.42 baseline.
const ladder: readonly MeasurementSource[] =
userPriorityJson == null
? DEFAULT_WORKOUT_SOURCE_PRIORITY
: getSourceLadder(parseSourcePriority(userPriorityJson), "steps");
// Stamp each input with its original index so the comparator
// has a stable tie-breaker without mutating the caller's row.
type Tagged = { row: T; origIndex: number };
const tagged: Tagged[] = rows.map((row, origIndex) => ({ row, origIndex }));
// Group anchors: each group is a list of (row, origIndex). The
// anchor row sets the window comparison reference.
const groups: Tagged[][] = [];
for (const entry of [...tagged].sort((a, b) => {
const delta = a.row.startedAt.getTime() - b.row.startedAt.getTime();
return delta || a.origIndex - b.origIndex;
})) {
let placed = false;
for (const group of groups) {
const anchor = group[0]!.row;
if (
anchor.userId === entry.row.userId &&
anchor.activityType === entry.row.activityType &&
Math.abs(anchor.startedAt.getTime() - entry.row.startedAt.getTime()) <=
WORKOUT_DEDUP_WINDOW_MS
) {
group.push(entry);
placed = true;
break;
}
}
if (!placed) {
groups.push([entry]);
}
}
// Preserve single-source groups. For genuine cross-source overlaps,
// select the winning source and retain every row it contributed.
const survivors: Tagged[] = [];
for (const group of groups) {
if (group.length === 1) {
survivors.push(group[0]!);
continue;
}
const sources = new Set(group.map((entry) => entry.row.source));
if (sources.size === 1) {
survivors.push(...group);
continue;
}
const annotated = group.map((entry) => ({
...entry,
row: {
...entry.row,
index: entry.row.index ?? entry.origIndex,
} as T,
}));
annotated.sort((a, b) => compareCandidates(a.row, b.row, ladder));
const winningSource = annotated[0]!.row.source;
survivors.push(
...group.filter((entry) => entry.row.source === winningSource),
);
}
survivors.sort((a, b) => a.origIndex - b.origIndex);
return survivors.map((entry) => entry.row);
}