feat: paginate mobile My Work queues (#169)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 4s

This commit is contained in:
timmy committed 2026-08-07 05:35:45 +00:00
1 parent 29a786cea3
commit 625e65e7e4
8 files changed
+588 -84

No files matched your search

+90 -1
View File
@@ -69,6 +69,8 @@ textarea { resize: vertical; min-height: 120px; }
.read-update { min-height:44px; width:100%; }
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
.load-more-notifications[hidden] { display:none; }
.load-more-work { min-height:44px; width:100%; margin-top:10px; }
.load-more-work[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; }
@@ -207,6 +209,8 @@ textarea { resize: vertical; min-height: 120px; }
</div>
</div>
<div class="my-work-list" id="my-work-list"></div>
<div class="small" id="work-page-status" aria-live="polite"></div>
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
<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>
@@ -522,6 +526,7 @@ textarea { resize: vertical; min-height: 120px; }
let lastNotifications = [];
let lastContextSnapshot = null;
let notificationPagination = { page: 1, total: 0, has_more: false };
let workPagination = {};
let hasContextSnapshot = false;
let selectedReview = null;
let reviewTrigger = null;
@@ -602,6 +607,15 @@ textarea { resize: vertical; min-height: 120px; }
return payload;
}
async function fetchWorkPage(stream, page) {
const response = await fetch('api/v1/work/' + encodeURIComponent(stream) + '?page=' + encodeURIComponent(page), {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading older work failed.');
return payload;
}
async function postNotificationReply(notificationId, body) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', {
method: 'POST',
@@ -648,6 +662,20 @@ textarea { resize: vertical; min-height: 120px; }
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const workPager = createWorkPager({
load: fetchWorkPage,
onItems: (stream, items) => {
if (!lastContextSnapshot) return;
if (stream === 'issue') lastContextSnapshot.issues = items;
else lastContextSnapshot.pull_requests = items;
paintMyWork(lastContextSnapshot);
},
onPagination: pagination => {
workPagination = pagination;
updateWorkPaginationControls();
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const notificationReplier = createNotificationReplier({
post: postNotificationReply,
storage: localStorage,
@@ -705,7 +733,25 @@ textarea { resize: vertical; min-height: 120px; }
function renderContextSnapshot(data) {
liveMode = true;
hasContextSnapshot = true;
if (lastContextSnapshot && Object.values(workPagination).some(page => page.page > 1)) {
const merge = (older, latest) => {
const byId = new Map((older || []).map(item => [item.id, item]));
(latest || []).forEach(item => {
const current = byId.get(item.id) || {};
const reasons = Array.from(new Set(
(current.work_reasons || []).concat(item.work_reasons || [])
));
byId.set(item.id, {
...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}),
});
});
return Array.from(byId.values());
};
data.issues = merge(lastContextSnapshot.issues, data.issues);
data.pull_requests = merge(lastContextSnapshot.pull_requests, data.pull_requests);
}
lastContextSnapshot = data;
if (data.work_pagination) workPager.reset(data.work_pagination);
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>' +
@@ -759,11 +805,36 @@ textarea { resize: vertical; min-height: 120px; }
qs('#my-work').removeAttribute('data-stale');
qs('#my-work-status').textContent = lastMyWork.length ?
summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
updateWorkPaginationControls();
renderMyWork();
}
function activeWorkStreams() {
if (selectedWorkFilter === 'issue') return ['issue'];
if (selectedWorkFilter === 'pull') return ['pull'];
if (selectedWorkFilter === 'review') return ['review'];
if (selectedWorkFilter === 'all') return ['issue', 'pull', 'review'];
return [];
}
function updateWorkPaginationControls() {
const labels = { issue: 'issues', pull: 'pull requests', review: 'review requests' };
const streams = activeWorkStreams();
const incomplete = streams.filter(stream => workPagination[stream]?.has_more);
const summaries = streams.flatMap(stream => {
const page = workPagination[stream];
return page && page.total ?
[Math.min(page.total, page.page * 50) + ' of ' + page.total + ' ' + labels[stream]] : [];
});
qs('#work-page-status').textContent = summaries.join(' · ');
qs('#load-more-work').hidden = incomplete.length === 0;
qs('#load-more-work').textContent = incomplete.length ?
'Load older ' + labels[incomplete[0]] : 'Load older work';
}
function renderMyWork() {
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
const contents =
@@ -786,7 +857,9 @@ textarea { resize: vertical; min-height: 120px; }
return '<article class="my-work-card"><button class="my-work-card-main pull-trigger" data-pull-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
}
return '<article class="my-work-card"><a class="my-work-card-main" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>' + readUpdate + markRead + '</article>';
}).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.</div>';
}).join('') : '<div class="muted">' + (incomplete ?
'More work is available. Load the next page.' :
'No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.') + '</div>';
document.querySelectorAll('[data-review-index]').forEach(button => {
button.addEventListener('click', () => openReviewSheet(lastMyWork[Number(button.dataset.reviewIndex)], button));
});
@@ -1666,6 +1739,21 @@ textarea { resize: vertical; min-height: 120px; }
qs('#load-more-notifications').addEventListener('click', () =>
notificationPager.loadMore(lastNotifications)
);
qs('#load-more-work').addEventListener('click', async () => {
const stream = activeWorkStreams().find(item => workPagination[item]?.has_more);
if (!stream || !lastContextSnapshot) return;
const button = qs('#load-more-work');
button.disabled = true;
const existing = stream === 'issue' ?
(lastContextSnapshot.issues || []) : (lastContextSnapshot.pull_requests || []);
try {
const loaded = await workPager.loadMore(stream, existing);
if (loaded) document.querySelector('.my-work-card:last-child .my-work-card-main')?.focus();
else button.focus();
} finally {
button.disabled = false;
}
});
qs('#bulk-mark-read').addEventListener('click', async () => {
const allIds = notificationIds(filterMyWork(lastMyWork, 'update'));
const ids = allIds.slice(0, 50);
@@ -1701,6 +1789,7 @@ textarea { resize: vertical; min-height: 120px; }
item.setAttribute('aria-pressed', String(item === button))
);
renderMyWork();
updateWorkPaginationControls();
});
});
contextPoller.start();