750 lines
27 KiB
JavaScript
750 lines
27 KiB
JavaScript
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 createWorkSessionCheckpoint({
|
|
storage,
|
|
getLogin,
|
|
onError = () => {},
|
|
key = 'stackchain.today-session.v1',
|
|
}) {
|
|
const login = () => String(getLogin() || '').trim();
|
|
let errorReported = false;
|
|
const reportError = error => {
|
|
if (errorReported) return;
|
|
errorReported = true;
|
|
onError(error);
|
|
};
|
|
const read = () => {
|
|
const owner = login();
|
|
if (!owner) return null;
|
|
try {
|
|
const value = JSON.parse(storage.getItem(key) || 'null');
|
|
if (value?.version !== 1 || value.login !== owner || typeof value.identity !== 'string' ||
|
|
!Number.isInteger(value.index) || value.index < 0) return null;
|
|
return value;
|
|
} catch (error) {
|
|
reportError(error);
|
|
return null;
|
|
}
|
|
};
|
|
return {
|
|
read,
|
|
save(identity, index) {
|
|
const owner = login();
|
|
if (!owner) return false;
|
|
try {
|
|
storage.setItem(key, JSON.stringify({ version:1, login:owner, identity, index }));
|
|
return true;
|
|
} catch (error) {
|
|
reportError(error);
|
|
return false;
|
|
}
|
|
},
|
|
clear() {
|
|
if (!read()) return false;
|
|
try {
|
|
storage.removeItem(key);
|
|
return true;
|
|
} catch (error) {
|
|
reportError(error);
|
|
return false;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function createWorkSession({
|
|
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
|
|
checkpointEnabled = () => true,
|
|
onOpen, onProgress, onFinish,
|
|
}) {
|
|
let currentIdentity = '';
|
|
let currentIndex = -1;
|
|
let running = false;
|
|
let durable = 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 = () => {
|
|
const clearCheckpoint = durable;
|
|
running = false;
|
|
durable = false;
|
|
currentIdentity = '';
|
|
currentIndex = -1;
|
|
if (clearCheckpoint) checkpoint?.clear();
|
|
onFinish();
|
|
return false;
|
|
};
|
|
const openAt = (items, index) => {
|
|
if (!items.length || index < 0 || index >= items.length) return finish();
|
|
currentIndex = index;
|
|
currentIdentity = workIdentity(items[index]);
|
|
if (durable) checkpoint?.save(currentIdentity, currentIndex);
|
|
report(items, index);
|
|
onOpen(items[index]);
|
|
return true;
|
|
};
|
|
|
|
return {
|
|
active: () => running,
|
|
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
|
|
end: () => finish(),
|
|
resumable: () => Boolean(checkpoint?.read()),
|
|
reopen(requested = null) {
|
|
if (!running) return false;
|
|
const items = queue();
|
|
const requestedIdentity = requested ? workIdentity(requested) : currentIdentity;
|
|
const index = items.findIndex(item => workIdentity(item) === requestedIdentity);
|
|
if (index < 0) return false;
|
|
currentIdentity = requestedIdentity;
|
|
currentIndex = index;
|
|
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
|
|
report(items, index);
|
|
onOpen(items[index]);
|
|
return true;
|
|
},
|
|
resume(requested = null) {
|
|
const saved = checkpoint?.read();
|
|
if (!saved) return false;
|
|
durable = true;
|
|
const items = queue();
|
|
if (!items.length) return finish();
|
|
running = true;
|
|
const exact = items.findIndex(item => workIdentity(item) ===
|
|
(requested ? workIdentity(requested) : saved.identity));
|
|
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
|
|
},
|
|
start(item = null) {
|
|
const items = queue();
|
|
if (!items.length) return finish();
|
|
running = true;
|
|
durable = Boolean(checkpoint && checkpointEnabled());
|
|
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(requested = null) {
|
|
if (!running) return false;
|
|
const items = queue();
|
|
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
|
if (requested) {
|
|
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
|
|
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
|
|
}
|
|
return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish();
|
|
},
|
|
complete(requested = null) {
|
|
if (!running) return false;
|
|
const items = queue();
|
|
if (requested) {
|
|
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
|
|
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
|
|
}
|
|
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();
|
|
},
|
|
items: () => queue().slice(),
|
|
target(action) {
|
|
const items = queue();
|
|
if (!items.length) return null;
|
|
if (action === 'start') return items[0];
|
|
if (action === 'resume') {
|
|
const saved = checkpoint?.read();
|
|
if (!saved) return null;
|
|
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
|
|
return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];
|
|
}
|
|
const current = items.findIndex(item => workIdentity(item) === currentIdentity);
|
|
if (action === 'continue') return current >= 0 ? items[current] : null;
|
|
if (action === 'next') return current >= 0 && current < items.length - 1 ? items[current + 1] : null;
|
|
if (action === 'complete') {
|
|
return items[current >= 0 ? Math.min(current + 1, items.length - 1) : Math.min(currentIndex, items.length - 1)];
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
}
|
|
|
|
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.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
|
|
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;
|
|
}
|