feat: make calendar capacity recurrence-aware (Closes #1208)
CI / lint (pull_request) Successful in 4m1s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 5m15s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-21 04:27:11 +00:00
1 parent 359d4337eb
commit f1adffef6b
4 files changed
+195 -11

No files matched your search

+74 -8
View File
@@ -14,6 +14,10 @@ function createWeekCalendarImport() {
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.');
@@ -22,14 +26,66 @@ function createWeekCalendarImport() {
lines.forEach(line=>{
if(line==='BEGIN:VEVENT'){current={};return;}
if(line==='END:VEVENT'){
if(current?.start!=null&&current?.end>current.start)result.push(current);
if(current?.start!=null&&current?.end>current.start&&current.status!=='CANCELLED'&&current.transparency!=='TRANSPARENT')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);
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<rangeEnd&&!excluded.has(start)&&all.indexOf(start)===index)
.map(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<rangeEnd&&matched<count&&(until==null||start<=until)) {
const cursor=new Date(start),cursorDay=Date.UTC(cursor.getFullYear(),cursor.getMonth(),cursor.getDate());
const weeks=Math.floor((cursorDay-originDay)/(7*24*60*60*1000));
const matches=rule.FREQ==='DAILY'||(weeks%interval===0&&selected.has(cursor.getDay()));
if(matches) {
matched+=1;
if(!excluded.has(start))result.push({start,end:start+duration});
}
cursor.setDate(cursor.getDate()+(rule.FREQ==='DAILY'?interval:1));start=cursor.getTime();
}
(event.rdates||[]).forEach(extra=>{
if(extra<rangeEnd&&!excluded.has(extra)&&!result.some(item=>item.start===extra))result.push({start:extra,end:extra+duration});
});
return result;
}
@@ -41,8 +97,11 @@ function createWeekCalendarImport() {
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 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.start<rangeEnd&&(event.end>rangeStart||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]);
@@ -55,6 +114,8 @@ function createWeekCalendarImport() {
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;
@@ -64,6 +125,7 @@ function createWeekCalendarImport() {
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='';
@@ -78,11 +140,15 @@ function createWeekCalendarImport() {
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.';
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||!controller.stageCapacities(reviewed))return false;
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;}