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