diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 980ddc6..74ec847 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -617,9 +617,21 @@ textarea { resize: vertical; min-height: 120px; } .work-session-nav button { min-height:44px; width:100%; } .find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } .find-work-sheet.open { display:flex; } -.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; } +.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; } .find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } .find-work-header-actions { display:flex; gap:8px; } +.mobile-find-work-nav { position:sticky; top:0; z-index:4; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:6px; padding:6px 0; background:#0b1526; } +.mobile-find-work-nav button { min-height:44px; min-width:0; padding:6px; } +.mobile-find-work-nav button[aria-current="step"] { border-color:#60a5fa; background:#17355b; color:#fff; } +.mobile-find-work-nav button:disabled { opacity:.5; } +.find-work-stage { display:grid; gap:12px; min-width:0; outline:none; } +.find-work-stage[hidden] { display:none; } +.find-work-review { padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; } +.find-work-review > div:first-child, .find-work-review-list { display:grid; gap:8px; } +.find-work-review-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; align-items:center; padding:10px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; } +.find-work-review-item > div { display:grid; gap:4px; min-width:0; } +.find-work-review-item button, .find-work-stage-actions button { min-height:44px; } +.find-work-stage-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 env(safe-area-inset-bottom); background:#101f36; } .find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; } .find-work-search { position:sticky; top:0; z-index:2; display:grid; gap:6px; padding:8px 0; background:#0b1526; } .find-work-search > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 1cd6859..620c9cb 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1095,6 +1095,7 @@ } return offlineToday.warm(confirmedOwnerLogin, todayMyWork); } + let findWorkNavigation = null; const findWorkController = createFindWork({ fetchJson: fetchReviewJson, onItems: renderAvailableIssues, @@ -1109,9 +1110,20 @@ qs('#find-work-selection-status').textContent = state.count ? state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No work selected.'; qs('#claim-selected-work').disabled = state.count === 0; + findWorkNavigation?.sync({ selectedCount:state.count }); renderAvailableIssues(findWorkController.items()); }, }); + + findWorkNavigation = createMobileFindWorkNavigation({ + document, + history:window.history, + eventTarget:window, + controller:findWorkController, + todayWork, + openFit:openFindWorkEstimateReview, + }); + findWorkNavigation.start(); let findWorkSearchTimer = null; function updateFindWorkMatchStatus() { @@ -2192,26 +2204,22 @@ )); } - function closeFindWorkEstimateReview() { - qs('#find-work-estimate-review').hidden = true; - qs('#batch-find-work-actions').hidden = !findWorkController.selection().active; - } - function openFindWorkEstimateReview(items) { const plan = todayWork.planning(); const planned = Object.values(plan.estimates).reduce((sum, minutes) => sum + minutes, 0); - qs('#find-work-estimate-summary').textContent = formatPlanMinutes(Math.max(0, plan.capacity_minutes - planned)) + - ' remaining. Estimate selected work before assigning it.'; + const estimatesRequired = Number.isInteger(plan.capacity_minutes) && plan.capacity_minutes > 0; + qs('#find-work-estimate-summary').textContent = estimatesRequired ? + formatPlanMinutes(Math.max(0, plan.capacity_minutes - planned)) + + ' remaining. Estimate selected work before assigning it.' : + items.length + ' selected. Confirm to assign and queue this batch in Today.'; qs('#find-work-estimate-list').innerHTML = items.map(item => { const id = String(item.repository || '') + '#' + String(item.number || ''); return '
' + escapeHtml(id) + - '' + escapeHtml(item.title || 'Untitled work') + '
'; + '' + escapeHtml(item.title || 'Untitled work') + '' + + (estimatesRequired ? '' : '') + ''; }).join(''); - qs('#batch-find-work-actions').hidden = true; - qs('#find-work-estimate-review').hidden = false; - qs('[data-find-work-estimate]')?.focus(); + if (estimatesRequired) qs('[data-find-work-estimate]')?.focus(); } function formatCalendarDueDate(value) { @@ -4290,6 +4298,7 @@ } findingWork = true; qs('#find-work-sheet').classList.add('open'); + findWorkNavigation.open(batchFindWork.pending() > 0); const retainedItems = findWorkController.items(); if (retainedItems.length) renderAvailableIssues(retainedItems); else qs('#find-work-list').textContent = ''; @@ -5635,15 +5644,13 @@ }); qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal')); qs('#find-work').addEventListener('click', openFindWorkSheet); - qs('#close-find-work').addEventListener('click', closeFindWorkSheet); - qs('#select-find-work').addEventListener('click', () => findWorkController.startSelection()); + qs('#fill-find-work-today').addEventListener('click', () => { const remainingSlots = todayWork.limit - todayWork.read().length; const outcome = findWorkController.fillSelection(remainingSlots); qs('#find-work-status').textContent = remainingSlots < 1 ? 'Today is full.' : outcome.selected ? outcome.selected + ' ranked work selected.' : 'No matching work.'; - if (outcome.selected && todayWork.planning().capacity_minutes !== null) - openFindWorkEstimateReview(findWorkController.selectedItems()); + if (outcome.selected) findWorkNavigation.go('review'); }); qs('#find-work-search-form').addEventListener('submit', event => event.preventDefault()); qs('#find-work-search').addEventListener('input', event => { @@ -5669,33 +5676,7 @@ input.dispatchEvent(new Event('input', { bubbles:true })); input.focus(); }); - qs('#cancel-find-work-selection').addEventListener('click', () => findWorkController.cancelSelection()); - qs('#claim-selected-work').addEventListener('click', async event => { - if (todayWork.planning().capacity_minutes !== null) { - openFindWorkEstimateReview(findWorkController.selectedItems()); - return; - } - event.currentTarget.disabled = true; - const outcome = await batchFindWork.run(findWorkController.selectedItems()); - if (outcome.status === 'complete') { - const assignedOnly = outcome.failed.filter(item => item.assigned).length; - const unavailable = outcome.failed.length - assignedOnly; - qs('#find-work-status').textContent = outcome.queued.length + ' queued' + - (unavailable ? ' · ' + unavailable + ' unavailable' : '') + - (assignedOnly ? ' · ' + assignedOnly + ' assigned but not queued' : '') + '.'; - if (outcome.failed.length) { - renderAvailableIssues(findWorkController.items()); - event.currentTarget.disabled = false; - } else { - findWorkController.cancelSelection(); - } - refreshMyWorkView(); - } else { - event.currentTarget.disabled = false; - } - }); - qs('#cancel-find-work-estimates').addEventListener('click', closeFindWorkEstimateReview); qs('#confirm-find-work-estimates').addEventListener('click', async event => { event.currentTarget.disabled = true; const outcome = await batchFindWork.run(findWorkController.selectedItems(), findWorkEstimateValues()); @@ -5706,10 +5687,18 @@ return; } if (outcome.status !== 'complete') return; + const assignedOnly = outcome.failed.filter(item => item.assigned).length; + const unavailable = outcome.failed.length - assignedOnly; qs('#find-work-status').textContent = outcome.queued.length + ' queued' + - (outcome.failed.length ? ' · ' + outcome.failed.length + ' unavailable.' : '.'); - closeFindWorkEstimateReview(); - if (!outcome.failed.length) findWorkController.cancelSelection(); + (unavailable ? ' · ' + unavailable + ' unavailable' : '') + + (assignedOnly ? ' · ' + assignedOnly + ' assigned but not queued' : '') + '.'; + if (!outcome.failed.length) { + findWorkNavigation.complete(); + findWorkController.cancelSelection(); + } else { + renderAvailableIssues(findWorkController.items()); + findWorkNavigation.sync({ selectedCount:findWorkController.selection().count, recovery:true }); + } refreshMyWorkView(); }); qs('#load-more-available').addEventListener('click', async event => { diff --git a/frontend/index.html b/frontend/index.html index 2bcebe1..bc337f1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -843,42 +843,60 @@
Open · unassigned

Find work

-

Claim an available issue and continue it in My Work.

- -
- Filter work -
-
Repositories
-
Labels
- -
-
- -
Open Find Work to load available issues.
- -
- - @@ -1596,6 +1614,7 @@ + diff --git a/frontend/mobile-find-work-nav.js b/frontend/mobile-find-work-nav.js new file mode 100644 index 0000000..9a17623 --- /dev/null +++ b/frontend/mobile-find-work-nav.js @@ -0,0 +1,194 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + else root.createMobileFindWorkNavigation = api; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + const stages = ['discover', 'review', 'fit']; + + return function createMobileFindWorkNavigation(options) { + const document = options.document; + const buttons = options.buttons || Object.fromEntries(stages.map(name => + [name, document?.querySelector('[data-find-work-stage="' + name + '"]')] + )); + const sections = options.sections || Object.fromEntries(stages.map(name => + [name, document?.getElementById('find-work-' + name + '-stage')] + )); + const history = options.history; + const eventTarget = options.eventTarget; + const onStageChange = options.onStageChange || (() => {}); + const listeners = new Map(); + let current = 'discover'; + let selectedCount = 0; + let recovery = false; + let started = false; + + function escape(value) { + return String(value || '').replace(/[&<>"']/g, character => + ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[character] + ); + } + + function renderReview() { + const reviewList = options.reviewList || document?.getElementById('find-work-review-list'); + const reviewSummary = options.reviewSummary || document?.getElementById('find-work-review-summary'); + if (!reviewList) return; + const items = options.controller?.selectedItems?.() || options.getSelectedItems?.() || []; + const planning = options.todayWork?.planning?.(); + const today = options.todayWork ? { + limit:options.todayWork.limit, count:options.todayWork.read().length, + capacityMinutes:planning.capacity_minutes, estimates:planning.estimates, + } : options.getTodayState?.() || {}; + const remainingSlots = Math.max(0, (Number(today.limit) || 0) - (Number(today.count) || 0)); + const planned = Object.values(today.estimates || {}).reduce((sum, minutes) => sum + minutes, 0); + const remainingMinutes = today.capacityMinutes === null || today.capacityMinutes === undefined ? null : + Math.max(0, today.capacityMinutes - planned); + const formatMinutes = minutes => [Math.floor(minutes / 60) ? Math.floor(minutes / 60) + 'h' : '', + minutes % 60 ? minutes % 60 + 'm' : ''].filter(Boolean).join(' ') || '0m'; + if (reviewSummary) reviewSummary.textContent = items.length + ' selected · ' + remainingSlots + + ' Today slot' + (remainingSlots === 1 ? '' : 's') + ' open' + + (remainingMinutes === null ? '' : ' · ' + formatMinutes(remainingMinutes) + ' available') + '.'; + reviewList.innerHTML = items.map(item => { + const id = String(item.repository || '') + '#' + String(item.number || ''); + return '
' + escape(id) + + '' + escape(item.title || 'Untitled work') + '
' + + '
'; + }).join(''); + reviewList.querySelectorAll('[data-find-work-review-remove]').forEach(button => + button.addEventListener('click', () => { + const item = items.find(candidate => String(candidate.repository || '') + '#' + + String(candidate.number || '') === button.dataset.findWorkReviewRemove); + if (item) (options.controller?.toggleSelection || options.toggleSelection)?.(item); + }) + ); + } + + function allowed(name) { + return stages.includes(name) && (name === 'discover' || selectedCount > 0 || recovery); + } + + function paint(name, focus = false, notify = true) { + if (!allowed(name)) name = 'discover'; + current = name; + stages.forEach(stage => { + const button = buttons[stage]; + const section = sections[stage]; + if (button) { + button.disabled = stage !== 'discover' && selectedCount === 0 && !recovery; + if (stage === name) button.setAttribute('aria-current', 'step'); + else button.removeAttribute('aria-current'); + } + if (section) section.hidden = stage !== name; + }); + if (name === 'review') renderReview(); + if (name === 'fit') options.openFit?.(options.controller?.selectedItems?.() || []); + if (notify) onStageChange(name); + if (focus) sections[name]?.focus?.(); + return name; + } + + function stateFor(name) { + const state = { ...(history?.state || {}) }; + if (name === 'discover') delete state.findWorkStage; + else state.findWorkStage = name; + return state; + } + + function go(name) { + if (!allowed(name) || name === current) return false; + const currentIndex = stages.indexOf(current); + const nextIndex = stages.indexOf(name); + if (nextIndex < currentIndex && history?.go) { + history.go(nextIndex - currentIndex); + return true; + } + if (nextIndex === currentIndex - 1 && history?.back) { + history.back(); + return true; + } + history?.pushState?.(stateFor(name), ''); + paint(name, true); + return true; + } + + function onPopState(event) { + if (event.state?.taskOverlay !== 'find') return; + paint(event.state?.findWorkStage || 'discover', true); + } + + function sync(state = {}) { + selectedCount = Math.max(0, Number(state.selectedCount) || 0); + recovery = state.recovery === true; + if (!allowed(current)) paint('discover'); + else paint(current, false, false); + } + + function open(hasRecovery) { + if (options.controller) { + selectedCount = options.controller.selection().count; + recovery = hasRecovery === true; + } + const restored = history?.state?.taskOverlay === 'find' ? history.state.findWorkStage : null; + return paint(allowed(restored) ? restored : 'discover'); + } + + function complete() { + selectedCount = 0; + recovery = false; + history?.replaceState?.(stateFor('discover'), ''); + return paint('discover'); + } + + function back() { + if (current === 'discover') return false; + history?.back?.(); + return true; + } + + return { + start() { + if (started) return; + started = true; + stages.forEach(name => { + const button = buttons[name]; + if (!button) return; + const listener = event => { event.preventDefault(); go(name); }; + listeners.set(button, listener); + button.addEventListener('click', listener); + }); + if (document) { + const bind = (id, action) => { + const button = document.getElementById(id); + if (!button) return; + const listener = event => { event.preventDefault(); action(); }; + listeners.set(button, listener); + button.addEventListener('click', listener); + }; + bind('select-find-work', () => { complete(); options.controller?.startSelection?.(); }); + bind('cancel-find-work-selection', () => { complete(); options.controller?.cancelSelection?.(); }); + bind('claim-selected-work', () => go('review')); + bind('continue-find-work-fit', () => go('fit')); + bind('back-find-work-discover', back); + bind('cancel-find-work-estimates', back); + bind('close-find-work', () => history?.go?.(-(stages.indexOf(current) + 1))); + } + eventTarget?.addEventListener?.('popstate', onPopState); + paint('discover'); + }, + stop() { + listeners.forEach((listener, button) => button.removeEventListener('click', listener)); + listeners.clear(); + eventTarget?.removeEventListener?.('popstate', onPopState); + started = false; + }, + sync, + open, + go, + back, + complete, + renderReview, + stage: () => current, + }; + }; +}); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index f8352f5..cd9a1e6 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -82,6 +82,7 @@ const SHELL = [ BASE + 'static/mobile-review-detail-nav.js', BASE + 'static/mobile-search-preview-nav.js', BASE + 'static/mobile-plan-today-nav.js', + BASE + 'static/mobile-find-work-nav.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 fde9756..864125b 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -30,7 +30,7 @@ FEATURE_SOURCES = { "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), "security-center": ("static/security-center.js",), "today-timer": ( - "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-plan-today-nav.js", + "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/today-completion.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-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-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/test_batch_find_work.py b/tests/test_batch_find_work.py index 222a9fb..411102e 100644 --- a/tests/test_batch_find_work.py +++ b/tests/test_batch_find_work.py @@ -9,6 +9,7 @@ HTML = Path(__file__).parents[1] / "frontend" / "index.html" DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js" CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css" WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js" +FIND_WORK_NAV = Path(__file__).parents[1] / "frontend" / "mobile-find-work-nav.js" def run_node(script): @@ -437,14 +438,15 @@ def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset(): dashboard = DASHBOARD.read_text() css = CSS.read_text() worker = WORKER.read_text() + navigation = FIND_WORK_NAV.read_text() assert 'id="select-find-work"' in html assert 'id="batch-find-work-actions"' in html assert 'id="claim-selected-work"' in html assert 'aria-live="polite"' in html assert "createBatchFindWork({" in dashboard - assert "findWorkController.startSelection()" in dashboard - assert "findWorkController.toggleSelection(item)" in dashboard + assert "options.controller?.startSelection?.()" in navigation + assert "options.controller?.toggleSelection" in navigation assert ".find-work-batch-actions" in css assert "position:sticky" in css assert "env(safe-area-inset-bottom)" in css @@ -472,7 +474,7 @@ def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff(): assert 'id="fill-find-work-today"' in html assert 'aria-describedby="find-work-selection-status"' in html assert "findWorkController.fillSelection(remainingSlots)" in dashboard - assert "openFindWorkEstimateReview(findWorkController.selectedItems())" in dashboard + assert "openFit:openFindWorkEstimateReview" in dashboard assert ".fill-find-work-today" in css and "min-height:44px" in css diff --git a/tests/test_mobile_find_work_navigation.py b/tests/test_mobile_find_work_navigation.py new file mode 100644 index 0000000..c9651e0 --- /dev/null +++ b/tests/test_mobile_find_work_navigation.py @@ -0,0 +1,135 @@ +import json +import subprocess +from pathlib import Path + + +CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-find-work-nav.js" +FRONTEND = CONTROLLER.parent + + +def run_navigation(scenario: str) -> dict: + script = f""" +const createNavigation = require({json.dumps(str(CONTROLLER))}); +class FakeElement {{ + constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.hidden=false; this.disabled=false; this.focused=0; }} + addEventListener(name, callback) {{ (this.listeners[name] ||= []).push(callback); }} + removeEventListener(name, callback) {{ this.listeners[name]=(this.listeners[name]||[]).filter(item=>item!==callback); }} + click() {{ for (const callback of this.listeners.click||[]) callback({{preventDefault(){{}}}}); }} + setAttribute(name,value) {{ this.attributes[name]=value; }} + removeAttribute(name) {{ delete this.attributes[name]; }} + focus() {{ this.focused++; }} +}} +class FakeEvents {{ + constructor() {{ this.listeners={{}}; }} + addEventListener(name, callback) {{ (this.listeners[name] ||= []).push(callback); }} + removeEventListener(name, callback) {{ this.listeners[name]=(this.listeners[name]||[]).filter(item=>item!==callback); }} + emit(name,event) {{ for (const callback of this.listeners[name]||[]) callback(event); }} +}} +{scenario} +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_find_work_navigation_gates_review_and_fit_until_work_is_selected(): + result = run_navigation(""" +const names=['discover','review','fit']; +const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)])); +const sections=Object.fromEntries(names.map(name=>[name,new FakeElement(name)])); +const events=new FakeEvents(); +const history={state:{taskOverlay:'find'}, pushes:[], backs:0, + pushState(state){this.state=state;this.pushes.push(state);}, back(){this.backs++;}}; +const navigation=createNavigation({buttons,sections,history,eventTarget:events,onStageChange:()=>{}}); +navigation.start(); +const initial={stage:navigation.stage(),reviewDisabled:buttons.review.disabled,fitDisabled:buttons.fit.disabled,hidden:Object.fromEntries(names.map(name=>[name,sections[name].hidden]))}; +navigation.sync({selectedCount:2,recovery:false}); +buttons.review.click(); +buttons.fit.click(); +process.stdout.write(JSON.stringify({initial,stage:navigation.stage(),pushes:history.pushes,selected:Object.fromEntries(names.map(name=>[name,buttons[name].attributes['aria-current']||null]))})); +""") + + assert result == { + "initial": { + "stage": "discover", + "reviewDisabled": True, + "fitDisabled": True, + "hidden": {"discover": False, "review": True, "fit": True}, + }, + "stage": "fit", + "pushes": [ + {"taskOverlay": "find", "findWorkStage": "review"}, + {"taskOverlay": "find", "findWorkStage": "fit"}, + ], + "selected": {"discover": None, "review": None, "fit": "step"}, + } + + +def test_browser_back_moves_fit_to_review_to_discover_without_losing_selection(): + result = run_navigation(""" +const names=['discover','review','fit']; +const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)])); +const sections=Object.fromEntries(names.map(name=>[name,new FakeElement(name)])); +const events=new FakeEvents(); const changes=[]; +const history={state:{taskOverlay:'find'},pushState(state){this.state=state;},back(){}}; +const navigation=createNavigation({buttons,sections,history,eventTarget:events,onStageChange:stage=>changes.push(stage)}); +navigation.start(); navigation.sync({selectedCount:1,recovery:false}); navigation.go('review'); navigation.go('fit'); +events.emit('popstate',{state:{taskOverlay:'find',findWorkStage:'review'}}); +const afterFirst=navigation.stage(); +events.emit('popstate',{state:{taskOverlay:'find'}}); +process.stdout.write(JSON.stringify({afterFirst,afterSecond:navigation.stage(),changes,reviewDisabled:buttons.review.disabled,fitDisabled:buttons.fit.disabled})); +""") + + assert result == { + "afterFirst": "review", + "afterSecond": "discover", + "changes": ["discover", "review", "fit", "review", "discover"], + "reviewDisabled": False, + "fitDisabled": False, + } + + +def test_reopening_restores_progress_but_completion_resets_to_discovery(): + result = run_navigation(""" +const names=['discover','review','fit']; +const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)])); +const sections=Object.fromEntries(names.map(name=>[name,new FakeElement(name)])); +const events=new FakeEvents(); +const history={state:{taskOverlay:'find',findWorkStage:'review'},replaced:[],pushState(state){this.state=state;},replaceState(state){this.state=state;this.replaced.push(state);},back(){}}; +const navigation=createNavigation({buttons,sections,history,eventTarget:events,onStageChange:()=>{}}); +navigation.start(); navigation.sync({selectedCount:1,recovery:false}); navigation.open(); +const restored=navigation.stage(); +navigation.complete(); +process.stdout.write(JSON.stringify({restored,completed:navigation.stage(),state:history.state,replaced:history.replaced})); +""") + + assert result == { + "restored": "review", + "completed": "discover", + "state": {"taskOverlay": "find"}, + "replaced": [{"taskOverlay": "find"}], + } + + +def test_find_work_ships_guided_mobile_flow_in_the_offline_bundle(): + html = (FRONTEND / "index.html").read_text() + css = (FRONTEND / "dashboard.css").read_text() + bundle = (FRONTEND.parent / "src" / "frontend_bundle.py").read_text() + service_worker = (FRONTEND / "service-worker.js").read_text() + dashboard = (FRONTEND / "dashboard.js").read_text() + + assert '