diff --git a/proof-of-work/components/workouts/WorkoutForm.tsx b/proof-of-work/components/workouts/WorkoutForm.tsx index 8336618..b96ba60 100644 --- a/proof-of-work/components/workouts/WorkoutForm.tsx +++ b/proof-of-work/components/workouts/WorkoutForm.tsx @@ -58,44 +58,61 @@ function ExerciseHistoryPopup({ }; }, [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. + // v1.1.0:7 — Infinite scroll via a plain scroll listener on the + // popup itself. The previous IntersectionObserver implementation was + // unreliable inside an absolute-positioned scroll container (the + // popup is `position: absolute` + `overflow-y-auto`, which some + // browsers don't observe consistently when the root is the same + // element). A `scroll` event on the popup is rock-solid. + // + // Fires whenever the user scrolls within ~300px of the popup's + // bottom edge, mirroring the rootMargin used by the workouts-list + // infinite-scroll on the main page. + // + // Also runs once on first render after history loads — important + // because if the user has 100+ history entries and the first page + // doesn't fill the popup OR the user opens the popup and immediately + // sees content without scrolling, we still want to fetch ahead. 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(); + if (loading || !hasMore || loadingMore || !popupRef.current) return; + const el = popupRef.current; + + const loadMore = async () => { + if (loadingMore || !hasMore) return; + setLoadingMore(true); + 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); + }; + + const maybeLoad = () => { + const { scrollTop, scrollHeight, clientHeight } = el; + // 300px lookahead — match WorkoutsList's rootMargin behavior. + if (scrollHeight - scrollTop - clientHeight < 300) { + loadMore(); + } + }; + + el.addEventListener("scroll", maybeLoad, { passive: true }); + // Initial check: if the loaded page doesn't fill the popup, we + // still want to fetch the next page so the user doesn't have to + // scroll a near-empty container before more arrives. + maybeLoad(); + return () => el.removeEventListener("scroll", maybeLoad); }, [loading, hasMore, loadingMore, history.length, exerciseId]); // Close on outside click @@ -167,10 +184,21 @@ function ExerciseHistoryPopup({ ); })} - {/* Sentinel + status row at the bottom of the list */} + {/* Status row at the bottom of the list. The sentinel ref + is no longer the load trigger (we use a scroll listener + on the popup itself in v1.1.0:7), but the visual marker + still tells the user whether more is loading or done. */}
{loadingMore && ( - + + + Loading more... + + )} + {!loadingMore && hasMore && ( + + Scroll to load more + )} {!loadingMore && !hasMore && history.length >= HISTORY_PAGE_SIZE && ( diff --git a/start9/0.4/startos/versions/index.ts b/start9/0.4/startos/versions/index.ts index 1f98411..5aaed71 100644 --- a/start9/0.4/startos/versions/index.ts +++ b/start9/0.4/startos/versions/index.ts @@ -12,6 +12,7 @@ import { v_1_1_0_3 } from './v1.1.0.3' import { v_1_1_0_4 } from './v1.1.0.4' import { v_1_1_0_5 } from './v1.1.0.5' import { v_1_1_0_6 } from './v1.1.0.6' +import { v_1_1_0_7 } from './v1.1.0.7' /** * Version graph for the `proof-of-work` package. @@ -44,9 +45,12 @@ import { v_1_1_0_6 } from './v1.1.0.6' * v1.1.0:6 — Exercise-history popup max-height bumped from ~320px * (5 rows) to 70vh (~15+ rows). Users with deep history * can scroll without fighting a tiny inner scrollbar. + * v1.1.0:7 — Exercise-history popup auto-loads more rows on scroll + * (switched from a flaky IntersectionObserver-in-popup to + * a plain scroll listener with 300px lookahead). */ export const versionGraph = VersionGraph.of({ - current: v_1_1_0_6, + current: v_1_1_0_7, other: [ v_1_0_0_1, v_1_0_0_2, @@ -60,5 +64,6 @@ export const versionGraph = VersionGraph.of({ v_1_1_0_3, v_1_1_0_4, v_1_1_0_5, + v_1_1_0_6, ], }) diff --git a/start9/0.4/startos/versions/v1.1.0.7.ts b/start9/0.4/startos/versions/v1.1.0.7.ts new file mode 100644 index 0000000..c182880 --- /dev/null +++ b/start9/0.4/startos/versions/v1.1.0.7.ts @@ -0,0 +1,34 @@ +import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk' + +/** + * v1.1.0:7 — Exercise-history popup auto-loads more rows on scroll. + * + * The popup HAD an IntersectionObserver-based infinite-scroll + * implementation (added in v1.0.0:6 alongside the workout-history + * page version of the same feature), but the observer was fiddly + * inside an `absolute`-positioned scroll container. With the small + * 60px rootMargin it would sometimes not fire at all. + * + * Replaced with a plain `scroll` event listener on the popup. Fires + * when the user scrolls within 300px of the bottom (mirroring the + * lookahead used by WorkoutsList on the main Workouts page). Also + * runs once on mount so if the first page doesn't fill the popup, + * we still fetch the next page proactively. + * + * Cosmetic: bottom-of-list status row now shows "Loading more..." / + * "Scroll to load more" / "End of history" so the user has feedback + * on the state instead of just seeing a thin spinner intermittently. + * + * No schema, no API, no data. + */ +export const v_1_1_0_7 = VersionInfo.of({ + version: '1.1.0:7', + releaseNotes: { + en_US: + 'Exercise-history popup (clock icon while logging or editing a workout) now reliably auto-loads more rows as you scroll, matching the Workouts page. Switched from a fiddly IntersectionObserver (which sometimes didn\'t fire inside the absolute-positioned popup) to a plain scroll listener with a 300px lookahead. Bottom-of-list now shows "Loading more..." / "Scroll to load more" / "End of history" feedback. Pure UI fix.', + }, + migrations: { + up: async () => {}, + down: IMPOSSIBLE, + }, +})