From 373f5bd454df87d806815e3b0f8b2af181e58691 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 4 Nov 2026 20:14:41 -0500 Subject: [PATCH] feat(6839): the decision log and the knowledge feed load as you scroll, through one ScrollSentinel ScrollSentinel sits at the foot of a server-paged list and asks for the next page a screen before the reader reaches it. It asks once per cursor, forgets the cursor when its element goes, and shows Try again on a failed page instead of retrying in a loop. FamilyView's decision log loses its "Older decisions" button. A failed page shows in place rather than as a toast, and rows already shown are skipped when a new decision shifts the offset. KnowledgeView drops its inline IntersectionObserver for the component. The cursor is the ids consumed, not the items rendered, so a batch that returns fewer records than it asked for still moves the feed on. A failed batch puts its ids back and offers Try again, where it used to fail silently and stop. Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/ScrollSentinel.vue | 96 +++++++++++++++++ frontend/src/views/FamilyView.vue | 28 +++-- frontend/src/views/KnowledgeView.vue | 117 +++++++++------------ 3 files changed, 167 insertions(+), 74 deletions(-) create mode 100644 frontend/src/components/ScrollSentinel.vue diff --git a/frontend/src/components/ScrollSentinel.vue b/frontend/src/components/ScrollSentinel.vue new file mode 100644 index 00000000..e5b848c9 --- /dev/null +++ b/frontend/src/components/ScrollSentinel.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/frontend/src/views/FamilyView.vue b/frontend/src/views/FamilyView.vue index d4fac26f..7c039b82 100644 --- a/frontend/src/views/FamilyView.vue +++ b/frontend/src/views/FamilyView.vue @@ -19,6 +19,7 @@ import { type FamilyIdea, type IdeaSnapshot, type IdeaStatus, } from "@/api/family"; import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue"; +import ScrollSentinel from "@/components/ScrollSentinel.vue"; import { useToastStore } from "@/stores/toast"; import { fmtStamp } from "@/utils/dateFormat"; import { recordHref } from "@/utils/recordHref"; @@ -36,6 +37,8 @@ const loading = ref(true); const failed = ref(false); const moreDecisions = ref(false); const loadingMore = ref(false); +const moreFailed = ref(false); +const decisionOffset = ref(0); // One pending reversal at a time: which row, which act, and its reason. const pending = ref<{ kind: "undo" | "retire"; id: number } | null>(null); @@ -63,7 +66,9 @@ async function load() { criteria.value = ideaData.criteria; conflictOrder.value = ideaData.conflict_order ?? []; decisions.value = log; + decisionOffset.value = log.length; moreDecisions.value = log.length === PAGE; + moreFailed.value = false; } catch { // Said out loud: a log that failed to load must not read as "nothing // has been decided". @@ -73,14 +78,20 @@ async function load() { } } +// Older decisions load as the log is scrolled. The log is offset-paged, so a +// decision recorded since the first page shifts the rest down by one; a row +// already shown is skipped, and the offset still counts what the server sent. async function loadMore() { loadingMore.value = true; + moreFailed.value = false; try { - const next = await listFamilyDecisions(PAGE, decisions.value.length); - decisions.value.push(...next); + const next = await listFamilyDecisions(PAGE, decisionOffset.value); + const shown = new Set(decisions.value.map((d) => d.id)); + decisions.value.push(...next.filter((d) => !shown.has(d.id))); + decisionOffset.value += next.length; moreDecisions.value = next.length === PAGE; - } catch (e: unknown) { - toast.show(apiErrorMessage(e, "Could not load more decisions"), "error"); + } catch { + moreFailed.value = true; } finally { loadingMore.value = false; } @@ -343,9 +354,12 @@ onMounted(load); - + diff --git a/frontend/src/views/KnowledgeView.vue b/frontend/src/views/KnowledgeView.vue index 4a48021e..9651c818 100644 --- a/frontend/src/views/KnowledgeView.vue +++ b/frontend/src/views/KnowledgeView.vue @@ -1,5 +1,5 @@ @@ -583,10 +580,7 @@ onUnmounted(() => { + to two different questions on one screen is neither. --> @@ -1115,19 +1111,6 @@ onUnmounted(() => { opacity: 0.85; } -/* ── Sentinel ────────────────────────────────────────────── */ -.scroll-sentinel { - grid-column: 1 / -1; - height: 40px; - display: flex; - align-items: center; - justify-content: center; -} -.sentinel-loading { - font-size: 0.8rem; - color: var(--fs-text-tertiary); -} - /* ── Graph panel ─────────────────────────────────────────── */ .graph-panel { width: 500px;