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:
Keysat
2026-05-09 20:18:31 -05:00
parent dc6a3b1116
commit ffa8e0d480
7 changed files with 553 additions and 86 deletions
+43 -37
View File
@@ -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(
+18 -9
View File
@@ -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>
+121 -29
View File
@@ -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,
);
});
});
+10 -11
View File
@@ -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],
}) })
+42
View File
@@ -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,
},
})