Merge pull request 'Bulk acknowledge unread updates from mobile My Work' (#142) from timmy/141-bulk-notification-triage into main
All checks were successful
CI / lint (push) Successful in 10s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s

This commit is contained in:
rockachopa 2026-08-06 21:56:41 +00:00
commit 54638d2686
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-title { display:block; margin:5px 0; font-weight:650; }
.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; }
.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; }
@ -138,6 +140,9 @@ textarea { resize: vertical; min-height: 120px; }
</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="my-work-bulk" id="bulk-mark-read-bar" hidden>
<button id="bulk-mark-read" type="button"></button>
</div>
</section>
<aside class="sidebar">
<details class="panel stack" data-panel-key="context" open>
@ -316,6 +321,8 @@ textarea { resize: vertical; min-height: 120px; }
let progress = null;
let draft = null;
let reviewFiles = [];
let bulkConfirmationPending = false;
let bulkMarkPending = false;
async function fetchReviewJson(url, options) {
const response = await fetch(url, options);
@ -345,6 +352,17 @@ textarea { resize: vertical; min-height: 120px; }
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({
markRead: markNotificationRead,
onItems: items => {
@ -353,6 +371,14 @@ textarea { resize: vertical; min-height: 120px; }
},
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) {
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) {
@ -734,6 +768,27 @@ textarea { resize: vertical; min-height: 120px; }
function load() { return contextPoller.refresh(); }
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 => {
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
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) {
if (selectedFilter === 'all') return items;
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
@ -124,5 +162,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.countMyWork = countMyWork;
buildMyWork.acknowledgeNotification = acknowledgeNotification;
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
buildMyWork.notificationIds = notificationIds;
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
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.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, PositiveInt
from src import gitea_proxy
from src.gitea_proxy import (
@ -68,6 +69,10 @@ class ReadinessPayloadError(ValueError):
"""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:
user_model = User(
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")
async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
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/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]
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
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
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
@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
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
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]
@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
async def test_mark_notification_read_removes_thread_from_retained_live_snapshot(monkeypatch):
async def mark(_thread_id):