stackchain-dashboard/frontend/week-plan.js
timmy c7d38593dd
All checks were successful
CI / lint (pull_request) Successful in 3m7s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m2s
CI / release-candidate (pull_request) Has been skipped
feat: keep Week-to-Today pulls through reloads (Closes #1246)
2026-08-22 02:41:29 +00:00

1048 lines
58 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,now=()=>Date.now()}={}) {
let week={revision:0,timezone:null,days:[]};
let flushing=null;
let lastConflict=null;
let offlineSnapshot=false;
let refreshedAt=null;
let confirmedItems={};
let pendingItems={};
let pulling=null;
const storagePrefix='stackchain.week-sync.v1.';
const confirmedPrefix='stackchain.week-confirmed.v1.';
const pullPrefix='stackchain.week-today-pull.v1.';
const cloneDay=day=>({
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
estimates:{...(day.estimates||{})},
});
const sanitizeItem=value=>value?{kind:value.kind==='pull'?'pull':'issue',
title:String(value.title||'').slice(0,500),repository:String(value.repository||'').slice(0,255),
number:Number.isInteger(value.number)?value.number:null}:null;
function sanitizeItems(days,sources={}) {
const sanitized={};
new Set((days||[]).flatMap(day=>day.ids||[])).forEach(id=>{
const value=sanitizeItem(sources[id]);if(value)sanitized[id]=value;
});
return sanitized;
}
const state=()=>({revision:week.revision,timezone:week.timezone,days:week.days.map(cloneDay),
offline_snapshot:offlineSnapshot,...(refreshedAt?{refreshed_at:refreshedAt}:{}),
...(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 confirmedKey() {
const login=String(getLogin?.()||'').trim().toLowerCase();
return login?confirmedPrefix+encodeURIComponent(login):'';
}
function pullKey() {
const login=String(getLogin?.()||'').trim().toLowerCase();
return login?pullPrefix+encodeURIComponent(login):'';
}
function pendingPull() {
const key=pullKey();
if(!key||!storage)return null;
try {
const value=JSON.parse(storage.getItem(key)||'null');
return typeof value?.body?.operation_id==='string'&&Array.isArray(value?.today?.ids)&&
Number.isInteger(value?.week?.revision)&&Array.isArray(value?.week?.days)?value:null;
} catch(_error){return null;}
}
function resumePull() {
const value=pendingPull();
if(!value)return null;
adopt(value.week);
return {...value,sync_pending:true};
}
function readConfirmed() {
const key=confirmedKey();
if(!key||!storage)return null;
try {
const value=JSON.parse(storage.getItem(key)||'null');
if(!Number.isInteger(value?.week?.revision)||!Array.isArray(value?.week?.days)||
typeof value?.refreshed_at!=='string')return null;
return value;
} catch(_error){return null;}
}
function persistConfirmed() {
const key=confirmedKey();
if(!key||!storage||week.sync_pending)return false;
const record={refreshed_at:new Date(now()).toISOString(),week:{revision:week.revision,
timezone:week.timezone,days:week.days.map(cloneDay)},items:{...confirmedItems}};
try {storage.setItem(key,JSON.stringify(record));refreshedAt=record.refreshed_at;return true;}
catch(_error){return false;}
}
function rememberItems(items={}) {
confirmedItems=sanitizeItems(week.days,{...confirmedItems,...items});
return persistConfirmed();
}
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,
items:sanitizeItems(value.days,value.items||{}),
...(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))};
offlineSnapshot=false;
pendingItems={};
lastConflict=null;
return state();
}
function adoptConfirmed(value,items=confirmedItems) {
const adopted=adopt(value);
if(adopted){confirmedItems=sanitizeItems(week.days,items);persistConfirmed();}
return adopted;
}
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;pendingItems=queued.items||{};
confirmedItems=sanitizeItems(week.days,readConfirmed()?.items||{});
offlineSnapshot=false;return state();
}
try {
adopt(await fetchJson('api/v1/week'));
const cached=readConfirmed();confirmedItems=sanitizeItems(week.days,cached?.items||{});persistConfirmed();
return state();
} catch(error) {
if(error?.status===401||error?.status===403)throw error;
const cached=readConfirmed();
if(!cached)throw error;
adopt(cached.week);offlineSnapshot=true;refreshedAt=cached.refreshed_at;
confirmedItems=sanitizeItems(week.days,cached.items||{});
return state();
}
}
function stageDays(days) {
if(offlineSnapshot)return false;
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)};
queued.items=sanitizeItems(queued.days,{...confirmedItems,...pendingItems,...(pending()?.items||{})});
try { storage.setItem(key,JSON.stringify(queued)); }
catch(_error) { return false; }
lastConflict=null;
week={revision:queued.base_revision,...queued,sync_pending:true};
pendingItems=queued.items;
return state();
}
function rememberPendingItem(id,value) {
const key=storageKey(),item=sanitizeItem(value);
if(!key||!storage||!item)return false;
try {
const raw=JSON.parse(storage.getItem(key)||'null');
if(!Number.isInteger(raw?.base_revision)||!Array.isArray(raw?.days)||
!raw.days.some(day=>(day.ids||[]).includes(id)))return false;
raw.items=sanitizeItems(raw.days,{...(raw.items||{}),[id]:item});
storage.setItem(key,JSON.stringify(raw));pendingItems=raw.items;
if(week.sync_pending)week={...week,items:raw.items};
return true;
} catch(_error){return false;}
}
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 previewReflow() {
const available=dates().map(item=>day(item.date));
const seen=new Set(),work=[];
available.forEach(source=>(source.ids||[]).forEach(id=>{
if(seen.has(id))return;
seen.add(id);
const estimate=Number(source.estimates?.[id]);
work.push({id,estimate:Number.isFinite(estimate)?estimate:null,from_date:source.plan_date});
}));
const blockers=work.filter(item=>!(item.estimate>0)).map(item=>({
type:'missing-estimate',id:item.id,plan_date:item.from_date,
}));
if(blockers.length)return {days:[],blockers,unscheduled:[]};
const days=available.map(source=>({plan_date:source.plan_date,ids:[],
capacity_minutes:source.capacity_minutes??null,estimates:{}}));
const unscheduled=[];let cursor=0;
if(!blockers.length)work.forEach(item=>{
const destination=days.slice(cursor).find(value=>value.ids.length<5&&Number(value.capacity_minutes)>0&&
value.ids.reduce((total,id)=>total+Number(value.estimates[id]),0)+item.estimate<=Number(value.capacity_minutes));
if(!destination){unscheduled.push({...item});return;}
cursor=days.indexOf(destination);
destination.ids.push(item.id);destination.estimates[item.id]=item.estimate;
});
return {days,blockers,unscheduled};
}
function applyReflow() {
const preview=previewReflow();
if(preview.blockers.length||offlineSnapshot)return false;
return stageDays(preview.days);
}
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 retire(id) {
const removed=week.days.reduce((total,item)=>total+(item.ids||[]).filter(value=>value===id).length,0);
if(!id||!removed||offlineSnapshot)return false;
const days=week.days.map(item=>{
const estimates={...(item.estimates||{})};delete estimates[id];
return {...cloneDay(item),ids:(item.ids||[]).filter(value=>value!==id),estimates};
});
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;
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())adoptConfirmed(saved,{...confirmedItems,...(local.items||{})});
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);adoptConfirmed(saved,{...confirmedItems,...(current.items||{})});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};
pendingItems=raw.items||{};
} catch(_error){return saved;}
return deliver();
}
adoptConfirmed(saved,{...confirmedItems,...(queued.items||{})});return saved;
};
flushing=deliver().finally(()=>{flushing=null;});
return flushing;
}
async function saveDay(planDate, value) {
if(offlineSnapshot)return false;
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 adoptConfirmed(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()) adoptConfirmed(saved,{...confirmedItems,...(local.items||{})});
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()) adoptConfirmed(saved,{...confirmedItems,...(local.items||{})});
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=adoptConfirmed(remote);
lastConflict=null;
return adopted;
}
async function promote(todayRevision) {
if(offlineSnapshot||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 pullItem(identity,today,operationId,allowOverCapacity=false) {
if(offlineSnapshot||pending()||conflict()||!identity||!operationId||
!Number.isInteger(today?.revision)||!(today.ids||[]).length)return false;
const body={operation_id:operationId,identity,today_revision:today.revision,week_revision:week.revision,
allow_over_capacity:Boolean(allowOverCapacity)};
const key=pullKey();
if(key&&storage){
const source=week.days.find(day=>(day.ids||[]).includes(identity));
if(!source)return false;
const estimate=Number(source.estimates?.[identity]);
const optimisticToday={...today,ids:[...(today.ids||[]),identity],estimates:{...(today.estimates||{}),
...(Number.isFinite(estimate)?{[identity]:estimate}:{})}};
const optimisticWeek={revision:week.revision,timezone:week.timezone,days:week.days.map(day=>{
const estimates={...(day.estimates||{})};delete estimates[identity];
return {...cloneDay(day),ids:(day.ids||[]).filter(id=>id!==identity),estimates};
})};
const record={body,today:optimisticToday,week:optimisticWeek,queued_at:now()};
try {storage.setItem(key,JSON.stringify(record));}
catch(_error){throw new Error('Could not save this pull on this device. Nothing changed.');}
adopt(optimisticWeek);
try{return await flushPull();}
catch(error){
if(error?.status===401||error?.status===403)throw error;
return {...record,sync_pending:true};
}
}
const result=await fetchJson('api/v1/week/pull-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
adoptConfirmed(result.week,{...confirmedItems,...pendingItems});return result;
}
function flushPull() {
if(pulling)return pulling;
const record=pendingPull(),key=pullKey();
if(!record||!key)return Promise.resolve(false);
pulling=(async()=>{
let result;
try {
result=await fetchJson('api/v1/week/pull-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(record.body)});
} catch(error) {
if(error?.status!==409)throw error;
const [today,currentWeek]=await Promise.all([fetchJson('api/v1/today'),fetchJson('api/v1/week')]);
const conflicted={...record,conflict:true,current:{today,week:currentWeek}};
storage.setItem(key,JSON.stringify(conflicted));
adopt(currentWeek);
return {today,week:currentWeek,conflict:true,sync_pending:true};
}
const current=pendingPull();
if(current?.body?.operation_id===record.body.operation_id)storage.removeItem(key);
adoptConfirmed(result.week,{...confirmedItems,...pendingItems});
return {...result,sync_pending:Boolean(pendingPull())};
})().finally(()=>{pulling=null;});
return pulling;
}
async function startEarly(planDate,todayRevision) {
if(offlineSnapshot||pending()||conflict()||!Number.isInteger(todayRevision))return false;
const day=week.days.find(item=>item.plan_date===planDate&&item.ids.length);
if(!day)return false;
const promoted=await fetchJson('api/v1/week/start-early',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
promotion_id:`early-${day.plan_date}-r${week.revision}`,week_revision:week.revision,
plan_date:day.plan_date,today_revision:todayRevision,
})});
await load();
return promoted;
}
async function reconcilePromotion(preserved,selection) {
if(offlineSnapshot||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||{})},
})});
adoptConfirmed(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,previewReflow,applyReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged,
keepLocal,useRemote,promote,pullItem,pendingPull,resumePull,flushPull,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,setTimer=globalThis.setTimeout,clearTimer=globalThis.clearTimeout,
operationId=()=>globalThis.crypto?.randomUUID?.()||('pull-'+Date.now()+'-'+Math.random().toString(16).slice(2)),x=null}={}) {
let selectedDate=null;
let reviewing=false;
let overviewing=false;
let editingFromReview=false;
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);
if(notice){
notice.hidden=!offline;
if(offline){
const refreshed=new Date(value.refreshed_at);
const label=Number.isNaN(refreshed.getTime())?'an earlier sync':refreshed.toLocaleString('en',{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
notice.textContent='Offline snapshot · refreshed '+label+'. Viewing only until live data returns.';
}
}
if(retry)retry.hidden=!offline;
if(capacity)capacity.hidden=offline;
}
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 closeReflow() {
const panel=qs('#week-reflow-review');if(panel)panel.hidden=true;
const apply=qs('#apply-week-reflow');if(apply)apply.disabled=false;
return true;
}
function openReflow() {
const preview=controller.previewReflow?.();
if(!preview)return false;
const labels=new Map(controller.dates().map(item=>[item.date,item.label]));
const panel=qs('#week-reflow-review'),days=qs('#week-reflow-days'),unscheduled=qs('#week-reflow-unscheduled');
panel.hidden=false;
if(preview.blockers.length){
days.innerHTML='';
const blocker=preview.blockers[0],item=getItem(blocker.id)||controller.item?.(blocker.id);
unscheduled.textContent='Add an estimate for '+(item?.title||blocker.id)+' before reflowing.';
qs('#apply-week-reflow').disabled=true;
panel.focus?.();return preview;
}
days.innerHTML=preview.days.filter(day=>day.ids.length).map(day=>{
const minutes=day.ids.reduce((total,id)=>total+Number(day.estimates?.[id]||0),0);
return '<div class="week-reflow-day"><strong>'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+
'</strong><span>'+escapeHtml(day.ids.length+' item'+(day.ids.length===1?'':'s')+' · '+minutes+' of '+Number(day.capacity_minutes||0)+' min')+'</span></div>';
}).join('')||'<p class="small muted">No work can be scheduled with the current capacities.</p>';
if(preview.unscheduled.length){
const names=preview.unscheduled.map(value=>(getItem(value.id)||controller.item?.(value.id))?.title||value.id);
unscheduled.textContent=names.length+' item'+(names.length===1?'':'s')+' cannot fit: '+names.join(', ')+'. '+
(names.length===1?'It':'They')+' will remain in My Work.';
} else unscheduled.textContent='All planned work fits within capacity.';
panel.focus?.();return preview;
}
async function applyReflow() {
const button=qs('#apply-week-reflow');button.disabled=true;
const staged=controller.applyReflow?.();
if(!staged){button.disabled=false;return false;}
closeReflow();renderReview();
try {
await controller.flush();renderReview();
qs('#mobile-week-summary').textContent=controller.summary();
qs('#week-review-status').textContent='Week Ahead reflowed and saved.';
return true;
} catch(error) {
renderReview();
qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Reflow remains saved on this phone.';
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?.());
const pending=Boolean(controller.pending?.());
const nextUp=overviewing?value.days.find(day=>day.ids.length):null;
const today=getTodayPlan?.();
const canStartEarly=Boolean(nextUp&&!readOnly&&!pending&&Number.isInteger(today?.revision)&&!today.ids?.length);
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)||controller.item?.(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;
const copy='<span class="week-review-item-copy"><strong>'+escapeHtml(item.title||'Untitled work')+'</strong><small>'+escapeHtml(reference+' · '+kind+(estimate?' · '+estimate+' min':''))+'</small></span>';
const actionable=overviewing&&['issue','pull'].includes(item.kind)&&item.repository&&item.number>0;
return actionable?'<button type="button" class="week-review-item-open" data-week-open-item="'+escapeAttribute(id)+'">'+copy+'</button>':copy;
};
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 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 unplan=id=>overviewing&&!readOnly?'<button type="button" class="week-review-unplan" data-week-unplan="'+escapeAttribute(id)+'">Remove from week</button>':'';
const pull=id=>{
const inToday=(today?.ids||[]).includes(id),full=(today?.ids||[]).length>=5;
if(!overviewing||readOnly||pending||!Number.isInteger(today?.revision)||!(today?.ids||[]).length||inToday)return '';
const todayMinutes=(today.ids||[]).reduce((total,itemId)=>total+(Number(today.estimates?.[itemId])||0),0);
const projected=todayMinutes+(Number(day.estimates?.[id])||0),capacity=Number(today.capacity_minutes)||0;
const overload=capacity&&projected>capacity;
return '<button type="button" class="week-review-pull" data-week-pull-item="'+escapeAttribute(id)+'"'+(full?' disabled':'')+'>'+
(full?'Today is full':(overload?'Review Today · '+projected+' of '+capacity+' min':'Add to Today'))+'</button>';
};
const items=day.ids.length?'<ul>'+day.ids.map(id=>'<li>'+itemMarkup(id,day)+move(id)+pull(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>':'';
return '<article class="week-review-day'+(day.overloaded?' is-overloaded':'')+(day===nextUp?' is-next-up':'')+'"><header><h3>'+escapeHtml(day.label)+(day===nextUp?' <span class="week-next-up">Next up</span>':'')+'</h3>'+edit+'</header><p class="week-review-load">'+
escapeHtml(load)+'</p>'+items+start+'</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');
confirm.disabled=!value.can_confirm||pending;
const blocker=value.blockers?.[0];
qs('#week-review-status').textContent=readOnly?'Offline snapshot · viewing only. Retry for live editing.':(overviewing?'Week Ahead overview · '+(pending?'sync pending.':'no changes made.'):(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.')));
confirm.hidden=overviewing||readOnly;
const reflow=qs('#open-week-reflow');if(reflow)reflow.hidden=!overviewing||readOnly||pending||!value.days.some(day=>day.overloaded);
const editWeek=qs('#edit-week-plan');if(editWeek){editWeek.hidden=!overviewing||readOnly;editWeek.textContent=nextUp?'Edit week':'Plan Week Ahead';}
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-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-pull-item]').forEach(button=>button.addEventListener('click',async event=>{
const id=event.currentTarget.dataset.weekPullItem,current=getTodayPlan?.();
if(!id||!Number.isInteger(current?.revision)||!(current.ids||[]).length||current.ids.length>=5)return;
const source=value.days.find(day=>day.ids.includes(id)),estimate=Number(source?.estimates?.[id])||0;
const todayMinutes=current.ids.reduce((total,itemId)=>total+(Number(current.estimates?.[itemId])||0),0);
const projected=todayMinutes+estimate,capacity=Number(current.capacity_minutes)||0;
const overCapacity=Boolean(capacity&&projected>capacity),item=getItem(id)||controller.item?.(id);
if(overCapacity&&!confirmEarly('Add '+String(item?.title||'work')+' to Today?\n\nToday will be '+projected+' of '+capacity+' min · '+(projected-capacity)+' min over capacity.'))return;
event.currentTarget.disabled=true;
try{
const result=await controller.pullItem?.(id,current,operationId(),overCapacity);
if(!result)return;
getTodayPlan?.(result.today);todayWork.replace(result.today.ids);
todayWork.replacePlanning({capacity_minutes:result.today.capacity_minutes??null,estimates:result.today.estimates||{}});
refresh();warm();renderReview();
qs('#mobile-week-summary').textContent=controller.summary();
qs('#week-review-status').textContent=result.sync_pending?String(item?.title||'Work')+' added to Today · sync pending.':
String(item?.title||'Work')+' added to Today.';
}catch(error){
renderReview();
qs('#week-review-status').textContent=error?.code==='today_full'?'Today is full. Finish or move work before adding more.':
((error.message||'Today or Week Ahead changed.')+' Reopen Week Ahead and retry.');
}
}));
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);
}));
root.querySelectorAll('[data-week-start-early]').forEach(button=>button.addEventListener('click',async event=>{
const day=value.days.find(item=>item.plan_date===event.currentTarget.dataset.weekStartEarly);
const current=getTodayPlan?.();
if(!day||!Number.isInteger(current?.revision)||current.ids?.length)return;
const capacity=Number(day.capacity_minutes)||0;
const detail=day.ids.length+' item'+(day.ids.length===1?'':'s')+' · '+day.planned_minutes+(capacity?' of '+capacity:'')+' min';
if(!confirmEarly('Start '+day.label+' early?\n\n'+detail+' will move into Today.'))return;
event.currentTarget.disabled=true;
try{
const promoted=await controller.startEarly(day.plan_date,current.revision);
if(!promoted)return;
todayWork.replace(promoted.ids);todayWork.replacePlanning(promoted);refresh();warm();
qs('#mobile-week-summary').textContent=controller.summary();
qs('#my-work-action-status').textContent=day.label+' is now Today.';
finish();
}catch(error){
qs('#my-work-action-status').textContent=(error.message||'Week Ahead changed before it could start.')+' Reopen Week Ahead and review.';
event.currentTarget.disabled=false;
}
}));
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 editWeek() {
overviewing=false;reviewing=false;selectedDate=controller.dates()[0].date;setReviewMode(false);
qs('#week-review').hidden=true;qs('#confirm-week-plan').hidden=false;
const edit=qs('#edit-week-plan');if(edit)edit.hidden=true;
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) {
if(trigger)trigger.disabled=true;reviewing=false;overviewing=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=null;renderDates();openPlanner(trigger);
qs('#mobile-week-summary').textContent='Loading Week Ahead…';
try{
loadState=await controller.load();
if(!loadState.offline_snapshot&&controller.rememberItems){
const items={};
(loadState.days||controller.state?.().days||[]).flatMap(day=>day.ids||[]).forEach(id=>{const value=getItem(id);if(value)items[id]=value;});
controller.rememberItems(items);
}
renderOfflineState();qs('#mobile-week-summary').textContent=controller.summary();overviewing=true;reviewing=true;
setReviewMode(true);reviewRoot.hidden=false;renderDates();renderReview();openPlanner(null,false);return true;}
catch(error){renderOfflineState(null);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{if(trigger)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;
}
async function retire(item) {
const staged=controller.retire?.(todayWork.identity({...item,kind:'issue'}));
if(!staged)return false;
const title=String(item?.title||'Completed work');
const refreshOverview=message=>{
if(overviewing||reviewing)renderReview();
qs('#week-review-status').textContent=message;
qs('#edit-week-plan')?.focus?.();
};
try {
await controller.flush();
refreshOverview(title+' removed and Week Ahead saved.');
return 'saved';
} catch(_error) {
refreshOverview(title+' removed · sync pending.');
return 'pending';
}
}
function adoptPull(value,message) {
if(!value?.today)return false;
t(value.today);todayWork.replace(value.today.ids);
todayWork.replacePlanning({capacity_minutes:value.today.capacity_minutes??null,estimates:value.today.estimates||{}});
refresh();warm();
qs('#mobile-week-summary').textContent=controller.summary();
qs('#my-work-action-status').textContent=message;
return value;
}
function resumePull() {
const restored=controller.resumePull?.();
return restored?adoptPull(restored,'Week-to-Today pull saved on this phone · sync pending.'):false;
}
async function flushPull() {
const pendingPull=controller.pendingPull?.();
if(!pendingPull)return false;
if(pendingPull.conflict)return adoptPull(pendingPull.current,
'Today or Week Ahead changed on another device. Saved pull needs review.');
try {
const result=await controller.flushPull();
return adoptPull(result,result.conflict?'Today or Week Ahead changed on another device. Saved pull needs review.':
'Week Ahead work added to Today.');
} catch(error) {
qs('#my-work-action-status').textContent=(error.message||'Week-to-Today sync is unavailable.')+' Saved on this phone · sync pending.';
return false;
}
}
function finish(){reviewing=false;overviewing=false;setReviewMode(false);closeReflow();qs('#week-review').hidden=true;return true;}
qs('#back-to-week-review')?.addEventListener('click',returnToReview);
qs('#edit-week-plan')?.addEventListener('click',editWeek);
qs('#open-week-reflow')?.addEventListener('click',openReflow);
qs('#cancel-week-reflow')?.addEventListener('click',closeReflow);
qs('#apply-week-reflow')?.addEventListener('click',applyReflow);
qs('#retry-week-live')?.addEventListener('click',event=>open(event.currentTarget));
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,retire,resumePull,flushPull,editWeek,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation),
reviewing:()=>reviewing,overviewing:()=>overviewing,selectedDate:()=>selectedDate,
day:()=>reconciliationDay()||(selectedDate?controller.day(selectedDate):(overviewing?{ids:[]}:null)),
copy:()=>reconciliation?{title:"Start today's plan",heading:'Unfinished Today + due Week Ahead',available:'Available today',build:'Build combined Today'}:
(overviewing?{title:'Week Ahead',heading:'Seven-day overview',available:'Planned work',build:'Edit week'}:
(selectedDate?{title:'Plan Week Ahead',heading:selectedDate+', in order',available:'Available this day',build:'Build this day'}:null)),
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();}};
globalThis.addEventListener?.('stackchain:issue-closed',event=>retire(event.detail));
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,...controller.reschedule(),
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){
module.exports=createWeekPlan;
module.exports.Workflow=createWeekPlanWorkflow;
}