Make mobile Work and Queues usable before optional hydration #1428
|
|
@ -1521,6 +1521,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.mobile-queue-all summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
|
||||
.mobile-queue-list [data-unavailable="true"] { border-color:#f59e0b; }
|
||||
.mobile-queue-list [data-unavailable="true"]::after { content:'Sync unavailable · open to retry'; color:#fbbf24; font-size:.75rem; }
|
||||
.mobile-queue-list [data-progressive-loading="true"]::after { content:'Still loading · available after workspace starts'; }
|
||||
.mobile-delivery-recovery { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.mobile-delivery-recovery::backdrop { background:rgba(3,9,18,.78); }
|
||||
.mobile-delivery-recovery-panel { box-sizing:border-box; display:grid; gap:12px; width:100%; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.();
|
||||
const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.();
|
||||
const progressiveHumanGatesHandoff = window.stackchainProgressiveHumanGates?.handoff?.();
|
||||
const progressiveMobileDockHandoff = window.stackchainProgressiveMobileDock?.handoff?.();
|
||||
window.stackchainProgressiveMobileDock?.stop?.();
|
||||
window.stackchainProgressiveMyWork?.stop();
|
||||
const qs = (s, el=document) => el.querySelector(s);
|
||||
const announceWork = message => qs('#my-work-action-status').textContent = message;
|
||||
|
|
@ -307,6 +309,12 @@
|
|||
},
|
||||
});
|
||||
mobileTaskDock.start();
|
||||
if (['work', 'queues'].includes(progressiveMobileDockHandoff?.lastTask)) {
|
||||
mobileTaskDock.select(progressiveMobileDockHandoff.lastTask);
|
||||
}
|
||||
if (progressiveMobileDockHandoff?.queueSheetOpen && !qs('#mobile-queue-sheet').open) {
|
||||
qs('#mobile-queue-sheet').showModal();
|
||||
}
|
||||
const mobileTodayActions = qs('#mobile-today-actions');
|
||||
createMobileTodayCommandBar({
|
||||
more:qs('[data-mobile-today-more]'),
|
||||
|
|
|
|||
|
|
@ -2325,6 +2325,7 @@
|
|||
<script src="static/my-work.js"></script>
|
||||
<script src="static/progressive-live-snapshot.js"></script>
|
||||
<script src="static/progressive-my-work.js"></script>
|
||||
<script src="static/progressive-mobile-dock.js"></script>
|
||||
<script src="static/progressive-capture.js"></script>
|
||||
<script src="static/agenda-replan.js"></script>
|
||||
<script src="static/agenda-calendar.js"></script>
|
||||
|
|
|
|||
119
frontend/progressive-mobile-dock.js
Normal file
119
frontend/progressive-mobile-dock.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createProgressiveMobileDock = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createProgressiveMobileDock(options) {
|
||||
const document = options.document;
|
||||
const myWork = options.myWork;
|
||||
const work = document.querySelector('[data-mobile-task="work"]');
|
||||
const queues = document.querySelector('[data-mobile-task="queues"]');
|
||||
const sheet = document.querySelector('#mobile-queue-sheet');
|
||||
const close = document.querySelector('#close-mobile-queues');
|
||||
const next = document.querySelector('#mobile-queue-next-action');
|
||||
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
|
||||
.map(row => [row.dataset.mobileQueue, row]));
|
||||
const countElements = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue-count]'))
|
||||
.map(element => [element.dataset.mobileQueueCount, element]));
|
||||
const originalDescriptions = Object.fromEntries(Object.entries(rows)
|
||||
.map(([name, row]) => [name, row.querySelector?.('small')?.textContent || '']));
|
||||
const originalCounts = Object.fromEntries(Object.entries(countElements)
|
||||
.map(([name, element]) => [name, element.textContent]));
|
||||
const supported = new Set(['all', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update']);
|
||||
const listeners = [];
|
||||
let started = false;
|
||||
let lastTask = null;
|
||||
let unsubscribe = null;
|
||||
|
||||
function listen(element, name, listener) {
|
||||
if (!element) return;
|
||||
element.addEventListener(name, listener);
|
||||
listeners.push([element, name, listener]);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const counts = myWork?.counts?.() || {};
|
||||
Object.entries(rows).forEach(([name, row]) => {
|
||||
const count = Math.max(0, Number(counts[name]) || 0);
|
||||
const countElement = countElements[name];
|
||||
if (supported.has(name)) {
|
||||
row.removeAttribute('data-unavailable');
|
||||
row.removeAttribute('data-progressive-loading');
|
||||
if (countElement) countElement.textContent = String(count);
|
||||
return;
|
||||
}
|
||||
row.setAttribute('data-unavailable', 'true');
|
||||
row.setAttribute('data-progressive-loading', 'true');
|
||||
const description = row.querySelector?.('small');
|
||||
if (description) description.textContent = 'Still loading';
|
||||
if (countElement) countElement.textContent = '…';
|
||||
});
|
||||
const actionable = ['attention', 'filed', 'authored', 'review', 'update']
|
||||
.find(name => Number(counts[name]) > 0);
|
||||
if (next) {
|
||||
if (actionable) {
|
||||
next.textContent = 'Open ' + (actionable === 'authored' ? 'My PRs' : actionable[0].toUpperCase() + actionable.slice(1))
|
||||
+ ' (' + counts[actionable] + ')';
|
||||
next.dataset.queue = actionable;
|
||||
next.removeAttribute('data-unavailable');
|
||||
} else {
|
||||
next.textContent = Number(counts.all) > 0 ? 'Open assigned work (' + counts.all + ')' : 'Assigned work is still loading';
|
||||
next.dataset.queue = 'all';
|
||||
if (!Number(counts.all)) next.setAttribute('data-unavailable', 'true');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openSheet() {
|
||||
lastTask = 'queues';
|
||||
render();
|
||||
if (sheet && !sheet.open) sheet.showModal();
|
||||
}
|
||||
|
||||
function openQueue(name) {
|
||||
if (!supported.has(name)) return false;
|
||||
lastTask = 'work';
|
||||
if (sheet?.open) sheet.close();
|
||||
myWork?.selectQueue?.(name, {openFirst:true});
|
||||
return true;
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return false;
|
||||
started = true;
|
||||
listen(work, 'click', () => { lastTask = 'work'; myWork?.openFirst?.(); });
|
||||
listen(queues, 'click', openSheet);
|
||||
listen(close, 'click', () => sheet?.open && sheet.close());
|
||||
Object.entries(rows).forEach(([name, row]) => listen(row, 'click', () => openQueue(name)));
|
||||
listen(next, 'click', () => openQueue(next.dataset.queue || 'all'));
|
||||
unsubscribe = myWork?.subscribe?.(render) || null;
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function handoff() {
|
||||
return {queueSheetOpen:Boolean(sheet?.open), lastTask};
|
||||
}
|
||||
|
||||
function stop() {
|
||||
listeners.splice(0).forEach(([element, name, listener]) => element.removeEventListener(name, listener));
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
Object.entries(rows).forEach(([name, row]) => {
|
||||
row.removeAttribute('data-unavailable');
|
||||
row.removeAttribute('data-progressive-loading');
|
||||
const description = row.querySelector?.('small');
|
||||
if (description) description.textContent = originalDescriptions[name];
|
||||
if (countElements[name]) countElements[name].textContent = originalCounts[name];
|
||||
});
|
||||
started = false;
|
||||
}
|
||||
|
||||
return {start, stop, handoff, render, openQueue};
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
window.stackchainProgressiveMobileDock = createProgressiveMobileDock({
|
||||
document,
|
||||
myWork:window.stackchainProgressiveMyWork,
|
||||
});
|
||||
window.stackchainProgressiveMobileDock.start();
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ function createProgressiveMyWork({
|
|||
const filters = Array.from(document.querySelectorAll('[data-work-filter]'));
|
||||
const listeners = [];
|
||||
const lifecycleListeners = [];
|
||||
const subscribers = new Set();
|
||||
let items = [];
|
||||
let active = 'all';
|
||||
let stopped = false;
|
||||
|
|
@ -47,6 +48,15 @@ function createProgressiveMyWork({
|
|||
filter === 'authored' ? item.is_authored :
|
||||
filter === 'pull' ? item.kind === 'pull' && !item.is_review : item.kind === filter;
|
||||
const visibleItems = () => active === 'all' ? items : items.filter(item => matches(item, active));
|
||||
const notify = () => subscribers.forEach(subscriber => subscriber());
|
||||
const queueCounts = () => ({
|
||||
all:items.length,
|
||||
filed:items.filter(item => matches(item, 'filed')).length,
|
||||
authored:items.filter(item => matches(item, 'authored')).length,
|
||||
attention:items.filter(item => matches(item, 'attention')).length,
|
||||
update:items.filter(item => matches(item, 'update')).length,
|
||||
review:items.filter(item => matches(item, 'review')).length,
|
||||
});
|
||||
const closeProgressiveDetail = ({ restoreFocus = true } = {}) => {
|
||||
if (!detail || detail.hidden) return;
|
||||
detail.hidden = true;
|
||||
|
|
@ -124,6 +134,7 @@ function createProgressiveMyWork({
|
|||
items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] });
|
||||
updateCounts();
|
||||
render();
|
||||
notify();
|
||||
const assigned = items.filter(item => item.is_assigned).length;
|
||||
if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.';
|
||||
return true;
|
||||
|
|
@ -150,6 +161,27 @@ function createProgressiveMyWork({
|
|||
|
||||
return {
|
||||
login() { return confirmedLogin; },
|
||||
counts() { return queueCounts(); },
|
||||
subscribe(subscriber) {
|
||||
subscribers.add(subscriber);
|
||||
return () => subscribers.delete(subscriber);
|
||||
},
|
||||
openFirst() {
|
||||
const item = visibleItems()[0];
|
||||
if (!item) return deferredQueues[active] ? 'loading' : 'empty';
|
||||
const trigger = list?.querySelector?.('[data-progressive-work-index="0"]') || null;
|
||||
openProgressiveDetail(item, trigger);
|
||||
return 'opened';
|
||||
},
|
||||
selectQueue(name, {openFirst = false} = {}) {
|
||||
const allowed = new Set(['all','attention','filed','authored','issue','pull','review','update','today','agenda','later','draft']);
|
||||
if (!allowed.has(name)) return 'unsupported';
|
||||
active = name;
|
||||
selectedByUser = true;
|
||||
render();
|
||||
notify();
|
||||
return openFirst ? this.openFirst() : 'selected';
|
||||
},
|
||||
handoff() {
|
||||
const state = { selectedFilter:selectedByUser ? active : null };
|
||||
if (openWork) {
|
||||
|
|
@ -204,6 +236,7 @@ function createProgressiveMyWork({
|
|||
lifecycleTarget.removeEventListener?.('keydown', keyListener);
|
||||
lifecycleListeners.forEach(([eventName, listener]) =>
|
||||
lifecycleTarget.removeEventListener?.(eventName, listener));
|
||||
subscribers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v142';
|
||||
const CACHE = 'stackchain-dashboard-shell-v143';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
|
|
@ -190,6 +190,7 @@ const SHELL = [
|
|||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/progressive-live-snapshot.js',
|
||||
BASE + 'static/progressive-my-work.js',
|
||||
BASE + 'static/progressive-mobile-dock.js',
|
||||
BASE + 'static/progressive-capture.js',
|
||||
BASE + 'static/agenda-replan.js',
|
||||
BASE + 'static/agenda-calendar.js',
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ COMMONJS_BROWSER_BRANCH = re.compile(
|
|||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"work-core": (
|
||||
"static/my-work.js", "static/progressive-my-work.js",
|
||||
"static/my-work.js", "static/progressive-my-work.js", "static/progressive-mobile-dock.js",
|
||||
"static/unfiled-captures.js", "static/progressive-capture.js",
|
||||
),
|
||||
"comment-actions": ("static/comment-actions.js",),
|
||||
|
|
|
|||
65
tests/e2e/test_progressive_mobile_dock_release.py
Normal file
65
tests/e2e/test_progressive_mobile_dock_release.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("progressive mobile dock checks run only in the browser gate", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [{"width": 320, "height": 568}, {"width": 390, "height": 844}])
|
||||
def test_mobile_work_and_queues_are_usable_before_optional_hydration(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch()
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "my-work.js")
|
||||
page.add_script_tag(path=FRONTEND / "context-poller.js")
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
window.fetch = async () => ({
|
||||
ok:true, headers:{get:()=>null}, json:async()=>({
|
||||
context:{
|
||||
user:{login:'timmy'},
|
||||
issues:[{
|
||||
number:1427,title:'Progressive mobile dock',repository:'stackchain/stackchain-dashboard',
|
||||
assignees:['timmy'],work_reasons:['created_by_me'],url:'https://forge.example/issues/1427',
|
||||
}],
|
||||
pull_requests:[],
|
||||
},
|
||||
events:[],notifications:[],
|
||||
}),
|
||||
});
|
||||
}"""
|
||||
)
|
||||
page.add_script_tag(path=FRONTEND / "progressive-my-work.js")
|
||||
expect(page.locator("#my-work-status")).to_contain_text("1 assigned work item")
|
||||
page.add_script_tag(path=FRONTEND / "progressive-mobile-dock.js")
|
||||
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
expect(page.locator("#mobile-queue-sheet")).to_be_visible()
|
||||
expect(page.locator('[data-mobile-queue="filed"] [data-mobile-queue-count]')).to_have_text("1")
|
||||
page.locator(".mobile-queue-all summary").click()
|
||||
expect(page.locator('[data-mobile-queue="delivery"]')).to_contain_text("Still loading")
|
||||
next_action = page.locator("#mobile-queue-next-action")
|
||||
bounds = next_action.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator('[data-mobile-queue="filed"]').click()
|
||||
expect(page.locator("#progressive-work-detail")).to_be_visible()
|
||||
expect(page.locator("#progressive-work-detail-title")).to_have_text("Progressive mobile dock")
|
||||
page.locator("#close-progressive-work-detail").click()
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
expect(page.locator("#progressive-work-detail")).to_be_visible()
|
||||
expect(page.locator("#progressive-work-detail-title")).to_have_text("Progressive mobile dock")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
|
|
@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
|||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v142" in worker
|
||||
assert "stackchain-dashboard-shell-v143" in worker
|
||||
|
|
|
|||
|
|
@ -593,7 +593,7 @@ process.stdout.write(JSON.stringify({{
|
|||
assert ".following-disposition-mode" in css
|
||||
assert "if (searchPreviewReturnKind === 'following')" in dashboard
|
||||
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
|
||||
assert "stackchain-dashboard-shell-v142" in service_worker
|
||||
assert "stackchain-dashboard-shell-v143" in service_worker
|
||||
|
||||
|
||||
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
||||
|
|
|
|||
|
|
@ -262,7 +262,7 @@ def test_human_gate_mobile_shell_and_deep_route_are_wired():
|
|||
assert "mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']})" in dashboard
|
||||
assert "counts.gate = queueCounts.gate" in dashboard
|
||||
assert "gate:preparationItems.gate || []" in dashboard
|
||||
assert "stackchain-dashboard-shell-v142" in WORKER.read_text()
|
||||
assert "stackchain-dashboard-shell-v143" in WORKER.read_text()
|
||||
|
||||
|
||||
def test_deep_link_opens_human_gates_without_waiting_for_optional_workspace():
|
||||
|
|
|
|||
|
|
@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
|||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
|
|
|
|||
|
|
@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
|||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v142" in worker
|
||||
assert "stackchain-dashboard-shell-v143" in worker
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
|||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v142" in worker
|
||||
assert "stackchain-dashboard-shell-v143" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
|
|
|||
|
|
@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
|||
assert "controller.recoverPermission('deadline')" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v142" in worker
|
||||
assert "stackchain-dashboard-shell-v143" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
|
|
|||
|
|
@ -274,5 +274,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
|||
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in worker
|
||||
assert "stackchain-dashboard-shell-v143" in worker
|
||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||
|
|
|
|||
|
|
@ -469,7 +469,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
|||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||
assert "stackchain-dashboard-shell-v142" in service_worker
|
||||
assert "stackchain-dashboard-shell-v143" in service_worker
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
|
|||
|
|
@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
|
|||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
|
|
|
|||
184
tests/test_progressive_mobile_dock.py
Normal file
184
tests/test_progressive_mobile_dock.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
MODULE = ROOT / "frontend" / "progressive-mobile-dock.js"
|
||||
INDEX = ROOT / "frontend" / "index.html"
|
||||
DASHBOARD = ROOT / "frontend" / "dashboard.js"
|
||||
CSS = ROOT / "frontend" / "dashboard.css"
|
||||
MY_WORK = ROOT / "frontend" / "my-work.js"
|
||||
PROGRESSIVE_MY_WORK = ROOT / "frontend" / "progressive-my-work.js"
|
||||
|
||||
|
||||
def test_progressive_mobile_dock_opens_work_and_truthful_queues_before_hydration():
|
||||
harness = f"""
|
||||
const createDock=require({json.dumps(str(MODULE))});
|
||||
function element(dataset={{}}) {{
|
||||
return {{dataset,attrs:{{}},listeners:{{}},open:false,textContent:'',focusCount:0,
|
||||
addEventListener(name,cb){{this.listeners[name]=cb;}},
|
||||
removeEventListener(name,cb){{if(this.listeners[name]===cb)delete this.listeners[name];}},
|
||||
setAttribute(name,value){{this.attrs[name]=String(value);}},
|
||||
removeAttribute(name){{delete this.attrs[name];}},
|
||||
showModal(){{this.open=true;}},close(){{this.open=false;}},focus(){{this.focusCount+=1;}},
|
||||
}};
|
||||
}}
|
||||
const work=element({{mobileTask:'work'}}); const queues=element({{mobileTask:'queues'}});
|
||||
const sheet=element(); const close=element(); const next=element();
|
||||
const rows=Object.fromEntries(['today','agenda','delivery','gate','attention','update','filed','later','draft','authored']
|
||||
.map(name=>[name,element({{mobileQueue:name}})]));
|
||||
const counts=Object.fromEntries(Object.keys(rows).map(name=>[name,element({{mobileQueueCount:name}})]));
|
||||
const smalls=Object.fromEntries(Object.keys(rows).map(name=>[name,{{textContent:'source label'}}]));
|
||||
Object.keys(rows).forEach(name=>rows[name].querySelector=selector=>selector==='small'?smalls[name]:null);
|
||||
const document={{
|
||||
querySelector(selector){{return {{
|
||||
'#mobile-queue-sheet':sheet,'#close-mobile-queues':close,'#mobile-queue-next-action':next,
|
||||
'[data-mobile-task="work"]':work,'[data-mobile-task="queues"]':queues,
|
||||
}}[selector]||null;}},
|
||||
querySelectorAll(selector){{
|
||||
if(selector==='[data-mobile-queue]')return Object.values(rows);
|
||||
if(selector==='[data-mobile-queue-count]')return Object.values(counts);
|
||||
return [];
|
||||
}},
|
||||
}};
|
||||
const calls=[];
|
||||
const myWork={{
|
||||
counts:()=>({{all:3,filed:1,authored:1,attention:1,update:0}}),
|
||||
openFirst:()=>calls.push(['openFirst']),
|
||||
selectQueue:(name,options)=>calls.push(['selectQueue',name,options]),
|
||||
}};
|
||||
const dock=createDock({{document,myWork}}); dock.start();
|
||||
queues.listeners.click({{currentTarget:queues}});
|
||||
const opened=sheet.open; const initialHandoff=dock.handoff();
|
||||
rows.filed.listeners.click();
|
||||
work.listeners.click({{currentTarget:work}});
|
||||
const known={{filed:counts.filed.textContent,attention:counts.attention.textContent}};
|
||||
const loading={{
|
||||
deferred:{{today:smalls.today.textContent,delivery:smalls.delivery.textContent,gate:smalls.gate.textContent,later:smalls.later.textContent}},
|
||||
unavailable:{{today:rows.today.attrs['data-unavailable'],delivery:rows.delivery.attrs['data-unavailable']}},
|
||||
}};
|
||||
dock.stop();
|
||||
console.log(JSON.stringify({{
|
||||
opened,initialHandoff,calls,loading,known,
|
||||
restored:{{today:smalls.today.textContent,delivery:smalls.delivery.textContent}},
|
||||
cleaned:!('data-unavailable' in rows.today.attrs) && !('data-progressive-loading' in rows.today.attrs),
|
||||
listeners:{{work:Object.keys(work.listeners),queues:Object.keys(queues.listeners),filed:Object.keys(rows.filed.listeners)}},
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"opened": True,
|
||||
"initialHandoff": {"queueSheetOpen": True, "lastTask": "queues"},
|
||||
"calls": [
|
||||
["selectQueue", "filed", {"openFirst": True}],
|
||||
["openFirst"],
|
||||
],
|
||||
"known": {"filed": "1", "attention": "1"},
|
||||
"loading": {
|
||||
"deferred": {
|
||||
"today": "Still loading",
|
||||
"delivery": "Still loading",
|
||||
"gate": "Still loading",
|
||||
"later": "Still loading",
|
||||
},
|
||||
"unavailable": {"today": "true", "delivery": "true"},
|
||||
},
|
||||
"restored": {"today": "source label", "delivery": "source label"},
|
||||
"cleaned": True,
|
||||
"listeners": {"work": [], "queues": [], "filed": []},
|
||||
}
|
||||
|
||||
|
||||
def test_progressive_mobile_dock_refreshes_counts_from_live_work_and_unsubscribes_on_handoff():
|
||||
harness = f"""
|
||||
const createDock=require({json.dumps(str(MODULE))});
|
||||
function element(dataset={{}}) {{return {{dataset,textContent:'',listeners:{{}},attrs:{{}},open:false,
|
||||
addEventListener(name,cb){{this.listeners[name]=cb;}},removeEventListener(name){{delete this.listeners[name];}},
|
||||
setAttribute(name,value){{this.attrs[name]=String(value);}},removeAttribute(name){{delete this.attrs[name];}},
|
||||
querySelector:()=>({{textContent:''}}),showModal(){{this.open=true;}},close(){{this.open=false;}},
|
||||
}};}}
|
||||
const work=element({{mobileTask:'work'}}),queues=element({{mobileTask:'queues'}}),sheet=element(),close=element(),next=element();
|
||||
const filed=element({{mobileQueue:'filed'}}),filedCount=element({{mobileQueueCount:'filed'}});
|
||||
const document={{querySelector:s=>({{
|
||||
'[data-mobile-task="work"]':work,'[data-mobile-task="queues"]':queues,
|
||||
'#mobile-queue-sheet':sheet,'#close-mobile-queues':close,'#mobile-queue-next-action':next,
|
||||
}}[s]||null),querySelectorAll:s=>s==='[data-mobile-queue]'?[filed]:s==='[data-mobile-queue-count]'?[filedCount]:[]}};
|
||||
let counts={{all:0,filed:0}}, subscriber=null, unsubscribed=false;
|
||||
const myWork={{counts:()=>counts,subscribe(cb){{subscriber=cb;return()=>{{unsubscribed=true;subscriber=null;}};}},openFirst(){{}},selectQueue(){{}}}};
|
||||
const dock=createDock({{document,myWork}}); dock.start();
|
||||
const before=filedCount.textContent; counts={{all:2,filed:2}}; subscriber(); const after=filedCount.textContent;
|
||||
dock.stop();
|
||||
console.log(JSON.stringify({{before,after,unsubscribed,subscriber:subscriber===null}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": "0", "after": "2", "unsubscribed": True, "subscriber": True,
|
||||
}
|
||||
|
||||
|
||||
def test_progressive_mobile_dock_is_shipped_before_optional_workspace_hydration():
|
||||
index = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert '<script src="static/progressive-mobile-dock.js"></script>' in index
|
||||
assert index.index('static/progressive-my-work.js') < index.index('static/progressive-mobile-dock.js')
|
||||
assert index.index('static/progressive-mobile-dock.js') < index.index('static/dashboard.js')
|
||||
assert "window.stackchainProgressiveMobileDock?.handoff?.()" in dashboard
|
||||
assert "window.stackchainProgressiveMobileDock?.stop?.()" in dashboard
|
||||
assert "mobileTaskDock.select(progressiveMobileDockHandoff.lastTask)" in dashboard
|
||||
assert "progressiveMobileDockHandoff?.queueSheetOpen && !qs('#mobile-queue-sheet').open" in dashboard
|
||||
assert '[data-progressive-loading="true"]::after' in css
|
||||
assert "content:'Still loading · available after workspace starts'" in css
|
||||
|
||||
|
||||
def test_progressive_my_work_exposes_counts_and_opens_the_first_selected_queue_item():
|
||||
harness = f"""
|
||||
const fs=require('fs'); const vm=require('vm');
|
||||
function element(extra={{}}) {{return Object.assign({{
|
||||
innerHTML:'',textContent:'',hidden:true,dataset:{{}},listeners:{{}},focusCount:0,
|
||||
addEventListener(name,cb){{this.listeners[name]=cb;}},removeEventListener(){{}},
|
||||
setAttribute(){{}},removeAttribute(){{}},focus(){{this.focusCount+=1;}},
|
||||
}},extra);}}
|
||||
const list=element(); const status=element(); const detail=element();
|
||||
const title=element(); const meta=element(); const reason=element(); const close=element(); const link=element();
|
||||
const elements={{'#my-work-list':list,'#my-work-status':status,'#progressive-work-detail':detail,
|
||||
'#progressive-work-detail-title':title,'#progressive-work-detail-meta':meta,
|
||||
'#progressive-work-detail-reason':reason,'#close-progressive-work-detail':close,
|
||||
'#open-progressive-work-gitea':link}};
|
||||
const buttons=['all','filed','authored','attention'].map(name=>element({{dataset:{{workFilter:name}}}}));
|
||||
const document={{querySelector:s=>elements[s]||null,querySelectorAll:()=>buttons}};
|
||||
const lifecycleTarget=element();
|
||||
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
||||
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(PROGRESSIVE_MY_WORK))},'utf8'),context);
|
||||
const flow=context.module.exports({{document,lifecycleTarget,fetchSnapshot:async()=>({{
|
||||
user:{{login:'timmy'}},
|
||||
issues:[{{number:7,title:'Filed issue',repository:'stackchain/dashboard',work_reasons:['created_by_me'],url:'https://forge.example/issues/7'}}],
|
||||
pull_requests:[{{number:8,title:'Authored PR',repository:'stackchain/dashboard',work_reasons:['authored_by_me'],url:'https://forge.example/pulls/8'}}],
|
||||
}})}});
|
||||
(async()=>{{
|
||||
let updates=0; const unsubscribe=flow.subscribe(()=>{{updates+=1;}});
|
||||
await flow.start(); const counts=flow.counts();
|
||||
const selected=flow.selectQueue('filed',{{openFirst:true}}); const handoff=flow.handoff();
|
||||
unsubscribe(); flow.selectQueue('authored');
|
||||
console.log(JSON.stringify({{counts,updates,selected,detailHidden:detail.hidden,title:title.textContent,handoff}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
state = json.loads(result.stdout)
|
||||
assert state["counts"] == {"all": 2, "filed": 1, "authored": 1, "attention": 0, "update": 0, "review": 0}
|
||||
assert state["updates"] == 2
|
||||
assert state["selected"] == "opened"
|
||||
assert state["detailHidden"] is False
|
||||
assert state["title"] == "Filed issue"
|
||||
assert state["handoff"]["selectedFilter"] == "filed"
|
||||
assert state["handoff"]["openWork"]["key"] == "stackchain/dashboard#7"
|
||||
|
|
@ -189,14 +189,14 @@ async function dispatchPush(payload) {{
|
|||
def test_shared_progressive_snapshot_broker_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/progressive-live-snapshot.js'" in source
|
||||
|
||||
|
||||
def test_week_unplan_undo_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
||||
|
|
@ -204,20 +204,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
|
|||
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
|
||||
|
||||
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
|
@ -226,7 +226,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
|||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/authored-outbox.js'" in source
|
||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||
|
|
@ -235,7 +235,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
|||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||
assert "BASE + 'static/issue-attachment.js'" in source
|
||||
|
||||
|
|
@ -243,14 +243,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
|||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -258,7 +258,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
|||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -266,7 +266,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
|||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/issue-sheet.js'" in source
|
||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
|
@ -276,14 +276,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
|||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
|
|
@ -292,21 +292,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
|||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
|
@ -1361,7 +1361,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
|||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
|
|
@ -1419,6 +1419,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/progressive-live-snapshot.js",
|
||||
"/dashboard/static/progressive-my-work.js",
|
||||
"/dashboard/static/progressive-mobile-dock.js",
|
||||
"/dashboard/static/progressive-capture.js",
|
||||
"/dashboard/static/agenda-replan.js",
|
||||
"/dashboard/static/agenda-calendar.js",
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
|||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v142';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v143';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
|
|||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v142" in source
|
||||
assert "stackchain-dashboard-shell-v143" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user