Avoid imported busy time in Week Ahead calendar export #1211

Merged
rockachopa merged 1 commits from timmy/1210-week-calendar-availability into main 2026-08-21 06:00:11 +00:00
6 changed files with 99 additions and 24 deletions

View File

@ -536,7 +536,7 @@
<section class="week-calendar-handoff" id="week-calendar-handoff" aria-labelledby="week-calendar-title" hidden>
<div class="small">Calendar handoff</div>
<h2 id="week-calendar-title">Add Week Ahead to calendar</h2>
<p class="small muted">Choose each days start time and exclude anything private. Titles, references, dates, and times are included in a local calendar file; nothing is uploaded.</p>
<p class="small muted">Titles, references, dates, and times are included in a local calendar file; nothing is uploaded. Imported busy time is used only until this page reloads; otherwise times follow the chosen start.</p>
<div class="week-calendar-days" id="week-calendar-days"></div>
<div class="small week-calendar-status" id="week-calendar-status" role="status" aria-live="assertive"></div>
<div class="week-calendar-actions">

View File

@ -93,6 +93,10 @@ function createWeekCalendarImport() {
const [year,month,day]=date.split('-').map(Number);
return new Date(year,month-1,day,Math.floor(minutes/60),minutes%60).getTime();
}
function localClock(value) {
const date=new Date(value);
return String(date.getHours()).padStart(2,'0')+':'+String(date.getMinutes()).padStart(2,'0');
}
function review(source,{dates,workdayStart='09:00',workdayEnd='17:00'}={}) {
if(!Array.isArray(dates)||dates.length!==7)throw new Error('Week Ahead must contain seven dates.');
const startMinute=clock(workdayStart),endMinute=clock(workdayEnd);
@ -112,13 +116,19 @@ function createWeekCalendarImport() {
else merged.push([...range]);
});
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};
const free_windows=[];let cursor=start;
merged.forEach(range=>{
if(range[0]>cursor)free_windows.push({start_time:localClock(cursor),end_time:localClock(range[0])});
cursor=Math.max(cursor,range[1]);
});
if(cursor<end)free_windows.push({start_time:localClock(cursor),end_time:localClock(end)});
return {plan_date,busy_minutes,capacity_minutes:endMinute-startMinute-busy_minutes,free_windows};
});
days.unsupported_count=unsupported_count;
return days;
}
function createWorkflow({controller,qs,onApplied=()=>{}}={}) {
let reviewed=null;
let reviewed=null,activeAvailability=null;
const root=()=>qs('#week-capacity-import');
function clear() {
reviewed=null;
@ -148,9 +158,12 @@ function createWeekCalendarImport() {
return reviewed.map(day=>({...day}));
}
async function apply() {
if(!reviewed||reviewed.unsupported_count||!controller.stageCapacities(reviewed))return false;
if(!reviewed||reviewed.unsupported_count||!controller.stageCapacities(reviewed.map(day=>({
plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
}))))return false;
qs('#apply-week-capacities').disabled=true;
try {await controller.flush();cancel();onApplied();return true;}
try {await controller.flush();activeAvailability=reviewed.map(day=>({plan_date:day.plan_date,
free_windows:day.free_windows.map(window=>({...window}))}));cancel();onApplied();return true;}
finally {qs('#apply-week-capacities').disabled=false;}
}
qs('#week-capacity-file')?.addEventListener('change',async event=>{
@ -164,10 +177,13 @@ function createWeekCalendarImport() {
qs('#apply-week-capacities')?.addEventListener('click',()=>apply().catch(error=>{
qs('#week-capacity-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Capacities remain saved on this phone.';
}));
return {open,cancel,review:reviewSource,apply,state:()=>reviewed?reviewed.map(day=>({...day})):null};
return {open,cancel,review:reviewSource,apply,state:()=>reviewed?reviewed.map(day=>({...day})):null,
availability:()=>activeAvailability?activeAvailability.map(day=>({plan_date:day.plan_date,
free_windows:day.free_windows.map(window=>({...window}))})):null};
}
function mount(controller,weekWorkflow,qs) {
const workflow=createWorkflow({controller,qs,onApplied:weekWorkflow.renderReview});
StackchainWeekCalendar.setAvailabilityProvider(workflow.availability);
qs('#open-week-capacity-import').addEventListener('click',workflow.open);
return workflow;
}

View File

@ -1,5 +1,6 @@
(function(root){
'use strict';
let availabilityProvider=()=>null;
function escapeText(value){
return String(value||'').replace(/\\/g,'\\\\').replace(/\r?\n/g,'\\n').replace(/,/g,'\\,').replace(/;/g,'\\;');
}
@ -8,6 +9,7 @@
const [hours,mins]=String(value).split(':').map(Number),total=hours*60+mins+Number(minutes);
return String(Math.floor(total/60)%24).padStart(2,'0')+':'+String(total%60).padStart(2,'0');
}
function clockMinutes(value){const [hours,minutes]=String(value).split(':').map(Number);return hours*60+minutes;}
function stableId(value){return String(value||'work').replace(/[^a-z0-9]+/gi,'-').replace(/^-|-$/g,'').toLowerCase();}
function foldLine(line){
const encoder=new TextEncoder(),chunks=[];let chunk='',bytes=0,limit=75;
@ -18,20 +20,32 @@
}
chunks.push(chunk);return chunks.join('\r\n ');
}
function buildWeekBlocks(plan,startTimes,getItem,selected){
const blocks=[];
function buildWeekSchedule(plan,startTimes,getItem,selected,availability){
const blocks=[],blockers=[],availableByDate=new Map((availability||[]).map(day=>[day.plan_date,day.free_windows||[]]));
(plan?.days||[]).slice().sort((a,b)=>a.plan_date.localeCompare(b.plan_date)).forEach(day=>{
let cursor=startTimes?.[day.plan_date]||'09:00';
(day.ids||[]).forEach(id=>{
if(selected&& !selected.has(id))return;
const minutes=Number(day.estimates?.[id]),item=getItem?.(id);
if(!item||!Number.isFinite(minutes)||minutes<=0)return;
const windows=availableByDate.get(day.plan_date);
if(windows){
const fit=windows.find(window=>{
const candidate=Math.max(clockMinutes(cursor),clockMinutes(window.start_time));
if(candidate+minutes>clockMinutes(window.end_time))return false;
cursor=addMinutes('00:00',candidate);return true;
});
if(!fit){blockers.push({id,title:item.title||'Untitled work',plan_date:day.plan_date,minutes});return;}
}
const end=addMinutes(cursor,minutes);
blocks.push({...item,id,plan_date:day.plan_date,start_time:cursor,end_time:end,minutes});
cursor=end;
});
});
return blocks;
return {blocks,blockers};
}
function buildWeekBlocks(plan,startTimes,getItem,selected,availability){
return buildWeekSchedule(plan,startTimes,getItem,selected,availability).blocks;
}
function serializeWeekCalendar(blocks,{timezone='UTC',revision=0,generatedAt}={}){
const stamp=generatedAt||new Date().toISOString().replace(/[-:]/g,'').replace(/\.\d{3}/,'');
@ -56,21 +70,27 @@
finally{urlApi.revokeObjectURL(href);}
return 'downloaded';
}
function mountWeekCalendarHandoff({qs,getItem,escapeHtml,escapeAttribute,onDone,windowObject=root,navigatorObject=root.navigator,
function mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,escapeHtml,escapeAttribute,onDone,windowObject=root,navigatorObject=root.navigator,
documentObject=root.document,urlApi=root.URL,FileCtor=root.File}={}){
const handoff=qs('#week-calendar-handoff'),review=qs('#week-review'),daysRoot=qs('#week-calendar-days');
let plan=null;
const selected=()=>new Set(Array.from(daysRoot.querySelectorAll('[data-week-calendar-item]')).filter(input=>input.checked).map(input=>input.value));
const starts=()=>Object.fromEntries(Array.from(daysRoot.querySelectorAll('[data-week-calendar-start]')).map(input=>[input.dataset.weekCalendarStart,input.value]));
const blocks=()=>buildWeekBlocks(plan,starts(),getItem,selected());
const schedule=()=>buildWeekSchedule(plan,starts(),getItem,selected(),getAvailability?.());
const blocks=()=>schedule().blocks;
function update(){
const current=blocks(),byId=new Map(current.map(block=>[block.id,block]));
const current=schedule(),byId=new Map(current.blocks.map(block=>[block.id,block])),blockedById=new Map(current.blockers.map(blocker=>[blocker.id,blocker]));
daysRoot.querySelectorAll('[data-week-calendar-preview]').forEach(node=>{
const block=byId.get(node.dataset.weekCalendarPreview);
node.textContent=block?block.start_time+''+block.end_time+' · '+block.minutes+' min':'Excluded from calendar';
const id=node.dataset.weekCalendarPreview,block=byId.get(id),blocker=blockedById.get(id);
node.textContent=block?block.start_time+''+block.end_time+' · '+block.minutes+' min':
(blocker?'Does not fit imported free time':'Excluded from calendar');
});
qs('#share-week-calendar').disabled=!current.length;
qs('#week-calendar-status').textContent=current.length+' calendar block'+(current.length===1?'':'s')+' selected.';
qs('#share-week-calendar').disabled=!current.blocks.length||current.blockers.length>0;
if(current.blockers.length){
const blocker=current.blockers[0];
qs('#week-calendar-status').textContent=blocker.title+' does not fit free time on '+blocker.plan_date+'. Adjust the start, estimate, or calendar import.';
}else qs('#week-calendar-status').textContent=(getAvailability?.()?'Planning around imported busy time · ':'Manual timing · ')+
current.blocks.length+' calendar block'+(current.blocks.length===1?'':'s')+' selected.';
}
function close({back=false}={}){
handoff.hidden=true;
@ -104,6 +124,7 @@
});
return {open,close,blocks};
}
const api={buildWeekBlocks,deliverWeekCalendar,escapeText,foldLine,mountWeekCalendarHandoff,serializeWeekCalendar};
function setAvailabilityProvider(value){availabilityProvider=typeof value==='function'?value:()=>null;}
const api={buildWeekBlocks,buildWeekSchedule,deliverWeekCalendar,escapeText,foldLine,mountWeekCalendarHandoff,serializeWeekCalendar,setAvailabilityProvider};
if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.StackchainWeekCalendar=api;
})(typeof window!=='undefined'?window:globalThis);

View File

@ -107,9 +107,9 @@ 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}T100000\r\n"
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"
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"
)
unsupported_title = "Private monthly board review"
unsupported_calendar = (
@ -167,7 +167,8 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
expect(page.locator("#week-review")).to_be_hidden()
start = page.locator("[data-week-calendar-start]").first
expect(start).to_have_value("09:00")
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("09:0009:30 · 30 min")
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("10:0010:30 · 30 min")
expect(page.locator("#week-calendar-status")).to_contain_text("Planning around imported busy time")
for control in (start, page.locator("#back-to-week-review-from-calendar"), page.locator("#share-week-calendar")):
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44

View File

@ -69,6 +69,27 @@ console.log(JSON.stringify({{blocks,text}}));
assert "URL:https://forge.alexanderwhitestone.com/git/stackchain/stackchain-dashboard/issues/12" in unfolded
def test_week_calendar_places_whole_tasks_in_imported_free_windows_and_reports_unfitted_work():
source = f"""
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
const plan={{days:[{{plan_date:'2026-08-21',ids:['one','two','three'],estimates:{{one:45,two:90,three:400}}}}]}};
const items={{one:{{title:'First',repository:'r',number:1}},two:{{title:'Second',repository:'r',number:2}},
three:{{title:'Too large',repository:'r',number:3}}}};
const availability=[{{plan_date:'2026-08-21',free_windows:[{{start_time:'09:00',end_time:'10:00'}},{{start_time:'11:00',end_time:'17:00'}}]}}];
const result=calendar.buildWeekSchedule(plan,{{'2026-08-21':'09:00'}},id=>items[id],null,availability);
console.log(JSON.stringify(result));
"""
result = run_node(source)
assert [(block["id"], block["start_time"], block["end_time"]) for block in result["blocks"]] == [
("one", "09:00", "09:45"),
("two", "11:00", "12:30"),
]
assert result["blockers"] == [{
"id": "three", "title": "Too large", "plan_date": "2026-08-21", "minutes": 400
}]
def test_week_calendar_excludes_unselected_items_and_downloads_when_native_share_is_unavailable():
source = f"""
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
@ -104,7 +125,10 @@ def test_week_calendar_handoff_is_wired_into_confirmation_with_mobile_privacy_co
assert 'id="share-week-calendar"' in index
assert 'id="back-to-week-review-from-calendar"' in index
assert "Titles, references, dates, and times are included" in index
assert "Imported busy time is used only until this page reloads" in index
assert "mountWeekCalendarHandoff" in WEEK_CALENDAR.read_text()
assert "weekCalendar.open(weekPlan.state())" in dashboard
assert "weekCalendar.close()" in dashboard
assert "StackchainWeekCalendar.setAvailabilityProvider(workflow.availability)" in (WEEK_CALENDAR.parent / "week-calendar-import.js").read_text()
assert "Planning around imported busy time" in WEEK_CALENDAR.read_text()
assert '"static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js"' in bundle

View File

@ -27,10 +27,15 @@ console.log(JSON.stringify({days,serialized:JSON.stringify(days)}));
""")
assert result["days"][0] == {
"plan_date": "2026-08-21", "busy_minutes": 150, "capacity_minutes": 330
"plan_date": "2026-08-21", "busy_minutes": 150, "capacity_minutes": 330,
"free_windows": [
{"start_time": "09:00", "end_time": "10:00"},
{"start_time": "12:00", "end_time": "16:30"},
],
}
assert result["days"][1] == {
"plan_date": "2026-08-22", "busy_minutes": 60, "capacity_minutes": 420
"plan_date": "2026-08-22", "busy_minutes": 60, "capacity_minutes": 420,
"free_windows": [{"start_time": "10:00", "end_time": "17:00"}],
}
assert [day["capacity_minutes"] for day in result["days"][2:]] == [480] * 5
for private_value in ("Private planning", "Secret customer call", "private@example.com", "Private office"):
@ -178,7 +183,7 @@ const reviewed=workflow.review(source);
await workflow.apply();
console.log(JSON.stringify({reviewed,staged,flushes,appliedCalls,rootHidden:elements.get('#week-capacity-import').hidden,
status:elements.get('#week-capacity-status').textContent,markup:elements.get('#week-capacity-days').innerHTML,
fileValue:elements.get('#week-capacity-file').value,workflowState:workflow.state()}));
fileValue:elements.get('#week-capacity-file').value,workflowState:workflow.state(),availability:workflow.availability()}));
""")
assert result["reviewed"][0]["capacity_minutes"] == 420
@ -189,6 +194,14 @@ console.log(JSON.stringify({reviewed,staged,flushes,appliedCalls,rootHidden:elem
assert result["rootHidden"] is True
assert result["fileValue"] == ""
assert result["workflowState"] is None
assert result["availability"][0] == {
"plan_date": "2026-08-21",
"free_windows": [
{"start_time": "09:00", "end_time": "10:00"},
{"start_time": "11:00", "end_time": "17:00"},
],
}
assert "free_windows" not in json.dumps(result["staged"])
assert "Do not retain me" not in json.dumps(result)