mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore.
181 lines
5.9 KiB
TypeScript
181 lines
5.9 KiB
TypeScript
/**
|
|
* WHOOP workout sync. Fetches workout activities since the incremental cursor
|
|
* and upserts each into the `Workout` table as `source = WHOOP`, keyed on
|
|
* `(userId, source, externalId)` so a re-score overwrites in place.
|
|
*
|
|
* Per-workout strain (`WORKOUT_STRAIN`), HR-zone durations, `percent_recorded`,
|
|
* and altitude live in `Workout.metadata` (tied to the workout row) rather than
|
|
* as free-floating Measurements — a phantom strain Measurement would survive
|
|
* if the workout were later de-duped away by the read-time picker. Energy is
|
|
* converted kJ→kcal via `KJ_TO_KCAL` for `totalEnergyKcal`.
|
|
*
|
|
* A WHOOP run and the same run via Apple Health remain two distinct `Workout`
|
|
* rows (different `source`); the read-time `pickCanonicalWorkoutRows` picker
|
|
* (E-slice, W6) collapses the cross-source twin at read time.
|
|
*/
|
|
import {
|
|
fetchWorkouts,
|
|
fetchWorkoutById,
|
|
KJ_TO_KCAL,
|
|
type WhoopWorkout,
|
|
} from "./client";
|
|
import {
|
|
getValidToken,
|
|
incrementalStart,
|
|
handleCollectionFetchError,
|
|
markResourceSynced,
|
|
resolveResourceCursor,
|
|
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
|
} from "./sync";
|
|
import { prisma } from "@/lib/db";
|
|
import { getEvent } from "@/lib/logging/context";
|
|
import type { Prisma } from "@/generated/prisma/client";
|
|
|
|
/** WHOOP reports a numeric `sport_id`; fall back to a generic label. */
|
|
function sportLabel(sportId: number | undefined, sportName?: string): string {
|
|
if (sportName) return sportName;
|
|
if (typeof sportId === "number") return `whoop_sport_${sportId}`;
|
|
return "workout";
|
|
}
|
|
|
|
export async function syncUserWorkout(
|
|
userId: string,
|
|
opts: { fullSync?: boolean } = {},
|
|
): Promise<number> {
|
|
const tokenInfo = await getValidToken(userId);
|
|
if (!tokenInfo) return 0;
|
|
|
|
const connection = await prisma.whoopConnection.findUnique({
|
|
where: { userId },
|
|
select: { lastSyncedAt: true, resourceCursors: true },
|
|
});
|
|
if (!connection) return 0;
|
|
|
|
// iOS #17 — the WHOOP `/activity/workout` collection filters on the
|
|
// workout's OWN time range, not on when it reached the cloud. A workout
|
|
// whose phone sync lands more than the overlap late then falls permanently
|
|
// before the cursor and is never ingested (operator-visible data loss). A
|
|
// 1 h overlap was far too tight for opportunistic phone sync; widen it to
|
|
// the same window recovery/sleep already use for their late re-scores. The
|
|
// `(userId, WHOOP, externalId)` upsert keeps the re-fetch idempotent and a
|
|
// handful of workouts/day keeps the page count down.
|
|
const start = incrementalStart(resolveResourceCursor(connection, "workout"), {
|
|
fullSync: opts.fullSync,
|
|
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
|
});
|
|
|
|
let records: Awaited<ReturnType<typeof fetchWorkouts>>;
|
|
try {
|
|
records = await fetchWorkouts(tokenInfo.accessToken, { start });
|
|
} catch (err) {
|
|
return handleCollectionFetchError("workout", userId, err);
|
|
}
|
|
|
|
let imported = 0;
|
|
for (const w of records) {
|
|
imported += await upsertWhoopWorkout(userId, w);
|
|
}
|
|
|
|
await markResourceSynced(userId, "workout");
|
|
return imported;
|
|
}
|
|
|
|
/**
|
|
* Webhook-driven targeted refresh: resolve ONE workout by its uuid and upsert
|
|
* it, instead of re-walking the whole collection. This is the direct fix for
|
|
* iOS #17 — a `workout.updated` webhook lands the exact workout immediately,
|
|
* with no dependence on the overlap window catching it. An unscored /
|
|
* since-deleted record yields nothing. Does NOT advance the resource cursor.
|
|
*/
|
|
export async function syncWhoopWorkoutById(
|
|
userId: string,
|
|
workoutId: string,
|
|
): Promise<number> {
|
|
const tokenInfo = await getValidToken(userId);
|
|
if (!tokenInfo) return 0;
|
|
|
|
let record: WhoopWorkout;
|
|
try {
|
|
record = await fetchWorkoutById(tokenInfo.accessToken, workoutId);
|
|
} catch (err) {
|
|
return handleCollectionFetchError("workout", userId, err);
|
|
}
|
|
|
|
return upsertWhoopWorkout(userId, record);
|
|
}
|
|
|
|
/**
|
|
* Upsert one WHOOP workout into the `Workout` table keyed on
|
|
* `(userId, source = WHOOP, externalId)`. Returns 1 when a row was written, 0
|
|
* for an unscored record (nothing to store yet) or an upsert failure (logged,
|
|
* never thrown — a single bad record must not fail the surrounding sync).
|
|
* Shared by the collection walk and the webhook-driven fetch-by-id refresh so
|
|
* both write an identical row shape.
|
|
*/
|
|
export async function upsertWhoopWorkout(
|
|
userId: string,
|
|
w: WhoopWorkout,
|
|
): Promise<number> {
|
|
if (!w.score) return 0; // unscored workout — nothing to store yet
|
|
const startedAt = new Date(w.start);
|
|
const endedAt = new Date(w.end);
|
|
const durationSec = Math.max(
|
|
0,
|
|
Math.round((endedAt.getTime() - startedAt.getTime()) / 1000),
|
|
);
|
|
const energyKcal =
|
|
typeof w.score.kilojoule === "number"
|
|
? Math.round(w.score.kilojoule * KJ_TO_KCAL)
|
|
: null;
|
|
|
|
const metadata: Prisma.InputJsonValue = {
|
|
whoopWorkoutStrain: w.score.strain,
|
|
percentRecorded: w.score.percent_recorded,
|
|
...(w.score.altitude_gain_meter != null
|
|
? { altitudeGainMeter: w.score.altitude_gain_meter }
|
|
: {}),
|
|
...(w.score.altitude_change_meter != null
|
|
? { altitudeChangeMeter: w.score.altitude_change_meter }
|
|
: {}),
|
|
...(w.score.zone_durations
|
|
? { zoneDurations: w.score.zone_durations }
|
|
: {}),
|
|
};
|
|
|
|
const row = {
|
|
sportType: sportLabel(w.sport_id, w.sport_name),
|
|
startedAt,
|
|
endedAt,
|
|
durationSec,
|
|
totalEnergyKcal: energyKcal,
|
|
totalDistanceM: w.score.distance_meter ?? null,
|
|
avgHeartRate: w.score.average_heart_rate ?? null,
|
|
maxHeartRate: w.score.max_heart_rate ?? null,
|
|
elevationM: w.score.altitude_gain_meter ?? null,
|
|
metadata,
|
|
};
|
|
|
|
try {
|
|
await prisma.workout.upsert({
|
|
where: {
|
|
userId_source_externalId: {
|
|
userId,
|
|
source: "WHOOP",
|
|
externalId: w.id,
|
|
},
|
|
},
|
|
create: {
|
|
userId,
|
|
source: "WHOOP",
|
|
externalId: w.id,
|
|
...row,
|
|
},
|
|
update: row,
|
|
});
|
|
return 1;
|
|
} catch (err) {
|
|
getEvent()?.addWarning(`WHOOP: failed to upsert workout: ${err}`);
|
|
return 0;
|
|
}
|
|
}
|