feat: keep Today-to-Week moves through offline reloads (Closes #1231)
This commit is contained in:
parent
edaeba4a4e
commit
e234d6b6c3
|
|
@ -57,7 +57,7 @@ jobs:
|
||||||
pip install -r requirements-e2e.txt
|
pip install -r requirements-e2e.txt
|
||||||
python3 -m playwright install --with-deps chromium
|
python3 -m playwright install --with-deps chromium
|
||||||
- name: Exercise packaged mobile work journeys
|
- name: Exercise packaged mobile work journeys
|
||||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||||
|
|
||||||
release-candidate:
|
release-candidate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,108 @@ function createTodayWeekReschedule({
|
||||||
getToday,
|
getToday,
|
||||||
adoptToday = () => {},
|
adoptToday = () => {},
|
||||||
operationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()),
|
operationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()),
|
||||||
|
storage = null,
|
||||||
|
getLogin = () => '',
|
||||||
|
now = () => Date.now(),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
let current = null;
|
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) {
|
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)) {
|
if (!identity || !today?.ids?.includes(identity)) {
|
||||||
throw new Error('The active Today item changed. Reopen rescheduling.');
|
throw new Error('The active Today item changed. Reopen rescheduling.');
|
||||||
}
|
}
|
||||||
const loaded = await week.load();
|
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.');
|
throw new Error('Reconnect before rescheduling Today into Week Ahead.');
|
||||||
}
|
}
|
||||||
|
offline = offline || Boolean(loaded?.offline_snapshot);
|
||||||
const estimate = Number(today.estimates?.[identity]);
|
const estimate = Number(today.estimates?.[identity]);
|
||||||
const estimateMinutes = Number.isFinite(estimate) && estimate > 0 ? estimate : null;
|
const estimateMinutes = Number.isFinite(estimate) && estimate > 0 ? estimate : null;
|
||||||
const days = week.review().days.map(day => {
|
const days = week.review().days.map(day => {
|
||||||
|
|
@ -37,6 +127,9 @@ function createTodayWeekReschedule({
|
||||||
operation_id: operationId(),
|
operation_id: operationId(),
|
||||||
estimate_minutes: estimateMinutes,
|
estimate_minutes: estimateMinutes,
|
||||||
days,
|
days,
|
||||||
|
today,
|
||||||
|
week: loaded,
|
||||||
|
offline,
|
||||||
};
|
};
|
||||||
return {...current, days:days.map(day => ({...day, ids:[...day.ids]}))};
|
return {...current, days:days.map(day => ({...day, ids:[...day.ids]}))};
|
||||||
}
|
}
|
||||||
|
|
@ -54,38 +147,89 @@ function createTodayWeekReschedule({
|
||||||
if (capacity && day.planned_minutes + estimate > capacity && !allowOverload) {
|
if (capacity && day.planned_minutes + estimate > capacity && !allowOverload) {
|
||||||
throw new Error("That move exceeds the day's capacity. Confirm overload before rescheduling.");
|
throw new Error("That move exceeds the day's capacity. Confirm overload before rescheduling.");
|
||||||
}
|
}
|
||||||
const result = await api('api/v1/week/reschedule', {
|
const body = {
|
||||||
method:'POST',
|
operation_id:current.operation_id,
|
||||||
headers:{'Content-Type':'application/json'},
|
identity:current.identity,
|
||||||
body:JSON.stringify({
|
estimate_minutes:estimate,
|
||||||
operation_id:current.operation_id,
|
plan_date:planDate,
|
||||||
identity:current.identity,
|
today_revision:current.today_revision,
|
||||||
estimate_minutes:estimate,
|
week_revision:current.week_revision,
|
||||||
plan_date:planDate,
|
allow_over_capacity:allowOverload,
|
||||||
today_revision:current.today_revision,
|
};
|
||||||
week_revision:current.week_revision,
|
const optimistic = optimisticSnapshots(planDate, estimate);
|
||||||
allow_over_capacity:allowOverload,
|
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);
|
adoptToday(result.today);
|
||||||
week.adopt(result.week);
|
week.adopt(result.week);
|
||||||
|
if (admitted && pending()?.body?.operation_id === body.operation_id) storage.removeItem(storageKey());
|
||||||
current = null;
|
current = null;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancel() { current = null; }
|
function cancel() { current = null; }
|
||||||
return {open, confirm, cancel, state:() => current};
|
return {open, confirm, cancel, state:() => current, pending, resume, flush};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountTodayWeekReschedule({
|
function mountTodayWeekReschedule({
|
||||||
qs, document=globalThis.document, week, api, getToday, adoptToday, currentTarget, closeActions,
|
qs, document=globalThis.document, window=globalThis.window, week, api, getToday, adoptToday, currentTarget, closeActions,
|
||||||
refresh, warm, continueToday, announce, schedule=callback=>requestAnimationFrame(callback),
|
refresh, warm, continueToday, announce, storage=null, getLogin=()=>'', schedule=callback=>requestAnimationFrame(callback),
|
||||||
}={}) {
|
}={}) {
|
||||||
const dialog=qs('#today-week-reschedule'),daysRoot=qs('#today-week-reschedule-days');
|
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 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]');
|
const confirm=qs('#confirm-today-week-reschedule'),launcher=qs('[data-work-session-reschedule-week]');
|
||||||
let selectedDate=null,allowOverload=false;
|
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() {
|
function close() {
|
||||||
controller.cancel();selectedDate=null;allowOverload=false;
|
controller.cancel();selectedDate=null;allowOverload=false;
|
||||||
if(dialog.open)dialog.close();schedule(()=>{
|
if(dialog.open)dialog.close();schedule(()=>{
|
||||||
|
|
@ -127,8 +271,13 @@ function mountTodayWeekReschedule({
|
||||||
if(!selectedDate)return;
|
if(!selectedDate)return;
|
||||||
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';
|
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';
|
||||||
try{
|
try{
|
||||||
await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
|
const result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
|
||||||
await refresh();warm();dialog.close();announce('Moved to Week Ahead. Continuing Today.');
|
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();
|
await continueToday();
|
||||||
}catch(error){
|
}catch(error){
|
||||||
const overload=error.message.includes('Confirm overload');allowOverload=overload;
|
const overload=error.message.includes('Confirm overload');allowOverload=overload;
|
||||||
|
|
@ -137,7 +286,7 @@ function mountTodayWeekReschedule({
|
||||||
confirm.disabled=false;
|
confirm.disabled=false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return {controller,close};
|
return {controller,close,flushPending,resumePending};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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,
|
keepLocal,useRemote,promote,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
|
||||||
item:id=>pendingItems[id]||confirmedItems[id]||null,
|
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,
|
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,
|
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();}};
|
const back=qs('#back-to-week-review');if(back)back.hidden=true;renderDates();}};
|
||||||
if(typeof weekCalendarImport!=='undefined')weekCalendarImport.mount(controller,workflow,qs);
|
if(typeof weekCalendarImport!=='undefined')weekCalendarImport.mount(controller,workflow,qs);
|
||||||
if(x)mountTodayWeekReschedule({
|
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(),
|
closeActions:()=>qs('#mobile-today-actions').close(),
|
||||||
continueToday:()=>qs('[data-work-session-next]').click(),
|
continueToday:()=>qs('[data-work-session-next]').click(),
|
||||||
announce:message=>{qs('#my-work-action-status').textContent=message;},
|
announce:message=>{qs('#my-work-action-status').textContent=message;},
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,14 @@ def test_release_promotion_waits_for_packaged_mobile_journeys():
|
||||||
"tests/e2e/test_mobile_today_summary_release.py "
|
"tests/e2e/test_mobile_today_summary_release.py "
|
||||||
"tests/e2e/test_mobile_tomorrow_conflict_release.py "
|
"tests/e2e/test_mobile_tomorrow_conflict_release.py "
|
||||||
"tests/e2e/test_mobile_week_ahead_release.py "
|
"tests/e2e/test_mobile_week_ahead_release.py "
|
||||||
|
"tests/e2e/test_mobile_today_week_reschedule_release.py "
|
||||||
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
|
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
|
||||||
) in browser
|
) in browser
|
||||||
assert "needs: [lint, build-release, browser-journey]" in release
|
assert "needs: [lint, build-release, browser-journey]" in release
|
||||||
|
|
||||||
|
|
||||||
|
def test_browser_job_runs_packaged_today_week_reschedule_journey():
|
||||||
|
text = WORKFLOW.read_text()
|
||||||
|
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||||
|
|
||||||
|
assert "tests/e2e/test_mobile_today_week_reschedule_release.py" in browser
|
||||||
|
|
|
||||||
|
|
@ -78,18 +78,127 @@ const opened=await controller.open('active');
|
||||||
let fullError='',overError='';
|
let fullError='',overError='';
|
||||||
try{await controller.confirm('2026-08-24',30);}catch(error){fullError=error.message;}
|
try{await controller.confirm('2026-08-24',30);}catch(error){fullError=error.message;}
|
||||||
try{await controller.confirm('2026-08-25',30);}catch(error){overError=error.message;}
|
try{await controller.confirm('2026-08-25',30);}catch(error){overError=error.message;}
|
||||||
const offline=createReschedule({week:{load:async()=>({revision:2,offline_snapshot:true}),review:()=>({days})},api,getToday:()=>({revision:3,ids:['active'],estimates:{active:30}})});
|
const offline=createReschedule({week:{load:async()=>({revision:2,offline_snapshot:true,days:[]}),review:()=>({days})},api,getToday:()=>({revision:3,ids:['active'],estimates:{active:30}})});
|
||||||
let offlineError='';try{await offline.open('active');}catch(error){offlineError=error.message;}
|
let offlineOpened=null;try{offlineOpened=await offline.open('active');}catch(_error){}
|
||||||
console.log(JSON.stringify({opened,fullError,overError,offlineError,calls}));
|
console.log(JSON.stringify({opened,fullError,overError,offlineOpened,calls}));
|
||||||
""")
|
""")
|
||||||
|
|
||||||
assert result["opened"]["days"][0]["eligible"] is False
|
assert result["opened"]["days"][0]["eligible"] is False
|
||||||
assert result["fullError"] == "That Week Ahead day already has five items."
|
assert result["fullError"] == "That Week Ahead day already has five items."
|
||||||
assert result["overError"] == "That move exceeds the day's capacity. Confirm overload before rescheduling."
|
assert result["overError"] == "That move exceeds the day's capacity. Confirm overload before rescheduling."
|
||||||
assert result["offlineError"] == "Reconnect before rescheduling Today into Week Ahead."
|
assert result["offlineOpened"]["offline"] is True
|
||||||
assert result["calls"] == 0
|
assert result["calls"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_reschedule_controller_persists_offline_move_before_optimistic_adoption():
|
||||||
|
result = run_controller("""
|
||||||
|
const values=new Map();
|
||||||
|
const events=[];
|
||||||
|
const storage={
|
||||||
|
getItem:key=>values.get(key)||null,
|
||||||
|
setItem:(key,value)=>{events.push('persist');values.set(key,value);},
|
||||||
|
removeItem:key=>values.delete(key),
|
||||||
|
};
|
||||||
|
const today={revision:4,ids:['active','other'],capacity_minutes:120,estimates:{active:45,other:20}};
|
||||||
|
const weekState={revision:7,timezone:'UTC',offline_snapshot:true,days:[
|
||||||
|
{plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}}
|
||||||
|
]};
|
||||||
|
const week={load:async()=>weekState,review:()=>({days:[
|
||||||
|
{plan_date:'2026-08-25',label:'Tue',ids:[],planned_minutes:0,capacity_minutes:90,estimates:{}}
|
||||||
|
]}),adopt:value=>events.push(['week',value])};
|
||||||
|
const api=async()=>{const error=new Error('offline');error.status=0;throw error;};
|
||||||
|
const controller=createReschedule({week,api,getToday:()=>today,adoptToday:value=>events.push(['today',value]),
|
||||||
|
storage,getLogin:()=> 'Timmy',operationId:()=> 'move-active',now:()=>123});
|
||||||
|
const opened=await controller.open('active');
|
||||||
|
const confirmed=await controller.confirm('2026-08-25',45);
|
||||||
|
console.log(JSON.stringify({opened,confirmed,events,keys:[...values.keys()],record:JSON.parse([...values.values()][0])}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["opened"]["offline"] is True
|
||||||
|
assert result["confirmed"]["sync_pending"] is True
|
||||||
|
assert result["events"][0] == "persist"
|
||||||
|
assert result["events"][1][0] == "today"
|
||||||
|
assert result["events"][1][1]["ids"] == ["other"]
|
||||||
|
assert result["events"][2][0] == "week"
|
||||||
|
assert result["events"][2][1]["days"][0]["ids"] == ["active"]
|
||||||
|
assert result["keys"] == ["stackchain.today-week-reschedule.v1.timmy"]
|
||||||
|
assert result["record"]["body"]["operation_id"] == "move-active"
|
||||||
|
assert result["record"]["queued_at"] == 123
|
||||||
|
|
||||||
|
|
||||||
|
def test_reschedule_controller_restores_and_delivers_same_operation_after_reload():
|
||||||
|
result = run_controller("""
|
||||||
|
const key='stackchain.today-week-reschedule.v1.timmy';
|
||||||
|
const record={queued_at:123,body:{operation_id:'move-active',identity:'active',estimate_minutes:45,
|
||||||
|
plan_date:'2026-08-25',today_revision:4,week_revision:7,allow_over_capacity:false},
|
||||||
|
today:{revision:4,ids:['other'],estimates:{other:20}},
|
||||||
|
week:{revision:7,timezone:'UTC',days:[{plan_date:'2026-08-25',ids:['active'],capacity_minutes:90,estimates:{active:45}}]}};
|
||||||
|
const values=new Map([[key,JSON.stringify(record)]]);const adopted=[];const requests=[];
|
||||||
|
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||||
|
const api=async(url,options)=>{requests.push({url,body:JSON.parse(options.body)});return {
|
||||||
|
today:{revision:5,ids:['other'],estimates:{other:20}},
|
||||||
|
week:{revision:8,timezone:'UTC',days:[{plan_date:'2026-08-25',ids:['active'],capacity_minutes:90,estimates:{active:45}}]}};};
|
||||||
|
const controller=createReschedule({week:{adopt:value=>adopted.push(['week',value])},api,
|
||||||
|
getToday:()=>null,adoptToday:value=>adopted.push(['today',value]),storage,getLogin:()=> 'timmy'});
|
||||||
|
const restored=controller.resume();
|
||||||
|
const delivered=await controller.flush();
|
||||||
|
console.log(JSON.stringify({restored,delivered,adopted,requests,remaining:values.size}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["restored"]["sync_pending"] is True
|
||||||
|
assert result["adopted"][0][0] == "today"
|
||||||
|
assert result["adopted"][1][0] == "week"
|
||||||
|
assert result["requests"][0]["body"]["operation_id"] == "move-active"
|
||||||
|
assert result["delivered"]["sync_pending"] is False
|
||||||
|
assert result["remaining"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_reschedule_controller_keeps_admitted_operation_when_connection_drops_on_confirm():
|
||||||
|
result = run_controller("""
|
||||||
|
const values=new Map();const events=[];
|
||||||
|
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>{events.push('persist');values.set(key,value);},removeItem:key=>values.delete(key)};
|
||||||
|
const weekState={revision:7,timezone:'UTC',days:[{plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}}]};
|
||||||
|
const week={load:async()=>weekState,review:()=>({days:[{...weekState.days[0],label:'Tue',planned_minutes:0}]}),
|
||||||
|
adopt:value=>events.push(['week',value])};
|
||||||
|
let calls=0;const api=async url=>{if(url==='api/v1/today')return {revision:4,ids:['active'],estimates:{active:45}};
|
||||||
|
calls++;const error=new Error('connection dropped');error.status=0;throw error;};
|
||||||
|
const controller=createReschedule({week,api,getToday:()=>null,adoptToday:value=>events.push(['today',value]),
|
||||||
|
storage,getLogin:()=> 'timmy',operationId:()=> 'stable-operation'});
|
||||||
|
await controller.open('active');
|
||||||
|
const result=await controller.confirm('2026-08-25',45);
|
||||||
|
console.log(JSON.stringify({result,events,calls,pending:controller.pending()}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["result"]["sync_pending"] is True
|
||||||
|
assert result["events"][0] == "persist"
|
||||||
|
assert result["calls"] == 1
|
||||||
|
assert result["pending"]["body"]["operation_id"] == "stable-operation"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reschedule_controller_restores_server_truth_and_keeps_intent_on_conflict():
|
||||||
|
result = run_controller("""
|
||||||
|
const key='stackchain.today-week-reschedule.v1.timmy';
|
||||||
|
const record={queued_at:123,body:{operation_id:'move-active'},today:{revision:4,ids:[]},week:{revision:7,days:[]}};
|
||||||
|
const values=new Map([[key,JSON.stringify(record)]]);const adopted=[];
|
||||||
|
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||||
|
const api=async url=>{
|
||||||
|
if(url==='api/v1/week/reschedule'){const error=new Error('conflict');error.status=409;throw error;}
|
||||||
|
if(url==='api/v1/today')return {revision:9,ids:['active'],estimates:{active:45}};
|
||||||
|
return {revision:12,timezone:'UTC',days:[]};
|
||||||
|
};
|
||||||
|
const controller=createReschedule({week:{adopt:value=>adopted.push(['week',value])},api,
|
||||||
|
adoptToday:value=>adopted.push(['today',value]),storage,getLogin:()=> 'timmy'});
|
||||||
|
let message='';try{await controller.flush();}catch(error){message=error.message;}
|
||||||
|
console.log(JSON.stringify({message,adopted,pending:controller.pending(),remaining:values.size}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["message"] == "Plans changed on another device. Review and retry this saved move."
|
||||||
|
assert result["adopted"][0][1]["revision"] == 9
|
||||||
|
assert result["adopted"][1][1]["revision"] == 12
|
||||||
|
assert result["pending"]["body"]["operation_id"] == "move-active"
|
||||||
|
assert result["remaining"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_active_today_reschedule_dialog_is_touch_safe_and_wired_into_release_bundle():
|
def test_mobile_active_today_reschedule_dialog_is_touch_safe_and_wired_into_release_bundle():
|
||||||
index = INDEX.read_text()
|
index = INDEX.read_text()
|
||||||
css = CSS.read_text()
|
css = CSS.read_text()
|
||||||
|
|
@ -105,5 +214,8 @@ def test_mobile_active_today_reschedule_dialog_is_touch_safe_and_wired_into_rele
|
||||||
assert '.today-week-reschedule-days button { min-height:44px;' in css
|
assert '.today-week-reschedule-days button { min-height:44px;' in css
|
||||||
assert '.today-week-reschedule-panel { width:min(100%,560px);' in css
|
assert '.today-week-reschedule-panel { width:min(100%,560px);' in css
|
||||||
assert 'x:currentTodayProgressTarget' in dashboard
|
assert 'x:currentTodayProgressTarget' in dashboard
|
||||||
|
assert 'restoreWhenOwned()' in CONTROLLER.read_text()
|
||||||
assert "runTodayTransition('next')" in dashboard
|
assert "runTodayTransition('next')" in dashboard
|
||||||
|
assert 'reschedule:()=>({storage,getLogin})' in (CONTROLLER.parent / "week-plan.js").read_text()
|
||||||
|
assert "addEventListener('online', flushPending)" in CONTROLLER.read_text()
|
||||||
assert '"static/today-week-reschedule.js"' in bundle
|
assert '"static/today-week-reschedule.js"' in bundle
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user