feat: fit search batches to Today capacity (Closes #811)
CI / lint (pull_request) Successful in 1m43s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-14 08:28:49 +00:00
1 parent da45cc36a3
commit c3d58d7111
5 files changed
+159 -4

No files matched your search

+8
View File
@@ -134,6 +134,14 @@ textarea { resize: vertical; min-height: 120px; }
.search-batch-actions span { grid-column:1 / -1; }
.search-batch-actions button, .search-batch-recovery { min-height:44px; }
.search-batch-recovery { width:100%; font-weight:700; }
.search-batch-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
.search-batch-estimate-review[hidden] { display:none; }
.search-batch-estimate-review > div:first-child, #search-batch-estimate-list { display:grid; gap:8px; }
.search-batch-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
.search-batch-estimate-row label { display:flex; align-items:center; gap:6px; }
.search-batch-estimate-review input { min-height:44px; width:76px; }
.search-batch-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
.search-batch-estimate-actions button { min-height:44px; }
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
+1 -1
View File
@@ -4424,7 +4424,7 @@
});
searchBatchPlanning = mountSearchBatchPlanning(
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,
searchPreviewPath, queueToday, acceptClaimedIssue, index=>commandItems[index]?.result,
searchPreviewPath, queueToday, todaySync, acceptClaimedIssue, index=>commandItems[index]?.result,
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr
);
searchDefer = createSearchDefer({
+10
View File
@@ -460,6 +460,16 @@
<button id="cancel-search-selection" type="button">Cancel</button>
<button id="queue-selected-search-results" type="button" disabled>Assign &amp; add to Today</button>
</div>
<section class="search-batch-estimate-review" id="search-batch-estimate-review"
aria-labelledby="search-batch-estimate-heading" hidden>
<div><strong id="search-batch-estimate-heading">Fit this work into Today</strong>
<span class="small" id="search-batch-estimate-summary" aria-live="polite"></span></div>
<div id="search-batch-estimate-list"></div>
<div class="search-batch-estimate-actions">
<button id="cancel-search-batch-estimates" type="button">Back</button>
<button id="confirm-search-batch-estimates" type="button">Confirm &amp; assign</button>
</div>
</section>
</div>
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
+59 -2
View File
@@ -62,7 +62,7 @@
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
acceptIssue, lookup, render, escapeHtml, escapeAttribute
todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute
) {
const get = selector => document.querySelector(selector);
let processor;
@@ -85,10 +85,24 @@
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') {
@@ -96,18 +110,61 @@
} 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();
}
},
});
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 '<div class="search-batch-estimate-row"><div><span class="small">' + escapeHtml(id) +
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div><label class="small">' +
'<input type="number" inputmode="numeric" min="5" max="1440" step="5" value="' +
escapeAttribute(estimate) + '" data-search-batch-estimate="' + escapeAttribute(id) +
'" aria-label="Estimate for ' + escapeAttribute(item.title || id) + ' in minutes" /> min</label></div>';
}).join('');
get('#search-batch-actions').hidden = true;
get('#search-batch-estimate-review').hidden = false;
document.querySelector('[data-search-batch-estimate]')?.focus();
}
get('#select-search-results').addEventListener('click', () => plan.start());
get('#cancel-search-selection').addEventListener('click', () => plan.cancel());
get('#queue-selected-search-results').addEventListener('click', () => processor.run(plan.snapshot().items));
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('#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', () => processor.resume());
get('#open-palette').addEventListener('click', () => restore());
get('#cmd-results').addEventListener('change', event => {