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: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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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.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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user