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