From a57aa879e2a0f24a3bb364ec6ee4cccb2cbd0066 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 09:10:05 +0000 Subject: [PATCH] feat: keep reviewed Filed history (Closes #882) --- README.md | 11 +++--- frontend/dashboard.css | 4 +++ frontend/dashboard.js | 28 ++++++++++------ frontend/index.html | 4 +++ frontend/my-work.js | 41 ++++++++++++++++++++--- tests/test_mobile_task_dock.py | 2 +- tests/test_my_work.py | 61 ++++++++++++++++++++++++++++++++-- 7 files changed, 129 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index b1069c7..db6b670 100644 --- a/README.md +++ b/README.md @@ -86,10 +86,13 @@ overwriting newer views. Rename and delete affect only the saved view, never Git sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default `.stackchain-state/saved-searches.sqlite3` path. Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged. -Acknowledgements hide the exact Gitea `updated_at` revision immediately on the current device, synchronize in -bounded batches to the confirmed account, and suppress that outcome on other signed-in devices. A later Gitea -update reopens review. Offline or failed synchronization keeps the local acknowledgement and retries on the next -healthy dashboard refresh without blocking **Acknowledge & next**. Set +Filed separates actionable **Needs review** from a browsable **Reviewed** history, so acknowledgement clears the +queue without erasing the delegated-work record. Reviewed cards reopen the existing read-only issue detail and +conversation, while the Filed badge continues to count actionable outcomes only. A later Gitea update moves that +issue back to Needs review automatically. Acknowledgements synchronize the exact Gitea `updated_at` revision in +bounded batches to the confirmed account and preserve the same Reviewed state on other signed-in devices. Offline +or failed synchronization keeps the local acknowledgement and retries on the next healthy dashboard refresh +without blocking **Acknowledge & next**. Set `STACKCHAIN_COMPLETED_FILED_REVIEW_DB` to override the default `.stackchain-state/completed-filed-reviews.sqlite3` path. Search previews also diff --git a/frontend/dashboard.css b/frontend/dashboard.css index e049427..d90998e 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -285,6 +285,10 @@ textarea { resize: vertical; min-height: 120px; } .queue-finder button { padding-inline:14px; } #search-older-work { width:100%; } .my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; } +.filed-history-tabs { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin:10px 0; } +.filed-history-tabs[hidden] { display:none; } +.filed-history-tabs button { min-height:44px; min-width:0; } +.filed-history-tabs button[aria-pressed="true"] { border-color:#60a5fa; background:#17365d; } .draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); } .draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; } .draft-preview { color:var(--muted); overflow-wrap:anywhere; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 8f37b97..2b91b08 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -61,7 +61,7 @@ let mobileQueueCounts = {}; function openFiledFollowUp() { selectMobileQueue('filed'); - const target = filedFollowUpTarget(lastMyWork); + const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork)); if (!target) { qs('#my-work-action-status').textContent = 'No filed issues are ready to open.'; return 'empty'; @@ -156,6 +156,7 @@ const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1'; const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1'; let selectedWorkFilter = 'all'; + let selectedFiledView = 'needs-review'; let selectedWorkMilestone = 'all'; let queueFindQuery = ''; let savedWorkFilter = null; @@ -220,6 +221,10 @@ storage: localStorage, getLogin() { return planningOwnerLogin; }, }); + const filedHistoryTabs = createFiledHistoryTabs({ + root:qs('#filed-history-tabs'), review:completedFiledReview, + onSelect:view => { selectedFiledView = view; renderMyWork(); }, + }); let completedFiledSyncFlight = null; let activeMyWork = []; let laterMyWork = []; @@ -344,7 +349,7 @@ .then(snapshot => { const changed = completedFiledReview.adopt(snapshot); if (lastContextSnapshot && changed) { - lastMyWork = completedFiledReview.visible(buildMyWork(lastContextSnapshot)); + lastMyWork = buildMyWork(lastContextSnapshot); refreshMyWorkView(); } if (pending.length) qs('#my-work-action-status').textContent = @@ -2545,7 +2550,7 @@ } function paintMyWork(data) { - lastMyWork = completedFiledReview.visible(buildMyWork(data)); + lastMyWork = buildMyWork(data); refreshMyWorkView(); void syncCompletedFiledReviews(); } @@ -2566,7 +2571,8 @@ function refreshMyWorkView({ reconcileSession = true } = {}) { lastDrafts = listDrafts(); - const partitioned = laterWork.partition(lastMyWork, { + const actionableMyWork = filedHistoryTabs.prepare(lastMyWork); + const partitioned = laterWork.partition(actionableMyWork, { pruneMissing: !Object.values(workPagination).some(page => page?.has_more), }); activeMyWork = partitioned.active; @@ -2574,7 +2580,7 @@ const authoritativeTodayReconciliation = liveMode && hasContextSnapshot && !lastContextSnapshot?.error && !Object.values(workPagination).some(page => page?.has_more); - todayMyWork = todayWork.reconcile(lastMyWork, { + todayMyWork = todayWork.reconcile(actionableMyWork, { pruneMissing: authoritativeTodayReconciliation, onPrune: retiredIds => { const queued = retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean); @@ -2871,9 +2877,11 @@ renderDrafts(); qs('#bulk-mark-read-bar').hidden = true; qs('#load-more-notifications').hidden = true; + qs('#filed-history-tabs').hidden = true; return; } - const queueItems = selectedWorkFilter === 'today' ? + const filedItems = filedHistoryTabs.render(selectedFiledView, selectedWorkFilter === 'filed'); + const queueItems = selectedWorkFilter === 'filed' ? filedItems : selectedWorkFilter === 'today' ? filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ? filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork) : @@ -2954,8 +2962,9 @@ (selectedWorkFilter === 'agenda' && workPagination.issue?.has_more ? (agendaChecking ? 'Checking all assigned deadlines…' : 'Older assigned deadlines remain unchecked. Retry the Agenda check.') : (incomplete ? - 'More work is available. Load the next page.' : - 'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.')) + ''); + 'More work is available. Load the next page.' : (selectedWorkFilter === 'filed' && selectedFiledView === 'reviewed' ? + 'No reviewed outcomes yet' : + 'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.'))) + ''); cardPlanning.wire(); document.querySelectorAll('[data-select-notification-id]').forEach(input => { input.addEventListener('change', () => { @@ -3472,10 +3481,9 @@ return; } const acknowledged = selectedIssue; - lastMyWork = completedFiledReview.visible(lastMyWork); closeIssueSheet(false); refreshMyWorkView(); - const target = filedFollowUpTarget(lastMyWork); + const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork)); qs('#my-work-action-status').textContent = 'Reviewed ' + acknowledged.key + '.' + (target ? ' Opening the next Filed item. Acknowledgement sync pending.' : ' Filed review is complete. Acknowledgement sync pending.'); diff --git a/frontend/index.html b/frontend/index.html index 828c867..7573e2c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -247,6 +247,10 @@ +
diff --git a/frontend/my-work.js b/frontend/my-work.js index 48fb565..bee668c 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -691,12 +691,22 @@ function createCompletedFiledReview({ repository:String(item?.repository || ''), number:item?.number, updated_at:stamp, }); const save = value => storage?.setItem(ownerKey(), JSON.stringify({ version:1, ...value })); + const partition = items => { + const acknowledged = read().items; + const needsReview = []; + const reviewed = []; + (items || []).forEach(item => { + const isReviewed = item?.is_completed && + acknowledged[identity(item)] === String(item.updated_at || ''); + (isReviewed ? reviewed : needsReview).push(item); + }); + reviewed.sort((left, right) => String(right.updated_at || '').localeCompare(String(left.updated_at || ''))); + return { needsReview, reviewed }; + }; return { + partition, visible(items) { - const acknowledged = read().items; - return (items || []).filter(item => - !item?.is_completed || acknowledged[identity(item)] !== String(item.updated_at || '') - ); + return partition(items).needsReview; }, pending() { const state = read(); @@ -742,6 +752,28 @@ function createCompletedFiledReview({ }; } +function createFiledHistoryTabs({ root, review, onSelect }) { + const buttons = Array.from(root.querySelectorAll('[data-filed-view]')); + let partition = { needsReview:[], reviewed:[] }; + buttons.forEach(button => button.addEventListener('click', () => onSelect(button.dataset.filedView))); + return { + prepare(items) { + partition = review.partition(items); + root.querySelector('#filed-needs-review-count').textContent = + partition.needsReview.filter(item => item.is_filed).length; + root.querySelector('#filed-reviewed-count').textContent = partition.reviewed.length; + return partition.needsReview; + }, + render(selected, visible) { + root.hidden = !visible; + buttons.forEach(button => + button.setAttribute('aria-pressed', String(button.dataset.filedView === selected)) + ); + return selected === 'reviewed' ? partition.reviewed : partition.needsReview; + }, + }; +} + function agendaMyWork(items, now = new Date()) { const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const today = localDay(start); @@ -1058,6 +1090,7 @@ function countMyWork(items) { if (typeof module !== 'undefined' && module.exports) { buildMyWork.filterMyWork = filterMyWork; buildMyWork.createCompletedFiledReview = createCompletedFiledReview; + buildMyWork.createFiledHistoryTabs = createFiledHistoryTabs; buildMyWork.agendaMyWork = agendaMyWork; buildMyWork.milestoneLanes = milestoneLanes; buildMyWork.createWorkSession = createWorkSession; diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py index 969557a..f96379a 100644 --- a/tests/test_mobile_task_dock.py +++ b/tests/test_mobile_task_dock.py @@ -582,7 +582,7 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher(): assert 'FiledIssues you delegated' in html assert 'data-mobile-queue-count="filed"' in html assert "openFiled: openFiledFollowUp" in html - assert "filedFollowUpTarget(lastMyWork)" in html + assert "filedFollowUpTarget(completedFiledReview.visible(lastMyWork))" in html assert 'data-mobile-queue="later"' in html assert 'data-mobile-queue="draft"' in html assert 'data-mobile-queue="recaps"' in html diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 8732392..fc57a76 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -141,6 +141,40 @@ if (typeof create !== 'function') {{ } +def test_completed_filed_review_partitions_acknowledged_history_until_a_new_revision(): + 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; +const open = {{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 completed = {{kind:'issue', key:'stackchain/api#9', repository:'stackchain/api', number:9, + is_filed:true, is_completed:true, updated_at:'2026-08-14T12:00:00Z'}}; +const review = create({{storage, getLogin:() => 'timmy'}}); +const before = review.partition([open, completed]); +review.acknowledge(completed); +const after = review.partition([open, completed]); +const revised = review.partition([open, {{...completed, updated_at:'2026-08-16T12:00:00Z'}}]); +const shape = value => ({{ + needsReview:value.needsReview.map(item => item.number), + reviewed:value.reviewed.map(item => item.number), +}}); +process.stdout.write(JSON.stringify({{before:shape(before), after:shape(after), revised:shape(revised)}})); +""" + completed = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + "before": {"needsReview": [10, 9], "reviewed": []}, + "after": {"needsReview": [10], "reviewed": [9]}, + "revised": {"needsReview": [10, 9], "reviewed": []}, + } + + def test_completed_filed_review_merges_remote_receipts_and_keeps_local_work_pending(): script = f""" const buildMyWork = require({json.dumps(str(MY_WORK))}); @@ -181,6 +215,27 @@ process.stdout.write(JSON.stringify({{pendingBefore, changed, visible, pendingAf } +@pytest.mark.anyio +async def test_mobile_filed_exposes_actionable_and_reviewed_views_without_inflating_badge(): + 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="filed-history-tabs"' in markup + assert 'data-filed-view="needs-review"' in markup + assert 'data-filed-view="reviewed"' in markup + assert 'No reviewed outcomes yet' in source + assert "const actionableMyWork = filedHistoryTabs.prepare(lastMyWork)" in source + assert "const counts = countMyWork(activeMyWork)" in source + assert "todayWork.reconcile(actionableMyWork" in source + assert "selectedFiledView === 'reviewed'" in source + assert "filedFollowUpTarget(completedFiledReview.visible(lastMyWork))" in source.split("function openFiledFollowUp()", 1)[1].split("}", 1)[0] + assert ".filed-history-tabs" in css + filed_tabs_rule = css.split(".filed-history-tabs button", 1)[1].split("}", 1)[0] + assert "min-height:44px" in filed_tabs_rule + assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css + + @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() @@ -191,14 +246,14 @@ async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow(): assert 'id="acknowledge-completed-filed"' in markup assert 'Acknowledge & next' in markup assert "createCompletedFiledReview({" in source - assert "completedFiledReview.visible(buildMyWork(data))" in source + assert "lastMyWork = 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 "syncCompletedFiledReviews()" in source assert "api/v1/completed-filed-reviews" in source assert "sync pending" in source - assert "filedFollowUpTarget(lastMyWork)" in handler + assert "filedFollowUpTarget(completedFiledReview.visible(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] @@ -2928,7 +2983,7 @@ async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions(): assert 'data-work-count="later"' in html assert 'const laterWork = createLaterWork({' in html assert 'getLogin: () => planningOwnerLogin' in html - assert 'laterWork.partition(lastMyWork,' in html + assert 'laterWork.partition(actionableMyWork,' in html assert 'data-later-preset="today"' in html assert 'data-later-preset="tomorrow"' in html assert 'data-later-restore' in html