Reschedule an active Today item into Week Ahead #1229
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
146
frontend/today-week-reschedule.js
Normal file
146
frontend/today-week-reschedule.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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){
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
37
src/main.py
37
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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
122
tests/e2e/test_mobile_today_week_reschedule_release.py
Normal file
122
tests/e2e/test_mobile_today_week_reschedule_release.py
Normal 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)
|
||||
|
|
@ -1331,6 +1331,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/week-calendar.js",
|
||||
"/dashboard/static/week-calendar-import.js",
|
||||
"/dashboard/static/week-plan.js",
|
||||
"/dashboard/static/today-week-reschedule.js",
|
||||
"/dashboard/static/search-week-plan.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/today-rollover.js",
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@ def test_mobile_settings_and_launch_route_wire_start_day_into_existing_promotion
|
|||
assert 'id="device-setup-start-day"' in html
|
||||
assert "startDayControl:qs('#push-start-day')" in dashboard
|
||||
assert "startDayHour:qs('#push-start-day-hour')" in dashboard
|
||||
assert "window.location.hash === '#/my-work/start-day'" in dashboard
|
||||
assert "window.location.hash !== '#/my-work/start-day'" in dashboard
|
||||
assert "weekFlow.promote(plan).finally(() =>" in dashboard
|
||||
assert "onRemotePlan: async plan =>" not in dashboard
|
||||
assert "openMobileStartDay()" in dashboard
|
||||
|
|
|
|||
109
tests/test_today_week_reschedule.py
Normal file
109
tests/test_today_week_reschedule.py
Normal 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
|
||||
|
|
@ -386,3 +386,144 @@ async def test_week_api_round_trip_and_conflict_contract(monkeypatch, tmp_path):
|
|||
await main.replace_week_plan(payload)
|
||||
assert raised.value.status_code == 409
|
||||
assert raised.value.detail == {"code": "week_changed", "snapshot": saved}
|
||||
|
||||
|
||||
def test_reschedule_today_item_atomically_moves_it_into_selected_week_day(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"s" * 32)
|
||||
active_id = "issue:stackchain/dashboard:1228:"
|
||||
other_id = "issue:stackchain/dashboard:1229:"
|
||||
today = store.apply("timmy", "add-active", "add", active_id)
|
||||
today = store.apply("timmy", "add-other", "add", other_id)
|
||||
week = store.replace_week(
|
||||
"timmy",
|
||||
base_revision=0,
|
||||
timezone="UTC",
|
||||
days=[{
|
||||
"plan_date": "2026-08-24",
|
||||
"ids": [active_id],
|
||||
"capacity_minutes": 120,
|
||||
"estimates": {active_id: 30},
|
||||
}, {
|
||||
"plan_date": "2026-08-25",
|
||||
"ids": [],
|
||||
"capacity_minutes": 90,
|
||||
"estimates": {},
|
||||
}],
|
||||
)
|
||||
|
||||
result = store.reschedule_today_to_week(
|
||||
"timmy",
|
||||
operation_id="reschedule-active-2026-08-25",
|
||||
identity=active_id,
|
||||
estimate_minutes=45,
|
||||
plan_date="2026-08-25",
|
||||
today_revision=today["revision"],
|
||||
week_revision=week["revision"],
|
||||
)
|
||||
|
||||
assert result["today"]["ids"] == [other_id]
|
||||
assert result["week"]["days"] == [{
|
||||
"plan_date": "2026-08-24", "ids": [], "capacity_minutes": 120, "estimates": {},
|
||||
}, {
|
||||
"plan_date": "2026-08-25", "ids": [active_id], "capacity_minutes": 90,
|
||||
"estimates": {active_id: 45},
|
||||
}]
|
||||
assert store.get("timmy") == result["today"]
|
||||
assert store.get_week("timmy") == result["week"]
|
||||
|
||||
|
||||
def test_reschedule_today_item_is_idempotent_and_rejects_stale_plans_without_partial_write(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"i" * 32)
|
||||
identity = "issue:stackchain/dashboard:1228:"
|
||||
today = store.apply("timmy", "add-active", "add", identity)
|
||||
week = store.replace_week(
|
||||
"timmy", base_revision=0, timezone="UTC",
|
||||
days=[{
|
||||
"plan_date": "2026-08-25", "ids": [],
|
||||
"capacity_minutes": 60, "estimates": {},
|
||||
}],
|
||||
)
|
||||
arguments = {
|
||||
"operation_id": "same-operation", "identity": identity,
|
||||
"estimate_minutes": 30, "plan_date": "2026-08-25",
|
||||
"today_revision": today["revision"], "week_revision": week["revision"],
|
||||
}
|
||||
|
||||
moved = store.reschedule_today_to_week("timmy", **arguments)
|
||||
assert store.reschedule_today_to_week("timmy", **arguments) == moved
|
||||
|
||||
another = store.apply("timmy", "add-another", "add", "issue:stackchain/dashboard:1230:")
|
||||
with pytest.raises(TodayPromotionConflict) as conflict:
|
||||
store.reschedule_today_to_week(
|
||||
"timmy", **{**arguments, "operation_id": "stale-operation", "identity": another["ids"][-1]}
|
||||
)
|
||||
assert conflict.value.today == another
|
||||
assert store.get_week("timmy") == moved["week"]
|
||||
|
||||
|
||||
def test_reschedule_today_item_requires_explicit_capacity_overload_confirmation(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"o" * 32)
|
||||
identity = "issue:stackchain/dashboard:1228:"
|
||||
today = store.apply("timmy", "add-active", "add", identity)
|
||||
week = store.replace_week(
|
||||
"timmy", base_revision=0, timezone="UTC",
|
||||
days=[{
|
||||
"plan_date": "2026-08-25", "ids": ["issue:stackchain/dashboard:1:"],
|
||||
"capacity_minutes": 60, "estimates": {"issue:stackchain/dashboard:1:": 45},
|
||||
}],
|
||||
)
|
||||
arguments = {
|
||||
"identity": identity, "estimate_minutes": 30, "plan_date": "2026-08-25",
|
||||
"today_revision": today["revision"], "week_revision": week["revision"],
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="explicit overload confirmation"):
|
||||
store.reschedule_today_to_week(
|
||||
"timmy", operation_id="unconfirmed", allow_over_capacity=False, **arguments
|
||||
)
|
||||
assert store.get("timmy") == today
|
||||
assert store.get_week("timmy") == week
|
||||
|
||||
moved = store.reschedule_today_to_week(
|
||||
"timmy", operation_id="confirmed", allow_over_capacity=True, **arguments
|
||||
)
|
||||
assert moved["week"]["days"][0]["ids"][-1] == identity
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_week_reschedule_api_returns_atomic_snapshots_and_conflict_recovery(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"a" * 32)
|
||||
identity = "issue:stackchain/dashboard:1228:"
|
||||
today = store.apply("timmy", "add-active", "add", identity)
|
||||
week = store.replace_week(
|
||||
"timmy", base_revision=0, timezone="UTC",
|
||||
days=[{
|
||||
"plan_date": "2026-08-25", "ids": [],
|
||||
"capacity_minutes": 60, "estimates": {},
|
||||
}],
|
||||
)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
payload = main.WeekReschedule(
|
||||
operation_id="move-active", identity=identity, estimate_minutes=30,
|
||||
plan_date="2026-08-25", today_revision=today["revision"],
|
||||
week_revision=week["revision"],
|
||||
)
|
||||
|
||||
moved = await main.reschedule_today_to_week(payload)
|
||||
assert moved["today"]["ids"] == []
|
||||
assert moved["week"]["days"][0]["ids"] == [identity]
|
||||
|
||||
stale = main.WeekReschedule(
|
||||
operation_id="stale", identity="issue:stackchain/dashboard:1229:", estimate_minutes=30,
|
||||
plan_date="2026-08-25", today_revision=today["revision"], week_revision=week["revision"],
|
||||
)
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.reschedule_today_to_week(stale)
|
||||
assert raised.value.status_code == 409
|
||||
assert raised.value.detail["code"] == "today_changed"
|
||||
assert raised.value.detail["today"] == moved["today"]
|
||||
assert raised.value.detail["week"] == moved["week"]
|
||||
|
|
|
|||
|
|
@ -542,7 +542,7 @@ def test_dashboard_routes_week_overview_controls_through_existing_detail_flow_wi
|
|||
|
||||
workflow_mount = dashboard.split("const weekFlow=createWeekPlanWorkflow({", 1)[1].split("});", 1)[0]
|
||||
assert "openItem:openRoutedWork" in workflow_mount
|
||||
assert "t:()=>latestTodayPlan" in workflow_mount
|
||||
assert "t:v=>v?latestTodayPlan=v:latestTodayPlan" in workflow_mount
|
||||
|
||||
assert ".week-review-item-open" in css
|
||||
rule = css.split(".week-review-item-open", 1)[1].split("}", 1)[0]
|
||||
|
|
@ -1241,7 +1241,7 @@ def test_mobile_week_ahead_review_is_rendered_touch_safe_and_confirmed_explicitl
|
|||
assert 'id="week-review-days"' in index
|
||||
assert 'id="week-review-duplicates"' in index
|
||||
assert 'id="confirm-week-plan"' in index
|
||||
assert "setReviewMode:value=>qs('#plan-today-sheet').classList.toggle('week-review-mode',value)" in dashboard
|
||||
assert "setReviewMode:v=>qs('#plan-today-sheet').classList.toggle('week-review-mode',v)" in dashboard
|
||||
assert "weekFlow.confirm()" in dashboard
|
||||
assert ".week-review-day.is-overloaded" in css
|
||||
assert ".week-review-day button { min-height:44px;" in css
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user