From 21705a085958ba1e5b873d1627510820394c21b3 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 15:01:26 +0000 Subject: [PATCH] feat: resume guided Prepare Today passes (Closes #899) --- README.md | 8 +- frontend/dashboard.css | 1 + frontend/dashboard.js | 23 ++++- frontend/index.html | 1 + frontend/mobile-start-day.js | 78 +++++++++++++++- frontend/service-worker.js | 2 +- tests/test_comment_next.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_mobile_start_day.py | 107 +++++++++++++++++++++- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 20 ++-- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 16 files changed, 228 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 5c22d9d..ca3cc82 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,11 @@ sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to ov `.stackchain-state/saved-searches.sqlite3` path. Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged. The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, and -Filed work, opens the highest-priority non-empty review queue, and refreshes the next action as queues clear. Once -urgent review is clear it continues the existing Today plan, or opens Find Work when Today is empty; viewing the -briefing itself never changes Gitea state. +Filed work and opens the highest-priority non-empty review queue. Starting it saves a confirmed-account, local-day +checkpoint: finishing Agenda, Updates, or the final Filed review returns to a focused handoff using fresh queue +counts, while reopening the queue sheet resumes the next live phase. **Finish for now** removes only that local +checkpoint. Once urgent review is clear the pass continues the existing Today plan, or opens Find Work when Today +is empty; the briefing and checkpoint never change Gitea state. Filed separates actionable **Needs review** from a browsable **Reviewed** history, so acknowledgement clears the queue without erasing the delegated-work record. Reviewed cards reopen the existing read-only issue detail and conversation, while the Filed badge continues to count actionable outcomes only. A later Gitea update moves that diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 3ae98bb..b838d07 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -854,6 +854,7 @@ textarea { resize: vertical; min-height: 120px; } .mobile-start-day h3, .mobile-start-day p { margin:0; } .mobile-start-day p + p { margin-top:4px; } .mobile-start-day-action { width:100%; min-height:48px; text-align:center; } + .mobile-start-day-finish { min-height:44px; width:100%; background:transparent; } .mobile-queue-list { display:grid; gap:8px; margin-top:12px; } .mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; } .mobile-queue-list button > span:first-child { display:grid; gap:2px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 8a1e985..ba400e4 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -97,15 +97,29 @@ }); const mobileStartDay = createMobileStartDay({ getCounts: () => mobileQueueCounts, - openQueue: name => name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name), + getLogin: () => confirmedOwnerLogin, + openQueue: name => { + const sheet = qs('#mobile-queue-sheet'); + if (sheet.open) sheet.close(); + return name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name); + }, + onHandoff: current => { + qs('#mobile-queue-heading').textContent = 'Prepare Today · ' + current.label; + const sheet = qs('#mobile-queue-sheet'); + if (!sheet.open) sheet.showModal(); + qs('#mobile-start-day-action').focus(); + }, elements: { summary: qs('#mobile-start-day-summary'), phases: qs('#mobile-start-day-phases'), action: qs('#mobile-start-day-action'), + finish: qs('#finish-mobile-start-day'), }, }); mobileStartDay.start(); - function showMobileQueueCompletion(completedName, cleared = true) { + qs('#finish-mobile-start-day').addEventListener('click', () => mobileStartDay.finish()); + function showMobileQueueCompletion(completedName, cleared = true, phase = '') { + if (cleared && phase && mobileStartDay.completePhase(phase)) return; mobileStartDay.render(); const next = mobileQueueLauncher.recommend(); qs('#mobile-queue-heading').textContent = completedName + (cleared ? ' cleared' : ''); @@ -125,6 +139,7 @@ ' marked read · ' + outcome.kept + ' kept unread'; review.hidden = !outcome.kept; review.textContent = 'Review ' + outcome.kept + ' kept unread'; + if (!outcome.kept && mobileStartDay.completePhase('update')) return; showMobileQueueCompletion(outcome.kept ? 'Updates reviewed' : 'Updates', !outcome.kept); if (outcome.kept) review.focus(); } @@ -1179,7 +1194,7 @@ qs('#retry-update-load').hidden = !message.startsWith('Could not load update.'); if (message === 'Inbox cleared.') { qs('#my-work-action-status').textContent = message; - showMobileQueueCompletion('Updates'); + if (!mobileStartDay.completePhase('update')) showMobileQueueCompletion('Updates'); } }, onClose: () => closeUpdateSheet(false), @@ -1501,6 +1516,7 @@ updateWorkSessionActions(); if (selectedWorkFilter === 'agenda') { closeOpenWorkSheets(); + if (mobileStartDay.completePhase('agenda')) return; window.location.hash = '#/my-work/agenda'; qs('#my-work-action-status').textContent = 'Agenda complete.'; return; @@ -3564,6 +3580,7 @@ const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]'); openRoutedWork(target.kind === 'update' ? { ...target.item, kind:'update' } : target.item, trigger); } else { + if (mobileStartDay.completePhase('filed')) return; window.location.hash = '#/my-work/filed'; qs('#my-work').focus(); } diff --git a/frontend/index.html b/frontend/index.html index c8e81e8..1016eb0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1230,6 +1230,7 @@

Checking Agenda, Attention, Updates, and Filed

+
diff --git a/frontend/mobile-start-day.js b/frontend/mobile-start-day.js index aaca4b5..4c3155e 100644 --- a/frontend/mobile-start-day.js +++ b/frontend/mobile-start-day.js @@ -2,6 +2,8 @@ if (typeof module === 'object' && module.exports) module.exports = factory; else root.createMobileStartDay = factory; })(typeof self !== 'undefined' ? self : this, function createMobileStartDay(options) { + const checkpointKey = 'stackchain.mobile-start-day.v1'; + const storage = options.storage || (typeof localStorage !== 'undefined' ? localStorage : null); const reviewOrder = [ ['agenda', 'Agenda'], ['attention', 'Attention'], @@ -13,6 +15,57 @@ return Math.max(0, Number(value) || 0); } + function localDay() { + const now = new Date(); + const pad = value => String(value).padStart(2, '0'); + return now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate()); + } + + function identity() { + try { + return { + login: String(options.getLogin ? options.getLogin() : '').trim(), + day: String(options.getDay ? options.getDay() : localDay()), + }; + } catch (_) { + return {login:'', day:''}; + } + } + + function checkpoint() { + if (!storage) return null; + const current = identity(); + if (!current.login || !current.day) return null; + try { + const saved = JSON.parse(storage.getItem(checkpointKey) || 'null'); + return saved?.login === current.login && saved?.day === current.day ? saved : null; + } catch (_) { + return null; + } + } + + function saveCheckpoint() { + if (!storage) return false; + const current = identity(); + if (!current.login || !current.day) return false; + try { + storage.setItem(checkpointKey, JSON.stringify(current)); + return true; + } catch (_) { + return false; + } + } + + function clearCheckpoint() { + if (!storage || !checkpoint()) return false; + try { + storage.removeItem(checkpointKey); + return true; + } catch (_) { + return false; + } + } + function briefing() { const counts = options.getCounts ? options.getCounts() : {}; const phases = reviewOrder @@ -34,10 +87,30 @@ function startNext() { const next = briefing().next; + if (reviewOrder.some(([name]) => name === next)) saveCheckpoint(); + else clearCheckpoint(); options.openQueue(next); return next; } + function state() { + const current = briefing(); + return {active:Boolean(checkpoint()), next:current.next, label:current.label}; + } + + function completePhase() { + if (!checkpoint()) return false; + const current = render(); + if (options.onHandoff) options.onHandoff(current); + return true; + } + + function finish() { + const cleared = clearCheckpoint(); + render(); + return cleared; + } + function render() { const current = briefing(); if (!options.elements) return current; @@ -45,7 +118,8 @@ options.elements.phases.textContent = current.phases.length ? current.phases.map(phase => phase.label + ' ' + phase.count).join(' · ') : 'All urgent queues reviewed'; - options.elements.action.textContent = current.label; + options.elements.action.textContent = checkpoint() ? 'Resume preparation · ' + current.label : current.label; + if (options.elements.finish) options.elements.finish.hidden = !checkpoint(); return current; } @@ -54,5 +128,5 @@ if (options.elements) options.elements.action.addEventListener('click', startNext); } - return {briefing, render, start, startNext}; + return {briefing, completePhase, finish, render, start, startNext, state}; }); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 25efeaf..1411dec 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,7 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v103'; +const CACHE = 'stackchain-dashboard-shell-v104'; 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 b6f25af..8aa4e16 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-v103" in worker + assert "stackchain-dashboard-shell-v104" in worker diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 534386e..712cff7 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-v103" in source + assert "stackchain-dashboard-shell-v104" 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 9657282..1d42e18 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-v103" in worker + assert "stackchain-dashboard-shell-v104" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 0b74503..b1ba82f 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,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-v103" in worker + assert "stackchain-dashboard-shell-v104" 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 ec91010..d11765e 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -214,7 +214,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-v103" in worker + assert "stackchain-dashboard-shell-v104" 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_mobile_start_day.py b/tests/test_mobile_start_day.py index c4ed648..8d800f7 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -111,6 +111,100 @@ process.stdout.write(JSON.stringify({{first, ready, opened}})); } +def test_prepare_today_pass_persists_per_account_and_hands_off_using_live_counts(): + script = f""" +const createStartDay = require({json.dumps(str(START_DAY))}); +const saved = new Map(); +const storage = {{ + getItem: key => saved.has(key) ? saved.get(key) : null, + setItem: (key, value) => saved.set(key, value), + removeItem: key => saved.delete(key), +}}; +let login = 'timmy'; +let day = '2026-08-15'; +let counts = {{agenda:2, attention:1, update:1, filed:1, today:3}}; +const opened = []; +const handoffs = []; +const controller = createStartDay({{ + storage, + getLogin: () => login, + getDay: () => day, + getCounts: () => counts, + openQueue: name => opened.push(name), + onHandoff: state => handoffs.push({{next:state.next, label:state.label}}), +}}); +const started = controller.startNext(); +counts = {{agenda:0, attention:0, update:1, filed:1, today:3}}; +const handed = controller.completePhase('agenda'); +const resumed = createStartDay({{ + storage, + getLogin: () => login, + getDay: () => day, + getCounts: () => counts, + openQueue: name => opened.push(name), +}}).state(); +login = 'alexander'; +const isolated = createStartDay({{ + storage, + getLogin: () => login, + getDay: () => day, + getCounts: () => counts, + openQueue: name => opened.push(name), +}}).state(); +login = 'timmy'; +day = '2026-08-16'; +const expired = createStartDay({{ + storage, + getLogin: () => login, + getDay: () => day, + getCounts: () => counts, + openQueue: name => opened.push(name), +}}).state(); +day = '2026-08-15'; +const finished = controller.finish(); +const cleared = controller.state(); +process.stdout.write(JSON.stringify({{started, handed, resumed, isolated, expired, finished, cleared, opened, handoffs}})); +""" + + assert run_node(script) == { + "started": "agenda", + "handed": True, + "resumed": {"active": True, "next": "update", "label": "Review Updates"}, + "isolated": {"active": False, "next": "update", "label": "Review Updates"}, + "expired": {"active": False, "next": "update", "label": "Review Updates"}, + "finished": True, + "cleared": {"active": False, "next": "update", "label": "Review Updates"}, + "opened": ["agenda"], + "handoffs": [{"next": "update", "label": "Review Updates"}], + } + + +def test_prepare_today_checkpoint_uses_the_device_local_day(): + script = f""" +process.env.TZ = 'Pacific/Honolulu'; +const RealDate = Date; +global.Date = class extends RealDate {{ + constructor(...args) {{ super(...(args.length ? args : ['2026-08-16T01:00:00Z'])); }} +}}; +const createStartDay = require({json.dumps(str(START_DAY))}); +const saved = new Map(); +const storage = {{ + getItem: key => saved.has(key) ? saved.get(key) : null, + setItem: (key, value) => saved.set(key, value), + removeItem: key => saved.delete(key), +}}; +createStartDay({{ + storage, + getLogin: () => 'timmy', + getCounts: () => ({{agenda:1}}), + openQueue: () => {{}}, +}}).startNext(); +process.stdout.write(JSON.stringify(JSON.parse(saved.get('stackchain.mobile-start-day.v1')))); +""" + + assert run_node(script) == {"login": "timmy", "day": "2026-08-15"} + + @pytest.mark.anyio async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile_bundle(): html = await dashboard() @@ -120,10 +214,21 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile assert 'id="mobile-start-day-summary"' in html assert 'id="mobile-start-day-phases"' in html assert 'id="mobile-start-day-action"' in html + assert 'id="finish-mobile-start-day"' in html assert '' in html assert "const mobileStartDay = createMobileStartDay({" in html - assert "openQueue: name => name === 'find'" in html + assert "getLogin: () => confirmedOwnerLogin" in html + assert "onHandoff: current =>" in html + assert "mobileStartDay.completePhase('agenda')" in html + assert "mobileStartDay.completePhase('update')" in html + assert "mobileStartDay.completePhase('filed')" in html + assert "mobileStartDay.finish()" in html + assert "openQueue: name =>" in html + assert "if (sheet.open) sheet.close();" in html + assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html assert "mobileStartDay.render();" in html assert ".mobile-start-day-action { width:100%; min-height:48px;" in html + assert ".mobile-start-day-finish { min-height:44px;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html assert "BASE + 'static/mobile-start-day.js'" in service_worker + assert "stackchain-dashboard-shell-v104" in service_worker diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index d4b1c8d..238e06d 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -410,7 +410,7 @@ 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-v103" in source + assert "stackchain-dashboard-shell-v104" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-readiness.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 08f3e1f..fd23511 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -152,7 +152,7 @@ async function dispatchPush(payload) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v103" in source + assert "stackchain-dashboard-shell-v104" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -161,14 +161,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-v103" in source + assert "stackchain-dashboard-shell-v104" 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-v103" in source + assert "stackchain-dashboard-shell-v104" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -176,7 +176,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-v103" in source + assert "stackchain-dashboard-shell-v104" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -184,14 +184,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-v103" in source + assert "stackchain-dashboard-shell-v104" 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-v103" in source + assert "stackchain-dashboard-shell-v104" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -200,21 +200,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-v103" in source + assert "stackchain-dashboard-shell-v104" 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-v103" in source + assert "stackchain-dashboard-shell-v104" 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-v103" in source + assert "stackchain-dashboard-shell-v104" in source assert "BASE + 'static/update-ownership.js'" in source @@ -800,7 +800,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-v103" in source + assert "stackchain-dashboard-shell-v104" 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 4795120..5c8f752 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-v103';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v104';" 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 a349c70..687f3af 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-v103" in source + assert "stackchain-dashboard-shell-v104" in source assert "BASE + 'static/today-sync.js'" in source -- 2.43.0