diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 68d0c58..4ba122b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -57,7 +57,7 @@ jobs: pip install -r requirements-e2e.txt python3 -m playwright install --with-deps chromium - name: Exercise packaged mobile work journeys - run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_today_handoff_release.py -q + run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py -q release-candidate: runs-on: ubuntu-latest diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 74ec847..716387d 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -258,12 +258,26 @@ textarea { resize: vertical; min-height: 120px; } .today-recap-totals { margin:12px 0; padding:12px; border-radius:10px; background:#10233d; font-weight:700; } .today-recap-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } .today-recap-actions button { min-height:44px; width:100%; } +.today-wrap-up-sheet { position:fixed; inset:0; z-index:89; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); } +.today-wrap-up-sheet[hidden] { display:none; } +.today-wrap-up-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; } +.today-wrap-up-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } +.today-wrap-up-header h2 { margin:.2rem 0; } +.today-wrap-up-header button { min-height:44px; min-width:44px; } +.today-wrap-up-items { display:grid; gap:8px; margin:14px 0; } +.today-wrap-up-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:12px; min-width:0; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233d; overflow-wrap:anywhere; } +.today-wrap-up-item span { display:block; } +.today-wrap-up-item label { display:flex; align-items:center; min-height:44px; gap:8px; font-weight:700; } +.today-wrap-up-item input { width:24px; min-height:24px; } +.today-wrap-up-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } +.today-wrap-up-actions button { min-height:44px; width:100%; } @media (max-width:420px) { .today-interruption-actions { grid-template-columns:1fr; } .today-recap-actions { grid-template-columns:1fr; } .today-recap-row { grid-template-columns:1fr; } .today-recap-row input { width:min(100%,8rem); } .today-recap-variance { grid-column:1; } + .today-wrap-up-item { grid-template-columns:1fr; } } .today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); } .today-readiness-sheet[hidden] { display:none; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 28b957b..ef51527 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1767,11 +1767,18 @@ interruptionPrompt.resolve(button.dataset.todayInterruption) ); }); + const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml, + onComplete:() => { + todayRecapView['completeReplan'](); + refreshMyWorkView(); + warmTodayOffline(); + }, + }); const todayRecapView = setupTodayRecap( timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions, () => planningOwnerLogin, identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null, - actualMinutes => openPlanToday(qs('#plan-today'), true, actualMinutes) + actualMinutes => todayWrapUpView.open(todayMyWork, actualMinutes) ); function updateDetailDeferLabels(active) { document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => { diff --git a/frontend/index.html b/frontend/index.html index bc337f1..5dec2fc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -406,13 +406,28 @@
Choose unfinished work to schedule for tomorrow at 09:00. Unchecked work stays in Today.
+ + +Nothing unfinished remains in Today.
'; + qs('#today-wrap-up-items').querySelectorAll('[data-wrap-up-identity]').forEach(input => { + input.addEventListener('change', () => controller.choose(input.dataset.wrapUpIdentity, input.checked)); + }); + } + + function open(items, recapActualMinutes = {}) { + actualMinutes = { ...recapActualMinutes }; + controller.open(items); + qs('#today-wrap-up-status').textContent = ''; + render(); + qs('#today-wrap-up-sheet').hidden = false; + document.body.classList.add('task-overlay-open'); + requestAnimationFrame(() => (qs('#today-wrap-up-items input') || qs('#finish-today-wrap-up')).focus()); + } + + async function finish(button) { + button.disabled = true; + qs('#today-wrap-up-status').textContent = 'Saving tomorrow’s plan…'; + try { + const result = await controller.finish(); + qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.'; + close(); + onComplete(result, actualMinutes); + } catch (error) { + qs('#today-wrap-up-status').textContent = error.message || 'Wrap-up could not be saved. Retry when ready.'; + } finally { + button.disabled = false; + } + } + + function bind() { + qs('#close-today-wrap-up').addEventListener('click', close); + qs('#finish-today-wrap-up').addEventListener('click', event => finish(event.currentTarget)); + } + + return { open, close, finish, render, bind }; +} + +function setupTodayWrapUp(options) { + const controller = createTodayWrapUp(options); + const view = createTodayWrapUpView({ ...options, controller }); + view.bind(); + return view; +} + +if (typeof module !== 'undefined' && module.exports) { + createTodayWrapUp.createView = createTodayWrapUpView; + createTodayWrapUp.setup = setupTodayWrapUp; + module.exports = createTodayWrapUp; +} diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 29e716e..fad9924 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -31,7 +31,7 @@ FEATURE_SOURCES = { "security-center": ("static/security-center.js",), "today-timer": ( "static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", - "static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", + "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", diff --git a/tests/e2e/test_mobile_today_wrap_up_release.py b/tests/e2e/test_mobile_today_wrap_up_release.py new file mode 100644 index 0000000..c189f15 --- /dev/null +++ b/tests/e2e/test_mobile_today_wrap_up_release.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import os +import threading +from pathlib import Path + +import pytest + +if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1": + pytest.skip("packaged Today wrap-up journey runs only in its gated CI job", allow_module_level=True) +pytest.importorskip("playwright.sync_api") +from playwright.sync_api import expect, sync_playwright + +from fake_gitea import FakeGiteaServer +from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server + + +@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)]) +def test_release_artifact_renders_and_applies_mobile_today_wrap_up( + tmp_path: Path, width: int, height: int +): + archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz")) + assert len(archives) == 1, "browser job must download exactly one assembled release archive" + + fake = FakeGiteaServer(("127.0.0.1", 0)) + fake_thread = threading.Thread(target=fake.serve_forever, daemon=True) + fake_thread.start() + fake_url = f"http://127.0.0.1:{fake.server_port}" + browser_errors: list[str] = [] + + try: + with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright: + browser = playwright.chromium.launch(args=["--ignore-certificate-errors"]) + context = browser.new_context( + viewport={"width": width, "height": height}, ignore_https_errors=True + ) + page = context.new_page() + page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error))) + page.on( + "console", + lambda message: browser_errors.append(message.text) + if message.type == "error" + else None, + ) + page.goto(origin + "/", wait_until="networkidle") + page.locator('input[name="device_label"]').fill("Today wrap-up release phone") + page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) + page.locator("#submit-sign-in").click() + page.wait_for_url(origin + "/", wait_until="networkidle") + + page.evaluate( + """ + () => { + const items=[ + {kind:'issue',repository:'acme/mobile',number:41,title:'Ship mobile capture',key:'acme/mobile#41'}, + {kind:'issue',repository:'acme/mobile',number:42,title:'Polish desktop filters',key:'acme/mobile#42'}, + ]; + const identity=item => `${item.kind}:${item.repository}:${item.number}:`; + const today=items.map(identity); const later={}; const operations=[]; + const todayWork={identity,read:()=>[...today],contains:item=>today.includes(identity(item)),remove:item=>{ + const index=today.indexOf(identity(item)); if(index<0)return false; today.splice(index,1); return true; + }}; + const laterWork={presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:(item,wake)=>{ + later[identity(item)]=wake.toISOString(); return 'deferred'; + }}; + const todaySync={enqueue:(action,id)=>{operations.push([action,id]);return true;},flush:()=>Promise.resolve(true)}; + const controller=createTodayWrapUp({todayWork,laterWork,todaySync}); + const view=createTodayWrapUpView({controller,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)}); + window.__wrapTest={view,today,later,operations}; + view.open(items,{}); + } + """ + ) + + sheet = page.locator("#today-wrap-up-sheet") + expect(sheet).to_be_visible() + expect(page.locator(".today-wrap-up-item")).to_have_count(2) + for control in sheet.locator("button, label").all(): + bounds = control.bounding_box() + assert bounds and bounds["height"] >= 44 + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + + page.locator('[data-wrap-up-identity="issue:acme/mobile:41:"]').check() + page.evaluate("window.__wrapTest.view.finish(document.querySelector('#finish-today-wrap-up'))") + expect(sheet).to_be_hidden() + result = page.evaluate( + "({today:window.__wrapTest.today,later:window.__wrapTest.later,operations:window.__wrapTest.operations})" + ) + assert result == { + "today": ["issue:acme/mobile:42:"], + "later": {"issue:acme/mobile:41:": "2026-08-17T09:00:00.000Z"}, + "operations": [["remove", "issue:acme/mobile:41:"]], + } + assert browser_errors == [] + browser.close() + finally: + fake.shutdown() + fake.server_close() + fake_thread.join(timeout=5) diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index 56bc7d8..597ae4a 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -59,6 +59,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey(): "python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py " "tests/e2e/test_mobile_search_preview_navigation.py " "tests/e2e/test_mobile_find_work_release.py " - "tests/e2e/test_mobile_today_handoff_release.py -q" + "tests/e2e/test_mobile_today_handoff_release.py " + "tests/e2e/test_mobile_today_wrap_up_release.py -q" ) in browser assert "needs: [lint, build-release, browser-journey]" in release diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 08136e1..b78474f 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -988,6 +988,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/today-work.js", "/dashboard/static/today-timer.js", "/dashboard/static/today-recap.js", + "/dashboard/static/today-wrap-up.js", "/dashboard/static/today-completion.js", "/dashboard/static/today-readiness.js", "/dashboard/static/comment-next.js", diff --git a/tests/test_today_recap.py b/tests/test_today_recap.py index 074fa75..55ce835 100644 --- a/tests/test_today_recap.py +++ b/tests/test_today_recap.py @@ -876,13 +876,13 @@ async def test_dashboard_renders_mobile_today_recap_flow(): assert ".today-recap-header button { min-height:44px;" in css assert ".today-recap-actions button { min-height:44px;" in css assert "overflow-x:hidden" in css - assert "Save recap & adjust plan" in html + assert "Save recap & wrap up" in html assert "todayRecapFeedbackRows(draft, describeWork)" in recap_source assert 'class="today-recap-variance"' in recap_source assert "await recap.saveForReplan()" in recap_source - assert "adjustPlan(handoff.actual_minutes)" in recap_source + assert "openWrapUp(handoff.actual_minutes)" in recap_source assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard - assert "openPlanToday(qs('#plan-today'), true, actualMinutes)" in dashboard + assert "todayWrapUpView.open(todayMyWork, actualMinutes)" in dashboard assert ".today-recap-row { grid-template-columns:1fr; }" in css diff --git a/tests/test_today_wrap_up.py b/tests/test_today_wrap_up.py new file mode 100644 index 0000000..1b16339 --- /dev/null +++ b/tests/test_today_wrap_up.py @@ -0,0 +1,93 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).parents[1] +TODAY_WRAP_UP = ROOT / "frontend" / "today-wrap-up.js" + + +def run_node(script: str): + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_wrap_up_schedules_selected_work_for_tomorrow_and_leaves_other_work_in_order(): + script = f""" +const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))}); +const items = [ + {{kind:'issue', repository:'stackchain/dashboard', number:1, title:'First'}}, + {{kind:'issue', repository:'stackchain/dashboard', number:2, title:'Second'}}, + {{kind:'pull', repository:'stackchain/dashboard', number:3, title:'Third'}}, +]; +const ids = items.map(item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':'); +const today = [...ids]; +const later = {{}}; +const todayOps = []; +const laterOps = []; +const todayWork = {{ + identity:item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':', + read:() => [...today], + contains:item => today.includes((item.kind || 'work') + ':' + item.repository + ':' + item.number + ':'), + remove:item => {{ + const id=(item.kind || 'work') + ':' + item.repository + ':' + item.number + ':'; + const index=today.indexOf(id); if(index < 0) return false; today.splice(index,1); return true; + }}, +}}; +const laterWork = {{ + identity:todayWork.identity, + presetUntil:preset => new Date('2026-08-17T09:00:00-04:00'), + defer:(item, until) => {{ later[todayWork.identity(item)] = until.toISOString(); laterOps.push(todayWork.identity(item)); return 'deferred'; }}, +}}; +const todaySync = {{enqueue:(action,id) => {{todayOps.push([action,id]); return true;}}, flush:()=>Promise.resolve(true)}}; +const wrap = createWrapUp({{todayWork,laterWork,todaySync}}); +wrap.open(items); +wrap.choose(ids[1], true); +const result = await wrap.finish(); +process.stdout.write(JSON.stringify({{result,today,later,todayOps,laterOps}})); +""" + output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})") + assert output["result"] == {"scheduled": 1, "left": 2, "wake_at": "2026-08-17T13:00:00.000Z"} + assert output["today"] == ["issue:stackchain/dashboard:1:", "pull:stackchain/dashboard:3:"] + assert output["later"] == {"issue:stackchain/dashboard:2:": "2026-08-17T13:00:00.000Z"} + assert output["todayOps"] == [["remove", "issue:stackchain/dashboard:2:"]] + assert output["laterOps"] == ["issue:stackchain/dashboard:2:"] + + +def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_save(): + from src import main + + html = main.FRONTEND_BUILD.dashboard_html + css = (ROOT / "frontend" / "dashboard.css").read_text() + dashboard = (ROOT / "frontend" / "dashboard.js").read_text() + recap = (ROOT / "frontend" / "today-recap.js").read_text() + + assert 'id="today-wrap-up-sheet" role="dialog"' in html + assert 'id="today-wrap-up-items"' in html + assert 'id="finish-today-wrap-up"' in html + assert 'static/today-wrap-up.js' in main.FRONTEND_BUILD.page_sources + assert 'setupTodayWrapUp({' in dashboard + assert 'todayWrapUpView.open' in dashboard + assert 'openWrapUp(handoff.actual_minutes)' in recap + assert '.today-wrap-up-actions button { min-height:44px;' in css + assert '.today-wrap-up-panel' in css and 'overflow-x:hidden' in css + assert 'tests/e2e/test_mobile_today_wrap_up_release.py' in (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text() + + +def test_wrap_up_repeat_confirmation_does_not_repeat_later_or_today_operations(): + script = f""" +const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))}); +const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Ship'}}; +const id='issue:acme/mobile:41:'; +let present=true; let deferred=0; let removed=0; +const todayWork={{identity:()=>id,read:()=>present?[id]:[],contains:()=>present,remove:()=>{{present=false;return true;}}}}; +const laterWork={{presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:()=>{{deferred+=1;return 'deferred';}}}}; +const todaySync={{enqueue:()=>{{removed+=1;return true;}},flush:()=>Promise.resolve(true)}}; +const wrap=createWrapUp({{todayWork,laterWork,todaySync}}); +wrap.open([item]); wrap.choose(id,true); +await wrap.finish(); await wrap.finish(); +process.stdout.write(JSON.stringify({{deferred,removed,present}})); +""" + output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})") + assert output == {"deferred": 1, "removed": 1, "present": False}