From 8411e907afb3d2223d234b2e6456bd61d8fc6e54 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 07:40:23 +0000 Subject: [PATCH] feat: review completed mobile Filed outcomes (Closes #878) --- frontend/dashboard.css | 4 ++ frontend/dashboard.js | 41 ++++++++++++- frontend/index.html | 4 ++ frontend/issue-sheet.js | 2 +- frontend/my-work.js | 45 ++++++++++++++- src/gitea_proxy.py | 10 ++-- tests/test_gitea_work_search.py | 14 +++-- tests/test_my_work.py | 95 ++++++++++++++++++++++++++++++- tests/test_work_route_resolver.py | 55 ++++++++++++++++++ 9 files changed, 253 insertions(+), 17 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 35303a5..e049427 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -465,6 +465,10 @@ textarea { resize: vertical; min-height: 120px; } .issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; } .issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } .issue-sheet-header button { min-height:44px; } +.completed-filed-actions { position:fixed; right:0; bottom:0; z-index:57; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; margin:0; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:12px 0 0; background:rgba(11,21,38,.98); overflow-wrap:anywhere; } +.completed-filed-actions[hidden] { display:none; } +.completed-filed-actions button { min-height:44px; min-width:0; } +#issue-sheet:has(.completed-filed-actions:not([hidden])) .issue-sheet-panel { padding-bottom:calc(110px + env(safe-area-inset-bottom)); } .issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; } .issue-blockers { max-width:100%; overflow-x:hidden; margin:16px 0; padding:12px; border:1px solid #b45309; border-radius:12px; background:#291b0c; } .issue-blockers h2 { margin-top:0; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 131b6c5..9cebe31 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -216,6 +216,10 @@ let confirmedOwnerLogin = ''; let planningOwnerLogin = ''; let activeFlushLogin = ''; + const completedFiledReview = createCompletedFiledReview({ + storage: localStorage, + getLogin() { return planningOwnerLogin; }, + }); let activeMyWork = []; let laterMyWork = []; let todayMyWork = []; @@ -2511,7 +2515,7 @@ } function paintMyWork(data) { - lastMyWork = buildMyWork(data); + lastMyWork = completedFiledReview.visible(buildMyWork(data)); refreshMyWorkView(); } @@ -2814,7 +2818,7 @@ } function updateWorkPaginationControls() { - const labels = { issue: 'issues', pull: 'pull requests', review: 'review requests' }; + const labels = { issue: 'issues', filed: 'filed issues', pull: 'pull requests', review: 'review requests' }; const streams = activeWorkStreams(); const incomplete = streams.filter(stream => workPagination[stream]?.has_more); const summaries = streams.flatMap(stream => { @@ -3322,6 +3326,13 @@ qs('#issue-sheet-key').textContent = item.key || ''; qs('#issue-sheet-title').textContent = item.title || 'Assigned issue'; qs('#issue-sheet-status').textContent = 'Loading issue…'; + const completedItems = lastMyWork.filter(candidate => candidate?.is_completed && candidate?.is_filed); + const completedPosition = completedItems.findIndex(candidate => + candidate.repository === item.repository && candidate.number === item.number + ); + qs('#completed-filed-actions').hidden = !item.is_completed; + qs('#completed-filed-progress').textContent = item.is_completed ? + 'Completed Filed issue ' + (completedPosition + 1) + ' of ' + completedItems.length : ''; qs('#issue-sheet-body').textContent = ''; qs('#issue-labels').textContent = ''; qs('#issue-assignees').textContent = ''; @@ -3381,7 +3392,8 @@ if (readOnly) paintIssueConversation(issueConversation.snapshot(), null); else renderIssueConversation(issueConversation.snapshot()); qs('#open-issue-gitea').href = detail.url || item.url || '#'; - qs('#issue-sheet-status').textContent = readOnly ? 'Filed issue ready · follow-up enabled' : + qs('#issue-sheet-status').textContent = item.is_completed ? + 'Completed Filed outcome ready · review the conversation' : readOnly ? 'Filed issue ready · follow-up enabled' : 'Issue ready · ' + (detail.state || 'open'); qs('#edit-issue-content').disabled = false; const dueDraft = issueController.loadDueDateDraft(item); @@ -3422,6 +3434,29 @@ if (issueTrigger?.isConnected) issueTrigger.focus(); } + qs('#acknowledge-completed-filed').addEventListener('click', () => { + if (!selectedIssue?.is_completed) return; + if (!completedFiledReview.acknowledge(selectedIssue)) { + qs('#issue-sheet-status').textContent = 'Could not save this acknowledgement on this device. Retry.'; + return; + } + const acknowledged = selectedIssue; + lastMyWork = completedFiledReview.visible(lastMyWork); + closeIssueSheet(false); + refreshMyWorkView(); + const target = filedFollowUpTarget(lastMyWork); + qs('#my-work-action-status').textContent = 'Reviewed ' + acknowledged.key + '.' + + (target ? ' Opening the next Filed item.' : ' Filed review is complete.'); + if (target) { + const index = lastMyWork.indexOf(target.item); + const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]'); + openRoutedWork(target.kind === 'update' ? { ...target.item, kind:'update' } : target.item, trigger); + } else { + window.location.hash = '#/my-work/filed'; + qs('#my-work').focus(); + } + }); + function renderCheckSection(prefix, detail, offline = false) { const rendered = createReviewController.renderChecks(detail?.checks, escapeHtml, { offline }); qs('#' + prefix + '-checks-summary').textContent = rendered.summary; diff --git a/frontend/index.html b/frontend/index.html index 2650779..828c867 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -594,6 +594,10 @@
Choose an issue.
+
diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index 02cade7..8b6d671 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -39,7 +39,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global return { readOnly(item) { - return Boolean(item?.is_filed && !item?.is_assigned); + return Boolean(item?.is_completed || (item?.is_filed && !item?.is_assigned)); }, load(item) { const access = this.readOnly(item) ? '?access=filed' : ''; diff --git a/frontend/my-work.js b/frontend/my-work.js index bd6e0aa..580ec4a 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -71,20 +71,23 @@ function buildMyWork(data, now = new Date()) { const assigned = (item.assignees || []).includes(login); const isReview = (item.work_reasons || []).includes('review_requested'); const isFiled = (item.work_reasons || []).includes('created_by_me'); + const isCompleted = isFiled && item.state === 'closed'; const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null; const normalized = { ...item, key: (item.repository || 'unknown') + '#' + item.number, is_review: isReview, is_filed: isFiled, + is_completed: isCompleted, is_assigned: assigned, has_update: false, ...(due ? { due_label: due.label } : {}), reason: priorityLabel ? priorityLabel + ' priority' : (due && due.priority < 4 ? due.label : - (isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work')))), + (isReview ? 'Needs your review' : (isCompleted ? 'Completed · review outcome' : + (assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work'))))), _priority: priorityLabel ? 0 : - (due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))), + (due && due.priority < 4 ? due.priority : (isReview ? 3 : (isCompleted ? 3.5 : (assigned ? 4 : 5)))), }; normalized.needs_attention = needsAttention(normalized); normalized.attention_reason = attentionReason(normalized); @@ -666,6 +669,43 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') { ); } +function createCompletedFiledReview({ + storage, + getLogin, + key = 'stackchain.completed-filed-review.v1', +}) { + const login = () => String(getLogin?.() || '').trim(); + const ownerKey = () => key + ':' + login(); + const identity = item => String(item?.repository || '') + '#' + String(item?.number || ''); + const read = () => { + if (!login()) return {}; + try { + const value = JSON.parse(storage?.getItem(ownerKey()) || '{}'); + return value && value.version === 1 && value.items && typeof value.items === 'object' ? + value.items : {}; + } catch (_error) { return {}; } + }; + return { + visible(items) { + const acknowledged = read(); + return (items || []).filter(item => + !item?.is_completed || acknowledged[identity(item)] !== String(item.updated_at || '') + ); + }, + acknowledge(item) { + const stamp = String(item?.updated_at || ''); + if (!login() || !item?.is_completed || !item?.repository || !Number.isInteger(item?.number) || !stamp) { + return false; + } + const entries = Object.entries({ ...read(), [identity(item)]: stamp }).slice(-200); + try { + storage?.setItem(ownerKey(), JSON.stringify({ version:1, items:Object.fromEntries(entries) })); + return true; + } catch (_error) { return false; } + }, + }; +} + function agendaMyWork(items, now = new Date()) { const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const today = localDay(start); @@ -981,6 +1021,7 @@ function countMyWork(items) { if (typeof module !== 'undefined' && module.exports) { buildMyWork.filterMyWork = filterMyWork; + buildMyWork.createCompletedFiledReview = createCompletedFiledReview; buildMyWork.agendaMyWork = agendaMyWork; buildMyWork.milestoneLanes = milestoneLanes; buildMyWork.createWorkSession = createWorkSession; diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 917266f..1f8b80a 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -380,7 +380,8 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict: "/api/v1/repos/issues/search", headers=_auth(), params={ - "state": "open", selector.split("=", 1)[0]: "true", + "state": "all" if stream == "filed" else "open", + selector.split("=", 1)[0]: "true", "type": item_type, "limit": limit, "page": page, }, ) @@ -2371,8 +2372,9 @@ async def resolve_work_route( requested = _login_in_users(login, target.get("requested_reviewers")) author = target.get("user") if isinstance(target.get("user"), dict) else {} authored = author.get("login") == login + state = target.get("state") eligible = ( - target.get("state") == "open" + (state == "open" or (kind == "filed" and state == "closed")) and ( (kind == "issue" and not isinstance(target.get("pull_request"), dict) and assigned) or (kind == "filed" and not isinstance(target.get("pull_request"), dict) and authored) @@ -2387,7 +2389,7 @@ async def resolve_work_route( "repository": repository, "number": number, "title": target.get("title", "") if isinstance(target.get("title"), str) else "", - "state": "open", + "state": state, "url": _safe_web_url(target.get("html_url")), **({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}), **({ @@ -2415,7 +2417,7 @@ async def is_authored_issue(repository: str, number: int) -> bool: ) author = issue.get("user") if isinstance(issue.get("user"), dict) else {} return ( - issue.get("state") == "open" + issue.get("state") in {"open", "closed"} and not isinstance(issue.get("pull_request"), dict) and author.get("login") == login ) diff --git a/tests/test_gitea_work_search.py b/tests/test_gitea_work_search.py index 489f742..4a22759 100644 --- a/tests/test_gitea_work_search.py +++ b/tests/test_gitea_work_search.py @@ -67,15 +67,16 @@ async def test_work_page_preserves_total_and_reason_without_loading_other_pages( @pytest.mark.anyio -async def test_filed_work_page_loads_open_authored_issues_with_a_distinct_reason(): +async def test_filed_work_page_loads_open_and_closed_authored_issues_with_a_distinct_reason(): requests = [] def upstream(request): requests.append(str(request.url)) return httpx.Response(200, headers={"X-Total-Count": "61"}, json=[{ - "id": 870, "number": 870, "title": "Delegated filing", "state": "open", + "id": 878, "number": 878, "title": "Completed delegation", "state": "closed", + "updated_at": "2026-08-15T12:00:00Z", "repository": {"full_name": "stackchain/dashboard"}, - "html_url": "https://forge.example/stackchain/dashboard/issues/870", + "html_url": "https://forge.example/stackchain/dashboard/issues/878", }]) gitea_proxy.start_client(transport=httpx.MockTransport(upstream)) @@ -85,14 +86,15 @@ async def test_filed_work_page_loads_open_authored_issues_with_a_distinct_reason await gitea_proxy.stop_client() assert requests == [ - "http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&created=true&type=issues&limit=50&page=2" + "http://127.0.0.1:3000/api/v1/repos/issues/search?state=all&created=true&type=issues&limit=50&page=2" ] assert result == { "stream": "filed", "page": 2, "total": 61, "has_more": False, "items": [{ - "id": 870, "number": 870, "title": "Delegated filing", "state": "open", + "id": 878, "number": 878, "title": "Completed delegation", "state": "closed", + "updated_at": "2026-08-15T12:00:00Z", "repository": {"full_name": "stackchain/dashboard"}, - "html_url": "https://forge.example/stackchain/dashboard/issues/870", + "html_url": "https://forge.example/stackchain/dashboard/issues/878", "work_reasons": ["created_by_me"], }], } diff --git a/tests/test_my_work.py b/tests/test_my_work.py index b843607..20890d4 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -72,6 +72,98 @@ process.stdout.write(JSON.stringify({{available, results}})); } +def test_completed_filed_outcomes_are_distinct_and_rank_before_open_filings(): + payload = { + "user": {"login": "timmy"}, + "issues": [ + {"number": 10, "title": "Still delegated", "repository": "stackchain/api", + "state": "open", "assignees": ["alex"], "work_reasons": ["created_by_me"], + "updated_at": "2026-08-15T12:00:00Z"}, + {"number": 9, "title": "Outcome ready", "repository": "stackchain/api", + "state": "closed", "assignees": ["timmy"], "work_reasons": ["created_by_me"], + "updated_at": "2026-08-14T12:00:00Z"}, + ], + } + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)}))); +""" + completed = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert completed.returncode == 0, completed.stderr + result = json.loads(completed.stdout) + assert [item["number"] for item in result] == [9, 10] + assert result[0]["is_completed"] is True + assert result[0]["reason"] == "Completed · review outcome" + assert result[1]["is_completed"] is False + assert result[1]["reason"] == "Filed by you" + + +def test_completed_filed_acknowledgement_survives_refresh_until_upstream_update(): + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const values = new Map(); +const storage = {{ + getItem:key => values.has(key) ? values.get(key) : null, + setItem:(key, value) => values.set(key, value), +}}; +const create = buildMyWork.createCompletedFiledReview; +if (typeof create !== 'function') {{ + process.stdout.write(JSON.stringify({{available:false}})); +}} else {{ + const items = [ + {{kind:'issue', key:'stackchain/api#9', repository:'stackchain/api', number:9, + is_filed:true, is_completed:true, updated_at:'2026-08-14T12:00:00Z'}}, + {{kind:'issue', key:'stackchain/api#10', repository:'stackchain/api', number:10, + is_filed:true, is_completed:false, updated_at:'2026-08-15T12:00:00Z'}}, + ]; + const first = create({{storage, getLogin:() => 'timmy'}}); + const before = first.visible(items).map(item => item.number); + const acknowledged = first.acknowledge(items[0]); + const after = first.visible(items).map(item => item.number); + const reloaded = create({{storage, getLogin:() => 'timmy'}}); + const afterRefresh = reloaded.visible(items).map(item => item.number); + const updated = reloaded.visible([{{...items[0], updated_at:'2026-08-16T12:00:00Z'}}, items[1]]) + .map(item => item.number); + process.stdout.write(JSON.stringify({{available:true, before, acknowledged, after, afterRefresh, updated}})); +}} +""" + completed = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + "available": True, + "before": [9, 10], + "acknowledged": True, + "after": [10], + "afterRefresh": [10], + "updated": [9, 10], + } + + +@pytest.mark.anyio +async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow(): + markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text() + css = (Path(__file__).parents[1] / "frontend" / "dashboard.css").read_text() + source = await dashboard() + + assert 'id="completed-filed-actions"' in markup + assert 'id="acknowledge-completed-filed"' in markup + assert 'Acknowledge & next' in markup + assert "createCompletedFiledReview({" in source + assert "completedFiledReview.visible(buildMyWork(data))" in source + assert "filed: 'filed issues'" in source + handler = source.split("qs('#acknowledge-completed-filed').addEventListener('click'", 1)[1] + assert "completedFiledReview.acknowledge(selectedIssue)" in handler + assert "filedFollowUpTarget(lastMyWork)" in handler + assert "openRoutedWork" in handler + assert ".completed-filed-actions" in css + action_rule = css.split(".completed-filed-actions", 1)[1].split("}", 1)[0] + assert "position:fixed" in action_rule + assert "env(safe-area-inset-bottom)" in action_rule + assert "min-height:44px" in css.split(".completed-filed-actions", 1)[1] + + def test_updates_rank_actionable_work_before_newer_routine_activity(): payload = { "user": {"login": "timmy"}, @@ -1612,6 +1704,7 @@ const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); const controller = createIssueSheet({{fetchJson:async () => ({{}}), storage:null}}); process.stdout.write(JSON.stringify([ controller.readOnly({{kind:'filed',is_filed:true,is_assigned:false}}), + controller.readOnly({{kind:'filed',is_filed:true,is_assigned:true,is_completed:true}}), controller.readOnly({{kind:'issue',is_filed:true,is_assigned:true}}), controller.readOnly({{kind:'issue',is_assigned:true}}), ])); @@ -1619,7 +1712,7 @@ process.stdout.write(JSON.stringify([ result = subprocess.run( ["node", "-e", script], check=True, capture_output=True, text=True ) - assert json.loads(result.stdout) == [True, False, False] + assert json.loads(result.stdout) == [True, True, False, False] def test_issue_sheet_loads_filed_items_through_read_only_access(): diff --git a/tests/test_work_route_resolver.py b/tests/test_work_route_resolver.py index 13a8b95..5971b40 100644 --- a/tests/test_work_route_resolver.py +++ b/tests/test_work_route_resolver.py @@ -147,6 +147,61 @@ async def test_resolve_work_route_returns_an_open_issue_filed_by_the_current_use } +@pytest.mark.anyio +async def test_resolve_work_route_returns_a_completed_issue_filed_by_the_current_user(): + async def upstream(request): + if request.url.path.endswith("/user"): + return httpx.Response(200, json={"login": "timmy"}) + return httpx.Response(200, json={ + "id": 702, + "number": 89, + "title": "Completed delegation", + "state": "closed", + "html_url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/issues/89", + "assignees": [{"login": "alex"}], + "user": {"login": "timmy"}, + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(upstream)) + try: + result = await gitea_proxy.resolve_work_route( + "filed", "stackchain/dashboard", 89, None + ) + finally: + await gitea_proxy.stop_client() + + assert result == { + "kind": "filed", + "repository": "stackchain/dashboard", + "number": 89, + "title": "Completed delegation", + "state": "closed", + "url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/issues/89", + "is_filed": True, + "is_assigned": False, + "work_reasons": ["created_by_me"], + } + + +@pytest.mark.anyio +async def test_completed_filed_issue_remains_authorized_for_detail_and_reply(): + async def upstream(request): + if request.url.path.endswith("/user"): + return httpx.Response(200, json={"login": "timmy"}) + return httpx.Response(200, json={ + "state": "closed", + "user": {"login": "timmy"}, + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(upstream)) + try: + authorized = await gitea_proxy.is_authored_issue("stackchain/dashboard", 89) + finally: + await gitea_proxy.stop_client() + + assert authorized is True + + @pytest.mark.anyio async def test_resolve_work_route_rejects_a_notification_that_is_already_read(): async def upstream(request): -- 2.43.0