stackchain-dashboard/frontend/search-week-plan.js
timmy 2b874d8427
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
feat: preserve Search work details offline (Closes #1220)
2026-08-21 12:03:25 +00:00

116 lines
7.4 KiB
JavaScript

function createSearchWeekPlan({week,claim,accept=item=>item,identity,maxItems=5}={}) {
let running=null;
const eligible=detail=>Boolean(detail&&detail.kind==='issue'&&detail.state==='open'&&
(detail.claimable||detail.assigned_to_me));
const loadDay=date=>{
const value=week.day(date),estimates=value.estimates||{};
return {...value,planned_minutes:(value.ids||[]).reduce((total,id)=>total+(Number(estimates[id])||0),0)};
};
async function preview(detail) {
if(!eligible(detail))return {eligible:false,days:[]};
await week.load();
const id=identity(detail),existing=week.placement(id);
return {eligible:true,existing,days:week.dates().map(item=>({...item,...loadDay(item.date)}))};
}
function plan(detail,options={}) {
if(running)return running;
const perform=async()=>{
if(!eligible(detail))return {status:'ineligible'};
const estimate=Number(options.estimate);
if(!Number.isFinite(estimate)||estimate<=0)return {status:'estimate-required'};
await week.load();
if(!week.dates().some(item=>item.date===options.date))return {status:'date-required'};
const originalId=identity(detail),existing=week.placement(originalId);
if(existing&&existing.date!==options.date&&!options.move)
return {status:'already-planned',date:existing.date,estimate:existing.estimate};
const destination=loadDay(options.date);
const alreadyThere=(destination.ids||[]).includes(originalId);
if(!alreadyThere&&(destination.ids||[]).length>=maxItems)return {status:'day-full',limit:maxItems};
const previous=alreadyThere?(Number(destination.estimates?.[originalId])||0):0;
const plannedMinutes=destination.planned_minutes-previous+estimate;
if(Number(destination.capacity_minutes)>0&&plannedMinutes>Number(destination.capacity_minutes)&&!options.confirmOverload)
return {status:'overload-confirmation-required',planned_minutes:plannedMinutes,
capacity_minutes:Number(destination.capacity_minutes)};
let item=detail,assigned=false;
if(detail.claimable){item=await claim(detail);assigned=true;}
item=accept(item)||item;
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};
} catch(_error) {
return {status:'planned-pending',date:options.date,estimate};
}
};
running=perform().finally(()=>{running=null;});
return running;
}
return {eligible,preview,plan,pending:()=>Boolean(running)};
}
function createSearchWeekPlanUI({planner,document,window,getDetail,onPlanned=()=>{},announce=()=>{},escapeHtml,escapeAttribute}={}) {
const qs=selector=>document.querySelector(selector);
let trigger=null,detail=null,preview=null,overload=false;
function close(navigate=false) {
if(navigate&&window.history.state?.searchWeekPlan){window.history.back();return;}
qs('#search-week-plan-sheet').hidden=true;
detail=null;preview=null;overload=false;trigger?.focus?.();trigger=null;
}
function selectDate(date) {
qs('#search-week-plan-days').querySelectorAll('[data-search-week-date]').forEach(button=>
button.setAttribute('aria-pressed',String(button.dataset.searchWeekDate===date)));
qs('#search-week-plan-days').dataset.selected=date;
overload=false;qs('#confirm-search-week-plan').textContent='Plan';
}
async function open(button) {
const selected=getDetail();
if(!selected||planner.pending())return false;
trigger=button;detail=selected;overload=false;
const sheet=qs('#search-week-plan-sheet'),status=qs('#search-week-plan-status');
sheet.hidden=false;status.textContent='Loading Week Ahead…';qs('#confirm-search-week-plan').disabled=true;
window.history.pushState({...window.history.state,searchWeekPlan:true},'',window.location.href);
qs('#search-week-plan-copy').textContent=detail.title||'Untitled issue';
try {
preview=await planner.preview(detail);
const existing=preview.existing;
qs('#search-week-plan-days').innerHTML=preview.days.map((day,index)=>
'<button type="button" data-search-week-date="'+escapeAttribute(day.date)+'" aria-pressed="'+String(index===0)+'"><strong>'+escapeHtml(day.label)+
'</strong><small>'+escapeHtml(day.planned_minutes+(day.capacity_minutes?' of '+day.capacity_minutes:'')+' min · '+day.ids.length+' planned')+'</small></button>').join('');
qs('#search-week-plan-days').querySelectorAll('[data-search-week-date]').forEach(dayButton=>
dayButton.addEventListener('click',()=>selectDate(dayButton.dataset.searchWeekDate)));
selectDate(existing?.date||preview.days[0].date);
qs('#search-week-plan-estimate').value=existing?.estimate||'';
status.textContent=existing?'Already planned for '+existing.date+'. Choose another day to move it.':'Choose a day and add an estimate.';
qs('#confirm-search-week-plan').disabled=false;qs('#search-week-plan-estimate').focus();return true;
} catch(error) {status.textContent=(error.message||'Week Ahead is unavailable.')+' Retry when connected.';return false;}
}
async function confirm() {
if(!detail||planner.pending())return;
const date=qs('#search-week-plan-days').dataset.selected,estimate=Number(qs('#search-week-plan-estimate').value);
const existing=preview?.existing,move=Boolean(existing&&existing.date!==date);
const button=qs('#confirm-search-week-plan'),status=qs('#search-week-plan-status');button.disabled=true;
const outcome=await planner.plan(detail,{date,estimate,move,confirmOverload:overload});
if(outcome.status==='overload-confirmation-required'){
overload=true;button.textContent='Confirm over capacity';
status.textContent=outcome.planned_minutes+' min exceeds '+outcome.capacity_minutes+' min capacity. Confirm to plan anyway.';
} else if(outcome.status==='estimate-required')status.textContent='Enter an estimate in minutes.';
else if(outcome.status==='day-full')status.textContent='That day already has '+outcome.limit+' items. Choose another day.';
else if(outcome.status==='already-planned')status.textContent='Already planned for '+outcome.date+'. Choose Move to change the day.';
else if(outcome.status==='assigned-not-planned')status.textContent='Assigned, not planned. Find it in My Work and retry.';
else if(outcome.status==='planned-pending'){announce('Planned for '+date+' · sync pending.');onPlanned(detail,true);close(true);}
else if(outcome.status==='planned'){announce('Planned for '+date+'.');onPlanned(detail,false);close(true);}
else status.textContent='Could not plan this issue. Retry.';
button.disabled=false;
}
qs('#plan-search-result').addEventListener('click',event=>open(event.currentTarget));
qs('#cancel-search-week-plan').addEventListener('click',()=>close(true));
qs('#confirm-search-week-plan').addEventListener('click',confirm);
window.addEventListener('popstate',()=>{if(!qs('#search-week-plan-sheet').hidden)close();});
document.addEventListener('keydown',event=>{
if(event.key==='Escape'&&!qs('#search-week-plan-sheet').hidden){event.preventDefault();close(true);}
});
return {open,close,confirm};
}
if(typeof module!=='undefined'&&module.exports){module.exports=createSearchWeekPlan;module.exports.UI=createSearchWeekPlanUI;}