Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/biome.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/lib/api/integrations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
24 changes: 8 additions & 16 deletions frontend/src/lib/composables/useCollectionEventStream.svelte.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 = {}) {
Expand All @@ -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', () => {
Expand All @@ -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;
};
Expand Down
14 changes: 6 additions & 8 deletions frontend/src/lib/composables/useItemEventStream.svelte.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { toExternal } from '../runtime/contextPath.js';
import { createBackgroundRefresh } from '../utils/backgroundRefresh.js';

const DEBOUNCE_MS = 250;

Expand All @@ -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?.();
Expand All @@ -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`));

Expand All @@ -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
Expand All @@ -67,7 +65,7 @@ export function useItemEventStream(getItemId, handlers = {}) {

return () => {
active = false;
if (timer) clearTimeout(timer);
refresh.dispose();
es.close();
connected = false;
};
Expand Down
10 changes: 4 additions & 6 deletions frontend/src/lib/features/collections/CollectionBoard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
};
Expand All @@ -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();
};
});

Expand Down
43 changes: 38 additions & 5 deletions frontend/src/lib/features/items/ItemSLAPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -18,35 +19,67 @@
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;
} catch {
// 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);
});

Expand All @@ -57,7 +90,7 @@
$effect(() =>
onWorkItemRefresh((detail) => {
if (!workItemRefreshTouchesWorkspace(detail, workspaceId)) return;
void load();
refreshBackground(true);
})
);

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib/features/sla/slaState.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 13 additions & 2 deletions frontend/src/lib/pages/PublicBoard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 {
Expand All @@ -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();
};
});
</script>
Expand Down
Loading
Loading