diff --git a/frontend/detail-defer.js b/frontend/detail-defer.js new file mode 100644 index 0000000..943da0c --- /dev/null +++ b/frontend/detail-defer.js @@ -0,0 +1,25 @@ +function createDetailDefer({ + laterWork, + session, + close, + refresh, + focus, + announce, + formatTime = value => new Date(value).toLocaleString(), +}) { + return { + defer(item, preset) { + if (!item) return false; + const until = laterWork.presetUntil(preset); + if (!laterWork.defer(item, until)) return false; + const inSession = session.active(); + if (!inSession) close(); + refresh(); + announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.'); + if (!inSession) focus(); + return true; + }, + }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createDetailDefer; diff --git a/frontend/index.html b/frontend/index.html index e171104..74625ed 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -84,6 +84,11 @@ textarea { resize: vertical; min-height: 120px; } .my-work-card-title { display:block; margin:5px 0; font-weight:650; } .later-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; } .later-actions button { min-height:44px; width:100%; } +.detail-defer { grid-column:1/-1; max-width:100%; } +.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; } +.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } +.detail-defer-options { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; margin-top:8px; } +.review-sheet-actions { position:sticky; bottom:0; z-index:3; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } .mark-update-read { min-height:44px; width:100%; } .read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; } .load-more-notifications { min-height:44px; width:100%; margin-top:10px; } @@ -514,6 +519,7 @@ textarea { resize: vertical; min-height: 120px; } Release assignment Close issue Open in Gitea + DeferLater todayTomorrowCancel @@ -617,6 +623,7 @@ textarea { resize: vertical; min-height: 120px; } Share Mark read & next Open in Gitea + DeferLater todayTomorrowCancel @@ -656,6 +663,7 @@ textarea { resize: vertical; min-height: 120px; } Share Open in Gitea + DeferLater todayTomorrowCancel @@ -720,6 +728,7 @@ textarea { resize: vertical; min-height: 120px; } Review history + DeferLater todayTomorrowCancel Previous @@ -750,6 +759,7 @@ textarea { resize: vertical; min-height: 120px; } + @@ -1255,6 +1265,30 @@ textarea { resize: vertical; min-height: 120px; } }, }); + const detailDefer = createDetailDefer({ + laterWork, + session: workSession, + close: () => workRoute.close(), + refresh: refreshMyWorkView, + focus: () => qs('[data-work-filter="' + selectedWorkFilter + '"]')?.focus(), + announce: message => { qs('#my-work-action-status').textContent = message; }, + formatTime: fmt, + }); + document.querySelectorAll('[data-detail-defer-preset]').forEach(button => { + button.addEventListener('click', () => { + const item = selectedUpdate || selectedReview || selectedIssue || selectedPull; + button.closest('.detail-defer').open = false; + detailDefer.defer(item, button.dataset.detailDeferPreset); + }); + }); + document.querySelectorAll('[data-detail-defer-cancel]').forEach(button => { + button.addEventListener('click', () => { + const chooser = button.closest('.detail-defer'); + chooser.open = false; + chooser.querySelector('summary')?.focus(); + }); + }); + function renderContextSnapshot(data) { liveMode = true; hasContextSnapshot = true; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 703b0f9..fbd115d 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-v14'; +const CACHE = 'stackchain-dashboard-shell-v15'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, @@ -18,6 +18,7 @@ const SHELL = [ BASE + 'static/offline-work.js', BASE + 'static/my-work.js', BASE + 'static/later-work.js', + BASE + 'static/detail-defer.js', BASE + 'static/pick-work.js', BASE + 'static/conversation.js', BASE + 'static/issue-sheet.js', diff --git a/tests/test_my_work.py b/tests/test_my_work.py index eb5d70a..6608f60 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -10,6 +10,7 @@ from src.views import dashboard MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js" LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js" +DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js" REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js" ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js" CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js" @@ -838,6 +839,65 @@ process.stdout.write(JSON.stringify({{ } +def test_detail_defer_closes_normal_triage_but_keeps_session_open_for_reconcile(): + script = f""" +const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))}); +const calls = []; +let sessionActive = false; +const controller = createDetailDefer({{ + laterWork: {{ + presetUntil:preset => new Date(preset === 'today' ? '2026-08-08T16:00:00Z' : '2026-08-09T09:00:00Z'), + defer:(item, until) => {{ calls.push(['defer', item.title, until.toISOString()]); return true; }}, + }}, + session: {{ active:() => sessionActive }}, + close:() => calls.push(['close']), + refresh:() => calls.push(['refresh']), + focus:() => calls.push(['focus']), + announce:message => calls.push(['announce', message]), + formatTime:value => value.toISOString(), +}}); +const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Read first'}}; +const outside = controller.defer(item, 'today'); +sessionActive = true; +const session = controller.defer(item, 'tomorrow'); +process.stdout.write(JSON.stringify({{outside,session,calls}})); +""" + + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + + assert json.loads(result.stdout) == { + "outside": True, + "session": True, + "calls": [ + ["defer", "Read first", "2026-08-08T16:00:00.000Z"], + ["close"], + ["refresh"], + ["announce", "Deferred until 2026-08-08T16:00:00.000Z. It stays unread and unchanged in Gitea."], + ["focus"], + ["defer", "Read first", "2026-08-09T09:00:00.000Z"], + ["refresh"], + ["announce", "Deferred until 2026-08-09T09:00:00.000Z. It stays unread and unchanged in Gitea."], + ], + } + + +@pytest.mark.anyio +async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutation(): + html = await dashboard() + + assert '' in html + assert html.count('class="detail-defer"') == 4 + assert html.count('data-detail-defer-preset="today"') == 4 + assert html.count('data-detail-defer-preset="tomorrow"') == 4 + assert 'const detailDefer = createDetailDefer({' in html + assert 'selectedUpdate || selectedReview || selectedIssue || selectedPull' in html + assert "detailDefer.defer(item, button.dataset.detailDeferPreset)" in html + assert ".detail-defer summary, .detail-defer button { min-height:44px;" in html + assert "fetch(" not in DETAIL_DEFER.read_text() + + @pytest.mark.anyio async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions(): html = await dashboard() diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index b4289dc..fd383a5 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -70,10 +70,10 @@ async function dispatchSync(tag) {{ return json.loads(completed.stdout) -def test_background_authored_sync_ships_in_a_new_shell_cache(): +def test_detail_defer_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v14" in source + assert "stackchain-dashboard-shell-v15" in source def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(): @@ -114,6 +114,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/offline-work.js", "/dashboard/static/my-work.js", "/dashboard/static/later-work.js", + "/dashboard/static/detail-defer.js", "/dashboard/static/pick-work.js", "/dashboard/static/conversation.js", "/dashboard/static/issue-sheet.js",