feat: preserve Search work details offline (Closes #1220)
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 committed 2026-08-21 12:03:25 +00:00
1 parent ba50784022
commit 2b874d8427
6 files changed
+151 -20

No files matched your search

+4 -1
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'});
}
+1
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};
+47 -19
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,