feat: budget Find Work batches by time (Closes #675)
All checks were successful
CI / lint (pull_request) Successful in 1m28s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-12 19:53:57 +00:00
parent c6eabb5fc7
commit 0767ccaa9d
5 changed files with 193 additions and 2 deletions

View File

@ -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),

View File

@ -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; }

View File

@ -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 Todays 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 '<div class="find-work-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" data-find-work-estimate="' +
escAttr(id) + '" aria-label="Estimate for ' + escAttr(item.title || id) + ' in minutes" /> min</label></div>';
}).join('');
qs('#batch-find-work-actions').hidden = true;
qs('#find-work-estimate-review').hidden = false;
qs('[data-find-work-estimate]')?.focus();
}
let planTodayTrigger = null;
function formatPlanMinutes(minutes) {
if (!Number.isInteger(minutes)) return 'Not set';
@ -4256,6 +4303,10 @@
});
qs('#cancel-find-work-selection').addEventListener('click', () => findWorkController.cancelSelection());
qs('#claim-selected-work').addEventListener('click', async event => {
if (todayWork.planning().capacity_minutes !== null) {
openFindWorkEstimateReview(findWorkController.selectedItems());
return;
}
event.currentTarget.disabled = true;
const outcome = await batchFindWork.run(findWorkController.selectedItems());
if (outcome.status === 'complete') {
@ -4275,6 +4326,23 @@
event.currentTarget.disabled = false;
}
});
qs('#cancel-find-work-estimates').addEventListener('click', closeFindWorkEstimateReview);
qs('#confirm-find-work-estimates').addEventListener('click', async event => {
event.currentTarget.disabled = true;
const outcome = await batchFindWork.run(findWorkController.selectedItems(), findWorkEstimateValues());
event.currentTarget.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;
qs('#find-work-status').textContent = outcome.queued.length + ' queued' +
(outcome.failed.length ? ' · ' + outcome.failed.length + ' unavailable.' : '.');
closeFindWorkEstimateReview();
if (!outcome.failed.length) findWorkController.cancelSelection();
refreshMyWorkView();
});
qs('#load-more-available').addEventListener('click', async event => {
event.currentTarget.disabled = true;
qs('#find-work-status').textContent = 'Loading more available issues…';

View File

@ -544,6 +544,15 @@
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
<div class="find-work-list" id="find-work-list"></div>
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden>
<div><strong id="find-work-estimate-heading">Fit this work into Today</strong>
<span class="small" id="find-work-estimate-summary" aria-live="polite"></span></div>
<div id="find-work-estimate-list"></div>
<div class="find-work-estimate-actions">
<button id="cancel-find-work-estimates" type="button">Back</button>
<button id="confirm-find-work-estimates" type="button">Confirm &amp; assign</button>
</div>
</section>
<div class="find-work-batch-actions" id="batch-find-work-actions" hidden>
<span id="find-work-selection-status" class="small" aria-live="polite">No work selected.</span>
<button id="cancel-find-work-selection" type="button">Cancel</button>

View File

@ -109,6 +109,81 @@ flow.run([{{repository:'stackchain/dashboard',number:671}}])
}
def test_time_budget_blocks_claims_until_every_estimate_fits():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
capacity:()=>3,
timeBudget:()=>({{capacity_minutes:90,planned_minutes:30}}),
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
}});
const items=[701,702].map(number=>({{repository:'stackchain/dashboard',number}}));
Promise.all([
flow.run(items, {{'stackchain/dashboard#701':30}}),
flow.run(items, {{'stackchain/dashboard#701':30,'stackchain/dashboard#702':45}}),
]).then(results=>process.stdout.write(JSON.stringify({{results,calls}})));
"""
assert run_node(script) == {
"results": [
{
"status": "estimates-required", "selected": 2, "available": 3,
"queued": [], "failed": [], "remaining_minutes": 60,
"requested_minutes": 30,
"invalid": ["stackchain/dashboard#702"],
},
{
"status": "over-budget", "selected": 2, "available": 3,
"queued": [], "failed": [], "remaining_minutes": 60,
"requested_minutes": 75, "over_minutes": 15,
},
],
"calls": [],
}
def test_time_budget_persists_estimates_only_for_successfully_queued_claims():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
capacity:()=>3,
timeBudget:()=>({{capacity_minutes:120,planned_minutes:30}}),
claim:item=>item.number===702 ? Promise.reject(new Error('already claimed')) : Promise.resolve(item),
queue:item=>'queued',
persistEstimate:(item,minutes)=>calls.push(item.number+':'+minutes),
}});
flow.run([701,702].map(number=>({{repository:'stackchain/dashboard',number}})),{{
'stackchain/dashboard#701':30,
'stackchain/dashboard#702':45,
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
assert run_node(script) == {
"result": {
"status": "complete", "selected": 2, "available": 3,
"queued": ["stackchain/dashboard#701"],
"failed": [{"key": "stackchain/dashboard#702", "reason": "already claimed"}],
},
"calls": ["701:30"],
}
def test_mobile_find_work_has_preclaim_time_review_controls():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="find-work-estimate-review"' in html
assert 'id="find-work-estimate-list"' in html
assert 'id="confirm-find-work-estimates"' in html
assert 'inputmode="numeric"' in dashboard
assert "timeBudget: () =>" in dashboard
assert "persistEstimate:" in dashboard
assert ".find-work-estimate-review" in css
assert ".find-work-estimate-review input" in css and "min-height:44px" in css
def test_find_work_selection_survives_loaded_pages_and_removes_confirmed_claims():
script = f"""
const createFindWork=require({json.dumps(str(PICK_WORK))});