feat(knowledge): two-tier pagination — ID pre-fetch + content batch loading

Backend:
- GET /api/knowledge/ids: returns up to 100 note IDs cheaply (no body
  parsing), supports same filters as /api/knowledge, includes has_more
- GET /api/knowledge/batch?ids=...: fetches full items for given IDs in
  order; used by frontend to load content in controlled batches

Frontend (KnowledgeView):
- Fetch 100 IDs upfront, load first 50 as content on mount
- IntersectionObserver sentinel (root: null) triggers 24-item content
  batches as user scrolls
- Proactive ID refill when queue drops below 48 unloaded IDs
- fetchGen counter invalidates stale in-flight responses on filter reset
- IDs claimed before async fetch to prevent double-loading
- sentinelVisible ref drives post-load re-check when content doesn't
  push sentinel off screen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 09:44:50 -04:00
parent 5495fd1500
commit 90afbec4c2
3 changed files with 244 additions and 58 deletions
+124 -58
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from "vue";
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import { useRouter } from "vue-router";
import { apiGet, apiPatch } from "@/api/client";
import { fmtCompact } from "@/utils/dateFormat";
@@ -50,40 +50,94 @@ const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
const searchQuery = ref("");
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
// ─── Items ────────────────────────────────────────────────────────────────────
// ─── Two-tier pagination ──────────────────────────────────────────────────────
const ID_BATCH = 100; // IDs fetched per server round-trip
const INITIAL_CONTENT = 50; // items loaded on first render
const CONTENT_PAGE = 24; // items loaded per sentinel trigger
const REFILL_THRESHOLD = 48; // fetch more IDs when queue drops below this
const items = ref<KnowledgeItem[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 24;
const loading = ref(false);
const allTags = ref<string[]>([]);
const idQueue = ref<number[]>([]); // unloaded IDs ready to be content-fetched
const idOffset = ref(0); // next offset for ID batch requests
const totalKnowledge = ref(0); // total matching items on server
const allIdsFetched = ref(false); // no more ID pages to fetch
const idsFetching = ref(false); // ID batch request in-flight
const contentFetching = ref(false); // content batch request in-flight
const sentinelVisible = ref(false); // updated by IntersectionObserver
async function fetchItems(reset = false) {
if (reset) page.value = 1;
loading.value = true;
const loading = computed(() => idsFetching.value || contentFetching.value);
let fetchGen = 0; // incremented on each reset to invalidate stale responses
function buildFilterParams(): URLSearchParams {
const p = new URLSearchParams();
if (activeType.value) p.set("type", activeType.value);
if (activeTag.value) p.set("tags", activeTag.value);
p.set("sort", sortMode.value);
if (searchQuery.value.trim()) p.set("q", searchQuery.value.trim());
return p;
}
async function fetchIdBatch(gen: number) {
if (idsFetching.value || allIdsFetched.value) return;
idsFetching.value = true;
try {
const params = new URLSearchParams();
if (activeType.value) params.set("type", activeType.value);
if (activeTag.value) params.set("tags", activeTag.value);
params.set("sort", sortMode.value);
params.set("page", String(page.value));
params.set("per_page", String(perPage));
if (searchQuery.value.trim()) params.set("q", searchQuery.value.trim());
const data = await apiGet<{ items: KnowledgeItem[]; total: number }>(`/api/knowledge?${params}`);
if (reset || page.value === 1) {
items.value = data.items;
} else {
items.value = [...items.value, ...data.items];
const p = buildFilterParams();
p.set("limit", String(ID_BATCH));
p.set("offset", String(idOffset.value));
const data = await apiGet<{ ids: number[]; total: number; has_more: boolean }>(
`/api/knowledge/ids?${p}`
);
if (gen !== fetchGen) return;
idQueue.value.push(...data.ids);
idOffset.value += data.ids.length;
totalKnowledge.value = data.total;
if (!data.has_more) allIdsFetched.value = true;
} catch { /* silent */ }
finally { if (gen === fetchGen) idsFetching.value = false; }
}
async function loadNextContent(gen: number, count: number) {
if (contentFetching.value || idQueue.value.length === 0) return;
const toLoad = idQueue.value.splice(0, count); // claim IDs immediately
contentFetching.value = true;
try {
const data = await apiGet<{ items: KnowledgeItem[] }>(
`/api/knowledge/batch?ids=${toLoad.join(",")}`
);
if (gen !== fetchGen) return;
items.value.push(...data.items);
// Proactively refill ID queue before it runs out
if (idQueue.value.length < REFILL_THRESHOLD && !allIdsFetched.value && !idsFetching.value) {
fetchIdBatch(gen);
}
} catch { /* silent */ }
finally {
if (gen !== fetchGen) return;
contentFetching.value = false;
// Sentinel may still be visible if new items didn't push it off screen
await nextTick();
if (sentinelVisible.value && idQueue.value.length > 0 && gen === fetchGen) {
loadNextContent(gen, CONTENT_PAGE);
}
total.value = data.total;
} catch {
/* silent */
} finally {
loading.value = false;
}
}
async function reset() {
fetchGen++;
const gen = fetchGen;
items.value = [];
idQueue.value = [];
idOffset.value = 0;
totalKnowledge.value = 0;
allIdsFetched.value = false;
await fetchIdBatch(gen);
if (gen !== fetchGen) return;
await loadNextContent(gen, INITIAL_CONTENT);
}
async function fetchTags() {
try {
const data = await apiGet<{ tags: string[] }>("/api/knowledge/tags");
@@ -91,12 +145,18 @@ async function fetchTags() {
} catch { /* silent */ }
}
function onSearchInput() {
if (searchDebounce) clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => fetchItems(true), 380);
async function resetAndReobserve() {
await reset();
await nextTick();
setupObserver();
}
watch([activeType, activeTag, sortMode], () => fetchItems(true));
function onSearchInput() {
if (searchDebounce) clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => resetAndReobserve(), 380);
}
watch([activeType, activeTag, sortMode], () => resetAndReobserve());
// ─── Today bar ────────────────────────────────────────────────────────────────
@@ -171,7 +231,7 @@ watch(
['create_note', 'update_note', 'create_task', 'update_task'].includes(tc.function) &&
tc.status === 'success'
) {
fetchItems(true);
reset();
fetchTags();
break;
}
@@ -220,7 +280,7 @@ async function toggleListItem(item: KnowledgeItem, index: number) {
try {
await apiPatch(`/api/notes/${item.id}`, { body: newBody });
} catch {
fetchItems(true); // revert on error
reset(); // revert on error
}
}
@@ -242,37 +302,37 @@ function formatDate(iso: string): string {
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
// ─── Infinite scroll ──────────────────────────────────────────────────────────
// ─── Sentinel observer ────────────────────────────────────────────────────────
const cardGridEl = ref<HTMLElement | null>(null);
let scrollRafId: number | null = null;
const sentinelEl = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
function onGridScroll() {
// Debounce via rAF — coalesces rapid scroll events into one check per frame
if (scrollRafId !== null) return;
scrollRafId = requestAnimationFrame(() => {
scrollRafId = null;
const el = cardGridEl.value;
if (!el || loading.value || items.value.length >= total.value) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 300;
if (nearBottom) {
page.value++;
fetchItems();
}
});
function setupObserver() {
observer?.disconnect();
if (!sentinelEl.value) return;
observer = new IntersectionObserver(
([entry]) => {
sentinelVisible.value = entry.isIntersecting;
if (entry.isIntersecting) loadNextContent(fetchGen, CONTENT_PAGE);
},
{ root: null, rootMargin: "200px" }
);
observer.observe(sentinelEl.value);
}
// ─── Lifecycle ────────────────────────────────────────────────────────────────
onMounted(() => {
fetchItems(true);
onMounted(async () => {
await reset();
fetchTags();
fetchTodayBar();
await nextTick();
setupObserver();
});
onUnmounted(() => {
if (searchDebounce) clearTimeout(searchDebounce);
if (scrollRafId !== null) cancelAnimationFrame(scrollRafId);
observer?.disconnect();
});
</script>
@@ -375,7 +435,7 @@ onUnmounted(() => {
</div>
<!-- Card grid -->
<div v-else ref="cardGridEl" class="card-grid" @scroll.passive="onGridScroll">
<div v-else class="card-grid">
<div
v-for="item in items"
:key="item.id"
@@ -440,8 +500,10 @@ onUnmounted(() => {
</div>
</div>
<!-- Loading indicator when fetching additional pages -->
<div v-if="loading && items.length > 0" class="load-more-indicator">Loading</div>
<!-- Sentinel IntersectionObserver triggers next content batch -->
<div ref="sentinelEl" class="scroll-sentinel">
<span v-if="contentFetching" class="sentinel-loading">Loading</span>
</div>
</div>
</div>
@@ -869,11 +931,15 @@ onUnmounted(() => {
}
.empty-hint { font-size: 0.85rem; opacity: 0.7; }
/* ── Load more indicator ─────────────────────────────────── */
.load-more-indicator {
/* ── Sentinel ────────────────────────────────────────────── */
.scroll-sentinel {
grid-column: 1 / -1;
padding: 16px;
text-align: center;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.sentinel-loading {
font-size: 0.8rem;
color: var(--color-muted);
}