diff --git a/README.md b/README.md index d8960f5..d9e15fc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/frontend/week-calendar-import.js b/frontend/week-calendar-import.js index 14ac0a2..05a8373 100644 --- a/frontend/week-calendar-import.js +++ b/frontend/week-calendar-import.js @@ -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&¤t?.end>current.start&¤t.status!=='CANCELLED'&¤t.transparency!=='TRANSPARENT')result.push(current); + 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),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)=>startweekdays[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=7?offset:0),event.timeZone); + while(start+duration<=rangeStart&&matched{ if(extraitem.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.startrangeStart||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})); } diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py index 7a8fce5..0762620 100644 --- a/tests/e2e/test_mobile_week_ahead_release.py +++ b/tests/e2e/test_mobile_week_ahead_release.py @@ -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 = ( diff --git a/tests/test_week_calendar_import.py b/tests/test_week_calendar_import.py index c3af71c..b375a60 100644 --- a/tests/test_week_calendar_import.py +++ b/tests/test_week_calendar_import.py @@ -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`;