feat: reschedule active Today work into Week Ahead (Closes #1228)
CI / lint (pull_request) Successful in 3m33s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 4m57s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-21 16:59:40 +00:00
1 parent c63df3c46d
commit 9d8a362583
15 files changed
+718 -15

No files matched your search

+13
View File
@@ -267,6 +267,19 @@ textarea { resize: vertical; min-height: 120px; }
.week-plan-dates[hidden] { display:none; }
.week-plan-dates button { min-height:44px; min-width:86px; flex:0 0 auto; padding:6px 10px; }
.week-plan-dates button[aria-current="date"] { color:#bfdbfe; background:#17365a; border-color:#60a5fa; }
.today-week-reschedule { box-sizing:border-box; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:inherit; }
.today-week-reschedule::backdrop { background:rgba(5,12,21,.82); }
.today-week-reschedule-panel { width:min(100%,560px); box-sizing:border-box; min-height:100%; margin-left:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; overflow-x:hidden; }
.today-week-reschedule-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-week-reschedule-panel h2, .today-week-reschedule-panel header p { margin-top:0; }
.today-week-reschedule-panel input { box-sizing:border-box; width:100%; min-height:44px; margin:6px 0 10px; font-size:16px; }
.today-week-reschedule-days { display:grid; gap:8px; margin:14px 0; }
.today-week-reschedule-days button { min-height:44px; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; text-align:left; overflow-wrap:anywhere; }
.today-week-reschedule-days button[aria-checked="true"] { border-color:#60a5fa; background:#17365a; color:#eff6ff; }
.today-week-reschedule-days button:disabled { opacity:.58; }
#cancel-today-week-reschedule { min-height:44px; }
#confirm-today-week-reschedule { position:sticky; bottom:0; width:100%; min-height:48px; }
#today-week-reschedule-status { min-height:1.4em; color:#fde68a; }
.back-to-week-review { width:100%; min-height:44px; margin:4px 0 12px; }
.tomorrow-conflict-review { margin-top:14px; }
.tomorrow-conflict-plans { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
+6 -6
View File
@@ -455,9 +455,10 @@
const weekItem=id=>[...todayMyWork,...activeMyWork].find(item=>todayWork.identity(item)===id);
const weekFlow=createWeekPlanWorkflow({controller:weekPlan,qs,
getItem:weekItem,openItem:openRoutedWork,
openPlanner:openPlanToday,setReviewMode:value=>qs('#plan-today-sheet').classList.toggle('week-review-mode',value),
openPlanner:openPlanToday,setReviewMode:v=>qs('#plan-today-sheet').classList.toggle('week-review-mode',v),
escapeHtml,escapeAttribute:escAttr,todayWork,
t:()=>latestTodayPlan,r:refreshMyWorkView,w:warmTodayOffline});
t:v=>v?latestTodayPlan=v:latestTodayPlan,r:refreshMyWorkView,w:warmTodayOffline,
x:currentTodayProgressTarget});
const weekCalendar=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekItem,escapeHtml,escapeAttribute:escAttr,
onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();},
});
@@ -474,9 +475,8 @@
onRemotePlan: plan => {
if (!planningOwnerLogin) return;
latestTodayPlan = plan;
const startDayLaunch = window.location.hash === '#/my-work/start-day';
weekFlow.promote(plan).finally(() => {
if (!startDayLaunch) return;
if (window.location.hash !== '#/my-work/start-day') return;
window.history.replaceState({}, '', '#/my-work/today');
openMobileStartDay();
});
@@ -484,8 +484,8 @@
capacity_minutes: plan.capacity_minutes ?? null,
estimates: plan.estimates || {},
});
const reviewState = todayRollover.reviewState(plan);
if (!['stale', 'legacy'].includes(reviewState)) {
const s = todayRollover.reviewState(plan);
if (!['stale', 'legacy'].includes(s)) {
rolloverReviewPlan = null;
qs('#plan-today').textContent = 'Plan Today';
return;
+16
View File
@@ -1820,10 +1820,25 @@
<button class="mobile-today-blocked" data-mobile-today-blocked type="button" hidden>Report blocker</button>
<button data-today-break-open type="button">Take break</button>
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
<button data-work-session-reschedule-week type="button">Reschedule to Week Ahead</button>
<button data-mobile-today-end type="button">End session</button>
</div>
</section>
</dialog>
<dialog class="today-week-reschedule" id="today-week-reschedule" aria-labelledby="today-week-reschedule-title">
<section class="today-week-reschedule-panel">
<header>
<div><p class="small muted">Active Today item</p><h2 id="today-week-reschedule-title">Reschedule to Week Ahead</h2></div>
<button id="cancel-today-week-reschedule" type="button">Cancel</button>
</header>
<p class="small">Choose a future day after reviewing its current planned load. Today will continue with the next ready item.</p>
<div class="today-week-reschedule-days" id="today-week-reschedule-days" role="radiogroup" aria-label="Week Ahead destination"></div>
<label for="today-week-reschedule-estimate">Estimate in minutes</label>
<input id="today-week-reschedule-estimate" type="number" inputmode="numeric" min="5" max="1440" step="5" required />
<p id="today-week-reschedule-status" class="small" role="status" aria-live="assertive"></p>
<button id="confirm-today-week-reschedule" type="button" disabled>Move to Week Ahead &amp; continue</button>
</section>
</dialog>
<dialog class="today-progress-sheet" id="today-progress-sheet" aria-labelledby="today-progress-title">
<section class="today-progress-panel">
<header><div><p class="small muted">Active Today item</p><h2 id="today-progress-title">Add progress update</h2></div><button id="cancel-today-progress" type="button">Cancel</button></header>
@@ -2064,6 +2079,7 @@
<script src="static/week-calendar.js"></script>
<script src="static/week-calendar-import.js"></script>
<script src="static/week-plan.js"></script>
<script src="static/today-week-reschedule.js"></script>
<script src="static/search-week-plan.js"></script>
<script src="static/today-sync.js"></script>
<script src="static/today-rollover.js"></script>
+1
View File
@@ -127,6 +127,7 @@ const SHELL = [
BASE + 'static/week-calendar.js',
BASE + 'static/week-calendar-import.js',
BASE + 'static/week-plan.js',
BASE + 'static/today-week-reschedule.js',
BASE + 'static/search-week-plan.js',
BASE + 'static/today-sync.js',
BASE + 'static/today-rollover.js',
+146
View File
@@ -0,0 +1,146 @@
function createTodayWeekReschedule({
week,
api,
getToday,
adoptToday = () => {},
operationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()),
} = {}) {
let current = null;
async function open(identity) {
const today = await api('api/v1/today');
if (!identity || !today?.ids?.includes(identity)) {
throw new Error('The active Today item changed. Reopen rescheduling.');
}
const loaded = await week.load();
if (loaded?.offline_snapshot || loaded?.sync_pending) {
throw new Error('Reconnect before rescheduling Today into Week Ahead.');
}
const estimate = Number(today.estimates?.[identity]);
const estimateMinutes = Number.isFinite(estimate) && estimate > 0 ? estimate : null;
const days = week.review().days.map(day => {
const ids = (day.ids || []).filter(id => id !== identity);
const planned = Number(day.planned_minutes) || 0;
const existing = Number(day.estimates?.[identity]) || 0;
return {
...day,
ids,
planned_minutes: Math.max(0, planned - existing),
load: `${Math.max(0, planned - existing)} / ${Number(day.capacity_minutes) || 0} min`,
eligible: ids.length < 5,
};
});
current = {
identity,
today_revision: today.revision,
week_revision: loaded.revision,
operation_id: operationId(),
estimate_minutes: estimateMinutes,
days,
};
return {...current, days:days.map(day => ({...day, ids:[...day.ids]}))};
}
async function confirm(planDate, estimateMinutes, {allowOverload = false} = {}) {
if (!current) throw new Error('Open rescheduling before choosing a day.');
const day = current.days.find(value => value.plan_date === planDate);
if (!day) throw new Error('Choose one of the next seven days.');
const estimate = Number(estimateMinutes);
if (!Number.isFinite(estimate) || estimate < 5 || estimate > 1440) {
throw new Error('Estimate must be between 5 and 1440 minutes.');
}
if (!day.eligible) throw new Error('That Week Ahead day already has five items.');
const capacity = Number(day.capacity_minutes) || 0;
if (capacity && day.planned_minutes + estimate > capacity && !allowOverload) {
throw new Error("That move exceeds the day's capacity. Confirm overload before rescheduling.");
}
const result = await api('api/v1/week/reschedule', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
operation_id:current.operation_id,
identity:current.identity,
estimate_minutes:estimate,
plan_date:planDate,
today_revision:current.today_revision,
week_revision:current.week_revision,
allow_over_capacity:allowOverload,
}),
});
adoptToday(result.today);
week.adopt(result.week);
current = null;
return result;
}
function cancel() { current = null; }
return {open, confirm, cancel, state:() => current};
}
function mountTodayWeekReschedule({
qs, document=globalThis.document, week, api, getToday, adoptToday, currentTarget, closeActions,
refresh, warm, continueToday, announce, schedule=callback=>requestAnimationFrame(callback),
}={}) {
const dialog=qs('#today-week-reschedule'),daysRoot=qs('#today-week-reschedule-days');
const estimate=qs('#today-week-reschedule-estimate'),status=qs('#today-week-reschedule-status');
const confirm=qs('#confirm-today-week-reschedule'),launcher=qs('[data-work-session-reschedule-week]');
let selectedDate=null,allowOverload=false;
const controller=createTodayWeekReschedule({week,api,getToday,adoptToday});
function close() {
controller.cancel();selectedDate=null;allowOverload=false;
if(dialog.open)dialog.close();schedule(()=>{
qs('[data-mobile-today-more]').click();launcher.focus();
});
}
function render(opened) {
daysRoot.replaceChildren(...opened.days.map(day=>{
const button=document.createElement('button');
button.type='button';button.dataset.planDate=day.plan_date;button.setAttribute('role','radio');
button.setAttribute('aria-checked','false');button.disabled=!day.eligible;
const label=document.createElement('strong');label.textContent=day.label;
const load=document.createElement('span');load.className='small';
load.textContent=day.eligible?day.load:'5 items · full';button.append(label,load);
button.addEventListener('click',()=>{
selectedDate=day.plan_date;allowOverload=false;
daysRoot.querySelectorAll('button').forEach(choice=>choice.setAttribute(
'aria-checked',String(choice===button)
));
status.textContent='';confirm.disabled=false;confirm.textContent='Move to Week Ahead & continue';
});
return button;
}));
}
launcher.addEventListener('click',async()=>{
const target=currentTarget();
if(!target){announce('Start or resume a checkpointed Today item before rescheduling.');return;}
closeActions();status.textContent='Loading Week Ahead…';confirm.disabled=true;dialog.showModal();
try{
const opened=await controller.open(target.identity);render(opened);
estimate.value=opened.estimate_minutes||'';
status.textContent=opened.estimate_minutes?'Choose a future day.':'Add an estimate, then choose a future day.';
schedule(()=>daysRoot.querySelector('button:not(:disabled)')?.focus());
}catch(error){status.textContent=error.message;}
});
qs('#cancel-today-week-reschedule').addEventListener('click',close);
dialog.addEventListener('cancel',event=>{event.preventDefault();close();});
confirm.addEventListener('click',async()=>{
if(!selectedDate)return;
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';
try{
await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
await refresh();warm();dialog.close();announce('Moved to Week Ahead. Continuing Today.');
await continueToday();
}catch(error){
const overload=error.message.includes('Confirm overload');allowOverload=overload;
status.textContent=error.message;
confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue';
confirm.disabled=false;
}
});
return {controller,close};
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = createTodayWeekReschedule;
module.exports.mount = mountTodayWeekReschedule;
}
+12 -2
View File
@@ -468,11 +468,11 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
keepLocal,useRemote,promote,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
item:id=>pendingItems[id]||confirmedItems[id]||null,
offline:()=>offlineSnapshot};
offline:()=>offlineSnapshot,request:fetchJson};
}
function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute,
todayWork,refresh:r=()=>{},warm:w=()=>{},today:t=()=>null,r:refresh=r,w:warm=w,t:getTodayPlan=t,
confirmEarly=message=>globalThis.confirm?.(message)??false}={}) {
confirmEarly=message=>globalThis.confirm?.(message)??false,x=null}={}) {
let selectedDate=null;
let reviewing=false;
let overviewing=false;
@@ -801,6 +801,16 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
clear(){selectedDate=null;reviewing=false;overviewing=false;editingFromReview=false;reconciliation=null;setReviewMode(false);qs('#week-review').hidden=true;
const back=qs('#back-to-week-review');if(back)back.hidden=true;renderDates();}};
if(typeof weekCalendarImport!=='undefined')weekCalendarImport.mount(controller,workflow,qs);
if(x)mountTodayWeekReschedule({
qs,week:controller,getToday:t,refresh:r,warm:w,api:controller.request,currentTarget:x,
closeActions:()=>qs('#mobile-today-actions').close(),
continueToday:()=>qs('[data-work-session-next]').click(),
announce:message=>{qs('#my-work-action-status').textContent=message;},
adoptToday:value=>{
t(value);todayWork.replace(value.ids);
todayWork.replacePlanning({capacity_minutes:value.capacity_minutes??null,estimates:value.estimates||{}});
},
});
return workflow;
}
if(typeof module!=='undefined'&&module.exports){