stackchain-dashboard/frontend/week-plan.js
timmy e77ad7c809
All checks were successful
CI / lint (pull_request) Successful in 4m1s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m19s
CI / release-candidate (pull_request) Has been skipped
feat: import private calendar capacity for Week Ahead (Closes #1206)
2026-08-21 03:16:40 +00:00

566 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 stageCapacities(values) {
const available=dates().map(item=>item.date);
if(!Array.isArray(values)||values.length!==available.length)return false;
const capacities=new Map(values.map(value=>[value?.plan_date,value?.capacity_minutes]));
if(capacities.size!==available.length||available.some(date=>{
const value=capacities.get(date);return !Number.isInteger(value)||value<0||value>1440;
}))return false;
return stageDays(available.map(planDate=>{
const value=day(planDate);value.capacity_minutes=capacities.get(planDate);return value;
}));
}
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}));
const blockers=[];
days.forEach(value=>{
if(value.ids.length&&!Number(value.capacity_minutes))blockers.push({type:'missing-capacity',plan_date:value.plan_date});
value.ids.forEach(id=>{if(!(Number(value.estimates?.[id])>0))blockers.push({type:'missing-estimate',plan_date:value.plan_date,id});});
if(value.overloaded)blockers.push({type:'over-capacity',plan_date:value.plan_date,
minutes:value.planned_minutes-Number(value.capacity_minutes)});
});
blockers.sort((left,right)=>left.plan_date.localeCompare(right.plan_date)||
['over-capacity','missing-estimate','missing-capacity'].indexOf(left.type)-['over-capacity','missing-estimate','missing-capacity'].indexOf(right.type));
return {days,duplicates,blockers,can_confirm:duplicates.length===0&&blockers.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 placement(id) {
const found=week.days.find(item=>(item.ids||[]).includes(id));
if(!found)return null;
const estimate=Number(found.estimates?.[id]);
return {date:found.plan_date,estimate:Number.isFinite(estimate)?estimate:null};
}
function place(id,toDate,estimate,{move:allowMove=false}={}) {
if(!id||!dates().some(item=>item.date===toDate)||!Number.isFinite(Number(estimate))||Number(estimate)<=0)return false;
const existing=placement(id);
if(existing&&existing.date!==toDate&&!allowMove)return false;
const changed=week.days.map(item=>{
const estimates={...(item.estimates||{})};
if(item.plan_date!==toDate){delete estimates[id];return {...cloneDay(item),ids:(item.ids||[]).filter(value=>value!==id),estimates};}
return cloneDay(item);
});
let destination=changed.find(item=>item.plan_date===toDate);
if(!destination){destination={plan_date:toDate,ids:[],capacity_minutes:null,estimates:{}};changed.push(destination);}
if(!destination.ids.includes(id))destination.ids.push(id);
destination.estimates[id]=Number(estimate);
return Boolean(stageDays(changed));
}
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,
})});
}
async function reconcilePromotion(preserved,selection) {
if(pending()||conflict()) return false;
const due=(preserved?.week?.days||week.days).find(item=>item.plan_date<=localDate()&&item.ids.length);
if(!due||!Number.isInteger(preserved?.today?.revision)) return false;
const result=await fetchJson('api/v1/week/reconcile',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
promotion_id:`reconcile-${due.plan_date}-r${preserved.week.revision}`,
week_revision:preserved.week.revision,plan_date:due.plan_date,today_revision:preserved.today.revision,
ids:[...(selection.ids||[])],capacity_minutes:selection.capacity_minutes??null,estimates:{...(selection.estimates||{})},
})});
adopt(result.week);
return result;
}
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,stageCapacities,review,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
keepLocal,useRemote,promote,reconcile:reconcilePromotion,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;
let reconciliation=null;
function reconciliationDay(){
if(!reconciliation)return null;
const due=(reconciliation.week?.days||[]).find(day=>day.ids?.length);
if(!due)return null;
const ids=[...new Set([...(reconciliation.today?.ids||[]),...(due.ids||[])])];
return {plan_date:due.plan_date,ids,capacity_minutes:due.capacity_minutes??reconciliation.today?.capacity_minutes??null,
estimates:{...(due.estimates||{}),...(reconciliation.today?.estimates||{})}};
}
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;
const blocker=value.blockers?.[0];
qs('#week-review-status').textContent=value.duplicates.length?'Duplicate work must be moved to one date.':
(pending?'Saving the latest week to your account…':blocker?(
blocker.type==='over-capacity'?blocker.plan_date+' is '+blocker.minutes+' min over capacity. Edit that day before confirming.':
blocker.type==='missing-estimate'?'Add an estimate for work on '+blocker.plan_date+' before confirming.':
'Add capacity for '+blocker.plan_date+' before confirming.'
):'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;
if(reconciliation){
const preserved=reconciliation;
controller.reconcile(preserved,normalized).then(result=>{
reconciliation=null;blockedReviewOpen=false;
todayWork.replace(result.today.ids);todayWork.replacePlanning(result.today);
refresh();warm();qs('#mobile-week-summary').textContent=controller.summary();
qs('#my-work-action-status').textContent='Today started from unfinished and Week Ahead work.';
}).catch(error=>{
qs('#my-work-action-status').textContent=(error.message||'Plans changed during review.')+' Reopen Start todays plan.';
});
return true;
}
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;
return true;
}
function finish(){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'){
reconciliation={today:plan,week:controller.state()};
qs('#my-work-action-status').textContent='Review unfinished Today with the due Week Ahead work before starting.';
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;
}
}
const workflow={open,save,advance,confirm,finish,promote,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation),
reviewing:()=>reviewing,selectedDate:()=>selectedDate,
day:()=>reconciliationDay()||(selectedDate?controller.day(selectedDate):null),
copy:()=>reconciliation?{title:"Start today's plan",heading:'Unfinished Today + due Week Ahead',available:'Available today',build:'Build combined Today'}:
(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;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);
return workflow;
}
if(typeof module!=='undefined'&&module.exports){
module.exports=createWeekPlan;
module.exports.Workflow=createWeekPlanWorkflow;
}