// Setup Completion Watcher // // Listens for the `taskCompleted` event the TaskEventBus dispatches when any // task hits a terminal state. If the completed task is the finalize task // recorded by the Setup Wizard handoff (stashed in sessionStorage), trigger // the celebratory exit: toast notification, re-enable the topbar nav, then // navigate to the dashboard. (function setupCompletionWatcher() { function readHandoff() { try { const raw = sessionStorage.getItem('libreportal_setup_handoff'); return raw ? JSON.parse(raw) : null; } catch { return null; } } function clearHandoff() { try { sessionStorage.removeItem('libreportal_setup_handoff'); } catch { /* noop */ } } // Floating banner shown across the top of any page while setup tasks are // running. Surfaces the X-of-Y completion count from the live TaskEventBus // snapshot so the user always knows how far through they are. Vanishes // automatically when the finalize event fires (the same handler that runs // the toast + dashboard redirect down below). let bannerEl = null; function isOnTasksPage() { const p = window.location.pathname; return p === '/tasks' || p.startsWith('/tasks') || p === '/tasks.html'; } function ensureBanner() { if (bannerEl && document.body.contains(bannerEl)) return bannerEl; bannerEl = document.createElement('div'); bannerEl.className = 'setup-progress-banner'; bannerEl.innerHTML = `
`; document.body.appendChild(bannerEl); return bannerEl; } function removeBanner() { if (bannerEl) { bannerEl.classList.add('leaving'); setTimeout(() => { bannerEl?.remove(); bannerEl = null; }, 350); } } // Local cache keyed by task id. SSE bus only registers tasks once they fire // an event AFTER it connects, so tasks that already finished before the page // loaded are invisible to it. We seed this from /api/tasks on start and // refresh from each SSE event so the banner reflects the true count. const groupTasks = new Map(); async function seedGroupTasks() { const handoff = readHandoff(); if (!handoff || !handoff.setupGroup) return; try { const res = await fetch('/api/tasks', { cache: 'no-store' }); if (!res.ok) return; const all = await res.json(); for (const t of all) { if (t && t.setupGroup === handoff.setupGroup) groupTasks.set(t.id, t); } refreshBanner(); // Reload-mid-setup case: if the finalize task already terminated // before the page came back up, no SSE event will fire for it. The // /api/tasks fetch above is the only signal — kick off the completion // handler from here too so the toast/redirect still runs. checkAlreadyCompleted(); } catch { /* network blip — SSE events will fill in the gaps */ } } function ingestEventTask(detail) { const t = detail && detail.task; if (!t || !t.id) return; const handoff = readHandoff(); if (!handoff || !handoff.setupGroup) return; if (t.setupGroup !== handoff.setupGroup) return; groupTasks.set(t.id, t); } function refreshBanner() { if (!isOnTasksPage()) { removeBanner(); return; } const handoff = readHandoff(); if (!handoff || !handoff.setupGroup) { removeBanner(); return; } const tasksInGroup = Array.from(groupTasks.values()); // Trust the handoff for the denominator — see seedGroupTasks for why // the SSE bus alone undercounts. const total = handoff.totalTaskCount || tasksInGroup.length; if (total === 0) return; const done = tasksInGroup.filter(t => t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled').length; const failed = tasksInGroup.some(t => t.status === 'failed'); const banner = ensureBanner(); banner.classList.toggle('failed', failed); banner.querySelector('.setup-progress-banner-count').textContent = `— ${done} of ${total} tasks complete`; const pct = total ? Math.round((done / total) * 100) : 0; banner.querySelector('.setup-progress-banner-fill').style.width = `${pct}%`; } function showWelcomeToast(installName, success) { const sys = (typeof window.ensureNotificationSystem === 'function') ? window.ensureNotificationSystem() : window.notificationSystem; if (!sys || typeof sys.show !== 'function') return; if (success) { sys.show( `Setup complete — welcome aboard, ${installName || 'Quantum Traveler'}. Your install is ready.`, 'success' ); } else { sys.show( `Setup ran into an issue. Check the failed task above for details.`, 'error' ); } } function reEnableNav() { document.querySelectorAll('.topbar-nav.setup-needed').forEach((nav) => { nav.classList.remove('setup-needed'); }); } function navigateToRecommendedApps() { const route = '/apps?=recommended'; if (window.spaClean && typeof window.spaClean.navigate === 'function') { window.spaClean.navigate(route); } else if (typeof window.navigateToRoute === 'function') { window.navigateToRoute(route); } else { window.location.href = 'apps.html?=recommended'; } } // Refresh the banner on every task event so the count stays live. const onTaskEvent = (e) => { ingestEventTask(e.detail || {}); refreshBanner(); }; window.addEventListener('taskCreated', onTaskEvent); window.addEventListener('taskUpdated', onTaskEvent); window.addEventListener('taskCompleted', onTaskEvent); // Re-evaluate on navigation: covers back/forward (popstate) and SPA // pushState/replaceState (monkey-patched once). Without this the banner // would linger after leaving /tasks (or never appear when arriving there) // because nothing else triggers refreshBanner. window.addEventListener('popstate', refreshBanner); ['pushState', 'replaceState'].forEach((m) => { const orig = history[m]; history[m] = function (...args) { const r = orig.apply(this, args); refreshBanner(); return r; }; }); // Seed once now and again every 3s as a safety net — covers the window // between the page loading and the SSE bus connecting, plus any FS-watch // hiccups that swallow a task.upsert event. seedGroupTasks(); setInterval(() => { if (readHandoff()) seedGroupTasks(); }, 3000); window.addEventListener('taskCompleted', (event) => { const detail = event.detail || {}; const handoff = readHandoff(); if (!handoff || !handoff.finalizeTaskId) return; if (detail.taskId !== handoff.finalizeTaskId) return; const success = detail.status === 'completed'; clearHandoff(); // Defer to the next frame so the tasks-manager listener (registered on the // same `taskCompleted` event) gets to flip the row to "completed" first — // otherwise the welcome toast appears while the finalize row still shows // "running", which reads as out-of-order. requestAnimationFrame(() => { removeBanner(); reEnableNav(); showWelcomeToast(handoff.installName, success); if (success) setTimeout(navigateToRecommendedApps, 1800); }); }); // Also handle the case where the user reloads the tasks page after the // finalize task has already completed — we won't get a fresh `taskCompleted` // event, so check the bus snapshot once it's ready. function checkAlreadyCompleted() { const handoff = readHandoff(); if (!handoff || !handoff.finalizeTaskId) return; // Prefer the /api/tasks-seeded cache (groupTasks) — the SSE bus only // knows tasks that emitted events after it connected, so reload-mid- // setup leaves it blind to anything that already finished. const task = groupTasks.get(handoff.finalizeTaskId) || (window.taskEventBus && window.taskEventBus.getTask(handoff.finalizeTaskId)); if (!task) return; if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') { const success = task.status === 'completed'; clearHandoff(); removeBanner(); reEnableNav(); showWelcomeToast(handoff.installName, success); if (success) setTimeout(navigateToRecommendedApps, 1800); } } window.addEventListener('taskBusReady', () => { checkAlreadyCompleted(); refreshBanner(); }); if (window.taskEventBus && window.taskEventBus.connected) { checkAlreadyCompleted(); refreshBanner(); } })();