feat: keep Tomorrow plans through offline saves (Closes #1160)
CI / lint (pull_request) Successful in 3m17s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-20 02:10:37 +00:00
1 parent ae632e3562
commit f42f89ca21
4 files changed
+235 -17

No files matched your search

+31 -9
View File
@@ -384,10 +384,28 @@
fetchJson:fetchReviewJson,
localDate:todayRollover.localDate,
timeZone:todayRollover.timeZone,
storage:localStorage,
getLogin:() => planningOwnerLogin,
});
function renderTomorrowQueueSummary(value) {
qs('#mobile-tomorrow-summary').textContent = value ? tomorrowPlan.summary(value) : tomorrowPlan.summary();
}
function syncPendingTomorrow() {
if (!tomorrowPlan.pending()) return Promise.resolve(false);
return tomorrowPlan.flush().then(saved => {
renderTomorrowQueueSummary(saved);
qs('#my-work-action-status').textContent = saved.ids.length ?
`Tomorrow saved for ${saved.plan_date} without changing Today.` :
`Tomorrow cleared for ${saved.plan_date}.`;
return saved;
}).catch(error => {
const conflict = tomorrowPlan.conflict();
qs('#my-work-action-status').textContent = conflict ?
'Another device changed Tomorrow. Your phone plan is preserved; reopen Plan Tomorrow to review it.' :
`${error.message || 'Tomorrow sync is unavailable.'} Saved on this phone · sync pending.`;
return false;
});
}
async function refreshTomorrowQueueSummary() {
try {
renderTomorrowQueueSummary(await tomorrowPlan.load());
@@ -494,7 +512,10 @@
tomorrowPlan.startLifecycle({
windowObject:window,
documentObject:document,
check:() => latestTodayPlan ? promoteTomorrowIfDue(latestTodayPlan) : false,
check:() => Promise.all([
syncPendingTomorrow(),
latestTodayPlan ? promoteTomorrowIfDue(latestTodayPlan) : false,
]),
});
const todayHandoff = createTodayHandoff({
storage:localStorage,
@@ -2716,15 +2737,15 @@
function saveTomorrowPlan(plan) {
const normalized = Array.isArray(plan) ?
{ids:plan, capacity_minutes:null, estimates:{}} : plan;
tomorrowPlan.save(normalized).then(saved => {
renderTomorrowQueueSummary(saved);
qs('#my-work-action-status').textContent = saved.ids.length ?
`Tomorrow saved for ${saved.plan_date} without changing Today.` :
`Tomorrow cleared for ${saved.plan_date}.`;
}).catch(error => {
const staged = tomorrowPlan.stage(normalized);
if (!staged) {
qs('#my-work-action-status').textContent =
`${error.message || 'Tomorrow could not be saved.'} Reopen Plan Tomorrow to retry.`;
});
'Tomorrow could not be saved on this phone. Free browser storage and retry.';
return false;
}
renderTomorrowQueueSummary(staged);
qs('#my-work-action-status').textContent = 'Tomorrow saved on this phone · sync pending.';
syncPendingTomorrow();
return true;
}
@@ -5377,6 +5398,7 @@
planningOwnerLogin = retainedPlanningLogin;
updatePlanningAvailability();
if (planningOwnerLogin) {
syncPendingTomorrow();
todaySync.migrate(todayWork.read());
todaySync.flush();
laterSync.migrate(laterWork.read());
+68 -6
View File
@@ -1,6 +1,22 @@
function createTomorrowPlan({fetchJson,localDate,timeZone}={}) {
function createTomorrowPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
let plan={revision:0,ids:[],capacity_minutes:null,estimates:{}};
let flushing=null;
let lastConflict=null;
const storagePrefix='stackchain.tomorrow-sync.v1.';
const state=()=>({...plan,ids:[...plan.ids],estimates:{...plan.estimates}});
function storageKey() {
const login=String(getLogin?.()||'').trim().toLowerCase();
return login?storagePrefix+encodeURIComponent(login):'';
}
function pending() {
const key=storageKey();
if(!key||!storage) return false;
try {
const value=JSON.parse(storage.getItem(key)||'null');
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.ids)?
{...value,ids:[...value.ids],estimates:{...(value.estimates||{})},sync_pending:true}:false;
} catch (_error) { return false; }
}
function adopt(value) {
if (!Number.isInteger(value?.revision)||!Array.isArray(value?.ids)) return false;
plan={revision:value.revision,ids:[...value.ids],capacity_minutes:value.capacity_minutes??null,
@@ -11,7 +27,48 @@ function createTomorrowPlan({fetchJson,localDate,timeZone}={}) {
const [y,m,d]=localDate().split('-').map(Number);
return new Date(Date.UTC(y,m-1,d+1)).toISOString().slice(0,10);
}
async function load(){return adopt(await fetchJson('api/v1/tomorrow'));}
async function load(){
const queued=pending();
return queued?(plan={...queued},state()):adopt(await fetchJson('api/v1/tomorrow'));
}
function stage(value) {
const key=storageKey();
if(!key||!storage) return false;
const queued={base_revision:plan.revision,revision:plan.revision,ids:[...(value.ids||[])],
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
plan_date:nextLocalDate(),timezone:timeZone(),sync_pending:true};
try { storage.setItem(key,JSON.stringify(queued)); }
catch (_error) { return false; }
lastConflict=null;
plan=queued;
return state();
}
function deliveryBody(value) {
return {base_revision:value.base_revision,ids:[...value.ids],
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
plan_date:value.plan_date,timezone:value.timezone};
}
function flush() {
if(flushing) return flushing;
const queued=pending();
const key=storageKey();
if(!queued||!key) return Promise.resolve(false);
const body=deliveryBody(queued);
flushing=fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},
body:JSON.stringify(body)}).then(saved=>{
const current=pending();
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(body)) storage.removeItem(key);
if(!pending()) adopt(saved);
return saved;
}).catch(async error=>{
if(error?.status===409) {
const remote=await fetchJson('api/v1/tomorrow');
lastConflict={local:pending(),remote:{...remote,ids:[...remote.ids],estimates:{...(remote.estimates||{})}}};
}
throw error;
}).finally(()=>{flushing=null;});
return flushing;
}
async function save(value) {
const body={base_revision:plan.revision,ids:[...(value.ids||[])],
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
@@ -19,7 +76,7 @@ function createTomorrowPlan({fetchJson,localDate,timeZone}={}) {
return adopt(await fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}));
}
async function promote(today_revision) {
if (!plan.plan_date||plan.plan_date>localDate()||!plan.ids.length) return false;
if (pending()||!plan.plan_date||plan.plan_date>localDate()||!plan.ids.length) return false;
const promotion_id=`tomorrow-${plan.plan_date}-r${plan.revision}`;
return fetchJson('api/v1/tomorrow/promote',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({promotion_id,tomorrow_revision:plan.revision,today_revision})});
@@ -50,12 +107,13 @@ function createTomorrowPlan({fetchJson,localDate,timeZone}={}) {
}
function summary(value=plan) {
const ids=Array.isArray(value?.ids)?value.ids:[];
if(!ids.length) return 'Nothing planned';
const suffix=value?.sync_pending?' · sync pending':'';
if(!ids.length) return 'Nothing planned'+suffix;
const label=`${ids.length} planned`;
const estimates=value.estimates||{};
const estimated=ids.reduce((total,id)=>total+(Number(estimates[id])||0),0);
const capacity=Number(value.capacity_minutes)||0;
return estimated&&capacity?`${label} · ${estimated} of ${capacity} min`:label;
return (estimated&&capacity?`${label} · ${estimated} of ${capacity} min`:label)+suffix;
}
function startLifecycle({windowObject,documentObject,check,setTimer=setTimeout,clearTimer=clearTimeout,
nextDelay=millisecondsUntilNextDay}={}) {
@@ -74,6 +132,10 @@ function createTomorrowPlan({fetchJson,localDate,timeZone}={}) {
run();
return {run,stop(){if(timer!==null)clearTimer(timer);timer=null;}};
}
return {adopt,load,save,promote,state,summary,nextLocalDate,startLifecycle};
function conflict() {
return lastConflict&&{local:{...lastConflict.local,ids:[...lastConflict.local.ids],estimates:{...lastConflict.local.estimates}},
remote:{...lastConflict.remote,ids:[...lastConflict.remote.ids],estimates:{...lastConflict.remote.estimates}}};
}
return {adopt,load,save,stage,pending,flush,conflict,promote,state,summary,nextLocalDate,startLifecycle};
}
if(typeof module!=='undefined'&&module.exports)module.exports=createTomorrowPlan;