Merge pull request 'Defer selected mobile updates as one unread batch' (#660) from timmy/659-defer-selected-updates 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 15:14:22 +00:00
commit f04a547e19
6 changed files with 109 additions and 3 deletions

View File

@ -268,6 +268,9 @@ textarea { resize: vertical; min-height: 120px; }
.retry-work-route[hidden] { display:none; }
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.my-work-bulk button { min-height:44px; width:100%; }
.my-work-bulk-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.my-work-bulk-actions button { min-height:44px; width:100%; }
.my-work-bulk-actions #bulk-mark-read { grid-column:1 / -1; }
.my-work[data-stale="true"] { border-color:#fcd34d; }
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.review-sheet.open { display:flex; }

View File

@ -2504,6 +2504,7 @@
});
const bulkBar = qs('#bulk-mark-read-bar');
const bulkButton = qs('#bulk-mark-read');
const updateIds = notificationIds(visible);
const selectionControls = qs('#update-selection-controls');
selectionControls.hidden = selectedWorkFilter !== 'update' || updateIds.length === 0;
@ -2515,6 +2516,9 @@
qs('#load-more-notifications').hidden =
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
bulkButton.disabled = bulkMarkPending || selection.count === 0;
document.querySelectorAll('[data-defer-selected]').forEach(button => {
button.disabled = bulkMarkPending || selection.count === 0 || !planningOwnerLogin;
});
bulkButton.textContent = bulkConfirmationPending ?
'Confirm marking ' + selection.count + ' selected read' :
'Mark ' + selection.count + ' selected read';
@ -5514,6 +5518,18 @@
notificationSelection.cancel();
qs('#select-updates').focus();
});
document.querySelectorAll('[data-defer-selected]').forEach(button => button.addEventListener('click', () => {
const selection = notificationSelection.snapshot();
const ids = new Set(selection.ids);
const selectedUpdates = lastMyWork.filter(item => item.has_update && ids.has(item.notification_id));
const until = laterWork.presetUntil(button.dataset.deferSelected);
const result = laterWork.deferMany(selectedUpdates, until);
qs('#my-work-action-status').textContent = result === 'deferred' ?
selection.count + ' updates deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
'Could not save Later on this device. Selection unchanged.';
if (result === 'deferred') notificationSelection.cancel();
refreshMyWorkView();
}));
qs('#bulk-mark-read').addEventListener('click', async () => {
const selection = notificationSelection.snapshot();
if (!selection.ids.length || bulkMarkPending) return;

View File

@ -178,7 +178,11 @@
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
<div class="small" id="work-route-share-status" aria-live="polite"></div>
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
<button id="bulk-mark-read" type="button"></button>
<div class="my-work-bulk-actions">
<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>
</div>
</div>
</section>
<aside class="sidebar">

View File

@ -52,6 +52,22 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
return 'deferred';
}
function deferMany(items, until) {
const key = storageKey();
const wake = new Date(until);
const entries = (items || []).map(item => [identity(item), item]);
if (!key) return 'unavailable';
if (!entries.length || entries.some(([id]) => !id) || Number.isNaN(wake.getTime()) || wake <= now()) {
return 'invalid';
}
const records = read();
const wakeAt = wake.toISOString();
entries.forEach(([id]) => { records[id] = wakeAt; });
if (!write(records)) return 'unavailable';
entries.forEach(([id]) => onChange('defer', id, wakeAt));
return 'deferred';
}
function presetUntil(preset) {
const current = new Date(now());
if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
@ -127,7 +143,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
return { active, later };
}
return { identity, read, adopt, defer, restore, presetUntil, partition };
return { identity, read, adopt, defer, deferMany, restore, presetUntil, partition };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;

View File

@ -29,7 +29,7 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",),
"today-timer": (
"static/mobile-task-dock.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.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",
),
}

View File

@ -1343,6 +1343,59 @@ process.stdout.write(JSON.stringify({{noIdentity, invalid, failedWrite}}));
}
def test_later_queue_defers_a_batch_atomically_with_one_wake_time_and_change_per_item():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
const values = new Map();
const changes = [];
let writes = 0;
let fail = false;
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => {{ writes += 1; if (fail) throw new Error('quota'); values.set(key,value); }},
removeItem:key => values.delete(key),
}};
const first = {{kind:'update',repository:'stackchain/api',number:17,notification_id:91}};
const second = {{kind:'update',repository:'stackchain/web',number:8,notification_id:92}};
const store = createLaterWork({{
storage, getLogin:() => 'timmy', now:() => new Date('2026-08-08T12:00:00Z'),
setTimer:() => 1, clearTimer:() => {{}},
onChange:(...change) => changes.push(change),
}});
const deferred = store.deferMany([first, second], new Date('2026-08-09T09:00:00Z'));
const saved = store.partition([first, second]);
const beforeFailure = JSON.stringify(store.read());
fail = true;
const unavailable = store.deferMany([
{{kind:'update',repository:'stackchain/api',number:19,notification_id:93}},
{{kind:'update',repository:'stackchain/api',number:20,notification_id:94}},
], new Date('2026-08-10T09:00:00Z'));
process.stdout.write(JSON.stringify({{
deferred, unavailable, writes, changes,
later:saved.later.map(item => [item.notification_id,item.deferred_until]),
unchanged:beforeFailure === JSON.stringify(store.read()),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"deferred": "deferred",
"unavailable": "unavailable",
"writes": 2,
"changes": [
["defer", "update:stackchain/api:17:91", "2026-08-09T09:00:00.000Z"],
["defer", "update:stackchain/web:8:92", "2026-08-09T09:00:00.000Z"],
],
"later": [
[91, "2026-08-09T09:00:00.000Z"],
[92, "2026-08-09T09:00:00.000Z"],
],
"unchanged": True,
}
def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
@ -5582,6 +5635,20 @@ async def test_updates_view_offers_accessible_sticky_mobile_subset_selection():
assert "body: JSON.stringify({ ids })" in html
@pytest.mark.anyio
async def test_selected_updates_can_be_deferred_together_without_marking_them_read():
html = await dashboard()
assert 'id="defer-selected-today"' in html
assert 'id="defer-selected-tomorrow"' in html
assert 'laterWork.deferMany(selectedUpdates, until)' in html
assert "notificationSelection.cancel()" in html
assert "Selection unchanged." in html
assert "work stays unread and unchanged in Gitea" in html
assert '.my-work-bulk-actions { display:grid;' in html
assert '.my-work-bulk-actions button { min-height:44px; width:100%; }' in html
@pytest.mark.anyio
async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile():
html = await dashboard()