feat: add mobile Later queue (#254)
CI / lint (pull_request) Successful in 23s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy committed 2026-08-08 02:41:09 +00:00
1 parent 8e0389153c
commit 65a09b5c66
6 files changed
+365 -14

No files matched your search

+57 -11
View File
@@ -82,6 +82,8 @@ textarea { resize: vertical; min-height: 120px; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
.my-work-card:hover { border-color:var(--accent); }
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
.later-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.later-actions button { min-height:44px; width:100%; }
.mark-update-read { min-height:44px; width:100%; }
.read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; }
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
@@ -312,6 +314,7 @@ textarea { resize: vertical; min-height: 120px; }
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
<button class="work-filter" data-work-filter="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
<button class="work-filter" data-work-filter="later" aria-pressed="false">Later <span data-work-count="later">0</span></button>
<button class="work-filter" data-work-filter="draft" aria-pressed="false">Drafts <span data-work-count="draft">0</span></button>
</div>
<label class="milestone-lane" for="work-milestone-filter"><span class="small">Release lane</span>
@@ -746,6 +749,7 @@ textarea { resize: vertical; min-height: 120px; }
<script src="static/authored-outbox.js"></script>
<script src="static/offline-work.js"></script>
<script src="static/my-work.js"></script>
<script src="static/later-work.js"></script>
<script src="static/pick-work.js"></script>
<script src="static/conversation.js"></script>
<script src="static/issue-sheet.js"></script>
@@ -785,7 +789,7 @@ textarea { resize: vertical; min-height: 120px; }
);
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
function openMobileWork() {
const counts = countMyWork(lastMyWork);
const counts = countMyWork(activeMyWork);
const filter = counts.attention ? 'attention' : 'all';
qs('[data-work-filter="' + filter + '"]').click();
qs('#my-work').scrollIntoView({block:'start'});
@@ -826,7 +830,7 @@ textarea { resize: vertical; min-height: 120px; }
let selectedWorkMilestone = 'all';
try {
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
if (['all', 'attention', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
if (['all', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
if (savedMilestone) selectedWorkMilestone = savedMilestone;
} catch (e) {
@@ -866,6 +870,19 @@ textarea { resize: vertical; min-height: 120px; }
let editingOutboxId = null;
let confirmedOwnerLogin = '';
let activeFlushLogin = '';
let activeMyWork = [];
let laterMyWork = [];
const laterWork = createLaterWork({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
onWake: () => {
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
refreshMyWorkView();
},
});
document.addEventListener('visibilitychange', () => {
if (!document.hidden) refreshMyWorkView();
});
async function fetchReviewJson(url, options) {
const response = await fetch(url, options);
@@ -1214,7 +1231,7 @@ textarea { resize: vertical; min-height: 120px; }
}
const workSession = createWorkSession({
getItems: () => lastMyWork,
getItems: () => activeMyWork,
getFilter: () => selectedWorkFilter,
getMilestone: () => selectedWorkMilestone,
onOpen: openWorkSessionItem,
@@ -1309,7 +1326,13 @@ textarea { resize: vertical; min-height: 120px; }
function refreshMyWorkView() {
lastDrafts = draftInbox.list();
const counts = countMyWork(lastMyWork);
const partitioned = laterWork.partition(lastMyWork, {
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
});
activeMyWork = partitioned.active;
laterMyWork = partitioned.later;
const counts = countMyWork(activeMyWork);
counts.later = laterMyWork.length;
counts.draft = lastDrafts.length;
Object.entries(counts).forEach(([filter, count]) => {
const element = qs('[data-work-count="' + filter + '"]');
@@ -1332,7 +1355,8 @@ 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.';
summarizeMyWork(activeMyWork) + (laterMyWork.length ? ' · ' + laterMyWork.length + ' deferred' : '') :
'No assigned work, review requests, or unread updates.';
updateWorkPaginationControls();
renderMyWork();
if (workSession.active()) workSession.reconcile();
@@ -1452,7 +1476,9 @@ textarea { resize: vertical; min-height: 120px; }
qs('#load-more-notifications').hidden = true;
return;
}
const visible = filterMyWork(lastMyWork, selectedWorkFilter, selectedWorkMilestone);
const visible = selectedWorkFilter === 'later' ?
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
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);
@@ -1465,24 +1491,28 @@ textarea { resize: vertical; min-height: 120px; }
(item.milestone?.title ? ' <span class="pill milestone-badge">' + escapeHtml(item.milestone.title) + '</span>' : '') +
(item.due_label ? ' <span class="pill due-badge">' + escapeHtml(item.due_label) + '</span>' : '') +
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
(item.deferred_until ? '<span class="small">Deferred until ' + escapeHtml(fmt(item.deferred_until)) + '</span>' : '') +
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
'<a class="read-update" href="' + escAttr(createWorkRoute.serialize({ kind:'update', notification_id:item.notification_id })) + '" data-update-index="' + index + '">Read update</a>' : '';
const laterActions = selectedWorkFilter === 'later' ?
'<div class="later-actions"><button type="button" data-later-restore data-work-index="' + index + '">Bring back now</button></div>' :
'<div class="later-actions" aria-label="Defer this work"><button type="button" data-later-preset="today" data-work-index="' + index + '">Later today</button><button type="button" data-later-preset="tomorrow" data-work-index="' + index + '">Tomorrow</button></div>';
if (item.is_review) {
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + '</article>';
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + laterActions + '</article>';
}
if (item.kind === 'issue') {
return '<article class="my-work-card"><a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + '</article>';
return '<article class="my-work-card"><a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + laterActions + '</article>';
}
if (item.kind === 'pull') {
return '<article class="my-work-card"><a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + '</article>';
return '<article class="my-work-card"><a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + laterActions + '</article>';
}
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + '</article>';
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + laterActions + '</article>';
}).join('') : '<div class="muted">' + (incomplete ?
'More work is available. Load the next page.' :
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')))) + '.') + '</div>';
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>';
document.querySelectorAll('[data-review-index]').forEach(button => {
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
});
@@ -1514,6 +1544,22 @@ textarea { resize: vertical; min-height: 120px; }
}
});
});
document.querySelectorAll('[data-later-preset]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
if (!item || !laterWork.defer(item, laterWork.presetUntil(button.dataset.laterPreset))) return;
qs('#my-work-action-status').textContent = 'Deferred work stays unread and unchanged in Gitea.';
refreshMyWorkView();
});
});
document.querySelectorAll('[data-later-restore]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.workIndex)];
if (!item || !laterWork.restore(item)) return;
qs('#my-work-action-status').textContent = 'Work returned to its priority position.';
refreshMyWorkView();
});
});
const allIds = notificationIds(visible);
const ids = allIds.slice(0, Math.min(lastNotifications.length, 50));
const bulkBar = qs('#bulk-mark-read-bar');
+113
View File
@@ -0,0 +1,113 @@
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {} }) {
const prefix = 'stackchain.later-work.v1.';
let timer = null;
function identity(item) {
if (!item) return '';
const kind = item.is_review ? 'review' : (item.kind || 'work');
const number = Number.isInteger(item.number) ? item.number : '';
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
return [kind, item.repository || '', number, notification].join(':');
}
function storageKey() {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function read() {
const key = storageKey();
if (!key || !storage) return {};
try {
const value = JSON.parse(storage.getItem(key) || '{}');
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
} catch (_error) {
return {};
}
}
function write(records) {
const key = storageKey();
if (!key || !storage) return false;
try {
if (Object.keys(records).length) storage.setItem(key, JSON.stringify(records));
else storage.removeItem(key);
return true;
} catch (_error) {
return false;
}
}
function defer(item, until) {
const key = storageKey();
const id = identity(item);
const wake = new Date(until);
if (!key || !id || Number.isNaN(wake.getTime()) || wake <= now()) return false;
const records = read();
records[id] = wake.toISOString();
write(records);
return true;
}
function presetUntil(preset) {
const current = new Date(now());
if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
if (preset === 'tomorrow') {
const wake = new Date(current);
wake.setDate(wake.getDate() + 1);
wake.setHours(9, 0, 0, 0);
return wake;
}
return new Date(NaN);
}
function restore(item) {
const id = identity(item);
const records = read();
if (!id || !Object.prototype.hasOwnProperty.call(records, id)) return false;
delete records[id];
write(records);
return true;
}
function schedule(wakeTimes, current) {
if (timer !== null) clearTimer(timer);
timer = null;
if (!wakeTimes.length) return;
const delay = Math.max(0, Math.min(...wakeTimes) - current);
timer = setTimer(() => {
timer = null;
onWake();
}, Math.min(delay, 2147483647));
}
function partition(items, { pruneMissing = true } = {}) {
const current = now().getTime();
const records = read();
const available = new Map((items || []).map(item => [identity(item), item]));
const retained = {};
const wakeTimes = [];
Object.entries(records).forEach(([id, wake]) => {
const wakeTime = new Date(wake).getTime();
if (!Number.isFinite(wakeTime) || wakeTime <= current) return;
if (pruneMissing && !available.has(id)) return;
retained[id] = new Date(wakeTime).toISOString();
wakeTimes.push(wakeTime);
});
if (JSON.stringify(retained) !== JSON.stringify(records)) write(retained);
schedule(wakeTimes, current);
const deferredIds = new Set(Object.keys(retained));
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
const later = (items || []).flatMap(item => {
const wake = retained[identity(item)];
return wake ? [{ ...item, deferred_until: wake }] : [];
});
return { active, later };
}
return { identity, defer, restore, presetUntil, partition };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
+2 -1
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v13';
const CACHE = 'stackchain-dashboard-shell-v14';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,
@@ -17,6 +17,7 @@ const SHELL = [
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/my-work.js',
BASE + 'static/later-work.js',
BASE + 'static/pick-work.js',
BASE + 'static/conversation.js',
BASE + 'static/issue-sheet.js',