feat: reschedule active Today work into Week Ahead (Closes #1228)
CI / lint (pull_request) Successful in 3m33s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 4m57s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-21 16:59:40 +00:00
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)
+1
View File
@@ -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",
+1 -1
View File
@@ -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
+109
View File
@@ -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
+141
View File
@@ -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"]
+2 -2
View File
@@ -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