Merge pull request 'Personalize routine priority in adaptive mobile Queues' (#1461) from timmy/1460-personalized-mobile-queue-priority into main
This commit is contained in:
commit
bf716cab6e
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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 ?
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
123
frontend/mobile-queue-priority.js
Normal file
123
frontend/mobile-queue-priority.js
Normal 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()};
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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))});
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user