diff --git a/prisma/migrations/0053_workout_and_route/migration.sql b/prisma/migrations/0053_workout_and_route/migration.sql new file mode 100644 index 000000000..bf61f969f --- /dev/null +++ b/prisma/migrations/0053_workout_and_route/migration.sql @@ -0,0 +1,69 @@ +-- v1.4.25 W8d — Workout + WorkoutRoute schema. +-- +-- Locks the workout-ingest contract before the v1.5 iOS-Swift session +-- so the iOS DTO can be generated against the table layout from day +-- one. v1.4.25 ships no UI surface for workouts beyond the VO2-max +-- tile (Migration 0052 + W8d.5); the ingest endpoint + chart row +-- land in a later release. +-- +-- Shape rationale (see .planning/research/w8d-implementation-outline.md +-- §4): +-- * HKWorkout-aligned typed columns for every metric the analytics +-- / doctor-PDF / Coach evidence pipeline reads on the hot path. +-- * `metadata` JSONB blob for the long-tail (HK metadata key/value +-- pairs, Withings hr_zone_0..3, device bundle id) — keeps the +-- table thin while preserving the source representation. +-- * `sport_type` as TEXT (not a Postgres enum) — Apple alone has +-- 70+ HKWorkoutActivityType values; managing them as a Postgres +-- enum would force a migration for every new sport. The Zod +-- boundary in src/lib/validations/* keeps the surface tight. +-- * Dedup on (user_id, source, external_id) — Postgres treats NULL +-- external_id as distinct so manual entries never collide. +-- +-- Route geometry uses GeoJSON LineString stored as JSONB rather than +-- Postgres `point[]` or PostGIS `geometry`. v1.4.20 set the same +-- precedent for correlations: no extension dependency on every +-- self-hosted Postgres install. JSONB also matches the GPX-import +-- target shape and Leaflet / MapLibre consume it directly. + +CREATE TABLE "workouts" ( + "id" TEXT PRIMARY KEY, + "user_id" TEXT NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, + "sport_type" TEXT NOT NULL, + "started_at" TIMESTAMP(3) NOT NULL, + "ended_at" TIMESTAMP(3) NOT NULL, + "duration_sec" INTEGER NOT NULL, + "total_energy_kcal" DOUBLE PRECISION, + "total_distance_m" DOUBLE PRECISION, + "avg_heart_rate" INTEGER, + "max_heart_rate" INTEGER, + "min_heart_rate" INTEGER, + "step_count" INTEGER, + "elevation_m" DOUBLE PRECISION, + "pause_duration_sec" INTEGER, + "source" "measurement_source" NOT NULL DEFAULT 'MANUAL', + "external_id" TEXT, + "external_source_version" TEXT, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL +); + +CREATE UNIQUE INDEX "workouts_user_source_external_key" + ON "workouts" ("user_id", "source", "external_id"); +CREATE INDEX "workouts_user_started_idx" + ON "workouts" ("user_id", "started_at"); +CREATE INDEX "workouts_user_sport_started_idx" + ON "workouts" ("user_id", "sport_type", "started_at"); + +CREATE TABLE "workout_routes" ( + "id" TEXT PRIMARY KEY, + "workout_id" TEXT NOT NULL UNIQUE REFERENCES "workouts"("id") ON DELETE CASCADE, + -- GeoJSON LineString: { type: "LineString", coordinates: [[lon, lat, alt?], ...] } + "geometry" JSONB NOT NULL, + -- Per-sample ISO timestamps + optional speeds / HR, one entry per + -- coordinate in `geometry`. NULL when the source only ships static + -- GPX (e.g. Withings). + "sample_timestamps" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8830d020d..222ce50e0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -184,6 +184,12 @@ model User { integrationStatuses IntegrationStatus[] coachConversations CoachConversation[] coachUsage CoachUsage[] + // v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings + // getworkouts / future Strava). Schema lands now so the v1.5 iOS + // session can build against a stable DTO. Routes hang off Workout + // (1:1), not User — the User → WorkoutRoute path is two hops by + // design. + workouts Workout[] @@map("users") } @@ -358,6 +364,86 @@ model Measurement { @@map("measurements") } +// ─── Workouts (v1.4.25 W8d) ──────────────────────────────── +// +// HKWorkout-aligned typed columns for the analytics hot path + an +// open `metadata Json?` blob for the source-specific tail (HK +// metadata, Withings hr_zone_0..3, device bundle id). `sportType` is +// a TEXT column rather than a Postgres enum so adding a new sport is +// a Zod-only change — Apple alone carries 70+ HKWorkoutActivityType +// values and the long tail will only grow. +model Workout { + id String @id @default(cuid()) + userId String @map("user_id") + /// Canonical sport string. See WorkoutSportType union in + /// src/lib/validations/workout.ts when that ships. Free-text at the + /// DB layer so a new sport is one Zod refinement away. + sportType String @map("sport_type") + startedAt DateTime @map("started_at") + endedAt DateTime @map("ended_at") + /// Denormalised seconds count — query convenience for the + /// "longest run / cycle" PR detector + the Coach evidence chips. + durationSec Int @map("duration_sec") + /// Apple `totalEnergyBurned` / Withings `calories`. NULL when the + /// source did not report it. + totalEnergyKcal Float? @map("total_energy_kcal") + /// Apple `totalDistance` / Withings `distance`. Metres. + totalDistanceM Float? @map("total_distance_m") + /// Apple computes the average post-hoc; Withings reports it + /// directly as `hr_average`. + avgHeartRate Int? @map("avg_heart_rate") + maxHeartRate Int? @map("max_heart_rate") + minHeartRate Int? @map("min_heart_rate") + stepCount Int? @map("step_count") + elevationM Float? @map("elevation_m") + /// Withings `pause_duration`. Apple does not report this directly + /// — derived from HKWorkoutEvent markers in the metadata blob. + pauseDurationSec Int? @map("pause_duration_sec") + source MeasurementSource @default(MANUAL) + /// HK `HKWorkout.uuid` or Withings workout id. NULL for manual + /// entries (Postgres NULL-distinct semantics keep them unique). + externalId String? @map("external_id") + externalSourceVersion String? @map("external_source_version") + /// HKWorkoutEvent markers, Withings hr_zone_0..3, device bundle id. + /// JSON only — never duck-typed at runtime; consumers run their + /// own Zod schema against the slice they care about. + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + route WorkoutRoute? + + /// Ingest dedup. NULL externalId is distinct so two manual + /// entries on the same day never collide here. + @@unique([userId, source, externalId]) + /// Dashboard "recent workouts" query path. + @@index([userId, startedAt]) + /// "Show me my last 10 runs" — per-sport history. + @@index([userId, sportType, startedAt]) + @@map("workouts") +} + +model WorkoutRoute { + id String @id @default(cuid()) + workoutId String @unique @map("workout_id") + /// GeoJSON LineString — `{ "type": "LineString", coordinates: + /// [[lon, lat, alt?], ...] }`. JSONB rather than PostGIS so the + /// Coolify Postgres template stays extension-free (same decision + /// v1.4.20 made for correlations); GPX import + Leaflet / MapLibre + /// consume GeoJSON natively. + geometry Json + /// Optional per-sample timestamps (ISO strings) and speeds — + /// parallel array to `geometry.coordinates`. NULL when the source + /// only ships static GPX (e.g. Withings). + sampleTimestamps Json? @map("sample_timestamps") + createdAt DateTime @default(now()) @map("created_at") + + workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade) + + @@map("workout_routes") +} + // ─── Medications ─────────────────────────────────────────── /// Treatment-class discriminator (v1.4.25 W4d). GLP1 turns on the diff --git a/src/lib/validations/__tests__/workout.test.ts b/src/lib/validations/__tests__/workout.test.ts new file mode 100644 index 000000000..c8aa98ada --- /dev/null +++ b/src/lib/validations/__tests__/workout.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import { + createWorkoutSchema, + geoJsonLineStringSchema, + workoutRouteSamplesSchema, + workoutSportTypeEnum, + type CreateWorkoutInput, +} from "../workout"; + +describe("workoutSportTypeEnum", () => { + it("accepts every documented sport", () => { + for (const sport of [ + "walking", + "running", + "cycling", + "swimming", + "hiit", + "other", + ]) { + expect(workoutSportTypeEnum.parse(sport)).toBe(sport); + } + }); + + it("rejects an unknown sport", () => { + expect(() => workoutSportTypeEnum.parse("teleportation")).toThrow(); + }); +}); + +describe("geoJsonLineStringSchema", () => { + it("accepts a valid GeoJSON LineString with lon/lat pairs", () => { + const parsed = geoJsonLineStringSchema.parse({ + type: "LineString", + coordinates: [ + [11.077, 49.452], + [11.078, 49.453], + ], + }); + expect(parsed.type).toBe("LineString"); + expect(parsed.coordinates).toHaveLength(2); + }); + + it("accepts coordinates with an altitude component", () => { + const parsed = geoJsonLineStringSchema.parse({ + type: "LineString", + coordinates: [ + [11.077, 49.452, 320.5], + [11.078, 49.453, 322.1], + ], + }); + expect(parsed.coordinates[0]).toHaveLength(3); + }); + + it("rejects a Point geometry", () => { + expect(() => + geoJsonLineStringSchema.parse({ + type: "Point", + coordinates: [11.077, 49.452], + }), + ).toThrow(); + }); + + it("rejects a single-point LineString", () => { + expect(() => + geoJsonLineStringSchema.parse({ + type: "LineString", + coordinates: [[11.077, 49.452]], + }), + ).toThrow(/at least 2 points/); + }); + + it("rejects out-of-bounds longitude / latitude", () => { + expect(() => + geoJsonLineStringSchema.parse({ + type: "LineString", + coordinates: [ + [200, 49.452], + [201, 49.453], + ], + }), + ).toThrow(); + expect(() => + geoJsonLineStringSchema.parse({ + type: "LineString", + coordinates: [ + [11.077, 200], + [11.078, 201], + ], + }), + ).toThrow(); + }); +}); + +describe("workoutRouteSamplesSchema", () => { + it("accepts an array of timestamp + optional speed/hr entries", () => { + const parsed = workoutRouteSamplesSchema.parse([ + { t: "2026-05-14T07:00:00.000Z", speedMs: 3.2, hr: 142 }, + { t: "2026-05-14T07:00:05.000Z" }, + ]); + expect(parsed).toHaveLength(2); + }); + + it("rejects negative speed", () => { + expect(() => + workoutRouteSamplesSchema.parse([ + { t: "2026-05-14T07:00:00.000Z", speedMs: -1 }, + ]), + ).toThrow(); + }); +}); + +describe("createWorkoutSchema", () => { + const minimalRun: Record = { + sportType: "running", + startedAt: "2026-05-14T06:30:00.000Z", + endedAt: "2026-05-14T07:15:00.000Z", + }; + + it("accepts a minimal workout payload", () => { + const parsed = createWorkoutSchema.parse(minimalRun); + expect(parsed.sportType).toBe("running"); + expect(parsed.startedAt).toBeInstanceOf(Date); + expect(parsed.endedAt).toBeInstanceOf(Date); + expect(parsed.source).toBe("MANUAL"); + }); + + it("accepts an Apple-Health-shaped HKWorkout payload", () => { + const input: Record = { + sportType: "running", + startedAt: "2026-05-14T06:30:00.000Z", + endedAt: "2026-05-14T07:15:00.000Z", + totalEnergyKcal: 412.3, + totalDistanceM: 7_800, + avgHeartRate: 154, + maxHeartRate: 178, + minHeartRate: 92, + source: "APPLE_HEALTH", + externalId: "B5F8-...-A3", + metadata: { HKAverageMETs: 8.4, sourceBundleId: "com.apple.health" }, + route: { + geometry: { + type: "LineString", + coordinates: [ + [11.077, 49.452, 320.5], + [11.078, 49.453, 322.1], + ], + }, + sampleTimestamps: [ + { t: "2026-05-14T06:30:00.000Z", speedMs: 3.2, hr: 142 }, + { t: "2026-05-14T06:30:05.000Z", speedMs: 3.3, hr: 144 }, + ], + }, + }; + const parsed: CreateWorkoutInput = createWorkoutSchema.parse(input); + expect(parsed.source).toBe("APPLE_HEALTH"); + expect(parsed.totalDistanceM).toBe(7_800); + expect(parsed.route?.geometry.coordinates).toHaveLength(2); + }); + + it("rejects an unknown sportType", () => { + expect(() => + createWorkoutSchema.parse({ ...minimalRun, sportType: "fartlek" }), + ).toThrow(); + }); + + it("rejects a max heart rate below the resting floor", () => { + expect(() => + createWorkoutSchema.parse({ ...minimalRun, maxHeartRate: 5 }), + ).toThrow(); + }); +}); diff --git a/src/lib/validations/workout.ts b/src/lib/validations/workout.ts new file mode 100644 index 000000000..cd5061d2c --- /dev/null +++ b/src/lib/validations/workout.ts @@ -0,0 +1,110 @@ +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; + +/** + * 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; + +/** + * 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; + +/** + * 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;