From c99d03b9bc4909e7cd72436f8fa793fc1f5ad808 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 20 Aug 2026 09:49:08 +0000 Subject: [PATCH] feat: carry wrap-up work into Tomorrow (Closes #1174) --- frontend/dashboard.js | 4 +- frontend/index.html | 2 +- frontend/today-wrap-up.js | 54 ++++++-- .../e2e/test_mobile_today_wrap_up_release.py | 23 +++- tests/test_today_wrap_up.py | 124 +++++++++++++++--- 5 files changed, 167 insertions(+), 40 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 6b297f7..60f793f 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -2044,11 +2044,13 @@ enqueueDurably:message => authoredOutbox.enqueueDurably(message), }); todaySummaryView.resume(); - const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml, + const todayWrapUpView = setupTodayWrapUp({ todayWork, tomorrowPlan, todaySync, qs, escapeHtml, onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => { todayRecapView['completeReplan'](); refreshMyWorkView(); warmTodayOffline(); + renderTomorrowQueueSummary(); + syncPendingTomorrow(); todaySummaryView.open(workedItems, tomorrowItems); }, }); diff --git a/frontend/index.html b/frontend/index.html index 2b73137..703ad70 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -567,7 +567,7 @@
End your day intentionally

Wrap up Today

-

Choose unfinished work to schedule for tomorrow at 09:00. Unchecked work stays in Today.

+

Carry selected work into the ordered Tomorrow plan. Unselected work stays in Today.

diff --git a/frontend/today-wrap-up.js b/frontend/today-wrap-up.js index 7768d44..11063d8 100644 --- a/frontend/today-wrap-up.js +++ b/frontend/today-wrap-up.js @@ -1,4 +1,4 @@ -function createTodayWrapUp({ todayWork, laterWork, todaySync }) { +function createTodayWrapUp({ todayWork, tomorrowPlan, todaySync, limit = 5 }) { let items = []; const selected = new Set(); @@ -28,16 +28,39 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) { } async function finish() { - const wake = laterWork.presetUntil('tomorrow'); - let scheduled = 0; - for (const item of items) { + const loaded = await tomorrowPlan.load(); + const currentIds = Array.isArray(loaded?.ids) ? [...loaded.ids] : []; + const carried = items.filter(item => selected.has(todayWork.identity(item)) && todayWork.contains(item)); + if (!carried.length) { + return { + scheduled:0, + left:items.filter(item => todayWork.contains(item)).length, + plan_count:currentIds.length, + plan_date:loaded?.plan_date, + sync_pending:Boolean(loaded?.sync_pending), + }; + } + const combinedIds = [...currentIds]; + for (const item of carried) { + const identity = todayWork.identity(item); + if (!combinedIds.includes(identity)) combinedIds.push(identity); + } + if (combinedIds.length > limit) { + throw new Error(`Tomorrow can hold ${limit} items. Keep ${combinedIds.length - limit} in Today or edit Tomorrow first.`); + } + const estimates = {...(loaded?.estimates || {})}; + const capacityMinutes = loaded?.capacity_minutes ?? null; + const plannedMinutes = combinedIds.reduce((total, identity) => total + (Number(estimates[identity]) || 0), 0); + if (capacityMinutes !== null && plannedMinutes > capacityMinutes) { + throw new Error('Tomorrow is over capacity. Edit the plan before finishing wrap-up.'); + } + const staged = tomorrowPlan.stage({ids:combinedIds, capacity_minutes:capacityMinutes, estimates}); + if (!staged) throw new Error('Tomorrow could not be saved on this device. Your Today plan is unchanged.'); + let scheduled = 0; + for (const item of carried) { const identity = todayWork.identity(item); - if (!selected.has(identity) || !todayWork.contains(item)) continue; - const deferred = laterWork.defer(item, wake, {handoff:'today'}); - if (deferred !== 'deferred') throw new Error('Tomorrow could not be saved. Your Today plan is unchanged.'); if (!todaySync.enqueue('remove', identity)) { - laterWork.restore?.(item); - throw new Error('Today sync could not be queued. Your Today plan is unchanged.'); + throw new Error('Today sync could not be queued. The item remains in Today.'); } if (todayWork.contains(item) && !todayWork.remove(item)) { throw new Error('Today could not be updated. Retry wrap-up.'); @@ -45,7 +68,13 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) { scheduled += 1; } await todaySync.flush(); - return { scheduled, left:items.length - scheduled, wake_at:wake.toISOString() }; + return { + scheduled, + left:items.length - scheduled, + plan_count:combinedIds.length, + plan_date:staged.plan_date, + sync_pending:Boolean(staged.sync_pending), + }; } return { open, choose, snapshot, finish }; @@ -69,7 +98,7 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => return '
' + escapeHtml(title) + '' + '' + escapeHtml(context) + '
'; + '> Carry to Tomorrow
'; }).join('') || '

Nothing unfinished remains in Today.

'; qs('#today-wrap-up-items').querySelectorAll('[data-wrap-up-identity]').forEach(input => { input.addEventListener('change', () => controller.choose(input.dataset.wrapUpIdentity, input.checked)); @@ -97,7 +126,8 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => context:String(row.item.key || row.item.repository || 'Work item').slice(0, 180), })); const result = await controller.finish(); - qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.'; + qs('#my-work-action-status').textContent = result.scheduled + ' carried to Tomorrow · ' + result.left + + ' left in Today' + (result.sync_pending ? ' · sync pending.' : '.'); close(); onComplete(result, actualMinutes, workedItems, tomorrowItems); } catch (error) { diff --git a/tests/e2e/test_mobile_today_wrap_up_release.py b/tests/e2e/test_mobile_today_wrap_up_release.py index c189f15..ee743c9 100644 --- a/tests/e2e/test_mobile_today_wrap_up_release.py +++ b/tests/e2e/test_mobile_today_wrap_up_release.py @@ -56,17 +56,19 @@ def test_release_artifact_renders_and_applies_mobile_today_wrap_up( {kind:'issue',repository:'acme/mobile',number:42,title:'Polish desktop filters',key:'acme/mobile#42'}, ]; const identity=item => `${item.kind}:${item.repository}:${item.number}:`; - const today=items.map(identity); const later={}; const operations=[]; + const today=items.map(identity); const operations=[]; + let tomorrow={revision:4,ids:['issue:acme/mobile:42:'],capacity_minutes:120, + estimates:{'issue:acme/mobile:42:':45},plan_date:'2026-08-18'}; const todayWork={identity,read:()=>[...today],contains:item=>today.includes(identity(item)),remove:item=>{ const index=today.indexOf(identity(item)); if(index<0)return false; today.splice(index,1); return true; }}; - const laterWork={presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:(item,wake)=>{ - later[identity(item)]=wake.toISOString(); return 'deferred'; + const tomorrowPlan={load:async()=>tomorrow,stage:value=>{ + tomorrow={...tomorrow,...value,sync_pending:true}; return tomorrow; }}; const todaySync={enqueue:(action,id)=>{operations.push([action,id]);return true;},flush:()=>Promise.resolve(true)}; - const controller=createTodayWrapUp({todayWork,laterWork,todaySync}); + const controller=createTodayWrapUp({todayWork,tomorrowPlan,todaySync}); const view=createTodayWrapUpView({controller,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)}); - window.__wrapTest={view,today,later,operations}; + window.__wrapTest={view,today,getTomorrow:()=>tomorrow,operations}; view.open(items,{}); } """ @@ -84,11 +86,18 @@ def test_release_artifact_renders_and_applies_mobile_today_wrap_up( page.evaluate("window.__wrapTest.view.finish(document.querySelector('#finish-today-wrap-up'))") expect(sheet).to_be_hidden() result = page.evaluate( - "({today:window.__wrapTest.today,later:window.__wrapTest.later,operations:window.__wrapTest.operations})" + "({today:window.__wrapTest.today,tomorrow:window.__wrapTest.getTomorrow(),operations:window.__wrapTest.operations})" ) assert result == { "today": ["issue:acme/mobile:42:"], - "later": {"issue:acme/mobile:41:": "2026-08-17T09:00:00.000Z"}, + "tomorrow": { + "revision": 4, + "ids": ["issue:acme/mobile:42:", "issue:acme/mobile:41:"], + "capacity_minutes": 120, + "estimates": {"issue:acme/mobile:42:": 45}, + "plan_date": "2026-08-18", + "sync_pending": True, + }, "operations": [["remove", "issue:acme/mobile:41:"]], } assert browser_errors == [] diff --git a/tests/test_today_wrap_up.py b/tests/test_today_wrap_up.py index 0c6cf09..6cf7fc4 100644 --- a/tests/test_today_wrap_up.py +++ b/tests/test_today_wrap_up.py @@ -13,7 +13,7 @@ def run_node(script: str): return json.loads(result.stdout) -def test_wrap_up_schedules_selected_work_for_tomorrow_and_leaves_other_work_in_order(): +def test_wrap_up_appends_selected_work_to_the_ordered_tomorrow_plan_before_removing_it_from_today(): script = f""" const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))}); const items = [ @@ -23,36 +23,117 @@ const items = [ ]; const ids = items.map(item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':'); const today = [...ids]; -const later = {{}}; const todayOps = []; -const laterOps = []; +const events = []; +let tomorrow = {{revision:7, ids:[ids[0]], capacity_minutes:120, estimates:{{[ids[0]]:45}}, plan_date:'2026-08-21'}}; const todayWork = {{ identity:item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':', read:() => [...today], contains:item => today.includes((item.kind || 'work') + ':' + item.repository + ':' + item.number + ':'), remove:item => {{ const id=(item.kind || 'work') + ':' + item.repository + ':' + item.number + ':'; - const index=today.indexOf(id); if(index < 0) return false; today.splice(index,1); return true; + const index=today.indexOf(id); if(index < 0) return false; events.push('remove:'+id); today.splice(index,1); return true; }}, }}; -const laterWork = {{ - identity:todayWork.identity, - presetUntil:preset => new Date('2026-08-17T09:00:00-04:00'), - defer:(item, until, options) => {{ later[todayWork.identity(item)] = until.toISOString(); laterOps.push([todayWork.identity(item), options]); return 'deferred'; }}, +const tomorrowPlan = {{ + load:async()=>tomorrow, + state:()=>({{...tomorrow,ids:[...tomorrow.ids],estimates:{{...tomorrow.estimates}}}}), + stage:value=>{{events.push('stage');tomorrow={{...tomorrow,...value,plan_date:'2026-08-21',sync_pending:true}};return tomorrow;}}, }}; const todaySync = {{enqueue:(action,id) => {{todayOps.push([action,id]); return true;}}, flush:()=>Promise.resolve(true)}}; -const wrap = createWrapUp({{todayWork,laterWork,todaySync}}); +const wrap = createWrapUp({{todayWork,tomorrowPlan,todaySync}}); wrap.open(items); wrap.choose(ids[1], true); const result = await wrap.finish(); -process.stdout.write(JSON.stringify({{result,today,later,todayOps,laterOps}})); +process.stdout.write(JSON.stringify({{result,today,tomorrow,todayOps,events}})); """ output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})") - assert output["result"] == {"scheduled": 1, "left": 2, "wake_at": "2026-08-17T13:00:00.000Z"} + assert output["result"] == { + "scheduled": 1, + "left": 2, + "plan_count": 2, + "plan_date": "2026-08-21", + "sync_pending": True, + } assert output["today"] == ["issue:stackchain/dashboard:1:", "pull:stackchain/dashboard:3:"] - assert output["later"] == {"issue:stackchain/dashboard:2:": "2026-08-17T13:00:00.000Z"} + assert output["tomorrow"]["ids"] == [ + "issue:stackchain/dashboard:1:", + "issue:stackchain/dashboard:2:", + ] + assert output["tomorrow"]["capacity_minutes"] == 120 + assert output["tomorrow"]["estimates"] == {"issue:stackchain/dashboard:1:": 45} assert output["todayOps"] == [["remove", "issue:stackchain/dashboard:2:"]] - assert output["laterOps"] == [["issue:stackchain/dashboard:2:", {"handoff": "today"}]] + assert output["events"] == ["stage", "remove:issue:stackchain/dashboard:2:"] + + +def test_wrap_up_keeps_today_unchanged_when_the_combined_tomorrow_plan_exceeds_five_items(): + script = f""" +const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))}); +const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Sixth'}}; +const id='issue:acme/mobile:41:'; let present=true; let staged=0; let enqueued=0; +const todayWork={{identity:()=>id,read:()=>[id],contains:()=>present,remove:()=>{{present=false;return true;}}}}; +const tomorrowPlan={{ + load:async()=>({{revision:3,ids:['a','b','c','d','e'],capacity_minutes:null,estimates:{{}},plan_date:'2026-08-21'}}), + stage:()=>{{staged+=1;return {{}};}}, +}}; +const todaySync={{enqueue:()=>{{enqueued+=1;return true;}},flush:async()=>true}}; +const wrap=createWrapUp({{todayWork,tomorrowPlan,todaySync}}); wrap.open([item]); wrap.choose(id,true); +let error=''; try {{ await wrap.finish(); }} catch (caught) {{ error=caught.message; }} +process.stdout.write(JSON.stringify({{error,present,staged,enqueued}})); +""" + output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})") + assert output == { + "error": "Tomorrow can hold 5 items. Keep 1 in Today or edit Tomorrow first.", + "present": True, + "staged": 0, + "enqueued": 0, + } + + +def test_wrap_up_keeps_today_unchanged_when_tomorrow_is_over_capacity(): + script = f""" +const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))}); +const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Carry'}}; +const id='issue:acme/mobile:41:'; let present=true; let staged=0; +const todayWork={{identity:()=>id,read:()=>[id],contains:()=>present,remove:()=>{{present=false;return true;}}}}; +const tomorrowPlan={{ + load:async()=>({{revision:3,ids:['a','b'],capacity_minutes:60,estimates:{{a:40,b:30}},plan_date:'2026-08-21'}}), + stage:()=>{{staged+=1;return {{}};}}, +}}; +const todaySync={{enqueue:()=>true,flush:async()=>true}}; +const wrap=createWrapUp({{todayWork,tomorrowPlan,todaySync}}); wrap.open([item]); wrap.choose(id,true); +let error=''; try {{ await wrap.finish(); }} catch (caught) {{ error=caught.message; }} +process.stdout.write(JSON.stringify({{error,present,staged}})); +""" + output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})") + assert output == { + "error": "Tomorrow is over capacity. Edit the plan before finishing wrap-up.", + "present": True, + "staged": 0, + } + + +def test_wrap_up_keeps_today_unchanged_when_tomorrow_cannot_be_saved_on_the_phone(): + script = f""" +const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))}); +const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Carry'}}; +const id='issue:acme/mobile:41:'; let present=true; let enqueued=0; +const todayWork={{identity:()=>id,read:()=>[id],contains:()=>present,remove:()=>{{present=false;return true;}}}}; +const tomorrowPlan={{ + load:async()=>({{revision:3,ids:[],capacity_minutes:null,estimates:{{}},plan_date:'2026-08-21'}}), + stage:()=>false, +}}; +const todaySync={{enqueue:()=>{{enqueued+=1;return true;}},flush:async()=>true}}; +const wrap=createWrapUp({{todayWork,tomorrowPlan,todaySync}}); wrap.open([item]); wrap.choose(id,true); +let error=''; try {{ await wrap.finish(); }} catch (caught) {{ error=caught.message; }} +process.stdout.write(JSON.stringify({{error,present,enqueued}})); +""" + output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})") + assert output == { + "error": "Tomorrow could not be saved on this device. Your Today plan is unchanged.", + "present": True, + "enqueued": 0, + } def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_save(): @@ -65,9 +146,13 @@ def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_sav assert 'id="today-wrap-up-sheet" role="dialog"' in html assert 'id="today-wrap-up-items"' in html + assert 'Carry selected work into the ordered Tomorrow plan.' in html + assert 'Tomorrow 09:00' not in html assert 'id="finish-today-wrap-up"' in html assert 'static/today-wrap-up.js' in main.FRONTEND_BUILD.page_sources assert 'setupTodayWrapUp({' in dashboard + assert 'todayWork, tomorrowPlan, todaySync' in dashboard + assert 'syncPendingTomorrow();' in dashboard assert 'todayWrapUpView.open' in dashboard assert 'openWrapUp(handoff.actual_minutes, workedItems)' in recap assert '.today-wrap-up-actions button { min-height:44px;' in css @@ -75,19 +160,20 @@ def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_sav assert 'tests/e2e/test_mobile_today_wrap_up_release.py' in (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text() -def test_wrap_up_repeat_confirmation_does_not_repeat_later_or_today_operations(): +def test_wrap_up_repeat_confirmation_does_not_repeat_tomorrow_or_today_operations(): script = f""" const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))}); const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Ship'}}; const id='issue:acme/mobile:41:'; -let present=true; let deferred=0; let removed=0; +let present=true; let staged=0; let removed=0; +let tomorrow={{revision:1,ids:[],capacity_minutes:null,estimates:{{}},plan_date:'2026-08-21'}}; const todayWork={{identity:()=>id,read:()=>present?[id]:[],contains:()=>present,remove:()=>{{present=false;return true;}}}}; -const laterWork={{presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:()=>{{deferred+=1;return 'deferred';}}}}; +const tomorrowPlan={{load:async()=>tomorrow,stage:value=>{{staged+=1;tomorrow={{...tomorrow,...value,sync_pending:true}};return tomorrow;}}}}; const todaySync={{enqueue:()=>{{removed+=1;return true;}},flush:()=>Promise.resolve(true)}}; -const wrap=createWrapUp({{todayWork,laterWork,todaySync}}); +const wrap=createWrapUp({{todayWork,tomorrowPlan,todaySync}}); wrap.open([item]); wrap.choose(id,true); await wrap.finish(); await wrap.finish(); -process.stdout.write(JSON.stringify({{deferred,removed,present}})); +process.stdout.write(JSON.stringify({{staged,removed,present}})); """ output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})") - assert output == {"deferred": 1, "removed": 1, "present": False} + assert output == {"staged": 1, "removed": 1, "present": False} -- 2.43.0