From da7ce9c3bd1b3092937d8b51ec0909bbeed505e5 Mon Sep 17 00:00:00 2001 From: Simon Cordt Date: Wed, 24 Sep 2025 20:55:02 +0200 Subject: [PATCH] feat: Implement infinite scroll for history view --- web/css/queue_style.css | 1 + web/pqueue.js | 112 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/web/css/queue_style.css b/web/css/queue_style.css index c507d30..c9b4b01 100644 --- a/web/css/queue_style.css +++ b/web/css/queue_style.css @@ -108,6 +108,7 @@ .pqueue-history-card__duration{font-size:.75rem;color:var(--muted-text,#a1a9b8)} .pqueue-thumb-row{display:flex;flex-wrap:wrap;gap:.5rem} .pqueue-thumb-wrap{position:relative;width:100px;height:100px;border-radius:8px;overflow:hidden;background:rgba(24,24,27,.6);border:1px solid rgba(82,82,91,.24);cursor:pointer} +.pqueue-history-sentinel{display:flex;align-items:center;justify-content:center;min-height:48px;opacity:.7} .pqueue-thumb{width:100%;height:100%;object-fit:cover;display:block} .pqueue-thumb-badge{position:absolute;right:6px;bottom:6px;padding:.15rem .5rem;border-radius:.6rem;background:rgba(15,23,42,.75);color:#fff;font-size:.68rem} .pqueue-thumb-placeholder{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--subtle-text,#94a3b8);height:100%;padding:.5rem;font-size:.75rem;gap:.35rem} diff --git a/web/pqueue.js b/web/pqueue.js index d5ecce4..84ae8de 100644 --- a/web/pqueue.js +++ b/web/pqueue.js @@ -7,6 +7,14 @@ const API = { getQueue: () => fetch("/api/pqueue").then((r) => r.json()), getHistory: (limit = 50) => fetch(`/api/pqueue/history?limit=${limit}`).then((r) => r.json()), + getHistoryPaginated: (params = {}) => { + const url = new URL("/api/pqueue/history", window.location.origin); + Object.entries(params).forEach(([k, v]) => { + if (v === undefined || v === null || v === "") return; + url.searchParams.set(k, String(v)); + }); + return fetch(url.href).then((r) => r.json()); + }, pause: () => fetch("/api/pqueue/pause", { method: "POST" }), resume: () => fetch("/api/pqueue/resume", { method: "POST" }), reorder: (order) => @@ -135,6 +143,13 @@ filters: { pending: "", }, + historyIds: new Set(), + historyPaging: { + isLoading: false, + hasMore: true, + nextCursor: null, + params: { sort_by: "id", sort_dir: "desc", limit: 60 }, + }, metrics: { runningCount: 0, queueCount: 0, @@ -687,6 +702,9 @@ } else { state.history.forEach((row) => grid.appendChild(UI.historyCard(row))); } + const sentinel = UI.historySentinel(); + state.dom.historySentinel = sentinel; + grid.appendChild(sentinel); return UI.card("Recent history", { icon: "ti ti-clock-bolt", subtitle: state.metrics.historyCount ? `${state.metrics.historyCount} entries` : null, @@ -694,6 +712,13 @@ }); }, + historySentinel() { + const wrap = UI.el("div", { class: "pqueue-history-sentinel" }); + const spinner = UI.icon("ti ti-loader-2", { size: "md", spin: true }); + wrap.appendChild(spinner); + return wrap; + }, + historyCard(row) { const card = UI.el("article", { class: "pqueue-history-card", title: Format.tooltip(row) }); const header = UI.el("div", { class: "pqueue-history-card__header" }, [UI.statusBadge(row.status || "success"), UI.el("span", { class: "pqueue-history-card__time", text: row.completed_at ? Format.relative(row.completed_at) : Format.relative(row.created_at) })]); @@ -799,6 +824,38 @@ }); }, + ensureHistoryObserver() { + try { + if (!('IntersectionObserver' in window)) return; + const grid = state.dom.historyGrid; + const sentinel = state.dom.historySentinel; + if (!grid || !sentinel) return; + + if (state.historyObserver) { + state.historyObserver.disconnect(); + state.historyObserver = null; + } + + // Initialize cursor from currently loaded list if not set + if (!state.historyPaging?.nextCursor && state.history?.length) { + const last = state.history[state.history.length - 1]; + state.historyPaging.nextCursor = { id: last?.id, value: last?.id }; + state.historyPaging.hasMore = true; + } + + const obs = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + Events.loadMoreHistory(); + }); + }, { root: null, rootMargin: '600px 0px', threshold: 0 }); + obs.observe(sentinel); + state.historyObserver = obs; + } catch (err) { + /* noop */ + } + }, + refreshFilter() { const table = state.dom.pendingTable; if (!table) return; @@ -1167,6 +1224,7 @@ table.addEventListener("click", Events.handleTableClick); table.addEventListener("keydown", Events.handleTableKey); } + UI.ensureHistoryObserver(); }, async togglePause() { @@ -1202,6 +1260,10 @@ manualRefresh() { if (state.isRefreshing) return; + // Reset history paging on manual refresh + state.history = []; + state.historyIds = new Set(); + state.historyPaging = { isLoading: false, hasMore: true, nextCursor: null, params: { sort_by: "id", sort_dir: "desc", limit: 60 } }; refresh(); }, @@ -1342,6 +1404,43 @@ } }, + async loadMoreHistory() { + try { + const paging = state.historyPaging; + if (!paging || paging.isLoading || !paging.hasMore) return; + paging.isLoading = true; + + const params = { ...paging.params }; + if (paging.nextCursor?.id != null) params.cursor_id = paging.nextCursor.id; + if (paging.nextCursor?.value != null) params.cursor_value = paging.nextCursor.value; + + const result = await API.getHistoryPaginated(params); + const list = Array.isArray(result?.history) ? result.history : []; + const grid = state.dom.historyGrid; + const sentinel = state.dom.historySentinel; + if (grid && sentinel && list.length) { + const frag = document.createDocumentFragment(); + for (const row of list) { + const id = row?.id; + if (id == null || state.historyIds.has(id)) continue; + state.historyIds.add(id); + frag.appendChild(UI.historyCard(row)); + } + grid.insertBefore(frag, sentinel); + } + if (list.length) state.history.push(...list); + paging.nextCursor = result?.next_cursor || null; + paging.hasMore = !!result?.has_more; + + // Hide sentinel when no more + if (!paging.hasMore && sentinel) sentinel.style.display = "none"; + } catch (err) { + /* ignore load errors to avoid UI jank */ + } finally { + if (state.historyPaging) state.historyPaging.isLoading = false; + } + }, + async deleteSelected() { if (!state.selectedPending.size) return; if (!window.confirm(`Delete ${state.selectedPending.size} selected prompt${state.selectedPending.size === 1 ? "" : "s"}?`)) return; @@ -1413,6 +1512,19 @@ }); state.lastUpdated = new Date().toISOString(); + // Seed history paging cursors and ids + try { + state.historyIds = new Set(); + state.history.forEach((row) => { + if (row && row.id != null) state.historyIds.add(row.id); + }); + const last = state.history[state.history.length - 1]; + state.historyPaging = state.historyPaging || { isLoading: false, hasMore: true, nextCursor: null, params: { sort_by: "id", sort_dir: "desc", limit: 60 } }; + state.historyPaging.nextCursor = last ? { id: last.id, value: last.id } : null; + state.historyPaging.hasMore = true; + } catch (err) { + /* noop */ + } } catch (err) { console.error("pqueue: refresh failed", err); state.error = err?.message || "Failed to load persistent queue";