From f0a966d3a3024a8743bb67365e8075140abdb5be Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 22 Aug 2026 10:56:36 +0000 Subject: [PATCH] feat: export saved Week Ahead from overview (Closes #1260) --- frontend/dashboard.css | 1 + frontend/dashboard.js | 57 ++++++++------------- frontend/index.html | 1 + frontend/week-calendar.js | 9 ++-- frontend/week-plan.js | 18 +++++-- tests/e2e/test_mobile_week_ahead_release.py | 23 +++++++-- tests/test_week_calendar.py | 10 +++- 7 files changed, 71 insertions(+), 48 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 07066fd..95c9416 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -371,6 +371,7 @@ textarea { resize: vertical; min-height: 120px; } #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; } #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-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; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index c62144b..a1b92ba 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -460,46 +460,33 @@ escapeHtml,escapeAttribute:escAttr,todayWork, t:v=>v?latestTodayPlan=v:latestTodayPlan,r:refreshMyWorkView,w:warmTodayOffline, x:currentTodayProgressTarget}); - const weekCalendar=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekItem,escapeHtml,escapeAttribute:escAttr, - onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();}, - }); - const todaySync = createTodaySync({ - storage: localStorage, - getLogin: () => planningOwnerLogin, - fetchJson: fetchReviewJson, - coordinator: outboxCoordinator, - onRemoteIds: ids => { - if (!planningOwnerLogin || !todayWork.replace(ids)) return; - refreshMyWorkView(); - warmTodayOffline(); - }, - onRemotePlan: plan => { - if (!planningOwnerLogin) return; - latestTodayPlan = plan; + const wc=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekFlow.i,escapeHtml,escapeAttribute:escAttr, + onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();}}); + const todaySync=createTodaySync({storage:localStorage,getLogin: () => planningOwnerLogin,fetchJson:fetchReviewJson, + coordinator:outboxCoordinator, + onRemoteIds:ids=>{if(!planningOwnerLogin||!todayWork.replace(ids))return;refreshMyWorkView();warmTodayOffline();}, + onRemotePlan:plan=>{ + if(!planningOwnerLogin)return; + latestTodayPlan=plan; weekFlow.promote(plan).finally(() => { if (window.location.hash !== '#/my-work/start-day') return; window.history.replaceState({}, '', '#/my-work/today'); openMobileStartDay(); }); - todayWork.replacePlanning({ - capacity_minutes: plan.capacity_minutes ?? null, - estimates: plan.estimates || {}, - }); - const s = todayRollover.reviewState(plan); - if (!['stale', 'legacy'].includes(s)) { - rolloverReviewPlan = null; - qs('#plan-today').textContent = 'Plan Today'; + todayWork.replacePlanning({capacity_minutes:plan.capacity_minutes??null,estimates:plan.estimates||{}}); + const s=todayRollover.reviewState(plan); + if(!['stale','legacy'].includes(s)){ + rolloverReviewPlan=null; + qs('#plan-today').textContent='Plan Today'; return; } - rolloverReviewPlan = plan; - qs('#plan-today').textContent = 'Review new day'; - qs('#my-work-action-status').textContent = 'Review yesterday’s unfinished work before starting today.'; - const marker = `stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`; - if (!localStorage.getItem(marker)) { - localStorage.setItem(marker, '1'); - setTimeout(() => { - if (!workSession.checkpointed()) openPlanToday(qs('#plan-today')); - }, 0); + rolloverReviewPlan=plan; + qs('#plan-today').textContent='Review new day'; + qs('#my-work-action-status').textContent='Review yesterday’s unfinished work before starting today.'; + const marker=`stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`; + if(!localStorage.getItem(marker)){ + localStorage.setItem(marker,'1'); + setTimeout(()=>{if(!workSession.checkpointed())openPlanToday(qs('#plan-today'));},0); } }, onStatus: (state, detail = {}) => { @@ -2706,7 +2693,7 @@ } planToday.cancel(); planningTomorrow = false; - weekCalendar.close(); + wc.close(); weekFlow.clear(); qs('#tomorrow-conflict-review').hidden = true; 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.'; return; } - weekCalendar.open(weekPlan.state()); + wc.open(weekPlan.state()); }); qs('#plan-today-sheet').addEventListener('click', event => { if (event.target === qs('#plan-today-sheet')) closePlanToday(); diff --git a/frontend/index.html b/frontend/index.html index 964927b..b43b620 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -560,6 +560,7 @@
+ diff --git a/frontend/week-calendar.js b/frontend/week-calendar.js index a14a6c4..9950725 100644 --- a/frontend/week-calendar.js +++ b/frontend/week-calendar.js @@ -73,7 +73,7 @@ function mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,escapeHtml,escapeAttribute,onDone,windowObject=root,navigatorObject=root.navigator, documentObject=root.document,urlApi=root.URL,FileCtor=root.File}={}){ const handoff=qs('#week-calendar-handoff'),review=qs('#week-review'),daysRoot=qs('#week-calendar-days'); - let plan=null; + 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 schedule=()=>buildWeekSchedule(plan,starts(),getItem,selected(),getAvailability?.()); @@ -94,10 +94,10 @@ } function close({back=false}={}){ 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){ - plan=value;review.hidden=true;handoff.hidden=false; + function open(value,{returnFocus=null}={}){ + 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'))])); daysRoot.innerHTML=(value.days||[]).filter(day=>day.ids?.length).map(day=>'

'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+ '

'+ @@ -108,6 +108,7 @@ update();qs('#back-to-week-review-from-calendar').focus?.();return 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()=>{ const button=qs('#share-week-calendar'),current=blocks();if(!current.length)return; button.disabled=true;qs('#week-calendar-status').textContent='Preparing calendar blocks…'; diff --git a/frontend/week-plan.js b/frontend/week-plan.js index 0704143..9075a51 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -936,13 +936,22 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla const confirm=qs('#confirm-week-plan'); confirm.disabled=!value.can_confirm||pending; 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?( - 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.' + blockerMessage ):'Week Ahead is balanced and ready.'))); 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 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',()=>{ @@ -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), + i:id=>getItem(id)||controller.item?.(id), reviewing:()=>reviewing,overviewing:()=>overviewing,selectedDate:()=>selectedDate, 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'}: diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py index 3379bb5..0ef3587 100644 --- a/tests/e2e/test_mobile_week_ahead_release.py +++ b/tests/e2e/test_mobile_week_ahead_release.py @@ -105,10 +105,11 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( expect(cards).to_have_count(7) expect(cards.first).to_contain_text("Next up") expect(cards.first).to_contain_text("Ship mobile capture") - expect(page.locator("#week-review-status")).to_have_text( - "Week Ahead overview · no changes made." - ) + expect(page.locator("#week-review-status")).to_contain_text("30 min over capacity") 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") expect(availability).to_be_visible() 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(planned_item).to_be_focused() 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:00–09: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): bounds = control.bounding_box() assert bounds and bounds["height"] >= 44 diff --git a/tests/test_week_calendar.py b/tests/test_week_calendar.py index 5932888..f928c55 100644 --- a/tests/test_week_calendar.py +++ b/tests/test_week_calendar.py @@ -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="share-week-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 "Imported busy time is used only until this page reloads" in index assert "mountWeekCalendarHandoff" in WEEK_CALENDAR.read_text() - assert "weekCalendar.open(weekPlan.state())" in dashboard - assert "weekCalendar.close()" in dashboard + assert "wc.open(weekPlan.state())" 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 "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