diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 0003623..1014ab9 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1169,6 +1169,15 @@ textarea { resize: vertical; min-height: 120px; } .mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); } .mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; } + .mobile-first-task { 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-first-task::backdrop { background:rgba(3,9,18,.78); } + .mobile-first-task-panel { display:grid; gap:14px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); } + .mobile-first-task-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } + .mobile-first-task-panel h2, .mobile-first-task-panel p { margin:0; } + .mobile-first-task-actions { display:grid; gap:10px; } + .mobile-first-task-actions button { min-height:48px; width:100%; } + .mobile-first-task-actions button:disabled { opacity:.55; } + .mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); } .mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); } .mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index ba9292d..bbee033 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -140,6 +140,11 @@ if (!sheet.open) qs('#mobile-queue-sheet').showModal(); qs('#mobile-start-day-action').focus(); } + const mobileFirstTask = createMobileFirstTask({ + getLogin: () => confirmedOwnerLogin, + hasWork: () => todayMyWork.length > 0 || activeMyWork.length > 0, + }); + mobileFirstTask.start(); const mobileWorkEntry = createMobileWorkEntry({ isTodayActive: () => workSession.checkpointed(), isTodayResumable: () => workSession.resumable(), @@ -152,6 +157,8 @@ startToday: startTodaySession, planToday: () => openPlanToday(mobileTaskButtons.work), prepareToday: openMobileStartDay, + shouldActivate: () => mobileFirstTask.required(), + openActivation: () => mobileFirstTask.open(), findWork: () => qs('#find-work').click(), }); const mobileStartDay = createMobileStartDay({ @@ -3225,6 +3232,7 @@ }); mobileStartDay.render(); mobileTaskDock.updateQueues(counts); + mobileFirstTask.refresh(); mobileTaskDock.updateWork(mobileWorkEntry.mode()); mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention); const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]'); diff --git a/frontend/index.html b/frontend/index.html index 5d22517..6ecd390 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1829,6 +1829,22 @@ + + + + Welcome to WorkStart your first task + Close + + Choose work, start it, then return to Work whenever you want to continue. + + + Find a task + Create a task + Make this phone work-ready + + + + Work Find @@ -1933,6 +1949,7 @@ + diff --git a/frontend/mobile-first-task.js b/frontend/mobile-first-task.js new file mode 100644 index 0000000..1cb108b --- /dev/null +++ b/frontend/mobile-first-task.js @@ -0,0 +1,95 @@ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory; + else root.createMobileFirstTask = factory; +})(typeof self !== 'undefined' ? self : this, function createMobileFirstTask(options) { + const doc = typeof document !== 'undefined' ? document : null; + const win = typeof window !== 'undefined' ? window : null; + options = Object.assign({ + storage: typeof localStorage !== 'undefined' ? localStorage : null, + isOnline: () => typeof navigator === 'undefined' || navigator.onLine, + mediaQuery: win?.matchMedia('(max-width: 600px)') || {matches:false}, + eventTarget: win, + sheet: doc?.querySelector('#mobile-first-task'), + findButton: doc?.querySelector('#mobile-first-task-find'), + createButton: doc?.querySelector('#mobile-first-task-create'), + setupButton: doc?.querySelector('#mobile-first-task-setup'), + closeButton: doc?.querySelector('#close-mobile-first-task'), + status: doc?.querySelector('#mobile-first-task-status'), + onFind: () => doc?.querySelector('#find-work')?.click(), + onCreate: () => doc?.querySelector('#new-issue')?.click(), + onSetup: () => doc?.querySelector('#open-device-setup')?.click(), + }, options); + const prefix = 'stackchain.first-task.v1:'; + + function account() { + return String(options.getLogin?.() || '').trim().toLowerCase(); + } + + function key() { + const login = account(); + return login ? prefix + login : ''; + } + + function completed() { + const currentKey = key(); + if (!currentKey) return false; + try { return options.storage.getItem(currentKey) === 'complete'; } + catch (_error) { return false; } + } + + function required() { + return Boolean(options.mediaQuery.matches && account() && !options.hasWork() && !completed()); + } + + function markComplete() { + const currentKey = key(); + if (!currentKey) return; + try { options.storage.setItem(currentKey, 'complete'); } + catch (_error) {} + } + + function render() { + const online = options.isOnline(); + options.findButton.disabled = !online; + options.status.textContent = online ? + 'Choose a task to claim or create one of your own.' : + 'You are offline. Create a task now and it will stay in Drafts until you reconnect.'; + } + + function open() { + if (!required()) return false; + render(); + if (!options.sheet.open) options.sheet.showModal(); + (options.findButton.disabled ? options.createButton : options.findButton).focus(); + return true; + } + + function refresh() { + if (account() && options.hasWork() && !completed()) { + markComplete(); + if (options.sheet.open) options.sheet.close(); + return 'completed'; + } + if (options.sheet.open) render(); + return required() ? 'required' : 'inactive'; + } + + function handoff(callback, requiresOnline = false) { + if (requiresOnline && !options.isOnline()) return; + if (options.sheet.open) options.sheet.close(); + callback(); + } + + function start() { + options.findButton.addEventListener('click', () => handoff(options.onFind, true)); + options.createButton.addEventListener('click', () => handoff(options.onCreate)); + options.setupButton.addEventListener('click', () => handoff(options.onSetup)); + options.closeButton.addEventListener('click', () => { + if (options.sheet.open) options.sheet.close(); + }); + options.eventTarget?.addEventListener('online', render); + options.eventTarget?.addEventListener('offline', render); + } + + return {required, open, refresh, render, start}; +}); diff --git a/frontend/mobile-work-entry.js b/frontend/mobile-work-entry.js index 1645f7c..e626bab 100644 --- a/frontend/mobile-work-entry.js +++ b/frontend/mobile-work-entry.js @@ -12,6 +12,7 @@ if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume'; if (options.getTodayCount() > 0) return 'start'; if (options.getEligibleCount() > 0) return 'plan'; + if (options.shouldActivate?.()) return 'activate'; return 'find'; } @@ -22,6 +23,7 @@ else if (current === 'resume') options.resumeToday(); else if (current === 'start') options.startToday(); else if (current === 'plan') options.planToday(); + else if (current === 'activate') options.openActivation(); else if (current === 'find') options.findWork(); else options.queueLauncher.open(current); return current; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 5297c7d..e7b945b 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -113,6 +113,7 @@ const SHELL = [ BASE + 'static/live-data-status.js', BASE + 'static/mobile-today-command-bar.js', BASE + 'static/mobile-task-dock.js', + BASE + 'static/mobile-first-task.js', BASE + 'static/mobile-work-entry.js', BASE + 'static/mobile-queue-launcher.js', BASE + 'static/mobile-delivery-recovery.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 216697d..684cd83 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -35,7 +35,7 @@ FEATURE_SOURCES = { "security-center": ("static/security-center.js",), "today-timer": ( "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", - "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js", + "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js", "static/today-rollover.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index 4a19bd1..b5262ef 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -16,6 +16,61 @@ from fake_gitea import AVAILABLE_ISSUES, FakeGiteaServer from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server +@pytest.mark.parametrize(("width", "height"), [(390, 844)]) +def test_release_artifact_guides_an_empty_mobile_account_to_first_work( + tmp_path: Path, width: int, height: int +): + archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz")) + assert len(archives) == 1, "browser job must download exactly one assembled release archive" + + fake = FakeGiteaServer(("127.0.0.1", 0)) + fake.assigned_issue_numbers = [] + fake_thread = threading.Thread(target=fake.serve_forever, daemon=True) + fake_thread.start() + fake_url = f"http://127.0.0.1:{fake.server_port}" + + try: + with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright: + browser = playwright.chromium.launch(args=["--ignore-certificate-errors"]) + context = browser.new_context( + viewport={"width": width, "height": height}, ignore_https_errors=True + ) + page = context.new_page() + page.goto(origin + "/", wait_until="networkidle") + page.locator('input[name="device_label"]').fill("First task release phone") + page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) + page.locator("#submit-sign-in").click() + page.wait_for_url(origin + "/", wait_until="networkidle") + expect(page.locator("#my-work-status")).to_contain_text("No assigned work") + + page.locator('[data-mobile-task="work"]').click() + sheet = page.locator("#mobile-first-task") + expect(sheet).to_be_visible() + expect(page.locator("#mobile-first-task-find")).to_be_focused() + for selector in ("#mobile-first-task-find", "#mobile-first-task-create", "#mobile-first-task-setup"): + bounds = page.locator(selector).bounding_box() + assert bounds and bounds["height"] >= 44 + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + + page.locator("#mobile-first-task-find").click() + expect(sheet).to_be_hidden() + expect(page.locator("#find-work-sheet")).to_be_visible() + page.locator("#close-find-work").click() + + context.set_offline(True) + page.locator('[data-mobile-task="work"]').click() + expect(sheet).to_be_visible() + expect(page.locator("#mobile-first-task-find")).to_be_disabled() + expect(page.locator("#mobile-first-task-status")).to_contain_text("Create a task now") + expect(page.locator("#mobile-first-task-create")).to_be_enabled() + expect(page.locator("#mobile-first-task-create")).to_be_focused() + browser.close() + finally: + fake.shutdown() + fake.server_close() + fake_thread.join(timeout=5) + + @pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)]) def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( tmp_path: Path, width: int, height: int diff --git a/tests/test_mobile_first_task.py b/tests/test_mobile_first_task.py new file mode 100644 index 0000000..69c478d --- /dev/null +++ b/tests/test_mobile_first_task.py @@ -0,0 +1,125 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from tests.dashboard_bundle import dashboard + + +CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-first-task.js" + + +def run_node(body: str) -> dict: + script = f""" +const createFirstTask = require({json.dumps(str(CONTROLLER))}); +class Element {{ + constructor() {{ this.listeners = {{}}; this.disabled = false; this.hidden = false; this.open = false; this.textContent = ''; this.focused = false; }} + addEventListener(name, callback) {{ this.listeners[name] = callback; }} + click() {{ this.listeners.click?.({{preventDefault() {{}}}}); }} + showModal() {{ this.open = true; }} + close() {{ this.open = false; this.listeners.close?.(); }} + focus() {{ this.focused = true; }} +}} +{body} +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_first_task_activation_is_account_bound_and_completes_when_work_appears(): + result = run_node( + """ +const values = new Map(); +const storage = {getItem:key => values.get(key) || null, setItem:(key,value) => values.set(key,value)}; +const sheet = new Element(); +let login = 'Timmy'; +let hasWork = false; +const controller = createFirstTask({ + storage, getLogin:() => login, hasWork:() => hasWork, isOnline:() => true, + mediaQuery:{matches:true}, sheet, findButton:new Element(), createButton:new Element(), + setupButton:new Element(), closeButton:new Element(), status:new Element(), + onFind() {}, onCreate() {}, onSetup() {}, +}); +const before = [controller.required(), controller.open(), sheet.open]; +hasWork = true; +const completed = controller.refresh(); +const timmyRequired = controller.required(); +login = 'alexander'; hasWork = false; +const alexanderRequired = controller.required(); +process.stdout.write(JSON.stringify({before, completed, sheetOpen:sheet.open, timmyRequired, alexanderRequired, values:[...values]})); +""" + ) + + assert result == { + "before": [True, True, True], + "completed": "completed", + "sheetOpen": False, + "timmyRequired": False, + "alexanderRequired": True, + "values": [["stackchain.first-task.v1:timmy", "complete"]], + } + + +def test_first_task_activation_routes_existing_flows_and_keeps_create_available_offline(): + result = run_node( + """ +const calls = []; +const sheet = new Element(); +const findButton = new Element(); +const createButton = new Element(); +const setupButton = new Element(); +const closeButton = new Element(); +const status = new Element(); +let online = false; +const controller = createFirstTask({ + storage:{getItem:() => null, setItem() {}}, getLogin:() => 'timmy', hasWork:() => false, + isOnline:() => online, mediaQuery:{matches:true}, sheet, findButton, createButton, + setupButton, closeButton, status, + onFind:() => calls.push('find'), onCreate:() => calls.push('create'), onSetup:() => calls.push('setup'), +}); +controller.start(); +controller.open(); +const offline = {findDisabled:findButton.disabled, createDisabled:createButton.disabled, status:status.textContent, createFocused:createButton.focused}; +findButton.click(); +createButton.click(); +controller.open(); +setupButton.click(); +controller.open(); +online = true; +controller.render(); +findButton.click(); +process.stdout.write(JSON.stringify({offline, calls, sheetOpen:sheet.open})); +""" + ) + + assert result == { + "offline": { + "findDisabled": True, + "createDisabled": False, + "status": "You are offline. Create a task now and it will stay in Drafts until you reconnect.", + "createFocused": True, + }, + "calls": ["create", "setup", "find"], + "sheetOpen": False, + } + + +@pytest.mark.anyio +async def test_dashboard_renders_and_wires_phone_safe_first_task_activation(): + html = await dashboard() + + assert '' in html + assert 'Start your first task' in html + assert 'id="mobile-first-task-find" type="button">Find a task' in html + assert 'id="mobile-first-task-create" type="button">Create a task' in html + assert 'id="mobile-first-task-setup" type="button">Make this phone work-ready' in html + assert '' in html + assert "const mobileFirstTask = createMobileFirstTask({" in html + assert "shouldActivate: () => mobileFirstTask.required()" in html + assert "openActivation: () => mobileFirstTask.open()" in html + assert "mobileFirstTask.refresh()" in html + assert '.mobile-first-task { box-sizing:border-box; width:100%;' in html + assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html + assert '.mobile-first-task-actions button { min-height:48px;' in html diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py index d757f10..d1827c3 100644 --- a/tests/test_mobile_task_dock.py +++ b/tests/test_mobile_task_dock.py @@ -140,6 +140,35 @@ process.stdout.write(JSON.stringify({{mode:entry.open(), calls}})); assert json.loads(result.stdout) == {"mode": "plan", "calls": ["plan"]} +def test_mobile_work_entry_guides_an_empty_first_workspace_before_find(): + script = f""" +const createEntry = require({json.dumps(str(ENTRY))}); +const calls = []; +let activationRequired = true; +const entry = createEntry({{ + isTodayActive: () => false, + isTodayResumable: () => false, + getTodayCount: () => 0, + getEligibleCount: () => 0, + shouldActivate: () => activationRequired, + queueLauncher: {{recommend: () => ({{name:'find'}}), open: () => {{}}}}, + openActivation: () => calls.push('activate'), + findWork: () => calls.push('find'), +}}); +const modes = [entry.open()]; +activationRequired = false; +modes.push(entry.open()); +process.stdout.write(JSON.stringify({{modes, calls}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "modes": ["activate", "find"], + "calls": ["activate", "find"], + } + + def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus(): script = f""" const createDock = require({json.dumps(str(DOCK))}); diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 3f05924..97199ec 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -1257,6 +1257,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/live-data-status.js", "/dashboard/static/mobile-today-command-bar.js", "/dashboard/static/mobile-task-dock.js", + "/dashboard/static/mobile-first-task.js", "/dashboard/static/mobile-work-entry.js", "/dashboard/static/mobile-queue-launcher.js", "/dashboard/static/mobile-delivery-recovery.js",
Welcome to Work
Choose work, start it, then return to Work whenever you want to continue.