Merge pull request 'Show priority work above the fold on mobile launch' (#300) from timmy/299-mobile-above-fold into main
This commit is contained in:
commit
de8227f4f4
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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 & 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
32
frontend/mobile-launch.js
Normal 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 };
|
||||
});
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
72
tests/test_mobile_launch.py
Normal file
72
tests/test_mobile_launch.py
Normal 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
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user