diff --git a/README.md b/README.md index 395ffc6..b9f967f 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ and an active Today session shows the current estimate plus estimated remaining first previews its Gitea dependencies: unresolved blockers are listed with links and require the explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported as unknown rather than unblocked. Starting a Today work session also -stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan. Actual time appears there as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap. +stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan. Actual time appears there as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap. After a reload or installed-app restart, **Resume Today** reopens the saved item (or the next surviving item if work changed); **Comment & next** on that current issue or pull request posts the handoff online or admits it diff --git a/frontend/dashboard.css b/frontend/dashboard.css index f3c25d5..044f5ee 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -127,6 +127,7 @@ textarea { resize: vertical; min-height: 120px; } .plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; } .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; } .plan-today-list, .plan-today-candidates { display:grid; gap:8px; } .plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; } .plan-today-item-copy { min-width:0; overflow-wrap:anywhere; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 21f697a..e27ea34 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1375,6 +1375,7 @@ if (capacityAware && !todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates)) return false; if (!todayWork.replace(ids)) return false; if (capacityAware && !todayWork.replacePlanning(plan)) return false; + todayRecapView.completeReplan(); refreshMyWorkView(); todaySync.flush(); warmTodayOffline(); @@ -1640,9 +1641,10 @@ taskOverlayHistory.open('plan-today'); return; } - const recommendations = actualMinutes || pendingPlanActualMinutes; + const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes; pendingPlanActualMinutes = null; planToday.open(todayMyWork, activeMyWork, todayWork.planning(), recommendations); + qs('#discard-recap-replan').hidden = !todayRecapView.pendingReplan(); qs('#plan-today-error').textContent = ''; qs('#plan-today-sheet').hidden = false; document.body.classList.add('task-overlay-open'); @@ -1650,6 +1652,14 @@ qs('#cancel-plan-today').focus(); } + qs('#discard-recap-replan').addEventListener('click', () => { + todayRecapView.discardReplan(); + planToday.open(todayMyWork, activeMyWork, todayWork.planning()); + qs('#discard-recap-replan').hidden = true; + qs('#plan-today-error').textContent = 'Recap feedback discarded. Your saved recap is unchanged.'; + renderPlanToday(); + }); + const detailDefer = createDetailDefer({ laterWork, session: workSession, diff --git a/frontend/index.html b/frontend/index.html index 3ae471b..33289f4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -251,6 +251,7 @@ min +

Today, in order

diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 591227d..13b458e 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-v88'; +const CACHE = 'stackchain-dashboard-shell-v89'; 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-recap.js b/frontend/today-recap.js index eb9e780..97a5ba0 100644 --- a/frontend/today-recap.js +++ b/frontend/today-recap.js @@ -23,6 +23,10 @@ function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), sto const login = String(getLogin() || '').trim().toLowerCase(); return login ? 'stackchain.today-recap-draft.v1.' + login : ''; }; + const handoffKey = () => { + const login = String(getLogin() || '').trim().toLowerCase(); + return login ? 'stackchain.today-recap-handoff.v1.' + login : ''; + }; const validStoredDraft = saved => { if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 || !Array.isArray(saved.items) || !saved.items.length || saved.items.length > 20) return false; @@ -47,6 +51,33 @@ function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), sto return null; } }; + const validHandoff = saved => { + if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 || + !saved.actual_minutes || typeof saved.actual_minutes !== 'object' || Array.isArray(saved.actual_minutes)) return false; + const entries = Object.entries(saved.actual_minutes); + return entries.length > 0 && entries.length <= 20 && entries.every(([identity, minutes]) => + identity.length > 0 && identity.length <= 500 && Number.isInteger(minutes) && minutes >= 0 && minutes <= 1440 + ); + }; + const pendingReplan = () => { + const key = handoffKey(); + if (!storage || !key) return null; + try { + const saved = JSON.parse(storage.getItem(key) || 'null'); + if (!validHandoff(saved)) { + if (saved !== null) storage.removeItem(key); + return null; + } + return { session_id:saved.session_id, actual_minutes:{...saved.actual_minutes} }; + } catch (_error) { + try { storage.removeItem(key); } catch (_ignored) {} + return null; + } + }; + const completeReplan = () => { + const key = handoffKey(); + if (storage && key) storage.removeItem(key); + }; let draftKey = storageKey(); let draft = load(); const persist = () => { @@ -75,6 +106,9 @@ function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), sto payload.items.map(item => [item.identity, item.actual_minutes]) ) : null; const result = await save(payload); + if (includeActuals && storage && handoffKey()) storage.setItem(handoffKey(), JSON.stringify({ + session_id:payload.session_id, actual_minutes:actualMinutes, + })); clear(); if (storage && draftKey) storage.removeItem(draftKey); draft = null; @@ -106,6 +140,9 @@ function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), sto snapshot, save:() => saveConfirmed(false), saveForReplan:() => saveConfirmed(true), + pendingReplan, + completeReplan, + discardReplan:completeReplan, }; } @@ -193,7 +230,12 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc qs('#discard-today-recap').addEventListener('click', close); qs('#save-today-recap').addEventListener('click', event => saveDraft(event.currentTarget)); }; - return { open, close, saveDraft, loadHistory, render, bind }; + return { + open, close, saveDraft, loadHistory, render, bind, + pendingReplan:recap.pendingReplan, + completeReplan:recap.completeReplan, + discardReplan:recap.discardReplan, + }; } function endTodaySession(workSession, qs) { diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 5d67fff..383a025 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-v88" in worker + assert "stackchain-dashboard-shell-v89" in worker diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index b315b71..063a0e4 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-v88';", + "const CACHE = 'stackchain-dashboard-shell-v89';", "const CACHE = 'stackchain-dashboard-shell-v999';", ) ) diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 02e476c..51f4999 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-v88" in source + assert "stackchain-dashboard-shell-v89" 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 20f30d1..020a013 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-v88" in worker + assert "stackchain-dashboard-shell-v89" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 39fc811..5b45678 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-v88" in worker + assert "stackchain-dashboard-shell-v89" 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 8126b7e..a4b8c63 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-v88" in worker + assert "stackchain-dashboard-shell-v89" 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 59bd309..57f7995 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -332,6 +332,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-v88" in source + assert "stackchain-dashboard-shell-v89" 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 74d0070..cf7bc38 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -135,7 +135,7 @@ async function dispatchPush(payload) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v88" in source + assert "stackchain-dashboard-shell-v89" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -144,14 +144,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-v88" in source + assert "stackchain-dashboard-shell-v89" 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-v88" in source + assert "stackchain-dashboard-shell-v89" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -159,7 +159,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-v88" in source + assert "stackchain-dashboard-shell-v89" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -167,14 +167,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-v88" in source + assert "stackchain-dashboard-shell-v89" 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-v88" in source + assert "stackchain-dashboard-shell-v89" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -183,21 +183,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-v88" in source + assert "stackchain-dashboard-shell-v89" 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-v88" in source + assert "stackchain-dashboard-shell-v89" 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-v88" in source + assert "stackchain-dashboard-shell-v89" in source assert "BASE + 'static/update-ownership.js'" in source @@ -638,7 +638,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-v88" in source + assert "stackchain-dashboard-shell-v89" 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 76afaed..6d6fc63 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-v88';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v89';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_recap.py b/tests/test_today_recap.py index ebfc9fa..9df866e 100644 --- a/tests/test_today_recap.py +++ b/tests/test_today_recap.py @@ -306,6 +306,61 @@ recap.begin([ assert output["after"] is None +def test_recap_controller_restores_account_scoped_pending_replan_until_completed(): + script = f""" +const createRecap = require({json.dumps(str(TODAY_RECAP))}); +const values=new Map(); +const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}}; +let login='timmy'; +const options={{save:payload=>Promise.resolve({{session_id:payload.session_id}}),clear:()=>{{}},storage,getLogin:()=>login}}; +const first=createRecap({{...options,makeId:()=> 'saved-session'}}); +first.begin([{{identity:'issue:r:1:',elapsed_ms:52*60000}}], {{'issue:r:1:':30}}); +(async()=>{{ + await first.saveForReplan(); + const afterSave=first.pendingReplan(); + const restored=createRecap(options).pendingReplan(); + login='alexander'; + const otherAccount=createRecap(options).pendingReplan(); + login='timmy'; + const retainedAfterCancel=createRecap(options).pendingReplan(); + createRecap(options).completeReplan(); + const afterComplete=createRecap(options).pendingReplan(); + process.stdout.write(JSON.stringify({{afterSave,restored,otherAccount,retainedAfterCancel,afterComplete,keys:[...values.keys()]}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + run = subprocess.run(["node", "-e", script], capture_output=True, text=True) + assert run.returncode == 0, run.stderr + output = json.loads(run.stdout) + pending = { + "session_id": "saved-session", + "actual_minutes": {"issue:r:1:": 52}, + } + assert output == { + "afterSave": pending, + "restored": pending, + "otherAccount": None, + "retainedAfterCancel": pending, + "afterComplete": None, + "keys": [], + } + + +def test_recap_controller_removes_malformed_or_unbounded_pending_replan(): + script = f""" +const createRecap = require({json.dumps(str(TODAY_RECAP))}); +const key='stackchain.today-recap-handoff.v1.timmy'; +const values=new Map([[key, JSON.stringify({{session_id:'saved',actual_minutes:Object.fromEntries( + Array.from({{length:21}}, (_,index)=>['issue:r:' + index + ':', 10]) +)}})]]); +const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}}; +const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=> 'timmy'}}); +process.stdout.write(JSON.stringify({{pending:recap.pendingReplan(),hasKey:values.has(key)}})); +""" + run = subprocess.run(["node", "-e", script], capture_output=True, text=True) + assert run.returncode == 0, run.stderr + assert json.loads(run.stdout) == {"pending": None, "hasKey": False} + + @pytest.mark.anyio async def test_dashboard_renders_mobile_today_recap_flow(): html = main.FRONTEND_BUILD.dashboard_html @@ -335,3 +390,19 @@ async def test_dashboard_renders_mobile_today_recap_flow(): assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard assert "openPlanToday(qs('#plan-today'), true, actualMinutes)" in dashboard assert ".today-recap-row { grid-template-columns:1fr; }" in css + + +@pytest.mark.anyio +async def test_dashboard_resumes_and_explicitly_discards_pending_recap_feedback(): + html = main.FRONTEND_BUILD.dashboard_html + dashboard = (ROOT / "frontend" / "dashboard.js").read_text() + recap_source = TODAY_RECAP.read_text() + + assert 'id="discard-recap-replan"' in html + assert "todayRecapView.pendingReplan()?.actual_minutes" in dashboard + assert "todayRecapView.completeReplan();" in dashboard + assert "todayRecapView.discardReplan();" in dashboard + assert "if (capacityAware && !todayWork.replacePlanning(plan)) return false;" in dashboard + assert dashboard.index("todayWork.replacePlanning(plan)") < dashboard.index("todayRecapView.completeReplan();") + assert "pendingReplan:recap.pendingReplan" in recap_source + assert "discardReplan:recap.discardReplan" in recap_source diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index db4b07d..6b9ed39 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-v88" in source + assert "stackchain-dashboard-shell-v89" in source assert "BASE + 'static/today-sync.js'" in source