feat: keep adaptive mobile Work actionable offline (Closes #1458)
All checks were successful
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m49s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-27 05:59:13 +00:00
parent 2432d4fc03
commit b0b051184b
4 changed files with 99 additions and 2 deletions

View File

@ -112,6 +112,7 @@
} }
let queueCounts = {}; let queueCounts = {};
let preparationItems = {}; let preparationItems = {};
let offlineWorkMode = false;
let renderMobileQueuePresentation = () => {}; let renderMobileQueuePresentation = () => {};
const followingQueue = attachFollowing(item => { const followingQueue = attachFollowing(item => {
searchPreviewReturnKind = 'following'; searchPreviewReturnKind = 'following';
@ -181,6 +182,7 @@
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'), firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
announce: announceWork, announce: announceWork,
getCounts: () => queueCounts, getCounts: () => queueCounts,
isOnline: () => !offlineWorkMode,
getPreparation: () => { getPreparation: () => {
const briefing = mobileStartDay.briefing(); const briefing = mobileStartDay.briefing();
return {...briefing, active:mobileStartDay.state().active}; return {...briefing, active:mobileStartDay.state().active};
@ -354,7 +356,6 @@
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click()); qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click()); qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
let liveMode = true; let liveMode = true;
let offlineWorkMode = false;
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1'; const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1'; const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
const WORK_FILTERS = ['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft']; const WORK_FILTERS = ['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'];
@ -7937,6 +7938,7 @@
} }
function setOfflineWorkMode(value) { function setOfflineWorkMode(value) {
offlineWorkMode = value; offlineWorkMode = value;
renderMobileQueuePresentation();
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read'] ['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
.forEach(selector => { const button = qs(selector); if (button) button.disabled = value; }); .forEach(selector => { const button = qs(selector); if (button) button.disabled = value; });
if (value) { if (value) {

View File

@ -22,6 +22,7 @@
['draft', 'Open Drafts'], ['draft', 'Open Drafts'],
]; ];
const activeQueueNames = continuation.map(([name]) => name); const activeQueueNames = continuation.map(([name]) => name);
const onlineOnlyQueues = new Set(['delivery', 'gate']);
const allQueues = [ const allQueues = [
'today', 'tomorrow', 'week', 'agenda', 'delivery', 'gate', 'attention', 'today', 'tomorrow', 'week', 'agenda', 'delivery', 'gate', 'attention',
'update', 'following', 'filed', 'authored', 'later', 'draft', 'find', 'recaps', 'update', 'following', 'filed', 'authored', 'later', 'draft', 'find', 'recaps',
@ -29,7 +30,10 @@
function recommend() { function recommend() {
const counts = options.getCounts ? options.getCounts() : {}; const counts = options.getCounts ? options.getCounts() : {};
const match = continuation.find(([name]) => Number(counts[name]) > 0); const online = options.isOnline ? options.isOnline() : true;
const match = continuation.find(([name]) =>
Number(counts[name]) > 0 && (online || !onlineOnlyQueues.has(name))
);
if (!match) return {name: 'find', count: 0, label: 'Find Work'}; if (!match) return {name: 'find', count: 0, label: 'Find Work'};
const [name, label] = match; const [name, label] = match;
const count = Math.max(0, Number(counts[name]) || 0); const count = Math.max(0, Number(counts[name]) || 0);

View File

@ -117,3 +117,52 @@ def test_adaptive_queues_open_existing_following_and_authored_work(viewport, que
next_action.press("Enter") next_action.press("Enter")
assert page.evaluate("window.destinations") == destination assert page.evaluate("window.destinations") == destination
browser.close() browser.close()
def test_adaptive_queues_keep_start_continue_actionable_offline_on_phone():
viewport = {"width": 390, "height": 844}
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
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 / "mobile-queue-launcher.js")
page.evaluate("""() => {
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
.map(row => [row.dataset.mobileQueue, row]));
window.online = false;
window.destinations = [];
window.adaptiveQueueLauncher = createMobileQueueLauncher({
getCounts:() => ({delivery:1, gate:2, today:3}),
isOnline:() => window.online,
rows,
nextAction:document.querySelector('#mobile-queue-next-action'),
activeList:document.querySelector('#mobile-queue-active-list'),
planningList:document.querySelector('#mobile-queue-planning-list'),
allList:document.querySelector('#mobile-queue-all-list'),
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
openToday:() => window.destinations.push('today'),
});
window.adaptiveQueueLauncher.renderPresentation();
document.querySelector('#mobile-queue-next-action').addEventListener(
'click', () => window.adaptiveQueueLauncher.continueWork()
);
document.querySelector('#mobile-queue-sheet').showModal();
}""")
next_action = page.locator("#mobile-queue-next-action")
expect(next_action).to_be_visible()
expect(next_action).to_have_text("Continue Today (3)")
next_action.press("Enter")
assert page.evaluate("window.destinations") == ["today"]
page.evaluate("""() => {
window.online = true;
window.adaptiveQueueLauncher.renderPresentation();
}""")
expect(next_action).to_have_text("Recover Delivery (1)")
bounds = next_action.bounding_box()
assert bounds and bounds["height"] >= 44
assert bounds["y"] + bounds["height"] <= viewport["height"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()

View File

@ -300,6 +300,18 @@ async def test_mobile_queue_sheet_prioritizes_next_active_and_planning_without_d
assert html.count(f'<button data-mobile-queue="{name}"') == 1 assert html.count(f'<button data-mobile-queue="{name}"') == 1
@pytest.mark.anyio
async def test_adaptive_mobile_queue_wiring_recommends_again_when_connectivity_changes():
html = await dashboard()
assert "let offlineWorkMode = false;" in html
assert "isOnline: () => !offlineWorkMode" in html
assert (
"offlineWorkMode = value;\n"
" renderMobileQueuePresentation();"
) in html
@pytest.mark.anyio @pytest.mark.anyio
async def test_following_hydration_refreshes_the_persistent_mobile_queue_census(): async def test_following_hydration_refreshes_the_persistent_mobile_queue_census():
html = await dashboard() html = await dashboard()
@ -770,6 +782,36 @@ process.stdout.write(JSON.stringify({{delivery, opened, afterRecovery, calls}}))
} }
def test_adaptive_mobile_work_skips_online_only_queues_offline_and_restores_them_online():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
let online = false;
const launcher = createLauncher({{
getCounts: () => ({{delivery:2, gate:1, today:3, update:4}}),
isOnline: () => online,
openToday: () => {{ calls.push('today'); return 'opened-today'; }},
selectFilter: name => calls.push('filter:' + name),
firstAction: () => null,
announce: () => {{}},
}});
const offline = launcher.recommend();
const opened = launcher.continueWork();
online = true;
const reconnected = launcher.recommend();
process.stdout.write(JSON.stringify({{offline, opened, reconnected, calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"offline": {"name": "today", "count": 3, "label": "Continue Today (3)"},
"opened": "opened-today",
"reconnected": {"name": "delivery", "count": 2, "label": "Recover Delivery (2)"},
"calls": ["today"],
}
def test_mobile_work_continues_into_filed_follow_up_before_later_work(): def test_mobile_work_continues_into_filed_follow_up_before_later_work():
script = f""" script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))}); const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});