diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 782f446..089de0d 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -267,6 +267,19 @@ textarea { resize: vertical; min-height: 120px; } .week-plan-dates[hidden] { display:none; } .week-plan-dates button { min-height:44px; min-width:86px; flex:0 0 auto; padding:6px 10px; } .week-plan-dates button[aria-current="date"] { color:#bfdbfe; background:#17365a; border-color:#60a5fa; } +.today-week-reschedule { box-sizing:border-box; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:inherit; } +.today-week-reschedule::backdrop { background:rgba(5,12,21,.82); } +.today-week-reschedule-panel { width:min(100%,560px); box-sizing:border-box; min-height:100%; margin-left:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; overflow-x:hidden; } +.today-week-reschedule-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } +.today-week-reschedule-panel h2, .today-week-reschedule-panel header p { margin-top:0; } +.today-week-reschedule-panel input { box-sizing:border-box; width:100%; min-height:44px; margin:6px 0 10px; font-size:16px; } +.today-week-reschedule-days { display:grid; gap:8px; margin:14px 0; } +.today-week-reschedule-days button { min-height:44px; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; text-align:left; overflow-wrap:anywhere; } +.today-week-reschedule-days button[aria-checked="true"] { border-color:#60a5fa; background:#17365a; color:#eff6ff; } +.today-week-reschedule-days button:disabled { opacity:.58; } +#cancel-today-week-reschedule { min-height:44px; } +#confirm-today-week-reschedule { position:sticky; bottom:0; width:100%; min-height:48px; } +#today-week-reschedule-status { min-height:1.4em; color:#fde68a; } .back-to-week-review { width:100%; min-height:44px; margin:4px 0 12px; } .tomorrow-conflict-review { margin-top:14px; } .tomorrow-conflict-plans { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 25f4fe0..3580300 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -455,9 +455,10 @@ const weekItem=id=>[...todayMyWork,...activeMyWork].find(item=>todayWork.identity(item)===id); const weekFlow=createWeekPlanWorkflow({controller:weekPlan,qs, getItem:weekItem,openItem:openRoutedWork, - openPlanner:openPlanToday,setReviewMode:value=>qs('#plan-today-sheet').classList.toggle('week-review-mode',value), + openPlanner:openPlanToday,setReviewMode:v=>qs('#plan-today-sheet').classList.toggle('week-review-mode',v), escapeHtml,escapeAttribute:escAttr,todayWork, - t:()=>latestTodayPlan,r:refreshMyWorkView,w:warmTodayOffline}); + t:v=>v?latestTodayPlan=v:latestTodayPlan,r:refreshMyWorkView,w:warmTodayOffline, + x:currentTodayProgressTarget}); const weekCalendar=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekItem,escapeHtml,escapeAttribute:escAttr, onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();}, }); @@ -474,9 +475,8 @@ onRemotePlan: plan => { if (!planningOwnerLogin) return; latestTodayPlan = plan; - const startDayLaunch = window.location.hash === '#/my-work/start-day'; weekFlow.promote(plan).finally(() => { - if (!startDayLaunch) return; + if (window.location.hash !== '#/my-work/start-day') return; window.history.replaceState({}, '', '#/my-work/today'); openMobileStartDay(); }); @@ -484,8 +484,8 @@ capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {}, }); - const reviewState = todayRollover.reviewState(plan); - if (!['stale', 'legacy'].includes(reviewState)) { + const s = todayRollover.reviewState(plan); + if (!['stale', 'legacy'].includes(s)) { rolloverReviewPlan = null; qs('#plan-today').textContent = 'Plan Today'; return; diff --git a/frontend/index.html b/frontend/index.html index 4b39f8e..4a03c9c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1820,10 +1820,25 @@ Report blocker Take break Adjust remaining plan + Reschedule to Week Ahead End session + + + + Active Today itemReschedule to Week Ahead + Cancel + + Choose a future day after reviewing its current planned load. Today will continue with the next ready item. + + Estimate in minutes + + + Move to Week Ahead & continue + + Active Today itemAdd progress updateCancel @@ -2064,6 +2079,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index cd66125..0129f66 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -127,6 +127,7 @@ const SHELL = [ BASE + 'static/week-calendar.js', BASE + 'static/week-calendar-import.js', BASE + 'static/week-plan.js', + BASE + 'static/today-week-reschedule.js', BASE + 'static/search-week-plan.js', BASE + 'static/today-sync.js', BASE + 'static/today-rollover.js', diff --git a/frontend/today-week-reschedule.js b/frontend/today-week-reschedule.js new file mode 100644 index 0000000..7901b49 --- /dev/null +++ b/frontend/today-week-reschedule.js @@ -0,0 +1,146 @@ +function createTodayWeekReschedule({ + week, + api, + getToday, + adoptToday = () => {}, + operationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()), +} = {}) { + let current = null; + + async function open(identity) { + const today = await api('api/v1/today'); + if (!identity || !today?.ids?.includes(identity)) { + throw new Error('The active Today item changed. Reopen rescheduling.'); + } + const loaded = await week.load(); + if (loaded?.offline_snapshot || loaded?.sync_pending) { + throw new Error('Reconnect before rescheduling Today into Week Ahead.'); + } + const estimate = Number(today.estimates?.[identity]); + const estimateMinutes = Number.isFinite(estimate) && estimate > 0 ? estimate : null; + const days = week.review().days.map(day => { + const ids = (day.ids || []).filter(id => id !== identity); + const planned = Number(day.planned_minutes) || 0; + const existing = Number(day.estimates?.[identity]) || 0; + return { + ...day, + ids, + planned_minutes: Math.max(0, planned - existing), + load: `${Math.max(0, planned - existing)} / ${Number(day.capacity_minutes) || 0} min`, + eligible: ids.length < 5, + }; + }); + current = { + identity, + today_revision: today.revision, + week_revision: loaded.revision, + operation_id: operationId(), + estimate_minutes: estimateMinutes, + days, + }; + return {...current, days:days.map(day => ({...day, ids:[...day.ids]}))}; + } + + async function confirm(planDate, estimateMinutes, {allowOverload = false} = {}) { + if (!current) throw new Error('Open rescheduling before choosing a day.'); + const day = current.days.find(value => value.plan_date === planDate); + if (!day) throw new Error('Choose one of the next seven days.'); + const estimate = Number(estimateMinutes); + if (!Number.isFinite(estimate) || estimate < 5 || estimate > 1440) { + throw new Error('Estimate must be between 5 and 1440 minutes.'); + } + if (!day.eligible) throw new Error('That Week Ahead day already has five items.'); + const capacity = Number(day.capacity_minutes) || 0; + if (capacity && day.planned_minutes + estimate > capacity && !allowOverload) { + throw new Error("That move exceeds the day's capacity. Confirm overload before rescheduling."); + } + const result = await api('api/v1/week/reschedule', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({ + operation_id:current.operation_id, + identity:current.identity, + estimate_minutes:estimate, + plan_date:planDate, + today_revision:current.today_revision, + week_revision:current.week_revision, + allow_over_capacity:allowOverload, + }), + }); + adoptToday(result.today); + week.adopt(result.week); + current = null; + return result; + } + + function cancel() { current = null; } + return {open, confirm, cancel, state:() => current}; +} + +function mountTodayWeekReschedule({ + qs, document=globalThis.document, week, api, getToday, adoptToday, currentTarget, closeActions, + refresh, warm, continueToday, announce, schedule=callback=>requestAnimationFrame(callback), +}={}) { + const dialog=qs('#today-week-reschedule'),daysRoot=qs('#today-week-reschedule-days'); + const estimate=qs('#today-week-reschedule-estimate'),status=qs('#today-week-reschedule-status'); + const confirm=qs('#confirm-today-week-reschedule'),launcher=qs('[data-work-session-reschedule-week]'); + let selectedDate=null,allowOverload=false; + const controller=createTodayWeekReschedule({week,api,getToday,adoptToday}); + function close() { + controller.cancel();selectedDate=null;allowOverload=false; + if(dialog.open)dialog.close();schedule(()=>{ + qs('[data-mobile-today-more]').click();launcher.focus(); + }); + } + function render(opened) { + daysRoot.replaceChildren(...opened.days.map(day=>{ + const button=document.createElement('button'); + button.type='button';button.dataset.planDate=day.plan_date;button.setAttribute('role','radio'); + button.setAttribute('aria-checked','false');button.disabled=!day.eligible; + const label=document.createElement('strong');label.textContent=day.label; + const load=document.createElement('span');load.className='small'; + load.textContent=day.eligible?day.load:'5 items · full';button.append(label,load); + button.addEventListener('click',()=>{ + selectedDate=day.plan_date;allowOverload=false; + daysRoot.querySelectorAll('button').forEach(choice=>choice.setAttribute( + 'aria-checked',String(choice===button) + )); + status.textContent='';confirm.disabled=false;confirm.textContent='Move to Week Ahead & continue'; + }); + return button; + })); + } + launcher.addEventListener('click',async()=>{ + const target=currentTarget(); + if(!target){announce('Start or resume a checkpointed Today item before rescheduling.');return;} + closeActions();status.textContent='Loading Week Ahead…';confirm.disabled=true;dialog.showModal(); + try{ + const opened=await controller.open(target.identity);render(opened); + estimate.value=opened.estimate_minutes||''; + status.textContent=opened.estimate_minutes?'Choose a future day.':'Add an estimate, then choose a future day.'; + schedule(()=>daysRoot.querySelector('button:not(:disabled)')?.focus()); + }catch(error){status.textContent=error.message;} + }); + qs('#cancel-today-week-reschedule').addEventListener('click',close); + dialog.addEventListener('cancel',event=>{event.preventDefault();close();}); + confirm.addEventListener('click',async()=>{ + if(!selectedDate)return; + confirm.disabled=true;status.textContent='Moving Today into Week Ahead…'; + try{ + await controller.confirm(selectedDate,Number(estimate.value),{allowOverload}); + await refresh();warm();dialog.close();announce('Moved to Week Ahead. Continuing Today.'); + await continueToday(); + }catch(error){ + const overload=error.message.includes('Confirm overload');allowOverload=overload; + status.textContent=error.message; + confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue'; + confirm.disabled=false; + } + }); + return {controller,close}; +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = createTodayWeekReschedule; + module.exports.mount = mountTodayWeekReschedule; +} diff --git a/frontend/week-plan.js b/frontend/week-plan.js index 34833b1..3b7899f 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -468,11 +468,11 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,placement,place,pending,flush,conflict,chooseDay,saveMerged, keepLocal,useRemote,promote,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem, item:id=>pendingItems[id]||confirmedItems[id]||null, - offline:()=>offlineSnapshot}; + offline:()=>offlineSnapshot,request:fetchJson}; } function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute, todayWork,refresh:r=()=>{},warm:w=()=>{},today:t=()=>null,r:refresh=r,w:warm=w,t:getTodayPlan=t, - confirmEarly=message=>globalThis.confirm?.(message)??false}={}) { + confirmEarly=message=>globalThis.confirm?.(message)??false,x=null}={}) { let selectedDate=null; let reviewing=false; let overviewing=false; @@ -801,6 +801,16 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla clear(){selectedDate=null;reviewing=false;overviewing=false;editingFromReview=false;reconciliation=null;setReviewMode(false);qs('#week-review').hidden=true; const back=qs('#back-to-week-review');if(back)back.hidden=true;renderDates();}}; if(typeof weekCalendarImport!=='undefined')weekCalendarImport.mount(controller,workflow,qs); + if(x)mountTodayWeekReschedule({ + qs,week:controller,getToday:t,refresh:r,warm:w,api:controller.request,currentTarget:x, + closeActions:()=>qs('#mobile-today-actions').close(), + continueToday:()=>qs('[data-work-session-next]').click(), + announce:message=>{qs('#my-work-action-status').textContent=message;}, + adoptToday:value=>{ + t(value);todayWork.replace(value.ids); + todayWork.replacePlanning({capacity_minutes:value.capacity_minutes??null,estimates:value.estimates||{}}); + }, + }); return workflow; } if(typeof module!=='undefined'&&module.exports){ diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index f863a99..a29ff29 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -36,7 +36,7 @@ FEATURE_SOURCES = { "planning": ( "static/plan-today.js", "static/plan-today-readiness.js", "static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js", - "static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js", + "static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js", ), "today-timer": ( "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", diff --git a/src/main.py b/src/main.py index 265dd63..8e16a6c 100644 --- a/src/main.py +++ b/src/main.py @@ -747,6 +747,16 @@ class WeekPromotion(BaseModel): today_revision: int = Field(ge=0) +class WeekReschedule(BaseModel): + operation_id: str = Field(min_length=1, max_length=100) + identity: str = Field(min_length=1, max_length=500) + estimate_minutes: int = Field(ge=5, le=1440) + plan_date: str = Field(min_length=10, max_length=10) + today_revision: int = Field(ge=0) + week_revision: int = Field(ge=0) + allow_over_capacity: bool = False + + class WeekReconciliation(WeekPromotion): ids: list[str] = Field(max_length=5) capacity_minutes: int | None = Field(default=None, ge=15, le=1440) @@ -1468,7 +1478,7 @@ async def require_operator_session(request: Request, call_next): async def prevent_live_api_caching(request, call_next): response = await call_next(request) path = dashboard_auth.application_path(request) - if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or ( + if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or ( path.startswith("/api/v1/repos/") and path.endswith("/review") ) or path.startswith("/api/v1/notifications") or ( @@ -2857,6 +2867,31 @@ async def promote_week_plan(payload: WeekPromotion): ) +@app.post("/api/v1/week/reschedule") +async def reschedule_today_to_week(payload: WeekReschedule): + login = await _confirmed_login() + try: + return await asyncio.to_thread( + _today_store().reschedule_today_to_week, login, **payload.model_dump() + ) + except WeekPlanConflict as error: + raise HTTPException( + status_code=409, detail={"code": "week_changed", "week": error.snapshot} + ) + except TodayPromotionConflict as error: + raise HTTPException( + status_code=409, + detail={"code": "today_changed", "today": error.today, "week": error.week}, + ) + except ValueError as error: + raise HTTPException(status_code=422, detail=str(error)) + except (OSError, sqlite3.Error, PrivateStateEncryptionError): + raise HTTPException( + status_code=503, detail="Week Ahead rescheduling is unavailable", + headers={"Retry-After": "1"}, + ) + + @app.post("/api/v1/week/start-early") async def start_week_day_early(payload: WeekPromotion): login = await _confirmed_login() diff --git a/src/today_store.py b/src/today_store.py index 6dc3e61..32baf0c 100644 --- a/src/today_store.py +++ b/src/today_store.py @@ -84,7 +84,7 @@ class TodayStore: def _initialize(self) -> None: connection = connect_private_sqlite(self.path, timeout=self.timeout) - if connection.execute("PRAGMA user_version").fetchone()[0] >= 5: + if connection.execute("PRAGMA user_version").fetchone()[0] >= 6: connection.close() return connection.execute("PRAGMA journal_mode=WAL") @@ -189,7 +189,12 @@ class TodayStore: "login TEXT NOT NULL, promotion_id TEXT NOT NULL, result TEXT NOT NULL, " "created_at REAL NOT NULL, PRIMARY KEY (login, promotion_id))" ) - connection.execute("PRAGMA user_version = 5") + connection.execute( + "CREATE TABLE IF NOT EXISTS week_reschedules (" + "login TEXT NOT NULL, operation_id TEXT NOT NULL, result TEXT NOT NULL, " + "created_at REAL NOT NULL, PRIMARY KEY (login, operation_id))" + ) + connection.execute("PRAGMA user_version = 6") connection.commit() connection.close() @@ -568,6 +573,110 @@ class TodayStore: ) return snapshot + def reschedule_today_to_week( + self, login: str, *, operation_id: str, identity: str, estimate_minutes: int, + plan_date: str, today_revision: int, week_revision: int, + allow_over_capacity: bool = False, + ) -> dict: + """Atomically move one current Today item to one Week Ahead date.""" + login = self._normalize_login(login) + if not isinstance(operation_id, str) or not operation_id.strip() or len(operation_id) > 100: + raise ValueError("operation_id is required and bounded") + operation_id = operation_id.strip() + if not isinstance(identity, str) or not identity or len(identity) > 500: + raise ValueError("identity is required and bounded") + if ( + not isinstance(estimate_minutes, int) or isinstance(estimate_minutes, bool) + or estimate_minutes < 5 or estimate_minutes > 1440 + ): + raise ValueError("estimate_minutes must be between 5 and 1440") + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + receipt = connection.execute( + "SELECT result FROM week_reschedules WHERE login = ? AND operation_id = ?", + (login, operation_id), + ).fetchone() + if receipt: + result, _legacy = self._cipher.open( + receipt[0], binding=f"week-reschedule:{login}:{operation_id}" + ) + if not isinstance(result, dict): + raise PrivateStateEncryptionError("private state could not be decrypted") + return result + today_row = connection.execute( + "SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone " + "FROM today_plans WHERE login = ?", (login,), + ).fetchone() + today, _legacy = self._snapshot(today_row, login) + week_row = connection.execute( + "SELECT revision, payload FROM week_plans WHERE login = ?", (login,) + ).fetchone() + week = self._week_snapshot(week_row, login) + if today["revision"] != today_revision or identity not in today["ids"]: + raise TodayPromotionConflict(today, week) + if week["revision"] != week_revision: + raise WeekPlanConflict(week) + + days = [] + for source in week["days"]: + estimates = dict(source.get("estimates", {})) + estimates.pop(identity, None) + days.append({ + **source, + "ids": [item_id for item_id in source.get("ids", []) if item_id != identity], + "estimates": estimates, + }) + destination = next((day for day in days if day["plan_date"] == plan_date), None) + if destination is None: + destination = { + "plan_date": plan_date, "ids": [], + "capacity_minutes": None, "estimates": {}, + } + days.append(destination) + destination["ids"].append(identity) + destination["estimates"][identity] = estimate_minutes + planned_minutes = sum( + int(destination["estimates"].get(item_id, 0)) for item_id in destination["ids"] + ) + if ( + destination.get("capacity_minutes") is not None + and planned_minutes > destination["capacity_minutes"] + and not allow_over_capacity + ): + raise ValueError("explicit overload confirmation is required") + normalized_week = self._normalize_week(days=days, timezone=week["timezone"]) + + today_ids = [item_id for item_id in today["ids"] if item_id != identity] + today_estimates = { + item_id: minutes for item_id, minutes in today["estimates"].items() + if item_id != identity + } + today_result = { + **today, "revision": today_revision + 1, + "ids": today_ids, "estimates": today_estimates, + } + week_result = {"revision": week_revision + 1, **normalized_week} + connection.execute( + "UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = NULL, " + "estimates = '{}', plan_date = NULL, timezone = NULL WHERE login = ?", + (today_result["revision"], self._sealed_plan(login, today_result), login), + ) + connection.execute( + "UPDATE week_plans SET revision = ?, payload = ? WHERE login = ?", + (week_result["revision"], self._cipher.seal( + normalized_week, binding=f"week:{login}" + ), login), + ) + result = {"today": today_result, "week": week_result} + connection.execute( + "INSERT INTO week_reschedules(login, operation_id, result, created_at) " + "VALUES (?, ?, ?, ?)", + (login, operation_id, self._cipher.seal( + result, binding=f"week-reschedule:{login}:{operation_id}" + ), self.clock()), + ) + return result + def promote_week( self, login: str, *, promotion_id: str, week_revision: int, plan_date: str, today_revision: int, allow_future: bool = False, diff --git a/tests/e2e/test_mobile_today_week_reschedule_release.py b/tests/e2e/test_mobile_today_week_reschedule_release.py new file mode 100644 index 0000000..b1baff8 --- /dev/null +++ b/tests/e2e/test_mobile_today_week_reschedule_release.py @@ -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) diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index b1b5631..425d3a8 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -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", diff --git a/tests/test_start_day_reminders.py b/tests/test_start_day_reminders.py index 02719d8..95508c8 100644 --- a/tests/test_start_day_reminders.py +++ b/tests/test_start_day_reminders.py @@ -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 diff --git a/tests/test_today_week_reschedule.py b/tests/test_today_week_reschedule.py new file mode 100644 index 0000000..8333d68 --- /dev/null +++ b/tests/test_today_week_reschedule.py @@ -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 diff --git a/tests/test_week_plan.py b/tests/test_week_plan.py index 4d208ec..3f70268 100644 --- a/tests/test_week_plan.py +++ b/tests/test_week_plan.py @@ -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"] diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py index 2e8231e..c04b294 100644 --- a/tests/test_week_plan_frontend.py +++ b/tests/test_week_plan_frontend.py @@ -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
Active Today item
Choose a future day after reviewing its current planned load. Today will continue with the next ready item.