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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user