function issueDueState(dueDate, now) { if (!dueDate) return null; const due = new Date(dueDate); if (Number.isNaN(due.getTime())) return null; const day = value => value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' + String(value.getDate()).padStart(2, '0'); const dueDay = day(due); const today = day(now); if (dueDay < today) return { label: 'Overdue', priority: 2 }; if (dueDay === today) return { label: 'Due today', priority: 2.5 }; return { label: 'Due ' + due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), priority: 4, }; } function urgencyReason(item) { if (!item?.is_assigned || item.kind !== 'issue') return ''; const priorityLabels = ['p0', 'priority-high', 'critical']; const priorityLabel = (item.labels || []).find(label => priorityLabels.includes(String(label).toLowerCase()) ); if (priorityLabel) return priorityLabel + ' priority'; return ['Overdue', 'Due today'].includes(item.due_label) ? item.due_label : ''; } function attentionReason(item) { if (item?.has_update) return 'Unread update'; return urgencyReason(item) || (item?.is_review ? 'Needs your review' : ''); } function needsAttention(item) { return Boolean(item && (item.has_update || item.is_review || urgencyReason(item))); } function buildMyWork(data, now = new Date()) { const login = data.user?.login || ''; const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' })); const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' })); const priorityLabels = ['p0', 'priority-high', 'critical']; const work = issues.concat(pulls).map((item) => { const labels = item.labels || []; const priorityLabel = labels.find((label) => priorityLabels.includes(String(label).toLowerCase()) ); const assigned = (item.assignees || []).includes(login); const isReview = (item.work_reasons || []).includes('review_requested'); const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null; const normalized = { ...item, key: (item.repository || 'unknown') + '#' + item.number, is_review: isReview, is_assigned: assigned, has_update: false, ...(due ? { due_label: due.label } : {}), reason: priorityLabel ? priorityLabel + ' priority' : (due && due.priority < 4 ? due.label : (isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work'))), _priority: priorityLabel ? 0 : (due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))), }; normalized.needs_attention = needsAttention(normalized); normalized.attention_reason = attentionReason(normalized); return normalized; }); const byKey = new Map(work.map((item) => [item.kind + ':' + item.key, item])); (data.notifications || []).filter((item) => item && item.unread).forEach((update) => { const key = (update.repository || 'unknown') + '#' + update.number; const subjectKind = String(update.subject_type || '').toLowerCase().includes('pull') ? 'pull' : 'issue'; const existing = byKey.get(subjectKind + ':' + key); if (existing) { existing.has_update = true; existing.needs_attention = true; existing.attention_reason = 'Unread update'; existing.notification_id = update.id; existing.url = update.url || existing.url; existing.updated_at = update.updated_at || existing.updated_at; existing._priority = Math.min(existing._priority, 1); return; } if (!update.url) return; const item = { ...update, key, kind: 'update', is_review: false, is_assigned: false, has_update: true, needs_attention: true, attention_reason: 'Unread update', notification_id: update.id, reason: 'Unread update', _priority: 1, }; work.push(item); byKey.set(subjectKind + ':' + key, item); }); return work.sort((left, right) => left._priority - right._priority || String(right.updated_at || '').localeCompare(String(left.updated_at || '')) || left.key.localeCompare(right.key) ).map(({ _priority, ...item }) => item); } function acknowledgeNotification(items, notificationId) { return items.flatMap((item) => { if (item.notification_id !== notificationId) return [item]; if (item.kind === 'update') return []; const { notification_id, ...acknowledged } = item; const next = { ...acknowledged, has_update: false }; if ('needs_attention' in item) next.needs_attention = needsAttention(next); if ('attention_reason' in item) next.attention_reason = attentionReason(next); return [next]; }); } function createNotificationAcknowledger({ markRead, onItems, onStatus }) { const pending = new Set(); return { async acknowledge(items, notificationId) { if (pending.has(notificationId)) return false; pending.add(notificationId); onItems(acknowledgeNotification(items, notificationId)); onStatus('Marking update read…'); try { await markRead(notificationId); onStatus('Update marked read.'); return true; } catch (_error) { onItems(items); onStatus('Could not mark update read. Retry.'); return false; } finally { pending.delete(notificationId); } }, }; } function notificationIds(items) { return Array.from(new Set((items || []) .filter((item) => item && item.has_update && Number.isInteger(item.notification_id)) .map((item) => item.notification_id))); } function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) { let pending = false; return { async acknowledge(items, requestedIds) { if (pending) return false; const ids = Array.from(new Set((requestedIds || []).filter(Number.isInteger))); if (!ids.length) return false; pending = true; onStatus('Marking ' + ids.length + ' updates read…'); try { const result = await markRead(ids); const marked = (result.marked || []).filter(Number.isInteger); const failed = (result.failed || []).filter(Number.isInteger); const updated = marked.reduce( (current, notificationId) => acknowledgeNotification(current, notificationId), items ); onItems(updated); onStatus(failed.length ? marked.length + ' marked read · ' + failed.length + ' could not be updated — retry.' : marked.length + ' updates marked read.'); return { marked, failed }; } catch (_error) { onStatus('Could not mark updates read. Retry.'); return false; } finally { pending = false; } }, }; } function createNotificationPager({ load, onNotifications, onPagination, onStatus }) { let pagination = { page: 1, total: 0, has_more: false }; let pending = false; return { reset(next) { pagination = { ...pagination, ...(next || {}) }; onPagination(pagination); }, async loadMore(existing) { if (pending || !pagination.has_more) return false; pending = true; onStatus('Loading older updates…'); try { const result = await load(pagination.page + 1); const byId = new Map((existing || []) .filter(item => item && Number.isInteger(item.id)) .map(item => [item.id, item])); (result.items || []).forEach(item => { if (item && Number.isInteger(item.id) && !byId.has(item.id)) byId.set(item.id, item); }); pagination = { page: result.page, total: result.total, has_more: result.has_more === true, }; const loaded = Math.min(pagination.total, pagination.page * 50); onNotifications(Array.from(byId.values())); onPagination(pagination); onStatus(loaded + ' of ' + pagination.total + ' unread updates loaded.'); return true; } catch (_error) { onStatus('Could not load older updates. Retry.'); return false; } finally { pending = false; } }, }; } function createWorkPager({ load, onItems, onPagination, onStatus }) { let pagination = {}; const pending = new Set(); const labels = { issue: 'issues', pull: 'pull requests', review: 'review requests', }; return { reset(next) { Object.entries(next || {}).forEach(([stream, value]) => { const current = pagination[stream]; pagination[stream] = current && current.page > 1 ? { ...value, page: current.page, has_more: current.page * 50 < value.total } : { ...value }; }); onPagination({ ...pagination }); }, async loadMore(stream, existing) { const page = pagination[stream]; if (pending.has(stream) || !page?.has_more) return false; pending.add(stream); const label = labels[stream] || 'work'; onStatus('Loading older ' + label + '…'); try { const result = await load(stream, page.page + 1); const merged = new Map((existing || []) .filter(item => item && Number.isInteger(item.id)) .map(item => [item.id, { ...item }])); (result.items || []).forEach(item => { if (!item || !Number.isInteger(item.id)) return; const current = merged.get(item.id); if (!current) { merged.set(item.id, { ...item }); return; } const reasons = Array.from(new Set( (current.work_reasons || []).concat(item.work_reasons || []) )); merged.set(item.id, { ...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}), }); }); pagination[stream] = { page: result.page, total: result.total, has_more: result.has_more === true, }; onItems(stream, Array.from(merged.values())); onPagination({ ...pagination }); onStatus(Math.min(result.total, result.page * 50) + ' of ' + result.total + ' ' + label + ' loaded.'); return true; } catch (_error) { onStatus('Could not load older ' + label + '. Retry.'); return false; } finally { pending.delete(stream); } }, }; } function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, onStatus, onClose, queueRead = null, loadSaved = () => null, loadConversation = null, onConversation = () => {}, createPager = typeof createConversationPager === 'function' ? createConversationPager : null, }) { let selected = null; let loadVersion = 0; let marking = false; let conversationPager = null; let offlineHydrated = false; async function open(item, savedDetail = null) { selected = item; offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail)); const version = ++loadVersion; onOpen(item); onStatus('Loading update…'); try { const detail = offlineHydrated ? savedDetail : await load(item.notification_id); if (selected !== item || version !== loadVersion) return false; onDetail(detail); if (createPager && loadConversation && detail.conversation) { conversationPager = createPager({ loadPage: page => loadConversation(item.notification_id, page), }); onConversation(conversationPager.reset(detail.conversation)); } else { conversationPager = null; } onStatus('Update ready.'); return true; } catch (_error) { if (selected === item && version === loadVersion) { onStatus('Could not load update. Retry or open it in Gitea.'); } return false; } } return { open, appendReply(comment) { if (!conversationPager) return false; onConversation(conversationPager.append(comment)); return true; }, async loadOlder() { if (!conversationPager || !selected || offlineHydrated) return false; const pager = conversationPager; const version = loadVersion; onStatus('Loading older messages…'); try { const state = await pager.loadOlder(); if (pager !== conversationPager || version !== loadVersion) return false; onConversation(state); onStatus(state.comments.length + ' of ' + state.total + ' messages loaded.'); return true; } catch (_error) { if (pager === conversationPager && version === loadVersion) { onStatus('Could not load older messages. Retry.'); } return false; } }, async markReadAndNext(items) { if (!selected || marking || (offlineHydrated && !queueRead)) return false; const current = selected; const queueing = offlineHydrated; marking = true; onStatus(queueing ? 'Queueing update read…' : 'Marking update read…'); try { if (queueing) await queueRead(current.notification_id); else await markRead(current.notification_id); const updated = acknowledgeNotification(items, current.notification_id); onItems(updated); const currentIndex = items.indexOf(current); const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex)); const next = remaining.find(item => item && item.has_update && Number.isInteger(item.notification_id) && (!queueing || loadSaved(item))); if (next) await open(next, queueing ? loadSaved(next) : null); else { selected = null; onClose(); onStatus('Inbox cleared.'); } return { items: updated, next: next || null }; } catch (_error) { onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.'); return false; } finally { marking = false; } }, }; } function createNotificationReplier({ post, storage, onStatus, authoredOutbox, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(), }) { let pending = false; const keyFor = item => 'stackchain.update-reply.v1.' + item.notification_id; const operationKeyFor = item => keyFor(item) + ':operation'; return { loadDraft(item) { try { return storage.getItem(keyFor(item)) || ''; } catch (_error) { return ''; } }, saveDraft(item, body) { try { if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item)); storage.setItem(keyFor(item), body); } catch (_error) { /* Keep the editable textarea as the fallback. */ } }, async submit(item, body) { if (pending) return false; pending = true; this.saveDraft(item, body); let operationId; try { operationId = storage.getItem(operationKeyFor(item)) || String(createOperationId()).slice(0, 128); storage.setItem(operationKeyFor(item), operationId); } catch (_error) { operationId = String(createOperationId()).slice(0, 128); } onStatus('Sending reply…'); try { const result = await post(item.notification_id, body, operationId); try { storage.removeItem(keyFor(item)); } catch (_error) { /* The posted reply is still authoritative. */ } try { storage.removeItem(operationKeyFor(item)); } catch (_error) { /* A confirmed result no longer needs replay identity. */ } onStatus('Reply posted. You can mark this update read when ready.'); return result; } catch (error) { const status = Number(error?.status || 0); if (authoredOutbox && (!status || status >= 500)) { onStatus('Saving for background delivery…'); const admission = await authoredOutbox.enqueueDurably({ kind: 'update-reply', notificationId: item.notification_id, body, operationId, }); if (admission.background) { onStatus('Queued for sync when the connection returns.'); return { queued: true }; } onStatus('Saved for next launch; background delivery unavailable.'); return { queued: false, degraded: true }; } onStatus('Could not send reply. Your draft is safe; retry.'); return false; } finally { pending = false; } }, }; } function filterMyWork(items, selectedFilter, selectedMilestone = 'all') { let filtered = items; if (selectedFilter === 'attention') filtered = items.filter(needsAttention); else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review); else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update); else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter); if (selectedMilestone === 'all') return filtered; if (selectedMilestone === 'unplanned') { return filtered.filter(item => item.kind === 'issue' && !item.milestone); } return filtered.filter(item => item.kind === 'issue' && String(item.milestone?.id || '') === String(selectedMilestone) ); } function milestoneLanes(items) { const lanes = new Map(); (items || []).forEach(item => { if (item?.kind === 'issue' && Number.isInteger(item.milestone?.id) && typeof item.milestone?.title === 'string') { lanes.set(item.milestone.id, { id: item.milestone.id, title: item.milestone.title }); } }); return Array.from(lanes.values()).sort((left, right) => left.title.localeCompare(right.title)); } function workIdentity(item) { if (!item) return ''; const kind = item.is_review ? 'review' : (item.kind || 'work'); const repository = item.repository || ''; const number = Number.isInteger(item.number) ? item.number : ''; const notification = Number.isInteger(item.notification_id) ? item.notification_id : ''; return [kind, repository, number, notification].join(':'); } function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', onOpen, onProgress, onFinish }) { let currentIdentity = ''; let currentIndex = -1; let running = false; const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone()); const report = (items, index) => onProgress({ index: index + 1, total: items.length, can_previous: index > 0, can_next: index < items.length - 1, }); const finish = () => { running = false; currentIdentity = ''; currentIndex = -1; onFinish(); return false; }; const openAt = (items, index) => { if (!items.length || index < 0 || index >= items.length) return finish(); currentIndex = index; currentIdentity = workIdentity(items[index]); report(items, index); onOpen(items[index]); return true; }; return { active: () => running, start(item = null) { const items = queue(); if (!items.length) return finish(); running = true; const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0; return openAt(items, requested >= 0 ? requested : 0); }, reconcile() { if (!running) return false; const items = queue(); const index = items.findIndex(item => workIdentity(item) === currentIdentity); if (index < 0) return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish(); currentIndex = index; report(items, index); return true; }, previous() { if (!running) return false; const items = queue(); const index = items.findIndex(item => workIdentity(item) === currentIdentity); return index > 0 ? openAt(items, index - 1) : false; }, next() { if (!running) return false; const items = queue(); const index = items.findIndex(item => workIdentity(item) === currentIdentity); return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish(); }, complete() { if (!running) return false; const items = queue(); const stillPresent = items.findIndex(item => workIdentity(item) === currentIdentity); if (stillPresent >= 0) { return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish(); } return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish(); }, }; } function replaceIssueLabels(data, repository, number, labels) { return { ...data, issues: (data.issues || []).map(item => item.repository === repository && item.number === number ? { ...item, labels: [...labels] } : item ), }; } function replaceIssueContent(data, repository, number, content) { return { ...data, issues: (data.issues || []).map(item => item.repository === repository && item.number === number ? { ...item, ...content } : item ), }; } function replaceIssueDueDate(data, repository, number, dueDate) { return { ...data, issues: (data.issues || []).map(item => item.repository === repository && item.number === number ? { ...item, due_date: dueDate } : item ), }; } function replaceIssueMilestone(data, repository, number, milestone) { return { ...data, issues: (data.issues || []).map(item => item.repository === repository && item.number === number ? { ...item, milestone } : item ), }; } function removeIssue(data, repository, number) { return { ...data, issues: (data.issues || []).filter(item => item.repository !== repository || item.number !== number ), }; } function summarizeMyWork(items) { const updates = items.filter((item) => item.has_update).length; const reviews = items.filter((item) => item.is_review).length; const assigned = items.filter((item) => item.is_assigned).length; const updateLabel = updates + ' unread update' + (updates === 1 ? '' : 's'); const reviewLabel = reviews + ' review' + (reviews === 1 ? '' : 's'); const assignedLabel = assigned + ' assigned'; return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel; } function countMyWork(items) { return { all: items.length, attention: items.filter(needsAttention).length, issue: items.filter((item) => item.kind === 'issue').length, pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length, review: items.filter((item) => item.is_review).length, update: items.filter((item) => item.has_update).length, }; } if (typeof module !== 'undefined' && module.exports) { buildMyWork.filterMyWork = filterMyWork; buildMyWork.milestoneLanes = milestoneLanes; buildMyWork.createWorkSession = createWorkSession; buildMyWork.workIdentity = workIdentity; buildMyWork.replaceIssueLabels = replaceIssueLabels; buildMyWork.replaceIssueContent = replaceIssueContent; buildMyWork.replaceIssueDueDate = replaceIssueDueDate; buildMyWork.replaceIssueMilestone = replaceIssueMilestone; buildMyWork.removeIssue = removeIssue; buildMyWork.summarizeMyWork = summarizeMyWork; buildMyWork.countMyWork = countMyWork; buildMyWork.acknowledgeNotification = acknowledgeNotification; buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger; buildMyWork.notificationIds = notificationIds; buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger; buildMyWork.createNotificationPager = createNotificationPager; buildMyWork.createWorkPager = createWorkPager; buildMyWork.createNotificationReader = createNotificationReader; buildMyWork.createNotificationReplier = createNotificationReplier; module.exports = buildMyWork; }