feat: keep Tomorrow plans through offline saves (Closes #1160)
This commit is contained in:
1 parent
ae632e3562
commit
f42f89ca21
4 files changed
+235
-17
No files matched your search
+31
-9
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user