@@ -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 = '
User
' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '
' +
@@ -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;
diff --git a/frontend/my-work.js b/frontend/my-work.js
index 5a1caac..88b9a43 100644
--- a/frontend/my-work.js
+++ b/frontend/my-work.js
@@ -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;
}
diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py
index 0456f76..d6b1ca9 100644
--- a/src/gitea_proxy.py
+++ b/src/gitea_proxy.py
@@ -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",
diff --git a/src/main.py b/src/main.py
index 1d6f861..37478d1 100644
--- a/src/main.py
+++ b/src/main.py
@@ -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:
diff --git a/tests/test_api_paths.py b/tests/test_api_paths.py
index 1d2e7f5..9106895 100644
--- a/tests/test_api_paths.py
+++ b/tests/test_api_paths.py
@@ -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",
}
diff --git a/tests/test_gitea_notifications.py b/tests/test_gitea_notifications.py
index 567e8e7..ab617e6 100644
--- a/tests/test_gitea_notifications.py
+++ b/tests/test_gitea_notifications.py
@@ -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"})
diff --git a/tests/test_live_snapshot.py b/tests/test_live_snapshot.py
index ea58959..36c581c 100644
--- a/tests/test_live_snapshot.py
+++ b/tests/test_live_snapshot.py
@@ -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"
}
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index 73996d4..b41055b 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -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()
diff --git a/tests/test_notification_pages.py b/tests/test_notification_pages.py
new file mode 100644
index 0000000..0e4dfd8
--- /dev/null
+++ b/tests/test_notification_pages.py
@@ -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]