diff --git a/frontend/dashboard.css b/frontend/dashboard.css index ce0d300..bbaf595 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -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 > span:first-child { display:grid; gap:2px; } .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="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; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 5864460..9959cce 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -115,6 +115,7 @@ let offlineWorkMode = false; let renderMobileQueuePresentation = () => {}; let mobileQueuePriority = null; + let mobileRecentWork = { record:() => false, render:() => 0 }; const followingQueue = attachFollowing(item => { searchPreviewReturnKind = 'following'; return searchPreview.open(item); @@ -313,7 +314,7 @@ find: () => qs('#find-work').click(), new: () => qs('#new-issue').click(), search: () => qs('#open-palette').click(), - queues: () => refreshTomorrowQueueSummary(), + queues: () => { refreshTomorrowQueueSummary(); mobileRecentWork.render(); }, }, observe(callback, overlays) { const observer = new MutationObserver(callback); @@ -431,6 +432,19 @@ let planningOwnerLogin = ''; let planningOwnerAccountKey = ''; 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({ storage: localStorage, getLogin: () => confirmedOwnerLogin, @@ -2061,6 +2075,7 @@ qs('#my-work-action-status').textContent = ''; closeOpenWorkSheets(); await openRoutedWorkSection(item); + mobileRecentWork.record(item); const route = createWorkRoute.parse(window.location.hash); if (route?.section === item.section) navigateWorkSection(item.kind, item.section); }, diff --git a/frontend/index.html b/frontend/index.html index fd3396c..f320300 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2183,6 +2183,10 @@
Start / Continue
+Delivery and Human Gates always stay first. Move the routine queues to match how you work.
@@ -2416,6 +2420,7 @@ + diff --git a/frontend/mobile-recent-work.js b/frontend/mobile-recent-work.js new file mode 100644 index 0000000..dc5c7c1 --- /dev/null +++ b/frontend/mobile-recent-work.js @@ -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 }; +}); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 9745a8e..ce49373 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -270,6 +270,7 @@ const SHELL = [ BASE + 'static/mobile-task-dock.js', BASE + 'static/mobile-first-task.js', BASE + 'static/mobile-work-entry.js', + BASE + 'static/mobile-recent-work.js', BASE + 'static/mobile-queue-priority.js', BASE + 'static/mobile-queue-launcher.js', BASE + 'static/mobile-delivery-recovery.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index c9ea018..b53c120 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -44,7 +44,7 @@ FEATURE_SOURCES = { ), "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/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/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", diff --git a/tests/test_mobile_recent_work.py b/tests/test_mobile_recent_work.py new file mode 100644 index 0000000..f33ac2c --- /dev/null +++ b/tests/test_mobile_recent_work.py @@ -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 '' 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 diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 4229b9b..5356432 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -1527,6 +1527,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/mobile-task-dock.js", "/dashboard/static/mobile-first-task.js", "/dashboard/static/mobile-work-entry.js", + "/dashboard/static/mobile-recent-work.js", "/dashboard/static/mobile-queue-priority.js", "/dashboard/static/mobile-queue-launcher.js", "/dashboard/static/mobile-delivery-recovery.js",