5833 lines
276 KiB
JavaScript
5833 lines
276 KiB
JavaScript
(async function(){
|
||
const qs = (s, el=document) => el.querySelector(s);
|
||
const fmt = (d) => new Date(d).toLocaleString();
|
||
const cardPlanning = createCardPlanning(document);
|
||
const mobileComposerViewport = createMobileComposerViewport({
|
||
viewport: window.visualViewport,
|
||
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
||
entries: [
|
||
{ panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.issue-comment-composer'), composer:qs('#issue-comment'), submit:qs('#send-issue-comment'), status:qs('#issue-comment-status') },
|
||
{ panel:qs('#pull-sheet .pull-sheet-panel'), workspace:qs('.pull-comment-composer'), composer:qs('#pull-comment'), submit:qs('#send-pull-comment'), status:qs('#pull-comment-status') },
|
||
{ panel:qs('#update-sheet .update-sheet-panel'), workspace:qs('.update-reply'), composer:qs('#update-reply'), submit:qs('#send-update-reply'), status:qs('#update-reply-status') },
|
||
{ panel:qs('#review-sheet .review-sheet-panel'), workspace:qs('#review-inline-composer'), composer:qs('#review-inline-body'), submit:qs('#save-inline-comment'), status:qs('#review-submit-status') },
|
||
{ panel:qs('.create-issue-panel'), workspace:qs('#create-issue-form'), focusWithin:true },
|
||
],
|
||
});
|
||
mobileComposerViewport.start();
|
||
[
|
||
[qs('.app-menu'), qs('#app-menu-toggle')],
|
||
[qs('.work-settings'), qs('#work-settings-toggle')],
|
||
].forEach(([disclosure, launcher]) => {
|
||
mobileLaunch.createDisclosure({disclosure, launcher}).start();
|
||
});
|
||
const PANEL_STATE_KEY = "stackchain.panel-state.v1";
|
||
const panels = Array.from(document.querySelectorAll('details[data-panel-key]'));
|
||
let savedState = {};
|
||
try {
|
||
savedState = JSON.parse(localStorage.getItem(PANEL_STATE_KEY) || '{}');
|
||
} catch (e) {
|
||
savedState = {};
|
||
}
|
||
panels.forEach((panel) => {
|
||
if (Object.prototype.hasOwnProperty.call(savedState, panel.dataset.panelKey)) {
|
||
panel.open = savedState[panel.dataset.panelKey];
|
||
}
|
||
panel.addEventListener('toggle', () => {
|
||
const state = Object.fromEntries(panels.map((item) => [item.dataset.panelKey, item.open]));
|
||
try {
|
||
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
|
||
} catch (e) {
|
||
console.warn('Panel state save failed', e);
|
||
}
|
||
});
|
||
});
|
||
const mobileTaskButtons = Object.fromEntries(
|
||
Array.from(document.querySelectorAll('[data-mobile-task]')).map(button => [button.dataset.mobileTask, button])
|
||
);
|
||
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
|
||
const attentionInterruption = qs('#attention-interruption');
|
||
function renderAttentionInterruption() {
|
||
const pending = timer.attentionInterruption();
|
||
attentionInterruption.hidden = !pending;
|
||
return pending;
|
||
}
|
||
function openMobileWorkFallback() {
|
||
qs('[data-work-filter="all"]').click();
|
||
qs('#my-work').scrollIntoView({block:'start'});
|
||
qs('#my-work').focus();
|
||
}
|
||
function selectMobileQueue(name) {
|
||
if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();
|
||
renderAttentionInterruption();
|
||
timerView.render();
|
||
qs('[data-work-filter="' + name + '"]').click();
|
||
qs('#my-work').scrollIntoView({block:'start'});
|
||
qs('#my-work').focus();
|
||
}
|
||
const mobileWorkEntry = createMobileWorkEntry({
|
||
isTodayActive: () => workSession.checkpointed(),
|
||
isTodayResumable: () => workSession.resumable(),
|
||
getTodayCount: () => todayMyWork.length,
|
||
getEligibleCount: () => activeMyWork.length,
|
||
continueToday: continueTodaySession,
|
||
resumeToday: resumeTodaySession,
|
||
startToday: startTodaySession,
|
||
planToday: () => openPlanToday(mobileTaskButtons.work),
|
||
openFallback: openMobileWorkFallback,
|
||
});
|
||
const mobileQueueLauncher = createMobileQueueLauncher({
|
||
openToday: () => mobileWorkEntry.open(),
|
||
selectFilter: selectMobileQueue,
|
||
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||
});
|
||
const mobileTaskDock = createMobileTaskDock({
|
||
nav: qs('#mobile-task-dock'),
|
||
sessionHud: qs('[data-mobile-today-hud]'),
|
||
buttons: mobileTaskButtons,
|
||
workLabel: qs('#mobile-work-label'),
|
||
queueSheet: qs('#mobile-queue-sheet'),
|
||
queueClose: qs('#close-mobile-queues'),
|
||
queueRows: Object.fromEntries(
|
||
Array.from(document.querySelectorAll('[data-mobile-queue]')).map(button => [button.dataset.mobileQueue, button])
|
||
),
|
||
queueCounts: Object.fromEntries(
|
||
Array.from(document.querySelectorAll('[data-mobile-queue-count]')).map(element => [element.dataset.mobileQueueCount, element])
|
||
),
|
||
queueBadge: qs('#mobile-queue-count'),
|
||
onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name),
|
||
overlays: mobileTaskOverlays,
|
||
actions: {
|
||
work: () => mobileWorkEntry.open(),
|
||
find: () => qs('#find-work').click(),
|
||
new: () => qs('#new-issue').click(),
|
||
search: () => qs('#open-palette').click(),
|
||
queues: () => {},
|
||
},
|
||
observe(callback, overlays) {
|
||
const observer = new MutationObserver(callback);
|
||
overlays.forEach(overlay => observer.observe(overlay, {attributes:true, attributeFilter:['class']}));
|
||
return observer;
|
||
},
|
||
});
|
||
mobileTaskDock.start();
|
||
let liveMode = true;
|
||
let offlineWorkMode = false;
|
||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
||
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
|
||
let selectedWorkFilter = 'all';
|
||
let selectedWorkMilestone = 'all';
|
||
let queueFindQuery = '';
|
||
let savedWorkFilter = null;
|
||
let launchFilterResolved = false;
|
||
try {
|
||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||
if (['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
|
||
selectedWorkFilter = savedFilter;
|
||
savedWorkFilter = savedFilter;
|
||
launchFilterResolved = true;
|
||
}
|
||
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
|
||
if (savedMilestone) selectedWorkMilestone = savedMilestone;
|
||
} catch (e) {
|
||
console.warn('Filter restore failed', e);
|
||
}
|
||
let lastMyWork = [];
|
||
let lastDrafts = [];
|
||
let lastNotifications = [];
|
||
let lastContextSnapshot = null;
|
||
let notificationPagination = { page: 1, total: 0, has_more: false };
|
||
let workPagination = {};
|
||
let hasContextSnapshot = false;
|
||
let selectedReview = null;
|
||
let reviewTrigger = null;
|
||
let offlineReview = false;
|
||
let selectedUpdate = null;
|
||
let selectedUpdateDetail = null;
|
||
let followUpSourceUpdate = null;
|
||
let updateTrigger = null;
|
||
let selectedIssue = null;
|
||
let selectedIssueOffline = false;
|
||
let selectedIssueDetail = null;
|
||
let issueBlockerCandidates = [];
|
||
let issueBlockerSearchTimer = null;
|
||
let issueConversation = null;
|
||
let issueTrigger = null;
|
||
let selectedPull = null;
|
||
let pullTrigger = null;
|
||
let selectedPullDetail = null;
|
||
let pullConversation = null;
|
||
let pullReviewState = null;
|
||
let creatingIssue = false;
|
||
let createAndStartRequested = false;
|
||
let findingWork = false;
|
||
let availablePagination = { page: 1, total: 0, has_more: false };
|
||
let progress = null;
|
||
let draft = null;
|
||
let reviewFiles = [];
|
||
let selectedReviewHead = '';
|
||
let activeInlineTarget = null;
|
||
let bulkConfirmationPending = false;
|
||
let bulkMarkPending = false;
|
||
let reviewHandoffPending = false;
|
||
let editingOutboxId = null;
|
||
let confirmedOwnerLogin = '';
|
||
let planningOwnerLogin = '';
|
||
let activeFlushLogin = '';
|
||
let activeMyWork = [];
|
||
let laterMyWork = [];
|
||
let todayMyWork = [];
|
||
let rolloverReviewPlan = null;
|
||
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
|
||
const todayRollover = createTodayRollover();
|
||
const todayWork = createTodayWork({
|
||
storage: localStorage,
|
||
getLogin: () => planningOwnerLogin,
|
||
});
|
||
const todaySync = createTodaySync({
|
||
storage: localStorage,
|
||
getLogin: () => planningOwnerLogin,
|
||
fetchJson: fetchReviewJson,
|
||
coordinator: outboxCoordinator,
|
||
onRemoteIds: ids => {
|
||
if (!planningOwnerLogin || !todayWork.replace(ids)) return;
|
||
refreshMyWorkView();
|
||
warmTodayOffline();
|
||
},
|
||
onRemotePlan: plan => {
|
||
if (!planningOwnerLogin) return;
|
||
todayWork.replacePlanning({
|
||
capacity_minutes: plan.capacity_minutes ?? null,
|
||
estimates: plan.estimates || {},
|
||
});
|
||
const reviewState = todayRollover.reviewState(plan);
|
||
if (!['stale', 'legacy'].includes(reviewState)) {
|
||
rolloverReviewPlan = null;
|
||
qs('#plan-today').textContent = 'Plan Today';
|
||
return;
|
||
}
|
||
rolloverReviewPlan = plan;
|
||
qs('#plan-today').textContent = 'Review new day';
|
||
qs('#my-work-action-status').textContent = 'Review yesterday’s unfinished work before starting today.';
|
||
const marker = `stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`;
|
||
if (!localStorage.getItem(marker)) {
|
||
localStorage.setItem(marker, '1');
|
||
setTimeout(() => openPlanToday(qs('#plan-today')), 0);
|
||
}
|
||
},
|
||
onStatus: (state, detail = {}) => {
|
||
const status = qs('#today-sync-status');
|
||
status.textContent = state === 'saved' ? 'Today saved to account.' :
|
||
(state === 'retrying' ? `Today saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
|
||
(state === 'pending' ? 'Today saved on this device · sync pending.' :
|
||
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
|
||
(state === 'expired' ? `Today edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` :
|
||
'Today sync unavailable · changes stay on this device.'))));
|
||
},
|
||
});
|
||
todaySync.startLifecycle({ window, document });
|
||
const laterWork = createLaterWork({
|
||
storage: localStorage,
|
||
getLogin: () => planningOwnerLogin,
|
||
onChange: (action, itemId, wakeAt) => {
|
||
if (laterSync.enqueue(action, itemId, wakeAt)) laterSync.flush();
|
||
},
|
||
onExpire: ids => {
|
||
const queued = ids.map(id => laterSync.enqueue('restore', id)).every(Boolean);
|
||
if (queued) laterSync.flush();
|
||
},
|
||
onWake: () => {
|
||
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
|
||
refreshMyWorkView();
|
||
},
|
||
});
|
||
const laterSync = createLaterSync({
|
||
storage: localStorage,
|
||
getLogin: () => planningOwnerLogin,
|
||
fetchJson: fetchReviewJson,
|
||
coordinator: outboxCoordinator,
|
||
onRemoteRecords: records => {
|
||
if (!planningOwnerLogin || !laterWork.adopt(records)) return;
|
||
refreshMyWorkView();
|
||
},
|
||
onStatus: (state, detail = {}) => {
|
||
qs('#later-sync-status').textContent = state === 'saved' ? 'Later saved to account.' :
|
||
(state === 'conflict' ? `Another device changed this Later item${detail.count > 1 ? 's' : ''} · account plan kept.` :
|
||
(state === 'retrying' ? `Later saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
|
||
(state === 'pending' ? 'Later saved on this device · sync pending.' :
|
||
(state === 'expired' ? `Later edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` :
|
||
'Later sync unavailable · changes stay on this device.'))));
|
||
},
|
||
});
|
||
laterSync.startLifecycle({ window, document });
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.hidden) interruptionPrompt.background();
|
||
else interruptionPrompt.foreground();
|
||
if (!document.hidden) refreshMyWorkView();
|
||
});
|
||
|
||
async function fetchReviewJson(url, options) {
|
||
const response = await fetch(url, options);
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
|
||
error.status = response.status;
|
||
error.code = payload.detail?.code;
|
||
const retryAfter = response.headers.get('Retry-After');
|
||
error.retryAfter = retryAfter === null ? undefined : Number(retryAfter);
|
||
throw error;
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
let commentActions = { isOwned: () => false, wire: () => {} };
|
||
|
||
function loadMentionCandidates(repository, query) {
|
||
return fetchReviewJson(
|
||
'api/v1/repos/' + repository + '/mention-candidates?q=' + encodeURIComponent(query),
|
||
{headers:{Accept:'application/json'}},
|
||
);
|
||
}
|
||
const issueMentions = createMentionComposer({
|
||
textarea:qs('#issue-comment'), listbox:qs('#issue-comment-mentions'),
|
||
status:qs('#issue-comment-mention-status'), getRepository:()=>selectedIssue?.repository,
|
||
loadCandidates:loadMentionCandidates,
|
||
});
|
||
const pullMentions = createMentionComposer({
|
||
textarea:qs('#pull-comment'), listbox:qs('#pull-comment-mentions'),
|
||
status:qs('#pull-comment-mention-status'), getRepository:()=>selectedPull?.repository,
|
||
loadCandidates:loadMentionCandidates,
|
||
});
|
||
const updateMentions = createMentionComposer({
|
||
textarea:qs('#update-reply'), listbox:qs('#update-reply-mentions'),
|
||
status:qs('#update-reply-mention-status'), getRepository:()=>selectedUpdate?.repository,
|
||
loadCandidates:loadMentionCandidates,
|
||
});
|
||
[issueMentions, pullMentions, updateMentions].forEach(controller => controller.start());
|
||
|
||
async function api(url) {
|
||
const response = await fetch(url, { headers: { Accept: 'application/json' } });
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
const error = new Error(payload.error || payload.detail || 'Shared work request failed.');
|
||
error.unavailable = response.status === 404;
|
||
throw error;
|
||
}
|
||
return payload;
|
||
}
|
||
let reviewController = null;
|
||
let wrapPreference = null;
|
||
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||
const issueAttachmentController = issueAttachment.mount({
|
||
input: qs('#issue-attachment'),
|
||
preview: qs('#issue-attachment-preview'),
|
||
image: qs('#issue-attachment-image'),
|
||
meta: qs('#issue-attachment-meta'),
|
||
remove: qs('#remove-issue-attachment'),
|
||
status: qs('#issue-comment-status'),
|
||
createObjectURL: file => URL.createObjectURL(file),
|
||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||
readDataUrl: file => new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result);
|
||
reader.onerror = () => reject(new Error('The screenshot could not be read. Choose it again.'));
|
||
reader.readAsDataURL(file);
|
||
}),
|
||
upload: payload => {
|
||
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
|
||
return fetchReviewJson(
|
||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(payload.number) + '/attachments',
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'Idempotency-Key': payload.operation_id,
|
||
},
|
||
body: issueAttachment.multipart(payload),
|
||
},
|
||
);
|
||
},
|
||
});
|
||
const pullAttachmentController = issueAttachment.mount({
|
||
input: qs('#pull-attachment'),
|
||
preview: qs('#pull-attachment-preview'),
|
||
image: qs('#pull-attachment-image'),
|
||
meta: qs('#pull-attachment-meta'),
|
||
remove: qs('#remove-pull-attachment'),
|
||
status: qs('#pull-comment-status'),
|
||
createObjectURL: file => URL.createObjectURL(file),
|
||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||
upload: payload => {
|
||
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
|
||
return fetchReviewJson(
|
||
'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(payload.number) + '/attachments',
|
||
{
|
||
method: 'POST',
|
||
headers: { Accept:'application/json', 'Idempotency-Key':payload.operation_id },
|
||
body: issueAttachment.multipart(payload),
|
||
},
|
||
);
|
||
},
|
||
});
|
||
const updateReplyAttachmentController = issueAttachment.mount({
|
||
input: qs('#update-reply-attachment'),
|
||
preview: qs('#update-reply-attachment-preview'),
|
||
image: qs('#update-reply-attachment-image'),
|
||
meta: qs('#update-reply-attachment-meta'),
|
||
remove: qs('#remove-update-reply-attachment'),
|
||
status: qs('#update-reply-status'),
|
||
readyMessage: 'Screenshot ready to send with this reply.',
|
||
removedMessage: 'Screenshot removed. Your reply is unchanged.',
|
||
createObjectURL: file => URL.createObjectURL(file),
|
||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||
upload: payload => fetchReviewJson(
|
||
'api/v1/notifications/' + encodeURIComponent(payload.notificationId) + '/attachments',
|
||
{ method:'POST', headers:{ Accept:'application/json', 'Idempotency-Key':payload.operation_id },
|
||
body:issueAttachment.multipart(payload) },
|
||
),
|
||
});
|
||
const createIssueAttachmentController = issueAttachment.mount({
|
||
input: qs('#create-issue-attachment'),
|
||
preview: qs('#create-issue-attachment-preview'),
|
||
image: qs('#create-issue-attachment-image'),
|
||
meta: qs('#create-issue-attachment-meta'),
|
||
remove: qs('#remove-create-issue-attachment'),
|
||
status: qs('#create-issue-attachment-status'),
|
||
readyMessage: 'Screenshot ready to file with this issue.',
|
||
removedMessage: 'Screenshot removed. Your issue draft is unchanged.',
|
||
createObjectURL: file => URL.createObjectURL(file),
|
||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||
readDataUrl: file => new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result);
|
||
reader.onerror = () => reject(new Error('The screenshot could not be read. Choose it again.'));
|
||
reader.readAsDataURL(file);
|
||
}),
|
||
upload: async () => { throw new Error('Create the issue before uploading its screenshot.'); },
|
||
});
|
||
const planningLoader = createIssueSheet.createPlanningLoader({
|
||
loadLabels: item => issueController.loadLabels(item),
|
||
loadMilestones: item => issueController.loadMilestones(item),
|
||
});
|
||
let issueCapture = null;
|
||
let updateFollowUp = null;
|
||
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
|
||
const unfiledCaptures = createUnfiledCaptures({
|
||
storage: localStorage,
|
||
attachmentStore: unfiledAttachmentStore,
|
||
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
|
||
getCurrentLogin: () => activeFlushLogin,
|
||
});
|
||
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
|
||
dFS.attach(qs, {
|
||
captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController,
|
||
getLogin:()=>activeFlushLogin, setResumedId:id=>{ rUC = id; },
|
||
openSheet:openCreateIssueSheet, setFilingMode:setIssueFilingMode,
|
||
});
|
||
let rUC = '';
|
||
let backgroundIssueSync = null;
|
||
if ('indexedDB' in window) {
|
||
const backgroundIssueStore = createIssueSyncStore();
|
||
backgroundIssueSync = createBackgroundIssueSync({
|
||
store: backgroundIssueStore, fetchJson: fetchReviewJson,
|
||
});
|
||
backgroundIssueSync.requestSync = async () => {
|
||
if (!('serviceWorker' in navigator)) throw new Error('Background Sync unavailable');
|
||
const registration = await navigator.serviceWorker.ready;
|
||
if (!registration.sync) throw new Error('Background Sync unavailable');
|
||
await registration.sync.register('stackchain-issue-outbox-v1');
|
||
};
|
||
}
|
||
const issueOutbox = createIssueOutbox({
|
||
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
||
backgroundSync: backgroundIssueSync,
|
||
getOwnerLogin: () => confirmedOwnerLogin,
|
||
});
|
||
const authoredOutbox = createAuthoredOutbox({
|
||
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
||
backgroundSync: backgroundIssueSync,
|
||
getOwnerLogin: () => confirmedOwnerLogin,
|
||
});
|
||
const queueOfflineIssueBlocker = createOfflineIssueBlocker({
|
||
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
|
||
});
|
||
const notificationReadOutbox = createNotificationReadOutbox({
|
||
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
||
backgroundSync: backgroundIssueSync,
|
||
getOwnerLogin: () => confirmedOwnerLogin,
|
||
});
|
||
if (backgroundIssueSync) {
|
||
backgroundIssueSync.snapshot().then(records => {
|
||
issueOutbox.reconcileBackground(records);
|
||
authoredOutbox.reconcileBackground(records);
|
||
notificationReadOutbox.reconcileBackground(records);
|
||
}).catch(() => { /* The foreground localStorage outboxes remain available. */ });
|
||
}
|
||
const shareParams = new URLSearchParams(location.search);
|
||
const sharedLaunch = {
|
||
title: shareParams.get('title') || '',
|
||
text: shareParams.get('text') || '',
|
||
url: shareParams.get('url') || '',
|
||
};
|
||
const commentActionFeatures = createFeatureLoader({
|
||
document,
|
||
urls: {
|
||
'comment-actions': document.querySelector('meta[name="stackchain-feature-comment-actions"]')?.content || '',
|
||
},
|
||
});
|
||
await commentActionFeatures.run('comment-actions', {
|
||
status: qs('#my-work-action-status'), retryLabel:'Reload to retry comment actions.',
|
||
}, () => {
|
||
commentActions = createCommentActions({
|
||
fetchJson: fetchReviewJson,
|
||
getLogin: () => confirmedOwnerLogin,
|
||
confirmDelete: message => window.confirm(message),
|
||
});
|
||
});
|
||
const issueCaptureFeatures = createFeatureLoader({
|
||
document,
|
||
urls: {
|
||
'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '',
|
||
'push-notifications': document.querySelector('meta[name="stackchain-feature-push-notifications"]')?.content || '',
|
||
'device-setup': document.querySelector('meta[name="stackchain-feature-device-setup"]')?.content || '',
|
||
},
|
||
});
|
||
let sharedLaunchState = null;
|
||
let sharedLaunchHandled = false;
|
||
async function ensureIssueCapture() {
|
||
return await issueCaptureFeatures.run('issue-capture', {
|
||
trigger: qs('#new-issue'), status: qs('#my-work-action-status'),
|
||
}, () => {
|
||
if (!issueCapture) {
|
||
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
|
||
}
|
||
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
|
||
sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch);
|
||
sharedLaunchHandled = true;
|
||
}
|
||
});
|
||
}
|
||
if (Object.values(sharedLaunch).some(Boolean)) await ensureIssueCapture();
|
||
const pullWorkflowFeatures = createFeatureLoader({
|
||
document,
|
||
urls: {
|
||
'pull-workflow': document.querySelector('meta[name="stackchain-feature-pull-workflow"]')?.content || '',
|
||
},
|
||
});
|
||
let pullController = null;
|
||
async function ensurePullWorkflow(trigger = null) {
|
||
return await pullWorkflowFeatures.run('pull-workflow', {
|
||
trigger, status: qs('#my-work-action-status'), retryLabel:'Tap the work card to retry.',
|
||
}, () => {
|
||
if (!pullController) {
|
||
pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||
createPullSheet.bindOwnershipControls(document, pullController, () => selectedPull, async item => {
|
||
const continuing = workSession.checkpointed(item);
|
||
lastContextSnapshot = createPullSheet.removeFromSnapshot(lastContextSnapshot, item);
|
||
closePullSheet();
|
||
if (continuing) return await completeOwnershipExitToday(item);
|
||
paintMyWork(lastContextSnapshot);
|
||
return false;
|
||
});
|
||
}
|
||
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
||
if (!wrapPreference) {
|
||
wrapPreference = createReviewController.createWrapPreference({
|
||
storage: localStorage,
|
||
mobile: window.matchMedia('(max-width: 600px)').matches,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
||
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
|
||
let offlineStorageReady = await offlineWorkStore.ready();
|
||
function renderOfflineTodayStatus(status) {
|
||
const container = qs('#offline-today-readiness');
|
||
const label = qs('#offline-today-status');
|
||
const retry = qs('#retry-offline-today');
|
||
const visible = offlineWorkStore.enabled() && status.total > 0;
|
||
container.hidden = !visible;
|
||
if (!visible) return;
|
||
label.textContent = 'Today offline: ' + status.ready + ' of ' + status.total + ' ready' +
|
||
(status.pending ? ' · saving ' + status.pending : '') +
|
||
(status.failed ? ' · retry ' + status.failed : '');
|
||
retry.hidden = status.failed === 0;
|
||
}
|
||
const offlineToday = createOfflineToday({
|
||
loadDetail: async item => {
|
||
if (item.is_review || item.kind === 'pull') {
|
||
if (!await ensurePullWorkflow()) throw new Error('Pull workspace is unavailable.');
|
||
return item.is_review ? reviewController.load(item) : pullController.load(item);
|
||
}
|
||
return issueController.load(item);
|
||
},
|
||
loadSavedDetail: (login, item) => offlineWorkStore.loadDetail(login, item),
|
||
saveDetail: (login, item, detail) => offlineWorkStore.saveDetail(login, item, detail),
|
||
onStatus: renderOfflineTodayStatus,
|
||
});
|
||
function warmTodayOffline() {
|
||
if (!offlineWorkStore.enabled() || !confirmedOwnerLogin || offlineWorkMode) {
|
||
offlineToday.cancel();
|
||
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
|
||
return Promise.resolve();
|
||
}
|
||
return offlineToday.warm(confirmedOwnerLogin, todayMyWork);
|
||
}
|
||
const findWorkController = createFindWork({
|
||
fetchJson: fetchReviewJson,
|
||
onItems: renderAvailableIssues,
|
||
onPagination: pagination => {
|
||
availablePagination = pagination;
|
||
qs('#load-more-available').hidden = !pagination.has_more;
|
||
},
|
||
onStatus: message => { qs('#find-work-status').textContent = message; },
|
||
});
|
||
|
||
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
|
||
function setClock() { qs('#clock').textContent = fmt(new Date()); }
|
||
setClock(); setInterval(setClock, 1000);
|
||
|
||
async function fetchLiveSnapshot(revisions = {}, { signal } = {}) {
|
||
const query = createContextPoller.buildRevisionQuery(revisions);
|
||
const res = await fetch('api/v1/live' + (query ? '?' + query : ''), {
|
||
headers: { Accept: 'application/json' },
|
||
signal,
|
||
});
|
||
if (!res.ok) {
|
||
const error = new Error('HTTP ' + res.status);
|
||
error.retryAfterMs = createContextPoller.retryAfterMs(res.headers.get('Retry-After'));
|
||
throw error;
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
async function markNotificationRead(notificationId) {
|
||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/read', {
|
||
method: 'PATCH',
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Mark read failed.');
|
||
return payload;
|
||
}
|
||
|
||
async function acknowledgeNotification(notificationId) {
|
||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
|
||
'/acknowledge', { method: 'POST', headers: { Accept: 'application/json' } });
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Acknowledging the update failed.');
|
||
return payload;
|
||
}
|
||
|
||
async function markNotificationsRead(ids) {
|
||
const response = await fetch('api/v1/notifications/read', {
|
||
method: 'PATCH',
|
||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ids }),
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Bulk mark read failed.');
|
||
return payload;
|
||
}
|
||
|
||
async function fetchNotificationPage(page) {
|
||
const response = await fetch('api/v1/notifications?page=' + encodeURIComponent(page), {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Loading unread updates failed.');
|
||
return payload;
|
||
}
|
||
|
||
async function fetchNotificationDetail(notificationId) {
|
||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId), {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Loading the update failed.');
|
||
return payload;
|
||
}
|
||
|
||
async function fetchNotificationConversation(notificationId, page) {
|
||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
|
||
'/conversation?page=' + encodeURIComponent(page) + '&limit=20', {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Loading older messages failed.');
|
||
return payload;
|
||
}
|
||
|
||
async function fetchWorkPage(stream, page) {
|
||
const response = await fetch('api/v1/work/' + encodeURIComponent(stream) + '?page=' + encodeURIComponent(page), {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Loading older work failed.');
|
||
return payload;
|
||
}
|
||
|
||
async function postNotificationReply(notificationId, body, operationId) {
|
||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', {
|
||
method: 'POST',
|
||
headers: {
|
||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||
'Idempotency-Key': operationId,
|
||
},
|
||
body: JSON.stringify({ body }),
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Posting the reply failed.');
|
||
return payload;
|
||
}
|
||
|
||
const notificationAcknowledger = createNotificationAcknowledger({
|
||
markRead: markNotificationRead,
|
||
onItems: items => {
|
||
lastMyWork = items;
|
||
refreshMyWorkView();
|
||
},
|
||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||
});
|
||
const bulkNotificationAcknowledger = createBulkNotificationAcknowledger({
|
||
markRead: markNotificationsRead,
|
||
onItems: items => {
|
||
lastMyWork = items;
|
||
refreshMyWorkView();
|
||
},
|
||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||
});
|
||
const notificationSelection = createNotificationSelection({
|
||
limit: 50,
|
||
onChange: () => {
|
||
bulkConfirmationPending = false;
|
||
renderMyWork();
|
||
},
|
||
});
|
||
const workSelection = createWorkSelection({ limit: 50, onChange: () => renderMyWork() });
|
||
const notificationPager = createNotificationPager({
|
||
load: fetchNotificationPage,
|
||
onNotifications: items => {
|
||
lastNotifications = items;
|
||
if (lastContextSnapshot) {
|
||
lastContextSnapshot.notifications = lastNotifications;
|
||
paintMyWork(lastContextSnapshot);
|
||
}
|
||
},
|
||
onPagination: pagination => {
|
||
notificationPagination = pagination;
|
||
const loaded = Math.min(pagination.total, pagination.page * 50);
|
||
qs('#notification-page-status').textContent = pagination.total ?
|
||
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
|
||
qs('#load-more-notifications').hidden =
|
||
selectedWorkFilter !== 'update' || !pagination.has_more;
|
||
},
|
||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||
});
|
||
const workPager = createWorkPager({
|
||
load: fetchWorkPage,
|
||
onItems: (stream, items) => {
|
||
if (!lastContextSnapshot) return;
|
||
if (stream === 'issue') lastContextSnapshot.issues = items;
|
||
else lastContextSnapshot.pull_requests = items;
|
||
paintMyWork(lastContextSnapshot);
|
||
},
|
||
onPagination: pagination => {
|
||
workPagination = pagination;
|
||
updateWorkPaginationControls();
|
||
},
|
||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||
});
|
||
const notificationReplier = createNotificationReplier({
|
||
post: postNotificationReply,
|
||
storage: localStorage,
|
||
authoredOutbox,
|
||
onStatus: message => { qs('#update-reply-status').textContent = message; },
|
||
});
|
||
const updateOwnership = createUpdateOwnership({
|
||
available: () => createAndStart.available(),
|
||
claim: item => fetchReviewJson(
|
||
'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||
'/issues/' + encodeURIComponent(item.number) + '/claim',
|
||
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
||
),
|
||
addToday: item => {
|
||
const result = todayWork.add(item);
|
||
refreshMyWorkView();
|
||
if (result === 'added') {
|
||
todaySync.enqueue('add', todayWork.identity(item));
|
||
todaySync.flush();
|
||
warmTodayOffline();
|
||
}
|
||
return result;
|
||
},
|
||
start: item => {
|
||
const outcome = createAndStart.complete(item);
|
||
if (outcome === 'started') openRoutedWork(item, qs('#update-ownership-start'));
|
||
return outcome;
|
||
},
|
||
recover: item => openRoutedWork(item, qs('#update-ownership-start')),
|
||
onClaimed: item => {
|
||
if (!lastContextSnapshot) return;
|
||
const issues = (lastContextSnapshot.issues || []).filter(candidate =>
|
||
candidate.repository !== item.repository || candidate.number !== item.number
|
||
);
|
||
lastContextSnapshot = { ...lastContextSnapshot, issues:issues.concat(item) };
|
||
paintMyWork(lastContextSnapshot);
|
||
},
|
||
onState: state => {
|
||
const button = qs('#update-ownership-action');
|
||
button.hidden = state.action === 'hidden';
|
||
button.disabled = state.busy;
|
||
button.textContent = state.action === 'today' ? 'Add to Today' : 'Take ownership';
|
||
if (state.message) qs('#update-sheet-status').textContent = state.message;
|
||
},
|
||
onStartState: state => {
|
||
const button = qs('#update-ownership-start');
|
||
button.hidden = state.action === 'hidden';
|
||
button.disabled = state.busy;
|
||
if (state.message) qs('#update-sheet-status').textContent = state.message;
|
||
},
|
||
});
|
||
const notificationUndo = createDashboardNotificationUndo({
|
||
restore: requestNotificationUnread,
|
||
getItems: () => lastMyWork,
|
||
setItems: items => { lastMyWork = items; },
|
||
getNotifications: () => lastNotifications,
|
||
setNotifications: items => { lastNotifications = items; },
|
||
refresh: refreshMyWorkView,
|
||
select: qs,
|
||
});
|
||
const notificationReader = createNotificationReader({
|
||
load: fetchNotificationDetail,
|
||
loadConversation: fetchNotificationConversation,
|
||
markRead: markNotificationRead,
|
||
acknowledge: acknowledgeNotification,
|
||
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
|
||
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
|
||
onOpen: item => {
|
||
if (selectedUpdate && selectedUpdate.notification_id !== item.notification_id) {
|
||
updateReplyAttachmentController.clear();
|
||
}
|
||
selectedUpdate = item;
|
||
selectedUpdateDetail = null;
|
||
updateMentions.dismiss();
|
||
qs('#update-sheet').classList.add('open');
|
||
qs('#update-sheet-key').textContent = item.key || '';
|
||
qs('#update-sheet-title').textContent = item.title || 'Unread update';
|
||
qs('#update-comments').textContent = '';
|
||
qs('#update-conversation-status').textContent = '';
|
||
qs('#load-older-update-comments').hidden = true;
|
||
qs('#update-subject-body').textContent = '';
|
||
qs('#update-subject-type').textContent = item.subject_type || 'Update';
|
||
qs('#update-subject-state').textContent = item.state || '';
|
||
qs('#open-update-gitea').href = item.url || '#';
|
||
qs('#update-reply').value = notificationReplier.loadDraft(item);
|
||
qs('#update-reply-status').textContent = '';
|
||
qs('#send-update-reply').disabled = false;
|
||
qs('#send-update-reply-read-next').disabled = false;
|
||
qs('#acknowledge-update-next').hidden = true;
|
||
qs('#update-ownership-action').hidden = true;
|
||
qs('#update-ownership-start').hidden = true;
|
||
qs('#create-update-follow-up').hidden = true;
|
||
qs('#retry-update-load').hidden = true;
|
||
setOfflineUpdateControls(false);
|
||
qs('#keep-update-unread').focus();
|
||
},
|
||
onDetail: async detail => {
|
||
selectedUpdateDetail = detail;
|
||
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
|
||
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
|
||
qs('#update-subject-state').textContent = detail.state || '';
|
||
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
|
||
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
|
||
qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;
|
||
qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type);
|
||
if (offlineWorkMode) {
|
||
setOfflineUpdateControls(true);
|
||
} else {
|
||
updateOwnership.open(detail, selectedUpdate);
|
||
if (offlineWorkStore.enabled() && confirmedOwnerLogin && selectedUpdate) {
|
||
await offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail);
|
||
}
|
||
}
|
||
qs('#retry-update-load').hidden = true;
|
||
},
|
||
onConversation: renderUpdateConversation,
|
||
onItems: items => {
|
||
const readId = selectedUpdate?.notification_id;
|
||
lastMyWork = items;
|
||
if (Number.isInteger(readId)) {
|
||
lastNotifications = lastNotifications.filter(item => item.id !== readId);
|
||
}
|
||
refreshMyWorkView();
|
||
},
|
||
onStatus: message => {
|
||
qs('#update-sheet-status').textContent = message;
|
||
qs('#retry-update-load').hidden = !message.startsWith('Could not load update.');
|
||
if (message === 'Inbox cleared.') qs('#my-work-action-status').textContent = message;
|
||
},
|
||
onClose: () => closeUpdateSheet(false),
|
||
});
|
||
|
||
function routedWorkItem(item) {
|
||
if (item?.has_update && item.kind === 'update') {
|
||
return { ...item, kind:'update' };
|
||
}
|
||
return { ...item, kind:item?.is_review ? 'review' : item?.kind };
|
||
}
|
||
|
||
function openRoutedWork(item, trigger, options = {}) {
|
||
if (!item) return;
|
||
if (offlineWorkMode) {
|
||
const offlineLogin = planningOwnerLogin || confirmedOwnerLogin ||
|
||
String(offlineWorkStore.load()?.user?.login || '').trim();
|
||
const savedDetail = offlineWorkStore.loadDetail(offlineLogin, item);
|
||
const savedUpdate = item.kind === 'update' && item.has_update;
|
||
if ((!savedUpdate && !todayWork.contains(item)) || !savedDetail) {
|
||
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
|
||
return;
|
||
}
|
||
if (item.kind === 'issue') {
|
||
issueTrigger = trigger;
|
||
openIssueSheet(item, trigger, savedDetail);
|
||
} else if (item.is_review) {
|
||
reviewTrigger = trigger;
|
||
openReviewSheet(item, trigger, savedDetail);
|
||
} else if (item.kind === 'pull') {
|
||
pullTrigger = trigger;
|
||
openPullSheet(item, trigger, savedDetail);
|
||
} else if (savedUpdate) {
|
||
updateTrigger = trigger;
|
||
notificationReader.open(item, savedDetail).then(opened => {
|
||
if (opened && selectedUpdate === item) {
|
||
qs('#update-sheet-status').textContent = 'Offline update · saved ' + fmt(savedDetail.saved_at) +
|
||
' · replies and read acknowledgements queue for sync. Reconnect to take ownership, defer, or load older messages.';
|
||
}
|
||
});
|
||
} else {
|
||
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
|
||
}
|
||
return;
|
||
}
|
||
if (item.has_update && item.kind === 'update') updateTrigger = trigger;
|
||
else if (item.is_review) reviewTrigger = trigger;
|
||
else if (item.kind === 'issue') issueTrigger = trigger;
|
||
else if (item.kind === 'pull') pullTrigger = trigger;
|
||
workRoute.open(routedWorkItem(item), options);
|
||
}
|
||
|
||
const workRoute = createWorkRoute.createController({
|
||
location: window.location,
|
||
history: window.history,
|
||
eventTarget: window,
|
||
resolve: route => {
|
||
const params = new URLSearchParams({ kind:route.kind });
|
||
if (route.kind === 'update') params.set('notification_id', route.notification_id);
|
||
else {
|
||
params.set('repository', route.repository);
|
||
params.set('number', route.number);
|
||
}
|
||
return api('api/v1/work-route?' + params.toString());
|
||
},
|
||
onResolving: () => {
|
||
qs('#retry-work-route').hidden = true;
|
||
qs('#my-work-action-status').textContent = 'Loading shared work item…';
|
||
},
|
||
onOpen: item => {
|
||
qs('#retry-work-route').hidden = true;
|
||
qs('#my-work-action-status').textContent = '';
|
||
closeOpenWorkSheets();
|
||
if (item.kind === 'update') notificationReader.open(item, lastMyWork);
|
||
else if (item.kind === 'review') openReviewSheet(item, reviewTrigger);
|
||
else if (item.kind === 'issue') openIssueSheet(item, issueTrigger);
|
||
else if (item.kind === 'pull') openPullSheet(item, pullTrigger);
|
||
},
|
||
onQueue: openWorkQueueRoute,
|
||
onClose: () => {
|
||
if (selectedIssue) closeIssueSheet(false);
|
||
if (selectedPull) closePullSheet(false);
|
||
if (selectedReview) closeReviewSheet(false);
|
||
if (selectedUpdate) closeUpdateSheet(true, false);
|
||
},
|
||
onInvalid: () => {
|
||
qs('#retry-work-route').hidden = true;
|
||
window.history.replaceState(null, '', window.location.pathname + window.location.search);
|
||
closeOpenWorkSheets();
|
||
qs('#my-work-action-status').textContent = 'Route unavailable · this item is no longer in My Work.';
|
||
qs('#my-work').scrollIntoView({ block:'start' });
|
||
},
|
||
onError: () => {
|
||
qs('#my-work-action-status').textContent = 'Could not load shared work item. The link is preserved; retry when connected.';
|
||
qs('#retry-work-route').hidden = false;
|
||
},
|
||
});
|
||
workRoute.start();
|
||
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
|
||
|
||
function closeOpenWorkSheets() {
|
||
['#issue-sheet .issue-sheet-panel', '#pull-sheet .pull-sheet-panel', '#review-sheet .review-sheet-panel', '#update-sheet .update-sheet-panel']
|
||
.forEach(selector => mobileComposerViewport.close(qs(selector)));
|
||
['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector =>
|
||
qs(selector).classList.remove('open')
|
||
);
|
||
selectedIssue = null;
|
||
selectedIssueDetail = null;
|
||
selectedPull = null;
|
||
selectedPullDetail = null;
|
||
selectedReview = null;
|
||
selectedUpdate = null;
|
||
}
|
||
|
||
function openWorkSessionItem(item) {
|
||
timerView.open(workIdentity(item), workSession.checkpointed(item));
|
||
openRoutedWork(item, null, { replace:true });
|
||
}
|
||
|
||
const sessionCheckpoint = createWorkSessionCheckpoint({
|
||
storage: localStorage,
|
||
getLogin: () => confirmedOwnerLogin,
|
||
onError: () => {
|
||
qs('#my-work-action-status').textContent =
|
||
'Session recovery could not be saved on this device. You can keep working.';
|
||
},
|
||
});
|
||
const timer = createTodayTimer({
|
||
storage: localStorage,
|
||
getLogin: () => confirmedOwnerLogin,
|
||
});
|
||
const timerView = createTodayTimerView({
|
||
timer,
|
||
isActive: () => workSession.checkpointed(),
|
||
queryAll: s => document.querySelectorAll(s),
|
||
formatEstimate: formatPlanMinutes,
|
||
getItem: identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity),
|
||
onReopen: identity => {
|
||
selectTodayWork();
|
||
workSession.reopen(todayMyWork.find(item => todayWork.identity(item) === identity));
|
||
},
|
||
onComplete: identity => {
|
||
const item = todayMyWork.find(entry => todayWork.identity(entry) === identity);
|
||
return completeTodayItem(item);
|
||
},
|
||
});
|
||
renderAttentionInterruption();
|
||
qs('#return-to-today').addEventListener('click', () => {
|
||
const returned = timer.returnFromAttention();
|
||
renderAttentionInterruption();
|
||
if (!returned) return;
|
||
selectTodayWork();
|
||
const item = todayMyWork.find(entry => todayWork.identity(entry) === returned.identity);
|
||
if (item) workSession.reopen(item);
|
||
else continueTodaySession();
|
||
timerView.render();
|
||
});
|
||
const interruptionPrompt = createTodayInterruptionPrompt({
|
||
timer,
|
||
sheet: qs('#today-interruption-sheet'),
|
||
description: qs('#today-interruption-description'),
|
||
getItemLabel: identity => {
|
||
const item = [...todayMyWork, ...activeMyWork].find(entry => todayWork.identity(entry) === identity);
|
||
return item?.title || '';
|
||
},
|
||
onResolved: () => timerView.render(),
|
||
});
|
||
window.addEventListener('pagehide', () => interruptionPrompt.background());
|
||
interruptionPrompt.restore();
|
||
document.querySelectorAll('[data-today-interruption]').forEach(button => {
|
||
button.addEventListener('click', () =>
|
||
interruptionPrompt.resolve(button.dataset.todayInterruption)
|
||
);
|
||
});
|
||
const todayRecapView = setupTodayRecap(
|
||
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
|
||
() => planningOwnerLogin,
|
||
identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null,
|
||
actualMinutes => openPlanToday(qs('#plan-today'), true, actualMinutes)
|
||
);
|
||
function updateDetailDeferLabels(active) {
|
||
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
|
||
button.textContent = active ? 'Later today & next' : 'Later today';
|
||
});
|
||
document.querySelectorAll('[data-detail-defer-preset=tomorrow]').forEach(button => {
|
||
button.textContent = active ? 'Tomorrow & next' : 'Tomorrow';
|
||
});
|
||
document.querySelectorAll('[data-detail-defer-custom]').forEach(button => {
|
||
button.textContent = active ? 'Choose date & time & next' : 'Choose date & time';
|
||
});
|
||
}
|
||
function updateWorkSessionActions() {
|
||
const active = workSession.active();
|
||
qs('#start-work-session').hidden = active;
|
||
qs('#resume-today-session').hidden = active || !todayMyWork.length || !workSession.resumable();
|
||
qs('#end-today-session').hidden = !active;
|
||
updateDetailDeferLabels(active);
|
||
mobileTaskDock.updateWork(mobileWorkEntry.mode());
|
||
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
|
||
}
|
||
|
||
const workSession = createWorkSession({
|
||
getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork,
|
||
getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter,
|
||
getMilestone: () => selectedWorkMilestone,
|
||
checkpoint: sessionCheckpoint,
|
||
checkpointEnabled: () => selectedWorkFilter === 'today',
|
||
onOpen: openWorkSessionItem,
|
||
onProgress: state => {
|
||
updateWorkSessionActions();
|
||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
|
||
const runway = selectedWorkFilter === 'today' ? todayWork.runway(todayMyWork, state.index - 1) : null;
|
||
timerView.update(state, runway);
|
||
document.querySelectorAll('[data-work-session-previous]').forEach(button => {
|
||
button.disabled = !state.can_previous;
|
||
});
|
||
document.querySelectorAll('[data-work-session-next]').forEach(button => {
|
||
button.textContent = state.can_next ? 'Next work item' : 'Finish session';
|
||
});
|
||
document.querySelectorAll('[data-work-session-complete]').forEach(button => {
|
||
button.hidden = !workSession.checkpointed();
|
||
});
|
||
},
|
||
onFinish: () => todayRecapView.finish(selectedWorkFilter),
|
||
});
|
||
function selectTodayWork() {
|
||
qs('[data-work-filter="today"]').click();
|
||
}
|
||
|
||
let todayReadinessTrigger = null;
|
||
let todayReadinessBlockerFocus = null;
|
||
let searchPreviewReturnKind = null;
|
||
function inspectTodayDependencies(item) {
|
||
if (offlineWorkMode) {
|
||
const login = confirmedOwnerLogin || String(offlineWorkStore.load()?.user?.login || '').trim();
|
||
const detail = offlineWorkStore.loadDetail(login, item);
|
||
return Promise.resolve({
|
||
available:detail?.dependencies_available === true,
|
||
dependencies:Array.isArray(detail?.dependencies) ? detail.dependencies : [],
|
||
});
|
||
}
|
||
const [owner, repo] = String(item.repository || '').split('/');
|
||
return api('api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) +
|
||
'/issues/' + encodeURIComponent(item.number) + '/detail').then(detail => ({
|
||
available:detail?.dependencies_available === true,
|
||
dependencies:Array.isArray(detail?.dependencies) ? detail.dependencies : [],
|
||
}));
|
||
}
|
||
|
||
function suspendTodayReadiness() {
|
||
qs('#today-readiness-sheet').hidden = true;
|
||
document.body.classList.remove('task-overlay-open');
|
||
}
|
||
|
||
function closeTodayReadiness(navigate = true) {
|
||
suspendTodayReadiness();
|
||
if (navigate) taskOverlayHistory.close();
|
||
else {
|
||
todayReadiness.cancel();
|
||
todayReadinessBlockerFocus = null;
|
||
requestAnimationFrame(() => todayReadinessTrigger?.focus());
|
||
}
|
||
}
|
||
|
||
function renderTodayReadiness(state) {
|
||
if (!state?.target) return;
|
||
const unknown = state.status === 'unknown';
|
||
qs('#today-readiness-title').textContent = unknown ? 'Blocker status unavailable' : 'This item is blocked';
|
||
qs('#today-readiness-summary').textContent = unknown
|
||
? 'Stackchain could not confirm whether this issue is ready. Retry or choose an explicit override.'
|
||
: state.dependencies.length + (state.dependencies.length === 1 ? ' open dependency must finish first.' : ' open dependencies must finish first.');
|
||
qs('#today-readiness-item').innerHTML = '<strong>' + escapeHtml(state.target.title || 'Untitled issue') +
|
||
'</strong><div class="small">' + escapeHtml(state.target.repository + '#' + state.target.number) + '</div>';
|
||
const blockers = qs('#today-readiness-blockers');
|
||
blockers.innerHTML = state.dependencies.map((blocker, index) =>
|
||
'<button class="issue-blocker today-readiness-blocker" type="button" data-today-blocker-index="' + index + '"><strong>' +
|
||
escapeHtml(blocker.repository + '#' + blocker.number) + ' · ' + escapeHtml(blocker.title || 'Untitled blocker') +
|
||
'</strong><span class="small">State: ' + escapeHtml(blocker.state || 'open') + ' · Preview and work blocker</span></button>'
|
||
).join('');
|
||
blockers.querySelectorAll('[data-today-blocker-index]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const blocker = state.dependencies[Number(button.dataset.todayBlockerIndex)];
|
||
todayReadinessBlockerFocus = blocker?.repository + '#' + blocker?.number;
|
||
todayReadiness.previewBlocker(blocker);
|
||
});
|
||
});
|
||
qs('#today-readiness-next').hidden = !state.nextReady;
|
||
if (state.nextReady) qs('#today-readiness-next').textContent = 'Start next ready · ' +
|
||
(state.nextReady.title || state.nextReady.key || 'work item');
|
||
qs('#today-readiness-retry').hidden = !unknown;
|
||
qs('#today-readiness-sheet').hidden = false;
|
||
document.body.classList.add('task-overlay-open');
|
||
requestAnimationFrame(() => {
|
||
const blocker = state.dependencies.findIndex(candidate =>
|
||
candidate?.repository + '#' + candidate?.number === todayReadinessBlockerFocus
|
||
);
|
||
if (blocker >= 0) blockers.querySelector('[data-today-blocker-index="' + blocker + '"]')?.focus();
|
||
else (state.nextReady ? qs('#today-readiness-next') : qs('#today-readiness-anyway')).focus();
|
||
});
|
||
}
|
||
|
||
function performTodayTransition(action, item = null) {
|
||
qs('#today-readiness-sheet').hidden = true;
|
||
document.body.classList.remove('task-overlay-open');
|
||
if (taskOverlayHistory.current?.() === 'today-readiness') taskOverlayHistory.close();
|
||
const transitioned = ({
|
||
start: () => workSession.start(item),
|
||
resume: () => workSession.resume(item),
|
||
continue: () => workSession.reopen(item),
|
||
next: () => workSession.next(item),
|
||
complete: () => workSession.complete(item),
|
||
})[action]?.();
|
||
if (!transitioned && action !== 'next' && action !== 'complete') {
|
||
qs('#my-work-action-status').textContent = 'Saved Today item is no longer available.';
|
||
updateWorkSessionActions();
|
||
}
|
||
}
|
||
|
||
const todayReadiness = createTodayReadiness({
|
||
inspect:inspectTodayDependencies,
|
||
onOpen:performTodayTransition,
|
||
onGate:state => {
|
||
todayReadinessTrigger = document.activeElement;
|
||
renderTodayReadiness(state);
|
||
taskOverlayHistory.open('today-readiness');
|
||
},
|
||
onPreview:blocker => {
|
||
searchPreviewReturnKind = 'today-readiness';
|
||
searchPreview.open({ ...blocker, kind:'issue' }).catch(() => {});
|
||
taskOverlayHistory.open('search-preview');
|
||
},
|
||
});
|
||
|
||
function runTodayTransition(action) {
|
||
selectTodayWork();
|
||
const target = workSession.target(action);
|
||
if (!target) {
|
||
performTodayTransition(action);
|
||
return Promise.resolve('empty');
|
||
}
|
||
qs('#my-work-action-status').textContent = 'Checking Today readiness…';
|
||
return todayReadiness.run(action, workSession.items(), target);
|
||
}
|
||
|
||
function continueTodaySession() {
|
||
return runTodayTransition('continue');
|
||
}
|
||
|
||
function resumeTodaySession() {
|
||
return runTodayTransition('resume');
|
||
}
|
||
|
||
function startTodaySession() {
|
||
return runTodayTransition('start');
|
||
}
|
||
|
||
const createAndStart = createCreateAndStart({
|
||
todayWork,
|
||
todaySync,
|
||
refresh: refreshMyWorkView,
|
||
warm: warmTodayOffline,
|
||
start: item => {
|
||
qs('[data-work-filter="today"]').click();
|
||
workSession.start(item);
|
||
},
|
||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||
});
|
||
|
||
const queueToday = createQueueToday({
|
||
todayWork,
|
||
todaySync,
|
||
refresh: refreshMyWorkView,
|
||
warm: warmTodayOffline,
|
||
});
|
||
const laterAndStart = createLaterAndStart({
|
||
todayWork,
|
||
todaySync,
|
||
laterWork,
|
||
refresh: refreshMyWorkView,
|
||
warm: warmTodayOffline,
|
||
start: item => {
|
||
qs('[data-work-filter="today"]').click();
|
||
qs('#my-work-action-status').textContent = 'Checking Today readiness…';
|
||
return todayReadiness.run('start', workSession.items(), item);
|
||
},
|
||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||
});
|
||
|
||
const selectedSessionItem = kind => ({
|
||
issue: selectedIssue,
|
||
pull: selectedPull,
|
||
review: selectedReview,
|
||
update: selectedUpdate,
|
||
})[kind] || null;
|
||
const completeTodayItem = createTodayCompletion({
|
||
todayWork,
|
||
todaySync,
|
||
workSession,
|
||
refresh: () => refreshMyWorkView({ reconcileSession:false }),
|
||
warm: warmTodayOffline,
|
||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||
advance: () => runTodayTransition('complete'),
|
||
});
|
||
async function completeOwnershipExitToday(item) {
|
||
if (!item || !todayWork.remove(item)) return null;
|
||
todaySync.enqueue('remove', todayWork.identity(item));
|
||
todaySync.flush();
|
||
refreshMyWorkView({ reconcileSession:false });
|
||
warmTodayOffline();
|
||
return await runTodayTransition('complete');
|
||
}
|
||
function setCommentNextVisibility(kind) {
|
||
const item = kind === 'issue' ? selectedIssue : selectedPull;
|
||
qs('#send-' + kind + '-comment-next').hidden = !item || !workSession.checkpointed(item);
|
||
}
|
||
|
||
const issueCommentNext = createCommentNext({
|
||
post: async (item, body) => {
|
||
const comment = await issueController.comment(item, body);
|
||
if (selectedIssue === item && issueConversation) renderIssueConversation(issueConversation.append(comment));
|
||
return comment;
|
||
},
|
||
queue: message => authoredOutbox.enqueueDurably(message),
|
||
canQueue: canQueueMessage,
|
||
accept: item => {
|
||
issueController.saveDraft(item, '');
|
||
if (selectedIssue === item) qs('#issue-comment').value = '';
|
||
},
|
||
complete: item => completeTodayItem(item, {
|
||
successMessage: 'Comment saved. Next Today item opened.',
|
||
failureMessage: 'Comment saved, but Today still needs completion.',
|
||
}),
|
||
});
|
||
const pullCommentNext = createCommentNext({
|
||
post: async (item, body) => {
|
||
const comment = await pullController.comment(item, body);
|
||
if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment));
|
||
return comment;
|
||
},
|
||
queue: message => authoredOutbox.enqueueDurably(message),
|
||
canQueue: canQueueMessage,
|
||
accept: item => {
|
||
pullController.saveDraft(item, '');
|
||
if (selectedPull === item) qs('#pull-comment').value = '';
|
||
},
|
||
complete: item => completeTodayItem(item, {
|
||
successMessage: 'Comment saved. Next Today item opened.',
|
||
failureMessage: 'Comment saved, but Today still needs completion.',
|
||
}),
|
||
});
|
||
const updateReplyReadNext = createUpdateReplyReadNext({
|
||
post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId),
|
||
markRead: markNotificationRead,
|
||
queue: message => authoredOutbox.enqueueDurably(message),
|
||
deliver: item => authoredOutbox.retry(item.id, activeFlushLogin),
|
||
canQueue: canQueueMessage,
|
||
accept: item => {
|
||
notificationReplier.saveDraft(item, '');
|
||
if (selectedUpdate === item) qs('#update-reply').value = '';
|
||
},
|
||
next: item => notificationReader.acceptReadAndNext(lastMyWork, item),
|
||
});
|
||
const closeOfflineIssue = createOfflineIssueClose({
|
||
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
|
||
completeToday: (item, options) => completeTodayItem(item, options),
|
||
});
|
||
|
||
function reviewingActiveTodayItem() {
|
||
return workSession.checkpointed();
|
||
}
|
||
|
||
function acceptClaimedIssue(confirmed) {
|
||
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
|
||
lastContextSnapshot.issues = [confirmed].concat((lastContextSnapshot.issues || []).filter(candidate =>
|
||
candidate.repository !== confirmed.repository || candidate.number !== confirmed.number
|
||
));
|
||
lastMyWork = buildMyWork(lastContextSnapshot);
|
||
return lastMyWork.find(work =>
|
||
work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number
|
||
);
|
||
}
|
||
|
||
const assignAndStart = createAssignAndStart({
|
||
available: createAndStart.available,
|
||
claim: item => findWorkController.claim(item),
|
||
start: confirmed => {
|
||
const claimed = acceptClaimedIssue(confirmed);
|
||
taskOverlayHistory.leave();
|
||
refreshMyWorkView();
|
||
return createAndStart.complete(claimed);
|
||
},
|
||
queue: confirmed => {
|
||
const claimed = acceptClaimedIssue(confirmed);
|
||
return queueToday(claimed);
|
||
},
|
||
recover: confirmed => {
|
||
const claimed = acceptClaimedIssue(confirmed);
|
||
taskOverlayHistory.leave();
|
||
refreshMyWorkView();
|
||
openRoutedWork(claimed, qs('#find-work'));
|
||
},
|
||
announce: message => {
|
||
qs('#find-work-status').textContent = message;
|
||
qs('#my-work-action-status').textContent = message;
|
||
},
|
||
});
|
||
|
||
let planTodayTrigger = null;
|
||
function formatPlanMinutes(minutes) {
|
||
if (!Number.isInteger(minutes)) return 'Not set';
|
||
const absolute = Math.abs(minutes);
|
||
const hours = Math.floor(absolute / 60);
|
||
const remainder = absolute % 60;
|
||
return [hours ? hours + 'h' : '', remainder ? remainder + 'm' : ''].filter(Boolean).join(' ') || '0m';
|
||
}
|
||
|
||
function planTodayItemMarkup(item, selected, index = -1) {
|
||
const id = todayWork.identity(item);
|
||
const key = escapeHtml(item.key || (item.repository + '#' + (item.number || '')));
|
||
const title = escapeHtml(item.title || 'Untitled work');
|
||
const state = planToday.snapshot();
|
||
const estimate = state.estimates?.[id] || '';
|
||
const recommendation = state.recommendations?.[id];
|
||
const estimateControl = selected ? '<label class="small plan-today-estimate-wrap">Estimate <input class="plan-today-estimate" type="number" inputmode="numeric" min="5" max="1440" step="5" value="' + escAttr(estimate) + '" data-plan-estimate="' + escAttr(id) + '" aria-label="Estimate for ' + escAttr(item.title || key) + ' in minutes" /> min</label>' : '';
|
||
const recommendationControl = selected && recommendation ? '<button class="plan-today-recommendation" type="button" data-plan-recommendation="' +
|
||
escAttr(id) + '">Use ' + formatPlanMinutes(recommendation) + ' as new estimate</button>' : '';
|
||
const controls = selected ?
|
||
'<div class="plan-today-item-actions"><button type="button" data-plan-move="up" data-plan-id="' + escAttr(id) + '"' + (index === 0 ? ' disabled' : '') + '>Up</button><button type="button" data-plan-move="down" data-plan-id="' + escAttr(id) + '"' + (index === state.count - 1 ? ' disabled' : '') + '>Down</button><button type="button" data-plan-remove="' + escAttr(id) + '">Remove</button></div>' :
|
||
(item.kind === 'issue' ?
|
||
'<div class="plan-today-candidate-actions"><button type="button" data-plan-preview="' + escAttr(id) + '">Preview to add</button></div>' :
|
||
'<div class="plan-today-candidate-actions"><button type="button" data-plan-preview="' + escAttr(id) + '">Preview</button><button type="button" data-plan-add="' + escAttr(id) + '">Add</button></div>');
|
||
return '<article class="plan-today-item"><div class="plan-today-item-copy"><span class="small">' + key + '</span><strong class="my-work-card-title">' + title + '</strong>' + estimateControl + recommendationControl + '</div>' + controls + '</article>';
|
||
}
|
||
|
||
function resetPlanTodayConfirmation() {
|
||
const save = qs('#save-today-plan');
|
||
const start = qs('#save-and-start-today');
|
||
save.dataset.confirmOverCapacity = '';
|
||
start.dataset.confirmOverCapacity = '';
|
||
save.textContent = 'Save plan';
|
||
start.textContent = 'Save & start';
|
||
}
|
||
|
||
function renderPlanToday() {
|
||
resetPlanTodayConfirmation();
|
||
const state = planToday.snapshot();
|
||
const capacityText = state.capacity_minutes === null ? 'Set available time to check fit' :
|
||
(state.over_capacity ? formatPlanMinutes(-state.remaining_minutes) + ' over capacity' :
|
||
formatPlanMinutes(state.remaining_minutes) + ' free');
|
||
qs('#plan-today-capacity').textContent = state.count + ' of ' + state.limit + ' selected · Planned ' +
|
||
formatPlanMinutes(state.planned_minutes) + ' · ' + capacityText +
|
||
(state.unestimated_count ? ' · ' + state.unestimated_count + ' unestimated' : '');
|
||
qs('#plan-today-available').value = state.capacity_minutes || '';
|
||
const build = state.build;
|
||
qs('#plan-today-build-status').textContent = build ?
|
||
('Built ' + build.selected.length + ' ready ' + (build.selected.length === 1 ? 'item' : 'items') +
|
||
(build.skipped.length ? ' · Skipped ' + build.skipped.length + ' blocked or unverified' : '') +
|
||
(build.needs_estimate.length ? ' · ' + build.needs_estimate.length + ' need estimates' : '')) : '';
|
||
const estimateSection = qs('#plan-today-estimates');
|
||
estimateSection.hidden = !build?.needs_estimate.length;
|
||
qs('#plan-today-estimate-list').innerHTML = (build?.needs_estimate || []).map(id => {
|
||
const item = planToday.item(id);
|
||
const key = escapeHtml(item?.key || ((item?.repository || '') + '#' + (item?.number || '')));
|
||
return '<div class="plan-today-estimate-row"><div><span class="small">' + key + '</span><strong class="my-work-card-title">' +
|
||
escapeHtml(item?.title || 'Untitled work') + '</strong></div><label class="small"><input type="number" inputmode="numeric" min="5" max="1440" step="5" data-plan-missing-estimate="' +
|
||
escAttr(id) + '" aria-label="Estimate for ' + escAttr(item?.title || key) + ' in minutes" /> min</label></div>';
|
||
}).join('');
|
||
qs('#plan-today-skipped').innerHTML = (build?.skipped || []).map(entry =>
|
||
'<div class="small plan-today-skipped-item"><strong>' + escapeHtml(planToday.item(entry.id)?.title || entry.id) +
|
||
'</strong> · ' + escapeHtml(entry.reason) + '</div>'
|
||
).join('');
|
||
qs('#plan-today-list').innerHTML = state.ids.length ? state.ids.map((id, index) =>
|
||
planTodayItemMarkup(planToday.item(id), true, index)
|
||
).join('') : '<p class="muted">No work selected yet.</p>';
|
||
const candidates = planToday.candidates();
|
||
qs('#plan-today-candidates').innerHTML = candidates.length ? candidates.map(item =>
|
||
planTodayItemMarkup(item, false)
|
||
).join('') : '<p class="muted">All available work is already selected.</p>';
|
||
document.querySelectorAll('[data-plan-add]').forEach(button => button.addEventListener('click', () => {
|
||
const result = planToday.toggle(planToday.item(button.dataset.planAdd));
|
||
qs('#plan-today-error').textContent = result === 'full' ? 'Today is full. Remove an item before adding another.' : '';
|
||
renderPlanToday();
|
||
}));
|
||
document.querySelectorAll('[data-plan-preview]').forEach(button => button.addEventListener('click', () => {
|
||
const item = planToday.item(button.dataset.planPreview);
|
||
if (!canPreviewPlanItem(item)) {
|
||
qs('#plan-today-error').textContent = 'Preview unavailable offline. Reconnect or add the item without previewing it.';
|
||
return;
|
||
}
|
||
qs('#plan-today-error').textContent = '';
|
||
if (planTodayPreview.open(item, button)) taskOverlayHistory.open('plan-today-preview');
|
||
}));
|
||
document.querySelectorAll('[data-plan-remove]').forEach(button => button.addEventListener('click', () => {
|
||
planToday.toggle(planToday.item(button.dataset.planRemove));
|
||
qs('#plan-today-error').textContent = '';
|
||
renderPlanToday();
|
||
}));
|
||
document.querySelectorAll('[data-plan-move]').forEach(button => button.addEventListener('click', () => {
|
||
planToday.move(button.dataset.planId, button.dataset.planMove);
|
||
renderPlanToday();
|
||
document.querySelector('[data-plan-id="' + CSS.escape(button.dataset.planId) + '"][data-plan-move="' + button.dataset.planMove + '"]')?.focus();
|
||
}));
|
||
document.querySelectorAll('[data-plan-estimate]').forEach(input => input.addEventListener('change', () => {
|
||
planToday.setEstimate(input.dataset.planEstimate, Number(input.value));
|
||
renderPlanToday();
|
||
}));
|
||
document.querySelectorAll('[data-plan-missing-estimate]').forEach(input => input.addEventListener('change', () => {
|
||
const id = input.dataset.planMissingEstimate;
|
||
if (!input.checkValidity() || !planToday.setEstimate(id, Number(input.value))) {
|
||
qs('#plan-today-error').textContent = 'Enter an estimate from 5 minutes to 24 hours.';
|
||
input.focus();
|
||
return;
|
||
}
|
||
qs('#plan-today-error').textContent = '';
|
||
renderPlanToday();
|
||
document.querySelector('[data-plan-missing-estimate]')?.focus();
|
||
}));
|
||
document.querySelectorAll('[data-plan-recommendation]').forEach(button => button.addEventListener('click', () => {
|
||
planToday.applyRecommendation(button.dataset.planRecommendation);
|
||
renderPlanToday();
|
||
document.querySelector('[data-plan-estimate="' + CSS.escape(button.dataset.planRecommendation) + '"]')?.focus();
|
||
}));
|
||
}
|
||
|
||
function closePlanToday(navigate = true) {
|
||
planTodayReadiness.cancel();
|
||
qs('#build-today-plan').disabled = false;
|
||
qs('#plan-today-build-status').textContent = '';
|
||
if (navigate) {
|
||
taskOverlayHistory.close();
|
||
return;
|
||
}
|
||
planToday.cancel();
|
||
qs('#plan-today-sheet').hidden = true;
|
||
document.body.classList.remove('task-overlay-open');
|
||
planTodayTrigger?.focus();
|
||
}
|
||
|
||
function saveTodayPlan(plan) {
|
||
const capacityAware = !Array.isArray(plan);
|
||
const ids = capacityAware ? plan.ids : plan;
|
||
const previous = todayWork.read();
|
||
if (rolloverReviewPlan) {
|
||
const operation = todayRollover.operation({
|
||
operation_id: 'pending', base_revision: rolloverReviewPlan.revision,
|
||
selected_ids: ids, capacity_minutes: plan.capacity_minutes, estimates: plan.estimates,
|
||
});
|
||
if (!todaySync.enqueueRollover(operation)) return false;
|
||
rolloverReviewPlan = null;
|
||
qs('#plan-today').textContent = 'Plan Today';
|
||
qs('#plan-today-title').textContent = 'Plan Today';
|
||
} else {
|
||
const operations = previous.map(id => ['remove', id]).concat(ids.map(id => ['add', id]));
|
||
if (!operations.every(([action, id]) => todaySync.enqueue(action, id))) return false;
|
||
if (capacityAware && !todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates)) return false;
|
||
}
|
||
if (!todayWork.replace(ids)) return false;
|
||
if (capacityAware && !todayWork.replacePlanning(plan)) return false;
|
||
todayRecapView.completeReplan();
|
||
refreshMyWorkView();
|
||
todaySync.flush();
|
||
warmTodayOffline();
|
||
qs('#my-work-action-status').textContent = ids.length ? 'Today plan saved in your chosen order and available time.' : 'Today plan cleared.';
|
||
return true;
|
||
}
|
||
|
||
const planToday = createPlanToday({
|
||
identity: item => todayWork.identity(item),
|
||
limit: todayWork.limit,
|
||
save: saveTodayPlan,
|
||
start: () => {
|
||
qs('[data-work-filter="today"]').click();
|
||
startTodaySession();
|
||
},
|
||
});
|
||
|
||
const planTodayReadiness = createPlanTodayReadiness({
|
||
concurrency:3,
|
||
identity:item => todayWork.identity(item),
|
||
inspect:async item => {
|
||
if (item.kind !== 'issue') return { status:'ready' };
|
||
const detail = await inspectTodayDependencies(item);
|
||
if (!detail.available) return { status:'unverified' };
|
||
if (detail.dependencies.length) return {
|
||
status:'blocked', reason:detail.dependencies.length + ' open ' +
|
||
(detail.dependencies.length === 1 ? 'dependency' : 'dependencies'),
|
||
};
|
||
return { status:'ready' };
|
||
},
|
||
});
|
||
|
||
async function buildTodayPlan() {
|
||
const button = qs('#build-today-plan');
|
||
const state = planToday.snapshot();
|
||
const candidates = state.ids.map(id => planToday.item(id)).concat(planToday.candidates());
|
||
button.disabled = true;
|
||
qs('#plan-today-build-status').textContent = 'Checking readiness and fitting your highest-ranked work…';
|
||
const checks = await planTodayReadiness.run(candidates);
|
||
if (!checks) return;
|
||
planToday.buildRecommendation(checks);
|
||
button.disabled = false;
|
||
renderPlanToday();
|
||
}
|
||
|
||
function canPreviewPlanItem(item) {
|
||
if (!item || !offlineWorkMode) return Boolean(item);
|
||
const login = planningOwnerLogin || confirmedOwnerLogin ||
|
||
String(offlineWorkStore.load()?.user?.login || '').trim();
|
||
return Boolean(offlineWorkStore.loadDetail(login, item));
|
||
}
|
||
|
||
function openPlanPreviewDetail(item, trigger) {
|
||
qs('#plan-today-sheet').hidden = true;
|
||
qs('#plan-preview-actions').hidden = false;
|
||
if (item.kind === 'issue') renderPlanIssueDependencies(null, true);
|
||
if (offlineWorkMode) {
|
||
openRoutedWork(item, trigger);
|
||
} else if (item.kind === 'update' && item.has_update) {
|
||
updateTrigger = trigger;
|
||
notificationReader.open(item, lastMyWork);
|
||
} else if (item.is_review || item.kind === 'review') {
|
||
reviewTrigger = trigger;
|
||
openReviewSheet(item, trigger);
|
||
} else if (item.kind === 'issue') {
|
||
issueTrigger = trigger;
|
||
openIssueSheet(item, trigger);
|
||
} else if (item.kind === 'pull') {
|
||
pullTrigger = trigger;
|
||
openPullSheet(item, trigger);
|
||
}
|
||
}
|
||
|
||
const planTodayPreview = createPlanTodayPreview({
|
||
planner: planToday,
|
||
identity: item => todayWork.identity(item),
|
||
getScroll: () => qs('.plan-today-panel').scrollTop,
|
||
setScroll: value => requestAnimationFrame(() => { qs('.plan-today-panel').scrollTop = value; }),
|
||
onOpen: openPlanPreviewDetail,
|
||
onClose: (_item, trigger) => {
|
||
closeOpenWorkSheets();
|
||
qs('#plan-preview-actions').hidden = true;
|
||
qs('#issue-blockers').hidden = true;
|
||
qs('#issue-blocker-list').textContent = '';
|
||
qs('#add-plan-preview').disabled = false;
|
||
qs('#add-plan-preview').textContent = 'Add to Today & back';
|
||
qs('#plan-today-sheet').hidden = false;
|
||
renderPlanToday();
|
||
requestAnimationFrame(() => trigger?.focus());
|
||
},
|
||
});
|
||
|
||
function renderPlanIssueDependencies(detail, loading = false) {
|
||
const preview = planTodayPreview.snapshot();
|
||
const assignedIssue = Boolean(selectedIssue && selectedIssueDetail === detail);
|
||
if (!assignedIssue && (!preview.open || preview.item?.kind !== 'issue')) return;
|
||
const panel = qs('#issue-blockers');
|
||
const list = qs('#issue-blocker-list');
|
||
const status = qs('#issue-blocker-status');
|
||
const addButton = qs('#add-plan-preview');
|
||
panel.hidden = false;
|
||
list.innerHTML = '';
|
||
qs('#manage-issue-blockers').hidden = !assignedIssue;
|
||
qs('#start-unblocked-issue').hidden = true;
|
||
if (!assignedIssue) qs('#issue-blocker-manager').hidden = true;
|
||
if (assignedIssue) {
|
||
const available = detail?.dependencies_available === true;
|
||
const dependencies = Array.isArray(detail?.dependencies) ? detail.dependencies : [];
|
||
list.innerHTML = dependencies.map((blocker, index) =>
|
||
'<div class="issue-blocker-row"><a class="issue-blocker" href="' + escAttr(blocker.url || '#') +
|
||
'" target="_blank" rel="noopener noreferrer"><strong>' + escapeHtml(blocker.repository + '#' + blocker.number) +
|
||
' · ' + escapeHtml(blocker.title || 'Untitled blocker') + '</strong><span class="small">State: ' +
|
||
escapeHtml(blocker.state || 'open') + '</span></a><button class="issue-blocker-remove" type="button" data-remove-blocker-index="' +
|
||
index + '">Remove blocker</button></div>'
|
||
).join('');
|
||
list.querySelectorAll('[data-remove-blocker-index]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const blocker = dependencies[Number(button.dataset.removeBlockerIndex)];
|
||
if (blocker && window.confirm('Remove ' + blocker.repository + '#' + blocker.number + ' as a blocker?')) {
|
||
mutateIssueBlocker(blocker, true, button);
|
||
}
|
||
});
|
||
});
|
||
status.textContent = available ? (dependencies.length ?
|
||
dependencies.length + (dependencies.length === 1 ? ' unresolved blocker.' : ' unresolved blockers.') :
|
||
'No unresolved blockers. Add one without leaving this issue.') :
|
||
'Blocker status unavailable. Reload before changing relationships.';
|
||
qs('#manage-issue-blockers').disabled = !available;
|
||
return;
|
||
}
|
||
addButton.disabled = loading;
|
||
addButton.dataset.planOverride = '';
|
||
if (loading) {
|
||
status.textContent = 'Checking unresolved blockers…';
|
||
addButton.textContent = 'Checking blockers…';
|
||
return;
|
||
}
|
||
const available = detail?.dependencies_available === true;
|
||
const dependencies = Array.isArray(detail?.dependencies) ? detail.dependencies : [];
|
||
planTodayPreview.setDependencies({ available, dependencies });
|
||
if (!available) {
|
||
status.textContent = 'Blocker status unavailable. Adding requires an explicit override.';
|
||
addButton.textContent = 'Add without blocker status anyway & back';
|
||
addButton.dataset.planOverride = 'true';
|
||
return;
|
||
}
|
||
if (dependencies.length) {
|
||
list.innerHTML = dependencies.map(blocker =>
|
||
'<a class="issue-blocker" href="' + escAttr(blocker.url || '#') + '" target="_blank" rel="noopener noreferrer"><strong>' +
|
||
escapeHtml(blocker.repository + '#' + blocker.number) + ' · ' + escapeHtml(blocker.title || 'Untitled blocker') +
|
||
'</strong><span class="small">State: ' + escapeHtml(blocker.state || 'open') + '</span></a>'
|
||
).join('');
|
||
status.textContent = dependencies.length + (dependencies.length === 1 ? ' unresolved blocker.' : ' unresolved blockers.');
|
||
addButton.textContent = 'Add blocked item anyway & back';
|
||
addButton.dataset.planOverride = 'true';
|
||
return;
|
||
}
|
||
panel.hidden = true;
|
||
status.textContent = '';
|
||
addButton.textContent = 'Add to Today & back';
|
||
}
|
||
|
||
async function mutateIssueBlocker(blocker, remove, button) {
|
||
if (!selectedIssue) return;
|
||
const item = selectedIssue;
|
||
button.disabled = true;
|
||
qs('#issue-blocker-status').textContent = remove ? 'Removing blocker…' : 'Adding blocker…';
|
||
try {
|
||
if (selectedIssueOffline) {
|
||
await queueOfflineIssueBlocker(item, blocker, !remove);
|
||
if (selectedIssue !== item) return;
|
||
button.textContent = 'Queued';
|
||
qs('#issue-blocker-status').textContent = remove ?
|
||
'Blocker removal queued. Today remains blocked until Stackchain confirms delivery.' :
|
||
'Blocker addition queued. Today readiness will update after Stackchain confirms delivery.';
|
||
return { queued:true };
|
||
}
|
||
const result = remove ?
|
||
await issueController.updateBlocker(selectedIssue, blocker, true) :
|
||
await issueController.updateBlocker(selectedIssue, blocker, false);
|
||
if (selectedIssue !== item) return;
|
||
selectedIssueDetail = { ...selectedIssueDetail, ...result };
|
||
renderPlanIssueDependencies(selectedIssueDetail);
|
||
if (remove && result.dependencies.length === 0 && workSession.checkpointed(item)) {
|
||
qs('#issue-blocker-status').textContent = 'All blockers cleared. This Today item is ready.';
|
||
qs('#start-unblocked-issue').hidden = false;
|
||
}
|
||
} catch (error) {
|
||
if (selectedIssue !== item) return;
|
||
qs('#issue-blocker-status').textContent = error.message + ' The previous blocker list is unchanged; reload before retrying.';
|
||
button.disabled = false;
|
||
button.focus();
|
||
}
|
||
}
|
||
|
||
function renderIssueBlockerCandidates(items) {
|
||
issueBlockerCandidates = items;
|
||
const results = qs('#issue-blocker-results');
|
||
results.innerHTML = items.map((result, index) =>
|
||
'<button type="button" role="option" data-blocker-result="' + index + '"><strong>' +
|
||
escapeHtml(result.repository + '#' + result.number) + '</strong><span class="small">' +
|
||
escapeHtml(result.title || 'Untitled issue') + '</span></button>'
|
||
).join('');
|
||
results.querySelectorAll('[data-blocker-result]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const blocker = issueBlockerCandidates[Number(button.dataset.blockerResult)];
|
||
if (blocker) mutateIssueBlocker(blocker, false, button).then(outcome => {
|
||
if (outcome?.queued || selectedIssueDetail?.dependencies?.some(candidate =>
|
||
candidate.repository === blocker.repository && candidate.number === blocker.number
|
||
)) {
|
||
qs('#issue-blocker-manager').hidden = true;
|
||
qs('#issue-blocker-search').setAttribute('aria-expanded', 'false');
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
qs('#manage-issue-blockers').addEventListener('click', () => {
|
||
qs('#issue-blocker-manager').hidden = false;
|
||
qs('#issue-blocker-search').setAttribute('aria-expanded', 'true');
|
||
qs('#issue-blocker-search-status').textContent = 'Enter at least 2 characters.';
|
||
qs('#issue-blocker-search').focus();
|
||
});
|
||
qs('#cancel-issue-blocker').addEventListener('click', () => {
|
||
qs('#issue-blocker-manager').hidden = true;
|
||
qs('#issue-blocker-search').setAttribute('aria-expanded', 'false');
|
||
qs('#manage-issue-blockers').focus();
|
||
});
|
||
qs('#issue-blocker-search').addEventListener('keydown', event => {
|
||
if (event.key === 'Escape') qs('#cancel-issue-blocker').click();
|
||
});
|
||
qs('#issue-blocker-search').addEventListener('input', event => {
|
||
clearTimeout(issueBlockerSearchTimer);
|
||
const query = event.target.value.trim();
|
||
if (query.length < 2) {
|
||
renderIssueBlockerCandidates([]);
|
||
qs('#issue-blocker-search-status').textContent = 'Enter at least 2 characters.';
|
||
return;
|
||
}
|
||
qs('#issue-blocker-search-status').textContent = 'Searching open issues…';
|
||
issueBlockerSearchTimer = setTimeout(async () => {
|
||
try {
|
||
const candidates = selectedIssueOffline ? workSession.items() :
|
||
(await api('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10')).items;
|
||
const existing = selectedIssueDetail?.dependencies || [];
|
||
const normalizedQuery = query.toLowerCase();
|
||
const items = (candidates || []).filter(result =>
|
||
result.kind === 'issue' && result.state === 'open' &&
|
||
(!selectedIssueOffline || (String(result.repository || '') + '#' + result.number + ' ' + String(result.title || ''))
|
||
.toLowerCase().includes(normalizedQuery)) &&
|
||
!(result.repository === selectedIssue?.repository && result.number === selectedIssue?.number) &&
|
||
!existing.some(blocker => blocker.repository === result.repository && blocker.number === result.number)
|
||
);
|
||
renderIssueBlockerCandidates(items);
|
||
qs('#issue-blocker-search-status').textContent = items.length ?
|
||
items.length + (items.length === 1 ? ' open issue found.' : ' open issues found.') : 'No eligible open issues found.';
|
||
} catch (error) {
|
||
renderIssueBlockerCandidates([]);
|
||
qs('#issue-blocker-search-status').textContent = error.message + ' Retry your search.';
|
||
}
|
||
}, 250);
|
||
});
|
||
qs('#start-unblocked-issue').addEventListener('click', () => {
|
||
if (selectedIssue) todayReadiness.run('start', workSession.items(), selectedIssue);
|
||
});
|
||
|
||
let addPlanPreviewOnReturn = false;
|
||
let addPlanPreviewOverride = false;
|
||
|
||
qs('#back-to-plan').addEventListener('click', () => {
|
||
addPlanPreviewOnReturn = false;
|
||
addPlanPreviewOverride = false;
|
||
taskOverlayHistory.close();
|
||
});
|
||
qs('#add-plan-preview').addEventListener('click', () => {
|
||
addPlanPreviewOnReturn = true;
|
||
addPlanPreviewOverride = qs('#add-plan-preview').dataset.planOverride === 'true';
|
||
taskOverlayHistory.close();
|
||
});
|
||
|
||
let pendingPlanActualMinutes = null;
|
||
function openPlanToday(trigger, navigate = true, actualMinutes = null) {
|
||
if (!planningOwnerLogin) {
|
||
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
||
return;
|
||
}
|
||
if (trigger) planTodayTrigger = trigger;
|
||
qs('#plan-today-title').textContent = rolloverReviewPlan ? 'New day review' : 'Plan Today';
|
||
if (actualMinutes) pendingPlanActualMinutes = actualMinutes;
|
||
if (navigate) {
|
||
taskOverlayHistory.open('plan-today');
|
||
return;
|
||
}
|
||
const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes;
|
||
pendingPlanActualMinutes = null;
|
||
planToday.open(todayMyWork, activeMyWork, todayWork.planning(), recommendations);
|
||
qs('#discard-recap-replan').hidden = !todayRecapView.pendingReplan();
|
||
qs('#plan-today-error').textContent = '';
|
||
qs('#plan-today-sheet').hidden = false;
|
||
document.body.classList.add('task-overlay-open');
|
||
renderPlanToday();
|
||
qs('#cancel-plan-today').focus();
|
||
}
|
||
|
||
qs('#discard-recap-replan').addEventListener('click', () => {
|
||
todayRecapView.discardReplan();
|
||
planToday.open(todayMyWork, activeMyWork, todayWork.planning());
|
||
qs('#discard-recap-replan').hidden = true;
|
||
qs('#plan-today-error').textContent = 'Recap feedback discarded. Your saved recap is unchanged.';
|
||
renderPlanToday();
|
||
});
|
||
|
||
const detailDefer = createDetailDefer({
|
||
laterWork,
|
||
session: workSession,
|
||
continueSession: item => completeTodayItem(item, {
|
||
successMessage: 'Deferred to Later. Next Today item opened.',
|
||
failureMessage: 'Could not remove this item from Today.',
|
||
}),
|
||
close: () => workRoute.close(),
|
||
refresh: refreshMyWorkView,
|
||
focus: () => qs('[data-work-filter="' + selectedWorkFilter + '"]')?.focus(),
|
||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||
formatTime: fmt,
|
||
});
|
||
const laterPickerElement = qs('#later-picker');
|
||
const laterPickerInput = qs('#later-picker-time');
|
||
const laterPicker = createLaterPicker({
|
||
history: window.history,
|
||
eventTarget: window,
|
||
onState: state => {
|
||
qs('#later-picker-error').textContent = state.message || '';
|
||
if (state.open) {
|
||
laterPickerInput.value = state.value || laterPickerInput.value;
|
||
qs('#later-picker-timezone').textContent = 'Times use ' +
|
||
(Intl.DateTimeFormat().resolvedOptions().timeZone || 'your device timezone') + '.';
|
||
laterPickerElement.hidden = false;
|
||
if (!laterPickerElement.open) laterPickerElement.showModal();
|
||
laterPickerInput.focus();
|
||
} else {
|
||
if (laterPickerElement.open) laterPickerElement.close();
|
||
laterPickerElement.hidden = true;
|
||
}
|
||
},
|
||
onConfirm: (item, until, context) => {
|
||
if (context === 'detail') {
|
||
const inSession = workSession.active();
|
||
const deferred = detailDefer.deferUntil(item, until, {
|
||
closeSheet:false,
|
||
restoreFocus:false,
|
||
});
|
||
if (!deferred) return false;
|
||
return inSession ? true : () => workRoute.close();
|
||
}
|
||
const result = laterWork.defer(item, until);
|
||
qs('#my-work-action-status').textContent = result === 'deferred' ?
|
||
'Deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
|
||
(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
|
||
if (result !== 'deferred') return false;
|
||
refreshMyWorkView();
|
||
return true;
|
||
},
|
||
});
|
||
laterPicker.start();
|
||
qs('#later-picker-form').addEventListener('submit', event => {
|
||
event.preventDefault();
|
||
laterPicker.submit(laterPickerInput.value);
|
||
});
|
||
qs('#cancel-later-picker').addEventListener('click', () => laterPicker.close());
|
||
laterPickerElement.addEventListener('cancel', event => {
|
||
event.preventDefault();
|
||
laterPicker.close();
|
||
});
|
||
document.querySelectorAll('[data-detail-defer-preset]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
|
||
button.closest('.detail-defer').open = false;
|
||
detailDefer.defer(item, button.dataset.detailDeferPreset);
|
||
});
|
||
});
|
||
document.querySelectorAll('[data-detail-defer-custom]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
|
||
button.closest('.detail-defer').open = false;
|
||
laterPicker.open(item, button, 'detail');
|
||
});
|
||
});
|
||
document.querySelectorAll('[data-detail-defer-cancel]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const chooser = button.closest('.detail-defer');
|
||
chooser.open = false;
|
||
chooser.querySelector('summary')?.focus();
|
||
});
|
||
});
|
||
|
||
function updatePlanningAvailability() {
|
||
document.querySelectorAll('[data-detail-defer-preset], [data-detail-defer-custom]').forEach(button => {
|
||
button.disabled = !planningOwnerLogin;
|
||
button.toggleAttribute('data-planning-disabled', !planningOwnerLogin);
|
||
});
|
||
}
|
||
|
||
function setOfflineDetailControls(kind) {
|
||
const selectors = kind === 'issue' ? [
|
||
'#edit-issue-content', '#release-issue', '#load-issue-handoff',
|
||
'#issue-handoff-recipient', '#confirm-issue-handoff', '#issue-due-date',
|
||
'#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date',
|
||
'#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments',
|
||
] : createPullSheet.ownershipSelectors().concat([
|
||
'#merge-pull', '#pull-review-retry', '#next-unreviewed-pull-file', '#load-older-pull-comments',
|
||
]);
|
||
selectors.forEach(selector => {
|
||
const control = qs(selector);
|
||
if (control) control.disabled = true;
|
||
});
|
||
if (kind === 'issue') {
|
||
qs('#issue-planning').inert = true;
|
||
qs('#issue-handoff').inert = true;
|
||
} else {
|
||
qs('#pull-review').inert = true;
|
||
qs('#pull-ownership').inert = true;
|
||
}
|
||
}
|
||
|
||
function setOfflineUpdateControls(offline) {
|
||
qs('#mark-update-read-next').disabled = false;
|
||
qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
|
||
qs('#acknowledge-update-next').disabled = offline;
|
||
qs('#update-ownership-action').disabled = offline;
|
||
qs('#update-ownership-start').disabled = offline;
|
||
qs('#load-older-update-comments').disabled = offline;
|
||
qs('#update-sheet .detail-defer').inert = offline;
|
||
}
|
||
|
||
function renderContextSnapshot(data) {
|
||
liveMode = true;
|
||
hasContextSnapshot = true;
|
||
if (lastContextSnapshot && Object.values(workPagination).some(page => page.page > 1)) {
|
||
const merge = (older, latest) => {
|
||
const byId = new Map((older || []).map(item => [item.id, item]));
|
||
(latest || []).forEach(item => {
|
||
const current = byId.get(item.id) || {};
|
||
const reasons = Array.from(new Set(
|
||
(current.work_reasons || []).concat(item.work_reasons || [])
|
||
));
|
||
byId.set(item.id, {
|
||
...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}),
|
||
});
|
||
});
|
||
return Array.from(byId.values());
|
||
};
|
||
data.issues = merge(lastContextSnapshot.issues, data.issues);
|
||
data.pull_requests = merge(lastContextSnapshot.pull_requests, data.pull_requests);
|
||
}
|
||
lastContextSnapshot = data;
|
||
if (data.work_pagination) workPager.reset(data.work_pagination);
|
||
if (data.error && lastMyWork.length) markMyWorkStale();
|
||
else paintMyWork(data);
|
||
openStagedSharedContent();
|
||
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
|
||
'<div class="label">Repos</div><div class="value">' + (data.repos?.length || 0) + '</div>' +
|
||
'<div class="label">Issues</div><div class="value">' + (data.issues?.length || 0) + '</div>' +
|
||
'<div class="label">PRs</div><div class="value">' + (data.pull_requests?.length || 0) + '</div></div>';
|
||
qs('#view-hint').textContent = 'Active view: ' + (data.view || 'dashboard');
|
||
|
||
const issuesBox = qs('#issues-content');
|
||
const openIssues = (data.issues || []).filter(i => i.state === 'open').slice(0, 12);
|
||
issuesBox.innerHTML = (openIssues.length ? openIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '" target="_blank">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' +
|
||
'<div class="muted">' + (i.labels || []).map(l => '<span class="pill">' + escapeHtml(String(l)) + '</span>').join(' ') + '</div></div>').join('') : '<div class="muted">No open issues.</div>');
|
||
|
||
const prsBox = qs('#prs-content');
|
||
const openPrs = (data.pull_requests || []).slice(0, 12);
|
||
prsBox.innerHTML = (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '" target="_blank">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' +
|
||
'<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>');
|
||
|
||
paintDeltas(data.deltas || []);
|
||
qs('#layout-hint').innerHTML = '<div class="kv"><div class="label">Active view</div><div class="value">' + escapeHtml(data.view || 'dashboard') + '</div><div class="label">Deltas</div><div class="value">' + (data.deltas||[]).length + '</div></div>';
|
||
renderRepoMix(qs('#repo-mix'), data);
|
||
setStatus(data.error ? 'Degraded · ' + data.error : 'Live · updated just now');
|
||
setClock();
|
||
}
|
||
|
||
async function handleContextError(e) {
|
||
console.error('context failed', e);
|
||
liveMode = false;
|
||
activeFlushLogin = '';
|
||
if (!hasContextSnapshot && await hydrateOfflineWork('outage')) return;
|
||
const timeoutStatus = e.name === 'TimeoutError' ?
|
||
'Update delayed · showing last snapshot' : 'Update failed · showing last snapshot';
|
||
setStatus(hasContextSnapshot ? timeoutStatus : 'Unavailable');
|
||
if (!hasContextSnapshot) {
|
||
qs('#context').innerHTML = '<div class="muted">Context unavailable.</div>';
|
||
qs('#view-hint').textContent = 'Active view unavailable.';
|
||
qs('#issues-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
|
||
qs('#prs-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
|
||
paintDeltas([]);
|
||
}
|
||
markMyWorkStale();
|
||
}
|
||
|
||
function paintMyWork(data) {
|
||
lastMyWork = buildMyWork(data);
|
||
refreshMyWorkView();
|
||
}
|
||
|
||
function listDrafts() {
|
||
const unfiled = unfiledCaptures.list().map(item => ({
|
||
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing',
|
||
title:item.title, preview:item.body + (item.hasAttachment ? ' · Screenshot attached' : ''),
|
||
hasAttachment:item.hasAttachment, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
|
||
updated_at:item.savedAt, quarantined:item.quarantined,
|
||
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
|
||
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
|
||
}));
|
||
return draftInbox.list().concat(unfiled).sort((left, right) =>
|
||
Number(right.updated_at || 0) - Number(left.updated_at || 0)
|
||
);
|
||
}
|
||
|
||
function refreshMyWorkView({ reconcileSession = true } = {}) {
|
||
lastDrafts = listDrafts();
|
||
const partitioned = laterWork.partition(lastMyWork, {
|
||
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
|
||
});
|
||
activeMyWork = partitioned.active;
|
||
laterMyWork = partitioned.later;
|
||
const authoritativeTodayReconciliation = liveMode && hasContextSnapshot &&
|
||
!lastContextSnapshot?.error &&
|
||
!Object.values(workPagination).some(page => page?.has_more);
|
||
todayMyWork = todayWork.reconcile(lastMyWork, {
|
||
pruneMissing: authoritativeTodayReconciliation,
|
||
onPrune: retiredIds => {
|
||
const queued = retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean);
|
||
if (queued) todaySync.flush();
|
||
return queued;
|
||
},
|
||
});
|
||
const counts = countMyWork(activeMyWork);
|
||
counts.today = todayMyWork.length;
|
||
counts.later = laterMyWork.length;
|
||
counts.draft = lastDrafts.length;
|
||
if (!launchFilterResolved) {
|
||
selectedWorkFilter = mobileLaunch.chooseFilter({
|
||
saved: savedWorkFilter, today: counts.today, attention: counts.attention,
|
||
});
|
||
launchFilterResolved = true;
|
||
document.querySelectorAll('[data-work-filter]').forEach(item =>
|
||
item.setAttribute('aria-pressed', String(item.dataset.workFilter === selectedWorkFilter))
|
||
);
|
||
}
|
||
Object.entries(counts).forEach(([filter, count]) => {
|
||
const element = qs('[data-work-count="' + filter + '"]');
|
||
if (element) element.textContent = count;
|
||
});
|
||
mobileTaskDock.updateQueues(counts);
|
||
mobileTaskDock.updateWork(mobileWorkEntry.mode());
|
||
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
|
||
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
|
||
qs('#active-work-queue').textContent = activeQueue.firstChild.textContent.trim() +
|
||
' (' + (counts[selectedWorkFilter] || 0) + ')';
|
||
const milestoneSelect = qs('#work-milestone-filter');
|
||
const lanes = milestoneLanes(lastMyWork);
|
||
milestoneSelect.innerHTML = '<option value="all">All milestones</option>' +
|
||
'<option value="unplanned">Unplanned</option>' + lanes.map(lane =>
|
||
'<option value="' + Number(lane.id) + '">' + escapeHtml(lane.title) + '</option>'
|
||
).join('');
|
||
milestoneSelect.value = selectedWorkMilestone;
|
||
if (milestoneSelect.value !== selectedWorkMilestone) {
|
||
const retained = document.createElement('option');
|
||
retained.value = selectedWorkMilestone;
|
||
retained.textContent = 'Selected milestone';
|
||
milestoneSelect.append(retained);
|
||
milestoneSelect.value = selectedWorkMilestone;
|
||
}
|
||
qs('#my-work').removeAttribute('data-stale');
|
||
qs('#my-work-status').textContent = lastMyWork.length ?
|
||
summarizeMyWork(activeMyWork) + (laterMyWork.length ? ' · ' + laterMyWork.length + ' deferred' : '') :
|
||
'No assigned work, review requests, or unread updates.';
|
||
updateWorkPaginationControls();
|
||
renderMyWork();
|
||
if (reconcileSession && workSession.active()) workSession.reconcile();
|
||
updateWorkSessionActions();
|
||
}
|
||
|
||
function activeWorkStreams() {
|
||
if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review'];
|
||
if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review'];
|
||
if (selectedWorkFilter === 'issue') return ['issue'];
|
||
if (selectedWorkFilter === 'pull') return ['pull'];
|
||
if (selectedWorkFilter === 'review') return ['review'];
|
||
if (selectedWorkFilter === 'all') return ['issue', 'pull', 'review'];
|
||
return [];
|
||
}
|
||
|
||
function renderDrafts() {
|
||
const list = qs('#my-work-list');
|
||
const displayedDrafts = findQueueItems(lastDrafts, queueFindQuery);
|
||
const deliveryCenter = draftInbox.partition(displayedDrafts);
|
||
const renderDraftCard = item => {
|
||
const index = lastDrafts.indexOf(item);
|
||
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
||
const isUnfiled = item.kind === 'unfiled-issue';
|
||
const reviewOutbox = item.outbox_kind === 'pull-review';
|
||
const closureOutbox = item.outbox_kind === 'issue-close';
|
||
const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now';
|
||
const outboxActions = item.quarantined ?
|
||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
|
||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||
item.continuation ?
|
||
'<button class="draft-continue" data-draft-index="' + index + '" type="button">Continue created work</button>' +
|
||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Dismiss</button>' :
|
||
item.kind === 'issue-outbox' ?
|
||
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' +
|
||
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
|
||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||
reviewOutbox && item.status === 'authorization' ?
|
||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open review</button>' +
|
||
'<button class="draft-authorize" data-draft-index="' + index + '" type="button">Authorize & send review</button>' +
|
||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
|
||
reviewOutbox && item.status === 'attention' ?
|
||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open current review</button>' +
|
||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy feedback</button>' +
|
||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
|
||
item.kind === 'authored-outbox' && closureOutbox ?
|
||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open issue</button>' +
|
||
'<button class="draft-authorize" data-draft-index="' + index + '" type="button">Authorize & close</button>' +
|
||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||
item.kind === 'authored-outbox' && !reviewOutbox ?
|
||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' +
|
||
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
|
||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||
reviewOutbox ?
|
||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open review</button>' +
|
||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
|
||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
|
||
(isUnfiled ? 'Choose repository' : 'Resume draft') + '</button>' +
|
||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
|
||
const state = (isOutbox || isUnfiled) ?
|
||
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
||
(isUnfiled ? 'Needs filing' : item.status === 'completion' ? 'Created · ready to start' :
|
||
item.status === 'attention' ? 'Needs attention' : item.status === 'sending' ? 'Sending' :
|
||
item.status === 'authorization' ? 'Awaiting authorization' : 'Queued for sync')) + '</span>' +
|
||
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
||
const attempt = item.last_attempt_error ? '<span class="delivery-attempt small">Last attempt ' +
|
||
escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '</span>' : '';
|
||
const captureTarget = isUnfiled ? ' tabindex="-1" data-capture-id="' + escapeHtml(item.capture_id) + '"' : '';
|
||
return '<article class="my-work-card draft-card"' + captureTarget + '>' +
|
||
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state + attempt +
|
||
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
|
||
'<div class="draft-actions">' + outboxActions + '</div></article>';
|
||
};
|
||
const deliverySummary = '<section class="delivery-center" aria-labelledby="delivery-center-title">' +
|
||
'<div><h3 id="delivery-center-title">Delivery center</h3>' +
|
||
'<p class="small">Waiting <strong data-delivery-count="waiting">' + deliveryCenter.counts.waiting + '</strong> · ' +
|
||
'Sending <strong data-delivery-count="sending">' + deliveryCenter.counts.sending + '</strong> · ' +
|
||
'Needs attention <strong data-delivery-count="attention">' + deliveryCenter.counts.attention + '</strong> · ' +
|
||
'Authorize <strong data-delivery-count="authorization">' + deliveryCenter.counts.authorization + '</strong></p></div>' +
|
||
'<button id="retry-waiting-deliveries" type="button"' +
|
||
(deliveryCenter.retryable.length && activeFlushLogin ? '' : ' disabled') + '>Retry waiting</button></section>';
|
||
const deliveryCards = deliveryCenter.deliveries.length ? deliveryCenter.deliveries.map(renderDraftCard).join('') :
|
||
'<div class="muted">No queued deliveries.</div>';
|
||
const draftCards = deliveryCenter.drafts.length ? deliveryCenter.drafts.map(renderDraftCard).join('') :
|
||
'<div class="muted">No unfinished drafts.</div>';
|
||
list.innerHTML = deliverySummary + '<section class="draft-section" aria-label="Queued deliveries">' +
|
||
'<h3>Queued deliveries</h3>' + deliveryCards + '</section>' +
|
||
'<section class="draft-section" aria-label="Unfinished drafts"><h3>Unfinished drafts</h3>' + draftCards + '</section>';
|
||
updateQueueFinder(displayedDrafts.length, lastDrafts.length, false);
|
||
qs('#retry-waiting-deliveries').addEventListener('click', async event => {
|
||
const button = event.currentTarget;
|
||
if (!activeFlushLogin || !deliveryCenter.retryable.length) return;
|
||
button.disabled = true;
|
||
qs('#my-work-action-status').textContent = 'Retrying safe waiting deliveries…';
|
||
const [issueResult, authoredResult] = await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)]);
|
||
applyOutboxResult(issueResult);
|
||
applyAuthoredOutboxResult(authoredResult);
|
||
qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.';
|
||
});
|
||
list.querySelectorAll('.draft-resume').forEach(button => {
|
||
button.addEventListener('click', async () => {
|
||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||
if (!item) return;
|
||
if (item.kind === 'unfiled-issue') {
|
||
try {
|
||
dFS.start(item.capture_id);
|
||
await dFS.nextCapture();
|
||
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
||
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
||
else if (item.route) workRoute.open(item.route);
|
||
});
|
||
});
|
||
list.querySelectorAll('.draft-continue').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||
const completion = issueOutbox.pendingCompletions(activeFlushLogin)
|
||
.find(candidate => candidate.id === item?.outbox_id);
|
||
if (completion) applyOutboxResult({
|
||
confirmed: [completion.issue], completions: [completion], remaining: issueOutbox.list(),
|
||
});
|
||
});
|
||
});
|
||
list.querySelectorAll('.draft-edit').forEach(button => {
|
||
button.addEventListener('click', async () => {
|
||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||
const queued = issueOutbox.list().find(candidate => candidate.id === item?.outbox_id);
|
||
if (!queued) return;
|
||
button.disabled = true;
|
||
try {
|
||
const hydrated = await issueOutbox.hydrateForEdit(queued.id);
|
||
if (!hydrated) return;
|
||
editingOutboxId = hydrated.id;
|
||
issueCapture.saveDraft(hydrated);
|
||
openCreateIssueSheet();
|
||
if (hydrated.attachment) createIssueAttachmentController.restore(hydrated.attachment);
|
||
else createIssueAttachmentController.clear();
|
||
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
|
||
} catch (error) {
|
||
qs('#my-work-action-status').textContent = String(error?.message ||
|
||
'The saved screenshot could not be loaded. Retry before editing this issue.');
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
});
|
||
list.querySelectorAll('.draft-send').forEach(button => {
|
||
button.addEventListener('click', async () => {
|
||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||
if (!item?.outbox_id) return;
|
||
button.disabled = true;
|
||
if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id, activeFlushLogin));
|
||
else applyOutboxResult(await issueOutbox.retry(item.outbox_id, activeFlushLogin));
|
||
});
|
||
});
|
||
list.querySelectorAll('.draft-authorize').forEach(button => {
|
||
button.addEventListener('click', async () => {
|
||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||
if (!item?.outbox_id || !activeFlushLogin) return;
|
||
button.disabled = true;
|
||
const reviewAuthorization = item.outbox_kind === 'pull-review';
|
||
qs('#my-work-action-status').textContent = reviewAuthorization ?
|
||
'Fresh authorization required for this exact review decision and head.' :
|
||
'Fresh authorization required for this exact issue.';
|
||
const result = await authoredOutbox.retry(item.outbox_id, activeFlushLogin);
|
||
applyAuthoredOutboxResult(result);
|
||
qs('#my-work-action-status').textContent = result.confirmed?.length ?
|
||
(reviewAuthorization ? 'Review submitted and queued intent cleared.' :
|
||
'Issue closed and queued intent cleared.') :
|
||
(reviewAuthorization ? 'Review was not confirmed. The queued review and feedback are still safe.' :
|
||
'Issue closure was not confirmed. The queued intent is still safe.');
|
||
});
|
||
});
|
||
list.querySelectorAll('.draft-copy').forEach(button => {
|
||
button.addEventListener('click', async () => {
|
||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||
if (!item?.copy_text) return;
|
||
await navigator.clipboard.writeText(item.copy_text);
|
||
qs('#my-work-action-status').textContent = 'Queued content copied without sending it.';
|
||
});
|
||
});
|
||
list.querySelectorAll('.draft-discard').forEach(button => {
|
||
button.addEventListener('click', async () => {
|
||
if (!window.confirm('Discard this unfinished draft?')) return;
|
||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||
if (item?.kind === 'unfiled-issue') await unfiledCaptures.discard(item.capture_id);
|
||
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
||
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
|
||
else if (item) draftInbox.discard(item.id);
|
||
lastDrafts = listDrafts();
|
||
const count = qs('[data-work-count="draft"]');
|
||
if (count) count.textContent = lastDrafts.length;
|
||
renderDrafts();
|
||
qs('#my-work-action-status').textContent = 'Draft discarded.';
|
||
});
|
||
});
|
||
}
|
||
|
||
function updateWorkPaginationControls() {
|
||
const labels = { issue: 'issues', pull: 'pull requests', review: 'review requests' };
|
||
const streams = activeWorkStreams();
|
||
const incomplete = streams.filter(stream => workPagination[stream]?.has_more);
|
||
const summaries = streams.flatMap(stream => {
|
||
const page = workPagination[stream];
|
||
return page && page.total ?
|
||
[Math.min(page.total, page.page * 50) + ' of ' + page.total + ' ' + labels[stream]] : [];
|
||
});
|
||
qs('#work-page-status').textContent = summaries.join(' · ');
|
||
qs('#load-more-work').hidden = incomplete.length === 0;
|
||
qs('#load-more-work').textContent = incomplete.length ?
|
||
'Load older ' + labels[incomplete[0]] : 'Load older work';
|
||
}
|
||
|
||
function renderMyWork() {
|
||
lastDrafts = listDrafts();
|
||
const draftCount = qs('[data-work-count="draft"]');
|
||
if (draftCount) draftCount.textContent = lastDrafts.length;
|
||
if (selectedWorkFilter === 'draft') {
|
||
renderDrafts();
|
||
qs('#bulk-mark-read-bar').hidden = true;
|
||
qs('#load-more-notifications').hidden = true;
|
||
return;
|
||
}
|
||
const queueItems = selectedWorkFilter === 'today' ?
|
||
filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ?
|
||
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
|
||
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
|
||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||
const visible = findQueueItems(queueItems, queueFindQuery);
|
||
updateQueueFinder(visible.length, queueItems.length, incomplete);
|
||
const selection = notificationSelection.snapshot();
|
||
const selectedIds = new Set(selection.ids);
|
||
const workSelectionState = workSelection.snapshot();
|
||
const selectedWorkIds = new Set(workSelectionState.ids);
|
||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
||
const routeItem = routedWorkItem(item);
|
||
const routeHref = createWorkRoute.serialize(routeItem);
|
||
const contents =
|
||
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
|
||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
|
||
(item.milestone?.title ? ' <span class="pill milestone-badge">' + escapeHtml(item.milestone.title) + '</span>' : '') +
|
||
(item.due_label ? ' <span class="pill due-badge">' + escapeHtml(item.due_label) + '</span>' : '') +
|
||
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
|
||
(item.deferred_until ? '<span class="small">Deferred until ' + escapeHtml(fmt(item.deferred_until)) + '</span>' : '') +
|
||
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
||
const selectable = selectedWorkFilter === 'update' && selection.active &&
|
||
item.has_update && Number.isInteger(item.notification_id);
|
||
const workSelectable = workSelectionState.active;
|
||
const workId = workSelection.identity(item);
|
||
const selector = workSelectable ?
|
||
'<label class="work-selector"><input type="checkbox" data-select-work-id="' + escAttr(workId) + '" data-work-index="' + index + '" aria-label="Select work ' + escAttr(item.key + ' ' + item.title) + '"' + (selectedWorkIds.has(workId) ? ' checked' : '') + '> Select</label>' : (selectable ?
|
||
'<label class="update-selector"><input type="checkbox" data-select-notification-id="' + item.notification_id + '" aria-label="Select update ' + escAttr(item.key + ' ' + item.title) + '"' + (selectedIds.has(item.notification_id) ? ' checked' : '') + '> Select</label>' : '');
|
||
const cardClasses = 'my-work-card' + ((selectable || workSelectable) ? ' selection-active' : '') +
|
||
((selectedIds.has(item.notification_id) || selectedWorkIds.has(workId)) ? ' selected' : '');
|
||
const markRead = item.has_update && Number.isInteger(item.notification_id) && !selection.active ?
|
||
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
|
||
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
|
||
'<a class="read-update" href="' + escAttr(createWorkRoute.serialize({ kind:'update', notification_id:item.notification_id })) + '" data-update-index="' + index + '">Read update</a>' : '';
|
||
const planningDisabled = planningOwnerLogin ? '' : ' disabled data-planning-disabled';
|
||
const laterActions = selectedWorkFilter === 'later' ?
|
||
'<div class="later-actions" aria-label="Deferred work actions"><button type="button" data-later-start data-work-index="' + index + '"' + planningDisabled + '>Start now</button><button type="button" data-later-restore data-work-index="' + index + '">Bring back now</button></div>' :
|
||
'<div class="later-actions" aria-label="Defer this work"><button type="button" data-later-preset="today" data-work-index="' + index + '"' + planningDisabled + '>Later today</button><button type="button" data-later-preset="tomorrow" data-work-index="' + index + '"' + planningDisabled + '>Tomorrow</button><button type="button" data-later-custom data-work-index="' + index + '"' + planningDisabled + '>Choose date & time</button></div>';
|
||
const alreadyToday = todayWork.contains(item);
|
||
const todayPosition = todayWork.position(item);
|
||
const todayActions = selectedWorkFilter === 'today' ?
|
||
'<div class="today-actions" aria-label="Reorder Today"><button type="button" data-today-move="up" data-work-index="' + index + '"' + (todayPosition.can_up ? '' : ' disabled') + '>Move up</button><button type="button" data-today-move="down" data-work-index="' + index + '"' + (todayPosition.can_down ? '' : ' disabled') + '>Move down</button><button type="button" data-today-remove data-work-index="' + index + '">Remove from Today</button></div>' :
|
||
'<div class="today-actions"><button type="button" data-today-add data-work-index="' + index + '"' + (alreadyToday ? ' disabled' : planningDisabled) + '>' + (alreadyToday ? 'Added to Today' : 'Add to Today') + '</button></div>';
|
||
const planningActions = selectedWorkFilter === 'later' ? laterActions :
|
||
'<details class="card-planning" data-card-planning><summary aria-expanded="false">Plan or defer</summary><div class="card-planning-actions">' + todayActions + laterActions + '</div></details>';
|
||
if (item.is_review) {
|
||
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||
}
|
||
if (item.kind === 'issue') {
|
||
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||
}
|
||
if (item.kind === 'pull') {
|
||
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||
}
|
||
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + planningActions + '</article>';
|
||
}).join('') : '<div class="muted">' + (incomplete ?
|
||
'More work is available. Load the next page.' :
|
||
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>';
|
||
cardPlanning.wire();
|
||
document.querySelectorAll('[data-select-notification-id]').forEach(input => {
|
||
input.addEventListener('change', () => {
|
||
const notificationId = Number(input.dataset.selectNotificationId);
|
||
const result = input.checked ? notificationSelection.select(notificationId) :
|
||
notificationSelection.toggle(notificationId);
|
||
if (result === 'limit') {
|
||
input.checked = false;
|
||
qs('#my-work-action-status').textContent = 'Select up to 50 updates per batch.';
|
||
}
|
||
});
|
||
});
|
||
document.querySelectorAll('[data-select-work-id]').forEach(input => {
|
||
input.addEventListener('change', () => {
|
||
const item = lastMyWork[Number(input.dataset.workIndex)];
|
||
const result = input.checked ? workSelection.select(item) : workSelection.toggle(item);
|
||
if (result === 'limit') {
|
||
input.checked = false;
|
||
qs('#my-work-action-status').textContent = 'Select up to 50 work items per batch.';
|
||
}
|
||
});
|
||
});
|
||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
||
});
|
||
document.querySelectorAll('[data-issue-index]').forEach(button => {
|
||
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.issueIndex)], button); });
|
||
});
|
||
document.querySelectorAll('[data-pull-index]').forEach(button => {
|
||
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.pullIndex)], button); });
|
||
});
|
||
document.querySelectorAll('[data-update-index]').forEach(button => {
|
||
button.addEventListener('click', event => {
|
||
event.preventDefault();
|
||
const item = lastMyWork[Number(button.dataset.updateIndex)];
|
||
if (!item) return;
|
||
updateTrigger = button;
|
||
openRoutedWork(item, button);
|
||
});
|
||
});
|
||
document.querySelectorAll('[data-notification-id]').forEach(button => {
|
||
button.addEventListener('click', async () => {
|
||
const notificationId = Number(button.dataset.notificationId);
|
||
button.disabled = true;
|
||
const acknowledged = await notificationAcknowledger.acknowledge(lastMyWork, notificationId);
|
||
if (acknowledged) {
|
||
lastNotifications = lastNotifications.filter(item => item.id !== notificationId);
|
||
(document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus();
|
||
} else {
|
||
document.querySelector('[data-notification-id="' + notificationId + '"]')?.focus();
|
||
}
|
||
});
|
||
});
|
||
document.querySelectorAll('[data-later-preset]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||
if (!item) return;
|
||
if (!planningOwnerLogin) {
|
||
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
||
return;
|
||
}
|
||
const until = laterWork.presetUntil(button.dataset.laterPreset);
|
||
const result = laterWork.defer(item, until);
|
||
qs('#my-work-action-status').textContent = result === 'deferred' ?
|
||
'Deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
|
||
(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
|
||
if (result !== 'deferred') return;
|
||
refreshMyWorkView();
|
||
});
|
||
});
|
||
document.querySelectorAll('[data-later-custom]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||
if (!item) return;
|
||
if (!planningOwnerLogin) {
|
||
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
||
return;
|
||
}
|
||
laterPicker.open(item, button, 'card');
|
||
});
|
||
});
|
||
document.querySelectorAll('[data-later-restore]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||
if (!item || !laterWork.restore(item)) return;
|
||
qs('#my-work-action-status').textContent = 'Work returned to its priority position.';
|
||
refreshMyWorkView();
|
||
});
|
||
});
|
||
document.querySelectorAll('[data-later-start]').forEach(button => {
|
||
button.addEventListener('click', async () => {
|
||
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||
if (!item) return;
|
||
button.disabled = true;
|
||
await laterAndStart.start(item);
|
||
if (button.isConnected) button.disabled = false;
|
||
});
|
||
});
|
||
document.querySelectorAll('[data-today-add]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
if (!planningOwnerLogin) {
|
||
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
||
return;
|
||
}
|
||
const result = todayWork.add(lastMyWork[Number(button.dataset.workIndex)]);
|
||
qs('#my-work-action-status').textContent = result === 'full' ?
|
||
'Today is limited to 5 items. Remove one before adding more.' :
|
||
(result === 'added' ? 'Added to Today without changing Gitea.' :
|
||
(result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.'));
|
||
refreshMyWorkView();
|
||
if (result === 'added') {
|
||
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||
todaySync.enqueue('add', todayWork.identity(item));
|
||
todaySync.flush();
|
||
warmTodayOffline();
|
||
}
|
||
});
|
||
});
|
||
document.querySelectorAll('[data-today-remove]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||
if (todayWork.remove(item)) {
|
||
todaySync.enqueue('remove', todayWork.identity(item));
|
||
todaySync.flush();
|
||
}
|
||
qs('#my-work-action-status').textContent = 'Removed from Today without changing Gitea.';
|
||
refreshMyWorkView();
|
||
});
|
||
});
|
||
document.querySelectorAll('[data-today-move]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||
if (todayWork.move(item, button.dataset.todayMove)) {
|
||
todaySync.enqueue('move', todayWork.identity(item), button.dataset.todayMove);
|
||
todaySync.flush();
|
||
}
|
||
refreshMyWorkView();
|
||
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus();
|
||
});
|
||
});
|
||
const bulkBar = qs('#bulk-mark-read-bar');
|
||
const bulkButton = qs('#bulk-mark-read');
|
||
|
||
const updateIds = notificationIds(visible);
|
||
const selectionControls = qs('#update-selection-controls');
|
||
const planningQueue = !['today', 'later', 'draft'].includes(selectedWorkFilter) && visible.length > 0;
|
||
selectionControls.hidden = !planningQueue && (selectedWorkFilter !== 'update' || updateIds.length === 0);
|
||
qs('#select-work').hidden = !planningQueue || workSelectionState.active || selection.active;
|
||
qs('#cancel-work-selection').hidden = !workSelectionState.active;
|
||
const selectionScopeActions = qs('#selection-scope-actions');
|
||
selectionScopeActions.hidden = !workSelectionState.active;
|
||
qs('#select-matching-work').disabled = visible.length === 0 || workSelectionState.count >= workSelection.limit;
|
||
qs('#clear-work-selection').disabled = workSelectionState.count === 0;
|
||
qs('#select-matching-work').onclick = () => {
|
||
const result = workSelection.selectMany(visible);
|
||
qs('#my-work-action-status').textContent = result.status === 'limit' ?
|
||
result.count + ' matches selected. Selection is capped at ' + result.limit + ' items.' :
|
||
result.count + ' matches selected from the current queue and search.';
|
||
};
|
||
qs('#clear-work-selection').onclick = () => {
|
||
workSelection.clear();
|
||
qs('#my-work-action-status').textContent = 'Selection cleared. Choose new matches or individual work.';
|
||
qs('#select-matching-work').focus();
|
||
};
|
||
qs('#select-updates').hidden = selectedWorkFilter !== 'update' || selection.active || workSelectionState.active;
|
||
qs('#cancel-update-selection').hidden = !selection.active;
|
||
qs('#update-selection-status').textContent = workSelectionState.active ?
|
||
workSelectionState.count + ' selected' : (selection.active ?
|
||
selection.count + ' of 50 selected' : 'Choose work to plan together or specific updates to mark read.');
|
||
bulkBar.hidden = !workSelectionState.active && (selectedWorkFilter !== 'update' || !selection.active);
|
||
qs('#load-more-notifications').hidden =
|
||
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
|
||
bulkButton.disabled = bulkMarkPending || selection.count === 0;
|
||
document.querySelectorAll('[data-defer-selected]').forEach(button => {
|
||
button.hidden = workSelectionState.active;
|
||
button.disabled = bulkMarkPending || selection.count === 0 || !planningOwnerLogin;
|
||
});
|
||
qs('#batch-add-today').hidden = !workSelectionState.active;
|
||
qs('#batch-add-today').disabled = workSelectionState.count === 0 || !planningOwnerLogin;
|
||
document.querySelectorAll('[data-batch-defer]').forEach(button => {
|
||
button.hidden = !workSelectionState.active;
|
||
button.disabled = workSelectionState.count === 0 || !planningOwnerLogin;
|
||
});
|
||
bulkButton.hidden = workSelectionState.active;
|
||
bulkButton.textContent = bulkConfirmationPending ?
|
||
'Confirm marking ' + selection.count + ' selected read' :
|
||
'Mark ' + selection.count + ' selected read';
|
||
workRoute.setItems(lastMyWork);
|
||
}
|
||
|
||
function reviewFileElement(filename) {
|
||
return Array.from(document.querySelectorAll('.review-file')).find(
|
||
element => element.dataset.reviewFilename === filename
|
||
);
|
||
}
|
||
|
||
function applyReviewWrap(snapshot = wrapPreference.snapshot()) {
|
||
const reviewFilesElement = qs('#review-files');
|
||
const button = qs('#review-wrap-lines');
|
||
reviewFilesElement.classList.toggle('wrap-lines', snapshot.wrapped);
|
||
button.setAttribute('aria-pressed', String(snapshot.wrapped));
|
||
button.textContent = snapshot.wrapped ? 'Lines wrapped' : 'Wrap lines';
|
||
}
|
||
|
||
function showReviewProgress(snapshot) {
|
||
const complete = snapshot.total > 0 && snapshot.reviewedCount === snapshot.total;
|
||
qs('#review-progress').textContent = complete ?
|
||
'All files reviewed — finish in Gitea' :
|
||
(snapshot.newHead ? 'New commits detected · ' : '') + snapshot.reviewedCount + ' of ' + snapshot.total + ' files reviewed';
|
||
qs('#next-unreviewed-review').disabled = !snapshot.nextFilename;
|
||
document.querySelectorAll('.review-mark').forEach(button => {
|
||
const reviewed = snapshot.reviewed.includes(button.dataset.reviewFilename);
|
||
button.setAttribute('aria-pressed', String(reviewed));
|
||
button.textContent = reviewed ? 'Reviewed' : 'Mark reviewed';
|
||
button.closest('.review-file')?.classList.toggle('reviewed', reviewed);
|
||
});
|
||
}
|
||
|
||
function openNextUnreviewed(snapshot) {
|
||
if (!snapshot?.nextFilename) return;
|
||
const file = reviewFileElement(snapshot.nextFilename);
|
||
if (!file) return;
|
||
const toggle = file.querySelector('.review-file-toggle');
|
||
const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls'));
|
||
if (toggle && panel && toggle.getAttribute('aria-expanded') !== 'true') {
|
||
createReviewController.toggleDiff(toggle, panel);
|
||
}
|
||
file.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
toggle?.focus();
|
||
}
|
||
|
||
function renderIssueComment(comment) {
|
||
const actions = commentActions.actionHtml?.(comment) || '';
|
||
return '<div class="issue-comment" data-comment-id="' + Number(comment.id || 0) + '"><div class="small">' +
|
||
escapeHtml(comment.author || 'Unknown author') +
|
||
(comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') +
|
||
'</div>' + actions + '<div class="issue-sheet-content markdown-content">' +
|
||
renderMarkdown(comment.body || 'No comment body provided.') + '</div></div>';
|
||
}
|
||
|
||
function renderIssueConversation(state) {
|
||
const comments = state?.comments || [];
|
||
qs('#issue-comments').innerHTML = comments.length ?
|
||
comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
|
||
qs('#load-older-issue-comments').hidden = !Number.isInteger(state?.older_page);
|
||
qs('#issue-conversation-status').textContent = comments.length ?
|
||
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
|
||
}
|
||
|
||
function renderUpdateConversation(state) {
|
||
const comments = state?.comments || [];
|
||
qs('#update-comments').innerHTML = comments.length ?
|
||
comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
|
||
qs('#load-older-update-comments').hidden = !Number.isInteger(state?.older_page);
|
||
qs('#update-conversation-status').textContent = comments.length ?
|
||
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
|
||
}
|
||
|
||
function renderPullConversation(state) {
|
||
const comments = state?.comments || [];
|
||
qs('#pull-comments').innerHTML = comments.length ? comments.map(comment =>
|
||
'<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>'
|
||
).join('') : '<div class="muted">No comments yet.</div>';
|
||
qs('#load-older-pull-comments').hidden = !Number.isInteger(state?.older_page);
|
||
qs('#pull-conversation-status').textContent = comments.length ?
|
||
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
|
||
}
|
||
|
||
function commentSurface(selector) {
|
||
if (selector === '#issue-comments') return {
|
||
context:{kind:'issue',item:selectedIssue}, pager:issueConversation,
|
||
render:renderIssueConversation, status:qs('#issue-sheet-status'),
|
||
};
|
||
if (selector === '#pull-comments') return {
|
||
context:{kind:'pull',item:selectedPull}, pager:pullConversation,
|
||
render:renderPullConversation, status:qs('#pull-sheet-status'),
|
||
};
|
||
return {
|
||
context:{kind:'update',item:selectedUpdate}, pager:notificationReader.commentPager(),
|
||
render:renderUpdateConversation, status:qs('#update-sheet-status'),
|
||
};
|
||
}
|
||
|
||
function wireCommentActions(selector) {
|
||
commentActions.wire({
|
||
root:qs(selector), getSurface:()=>commentSurface(selector),
|
||
isOffline:()=>offlineWorkMode || navigator.onLine === false, escapeHtml,
|
||
});
|
||
}
|
||
|
||
wireCommentActions('#issue-comments');
|
||
wireCommentActions('#pull-comments');
|
||
wireCommentActions('#update-comments');
|
||
|
||
function renderIssueLabelEditor(item, confirmedNames, labels) {
|
||
const list = qs('#issue-label-list');
|
||
const status = qs('#issue-label-status');
|
||
list.textContent = '';
|
||
const draftIds = issueController.loadLabelDraft(item);
|
||
const selectedIds = draftIds.length ? new Set(draftIds) : new Set(
|
||
labels.filter(label => confirmedNames.includes(label.name)).map(label => Number(label.id))
|
||
);
|
||
list.innerHTML = labels.map(label =>
|
||
'<label class="issue-label-option"><input type="checkbox" name="issue-label" value="' +
|
||
Number(label.id) + '"' + (selectedIds.has(Number(label.id)) ? ' checked' : '') + '><span>' +
|
||
escapeHtml(label.name) + '</span></label>'
|
||
).join('');
|
||
status.textContent = labels.length ? 'Choose labels, then save.' : 'This repository has no labels.';
|
||
qs('#save-issue-labels').disabled = false;
|
||
}
|
||
|
||
function selectedEditIssueLabelIds() {
|
||
return Array.from(document.querySelectorAll('input[name="issue-label"]:checked'))
|
||
.map(input => Number(input.value)).filter(Number.isInteger);
|
||
}
|
||
|
||
function renderIssueMilestoneEditor(item, confirmedMilestone, milestones) {
|
||
const select = qs('#issue-milestone');
|
||
const status = qs('#issue-milestone-status');
|
||
select.innerHTML = '<option value="">No milestone</option>';
|
||
select.innerHTML += milestones.map(milestone =>
|
||
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
|
||
).join('');
|
||
const draft = issueController.loadMilestoneDraft(item);
|
||
select.value = String(draft ?? confirmedMilestone?.id ?? '');
|
||
select.disabled = false;
|
||
qs('#save-issue-milestone').disabled = false;
|
||
status.textContent = confirmedMilestone ?
|
||
'Planned for ' + confirmedMilestone.title + '.' : 'No milestone set.';
|
||
}
|
||
|
||
async function loadIssuePlanning() {
|
||
const item = selectedIssue;
|
||
const detail = selectedIssueDetail;
|
||
if (!item || !detail) return;
|
||
qs('#retry-issue-planning').hidden = true;
|
||
qs('#issue-label-status').textContent = 'Loading labels…';
|
||
qs('#issue-milestone-status').textContent = 'Loading milestones…';
|
||
try {
|
||
const planning = await planningLoader.open(selectedIssue);
|
||
if (selectedIssue !== item) return;
|
||
renderIssueLabelEditor(item, detail.labels || [], planning.labels);
|
||
renderIssueMilestoneEditor(item, detail.milestone, planning.milestones);
|
||
} catch (_error) {
|
||
if (selectedIssue !== item) return;
|
||
qs('#issue-label-status').textContent = 'Labels could not be loaded.';
|
||
qs('#issue-milestone-status').textContent = 'Milestones could not be loaded.';
|
||
qs('#retry-issue-planning').hidden = false;
|
||
}
|
||
}
|
||
|
||
async function openIssueSheet(item, trigger, offlineDetail = null) {
|
||
if (!item) return;
|
||
qs('#issue-planning').inert = false;
|
||
qs('#issue-handoff').inert = false;
|
||
selectedIssue = item;
|
||
issueMentions.dismiss();
|
||
selectedIssueOffline = Boolean(offlineDetail);
|
||
selectedIssueDetail = null;
|
||
issueConversation = null;
|
||
issueTrigger = trigger;
|
||
qs('#issue-sheet').classList.add('open');
|
||
qs('#issue-sheet-key').textContent = item.key || '';
|
||
qs('#issue-sheet-title').textContent = item.title || 'Assigned issue';
|
||
qs('#issue-sheet-status').textContent = 'Loading issue…';
|
||
qs('#issue-sheet-body').textContent = '';
|
||
qs('#issue-labels').textContent = '';
|
||
qs('#issue-assignees').textContent = '';
|
||
qs('#issue-comments').textContent = '';
|
||
qs('#load-older-issue-comments').hidden = true;
|
||
qs('#issue-conversation-status').textContent = 'Loading newest messages…';
|
||
qs('#issue-comment').value = issueController.loadDraft(item);
|
||
qs('#issue-comment-status').textContent = '';
|
||
qs('#issue-handoff').open = false;
|
||
qs('#issue-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
|
||
qs('#issue-handoff-recipient').disabled = true;
|
||
qs('#confirm-issue-handoff').disabled = true;
|
||
qs('#confirm-issue-handoff').textContent = workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff';
|
||
qs('#load-issue-handoff').disabled = false;
|
||
qs('#issue-handoff-status').textContent = 'Load teammates to transfer ownership.';
|
||
qs('#issue-planning').open = false;
|
||
qs('#retry-issue-planning').hidden = true;
|
||
qs('#issue-label-list').textContent = '';
|
||
qs('#issue-label-status').textContent = 'Expand planning controls to load labels.';
|
||
qs('#save-issue-labels').disabled = true;
|
||
qs('#issue-due-date').value = '';
|
||
qs('#issue-due-date').disabled = true;
|
||
qs('#save-issue-due-date').disabled = true;
|
||
qs('#clear-issue-due-date').disabled = true;
|
||
qs('#issue-due-status').textContent = 'Loading due date…';
|
||
qs('#issue-milestone').innerHTML = '<option value="">No milestone</option>';
|
||
qs('#issue-milestone').disabled = true;
|
||
qs('#save-issue-milestone').disabled = true;
|
||
qs('#issue-milestone-status').textContent = 'Expand planning controls to load milestones.';
|
||
qs('#retry-issue-load').hidden = true;
|
||
qs('#open-issue-gitea').href = item.url || '#';
|
||
qs('#send-issue-comment').disabled = false;
|
||
setCommentNextVisibility('issue');
|
||
qs('#edit-issue-content').disabled = true;
|
||
qs('#issue-edit-form').hidden = true;
|
||
qs('#issue-edit-status').textContent = '';
|
||
qs('#close-issue').disabled = false;
|
||
qs('#close-issue').textContent = offlineDetail ?
|
||
(workSession.active() ? 'Queue close & next' : 'Queue issue closure') :
|
||
(workSession.active() ? 'Close & next' : 'Close issue');
|
||
qs('#release-issue').textContent = workSession.checkpointed(item) ? 'Release & next' : 'Release assignment';
|
||
qs('#close-issue-sheet').focus();
|
||
try {
|
||
const detail = offlineDetail || await issueController.load(item);
|
||
if (selectedIssue !== item) return;
|
||
selectedIssueDetail = detail;
|
||
renderPlanIssueDependencies(detail);
|
||
issueConversation = issueController.conversation(item, detail.conversation);
|
||
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
|
||
qs('#issue-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
|
||
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
|
||
'<span class="pill">' + escapeHtml(label) + '</span>'
|
||
).join(' ');
|
||
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
|
||
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
|
||
renderIssueConversation(issueConversation.snapshot());
|
||
qs('#open-issue-gitea').href = detail.url || item.url || '#';
|
||
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
|
||
qs('#edit-issue-content').disabled = false;
|
||
const dueDraft = issueController.loadDueDateDraft(item);
|
||
qs('#issue-due-date').value = String(dueDraft || detail.due_date || '').slice(0, 10);
|
||
qs('#issue-due-date').disabled = false;
|
||
qs('#save-issue-due-date').disabled = false;
|
||
qs('#clear-issue-due-date').disabled = !detail.due_date;
|
||
qs('#issue-due-status').textContent = detail.due_date ?
|
||
'Due ' + new Date(detail.due_date).toLocaleDateString() : 'No due date set.';
|
||
if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning();
|
||
if (offlineDetail) {
|
||
setOfflineDetailControls('issue');
|
||
qs('#issue-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
|
||
' · comments queue for sync';
|
||
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
|
||
await offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
|
||
}
|
||
} catch (error) {
|
||
if (selectedIssue !== item) return;
|
||
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
|
||
qs('#retry-issue-load').hidden = false;
|
||
qs('#retry-issue-load').focus();
|
||
}
|
||
}
|
||
|
||
function closeIssueSheet(navigate = true) {
|
||
if (navigate && createWorkRoute.parse(window.location.hash)) {
|
||
workRoute.close();
|
||
return;
|
||
}
|
||
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
|
||
issueAttachmentController.clear();
|
||
qs('#issue-sheet').classList.remove('open');
|
||
selectedIssue = null;
|
||
selectedIssueOffline = false;
|
||
selectedIssueDetail = null;
|
||
issueConversation = null;
|
||
if (issueTrigger?.isConnected) issueTrigger.focus();
|
||
}
|
||
|
||
function renderCheckSection(prefix, detail, offline = false) {
|
||
const rendered = createReviewController.renderChecks(detail?.checks, escapeHtml, { offline });
|
||
qs('#' + prefix + '-checks-summary').textContent = rendered.summary;
|
||
qs('#' + prefix + '-check-list').innerHTML = rendered.html || '<div class="muted">No individual checks were reported.</div>';
|
||
qs('#' + prefix + '-checks').open = rendered.expanded;
|
||
}
|
||
|
||
function renderPullReview(detail, focusFilename = null) {
|
||
renderCheckSection('pull', detail);
|
||
pullReviewState = pullController.reviewState(selectedPull, detail);
|
||
qs('#pull-review-progress').textContent = pullReviewState.total ?
|
||
pullReviewState.reviewed.length + ' of ' + pullReviewState.total + ' files reviewed' : 'No changed files to review';
|
||
qs('#next-unreviewed-pull-file').disabled = pullReviewState.complete;
|
||
const eligibility = createPullSheet.mergeEligibility(detail, pullReviewState);
|
||
qs('#pull-merge-state').textContent = eligibility.reason;
|
||
qs('#merge-pull').disabled = !eligibility.allowed;
|
||
qs('#pull-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
|
||
createPullSheet.renderFile(file, index, pullReviewState.reviewed.includes(file.filename), escapeHtml)
|
||
).join('') : '<div class="muted">No changed files reported.</div>';
|
||
qs('#pull-files').querySelectorAll('.pull-file-toggle').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const panel = document.getElementById(button.getAttribute('aria-controls'));
|
||
const expanded = button.getAttribute('aria-expanded') === 'true';
|
||
button.setAttribute('aria-expanded', String(!expanded));
|
||
if (panel) panel.hidden = expanded;
|
||
});
|
||
});
|
||
qs('#pull-files').querySelectorAll('.pull-review-file').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const filename = button.dataset.pullReviewFile;
|
||
pullReviewState = pullController.toggleReviewed(selectedPull, detail, filename);
|
||
renderPullReview(detail, filename);
|
||
});
|
||
});
|
||
if (focusFilename) {
|
||
Array.from(qs('#pull-files').querySelectorAll('.pull-review-file'))
|
||
.find(button => button.dataset.pullReviewFile === focusFilename)?.focus();
|
||
}
|
||
}
|
||
|
||
function renderPullCheckStatus(status) {
|
||
qs('#pull-ci-state').textContent = 'CI ' + (status.ci_state || 'unknown');
|
||
renderCheckSection('pull', status);
|
||
if (status.head_sha !== selectedPullDetail?.head_sha) {
|
||
qs('#pull-review-status').textContent = 'New commits detected. Reload review data before merging.';
|
||
qs('#pull-merge-state').textContent = 'Review data is stale';
|
||
qs('#merge-pull').disabled = true;
|
||
return;
|
||
}
|
||
selectedPullDetail = { ...selectedPullDetail, ...status };
|
||
const eligibility = createPullSheet.mergeEligibility(selectedPullDetail, pullReviewState);
|
||
qs('#pull-merge-state').textContent = eligibility.reason;
|
||
qs('#merge-pull').disabled = !eligibility.allowed;
|
||
qs('#pull-review-status').textContent = 'Checks refreshed for the current head.';
|
||
}
|
||
|
||
async function loadPullReview({ refresh = false } = {}) {
|
||
if (!selectedPull || !selectedPullDetail?.head_sha) return;
|
||
const item = selectedPull;
|
||
const readDetail = selectedPullDetail;
|
||
qs('#pull-review-status').textContent = 'Loading changed files and merge readiness…';
|
||
qs('#pull-review-retry').hidden = true;
|
||
qs('#merge-pull').disabled = true;
|
||
try {
|
||
const review = await pullController.loadReview(item, readDetail.head_sha, { refresh });
|
||
if (selectedPull !== item) return;
|
||
selectedPullDetail = { ...readDetail, ...review };
|
||
qs('#pull-ci-state').textContent = 'CI ' + (review.ci_state || 'unknown');
|
||
renderPullReview(selectedPullDetail);
|
||
qs('#pull-review-status').textContent = review.head_sha === readDetail.head_sha ?
|
||
'Review data ready for the current head.' : 'New commits detected. Review progress restarted for the latest head.';
|
||
} catch (error) {
|
||
if (selectedPull !== item) return;
|
||
qs('#pull-review-status').textContent = error.message + ' Reading and replies remain available; retry here.';
|
||
qs('#pull-review-retry').hidden = false;
|
||
qs('#pull-merge-state').textContent = 'Review data unavailable';
|
||
qs('#merge-pull').disabled = true;
|
||
}
|
||
}
|
||
|
||
async function openPullSheet(item, trigger, offlineDetail = null) {
|
||
if (!item) return;
|
||
if (!await ensurePullWorkflow(trigger)) return;
|
||
if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear();
|
||
qs('#pull-review').inert = false;
|
||
qs('#pull-ownership').inert = false;
|
||
selectedPull = item;
|
||
pullMentions.dismiss();
|
||
pullTrigger = trigger;
|
||
selectedPullDetail = null;
|
||
pullConversation = null;
|
||
pullReviewState = null;
|
||
qs('#pull-sheet').classList.add('open');
|
||
qs('#pull-sheet-key').textContent = item.key || '';
|
||
qs('#pull-sheet-title').textContent = item.title || 'Assigned pull request';
|
||
qs('#pull-sheet-status').textContent = 'Loading pull request…';
|
||
qs('#pull-sheet-body').textContent = '';
|
||
qs('#pull-files').textContent = '';
|
||
qs('#pull-review').open = false;
|
||
qs('#pull-review-status').textContent = 'Expand to load changed files and merge readiness.';
|
||
qs('#pull-review-retry').hidden = true;
|
||
qs('#pull-review-progress').textContent = 'Review progress unavailable.';
|
||
qs('#next-unreviewed-pull-file').disabled = true;
|
||
qs('#pull-comments').textContent = '';
|
||
qs('#load-older-pull-comments').hidden = true;
|
||
qs('#pull-conversation-status').textContent = 'Loading newest messages…';
|
||
qs('#pull-comment').value = pullController.loadDraft(item);
|
||
qs('#pull-comment-status').textContent = '';
|
||
qs('#pull-ci-state').textContent = 'CI unknown';
|
||
qs('#pull-checks-summary').textContent = 'Not loaded';
|
||
qs('#pull-check-list').textContent = '';
|
||
qs('#pull-checks').open = false;
|
||
qs('#pull-merge-state').textContent = 'Review data not loaded';
|
||
qs('#merge-pull').disabled = true;
|
||
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
|
||
createPullSheet.resetOwnershipControls(document, item, candidate => workSession.checkpointed(candidate));
|
||
qs('#retry-pull-load').hidden = true;
|
||
qs('#open-pull-gitea').href = item.url || '#';
|
||
setCommentNextVisibility('pull');
|
||
qs('#close-pull-sheet').focus();
|
||
try {
|
||
const detail = offlineDetail || await pullController.load(item);
|
||
if (selectedPull !== item) return;
|
||
selectedPullDetail = detail;
|
||
pullConversation = pullController.conversation(item, detail.conversation);
|
||
qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request';
|
||
qs('#pull-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
|
||
renderPullConversation(pullConversation.snapshot());
|
||
qs('#open-pull-gitea').href = detail.url || item.url || '#';
|
||
qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author');
|
||
if (offlineDetail) {
|
||
setOfflineDetailControls('pull');
|
||
qs('#pull-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
|
||
' · comments queue for sync';
|
||
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
|
||
await offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
|
||
}
|
||
} catch (error) {
|
||
if (selectedPull !== item) return;
|
||
qs('#pull-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
|
||
qs('#retry-pull-load').hidden = false;
|
||
qs('#retry-pull-load').focus();
|
||
}
|
||
}
|
||
|
||
function closePullSheet(navigate = true) {
|
||
if (navigate && createWorkRoute.parse(window.location.hash)) {
|
||
workRoute.close();
|
||
return;
|
||
}
|
||
mobileComposerViewport.close(qs('#pull-sheet .pull-sheet-panel'));
|
||
pullAttachmentController.clear();
|
||
qs('#pull-sheet').classList.remove('open');
|
||
selectedPull = null;
|
||
selectedPullDetail = null;
|
||
pullConversation = null;
|
||
pullReviewState = null;
|
||
if (pullTrigger?.isConnected) pullTrigger.focus();
|
||
}
|
||
|
||
function selectedIssueLabelIds() {
|
||
return Array.from(document.querySelectorAll('input[name="create-issue-label"]:checked'))
|
||
.map(input => Number(input.value)).filter(Number.isInteger);
|
||
}
|
||
|
||
function renderAvailableIssues(items) {
|
||
const list = qs('#find-work-list');
|
||
list.innerHTML = items.length ? items.map((item, index) => {
|
||
const expanded = findWorkController.isPreviewed(item);
|
||
const detailId = 'find-work-detail-' + index;
|
||
const detail = '<div id="' + detailId + '" class="find-work-detail"' + (expanded ? '' : ' hidden') +
|
||
'><div class="find-work-description markdown-content">' + renderMarkdown(item.body || 'No description provided.') + '</div>' +
|
||
(item.url ? '<a href="' + escapeHtml(item.url) + '" target="_blank" rel="noopener noreferrer">Open in Gitea</a>' : '') +
|
||
'</div>';
|
||
return '<article class="find-work-card"><div class="small">' + escapeHtml(item.repository) + '#' +
|
||
Number(item.number) + '</div><strong>' + escapeHtml(item.title || 'Untitled issue') + '</strong>' +
|
||
'<div>' + (item.labels || []).map(label => '<span class="pill">' + escapeHtml(label) + '</span>').join(' ') +
|
||
'</div><button type="button" data-preview-index="' + index + '" aria-expanded="' + expanded +
|
||
'" aria-controls="' + detailId + '">' + (expanded ? 'Hide details' : 'View details') + '</button>' +
|
||
detail + '<div class="find-work-claim-actions"><button type="button" data-claim-index="' + index +
|
||
'">Assign</button><button type="button" data-claim-queue-index="' + index +
|
||
'">Queue Today</button><button type="button" data-claim-start-index="' + index +
|
||
'">Start now</button></div></article>';
|
||
}).join('') : '<div class="muted">No unassigned issues are available on this page.</div>';
|
||
list.querySelectorAll('[data-preview-index]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const item = findWorkController.items()[Number(button.dataset.previewIndex)];
|
||
if (!item) return;
|
||
const expanded = findWorkController.togglePreview(item);
|
||
const detail = document.getElementById(button.getAttribute('aria-controls'));
|
||
button.setAttribute('aria-expanded', String(expanded));
|
||
button.textContent = expanded ? 'Hide details' : 'View details';
|
||
if (detail) detail.hidden = !expanded;
|
||
});
|
||
});
|
||
list.querySelectorAll('[data-claim-index]').forEach(button => {
|
||
button.addEventListener('click', async () => {
|
||
const item = findWorkController.items()[Number(button.dataset.claimIndex)];
|
||
if (!item) return;
|
||
button.disabled = true;
|
||
try {
|
||
const confirmed = await findWorkController.claim(item);
|
||
const claimed = acceptClaimedIssue(confirmed);
|
||
taskOverlayHistory.leave();
|
||
refreshMyWorkView();
|
||
qs('#my-work-action-status').textContent = confirmed.repository + '#' + confirmed.number + ' assigned to you.';
|
||
openRoutedWork(claimed, qs('#find-work'));
|
||
} catch (error) {
|
||
qs('#find-work-status').textContent = error.message + ' Refresh and retry.';
|
||
button.disabled = false;
|
||
button.focus();
|
||
}
|
||
});
|
||
});
|
||
list.querySelectorAll('[data-claim-queue-index]').forEach(button => {
|
||
button.addEventListener('click', async () => {
|
||
const item = findWorkController.items()[Number(button.dataset.claimQueueIndex)];
|
||
if (!item) return;
|
||
const claimButtons = button.closest('.find-work-card')
|
||
.querySelectorAll('[data-claim-index], [data-claim-queue-index], [data-claim-start-index]');
|
||
claimButtons.forEach(action => { action.disabled = true; });
|
||
try {
|
||
const outcome = await assignAndStart.run(item, { destination:'queue' });
|
||
if (outcome === 'queued') {
|
||
(qs('[data-claim-queue-index]') || qs('#close-find-work')).focus();
|
||
}
|
||
} catch (error) {
|
||
qs('#find-work-status').textContent = error.message + ' Nothing was added to Today; retry assignment.';
|
||
claimButtons.forEach(action => { action.disabled = false; });
|
||
button.focus();
|
||
}
|
||
});
|
||
});
|
||
list.querySelectorAll('[data-claim-start-index]').forEach(button => {
|
||
button.addEventListener('click', async () => {
|
||
const item = findWorkController.items()[Number(button.dataset.claimStartIndex)];
|
||
if (!item) return;
|
||
const claimButtons = button.closest('.find-work-card')
|
||
.querySelectorAll('[data-claim-index], [data-claim-queue-index], [data-claim-start-index]');
|
||
claimButtons.forEach(action => { action.disabled = true; });
|
||
try {
|
||
await assignAndStart.run(item);
|
||
} catch (error) {
|
||
qs('#find-work-status').textContent = error.message + ' Nothing was added to Today; retry assignment.';
|
||
claimButtons.forEach(action => { action.disabled = false; });
|
||
button.focus();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function openFindWorkSheet(navigate = true) {
|
||
if (navigate) {
|
||
taskOverlayHistory.open('find');
|
||
return;
|
||
}
|
||
findingWork = true;
|
||
qs('#find-work-sheet').classList.add('open');
|
||
const retainedItems = findWorkController.items();
|
||
if (retainedItems.length) renderAvailableIssues(retainedItems);
|
||
else qs('#find-work-list').textContent = '';
|
||
qs('#find-work-status').textContent = retainedItems.length ?
|
||
'Refreshing available issues…' : 'Loading available issues…';
|
||
qs('#load-more-available').hidden = true;
|
||
qs('#close-find-work').focus();
|
||
try {
|
||
const result = await findWorkController.load();
|
||
if (!result?.stale) {
|
||
qs('#find-work-status').textContent = findWorkController.items().length ?
|
||
findWorkController.items().length + ' of ' + availablePagination.total + ' available issues loaded.' :
|
||
'No unassigned issues are available.';
|
||
}
|
||
} catch (error) {
|
||
qs('#find-work-status').textContent = error.message + ' Close and retry.';
|
||
}
|
||
}
|
||
|
||
function closeFindWorkSheet(navigate = true) {
|
||
if (navigate && taskOverlayHistory.current() === 'find') {
|
||
taskOverlayHistory.close();
|
||
return;
|
||
}
|
||
findingWork = false;
|
||
qs('#find-work-sheet').classList.remove('open');
|
||
qs('#find-work').focus();
|
||
}
|
||
|
||
function saveIssueCaptureDraft() {
|
||
if (!issueCapture) return;
|
||
issueCapture.saveDraft({
|
||
repository: qs('#create-issue-repository').value,
|
||
title: qs('#create-issue-title').value,
|
||
body: qs('#create-issue-body').value,
|
||
labelIds: selectedIssueLabelIds(),
|
||
milestoneId: Number(qs('#create-issue-milestone').value) || null,
|
||
dueDate: qs('#create-issue-due-date').value,
|
||
});
|
||
}
|
||
|
||
function currentIssueCaptureDraft() {
|
||
return {
|
||
repository: qs('#create-issue-repository').value,
|
||
title: qs('#create-issue-title').value.trim(),
|
||
body: qs('#create-issue-body').value.trim(),
|
||
labelIds: selectedIssueLabelIds(),
|
||
milestoneId: Number(qs('#create-issue-milestone').value) || null,
|
||
dueDate: qs('#create-issue-due-date').value,
|
||
};
|
||
}
|
||
|
||
let issueCaptureRepositories = [];
|
||
let nextIssueRepositoryPage = 2;
|
||
let moreIssueRepositoriesAvailable = false;
|
||
let issueRepositorySearchTimer = null;
|
||
|
||
function appendIssueRepositories(items) {
|
||
const select = qs('#create-issue-repository');
|
||
const selected = select.value;
|
||
const known = new Set(issueCaptureRepositories);
|
||
(Array.isArray(items) ? items : []).forEach(item => {
|
||
const repository = String(item?.full_name || '').trim();
|
||
if (!repository || known.has(repository)) return;
|
||
known.add(repository);
|
||
issueCaptureRepositories.push(repository);
|
||
const option = document.createElement('option');
|
||
option.value = repository;
|
||
option.textContent = repository;
|
||
select.appendChild(option);
|
||
});
|
||
if (selected) select.value = selected;
|
||
}
|
||
|
||
function updateIssueCreateActions() {
|
||
const hasRepository = Boolean(qs('#create-issue-repository').value);
|
||
qs('#submit-new-issue').disabled = !hasRepository;
|
||
qs('#create-and-start-issue').disabled = !hasRepository || !createAndStart.available();
|
||
}
|
||
|
||
function renderIssueRepositoryResults(items) {
|
||
const results = qs('#create-issue-repository-results');
|
||
results.replaceChildren();
|
||
(Array.isArray(items) ? items : []).forEach(item => {
|
||
const repository = String(item?.full_name || '').trim();
|
||
if (!repository) return;
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = 'create-issue-repository-result';
|
||
button.setAttribute('role', 'option');
|
||
button.textContent = repository;
|
||
button.addEventListener('click', () => selectIssueCaptureRepository(repository));
|
||
results.appendChild(button);
|
||
});
|
||
results.hidden = !results.childElementCount;
|
||
}
|
||
|
||
function selectIssueCaptureRepository(repository) {
|
||
appendIssueRepositories([{full_name: repository}]);
|
||
qs('#create-issue-repository').value = repository;
|
||
qs('#create-issue-repository-search').value = repository;
|
||
qs('#create-issue-repository-results').hidden = true;
|
||
qs('#create-issue-repository-status').textContent = 'Selected ' + repository + '.';
|
||
loadIssueLabels(repository);
|
||
loadIssueMilestones(repository);
|
||
saveIssueCaptureDraft();
|
||
scheduleIssueDuplicateCheck();
|
||
updateIssueCreateActions();
|
||
qs('#create-issue-title').focus();
|
||
}
|
||
|
||
let duplicateCheckTimer = null;
|
||
function renderIssueDuplicates(state) {
|
||
if (state.status === 'stale') return;
|
||
const panel = qs('#create-issue-duplicates');
|
||
const list = qs('#create-issue-duplicate-list');
|
||
const status = qs('#create-issue-duplicate-status');
|
||
const anyway = qs('#create-issue-anyway');
|
||
list.replaceChildren();
|
||
anyway.hidden = true;
|
||
if (state.status === 'idle') {
|
||
panel.hidden = true;
|
||
return;
|
||
}
|
||
panel.hidden = false;
|
||
if (state.status === 'failed') {
|
||
status.textContent = 'Could not check for existing issues. You can still create this issue.';
|
||
return;
|
||
}
|
||
if (!state.candidates.length) {
|
||
status.textContent = 'No similar open issues found in this repository.';
|
||
return;
|
||
}
|
||
status.textContent = (state.partial ? 'Search was incomplete. ' : '') + state.candidates.length +
|
||
' similar open issue' + (state.candidates.length === 1 ? '' : 's') +
|
||
' found. ' + (state.partial ? 'You can continue without waiting.' : 'Review before creating another.');
|
||
state.candidates.forEach(item => {
|
||
const card = document.createElement('article');
|
||
card.className = 'create-issue-duplicate-card';
|
||
const key = document.createElement('span');
|
||
key.className = 'small';
|
||
key.textContent = item.repository + ' #' + item.number;
|
||
const link = document.createElement('a');
|
||
link.textContent = item.title || 'Review existing issue';
|
||
link.href = safeSearchUrl(item.url) || '#';
|
||
link.target = '_blank';
|
||
link.rel = 'noopener noreferrer';
|
||
link.addEventListener('click', saveIssueCaptureDraft);
|
||
card.append(key, link);
|
||
list.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function scheduleIssueDuplicateCheck() {
|
||
clearTimeout(duplicateCheckTimer);
|
||
const draft = currentIssueCaptureDraft();
|
||
duplicateCheckTimer = setTimeout(async () => {
|
||
renderIssueDuplicates(await issueCapture.findDuplicates(draft));
|
||
}, 350);
|
||
}
|
||
|
||
async function loadIssueLabels(repository, selectedIds = []) {
|
||
const list = qs('#create-issue-label-list');
|
||
const status = qs('#create-issue-label-status');
|
||
list.innerHTML = '';
|
||
if (!repository) {
|
||
status.textContent = 'Choose a repository to load labels.';
|
||
return;
|
||
}
|
||
status.textContent = 'Loading labels…';
|
||
try {
|
||
const labels = await issueCapture.loadLabels(repository);
|
||
const selected = new Set(selectedIds.map(Number));
|
||
list.innerHTML = labels.map(label =>
|
||
'<label class="create-issue-label-option"><input type="checkbox" name="create-issue-label" value="' +
|
||
Number(label.id) + '"' + (selected.has(Number(label.id)) ? ' checked' : '') + '><span>' +
|
||
escapeHtml(label.name) + '</span></label>'
|
||
).join('');
|
||
status.textContent = labels.length ? 'Select labels to triage this issue.' : 'This repository has no labels.';
|
||
} catch (error) {
|
||
status.textContent = 'Labels could not be loaded. You can still create the issue without labels.';
|
||
}
|
||
}
|
||
|
||
async function loadIssueMilestones(repository, selectedId = null) {
|
||
const select = qs('#create-issue-milestone');
|
||
const status = qs('#create-issue-milestone-status');
|
||
select.innerHTML = '<option value="">No milestone</option>';
|
||
if (!repository) {
|
||
status.textContent = 'Choose a repository to load milestones.';
|
||
return;
|
||
}
|
||
status.textContent = 'Loading milestones…';
|
||
try {
|
||
const milestones = await issueCapture.loadMilestones(repository);
|
||
if (qs('#create-issue-repository').value !== repository) return;
|
||
select.innerHTML += milestones.map(milestone =>
|
||
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
|
||
).join('');
|
||
if (selectedId) select.value = String(selectedId);
|
||
status.textContent = milestones.length ?
|
||
'Choose the release lane for this issue.' : 'This repository has no open milestones.';
|
||
} catch (_error) {
|
||
if (qs('#create-issue-repository').value !== repository) return;
|
||
status.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.';
|
||
}
|
||
}
|
||
|
||
function clearSharedLaunchUrl() {
|
||
const cleanUrl = location.pathname + location.hash;
|
||
history.replaceState(history.state || {}, '', cleanUrl);
|
||
}
|
||
|
||
function openStagedSharedContent() {
|
||
if (!sharedLaunchState || sharedLaunchState.status === 'shown') return;
|
||
openCreateIssueSheet();
|
||
if (sharedLaunchState.status === 'conflict') {
|
||
qs('#shared-content-conflict').hidden = false;
|
||
qs('#create-issue-status').textContent = 'Choose which draft to continue.';
|
||
qs('#resume-issue-draft').focus();
|
||
sharedLaunchState = {status: 'shown'};
|
||
return;
|
||
}
|
||
clearSharedLaunchUrl();
|
||
sharedLaunchState = null;
|
||
}
|
||
|
||
async function openCreateIssueSheet(navigate = true) {
|
||
if (!issueCapture && !await ensureIssueCapture()) return;
|
||
if (navigate) {
|
||
taskOverlayHistory.open('new');
|
||
return;
|
||
}
|
||
const captureDraft = issueCapture.loadDraft();
|
||
const initialRepositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
|
||
if (!issueCaptureRepositories.length) issueCaptureRepositories = initialRepositories.slice();
|
||
if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) {
|
||
issueCaptureRepositories.unshift(captureDraft.repository);
|
||
}
|
||
qs('#create-issue-repository').innerHTML = '<option value="">Choose repository</option>' + issueCaptureRepositories.map(repository =>
|
||
'<option value="' + escAttr(repository) + '">' + escapeHtml(repository) + '</option>'
|
||
).join('');
|
||
if (nextIssueRepositoryPage === 2) {
|
||
moreIssueRepositoriesAvailable = lastContextSnapshot?.repository_pagination?.has_more === true;
|
||
}
|
||
qs('#load-more-issue-repositories').hidden = !moreIssueRepositoriesAvailable;
|
||
if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository;
|
||
else qs('#create-issue-repository').value = '';
|
||
qs('#create-issue-repository-search').value = captureDraft.repository || '';
|
||
qs('#create-issue-repository-results').hidden = true;
|
||
qs('#create-issue-title').value = captureDraft.title;
|
||
qs('#create-issue-body').value = captureDraft.body;
|
||
setIssueFilingMode(Boolean(captureDraft.repository));
|
||
qs('#create-issue-capture-status').textContent = '';
|
||
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
|
||
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
|
||
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
||
scheduleIssueDuplicateCheck();
|
||
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
|
||
qs('#create-follow-up-next').hidden = !updateFollowUp?.source();
|
||
updateIssueCreateActions();
|
||
qs('#create-issue-sheet').classList.add('open');
|
||
creatingIssue = true;
|
||
qs('#create-issue-title').focus();
|
||
}
|
||
|
||
function setIssueFilingMode(enabled) {
|
||
qs('#create-issue-filing').hidden = !enabled;
|
||
qs('.create-issue-capture-actions').hidden = enabled;
|
||
qs('#create-issue-heading').textContent = enabled ? 'File issue' : 'Capture work';
|
||
}
|
||
|
||
let suppressCreateDraftOnHistoryClose = false;
|
||
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
|
||
if (navigate && taskOverlayHistory.current() === 'new') {
|
||
suppressCreateDraftOnHistoryClose = !preserveDraft;
|
||
taskOverlayHistory.close();
|
||
return;
|
||
}
|
||
qs('#create-issue-sheet').classList.remove('open');
|
||
mobileComposerViewport.close(qs('.create-issue-panel'));
|
||
clearTimeout(duplicateCheckTimer);
|
||
qs('#create-issue-duplicates').hidden = true;
|
||
creatingIssue = false;
|
||
if (followUpSourceUpdate) {
|
||
const source = followUpSourceUpdate;
|
||
followUpSourceUpdate = null;
|
||
notificationReader.open(source.item, source.detail);
|
||
return;
|
||
}
|
||
qs('#new-issue').focus();
|
||
}
|
||
|
||
function applyOutboxResult(result, openCreated = false, startCreated = false) {
|
||
if (result.lease_skipped) { refreshMyWorkView(); return; }
|
||
(result.confirmed || []).forEach(confirmed => {
|
||
if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
|
||
});
|
||
if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot);
|
||
refreshMyWorkView();
|
||
const startedCompletions = new Set();
|
||
(result.completions || []).forEach(completion => {
|
||
if (completion.ownerLogin !== activeFlushLogin || completion.intent !== 'create-and-start') return;
|
||
const created = lastMyWork.find(item => item.kind === 'issue' &&
|
||
item.repository === completion.issue?.repository && item.number === completion.issue?.number);
|
||
if (!created) return;
|
||
const outcome = createAndStart.complete(created);
|
||
if (outcome === 'started' || outcome === 'exists') {
|
||
issueOutbox.completeIntent(completion.id, activeFlushLogin);
|
||
startedCompletions.add(created.repository + '#' + created.number);
|
||
}
|
||
});
|
||
if (result.confirmed?.length) {
|
||
const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', ');
|
||
qs('#my-work-action-status').textContent = keys + ' created and assigned to you.';
|
||
const confirmed = result.confirmed[result.confirmed.length - 1];
|
||
const created = lastMyWork.find(item =>
|
||
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
|
||
);
|
||
const completionHandled = startedCompletions.has(confirmed.repository + '#' + confirmed.number);
|
||
if (startCreated && !(result.completions || []).length && created) {
|
||
const outcome = createAndStart.complete(created);
|
||
if (outcome !== 'started' && openCreated) openRoutedWork(created, qs('#new-issue'));
|
||
} else if (openCreated && created && !completionHandled) openRoutedWork(created, qs('#new-issue'));
|
||
} else if ((result.remaining || []).some(item => item.status === 'attention')) {
|
||
qs('#my-work-action-status').textContent = 'Needs attention · edit the queued issue before sending again.';
|
||
} else {
|
||
qs('#my-work-action-status').textContent = 'Queued for sync when the connection returns.';
|
||
}
|
||
}
|
||
|
||
async function flushIssueOutbox() {
|
||
if (!navigator.onLine || !activeFlushLogin || !issueOutbox.list().length) return;
|
||
applyOutboxResult(await issueOutbox.flush(activeFlushLogin));
|
||
}
|
||
|
||
function applyAuthoredOutboxResult(result) {
|
||
if (result.lease_skipped) { refreshMyWorkView(); return; }
|
||
refreshMyWorkView();
|
||
const attention = (result.remaining || []).some(item => item.status === 'attention');
|
||
qs('#my-work-action-status').textContent = result.confirmed?.length ?
|
||
result.confirmed.length + ' queued message' + (result.confirmed.length === 1 ? '' : 's') + ' sent.' :
|
||
attention ? 'A queued message needs attention. Open Drafts to edit or discard it.' :
|
||
'Message queued for sync when the connection returns.';
|
||
}
|
||
|
||
async function flushAuthoredOutbox() {
|
||
if (!navigator.onLine || !activeFlushLogin || !authoredOutbox.list().length) return;
|
||
applyAuthoredOutboxResult(await authoredOutbox.flush(activeFlushLogin));
|
||
}
|
||
|
||
async function flushNotificationReadOutbox() {
|
||
if (!navigator.onLine || !activeFlushLogin || !notificationReadOutbox.list().length) return;
|
||
const result = await notificationReadOutbox.flush(activeFlushLogin);
|
||
if (result.confirmed?.length) {
|
||
qs('#my-work-action-status').textContent = result.confirmed.length +
|
||
' queued update' + (result.confirmed.length === 1 ? '' : 's') + ' marked read.';
|
||
}
|
||
}
|
||
|
||
function canQueueMessage(error) {
|
||
const status = Number(error?.status || 0);
|
||
return !status || status >= 500;
|
||
}
|
||
|
||
function inlineAnchor(target) {
|
||
return {
|
||
path: target.dataset.reviewFilename,
|
||
...(target.dataset.newPosition ? { new_position: Number(target.dataset.newPosition) } : {}),
|
||
...(target.dataset.oldPosition ? { old_position: Number(target.dataset.oldPosition) } : {}),
|
||
};
|
||
}
|
||
|
||
function sameInlineAnchor(comment, anchor) {
|
||
return comment.path === anchor.path && comment.new_position === anchor.new_position &&
|
||
comment.old_position === anchor.old_position;
|
||
}
|
||
|
||
function closeInlineComposer() {
|
||
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
|
||
qs('#review-inline-composer').hidden = true;
|
||
qs('#review-inline-body').value = '';
|
||
activeInlineTarget = null;
|
||
}
|
||
|
||
function openInlineComposer(target) {
|
||
if (!draft) return;
|
||
activeInlineTarget = target;
|
||
const anchor = inlineAnchor(target);
|
||
const existing = draft.snapshot().comments.find(comment => sameInlineAnchor(comment, anchor));
|
||
qs('#review-inline-location').textContent = anchor.path + ' · line ' +
|
||
(anchor.new_position || anchor.old_position) + (anchor.new_position ? ' (new)' : ' (old)');
|
||
qs('#review-inline-body').value = existing?.body || '';
|
||
qs('#delete-inline-comment').disabled = !existing;
|
||
qs('#review-inline-composer').hidden = false;
|
||
qs('#review-inline-body').focus();
|
||
}
|
||
|
||
async function openReviewSheet(item, trigger, cachedDetail = null) {
|
||
if (!item || !await ensurePullWorkflow(trigger)) return;
|
||
selectedReview = item;
|
||
reviewTrigger = trigger;
|
||
offlineReview = Boolean(cachedDetail);
|
||
qs('#review-sheet').classList.add('open');
|
||
qs('#review-sheet-key').textContent = item.key;
|
||
qs('#review-sheet-title').textContent = item.title;
|
||
qs('#review-sheet-status').textContent = 'Loading review details…';
|
||
qs('#retry-review-load').hidden = true;
|
||
qs('#review-sheet-body').textContent = '';
|
||
qs('#review-files').textContent = '';
|
||
applyReviewWrap();
|
||
qs('#review-progress').textContent = '0 of 0 files reviewed';
|
||
qs('#next-unreviewed-review').disabled = true;
|
||
qs('#review-history').textContent = '';
|
||
qs('#open-review-gitea').href = item.url;
|
||
qs('#review-decision').value = 'comment';
|
||
qs('#review-summary').value = '';
|
||
qs('#review-copy-fallback').hidden = true;
|
||
qs('#review-copy-fallback').value = '';
|
||
qs('#review-handoff-link').hidden = true;
|
||
qs('#review-handoff-link').href = item.url;
|
||
qs('#review-handoff-status').textContent = '';
|
||
qs('#review-submit-status').textContent = '';
|
||
qs('#continue-review-to-merge').hidden = true;
|
||
qs('#review-ci-state').textContent = 'CI unknown';
|
||
qs('#review-checks-summary').textContent = cachedDetail ? 'Last known · loading' : 'Loading';
|
||
qs('#review-check-list').textContent = '';
|
||
qs('#review-checks').open = false;
|
||
qs('#refresh-review-checks').disabled = offlineReview;
|
||
qs('#submit-review').disabled = true;
|
||
qs('#submit-review').textContent = offlineReview && reviewingActiveTodayItem() ? 'Queue review & next' :
|
||
(offlineReview ? 'Queue review for reconnect' : 'Submit review');
|
||
closeInlineComposer();
|
||
draft = null;
|
||
reviewFiles = [];
|
||
selectedReviewHead = '';
|
||
qs('#close-review-sheet').focus();
|
||
try {
|
||
const detail = cachedDetail || await reviewController.load(selectedReview);
|
||
if (selectedReview !== item) return;
|
||
qs('#review-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
|
||
qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
|
||
renderCheckSection('review', detail, offlineReview);
|
||
qs('#review-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
|
||
createReviewController.renderDiffFile(file, index, escapeHtml)
|
||
).join('') : '<div>No changed files reported.</div>';
|
||
document.querySelectorAll('.review-file-toggle').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const panel = document.getElementById(button.getAttribute('aria-controls'));
|
||
if (panel) createReviewController.toggleDiff(button, panel);
|
||
});
|
||
});
|
||
progress = createReviewController.createProgress({
|
||
storage: localStorage,
|
||
repository: item.repository,
|
||
number: item.number,
|
||
headSha: detail.head_sha || 'unknown',
|
||
files: detail.files || [],
|
||
});
|
||
reviewFiles = detail.files || [];
|
||
selectedReviewHead = detail.head_sha || '';
|
||
draft = createReviewController.createDraft({
|
||
storage: localStorage,
|
||
repository: item.repository,
|
||
number: item.number,
|
||
headSha: detail.head_sha || 'unknown',
|
||
files: reviewFiles,
|
||
});
|
||
const draftSnapshot = draft.snapshot();
|
||
qs('#review-decision').value = draftSnapshot.decision;
|
||
qs('#review-summary').value = draftSnapshot.summary;
|
||
document.querySelectorAll('.review-note').forEach(note => {
|
||
note.value = draftSnapshot.notes[note.dataset.reviewFilename] || '';
|
||
note.addEventListener('input', () => draft.setNote(note.dataset.reviewFilename, note.value));
|
||
});
|
||
document.querySelectorAll('.review-inline-target').forEach(target => {
|
||
const anchor = inlineAnchor(target);
|
||
target.classList.toggle('has-draft', draftSnapshot.comments.some(comment =>
|
||
sameInlineAnchor(comment, anchor)
|
||
));
|
||
target.addEventListener('click', () => openInlineComposer(target));
|
||
});
|
||
document.querySelectorAll('.review-mark').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const snapshot = progress.markReviewed(button.dataset.reviewFilename);
|
||
showReviewProgress(snapshot);
|
||
openNextUnreviewed(snapshot);
|
||
});
|
||
});
|
||
const progressSnapshot = progress.snapshot();
|
||
showReviewProgress(progressSnapshot);
|
||
openNextUnreviewed(progressSnapshot);
|
||
qs('#review-history').innerHTML = (detail.reviews || []).length ? detail.reviews.map(review =>
|
||
'<div class="review-history"><strong>' + escapeHtml(review.user?.login || 'Reviewer') + '</strong> · ' +
|
||
escapeHtml(review.state || 'commented') + (review.body ? '<div class="small markdown-content">' + renderMarkdown(review.body) + '</div>' : '') + '</div>'
|
||
).join('') : '<div>No prior reviews.</div>';
|
||
qs('#review-sheet-status').textContent = offlineReview ?
|
||
'Offline review · saved ' + fmt(detail.saved_at) + ' · draft feedback stays on this device.' :
|
||
'Ready to review · by ' + (detail.author || 'unknown author');
|
||
qs('#submit-review').disabled = false;
|
||
} catch (error) {
|
||
if (selectedReview !== item) return;
|
||
qs('#review-sheet-status').textContent = error.message + ' Retry here or use Open in Gitea.';
|
||
qs('#retry-review-load').hidden = false;
|
||
qs('#retry-review-load').focus();
|
||
}
|
||
}
|
||
|
||
function closeReviewSheet(navigate = true) {
|
||
if (navigate && createWorkRoute.parse(window.location.hash)) {
|
||
workRoute.close();
|
||
return;
|
||
}
|
||
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
|
||
qs('#review-sheet').classList.remove('open');
|
||
selectedReview = null;
|
||
offlineReview = false;
|
||
progress = null;
|
||
draft = null;
|
||
reviewFiles = [];
|
||
selectedReviewHead = '';
|
||
closeInlineComposer();
|
||
if (reviewTrigger?.isConnected) reviewTrigger.focus();
|
||
}
|
||
|
||
function closeUpdateSheet(restoreTrigger = true, navigate = true) {
|
||
if (navigate && createWorkRoute.parse(window.location.hash)) {
|
||
workRoute.close();
|
||
return;
|
||
}
|
||
mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel'));
|
||
updateReplyAttachmentController.clear();
|
||
qs('#update-sheet').classList.remove('open');
|
||
selectedUpdate = null;
|
||
selectedUpdateDetail = null;
|
||
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
|
||
else qs('[data-work-filter="update"]')?.focus();
|
||
}
|
||
|
||
function markMyWorkStale() {
|
||
qs('#my-work').setAttribute('data-stale', 'true');
|
||
qs('#my-work-status').textContent = lastMyWork.length ?
|
||
'Update failed · showing last known work' : 'Work inbox unavailable.';
|
||
}
|
||
|
||
function markNotificationsStale() {
|
||
qs('#my-work').setAttribute('data-stale', 'true');
|
||
qs('#my-work-status').textContent = lastNotifications.length ?
|
||
'Unread updates unavailable · showing last known updates' :
|
||
'Unread updates unavailable · assigned work is fresh';
|
||
}
|
||
|
||
function paintDeltas(deltas) {
|
||
const el = qs('#ai');
|
||
el.innerHTML = deltas.length ? deltas.map(d => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
|
||
}
|
||
|
||
function paintEventStream(events) {
|
||
const el = qs('#gitea-events');
|
||
el.innerHTML = events.length ? events.slice(0, 12).map(event =>
|
||
'<div class="event"><strong>' + escapeHtml(event.actor?.login || 'Gitea') + '</strong> ' +
|
||
escapeHtml(event.type || 'activity') +
|
||
'<div class="small">' + escapeHtml(event.repo?.full_name || '') +
|
||
(event.created_at ? ' · ' + escapeHtml(fmt(event.created_at)) : '') + '</div></div>'
|
||
).join('') : '<div class="muted">No recent Gitea activity.</div>';
|
||
}
|
||
|
||
function setEventStreamStatus(message) {
|
||
qs('#gitea-events-status').textContent = message;
|
||
}
|
||
|
||
async function renderLiveSnapshot(snapshot, changedSections = ['context', 'events', 'notifications']) {
|
||
setOfflineWorkMode(false);
|
||
offlineStatus.hidden = true;
|
||
const contextFreshness = snapshot.freshness?.sections?.context;
|
||
const eventsFreshness = snapshot.freshness?.sections?.events;
|
||
const notificationFreshness = snapshot.freshness?.sections?.notifications;
|
||
const contextChanged = changedSections.includes('context');
|
||
const notificationsChanged = changedSections.includes('notifications');
|
||
const eventsChanged = changedSections.includes('events');
|
||
const workChanged = contextChanged || notificationsChanged;
|
||
const hasNotifications = Array.isArray(snapshot.notifications);
|
||
const notificationsFresh = hasNotifications && !notificationFreshness?.stale;
|
||
if (notificationsChanged && hasNotifications) {
|
||
if (notificationPagination.page > 1) {
|
||
const byId = new Map(lastNotifications.map(item => [item.id, item]));
|
||
snapshot.notifications.forEach(item => byId.set(item.id, item));
|
||
lastNotifications = Array.from(byId.values());
|
||
} else {
|
||
lastNotifications = snapshot.notifications;
|
||
}
|
||
if (snapshot.notification_pagination) {
|
||
const page = notificationPagination.page > 1 ? notificationPagination.page :
|
||
snapshot.notification_pagination.page;
|
||
notificationPager.reset({
|
||
page,
|
||
total: snapshot.notification_pagination.total,
|
||
has_more: page * 50 < snapshot.notification_pagination.total,
|
||
});
|
||
}
|
||
}
|
||
if (snapshot.context && workChanged) {
|
||
setOfflineWorkMode(false);
|
||
const retainedPlanningLogin = !snapshot.context.error ?
|
||
String(snapshot.context.user?.login || '').trim() : '';
|
||
planningOwnerLogin = retainedPlanningLogin;
|
||
updatePlanningAvailability();
|
||
if (planningOwnerLogin) {
|
||
todaySync.migrate(todayWork.read());
|
||
todaySync.flush();
|
||
laterSync.migrate(laterWork.read());
|
||
laterSync.flush();
|
||
}
|
||
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
|
||
!contextFreshness?.degraded && !contextFreshness?.revalidating;
|
||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||
if (activeFlushLogin) {
|
||
confirmedOwnerLogin = activeFlushLogin;
|
||
updateDeliveryReceiptControls();
|
||
interruptionPrompt.restore();
|
||
}
|
||
snapshot.context.notifications = lastNotifications;
|
||
renderContextSnapshot(snapshot.context);
|
||
if (contextFreshness?.stale) markMyWorkStale();
|
||
else if (!notificationsFresh) markNotificationsStale();
|
||
if (!contextFreshness?.stale && notificationsFresh) {
|
||
const admitted = await offlineWorkStore.save({
|
||
...snapshot.context,
|
||
notifications: snapshot.notifications,
|
||
notification_pagination: snapshot.notification_pagination,
|
||
});
|
||
await updateOfflineWorkControls(admitted === false ?
|
||
'Offline saving unavailable · retry after reconnect.' : undefined);
|
||
warmTodayOffline();
|
||
}
|
||
flushIssueOutbox();
|
||
flushAuthoredOutbox();
|
||
flushNotificationReadOutbox();
|
||
} else if (!snapshot.context) handleContextError(new Error('Context section unavailable'));
|
||
if (eventsChanged && Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
|
||
if (eventsFreshness?.revalidating) {
|
||
setEventStreamStatus('Refreshing activity · showing last activity');
|
||
} else if (eventsFreshness?.stale || eventsFreshness?.degraded) {
|
||
const retrySeconds = Number(eventsFreshness.retry_in_seconds) || 0;
|
||
setEventStreamStatus('Activity refresh failed · showing last activity' +
|
||
(retrySeconds > 0 ? ' · retrying in ' + retrySeconds + 's' : ''));
|
||
} else if (Array.isArray(snapshot.events)) {
|
||
setEventStreamStatus('Updated ' + fmt(new Date()));
|
||
} else {
|
||
setEventStreamStatus('Update failed · showing last activity');
|
||
}
|
||
if (!eventsFreshness && snapshot.freshness?.degraded && !snapshot.freshness.revalidating) {
|
||
const retrySeconds = Number(snapshot.freshness.retry_in_seconds) || 0;
|
||
setEventStreamStatus('Refresh failed · showing last known data' +
|
||
(retrySeconds > 0 ? ' · retrying in ' + retrySeconds + 's' : ''));
|
||
} else if (!eventsFreshness && snapshot.freshness?.revalidating) {
|
||
setEventStreamStatus('Refreshing · showing recent snapshot');
|
||
}
|
||
}
|
||
|
||
|
||
function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||
function escAttr(s) { return escapeHtml(s); }
|
||
function openModal(id) { qs('#' + id).classList.add('open'); }
|
||
function closeModal(id) { qs('#' + id).classList.remove('open'); }
|
||
|
||
/* Creative ambient background */
|
||
(function bg(){
|
||
const c=qs('#bg'),ctx=c.getContext('2d');
|
||
let w,h,time=0;
|
||
const resize=()=>{ w=c.width=innerWidth; h=c.height=innerHeight; };
|
||
resize(); addEventListener('resize', resize);
|
||
const draw=()=>{
|
||
time+=0.012;
|
||
ctx.clearRect(0,0,w,h);
|
||
ctx.strokeStyle='#1f3a5f'; ctx.lineWidth=1;
|
||
const step=60;
|
||
for(let x=0;x<w;x+=step){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
|
||
for(let y=0;y<h;y+=step){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke(); }
|
||
const cx=w/2+Math.sin(time*0.31)*90, cy=h/2+Math.cos(time*0.37)*70;
|
||
const grad=ctx.createRadialGradient(cx,cy,20,cx,cy,260);
|
||
grad.addColorStop(0,'rgba(96,165,250,.28)'); grad.addColorStop(1,'rgba(96,165,250,0)');
|
||
ctx.fillStyle=grad; ctx.fillRect(0,0,w,h);
|
||
requestAnimationFrame(draw);
|
||
};
|
||
draw();
|
||
})();
|
||
|
||
/* Whiteboard */
|
||
function initWhiteboard() {
|
||
const canvas = qs('#wb'), ctx = canvas.getContext('2d');
|
||
let drawing = false;
|
||
function resize() { const dpr = window.devicePixelRatio||1; const r=canvas.getBoundingClientRect(); canvas.width=r.width*dpr; canvas.height=r.height*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); ctx.lineCap='round'; ctx.lineJoin='round'; ctx.strokeStyle='#e5e7eb'; ctx.lineWidth=2; }
|
||
resize(); addEventListener('resize', resize);
|
||
const p = (e) => ({ x: e.clientX - canvas.getBoundingClientRect().left, y: e.clientY - canvas.getBoundingClientRect().top });
|
||
canvas.addEventListener('pointerdown', (e)=>{ drawing=true; const pos=p(e); ctx.beginPath(); ctx.moveTo(pos.x, pos.y); });
|
||
canvas.addEventListener('pointermove', (e)=>{ if (!drawing) return; const pos=p(e); ctx.lineTo(pos.x, pos.y); ctx.stroke(); });
|
||
canvas.addEventListener('pointerup', () => drawing=false);
|
||
canvas.addEventListener('pointerleave', () => drawing=false);
|
||
qs('#wb-clear').addEventListener('click', () => { const r=canvas.getBoundingClientRect(); ctx.clearRect(0,0,r.width,r.height); });
|
||
qs('#wb-save').addEventListener('click', () => { const a=document.createElement('a'); a.href=canvas.toDataURL(); a.download='whiteboard.png'; a.click(); });
|
||
}
|
||
|
||
/* Markdown */
|
||
qs('#md-input').addEventListener('input', renderMD);
|
||
function renderMD() {
|
||
const raw = qs('#md-input').value || '';
|
||
qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + renderMarkdown(raw) + '</div>';
|
||
}
|
||
|
||
/* Commands */
|
||
const commands = [
|
||
{ name: 'Open whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } },
|
||
{ name: 'Open markdown widget', run: () => { qs('#md-input').focus(); } },
|
||
{ name: 'Refresh now', run: load },
|
||
{ name: 'Scroll issues', run: () => qs('#work').scrollIntoView({ behavior:'smooth', block:'start' }) },
|
||
];
|
||
let commandSearchState = { status:'idle', query:'', items:[] };
|
||
let commandItems = [];
|
||
let commandSelection = -1;
|
||
async function searchGlobalWork(query, signal) {
|
||
const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10', {
|
||
headers: { Accept:'application/json' },
|
||
signal,
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.');
|
||
return {
|
||
items: Array.isArray(payload.items) ? payload.items : [],
|
||
partial: payload.partial === true,
|
||
};
|
||
}
|
||
const commandSearch = filterCommands.createGlobalSearchController({
|
||
search: searchGlobalWork,
|
||
onState: state => {
|
||
commandSearchState = state;
|
||
renderCommands(state.query);
|
||
},
|
||
});
|
||
function safeSearchUrl(value) {
|
||
try {
|
||
const url = new URL(value);
|
||
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
||
} catch (_) { return '';
|
||
}
|
||
}
|
||
let searchPreviewDetail = null;
|
||
function searchPreviewPath(item) {
|
||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||
'/preview?kind=' + encodeURIComponent(item.kind);
|
||
}
|
||
function renderSearchPreview(state) {
|
||
const sheet = qs('#search-preview');
|
||
const status = qs('#search-preview-status');
|
||
const claimButton = qs('#claim-search-result');
|
||
const startButton = qs('#start-search-result');
|
||
|
||
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
|
||
? 'Back to blockers' : 'Back to search';
|
||
if (state.status === 'closed') {
|
||
sheet.classList.remove('open');
|
||
return;
|
||
}
|
||
sheet.classList.add('open');
|
||
claimButton.hidden = true;
|
||
claimButton.disabled = false;
|
||
startButton.hidden = true;
|
||
startButton.disabled = false;
|
||
|
||
if (state.status === 'loading') {
|
||
searchPreviewDetail = null;
|
||
qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number;
|
||
qs('#search-preview-title').textContent = state.item.title || 'Work preview';
|
||
qs('#search-preview-meta').textContent = '';
|
||
qs('#search-preview-body').textContent = '';
|
||
qs('#open-search-result-gitea').href = safeSearchUrl(state.item.url) || '#';
|
||
status.textContent = 'Loading preview…';
|
||
return;
|
||
}
|
||
if (state.status === 'error') {
|
||
status.textContent = state.error?.message || 'Preview unavailable. Open this result in Gitea or retry.';
|
||
return;
|
||
}
|
||
const detail = state.detail;
|
||
if (!detail) return;
|
||
searchPreviewDetail = detail;
|
||
qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number;
|
||
qs('#search-preview-title').textContent = detail.title || 'Untitled work item';
|
||
qs('#search-preview-meta').textContent =
|
||
(detail.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + (detail.state || 'unknown') +
|
||
(detail.author ? ' · by ' + detail.author : '') +
|
||
(detail.labels?.length ? ' · ' + detail.labels.join(', ') : '') +
|
||
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
|
||
qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
|
||
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
|
||
claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue'));
|
||
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
|
||
claimButton.disabled = state.status === 'claiming';
|
||
startButton.hidden = !(detail.kind === 'issue' && (detail.reopenable ||
|
||
(detail.state === 'open' && (detail.claimable || detail.assigned_to_me))));
|
||
startButton.textContent = detail.reopenable ? 'Reopen & resume' :
|
||
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
|
||
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
|
||
status.textContent = state.status === 'reopening' ? 'Reopening…' :
|
||
(state.status === 'claiming' ? 'Assigning this issue to you…' :
|
||
(state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' :
|
||
(detail.claimable ? 'This issue is open and unassigned.' :
|
||
(detail.assigned_to_me ? 'This issue is already in My Work.' :
|
||
(detail.reopenable ? 'Closed—reopen to resume.' : 'Read-only preview.')))));
|
||
}
|
||
const searchPreview = createSearchPreview({
|
||
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
|
||
mutate: (detail, action) => fetchReviewJson(
|
||
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
|
||
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
|
||
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
||
),
|
||
onState: renderSearchPreview,
|
||
});
|
||
function createSearchStart(claim) {
|
||
return createAssignAndStart({
|
||
available: createAndStart.available,
|
||
claim,
|
||
start: confirmed => {
|
||
const item = acceptClaimedIssue(confirmed);
|
||
taskOverlayHistory.leave();
|
||
refreshMyWorkView();
|
||
return createAndStart.complete(item);
|
||
},
|
||
recover: confirmed => {
|
||
const item = acceptClaimedIssue(confirmed);
|
||
taskOverlayHistory.leave();
|
||
refreshMyWorkView();
|
||
openRoutedWork(item, qs('#open-palette'));
|
||
},
|
||
announce: message => {
|
||
qs('#search-preview-status').textContent = message;
|
||
qs('#my-work-action-status').textContent = message;
|
||
},
|
||
});
|
||
}
|
||
const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail));
|
||
const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail));
|
||
const mobileSearchViewport = createMobileSearchViewport({
|
||
palette: qs('#cmd-palette'),
|
||
results: qs('#cmd-results'),
|
||
viewport: window.visualViewport,
|
||
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
||
schedule: callback => requestAnimationFrame(callback),
|
||
});
|
||
function closeSearchPreview(navigate = true) {
|
||
if (navigate && taskOverlayHistory.current() === 'search-preview') {
|
||
taskOverlayHistory.close();
|
||
return;
|
||
}
|
||
searchPreview.close();
|
||
qs('#cmd-palette').classList.add('open');
|
||
qs('#cmd-input').setAttribute('aria-expanded', 'true');
|
||
renderCommands(qs('#cmd-input').value);
|
||
qs('#cmd-input').focus();
|
||
}
|
||
async function openPreviewIssueInMyWork(detail) {
|
||
await load();
|
||
const item = lastMyWork.find(candidate =>
|
||
candidate.kind === 'issue' && candidate.key === detail.repository + '#' + detail.number
|
||
);
|
||
if (!item) return false;
|
||
taskOverlayHistory.leave();
|
||
openRoutedWork(item, qs('#find-work'));
|
||
return true;
|
||
}
|
||
function runCommandItem(item) {
|
||
if (item.command) {
|
||
taskOverlayHistory.leave();
|
||
item.command.run();
|
||
qs('#cmd-input').value = '';
|
||
} else {
|
||
mobileSearchViewport.rememberScroll();
|
||
searchPreviewReturnKind = 'search';
|
||
searchPreview.open(item.result).catch(() => {});
|
||
taskOverlayHistory.open('search-preview');
|
||
}
|
||
qs('#cmd-palette').classList.remove('open');
|
||
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
||
}
|
||
function renderCommands(filter) {
|
||
const el = qs('#cmd-results');
|
||
const local = filterCommands(commands, filter).map(command => ({ command }));
|
||
const remote = commandSearchState.query === String(filter || '').trim()
|
||
? commandSearchState.items.map(result => ({ result })) : [];
|
||
commandItems = local.concat(remote);
|
||
if (commandSelection >= commandItems.length) commandSelection = -1;
|
||
let html = local.length ? '<div class="cmd-group">Commands</div>' : '';
|
||
html += local.map((item, idx) => '<div class="cmd-item' + (idx === commandSelection ? ' selected' : '') + '" role="option" aria-selected="' + (idx === commandSelection) + '" data-idx="' + idx + '"><span>' + escapeHtml(item.command.name) + '</span><span class="cmd-meta">Command</span></div>').join('');
|
||
if (remote.length) html += '<div class="cmd-group">Issues and pull requests</div>';
|
||
html += remote.map((item, remoteIdx) => {
|
||
const idx = local.length + remoteIdx;
|
||
const result = item.result;
|
||
return '<div class="cmd-item' + (idx === commandSelection ? ' selected' : '') + '" role="option" aria-selected="' + (idx === commandSelection) + '" data-idx="' + idx + '"><span>' + escapeHtml(result.title) + '</span><span class="cmd-meta">' + escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' + escapeHtml(result.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + escapeHtml(result.state) + '</span></div>';
|
||
}).join('');
|
||
if (commandSearchState.status === 'loading') html += '<div class="cmd-status">Searching accessible work…</div>';
|
||
else if (commandSearchState.status === 'error') html += '<div class="cmd-status">Search unavailable. Keep typing or retry.</div>';
|
||
else if (commandSearchState.partial) html += '<div class="cmd-status">Some results are temporarily unavailable.</div>';
|
||
else if (String(filter || '').trim().length >= 2 && !remote.length) html += '<div class="cmd-status">No matching issues or pull requests.</div>';
|
||
el.innerHTML = html;
|
||
el.querySelectorAll('.cmd-item').forEach((item) => {
|
||
item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)]));
|
||
});
|
||
}
|
||
function openCommandPalette(navigate = true) {
|
||
if (navigate) {
|
||
taskOverlayHistory.open('search');
|
||
return;
|
||
}
|
||
qs('#cmd-palette').classList.add('open');
|
||
mobileSearchViewport.open();
|
||
mobileSearchViewport.restoreScroll();
|
||
qs('#cmd-input').setAttribute('aria-expanded', 'true');
|
||
qs('#cmd-input').focus();
|
||
commandSelection = -1;
|
||
renderCommands(qs('#cmd-input').value);
|
||
}
|
||
const taskOverlayHistory = createTaskOverlayHistory({
|
||
history: window.history,
|
||
eventTarget: window,
|
||
onChange(kind, previous) {
|
||
if (previous === 'new' && kind !== 'new') {
|
||
if (!suppressCreateDraftOnHistoryClose) saveIssueCaptureDraft();
|
||
suppressCreateDraftOnHistoryClose = false;
|
||
closeCreateIssueSheet(false);
|
||
}
|
||
if (previous === 'find' && kind !== 'find') closeFindWorkSheet(false);
|
||
if (previous === 'search-preview' && kind !== 'search-preview') {
|
||
if (kind === 'search') closeSearchPreview(false);
|
||
else {
|
||
searchPreview.close();
|
||
mobileSearchViewport.close();
|
||
}
|
||
searchPreviewReturnKind = null;
|
||
}
|
||
if (previous === 'search' && kind !== 'search' && kind !== 'search-preview') {
|
||
qs('#cmd-palette').classList.remove('open');
|
||
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
||
mobileSearchViewport.close();
|
||
qs('#open-palette').focus();
|
||
}
|
||
if (previous === 'plan-today-preview' && kind !== 'plan-today-preview') {
|
||
if (addPlanPreviewOnReturn && addPlanPreviewOverride) planTodayPreview.close({ add:true, override:true });
|
||
else if (addPlanPreviewOnReturn) planTodayPreview.close({ add:true });
|
||
else planTodayPreview.close();
|
||
addPlanPreviewOnReturn = false;
|
||
addPlanPreviewOverride = false;
|
||
}
|
||
if (previous === 'plan-today' && kind !== 'plan-today' && kind !== 'plan-today-preview') closePlanToday(false);
|
||
if (previous === 'today-readiness' && kind !== 'today-readiness') {
|
||
if (kind === 'search-preview') suspendTodayReadiness();
|
||
else closeTodayReadiness(false);
|
||
}
|
||
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
|
||
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
|
||
if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false);
|
||
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
|
||
if (kind === 'today-readiness' && previous !== 'today-readiness') renderTodayReadiness(todayReadiness.snapshot());
|
||
},
|
||
});
|
||
taskOverlayHistory.start();
|
||
qs('#open-palette').addEventListener('click', openCommandPalette);
|
||
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
|
||
qs('#cmd-input').addEventListener('input', (e) => {
|
||
commandSelection = -1;
|
||
renderCommands(e.target.value);
|
||
commandSearch.setQuery(e.target.value);
|
||
});
|
||
qs('#cmd-input').addEventListener('keydown', event => {
|
||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||
event.preventDefault();
|
||
commandSelection = filterCommands.nextSelection(commandSelection, event.key, commandItems.length);
|
||
renderCommands(event.currentTarget.value);
|
||
const selected = qs('#cmd-results .selected');
|
||
if (selected) selected.scrollIntoView({ block:'nearest' });
|
||
} else if (event.key === 'Enter' && commandSelection >= 0) {
|
||
event.preventDefault();
|
||
runCommandItem(commandItems[commandSelection]);
|
||
}
|
||
});
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && qs('#cmd-palette').classList.contains('open')) {
|
||
e.preventDefault();
|
||
taskOverlayHistory.close();
|
||
return;
|
||
}
|
||
if (e.key === 'Escape' && qs('#search-preview').classList.contains('open')) {
|
||
e.preventDefault();
|
||
closeSearchPreview();
|
||
return;
|
||
}
|
||
if (e.key === 'Escape' && findingWork) {
|
||
e.preventDefault();
|
||
closeFindWorkSheet();
|
||
return;
|
||
}
|
||
if (e.key === 'Escape' && creatingIssue) {
|
||
e.preventDefault();
|
||
saveIssueCaptureDraft();
|
||
closeCreateIssueSheet();
|
||
return;
|
||
}
|
||
if (e.key === 'Escape' && selectedIssue) {
|
||
e.preventDefault();
|
||
closeIssueSheet();
|
||
return;
|
||
}
|
||
if (e.key === 'Escape' && selectedPull) {
|
||
e.preventDefault();
|
||
closePullSheet();
|
||
return;
|
||
}
|
||
if (e.key === 'Escape' && selectedReview) {
|
||
e.preventDefault();
|
||
closeReviewSheet();
|
||
return;
|
||
}
|
||
if (e.key === 'Escape' && selectedUpdate) {
|
||
e.preventDefault();
|
||
closeUpdateSheet();
|
||
return;
|
||
}
|
||
if ((e.metaKey||e.ctrlKey) && e.key==='k') {
|
||
e.preventDefault();
|
||
if (qs('#cmd-palette').classList.contains('open')) {
|
||
taskOverlayHistory.close();
|
||
} else openCommandPalette();
|
||
}
|
||
});
|
||
qs('#close-search-preview').addEventListener('click', closeSearchPreview);
|
||
document.querySelectorAll('.share-work-route').forEach(button => {
|
||
button.addEventListener('click', async () => {
|
||
const status = qs('#work-route-share-status');
|
||
try {
|
||
const result = await createWorkRoute.share(window.location.href, navigator, navigator.clipboard);
|
||
status.textContent = result === 'shared' ? 'Work link shared.' : 'Work link copied.';
|
||
} catch (error) {
|
||
status.textContent = error?.name === 'AbortError' ? 'Share canceled.' : 'Could not share this work link.';
|
||
}
|
||
});
|
||
});
|
||
qs('#claim-search-result').addEventListener('click', async () => {
|
||
if (!searchPreviewDetail || (!searchPreviewDetail.claimable && !searchPreviewDetail.assigned_to_me)) return;
|
||
try {
|
||
const claimed = searchPreviewDetail;
|
||
if (claimed.claimable) await searchPreview.claim(claimed);
|
||
const opened = await openPreviewIssueInMyWork(claimed);
|
||
if (!opened) {
|
||
qs('#search-preview-status').textContent =
|
||
'Assignment confirmed. Refresh My Work to open the issue.';
|
||
}
|
||
} catch (error) {
|
||
qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
|
||
}
|
||
});
|
||
qs('#start-search-result').addEventListener('click', async () => {
|
||
const detail = searchPreviewDetail;
|
||
if (!detail || detail.kind !== 'issue') return;
|
||
try {
|
||
if (detail.reopenable) await searchReopenAndStart.run(detail);
|
||
else if (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)) {
|
||
await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me });
|
||
}
|
||
} catch (error) {
|
||
qs('#search-preview-status').textContent = error.message + ' Retry.';
|
||
}
|
||
});
|
||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||
qs('#find-work').addEventListener('click', openFindWorkSheet);
|
||
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
|
||
qs('#load-more-available').addEventListener('click', async event => {
|
||
event.currentTarget.disabled = true;
|
||
qs('#find-work-status').textContent = 'Loading more available issues…';
|
||
try {
|
||
await findWorkController.loadMore();
|
||
qs('#find-work-status').textContent = findWorkController.items().length + ' of ' +
|
||
availablePagination.total + ' available issues loaded.';
|
||
} catch (error) {
|
||
qs('#find-work-status').textContent = error.message + ' Retry loading more.';
|
||
} finally {
|
||
event.currentTarget.disabled = false;
|
||
}
|
||
});
|
||
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
||
qs('#create-update-follow-up').addEventListener('click', async () => {
|
||
if (!selectedUpdate || !selectedUpdateDetail || !await ensureIssueCapture()) return;
|
||
const source = {item: selectedUpdate, detail: selectedUpdateDetail};
|
||
const state = issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail));
|
||
updateFollowUp.stageSource(source.item);
|
||
followUpSourceUpdate = source;
|
||
closeUpdateSheet(false, false);
|
||
await openCreateIssueSheet(false);
|
||
if (state.status === 'conflict') {
|
||
qs('#shared-content-conflict').hidden = false;
|
||
qs('#create-issue-status').textContent = 'Choose which draft to continue.';
|
||
qs('#resume-issue-draft').focus();
|
||
} else {
|
||
qs('#create-issue-status').textContent = 'Follow-up context added. Review, save to Drafts, or create it.';
|
||
}
|
||
qs('#create-follow-up-next').hidden = false;
|
||
});
|
||
dFS.bind();
|
||
qs('#file-new-issue').addEventListener('click', () => {
|
||
if (!qs('#create-issue-title').value.trim()) {
|
||
qs('#create-issue-capture-status').textContent = 'Add a title before filing.';
|
||
qs('#create-issue-title').focus();
|
||
return;
|
||
}
|
||
saveIssueCaptureDraft();
|
||
setIssueFilingMode(true);
|
||
qs('#create-issue-capture-status').textContent = '';
|
||
qs('#create-issue-repository-search').focus();
|
||
});
|
||
qs('#save-unfiled-issue').addEventListener('click', async () => {
|
||
try {
|
||
const captureDraft = {
|
||
title: qs('#create-issue-title').value.trim(),
|
||
body: qs('#create-issue-body').value.trim(),
|
||
attachment: await createIssueAttachmentController.serialize(),
|
||
};
|
||
if (showDraftCapacityDialog(unfiledCaptures)) return;
|
||
const savedCapture = await unfiledCaptures.save(captureDraft);
|
||
if (rUC) {
|
||
await unfiledCaptures.completeResume(rUC);
|
||
rUC = '';
|
||
}
|
||
issueCapture.clearDraft();
|
||
qs('#create-issue-title').value = '';
|
||
qs('#create-issue-body').value = '';
|
||
createIssueAttachmentController.clear();
|
||
closeCreateIssueSheet(true, false);
|
||
qs('[data-work-filter="draft"]').click();
|
||
mobileTaskDock.select('queues');
|
||
refreshMyWorkView();
|
||
const savedCard = qs('[data-capture-id="' + CSS.escape(savedCapture.id) + '"]');
|
||
requestAnimationFrame(() => {
|
||
savedCard?.scrollIntoView({block:'nearest'});
|
||
savedCard?.focus({preventScroll:true});
|
||
});
|
||
qs('#my-work-action-status').textContent = 'Saved to Drafts. Choose a repository when you’re ready to file it.';
|
||
} catch (error) {
|
||
qs('#create-issue-capture-status').textContent = error.message;
|
||
qs('#create-issue-title').focus();
|
||
}
|
||
});
|
||
bindDraftCapacityDialog();
|
||
qs('#use-shared-content').addEventListener('click', () => {
|
||
if (issueCapture.pendingFollowUp()) issueCapture.acceptFollowUp();
|
||
else issueCapture.acceptSharedContent();
|
||
qs('#shared-content-conflict').hidden = true;
|
||
sharedLaunchState = null;
|
||
clearSharedLaunchUrl();
|
||
openCreateIssueSheet();
|
||
qs('#create-issue-status').textContent = 'Shared content added. Choose a repository and finish planning.';
|
||
});
|
||
qs('#resume-issue-draft').addEventListener('click', () => {
|
||
issueCapture.discardSharedContent();
|
||
issueCapture.discardFollowUp();
|
||
updateFollowUp.discardSource();
|
||
qs('#shared-content-conflict').hidden = true;
|
||
sharedLaunchState = null;
|
||
clearSharedLaunchUrl();
|
||
qs('#create-issue-status').textContent = 'Existing draft restored.';
|
||
qs('#create-issue-title').focus();
|
||
});
|
||
qs('#cancel-new-issue').addEventListener('click', () => {
|
||
const discardEditedDraft = Boolean(editingOutboxId);
|
||
if (editingOutboxId) {
|
||
issueCapture.clearDraft();
|
||
editingOutboxId = null;
|
||
} else saveIssueCaptureDraft();
|
||
closeCreateIssueSheet(true, !discardEditedDraft);
|
||
});
|
||
['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector =>
|
||
qs(selector).addEventListener('input', () => {
|
||
saveIssueCaptureDraft();
|
||
if (selector === '#create-issue-title') scheduleIssueDuplicateCheck();
|
||
})
|
||
);
|
||
qs('#create-issue-repository').addEventListener('change', event => {
|
||
loadIssueLabels(event.target.value);
|
||
loadIssueMilestones(event.target.value);
|
||
qs('#create-issue-repository-search').value = event.target.value;
|
||
saveIssueCaptureDraft();
|
||
scheduleIssueDuplicateCheck();
|
||
updateIssueCreateActions();
|
||
});
|
||
qs('#create-issue-repository-search').addEventListener('input', event => {
|
||
clearTimeout(issueRepositorySearchTimer);
|
||
const query = event.target.value.trim();
|
||
const results = qs('#create-issue-repository-results');
|
||
const status = qs('#create-issue-repository-status');
|
||
if (query.length < 2) {
|
||
issueCapture.searchRepositories(query);
|
||
results.hidden = true;
|
||
status.textContent = query ? 'Enter at least 2 characters to search.' : 'Search or browse to choose a repository.';
|
||
return;
|
||
}
|
||
status.textContent = 'Searching accessible repositories…';
|
||
issueRepositorySearchTimer = setTimeout(async () => {
|
||
const state = await issueCapture.searchRepositories(query);
|
||
if (state.status === 'stale') return;
|
||
if (state.status === 'failed') {
|
||
results.hidden = true;
|
||
status.textContent = 'Repository search failed. Your draft is safe; retry or browse below.';
|
||
return;
|
||
}
|
||
renderIssueRepositoryResults(state.items);
|
||
status.textContent = state.items.length ? 'Choose a matching repository.' : 'No accessible repositories match.';
|
||
}, 250);
|
||
});
|
||
qs('#load-more-issue-repositories').addEventListener('click', async event => {
|
||
const button = event.currentTarget;
|
||
const status = qs('#create-issue-repository-status');
|
||
saveIssueCaptureDraft();
|
||
button.disabled = true;
|
||
status.textContent = 'Loading more repositories…';
|
||
try {
|
||
const payload = await issueCapture.loadRepositoryPage(nextIssueRepositoryPage);
|
||
appendIssueRepositories(payload.items);
|
||
nextIssueRepositoryPage = payload.page + 1;
|
||
moreIssueRepositoriesAvailable = payload.has_more;
|
||
button.hidden = !moreIssueRepositoriesAvailable;
|
||
status.textContent = payload.items.length ?
|
||
'More repositories are available in the selector.' : 'All accessible repositories are loaded.';
|
||
} catch (_error) {
|
||
status.textContent = 'Repositories could not be loaded. Your draft is safe; retry.';
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
|
||
qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft);
|
||
qs('#create-issue-anyway').addEventListener('click', () => {
|
||
const captureDraft = currentIssueCaptureDraft();
|
||
issueCapture.acknowledgeDuplicates(captureDraft);
|
||
qs('#create-issue-anyway').hidden = true;
|
||
qs('#create-issue-form').requestSubmit();
|
||
});
|
||
qs('#create-issue-form').addEventListener('submit', async event => {
|
||
event.preventDefault();
|
||
if (event.submitter) createAndStartRequested = event.submitter?.id === 'create-and-start-issue';
|
||
const followUpNextRequested = event.submitter?.id === 'create-follow-up-next';
|
||
const captureDraft = currentIssueCaptureDraft();
|
||
if (!captureDraft.repository || !captureDraft.title) {
|
||
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
|
||
qs('#create-issue-title').focus();
|
||
return;
|
||
}
|
||
if (issueCapture.needsDuplicateAcknowledgement(captureDraft)) {
|
||
qs('#create-issue-status').textContent = 'Review the possible existing issues, or choose Create anyway.';
|
||
qs('#create-issue-anyway').hidden = false;
|
||
qs('#create-issue-anyway').focus();
|
||
return;
|
||
}
|
||
if (createAndStartRequested && !createAndStart.available()) {
|
||
qs('#create-issue-status').textContent = 'Today is full. Remove an item before creating and starting another.';
|
||
qs('#create-and-start-issue').focus();
|
||
return;
|
||
}
|
||
const button = qs('#submit-new-issue');
|
||
const startButton = qs('#create-and-start-issue');
|
||
const followUpButton = qs('#create-follow-up-next');
|
||
button.disabled = true;
|
||
startButton.disabled = true;
|
||
followUpButton.disabled = true;
|
||
qs('#create-issue-status').textContent = createAndStartRequested ?
|
||
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
|
||
try {
|
||
const durableDraft = {
|
||
...captureDraft,
|
||
attachment: await createIssueAttachmentController.serialize(),
|
||
...(rUC ? { sourceCaptureId: rUC } : {}),
|
||
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
|
||
};
|
||
const admission = followUpNextRequested ? (await updateFollowUp.complete({
|
||
admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||
issueOutbox.enqueueDurably(durableDraft),
|
||
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
|
||
advance: source => notificationReader.acceptReadAndNext(lastMyWork, source),
|
||
})).admission : (editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||
await issueOutbox.enqueueDurably(durableDraft));
|
||
const queued = admission.item;
|
||
const fS = dFS.current();
|
||
if (rUC && (!durableDraft.attachment || admission.background)) {
|
||
await unfiledCaptures.completeResume(rUC);
|
||
rUC = '';
|
||
}
|
||
if (fS && !rUC && await dFS.advance(fS.id)) {
|
||
refreshMyWorkView();
|
||
return;
|
||
}
|
||
if (!admission.background) {
|
||
editingOutboxId = queued.id;
|
||
refreshMyWorkView();
|
||
qs('#create-issue-status').textContent = 'Saved for next launch; background delivery unavailable.';
|
||
button.disabled = false;
|
||
startButton.disabled = !createAndStart.available();
|
||
return;
|
||
}
|
||
editingOutboxId = null;
|
||
followUpSourceUpdate = null;
|
||
if (!followUpNextRequested) updateFollowUp.discardSource();
|
||
issueCapture.clearDraft();
|
||
createIssueAttachmentController.clear();
|
||
suppressCreateDraftOnHistoryClose = true;
|
||
taskOverlayHistory.leave();
|
||
refreshMyWorkView();
|
||
qs('#my-work-action-status').textContent = 'Queued for sync.';
|
||
if (navigator.onLine) applyOutboxResult(
|
||
await issueOutbox.retry(queued.id, activeFlushLogin), true, createAndStartRequested
|
||
);
|
||
createAndStartRequested = false;
|
||
} catch (error) {
|
||
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
||
button.disabled = false;
|
||
startButton.disabled = !createAndStart.available();
|
||
followUpButton.disabled = false;
|
||
qs('#create-issue-title').focus();
|
||
}
|
||
});
|
||
qs('#close-issue-sheet').addEventListener('click', closeIssueSheet);
|
||
qs('#retry-issue-load').addEventListener('click', () => {
|
||
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
|
||
});
|
||
qs('#issue-planning').addEventListener('toggle', event => {
|
||
if (event.currentTarget.open) loadIssuePlanning();
|
||
});
|
||
qs('#retry-issue-planning').addEventListener('click', loadIssuePlanning);
|
||
qs('#edit-issue-content').addEventListener('click', () => {
|
||
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
|
||
const draft = issueController.loadEditDraft(selectedIssue) || {
|
||
title: selectedIssueDetail.title || '',
|
||
body: selectedIssueDetail.body || '',
|
||
expectedUpdatedAt: selectedIssueDetail.updated_at,
|
||
};
|
||
qs('#issue-edit-title').value = draft.title;
|
||
qs('#issue-edit-body').value = draft.body;
|
||
qs('#issue-edit-form').hidden = false;
|
||
qs('#issue-edit-status').textContent = 'Edit the issue, then save.';
|
||
qs('#issue-edit-title').focus();
|
||
});
|
||
['#issue-edit-title', '#issue-edit-body'].forEach(selector =>
|
||
qs(selector).addEventListener('input', () => {
|
||
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
|
||
issueController.saveEditDraft(selectedIssue, {
|
||
title: qs('#issue-edit-title').value,
|
||
body: qs('#issue-edit-body').value,
|
||
expectedUpdatedAt: issueController.loadEditDraft(selectedIssue)?.expectedUpdatedAt || selectedIssueDetail.updated_at,
|
||
});
|
||
})
|
||
);
|
||
qs('#cancel-issue-content').addEventListener('click', () => {
|
||
qs('#issue-edit-form').hidden = true;
|
||
qs('#edit-issue-content').focus();
|
||
});
|
||
qs('#issue-edit-form').addEventListener('submit', async event => {
|
||
event.preventDefault();
|
||
if (!selectedIssue || !selectedIssueDetail?.updated_at || !lastContextSnapshot) return;
|
||
const title = qs('#issue-edit-title').value.trim();
|
||
const body = qs('#issue-edit-body').value.trim();
|
||
if (!title) {
|
||
qs('#issue-edit-status').textContent = 'Add a title before saving.';
|
||
qs('#issue-edit-title').focus();
|
||
return;
|
||
}
|
||
const editing = selectedIssue;
|
||
const savedDraft = issueController.loadEditDraft(editing);
|
||
const draft = {
|
||
title,
|
||
body,
|
||
expectedUpdatedAt: savedDraft?.expectedUpdatedAt || selectedIssueDetail.updated_at,
|
||
};
|
||
const button = qs('#save-issue-content');
|
||
button.disabled = true;
|
||
qs('#issue-edit-status').textContent = 'Saving issue…';
|
||
try {
|
||
const confirmed = await issueController.updateContent(editing, draft);
|
||
lastContextSnapshot = buildMyWork.replaceIssueContent(
|
||
lastContextSnapshot, editing.repository, editing.number, confirmed
|
||
);
|
||
selectedIssue = { ...editing, ...confirmed, key: editing.key };
|
||
selectedIssueDetail = { ...selectedIssueDetail, ...confirmed };
|
||
qs('#issue-sheet-title').textContent = confirmed.title;
|
||
qs('#issue-sheet-body').innerHTML = renderMarkdown(confirmed.body || 'No description provided.');
|
||
paintMyWork(lastContextSnapshot);
|
||
qs('#issue-edit-form').hidden = true;
|
||
qs('#issue-sheet-status').textContent = 'Issue saved.';
|
||
qs('#edit-issue-content').focus();
|
||
} catch (error) {
|
||
qs('#issue-edit-status').textContent = error.message + ' Your draft is safe; reload latest or open in Gitea.';
|
||
qs('#retry-issue-load').hidden = false;
|
||
qs('#issue-edit-title').focus();
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
qs('#issue-comment').addEventListener('input', event => {
|
||
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
|
||
});
|
||
qs('#load-older-issue-comments').addEventListener('click', async () => {
|
||
if (!issueConversation) return;
|
||
const button = qs('#load-older-issue-comments');
|
||
const panel = qs('#issue-sheet .issue-sheet-panel');
|
||
const previousHeight = panel.scrollHeight;
|
||
button.disabled = true;
|
||
qs('#issue-conversation-status').textContent = 'Loading older messages…';
|
||
try {
|
||
renderIssueConversation(await issueConversation.loadOlder());
|
||
panel.scrollTop += panel.scrollHeight - previousHeight;
|
||
} catch (error) {
|
||
qs('#issue-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.';
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
qs('#save-issue-labels').addEventListener('click', async () => {
|
||
if (!selectedIssue || !lastContextSnapshot) return;
|
||
const editing = selectedIssue;
|
||
const button = qs('#save-issue-labels');
|
||
button.disabled = true;
|
||
qs('#issue-label-status').textContent = 'Saving labels…';
|
||
try {
|
||
const confirmed = await issueController.updateLabels(selectedIssue, selectedEditIssueLabelIds());
|
||
lastContextSnapshot = buildMyWork.replaceIssueLabels(
|
||
lastContextSnapshot, editing.repository, editing.number, confirmed.labels
|
||
);
|
||
selectedIssue = { ...editing, labels: confirmed.labels };
|
||
qs('#issue-labels').innerHTML = confirmed.labels.map(label =>
|
||
'<span class="pill">' + escapeHtml(label) + '</span>'
|
||
).join(' ');
|
||
paintMyWork(lastContextSnapshot);
|
||
qs('#issue-label-status').textContent = 'Labels saved. My Work reprioritized.';
|
||
} catch (error) {
|
||
qs('#issue-label-status').textContent = error.message + ' Your selection is safe; retry.';
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
|
||
async function saveSelectedIssueDueDate(dueDate) {
|
||
if (!selectedIssue || !lastContextSnapshot) return;
|
||
const editing = selectedIssue;
|
||
const saveButton = qs('#save-issue-due-date');
|
||
const clearButton = qs('#clear-issue-due-date');
|
||
saveButton.disabled = true;
|
||
clearButton.disabled = true;
|
||
qs('#issue-due-status').textContent = dueDate ? 'Saving due date…' : 'Clearing due date…';
|
||
try {
|
||
const confirmed = await issueController.updateDueDate(editing, dueDate);
|
||
lastContextSnapshot = buildMyWork.replaceIssueDueDate(
|
||
lastContextSnapshot, editing.repository, editing.number, confirmed.due_date
|
||
);
|
||
selectedIssue = { ...editing, due_date: confirmed.due_date };
|
||
selectedIssueDetail = { ...selectedIssueDetail, due_date: confirmed.due_date };
|
||
qs('#issue-due-date').value = String(confirmed.due_date || '').slice(0, 10);
|
||
paintMyWork(lastContextSnapshot);
|
||
qs('#issue-due-status').textContent = confirmed.due_date ?
|
||
'Due date saved. My Work reprioritized.' : 'Due date cleared.';
|
||
} catch (error) {
|
||
qs('#issue-due-status').textContent = error.message + ' Your selection is safe; retry.';
|
||
qs('#issue-due-date').focus();
|
||
} finally {
|
||
saveButton.disabled = false;
|
||
clearButton.disabled = !selectedIssueDetail?.due_date;
|
||
}
|
||
}
|
||
|
||
qs('#save-issue-due-date').addEventListener('click', () => {
|
||
const value = qs('#issue-due-date').value;
|
||
if (!value) {
|
||
qs('#issue-due-status').textContent = 'Choose a date or use Clear due date.';
|
||
qs('#issue-due-date').focus();
|
||
return;
|
||
}
|
||
saveSelectedIssueDueDate(value + 'T23:59:59Z');
|
||
});
|
||
qs('#clear-issue-due-date').addEventListener('click', () => saveSelectedIssueDueDate(null));
|
||
qs('#save-issue-milestone').addEventListener('click', async () => {
|
||
if (!selectedIssue || !lastContextSnapshot) return;
|
||
const editing = selectedIssue;
|
||
const button = qs('#save-issue-milestone');
|
||
const raw = qs('#issue-milestone').value;
|
||
const milestoneId = raw ? Number(raw) : null;
|
||
button.disabled = true;
|
||
qs('#issue-milestone').disabled = true;
|
||
qs('#issue-milestone-status').textContent = milestoneId ? 'Saving milestone…' : 'Clearing milestone…';
|
||
try {
|
||
const confirmed = await issueController.updateMilestone(editing, milestoneId);
|
||
lastContextSnapshot = buildMyWork.replaceIssueMilestone(
|
||
lastContextSnapshot, editing.repository, editing.number, confirmed.milestone
|
||
);
|
||
selectedIssue = { ...editing, milestone: confirmed.milestone };
|
||
selectedIssueDetail = { ...selectedIssueDetail, milestone: confirmed.milestone };
|
||
paintMyWork(lastContextSnapshot);
|
||
qs('#issue-milestone-status').textContent = confirmed.milestone ?
|
||
'Planned for ' + confirmed.milestone.title + '. Release lane updated.' : 'Milestone cleared.';
|
||
} catch (error) {
|
||
qs('#issue-milestone-status').textContent = error.message + ' Your selection is safe; retry.';
|
||
qs('#issue-milestone').focus();
|
||
} finally {
|
||
button.disabled = false;
|
||
qs('#issue-milestone').disabled = false;
|
||
}
|
||
});
|
||
async function queueIssueScreenshotComment(item, body, operationId, advance = false) {
|
||
const message = {
|
||
kind: 'issue-comment', repository: item.repository, number: item.number, body,
|
||
operationId: operationId || globalThis.crypto?.randomUUID?.() || String(Date.now()),
|
||
attachment: await issueAttachmentController.serialize(),
|
||
};
|
||
if (advance) return await issueCommentNext.admit(item, message);
|
||
const admission = await authoredOutbox.enqueueDurably(message);
|
||
issueController.saveDraft(item, '');
|
||
if (selectedIssue === item) qs('#issue-comment').value = '';
|
||
return admission;
|
||
}
|
||
|
||
async function queuePullScreenshotComment(item, body, operationId, advance = false, deliver = false) {
|
||
const message = {
|
||
kind: 'pull-comment', repository: item.repository, number: item.number, body,
|
||
operationId: operationId || globalThis.crypto?.randomUUID?.() || String(Date.now()),
|
||
attachment: await pullAttachmentController.serialize(),
|
||
};
|
||
if (advance) return await pullCommentNext.admit(item, message);
|
||
const admission = await authoredOutbox.enqueueDurably(message);
|
||
if (deliver) {
|
||
const delivery = await authoredOutbox.retry(admission.item.id, activeFlushLogin);
|
||
return { ...admission, delivered: delivery.confirmed?.[0] || null };
|
||
}
|
||
pullController.saveDraft(item, '');
|
||
if (selectedPull === item) qs('#pull-comment').value = '';
|
||
return admission;
|
||
}
|
||
|
||
async function submitCommentAndNext(kind) {
|
||
const item = kind === 'issue' ? selectedIssue : selectedPull;
|
||
if (!item || !workSession.checkpointed(item)) return;
|
||
const textarea = qs('#' + kind + '-comment');
|
||
const status = qs('#' + kind + '-comment-status');
|
||
const body = textarea.value.trim();
|
||
const attachmentController = kind === 'issue' ? issueAttachmentController : pullAttachmentController;
|
||
const queueScreenshot = kind === 'issue' ? queueIssueScreenshotComment : queuePullScreenshotComment;
|
||
if (!body && !attachmentController.state()) {
|
||
status.textContent = 'Write a comment before posting.';
|
||
textarea.focus();
|
||
return;
|
||
}
|
||
const postButton = qs('#send-' + kind + '-comment');
|
||
const nextButton = qs('#send-' + kind + '-comment-next');
|
||
const controller = kind === 'issue' ? issueCommentNext : pullCommentNext;
|
||
const operationId = () => localStorage.getItem('stackchain.' + kind + '-comment.v1:' +
|
||
item.repository + '#' + item.number + ':operation');
|
||
postButton.disabled = true;
|
||
nextButton.disabled = true;
|
||
attachmentController.setBusy(true);
|
||
status.textContent = attachmentController.state() ?
|
||
'Uploading screenshot before opening next…' : 'Posting comment and opening next…';
|
||
try {
|
||
let result;
|
||
if (attachmentController.state() && (kind === 'pull' || navigator.onLine === false)) {
|
||
result = await queueScreenshot(item, body, operationId(), true);
|
||
} else {
|
||
let preparedBody;
|
||
try {
|
||
preparedBody = kind === 'issue' ?
|
||
await issueAttachmentController.prepareComment(item, body) :
|
||
await pullAttachmentController.prepareComment(item, body);
|
||
} catch (error) {
|
||
if (!attachmentController.state() || !canQueueMessage(error)) throw error;
|
||
result = await queueScreenshot(item, body, operationId(), true);
|
||
}
|
||
if (!result) result = await controller.submit(item, preparedBody, operationId);
|
||
}
|
||
const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item;
|
||
if (!stillOpen) return;
|
||
attachmentController.clear();
|
||
if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.';
|
||
else if (result.delivery === 'posted') status.textContent = 'Comment posted.';
|
||
else if (result.background) status.textContent = 'Queued for sync when the connection returns.';
|
||
else status.textContent = 'Saved for next launch; background delivery unavailable.';
|
||
} catch (error) {
|
||
status.textContent = error.message + ' Your draft, screenshot, and Today position are safe; retry.';
|
||
textarea.focus();
|
||
} finally {
|
||
attachmentController.setBusy(false);
|
||
postButton.disabled = false;
|
||
nextButton.disabled = false;
|
||
}
|
||
}
|
||
qs('#send-issue-comment-next').addEventListener('click', () => submitCommentAndNext('issue'));
|
||
qs('#send-pull-comment-next').addEventListener('click', () => submitCommentAndNext('pull'));
|
||
qs('#send-issue-comment').addEventListener('click', async () => {
|
||
if (!selectedIssue) return;
|
||
const body = qs('#issue-comment').value.trim();
|
||
if (!body && !issueAttachmentController.state()) {
|
||
qs('#issue-comment-status').textContent = 'Write a comment before posting.';
|
||
qs('#issue-comment').focus();
|
||
return;
|
||
}
|
||
const button = qs('#send-issue-comment');
|
||
button.disabled = true;
|
||
qs('#issue-comment-status').textContent = issueAttachmentController.state() ?
|
||
'Uploading screenshot…' : 'Posting comment…';
|
||
let preparedBody;
|
||
try {
|
||
if (issueAttachmentController.state() && navigator.onLine === false) {
|
||
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
|
||
const admission = await queueIssueScreenshotComment(selectedIssue, body, operationId);
|
||
refreshMyWorkView();
|
||
issueAttachmentController.clear();
|
||
qs('#issue-comment-status').textContent = admission.background ?
|
||
'Queued with screenshot for sync when the connection returns.' :
|
||
'Saved with screenshot for next launch; background delivery unavailable.';
|
||
button.disabled = false;
|
||
return;
|
||
}
|
||
preparedBody = await issueAttachmentController.prepareComment(selectedIssue, body);
|
||
} catch (error) {
|
||
if (issueAttachmentController.state() && canQueueMessage(error)) {
|
||
try {
|
||
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
|
||
const admission = await queueIssueScreenshotComment(selectedIssue, body, operationId);
|
||
refreshMyWorkView();
|
||
issueAttachmentController.clear();
|
||
qs('#issue-comment-status').textContent = admission.background ?
|
||
'Queued with screenshot for sync when the connection returns.' :
|
||
'Saved with screenshot for next launch; background delivery unavailable.';
|
||
button.disabled = false;
|
||
return;
|
||
} catch (admissionError) {
|
||
error = admissionError;
|
||
}
|
||
}
|
||
qs('#issue-comment-status').textContent = error.message + ' Your comment and screenshot are safe; retry.';
|
||
qs('#issue-comment').focus();
|
||
button.disabled = false;
|
||
return;
|
||
}
|
||
try {
|
||
const comment = await issueController.comment(selectedIssue, preparedBody);
|
||
if (issueConversation) renderIssueConversation(issueConversation.append(comment));
|
||
qs('#issue-comment').value = '';
|
||
issueAttachmentController.clear();
|
||
qs('#issue-comment-status').textContent = 'Comment posted.';
|
||
} catch (error) {
|
||
if (canQueueMessage(error)) {
|
||
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
|
||
qs('#issue-comment-status').textContent = 'Saving for background delivery…';
|
||
const admission = await authoredOutbox.enqueueDurably({ kind:'issue-comment', repository:selectedIssue.repository,
|
||
number:selectedIssue.number, body:preparedBody, operationId });
|
||
refreshMyWorkView();
|
||
if (admission.background) {
|
||
qs('#issue-comment').value = '';
|
||
issueAttachmentController.clear();
|
||
qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.';
|
||
} else {
|
||
qs('#issue-comment-status').textContent = 'Saved for next launch; background delivery unavailable.';
|
||
qs('#issue-comment').focus();
|
||
}
|
||
} else {
|
||
qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.';
|
||
qs('#issue-comment').focus();
|
||
}
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
qs('#release-issue').addEventListener('click', async () => {
|
||
if (!selectedIssue || !window.confirm('Release ' + selectedIssue.key + ' from your My Work?')) return;
|
||
const releasing = selectedIssue;
|
||
const continuingSession = workSession.checkpointed(releasing);
|
||
const button = qs('#release-issue');
|
||
button.disabled = true;
|
||
qs('#issue-sheet-status').textContent = 'Releasing assignment…';
|
||
try {
|
||
const confirmed = await issueController.release(selectedIssue, lastContextSnapshot?.user?.login);
|
||
lastContextSnapshot = buildMyWork.removeIssue(
|
||
lastContextSnapshot, releasing.repository, releasing.number
|
||
);
|
||
closeIssueSheet();
|
||
if (continuingSession) {
|
||
const transitionResult = await completeOwnershipExitToday(releasing);
|
||
if (transitionResult === 'opened') {
|
||
qs('#my-work-action-status').textContent = releasing.key + ' released. Next work item opened.';
|
||
} else if (transitionResult === 'gated') {
|
||
qs('#my-work-action-status').textContent = releasing.key + ' released. Choose the next ready Today item.';
|
||
} else if (transitionResult === null) {
|
||
qs('#my-work-action-status').textContent = releasing.key + ' released, but Today still needs completion.';
|
||
}
|
||
} else {
|
||
paintMyWork(lastContextSnapshot);
|
||
qs('#my-work-action-status').textContent = releasing.key + ' released.' +
|
||
(confirmed.available ? ' It is available in Find Work.' : ' Other assignees remain.');
|
||
}
|
||
} catch (error) {
|
||
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
|
||
button.disabled = false;
|
||
button.focus();
|
||
}
|
||
});
|
||
qs('#load-issue-handoff').addEventListener('click', async () => {
|
||
if (!selectedIssue) return;
|
||
const button = qs('#load-issue-handoff');
|
||
const select = qs('#issue-handoff-recipient');
|
||
button.disabled = true;
|
||
qs('#issue-handoff-status').textContent = 'Loading eligible teammates…';
|
||
try {
|
||
const candidates = await issueController.loadHandoffCandidates(selectedIssue);
|
||
select.textContent = '';
|
||
const placeholder = document.createElement('option');
|
||
placeholder.value = '';
|
||
placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates';
|
||
select.appendChild(placeholder);
|
||
candidates.forEach(candidate => {
|
||
const option = document.createElement('option');
|
||
option.value = candidate.login;
|
||
option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')');
|
||
select.appendChild(option);
|
||
});
|
||
select.disabled = !candidates.length;
|
||
qs('#confirm-issue-handoff').disabled = true;
|
||
qs('#issue-handoff-status').textContent = candidates.length ?
|
||
'Choose who should own this issue next.' : 'No other eligible assignees were found.';
|
||
if (candidates.length) select.focus();
|
||
} catch (error) {
|
||
qs('#issue-handoff-status').textContent = error.message + ' Retry loading teammates.';
|
||
button.disabled = false;
|
||
button.focus();
|
||
}
|
||
});
|
||
qs('#issue-handoff-recipient').addEventListener('change', event => {
|
||
qs('#confirm-issue-handoff').disabled = !event.target.value;
|
||
});
|
||
qs('#confirm-issue-handoff').addEventListener('click', async () => {
|
||
const recipient = qs('#issue-handoff-recipient').value;
|
||
if (!selectedIssue || !recipient || !window.confirm('Hand off ' + selectedIssue.key + ' to @' + recipient + '?')) return;
|
||
const handingOff = selectedIssue;
|
||
const continuingSession = workSession.checkpointed(handingOff);
|
||
const button = qs('#confirm-issue-handoff');
|
||
button.disabled = true;
|
||
qs('#issue-handoff-status').textContent = 'Confirming handoff…';
|
||
try {
|
||
await issueController.handoff(selectedIssue, recipient, lastContextSnapshot?.user?.login);
|
||
lastContextSnapshot = buildMyWork.removeIssue(
|
||
lastContextSnapshot, handingOff.repository, handingOff.number
|
||
);
|
||
closeIssueSheet();
|
||
if (continuingSession) {
|
||
const transitionResult = await completeOwnershipExitToday(handingOff);
|
||
if (transitionResult === 'opened') {
|
||
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '. Next work item opened.';
|
||
} else if (transitionResult === 'gated') {
|
||
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '. Choose the next ready Today item.';
|
||
} else if (transitionResult === null) {
|
||
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + ', but Today still needs completion.';
|
||
}
|
||
} else {
|
||
paintMyWork(lastContextSnapshot);
|
||
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '.';
|
||
}
|
||
} catch (error) {
|
||
qs('#issue-handoff-status').textContent = error.message + ' The issue remains in My Work; retry.';
|
||
button.disabled = false;
|
||
button.focus();
|
||
}
|
||
});
|
||
qs('#close-issue').addEventListener('click', async () => {
|
||
if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return;
|
||
const closing = selectedIssue;
|
||
const button = qs('#close-issue');
|
||
button.disabled = true;
|
||
if (selectedIssueOffline) {
|
||
qs('#issue-sheet-status').textContent = 'Saving issue closure for background delivery…';
|
||
try {
|
||
const outcome = await closeOfflineIssue(closing);
|
||
closeIssueSheet();
|
||
refreshMyWorkView({ reconcileSession:false });
|
||
if (!outcome.advanced) {
|
||
qs('#my-work-action-status').textContent = 'Issue closure queued, but Today still needs completion.';
|
||
} else if (outcome.admission.background) {
|
||
qs('#my-work-action-status').textContent = 'Issue closure queued for reconnect. Next Today item opened.';
|
||
} else {
|
||
qs('#my-work-action-status').textContent = 'Issue closure saved for next launch. Next Today item opened.';
|
||
}
|
||
} catch (error) {
|
||
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in Today; retry.';
|
||
button.disabled = false;
|
||
button.focus();
|
||
}
|
||
return;
|
||
}
|
||
qs('#issue-sheet-status').textContent = 'Closing issue…';
|
||
try {
|
||
await issueController.close(selectedIssue);
|
||
closeIssueSheet();
|
||
lastMyWork = lastMyWork.filter(item =>
|
||
!(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number)
|
||
);
|
||
refreshMyWorkView({ reconcileSession:false });
|
||
const continuingSession = workSession.active();
|
||
const transitionResult = workSession.active() ? await runTodayTransition('complete') : null;
|
||
if (transitionResult === 'opened') {
|
||
qs('#my-work-action-status').textContent = closing.key + ' closed. Next work item opened.';
|
||
} else if (transitionResult === 'gated') {
|
||
qs('#my-work-action-status').textContent = closing.key + ' closed. Choose the next ready Today item.';
|
||
} else if (!continuingSession) {
|
||
qs('#my-work-action-status').textContent = closing.key + ' closed.';
|
||
}
|
||
} catch (error) {
|
||
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
|
||
button.disabled = false;
|
||
button.focus();
|
||
}
|
||
});
|
||
qs('#close-pull-sheet').addEventListener('click', closePullSheet);
|
||
qs('#retry-pull-load').addEventListener('click', () => {
|
||
if (selectedPull) openPullSheet(selectedPull, pullTrigger);
|
||
});
|
||
qs('#pull-review').addEventListener('toggle', event => {
|
||
if (event.currentTarget.open) loadPullReview();
|
||
});
|
||
qs('#pull-review-retry').addEventListener('click', loadPullReview);
|
||
qs('#refresh-pull-checks').addEventListener('click', async () => {
|
||
if (!selectedPull || !selectedPullDetail?.head_sha) return;
|
||
const item = selectedPull;
|
||
const button = qs('#refresh-pull-checks');
|
||
button.disabled = true;
|
||
qs('#pull-checks-summary').textContent = 'Refreshing…';
|
||
try {
|
||
const status = await pullController.loadChecks(item);
|
||
if (selectedPull !== item) return;
|
||
renderPullCheckStatus(status);
|
||
} catch (_error) {
|
||
if (selectedPull === item) qs('#pull-checks-summary').textContent = 'Refresh failed · retry';
|
||
} finally { button.disabled = false; }
|
||
});
|
||
qs('#next-unreviewed-pull-file').addEventListener('click', () => {
|
||
createPullSheet.focusNextUnreviewed(document, selectedPullDetail, pullReviewState, pullController);
|
||
});
|
||
qs('#load-older-pull-comments').addEventListener('click', async () => {
|
||
if (!pullConversation) return;
|
||
const button = qs('#load-older-pull-comments');
|
||
const panel = qs('#pull-sheet .pull-sheet-panel');
|
||
const previousHeight = panel.scrollHeight;
|
||
button.disabled = true;
|
||
qs('#pull-conversation-status').textContent = 'Loading older messages…';
|
||
try {
|
||
renderPullConversation(await pullConversation.loadOlder());
|
||
panel.scrollTop += panel.scrollHeight - previousHeight;
|
||
} catch (error) {
|
||
qs('#pull-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.';
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
qs('#pull-comment').addEventListener('input', event => {
|
||
if (selectedPull) pullController.saveDraft(selectedPull, event.target.value);
|
||
});
|
||
qs('#send-pull-comment').addEventListener('click', async () => {
|
||
if (!selectedPull) return;
|
||
const item = selectedPull;
|
||
const body = qs('#pull-comment').value.trim();
|
||
if (!body && !pullAttachmentController.state()) {
|
||
qs('#pull-comment-status').textContent = 'Write a comment before posting.';
|
||
qs('#pull-comment').focus();
|
||
return;
|
||
}
|
||
const button = qs('#send-pull-comment');
|
||
button.disabled = true;
|
||
pullAttachmentController.setBusy(true);
|
||
qs('#pull-comment-status').textContent = pullAttachmentController.state() ?
|
||
'Uploading screenshot…' : 'Posting comment…';
|
||
const operationId = localStorage.getItem('stackchain.pull-comment.v1:' +
|
||
item.repository + '#' + item.number + ':operation');
|
||
let preparedBody;
|
||
try {
|
||
if (pullAttachmentController.state()) {
|
||
const admission = await queuePullScreenshotComment(item, body, operationId, false, true);
|
||
refreshMyWorkView();
|
||
pullController.saveDraft(item, '');
|
||
if (selectedPull === item) {
|
||
if (admission.delivered && pullConversation) {
|
||
renderPullConversation(pullConversation.append(admission.delivered));
|
||
}
|
||
qs('#pull-comment').value = '';
|
||
pullAttachmentController.clear();
|
||
qs('#pull-comment-status').textContent = admission.delivered ? 'Comment posted.' :
|
||
(admission.background ? 'Queued with screenshot for sync when the connection returns.' :
|
||
'Saved with screenshot for next launch; background delivery unavailable.');
|
||
}
|
||
return;
|
||
}
|
||
preparedBody = body;
|
||
const comment = await pullController.comment(item, preparedBody);
|
||
if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment));
|
||
pullController.saveDraft(item, '');
|
||
if (selectedPull === item) qs('#pull-comment').value = '';
|
||
pullAttachmentController.clear();
|
||
qs('#pull-comment-status').textContent = 'Comment posted.';
|
||
} catch (error) {
|
||
if (canQueueMessage(error) && !pullAttachmentController.state()) {
|
||
try {
|
||
qs('#pull-comment-status').textContent = 'Saving for background delivery…';
|
||
const admission = await authoredOutbox.enqueueDurably({
|
||
kind:'pull-comment', repository:item.repository, number:item.number,
|
||
body:preparedBody ?? body, operationId,
|
||
});
|
||
pullController.saveDraft(item, '');
|
||
if (selectedPull === item) qs('#pull-comment').value = '';
|
||
refreshMyWorkView();
|
||
qs('#pull-comment-status').textContent = admission.background ?
|
||
'Queued for sync when the connection returns.' :
|
||
'Saved for next launch; background delivery unavailable.';
|
||
return;
|
||
} catch (admissionError) {
|
||
error = admissionError;
|
||
}
|
||
}
|
||
qs('#pull-comment-status').textContent = error.message + ' Your comment and screenshot are safe; retry.';
|
||
qs('#pull-comment').focus();
|
||
} finally {
|
||
pullAttachmentController.setBusy(false);
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
qs('#merge-pull').addEventListener('click', async () => {
|
||
if (!selectedPull || !selectedPullDetail?.head_sha || !window.confirm('Merge ' + selectedPull.key + ' at current head?')) return;
|
||
const merging = selectedPull;
|
||
const button = qs('#merge-pull');
|
||
button.disabled = true;
|
||
qs('#pull-sheet-status').textContent = 'Merging pull request…';
|
||
try {
|
||
await pullController.merge(selectedPull, selectedPullDetail.head_sha);
|
||
closePullSheet();
|
||
lastMyWork = lastMyWork.filter(item =>
|
||
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
|
||
);
|
||
refreshMyWorkView({ reconcileSession:false });
|
||
const continuingSession = workSession.active();
|
||
const transitionResult = workSession.active() ? await runTodayTransition('complete') : null;
|
||
if (transitionResult === 'opened') {
|
||
qs('#my-work-action-status').textContent = merging.key + ' merged. Next work item opened.';
|
||
} else if (transitionResult === 'gated') {
|
||
qs('#my-work-action-status').textContent = merging.key + ' merged. Choose the next ready Today item.';
|
||
} else if (!continuingSession) {
|
||
qs('#my-work-action-status').textContent = merging.key + ' merged.';
|
||
}
|
||
} catch (error) {
|
||
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; refresh and retry.';
|
||
button.disabled = false;
|
||
button.focus();
|
||
}
|
||
});
|
||
qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true));
|
||
qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act());
|
||
qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start());
|
||
qs('#retry-update-load').addEventListener('click', () => {
|
||
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);
|
||
});
|
||
qs('#load-older-update-comments').addEventListener('click', async () => {
|
||
const button = qs('#load-older-update-comments');
|
||
const panel = qs('#update-sheet .update-sheet-panel');
|
||
const previousHeight = panel.scrollHeight;
|
||
const previousTop = panel.scrollTop;
|
||
button.disabled = true;
|
||
await notificationReader.loadOlder();
|
||
panel.scrollTop = previousTop + (panel.scrollHeight - previousHeight);
|
||
button.disabled = false;
|
||
});
|
||
qs('#update-reply').addEventListener('input', event => {
|
||
if (selectedUpdate) notificationReplier.saveDraft(selectedUpdate, event.target.value);
|
||
});
|
||
qs('#send-update-reply').addEventListener('click', async () => {
|
||
if (!selectedUpdate) return;
|
||
const body = qs('#update-reply').value.trim();
|
||
const attachment = await updateReplyAttachmentController.serialize();
|
||
if (!body && !attachment) {
|
||
qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
|
||
qs('#update-reply').focus();
|
||
return;
|
||
}
|
||
qs('#send-update-reply').disabled = true;
|
||
updateReplyAttachmentController.setBusy(true);
|
||
const result = await notificationReplier.submit(selectedUpdate, body, attachment);
|
||
qs('#send-update-reply').disabled = false;
|
||
updateReplyAttachmentController.setBusy(false);
|
||
if (result?.queued) {
|
||
qs('#update-reply').value = '';
|
||
updateReplyAttachmentController.clear();
|
||
refreshMyWorkView();
|
||
qs('#my-work-action-status').textContent = 'Reply queued for sync.';
|
||
} else if (result) {
|
||
notificationReader.appendReply(result);
|
||
qs('#update-reply').value = '';
|
||
updateReplyAttachmentController.clear();
|
||
qs('#mark-update-read-next').focus();
|
||
} else {
|
||
qs('#update-reply').focus();
|
||
}
|
||
});
|
||
qs('#send-update-reply-read-next').addEventListener('click', async () => {
|
||
if (!selectedUpdate) return;
|
||
const item = selectedUpdate;
|
||
const body = qs('#update-reply').value.trim();
|
||
const attachment = await updateReplyAttachmentController.serialize();
|
||
if (!body && !attachment) {
|
||
qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
|
||
qs('#update-reply').focus();
|
||
return;
|
||
}
|
||
const button = qs('#send-update-reply-read-next');
|
||
const sendButton = qs('#send-update-reply');
|
||
button.disabled = true;
|
||
sendButton.disabled = true;
|
||
updateReplyAttachmentController.setBusy(true);
|
||
qs('#update-reply-status').textContent = 'Replying, then marking read…';
|
||
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
|
||
try {
|
||
const result = await updateReplyReadNext.submit(item, body, operationId, attachment);
|
||
if (result?.accepted) updateReplyAttachmentController.clear();
|
||
if (result?.accepted) qs('#my-work-action-status').textContent =
|
||
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
|
||
'Reply and read acknowledgement queued for sync.';
|
||
if (result?.delivery === 'posted' && result?.next) {
|
||
notificationUndo.offer(item, result.next.items);
|
||
}
|
||
} catch (error) {
|
||
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
|
||
qs('#update-reply').focus();
|
||
} finally {
|
||
button.disabled = false;
|
||
sendButton.disabled = false;
|
||
updateReplyAttachmentController.setBusy(false);
|
||
}
|
||
});
|
||
qs('#mark-update-read-next').addEventListener('click', async () => {
|
||
qs('#mark-update-read-next').disabled = true;
|
||
try {
|
||
const result = await notificationReader.markReadAndNext(lastMyWork);
|
||
if (result) notificationUndo.offer(result.item, result.items);
|
||
} finally {
|
||
qs('#mark-update-read-next').disabled = false;
|
||
}
|
||
});
|
||
qs('#acknowledge-update-next').addEventListener('click', async () => {
|
||
const button = qs('#acknowledge-update-next');
|
||
button.disabled = true;
|
||
try {
|
||
const result = await notificationReader.acknowledgeAndNext(lastMyWork);
|
||
if (result) notificationUndo.offer(result.item, result.items);
|
||
} finally {
|
||
button.disabled = offlineWorkMode;
|
||
}
|
||
});
|
||
qs('#undo-notification').addEventListener('click', async () => {
|
||
const button = qs('#undo-notification');
|
||
button.disabled = true;
|
||
const restored = await notificationUndo.run();
|
||
button.disabled = restored;
|
||
});
|
||
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
|
||
qs('#retry-review-load').addEventListener('click', () => {
|
||
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
|
||
});
|
||
qs('#refresh-review-checks').addEventListener('click', async () => {
|
||
if (!selectedReview || offlineReview) return;
|
||
const item = selectedReview;
|
||
const button = qs('#refresh-review-checks');
|
||
button.disabled = true;
|
||
qs('#review-checks-summary').textContent = 'Refreshing…';
|
||
try {
|
||
const detail = await reviewController.loadChecks(item);
|
||
if (selectedReview !== item) return;
|
||
if (selectedReviewHead && detail.head_sha !== selectedReviewHead) {
|
||
qs('#review-sheet-status').textContent = 'New commits detected. Reload the review before submitting feedback.';
|
||
qs('#submit-review').disabled = true;
|
||
}
|
||
qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
|
||
renderCheckSection('review', detail);
|
||
} catch (error) {
|
||
if (selectedReview === item) qs('#review-checks-summary').textContent = 'Refresh failed · retry';
|
||
} finally { button.disabled = false; }
|
||
});
|
||
qs('#next-unreviewed-review').addEventListener('click', () => {
|
||
if (progress) openNextUnreviewed(progress.snapshot());
|
||
});
|
||
qs('#review-wrap-lines').addEventListener('click', () => {
|
||
const next = !wrapPreference.snapshot().wrapped;
|
||
applyReviewWrap(wrapPreference.setWrapped(next));
|
||
});
|
||
qs('#review-summary').addEventListener('input', event => draft?.setSummary(event.target.value));
|
||
qs('#review-decision').addEventListener('change', event => draft?.setDecision(event.target.value));
|
||
qs('#save-inline-comment').addEventListener('click', () => {
|
||
if (!draft || !activeInlineTarget) return;
|
||
const body = qs('#review-inline-body').value.trim();
|
||
if (!body) {
|
||
qs('#review-inline-body').focus();
|
||
return;
|
||
}
|
||
draft.setInlineComment(inlineAnchor(activeInlineTarget), body);
|
||
activeInlineTarget.classList.add('has-draft');
|
||
const target = activeInlineTarget;
|
||
closeInlineComposer();
|
||
target.focus();
|
||
});
|
||
qs('#delete-inline-comment').addEventListener('click', () => {
|
||
if (!draft || !activeInlineTarget) return;
|
||
draft.removeInlineComment(inlineAnchor(activeInlineTarget));
|
||
activeInlineTarget.classList.remove('has-draft');
|
||
const target = activeInlineTarget;
|
||
closeInlineComposer();
|
||
target.focus();
|
||
});
|
||
qs('#cancel-inline-comment').addEventListener('click', () => {
|
||
const target = activeInlineTarget;
|
||
closeInlineComposer();
|
||
target?.focus();
|
||
});
|
||
qs('#submit-review').addEventListener('click', async () => {
|
||
if (!draft || !selectedReview || !selectedReviewHead) return;
|
||
const snapshot = draft.snapshot();
|
||
const labels = { approve: 'Approve', request_changes: 'Request changes' };
|
||
if (labels[snapshot.decision] && !window.confirm(
|
||
labels[snapshot.decision] + ' ' + selectedReview.repository + '#' + selectedReview.number + '?'
|
||
)) return;
|
||
const button = qs('#submit-review');
|
||
button.disabled = true;
|
||
if (offlineReview) {
|
||
const queuedTodayReview = reviewingActiveTodayItem();
|
||
qs('#review-submit-status').textContent = 'Queueing review safely…';
|
||
try {
|
||
await authoredOutbox.enqueueDurably({
|
||
kind: 'pull-review',
|
||
repository: selectedReview.repository,
|
||
number: selectedReview.number,
|
||
body: createReviewController.formatFeedback(snapshot, reviewFiles),
|
||
decision: snapshot.decision,
|
||
expectedHeadSha: selectedReviewHead,
|
||
comments: snapshot.comments,
|
||
draftKey: draft.storageKey,
|
||
progressKey: progress?.storageKey || '',
|
||
draftFingerprint: localStorage.getItem(draft.storageKey) || '',
|
||
progressFingerprint: localStorage.getItem(progress?.storageKey) || '',
|
||
});
|
||
if (queuedTodayReview) {
|
||
const advanced = completeTodayItem(selectedReview, {
|
||
successMessage: 'Review queued. Next Today item opened.',
|
||
failureMessage: 'Review queued, but Today still needs completion.',
|
||
});
|
||
if (!advanced) {
|
||
qs('#review-submit-status').textContent = 'Review queued, but Today still needs completion.';
|
||
}
|
||
} else {
|
||
qs('#review-submit-status').textContent = 'Review queued · it will submit after reconnect.';
|
||
}
|
||
} catch (error) {
|
||
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry when ready.';
|
||
button.disabled = false;
|
||
button.focus();
|
||
}
|
||
return;
|
||
}
|
||
qs('#review-submit-status').textContent = 'Submitting review…';
|
||
try {
|
||
const item = selectedReview;
|
||
const progressSnapshot = progress?.snapshot() || { reviewed: [] };
|
||
const result = await reviewController.submit(selectedReview, {
|
||
decision: snapshot.decision,
|
||
body: createReviewController.formatFeedback(snapshot, reviewFiles),
|
||
expected_head_sha: selectedReviewHead,
|
||
comments: snapshot.comments,
|
||
});
|
||
const canContinueToMerge = createReviewController.prepareMergeContinuation({
|
||
storage: localStorage,
|
||
item,
|
||
headSha: selectedReviewHead,
|
||
reviewed: progressSnapshot.reviewed,
|
||
decision: snapshot.decision,
|
||
});
|
||
draft.clear();
|
||
progress?.clear();
|
||
qs('#review-decision').value = 'comment';
|
||
qs('#review-summary').value = '';
|
||
document.querySelectorAll('.review-note').forEach(note => { note.value = ''; });
|
||
if (progress) showReviewProgress(progress.snapshot());
|
||
qs('#review-submit-status').textContent = 'Review submitted · ' + (result.state || 'complete') + '.';
|
||
if (canContinueToMerge) {
|
||
qs('#continue-review-to-merge').hidden = false;
|
||
qs('#continue-review-to-merge').focus();
|
||
} else {
|
||
await load();
|
||
if (workSession.active()) await runTodayTransition('complete');
|
||
else button.focus();
|
||
}
|
||
} catch (error) {
|
||
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry or open in Gitea.';
|
||
button.disabled = false;
|
||
button.focus();
|
||
}
|
||
});
|
||
qs('#continue-review-to-merge').addEventListener('click', () => {
|
||
const item = selectedReview;
|
||
if (!item) return;
|
||
workRoute.open({ ...item, kind:'pull', is_review:false }, { replace:true });
|
||
});
|
||
qs('#copy-review-feedback').addEventListener('click', async () => {
|
||
if (!draft || !selectedReview || reviewHandoffPending) return;
|
||
reviewHandoffPending = true;
|
||
qs('#copy-review-feedback').disabled = true;
|
||
const fallback = qs('#review-copy-fallback');
|
||
const directLink = qs('#review-handoff-link');
|
||
fallback.hidden = true;
|
||
directLink.hidden = true;
|
||
let handoffWindow = null;
|
||
try {
|
||
const result = await createReviewController.copyAndContinue({
|
||
text: createReviewController.formatFeedback(draft.snapshot(), reviewFiles),
|
||
url: selectedReview.url,
|
||
copy: text => navigator.clipboard.writeText(text),
|
||
open: () => {
|
||
handoffWindow = window.open('about:blank', '_blank');
|
||
if (handoffWindow) handoffWindow.opener = null;
|
||
return handoffWindow;
|
||
},
|
||
fallback: text => {
|
||
fallback.value = text;
|
||
fallback.hidden = false;
|
||
fallback.focus();
|
||
fallback.select();
|
||
},
|
||
});
|
||
if (result.opened) {
|
||
qs('#review-handoff-status').textContent = 'Feedback copied. Gitea opened in a new tab.';
|
||
} else {
|
||
directLink.hidden = false;
|
||
qs('#review-handoff-status').textContent = result.copied ?
|
||
'Feedback copied. Your browser blocked the new tab; continue with the link below.' :
|
||
'Clipboard unavailable. Copy the selected feedback, then continue to Gitea.';
|
||
}
|
||
} finally {
|
||
reviewHandoffPending = false;
|
||
qs('#copy-review-feedback').disabled = false;
|
||
}
|
||
});
|
||
|
||
const contextPoller = createContextPoller({
|
||
fetchContext: fetchLiveSnapshot,
|
||
onSnapshot: renderLiveSnapshot,
|
||
onError: async error => {
|
||
await handleContextError(error);
|
||
setEventStreamStatus('Update failed · showing last activity');
|
||
},
|
||
isHidden: () => document.hidden,
|
||
intervalMs: 8000,
|
||
});
|
||
function load() { return contextPoller.refresh({ force: true }); }
|
||
|
||
const offlineStatus = qs('#offline-status');
|
||
const keepWorkOffline = qs('#keep-work-offline');
|
||
const offlineWorkStatus = qs('#offline-work-status');
|
||
const retryOfflineStorage = qs('#retry-offline-storage');
|
||
const deliveryReceipts = qs('#delivery-receipts');
|
||
const deliveryReceiptStatus = qs('#delivery-receipt-status');
|
||
async function updateOfflineWorkControls(message) {
|
||
keepWorkOffline.checked = offlineWorkStore.enabled();
|
||
keepWorkOffline.disabled = !offlineStorageReady;
|
||
retryOfflineStorage.hidden = offlineStorageReady;
|
||
const saved = await offlineWorkStore.load();
|
||
offlineWorkStatus.textContent = message || (!offlineStorageReady ?
|
||
'Offline saving unavailable · online work remains live.' : saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
|
||
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
|
||
}
|
||
async function updateDeliveryReceiptControls(message) {
|
||
const supported = Boolean(backgroundIssueSync && 'Notification' in window && 'serviceWorker' in navigator);
|
||
deliveryReceipts.disabled = !supported || !confirmedOwnerLogin;
|
||
if (!supported) {
|
||
deliveryReceipts.checked = false;
|
||
deliveryReceiptStatus.textContent = 'Delivery notifications are unavailable in this browser.';
|
||
return;
|
||
}
|
||
if (!confirmedOwnerLogin) {
|
||
deliveryReceipts.checked = false;
|
||
deliveryReceiptStatus.textContent = 'Waiting for your signed-in account.';
|
||
return;
|
||
}
|
||
deliveryReceipts.checked = Notification.permission === 'granted' &&
|
||
await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin);
|
||
deliveryReceiptStatus.textContent = message || (deliveryReceipts.checked
|
||
? 'Background delivery receipts enabled for @' + confirmedOwnerLogin + '.'
|
||
: Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Off by default.');
|
||
}
|
||
function setOfflineWorkMode(value) {
|
||
offlineWorkMode = value;
|
||
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
|
||
.forEach(selector => { const button = qs(selector); if (button) button.disabled = value; });
|
||
if (value) {
|
||
document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')
|
||
.forEach(button => { button.disabled = true; });
|
||
}
|
||
}
|
||
async function hydrateOfflineWork(mode = 'offline') {
|
||
const saved = await offlineWorkStore.load();
|
||
if (!saved) return false;
|
||
const outage = mode === 'outage';
|
||
confirmedOwnerLogin = String(saved.user?.login || '').trim();
|
||
planningOwnerLogin = confirmedOwnerLogin;
|
||
interruptionPrompt.restore();
|
||
updatePlanningAvailability();
|
||
saved.notifications = notificationReadOutbox.suppress(saved.notifications || []);
|
||
lastNotifications = saved.notifications;
|
||
notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false };
|
||
workPagination = saved.work_pagination || {};
|
||
lastContextSnapshot = saved;
|
||
hasContextSnapshot = true;
|
||
paintMyWork(saved);
|
||
setOfflineWorkMode(true);
|
||
const savedLabel = fmt(saved.saved_at);
|
||
if (outage) {
|
||
qs('#my-work-status').textContent = 'Outage · saved ' + savedLabel + ' · read-only';
|
||
offlineStatus.textContent = 'Server unavailable · showing private My Work saved ' + savedLabel + '. Live details and actions will return automatically.';
|
||
offlineWorkStatus.textContent = 'Outage · saved ' + savedLabel + ' · expires after 7 days.';
|
||
setStatus('Outage · saved snapshot');
|
||
} else {
|
||
qs('#my-work-status').textContent = 'Offline · saved ' + savedLabel + ' · read-only';
|
||
offlineStatus.textContent = 'Offline · showing private My Work saved ' + savedLabel + '. Live details and actions require reconnection.';
|
||
offlineWorkStatus.textContent = 'Offline · saved ' + savedLabel + ' · expires after 7 days.';
|
||
setStatus('Offline · saved snapshot');
|
||
}
|
||
offlineStatus.hidden = false;
|
||
return true;
|
||
}
|
||
async function showOfflineStatus() {
|
||
activeFlushLogin = '';
|
||
offlineStatus.hidden = false;
|
||
setStatus('Offline');
|
||
if (!hasContextSnapshot) await hydrateOfflineWork();
|
||
}
|
||
function reconnectLiveData() {
|
||
offlineStatus.hidden = true;
|
||
setOfflineWorkMode(false);
|
||
setStatus('Reconnecting…');
|
||
contextPoller.refresh({ force: true }).then(() => {
|
||
if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);
|
||
});
|
||
}
|
||
async function setOfflineWorkEnabled(enabled) {
|
||
keepWorkOffline.checked = enabled;
|
||
offlineWorkStore.setEnabled(enabled);
|
||
if (enabled && liveMode && lastContextSnapshot) {
|
||
const admitted = await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
|
||
notification_pagination:notificationPagination });
|
||
if (admitted === false) {
|
||
await updateOfflineWorkControls('Offline saving unavailable · retry after reconnect.');
|
||
return false;
|
||
}
|
||
}
|
||
if (!enabled) await offlineWorkStore.clear();
|
||
await updateOfflineWorkControls(enabled ? 'Offline saving enabled.' : 'Offline work data cleared.');
|
||
if (enabled) warmTodayOffline();
|
||
else offlineToday.cancel();
|
||
return offlineWorkStore.enabled();
|
||
}
|
||
keepWorkOffline.addEventListener('change', async () => {
|
||
await setOfflineWorkEnabled(keepWorkOffline.checked);
|
||
});
|
||
deliveryReceipts.addEventListener('change', async () => {
|
||
let enabled = deliveryReceipts.checked;
|
||
if (enabled && Notification.permission !== 'granted') {
|
||
enabled = await Notification.requestPermission() === 'granted';
|
||
}
|
||
deliveryReceipts.checked = enabled;
|
||
if (backgroundIssueSync && confirmedOwnerLogin) {
|
||
await backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled);
|
||
}
|
||
await updateDeliveryReceiptControls(enabled ? 'Background delivery receipts enabled.' :
|
||
(Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Background delivery receipts disabled.'));
|
||
});
|
||
qs('#clear-offline-work').addEventListener('click', async () => {
|
||
await offlineWorkStore.clear();
|
||
offlineToday.cancel();
|
||
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
|
||
await updateOfflineWorkControls('Offline work data cleared.');
|
||
});
|
||
retryOfflineStorage.addEventListener('click', async () => {
|
||
retryOfflineStorage.disabled = true;
|
||
offlineWorkStatus.textContent = 'Retrying offline saving…';
|
||
offlineStorageReady = await offlineWorkStore.retry();
|
||
retryOfflineStorage.disabled = false;
|
||
if (offlineStorageReady && offlineWorkStore.enabled() && liveMode && lastContextSnapshot) {
|
||
await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
|
||
notification_pagination:notificationPagination });
|
||
}
|
||
await updateOfflineWorkControls(offlineStorageReady ? 'Offline saving restored.' :
|
||
'Offline saving unavailable · online work remains live.');
|
||
});
|
||
qs('#retry-offline-today').addEventListener('click', () =>
|
||
offlineToday.retry(confirmedOwnerLogin, todayMyWork)
|
||
);
|
||
await updateOfflineWorkControls();
|
||
updateDeliveryReceiptControls();
|
||
if (!navigator.onLine) await showOfflineStatus();
|
||
window.addEventListener('offline', showOfflineStatus);
|
||
window.addEventListener('online', reconnectLiveData);
|
||
|
||
qs('#refresh').addEventListener('click', load);
|
||
qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget));
|
||
qs('#cancel-plan-today').addEventListener('click', closePlanToday);
|
||
qs('#plan-today-sheet').addEventListener('click', event => {
|
||
if (event.target === qs('#plan-today-sheet')) closePlanToday();
|
||
});
|
||
qs('#today-readiness-close').addEventListener('click', () => closeTodayReadiness());
|
||
qs('#today-readiness-sheet').addEventListener('click', event => {
|
||
if (event.target === qs('#today-readiness-sheet')) closeTodayReadiness();
|
||
});
|
||
qs('#today-readiness-next').addEventListener('click', () => todayReadiness.startNextReady());
|
||
qs('#today-readiness-anyway').addEventListener('click', () => todayReadiness.workAnyway());
|
||
qs('#today-readiness-retry').addEventListener('click', () => todayReadiness.retry());
|
||
qs('#today-readiness-plan').addEventListener('click', () => {
|
||
closeTodayReadiness();
|
||
openPlanToday(qs('#plan-today'));
|
||
});
|
||
qs('#plan-today-available').addEventListener('change', event => {
|
||
planToday.setCapacity(Number(event.currentTarget.value));
|
||
qs('#plan-today-error').textContent = '';
|
||
renderPlanToday();
|
||
});
|
||
qs('#build-today-plan').addEventListener('click', () => buildTodayPlan());
|
||
function commitPlanToday(start, button) {
|
||
const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' });
|
||
if (result === 'saved') {
|
||
button.dataset.confirmOverCapacity = '';
|
||
taskOverlayHistory.leave();
|
||
} else if (result === 'confirm-over-capacity') {
|
||
button.dataset.confirmOverCapacity = 'true';
|
||
qs('#plan-today-error').textContent = 'This plan exceeds your available time. Press again to save over capacity.';
|
||
button.textContent = start ? 'Save over capacity & start' : 'Save over capacity';
|
||
} else {
|
||
qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
|
||
}
|
||
}
|
||
qs('#save-today-plan').addEventListener('click', event => commitPlanToday(false, event.currentTarget));
|
||
qs('#save-and-start-today').addEventListener('click', event => commitPlanToday(true, event.currentTarget));
|
||
qs('#start-work-session').addEventListener('click', () => {
|
||
const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter);
|
||
if (!sessionItems.length) {
|
||
qs('#my-work-action-status').textContent = 'No visible work to start.';
|
||
return;
|
||
}
|
||
if (selectedWorkFilter === 'today') runTodayTransition('start');
|
||
else workSession.start();
|
||
});
|
||
qs('#resume-today-session').addEventListener('click', () => resumeTodaySession());
|
||
|
||
qs('#end-today-session').addEventListener('click', () => endTodaySession(workSession, qs));
|
||
document.querySelectorAll('[data-work-session-previous]').forEach(button =>
|
||
button.addEventListener('click', () => workSession.previous())
|
||
);
|
||
document.querySelectorAll('[data-work-session-next]').forEach(button =>
|
||
button.addEventListener('click', () => workSession.checkpointed() ? runTodayTransition('next') : workSession.next())
|
||
);
|
||
document.querySelectorAll('[data-work-session-timer-toggle]').forEach(button =>
|
||
button.addEventListener('click', () => timerView.toggle())
|
||
);
|
||
document.querySelectorAll('[data-work-session-complete]').forEach(button =>
|
||
button.addEventListener('click', () =>
|
||
completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))
|
||
)
|
||
);
|
||
qs('#load-more-notifications').addEventListener('click', () =>
|
||
notificationPager.loadMore(lastNotifications)
|
||
);
|
||
qs('#load-more-work').addEventListener('click', async () => {
|
||
const stream = activeWorkStreams().find(item => workPagination[item]?.has_more);
|
||
if (!stream || !lastContextSnapshot) return;
|
||
const button = qs('#load-more-work');
|
||
button.disabled = true;
|
||
const existing = stream === 'issue' ?
|
||
(lastContextSnapshot.issues || []) : (lastContextSnapshot.pull_requests || []);
|
||
try {
|
||
const loaded = await workPager.loadMore(stream, existing);
|
||
if (loaded) document.querySelector('.my-work-card:last-child .my-work-card-main')?.focus();
|
||
else button.focus();
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
qs('#select-work').addEventListener('click', () => {
|
||
if (notificationSelection.snapshot().active) notificationSelection.cancel();
|
||
workSelection.start();
|
||
document.querySelector('[data-select-work-id]')?.focus();
|
||
});
|
||
qs('#cancel-work-selection').addEventListener('click', () => {
|
||
workSelection.cancel();
|
||
qs('#select-work').focus();
|
||
});
|
||
qs('#batch-add-today').addEventListener('click', () => {
|
||
const ids = new Set(workSelection.snapshot().ids);
|
||
const selectedItems = lastMyWork.filter(item => ids.has(workSelection.identity(item)));
|
||
const result = todayWork.addMany(selectedItems);
|
||
if (result.status === 'added') {
|
||
result.ids.forEach(id => todaySync.enqueue('add', id));
|
||
todaySync.flush();
|
||
warmTodayOffline();
|
||
qs('#my-work-action-status').textContent = result.ids.length + ' items added to Today without changing Gitea.';
|
||
workSelection.cancel();
|
||
} else {
|
||
qs('#my-work-action-status').textContent = result.status === 'full' ?
|
||
'The full selection will not fit in Today. Nothing was added; selection unchanged.' :
|
||
(result.status === 'exists' ? 'Every selected item is already in Today.' :
|
||
'Could not save Today on this device. Nothing was added; selection unchanged.');
|
||
}
|
||
refreshMyWorkView();
|
||
});
|
||
document.querySelectorAll('[data-batch-defer]').forEach(button => button.addEventListener('click', () => {
|
||
const ids = new Set(workSelection.snapshot().ids);
|
||
const selectedItems = lastMyWork.filter(item => ids.has(workSelection.identity(item)));
|
||
const until = laterWork.presetUntil(button.dataset.batchDefer);
|
||
const result = laterWork.deferMany(selectedItems, until);
|
||
qs('#my-work-action-status').textContent = result === 'deferred' ?
|
||
selectedItems.length + ' items deferred until ' + fmt(until) + '; updates stay unread and Gitea is unchanged.' :
|
||
'Could not save Later on this device. Selection unchanged.';
|
||
if (result === 'deferred') workSelection.cancel();
|
||
refreshMyWorkView();
|
||
}));
|
||
qs('#select-updates').addEventListener('click', () => {
|
||
notificationSelection.start();
|
||
qs('#update-selection-status').textContent = '0 of 50 selected';
|
||
document.querySelector('[data-select-notification-id]')?.focus();
|
||
});
|
||
qs('#cancel-update-selection').addEventListener('click', () => {
|
||
notificationSelection.cancel();
|
||
qs('#select-updates').focus();
|
||
});
|
||
document.querySelectorAll('[data-defer-selected]').forEach(button => button.addEventListener('click', () => {
|
||
const selection = notificationSelection.snapshot();
|
||
const ids = new Set(selection.ids);
|
||
const selectedUpdates = lastMyWork.filter(item => item.has_update && ids.has(item.notification_id));
|
||
const until = laterWork.presetUntil(button.dataset.deferSelected);
|
||
const result = laterWork.deferMany(selectedUpdates, until);
|
||
qs('#my-work-action-status').textContent = result === 'deferred' ?
|
||
selection.count + ' updates deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
|
||
'Could not save Later on this device. Selection unchanged.';
|
||
if (result === 'deferred') notificationSelection.cancel();
|
||
refreshMyWorkView();
|
||
}));
|
||
qs('#bulk-mark-read').addEventListener('click', async () => {
|
||
const selection = notificationSelection.snapshot();
|
||
if (!selection.ids.length || bulkMarkPending) return;
|
||
if (!bulkConfirmationPending) {
|
||
bulkConfirmationPending = true;
|
||
qs('#my-work-action-status').textContent = 'Confirm to mark only the selected updates read.';
|
||
renderMyWork();
|
||
return;
|
||
}
|
||
bulkConfirmationPending = false;
|
||
bulkMarkPending = true;
|
||
renderMyWork();
|
||
const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, selection.ids);
|
||
if (result) {
|
||
const marked = new Set(result.marked);
|
||
lastNotifications = lastNotifications.filter(item => !marked.has(item.id));
|
||
notificationSelection.retain(result.failed);
|
||
if (!result.failed.length) notificationSelection.cancel();
|
||
}
|
||
bulkMarkPending = false;
|
||
renderMyWork();
|
||
(document.querySelector('[data-select-notification-id]') || qs('#select-updates'))?.focus();
|
||
});
|
||
document.querySelectorAll('[data-work-filter]').forEach(button => {
|
||
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
|
||
button.addEventListener('click', () => {
|
||
selectWorkQueue(button.dataset.workFilter);
|
||
});
|
||
});
|
||
function updateQueueFinder(matches, loaded, incomplete) {
|
||
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
|
||
const queueLabel = activeQueue?.firstChild?.textContent?.trim() || 'work';
|
||
qs('#queue-find-label').textContent = queueLabel;
|
||
qs('#clear-queue-find').hidden = !queueFindQuery;
|
||
qs('#search-older-work').hidden = !queueFindQuery || matches > 0 || !incomplete;
|
||
qs('#queue-find-status').textContent = queueFindQuery ?
|
||
(matches + (matches === 1 ? ' match' : ' matches') + ' in ' + queueLabel +
|
||
(incomplete ? ' among loaded work.' : '.')) : '';
|
||
}
|
||
qs('#queue-finder').addEventListener('submit', event => event.preventDefault());
|
||
qs('#queue-find-input').addEventListener('input', event => {
|
||
queueFindQuery = event.target.value;
|
||
renderMyWork();
|
||
});
|
||
qs('#clear-queue-find').addEventListener('click', () => {
|
||
queueFindQuery = '';
|
||
qs('#queue-find-input').value = '';
|
||
renderMyWork();
|
||
qs('#queue-find-input').focus();
|
||
});
|
||
qs('#search-older-work').addEventListener('click', () => {
|
||
const notificationButton = qs('#load-more-notifications');
|
||
const workButton = qs('#load-more-work');
|
||
const target = selectedWorkFilter === 'update' ? notificationButton : workButton;
|
||
if (!target || target.hidden || target.disabled) return;
|
||
qs('#queue-find-status').textContent = 'Searching older ' +
|
||
(selectedWorkFilter === 'update' ? 'updates…' : 'work…');
|
||
target.click();
|
||
});
|
||
function selectWorkQueue(filter, { preserveRoute = false } = {}) {
|
||
const button = qs('[data-work-filter="' + filter + '"]');
|
||
if (!button) return false;
|
||
const leavingUpdates = selectedWorkFilter === 'update' && filter !== 'update';
|
||
selectedWorkFilter = filter;
|
||
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
|
||
if (workSelection.snapshot().active) workSelection.cancel();
|
||
savedWorkFilter = selectedWorkFilter;
|
||
launchFilterResolved = true;
|
||
try {
|
||
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
|
||
} catch (e) {
|
||
console.warn('Filter save failed', e);
|
||
}
|
||
document.querySelectorAll('[data-work-filter]').forEach(item =>
|
||
item.setAttribute('aria-pressed', String(item === button))
|
||
);
|
||
const selectedCount = button.querySelector('[data-work-count]').textContent;
|
||
qs('#active-work-queue').textContent = button.firstChild.textContent.trim() + ' (' + selectedCount + ')';
|
||
renderMyWork();
|
||
updateWorkPaginationControls();
|
||
if (!preserveRoute) workRoute.queue(filter);
|
||
return true;
|
||
}
|
||
function openWorkQueueRoute(filter) {
|
||
if (!selectWorkQueue(filter, { preserveRoute:true })) return;
|
||
qs('#my-work').scrollIntoView({block:'start'});
|
||
qs('#my-work').focus();
|
||
}
|
||
qs('#work-milestone-filter').addEventListener('change', event => {
|
||
selectedWorkMilestone = event.target.value;
|
||
try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); }
|
||
catch (e) { console.warn('Milestone save failed', e); }
|
||
renderMyWork();
|
||
if (workSession.active()) workSession.reconcile();
|
||
});
|
||
let pushControllerReady = Promise.resolve(null);
|
||
if ('serviceWorker' in navigator) {
|
||
pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => {
|
||
await issueCaptureFeatures.load('push-notifications');
|
||
const controller = createPushNotifications({
|
||
control:qs('#push-updates'),
|
||
status:qs('#push-update-status'),
|
||
notification:window.Notification,
|
||
serviceWorker:navigator.serviceWorker,
|
||
fetchJson:fetchReviewJson,
|
||
});
|
||
await controller.init();
|
||
return controller;
|
||
}).catch(error => {
|
||
qs('#push-updates').disabled = true;
|
||
qs('#push-update-status').textContent = 'Update notification settings unavailable.';
|
||
console.warn('Push notifications unavailable', error);
|
||
return null;
|
||
});
|
||
} else {
|
||
qs('#push-updates').disabled = true;
|
||
qs('#push-update-status').textContent = 'This browser does not support update notifications.';
|
||
}
|
||
let deferredInstallPrompt = null;
|
||
window.addEventListener('beforeinstallprompt', event => {
|
||
event.preventDefault();
|
||
deferredInstallPrompt = event;
|
||
});
|
||
let deviceSetup = null;
|
||
async function ensureDeviceSetup() {
|
||
if (deviceSetup) return deviceSetup;
|
||
await issueCaptureFeatures.load('device-setup');
|
||
const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||
const installApp = createInstallApp({
|
||
window, initialPrompt:deferredInstallPrompt,
|
||
card:qs('#install-app-card'), installButton:qs('#install-app'),
|
||
dismissButton:qs('#dismiss-install-app'), guidance:qs('#install-app-guidance'),
|
||
status:qs('#install-app-status'), storage:localStorage,
|
||
isStandalone:() => window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true,
|
||
isIosSafari:() => isIosDevice && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent),
|
||
});
|
||
installApp.start();
|
||
deviceSetup = createMobileDeviceSetup.mount({
|
||
document, installApp, promptStorage:localStorage,
|
||
offlineAvailable:() => offlineStorageReady,
|
||
offlineEnabled:() => offlineWorkStore.enabled(),
|
||
enableOffline:() => setOfflineWorkEnabled(true),
|
||
enablePush:async () => {
|
||
const controller = await pushControllerReady;
|
||
if (!controller) return;
|
||
qs('#push-updates').checked = true;
|
||
await controller.change();
|
||
},
|
||
});
|
||
await deviceSetup.start();
|
||
return deviceSetup;
|
||
}
|
||
qs('#open-device-setup').addEventListener('click', async event => {
|
||
if (!deviceSetup) await (await ensureDeviceSetup()).open(event);
|
||
});
|
||
pushControllerReady.then(ensureDeviceSetup).catch(console.warn);
|
||
contextPoller.start();
|
||
document.addEventListener('visibilitychange', () => {
|
||
contextPoller.setVisible(!document.hidden);
|
||
if (!document.hidden) deviceSetup?.render();
|
||
});
|
||
|
||
/* Widgets */
|
||
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
||
setInterval(widgetTick, 1000);
|
||
})();
|