Merge pull request 'Reopen recent work from mobile Queues' (#1472) from timmy/1471-mobile-recent-work into main
All checks were successful
CI / lint (push) Successful in 3m44s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m32s
CI / release-candidate (push) Successful in 7s

This commit is contained in:
rockachopa 2026-08-27 15:53:08 +00:00
commit 0e70fb9b62
8 changed files with 268 additions and 2 deletions

View File

@ -1551,6 +1551,7 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; } .mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
.mobile-queue-list button > span:first-child { display:grid; gap:2px; } .mobile-queue-list button > span:first-child { display:grid; gap:2px; }
.mobile-queue-list small { color:var(--muted); } .mobile-queue-list small { color:var(--muted); }
.mobile-queue-list [data-recent-work-route] { min-height:56px; overflow-wrap:anywhere; }
.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

@ -115,6 +115,7 @@
let offlineWorkMode = false; let offlineWorkMode = false;
let renderMobileQueuePresentation = () => {}; let renderMobileQueuePresentation = () => {};
let mobileQueuePriority = null; let mobileQueuePriority = null;
let mobileRecentWork = { record:() => false, render:() => 0 };
const followingQueue = attachFollowing(item => { const followingQueue = attachFollowing(item => {
searchPreviewReturnKind = 'following'; searchPreviewReturnKind = 'following';
return searchPreview.open(item); return searchPreview.open(item);
@ -313,7 +314,7 @@
find: () => qs('#find-work').click(), find: () => qs('#find-work').click(),
new: () => qs('#new-issue').click(), new: () => qs('#new-issue').click(),
search: () => qs('#open-palette').click(), search: () => qs('#open-palette').click(),
queues: () => refreshTomorrowQueueSummary(), queues: () => { refreshTomorrowQueueSummary(); mobileRecentWork.render(); },
}, },
observe(callback, overlays) { observe(callback, overlays) {
const observer = new MutationObserver(callback); const observer = new MutationObserver(callback);
@ -431,6 +432,19 @@
let planningOwnerLogin = ''; let planningOwnerLogin = '';
let planningOwnerAccountKey = ''; let planningOwnerAccountKey = '';
let activeFlushLogin = ''; let activeFlushLogin = '';
mobileRecentWork = createMobileRecentWork({
storage:localStorage,
getLogin:() => confirmedOwnerLogin,
document,
section:qs('#mobile-recent-work'),
list:qs('#mobile-recent-work-list'),
openRoute:fragment => {
const sheet = qs('#mobile-queue-sheet');
if (sheet.open) sheet.close();
if (window.location.hash !== fragment) window.history.pushState({ workRoute:fragment }, '', fragment);
workRoute.sync();
},
});
mobileQueuePriority = createMobileQueuePriority({ mobileQueuePriority = createMobileQueuePriority({
storage: localStorage, storage: localStorage,
getLogin: () => confirmedOwnerLogin, getLogin: () => confirmedOwnerLogin,
@ -2061,6 +2075,7 @@
qs('#my-work-action-status').textContent = ''; qs('#my-work-action-status').textContent = '';
closeOpenWorkSheets(); closeOpenWorkSheets();
await openRoutedWorkSection(item); await openRoutedWorkSection(item);
mobileRecentWork.record(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);
}, },

View File

@ -2183,6 +2183,10 @@
<p class="small muted" id="mobile-queue-next-heading">Start / Continue</p> <p class="small muted" id="mobile-queue-next-heading">Start / Continue</p>
<button id="mobile-queue-next-action" type="button">Find Work</button> <button id="mobile-queue-next-action" type="button">Find Work</button>
</section> </section>
<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>
<div class="mobile-queue-list" id="mobile-recent-work-list"></div>
</section>
<details class="mobile-queue-priority" id="mobile-queue-priority"> <details class="mobile-queue-priority" id="mobile-queue-priority">
<summary>Customize routine order</summary> <summary>Customize routine order</summary>
<p class="small muted">Delivery and Human Gates always stay first. Move the routine queues to match how you work.</p> <p class="small muted">Delivery and Human Gates always stay first. Move the routine queues to match how you work.</p>
@ -2416,6 +2420,7 @@
<script src="static/mobile-pull-refresh.js"></script> <script src="static/mobile-pull-refresh.js"></script>
<script src="static/mobile-first-task.js"></script> <script src="static/mobile-first-task.js"></script>
<script src="static/mobile-work-entry.js"></script> <script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-recent-work.js"></script>
<script src="static/mobile-queue-priority.js"></script> <script src="static/mobile-queue-priority.js"></script>
<script src="static/mobile-queue-launcher.js"></script> <script src="static/mobile-queue-launcher.js"></script>
<script src="static/mobile-delivery-recovery.js"></script> <script src="static/mobile-delivery-recovery.js"></script>

View File

@ -0,0 +1,102 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileRecentWork = factory;
})(typeof globalThis !== 'undefined' ? globalThis : this, function createMobileRecentWork(options) {
'use strict';
const storage = options.storage;
const getLogin = options.getLogin;
const limit = Math.max(1, Number(options.limit) || 5);
const prefix = 'stackchain.mobile-recent-work.v1.';
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
function login() {
return String(getLogin?.() || '').trim().toLowerCase();
}
function key() {
const owner = login();
return owner ? prefix + owner : '';
}
function normalize(item) {
const kind = String(item?.kind || '');
const number = Number(item?.number ?? item?.notification_id);
if (!kinds.has(kind) || !Number.isSafeInteger(number) || number < 1) return null;
let route = '';
let repository = '';
if (kind === 'update') {
route = '#/my-work/update/' + number;
} else {
repository = String(item?.repository || '');
if (!repositoryPattern.test(repository)) return null;
route = '#/my-work/' + kind + '/' + repository + '/' + number;
}
const title = String(item?.title || item?.subject?.title || '').trim().slice(0, 180);
if (!title) return null;
return {
kind,
...(repository ? { repository } : {}),
number,
title,
route,
};
}
function items() {
const storageKey = key();
if (!storageKey) return [];
try {
const parsed = JSON.parse(storage.getItem(storageKey) || '[]');
if (!Array.isArray(parsed)) return [];
return parsed.map(normalize).filter(Boolean).slice(0, limit);
} catch (_) {
return [];
}
}
function record(item) {
const storageKey = key();
const normalized = normalize(item);
if (!storageKey || !normalized) return false;
const next = [normalized, ...items().filter(existing => existing.route !== normalized.route)].slice(0, limit);
try {
storage.setItem(storageKey, JSON.stringify(next));
return true;
} catch (_) {
return false;
}
}
function render() {
const recent = items();
const list = options.list;
const section = options.section;
if (!list || !section || !options.document) return recent.length;
const rows = recent.map(item => {
const detail = item.kind === 'update'
? 'Update · #' + item.number
: item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
const button = options.document.createElement('button');
const copy = options.document.createElement('span');
const primary = options.document.createElement('strong');
const secondary = options.document.createElement('small');
primary.textContent = item.title;
secondary.textContent = detail;
copy.appendChild(primary);
copy.appendChild(secondary);
button.appendChild(copy);
button.setAttribute('type', 'button');
button.setAttribute('data-recent-work-route', item.route);
button.setAttribute('aria-label', 'Open ' + item.title + ', ' + detail.toLowerCase().replace(' · ', ' '));
button.addEventListener('click', () => options.openRoute?.(item.route));
return button;
});
list.replaceChildren(...rows);
section.hidden = rows.length === 0;
return rows.length;
}
return { items, record, render };
});

View File

@ -270,6 +270,7 @@ const SHELL = [
BASE + 'static/mobile-task-dock.js', BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-first-task.js', BASE + 'static/mobile-first-task.js',
BASE + 'static/mobile-work-entry.js', BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-recent-work.js',
BASE + 'static/mobile-queue-priority.js', BASE + 'static/mobile-queue-priority.js',
BASE + 'static/mobile-queue-launcher.js', BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/mobile-delivery-recovery.js', BASE + 'static/mobile-delivery-recovery.js',

View File

@ -44,7 +44,7 @@ FEATURE_SOURCES = {
), ),
"today-timer": ( "today-timer": (
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-search-modal.js", "static/mobile-composer-viewport.js", "static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-search-modal.js", "static/mobile-composer-viewport.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-priority.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js", "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-recent-work.js", "static/mobile-queue-priority.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.js", "static/unfiled-draft-sync.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",

View File

@ -0,0 +1,141 @@
import json
import subprocess
from pathlib import Path
RECENT_WORK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-recent-work.js"
INDEX = Path(__file__).resolve().parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).resolve().parents[1] / "frontend" / "dashboard.css"
def run_node(script: str) -> dict:
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_recent_work_is_account_scoped_deduplicated_and_bounded():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key, value) => values.set(key, value),
removeItem:key => values.delete(key),
}};
let login = ' Alice ';
const recent = createRecentWork({{storage, getLogin:() => login, limit:5}});
for (let number=1; number<=6; number += 1) {{
recent.record({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
}}
recent.record({{kind:'issue', repository:'stackchain/dashboard', number:3, title:'Issue 3 updated'}});
const alice = recent.items();
login = 'bob';
recent.record({{kind:'pull', repository:'stackchain/api', number:9, title:'Ship API'}});
const bob = recent.items();
login = '';
const anonymousRecord = recent.record({{kind:'issue', repository:'stackchain/dashboard', number:99, title:'Private'}});
const anonymous = recent.items();
process.stdout.write(JSON.stringify({{alice,bob,anonymousRecord,anonymous,keys:Array.from(values.keys()).sort()}}));
"""
payload = run_node(script)
assert [item["number"] for item in payload["alice"]] == [3, 6, 5, 4, 2]
assert payload["alice"][0] == {
"kind": "issue",
"repository": "stackchain/dashboard",
"number": 3,
"title": "Issue 3 updated",
"route": "#/my-work/issue/stackchain/dashboard/3",
}
assert payload["bob"] == [
{
"kind": "pull",
"repository": "stackchain/api",
"number": 9,
"title": "Ship API",
"route": "#/my-work/pull/stackchain/api/9",
}
]
assert payload["anonymousRecord"] is False
assert payload["anonymous"] == []
assert payload["keys"] == [
"stackchain.mobile-recent-work.v1.alice",
"stackchain.mobile-recent-work.v1.bob",
]
def test_recent_work_renders_safe_rows_and_opens_the_selected_route():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
const values = new Map([
['stackchain.mobile-recent-work.v1.alice', JSON.stringify([
{{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Fix mobile queue',route:'#/wrong'}},
{{kind:'update',number:42,title:'Review release status'}},
{{kind:'pull',repository:'bad/repo/extra',number:1,title:'Unsafe'}},
{{kind:'issue',repository:'stackchain/dashboard',number:0,title:'Invalid'}},
])],
]);
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?.();}},
}};
}}
const list=node('div'); const section=node('section'); const opened=[];
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice', document:{{createElement:node}}, list, section,
openRoute:route=>opened.push(route),
}});
const rendered=recent.render();
list.children[1].click();
process.stdout.write(JSON.stringify({{
rendered,hidden:section.hidden,rows:list.children.map(button=>({{
label:button.attributes['aria-label'],route:button.attributes['data-recent-work-route'],
primary:button.children[0].children[0].textContent,
secondary:button.children[0].children[1].textContent,
}})),opened,
}}));
"""
payload = run_node(script)
assert payload == {
"rendered": 2,
"hidden": False,
"rows": [
{
"label": "Open Fix mobile queue, issue stackchain/dashboard #7",
"route": "#/my-work/issue/stackchain/dashboard/7",
"primary": "Fix mobile queue",
"secondary": "Issue · stackchain/dashboard #7",
},
{
"label": "Open Review release status, update #42",
"route": "#/my-work/update/42",
"primary": "Review release status",
"secondary": "Update · #42",
},
],
"opened": ["#/my-work/update/42"],
}
def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
html = INDEX.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="mobile-recent-work"' in html
assert 'id="mobile-recent-work-list"' in html
assert '<script src="static/mobile-recent-work.js"></script>' in html
assert "createMobileRecentWork({" in dashboard
assert "mobileRecentWork.record(item)" in dashboard
assert "mobileRecentWork.render()" in dashboard
assert "workRoute.sync()" in dashboard
assert "[data-recent-work-route]" in css
assert "min-height:56px" in css

View File

@ -1527,6 +1527,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-task-dock.js", "/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-first-task.js", "/dashboard/static/mobile-first-task.js",
"/dashboard/static/mobile-work-entry.js", "/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-recent-work.js",
"/dashboard/static/mobile-queue-priority.js", "/dashboard/static/mobile-queue-priority.js",
"/dashboard/static/mobile-queue-launcher.js", "/dashboard/static/mobile-queue-launcher.js",
"/dashboard/static/mobile-delivery-recovery.js", "/dashboard/static/mobile-delivery-recovery.js",