feat: paginate mobile My Work queues (#169)
This commit is contained in:
1 parent
29a786cea3
commit
625e65e7e4
8 files changed
+588
-84
No files matched your search
+90
-1
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user