diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index f99d6df..deaee06 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -458,6 +458,14 @@ textarea { resize: vertical; min-height: 120px; }
.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-filters { border:1px solid #2a496e; border-radius:10px; padding:10px; }
+.find-work-filters summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
+.find-work-filter-panel, .find-work-filter-options { display:grid; gap:8px; }
+.find-work-filter-panel fieldset { min-width:0; border:0; padding:6px 0; margin:0; }
+.find-work-filter-options { grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); }
+.find-work-filter-option { min-height:44px; display:flex; align-items:center; gap:8px; padding:6px; overflow-wrap:anywhere; }
+.find-work-filter-option input { width:20px; height:20px; flex:0 0 auto; }
+#clear-find-work-filters { min-height:44px; width:100%; }
.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/index.html b/frontend/index.html
index 6fb934c..66fa1b4 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -541,6 +541,14 @@
+
+ Filter work
+
+
+
+
+
+
Open Find Work to load available issues.
diff --git a/frontend/pick-work.js b/frontend/pick-work.js
index 73f38d6..99954b3 100644
--- a/frontend/pick-work.js
+++ b/frontend/pick-work.js
@@ -1,9 +1,11 @@
-function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {} }) {
+function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {}, onFacets = () => {} }) {
let available = [];
let pagination = { page: 1, total: 0, has_more: false };
let loadRequest = null;
let loadGeneration = 0;
let query = '';
+ let filters = { repositories: [], labels: [] };
+ let facetView;
let claimRequest = null;
const previewed = new Set();
let selecting = false;
@@ -41,12 +43,27 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio
};
onItems(available.slice());
onPagination({ ...pagination });
+ if (result?.facets) onFacets({
+ repositories: Array.isArray(result.facets.repositories) ? result.facets.repositories.slice() : [],
+ labels: Array.isArray(result.facets.labels) ? result.facets.labels.slice() : [],
+ });
+ if (result?.facets && typeof document !== 'undefined') {
+ facetView ||= attachFindWorkFacets(document.querySelector('#find-work-filters'), value =>
+ String(value).replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character])),
+ next => { filters = next; loadPage(1, false); });
+ facetView(result.facets);
+ }
}
function loadPage(page, append, requestedQuery = query) {
if (loadRequest && requestedQuery === query && append) return loadRequest;
const generation = ++loadGeneration;
- const queryPart = requestedQuery ? '&q=' + encodeURIComponent(requestedQuery) : '';
+ const params = new URLSearchParams();
+ params.set('facets', 'true');
+ if (requestedQuery) params.set('q', requestedQuery);
+ filters.repositories.forEach(value => params.append('repository', value));
+ filters.labels.forEach(value => params.append('label', value));
+ const queryPart = params.toString() ? '&' + params.toString() : '';
const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, {
headers: { Accept: 'application/json' },
}).then(result => {
@@ -81,6 +98,17 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio
query() {
return query;
},
+ setFilters(next) {
+ filters = {
+ repositories: Array.from(new Set((next?.repositories || []).map(String))).slice(0, 10),
+ labels: Array.from(new Set((next?.labels || []).map(String))).slice(0, 10),
+ };
+ return loadPage(1, false);
+ },
+ filters() {
+ return { repositories: filters.repositories.slice(), labels: filters.labels.slice() };
+ },
+
items() {
return available.slice();
},
@@ -163,4 +191,23 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio
};
}
+function attachFindWorkFacets(root, escapeHtml, onChange) {
+ const values = name => Array.from(root.querySelectorAll('input[name="' + name + '"]:checked'), input => input.value);
+ root.onchange = event => event.target.type === 'checkbox' && onChange({ repositories:values('find-work-repository'), labels:values('find-work-label') });
+ root.querySelector('#clear-find-work-filters').onclick = () => {
+ root.querySelectorAll('input').forEach(input => { input.checked = false; });
+ onChange({ repositories:[], labels:[] });
+ };
+ return facets => {
+ const active = { repositories:values('find-work-repository'), labels:values('find-work-label') };
+ const render = (id, choices, selected, name) => {
+ root.querySelector(id).innerHTML = choices.map(value => '').join('');
+ };
+ render('#find-work-repository-filters', facets.repositories || [], active.repositories, 'find-work-repository');
+ render('#find-work-label-filters', facets.labels || [], active.labels, 'find-work-label');
+ const count = active.repositories.length + active.labels.length;
+ root.querySelector('#find-work-filter-count').textContent = count ? '(' + count + ' active)' : '';
+ };
+}
+
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
diff --git a/src/main.py b/src/main.py
index f619255..2487116 100644
--- a/src/main.py
+++ b/src/main.py
@@ -2722,8 +2722,20 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
def _available_issue_page(
- items: list[dict], page: int, limit: int = 50, query: str = ""
+ items: list[dict], page: int, limit: int = 50, query: str = "",
+ repositories: list[str] | None = None, labels: list[str] | None = None,
+ include_facets: bool = False,
) -> dict:
+ facets = {
+ "repositories": sorted({str(item.get("repository")) for item in items if item.get("repository")}, key=str.casefold),
+ "labels": sorted({str(label) for item in items for label in (item.get("labels") or [])}, key=str.casefold),
+ }
+ repository_filter = {value.casefold() for value in (repositories or [])}
+ label_filter = {value.casefold() for value in (labels or [])}
+ if repository_filter:
+ items = [item for item in items if str(item.get("repository") or "").casefold() in repository_filter]
+ if label_filter:
+ items = [item for item in items if label_filter & {str(label).casefold() for label in (item.get("labels") or [])}]
normalized_query = query.strip().casefold()
if normalized_query:
number_query = normalized_query.removeprefix("#")
@@ -2735,19 +2747,27 @@ def _available_issue_page(
]
start = (page - 1) * limit
page_items = items[start:start + limit]
- return {
+ result = {
"items": page_items,
"page": page,
"total": len(items),
"has_more": start + len(page_items) < len(items),
}
+ if include_facets or repository_filter or label_filter:
+ result["facets"] = facets
+ return result
@app.get("/api/v1/available-issues")
async def available_issues(
page: int = Query(default=1, ge=1, le=100),
q: str = Query(default="", max_length=100),
+ repository: list[str] = Query(default=[], max_length=100),
+ label: list[str] = Query(default=[], max_length=100),
+ facets: bool = Query(default=False),
) -> JSONResponse:
+ if len(repository) > 10 or len(label) > 10:
+ return JSONResponse({"error": "Select at most 10 repositories or labels."}, status_code=422)
try:
items, stale, revalidating, refresh_failed = await asyncio.wait_for(
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
@@ -2758,7 +2778,9 @@ async def available_issues(
status_code=503,
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
)
- result = _available_issue_page(items, page, query=q)
+ result = _available_issue_page(
+ items, page, query=q, repositories=repository, labels=label, include_facets=facets
+ )
if stale:
result["stale"] = True
if revalidating:
diff --git a/tests/test_batch_find_work.py b/tests/test_batch_find_work.py
index 1571673..46406d3 100644
--- a/tests/test_batch_find_work.py
+++ b/tests/test_batch_find_work.py
@@ -269,7 +269,7 @@ const pages={{
}};
const controller=createFindWork({{
fetchJson:path=>path.includes('/claim') ? Promise.resolve({{repository:'stackchain/dashboard',number:671,assignees:['timmy']}}) :
- Promise.resolve(pages[path.endsWith('=2') ? 2 : 1]),
+ Promise.resolve(pages[path.includes('page=2') ? 2 : 1]),
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
}});
controller.load().then(()=>{{
@@ -426,9 +426,9 @@ second.then(()=>{{
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",
+ "api/v1/available-issues?page=1&facets=true&q=api",
+ "api/v1/available-issues?page=1&facets=true&q=dashboard",
+ "api/v1/available-issues?page=1&facets=true&q=worker",
],
"rendered": [[673], [674]],
"items": [674],
@@ -449,3 +449,40 @@ def test_mobile_find_work_search_has_clear_live_results_and_touch_targets():
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
+
+
+def test_find_work_facets_serialize_filters_and_preserve_selection():
+ script = f"""
+const createFindWork=require({json.dumps(str(PICK_WORK))});
+const calls=[];
+const controller=createFindWork({{
+ fetchJson:path=>{{ calls.push(path); return Promise.resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false,facets:{{repositories:['stackchain/api'],labels:['critical']}}}}); }},
+ onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}}, onFacets:()=>{{}},
+}});
+controller.reset({{items:[{{id:9,repository:'stackchain/old',number:9}}],page:1,total:1,has_more:false}});
+controller.startSelection(); controller.toggleSelection(controller.items()[0]);
+controller.setFilters({{repositories:['stackchain/api'],labels:['critical']}}).then(()=>process.stdout.write(JSON.stringify({{
+ calls, filters:controller.filters(), selected:controller.selectedItems().map(item=>item.number)
+}})));
+"""
+
+ assert run_node(script) == {
+ "calls": ["api/v1/available-issues?page=1&facets=true&repository=stackchain%2Fapi&label=critical"],
+ "filters": {"repositories": ["stackchain/api"], "labels": ["critical"]},
+ "selected": [9],
+ }
+
+
+def test_mobile_find_work_exposes_accessible_touch_sized_facets():
+ html = HTML.read_text()
+ dashboard = DASHBOARD.read_text()
+ css = CSS.read_text()
+
+ assert 'id="find-work-filters"' in html
+ assert 'id="find-work-repository-filters"' in html
+ assert 'id="find-work-label-filters"' in html
+ assert 'id="clear-find-work-filters"' in html
+ assert 'aria-label="Filter available work"' in html
+ assert "setFilters(next)" in PICK_WORK.read_text()
+ assert ".find-work-filters" in css
+ assert ".find-work-filter-option" in css and "min-height:44px" in css
diff --git a/tests/test_gitea_work_search.py b/tests/test_gitea_work_search.py
index b0a6791..e1f80af 100644
--- a/tests/test_gitea_work_search.py
+++ b/tests/test_gitea_work_search.py
@@ -248,6 +248,74 @@ async def test_available_issue_search_filters_full_catalog_before_pagination(mon
assert by_title.json() == by_repo.json()
+@pytest.mark.anyio
+async def test_available_issue_facets_filter_before_pagination_and_describe_catalog(monkeypatch):
+ async def available():
+ return [
+ {"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]},
+ {"repository": "stackchain/api", "number": 2, "title": "API docs", "labels": ["docs"]},
+ {"repository": "stackchain/dashboard", "number": 3, "title": "Critical UI", "labels": ["critical", "frontend"]},
+ ]
+
+ 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:
+ response = await client.get(
+ "/api/v1/available-issues",
+ params=[("repository", "stackchain/api"), ("label", "critical")],
+ )
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "items": [{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]}],
+ "page": 1,
+ "total": 1,
+ "has_more": False,
+ "facets": {
+ "repositories": ["stackchain/api", "stackchain/dashboard"],
+ "labels": ["backend", "critical", "docs", "frontend"],
+ },
+ }
+
+
+@pytest.mark.anyio
+async def test_available_issue_facets_or_within_each_facet_and_compose_with_search(monkeypatch):
+ async def available():
+ return [
+ {"repository": "stackchain/api", "number": 1, "title": "Repair worker", "labels": ["backend"]},
+ {"repository": "stackchain/web", "number": 2, "title": "Repair mobile", "labels": ["frontend"]},
+ {"repository": "stackchain/docs", "number": 3, "title": "Repair guide", "labels": ["docs"]},
+ {"repository": "stackchain/api", "number": 4, "title": "Routine task", "labels": ["backend"]},
+ ]
+
+ 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:
+ response = await client.get(
+ "/api/v1/available-issues",
+ params=[("q", "repair"), ("repository", "stackchain/api"), ("repository", "stackchain/web"),
+ ("label", "backend"), ("label", "frontend")],
+ )
+
+ assert [item["number"] for item in response.json()["items"]] == [1, 2]
+
+
+@pytest.mark.anyio
+async def test_available_issue_facets_reject_excess_values_before_scanning(monkeypatch):
+ async def must_not_scan():
+ raise AssertionError("invalid facets 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=[("label", f"label-{index}") for index in range(11)],
+ )
+
+ assert response.status_code == 422
+
+
@pytest.mark.anyio
async def test_available_issue_search_rejects_oversized_query_without_scanning(monkeypatch):
async def must_not_scan():
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index e21ffd2..5a691ca 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -3345,7 +3345,10 @@ controller.load().then(() => controller.loadMore()).then(() =>
)
output = json.loads(result.stdout)
- assert output["calls"] == ["api/v1/available-issues?page=1", "api/v1/available-issues?page=2"]
+ assert output["calls"] == [
+ "api/v1/available-issues?page=1&facets=true",
+ "api/v1/available-issues?page=2&facets=true",
+ ]
assert [item["id"] for item in output["items"]] == [1, 2]
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}