feat: resume mobile Updates triage (Closes #747)
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-13 14:58:19 +00:00
1 parent 341a3283ea
commit 4f6cebae7c
18 files changed
+265 -26

No files matched your search

+1
View File
@@ -383,6 +383,7 @@ textarea { resize: vertical; min-height: 120px; }
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.update-sheet-header button { min-height:44px; }
.update-triage-progress { margin:10px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.update-reply { display:grid; gap:8px; margin-top:16px; }
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
+42 -4
View File
@@ -73,6 +73,7 @@
const mobileQueueLauncher = createMobileQueueLauncher({
openToday: () => mobileWorkEntry.open(),
openAgenda: openAgendaSession,
openUpdates: openUpdateTriage,
selectFilter: selectMobileQueue,
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
announce: message => { qs('#my-work-action-status').textContent = message; },
@@ -276,7 +277,10 @@
document.addEventListener('visibilitychange', () => {
if (document.hidden) interruptionPrompt.background();
else interruptionPrompt.foreground();
if (!document.hidden) refreshMyWorkView();
if (!document.hidden) {
refreshMyWorkView();
if (updateTriage.active()) updateTriage.reconcile();
}
});
async function fetchReviewJson(url, options) {
@@ -969,6 +973,30 @@
onClose: () => closeUpdateSheet(false),
});
const updateTriage = createUpdateTriageSession({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
getItems: () => lastMyWork.filter(item => item?.has_update && Number.isInteger(item.notification_id)),
onOpen: item => notificationReader.open(
item,
offlineWorkMode ? offlineWorkStore.loadDetail(confirmedOwnerLogin, item) : null
),
onProgress: state => {
const progress = qs('#update-triage-progress');
progress.hidden = false;
progress.textContent = 'Update ' + state.index + ' of ' + state.total;
},
onFinish: () => {
qs('#update-triage-progress').hidden = true;
showMobileQueueCompletion('Updates');
},
});
function openUpdateTriage() {
selectMobileQueue('update');
return updateTriage.resumable() ? updateTriage.resume() : updateTriage.start();
}
function routedWorkItem(item) {
if (item?.has_update && item.kind === 'update') {
return { ...item, kind:'update' };
@@ -5444,7 +5472,10 @@
button.focus();
}
});
qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true));
qs('#keep-update-unread').addEventListener('click', () => {
if (updateTriage.active()) updateTriage.keepUnreadAndNext();
else closeUpdateSheet(true);
});
qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act());
qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start());
qs('#retry-update-load').addEventListener('click', () => {
@@ -5516,6 +5547,7 @@
'Reply and read acknowledgement queued for sync.';
if (result?.delivery === 'posted' && result?.next) {
notificationUndo.offer(item, result.next.items);
if (updateTriage.active()) updateTriage.acceptCompleted();
}
} catch (error) {
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
@@ -5530,7 +5562,10 @@
qs('#mark-update-read-next').disabled = true;
try {
const result = await notificationReader.markReadAndNext(lastMyWork);
if (result) notificationUndo.offer(result.item, result.items);
if (result) {
notificationUndo.offer(result.item, result.items);
if (updateTriage.active()) updateTriage.acceptCompleted();
}
} finally {
qs('#mark-update-read-next').disabled = false;
}
@@ -5540,7 +5575,10 @@
button.disabled = true;
try {
const result = await notificationReader.acknowledgeAndNext(lastMyWork);
if (result) notificationUndo.offer(result.item, result.items);
if (result) {
notificationUndo.offer(result.item, result.items);
if (updateTriage.active()) updateTriage.acceptCompleted();
}
} finally {
button.disabled = offlineWorkMode;
}
+3 -1
View File
@@ -749,8 +749,9 @@
<div class="small" id="update-sheet-key"></div>
<h3 id="update-sheet-title">Unread update</h3>
</div>
<button id="keep-update-unread" type="button">Keep unread</button>
<button id="keep-update-unread" type="button">Keep unread &amp; next</button>
</div>
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
<div class="row">
@@ -1071,6 +1072,7 @@
<script src="static/mobile-task-dock.js"></script>
<script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-queue-launcher.js"></script>
<script src="static/update-triage-session.js"></script>
<script src="static/agenda-session-launcher.js"></script>
<script src="static/mobile-launch.js"></script>
<script src="static/mobile-app-shortcuts.js"></script>
+1
View File
@@ -28,6 +28,7 @@
function open(name) {
if (name === 'today') return options.openToday();
if (name === 'agenda') return options.openAgenda();
if (name === 'update' && options.openUpdates) return options.openUpdates();
options.selectFilter(name);
const action = options.firstAction(name);
if (!action) {
+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-v99';
const CACHE = 'stackchain-dashboard-shell-v100';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@@ -76,6 +76,7 @@ const SHELL = [
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/update-triage-session.js',
BASE + 'static/agenda-session-launcher.js',
BASE + 'static/mobile-launch.js',
BASE + 'static/mobile-app-shortcuts.js',
+100
View File
@@ -0,0 +1,100 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateTriageSession = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageSession(options) {
const key = options.key || 'stackchain.update-triage.v1';
const identity = item => Number.isInteger(item?.notification_id) ? String(item.notification_id) : '';
const login = () => String(options.getLogin() || '').trim();
let state = null;
let running = false;
function read() {
const owner = login();
if (!owner) return null;
try {
const value = JSON.parse(options.storage.getItem(key) || 'null');
if (value?.version !== 1 || value.login !== owner || !Array.isArray(value.identities) ||
!value.identities.length || typeof value.current !== 'string' || !Array.isArray(value.completed)) return null;
const identities = value.identities.filter(value => typeof value === 'string' && value);
const completed = value.completed.filter(value => identities.includes(value));
if (!identities.length || !identities.includes(value.current)) return null;
return { version:1, login:owner, identities, current:value.current, completed };
} catch (_) {
return null;
}
}
function persist() {
if (!state) return;
options.storage.setItem(key, JSON.stringify(state));
}
function available() {
const byIdentity = new Map((options.getItems() || []).map(item => [identity(item), item]));
return state.identities.filter(id => !state.completed.includes(id) && byIdentity.has(id))
.map(id => byIdentity.get(id));
}
function finish() {
running = false;
state = null;
options.storage.removeItem(key);
options.onFinish();
return false;
}
function openCurrent(preferredIndex = null) {
const items = available();
if (!items.length) return finish();
let index = items.findIndex(item => identity(item) === state.current);
if (index < 0) index = Math.min(preferredIndex ?? 0, items.length - 1);
state.current = identity(items[index]);
persist();
const originalIndex = state.identities.indexOf(state.current);
options.onProgress({ index:originalIndex + 1, total:state.identities.length });
options.onOpen(items[index]);
return true;
}
function advance(openNext = true) {
if (!running || !state) return false;
const originalIndex = state.identities.indexOf(state.current);
if (!state.completed.includes(state.current)) state.completed.push(state.current);
const candidates = available();
const next = candidates.find(item => state.identities.indexOf(identity(item)) > originalIndex) || candidates[0];
if (!next) return finish();
state.current = identity(next);
if (openNext) return openCurrent();
persist();
options.onProgress({ index:state.identities.indexOf(state.current) + 1, total:state.identities.length });
return true;
}
return {
active: () => running,
resumable: () => Boolean(read()),
start() {
const identities = (options.getItems() || []).map(identity).filter(Boolean);
if (!identities.length) return finish();
state = { version:1, login:login(), identities, current:identities[0], completed:[] };
if (!state.login) return false;
running = true;
return openCurrent();
},
resume() {
state = read();
if (!state) return false;
running = true;
return openCurrent(state.identities.indexOf(state.current));
},
reconcile() {
if (!running || !state) return false;
return openCurrent(state.identities.indexOf(state.current));
},
completeAndNext: advance,
acceptCompleted: () => advance(false),
keepUnreadAndNext: advance,
items: () => state ? available().slice() : [],
end: finish,
};
});