feat: keep Today-to-Week moves through offline reloads (Closes #1231)
CI / lint (pull_request) Successful in 3m21s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 4m57s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-21 18:18:14 +00:00
1 parent edaeba4a4e
commit e234d6b6c3
5 files changed
+298 -29

No files matched your search

+171 -22
View File
@@ -4,18 +4,108 @@ function createTodayWeekReschedule({
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 persist(record) {
const key = storageKey();
if (!key || !storage) return false;
try { storage.setItem(key, JSON.stringify(record)); return true; }
catch (_error) { return false; }
}
function pending() {
const key = storageKey();
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) && Array.isArray(value?.week?.days) ? value : null;
} catch (_error) { return null; }
}
function resume() {
const record = pending();
if (!record) return null;
adoptToday(record.today);
week.adopt(record.week);
return {...record, sync_pending:true};
}
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() {
if (flushing) return flushing;
const record = pending();
if (!record) return Promise.resolve(false);
const key = storageKey();
flushing = api('api/v1/week/reschedule', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(record.body),
}).then(result => {
adoptToday(result.today);
week.adopt(result.week);
const latest = pending();
if (latest?.body?.operation_id === record.body.operation_id) storage.removeItem(key);
return {...result, sync_pending:false};
}).catch(restoreConflict).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) {
const today = await api('api/v1/today');
let today;
let offline = false;
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 = await week.load();
if (loaded?.offline_snapshot || loaded?.sync_pending) {
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 => {
@@ -37,6 +127,9 @@ function createTodayWeekReschedule({
operation_id: operationId(),
estimate_minutes: estimateMinutes,
days,
today,
week: loaded,
offline,
};
return {...current, days:days.map(day => ({...day, ids:[...day.ids]}))};
}
@@ -54,38 +147,89 @@ function createTodayWeekReschedule({
if (capacity && day.planned_minutes + estimate > capacity && !allowOverload) {
throw new Error("That move exceeds the day's capacity. Confirm overload before rescheduling.");
}
const result = await api('api/v1/week/reschedule', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
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 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};
}
let result;
try {
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);
if (admitted && pending()?.body?.operation_id === body.operation_id) storage.removeItem(storageKey());
current = null;
return result;
}
function cancel() { current = null; }
return {open, confirm, cancel, state:() => current};
return {open, confirm, cancel, state:() => current, pending, resume, flush};
}
function mountTodayWeekReschedule({
qs, document=globalThis.document, week, api, getToday, adoptToday, currentTarget, closeActions,
refresh, warm, continueToday, announce, schedule=callback=>requestAnimationFrame(callback),
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});
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(()=>{
@@ -127,8 +271,13 @@ function mountTodayWeekReschedule({
if(!selectedDate)return;
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';
try{
await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
await refresh();warm();dialog.close();announce('Moved to Week Ahead. Continuing Today.');
const result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
dialog.close();
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){
const overload=error.message.includes('Confirm overload');allowOverload=overload;
@@ -137,7 +286,7 @@ function mountTodayWeekReschedule({
confirm.disabled=false;
}
});
return {controller,close};
return {controller,close,flushPending,resumePending};
}
if (typeof module !== 'undefined' && module.exports) {
+2 -2
View File
@@ -468,7 +468,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
keepLocal,useRemote,promote,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
item:id=>pendingItems[id]||confirmedItems[id]||null,
offline:()=>offlineSnapshot,request:fetchJson};
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,
@@ -802,7 +802,7 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
const back=qs('#back-to-week-review');if(back)back.hidden=true;renderDates();}};
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,
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;},