diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 2361b67..9e88eb1 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -136,6 +136,13 @@ textarea { resize: vertical; min-height: 120px; }
.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-estimates { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
+.plan-today-estimates h3, .plan-today-estimates p { margin-top:0; }
+.plan-today-estimates input { min-height:44px; width:108px; box-sizing:border-box; }
+.plan-today-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:8px 0; border-top:1px solid #29496d; }
+.plan-today-estimate-row label { display:flex; align-items:center; gap:6px; }
+.plan-today-skipped { display:grid; gap:6px; margin:8px 0; }
+.plan-today-skipped-item { padding:8px 10px; border-left:3px solid #b45309; background:#261a13; overflow-wrap:anywhere; }
.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 bd167b3..98ebb2c 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -1399,6 +1399,19 @@
('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' : '')) : '';
+ const estimateSection = qs('#plan-today-estimates');
+ estimateSection.hidden = !build?.needs_estimate.length;
+ qs('#plan-today-estimate-list').innerHTML = (build?.needs_estimate || []).map(id => {
+ const item = planToday.item(id);
+ const key = escapeHtml(item?.key || ((item?.repository || '') + '#' + (item?.number || '')));
+ return '
';
+ }).join('');
+ qs('#plan-today-skipped').innerHTML = (build?.skipped || []).map(entry =>
+ '' + escapeHtml(planToday.item(entry.id)?.title || entry.id) +
+ ' · ' + escapeHtml(entry.reason) + '
'
+ ).join('');
qs('#plan-today-list').innerHTML = state.ids.length ? state.ids.map((id, index) =>
planTodayItemMarkup(planToday.item(id), true, index)
).join('') : 'No work selected yet.
';
@@ -1434,6 +1447,17 @@
planToday.setEstimate(input.dataset.planEstimate, Number(input.value));
renderPlanToday();
}));
+ document.querySelectorAll('[data-plan-missing-estimate]').forEach(input => input.addEventListener('change', () => {
+ const id = input.dataset.planMissingEstimate;
+ if (!input.checkValidity() || !planToday.setEstimate(id, Number(input.value))) {
+ qs('#plan-today-error').textContent = 'Enter an estimate from 5 minutes to 24 hours.';
+ input.focus();
+ return;
+ }
+ qs('#plan-today-error').textContent = '';
+ renderPlanToday();
+ document.querySelector('[data-plan-missing-estimate]')?.focus();
+ }));
document.querySelectorAll('[data-plan-recommendation]').forEach(button => button.addEventListener('click', () => {
planToday.applyRecommendation(button.dataset.planRecommendation);
renderPlanToday();
diff --git a/frontend/index.html b/frontend/index.html
index de8e5a4..41c73df 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -256,6 +256,12 @@
+
+ Estimate to finish your plan
+ Add minutes for ready work and your plan will re-fit immediately.
+
+
+
diff --git a/frontend/plan-today.js b/frontend/plan-today.js
index 6e60f8a..ee378e0 100644
--- a/frontend/plan-today.js
+++ b/frontend/plan-today.js
@@ -8,6 +8,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
let recommendationAware = false;
let capacityAware = false;
let buildState = null;
+ let buildReadiness = null;
function cleanItems(items) {
const unique = new Map();
@@ -35,6 +36,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
recommendations = {};
recommendationAware = actualMinutes !== null;
buildState = null;
+ buildReadiness = 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;
@@ -77,6 +79,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
recommendationAware = false;
capacityAware = false;
buildState = null;
+ buildReadiness = null;
}
function cancel() {
@@ -91,10 +94,12 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
}
function setEstimate(id, minutes) {
- if (!openState || !draftIds.includes(id)) return false;
+ const resolvingBuildEstimate = buildState?.needs_estimate?.includes(id);
+ if (!openState || (!draftIds.includes(id) && !resolvingBuildEstimate)) return false;
capacityAware = true;
- if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
- else delete estimates[id];
+ if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) return false;
+ estimates[id] = minutes;
+ if (resolvingBuildEstimate && buildReadiness) buildRecommendation(buildReadiness);
return true;
}
@@ -109,6 +114,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
function buildRecommendation(readiness = {}) {
if (!openState) return null;
+ buildReadiness = { ...readiness };
const selected = [];
const reasons = {};
const skipped = [];
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 28769cd..d876037 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v93';
+const CACHE = 'stackchain-dashboard-shell-v94';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py
index a3213fe..1e62ce2 100644
--- a/tests/test_comment_next.py
+++ b/tests/test_comment_next.py
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v93" in worker
+ assert "stackchain-dashboard-shell-v94" in worker
diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py
index f2eb2d6..565102f 100644
--- a/tests/test_frontend_bundle.py
+++ b/tests/test_frontend_bundle.py
@@ -187,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
worker = changed_frontend / "service-worker.js"
worker.write_text(
worker.read_text().replace(
- "const CACHE = 'stackchain-dashboard-shell-v93';",
+ "const CACHE = 'stackchain-dashboard-shell-v94';",
"const CACHE = 'stackchain-dashboard-shell-v999';",
)
)
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index 5c4b024..7a2c9c0 100644
--- a/tests/test_later_sync.py
+++ b/tests/test_later_sync.py
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v93" in source
+ assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/later-sync.js'" in source
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index 11a79b5..fc7edca 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
- assert "stackchain-dashboard-shell-v93" in worker
+ assert "stackchain-dashboard-shell-v94" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index 26954db..81df2c7 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -41,7 +41,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
- assert "stackchain-dashboard-shell-v93" in worker
+ assert "stackchain-dashboard-shell-v94" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py
index 714d018..1b9bc9b 100644
--- a/tests/test_mobile_device_setup.py
+++ b/tests/test_mobile_device_setup.py
@@ -186,7 +186,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
- assert "stackchain-dashboard-shell-v93" in worker
+ assert "stackchain-dashboard-shell-v94" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index 5b4a478..d07e4ef 100644
--- a/tests/test_plan_today.py
+++ b/tests/test_plan_today.py
@@ -207,6 +207,34 @@ process.stdout.write(JSON.stringify({{build, snapshot:planner.snapshot(), saved}
assert result["saved"] == []
+def test_plan_today_estimates_ready_omission_and_refits_without_another_readiness_check():
+ script = f"""
+const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
+const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
+const planner = createPlanToday({{
+ identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
+ save: () => true,
+}});
+const id = n => 'issue:stackchain/dashboard:' + n + ':';
+planner.open([], [item(1), item(2)], {{capacity_minutes:60, estimates:{{[id(1)]:30}}}});
+planner.buildRecommendation({{[id(1)]:{{status:'ready'}}, [id(2)]:{{status:'ready'}}}});
+const before = planner.snapshot();
+const estimated = planner.setEstimate(id(2), 30);
+const after = planner.snapshot();
+process.stdout.write(JSON.stringify({{before, estimated, after}}));
+"""
+ result = run_node(script)
+ assert result["before"]["ids"] == ["issue:stackchain/dashboard:1:"]
+ assert result["before"]["build"]["needs_estimate"] == ["issue:stackchain/dashboard:2:"]
+ assert result["estimated"] is True
+ assert result["after"]["ids"] == [
+ "issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"
+ ]
+ assert result["after"]["estimates"]["issue:stackchain/dashboard:2:"] == 30
+ assert result["after"]["remaining_minutes"] == 0
+ assert result["after"]["build"]["needs_estimate"] == []
+
+
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
@@ -323,6 +351,10 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
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 'id="plan-today-estimates"' in html
+ assert 'Estimate to finish your plan' in html
+ assert 'data-plan-missing-estimate="' in html
+ assert '.plan-today-estimates input { min-height:44px;' in html
assert 'inputmode="numeric" min="15" max="1440"' in html
assert 'data-plan-estimate="' in html
assert 'aria-label="Estimate for ' in html
@@ -378,6 +410,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
- assert "stackchain-dashboard-shell-v93" in source
+ assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index a57017d..332f25c 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -145,7 +145,7 @@ async function dispatchPush(payload) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v93" in source
+ assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -154,14 +154,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v93" in source
+ assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v93" in source
+ assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -169,7 +169,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v93" in source
+ assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -177,14 +177,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v93" in source
+ assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v93" in source
+ assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -193,21 +193,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v93" in source
+ assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v93" in source
+ assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v93" in source
+ assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -654,7 +654,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v93" in source
+ assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/queue-today.js'" in source
diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py
index 6a307ec..842b32a 100644
--- a/tests/test_today_readiness.py
+++ b/tests/test_today_readiness.py
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
- assert "const CACHE = 'stackchain-dashboard-shell-v93';" in service_worker
+ assert "const CACHE = 'stackchain-dashboard-shell-v94';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py
index a87c315..e9eb81c 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v93" in source
+ assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/today-sync.js'" in source