Reschedule an active Today item into Week Ahead #1229

Merged
rockachopa merged 1 commits from timmy/1228-reschedule-today-to-week into main 2026-08-21 17:13:01 +00:00
15 changed files with 718 additions and 15 deletions

View File

@ -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; }

View File

@ -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;

View File

@ -1820,10 +1820,25 @@
<button class="mobile-today-blocked" data-mobile-today-blocked type="button" hidden>Report blocker</button>
<button data-today-break-open type="button">Take break</button>
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
<button data-work-session-reschedule-week type="button">Reschedule to Week Ahead</button>
<button data-mobile-today-end type="button">End session</button>
</div>
</section>
</dialog>
<dialog class="today-week-reschedule" id="today-week-reschedule" aria-labelledby="today-week-reschedule-title">
<section class="today-week-reschedule-panel">
<header>
<div><p class="small muted">Active Today item</p><h2 id="today-week-reschedule-title">Reschedule to Week Ahead</h2></div>
<button id="cancel-today-week-reschedule" type="button">Cancel</button>
</header>
<p class="small">Choose a future day after reviewing its current planned load. Today will continue with the next ready item.</p>
<div class="today-week-reschedule-days" id="today-week-reschedule-days" role="radiogroup" aria-label="Week Ahead destination"></div>
<label for="today-week-reschedule-estimate">Estimate in minutes</label>
<input id="today-week-reschedule-estimate" type="number" inputmode="numeric" min="5" max="1440" step="5" required />
<p id="today-week-reschedule-status" class="small" role="status" aria-live="assertive"></p>
<button id="confirm-today-week-reschedule" type="button" disabled>Move to Week Ahead &amp; continue</button>
</section>
</dialog>
<dialog class="today-progress-sheet" id="today-progress-sheet" aria-labelledby="today-progress-title">
<section class="today-progress-panel">
<header><div><p class="small muted">Active Today item</p><h2 id="today-progress-title">Add progress update</h2></div><button id="cancel-today-progress" type="button">Cancel</button></header>
@ -2064,6 +2079,7 @@
<script src="static/week-calendar.js"></script>
<script src="static/week-calendar-import.js"></script>
<script src="static/week-plan.js"></script>
<script src="static/today-week-reschedule.js"></script>
<script src="static/search-week-plan.js"></script>
<script src="static/today-sync.js"></script>
<script src="static/today-rollover.js"></script>

View File

@ -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',

View File

@ -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;
}

View File

@ -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){

View File

@ -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",

View File

@ -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()

View File

@ -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,

View File

@ -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)

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",

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

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

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"]

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