stackchain-dashboard/frontend/week-plan.js
timmy 9f40a4c5ac
All checks were successful
CI / lint (pull_request) Successful in 3m21s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 4m38s
CI / release-candidate (pull_request) Has been skipped
feat: make Week Ahead review readable and editable (Closes #1190)
2026-08-20 18:33:37 +00:00

482 lines
26 KiB
JavaScript

function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
let week={revision:0,timezone:null,days:[]};
let flushing=null;
let lastConflict=null;
const storagePrefix='stackchain.week-sync.v1.';
const cloneDay=day=>({
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
estimates:{...(day.estimates||{})},
});
const state=()=>({revision:week.revision,timezone:week.timezone,days:week.days.map(cloneDay),
...(week.sync_pending?{base_revision:week.base_revision,sync_pending:true}:{})});
function storageKey() {
const login=String(getLogin?.()||'').trim().toLowerCase();
return login?storagePrefix+encodeURIComponent(login):'';
}
function pending() {
const key=storageKey();
if(!key||!storage) return false;
try {
const value=JSON.parse(storage.getItem(key)||'null');
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.days)?{
revision:value.base_revision,base_revision:value.base_revision,timezone:value.timezone||null,
days:value.days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),sync_pending:true,
...(Array.isArray(value.base_days)?{base_days:value.base_days.map(cloneDay)
.sort((left,right)=>left.plan_date.localeCompare(right.plan_date))}:{}),
...(value.resolutions&&typeof value.resolutions==='object'?{resolutions:{...value.resolutions}}:{}),
}:false;
} catch(_error) { return false; }
}
function adopt(value) {
if(!Number.isInteger(value?.revision)||!Array.isArray(value?.days)) return false;
week={revision:value.revision,timezone:value.timezone||null,days:value.days.map(cloneDay)
.sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
lastConflict=null;
return state();
}
function addDays(value, amount) {
const [year,month,day]=value.split('-').map(Number);
return new Date(Date.UTC(year,month-1,day+amount)).toISOString().slice(0,10);
}
function dates() {
return Array.from({length:7},(_,index)=>{
const date=addDays(localDate(),index+1);
const parsed=new Date(date+'T12:00:00Z');
return {date,label:new Intl.DateTimeFormat('en',{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'}).format(parsed)};
});
}
function day(planDate) {
const found=week.days.find(item=>item.plan_date===planDate);
return found?cloneDay(found):{plan_date:planDate,ids:[],capacity_minutes:null,estimates:{}};
}
function pass(planDate) {
const available=dates(),index=available.findIndex(item=>item.date===planDate);
if(index<0) return null;
return {position:index+1,total:available.length,planned:week.days.filter(item=>item.ids.length).length,
next_date:available[index+1]?.date||null,last:index===available.length-1};
}
async function load() {
const queued=pending();
if(queued){week=queued;return state();}
return adopt(await fetchJson('api/v1/week'));
}
function stageDays(days) {
const key=storageKey();
if(!key||!storage) return false;
const queued={base_revision:Number.isInteger(week.base_revision)?week.base_revision:week.revision,
timezone:timeZone(),days:days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),
base_days:(week.base_days||week.days).map(cloneDay)};
try { storage.setItem(key,JSON.stringify(queued)); }
catch(_error) { return false; }
lastConflict=null;
week={revision:queued.base_revision,...queued,sync_pending:true};
return state();
}
function stageDay(planDate,value) {
return stageDays(week.days.filter(item=>item.plan_date!==planDate).concat([{
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
estimates:{...(value.estimates||{})},
}]));
}
function review() {
const assigned=new Map();
const days=dates().map(item=>{
const value=day(item.date),estimates=value.estimates||{};
value.ids.forEach(id=>assigned.set(id,[...(assigned.get(id)||[]),item.date]));
const planned_minutes=value.ids.reduce((total,id)=>total+(Number(estimates[id])||0),0);
const capacity_minutes=Number(value.capacity_minutes)||0;
return {...value,label:item.label,planned_minutes,
overloaded:Boolean(capacity_minutes&&planned_minutes>capacity_minutes)};
});
const duplicates=[...assigned.entries()].filter(([_id,assignedDates])=>assignedDates.length>1)
.map(([id,assignedDates])=>({id,dates:assignedDates}));
return {days,duplicates,can_confirm:duplicates.length===0};
}
function move(id,toDate) {
if(!dates().some(item=>item.date===toDate)) return false;
const sources=week.days.filter(item=>(item.ids||[]).includes(id));
if(!sources.length) return false;
const estimate=sources.map(item=>Number(item.estimates?.[id])).find(Number.isFinite);
const moved=week.days.map(item=>{
const estimates={...(item.estimates||{})};delete estimates[id];
return {...cloneDay(item),ids:(item.ids||[]).filter(value=>value!==id),estimates};
});
let destination=moved.find(item=>item.plan_date===toDate);
if(!destination){destination={plan_date:toDate,ids:[],capacity_minutes:null,estimates:{}};moved.push(destination);}
destination.ids.push(id);
if(Number.isFinite(estimate)) destination.estimates[id]=estimate;
return Boolean(stageDays(moved));
}
function deliveryBody(value) {
return {base_revision:value.base_revision,timezone:value.timezone,days:value.days.map(cloneDay)};
}
function reconcile(local,remote) {
if(!Array.isArray(local.base_days)) return null;
const byDate=(days,date)=>days.find(day=>day.plan_date===date)||
{plan_date:date,ids:[],capacity_minutes:null,estimates:{}};
const same=(left,right)=>JSON.stringify(cloneDay(left))===JSON.stringify(cloneDay(right));
const dates=[...new Set([...local.base_days,...local.days,...remote.days].map(day=>day.plan_date))].sort();
const conflicts=[],days=dates.map(date=>{
const base=byDate(local.base_days,date),phone=byDate(local.days,date),account=byDate(remote.days,date);
const phoneChanged=!same(phone,base),accountChanged=!same(account,base);
if(phoneChanged&&accountChanged&&!same(phone,account)){
conflicts.push({plan_date:date,local:cloneDay(phone),remote:cloneDay(account)});return cloneDay(phone);
}
return cloneDay(phoneChanged?phone:account);
});
return {days,conflicts};
}
function flush() {
if(flushing) return flushing;
const key=storageKey();
if(!pending()||!key) return Promise.resolve(false);
const sameBody=(value,body)=>JSON.stringify(deliveryBody(value))===JSON.stringify(body);
const deliver=async()=>{
const queued=pending();
if(!queued)return false;
const body=deliveryBody(queued);
let saved;
try {
saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
} catch(error) {
if(error?.status===409){
const remote=await fetchJson('api/v1/week');
const local=pending();
const merged=local&&reconcile(local,remote);
if(merged&&!merged.conflicts.length){
saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},
body:JSON.stringify({base_revision:remote.revision,timezone:local.timezone,days:merged.days})});
const current=pending();
if(current&&sameBody(current,deliveryBody(local)))storage.removeItem(key);
if(!pending())adopt(saved);
return saved;
}
lastConflict={key,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
days:remote.days.map(cloneDay)},merged,choices:{...(local?.resolutions||{})}};
} else {
const current=pending();
if(current&&!sameBody(current,body))return deliver();
}
throw error;
}
const current=pending();
if(current&&sameBody(current,body)){
storage.removeItem(key);adopt(saved);return saved;
}
if(current){
try {
const raw=JSON.parse(storage.getItem(key));
raw.base_revision=saved.revision;raw.base_days=saved.days.map(cloneDay);
storage.setItem(key,JSON.stringify(raw));
week={revision:raw.base_revision,...raw,sync_pending:true};
} catch(_error){return saved;}
return deliver();
}
adopt(saved);return saved;
};
flushing=deliver().finally(()=>{flushing=null;});
return flushing;
}
async function saveDay(planDate, value) {
const local={revision:week.revision,timezone:timeZone(),days:week.days
.filter(item=>item.plan_date!==planDate).concat([{
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
estimates:{...(value.estimates||{})},
}]).sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
const body={base_revision:local.revision,timezone:local.timezone,days:local.days};
try {
return adopt(await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}));
} catch(error) {
if(error?.status===409) {
const remote=await fetchJson('api/v1/week');
lastConflict={local,remote:{revision:remote.revision,timezone:remote.timezone,days:remote.days.map(cloneDay)}};
}
throw error;
}
}
function conflict() {
const value=lastConflict&&(!lastConflict.key||lastConflict.key===storageKey())?{
local:{...lastConflict.local,days:lastConflict.local.days.map(cloneDay)},
remote:{...lastConflict.remote,days:lastConflict.remote.days.map(cloneDay)},
}:null;
if(value&&lastConflict.merged) value.conflicts=lastConflict.merged.conflicts.map(item=>({
plan_date:item.plan_date,local:cloneDay(item.local),remote:cloneDay(item.remote),
choice:lastConflict.choices[item.plan_date]||null,
}));
return value;
}
function chooseDay(planDate,source) {
if(!lastConflict?.merged||!['phone','account'].includes(source)||
!lastConflict.merged.conflicts.some(item=>item.plan_date===planDate)) return false;
const key=storageKey(),queued=pending();
if(key&&queued){
try {
const raw=JSON.parse(storage.getItem(key));
raw.resolutions={...(raw.resolutions||{}),[planDate]:source};
storage.setItem(key,JSON.stringify(raw));
} catch(_error) { return false; }
}
lastConflict.choices[planDate]=source;
return conflict();
}
async function saveMerged() {
if(!lastConflict?.merged||lastConflict.key!==storageKey()) return false;
if(lastConflict.merged.conflicts.some(item=>!lastConflict.choices[item.plan_date])) return false;
const choices=lastConflict.choices;
const days=lastConflict.merged.days.map(day=>{
const item=lastConflict.merged.conflicts.find(value=>value.plan_date===day.plan_date);
return item?cloneDay(choices[day.plan_date]==='account'?item.remote:item.local):cloneDay(day);
});
const local=lastConflict.local,body={base_revision:lastConflict.remote.revision,timezone:local.timezone,days};
try {
const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const current=pending();
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(deliveryBody(local))) storage.removeItem(storageKey());
if(!pending()) adopt(saved);
lastConflict=null;
return saved;
} catch(error) {
if(error?.status===409){
const remote=await fetchJson('api/v1/week'),merged=reconcile(local,remote);
lastConflict={key:storageKey(),local,remote:{revision:remote.revision,timezone:remote.timezone||null,
days:remote.days.map(cloneDay)},merged,choices:{...(local.resolutions||{})}};
}
throw error;
}
}
async function keepLocal() {
if(!lastConflict||lastConflict.key!==storageKey()) return false;
const conflictKey=lastConflict.key;
const local={...lastConflict.local,days:lastConflict.local.days.map(cloneDay)};
const body=deliveryBody({...local,base_revision:lastConflict.remote.revision});
try {
const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const current=pending();
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(deliveryBody(local))) storage.removeItem(storageKey());
if(!pending()) adopt(saved);
lastConflict=null;
return saved;
} catch(error) {
if(error?.status===409){
const remote=await fetchJson('api/v1/week');
lastConflict={key:conflictKey,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
days:remote.days.map(cloneDay)}};
}
throw error;
}
}
function useRemote() {
if(!lastConflict||lastConflict.key!==storageKey()) return false;
const remote=lastConflict.remote;
storage.removeItem(storageKey());
const adopted=adopt(remote);
lastConflict=null;
return adopted;
}
async function promote(todayRevision) {
if(pending()||conflict()) return false;
const due=week.days.find(item=>item.plan_date<=localDate()&&item.ids.length);
if(!due) return false;
return fetchJson('api/v1/week/promote',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
promotion_id:`week-${due.plan_date}-r${week.revision}`,week_revision:week.revision,
plan_date:due.plan_date,today_revision:todayRevision,
})});
}
function summary() {
const planned=week.days.filter(item=>item.ids.length);
const items=planned.reduce((total,item)=>total+item.ids.length,0);
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,review,move,pending,flush,conflict,chooseDay,saveMerged,
keepLocal,useRemote,promote,summary};
}
function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute,
todayWork,refresh,warm}={}) {
let selectedDate=null;
let reviewing=false;
let editingFromReview=false;
let blockedReviewOpen=false;
function renderPass() {
const progress=qs('#week-plan-progress'),save=qs('#save-today-plan');
if(reviewing){
const value=controller.review();
progress.hidden=false;
progress.textContent=`Review week · ${value.days.filter(day=>day.ids.length).length} planned`;
return;
}
if(!selectedDate){progress.hidden=true;return;}
const current=controller.pass(selectedDate);
progress.hidden=false;
progress.textContent=`Day ${current.position} of ${current.total} · ${current.planned} planned`;
save.textContent=editingFromReview?'Save & review':(current.last?'Save week':'Save & next');
}
function conflictDetail(day) {
const estimates=day.estimates||{},ids=day.ids||[];
const minutes=ids.reduce((total,id)=>total+(Number(estimates[id])||0),0),capacity=Number(day.capacity_minutes)||0;
return ids.length+' item'+(ids.length===1?'':'s')+(minutes&&capacity?' · '+minutes+' of '+capacity+' min':'');
}
function conflictMarkup(value) {
const days=value?.days||[];
return days.length?days.map(day=>'<p class="week-conflict-day"><strong>'+escapeHtml(day.plan_date)+'</strong>'+escapeHtml(conflictDetail(day))+'</p>').join(''):
'<p class="muted">Nothing planned.</p>';
}
function renderConflict(conflict) {
qs('#week-conflict-phone-plan').innerHTML=conflictMarkup(conflict.local);
qs('#week-conflict-server-plan').innerHTML=conflictMarkup(conflict.remote);
const perDay=Array.isArray(conflict.conflicts),days=qs('#week-conflict-days'),save=qs('#save-merged-week');
qs('#week-conflict-legacy-plans').hidden=perDay;qs('#week-conflict-legacy-actions').hidden=perDay;
days.hidden=!perDay;save.hidden=!perDay;
if(perDay){
days.innerHTML=conflict.conflicts.map(item=>{
const date=escapeAttribute(item.plan_date),name='week-conflict-'+date;
const choice=source=>'<label class="week-conflict-choice"><input type="radio" name="'+name+
'" data-week-conflict-choice="'+source+'" data-week-conflict-date="'+date+'"'+(item.choice===source?' checked':'')+
'><span><strong>'+(source==='phone'?'This phone':'Saved account')+'</strong><small>'+escapeHtml(conflictDetail(source==='phone'?item.local:item.remote))+'</small></span></label>';
return '<section class="week-conflict-date"><h4>'+escapeHtml(item.plan_date)+'</h4>'+choice('phone')+choice('account')+'</section>';
}).join('');
save.disabled=conflict.conflicts.some(item=>!item.choice);
days.querySelectorAll('[data-week-conflict-choice]').forEach(input=>input.addEventListener('change',event=>{
controller.chooseDay(event.currentTarget.dataset.weekConflictDate,event.currentTarget.dataset.weekConflictChoice);
save.disabled=controller.conflict().conflicts.some(item=>!item.choice);
}));
}
return perDay?days.querySelector('[data-week-conflict-choice]'):qs('#keep-phone-week');
}
function renderDates() {
const root=qs('#week-plan-dates');
root.hidden=!selectedDate;
renderPass();
if(!selectedDate)return;
root.innerHTML=controller.dates().map(item=>'<button type="button" data-week-plan-date="'+
escapeAttribute(item.date)+'"'+(item.date===selectedDate?' aria-current="date"':'')+'><strong>'+escapeHtml(item.label)+
'</strong><small>'+escapeHtml(controller.day(item.date).ids.length+' planned')+'</small></button>').join('');
root.querySelectorAll('[data-week-plan-date]').forEach(button=>button.addEventListener('click',()=>{
selectedDate=button.dataset.weekPlanDate;renderDates();openPlanner(null,false);
}));
}
function renderReview() {
const value=controller.review(),root=qs('#week-review-days'),duplicates=qs('#week-review-duplicates');
const destinations=value.days.map(day=>'<option value="'+escapeAttribute(day.plan_date)+'">'+escapeHtml(day.label)+'</option>').join('');
const itemMarkup=(id,day)=>{
const item=getItem(id),estimate=Number(day.estimates?.[id])||0;
if(!item)return '<div class="week-review-item-copy"><strong>Work details unavailable</strong><small>'+escapeHtml(String(id).slice(0,96))+'</small></div>';
const kind=item.kind==='pull'?'Pull request':'Issue';
const reference=item.repository&&item.number!=null?item.repository+' #'+item.number:kind;
return '<div class="week-review-item-copy"><strong>'+escapeHtml(item.title||'Untitled work')+'</strong><small>'+escapeHtml(reference+' · '+kind+(estimate?' · '+estimate+' min':''))+'</small></div>';
};
root.innerHTML=value.days.map(day=>{
const capacity=Number(day.capacity_minutes)||0;
const load=day.planned_minutes+(capacity?' of '+capacity:'')+' min'+(day.overloaded?' · over capacity':'');
const items=day.ids.length?'<ul>'+day.ids.map(id=>'<li>'+itemMarkup(id,day)+'<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></li>').join('')+'</ul>':
'<p class="small muted">Nothing planned.</p>';
return '<article class="week-review-day'+(day.overloaded?' is-overloaded':'')+'"><header><h3>'+escapeHtml(day.label)+'</h3><button type="button" data-week-edit-day="'+
escapeAttribute(day.plan_date)+'" aria-label="Edit '+escapeAttribute(day.label)+'">Edit day</button></header><p class="week-review-load">'+
escapeHtml(load)+'</p>'+items+'</article>';
}).join('');
duplicates.hidden=!value.duplicates.length;
duplicates.innerHTML=value.duplicates.length?'<strong>Choose one date for duplicated work before confirming.</strong><ul>'+value.duplicates.map(item=>
'<li><code>'+escapeHtml(item.id)+'</code> · '+escapeHtml(item.dates.join(', '))+'</li>').join('')+'</ul>':'';
const confirm=qs('#confirm-week-plan');
const pending=Boolean(controller.pending?.());
confirm.disabled=!value.can_confirm||pending;
qs('#week-review-status').textContent=value.duplicates.length?'Duplicate work must be moved to one date.':
(pending?'Saving the latest week to your account…':'Week Ahead is balanced and ready.');
root.querySelectorAll('[data-week-move]').forEach(button=>button.addEventListener('click',()=>{
const selector=root.querySelector(`[data-week-move-destination="${button.dataset.weekMove}"]`);
if(!controller.move(button.dataset.weekMove,selector?.value))return;
renderReview();renderPass();
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-edit-day]').forEach(button=>button.addEventListener('click',()=>editDay(button.dataset.weekEditDay)));
renderPass();
}
function editDay(planDate) {
if(!controller.dates().some(item=>item.date===planDate))return false;
reviewing=false;editingFromReview=true;selectedDate=planDate;setReviewMode(false);
qs('#week-review').hidden=true;
const back=qs('#back-to-week-review');if(back)back.hidden=false;
renderDates();openPlanner(null,false);
qs('#week-plan-progress')?.focus?.();
return true;
}
function returnToReview() {
if(!editingFromReview)return false;
const editedDate=selectedDate;
editingFromReview=false;reviewing=true;selectedDate=null;setReviewMode(true);
qs('#week-review').hidden=false;
const back=qs('#back-to-week-review');if(back)back.hidden=true;
renderDates();renderReview();openPlanner(null,false);
qs('#week-review-days').querySelector(`[data-week-edit-day="${editedDate}"]`)?.focus?.();
return true;
}
async function open(trigger) {
trigger.disabled=true;reviewing=false;editingFromReview=false;setReviewMode(false);const reviewRoot=qs('#week-review');if(reviewRoot)reviewRoot.hidden=true;
const back=qs('#back-to-week-review');if(back)back.hidden=true;
selectedDate=controller.dates()[0].date;renderDates();openPlanner(trigger);
qs('#mobile-week-summary').textContent='Loading Week Ahead…';
try{await controller.load();qs('#mobile-week-summary').textContent=controller.summary();renderDates();openPlanner(null,false);return true;}
catch(error){qs('#mobile-week-summary').textContent='Unavailable · tap to retry';qs('#my-work-action-status').textContent=(error.message||'Week Ahead is unavailable.')+' Retry when connected.';return false;}
finally{trigger.disabled=false;}
}
function save(plan){
const date=selectedDate,normalized=Array.isArray(plan)?{ids:plan,capacity_minutes:null,estimates:{}}:plan;
const staged=controller.stageDay(date,normalized);
if(!staged){qs('#my-work-action-status').textContent='Week Ahead could not be saved on this phone. Free browser storage and retry.';return false;}
qs('#mobile-week-summary').textContent=controller.summary();
qs('#my-work-action-status').textContent='Week Ahead saved on this phone · sync pending.';
controller.flush().then(()=>{qs('#mobile-week-summary').textContent=controller.summary();qs('#my-work-action-status').textContent=(normalized.ids.length?'Week Ahead saved for ':'Week Ahead cleared for ')+date+'.';if(reviewing)renderReview();})
.catch(error=>{
const conflict=controller.conflict();
qs('#mobile-week-summary').textContent=conflict?'Conflict · review required':controller.summary();
qs('#my-work-action-status').textContent=conflict?'Another device changed Week Ahead. Both versions are preserved; choose each changed day.':
(error.message||'Week Ahead sync is unavailable.')+' Saved on this phone · sync pending.';
if(conflict) openPlanner(null,false);
});
return true;
}
function advance() {
if(editingFromReview)return returnToReview();
const current=selectedDate&&controller.pass(selectedDate);
if(current?.last){
reviewing=true;selectedDate=null;setReviewMode(true);qs('#week-review').hidden=false;
renderDates();renderReview();openPlanner(null,false);qs('#confirm-week-plan').focus?.();return true;
}
if(!current?.next_date) return false;
selectedDate=current.next_date;renderDates();openPlanner(null,false);
const selected=qs('#week-plan-dates').querySelector(`[data-week-plan-date="${selectedDate}"]`);
selected?.scrollIntoView({block:'nearest',inline:'center'});selected?.focus?.();
return true;
}
function confirm() {
const value=controller.review();
if(!reviewing||!value.can_confirm||controller.pending()) return false;
reviewing=false;setReviewMode(false);qs('#week-review').hidden=true;return true;
}
qs('#back-to-week-review')?.addEventListener('click',returnToReview);
async function promote(plan){
try{await controller.load();const promoted=await controller.promote(plan.revision);if(!promoted)return false;blockedReviewOpen=false;todayWork.replace(promoted.ids);todayWork.replacePlanning({capacity_minutes:promoted.capacity_minutes??null,estimates:promoted.estimates||{}});refresh();warm();qs('#mobile-week-summary').textContent=controller.summary();qs('#my-work-action-status').textContent='Your saved Week Ahead plan is now Today.';return true;}
catch(error){
if(error?.code==='week_today_in_progress'){
qs('#my-work-action-status').textContent='Review unfinished Today before starting the saved Week Ahead day.';
if(!blockedReviewOpen){blockedReviewOpen=true;openPlanner(null,false);}
return false;
}
blockedReviewOpen=false;
qs('#my-work-action-status').textContent=(error.message||'Week Ahead needs review before promotion.')+' Open Week Ahead to review.';return false;
}
}
return {open,save,advance,confirm,promote,renderDates,renderConflict,active:()=>Boolean(selectedDate)||reviewing,
reviewing:()=>reviewing,selectedDate:()=>selectedDate,
day:()=>selectedDate?controller.day(selectedDate):null,
copy:()=>selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null,
clear(){selectedDate=null;reviewing=false;editingFromReview=false;setReviewMode(false);qs('#week-review').hidden=true;
const back=qs('#back-to-week-review');if(back)back.hidden=true;renderDates();}};
}
if(typeof module!=='undefined'&&module.exports){
module.exports=createWeekPlan;
module.exports.Workflow=createWeekPlanWorkflow;
}