feat: avoid imported busy time in Week Ahead export (Closes #1210)
All checks were successful
CI / lint (pull_request) Successful in 3m17s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m38s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-21 05:50:23 +00:00
parent 865083a835
commit 4e275b1518
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> <section class="week-calendar-handoff" id="week-calendar-handoff" aria-labelledby="week-calendar-title" hidden>
<div class="small">Calendar handoff</div> <div class="small">Calendar handoff</div>
<h2 id="week-calendar-title">Add Week Ahead to calendar</h2> <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="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="small week-calendar-status" id="week-calendar-status" role="status" aria-live="assertive"></div>
<div class="week-calendar-actions"> <div class="week-calendar-actions">

View File

@ -93,6 +93,10 @@ function createWeekCalendarImport() {
const [year,month,day]=date.split('-').map(Number); const [year,month,day]=date.split('-').map(Number);
return new Date(year,month-1,day,Math.floor(minutes/60),minutes%60).getTime(); 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'}={}) { 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.'); if(!Array.isArray(dates)||dates.length!==7)throw new Error('Week Ahead must contain seven dates.');
const startMinute=clock(workdayStart),endMinute=clock(workdayEnd); const startMinute=clock(workdayStart),endMinute=clock(workdayEnd);
@ -112,13 +116,19 @@ function createWeekCalendarImport() {
else merged.push([...range]); else merged.push([...range]);
}); });
const busy_minutes=Math.round(merged.reduce((total,range)=>total+range[1]-range[0],0)/60000); 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; days.unsupported_count=unsupported_count;
return days; return days;
} }
function createWorkflow({controller,qs,onApplied=()=>{}}={}) { function createWorkflow({controller,qs,onApplied=()=>{}}={}) {
let reviewed=null; let reviewed=null,activeAvailability=null;
const root=()=>qs('#week-capacity-import'); const root=()=>qs('#week-capacity-import');
function clear() { function clear() {
reviewed=null; reviewed=null;
@ -148,9 +158,12 @@ function createWeekCalendarImport() {
return reviewed.map(day=>({...day})); return reviewed.map(day=>({...day}));
} }
async function apply() { 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; 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;} finally {qs('#apply-week-capacities').disabled=false;}
} }
qs('#week-capacity-file')?.addEventListener('change',async event=>{ qs('#week-capacity-file')?.addEventListener('change',async event=>{
@ -164,10 +177,13 @@ function createWeekCalendarImport() {
qs('#apply-week-capacities')?.addEventListener('click',()=>apply().catch(error=>{ 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.'; 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) { function mount(controller,weekWorkflow,qs) {
const workflow=createWorkflow({controller,qs,onApplied:weekWorkflow.renderReview}); const workflow=createWorkflow({controller,qs,onApplied:weekWorkflow.renderReview});
StackchainWeekCalendar.setAvailabilityProvider(workflow.availability);
qs('#open-week-capacity-import').addEventListener('click',workflow.open); qs('#open-week-capacity-import').addEventListener('click',workflow.open);
return workflow; return workflow;
} }

View File

@ -1,5 +1,6 @@
(function(root){ (function(root){
'use strict'; 'use strict';
let availabilityProvider=()=>null;
function escapeText(value){ function escapeText(value){
return String(value||'').replace(/\\/g,'\\\\').replace(/\r?\n/g,'\\n').replace(/,/g,'\\,').replace(/;/g,'\\;'); 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); 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'); 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 stableId(value){return String(value||'work').replace(/[^a-z0-9]+/gi,'-').replace(/^-|-$/g,'').toLowerCase();}
function foldLine(line){ function foldLine(line){
const encoder=new TextEncoder(),chunks=[];let chunk='',bytes=0,limit=75; const encoder=new TextEncoder(),chunks=[];let chunk='',bytes=0,limit=75;
@ -18,20 +20,32 @@
} }
chunks.push(chunk);return chunks.join('\r\n '); chunks.push(chunk);return chunks.join('\r\n ');
} }
function buildWeekBlocks(plan,startTimes,getItem,selected){ function buildWeekSchedule(plan,startTimes,getItem,selected,availability){
const blocks=[]; 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=>{ (plan?.days||[]).slice().sort((a,b)=>a.plan_date.localeCompare(b.plan_date)).forEach(day=>{
let cursor=startTimes?.[day.plan_date]||'09:00'; let cursor=startTimes?.[day.plan_date]||'09:00';
(day.ids||[]).forEach(id=>{ (day.ids||[]).forEach(id=>{
if(selected&& !selected.has(id))return; if(selected&& !selected.has(id))return;
const minutes=Number(day.estimates?.[id]),item=getItem?.(id); const minutes=Number(day.estimates?.[id]),item=getItem?.(id);
if(!item||!Number.isFinite(minutes)||minutes<=0)return; 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); const end=addMinutes(cursor,minutes);
blocks.push({...item,id,plan_date:day.plan_date,start_time:cursor,end_time:end,minutes}); blocks.push({...item,id,plan_date:day.plan_date,start_time:cursor,end_time:end,minutes});
cursor=end; 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}={}){ function serializeWeekCalendar(blocks,{timezone='UTC',revision=0,generatedAt}={}){
const stamp=generatedAt||new Date().toISOString().replace(/[-:]/g,'').replace(/\.\d{3}/,''); const stamp=generatedAt||new Date().toISOString().replace(/[-:]/g,'').replace(/\.\d{3}/,'');
@ -56,21 +70,27 @@
finally{urlApi.revokeObjectURL(href);} finally{urlApi.revokeObjectURL(href);}
return 'downloaded'; 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}={}){ documentObject=root.document,urlApi=root.URL,FileCtor=root.File}={}){
const handoff=qs('#week-calendar-handoff'),review=qs('#week-review'),daysRoot=qs('#week-calendar-days'); const handoff=qs('#week-calendar-handoff'),review=qs('#week-review'),daysRoot=qs('#week-calendar-days');
let plan=null; let plan=null;
const selected=()=>new Set(Array.from(daysRoot.querySelectorAll('[data-week-calendar-item]')).filter(input=>input.checked).map(input=>input.value)); 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 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(){ 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=>{ daysRoot.querySelectorAll('[data-week-calendar-preview]').forEach(node=>{
const block=byId.get(node.dataset.weekCalendarPreview); 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':'Excluded from calendar'; 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('#share-week-calendar').disabled=!current.blocks.length||current.blockers.length>0;
qs('#week-calendar-status').textContent=current.length+' calendar block'+(current.length===1?'':'s')+' selected.'; 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}={}){ function close({back=false}={}){
handoff.hidden=true; handoff.hidden=true;
@ -104,6 +124,7 @@
}); });
return {open,close,blocks}; 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; if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.StackchainWeekCalendar=api;
})(typeof window!=='undefined'?window:globalThis); })(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") added = (date.today() + timedelta(days=4)).strftime("%Y%m%d")
calendar = ( calendar = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n" "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
f"SUMMARY:{private_title}\r\nDTSTART:{tomorrow}T100000\r\n" f"SUMMARY:{private_title}\r\nDTSTART:{tomorrow}T090000\r\n"
f"DTEND:{tomorrow}T110000\r\nRRULE:FREQ=DAILY;COUNT=3\r\n" f"DTEND:{tomorrow}T100000\r\nRRULE:FREQ=DAILY;COUNT=3\r\n"
f"EXDATE:{excluded}T100000\r\nRDATE:{added}T100000\r\nEND:VEVENT\r\nEND:VCALENDAR" f"EXDATE:{excluded}T090000\r\nRDATE:{added}T090000\r\nEND:VEVENT\r\nEND:VCALENDAR"
) )
unsupported_title = "Private monthly board review" unsupported_title = "Private monthly board review"
unsupported_calendar = ( unsupported_calendar = (
@ -167,7 +167,8 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
expect(page.locator("#week-review")).to_be_hidden() expect(page.locator("#week-review")).to_be_hidden()
start = page.locator("[data-week-calendar-start]").first start = page.locator("[data-week-calendar-start]").first
expect(start).to_have_value("09:00") 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")): for control in (start, page.locator("#back-to-week-review-from-calendar"), page.locator("#share-week-calendar")):
bounds = control.bounding_box() bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44 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 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(): def test_week_calendar_excludes_unselected_items_and_downloads_when_native_share_is_unavailable():
source = f""" source = f"""
const calendar=require({json.dumps(str(WEEK_CALENDAR))}); 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="share-week-calendar"' in index
assert 'id="back-to-week-review-from-calendar"' in index assert 'id="back-to-week-review-from-calendar"' in index
assert "Titles, references, dates, and times are included" 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 "mountWeekCalendarHandoff" in WEEK_CALENDAR.read_text()
assert "weekCalendar.open(weekPlan.state())" in dashboard assert "weekCalendar.open(weekPlan.state())" in dashboard
assert "weekCalendar.close()" 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 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] == { 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] == { 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 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"): 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(); await workflow.apply();
console.log(JSON.stringify({reviewed,staged,flushes,appliedCalls,rootHidden:elements.get('#week-capacity-import').hidden, 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, 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 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["rootHidden"] is True
assert result["fileValue"] == "" assert result["fileValue"] == ""
assert result["workflowState"] is None 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) assert "Do not retain me" not in json.dumps(result)