function createWeekCalendarImport() { const maxBytes=1024*1024; function clock(value) { const match=/^(\d{2}):(\d{2})$/.exec(String(value||'')); if(!match)return null; const minutes=Number(match[1])*60+Number(match[2]); return Number(match[1])<24&&Number(match[2])<60?minutes:null; } function instant(value) { const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(value||''); if(!match)return null; const parts=match.slice(1,7).map(Number); const milliseconds=match[7]?Date.UTC(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]): new Date(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]).getTime(); return Number.isFinite(milliseconds)?milliseconds:null; } function dateInstant(value) { const match=/^(\d{4})(\d{2})(\d{2})$/.exec(value||''); return match?new Date(Number(match[1]),Number(match[2])-1,Number(match[3])).getTime():null; } function events(source) { if(typeof source!=='string'||!source.includes('BEGIN:VCALENDAR'))throw new Error('Choose a valid .ics calendar file.'); if(new TextEncoder().encode(source).length>maxBytes)throw new Error('Calendar files must be 1 MB or smaller.'); const lines=source.replace(/\r\n[ \t]/g,'').split(/\r?\n/); const result=[];let current=null; lines.forEach(line=>{ if(line==='BEGIN:VEVENT'){current={};return;} if(line==='END:VEVENT'){ if(current?.start!=null&¤t?.end>current.start&¤t.status!=='CANCELLED'&¤t.transparency!=='TRANSPARENT')result.push(current); current=null;return; } if(!current)return; const separator=line.indexOf(':');if(separator<0)return; const property=line.slice(0,separator),name=property.split(';')[0],value=line.slice(separator+1); const allDay=property.split(';').slice(1).includes('VALUE=DATE'); if(name==='DTSTART')current.start=allDay?dateInstant(value):instant(value); if(name==='DTEND')current.end=allDay?dateInstant(value):instant(value); if(name==='RRULE')current.rule=value; if(name==='RDATE')current.rdates=(current.rdates||[]).concat(value.split(',').map(item=>allDay?dateInstant(item):instant(item)).filter(value=>value!=null)); if(name==='EXDATE')current.exdates=(current.exdates||[]).concat(value.split(',').map(item=>allDay?dateInstant(item):instant(item)).filter(value=>value!=null)); if(name==='STATUS')current.status=value.toUpperCase(); if(name==='TRANSP')current.transparency=value.toUpperCase(); }); return result; } function recurrenceRule(value) { return Object.fromEntries(String(value||'').split(';').filter(Boolean).map(part=>part.split('='))); } function supportedRecurrence(value) { const rule=recurrenceRule(value),keys=Object.keys(rule); if(!['DAILY','WEEKLY'].includes(rule.FREQ))return false; const allowed=new Set(['FREQ','COUNT','INTERVAL','UNTIL',...(rule.FREQ==='WEEKLY'?['BYDAY']:[])]); if(keys.some(key=>!allowed.has(key)))return false; if(rule.COUNT&&(!/^\d+$/.test(rule.COUNT)||Number(rule.COUNT)<1))return false; if(rule.INTERVAL&&(!/^\d+$/.test(rule.INTERVAL)||Number(rule.INTERVAL)<1))return false; if(rule.COUNT&&rule.UNTIL)return false; if(rule.UNTIL&&instant(rule.UNTIL)==null&&dateInstant(rule.UNTIL)==null)return false; return !rule.BYDAY||rule.BYDAY.split(',').every(day=>/^(MO|TU|WE|TH|FR|SA|SU)$/.test(day)); } function occurrences(event,rangeEnd) { const duration=event.end-event.start,excluded=new Set(event.exdates||[]); if(!event.rule) { return [event.start,...(event.rdates||[])].filter((start,index,all)=>start({start,end:start+duration})); } const rule=recurrenceRule(event.rule); if(!['DAILY','WEEKLY'].includes(rule.FREQ))return [event]; const count=Math.max(1,Math.min(Number(rule.COUNT)||10000,10000)); const result=[]; const weekdays={SU:0,MO:1,TU:2,WE:3,TH:4,FR:5,SA:6}; const selected=new Set((rule.BYDAY||'').split(',').map(day=>weekdays[day]).filter(day=>day!=null)); if(rule.FREQ==='WEEKLY'&&!selected.size)selected.add(new Date(event.start).getDay()); const interval=Math.max(1,Number(rule.INTERVAL)||1),origin=new Date(event.start); const until=rule.UNTIL?(instant(rule.UNTIL)??dateInstant(rule.UNTIL)):null; const originDay=Date.UTC(origin.getFullYear(),origin.getMonth(),origin.getDate()); let start=event.start,matched=0; while(start{ if(extraitem.start===extra))result.push({start:extra,end:extra+duration}); }); return result; } function dayBoundary(date,minutes) { const [year,month,day]=date.split('-').map(Number); return new Date(year,month-1,day,Math.floor(minutes/60),minutes%60).getTime(); } function review(source,{dates,workdayStart='09:00',workdayEnd='17:00'}={}) { if(!Array.isArray(dates)||dates.length!==7)throw new Error('Week Ahead must contain seven dates.'); const startMinute=clock(workdayStart),endMinute=clock(workdayEnd); if(startMinute==null||endMinute==null||endMinute<=startMinute)throw new Error('Working hours must end after they start.'); const parsedEvents=events(source),rangeStart=dayBoundary(dates[0],0),rangeEnd=dayBoundary(dates[6],24*60); const unsupported_count=parsedEvents.filter(event=>event.rule&&!supportedRecurrence(event.rule)&& event.startrangeStart||event.rule)).length; const calendarEvents=parsedEvents.flatMap(event=>occurrences(event,rangeEnd)); const days=dates.map(plan_date=>{ const start=dayBoundary(plan_date,startMinute),end=dayBoundary(plan_date,endMinute); const ranges=calendarEvents.map(event=>[Math.max(start,event.start),Math.min(end,event.end)]) .filter(range=>range[1]>range[0]).sort((left,right)=>left[0]-right[0]); const merged=[]; ranges.forEach(range=>{ const previous=merged[merged.length-1]; if(previous&&range[0]<=previous[1])previous[1]=Math.max(previous[1],range[1]); else merged.push([...range]); }); const busy_minutes=Math.round(merged.reduce((total,range)=>total+range[1]-range[0],0)/60000); return {plan_date,busy_minutes,capacity_minutes:endMinute-startMinute-busy_minutes}; }); days.unsupported_count=unsupported_count; return days; } function createWorkflow({controller,qs,onApplied=()=>{}}={}) { let reviewed=null; const root=()=>qs('#week-capacity-import'); function clear() { reviewed=null; qs('#week-capacity-file').value=''; qs('#week-capacity-review').hidden=true; qs('#week-capacity-days').innerHTML=''; qs('#apply-week-capacities').disabled=false; } function open() { clear();root().hidden=false;qs('#week-capacity-status').textContent=''; qs('#week-capacity-file').focus?.();return true; } function cancel() {clear();root().hidden=true;return true;} function reviewSource(source) { const display=controller.dates(); reviewed=review(source,{dates:display.map(item=>item.date),workdayStart:qs('#week-capacity-start').value, workdayEnd:qs('#week-capacity-end').value}); qs('#week-capacity-days').innerHTML=reviewed.map((day,index)=>'
'+ display[index].label+''+day.capacity_minutes+' min available'+day.busy_minutes+ ' min busy during working hours
').join(''); qs('#week-capacity-review').hidden=false; const unsupported=reviewed.unsupported_count||0; qs('#apply-week-capacities').disabled=unsupported>0; qs('#week-capacity-status').textContent=unsupported? unsupported+' recurring event'+(unsupported===1?'':'s')+' could not be counted. Apply is unavailable; export a simpler seven-day calendar and try again.': 'Review seven capacity totals. Calendar details stay on this device.'; return reviewed.map(day=>({...day})); } async function apply() { if(!reviewed||reviewed.unsupported_count||!controller.stageCapacities(reviewed))return false; qs('#apply-week-capacities').disabled=true; try {await controller.flush();cancel();onApplied();return true;} finally {qs('#apply-week-capacities').disabled=false;} } qs('#week-capacity-file')?.addEventListener('change',async event=>{ const file=event.currentTarget.files?.[0];if(!file)return; try { if(file.size>maxBytes)throw new Error('Calendar files must be 1 MB or smaller.'); reviewSource(await file.text()); } catch(error) {clear();qs('#week-capacity-status').textContent=error.message||'Calendar could not be read.';} }); qs('#cancel-week-capacity-import')?.addEventListener('click',cancel); qs('#apply-week-capacities')?.addEventListener('click',()=>apply().catch(error=>{ qs('#week-capacity-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Capacities remain saved on this phone.'; })); return {open,cancel,review:reviewSource,apply,state:()=>reviewed?reviewed.map(day=>({...day})):null}; } function mount(controller,weekWorkflow,qs) { const workflow=createWorkflow({controller,qs,onApplied:weekWorkflow.renderReview}); qs('#open-week-capacity-import').addEventListener('click',workflow.open); return workflow; } return {review,createWorkflow,mount}; } const weekCalendarImport=createWeekCalendarImport(); if(typeof module!=='undefined'&&module.exports)module.exports=weekCalendarImport;