diff --git a/frontend/biome.json b/frontend/biome.json index 04bf240a5..e950df64d 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.6.3/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/frontend/src/lib/api/integrations.js b/frontend/src/lib/api/integrations.js index b411b8fe2..4496eb31b 100644 --- a/frontend/src/lib/api/integrations.js +++ b/frontend/src/lib/api/integrations.js @@ -81,8 +81,11 @@ export const zammadTickets = { forItem: (itemId) => fetchAPI(`/items/${itemId}/zammad-links`), history: (itemId, { limit = 6 } = {}) => fetchAPI(`/items/${itemId}/zammad-history?limit=${encodeURIComponent(limit)}`), - workspaceOverview: (workspaceId, { limit = 5 } = {}) => - fetchAPI(`/workspaces/${workspaceId}/zammad-overview?limit=${encodeURIComponent(limit)}`), + /** @param {number|string} workspaceId @param {{ limit?: number, signal?: AbortSignal }} [options] */ + workspaceOverview: (workspaceId, { limit = 5, signal } = {}) => + fetchAPI(`/workspaces/${workspaceId}/zammad-overview?limit=${encodeURIComponent(limit)}`, { + signal, + }), create: (itemId, data) => fetchAPI(`/items/${itemId}/zammad-tickets`, { method: 'POST', diff --git a/frontend/src/lib/composables/useCollectionEventStream.svelte.js b/frontend/src/lib/composables/useCollectionEventStream.svelte.js index 1a2a6b2d5..30db3fdba 100644 --- a/frontend/src/lib/composables/useCollectionEventStream.svelte.js +++ b/frontend/src/lib/composables/useCollectionEventStream.svelte.js @@ -1,4 +1,5 @@ import { toExternal } from '../runtime/contextPath.js'; +import { createBackgroundRefresh } from '../utils/backgroundRefresh.js'; import { createConnectionReconcileTracker } from './useItemEventStream.svelte.js'; const DEBOUNCE_MS = 250; @@ -9,7 +10,7 @@ const DEBOUNCE_MS = 250; * the stream never has to carry item-level detail. * * @param {() => ({ kind: 'collection'|'workspace', id: number|string|null }|null)} getScope - * @param {{ onInvalidate?: () => void }} [handlers] + * @param {{ onInvalidate?: () => unknown, debounceMs?: number }} [handlers] * @returns {{ readonly connected: boolean }} */ export function useCollectionEventStream(getScope, handlers = {}) { @@ -32,19 +33,11 @@ export function useCollectionEventStream(getScope, handlers = {}) { ? toExternal(`/api/workspaces/${id}/events`) : toExternal(`/api/collections/${id}/events`); - let pending = false; - let timer = null; const connectionTracker = createConnectionReconcileTracker(); - const schedule = () => { - pending = true; - if (timer) return; - timer = setTimeout(() => { - timer = null; - if (!pending) return; - pending = false; - handlers.onInvalidate?.(); - }, DEBOUNCE_MS); - }; + const refresh = createBackgroundRefresh(() => handlers.onInvalidate?.(), { + delay: handlers.debounceMs ?? DEBOUNCE_MS, + }); + const schedule = refresh.schedule; const es = new EventSource(url); es.addEventListener('connected', () => { @@ -55,15 +48,14 @@ export function useCollectionEventStream(getScope, handlers = {}) { if (connectionTracker.markConnected()) schedule(); }); es.addEventListener('changed', schedule); - // The browser auto-reconnects; until it does, mark disconnected so the - // poller resumes as the fallback. + // The browser reconnects automatically; reconcile after the gap. es.onerror = () => { connectionTracker.markDisconnected(); connected = false; }; return () => { - if (timer) clearTimeout(timer); + refresh.dispose(); es.close(); connected = false; }; diff --git a/frontend/src/lib/composables/useItemEventStream.svelte.js b/frontend/src/lib/composables/useItemEventStream.svelte.js index 5e8bd3d8e..a0665612d 100644 --- a/frontend/src/lib/composables/useItemEventStream.svelte.js +++ b/frontend/src/lib/composables/useItemEventStream.svelte.js @@ -1,4 +1,5 @@ import { toExternal } from '../runtime/contextPath.js'; +import { createBackgroundRefresh } from '../utils/backgroundRefresh.js'; const DEBOUNCE_MS = 250; @@ -22,14 +23,12 @@ export function useItemEventStream(getItemId, handlers = {}) { if (!itemId) return; if (typeof EventSource === 'undefined') return; // SSR / unsupported - let timer = null; // A handler that was already running when the subscription was torn down // must not reconcile against the next item's view. let active = true; const connectionTracker = createConnectionReconcileTracker(); const reconcile = async () => { - timer = null; if (!active) return; try { await handlers.onReconcile?.(); @@ -39,9 +38,8 @@ export function useItemEventStream(getItemId, handlers = {}) { console.warn('Item event stream reconcile failed:', error); } }; - const scheduleReconcile = () => { - if (!timer) timer = setTimeout(reconcile, DEBOUNCE_MS); - }; + const refresh = createBackgroundRefresh(reconcile, { delay: DEBOUNCE_MS }); + const scheduleReconcile = refresh.schedule; const es = new EventSource(toExternal(`/api/items/${itemId}/events`)); @@ -54,8 +52,8 @@ export function useItemEventStream(getItemId, handlers = {}) { es.addEventListener('deleted', () => { // Deletion is authoritative and must tear down item-bound loaders before // their in-flight requests start returning expected 404s. - if (timer) clearTimeout(timer); - timer = null; + active = false; + refresh.dispose(); handlers.onDeleted?.(); }); // The browser auto-reconnects (honoring the server's retry hint). Until it @@ -67,7 +65,7 @@ export function useItemEventStream(getItemId, handlers = {}) { return () => { active = false; - if (timer) clearTimeout(timer); + refresh.dispose(); es.close(); connected = false; }; diff --git a/frontend/src/lib/features/collections/CollectionBoard.svelte b/frontend/src/lib/features/collections/CollectionBoard.svelte index 7562619ac..b66d72b54 100644 --- a/frontend/src/lib/features/collections/CollectionBoard.svelte +++ b/frontend/src/lib/features/collections/CollectionBoard.svelte @@ -89,6 +89,7 @@ import { workspacePermissions } from '../../stores/workspacePermissions.svelte.js'; import { itemModalStore } from '../../stores/itemModalStore.svelte.js'; import { useCollectionEventStream } from '../../composables/useCollectionEventStream.svelte.js'; + import { canRunBackgroundSync, onBackgroundSyncAvailable } from '../../utils/backgroundSync.js'; import { useCollectionDragDrop } from '../../composables/useCollectionDragDrop.js'; import { createDependencyLoader } from './dependencyLoader.svelte.js'; import { loadedVisibleItemIDs, visibleAssigneeIDs } from './boardHydration.js'; @@ -746,7 +747,7 @@ const jitter = base * 0.1; let timer = null; const tick = () => { - if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return; + if (!canRunBackgroundSync()) return; invalidateSLAState(); slaRefreshToken += 1; }; @@ -756,14 +757,11 @@ schedule(); }, base + (Math.random() * 2 - 1) * jitter); }; - const onVisibility = () => { - if (document.visibilityState === 'visible') tick(); - }; schedule(); - document.addEventListener('visibilitychange', onVisibility); + const stopRecovery = onBackgroundSyncAvailable(tick); return () => { if (timer) clearTimeout(timer); - document.removeEventListener('visibilitychange', onVisibility); + stopRecovery(); }; }); diff --git a/frontend/src/lib/features/items/ItemSLAPanel.svelte b/frontend/src/lib/features/items/ItemSLAPanel.svelte index fb8a8c849..7e864fff4 100644 --- a/frontend/src/lib/features/items/ItemSLAPanel.svelte +++ b/frontend/src/lib/features/items/ItemSLAPanel.svelte @@ -7,6 +7,7 @@ import { formatInstant } from '../../utils/dateFormatter.js'; import { onWorkItemRefresh, workItemRefreshTouchesWorkspace } from '../../stores/workItemRefresh.js'; import { getItemSLA } from '../sla/slaState.js'; + import { canRunBackgroundSync, onBackgroundSyncAvailable } from '../../utils/backgroundSync.js'; let { itemId = null, workspaceId = null } = $props(); @@ -18,14 +19,23 @@ let states = $state([]); let loading = $state(false); let requestToken = 0; + let backgroundDirty = false; + let loadInFlight = false; + let recoveryPending = false; - async function load() { + async function load(force = false) { if (!itemId) return; + if (loadInFlight) { + if (force) recoveryPending = true; + return; + } + loadInFlight = true; + recoveryPending = false; const token = ++requestToken; const requestedItemId = itemId; loading = true; try { - const result = (await getItemSLA(requestedItemId, workspaceId)) ?? []; + const result = (await getItemSLA(requestedItemId, workspaceId, { force })) ?? []; // A newer request or an item switch supersedes this response. if (token !== requestToken) return; states = result; @@ -33,20 +43,43 @@ // SLA state is supplementary; never break the item detail on a read error. if (token === requestToken) states = []; } finally { - if (token === requestToken) loading = false; + if (token === requestToken) { + loading = false; + loadInFlight = false; + if (recoveryPending && canRunBackgroundSync()) void load(true); + } } } $effect(() => { if (itemId) void load(); + return () => { + requestToken++; + loadInFlight = false; + recoveryPending = false; + backgroundDirty = false; + }; }); let hasOngoing = $derived(states.some((state) => state.ongoing)); + function refreshBackground(force = false) { + if (!canRunBackgroundSync()) { + backgroundDirty = true; + return; + } + backgroundDirty = false; + void load(force); + } + + $effect(() => onBackgroundSyncAvailable(() => { + if (loading || hasOngoing || backgroundDirty || recoveryPending) refreshBackground(true); + })); + $effect(() => { // Only poll while a timer is actually running; completed history is static. if (!itemId || !hasOngoing) return; - const timer = setInterval(() => void load(), SLA_REFRESH_MS); + const timer = setInterval(refreshBackground, SLA_REFRESH_MS); return () => clearInterval(timer); }); @@ -57,7 +90,7 @@ $effect(() => onWorkItemRefresh((detail) => { if (!workItemRefreshTouchesWorkspace(detail, workspaceId)) return; - void load(); + refreshBackground(true); }) ); diff --git a/frontend/src/lib/features/sla/slaState.js b/frontend/src/lib/features/sla/slaState.js index f763fadae..f9d25fb1c 100644 --- a/frontend/src/lib/features/sla/slaState.js +++ b/frontend/src/lib/features/sla/slaState.js @@ -70,8 +70,9 @@ const pendingByWorkspace = new Map(); // this, so ids are split across requests instead of being rejected whole. const BATCH_ID_LIMIT = 200; -export async function getItemSLA(itemId, workspaceId = null) { +export async function getItemSLA(itemId, workspaceId = null, { force = false } = {}) { if (!itemId) return []; + if (force) stateCache.delete(itemId); const cached = stateCache.get(itemId); const now = Date.now(); if (cached && now - cached.at < STATE_TTL_MS) return cached.value; diff --git a/frontend/src/lib/pages/PublicBoard.svelte b/frontend/src/lib/pages/PublicBoard.svelte index fab8661cc..cd531fdf0 100644 --- a/frontend/src/lib/pages/PublicBoard.svelte +++ b/frontend/src/lib/pages/PublicBoard.svelte @@ -9,6 +9,7 @@ import PublicBoardItemDetail from './PublicBoardItemDetail.svelte'; import { primaryMilestoneForItem, sortMilestonesForLanes, SWIMLANE_NONE_KEY } from '../features/collections/swimlanes.js'; import { formatDateOnly } from '../utils/dateFormatter.js'; + import { canRunBackgroundSync, onBackgroundSyncAvailable } from '../utils/backgroundSync.js'; let { slug } = $props(); @@ -110,14 +111,21 @@ const controller = new AbortController(); let inFlight = false; let disposed = false; + let recoveryPending = false; board = null; error = null; loading = true; selectedItemKey = null; - async function load(silent) { - if (inFlight || disposed) return; + async function load(silent, recovery = false) { + if (silent && !canRunBackgroundSync()) return; + if (disposed) return; + if (inFlight) { + if (recovery) recoveryPending = true; + return; + } + recoveryPending = false; inFlight = true; if (!silent) loading = true; try { @@ -131,17 +139,20 @@ } finally { inFlight = false; if (!disposed && id === loadId) loading = false; + if (recoveryPending && !disposed) void load(true); } } load(false); // Auto-refresh every 60 seconds without overlapping a slow request. const refreshInterval = setInterval(() => load(true), 60000); + const stopRecovery = onBackgroundSyncAvailable(() => load(true, true)); return () => { disposed = true; controller.abort(); clearInterval(refreshInterval); + stopRecovery(); }; }); diff --git a/frontend/src/lib/stores/collectionContext.svelte.js b/frontend/src/lib/stores/collectionContext.svelte.js index 89ea7e7fd..85f80d7fa 100644 --- a/frontend/src/lib/stores/collectionContext.svelte.js +++ b/frontend/src/lib/stores/collectionContext.svelte.js @@ -16,7 +16,11 @@ import { defaultHierarchyLevel, } from '../features/collections/hierarchyLevels.js'; import { currentRoute, GLOBAL_COLLECTION_VIEWS } from '../router.js'; -import { isExpectedBackgroundSyncError } from '../utils/backgroundSync.js'; +import { + canRunBackgroundSync, + isExpectedBackgroundSyncError, + onBackgroundSyncAvailable, +} from '../utils/backgroundSync.js'; import { calcHasMore } from '../utils/paginationUtils.js'; import { collectionPreferences } from './collectionPreferences.svelte.js'; import { surfaceError } from './toasts.svelte.js'; @@ -333,6 +337,7 @@ class CollectionStore { #searchId = 0; #searchQuery = ''; #reconcileTimer = null; + #stopReconcileRecovery = null; #reconcileInFlight = null; #reconcilePending = false; #levelCountsScope = null; @@ -364,6 +369,7 @@ class CollectionStore { // Navigated away from a collection view — clear the route key // so that returning to the same collection triggers a fresh load. this.#previousRouteKey = null; + this.#clearScheduledReconciliation(); return; } @@ -379,6 +385,7 @@ class CollectionStore { * Initial load: fetches page 1 of items and backlog, resets all pagination state. */ async load(wsId, colId, view = this.#currentView) { + this.#clearScheduledReconciliation(); const sameCollection = wsId === this.#wsId && colId === this.#colId; const viewChanged = view !== this.#currentView; const targetInitialLimit = initialItemsPageSize(view); @@ -1482,6 +1489,10 @@ class CollectionStore { * once after it settles. */ refreshDeltas() { + if (!canRunBackgroundSync()) { + this.scheduleLocalReconciliation(); + return Promise.resolve(false); + } if (this.loading || this.#reconcileInFlight) { this.#reconcilePending = true; return this.#reconcileInFlight ?? Promise.resolve(false); @@ -1586,13 +1597,30 @@ class CollectionStore { * runs and retains a trailing run. */ scheduleLocalReconciliation() { - if (this.#reconcileTimer) clearTimeout(this.#reconcileTimer); + this.#clearScheduledReconciliation(); + if (!canRunBackgroundSync()) { + this.#stopReconcileRecovery = onBackgroundSyncAvailable(() => + this.scheduleLocalReconciliation() + ); + return; + } this.#reconcileTimer = setTimeout(() => { this.#reconcileTimer = null; + if (!canRunBackgroundSync()) { + this.scheduleLocalReconciliation(); + return; + } void this.refreshDeltas(); }, LOCAL_RECONCILE_DEBOUNCE_MS); } + #clearScheduledReconciliation() { + if (this.#reconcileTimer) clearTimeout(this.#reconcileTimer); + this.#reconcileTimer = null; + this.#stopReconcileRecovery?.(); + this.#stopReconcileRecovery = null; + } + async #primeChangesWatermark() { const loadId = this.#loadId; const wsId = this.#wsId; @@ -1712,10 +1740,7 @@ class CollectionStore { if (this.#unsubscribe) { this.#unsubscribe(); } - if (this.#reconcileTimer) { - clearTimeout(this.#reconcileTimer); - this.#reconcileTimer = null; - } + this.#clearScheduledReconciliation(); } } diff --git a/frontend/src/lib/utils/backgroundRefresh.js b/frontend/src/lib/utils/backgroundRefresh.js new file mode 100644 index 000000000..93035be99 --- /dev/null +++ b/frontend/src/lib/utils/backgroundRefresh.js @@ -0,0 +1,56 @@ +import { surfaceError } from '../stores/toasts.svelte.js'; +import { + canRunBackgroundSync, + isExpectedBackgroundSyncError, + onBackgroundSyncAvailable, +} from './backgroundSync.js'; + +/** + * Coalesce view invalidations and retain one trailing refresh during slow reads. + * Callers keep ownership of polling cadence. + * @param {() => unknown} refresh + * @param {{ delay?: number }} [options] + */ +export function createBackgroundRefresh(refresh, { delay = 250 } = {}) { + let timer = null; + let pending = false; + let running = false; + let disposed = false; + + const queue = (wait) => { + if (disposed) return; + pending = true; + if (timer !== null || running || !canRunBackgroundSync()) return; + timer = setTimeout(async () => { + timer = null; + if (disposed || !canRunBackgroundSync()) return; + pending = false; + running = true; + try { + await refresh(); + } catch (error) { + if (!disposed && !isExpectedBackgroundSyncError(error)) { + surfaceError(error, { silent: true, context: 'backgroundRefresh' }); + } + } finally { + running = false; + if (pending) queue(delay); + } + }, wait); + }; + const schedule = () => queue(delay); + const stopRecovery = onBackgroundSyncAvailable(() => { + if (timer !== null) clearTimeout(timer); + timer = null; + queue(0); + }); + return { + schedule, + dispose() { + disposed = true; + pending = false; + if (timer !== null) clearTimeout(timer); + stopRecovery(); + }, + }; +} diff --git a/frontend/src/lib/utils/backgroundSync.js b/frontend/src/lib/utils/backgroundSync.js index 10f5c406c..deb4f308c 100644 --- a/frontend/src/lib/utils/backgroundSync.js +++ b/frontend/src/lib/utils/backgroundSync.js @@ -66,12 +66,14 @@ export function onBackgroundSyncAvailable(callback, environment = {}) { documentRef?.addEventListener('visibilitychange', markSuspended); documentRef?.addEventListener('visibilitychange', refreshIfAvailable); + windowRef?.addEventListener('offline', markSuspended); windowRef?.addEventListener('online', markSuspended); windowRef?.addEventListener('online', refreshIfAvailable); return () => { documentRef?.removeEventListener('visibilitychange', markSuspended); documentRef?.removeEventListener('visibilitychange', refreshIfAvailable); + windowRef?.removeEventListener('offline', markSuspended); windowRef?.removeEventListener('online', markSuspended); windowRef?.removeEventListener('online', refreshIfAvailable); }; diff --git a/frontend/src/lib/widgets/SupportMetricsWidget.svelte b/frontend/src/lib/widgets/SupportMetricsWidget.svelte index e179838fc..d5a61f934 100644 --- a/frontend/src/lib/widgets/SupportMetricsWidget.svelte +++ b/frontend/src/lib/widgets/SupportMetricsWidget.svelte @@ -2,6 +2,7 @@ import Download from '@lucide/svelte/icons/download'; import { support } from '../api/support.js'; import { useCollectionEventStream } from '../composables/useCollectionEventStream.svelte.js'; + import { canRunBackgroundSync } from '../utils/backgroundSync.js'; import { t } from '../stores/i18n.svelte.js'; import { downloadSupportMetricsCSV } from '../features/support/metricColumns.js'; import Chart from './Chart.svelte'; @@ -71,36 +72,27 @@ } } - // Live refresh: the workspace/collection SSE stream fires coarse item - // invalidations; debounce them so bursts collapse into one refetch. - let refreshTimer = null; + // Keep the metrics cadence at the read boundary so hiding during the delay + // defers the request too. const stream = useCollectionEventStream( () => { if (collectionId) return { kind: 'collection', id: collectionId }; return { kind: 'workspace', id: workspaceId }; }, { - onInvalidate: () => { - if (refreshTimer) return; - refreshTimer = setTimeout(() => { - refreshTimer = null; - load(); - }, 5000); - }, + debounceMs: 5000, + onInvalidate: () => load(), } ); $effect(() => { const onFocus = () => { - if (loadKey) load(); + if (loadKey && canRunBackgroundSync()) load(); }; window.addEventListener('focus', onFocus); return () => { window.removeEventListener('focus', onFocus); - if (refreshTimer) { - clearTimeout(refreshTimer); - refreshTimer = null; - } + version++; }; }); diff --git a/frontend/src/lib/widgets/ZammadSupportWidget.svelte b/frontend/src/lib/widgets/ZammadSupportWidget.svelte index a3128bce9..dd325749b 100644 --- a/frontend/src/lib/widgets/ZammadSupportWidget.svelte +++ b/frontend/src/lib/widgets/ZammadSupportWidget.svelte @@ -17,6 +17,7 @@ } from '../utils/zammadObservations.js'; import Lozenge from '../components/Lozenge.svelte'; import WidgetState from './WidgetState.svelte'; + import { canRunBackgroundSync, onBackgroundSyncAvailable } from '../utils/backgroundSync.js'; let { workspaceId = null } = $props(); @@ -24,7 +25,9 @@ let loading = $state(false); let error = $state(null); let requestVersion = 0; - let loadedWorkspaceId = $state(undefined); + let overviewController = null; + let overviewInFlight = null; + let recoveryPending = false; let timezone = $derived(getUserTimezone(authStore.currentUser)); let hasOverviewData = $derived( Boolean( @@ -48,17 +51,28 @@ const overviewRefreshIntervalMs = 120_000; $effect(() => { - if (workspaceId === loadedWorkspaceId) return; - loadedWorkspaceId = workspaceId; void loadOverview(workspaceId); + return () => { + requestVersion++; + overviewController?.abort(); + overviewInFlight = null; + recoveryPending = false; + }; }); onMount(() => { - const interval = window.setInterval(() => { - if (workspaceId) void loadOverview(workspaceId); - }, overviewRefreshIntervalMs); + const refresh = (recovery = false) => { + if (!workspaceId || !canRunBackgroundSync()) return; + if (recovery && overviewInFlight) recoveryPending = true; + else void loadOverview(workspaceId); + }; + const interval = window.setInterval(refresh, overviewRefreshIntervalMs); + const stopRecovery = onBackgroundSyncAvailable(() => refresh(true)); - return () => window.clearInterval(interval); + return () => { + window.clearInterval(interval); + stopRecovery(); + }; }); function fieldLabel(field) { @@ -93,7 +107,9 @@ } async function loadOverview(targetWorkspaceId = workspaceId) { + if (overviewInFlight?.workspaceId === targetWorkspaceId) return; const version = ++requestVersion; + recoveryPending = false; const isCurrentRequest = () => isCurrentZammadWorkspaceOverviewRequest( version, @@ -110,8 +126,12 @@ loading = true; error = null; + overviewController = new AbortController(); + const controller = overviewController; try { - const response = await zammadTickets.workspaceOverview(targetWorkspaceId, { limit: 5 }); + const promise = zammadTickets.workspaceOverview(targetWorkspaceId, { limit: 5, signal: controller.signal }); + overviewInFlight = { workspaceId: targetWorkspaceId, promise }; + const response = await promise; if (!isCurrentRequest()) return; overview = { total: 0, @@ -131,13 +151,15 @@ : [], }; } catch (err) { - if (!isCurrentRequest()) return; + if (!isCurrentRequest() || err?.name === 'AbortError') return; console.error('Failed to load Zammad support overview:', err); overview = null; error = t('zammad.overview.loadFailed'); } finally { if (isCurrentRequest()) { loading = false; + overviewInFlight = null; + if (recoveryPending && canRunBackgroundSync()) void loadOverview(workspaceId); } } }