Merge pull request 'Make Week Ahead calendar import timezone-correct and range-bounded' (#1213) from timmy/1212-week-calendar-timezone into main
All checks were successful
CI / lint (push) Successful in 3m30s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m2s
CI / release-candidate (push) Successful in 9s

This commit is contained in:
timmy 2026-08-21 06:57:16 +00:00
commit 4d9c936b86
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
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
availability totals before one atomic apply. Daily and weekly recurrence (`COUNT`, `UNTIL`, `INTERVAL`, and
weekly `BYDAY`), `RDATE`/`EXDATE`, and all-day events are evaluated only for the seven review dates; cancelled
and transparent events do not consume capacity. If 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
availability totals before one atomic apply. IANA `TZID` values from Google and Outlook calendars are converted
to the device's local workday, including daylight-saving transitions. Daily and weekly recurrence (`COUNT`,
`UNTIL`, `INTERVAL`, and weekly `BYDAY`), `RDATE`/`EXDATE`, and all-day events are evaluated with work bounded
to the seven review dates; cancelled and transparent events do not consume capacity. If a time zone is unknown,
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
the existing encrypted, account-bound Week Ahead sync.
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();
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) {
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;
@ -26,18 +66,21 @@ function createWeekCalendarImport() {
lines.forEach(line=>{
if(line==='BEGIN:VEVENT'){current={};return;}
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;
}
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);
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):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==='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();
});
@ -57,7 +100,7 @@ function createWeekCalendarImport() {
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) {
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<rangeEnd&&!excluded.has(start)&&all.indexOf(start)===index)
@ -69,20 +112,43 @@ function createWeekCalendarImport() {
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());
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<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)) {
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 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) {
matched+=1;
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=>{
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);
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)&&
const unsupportedRecurrence=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 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)])
@ -125,6 +193,7 @@ function createWeekCalendarImport() {
return {plan_date,busy_minutes,capacity_minutes:endMinute-startMinute-busy_minutes,free_windows};
});
days.unsupported_count=unsupported_count;
days.unsupported_timezone_count=unsupportedTimezone;
return days;
}
function createWorkflow({controller,qs,onApplied=()=>{}}={}) {
@ -152,8 +221,9 @@ function createWeekCalendarImport() {
qs('#week-capacity-review').hidden=false;
const unsupported=reviewed.unsupported_count||0;
qs('#apply-week-capacities').disabled=unsupported>0;
const kind=reviewed.unsupported_timezone_count?'calendar event':'recurring event';
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.';
return reviewed.map(day=>({...day}));
}

View File

@ -30,7 +30,7 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
try:
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"])
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.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")
calendar = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
f"SUMMARY:{private_title}\r\nDTSTART:{tomorrow}T090000\r\n"
f"DTEND:{tomorrow}T100000\r\nRRULE:FREQ=DAILY;COUNT=3\r\n"
f"EXDATE:{excluded}T090000\r\nRDATE:{added}T090000\r\nEND:VEVENT\r\nEND:VCALENDAR"
f"SUMMARY:{private_title}\r\nDTSTART;TZID=America/New_York:{tomorrow}T050000\r\n"
f"DTEND;TZID=America/New_York:{tomorrow}T060000\r\nRRULE:FREQ=DAILY;COUNT=3\r\n"
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_calendar = (

View File

@ -42,6 +42,104 @@ console.log(JSON.stringify({days,serialized:JSON.stringify(days)}));
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():
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`;