112 lines
5.7 KiB
JavaScript
112 lines
5.7 KiB
JavaScript
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 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)result.push(current);
|
|
current=null;return;
|
|
}
|
|
if(!current)return;
|
|
const separator=line.indexOf(':');if(separator<0)return;
|
|
const name=line.slice(0,separator).split(';')[0],value=line.slice(separator+1);
|
|
if(name==='DTSTART')current.start=instant(value);
|
|
if(name==='DTEND')current.end=instant(value);
|
|
});
|
|
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 calendarEvents=events(source);
|
|
return 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};
|
|
});
|
|
}
|
|
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='';
|
|
}
|
|
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)=>'<article class="week-capacity-day"><strong>'+
|
|
display[index].label+'</strong><span>'+day.capacity_minutes+' min available</span><small>'+day.busy_minutes+
|
|
' min busy during working hours</small></article>').join('');
|
|
qs('#week-capacity-review').hidden=false;
|
|
qs('#week-capacity-status').textContent='Review seven capacity totals. Calendar details stay on this device.';
|
|
return reviewed.map(day=>({...day}));
|
|
}
|
|
async function apply() {
|
|
if(!reviewed||!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;
|