Calendar handoff
Add Week Ahead to calendar
- Choose each day’s start time and exclude anything private. Titles, references, dates, and times are included in a local calendar file; nothing is uploaded.
+ 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.
diff --git a/frontend/week-calendar-import.js b/frontend/week-calendar-import.js
index 02cbfe1..14ac0a2 100644
--- a/frontend/week-calendar-import.js
+++ b/frontend/week-calendar-import.js
@@ -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{}}={}) {
- 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;
}
diff --git a/frontend/week-calendar.js b/frontend/week-calendar.js
index 1fdd86b..e06955c 100644
--- a/frontend/week-calendar.js
+++ b/frontend/week-calendar.js
@@ -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);
diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py
index f3432d8..7a8fce5 100644
--- a/tests/e2e/test_mobile_week_ahead_release.py
+++ b/tests/e2e/test_mobile_week_ahead_release.py
@@ -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:00–09:30 · 30 min")
+ expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("10:00–10: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
diff --git a/tests/test_week_calendar.py b/tests/test_week_calendar.py
index d89dac3..c76c9cf 100644
--- a/tests/test_week_calendar.py
+++ b/tests/test_week_calendar.py
@@ -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
diff --git a/tests/test_week_calendar_import.py b/tests/test_week_calendar_import.py
index 89fe0d1..c3af71c 100644
--- a/tests/test_week_calendar_import.py
+++ b/tests/test_week_calendar_import.py
@@ -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)