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 /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(
|
export async function GET(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -17,6 +29,10 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
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({
|
const exercise = await prisma.exercise.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: params.id,
|
id: params.id,
|
||||||
@@ -28,47 +44,37 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: "Exercise not found" }, { status: 404 });
|
return NextResponse.json({ error: "Exercise not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get exercise history grouped by workout
|
// Pull workouts that contain this exercise, with only the matching
|
||||||
const setLogs = await prisma.setLog.findMany({
|
// 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: {
|
where: {
|
||||||
exerciseId: params.id,
|
userId: user.id,
|
||||||
workout: {
|
deletedAt: null,
|
||||||
userId: user.id,
|
setLogs: { some: { exerciseId: params.id } },
|
||||||
deletedAt: null,
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
date: true,
|
||||||
|
name: true,
|
||||||
|
setLogs: {
|
||||||
|
where: { exerciseId: params.id },
|
||||||
|
orderBy: { setNumber: "asc" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
orderBy: { date: "desc" },
|
||||||
workout: {
|
take: limit + 1,
|
||||||
select: {
|
skip: offset,
|
||||||
id: true,
|
|
||||||
date: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: [
|
|
||||||
{ workout: { date: "desc" } },
|
|
||||||
{ setNumber: "asc" },
|
|
||||||
],
|
|
||||||
take: 500,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group by workout
|
const hasMore = rows.length > limit;
|
||||||
const workoutMap = new Map<string, { workout: any; sets: any[] }>();
|
const history = rows.slice(0, limit).map((w) => ({
|
||||||
for (const log of setLogs) {
|
workout: { id: w.id, date: w.date, name: w.name },
|
||||||
const key = log.workoutId;
|
sets: w.setLogs,
|
||||||
if (!workoutMap.has(key)) {
|
}));
|
||||||
workoutMap.set(key, { workout: log.workout, sets: [] });
|
|
||||||
}
|
|
||||||
workoutMap.get(key)!.sets.push(log);
|
|
||||||
}
|
|
||||||
|
|
||||||
const history = Array.from(workoutMap.values()).slice(0, 50);
|
return NextResponse.json({ exercise, history, hasMore });
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
exercise,
|
|
||||||
history,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("GET /api/exercises/[id] error:", error);
|
console.error("GET /api/exercises/[id] error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import Link from "next/link";
|
|||||||
import { Plus, Activity, Upload } from "lucide-react";
|
import { Plus, Activity, Upload } from "lucide-react";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { getWorkouts } from "@/lib/db/workouts";
|
import { getWorkouts } from "@/lib/db/workouts";
|
||||||
import WorkoutCard from "@/components/workouts/WorkoutCard";
|
import WorkoutsList from "@/components/workouts/WorkoutsList";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
searchParams: { q?: string; dateFrom?: string; dateTo?: string };
|
searchParams: { q?: string; dateFrom?: string; dateTo?: string };
|
||||||
@@ -31,13 +33,16 @@ export default async function WorkoutsPage({ searchParams }: PageProps) {
|
|||||||
? new Date(searchParams.dateTo)
|
? new Date(searchParams.dateTo)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Fetch workouts
|
// Fetch first page + 1 extra row to detect hasMore without a second
|
||||||
const workouts = await getWorkouts(user.id, {
|
// count() query. The +1 row is sliced off before render.
|
||||||
|
const fetched = await getWorkouts(user.id, {
|
||||||
query,
|
query,
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo,
|
dateTo,
|
||||||
limit: 50,
|
limit: PAGE_SIZE + 1,
|
||||||
});
|
});
|
||||||
|
const hasMore = fetched.length > PAGE_SIZE;
|
||||||
|
const workouts = fetched.slice(0, PAGE_SIZE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0A0A0A]">
|
<div className="min-h-screen bg-[#0A0A0A]">
|
||||||
@@ -135,11 +140,15 @@ export default async function WorkoutsPage({ searchParams }: PageProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 pb-20 sm:pb-6">
|
<WorkoutsList
|
||||||
{workouts.map((workout) => (
|
initialWorkouts={workouts}
|
||||||
<WorkoutCard key={workout.id} workout={workout} />
|
initialHasMore={hasMore}
|
||||||
))}
|
filters={{
|
||||||
</div>
|
q: query || undefined,
|
||||||
|
dateFrom: searchParams.dateFrom,
|
||||||
|
dateTo: searchParams.dateTo,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import SetRow, { InputField } from "./SetRow";
|
|||||||
import { formatSetsSummary } from "@/lib/formatSets";
|
import { formatSetsSummary } from "@/lib/formatSets";
|
||||||
|
|
||||||
// --------------- Exercise History Popup ---------------
|
// --------------- 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({
|
function ExerciseHistoryPopup({
|
||||||
exerciseId,
|
exerciseId,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -16,27 +23,81 @@ function ExerciseHistoryPopup({
|
|||||||
exerciseId: string;
|
exerciseId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [history, setHistory] = useState<
|
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||||
Array<{ workout: { id: string; date: string; name?: string }; sets: Array<{ weight?: number; reps?: number; weightUnit?: string }> }>
|
|
||||||
>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
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 popupRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Initial fetch (page 1)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/exercises/${exerciseId}`);
|
const res = await fetch(
|
||||||
|
`/api/exercises/${exerciseId}?offset=0&limit=${HISTORY_PAGE_SIZE}`,
|
||||||
|
);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
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);
|
if (!cancelled) setLoading(false);
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [exerciseId]);
|
}, [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
|
// Close on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
@@ -51,11 +112,17 @@ function ExerciseHistoryPopup({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={popupRef}
|
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">
|
<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">Recent History</span>
|
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
|
||||||
<button type="button" onClick={onClose} className="p-0.5 text-zinc-500 hover:text-white">
|
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" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,25 +133,50 @@ function ExerciseHistoryPopup({
|
|||||||
) : history.length === 0 ? (
|
) : history.length === 0 ? (
|
||||||
<p className="text-xs text-zinc-500 text-center py-4">No history yet</p>
|
<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) => {
|
<div className="divide-y divide-zinc-800/50">
|
||||||
const d = new Date(entry.workout.date);
|
{history.map((entry) => {
|
||||||
const dateStr = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
const d = new Date(entry.workout.date);
|
||||||
const summary = formatSetsSummary(entry.sets);
|
const dateStr = d.toLocaleDateString("en-US", {
|
||||||
return (
|
month: "short",
|
||||||
<div key={entry.workout.id} className="px-3 py-2">
|
day: "numeric",
|
||||||
<div className="flex items-baseline gap-2">
|
year: "numeric",
|
||||||
<span className="text-[11px] text-zinc-500 flex-shrink-0">{dateStr}</span>
|
});
|
||||||
<span className="text-[11px] text-zinc-600">·</span>
|
const summary = formatSetsSummary(entry.sets);
|
||||||
<span className="text-[11px] text-zinc-500">{entry.sets.length} set{entry.sets.length !== 1 ? "s" : ""}</span>
|
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>
|
</div>
|
||||||
{summary && (
|
);
|
||||||
<p className="text-xs text-zinc-300 mt-0.5">{summary}</p>
|
})}
|
||||||
)}
|
</div>
|
||||||
</div>
|
{/* Sentinel + status row at the bottom of the list */}
|
||||||
);
|
<div ref={sentinelRef} className="flex justify-center py-2">
|
||||||
})}
|
{loadingMore && (
|
||||||
</div>
|
<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>
|
</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_3 } from './v1.0.0.3'
|
||||||
import { v_1_0_0_4 } from './v1.0.0.4'
|
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_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.
|
* Version graph for the `proof-of-work` package.
|
||||||
*
|
*
|
||||||
* v1.0.0:1 — initial release, seeded cutover from the legacy
|
* v1.0.0:1 — initial release, seeded cutover from `workout-log`.
|
||||||
* `workout-log` package.
|
|
||||||
* v1.0.0:2 — CSP fix.
|
* v1.0.0:2 — CSP fix.
|
||||||
* v1.0.0:3 — post-cutover seed strip.
|
* v1.0.0:3 — post-cutover seed strip.
|
||||||
* v1.0.0:4 — removes the default admin@local credentials; operator
|
* v1.0.0:4 — removes default admin@local credentials; operator must
|
||||||
* must run the StartOS Action to bootstrap the first admin.
|
* run StartOS Action to bootstrap the first admin.
|
||||||
* v1.0.0:5 — internal cleanup (removes caloriesBurned raw-SQL
|
* v1.0.0:5 — internal cleanup (caloriesBurned raw-SQL workaround).
|
||||||
* workaround). No user-facing change.
|
* v1.0.0:6 — paginate workout history (infinite scroll); removes
|
||||||
*
|
* invisible 50-row caps on the clock-button popup and
|
||||||
* StartOS picks `current` as the install target; `other` lists every
|
* the /main/workouts page.
|
||||||
* node that can upgrade into `current`.
|
|
||||||
*/
|
*/
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_1_0_0_5,
|
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],
|
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