/** In-app notification bell — polls completed detection jobs (FR-05). */ const SEEN_JOBS_KEY = 'dda_seen_job_ids'; const POLL_MS = 30000; function getSeenJobIds() { try { return new Set(JSON.parse(localStorage.getItem(SEEN_JOBS_KEY) || '[]')); } catch (_) { return new Set(); } } function saveSeenJobIds(ids) { try { localStorage.setItem(SEEN_JOBS_KEY, JSON.stringify([...ids].slice(-200))); } catch (_) {} } function markJobSeen(jobId) { const seen = getSeenJobIds(); seen.add(String(jobId)); saveSeenJobIds(seen); refreshNotifications(); } function formatNotifyTime(iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); } catch (_) { return iso; } } async function fetchNotifyJobs() { try { const data = await ddaApi('GET', '/api/dda/jobs?limit=25'); return data.jobs || []; } catch (_) { return []; } } function updateNotifyBadge(jobs) { const badge = document.getElementById('dda-notify-badge'); if (!badge) return; const seen = getSeenJobIds(); const unread = jobs.filter((j) => j.status === 'completed' && j.runId && !seen.has(String(j.id))); if (unread.length) { badge.textContent = unread.length > 9 ? '9+' : String(unread.length); badge.classList.remove('hidden'); } else { badge.classList.add('hidden'); } return unread; } function renderNotifyPanel(jobs) { const panel = document.getElementById('dda-notify-panel'); if (!panel) return; const seen = getSeenJobIds(); const items = jobs.filter((j) => ['completed', 'failed', 'running', 'queued'].includes(j.status)); if (!items.length) { panel.innerHTML = '

No recent jobs.

'; return; } panel.innerHTML = items.slice(0, 12).map((j) => { const unread = j.status === 'completed' && !seen.has(String(j.id)); const title = j.title || `${j.basePath || 'Base'} vs ${j.comparisonPath || 'Comparison'}`; const pct = j.report?.changePercentage; const meta = j.status === 'completed' && pct != null ? `${pct.toFixed(2)}% change` : j.status; return ` `; }).join(''); panel.querySelectorAll('.dda-notify-item').forEach((btn) => { btn.addEventListener('click', async () => { markJobSeen(btn.dataset.jobId); const status = btn.dataset.status; const runId = btn.dataset.runId; closeNotifyPanel(); if (status === 'completed' && runId) { if (typeof showDdaResult === 'function') { try { const data = await ddaApi('GET', `/api/history/${runId}`); showDdaResult(data); } catch (_) { window.open(`/dda/reports/${runId}`, '_blank'); } } else { window.open(`/dda/reports/${runId}`, '_blank'); } } else if (status === 'failed') { if (typeof showDdaError === 'function') showDdaError('Detection job failed. See Reports tab for details.'); document.querySelector('.dda-tab[data-tab="reports"]')?.click(); } else { document.querySelector('.dda-tab[data-tab="reports"]')?.click(); } }); }); } function closeNotifyPanel() { document.getElementById('dda-notify-panel')?.classList.add('hidden'); } async function refreshNotifications() { const jobs = await fetchNotifyJobs(); updateNotifyBadge(jobs); const panel = document.getElementById('dda-notify-panel'); if (panel && !panel.classList.contains('hidden')) { renderNotifyPanel(jobs); } return jobs; } function initNotifications() { const btn = document.getElementById('dda-notify-btn'); if (!btn) return; btn.addEventListener('click', async (e) => { e.stopPropagation(); const panel = document.getElementById('dda-notify-panel'); const wasHidden = panel?.classList.contains('hidden'); if (wasHidden) { const jobs = await fetchNotifyJobs(); renderNotifyPanel(jobs); panel?.classList.remove('hidden'); } else { closeNotifyPanel(); } }); document.addEventListener('click', (e) => { if (e.target.closest('.dda-notify-wrap')) return; closeNotifyPanel(); }); refreshNotifications(); setInterval(refreshNotifications, POLL_MS); } document.addEventListener('DOMContentLoaded', initNotifications); window.markDdaJobSeen = markJobSeen; window.refreshDdaNotifications = refreshNotifications;