Merge pull request 'Make Week Ahead calendar import recurrence-aware and truthful' (#1209) from timmy/1208-week-calendar-recurrence into main
This commit is contained in:
commit
865083a835
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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;}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user