Files
HealthLog/src/lib/whoop/sync-workout.ts
T
Marc-André Bombeck 5d62236412 feat(arrivals): add the data-arrival event spine
Every ingest path in the product can now emit a typed, salience-classified
event into pg-boss, consumed by one worker that claims a durable per-day
reaction marker and fans out to the reaction surfaces.

The recency classifier runs at the seam, before anything is enqueued, so a
mass backfill — a multi-year Apple Health export, a provider re-sync, a
30-day catch-up — emits nothing at all. Non-salient writes are annotated and
dropped.

The worker makes no provider call by construction: it imports no provider
module, writes only markers, and enqueues nothing that is not budget-gated
downstream. Business refusals return a status rather than throwing, so a
refusal is never retried against a ceiling that will not move until the local
day rolls over.

Seams: sleep (one hook, inherited by all seven transports), workouts (the
batch route, the four provider syncs, the export importer), weight and blood
pressure (both measurement routes), labs (manual, OCR commit, document fact
commit).

The provider workout syncs could not previously tell a created row from an
updated one, so a re-sync of a recent session would have re-fired on every
poll; they now recover the distinction from the row the upsert already
returns.

Reaction lines are AES-256-GCM at rest, following the InsightNarrative
precedent, and the column is registered for key rotation. Markers are pruned
after 14 days by a daily maintenance job.
2026-07-19 13:17:34 +02:00

197 lines
6.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.
*
* `sportType` writes the canonical `WorkoutSportType` bucket via
* `mapWhoopSportType()` (`./sport-map.ts`) — mirroring the Fitbit / Google
* Health mappers — so a WHOOP row clusters and icons the same as any other
* source's. Raw `sport_id` / `sport_name` still live in `Workout.metadata`
* for traceability.
*/
import {
fetchWorkouts,
fetchWorkoutById,
KJ_TO_KCAL,
type WhoopWorkout,
} from "./client";
import {
getValidToken,
incrementalStart,
handleCollectionFetchError,
markResourceSynced,
resolveResourceCursor,
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
} from "./sync";
import { mapWhoopSportType } from "./sport-map";
import { prisma } from "@/lib/db";
import { emitWorkoutArrivalIfCreated } from "@/lib/arrivals/workout-emit";
import { getEvent } from "@/lib/logging/context";
import type { Prisma } from "@/generated/prisma/client";
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 }
: {}),
// Raw WHOOP sport fields, kept for traceability once mapWhoopSportType()
// has resolved `Workout.sportType` to a canonical bucket — a support
// request about a misclassified sport can trace back to exactly what
// WHOOP sent without re-fetching the workout.
...(w.sport_id != null ? { whoopSportId: w.sport_id } : {}),
...(w.sport_name ? { whoopSportName: w.sport_name } : {}),
};
const row = {
sportType: mapWhoopSportType(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 {
const saved = await prisma.workout.upsert({
where: {
userId_source_externalId: {
userId,
source: "WHOOP",
externalId: w.id,
},
},
create: {
userId,
source: "WHOOP",
externalId: w.id,
...row,
},
update: row,
select: {
id: true,
startedAt: true,
createdAt: true,
updatedAt: true,
},
});
// v1.31.0 — data-arrival spine. Only a genuinely NEW workout reacts;
// WHOOP re-posts recent workouts on every poll and an update is not news.
void emitWorkoutArrivalIfCreated(userId, saved, "whoop").catch(() => {});
return 1;
} catch (err) {
getEvent()?.addWarning(`WHOOP: failed to upsert workout: ${err}`);
return 0;
}
}