Plan selected issues into a release
diff --git a/frontend/search-batch-plan.js b/frontend/search-batch-plan.js
index 360449c..39cf0b0 100644
--- a/frontend/search-batch-plan.js
+++ b/frontend/search-batch-plan.js
@@ -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 => '
').join('');
+ get('#search-week-batch-list').innerHTML = items.map(item => {
+ const id = todayWork.identity(item), existing = preview.placements[id];
+ return '
';
+ }).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') + '';
}
- return { plan, processor, laterProcessor, releaseProcessor, restore, resultHtml };
+ return { plan, processor, laterProcessor, releaseProcessor, weekProcessor, restore, resultHtml };
};
return createSearchBatchPlan;
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index 3674622..553d389 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -36,7 +36,7 @@ FEATURE_SOURCES = {
"planning": (
"static/plan-today.js", "static/plan-today-readiness.js",
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
- "static/tomorrow-plan.js", "static/week-plan.js", "static/search-week-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
+ "static/tomorrow-plan.js", "static/week-plan.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
),
"today-timer": (
"static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
@@ -45,7 +45,7 @@ FEATURE_SOURCES = {
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",
- "static/search-batch-plan.js", "static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
+ "static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
),
}
diff --git a/tests/e2e/test_mobile_search_week_plan.py b/tests/e2e/test_mobile_search_week_plan.py
index 6f8a130..f893d1f 100644
--- a/tests/e2e/test_mobile_search_week_plan.py
+++ b/tests/e2e/test_mobile_search_week_plan.py
@@ -41,3 +41,37 @@ def test_search_week_plan_sheet_fits_small_phones_with_touch_safe_controls(width
"node => node.scrollWidth <= node.clientWidth"
)
browser.close()
+
+
+@pytest.mark.parametrize("width,height", [(320, 568), (390, 844)])
+def test_search_week_batch_review_fits_small_phones_without_horizontal_overflow(width, height):
+ html = (FRONTEND / "index.html").read_text()
+ with sync_api.sync_playwright() as playwright:
+ try:
+ browser = playwright.chromium.launch(headless=True)
+ except Exception as error:
+ pytest.skip(f"Chromium unavailable: {error}")
+ page = browser.new_page(viewport={"width": width, "height": height})
+ page.set_content(html)
+ page.add_style_tag(path=FRONTEND / "dashboard.css")
+ page.locator("#search-week-batch-list").evaluate(
+ """node => node.innerHTML = Array.from({length:4}, (_, index) =>
+ `
stackchain/dashboard#${1196+index}
+ Substantial mobile planning issue ${index+1}
+
+
`).join('')"""
+ )
+ page.locator("#search-week-batch-review").evaluate("node => node.hidden = false")
+ page.locator("#cmd-palette").evaluate("node => node.classList.add('open')")
+
+ expect = sync_api.expect
+ expect(page.locator("#search-week-batch-review")).to_be_visible()
+ expect(page.locator("#confirm-search-week-batch")).to_be_visible()
+ for control in page.locator("#search-week-batch-review select, #search-week-batch-review input, #search-week-batch-review button").all():
+ bounds = control.bounding_box()
+ assert bounds and bounds["height"] >= 44
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+ assert page.locator("#search-week-batch-review").evaluate(
+ "node => node.scrollWidth <= node.clientWidth"
+ )
+ browser.close()
diff --git a/tests/test_search_batch_plan.py b/tests/test_search_batch_plan.py
index 1e0664b..73899e6 100644
--- a/tests/test_search_batch_plan.py
+++ b/tests/test_search_batch_plan.py
@@ -83,6 +83,60 @@ process.stdout.write(JSON.stringify({{one,mixed:flow.releaseRepository()}}));
assert run_node(script) == {"one": "stackchain/dashboard", "mixed": None}
+def test_week_batch_validates_every_row_before_claiming_and_flushes_one_staged_week():
+ script = f"""
+const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
+const createWeekBatchPlan=createSearchBatchPlan.createWeekBatchPlan;
+const calls=[];
+const days=[
+ {{date:'2026-08-21',label:'Fri, Aug 21'}},
+ {{date:'2026-08-22',label:'Sat, Aug 22'}},
+];
+const state={{
+ '2026-08-21':{{ids:['stackchain/dashboard#1'],capacity_minutes:90,estimates:{{'stackchain/dashboard#1':30}}}},
+ '2026-08-22':{{ids:[],capacity_minutes:60,estimates:{{}}}},
+}};
+const week={{
+ load:async()=>{{calls.push('load');}},dates:()=>days,
+ day:date=>state[date],placement:id=>null,
+ place:(id,date,estimate,options)=>{{calls.push(['place',id,date,estimate,options.move]);state[date].ids.push(id);state[date].estimates[id]=estimate;return true;}},
+ flush:async()=>{{calls.push('flush');}},
+}};
+const items=[
+ {{kind:'issue',state:'open',repository:'stackchain/dashboard',number:2,title:'Two'}},
+ {{kind:'issue',state:'open',repository:'stackchain/api',number:3,title:'Three'}},
+];
+const flow=createWeekBatchPlan({{
+ week,identity:item=>item.repository+'#'+item.number,
+ prepare:async item=>{{calls.push(['claim',item.number]);return {{...item,assigned_to_me:true}};}},
+}});
+(async()=>{{
+ const invalid=await flow.run(items,{{
+ 'stackchain/dashboard#2':{{date:'2026-08-21',estimate:25}},
+ 'stackchain/api#3':{{date:'2026-08-22',estimate:0}},
+ }});
+ const before=calls.slice();
+ const planned=await flow.run(items,{{
+ 'stackchain/dashboard#2':{{date:'2026-08-21',estimate:25}},
+ 'stackchain/api#3':{{date:'2026-08-22',estimate:20}},
+ }});
+ process.stdout.write(JSON.stringify({{invalid,before,planned,calls}}));
+}})();
+"""
+
+ result = run_node(script)
+ assert result["invalid"]["status"] == "invalid"
+ assert result["invalid"]["errors"] == [{"id": "stackchain/api#3", "reason": "estimate-required"}]
+ assert result["before"] == ["load"]
+ assert result["planned"]["status"] == "planned"
+ assert result["planned"]["planned"] == 2
+ assert result["planned"]["failed"] == []
+ assert result["calls"].count("flush") == 1
+ assert [call for call in result["calls"] if isinstance(call, list) and call[0] == "claim"] == [
+ ["claim", 2], ["claim", 3],
+ ]
+
+
def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
@@ -124,6 +178,29 @@ def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
assert "BASE + 'static/search-batch-plan.js'" in worker
+def test_mobile_search_exposes_batch_week_review_with_per_issue_day_and_estimate_controls():
+ html = HTML.read_text()
+ dashboard = DASHBOARD.read_text()
+ css = CSS.read_text()
+ controller = SEARCH_BATCH_PLAN.read_text()
+
+ assert 'id="week-selected-search-results"' in html
+ assert 'id="search-week-batch-review" role="dialog" aria-modal="true"' in html
+ assert 'id="search-week-batch-list"' in html
+ assert 'id="search-week-batch-summary" aria-live="polite"' in html
+ assert 'id="cancel-search-week-batch"' in html
+ assert 'id="confirm-search-week-batch"' in html
+ assert "createWeekBatchPlan" in controller
+ assert "data-search-week-batch-date" in controller
+ assert "data-search-week-batch-estimate" in controller
+ assert "overload-confirmation-required" in controller
+ assert "weekPlan, localStorage" in dashboard
+ assert ".search-week-batch-review" in css
+ assert "max-height:calc(100dvh - 48px)" in css
+ assert "min-height:44px" in css
+ assert "env(safe-area-inset-bottom)" in css
+
+
def test_selected_search_issues_choose_one_future_time_before_durable_batch_defer():
script = f"""
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});