diff --git a/frontend/dashboard.css b/frontend/dashboard.css index e5ff135..279501c 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1061,3 +1061,47 @@ textarea { resize: vertical; min-height: 120px; } .obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,') center/contain no-repeat; display:inline-block; } .footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; } @keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } +/* One-handed live-data recovery on the mobile Home surface. */ +.mobile-pull-refresh { + display: none; +} + +@media (max-width: 600px) { + .mobile-pull-refresh { + position: sticky; + top: calc(3.25rem + env(safe-area-inset-top)); + z-index: 8; + display: block; + width: max-content; + max-width: calc(100vw - 2rem); + margin: 0 auto -2rem; + padding: .45rem .8rem; + border: 1px solid rgba(125, 211, 252, .45); + border-radius: 999px; + background: rgba(11, 18, 32, .96); + color: #dbeafe; + font-size: .78rem; + box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .28); + transform: translateY(.5rem); + pointer-events: none; + } + + .mobile-pull-refresh[hidden] { + display: none; + } + + .mobile-pull-refresh[data-state="ready"], + .mobile-pull-refresh[data-state="success"] { + border-color: rgba(52, 211, 153, .7); + } + + .mobile-pull-refresh[data-state="error"] { + border-color: rgba(251, 113, 133, .75); + } +} + +@media (max-width: 600px) and (prefers-reduced-motion: no-preference) { + .mobile-pull-refresh { + transition: transform 120ms ease, border-color 120ms ease; + } +} diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 73707bf..36b919c 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -7183,6 +7183,7 @@ intervalMs: 8000, }); function load() { return contextPoller.refresh({ force: true }); } + createMobilePullRefresh({refresh:load}).start(); const liveDataStatusSheet = qs('#live-data-status-sheet'); const liveDataStatusTrigger = qs('#open-live-data-status'); let latestLiveFreshness = {}; diff --git a/frontend/index.html b/frontend/index.html index 899f6ac..d16c9c6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -124,6 +124,7 @@
+
@@ -1634,6 +1635,7 @@ + diff --git a/frontend/mobile-pull-refresh.js b/frontend/mobile-pull-refresh.js new file mode 100644 index 0000000..fe4a110 --- /dev/null +++ b/frontend/mobile-pull-refresh.js @@ -0,0 +1,88 @@ +(function (root, factory) { + if (typeof module !== 'undefined' && module.exports) module.exports = factory; + else root.createMobilePullRefresh = factory; +})(typeof self !== 'undefined' ? self : this, function createMobilePullRefresh(options) { + const surface = options.surface || document.querySelector('#my-work'); + const indicator = options.indicator || document.querySelector('#mobile-pull-refresh'); + const threshold = options.threshold || 64; + const isMobile = options.isMobile || (() => window.matchMedia('(max-width: 600px)').matches); + const getScrollY = options.getScrollY || (() => window.scrollY); + const isBlocked = options.isBlocked || (() => Array.from(options.overlays || document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal')).some(overlay => + (overlay.checkVisibility ? overlay.checkVisibility() : !overlay.closest('[hidden]')) && + (overlay.classList.contains('open') || overlay.getAttribute('aria-modal') === 'true'))); + let gesture = null; + let inFlight = null; + + function show(state, text) { + indicator.hidden = false; + indicator.dataset.state = state; + indicator.textContent = text; + } + + function resetGesture() { + gesture = null; + } + + function startsOnExcludedTarget(target) { + if (!target) return false; + if (target.closest?.('button, a, input, textarea, select, summary, [contenteditable="true"], [role="button"]')) return true; + for (let node = target; node && node !== surface; node = node.parentElement) { + if ((node.scrollWidth || 0) > (node.clientWidth || 0)) return true; + } + return false; + } + + function onPointerDown(event) { + if (inFlight || event.pointerType !== 'touch' || !isMobile() || getScrollY() > 0 || isBlocked() || startsOnExcludedTarget(event.target)) return; + gesture = {id:event.pointerId, x:event.clientX, y:event.clientY, ready:false}; + surface.setPointerCapture?.(event.pointerId); + } + + function onPointerMove(event) { + if (!gesture || gesture.id !== event.pointerId) return; + const dx = event.clientX - gesture.x; + const dy = event.clientY - gesture.y; + if (dy <= 0 || Math.abs(dx) > dy) return resetGesture(); + event.preventDefault(); + gesture.ready = dy >= threshold; + show(gesture.ready ? 'ready' : 'pulling', gesture.ready ? 'Release to refresh' : 'Pull to refresh'); + } + + async function onPointerUp(event) { + if (!gesture || gesture.id !== event.pointerId) return; + const shouldRefresh = gesture.ready; + resetGesture(); + surface.releasePointerCapture?.(event.pointerId); + if (!shouldRefresh || inFlight) return; + show('refreshing', 'Refreshing My Work…'); + inFlight = Promise.resolve().then(options.refresh); + try { + await inFlight; + show('success', 'My Work is up to date'); + } catch (_) { + show('error', 'Could not refresh · pull to retry'); + } finally { + inFlight = null; + } + } + + function onPointerCancel() { + resetGesture(); + } + + return { + start() { + surface.addEventListener('pointerdown', onPointerDown); + surface.addEventListener('pointermove', onPointerMove); + surface.addEventListener('pointerup', onPointerUp); + surface.addEventListener('pointercancel', onPointerCancel); + }, + stop() { + surface.removeEventListener('pointerdown', onPointerDown); + surface.removeEventListener('pointermove', onPointerMove); + surface.removeEventListener('pointerup', onPointerUp); + surface.removeEventListener('pointercancel', onPointerCancel); + resetGesture(); + }, + }; +}); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 64d538f..826cec9 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -86,6 +86,7 @@ const SHELL = [ BASE + 'static/mobile-search-preview-nav.js', BASE + 'static/mobile-plan-today-nav.js', BASE + 'static/mobile-find-work-nav.js', + BASE + 'static/mobile-pull-refresh.js', BASE + 'static/checklist-conflict.js', BASE + 'static/voice-transcript-store.js', BASE + 'static/voice-issue-capture.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 49ddaeb..467c74e 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -33,7 +33,7 @@ FEATURE_SOURCES = { ), "security-center": ("static/security-center.js",), "today-timer": ( - "static/conversation.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/conversation.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/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.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-recap.js", "static/today-wrap-up.js", "static/today-handoff.js", "static/today-rollover.js", "static/later-work.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", diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index b2e0fc5..6af1484 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -29,6 +29,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( browser_errors: list[str] = [] failed_responses: list[str] = [] workspace_requests: list[str] = [] + live_requests: list[str] = [] try: with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright: @@ -56,6 +57,12 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( if "feature-today-timer-" in request.url else None, ) + page.on( + "request", + lambda request: live_requests.append(request.url) + if "/api/v1/live" in request.url + else None, + ) page.goto(origin + "/", wait_until="networkidle") page.locator('input[name="device_label"]').fill("Home bootstrap release phone") @@ -64,6 +71,29 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( page.wait_for_url(origin + "/", wait_until="networkidle") expect(page.locator("#my-work-status")).to_contain_text("2") + initial_live_requests = len(live_requests) + page.evaluate( + """ + () => { + window.scrollTo(0, 0); + const surface = document.querySelector('#my-work'); + surface.setPointerCapture = () => {}; + surface.releasePointerCapture = () => {}; + const pointer = (type, y) => surface.dispatchEvent(new PointerEvent(type, { + bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:y, + })); + pointer('pointerdown', 10); + pointer('pointermove', 90); + } + """ + ) + expect(page.locator("#mobile-pull-refresh")).to_have_text("Release to refresh") + page.evaluate( + "document.querySelector('#my-work').dispatchEvent(new PointerEvent('pointerup', " + "{bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:90}))" + ) + expect(page.locator("#mobile-pull-refresh")).to_have_text("My Work is up to date") + assert len(live_requests) == initial_live_requests + 1 dock = page.locator("#mobile-task-dock") expect(dock).to_be_visible() expect(dock.locator("button")).to_have_count(5) diff --git a/tests/test_mobile_pull_refresh.py b/tests/test_mobile_pull_refresh.py new file mode 100644 index 0000000..a7d0b97 --- /dev/null +++ b/tests/test_mobile_pull_refresh.py @@ -0,0 +1,188 @@ +import json +import subprocess +from pathlib import Path +import pytest + +from src.frontend_bundle import build_frontend +from tests.dashboard_bundle import dashboard + + +CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-pull-refresh.js" + + +def run_scenario(source: str) -> dict: + script = f""" +const createPullRefresh = require({json.dumps(str(CONTROLLER))}); +class FakeElement {{ + constructor() {{ + this.listeners = {{}}; + this.dataset = {{}}; + this.hidden = true; + this.textContent = ''; + }} + addEventListener(name, callback) {{ this.listeners[name] = callback; }} + removeEventListener(name) {{ delete this.listeners[name]; }} + setPointerCapture() {{}} + releasePointerCapture() {{}} +}} +const surface = new FakeElement(); +const indicator = new FakeElement(); +const plain = {{closest: () => null, parentElement: null, scrollWidth: 100, clientWidth: 100}}; +const event = (x, y, target=plain, id=1) => ({{ + pointerId:id, pointerType:'touch', clientX:x, clientY:y, target, + preventDefault() {{ this.prevented = true; }}, +}}); +{source} +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_mobile_pull_refresh_runs_one_refresh_after_vertical_threshold_and_announces_success(): + result = run_scenario(""" +let calls = 0; +const controller = createPullRefresh({ + surface, indicator, threshold:64, + refresh: async () => { calls += 1; }, + isMobile: () => true, + getScrollY: () => 0, + isBlocked: () => false, +}); +controller.start(); +surface.listeners.pointerdown(event(100, 10)); +surface.listeners.pointermove(event(102, 90)); +const ready = {hidden:indicator.hidden, state:indicator.dataset.state, text:indicator.textContent}; +const completion = surface.listeners.pointerup(event(102, 90)); +Promise.resolve(completion).then(() => process.stdout.write(JSON.stringify({ + calls, ready, + final:{hidden:indicator.hidden, state:indicator.dataset.state, text:indicator.textContent}, +}))); +""") + + assert result == { + "calls": 1, + "ready": {"hidden": False, "state": "ready", "text": "Release to refresh"}, + "final": {"hidden": False, "state": "success", "text": "My Work is up to date"}, + } + + +def test_mobile_pull_refresh_ignores_ineligible_gestures_and_short_pulls(): + result = run_scenario(""" +let calls = 0; +let mobile = true; +let scrollY = 0; +let blocked = false; +const controller = createPullRefresh({ + surface, indicator, threshold:64, + refresh: async () => { calls += 1; }, + isMobile: () => mobile, + getScrollY: () => scrollY, + isBlocked: () => blocked, +}); +controller.start(); +async function drag(fromX, fromY, toX, toY, target=plain) { + surface.listeners.pointerdown(event(fromX, fromY, target)); + surface.listeners.pointermove(event(toX, toY, target)); + await surface.listeners.pointerup(event(toX, toY, target)); +} +(async () => { + await drag(100, 10, 102, 50); + await drag(100, 10, 180, 30); + mobile = false; await drag(100, 10, 100, 100); + mobile = true; scrollY = 1; await drag(100, 10, 100, 100); + scrollY = 0; blocked = true; await drag(100, 10, 100, 100); + blocked = false; + const interactive = {closest: selector => selector ? interactive : null, parentElement:null, scrollWidth:100, clientWidth:100}; + await drag(100, 10, 100, 100, interactive); + const scroller = {closest: () => null, parentElement:null, scrollWidth:300, clientWidth:100}; + await drag(100, 10, 100, 100, scroller); + process.stdout.write(JSON.stringify({calls})); +})(); +""") + + assert result == {"calls": 0} + + +def test_mobile_pull_refresh_stays_single_flight_and_keeps_refreshing_status(): + result = run_scenario(""" +let calls = 0; +let finish; +const pending = new Promise(resolve => { finish = resolve; }); +const controller = createPullRefresh({ + surface, indicator, threshold:64, + refresh: () => { calls += 1; return pending; }, + isMobile: () => true, + getScrollY: () => 0, + isBlocked: () => false, +}); +controller.start(); +function pull(id) { + surface.listeners.pointerdown(event(100, 10, plain, id)); + surface.listeners.pointermove(event(100, 90, plain, id)); + return surface.listeners.pointerup(event(100, 90, plain, id)); +} +const first = pull(1); +Promise.resolve().then(async () => { + const second = pull(2); + const during = {calls, state:indicator.dataset.state, text:indicator.textContent}; + finish(); + await first; + await second; + process.stdout.write(JSON.stringify({calls, during, final:indicator.dataset.state})); +}); +""") + + assert result == { + "calls": 1, + "during": {"calls": 1, "state": "refreshing", "text": "Refreshing My Work…"}, + "final": "success", + } + + +def test_mobile_pull_refresh_announces_failure_and_allows_a_retry(): + result = run_scenario(""" +let calls = 0; +const controller = createPullRefresh({ + surface, indicator, threshold:64, + refresh: async () => { calls += 1; if (calls === 1) throw new Error('offline secret'); }, + isMobile: () => true, + getScrollY: () => 0, + isBlocked: () => false, +}); +controller.start(); +async function pull(id) { + surface.listeners.pointerdown(event(100, 10, plain, id)); + surface.listeners.pointermove(event(100, 90, plain, id)); + try { await surface.listeners.pointerup(event(100, 90, plain, id)); } catch (_) {} +} +(async () => { + await pull(1); + const failed = {state:indicator.dataset.state, text:indicator.textContent}; + await pull(2); + process.stdout.write(JSON.stringify({calls, failed, final:indicator.dataset.state})); +})(); +""") + + assert result == { + "calls": 2, + "failed": {"state": "error", "text": "Could not refresh · pull to retry"}, + "final": "success", + } + + +@pytest.mark.anyio +async def test_mobile_pull_refresh_is_wired_to_live_refresh_and_packaged_offline(): + html = await dashboard() + frontend = CONTROLLER.parent + build = build_frontend(frontend) + today_bundle = build.feature_bundles["today-timer"].runtime_bytes.decode() + + assert 'id="mobile-pull-refresh"' in html + assert 'role="status"' in html + assert html.index('static/mobile-pull-refresh.js') < html.index('static/dashboard.js') + assert "createMobilePullRefresh" in html + assert "contextPoller.refresh({ force: true })" in html + assert "createMobilePullRefresh" in today_bundle + assert "liveDataStatus" in today_bundle + assert build.feature_bundles["today-timer"].runtime_name in build.service_worker_source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index a0dd3ad..dfe3bba 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -1023,6 +1023,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/mobile-search-preview-nav.js", "/dashboard/static/mobile-plan-today-nav.js", "/dashboard/static/mobile-find-work-nav.js", + "/dashboard/static/mobile-pull-refresh.js", "/dashboard/static/checklist-conflict.js", "/dashboard/static/voice-transcript-store.js", "/dashboard/static/voice-issue-capture.js",