feat: unplan Week Ahead work with undo (Closes #1240)
CI / lint (pull_request) Successful in 3m58s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m20s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-21 22:45:15 +00:00
1 parent 95887d7493
commit 449ab78991
18 files changed
+216 -32

No files matched your search

+3
View File
@@ -346,6 +346,9 @@ textarea { resize: vertical; min-height: 120px; }
.week-review-item-open { display:block; width:100%; min-width:0; min-height:44px; padding:8px; border:0; border-radius:8px; background:transparent; color:inherit; text-align:left; }
.week-review-item-open:hover { background:#173453; }
.week-review-item-open:focus-visible { outline:3px solid #93c5fd; outline-offset:2px; }
.week-review-unplan { width:100%; min-height:44px; border-color:#6b87a6; background:transparent; color:#d7e5f5; }
.week-unplan-receipt { margin:12px 0 6px; padding:10px 12px; border:1px solid #60a5fa; border-radius:10px; background:#112d4d; color:#dbeafe; }
#undo-week-unplan { width:100%; min-height:44px; margin-bottom:8px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }
.week-start-early { display:block; width:100%; min-height:44px; margin-top:12px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }
.week-start-early:focus-visible { outline:3px solid #bfdbfe; outline-offset:2px; }
.week-review-item-copy strong, .week-review-item-copy small { overflow-wrap:anywhere; }
+2
View File
@@ -526,6 +526,8 @@
</section>
<div class="week-review-duplicates" id="week-review-duplicates" role="alert" hidden></div>
<div class="week-review-days" id="week-review-days"></div>
<div class="week-unplan-receipt" id="week-unplan-receipt" role="status" aria-live="polite" hidden></div>
<button id="undo-week-unplan" type="button" hidden>Undo</button>
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div>
<button id="edit-week-plan" type="button" hidden>Edit week</button>
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button>
+1 -1
View File
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v129';
const CACHE = 'stackchain-dashboard-shell-v130';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
+68 -3
View File
@@ -248,6 +248,24 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
const staged=stageDays(days);
return staged?{...staged,removed}:false;
}
function unplan(id) {
if(!id||offlineSnapshot)return false;
const source=week.days.find(item=>(item.ids||[]).includes(id));
if(!source)return false;
const index=source.ids.indexOf(id),estimate=Number(source.estimates?.[id]);
const receipt={id,plan_date:source.plan_date,index,estimate:Number.isFinite(estimate)?estimate:null};
const staged=retire(id);
return staged?receipt:false;
}
function restore(receipt) {
if(!receipt?.id||!receipt.plan_date||!Number.isInteger(receipt.index)||offlineSnapshot||placement(receipt.id))return false;
const days=week.days.map(cloneDay);
let destination=days.find(item=>item.plan_date===receipt.plan_date);
if(!destination){destination={plan_date:receipt.plan_date,ids:[],capacity_minutes:null,estimates:{}};days.push(destination);}
destination.ids.splice(Math.min(receipt.index,destination.ids.length),0,receipt.id);
if(Number.isFinite(Number(receipt.estimate)))destination.estimates[receipt.id]=Number(receipt.estimate);
return stageDays(days);
}
function placement(id) {
const found=week.days.find(item=>(item.ids||[]).includes(id));
if(!found)return null;
@@ -475,14 +493,14 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
const label=planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned';
return label+(pending()?' · sync pending':'');
}
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,retire,placement,place,pending,flush,conflict,chooseDay,saveMerged,
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,retire,unplan,restore,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,request:fetchJson,reschedule:()=>({storage,getLogin})};
}
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,x=null}={}) {
confirmEarly=message=>globalThis.confirm?.(message)??false,setTimer=globalThis.setTimeout,clearTimer=globalThis.clearTimeout,x=null}={}) {
let selectedDate=null;
let reviewing=false;
let overviewing=false;
@@ -490,6 +508,8 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
let blockedReviewOpen=false;
let reconciliation=null;
let loadState=null;
let unplanReceipt=null;
let unplanTimer=null;
function renderOfflineState(value=loadState) {
const notice=qs('#week-offline-snapshot'),retry=qs('#retry-week-live'),capacity=qs('#open-week-capacity-import');
const offline=Boolean(value?.offline_snapshot);
@@ -616,6 +636,36 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
return false;
}
}
function dismissUnplanReceipt({focus=false}={}) {
if(unplanTimer){clearTimer?.(unplanTimer);unplanTimer=null;}
unplanReceipt=null;
const receipt=qs('#week-unplan-receipt'),undo=qs('#undo-week-unplan');
if(receipt)receipt.hidden=true;
if(undo)undo.hidden=true;
if(focus)qs('#edit-week-plan')?.focus?.();
}
function showUnplanReceipt(title) {
const receipt=qs('#week-unplan-receipt'),undo=qs('#undo-week-unplan');
if(receipt){receipt.textContent=title+' removed from Week Ahead.';receipt.hidden=false;}
if(undo){undo.hidden=false;undo.focus?.();}
if(unplanTimer)clearTimer?.(unplanTimer);
unplanTimer=setTimer?.(()=>dismissUnplanReceipt({focus:true}),10000)||null;
}
async function undoUnplan() {
const receipt=unplanReceipt;
if(!receipt||!controller.restore?.(receipt))return false;
dismissUnplanReceipt();renderReview();renderPass();
try{
await controller.flush();
renderReview();
qs('#week-review-status').textContent='Restored to Week Ahead.';
}catch(error){
qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Restore remains saved on this phone.';
}
qs('#week-review-days').querySelector?.(`[data-week-unplan="${receipt.id}"]`)?.focus?.();
return true;
}
qs('#undo-week-unplan')?.addEventListener('click',undoUnplan);
function renderReview() {
const value=controller.review(),root=qs('#week-review-days'),duplicates=qs('#week-review-duplicates');
const readOnly=Boolean(controller.offline?.());
@@ -638,7 +688,8 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
const load=day.planned_minutes+(capacity?' of '+capacity:'')+' min'+(day.overloaded?' · over capacity':'');
const move=id=>overviewing?'':'<div class="week-review-move"><label>Move to <select data-week-move-destination="'+escapeAttribute(id)+'">'+destinations+
'</select></label><button type="button" data-week-move="'+escapeAttribute(id)+'">Move</button></div>';
const items=day.ids.length?'<ul>'+day.ids.map(id=>'<li>'+itemMarkup(id,day)+move(id)+'</li>').join('')+'</ul>':
const unplan=id=>overviewing&&!readOnly?'<button type="button" class="week-review-unplan" data-week-unplan="'+escapeAttribute(id)+'">Remove from week</button>':'';
const items=day.ids.length?'<ul>'+day.ids.map(id=>'<li>'+itemMarkup(id,day)+move(id)+unplan(id)+'</li>').join('')+'</ul>':
'<p class="small muted">Nothing planned.</p>';
const edit=readOnly?'':'<button type="button" data-week-edit-day="'+escapeAttribute(day.plan_date)+'" aria-label="Edit '+escapeAttribute(day.label)+'">Edit day</button>';
const start=day===nextUp&&canStartEarly?'<button type="button" class="week-start-early" data-week-start-early="'+escapeAttribute(day.plan_date)+'">Start this day early</button>':'';
@@ -667,6 +718,20 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
controller.flush().then(()=>{renderReview();qs('#mobile-week-summary').textContent=controller.summary();})
.catch(error=>{qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Changes remain on this phone.';});
}));
root.querySelectorAll('[data-week-unplan]').forEach(button=>button.addEventListener('click',async event=>{
const id=event.currentTarget.dataset.weekUnplan,item=getItem(id)||controller.item?.(id);
const receipt=controller.unplan?.(id);
if(!receipt)return;
unplanReceipt=receipt;
renderReview();renderPass();showUnplanReceipt(String(item?.title||'Work'));
try{
await controller.flush();
renderReview();
qs('#mobile-week-summary').textContent=controller.summary();
}catch(error){
qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Removal remains saved on this phone.';
}
}));
root.querySelectorAll('[data-week-open-item]').forEach(button=>button.addEventListener('click',event=>{
const id=button.dataset.weekOpenItem,item=getItem(id)||controller.item?.(id);
if(item)openItem?.(item,event.currentTarget);