364 lines
14 KiB
JavaScript
364 lines
14 KiB
JavaScript
function createTodayWeekReschedule({
|
|
week,
|
|
api,
|
|
getToday,
|
|
adoptToday = () => {},
|
|
operationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()),
|
|
storage = null,
|
|
getLogin = () => '',
|
|
now = () => Date.now(),
|
|
} = {}) {
|
|
let current = null;
|
|
let flushing = null;
|
|
const storagePrefix = 'stackchain.today-week-reschedule.v1.';
|
|
|
|
function storageKey() {
|
|
const login = String(getLogin?.() || '').trim().toLowerCase();
|
|
return login ? storagePrefix + encodeURIComponent(login) : '';
|
|
}
|
|
|
|
function validRecord(value) {
|
|
return typeof value?.body?.operation_id === 'string' &&
|
|
Array.isArray(value?.today?.ids) && Array.isArray(value?.week?.days);
|
|
}
|
|
|
|
function readQueue() {
|
|
const key = storageKey();
|
|
if (!key || !storage) return [];
|
|
try {
|
|
const value = JSON.parse(storage.getItem(key) || 'null');
|
|
if (Array.isArray(value?.records)) return value.records.filter(validRecord);
|
|
return validRecord(value) ? [value] : [];
|
|
} catch (_error) { return []; }
|
|
}
|
|
|
|
function saveQueue(records) {
|
|
const key = storageKey();
|
|
if (!key || !storage) return false;
|
|
try {
|
|
if (!records.length) storage.removeItem(key);
|
|
else storage.setItem(key, JSON.stringify({version:2, records}));
|
|
return true;
|
|
}
|
|
catch (_error) { return false; }
|
|
}
|
|
|
|
function persist(record) { return saveQueue([...readQueue(), record]); }
|
|
|
|
function pending() {
|
|
const key = storageKey();
|
|
if (!key || !storage) return null;
|
|
return readQueue()[0] || null;
|
|
}
|
|
|
|
function resume() {
|
|
const records = readQueue();
|
|
const record = records[records.length - 1];
|
|
if (!record) return null;
|
|
adoptToday(record.today);
|
|
week.adopt(record.week);
|
|
return {...record, sync_pending:true, pending_count:records.length};
|
|
}
|
|
|
|
async function restoreConflict(error) {
|
|
if (error?.status !== 409) throw error;
|
|
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
|
|
adoptToday(today);
|
|
week.adopt(weekState);
|
|
const conflict = new Error('Plans changed on another device. Review and retry this saved move.');
|
|
conflict.status = 409;
|
|
throw conflict;
|
|
}
|
|
|
|
function flush(retryConflict = false) {
|
|
if (flushing) return flushing;
|
|
if (!pending()) return Promise.resolve(false);
|
|
flushing = (async () => {
|
|
let result = null;
|
|
let conflictRetried = false;
|
|
while (true) {
|
|
const records = readQueue();
|
|
const record = records[0];
|
|
if (!record) return result ? {...result, sync_pending:false, pending_count:0} : false;
|
|
try {
|
|
result = await api('api/v1/week/reschedule', {
|
|
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(record.body),
|
|
});
|
|
} catch (error) {
|
|
if (error?.status === 409 && retryConflict && !conflictRetried) {
|
|
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
|
|
adoptToday(today); week.adopt(weekState);
|
|
const latest = readQueue();
|
|
if (today?.ids?.includes(record.body.identity) &&
|
|
latest[0]?.body?.operation_id === record.body.operation_id) {
|
|
latest[0] = {...latest[0], body:{
|
|
...latest[0].body,
|
|
today_revision:today.revision,
|
|
week_revision:weekState.revision,
|
|
}};
|
|
if (!saveQueue(latest)) throw new Error('Could not update this saved move after plans changed.');
|
|
conflictRetried = true;
|
|
continue;
|
|
}
|
|
}
|
|
await restoreConflict(error);
|
|
}
|
|
adoptToday(result.today);
|
|
week.adopt(result.week);
|
|
const latest = readQueue();
|
|
if (latest[0]?.body?.operation_id !== record.body.operation_id) continue;
|
|
const remaining = latest.slice(1).map((entry, index) => index ? entry : ({
|
|
...entry,
|
|
body:{...entry.body, today_revision:result.today.revision, week_revision:result.week.revision},
|
|
}));
|
|
if (!saveQueue(remaining)) throw new Error('Move synced, but its local queue could not be updated.');
|
|
}
|
|
})().finally(() => { flushing = null; });
|
|
return flushing;
|
|
}
|
|
|
|
function optimisticSnapshots(planDate, estimate) {
|
|
const today = {
|
|
...current.today,
|
|
ids: current.today.ids.filter(id => id !== current.identity),
|
|
estimates: {...(current.today.estimates || {})},
|
|
};
|
|
delete today.estimates[current.identity];
|
|
const days = (current.week.days || []).map(day => {
|
|
const estimates = {...(day.estimates || {})}; delete estimates[current.identity];
|
|
return {...day, ids:(day.ids || []).filter(id => id !== current.identity), estimates};
|
|
});
|
|
let destination = days.find(day => day.plan_date === planDate);
|
|
if (!destination) {
|
|
destination = {plan_date:planDate, ids:[], capacity_minutes:null, estimates:{}};
|
|
days.push(destination);
|
|
}
|
|
destination.ids.push(current.identity);
|
|
destination.estimates[current.identity] = estimate;
|
|
return {today, week:{revision:current.week_revision, timezone:current.week.timezone || null, days}};
|
|
}
|
|
|
|
async function open(identity) {
|
|
let today;
|
|
const queued = readQueue().length;
|
|
let offline = Boolean(queued);
|
|
if (queued) {
|
|
today = getToday?.();
|
|
} else {
|
|
try { today = await api('api/v1/today'); }
|
|
catch (error) {
|
|
if (error?.status === 401 || error?.status === 403) throw error;
|
|
today = getToday?.(); offline = true;
|
|
}
|
|
}
|
|
if (!identity || !today?.ids?.includes(identity)) {
|
|
throw new Error('The active Today item changed. Reopen rescheduling.');
|
|
}
|
|
const loaded = queued ? week.state?.() : await week.load();
|
|
if (!loaded) throw new Error('Reload Today before rescheduling another saved move.');
|
|
if (loaded?.sync_pending) {
|
|
throw new Error('Reconnect before rescheduling Today into Week Ahead.');
|
|
}
|
|
offline = offline || Boolean(loaded?.offline_snapshot);
|
|
const estimate = Number(today.estimates?.[identity]);
|
|
const estimateMinutes = Number.isFinite(estimate) && estimate > 0 ? estimate : null;
|
|
const days = week.review().days.map(day => {
|
|
const ids = (day.ids || []).filter(id => id !== identity);
|
|
const planned = Number(day.planned_minutes) || 0;
|
|
const existing = Number(day.estimates?.[identity]) || 0;
|
|
return {
|
|
...day,
|
|
ids,
|
|
planned_minutes: Math.max(0, planned - existing),
|
|
load: `${Math.max(0, planned - existing)} / ${Number(day.capacity_minutes) || 0} min`,
|
|
eligible: ids.length < 5,
|
|
};
|
|
});
|
|
current = {
|
|
identity,
|
|
today_revision: today.revision,
|
|
week_revision: loaded.revision,
|
|
operation_id: operationId(),
|
|
estimate_minutes: estimateMinutes,
|
|
days,
|
|
today,
|
|
week: loaded,
|
|
offline,
|
|
};
|
|
return {...current, days:days.map(day => ({...day, ids:[...day.ids]}))};
|
|
}
|
|
|
|
async function confirm(planDate, estimateMinutes, {allowOverload = false} = {}) {
|
|
if (!current) throw new Error('Open rescheduling before choosing a day.');
|
|
const day = current.days.find(value => value.plan_date === planDate);
|
|
if (!day) throw new Error('Choose one of the next seven days.');
|
|
const estimate = Number(estimateMinutes);
|
|
if (!Number.isFinite(estimate) || estimate < 5 || estimate > 1440) {
|
|
throw new Error('Estimate must be between 5 and 1440 minutes.');
|
|
}
|
|
if (!day.eligible) throw new Error('That Week Ahead day already has five items.');
|
|
const capacity = Number(day.capacity_minutes) || 0;
|
|
if (capacity && day.planned_minutes + estimate > capacity && !allowOverload) {
|
|
throw new Error("That move exceeds the day's capacity. Confirm overload before rescheduling.");
|
|
}
|
|
const body = {
|
|
operation_id:current.operation_id,
|
|
identity:current.identity,
|
|
estimate_minutes:estimate,
|
|
plan_date:planDate,
|
|
today_revision:current.today_revision,
|
|
week_revision:current.week_revision,
|
|
allow_over_capacity:allowOverload,
|
|
};
|
|
const optimistic = optimisticSnapshots(planDate, estimate);
|
|
const durable = Boolean(storageKey() && storage);
|
|
const admitted = durable && persist({body, ...optimistic, queued_at:now()});
|
|
if (durable && !admitted) {
|
|
throw new Error('Could not save this move on this device. Nothing changed.');
|
|
}
|
|
if (admitted) {
|
|
adoptToday(optimistic.today);
|
|
week.adopt(optimistic.week);
|
|
}
|
|
if (current.offline) {
|
|
current = null;
|
|
return {...optimistic, sync_pending:true, pending_count:readQueue().length};
|
|
}
|
|
let result;
|
|
try {
|
|
if (admitted) {
|
|
result = await flush(true);
|
|
} else {
|
|
try {
|
|
result = await api('api/v1/week/reschedule', {
|
|
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
|
|
});
|
|
} catch (error) {
|
|
if (error?.status !== 409) throw error;
|
|
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
|
|
adoptToday(today); week.adopt(weekState);
|
|
if (!today?.ids?.includes(body.identity)) throw error;
|
|
body.today_revision = today.revision; body.week_revision = weekState.revision;
|
|
result = await api('api/v1/week/reschedule', {
|
|
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
|
|
});
|
|
}
|
|
}
|
|
} catch(error) {
|
|
if (admitted && error?.status === 409) await restoreConflict(error);
|
|
if (admitted && error?.status !== 401 && error?.status !== 403) {
|
|
current = null;
|
|
return {...optimistic, sync_pending:true};
|
|
}
|
|
throw error;
|
|
}
|
|
adoptToday(result.today);
|
|
week.adopt(result.week);
|
|
current = null;
|
|
return result;
|
|
}
|
|
|
|
function cancel() { current = null; }
|
|
return {open, confirm, cancel, state:() => current, pending, resume, flush};
|
|
}
|
|
|
|
function mountTodayWeekReschedule({
|
|
qs, document=globalThis.document, window=globalThis.window, week, api, getToday, adoptToday, currentTarget, closeActions,
|
|
refresh, warm, continueToday, announce, storage=null, getLogin=()=>'', schedule=callback=>requestAnimationFrame(callback),
|
|
}={}) {
|
|
const dialog=qs('#today-week-reschedule'),daysRoot=qs('#today-week-reschedule-days');
|
|
const estimate=qs('#today-week-reschedule-estimate'),status=qs('#today-week-reschedule-status');
|
|
const confirm=qs('#confirm-today-week-reschedule'),launcher=qs('[data-work-session-reschedule-week]');
|
|
let selectedDate=null,allowOverload=false;
|
|
const controller=createTodayWeekReschedule({week,api,getToday,adoptToday,storage,getLogin});
|
|
async function flushPending() {
|
|
if(!controller.pending())return false;
|
|
try {
|
|
const result=await controller.flush();
|
|
announce('Today-to-Week move synced to your account.');
|
|
await refresh();warm();
|
|
return result;
|
|
} catch(error) {
|
|
announce(`${error.message||'Sync unavailable.'} Move saved on this device · sync pending.`);
|
|
return false;
|
|
}
|
|
}
|
|
function resumePending() {
|
|
const restored=controller.resume();
|
|
if(!restored)return false;
|
|
announce('Today-to-Week move saved on this device · sync pending.');
|
|
flushPending();
|
|
return restored;
|
|
}
|
|
window?.addEventListener('online', flushPending);
|
|
document?.addEventListener('visibilitychange',()=>document.hidden?false:flushPending());
|
|
let restoreAttempts=0;
|
|
function restoreWhenOwned(){
|
|
if(resumePending()||getLogin()||restoreAttempts++>=20)return;
|
|
window?.setTimeout(restoreWhenOwned,250);
|
|
}
|
|
restoreWhenOwned();
|
|
function close() {
|
|
controller.cancel();selectedDate=null;allowOverload=false;
|
|
if(dialog.open)dialog.close();schedule(()=>{
|
|
qs('[data-mobile-today-more]').click();launcher.focus();
|
|
});
|
|
}
|
|
function render(opened) {
|
|
daysRoot.replaceChildren(...opened.days.map(day=>{
|
|
const button=document.createElement('button');
|
|
button.type='button';button.dataset.planDate=day.plan_date;button.setAttribute('role','radio');
|
|
button.setAttribute('aria-checked','false');button.disabled=!day.eligible;
|
|
const label=document.createElement('strong');label.textContent=day.label;
|
|
const load=document.createElement('span');load.className='small';
|
|
load.textContent=day.eligible?day.load:'5 items · full';button.append(label,load);
|
|
button.addEventListener('click',()=>{
|
|
selectedDate=day.plan_date;allowOverload=false;
|
|
daysRoot.querySelectorAll('button').forEach(choice=>choice.setAttribute(
|
|
'aria-checked',String(choice===button)
|
|
));
|
|
status.textContent='';confirm.disabled=false;confirm.textContent='Move to Week Ahead & continue';
|
|
});
|
|
return button;
|
|
}));
|
|
}
|
|
launcher.addEventListener('click',async()=>{
|
|
const target=currentTarget();
|
|
if(!target){announce('Start or resume a checkpointed Today item before rescheduling.');return;}
|
|
closeActions();status.textContent='Loading Week Ahead…';confirm.disabled=true;dialog.showModal();
|
|
try{
|
|
const opened=await controller.open(target.identity);render(opened);
|
|
estimate.value=opened.estimate_minutes||'';
|
|
status.textContent=opened.estimate_minutes?'Choose a future day.':'Add an estimate, then choose a future day.';
|
|
schedule(()=>daysRoot.querySelector('button:not(:disabled)')?.focus());
|
|
}catch(error){status.textContent=error.message;}
|
|
});
|
|
qs('#cancel-today-week-reschedule').addEventListener('click',close);
|
|
dialog.addEventListener('cancel',event=>{event.preventDefault();close();});
|
|
confirm.addEventListener('click',async()=>{
|
|
if(!selectedDate)return;
|
|
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';dialog.close();
|
|
try{
|
|
const result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
|
|
if(result.sync_pending){
|
|
announce('Moved locally. Saved on this device · sync pending.');warm();
|
|
}else{
|
|
await refresh();warm();announce('Moved to Week Ahead. Continuing Today.');
|
|
}
|
|
await continueToday();
|
|
}catch(error){
|
|
if(!dialog.open)dialog.showModal();
|
|
const overload=error.message.includes('Confirm overload');allowOverload=overload;
|
|
status.textContent=error.message;
|
|
confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue';
|
|
confirm.disabled=false;
|
|
}
|
|
});
|
|
return {controller,close,flushPending,resumePending};
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = createTodayWeekReschedule;
|
|
module.exports.mount = mountTodayWeekReschedule;
|
|
}
|