From ebe19f22810dcdb24527ad52277c95b9f7a47660 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 11 Aug 2026 23:04:49 +0000 Subject: [PATCH] feat: replan Today from live budget risk (Closes #597) --- frontend/service-worker.js | 2 +- frontend/today-timer.js | 88 ++++++++++++++++++++- frontend/today-work.js | 11 ++- 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_my_work.py | 96 ++++++++++++++++++++++- tests/test_plan_today.py | 5 +- tests/test_service_worker.py | 20 ++--- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- tests/test_today_work.py | 48 +++++++++++- 15 files changed, 254 insertions(+), 32 deletions(-) diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 886cb45..c2df855 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-v91'; +const CACHE = 'stackchain-dashboard-shell-v92'; 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/frontend/today-timer.js b/frontend/today-timer.js index faa7a1b..0f9a691 100644 --- a/frontend/today-timer.js +++ b/frontend/today-timer.js @@ -188,13 +188,42 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) { clearRecap() { return write(empty()); }, + totalElapsed() { + const state = read(); + return Object.entries(state.entries).reduce((total, [identity, entry]) => { + const live = identity === state.active_identity && entry.running ? + Math.max(0, now() - Number(entry.started_at ?? now())) : 0; + return total + Math.max(0, Number(entry.elapsed_ms) || 0) + live; + }, 0); + }, snapshot, }; } -function createTodayTimerView({ timer, isActive, queryAll, formatEstimate }) { +function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway }) { let progress = null; let runway = null; + if (typeof document !== 'undefined') queryAll('.work-session-nav').forEach(nav => { + const button = document.createElement('button'); + button.type = 'button'; + button.hidden = true; + button.dataset.workSessionAdjustPlan = ''; + button.textContent = 'Adjust remaining plan'; + nav.insertBefore(button, nav.querySelector('[data-work-session-complete]')); + }); + if (typeof MutationObserver !== 'undefined') { + const plan = queryAll('#plan-today')[0]; + const sheet = queryAll('#plan-today-sheet')[0]; + if (plan && sheet) { + const replan = createTodayBudgetReplan({ timer, openPlan:() => plan.click() }); + queryAll('[data-work-session-adjust-plan]').forEach(button => + button.addEventListener('click', () => replan.open()) + ); + new MutationObserver(() => { + if (sheet.hidden && replan.restore()) render(); + }).observe(sheet, { attributes:true, attributeFilter:['hidden'] }); + } + } const elapsed = milliseconds => { const seconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000)); const hours = Math.floor(seconds / 3600); @@ -205,11 +234,37 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate }) { const render = () => { if (!progress) return; const snapshot = timer.snapshot(); + const sourceRunway = getRunway?.(snapshot) || runway; + const liveRunway = sourceRunway?.future_minutes !== undefined ? (() => { + const currentElapsed = Math.max(0, Math.ceil(snapshot.elapsed_ms / 60000)); + const currentRemaining = sourceRunway.current_minutes === null ? null : + Math.max(0, sourceRunway.current_minutes - currentElapsed); + const remaining = currentRemaining === null || sourceRunway.future_minutes === null ? null : + currentRemaining + sourceRunway.future_minutes; + const projected = remaining === null ? null : Math.ceil(timer.totalElapsed() / 60000) + remaining; + const capacityRemaining = sourceRunway.capacity_minutes === null || projected === null ? null : + sourceRunway.capacity_minutes - projected; + return { + ...sourceRunway, + remaining_minutes:remaining, + over_estimate_minutes:sourceRunway.current_minutes === null ? 0 : + Math.max(0, currentElapsed - sourceRunway.current_minutes), + over_capacity_minutes:capacityRemaining === null ? 0 : Math.max(0, -capacityRemaining), + }; + })() : sourceRunway; const timing = isActive() && snapshot.identity ? ' · ' + elapsed(snapshot.elapsed_ms) + - (runway?.current_minutes ? ' / ' + formatEstimate(runway.current_minutes) : '') : ''; + (liveRunway?.current_minutes ? ' / ' + formatEstimate(liveRunway.current_minutes) : '') : ''; + const estimateRisk = liveRunway?.over_estimate_minutes ? + ' · ' + formatEstimate(liveRunway.over_estimate_minutes) + ' over estimate' : ''; + const capacityRisk = liveRunway?.over_capacity_minutes ? + ' · Today projected ' + formatEstimate(liveRunway.over_capacity_minutes) + ' over capacity' : ''; queryAll('[data-work-session-progress]').forEach(element => { - element.textContent = 'Item ' + progress.index + ' of ' + progress.total + timing + - (runway?.remaining_minutes ? ' · ' + formatEstimate(runway.remaining_minutes) + ' remaining' : ''); + element.textContent = 'Item ' + progress.index + ' of ' + progress.total + timing + estimateRisk + capacityRisk + + (!estimateRisk && !capacityRisk && liveRunway?.remaining_minutes ? + ' · ' + formatEstimate(liveRunway.remaining_minutes) + ' remaining' : ''); + }); + queryAll('[data-work-session-adjust-plan]').forEach(button => { + button.hidden = !(liveRunway?.over_estimate_minutes || liveRunway?.over_capacity_minutes); }); queryAll('[data-work-session-timer-toggle]').forEach(button => { button.hidden = !isActive(); @@ -227,6 +282,30 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate }) { }; } +function createTodayBudgetReplan({ timer, openPlan }) { + let active = false; + let resume = false; + return { + open() { + if (active) return false; + const state = timer.snapshot(); + if (!state.identity) return false; + resume = Boolean(state.running); + if (resume && timer.pause() === false) return false; + active = true; + openPlan?.(state); + return true; + }, + restore() { + if (!active) return false; + active = false; + const shouldResume = resume; + resume = false; + return !shouldResume || timer.resume() !== false; + }, + }; +} + function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel, onResolved }) { const render = pending => { if (!pending) { @@ -255,5 +334,6 @@ function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel if (typeof module !== 'undefined' && module.exports) { createTodayTimer.createView = createTodayTimerView; createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt; + createTodayTimer.createBudgetReplan = createTodayBudgetReplan; module.exports = createTodayTimer; } diff --git a/frontend/today-work.js b/frontend/today-work.js index 8e87ed5..bb4a6fa 100644 --- a/frontend/today-work.js +++ b/frontend/today-work.js @@ -150,12 +150,15 @@ function createTodayWork({ storage, getLogin, limit = 5 }) { } function runway(items, currentIndex = 0) { - const estimates = planning().estimates; - const remaining = (items || []).slice(Math.max(0, currentIndex)); - const minutes = remaining.map(item => estimates[identity(item)] || null); + const plan = planning(); + const minutes = (items || []).slice(Math.max(0, currentIndex)).map(item => plan.estimates[identity(item)] || null); + const total = values => values.some(value => value === null) ? null : + values.reduce((sum, value) => sum + value, 0); return { current_minutes: minutes[0] || null, - remaining_minutes: minutes.some(value => value === null) ? null : minutes.reduce((total, value) => total + value, 0), + remaining_minutes: total(minutes), + future_minutes: total(minutes.slice(1)), + capacity_minutes: plan.capacity_minutes, }; } diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 3abb887..9bf7a29 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-v91" in worker + assert "stackchain-dashboard-shell-v92" in worker diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index cb390a6..1100504 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -162,7 +162,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-v91';", + "const CACHE = 'stackchain-dashboard-shell-v92';", "const CACHE = 'stackchain-dashboard-shell-v999';", ) ) diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index de248e2..3045aa1 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-v91" in source + assert "stackchain-dashboard-shell-v92" 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 18fe994..a6946b3 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-v91" in worker + assert "stackchain-dashboard-shell-v92" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 7a3c4a1..e437b04 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-v91" in worker + assert "stackchain-dashboard-shell-v92" 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 9d55062..b3d35c4 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-v91" in worker + assert "stackchain-dashboard-shell-v92" 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_my_work.py b/tests/test_my_work.py index 528be53..39acd05 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -2214,6 +2214,83 @@ process.stdout.write(JSON.stringify({{first,second,stopped,other}})); } +def test_today_timer_totals_elapsed_work_for_live_capacity_without_cross_account_leakage(): + script = f""" +const createTodayTimer = require({json.dumps(str(TODAY_TIMER))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +let login = 'timmy'; +let now = 0; +const timer = createTodayTimer({{storage,getLogin:()=>login,now:()=>now}}); +timer.activate('issue:r:1:'); +now = 10 * 60000; +timer.activate('issue:r:2:'); +now = 25 * 60000; +const timmy = timer.totalElapsed(); +login = 'alexander'; +const isolated = timer.totalElapsed(); +process.stdout.write(JSON.stringify({{timmy,isolated}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == {"timmy": 25 * 60000, "isolated": 0} + + +def test_today_timer_view_renders_live_budget_risk_and_replan_action(): + script = f""" +const createTodayTimer = require({json.dumps(str(TODAY_TIMER))}); +const progress = {{textContent:''}}; +const adjust = {{hidden:true}}; +const toggle = {{hidden:false,textContent:'',setAttribute(){{}}}}; +const snapshot = {{identity:'issue:r:1:',elapsed_ms:45*60000,running:true}}; +const view = createTodayTimer.createView({{ + timer:{{snapshot:()=>snapshot,totalElapsed:()=>45*60000}}, isActive:()=>true, + queryAll:selector => selector.includes('progress') ? [progress] : selector.includes('adjust-plan') ? [adjust] : [toggle], + formatEstimate:minutes => minutes + 'm', + getRunway:() => ({{current_minutes:30,remaining_minutes:90,future_minutes:60,capacity_minutes:100}}), +}}); +view.update({{index:1,total:2}}); +process.stdout.write(JSON.stringify({{text:progress.textContent,adjustHidden:adjust.hidden}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "text": "Item 1 of 2 · 45:00 / 30m · 15m over estimate · Today projected 5m over capacity", + "adjustHidden": False, + } + + +def test_today_budget_replan_pauses_once_and_restores_the_prior_timer_state(): + script = f""" +const createTodayTimer = require({json.dumps(str(TODAY_TIMER))}); +const calls = []; +let running = true; +const timer = {{ + snapshot:()=>({{identity:'issue:r:1:',elapsed_ms:45*60000,running}}), + pause:()=>{{calls.push('pause');running=false;return true;}}, + resume:()=>{{calls.push('resume');running=true;return true;}}, +}}; +const handoff = createTodayTimer.createBudgetReplan({{timer,openPlan:state=>calls.push('open:' + state.identity + ':' + Math.ceil(state.elapsed_ms/60000))}}); +const opened = handoff.open(); +const duplicate = handoff.open(); +const restored = handoff.restore(); +running = false; +const pausedOpen = handoff.open(); +const pausedRestore = handoff.restore(); +process.stdout.write(JSON.stringify({{opened,duplicate,restored,pausedOpen,pausedRestore,calls}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "opened": True, + "duplicate": False, + "restored": True, + "pausedOpen": True, + "pausedRestore": True, + "calls": ["pause", "open:issue:r:1::45", "resume", "open:issue:r:1::45"], + } + + def test_today_timer_can_be_initialized_before_operator_identity_is_restored(): script = f""" const createTodayTimer = require({json.dumps(str(TODAY_TIMER))}); @@ -2240,6 +2317,23 @@ process.stdout.write(JSON.stringify({{activated,snapshot:timer.snapshot()}})); } +@pytest.mark.anyio +async def test_today_live_budget_replan_is_wired_into_every_mobile_session_control(): + html = await dashboard() + dashboard_source = TODAY_TIMER.with_name("dashboard.js").read_text() + + assert html.count('