v1.1.0:7 — exercise-history popup auto-loads on scroll
The popup HAD an IntersectionObserver-based infinite scroll (since v1.0.0:6 alongside the main workout-history page), but the observer was unreliable inside an `absolute`-positioned scroll container with a small 60px rootMargin. It often didn't fire at all — leaving the user with a popup that scrolled internally but never fetched more data even when hundreds of history entries existed server-side. Fix: replace IntersectionObserver with a plain `scroll` event listener on the popup. Fires whenever the user scrolls within 300px of the bottom (matching WorkoutsList's lookahead on the main page). Also runs once on mount in case the first page doesn't fill the popup. Bottom status row now shows "Loading more..." / "Scroll to load more" / "End of history" so the user has feedback on state. No schema, no API, no data.
This commit is contained in:
@@ -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({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div ref={sentinelRef} className="flex justify-center py-2">
|
||||
{loadingMore && (
|
||||
<Loader className="w-3.5 h-3.5 animate-spin text-zinc-500" />
|
||||
<span className="inline-flex items-center gap-2 text-[10px] text-zinc-500 uppercase tracking-wider">
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
Loading more...
|
||||
</span>
|
||||
)}
|
||||
{!loadingMore && hasMore && (
|
||||
<span className="text-[10px] text-zinc-600 uppercase tracking-wider">
|
||||
Scroll to load more
|
||||
</span>
|
||||
)}
|
||||
{!loadingMore && !hasMore && history.length >= HISTORY_PAGE_SIZE && (
|
||||
<span className="text-[10px] text-zinc-600 uppercase tracking-wider">
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user