feat: avoid imported busy time in Week Ahead export (Closes #1210)
This commit is contained in:
parent
865083a835
commit
4e275b1518
|
|
@ -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 day’s 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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user