Keep acknowledged outcomes in mobile Filed history #883

Merged
timmy merged 1 commits from timmy/882-reviewed-filed-history into main 2026-08-15 09:13:44 +00:00
7 changed files with 129 additions and 22 deletions

View File

@ -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 sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
`.stackchain-state/saved-searches.sqlite3` path. `.stackchain-state/saved-searches.sqlite3` path.
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged. 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 Filed separates actionable **Needs review** from a browsable **Reviewed** history, so acknowledgement clears the
bounded batches to the confirmed account, and suppress that outcome on other signed-in devices. A later Gitea queue without erasing the delegated-work record. Reviewed cards reopen the existing read-only issue detail and
update reopens review. Offline or failed synchronization keeps the local acknowledgement and retries on the next conversation, while the Filed badge continues to count actionable outcomes only. A later Gitea update moves that
healthy dashboard refresh without blocking **Acknowledge & next**. Set 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_COMPLETED_FILED_REVIEW_DB` to override the default
`.stackchain-state/completed-filed-reviews.sqlite3` path. `.stackchain-state/completed-filed-reviews.sqlite3` path.
Search previews also Search previews also

View File

@ -285,6 +285,10 @@ textarea { resize: vertical; min-height: 120px; }
.queue-finder button { padding-inline:14px; } .queue-finder button { padding-inline:14px; }
#search-older-work { width:100%; } #search-older-work { width:100%; }
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; } .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 { 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-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; } .draft-preview { color:var(--muted); overflow-wrap:anywhere; }

View File

@ -61,7 +61,7 @@
let mobileQueueCounts = {}; let mobileQueueCounts = {};
function openFiledFollowUp() { function openFiledFollowUp() {
selectMobileQueue('filed'); selectMobileQueue('filed');
const target = filedFollowUpTarget(lastMyWork); const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork));
if (!target) { if (!target) {
qs('#my-work-action-status').textContent = 'No filed issues are ready to open.'; qs('#my-work-action-status').textContent = 'No filed issues are ready to open.';
return 'empty'; return 'empty';
@ -156,6 +156,7 @@
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1'; const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1'; const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
let selectedWorkFilter = 'all'; let selectedWorkFilter = 'all';
let selectedFiledView = 'needs-review';
let selectedWorkMilestone = 'all'; let selectedWorkMilestone = 'all';
let queueFindQuery = ''; let queueFindQuery = '';
let savedWorkFilter = null; let savedWorkFilter = null;
@ -220,6 +221,10 @@
storage: localStorage, storage: localStorage,
getLogin() { return planningOwnerLogin; }, getLogin() { return planningOwnerLogin; },
}); });
const filedHistoryTabs = createFiledHistoryTabs({
root:qs('#filed-history-tabs'), review:completedFiledReview,
onSelect:view => { selectedFiledView = view; renderMyWork(); },
});
let completedFiledSyncFlight = null; let completedFiledSyncFlight = null;
let activeMyWork = []; let activeMyWork = [];
let laterMyWork = []; let laterMyWork = [];
@ -344,7 +349,7 @@
.then(snapshot => { .then(snapshot => {
const changed = completedFiledReview.adopt(snapshot); const changed = completedFiledReview.adopt(snapshot);
if (lastContextSnapshot && changed) { if (lastContextSnapshot && changed) {
lastMyWork = completedFiledReview.visible(buildMyWork(lastContextSnapshot)); lastMyWork = buildMyWork(lastContextSnapshot);
refreshMyWorkView(); refreshMyWorkView();
} }
if (pending.length) qs('#my-work-action-status').textContent = if (pending.length) qs('#my-work-action-status').textContent =
@ -2545,7 +2550,7 @@
} }
function paintMyWork(data) { function paintMyWork(data) {
lastMyWork = completedFiledReview.visible(buildMyWork(data)); lastMyWork = buildMyWork(data);
refreshMyWorkView(); refreshMyWorkView();
void syncCompletedFiledReviews(); void syncCompletedFiledReviews();
} }
@ -2566,7 +2571,8 @@
function refreshMyWorkView({ reconcileSession = true } = {}) { function refreshMyWorkView({ reconcileSession = true } = {}) {
lastDrafts = listDrafts(); 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), pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
}); });
activeMyWork = partitioned.active; activeMyWork = partitioned.active;
@ -2574,7 +2580,7 @@
const authoritativeTodayReconciliation = liveMode && hasContextSnapshot && const authoritativeTodayReconciliation = liveMode && hasContextSnapshot &&
!lastContextSnapshot?.error && !lastContextSnapshot?.error &&
!Object.values(workPagination).some(page => page?.has_more); !Object.values(workPagination).some(page => page?.has_more);
todayMyWork = todayWork.reconcile(lastMyWork, { todayMyWork = todayWork.reconcile(actionableMyWork, {
pruneMissing: authoritativeTodayReconciliation, pruneMissing: authoritativeTodayReconciliation,
onPrune: retiredIds => { onPrune: retiredIds => {
const queued = retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean); const queued = retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean);
@ -2871,9 +2877,11 @@
renderDrafts(); renderDrafts();
qs('#bulk-mark-read-bar').hidden = true; qs('#bulk-mark-read-bar').hidden = true;
qs('#load-more-notifications').hidden = true; qs('#load-more-notifications').hidden = true;
qs('#filed-history-tabs').hidden = true;
return; 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(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ?
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'agenda' ? filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'agenda' ?
agendaMyWork(activeMyWork) : agendaMyWork(activeMyWork) :
@ -2954,8 +2962,9 @@
(selectedWorkFilter === 'agenda' && workPagination.issue?.has_more ? (selectedWorkFilter === 'agenda' && workPagination.issue?.has_more ?
(agendaChecking ? 'Checking all assigned deadlines…' : (agendaChecking ? 'Checking all assigned deadlines…' :
'Older assigned deadlines remain unchecked. Retry the Agenda check.') : (incomplete ? 'Older assigned deadlines remain unchecked. Retry the Agenda check.') : (incomplete ?
'More work is available. Load the next page.' : 'More work is available. Load the next page.' : (selectedWorkFilter === 'filed' && selectedFiledView === 'reviewed' ?
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.')) + '</div>'); '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'))))) + '.'))) + '</div>');
cardPlanning.wire(); cardPlanning.wire();
document.querySelectorAll('[data-select-notification-id]').forEach(input => { document.querySelectorAll('[data-select-notification-id]').forEach(input => {
input.addEventListener('change', () => { input.addEventListener('change', () => {
@ -3472,10 +3481,9 @@
return; return;
} }
const acknowledged = selectedIssue; const acknowledged = selectedIssue;
lastMyWork = completedFiledReview.visible(lastMyWork);
closeIssueSheet(false); closeIssueSheet(false);
refreshMyWorkView(); refreshMyWorkView();
const target = filedFollowUpTarget(lastMyWork); const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork));
qs('#my-work-action-status').textContent = 'Reviewed ' + acknowledged.key + '.' + qs('#my-work-action-status').textContent = 'Reviewed ' + acknowledged.key + '.' +
(target ? ' Opening the next Filed item. Acknowledgement sync pending.' : (target ? ' Opening the next Filed item. Acknowledgement sync pending.' :
' Filed review is complete. Acknowledgement sync pending.'); ' Filed review is complete. Acknowledgement sync pending.');

View File

@ -247,6 +247,10 @@
</div> </div>
<button id="protect-today" type="button">Protect Today</button> <button id="protect-today" type="button">Protect Today</button>
</section> </section>
<div class="filed-history-tabs" id="filed-history-tabs" role="group" aria-label="Filed outcome view" hidden>
<button type="button" data-filed-view="needs-review" aria-pressed="true">Needs review <span id="filed-needs-review-count">0</span></button>
<button type="button" data-filed-view="reviewed" aria-pressed="false">Reviewed <span id="filed-reviewed-count">0</span></button>
</div>
<div class="my-work-list" id="my-work-list"></div> <div class="my-work-list" id="my-work-list"></div>
<div class="small" id="work-page-status" aria-live="polite"></div> <div class="small" id="work-page-status" aria-live="polite"></div>
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button> <button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>

View File

@ -691,12 +691,22 @@ function createCompletedFiledReview({
repository:String(item?.repository || ''), number:item?.number, updated_at:stamp, repository:String(item?.repository || ''), number:item?.number, updated_at:stamp,
}); });
const save = value => storage?.setItem(ownerKey(), JSON.stringify({ version:1, ...value })); 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 { return {
partition,
visible(items) { visible(items) {
const acknowledged = read().items; return partition(items).needsReview;
return (items || []).filter(item =>
!item?.is_completed || acknowledged[identity(item)] !== String(item.updated_at || '')
);
}, },
pending() { pending() {
const state = read(); 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()) { function agendaMyWork(items, now = new Date()) {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const today = localDay(start); const today = localDay(start);
@ -1058,6 +1090,7 @@ function countMyWork(items) {
if (typeof module !== 'undefined' && module.exports) { if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork; buildMyWork.filterMyWork = filterMyWork;
buildMyWork.createCompletedFiledReview = createCompletedFiledReview; buildMyWork.createCompletedFiledReview = createCompletedFiledReview;
buildMyWork.createFiledHistoryTabs = createFiledHistoryTabs;
buildMyWork.agendaMyWork = agendaMyWork; buildMyWork.agendaMyWork = agendaMyWork;
buildMyWork.milestoneLanes = milestoneLanes; buildMyWork.milestoneLanes = milestoneLanes;
buildMyWork.createWorkSession = createWorkSession; buildMyWork.createWorkSession = createWorkSession;

View File

@ -582,7 +582,7 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
assert '<strong>Filed</strong><small>Issues you delegated</small>' in html assert '<strong>Filed</strong><small>Issues you delegated</small>' in html
assert 'data-mobile-queue-count="filed"' in html assert 'data-mobile-queue-count="filed"' in html
assert "openFiled: openFiledFollowUp" 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="later"' in html
assert 'data-mobile-queue="draft"' in html assert 'data-mobile-queue="draft"' in html
assert 'data-mobile-queue="recaps"' in html assert 'data-mobile-queue="recaps"' in html

View File

@ -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(): def test_completed_filed_review_merges_remote_receipts_and_keeps_local_work_pending():
script = f""" script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))}); 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 @pytest.mark.anyio
async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow(): async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow():
markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text() 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 'id="acknowledge-completed-filed"' in markup
assert 'Acknowledge &amp; next' in markup assert 'Acknowledge &amp; next' in markup
assert "createCompletedFiledReview({" in source assert "createCompletedFiledReview({" in source
assert "completedFiledReview.visible(buildMyWork(data))" in source assert "lastMyWork = buildMyWork(data)" in source
assert "filed: 'filed issues'" in source assert "filed: 'filed issues'" in source
handler = source.split("qs('#acknowledge-completed-filed').addEventListener('click'", 1)[1] handler = source.split("qs('#acknowledge-completed-filed').addEventListener('click'", 1)[1]
assert "completedFiledReview.acknowledge(selectedIssue)" in handler assert "completedFiledReview.acknowledge(selectedIssue)" in handler
assert "syncCompletedFiledReviews()" in source assert "syncCompletedFiledReviews()" in source
assert "api/v1/completed-filed-reviews" in source assert "api/v1/completed-filed-reviews" in source
assert "sync pending" in source assert "sync pending" in source
assert "filedFollowUpTarget(lastMyWork)" in handler assert "filedFollowUpTarget(completedFiledReview.visible(lastMyWork))" in handler
assert "openRoutedWork" in handler assert "openRoutedWork" in handler
assert ".completed-filed-actions" in css assert ".completed-filed-actions" in css
action_rule = css.split(".completed-filed-actions", 1)[1].split("}", 1)[0] 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 'data-work-count="later"' in html
assert 'const laterWork = createLaterWork({' in html assert 'const laterWork = createLaterWork({' in html
assert 'getLogin: () => planningOwnerLogin' 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="today"' in html
assert 'data-later-preset="tomorrow"' in html assert 'data-later-preset="tomorrow"' in html
assert 'data-later-restore' in html assert 'data-later-restore' in html