Merge pull request 'Defer selected mobile updates as one unread batch' (#660) from timmy/659-defer-selected-updates into main
This commit is contained in:
commit
f04a547e19
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user