feat: preserve Search work details offline (Closes #1220)
All checks were successful
CI / lint (pull_request) Successful in 3m46s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m16s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-21 12:03:25 +00:00
parent ba50784022
commit 2b874d8427
6 changed files with 151 additions and 20 deletions

View File

@ -133,7 +133,10 @@
const existing = week.placement(id);
if (!week.place(id, choice.date, Number(choice.estimate), {move:Boolean(existing && existing.date !== choice.date)})) {
failed.push({id:originalId, reason:'assigned-not-planned'});
} else planned.push(id);
} else {
week.rememberPendingItem?.(id, confirmed);
planned.push(id);
}
} catch (error) {
failed.push({id:originalId, reason:error?.message || 'assignment failed'});
}

View File

@ -37,6 +37,7 @@ function createSearchWeekPlan({week,claim,accept=item=>item,identity,maxItems=5}
const id=identity(item);
if(!week.place(id,options.date,estimate,{move:Boolean(options.move)}))
return {status:assigned?'assigned-not-planned':'not-planned',item};
week.rememberPendingItem?.(id,item);
try {
await week.flush();
return {status:'planned',date:options.date,estimate,item};

View File

@ -5,12 +5,23 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
let offlineSnapshot=false;
let refreshedAt=null;
let confirmedItems={};
let pendingItems={};
const storagePrefix='stackchain.week-sync.v1.';
const confirmedPrefix='stackchain.week-confirmed.v1.';
const cloneDay=day=>({
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
estimates:{...(day.estimates||{})},
});
const sanitizeItem=value=>value?{kind:value.kind==='pull'?'pull':'issue',
title:String(value.title||'').slice(0,500),repository:String(value.repository||'').slice(0,255),
number:Number.isInteger(value.number)?value.number:null}:null;
function sanitizeItems(days,sources={}) {
const sanitized={};
new Set((days||[]).flatMap(day=>day.ids||[])).forEach(id=>{
const value=sanitizeItem(sources[id]);if(value)sanitized[id]=value;
});
return sanitized;
}
const state=()=>({revision:week.revision,timezone:week.timezone,days:week.days.map(cloneDay),
offline_snapshot:offlineSnapshot,...(refreshedAt?{refreshed_at:refreshedAt}:{}),
...(week.sync_pending?{base_revision:week.base_revision,sync_pending:true}:{})});
@ -41,14 +52,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
catch(_error){return false;}
}
function rememberItems(items={}) {
const ids=new Set(week.days.flatMap(day=>day.ids||[])),sanitized={};
ids.forEach(id=>{
const value=items[id];
if(!value)return;
sanitized[id]={kind:value.kind==='pull'?'pull':'issue',title:String(value.title||'').slice(0,500),
repository:String(value.repository||'').slice(0,255),number:Number.isInteger(value.number)?value.number:null};
});
confirmedItems=sanitized;
confirmedItems=sanitizeItems(week.days,{...confirmedItems,...items});
return persistConfirmed();
}
function pending() {
@ -59,6 +63,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.days)?{
revision:value.base_revision,base_revision:value.base_revision,timezone:value.timezone||null,
days:value.days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),sync_pending:true,
items:sanitizeItems(value.days,value.items||{}),
...(Array.isArray(value.base_days)?{base_days:value.base_days.map(cloneDay)
.sort((left,right)=>left.plan_date.localeCompare(right.plan_date))}:{}),
...(value.resolutions&&typeof value.resolutions==='object'?{resolutions:{...value.resolutions}}:{}),
@ -70,12 +75,13 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
week={revision:value.revision,timezone:value.timezone||null,days:value.days.map(cloneDay)
.sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
offlineSnapshot=false;
pendingItems={};
lastConflict=null;
return state();
}
function adoptConfirmed(value) {
function adoptConfirmed(value,items=confirmedItems) {
const adopted=adopt(value);
if(adopted)persistConfirmed();
if(adopted){confirmedItems=sanitizeItems(week.days,items);persistConfirmed();}
return adopted;
}
function addDays(value, amount) {
@ -101,16 +107,21 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
}
async function load() {
const queued=pending();
if(queued){week=queued;offlineSnapshot=false;return state();}
if(queued){
week=queued;pendingItems=queued.items||{};
confirmedItems=sanitizeItems(week.days,readConfirmed()?.items||{});
offlineSnapshot=false;return state();
}
try {
adopt(await fetchJson('api/v1/week'));
const cached=readConfirmed();confirmedItems=cached?.items||{};persistConfirmed();
const cached=readConfirmed();confirmedItems=sanitizeItems(week.days,cached?.items||{});persistConfirmed();
return state();
} catch(error) {
if(error?.status===401||error?.status===403)throw error;
const cached=readConfirmed();
if(!cached)throw error;
adopt(cached.week);offlineSnapshot=true;refreshedAt=cached.refreshed_at;confirmedItems=cached.items||{};
adopt(cached.week);offlineSnapshot=true;refreshedAt=cached.refreshed_at;
confirmedItems=sanitizeItems(week.days,cached.items||{});
return state();
}
}
@ -121,12 +132,27 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
const queued={base_revision:Number.isInteger(week.base_revision)?week.base_revision:week.revision,
timezone:timeZone(),days:days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),
base_days:(week.base_days||week.days).map(cloneDay)};
queued.items=sanitizeItems(queued.days,{...confirmedItems,...pendingItems,...(pending()?.items||{})});
try { storage.setItem(key,JSON.stringify(queued)); }
catch(_error) { return false; }
lastConflict=null;
week={revision:queued.base_revision,...queued,sync_pending:true};
pendingItems=queued.items;
return state();
}
function rememberPendingItem(id,value) {
const key=storageKey(),item=sanitizeItem(value);
if(!key||!storage||!item)return false;
try {
const raw=JSON.parse(storage.getItem(key)||'null');
if(!Number.isInteger(raw?.base_revision)||!Array.isArray(raw?.days)||
!raw.days.some(day=>(day.ids||[]).includes(id)))return false;
raw.items=sanitizeItems(raw.days,{...(raw.items||{}),[id]:item});
storage.setItem(key,JSON.stringify(raw));pendingItems=raw.items;
if(week.sync_pending)week={...week,items:raw.items};
return true;
} catch(_error){return false;}
}
function stageDay(planDate,value) {
return stageDays(week.days.filter(item=>item.plan_date!==planDate).concat([{
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
@ -244,7 +270,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
body:JSON.stringify({base_revision:remote.revision,timezone:local.timezone,days:merged.days})});
const current=pending();
if(current&&sameBody(current,deliveryBody(local)))storage.removeItem(key);
if(!pending())adoptConfirmed(saved);
if(!pending())adoptConfirmed(saved,{...confirmedItems,...(local.items||{})});
return saved;
}
lastConflict={key,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
@ -257,7 +283,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
}
const current=pending();
if(current&&sameBody(current,body)){
storage.removeItem(key);adoptConfirmed(saved);return saved;
storage.removeItem(key);adoptConfirmed(saved,{...confirmedItems,...(current.items||{})});return saved;
}
if(current){
try {
@ -265,10 +291,11 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
raw.base_revision=saved.revision;raw.base_days=saved.days.map(cloneDay);
storage.setItem(key,JSON.stringify(raw));
week={revision:raw.base_revision,...raw,sync_pending:true};
pendingItems=raw.items||{};
} catch(_error){return saved;}
return deliver();
}
adoptConfirmed(saved);return saved;
adoptConfirmed(saved,{...confirmedItems,...(queued.items||{})});return saved;
};
flushing=deliver().finally(()=>{flushing=null;});
return flushing;
@ -329,7 +356,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const current=pending();
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(deliveryBody(local))) storage.removeItem(storageKey());
if(!pending()) adoptConfirmed(saved);
if(!pending()) adoptConfirmed(saved,{...confirmedItems,...(local.items||{})});
lastConflict=null;
return saved;
} catch(error) {
@ -350,7 +377,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const current=pending();
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(deliveryBody(local))) storage.removeItem(storageKey());
if(!pending()) adoptConfirmed(saved);
if(!pending()) adoptConfirmed(saved,{...confirmedItems,...(local.items||{})});
lastConflict=null;
return saved;
} catch(error) {
@ -398,7 +425,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
return label+(pending()?' · sync pending':'');
}
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
keepLocal,useRemote,promote,reconcile:reconcilePromotion,summary,rememberItems,item:id=>confirmedItems[id]||null,
keepLocal,useRemote,promote,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
item:id=>pendingItems[id]||confirmedItems[id]||null,
offline:()=>offlineSnapshot};
}
function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute,

View File

@ -137,6 +137,30 @@ const flow=createWeekBatchPlan({{
]
def test_week_batch_stages_details_only_for_successfully_placed_canonical_items():
script = f"""
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
const flow=createSearchBatchPlan.createWeekBatchPlan({{
week:{{load:async()=>{{}},dates:()=>[{{date:'2026-08-21'}}],day:()=>({{ids:[],estimates:{{}}}}),
placement:()=>null,place:id=>!id.endsWith('#2'),
rememberPendingItem:(id,item)=>calls.push(['details',id,item.title]),flush:async()=>calls.push(['flush'])}},
prepare:async item=>({{...item,title:'Canonical '+item.number}}),
identity:item=>item.repository+'#'+item.number,
}});
const calls=[];const items=[
{{repository:'r',number:1,title:'Search one'}},{{repository:'r',number:2,title:'Search two'}}];
(async()=>{{const outcome=await flow.run(items,{{'r#1':{{date:'2026-08-21',estimate:20}},'r#2':{{date:'2026-08-21',estimate:30}}}});
process.stdout.write(JSON.stringify({{outcome,calls}}));}})();
"""
result = run_node(script)
assert result["outcome"] == {
"status": "partial", "planned": 1,
"failed": [{"id": "r#2", "reason": "assigned-not-planned"}],
}
assert result["calls"] == [["details", "r#1", "Canonical 1"], ["flush"]]
def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()

View File

@ -99,3 +99,26 @@ console.log(JSON.stringify(outcome));
""")
assert result == {"status": "planned-pending", "date": "2026-08-22", "estimate": 45}
def test_search_week_plan_stages_canonical_details_before_flushing():
result = run_planner("""
const calls=[];
const week={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat'}],
day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),placement:()=>null,
place:(id)=>{calls.push(['place',id]);return true;},
rememberPendingItem:(id,item)=>{calls.push(['details',id,item.title,item.repository,item.number]);return true;},
flush:async()=>{calls.push(['flush']);return {revision:3};}};
const planner=createSearchWeekPlan({week,claim:async item=>({...item,title:'Canonical title'}),
accept:item=>({...item,accepted:true}),identity:item=>`issue:${item.repository}:${item.number}:`});
const outcome=await planner.plan({kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true},
{date:'2026-08-22',estimate:45});
console.log(JSON.stringify({outcome,calls}));
""")
assert result["outcome"]["status"] == "planned"
assert result["calls"] == [
["place", "issue:stackchain/dashboard:42:"],
["details", "issue:stackchain/dashboard:42:", "Canonical title", "stackchain/dashboard", 42],
["flush"],
]

View File

@ -577,6 +577,58 @@ console.log(JSON.stringify({loaded,restored,item:fallback.item('issue:r:42:'),ot
assert "secret" not in result["snapshot"]["items"]["issue:r:42:"]
def test_week_controller_promotes_bounded_pending_work_details_after_matching_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 online=true;const requests=[];
const options={storage,getLogin:()=> 'timmy',now:()=>Date.parse('2026-08-20T12:34:00Z'),
fetchJson:async(_url,request={})=>{
requests.push(request.body?JSON.parse(request.body):null);
if(!online)throw new Error('offline');
if(request.method==='PUT')return {revision:8,timezone:'UTC',days:JSON.parse(request.body).days};
return {revision:7,timezone:'UTC',days:[]};
},localDate:()=> '2026-08-20',timeZone:()=> 'UTC'};
const live=createWeekPlan(options);await live.load();
live.place('issue:r:42:','2026-08-21',45);
const admitted=live.rememberPendingItem('issue:r:42:',{kind:'issue',title:'T'.repeat(600),repository:'r'.repeat(300),
number:42,body:'private body',token:'drop'});
const queued=live.pending();
await live.flush();
const confirmed=JSON.parse(values.get('stackchain.week-confirmed.v1.timmy'));
online=false;const offline=createWeekPlan(options);const restored=await offline.load();
console.log(JSON.stringify({admitted,queued,confirmed,restored,item:offline.item('issue:r:42:'),requests}));
""")
assert result["admitted"] is True
assert result["queued"]["items"]["issue:r:42:"]["title"] == "T" * 500
assert len(result["queued"]["items"]["issue:r:42:"]["repository"]) == 255
assert result["requests"][1].keys() == {"base_revision", "timezone", "days"}
assert result["confirmed"]["week"]["revision"] == 8
assert result["confirmed"]["items"]["issue:r:42:"] == result["item"]
assert result["item"]["number"] == 42
assert "body" not in result["item"] and "token" not in result["item"]
assert result["restored"]["offline_snapshot"] is True
def test_week_controller_keeps_pending_work_details_across_a_cold_offline_reload():
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 options={storage,getLogin:()=> 'timmy',fetchJson:async()=>{throw new Error('offline');},
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'};
const first=createWeekPlan(options);first.adopt({revision:7,timezone:'UTC',days:[]});
first.place('issue:r:42:','2026-08-21',45);
first.rememberPendingItem('issue:r:42:',{kind:'issue',title:'Pending work',repository:'r',number:42});
const restored=createWeekPlan(options);const state=await restored.load();
console.log(JSON.stringify({state,item:restored.item('issue:r:42:'),pending:restored.pending()}));
""")
assert result["state"]["sync_pending"] is True
assert result["item"] == {"kind": "issue", "title": "Pending work", "repository": "r", "number": 42}
assert result["pending"]["items"]["issue:r:42:"] == result["item"]
def test_week_controller_does_not_use_a_confirmed_snapshot_after_authentication_fails():
result = run_controller("""
const values=new Map();