Merge pull request 'Refresh Week Ahead calendar availability with atomic reflow' (#1267) from timmy/1266-refresh-week-calendar-atomic-reflow into main
This commit is contained in:
commit
d27a481d96
10
README.md
10
README.md
|
|
@ -193,9 +193,13 @@ work that cannot fit is named and remains in My Work, while missing estimates bl
|
|||
write, and apply stages one durable whole-week transition through the existing conflict-safe sync path.
|
||||
Confirmation remains disabled while duplicates exist or the account-bound week is still syncing.
|
||||
Rapid saves and review moves use one network flight plus a coalesced latest-state delivery, so later staged days
|
||||
are not stranded behind an earlier request. From the same review, **Set capacity from calendar** reads a local
|
||||
`.ics` file on-device, unions overlapping busy periods within chosen working hours, and previews seven daily
|
||||
availability totals before one atomic apply. IANA `TZID` values from Google and Outlook calendars are converted
|
||||
are not stranded behind an earlier request. From the same review, **Refresh from calendar** reads a local
|
||||
`.ics` file on-device, unions overlapping busy periods within chosen working hours, and compares every current
|
||||
capacity with refreshed availability and planned load. If refreshed availability creates an overload, the review
|
||||
previews the deterministic destination of moved work and reports anything that cannot fit. One explicit
|
||||
**Apply capacities & reflow** action stages the complete seven-day plan once—there is no transient overloaded
|
||||
save—and preserves consented free windows alongside the redistributed work. A refresh that already fits keeps
|
||||
the direct one-write capacity path. IANA `TZID` values from Google and Outlook calendars are converted
|
||||
to the device's local workday, including daylight-saving transitions. Daily and weekly recurrence (`COUNT`,
|
||||
`UNTIL`, `INTERVAL`, and weekly `BYDAY`), `RDATE`/`EXDATE`, and all-day events are evaluated with work bounded
|
||||
to the seven review dates; cancelled and transparent events do not consume capacity. If a time zone is unknown,
|
||||
|
|
|
|||
|
|
@ -392,6 +392,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.week-capacity-day { display:grid; min-width:0; gap:3px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#10233a; overflow-wrap:anywhere; }
|
||||
.week-capacity-day span { color:#bfdbfe; font-weight:700; }
|
||||
.week-capacity-day small { color:#a9bdd3; }
|
||||
.week-capacity-day.is-overloaded { border-color:#f59e0b; background:#2a1c12; }
|
||||
.week-capacity-day .week-capacity-over { color:#fde68a; font-weight:700; }
|
||||
.week-free-times-consent { display:flex; align-items:flex-start; gap:10px; min-width:0; margin:12px 0; padding:10px; border:1px solid #31577f; border-radius:10px; }
|
||||
.week-free-times-consent input { flex:0 0 44px; width:44px; margin:0; }
|
||||
.week-free-times-consent span { display:grid; min-width:0; gap:4px; overflow-wrap:anywhere; }
|
||||
|
|
|
|||
|
|
@ -522,7 +522,7 @@
|
|||
<p class="small muted">See what is next and check each day’s load. Edit only when you are ready.</p>
|
||||
<p class="week-offline-snapshot" id="week-offline-snapshot" role="status" aria-live="polite" hidden></p>
|
||||
<button id="retry-week-live" type="button" hidden>Retry live Week Ahead</button>
|
||||
<button id="open-week-capacity-import" type="button">Set capacity from calendar</button>
|
||||
<button id="open-week-capacity-import" type="button">Refresh from calendar</button>
|
||||
<button id="open-week-availability" type="button">Set weekly availability</button>
|
||||
<section class="week-availability-editor" id="week-availability-editor" aria-labelledby="week-availability-title" hidden>
|
||||
<h3 id="week-availability-title">Weekly availability</h3>
|
||||
|
|
@ -565,9 +565,9 @@
|
|||
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button>
|
||||
</section>
|
||||
<section class="week-capacity-import" id="week-capacity-import" aria-labelledby="week-capacity-title" hidden>
|
||||
<header><div><div class="small">Private calendar import</div><h2 id="week-capacity-title">Set Week Ahead capacity</h2></div>
|
||||
<header><div><div class="small">Private calendar refresh</div><h2 id="week-capacity-title">Refresh Week Ahead availability</h2></div>
|
||||
<button id="cancel-week-capacity-import" type="button">Cancel</button></header>
|
||||
<p class="small muted">Your calendar is read only on this device. Event titles, people, locations, and the file are never saved or uploaded.</p>
|
||||
<p class="small muted">Compare refreshed availability with planned load before anything changes. Your calendar is read only on this device; event titles, people, locations, and the file are never saved or uploaded.</p>
|
||||
<label>Calendar file
|
||||
<input id="week-capacity-file" type="file" accept=".ics,text/calendar" />
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -197,14 +197,15 @@ function createWeekCalendarImport() {
|
|||
return days;
|
||||
}
|
||||
function createWorkflow({controller,qs,onApplied=()=>{}}={}) {
|
||||
let reviewed=null,activeAvailability=null;
|
||||
let reviewed=null,activeAvailability=null,needsReflow=false,reflowPreview=null;
|
||||
const root=()=>qs('#week-capacity-import');
|
||||
function clear() {
|
||||
reviewed=null;
|
||||
reviewed=null;needsReflow=false;reflowPreview=null;
|
||||
qs('#week-capacity-file').value='';
|
||||
qs('#week-capacity-review').hidden=true;
|
||||
qs('#week-capacity-days').innerHTML='';
|
||||
qs('#apply-week-capacities').disabled=false;
|
||||
qs('#apply-week-capacities').textContent='Apply seven capacities';
|
||||
}
|
||||
function open() {
|
||||
clear();root().hidden=false;qs('#week-capacity-status').textContent='';
|
||||
|
|
@ -215,24 +216,45 @@ function createWeekCalendarImport() {
|
|||
const display=controller.dates();
|
||||
reviewed=review(source,{dates:display.map(item=>item.date),workdayStart:qs('#week-capacity-start').value,
|
||||
workdayEnd:qs('#week-capacity-end').value});
|
||||
qs('#week-capacity-days').innerHTML=reviewed.map((day,index)=>'<article class="week-capacity-day"><strong>'+
|
||||
display[index].label+'</strong><span>'+day.capacity_minutes+' min available</span><small>'+day.busy_minutes+
|
||||
' min busy during working hours</small></article>').join('');
|
||||
const current=controller.review?.().days||[];
|
||||
needsReflow=reviewed.some((day,index)=>Number(current[index]?.planned_minutes)>day.capacity_minutes);
|
||||
const previewValues=reviewed.map(day=>({plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
|
||||
free_windows:day.free_windows.map(window=>({...window}))}));
|
||||
reflowPreview=needsReflow?controller.previewCapacityReflow?.(previewValues):null;
|
||||
const destinations=new Map();
|
||||
(reflowPreview?.days||[]).forEach(day=>(day.ids||[]).forEach(id=>destinations.set(id,day.plan_date)));
|
||||
qs('#week-capacity-days').innerHTML=reviewed.map((day,index)=>{
|
||||
const previous=current[index]||{},planned=Number(previous.planned_minutes)||0,over=Math.max(0,planned-day.capacity_minutes);
|
||||
const moved=[...new Set((previous.ids||[]).map(id=>destinations.get(id)).filter(date=>date&&date!==day.plan_date))]
|
||||
.map(date=>display.find(item=>item.date===date)?.label||date);
|
||||
return '<article class="week-capacity-day'+(over?' is-overloaded':'')+'"><strong>'+display[index].label+'</strong><span>'+
|
||||
(Number.isInteger(previous.capacity_minutes)?previous.capacity_minutes+' → ':'')+day.capacity_minutes+' min available</span><small>'+
|
||||
planned+' min planned · '+day.busy_minutes+' min busy</small>'+(over?'<small class="week-capacity-over">'+over+
|
||||
' min over refreshed capacity</small>':'')+(moved.length?'<small>Moves to '+moved.join(', ')+'</small>':'')+'</article>';
|
||||
}).join('');
|
||||
qs('#week-capacity-review').hidden=false;
|
||||
const unsupported=reviewed.unsupported_count||0;
|
||||
qs('#apply-week-capacities').disabled=unsupported>0;
|
||||
const reflowBlocked=Boolean(reflowPreview?.blockers?.length);
|
||||
qs('#apply-week-capacities').disabled=unsupported>0||reflowBlocked;
|
||||
qs('#apply-week-capacities').textContent=needsReflow?'Apply capacities & reflow':'Apply seven capacities';
|
||||
const kind=reviewed.unsupported_timezone_count?'calendar event':'recurring event';
|
||||
qs('#week-capacity-status').textContent=unsupported?
|
||||
unsupported+' '+kind+(unsupported===1?'':'s')+' could not be counted. Apply is unavailable; export a simpler seven-day calendar and try again.':
|
||||
'Review seven capacity totals. Calendar details stay on this device.';
|
||||
(reflowBlocked?'Reflow needs an estimate for every planned item before refreshed capacity can be applied.':
|
||||
(reflowPreview?.unscheduled?.length?reflowPreview.unscheduled.length+' planned item'+
|
||||
(reflowPreview.unscheduled.length===1?'':'s')+' will return to My Work. Review before applying.':
|
||||
'Review seven capacity totals. Calendar details stay on this device.'));
|
||||
return reviewed.map(day=>({...day}));
|
||||
}
|
||||
async function apply() {
|
||||
if(!reviewed||reviewed.unsupported_count)return false;
|
||||
const keep=Boolean(qs('#keep-week-free-times')?.checked);
|
||||
if(!reviewed||reviewed.unsupported_count||!controller.stageCapacities(reviewed.map(day=>({
|
||||
const values=reviewed.map(day=>({
|
||||
plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
|
||||
...(keep?{free_windows:day.free_windows.map(window=>({...window}))}:{}),
|
||||
}))))return false;
|
||||
}));
|
||||
const staged=needsReflow?controller.applyCapacityReflow?.(values):controller.stageCapacities(values);
|
||||
if(!staged)return false;
|
||||
qs('#apply-week-capacities').disabled=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;}
|
||||
|
|
|
|||
|
|
@ -209,6 +209,10 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
|||
}]));
|
||||
}
|
||||
function stageCapacities(values) {
|
||||
const changed=capacityDays(values);
|
||||
return changed&&stageDays(changed);
|
||||
}
|
||||
function capacityDays(values) {
|
||||
const available=dates().map(item=>item.date);
|
||||
if(!Array.isArray(values)||values.length!==available.length)return false;
|
||||
const capacities=new Map(values.map(value=>[value?.plan_date,value]));
|
||||
|
|
@ -216,13 +220,13 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
|||
const value=capacities.get(date)?.capacity_minutes;
|
||||
return !Number.isInteger(value)||value<0||value>1440;
|
||||
}))return false;
|
||||
return stageDays(available.map(planDate=>{
|
||||
return available.map(planDate=>{
|
||||
const value=day(planDate),capacity=capacities.get(planDate);
|
||||
value.capacity_minutes=capacity.capacity_minutes;
|
||||
if(Array.isArray(capacity.free_windows))value.free_windows=cloneWindows(capacity.free_windows);
|
||||
else delete value.free_windows;
|
||||
return value;
|
||||
}));
|
||||
});
|
||||
}
|
||||
function stageAvailabilityDefaults(values) {
|
||||
if(!cloneDefaults(values)||values.some(value=>!Number.isInteger(value)||value<0||value>1440)||offlineSnapshot)return false;
|
||||
|
|
@ -263,8 +267,9 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
|||
['over-capacity','missing-estimate','missing-capacity'].indexOf(left.type)-['over-capacity','missing-estimate','missing-capacity'].indexOf(right.type));
|
||||
return {days,duplicates,blockers,can_confirm:duplicates.length===0&&blockers.length===0};
|
||||
}
|
||||
function previewReflow() {
|
||||
const available=dates().map(item=>day(item.date));
|
||||
function previewReflow(capacities=null) {
|
||||
const available=capacities?capacityDays(capacities):dates().map(item=>day(item.date));
|
||||
if(!available)return {days:[],blockers:[{type:'invalid-capacities'}],unscheduled:[]};
|
||||
const seen=new Set(),work=[];
|
||||
available.forEach(source=>(source.ids||[]).forEach(id=>{
|
||||
if(seen.has(id))return;
|
||||
|
|
@ -277,7 +282,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
|||
}));
|
||||
if(blockers.length)return {days:[],blockers,unscheduled:[]};
|
||||
const days=available.map(source=>({plan_date:source.plan_date,ids:[],
|
||||
capacity_minutes:source.capacity_minutes??null,estimates:{}}));
|
||||
capacity_minutes:source.capacity_minutes??null,estimates:{},
|
||||
...(cloneWindows(source.free_windows)?{free_windows:cloneWindows(source.free_windows)}:{})}));
|
||||
const unscheduled=[];let cursor=0;
|
||||
if(!blockers.length)work.forEach(item=>{
|
||||
const destination=days.slice(cursor).find(value=>value.ids.length<5&&Number(value.capacity_minutes)>0&&
|
||||
|
|
@ -293,6 +299,12 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
|||
if(preview.blockers.length||offlineSnapshot)return false;
|
||||
return stageDays(preview.days);
|
||||
}
|
||||
function previewCapacityReflow(values) { return previewReflow(values); }
|
||||
function applyCapacityReflow(values) {
|
||||
const preview=previewCapacityReflow(values);
|
||||
if(preview.blockers.length||offlineSnapshot)return false;
|
||||
return stageDays(preview.days);
|
||||
}
|
||||
function move(id,toDate) {
|
||||
if(!dates().some(item=>item.date===toDate)) return false;
|
||||
const sources=week.days.filter(item=>(item.ids||[]).includes(id));
|
||||
|
|
@ -683,7 +695,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
|||
const label=planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned';
|
||||
return label+(pending()?' · sync pending':'');
|
||||
}
|
||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,stageAvailabilityDefaults,disableAvailabilityDefaults,review,previewReflow,applyReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,stageAvailabilityDefaults,disableAvailabilityDefaults,review,previewReflow,applyReflow,previewCapacityReflow,applyCapacityReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
||||
keepLocal,useRemote,promote,pullItem,pendingPull,resumePull,pullConflict,resolvePullConflict,flushPull,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
|
||||
item:id=>pendingItems[id]||confirmedItems[id]||null,
|
||||
offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})};
|
||||
|
|
|
|||
|
|
@ -334,12 +334,56 @@ console.log(JSON.stringify({staged,restored}));
|
|||
assert "private@example.com" not in json.dumps(result)
|
||||
|
||||
|
||||
def test_calendar_refresh_compares_load_and_applies_overloaded_capacity_with_one_atomic_reflow():
|
||||
result = run_import("""
|
||||
const elements=new Map();
|
||||
const element=(value='')=>({value,checked:false,hidden:false,textContent:'',innerHTML:'',disabled:false,files:[],listeners:{},
|
||||
addEventListener(name,listener){this.listeners[name]=listener;},focus(){}});
|
||||
for(const selector of ['#week-capacity-import','#week-capacity-review','#week-capacity-days','#week-capacity-status',
|
||||
'#week-capacity-file','#week-capacity-start','#week-capacity-end','#keep-week-free-times',
|
||||
'#apply-week-capacities','#cancel-week-capacity-import']) elements.set(selector,element());
|
||||
elements.get('#week-capacity-start').value='09:00';elements.get('#week-capacity-end').value='17:00';
|
||||
elements.get('#keep-week-free-times').checked=true;
|
||||
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
||||
const current=dates.map((date,index)=>({plan_date:date,capacity_minutes:480,planned_minutes:index===0?90:index===1?30:0,
|
||||
ids:index===0?['one','two']:index===1?['three']:[],estimates:index===0?{one:45,two:45}:index===1?{three:30}:{}}));
|
||||
let atomicCalls=0,ordinaryCalls=0,flushes=0,received=null;
|
||||
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),review:()=>({days:current}),
|
||||
previewCapacityReflow:values=>({blockers:[],unscheduled:[],days:values.map((value,index)=>({...value,
|
||||
ids:index===0?['one']:index===1?['two','three']:[],estimates:index===0?{one:45}:index===1?{two:45,three:30}:{}}))}),
|
||||
applyCapacityReflow:values=>{atomicCalls+=1;received=values;return {sync_pending:true};},
|
||||
stageCapacities:()=>{ordinaryCalls+=1;return {sync_pending:true};},flush:async()=>{flushes+=1;return {};}};
|
||||
const workflow=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector)});
|
||||
const source=`BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Private meeting name\r\nATTENDEE:private@example.com\r\nDTSTART:20260821T090000\r\nDTEND:20260821T163000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
||||
const reviewed=workflow.review(source);const label=elements.get('#apply-week-capacities').textContent;
|
||||
const markup=elements.get('#week-capacity-days').innerHTML;await workflow.apply();
|
||||
console.log(JSON.stringify({reviewed,label,markup,atomicCalls,ordinaryCalls,flushes,received}));
|
||||
""")
|
||||
|
||||
assert result["reviewed"][0]["capacity_minutes"] == 30
|
||||
assert "480 → 30 min available" in result["markup"]
|
||||
assert "90 min planned" in result["markup"]
|
||||
assert "60 min over refreshed capacity" in result["markup"]
|
||||
assert "Moves to Day 2" in result["markup"]
|
||||
assert result["label"] == "Apply capacities & reflow"
|
||||
assert result["atomicCalls"] == 1
|
||||
assert result["ordinaryCalls"] == 0
|
||||
assert result["flushes"] == 1
|
||||
assert result["received"][0]["free_windows"] == [
|
||||
{"start_time": "16:30", "end_time": "17:00"}
|
||||
]
|
||||
assert "Private meeting name" not in json.dumps(result)
|
||||
assert "private@example.com" not in json.dumps(result)
|
||||
|
||||
|
||||
def test_week_ahead_exposes_private_calendar_capacity_import_in_the_packaged_planning_flow():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="open-week-capacity-import"' in html
|
||||
assert '>Refresh from calendar</button>' in html
|
||||
assert '<h2 id="week-capacity-title">Refresh Week Ahead availability</h2>' in html
|
||||
assert 'id="week-capacity-import"' in html
|
||||
assert 'id="week-capacity-file"' in html and 'accept=".ics,text/calendar"' in html
|
||||
assert 'id="week-capacity-start"' in html and 'id="week-capacity-end"' in html
|
||||
|
|
|
|||
|
|
@ -311,6 +311,35 @@ console.log(JSON.stringify({before,preview,unchanged,applied,writes,pending:week
|
|||
assert result["pending"]["days"] == result["preview"]["days"]
|
||||
|
||||
|
||||
def test_week_controller_atomically_reflows_against_refreshed_calendar_capacity_and_keeps_windows():
|
||||
result = run_controller("""
|
||||
const values=new Map();let writes=0;
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>{writes+=1;values.set(key,value);},removeItem:key=>values.delete(key)};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson:async()=>({}),
|
||||
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:6,timezone:'UTC',days:[
|
||||
{plan_date:'2026-08-21',ids:['one','two'],capacity_minutes:120,estimates:{one:45,two:45},free_windows:[{start_time:'09:00',end_time:'11:00'}]},
|
||||
{plan_date:'2026-08-22',ids:['three'],capacity_minutes:120,estimates:{three:30}},
|
||||
{plan_date:'2026-08-23',ids:[],capacity_minutes:120,estimates:{}}
|
||||
]});
|
||||
const capacities=week.dates().map((item,index)=>({plan_date:item.date,capacity_minutes:index===0?45:120,
|
||||
free_windows:index===0?[{start_time:'10:00',end_time:'10:45'}]:[{start_time:'09:00',end_time:'11:00'}]}));
|
||||
const before=week.state();const preview=week.previewCapacityReflow(capacities);const unchanged=week.state();
|
||||
const applied=week.applyCapacityReflow(capacities);
|
||||
console.log(JSON.stringify({before,preview,unchanged,applied,writes,pending:week.pending()}));
|
||||
""")
|
||||
|
||||
assert result["unchanged"] == result["before"]
|
||||
assert result["writes"] == 1
|
||||
assert result["applied"]["sync_pending"] is True
|
||||
assert [day["ids"] for day in result["preview"]["days"][:2]] == [["one"], ["two", "three"]]
|
||||
assert result["preview"]["days"][0]["free_windows"] == [
|
||||
{"start_time": "10:00", "end_time": "10:45"}
|
||||
]
|
||||
assert result["pending"]["days"] == result["preview"]["days"]
|
||||
assert [item for day in result["pending"]["days"] for item in day["ids"]] == ["one", "two", "three"]
|
||||
|
||||
|
||||
def test_week_controller_reflow_never_places_more_than_five_items_on_a_day():
|
||||
result = run_controller("""
|
||||
const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user