diff --git a/frontend/batch-find-work.js b/frontend/batch-find-work.js index a7e2422..db9eb06 100644 --- a/frontend/batch-find-work.js +++ b/frontend/batch-find-work.js @@ -1,4 +1,11 @@ -function createBatchFindWork({ capacity, claim, queue, onProgress = () => {} }) { +function createBatchFindWork({ + capacity, + claim, + queue, + timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }), + persistEstimate = () => {}, + onProgress = () => {}, +}) { let request = null; function result(status, selected, available, queued = [], failed = []) { @@ -9,7 +16,7 @@ function createBatchFindWork({ capacity, claim, queue, onProgress = () => {} }) return String(item.repository || '') + '#' + String(item.number || ''); } - function run(items) { + function run(items, estimates = {}) { if (request) return request; const selected = Array.isArray(items) ? items.slice() : []; const available = Math.max(0, Number(capacity()) || 0); @@ -18,6 +25,27 @@ function createBatchFindWork({ capacity, claim, queue, onProgress = () => {} }) onProgress(outcome); return Promise.resolve(outcome); } + const budget = timeBudget() || {}; + if (Number.isInteger(budget.capacity_minutes) && budget.capacity_minutes > 0) { + const remaining = Math.max(0, budget.capacity_minutes - (Number(budget.planned_minutes) || 0)); + const invalid = selected.map(key).filter(id => + !Number.isInteger(estimates[id]) || estimates[id] <= 0 + ); + const requested = selected.reduce((sum, item) => sum + + (Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0 ? estimates[key(item)] : 0), 0); + if (invalid.length) { + const outcome = { ...result('estimates-required', selected.length, available), + remaining_minutes: remaining, requested_minutes: requested, invalid }; + onProgress(outcome); + return Promise.resolve(outcome); + } + if (requested > remaining) { + const outcome = { ...result('over-budget', selected.length, available), + remaining_minutes: remaining, requested_minutes: requested, over_minutes: requested - remaining }; + onProgress(outcome); + return Promise.resolve(outcome); + } + } request = (async () => { const queued = []; const failed = []; @@ -28,6 +56,9 @@ function createBatchFindWork({ capacity, claim, queue, onProgress = () => {} }) const queueResult = await queue(confirmed); if (queueResult === 'queued' || queueResult === 'exists') { queued.push(key(item)); + if (Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0) { + persistEstimate(confirmed, estimates[key(item)]); + } } else { failed.push({ key: key(item), diff --git a/frontend/dashboard.css b/frontend/dashboard.css index e536d4e..8c468d9 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -467,6 +467,14 @@ textarea { resize: vertical; min-height: 120px; } .find-work-batch-actions[hidden] { display:none; } .find-work-batch-actions span { grid-column:1 / -1; } .find-work-batch-actions button { min-height:44px; } +.find-work-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; } +.find-work-estimate-review[hidden] { display:none; } +.find-work-estimate-review > div:first-child, #find-work-estimate-list { display:grid; gap:8px; } +.find-work-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; } +.find-work-estimate-row label { display:flex; align-items:center; gap:6px; } +.find-work-estimate-review input { min-height:44px; width:76px; } +.find-work-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; } +.find-work-estimate-actions button { min-height:44px; } .find-work-card button { width:100%; font-weight:700; } .find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 6e17bdf..a85b124 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1393,12 +1393,31 @@ const batchFindWork = createBatchFindWork({ capacity: () => Math.max(0, todayWork.limit - todayWork.read().length), + 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 + '…'; @@ -1406,6 +1425,34 @@ }, }); + function findWorkEstimateValues() { + return Object.fromEntries(Array.from(document.querySelectorAll('[data-find-work-estimate]')).map(input => + [input.dataset.findWorkEstimate, Number(input.value)] + )); + } + + function closeFindWorkEstimateReview() { + qs('#find-work-estimate-review').hidden = true; + qs('#batch-find-work-actions').hidden = !findWorkController.selection().active; + } + + function openFindWorkEstimateReview(items) { + const plan = todayWork.planning(); + const planned = Object.values(plan.estimates).reduce((sum, minutes) => sum + minutes, 0); + qs('#find-work-estimate-summary').textContent = formatPlanMinutes(Math.max(0, plan.capacity_minutes - planned)) + + ' remaining. Estimate selected work before assigning it.'; + qs('#find-work-estimate-list').innerHTML = items.map(item => { + const id = String(item.repository || '') + '#' + String(item.number || ''); + return '