ffa8e0d480
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.
184 lines
6.3 KiB
TypeScript
184 lines
6.3 KiB
TypeScript
import { redirect } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { Plus, Activity, Upload } from "lucide-react";
|
|
import { getCurrentUser } from "@/lib/auth";
|
|
import { getWorkouts } from "@/lib/db/workouts";
|
|
import WorkoutsList from "@/components/workouts/WorkoutsList";
|
|
|
|
const PAGE_SIZE = 50;
|
|
|
|
interface PageProps {
|
|
searchParams: { q?: string; dateFrom?: string; dateTo?: string };
|
|
}
|
|
|
|
export const metadata = {
|
|
title: "Workout History",
|
|
description: "View your workout history",
|
|
};
|
|
export const dynamic = "force-dynamic";
|
|
export const revalidate = 0;
|
|
|
|
export default async function WorkoutsPage({ searchParams }: PageProps) {
|
|
const user = await getCurrentUser();
|
|
if (!user) {
|
|
redirect("/auth/login");
|
|
}
|
|
|
|
// Parse search params
|
|
const query = searchParams.q || "";
|
|
const dateFrom = searchParams.dateFrom
|
|
? new Date(searchParams.dateFrom)
|
|
: undefined;
|
|
const dateTo = searchParams.dateTo
|
|
? new Date(searchParams.dateTo)
|
|
: undefined;
|
|
|
|
// 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: PAGE_SIZE + 1,
|
|
});
|
|
const hasMore = fetched.length > PAGE_SIZE;
|
|
const workouts = fetched.slice(0, PAGE_SIZE);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#0A0A0A]">
|
|
{/* Header */}
|
|
<div className="border-b border-zinc-800 sticky top-0 z-40">
|
|
<div className="max-w-2xl mx-auto px-4 py-4 sm:py-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl sm:text-3xl font-bold text-white">
|
|
Workout History
|
|
</h1>
|
|
<Link
|
|
href="/main/import"
|
|
className="p-2 hover:bg-zinc-900 rounded-lg text-zinc-400 hover:text-white transition"
|
|
title="Import workouts from CSV"
|
|
>
|
|
<Upload className="w-5 h-5" />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-w-2xl mx-auto px-4 py-6">
|
|
{/* Search and filters */}
|
|
<form className="mb-6 space-y-4">
|
|
{/* Search bar */}
|
|
<div>
|
|
<input
|
|
type="text"
|
|
name="q"
|
|
placeholder="Search workouts..."
|
|
defaultValue={query}
|
|
className="w-full px-4 py-3 border border-zinc-700 bg-zinc-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-white text-white placeholder-zinc-500 text-base"
|
|
/>
|
|
</div>
|
|
|
|
{/* Date range */}
|
|
<div className="grid grid-cols-2 gap-3 sm:gap-4">
|
|
<div>
|
|
<label className="block text-sm text-zinc-400 mb-1">From</label>
|
|
<input
|
|
type="date"
|
|
name="dateFrom"
|
|
defaultValue={searchParams.dateFrom || ""}
|
|
className="w-full px-4 py-2 border border-zinc-700 bg-zinc-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-white text-white text-base"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-zinc-400 mb-1">To</label>
|
|
<input
|
|
type="date"
|
|
name="dateTo"
|
|
defaultValue={searchParams.dateTo || ""}
|
|
className="w-full px-4 py-2 border border-zinc-700 bg-zinc-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-white text-white text-base"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Submit buttons */}
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="submit"
|
|
className="flex-1 px-4 py-2 bg-white text-black rounded-lg font-medium hover:bg-gray-100 touch-target"
|
|
>
|
|
Filter
|
|
</button>
|
|
<Link
|
|
href="/main/workouts"
|
|
className="flex-1 px-4 py-2 bg-zinc-800 text-white rounded-lg font-medium hover:bg-zinc-700 text-center touch-target"
|
|
>
|
|
Clear
|
|
</Link>
|
|
</div>
|
|
</form>
|
|
|
|
{/* Workout list */}
|
|
{workouts.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<div className="flex justify-center mb-4">
|
|
<Activity className="w-12 h-12 text-zinc-600" />
|
|
</div>
|
|
<h2 className="text-lg font-semibold text-white mb-2">
|
|
No workouts yet
|
|
</h2>
|
|
<p className="text-zinc-400 mb-6">
|
|
{query || dateFrom || dateTo
|
|
? "No workouts match your filters. Try adjusting your search."
|
|
: "Start tracking your fitness journey by logging your first workout."}
|
|
</p>
|
|
<Link
|
|
href="/main/workouts/new"
|
|
className="inline-flex items-center gap-2 px-6 py-3 bg-white text-black rounded-lg font-semibold hover:bg-gray-100 touch-target"
|
|
>
|
|
<Plus className="w-5 h-5" />
|
|
Log Your First Workout
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<WorkoutsList
|
|
initialWorkouts={workouts}
|
|
initialHasMore={hasMore}
|
|
filters={{
|
|
q: query || undefined,
|
|
dateFrom: searchParams.dateFrom,
|
|
dateTo: searchParams.dateTo,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Floating action button for mobile, regular button for desktop */}
|
|
{workouts.length > 0 && (
|
|
<>
|
|
{/* Mobile FAB */}
|
|
<div className="fixed bottom-6 right-6 sm:hidden">
|
|
<Link
|
|
href="/main/workouts/new"
|
|
className="flex items-center justify-center w-14 h-14 bg-white text-black rounded-full shadow-lg hover:bg-gray-100 active:bg-gray-200 touch-target"
|
|
aria-label="Log new workout"
|
|
>
|
|
<Plus className="w-6 h-6" />
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Desktop button */}
|
|
<div className="hidden sm:block fixed bottom-6 right-6">
|
|
<Link
|
|
href="/main/workouts/new"
|
|
className="flex items-center gap-2 px-6 py-3 bg-white text-black rounded-lg font-semibold hover:bg-gray-100 shadow-lg"
|
|
>
|
|
<Plus className="w-5 h-5" />
|
|
Log Workout
|
|
</Link>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|