From 0fd63143911f127cf23447431e328171ea02ba9f Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 20 Aug 2026 01:16:17 +0000 Subject: [PATCH] feat: make Tomorrow a first-class mobile queue (Closes #1158) --- frontend/dashboard.css | 1 - frontend/dashboard.js | 64 +++++++++++++++++++--------- frontend/index.html | 1 + frontend/tomorrow-plan.js | 11 ++++- tests/test_tomorrow_plan_frontend.py | 36 ++++++++++++++++ 5 files changed, 90 insertions(+), 23 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 92ebf0e..af7c946 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1096,7 +1096,6 @@ textarea { resize: vertical; min-height: 120px; } .attention-interruption:not([hidden]) { position:sticky; top:64px; z-index:6; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); } .my-work-header { align-items:flex-start; } .my-work-actions { display:none; } - .my-work-actions { display:flex; } .work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; } .work-settings:not([open]) > .work-settings-panel { display:none; } .work-settings-panel { display:grid; gap:10px; margin-top:8px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 8be1964..84ecea2 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -227,8 +227,9 @@ ), queueBadge: qs('#mobile-queue-count'), deadlineBadge: qs('#mobile-deadline-count'), - onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : - name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name), + onSelectQueue:(name, row) => name === 'recaps' ? qs('#open-today-recaps').click() : + name === 'find' ? qs('#find-work').click() : + name === 'tomorrow' ? openTomorrowPlanner(row) : mobileQueueLauncher.open(name), detour:() => timerView, overlays: mobileTaskOverlays, actions: { @@ -236,6 +237,7 @@ find: () => qs('#find-work').click(), new: () => qs('#new-issue').click(), search: () => qs('#open-palette').click(), + queues: () => refreshTomorrowQueueSummary(), }, observe(callback, overlays) { const observer = new MutationObserver(callback); @@ -383,6 +385,40 @@ localDate:todayRollover.localDate, timeZone:todayRollover.timeZone, }); + function renderTomorrowQueueSummary(value) { + qs('#mobile-tomorrow-summary').textContent = value ? tomorrowPlan.summary(value) : tomorrowPlan.summary(); + } + async function refreshTomorrowQueueSummary() { + try { + renderTomorrowQueueSummary(await tomorrowPlan.load()); + return true; + } catch (_error) { + qs('#mobile-tomorrow-summary').textContent = 'Unavailable · tap to retry'; + return false; + } + } + async function openTomorrowPlanner(trigger) { + if (!planningOwnerLogin) { + qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.'; + return false; + } + trigger.disabled = true; + qs('#mobile-tomorrow-summary').textContent = 'Loading Tomorrow…'; + try { + const loaded = await tomorrowPlan.load(); + renderTomorrowQueueSummary(loaded); + planningTomorrow = true; + openPlanToday(trigger); + qs('#my-work-action-status').textContent = ''; + return true; + } catch (error) { + qs('#mobile-tomorrow-summary').textContent = 'Unavailable · tap to retry'; + qs('#my-work-action-status').textContent = `${error.message || 'Tomorrow is unavailable.'} Retry when connected.`; + return false; + } finally { + trigger.disabled = false; + } + } const todayWork = createTodayWork({ storage: localStorage, getLogin: () => planningOwnerLogin, @@ -2643,6 +2679,9 @@ planningTomorrow = false; qs('#plan-today-sheet').hidden = true; document.body.classList.remove('task-overlay-open'); + if (planTodayTrigger?.dataset.mobileQueue === 'tomorrow') { + if (!qs('#mobile-queue-sheet').open) qs('#mobile-queue-sheet').showModal(); + } planTodayTrigger?.focus(); } @@ -2678,6 +2717,7 @@ const normalized = Array.isArray(plan) ? {ids:plan, capacity_minutes:null, estimates:{}} : plan; tomorrowPlan.save(normalized).then(saved => { + renderTomorrowQueueSummary(saved); qs('#my-work-action-status').textContent = saved.ids.length ? `Tomorrow saved for ${saved.plan_date} without changing Today.` : `Tomorrow cleared for ${saved.plan_date}.`; @@ -7694,25 +7734,7 @@ qs('#refresh').addEventListener('click', load); qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget)); - qs('#plan-tomorrow').addEventListener('click', async event => { - const button = event.currentTarget; - if (!planningOwnerLogin) { - qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.'; - return; - } - button.disabled = true; - qs('#my-work-action-status').textContent = 'Loading Tomorrow…'; - try { - await tomorrowPlan.load(); - planningTomorrow = true; - openPlanToday(button); - qs('#my-work-action-status').textContent = ''; - } catch (error) { - qs('#my-work-action-status').textContent = `${error.message || 'Tomorrow is unavailable.'} Retry when connected.`; - } finally { - button.disabled = false; - } - }); + qs('#plan-tomorrow').addEventListener('click', event => openTomorrowPlanner(event.currentTarget)); qs('#cancel-plan-today').addEventListener('click', closePlanToday); 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 f595694..3908fca 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1810,6 +1810,7 @@
+ diff --git a/frontend/tomorrow-plan.js b/frontend/tomorrow-plan.js index 5657984..d7280ac 100644 --- a/frontend/tomorrow-plan.js +++ b/frontend/tomorrow-plan.js @@ -48,6 +48,15 @@ function createTomorrowPlan({fetchJson,localDate,timeZone}={}) { } return Math.max(1,candidate-now+250); } + function summary(value=plan) { + const ids=Array.isArray(value?.ids)?value.ids:[]; + if(!ids.length) return 'Nothing planned'; + const label=`${ids.length} planned`; + const estimates=value.estimates||{}; + const estimated=ids.reduce((total,id)=>total+(Number(estimates[id])||0),0); + const capacity=Number(value.capacity_minutes)||0; + return estimated&&capacity?`${label} · ${estimated} of ${capacity} min`:label; + } function startLifecycle({windowObject,documentObject,check,setTimer=setTimeout,clearTimer=clearTimeout, nextDelay=millisecondsUntilNextDay}={}) { let flight=null; @@ -65,6 +74,6 @@ function createTomorrowPlan({fetchJson,localDate,timeZone}={}) { run(); return {run,stop(){if(timer!==null)clearTimer(timer);timer=null;}}; } - return {adopt,load,save,promote,state,nextLocalDate,startLifecycle}; + return {adopt,load,save,promote,state,summary,nextLocalDate,startLifecycle}; } if(typeof module!=='undefined'&&module.exports)module.exports=createTomorrowPlan; diff --git a/tests/test_tomorrow_plan_frontend.py b/tests/test_tomorrow_plan_frontend.py index 1a621a1..cd71f2c 100644 --- a/tests/test_tomorrow_plan_frontend.py +++ b/tests/test_tomorrow_plan_frontend.py @@ -138,6 +138,42 @@ console.log(JSON.stringify({scheduled:Number.isFinite(scheduled)&&scheduled>0})) assert result == {"scheduled": True} +def test_tomorrow_planner_summarizes_empty_and_capacity_aware_plans_for_mobile_queues(): + result = run_controller(""" +const planner=createTomorrowPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); +const empty=planner.summary({ids:[],capacity_minutes:null,estimates:{}}); +const planned=planner.summary({ + ids:['issue:r:1:','issue:r:2:','issue:r:3:'], capacity_minutes:120, + estimates:{'issue:r:1:':30,'issue:r:2:':45,'issue:r:3:':30} +}); +const unestimated=planner.summary({ids:['issue:r:4:'],capacity_minutes:null,estimates:{}}); +console.log(JSON.stringify({empty,planned,unestimated})); +""") + + assert result == { + "empty": "Nothing planned", + "planned": "3 planned · 105 of 120 min", + "unestimated": "1 planned", + } + + +def test_mobile_queues_expose_tomorrow_and_keep_duplicate_header_actions_hidden(): + index = INDEX.read_text() + css = CSS.read_text() + dashboard = (FRONTEND / "dashboard.js").read_text() + + assert 'data-mobile-queue="tomorrow"' in index + assert 'id="mobile-tomorrow-summary"' in index + assert '>Nothing planned<' in index + assert "name === 'tomorrow' ? openTomorrowPlanner" in dashboard + assert "tomorrowPlan.summary()" in dashboard + assert "renderTomorrowQueueSummary(saved)" in dashboard + assert "planTodayTrigger?.dataset.mobileQueue === 'tomorrow'" in dashboard + assert "qs('#mobile-queue-sheet').showModal()" in dashboard + assert ".my-work-actions { display:none; }\n .my-work-actions { display:flex; }" not in css + assert ".my-work-actions { display:none; }" in css + + def test_tomorrow_plan_has_a_touch_safe_mobile_entry_and_reuses_the_ordered_planner(): index = INDEX.read_text() css = CSS.read_text()