mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user