Merge pull request 'Export a saved Week Ahead plan directly from the overview' (#1261) from timmy/1260-export-saved-week-calendar into main
Some checks failed
CI / lint (push) Successful in 4m2s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Failing after 5m58s
CI / release-candidate (push) Has been skipped

This commit is contained in:
rockachopa 2026-08-22 11:31:03 +00:00
commit 76f89df609
7 changed files with 71 additions and 48 deletions

View File

@ -371,6 +371,7 @@ textarea { resize: vertical; min-height: 120px; }
#retry-week-live { min-height:44px; width:100%; margin-bottom:8px; border-color:#f59e0b; } #retry-week-live { min-height:44px; width:100%; margin-bottom:8px; border-color:#f59e0b; }
#confirm-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; } #confirm-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
#edit-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; } #edit-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
#export-saved-week-calendar { width:100%; min-height:48px; margin:0 0 8px; }
#open-week-capacity-import { width:100%; min-height:44px; margin:8px 0 12px; } #open-week-capacity-import { width:100%; min-height:44px; margin:8px 0 12px; }
#open-week-reflow { width:100%; min-height:48px; margin:0 0 12px; border-color:#60a5fa; } #open-week-reflow { width:100%; min-height:48px; margin:0 0 12px; border-color:#60a5fa; }
.week-reflow-review { box-sizing:border-box; width:100%; margin:0 0 14px; padding:14px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; overflow-x:hidden; } .week-reflow-review { box-sizing:border-box; width:100%; margin:0 0 14px; padding:14px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; overflow-x:hidden; }

View File

@ -460,46 +460,33 @@
escapeHtml,escapeAttribute:escAttr,todayWork, escapeHtml,escapeAttribute:escAttr,todayWork,
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 weekCalendar=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekItem,escapeHtml,escapeAttribute:escAttr, const wc=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekFlow.i,escapeHtml,escapeAttribute:escAttr,
onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();}, onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();}});
}); const todaySync=createTodaySync({storage:localStorage,getLogin: () => planningOwnerLogin,fetchJson:fetchReviewJson,
const todaySync = createTodaySync({ coordinator:outboxCoordinator,
storage: localStorage, onRemoteIds:ids=>{if(!planningOwnerLogin||!todayWork.replace(ids))return;refreshMyWorkView();warmTodayOffline();},
getLogin: () => planningOwnerLogin, onRemotePlan:plan=>{
fetchJson: fetchReviewJson, if(!planningOwnerLogin)return;
coordinator: outboxCoordinator, latestTodayPlan=plan;
onRemoteIds: ids => {
if (!planningOwnerLogin || !todayWork.replace(ids)) return;
refreshMyWorkView();
warmTodayOffline();
},
onRemotePlan: plan => {
if (!planningOwnerLogin) return;
latestTodayPlan = plan;
weekFlow.promote(plan).finally(() => { weekFlow.promote(plan).finally(() => {
if (window.location.hash !== '#/my-work/start-day') return; if (window.location.hash !== '#/my-work/start-day') return;
window.history.replaceState({}, '', '#/my-work/today'); window.history.replaceState({}, '', '#/my-work/today');
openMobileStartDay(); openMobileStartDay();
}); });
todayWork.replacePlanning({ todayWork.replacePlanning({capacity_minutes:plan.capacity_minutes??null,estimates:plan.estimates||{}});
capacity_minutes: plan.capacity_minutes ?? null, const s=todayRollover.reviewState(plan);
estimates: plan.estimates || {}, if(!['stale','legacy'].includes(s)){
}); rolloverReviewPlan=null;
const s = todayRollover.reviewState(plan); qs('#plan-today').textContent='Plan Today';
if (!['stale', 'legacy'].includes(s)) {
rolloverReviewPlan = null;
qs('#plan-today').textContent = 'Plan Today';
return; return;
} }
rolloverReviewPlan = plan; rolloverReviewPlan=plan;
qs('#plan-today').textContent = 'Review new day'; qs('#plan-today').textContent='Review new day';
qs('#my-work-action-status').textContent = 'Review yesterdays unfinished work before starting today.'; qs('#my-work-action-status').textContent='Review yesterdays unfinished work before starting today.';
const marker = `stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`; const marker=`stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`;
if (!localStorage.getItem(marker)) { if(!localStorage.getItem(marker)){
localStorage.setItem(marker, '1'); localStorage.setItem(marker,'1');
setTimeout(() => { setTimeout(()=>{if(!workSession.checkpointed())openPlanToday(qs('#plan-today'));},0);
if (!workSession.checkpointed()) openPlanToday(qs('#plan-today'));
}, 0);
} }
}, },
onStatus: (state, detail = {}) => { onStatus: (state, detail = {}) => {
@ -2706,7 +2693,7 @@
} }
planToday.cancel(); planToday.cancel();
planningTomorrow = false; planningTomorrow = false;
weekCalendar.close(); wc.close();
weekFlow.clear(); weekFlow.clear();
qs('#tomorrow-conflict-review').hidden = true; qs('#tomorrow-conflict-review').hidden = true;
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode'); qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
@ -7926,7 +7913,7 @@
qs('#week-review-status').textContent=weekPlan.pending()?'Wait for Week Ahead to finish syncing before confirming.':'Move duplicated work to one date before confirming.'; qs('#week-review-status').textContent=weekPlan.pending()?'Wait for Week Ahead to finish syncing before confirming.':'Move duplicated work to one date before confirming.';
return; return;
} }
weekCalendar.open(weekPlan.state()); wc.open(weekPlan.state());
}); });
qs('#plan-today-sheet').addEventListener('click', event => { qs('#plan-today-sheet').addEventListener('click', event => {
if (event.target === qs('#plan-today-sheet')) closePlanToday(); if (event.target === qs('#plan-today-sheet')) closePlanToday();

View File

@ -560,6 +560,7 @@
<div class="week-unplan-receipt" id="week-unplan-receipt" role="status" aria-live="polite" hidden></div> <div class="week-unplan-receipt" id="week-unplan-receipt" role="status" aria-live="polite" hidden></div>
<button id="undo-week-unplan" type="button" hidden>Undo</button> <button id="undo-week-unplan" type="button" hidden>Undo</button>
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div> <div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div>
<button id="export-saved-week-calendar" type="button" hidden>Add saved week to calendar</button>
<button id="edit-week-plan" type="button" hidden>Edit week</button> <button id="edit-week-plan" type="button" hidden>Edit week</button>
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button> <button id="confirm-week-plan" type="button">Confirm Week Ahead</button>
</section> </section>

View File

@ -73,7 +73,7 @@
function mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,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}={}){ 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; 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]));
const schedule=()=>buildWeekSchedule(plan,starts(),getItem,selected(),getAvailability?.()); const schedule=()=>buildWeekSchedule(plan,starts(),getItem,selected(),getAvailability?.());
@ -94,10 +94,10 @@
} }
function close({back=false}={}){ function close({back=false}={}){
handoff.hidden=true; handoff.hidden=true;
if(back){review.hidden=false;qs('#confirm-week-plan').focus?.();} if(back){review.hidden=false;const target=focusReturn||qs('#confirm-week-plan');focusReturn=null;target.focus?.();}
} }
function open(value){ function open(value,{returnFocus=null}={}){
plan=value;review.hidden=true;handoff.hidden=false; plan=value;if(returnFocus)focusReturn=returnFocus;review.hidden=true;handoff.hidden=false;
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>'+
@ -108,6 +108,7 @@
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('#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;
button.disabled=true;qs('#week-calendar-status').textContent='Preparing calendar blocks…'; button.disabled=true;qs('#week-calendar-status').textContent='Preparing calendar blocks…';

View File

@ -936,13 +936,22 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
const confirm=qs('#confirm-week-plan'); const confirm=qs('#confirm-week-plan');
confirm.disabled=!value.can_confirm||pending; confirm.disabled=!value.can_confirm||pending;
const blocker=value.blockers?.[0]; const blocker=value.blockers?.[0];
qs('#week-review-status').textContent=readOnly?'Offline snapshot · viewing only. Retry for live editing.':(overviewing?'Week Ahead overview · '+(pending?'sync pending.':'no changes made.'):(value.duplicates.length?'Duplicate work must be moved to one date.': const blockerMessage=blocker?(
blocker.type==='over-capacity'?blocker.plan_date+' is '+blocker.minutes+' min over capacity. Edit that day before confirming.':
blocker.type==='missing-estimate'?'Add an estimate for work on '+blocker.plan_date+' before confirming.':
'Add capacity for '+blocker.plan_date+' before confirming.'
):'';
qs('#week-review-status').textContent=readOnly?'Offline snapshot · viewing only. Retry for live editing.':(overviewing?(pending?'Week Ahead overview · sync pending.':
(blockerMessage||'Week Ahead overview · no changes made.')):(value.duplicates.length?'Duplicate work must be moved to one date.':
(pending?'Saving the latest week to your account…':blocker?( (pending?'Saving the latest week to your account…':blocker?(
blocker.type==='over-capacity'?blocker.plan_date+' is '+blocker.minutes+' min over capacity. Edit that day before confirming.': blockerMessage
blocker.type==='missing-estimate'?'Add an estimate for work on '+blocker.plan_date+' before confirming.':
'Add capacity for '+blocker.plan_date+' before confirming.'
):'Week Ahead is balanced and ready.'))); ):'Week Ahead is balanced and ready.')));
confirm.hidden=overviewing||readOnly; confirm.hidden=overviewing||readOnly;
const exportSaved=qs('#export-saved-week-calendar');
if(exportSaved){
exportSaved.hidden=!overviewing||!value.days.some(day=>day.ids.length);
exportSaved.disabled=pending||!value.can_confirm;
}
const reflow=qs('#open-week-reflow');if(reflow)reflow.hidden=!overviewing||readOnly||pending||!value.days.some(day=>day.overloaded); const reflow=qs('#open-week-reflow');if(reflow)reflow.hidden=!overviewing||readOnly||pending||!value.days.some(day=>day.overloaded);
const editWeek=qs('#edit-week-plan');if(editWeek){editWeek.hidden=!overviewing||readOnly;editWeek.textContent=nextUp?'Edit week':'Plan Week Ahead';} const editWeek=qs('#edit-week-plan');if(editWeek){editWeek.hidden=!overviewing||readOnly;editWeek.textContent=nextUp?'Edit week':'Plan Week Ahead';}
root.querySelectorAll('[data-week-move]').forEach(button=>button.addEventListener('click',()=>{ root.querySelectorAll('[data-week-move]').forEach(button=>button.addEventListener('click',()=>{
@ -1215,6 +1224,7 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
} }
} }
const workflow={open,save,advance,confirm,finish,promote,retire,resumePull,flushPull,editWeek,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation), const workflow={open,save,advance,confirm,finish,promote,retire,resumePull,flushPull,editWeek,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation),
i:id=>getItem(id)||controller.item?.(id),
reviewing:()=>reviewing,overviewing:()=>overviewing,selectedDate:()=>selectedDate, reviewing:()=>reviewing,overviewing:()=>overviewing,selectedDate:()=>selectedDate,
day:()=>reconciliationDay()||(selectedDate?controller.day(selectedDate):(overviewing?{ids:[]}:null)), day:()=>reconciliationDay()||(selectedDate?controller.day(selectedDate):(overviewing?{ids:[]}:null)),
copy:()=>reconciliation?{title:"Start today's plan",heading:'Unfinished Today + due Week Ahead',available:'Available today',build:'Build combined Today'}: copy:()=>reconciliation?{title:"Start today's plan",heading:'Unfinished Today + due Week Ahead',available:'Available today',build:'Build combined Today'}:

View File

@ -105,10 +105,11 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
expect(cards).to_have_count(7) expect(cards).to_have_count(7)
expect(cards.first).to_contain_text("Next up") expect(cards.first).to_contain_text("Next up")
expect(cards.first).to_contain_text("Ship mobile capture") expect(cards.first).to_contain_text("Ship mobile capture")
expect(page.locator("#week-review-status")).to_have_text( expect(page.locator("#week-review-status")).to_contain_text("30 min over capacity")
"Week Ahead overview · no changes made."
)
expect(page.locator("#confirm-week-plan")).to_be_hidden() expect(page.locator("#confirm-week-plan")).to_be_hidden()
blocked_export = page.locator("#export-saved-week-calendar")
expect(blocked_export).to_be_visible()
expect(blocked_export).to_be_disabled()
availability = page.locator("#open-week-availability") availability = page.locator("#open-week-availability")
expect(availability).to_be_visible() expect(availability).to_be_visible()
availability_bounds = availability.bounding_box() availability_bounds = availability.bounding_box()
@ -192,6 +193,22 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
expect(overview).to_be_visible() expect(overview).to_be_visible()
expect(planned_item).to_be_focused() expect(planned_item).to_be_focused()
assert len(saved) == writes_before_reflow + 1, "opening and inspecting Week Ahead must not add a write" assert len(saved) == writes_before_reflow + 1, "opening and inspecting Week Ahead must not add a write"
export_saved = page.locator("#export-saved-week-calendar")
expect(export_saved).to_be_visible()
expect(export_saved).to_be_enabled()
export_bounds = export_saved.bounding_box()
assert export_bounds and export_bounds["height"] >= 44
writes_before_export = len(saved)
export_saved.click()
expect(page.locator("#week-calendar-handoff")).to_be_visible()
expect(overview).to_be_hidden()
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text(
"09:0009:45 · 45 min"
)
assert len(saved) == writes_before_export, "exporting a saved week must not add a write"
page.locator("#back-to-week-review-from-calendar").click()
expect(overview).to_be_visible()
expect(export_saved).to_be_focused()
for control in (cards.first.locator("[data-week-edit-day]"), edit_week): for control in (cards.first.locator("[data-week-edit-day]"), edit_week):
bounds = control.bounding_box() bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44 assert bounds and bounds["height"] >= 44

View File

@ -151,11 +151,17 @@ def test_week_calendar_handoff_is_wired_into_confirmation_with_mobile_privacy_co
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="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 "Add saved week to calendar" in index
assert "Titles, references, dates, and times are included" 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 "Imported busy time is used only until this page reloads" in index
assert "mountWeekCalendarHandoff" in WEEK_CALENDAR.read_text() assert "mountWeekCalendarHandoff" in WEEK_CALENDAR.read_text()
assert "weekCalendar.open(weekPlan.state())" in dashboard assert "wc.open(weekPlan.state())" in dashboard
assert "weekCalendar.close()" in dashboard assert "getItem:weekFlow.i" in dashboard
assert "i:id=>getItem(id)||controller.item?.(id)" in (WEEK_CALENDAR.parent / "week-plan.js").read_text()
assert "#export-saved-week-calendar" in WEEK_CALENDAR.read_text()
assert "focusReturn=event.currentTarget" in WEEK_CALENDAR.read_text()
assert "wc.close()" in dashboard
assert "StackchainWeekCalendar.setAvailabilityProvider(workflow.availability)" in (WEEK_CALENDAR.parent / "week-calendar-import.js").read_text() 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 "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 assert '"static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js"' in bundle