feat: reschedule active Today work into Week Ahead (Closes #1228)
This commit is contained in:
1 parent
c63df3c46d
commit
9d8a362583
15 files changed
+718
-15
No files matched your search
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Today to 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_reschedules_active_today_into_week_ahead(
|
||||
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()
|
||||
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}, timezone_id="UTC"
|
||||
)
|
||||
page_errors: list[str] = []
|
||||
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Reschedule 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")
|
||||
|
||||
plan_date = (date.today() + timedelta(days=2)).isoformat()
|
||||
saved_week = page.evaluate(
|
||||
"""async ({planDate}) => {
|
||||
const response=await fetch('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({base_revision:0,timezone:'UTC',days:[{
|
||||
plan_date:planDate,ids:[],capacity_minutes:90,estimates:{}
|
||||
}]})});
|
||||
return {status:response.status,body:await response.json()};
|
||||
}""",
|
||||
{"planDate": plan_date},
|
||||
)
|
||||
assert saved_week["status"] == 200
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
page.locator("#plan-today-available").fill("90")
|
||||
page.locator("#plan-today-available").press("Tab")
|
||||
page.locator("#build-today-plan").click()
|
||||
missing = page.locator("[data-plan-missing-estimate]")
|
||||
for _ in range(2):
|
||||
missing.nth(0).fill("30")
|
||||
missing.nth(0).press("Tab")
|
||||
save_and_start = page.locator("#save-and-start-today")
|
||||
expect(save_and_start).to_be_enabled()
|
||||
save_and_start.click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden(timeout=10_000)
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
page.locator("#close-issue-sheet").click()
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
launcher = page.locator("[data-work-session-reschedule-week]")
|
||||
page.locator("[data-mobile-today-more]").click()
|
||||
expect(launcher).to_be_visible()
|
||||
launcher_bounds = launcher.bounding_box()
|
||||
assert launcher_bounds and launcher_bounds["height"] >= 44
|
||||
launcher.click()
|
||||
dialog = page.locator("#today-week-reschedule")
|
||||
expect(dialog).to_be_visible()
|
||||
days = page.locator("#today-week-reschedule-days button")
|
||||
try:
|
||||
expect(days).to_have_count(7)
|
||||
except AssertionError as error:
|
||||
raise AssertionError({
|
||||
"status": page.locator("#today-week-reschedule-status").text_content(),
|
||||
"page_errors": page_errors,
|
||||
"today": page.evaluate("async()=>await (await fetch('api/v1/today')).json()"),
|
||||
"week": page.evaluate("async()=>await (await fetch('api/v1/week')).json()"),
|
||||
}) from error
|
||||
for control in [*days.all(), page.locator("#cancel-today-week-reschedule"), page.locator("#confirm-today-week-reschedule")]:
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator("#cancel-today-week-reschedule").click()
|
||||
expect(dialog).to_be_hidden()
|
||||
expect(launcher).to_be_focused()
|
||||
|
||||
launcher.click()
|
||||
destination = page.locator(
|
||||
f'#today-week-reschedule-days button[data-plan-date="{plan_date}"]'
|
||||
)
|
||||
destination.click()
|
||||
page.locator("#confirm-today-week-reschedule").click()
|
||||
expect(dialog).to_be_hidden()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Polish desktop filters")
|
||||
week = page.evaluate("async()=>await (await fetch('api/v1/week')).json()")
|
||||
today = page.evaluate("async()=>await (await fetch('api/v1/today')).json()")
|
||||
assert week["days"][0]["plan_date"] == plan_date
|
||||
assert len(week["days"][0]["ids"]) == 1
|
||||
assert len(today["ids"]) == 1
|
||||
assert not page_errors
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=2)
|
||||
@@ -1331,6 +1331,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/week-calendar.js",
|
||||
"/dashboard/static/week-calendar-import.js",
|
||||
"/dashboard/static/week-plan.js",
|
||||
"/dashboard/static/today-week-reschedule.js",
|
||||
"/dashboard/static/search-week-plan.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/today-rollover.js",
|
||||
|
||||
@@ -311,7 +311,7 @@ def test_mobile_settings_and_launch_route_wire_start_day_into_existing_promotion
|
||||
assert 'id="device-setup-start-day"' in html
|
||||
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 "window.location.hash !== '#/my-work/start-day'" in dashboard
|
||||
assert "weekFlow.promote(plan).finally(() =>" in dashboard
|
||||
assert "onRemotePlan: async plan =>" not in dashboard
|
||||
assert "openMobileStartDay()" in dashboard
|
||||
@@ -0,0 +1,109 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).parents[1] / "frontend" / "today-week-reschedule.js"
|
||||
INDEX = CONTROLLER.parent / "index.html"
|
||||
CSS = CONTROLLER.parent / "dashboard.css"
|
||||
DASHBOARD = CONTROLLER.parent / "dashboard.js"
|
||||
BUNDLE = CONTROLLER.parents[1] / "src" / "frontend_bundle.py"
|
||||
|
||||
|
||||
def run_controller(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createReschedule = 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_reschedule_controller_reviews_load_and_atomically_confirms_selected_day():
|
||||
result = run_controller("""
|
||||
const requests=[];
|
||||
const weekState={revision:7,offline_snapshot:false,days:[]};
|
||||
const review={days:[
|
||||
{plan_date:'2026-08-24',label:'Mon, Aug 24',ids:['one'],planned_minutes:30,capacity_minutes:60,overloaded:false},
|
||||
{plan_date:'2026-08-25',label:'Tue, Aug 25',ids:[],planned_minutes:0,capacity_minutes:90,overloaded:false}
|
||||
]};
|
||||
const week={load:async()=>weekState,review:()=>review,adopt:value=>{weekState.revision=value.revision;weekState.days=value.days;}};
|
||||
const api=async(url,options)=>{
|
||||
if(url==='api/v1/today')return {revision:4,ids:['active','other'],estimates:{active:45}};
|
||||
requests.push({url,body:JSON.parse(options.body)});return {
|
||||
today:{revision:5,ids:['other'],capacity_minutes:120,estimates:{other:20}},
|
||||
week:{revision:8,timezone:'UTC',days:[{plan_date:'2026-08-25',ids:['active'],capacity_minutes:90,estimates:{active:45}}]}
|
||||
};};
|
||||
let adoptedToday=null;
|
||||
const controller=createReschedule({week,api,getToday:()=>({revision:4,ids:['active','other'],estimates:{active:45}}),
|
||||
adoptToday:value=>{adoptedToday=value;},operationId:()=> 'move-active'});
|
||||
const opened=await controller.open('active');
|
||||
const confirmed=await controller.confirm('2026-08-25',45);
|
||||
console.log(JSON.stringify({opened,confirmed,requests,adoptedToday,weekState}));
|
||||
""")
|
||||
|
||||
assert result["opened"]["estimate_minutes"] == 45
|
||||
assert result["opened"]["days"][0]["load"] == "30 / 60 min"
|
||||
assert result["opened"]["days"][1]["eligible"] is True
|
||||
assert result["requests"] == [{
|
||||
"url": "api/v1/week/reschedule",
|
||||
"body": {
|
||||
"operation_id": "move-active",
|
||||
"identity": "active",
|
||||
"estimate_minutes": 45,
|
||||
"plan_date": "2026-08-25",
|
||||
"today_revision": 4,
|
||||
"week_revision": 7,
|
||||
"allow_over_capacity": False,
|
||||
},
|
||||
}]
|
||||
assert result["adoptedToday"]["ids"] == ["other"]
|
||||
assert result["weekState"]["revision"] == 8
|
||||
assert result["confirmed"]["today"]["revision"] == 5
|
||||
|
||||
|
||||
def test_reschedule_controller_blocks_offline_full_and_overloaded_days_without_mutation():
|
||||
result = run_controller("""
|
||||
let calls=0;
|
||||
const days=[
|
||||
{plan_date:'2026-08-24',label:'Mon',ids:['1','2','3','4','5'],planned_minutes:50,capacity_minutes:60},
|
||||
{plan_date:'2026-08-25',label:'Tue',ids:['1'],planned_minutes:50,capacity_minutes:60}
|
||||
];
|
||||
const week={load:async()=>({revision:2,offline_snapshot:false}),review:()=>({days}),adopt:()=>{}};
|
||||
const api=async url=>{if(url==='api/v1/today')return {revision:3,ids:['active'],estimates:{active:30}};calls++;};
|
||||
const controller=createReschedule({week,api,getToday:()=>({revision:3,ids:['active'],estimates:{active:30}})});
|
||||
const opened=await controller.open('active');
|
||||
let fullError='',overError='';
|
||||
try{await controller.confirm('2026-08-24',30);}catch(error){fullError=error.message;}
|
||||
try{await controller.confirm('2026-08-25',30);}catch(error){overError=error.message;}
|
||||
const offline=createReschedule({week:{load:async()=>({revision:2,offline_snapshot:true}),review:()=>({days})},api,getToday:()=>({revision:3,ids:['active'],estimates:{active:30}})});
|
||||
let offlineError='';try{await offline.open('active');}catch(error){offlineError=error.message;}
|
||||
console.log(JSON.stringify({opened,fullError,overError,offlineError,calls}));
|
||||
""")
|
||||
|
||||
assert result["opened"]["days"][0]["eligible"] is False
|
||||
assert result["fullError"] == "That Week Ahead day already has five items."
|
||||
assert result["overError"] == "That move exceeds the day's capacity. Confirm overload before rescheduling."
|
||||
assert result["offlineError"] == "Reconnect before rescheduling Today into Week Ahead."
|
||||
assert result["calls"] == 0
|
||||
|
||||
|
||||
def test_mobile_active_today_reschedule_dialog_is_touch_safe_and_wired_into_release_bundle():
|
||||
index = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
bundle = BUNDLE.read_text()
|
||||
|
||||
assert 'data-work-session-reschedule-week' in index
|
||||
assert 'id="today-week-reschedule"' in index
|
||||
assert 'id="today-week-reschedule-days"' in index
|
||||
assert 'id="today-week-reschedule-estimate"' in index
|
||||
assert 'id="confirm-today-week-reschedule"' in index
|
||||
assert 'id="cancel-today-week-reschedule"' in index
|
||||
assert '.today-week-reschedule-days button { min-height:44px;' in css
|
||||
assert '.today-week-reschedule-panel { width:min(100%,560px);' in css
|
||||
assert 'x:currentTodayProgressTarget' in dashboard
|
||||
assert "runTodayTransition('next')" in dashboard
|
||||
assert '"static/today-week-reschedule.js"' in bundle
|
||||
@@ -386,3 +386,144 @@ async def test_week_api_round_trip_and_conflict_contract(monkeypatch, tmp_path):
|
||||
await main.replace_week_plan(payload)
|
||||
assert raised.value.status_code == 409
|
||||
assert raised.value.detail == {"code": "week_changed", "snapshot": saved}
|
||||
|
||||
|
||||
def test_reschedule_today_item_atomically_moves_it_into_selected_week_day(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"s" * 32)
|
||||
active_id = "issue:stackchain/dashboard:1228:"
|
||||
other_id = "issue:stackchain/dashboard:1229:"
|
||||
today = store.apply("timmy", "add-active", "add", active_id)
|
||||
today = store.apply("timmy", "add-other", "add", other_id)
|
||||
week = store.replace_week(
|
||||
"timmy",
|
||||
base_revision=0,
|
||||
timezone="UTC",
|
||||
days=[{
|
||||
"plan_date": "2026-08-24",
|
||||
"ids": [active_id],
|
||||
"capacity_minutes": 120,
|
||||
"estimates": {active_id: 30},
|
||||
}, {
|
||||
"plan_date": "2026-08-25",
|
||||
"ids": [],
|
||||
"capacity_minutes": 90,
|
||||
"estimates": {},
|
||||
}],
|
||||
)
|
||||
|
||||
result = store.reschedule_today_to_week(
|
||||
"timmy",
|
||||
operation_id="reschedule-active-2026-08-25",
|
||||
identity=active_id,
|
||||
estimate_minutes=45,
|
||||
plan_date="2026-08-25",
|
||||
today_revision=today["revision"],
|
||||
week_revision=week["revision"],
|
||||
)
|
||||
|
||||
assert result["today"]["ids"] == [other_id]
|
||||
assert result["week"]["days"] == [{
|
||||
"plan_date": "2026-08-24", "ids": [], "capacity_minutes": 120, "estimates": {},
|
||||
}, {
|
||||
"plan_date": "2026-08-25", "ids": [active_id], "capacity_minutes": 90,
|
||||
"estimates": {active_id: 45},
|
||||
}]
|
||||
assert store.get("timmy") == result["today"]
|
||||
assert store.get_week("timmy") == result["week"]
|
||||
|
||||
|
||||
def test_reschedule_today_item_is_idempotent_and_rejects_stale_plans_without_partial_write(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"i" * 32)
|
||||
identity = "issue:stackchain/dashboard:1228:"
|
||||
today = store.apply("timmy", "add-active", "add", identity)
|
||||
week = store.replace_week(
|
||||
"timmy", base_revision=0, timezone="UTC",
|
||||
days=[{
|
||||
"plan_date": "2026-08-25", "ids": [],
|
||||
"capacity_minutes": 60, "estimates": {},
|
||||
}],
|
||||
)
|
||||
arguments = {
|
||||
"operation_id": "same-operation", "identity": identity,
|
||||
"estimate_minutes": 30, "plan_date": "2026-08-25",
|
||||
"today_revision": today["revision"], "week_revision": week["revision"],
|
||||
}
|
||||
|
||||
moved = store.reschedule_today_to_week("timmy", **arguments)
|
||||
assert store.reschedule_today_to_week("timmy", **arguments) == moved
|
||||
|
||||
another = store.apply("timmy", "add-another", "add", "issue:stackchain/dashboard:1230:")
|
||||
with pytest.raises(TodayPromotionConflict) as conflict:
|
||||
store.reschedule_today_to_week(
|
||||
"timmy", **{**arguments, "operation_id": "stale-operation", "identity": another["ids"][-1]}
|
||||
)
|
||||
assert conflict.value.today == another
|
||||
assert store.get_week("timmy") == moved["week"]
|
||||
|
||||
|
||||
def test_reschedule_today_item_requires_explicit_capacity_overload_confirmation(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"o" * 32)
|
||||
identity = "issue:stackchain/dashboard:1228:"
|
||||
today = store.apply("timmy", "add-active", "add", identity)
|
||||
week = store.replace_week(
|
||||
"timmy", base_revision=0, timezone="UTC",
|
||||
days=[{
|
||||
"plan_date": "2026-08-25", "ids": ["issue:stackchain/dashboard:1:"],
|
||||
"capacity_minutes": 60, "estimates": {"issue:stackchain/dashboard:1:": 45},
|
||||
}],
|
||||
)
|
||||
arguments = {
|
||||
"identity": identity, "estimate_minutes": 30, "plan_date": "2026-08-25",
|
||||
"today_revision": today["revision"], "week_revision": week["revision"],
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="explicit overload confirmation"):
|
||||
store.reschedule_today_to_week(
|
||||
"timmy", operation_id="unconfirmed", allow_over_capacity=False, **arguments
|
||||
)
|
||||
assert store.get("timmy") == today
|
||||
assert store.get_week("timmy") == week
|
||||
|
||||
moved = store.reschedule_today_to_week(
|
||||
"timmy", operation_id="confirmed", allow_over_capacity=True, **arguments
|
||||
)
|
||||
assert moved["week"]["days"][0]["ids"][-1] == identity
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_week_reschedule_api_returns_atomic_snapshots_and_conflict_recovery(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"a" * 32)
|
||||
identity = "issue:stackchain/dashboard:1228:"
|
||||
today = store.apply("timmy", "add-active", "add", identity)
|
||||
week = store.replace_week(
|
||||
"timmy", base_revision=0, timezone="UTC",
|
||||
days=[{
|
||||
"plan_date": "2026-08-25", "ids": [],
|
||||
"capacity_minutes": 60, "estimates": {},
|
||||
}],
|
||||
)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
payload = main.WeekReschedule(
|
||||
operation_id="move-active", identity=identity, estimate_minutes=30,
|
||||
plan_date="2026-08-25", today_revision=today["revision"],
|
||||
week_revision=week["revision"],
|
||||
)
|
||||
|
||||
moved = await main.reschedule_today_to_week(payload)
|
||||
assert moved["today"]["ids"] == []
|
||||
assert moved["week"]["days"][0]["ids"] == [identity]
|
||||
|
||||
stale = main.WeekReschedule(
|
||||
operation_id="stale", identity="issue:stackchain/dashboard:1229:", estimate_minutes=30,
|
||||
plan_date="2026-08-25", today_revision=today["revision"], week_revision=week["revision"],
|
||||
)
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.reschedule_today_to_week(stale)
|
||||
assert raised.value.status_code == 409
|
||||
assert raised.value.detail["code"] == "today_changed"
|
||||
assert raised.value.detail["today"] == moved["today"]
|
||||
assert raised.value.detail["week"] == moved["week"]
|
||||
@@ -542,7 +542,7 @@ def test_dashboard_routes_week_overview_controls_through_existing_detail_flow_wi
|
||||
|
||||
workflow_mount = dashboard.split("const weekFlow=createWeekPlanWorkflow({", 1)[1].split("});", 1)[0]
|
||||
assert "openItem:openRoutedWork" in workflow_mount
|
||||
assert "t:()=>latestTodayPlan" in workflow_mount
|
||||
assert "t:v=>v?latestTodayPlan=v:latestTodayPlan" in workflow_mount
|
||||
|
||||
assert ".week-review-item-open" in css
|
||||
rule = css.split(".week-review-item-open", 1)[1].split("}", 1)[0]
|
||||
@@ -1241,7 +1241,7 @@ def test_mobile_week_ahead_review_is_rendered_touch_safe_and_confirmed_explicitl
|
||||
assert 'id="week-review-days"' in index
|
||||
assert 'id="week-review-duplicates"' in index
|
||||
assert 'id="confirm-week-plan"' in index
|
||||
assert "setReviewMode:value=>qs('#plan-today-sheet').classList.toggle('week-review-mode',value)" in dashboard
|
||||
assert "setReviewMode:v=>qs('#plan-today-sheet').classList.toggle('week-review-mode',v)" in dashboard
|
||||
assert "weekFlow.confirm()" in dashboard
|
||||
assert ".week-review-day.is-overloaded" in css
|
||||
assert ".week-review-day button { min-height:44px;" in css
|
||||
|
||||
Reference in new issue
Block a user