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