diff --git a/server/src/ledgrab/static/css/activity-log.css b/server/src/ledgrab/static/css/activity-log.css index 1adb498..d78e715 100644 --- a/server/src/ledgrab/static/css/activity-log.css +++ b/server/src/ledgrab/static/css/activity-log.css @@ -577,6 +577,16 @@ min-height: 0; } +/* Subtle busy state while a slow re-query is in flight: the current rows stay + visible (no spinner flash) but dim slightly and stop accepting clicks until + the fresh results swap in. Only applied after a short delay, so instant + filtering shows nothing. */ +.al-list-container.al-busy { + opacity: 0.55; + pointer-events: none; + transition: opacity var(--duration-fast) var(--ease-out); +} + /* ── Tabular-nums utility ────────────────────────────────────────────────── */ .tabular-nums { font-variant-numeric: tabular-nums; } diff --git a/server/src/ledgrab/static/js/features/activity-log.ts b/server/src/ledgrab/static/js/features/activity-log.ts index 3f20482..7f0165a 100644 --- a/server/src/ledgrab/static/js/features/activity-log.ts +++ b/server/src/ledgrab/static/js/features/activity-log.ts @@ -74,6 +74,13 @@ let _total = 0; let _expandedIds = new Set(); let _debounceTimer: ReturnType | null = null; let _liveEventListener: ((e: Event) => void) | null = null; +// Loading UX: `_showSpinner` gates the full-panel spinner so it only appears +// after a short delay (slow requests), never flashing on instant filtering. +// `_hasLoadedOnce` distinguishes the genuine first load (spinner immediately) +// from re-queries (keep current rows, subtle delayed busy hint). +let _loadingDelayTimer: ReturnType | null = null; +let _showSpinner = false; +let _hasLoadedOnce = false; const _filters: ActiveFilters = { categories: [], @@ -353,7 +360,7 @@ function _renderFilterToolbar(): string { // ─── List and state rendering ──────────────────────────────── function _renderList(): string { - if (_loading && _entries.length === 0) { + if (_showSpinner && _entries.length === 0) { return `
${escapeHtml(t('activity_log.loading'))} @@ -496,15 +503,58 @@ function _updateListContainer(): void { // ─── Data fetching ─────────────────────────────────────────── +/** Surface a loading affordance only when a request is slow enough to notice. */ +function _showDelayedBusy(): void { + if (!_loading) return; + if (_entries.length === 0) { + // Nothing to keep on screen — fall back to the full spinner. + _showSpinner = true; + _updateListContainer(); + } else { + // Re-query of a populated list: keep the current rows, just dim them. + const c = document.getElementById('al-list-container'); + c?.classList.add('al-busy'); + c?.setAttribute('aria-busy', 'true'); + } +} + +/** Clear all loading affordances (timer, spinner flag, busy dim). Idempotent. */ +function _clearBusy(): void { + if (_loadingDelayTimer) { + clearTimeout(_loadingDelayTimer); + _loadingDelayTimer = null; + } + _showSpinner = false; + const c = document.getElementById('al-list-container'); + c?.classList.remove('al-busy'); + c?.removeAttribute('aria-busy'); +} + async function _fetchPage(beforeSeq: number | null = null, append = false): Promise { if (_loading) return; _loading = true; if (!append) { - _entries = []; + // Reset the cursor for a fresh query, but DON'T clear `_entries` — keep + // the current rows on screen so filtering an already-populated list + // never flashes the full "Loading" state (the new results replace them + // on arrival). _nextBeforeSeq = null; _hasMore = false; } - _updateListContainer(); + + if (!_hasLoadedOnce && !append) { + // Genuine first load — there's nothing to show yet, so the spinner is + // the correct (and expected) initial state. Show it immediately. + _showSpinner = true; + _updateListContainer(); + } else if (!append) { + // Re-query (filter change / language change): defer any loading hint so + // near-instant responses show nothing at all; a slow request gets a + // subtle dim after the delay. + if (_loadingDelayTimer) clearTimeout(_loadingDelayTimer); + _loadingDelayTimer = setTimeout(_showDelayedBusy, 180); + } + // append (load-more): keep existing rows, no loading indicator. try { const qs = _buildQuery(beforeSeq); @@ -524,15 +574,16 @@ async function _fetchPage(beforeSeq: number | null = null, append = false): Prom _nextBeforeSeq = page.next_before_seq; _hasMore = page.has_more; _total = page.total; - // Reset the loading flag BEFORE rendering: _renderList() shows the - // spinner whenever (_loading && _entries.length === 0), so a zero-result - // page (e.g. an unmatched entity-type filter, or a fresh install) would - // otherwise render the spinner here and spin forever — the finally below - // clears _loading but does not re-render. + _hasLoadedOnce = true; + // Clear loading affordances BEFORE rendering so a zero-result page + // renders the empty state (not the spinner) and a re-query swaps in the + // fresh, undimmed rows. + _clearBusy(); _loading = false; _updateListContainer(); } catch (e: unknown) { if (e && typeof e === 'object' && 'isAuth' in e) return; + _clearBusy(); const container = document.getElementById('al-list-container'); if (container) { container.innerHTML = `