Compare commits

...

6 Commits

Author SHA1 Message Date
f9d602f85f Merge pull request 'Pin the current item from mobile detail' (#1490) from timmy/1489-pin-current-mobile-detail into main
Some checks failed
CI / lint (push) Successful in 3m39s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Failing after 1h14m26s
CI / release-candidate (push) Has been cancelled
2026-08-28 02:11:46 +00:00
2a06cf1daa feat: pin current mobile detail item (Closes #1489)
Some checks failed
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 1h10m38s
CI / release-candidate (pull_request) Has been cancelled
2026-08-27 23:30:06 +00:00
b0dbcb5a25 Merge pull request 'Compact and rank pinned mobile work by recent use' (#1488) from timmy/1487-compact-rank-pinned-mobile-work into main
All checks were successful
CI / lint (push) Successful in 3m47s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m38s
CI / release-candidate (push) Successful in 6s
2026-08-27 22:46:49 +00:00
2766a75f24 Compact and rank pinned mobile work
All checks were successful
CI / lint (pull_request) Successful in 3m43s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 22:34:37 +00:00
5f9b5b3a7f Merge pull request 'Make mobile Recent Work sync generation-safe and self-healing' (#1486) from timmy/1485-mobile-recent-work-sync into main
All checks were successful
CI / lint (push) Successful in 3m46s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m49s
CI / release-candidate (push) Successful in 7s
2026-08-27 21:55:39 +00:00
cec5b93665 fix: make mobile recent work sync generation-safe (Closes #1485)
All checks were successful
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 8m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 21:42:47 +00:00
8 changed files with 437 additions and 34 deletions

View File

@ -888,8 +888,11 @@ textarea { resize: vertical; min-height: 120px; }
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; } .issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } .issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.issue-sheet-header button { min-height:44px; } .issue-sheet-header button { min-height:44px; }
.current-work-pin { display:none; }
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav { display:none; } .mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav { display:none; }
@media (max-width:600px) { @media (max-width:600px) {
[data-current-work-pin] { display:inline-flex; align-items:center; justify-content:center; min-width:64px; min-height:44px; padding-inline:12px; }
[data-current-work-pin="unpin"] { border-color:#60a5fa; background:#17365a; color:#fff; }
.issue-sheet-panel, .pull-sheet-panel, .update-sheet-panel, .review-sheet-panel { padding-top:max(12px,env(safe-area-inset-top)); } .issue-sheet-panel, .pull-sheet-panel, .update-sheet-panel, .review-sheet-panel { padding-top:max(12px,env(safe-area-inset-top)); }
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav { .mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav {
position:sticky; top:env(safe-area-inset-top); z-index:6; position:sticky; top:env(safe-area-inset-top); z-index:6;
@ -1555,6 +1558,7 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-queue-list .mobile-recent-work-row button { min-height:44px; width:auto; } .mobile-queue-list .mobile-recent-work-row button { min-height:44px; width:auto; }
.mobile-queue-list .mobile-recent-work-row [data-recent-work-route] { min-width:0; width:100%; min-height:56px; overflow-wrap:anywhere; } .mobile-queue-list .mobile-recent-work-row [data-recent-work-route] { min-width:0; width:100%; min-height:56px; overflow-wrap:anywhere; }
.mobile-queue-list .mobile-recent-work-row [data-recent-work-pin] { min-width:64px; justify-content:center; padding-inline:12px; } .mobile-queue-list .mobile-recent-work-row [data-recent-work-pin] { min-width:64px; justify-content:center; padding-inline:12px; }
.mobile-pinned-work-toggle { min-height:44px; width:100%; margin-top:8px; }
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; } .mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; } .mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; } .mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }

View File

@ -441,6 +441,8 @@
list:qs('#mobile-recent-work-list'), list:qs('#mobile-recent-work-list'),
pinnedSection:qs('#mobile-pinned-work'), pinnedSection:qs('#mobile-pinned-work'),
pinnedList:qs('#mobile-pinned-work-list'), pinnedList:qs('#mobile-pinned-work-list'),
pinnedToggle:qs('#mobile-pinned-work-toggle'),
detailPins:qsa('[data-current-work-pin]'),
status:qs('#mobile-recent-work-status'), status:qs('#mobile-recent-work-status'),
openRoute:fragment => { openRoute:fragment => {
const sheet = qs('#mobile-queue-sheet'); const sheet = qs('#mobile-queue-sheet');
@ -2081,6 +2083,7 @@
closeOpenWorkSheets(); closeOpenWorkSheets();
await openRoutedWorkSection(item); await openRoutedWorkSection(item);
mobileRecentWork.record(item); mobileRecentWork.record(item);
mobileRecentWork.setCurrent(item);
const route = createWorkRoute.parse(window.location.hash); const route = createWorkRoute.parse(window.location.hash);
if (route?.section === item.section) navigateWorkSection(item.kind, item.section); if (route?.section === item.section) navigateWorkSection(item.kind, item.section);
}, },
@ -2111,6 +2114,7 @@
qs('#retry-work-route').addEventListener('click', () => workRoute.sync()); qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
function closeOpenWorkSheets() { function closeOpenWorkSheets() {
mobileRecentWork.setCurrent(null);
issueVoiceReply.cancel(); issueVoiceReply.cancel();
pullVoiceReply.cancel(); pullVoiceReply.cancel();
updateVoiceReply.cancel(); updateVoiceReply.cancel();

View File

@ -1053,6 +1053,7 @@
<div class="small" id="issue-sheet-key"></div> <div class="small" id="issue-sheet-key"></div>
<h3 id="issue-sheet-title">Assigned issue</h3> <h3 id="issue-sheet-title">Assigned issue</h3>
</div> </div>
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
<button id="close-issue-sheet" type="button">Close sheet</button> <button id="close-issue-sheet" type="button">Close sheet</button>
</div> </div>
<nav class="mobile-issue-detail-nav" aria-label="Issue sections"> <nav class="mobile-issue-detail-nav" aria-label="Issue sections">
@ -1553,6 +1554,7 @@
<div class="small" id="update-sheet-key"></div> <div class="small" id="update-sheet-key"></div>
<h3 id="update-sheet-title">Unread update</h3> <h3 id="update-sheet-title">Unread update</h3>
</div> </div>
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
</div> </div>
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div> <div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
<nav class="mobile-update-detail-nav" aria-label="Update sections"> <nav class="mobile-update-detail-nav" aria-label="Update sections">
@ -1676,6 +1678,7 @@
<section class="pull-sheet-panel"> <section class="pull-sheet-panel">
<div class="pull-sheet-header"> <div class="pull-sheet-header">
<div><div class="small" id="pull-sheet-key"></div><h3 id="pull-sheet-title">Assigned pull request</h3></div> <div><div class="small" id="pull-sheet-key"></div><h3 id="pull-sheet-title">Assigned pull request</h3></div>
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
<button id="close-pull-sheet" type="button">Close</button> <button id="close-pull-sheet" type="button">Close</button>
</div> </div>
<nav class="mobile-detail-nav mobile-pull-detail-nav" aria-label="Pull request sections"> <nav class="mobile-detail-nav mobile-pull-detail-nav" aria-label="Pull request sections">
@ -1912,6 +1915,7 @@
<div class="small" id="review-sheet-key"></div> <div class="small" id="review-sheet-key"></div>
<h3 id="review-sheet-title">Pull request review</h3> <h3 id="review-sheet-title">Pull request review</h3>
</div> </div>
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
<button class="review-action" id="close-review-sheet">Close</button> <button class="review-action" id="close-review-sheet">Close</button>
</div> </div>
<nav class="mobile-review-detail-nav" aria-label="Review sections"> <nav class="mobile-review-detail-nav" aria-label="Review sections">
@ -2187,6 +2191,7 @@
<section class="mobile-queue-group" id="mobile-pinned-work" aria-labelledby="mobile-pinned-work-heading" hidden> <section class="mobile-queue-group" id="mobile-pinned-work" aria-labelledby="mobile-pinned-work-heading" hidden>
<h3 id="mobile-pinned-work-heading">Pinned work</h3> <h3 id="mobile-pinned-work-heading">Pinned work</h3>
<div class="mobile-queue-list" id="mobile-pinned-work-list"></div> <div class="mobile-queue-list" id="mobile-pinned-work-list"></div>
<button class="mobile-pinned-work-toggle" id="mobile-pinned-work-toggle" type="button" aria-controls="mobile-pinned-work-list" aria-expanded="false" hidden>Show all</button>
</section> </section>
<section class="mobile-queue-group" id="mobile-recent-work" aria-labelledby="mobile-recent-work-heading" hidden> <section class="mobile-queue-group" id="mobile-recent-work" aria-labelledby="mobile-recent-work-heading" hidden>
<h3 id="mobile-recent-work-heading">Recent work</h3> <h3 id="mobile-recent-work-heading">Recent work</h3>

View File

@ -15,9 +15,57 @@
const setTimer = options.setTimeout || setTimeout; const setTimer = options.setTimeout || setTimeout;
const clearTimer = options.clearTimeout || clearTimeout; const clearTimer = options.clearTimeout || clearTimeout;
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150; const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
const retryMs = Number.isFinite(options.retryMs) ? Math.max(1, options.retryMs) : 1000;
const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryMs, options.retryMaxMs) : 30000;
let syncFlight = null; let syncFlight = null;
let syncAccount = ''; let syncAccount = '';
let debounceTimer = null; let debounceTimer = null;
let retryTimer = null;
let retryAccount = '';
let retryAttempt = 0;
let operationSequence = 0;
let pinsExpanded = false;
let currentItem = null;
const detailPins = Array.from(options.detailPins || []);
detailPins.forEach(button => button.addEventListener?.('click', () => {
if (!currentItem) return;
const isPinned = pinned().some(item => item.route === currentItem.route);
if (isPinned) unpin(currentItem.route);
else pin(currentItem);
}));
options.pinnedToggle?.addEventListener?.('click', () => {
pinsExpanded = !pinsExpanded;
render();
});
function operationId() {
operationSequence += 1;
return Date.now().toString(36) + '-' + operationSequence.toString(36);
}
function clearRetry(resetAttempt = false) {
if (retryTimer) clearTimer(retryTimer);
retryTimer = null;
retryAccount = '';
if (resetAttempt) retryAttempt = 0;
}
function scheduleRetry(accountKey) {
if (retryTimer || key() !== accountKey || !hasPending(read())) return false;
retryAccount = accountKey;
const delay = Math.min(retryMaxMs, retryMs * (2 ** retryAttempt));
retryAttempt += 1;
retryTimer = setTimer(() => {
const timer = retryTimer;
retryTimer = null;
retryAccount = '';
if (timer) clearTimer(timer);
if (key() === accountKey && hasPending(read())) void sync();
}, delay);
return true;
}
function login() { function login() {
return String(getLogin?.() || '').trim().toLowerCase(); return String(getLogin?.() || '').trim().toLowerCase();
@ -66,13 +114,28 @@
const route = action === 'pin' ? item?.route : String(candidate?.route || ''); const route = action === 'pin' ? item?.route : String(candidate?.route || '');
if ((action !== 'pin' && action !== 'unpin') || !route || (action === 'pin' && !item)) continue; if ((action !== 'pin' && action !== 'unpin') || !route || (action === 'pin' && !item)) continue;
if (!unique.some(existing => (existing.item?.route || existing.route) === route)) { if (!unique.some(existing => (existing.item?.route || existing.route) === route)) {
unique.push(action === 'pin' ? {action, item} : {action, route}); const normalized = action === 'pin' ? {action, item} : {action, route};
if (typeof candidate.operationId === 'string' && candidate.operationId) normalized.operationId = candidate.operationId;
unique.push(normalized);
} }
if (unique.length === pinnedLimit) break; if (unique.length === pinnedLimit) break;
} }
return unique; return unique;
} }
function normalizePending(value) {
if (!Array.isArray(value)) return [];
const unique = [];
for (const candidate of value) {
const item = normalize(candidate);
if (!item || unique.some(existing => existing.route === item.route)) continue;
if (typeof candidate.operationId === 'string' && candidate.operationId) item.operationId = candidate.operationId;
unique.push(item);
if (unique.length === limit) break;
}
return unique;
}
function empty() { function empty() {
return {items:[], pinned:[], pending:[], pinOps:[]}; return {items:[], pinned:[], pending:[], pinOps:[]};
} }
@ -86,7 +149,7 @@
return { return {
items:normalizeList(parsed?.items), items:normalizeList(parsed?.items),
pinned:normalizeList(parsed?.pinned, pinnedLimit), pinned:normalizeList(parsed?.pinned, pinnedLimit),
pending:normalizeList(parsed?.pending), pending:normalizePending(parsed?.pending),
pinOps:normalizePinOps(parsed?.pinOps), pinOps:normalizePinOps(parsed?.pinOps),
}; };
} catch (_) { } catch (_) {
@ -100,7 +163,7 @@
storage.setItem(accountKey, JSON.stringify({ storage.setItem(accountKey, JSON.stringify({
items:normalizeList(value.items), items:normalizeList(value.items),
pinned:normalizeList(value.pinned, pinnedLimit), pinned:normalizeList(value.pinned, pinnedLimit),
pending:normalizeList(value.pending), pending:normalizePending(value.pending),
pinOps:normalizePinOps(value.pinOps), pinOps:normalizePinOps(value.pinOps),
})); }));
return true; return true;
@ -148,8 +211,10 @@
if (!accountKey || !normalized) return false; if (!accountKey || !normalized) return false;
const current = read(); const current = read();
current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit); current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit);
current.pinned = current.pinned.map(existing => existing.route === normalized.route ? normalized : existing); current.pinned = current.pinned.some(existing => existing.route === normalized.route)
current.pending = [normalized, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit); ? [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)]
: current.pinned;
current.pending = [{...normalized, operationId:operationId()}, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit);
if (!persist(current, accountKey)) return false; if (!persist(current, accountKey)) return false;
announce(current); announce(current);
render(); render();
@ -159,7 +224,7 @@
function queuePinOp(current, operation) { function queuePinOp(current, operation) {
const route = operation.item?.route || operation.route; const route = operation.item?.route || operation.route;
current.pinOps = [operation, ...current.pinOps.filter(existing => (existing.item?.route || existing.route) !== route)]; current.pinOps = [{...operation, operationId:operationId()}, ...current.pinOps.filter(existing => (existing.item?.route || existing.route) !== route)];
} }
function pin(item) { function pin(item) {
@ -206,7 +271,7 @@
if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false; if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false;
const remote = normalizeList(snapshot.items); const remote = normalizeList(snapshot.items);
const remotePinned = normalizeList(snapshot.pinned, pinnedLimit); const remotePinned = normalizeList(snapshot.pinned, pinnedLimit);
const unsent = normalizeList(pending); const unsent = normalizePending(pending);
const unsentPinOps = normalizePinOps(pinOps); const unsentPinOps = normalizePinOps(pinOps);
const value = { const value = {
items:normalizeList([...unsent, ...remote]), items:normalizeList([...unsent, ...remote]),
@ -231,7 +296,7 @@
let snapshot; let snapshot;
if (sending) { if (sending) {
snapshot = await fetchJson('api/v1/recent-work', { snapshot = await fetchJson('api/v1/recent-work', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(sending), method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(normalize(sending)),
}); });
} else { } else {
const isPin = pinOperation.action === 'pin'; const isPin = pinOperation.action === 'pin';
@ -244,17 +309,21 @@
if (key() !== accountKey) return read(); if (key() !== accountKey) return read();
const latest = read(); const latest = read();
const pending = sending const pending = sending
? latest.pending.filter(item => item.route !== sending.route) ? latest.pending.filter(item => item.operationId !== sending.operationId)
: latest.pending; : latest.pending;
const pinOps = pinOperation const pinOps = pinOperation
? latest.pinOps.filter(operation => { ? latest.pinOps.filter(operation => {
const sameRoute = (operation.item?.route || operation.route) === (pinOperation.item?.route || pinOperation.route); const sameRoute = (operation.item?.route || operation.route) === (pinOperation.item?.route || pinOperation.route);
return !sameRoute || operation.action !== pinOperation.action; return !sameRoute || operation.action !== pinOperation.action || operation.operationId !== pinOperation.operationId;
}) })
: latest.pinOps; : latest.pinOps;
if (!adopt(snapshot, accountKey, pending, pinOps)) throw new Error('Recent work response is invalid.'); if (!adopt(snapshot, accountKey, pending, pinOps)) throw new Error('Recent work response is invalid.');
retryAttempt = 0;
} catch (_error) { } catch (_error) {
if (key() === accountKey) announce(read()); if (key() === accountKey) {
announce(read());
scheduleRetry(accountKey);
}
return read(); return read();
} }
} }
@ -264,6 +333,8 @@
function sync() { function sync() {
if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; } if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
const accountKey = key(); const accountKey = key();
if (retryTimer && retryAccount !== accountKey) clearRetry(true);
else if (retryTimer) clearRetry(false);
if (!fetchJson || !accountKey || !hasPending(read())) return Promise.resolve(read()); if (!fetchJson || !accountKey || !hasPending(read())) return Promise.resolve(read());
if (syncFlight && syncAccount === accountKey) return syncFlight; if (syncFlight && syncAccount === accountKey) return syncFlight;
syncAccount = accountKey; syncAccount = accountKey;
@ -320,7 +391,10 @@
button.setAttribute('type', 'button'); button.setAttribute('type', 'button');
button.setAttribute('data-recent-work-route', item.route); button.setAttribute('data-recent-work-route', item.route);
button.setAttribute('aria-label', 'Open ' + item.title + ', ' + itemDetail.toLowerCase().replace(' · ', ' ')); button.setAttribute('aria-label', 'Open ' + item.title + ', ' + itemDetail.toLowerCase().replace(' · ', ' '));
button.addEventListener('click', () => options.openRoute?.(item.route)); button.addEventListener('click', () => {
if (isPinned) record(item);
options.openRoute?.(item.route);
});
action.textContent = isPinned ? 'Unpin' : 'Pin'; action.textContent = isPinned ? 'Unpin' : 'Pin';
action.setAttribute('type', 'button'); action.setAttribute('type', 'button');
action.setAttribute('data-recent-work-pin', isPinned ? 'unpin' : 'pin'); action.setAttribute('data-recent-work-pin', isPinned ? 'unpin' : 'pin');
@ -331,23 +405,51 @@
return wrapper; return wrapper;
} }
function renderCurrent() {
const isPinned = currentItem && pinned().some(item => item.route === currentItem.route);
detailPins.forEach(button => {
button.hidden = !currentItem;
if (!currentItem) return;
button.textContent = isPinned ? 'Pinned' : 'Pin';
button.setAttribute('aria-pressed', isPinned ? 'true' : 'false');
button.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + currentItem.title);
button.setAttribute('data-current-work-pin', isPinned ? 'unpin' : 'pin');
});
}
function setCurrent(item) {
currentItem = normalize(item);
renderCurrent();
return Boolean(currentItem);
}
function render() { function render() {
const recent = items(); const recent = items();
const fixed = pinned(); const fixed = pinned();
const fixedRoutes = new Set(fixed.map(item => item.route));
const visibleRecent = recent.filter(item => !fixedRoutes.has(item.route));
const list = options.list; const list = options.list;
const section = options.section; const section = options.section;
if (list && section && options.document) { if (list && section && options.document) {
const rows = recent.map(item => row(item, false)); const rows = visibleRecent.map(item => row(item, false));
list.replaceChildren(...rows); list.replaceChildren(...rows);
section.hidden = rows.length === 0; section.hidden = rows.length === 0;
} }
if (options.pinnedList && options.pinnedSection && options.document) { if (options.pinnedList && options.pinnedSection && options.document) {
const rows = fixed.map(item => row(item, true)); const visiblePins = pinsExpanded ? fixed : fixed.slice(0, 3);
const rows = visiblePins.map(item => row(item, true));
options.pinnedList.replaceChildren(...rows); options.pinnedList.replaceChildren(...rows);
options.pinnedSection.hidden = rows.length === 0; options.pinnedSection.hidden = rows.length === 0;
} }
return recent.length + fixed.length; if (options.pinnedToggle) {
options.pinnedToggle.hidden = fixed.length <= 3;
options.pinnedToggle.textContent = pinsExpanded ? 'Show fewer' : 'Show all ' + fixed.length;
options.pinnedToggle.setAttribute('aria-expanded', pinsExpanded ? 'true' : 'false');
options.pinnedToggle.setAttribute('aria-controls', 'mobile-pinned-work-list');
}
renderCurrent();
return visibleRecent.length + fixed.length;
} }
return {items, pinned, record, pin, unpin, render, load, sync, startLifecycle, state}; return {items, pinned, record, pin, unpin, setCurrent, render, load, sync, startLifecycle, state};
}); });

View File

@ -141,9 +141,9 @@ class RecentWorkStore:
state, _legacy = self._state(row, login) state, _legacy = self._state(row, login)
state["items"] = [normalized, *(entry for entry in state["items"] if entry["route"] != normalized["route"])][: self.limit] state["items"] = [normalized, *(entry for entry in state["items"] if entry["route"] != normalized["route"])][: self.limit]
state["pinned"] = [ state["pinned"] = [
normalized if entry["route"] == normalized["route"] else entry normalized,
for entry in state["pinned"] *(entry for entry in state["pinned"] if entry["route"] != normalized["route"]),
] ] if any(entry["route"] == normalized["route"] for entry in state["pinned"]) else state["pinned"]
self._write(connection, login, state) self._write(connection, login, state)
return state return state

View File

@ -30,10 +30,6 @@ def test_operator_pins_and_reopens_frequent_work_without_phone_overflow(viewport
getItem:key => values.get(key) || null, getItem:key => values.get(key) || null,
setItem:(key, value) => values.set(key, value), setItem:(key, value) => values.set(key, value),
}; };
const item = {
kind:'issue', repository:'stackchain/stackchain-dashboard', number:1477,
title:'Pin frequent work across signed-in mobile devices',
};
window.opened = []; window.opened = [];
window.recentWork = createMobileRecentWork({ window.recentWork = createMobileRecentWork({
storage, getLogin:() => 'timmy', document, storage, getLogin:() => 'timmy', document,
@ -41,32 +37,71 @@ def test_operator_pins_and_reopens_frequent_work_without_phone_overflow(viewport
list:document.querySelector('#mobile-recent-work-list'), list:document.querySelector('#mobile-recent-work-list'),
pinnedSection:document.querySelector('#mobile-pinned-work'), pinnedSection:document.querySelector('#mobile-pinned-work'),
pinnedList:document.querySelector('#mobile-pinned-work-list'), pinnedList:document.querySelector('#mobile-pinned-work-list'),
pinnedToggle:document.querySelector('#mobile-pinned-work-toggle'),
detailPins:document.querySelectorAll('[data-current-work-pin]'),
status:document.querySelector('#mobile-recent-work-status'), status:document.querySelector('#mobile-recent-work-status'),
openRoute:route => window.opened.push(route), openRoute:route => window.opened.push(route),
}); });
window.recentWork.record(item); for (let number=1; number<=20; number += 1) {
window.recentWork.pin(item); const item = {
kind:'issue', repository:'stackchain/stackchain-dashboard', number,
title:'Pinned mobile work ' + number,
};
window.recentWork.record(item);
window.recentWork.pin(item);
}
document.querySelector('#mobile-queue-sheet').showModal(); document.querySelector('#mobile-queue-sheet').showModal();
}""") }""")
expect(page.locator("#mobile-pinned-work")).to_be_visible() expect(page.locator("#mobile-pinned-work")).to_be_visible()
expect(page.locator("#mobile-recent-work")).to_be_visible() expect(page.locator("#mobile-recent-work")).to_be_hidden()
expect(page.locator("#mobile-pinned-work-list .mobile-recent-work-row")).to_have_count(3)
expect(page.locator("#mobile-recent-work-status")).to_have_text("Sync pending.") expect(page.locator("#mobile-recent-work-status")).to_have_text("Sync pending.")
toggle = page.locator("#mobile-pinned-work-toggle")
open_button = page.get_by_role( open_button = page.get_by_role(
"button", name="Open Pin frequent work across signed-in mobile devices, issue stackchain/stackchain-dashboard #1477" "button", name="Open Pinned mobile work 20, issue stackchain/stackchain-dashboard #20"
).first ).first
pin_button = page.get_by_role( pin_button = page.get_by_role(
"button", name="Unpin Pin frequent work across signed-in mobile devices" "button", name="Unpin Pinned mobile work 20"
) )
for control in (open_button, pin_button): for control in (open_button, pin_button, toggle):
bounds = control.bounding_box() bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44 assert bounds and bounds["height"] >= 44
assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"] assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
toggle.click()
expect(page.locator("#mobile-pinned-work-list .mobile-recent-work-row")).to_have_count(20)
expect(toggle).to_have_text("Show fewer")
assert toggle.get_attribute("aria-expanded") == "true"
toggle.click()
expect(page.locator("#mobile-pinned-work-list .mobile-recent-work-row")).to_have_count(3)
open_button.focus() open_button.focus()
open_button.press("Enter") open_button.press("Enter")
assert page.evaluate("window.opened") == [ assert page.evaluate("window.opened") == [
"#/my-work/issue/stackchain/stackchain-dashboard/1477" "#/my-work/issue/stackchain/stackchain-dashboard/20"
] ]
page.evaluate("""() => {
document.querySelector('#mobile-queue-sheet').close();
document.querySelector('#issue-sheet').classList.add('open');
window.recentWork.setCurrent({
kind:'issue', repository:'stackchain/stackchain-dashboard', number:1489,
title:'Pin the current item from mobile detail',
});
}""")
detail_pin = page.get_by_role(
"button", name="Pin Pin the current item from mobile detail"
)
expect(detail_pin).to_be_visible()
bounds = detail_pin.bounding_box()
assert bounds and bounds["height"] >= 44
assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
detail_pin.focus()
detail_pin.press("Enter")
expect(detail_pin).to_have_text("Pinned")
expect(detail_pin).to_have_attribute("aria-pressed", "true")
assert page.evaluate("window.recentWork.pinned()[0].number") == 1489
browser.close() browser.close()

View File

@ -159,6 +159,118 @@ process.stdout.write(JSON.stringify({{immediate,settled:recent.items(),status:st
assert payload["calls"] == [["api/v1/recent-work", "POST"]] assert payload["calls"] == [["api/v1/recent-work", "POST"]]
def test_recent_work_drains_a_newer_same_route_generation_after_an_inflight_response():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
(async()=>{{
const values = new Map(); const calls=[];
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
const oldItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Old title'}};
const newItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'New title'}};
let releaseFirst;
const firstResponse=new Promise(resolve=>{{releaseFirst=resolve;}});
const recent=createRecentWork({{
storage,getLogin:()=>'alice',debounceMs:99999,
fetchJson:async (_url, options)=>{{
const sent=JSON.parse(options.body);
calls.push(sent.title);
if (calls.length === 1) return firstResponse;
return {{items:[{{...newItem,route:'#/my-work/issue/stackchain/dashboard/7'}}],pinned:[]}};
}},
}});
recent.record(oldItem);
const syncing=recent.sync();
await Promise.resolve();
recent.record(newItem);
releaseFirst({{items:[{{...oldItem,route:'#/my-work/issue/stackchain/dashboard/7'}}],pinned:[]}});
await syncing;
process.stdout.write(JSON.stringify({{calls,items:recent.items(),state:recent.state()}}));
process.exit(0);
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
payload = run_node(script)
assert payload["calls"] == ["Old title", "New title"]
assert payload["items"][0]["title"] == "New title"
assert payload["state"] == {"pending": False, "pendingCount": 0}
def test_recent_work_retries_a_transient_sync_failure_without_a_lifecycle_event():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
(async()=>{{
const values=new Map(); const timers=[]; let calls=0;
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice',debounceMs:99999,retryMs:25,
setTimeout:(callback,delay)=>{{const timer={{callback,delay,cleared:false}};timers.push(timer);return timer;}},
clearTimeout:timer=>{{timer.cleared=true;}},
fetchJson:async (_url,options)=>{{
calls += 1;
if (calls === 1) throw new Error('temporary outage');
return {{items:[JSON.parse(options.body)],pinned:[]}};
}},
}});
recent.record({{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Keep me'}});
await recent.sync();
const afterFailure={{calls,state:recent.state(),active:timers.filter(timer=>!timer.cleared).map(timer=>timer.delay)}};
const retryTimer=timers.find(timer=>!timer.cleared);
retryTimer?.callback();
await new Promise(resolve=>setImmediate(resolve));
const settled={{calls,state:recent.state(),active:timers.filter(timer=>!timer.cleared).length}};
process.stdout.write(JSON.stringify({{afterFailure,settled}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
payload = run_node(script)
assert payload["afterFailure"] == {
"calls": 1,
"state": {"pending": True, "pendingCount": 1},
"active": [25],
}
assert payload["settled"] == {
"calls": 2,
"state": {"pending": False, "pendingCount": 0},
"active": 0,
}
def test_recent_work_drains_a_newer_pin_generation_after_an_inflight_response():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
(async()=>{{
const values=new Map(); const calls=[];
const oldItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Old pin'}};
const newItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'New pin'}};
const route='#/my-work/issue/stackchain/dashboard/7';
let releaseFirst;
const firstResponse=new Promise(resolve=>{{releaseFirst=resolve;}});
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice',debounceMs:99999,
fetchJson:async (_url,options)=>{{
const sent=JSON.parse(options.body); calls.push(sent.title);
if (calls.length === 1) return firstResponse;
return {{items:[],pinned:[{{...newItem,route}}]}};
}},
}});
recent.pin(oldItem);
const syncing=recent.sync();
await Promise.resolve();
recent.pin(newItem);
releaseFirst({{items:[],pinned:[{{...oldItem,route}}]}});
await syncing;
process.stdout.write(JSON.stringify({{calls,pinned:recent.pinned(),state:recent.state()}}));
process.exit(0);
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
payload = run_node(script)
assert payload["calls"] == ["Old pin", "New pin"]
assert payload["pinned"][0]["title"] == "New pin"
assert payload["state"] == {"pending": False, "pendingCount": 0}
def test_recent_work_pins_offline_first_syncs_and_renders_separate_touch_actions(): def test_recent_work_pins_offline_first_syncs_and_renders_separate_touch_actions():
script = f""" script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))}); const createRecentWork = require({json.dumps(str(RECENT_WORK))});
@ -194,7 +306,7 @@ const recent=createRecentWork({{
const pinnedImmediately=recent.pin(item); const pinnedImmediately=recent.pin(item);
recent.render(); recent.render();
const immediate={{pinned:recent.pinned(),status:status.textContent,recentHidden:recentSection.hidden,pinnedHidden:pinnedSection.hidden, const immediate={{pinned:recent.pinned(),status:status.textContent,recentHidden:recentSection.hidden,pinnedHidden:pinnedSection.hidden,
recentActions:recentList.children[0].children.map(child=>child.attributes), recentRows:recentList.children.length,
pinnedActions:pinnedList.children[0].children.map(child=>child.attributes)}}; pinnedActions:pinnedList.children[0].children.map(child=>child.attributes)}};
await recent.sync(); await recent.sync();
pinnedList.children[0].children[0].click(); pinnedList.children[0].children[0].click();
@ -208,16 +320,16 @@ process.stdout.write(JSON.stringify({{pinnedImmediately,immediate,opened,unpinne
assert payload["pinnedImmediately"] is True assert payload["pinnedImmediately"] is True
assert payload["immediate"]["pinned"][0]["number"] == 1477 assert payload["immediate"]["pinned"][0]["number"] == 1477
assert payload["immediate"]["status"] == "Sync pending." assert payload["immediate"]["status"] == "Sync pending."
assert payload["immediate"]["recentHidden"] is False assert payload["immediate"]["recentHidden"] is True
assert payload["immediate"]["pinnedHidden"] is False assert payload["immediate"]["pinnedHidden"] is False
assert payload["immediate"]["recentActions"][0]["data-recent-work-route"].endswith("/1477") assert payload["immediate"]["recentRows"] == 0
assert payload["immediate"]["recentActions"][1]["data-recent-work-pin"] == "pin"
assert payload["immediate"]["pinnedActions"][1]["data-recent-work-pin"] == "unpin" assert payload["immediate"]["pinnedActions"][1]["data-recent-work-pin"] == "unpin"
assert payload["opened"] == ["#/my-work/issue/stackchain/dashboard/1477"] assert payload["opened"] == ["#/my-work/issue/stackchain/dashboard/1477"]
assert payload["unpinnedImmediately"] is True assert payload["unpinnedImmediately"] is True
assert payload["settled"] == [] assert payload["settled"] == []
assert payload["calls"] == [ assert payload["calls"] == [
["api/v1/recent-work/pin", "PUT", payload["immediate"]["pinned"][0]], ["api/v1/recent-work/pin", "PUT", payload["immediate"]["pinned"][0]],
["api/v1/recent-work", "POST", payload["immediate"]["pinned"][0]],
[ [
"api/v1/recent-work/pin", "api/v1/recent-work/pin",
"DELETE", "DELETE",
@ -226,6 +338,124 @@ process.stdout.write(JSON.stringify({{pinnedImmediately,immediate,opened,unpinne
] ]
def test_current_detail_pin_follows_route_and_toggles_offline_first():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
const values=new Map();
function button() {{ return {{hidden:true,textContent:'',attributes:{{}},listeners:{{}},
setAttribute(name,value){{this.attributes[name]=String(value);}},
addEventListener(name,callback){{this.listeners[name]=callback;}},
click(){{this.listeners.click?.();}},
}}; }}
const detailPins=[button(),button(),button(),button()];
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice',detailPins,debounceMs:99999,
}});
const issue={{kind:'issue',repository:'stackchain/dashboard',number:1489,title:'Pin current detail'}};
const pull={{kind:'pull',repository:'stackchain/api',number:20,title:'Ship API'}};
recent.setCurrent(issue);
const issueReady=detailPins.map(pin=>({{hidden:pin.hidden,text:pin.textContent,
pressed:pin.attributes['aria-pressed'],label:pin.attributes['aria-label'],action:pin.attributes['data-current-work-pin']}}));
detailPins[0].click();
const pinned={{items:recent.pinned(),state:recent.state(),buttons:detailPins.map(pin=>({{text:pin.textContent,
pressed:pin.attributes['aria-pressed'],label:pin.attributes['aria-label'],action:pin.attributes['data-current-work-pin']}}))}};
recent.setCurrent(pull);
const pullReady={{text:detailPins[1].textContent,label:detailPins[1].attributes['aria-label'],pressed:detailPins[1].attributes['aria-pressed']}};
recent.setCurrent(issue);
detailPins[2].click();
const unpinned={{items:recent.pinned(),recent:recent.items(),state:recent.state(),text:detailPins[0].textContent}};
recent.setCurrent(null);
process.stdout.write(JSON.stringify({{issueReady,pinned,pullReady,unpinned,hidden:detailPins.map(pin=>pin.hidden)}}));
"""
payload = run_node(script)
assert payload["issueReady"] == [
{
"hidden": False,
"text": "Pin",
"pressed": "false",
"label": "Pin Pin current detail",
"action": "pin",
}
] * 4
assert payload["pinned"]["items"][0]["route"] == "#/my-work/issue/stackchain/dashboard/1489"
assert payload["pinned"]["state"] == {"pending": True, "pendingCount": 1}
assert payload["pinned"]["buttons"] == [
{
"text": "Pinned",
"pressed": "true",
"label": "Unpin Pin current detail",
"action": "unpin",
}
] * 4
assert payload["pullReady"] == {
"text": "Pin",
"label": "Pin Ship API",
"pressed": "false",
}
assert payload["unpinned"]["items"] == []
assert payload["unpinned"]["recent"] == []
assert payload["unpinned"]["state"] == {"pending": True, "pendingCount": 1}
assert payload["unpinned"]["text"] == "Pin"
assert payload["hidden"] == [True] * 4
def test_pinned_work_is_compact_deduplicated_and_promotes_on_open():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
const makeItem=number=>({{kind:'issue',repository:'stackchain/dashboard',number,title:'Issue '+number}});
const values=new Map([['stackchain.mobile-recent-work.v1.alice',JSON.stringify({{
items:[1,2,3,4,5].map(makeItem),pinned:[1,2,3,4,5].map(makeItem),pending:[],pinOps:[]
}})]]);
function node(tag) {{ return {{tag,children:[],attributes:{{}},listeners:{{}},hidden:false,textContent:'',
appendChild(child){{this.children.push(child);return child;}},
replaceChildren(...children){{this.children=children;}},
setAttribute(name,value){{this.attributes[name]=String(value);}},
addEventListener(name,callback){{this.listeners[name]=callback;}},
click(){{this.listeners.click?.();}}, focus(){{this.focused=true;}},
}}; }}
const recentList=node('div'), recentSection=node('section');
const pinnedList=node('div'), pinnedSection=node('section'), pinnedToggle=node('button');
const opened=[];
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice',document:{{createElement:node}},debounceMs:99999,
list:recentList,section:recentSection,pinnedList,pinnedSection,pinnedToggle,
openRoute:route=>opened.push(route),
}});
recent.render();
const collapsed={{pinnedRows:pinnedList.children.length,recentRows:recentList.children.length,
recentHidden:recentSection.hidden,toggleHidden:pinnedToggle.hidden,toggleText:pinnedToggle.textContent,
expanded:pinnedToggle.attributes['aria-expanded'],controls:pinnedToggle.attributes['aria-controls']}};
pinnedList.children[2].children[0].click();
const promoted=recent.pinned().map(item=>item.number);
pinnedToggle.click();
const expanded={{pinnedRows:pinnedList.children.length,toggleText:pinnedToggle.textContent,
ariaExpanded:pinnedToggle.attributes['aria-expanded']}};
process.stdout.write(JSON.stringify({{collapsed,promoted,expanded,opened,state:recent.state()}}));
"""
payload = run_node(script)
assert payload["collapsed"] == {
"pinnedRows": 3,
"recentRows": 0,
"recentHidden": True,
"toggleHidden": False,
"toggleText": "Show all 5",
"expanded": "false",
"controls": "mobile-pinned-work-list",
}
assert payload["promoted"] == [3, 1, 2, 4, 5]
assert payload["expanded"] == {
"pinnedRows": 5,
"toggleText": "Show fewer",
"ariaExpanded": "true",
}
assert payload["opened"] == ["#/my-work/issue/stackchain/dashboard/3"]
assert payload["state"] == {"pending": True, "pendingCount": 1}
def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes(): def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
html = INDEX.read_text() html = INDEX.read_text()
dashboard = DASHBOARD.read_text() dashboard = DASHBOARD.read_text()
@ -235,6 +465,8 @@ def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
assert 'id="mobile-recent-work-list"' in html assert 'id="mobile-recent-work-list"' in html
assert 'id="mobile-pinned-work"' in html assert 'id="mobile-pinned-work"' in html
assert 'id="mobile-pinned-work-list"' in html assert 'id="mobile-pinned-work-list"' in html
assert 'id="mobile-pinned-work-toggle"' in html
assert 'aria-controls="mobile-pinned-work-list"' in html
assert 'id="mobile-recent-work-status" role="status" aria-live="polite"' in html assert 'id="mobile-recent-work-status" role="status" aria-live="polite"' in html
assert '<script src="static/mobile-recent-work.js"></script>' in html assert '<script src="static/mobile-recent-work.js"></script>' in html
assert "createMobileRecentWork({" in dashboard assert "createMobileRecentWork({" in dashboard
@ -242,12 +474,18 @@ def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
assert "status:qs('#mobile-recent-work-status')" in dashboard assert "status:qs('#mobile-recent-work-status')" in dashboard
assert "pinnedList:qs('#mobile-pinned-work-list')" in dashboard assert "pinnedList:qs('#mobile-pinned-work-list')" in dashboard
assert "pinnedSection:qs('#mobile-pinned-work')" in dashboard assert "pinnedSection:qs('#mobile-pinned-work')" in dashboard
assert "pinnedToggle:qs('#mobile-pinned-work-toggle')" in dashboard
assert "mobileRecentWork.startLifecycle({window, document})" in dashboard assert "mobileRecentWork.startLifecycle({window, document})" in dashboard
assert "void mobileRecentWork.load();" in dashboard assert "void mobileRecentWork.load();" in dashboard
assert "mobileRecentWork.record(item)" in dashboard assert "mobileRecentWork.record(item)" in dashboard
assert "mobileRecentWork.render()" in dashboard assert "mobileRecentWork.render()" in dashboard
assert "workRoute.sync()" in dashboard assert "workRoute.sync()" in dashboard
assert html.count('data-current-work-pin') == 4
assert "detailPins:qsa('[data-current-work-pin]')" in dashboard
assert "mobileRecentWork.setCurrent(item)" in dashboard
assert "mobileRecentWork.setCurrent(null)" in dashboard
assert "[data-recent-work-route]" in css assert "[data-recent-work-route]" in css
assert "[data-recent-work-pin]" in css assert "[data-recent-work-pin]" in css
assert "[data-current-work-pin]" in css
assert "min-height:44px" in css assert "min-height:44px" in css
assert "min-width:0" in css assert "min-width:0" in css

View File

@ -60,6 +60,21 @@ def test_pinned_work_survives_recent_limit_and_unpin_keeps_recent_item(tmp_path)
assert "#/my-work/issue" not in payload assert "#/my-work/issue" not in payload
def test_reopening_pinned_work_promotes_it_for_every_device(tmp_path):
store = RecentWorkStore(
tmp_path / "recent-work.sqlite3",
encryption_key=b"r" * 32,
)
for number in range(1, 5):
store.pin("timmy", item(number))
reopened = store.record("timmy", item(1, title="Issue 1 current"))
assert [entry["number"] for entry in reopened["pinned"]] == [1, 4, 3, 2]
assert reopened["pinned"][0]["title"] == "Issue 1 current"
assert store.get("timmy")["pinned"] == reopened["pinned"]
def test_recent_work_rejects_noncanonical_or_unsupported_items(tmp_path): def test_recent_work_rejects_noncanonical_or_unsupported_items(tmp_path):
store = RecentWorkStore(tmp_path / "recent-work.sqlite3", encryption_key=b"r" * 32) store = RecentWorkStore(tmp_path / "recent-work.sqlite3", encryption_key=b"r" * 32)