mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(workouts): enrich canonical WHOOP aggregates
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user