Make Tomorrow planning a first-class mobile queue #1159

Merged
rockachopa merged 1 commits from timmy/1158-mobile-tomorrow-queue into main 2026-08-20 01:29:19 +00:00
5 changed files with 90 additions and 23 deletions

View File

@ -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; }

View File

@ -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();

View File

@ -1810,6 +1810,7 @@
</section>
<div class="mobile-queue-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
<button data-mobile-queue="tomorrow" type="button"><span><strong>Tomorrow</strong><small id="mobile-tomorrow-summary" aria-live="polite">Nothing planned</small></span></button>
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>

View File

@ -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;

View File

@ -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()