feat: fit search batches to Today capacity (Closes #811)
This commit is contained in:
parent
da45cc36a3
commit
c3d58d7111
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -460,6 +460,16 @@
|
|||
<button id="cancel-search-selection" type="button">Cancel</button>
|
||||
<button id="queue-selected-search-results" type="button" disabled>Assign & 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 & assign</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -85,7 +85,11 @@ def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
|
|||
assert "journalName:'search-today-batch'" in controller
|
||||
assert "autoMount:false" in controller
|
||||
assert "plan.toggle(result)" in controller
|
||||
assert "processor.run(plan.snapshot().items)" in controller
|
||||
assert "processor.run(plan.snapshot().items, estimateValues())" in controller
|
||||
assert "timeBudget:()" in controller
|
||||
assert "persistEstimate" in controller
|
||||
assert 'id="search-batch-estimate-review"' in html
|
||||
assert 'data-search-batch-estimate' in controller
|
||||
assert "processor.resume()" in controller
|
||||
assert 'aria-label="Select ' in controller
|
||||
assert "result.kind === 'issue' && result.state === 'open'" in controller
|
||||
|
|
@ -94,3 +98,79 @@ def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
|
|||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert ".cmd-select-result" in css and "min-height:44px" in css
|
||||
assert "BASE + 'static/search-batch-plan.js'" in worker
|
||||
|
||||
|
||||
def test_search_batch_reviews_time_estimates_before_any_assignment_and_persists_queued_estimates():
|
||||
script = f"""
|
||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||
const listeners={{}};
|
||||
const elements={{}};
|
||||
for (const id of [
|
||||
'search-batch-actions','select-search-results','queue-selected-search-results',
|
||||
'search-selection-status','cmd-search-action-status','resume-search-batch',
|
||||
'open-palette','cmd-results','cancel-search-selection','search-batch-estimate-review',
|
||||
'search-batch-estimate-summary','search-batch-estimate-list',
|
||||
'cancel-search-batch-estimates','confirm-search-batch-estimates'
|
||||
]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'',
|
||||
addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}};
|
||||
const estimateInputs=[
|
||||
{{dataset:{{searchBatchEstimate:'stackchain/dashboard#811'}},value:'25'}},
|
||||
{{dataset:{{searchBatchEstimate:'stackchain/api#12'}},value:'30'}},
|
||||
];
|
||||
const document={{
|
||||
querySelector:selector=>elements[selector] || null,
|
||||
querySelectorAll:selector=>selector==='[data-search-batch-estimate]' ? estimateInputs : [],
|
||||
}};
|
||||
const calls=[];
|
||||
let processorOptions;
|
||||
const processor={{
|
||||
run:(items,estimates)=>{{calls.push({{type:'run',items,estimates}});return Promise.resolve();}},
|
||||
resume:()=>Promise.resolve(),pending:()=>0,
|
||||
}};
|
||||
const batchFactory=options=>{{processorOptions=options;return processor;}};
|
||||
const planning={{capacity_minutes:90,estimates:{{'stackchain/existing#1':20}}}};
|
||||
const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number,
|
||||
replacePlanning:value=>Object.assign(planning,value)}};
|
||||
const todaySync={{enqueueConfiguration:()=>{{}},flush:()=>{{}}}};
|
||||
const items=[
|
||||
{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:811,title:'Capacity'}},
|
||||
{{kind:'issue',state:'open',repository:'stackchain/api',number:12,title:'API'}},
|
||||
];
|
||||
let selected=items;
|
||||
const mounted=createSearchBatchPlan.mount(
|
||||
document,batchFactory,todayWork,()=> 'timmy',()=>Promise.resolve(),()=>'',()=>Promise.resolve('queued'),
|
||||
todaySync,item=>item,index=>selected[index],()=>{{}},value=>value,value=>value
|
||||
);
|
||||
mounted.plan.start();
|
||||
items.forEach(item=>mounted.plan.toggle(item));
|
||||
listeners['queue-selected-search-results:click']();
|
||||
const before={{
|
||||
calls:calls.slice(),
|
||||
reviewHidden:elements['#search-batch-estimate-review'].hidden,
|
||||
actionsHidden:elements['#search-batch-actions'].hidden,
|
||||
markup:elements['#search-batch-estimate-list'].innerHTML,
|
||||
budget:processorOptions.timeBudget(),
|
||||
}};
|
||||
listeners['confirm-search-batch-estimates:click']();
|
||||
processorOptions.persistEstimate(items[0],25);
|
||||
processorOptions.onProgress({{status:'complete',queued:['stackchain/dashboard#811','stackchain/api#12'],failed:[]}});
|
||||
const afterCompleteHidden=elements['#search-batch-estimate-review'].hidden;
|
||||
process.stdout.write(JSON.stringify({{before,calls,afterCompleteHidden}}));
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["before"]["calls"] == []
|
||||
assert result["before"]["reviewHidden"] is False
|
||||
assert result["before"]["actionsHidden"] is True
|
||||
assert "Capacity" in result["before"]["markup"]
|
||||
assert "data-search-batch-estimate" in result["before"]["markup"]
|
||||
assert result["before"]["budget"] == {"capacity_minutes": 90, "planned_minutes": 20}
|
||||
assert result["calls"] == [{
|
||||
"type": "run",
|
||||
"items": [
|
||||
{"kind": "issue", "state": "open", "repository": "stackchain/dashboard", "number": 811, "title": "Capacity"},
|
||||
{"kind": "issue", "state": "open", "repository": "stackchain/api", "number": 12, "title": "API"},
|
||||
],
|
||||
"estimates": {"stackchain/dashboard#811": 25, "stackchain/api#12": 30},
|
||||
}]
|
||||
assert result["afterCompleteHidden"] is True
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user