function createWeekCalendarImport() { const maxBytes=1024*1024; const zonedInstantFormatters=new Map(),zonePartFormatters=new Map(),supportedTimeZones=new Map(); 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 zonedInstant(value,timeZone) { if(!timeZone)return instant(value); const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/.exec(value||''); if(!match)return null; try { const wanted=match.slice(1).map(Number); if(!zonedInstantFormatters.has(timeZone))zonedInstantFormatters.set(timeZone,new Intl.DateTimeFormat('en-CA',{ timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23', })); const formatter=zonedInstantFormatters.get(timeZone); let result=Date.UTC(wanted[0],wanted[1]-1,wanted[2],wanted[3],wanted[4],wanted[5]); for(let attempt=0;attempt<2;attempt+=1) { const shown=Object.fromEntries(formatter.formatToParts(new Date(result)).map(part=>[part.type,part.value])); const represented=Date.UTC(Number(shown.year),Number(shown.month)-1,Number(shown.day),Number(shown.hour),Number(shown.minute),Number(shown.second)); result+=Date.UTC(wanted[0],wanted[1]-1,wanted[2],wanted[3],wanted[4],wanted[5])-represented; } return result; } catch(error) { return null; } } function supportedTimeZone(timeZone) { if(!timeZone)return true; if(supportedTimeZones.has(timeZone))return supportedTimeZones.get(timeZone); try {new Intl.DateTimeFormat('en-US',{timeZone}).format(0);supportedTimeZones.set(timeZone,true);return true;} catch(error) {supportedTimeZones.set(timeZone,false);return false;} } function zoneParts(value,timeZone) { const date=new Date(value); if(!timeZone)return {year:date.getFullYear(),month:date.getMonth()+1,day:date.getDate(),weekday:date.getDay(), hour:date.getHours(),minute:date.getMinutes(),second:date.getSeconds()}; if(!zonePartFormatters.has(timeZone))zonePartFormatters.set(timeZone,new Intl.DateTimeFormat('en-US',{ timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',weekday:'short',hourCycle:'h23', })); const formatter=zonePartFormatters.get(timeZone); const parts=Object.fromEntries(formatter.formatToParts(date).map(part=>[part.type,part.value])); return {year:Number(parts.year),month:Number(parts.month),day:Number(parts.day), weekday:['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].indexOf(parts.weekday),hour:Number(parts.hour), minute:Number(parts.minute),second:Number(parts.second)}; } function addCalendarDays(value,days,timeZone) { if(!timeZone) {const date=new Date(value);date.setDate(date.getDate()+days);return date.getTime();} const parts=zoneParts(value,timeZone),day=new Date(Date.UTC(parts.year,parts.month-1,parts.day+days)); const wall=[day.getUTCFullYear(),String(day.getUTCMonth()+1).padStart(2,'0'),String(day.getUTCDate()).padStart(2,'0')].join('')+ 'T'+[parts.hour,parts.minute,parts.second].map(part=>String(part).padStart(2,'0')).join(''); return zonedInstant(wall,timeZone); } 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?.unsupportedTimezone||(current?.start!=null&¤t?.end>current.start))&& current.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),parts=property.split(';'),name=parts[0],value=line.slice(separator+1); const parameters=Object.fromEntries(parts.slice(1).map(part=>part.split('='))); if(parameters.TZID&&!supportedTimeZone(parameters.TZID))current.unsupportedTimezone=true; const allDay=parameters.VALUE==='DATE',parsed=allDay?dateInstant(value):zonedInstant(value,parameters.TZID); if(name==='DTSTART'){current.start=parsed;current.timeZone=parameters.TZID||null;} if(name==='DTEND')current.end=parsed; if(name==='RRULE')current.rule=value; if(name==='RDATE')current.rdates=(current.rdates||[]).concat(value.split(',').map(item=>allDay?dateInstant(item):zonedInstant(item,parameters.TZID)).filter(value=>value!=null)); if(name==='EXDATE')current.exdates=(current.exdates||[]).concat(value.split(',').map(item=>allDay?dateInstant(item):zonedInstant(item,parameters.TZID)).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,rangeStart,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(zoneParts(event.start,event.timeZone).weekday); const interval=Math.max(1,Number(rule.INTERVAL)||1),origin=zoneParts(event.start,event.timeZone); const until=rule.UNTIL?(rule.UNTIL.endsWith('Z')?instant(rule.UNTIL): (zonedInstant(rule.UNTIL,event.timeZone)??dateInstant(rule.UNTIL))):null; const originDay=Date.UTC(origin.year,origin.month-1,origin.day); let start=event.start,matched=0; if(rule.FREQ==='DAILY'&&start+duration<=rangeStart) { const target=zoneParts(rangeStart-duration,event.timeZone); const targetDay=Date.UTC(target.year,target.month-1,target.day); const elapsed=Math.max(0,Math.floor((targetDay-originDay)/(24*60*60*1000))); const jumps=Math.floor(elapsed/interval); if(jumps) {start=addCalendarDays(start,jumps*interval,event.timeZone);matched=jumps;} while(start+duration<=rangeStart&&matched=7?offset:0),event.timeZone); while(start+duration<=rangeStart&&matched{ 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 localClock(value) { const date=new Date(value); return String(date.getHours()).padStart(2,'0')+':'+String(date.getMinutes()).padStart(2,'0'); } 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 unsupportedRecurrence=parsedEvents.filter(event=>event.rule&&!supportedRecurrence(event.rule)&& event.startrangeStart||event.rule)).length; const unsupportedTimezone=parsedEvents.filter(event=>event.unsupportedTimezone).length; const unsupported_count=unsupportedRecurrence+unsupportedTimezone; const calendarEvents=parsedEvents.filter(event=>!event.unsupportedTimezone).flatMap(event=>occurrences(event,rangeStart,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); const free_windows=[];let cursor=start; merged.forEach(range=>{ if(range[0]>cursor)free_windows.push({start_time:localClock(cursor),end_time:localClock(range[0])}); cursor=Math.max(cursor,range[1]); }); if(cursor{}}={}) { let reviewed=null,activeAvailability=null,needsReflow=false,reflowPreview=null; const root=()=>qs('#week-capacity-import'); function clear() { reviewed=null;needsReflow=false;reflowPreview=null; qs('#week-capacity-file').value=''; qs('#week-capacity-review').hidden=true; qs('#week-capacity-days').innerHTML=''; qs('#apply-week-capacities').disabled=false; qs('#apply-week-capacities').textContent='Apply seven capacities'; } 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}); const current=controller.review?.().days||[]; needsReflow=reviewed.some((day,index)=>Number(current[index]?.planned_minutes)>day.capacity_minutes); const previewValues=reviewed.map(day=>({plan_date:day.plan_date,capacity_minutes:day.capacity_minutes, free_windows:day.free_windows.map(window=>({...window}))})); reflowPreview=needsReflow?controller.previewCapacityReflow?.(previewValues):null; const destinations=new Map(); (reflowPreview?.days||[]).forEach(day=>(day.ids||[]).forEach(id=>destinations.set(id,day.plan_date))); qs('#week-capacity-days').innerHTML=reviewed.map((day,index)=>{ const previous=current[index]||{},planned=Number(previous.planned_minutes)||0,over=Math.max(0,planned-day.capacity_minutes); const moved=[...new Set((previous.ids||[]).map(id=>destinations.get(id)).filter(date=>date&&date!==day.plan_date))] .map(date=>display.find(item=>item.date===date)?.label||date); return '
'+display[index].label+''+ (Number.isInteger(previous.capacity_minutes)?previous.capacity_minutes+' → ':'')+day.capacity_minutes+' min available'+ planned+' min planned · '+day.busy_minutes+' min busy'+(over?''+over+ ' min over refreshed capacity':'')+(moved.length?'Moves to '+moved.join(', ')+'':'')+'
'; }).join(''); qs('#week-capacity-review').hidden=false; const unsupported=reviewed.unsupported_count||0; const reflowBlocked=Boolean(reflowPreview?.blockers?.length); qs('#apply-week-capacities').disabled=unsupported>0||reflowBlocked; qs('#apply-week-capacities').textContent=needsReflow?'Apply capacities & reflow':'Apply seven capacities'; const kind=reviewed.unsupported_timezone_count?'calendar event':'recurring event'; qs('#week-capacity-status').textContent=unsupported? unsupported+' '+kind+(unsupported===1?'':'s')+' could not be counted. Apply is unavailable; export a simpler seven-day calendar and try again.': (reflowBlocked?'Reflow needs an estimate for every planned item before refreshed capacity can be applied.': (reflowPreview?.unscheduled?.length?reflowPreview.unscheduled.length+' planned item'+ (reflowPreview.unscheduled.length===1?'':'s')+' will return to My Work. Review before applying.': 'Review seven capacity totals. Calendar details stay on this device.')); return reviewed.map(day=>({...day})); } async function apply() { if(!reviewed||reviewed.unsupported_count)return false; const keep=Boolean(qs('#keep-week-free-times')?.checked); const values=reviewed.map(day=>({ plan_date:day.plan_date,capacity_minutes:day.capacity_minutes, ...(keep?{free_windows:day.free_windows.map(window=>({...window}))}:{}), })); const staged=needsReflow?controller.applyCapacityReflow?.(values):controller.stageCapacities(values); if(!staged)return false; qs('#apply-week-capacities').disabled=true; try {await controller.flush();activeAvailability=reviewed.map(day=>({plan_date:day.plan_date, free_windows:day.free_windows.map(window=>({...window}))}));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.'; })); function availability() { const saved=(controller.state?.().days||[]).filter(day=>Array.isArray(day.free_windows)); const source=activeAvailability||saved; return source.length?source.map(day=>({plan_date:day.plan_date, free_windows:day.free_windows.map(window=>({...window}))})):null; } return {open,cancel,review:reviewSource,apply,state:()=>reviewed?reviewed.map(day=>({...day})):null, availability}; } function mount(controller,weekWorkflow,qs) { const workflow=createWorkflow({controller,qs,onApplied:weekWorkflow.renderReview}); StackchainWeekCalendar.setAvailabilityProvider(workflow.availability); 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;