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
+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_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],
})
+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,
},
})