From a08a51af0292d046b11c00692a10e760fbd7d799 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 12 Aug 2026 08:55:52 +0000 Subject: [PATCH] feat: finish Today plans with inline estimates (Closes #635) --- frontend/dashboard.css | 7 +++++ frontend/dashboard.js | 24 ++++++++++++++++ frontend/index.html | 6 ++++ frontend/plan-today.js | 12 ++++++-- frontend/service-worker.js | 2 +- tests/test_comment_next.py | 2 +- tests/test_frontend_bundle.py | 2 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_plan_today.py | 34 ++++++++++++++++++++++- tests/test_service_worker.py | 20 ++++++------- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 15 files changed, 98 insertions(+), 23 deletions(-) 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 '
' + key + '' + + escapeHtml(item?.title || 'Untitled work') + '
'; + }).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 @@
+ +
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 -- 2.43.0