feat: paginate unread updates inbox (#147)
All checks were successful
CI / lint (pull_request) Successful in 11s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy 2026-08-06 23:30:32 +00:00
parent 953f59fb19
commit a86de5cbf4
9 changed files with 350 additions and 31 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%; }
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
.load-more-notifications[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 button { min-height:44px; width:100%; }
.my-work[data-stale="true"] { border-color:#fcd34d; }
@ -141,6 +143,8 @@ textarea { resize: vertical; min-height: 120px; }
</div>
</div>
<div class="my-work-list" id="my-work-list"></div>
<div class="small" id="notification-page-status" aria-live="polite"></div>
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
<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>
@ -318,6 +322,8 @@ textarea { resize: vertical; min-height: 120px; }
}
let lastMyWork = [];
let lastNotifications = [];
let lastContextSnapshot = null;
let notificationPagination = { page: 1, total: 0, has_more: false };
let hasContextSnapshot = false;
let selectedReview = null;
let reviewTrigger = null;
@ -367,6 +373,15 @@ textarea { resize: vertical; min-height: 120px; }
return payload;
}
async function fetchNotificationPage(page) {
const response = await fetch('api/v1/notifications?page=' + encodeURIComponent(page), {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading unread updates failed.');
return payload;
}
const notificationAcknowledger = createNotificationAcknowledger({
markRead: markNotificationRead,
onItems: items => {
@ -383,10 +398,30 @@ textarea { resize: vertical; min-height: 120px; }
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const notificationPager = createNotificationPager({
load: fetchNotificationPage,
onNotifications: items => {
lastNotifications = items;
if (lastContextSnapshot) {
lastContextSnapshot.notifications = lastNotifications;
paintMyWork(lastContextSnapshot);
}
},
onPagination: pagination => {
notificationPagination = pagination;
const loaded = Math.min(pagination.total, pagination.page * 50);
qs('#notification-page-status').textContent = pagination.total ?
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
qs('#load-more-notifications').hidden =
selectedWorkFilter !== 'update' || !pagination.has_more;
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
function renderContextSnapshot(data) {
liveMode = true;
hasContextSnapshot = true;
lastContextSnapshot = data;
if (data.error && lastMyWork.length) markMyWorkStale();
else paintMyWork(data);
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
@ -476,14 +511,20 @@ textarea { resize: vertical; min-height: 120px; }
}
});
});
const ids = notificationIds(visible);
const allIds = notificationIds(visible);
const ids = allIds.slice(0, Math.min(lastNotifications.length, 50));
const bulkBar = qs('#bulk-mark-read-bar');
const bulkButton = qs('#bulk-mark-read');
bulkBar.hidden = selectedWorkFilter !== 'update' || ids.length === 0;
qs('#load-more-notifications').hidden =
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
bulkButton.disabled = bulkMarkPending;
const bulkLabel = allIds.length > ids.length ?
'next ' + ids.length + ' of ' + allIds.length + ' loaded updates' :
'all ' + ids.length + ' updates';
bulkButton.textContent = bulkConfirmationPending ?
'Confirm marking ' + ids.length + ' updates read' :
'Mark all ' + ids.length + ' updates read';
'Confirm marking ' + bulkLabel + ' read' :
'Mark ' + bulkLabel + ' read';
}
function reviewFileElement(filename) {
@ -645,7 +686,24 @@ textarea { resize: vertical; min-height: 120px; }
function renderLiveSnapshot(snapshot) {
const notificationsFresh = Array.isArray(snapshot.notifications);
if (notificationsFresh) lastNotifications = snapshot.notifications;
if (notificationsFresh) {
if (notificationPagination.page > 1) {
const byId = new Map(lastNotifications.map(item => [item.id, item]));
snapshot.notifications.forEach(item => byId.set(item.id, item));
lastNotifications = Array.from(byId.values());
} else {
lastNotifications = snapshot.notifications;
}
if (snapshot.notification_pagination) {
const page = notificationPagination.page > 1 ? notificationPagination.page :
snapshot.notification_pagination.page;
notificationPager.reset({
page,
total: snapshot.notification_pagination.total,
has_more: page * 50 < snapshot.notification_pagination.total,
});
}
}
if (snapshot.context) {
snapshot.context.notifications = lastNotifications;
renderContextSnapshot(snapshot.context);
@ -793,8 +851,12 @@ textarea { resize: vertical; min-height: 120px; }
function load() { return contextPoller.refresh(); }
qs('#refresh').addEventListener('click', load);
qs('#load-more-notifications').addEventListener('click', () =>
notificationPager.loadMore(lastNotifications)
);
qs('#bulk-mark-read').addEventListener('click', async () => {
const ids = notificationIds(filterMyWork(lastMyWork, 'update'));
const allIds = notificationIds(filterMyWork(lastMyWork, 'update'));
const ids = allIds.slice(0, 50);
if (!ids.length || bulkMarkPending) return;
if (!bulkConfirmationPending) {
bulkConfirmationPending = true;

View File

@ -129,6 +129,46 @@ function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
};
}
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
let pagination = { page: 1, total: 0, has_more: false };
let pending = false;
return {
reset(next) {
pagination = { ...pagination, ...(next || {}) };
onPagination(pagination);
},
async loadMore(existing) {
if (pending || !pagination.has_more) return false;
pending = true;
onStatus('Loading older updates…');
try {
const result = await load(pagination.page + 1);
const byId = new Map((existing || [])
.filter(item => item && Number.isInteger(item.id))
.map(item => [item.id, item]));
(result.items || []).forEach(item => {
if (item && Number.isInteger(item.id) && !byId.has(item.id)) byId.set(item.id, item);
});
pagination = {
page: result.page,
total: result.total,
has_more: result.has_more === true,
};
const loaded = Math.min(pagination.total, pagination.page * 50);
onNotifications(Array.from(byId.values()));
onPagination(pagination);
onStatus(loaded + ' of ' + pagination.total + ' unread updates loaded.');
return true;
} catch (_error) {
onStatus('Could not load older updates. 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);
@ -164,5 +204,6 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
buildMyWork.notificationIds = notificationIds;
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
buildMyWork.createNotificationPager = createNotificationPager;
module.exports = buildMyWork;
}

View File

@ -125,8 +125,7 @@ def _safe_web_url(value: Any) -> str:
return value if parsed.scheme in {"http", "https"} and parsed.netloc else ""
async def notifications() -> list[dict]:
threads = await fetch("notifications?status-types=unread&limit=50")
def _normalize_notifications(threads: Any) -> list[dict]:
if not isinstance(threads, list):
raise ValueError("Gitea notification response was not a list")
normalized = []
@ -181,6 +180,29 @@ async def notifications() -> list[dict]:
return normalized
async def notifications() -> dict:
return await notification_page(1)
async def notification_page(page: int, limit: int = 50) -> dict:
response = await _get_client().get(
f"/api/v1/notifications?status-types=unread&limit={limit}&page={page}",
headers=_auth(),
)
response.raise_for_status()
items = _normalize_notifications(response.json())
try:
total = max(len(items), int(response.headers.get("X-Total-Count", len(items))))
except (TypeError, ValueError):
total = len(items)
return {
"items": items,
"page": page,
"total": total,
"has_more": page * limit < total,
}
async def mark_notification_read(thread_id: int) -> None:
response = await _get_client().patch(
f"/api/v1/notifications/threads/{thread_id}?to-status=read",

View File

@ -4,7 +4,7 @@ import time
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, Path as PathParam
from fastapi import FastAPI, HTTPException, Path as PathParam, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
@ -53,6 +53,7 @@ EVENT_STREAM_TIMEOUT_SECONDS = 5.0
READINESS_TIMEOUT_SECONDS = 5.0
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
BULK_NOTIFICATION_CONCURRENCY = 5
BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0
@ -135,7 +136,7 @@ app.include_router(frontend_router)
@app.middleware("http")
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live"} or (
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/notifications"} or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/review")
) or (
@ -263,10 +264,21 @@ async def _build_live_snapshot() -> dict:
context_ok = not isinstance(context_result, BaseException)
events_ok = not isinstance(events_result, BaseException)
notifications_ok = not isinstance(notifications_result, BaseException)
notification_items = notifications_result
notification_pagination = None
if notifications_ok and isinstance(notifications_result, dict):
notification_items = notifications_result.get("items")
notification_pagination = {
"page": notifications_result.get("page", 1),
"total": notifications_result.get("total", 0),
"has_more": notifications_result.get("has_more") is True,
}
notifications_ok = isinstance(notification_items, list)
return {
"context": context_result if context_ok else None,
"events": events_result if events_ok else None,
"notifications": notifications_result if notifications_ok else None,
"notifications": notification_items if notifications_ok else None,
"notification_pagination": notification_pagination if notifications_ok else None,
"sections": {
"context": "fresh" if context_ok else "temporarily unavailable",
"events": "fresh" if events_ok else "temporarily unavailable",
@ -479,6 +491,28 @@ async def read_notifications(batch: NotificationReadBatch) -> JSONResponse:
)
@app.get("/api/v1/notifications")
async def notification_page(page: int = Query(default=1, ge=1)) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.notification_page(page),
timeout=NOTIFICATION_PAGE_TIMEOUT_SECONDS,
)
except TimeoutError:
return JSONResponse(
{"error": "Unread updates timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "Unread updates are temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.patch("/api/v1/notifications/{thread_id}/read")
async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
try:

View File

@ -17,5 +17,6 @@ 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?page=",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
}

View File

@ -1,15 +1,49 @@
import httpx
import pytest
from src import gitea_proxy
@pytest.mark.anyio
async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handoff(monkeypatch):
requested_paths = []
async def test_notification_page_preserves_upstream_total_without_loading_other_pages():
requests = []
async def fake_fetch(path):
requested_paths.append(path)
return [
def upstream(request):
requests.append(str(request.url))
return httpx.Response(
200,
headers={"X-Total-Count": "125"},
json=[{
"id": 42,
"unread": True,
"repository": {"full_name": "stackchain/api"},
"subject": {
"title": "Retry failed deploy",
"type": "Issue",
"html_url": "https://forge.example/stackchain/api/issues/7",
},
}],
)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.notification_page(page=2)
finally:
await gitea_proxy.stop_client()
assert requests == [
"http://127.0.0.1:3000/api/v1/notifications?status-types=unread&limit=50&page=2"
]
assert result["page"] == 2
assert result["total"] == 125
assert result["has_more"] is True
assert [item["id"] for item in result["items"]] == [42]
@pytest.mark.anyio
async def test_unread_notifications_are_normalized_for_mobile_handoff():
result = gitea_proxy._normalize_notifications(
[
{
"id": 42,
"unread": True,
@ -31,12 +65,7 @@ async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handof
},
"malformed",
]
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
result = await gitea_proxy.notifications()
assert requested_paths == ["notifications?status-types=unread&limit=50"]
)
assert result == [
{
"id": 42,
@ -78,11 +107,6 @@ async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handof
@pytest.mark.anyio
async def test_notification_collection_rejects_non_list_payload(monkeypatch):
async def fake_fetch(_path):
return {"message": "unexpected"}
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
async def test_notification_collection_rejects_non_list_payload():
with pytest.raises(ValueError, match="notification response was not a list"):
await gitea_proxy.notifications()
gitea_proxy._normalize_notifications({"message": "unexpected"})

View File

@ -37,7 +37,12 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
return [{"type": "push"}]
async def updates():
return [{"id": 42, "title": "Mentioned you"}]
return {
"items": [{"id": 42, "title": "Mentioned you"}],
"page": 1,
"total": 125,
"has_more": True,
}
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
@ -53,6 +58,9 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
assert result["context"]["user"]["login"] == "timmy"
assert result["events"] == [{"type": "push"}]
assert result["notifications"] == [{"id": 42, "title": "Mentioned you"}]
assert result["notification_pagination"] == {
"page": 1, "total": 125, "has_more": True
}
assert result["sections"] == {
"context": "fresh", "events": "fresh", "notifications": "fresh"
}

View File

@ -305,6 +305,85 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
]
def test_notification_pager_is_single_flight_and_merges_unique_updates():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let calls = 0;
let release;
const pages = [];
const notifications = [];
const statuses = [];
const pager = buildMyWork.createNotificationPager({{
load: page => {{
calls += 1;
return new Promise(resolve => {{ release = () => resolve({{
items: [{{id:50, title:'duplicate'}}, {{id:51, title:'older'}}],
page, total: 75, has_more: false,
}}); }});
}},
onNotifications: items => notifications.push(items),
onPagination: page => pages.push(page),
onStatus: status => statuses.push(status),
}});
pager.reset({{page:1, total:75, has_more:true}});
const existing = [{{id:50, title:'newer'}}];
const first = pager.loadMore(existing);
const duplicate = pager.loadMore(existing);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, pages, notifications, statuses, results
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 1
assert output["notifications"] == [[
{"id": 50, "title": "newer"},
{"id": 51, "title": "older"},
]]
assert output["pages"][-1] == {"page": 2, "total": 75, "has_more": False}
assert output["statuses"] == ["Loading older updates…", "75 of 75 unread updates loaded."]
assert output["results"] == [True, False]
def test_notification_pager_keeps_loaded_updates_and_retries_the_same_page_after_failure():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const states = [];
const statuses = [];
const requested = [];
const pager = buildMyWork.createNotificationPager({{
load: async page => {{ requested.push(page); throw new Error('offline'); }},
onNotifications: items => states.push(items),
onPagination: () => {{}},
onStatus: status => statuses.push(status),
}});
pager.reset({{page:2, total:125, has_more:true}});
pager.loadMore([{{id:1}}]).then(result =>
pager.loadMore([{{id:1}}]).then(retry =>
process.stdout.write(JSON.stringify({{requested, states, statuses, result, retry}}))
)
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"requested": [3, 3],
"states": [],
"statuses": [
"Loading older updates…", "Could not load older updates. Retry.",
"Loading older updates…", "Could not load older updates. Retry.",
],
"result": False,
"retry": False,
}
@pytest.mark.anyio
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
html = await dashboard()
@ -347,13 +426,28 @@ async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement(
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 "'next ' + ids.length + ' of ' + allIds.length + ' loaded updates'" in html
assert "'Confirm marking ' + bulkLabel + ' read'" in html
assert "const ids = allIds.slice(0, 50)" 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_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile():
html = await dashboard()
assert 'id="notification-page-status"' in html
assert 'id="load-more-notifications"' in html
assert '.load-more-notifications' in html and 'min-height:44px' in html
assert "createNotificationPager" in html
assert "api/v1/notifications?page=" in html
assert "snapshot.notification_pagination" in html
assert "notificationPager.loadMore(lastNotifications)" in html
assert "Math.min(lastNotifications.length, 50)" in html
@pytest.mark.anyio
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
html = await dashboard()

View File

@ -0,0 +1,33 @@
import httpx
import pytest
from src import main
@pytest.mark.anyio
async def test_notification_page_endpoint_loads_only_requested_page_and_is_not_cacheable(monkeypatch):
requested = []
async def page_loader(page):
requested.append(page)
return {
"items": [{"id": 51, "title": "Older update"}],
"page": page,
"total": 125,
"has_more": True,
}
monkeypatch.setattr(main.gitea_proxy, "notification_page", page_loader)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/notifications?page=2")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"items": [{"id": 51, "title": "Older update"}],
"page": 2,
"total": 125,
"has_more": True,
}
assert requested == [2]