Merge pull request 'Batch-plan work directly from mobile queues' (#662) from timmy/661-mobile-batch-planning into main
This commit is contained in:
commit
97eb928187
|
|
@ -234,8 +234,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||||
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||||
.update-selection-controls button { min-height:44px; }
|
.update-selection-controls button { min-height:44px; }
|
||||||
.update-selector { min-height:44px; display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
.update-selector { min-height:44px; }
|
||||||
.update-selector input { width:22px; height:22px; flex:0 0 auto; }
|
.work-selector { min-height:44px; }
|
||||||
|
.update-selector, .work-selector { display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||||
|
.update-selector input, .work-selector input { width:22px; height:22px; flex:0 0 auto; }
|
||||||
.my-work-card.selection-active { border-color:#31577f; }
|
.my-work-card.selection-active { border-color:#31577f; }
|
||||||
.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
|
.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
|
||||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||||
|
|
|
||||||
|
|
@ -702,6 +702,7 @@
|
||||||
renderMyWork();
|
renderMyWork();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const workSelection = createWorkSelection({ limit: 50, onChange: () => renderMyWork() });
|
||||||
const notificationPager = createNotificationPager({
|
const notificationPager = createNotificationPager({
|
||||||
load: fetchNotificationPage,
|
load: fetchNotificationPage,
|
||||||
onNotifications: items => {
|
onNotifications: items => {
|
||||||
|
|
@ -2325,6 +2326,8 @@
|
||||||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||||||
const selection = notificationSelection.snapshot();
|
const selection = notificationSelection.snapshot();
|
||||||
const selectedIds = new Set(selection.ids);
|
const selectedIds = new Set(selection.ids);
|
||||||
|
const workSelectionState = workSelection.snapshot();
|
||||||
|
const selectedWorkIds = new Set(workSelectionState.ids);
|
||||||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||||||
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
||||||
const routeItem = routedWorkItem(item);
|
const routeItem = routedWorkItem(item);
|
||||||
|
|
@ -2340,10 +2343,13 @@
|
||||||
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
||||||
const selectable = selectedWorkFilter === 'update' && selection.active &&
|
const selectable = selectedWorkFilter === 'update' && selection.active &&
|
||||||
item.has_update && Number.isInteger(item.notification_id);
|
item.has_update && Number.isInteger(item.notification_id);
|
||||||
const selector = selectable ?
|
const workSelectable = workSelectionState.active;
|
||||||
'<label class="update-selector"><input type="checkbox" data-select-notification-id="' + item.notification_id + '" aria-label="Select update ' + escAttr(item.key + ' ' + item.title) + '"' + (selectedIds.has(item.notification_id) ? ' checked' : '') + '> Select</label>' : '';
|
const workId = workSelection.identity(item);
|
||||||
const cardClasses = 'my-work-card' + (selectable ? ' selection-active' : '') +
|
const selector = workSelectable ?
|
||||||
(selectedIds.has(item.notification_id) ? ' selected' : '');
|
'<label class="work-selector"><input type="checkbox" data-select-work-id="' + escAttr(workId) + '" data-work-index="' + index + '" aria-label="Select work ' + escAttr(item.key + ' ' + item.title) + '"' + (selectedWorkIds.has(workId) ? ' checked' : '') + '> Select</label>' : (selectable ?
|
||||||
|
'<label class="update-selector"><input type="checkbox" data-select-notification-id="' + item.notification_id + '" aria-label="Select update ' + escAttr(item.key + ' ' + item.title) + '"' + (selectedIds.has(item.notification_id) ? ' checked' : '') + '> Select</label>' : '');
|
||||||
|
const cardClasses = 'my-work-card' + ((selectable || workSelectable) ? ' selection-active' : '') +
|
||||||
|
((selectedIds.has(item.notification_id) || selectedWorkIds.has(workId)) ? ' selected' : '');
|
||||||
const markRead = item.has_update && Number.isInteger(item.notification_id) && !selection.active ?
|
const markRead = item.has_update && Number.isInteger(item.notification_id) && !selection.active ?
|
||||||
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
|
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
|
||||||
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
|
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
|
||||||
|
|
@ -2384,6 +2390,16 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll('[data-select-work-id]').forEach(input => {
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
const item = lastMyWork[Number(input.dataset.workIndex)];
|
||||||
|
const result = input.checked ? workSelection.select(item) : workSelection.toggle(item);
|
||||||
|
if (result === 'limit') {
|
||||||
|
input.checked = false;
|
||||||
|
qs('#my-work-action-status').textContent = 'Select up to 50 work items per batch.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||||||
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
||||||
});
|
});
|
||||||
|
|
@ -2507,18 +2523,30 @@
|
||||||
|
|
||||||
const updateIds = notificationIds(visible);
|
const updateIds = notificationIds(visible);
|
||||||
const selectionControls = qs('#update-selection-controls');
|
const selectionControls = qs('#update-selection-controls');
|
||||||
selectionControls.hidden = selectedWorkFilter !== 'update' || updateIds.length === 0;
|
const planningQueue = !['today', 'later', 'draft'].includes(selectedWorkFilter) && visible.length > 0;
|
||||||
qs('#select-updates').hidden = selection.active;
|
selectionControls.hidden = !planningQueue && (selectedWorkFilter !== 'update' || updateIds.length === 0);
|
||||||
|
qs('#select-work').hidden = !planningQueue || workSelectionState.active || selection.active;
|
||||||
|
qs('#cancel-work-selection').hidden = !workSelectionState.active;
|
||||||
|
qs('#select-updates').hidden = selectedWorkFilter !== 'update' || selection.active || workSelectionState.active;
|
||||||
qs('#cancel-update-selection').hidden = !selection.active;
|
qs('#cancel-update-selection').hidden = !selection.active;
|
||||||
qs('#update-selection-status').textContent = selection.active ?
|
qs('#update-selection-status').textContent = workSelectionState.active ?
|
||||||
selection.count + ' of 50 selected' : 'Choose specific updates to keep important work unread.';
|
workSelectionState.count + ' selected' : (selection.active ?
|
||||||
bulkBar.hidden = selectedWorkFilter !== 'update' || !selection.active;
|
selection.count + ' of 50 selected' : 'Choose work to plan together or specific updates to mark read.');
|
||||||
|
bulkBar.hidden = !workSelectionState.active && (selectedWorkFilter !== 'update' || !selection.active);
|
||||||
qs('#load-more-notifications').hidden =
|
qs('#load-more-notifications').hidden =
|
||||||
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
|
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
|
||||||
bulkButton.disabled = bulkMarkPending || selection.count === 0;
|
bulkButton.disabled = bulkMarkPending || selection.count === 0;
|
||||||
document.querySelectorAll('[data-defer-selected]').forEach(button => {
|
document.querySelectorAll('[data-defer-selected]').forEach(button => {
|
||||||
|
button.hidden = workSelectionState.active;
|
||||||
button.disabled = bulkMarkPending || selection.count === 0 || !planningOwnerLogin;
|
button.disabled = bulkMarkPending || selection.count === 0 || !planningOwnerLogin;
|
||||||
});
|
});
|
||||||
|
qs('#batch-add-today').hidden = !workSelectionState.active;
|
||||||
|
qs('#batch-add-today').disabled = workSelectionState.count === 0 || !planningOwnerLogin;
|
||||||
|
document.querySelectorAll('[data-batch-defer]').forEach(button => {
|
||||||
|
button.hidden = !workSelectionState.active;
|
||||||
|
button.disabled = workSelectionState.count === 0 || !planningOwnerLogin;
|
||||||
|
});
|
||||||
|
bulkButton.hidden = workSelectionState.active;
|
||||||
bulkButton.textContent = bulkConfirmationPending ?
|
bulkButton.textContent = bulkConfirmationPending ?
|
||||||
'Confirm marking ' + selection.count + ' selected read' :
|
'Confirm marking ' + selection.count + ' selected read' :
|
||||||
'Mark ' + selection.count + ' selected read';
|
'Mark ' + selection.count + ' selected read';
|
||||||
|
|
@ -5509,6 +5537,44 @@
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
qs('#select-work').addEventListener('click', () => {
|
||||||
|
if (notificationSelection.snapshot().active) notificationSelection.cancel();
|
||||||
|
workSelection.start();
|
||||||
|
document.querySelector('[data-select-work-id]')?.focus();
|
||||||
|
});
|
||||||
|
qs('#cancel-work-selection').addEventListener('click', () => {
|
||||||
|
workSelection.cancel();
|
||||||
|
qs('#select-work').focus();
|
||||||
|
});
|
||||||
|
qs('#batch-add-today').addEventListener('click', () => {
|
||||||
|
const ids = new Set(workSelection.snapshot().ids);
|
||||||
|
const selectedItems = lastMyWork.filter(item => ids.has(workSelection.identity(item)));
|
||||||
|
const result = todayWork.addMany(selectedItems);
|
||||||
|
if (result.status === 'added') {
|
||||||
|
result.ids.forEach(id => todaySync.enqueue('add', id));
|
||||||
|
todaySync.flush();
|
||||||
|
warmTodayOffline();
|
||||||
|
qs('#my-work-action-status').textContent = result.ids.length + ' items added to Today without changing Gitea.';
|
||||||
|
workSelection.cancel();
|
||||||
|
} else {
|
||||||
|
qs('#my-work-action-status').textContent = result.status === 'full' ?
|
||||||
|
'The full selection will not fit in Today. Nothing was added; selection unchanged.' :
|
||||||
|
(result.status === 'exists' ? 'Every selected item is already in Today.' :
|
||||||
|
'Could not save Today on this device. Nothing was added; selection unchanged.');
|
||||||
|
}
|
||||||
|
refreshMyWorkView();
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-batch-defer]').forEach(button => button.addEventListener('click', () => {
|
||||||
|
const ids = new Set(workSelection.snapshot().ids);
|
||||||
|
const selectedItems = lastMyWork.filter(item => ids.has(workSelection.identity(item)));
|
||||||
|
const until = laterWork.presetUntil(button.dataset.batchDefer);
|
||||||
|
const result = laterWork.deferMany(selectedItems, until);
|
||||||
|
qs('#my-work-action-status').textContent = result === 'deferred' ?
|
||||||
|
selectedItems.length + ' items deferred until ' + fmt(until) + '; updates stay unread and Gitea is unchanged.' :
|
||||||
|
'Could not save Later on this device. Selection unchanged.';
|
||||||
|
if (result === 'deferred') workSelection.cancel();
|
||||||
|
refreshMyWorkView();
|
||||||
|
}));
|
||||||
qs('#select-updates').addEventListener('click', () => {
|
qs('#select-updates').addEventListener('click', () => {
|
||||||
notificationSelection.start();
|
notificationSelection.start();
|
||||||
qs('#update-selection-status').textContent = '0 of 50 selected';
|
qs('#update-selection-status').textContent = '0 of 50 selected';
|
||||||
|
|
@ -5565,6 +5631,7 @@
|
||||||
const leavingUpdates = selectedWorkFilter === 'update' && filter !== 'update';
|
const leavingUpdates = selectedWorkFilter === 'update' && filter !== 'update';
|
||||||
selectedWorkFilter = filter;
|
selectedWorkFilter = filter;
|
||||||
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
|
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
|
||||||
|
if (workSelection.snapshot().active) workSelection.cancel();
|
||||||
savedWorkFilter = selectedWorkFilter;
|
savedWorkFilter = selectedWorkFilter;
|
||||||
launchFilterResolved = true;
|
launchFilterResolved = true;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,8 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="update-selection-controls" id="update-selection-controls" hidden>
|
<div class="update-selection-controls" id="update-selection-controls" hidden>
|
||||||
|
<button id="select-work" type="button">Select work</button>
|
||||||
|
<button id="cancel-work-selection" type="button" hidden>Cancel selection</button>
|
||||||
<button id="select-updates" type="button">Select updates</button>
|
<button id="select-updates" type="button">Select updates</button>
|
||||||
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
|
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
|
||||||
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
|
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
|
||||||
|
|
@ -179,6 +181,9 @@
|
||||||
<div class="small" id="work-route-share-status" aria-live="polite"></div>
|
<div class="small" id="work-route-share-status" aria-live="polite"></div>
|
||||||
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
||||||
<div class="my-work-bulk-actions">
|
<div class="my-work-bulk-actions">
|
||||||
|
<button id="batch-add-today" type="button" hidden>Add to Today</button>
|
||||||
|
<button data-batch-defer="today" type="button" hidden>Later today</button>
|
||||||
|
<button data-batch-defer="tomorrow" type="button" hidden>Tomorrow</button>
|
||||||
<button id="defer-selected-today" data-defer-selected="today" type="button">Later today</button>
|
<button id="defer-selected-today" data-defer-selected="today" type="button">Later today</button>
|
||||||
<button id="defer-selected-tomorrow" data-defer-selected="tomorrow" type="button">Tomorrow</button>
|
<button id="defer-selected-tomorrow" data-defer-selected="tomorrow" type="button">Tomorrow</button>
|
||||||
<button id="bulk-mark-read" type="button"></button>
|
<button id="bulk-mark-read" type="button"></button>
|
||||||
|
|
@ -911,6 +916,7 @@
|
||||||
<script src="static/my-work.js"></script>
|
<script src="static/my-work.js"></script>
|
||||||
<script src="static/notification-undo.js"></script>
|
<script src="static/notification-undo.js"></script>
|
||||||
<script src="static/card-planning.js"></script>
|
<script src="static/card-planning.js"></script>
|
||||||
|
<script src="static/work-selection.js"></script>
|
||||||
<script src="static/today-work.js"></script>
|
<script src="static/today-work.js"></script>
|
||||||
<script src="static/today-timer.js"></script>
|
<script src="static/today-timer.js"></script>
|
||||||
<script src="static/today-recap.js"></script>
|
<script src="static/today-recap.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const SHELL = [
|
||||||
BASE + 'static/my-work.js',
|
BASE + 'static/my-work.js',
|
||||||
BASE + 'static/notification-undo.js',
|
BASE + 'static/notification-undo.js',
|
||||||
BASE + 'static/card-planning.js',
|
BASE + 'static/card-planning.js',
|
||||||
|
BASE + 'static/work-selection.js',
|
||||||
BASE + 'static/today-work.js',
|
BASE + 'static/today-work.js',
|
||||||
BASE + 'static/today-timer.js',
|
BASE + 'static/today-timer.js',
|
||||||
BASE + 'static/today-recap.js',
|
BASE + 'static/today-recap.js',
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,21 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||||
return write(ids) ? 'added' : 'unavailable';
|
return write(ids) ? 'added' : 'unavailable';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addMany(items) {
|
||||||
|
if (!storageKey()) return { status: 'unavailable', ids: [] };
|
||||||
|
const current = read();
|
||||||
|
const additions = [];
|
||||||
|
for (const item of items || []) {
|
||||||
|
const id = identity(item);
|
||||||
|
if (!id) return { status: 'unavailable', ids: [] };
|
||||||
|
if (!current.includes(id) && !additions.includes(id)) additions.push(id);
|
||||||
|
}
|
||||||
|
if (!additions.length) return { status: 'exists', ids: [] };
|
||||||
|
if (current.length + additions.length > limit) return { status: 'full', ids: [] };
|
||||||
|
return write(current.concat(additions)) ?
|
||||||
|
{ status: 'added', ids: additions } : { status: 'unavailable', ids: [] };
|
||||||
|
}
|
||||||
|
|
||||||
function replace(ids) {
|
function replace(ids) {
|
||||||
const unique = [];
|
const unique = [];
|
||||||
for (const id of ids || []) {
|
for (const id of ids || []) {
|
||||||
|
|
@ -162,7 +177,7 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { identity, read, replace, planning, replacePlanning, runway, add, remove, move, reconcile, contains, position, limit };
|
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, move, reconcile, contains, position, limit };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||||
|
|
|
||||||
68
frontend/work-selection.js
Normal file
68
frontend/work-selection.js
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||||
|
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||||
|
let active = false;
|
||||||
|
const selected = new Set();
|
||||||
|
|
||||||
|
function identity(item) {
|
||||||
|
if (!item) return '';
|
||||||
|
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||||
|
const number = Number.isInteger(item.number) ? item.number : '';
|
||||||
|
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||||
|
return [kind, item.repository || '', number, notification].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function changed() {
|
||||||
|
const state = snapshot();
|
||||||
|
onChange(state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
active = true;
|
||||||
|
selected.clear();
|
||||||
|
return changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
active = false;
|
||||||
|
selected.clear();
|
||||||
|
return changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(item) {
|
||||||
|
if (!active) return 'inactive';
|
||||||
|
const id = identity(item);
|
||||||
|
if (!id) return 'invalid';
|
||||||
|
if (selected.has(id)) return 'selected';
|
||||||
|
if (selected.size >= maximum) return 'limit';
|
||||||
|
selected.add(id);
|
||||||
|
changed();
|
||||||
|
return 'selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(item) {
|
||||||
|
if (!active) return 'inactive';
|
||||||
|
const id = identity(item);
|
||||||
|
if (!id) return 'invalid';
|
||||||
|
if (selected.has(id)) {
|
||||||
|
selected.delete(id);
|
||||||
|
changed();
|
||||||
|
return 'removed';
|
||||||
|
}
|
||||||
|
return select(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function retain(items) {
|
||||||
|
const retained = new Set((items || []).map(identity).filter(Boolean));
|
||||||
|
Array.from(selected).forEach(id => { if (!retained.has(id)) selected.delete(id); });
|
||||||
|
return changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot() {
|
||||||
|
return { active, count: selected.size, ids: Array.from(selected) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { identity, start, cancel, select, toggle, retain, snapshot, limit: maximum };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createWorkSelection;
|
||||||
|
|
@ -30,7 +30,8 @@ FEATURE_SOURCES = {
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/mobile-task-dock.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
"static/mobile-task-dock.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||||
|
"static/today-work.js",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
CACHE_DECLARATION = re.compile(
|
CACHE_DECLARATION = re.compile(
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,63 @@ PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||||
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
||||||
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
|
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
|
||||||
CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
|
CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
|
||||||
|
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
|
||||||
|
WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_batch_admission_is_ordered_deduplicated_and_atomic():
|
||||||
|
script = f"""
|
||||||
|
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||||
|
function storage(fail = false) {{
|
||||||
|
const values = new Map();
|
||||||
|
return {{
|
||||||
|
getItem:key => values.get(key) || null,
|
||||||
|
setItem(key, value) {{ if (fail) throw new Error('quota'); values.set(key, value); }},
|
||||||
|
removeItem:key => values.delete(key),
|
||||||
|
}};
|
||||||
|
}}
|
||||||
|
const first = {{kind:'issue',repository:'o/r',number:1}};
|
||||||
|
const second = {{kind:'pull',repository:'o/r',number:2}};
|
||||||
|
const third = {{kind:'issue',repository:'o/r',number:3}};
|
||||||
|
const today = createTodayWork({{storage:storage(),getLogin:()=> 'timmy',limit:3}});
|
||||||
|
const admitted = today.addMany([first, first, second]);
|
||||||
|
const existing = today.addMany([second, third]);
|
||||||
|
const full = today.addMany([{{kind:'issue',repository:'o/r',number:4}}]);
|
||||||
|
const broken = createTodayWork({{storage:storage(true),getLogin:()=> 'timmy'}});
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
admitted, existing, full, ids:today.read(), broken:broken.addMany([first, second]), brokenIds:broken.read(),
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"admitted": {"status": "added", "ids": ["issue:o/r:1:", "pull:o/r:2:"]},
|
||||||
|
"existing": {"status": "added", "ids": ["issue:o/r:3:"]},
|
||||||
|
"full": {"status": "full", "ids": []},
|
||||||
|
"ids": ["issue:o/r:1:", "pull:o/r:2:", "issue:o/r:3:"],
|
||||||
|
"broken": {"status": "unavailable", "ids": []},
|
||||||
|
"brokenIds": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_work_selection_tracks_cross_kind_items_and_retains_failures():
|
||||||
|
script = f"""
|
||||||
|
const create = require({json.dumps(str(WORK_SELECTION))});
|
||||||
|
const selection = create({{limit:3}});
|
||||||
|
const issue = {{kind:'issue',repository:'o/r',number:1,title:'One'}};
|
||||||
|
const pull = {{kind:'pull',repository:'o/r',number:2,title:'Two'}};
|
||||||
|
selection.start(); selection.select(issue); selection.select(pull);
|
||||||
|
const before = selection.snapshot();
|
||||||
|
selection.retain([pull]);
|
||||||
|
process.stdout.write(JSON.stringify({{before, after:selection.snapshot(), issueId:selection.identity(issue)}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"before": {"active": True, "count": 2, "ids": ["issue:o/r:1:", "pull:o/r:2:"]},
|
||||||
|
"after": {"active": True, "count": 1, "ids": ["pull:o/r:2:"]},
|
||||||
|
"issueId": "issue:o/r:1:",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_card_planning_disclosures_keep_one_open_and_escape_restores_focus():
|
def test_card_planning_disclosures_keep_one_open_and_escape_restores_focus():
|
||||||
|
|
@ -5649,6 +5706,25 @@ async def test_selected_updates_can_be_deferred_together_without_marking_them_re
|
||||||
assert '.my-work-bulk-actions button { min-height:44px; width:100%; }' in html
|
assert '.my-work-bulk-actions button { min-height:44px; width:100%; }' in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_work_queues_batch_plan_cross_kind_selection():
|
||||||
|
html = await dashboard()
|
||||||
|
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
|
assert '<script src="static/work-selection.js"></script>' in html
|
||||||
|
assert 'id="select-work"' in html
|
||||||
|
assert 'id="batch-add-today"' in html
|
||||||
|
assert 'createWorkSelection({ limit: 50' in html
|
||||||
|
assert 'todayWork.addMany(selectedItems)' in html
|
||||||
|
assert 'laterWork.deferMany(selectedItems, until)' in html
|
||||||
|
assert 'data-select-work-id=' in html
|
||||||
|
assert 'Select work ' in html
|
||||||
|
assert "workSelectionState.count + ' selected'" in html
|
||||||
|
assert '.work-selector { min-height:44px;' in html
|
||||||
|
assert '.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }' in html
|
||||||
|
assert "BASE + 'static/work-selection.js'" in service_worker
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile():
|
async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
|
|
@ -697,6 +697,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/my-work.js",
|
"/dashboard/static/my-work.js",
|
||||||
"/dashboard/static/notification-undo.js",
|
"/dashboard/static/notification-undo.js",
|
||||||
"/dashboard/static/card-planning.js",
|
"/dashboard/static/card-planning.js",
|
||||||
|
"/dashboard/static/work-selection.js",
|
||||||
"/dashboard/static/today-work.js",
|
"/dashboard/static/today-work.js",
|
||||||
"/dashboard/static/today-timer.js",
|
"/dashboard/static/today-timer.js",
|
||||||
"/dashboard/static/today-recap.js",
|
"/dashboard/static/today-recap.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user