mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
d247f6966a
commit
30be0c4e0d
8 files changed
+463
-13
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in new issue
Block a user