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(); let result; try{ result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload}); }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; } try{ 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){ announce(`${error.message||'Refresh unavailable.'} Move completed; refresh to continue.`); } }); return {controller,close,flushPending,resumePending}; } if (typeof module !== 'undefined' && module.exports) { module.exports = createTodayWeekReschedule; module.exports.mount = mountTodayWeekReschedule; }