fix(workouts): one canonical-workout picker with one source order

Three pickers decided which of two overlapping workouts to keep: the
read-time picker the list, the detail page and the Coach use, a
write-time batch picker with its own 90-second window, and an unwired
field-merge picker. They used different source orders (the `steps`
ladder, or a separate workout default when no order was saved), so the
batch route could keep a row the list then ranked below another.

The batch route now runs the read-time picker over the rows it is about
to insert, with the ladder from `workoutSourceLadder`, and reports what
it hides as duplicates. A session HealthKit hands over twice under two
ids is stored once, as the list already showed it. The write-time and
the unwired picker are removed.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 04:37:39 +02:00
1 parent 2861075676
commit e355c6120f
8 files changed
+109 -71

No files matched your search

+34 -1
View File
@@ -356,8 +356,16 @@ describe("POST /api/workouts/batch (real Postgres)", () => {
it("preserves same-source workouts and surfaces exact re-posts as duplicates", async () => {
const { POST } = await import("@/app/api/workouts/batch/route");
// Two sessions of one source in the same five-minute slot: different
// start and length, so two workouts.
const body = {
workouts: [baseWorkout("hk-uuid-dup-1"), baseWorkout("hk-uuid-dup-2")],
workouts: [
baseWorkout("hk-uuid-dup-1"),
baseWorkout("hk-uuid-dup-2", {
startedAt: "2026-05-14T06:32:00.000Z",
endedAt: "2026-05-14T06:50:00.000Z",
}),
],
};
const first = await POST(makeRequest(body));
@@ -393,6 +401,31 @@ describe("POST /api/workouts/batch (real Postgres)", () => {
expect(stored).toBe(2);
});
it("stores a session re-sent under a new id once, the way the list shows it", async () => {
// HealthKit can hand the same session over twice with two external ids.
// The list collapses it to one workout; the write keeps one row and
// reports the other as a duplicate instead of storing what no read shows.
const { POST } = await import("@/app/api/workouts/batch/route");
const res = await POST(
makeRequest({
workouts: [
baseWorkout("hk-uuid-resend-1"),
baseWorkout("hk-uuid-resend-2"),
],
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { inserted: number; duplicates: number };
};
expect(json.data.inserted).toBe(1);
expect(json.data.duplicates).toBe(1);
const stored = await getPrismaClient().workout.count({
where: { userId: TEST_USER_ID },
});
expect(stored).toBe(1);
});
it("replays a cached response when the same Idempotency-Key is reused", async () => {
const { POST } = await import("@/app/api/workouts/batch/route");
+3 -3
View File
@@ -182,9 +182,9 @@ describe("POST /api/workouts/batch — concurrent-write race", () => {
const { POST } = await import("@/app/api/workouts/batch/route");
// Batch A — a cross-source pair one minute apart plus one shared key.
// The v1.4.42 `dedupeWorkoutBatch` picker collapses the Apple Health +
// Manual pair (same activity, within the 90 s window) to one
// surviving row, leaving 2 effective rows per batch.
// The workout picker collapses the Apple Health + Manual pair (same
// activity, same five-minute slot) to one surviving row, leaving 2
// effective rows per batch.
const batchA: WorkoutFixture[] = [
workout("hk-uuid-only-a-0", 1),
workout("hk-uuid-only-a-1", 2, "MANUAL"),