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(
|
||||
|
||||
@@ -3,7 +3,9 @@ import Link from "next/link";
|
||||
import { Plus, Activity, Upload } from "lucide-react";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getWorkouts } from "@/lib/db/workouts";
|
||||
import WorkoutCard from "@/components/workouts/WorkoutCard";
|
||||
import WorkoutsList from "@/components/workouts/WorkoutsList";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface PageProps {
|
||||
searchParams: { q?: string; dateFrom?: string; dateTo?: string };
|
||||
@@ -31,13 +33,16 @@ export default async function WorkoutsPage({ searchParams }: PageProps) {
|
||||
? new Date(searchParams.dateTo)
|
||||
: undefined;
|
||||
|
||||
// Fetch workouts
|
||||
const workouts = await getWorkouts(user.id, {
|
||||
// Fetch first page + 1 extra row to detect hasMore without a second
|
||||
// count() query. The +1 row is sliced off before render.
|
||||
const fetched = await getWorkouts(user.id, {
|
||||
query,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
limit: 50,
|
||||
limit: PAGE_SIZE + 1,
|
||||
});
|
||||
const hasMore = fetched.length > PAGE_SIZE;
|
||||
const workouts = fetched.slice(0, PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A]">
|
||||
@@ -135,11 +140,15 @@ export default async function WorkoutsPage({ searchParams }: PageProps) {
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 pb-20 sm:pb-6">
|
||||
{workouts.map((workout) => (
|
||||
<WorkoutCard key={workout.id} workout={workout} />
|
||||
))}
|
||||
</div>
|
||||
<WorkoutsList
|
||||
initialWorkouts={workouts}
|
||||
initialHasMore={hasMore}
|
||||
filters={{
|
||||
q: query || undefined,
|
||||
dateFrom: searchParams.dateFrom,
|
||||
dateTo: searchParams.dateTo,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ import SetRow, { InputField } from "./SetRow";
|
||||
import { formatSetsSummary } from "@/lib/formatSets";
|
||||
|
||||
// --------------- Exercise History Popup ---------------
|
||||
type HistoryEntry = {
|
||||
workout: { id: string; date: string; name?: string };
|
||||
sets: Array<{ weight?: number; reps?: number; weightUnit?: string }>;
|
||||
};
|
||||
|
||||
const HISTORY_PAGE_SIZE = 25;
|
||||
|
||||
function ExerciseHistoryPopup({
|
||||
exerciseId,
|
||||
onClose,
|
||||
@@ -16,27 +23,81 @@ function ExerciseHistoryPopup({
|
||||
exerciseId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [history, setHistory] = useState<
|
||||
Array<{ workout: { id: string; date: string; name?: string }; sets: Array<{ weight?: number; reps?: number; weightUnit?: string }> }>
|
||||
>([]);
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Initial fetch (page 1)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/exercises/${exerciseId}`);
|
||||
const res = await fetch(
|
||||
`/api/exercises/${exerciseId}?offset=0&limit=${HISTORY_PAGE_SIZE}`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (!cancelled) setHistory(data.history || []);
|
||||
if (!cancelled) {
|
||||
setHistory(data.history || []);
|
||||
setHasMore(!!data.hasMore);
|
||||
}
|
||||
} else if (!cancelled) {
|
||||
setError(`Failed to load history (${res.status})`);
|
||||
}
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError("Failed to load history");
|
||||
}
|
||||
if (!cancelled) setLoading(false);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [exerciseId]);
|
||||
|
||||
// Infinite scroll — observe a sentinel below the rendered list. The
|
||||
// root is the popup's scroll container (the popup itself), not the
|
||||
// viewport, since the user scrolls inside the popup.
|
||||
useEffect(() => {
|
||||
if (loading || !hasMore || !sentinelRef.current || !popupRef.current) {
|
||||
return;
|
||||
}
|
||||
const sentinel = sentinelRef.current;
|
||||
const root = popupRef.current;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries[0]?.isIntersecting) return;
|
||||
if (loadingMore || !hasMore) return;
|
||||
setLoadingMore(true);
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/exercises/${exerciseId}?offset=${history.length}&limit=${HISTORY_PAGE_SIZE}`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setHistory((prev) => [...prev, ...(data.history || [])]);
|
||||
setHasMore(!!data.hasMore);
|
||||
} else {
|
||||
setError(`Failed to load more (${res.status})`);
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load more");
|
||||
setHasMore(false);
|
||||
}
|
||||
setLoadingMore(false);
|
||||
})();
|
||||
},
|
||||
{ root, rootMargin: "60px" },
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [loading, hasMore, loadingMore, history.length, exerciseId]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -51,11 +112,17 @@ function ExerciseHistoryPopup({
|
||||
return (
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="absolute left-0 right-0 top-full mt-1 z-50 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl max-h-64 overflow-y-auto"
|
||||
className="absolute left-0 right-0 top-full mt-1 z-50 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl max-h-80 overflow-y-auto"
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800 sticky top-0 bg-zinc-900">
|
||||
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Recent History</span>
|
||||
<button type="button" onClick={onClose} className="p-0.5 text-zinc-500 hover:text-white">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800 sticky top-0 bg-zinc-900 z-10">
|
||||
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
|
||||
Exercise History
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-0.5 text-zinc-500 hover:text-white"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -66,25 +133,50 @@ function ExerciseHistoryPopup({
|
||||
) : history.length === 0 ? (
|
||||
<p className="text-xs text-zinc-500 text-center py-4">No history yet</p>
|
||||
) : (
|
||||
<div className="divide-y divide-zinc-800/50">
|
||||
{history.slice(0, 50).map((entry) => {
|
||||
const d = new Date(entry.workout.date);
|
||||
const dateStr = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
const summary = formatSetsSummary(entry.sets);
|
||||
return (
|
||||
<div key={entry.workout.id} className="px-3 py-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[11px] text-zinc-500 flex-shrink-0">{dateStr}</span>
|
||||
<span className="text-[11px] text-zinc-600">·</span>
|
||||
<span className="text-[11px] text-zinc-500">{entry.sets.length} set{entry.sets.length !== 1 ? "s" : ""}</span>
|
||||
<>
|
||||
<div className="divide-y divide-zinc-800/50">
|
||||
{history.map((entry) => {
|
||||
const d = new Date(entry.workout.date);
|
||||
const dateStr = d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const summary = formatSetsSummary(entry.sets);
|
||||
return (
|
||||
<div key={entry.workout.id} className="px-3 py-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[11px] text-zinc-500 flex-shrink-0">
|
||||
{dateStr}
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-600">·</span>
|
||||
<span className="text-[11px] text-zinc-500">
|
||||
{entry.sets.length} set
|
||||
{entry.sets.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{summary && (
|
||||
<p className="text-xs text-zinc-300 mt-0.5">{summary}</p>
|
||||
)}
|
||||
</div>
|
||||
{summary && (
|
||||
<p className="text-xs text-zinc-300 mt-0.5">{summary}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Sentinel + status row at the bottom of the list */}
|
||||
<div ref={sentinelRef} className="flex justify-center py-2">
|
||||
{loadingMore && (
|
||||
<Loader className="w-3.5 h-3.5 animate-spin text-zinc-500" />
|
||||
)}
|
||||
{!loadingMore && !hasMore && history.length >= HISTORY_PAGE_SIZE && (
|
||||
<span className="text-[10px] text-zinc-600 uppercase tracking-wider">
|
||||
End of history
|
||||
</span>
|
||||
)}
|
||||
{error && (
|
||||
<span className="text-[10px] text-red-400">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader } from 'lucide-react';
|
||||
import WorkoutCard from './WorkoutCard';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface Filters {
|
||||
q?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
export default function WorkoutsList({
|
||||
initialWorkouts,
|
||||
initialHasMore,
|
||||
filters,
|
||||
}: {
|
||||
// Loose typing — WorkoutCard accepts the same shape getWorkouts returns,
|
||||
// both client- and server-side. Avoids importing Prisma types into a
|
||||
// client component which drags PrismaClient into the client bundle.
|
||||
initialWorkouts: any[];
|
||||
initialHasMore: boolean;
|
||||
filters: Filters;
|
||||
}) {
|
||||
const [items, setItems] = useState(initialWorkouts);
|
||||
const [hasMore, setHasMore] = useState(initialHasMore);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Reset when filters or initial data change (URL-driven re-render)
|
||||
useEffect(() => {
|
||||
setItems(initialWorkouts);
|
||||
setHasMore(initialHasMore);
|
||||
setError(null);
|
||||
}, [initialWorkouts, initialHasMore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore || !sentinelRef.current) return;
|
||||
const sentinel = sentinelRef.current;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries[0]?.isIntersecting) return;
|
||||
if (loadingMore || !hasMore) return;
|
||||
setLoadingMore(true);
|
||||
const params = new URLSearchParams({
|
||||
offset: String(items.length),
|
||||
limit: String(PAGE_SIZE),
|
||||
});
|
||||
if (filters.q) params.set('q', filters.q);
|
||||
if (filters.dateFrom) params.set('dateFrom', filters.dateFrom);
|
||||
if (filters.dateTo) params.set('dateTo', filters.dateTo);
|
||||
fetch(`/api/workouts?${params}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
setError(`Failed to load more (${res.status})`);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
const body = await res.json();
|
||||
setItems((prev) => [...prev, ...(body.data ?? [])]);
|
||||
setHasMore(!!body.meta?.hasMore);
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Failed to load more');
|
||||
setHasMore(false);
|
||||
})
|
||||
.finally(() => setLoadingMore(false));
|
||||
},
|
||||
{ rootMargin: '300px' },
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, loadingMore, items.length, filters.q, filters.dateFrom, filters.dateTo]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3 pb-20 sm:pb-6">
|
||||
{items.map((workout) => (
|
||||
<WorkoutCard key={workout.id} workout={workout} />
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="flex justify-center py-6">
|
||||
{loadingMore ? (
|
||||
<Loader className="w-5 h-5 animate-spin text-zinc-500" />
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600 uppercase tracking-wider">
|
||||
Loading more...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && items.length >= PAGE_SIZE && (
|
||||
<p className="text-center text-xs text-zinc-600 uppercase tracking-wider py-6">
|
||||
End of history · {items.length} workouts
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded bg-red-900/50 px-3 py-2 mt-3 border border-red-800 text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
const { getCurrentUserMock } = vi.hoisted(() => ({
|
||||
getCurrentUserMock: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/auth', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, getCurrentUser: getCurrentUserMock };
|
||||
});
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { GET as getExerciseDetail } from '@/app/api/exercises/[id]/route';
|
||||
|
||||
function req(url: string): NextRequest {
|
||||
return new NextRequest(url, {
|
||||
method: 'GET',
|
||||
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||
}
|
||||
|
||||
async function makeUserWithExerciseAndWorkouts(opts: {
|
||||
email: string;
|
||||
exerciseName: string;
|
||||
workoutCount: number;
|
||||
}) {
|
||||
const user = await prisma.user.create({
|
||||
data: { email: opts.email, passwordHash: 'fake', isAdmin: false },
|
||||
});
|
||||
const exercise = await prisma.exercise.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: opts.exerciseName,
|
||||
type: 'barbell',
|
||||
muscleGroups: '[]',
|
||||
},
|
||||
});
|
||||
// Create N workouts spaced 1 day apart, descending. Each contains
|
||||
// 2 sets of the exercise + 1 set of an unrelated exercise (to
|
||||
// prove the route filters set logs to the queried exerciseId).
|
||||
const otherExercise = await prisma.exercise.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: `Other for ${opts.exerciseName}`,
|
||||
type: 'cable',
|
||||
muscleGroups: '[]',
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < opts.workoutCount; i++) {
|
||||
const date = new Date(2026, 0, 1 + i); // ascending dates → newer is bigger
|
||||
await prisma.workout.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
date,
|
||||
setLogs: {
|
||||
create: [
|
||||
{ exerciseId: exercise.id, setNumber: 1, reps: 5, weight: 100 + i },
|
||||
{ exerciseId: exercise.id, setNumber: 2, reps: 5, weight: 100 + i },
|
||||
{ exerciseId: otherExercise.id, setNumber: 1, reps: 10 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return { user, exercise, otherExercise };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.session.deleteMany();
|
||||
await prisma.exercise.deleteMany();
|
||||
await prisma.workout.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
await prisma.instanceSettings.deleteMany();
|
||||
getCurrentUserMock.mockReset();
|
||||
});
|
||||
|
||||
describe('GET /api/exercises/[id] (paginated history)', () => {
|
||||
it('returns 401 unauthenticated', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(null);
|
||||
const res = await getExerciseDetail(req('http://x/api/exercises/anything'), {
|
||||
params: { id: 'anything' },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 404 when exercise belongs to another user', async () => {
|
||||
const me = await prisma.user.create({
|
||||
data: { email: 'me@x', passwordHash: 'fake', isAdmin: false },
|
||||
});
|
||||
const { exercise } = await makeUserWithExerciseAndWorkouts({
|
||||
email: 'them@x',
|
||||
exerciseName: 'TheirEx',
|
||||
workoutCount: 1,
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(me);
|
||||
const res = await getExerciseDetail(req('http://x/api/exercises/' + exercise.id), {
|
||||
params: { id: exercise.id },
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('first page returns the latest N workouts ordered date-desc with hasMore=true when more exist', async () => {
|
||||
const { user, exercise } = await makeUserWithExerciseAndWorkouts({
|
||||
email: 'a@x',
|
||||
exerciseName: 'Bench',
|
||||
workoutCount: 30,
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(user);
|
||||
const res = await getExerciseDetail(
|
||||
req(`http://x/api/exercises/${exercise.id}?offset=0&limit=10`),
|
||||
{ params: { id: exercise.id } },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.history).toHaveLength(10);
|
||||
expect(body.hasMore).toBe(true);
|
||||
|
||||
// Date-descending — the newest workout is first.
|
||||
const dates = body.history.map((h: { workout: { date: string } }) =>
|
||||
new Date(h.workout.date).getTime(),
|
||||
);
|
||||
for (let i = 1; i < dates.length; i++) {
|
||||
expect(dates[i - 1]).toBeGreaterThanOrEqual(dates[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it('second page returns the next slice and hasMore flips to false at the end', async () => {
|
||||
const { user, exercise } = await makeUserWithExerciseAndWorkouts({
|
||||
email: 'a@x',
|
||||
exerciseName: 'Bench',
|
||||
workoutCount: 18,
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(user);
|
||||
|
||||
const r1 = await (
|
||||
await getExerciseDetail(
|
||||
req(`http://x/api/exercises/${exercise.id}?offset=0&limit=10`),
|
||||
{ params: { id: exercise.id } },
|
||||
)
|
||||
).json();
|
||||
expect(r1.history).toHaveLength(10);
|
||||
expect(r1.hasMore).toBe(true);
|
||||
|
||||
const r2 = await (
|
||||
await getExerciseDetail(
|
||||
req(`http://x/api/exercises/${exercise.id}?offset=10&limit=10`),
|
||||
{ params: { id: exercise.id } },
|
||||
)
|
||||
).json();
|
||||
expect(r2.history).toHaveLength(8); // 18 - 10
|
||||
expect(r2.hasMore).toBe(false);
|
||||
|
||||
// No overlap between pages.
|
||||
const ids1 = new Set(
|
||||
r1.history.map((h: { workout: { id: string } }) => h.workout.id),
|
||||
);
|
||||
for (const h of r2.history) {
|
||||
expect(ids1.has(h.workout.id)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('only returns set logs for the queried exerciseId (not unrelated sets in the same workout)', async () => {
|
||||
const { user, exercise } = await makeUserWithExerciseAndWorkouts({
|
||||
email: 'a@x',
|
||||
exerciseName: 'Bench',
|
||||
workoutCount: 1,
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(user);
|
||||
const res = await getExerciseDetail(
|
||||
req(`http://x/api/exercises/${exercise.id}?offset=0&limit=25`),
|
||||
{ params: { id: exercise.id } },
|
||||
);
|
||||
const body = await res.json();
|
||||
expect(body.history).toHaveLength(1);
|
||||
// Each workout in the seed had 2 sets of `exercise` + 1 set of
|
||||
// `otherExercise`. The response should only show the 2.
|
||||
expect(body.history[0].sets).toHaveLength(2);
|
||||
expect(
|
||||
body.history[0].sets.every(
|
||||
(s: { exerciseId: string }) => s.exerciseId === exercise.id,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('omits soft-deleted workouts from the history', async () => {
|
||||
const { user, exercise } = await makeUserWithExerciseAndWorkouts({
|
||||
email: 'a@x',
|
||||
exerciseName: 'Bench',
|
||||
workoutCount: 5,
|
||||
});
|
||||
// Soft-delete the most recent workout
|
||||
const newest = await prisma.workout.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { date: 'desc' },
|
||||
});
|
||||
await prisma.workout.update({
|
||||
where: { id: newest!.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(user);
|
||||
const res = await getExerciseDetail(
|
||||
req(`http://x/api/exercises/${exercise.id}`),
|
||||
{ params: { id: exercise.id } },
|
||||
);
|
||||
const body = await res.json();
|
||||
expect(body.history).toHaveLength(4);
|
||||
expect(body.history.every((h: { workout: { id: string } }) => h.workout.id !== newest!.id)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,23 +4,22 @@ import { v_1_0_0_2 } from './v1.0.0.2'
|
||||
import { v_1_0_0_3 } from './v1.0.0.3'
|
||||
import { v_1_0_0_4 } from './v1.0.0.4'
|
||||
import { v_1_0_0_5 } from './v1.0.0.5'
|
||||
import { v_1_0_0_6 } from './v1.0.0.6'
|
||||
|
||||
/**
|
||||
* Version graph for the `proof-of-work` package.
|
||||
*
|
||||
* v1.0.0:1 — initial release, seeded cutover from the legacy
|
||||
* `workout-log` package.
|
||||
* v1.0.0:1 — initial release, seeded cutover from `workout-log`.
|
||||
* v1.0.0:2 — CSP fix.
|
||||
* v1.0.0:3 — post-cutover seed strip.
|
||||
* v1.0.0:4 — removes the default admin@local credentials; operator
|
||||
* must run the StartOS Action to bootstrap the first admin.
|
||||
* v1.0.0:5 — internal cleanup (removes caloriesBurned raw-SQL
|
||||
* workaround). No user-facing change.
|
||||
*
|
||||
* StartOS picks `current` as the install target; `other` lists every
|
||||
* node that can upgrade into `current`.
|
||||
* v1.0.0:4 — removes default admin@local credentials; operator must
|
||||
* run StartOS Action to bootstrap the first admin.
|
||||
* v1.0.0:5 — internal cleanup (caloriesBurned raw-SQL workaround).
|
||||
* v1.0.0:6 — paginate workout history (infinite scroll); removes
|
||||
* invisible 50-row caps on the clock-button popup and
|
||||
* the /main/workouts page.
|
||||
*/
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_1_0_0_5,
|
||||
other: [v_1_0_0_1, v_1_0_0_2, v_1_0_0_3, v_1_0_0_4],
|
||||
current: v_1_0_0_6,
|
||||
other: [v_1_0_0_1, v_1_0_0_2, v_1_0_0_3, v_1_0_0_4, v_1_0_0_5],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
/**
|
||||
* v1.0.0:6 — paginate history, no more 50-row caps.
|
||||
*
|
||||
* Two surfaces had hard 50-row caps that were invisible to the user:
|
||||
*
|
||||
* - The clock-button "Exercise History" popup in the workout
|
||||
* logging form: only ever showed the most recent 50 workouts
|
||||
* containing that exercise. No way to see further back.
|
||||
*
|
||||
* - The /main/workouts page: only ever rendered the most recent
|
||||
* 50 workouts. The only way to reach older ones was the date
|
||||
* filter, but you had to know the date.
|
||||
*
|
||||
* Both now use server-side pagination + client-side infinite scroll
|
||||
* via IntersectionObserver. The first page renders identically to
|
||||
* before (instant paint, server-rendered for /main/workouts; first
|
||||
* 25 fetched on popup open). Subsequent pages auto-load when the
|
||||
* sentinel element scrolls into view. "End of history · N workouts"
|
||||
* shown once everything is loaded.
|
||||
*
|
||||
* Server queries use the `take: limit + 1` trick to detect hasMore
|
||||
* without a second COUNT() round-trip. The exercise-history query
|
||||
* was also rewritten to use Prisma's `setLogs.some` filter
|
||||
* (single SQL, hits the (userId, deletedAt, date) composite index)
|
||||
* instead of fetching all set logs and grouping in JS.
|
||||
*
|
||||
* No schema changes, no migration, no data movement. /data is
|
||||
* untouched.
|
||||
*/
|
||||
export const v_1_0_0_6 = VersionInfo.of({
|
||||
version: '1.0.0:6',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Paginate workout history. The clock-button "Exercise History" popup in the workout logger now scrolls infinitely to load older workouts. The /main/workouts page now does the same — scroll to the bottom and the next page auto-loads. No more invisible 50-row cap. No data migration; existing /data untouched.',
|
||||
},
|
||||
migrations: {
|
||||
up: async () => {},
|
||||
down: IMPOSSIBLE,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user