Make Week Ahead calendar import timezone-correct and range-bounded #1213

Merged
timmy merged 1 commits from timmy/1212-week-calendar-timezone into main 2026-08-21 06:57:17 +00:00
4 changed files with 198 additions and 27 deletions

View File

@ -175,11 +175,13 @@ week revision. Confirmation remains disabled while duplicates exist or the accou
Rapid saves and review moves use one network flight plus a coalesced latest-state delivery, so later staged days Rapid saves and review moves use one network flight plus a coalesced latest-state delivery, so later staged days
are not stranded behind an earlier request. From the same review, **Set capacity from calendar** reads a local are not stranded behind an earlier request. From the same review, **Set capacity from calendar** reads a local
`.ics` file on-device, unions overlapping busy periods within chosen working hours, and previews seven daily `.ics` file on-device, unions overlapping busy periods within chosen working hours, and previews seven daily
availability totals before one atomic apply. Daily and weekly recurrence (`COUNT`, `UNTIL`, `INTERVAL`, and availability totals before one atomic apply. IANA `TZID` values from Google and Outlook calendars are converted
weekly `BYDAY`), `RDATE`/`EXDATE`, and all-day events are evaluated only for the seven review dates; cancelled to the device's local workday, including daylight-saving transitions. Daily and weekly recurrence (`COUNT`,
and transparent events do not consume capacity. If a recurring event that could affect the week uses another `UNTIL`, `INTERVAL`, and weekly `BYDAY`), `RDATE`/`EXDATE`, and all-day events are evaluated with work bounded
frequency or unsupported rule part, the review reports only an affected-event count and disables apply rather to the seven review dates; cancelled and transparent events do not consume capacity. If a time zone is unknown,
than understating busy time. Export a simpler seven-day calendar to proceed. Raw calendar data and event or a recurring event that could affect the week uses another frequency or unsupported rule part, the review
reports only an affected-event count and disables apply rather than understating busy time. Export a simpler
seven-day calendar to proceed. Raw calendar data and event
metadata are never persisted, rendered in diagnostics, or sent; only the reviewed capacity-minute totals use metadata are never persisted, rendered in diagnostics, or sent; only the reviewed capacity-minute totals use
the existing encrypted, account-bound Week Ahead sync. the existing encrypted, account-bound Week Ahead sync.
Planning edits can remain offline for up to 30 days. After that, the Planning edits can remain offline for up to 30 days. After that, the

View File

@ -14,6 +14,46 @@ function createWeekCalendarImport() {
new Date(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]).getTime(); new Date(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]).getTime();
return Number.isFinite(milliseconds)?milliseconds:null; 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),formatter=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',
});
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;
try {new Intl.DateTimeFormat('en-US',{timeZone}).format(0);return true;}
catch(error) {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()};
const formatter=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 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) { function dateInstant(value) {
const match=/^(\d{4})(\d{2})(\d{2})$/.exec(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; return match?new Date(Number(match[1]),Number(match[2])-1,Number(match[3])).getTime():null;
@ -26,18 +66,21 @@ function createWeekCalendarImport() {
lines.forEach(line=>{ lines.forEach(line=>{
if(line==='BEGIN:VEVENT'){current={};return;} if(line==='BEGIN:VEVENT'){current={};return;}
if(line==='END:VEVENT'){ if(line==='END:VEVENT'){
if(current?.start!=null&&current?.end>current.start&&current.status!=='CANCELLED'&&current.transparency!=='TRANSPARENT')result.push(current); if((current?.unsupportedTimezone||(current?.start!=null&&current?.end>current.start))&&
current.status!=='CANCELLED'&&current.transparency!=='TRANSPARENT')result.push(current);
current=null;return; current=null;return;
} }
if(!current)return; if(!current)return;
const separator=line.indexOf(':');if(separator<0)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 property=line.slice(0,separator),parts=property.split(';'),name=parts[0],value=line.slice(separator+1);
const allDay=property.split(';').slice(1).includes('VALUE=DATE'); const parameters=Object.fromEntries(parts.slice(1).map(part=>part.split('=')));
if(name==='DTSTART')current.start=allDay?dateInstant(value):instant(value); if(parameters.TZID&&!supportedTimeZone(parameters.TZID))current.unsupportedTimezone=true;
if(name==='DTEND')current.end=allDay?dateInstant(value):instant(value); 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==='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==='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):instant(item)).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==='STATUS')current.status=value.toUpperCase();
if(name==='TRANSP')current.transparency=value.toUpperCase(); if(name==='TRANSP')current.transparency=value.toUpperCase();
}); });
@ -57,7 +100,7 @@ function createWeekCalendarImport() {
if(rule.UNTIL&&instant(rule.UNTIL)==null&&dateInstant(rule.UNTIL)==null)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)); return !rule.BYDAY||rule.BYDAY.split(',').every(day=>/^(MO|TU|WE|TH|FR|SA|SU)$/.test(day));
} }
function occurrences(event,rangeEnd) { function occurrences(event,rangeStart,rangeEnd) {
const duration=event.end-event.start,excluded=new Set(event.exdates||[]); const duration=event.end-event.start,excluded=new Set(event.exdates||[]);
if(!event.rule) { if(!event.rule) {
return [event.start,...(event.rdates||[])].filter((start,index,all)=>start<rangeEnd&&!excluded.has(start)&&all.indexOf(start)===index) return [event.start,...(event.rdates||[])].filter((start,index,all)=>start<rangeEnd&&!excluded.has(start)&&all.indexOf(start)===index)
@ -69,20 +112,43 @@ function createWeekCalendarImport() {
const result=[]; const result=[];
const weekdays={SU:0,MO:1,TU:2,WE:3,TH:4,FR:5,SA:6}; 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)); 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()); if(rule.FREQ==='WEEKLY'&&!selected.size)selected.add(zoneParts(event.start,event.timeZone).weekday);
const interval=Math.max(1,Number(rule.INTERVAL)||1),origin=new Date(event.start); const interval=Math.max(1,Number(rule.INTERVAL)||1),origin=zoneParts(event.start,event.timeZone);
const until=rule.UNTIL?(instant(rule.UNTIL)??dateInstant(rule.UNTIL)):null; const until=rule.UNTIL?(rule.UNTIL.endsWith('Z')?instant(rule.UNTIL):
const originDay=Date.UTC(origin.getFullYear(),origin.getMonth(),origin.getDate()); (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; 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<count) {start=addCalendarDays(start,interval,event.timeZone);matched+=1;}
}
if(rule.FREQ==='WEEKLY'&&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 cycleDays=7*interval,cycles=Math.floor(elapsed/cycleDays),offset=elapsed-cycles*cycleDays;
matched=cycles*selected.size;
start=addCalendarDays(event.start,cycles*cycleDays+(offset>=7?offset:0),event.timeZone);
while(start+duration<=rangeStart&&matched<count) {
const cursor=zoneParts(start,event.timeZone),cursorDay=Date.UTC(cursor.year,cursor.month-1,cursor.day);
const weeks=Math.floor((cursorDay-originDay)/(7*24*60*60*1000));
if(weeks%interval===0&&selected.has(cursor.weekday))matched+=1;
start=addCalendarDays(start,1,event.timeZone);
}
}
while(start<rangeEnd&&matched<count&&(until==null||start<=until)) { while(start<rangeEnd&&matched<count&&(until==null||start<=until)) {
const cursor=new Date(start),cursorDay=Date.UTC(cursor.getFullYear(),cursor.getMonth(),cursor.getDate()); const cursor=zoneParts(start,event.timeZone),cursorDay=Date.UTC(cursor.year,cursor.month-1,cursor.day);
const weeks=Math.floor((cursorDay-originDay)/(7*24*60*60*1000)); const weeks=Math.floor((cursorDay-originDay)/(7*24*60*60*1000));
const matches=rule.FREQ==='DAILY'||(weeks%interval===0&&selected.has(cursor.getDay())); const matches=rule.FREQ==='DAILY'||(weeks%interval===0&&selected.has(cursor.weekday));
if(matches) { if(matches) {
matched+=1; matched+=1;
if(!excluded.has(start))result.push({start,end:start+duration}); if(!excluded.has(start))result.push({start,end:start+duration});
} }
cursor.setDate(cursor.getDate()+(rule.FREQ==='DAILY'?interval:1));start=cursor.getTime(); start=addCalendarDays(start,rule.FREQ==='DAILY'?interval:1,event.timeZone);
} }
(event.rdates||[]).forEach(extra=>{ (event.rdates||[]).forEach(extra=>{
if(extra<rangeEnd&&!excluded.has(extra)&&!result.some(item=>item.start===extra))result.push({start:extra,end:extra+duration}); if(extra<rangeEnd&&!excluded.has(extra)&&!result.some(item=>item.start===extra))result.push({start:extra,end:extra+duration});
@ -102,9 +168,11 @@ function createWeekCalendarImport() {
const startMinute=clock(workdayStart),endMinute=clock(workdayEnd); const startMinute=clock(workdayStart),endMinute=clock(workdayEnd);
if(startMinute==null||endMinute==null||endMinute<=startMinute)throw new Error('Working hours must end after they start.'); 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 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)&& const unsupportedRecurrence=parsedEvents.filter(event=>event.rule&&!supportedRecurrence(event.rule)&&
event.start<rangeEnd&&(event.end>rangeStart||event.rule)).length; event.start<rangeEnd&&(event.end>rangeStart||event.rule)).length;
const calendarEvents=parsedEvents.flatMap(event=>occurrences(event,rangeEnd)); 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 days=dates.map(plan_date=>{
const start=dayBoundary(plan_date,startMinute),end=dayBoundary(plan_date,endMinute); 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)]) const ranges=calendarEvents.map(event=>[Math.max(start,event.start),Math.min(end,event.end)])
@ -125,6 +193,7 @@ function createWeekCalendarImport() {
return {plan_date,busy_minutes,capacity_minutes:endMinute-startMinute-busy_minutes,free_windows}; return {plan_date,busy_minutes,capacity_minutes:endMinute-startMinute-busy_minutes,free_windows};
}); });
days.unsupported_count=unsupported_count; days.unsupported_count=unsupported_count;
days.unsupported_timezone_count=unsupportedTimezone;
return days; return days;
} }
function createWorkflow({controller,qs,onApplied=()=>{}}={}) { function createWorkflow({controller,qs,onApplied=()=>{}}={}) {
@ -152,8 +221,9 @@ function createWeekCalendarImport() {
qs('#week-capacity-review').hidden=false; qs('#week-capacity-review').hidden=false;
const unsupported=reviewed.unsupported_count||0; const unsupported=reviewed.unsupported_count||0;
qs('#apply-week-capacities').disabled=unsupported>0; qs('#apply-week-capacities').disabled=unsupported>0;
const kind=reviewed.unsupported_timezone_count?'calendar event':'recurring event';
qs('#week-capacity-status').textContent=unsupported? 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.': unsupported+' '+kind+(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.'; 'Review seven capacity totals. Calendar details stay on this device.';
return reviewed.map(day=>({...day})); return reviewed.map(day=>({...day}));
} }

View File

@ -30,7 +30,7 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
try: try:
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright: with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"]) browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
page = browser.new_page(viewport={"width": width, "height": height}) page = browser.new_page(viewport={"width": width, "height": height}, timezone_id="UTC")
page_errors: list[str] = [] page_errors: list[str] = []
page.on("pageerror", lambda error: page_errors.append(str(error))) page.on("pageerror", lambda error: page_errors.append(str(error)))
@ -107,9 +107,10 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
added = (date.today() + timedelta(days=4)).strftime("%Y%m%d") added = (date.today() + timedelta(days=4)).strftime("%Y%m%d")
calendar = ( calendar = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n" "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
f"SUMMARY:{private_title}\r\nDTSTART:{tomorrow}T090000\r\n" f"SUMMARY:{private_title}\r\nDTSTART;TZID=America/New_York:{tomorrow}T050000\r\n"
f"DTEND:{tomorrow}T100000\r\nRRULE:FREQ=DAILY;COUNT=3\r\n" f"DTEND;TZID=America/New_York:{tomorrow}T060000\r\nRRULE:FREQ=DAILY;COUNT=3\r\n"
f"EXDATE:{excluded}T090000\r\nRDATE:{added}T090000\r\nEND:VEVENT\r\nEND:VCALENDAR" f"EXDATE;TZID=America/New_York:{excluded}T050000\r\n"
f"RDATE;TZID=America/New_York:{added}T050000\r\nEND:VEVENT\r\nEND:VCALENDAR"
) )
unsupported_title = "Private monthly board review" unsupported_title = "Private monthly board review"
unsupported_calendar = ( unsupported_calendar = (

View File

@ -42,6 +42,104 @@ console.log(JSON.stringify({days,serialized:JSON.stringify(days)}));
assert private_value not in result["serialized"] assert private_value not in result["serialized"]
def test_calendar_import_converts_iana_tzid_into_the_device_local_workday():
result = run_import("""
process.env.TZ='America/Los_Angeles';
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20260821T120000\r\nDTEND;TZID=America/New_York:20260821T130000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
console.log(JSON.stringify({first:days[0]}));
""")
assert result["first"]["busy_minutes"] == 60
assert result["first"]["free_windows"] == [
{"start_time": "10:00", "end_time": "17:00"},
]
def test_calendar_import_fails_closed_for_unknown_tzid_without_exposing_event_details():
result = run_import("""
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nSUMMARY:Private acquisition\r\nATTENDEE:mailto:secret@example.com\r\nDTSTART;TZID=Private/Boardroom:20260821T120000\r\nDTEND;TZID=Private/Boardroom:20260821T130000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
const days=calendarImport.review(source,{dates});
console.log(JSON.stringify({unsupported:days.unsupported_count,busy:days.map(day=>day.busy_minutes),serialized:JSON.stringify(days)}));
""")
assert result["unsupported"] == 1
assert result["busy"] == [0] * 7
assert "Private acquisition" not in result["serialized"]
assert "secret@example.com" not in result["serialized"]
def test_calendar_import_applies_tzid_to_rdate_and_exdate_values():
result = run_import("""
process.env.TZ='America/Los_Angeles';
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20260821T120000\r\nDTEND;TZID=America/New_York:20260821T130000\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEXDATE;TZID=America/New_York:20260822T120000\r\nRDATE;TZID=America/New_York:20260824T120000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
const days=calendarImport.review(source,{dates});
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes),fourth:days[3].free_windows}));
""")
assert result["busy"] == [60, 0, 60, 60, 0, 0, 0]
assert result["fourth"] == [{"start_time": "10:00", "end_time": "17:00"}]
def test_calendar_import_keeps_zoned_daily_recurrence_at_wall_time_across_dst():
result = run_import("""
process.env.TZ='UTC';
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20260307T090000\r\nDTEND;TZID=America/New_York:20260307T100000\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEND:VEVENT\r\nEND:VCALENDAR`;
const dates=['2026-03-07','2026-03-08','2026-03-09','2026-03-10','2026-03-11','2026-03-12','2026-03-13'];
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
console.log(JSON.stringify({windows:days.slice(0,3).map(day=>day.free_windows)}));
""")
assert result["windows"] == [
[{"start_time": "09:00", "end_time": "14:00"}, {"start_time": "15:00", "end_time": "17:00"}],
[{"start_time": "09:00", "end_time": "13:00"}, {"start_time": "14:00", "end_time": "17:00"}],
[{"start_time": "09:00", "end_time": "13:00"}, {"start_time": "14:00", "end_time": "17:00"}],
]
def test_calendar_import_interprets_floating_until_in_the_event_timezone():
result = run_import("""
process.env.TZ='UTC';
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20260307T090000\r\nDTEND;TZID=America/New_York:20260307T100000\r\nRRULE:FREQ=DAILY;UNTIL=20260308T090000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
const dates=['2026-03-07','2026-03-08','2026-03-09','2026-03-10','2026-03-11','2026-03-12','2026-03-13'];
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
""")
assert result["busy"] == [60, 60, 0, 0, 0, 0, 0]
def test_calendar_import_fast_forwards_historical_daily_series_to_seven_day_range():
result = run_import("""
process.env.TZ='UTC';
const event=`BEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20000101T090000\r\nDTEND;TZID=America/New_York:20000101T100000\r\nRRULE:FREQ=DAILY\r\nEND:VEVENT\r\n`;
const source='BEGIN:VCALENDAR\\r\\nVERSION:2.0\\r\\n'+event.repeat(80)+'END:VCALENDAR';
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
const started=Date.now();const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
console.log(JSON.stringify({elapsed:Date.now()-started,busy:days.map(day=>day.busy_minutes)}));
""")
assert result["busy"] == [60] * 7
assert result["elapsed"] < 1000
def test_calendar_import_fast_forwards_historical_weekly_series_to_seven_day_range():
result = run_import("""
process.env.TZ='UTC';
const event=`BEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20000101T090000\r\nDTEND;TZID=America/New_York:20000101T100000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2\r\nEND:VEVENT\r\n`;
const source='BEGIN:VCALENDAR\\r\\nVERSION:2.0\\r\\n'+event.repeat(80)+'END:VCALENDAR';
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
const started=Date.now();const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
console.log(JSON.stringify({elapsed:Date.now()-started,busy:days.map(day=>day.busy_minutes)}));
""")
assert result["busy"] == [0, 0, 0, 60, 0, 60, 0]
assert result["elapsed"] < 1000
def test_calendar_import_expands_bounded_daily_recurrence_and_honors_exdate(): def test_calendar_import_expands_bounded_daily_recurrence_and_honors_exdate():
result = run_import(""" result = run_import("""
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:standup\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRRULE:FREQ=DAILY;COUNT=5\r\nEXDATE:20260823T100000\r\nEND:VEVENT\r\nEND:VCALENDAR`; const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:standup\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRRULE:FREQ=DAILY;COUNT=5\r\nEXDATE:20260823T100000\r\nEND:VEVENT\r\nEND:VCALENDAR`;