From 2b874d842734a7dc492258e05306dcfd40c2f844 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 21 Aug 2026 12:03:25 +0000 Subject: [PATCH] feat: preserve Search work details offline (Closes #1220) --- frontend/search-batch-plan.js | 5 ++- frontend/search-week-plan.js | 1 + frontend/week-plan.js | 66 +++++++++++++++++++++++--------- tests/test_search_batch_plan.py | 24 ++++++++++++ tests/test_search_week_plan.py | 23 +++++++++++ tests/test_week_plan_frontend.py | 52 +++++++++++++++++++++++++ 6 files changed, 151 insertions(+), 20 deletions(-) diff --git a/frontend/search-batch-plan.js b/frontend/search-batch-plan.js index 39cf0b0..9fae371 100644 --- a/frontend/search-batch-plan.js +++ b/frontend/search-batch-plan.js @@ -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'}); } diff --git a/frontend/search-week-plan.js b/frontend/search-week-plan.js index 3c8bd3e..f1f8bb7 100644 --- a/frontend/search-week-plan.js +++ b/frontend/search-week-plan.js @@ -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}; diff --git a/frontend/week-plan.js b/frontend/week-plan.js index cacbba0..abb0fc3 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -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, diff --git a/tests/test_search_batch_plan.py b/tests/test_search_batch_plan.py index 73899e6..2968981 100644 --- a/tests/test_search_batch_plan.py +++ b/tests/test_search_batch_plan.py @@ -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() diff --git a/tests/test_search_week_plan.py b/tests/test_search_week_plan.py index dbb6521..9945d5b 100644 --- a/tests/test_search_week_plan.py +++ b/tests/test_search_week_plan.py @@ -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"], + ] diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py index 6013d1d..13b8723 100644 --- a/tests/test_week_plan_frontend.py +++ b/tests/test_week_plan_frontend.py @@ -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(); -- 2.43.0