feat: plan the next seven days on mobile (Closes #1177)
This commit is contained in:
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)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user