diff --git a/README.md b/README.md index 4846453..d8960f5 100644 --- a/README.md +++ b/README.md @@ -175,8 +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. Raw calendar data and event metadata are never persisted or sent; -only the reviewed capacity-minute totals use the existing encrypted, account-bound Week Ahead sync. +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 +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 expired edit is discarded visibly and the account plan is kept rather than replaying stale intent. The server retains no more than 4,096 operation receipts per account and removes diff --git a/frontend/week-calendar-import.js b/frontend/week-calendar-import.js index 4fe7f2f..02cbfe1 100644 --- a/frontend/week-calendar-import.js +++ b/frontend/week-calendar-import.js @@ -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&¤t?.end>current.start)result.push(current); + if(current?.start!=null&¤t?.end>current.start&¤t.status!=='CANCELLED'&¤t.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({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{ + if(extraitem.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.startrangeStart||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+''+day.capacity_minutes+' min available'+day.busy_minutes+ ' min busy during working hours').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;} diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py index 9b63519..30a3082 100644 --- a/tests/e2e/test_mobile_week_ahead_release.py +++ b/tests/e2e/test_mobile_week_ahead_release.py @@ -103,10 +103,12 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( expect(capacity_import).to_be_visible() private_title = "Private customer planning" tomorrow = (date.today() + timedelta(days=1)).strftime("%Y%m%d") + excluded = (date.today() + timedelta(days=2)).strftime("%Y%m%d") calendar = ( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n" f"SUMMARY:{private_title}\r\nDTSTART:{tomorrow}T100000\r\n" - f"DTEND:{tomorrow}T110000\r\nEND:VEVENT\r\nEND:VCALENDAR" + f"DTEND:{tomorrow}T110000\r\nRRULE:FREQ=DAILY;COUNT=3\r\n" + f"EXDATE:{excluded}T100000\r\nEND:VEVENT\r\nEND:VCALENDAR" ) page.locator("#week-capacity-file").set_input_files({ "name": "availability.ics", "mimeType": "text/calendar", "buffer": calendar.encode() @@ -115,6 +117,12 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( expect(page.locator("#week-capacity-days .week-capacity-day").first).to_contain_text( "420 min available" ) + expect(page.locator("#week-capacity-days .week-capacity-day").nth(1)).to_contain_text( + "480 min available" + ) + expect(page.locator("#week-capacity-days .week-capacity-day").nth(2)).to_contain_text( + "420 min available" + ) expect(capacity_import).not_to_contain_text(private_title) for control in ( page.locator("#cancel-week-capacity-import"), diff --git a/tests/test_week_calendar_import.py b/tests/test_week_calendar_import.py index 77a3ac4..89fe0d1 100644 --- a/tests/test_week_calendar_import.py +++ b/tests/test_week_calendar_import.py @@ -37,6 +37,72 @@ console.log(JSON.stringify({days,serialized:JSON.stringify(days)})); assert private_value not in result["serialized"] +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`; +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({busy:days.map(day=>day.busy_minutes)})); +""") + + assert result["busy"] == [60, 60, 0, 60, 60, 0, 0] + + +def test_calendar_import_adds_rdates_and_excludes_matching_occurrences(): + result = run_import(""" +const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRDATE:20260823T100000,20260825T100000\r\nEXDATE:20260823T100000\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)})); +""") + + assert result["busy"] == [60, 0, 0, 0, 60, 0, 0] + + +def test_calendar_import_stops_recurrence_at_until_boundary(): + result = run_import(""" +const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRRULE:FREQ=DAILY;UNTIL=20260823T100000\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)})); +""") + + assert result["busy"] == [60, 60, 60, 0, 0, 0, 0] + + +def test_calendar_import_expands_weekly_recurrence_on_selected_weekdays(): + result = run_import(""" +const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=5\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)})); +""") + + assert result["busy"] == [60, 0, 0, 60, 0, 60, 0] + + +def test_calendar_import_treats_all_day_events_as_busy_for_working_hours(): + result = run_import(""" +const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;VALUE=DATE:20260822\r\nDTEND;VALUE=DATE:20260823\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({busy:days.map(day=>day.busy_minutes)})); +""") + + assert result["busy"] == [0, 480, 0, 0, 0, 0, 0] + + +def test_calendar_import_ignores_cancelled_and_transparent_events(): + result = run_import(""" +const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nSTATUS:CANCELLED\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nTRANSP:TRANSPARENT\r\nDTSTART:20260822T100000\r\nDTEND:20260822T110000\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)})); +""") + + assert result["busy"] == [0] * 7 + + def test_calendar_import_rejects_invalid_and_oversized_files_without_returning_capacity(): result = run_import(""" const errors=[]; @@ -53,6 +119,45 @@ console.log(JSON.stringify({errors})); ] +def test_calendar_import_blocks_apply_when_recurrence_cannot_be_counted_truthfully(): + result = run_import(""" +const elements=new Map(); +const element=(value='')=>({value,hidden:false,textContent:'',innerHTML:'',disabled:false,files:[],listeners:{}, + addEventListener(name,listener){this.listeners[name]=listener;},focus(){}}); +for(const selector of ['#week-capacity-import','#week-capacity-review','#week-capacity-days','#week-capacity-status', + '#week-capacity-file','#week-capacity-start','#week-capacity-end','#apply-week-capacities','#cancel-week-capacity-import']) + elements.set(selector,element()); +elements.get('#week-capacity-start').value='09:00';elements.get('#week-capacity-end').value='17:00'; +const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27']; +let staged=0; +const controller={dates:()=>dates.map(date=>({date,label:date})),stageCapacities:()=>{staged+=1;return true;},flush:async()=>{}}; +const workflow=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector)}); +const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nSUMMARY:Private board review\r\nATTENDEE:mailto:secret@example.com\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRRULE:FREQ=MONTHLY;BYDAY=1FR\r\nEND:VEVENT\r\nEND:VCALENDAR`; +workflow.review(source);const applied=await workflow.apply(); +console.log(JSON.stringify({disabled:elements.get('#apply-week-capacities').disabled,status:elements.get('#week-capacity-status').textContent, + markup:elements.get('#week-capacity-days').innerHTML,applied,staged})); +""") + + assert result["disabled"] is True + assert result["applied"] is False + assert result["staged"] == 0 + assert "1 recurring event could not be counted" in result["status"] + assert "Apply is unavailable" in result["status"] + assert "Private board review" not in json.dumps(result) + assert "secret@example.com" not in json.dumps(result) + + +def test_calendar_import_marks_unsupported_daily_rule_parts_instead_of_ignoring_them(): + result = run_import(""" +const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRRULE:FREQ=DAILY;BYHOUR=10\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})); +""") + + assert result["unsupported"] == 1 + + def test_calendar_import_workflow_reviews_then_applies_once_without_persisting_calendar_text(): result = run_import(""" const elements=new Map();