From ffa8e0d4805985310877271b4ec3c9f9c682a239 Mon Sep 17 00:00:00 2001 From: Keysat Date: Sat, 9 May 2026 20:18:31 -0500 Subject: [PATCH] =?UTF-8?q?v1.0.0:6=20=E2=80=94=20paginate=20workout=20his?= =?UTF-8?q?tory=20(infinite=20scroll)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- proof-of-work/app/api/exercises/[id]/route.ts | 80 ++++--- proof-of-work/app/main/workouts/page.tsx | 27 ++- .../components/workouts/WorkoutForm.tsx | 150 ++++++++++--- .../components/workouts/WorkoutsList.tsx | 108 +++++++++ .../tests/routes-exercise-history.test.ts | 211 ++++++++++++++++++ start9/0.4/startos/versions/index.ts | 21 +- start9/0.4/startos/versions/v1.0.0.6.ts | 42 ++++ 7 files changed, 553 insertions(+), 86 deletions(-) create mode 100644 proof-of-work/components/workouts/WorkoutsList.tsx create mode 100644 proof-of-work/tests/routes-exercise-history.test.ts create mode 100644 start9/0.4/startos/versions/v1.0.0.6.ts diff --git a/proof-of-work/app/api/exercises/[id]/route.ts b/proof-of-work/app/api/exercises/[id]/route.ts index 96bee2b..33071ff 100644 --- a/proof-of-work/app/api/exercises/[id]/route.ts +++ b/proof-of-work/app/api/exercises/[id]/route.ts @@ -5,10 +5,22 @@ import { z } from "zod"; /** * 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( - _request: NextRequest, + request: NextRequest, { params }: { params: { id: string } } ) { try { @@ -17,6 +29,10 @@ export async function GET( 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({ where: { id: params.id, @@ -28,47 +44,37 @@ export async function GET( return NextResponse.json({ error: "Exercise not found" }, { status: 404 }); } - // Get exercise history grouped by workout - const setLogs = await prisma.setLog.findMany({ + // Pull workouts that contain this exercise, with only the matching + // 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: { - exerciseId: params.id, - workout: { - userId: user.id, - deletedAt: null, + userId: user.id, + deletedAt: null, + setLogs: { some: { exerciseId: params.id } }, + }, + select: { + id: true, + date: true, + name: true, + setLogs: { + where: { exerciseId: params.id }, + orderBy: { setNumber: "asc" }, }, }, - include: { - workout: { - select: { - id: true, - date: true, - name: true, - }, - }, - }, - orderBy: [ - { workout: { date: "desc" } }, - { setNumber: "asc" }, - ], - take: 500, + orderBy: { date: "desc" }, + take: limit + 1, + skip: offset, }); - // Group by workout - const workoutMap = new Map(); - for (const log of setLogs) { - const key = log.workoutId; - if (!workoutMap.has(key)) { - workoutMap.set(key, { workout: log.workout, sets: [] }); - } - workoutMap.get(key)!.sets.push(log); - } + const hasMore = rows.length > limit; + const history = rows.slice(0, limit).map((w) => ({ + workout: { id: w.id, date: w.date, name: w.name }, + sets: w.setLogs, + })); - const history = Array.from(workoutMap.values()).slice(0, 50); - - return NextResponse.json({ - exercise, - history, - }); + return NextResponse.json({ exercise, history, hasMore }); } catch (error) { console.error("GET /api/exercises/[id] error:", error); return NextResponse.json( diff --git a/proof-of-work/app/main/workouts/page.tsx b/proof-of-work/app/main/workouts/page.tsx index 5601696..0c250f5 100644 --- a/proof-of-work/app/main/workouts/page.tsx +++ b/proof-of-work/app/main/workouts/page.tsx @@ -3,7 +3,9 @@ import Link from "next/link"; import { Plus, Activity, Upload } from "lucide-react"; import { getCurrentUser } from "@/lib/auth"; import { getWorkouts } from "@/lib/db/workouts"; -import WorkoutCard from "@/components/workouts/WorkoutCard"; +import WorkoutsList from "@/components/workouts/WorkoutsList"; + +const PAGE_SIZE = 50; interface PageProps { searchParams: { q?: string; dateFrom?: string; dateTo?: string }; @@ -31,13 +33,16 @@ export default async function WorkoutsPage({ searchParams }: PageProps) { ? new Date(searchParams.dateTo) : undefined; - // Fetch workouts - const workouts = await getWorkouts(user.id, { + // 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: 50, + limit: PAGE_SIZE + 1, }); + const hasMore = fetched.length > PAGE_SIZE; + const workouts = fetched.slice(0, PAGE_SIZE); return (
@@ -135,11 +140,15 @@ export default async function WorkoutsPage({ searchParams }: PageProps) {
) : ( -
- {workouts.map((workout) => ( - - ))} -
+ )} diff --git a/proof-of-work/components/workouts/WorkoutForm.tsx b/proof-of-work/components/workouts/WorkoutForm.tsx index c9b73f7..612e638 100644 --- a/proof-of-work/components/workouts/WorkoutForm.tsx +++ b/proof-of-work/components/workouts/WorkoutForm.tsx @@ -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([]); const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [error, setError] = useState(null); const popupRef = useRef(null); + const sentinelRef = useRef(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 (
-
- Recent History -
@@ -66,25 +133,50 @@ function ExerciseHistoryPopup({ ) : history.length === 0 ? (

No history yet

) : ( -
- {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 ( -
-
- {dateStr} - · - {entry.sets.length} set{entry.sets.length !== 1 ? "s" : ""} + <> +
+ {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 ( +
+
+ + {dateStr} + + · + + {entry.sets.length} set + {entry.sets.length !== 1 ? "s" : ""} + +
+ {summary && ( +

{summary}

+ )}
- {summary && ( -

{summary}

- )} -
- ); - })} -
+ ); + })} +
+ {/* Sentinel + status row at the bottom of the list */} +
+ {loadingMore && ( + + )} + {!loadingMore && !hasMore && history.length >= HISTORY_PAGE_SIZE && ( + + End of history + + )} + {error && ( + {error} + )} +
+ )}
); diff --git a/proof-of-work/components/workouts/WorkoutsList.tsx b/proof-of-work/components/workouts/WorkoutsList.tsx new file mode 100644 index 0000000..5537504 --- /dev/null +++ b/proof-of-work/components/workouts/WorkoutsList.tsx @@ -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(null); + const sentinelRef = useRef(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 ( + <> +
+ {items.map((workout) => ( + + ))} +
+ {hasMore && ( +
+ {loadingMore ? ( + + ) : ( + + Loading more... + + )} +
+ )} + {!hasMore && items.length >= PAGE_SIZE && ( +

+ End of history · {items.length} workouts +

+ )} + {error && ( +
+ {error} +
+ )} + + ); +} diff --git a/proof-of-work/tests/routes-exercise-history.test.ts b/proof-of-work/tests/routes-exercise-history.test.ts new file mode 100644 index 0000000..1689d81 --- /dev/null +++ b/proof-of-work/tests/routes-exercise-history.test.ts @@ -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; + 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[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, + ); + }); +}); diff --git a/start9/0.4/startos/versions/index.ts b/start9/0.4/startos/versions/index.ts index fb61d46..6b592c7 100644 --- a/start9/0.4/startos/versions/index.ts +++ b/start9/0.4/startos/versions/index.ts @@ -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_4 } from './v1.0.0.4' 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. * - * v1.0.0:1 — initial release, seeded cutover from the legacy - * `workout-log` package. + * v1.0.0:1 — initial release, seeded cutover from `workout-log`. * v1.0.0:2 — CSP fix. * v1.0.0:3 — post-cutover seed strip. - * v1.0.0:4 — removes the default admin@local credentials; operator - * must run the StartOS Action to bootstrap the first admin. - * v1.0.0:5 — internal cleanup (removes caloriesBurned raw-SQL - * workaround). No user-facing change. - * - * StartOS picks `current` as the install target; `other` lists every - * node that can upgrade into `current`. + * v1.0.0:4 — removes default admin@local credentials; operator must + * run StartOS Action to bootstrap the first admin. + * v1.0.0:5 — internal cleanup (caloriesBurned raw-SQL workaround). + * v1.0.0:6 — paginate workout history (infinite scroll); removes + * invisible 50-row caps on the clock-button popup and + * the /main/workouts page. */ export const versionGraph = VersionGraph.of({ - current: v_1_0_0_5, - other: [v_1_0_0_1, v_1_0_0_2, v_1_0_0_3, v_1_0_0_4], + 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, v_1_0_0_5], }) diff --git a/start9/0.4/startos/versions/v1.0.0.6.ts b/start9/0.4/startos/versions/v1.0.0.6.ts new file mode 100644 index 0000000..1b7b88c --- /dev/null +++ b/start9/0.4/startos/versions/v1.0.0.6.ts @@ -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, + }, +})