From 854d040e88a1aec5c5ffe03eeead8468d2f34091 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 9 Aug 2026 19:37:40 +0000 Subject: [PATCH] feat: finish Today items without changing Gitea (#421) --- frontend/dashboard.css | 2 +- frontend/dashboard.js | 23 +++++++ frontend/index.html | 5 ++ frontend/service-worker.js | 3 +- frontend/today-completion.js | 17 +++++ tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 17 ++--- tests/test_today_sync.py | 2 +- tests/test_today_work.py | 81 +++++++++++++++++++++++ 12 files changed, 143 insertions(+), 15 deletions(-) create mode 100644 frontend/today-completion.js diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 9c51b1e..ef00529 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -251,7 +251,7 @@ textarea { resize: vertical; min-height: 120px; } .my-work-actions { display:flex; flex-wrap:wrap; gap:8px; } .start-work-session { min-height:44px; } .resume-today-session, .end-today-session { min-height:44px; } -.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } +.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } .work-session-nav[hidden] { display:none; } .work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; } .work-session-nav button { min-height:44px; width:100%; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index d28294a..a79c13c 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -710,6 +710,9 @@ document.querySelectorAll('[data-work-session-next]').forEach(button => { button.textContent = state.can_next ? 'Next work item' : 'Finish session'; }); + document.querySelectorAll('[data-work-session-complete]').forEach(button => { + button.hidden = !workSession.checkpointed(); + }); }, onFinish: () => { closeOpenWorkSheets(); @@ -764,6 +767,21 @@ warm: warmTodayOffline, }); + const selectedSessionItem = kind => ({ + issue: selectedIssue, + pull: selectedPull, + review: selectedReview, + update: selectedUpdate, + })[kind] || null; + const completeTodayItem = createTodayCompletion({ + todayWork, + todaySync, + workSession, + refresh: () => refreshMyWorkView({ reconcileSession:false }), + warm: warmTodayOffline, + announce: message => { qs('#my-work-action-status').textContent = message; }, + }); + function acceptClaimedIssue(confirmed) { lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] }; lastContextSnapshot.issues = [confirmed].concat((lastContextSnapshot.issues || []).filter(candidate => @@ -3922,6 +3940,11 @@ document.querySelectorAll('[data-work-session-next]').forEach(button => button.addEventListener('click', () => workSession.next()) ); + document.querySelectorAll('[data-work-session-complete]').forEach(button => + button.addEventListener('click', () => + completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete)) + ) + ); qs('#load-more-notifications').addEventListener('click', () => notificationPager.loadMore(lastNotifications) ); diff --git a/frontend/index.html b/frontend/index.html index 7067bc6..43f5ace 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -333,6 +333,7 @@ @@ -448,6 +449,7 @@ @@ -488,6 +490,7 @@ @@ -552,6 +555,7 @@ @@ -596,6 +600,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 2e1483c..0844de2 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-v67'; +const CACHE = 'stackchain-dashboard-shell-v68'; 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; @@ -27,6 +27,7 @@ const SHELL = [ BASE + 'static/my-work.js', BASE + 'static/card-planning.js', BASE + 'static/today-work.js', + BASE + 'static/today-completion.js', BASE + 'static/plan-today.js', BASE + 'static/plan-today-preview.js', BASE + 'static/today-sync.js', diff --git a/frontend/today-completion.js b/frontend/today-completion.js new file mode 100644 index 0000000..76d21e5 --- /dev/null +++ b/frontend/today-completion.js @@ -0,0 +1,17 @@ +function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce }) { + return function completeTodayItem(item) { + if (!item || !todayWork.remove(item)) { + announce('Could not update Today on this device. Try again.'); + return false; + } + todaySync.enqueue('remove', todayWork.identity(item)); + todaySync.flush(); + refresh(); + warm(); + workSession.complete(); + announce('Done for Today. The Gitea item is unchanged.'); + return true; + }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createTodayCompletion; diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 0b8de34..de43640 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-v67" in source + assert "stackchain-dashboard-shell-v68" 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 b523e25..7baddca 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-v67" in worker + assert "stackchain-dashboard-shell-v68" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 1e98bda..ad7e42b 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -35,4 +35,4 @@ 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-v67" in worker + assert "stackchain-dashboard-shell-v68" in worker diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index b01b1fd..2d7e47d 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -168,6 +168,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-v67" in source + assert "stackchain-dashboard-shell-v68" 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 ec08a95..08fb91a 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -121,7 +121,7 @@ async function dispatchNotificationClick(route) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v67" in source + assert "stackchain-dashboard-shell-v68" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -130,7 +130,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v67" in source + assert "stackchain-dashboard-shell-v68" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -138,14 +138,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-v67" in source + assert "stackchain-dashboard-shell-v68" 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-v67" in source + assert "stackchain-dashboard-shell-v68" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -154,21 +154,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-v67" in source + assert "stackchain-dashboard-shell-v68" 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-v67" in source + assert "stackchain-dashboard-shell-v68" 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-v67" in source + assert "stackchain-dashboard-shell-v68" in source assert "BASE + 'static/update-ownership.js'" in source @@ -334,7 +334,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-v67" in source + assert "stackchain-dashboard-shell-v68" in source assert "BASE + 'static/queue-today.js'" in source @@ -371,6 +371,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/my-work.js", "/dashboard/static/card-planning.js", "/dashboard/static/today-work.js", + "/dashboard/static/today-completion.js", "/dashboard/static/plan-today.js", "/dashboard/static/plan-today-preview.js", "/dashboard/static/today-sync.js", diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index ef555f2..47ceb97 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:'); 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-v67" in source + assert "stackchain-dashboard-shell-v68" in source assert "BASE + 'static/today-sync.js'" in source diff --git a/tests/test_today_work.py b/tests/test_today_work.py index 1d99328..bdcf350 100644 --- a/tests/test_today_work.py +++ b/tests/test_today_work.py @@ -8,6 +8,8 @@ from tests.dashboard_bundle import dashboard TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js" +TODAY_COMPLETION = Path(__file__).parents[1] / "frontend" / "today-completion.js" +SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js" def run_node(script): @@ -87,6 +89,69 @@ process.stdout.write(JSON.stringify([ ] +def test_done_for_today_removes_current_plan_item_syncs_and_advances_without_gitea_mutation(): + script = f""" +const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))}); +const calls = []; +const current = {{kind:'issue', repository:'stackchain/dashboard', number:421}}; +const complete = createTodayCompletion({{ + todayWork: {{ + identity: item => `issue:${{item.repository}}:${{item.number}}:`, + remove: item => {{ calls.push(`remove:${{item.number}}`); return true; }}, + }}, + todaySync: {{ + enqueue: (action, identity) => {{ calls.push(`sync:${{action}}:${{identity}}`); return true; }}, + flush: () => calls.push('flush'), + }}, + refresh: () => calls.push('refresh'), + warm: () => calls.push('warm'), + workSession: {{ complete: () => calls.push('advance') }}, + announce: message => calls.push(`announce:${{message}}`), +}}); +const completed = complete(current); +process.stdout.write(JSON.stringify({{completed, calls}})); +""" + + assert json.loads(run_node(script)) == { + "completed": True, + "calls": [ + "remove:421", + "sync:remove:issue:stackchain/dashboard:421:", + "flush", + "refresh", + "warm", + "advance", + "announce:Done for Today. The Gitea item is unchanged.", + ], + } + + +def test_done_for_today_does_not_advance_when_local_plan_persistence_fails(): + script = f""" +const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))}); +const calls = []; +const complete = createTodayCompletion({{ + todayWork: {{identity: () => 'issue:r:1:', remove: () => false}}, + todaySync: {{enqueue: () => calls.push('sync'), flush: () => calls.push('flush')}}, + refresh: () => calls.push('refresh'), + warm: () => calls.push('warm'), + workSession: {{complete: () => calls.push('advance')}}, + announce: message => calls.push(message), +}}); +const completed = complete({{kind:'issue', repository:'r', number:1}}); +process.stdout.write(JSON.stringify({{completed, calls}})); +""" + + assert json.loads(run_node(script)) == { + "completed": False, + "calls": ["Could not update Today on this device. Try again."], + } + + +def test_done_for_today_logic_is_available_in_the_offline_app_shell(): + assert "BASE + 'static/today-completion.js'" in SERVICE_WORKER.read_text() + + def test_authoritative_reconciliation_reports_retired_ids_before_local_prune(): script = f""" const createTodayWork = require({json.dumps(str(TODAY_WORK))}); @@ -183,6 +248,22 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow(): assert "Another device filled Today ยท showing its saved plan." in html +@pytest.mark.anyio +async def test_today_session_sheets_offer_a_touch_safe_plan_only_completion_action(): + html = await dashboard() + + assert '' in html + assert html.count('data-work-session-complete=') == 4 + assert html.count('Done for Today & next') == 4 + assert "button.hidden = !workSession.checkpointed();" in html + assert "const completeTodayItem = createTodayCompletion({" in html + assert "refresh: () => refreshMyWorkView({ reconcileSession:false })," in html + assert "document.querySelectorAll('[data-work-session-complete]')" in html + assert "completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))" in html + assert ".work-session-nav button { min-height:44px; width:100%; }" in html + assert "grid-template-columns:repeat(3,minmax(0,1fr))" in html + + @pytest.mark.anyio async def test_dashboard_activates_today_lifecycle_convergence(): html = await dashboard() -- 2.43.0