diff --git a/frontend/context-poller.js b/frontend/context-poller.js index c75ce83..e633480 100644 --- a/frontend/context-poller.js +++ b/frontend/context-poller.js @@ -126,7 +126,7 @@ function createContextPoller({ let request; try { - request = fetchContext({ ...revisions }, { signal: controller.signal }); + request = fetchContext(options.full ? {} : { ...revisions }, { signal: controller.signal }); } catch (error) { request = Promise.reject(error); } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 99d2bac..5864460 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -358,6 +358,7 @@ qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click()); qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click()); let liveMode = true; + let initialAccountRecovery = Promise.resolve(false); const WORK_FILTER_KEY = 'stackchain.my-work-filter.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']; @@ -5644,6 +5645,9 @@ activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : ''; if (activeFlushLogin) { confirmedOwnerLogin = activeFlushLogin; + planningOwnerLogin = activeFlushLogin; + planningOwnerAccountKey = snapshot.context.user?.id ? + String(snapshot.context.user.id) + ':' + activeFlushLogin : ''; mobileQueuePriority.render(); renderMobileQueuePresentation(); void mobileQueuePriority.load(); @@ -5666,7 +5670,10 @@ if (planningOwnerLogin) { syncPendingTomorrow(); todaySync.migrate(todayWork.read()); - todaySync.flush(); + initialAccountRecovery = Promise.all([ + initialAccountRecovery, + todaySync.flush(), + ]).then(() => true); laterSync.migrate(laterWork.read()); laterSync.flush(); } @@ -8597,6 +8604,20 @@ adoptedProgressiveSnapshot = await contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise); } if (!adoptedProgressiveSnapshot) await load(); + else if (!confirmedOwnerLogin) { + await contextPoller.refresh({ force:true, full:true }); + if (!confirmedOwnerLogin) { + try { + const identity = await fetchReviewJson('api/v1/background-identity'); + const login = String(identity?.login || '').trim(); + if (login) { + confirmedOwnerLogin = login; + planningOwnerLogin = login; + initialAccountRecovery = timerView.restore(todaySync.flush()); + } + } catch (_error) {} + } + } if (progressiveWorkHandoff?.openWork) { const progressiveItem = lastMyWork.find(item => item.repository === progressiveWorkHandoff.openWork.repository && @@ -8615,6 +8636,8 @@ function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); } setInterval(widgetTick, 1000); + await initialAccountRecovery; + if (planningOwnerLogin) await todaySync.flush(); await timerView.restore(Promise.resolve(true)); workspaceLifecycle.markWorkspaceReady?.(); })(); diff --git a/frontend/today-timer.js b/frontend/today-timer.js index 935a3ff..460dfac 100644 --- a/frontend/today-timer.js +++ b/frontend/today-timer.js @@ -489,7 +489,10 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu const snapshot = timer.snapshot(); breakView?.render(); const active = Boolean(progress && isActive() && snapshot.identity); - queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; }); + queryAll('[data-mobile-today-hud]').forEach(element => { + const coaching = element.querySelector?.('[data-mobile-first-task-coach]:not([hidden])'); + element.hidden = !active && !coaching; + }); queryAll('[data-mobile-today-open]').forEach(element => { element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : ''; }); diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index 75d7b0b..fafc02e 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -60,6 +60,7 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work( expect(sheet).to_be_hidden() expect(page.locator("#find-work-sheet")).to_be_visible() page.locator("#close-find-work").click() + expect(page.locator("#find-work-sheet")).to_be_hidden() context.set_offline(True) page.locator('[data-mobile-task="work"]').click() @@ -70,8 +71,9 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work( expect(page.locator("#mobile-first-task-create")).to_be_focused() page.locator("#close-mobile-first-task").click() + expect(sheet).to_be_hidden() context.set_offline(False) - page.evaluate( + probe_result = page.evaluate( """() => { document.querySelector('[data-mobile-today-hud]').hidden = false; localStorage.setItem('stackchain.first-task.v1:timmy', 'coaching'); @@ -82,17 +84,54 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work( getLogin: () => 'timmy', hasWork: () => true, isTodayActive: () => true, coach: isolatedCoach, }); - return window.firstTaskOutcomeProbe.refresh(); + return { + result: window.firstTaskOutcomeProbe.refresh(), + state: localStorage.getItem('stackchain.first-task.v1:timmy'), + mobile: matchMedia('(max-width: 600px)').matches, + }; }""" ) + assert probe_result == {"result": "coaching", "state": "coaching", "mobile": True} coach = page.locator("[data-mobile-first-task-coach]") - expect(coach).to_be_visible() + try: + expect(coach).to_be_visible() + except AssertionError as error: + raise AssertionError(page.evaluate("""() => { + const coach = document.querySelector('[data-mobile-first-task-coach]'); + const hud = coach.closest('[data-mobile-today-hud]'); + return { + coachHidden: coach.hidden, + coachDisplay: getComputedStyle(coach).display, + hudHidden: hud.hidden, + hudOverlay: hud.getAttribute('data-overlay-hidden'), + hudDisplay: getComputedStyle(hud).display, + open: Array.from(document.querySelectorAll('dialog, .open')) + .filter(element => element.open || element.classList.contains('open')) + .map(element => element.id || element.className), + }; + }""")) from error 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 + completion = page.evaluate("""() => { + const completed = window.firstTaskOutcomeProbe.completeOutcome(); + return { + completed, + status: document.querySelector('#today-sync-status').textContent, + pending: Object.keys(localStorage).filter(key => + key.startsWith('stackchain.today-sync.v1.timmy.operation.')), + }; + }""") + assert completion["completed"] is True + assert completion["pending"] + assert "sync pending" in completion["status"] expect(page.locator("#mobile-first-task-receipt")).to_be_visible() expect(coach).to_be_hidden() expect(page.locator("#today-sync-status")).to_contain_text("Today saved to account") + remote_activation = page.evaluate("""async () => { + const response = await fetch('api/v1/today'); + return (await response.json()).first_task_state; + }""") + assert remote_activation == "complete" assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") fresh_context = browser.new_context( diff --git a/tests/e2e/test_mobile_today_handoff_release.py b/tests/e2e/test_mobile_today_handoff_release.py index f51a658..92fb13f 100644 --- a/tests/e2e/test_mobile_today_handoff_release.py +++ b/tests/e2e/test_mobile_today_handoff_release.py @@ -278,7 +278,16 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path: assert page.evaluate("""() => !Object.keys(localStorage).some( key => key.startsWith('stackchain.today-sync.v1.timmy.operation.') )""") - expect(page.locator("#today-break-status")).to_contain_text("On break · resume in") + try: + expect(page.locator("#today-break-status")).to_contain_text("On break · resume in") + except AssertionError as error: + raise AssertionError(page.evaluate("""() => ({ + login: document.querySelector('#current-user')?.textContent, + timers: Object.fromEntries(Object.keys(localStorage) + .filter(key => key.startsWith('stackchain.today-timer.v1.')) + .map(key => [key, JSON.parse(localStorage.getItem(key))])), + status: document.querySelector('#today-break-status').outerHTML, + })""")) from error if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open": page.locator("#close-issue-sheet").click() if page.locator("#plan-today-sheet").is_visible(): diff --git a/tests/test_context_polling.py b/tests/test_context_polling.py index 03e2ba8..398476b 100644 --- a/tests/test_context_polling.py +++ b/tests/test_context_polling.py @@ -117,6 +117,32 @@ const poller = createContextPoller({{ } +def test_context_poller_can_force_a_full_identity_refresh_after_adoption(): + script = f""" +const createContextPoller = require({json.dumps(str(POLLER))}); +const requested = []; +const seed = {{ + context:{{user:{{login:'timmy'}}}}, events:[], notifications:[], + revisions:{{context:'0123456789abcdef.1',events:'fedcba9876543210.2'}}, +}}; +const poller = createContextPoller({{ + fetchContext: revisions => {{ + requested.push({{...revisions}}); + return Promise.resolve({{...seed, freshness:{{sections:{{context:{{degraded:false}}}}}}}}); + }}, + onSnapshot: () => {{}}, onError: error => {{ throw error; }}, + setTimer: () => 1, clearTimer: () => {{}}, +}}); +poller.adopt(seed); +(async () => {{ + await poller.refresh({{force:true, full:true}}); + process.stdout.write(JSON.stringify({{requested}})); +}})(); +""" + + assert run_node(script) == {"requested": [{}]} + + def test_context_poller_adopts_progressive_snapshot_before_revision_conditional_refresh(): script = f""" const createContextPoller = require({json.dumps(str(POLLER))}); @@ -200,6 +226,9 @@ def test_dashboard_adopts_progressive_snapshot_or_falls_back_to_immediate_load() assert "let adoptedProgressiveSnapshot = contextPoller.adopt(progressiveWorkHandoff?.liveSnapshot);" in source assert "contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise)" in source assert "if (!adoptedProgressiveSnapshot) await load();" in source + assert "contextPoller.refresh({ force:true, full:true })" in source + assert "fetchReviewJson('api/v1/background-identity')" in source + assert "initialAccountRecovery = timerView.restore(todaySync.flush());" in source def test_context_poller_uses_failed_section_retry_before_healthy_freshness_deadline():