feat(workouts): accept an optional HR series on the batch contract

Extend POST /api/workouts/batch with an optional per-workout `samples`
array — a route-independent heart-rate series so indoor workouts still
yield a strain-relevant signal. Each entry is `{ t, hr?, speedMs?,
power?, cadence? }`; the array is capped at 30 000 points
(MAX_WORKOUT_HR_SAMPLES), covering a long endurance session sampled at
1 Hz while bounding the largest new workout-side write stream the same
way the 20 000-point route cap bounds the geometry.

Persistence rides the existing route-attachment path: the freshly
written workout id is resolved once (by composite key, or the manual
tuple), then both the route geometry and the HR series attach by FK
with skipDuplicates so a re-submitted batch stays idempotent. A workout
may carry both a GPS route and a canonical HR series, or just one.

GET /api/workouts/{id} returns the series alongside the route. OpenAPI
regenerated.

`samples` keeps first-write-wins/overwrite parity with the workout row
it hangs off: the 1:1 FK plus skipDuplicates means a re-ingest of an
existing workout never duplicates or mutates its series.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 22:02:46 +02:00
1 parent d247f6966a
commit 30be0c4e0d
8 files changed
+463 -13

No files matched your search

+46
View File
@@ -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:
+17
View File
@@ -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
@@ -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 });
+79 -12
View File
@@ -218,6 +218,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
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<Response> {
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<Response> {
index,
row,
route,
samples,
dedupKey: w.externalId
? { source: w.source, externalId: w.externalId }
: null,
@@ -372,19 +386,27 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
// 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<Response> {
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<Response> {
: (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<Response> {
: (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<Response> {
});
}
}
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,
});
}
}
}
});
+13 -1
View File
@@ -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" });
@@ -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<string, unknown> = {
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", () => {
+54
View File
@@ -91,6 +91,51 @@ export const workoutRouteSamplesSchema = z.array(
export type WorkoutRouteSamples = z.infer<typeof workoutRouteSamplesSchema>;
/**
* 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<typeof workoutHrSamplesSchema>;
/**
* 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()) {
@@ -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");