feat: personalize adaptive mobile queue priority (Closes #1460)
All checks were successful
CI / lint (pull_request) Successful in 3m53s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m39s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-27 07:09:06 +00:00
parent 890fd57531
commit dbabd8640b
10 changed files with 383 additions and 17 deletions

View File

@ -1515,6 +1515,16 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-queue-next { display:grid; gap:6px; margin-top:12px; padding:12px; border:1px solid #60a5fa; border-radius:14px; background:#122f50; } .mobile-queue-next { display:grid; gap:6px; margin-top:12px; padding:12px; border:1px solid #60a5fa; border-radius:14px; background:#122f50; }
.mobile-queue-next p, .mobile-queue-group h3 { margin:0; } .mobile-queue-next p, .mobile-queue-group h3 { margin:0; }
.mobile-queue-next button { min-height:48px; width:100%; text-align:center; font-weight:800; } .mobile-queue-next button { min-height:48px; width:100%; text-align:center; font-weight:800; }
.mobile-queue-priority { margin-top:12px; border:1px solid #31577f; border-radius:12px; background:#0b1b30; }
.mobile-queue-priority > summary { min-height:44px; display:flex; align-items:center; padding:0 12px; cursor:pointer; font-weight:700; }
.mobile-queue-priority > p { margin:0; padding:0 12px 10px; }
.mobile-queue-priority-list { display:grid; gap:6px; padding:0 8px; }
.mobile-queue-priority-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; min-width:0; padding:6px 4px; border-top:1px solid #233f61; }
.mobile-queue-priority-row > span:first-child { min-width:0; overflow-wrap:anywhere; font-weight:700; }
.mobile-queue-priority-controls { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:4px; }
.mobile-queue-priority-controls button { min-height:44px; min-width:64px; padding:6px 8px; }
.mobile-queue-priority-footer { display:grid; gap:6px; padding:10px 12px 12px; }
.mobile-queue-priority-footer button { min-height:44px; }
.mobile-queue-group { margin-top:16px; } .mobile-queue-group { margin-top:16px; }
.mobile-queue-group h3 { font-size:1rem; } .mobile-queue-group h3 { font-size:1rem; }
.mobile-queue-all { margin-top:16px; } .mobile-queue-all { margin-top:16px; }

View File

@ -114,6 +114,7 @@
let preparationItems = {}; let preparationItems = {};
let offlineWorkMode = false; let offlineWorkMode = false;
let renderMobileQueuePresentation = () => {}; let renderMobileQueuePresentation = () => {};
let mobileQueuePriority = null;
const followingQueue = attachFollowing(item => { const followingQueue = attachFollowing(item => {
searchPreviewReturnKind = 'following'; searchPreviewReturnKind = 'following';
return searchPreview.open(item); return searchPreview.open(item);
@ -183,6 +184,7 @@
announce: announceWork, announce: announceWork,
getCounts: () => queueCounts, getCounts: () => queueCounts,
isOnline: () => !offlineWorkMode, isOnline: () => !offlineWorkMode,
getRoutineOrder: () => mobileQueuePriority?.getOrder(),
getPreparation: () => { getPreparation: () => {
const briefing = mobileStartDay.briefing(); const briefing = mobileStartDay.briefing();
return {...briefing, active:mobileStartDay.state().active}; return {...briefing, active:mobileStartDay.state().active};
@ -428,6 +430,20 @@
let planningOwnerLogin = ''; let planningOwnerLogin = '';
let planningOwnerAccountKey = ''; let planningOwnerAccountKey = '';
let activeFlushLogin = ''; let activeFlushLogin = '';
mobileQueuePriority = createMobileQueuePriority({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
document,
list: qs('#mobile-queue-priority-list'),
resetButton: qs('#reset-mobile-queue-priority'),
status: qs('#mobile-queue-priority-status'),
labels: {
attention:'Attention', today:'Today', update:'Updates', agenda:'Agenda',
following:'Following', authored:'My PRs', filed:'Filed', later:'Later', draft:'Drafts',
},
onChange: () => renderMobileQueuePresentation(),
});
mobileQueuePriority.start();
let rR = null; let rR = null;
function rRC() { function rRC() {
if (rR) return rR; if (rR) return rR;
@ -5620,6 +5636,8 @@
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : ''; activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
if (activeFlushLogin) { if (activeFlushLogin) {
confirmedOwnerLogin = activeFlushLogin; confirmedOwnerLogin = activeFlushLogin;
mobileQueuePriority.render();
renderMobileQueuePresentation();
void refreshPhotoDraftInbox(); void refreshPhotoDraftInbox();
timerView.restore(todaySync.flush()); timerView.restore(todaySync.flush());
restoreReleaseReceipt(); restoreReleaseReceipt();
@ -7951,6 +7969,8 @@
if (!saved) return false; if (!saved) return false;
const outage = mode === 'outage'; const outage = mode === 'outage';
confirmedOwnerLogin = String(saved.user?.login || '').trim(); confirmedOwnerLogin = String(saved.user?.login || '').trim();
mobileQueuePriority.render();
renderMobileQueuePresentation();
restoreReleaseReceipt(); restoreReleaseReceipt();
planningOwnerLogin = confirmedOwnerLogin; planningOwnerLogin = confirmedOwnerLogin;
planningOwnerAccountKey = confirmedOwnerLogin && saved.user?.id ? planningOwnerAccountKey = confirmedOwnerLogin && saved.user?.id ?

View File

@ -2183,6 +2183,15 @@
<p class="small muted" id="mobile-queue-next-heading">Start / Continue</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>
<details class="mobile-queue-priority" id="mobile-queue-priority">
<summary>Customize routine order</summary>
<p class="small muted">Delivery and Human Gates always stay first. Move the routine queues to match how you work.</p>
<div id="mobile-queue-priority-list" class="mobile-queue-priority-list"></div>
<div class="mobile-queue-priority-footer">
<button id="reset-mobile-queue-priority" type="button">Reset order</button>
<span id="mobile-queue-priority-status" role="status" aria-live="polite" class="small"></span>
</div>
</details>
<section class="mobile-queue-group" aria-labelledby="mobile-queue-active-heading" hidden> <section class="mobile-queue-group" aria-labelledby="mobile-queue-active-heading" hidden>
<h3 id="mobile-queue-active-heading">Active now</h3> <h3 id="mobile-queue-active-heading">Active now</h3>
<div class="mobile-queue-list" id="mobile-queue-active-list"></div> <div class="mobile-queue-list" id="mobile-queue-active-list"></div>
@ -2402,6 +2411,7 @@
<script src="static/mobile-pull-refresh.js"></script> <script src="static/mobile-pull-refresh.js"></script>
<script src="static/mobile-first-task.js"></script> <script src="static/mobile-first-task.js"></script>
<script src="static/mobile-work-entry.js"></script> <script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-queue-priority.js"></script>
<script src="static/mobile-queue-launcher.js"></script> <script src="static/mobile-queue-launcher.js"></script>
<script src="static/mobile-delivery-recovery.js"></script> <script src="static/mobile-delivery-recovery.js"></script>
<script src="static/mobile-start-day.js"></script> <script src="static/mobile-start-day.js"></script>

View File

@ -8,30 +8,38 @@
later: 'No deferred work is ready to open.', later: 'No deferred work is ready to open.',
draft: 'No drafts are ready to open.', draft: 'No drafts are ready to open.',
}; };
const continuation = [ const labels = {
['delivery', 'Recover Delivery'], delivery: 'Recover Delivery', gate: 'Review Human Gates', attention: 'Start Attention',
['gate', 'Review Human Gates'], today: 'Continue Today', update: 'Resume Updates', agenda: 'Open Agenda',
['attention', 'Start Attention'], following: 'Review Following', authored: 'Open My PRs', filed: 'Review Filed',
['today', 'Continue Today'], later: 'Start Later', draft: 'Open Drafts',
['update', 'Resume Updates'], };
['agenda', 'Open Agenda'], const criticalQueueNames = ['delivery', 'gate'];
['following', 'Review Following'], const defaultRoutineOrder = [
['authored', 'Open My PRs'], 'attention', 'today', 'update', 'agenda', 'following', 'authored', 'filed', 'later', 'draft',
['filed', 'Review Filed'],
['later', 'Start Later'],
['draft', 'Open Drafts'],
]; ];
const activeQueueNames = continuation.map(([name]) => name);
const onlineOnlyQueues = new Set(['delivery', 'gate']); const onlineOnlyQueues = new Set(['delivery', 'gate']);
const allQueues = [ const allQueues = [
'today', 'tomorrow', 'week', 'agenda', 'delivery', 'gate', 'attention', 'today', 'tomorrow', 'week', 'agenda', 'delivery', 'gate', 'attention',
'update', 'following', 'filed', 'authored', 'later', 'draft', 'find', 'recaps', 'update', 'following', 'filed', 'authored', 'later', 'draft', 'find', 'recaps',
]; ];
function routineOrder() {
const proposed = options.getRoutineOrder ? options.getRoutineOrder() : defaultRoutineOrder;
if (!Array.isArray(proposed) || proposed.length !== defaultRoutineOrder.length ||
new Set(proposed).size !== defaultRoutineOrder.length ||
proposed.some(name => !defaultRoutineOrder.includes(name))) return defaultRoutineOrder.slice();
return proposed.slice();
}
function activeQueueNames() {
return criticalQueueNames.concat(routineOrder());
}
function recommend() { function recommend() {
const counts = options.getCounts ? options.getCounts() : {}; const counts = options.getCounts ? options.getCounts() : {};
const online = options.isOnline ? options.isOnline() : true; const online = options.isOnline ? options.isOnline() : true;
const match = continuation.find(([name]) => const match = activeQueueNames().map(name => [name, labels[name]]).find(([name]) =>
Number(counts[name]) > 0 && (online || !onlineOnlyQueues.has(name)) Number(counts[name]) > 0 && (online || !onlineOnlyQueues.has(name))
); );
if (!match) return {name: 'find', count: 0, label: 'Find Work'}; if (!match) return {name: 'find', count: 0, label: 'Find Work'};
@ -51,10 +59,11 @@
function presentation() { function presentation() {
const counts = options.getCounts ? options.getCounts() : {}; const counts = options.getCounts ? options.getCounts() : {};
const active = activeQueueNames const names = activeQueueNames();
const active = names
.map(name => ({name, count: Math.max(0, Number(counts[name]) || 0)})) .map(name => ({name, count: Math.max(0, Number(counts[name]) || 0)}))
.filter(item => item.count > 0); .filter(item => item.count > 0);
activeQueueNames.forEach(name => { names.forEach(name => {
if (counts[name + 'Unavailable'] && !active.some(item => item.name === name)) { if (counts[name + 'Unavailable'] && !active.some(item => item.name === name)) {
active.push({name, unavailable: true}); active.push({name, unavailable: true});
} }

View File

@ -0,0 +1,123 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileQueuePriority = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileQueuePriority(options = {}) {
const DEFAULT_ORDER = [
'attention', 'today', 'update', 'agenda', 'following', 'authored', 'filed', 'later', 'draft',
];
const storage = options.storage;
const getLogin = options.getLogin || (() => '');
const prefix = 'stackchain-mobile-queue-priority-v1:';
const labels = options.labels || {};
const documentRef = options.document || (typeof document !== 'undefined' ? document : null);
function key() {
const login = String(getLogin() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function valid(order) {
return Array.isArray(order) && order.length === DEFAULT_ORDER.length &&
new Set(order).size === DEFAULT_ORDER.length &&
order.every((name, index) => DEFAULT_ORDER.includes(name) && typeof name === 'string');
}
function getOrder() {
const accountKey = key();
if (!accountKey || !storage) return DEFAULT_ORDER.slice();
try {
const saved = JSON.parse(storage.getItem(accountKey) || 'null');
return valid(saved) ? saved.slice() : DEFAULT_ORDER.slice();
} catch (_error) {
return DEFAULT_ORDER.slice();
}
}
function save(order) {
const accountKey = key();
if (!accountKey || !storage || !valid(order)) return false;
try {
storage.setItem(accountKey, JSON.stringify(order));
options.onChange?.(order.slice());
return true;
} catch (_error) {
return false;
}
}
function move(name, delta) {
const order = getOrder();
const index = order.indexOf(name);
const next = index + (Number(delta) < 0 ? -1 : 1);
if (index < 0 || next < 0 || next >= order.length) return order;
[order[index], order[next]] = [order[next], order[index]];
save(order);
return order.slice();
}
function reset() {
const accountKey = key();
if (accountKey && storage) {
try { storage.removeItem(accountKey); } catch (_error) {}
}
const order = DEFAULT_ORDER.slice();
options.onChange?.(order.slice());
return order;
}
function displayName(name) {
return labels[name] || name.charAt(0).toUpperCase() + name.slice(1);
}
function render() {
if (!options.list || !documentRef) return getOrder();
const order = getOrder();
const signedIn = Boolean(key());
const rows = order.map((name, index) => {
const row = documentRef.createElement('div');
row.setAttribute('data-queue-priority', name);
row.setAttribute('class', 'mobile-queue-priority-row');
const label = documentRef.createElement('span');
label.textContent = displayName(name);
const controls = documentRef.createElement('span');
controls.setAttribute('class', 'mobile-queue-priority-controls');
const earlier = documentRef.createElement('button');
earlier.textContent = 'Earlier';
earlier.setAttribute('type', 'button');
earlier.setAttribute('aria-label', 'Move ' + displayName(name) + ' earlier');
earlier.disabled = !signedIn || index === 0;
earlier.addEventListener('click', () => {
move(name, -1);
if (options.status) options.status.textContent = displayName(name) + ' moved earlier.';
render();
});
const later = documentRef.createElement('button');
later.textContent = 'Later';
later.setAttribute('type', 'button');
later.setAttribute('aria-label', 'Move ' + displayName(name) + ' later');
later.disabled = !signedIn || index === order.length - 1;
later.addEventListener('click', () => {
move(name, 1);
if (options.status) options.status.textContent = displayName(name) + ' moved later.';
render();
});
controls.append(earlier, later);
row.append(label, controls);
return row;
});
options.list.replaceChildren(...rows);
if (options.resetButton) options.resetButton.disabled = !signedIn;
return order;
}
function start() {
options.resetButton?.addEventListener('click', () => {
reset();
if (options.status) options.status.textContent = 'Routine order reset.';
render();
});
return render();
}
return {getOrder, move, reset, render, start, defaultOrder: () => DEFAULT_ORDER.slice()};
});

View File

@ -270,6 +270,7 @@ const SHELL = [
BASE + 'static/mobile-task-dock.js', BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-first-task.js', BASE + 'static/mobile-first-task.js',
BASE + 'static/mobile-work-entry.js', BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-queue-priority.js',
BASE + 'static/mobile-queue-launcher.js', BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/mobile-delivery-recovery.js', BASE + 'static/mobile-delivery-recovery.js',
BASE + 'static/mobile-start-day.js', BASE + 'static/mobile-start-day.js',

View File

@ -44,7 +44,7 @@ FEATURE_SOURCES = {
), ),
"today-timer": ( "today-timer": (
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-search-modal.js", "static/mobile-composer-viewport.js", "static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-search-modal.js", "static/mobile-composer-viewport.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js", "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-priority.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.js", "static/unfiled-draft-sync.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",

View File

@ -166,3 +166,66 @@ def test_adaptive_queues_keep_start_continue_actionable_offline_on_phone():
assert bounds["y"] + bounds["height"] <= viewport["height"] assert bounds["y"] + bounds["height"] <= viewport["height"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close() browser.close()
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 375, "height": 667},
{"width": 430, "height": 932},
])
def test_operator_reorders_routine_queues_without_leaking_priority_between_accounts(viewport):
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "mobile-queue-priority.js")
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
page.evaluate("""() => {
const values = new Map();
window.login = 'alice';
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
.map(row => [row.dataset.mobileQueue, row]));
window.launcher = createMobileQueueLauncher({
getCounts:() => ({attention:2, following:3, authored:1}),
getRoutineOrder:() => window.priority.getOrder(), rows,
nextAction:document.querySelector('#mobile-queue-next-action'),
activeList:document.querySelector('#mobile-queue-active-list'),
planningList:document.querySelector('#mobile-queue-planning-list'),
allList:document.querySelector('#mobile-queue-all-list'),
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
});
window.priority = createMobileQueuePriority({
storage:{getItem:key => values.get(key) || null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:() => window.login, document,
list:document.querySelector('#mobile-queue-priority-list'),
resetButton:document.querySelector('#reset-mobile-queue-priority'),
status:document.querySelector('#mobile-queue-priority-status'),
labels:{attention:'Attention',today:'Today',update:'Updates',agenda:'Agenda',following:'Following',authored:'My PRs',filed:'Filed',later:'Later',draft:'Drafts'},
onChange:() => window.launcher.renderPresentation(),
});
window.priority.start(); window.launcher.renderPresentation();
document.querySelector('#mobile-queue-priority').open = true;
document.querySelector('#mobile-queue-sheet').showModal();
}""")
for _ in range(4):
page.get_by_role("button", name="Move Following earlier").click()
expect(page.locator("#mobile-queue-next-action")).to_have_text("Review Following (3)")
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
"rows => rows.map(row => row.dataset.mobileQueue)"
) == ["following", "attention", "authored"]
expect(page.locator("#mobile-queue-priority-status")).to_have_text("Following moved earlier.")
controls = page.locator(".mobile-queue-priority-controls button")
assert controls.count() == 18
assert all((controls.nth(i).bounding_box() or {}).get("height", 0) >= 44 for i in range(controls.count()))
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.evaluate("""() => {
window.login = 'bob'; window.priority.render(); window.launcher.renderPresentation();
}""")
expect(page.locator("#mobile-queue-next-action")).to_have_text("Start Attention (2)")
assert page.locator("#mobile-queue-priority-list [data-queue-priority]").evaluate_all(
"rows => rows.map(row => row.dataset.queuePriority)"
)[:3] == ["attention", "today", "update"]
browser.close()

View File

@ -10,6 +10,7 @@ from tests.dashboard_bundle import dashboard
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js" DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js" ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js"
QUEUE_LAUNCHER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queue-launcher.js" QUEUE_LAUNCHER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queue-launcher.js"
QUEUE_PRIORITY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queue-priority.js"
TIMER = Path(__file__).resolve().parents[1] / "frontend" / "today-timer.js" TIMER = Path(__file__).resolve().parents[1] / "frontend" / "today-timer.js"
@ -48,6 +49,88 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
} }
def test_mobile_queue_priority_persists_complete_account_scoped_routine_order():
script = f"""
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key, value) => values.set(key, value),
removeItem:key => values.delete(key),
}};
let login = 'alice';
const priority = createPriority({{storage, getLogin:() => login}});
const original = priority.getOrder();
priority.move('following', -1);
priority.move('following', -1);
priority.move('following', -1);
const alice = priority.getOrder();
login = 'bob';
const bob = priority.getOrder();
login = '';
const anonymous = priority.getOrder();
login = 'alice';
priority.reset();
process.stdout.write(JSON.stringify({{
original, alice, bob, anonymous, reset:priority.getOrder(), keys:Array.from(values.keys()),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
default = ["attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft"]
assert json.loads(result.stdout) == {
"original": default,
"alice": ["attention", "following", "today", "update", "agenda", "authored", "filed", "later", "draft"],
"bob": default,
"anonymous": default,
"reset": default,
"keys": [],
}
def test_mobile_queue_priority_renders_keyboard_controls_and_updates_immediately():
script = f"""
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
class Element {{
constructor(tag='div') {{ this.tag=tag; this.children=[]; this.listeners={{}}; this.attributes={{}}; this.disabled=false; this.textContent=''; }}
append(...items) {{ this.children.push(...items); }}
replaceChildren(...items) {{ this.children=[...items]; }}
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
setAttribute(name, value) {{ this.attributes[name]=value; }}
click() {{ this.listeners.click?.(); }}
}}
const values = new Map();
const list = new Element(); const resetButton = new Element('button'); const status = new Element();
const priority = createPriority({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
getLogin:()=>'alice', document:{{createElement:tag=>new Element(tag)}}, list, resetButton, status,
labels:{{attention:'Attention',today:'Today',update:'Updates',agenda:'Agenda',following:'Following',authored:'My PRs',filed:'Filed',later:'Later',draft:'Drafts'}},
}});
priority.start();
for (let index=0; index<3; index += 1) {{
const row = list.children.find(item => item.attributes['data-queue-priority'] === 'following');
row.children[1].children[0].click();
}}
const following = list.children.find(item => item.attributes['data-queue-priority'] === 'following');
process.stdout.write(JSON.stringify({{
order:list.children.map(item => item.attributes['data-queue-priority']),
earlierLabel:following.children[1].children[0].attributes['aria-label'],
laterLabel:following.children[1].children[1].attributes['aria-label'],
status:status.textContent,
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"order": ["attention", "following", "today", "update", "agenda", "authored", "filed", "later", "draft"],
"earlierLabel": "Move Following earlier",
"laterLabel": "Move Following later",
"status": "Following moved earlier.",
}
def test_mobile_work_entry_preserves_active_today_then_launches_highest_priority_queue(): def test_mobile_work_entry_preserves_active_today_then_launches_highest_priority_queue():
script = f""" script = f"""
const createEntry = require({json.dumps(str(ENTRY))}); const createEntry = require({json.dumps(str(ENTRY))});
@ -300,6 +383,22 @@ async def test_mobile_queue_sheet_prioritizes_next_active_and_planning_without_d
assert html.count(f'<button data-mobile-queue="{name}"') == 1 assert html.count(f'<button data-mobile-queue="{name}"') == 1
@pytest.mark.anyio
async def test_mobile_queue_priority_is_packaged_account_scoped_and_touch_safe():
html = await dashboard()
assert 'id="mobile-queue-priority"' in html
assert 'id="mobile-queue-priority-list"' in html
assert 'id="reset-mobile-queue-priority"' in html
assert 'id="mobile-queue-priority-status" role="status" aria-live="polite"' in html
assert '<script src="static/mobile-queue-priority.js"></script>' in html
assert "getRoutineOrder: () => mobileQueuePriority?.getOrder()" in html
assert "createMobileQueuePriority({" in html
assert "getLogin: () => confirmedOwnerLogin" in html
assert "mobileQueuePriority.render();\n renderMobileQueuePresentation();" in html
assert ".mobile-queue-priority-controls button { min-height:44px;" in html
@pytest.mark.anyio @pytest.mark.anyio
async def test_adaptive_mobile_queue_wiring_recommends_again_when_connectivity_changes(): async def test_adaptive_mobile_queue_wiring_recommends_again_when_connectivity_changes():
html = await dashboard() html = await dashboard()
@ -910,6 +1009,36 @@ process.stdout.write(JSON.stringify({{first, agenda, opened, fallback, calls}}))
} }
def test_mobile_queue_launcher_applies_routine_priority_without_demoting_safety_queues():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
let online = true;
const launcher = createLauncher({{
getCounts:() => ({{delivery:1, gate:2, attention:3, following:4, authored:5}}),
isOnline:() => online,
getRoutineOrder:() => ['following','authored','attention','today','update','agenda','filed','later','draft'],
}});
const onlineView = launcher.presentation();
online = false;
const offlineView = launcher.presentation();
process.stdout.write(JSON.stringify({{
onlineNext:onlineView.nextUp.name,
onlineActive:onlineView.active.map(item => item.name),
offlineNext:offlineView.nextUp.name,
offlineActive:offlineView.active.map(item => item.name),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"onlineNext": "delivery",
"onlineActive": ["delivery", "gate", "following", "authored", "attention"],
"offlineNext": "following",
"offlineActive": ["delivery", "gate", "following", "authored", "attention"],
}
def test_mobile_queue_launcher_uses_one_adaptive_action_for_daily_preparation(): def test_mobile_queue_launcher_uses_one_adaptive_action_for_daily_preparation():
script = f""" script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))}); const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});

View File

@ -1527,6 +1527,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-task-dock.js", "/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-first-task.js", "/dashboard/static/mobile-first-task.js",
"/dashboard/static/mobile-work-entry.js", "/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-queue-priority.js",
"/dashboard/static/mobile-queue-launcher.js", "/dashboard/static/mobile-queue-launcher.js",
"/dashboard/static/mobile-delivery-recovery.js", "/dashboard/static/mobile-delivery-recovery.js",
"/dashboard/static/mobile-start-day.js", "/dashboard/static/mobile-start-day.js",