feat: keep Tomorrow plans through offline saves (Closes #1160)
This commit is contained in:
parent
ae632e3562
commit
f42f89ca21
|
|
@ -149,7 +149,13 @@ failure preserves both the reply draft and checkpoint. Finishing or choosing **E
|
|||
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
||||
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
||||
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
||||
offline operations. Planning edits can remain offline for up to 30 days. After that, the
|
||||
offline operations. **Plan Tomorrow** remains independent from active Today work and durably admits the exact
|
||||
ordered plan, capacity, estimates, next local date, timezone, and server revision to account-bound browser
|
||||
storage before closing on a phone. The Queues summary marks it **sync pending** while offline; reconnect,
|
||||
foreground, and midnight lifecycle checks share one delivery flight. A successful account receipt removes
|
||||
the pending copy, while a revision conflict preserves both the phone plan and fresh server snapshot for
|
||||
review. Unsynced Tomorrow work is never promoted into Today.
|
||||
Planning edits can remain offline for up to 30 days. After that, the
|
||||
expired edit is discarded visibly and the account plan is kept rather than replaying stale
|
||||
intent. The server retains no more than 4,096 operation receipts per account and removes
|
||||
receipts older than the same 30-day window; client base revisions keep a pruned replay from
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,96 @@ const createTomorrowPlan = require({json.dumps(str(CONTROLLER))});
|
|||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_tomorrow_planner_restores_an_account_bound_plan_before_network_delivery():
|
||||
result = run_controller("""
|
||||
const values=new Map();
|
||||
const storage={
|
||||
getItem:key=>values.has(key)?values.get(key):null,
|
||||
setItem:(key,value)=>values.set(key,value),
|
||||
removeItem:key=>values.delete(key),
|
||||
};
|
||||
let login='Timmy';
|
||||
let requests=0;
|
||||
const options={storage,getLogin:()=>login,fetchJson:async()=>{requests+=1;throw new Error('offline');},
|
||||
localDate:()=> '2026-08-19',timeZone:()=> 'America/New_York'};
|
||||
const first=createTomorrowPlan(options);
|
||||
first.adopt({revision:7,ids:[],capacity_minutes:null,estimates:{}});
|
||||
const queued=first.stage({ids:['issue:r:3:','issue:r:2:'],capacity_minutes:120,
|
||||
estimates:{'issue:r:3:':45,'issue:r:2:':30}});
|
||||
const restored=await createTomorrowPlan(options).load();
|
||||
login='alexander';
|
||||
let otherError='';
|
||||
try { await createTomorrowPlan(options).load(); } catch(error) { otherError=error.message; }
|
||||
console.log(JSON.stringify({queued,restored,requests,otherError,keys:[...values.keys()]}));
|
||||
""")
|
||||
|
||||
assert result["queued"]["sync_pending"] is True
|
||||
assert result["restored"] == result["queued"]
|
||||
assert result["requests"] == 1
|
||||
assert result["otherError"] == "offline"
|
||||
assert result["keys"] == ["stackchain.tomorrow-sync.v1.timmy"]
|
||||
|
||||
|
||||
def test_tomorrow_planner_flushes_once_and_removes_pending_only_after_server_receipt():
|
||||
result = run_controller("""
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
let resolveRequest;
|
||||
let requests=0;
|
||||
const fetchJson=async (_url,options={})=>{
|
||||
requests+=1;
|
||||
const body=JSON.parse(options.body);
|
||||
await new Promise(resolve=>{resolveRequest=()=>resolve({revision:9,...body});});
|
||||
return {revision:9,...body};
|
||||
};
|
||||
const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson,
|
||||
localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
|
||||
planner.adopt({revision:8,ids:[],capacity_minutes:null,estimates:{}});
|
||||
planner.stage({ids:['issue:r:4:'],capacity_minutes:60,estimates:{'issue:r:4:':30}});
|
||||
const first=planner.flush();
|
||||
const second=planner.flush();
|
||||
await Promise.resolve();
|
||||
const pendingDuring=planner.pending();
|
||||
resolveRequest();
|
||||
const [saved,reused]=await Promise.all([first,second]);
|
||||
console.log(JSON.stringify({requests,pendingDuring,saved,reused,pendingAfter:planner.pending(),keys:[...values.keys()]}));
|
||||
""")
|
||||
|
||||
assert result["requests"] == 1
|
||||
assert result["pendingDuring"]["ids"] == ["issue:r:4:"]
|
||||
assert result["saved"]["revision"] == 9
|
||||
assert result["reused"]["revision"] == 9
|
||||
assert result["pendingAfter"] is False
|
||||
assert result["keys"] == []
|
||||
|
||||
|
||||
def test_tomorrow_planner_preserves_local_and_server_plans_on_revision_conflict():
|
||||
result = run_controller("""
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const requests=[];
|
||||
const fetchJson=async (_url,options={})=>{
|
||||
requests.push(options.method||'GET');
|
||||
if(options.method==='PUT'){const error=new Error('changed elsewhere');error.status=409;throw error;}
|
||||
return {revision:12,ids:['issue:r:server:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'};
|
||||
};
|
||||
const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson,
|
||||
localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
|
||||
planner.adopt({revision:11,ids:[],capacity_minutes:null,estimates:{}});
|
||||
planner.stage({ids:['issue:r:phone:'],capacity_minutes:90,estimates:{}});
|
||||
let message='';
|
||||
try { await planner.flush(); } catch(error) { message=error.message; }
|
||||
console.log(JSON.stringify({requests,message,pending:planner.pending(),conflict:planner.conflict()}));
|
||||
""")
|
||||
|
||||
assert result["requests"] == ["PUT", "GET"]
|
||||
assert result["message"] == "changed elsewhere"
|
||||
assert result["pending"]["ids"] == ["issue:r:phone:"]
|
||||
assert result["conflict"]["local"]["ids"] == ["issue:r:phone:"]
|
||||
assert result["conflict"]["remote"]["ids"] == ["issue:r:server:"]
|
||||
assert result["conflict"]["remote"]["revision"] == 12
|
||||
|
||||
|
||||
def test_tomorrow_planner_loads_and_saves_independently_from_today():
|
||||
result = run_controller("""
|
||||
const requests=[];
|
||||
|
|
@ -57,6 +147,26 @@ console.log(JSON.stringify({loaded,saved,requests}));
|
|||
assert all(request["url"] != "api/v1/today" for request in result["requests"])
|
||||
|
||||
|
||||
def test_pending_tomorrow_plan_is_not_promoted_before_account_sync():
|
||||
result = run_controller("""
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const requests=[];
|
||||
let date='2026-08-19';
|
||||
const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson:async(url)=>{requests.push(url);return {};},
|
||||
localDate:()=> date,timeZone:()=> 'UTC'});
|
||||
planner.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
|
||||
planner.stage({ids:['issue:r:2:'],capacity_minutes:60,estimates:{}});
|
||||
date='2026-08-22';
|
||||
const promoted=await planner.promote(7);
|
||||
console.log(JSON.stringify({promoted,requests,pending:planner.pending()}));
|
||||
""")
|
||||
|
||||
assert result["promoted"] is False
|
||||
assert result["requests"] == []
|
||||
assert result["pending"]["ids"] == ["issue:r:2:"]
|
||||
|
||||
|
||||
def test_tomorrow_planner_promotes_overdue_plans_with_a_stable_retry_identity():
|
||||
result = run_controller("""
|
||||
const requests=[];
|
||||
|
|
@ -147,16 +257,34 @@ const planned=planner.summary({
|
|||
estimates:{'issue:r:1:':30,'issue:r:2:':45,'issue:r:3:':30}
|
||||
});
|
||||
const unestimated=planner.summary({ids:['issue:r:4:'],capacity_minutes:null,estimates:{}});
|
||||
console.log(JSON.stringify({empty,planned,unestimated}));
|
||||
const pending=planner.summary({ids:['issue:r:5:'],capacity_minutes:60,estimates:{'issue:r:5:':30},sync_pending:true});
|
||||
console.log(JSON.stringify({empty,planned,unestimated,pending}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"empty": "Nothing planned",
|
||||
"planned": "3 planned · 105 of 120 min",
|
||||
"unestimated": "1 planned",
|
||||
"pending": "1 planned · 30 of 60 min · sync pending",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_tomorrow_save_is_admitted_before_background_delivery():
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert "storage:localStorage" in dashboard
|
||||
assert "getLogin:() => planningOwnerLogin" in dashboard
|
||||
assert "const staged = tomorrowPlan.stage(normalized);" in dashboard
|
||||
assert "if (!staged) {" in dashboard
|
||||
assert "Free browser storage and retry." in dashboard
|
||||
assert "renderTomorrowQueueSummary(staged);" in dashboard
|
||||
assert "Tomorrow saved on this phone · sync pending." in dashboard
|
||||
assert "tomorrowPlan.flush().then" in dashboard
|
||||
assert "syncPendingTomorrow()" in dashboard
|
||||
assert dashboard.count("syncPendingTomorrow();") >= 2
|
||||
assert "Another device changed Tomorrow" in dashboard
|
||||
|
||||
|
||||
def test_mobile_queues_expose_tomorrow_and_keep_duplicate_header_actions_hidden():
|
||||
index = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user