feat: plan the next seven days on mobile (Closes #1177)
CI / lint (pull_request) Successful in 3m39s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 4m42s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-20 11:17:41 +00:00
1 parent 880d1c4a1f
commit 7d8db5f459
16 files changed
+707 -42

No files matched your search

@@ -0,0 +1,75 @@
from __future__ import annotations
import json
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged Week Ahead 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_plans_seven_touch_safe_mobile_dates(
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))
thread = threading.Thread(target=fake.serve_forever, daemon=True)
thread.start()
saved: list[dict] = []
try:
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
page = browser.new_page(viewport={"width": width, "height": height})
def week_route(route):
if route.request.method == "PUT":
body = json.loads(route.request.post_data or "{}")
saved.append(body)
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"revision": body["base_revision"] + 1,
"timezone": body["timezone"], "days": body["days"],
}))
return
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"revision": 0, "timezone": None, "days": [],
}))
page.route("**/api/v1/week", week_route)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Week Ahead 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.locator('[data-mobile-task="queues"]').click()
page.locator('[data-mobile-queue="week"]').click()
expect(page.locator("#plan-today-title")).to_have_text("Plan Week Ahead")
dates = page.locator("#week-plan-dates button")
expect(dates).to_have_count(7)
expect(dates.first).to_have_attribute("aria-current", "date")
for index in range(7):
bounds = dates.nth(index).bounding_box()
assert bounds and bounds["height"] >= 44
dates.nth(2).click()
expect(dates.nth(2)).to_have_attribute("aria-current", "date")
page.locator("#save-today-plan").click()
expect(page.locator("#plan-today-sheet")).to_be_hidden()
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
assert saved and len(saved[-1]["days"]) == 1
assert saved[-1]["days"][0]["plan_date"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()
finally:
fake.shutdown()
fake.server_close()
thread.join(timeout=5)
+1
View File
@@ -68,6 +68,7 @@ def test_release_promotion_waits_for_packaged_mobile_journeys():
"tests/e2e/test_mobile_today_wrap_up_release.py "
"tests/e2e/test_mobile_today_summary_release.py "
"tests/e2e/test_mobile_tomorrow_conflict_release.py "
"tests/e2e/test_mobile_week_ahead_release.py "
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
) in browser
assert "needs: [lint, build-release, browser-journey]" in release
+1
View File
@@ -1321,6 +1321,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/plan-today-readiness.js",
"/dashboard/static/plan-today-preview.js",
"/dashboard/static/tomorrow-plan.js",
"/dashboard/static/week-plan.js",
"/dashboard/static/today-sync.js",
"/dashboard/static/today-rollover.js",
"/dashboard/static/update-ownership.js",
+1 -1
View File
@@ -309,6 +309,6 @@ def test_mobile_settings_and_launch_route_wire_start_day_into_existing_promotion
assert "startDayControl:qs('#push-start-day')" in dashboard
assert "startDayHour:qs('#push-start-day-hour')" in dashboard
assert "window.location.hash === '#/my-work/start-day'" in dashboard
assert "promoteTomorrowIfDue(plan).finally(() =>" in dashboard
assert "weekWorkflow.promote(plan).finally(() =>" in dashboard
assert "onRemotePlan: async plan =>" not in dashboard
assert "openMobileStartDay()" in dashboard
+1 -1
View File
@@ -419,5 +419,5 @@ def test_tomorrow_plan_has_a_touch_safe_mobile_entry_and_reuses_the_ordered_plan
assert "qs('#plan-tomorrow').addEventListener('click'" in dashboard
assert "planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)" in dashboard
assert "tomorrowPlan.load()" in dashboard
assert "tomorrowPlan.promote(plan.revision)" in dashboard
assert "weekWorkflow.promote(plan)" in dashboard
assert "tomorrowPlan.startLifecycle" in dashboard
+112
View File
@@ -0,0 +1,112 @@
import sqlite3
from datetime import datetime, timezone
import pytest
from src import main
from src.today_store import TodayStore, WeekPlanConflict
def sample_days():
return [
{
"plan_date": "2026-08-21",
"ids": ["issue:secret/repo:3:", "issue:secret/repo:2:"],
"capacity_minutes": 150,
"estimates": {"issue:secret/repo:3:": 60, "issue:secret/repo:2:": 45},
},
{
"plan_date": "2026-08-23",
"ids": ["issue:secret/repo:5:"],
"capacity_minutes": 90,
"estimates": {"issue:secret/repo:5:": 30},
},
]
def test_week_plan_is_encrypted_account_scoped_revisioned_and_preserves_other_dates(tmp_path):
path = tmp_path / "today.sqlite3"
store = TodayStore(path, encryption_key=b"w" * 32)
saved = store.replace_week(
"Timmy", base_revision=0, days=sample_days(), timezone="America/Los_Angeles"
)
assert saved == {
"revision": 1,
"timezone": "America/Los_Angeles",
"days": sample_days(),
}
assert store.get_week("timmy") == saved
assert store.get_week("alexander") == {"revision": 0, "timezone": None, "days": []}
retained = path.read_bytes()
assert b"issue:secret/repo" not in retained
assert b"America/Los_Angeles" not in retained
changed = [dict(sample_days()[0], capacity_minutes=180), sample_days()[1]]
updated = store.replace_week("timmy", base_revision=1, days=changed, timezone="America/Los_Angeles")
assert updated["days"][1] == saved["days"][1]
with pytest.raises(WeekPlanConflict) as conflict:
store.replace_week("timmy", base_revision=1, days=[], timezone="UTC")
assert conflict.value.snapshot == updated
def test_week_promotion_moves_only_due_date_to_today_exactly_once(tmp_path):
store = TodayStore(
tmp_path / "today.sqlite3",
encryption_key=b"p" * 32,
clock=lambda: datetime(2026, 8, 22, 8, tzinfo=timezone.utc).timestamp(),
)
today = store.apply("timmy", "today", "add", "issue:r:1:")
week = store.replace_week("timmy", base_revision=0, days=sample_days(), timezone="UTC")
promoted = store.promote_week(
"timmy", promotion_id="week-2026-08-21-r1", week_revision=week["revision"],
plan_date="2026-08-21", today_revision=today["revision"],
)
replay = store.promote_week(
"timmy", promotion_id="week-2026-08-21-r1", week_revision=week["revision"],
plan_date="2026-08-21", today_revision=today["revision"],
)
assert replay == promoted
assert promoted["ids"] == sample_days()[0]["ids"]
remaining = store.get_week("timmy")
assert remaining["revision"] == 2
assert remaining["days"] == [sample_days()[1]]
def test_existing_tomorrow_plan_migrates_into_week_without_losing_planning_data(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"m" * 32)
tomorrow = store.replace_tomorrow(
"timmy", base_revision=0, ids=["issue:r:9:"], capacity_minutes=75,
estimates={"issue:r:9:": 45}, plan_date="2026-08-21", timezone="UTC",
)
week = store.get_week("timmy")
assert week == {
"revision": 1, "timezone": "UTC",
"days": [{key: tomorrow[key] for key in ("plan_date", "ids", "capacity_minutes", "estimates")}],
}
assert store.get_tomorrow("timmy") == {
"revision": 2, "ids": [], "capacity_minutes": None, "estimates": {}
}
@pytest.mark.anyio
async def test_week_api_round_trip_and_conflict_contract(monkeypatch, tmp_path):
async def user():
return {"login": "timmy"}
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"a" * 32)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "_today_store", lambda: store)
payload = main.WeekPlanUpdate(base_revision=0, days=sample_days(), timezone="UTC")
saved = await main.replace_week_plan(payload)
assert await main.get_week_plan() == saved
with pytest.raises(main.HTTPException) as raised:
await main.replace_week_plan(payload)
assert raised.value.status_code == 409
assert raised.value.detail == {"code": "week_changed", "snapshot": saved}
+107
View File
@@ -0,0 +1,107 @@
import json
import subprocess
from pathlib import Path
FRONTEND = Path(__file__).parents[1] / "frontend"
CONTROLLER = FRONTEND / "week-plan.js"
def run_controller(scenario: str) -> dict:
harness = f"""
const createWeekPlan = require({json.dumps(str(CONTROLLER))});
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
return json.loads(completed.stdout)
def test_week_controller_lists_seven_local_dates_and_saves_one_without_changing_other_days():
result = run_controller("""
const requests=[];
const initial={revision:4,timezone:'UTC',days:[
{plan_date:'2026-08-21',ids:['issue:r:1:'],capacity_minutes:60,estimates:{'issue:r:1:':30}},
{plan_date:'2026-08-23',ids:['issue:r:3:'],capacity_minutes:90,estimates:{'issue:r:3:':45}}
]};
const fetchJson=async(url,options={})=>{
requests.push({url,method:options.method||'GET',body:options.body?JSON.parse(options.body):null});
if(!options.method)return initial;
return {revision:5,timezone:'UTC',days:JSON.parse(options.body).days};
};
const week=createWeekPlan({fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
await week.load();
const dates=week.dates();
const saved=await week.saveDay('2026-08-22',{ids:['issue:r:2:'],capacity_minutes:120,estimates:{'issue:r:2:':50}});
console.log(JSON.stringify({dates,saved,requests}));
""")
assert [item["date"] for item in result["dates"]] == [
"2026-08-21", "2026-08-22", "2026-08-23", "2026-08-24",
"2026-08-25", "2026-08-26", "2026-08-27",
]
assert [day["plan_date"] for day in result["saved"]["days"]] == [
"2026-08-21", "2026-08-22", "2026-08-23"
]
assert result["requests"][1]["body"]["base_revision"] == 4
assert result["requests"][1]["body"]["days"][0]["ids"] == ["issue:r:1:"]
assert result["requests"][1]["body"]["days"][2]["ids"] == ["issue:r:3:"]
def test_week_controller_preserves_both_versions_when_another_device_changes_the_week():
result = run_controller("""
let calls=0;
const remote={revision:8,timezone:'UTC',days:[{plan_date:'2026-08-22',ids:['server'],capacity_minutes:60,estimates:{}}]};
const fetchJson=async(_url,options={})=>{
calls+=1;
if(options.method==='PUT'){const error=new Error('changed');error.status=409;throw error;}
return calls===1?{revision:7,timezone:'UTC',days:[]}:remote;
};
const week=createWeekPlan({fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
await week.load();
try { await week.saveDay('2026-08-21',{ids:['phone'],capacity_minutes:90,estimates:{}}); } catch(_error) {}
console.log(JSON.stringify({conflict:week.conflict(),state:week.state()}));
""")
assert result["conflict"]["local"]["days"][0]["ids"] == ["phone"]
assert result["conflict"]["remote"]["days"][0]["ids"] == ["server"]
assert result["state"]["revision"] == 7
def test_week_controller_promotes_only_the_earliest_due_plan_with_stable_identity():
result = run_controller("""
const requests=[];
const week=createWeekPlan({fetchJson:async(url,options={})=>{
requests.push({url,body:options.body?JSON.parse(options.body):null});
return {revision:9,ids:['oldest']};
},localDate:()=> '2026-08-23',timeZone:()=> 'UTC'});
week.adopt({revision:6,timezone:'UTC',days:[
{plan_date:'2026-08-21',ids:['oldest'],capacity_minutes:60,estimates:{}},
{plan_date:'2026-08-22',ids:['newer'],capacity_minutes:60,estimates:{}}
]});
const promoted=await week.promote(3);
console.log(JSON.stringify({promoted,requests}));
""")
assert result["requests"] == [{
"url": "api/v1/week/promote",
"body": {
"promotion_id": "week-2026-08-21-r6",
"week_revision": 6,
"plan_date": "2026-08-21",
"today_revision": 3,
},
}]
def test_mobile_week_ahead_entry_and_date_strip_are_touch_safe():
index = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
dashboard = (FRONTEND / "dashboard.js").read_text()
assert 'data-mobile-queue="week"' in index
assert 'id="week-plan-dates"' in index
assert '<script src="static/week-plan.js"></script>' in index
assert "name === 'week' ? openWeekPlanner" in dashboard
assert "weekWorkflow.save" in dashboard
assert ".week-plan-dates button { min-height:44px;" in css
assert "overflow-x:auto" in css