Defer selected mobile updates as one unread batch #660
|
|
@ -268,6 +268,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.retry-work-route[hidden] { display:none; }
|
.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 { 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 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; }
|
.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 { 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; }
|
.review-sheet.open { display:flex; }
|
||||||
|
|
|
||||||
|
|
@ -2504,6 +2504,7 @@
|
||||||
});
|
});
|
||||||
const bulkBar = qs('#bulk-mark-read-bar');
|
const bulkBar = qs('#bulk-mark-read-bar');
|
||||||
const bulkButton = qs('#bulk-mark-read');
|
const bulkButton = qs('#bulk-mark-read');
|
||||||
|
|
||||||
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;
|
selectionControls.hidden = selectedWorkFilter !== 'update' || updateIds.length === 0;
|
||||||
|
|
@ -2515,6 +2516,9 @@
|
||||||
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 => {
|
||||||
|
button.disabled = bulkMarkPending || selection.count === 0 || !planningOwnerLogin;
|
||||||
|
});
|
||||||
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';
|
||||||
|
|
@ -5514,6 +5518,18 @@
|
||||||
notificationSelection.cancel();
|
notificationSelection.cancel();
|
||||||
qs('#select-updates').focus();
|
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 () => {
|
qs('#bulk-mark-read').addEventListener('click', async () => {
|
||||||
const selection = notificationSelection.snapshot();
|
const selection = notificationSelection.snapshot();
|
||||||
if (!selection.ids.length || bulkMarkPending) return;
|
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>
|
<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="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>
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,22 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||||
return 'deferred';
|
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) {
|
function presetUntil(preset) {
|
||||||
const current = new Date(now());
|
const current = new Date(now());
|
||||||
if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
|
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 { 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;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ FEATURE_SOURCES = {
|
||||||
"security-center": ("static/security-center.js",),
|
"security-center": ("static/security-center.js",),
|
||||||
"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/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",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
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
|
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
|
@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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user