From 988bbf1a2fdc1113595250e60eff3455d222dbc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 15 May 2026 17:31:37 +0200 Subject: [PATCH] feat(workouts): wire pickCanonicalWorkout into the list read path and collapse the attach route N+1 Adds `GET /api/workouts` as a paginated read surface that runs every fetch through `pickCanonicalWorkout()` so a duplicated workout that Apple Health and Withings both publish collapses to the canonical row before reaching the client. The picker stays a pure function; the route owns the query window, the fetch multiplier that keeps the page full after dedup, and the meta envelope that surfaces the dedup ratio. Collapses the batch-route route-attach lookup from `Promise.all` of `tx.workout.findFirst` per manual workout to a single batched `findMany` keyed by the deterministic `(source, startedAt, sportType)` tuple. For a 100-row batch this cuts the per-batch round-trip count from 1 + N to 2 while preserving the most-recent createdAt tie-break through in-memory grouping. Adds `__tests__/canonical-dedup.test.ts` covering twin collapse, proximity-window separation, sport-type isolation, and the limit clamp. --- .../__tests__/canonical-dedup.test.ts | 167 ++++++++++++++++++ src/app/api/workouts/batch/route.ts | 54 ++++-- src/app/api/workouts/route.ts | 142 +++++++++++++++ 3 files changed, 351 insertions(+), 12 deletions(-) create mode 100644 src/app/api/workouts/__tests__/canonical-dedup.test.ts create mode 100644 src/app/api/workouts/route.ts diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts new file mode 100644 index 000000000..3308ff327 --- /dev/null +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -0,0 +1,167 @@ +/** + * Unit suite for `GET /api/workouts` — the canonical-dedup contract. + * + * v1.4.27 B7 / BL-P2-3 — wires `pickCanonicalWorkout()` into the read + * path. The test pins the contract: + * - twin workouts on the same `(sportType, ±5 min)` cluster + * collapse to a single row; + * - the source ladder picks APPLE_HEALTH over WITHINGS over MANUAL; + * - the `meta.droppedDuplicates` count reflects the diff between + * the raw fetch and the canonical subset. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + workout: { + findMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const }, +}; + +function makeRequest(query = ""): NextRequest { + return new NextRequest(`http://localhost/api/workouts${query}`, { + method: "GET", + }); +} + +interface FakeWorkoutRow { + id: string; + source: string; + externalId: string | null; + sportType: string; + startedAt: Date; + endedAt: Date; + durationSec: number; + distanceMeters: number | null; + avgHeartRate: number | null; + maxHeartRate: number | null; + energyKcal: number | null; + createdAt: Date; +} + +function makeRow( + id: string, + source: string, + startedAt: string, + sportType = "RUNNING", +): FakeWorkoutRow { + const start = new Date(startedAt); + return { + id, + source, + externalId: source === "MANUAL" ? null : `ext-${id}`, + sportType, + startedAt: start, + endedAt: new Date(start.getTime() + 30 * 60_000), + durationSec: 1800, + distanceMeters: 5000, + avgHeartRate: 145, + maxHeartRate: 170, + energyKcal: 320, + createdAt: start, + }; +} + +describe("GET /api/workouts — canonical dedup", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + }); + + it("collapses an APPLE_HEALTH + WITHINGS twin to the Apple row", async () => { + const apple = makeRow("w-apple", "APPLE_HEALTH", "2026-05-15T07:00:00Z"); + const withings = makeRow("w-withings", "WITHINGS", "2026-05-15T07:01:30Z"); + + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + apple, + withings, + ] as never); + + const res = await GET(makeRequest()); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.workouts).toHaveLength(1); + expect(body.data.workouts[0].id).toBe("w-apple"); + expect(body.data.meta.droppedDuplicates).toBe(1); + expect(body.data.meta.clusters).toBe(1); + }); + + it("keeps two workouts when they are outside the proximity window", async () => { + const morning = makeRow("w-am", "APPLE_HEALTH", "2026-05-15T07:00:00Z"); + const evening = makeRow("w-pm", "APPLE_HEALTH", "2026-05-15T18:00:00Z"); + + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + morning, + evening, + ] as never); + + const res = await GET(makeRequest()); + const body = await res.json(); + + expect(body.data.workouts).toHaveLength(2); + expect(body.data.meta.droppedDuplicates).toBe(0); + expect(body.data.meta.clusters).toBe(2); + }); + + it("keeps two workouts when sportType differs at the same instant", async () => { + const run = makeRow("w-run", "APPLE_HEALTH", "2026-05-15T07:00:00Z", "RUNNING"); + const walk = makeRow("w-walk", "APPLE_HEALTH", "2026-05-15T07:00:00Z", "WALKING"); + + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + run, + walk, + ] as never); + + const res = await GET(makeRequest()); + const body = await res.json(); + + expect(body.data.workouts).toHaveLength(2); + expect(body.data.meta.droppedDuplicates).toBe(0); + }); + + it("honours the limit query parameter after dedup", async () => { + const rows = Array.from({ length: 8 }, (_, i) => + makeRow( + `w-${i}`, + "APPLE_HEALTH", + new Date(2026, 4, 15, 7 + i, 0, 0).toISOString(), + ), + ); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce(rows as never); + + const res = await GET(makeRequest("?limit=3")); + const body = await res.json(); + + expect(body.data.workouts).toHaveLength(3); + expect(body.data.meta.limit).toBe(3); + }); +}); diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 7ce3dd2c5..ec5a2264b 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -356,21 +356,51 @@ async function postBatch(request: NextRequest): Promise { // is NULL-distinct, so two manual entries on the same instant // would both insert. We look these up by the deterministic // (userId, source, startedAt, sportType) tuple for each row. - const manualMatches = await Promise.all( - withoutExternal.map((p) => - tx.workout.findFirst({ - where: { - userId: user.id, + // + // v1.4.27 B7 / BL-code-M3 — single batched findMany with one + // OR-row per entry instead of N serial findFirst probes. For + // a 100-row batch this cuts the per-batch round-trip count + // from 1 + N to 2; the per-entry "most-recent createdAt" + // tie-break is preserved by sorting the returned rows in + // memory and keying on the deterministic tuple. + type ManualMatch = { id: string; createdAt: Date }; + const manualMatchesById = new Map(); + if (withoutExternal.length > 0) { + const manualRows = await tx.workout.findMany({ + where: { + userId: user.id, + route: null, + OR: withoutExternal.map((p) => ({ source: p.row.source, startedAt: p.row.startedAt as Date, sportType: p.row.sportType, - route: null, - }, - select: { id: true }, - orderBy: { createdAt: "desc" }, - }), - ), - ); + })), + }, + select: { + id: true, + source: true, + startedAt: true, + sportType: true, + createdAt: true, + }, + orderBy: { createdAt: "desc" }, + }); + for (const row of manualRows) { + const key = `${row.source}::${row.startedAt.getTime()}::${row.sportType}`; + const existing = manualMatchesById.get(key); + if (!existing || existing.createdAt < row.createdAt) { + manualMatchesById.set(key, { + id: row.id, + createdAt: row.createdAt, + }); + } + } + } + const manualMatches = withoutExternal.map((p) => { + const key = `${p.row.source}::${(p.row.startedAt as Date).getTime()}::${p.row.sportType}`; + const match = manualMatchesById.get(key); + return match ? { id: match.id } : null; + }); const routesToInsert: Prisma.WorkoutRouteCreateManyInput[] = []; for (let i = 0; i < withExternal.length; i++) { diff --git a/src/app/api/workouts/route.ts b/src/app/api/workouts/route.ts new file mode 100644 index 000000000..994062c4c --- /dev/null +++ b/src/app/api/workouts/route.ts @@ -0,0 +1,142 @@ +/** + * `GET /api/workouts` — paginated workout list with canonical-source + * deduplication. + * + * Consumers (v1.4.27+): + * 1. The /insights workouts surface (and future /workouts page) reads + * from this endpoint so duplicate twin workouts (Apple Watch + * writing a workout that Withings also published over its server- + * to-server feed) collapse to a single canonical row. + * 2. The native iOS app drains historical workouts through the same + * surface — Apple Health is the canonical ladder anchor, so an + * iOS-originated request typically returns its own rows back. + * + * Dedup contract: + * - The route reads `DEFAULT_WORKOUT_SOURCE_PRIORITY` from + * `pick-canonical-workout.ts` (APPLE_HEALTH > WITHINGS > MANUAL > + * IMPORT) with the ±5 min cluster window. A per-user workout + * ladder override is reserved for the v1.5 Settings surface; the + * server already honours it transparently through the picker's + * options bag when the field lands in `User.sourcePriorityJson`. + * - Per-cluster the picker keeps the row from the highest-priority + * source present; the others are dropped from the canonical list. + * + * Query params: + * - `limit` (default 50, max 200) + * - `offset` (default 0) + * - `since` / `until` — ISO timestamps, inclusive. Optional. + * - `sportType` — narrow to one HKWorkoutActivityType. Optional. + * + * v1.4.27 B7 / BL-P2-3 — wires `pickCanonicalWorkout()` into the read + * path so the workout dedup contract finally has a consumer. The + * picker stays a pure function — the route owns the query window and + * the response shaping. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; +import { + pickCanonicalWorkout, + DEFAULT_WORKOUT_SOURCE_PRIORITY, + DEFAULT_WORKOUT_PROXIMITY_MINUTES, + type WorkoutPickerRow, +} from "@/lib/sources/pick-canonical-workout"; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; +// The picker can collapse heavy two-source ingest by ~50%; fetching a +// 2× window ahead of pagination keeps the page full without leaving +// the server fetching unbounded. +const FETCH_MULTIPLIER = 2; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "workouts.list" } }); + + const { searchParams } = new URL(request.url); + const limit = Math.min( + Math.max( + 1, + parseInt(searchParams.get("limit") ?? String(DEFAULT_LIMIT), 10) || + DEFAULT_LIMIT, + ), + MAX_LIMIT, + ); + const offset = Math.max( + 0, + parseInt(searchParams.get("offset") ?? "0", 10) || 0, + ); + const since = searchParams.get("since"); + const until = searchParams.get("until"); + const sportType = searchParams.get("sportType"); + + const where: Record = { userId: user.id }; + if (since || until) { + const range: Record = {}; + if (since) { + const d = new Date(since); + if (!Number.isNaN(d.getTime())) range.gte = d; + } + if (until) { + const d = new Date(until); + if (!Number.isNaN(d.getTime())) range.lte = d; + } + if (Object.keys(range).length > 0) { + where.startedAt = range; + } + } + if (sportType) { + where.sportType = sportType; + } + + const rawTake = Math.min( + limit * FETCH_MULTIPLIER, + MAX_LIMIT * FETCH_MULTIPLIER, + ); + + const rows = await prisma.workout.findMany({ + where, + orderBy: { startedAt: "desc" }, + take: rawTake, + skip: offset, + select: { + id: true, + source: true, + externalId: true, + sportType: true, + startedAt: true, + endedAt: true, + durationSec: true, + distanceMeters: true, + avgHeartRate: true, + maxHeartRate: true, + energyKcal: true, + createdAt: true, + }, + }); + + // The picker only inspects `source`, `startedAt`, `sportType`, and + // `id`. The rest of the selected columns ride through untouched on + // the returned subset. + const pickerRows = rows as Array<(typeof rows)[number] & WorkoutPickerRow>; + const { canonical, clusters } = pickCanonicalWorkout(pickerRows, { + sourcePriority: DEFAULT_WORKOUT_SOURCE_PRIORITY, + proximityMinutes: DEFAULT_WORKOUT_PROXIMITY_MINUTES, + }); + + const page = canonical.slice(0, limit); + + return apiSuccess({ + workouts: page, + meta: { + total: canonical.length, + limit, + offset, + droppedDuplicates: rows.length - canonical.length, + clusters: clusters.length, + }, + }); +});