diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9e63b701b..08f8aa46d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -834,6 +834,35 @@ paths: - t required: - geometry + samples: + minItems: 1 + maxItems: 30000 + type: array + items: + type: object + properties: + t: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + hr: + type: integer + minimum: 20 + maximum: 300 + speedMs: + type: number + minimum: 0 + maximum: 50 + power: + type: number + minimum: 0 + maximum: 3000 + cadence: + type: number + minimum: 0 + maximum: 400 + required: + - t required: - sportType - startedAt @@ -4322,6 +4351,10 @@ components: anyOf: - $ref: "#/components/schemas/WorkoutRouteGeometry" - type: "null" + samples: + anyOf: + - $ref: "#/components/schemas/WorkoutHrSeries" + - type: "null" canonicalId: type: string required: @@ -4342,6 +4375,7 @@ components: - externalId - metadata - route + - samples - canonicalId additionalProperties: false WorkoutRouteGeometry: @@ -4358,6 +4392,18 @@ components: - geometry - sampleTimestamps additionalProperties: false + WorkoutHrSeries: + type: object + properties: + sampleCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + samples: {} + required: + - sampleCount + - samples + additionalProperties: false BatchWorkoutsResponse: type: object properties: diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index 9c9980967..d2b7141a9 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -50,6 +50,12 @@ export const GET = apiHandler( createdAt: true, }, }, + samples: { + select: { + samples: true, + sampleCount: true, + }, + }, }, }); @@ -108,6 +114,16 @@ export const GET = apiHandler( } : null; + // v1.10.0 — route-independent per-workout HR series. Present for + // both indoor (no route) and outdoor workouts that shipped a + // `samples` array on ingest; null otherwise. + const samples = row.samples + ? { + sampleCount: row.samples.sampleCount, + samples: row.samples.samples, + } + : null; + return apiSuccess({ id: row.id, sportType: row.sportType, @@ -126,6 +142,7 @@ export const GET = apiHandler( externalId: row.externalId, metadata: row.metadata, route, + samples, // v1.4.32 — when the requested id is a non-canonical twin the // caller can redirect to `canonicalId` to land on the cluster // winner. `canonicalId === id` when the requested row already diff --git a/src/app/api/workouts/__tests__/batch-create.test.ts b/src/app/api/workouts/__tests__/batch-create.test.ts index 74a19557b..e834f4abb 100644 --- a/src/app/api/workouts/__tests__/batch-create.test.ts +++ b/src/app/api/workouts/__tests__/batch-create.test.ts @@ -21,6 +21,9 @@ vi.mock("@/lib/db", () => ({ workoutRoute: { createMany: vi.fn(), }, + workoutSamples: { + createMany: vi.fn(), + }, user: { findUnique: vi.fn(), }, @@ -123,6 +126,7 @@ beforeEach(() => { vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 0 }); vi.mocked(prisma.workout.findFirst).mockResolvedValue(null); vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 }); + vi.mocked(prisma.workoutSamples.createMany).mockResolvedValue({ count: 0 }); // v1.4.43 W9 — default to no per-user source-priority override so // the write-time picker walks the canonical default ladder. Tests // that exercise a custom ladder override this in-line. @@ -386,6 +390,122 @@ describe("POST /api/workouts/batch — nested route attachment", () => { }); }); +describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => { + it("creates a WorkoutSamples row for an indoor workout (samples, no route)", async () => { + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); + vi.mocked(prisma.workout.findMany).mockImplementation((async (args: { + select?: { id?: true }; + }) => { + if (args.select?.id) { + return [ + { + id: "wkt-indoor-id", + source: "APPLE_HEALTH", + externalId: "uuid-indoor", + }, + ]; + } + return []; + }) as never); + + const res = await POST( + makeRequest({ + workouts: [ + validWorkout("uuid-indoor", { + sportType: "strength", + samples: [ + { t: "2026-05-14T06:30:00.000Z", hr: 110 }, + { t: "2026-05-14T06:30:05.000Z", hr: 118 }, + { t: "2026-05-14T06:30:10.000Z", hr: 124, power: 0, cadence: 0 }, + ], + }), + ], + }), + ); + expect(res.status).toBe(200); + // The route table is untouched — an indoor workout has no GPS route. + expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled(); + // The HR series lands in the dedicated child table by FK. + expect(prisma.workoutSamples.createMany).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.workoutSamples.createMany).mock.calls[0]?.[0]; + const rows = ( + call as { + data: Array<{ + workoutId: string; + sampleCount: number; + samples: unknown; + }>; + } + ).data; + expect(rows[0]?.workoutId).toBe("wkt-indoor-id"); + // `sampleCount` is the denormalised series length. + expect(rows[0]?.sampleCount).toBe(3); + expect((rows[0]?.samples as unknown[]).length).toBe(3); + }); + + it("attaches both a route and an HR series on one workout", async () => { + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); + vi.mocked(prisma.workout.findMany).mockImplementation((async (args: { + select?: { id?: true }; + }) => { + if (args.select?.id) { + return [ + { + id: "wkt-both-id", + source: "APPLE_HEALTH", + externalId: "uuid-both", + }, + ]; + } + return []; + }) as never); + + const res = await POST( + makeRequest({ + workouts: [ + validWorkout("uuid-both", { + route: { + geometry: { + type: "LineString", + coordinates: [ + [11.077, 49.452], + [11.078, 49.453], + ], + }, + }, + samples: [{ t: "2026-05-14T06:30:00.000Z", hr: 142 }], + }), + ], + }), + ); + expect(res.status).toBe(200); + expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1); + expect(prisma.workoutSamples.createMany).toHaveBeenCalledTimes(1); + const routeRows = ( + vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0] as { + data: Array<{ workoutId: string }>; + } + ).data; + const sampleRows = ( + vi.mocked(prisma.workoutSamples.createMany).mock.calls[0]?.[0] as { + data: Array<{ workoutId: string }>; + } + ).data; + // Both children attach to the SAME freshly-inserted workout id. + expect(routeRows[0]?.workoutId).toBe("wkt-both-id"); + expect(sampleRows[0]?.workoutId).toBe("wkt-both-id"); + }); + + it("does not touch the samples table when no workout ships a series", async () => { + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); + const res = await POST( + makeRequest({ workouts: [validWorkout("uuid-no-series")] }), + ); + expect(res.status).toBe(200); + expect(prisma.workoutSamples.createMany).not.toHaveBeenCalled(); + }); +}); + describe("POST /api/workouts/batch — user source-priority (v1.4.43 W9)", () => { it("looks up the user's source-priority blob exactly once per batch", async () => { vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 29d13dea4..5ee00fb99 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -218,6 +218,13 @@ async function postBatch(request: NextRequest): Promise { geometry: unknown; sampleTimestamps: unknown; } | null; + /** Route-independent per-workout HR series. NULL when the entry + * ships no `samples` array. Persisted to the `WorkoutSamples` + * child by FK after the workout row lands. */ + samples: { + samples: unknown; + sampleCount: number; + } | null; /** Composite-key probe for the pre-flight findMany. NULL when * the entry has no externalId — those rows always pass through * as "inserted" because the NULL-distinct unique index can never @@ -237,6 +244,12 @@ async function postBatch(request: NextRequest): Promise { sampleTimestamps: (w.route.sampleTimestamps ?? null) as unknown, } : null; + const samples = w.samples + ? { + samples: w.samples as unknown, + sampleCount: w.samples.length, + } + : null; const row: Prisma.WorkoutCreateManyInput = { userId: user.id, sportType: w.sportType, @@ -262,6 +275,7 @@ async function postBatch(request: NextRequest): Promise { index, row, route, + samples, dedupKey: w.externalId ? { source: w.source, externalId: w.externalId } : null, @@ -372,19 +386,27 @@ async function postBatch(request: NextRequest): Promise { } // Look up the workouts we just wrote (or that the racing batch - // wrote) so we can attach routes by FK. We probe by the + // wrote) so we can attach their child rows (route geometry + + // the route-independent HR series) by FK. We probe by the // composite unique key for entries that have one, and by the // `(userId, source, startedAt, sportType)` tuple as a fallback // for manual entries (where externalId is null). The fallback // is best-effort — manual entries from the iOS client always // carry an HK uuid, so the null-externalId case is exercised // only by future surface (manual workout entry). - const routeAttachable = toInsert.filter((p) => p.route !== null); - if (routeAttachable.length > 0) { - const withExternal = routeAttachable.filter( + // + // v1.10.0 — `samples` (the per-workout HR series) rides the + // SAME id lookup as `route`: an indoor workout carries samples + // with no route, so the lookup runs whenever EITHER child is + // present on a to-insert entry. + const childAttachable = toInsert.filter( + (p) => p.route !== null || p.samples !== null, + ); + if (childAttachable.length > 0) { + const withExternal = childAttachable.filter( (p) => p.dedupKey !== null, ); - const withoutExternal = routeAttachable.filter( + const withoutExternal = childAttachable.filter( (p) => p.dedupKey === null, ); @@ -463,13 +485,30 @@ async function postBatch(request: NextRequest): Promise { return match ? { id: match.id } : null; }); + // Resolve the freshly-written workout id for a prepared + // entry — by composite key for externalId-bearing rows, by + // the deterministic (source, startedAt, sportType) tuple for + // manual rows. Shared by both child tables (route + samples) + // so the HR series and the route attach to the same row. + const resolveId = ( + p: Prepared, + manualIndex: number, + ): string | undefined => { + if (p.dedupKey !== null) { + return idBySourceExt.get( + `${p.dedupKey.source}::${p.dedupKey.externalId}`, + ); + } + return manualMatches[manualIndex]?.id ?? undefined; + }; + const routesToInsert: Prisma.WorkoutRouteCreateManyInput[] = []; + const samplesToInsert: Prisma.WorkoutSamplesCreateManyInput[] = []; for (let i = 0; i < withExternal.length; i++) { const p = withExternal[i]; - const id = idBySourceExt.get( - `${p.dedupKey!.source}::${p.dedupKey!.externalId}`, - ); - if (id && p.route) { + const id = resolveId(p, -1); + if (!id) continue; + if (p.route) { routesToInsert.push({ workoutId: id, geometry: p.route.geometry as Prisma.InputJsonValue, @@ -479,13 +518,21 @@ async function postBatch(request: NextRequest): Promise { : (p.route.sampleTimestamps as Prisma.InputJsonValue), }); } + if (p.samples) { + samplesToInsert.push({ + workoutId: id, + samples: p.samples.samples as Prisma.InputJsonValue, + sampleCount: p.samples.sampleCount, + }); + } } for (let i = 0; i < withoutExternal.length; i++) { const p = withoutExternal[i]; - const match = manualMatches[i]; - if (match?.id && p.route) { + const id = resolveId(p, i); + if (!id) continue; + if (p.route) { routesToInsert.push({ - workoutId: match.id, + workoutId: id, geometry: p.route.geometry as Prisma.InputJsonValue, sampleTimestamps: p.route.sampleTimestamps === null @@ -493,6 +540,13 @@ async function postBatch(request: NextRequest): Promise { : (p.route.sampleTimestamps as Prisma.InputJsonValue), }); } + if (p.samples) { + samplesToInsert.push({ + workoutId: id, + samples: p.samples.samples as Prisma.InputJsonValue, + sampleCount: p.samples.sampleCount, + }); + } } if (routesToInsert.length > 0) { @@ -508,6 +562,19 @@ async function postBatch(request: NextRequest): Promise { }); } } + + if (samplesToInsert.length > 0) { + for (let i = 0; i < samplesToInsert.length; i += CHUNK) { + await tx.workoutSamples.createMany({ + data: samplesToInsert.slice(i, i + CHUNK), + // 1:1 FK on `workoutId` (UNIQUE), same idempotency as + // the route child: a re-submitted batch that wins the + // workout-row race but loses the sample-row race is a + // no-op, not a hard error. + skipDuplicates: true, + }); + } + } } }); diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index e322b886c..32d23dd53 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -125,7 +125,7 @@ coachPrefsSchema.meta({ createBatchWorkoutSchema.meta({ id: "CreateBatchWorkoutRequest", description: - "v1.4.25 W16b — typed workout batch ingest. Each entry is an HKWorkout-aligned record with an optional nested GeoJSON LineString route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only).", + "Typed workout batch ingest. Each entry is an HKWorkout-aligned record with an optional nested GeoJSON LineString route AND an optional route-independent per-workout heart-rate series (`samples`: `[{ t, hr?, speedMs?, power?, cadence? }]`, up to 30 000 points). The `samples` series is the strain-engine input for indoor workouts that have no GPS route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only).", }); const coachMessageFeedbackBody = z @@ -501,6 +501,17 @@ const workoutRouteGeometry = z }) .meta({ id: "WorkoutRouteGeometry" }); +// v1.10.0 — route-independent per-workout heart-rate series. Present +// for indoor and outdoor workouts that shipped a `samples` array on +// ingest. `samples` mirrors the ingest shape `[{ t, hr?, speedMs?, +// power?, cadence? }]`; `sampleCount` is the denormalised length. +const workoutHrSeries = z + .object({ + sampleCount: z.number().int().nonnegative(), + samples: z.unknown(), + }) + .meta({ id: "WorkoutHrSeries" }); + const workoutDetailResponse = z .object({ id: z.string(), @@ -520,6 +531,7 @@ const workoutDetailResponse = z externalId: z.string().nullable(), metadata: z.unknown().nullable(), route: workoutRouteGeometry.nullable(), + samples: workoutHrSeries.nullable(), canonicalId: z.string(), }) .meta({ id: "WorkoutDetailResponse" }); diff --git a/src/lib/validations/__tests__/workout.test.ts b/src/lib/validations/__tests__/workout.test.ts index 557f63f33..297d88864 100644 --- a/src/lib/validations/__tests__/workout.test.ts +++ b/src/lib/validations/__tests__/workout.test.ts @@ -4,7 +4,9 @@ import { createWorkoutSchema, geoJsonLineStringSchema, MAX_ROUTE_POINTS, + MAX_WORKOUT_HR_SAMPLES, MAX_WORKOUTS_PER_BATCH, + workoutHrSamplesSchema, workoutRouteSamplesSchema, workoutSportTypeEnum, type CreateWorkoutInput, @@ -111,6 +113,61 @@ describe("workoutRouteSamplesSchema", () => { }); }); +describe("workoutHrSamplesSchema", () => { + it("accepts a route-independent HR series with optional channels", () => { + const parsed = workoutHrSamplesSchema.parse([ + { t: "2026-05-14T07:00:00.000Z", hr: 142 }, + { t: "2026-05-14T07:00:05.000Z", hr: 145, speedMs: 3.1 }, + { t: "2026-05-14T07:00:10.000Z", hr: 150, power: 220, cadence: 88 }, + ]); + expect(parsed).toHaveLength(3); + }); + + it("accepts a bare-timestamp entry (sparse series)", () => { + expect( + workoutHrSamplesSchema.parse([{ t: "2026-05-14T07:00:00.000Z" }]), + ).toHaveLength(1); + }); + + it("rejects an empty series", () => { + expect(() => workoutHrSamplesSchema.parse([])).toThrow(); + }); + + it("rejects an out-of-range heart rate", () => { + expect(() => + workoutHrSamplesSchema.parse([ + { t: "2026-05-14T07:00:00.000Z", hr: 5 }, + ]), + ).toThrow(); + }); + + it("rejects a series above the per-workout sample cap", () => { + const oversized = Array.from( + { length: MAX_WORKOUT_HR_SAMPLES + 1 }, + (_v, i) => ({ + t: new Date(Date.UTC(2026, 4, 14, 7, 0, 0) + i * 1000).toISOString(), + hr: 140, + }), + ); + expect(() => workoutHrSamplesSchema.parse(oversized)).toThrow( + /sample.*cap/i, + ); + }); + + it("accepts a series exactly at the cap", () => { + const atCap = Array.from( + { length: MAX_WORKOUT_HR_SAMPLES }, + (_v, i) => ({ + t: new Date(Date.UTC(2026, 4, 14, 7, 0, 0) + i * 1000).toISOString(), + hr: 140, + }), + ); + expect(workoutHrSamplesSchema.parse(atCap)).toHaveLength( + MAX_WORKOUT_HR_SAMPLES, + ); + }); +}); + describe("createWorkoutSchema", () => { const minimalRun: Record = { sportType: "running", @@ -261,6 +318,40 @@ describe("createWorkoutSchema", () => { }); expect(parsed.route?.sampleTimestamps).toHaveLength(2); }); + + it("accepts a route-independent HR series on an indoor workout (no route)", () => { + const parsed = createWorkoutSchema.parse({ + sportType: "strength", + startedAt: "2026-05-14T06:30:00.000Z", + endedAt: "2026-05-14T07:15:00.000Z", + source: "APPLE_HEALTH", + externalId: "indoor-1", + samples: [ + { t: "2026-05-14T06:30:00.000Z", hr: 110 }, + { t: "2026-05-14T06:30:05.000Z", hr: 118 }, + ], + }); + expect(parsed.route).toBeUndefined(); + expect(parsed.samples).toHaveLength(2); + }); + + it("accepts both a GPS route and a canonical HR series on one workout", () => { + const parsed = createWorkoutSchema.parse({ + ...minimalRun, + route: { + geometry: { + type: "LineString", + coordinates: [ + [11.077, 49.452], + [11.078, 49.453], + ], + }, + }, + samples: [{ t: "2026-05-14T06:30:00.000Z", hr: 142 }], + }); + expect(parsed.route?.geometry.coordinates).toHaveLength(2); + expect(parsed.samples).toHaveLength(1); + }); }); describe("createBatchWorkoutSchema", () => { diff --git a/src/lib/validations/workout.ts b/src/lib/validations/workout.ts index 5b8f35c46..f26864aae 100644 --- a/src/lib/validations/workout.ts +++ b/src/lib/validations/workout.ts @@ -91,6 +91,51 @@ export const workoutRouteSamplesSchema = z.array( export type WorkoutRouteSamples = z.infer; +/** + * Upper bound on per-workout heart-rate samples. HealthKit emits a HR + * sample roughly every 5 s during a workout — a 6-hour ultra at that + * cadence lands at ~4 320 points, and even a 1 Hz reconstruction of + * that session is ~21 600. 30 000 covers any realistic single session + * (including long endurance events sampled at 1 Hz) while still + * rejecting pathological payloads. The series is the largest new + * workout-side write stream, so the cap keeps each workout's payload — + * and the total request body — bounded the same way `MAX_ROUTE_POINTS` + * bounds the route geometry. iOS should downsample to ≤ this many + * points (e.g. 1-min buckets for very long sessions) before posting. + */ +export const MAX_WORKOUT_HR_SAMPLES = 30_000; + +/** + * Route-INDEPENDENT per-workout heart-rate series — the + * `WorkoutSamples.samples` shape. Mirrors `workoutRouteSamplesSchema` + * but carries no geometry coupling, so an indoor workout (no GPS) can + * still ship its HR signal for the training-strain engine. + * + * `t` is the per-sample ISO timestamp; `hr` beats/min. `speedMs` / + * `power` / `cadence` are optional companion channels HealthKit may + * report (cycling power meters, running cadence). Every channel is + * optional EXCEPT `t` so a sparse series (HR only) is valid; an entry + * with no signal channel at all is still permitted (a bare timestamp) + * but carries no analytical value. + */ +export const workoutHrSamplesSchema = z + .array( + z.object({ + t: z.iso.datetime({ offset: true }), + hr: z.number().int().min(20).max(300).optional(), + speedMs: z.number().min(0).max(50).optional(), + power: z.number().min(0).max(3000).optional(), + cadence: z.number().min(0).max(400).optional(), + }), + ) + .min(1, "samples requires at least 1 point") + .max( + MAX_WORKOUT_HR_SAMPLES, + `samples exceeds the ${MAX_WORKOUT_HR_SAMPLES}-point cap`, + ); + +export type WorkoutHrSamples = z.infer; + /** * Workout insert payload — the shape `POST /api/workouts/batch` accepts. * Defined here so the iOS Swift session can generate its DTO against the @@ -131,6 +176,15 @@ export const createWorkoutSchema = z sampleTimestamps: workoutRouteSamplesSchema.optional(), }) .optional(), + /** + * Route-independent per-workout heart-rate series. Persisted to the + * `WorkoutSamples` child so an indoor workout (no GPS route) still + * yields a strain-relevant HR signal. Independent of `route` — a + * GPS workout MAY carry both (`route.sampleTimestamps` for the + * geometry-aligned series, `samples` for the canonical HR series); + * an indoor workout carries only `samples`. + */ + samples: workoutHrSamplesSchema.optional(), }) .superRefine((value, ctx) => { if (value.endedAt.getTime() <= value.startedAt.getTime()) { diff --git a/tests/integration/workout-batch-create.test.ts b/tests/integration/workout-batch-create.test.ts index c1dde3885..0bcbf6277 100644 --- a/tests/integration/workout-batch-create.test.ts +++ b/tests/integration/workout-batch-create.test.ts @@ -97,6 +97,13 @@ interface WorkoutFixture { geometry: { type: "LineString"; coordinates: [number, number][] }; sampleTimestamps?: Array<{ t: string; speedMs?: number; hr?: number }>; }; + samples?: Array<{ + t: string; + hr?: number; + speedMs?: number; + power?: number; + cadence?: number; + }>; } function makeRequest( @@ -189,6 +196,42 @@ describe("POST /api/workouts/batch (real Postgres)", () => { expect(workouts[0]?.durationSec).toBe(45 * 60); }); + it("persists a route-independent HR series for an indoor workout (v1.10.0)", async () => { + const { POST } = await import("@/app/api/workouts/batch/route"); + + const body = { + workouts: [ + baseWorkout("hk-uuid-indoor-001", { + sportType: "strength", + // No route — an indoor session has no GPS geometry. + samples: [ + { t: "2026-05-14T06:30:00.000Z", hr: 110 }, + { t: "2026-05-14T06:30:05.000Z", hr: 118, power: 0, cadence: 0 }, + { t: "2026-05-14T06:30:10.000Z", hr: 124 }, + ], + }), + ], + }; + + const res = await POST(makeRequest(body)); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { inserted: number }; + }; + expect(json.data.inserted).toBe(1); + + const workouts = await getPrismaClient().workout.findMany({ + where: { userId: TEST_USER_ID, externalId: "hk-uuid-indoor-001" }, + include: { route: true, samples: true }, + }); + expect(workouts).toHaveLength(1); + // No route row — the series lives in the dedicated child table. + expect(workouts[0]?.route).toBeNull(); + expect(workouts[0]?.samples).not.toBeNull(); + expect(workouts[0]?.samples?.sampleCount).toBe(3); + expect((workouts[0]?.samples?.samples as unknown[]).length).toBe(3); + }); + it("inserts a 100-workout batch with a mix of routed and routeless entries", async () => { const { POST } = await import("@/app/api/workouts/batch/route");