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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 15:57:39 +02:00
1 parent 89fb3bcc6c
commit 5d6f0c3110
4 files changed
+435

No files matched your search

@@ -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
);
+86
View File
@@ -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
@@ -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<string, unknown> = {
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<string, unknown> = {
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();
});
});
+110
View File
@@ -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<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>;