8665 lines
410 KiB
JavaScript
8665 lines
410 KiB
JavaScript
(async function(){
|
||
const workspaceLifecycle = await (window.stackchainWorkspaceLifecycle || loadWorkspace({ document, window }));
|
||
await workspaceLifecycle.optionalReady;
|
||
const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.();
|
||
const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.();
|
||
const progressiveHumanGatesHandoff = window.stackchainProgressiveHumanGates?.handoff?.();
|
||
const progressiveMobileDockHandoff = window.stackchainProgressiveMobileDock?.handoff?.();
|
||
window.stackchainProgressiveMobileDock?.stop?.();
|
||
window.stackchainProgressiveMyWork?.stop();
|
||
const qs = (s, el=document) => el.querySelector(s);
|
||
const announceWork = message => qs('#my-work-action-status').textContent = message;
|
||
const fmt = (d) => new Date(d).toLocaleString();
|
||
const appBadge = createMobileAppBadge({
|
||
control:qs('#app-badge-control'), status:qs('#app-badge-status'),
|
||
container:qs('#app-badge-setting'), navigator, storage:localStorage,
|
||
serviceWorker:navigator.serviceWorker,
|
||
});
|
||
appBadge.start();
|
||
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('#issue-sheet .issue-sheet-panel'), workspace:qs('.checklist-add'), composer:qs('#add-checklist-step'), submit:qs('#save-checklist-step'), status:qs('#add-checklist-step-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();
|
||
const issueDetailPanel = qs('#issue-sheet .issue-sheet-panel');
|
||
const mobileIssueDetailNavigation = createMobileIssueDetailNavigation({
|
||
root:issueDetailPanel,
|
||
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-issue-section]')).map(button => [button.dataset.issueSection, button])),
|
||
targets:{
|
||
overview:qs('#issue-overview'),
|
||
conversation:qs('#issue-conversation'),
|
||
reply:qs('#issue-comment'),
|
||
actions:qs('#issue-planning'),
|
||
},
|
||
planning:qs('#issue-planning'),
|
||
onSectionChange:(section, options) => workRoute.section(section, options),
|
||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||
});
|
||
mobileIssueDetailNavigation.start();
|
||
const pullDetailPanel = qs('#pull-sheet .pull-sheet-panel');
|
||
const mobilePullDetailNavigation = createMobileIssueDetailNavigation({
|
||
root:pullDetailPanel,
|
||
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-pull-section]')).map(button => [button.dataset.pullSection, button])),
|
||
targets:{
|
||
overview:qs('#pull-overview'),
|
||
conversation:qs('#pull-conversation'),
|
||
reply:qs('#pull-comment'),
|
||
review:qs('#pull-review'),
|
||
},
|
||
beforeNavigate:{review(target) { target.open = true; }},
|
||
onSectionChange:(section, options) => workRoute.section(section, options),
|
||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||
});
|
||
mobilePullDetailNavigation.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 (_) {}
|
||
});
|
||
});
|
||
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 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();
|
||
}
|
||
async function recoverMobileDelivery(item) {
|
||
if (!item?.outbox_id || !activeFlushLogin) throw new Error('Sign in again to retry.');
|
||
const authored = item.kind === 'authored-outbox';
|
||
const result = await (authored ? authoredOutbox : issueOutbox).retry(item.outbox_id, activeFlushLogin);
|
||
(authored ? applyAuthoredOutboxResult : applyOutboxResult)(result);
|
||
}
|
||
let queueCounts = {};
|
||
let preparationItems = {};
|
||
let offlineWorkMode = false;
|
||
let renderMobileQueuePresentation = () => {};
|
||
let mobileQueuePriority = null;
|
||
let mobileRecentWork = { record:() => false, render:() => 0 };
|
||
const followingQueue = attachFollowing(item => {
|
||
searchPreviewReturnKind = 'following';
|
||
return searchPreview.open(item);
|
||
}, {
|
||
onCount:(count, items) => {
|
||
appBadge.reconcile('following', count, true);
|
||
queueCounts.following = count;
|
||
queueCounts.followingUnavailable = false;
|
||
preparationItems.following = items.filter(item => item.has_unseen_change === true);
|
||
renderMobileQueuePresentation();
|
||
mobileTaskDock.updateQueues(queueCounts);
|
||
mobileStartDay.render();
|
||
},
|
||
onStatus:status => {
|
||
if (status === 'loading') return;
|
||
queueCounts.followingUnavailable = status === 'error';
|
||
renderMobileQueuePresentation();
|
||
mobileTaskDock.updateQueues(queueCounts);
|
||
mobileStartDay.render();
|
||
},
|
||
onReviewComplete:() => mobileStartDay.completePhase('following'),
|
||
});
|
||
function bindDetailWatch(kind, button, status) {
|
||
const feature=createDetailWatch({fetchJson:fetchReviewJson,refreshFollowing:()=>followingQueue.load(),onState:(state,watching,error)=>{
|
||
button.disabled=['loading','watching','unwatching'].includes(state);
|
||
button.textContent=watching?'Stop watching':'Watch '+(kind==='pull'?'pull request':'issue');
|
||
status.textContent=state==='ready'?(watching?'Watching · available in Following.':''):
|
||
searchPreviewWatchStatus({status:state==='error'?'watch-error':state,error,detail:{kind}});
|
||
}});
|
||
button.addEventListener('click',()=>feature.toggle().catch(()=>{}));
|
||
return feature;
|
||
}
|
||
const issueDetailWatch = bindDetailWatch('issue',qs('#watch-issue-detail'),qs('#issue-watch-status'));
|
||
const pullDetailWatch = bindDetailWatch('pull',qs('#watch-pull-detail'),qs('#pull-watch-status'));
|
||
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
|
||
getItems: () => draftInbox.partition(lastDrafts).deliveries,
|
||
getIndex: item => lastDrafts.indexOf(item),
|
||
activate: recoverMobileDelivery,
|
||
beforeOpen: () => selectMobileQueue('draft'),
|
||
onComplete: () => {
|
||
if (!mobileStartDay.completePhase('delivery')) showMobileQueueCompletion('Delivery', true);
|
||
},
|
||
});
|
||
mobileDeliveryRecovery.start();
|
||
function openFiledFollowUp() {
|
||
selectMobileQueue('filed');
|
||
const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork));
|
||
if (!target) {
|
||
qs('#my-work-action-status').textContent = 'No filed issues are ready to open.';
|
||
return 'empty';
|
||
}
|
||
const index = lastMyWork.indexOf(target.item);
|
||
const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]');
|
||
openRoutedWork(target.kind === 'update' ? { ...target.item, kind:'update' } : target.item, trigger);
|
||
return target.kind === 'update' ? 'opened-update' : 'opened-issue';
|
||
}
|
||
const mobileQueueLauncher = createMobileQueueLauncher({
|
||
openDelivery: () => mobileDeliveryRecovery.open(),
|
||
openHumanGates: () => openHumanGates(),
|
||
openToday: () => mobileWorkEntry.open(),
|
||
openAgenda: openAgendaSession,
|
||
openUpdates: openUpdateTriage,
|
||
openFollowing:followingQueue.open,
|
||
openFiled: openFiledFollowUp,
|
||
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: announceWork,
|
||
getCounts: () => queueCounts,
|
||
isOnline: () => !offlineWorkMode,
|
||
getRoutineOrder: () => mobileQueuePriority?.getOrder(),
|
||
getPreparation: () => {
|
||
const briefing = mobileStartDay.briefing();
|
||
return {...briefing, active:mobileStartDay.state().active};
|
||
},
|
||
openPreparation: () => mobileStartDay.startNext(),
|
||
openFindWork: () => qs('#find-work').click(),
|
||
rows: Object.fromEntries(
|
||
Array.from(document.querySelectorAll('[data-mobile-queue]')).map(button => [button.dataset.mobileQueue, button])
|
||
),
|
||
nextAction: qs('#mobile-queue-next-action'),
|
||
activeList: qs('#mobile-queue-active-list'),
|
||
planningList: qs('#mobile-queue-planning-list'),
|
||
allList: qs('#mobile-queue-all-list'),
|
||
activeSection: qs('#mobile-queue-active-list').parentElement,
|
||
});
|
||
renderMobileQueuePresentation = () => mobileQueueLauncher.renderPresentation();
|
||
qs('#mobile-queue-next-action').addEventListener('click', () => mobileQueueLauncher.continueWork());
|
||
function openMobileStartDay() {
|
||
followingQueue.load().catch(() => {});
|
||
const state = mobileStartDay.state();
|
||
mobileStartDay.render();
|
||
qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';
|
||
const sheet = qs('#mobile-queue-sheet');
|
||
if (!sheet.open) qs('#mobile-queue-sheet').showModal();
|
||
qs('#mobile-queue-next-action').focus();
|
||
}
|
||
const mobileFirstTask = createMobileFirstTask({
|
||
getLogin: () => confirmedOwnerLogin,
|
||
hasWork: () => todayMyWork.length > 0 || activeMyWork.length > 0,
|
||
isTodayActive: () => workSession.checkpointed(),
|
||
});
|
||
mobileFirstTask.start();
|
||
const mobileWorkEntry = createMobileWorkEntry({
|
||
isTodayActive: () => workSession.checkpointed(),
|
||
isTodayResumable: () => workSession.resumable(),
|
||
getTodayCount: () => todayMyWork.length,
|
||
getEligibleCount: () => activeMyWork.length,
|
||
getPreparationState: () => mobileStartDay.state(),
|
||
queueLauncher: mobileQueueLauncher,
|
||
continueToday: continueTodaySession,
|
||
resumeToday: resumeTodaySession,
|
||
startToday: startTodaySession,
|
||
planToday: () => openPlanToday(mobileTaskButtons.work),
|
||
prepareToday: openMobileStartDay,
|
||
shouldActivate: () => mobileFirstTask.required(),
|
||
openActivation: () => mobileFirstTask.open(),
|
||
findWork: () => qs('#find-work').click(),
|
||
});
|
||
const mobileStartDay = createMobileStartDay({
|
||
getCounts: () => queueCounts,
|
||
getPhaseItems: () => preparationItems,
|
||
getLogin: () => confirmedOwnerLogin,
|
||
openQueue: name => {
|
||
const sheet = qs('#mobile-queue-sheet');
|
||
if (sheet.open) sheet.close();
|
||
return name === 'find' ? qs('#find-work').click() :
|
||
name === 'following' ? (queueCounts.followingUnavailable ? followingQueue.open() :
|
||
followingQueue.review()) : mobileQueueLauncher.open(name);
|
||
},
|
||
onHandoff: current => {
|
||
qs('#mobile-queue-heading').textContent = 'Prepare Today · ' + current.label;
|
||
const sheet = qs('#mobile-queue-sheet');
|
||
if (!sheet.open) sheet.showModal();
|
||
qs('#mobile-queue-next-action').focus();
|
||
},
|
||
elements: {
|
||
summary: qs('#mobile-start-day-summary'),
|
||
phases: qs('#mobile-start-day-phases'),
|
||
finish: qs('#finish-mobile-start-day'),
|
||
},
|
||
});
|
||
mobileStartDay.start();
|
||
renderMobileQueuePresentation();
|
||
qs('#finish-mobile-start-day').addEventListener('click', () => {
|
||
mobileStartDay.finish();
|
||
renderMobileQueuePresentation();
|
||
});
|
||
function showMobileQueueCompletion(completedName, cleared = true, phase = '') {
|
||
if (cleared && phase && mobileStartDay.completePhase(phase)) return;
|
||
mobileStartDay.render();
|
||
const next = mobileQueueLauncher.recommend();
|
||
qs('#mobile-queue-heading').textContent = completedName + (cleared ? ' cleared' : '');
|
||
document.querySelectorAll('[data-mobile-queue]').forEach(row => row.removeAttribute('data-recommended'));
|
||
const row = qs('[data-mobile-queue="' + next.name + '"]');
|
||
row.setAttribute('data-recommended', 'true');
|
||
qs('#mobile-queue-sheet').showModal();
|
||
row.focus();
|
||
}
|
||
let keptUpdateIdentities = [];
|
||
function showUpdateTriageOutcome(outcome) {
|
||
const box = qs('#mobile-update-outcome');
|
||
const review = qs('#review-kept-updates');
|
||
keptUpdateIdentities = outcome.keptIdentities;
|
||
box.hidden = false;
|
||
qs('#mobile-update-outcome-summary').textContent = outcome.reviewed + ' reviewed · ' + outcome.resolved +
|
||
' marked read · ' + outcome.kept + ' kept unread';
|
||
review.hidden = !outcome.kept;
|
||
review.textContent = 'Review ' + outcome.kept + ' kept unread';
|
||
if (!outcome.kept && mobileStartDay.completePhase('update')) return;
|
||
showMobileQueueCompletion(outcome.kept ? 'Updates reviewed' : 'Updates', !outcome.kept);
|
||
if (outcome.kept) review.focus();
|
||
}
|
||
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'),
|
||
deadlineBadge: qs('#mobile-deadline-count'),
|
||
onSelectQueue:(name, row) => name === 'recaps' ? qs('#open-today-recaps').click() :
|
||
name === 'find' ? qs('#find-work').click() :
|
||
name === 'week' ? openWeekPlanner(row) :
|
||
name === 'tomorrow' ? openTomorrowPlanner(row) : mobileQueueLauncher.open(name),
|
||
detour:() => timerView,
|
||
overlays: mobileTaskOverlays,
|
||
actions: {
|
||
work: () => mobileWorkEntry.open(),
|
||
find: () => qs('#find-work').click(),
|
||
new: () => qs('#new-issue').click(),
|
||
search: () => qs('#open-palette').click(),
|
||
queues: () => { refreshTomorrowQueueSummary(); mobileRecentWork.render(); },
|
||
},
|
||
observe(callback, overlays) {
|
||
const observer = new MutationObserver(callback);
|
||
overlays.forEach(overlay => observer.observe(overlay, {attributes:true, attributeFilter:['class']}));
|
||
return observer;
|
||
},
|
||
});
|
||
mobileTaskDock.start();
|
||
if (['work', 'queues'].includes(progressiveMobileDockHandoff?.lastTask)) {
|
||
mobileTaskDock.select(progressiveMobileDockHandoff.lastTask);
|
||
}
|
||
if (progressiveMobileDockHandoff?.queueSheetOpen && !qs('#mobile-queue-sheet').open) {
|
||
qs('#mobile-queue-sheet').showModal();
|
||
}
|
||
const mobileTodayActions = qs('#mobile-today-actions');
|
||
createMobileTodayCommandBar({
|
||
more:qs('[data-mobile-today-more]'),
|
||
sheet:mobileTodayActions,
|
||
close:qs('[data-mobile-today-actions-close]'),
|
||
firstAction:qs('[data-work-session-previous]', mobileTodayActions),
|
||
actionButtons:Array.from(mobileTodayActions.querySelectorAll(
|
||
'[data-work-session-previous], [data-work-session-next], [data-mobile-today-update], [data-mobile-today-blocked], [data-today-break-open], [data-work-session-adjust-plan]'
|
||
)),
|
||
endAction:qs('[data-mobile-today-end]', mobileTodayActions),
|
||
existingEndControl:qs('#end-today-session'),
|
||
hud:qs('[data-mobile-today-hud]'),
|
||
dock:qs('#mobile-task-dock'),
|
||
style:document.documentElement.style,
|
||
});
|
||
const mobileInsights = createMobileInsights({
|
||
root: qs('#insights-sheet'),
|
||
launcher: qs('#open-insights'),
|
||
closeButton: qs('#close-insights'),
|
||
|
||
dock: qs('#mobile-task-dock'),
|
||
hud: qs('[data-mobile-today-hud]'),
|
||
backgrounds: [qs('header'), qs('#my-work')],
|
||
history: window.history,
|
||
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
||
detour:() => timerView,
|
||
});
|
||
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
|
||
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
|
||
let liveMode = true;
|
||
let initialAccountRecovery = Promise.resolve(false);
|
||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
||
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
|
||
const WORK_FILTERS = ['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'];
|
||
const hasProgressiveWorkFilter = WORK_FILTERS.includes(progressiveWorkHandoff?.selectedFilter);
|
||
let selectedWorkFilter = hasProgressiveWorkFilter ? progressiveWorkHandoff.selectedFilter : 'all';
|
||
let selectedFiledView = 'needs-review';
|
||
let selectedWorkMilestone = 'all';
|
||
let queueFindQuery = '';
|
||
let savedWorkFilter = null;
|
||
let launchFilterResolved = hasProgressiveWorkFilter;
|
||
try {
|
||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||
if (!hasProgressiveWorkFilter && ['all', 'today', 'agenda', 'attention', 'filed', 'authored', '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) {}
|
||
let lastMyWork = [];
|
||
let lastDrafts = [];
|
||
let lastNotifications = [];
|
||
let lastContextSnapshot = null;
|
||
let notificationPagination = { page: 1, total: 0, has_more: false };
|
||
let workPagination = {};
|
||
let agendaChecking = false;
|
||
let agendaReplan = null;
|
||
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 delegatedParentReview = null;
|
||
let dismissedChecklistBody = null;
|
||
|
||
let issueBlockerCandidates = [];
|
||
let issueBlockerSearchTimer = null;
|
||
let issueConversation = null;
|
||
let issueTrigger = null;
|
||
let issueEditHistoryActive = false;
|
||
let selectedPull = null;
|
||
let pullTrigger = null;
|
||
let selectedPullDetail = null;
|
||
let pullConversation = null;
|
||
let pullReviewState = null;
|
||
|
||
let creatingIssue = false;
|
||
let createAndStartRequested = false;
|
||
let pendingIssueFilingIntent = 'create-and-assign';
|
||
let findingWork = false;
|
||
let availablePagination = { page: 1, total: 0, has_more: false };
|
||
let progress = null;
|
||
let draft = null;
|
||
let reviewFiles = [];
|
||
let selectedReviewHead = '';
|
||
let reviewHandoffSubmitted = false;
|
||
let activeInlineTarget = null;
|
||
let bulkConfirmationPending = false;
|
||
let bulkMarkPending = false;
|
||
let reviewHandoffPending = false;
|
||
let editingOutboxId = null;
|
||
let confirmedOwnerLogin = '';
|
||
let planningOwnerLogin = '';
|
||
let planningOwnerAccountKey = '';
|
||
let activeFlushLogin = '';
|
||
mobileRecentWork = createMobileRecentWork({
|
||
storage:localStorage,
|
||
getLogin:() => confirmedOwnerLogin,
|
||
fetchJson:fetchReviewJson,
|
||
document,
|
||
section:qs('#mobile-recent-work'),
|
||
list:qs('#mobile-recent-work-list'),
|
||
pinnedSection:qs('#mobile-pinned-work'),
|
||
pinnedList:qs('#mobile-pinned-work-list'),
|
||
status:qs('#mobile-recent-work-status'),
|
||
openRoute:fragment => {
|
||
const sheet = qs('#mobile-queue-sheet');
|
||
if (sheet.open) sheet.close();
|
||
if (window.location.hash !== fragment) window.history.pushState({ workRoute:fragment }, '', fragment);
|
||
workRoute.sync();
|
||
},
|
||
});
|
||
mobileRecentWork.startLifecycle({window, document});
|
||
mobileQueuePriority = createMobileQueuePriority({
|
||
storage: localStorage,
|
||
getLogin: () => confirmedOwnerLogin,
|
||
fetchJson: fetchReviewJson,
|
||
document,
|
||
list: qs('#mobile-queue-priority-list'),
|
||
resetButton: qs('#reset-mobile-queue-priority'),
|
||
status: qs('#mobile-queue-priority-status'),
|
||
conflict: qs('#mobile-queue-priority-conflict'),
|
||
keepLocalButton: qs('#keep-local-mobile-queue-priority'),
|
||
useRemoteButton: qs('#use-remote-mobile-queue-priority'),
|
||
labels: {
|
||
attention:'Attention', today:'Today', update:'Updates', agenda:'Agenda',
|
||
following:'Following', authored:'My PRs', filed:'Filed', later:'Later', draft:'Drafts',
|
||
},
|
||
onChange: () => {
|
||
renderMobileQueuePresentation();
|
||
},
|
||
});
|
||
mobileQueuePriority.start();
|
||
mobileQueuePriority.startLifecycle({window, document});
|
||
let rR = null;
|
||
function rRC() {
|
||
if (rR) return rR;
|
||
rR = createReleaseReceipt({
|
||
storage:localStorage, getLogin:()=>confirmedOwnerLogin, fetchJson:fetchReviewJson,
|
||
launcher:qs('#release-receipt-launcher'), dialog:qs('#release-receipt-sheet'),
|
||
statusNode:qs('#release-receipt-status'), checksNode:qs('#release-receipt-checks'),
|
||
releaseNode:qs('#release-receipt-link'),
|
||
listNode:qs('#release-watchlist'),
|
||
openPull:openPullSheet,
|
||
});
|
||
rR.bind();
|
||
qs('#close-release-receipt').addEventListener('click', () => qs('#release-receipt-sheet').close());
|
||
qs('#refresh-release-receipt').addEventListener('click', () => rR.refresh().catch(error => {
|
||
qs('#release-receipt-status').textContent = error.message + ' Retry when connected.';
|
||
}));
|
||
qs('#dismiss-release-receipt').addEventListener('click', () => rR.dismiss());
|
||
return rR;
|
||
}
|
||
const completedFiledReview = createCompletedFiledReview({
|
||
storage: localStorage,
|
||
getLogin() { return planningOwnerLogin; },
|
||
});
|
||
const filedHistoryTabs = createFiledHistoryTabs({
|
||
root:qs('#filed-history-tabs'), review:completedFiledReview,
|
||
onSelect:view => { selectedFiledView = view; renderMyWork(); },
|
||
});
|
||
let completedFiledSyncFlight = null;
|
||
let activeMyWork = [];
|
||
let laterMyWork = [];
|
||
let todayMyWork = [];
|
||
let rolloverReviewPlan = null;
|
||
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
|
||
const todayRollover = createTodayRollover();
|
||
let planningTomorrow = false;
|
||
let latestTodayPlan = null;
|
||
|
||
const tomorrowPlan = createTomorrowPlan({
|
||
fetchJson:fetchReviewJson,
|
||
localDate:todayRollover.localDate,
|
||
timeZone:todayRollover.timeZone,
|
||
storage:localStorage,
|
||
getLogin:() => planningOwnerLogin,
|
||
});
|
||
const weekPlan = createWeekPlan({
|
||
fetchJson:fetchReviewJson,localDate:todayRollover.localDate,timeZone:todayRollover.timeZone,
|
||
storage:localStorage,getLogin:() => planningOwnerLogin,coordinator:outboxCoordinator,
|
||
});
|
||
function openWeekPlanner(trigger) { planningTomorrow=false; return weekFlow.open(trigger); }
|
||
function renderTomorrowQueueSummary(value) {
|
||
qs('#mobile-tomorrow-summary').textContent = value ? tomorrowPlan.summary(value) : tomorrowPlan.summary();
|
||
}
|
||
function syncPendingTomorrow() {
|
||
weekFlow.resumePull()&&weekFlow.flushPull();
|
||
if (!tomorrowPlan.pending()) return Promise.resolve(false);
|
||
return tomorrowPlan.flush().then(saved => {
|
||
renderTomorrowQueueSummary(saved);
|
||
qs('#my-work-action-status').textContent = saved.ids.length ?
|
||
`Tomorrow saved for ${saved.plan_date} without changing Today.` :
|
||
`Tomorrow cleared for ${saved.plan_date}.`;
|
||
return saved;
|
||
}).catch(error => {
|
||
const conflict = tomorrowPlan.conflict();
|
||
if (conflict) renderTomorrowQueueSummary({ids:[],sync_pending:false,conflict:true});
|
||
qs('#mobile-tomorrow-summary').textContent = conflict ? 'Conflict · review required' : qs('#mobile-tomorrow-summary').textContent;
|
||
qs('#my-work-action-status').textContent = conflict ?
|
||
'Another device changed Tomorrow. Review it.' :
|
||
`${error.message || 'Tomorrow sync is unavailable.'} Saved on this phone · sync pending.`;
|
||
return false;
|
||
});
|
||
}
|
||
async function refreshTomorrowQueueSummary() {
|
||
try {
|
||
const loaded = await tomorrowPlan.load();
|
||
if (tomorrowPlan.conflict()) qs('#mobile-tomorrow-summary').textContent = 'Conflict · review required';
|
||
else renderTomorrowQueueSummary(loaded);
|
||
return true;
|
||
} catch (_error) {
|
||
qs('#mobile-tomorrow-summary').textContent = 'Unavailable · tap to retry';
|
||
return false;
|
||
}
|
||
}
|
||
async function openTomorrowPlanner(trigger) {
|
||
if (!planningOwnerLogin) {
|
||
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
||
return false;
|
||
}
|
||
trigger.disabled = true;
|
||
qs('#mobile-tomorrow-summary').textContent = 'Loading Tomorrow…';
|
||
try {
|
||
const loaded = await tomorrowPlan.load();
|
||
renderTomorrowQueueSummary(loaded);
|
||
planningTomorrow = true;
|
||
openPlanToday(trigger);
|
||
qs('#my-work-action-status').textContent = '';
|
||
return true;
|
||
} catch (error) {
|
||
qs('#mobile-tomorrow-summary').textContent = 'Unavailable · tap to retry';
|
||
qs('#my-work-action-status').textContent = `${error.message || 'Tomorrow is unavailable.'} Retry when connected.`;
|
||
return false;
|
||
} finally {
|
||
trigger.disabled = false;
|
||
}
|
||
}
|
||
const todayWork = createTodayWork({
|
||
storage: localStorage,
|
||
getLogin: () => planningOwnerLogin,
|
||
});
|
||
const weekItem=id=>[...todayMyWork,...activeMyWork].find(item=>todayWork.identity(item)===id);
|
||
const weekFlow=createWeekPlanWorkflow({controller:weekPlan,qs,
|
||
getItem:weekItem,openItem:openRoutedWork,
|
||
openPlanner:openPlanToday,setReviewMode:v=>qs('#plan-today-sheet').classList.toggle('week-review-mode',v),
|
||
escapeHtml,escapeAttribute:escAttr,todayWork,
|
||
t:v=>v?latestTodayPlan=v:latestTodayPlan,r:refreshMyWorkView,w:warmTodayOffline,
|
||
x:currentTodayProgressTarget});
|
||
const wc=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekFlow.i,escapeHtml,escapeAttribute:escAttr,
|
||
onSave:async byDate=>{Object.entries(byDate).forEach(([planDate,startTimes])=>weekPlan.stageStartTimes(planDate,startTimes));await weekPlan.flush();},
|
||
onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();}});
|
||
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;
|
||
latestTodayPlan=plan;
|
||
weekFlow.promote(plan).finally(() => {
|
||
if (window.location.hash !== '#/my-work/start-day') return;
|
||
window.history.replaceState({}, '', '#/my-work/today');
|
||
openMobileStartDay();
|
||
});
|
||
todayWork.replacePlanning({capacity_minutes:plan.capacity_minutes??null,estimates:plan.estimates||{}});
|
||
const s=todayRollover.reviewState(plan);
|
||
if(!['stale','legacy'].includes(s)){
|
||
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(()=>{if(!workSession.checkpointed())openPlanToday(qs('#plan-today'));},0);
|
||
}
|
||
},
|
||
onStatus: (state, detail = {}) => {
|
||
const status = qs('#today-sync-status');
|
||
status.textContent = state === 'saved' ? 'Today saved to account.' :
|
||
(state === 'recovered' ? `Today queue recovered · discarded ${detail.discarded} unreadable device record${detail.discarded === 1 ? '' : 's'}.` :
|
||
(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 });
|
||
tomorrowPlan.startLifecycle({
|
||
windowObject:window,
|
||
documentObject:document,
|
||
check:() => Promise.all([
|
||
syncPendingTomorrow(),
|
||
weekPlan.pending() ? weekPlan.flush().catch(() => false) : false,
|
||
latestTodayPlan ? weekFlow.promote(latestTodayPlan) : false,
|
||
]),
|
||
});
|
||
const todayHandoff = createTodayHandoff({
|
||
storage:localStorage,
|
||
getLogin:() => planningOwnerLogin,
|
||
identity:item => todayWork.identity(item),
|
||
items:() => [...todayMyWork, ...activeMyWork],
|
||
todayWork,
|
||
todaySync,
|
||
});
|
||
const todayHandoffView = todayHandoff.mount({ qs, escapeHtml,
|
||
onComplete:() => refreshMyWorkView(),
|
||
});
|
||
const promptTodayHandoff = () => todayHandoffView.open();
|
||
const laterWork = createLaterWork({
|
||
storage: localStorage,
|
||
getLogin: () => planningOwnerLogin,
|
||
onChange: (action, itemId, wakeAt, handoff) => {
|
||
if (laterSync.enqueue(action, itemId, wakeAt, handoff)) laterSync.flush();
|
||
},
|
||
onExpire: (ids, handoffs) => {
|
||
if (handoffs.length) {
|
||
todayHandoff.capture(handoffs);
|
||
todayHandoffView.reset();
|
||
}
|
||
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();
|
||
setTimeout(promptTodayHandoff, 0);
|
||
},
|
||
});
|
||
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();
|
||
if (updateTriage.active()) updateTriage.reconcile();
|
||
}
|
||
});
|
||
|
||
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.payload = payload;
|
||
|
||
error.code = payload.detail?.code;
|
||
const retryAfter = response.headers.get('Retry-After');
|
||
error.retryAfter = retryAfter === null ? undefined : Number(retryAfter);
|
||
throw error;
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
const humanGatesOnChange = (snapshot, state)=>{
|
||
queueCounts.gate = snapshot.pending_count;
|
||
appBadge.reconcile('human-gates', snapshot.pending_count, state.authoritative === true);
|
||
queueCounts.gateUnavailable = state.available === false;
|
||
preparationItems.gate = snapshot.items;
|
||
mobileTaskDock.updateQueues(queueCounts);
|
||
if (state.authoritative) mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']});
|
||
mobileStartDay.render();
|
||
};
|
||
const humanGates = progressiveHumanGatesHandoff?.controller || createHumanGates({
|
||
storage:localStorage,
|
||
getLogin:()=>planningOwnerLogin,
|
||
getAccountKey:()=>planningOwnerAccountKey,
|
||
isOnline:()=>navigator.onLine,
|
||
location:window.location,
|
||
fetchJson:fetchReviewJson,
|
||
onChange:humanGatesOnChange,
|
||
nodes:{
|
||
count:qs('#human-gates-count'), list:qs('#human-gates-list'),
|
||
status:qs('#human-gates-status'), panel:qs('#human-gates'),
|
||
detail:qs('#human-gate-detail'),
|
||
pendingTab:qs('#human-gates-pending'), historyTab:qs('#human-gates-history'),
|
||
},
|
||
});
|
||
progressiveHumanGatesHandoff?.adoptIdentity(planningOwnerLogin, planningOwnerAccountKey);
|
||
humanGates.setOnChange?.(humanGatesOnChange);
|
||
const openHumanGates = () => humanGates.open().catch(error => {
|
||
qs('#human-gates-status').textContent = error.message || 'Human Gates are unavailable.';
|
||
});
|
||
if (!progressiveHumanGatesHandoff) {
|
||
qs('#open-human-gates').addEventListener('click', openHumanGates);
|
||
qs('#close-human-gates').addEventListener('click', () => {
|
||
qs('#human-gates').hidden = true;
|
||
if (window.location.hash === '#/my-work/human-gates') window.history.replaceState({}, '', '#/my-work');
|
||
});
|
||
qs('#human-gates-list').addEventListener('click', event => {
|
||
const card = event.target.closest('[data-human-gate-id]');
|
||
if (!card) return;
|
||
humanGates.select(card.dataset.humanGateId);
|
||
});
|
||
qs('#human-gates-pending').addEventListener('click', () => humanGates.showPending());
|
||
qs('#human-gates-history').addEventListener('click', () => humanGates.showHistory().catch(error => {
|
||
qs('#human-gates-status').textContent = error.message;
|
||
}));
|
||
qs('#human-gate-detail').addEventListener('click', event => {
|
||
const recovery = event.target.closest('[data-gate-recover]');
|
||
if (recovery) {
|
||
recovery.disabled = true;
|
||
humanGates.recoverDecision().catch(error => {
|
||
qs('#human-gates-status').textContent = error.message || 'The decision outcome could not be verified.';
|
||
recovery.disabled = false;
|
||
});
|
||
return;
|
||
}
|
||
const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision;
|
||
if (!decision) return;
|
||
humanGates.submitDecision(decision).catch(error => {
|
||
if (!error?.targetSelector) qs('#human-gates-status').textContent = error.message;
|
||
});
|
||
});
|
||
}
|
||
if (!progressiveHumanGatesHandoff?.started) humanGates.load().catch(() => {});
|
||
if (window.location.hash === '#/my-work/human-gates' && !progressiveHumanGatesHandoff?.started) openHumanGates();
|
||
|
||
function syncCompletedFiledReviews() {
|
||
if (!planningOwnerLogin) return Promise.resolve(false);
|
||
if (completedFiledSyncFlight) return completedFiledSyncFlight;
|
||
const pending = completedFiledReview.pending();
|
||
const options = pending.length ? {
|
||
method:'POST',
|
||
headers:{Accept:'application/json', 'Content-Type':'application/json'},
|
||
body:JSON.stringify({ receipts:pending }),
|
||
} : { headers:{Accept:'application/json'} };
|
||
completedFiledSyncFlight = fetchReviewJson('api/v1/completed-filed-reviews', options)
|
||
.then(snapshot => {
|
||
const changed = completedFiledReview.adopt(snapshot);
|
||
if (lastContextSnapshot && changed) {
|
||
lastMyWork = buildMyWork(lastContextSnapshot);
|
||
refreshMyWorkView();
|
||
}
|
||
if (pending.length) qs('#my-work-action-status').textContent =
|
||
'Completed Filed review saved to account.';
|
||
return true;
|
||
})
|
||
.catch(() => {
|
||
if (pending.length) qs('#my-work-action-status').textContent =
|
||
'Acknowledged on this device · sync pending.';
|
||
return false;
|
||
})
|
||
.finally(() => { completedFiledSyncFlight = null; });
|
||
return completedFiledSyncFlight;
|
||
}
|
||
|
||
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, pullMentions, updateMentions, todayProgressMentions] = [
|
||
['issue-comment',()=>selectedIssue?.repository],
|
||
['pull-comment',()=>selectedPull?.repository],
|
||
['update-reply',()=>selectedUpdate?.repository],
|
||
['today-progress-body',()=>currentTodayProgressTarget()?.repository],
|
||
].map(([id,getRepository]) => {
|
||
const controller = createMentionComposer({
|
||
textarea:qs('#'+id), listbox:qs('#'+id+'-mentions'),
|
||
status:qs('#'+id+'-mention-status'), getRepository, loadCandidates:loadMentionCandidates,
|
||
});
|
||
controller.start();
|
||
return controller;
|
||
});
|
||
|
||
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,
|
||
enqueueDurably:message => authoredOutbox.enqueueDurably(message),
|
||
});
|
||
function overdueAgendaItems() {
|
||
return agendaMyWork(activeMyWork).filter(item => item.agenda_group === 'Overdue');
|
||
}
|
||
function renderAgendaReplan() {
|
||
const state = agendaReplan.snapshot();
|
||
qs('#agenda-replan-controls').hidden = !state.active;
|
||
if (!state.active) return;
|
||
qs('#agenda-replan-progress').textContent =
|
||
'Overdue deadline ' + (state.index + 1) + ' of ' + state.total + ' · ' + (agendaReplan.current()?.key || '');
|
||
}
|
||
function openAgendaReplanCurrent() {
|
||
const item = agendaReplan.current();
|
||
if (!item) {
|
||
closeIssueSheet(false);
|
||
renderAgendaReplan();
|
||
qs('#my-work-action-status').textContent = 'Overdue sweep complete. Agenda updated.';
|
||
return;
|
||
}
|
||
renderAgendaReplan();
|
||
openIssueSheet(item, qs('#start-agenda-replan'));
|
||
}
|
||
agendaReplan = createAgendaReplan({
|
||
update: async (item, dueDate) => {
|
||
const confirmed = await issueController.updateDueDate(item, dueDate);
|
||
lastContextSnapshot = buildMyWork.replaceIssueDueDate(
|
||
lastContextSnapshot, item.repository, item.number, confirmed.due_date
|
||
);
|
||
paintMyWork(lastContextSnapshot);
|
||
return confirmed;
|
||
},
|
||
});
|
||
qs('#start-agenda-replan').addEventListener('click', () => {
|
||
const overdue = overdueAgendaItems();
|
||
agendaReplan.start(overdue);
|
||
openAgendaReplanCurrent();
|
||
});
|
||
async function runAgendaReplan(action) {
|
||
const result = await action();
|
||
if (!result.ok) {
|
||
qs('#agenda-replan-progress').textContent = result.error + ' Current deadline retained; retry.';
|
||
return;
|
||
}
|
||
openAgendaReplanCurrent();
|
||
}
|
||
qs('#agenda-replan-keep').addEventListener('click', () => runAgendaReplan(() => agendaReplan.keep()));
|
||
qs('#agenda-replan-tomorrow').addEventListener('click', () => runAgendaReplan(() => agendaReplan.tomorrow()));
|
||
qs('#agenda-replan-choose').addEventListener('click', () =>
|
||
runAgendaReplan(() => agendaReplan.choose(qs('#agenda-replan-date').value))
|
||
);
|
||
qs('#agenda-replan-cancel').addEventListener('click', () => {
|
||
agendaReplan.cancel();
|
||
closeIssueSheet(false);
|
||
renderAgendaReplan();
|
||
window.location.hash = '#/my-work/agenda';
|
||
qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.';
|
||
});
|
||
|
||
StackchainAgendaCalendar.mountAgendaCalendarExport({
|
||
qs,
|
||
getItems:() => agendaMyWork(activeMyWork),
|
||
escapeHtml,
|
||
onDone:result => {
|
||
qs('#my-work-action-status').textContent = result === 'shared' ?
|
||
'Agenda calendar snapshot shared.' : 'Agenda calendar snapshot downloaded.';
|
||
},
|
||
});
|
||
let searchReplyAttachmentTarget = null;
|
||
let searchReplyRestoreGeneration = 0;
|
||
let restoringSearchReplyPhotos = false;
|
||
const searchReplyDraftStore = 'indexedDB' in window ? createSearchReplyDraftStore({
|
||
indexedDB:window.indexedDB,
|
||
getOwnerLogin:() => confirmedOwnerLogin,
|
||
}) : null;
|
||
const sameSearchReplyTarget = (left, right) => left && right && left.kind === right.kind &&
|
||
left.repository === right.repository && Number(left.number) === Number(right.number);
|
||
async function persistSearchReplyPhotos() {
|
||
const target = searchReplyAttachmentTarget ? { ...searchReplyAttachmentTarget } : null;
|
||
if (!target || !searchReplyDraftStore || !searchReplyAttachmentController || restoringSearchReplyPhotos) return;
|
||
try {
|
||
const attachments = await searchReplyAttachmentController.serialize();
|
||
await searchReplyDraftStore.save(target, attachments);
|
||
void refreshPhotoDraftInbox();
|
||
} catch (error) {
|
||
qs('#search-preview-reply-status').textContent = error?.message ||
|
||
'Photos could not be saved. They remain in this preview; retry before leaving.';
|
||
throw error;
|
||
}
|
||
}
|
||
async function restoreSearchReplyPhotos(target) {
|
||
if (!searchReplyDraftStore || !target) return;
|
||
const generation = ++searchReplyRestoreGeneration;
|
||
try {
|
||
const attachments = await searchReplyDraftStore.load(target);
|
||
if (generation !== searchReplyRestoreGeneration || !sameSearchReplyTarget(target, searchReplyAttachmentTarget)) return;
|
||
if (attachments?.length) {
|
||
restoringSearchReplyPhotos = true;
|
||
try { searchReplyAttachmentController.restore(attachments); }
|
||
finally { restoringSearchReplyPhotos = false; }
|
||
updateSearchReplyButtons(searchReplyAttachmentController.state());
|
||
qs('#search-preview-reply-status').textContent = 'Saved photo evidence restored.';
|
||
}
|
||
} catch (error) {
|
||
if (generation === searchReplyRestoreGeneration) {
|
||
qs('#search-preview-reply-status').textContent = error?.message || 'Saved photo evidence could not be restored.';
|
||
}
|
||
}
|
||
}
|
||
const updateSearchReplyButtons = state => {
|
||
const enabled = Boolean(state) || Boolean(qs('#search-preview-reply').value.trim());
|
||
qs('#send-search-preview-reply').disabled = !enabled;
|
||
qs('#send-search-preview-reply-next').disabled = !enabled;
|
||
};
|
||
const searchReplyAttachmentController = issueAttachment.mount({
|
||
maxFiles: 5,
|
||
input: qs('#search-reply-attachment'),
|
||
inputs: [qs('#take-search-reply-photo'), qs('#search-reply-attachment')],
|
||
preview: qs('#search-reply-attachment-preview'),
|
||
image: qs('#search-reply-attachment-image'),
|
||
meta: qs('#search-reply-attachment-meta'),
|
||
remove: qs('#remove-search-reply-attachment'),
|
||
tray: qs('#search-reply-attachment-tray'),
|
||
earlier: qs('#move-search-reply-attachment-earlier'),
|
||
later: qs('#move-search-reply-attachment-later'),
|
||
note: qs('#search-reply-attachment-note'),
|
||
noteLabel: qs('#search-reply-attachment-note-label'),
|
||
status: qs('#search-preview-reply-status'),
|
||
readyMessage: 'Photo ready to send with this Search reply.',
|
||
removedMessage: 'Photo removed. Your Search reply is unchanged.',
|
||
onChange: state => {
|
||
updateSearchReplyButtons(state);
|
||
persistSearchReplyPhotos().catch(() => {});
|
||
},
|
||
onCheckpoint:() => persistSearchReplyPhotos(),
|
||
editor: {
|
||
document,
|
||
edit: qs('#edit-search-reply-attachment'),
|
||
dialog: qs('#issue-evidence-editor'),
|
||
canvas: qs('#issue-evidence-editor-canvas'),
|
||
exportCanvas: qs('#issue-evidence-editor-export'),
|
||
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
|
||
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
|
||
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
|
||
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
|
||
status: qs('#issue-evidence-editor-status'),
|
||
appliedMessage: 'Edited photo flattened and ready to send.',
|
||
},
|
||
createObjectURL: file => URL.createObjectURL(file),
|
||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||
upload: payload => {
|
||
const target = searchReplyAttachmentTarget;
|
||
if (!target || target.repository !== payload.repository || Number(target.number) !== Number(payload.number)) {
|
||
return Promise.reject(new Error('Search result changed. Reopen it before sending this photo.'));
|
||
}
|
||
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
|
||
return fetchReviewJson(
|
||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(payload.number) +
|
||
'/preview/attachments?kind=' + encodeURIComponent(target.kind),
|
||
{
|
||
method:'POST',
|
||
headers:{Accept:'application/json','Idempotency-Key':payload.operation_id},
|
||
body:issueAttachment.multipart(payload),
|
||
},
|
||
);
|
||
},
|
||
});
|
||
const issueAttachmentController = issueAttachment.mount({
|
||
maxFiles: 5,
|
||
input: qs('#issue-attachment'),
|
||
inputs: [qs('#take-issue-comment-photo'), qs('#issue-attachment')],
|
||
preview: qs('#issue-attachment-preview'),
|
||
image: qs('#issue-attachment-image'),
|
||
meta: qs('#issue-attachment-meta'),
|
||
remove: qs('#remove-issue-attachment'),
|
||
tray: qs('#issue-attachment-tray'),
|
||
earlier: qs('#move-issue-attachment-earlier'),
|
||
later: qs('#move-issue-attachment-later'),
|
||
note: qs('#issue-attachment-note'),
|
||
noteLabel: qs('#issue-attachment-note-label'),
|
||
status: qs('#issue-comment-status'),
|
||
onChange:() => conversationPhotoDrafts.checkpoint('issue').catch(() => {}),
|
||
onCheckpoint:() => conversationPhotoDrafts.checkpoint('issue'),
|
||
editor: {
|
||
document,
|
||
edit: qs('#edit-issue-attachment'),
|
||
dialog: qs('#issue-evidence-editor'),
|
||
canvas: qs('#issue-evidence-editor-canvas'),
|
||
exportCanvas: qs('#issue-evidence-editor-export'),
|
||
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
|
||
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
|
||
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
|
||
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
|
||
status: qs('#issue-evidence-editor-status'),
|
||
appliedMessage: 'Edited photo flattened and ready to send.',
|
||
},
|
||
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({
|
||
maxFiles: 5,
|
||
input: qs('#pull-attachment'),
|
||
inputs: [qs('#take-pull-comment-photo'), qs('#pull-attachment')],
|
||
preview: qs('#pull-attachment-preview'),
|
||
image: qs('#pull-attachment-image'),
|
||
meta: qs('#pull-attachment-meta'),
|
||
remove: qs('#remove-pull-attachment'),
|
||
tray: qs('#pull-attachment-tray'),
|
||
earlier: qs('#move-pull-attachment-earlier'),
|
||
later: qs('#move-pull-attachment-later'),
|
||
note: qs('#pull-attachment-note'),
|
||
noteLabel: qs('#pull-attachment-note-label'),
|
||
status: qs('#pull-comment-status'),
|
||
onChange:() => conversationPhotoDrafts.checkpoint('pull').catch(() => {}),
|
||
onCheckpoint:() => conversationPhotoDrafts.checkpoint('pull'),
|
||
editor: {
|
||
document,
|
||
edit: qs('#edit-pull-attachment'),
|
||
dialog: qs('#issue-evidence-editor'),
|
||
canvas: qs('#issue-evidence-editor-canvas'),
|
||
exportCanvas: qs('#issue-evidence-editor-export'),
|
||
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
|
||
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
|
||
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
|
||
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
|
||
status: qs('#issue-evidence-editor-status'),
|
||
appliedMessage: 'Edited photo flattened and ready to send.',
|
||
},
|
||
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({
|
||
maxFiles: 5,
|
||
input: qs('#update-reply-attachment'),
|
||
inputs: [qs('#take-update-reply-photo'), 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'),
|
||
tray: qs('#update-reply-attachment-tray'),
|
||
earlier: qs('#move-update-reply-attachment-earlier'),
|
||
later: qs('#move-update-reply-attachment-later'),
|
||
note: qs('#update-reply-attachment-note'),
|
||
noteLabel: qs('#update-reply-attachment-note-label'),
|
||
status: qs('#update-reply-status'),
|
||
onChange:() => conversationPhotoDrafts.checkpoint('update').catch(() => {}),
|
||
onCheckpoint:() => conversationPhotoDrafts.checkpoint('update'),
|
||
readyMessage: 'Screenshot ready to send with this reply.',
|
||
removedMessage: 'Screenshot removed. Your reply is unchanged.',
|
||
editor: {
|
||
document,
|
||
edit: qs('#edit-update-reply-attachment'),
|
||
dialog: qs('#issue-evidence-editor'),
|
||
canvas: qs('#issue-evidence-editor-canvas'),
|
||
exportCanvas: qs('#issue-evidence-editor-export'),
|
||
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
|
||
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
|
||
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
|
||
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
|
||
status: qs('#issue-evidence-editor-status'),
|
||
appliedMessage: 'Edited photo flattened and ready to send.',
|
||
},
|
||
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 conversationPhotoDraftStore = createConversationReplyDraftStore({
|
||
indexedDB:window.indexedDB, getOwnerLogin:() => confirmedOwnerLogin,
|
||
});
|
||
const photoDraftLane = (controller, status) => ({ controller, onError:error => {
|
||
qs(status).textContent = error.message + ' Your photos remain here; retry before leaving.';
|
||
} });
|
||
const conversationPhotoDrafts = createConversationPhotoDrafts({
|
||
store:conversationPhotoDraftStore,
|
||
onChange:() => { void refreshPhotoDraftInbox(); },
|
||
lanes:{
|
||
issue:photoDraftLane(issueAttachmentController, '#issue-comment-status'),
|
||
pull:photoDraftLane(pullAttachmentController, '#pull-comment-status'),
|
||
update:photoDraftLane(updateReplyAttachmentController, '#update-reply-status'),
|
||
},
|
||
});
|
||
const todayProgressPhotos = createTodayProgressPhotos({
|
||
qs, document, issueAttachment, fetchJson:fetchReviewJson,
|
||
createStore:createConversationReplyDraftStore, createDrafts:createConversationPhotoDrafts,
|
||
getLogin:() => confirmedOwnerLogin,
|
||
});
|
||
const voiceTranscriptStore = createVoiceTranscriptStore();
|
||
const voiceOptions = {
|
||
qs, Recognition:window.SpeechRecognition || window.webkitSpeechRecognition,
|
||
transcriptStore:voiceTranscriptStore, getLogin:() => confirmedOwnerLogin,
|
||
};
|
||
const [issueVoiceReply, pullVoiceReply, updateVoiceReply, searchVoiceReply, todayProgressVoice] = [
|
||
['issue-comment', '#issue-comment'], ['pull-comment', '#pull-comment'],
|
||
['update-reply', '#update-reply'], ['search-reply', '#search-preview-reply'],
|
||
['today-progress', '#today-progress-body'],
|
||
].map(([kind, draftSelector]) => mountVoiceConversation({...voiceOptions, kind, draftSelector}));
|
||
function conversationVoiceTarget(kind, item) {
|
||
return kind + ':' + String(item?.repository || '').trim().toLowerCase() + '#' + Number(item?.number);
|
||
}
|
||
function searchConversationVoiceTarget(item) {
|
||
return 'search:' + conversationVoiceTarget(item?.kind, item);
|
||
}
|
||
const createIssueAttachmentController = issueAttachment.mount({
|
||
input: qs('#create-issue-attachment'),
|
||
inputs: [qs('#take-create-issue-photo'), 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'),
|
||
tray: qs('#create-issue-evidence-tray'),
|
||
earlier: qs('#move-create-issue-attachment-earlier'),
|
||
later: qs('#move-create-issue-attachment-later'),
|
||
note: qs('#create-issue-evidence-note'),
|
||
noteLabel: qs('#create-issue-evidence-note-label'),
|
||
status: qs('#create-issue-attachment-status'),
|
||
readyMessage: 'Screenshot ready to file with this issue.',
|
||
removedMessage: 'Screenshot removed. Your issue draft is unchanged.',
|
||
editor: {
|
||
document,
|
||
edit: qs('#edit-create-issue-attachment'),
|
||
dialog: qs('#issue-evidence-editor'),
|
||
canvas: qs('#issue-evidence-editor-canvas'),
|
||
exportCanvas: qs('#issue-evidence-editor-export'),
|
||
crop: qs('#crop-issue-evidence'),
|
||
redact: qs('#redact-issue-evidence'),
|
||
highlight: qs('#highlight-issue-evidence'),
|
||
arrow: qs('#arrow-issue-evidence'),
|
||
undo: qs('#undo-issue-evidence-edit'),
|
||
reset: qs('#reset-issue-evidence-edit'),
|
||
cancel: qs('#cancel-issue-evidence-edit'),
|
||
apply: qs('#apply-issue-evidence-edit'),
|
||
status: qs('#issue-evidence-editor-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: 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 checklistPromotion = null;
|
||
let issueOwnerPicker = null;
|
||
let issueTemplatePicker = null;
|
||
let issueFilingMetadata = 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,
|
||
});
|
||
createUnfiledDraftSync(
|
||
unfiledCaptures, fetchReviewJson
|
||
);
|
||
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
|
||
dFS.attach(qs, {
|
||
captures:unfiledCaptures, capture:()=>issueCapture, attachment:createIssueAttachmentController,
|
||
getLogin:()=>activeFlushLogin, setResumedId:id=>{ rUC = id; },
|
||
openSheet:openCreateIssueSheet, setFilingMode:setIssueFilingMode,
|
||
review:r=>loadIssueFilingMetadata(r.repository,r)
|
||
.then(()=>issueCapture.findDuplicates(currentIssueCaptureDraft())).then(renderIssueDuplicates),
|
||
});
|
||
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 filingReview = createIssueFilingReview({
|
||
sheet: qs('#issue-filing-review'),
|
||
confirmButton: qs('#confirm-issue-filing'),
|
||
backButton: qs('#back-to-issue-edit'),
|
||
evidenceList: qs('#issue-filing-review-evidence'),
|
||
evidenceEmpty: qs('#issue-filing-review-evidence-empty'),
|
||
evidencePreview: qs('#issue-filing-review-evidence-preview'),
|
||
evidenceImage: qs('#issue-filing-review-evidence-image'),
|
||
evidencePosition: qs('#issue-filing-review-evidence-position'),
|
||
evidenceFilename: qs('#issue-filing-review-evidence-filename'),
|
||
evidenceNote: qs('#issue-filing-review-evidence-note'),
|
||
repository: qs('#issue-filing-review-repository'),
|
||
intent: qs('#issue-filing-review-intent'),
|
||
issueType: qs('#issue-filing-review-template'),
|
||
title: qs('#issue-filing-review-title'),
|
||
body: qs('#issue-filing-review-body'),
|
||
metadata: qs('#issue-filing-review-metadata'),
|
||
blockerList: qs('#issue-filing-review-blockers'),
|
||
status: qs('#issue-filing-review-status'),
|
||
document,
|
||
createObjectURL: blob => URL.createObjectURL(blob),
|
||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||
onConfirm: admitReviewedIssue,
|
||
});
|
||
const authoredOutbox = createAuthoredOutbox({
|
||
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
||
backgroundSync: backgroundIssueSync,
|
||
getOwnerLogin: () => confirmedOwnerLogin,
|
||
mergeChecklistConflict: mergeChecklistConflict,
|
||
});
|
||
const todayProgress = createTodayProgress({
|
||
storage:localStorage,
|
||
getLogin:() => confirmedOwnerLogin,
|
||
admit:message => authoredOutbox.enqueueDurably(message),
|
||
});
|
||
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(() => {});
|
||
}
|
||
const shareParams = new URLSearchParams(location.search);
|
||
const appShortcut = mobileAppShortcuts.createController({
|
||
search: location.search,
|
||
continueWork: () => mobileWorkEntry.open(),
|
||
newIssue: () => openCreateIssueSheet(),
|
||
agenda: () => openAgendaSession(),
|
||
});
|
||
const sharedLaunch = {
|
||
title: shareParams.get('title') || '',
|
||
text: shareParams.get('text') || '',
|
||
url: shareParams.get('url') || '',
|
||
};
|
||
const sharedImageMarker = shareParams.get('shared') || '';
|
||
let sharedImageHandled = false;
|
||
const commentActionFeatures = createFeatureLoader({
|
||
document,
|
||
urls: {
|
||
'comment-actions': document.querySelector('meta[name="stackchain-feature-comment-actions"]')?.content || '',
|
||
},
|
||
});
|
||
const actionHydrator = createConversationActionHydrator({
|
||
load: () => commentActionFeatures.load('comment-actions'),
|
||
activate: () => {
|
||
commentActions = createCommentActions({
|
||
fetchJson: fetchReviewJson,
|
||
getLogin: () => confirmedOwnerLogin,
|
||
confirmDelete: message => window.confirm(message),
|
||
});
|
||
return commentActions;
|
||
},
|
||
});
|
||
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;
|
||
let voiceIssueCapture = null;
|
||
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 });
|
||
voiceIssueCapture = createVoiceIssueCapture({
|
||
Recognition: window.SpeechRecognition || window.webkitSpeechRecognition,
|
||
elements: {
|
||
root:qs('#voice-issue-capture'), start:qs('#start-voice-issue-capture'),
|
||
stop:qs('#stop-voice-issue-capture'), review:qs('#voice-issue-review'),
|
||
transcript:qs('#voice-issue-transcript'), append:qs('#append-voice-issue-transcript'),
|
||
replace:qs('#replace-with-voice-issue-transcript'), discard:qs('#discard-voice-issue-transcript'),
|
||
status:qs('#voice-issue-status'), title:qs('#create-issue-title'), body:qs('#create-issue-body'),
|
||
},
|
||
transcriptStore:createVoiceTranscriptStore(),
|
||
getLogin:()=>confirmedOwnerLogin,
|
||
});
|
||
issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document,
|
||
() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });
|
||
issueTemplatePicker = issueCapture.bindTemplatePicker({
|
||
field:qs('#create-issue-template-field'), select:qs('#create-issue-template'),
|
||
status:qs('#create-issue-template-status'), document,
|
||
getRepository:()=>qs('#create-issue-repository').value,
|
||
labelInputs:()=>document.querySelectorAll('input[name="create-issue-label"]'),
|
||
}, {
|
||
getDraft:currentIssueCaptureDraft,
|
||
setDraft:draft=>{ qs('#create-issue-title').value=draft.title; qs('#create-issue-body').value=draft.body; },
|
||
changed:()=>{ saveIssueCaptureDraft(); scheduleIssueDuplicateCheck(); },
|
||
});
|
||
issueFilingMetadata = issueCapture.bindFilingMetadata({
|
||
document,
|
||
labelList:qs('#create-issue-label-list'),
|
||
labelStatus:qs('#create-issue-label-status'),
|
||
milestoneSelect:qs('#create-issue-milestone'),
|
||
milestoneStatus:qs('#create-issue-milestone-status'),
|
||
templateStatus:qs('#create-issue-template-status'),
|
||
getRepository:()=>qs('#create-issue-repository').value,
|
||
}, issueTemplatePicker);
|
||
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
|
||
checklistPromotion = createIssueCapture.createChecklistPromotion({
|
||
issueController, issueCapture,
|
||
clearAttachments:()=>createIssueAttachmentController.clear(),
|
||
onLinked:(promotion, confirmed) => {
|
||
if (selectedIssue?.repository === promotion.item.repository &&
|
||
selectedIssue?.number === promotion.item.number) {
|
||
applyIssueContent(promotion.item, promotion.detail, confirmed);
|
||
qs('#issue-sheet').classList.add('open');
|
||
}
|
||
},
|
||
onStatus:message=>{ qs('#my-work-action-status').textContent = message; },
|
||
});
|
||
}
|
||
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
|
||
sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch);
|
||
sharedLaunchHandled = true;
|
||
}
|
||
});
|
||
}
|
||
if (Object.values(sharedLaunch).some(Boolean) || sharedImageMarker) 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, onState:refreshMyWorkView });
|
||
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;
|
||
}, ()=>selectedPullDetail, ()=>confirmedOwnerLogin);
|
||
}
|
||
rRC();
|
||
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
||
if (!wrapPreference) {
|
||
wrapPreference = createReviewController.createWrapPreference({
|
||
storage: localStorage,
|
||
mobile: window.matchMedia('(max-width: 600px)').matches,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
function restoreReleaseReceipt() {
|
||
const account = String(confirmedOwnerLogin || '').trim().toLowerCase();
|
||
if (!account) return;
|
||
try {
|
||
if (!localStorage.getItem('stackchain.release-receipt.v1:' + account)) return;
|
||
} catch (_error) { return; }
|
||
ensurePullWorkflow().then(() => rR.restore()).catch(() => {});
|
||
}
|
||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||
const photoDraftInbox = createPhotoDraftInbox({
|
||
conversation:conversationPhotoDraftStore,
|
||
search:searchReplyDraftStore,
|
||
});
|
||
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);
|
||
}
|
||
let findWorkNavigation = null;
|
||
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; },
|
||
onSelection: state => {
|
||
qs('#batch-find-work-actions').hidden = !state.active;
|
||
qs('#select-find-work').hidden = state.active;
|
||
qs('#find-work-selection-status').textContent = state.count ?
|
||
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No work selected.';
|
||
qs('#claim-selected-work').disabled = state.count === 0;
|
||
findWorkNavigation?.sync({ selectedCount:state.count });
|
||
renderAvailableIssues(findWorkController.items());
|
||
},
|
||
});
|
||
|
||
findWorkNavigation = createMobileFindWorkNavigation({
|
||
document,
|
||
history:window.history,
|
||
eventTarget:window,
|
||
controller:findWorkController,
|
||
todayWork,
|
||
openFit:openFindWorkEstimateReview,
|
||
});
|
||
findWorkNavigation.start();
|
||
let findWorkSearchTimer = null;
|
||
|
||
function updateFindWorkMatchStatus() {
|
||
const query = findWorkController.query();
|
||
qs('#find-work-match-status').textContent = query ?
|
||
availablePagination.total + ' match' + (availablePagination.total === 1 ? '' : 'es') +
|
||
' for “' + query + '”.' : '';
|
||
}
|
||
|
||
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 muteNotification(notificationId) {
|
||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
|
||
'/mute', { method: 'POST', headers: { Accept: 'application/json' } });
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
const error = new Error(payload.error || 'Muting future updates failed.');
|
||
error.muted = payload.muted === true;
|
||
throw error;
|
||
}
|
||
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 pageQuery = Number.isInteger(page) ? '&page=' + encodeURIComponent(page) : '';
|
||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
|
||
'/conversation?limit=20' + pageQuery, {
|
||
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;
|
||
appBadge.reconcile('updates', pagination.total, true);
|
||
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' || stream === 'filed') 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 updateReadPosition = createUpdateReadPosition({
|
||
panel: qs('#update-sheet .update-sheet-panel'),
|
||
jump: qs('#jump-update-new-activity'),
|
||
});
|
||
const mobileUpdateDetailNavigation = createMobileUpdateDetailNavigation({
|
||
root:qs('#update-sheet .update-sheet-panel'),
|
||
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-update-section]')).map(button => [button.dataset.updateSection, button])),
|
||
targets:{
|
||
conversation:qs('#update-conversation'),
|
||
context:qs('#update-subject-context'),
|
||
reply:qs('#update-reply-workspace'),
|
||
},
|
||
replyComposer:qs('#update-reply'),
|
||
jumpToNewActivity:() => updateReadPosition.jump(),
|
||
onSectionChange:(section, options) => workRoute.section(section, options),
|
||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||
});
|
||
mobileUpdateDetailNavigation.start();
|
||
const mobileReviewDetailNavigation = createMobileReviewDetailNavigation({
|
||
root:qs('#review-sheet .review-sheet-panel'),
|
||
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-review-section]')).map(button => [button.dataset.reviewSection, button])),
|
||
targets:{
|
||
overview:qs('#review-overview'),
|
||
files:qs('#review-files-workspace'),
|
||
feedback:qs('#review-feedback'),
|
||
history:qs('#review-history-workspace'),
|
||
},
|
||
summaryComposer:qs('#review-summary'),
|
||
onSectionChange:(section, options) => workRoute.section(section, options),
|
||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||
});
|
||
mobileReviewDetailNavigation.start();
|
||
const issueDetailPosition = createWorkDetailPosition({ panel: qs('#issue-sheet .issue-sheet-panel') });
|
||
const pullDetailPosition = createWorkDetailPosition({ panel: qs('#pull-sheet .pull-sheet-panel') });
|
||
const reviewDetailPosition = createWorkDetailPosition({ panel: qs('#review-sheet .review-sheet-panel') });
|
||
const workDetailIdentity = (kind, item) => kind + ':' + item.key;
|
||
qs('#jump-update-new-activity').addEventListener('click', () => updateReadPosition.jump());
|
||
qs('#focus-update-reply').addEventListener('click', () => {
|
||
const composer = qs('#update-reply');
|
||
composer.scrollIntoView({ behavior:'smooth', block:'center' });
|
||
qs('#update-reply').focus({ preventScroll:true });
|
||
});
|
||
qs('#toggle-update-more').addEventListener('click', event => {
|
||
const more = qs('.update-more-actions');
|
||
more.open = !more.open;
|
||
event.currentTarget.setAttribute('aria-expanded', String(more.open));
|
||
if (more.open) more.scrollIntoView({ behavior:'smooth', block:'end' });
|
||
});
|
||
const updateDecision = createUpdateDecisionTransaction({
|
||
controls: [
|
||
qs('#keep-update-unread'), qs('#mark-update-read-next'),
|
||
qs('#review-update-now'), qs('#focus-update-reply'), qs('#toggle-update-more'),
|
||
],
|
||
status: qs('#update-gesture-status'),
|
||
});
|
||
const notificationReader = createNotificationReader({
|
||
load: fetchNotificationDetail,
|
||
getScope: () => confirmedOwnerLogin,
|
||
loadConversation: fetchNotificationConversation,
|
||
markRead: markNotificationRead,
|
||
acknowledge: acknowledgeNotification,
|
||
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
|
||
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
|
||
onOpen: item => {
|
||
selectedUpdate = item;
|
||
void conversationPhotoDrafts.switchTo('update',
|
||
{ kind:'update', notificationId:item.notification_id }, () => selectedUpdate === item);
|
||
void updateVoiceReply.open('update:' + item.notification_id);
|
||
selectedUpdateDetail = null;
|
||
updateReadPosition.open(String(item.notification_id));
|
||
mobileUpdateDetailNavigation.reset();
|
||
updateMentions.dismiss();
|
||
qs('#update-sheet').classList.add('open');
|
||
qs('.update-more-actions').open = false;
|
||
qs('#toggle-update-more').setAttribute('aria-expanded', 'false');
|
||
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('#retry-update-conversation').hidden = true;
|
||
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('#mute-update-next').hidden = true;
|
||
qs('#update-ownership-action').hidden = true;
|
||
qs('#update-ownership-start').hidden = true;
|
||
qs('#create-update-follow-up').hidden = true;
|
||
qs('#review-update-now').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('#mute-update-next').hidden = !detail.mute_supported;
|
||
qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type);
|
||
qs('#review-update-now').hidden = !updateReviewHandoff.eligible(detail, selectedUpdate);
|
||
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,
|
||
onConversationStatus: message => {
|
||
qs('#update-conversation-status').textContent = message;
|
||
qs('#retry-update-conversation').hidden = !message.startsWith('Conversation temporarily unavailable.');
|
||
},
|
||
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;
|
||
if (message === 'Update ready.' && updateTriage.active() && !offlineWorkMode) {
|
||
notificationReader.prefetch(updateTriage.next());
|
||
}
|
||
qs('#retry-update-load').hidden = !message.startsWith('Could not load update.');
|
||
if (message === 'Inbox cleared.') {
|
||
qs('#my-work-action-status').textContent = message;
|
||
if (!mobileStartDay.completePhase('update')) showMobileQueueCompletion('Updates');
|
||
}
|
||
},
|
||
onClose: () => closeUpdateSheet(false),
|
||
});
|
||
|
||
const updateTriage = createUpdateTriageSession({
|
||
storage: localStorage,
|
||
getLogin: () => confirmedOwnerLogin,
|
||
getItems: () => lastMyWork.filter(item => item?.has_update && Number.isInteger(item.notification_id)),
|
||
onOpen: item => notificationReader.open(
|
||
item,
|
||
offlineWorkMode ? offlineWorkStore.loadDetail(confirmedOwnerLogin, item) : null
|
||
),
|
||
onProgress: state => {
|
||
const progress = qs('#update-triage-progress');
|
||
progress.hidden = false;
|
||
progress.textContent = 'Update ' + state.index + ' of ' + state.total +
|
||
(state.reason ? ' · ' + state.reason : '');
|
||
},
|
||
onFinish: outcome => {
|
||
notificationReader.prefetch();
|
||
qs('#update-triage-progress').hidden = true;
|
||
showUpdateTriageOutcome(outcome);
|
||
},
|
||
});
|
||
const updateReviewHandoff = createUpdateReviewHandoff({
|
||
openReview: item => {
|
||
qs('#update-sheet').classList.remove('open');
|
||
reviewHandoffSubmitted = false;
|
||
openReviewSheet(item, qs('#review-update-now'),
|
||
offlineWorkMode ? offlineWorkStore.loadDetail(confirmedOwnerLogin, item) : null);
|
||
},
|
||
restoreUpdate: () => {
|
||
qs('#review-sheet').classList.remove('open');
|
||
selectedReview = null;
|
||
qs('#update-sheet').classList.add('open');
|
||
qs('#review-update-now').focus();
|
||
},
|
||
admitRead: notificationId => offlineWorkMode ?
|
||
notificationReadOutbox.enqueueDurably(notificationId) : markNotificationRead(notificationId),
|
||
advance: source => {
|
||
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
|
||
qs('#review-sheet').classList.remove('open');
|
||
selectedReview = null;
|
||
offlineReview = false;
|
||
progress = null;
|
||
draft = null;
|
||
reviewFiles = [];
|
||
selectedReviewHead = '';
|
||
notificationReader.acceptReadAndNext(lastMyWork, source);
|
||
if (updateTriage.active()) updateTriage.acceptCompleted();
|
||
reviewHandoffSubmitted = false;
|
||
},
|
||
});
|
||
qs('#review-update-now').addEventListener('click', () => updateReviewHandoff.begin(selectedUpdate));
|
||
qs('#review-kept-updates').addEventListener('click', () => {
|
||
qs('#mobile-queue-sheet').close();
|
||
updateTriage.reviewKept(keptUpdateIdentities);
|
||
});
|
||
|
||
const updateTriageLauncher = createUpdateTriageLauncher({
|
||
selectUpdates: () => selectMobileQueue('update'),
|
||
discover: () => api('api/v1/notifications/snapshot'),
|
||
applySnapshot: snapshot => {
|
||
lastNotifications = snapshot.items || [];
|
||
notificationPager.reset({page:1, total:snapshot.total || 0, has_more:false});
|
||
if (lastContextSnapshot) {
|
||
lastContextSnapshot.notifications = lastNotifications;
|
||
paintMyWork(lastContextSnapshot);
|
||
}
|
||
},
|
||
isUpdatesSelected: () => selectedWorkFilter === 'update',
|
||
hasMore: () => Boolean(notificationPagination.has_more),
|
||
hasCheckpoint: () => updateTriage.resumable(),
|
||
resume: () => updateTriage.resume(),
|
||
start: () => updateTriage.start(),
|
||
announce: announceWork,
|
||
});
|
||
|
||
function openUpdateTriage() {
|
||
return updateTriageLauncher.open();
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
function navigateWorkSection(kind, section) {
|
||
if (['issue', 'filed'].includes(kind)) {
|
||
mobileIssueDetailNavigation.navigate(section, { focus:false });
|
||
} else if (kind === 'pull') {
|
||
mobilePullDetailNavigation.navigate(section, { focus:false });
|
||
} else if (kind === 'update') {
|
||
mobileUpdateDetailNavigation.navigate(section, { focus:false });
|
||
} else if (kind === 'review') {
|
||
mobileReviewDetailNavigation.navigate(section, { focus:false });
|
||
}
|
||
}
|
||
|
||
async function openRoutedWorkSection(item) {
|
||
if (item.kind === 'update') await notificationReader.open(item, lastMyWork);
|
||
else if (item.kind === 'review') await openReviewSheet(item, reviewTrigger);
|
||
else if (item.kind === 'issue' || item.kind === 'filed') await openIssueSheet(item, issueTrigger);
|
||
else if (item.kind === 'pull') await openPullSheet(item, pullTrigger);
|
||
}
|
||
|
||
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: async item => {
|
||
qs('#retry-work-route').hidden = true;
|
||
qs('#my-work-action-status').textContent = '';
|
||
closeOpenWorkSheets();
|
||
await openRoutedWorkSection(item);
|
||
mobileRecentWork.record(item);
|
||
const route = createWorkRoute.parse(window.location.hash);
|
||
if (route?.section === item.section) navigateWorkSection(item.kind, item.section);
|
||
},
|
||
onSection: (section, options) => {
|
||
const route = createWorkRoute.parse(window.location.hash);
|
||
if (options.restore) navigateWorkSection(route?.kind, section);
|
||
},
|
||
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() {
|
||
issueVoiceReply.cancel();
|
||
pullVoiceReply.cancel();
|
||
updateVoiceReply.cancel();
|
||
['#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 agendaSessionCheckpoint = createWorkSessionCheckpoint({
|
||
storage: localStorage,
|
||
getLogin: () => confirmedOwnerLogin,
|
||
key: 'stackchain.agenda-session.v1',
|
||
onError: () => {
|
||
qs('#my-work-action-status').textContent =
|
||
'Agenda progress could not be saved on this device. You can keep working.';
|
||
},
|
||
});
|
||
let todaySessionSync = null;
|
||
let todayLockScreen = null;
|
||
const timer = createTodayTimer({
|
||
storage: localStorage,
|
||
getLogin: () => confirmedOwnerLogin,
|
||
onChange: snapshot => {
|
||
todaySessionSync?.publish(timer.sessionSnapshot());
|
||
queueMicrotask(() => {
|
||
if (todayLockScreen) todayLockScreen.sync(snapshot, workSession.checkpointed());
|
||
});
|
||
},
|
||
});
|
||
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 => {
|
||
taskOverlayHistory.leave();
|
||
selectTodayWork();
|
||
const item = [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity);
|
||
if (!workSession.reopen(item)) workSession.resume(item);
|
||
},
|
||
onResume: identity => {
|
||
selectTodayWork();
|
||
const item = todayMyWork.find(entry => todayWork.identity(entry) === identity);
|
||
if (!workSession.resume(item)) resumeTodaySession();
|
||
},
|
||
onComplete: identity => {
|
||
const item = todayMyWork.find(entry => todayWork.identity(entry) === identity);
|
||
return completeTodayItem(item);
|
||
},
|
||
onCapture:() => openCreateIssueSheet(),
|
||
});
|
||
window.stackchainTodayTimerView = timerView;
|
||
mobileInsights.start();
|
||
todaySessionSync = attachTodaySessionHandoff({
|
||
fetchJson:fetchReviewJson, storage:localStorage, timer, qs,
|
||
items:() => [...todayMyWork, ...activeMyWork],
|
||
identity:item => todayWork.identity(item),
|
||
selectToday:() => selectTodayWork(),
|
||
startItem:item => workSession.start(item),
|
||
announce:message => { qs('#my-work-action-status').textContent = message; },
|
||
renderTimer:() => timerView.render(),
|
||
});
|
||
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 todaySummaryView = setupTodaySummary({
|
||
qs, escapeHtml, getLogin:() => planningOwnerLogin, fetchJson:api,
|
||
enqueueDurably:message => authoredOutbox.enqueueDurably(message),
|
||
});
|
||
todaySummaryView.resume();
|
||
const todayWrapUpView = setupTodayWrapUp({ todayWork, tomorrowPlan, todaySync, qs, escapeHtml,
|
||
onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => {
|
||
todayRecapView['completeReplan']();
|
||
refreshMyWorkView();
|
||
warmTodayOffline();
|
||
renderTomorrowQueueSummary();
|
||
syncPendingTomorrow();
|
||
todaySummaryView.open(workedItems, tomorrowItems);
|
||
},
|
||
});
|
||
const todayRecapView = setupTodayRecap(
|
||
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
|
||
() => planningOwnerLogin,
|
||
identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null,
|
||
(actualMinutes, workedItems) => todayWrapUpView.open(todayMyWork, actualMinutes, workedItems)
|
||
);
|
||
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();
|
||
const startWorkSession = qs('#start-work-session');
|
||
startWorkSession.hidden = active;
|
||
startWorkSession.textContent = selectedWorkFilter === 'agenda' ?
|
||
(agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda') : 'Start work';
|
||
qs('#resume-today-session').hidden = active || selectedWorkFilter !== 'today' ||
|
||
!todayMyWork.length || !workSession.resumable();
|
||
qs('#end-today-session').hidden = !active;
|
||
todayProgressView.update();
|
||
updateDetailDeferLabels(active);
|
||
mobileTaskDock.updateWork(mobileWorkEntry.mode());
|
||
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
|
||
}
|
||
|
||
const workSession = createWorkSession({
|
||
getItems: () => selectedWorkFilter === 'today' ? todayMyWork :
|
||
selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork) : activeMyWork,
|
||
getFilter: () => selectedWorkFilter === 'today' ? 'all' :
|
||
selectedWorkFilter === 'agenda' ? 'all' : selectedWorkFilter,
|
||
getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone,
|
||
checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint,
|
||
checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter),
|
||
checkpointedEnabled: () => 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();
|
||
});
|
||
todayProgressView.update();
|
||
},
|
||
onFinish: () => {
|
||
updateWorkSessionActions();
|
||
if (selectedWorkFilter === 'agenda') {
|
||
closeOpenWorkSheets();
|
||
if (mobileStartDay.completePhase('agenda')) return;
|
||
window.location.hash = '#/my-work/agenda';
|
||
qs('#my-work-action-status').textContent = 'Agenda complete.';
|
||
return;
|
||
}
|
||
todayRecapView.finish(selectedWorkFilter);
|
||
},
|
||
});
|
||
function currentTodayProgressTarget() {
|
||
if (!workSession.checkpointed()) return null;
|
||
const item = workSession.target('continue');
|
||
if (!item || item.is_review || !['issue', 'pull'].includes(item.kind) ||
|
||
!item.repository || !Number.isInteger(item.number)) return null;
|
||
return {
|
||
identity:todayWork.identity(item), kind:item.kind, repository:item.repository,
|
||
number:item.number, label:item.key || item.repository + '#' + item.number,
|
||
title:item.title || '', item,
|
||
};
|
||
}
|
||
const todayProgressView = createTodayProgressView({
|
||
progress:todayProgress, currentTarget:currentTodayProgressTarget, qs, photos:todayProgressPhotos,
|
||
voice:todayProgressVoice, mentions:todayProgressMentions,
|
||
activity:mountTodayProgressActivity(qs,fetchReviewJson,actionHydrator,renderMarkdown),
|
||
announce:message => { qs('#my-work-action-status').textContent = message; },
|
||
onAdmitted:() => refreshMyWorkView(),
|
||
moveOn:(target,until) => detailDefer.deferUntil(target.item,until),
|
||
});
|
||
function selectTodayWork() {
|
||
qs('[data-work-filter="today"]').click();
|
||
}
|
||
|
||
const agendaSessionLauncher = createAgendaSessionLauncher({
|
||
selectAgenda: () => selectMobileQueue('agenda'),
|
||
discover: completeAgendaIssues,
|
||
isAgendaSelected: () => selectedWorkFilter === 'agenda',
|
||
hasMore: () => Boolean(workPagination.issue?.has_more),
|
||
hasCheckpoint: () => Boolean(agendaSessionCheckpoint.read()),
|
||
resume: () => workSession.resume(),
|
||
start: () => workSession.start(),
|
||
announce: announceWork,
|
||
});
|
||
function openAgendaSession() {
|
||
return agendaSessionLauncher.open();
|
||
}
|
||
|
||
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;
|
||
qs('#plan-today-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: announceWork,
|
||
});
|
||
function renderCreateStartCapacity() {
|
||
const result = createAndStart.capacity(qs('#create-issue-estimate').value);
|
||
qs('#create-issue-estimate-status').textContent = createAndStart.capacityMessage(result);
|
||
return result;
|
||
}
|
||
qs('#create-issue-estimate').addEventListener('input', () => { renderCreateStartCapacity(); saveIssueCaptureDraft(); });
|
||
|
||
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: announceWork,
|
||
});
|
||
|
||
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: announceWork,
|
||
advance: () => (mobileFirstTask.completeOutcome(), runTodayTransition('complete')),
|
||
});
|
||
todayLockScreen = createTodayLockScreen({
|
||
storage:localStorage,
|
||
getLogin:() => confirmedOwnerLogin,
|
||
serviceWorker:navigator.serviceWorker,
|
||
NotificationRef:window.Notification,
|
||
control:qs('#today-lock-screen'),
|
||
status:qs('#today-lock-screen-status'),
|
||
locationRef:window.location,
|
||
historyRef:window.history,
|
||
onAction:(action, expectedIdentity) => {
|
||
const state = timer.snapshot();
|
||
if (action === 'complete') {
|
||
selectTodayWork();
|
||
if (!state.identity || state.identity !== expectedIdentity) {
|
||
qs('#my-work-action-status').textContent =
|
||
'That lock-screen action is stale. The current Today item was not changed.';
|
||
return;
|
||
}
|
||
const item = todayMyWork.find(entry => todayWork.identity(entry) === expectedIdentity);
|
||
if (!item) {
|
||
qs('#my-work-action-status').textContent =
|
||
'That Today item is no longer available. Nothing was changed.';
|
||
return;
|
||
}
|
||
workSession.reopen(item);
|
||
completeTodayItem(item);
|
||
timerView.render();
|
||
return;
|
||
}
|
||
if (!state.identity || (action === 'pause' && !state.running) ||
|
||
(action === 'resume' && state.running)) return;
|
||
const changed = action === 'pause' ? timer.pause() : timer.resume();
|
||
if (changed === false) return;
|
||
selectTodayWork();
|
||
const item = todayMyWork.find(entry => todayWork.identity(entry) === state.identity);
|
||
if (item) workSession.reopen(item);
|
||
timerView.render();
|
||
},
|
||
});
|
||
await todayLockScreen.sync(timer.snapshot(), workSession.checkpointed());
|
||
await todayLockScreen.consumeLaunchAction();
|
||
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;
|
||
},
|
||
});
|
||
|
||
const filedAssignAndStart = createAssignAndStart({
|
||
available: createAndStart.available,
|
||
claim: item => findWorkController.claim(item),
|
||
start: confirmed => {
|
||
const claimed = acceptClaimedIssue(confirmed);
|
||
closeIssueSheet(false);
|
||
refreshMyWorkView();
|
||
return createAndStart.complete(claimed);
|
||
},
|
||
queue: confirmed => {
|
||
const claimed = acceptClaimedIssue(confirmed);
|
||
const outcome = queueToday(claimed);
|
||
closeIssueSheet(false);
|
||
refreshMyWorkView();
|
||
openRoutedWork(claimed, qs('#my-work'));
|
||
return outcome;
|
||
},
|
||
recover: confirmed => {
|
||
const claimed = acceptClaimedIssue(confirmed);
|
||
closeIssueSheet(false);
|
||
refreshMyWorkView();
|
||
openRoutedWork(claimed, qs('#my-work'));
|
||
},
|
||
announce: message => {
|
||
qs('#issue-sheet-status').textContent = message;
|
||
qs('#my-work-action-status').textContent = message;
|
||
},
|
||
});
|
||
|
||
const batchFindWork = createBatchFindWork({
|
||
capacity: () => Math.max(0, todayWork.limit - todayWork.read().length),
|
||
owner:()=>planningOwnerLogin,timeBudget:()=>{
|
||
const plan = todayWork.planning();
|
||
return {
|
||
capacity_minutes: plan.capacity_minutes,
|
||
planned_minutes: Object.values(plan.estimates).reduce((sum, minutes) => sum + minutes, 0),
|
||
};
|
||
},
|
||
claim: item => findWorkController.claim(item),
|
||
queue: confirmed => queueToday(acceptClaimedIssue(confirmed)),
|
||
persistEstimate: (confirmed, minutes) => {
|
||
const plan = todayWork.planning();
|
||
plan.estimates[todayWork.identity(confirmed)] = minutes;
|
||
todayWork.replacePlanning(plan);
|
||
todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates);
|
||
todaySync.flush();
|
||
},
|
||
onProgress: progress => {
|
||
if (progress.status === 'full') {
|
||
qs('#find-work-status').textContent = 'Today has ' + progress.available +
|
||
' open slot' + (progress.available === 1 ? '' : 's') + '. Reduce the selection before assigning.';
|
||
} else if (progress.status === 'estimates-required') {
|
||
qs('#find-work-estimate-summary').textContent = 'Add an estimate for every issue.';
|
||
} else if (progress.status === 'over-budget') {
|
||
qs('#find-work-estimate-summary').textContent = formatPlanMinutes(progress.over_minutes) +
|
||
' over Today’s remaining time. Reduce an estimate or selection.';
|
||
} else if (progress.status === 'running') {
|
||
qs('#find-work-status').textContent = 'Assigning and queueing ' + progress.processed +
|
||
' of ' + progress.selected + '…';
|
||
}
|
||
},
|
||
});
|
||
|
||
function findWorkEstimateValues() {
|
||
return Object.fromEntries(Array.from(document.querySelectorAll('[data-find-work-estimate]')).map(input =>
|
||
[input.dataset.findWorkEstimate, Number(input.value)]
|
||
));
|
||
}
|
||
|
||
function openFindWorkEstimateReview(items) {
|
||
const plan = todayWork.planning();
|
||
const planned = Object.values(plan.estimates).reduce((sum, minutes) => sum + minutes, 0);
|
||
const estimatesRequired = Number.isInteger(plan.capacity_minutes) && plan.capacity_minutes > 0;
|
||
qs('#find-work-estimate-summary').textContent = estimatesRequired ?
|
||
formatPlanMinutes(Math.max(0, plan.capacity_minutes - planned)) +
|
||
' remaining. Estimate selected work before assigning it.' :
|
||
items.length + ' selected. Confirm to assign and queue this batch in Today.';
|
||
qs('#find-work-estimate-list').innerHTML = items.map(item => {
|
||
const id = String(item.repository || '') + '#' + String(item.number || '');
|
||
return '<div class="find-work-estimate-row"><div><span class="small">' + escapeHtml(id) +
|
||
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div>' +
|
||
(estimatesRequired ? '<label class="small"><input type="number" inputmode="numeric" min="5" max="1440" step="5" data-find-work-estimate="' +
|
||
escAttr(id) + '" aria-label="Estimate for ' + escAttr(item.title || id) + ' in minutes" /> min</label>' : '') + '</div>';
|
||
}).join('');
|
||
if (estimatesRequired) qs('[data-find-work-estimate]')?.focus();
|
||
}
|
||
|
||
function formatCalendarDueDate(value) {
|
||
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/);
|
||
if (!match) return '';
|
||
return new Intl.DateTimeFormat(undefined).format(
|
||
new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
|
||
);
|
||
}
|
||
|
||
let planTodayTrigger = null;
|
||
let pendingProtectToday = 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();
|
||
planningTomorrow = false;
|
||
wc.close();
|
||
weekFlow.clear();
|
||
qs('#tomorrow-conflict-review').hidden = true;
|
||
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
|
||
qs('#week-conflict-review').hidden = true;
|
||
qs('#plan-today-sheet').classList.remove('week-conflict-mode');
|
||
qs('#plan-today-sheet').hidden = true;
|
||
document.body.classList.remove('task-overlay-open');
|
||
if (planTodayTrigger?.dataset.mobileQueue === 'tomorrow') {
|
||
if (!qs('#mobile-queue-sheet').open) qs('#mobile-queue-sheet').showModal();
|
||
}
|
||
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;
|
||
}
|
||
|
||
function saveTomorrowPlan(plan) {
|
||
const normalized = Array.isArray(plan) ?
|
||
{ids:plan, capacity_minutes:null, estimates:{}} : plan;
|
||
const staged = tomorrowPlan.stage(normalized);
|
||
if (!staged) {
|
||
qs('#my-work-action-status').textContent =
|
||
'Tomorrow could not be saved on this phone. Free browser storage and retry.';
|
||
return false;
|
||
}
|
||
renderTomorrowQueueSummary(staged);
|
||
qs('#my-work-action-status').textContent = 'Tomorrow saved on this phone · sync pending.';
|
||
syncPendingTomorrow();
|
||
return true;
|
||
}
|
||
|
||
const planToday = createPlanToday({
|
||
identity: item => todayWork.identity(item),
|
||
limit: todayWork.limit,
|
||
save: plan => weekFlow.active() ? weekFlow.save(plan) :
|
||
(planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)),
|
||
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(!preview.open && 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 tomorrowConflictPlanMarkup(value) {
|
||
const ids = value?.ids || [];
|
||
const estimates = value?.estimates || {};
|
||
const estimated = ids.reduce((total, id) => total + (Number(estimates[id]) || 0), 0);
|
||
const capacity = Number(value?.capacity_minutes) || 0;
|
||
const summary = ids.length ? `${ids.length} planned` +
|
||
(estimated && capacity ? ` · ${estimated} of ${capacity} min` : '') : 'Nothing planned';
|
||
const available = [...todayMyWork, ...activeMyWork];
|
||
const items = ids.map(id => {
|
||
const item = available.find(candidate => todayWork.identity(candidate) === id);
|
||
const label = item?.title || item?.key || id;
|
||
const estimate = Number(estimates[id]) || 0;
|
||
return `<li><strong>${escapeHtml(label)}</strong>${estimate ? ` <span>· ${estimate} min</span>` : ''}</li>`;
|
||
}).join('');
|
||
return `<p class="tomorrow-conflict-plan-summary">${summary}</p>` +
|
||
(items ? `<ol class="tomorrow-conflict-plan-list">${items}</ol>` : '<p class="muted">No work selected.</p>');
|
||
}
|
||
|
||
function showTomorrowConflict(conflict) {
|
||
qs('#plan-today-title').textContent = 'Resolve Tomorrow conflict';
|
||
qs('#tomorrow-conflict-phone-plan').innerHTML = tomorrowConflictPlanMarkup(conflict.local);
|
||
qs('#tomorrow-conflict-server-plan').innerHTML = tomorrowConflictPlanMarkup(conflict.remote);
|
||
qs('#tomorrow-conflict-status').textContent = '';
|
||
qs('#tomorrow-conflict-review').hidden = false;
|
||
qs('#plan-today-sheet').classList.add('tomorrow-conflict-mode');
|
||
qs('#plan-today-sheet').hidden = false;
|
||
document.body.classList.add('task-overlay-open');
|
||
const keep = qs('#keep-phone-tomorrow');
|
||
keep.focus();
|
||
requestAnimationFrame(() => keep.focus());
|
||
}
|
||
|
||
function showWeekConflict(conflict) {
|
||
qs('#plan-today-title').textContent='Resolve Week Ahead conflict';
|
||
const focus=weekFlow.renderConflict(conflict);
|
||
qs('#week-conflict-status').textContent='';
|
||
qs('#week-conflict-review').hidden=false;
|
||
qs('#plan-today-sheet').classList.add('week-conflict-mode');
|
||
qs('#plan-today-sheet').hidden=false;
|
||
document.body.classList.add('task-overlay-open');
|
||
focus.focus();requestAnimationFrame(()=>focus.focus());
|
||
}
|
||
|
||
function openPlanToday(trigger, navigate = true, actualMinutes = null) {
|
||
if (!planningOwnerLogin && !weekFlow.active()) {
|
||
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
||
return;
|
||
}
|
||
if (trigger) planTodayTrigger = trigger;
|
||
const weekCopy=weekFlow.copy();
|
||
qs('#plan-today-title').textContent = weekCopy ? weekCopy.title : (planningTomorrow ? 'Plan Tomorrow' :
|
||
(pendingProtectToday ? 'Protect Today' : (rolloverReviewPlan ? 'New day review' : 'Plan Today')));
|
||
if (actualMinutes) pendingPlanActualMinutes = actualMinutes;
|
||
if (navigate) {
|
||
taskOverlayHistory.open('plan-today');
|
||
return;
|
||
}
|
||
const conflict = planningTomorrow ? tomorrowPlan.conflict() : null;
|
||
if (conflict) {
|
||
showTomorrowConflict(conflict);
|
||
return;
|
||
}
|
||
const weekConflict=weekFlow.active()?weekPlan.conflict():null;
|
||
if(weekConflict){showWeekConflict(weekConflict);return;}
|
||
qs('#tomorrow-conflict-review').hidden = true;
|
||
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
|
||
qs('#week-conflict-review').hidden=true;
|
||
qs('#plan-today-sheet').classList.remove('week-conflict-mode');
|
||
const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes;
|
||
pendingPlanActualMinutes = null;
|
||
const protectProposal = pendingProtectToday;
|
||
const tomorrow = planningTomorrow ? tomorrowPlan.state() : null;
|
||
const weekDay = weekFlow.day();
|
||
const availablePlanningItems = [...todayMyWork, ...activeMyWork].filter((item, index, items) =>
|
||
items.findIndex(candidate => todayWork.identity(candidate) === todayWork.identity(item)) === index
|
||
);
|
||
const selected = (planningTomorrow || weekCopy) ? availablePlanningItems.filter(item =>
|
||
(weekDay || tomorrow).ids.includes(todayWork.identity(item))
|
||
) : (protectProposal?.selected || todayMyWork);
|
||
planToday.open(selected, activeMyWork, weekDay || (planningTomorrow ? tomorrow : todayWork.planning()), recommendations);
|
||
qs('#today-plan-heading').textContent = weekCopy ? weekCopy.heading : (planningTomorrow ? 'Tomorrow, in order' : 'Today, in order');
|
||
qs('.plan-today-available').firstChild.textContent = weekCopy ? weekCopy.available : (planningTomorrow ? 'Available this day' : 'Available today');
|
||
qs('#build-today-plan').textContent = weekCopy ? weekCopy.build : (planningTomorrow ? 'Build my Tomorrow' : 'Build my Today');
|
||
qs('#save-and-start-today').hidden = planningTomorrow || Boolean(weekCopy);
|
||
pendingProtectToday = null;
|
||
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();
|
||
weekFlow.renderDates();
|
||
if (protectProposal) qs('#plan-today-build-status').textContent = protectProposal.summary +
|
||
(protectProposal.displaced.length ? '. Displaced work remains unchanged until you save.' : '. Review estimates and capacity before saving.');
|
||
qs('#cancel-plan-today').focus();
|
||
}
|
||
|
||
qs('#protect-today').addEventListener('click', event => {
|
||
const proposal = protectToday.propose({
|
||
agenda:agendaMyWork(activeMyWork), today:todayMyWork,
|
||
identity:item => todayWork.identity(item), limit:todayWork.limit,
|
||
});
|
||
if (!proposal.protected.length) {
|
||
qs('#protect-today-status').textContent = 'No overdue or due-today work needs protection.';
|
||
return;
|
||
}
|
||
pendingProtectToday = proposal;
|
||
openPlanToday(event.currentTarget);
|
||
});
|
||
|
||
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: announceWork,
|
||
formatTime: fmt,
|
||
});
|
||
const laterPickerElement = qs('#later-picker');
|
||
const laterPickerInput = qs('#later-picker-time');
|
||
let searchDefer = null;
|
||
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 === 'search') {
|
||
return () => searchDefer.run(item, until).then(outcome => {
|
||
if (outcome === 'deferred') return next();
|
||
}).catch(error => {
|
||
qs('#search-preview-status').textContent = error.message + ' Retry.';
|
||
});
|
||
}
|
||
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('#mute-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();
|
||
void syncCompletedFiledReviews();
|
||
}
|
||
|
||
function listDrafts() {
|
||
const unfiled = unfiledCaptures.list().map(item => ({
|
||
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', ...unfiledDraftSummary(item),
|
||
title:unfiledDraftDisplayTitle(item),
|
||
preview:[item.body, item.hasAttachment ? 'Photo evidence attached' : ''].filter(Boolean).join(' · '),
|
||
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(photoDraftInbox.list(), unfiled).sort((left, right) =>
|
||
Number(right.updated_at || 0) - Number(left.updated_at || 0)
|
||
);
|
||
}
|
||
|
||
async function refreshPhotoDraftInbox() {
|
||
try { await photoDraftInbox.refresh(draftInbox.list()); refreshMyWorkView({ reconcileSession:false, refreshFirstTask:false }); }
|
||
catch (_error) { /* Local photo inventory must not break My Work. */ }
|
||
}
|
||
|
||
function refreshMyWorkView({ reconcileSession = true, refreshFirstTask = true } = {}) {
|
||
lastDrafts = listDrafts();
|
||
const actionableMyWork = filedHistoryTabs.prepare(lastMyWork);
|
||
const partitioned = laterWork.partition(actionableMyWork, {
|
||
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
|
||
});
|
||
activeMyWork = partitioned.active;
|
||
laterMyWork = partitioned.later;
|
||
const authoritativeMyWorkRefresh = liveMode && hasContextSnapshot &&
|
||
!lastContextSnapshot?.error &&
|
||
!Object.values(workPagination).some(page => page?.has_more);
|
||
todayMyWork = todayWork.reconcile(actionableMyWork, {
|
||
pruneMissing: authoritativeMyWorkRefresh,
|
||
onPrune: retiredIds => {
|
||
const queued = retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean);
|
||
if (queued) todaySync.flush();
|
||
return queued;
|
||
},
|
||
});
|
||
const counts = countMyWork(activeMyWork);
|
||
const agendaItems = agendaMyWork(activeMyWork);
|
||
counts.agenda = agendaItems.length;
|
||
counts.today = todayMyWork.length;
|
||
counts.later = laterMyWork.length;
|
||
counts.draft = lastDrafts.length;
|
||
counts.delivery = draftInbox.partition(lastDrafts).actionable;
|
||
counts.gate = queueCounts.gate;
|
||
counts.gateUnavailable = queueCounts.gateUnavailable;
|
||
counts.following = queueCounts.following;
|
||
counts.followingUnavailable = queueCounts.followingUnavailable;
|
||
preparationItems = {
|
||
delivery:draftInbox.partition(lastDrafts).deliveries,
|
||
gate:preparationItems.gate || [],
|
||
following:preparationItems.following || [],
|
||
agenda:agendaMyWork(activeMyWork),
|
||
attention:activeMyWork.filter(item => item.needs_attention),
|
||
update:activeMyWork.filter(item => item.has_update),
|
||
filed:activeMyWork.filter(item => item.is_filed),
|
||
};
|
||
if (!launchFilterResolved) {
|
||
selectedWorkFilter = mobileLaunch.chooseFilter({
|
||
saved: savedWorkFilter, today: counts.today, attention: counts.attention,
|
||
agenda: counts.agenda,
|
||
});
|
||
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;
|
||
});
|
||
queueCounts = counts;
|
||
mobileQueueLauncher.renderPresentation();
|
||
mobileStartDay.reconcile({
|
||
authoritative:authoritativeMyWorkRefresh,
|
||
authoritativePhases:['delivery'],
|
||
});
|
||
mobileStartDay.render();
|
||
mobileTaskDock.updateQueues(counts);
|
||
if (refreshFirstTask) mobileFirstTask.refresh();
|
||
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 (selectedWorkFilter === 'agenda' && workPagination.issue?.has_more && !agendaChecking) {
|
||
completeAgendaIssues();
|
||
}
|
||
if (reconcileSession && workSession.active()) workSession.reconcile();
|
||
updateWorkSessionActions();
|
||
if (todayHandoff.pending().length) setTimeout(promptTodayHandoff, 0);
|
||
}
|
||
|
||
function activeWorkStreams() {
|
||
if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review'];
|
||
if (selectedWorkFilter === 'agenda') return ['issue'];
|
||
if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review'];
|
||
if (selectedWorkFilter === 'filed') return ['filed'];
|
||
if (['issue', 'pull', 'review', 'authored'].includes(selectedWorkFilter)) {
|
||
return [selectedWorkFilter];
|
||
}
|
||
if (selectedWorkFilter === 'all') return ['issue', 'filed', 'pull', 'review', 'authored'];
|
||
return [];
|
||
}
|
||
|
||
async function completeAgendaIssues() {
|
||
if (selectedWorkFilter !== 'agenda') return false;
|
||
if (!workPagination.issue?.has_more) return true;
|
||
if (!lastContextSnapshot) return false;
|
||
agendaChecking = true;
|
||
qs('#my-work-action-status').textContent = 'Checking all assigned deadlines…';
|
||
renderMyWork();
|
||
const complete = await workPager.loadAll('issue', () => lastContextSnapshot?.issues || []);
|
||
agendaChecking = false;
|
||
if (selectedWorkFilter !== 'agenda') return false;
|
||
qs('#my-work-action-status').textContent = complete ?
|
||
'All assigned deadlines checked.' :
|
||
'Agenda check paused. Retry to check older assigned deadlines.';
|
||
renderMyWork();
|
||
return complete && !workPagination.issue?.has_more;
|
||
}
|
||
|
||
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 checklistConflict = item.checklist_conflict === true;
|
||
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' && checklistConflict ?
|
||
'<button class="draft-review-checklist" data-draft-index="' + index + '" type="button">Review changes</button>' +
|
||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</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 ? item.action : 'Resume draft') + '</button>' +
|
||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
|
||
const state = isOutbox ?
|
||
'<span class="pill">' + draftInbox.deliveryLabel(item) + '</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, item.details].filter(Boolean).join(' · ')) + '</span>' +
|
||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||
'<span class="draft-preview">' + escapeHtml(photoDraftInbox.description(item)) + '</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" tabindex="-1">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 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; attention stayed queued.';
|
||
});
|
||
list.querySelectorAll('.draft-resume').forEach(button => {
|
||
button.onclick=async()=>{
|
||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||
if (!item) return;
|
||
if (item.kind === 'unfiled-issue') {
|
||
try {
|
||
await dFS.open(item.capture_id, item.ready ? button : null);
|
||
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
||
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
||
else if (item.kind === 'photo-reply' && item.route?.kind === 'search') {
|
||
taskOverlayHistory.open('search-preview', { preview:photoDraftInbox.searchTarget(item) });
|
||
}
|
||
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.attachments || hydrated.attachment) {
|
||
createIssueAttachmentController.restore(hydrated.attachments || 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-review-checklist').forEach(button => {
|
||
button.onclick=async()=>{
|
||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||
const queued = authoredOutbox.list().find(candidate => candidate.id === item?.outbox_id);
|
||
if (!item?.checklist_conflict || !queued || !activeFlushLogin) return;
|
||
button.disabled = true;
|
||
qs('#my-work-action-status').textContent = 'Loading the latest checklist for review…';
|
||
try {
|
||
const latest = await issueController.load({
|
||
repository:item.repository, number:item.number,
|
||
});
|
||
const preview = mergeChecklistConflict({
|
||
baseBody:queued.baseBody, localBody:queued.body, remoteBody:latest.body,
|
||
});
|
||
if (preview.conflicts.length) {
|
||
qs('#my-work-action-status').textContent = 'Checklist changes for ' +
|
||
preview.conflicts.map(conflict => conflict.label).join(', ') +
|
||
' could not be matched safely. Open the issue to compare renamed or deleted tasks.';
|
||
return;
|
||
}
|
||
if (!window.confirm('Apply ' + preview.changes.length + ' checklist change' +
|
||
(preview.changes.length === 1 ? '' : 's') + ' to the latest issue? Remote prose and other tasks will stay unchanged.')) return;
|
||
const result = await authoredOutbox.resolveIssueContentConflict(item.outbox_id, latest, activeFlushLogin);
|
||
if (result.conflicts.length) return;
|
||
const delivery = await authoredOutbox.retry(item.outbox_id, activeFlushLogin);
|
||
applyAuthoredOutboxResult(delivery);
|
||
qs('#my-work-action-status').textContent = delivery.confirmed?.length ?
|
||
'Checklist changes applied to the latest issue.' :
|
||
'The issue changed again. Review the checklist conflict against the new version.';
|
||
} catch (error) {
|
||
qs('#my-work-action-status').textContent = String(error?.message || 'Checklist review could not be loaded. Retry when online.');
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
};
|
||
});
|
||
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.onclick=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?.kind === 'photo-reply') await photoDraftInbox.discard(item);
|
||
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.';
|
||
});
|
||
});
|
||
mobileDeliveryRecovery.render();
|
||
}
|
||
|
||
function updateWorkPaginationControls() {
|
||
const labels = { issue: 'issues', filed: 'filed issues', pull: 'pull requests', review: 'review requests', authored: 'pull 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;
|
||
qs('#filed-history-tabs').hidden = true;
|
||
return;
|
||
}
|
||
const filedItems = filedHistoryTabs.render(selectedFiledView, selectedWorkFilter === 'filed');
|
||
const queueItems = selectedWorkFilter === 'filed' ? filedItems : selectedWorkFilter === 'today' ?
|
||
filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ?
|
||
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'agenda' ?
|
||
agendaMyWork(activeMyWork) :
|
||
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
|
||
const overdue = queueItems.filter(item => item.agenda_group === 'Overdue');
|
||
const urgentAgenda = selectedWorkFilter === 'agenda' ? queueItems.filter(item =>
|
||
item.agenda_group === 'Overdue' || item.agenda_group === 'Today') : [];
|
||
const protectPanel = qs('#protect-today-panel');
|
||
protectPanel.hidden = selectedWorkFilter !== 'agenda' || urgentAgenda.length === 0;
|
||
qs('#protect-today-status').textContent = urgentAgenda.length ?
|
||
urgentAgenda.length + ' urgent ' + (urgentAgenda.length === 1 ? 'deadline is' : 'deadlines are') + ' ready to reconcile with Today.' : '';
|
||
const replanPanel = qs('#agenda-replan');
|
||
replanPanel.hidden = selectedWorkFilter !== 'agenda' || overdue.length === 0;
|
||
if (!agendaReplan?.snapshot().active) {
|
||
qs('#start-agenda-replan').textContent = 'Replan overdue (' + overdue.length + ')';
|
||
qs('#agenda-replan-controls').hidden = true;
|
||
}
|
||
const agendaExport = qs('#agenda-export');
|
||
const agendaExportButton = qs('#open-agenda-export');
|
||
agendaExport.hidden = selectedWorkFilter !== 'agenda';
|
||
agendaExportButton.disabled = queueItems.length === 0;
|
||
qs('#agenda-export-empty').textContent = queueItems.length ?
|
||
'Review ' + queueItems.length + (queueItems.length === 1 ? ' deadline' : ' deadlines') +
|
||
' before sharing a calendar snapshot.' :
|
||
'No deadlines are available to export from this Agenda.';
|
||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||
const visible = findQueueItems(queueItems, queueFindQuery);
|
||
const emptyWorkStart = qs('#empty-work-start');
|
||
const showEmptyStart = selectedWorkFilter === 'all' && !queueFindQuery &&
|
||
!queueItems.length && !incomplete && hasContextSnapshot;
|
||
emptyWorkStart.hidden = !showEmptyStart;
|
||
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>' +
|
||
(item.agenda_group ? '<span class="pill agenda-badge" data-agenda-group="' + escAttr(item.agenda_group) + '">' + escapeHtml(item.agenda_group) + '</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('') : (showEmptyStart ? '' : '<div class="muted">' +
|
||
(selectedWorkFilter === 'agenda' && workPagination.issue?.has_more ?
|
||
(agendaChecking ? 'Checking all assigned deadlines…' :
|
||
'Older assigned deadlines remain unchecked. Retry the Agenda check.') : (incomplete ?
|
||
'More work is available. Load the next page.' : (selectedWorkFilter === 'filed' && selectedFiledView === 'reviewed' ?
|
||
'No reviewed outcomes yet' :
|
||
'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();
|
||
}
|
||
|
||
const {paintIssueConversation,paintPullConversation,paintUpdateConversation}=createConversationRenderers({
|
||
qs,renderComment:(comment,controller)=>renderConversationComment(
|
||
comment,controller,escapeHtml,fmt,renderMarkdown),
|
||
updateReadPosition,getSelectedUpdate:()=>selectedUpdate,
|
||
});
|
||
|
||
function commentSurface(selector) {
|
||
if (selector === '#search-preview-comments') return {
|
||
context:{kind:searchPreviewDetail.kind,item:searchPreviewDetail}, pager:searchPreview.commentPager(),
|
||
render:state=>showSearchConversationWithActions(state), status:qs('#search-preview-conversation-status'),
|
||
};
|
||
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, controller = commentActions) {
|
||
controller.wire({
|
||
root:qs(selector), getSurface:()=>commentSurface(selector),
|
||
isOffline:()=>offlineWorkMode || navigator.onLine === false, escapeHtml,
|
||
});
|
||
}
|
||
|
||
const conversationActionSurfaces = {
|
||
issue: { selector:'#issue-comments', paint:paintIssueConversation },
|
||
pull: { selector:'#pull-comments', paint:paintPullConversation },
|
||
update: { selector:'#update-comments', paint:paintUpdateConversation },
|
||
};
|
||
const latestConversationStates = {};
|
||
|
||
function wireConversationActions(kind) {
|
||
if (kind === 'issue') wireCommentActions('#issue-comments');
|
||
if (kind === 'pull') wireCommentActions('#pull-comments');
|
||
if (kind === 'update') wireCommentActions('#update-comments');
|
||
}
|
||
|
||
function showConversationWithActions(kind, state) {
|
||
const surface = conversationActionSurfaces[kind];
|
||
latestConversationStates[kind] = state;
|
||
return actionHydrator.show({
|
||
root:qs(surface.selector), state, paint:surface.paint,
|
||
retry:qs('#retry-' + kind + '-comment-actions'),
|
||
wire:controller => wireConversationActions(kind, controller),
|
||
});
|
||
}
|
||
|
||
function renderIssueConversation(state) { void showConversationWithActions('issue', state); }
|
||
function renderPullConversation(state) { void showConversationWithActions('pull', state); }
|
||
function renderUpdateConversation(state) { void showConversationWithActions('update', state); }
|
||
|
||
function retryConversationActions(kind) {
|
||
const state = latestConversationStates[kind];
|
||
if (state) void showConversationWithActions(kind, state);
|
||
}
|
||
qs('#retry-issue-comment-actions').addEventListener('click', () => retryConversationActions('issue'));
|
||
qs('#retry-pull-comment-actions').addEventListener('click', () => retryConversationActions('pull'));
|
||
qs('#retry-update-comment-actions').addEventListener('click', () => retryConversationActions('update'));
|
||
|
||
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);
|
||
}
|
||
|
||
|
||
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);
|
||
issueController.renderMilestoneEditor(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;
|
||
}
|
||
}
|
||
|
||
function renderChecklistCompletion(detail) {
|
||
const interactive = !issueController.readOnly(selectedIssue) && detail?.state === 'open' && detail?.updated_at;
|
||
const completion = issueController.checklistCompletion(qs('#issue-sheet-body'), {
|
||
interactive, today:workSession.active(), offline:selectedIssueOffline,
|
||
dismissed:dismissedChecklistBody === detail?.body,
|
||
});
|
||
const bar = qs('#checklist-completion');
|
||
bar.hidden = !completion.visible;
|
||
qs('#checklist-completion-status').textContent = completion.status;
|
||
qs('#complete-checklist-issue').textContent = completion.label;
|
||
}
|
||
|
||
function renderIssueBody(detail) {
|
||
const interactive = !issueController.readOnly(selectedIssue) && detail.state === 'open' && detail.updated_at;
|
||
issueController.renderTasks(qs('#issue-sheet-body'), detail, interactive);
|
||
qs('#open-add-checklist-step').disabled = !interactive;
|
||
renderChecklistCompletion(detail);
|
||
}
|
||
|
||
function applyIssueContent(editing, detail, confirmed) {
|
||
const merged = issueController.mergeContent(
|
||
lastContextSnapshot, editing, detail, confirmed, buildMyWork.replaceIssueContent
|
||
);
|
||
lastContextSnapshot = merged.snapshot;
|
||
selectedIssue = merged.item;
|
||
selectedIssueDetail = merged.detail;
|
||
qs('#issue-sheet-title').textContent = confirmed.title;
|
||
renderIssueBody(selectedIssueDetail);
|
||
if (lastContextSnapshot) paintMyWork(lastContextSnapshot);
|
||
}
|
||
|
||
async function openIssueSheet(item, trigger, offlineDetail = null) {
|
||
if (!item) return;
|
||
const readOnly = issueController.readOnly(item);
|
||
const withdrawable = item.is_filed && !item.is_assigned && !item.is_completed && item.state === 'open';
|
||
const reassignable = item.is_filed && !item.is_completed && item.state === 'open' &&
|
||
Array.isArray(item.assignees) && item.assignees.length > 0;
|
||
qs('#issue-sheet').classList.toggle('read-only', readOnly);
|
||
qs('#issue-sheet').classList.toggle('filed-reassignable', reassignable);
|
||
issueDetailPosition.open(workDetailIdentity('issue', item));
|
||
qs('#issue-planning').inert = false;
|
||
qs('#issue-handoff').inert = false;
|
||
selectedIssue = item;
|
||
void conversationPhotoDrafts.switchTo('issue',
|
||
{ kind:'issue', repository:item.repository, number:item.number }, () => selectedIssue === item);
|
||
void issueVoiceReply.open(conversationVoiceTarget('issue', item));
|
||
dismissedChecklistBody = null;
|
||
issueMentions.dismiss();
|
||
selectedIssueOffline = Boolean(offlineDetail);
|
||
selectedIssueDetail = null;
|
||
delegatedParentReview = null;
|
||
issueConversation = null;
|
||
issueTrigger = trigger;
|
||
qs('#issue-sheet').classList.add('open');
|
||
if (offlineDetail) {
|
||
qs('#watch-issue-detail').hidden = true;
|
||
qs('#issue-watch-status').textContent = 'Reconnect to change watch status.';
|
||
} else {
|
||
qs('#watch-issue-detail').hidden = item.state !== 'open';
|
||
void issueDetailWatch.open(item).catch(error => {
|
||
if (selectedIssue === item) qs('#issue-watch-status').textContent = error.message + ' Retry.';
|
||
});
|
||
}
|
||
qs('#issue-sheet-key').textContent = item.key || '';
|
||
qs('#issue-sheet-title').textContent = item.title || 'Assigned issue';
|
||
qs('#issue-sheet-status').textContent = 'Loading issue…';
|
||
const completedItems = lastMyWork.filter(candidate => candidate?.is_completed && candidate?.is_filed);
|
||
const completedPosition = completedItems.findIndex(candidate =>
|
||
candidate.repository === item.repository && candidate.number === item.number
|
||
);
|
||
qs('#completed-filed-actions').hidden = !item.is_completed;
|
||
qs('#complete-parent-and-acknowledge').hidden = true;
|
||
qs('#complete-parent-and-acknowledge').disabled = true;
|
||
qs('#filed-claim-actions').hidden = true;
|
||
qs('#queue-filed-issue').disabled = true;
|
||
qs('#start-filed-issue').disabled = true;
|
||
qs('#completed-filed-progress').textContent = item.is_completed ?
|
||
'Completed Filed issue ' + (completedPosition + 1) + ' of ' + completedItems.length : '';
|
||
qs('#issue-sheet-body').textContent = '';
|
||
checklistStepManagement.reset();
|
||
qs('#checklist-step-label').value = '';
|
||
qs('#open-add-checklist-step').disabled = true;
|
||
qs('#add-checklist-step-form').hidden = true;
|
||
qs('#add-checklist-step').value = '';
|
||
qs('#add-checklist-step-status').textContent = '';
|
||
qs('#checklist-completion').hidden = true;
|
||
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-title').textContent = readOnly ? 'Add follow-up' : 'Add comment';
|
||
qs('#issue-comment-status').textContent = '';
|
||
qs('#issue-handoff').open = false;
|
||
qs('#issue-handoff-summary').textContent = reassignable ? 'Change delegate' : 'Hand off to teammate';
|
||
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 = reassignable ? 'Confirm change' :
|
||
(workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff');
|
||
qs('#load-issue-handoff').disabled = false;
|
||
qs('#issue-handoff-status').textContent = reassignable ?
|
||
'Load teammates to change the current delegate.' : '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').hidden = readOnly && !withdrawable;
|
||
qs('#close-issue').textContent = withdrawable ? 'Withdraw issue' : 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 loadedDetail = offlineDetail || await issueController.load(item);
|
||
if (selectedIssue !== item) return;
|
||
const detail = issueController.pendingTask(
|
||
item, loadedDetail, authoredOutbox.list(), confirmedOwnerLogin
|
||
);
|
||
selectedIssueDetail = detail;
|
||
renderPlanIssueDependencies(detail);
|
||
issueConversation = issueController.conversation(item, detail.conversation);
|
||
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
|
||
renderIssueBody(detail);
|
||
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';
|
||
const claimableFiling = !offlineDetail && filedClaimEligible(item, detail);
|
||
qs('#filed-claim-actions').hidden = !claimableFiling;
|
||
qs('#queue-filed-issue').disabled = !claimableFiling;
|
||
qs('#start-filed-issue').disabled = !claimableFiling;
|
||
if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);
|
||
else renderIssueConversation(issueConversation.snapshot());
|
||
qs('#open-issue-gitea').href = detail.url || item.url || '#';
|
||
qs('#issue-sheet-status').textContent = item.is_completed ?
|
||
'Completed Filed outcome ready · review the conversation' : readOnly ? 'Filed issue ready · follow-up enabled' :
|
||
'Issue ready · ' + (detail.state || 'open');
|
||
const parentReference = item.is_completed && !offlineDetail ?
|
||
issueController.delegatedParentReference(detail) : null;
|
||
if (parentReference) {
|
||
try {
|
||
const parentItem = { ...parentReference, key:parentReference.repository + '#' + parentReference.number };
|
||
const parentDetail = await issueController.load(parentItem);
|
||
if (selectedIssue !== item) return;
|
||
const relationship = issueController.resolveDelegatedParent(item, detail, parentDetail);
|
||
if (relationship && parentDetail.state === 'open' && parentDetail.updated_at) {
|
||
delegatedParentReview = { parentItem, parentDetail, relationship };
|
||
const button = qs('#complete-parent-and-acknowledge');
|
||
button.hidden = false;
|
||
button.disabled = false;
|
||
button.textContent = relationship.checklistComplete ?
|
||
'Complete & close parent · acknowledge' : relationship.alreadyCompleted ?
|
||
'Parent complete · acknowledge' : 'Complete parent step & acknowledge';
|
||
qs('#completed-filed-progress').textContent = relationship.checklistComplete ?
|
||
'Delegated outcome completes the parent checklist. Confirm to close the parent.' :
|
||
relationship.alreadyCompleted ? 'Parent checklist already reflects this outcome.' :
|
||
'Delegated outcome ready · complete its parent checklist step.';
|
||
}
|
||
} catch (_error) {
|
||
if (selectedIssue !== item) return;
|
||
delegatedParentReview = null;
|
||
}
|
||
}
|
||
const revisableFiling = item.is_filed && !item.is_completed && detail.state === 'open';
|
||
qs('#edit-issue-content').textContent = revisableFiling ? 'Revise filing' : 'Edit issue';
|
||
qs('#edit-issue-content').disabled = readOnly && !revisableFiling;
|
||
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 ' + formatCalendarDueDate(detail.due_date) : '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 && issueEditHistoryActive) {
|
||
history.back();
|
||
return;
|
||
}
|
||
if (navigate && createWorkRoute.parse(window.location.hash)) {
|
||
workRoute.close();
|
||
return;
|
||
}
|
||
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
|
||
issueVoiceReply.cancel();
|
||
void conversationPhotoDrafts.leave('issue');
|
||
qs('#issue-sheet').classList.remove('open');
|
||
selectedIssue = null;
|
||
selectedIssueOffline = false;
|
||
selectedIssueDetail = null;
|
||
issueConversation = null;
|
||
setTimeout(()=>issueTrigger?.focus());
|
||
}
|
||
|
||
function closeIssueEditorFromHistory() {
|
||
if (!issueEditHistoryActive) return;
|
||
issueEditHistoryActive = false;
|
||
qs('#issue-edit-form').hidden = true;
|
||
if (selectedIssue) qs('#edit-issue-content').focus();
|
||
}
|
||
window.addEventListener('popstate', closeIssueEditorFromHistory);
|
||
|
||
function acknowledgeCompletedFiled() {
|
||
if (!selectedIssue?.is_completed) return;
|
||
if (!completedFiledReview.acknowledge(selectedIssue)) {
|
||
qs('#issue-sheet-status').textContent = 'Could not save this acknowledgement on this device. Retry.';
|
||
return;
|
||
}
|
||
const acknowledged = selectedIssue;
|
||
closeIssueSheet(false);
|
||
refreshMyWorkView();
|
||
const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork));
|
||
qs('#my-work-action-status').textContent = 'Reviewed ' + acknowledged.key + '.' +
|
||
(target ? ' Opening the next Filed item. Acknowledgement sync pending.' :
|
||
' Filed review is complete. Acknowledgement sync pending.');
|
||
void syncCompletedFiledReviews();
|
||
if (target) {
|
||
const index = lastMyWork.indexOf(target.item);
|
||
const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]');
|
||
openRoutedWork(target.kind === 'update' ? { ...target.item, kind:'update' } : target.item, trigger);
|
||
} else {
|
||
if (mobileStartDay.completePhase('filed')) return;
|
||
window.location.hash = '#/my-work/filed';
|
||
qs('#my-work').focus();
|
||
}
|
||
}
|
||
|
||
qs('#acknowledge-completed-filed').addEventListener('click', acknowledgeCompletedFiled);
|
||
|
||
qs('#complete-parent-and-acknowledge').addEventListener('click', async () => {
|
||
if (!selectedIssue?.is_completed || !delegatedParentReview) return;
|
||
const button = qs('#complete-parent-and-acknowledge');
|
||
const { parentItem, parentDetail, relationship } = delegatedParentReview;
|
||
if (relationship.checklistComplete && !window.confirm(
|
||
'Complete the final checklist step and close ' + parentItem.key + '?'
|
||
)) return;
|
||
button.disabled = true;
|
||
try {
|
||
await issueController.finishDelegatedParentReview({
|
||
relationship, parentItem, parentDetail, childUrl:selectedIssue.url,
|
||
updateContent:(item, payload) => issueController.updateContent(item, payload),
|
||
closeParent:item => issueController.close(item),
|
||
});
|
||
acknowledgeCompletedFiled();
|
||
} catch (error) {
|
||
if (!selectedIssue) return;
|
||
button.disabled = false;
|
||
qs('#issue-sheet-status').textContent =
|
||
'Parent review action did not finish. Reload the parent relationship and retry. ' + error.message;
|
||
button.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.review(detail, pullReviewState, document);
|
||
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 };
|
||
renderPullReview(selectedPullDetail);
|
||
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;
|
||
|
||
pullDetailPosition.open(workDetailIdentity('pull', item));
|
||
qs('#pull-review').inert = false;
|
||
qs('#pull-ownership').inert = false;
|
||
selectedPull = item;
|
||
void conversationPhotoDrafts.switchTo('pull',
|
||
{ kind:'pull', repository:item.repository, number:item.number }, () => selectedPull === item);
|
||
void pullVoiceReply.open(conversationVoiceTarget('pull', item));
|
||
pullMentions.dismiss();
|
||
pullTrigger = trigger;
|
||
selectedPullDetail = null;
|
||
pullConversation = null;
|
||
pullReviewState = null;
|
||
qs('#pull-sheet').classList.add('open');
|
||
if (offlineDetail) {
|
||
qs('#watch-pull-detail').hidden = true;
|
||
qs('#pull-watch-status').textContent = 'Reconnect to change watch status.';
|
||
} else {
|
||
qs('#watch-pull-detail').hidden = item.state !== 'open';
|
||
void pullDetailWatch.open(item).catch(error => {
|
||
if (selectedPull === item) qs('#pull-watch-status').textContent = error.message + ' Retry.';
|
||
});
|
||
}
|
||
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;
|
||
pullController.setReviewDetail(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'));
|
||
pullVoiceReply.cancel();
|
||
void conversationPhotoDrafts.leave('pull');
|
||
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 selectedIssueLabelNames() {
|
||
return Array.from(document.querySelectorAll('input[name="create-issue-label"]:checked'))
|
||
.map(input => input.closest('label')?.querySelector('span')?.textContent?.trim()).filter(Boolean);
|
||
}
|
||
|
||
function renderAvailableIssues(items) {
|
||
const list = qs('#find-work-list');
|
||
const selection = findWorkController.selection();
|
||
list.innerHTML = items.length ? items.map((item, index) => {
|
||
const expanded = findWorkController.isPreviewed(item);
|
||
const selected = findWorkController.isSelected(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' + (selected ? ' selected' : '') + '">' +
|
||
(selection.active ? '<label class="find-work-select"><input type="checkbox" data-find-work-select="' + index +
|
||
'"' + (selected ? ' checked' : '') + ' /> Select ' + escapeHtml(item.title || 'Untitled issue') + '</label>' : '') +
|
||
'<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"' + (selection.active ? ' hidden' : '') + '><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-find-work-select]').forEach(input => {
|
||
input.addEventListener('change', () => {
|
||
const item = findWorkController.items()[Number(input.dataset.findWorkSelect)];
|
||
if (item) findWorkController.toggleSelection(item);
|
||
});
|
||
});
|
||
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');
|
||
findWorkNavigation.open(batchFindWork.pending() > 0);
|
||
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();
|
||
}
|
||
|
||
let issueCaptureBlockers = [];
|
||
|
||
function saveIssueCaptureDraft() {
|
||
if (issueCapture) issueCapture.saveDraft(currentIssueCaptureDraft(false));
|
||
}
|
||
|
||
function currentIssueCaptureDraft(trim = true) {
|
||
return withFilingEstimate(issueTemplatePicker.fields(
|
||
issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, trim)
|
||
), qs('#create-issue-estimate').value);
|
||
}
|
||
|
||
let issueCaptureRepositories = [];
|
||
let nextIssueRepositoryPage = 2;
|
||
let moreIssueRepositoriesAvailable = false;
|
||
let issueRepositorySearchTimer = null;
|
||
let captureBlockerSearchTimer = 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() {
|
||
issueOwnerPicker.updateActions(Boolean(qs('#create-issue-repository').value),
|
||
issueCaptureBlockers.length > 0, createAndStart.available());
|
||
}
|
||
|
||
function renderIssueCaptureBlockers(blockers) {
|
||
issueCaptureBlockers = Array.isArray(blockers) ? blockers.slice(0, 5) : [];
|
||
const selected = qs('#create-issue-blocker-selected');
|
||
selected.replaceChildren(...issueCaptureBlockers.map((blocker, index) => {
|
||
const item = document.createElement('li');
|
||
item.className = 'create-issue-blocker-selected';
|
||
const text = document.createElement('span');
|
||
text.textContent = blocker.repository + ' #' + blocker.number + ' — ' + blocker.title;
|
||
const remove = document.createElement('button');
|
||
remove.type = 'button';
|
||
remove.textContent = 'Remove';
|
||
remove.setAttribute('aria-label', 'Remove blocker ' + blocker.repository + ' #' + blocker.number);
|
||
remove.addEventListener('click', () => {
|
||
renderIssueCaptureBlockers(issueCaptureBlockers.filter((_value, position) => position !== index));
|
||
saveIssueCaptureDraft();
|
||
});
|
||
item.append(text, remove);
|
||
return item;
|
||
}));
|
||
qs('#create-issue-blocker-status').textContent = issueCaptureBlockers.length ?
|
||
issueCaptureBlockers.length + ' blocker' + (issueCaptureBlockers.length === 1 ? '' : 's') +
|
||
' selected. Blocked work will be created without starting.' : 'No blockers selected.';
|
||
updateIssueCreateActions();
|
||
}
|
||
|
||
function renderIssueCaptureBlockerResults(items) {
|
||
const results = qs('#create-issue-blocker-results');
|
||
results.replaceChildren();
|
||
(Array.isArray(items) ? items : []).filter(candidate => !issueCaptureBlockers.some(blocker =>
|
||
blocker.repository === candidate.repository && blocker.number === Number(candidate.number))).forEach(candidate => {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = 'create-issue-blocker-result';
|
||
button.setAttribute('role', 'option');
|
||
button.textContent = candidate.repository + ' #' + candidate.number + ' — ' + candidate.title;
|
||
button.addEventListener('click', () => {
|
||
renderIssueCaptureBlockers([...issueCaptureBlockers, {
|
||
repository:candidate.repository, number:Number(candidate.number), title:candidate.title,
|
||
}]);
|
||
results.hidden = true;
|
||
qs('#create-issue-blocker-search').value = '';
|
||
saveIssueCaptureDraft();
|
||
});
|
||
results.appendChild(button);
|
||
});
|
||
results.hidden = !results.childElementCount;
|
||
}
|
||
|
||
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 + '.';
|
||
issueOwnerPicker.reset(repository);
|
||
loadIssueFilingMetadata(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 loadIssueFilingMetadata(repository, selected = {}) {
|
||
return issueFilingMetadata.load(repository, selected);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
let createIssueModalLifecycle = null;
|
||
let createIssueLauncher = null;
|
||
function issueCaptureModal() {
|
||
if (createIssueModalLifecycle) return createIssueModalLifecycle;
|
||
const root = qs('#create-issue-sheet');
|
||
const background = Array.from(document.body.children).filter(element =>
|
||
element !== root && element.tagName !== 'SCRIPT' && element.getAttribute('role') !== 'dialog'
|
||
);
|
||
createIssueModalLifecycle = createIssueCapture.createModalLifecycle({
|
||
root, background, document, requestClose:() => closeCreateIssueSheet(),
|
||
});
|
||
return createIssueModalLifecycle;
|
||
}
|
||
|
||
async function openCreateIssueSheet(navigate = true) {
|
||
const root = qs('#create-issue-sheet');
|
||
if (!root.classList.contains('open') && document.activeElement && !root.contains(document.activeElement)) {
|
||
createIssueLauncher = document.activeElement;
|
||
}
|
||
if (!issueCapture && !await ensureIssueCapture()) return;
|
||
if (navigate) {
|
||
taskOverlayHistory.open('new');
|
||
return;
|
||
}
|
||
if (!sharedImageHandled && sharedImageMarker === 'bundle') {
|
||
sharedImageHandled = true;
|
||
if (createIssueAttachmentController.state()) {
|
||
qs('#create-issue-attachment-status').textContent = 'Remove the current screenshot before adding the shared screenshots.';
|
||
} else {
|
||
await sharedImageCapture.consume({
|
||
marker:sharedImageMarker,
|
||
store:unfiledAttachmentStore,
|
||
restore:value=>createIssueAttachmentController.restore(value),
|
||
restoreContent:content=>{
|
||
sharedLaunchState = issueCapture.stageSharedContent(content);
|
||
sharedLaunchHandled = true;
|
||
},
|
||
status:message=>{ qs('#create-issue-attachment-status').textContent = message; },
|
||
});
|
||
}
|
||
clearSharedLaunchUrl();
|
||
}
|
||
const captureDraft = issueCapture.loadDraft();
|
||
issueTemplatePicker.reset(captureDraft);
|
||
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 || '';
|
||
qs('#create-issue-estimate').value = captureDraft.estimateMinutes || '';
|
||
issueOwnerPicker.reset(captureDraft.repository, captureDraft);
|
||
renderIssueCaptureBlockers(captureDraft.blockers || []);
|
||
qs('#create-issue-blocker-search').value = '';
|
||
qs('#create-issue-blocker-results').hidden = true;
|
||
loadIssueFilingMetadata(qs('#create-issue-repository').value, captureDraft);
|
||
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;
|
||
issueCaptureModal().open(createIssueLauncher);
|
||
if (!sharedImageHandled && sharedImageMarker) {
|
||
sharedImageHandled = true;
|
||
if (createIssueAttachmentController.state()) {
|
||
qs('#create-issue-attachment-status').textContent = 'Remove the current screenshot before adding the shared screenshot.';
|
||
} else {
|
||
await sharedImageCapture.consume({
|
||
marker:sharedImageMarker,
|
||
store:unfiledAttachmentStore,
|
||
restore:value=>createIssueAttachmentController.restore(value),
|
||
status:message=>{ qs('#create-issue-attachment-status').textContent = message; },
|
||
});
|
||
}
|
||
clearSharedLaunchUrl();
|
||
}
|
||
const mobileCapture = window.matchMedia?.('(max-width: 600px)').matches === true;
|
||
const continuingCapture = Boolean(rUC || captureDraft.title || captureDraft.body || captureDraft.repository);
|
||
if (unfiledShouldFocusTitle({mobile:mobileCapture, continuing:continuingCapture})) {
|
||
qs('#create-issue-title').focus();
|
||
}
|
||
}
|
||
|
||
function setIssueFilingMode(enabled) {
|
||
applyIssueFilingMode(qs, enabled);
|
||
}
|
||
|
||
let suppressCreateDraftOnHistoryClose = false;
|
||
async function startChecklistPromotion(context) {
|
||
if (!issueCapture && !await ensureIssueCapture()) throw new Error('Issue capture is unavailable.');
|
||
checklistPromotion.start(context);
|
||
await openCreateIssueSheet();
|
||
}
|
||
const issueFilingReceipt = createIssueFilingReceipt({
|
||
root:qs('#issue-filing-receipt'), heading:qs('#issue-filing-receipt-heading'),
|
||
key:qs('#issue-filing-receipt-key'), title:qs('#issue-filing-receipt-title'),
|
||
ownership:qs('#issue-filing-receipt-ownership'), openLink:qs('#issue-filing-receipt-open'),
|
||
filedButton:qs('#issue-filing-receipt-filed'),
|
||
shareButton:qs('#issue-filing-receipt-share'), relatedButton:qs('#issue-filing-receipt-related'),
|
||
fileAnotherButton:qs('#issue-filing-receipt-another'),
|
||
doneButton:qs('#issue-filing-receipt-done'), status:qs('#issue-filing-receipt-status'),
|
||
navigator,
|
||
onFileRelated:plan => {
|
||
issueCapture.saveDraft(plan);
|
||
createIssueAttachmentController.clear();
|
||
qs('#new-issue').click();
|
||
},
|
||
onFileAnother:() => qs('#new-issue').click(),
|
||
onViewFiled:issue => {
|
||
selectMobileQueue('filed');
|
||
openRoutedWork({
|
||
...issue, kind:'issue', is_filed:true, is_assigned:(issue.assignees || []).includes(confirmedOwnerLogin),
|
||
});
|
||
},
|
||
});
|
||
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
|
||
if (navigate && taskOverlayHistory.current() === 'new') {
|
||
suppressCreateDraftOnHistoryClose = !preserveDraft;
|
||
taskOverlayHistory.leave();
|
||
return;
|
||
}
|
||
qs('#create-issue-sheet').classList.remove('open');
|
||
voiceIssueCapture.cancel();
|
||
mobileComposerViewport.close(qs('.create-issue-panel'));
|
||
clearTimeout(duplicateCheckTimer);
|
||
qs('#create-issue-duplicates').hidden = true;
|
||
creatingIssue = false;
|
||
issueCaptureModal().close({restore:!followUpSourceUpdate});
|
||
createIssueLauncher = null;
|
||
if (createAndStartRequested) timerView.transferCapture();
|
||
else timerView.finishCapture();
|
||
if (followUpSourceUpdate) {
|
||
const source = followUpSourceUpdate;
|
||
followUpSourceUpdate = null;
|
||
notificationReader.open(source.item, source.detail);
|
||
return;
|
||
}
|
||
}
|
||
|
||
function applyOutboxResult(result, openCreated = false, startCreated = false) {
|
||
if (result.lease_skipped) { refreshMyWorkView(); return; }
|
||
(result.confirmed || []).forEach(confirmed => {
|
||
confirmed.work_reasons = ['created_by_me'];
|
||
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, completion.estimateMinutes);
|
||
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];
|
||
(result.filings || []).filter(filing => filing.relatedDraft?.checklistPromotion).forEach(filing => {
|
||
void checklistPromotion?.finish(filing.issue, filing.relatedDraft.checklistPromotion);
|
||
});
|
||
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 (!(confirmed.assignees || []).includes(activeFlushLogin)) {
|
||
const filing = (result.filings || []).find(candidate => candidate.issue?.repository === confirmed.repository &&
|
||
candidate.issue?.number === confirmed.number);
|
||
issueFilingReceipt.show(confirmed, qs('#new-issue'), filing?.relatedDraft);
|
||
}
|
||
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;
|
||
reviewDetailPosition.open(workDetailIdentity('review', item));
|
||
mobileReviewDetailNavigation.reset();
|
||
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 (updateReviewHandoff.active()) {
|
||
updateReviewHandoff.cancel();
|
||
return;
|
||
}
|
||
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'));
|
||
updateVoiceReply.cancel();
|
||
void conversationPhotoDrafts.leave('update');
|
||
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) {
|
||
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
|
||
!contextFreshness?.degraded && !contextFreshness?.revalidating;
|
||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||
if (activeFlushLogin) {
|
||
confirmedOwnerLogin = activeFlushLogin;
|
||
planningOwnerLogin = activeFlushLogin;
|
||
planningOwnerAccountKey = snapshot.context.user?.id ?
|
||
String(snapshot.context.user.id) + ':' + activeFlushLogin : '';
|
||
mobileQueuePriority.render();
|
||
renderMobileQueuePresentation();
|
||
void mobileRecentWork.load();
|
||
void mobileQueuePriority.load();
|
||
void refreshPhotoDraftInbox();
|
||
timerView.restore(todaySync.flush());
|
||
restoreReleaseReceipt();
|
||
updateDeliveryReceiptControls();
|
||
interruptionPrompt.restore();
|
||
}
|
||
}
|
||
if (snapshot.context && workChanged) {
|
||
setOfflineWorkMode(false);
|
||
const retainedPlanningLogin = !snapshot.context.error ?
|
||
String(snapshot.context.user?.login || '').trim() : '';
|
||
planningOwnerLogin = retainedPlanningLogin;
|
||
planningOwnerAccountKey = retainedPlanningLogin && snapshot.context.user?.id ?
|
||
String(snapshot.context.user.id) + ':' + retainedPlanningLogin : '';
|
||
timerView.render();
|
||
updatePlanningAvailability();
|
||
if (planningOwnerLogin) {
|
||
syncPendingTomorrow();
|
||
todaySync.migrate(todayWork.read());
|
||
initialAccountRecovery = Promise.all([
|
||
initialAccountRecovery,
|
||
todaySync.flush(),
|
||
]).then(() => true);
|
||
laterSync.migrate(laterWork.read());
|
||
laterSync.flush();
|
||
}
|
||
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');
|
||
}
|
||
renderLiveDataStatus(snapshot.freshness || {});
|
||
}
|
||
|
||
|
||
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'); }
|
||
|
||
(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();
|
||
})();
|
||
|
||
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(); });
|
||
}
|
||
|
||
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>';
|
||
}
|
||
|
||
const commands = [
|
||
{ name: 'Whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } },
|
||
{ name: 'Markdown', run: () => { qs('#md-input').focus(); } },
|
||
{ name: 'Refresh', run: load },
|
||
];
|
||
let commandSearchState = { status:'idle', query:'', items:[] };
|
||
let commandItems = [];
|
||
let commandSelection = -1;
|
||
let searchBatchPlanning = null;
|
||
async function searchGlobalWork(query, signal, page = 1, scope = {kind:'all', state:'all'}, continuation) {
|
||
const response = await fetch(filterCommands.searchUrl(query, page, scope, continuation), {
|
||
headers: { Accept:'application/json' },
|
||
signal,
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.');
|
||
return payload;
|
||
}
|
||
const commandSearch = filterCommands.createGlobalSearchController({
|
||
search: searchGlobalWork,
|
||
onState: state => {
|
||
commandSearchState = state;
|
||
renderCommands(state.query);
|
||
},
|
||
});
|
||
function currentSearchScope() {
|
||
return { kind:qs('#cmd-search-kind').value, state:qs('#cmd-search-state').value,
|
||
repository:qs('#cmd-search-repository').value.trim() };
|
||
}
|
||
function applySearchScope(scope = {kind:'all', state:'all'}) {
|
||
qs('#cmd-search-kind').value = scope.kind;
|
||
qs('#cmd-search-state').value = scope.state;
|
||
qs('#cmd-search-repository').value = scope.repository || '';
|
||
commandSearch.setScope(scope);
|
||
}
|
||
let searchPreviewDetail = null;
|
||
const showSearchConversationWithActions = createSearchPreviewConversationActions({
|
||
hydrator:actionHydrator, rootNode:qs('#search-preview-comments'),
|
||
retry:qs('#retry-search-preview-comment-actions'),
|
||
paint:(conversation,controller)=>renderSearchPreviewConversation(
|
||
conversation,document,escapeHtml,fmt,renderMarkdown,controller),
|
||
wire:controller=>wireCommentActions('#search-preview-comments',controller),
|
||
});
|
||
const renderSearchConversation = conversation => { void showSearchConversationWithActions(conversation); };
|
||
function renderSearchPreview(state) {
|
||
followingQueue.preview(state);
|
||
const sheet = qs('#search-preview');
|
||
const status = qs('#search-preview-status');
|
||
const claimButton = qs('#claim-search-result');
|
||
const deferButton = qs('#defer-search-result');
|
||
const queueButton = qs('#queue-search-result');
|
||
const planButton = qs('#plan-search-result');
|
||
const startButton = qs('#start-search-result');
|
||
const watchButton = qs('#watch-search-result');
|
||
const shareButton = qs('#share-search-result');
|
||
|
||
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
|
||
? 'Back to blockers' : searchPreviewReturnKind === 'following' ? 'Back to Following' : 'Back to search';
|
||
if (state.status === 'closed') {
|
||
searchVoiceReply.cancel();
|
||
sheet.classList.remove('open');
|
||
return;
|
||
}
|
||
sheet.classList.add('open');
|
||
claimButton.hidden = true;
|
||
claimButton.disabled = false;
|
||
deferButton.hidden = true;
|
||
deferButton.disabled = false;
|
||
queueButton.hidden = true;
|
||
planButton.hidden = true;
|
||
planButton.disabled = false;
|
||
startButton.hidden = true;
|
||
startButton.disabled = false;
|
||
watchButton.hidden = true;
|
||
watchButton.disabled = false;
|
||
shareButton.disabled = true;
|
||
renderSearchPreviewWorkspaces(state,null,searchPreview,document,escapeHtml);
|
||
|
||
if (state.status === 'loading') {
|
||
searchReplyAttachmentTarget = { ...state.item };
|
||
restoreSearchReplyPhotos(searchReplyAttachmentTarget);
|
||
searchVoiceReply.open(searchConversationVoiceTarget(state.item));
|
||
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 = '';
|
||
renderSearchConversation(null);
|
||
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;
|
||
|
||
shareButton.disabled = state.status === 'sharing';
|
||
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.');
|
||
renderSearchConversation(state.conversation);
|
||
renderSearchPreviewWorkspaces(state,detail,searchPreview,document,escapeHtml);
|
||
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
|
||
claimButton.hidden = !(detail.claimable || detail.assigned_to_me);
|
||
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
|
||
claimButton.disabled = state.status === 'claiming';
|
||
deferButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
|
||
(detail.claimable || detail.assigned_to_me));
|
||
deferButton.textContent = detail.assigned_to_me ? 'Defer' : 'Assign & defer';
|
||
deferButton.disabled = state.status === 'claiming' || searchDefer?.pending();
|
||
queueButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
|
||
(detail.claimable || detail.assigned_to_me));
|
||
queueButton.textContent = detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today';
|
||
planButton.hidden = !searchWeekPlan.eligible(detail);
|
||
planButton.textContent = detail.assigned_to_me ? 'Plan ahead' : 'Assign & plan ahead';
|
||
planButton.disabled = searchWeekPlan.pending();
|
||
renderSearchPreviewStart(detail, state, startButton);
|
||
renderSearchPreviewWatch(detail, state, watchButton);
|
||
followingQueue.preview(state);
|
||
const shareStatus = {
|
||
sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.',
|
||
'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.',
|
||
};
|
||
if (shareStatus[state.status]) status.textContent = shareStatus[state.status];
|
||
else if (state.status === 'reopening') status.textContent = 'Reopening…';
|
||
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
|
||
else if (state.status === 'claimed') status.textContent = 'Assigned. Opening My Work…';
|
||
else if (state.status.includes('watch')) status.textContent = searchPreviewWatchStatus(state);
|
||
else if (detail.claimable) status.textContent = 'Open and unassigned.';
|
||
else if (detail.assigned_to_me) status.textContent = 'Already in My Work.';
|
||
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
|
||
else status.textContent = 'Ready.';
|
||
}
|
||
const searchSubscription = searchPreviewSubscriptionOptions(fetchReviewJson);
|
||
const searchPreview = createSearchPreview({
|
||
fetchJson:searchSubscription.preview,
|
||
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page)),
|
||
fetchReview:searchSubscription.review,
|
||
mutate:searchPreviewMutation(fetchReviewJson),
|
||
watch:(detail,watching) => searchSubscription.watch(detail, watching).then(result =>
|
||
followingQueue.load().catch(() => {}).then(() => result)),
|
||
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
|
||
queueReply:async (item,body,operationId) => {
|
||
searchReplyAttachmentTarget = item;
|
||
const serialized = await searchReplyAttachmentController.serialize();
|
||
const attachments = (Array.isArray(serialized) ? serialized : [serialized]).filter(Boolean);
|
||
const admitted = await authoredOutbox.enqueueDurably({
|
||
kind:'search-reply',targetKind:item.kind,repository:item.repository,number:item.number,
|
||
body,operationId,...(attachments.length ? {attachments} : {}),
|
||
});
|
||
renderOutbox();
|
||
return { id:admitted.item.id, queued:true };
|
||
},
|
||
prepareReply:(item,body) => {
|
||
searchReplyAttachmentTarget = item;
|
||
return searchReplyAttachmentController.prepareComment(item, body);
|
||
},
|
||
afterReply:item => {
|
||
if (!searchReplyDraftStore) return null;
|
||
return searchReplyDraftStore.remove(item);
|
||
},
|
||
hasAttachments:() => Boolean(searchReplyAttachmentController.state()),
|
||
clearAttachments:() => {
|
||
searchReplyAttachmentTarget = null;
|
||
searchReplyAttachmentController.clear();
|
||
},
|
||
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
|
||
getSession:() => followingQueue.session() || commandSearchState,
|
||
loadMore:() => commandSearch.loadMore(),
|
||
onNavigate:item => {
|
||
if (!followingQueue.session()) taskOverlayHistory.update({preview:item});
|
||
},
|
||
onOpened:item => followingQueue.previewLoaded(item),
|
||
afterUnwatch:item => followingQueue.retire(item),
|
||
navigationRoot:document,
|
||
onState: renderSearchPreview,
|
||
});
|
||
const searchWeekPlan = createSearchWeekPlan({
|
||
week:weekPlan,claim:detail=>searchPreview.claim(detail),accept:acceptClaimedIssue,
|
||
identity:item=>todayWork.identity(item),
|
||
});
|
||
createSearchWeekPlanUI({planner:searchWeekPlan,document,window,escapeHtml,escapeAttribute:escAttr,
|
||
getDetail:()=>searchPreviewDetail,
|
||
announce:message=>{qs('#search-preview-status').textContent=message;},
|
||
onPlanned:item=>(searchPreviewDetail=item,item.following&&next()),
|
||
});
|
||
searchBatchPlanning = mountSearchBatchPlanning(
|
||
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,
|
||
searchPreviewPath, queueToday, todaySync, acceptClaimedIssue, i=>commandItems[i]?.result,
|
||
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr, laterWork, laterPicker, weekPlan, localStorage
|
||
);
|
||
searchDefer = createSearchDefer({
|
||
claim: detail => searchPreview.claim(detail),
|
||
accept: confirmed => acceptClaimedIssue(confirmed),
|
||
defer: (item, until) => laterWork.defer(item, until),
|
||
refresh: refreshMyWorkView,
|
||
announce: message => {
|
||
qs('#search-preview-status').textContent = message;
|
||
qs('#cmd-search-action-status').textContent = message;
|
||
qs('#my-work-action-status').textContent = message;
|
||
},
|
||
formatTime: fmt,
|
||
});
|
||
const canonicalSearchPreviewUrl = () => searchPreviewUrl(
|
||
taskOverlayHistory.currentState(), currentSearchScope(), window.location
|
||
);
|
||
function createSearchStart(claim) {
|
||
return createAssignAndStart({
|
||
available: createAndStart.available,
|
||
claim,
|
||
start: confirmed => {
|
||
const item = acceptClaimedIssue(confirmed);
|
||
taskOverlayHistory.leave();
|
||
refreshMyWorkView();
|
||
return createAndStart.complete(item);
|
||
},
|
||
queue: confirmed => queueToday(acceptClaimedIssue(confirmed)),
|
||
recover: confirmed => {
|
||
const item = acceptClaimedIssue(confirmed);
|
||
taskOverlayHistory.leave();
|
||
refreshMyWorkView();
|
||
openRoutedWork(item, qs('#open-palette'));
|
||
},
|
||
announce: message => {
|
||
qs('#search-preview-status').textContent = message;
|
||
qs('#cmd-search-action-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 recoverSearchPull = createSearchAuthoredPullRecovery({
|
||
confirm,
|
||
reopen:detail => searchPreview.reopenPull(detail),
|
||
refresh:load,
|
||
find:detail => lastMyWork.find(item => item.key === detail.repository + '#' + detail.number),
|
||
open:item => openRoutedWork(item, qs('#start-search-result')),
|
||
unavailable:message => qs('#search-preview-status').textContent = message,
|
||
});
|
||
const mobileSearchViewport = createMobileSearchViewport({
|
||
palette: qs('#cmd-palette'),
|
||
results: qs('#cmd-results'),
|
||
viewport: window.visualViewport,
|
||
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
||
schedule: callback => requestAnimationFrame(callback),
|
||
});
|
||
const searchModal = createMobileSearchModal({document});
|
||
function closeSearchPreview(navigate = true) {
|
||
if (searchPreviewReturnKind === 'following') {
|
||
searchPreview.close();
|
||
searchPreviewReturnKind = null;
|
||
followingQueue.returnToFollowing();
|
||
return;
|
||
}
|
||
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);
|
||
searchModal.transition(qs('#cmd-palette'), { initialFocus:qs('#cmd-input') });
|
||
}
|
||
const next = searchPreview.next;
|
||
async function openPreviewWorkInMyWork(detail) {
|
||
await load();
|
||
const item = lastMyWork.find(candidate =>
|
||
candidate.kind === detail.kind && 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(() => {});
|
||
searchModal.transition(qs('#search-preview'), { initialFocus:qs('#close-search-preview') });
|
||
taskOverlayHistory.open('search-preview', {
|
||
query:qs('#cmd-input').value, scope:currentSearchScope(), preview:item.result,
|
||
});
|
||
}
|
||
qs('#cmd-palette').classList.remove('open');
|
||
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
||
}
|
||
function renderCommands(filter) {
|
||
const el = qs('#cmd-results');
|
||
const loadMore = qs('#cmd-load-more');
|
||
const selecting = searchBatchPlanning?.plan.snapshot().active === true;
|
||
const local = selecting ? [] : 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;
|
||
if (selecting) return searchBatchPlanning.resultHtml(result, idx);
|
||
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;
|
||
loadMore.hidden = !commandSearchState.more;
|
||
loadMore.textContent = commandSearchState.partial ? 'Retry missing results' : 'More results';
|
||
el.querySelectorAll('.cmd-item').forEach((item) => {
|
||
item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)]));
|
||
});
|
||
|
||
}
|
||
function openCommandPalette(navigate = true) {
|
||
if (navigate) {
|
||
taskOverlayHistory.open('search', { scope:currentSearchScope() });
|
||
return;
|
||
}
|
||
qs('#cmd-palette').classList.add('open');
|
||
mobileSearchViewport.open();
|
||
mobileSearchViewport.restoreScroll();
|
||
qs('#cmd-input').setAttribute('aria-expanded', 'true');
|
||
searchModal.activate(qs('#cmd-palette'), {
|
||
opener:document.activeElement,
|
||
initialFocus:qs('#cmd-input'),
|
||
});
|
||
commandSelection = -1;
|
||
renderCommands(qs('#cmd-input').value);
|
||
}
|
||
const taskOverlayHistory = createTaskOverlayHistory({
|
||
history: window.history,
|
||
eventTarget: window,
|
||
onChange(kind, previous, detail) {
|
||
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();
|
||
searchModal.deactivate();
|
||
}
|
||
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();
|
||
searchModal.deactivate();
|
||
}
|
||
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') {
|
||
if (detail?.scope) applySearchScope(detail.scope);
|
||
if (detail?.query !== undefined) {
|
||
qs('#cmd-input').value = detail.query;
|
||
commandSearch.setQuery(detail.query);
|
||
}
|
||
openCommandPalette(false);
|
||
}
|
||
if (kind === 'search-preview' && detail?.preview && previous !== 'search') {
|
||
if (detail?.scope) applySearchScope(detail.scope);
|
||
if (detail?.query !== undefined) qs('#cmd-input').value = detail.query;
|
||
searchPreviewReturnKind = 'search';
|
||
searchModal.activate(qs('#search-preview'), {
|
||
opener:document.activeElement,
|
||
initialFocus:qs('#close-search-preview'),
|
||
});
|
||
searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close());
|
||
}
|
||
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();
|
||
createSavedSearches.mount(
|
||
document, fetch, commandSearch, applySearchScope, taskOverlayHistory
|
||
).load();
|
||
qs('#open-palette').addEventListener('click', openCommandPalette);
|
||
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
|
||
qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore());
|
||
|
||
function changeSearchScope() {
|
||
const scope = currentSearchScope();
|
||
commandSelection = -1;
|
||
taskOverlayHistory.update({ scope });
|
||
commandSearch.setScope(scope);
|
||
}
|
||
qs('#cmd-search-kind').addEventListener('change', changeSearchScope);
|
||
qs('#cmd-search-state').addEventListener('change', changeSearchScope);
|
||
let repositoryLookupController = null;
|
||
let repositoryLookupTimer = null;
|
||
async function suggestSearchRepositories() {
|
||
const input = qs('#cmd-search-repository');
|
||
const query = input.value.trim();
|
||
const status = qs('#cmd-repository-status');
|
||
if (repositoryLookupTimer !== null) clearTimeout(repositoryLookupTimer);
|
||
if (repositoryLookupController) repositoryLookupController.abort();
|
||
repositoryLookupController = null;
|
||
if (!query) {
|
||
qs('#cmd-search-repositories').innerHTML = '';
|
||
status.textContent = 'Searching all accessible repositories.';
|
||
changeSearchScope();
|
||
return;
|
||
}
|
||
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(query)) changeSearchScope();
|
||
if (query.length < 2) {
|
||
status.textContent = 'Enter at least 2 characters to find a repository.';
|
||
return;
|
||
}
|
||
status.textContent = 'Finding accessible repositories…';
|
||
repositoryLookupTimer = setTimeout(async () => {
|
||
const controller = new AbortController();
|
||
repositoryLookupController = controller;
|
||
try {
|
||
const response = await fetch('api/v1/repositories/search?q=' + encodeURIComponent(query) + '&limit=20', {
|
||
headers:{ Accept:'application/json' }, signal:controller.signal,
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Repository lookup unavailable.');
|
||
if (repositoryLookupController !== controller || input.value.trim() !== query) return;
|
||
const items = Array.isArray(payload.items) ? payload.items : [];
|
||
qs('#cmd-search-repositories').innerHTML = items.map(item =>
|
||
'<option value="' + escapeHtml(item.full_name) + '"></option>'
|
||
).join('');
|
||
status.textContent = items.length ? 'Choose an accessible repository.' : 'No accessible repositories found.';
|
||
} catch (error) {
|
||
if (error?.name !== 'AbortError') status.textContent = 'Repository lookup unavailable; global Search still works.';
|
||
} finally {
|
||
if (repositoryLookupController === controller) repositoryLookupController = null;
|
||
}
|
||
}, 200);
|
||
}
|
||
qs('#cmd-search-repository').addEventListener('input', suggestSearchRepositories);
|
||
qs('#cmd-search-repository').addEventListener('change', changeSearchScope);
|
||
qs('#cmd-search-repository').addEventListener('search', suggestSearchRepositories);
|
||
qs('#cmd-input').addEventListener('input', (e) => {
|
||
commandSelection = -1;
|
||
taskOverlayHistory.update({ query:e.target.value });
|
||
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();
|
||
if (!qs('#issue-edit-form').hidden) {
|
||
history.back();
|
||
return;
|
||
}
|
||
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.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape' && searchPreviewReturnKind === 'following' &&
|
||
qs('#search-preview').classList.contains('open')) {
|
||
e.preventDefault();
|
||
closeSearchPreview(false);
|
||
}
|
||
});
|
||
|
||
qs('#share-search-result').addEventListener('click', () => {
|
||
searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {});
|
||
});
|
||
wireSearchPreviewWatch(qs('#watch-search-result'), searchPreview, () => searchPreviewDetail);
|
||
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 openPreviewWorkInMyWork(claimed);
|
||
if (!opened) {
|
||
qs('#search-preview-status').textContent = 'Refresh My Work to open this item.';
|
||
}
|
||
} catch (error) {
|
||
qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
|
||
}
|
||
});
|
||
qs('#defer-search-result').addEventListener('click', event => {
|
||
const detail = searchPreviewDetail;
|
||
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
|
||
(!detail.claimable && !detail.assigned_to_me) || searchDefer.pending()) return;
|
||
laterPicker.open(detail, event.currentTarget, 'search');
|
||
});
|
||
|
||
qs('#queue-search-result').addEventListener('click', async () => {
|
||
const detail = searchPreviewDetail;
|
||
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
|
||
(!detail.claimable && !detail.assigned_to_me)) return;
|
||
try {
|
||
if (todayWork.contains(detail)) {
|
||
qs('#cmd-search-action-status').textContent = 'Already in Today.';
|
||
await next();
|
||
return;
|
||
}
|
||
const outcome = await searchAssignAndStart.run(detail, {
|
||
alreadyOwned: detail.assigned_to_me,
|
||
destination: 'queue',
|
||
});
|
||
if (outcome === 'queued') await next();
|
||
} catch (error) {
|
||
qs('#search-preview-status').textContent = error.message + ' Retry.';
|
||
}
|
||
});
|
||
qs('#start-search-result').addEventListener('click', async () => {
|
||
const detail = searchPreviewDetail;
|
||
if (detail?.authored_pull_reopenable) {
|
||
try {
|
||
await recoverSearchPull(detail);
|
||
} catch (error) {
|
||
qs('#search-preview-status').textContent = error.message + ' Retry.';
|
||
}
|
||
return;
|
||
}
|
||
if (detail?.reviewable) {
|
||
taskOverlayHistory.leave();
|
||
detail.kind = 'review';
|
||
return openRoutedWork(detail, null, { replace:true });
|
||
}
|
||
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('#fill-find-work-today').addEventListener('click', () => {
|
||
const remainingSlots = todayWork.limit - todayWork.read().length;
|
||
const outcome = findWorkController.fillSelection(remainingSlots);
|
||
qs('#find-work-status').textContent = remainingSlots < 1 ? 'Today is full.' :
|
||
outcome.selected ? outcome.selected + ' ranked work selected.' : 'No matching work.';
|
||
if (outcome.selected) findWorkNavigation.go('review');
|
||
});
|
||
qs('#find-work-search-form').addEventListener('submit', event => event.preventDefault());
|
||
qs('#find-work-search').addEventListener('input', event => {
|
||
const value = event.currentTarget.value;
|
||
qs('#clear-find-work-search').hidden = !value;
|
||
clearTimeout(findWorkSearchTimer);
|
||
findWorkSearchTimer = setTimeout(async () => {
|
||
qs('#find-work-status').textContent = value.trim() ?
|
||
'Searching available issues…' : 'Loading available issues…';
|
||
try {
|
||
await findWorkController.search(value);
|
||
updateFindWorkMatchStatus();
|
||
qs('#find-work-status').textContent = availablePagination.total ?
|
||
'Available work loaded.' : 'No matching unassigned issues.';
|
||
} catch (error) {
|
||
qs('#find-work-status').textContent = error.message + ' Retry search.';
|
||
}
|
||
}, 250);
|
||
});
|
||
qs('#clear-find-work-search').addEventListener('click', () => {
|
||
const input = qs('#find-work-search');
|
||
input.value = '';
|
||
input.dispatchEvent(new Event('input', { bubbles:true }));
|
||
input.focus();
|
||
});
|
||
|
||
qs('#confirm-find-work-estimates').addEventListener('click', async event => {
|
||
const button = event.currentTarget;
|
||
button.disabled = true;
|
||
const outcome = await batchFindWork.run(findWorkController.selectedItems(), findWorkEstimateValues());
|
||
button.disabled = false;
|
||
if (outcome.status === 'estimates-required') {
|
||
const first = outcome.invalid[0];
|
||
document.querySelector('[data-find-work-estimate="' + CSS.escape(first) + '"]')?.focus();
|
||
return;
|
||
}
|
||
if (outcome.status !== 'complete') return;
|
||
const assignedOnly = outcome.failed.filter(item => item.assigned).length;
|
||
const unavailable = outcome.failed.length - assignedOnly;
|
||
qs('#find-work-status').textContent = outcome.queued.length + ' queued' +
|
||
(unavailable ? ' · ' + unavailable + ' unavailable' : '') +
|
||
(assignedOnly ? ' · ' + assignedOnly + ' assigned but not queued' : '') + '.';
|
||
if (!outcome.failed.length) {
|
||
findWorkNavigation.complete();
|
||
findWorkController.cancelSelection();
|
||
} else {
|
||
renderAvailableIssues(findWorkController.items());
|
||
findWorkNavigation.sync({ selectedCount:findWorkController.selection().count, recovery:true });
|
||
}
|
||
refreshMyWorkView();
|
||
});
|
||
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('#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 evidence = await createIssueAttachmentController.serialize();
|
||
const captureDraft = currentIssueCaptureDraft();
|
||
Object.assign(captureDraft, Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence});
|
||
if (showDraftCapacityDialog(unfiledCaptures, qs)) return;
|
||
const savedCapture = await unfiledCaptures.save(captureDraft);
|
||
if (rUC) {
|
||
await unfiledCaptures.completeResume(rUC);
|
||
rUC = '';
|
||
}
|
||
const capture = timerView.finishCapture();
|
||
issueCapture.clearDraft();
|
||
qs('#create-issue-title').value = '';
|
||
qs('#create-issue-body').value = '';
|
||
createIssueAttachmentController.clear();
|
||
closeCreateIssueSheet(true, false);
|
||
if (!capture) {
|
||
qs('[data-work-filter="draft"]').click();
|
||
mobileTaskDock.select('queues');
|
||
}
|
||
refreshMyWorkView();
|
||
const savedCard = qs('[data-capture-id="' + CSS.escape(savedCapture.id) + '"]');
|
||
if (!capture) requestAnimationFrame(() => {
|
||
savedCard?.scrollIntoView({block:'nearest'});
|
||
savedCard?.focus({preventScroll:true});
|
||
});
|
||
qs('#my-work-action-status').textContent = unfiledSavedMessage(captureDraft);
|
||
} catch (error) {
|
||
qs('#create-issue-capture-status').textContent = error.message;
|
||
qs('#create-issue-title').focus();
|
||
}
|
||
});
|
||
bindDraftCapacityDialog({
|
||
qs, saveIssueCaptureDraft, closeCreateIssueSheet, mobileTaskDock, refreshMyWorkView,
|
||
unfiledCaptures, currentIssueCaptureDraft, createIssueAttachmentController, issueCapture,
|
||
});
|
||
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', () => {
|
||
if (checklistPromotion?.cancel()) {
|
||
closeCreateIssueSheet(true, false);
|
||
return;
|
||
}
|
||
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();
|
||
})
|
||
);
|
||
if (progressiveCaptureHandoff?.open && await ensureIssueCapture()) {
|
||
issueCapture.saveDraft({repository:'', labelIds:[],
|
||
title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body});
|
||
progressiveCaptureHandoff.complete?.();
|
||
await openCreateIssueSheet(false);
|
||
}
|
||
qs('#create-issue-repository').addEventListener('change', event => {
|
||
issueTemplatePicker.changeRepository(currentIssueCaptureDraft());
|
||
issueOwnerPicker.reset(event.target.value);
|
||
loadIssueFilingMetadata(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('#create-issue-blocker-search').addEventListener('input', event => {
|
||
clearTimeout(captureBlockerSearchTimer);
|
||
const query = event.target.value.trim();
|
||
const results = qs('#create-issue-blocker-results');
|
||
const status = qs('#create-issue-blocker-status');
|
||
if (query.length < 2) {
|
||
issueCapture.searchBlockers(query);
|
||
results.hidden = true;
|
||
status.textContent = issueCaptureBlockers.length ? issueCaptureBlockers.length + ' blocker(s) selected.' :
|
||
(query ? 'Enter at least 2 characters to search.' : 'No blockers selected.');
|
||
return;
|
||
}
|
||
if (issueCaptureBlockers.length >= 5) {
|
||
results.hidden = true;
|
||
status.textContent = 'Five blockers selected. Remove one to choose another.';
|
||
return;
|
||
}
|
||
status.textContent = 'Searching open issues…';
|
||
captureBlockerSearchTimer = setTimeout(async () => {
|
||
const state = await issueCapture.searchBlockers(query);
|
||
if (state.status === 'stale') return;
|
||
if (state.status === 'failed') {
|
||
results.hidden = true;
|
||
status.textContent = 'Blocker search failed. Your draft is safe; retry.';
|
||
return;
|
||
}
|
||
renderIssueCaptureBlockerResults(state.items);
|
||
status.textContent = state.items.length ? 'Choose an issue that must finish first.' : 'No open issues 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();
|
||
});
|
||
async function admitReviewedIssue(review) {
|
||
const durableDraft = review.draft;
|
||
const promotion = checklistPromotion?.deliveryContext();
|
||
const deliveryDraft = {
|
||
...durableDraft, relatedDraft:issueCapture.buildRelatedDraft(durableDraft),
|
||
};
|
||
if (promotion) deliveryDraft.relatedDraft.checklistPromotion = promotion;
|
||
const followUpNextRequested = review.intent === 'follow-up-and-next';
|
||
createAndStartRequested = review.intent === 'create-and-start';
|
||
if (createAndStartRequested && !createAndStart.available()) {
|
||
throw new Error('Today is full now. Go back and remove an item before creating and starting another.');
|
||
}
|
||
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 admission = followUpNextRequested ? (await updateFollowUp.complete({
|
||
admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, deliveryDraft) :
|
||
issueOutbox.enqueueDurably(deliveryDraft),
|
||
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
|
||
advance: source => notificationReader.acceptReadAndNext(lastMyWork, source),
|
||
})).admission : (editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, deliveryDraft) :
|
||
await issueOutbox.enqueueDurably(deliveryDraft));
|
||
const queued = admission.item;
|
||
pendingIssueFilingIntent = 'create-and-assign';
|
||
const fS = dFS.current();
|
||
if (rUC && ((!durableDraft.attachment && !durableDraft.attachments) || 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.';
|
||
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.';
|
||
throw error;
|
||
} finally {
|
||
button.disabled = false;
|
||
startButton.disabled = !createAndStart.available();
|
||
followUpButton.disabled = false;
|
||
}
|
||
}
|
||
|
||
qs('#create-issue-form').addEventListener('submit', async event => {
|
||
event.preventDefault();
|
||
if (event.submitter) pendingIssueFilingIntent =
|
||
event.submitter.id === 'create-and-start-issue' ? 'create-and-start' :
|
||
(event.submitter.id === 'create-follow-up-next' ? 'follow-up-and-next' : 'create-and-assign');
|
||
const intent = pendingIssueFilingIntent;
|
||
createAndStartRequested = intent === 'create-and-start';
|
||
const startCapacity = createAndStartRequested
|
||
? createAndStart.capacity(qs('#create-issue-estimate').value) : null;
|
||
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;
|
||
}
|
||
if (createAndStartRequested && !startCapacity.valid) {
|
||
qs('#create-issue-status').textContent = startCapacity.reason === 'required'
|
||
? 'Add a work estimate before creating and starting.'
|
||
: 'Use a whole-number estimate from 5 to 1440 minutes.';
|
||
qs('#create-issue-estimate').focus();
|
||
renderCreateStartCapacity();
|
||
return;
|
||
}
|
||
if (createAndStartRequested && !startCapacity.fits) {
|
||
qs('#create-issue-status').textContent = 'This would put Today ' +
|
||
Math.abs(startCapacity.projectedMinutes) + ' minutes over capacity. Adjust the estimate or Today plan.';
|
||
qs('#create-issue-estimate').focus();
|
||
renderCreateStartCapacity();
|
||
return;
|
||
}
|
||
qs('#create-issue-status').textContent = 'Preparing complete filing review…';
|
||
try {
|
||
const evidence = await createIssueAttachmentController.serialize();
|
||
const milestoneOption = qs('#create-issue-milestone').selectedOptions?.[0];
|
||
const durableDraft = {
|
||
...captureDraft,
|
||
labels: selectedIssueLabelNames(),
|
||
milestoneTitle: captureDraft.milestoneId ? milestoneOption?.textContent?.trim() : '',
|
||
...(Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}),
|
||
...(rUC ? { sourceCaptureId: rUC } : {}),
|
||
...(createAndStartRequested ? {
|
||
completionIntent: 'create-and-start', estimateMinutes: startCapacity.minutes,
|
||
todayCapacity: startCapacity,
|
||
} : {}),
|
||
};
|
||
filingReview.open({draft: durableDraft, intent},
|
||
dFS.takeTrigger() || event.submitter || qs('#submit-new-issue'));
|
||
qs('#create-issue-status').textContent = 'Review the complete payload, then confirm filing.';
|
||
} catch (error) {
|
||
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
||
qs('#create-issue-title').focus();
|
||
}
|
||
});
|
||
qs('#close-issue-sheet').addEventListener('click', closeIssueSheet);
|
||
function runFiledClaim(destination) {
|
||
const item = selectedIssue;
|
||
if (!item || !selectedIssueDetail || !filedClaimEligible(item, selectedIssueDetail)) return;
|
||
qs('#queue-filed-issue').disabled = true;
|
||
qs('#start-filed-issue').disabled = true;
|
||
const request = destination === 'queue' ?
|
||
filedAssignAndStart.run(item, { destination:'queue' }) : filedAssignAndStart.run(item);
|
||
request.catch(error => {
|
||
if (selectedIssue !== item) return;
|
||
qs('#issue-sheet-status').textContent = 'Claim failed; this issue is still in Filed. ' + error.message + ' Retry.';
|
||
}).finally(() => {
|
||
if (selectedIssue === item && filedClaimEligible(item, selectedIssueDetail)) {
|
||
qs('#queue-filed-issue').disabled = false;
|
||
qs('#start-filed-issue').disabled = false;
|
||
}
|
||
});
|
||
}
|
||
qs('#queue-filed-issue').addEventListener('click', () => runFiledClaim('queue'));
|
||
qs('#start-filed-issue').addEventListener('click', () => runFiledClaim('start'));
|
||
qs('#retry-issue-load').addEventListener('click', () => {
|
||
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
|
||
});
|
||
issueController.bindTaskToggles({
|
||
container:qs('#issue-sheet-body'), status:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'),
|
||
current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline}),
|
||
confirmed:applyIssueContent,
|
||
restore:renderIssueBody,
|
||
});
|
||
const checklistStepManagement = issueController.bindTaskManagement({
|
||
container:qs('#issue-sheet-body'), editor:qs('#checklist-step-editor'), label:qs('#checklist-step-label'),
|
||
earlier:qs('#move-checklist-step-earlier'), later:qs('#move-checklist-step-later'),
|
||
fileRelated:qs('#file-checklist-step-related'), onFileRelated:startChecklistPromotion,
|
||
remove:qs('#remove-checklist-step'), cancel:qs('#cancel-checklist-step-edit'),
|
||
status:qs('#checklist-step-edit-status'), sheetStatus:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'),
|
||
current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline}),
|
||
confirmed:applyIssueContent,
|
||
});
|
||
function closeAddChecklistStep() {
|
||
qs('#add-checklist-step-form').hidden = true;
|
||
qs('#add-checklist-step').value = '';
|
||
qs('#open-add-checklist-step').focus();
|
||
}
|
||
qs('#open-add-checklist-step').addEventListener('click', () => {
|
||
qs('#add-checklist-step-form').hidden = false;
|
||
qs('#add-checklist-step-status').textContent = 'Add one required step.';
|
||
qs('#add-checklist-step').focus();
|
||
});
|
||
qs('#cancel-checklist-step').addEventListener('click', closeAddChecklistStep);
|
||
qs('#add-checklist-step-form').addEventListener('submit', async event => {
|
||
event.preventDefault();
|
||
const state = {item:selectedIssue, detail:selectedIssueDetail, offline:selectedIssueOffline};
|
||
if (!state.item || !state.detail?.updated_at) return;
|
||
const submit = qs('#save-checklist-step');
|
||
submit.disabled = true;
|
||
qs('#add-checklist-step-status').textContent = state.offline ? 'Queueing checklist step…' : 'Adding checklist step…';
|
||
try {
|
||
const result = await (selectedIssueOffline ? issueController.queueAddedTask : issueController.addTask).call(
|
||
issueController, state.item, state.detail, qs('#add-checklist-step').value
|
||
);
|
||
if (selectedIssue !== state.item) return;
|
||
applyIssueContent(state.item, state.detail, state.offline ? result.detail : result);
|
||
closeAddChecklistStep();
|
||
qs('#add-checklist-step-status').textContent = state.offline ?
|
||
'Checklist step queued. Pending sync.' : 'Checklist step added.';
|
||
} catch (error) {
|
||
if (selectedIssue === state.item) {
|
||
qs('#add-checklist-step-status').textContent = error.message;
|
||
qs('#add-checklist-step').focus();
|
||
}
|
||
} finally {
|
||
submit.disabled = false;
|
||
}
|
||
});
|
||
qs('#complete-checklist-issue').addEventListener('click', () => {
|
||
qs('#close-issue').click();
|
||
});
|
||
qs('#keep-checklist-issue-open').addEventListener('click', () => {
|
||
dismissedChecklistBody = selectedIssueDetail?.body;
|
||
if (selectedIssueDetail) renderChecklistCompletion(selectedIssueDetail);
|
||
qs('#issue-sheet-body').focus();
|
||
});
|
||
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;
|
||
if (!issueEditHistoryActive) {
|
||
history.pushState({ ...history.state, stackchainIssueEdit:true }, '', window.location.href);
|
||
issueEditHistoryActive = true;
|
||
}
|
||
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', () => {
|
||
if (issueEditHistoryActive) history.back();
|
||
else {
|
||
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);
|
||
applyIssueContent(editing, selectedIssueDetail, confirmed);
|
||
if (issueEditHistoryActive) history.back();
|
||
else 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 {
|
||
if (issueController.readOnly(selectedIssue)) {
|
||
paintIssueConversation(await issueConversation.loadOlder(), null);
|
||
} else {
|
||
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;
|
||
await conversationPhotoDrafts.complete(kind);
|
||
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();
|
||
await conversationPhotoDrafts.complete('issue');
|
||
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();
|
||
await conversationPhotoDrafts.complete('issue');
|
||
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) {
|
||
const conversation = issueConversation.append(comment);
|
||
if (issueController.readOnly(selectedIssue)) paintIssueConversation(conversation, null);
|
||
else renderIssueConversation(conversation);
|
||
}
|
||
qs('#issue-comment').value = '';
|
||
await conversationPhotoDrafts.complete('issue');
|
||
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();
|
||
await conversationPhotoDrafts.complete('issue');
|
||
if (admission.background) {
|
||
qs('#issue-comment').value = '';
|
||
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);
|
||
const currentAssignees = new Set(selectedIssue.is_filed ? (selectedIssue.assignees || []) : []);
|
||
const availableCandidates = candidates.filter(candidate => !currentAssignees.has(candidate.login));
|
||
select.textContent = '';
|
||
const placeholder = document.createElement('option');
|
||
placeholder.value = '';
|
||
placeholder.textContent = availableCandidates.length ? 'Select a teammate' : 'No eligible teammates';
|
||
select.appendChild(placeholder);
|
||
availableCandidates.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 = !availableCandidates.length;
|
||
qs('#confirm-issue-handoff').disabled = true;
|
||
qs('#issue-handoff-status').textContent = availableCandidates.length ?
|
||
'Choose who should own this issue next.' : 'No other eligible assignees were found.';
|
||
if (availableCandidates.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) return;
|
||
const changingDelegate = selectedIssue.is_filed && !selectedIssue.is_completed &&
|
||
Array.isArray(selectedIssue.assignees) && selectedIssue.assignees.length > 0;
|
||
const previousDelegates = changingDelegate ? selectedIssue.assignees.join(', @') : '';
|
||
const confirmation = changingDelegate ?
|
||
'Change delegate from @' + previousDelegates + ' to @' + recipient + ' for ' + selectedIssue.key + '?' :
|
||
'Hand off ' + selectedIssue.key + ' to @' + recipient + '?';
|
||
if (!window.confirm(confirmation)) 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 {
|
||
if (changingDelegate) {
|
||
const confirmed = await issueController.reassign(selectedIssue, recipient);
|
||
Object.assign(handingOff, { assignees:confirmed.assignees, updated_at:confirmed.updated_at || handingOff.updated_at });
|
||
selectedIssue = handingOff;
|
||
if (selectedIssueDetail) selectedIssueDetail.assignees = confirmed.assignees;
|
||
qs('#issue-assignees').textContent = 'Assigned to ' + recipient;
|
||
qs('#issue-handoff').open = false;
|
||
qs('#issue-handoff-status').textContent = 'Delegate changed to @' + recipient + '.';
|
||
refreshMyWorkView({ reconcileSession:false });
|
||
return;
|
||
}
|
||
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) return;
|
||
const withdrawing = selectedIssue.is_filed && !selectedIssue.is_assigned;
|
||
const confirmation = withdrawing ?
|
||
'Withdraw ' + selectedIssue.key + '? This closes the delegated request.' :
|
||
'Close ' + selectedIssue.key + '?';
|
||
if (!window.confirm(confirmation)) 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 close queued; Today still needs completion.';
|
||
} else if (outcome.admission.background) {
|
||
qs('#my-work-action-status').textContent = 'Issue close queued. Next Today item opened.';
|
||
} else {
|
||
qs('#my-work-action-status').textContent = 'Issue close saved. 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 {
|
||
const result = await issueController.close(selectedIssue);
|
||
closeIssueSheet();
|
||
if (withdrawing) Object.assign(closing, {
|
||
state:'closed',is_completed:true,updated_at:result.updated_at || closing.updated_at,
|
||
});
|
||
else 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 + (withdrawing ? ' withdrawn.' : ' 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 = '';
|
||
await conversationPhotoDrafts.complete('pull');
|
||
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 = '';
|
||
await conversationPhotoDrafts.complete('pull');
|
||
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 {
|
||
const mergeResult = await pullController.merge(selectedPull, selectedPullDetail.head_sha);
|
||
rR.capture(merging, mergeResult);
|
||
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();
|
||
}
|
||
});
|
||
const updateDecisionActions = createUpdateDecisionActions({
|
||
transaction:updateDecision, triage:updateTriage, close:()=>closeUpdateSheet(true),
|
||
reader:notificationReader, items:()=>lastMyWork, undo:notificationUndo,
|
||
keepControl:qs('#keep-update-unread'), readControl:qs('#mark-update-read-next'),
|
||
});
|
||
const keepUpdateUnread = () => updateDecisionActions.keepUnread();
|
||
const markUpdateRead = () => updateDecisionActions.markRead();
|
||
qs('#keep-update-unread').addEventListener('click', keepUpdateUnread);
|
||
createUpdateTriageGesture({
|
||
surface: qs('#update-sheet .update-sheet-panel'), enabled: () => selectedUpdateDetail,
|
||
keepUnread: keepUpdateUnread, markRead: markUpdateRead,
|
||
});
|
||
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('#retry-update-conversation').addEventListener('click', () => {
|
||
notificationReader.retryConversation();
|
||
});
|
||
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 = '';
|
||
await conversationPhotoDrafts.complete('update');
|
||
refreshMyWorkView();
|
||
qs('#my-work-action-status').textContent = 'Reply queued for sync.';
|
||
} else if (result) {
|
||
notificationReader.appendReply(result);
|
||
qs('#update-reply').value = '';
|
||
await conversationPhotoDrafts.complete('update');
|
||
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) await conversationPhotoDrafts.complete('update');
|
||
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);
|
||
if (updateTriage.active()) updateTriage.acceptCompleted();
|
||
}
|
||
} 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', markUpdateRead);
|
||
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);
|
||
if (updateTriage.active()) updateTriage.acceptCompleted();
|
||
}
|
||
} finally {
|
||
button.disabled = offlineWorkMode;
|
||
}
|
||
});
|
||
qs('#mute-update-next').addEventListener('click', async () => {
|
||
if (!selectedUpdate || offlineWorkMode) return;
|
||
const button = qs('#mute-update-next');
|
||
const item = selectedUpdate;
|
||
button.disabled = true;
|
||
qs('#update-sheet-status').textContent = 'Muting future updates…';
|
||
try {
|
||
await muteNotification(item.notification_id);
|
||
const result = await notificationReader.acceptReadAndNext(lastMyWork, item);
|
||
if (result && updateTriage.active()) updateTriage.acceptCompleted();
|
||
} catch (error) {
|
||
qs('#update-sheet-status').textContent = error.muted ?
|
||
'Future updates are muted; current item is still unread. Retry mark read & next.' : error.message;
|
||
if (error.muted) {
|
||
button.hidden = true;
|
||
qs('#mark-update-read-next').focus();
|
||
}
|
||
} 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 (updateReviewHandoff.active() && reviewHandoffSubmitted) {
|
||
try {
|
||
await updateReviewHandoff.complete();
|
||
} catch (error) {
|
||
qs('#review-submit-status').textContent = 'Review submitted, but the update is still unread. ' +
|
||
error.message + ' Retry to continue.';
|
||
button.disabled = false;
|
||
button.focus();
|
||
}
|
||
return;
|
||
}
|
||
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 (updateReviewHandoff.active()) {
|
||
reviewHandoffSubmitted = true;
|
||
await updateReviewHandoff.complete();
|
||
return;
|
||
}
|
||
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,
|
||
});
|
||
if (updateReviewHandoff.active()) {
|
||
reviewHandoffSubmitted = true;
|
||
await updateReviewHandoff.complete();
|
||
return;
|
||
}
|
||
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 }); }
|
||
createMobilePullRefresh({refresh:load}).start();
|
||
const liveDataStatusSheet = qs('#live-data-status-sheet');
|
||
const liveDataStatusTrigger = qs('#open-live-data-status');
|
||
let latestLiveFreshness = {};
|
||
function feedAge(feed) {
|
||
return feed.ageSeconds === null ? 'Snapshot age unavailable' :
|
||
(feed.ageSeconds < 60 ? feed.ageSeconds + 's old' : Math.floor(feed.ageSeconds / 60) + 'm old');
|
||
}
|
||
function renderLiveDataStatus(freshness = latestLiveFreshness) {
|
||
latestLiveFreshness = freshness;
|
||
const description = liveDataStatus.describe(freshness);
|
||
setStatus(description.summary);
|
||
qs('#live-data-status-feeds').innerHTML = description.feeds.map(feed =>
|
||
'<div class="live-data-status-feed"><strong>' + escapeHtml(feed.label) + '</strong><span>' +
|
||
escapeHtml(feed.state === 'live' ? 'Live · ' + feedAge(feed) :
|
||
feed.state === 'refreshing' ? 'Refreshing · ' + feedAge(feed) : 'Delayed · ' + feedAge(feed)) + '</span></div>'
|
||
).join('');
|
||
const pollState = contextPoller.getState();
|
||
const retrySeconds = description.nextRetrySeconds || (pollState.nextRetryAt ?
|
||
Math.max(1, Math.ceil((pollState.nextRetryAt - Date.now()) / 1000)) : null);
|
||
qs('#live-data-status-retry').textContent = retrySeconds ?
|
||
'Next automatic retry in ' + retrySeconds + 's.' :
|
||
(pollState.lastSuccessAt ? 'Last successful refresh ' + fmt(new Date(pollState.lastSuccessAt)) + '.' : 'Waiting for the first successful refresh.');
|
||
}
|
||
liveDataStatus.mount({document, window, timerView, onOpen:renderLiveDataStatus}).start();
|
||
const liveDataRefresh = liveDataStatus.createRefreshController({
|
||
button: qs('#refresh-live-data'),
|
||
output: qs('#live-data-status-result'),
|
||
refresh: () => contextPoller.refresh({ force: true }),
|
||
onState: state => { if (state === 'refreshing') setStatus('Refreshing live data'); },
|
||
});
|
||
qs('#refresh-live-data').addEventListener('click', liveDataRefresh.run);
|
||
|
||
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;
|
||
renderMobileQueuePresentation();
|
||
['#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();
|
||
mobileQueuePriority.render();
|
||
renderMobileQueuePresentation();
|
||
restoreReleaseReceipt();
|
||
planningOwnerLogin = confirmedOwnerLogin;
|
||
planningOwnerAccountKey = confirmedOwnerLogin && saved.user?.id ?
|
||
String(saved.user.id) + ':' + confirmedOwnerLogin : '';
|
||
interruptionPrompt.restore();
|
||
updatePlanningAvailability();
|
||
syncPendingTomorrow();
|
||
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();
|
||
}
|
||
const reconnectOutboxes = createReconnectOutboxes({
|
||
refresh: async () => {
|
||
setStatus('Reconnecting…');
|
||
const snapshot = await contextPoller.refresh({ force: true });
|
||
if (!snapshot) {
|
||
offlineStatus.hidden = false;
|
||
setOfflineWorkMode(true);
|
||
return null;
|
||
}
|
||
offlineStatus.hidden = true;
|
||
setOfflineWorkMode(false);
|
||
if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);
|
||
return snapshot;
|
||
},
|
||
restoreIdentity: login => { activeFlushLogin = login; confirmedOwnerLogin = login; },
|
||
flushIssue: flushIssueOutbox,
|
||
flushAuthored: flushAuthoredOutbox,
|
||
flushNotificationReads: flushNotificationReadOutbox,
|
||
});
|
||
const reconnectAfterOnline = () => setTimeout(reconnectOutboxes, 500);
|
||
window.addEventListener('online', reconnectAfterOnline);
|
||
workspaceLifecycle.replayOnline(reconnectAfterOnline);
|
||
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);
|
||
|
||
qs('#refresh').addEventListener('click', load);
|
||
qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget));
|
||
qs('#plan-tomorrow').addEventListener('click', event => openTomorrowPlanner(event.currentTarget));
|
||
qs('#keep-phone-tomorrow').addEventListener('click', async () => {
|
||
const keep = qs('#keep-phone-tomorrow');
|
||
const use = qs('#use-server-tomorrow');
|
||
keep.disabled = true;
|
||
use.disabled = true;
|
||
qs('#tomorrow-conflict-status').textContent = 'Saving this phone’s plan…';
|
||
try {
|
||
const saved = await tomorrowPlan.keepLocal();
|
||
renderTomorrowQueueSummary(saved);
|
||
qs('#my-work-action-status').textContent = 'This phone’s Tomorrow plan is saved to your account. Today was not changed.';
|
||
closePlanToday();
|
||
} catch (error) {
|
||
const conflict = tomorrowPlan.conflict();
|
||
if (conflict) showTomorrowConflict(conflict);
|
||
qs('#tomorrow-conflict-status').textContent = error?.status === 409 ?
|
||
'Tomorrow changed again. Both latest plans are still preserved; choose again.' :
|
||
`${error.message || 'Tomorrow could not be saved.'} Both plans are still preserved.`;
|
||
} finally {
|
||
keep.disabled = false;
|
||
use.disabled = false;
|
||
}
|
||
});
|
||
qs('#use-server-tomorrow').addEventListener('click', () => {
|
||
const adopted = tomorrowPlan.useRemote();
|
||
if (!adopted) return;
|
||
renderTomorrowQueueSummary(adopted);
|
||
qs('#my-work-action-status').textContent = 'Saved account Tomorrow plan selected. Today was not changed.';
|
||
closePlanToday();
|
||
});
|
||
qs('#keep-phone-week').addEventListener('click', async () => {
|
||
const keep=qs('#keep-phone-week'),use=qs('#use-server-week');
|
||
keep.disabled=true;use.disabled=true;
|
||
qs('#week-conflict-status').textContent='Saving this phone’s complete week…';
|
||
try {
|
||
await weekPlan.keepLocal();
|
||
qs('#mobile-week-summary').textContent=weekPlan.summary();
|
||
qs('#my-work-action-status').textContent='This phone’s Week Ahead plan is saved. Today was not changed.';
|
||
closePlanToday();
|
||
} catch(error) {
|
||
const conflict=weekPlan.conflict();
|
||
if(conflict)showWeekConflict(conflict);
|
||
qs('#week-conflict-status').textContent=error?.status===409?
|
||
'Week Ahead changed again. Both latest weeks are preserved; choose again.':
|
||
`${error.message||'Week Ahead could not be saved.'} Both weeks are still preserved.`;
|
||
} finally {keep.disabled=false;use.disabled=false;}
|
||
});
|
||
qs('#use-server-week').addEventListener('click', () => {
|
||
const adopted=weekPlan.useRemote();
|
||
if(!adopted)return;
|
||
qs('#mobile-week-summary').textContent=weekPlan.summary();
|
||
qs('#my-work-action-status').textContent='Saved account Week Ahead plan selected. Today was not changed.';
|
||
closePlanToday();
|
||
});
|
||
qs('#save-merged-week').addEventListener('click', async () => {
|
||
const save=qs('#save-merged-week');
|
||
save.disabled=true;
|
||
qs('#week-conflict-status').textContent='Saving combined Week Ahead plan…';
|
||
try {
|
||
await weekPlan.saveMerged();
|
||
qs('#mobile-week-summary').textContent=weekPlan.summary();
|
||
qs('#my-work-action-status').textContent='Combined Week Ahead changes are saved. Today was not changed.';
|
||
closePlanToday();
|
||
} catch(error) {
|
||
const conflict=weekPlan.conflict();
|
||
if(conflict)showWeekConflict(conflict);
|
||
qs('#week-conflict-status').textContent=error?.status===409?
|
||
'Week Ahead changed again. The latest days are preserved; review the remaining choices.':
|
||
`${error.message||'The combined week could not be saved.'} Your phone plan is still preserved.`;
|
||
} finally {
|
||
const conflict=weekPlan.conflict();
|
||
save.disabled=Boolean(conflict?.conflicts?.some(item=>!item.choice));
|
||
}
|
||
});
|
||
qs('#cancel-plan-today').addEventListener('click', closePlanToday);
|
||
qs('#confirm-week-plan').addEventListener('click', () => {
|
||
if(!weekFlow.confirm()){
|
||
qs('#week-review-status').textContent=weekPlan.pending()?'Wait for Week Ahead to finish syncing before confirming.':'Move duplicated work to one date before confirming.';
|
||
return;
|
||
}
|
||
wc.open(weekPlan.state());
|
||
});
|
||
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 = '';
|
||
if (!weekFlow.active() || !weekFlow.advance()) 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 = workSession.items();
|
||
if (!sessionItems.length) {
|
||
qs('#my-work-action-status').textContent = 'No visible work to start.';
|
||
return;
|
||
}
|
||
if (selectedWorkFilter === 'today') runTodayTransition('start');
|
||
else if (selectedWorkFilter === 'agenda' && agendaSessionCheckpoint.read()) workSession.resume();
|
||
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 () => {
|
||
if (selectedWorkFilter === 'agenda') {
|
||
await completeAgendaIssues();
|
||
return;
|
||
}
|
||
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' || stream === 'filed' ?
|
||
(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) {}
|
||
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();
|
||
updateWorkSessionActions();
|
||
updateWorkPaginationControls();
|
||
if (selectedWorkFilter === 'agenda') completeAgendaIssues();
|
||
if (!preserveRoute) workRoute.queue(filter);
|
||
return true;
|
||
}
|
||
async function openWorkQueueRoute(filter, action = null) {
|
||
if (filter === 'following') return followingQueue.route();
|
||
if (!selectWorkQueue(filter, { preserveRoute:true })) return;
|
||
qs('#my-work').scrollIntoView({block:'start'});
|
||
qs('#my-work').focus();
|
||
if (action !== 'protect-today') return;
|
||
await completeAgendaIssues();
|
||
if (workPagination.issue?.has_more) {
|
||
qs('#my-work-action-status').textContent =
|
||
'Protect Today needs all assigned deadlines. Retry when connected.';
|
||
return;
|
||
}
|
||
const proposal = protectToday.propose({
|
||
agenda:agendaMyWork(activeMyWork), today:todayMyWork,
|
||
identity:item => todayWork.identity(item), limit:todayWork.limit,
|
||
});
|
||
if (!proposal.protected.length) {
|
||
qs('#protect-today-status').textContent = 'No overdue or due-today work needs protection.';
|
||
return;
|
||
}
|
||
pendingProtectToday = proposal;
|
||
openPlanToday(qs('#protect-today'));
|
||
}
|
||
qs('#work-milestone-filter').addEventListener('change', event => {
|
||
selectedWorkMilestone = event.target.value;
|
||
try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); }
|
||
catch (e) {}
|
||
renderMyWork();
|
||
if (workSession.active()) workSession.reconcile();
|
||
});
|
||
let pushController = null;
|
||
for (const selector of [
|
||
qs('#push-deadline-hour'), qs('#device-setup-deadline-hour'),
|
||
qs('#push-start-day-hour'), qs('#device-setup-start-day-hour'),
|
||
]) {
|
||
for (let hour = 0; hour < 24; hour += 1) {
|
||
const option = document.createElement('option');
|
||
option.value = String(hour);
|
||
option.textContent = `${String(hour).padStart(2, '0')}:00`;
|
||
selector.appendChild(option);
|
||
}
|
||
selector.value = '9';
|
||
}
|
||
qs('#push-deadline-hour').addEventListener('change', event => {
|
||
qs('#device-setup-deadline-hour').value = event.target.value;
|
||
if (qs('#push-deadlines').checked) pushController?.changeDeadline();
|
||
});
|
||
qs('#device-setup-deadline-hour').addEventListener('change', event => {
|
||
qs('#push-deadline-hour').value = event.target.value;
|
||
});
|
||
qs('#push-deadline-days').addEventListener('change', event => {
|
||
qs('#device-setup-deadline-days').value = event.target.value;
|
||
if (qs('#push-deadlines').checked) pushController?.changeDeadline();
|
||
});
|
||
qs('#device-setup-deadline-days').addEventListener('change', event => {
|
||
qs('#push-deadline-days').value = event.target.value;
|
||
});
|
||
qs('#push-start-day-hour').addEventListener('change', event => {
|
||
qs('#device-setup-start-day-hour').value = event.target.value;
|
||
if (qs('#push-start-day').checked) pushController?.changeStartDay();
|
||
});
|
||
qs('#device-setup-start-day-hour').addEventListener('change', event => {
|
||
qs('#push-start-day-hour').value = event.target.value;
|
||
});
|
||
let pushControllerReady = Promise.resolve(null);
|
||
if ('serviceWorker' in navigator) {
|
||
pushControllerReady = (workspaceLifecycle.serviceWorkerReady ||
|
||
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'),
|
||
testControl:qs('#push-test'),
|
||
deadlineControl:qs('#push-deadlines'),
|
||
deadlineStatus:qs('#push-deadline-status'),
|
||
deadlineHour:qs('#push-deadline-hour'),
|
||
deadlineDays:qs('#push-deadline-days'),
|
||
startDayControl:qs('#push-start-day'),
|
||
startDayStatus:qs('#push-start-day-status'),
|
||
startDayHour:qs('#push-start-day-hour'),
|
||
followingControl:qs('#push-following'),
|
||
followingStatus:qs('#push-following-status'),
|
||
humanGateControl:qs('#push-human-gates'),
|
||
humanGateStatus:qs('#push-human-gates-status'),
|
||
deadlineSnooze:qs('#deadline-snooze'),
|
||
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
|
||
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),
|
||
onReviewDeadlines:() => {
|
||
window.location.hash = '#/my-work/agenda';
|
||
setTimeout(() => {
|
||
const protect = qs('#protect-today');
|
||
if (!protect.closest('[hidden]')) protect.click();
|
||
}, 0);
|
||
},
|
||
notification:window.Notification,
|
||
serviceWorker:navigator.serviceWorker,
|
||
fetchJson:fetchReviewJson,
|
||
});
|
||
await controller.init();
|
||
pushController = controller;
|
||
qs('#device-setup-deadline-hour').value = qs('#push-deadline-hour').value;
|
||
qs('#device-setup-deadline-days').value = qs('#push-deadline-days').value;
|
||
qs('#device-setup-start-day-hour').value = qs('#push-start-day-hour').value;
|
||
return controller;
|
||
}).catch(error => {
|
||
qs('#push-updates').disabled = true;
|
||
qs('#push-update-status').textContent = 'Update notification settings unavailable.';
|
||
return null;
|
||
});
|
||
} else {
|
||
qs('#push-updates').disabled = true;
|
||
qs('#push-update-status').textContent = 'This browser does not support update notifications.';
|
||
}
|
||
qs('#device-setup-start-day').addEventListener('click', async () => {
|
||
const controller = await pushControllerReady;
|
||
if (!controller) return;
|
||
qs('#push-start-day-hour').value = qs('#device-setup-start-day-hour').value;
|
||
qs('#push-start-day').checked = true;
|
||
await controller.changeStartDay();
|
||
qs('#device-setup-start-day-status').textContent = qs('#push-start-day-status').textContent;
|
||
});
|
||
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();
|
||
const deviceStorage = createDeviceStorage.mount(document);
|
||
await deviceStorage.start();
|
||
deviceSetup = createMobileDeviceSetup.mount({
|
||
document, installApp, promptStorage:localStorage,
|
||
timerView,
|
||
offlineAvailable:() => offlineStorageReady,
|
||
offlineEnabled:() => offlineWorkStore.enabled(),
|
||
enableOffline:() => setOfflineWorkEnabled(true),
|
||
storageProtectionReadiness:() => deviceStorage.persistenceReadiness(),
|
||
protectStorage:() => deviceStorage.requestPersistence(),
|
||
notificationReadiness:() => pushController?.notificationReadiness()
|
||
|| {state:'unavailable', detail:'Update notifications are unavailable.'},
|
||
appBadgeReadiness:() => appBadge.readiness(),
|
||
enableAppBadge:() => appBadge.enable(),
|
||
enablePush:async () => {
|
||
const controller = await pushControllerReady;
|
||
if (!controller) return;
|
||
if (controller.notificationReadiness().state === 'blocked') {
|
||
await controller.recoverPermission('updates');
|
||
return;
|
||
}
|
||
qs('#push-updates').checked = true;
|
||
await controller.change();
|
||
},
|
||
deadlineReadiness:() => pushController?.deadlineReadiness()
|
||
|| {state:'unavailable', detail:'Deadline reminders are unavailable.'},
|
||
enableDeadline:async () => {
|
||
const controller = await pushControllerReady;
|
||
if (!controller) return;
|
||
qs('#push-deadline-hour').value = qs('#device-setup-deadline-hour').value;
|
||
qs('#push-deadline-days').value = qs('#device-setup-deadline-days').value;
|
||
if (controller.deadlineReadiness().state === 'blocked') {
|
||
await controller.recoverPermission('deadline');
|
||
return;
|
||
}
|
||
await controller.enableDeadline();
|
||
},
|
||
});
|
||
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);
|
||
let adoptedProgressiveSnapshot = contextPoller.adopt(progressiveWorkHandoff?.liveSnapshot);
|
||
if (!adoptedProgressiveSnapshot && progressiveWorkHandoff?.liveSnapshotPromise) {
|
||
adoptedProgressiveSnapshot = await contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise);
|
||
}
|
||
if (!adoptedProgressiveSnapshot) await load();
|
||
else if (!confirmedOwnerLogin) {
|
||
await contextPoller.refresh({ force:true, full:true });
|
||
if (!confirmedOwnerLogin) {
|
||
try {
|
||
const identity = await fetchReviewJson('api/v1/background-identity');
|
||
const login = String(identity?.login || '').trim();
|
||
if (login) {
|
||
confirmedOwnerLogin = login;
|
||
planningOwnerLogin = login;
|
||
initialAccountRecovery = timerView.restore(todaySync.flush());
|
||
}
|
||
} catch (_error) {}
|
||
}
|
||
}
|
||
if (progressiveWorkHandoff?.openWork) {
|
||
const progressiveItem = lastMyWork.find(item =>
|
||
item.repository === progressiveWorkHandoff.openWork.repository &&
|
||
Number(item.number) === Number(progressiveWorkHandoff.openWork.number));
|
||
if (progressiveItem) {
|
||
openRoutedWork(progressiveItem, null);
|
||
qs('#progressive-work-detail').hidden = true;
|
||
}
|
||
}
|
||
|
||
await appShortcut.run();
|
||
document.addEventListener('visibilitychange', () => {
|
||
contextPoller.setVisible(!document.hidden);
|
||
if (!document.hidden) deviceSetup?.render();
|
||
});
|
||
|
||
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
||
setInterval(widgetTick, 1000);
|
||
await initialAccountRecovery;
|
||
if (planningOwnerLogin) await todaySync.flush();
|
||
await timerView.restore(Promise.resolve(true));
|
||
workspaceLifecycle.markWorkspaceReady?.();
|
||
})();
|