feat: batch-plan mobile work queues (Closes #661)
This commit is contained in:
parent
f04a547e19
commit
80a4096118
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
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": (
|
||||
"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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user