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[hidden] { display:none; }
|
||||||
.week-plan-dates button { min-height:44px; min-width:86px; flex:0 0 auto; padding:6px 10px; }
|
.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; }
|
.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; }
|
.back-to-week-review { width:100%; min-height:44px; margin:4px 0 12px; }
|
||||||
.tomorrow-conflict-review { margin-top:14px; }
|
.tomorrow-conflict-review { margin-top:14px; }
|
||||||
.tomorrow-conflict-plans { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
.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 weekItem=id=>[...todayMyWork,...activeMyWork].find(item=>todayWork.identity(item)===id);
|
||||||
const weekFlow=createWeekPlanWorkflow({controller:weekPlan,qs,
|
const weekFlow=createWeekPlanWorkflow({controller:weekPlan,qs,
|
||||||
getItem:weekItem,openItem:openRoutedWork,
|
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,
|
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,
|
const weekCalendar=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekItem,escapeHtml,escapeAttribute:escAttr,
|
||||||
onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();},
|
onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();},
|
||||||
});
|
});
|
||||||
|
|
@ -474,9 +475,8 @@
|
||||||
onRemotePlan: plan => {
|
onRemotePlan: plan => {
|
||||||
if (!planningOwnerLogin) return;
|
if (!planningOwnerLogin) return;
|
||||||
latestTodayPlan = plan;
|
latestTodayPlan = plan;
|
||||||
const startDayLaunch = window.location.hash === '#/my-work/start-day';
|
|
||||||
weekFlow.promote(plan).finally(() => {
|
weekFlow.promote(plan).finally(() => {
|
||||||
if (!startDayLaunch) return;
|
if (window.location.hash !== '#/my-work/start-day') return;
|
||||||
window.history.replaceState({}, '', '#/my-work/today');
|
window.history.replaceState({}, '', '#/my-work/today');
|
||||||
openMobileStartDay();
|
openMobileStartDay();
|
||||||
});
|
});
|
||||||
|
|
@ -484,8 +484,8 @@
|
||||||
capacity_minutes: plan.capacity_minutes ?? null,
|
capacity_minutes: plan.capacity_minutes ?? null,
|
||||||
estimates: plan.estimates || {},
|
estimates: plan.estimates || {},
|
||||||
});
|
});
|
||||||
const reviewState = todayRollover.reviewState(plan);
|
const s = todayRollover.reviewState(plan);
|
||||||
if (!['stale', 'legacy'].includes(reviewState)) {
|
if (!['stale', 'legacy'].includes(s)) {
|
||||||
rolloverReviewPlan = null;
|
rolloverReviewPlan = null;
|
||||||
qs('#plan-today').textContent = 'Plan Today';
|
qs('#plan-today').textContent = 'Plan Today';
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1820,10 +1820,25 @@
|
||||||
<button class="mobile-today-blocked" data-mobile-today-blocked type="button" hidden>Report blocker</button>
|
<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-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-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>
|
<button data-mobile-today-end type="button">End session</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</dialog>
|
</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">
|
<dialog class="today-progress-sheet" id="today-progress-sheet" aria-labelledby="today-progress-title">
|
||||||
<section class="today-progress-panel">
|
<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>
|
<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.js"></script>
|
||||||
<script src="static/week-calendar-import.js"></script>
|
<script src="static/week-calendar-import.js"></script>
|
||||||
<script src="static/week-plan.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/search-week-plan.js"></script>
|
||||||
<script src="static/today-sync.js"></script>
|
<script src="static/today-sync.js"></script>
|
||||||
<script src="static/today-rollover.js"></script>
|
<script src="static/today-rollover.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@ const SHELL = [
|
||||||
BASE + 'static/week-calendar.js',
|
BASE + 'static/week-calendar.js',
|
||||||
BASE + 'static/week-calendar-import.js',
|
BASE + 'static/week-calendar-import.js',
|
||||||
BASE + 'static/week-plan.js',
|
BASE + 'static/week-plan.js',
|
||||||
|
BASE + 'static/today-week-reschedule.js',
|
||||||
BASE + 'static/search-week-plan.js',
|
BASE + 'static/search-week-plan.js',
|
||||||
BASE + 'static/today-sync.js',
|
BASE + 'static/today-sync.js',
|
||||||
BASE + 'static/today-rollover.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,
|
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,
|
keepLocal,useRemote,promote,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
|
||||||
item:id=>pendingItems[id]||confirmedItems[id]||null,
|
item:id=>pendingItems[id]||confirmedItems[id]||null,
|
||||||
offline:()=>offlineSnapshot};
|
offline:()=>offlineSnapshot,request:fetchJson};
|
||||||
}
|
}
|
||||||
function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute,
|
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,
|
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 selectedDate=null;
|
||||||
let reviewing=false;
|
let reviewing=false;
|
||||||
let overviewing=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;
|
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();}};
|
const back=qs('#back-to-week-review');if(back)back.hidden=true;renderDates();}};
|
||||||
if(typeof weekCalendarImport!=='undefined')weekCalendarImport.mount(controller,workflow,qs);
|
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;
|
return workflow;
|
||||||
}
|
}
|
||||||
if(typeof module!=='undefined'&&module.exports){
|
if(typeof module!=='undefined'&&module.exports){
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ FEATURE_SOURCES = {
|
||||||
"planning": (
|
"planning": (
|
||||||
"static/plan-today.js", "static/plan-today-readiness.js",
|
"static/plan-today.js", "static/plan-today-readiness.js",
|
||||||
"static/plan-today-preview.js", "static/today-rollover.js", "static/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": (
|
"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",
|
"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)
|
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):
|
class WeekReconciliation(WeekPromotion):
|
||||||
ids: list[str] = Field(max_length=5)
|
ids: list[str] = Field(max_length=5)
|
||||||
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
|
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):
|
async def prevent_live_api_caching(request, call_next):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
path = dashboard_auth.application_path(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/")
|
path.startswith("/api/v1/repos/")
|
||||||
and path.endswith("/review")
|
and path.endswith("/review")
|
||||||
) or path.startswith("/api/v1/notifications") or (
|
) 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")
|
@app.post("/api/v1/week/start-early")
|
||||||
async def start_week_day_early(payload: WeekPromotion):
|
async def start_week_day_early(payload: WeekPromotion):
|
||||||
login = await _confirmed_login()
|
login = await _confirmed_login()
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ class TodayStore:
|
||||||
|
|
||||||
def _initialize(self) -> None:
|
def _initialize(self) -> None:
|
||||||
connection = connect_private_sqlite(self.path, timeout=self.timeout)
|
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()
|
connection.close()
|
||||||
return
|
return
|
||||||
connection.execute("PRAGMA journal_mode=WAL")
|
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, "
|
"login TEXT NOT NULL, promotion_id TEXT NOT NULL, result TEXT NOT NULL, "
|
||||||
"created_at REAL NOT NULL, PRIMARY KEY (login, promotion_id))"
|
"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.commit()
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
|
@ -568,6 +573,110 @@ class TodayStore:
|
||||||
)
|
)
|
||||||
return snapshot
|
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(
|
def promote_week(
|
||||||
self, login: str, *, promotion_id: str, week_revision: int,
|
self, login: str, *, promotion_id: str, week_revision: int,
|
||||||
plan_date: str, today_revision: int, allow_future: bool = False,
|
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.js",
|
||||||
"/dashboard/static/week-calendar-import.js",
|
"/dashboard/static/week-calendar-import.js",
|
||||||
"/dashboard/static/week-plan.js",
|
"/dashboard/static/week-plan.js",
|
||||||
|
"/dashboard/static/today-week-reschedule.js",
|
||||||
"/dashboard/static/search-week-plan.js",
|
"/dashboard/static/search-week-plan.js",
|
||||||
"/dashboard/static/today-sync.js",
|
"/dashboard/static/today-sync.js",
|
||||||
"/dashboard/static/today-rollover.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 'id="device-setup-start-day"' in html
|
||||||
assert "startDayControl:qs('#push-start-day')" in dashboard
|
assert "startDayControl:qs('#push-start-day')" in dashboard
|
||||||
assert "startDayHour:qs('#push-start-day-hour')" 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 "weekFlow.promote(plan).finally(() =>" in dashboard
|
||||||
assert "onRemotePlan: async plan =>" not in dashboard
|
assert "onRemotePlan: async plan =>" not in dashboard
|
||||||
assert "openMobileStartDay()" 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)
|
await main.replace_week_plan(payload)
|
||||||
assert raised.value.status_code == 409
|
assert raised.value.status_code == 409
|
||||||
assert raised.value.detail == {"code": "week_changed", "snapshot": saved}
|
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]
|
workflow_mount = dashboard.split("const weekFlow=createWeekPlanWorkflow({", 1)[1].split("});", 1)[0]
|
||||||
assert "openItem:openRoutedWork" in workflow_mount
|
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
|
assert ".week-review-item-open" in css
|
||||||
rule = css.split(".week-review-item-open", 1)[1].split("}", 1)[0]
|
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-days"' in index
|
||||||
assert 'id="week-review-duplicates"' in index
|
assert 'id="week-review-duplicates"' in index
|
||||||
assert 'id="confirm-week-plan"' 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 "weekFlow.confirm()" in dashboard
|
||||||
assert ".week-review-day.is-overloaded" in css
|
assert ".week-review-day.is-overloaded" in css
|
||||||
assert ".week-review-day button { min-height:44px;" in css
|
assert ".week-review-day button { min-height:44px;" in css
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user