Files
HealthLog/src/lib/validations/workout.ts
T
Marc-André Bombeck 5d6f0c3110 feat(schema): Workout + WorkoutRoute tables for HKWorkout passthrough (Migration 0053)
Locks the workout-ingest contract before the v1.5 iOS-Swift session.
v1.4.25 ships no UI surface for workouts beyond the VO2-max tile
landing in W8d.5; the table layout is in place so the iOS DTO can
be generated against it and the v1.4.26 XML / Strava workers can
target the same shape.

Schema highlights (full rationale in
.planning/research/w8d-implementation-outline.md §4):

  - Typed columns for every hot-path metric (sport, duration, energy,
    distance, HR avg/max/min, steps, elevation, pause). HKWorkout-
    shaped 1:1, so the iOS DTO needs zero glue. Withings v2/measure
    getworkouts maps cleanly modulo `hr_zone_0..3` (-> metadata).
  - Open `metadata Json?` blob for the long tail (HK metadata bag,
    Withings HR-zone breakdown, device bundle id).
  - `sportType` is TEXT, not a Postgres enum — Apple alone carries
    70+ HKWorkoutActivityType values and the tail only grows. Adding
    a new sport is a Zod refinement, never a Postgres migration.
  - Composite unique `(userId, source, externalId)` for ingest dedup;
    Postgres NULL-distinct semantics keep manual entries non-colliding.
  - WorkoutRoute is 1:1 with Workout, FK on workoutId with cascade
    delete. Geometry is GeoJSON LineString in JSONB (no PostGIS
    dependency — v1.4.20 set the same precedent for correlations).
    Per-sample timestamps + speed/HR ride in `sample_timestamps` and
    stay parallel to the coordinate array.

New module src/lib/validations/workout.ts:
  - workoutSportTypeEnum: 20-member roster covering ≥98% of typical
    workouts in the open-source corpora referenced in the outline.
  - geoJsonLineStringSchema: RFC 7946 §3.1.4 with optional altitude
    component; ≥2 points, lon ∈ [-180, 180], lat ∈ [-90, 90].
  - workoutRouteSamplesSchema: parallel-array timestamp + speed + HR.
  - createWorkoutSchema: full HKWorkout-shaped payload Zod for the
    eventual POST /api/measurements/batch?kind=workout endpoint.

13 unit tests cover the happy path (Apple Health full payload), the
minimal-payload defaults, sport-type rejection, GeoJSON validation
(Point rejected, single-point LineString rejected, OOB coordinates
rejected), and route-sample edge cases. No ingest route ships in
v1.4.25 — the schema is the contract.
2026-05-14 15:57:39 +02:00

111 lines
3.8 KiB
TypeScript

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<typeof workoutSportTypeEnum>;
/**
* 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"),
});
export type GeoJsonLineString = z.infer<typeof geoJsonLineStringSchema>;
/**
* 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<typeof workoutRouteSamplesSchema>;
/**
* Workout insert payload — the shape `POST /api/measurements/batch?kind=workout`
* will accept once that endpoint lands (v1.5). 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.
*/
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(),
});
export type CreateWorkoutInput = z.infer<typeof createWorkoutSchema>;