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

This commit is contained in:
timmy committed 2026-08-06 23:30:32 +00:00
1 parent 953f59fb19
commit a86de5cbf4
9 files changed
+350 -31

No files matched your search

+67 -5
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;
+41
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;
}