Bulk acknowledge unread updates from mobile My Work #142

Merged
rockachopa merged 1 commits from timmy/141-bulk-notification-triage into main 2026-08-06 21:56:42 +00:00
6 changed files with 246 additions and 0 deletions

View File

@ -66,6 +66,8 @@ textarea { resize: vertical; min-height: 120px; }
.my-work-card:hover { border-color:var(--accent); } .my-work-card:hover { border-color:var(--accent); }
.my-work-card-title { display:block; margin:5px 0; font-weight:650; } .my-work-card-title { display:block; margin:5px 0; font-weight:650; }
.mark-update-read { min-height:44px; width:100%; } .mark-update-read { min-height:44px; width:100%; }
.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[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; }
@ -138,6 +140,9 @@ textarea { resize: vertical; min-height: 120px; }
</div> </div>
<div class="my-work-list" id="my-work-list"></div> <div class="my-work-list" id="my-work-list"></div>
<div class="small" id="my-work-action-status" aria-live="assertive"></div> <div class="small" id="my-work-action-status" aria-live="assertive"></div>
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
<button id="bulk-mark-read" type="button"></button>
</div>
</section> </section>
<aside class="sidebar"> <aside class="sidebar">
<details class="panel stack" data-panel-key="context" open> <details class="panel stack" data-panel-key="context" open>
@ -316,6 +321,8 @@ textarea { resize: vertical; min-height: 120px; }
let progress = null; let progress = null;
let draft = null; let draft = null;
let reviewFiles = []; let reviewFiles = [];
let bulkConfirmationPending = false;
let bulkMarkPending = false;
async function fetchReviewJson(url, options) { async function fetchReviewJson(url, options) {
const response = await fetch(url, options); const response = await fetch(url, options);
@ -345,6 +352,17 @@ textarea { resize: vertical; min-height: 120px; }
return payload; return payload;
} }
async function markNotificationsRead(ids) {
const response = await fetch('api/v1/notifications/read', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Bulk mark read failed.');
return payload;
}
const notificationAcknowledger = createNotificationAcknowledger({ const notificationAcknowledger = createNotificationAcknowledger({
markRead: markNotificationRead, markRead: markNotificationRead,
onItems: items => { onItems: items => {
@ -353,6 +371,14 @@ textarea { resize: vertical; min-height: 120px; }
}, },
onStatus: message => { qs('#my-work-action-status').textContent = message; }, onStatus: message => { qs('#my-work-action-status').textContent = message; },
}); });
const bulkNotificationAcknowledger = createBulkNotificationAcknowledger({
markRead: markNotificationsRead,
onItems: items => {
lastMyWork = items;
refreshMyWorkView();
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
function renderContextSnapshot(data) { function renderContextSnapshot(data) {
liveMode = true; liveMode = true;
@ -446,6 +472,14 @@ textarea { resize: vertical; min-height: 120px; }
} }
}); });
}); });
const ids = notificationIds(visible);
const bulkBar = qs('#bulk-mark-read-bar');
const bulkButton = qs('#bulk-mark-read');
bulkBar.hidden = selectedWorkFilter !== 'update' || ids.length === 0;
bulkButton.disabled = bulkMarkPending;
bulkButton.textContent = bulkConfirmationPending ?
'Confirm marking ' + ids.length + ' updates read' :
'Mark all ' + ids.length + ' updates read';
} }
function reviewFileElement(filename) { function reviewFileElement(filename) {
@ -734,6 +768,27 @@ textarea { resize: vertical; min-height: 120px; }
function load() { return contextPoller.refresh(); } function load() { return contextPoller.refresh(); }
qs('#refresh').addEventListener('click', load); qs('#refresh').addEventListener('click', load);
qs('#bulk-mark-read').addEventListener('click', async () => {
const ids = notificationIds(filterMyWork(lastMyWork, 'update'));
if (!ids.length || bulkMarkPending) return;
if (!bulkConfirmationPending) {
bulkConfirmationPending = true;
qs('#my-work-action-status').textContent = 'Confirm to mark all visible updates read.';
renderMyWork();
return;
}
bulkConfirmationPending = false;
bulkMarkPending = true;
renderMyWork();
const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, ids);
if (result) {
const marked = new Set(result.marked);
lastNotifications = lastNotifications.filter(item => !marked.has(item.id));
}
bulkMarkPending = false;
renderMyWork();
(document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus();
});
document.querySelectorAll('[data-work-filter]').forEach(button => { document.querySelectorAll('[data-work-filter]').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter)); button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
button.addEventListener('click', () => { button.addEventListener('click', () => {

View File

@ -91,6 +91,44 @@ function createNotificationAcknowledger({ markRead, onItems, onStatus }) {
}; };
} }
function notificationIds(items) {
return Array.from(new Set((items || [])
.filter((item) => item && item.has_update && Number.isInteger(item.notification_id))
.map((item) => item.notification_id)));
}
function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
let pending = false;
return {
async acknowledge(items, requestedIds) {
if (pending) return false;
const ids = Array.from(new Set((requestedIds || []).filter(Number.isInteger)));
if (!ids.length) return false;
pending = true;
onStatus('Marking ' + ids.length + ' updates read…');
try {
const result = await markRead(ids);
const marked = (result.marked || []).filter(Number.isInteger);
const failed = (result.failed || []).filter(Number.isInteger);
const updated = marked.reduce(
(current, notificationId) => acknowledgeNotification(current, notificationId),
items
);
onItems(updated);
onStatus(failed.length ?
marked.length + ' marked read · ' + failed.length + ' could not be updated — retry.' :
marked.length + ' updates marked read.');
return { marked, failed };
} catch (_error) {
onStatus('Could not mark updates read. Retry.');
return false;
} finally {
pending = false;
}
},
};
}
function filterMyWork(items, selectedFilter) { function filterMyWork(items, selectedFilter) {
if (selectedFilter === 'all') return items; if (selectedFilter === 'all') return items;
if (selectedFilter === 'review') return items.filter((item) => item.is_review); if (selectedFilter === 'review') return items.filter((item) => item.is_review);
@ -124,5 +162,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.countMyWork = countMyWork; buildMyWork.countMyWork = countMyWork;
buildMyWork.acknowledgeNotification = acknowledgeNotification; buildMyWork.acknowledgeNotification = acknowledgeNotification;
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger; buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
buildMyWork.notificationIds = notificationIds;
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
module.exports = buildMyWork; module.exports = buildMyWork;
} }

View File

@ -8,6 +8,7 @@ from fastapi import FastAPI, HTTPException, Path as PathParam
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, PositiveInt
from src import gitea_proxy from src import gitea_proxy
from src.gitea_proxy import ( from src.gitea_proxy import (
@ -68,6 +69,10 @@ class ReadinessPayloadError(ValueError):
"""Raised when Gitea returns a structurally invalid readiness payload.""" """Raised when Gitea returns a structurally invalid readiness payload."""
class NotificationReadBatch(BaseModel):
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict: def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
user_model = User( user_model = User(
id=user_data["id"], id=user_data["id"],
@ -424,6 +429,30 @@ async def event_stream():
) )
async def _mark_notification_read_result(thread_id: int) -> tuple[int, bool]:
try:
await asyncio.wait_for(
mark_notification_read(thread_id),
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
)
except Exception:
return thread_id, False
_remove_notification_from_live_snapshot(thread_id)
return thread_id, True
@app.patch("/api/v1/notifications/read")
async def read_notifications(batch: NotificationReadBatch) -> JSONResponse:
thread_ids = list(dict.fromkeys(batch.ids))
results = await asyncio.gather(
*(_mark_notification_read_result(thread_id) for thread_id in thread_ids)
)
return JSONResponse({
"marked": [thread_id for thread_id, succeeded in results if succeeded],
"failed": [thread_id for thread_id, succeeded in results if not succeeded],
})
@app.patch("/api/v1/notifications/{thread_id}/read") @app.patch("/api/v1/notifications/{thread_id}/read")
async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse: async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
try: try:

View File

@ -17,4 +17,5 @@ def test_api_requests_resolve_inside_dashboard_subpath():
} == { } == {
"https://forge.alexanderwhitestone.com/dashboard/api/v1/live", "https://forge.alexanderwhitestone.com/dashboard/api/v1/live",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/", "https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
} }

View File

@ -256,6 +256,55 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
assert output["results"] == [False, False] assert output["results"] == [False, False]
def test_bulk_notification_acknowledger_deduplicates_and_keeps_partial_failures_retryable():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = [
{{kind:'issue', key:'repo#1', notification_id:42, has_update:true}},
{{kind:'update', key:'repo#2', notification_id:43, has_update:true}},
{{kind:'update', key:'repo#3', notification_id:44, has_update:true}},
];
let calls = 0;
let release;
const states = [];
const statuses = [];
const controller = buildMyWork.createBulkNotificationAcknowledger({{
markRead: ids => {{
calls += 1;
return new Promise(resolve => {{ release = () => resolve({{marked:[42,43], failed:[44]}}); }});
}},
onItems: items => states.push(items),
onStatus: status => statuses.push(status),
}});
const first = controller.acknowledge(original, [42, 43, 42, 44]);
const duplicate = controller.acknowledge(original, [42, 43, 44]);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, states, statuses, results,
retryIds: buildMyWork.notificationIds(states.at(-1)),
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 1
assert output["results"] == [
{"marked": [42, 43], "failed": [44]},
False,
]
assert output["states"] == [[
{"kind": "issue", "key": "repo#1", "has_update": False},
{"kind": "update", "key": "repo#3", "notification_id": 44, "has_update": True},
]]
assert output["retryIds"] == [44]
assert output["statuses"] == [
"Marking 3 updates read…",
"2 marked read · 1 could not be updated — retry.",
]
@pytest.mark.anyio @pytest.mark.anyio
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels(): async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
html = await dashboard() html = await dashboard()
@ -288,6 +337,23 @@ async def test_unread_cards_offer_accessible_mobile_mark_read_without_nested_act
assert '<a class="my-work-card"' not in html assert '<a class="my-work-card"' not in html
@pytest.mark.anyio
async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
html = await dashboard()
assert 'id="bulk-mark-read"' in html
assert 'id="bulk-mark-read-bar"' in html
assert 'class="my-work-bulk"' in html
assert '.my-work-bulk { position:sticky;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
assert '.my-work-bulk button { min-height:44px; width:100%; }' in html
assert "'Mark all ' + ids.length + ' updates read'" in html
assert "'Confirm marking ' + ids.length + ' updates read'" in html
assert "createBulkNotificationAcknowledger" in html
assert "api/v1/notifications/read" in html
assert "body: JSON.stringify({ ids })" in html
@pytest.mark.anyio @pytest.mark.anyio
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session(): async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
html = await dashboard() html = await dashboard()

View File

@ -49,6 +49,61 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
assert marked == [42] assert marked == [42]
@pytest.mark.anyio
async def test_bulk_mark_read_reports_partial_progress_and_retains_only_failures(monkeypatch):
calls = []
async def mark(thread_id):
calls.append(thread_id)
if thread_id == 43:
raise httpx.HTTPError("upstream unavailable")
monkeypatch.setattr(main, "mark_notification_read", mark)
monkeypatch.setattr(
main,
"_live_snapshot_value",
{"notifications": [{"id": 42}, {"id": 43}, {"id": 44}]},
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/notifications/read", json={"ids": [42, 43, 42, 44]}
)
assert response.status_code == 200
assert response.json() == {"marked": [42, 44], "failed": [43]}
assert response.headers["cache-control"] == "no-store"
assert sorted(calls) == [42, 43, 44]
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
@pytest.mark.anyio
async def test_bulk_mark_read_rejects_empty_invalid_and_oversized_batches(monkeypatch):
marked = []
async def mark(thread_id):
marked.append(thread_id)
monkeypatch.setattr(main, "mark_notification_read", mark)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
empty = await client.patch("/api/v1/notifications/read", json={"ids": []})
invalid = await client.patch(
"/api/v1/notifications/read", json={"ids": [42, 0]}
)
oversized = await client.patch(
"/api/v1/notifications/read", json={"ids": list(range(1, 52))}
)
assert [empty.status_code, invalid.status_code, oversized.status_code] == [
422,
422,
422,
]
assert all(response.headers["cache-control"] == "no-store" for response in [empty, invalid, oversized])
assert marked == []
@pytest.mark.anyio @pytest.mark.anyio
async def test_mark_notification_read_removes_thread_from_retained_live_snapshot(monkeypatch): async def test_mark_notification_read_removes_thread_from_retained_live_snapshot(monkeypatch):
async def mark(_thread_id): async def mark(_thread_id):