feat: persist exact Week Ahead task times (Closes #1270)
This commit is contained in:
parent
f78da3dfc9
commit
5fb4da30a4
|
|
@ -415,13 +415,17 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.week-calendar-day header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
.week-calendar-day header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||||
.week-calendar-day h3 { margin:0; }
|
.week-calendar-day h3 { margin:0; }
|
||||||
.week-calendar-day input[type="time"] { min-height:44px; font-size:16px; }
|
.week-calendar-day input[type="time"] { min-height:44px; font-size:16px; }
|
||||||
.week-calendar-item { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:center; min-height:44px; padding:8px 0; overflow-wrap:anywhere; }
|
.week-calendar-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; align-items:center; min-width:0; padding:8px 0; overflow-wrap:anywhere; }
|
||||||
.week-calendar-item input { width:22px; height:22px; }
|
.week-calendar-choice { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:center; min-height:44px; }
|
||||||
.week-calendar-item span { display:grid; gap:3px; }
|
.week-calendar-choice input { width:22px; height:22px; }
|
||||||
|
.week-calendar-choice span { display:grid; gap:3px; }
|
||||||
|
.week-calendar-time { display:grid; gap:2px; font-size:12px; font-weight:700; }
|
||||||
|
.week-calendar-time input { box-sizing:border-box; width:7.5rem; min-height:44px; font-size:16px; }
|
||||||
.week-calendar-item small { color:#a9bdd3; }
|
.week-calendar-item small { color:#a9bdd3; }
|
||||||
.week-calendar-status { min-height:1.4em; margin:10px 0; }
|
.week-calendar-status { min-height:1.4em; margin:10px 0; }
|
||||||
.week-calendar-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 2fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#0b1526; }
|
.week-calendar-actions { position:sticky; bottom:0; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#0b1526; }
|
||||||
.week-calendar-actions button { min-width:0; min-height:48px; }
|
.week-calendar-actions button { min-width:0; min-height:48px; }
|
||||||
|
@media(max-width:359px) { .week-calendar-item, .week-calendar-actions { grid-template-columns:1fr; } .week-calendar-time input { width:100%; } }
|
||||||
.plan-today-sheet.week-review-mode .week-plan-dates,
|
.plan-today-sheet.week-review-mode .week-plan-dates,
|
||||||
.plan-today-sheet.week-review-mode .mobile-plan-today-nav,
|
.plan-today-sheet.week-review-mode .mobile-plan-today-nav,
|
||||||
.plan-today-sheet.week-review-mode #plan-today-fit,
|
.plan-today-sheet.week-review-mode #plan-today-fit,
|
||||||
|
|
|
||||||
|
|
@ -461,6 +461,7 @@
|
||||||
t:v=>v?latestTodayPlan=v:latestTodayPlan,r:refreshMyWorkView,w:warmTodayOffline,
|
t:v=>v?latestTodayPlan=v:latestTodayPlan,r:refreshMyWorkView,w:warmTodayOffline,
|
||||||
x:currentTodayProgressTarget});
|
x:currentTodayProgressTarget});
|
||||||
const wc=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekFlow.i,escapeHtml,escapeAttribute:escAttr,
|
const wc=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekFlow.i,escapeHtml,escapeAttribute:escAttr,
|
||||||
|
onSave:async byDate=>{Object.entries(byDate).forEach(([planDate,startTimes])=>weekPlan.stageStartTimes(planDate,startTimes));await weekPlan.flush();},
|
||||||
onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();}});
|
onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();}});
|
||||||
const todaySync=createTodaySync({storage:localStorage,getLogin: () => planningOwnerLogin,fetchJson:fetchReviewJson,
|
const todaySync=createTodaySync({storage:localStorage,getLogin: () => planningOwnerLogin,fetchJson:fetchReviewJson,
|
||||||
coordinator:outboxCoordinator,
|
coordinator:outboxCoordinator,
|
||||||
|
|
|
||||||
|
|
@ -593,6 +593,7 @@
|
||||||
<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">
|
||||||
<button id="back-to-week-review-from-calendar" type="button">Back to review</button>
|
<button id="back-to-week-review-from-calendar" type="button">Back to review</button>
|
||||||
|
<button id="save-week-calendar-times" type="button">Save exact times</button>
|
||||||
<button id="share-week-calendar" type="button">Share calendar blocks</button>
|
<button id="share-week-calendar" type="button">Share calendar blocks</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -24,22 +24,39 @@
|
||||||
const blocks=[],blockers=[],availableByDate=new Map((availability||[]).map(day=>[day.plan_date,day.free_windows||[]]));
|
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';
|
||||||
|
const windows=availableByDate.has(day.plan_date)?availableByDate.get(day.plan_date):(day.free_windows||null);
|
||||||
|
const candidates=[];
|
||||||
(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);
|
const exact=startTimes?.[id]||day.start_times?.[id];
|
||||||
if(windows){
|
let start=exact||cursor;
|
||||||
|
if(!exact&&windows){
|
||||||
const fit=windows.find(window=>{
|
const fit=windows.find(window=>{
|
||||||
const candidate=Math.max(clockMinutes(cursor),clockMinutes(window.start_time));
|
const candidate=Math.max(clockMinutes(cursor),clockMinutes(window.start_time));
|
||||||
if(candidate+minutes>clockMinutes(window.end_time))return false;
|
if(candidate+minutes>clockMinutes(window.end_time))return false;
|
||||||
cursor=addMinutes('00:00',candidate);return true;
|
start=addMinutes('00:00',candidate);return true;
|
||||||
});
|
});
|
||||||
if(!fit){blockers.push({id,title:item.title||'Untitled work',plan_date:day.plan_date,minutes});return;}
|
if(!fit){blockers.push({id,title:item.title||'Untitled work',plan_date:day.plan_date,minutes});return;}
|
||||||
}
|
}
|
||||||
const end=addMinutes(cursor,minutes);
|
const startMinute=clockMinutes(start),endMinute=startMinute+minutes;
|
||||||
blocks.push({...item,id,plan_date:day.plan_date,start_time:cursor,end_time:end,minutes});
|
candidates.push({item,id,minutes,start,startMinute,endMinute,exact:Boolean(exact)});
|
||||||
cursor=end;
|
if(!exact)cursor=addMinutes('00:00',endMinute);
|
||||||
|
});
|
||||||
|
const invalid=new Set();
|
||||||
|
candidates.forEach(candidate=>{
|
||||||
|
if(candidate.endMinute>1440||windows&&!windows.some(window=>clockMinutes(window.start_time)<=candidate.startMinute&&candidate.endMinute<=clockMinutes(window.end_time)))invalid.add(candidate.id);
|
||||||
|
});
|
||||||
|
candidates.forEach((left,index)=>candidates.slice(index+1).forEach(right=>{
|
||||||
|
if(left.startMinute<right.endMinute&&left.endMinute>right.startMinute){invalid.add(left.id);invalid.add(right.id);}
|
||||||
|
}));
|
||||||
|
candidates.forEach(candidate=>{
|
||||||
|
if(invalid.has(candidate.id)){
|
||||||
|
blockers.push({id:candidate.id,title:candidate.item.title||'Untitled work',plan_date:day.plan_date,minutes:candidate.minutes,reason:'invalid-exact-time'});return;
|
||||||
|
}
|
||||||
|
blocks.push({...candidate.item,id:candidate.id,plan_date:day.plan_date,start_time:candidate.start,
|
||||||
|
end_time:addMinutes('00:00',candidate.endMinute),minutes:candidate.minutes});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return {blocks,blockers};
|
return {blocks,blockers};
|
||||||
|
|
@ -70,12 +87,15 @@
|
||||||
finally{urlApi.revokeObjectURL(href);}
|
finally{urlApi.revokeObjectURL(href);}
|
||||||
return 'downloaded';
|
return 'downloaded';
|
||||||
}
|
}
|
||||||
function mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,escapeHtml,escapeAttribute,onDone,windowObject=root,navigatorObject=root.navigator,
|
function mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,escapeHtml,escapeAttribute,onSave,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,focusReturn=null;
|
let plan=null,focusReturn=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]),
|
||||||
|
...Array.from(daysRoot.querySelectorAll('[data-week-calendar-exact]')).filter(input=>input.value).map(input=>[input.dataset.weekCalendarExact,input.value]),
|
||||||
|
]);
|
||||||
const schedule=()=>buildWeekSchedule(plan,starts(),getItem,selected(),getAvailability?.());
|
const schedule=()=>buildWeekSchedule(plan,starts(),getItem,selected(),getAvailability?.());
|
||||||
const blocks=()=>schedule().blocks;
|
const blocks=()=>schedule().blocks;
|
||||||
function update(){
|
function update(){
|
||||||
|
|
@ -86,6 +106,7 @@
|
||||||
(blocker?'Does not fit imported free time':'Excluded from calendar');
|
(blocker?'Does not fit imported free time':'Excluded from calendar');
|
||||||
});
|
});
|
||||||
qs('#share-week-calendar').disabled=!current.blocks.length||current.blockers.length>0;
|
qs('#share-week-calendar').disabled=!current.blocks.length||current.blockers.length>0;
|
||||||
|
qs('#save-week-calendar-times').disabled=!current.blocks.length||current.blockers.length>0;
|
||||||
if(current.blockers.length){
|
if(current.blockers.length){
|
||||||
const blocker=current.blockers[0];
|
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.';
|
qs('#week-calendar-status').textContent=blocker.title+' does not fit free time on '+blocker.plan_date+'. Adjust the start, estimate, or calendar import.';
|
||||||
|
|
@ -101,13 +122,22 @@
|
||||||
const labels=new Map((value.days||[]).map(day=>[day.plan_date,new Intl.DateTimeFormat('en',{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'}).format(new Date(day.plan_date+'T12:00:00Z'))]));
|
const labels=new Map((value.days||[]).map(day=>[day.plan_date,new Intl.DateTimeFormat('en',{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'}).format(new Date(day.plan_date+'T12:00:00Z'))]));
|
||||||
daysRoot.innerHTML=(value.days||[]).filter(day=>day.ids?.length).map(day=>'<article class="week-calendar-day"><header><h3>'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+
|
daysRoot.innerHTML=(value.days||[]).filter(day=>day.ids?.length).map(day=>'<article class="week-calendar-day"><header><h3>'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+
|
||||||
'</h3><label>Start <input type="time" value="09:00" data-week-calendar-start="'+escapeAttribute(day.plan_date)+'"></label></header>'+
|
'</h3><label>Start <input type="time" value="09:00" data-week-calendar-start="'+escapeAttribute(day.plan_date)+'"></label></header>'+
|
||||||
day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96);
|
day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96),saved=day.start_times?.[id]||'';
|
||||||
return '<label class="week-calendar-item"><input type="checkbox" data-week-calendar-item value="'+escapeAttribute(id)+'" checked><span><strong>'+escapeHtml(item?.title||'Work details unavailable')+
|
return '<div class="week-calendar-item"><label class="week-calendar-choice"><input type="checkbox" data-week-calendar-item value="'+escapeAttribute(id)+'" checked><span><strong>'+escapeHtml(item?.title||'Work details unavailable')+
|
||||||
'</strong><small>'+escapeHtml(reference)+'</small><small data-week-calendar-preview="'+escapeAttribute(id)+'"></small></span></label>';}).join('')+'</article>').join('');
|
'</strong><small>'+escapeHtml(reference)+'</small><small data-week-calendar-preview="'+escapeAttribute(id)+'"></small></span></label><label class="week-calendar-time">Start<input type="time" value="'+escapeAttribute(saved)+'" data-week-calendar-exact="'+escapeAttribute(id)+'" aria-label="Exact start for '+escapeAttribute(item?.title||'work')+'"></label></div>';}).join('')+'</article>').join('');
|
||||||
daysRoot.querySelectorAll('input').forEach(input=>input.addEventListener('change',update));
|
daysRoot.querySelectorAll('input').forEach(input=>input.addEventListener('change',update));
|
||||||
update();qs('#back-to-week-review-from-calendar').focus?.();return true;
|
update();qs('#back-to-week-review-from-calendar').focus?.();return true;
|
||||||
}
|
}
|
||||||
qs('#back-to-week-review-from-calendar').addEventListener('click',()=>close({back:true}));
|
qs('#back-to-week-review-from-calendar').addEventListener('click',()=>close({back:true}));
|
||||||
|
qs('#save-week-calendar-times').addEventListener('click',async()=>{
|
||||||
|
const button=qs('#save-week-calendar-times'),current=schedule();if(!current.blocks.length||current.blockers.length)return;
|
||||||
|
button.disabled=true;qs('#week-calendar-status').textContent='Saving exact task times…';
|
||||||
|
const byDate={};current.blocks.forEach(block=>(byDate[block.plan_date]||(byDate[block.plan_date]={}))[block.id]=block.start_time);
|
||||||
|
try{await onSave?.(byDate);plan={...plan,days:(plan.days||[]).map(day=>({...day,start_times:{...(byDate[day.plan_date]||{})}}))};
|
||||||
|
qs('#week-calendar-status').textContent='Exact task times saved to Week Ahead.';}
|
||||||
|
catch(error){qs('#week-calendar-status').textContent=(error?.message||'Exact times could not be saved.')+' Retry when connected.';}
|
||||||
|
finally{button.disabled=false;}
|
||||||
|
});
|
||||||
qs('#export-saved-week-calendar')?.addEventListener('click',event=>{focusReturn=event.currentTarget;qs('#confirm-week-plan').click();});
|
qs('#export-saved-week-calendar')?.addEventListener('click',event=>{focusReturn=event.currentTarget;qs('#confirm-week-plan').click();});
|
||||||
qs('#share-week-calendar').addEventListener('click',async()=>{
|
qs('#share-week-calendar').addEventListener('click',async()=>{
|
||||||
const button=qs('#share-week-calendar'),current=blocks();if(!current.length)return;
|
const button=qs('#share-week-calendar'),current=blocks();if(!current.length)return;
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,13 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
const cloneWindows=value=>Array.isArray(value)?value.map(window=>({
|
const cloneWindows=value=>Array.isArray(value)?value.map(window=>({
|
||||||
start_time:window.start_time,end_time:window.end_time,
|
start_time:window.start_time,end_time:window.end_time,
|
||||||
})):null;
|
})):null;
|
||||||
|
const cloneStartTimes=day=>Object.fromEntries(Object.entries(day.start_times||{})
|
||||||
|
.filter(([id,value])=>(day.ids||[]).includes(id)&&/^\d{2}:\d{2}$/.test(value)));
|
||||||
const cloneDay=day=>({
|
const cloneDay=day=>({
|
||||||
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
|
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
|
||||||
estimates:{...(day.estimates||{})},
|
estimates:{...(day.estimates||{})},
|
||||||
...(cloneWindows(day.free_windows)?{free_windows:cloneWindows(day.free_windows)}:{}),
|
...(cloneWindows(day.free_windows)?{free_windows:cloneWindows(day.free_windows)}:{}),
|
||||||
|
...(Object.keys(cloneStartTimes(day)).length?{start_times:cloneStartTimes(day)}:{}),
|
||||||
});
|
});
|
||||||
const sanitizeItem=value=>value?{kind:value.kind==='pull'?'pull':'issue',
|
const sanitizeItem=value=>value?{kind:value.kind==='pull'?'pull':'issue',
|
||||||
title:String(value.title||'').slice(0,500),repository:String(value.repository||'').slice(0,255),
|
title:String(value.title||'').slice(0,500),repository:String(value.repository||'').slice(0,255),
|
||||||
|
|
@ -206,8 +209,16 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
return stageDays(week.days.filter(item=>item.plan_date!==planDate).concat([{
|
return stageDays(week.days.filter(item=>item.plan_date!==planDate).concat([{
|
||||||
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
|
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
|
||||||
estimates:{...(value.estimates||{})},
|
estimates:{...(value.estimates||{})},
|
||||||
|
...(cloneWindows(value.free_windows)?{free_windows:cloneWindows(value.free_windows)}:{}),
|
||||||
|
...(Object.keys(cloneStartTimes(value)).length?{start_times:cloneStartTimes(value)}:{}),
|
||||||
}]));
|
}]));
|
||||||
}
|
}
|
||||||
|
function stageStartTimes(planDate,startTimes) {
|
||||||
|
const value=day(planDate);
|
||||||
|
if(!value||!startTimes||typeof startTimes!=='object'||Array.isArray(startTimes))return false;
|
||||||
|
value.start_times=Object.fromEntries(value.ids.filter(id=>typeof startTimes[id]==='string').map(id=>[id,startTimes[id]]));
|
||||||
|
return stageDay(planDate,value);
|
||||||
|
}
|
||||||
function stageCapacities(values) {
|
function stageCapacities(values) {
|
||||||
const changed=capacityDays(values);
|
const changed=capacityDays(values);
|
||||||
return changed&&stageDays(changed);
|
return changed&&stageDays(changed);
|
||||||
|
|
@ -695,7 +706,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';
|
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 label+(pending()?' · sync pending':'');
|
||||||
}
|
}
|
||||||
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,
|
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageStartTimes,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,
|
keepLocal,useRemote,promote,pullItem,pendingPull,resumePull,pullConflict,resolvePullConflict,flushPull,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
|
||||||
item:id=>pendingItems[id]||confirmedItems[id]||null,
|
item:id=>pendingItems[id]||confirmedItems[id]||null,
|
||||||
offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})};
|
offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})};
|
||||||
|
|
|
||||||
|
|
@ -738,6 +738,7 @@ class WeekPlanDay(BaseModel):
|
||||||
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
|
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
|
||||||
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
|
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
|
||||||
free_windows: list[WeekFreeWindow] | None = Field(default=None, max_length=16)
|
free_windows: list[WeekFreeWindow] | None = Field(default=None, max_length=16)
|
||||||
|
start_times: dict[str, str] = Field(default_factory=dict, max_length=5)
|
||||||
|
|
||||||
|
|
||||||
class WeekPlanUpdate(BaseModel):
|
class WeekPlanUpdate(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -578,6 +578,39 @@ class TodayStore:
|
||||||
})
|
})
|
||||||
previous_end = end
|
previous_end = end
|
||||||
plan["free_windows"] = normalized_windows
|
plan["free_windows"] = normalized_windows
|
||||||
|
if "start_times" in day:
|
||||||
|
start_times = day["start_times"]
|
||||||
|
if not isinstance(start_times, dict) or len(start_times) > 5:
|
||||||
|
raise ValueError("task start times must be a bounded object")
|
||||||
|
if not set(start_times).issubset(plan["ids"]):
|
||||||
|
raise ValueError("task start times must belong to work on the same date")
|
||||||
|
occupied = []
|
||||||
|
for identity in plan["ids"]:
|
||||||
|
if identity not in start_times:
|
||||||
|
continue
|
||||||
|
value = start_times[identity]
|
||||||
|
if (
|
||||||
|
not isinstance(value, str) or len(value) != 5 or value[2] != ":"
|
||||||
|
or not value[:2].isdigit() or not value[3:].isdigit()
|
||||||
|
or int(value[:2]) > 23 or int(value[3:]) > 59
|
||||||
|
):
|
||||||
|
raise ValueError("task start times must use HH:mm times")
|
||||||
|
start = int(value[:2]) * 60 + int(value[3:])
|
||||||
|
minutes = plan["estimates"].get(identity)
|
||||||
|
if not isinstance(minutes, int) or start + minutes > 1440:
|
||||||
|
raise ValueError("task start times must fit their estimates within the date")
|
||||||
|
end = start + minutes
|
||||||
|
if any(start < other_end and end > other_start for other_start, other_end in occupied):
|
||||||
|
raise ValueError("task start times must not overlap")
|
||||||
|
windows = plan.get("free_windows")
|
||||||
|
if windows and not any(
|
||||||
|
int(window["start_time"][:2]) * 60 + int(window["start_time"][3:]) <= start
|
||||||
|
and end <= int(window["end_time"][:2]) * 60 + int(window["end_time"][3:])
|
||||||
|
for window in windows
|
||||||
|
):
|
||||||
|
raise ValueError("task start times must fit retained free windows")
|
||||||
|
occupied.append((start, end))
|
||||||
|
plan["start_times"] = {identity: start_times[identity] for identity in plan["ids"] if identity in start_times}
|
||||||
duplicate_ids = seen_ids.intersection(plan["ids"])
|
duplicate_ids = seen_ids.intersection(plan["ids"])
|
||||||
if duplicate_ids:
|
if duplicate_ids:
|
||||||
raise ValueError("work must be assigned to only one Week Ahead date")
|
raise ValueError("work must be assigned to only one Week Ahead date")
|
||||||
|
|
|
||||||
|
|
@ -328,9 +328,18 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||||
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("10:00–10:45 · 45 min")
|
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("10:00–10:45 · 45 min")
|
||||||
expect(page.locator("#week-calendar-status")).to_contain_text("Planning around imported busy time")
|
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")):
|
exact = page.locator('[data-week-calendar-exact="issue:acme/mobile:41:"]')
|
||||||
|
expect(exact).to_have_value("")
|
||||||
|
exact.fill("14:00")
|
||||||
|
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("14:00–14:45 · 45 min")
|
||||||
|
save_times = page.locator("#save-week-calendar-times")
|
||||||
|
for control in (start, exact, page.locator("#back-to-week-review-from-calendar"), save_times, 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
|
||||||
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
save_times.click()
|
||||||
|
expect(page.locator("#week-calendar-status")).to_have_text("Exact task times saved to Week Ahead.")
|
||||||
|
assert saved[-1]["days"][0]["start_times"] == {"issue:acme/mobile:41:": "14:00"}
|
||||||
page.locator("#back-to-week-review-from-calendar").click()
|
page.locator("#back-to-week-review-from-calendar").click()
|
||||||
expect(page.locator("#week-review")).to_be_visible()
|
expect(page.locator("#week-review")).to_be_visible()
|
||||||
confirm.click()
|
confirm.click()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from pathlib import Path
|
||||||
WEEK_CALENDAR = Path(__file__).parents[1] / "frontend" / "week-calendar.js"
|
WEEK_CALENDAR = Path(__file__).parents[1] / "frontend" / "week-calendar.js"
|
||||||
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||||
|
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||||
BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py"
|
BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -117,6 +118,26 @@ console.log(JSON.stringify(result));
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_calendar_honors_exact_saved_task_times_and_rejects_conflicts():
|
||||||
|
source = f"""
|
||||||
|
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
|
||||||
|
const items={{one:{{title:'First'}},two:{{title:'Second'}}}};
|
||||||
|
const plan={{days:[{{plan_date:'2026-08-21',ids:['one','two'],estimates:{{one:45,two:90}},
|
||||||
|
free_windows:[{{start_time:'09:00',end_time:'10:00'}},{{start_time:'13:00',end_time:'17:00'}}],
|
||||||
|
start_times:{{one:'09:00',two:'14:00'}}}}]}};
|
||||||
|
const saved=calendar.buildWeekSchedule(plan,{{}},id=>items[id]);
|
||||||
|
const conflict=calendar.buildWeekSchedule(plan,{{one:'14:30',two:'14:00'}},id=>items[id]);
|
||||||
|
console.log(JSON.stringify({{saved,conflict}}));
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = run_node(source)
|
||||||
|
assert [(block["id"], block["start_time"], block["end_time"]) for block in result["saved"]["blocks"]] == [
|
||||||
|
("one", "09:00", "09:45"), ("two", "14:00", "15:30")
|
||||||
|
]
|
||||||
|
assert result["conflict"]["blocks"] == []
|
||||||
|
assert {blocker["id"] for blocker in result["conflict"]["blockers"]} == {"one", "two"}
|
||||||
|
|
||||||
|
|
||||||
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))});
|
||||||
|
|
@ -150,6 +171,10 @@ def test_week_calendar_handoff_is_wired_into_confirmation_with_mobile_privacy_co
|
||||||
assert 'id="week-calendar-handoff"' in index
|
assert 'id="week-calendar-handoff"' in index
|
||||||
assert 'id="week-calendar-days"' in index
|
assert 'id="week-calendar-days"' in index
|
||||||
assert 'id="share-week-calendar"' in index
|
assert 'id="share-week-calendar"' in index
|
||||||
|
assert 'id="save-week-calendar-times"' in index
|
||||||
|
assert 'data-week-calendar-exact' in WEEK_CALENDAR.read_text()
|
||||||
|
assert "onSave" in WEEK_CALENDAR.read_text()
|
||||||
|
assert 'min-height:44px' in CSS.read_text()[CSS.read_text().index('.week-calendar-time'):]
|
||||||
assert 'id="back-to-week-review-from-calendar"' in index
|
assert 'id="back-to-week-review-from-calendar"' in index
|
||||||
assert 'id="export-saved-week-calendar"' in index
|
assert 'id="export-saved-week-calendar"' in index
|
||||||
assert "Add saved week to calendar" in index
|
assert "Add saved week to calendar" in index
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,30 @@ def test_week_plan_persists_only_bounded_non_overlapping_calendar_free_windows(t
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_plan_persists_only_valid_exact_task_start_times(tmp_path):
|
||||||
|
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"w" * 32)
|
||||||
|
days = sample_days()
|
||||||
|
days[0]["start_times"] = {
|
||||||
|
"issue:secret/repo:3:": "09:00",
|
||||||
|
"issue:secret/repo:2:": "10:15",
|
||||||
|
}
|
||||||
|
|
||||||
|
saved = store.replace_week(
|
||||||
|
"timmy", base_revision=0, days=days, timezone="UTC",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert saved["days"][0]["start_times"] == days[0]["start_times"]
|
||||||
|
assert store.get_week("timmy") == saved
|
||||||
|
assert b"09:00" not in (tmp_path / "today.sqlite3").read_bytes()
|
||||||
|
|
||||||
|
invalid = sample_days()
|
||||||
|
invalid[0]["start_times"] = {"issue:secret/repo:3:": "23:30"}
|
||||||
|
with pytest.raises(ValueError, match="task start times"):
|
||||||
|
store.replace_week(
|
||||||
|
"timmy", base_revision=1, days=invalid, timezone="UTC",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_week_update_model_accepts_zero_capacity_weekday_defaults():
|
def test_week_update_model_accepts_zero_capacity_weekday_defaults():
|
||||||
payload = main.WeekPlanUpdate.model_validate({
|
payload = main.WeekPlanUpdate.model_validate({
|
||||||
"base_revision": 2, "timezone": "UTC", "days": [],
|
"base_revision": 2, "timezone": "UTC", "days": [],
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,29 @@ console.log(JSON.stringify({pending,request:requests[0],restored:restored.state(
|
||||||
assert result["restored"]["days"][0]["free_windows"] == windows
|
assert result["restored"]["days"][0]["free_windows"] == windows
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_controller_keeps_exact_task_times_through_pending_flush_and_reload():
|
||||||
|
result = run_controller("""
|
||||||
|
const values=new Map();const requests=[];let remote={revision:4,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['one','two'],capacity_minutes:420,
|
||||||
|
estimates:{one:45,two:90},start_times:{one:'09:00'}}]};
|
||||||
|
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||||
|
const fetchJson=async(url,options={})=>{
|
||||||
|
if(!options.method)return remote;
|
||||||
|
const body=JSON.parse(options.body);requests.push(body);remote={revision:5,timezone:body.timezone,days:body.days};return remote;
|
||||||
|
};
|
||||||
|
const options={storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'};
|
||||||
|
const first=createWeekPlan(options);await first.load();
|
||||||
|
first.stageStartTimes('2026-08-21',{one:'09:00',two:'11:00'});
|
||||||
|
const pending=first.pending();await first.flush();
|
||||||
|
const restored=createWeekPlan(options);await restored.load();
|
||||||
|
console.log(JSON.stringify({pending,request:requests[0],restored:restored.state()}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
expected = {"one": "09:00", "two": "11:00"}
|
||||||
|
assert result["pending"]["days"][0]["start_times"] == expected
|
||||||
|
assert result["request"]["days"][0]["start_times"] == expected
|
||||||
|
assert result["restored"]["days"][0]["start_times"] == expected
|
||||||
|
|
||||||
|
|
||||||
def test_week_controller_previews_and_atomically_applies_a_capacity_safe_reflow():
|
def test_week_controller_previews_and_atomically_applies_a_capacity_safe_reflow():
|
||||||
result = run_controller("""
|
result = run_controller("""
|
||||||
const values=new Map();let writes=0;
|
const values=new Map();let writes=0;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user