mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
feat(workouts): attach a route to a stored workout that has none
The workout batch kept `route` only for entries it inserted. An entry matching a stored workout reported `duplicate` and its track was dropped, so turning on `storeWorkoutRoutes` only helped workouts recorded from then on. A matching entry that carries a route now attaches it when the stored workout has no track and the account keeps tracks, the same way the heart-rate series enrichment works: the planner probes for a stored track, the write seals it like an insert does, and the entry reports `enriched` (once, also when it attaches series and track together). The climb derived from the track fills an empty `elevationM`, as it would on insert. A workout that already has a track keeps it, and with routes off nothing is written. Track rows now go through `createManyAndReturn` on the unique `workoutId`, so a track that lands between the probe and the write wins and this entry settles as a duplicate instead of failing or adding a second row.
This commit is contained in:
1 parent
da3d29424d
commit
7b4c67b372
11 files changed
+705
-81
No files matched your search
@@ -20,7 +20,7 @@ vi.mock("@/lib/db", () => ({
|
||||
createManyAndReturn: vi.fn(),
|
||||
},
|
||||
workoutRoute: {
|
||||
createMany: vi.fn(),
|
||||
createManyAndReturn: vi.fn(),
|
||||
},
|
||||
workoutSamples: {
|
||||
createManyAndReturn: vi.fn(),
|
||||
@@ -201,13 +201,16 @@ beforeEach(() => {
|
||||
}) as never,
|
||||
);
|
||||
vi.mocked(prisma.workout.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 });
|
||||
// The series write reports which rows landed, so an enrichment can tell
|
||||
// an attach from a row a concurrent writer got in first. The fake
|
||||
// echoes every row back as landed.
|
||||
// The series and track writes report which rows landed, so an
|
||||
// enrichment can tell an attach from a row a concurrent writer got in
|
||||
// first. The fakes echo every row back as landed.
|
||||
const echoLanded = (async (args: { data: Array<{ workoutId: string }> }) =>
|
||||
args.data.map((row) => ({ workoutId: row.workoutId }))) as never;
|
||||
vi.mocked(prisma.workoutRoute.createManyAndReturn).mockImplementation(
|
||||
echoLanded,
|
||||
);
|
||||
vi.mocked(prisma.workoutSamples.createManyAndReturn).mockImplementation(
|
||||
(async (args: { data: Array<{ workoutId: string }> }) =>
|
||||
args.data.map((row) => ({ workoutId: row.workoutId }))) as never,
|
||||
echoLanded,
|
||||
);
|
||||
// v1.4.43 — default to no per-user source-priority override so
|
||||
// the write-time picker walks the canonical default ladder. Tests
|
||||
@@ -550,7 +553,7 @@ describe("POST /api/workouts/batch — workout-route opt-in", () => {
|
||||
};
|
||||
// Same per-entry status as a stored track: the client needs no branch.
|
||||
expect(body.data.entries[0]?.status).toBe("inserted");
|
||||
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
|
||||
expect(prisma.workoutRoute.createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops the track when the account row carries no opt-in at all", async () => {
|
||||
@@ -564,7 +567,7 @@ describe("POST /api/workouts/batch — workout-route opt-in", () => {
|
||||
|
||||
const res = await POST(makeRequest({ workouts: [withRoute()] }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
|
||||
expect(prisma.workoutRoute.createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -599,8 +602,9 @@ describe("POST /api/workouts/batch — nested route attachment", () => {
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0];
|
||||
expect(prisma.workoutRoute.createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.workoutRoute.createManyAndReturn).mock
|
||||
.calls[0]?.[0];
|
||||
const rows = (
|
||||
call as {
|
||||
data: Array<{
|
||||
@@ -654,7 +658,7 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// The route table is untouched — an indoor workout has no GPS route.
|
||||
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
|
||||
expect(prisma.workoutRoute.createManyAndReturn).not.toHaveBeenCalled();
|
||||
// The HR series lands in the dedicated child table by FK.
|
||||
expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.workoutSamples.createManyAndReturn).mock
|
||||
@@ -703,10 +707,10 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.workoutRoute.createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
const routeRows = (
|
||||
vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0] as {
|
||||
vi.mocked(prisma.workoutRoute.createManyAndReturn).mock.calls[0]?.[0] as {
|
||||
data: Array<{ workoutId: string }>;
|
||||
}
|
||||
).data;
|
||||
|
||||
@@ -27,7 +27,7 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
const txClient = {
|
||||
workout: { createManyAndReturn: vi.fn() },
|
||||
workoutRoute: { createMany: vi.fn() },
|
||||
workoutRoute: { createManyAndReturn: vi.fn() },
|
||||
workoutSamples: { createMany: vi.fn() },
|
||||
};
|
||||
|
||||
@@ -133,7 +133,7 @@ beforeEach(() => {
|
||||
}));
|
||||
},
|
||||
);
|
||||
txClient.workoutRoute.createMany.mockResolvedValue({ count: 0 });
|
||||
txClient.workoutRoute.createManyAndReturn.mockResolvedValue([]);
|
||||
txClient.workoutSamples.createMany.mockResolvedValue({ count: 0 });
|
||||
});
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
* - `Content-Length` ceiling at 5 MB before parsing — anything
|
||||
* larger returns 413 so the iOS client falls back to one workout
|
||||
* per call.
|
||||
* - Per-entry status (`inserted | duplicate | enriched | skipped`) so
|
||||
* the iOS sync cursor can checkpoint accurately.
|
||||
* - Per-entry status (`inserted | duplicate | enriched | updated |
|
||||
* skipped`) so the iOS sync cursor can checkpoint accurately.
|
||||
*
|
||||
* Idempotency contract:
|
||||
* - HTTP-level via `Idempotency-Key` (replays cached envelope).
|
||||
@@ -45,12 +45,15 @@
|
||||
* composite index. Re-posting the same batch surfaces duplicates
|
||||
* rather than failing the call.
|
||||
*
|
||||
* Heart-rate series enrichment: a re-post that matches a stored
|
||||
* workout and carries a `samples` array attaches that series when the
|
||||
* workout has none. The workout row itself stays first-write-wins —
|
||||
* nothing on it is written, not one column — and a workout that already
|
||||
* holds a series is a no-op, so the client's history sweep is repeatable
|
||||
* as often as it likes. See `src/lib/workouts/batch-ingest/plan.ts`.
|
||||
* Enrichment: a re-post that matches a stored workout and carries a
|
||||
* `samples` array attaches that series when the workout has none, and one
|
||||
* that carries a `route` attaches that track when the workout has none and
|
||||
* the account keeps tracks. The workout row itself stays first-write-wins:
|
||||
* a series writes nothing on it, an attached track fills only an empty
|
||||
* `elevationM` with its climb (as an insert would). A workout that already
|
||||
* holds a series or a track keeps it, so the client's history sweep is
|
||||
* repeatable as often as it likes. See
|
||||
* `src/lib/workouts/batch-ingest/plan.ts`.
|
||||
*
|
||||
* Race reconciliation uses PostgreSQL's `INSERT ... RETURNING` through
|
||||
* Prisma's `createManyAndReturn`. The returned composite keys identify the
|
||||
|
||||
Reference in new issue
Block a user