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;