Bulk acknowledge unread updates from mobile My Work #142
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
29
src/main.py
29
src/main.py
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user