import { z } from "zod/v4"; import { measurementSourceEnum } from "./measurement"; /** * Workout sport-type union. * * Stored as a TEXT column at the DB layer (see `Workout.sport_type` in * `prisma/schema.prisma`) rather than a Postgres enum. Apple alone has * 70+ `HKWorkoutActivityType` values and the long tail (paddle / row / * ski / etc.) only grows release-on-release. A Zod `enum` here gives * compile-time exhaustiveness without making every new sport a Postgres * migration. Adding a new sport: append the literal to this union and * any source-specific code that maps to it (e.g. an HK numeric-code → * union entry in `apple-health-mapping.ts`). * * The initial roster covers ≥98% of typical workouts observed across * the open-source Apple Health corpora referenced in the W8d research * outline. Anything not in the list lands as `"other"` until promoted * to a first-class member. */ export const workoutSportTypeEnum = z.enum([ "walking", "running", "cycling", "hiking", "swimming", "rowing", "elliptical", "stairClimber", "yoga", "mindAndBody", "strength", "hiit", "dance", "golf", "tennis", "basketball", "soccer", "crossTraining", "mixedCardio", "other", ]); export type WorkoutSportType = z.infer; /** * Upper bound on points per route LineString. A 24h ultra at 1Hz lands * at 86 400 points; 20 000 covers any normal run / ride / hike while * still rejecting pathological payloads (an attacker shipping a 5 MB * route per workout * 100 workouts/batch * unbounded retries). The * route geometry is the largest tail in the batch payload — keeping it * bounded keeps the total request body bounded with it. */ export const MAX_ROUTE_POINTS = 20_000; /** * GeoJSON LineString — the WorkoutRoute.geometry shape. RFC 7946 §3.1.4 * with one HealthLog-specific allowance: the altitude component on * each coordinate is optional. Apple HKWorkoutRoute carries it; some * GPX imports do not. */ export const geoJsonLineStringSchema = z.object({ type: z.literal("LineString"), coordinates: z .array( z .tuple([z.number().min(-180).max(180), z.number().min(-90).max(90)]) .rest(z.number()), ) .min(2, "LineString requires at least 2 points") .max( MAX_ROUTE_POINTS, `LineString exceeds the ${MAX_ROUTE_POINTS}-point cap`, ), }); export type GeoJsonLineString = z.infer; /** * Per-sample timestamp + speed companion to the LineString. Parallel * to `coordinates` — same length, one entry per point. NULL at the * column level when the source only ships static GPX (Withings). */ export const workoutRouteSamplesSchema = z.array( z.object({ t: z.iso.datetime({ offset: true }), speedMs: z.number().min(0).max(50).optional(), hr: z.number().int().min(20).max(300).optional(), }), ); export type WorkoutRouteSamples = z.infer; /** * Upper bound on per-workout heart-rate samples. HealthKit emits a HR * sample roughly every 5 s during a workout — a 6-hour ultra at that * cadence lands at ~4 320 points, and even a 1 Hz reconstruction of * that session is ~21 600. 30 000 covers any realistic single session * (including long endurance events sampled at 1 Hz) while still * rejecting pathological payloads. The series is the largest new * workout-side write stream, so the cap keeps each workout's payload — * and the total request body — bounded the same way `MAX_ROUTE_POINTS` * bounds the route geometry. iOS should downsample to ≤ this many * points (e.g. 1-min buckets for very long sessions) before posting. */ export const MAX_WORKOUT_HR_SAMPLES = 30_000; /** * Route-INDEPENDENT per-workout heart-rate series — the * `WorkoutSamples.samples` shape. Mirrors `workoutRouteSamplesSchema` * but carries no geometry coupling, so an indoor workout (no GPS) can * still ship its HR signal for the training-strain engine. * * `t` is the per-sample ISO timestamp; `hr` beats/min. `speedMs` / * `power` / `cadence` are optional companion channels HealthKit may * report (cycling power meters, running cadence). Every channel is * optional EXCEPT `t` so a sparse series (HR only) is valid; an entry * with no signal channel at all is still permitted (a bare timestamp) * but carries no analytical value. */ export const workoutHrSamplesSchema = z .array( z.object({ t: z.iso.datetime({ offset: true }), hr: z.number().int().min(20).max(300).optional(), speedMs: z.number().min(0).max(50).optional(), power: z.number().min(0).max(3000).optional(), cadence: z.number().min(0).max(400).optional(), }), ) .min(1, "samples requires at least 1 point") .max( MAX_WORKOUT_HR_SAMPLES, `samples exceeds the ${MAX_WORKOUT_HR_SAMPLES}-point cap`, ); export type WorkoutHrSamples = z.infer; /** * Workout insert payload — the shape `POST /api/workouts/batch` accepts. * Defined here so the iOS Swift session can generate its DTO against the * locked contract and the v1.4.26 XML import worker has the same target. * * Cross-field invariants enforced via `.superRefine()`: * - `endedAt` MUST be strictly greater than `startedAt`. A workout * with `endedAt <= startedAt` produces a non-positive * `durationSec`, which downstream consumers (PR detector, weekly * report, dashboards) treat as a "real" zero — locking in a * fastest-5km time of zero seconds is far worse than refusing the * ingest at the schema gate. * - When `route.sampleTimestamps` is present, its length MUST equal * `route.geometry.coordinates.length`. A desynced pair silently * degrades downstream analytics (per-sample HR / speed would line * up against the wrong coordinate), so we hard-fail at parse time. */ export const createWorkoutSchema = z .object({ sportType: workoutSportTypeEnum, startedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), endedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), totalEnergyKcal: z.number().min(0).max(50000).optional(), totalDistanceM: z.number().min(0).max(1_000_000).optional(), avgHeartRate: z.number().int().min(20).max(300).optional(), maxHeartRate: z.number().int().min(20).max(300).optional(), minHeartRate: z.number().int().min(20).max(300).optional(), stepCount: z.number().int().min(0).max(200_000).optional(), elevationM: z.number().min(-500).max(10_000).optional(), pauseDurationSec: z.number().int().min(0).max(86_400).optional(), source: measurementSourceEnum.optional().default("MANUAL"), externalId: z.string().max(128).optional(), externalSourceVersion: z.string().max(64).optional(), metadata: z.record(z.string(), z.unknown()).optional(), route: z .object({ geometry: geoJsonLineStringSchema, sampleTimestamps: workoutRouteSamplesSchema.optional(), }) .optional(), /** * Route-independent per-workout heart-rate series. Persisted to the * `WorkoutSamples` child so an indoor workout (no GPS route) still * yields a strain-relevant HR signal. Independent of `route` — a * GPS workout MAY carry both (`route.sampleTimestamps` for the * geometry-aligned series, `samples` for the canonical HR series); * an indoor workout carries only `samples`. */ samples: workoutHrSamplesSchema.optional(), }) .superRefine((value, ctx) => { if (value.endedAt.getTime() <= value.startedAt.getTime()) { ctx.addIssue({ code: "custom", path: ["endedAt"], message: "endedAt must be strictly after startedAt", }); } const samples = value.route?.sampleTimestamps; const coords = value.route?.geometry.coordinates; if (samples && coords && samples.length !== coords.length) { ctx.addIssue({ code: "custom", path: ["route", "sampleTimestamps"], message: "sampleTimestamps length must match geometry.coordinates length", }); } }); export type CreateWorkoutInput = z.infer; /** * Upper bound on workouts per batch ingest call. Workouts are heavier * than measurements (each may carry a 20 000-point route geometry), so * the cap is an order of magnitude tighter than the 500-entry * measurements batch. A cold-start iOS HealthKit backfill is "every * workout I've ever recorded" — 100 covers a healthy multi-year history * and rejects pathological cases without forcing pagination on the * happy path. */ export const MAX_WORKOUTS_PER_BATCH = 100; /** * Batch ingest payload — `{ workouts: [createWorkoutSchema, ...] }`. * Mirrors `createBatchMeasurementSchema` shape so the iOS sync engine * can re-use the same retry / cursor plumbing. Each entry is a typed * workout; an optional nested `route` carries the GeoJSON LineString. */ export const createBatchWorkoutSchema = z.object({ workouts: z .array(createWorkoutSchema) .min(1) .max(MAX_WORKOUTS_PER_BATCH), }); export type CreateBatchWorkoutInput = z.infer;