(function (root, factory) { const createSearchBatchPlan = factory(); if (typeof module === 'object' && module.exports) module.exports = createSearchBatchPlan; if (root) { root.createSearchBatchPlan = createSearchBatchPlan; root.mountSearchBatchPlanning = createSearchBatchPlan.mount; } })(typeof globalThis !== 'undefined' ? globalThis : this, function () { function createSearchBatchPlan({ limit = 50, resolve = item => Promise.resolve(item), claim = item => Promise.resolve(item), onChange = () => {}, } = {}) { const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50; const selected = new Map(); let active = false; const identity = item => [item?.kind || '', item?.repository || '', item?.number || ''].join(':'); const eligible = item => item?.kind === 'issue' && item?.state === 'open' && Boolean(item.repository) && Number.isInteger(item.number); function snapshot() { return { active, count:selected.size, items:Array.from(selected.values()) }; } function changed() { const state = snapshot(); onChange(state); return state; } function start() { active = true; selected.clear(); return changed(); } function cancel() { active = false; selected.clear(); return changed(); } function toggle(item) { if (!active) return 'inactive'; if (!eligible(item)) return 'ineligible'; const key = identity(item); if (selected.has(key)) { selected.delete(key); changed(); return 'removed'; } if (selected.size >= maximum) return 'limit'; selected.set(key, { ...item }); changed(); return 'selected'; } async function prepare(item) { const detail = await resolve(item); if (detail?.assigned_to_me) return detail; if (detail?.claimable) return claim(detail); throw new Error('This issue is no longer available to assign.'); } function releaseRepository() { const repositories = new Set(Array.from(selected.values()).map(item => item.repository)); return repositories.size === 1 ? repositories.values().next().value : null; } return { identity, eligible, start, cancel, toggle, snapshot, prepare, releaseRepository, limit:maximum }; } function createWeekBatchPlan({ week, prepare = item => Promise.resolve(item), accept = item => item, identity = item => String(item?.repository || '') + '#' + String(item?.number || ''), maxItems = 5, onProgress = () => {}, } = {}) { let running = null; const load = async () => { await week.load(); return week.dates().map(day => ({ ...day, ...week.day(day.date) })); }; function validate(items, assignments, days, confirmOverload = false) { const allowed = new Set(days.map(day => day.date)); const errors = []; const simulated = new Map(days.map(day => [day.date, { ids:[...(day.ids || [])], estimates:{...(day.estimates || {})}, capacity_minutes:Number(day.capacity_minutes) || 0, }])); items.forEach(item => { const id = identity(item), choice = assignments?.[id] || {}; const estimate = Number(choice.estimate); if (!allowed.has(choice.date)) errors.push({ id, reason:'date-required' }); if (!Number.isFinite(estimate) || estimate <= 0) errors.push({ id, reason:'estimate-required' }); if (!allowed.has(choice.date) || !Number.isFinite(estimate) || estimate <= 0) return; simulated.forEach((day, date) => { if (day.ids.includes(id) && choice.date !== date) { day.ids = day.ids.filter(value => value !== id); delete day.estimates[id]; } }); const destination = simulated.get(choice.date); if (!destination.ids.includes(id) && destination.ids.length >= maxItems) { errors.push({ id, reason:'day-full', date:choice.date, limit:maxItems }); return; } if (!destination.ids.includes(id)) destination.ids.push(id); destination.estimates[id] = estimate; }); const overloads = []; simulated.forEach((day, date) => { const planned = day.ids.reduce((sum, id) => sum + (Number(day.estimates[id]) || 0), 0); if (day.capacity_minutes > 0 && planned > day.capacity_minutes) { overloads.push({ date, planned_minutes:planned, capacity_minutes:day.capacity_minutes }); } }); if (errors.length) return { status:'invalid', errors, overloads:[] }; if (overloads.length && !confirmOverload) return { status:'overload-confirmation-required', errors:[], overloads }; return { status:'ready', errors:[], overloads }; } async function preview(items) { const days = await load(); return { days, placements:Object.fromEntries(items.map(item => [identity(item), week.placement(identity(item))])) }; } function run(items, assignments, { confirmOverload = false } = {}) { if (running) return running; running = (async () => { const selected = Array.isArray(items) ? items.slice() : []; const days = await load(); const checked = validate(selected, assignments, days, confirmOverload); if (checked.status !== 'ready') return checked; const failed = [], planned = []; for (let index = 0; index < selected.length; index += 1) { const source = selected[index], originalId = identity(source), choice = assignments[originalId]; try { const confirmed = accept(await prepare(source)) || source; const id = identity(confirmed); const existing = week.placement(id); if (!week.place(id, choice.date, Number(choice.estimate), {move:Boolean(existing && existing.date !== choice.date)})) { failed.push({id:originalId, reason:'assigned-not-planned'}); } else planned.push(id); } catch (error) { failed.push({id:originalId, reason:error?.message || 'assignment failed'}); } onProgress({status:'running',processed:index + 1,selected:selected.length}); } if (planned.length) { try { await week.flush(); } catch (_error) { return {status:'planned-pending',planned:planned.length,failed}; } } return {status:failed.length?'partial':'planned',planned:planned.length,failed}; })().finally(() => { running = null; }); return running; } return { preview, validate, run, pending:() => Boolean(running) }; } createSearchBatchPlan.createWeekBatchPlan = createWeekBatchPlan; createSearchBatchPlan.mount = function mountSearchBatchPlanning( document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday, todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute, laterWork = null, laterPicker = null, weekPlan = null, storage = null ) { const get = selector => document.querySelector(selector); let processor; let laterProcessor; let releaseProcessor; let releaseContext = null; const plan = createSearchBatchPlan({ resolve:item => fetchJson(previewPath(item), {headers:{Accept:'application/json'}}), claim:detail => fetchJson( 'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') + '/issues/' + encodeURIComponent(detail.number) + '/claim', {method:'PATCH', headers:{Accept:'application/json'}} ), onChange:state => { get('#search-batch-actions').hidden = !state.active; get('#select-search-results').hidden = state.active; get('#queue-selected-search-results').disabled = state.count === 0; get('#defer-selected-search-results').disabled = state.count === 0; get('#plan-selected-search-results').disabled = state.count === 0; if (get('#week-selected-search-results')) get('#week-selected-search-results').disabled = state.count === 0; get('#search-selection-status').textContent = state.count ? state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.'; render(); }, }); processor = batchFactory({ capacity:() => Math.max(0, todayWork.limit - todayWork.read().length), owner:getOwner, timeBudget:() => { const planning = todayWork.planning(); return { capacity_minutes:planning.capacity_minutes, planned_minutes:Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0), }; }, journalName:'search-today-batch', autoMount:false, claim:item => plan.prepare(item), queue:confirmed => queueToday(acceptIssue(confirmed)), persistEstimate:(confirmed, minutes) => { const planning = todayWork.planning(); planning.estimates[todayWork.identity(confirmed)] = minutes; todayWork.replacePlanning(planning); todaySync.enqueueConfiguration(planning.capacity_minutes, planning.estimates); todaySync.flush(); }, onProgress:progress => { get('#queue-selected-search-results').disabled = progress.status === 'running'; if (progress.status === 'running') { get('#search-selection-status').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…'; } else if (progress.status === 'full') { get('#search-selection-status').textContent = 'Today has ' + progress.available + ' remaining slot' + (progress.available === 1 ? '.' : 's.'); } else if (progress.status === 'estimates-required') { get('#search-batch-estimate-summary').textContent = 'Estimate every selected issue.'; } else if (progress.status === 'over-budget') { get('#search-batch-estimate-summary').textContent = progress.requested_minutes + ' min selected · ' + progress.remaining_minutes + ' min remain.'; } else if (progress.status === 'complete') { get('#cmd-search-action-status').textContent = progress.failed.length ? progress.queued.length + ' queued · ' + progress.failed.length + ' need retry.' : progress.queued.length + ' added to Today.'; get('#resume-search-batch').hidden = processor.pending() === 0; closeEstimateReview(); if (!progress.failed.length) plan.cancel(); } }, }); laterProcessor = batchFactory({ capacity:() => plan.limit, owner:getOwner, journalName:'search-later-batch', queueFailureReason:'assigned but Later could not be saved', autoMount:false, claim:item => plan.prepare(item), queue:(confirmed, context) => { const outcome = laterWork?.defer(acceptIssue(confirmed), context?.until); return outcome === 'deferred' ? 'queued' : outcome; }, onProgress:progress => { get('#defer-selected-search-results').disabled = progress.status === 'running'; if (progress.status === 'running') { get('#search-selection-status').textContent = 'Deferring ' + progress.processed + ' of ' + progress.selected + '…'; } else if (progress.status === 'complete') { get('#cmd-search-action-status').textContent = progress.failed.length ? progress.queued.length + ' deferred · ' + progress.failed.length + ' need retry.' : progress.queued.length + ' added to Later.'; restore(); if (!progress.failed.length) plan.cancel(); } }, }); releaseProcessor = batchFactory({ capacity:() => plan.limit, owner:getOwner, journalName:'search-release-batch', queueFailureReason:'assigned but release planning could not be confirmed', autoMount:false, claim:item => plan.prepare(item), queue:async (confirmed, context) => { const issuePath = 'api/v1/repos/' + confirmed.repository.split('/').map(encodeURIComponent).join('/') + '/issues/' + encodeURIComponent(confirmed.number); const releasePlan = await fetchJson(issuePath + '/release-plan', { method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'}, body:JSON.stringify({milestone_id:context.milestone_id, due_date:context.due_date || null}), }); if (releasePlan?.number !== confirmed.number || releasePlan?.milestone?.id !== context.milestone_id || (context.due_date && releasePlan?.due_date !== context.due_date)) { throw new Error('Issue release plan was not confirmed.'); } return 'queued'; }, onProgress:progress => { get('#confirm-search-release').disabled = progress.status === 'running'; if (progress.status === 'running') { get('#search-release-summary').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…'; } else if (progress.status === 'complete') { get('#cmd-search-action-status').textContent = progress.failed.length ? progress.queued.length + ' planned · ' + progress.failed.length + ' need retry.' : progress.queued.length + ' planned for ' + (releaseContext?.milestone_title || 'release') + '.'; get('#resume-search-batch').hidden = releaseProcessor.pending() === 0; closeReleaseReview(); if (!progress.failed.length) plan.cancel(); } }, }); const weekProcessor = weekPlan ? createWeekBatchPlan({ week:weekPlan,prepare:item=>plan.prepare(item),accept:acceptIssue, identity:item=>todayWork.identity(item), onProgress:progress=>{ if (progress.status === 'running') get('#search-week-batch-summary').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…'; }, storage, }) : null; let weekOverload = false; function weekAssignments() { const values = {}; document.querySelectorAll('[data-search-week-batch-date]').forEach(select => { const id = select.dataset.searchWeekBatchDate; const estimate = document.querySelector('[data-search-week-batch-estimate="' + id + '"]'); values[id] = {date:select.value,estimate:Number(estimate?.value)}; }); return values; } function closeWeekReview() { if (!get('#search-week-batch-review')) return; get('#search-week-batch-review').hidden = true; get('#search-batch-actions').hidden = !plan.snapshot().active; weekOverload = false; get('#confirm-search-week-batch').textContent = 'Assign & plan'; } async function openWeekReview() { const items = plan.snapshot().items; if (!weekProcessor || !items.length) return; const button = get('#week-selected-search-results'); button.disabled = true; get('#search-selection-status').textContent = 'Loading Week Ahead…'; try { const preview = await weekProcessor.preview(items); const options = selected => preview.days.map(day => '').join(''); get('#search-week-batch-list').innerHTML = items.map(item => { const id = todayWork.identity(item), existing = preview.placements[id]; return '
' + escapeHtml(id) + '' + escapeHtml(item.title || 'Untitled work') + '
'; }).join(''); get('#search-week-batch-summary').textContent = items.length + ' selected · review every day and estimate before assignment.'; get('#search-batch-actions').hidden = true; get('#search-week-batch-review').hidden = false; document.querySelector('[data-search-week-batch-date]')?.focus(); } catch (error) { get('#search-selection-status').textContent = error?.message || 'Week Ahead is unavailable. Retry when connected.'; } finally { button.disabled = false; } } async function confirmWeekReview() { if (!weekProcessor || weekProcessor.pending()) return; const button = get('#confirm-search-week-batch'); button.disabled = true; const outcome = await weekProcessor.run(plan.snapshot().items, weekAssignments(), {confirmOverload:weekOverload}); if (outcome.status === 'invalid') { const reasons = [...new Set(outcome.errors.map(error => error.reason))]; get('#search-week-batch-summary').textContent = reasons.includes('day-full') ? 'A day already has five items. Choose another day.' : 'Choose a valid day and estimate for every issue.'; } else if (outcome.status === 'overload-confirmation-required') { weekOverload = true; button.textContent = 'Confirm over capacity'; get('#search-week-batch-summary').textContent = outcome.overloads.map(day => day.planned_minutes + ' of ' + day.capacity_minutes + ' min on ' + day.date).join(' · ') + '. Confirm to plan anyway.'; } else { const pending = outcome.status === 'planned-pending'; get('#cmd-search-action-status').textContent = outcome.planned + ' planned' + (pending ? ' · sync pending' : '') + (outcome.failed.length ? ' · ' + outcome.failed.length + ' need attention.' : '.'); closeWeekReview(); if (!outcome.failed.length) plan.cancel(); } button.disabled = false; } function estimateValues() { return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input => [input.dataset.searchBatchEstimate, Number(input.value)] )); } function closeEstimateReview() { get('#search-batch-estimate-review').hidden = true; get('#search-batch-actions').hidden = !plan.snapshot().active; } function openEstimateReview(items) { const planning = todayWork.planning(); const planned = Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0); const remaining = Math.max(0, planning.capacity_minutes - planned); get('#search-batch-estimate-summary').textContent = remaining + ' min remain. Estimate before assigning.'; get('#search-batch-estimate-list').innerHTML = items.map(item => { const id = String(item.repository || '') + '#' + String(item.number || ''); const estimate = planning.estimates?.[id] || ''; return '
' + escapeHtml(id) + '' + escapeHtml(item.title || 'Untitled work') + '
'; }).join(''); get('#search-batch-actions').hidden = true; get('#search-batch-estimate-review').hidden = false; document.querySelector('[data-search-batch-estimate]')?.focus(); } function closeReleaseReview() { get('#search-release-review').hidden = true; get('#search-batch-actions').hidden = !plan.snapshot().active; } async function openReleaseReview() { const items = plan.snapshot().items; const repository = plan.releaseRepository(); if (!items.length) return; if (!repository) { get('#search-selection-status').textContent = 'Select issues from one repository to plan a release.'; return; } get('#plan-selected-search-results').disabled = true; get('#search-selection-status').textContent = 'Loading release milestones…'; try { const milestones = await fetchJson( 'api/v1/repos/' + repository.split('/').map(encodeURIComponent).join('/') + '/milestones', {headers:{Accept:'application/json'}} ); const open = (Array.isArray(milestones) ? milestones : []).filter(item => item?.state !== 'closed'); get('#search-release-milestone').innerHTML = '' + open.map(item => '' ).join(''); get('#search-release-list').innerHTML = items.map(item => '
' + escapeHtml(item.repository + ' #' + item.number) + '' + escapeHtml(item.title || 'Untitled work') + '
').join(''); get('#search-release-summary').textContent = items.length + ' issues in ' + repository + '. Assignment starts only after confirmation.'; get('#search-batch-actions').hidden = true; get('#search-release-review').hidden = false; get('#search-release-milestone').focus(); } catch (error) { get('#search-selection-status').textContent = error?.message || 'Milestones could not be loaded. Try again.'; } finally { get('#plan-selected-search-results').disabled = false; } } get('#select-search-results').addEventListener('click', () => plan.start()); get('#cancel-search-selection').addEventListener('click', () => plan.cancel()); get('#queue-selected-search-results').addEventListener('click', () => { const items = plan.snapshot().items; const capacity = todayWork.planning().capacity_minutes; if (Number.isInteger(capacity) && capacity > 0) openEstimateReview(items); else processor.run(items); }); get('#defer-selected-search-results').addEventListener('click', event => { const items = plan.snapshot().items; if (!items.length) return; laterPicker?.open({ items, confirm:until => () => laterProcessor.run( items, {}, { until:new Date(until).toISOString() } ) }, event.currentTarget, 'search-batch'); }); get('#plan-selected-search-results').addEventListener('click', openReleaseReview); get('#week-selected-search-results')?.addEventListener('click', openWeekReview); get('#cancel-search-week-batch')?.addEventListener('click', closeWeekReview); get('#confirm-search-week-batch')?.addEventListener('click', confirmWeekReview); get('#cancel-search-release').addEventListener('click', closeReleaseReview); get('#confirm-search-release').addEventListener('click', () => { const milestoneSelect = get('#search-release-milestone'); const milestoneId = Number(milestoneSelect.value); if (!Number.isInteger(milestoneId) || milestoneId < 1) { get('#search-release-summary').textContent = 'Choose an open milestone.'; return; } const selectedOption = milestoneSelect.options?.[milestoneSelect.selectedIndex]; const milestoneTitle = selectedOption?.textContent || selectedOption?.text || 'release'; const dueDay = get('#search-release-due-date').value; releaseContext = { milestone_id:milestoneId, milestone_title:milestoneTitle, due_date:dueDay ? dueDay + 'T23:59:59Z' : null, }; releaseProcessor.run(plan.snapshot().items, {}, releaseContext); }); get('#cancel-search-batch-estimates').addEventListener('click', closeEstimateReview); get('#confirm-search-batch-estimates').addEventListener('click', () => processor.run(plan.snapshot().items, estimateValues()) ); get('#resume-search-batch').addEventListener('click', () => { if (processor.pending()) return processor.resume(); if (laterProcessor.pending()) return laterProcessor.resume(); return releaseProcessor.resume(); }); get('#open-palette').addEventListener('click', () => restore()); get('#cmd-results').addEventListener('change', event => { const index = event.target?.dataset?.searchSelect; if (index === undefined) return; const result = lookup(Number(index)); if (result) plan.toggle(result); }); function restore() { const pending = processor.pending() + laterProcessor.pending() + releaseProcessor.pending(); get('#resume-search-batch').hidden = pending === 0; get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted'; } function resultHtml(result, index) { const allowed = result.kind === 'issue' && result.state === 'open'; const selected = plan.snapshot().items.some(item => plan.identity(item) === plan.identity(result)); return ''; } return { plan, processor, laterProcessor, releaseProcessor, weekProcessor, restore, resultHtml }; }; return createSearchBatchPlan; });