From a91788276c97f8a5a69da082368891fff874bb52 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 12 Aug 2026 08:28:03 +0000 Subject: [PATCH] feat: build a ready capacity-fit Today plan (Closes #633) --- frontend/dashboard.css | 2 ++ frontend/dashboard.js | 32 ++++++++++++++++++++++++++++ frontend/index.html | 2 ++ frontend/plan-today.js | 40 ++++++++++++++++++++++++++++++++++- tests/test_plan_today.py | 45 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 1 deletion(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 5169a76..2361b67 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -134,6 +134,8 @@ textarea { resize: vertical; min-height: 120px; } .plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; } .plan-today-available span { display:flex; align-items:center; gap:6px; } .plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; } +.plan-today-build { min-height:44px; width:100%; font-weight:700; } +.plan-today-build-status { min-height:1.4em; margin-top:6px; color:#bfdbfe; } .plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; } .plan-today-error { min-height:1.4em; color:#fca5a5; } .discard-recap-replan { min-height:44px; margin-bottom:8px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 9c9c3c1..bd167b3 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1394,6 +1394,11 @@ formatPlanMinutes(state.planned_minutes) + ' · ' + capacityText + (state.unestimated_count ? ' · ' + state.unestimated_count + ' unestimated' : ''); qs('#plan-today-available').value = state.capacity_minutes || ''; + const build = state.build; + qs('#plan-today-build-status').textContent = build ? + ('Built ' + build.selected.length + ' ready ' + (build.selected.length === 1 ? 'item' : 'items') + + (build.skipped.length ? ' · Skipped ' + build.skipped.length + ' blocked or unverified' : '') + + (build.needs_estimate.length ? ' · ' + build.needs_estimate.length + ' need estimates' : '')) : ''; qs('#plan-today-list').innerHTML = state.ids.length ? state.ids.map((id, index) => planTodayItemMarkup(planToday.item(id), true, index) ).join('') : '

No work selected yet.

'; @@ -1485,6 +1490,32 @@ }, }); + async function buildTodayPlan() { + const button = qs('#build-today-plan'); + const state = planToday.snapshot(); + const candidates = state.ids.map(id => planToday.item(id)).concat(planToday.candidates()); + button.disabled = true; + qs('#plan-today-build-status').textContent = 'Checking readiness and fitting your highest-ranked work…'; + const checks = await Promise.all(candidates.map(async item => { + const id = todayWork.identity(item); + if (item.kind !== 'issue') return [id, { status:'ready' }]; + try { + const detail = await inspectTodayDependencies(item); + if (!detail.available) return [id, { status:'unverified' }]; + if (detail.dependencies.length) return [id, { + status:'blocked', reason:detail.dependencies.length + ' open ' + + (detail.dependencies.length === 1 ? 'dependency' : 'dependencies'), + }]; + return [id, { status:'ready' }]; + } catch (_error) { + return [id, { status:'unverified' }]; + } + })); + planToday.buildRecommendation(Object.fromEntries(checks)); + button.disabled = false; + renderPlanToday(); + } + function canPreviewPlanItem(item) { if (!item || !offlineWorkMode) return Boolean(item); const login = planningOwnerLogin || confirmedOwnerLogin || @@ -5357,6 +5388,7 @@ qs('#plan-today-error').textContent = ''; renderPlanToday(); }); + qs('#build-today-plan').addEventListener('click', () => buildTodayPlan()); function commitPlanToday(start, button) { const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' }); if (result === 'saved') { diff --git a/frontend/index.html b/frontend/index.html index 5dc775d..de8e5a4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -254,6 +254,8 @@ + +
diff --git a/frontend/plan-today.js b/frontend/plan-today.js index 6683af6..6e60f8a 100644 --- a/frontend/plan-today.js +++ b/frontend/plan-today.js @@ -7,6 +7,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) { let recommendations = {}; let recommendationAware = false; let capacityAware = false; + let buildState = null; function cleanItems(items) { const unique = new Map(); @@ -33,6 +34,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) { } recommendations = {}; recommendationAware = actualMinutes !== null; + buildState = null; for (const [id, minutes] of Object.entries(actualMinutes || {})) { if (draftIds.includes(id) && Number.isInteger(minutes) && minutes >= 5 && minutes <= 1440 && estimates[id] !== minutes) recommendations[id] = minutes; @@ -74,6 +76,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) { recommendations = {}; recommendationAware = false; capacityAware = false; + buildState = null; } function cancel() { @@ -104,6 +107,40 @@ function createPlanToday({ identity, save, start, limit = 5 }) { return true; } + function buildRecommendation(readiness = {}) { + if (!openState) return null; + const selected = []; + const reasons = {}; + const skipped = []; + const needsEstimate = []; + let used = 0; + for (const [id, item] of itemsById) { + if (selected.length >= limit) break; + const check = readiness[id] || { status:'unverified' }; + if (check.status !== 'ready') { + skipped.push({ + id, + status:check.status === 'blocked' ? 'blocked' : 'unverified', + reason:check.reason || (check.status === 'blocked' ? 'Blocked' : 'Could not verify readiness'), + }); + continue; + } + const minutes = estimates[id]; + if (!Number.isInteger(minutes)) { + needsEstimate.push(id); + continue; + } + if (capacityMinutes !== null && used + minutes > capacityMinutes) continue; + selected.push(id); + reasons[id] = item.reason || item.attention_reason || 'Ranked My Work'; + used += minutes; + } + draftIds = selected; + capacityAware = true; + buildState = { selected:[...selected], reasons, skipped, needs_estimate:needsEstimate }; + return buildState; + } + function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) { if (!openState) return 'closed'; const ids = [...draftIds]; @@ -138,6 +175,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) { remaining_minutes: remainingMinutes, unestimated_count: draftIds.length - Object.keys(selectedEstimates).length, over_capacity: remainingMinutes !== null && remainingMinutes < 0, + ...(buildState ? { build:buildState } : {}), }; } @@ -149,7 +187,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) { return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value); } - return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, commit, snapshot, item, candidates }; + return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, buildRecommendation, commit, snapshot, item, candidates }; } if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday; diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 85a3aa7..5b4a478 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -164,6 +164,49 @@ process.stdout.write(JSON.stringify({{before,ignored,applied,after,result,saved} } +def test_plan_today_builds_ranked_ready_capacity_fit_without_saving(): + script = f""" +const createPlanToday = require({json.dumps(str(PLAN_TODAY))}); +const item = (number, reason) => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number, reason}}); +const saved = []; ++const planner = createPlanToday({{ + identity: value => 'issue:stackchain/dashboard:' + value.number + ':', + save: plan => saved.push(plan), +}}); +const id = n => 'issue:stackchain/dashboard:' + n + ':'; +planner.open([], [item(1, 'Due today'), item(2, 'Unread update'), item(3, 'Assigned to you'), item(4, 'Needs your review')], {{ + capacity_minutes:90, estimates:{{[id(1)]:60,[id(2)]:45,[id(3)]:30,[id(4)]:20}} +}}); +const build = planner.buildRecommendation({{ + [id(1)]:{{status:'ready'}}, + [id(2)]:{{status:'blocked', reason:'Waiting on API'}}, + [id(3)]:{{status:'ready'}}, + [id(4)]:{{status:'unverified'}}, +}}); +process.stdout.write(JSON.stringify({{build, snapshot:planner.snapshot(), saved}})); +""".replace("+const", "const") + result = run_node(script) + assert result["build"] == { + "selected": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:3:"], + "reasons": { + "issue:stackchain/dashboard:1:": "Due today", + "issue:stackchain/dashboard:3:": "Assigned to you", + }, + "skipped": [ + {"id": "issue:stackchain/dashboard:2:", "status": "blocked", "reason": "Waiting on API"}, + {"id": "issue:stackchain/dashboard:4:", "status": "unverified", "reason": "Could not verify readiness"}, + ], + "needs_estimate": [], + } + assert result["snapshot"]["ids"] == [ + "issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:3:" + ] + assert result["snapshot"]["planned_minutes"] == 90 + assert result["snapshot"]["remaining_minutes"] == 0 + assert result["snapshot"]["build"] == result["build"] + assert result["saved"] == [] + + def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return(): script = f""" const createPlanToday = require({json.dumps(str(PLAN_TODAY))}); @@ -278,6 +321,8 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet(): assert 'id="plan-today-sheet" role="dialog"' in html assert 'id="plan-today-capacity"' in html assert 'id="plan-today-available"' in html + assert 'id="build-today-plan"' in html + assert 'id="plan-today-build-status" role="status" aria-live="polite"' in html assert 'inputmode="numeric" min="15" max="1440"' in html assert 'data-plan-estimate="' in html assert 'aria-label="Estimate for ' in html -- 2.43.0