Files
proof-of-work/proof-of-work/app/main/workouts/page.tsx
T
Keysat ffa8e0d480 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.
2026-05-09 20:18:31 -05:00

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>
);
}