132 lines
9.4 KiB
JavaScript
132 lines
9.4 KiB
JavaScript
(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';
|
||
(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 windows=availableByDate.get(day.plan_date);
|
||
if(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;
|
||
cursor=addMinutes('00:00',candidate);return true;
|
||
});
|
||
if(!fit){blockers.push({id,title:item.title||'Untitled work',plan_date:day.plan_date,minutes});return;}
|
||
}
|
||
const end=addMinutes(cursor,minutes);
|
||
blocks.push({...item,id,plan_date:day.plan_date,start_time:cursor,end_time:end,minutes});
|
||
cursor=end;
|
||
});
|
||
});
|
||
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,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]));
|
||
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;
|
||
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);
|
||
return '<label class="week-calendar-item"><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>';}).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('#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);
|