From e2b5f6042c765ec7ffa1d75a9fcaf6ababa95310 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 19 Aug 2026 19:33:34 +0000 Subject: [PATCH] feat: coach first mobile task to completion (Closes #1146) --- frontend/dashboard.css | 4 ++ frontend/dashboard.js | 17 ++++--- frontend/index.html | 7 +++ frontend/mobile-first-task.js | 49 +++++++++++++------ .../e2e/test_mobile_home_bootstrap_release.py | 21 ++++++++ tests/test_mobile_first_task.py | 41 ++++++++++++---- tests/test_my_work.py | 2 +- 7 files changed, 109 insertions(+), 32 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 1014ab9..f370e74 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1206,6 +1206,10 @@ textarea { resize: vertical; min-height: 120px; } .mobile-update-outcome p { margin:0 0 10px; } .mobile-update-outcome button { width:100%; min-height:44px; } .mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:4px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); } + .mobile-first-task-coach { grid-column:1 / -1; display:grid; gap:2px; padding:8px 10px; border-left:3px solid #60a5fa; border-radius:6px; background:#172f4d; } + .mobile-first-task-coach[hidden] { display:none; } + .mobile-first-task-receipt { position:fixed; left:12px; right:12px; bottom:calc(var(--mobile-today-clearance, 166px) + 8px + env(safe-area-inset-bottom)); z-index:46; padding:12px; border:1px solid #34d399; border-radius:10px; background:#0d3b35; font-weight:700; } + .mobile-first-task-receipt[hidden] { display:none; } .mobile-today-summary { grid-column:1 / 4; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; } .mobile-today-hud [data-work-session-progress] { grid-column:4 / 7; align-self:center; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .mobile-today-hud [data-mobile-today-complete] { grid-column:1 / 3; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 59012c2..0fcac55 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1,6 +1,7 @@ (async function(){ const workspaceLifecycle = await loadWorkspace({ document, window }); const qs = (s, el=document) => el.querySelector(s); + const announceWork = message => qs('#my-work-action-status').textContent = message; const fmt = (d) => new Date(d).toLocaleString(); const cardPlanning = createCardPlanning(document); const mobileComposerViewport = createMobileComposerViewport({ @@ -128,7 +129,7 @@ openFiled: openFiledFollowUp, selectFilter: selectMobileQueue, 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: message => { qs('#my-work-action-status').textContent = message; }, + announce: announceWork, getCounts: () => mobileQueueCounts, openFindWork: () => qs('#find-work').click(), }); @@ -1700,7 +1701,7 @@ hasCheckpoint: () => updateTriage.resumable(), resume: () => updateTriage.resume(), start: () => updateTriage.start(), - announce: message => { qs('#my-work-action-status').textContent = message; }, + announce: announceWork, }); function openUpdateTriage() { @@ -2051,7 +2052,7 @@ hasCheckpoint: () => Boolean(agendaSessionCheckpoint.read()), resume: () => workSession.resume(), start: () => workSession.start(), - announce: message => { qs('#my-work-action-status').textContent = message; }, + announce: announceWork, }); function openAgendaSession() { return agendaSessionLauncher.open(); @@ -2193,7 +2194,7 @@ qs('[data-work-filter="today"]').click(); workSession.start(item); }, - announce: message => { qs('#my-work-action-status').textContent = message; }, + announce: announceWork, }); function renderCreateStartCapacity() { const result = createAndStart.capacity(qs('#create-issue-estimate').value); @@ -2219,7 +2220,7 @@ qs('#my-work-action-status').textContent = 'Checking Today readiness…'; return todayReadiness.run('start', workSession.items(), item); }, - announce: message => { qs('#my-work-action-status').textContent = message; }, + announce: announceWork, }); const selectedSessionItem = kind => ({ @@ -2234,8 +2235,8 @@ workSession, refresh: () => refreshMyWorkView({ reconcileSession:false }), warm: warmTodayOffline, - announce: message => { qs('#my-work-action-status').textContent = message; }, - advance: () => runTodayTransition('complete'), + announce: announceWork, + advance: () => (mobileFirstTask.completeOutcome(), runTodayTransition('complete')), }); todayLockScreen = createTodayLockScreen({ storage:localStorage, @@ -2971,7 +2972,7 @@ close: () => workRoute.close(), refresh: refreshMyWorkView, focus: () => qs('[data-work-filter="' + selectedWorkFilter + '"]')?.focus(), - announce: message => { qs('#my-work-action-status').textContent = message; }, + announce: announceWork, formatTime: fmt, }); const laterPickerElement = qs('#later-picker'); diff --git a/frontend/index.html b/frontend/index.html index ecee3c9..85ba709 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1655,12 +1655,19 @@ +

Today session

diff --git a/frontend/mobile-first-task.js b/frontend/mobile-first-task.js index 3369c59..744b27c 100644 --- a/frontend/mobile-first-task.js +++ b/frontend/mobile-first-task.js @@ -9,6 +9,7 @@ isOnline: () => typeof navigator === 'undefined' || navigator.onLine, mediaQuery: win?.matchMedia('(max-width: 600px)') || {matches:false}, eventTarget: win, + setTimer: (...args) => globalThis.setTimeout(...args), sheet: doc?.querySelector('#mobile-first-task'), title: doc?.querySelector('#mobile-first-task-title'), findButton: doc?.querySelector('#mobile-first-task-find'), @@ -16,6 +17,8 @@ setupButton: doc?.querySelector('#mobile-first-task-setup'), closeButton: doc?.querySelector('#close-mobile-first-task'), status: doc?.querySelector('#mobile-first-task-status'), + coach: doc?.querySelector('[data-mobile-first-task-coach]'), + receipt: doc?.querySelector('#mobile-first-task-receipt'), isTodayActive: () => false, onFind: () => doc?.querySelector('#find-work')?.click(), onCreate: () => doc?.querySelector('#new-issue')?.click(), @@ -32,22 +35,27 @@ return login ? prefix + login : ''; } - function completed() { + function state() { const currentKey = key(); - if (!currentKey) return false; - try { return options.storage.getItem(currentKey) === 'complete'; } - catch (_error) { return false; } + if (!currentKey) return ''; + try { return options.storage.getItem(currentKey) || ''; } + catch (_error) { return ''; } + } + + function store(value) { + const currentKey = key(); + if (!currentKey) return; + try { options.storage.setItem(currentKey, value); } + catch (_error) {} } function required() { - return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !completed()); + return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !state()); } - function markComplete() { - const currentKey = key(); - if (!currentKey) return; - try { options.storage.setItem(currentKey, 'complete'); } - catch (_error) {} + function renderCoach() { + if (!options.coach) return; + options.coach.hidden = !(options.mediaQuery.matches && state() === 'coaching' && options.isTodayActive()); } function render() { @@ -72,16 +80,29 @@ } function refresh() { - if (account() && options.isTodayActive() && !completed()) { - markComplete(); + if (account() && options.isTodayActive() && state() !== 'complete') { + store('coaching'); if (options.sheet.open) options.sheet.close(); - return 'completed'; + renderCoach(); + return 'coaching'; } + renderCoach(); if (options.sheet.open) render(); if (required() && options.hasWork()) return 'awaiting-start'; return required() ? 'required' : 'inactive'; } + function completeOutcome() { + if (state() !== 'coaching') return false; + store('complete'); + renderCoach(); + if (options.receipt) { + options.receipt.hidden = false; + options.setTimer?.(() => { options.receipt.hidden = true; }, 6000); + } + return true; + } + function handoff(callback, requiresOnline = false) { if (requiresOnline && !options.isOnline()) return; if (options.sheet.open) options.sheet.close(); @@ -99,5 +120,5 @@ options.eventTarget?.addEventListener('offline', render); } - return {required, open, refresh, render, start}; + return {required, open, refresh, render, start, completeOutcome}; }); diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index b5262ef..9a910f5 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -64,6 +64,27 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work( 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() + + page.locator("#close-mobile-first-task").click() + context.set_offline(False) + page.evaluate( + """() => { + document.querySelector('[data-mobile-today-hud]').hidden = false; + localStorage.setItem('stackchain.first-task.v1:timmy', 'coaching'); + window.firstTaskOutcomeProbe = createMobileFirstTask({ + getLogin: () => 'timmy', hasWork: () => true, isTodayActive: () => true, + }); + return window.firstTaskOutcomeProbe.refresh(); + }""" + ) + coach = page.locator("[data-mobile-first-task-coach]") + expect(coach).to_be_visible() + expect(coach).to_contain_text("Complete your first task") + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + assert page.evaluate("window.firstTaskOutcomeProbe.completeOutcome()") is True + expect(page.locator("#mobile-first-task-receipt")).to_be_visible() + expect(coach).to_be_hidden() + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") browser.close() finally: fake.shutdown() diff --git a/tests/test_mobile_first_task.py b/tests/test_mobile_first_task.py index 5968b13..87f5e08 100644 --- a/tests/test_mobile_first_task.py +++ b/tests/test_mobile_first_task.py @@ -28,31 +28,39 @@ class Element {{ return json.loads(result.stdout) -def test_first_task_activation_is_account_bound_and_completes_only_when_today_is_active(): +def test_first_task_activation_is_account_bound_and_completes_only_after_a_today_outcome(): 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(); +const coach = new Element(); coach.hidden = true; +const receipt = new Element(); receipt.hidden = true; let login = 'Timmy'; let hasWork = false; let todayActive = false; +let hideReceipt; const controller = createFirstTask({ storage, getLogin:() => login, hasWork:() => hasWork, isTodayActive:() => todayActive, - isOnline:() => true, mediaQuery:{matches:true}, sheet, title:new Element(), + isOnline:() => true, mediaQuery:{matches:true}, sheet, title:new Element(), coach, receipt, findButton:new Element(), createButton:new Element(), setupButton:new Element(), closeButton:new Element(), status:new Element(), onFind() {}, onCreate() {}, onSetup() {}, + setTimer:callback => { hideReceipt = callback; return {unref() {}}; }, }); const before = [controller.required(), controller.open(), sheet.open]; hasWork = true; const awaitingStart = controller.refresh(); const stillRequired = controller.required(); todayActive = true; -const completed = controller.refresh(); -const timmyRequired = controller.required(); +const coaching = controller.refresh(); +const afterStart = {required:controller.required(), sheetOpen:sheet.open, coachHidden:coach.hidden, stored:values.get('stackchain.first-task.v1:timmy')}; +const completed = controller.completeOutcome(); +const afterOutcome = {required:controller.required(), coachHidden:coach.hidden, receiptHidden:receipt.hidden, stored:values.get('stackchain.first-task.v1:timmy')}; +hideReceipt(); +const receiptHiddenAfterTimeout = receipt.hidden; login = 'alexander'; hasWork = false; todayActive = false; const alexanderRequired = controller.required(); -process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, completed, sheetOpen:sheet.open, timmyRequired, alexanderRequired, values:[...values]})); +process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, coaching, afterStart, completed, afterOutcome, receiptHiddenAfterTimeout, alexanderRequired})); """ ) @@ -60,11 +68,22 @@ process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, compl "before": [True, True, True], "awaitingStart": "awaiting-start", "stillRequired": True, - "completed": "completed", - "sheetOpen": False, - "timmyRequired": False, + "coaching": "coaching", + "afterStart": { + "required": False, + "sheetOpen": False, + "coachHidden": False, + "stored": "coaching", + }, + "completed": True, + "afterOutcome": { + "required": False, + "coachHidden": True, + "receiptHidden": False, + "stored": "complete", + }, + "receiptHiddenAfterTimeout": True, "alexanderRequired": True, - "values": [["stackchain.first-task.v1:timmy", "complete"]], } @@ -158,6 +177,10 @@ async def test_dashboard_renders_and_wires_phone_safe_first_task_activation(): assert "shouldActivate: () => mobileFirstTask.required()" in html assert "openActivation: () => mobileFirstTask.open()" in html assert "mobileFirstTask.refresh()" in html + assert 'data-mobile-first-task-coach hidden' in html + assert 'Complete your first task' in html + assert 'id="mobile-first-task-receipt" role="status" aria-live="polite" hidden' in html + assert "advance: () => (mobileFirstTask.completeOutcome(), runTodayTransition('complete'))" 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_my_work.py b/tests/test_my_work.py index 5541ce7..51b5f95 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -3829,7 +3829,7 @@ async def test_checkpointed_detail_defer_completes_today_and_advertises_next_ite assert "continueSession: item => completeTodayItem(item, {" in html assert "successMessage: 'Deferred to Later. Next Today item opened.'" in html - assert "advance: () => runTodayTransition('complete')" in html + assert "advance: () => (mobileFirstTask.completeOutcome(), runTodayTransition('complete'))" in html assert "button.textContent = active ? 'Later today & next' : 'Later today'" in html assert "button.textContent = active ? 'Tomorrow & next' : 'Tomorrow'" in html assert "button.textContent = active ? 'Choose date & time & next' : 'Choose date & time'" in html