v1.0.0:6 — paginate workout history (infinite scroll)

Two surfaces had invisible 50-row caps that this commit removes.

Exercise history popup (clock button in WorkoutForm):
  - /api/exercises/[id] now accepts ?offset=N&limit=N (default 25,
    max 100) and returns { exercise, history, hasMore }. Pagination
    uses take: limit + 1 to detect hasMore without a second COUNT
    round-trip.
  - Query rewritten to use Prisma's setLogs.some filter — single SQL
    that hits the (userId, deletedAt, date) composite index, instead
    of fetching all set logs then grouping in JS.
  - ExerciseHistoryPopup now uses an IntersectionObserver on a
    sentinel div. When sentinel scrolls into view (root: the popup
    itself, not the viewport), fetches next page and appends. Status
    row at the bottom shows a spinner while loading and "End of
    history" when done.
  - Container max height bumped from h-64 -> h-80 for a bit more
    breathing room on first render.

Workout history page (/main/workouts):
  - Page still server-renders the first 50 workouts (instant paint
    + correct date filter forwarding). Now uses take: PAGE_SIZE + 1
    to detect hasMore.
  - New WorkoutsList client component takes initial workouts +
    hasMore + filter values as props. IntersectionObserver on a
    sentinel below the cards auto-fetches the next page from
    /api/workouts?offset=N&limit=50&q=...&dateFrom=...&dateTo=...
    when scrolled to. Filters round-trip through URL params, so a
    filter change re-renders the page from scratch with a fresh
    first page.
  - "End of history · N workouts" line shown once everything is
    loaded.

Tests:
  - tests/routes-exercise-history.test.ts: 6 new tests covering
    auth, cross-user 404, first-page hasMore=true, second-page
    hasMore=false + no overlap, set-log filter scoped to the
    queried exerciseId, soft-deleted workouts excluded.
  - All 87 tests pass.

No schema changes, no migration. /data untouched.
This commit is contained in:
Keysat
2026-05-09 20:18:31 -05:00
parent dc6a3b1116
commit ffa8e0d480
7 changed files with 553 additions and 86 deletions
+43 -37
View File
@@ -5,10 +5,22 @@ import { z } from "zod";
/**
* GET /api/exercises/[id]
* Get exercise with history
*
* Get the exercise + a paginated slice of its workout history.
*
* Query params:
* - offset: number (default 0) — how many workouts to skip
* - limit: number (default 25, max 100) — page size
*
* Response shape:
* { exercise, history: [{workout:{id,date,name}, sets:[...]}], hasMore: bool }
*
* Pagination uses the take: limit + 1 trick — fetch one extra row, slice
* it off, and use its presence to set hasMore. Avoids a second COUNT()
* query.
*/
export async function GET(
_request: NextRequest,
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
@@ -17,6 +29,10 @@ export async function GET(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const sp = request.nextUrl.searchParams;
const limit = Math.min(parseInt(sp.get("limit") || "25"), 100);
const offset = Math.max(parseInt(sp.get("offset") || "0"), 0);
const exercise = await prisma.exercise.findFirst({
where: {
id: params.id,
@@ -28,47 +44,37 @@ export async function GET(
return NextResponse.json({ error: "Exercise not found" }, { status: 404 });
}
// Get exercise history grouped by workout
const setLogs = await prisma.setLog.findMany({
// Pull workouts that contain this exercise, with only the matching
// set logs included. Cleaner + faster than fetching all set logs
// and grouping in JS — Prisma generates a single SQL with the
// (userId, deletedAt, date) composite index doing the heavy lift.
const rows = await prisma.workout.findMany({
where: {
exerciseId: params.id,
workout: {
userId: user.id,
deletedAt: null,
userId: user.id,
deletedAt: null,
setLogs: { some: { exerciseId: params.id } },
},
select: {
id: true,
date: true,
name: true,
setLogs: {
where: { exerciseId: params.id },
orderBy: { setNumber: "asc" },
},
},
include: {
workout: {
select: {
id: true,
date: true,
name: true,
},
},
},
orderBy: [
{ workout: { date: "desc" } },
{ setNumber: "asc" },
],
take: 500,
orderBy: { date: "desc" },
take: limit + 1,
skip: offset,
});
// Group by workout
const workoutMap = new Map<string, { workout: any; sets: any[] }>();
for (const log of setLogs) {
const key = log.workoutId;
if (!workoutMap.has(key)) {
workoutMap.set(key, { workout: log.workout, sets: [] });
}
workoutMap.get(key)!.sets.push(log);
}
const hasMore = rows.length > limit;
const history = rows.slice(0, limit).map((w) => ({
workout: { id: w.id, date: w.date, name: w.name },
sets: w.setLogs,
}));
const history = Array.from(workoutMap.values()).slice(0, 50);
return NextResponse.json({
exercise,
history,
});
return NextResponse.json({ exercise, history, hasMore });
} catch (error) {
console.error("GET /api/exercises/[id] error:", error);
return NextResponse.json(