Merge pull request 'Give mobile Queues one adaptive Start / Continue action' (#1451) from timmy/1450-adaptive-mobile-queue-action into main
This commit is contained in:
commit
977493907e
|
|
@ -1533,7 +1533,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.mobile-start-day { display:grid; gap:12px; max-width:100%; overflow-wrap:anywhere; margin-top:12px; padding:14px; border:1px solid #31577f; border-radius:14px; background:linear-gradient(135deg,#173b64,#102641); }
|
.mobile-start-day { display:grid; gap:12px; max-width:100%; overflow-wrap:anywhere; margin-top:12px; padding:14px; border:1px solid #31577f; border-radius:14px; background:linear-gradient(135deg,#173b64,#102641); }
|
||||||
.mobile-start-day h3, .mobile-start-day p { margin:0; }
|
.mobile-start-day h3, .mobile-start-day p { margin:0; }
|
||||||
.mobile-start-day p + p { margin-top:4px; }
|
.mobile-start-day p + p { margin-top:4px; }
|
||||||
.mobile-start-day-action { width:100%; min-height:48px; text-align:center; }
|
|
||||||
.mobile-start-day-finish { min-height:44px; width:100%; background:transparent; }
|
.mobile-start-day-finish { min-height:44px; width:100%; background:transparent; }
|
||||||
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
|
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
|
||||||
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
|
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,11 @@
|
||||||
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
||||||
announce: announceWork,
|
announce: announceWork,
|
||||||
getCounts: () => queueCounts,
|
getCounts: () => queueCounts,
|
||||||
|
getPreparation: () => {
|
||||||
|
const briefing = mobileStartDay.briefing();
|
||||||
|
return {...briefing, active:mobileStartDay.state().active};
|
||||||
|
},
|
||||||
|
openPreparation: () => mobileStartDay.startNext(),
|
||||||
openFindWork: () => qs('#find-work').click(),
|
openFindWork: () => qs('#find-work').click(),
|
||||||
rows: Object.fromEntries(
|
rows: Object.fromEntries(
|
||||||
Array.from(document.querySelectorAll('[data-mobile-queue]')).map(button => [button.dataset.mobileQueue, button])
|
Array.from(document.querySelectorAll('[data-mobile-queue]')).map(button => [button.dataset.mobileQueue, button])
|
||||||
|
|
@ -190,7 +195,6 @@
|
||||||
activeSection: qs('#mobile-queue-active-list').parentElement,
|
activeSection: qs('#mobile-queue-active-list').parentElement,
|
||||||
});
|
});
|
||||||
renderMobileQueuePresentation = () => mobileQueueLauncher.renderPresentation();
|
renderMobileQueuePresentation = () => mobileQueueLauncher.renderPresentation();
|
||||||
renderMobileQueuePresentation();
|
|
||||||
qs('#mobile-queue-next-action').addEventListener('click', () => mobileQueueLauncher.continueWork());
|
qs('#mobile-queue-next-action').addEventListener('click', () => mobileQueueLauncher.continueWork());
|
||||||
function openMobileStartDay() {
|
function openMobileStartDay() {
|
||||||
followingQueue.load().catch(() => {});
|
followingQueue.load().catch(() => {});
|
||||||
|
|
@ -199,7 +203,7 @@
|
||||||
qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';
|
qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';
|
||||||
const sheet = qs('#mobile-queue-sheet');
|
const sheet = qs('#mobile-queue-sheet');
|
||||||
if (!sheet.open) qs('#mobile-queue-sheet').showModal();
|
if (!sheet.open) qs('#mobile-queue-sheet').showModal();
|
||||||
qs('#mobile-start-day-action').focus();
|
qs('#mobile-queue-next-action').focus();
|
||||||
}
|
}
|
||||||
const mobileFirstTask = createMobileFirstTask({
|
const mobileFirstTask = createMobileFirstTask({
|
||||||
getLogin: () => confirmedOwnerLogin,
|
getLogin: () => confirmedOwnerLogin,
|
||||||
|
|
@ -238,17 +242,20 @@
|
||||||
qs('#mobile-queue-heading').textContent = 'Prepare Today · ' + current.label;
|
qs('#mobile-queue-heading').textContent = 'Prepare Today · ' + current.label;
|
||||||
const sheet = qs('#mobile-queue-sheet');
|
const sheet = qs('#mobile-queue-sheet');
|
||||||
if (!sheet.open) sheet.showModal();
|
if (!sheet.open) sheet.showModal();
|
||||||
qs('#mobile-start-day-action').focus();
|
qs('#mobile-queue-next-action').focus();
|
||||||
},
|
},
|
||||||
elements: {
|
elements: {
|
||||||
summary: qs('#mobile-start-day-summary'),
|
summary: qs('#mobile-start-day-summary'),
|
||||||
phases: qs('#mobile-start-day-phases'),
|
phases: qs('#mobile-start-day-phases'),
|
||||||
action: qs('#mobile-start-day-action'),
|
|
||||||
finish: qs('#finish-mobile-start-day'),
|
finish: qs('#finish-mobile-start-day'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
mobileStartDay.start();
|
mobileStartDay.start();
|
||||||
qs('#finish-mobile-start-day').addEventListener('click', () => mobileStartDay.finish());
|
renderMobileQueuePresentation();
|
||||||
|
qs('#finish-mobile-start-day').addEventListener('click', () => {
|
||||||
|
mobileStartDay.finish();
|
||||||
|
renderMobileQueuePresentation();
|
||||||
|
});
|
||||||
function showMobileQueueCompletion(completedName, cleared = true, phase = '') {
|
function showMobileQueueCompletion(completedName, cleared = true, phase = '') {
|
||||||
if (cleared && phase && mobileStartDay.completePhase(phase)) return;
|
if (cleared && phase && mobileStartDay.completePhase(phase)) return;
|
||||||
mobileStartDay.render();
|
mobileStartDay.render();
|
||||||
|
|
|
||||||
|
|
@ -2177,11 +2177,10 @@
|
||||||
<p id="mobile-start-day-summary" class="small" aria-live="polite">Reviewing urgent queues…</p>
|
<p id="mobile-start-day-summary" class="small" aria-live="polite">Reviewing urgent queues…</p>
|
||||||
<p id="mobile-start-day-phases" class="small muted">Checking Agenda, Attention, Updates, Filed, and Following</p>
|
<p id="mobile-start-day-phases" class="small muted">Checking Agenda, Attention, Updates, Filed, and Following</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="mobile-start-day-action" id="mobile-start-day-action" type="button">Prepare Today</button>
|
|
||||||
<button class="mobile-start-day-finish" id="finish-mobile-start-day" type="button" hidden>Finish for now</button>
|
<button class="mobile-start-day-finish" id="finish-mobile-start-day" type="button" hidden>Finish for now</button>
|
||||||
</section>
|
</section>
|
||||||
<section class="mobile-queue-next" aria-labelledby="mobile-queue-next-heading">
|
<section class="mobile-queue-next" aria-labelledby="mobile-queue-next-heading">
|
||||||
<p class="small muted" id="mobile-queue-next-heading">Next up</p>
|
<p class="small muted" id="mobile-queue-next-heading">Start / Continue</p>
|
||||||
<button id="mobile-queue-next-action" type="button">Find Work</button>
|
<button id="mobile-queue-next-action" type="button">Find Work</button>
|
||||||
</section>
|
</section>
|
||||||
<section class="mobile-queue-group" aria-labelledby="mobile-queue-active-heading" hidden>
|
<section class="mobile-queue-group" aria-labelledby="mobile-queue-active-heading" hidden>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,15 @@
|
||||||
return {name, count, label: label + ' (' + count + ')'};
|
return {name, count, label: label + ' (' + count + ')'};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function adaptiveRecommendation() {
|
||||||
|
const preparation = options.getPreparation ? options.getPreparation() : null;
|
||||||
|
if (preparation && (preparation.active || Number(preparation.total) > 0)) {
|
||||||
|
const prefix = preparation.active ? 'Resume preparation · ' : 'Start day · ';
|
||||||
|
return {name: 'prepare', count: Math.max(0, Number(preparation.total) || 0), label: prefix + preparation.label};
|
||||||
|
}
|
||||||
|
return recommend();
|
||||||
|
}
|
||||||
|
|
||||||
function presentation() {
|
function presentation() {
|
||||||
const counts = options.getCounts ? options.getCounts() : {};
|
const counts = options.getCounts ? options.getCounts() : {};
|
||||||
const active = activeQueueNames
|
const active = activeQueueNames
|
||||||
|
|
@ -45,7 +54,7 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
nextUp: recommend(),
|
nextUp: adaptiveRecommendation(),
|
||||||
active,
|
active,
|
||||||
planning: ['today', 'tomorrow', 'week'],
|
planning: ['today', 'tomorrow', 'week'],
|
||||||
all: allQueues.slice(),
|
all: allQueues.slice(),
|
||||||
|
|
@ -60,7 +69,8 @@
|
||||||
if (options.nextAction) {
|
if (options.nextAction) {
|
||||||
options.nextAction.textContent = view.nextUp.label;
|
options.nextAction.textContent = view.nextUp.label;
|
||||||
options.nextAction.dataset.queue = view.nextUp.name;
|
options.nextAction.dataset.queue = view.nextUp.name;
|
||||||
options.nextAction.setAttribute('aria-label', 'Next up: ' + view.nextUp.label);
|
const prefix = view.nextUp.name === 'prepare' ? 'Start or continue: ' : 'Next up: ';
|
||||||
|
options.nextAction.setAttribute('aria-label', prefix + view.nextUp.label);
|
||||||
}
|
}
|
||||||
active.forEach(item => {
|
active.forEach(item => {
|
||||||
const row = options.rows?.[item.name];
|
const row = options.rows?.[item.name];
|
||||||
|
|
@ -103,7 +113,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function continueWork() {
|
function continueWork() {
|
||||||
const next = recommend();
|
const next = adaptiveRecommendation();
|
||||||
|
if (next.name === 'prepare') {
|
||||||
|
options.openPreparation();
|
||||||
|
return 'prepare';
|
||||||
|
}
|
||||||
if (next.name === 'find') {
|
if (next.name === 'find') {
|
||||||
options.openFindWork();
|
options.openFindWork();
|
||||||
return 'find';
|
return 'find';
|
||||||
|
|
@ -111,5 +125,5 @@
|
||||||
return open(next.name);
|
return open(next.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { open, recommend, presentation, renderPresentation, continueWork };
|
return { open, recommend, adaptiveRecommendation, presentation, renderPresentation, continueWork };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -185,14 +185,16 @@
|
||||||
options.elements.phases.textContent = current.phases.length ?
|
options.elements.phases.textContent = current.phases.length ?
|
||||||
current.phases.map(phase => phase.label + ' ' + phase.count).join(' · ') :
|
current.phases.map(phase => phase.label + ' ' + phase.count).join(' · ') :
|
||||||
'All urgent queues reviewed';
|
'All urgent queues reviewed';
|
||||||
options.elements.action.textContent = checkpoint() ? 'Resume preparation · ' + current.label : current.label;
|
if (options.elements.action) {
|
||||||
|
options.elements.action.textContent = checkpoint() ? 'Resume preparation · ' + current.label : current.label;
|
||||||
|
}
|
||||||
if (options.elements.finish) options.elements.finish.hidden = !checkpoint();
|
if (options.elements.finish) options.elements.finish.hidden = !checkpoint();
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
render();
|
render();
|
||||||
if (options.elements) options.elements.action.addEventListener('click', startNext);
|
if (options.elements?.action) options.elements.action.addEventListener('click', startNext);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {briefing, completePhase, finish, reconcile, render, start, startNext, state};
|
return {briefing, completePhase, finish, reconcile, render, start, startNext, state};
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,25 @@ process.stdout.write(JSON.stringify({{first, ready, opened}}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_day_view_can_render_status_without_own_action_button():
|
||||||
|
script = f"""
|
||||||
|
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||||
|
const elements = {{summary:{{textContent:''}}, phases:{{textContent:''}}}};
|
||||||
|
const controller = createStartDay({{
|
||||||
|
getCounts: () => ({{attention:2, today:3}}),
|
||||||
|
openQueue: () => {{}},
|
||||||
|
elements,
|
||||||
|
}});
|
||||||
|
controller.start();
|
||||||
|
process.stdout.write(JSON.stringify({{summary:elements.summary.textContent, phases:elements.phases.textContent}}));
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert run_node(script) == {
|
||||||
|
"summary": "2 items before Today · 3 planned",
|
||||||
|
"phases": "Attention 2",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_today_pass_persists_per_account_and_hands_off_using_live_counts():
|
def test_prepare_today_pass_persists_per_account_and_hands_off_using_live_counts():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||||
|
|
@ -440,6 +459,22 @@ process.stdout.write(JSON.stringify(JSON.parse(saved.get('stackchain.mobile-star
|
||||||
assert run_node(script) == {"login": "timmy", "day": "2026-08-15", "phase": "agenda"}
|
assert run_node(script) == {"login": "timmy", "day": "2026-08-15", "phase": "agenda"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_queues_render_one_adaptive_start_or_continue_action():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert 'id="mobile-queue-next-heading">Start / Continue</p>' in html
|
||||||
|
assert html.count('id="mobile-queue-next-action"') == 1
|
||||||
|
assert 'id="mobile-start-day-action"' not in html
|
||||||
|
assert "getPreparation: () =>" in html
|
||||||
|
assert "openPreparation: () => mobileStartDay.startNext()" in html
|
||||||
|
assert "action: qs('#mobile-start-day-action')" not in html
|
||||||
|
assert "qs('#mobile-queue-next-action').focus();" in html
|
||||||
|
assert "mobileStartDay.finish();" in html
|
||||||
|
assert "renderMobileQueuePresentation();" in html
|
||||||
|
assert "qs('#finish-mobile-start-day').addEventListener('click', () => {" in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile_bundle():
|
async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile_bundle():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
@ -448,7 +483,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||||
assert 'class="mobile-start-day"' in html
|
assert 'class="mobile-start-day"' in html
|
||||||
assert 'id="mobile-start-day-summary"' in html
|
assert 'id="mobile-start-day-summary"' in html
|
||||||
assert 'id="mobile-start-day-phases"' in html
|
assert 'id="mobile-start-day-phases"' in html
|
||||||
assert 'id="mobile-start-day-action"' in html
|
assert 'id="mobile-queue-next-action"' in html
|
||||||
assert 'id="finish-mobile-start-day"' in html
|
assert 'id="finish-mobile-start-day"' in html
|
||||||
assert '<script src="static/mobile-start-day.js"></script>' in html
|
assert '<script src="static/mobile-start-day.js"></script>' in html
|
||||||
assert "const mobileStartDay = createMobileStartDay({" in html
|
assert "const mobileStartDay = createMobileStartDay({" in html
|
||||||
|
|
@ -465,7 +500,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||||
assert "queueCounts.followingUnavailable ? followingQueue.open()" in html
|
assert "queueCounts.followingUnavailable ? followingQueue.open()" in html
|
||||||
assert "Checking Agenda, Attention, Updates, Filed, and Following" in html
|
assert "Checking Agenda, Attention, Updates, Filed, and Following" in html
|
||||||
assert "mobileStartDay.render();" in html
|
assert "mobileStartDay.render();" in html
|
||||||
assert ".mobile-start-day-action { width:100%; min-height:48px;" in html
|
assert ".mobile-queue-next button { min-height:48px; width:100%;" in html
|
||||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||||
|
|
@ -481,7 +516,7 @@ async def test_primary_mobile_work_action_opens_a_unique_item_prepare_today_brie
|
||||||
assert "function openMobileStartDay()" in html
|
assert "function openMobileStartDay()" in html
|
||||||
assert "qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';" in html
|
assert "qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';" in html
|
||||||
assert "qs('#mobile-queue-sheet').showModal();" in html
|
assert "qs('#mobile-queue-sheet').showModal();" in html
|
||||||
assert "qs('#mobile-start-day-action').focus();" in html
|
assert "qs('#mobile-queue-next-action').focus();" in html
|
||||||
assert "let preparationItems = {};" in html
|
assert "let preparationItems = {};" in html
|
||||||
assert "getPhaseItems: () => preparationItems" in html
|
assert "getPhaseItems: () => preparationItems" in html
|
||||||
assert "preparationItems = {" in html
|
assert "preparationItems = {" in html
|
||||||
|
|
|
||||||
|
|
@ -752,6 +752,45 @@ process.stdout.write(JSON.stringify({{first, agenda, opened, fallback, calls}}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_queue_launcher_uses_one_adaptive_action_for_daily_preparation():
|
||||||
|
script = f"""
|
||||||
|
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||||
|
const calls = [];
|
||||||
|
let preparation = {{active:true, total:2, label:'Review Human Gates'}};
|
||||||
|
const nextAction = {{textContent:'', dataset:{{}}, attributes:{{}}, setAttribute(name, value) {{ this.attributes[name]=value; }}}};
|
||||||
|
const launcher = createLauncher({{
|
||||||
|
getCounts: () => ({{delivery:4}}),
|
||||||
|
getPreparation: () => preparation,
|
||||||
|
openPreparation: () => calls.push('prepare'),
|
||||||
|
nextAction,
|
||||||
|
}});
|
||||||
|
launcher.renderPresentation();
|
||||||
|
const resumed = [nextAction.textContent, nextAction.dataset.queue, nextAction.attributes['aria-label']];
|
||||||
|
launcher.continueWork();
|
||||||
|
preparation = {{active:false, total:2, label:'Review Agenda'}};
|
||||||
|
launcher.renderPresentation();
|
||||||
|
const started = [nextAction.textContent, nextAction.dataset.queue, nextAction.attributes['aria-label']];
|
||||||
|
launcher.continueWork();
|
||||||
|
process.stdout.write(JSON.stringify({{resumed, started, calls}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"resumed": [
|
||||||
|
"Resume preparation · Review Human Gates",
|
||||||
|
"prepare",
|
||||||
|
"Start or continue: Resume preparation · Review Human Gates",
|
||||||
|
],
|
||||||
|
"started": [
|
||||||
|
"Start day · Review Agenda",
|
||||||
|
"prepare",
|
||||||
|
"Start or continue: Start day · Review Agenda",
|
||||||
|
],
|
||||||
|
"calls": ["prepare", "prepare"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_queue_launcher_builds_truthful_next_up_and_active_sections():
|
def test_mobile_queue_launcher_builds_truthful_next_up_and_active_sections():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user