feat: schedule unfinished work during Today wrap-up (Closes #990)
CI / lint (pull_request) Successful in 2m44s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 1m36s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-16 22:07:04 +00:00
1 parent edf5d3fda4
commit 110fd860b3
13 files changed
+367 -13

No files matched your search

@@ -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)
+2 -1
View File
@@ -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
+1
View File
@@ -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",
+3 -3
View File
@@ -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 &amp; adjust plan" in html
assert "Save recap &amp; 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
+93
View File
@@ -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}