Merge pull request 'Make Week Ahead calendar import recurrence-aware and truthful' (#1209) from timmy/1208-week-calendar-recurrence into main
All checks were successful
CI / lint (push) Successful in 3m42s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m3s
CI / release-candidate (push) Successful in 7s

This commit is contained in:
rockachopa 2026-08-21 05:03:41 +00:00
commit 865083a835
4 changed files with 214 additions and 11 deletions

View File

@ -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

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;}

View File

@ -103,11 +103,29 @@ 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")
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}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\nRDATE:{added}T100000\r\nEND:VEVENT\r\nEND:VCALENDAR"
)
unsupported_title = "Private monthly board review"
unsupported_calendar = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
f"SUMMARY:{unsupported_title}\r\nDTSTART:{tomorrow}T100000\r\n"
f"DTEND:{tomorrow}T110000\r\nRRULE:FREQ=MONTHLY;BYDAY=1FR\r\n"
"END:VEVENT\r\nEND:VCALENDAR"
)
page.locator("#week-capacity-file").set_input_files({
"name": "unsupported.ics", "mimeType": "text/calendar", "buffer": unsupported_calendar.encode()
})
expect(page.locator("#week-capacity-status")).to_contain_text(
"1 recurring event could not be counted"
)
expect(page.locator("#apply-week-capacities")).to_be_disabled()
expect(capacity_import).not_to_contain_text(unsupported_title)
page.locator("#week-capacity-file").set_input_files({
"name": "availability.ics", "mimeType": "text/calendar", "buffer": calendar.encode()
})
@ -115,6 +133,15 @@ 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(page.locator("#week-capacity-days .week-capacity-day").nth(3)).to_contain_text(
"420 min available"
)
expect(capacity_import).not_to_contain_text(private_title)
for control in (
page.locator("#cancel-week-capacity-import"),

View File

@ -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();