stackchain-dashboard/frontend/week-calendar.js
timmy 5fb4da30a4
All checks were successful
CI / lint (pull_request) Successful in 4m3s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m22s
CI / release-candidate (pull_request) Has been skipped
feat: persist exact Week Ahead task times (Closes #1270)
2026-08-22 16:39:24 +00:00

162 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function(root){
'use strict';
let availabilityProvider=()=>null;
function escapeText(value){
return String(value||'').replace(/\\/g,'\\\\').replace(/\r?\n/g,'\\n').replace(/,/g,'\\,').replace(/;/g,'\\;');
}
function compact(value){return String(value||'').replace(/[-:]/g,'');}
function addMinutes(value,minutes){
const [hours,mins]=String(value).split(':').map(Number),total=hours*60+mins+Number(minutes);
return String(Math.floor(total/60)%24).padStart(2,'0')+':'+String(total%60).padStart(2,'0');
}
function clockMinutes(value){const [hours,minutes]=String(value).split(':').map(Number);return hours*60+minutes;}
function stableId(value){return String(value||'work').replace(/[^a-z0-9]+/gi,'-').replace(/^-|-$/g,'').toLowerCase();}
function foldLine(line){
const encoder=new TextEncoder(),chunks=[];let chunk='',bytes=0,limit=75;
for(const character of String(line)){
const width=encoder.encode(character).length;
if(chunk&&bytes+width>limit){chunks.push(chunk);chunk=character;bytes=width;limit=74;}
else {chunk+=character;bytes+=width;}
}
chunks.push(chunk);return chunks.join('\r\n ');
}
function buildWeekSchedule(plan,startTimes,getItem,selected,availability){
const blocks=[],blockers=[],availableByDate=new Map((availability||[]).map(day=>[day.plan_date,day.free_windows||[]]));
(plan?.days||[]).slice().sort((a,b)=>a.plan_date.localeCompare(b.plan_date)).forEach(day=>{
let cursor=startTimes?.[day.plan_date]||'09:00';
const windows=availableByDate.has(day.plan_date)?availableByDate.get(day.plan_date):(day.free_windows||null);
const candidates=[];
(day.ids||[]).forEach(id=>{
if(selected&&!selected.has(id))return;
const minutes=Number(day.estimates?.[id]),item=getItem?.(id);
if(!item||!Number.isFinite(minutes)||minutes<=0)return;
const exact=startTimes?.[id]||day.start_times?.[id];
let start=exact||cursor;
if(!exact&&windows){
const fit=windows.find(window=>{
const candidate=Math.max(clockMinutes(cursor),clockMinutes(window.start_time));
if(candidate+minutes>clockMinutes(window.end_time))return false;
start=addMinutes('00:00',candidate);return true;
});
if(!fit){blockers.push({id,title:item.title||'Untitled work',plan_date:day.plan_date,minutes});return;}
}
const startMinute=clockMinutes(start),endMinute=startMinute+minutes;
candidates.push({item,id,minutes,start,startMinute,endMinute,exact:Boolean(exact)});
if(!exact)cursor=addMinutes('00:00',endMinute);
});
const invalid=new Set();
candidates.forEach(candidate=>{
if(candidate.endMinute>1440||windows&&!windows.some(window=>clockMinutes(window.start_time)<=candidate.startMinute&&candidate.endMinute<=clockMinutes(window.end_time)))invalid.add(candidate.id);
});
candidates.forEach((left,index)=>candidates.slice(index+1).forEach(right=>{
if(left.startMinute<right.endMinute&&left.endMinute>right.startMinute){invalid.add(left.id);invalid.add(right.id);}
}));
candidates.forEach(candidate=>{
if(invalid.has(candidate.id)){
blockers.push({id:candidate.id,title:candidate.item.title||'Untitled work',plan_date:day.plan_date,minutes:candidate.minutes,reason:'invalid-exact-time'});return;
}
blocks.push({...candidate.item,id:candidate.id,plan_date:day.plan_date,start_time:candidate.start,
end_time:addMinutes('00:00',candidate.endMinute),minutes:candidate.minutes});
});
});
return {blocks,blockers};
}
function buildWeekBlocks(plan,startTimes,getItem,selected,availability){
return buildWeekSchedule(plan,startTimes,getItem,selected,availability).blocks;
}
function serializeWeekCalendar(blocks,{timezone='UTC',revision=0,generatedAt}={}){
const stamp=generatedAt||new Date().toISOString().replace(/[-:]/g,'').replace(/\.\d{3}/,'');
const lines=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Stackchain//Week Ahead//EN','CALSCALE:GREGORIAN','METHOD:PUBLISH','X-WR-CALNAME:Stackchain Week Ahead',`X-WR-TIMEZONE:${timezone}`,'BEGIN:VTIMEZONE',`TZID:${timezone}`,'END:VTIMEZONE'];
(blocks||[]).forEach(block=>{
const day=compact(block.plan_date),reference=block.repository+(block.number!=null?' #'+block.number:'');
lines.push('BEGIN:VEVENT',`UID:week-${stableId(block.id)}@stackchain`,`SEQUENCE:${Math.max(0,Math.floor(Number(revision)||0))}`,`DTSTAMP:${stamp}`,
`DTSTART;TZID=${timezone}:${day}T${compact(block.start_time)}00`,`DTEND;TZID=${timezone}:${day}T${compact(block.end_time)}00`,
`SUMMARY:${escapeText(block.title||'Untitled work')}`,`DESCRIPTION:${escapeText(reference+' · Stackchain Week Ahead')}`,
`URL:${String(block.url||'')}`,'TRANSP:OPAQUE','END:VEVENT');
});
lines.push('END:VCALENDAR');return lines.map(foldLine).join('\r\n')+'\r\n';
}
async function deliverWeekCalendar({text,filename,navigator,document,urlApi,FileCtor}){
const file=new FileCtor([text],filename,{type:'text/calendar;charset=utf-8'});
const payload={files:[file],title:'Stackchain Week Ahead',text:'Timed Week Ahead calendar blocks'};
if(typeof navigator?.share==='function'&&typeof navigator?.canShare==='function'&&navigator.canShare(payload)){
await navigator.share(payload);return 'shared';
}
const href=urlApi.createObjectURL(file);
try{const anchor=document.createElement('a');anchor.href=href;anchor.download=filename;anchor.click();}
finally{urlApi.revokeObjectURL(href);}
return 'downloaded';
}
function mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,escapeHtml,escapeAttribute,onSave,onDone,windowObject=root,navigatorObject=root.navigator,
documentObject=root.document,urlApi=root.URL,FileCtor=root.File}={}){
const handoff=qs('#week-calendar-handoff'),review=qs('#week-review'),daysRoot=qs('#week-calendar-days');
let plan=null,focusReturn=null;
const selected=()=>new Set(Array.from(daysRoot.querySelectorAll('[data-week-calendar-item]')).filter(input=>input.checked).map(input=>input.value));
const starts=()=>Object.fromEntries([
...Array.from(daysRoot.querySelectorAll('[data-week-calendar-start]')).map(input=>[input.dataset.weekCalendarStart,input.value]),
...Array.from(daysRoot.querySelectorAll('[data-week-calendar-exact]')).filter(input=>input.value).map(input=>[input.dataset.weekCalendarExact,input.value]),
]);
const schedule=()=>buildWeekSchedule(plan,starts(),getItem,selected(),getAvailability?.());
const blocks=()=>schedule().blocks;
function update(){
const current=schedule(),byId=new Map(current.blocks.map(block=>[block.id,block])),blockedById=new Map(current.blockers.map(blocker=>[blocker.id,blocker]));
daysRoot.querySelectorAll('[data-week-calendar-preview]').forEach(node=>{
const id=node.dataset.weekCalendarPreview,block=byId.get(id),blocker=blockedById.get(id);
node.textContent=block?block.start_time+''+block.end_time+' · '+block.minutes+' min':
(blocker?'Does not fit imported free time':'Excluded from calendar');
});
qs('#share-week-calendar').disabled=!current.blocks.length||current.blockers.length>0;
qs('#save-week-calendar-times').disabled=!current.blocks.length||current.blockers.length>0;
if(current.blockers.length){
const blocker=current.blockers[0];
qs('#week-calendar-status').textContent=blocker.title+' does not fit free time on '+blocker.plan_date+'. Adjust the start, estimate, or calendar import.';
}else qs('#week-calendar-status').textContent=(getAvailability?.()?'Planning around imported busy time · ':'Manual timing · ')+
current.blocks.length+' calendar block'+(current.blocks.length===1?'':'s')+' selected.';
}
function close({back=false}={}){
handoff.hidden=true;
if(back){review.hidden=false;const target=focusReturn||qs('#confirm-week-plan');focusReturn=null;target.focus?.();}
}
function open(value,{returnFocus=null}={}){
plan=value;if(returnFocus)focusReturn=returnFocus;review.hidden=true;handoff.hidden=false;
const labels=new Map((value.days||[]).map(day=>[day.plan_date,new Intl.DateTimeFormat('en',{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'}).format(new Date(day.plan_date+'T12:00:00Z'))]));
daysRoot.innerHTML=(value.days||[]).filter(day=>day.ids?.length).map(day=>'<article class="week-calendar-day"><header><h3>'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+
'</h3><label>Start <input type="time" value="09:00" data-week-calendar-start="'+escapeAttribute(day.plan_date)+'"></label></header>'+
day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96),saved=day.start_times?.[id]||'';
return '<div class="week-calendar-item"><label class="week-calendar-choice"><input type="checkbox" data-week-calendar-item value="'+escapeAttribute(id)+'" checked><span><strong>'+escapeHtml(item?.title||'Work details unavailable')+
'</strong><small>'+escapeHtml(reference)+'</small><small data-week-calendar-preview="'+escapeAttribute(id)+'"></small></span></label><label class="week-calendar-time">Start<input type="time" value="'+escapeAttribute(saved)+'" data-week-calendar-exact="'+escapeAttribute(id)+'" aria-label="Exact start for '+escapeAttribute(item?.title||'work')+'"></label></div>';}).join('')+'</article>').join('');
daysRoot.querySelectorAll('input').forEach(input=>input.addEventListener('change',update));
update();qs('#back-to-week-review-from-calendar').focus?.();return true;
}
qs('#back-to-week-review-from-calendar').addEventListener('click',()=>close({back:true}));
qs('#save-week-calendar-times').addEventListener('click',async()=>{
const button=qs('#save-week-calendar-times'),current=schedule();if(!current.blocks.length||current.blockers.length)return;
button.disabled=true;qs('#week-calendar-status').textContent='Saving exact task times…';
const byDate={};current.blocks.forEach(block=>(byDate[block.plan_date]||(byDate[block.plan_date]={}))[block.id]=block.start_time);
try{await onSave?.(byDate);plan={...plan,days:(plan.days||[]).map(day=>({...day,start_times:{...(byDate[day.plan_date]||{})}}))};
qs('#week-calendar-status').textContent='Exact task times saved to Week Ahead.';}
catch(error){qs('#week-calendar-status').textContent=(error?.message||'Exact times could not be saved.')+' Retry when connected.';}
finally{button.disabled=false;}
});
qs('#export-saved-week-calendar')?.addEventListener('click',event=>{focusReturn=event.currentTarget;qs('#confirm-week-plan').click();});
qs('#share-week-calendar').addEventListener('click',async()=>{
const button=qs('#share-week-calendar'),current=blocks();if(!current.length)return;
button.disabled=true;qs('#week-calendar-status').textContent='Preparing calendar blocks…';
const day=new Date().toISOString().slice(0,10);
try{
const text=serializeWeekCalendar(current,{timezone:plan.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone,revision:plan.revision});
const result=await deliverWeekCalendar({text,filename:'stackchain-week-ahead-'+day+'.ics',navigator:navigatorObject,
document:documentObject,urlApi,FileCtor});
close();onDone?.(result);
}catch(error){
qs('#week-calendar-status').textContent=error?.name==='AbortError'?'Share cancelled. Your Week Ahead is still ready.':'Calendar export failed. Retry without leaving Week Ahead.';
button.disabled=false;
}
});
return {open,close,blocks};
}
function setAvailabilityProvider(value){availabilityProvider=typeof value==='function'?value:()=>null;}
const api={buildWeekBlocks,buildWeekSchedule,deliverWeekCalendar,escapeText,foldLine,mountWeekCalendarHandoff,serializeWeekCalendar,setAvailabilityProvider};
if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.StackchainWeekCalendar=api;
})(typeof window!=='undefined'?window:globalThis);