function createProgressiveMyWork({ document, fetchSnapshot, liveSnapshot: snapshotBroker = null, pollerOptions = {}, lifecycleTarget = globalThis, }) { const list = document.querySelector('#my-work-list'); const status = document.querySelector('#my-work-status'); const detail = document.querySelector('#progressive-work-detail'); const detailTitle = document.querySelector('#progressive-work-detail-title'); const detailMeta = document.querySelector('#progressive-work-detail-meta'); const detailReason = document.querySelector('#progressive-work-detail-reason'); const closeDetail = document.querySelector('#close-progressive-work-detail'); const openGitea = document.querySelector('#open-progressive-work-gitea'); const filters = Array.from(document.querySelectorAll('[data-work-filter]')); const listeners = []; const lifecycleListeners = []; const subscribers = new Set(); let items = []; let active = 'all'; let stopped = false; let selectedByUser = false; let liveSnapshot = null; let liveSnapshotPromise = null; let confirmedLogin = ''; let poller = null; let detailTrigger = null; let openWork = null; let contextUnavailable = false; const deferredQueues = { today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts', }; const fetchProgressiveSnapshot = (revisions = {}, options = {}) => snapshotBroker && Object.keys(revisions || {}).length === 0 ? snapshotBroker.acquire() : fetchSnapshot(revisions, options); const escapeHtml = value => String(value || '').replace(/[&<>"']/g, character => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''', })[character]); const safeUrl = value => { try { const url = new URL(String(value || ''), globalThis.location?.href || 'https://invalid.example/'); return ['http:', 'https:'].includes(url.protocol) ? url.href : ''; } catch (_error) { return ''; } }; const matches = (item, filter) => filter === 'review' ? item.is_review : filter === 'update' ? item.has_update : filter === 'attention' ? item.needs_attention : filter === 'filed' ? item.is_filed : filter === 'authored' ? item.is_authored : filter === 'pull' ? item.kind === 'pull' && !item.is_review : item.kind === filter; const visibleItems = () => active === 'all' ? items : items.filter(item => matches(item, active)); const notify = () => subscribers.forEach(subscriber => subscriber()); const queueCounts = () => ({ all:items.length, filed:items.filter(item => matches(item, 'filed')).length, authored:items.filter(item => matches(item, 'authored')).length, attention:items.filter(item => matches(item, 'attention')).length, update:items.filter(item => matches(item, 'update')).length, review:items.filter(item => matches(item, 'review')).length, }); const closeProgressiveDetail = ({ restoreFocus = true } = {}) => { if (!detail || detail.hidden) return; detail.hidden = true; if (restoreFocus) detailTrigger?.focus?.(); detailTrigger = null; openWork = null; }; const openProgressiveDetail = (item, trigger) => { if (!detail || !item) return; detailTrigger = trigger; openWork = item; detailTitle.textContent = item.title || item.key || 'Untitled work'; detailMeta.textContent = (item.key || 'Unknown work item') + ' · ' + (item.kind === 'pull' ? 'Pull request' : 'Issue'); detailReason.textContent = item.reason || 'Assigned to you'; const href = safeUrl(item.url); openGitea.hidden = !href; if (href) openGitea.href = href; else openGitea.removeAttribute?.('href'); detail.hidden = false; closeDetail?.focus?.(); }; const updateCounts = () => { ['all','attention','filed','authored','issue','pull','review','update'].forEach(filter => { const count = filter === 'all' ? items.length : items.filter(item => matches(item, filter)).length; const element = document.querySelector('[data-work-count="' + filter + '"]'); if (element) element.textContent = String(count); }); }; const render = () => { if (stopped || !list) return; const visible = visibleItems(); list.innerHTML = contextUnavailable && !items.length ? '
Assigned work is reconnecting…
' : deferredQueues[active] ? '
' + deferredQueues[active] + ' is still loading…
' : visible.length ? visible.map((item, index) => { const title = escapeHtml(item.title || item.key || 'Untitled work'); const context = escapeHtml(item.key || ''); const reason = escapeHtml(item.reason || 'Assigned to you'); return '
' + '
'; }).join('') : '
No work in this queue.
'; filters.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.workFilter === active))); }; filters.forEach(button => { const listener = () => { active = button.dataset.workFilter || 'all'; selectedByUser = true; render(); }; button.addEventListener('click', listener); listeners.push([button, listener]); }); const openListener = event => { const trigger = event.target?.closest?.('[data-progressive-work-index]'); if (!trigger) return; const item = visibleItems()[Number(trigger.dataset.progressiveWorkIndex)]; if (!item) return; event.preventDefault?.(); openProgressiveDetail(item, trigger); }; const closeListener = () => closeProgressiveDetail(); const keyListener = event => { if (event.key !== 'Escape' || detail?.hidden) return; event.preventDefault?.(); closeProgressiveDetail(); }; list?.addEventListener?.('click', openListener); closeDetail?.addEventListener?.('click', closeListener); lifecycleTarget.addEventListener?.('keydown', keyListener); const applySnapshot = snapshot => { if (stopped) return false; const transferable = snapshot && typeof snapshot === 'object' && Object.prototype.hasOwnProperty.call(snapshot, 'context'); const contextDegraded = transferable && ( !snapshot.context || snapshot.freshness?.sections?.context?.degraded ); if (contextDegraded) { contextUnavailable = true; render(); notify(); if (status) status.textContent = 'Assigned work is reconnecting…'; return false; } contextUnavailable = false; if (transferable) liveSnapshot = snapshot; const context = snapshot?.context || snapshot || {}; confirmedLogin = String(context.user?.login || '').trim(); items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] }); updateCounts(); render(); notify(); const assigned = items.filter(item => item.is_assigned).length; if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.'; return true; }; if (typeof createContextPoller === 'function') { poller = createContextPoller({ ...pollerOptions, fetchContext: fetchProgressiveSnapshot, onSnapshot: applySnapshot, onError: () => { if (status && !stopped) status.textContent = 'Assigned work is reconnecting…'; }, isHidden: pollerOptions.isHidden || (() => Boolean(document.hidden)), }); const recover = () => { if (!document.hidden) void poller.refresh(); }; ['online', 'visibilitychange'].forEach(eventName => { lifecycleTarget.addEventListener?.(eventName, recover); lifecycleListeners.push([eventName, recover]); }); } return { login() { return confirmedLogin; }, counts() { return queueCounts(); }, subscribe(subscriber) { subscribers.add(subscriber); return () => subscribers.delete(subscriber); }, openFirst() { const item = visibleItems()[0]; if (!item) return deferredQueues[active] ? 'loading' : 'empty'; const trigger = list?.querySelector?.('[data-progressive-work-index="0"]') || null; openProgressiveDetail(item, trigger); return 'opened'; }, selectQueue(name, {openFirst = false} = {}) { const allowed = new Set(['all','attention','filed','authored','issue','pull','review','update','today','agenda','later','draft']); if (!allowed.has(name)) return 'unsupported'; active = name; selectedByUser = true; render(); notify(); return openFirst ? this.openFirst() : 'selected'; }, handoff() { const state = { selectedFilter:selectedByUser ? active : null }; if (openWork) { state.openWork = { kind:openWork.kind, key:openWork.key, number:openWork.number, repository:openWork.repository, }; openWork = null; } if (liveSnapshot) { state.liveSnapshot = liveSnapshot; liveSnapshot = null; liveSnapshotPromise = null; } else if (liveSnapshotPromise) { state.liveSnapshotPromise = liveSnapshotPromise; liveSnapshotPromise = null; } return state; }, async start() { if (status) status.textContent = 'Loading assigned work…'; if (poller) { const request = poller.start(); liveSnapshotPromise = request.then(snapshot => ( snapshot && typeof snapshot === 'object' && Object.prototype.hasOwnProperty.call(snapshot, 'context') ? snapshot : null )); return Boolean(await request); } const request = Promise.resolve().then(() => fetchProgressiveSnapshot()); liveSnapshotPromise = request.then(snapshot => ( snapshot && typeof snapshot === 'object' && Object.prototype.hasOwnProperty.call(snapshot, 'context') ? snapshot : null ), () => null); try { const snapshot = await request; if (stopped) return false; const applied = applySnapshot(snapshot); if (!liveSnapshot) liveSnapshotPromise = null; return applied; } catch (_error) { if (status && !stopped) status.textContent = 'Assigned work is reconnecting…'; return false; } }, stop() { stopped = true; poller?.stop(); listeners.forEach(([button, listener]) => button.removeEventListener?.('click', listener)); list?.removeEventListener?.('click', openListener); closeDetail?.removeEventListener?.('click', closeListener); lifecycleTarget.removeEventListener?.('keydown', keyListener); lifecycleListeners.forEach(([eventName, listener]) => lifecycleTarget.removeEventListener?.(eventName, listener)); subscribers.clear(); }, }; } if (typeof window !== 'undefined' && typeof document !== 'undefined') { window.stackchainProgressiveMyWork = createProgressiveMyWork({ document, lifecycleTarget: window, liveSnapshot:window.stackchainProgressiveLiveSnapshot, fetchSnapshot: async (revisions = {}, { signal } = {}) => { const query = createContextPoller.buildRevisionQuery(revisions); const response = await fetch('api/v1/live' + (query ? '?' + query : ''), { headers:{Accept:'application/json'}, signal, }); if (!response.ok) { const error = new Error('HTTP ' + response.status); error.retryAfterMs = createContextPoller.retryAfterMs(response.headers.get('Retry-After')); throw error; } return response.json(); }, }); void window.stackchainProgressiveMyWork.start(); } if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveMyWork;