feat: avoid imported busy time in Week Ahead export (Closes #1210)
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 committed 2026-08-21 05:50:23 +00:00
1 parent 865083a835
commit 4e275b1518
6 files changed
+99 -24

No files matched your search

+1 -1
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 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">
+21 -5
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;
}
+32 -11
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);