From ccf5f69400dd152b779d26d1147b9b88bcfebe62 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 12:18:24 +0000 Subject: [PATCH] feat: show priority work above fold on mobile (#299) --- frontend/dashboard.css | 24 +++++++++- frontend/dashboard.js | 30 ++++++++++++- frontend/index.html | 87 ++++++++++++++++++++---------------- frontend/mobile-launch.js | 32 +++++++++++++ frontend/service-worker.js | 3 +- tests/test_mobile_launch.py | 72 +++++++++++++++++++++++++++++ tests/test_service_worker.py | 3 +- 7 files changed, 208 insertions(+), 43 deletions(-) create mode 100644 frontend/mobile-launch.js create mode 100644 tests/test_mobile_launch.py diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 675bf52..a6ac3ca 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -4,6 +4,11 @@ html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); #bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: .35; } header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; } .toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; } +.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; } +.app-live-status { display:inline-flex; gap:6px; align-items:center; } +.app-menu { margin-left:auto; } +.app-menu > summary { display:none; } +.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; } button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; } #sign-out-all { min-height:44px; } button:hover { filter: brightness(1.15); } @@ -63,6 +68,9 @@ textarea { resize: vertical; min-height: 120px; } .event:last-child { border-bottom: 0; } .my-work { grid-column: 1 / -1; } .my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; } +.work-settings { width:100%; } +.work-settings > summary { display:none; } +.work-settings-panel { display:flex; align-items:center; gap:10px; flex-wrap:wrap; } .work-filters { display:flex; gap:8px; flex-wrap:wrap; } .work-filter { min-height: 44px; } .work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; } @@ -268,8 +276,22 @@ textarea { resize: vertical; min-height: 120px; } .mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; } @media (max-width: 600px) { body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); } - header { align-items:flex-start; } + header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); } + .app-brand .muted, #clock { display:none; } + .app-live-status { margin-left:auto; } + .app-menu { position:relative; margin-left:0; } + .app-menu > summary { min-width:44px; min-height:44px; display:grid; place-items:center; cursor:pointer; border:1px solid #2a496e; border-radius:10px; list-style:none; } + .app-menu > summary::-webkit-details-marker { display:none; } + .app-menu:not([open]) > .app-menu-panel { display:none; } + .app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); } + .app-menu-panel button { min-height:44px; width:100%; } .my-work { margin:0; } + .my-work-header { align-items:flex-start; } + .my-work-actions { width:100%; flex-wrap:nowrap; } + .my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; } + .work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; } + .work-settings:not([open]) > .work-settings-panel { display:none; } + .work-settings-panel { display:grid; gap:10px; margin-top:8px; } .my-work-list { grid-template-columns:1fr; } .work-filters { width:100%; } .work-filter { flex:1 1 calc(50% - 8px); } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index e277611..a0fd30c 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1,6 +1,12 @@ (function(){ const qs = (s, el=document) => el.querySelector(s); const fmt = (d) => new Date(d).toLocaleString(); + [ + [qs('.app-menu'), qs('#app-menu-toggle')], + [qs('.work-settings'), qs('#work-settings-toggle')], + ].forEach(([disclosure, launcher]) => { + mobileLaunch.createDisclosure({disclosure, launcher}).start(); + }); const PANEL_STATE_KEY = "stackchain.panel-state.v1"; const panels = Array.from(document.querySelectorAll('details[data-panel-key]')); let savedState = {}; @@ -66,9 +72,15 @@ const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1'; let selectedWorkFilter = 'all'; let selectedWorkMilestone = 'all'; + let savedWorkFilter = null; + let launchFilterResolved = false; try { const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY); - if (['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter; + if (['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) { + selectedWorkFilter = savedFilter; + savedWorkFilter = savedFilter; + launchFilterResolved = true; + } const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY); if (savedMilestone) selectedWorkMilestone = savedMilestone; } catch (e) { @@ -621,11 +633,23 @@ counts.today = todayMyWork.length; counts.later = laterMyWork.length; counts.draft = lastDrafts.length; + if (!launchFilterResolved) { + selectedWorkFilter = mobileLaunch.chooseFilter({ + saved: savedWorkFilter, today: counts.today, attention: counts.attention, + }); + launchFilterResolved = true; + document.querySelectorAll('[data-work-filter]').forEach(item => + item.setAttribute('aria-pressed', String(item.dataset.workFilter === selectedWorkFilter)) + ); + } Object.entries(counts).forEach(([filter, count]) => { const element = qs('[data-work-count="' + filter + '"]'); if (element) element.textContent = count; }); mobileTaskDock.updateAttention(counts.attention); + const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]'); + qs('#active-work-queue').textContent = activeQueue.firstChild.textContent.trim() + + ' (' + (counts[selectedWorkFilter] || 0) + ')'; const milestoneSelect = qs('#work-milestone-filter'); const lanes = milestoneLanes(lastMyWork); milestoneSelect.innerHTML = '' + @@ -2904,6 +2928,8 @@ button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter)); button.addEventListener('click', () => { selectedWorkFilter = button.dataset.workFilter; + savedWorkFilter = selectedWorkFilter; + launchFilterResolved = true; try { sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter); } catch (e) { @@ -2912,6 +2938,8 @@ document.querySelectorAll('[data-work-filter]').forEach(item => item.setAttribute('aria-pressed', String(item === button)) ); + const selectedCount = button.querySelector('[data-work-count]').textContent; + qs('#active-work-queue').textContent = button.firstChild.textContent.trim() + ' (' + selectedCount + ')'; renderMyWork(); updateWorkPaginationControls(); }); diff --git a/frontend/index.html b/frontend/index.html index b1577d4..cfb81fa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -11,15 +11,18 @@
-
Stackchain Dashboard
-
-
Live
- - - - - -
+
StackchainDashboard
+
Live
+
+ Menu +
+ + + + + +
+
-
- - - - - - - - - -
- -
- - - - - - +
@@ -516,6 +524,7 @@ + diff --git a/frontend/mobile-launch.js b/frontend/mobile-launch.js new file mode 100644 index 0000000..482e6d0 --- /dev/null +++ b/frontend/mobile-launch.js @@ -0,0 +1,32 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + else root.mobileLaunch = api; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + const allowedFilters = new Set([ + 'all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft', + ]); + + function chooseFilter({ saved, today = 0, attention = 0 } = {}) { + if (allowedFilters.has(saved)) return saved; + if (today > 0) return 'today'; + if (attention > 0) return 'attention'; + return 'all'; + } + + function createDisclosure({ disclosure, launcher }) { + function onKeydown(event) { + if (event.key !== 'Escape' || !disclosure.open) return; + event.preventDefault(); + disclosure.open = false; + launcher.focus(); + } + return { + start() { + disclosure.addEventListener('keydown', onKeydown); + }, + }; + } + + return { chooseFilter, createDisclosure }; +}); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index b74c59d..7bd8d25 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v27'; +const CACHE = 'stackchain-dashboard-shell-v28'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, @@ -32,6 +32,7 @@ const SHELL = [ BASE + 'static/work-route.js', BASE + 'static/context-poller.js', BASE + 'static/mobile-task-dock.js', + BASE + 'static/mobile-launch.js', BASE + 'static/install-app.js', BASE + 'static/background-issue-sync.js', ]; diff --git a/tests/test_mobile_launch.py b/tests/test_mobile_launch.py new file mode 100644 index 0000000..1a2a7b0 --- /dev/null +++ b/tests/test_mobile_launch.py @@ -0,0 +1,72 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from tests.dashboard_bundle import dashboard + + +MOBILE_LAUNCH = Path(__file__).resolve().parents[1] / "frontend" / "mobile-launch.js" + + +def test_mobile_launch_selects_priority_queue_without_overriding_saved_choice(): + script = f""" +const mobileLaunch = require({json.dumps(str(MOBILE_LAUNCH))}); +process.stdout.write(JSON.stringify({{ + today: mobileLaunch.chooseFilter({{saved:null, today:2, attention:4}}), + attention: mobileLaunch.chooseFilter({{saved:null, today:0, attention:4}}), + all: mobileLaunch.chooseFilter({{saved:null, today:0, attention:0}}), + saved: mobileLaunch.chooseFilter({{saved:'review', today:2, attention:4}}), +}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "today": "today", + "attention": "attention", + "all": "all", + "saved": "review", + } + + +def test_mobile_disclosure_closes_on_escape_and_restores_launcher_focus(): + script = f""" +const mobileLaunch = require({json.dumps(str(MOBILE_LAUNCH))}); +const listeners = {{}}; +const disclosure = {{open:true, addEventListener(name, fn) {{ listeners[name] = fn; }}}}; +const launcher = {{focuses:0, focus() {{ this.focuses += 1; }}}}; +const controller = mobileLaunch.createDisclosure({{disclosure, launcher}}); +controller.start(); +let prevented = false; +listeners.keydown({{key:'Escape', preventDefault() {{ prevented = true; }}}}); +process.stdout.write(JSON.stringify({{open:disclosure.open, focuses:launcher.focuses, prevented}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == {"open": False, "focuses": 1, "prevented": True} + + +@pytest.mark.anyio +async def test_mobile_launch_progressively_discloses_secondary_controls(): + html = await dashboard() + + assert 'class="app-brand"' in html + assert 'class="app-live-status"' in html + assert 'class="app-menu"' in html + assert 'id="app-menu-toggle"' in html + assert 'class="work-settings"' in html + assert 'id="work-settings-toggle"' in html + assert 'id="active-work-queue"' in html + assert '' in html + service_worker = (Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js").read_text() + assert "BASE + 'static/mobile-launch.js'" in service_worker + assert "mobileLaunch.chooseFilter" in html + assert "mobileLaunch.createDisclosure" in html + assert "@media (max-width: 600px)" in html + assert "header { min-height:56px; max-height:64px;" in html + assert ".work-settings:not([open]) > .work-settings-panel { display:none; }" in html + assert ".app-menu:not([open]) > .app-menu-panel { display:none; }" in html + assert ".my-work-actions button { min-height:44px;" in html diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 7b85db9..940a861 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -95,7 +95,7 @@ async function dispatchNotificationClick(route) {{ def test_strict_browser_assets_ship_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v27" in source + assert "stackchain-dashboard-shell-v28" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -234,6 +234,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/work-route.js", "/dashboard/static/context-poller.js", "/dashboard/static/mobile-task-dock.js", + "/dashboard/static/mobile-launch.js", "/dashboard/static/install-app.js", "/dashboard/static/background-issue-sync.js", }