fix(workouts): enrich canonical WHOOP aggregates

This commit is contained in:
Marc-André Bombeck committed 2026-07-29 14:55:11 +02:00
1 parent 1cb0f7dd6a
commit 635e9b64c5
2 files changed
+157 -4

No files matched your search

@@ -4,6 +4,7 @@ import { pickCanonicalWorkoutRows } from "../pick-canonical-workout-rows";
interface RowFixture {
id: string;
userId?: string;
startedAt: Date;
sportType: string;
source: "APPLE_HEALTH" | "WHOOP" | "WITHINGS" | "MANUAL" | "IMPORT";
@@ -227,6 +228,60 @@ describe("pickCanonicalWorkoutRows", () => {
});
});
it("does not collapse or enrich rows belonging to different users", () => {
const result = pickCanonicalWorkoutRows<RowFixture>([
{
id: "apple-u1",
userId: "u1",
startedAt: new Date("2026-06-03T06:30:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
avgHeartRate: null,
},
{
id: "whoop-u2",
userId: "u2",
startedAt: new Date("2026-06-03T06:30:30Z"),
sportType: "running",
source: "WHOOP",
avgHeartRate: 150,
},
]);
expect(result).toHaveLength(2);
expect(result.find((row) => row.id === "apple-u1")?.avgHeartRate).toBeNull();
});
it("uses each WHOOP twin at most once for same-slot occurrences", () => {
const result = pickCanonicalWorkoutRows<RowFixture>([
{
id: "apple-near",
startedAt: new Date("2026-06-03T06:30:10Z"),
sportType: "running",
source: "APPLE_HEALTH",
avgHeartRate: null,
},
{
id: "apple-far",
startedAt: new Date("2026-06-03T06:33:30Z"),
sportType: "running",
source: "APPLE_HEALTH",
avgHeartRate: null,
},
{
id: "whoop",
startedAt: new Date("2026-06-03T06:30:00Z"),
sportType: "running",
source: "WHOOP",
avgHeartRate: 150,
},
]);
expect(
result.filter((row) => row.avgHeartRate === 150).map((row) => row.id),
).toEqual(["apple-near"]);
});
it("keeps the WHOOP run when no richer source logged the same session", () => {
const rows: RowFixture[] = [
{
@@ -44,9 +44,13 @@ import {
* other column they fetched alongside.
*/
export interface WorkoutPickerRow {
userId?: string;
startedAt: Date;
sportType: string;
source: MeasurementSource;
avgHeartRate?: number | null;
maxHeartRate?: number | null;
metadata?: unknown;
}
/**
@@ -64,7 +68,98 @@ 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}`;
return `${row.userId ?? ""}:${slot}:${row.sportType}`;
}
function jsonRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function enrichFromWhoopTwin<T extends WorkoutPickerRow>(
winner: T,
donor: T,
): T {
const winnerMetadata = jsonRecord(winner.metadata);
const donorMetadata = jsonRecord(donor.metadata);
const donorZones = donorMetadata?.zoneDurations;
const needsZones =
donorZones != null && winnerMetadata?.zoneDurations == null;
const needsAvg =
winner.avgHeartRate == null && donor.avgHeartRate != null;
const needsMax =
winner.maxHeartRate == null && donor.maxHeartRate != null;
if (!needsAvg && !needsMax && !needsZones) return winner;
return {
...winner,
...(needsAvg ? { avgHeartRate: donor.avgHeartRate } : {}),
...(needsMax ? { maxHeartRate: donor.maxHeartRate } : {}),
...(needsZones
? {
metadata: {
...(winnerMetadata ?? {}),
zoneDurations: donorZones,
},
}
: {}),
};
}
/**
* Pair winners and WHOOP donors one-to-one by nearest start time. A single
* donor can never enrich two same-source occurrences that happen to share a
* fixed slot. Stable input indexes break equal-distance ties.
*/
function enrichWinnersFromWhoop<T extends WorkoutPickerRow>(
winners: readonly T[],
donors: readonly T[],
): T[] {
if (winners.length === 0 || donors.length === 0) return [...winners];
const candidates: Array<{
winnerIndex: number;
donorIndex: number;
deltaMs: number;
}> = [];
winners.forEach((winner, winnerIndex) => {
donors.forEach((donor, donorIndex) => {
candidates.push({
winnerIndex,
donorIndex,
deltaMs: Math.abs(
winner.startedAt.getTime() - donor.startedAt.getTime(),
),
});
});
});
candidates.sort(
(left, right) =>
left.deltaMs - right.deltaMs ||
left.winnerIndex - right.winnerIndex ||
left.donorIndex - right.donorIndex,
);
const enriched = [...winners];
const matchedWinners = new Set<number>();
const matchedDonors = new Set<number>();
for (const candidate of candidates) {
if (
matchedWinners.has(candidate.winnerIndex) ||
matchedDonors.has(candidate.donorIndex)
) {
continue;
}
enriched[candidate.winnerIndex] = enrichFromWhoopTwin(
winners[candidate.winnerIndex],
donors[candidate.donorIndex],
);
matchedWinners.add(candidate.winnerIndex);
matchedDonors.add(candidate.donorIndex);
}
return enriched;
}
/**
@@ -134,9 +229,12 @@ export function pickCanonicalWorkoutRows<T extends WorkoutPickerRow>(
continue;
}
for (const row of slot.rows) {
if (row.source === picked) canonical.push(row);
}
const winners = slot.rows.filter((row) => row.source === picked);
const whoopDonors =
picked === "WHOOP"
? []
: slot.rows.filter((row) => row.source === "WHOOP");
canonical.push(...enrichWinnersFromWhoop(winners, whoopDonors));
}
// Re-sort to preserve input order — the bucket map iterates in