mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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.
111 lines
3.8 KiB
TypeScript
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>;
|