Merge pull request 'Make Tomorrow planning a first-class mobile queue' (#1159) from timmy/1158-mobile-tomorrow-queue into main
This commit is contained in:
commit
ae632e3562
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user