diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 3a01191..ae2a5b0 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -587,7 +587,16 @@ textarea { resize: vertical; min-height: 120px; } .composer-keyboard-active .pull-comment-composer, .composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); } .mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); } - .mobile-task-dock[data-attention="true"] { grid-template-columns:repeat(6,minmax(0,1fr)); } + .mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; } + .mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); } + .mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); } + .mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; } + .mobile-queue-panel h2 { margin:0; } + .mobile-queue-list { display:grid; gap:8px; margin-top:12px; } + .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-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; } .mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); } .mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; } .mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 99beab5..800ff0a 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -55,11 +55,11 @@ qs('#my-work').scrollIntoView({block:'start'}); qs('#my-work').focus(); } - function openMobileAttention() { - if (workSession.checkpointed()) timer.beginAttention(); + function openMobileQueue(name) { + if (name === 'attention' && workSession.checkpointed()) timer.beginAttention(); renderAttentionInterruption(); timerView.render(); - qs('[data-work-filter="attention"]').click(); + qs('[data-work-filter="' + name + '"]').click(); qs('#my-work').scrollIntoView({block:'start'}); qs('#my-work').focus(); } @@ -79,15 +79,23 @@ sessionHud: qs('[data-mobile-today-hud]'), buttons: mobileTaskButtons, workLabel: qs('#mobile-work-label'), - attentionBadge: qs('#mobile-attention-count'), + queueSheet: qs('#mobile-queue-sheet'), + queueClose: qs('#close-mobile-queues'), + queueRows: Object.fromEntries( + Array.from(document.querySelectorAll('[data-mobile-queue]')).map(button => [button.dataset.mobileQueue, button]) + ), + queueCounts: Object.fromEntries( + Array.from(document.querySelectorAll('[data-mobile-queue-count]')).map(element => [element.dataset.mobileQueueCount, element]) + ), + queueBadge: qs('#mobile-queue-count'), + onSelectQueue: openMobileQueue, overlays: mobileTaskOverlays, actions: { work: () => mobileWorkEntry.open(), - attention: openMobileAttention, find: () => qs('#find-work').click(), new: () => qs('#new-issue').click(), search: () => qs('#open-palette').click(), - drafts: () => qs('[data-work-filter="draft"]').click(), + queues: () => {}, }, observe(callback, overlays) { const observer = new MutationObserver(callback); @@ -96,14 +104,6 @@ }, }); mobileTaskDock.start(); - const sourceDraftCount = qs('[data-work-count="draft"]'); - const draftCount = qs('#mobile-draft-count'); - function syncMobileDraftCount() { - draftCount.textContent = sourceDraftCount.textContent; - draftCount.setAttribute('aria-label', sourceDraftCount.textContent + ' drafts'); - } - new MutationObserver(syncMobileDraftCount).observe(sourceDraftCount, {childList:true, characterData:true, subtree:true}); - syncMobileDraftCount(); let liveMode = true; let offlineWorkMode = false; const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1'; @@ -1989,6 +1989,7 @@ const element = qs('[data-work-count="' + filter + '"]'); if (element) element.textContent = count; }); + mobileTaskDock.updateQueues(counts); mobileTaskDock.updateWork(mobileWorkEntry.mode()); mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention); const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]'); @@ -4082,7 +4083,7 @@ createIssueAttachmentController.clear(); closeCreateIssueSheet(true, false); qs('[data-work-filter="draft"]').click(); - mobileTaskDock.select('drafts'); + mobileTaskDock.select('queues'); refreshMyWorkView(); const savedCard = qs('[data-capture-id="' + CSS.escape(savedCapture.id) + '"]'); requestAnimationFrame(() => { diff --git a/frontend/draft-capacity-dialog.js b/frontend/draft-capacity-dialog.js index b17056a..6429bcc 100644 --- a/frontend/draft-capacity-dialog.js +++ b/frontend/draft-capacity-dialog.js @@ -22,7 +22,7 @@ function bindDraftCapacityDialog() { qs('#draft-capacity-sheet').hidden = true; closeCreateIssueSheet(true, false); qs('[data-work-filter="draft"]').click(); - mobileTaskDock.select('drafts'); + mobileTaskDock.select('queues'); refreshMyWorkView(); requestAnimationFrame(() => { const card = qs('[data-capture-id="' + CSS.escape(oldestId) + '"]'); @@ -52,7 +52,7 @@ function bindDraftCapacityDialog() { createIssueAttachmentController.clear(); closeCreateIssueSheet(true, false); qs('[data-work-filter="draft"]').click(); - mobileTaskDock.select('drafts'); + mobileTaskDock.select('queues'); refreshMyWorkView(); requestAnimationFrame(() => qs('[data-capture-id="' + CSS.escape(saved.id) + '"]')?.focus()); qs('#my-work-action-status').textContent = 'Oldest Draft replaced. New work saved to Drafts.'; diff --git a/frontend/index.html b/frontend/index.html index fb9669e..472ab28 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -838,13 +838,25 @@ + +
diff --git a/frontend/mobile-task-dock.js b/frontend/mobile-task-dock.js index 91fcf83..12f4a50 100644 --- a/frontend/mobile-task-dock.js +++ b/frontend/mobile-task-dock.js @@ -18,7 +18,7 @@ } function refreshVisibility() { - const hidden = overlays.some(overlay => overlay.classList.contains('open')); + const hidden = overlays.some(overlay => overlay.open || overlay.classList.contains('open')); nav.hidden = hidden; if (options.sessionHud) { if (hidden) options.sessionHud.setAttribute('data-overlay-hidden', 'true'); @@ -28,14 +28,38 @@ wasHidden = hidden; } + function openQueues() { + if (!options.queueSheet || options.queueSheet.open) return; + options.queueSheet.showModal(); + } + + function closeQueues() { + if (options.queueSheet?.open) options.queueSheet.close(); + } + function start() { Object.entries(buttons).forEach(([name, button]) => { button.addEventListener('click', event => { launcher = event.currentTarget; select(name); - options.actions[name](); + if (name === 'queues' && options.queueSheet) openQueues(); + else options.actions[name](); }); }); + if (options.queueSheet) { + options.queueClose?.addEventListener('click', closeQueues); + options.queueSheet.addEventListener('cancel', event => { + event.preventDefault(); + closeQueues(); + }); + options.queueSheet.addEventListener('close', () => buttons.queues?.focus()); + Object.entries(options.queueRows || {}).forEach(([name, row]) => { + row.addEventListener('click', () => { + closeQueues(); + options.onSelectQueue?.(name); + }); + }); + } options.observe(refreshVisibility, overlays); refreshVisibility(); } @@ -70,5 +94,19 @@ else nav.removeAttribute('data-attention'); } - return {start, select, refreshVisibility, updateAttention, updateWork}; + function updateQueues(counts) { + const names = ['today', 'attention', 'later', 'draft']; + const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)])); + const total = names.reduce((sum, name) => sum + normalized[name], 0); + Object.entries(options.queueCounts || {}).forEach(([name, element]) => { + element.textContent = String(normalized[name] || 0); + }); + if (options.queueBadge) { + options.queueBadge.textContent = String(total); + options.queueBadge.hidden = total === 0; + } + if (buttons.queues) buttons.queues.setAttribute('aria-label', 'Queues, ' + total + ' items'); + } + + return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork}; }); diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py index 0c28285..38283d7 100644 --- a/tests/test_mobile_task_dock.py +++ b/tests/test_mobile_task_dock.py @@ -274,6 +274,82 @@ process.stdout.write(JSON.stringify({{ } +def test_mobile_task_dock_opens_stable_queue_switcher_and_routes_each_queue(): + script = f""" +const createDock = require({json.dumps(str(DOCK))}); +class FakeElement {{ + constructor() {{ this.listeners = {{}}; this.attributes = {{}}; this.hidden = false; this.textContent = ''; this.focuses = 0; this.classList = {{contains:()=>false}}; }} + addEventListener(name, callback) {{ this.listeners[name] = callback; }} + click() {{ return this.listeners.click?.({{currentTarget:this}}); }} + setAttribute(name, value) {{ this.attributes[name] = value; }} + removeAttribute(name) {{ delete this.attributes[name]; }} + focus() {{ this.focuses += 1; }} +}} +const nav = new FakeElement(); +const queues = new FakeElement(); +const sheet = new FakeElement(); sheet.open = false; +sheet.showModal = function () {{ this.open = true; }}; +sheet.close = function () {{ this.open = false; this.listeners.close?.(); }}; +const close = new FakeElement(); +const badge = new FakeElement(); +const rows = Object.fromEntries(['today','attention','later','draft'].map(name => [name, new FakeElement()])); +const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()])); +const selected = []; +const dock = createDock({{ + nav, buttons:{{queues}}, actions:{{queues:()=>{{}}}}, overlays:[sheet], + queueSheet:sheet, queueClose:close, queueRows:rows, queueCounts:counts, queueBadge:badge, + onSelectQueue:name=>selected.push(name), + observe() {{}}, +}}); +dock.start(); +dock.updateQueues({{today:2, attention:1, later:3, draft:4}}); +queues.click(); +const opened = sheet.open; +rows.later.click(); +const closedAfterSelect = !sheet.open; +queues.click(); close.click(); +process.stdout.write(JSON.stringify({{ + opened, closedAfterSelect, selected, + badge:badge.textContent, badgeLabel:queues.attributes['aria-label'], + counts:Object.fromEntries(Object.entries(counts).map(([name, node]) => [name, node.textContent])), + queueFocuses:queues.focuses, + columnState:nav.attributes['data-attention'] || null, +}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "opened": True, + "closedAfterSelect": True, + "selected": ["later"], + "badge": "10", + "badgeLabel": "Queues, 10 items", + "counts": {"today": "2", "attention": "1", "later": "3", "draft": "4"}, + "queueFocuses": 2, + "columnState": None, + } + + +@pytest.mark.anyio +async def test_dashboard_renders_and_wires_mobile_queue_switcher(): + html = await dashboard() + + assert 'data-mobile-task="queues"' in html + assert 'id="mobile-queue-sheet"' in html + assert 'aria-labelledby="mobile-queue-heading"' in html + assert 'data-mobile-queue="today"' in html + assert 'data-mobile-queue="attention"' in html + assert 'data-mobile-queue="later"' in html + assert 'data-mobile-queue="draft"' in html + assert 'id="mobile-queue-count"' in html + assert 'mobileTaskDock.updateQueues(counts)' in html + assert "onSelectQueue: openMobileQueue" in html + assert '.mobile-queue-sheet' in html + assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html + assert '.mobile-task-dock[data-attention="true"]' not in html + + @pytest.mark.anyio async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle(): html = await dashboard() @@ -301,27 +377,25 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock(): html = await dashboard() assert '