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
|
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,
|
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
|
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
|
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
|
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
|
receipts older than the same 30-day window; client base revisions keep a pruned replay from
|
||||||
|
|
|
||||||
|
|
@ -384,10 +384,28 @@
|
||||||
fetchJson:fetchReviewJson,
|
fetchJson:fetchReviewJson,
|
||||||
localDate:todayRollover.localDate,
|
localDate:todayRollover.localDate,
|
||||||
timeZone:todayRollover.timeZone,
|
timeZone:todayRollover.timeZone,
|
||||||
|
storage:localStorage,
|
||||||
|
getLogin:() => planningOwnerLogin,
|
||||||
});
|
});
|
||||||
function renderTomorrowQueueSummary(value) {
|
function renderTomorrowQueueSummary(value) {
|
||||||
qs('#mobile-tomorrow-summary').textContent = value ? tomorrowPlan.summary(value) : tomorrowPlan.summary();
|
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() {
|
async function refreshTomorrowQueueSummary() {
|
||||||
try {
|
try {
|
||||||
renderTomorrowQueueSummary(await tomorrowPlan.load());
|
renderTomorrowQueueSummary(await tomorrowPlan.load());
|
||||||
|
|
@ -494,7 +512,10 @@
|
||||||
tomorrowPlan.startLifecycle({
|
tomorrowPlan.startLifecycle({
|
||||||
windowObject:window,
|
windowObject:window,
|
||||||
documentObject:document,
|
documentObject:document,
|
||||||
check:() => latestTodayPlan ? promoteTomorrowIfDue(latestTodayPlan) : false,
|
check:() => Promise.all([
|
||||||
|
syncPendingTomorrow(),
|
||||||
|
latestTodayPlan ? promoteTomorrowIfDue(latestTodayPlan) : false,
|
||||||
|
]),
|
||||||
});
|
});
|
||||||
const todayHandoff = createTodayHandoff({
|
const todayHandoff = createTodayHandoff({
|
||||||
storage:localStorage,
|
storage:localStorage,
|
||||||
|
|
@ -2716,15 +2737,15 @@
|
||||||
function saveTomorrowPlan(plan) {
|
function saveTomorrowPlan(plan) {
|
||||||
const normalized = Array.isArray(plan) ?
|
const normalized = Array.isArray(plan) ?
|
||||||
{ids:plan, capacity_minutes:null, estimates:{}} : plan;
|
{ids:plan, capacity_minutes:null, estimates:{}} : plan;
|
||||||
tomorrowPlan.save(normalized).then(saved => {
|
const staged = tomorrowPlan.stage(normalized);
|
||||||
renderTomorrowQueueSummary(saved);
|
if (!staged) {
|
||||||
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 => {
|
|
||||||
qs('#my-work-action-status').textContent =
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5377,6 +5398,7 @@
|
||||||
planningOwnerLogin = retainedPlanningLogin;
|
planningOwnerLogin = retainedPlanningLogin;
|
||||||
updatePlanningAvailability();
|
updatePlanningAvailability();
|
||||||
if (planningOwnerLogin) {
|
if (planningOwnerLogin) {
|
||||||
|
syncPendingTomorrow();
|
||||||
todaySync.migrate(todayWork.read());
|
todaySync.migrate(todayWork.read());
|
||||||
todaySync.flush();
|
todaySync.flush();
|
||||||
laterSync.migrate(laterWork.read());
|
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 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}});
|
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) {
|
function adopt(value) {
|
||||||
if (!Number.isInteger(value?.revision)||!Array.isArray(value?.ids)) return false;
|
if (!Number.isInteger(value?.revision)||!Array.isArray(value?.ids)) return false;
|
||||||
plan={revision:value.revision,ids:[...value.ids],capacity_minutes:value.capacity_minutes??null,
|
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);
|
const [y,m,d]=localDate().split('-').map(Number);
|
||||||
return new Date(Date.UTC(y,m-1,d+1)).toISOString().slice(0,10);
|
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) {
|
async function save(value) {
|
||||||
const body={base_revision:plan.revision,ids:[...(value.ids||[])],
|
const body={base_revision:plan.revision,ids:[...(value.ids||[])],
|
||||||
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
|
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)}));
|
return adopt(await fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}));
|
||||||
}
|
}
|
||||||
async function promote(today_revision) {
|
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}`;
|
const promotion_id=`tomorrow-${plan.plan_date}-r${plan.revision}`;
|
||||||
return fetchJson('api/v1/tomorrow/promote',{method:'POST',headers:{'Content-Type':'application/json'},
|
return fetchJson('api/v1/tomorrow/promote',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||||
body:JSON.stringify({promotion_id,tomorrow_revision:plan.revision,today_revision})});
|
body:JSON.stringify({promotion_id,tomorrow_revision:plan.revision,today_revision})});
|
||||||
|
|
@ -50,12 +107,13 @@ function createTomorrowPlan({fetchJson,localDate,timeZone}={}) {
|
||||||
}
|
}
|
||||||
function summary(value=plan) {
|
function summary(value=plan) {
|
||||||
const ids=Array.isArray(value?.ids)?value.ids:[];
|
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 label=`${ids.length} planned`;
|
||||||
const estimates=value.estimates||{};
|
const estimates=value.estimates||{};
|
||||||
const estimated=ids.reduce((total,id)=>total+(Number(estimates[id])||0),0);
|
const estimated=ids.reduce((total,id)=>total+(Number(estimates[id])||0),0);
|
||||||
const capacity=Number(value.capacity_minutes)||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,
|
function startLifecycle({windowObject,documentObject,check,setTimer=setTimeout,clearTimer=clearTimeout,
|
||||||
nextDelay=millisecondsUntilNextDay}={}) {
|
nextDelay=millisecondsUntilNextDay}={}) {
|
||||||
|
|
@ -74,6 +132,10 @@ function createTomorrowPlan({fetchJson,localDate,timeZone}={}) {
|
||||||
run();
|
run();
|
||||||
return {run,stop(){if(timer!==null)clearTimer(timer);timer=null;}};
|
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;
|
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)
|
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():
|
def test_tomorrow_planner_loads_and_saves_independently_from_today():
|
||||||
result = run_controller("""
|
result = run_controller("""
|
||||||
const requests=[];
|
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"])
|
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():
|
def test_tomorrow_planner_promotes_overdue_plans_with_a_stable_retry_identity():
|
||||||
result = run_controller("""
|
result = run_controller("""
|
||||||
const requests=[];
|
const requests=[];
|
||||||
|
|
@ -147,16 +257,34 @@ const planned=planner.summary({
|
||||||
estimates:{'issue:r:1:':30,'issue:r:2:':45,'issue:r:3:':30}
|
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:{}});
|
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 == {
|
assert result == {
|
||||||
"empty": "Nothing planned",
|
"empty": "Nothing planned",
|
||||||
"planned": "3 planned · 105 of 120 min",
|
"planned": "3 planned · 105 of 120 min",
|
||||||
"unestimated": "1 planned",
|
"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():
|
def test_mobile_queues_expose_tomorrow_and_keep_duplicate_header_actions_hidden():
|
||||||
index = INDEX.read_text()
|
index = INDEX.read_text()
|
||||||
css = CSS.read_text()
|
css = CSS.read_text()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user