feat: reschedule active Today work into Week Ahead (Closes #1228)
This commit is contained in:
1 parent
c63df3c46d
commit
9d8a362583
15 files changed
+718
-15
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+12
-2
@@ -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){
|
||||
|
||||
Reference in new issue
Block a user