Merge pull request 'Batch-plan work directly from mobile queues' (#662) from timmy/661-mobile-batch-planning into main
All checks were successful
CI / lint (push) Successful in 1m37s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s

This commit is contained in:
timmy 2026-08-12 16:05:43 +00:00
commit 97eb928187
9 changed files with 250 additions and 13 deletions

View File

@ -234,8 +234,10 @@ textarea { resize: vertical; min-height: 120px; }
.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 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 input { width:22px; height:22px; flex:0 0 auto; }
.update-selector { min-height:44px; }
.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.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; }

View File

@ -702,6 +702,7 @@
renderMyWork();
},
});
const workSelection = createWorkSelection({ limit: 50, onChange: () => renderMyWork() });
const notificationPager = createNotificationPager({
load: fetchNotificationPage,
onNotifications: items => {
@ -2325,6 +2326,8 @@
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
const selection = notificationSelection.snapshot();
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 => {
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
const routeItem = routedWorkItem(item);
@ -2340,10 +2343,13 @@
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
const selectable = selectedWorkFilter === 'update' && selection.active &&
item.has_update && Number.isInteger(item.notification_id);
const selector = 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 ? ' selection-active' : '') +
(selectedIds.has(item.notification_id) ? ' selected' : '');
const workSelectable = workSelectionState.active;
const workId = workSelection.identity(item);
const selector = workSelectable ?
'<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 ?
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
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 => {
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
});
@ -2507,18 +2523,30 @@
const updateIds = notificationIds(visible);
const selectionControls = qs('#update-selection-controls');
selectionControls.hidden = selectedWorkFilter !== 'update' || updateIds.length === 0;
qs('#select-updates').hidden = selection.active;
const planningQueue = !['today', 'later', 'draft'].includes(selectedWorkFilter) && visible.length > 0;
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('#update-selection-status').textContent = selection.active ?
selection.count + ' of 50 selected' : 'Choose specific updates to keep important work unread.';
bulkBar.hidden = selectedWorkFilter !== 'update' || !selection.active;
qs('#update-selection-status').textContent = workSelectionState.active ?
workSelectionState.count + ' selected' : (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 =
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
bulkButton.disabled = bulkMarkPending || selection.count === 0;
document.querySelectorAll('[data-defer-selected]').forEach(button => {
button.hidden = workSelectionState.active;
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 ?
'Confirm marking ' + selection.count + ' selected read' :
'Mark ' + selection.count + ' selected read';
@ -5509,6 +5537,44 @@
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', () => {
notificationSelection.start();
qs('#update-selection-status').textContent = '0 of 50 selected';
@ -5565,6 +5631,7 @@
const leavingUpdates = selectedWorkFilter === 'update' && filter !== 'update';
selectedWorkFilter = filter;
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
if (workSelection.snapshot().active) workSelection.cancel();
savedWorkFilter = selectedWorkFilter;
launchFilterResolved = true;
try {

View File

@ -163,6 +163,8 @@
</div>
</section>
<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="cancel-update-selection" type="button" hidden>Cancel selection</button>
<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="my-work-bulk" id="bulk-mark-read-bar" hidden>
<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-tomorrow" data-defer-selected="tomorrow" type="button">Tomorrow</button>
<button id="bulk-mark-read" type="button"></button>
@ -911,6 +916,7 @@
<script src="static/my-work.js"></script>
<script src="static/notification-undo.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-timer.js"></script>
<script src="static/today-recap.js"></script>

View File

@ -34,6 +34,7 @@ const SHELL = [
BASE + 'static/my-work.js',
BASE + 'static/notification-undo.js',
BASE + 'static/card-planning.js',
BASE + 'static/work-selection.js',
BASE + 'static/today-work.js',
BASE + 'static/today-timer.js',
BASE + 'static/today-recap.js',

View File

@ -91,6 +91,21 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
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) {
const unique = [];
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;

View 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;

View File

@ -30,7 +30,8 @@ FEATURE_SOURCES = {
"today-timer": (
"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/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(

View File

@ -25,6 +25,63 @@ PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.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():
@ -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
@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
async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile():
html = await dashboard()

View File

@ -697,6 +697,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/my-work.js",
"/dashboard/static/notification-undo.js",
"/dashboard/static/card-planning.js",
"/dashboard/static/work-selection.js",
"/dashboard/static/today-work.js",
"/dashboard/static/today-timer.js",
"/dashboard/static/today-recap.js",