feat: batch-plan Search results into Week Ahead (Closes #1196)
This commit is contained in:
1 parent
f557f06bc5
commit
78839a50fe
8 files changed
+305
-5
No files matched your search
@@ -212,6 +212,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.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; }
|
||||
.search-week-batch-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; overflow-x:hidden; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||
.search-week-batch-review[hidden] { display:none; }
|
||||
.search-week-batch-review > div:first-child, #search-week-batch-list { display:grid; gap:10px; }
|
||||
.search-week-batch-row { display:grid; gap:8px; padding:10px 0; border-bottom:1px solid #2a496e; overflow-wrap:anywhere; }
|
||||
.search-week-batch-row-controls { display:grid; grid-template-columns:minmax(0,1fr) minmax(96px,.45fr); gap:8px; }
|
||||
.search-week-batch-row select, .search-week-batch-row input, .search-week-batch-actions button { min-height:44px; width:100%; }
|
||||
.search-week-batch-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.search-release-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.search-release-review[hidden] { display:none; }
|
||||
.search-release-review > div:first-child, #search-release-list, .search-release-row { display:grid; gap:6px; }
|
||||
|
||||
@@ -5750,7 +5750,7 @@
|
||||
searchBatchPlanning = mountSearchBatchPlanning(
|
||||
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,
|
||||
searchPreviewPath, queueToday, todaySync, acceptClaimedIssue, i=>commandItems[i]?.result,
|
||||
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr, laterWork, laterPicker
|
||||
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr, laterWork, laterPicker, weekPlan, localStorage
|
||||
);
|
||||
searchDefer = createSearchDefer({
|
||||
claim: detail => searchPreview.claim(detail),
|
||||
|
||||
@@ -738,6 +738,7 @@
|
||||
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
|
||||
<button id="cancel-search-selection" type="button">Cancel</button>
|
||||
<button id="defer-selected-search-results" type="button" disabled>Defer</button>
|
||||
<button id="week-selected-search-results" type="button" disabled>Plan Week Ahead</button>
|
||||
<button id="plan-selected-search-results" type="button" disabled>Plan release</button>
|
||||
<button id="queue-selected-search-results" type="button" disabled>Assign & add to Today</button>
|
||||
</div>
|
||||
@@ -751,6 +752,16 @@
|
||||
<button id="confirm-search-batch-estimates" type="button">Confirm & assign</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="search-week-batch-review" id="search-week-batch-review" role="dialog" aria-modal="true"
|
||||
aria-labelledby="search-week-batch-heading" hidden>
|
||||
<div><strong id="search-week-batch-heading">Plan selected issues into Week Ahead</strong>
|
||||
<span class="small" id="search-week-batch-summary" aria-live="polite"></span></div>
|
||||
<div id="search-week-batch-list"></div>
|
||||
<div class="search-week-batch-actions">
|
||||
<button id="cancel-search-week-batch" type="button">Back</button>
|
||||
<button id="confirm-search-week-batch" type="button">Assign & plan</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="search-release-review" id="search-release-review"
|
||||
aria-labelledby="search-release-heading" hidden>
|
||||
<div><strong id="search-release-heading">Plan selected issues into a release</strong>
|
||||
|
||||
@@ -64,10 +64,97 @@
|
||||
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
|
||||
laterWork = null, laterPicker = null, weekPlan = null, storage = null
|
||||
) {
|
||||
const get = selector => document.querySelector(selector);
|
||||
let processor;
|
||||
@@ -87,6 +174,7 @@
|
||||
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();
|
||||
@@ -194,6 +282,81 @@
|
||||
}
|
||||
},
|
||||
});
|
||||
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 => '<option value="' + escapeAttribute(day.date) + '"' +
|
||||
(day.date === selected ? ' selected' : '') + '>' + escapeHtml(day.label + ' · ' + (day.ids || []).length + ' planned') + '</option>').join('');
|
||||
get('#search-week-batch-list').innerHTML = items.map(item => {
|
||||
const id = todayWork.identity(item), existing = preview.placements[id];
|
||||
return '<div class="search-week-batch-row"><div><span class="small">' + escapeHtml(id) + '</span><strong>' +
|
||||
escapeHtml(item.title || 'Untitled work') + '</strong></div><div class="search-week-batch-row-controls"><label>Day<select data-search-week-batch-date="' +
|
||||
escapeAttribute(id) + '">' + options(existing?.date || preview.days[0]?.date) + '</select></label><label>Minutes<input type="number" inputmode="numeric" min="5" max="1440" step="5" value="' +
|
||||
escapeAttribute(existing?.estimate || '') + '" data-search-week-batch-estimate="' + escapeAttribute(id) +
|
||||
'" aria-label="Estimate for ' + escapeAttribute(item.title || id) + ' in minutes"></label></div></div>';
|
||||
}).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)]
|
||||
@@ -274,6 +437,9 @@
|
||||
) }, 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');
|
||||
@@ -322,7 +488,7 @@
|
||||
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
|
||||
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
|
||||
}
|
||||
return { plan, processor, laterProcessor, releaseProcessor, restore, resultHtml };
|
||||
return { plan, processor, laterProcessor, releaseProcessor, weekProcessor, restore, resultHtml };
|
||||
};
|
||||
|
||||
return createSearchBatchPlan;
|
||||
|
||||
Reference in new issue
Block a user