stackchain-dashboard/frontend/week-calendar.js
timmy 218dd43764
All checks were successful
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m2s
CI / release-candidate (pull_request) Has been skipped
feat: update repeated Week Ahead calendar exports (Closes #1214)
2026-08-21 07:29:23 +00:00

131 lines
9.1 KiB
JavaScript
Raw Permalink 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';
(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;
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;qs('#confirm-week-plan').focus?.();}
}
function open(value){
plan=value;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('#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);