feat: persist exact Week Ahead task times (Closes #1270)
All checks were successful
CI / lint (pull_request) Successful in 4m3s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m22s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-22 16:37:25 +00:00
parent f78da3dfc9
commit 5fb4da30a4
11 changed files with 180 additions and 18 deletions

View File

@ -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 h3 { margin:0; }
.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 input { width:22px; height:22px; }
.week-calendar-item span { display:grid; gap:3px; }
.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-choice { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:center; min-height:44px; }
.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-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; }
@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 .mobile-plan-today-nav,
.plan-today-sheet.week-review-mode #plan-today-fit,

View File

@ -461,6 +461,7 @@
t:v=>v?latestTodayPlan=v:latestTodayPlan,r:refreshMyWorkView,w:warmTodayOffline,
x:currentTodayProgressTarget});
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();}});
const todaySync=createTodaySync({storage:localStorage,getLogin: () => planningOwnerLogin,fetchJson:fetchReviewJson,
coordinator:outboxCoordinator,

View File

@ -593,6 +593,7 @@
<div class="small week-calendar-status" id="week-calendar-status" role="status" aria-live="assertive"></div>
<div class="week-calendar-actions">
<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>
</div>
</section>

View File

@ -24,22 +24,39 @@
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';
const windows=availableByDate.has(day.plan_date)?availableByDate.get(day.plan_date):(day.free_windows||null);
const candidates=[];
(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 exact=startTimes?.[id]||day.start_times?.[id];
let start=exact||cursor;
if(!exact&&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;
start=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;
const startMinute=clockMinutes(start),endMinute=startMinute+minutes;
candidates.push({item,id,minutes,start,startMinute,endMinute,exact:Boolean(exact)});
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};
@ -70,12 +87,15 @@
finally{urlApi.revokeObjectURL(href);}
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}={}){
const handoff=qs('#week-calendar-handoff'),review=qs('#week-review'),daysRoot=qs('#week-calendar-days');
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 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 blocks=()=>schedule().blocks;
function update(){
@ -86,6 +106,7 @@
(blocker?'Does not fit imported free time':'Excluded from calendar');
});
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){
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.';
@ -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'))]));
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>'+
day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96);
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')+
'</strong><small>'+escapeHtml(reference)+'</small><small data-week-calendar-preview="'+escapeAttribute(id)+'"></small></span></label>';}).join('')+'</article>').join('');
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 '<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><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));
update();qs('#back-to-week-review-from-calendar').focus?.();return 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('#share-week-calendar').addEventListener('click',async()=>{
const button=qs('#share-week-calendar'),current=blocks();if(!current.length)return;

View File

@ -14,10 +14,13 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
const cloneWindows=value=>Array.isArray(value)?value.map(window=>({
start_time:window.start_time,end_time:window.end_time,
})):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=>({
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
estimates:{...(day.estimates||{})},
...(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',
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([{
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
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) {
const changed=capacityDays(values);
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';
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,
item:id=>pendingItems[id]||confirmedItems[id]||null,
offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})};

View File

@ -738,6 +738,7 @@ class WeekPlanDay(BaseModel):
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
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):

View File

@ -578,6 +578,39 @@ class TodayStore:
})
previous_end = end
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"])
if duplicate_ids:
raise ValueError("work must be assigned to only one Week Ahead date")

View File

@ -328,9 +328,18 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
expect(start).to_have_value("09:00")
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("10:0010:45 · 45 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")):
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:0014: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()
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()
expect(page.locator("#week-review")).to_be_visible()
confirm.click()

View File

@ -6,6 +6,7 @@ from pathlib import Path
WEEK_CALENDAR = Path(__file__).parents[1] / "frontend" / "week-calendar.js"
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
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"
@ -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():
source = f"""
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-days"' 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="export-saved-week-calendar"' in index
assert "Add saved week to calendar" in index

View File

@ -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():
payload = main.WeekPlanUpdate.model_validate({
"base_revision": 2, "timezone": "UTC", "days": [],

View File

@ -276,6 +276,29 @@ console.log(JSON.stringify({pending,request:requests[0],restored:restored.state(
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():
result = run_controller("""
const values=new Map();let writes=0;