feat: show priority work above fold on mobile (#299)
All checks were successful
CI / lint (pull_request) Successful in 31s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy 2026-08-08 12:18:24 +00:00
parent 207df76b69
commit ccf5f69400
7 changed files with 208 additions and 43 deletions

View File

@ -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); }

View File

@ -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 = '<option value="all">All milestones</option>' +
@ -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();
});

View File

@ -11,15 +11,18 @@
<body>
<canvas id="bg"></canvas>
<header>
<div><span class="obi"></span> <strong style="margin-left:6px;">Stackchain</strong> <span class="muted">Dashboard</span></div>
<div class="toolbar">
<div class="status"><span class="dot"></span><span class="small" id="status">Live</span></div>
<button id="refresh">Refresh</button>
<button id="sign-out" type="button">Sign out &amp; clear this device</button>
<button id="sign-out-all" type="button">Sign out all devices</button>
<button id="open-palette">Command</button>
<span class="small muted" id="clock"></span>
</div>
<div class="app-brand"><span class="obi"></span><strong>Stackchain</strong><span class="muted">Dashboard</span></div>
<div class="app-live-status"><span class="dot"></span><span class="small" id="status">Live</span></div>
<details class="app-menu">
<summary id="app-menu-toggle" aria-label="Open dashboard menu">Menu</summary>
<div class="app-menu-panel">
<button id="refresh">Refresh</button>
<button id="open-palette">Command</button>
<button id="sign-out" type="button">Sign out &amp; clear this device</button>
<button id="sign-out-all" type="button">Sign out all devices</button>
<span class="small muted" id="clock"></span>
</div>
</details>
</header>
<div class="offline-status" id="offline-status" role="status" aria-live="polite" hidden>
Offline · live Gitea data is unavailable. Saved drafts remain available on this device.
@ -37,37 +40,42 @@
<button class="find-work-action" id="find-work" type="button">Find work</button>
<button class="new-issue" id="new-issue" type="button">New issue</button>
</div>
<div class="work-filters" aria-label="Filter My Work">
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
<button class="work-filter" data-work-filter="today" aria-pressed="false">Today <span data-work-count="today">0</span></button>
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
<button class="work-filter" data-work-filter="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
<button class="work-filter" data-work-filter="later" aria-pressed="false">Later <span data-work-count="later">0</span></button>
<button class="work-filter" data-work-filter="draft" aria-pressed="false">Drafts <span data-work-count="draft">0</span></button>
</div>
<label class="milestone-lane" for="work-milestone-filter"><span class="small">Release lane</span>
<select class="work-milestone-filter" id="work-milestone-filter">
<option value="all">All milestones</option>
<option value="unplanned">Unplanned</option>
</select>
</label>
<div class="offline-work-controls">
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
<button id="clear-offline-work" type="button">Clear offline work data</button>
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
<span class="small" id="delivery-receipt-status" role="status" aria-live="polite"></span>
<div class="install-app-card" id="install-app-card" hidden>
<p><strong>Install Stackchain</strong><br><span class="small">Keep mobile work one tap away and launch the saved app shell during an outage.</span></p>
<p class="small" id="install-app-guidance" hidden>On Safari, tap Share, then choose <strong>Add to Home Screen</strong>.</p>
<button id="install-app" type="button">Install Stackchain</button>
<button id="dismiss-install-app" type="button">Not now</button>
<details class="work-settings">
<summary id="work-settings-toggle">Queue &amp; settings · <span id="active-work-queue">All</span></summary>
<div class="work-settings-panel">
<div class="work-filters" aria-label="Filter My Work">
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
<button class="work-filter" data-work-filter="today" aria-pressed="false">Today <span data-work-count="today">0</span></button>
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
<button class="work-filter" data-work-filter="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
<button class="work-filter" data-work-filter="later" aria-pressed="false">Later <span data-work-count="later">0</span></button>
<button class="work-filter" data-work-filter="draft" aria-pressed="false">Drafts <span data-work-count="draft">0</span></button>
</div>
<label class="milestone-lane" for="work-milestone-filter"><span class="small">Release lane</span>
<select class="work-milestone-filter" id="work-milestone-filter">
<option value="all">All milestones</option>
<option value="unplanned">Unplanned</option>
</select>
</label>
<div class="offline-work-controls">
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
<button id="clear-offline-work" type="button">Clear offline work data</button>
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
<span class="small" id="delivery-receipt-status" role="status" aria-live="polite"></span>
<div class="install-app-card" id="install-app-card" hidden>
<p><strong>Install Stackchain</strong><br><span class="small">Keep mobile work one tap away and launch the saved app shell during an outage.</span></p>
<p class="small" id="install-app-guidance" hidden>On Safari, tap Share, then choose <strong>Add to Home Screen</strong>.</p>
<button id="install-app" type="button">Install Stackchain</button>
<button id="dismiss-install-app" type="button">Not now</button>
</div>
<span class="small" id="install-app-status" role="status" aria-live="polite"></span>
</div>
</div>
<span class="small" id="install-app-status" role="status" aria-live="polite"></span>
</div>
</details>
</div>
<div class="my-work-list" id="my-work-list"></div>
<div class="small" id="work-page-status" aria-live="polite"></div>
@ -516,6 +524,7 @@
<script src="static/work-route.js"></script>
<script src="static/context-poller.js"></script>
<script src="static/mobile-task-dock.js"></script>
<script src="static/mobile-launch.js"></script>
<script src="static/install-app.js"></script>
<script src="static/dashboard.js"></script>
</body>

32
frontend/mobile-launch.js Normal file
View File

@ -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 };
});

View File

@ -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',
];

View File

@ -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 '<script src="static/mobile-launch.js"></script>' 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

View File

@ -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",
}