From 77fab85a66563e8159c7438f3f6635753fd13270 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 12 Aug 2026 19:24:55 +0000 Subject: [PATCH] feat: search the full Find Work catalog (Closes #673) --- frontend/dashboard.css | 3 ++ frontend/dashboard.js | 32 ++++++++++++ frontend/index.html | 6 +++ frontend/pick-work.js | 23 +++++++-- frontend/service-worker.js | 2 +- src/main.py | 20 ++++++-- tests/test_batch_find_work.py | 59 +++++++++++++++++++++++ tests/test_comment_next.py | 2 +- tests/test_frontend_bundle.py | 2 +- tests/test_gitea_work_search.py | 46 ++++++++++++++++++ tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 20 ++++---- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 18 files changed, 201 insertions(+), 28 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index f664416..e536d4e 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -455,6 +455,9 @@ textarea { resize: vertical; min-height: 120px; } .find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } .find-work-header-actions { display:flex; gap:8px; } .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; } +.find-work-search input, .find-work-search button { min-height:44px; } .find-work-list { display:grid; gap:10px; } .find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; } .find-work-card.selected { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 741b0bc..6e17bdf 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -593,6 +593,14 @@ renderAvailableIssues(findWorkController.items()); }, }); + let findWorkSearchTimer = null; + + function updateFindWorkMatchStatus() { + const query = findWorkController.query(); + qs('#find-work-match-status').textContent = query ? + availablePagination.total + ' match' + (availablePagination.total === 1 ? '' : 'es') + + ' for “' + query + '”.' : ''; + } function setStatus(msg) { qs('#status').textContent = msg || 'Live'; } function setClock() { qs('#clock').textContent = fmt(new Date()); } @@ -4222,6 +4230,30 @@ qs('#find-work').addEventListener('click', openFindWorkSheet); qs('#close-find-work').addEventListener('click', closeFindWorkSheet); qs('#select-find-work').addEventListener('click', () => findWorkController.startSelection()); + qs('#find-work-search-form').addEventListener('submit', event => event.preventDefault()); + qs('#find-work-search').addEventListener('input', event => { + const value = event.currentTarget.value; + qs('#clear-find-work-search').hidden = !value; + clearTimeout(findWorkSearchTimer); + findWorkSearchTimer = setTimeout(async () => { + qs('#find-work-status').textContent = value.trim() ? + 'Searching available issues…' : 'Loading available issues…'; + try { + await findWorkController.search(value); + updateFindWorkMatchStatus(); + qs('#find-work-status').textContent = availablePagination.total ? + 'Available work loaded.' : 'No matching unassigned issues.'; + } catch (error) { + qs('#find-work-status').textContent = error.message + ' Retry search.'; + } + }, 250); + }); + qs('#clear-find-work-search').addEventListener('click', () => { + const input = qs('#find-work-search'); + input.value = ''; + 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 => { event.currentTarget.disabled = true; diff --git a/frontend/index.html b/frontend/index.html index 782cc77..8393a41 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -535,6 +535,12 @@

Claim an available issue and continue it in My Work.

+
Open Find Work to load available issues.
diff --git a/frontend/pick-work.js b/frontend/pick-work.js index 59d883a..43426a1 100644 --- a/frontend/pick-work.js +++ b/frontend/pick-work.js @@ -2,6 +2,8 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio let available = []; let pagination = { page: 1, total: 0, has_more: false }; let loadRequest = null; + let loadGeneration = 0; + let query = ''; let claimRequest = null; const previewed = new Set(); let selecting = false; @@ -41,11 +43,14 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio onPagination({ ...pagination }); } - function loadPage(page, append) { - if (loadRequest) return loadRequest; - loadRequest = fetchJson('api/v1/available-issues?page=' + page, { + function loadPage(page, append, requestedQuery = query) { + if (loadRequest && requestedQuery === query && append) return loadRequest; + const generation = ++loadGeneration; + const queryPart = requestedQuery ? '&q=' + encodeURIComponent(requestedQuery) : ''; + const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, { headers: { Accept: 'application/json' }, }).then(result => { + if (generation !== loadGeneration) return result; apply(result, append); if (result?.refresh_failed === true) { onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.'); @@ -53,8 +58,9 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio onStatus('Showing saved available work while the catalog refreshes…'); } return result; - }).finally(() => { loadRequest = null; }); - return loadRequest; + }).finally(() => { if (loadRequest === request) loadRequest = null; }); + loadRequest = request; + return request; } return { @@ -68,6 +74,13 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio if (!pagination.has_more) return Promise.resolve(false); return loadPage(pagination.page + 1, true); }, + search(value) { + query = String(value || '').trim(); + return loadPage(1, false, query); + }, + query() { + return query; + }, items() { return available.slice(); }, diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 15db6cc..8e86097 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v97'; +const CACHE = 'stackchain-dashboard-shell-v98'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; diff --git a/src/main.py b/src/main.py index e823f49..f619255 100644 --- a/src/main.py +++ b/src/main.py @@ -2721,7 +2721,18 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]: raise -def _available_issue_page(items: list[dict], page: int, limit: int = 50) -> dict: +def _available_issue_page( + items: list[dict], page: int, limit: int = 50, query: str = "" +) -> dict: + normalized_query = query.strip().casefold() + if normalized_query: + number_query = normalized_query.removeprefix("#") + items = [ + item for item in items + if normalized_query in str(item.get("repository") or "").casefold() + or normalized_query in str(item.get("title") or "").casefold() + or (number_query.isdigit() and number_query == str(item.get("number") or "")) + ] start = (page - 1) * limit page_items = items[start:start + limit] return { @@ -2733,7 +2744,10 @@ def _available_issue_page(items: list[dict], page: int, limit: int = 50) -> dict @app.get("/api/v1/available-issues") -async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONResponse: +async def available_issues( + page: int = Query(default=1, ge=1, le=100), + q: str = Query(default="", max_length=100), +) -> JSONResponse: try: items, stale, revalidating, refresh_failed = await asyncio.wait_for( _available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS @@ -2744,7 +2758,7 @@ async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONRe status_code=503, headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))}, ) - result = _available_issue_page(items, page) + result = _available_issue_page(items, page, query=q) if stale: result["stale"] = True if revalidating: diff --git a/tests/test_batch_find_work.py b/tests/test_batch_find_work.py index 2780a35..aa14829 100644 --- a/tests/test_batch_find_work.py +++ b/tests/test_batch_find_work.py @@ -155,3 +155,62 @@ def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset(): assert "env(safe-area-inset-bottom)" in css assert "BASE + 'static/batch-find-work.js'" in worker assert '' in html + + +def test_find_work_search_ignores_stale_response_and_preserves_cross_query_selection(): + script = f""" +const createFindWork=require({json.dumps(str(PICK_WORK))}); +const pending=[]; +const rendered=[]; +const fetchJson=path=>new Promise(resolve=>pending.push({{path,resolve}})); +const controller=createFindWork({{ + fetchJson, + onItems:items=>rendered.push(items.map(item=>item.number)), + onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}}, +}}); +const first=controller.search('api'); +const second=controller.search('dashboard'); +pending[1].resolve({{items:[{{id:2,repository:'stackchain/dashboard',number:673}}],page:1,total:1,has_more:false}}); +second.then(()=>{{ + controller.startSelection(); + controller.toggleSelection(controller.items()[0]); + const third=controller.search('worker'); + pending[2].resolve({{items:[{{id:3,repository:'stackchain/worker',number:674}}],page:1,total:1,has_more:false}}); + return third; +}}).then(()=>{{ + controller.toggleSelection(controller.items()[0]); + pending[0].resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false}}); + return first; +}}).then(()=>process.stdout.write(JSON.stringify({{ + calls:pending.map(entry=>entry.path), + rendered, + items:controller.items().map(item=>item.number), + selected:controller.selectedItems().map(item=>item.number), +}}))); +""" + + assert run_node(script) == { + "calls": [ + "api/v1/available-issues?page=1&q=api", + "api/v1/available-issues?page=1&q=dashboard", + "api/v1/available-issues?page=1&q=worker", + ], + "rendered": [[673], [674]], + "items": [674], + "selected": [673, 674], + } + + +def test_mobile_find_work_search_has_clear_live_results_and_touch_targets(): + html = HTML.read_text() + dashboard = DASHBOARD.read_text() + css = CSS.read_text() + + assert 'id="find-work-search"' in html + assert 'id="clear-find-work-search"' in html + assert 'id="find-work-match-status"' in html + assert 'aria-live="polite"' in html + assert "findWorkController.search(" in dashboard + assert "#find-work-search" in dashboard + assert ".find-work-search" in css + assert ".find-work-search button" in css and "min-height:44px" in css diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 0ef91ec..f7ac843 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v97" in worker + assert "stackchain-dashboard-shell-v98" in worker diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index cb342b4..ab3bac1 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -187,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path): worker = changed_frontend / "service-worker.js" worker.write_text( worker.read_text().replace( - "const CACHE = 'stackchain-dashboard-shell-v97';", + "const CACHE = 'stackchain-dashboard-shell-v98';", "const CACHE = 'stackchain-dashboard-shell-v999';", ) ) diff --git a/tests/test_gitea_work_search.py b/tests/test_gitea_work_search.py index 2bbc00f..b0a6791 100644 --- a/tests/test_gitea_work_search.py +++ b/tests/test_gitea_work_search.py @@ -215,6 +215,52 @@ async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkey assert calls == [True] +@pytest.mark.anyio +async def test_available_issue_search_filters_full_catalog_before_pagination(monkeypatch): + async def available(): + return [ + { + "repository": "stackchain/api", + "number": number, + "title": "Routine API maintenance", + } + for number in range(1, 52) + ] + [{ + "repository": "stackchain/dashboard", + "number": 673, + "title": "Search the full Find Work catalog", + }] + + monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + by_repo = await client.get("/api/v1/available-issues?page=1&q=DASHBOARD") + by_number = await client.get("/api/v1/available-issues?page=1&q=%23673") + by_title = await client.get("/api/v1/available-issues?page=1&q=full%20find") + + expected = [{ + "repository": "stackchain/dashboard", + "number": 673, + "title": "Search the full Find Work catalog", + }] + assert by_repo.json() == {"items": expected, "page": 1, "total": 1, "has_more": False} + assert by_number.json() == by_repo.json() + assert by_title.json() == by_repo.json() + + +@pytest.mark.anyio +async def test_available_issue_search_rejects_oversized_query_without_scanning(monkeypatch): + async def must_not_scan(): + raise AssertionError("invalid query must be rejected before catalog scan") + + monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/available-issues", params={"q": "x" * 101}) + + assert response.status_code == 422 + + @pytest.mark.anyio async def test_available_issue_endpoint_reuses_catalog_published_by_another_worker( monkeypatch, tmp_path diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 5e86229..4f5dafe 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v97" in source + assert "stackchain-dashboard-shell-v98" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index c009696..dbffa7b 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v97" in worker + assert "stackchain-dashboard-shell-v98" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 1874070..29a0e9c 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v97" in worker + assert "stackchain-dashboard-shell-v98" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index 4adb96d..0efe8d5 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -186,7 +186,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "promptStorage:localStorage" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v97" in worker + assert "stackchain-dashboard-shell-v98" in worker assert ".device-setup-panel" in css assert ".device-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 47c2204..1526e23 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history( def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v97" in source + assert "stackchain-dashboard-shell-v98" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 83039ed..b0ec602 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -145,7 +145,7 @@ async function dispatchPush(payload) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v97" in source + assert "stackchain-dashboard-shell-v98" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -154,14 +154,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v97" in source + assert "stackchain-dashboard-shell-v98" in source assert "BASE + 'static/dashboard.js'" in source def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v97" in source + assert "stackchain-dashboard-shell-v98" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -169,7 +169,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v97" in source + assert "stackchain-dashboard-shell-v98" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -177,14 +177,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v97" in source + assert "stackchain-dashboard-shell-v98" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v97" in source + assert "stackchain-dashboard-shell-v98" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -193,21 +193,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v97" in source + assert "stackchain-dashboard-shell-v98" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v97" in source + assert "stackchain-dashboard-shell-v98" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v97" in source + assert "stackchain-dashboard-shell-v98" in source assert "BASE + 'static/update-ownership.js'" in source @@ -654,7 +654,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain(): def test_queue_today_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v97" in source + assert "stackchain-dashboard-shell-v98" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 363feff..687a3e9 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v97';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v98';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 6692203..85debdb 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}}); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v97" in source + assert "stackchain-dashboard-shell-v98" in source assert "BASE + 'static/today-sync.js'" in source