diff --git a/frontend/agenda-replan.js b/frontend/agenda-replan.js new file mode 100644 index 0000000..250bd78 --- /dev/null +++ b/frontend/agenda-replan.js @@ -0,0 +1,79 @@ +function createAgendaReplan({ now = () => new Date(), update }) { + let items = []; + let index = 0; + let pending = false; + let active = false; + + const dayKey = date => [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0'), + ].join('-'); + const current = () => active ? items[index] || null : null; + const snapshot = () => ({ + active, + index, + total: items.length, + current: current()?.key || null, + pending, + }); + const advance = () => { + index += 1; + if (index >= items.length) active = false; + return { ok:true, done:!active }; + }; + const change = async dueDate => { + const item = current(); + if (!item) return { ok:false, error:'No overdue deadline selected.' }; + if (pending) return { ok:false, error:'Deadline update already in progress.' }; + pending = true; + try { + await update(item, dueDate); + return advance(); + } catch (error) { + return { ok:false, error:error?.message || 'Deadline could not be updated.' }; + } finally { + pending = false; + } + }; + + return { + start(overdue) { + items = (overdue || []).slice().sort((left, right) => + new Date(left.due_date).getTime() - new Date(right.due_date).getTime() || + String(left.repository || '').localeCompare(String(right.repository || '')) || + Number(left.number || 0) - Number(right.number || 0) + ); + index = 0; + pending = false; + active = items.length > 0; + return snapshot(); + }, + snapshot, + current, + keep() { + if (pending) return Promise.resolve({ ok:false, error:'Deadline update already in progress.' }); + if (!current()) return Promise.resolve({ ok:false, error:'No overdue deadline selected.' }); + return Promise.resolve(advance()); + }, + tomorrow() { + const date = new Date(now()); + date.setDate(date.getDate() + 1); + return change(dayKey(date) + 'T23:59:59Z'); + }, + choose(value) { + const chosen = String(value || ''); + if (!/^\d{4}-\d{2}-\d{2}$/.test(chosen) || chosen <= dayKey(now())) { + return Promise.resolve({ ok:false, error:'Choose a future date.' }); + } + return change(chosen + 'T23:59:59Z'); + }, + cancel() { + active = false; + pending = false; + return snapshot(); + }, + }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createAgendaReplan; diff --git a/frontend/dashboard.css b/frontend/dashboard.css index f103e24..250cb0c 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -243,6 +243,14 @@ textarea { resize: vertical; min-height: 120px; } .draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; } .draft-filing-session-actions button { min-height:44px; width:100%; } .my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); } +.agenda-replan { margin:10px 0; padding:12px; border:1px solid #7c4a1d; border-radius:12px; background:#24170d; } +.agenda-replan-launch { display:flex; align-items:center; justify-content:space-between; gap:12px; } +.agenda-replan-launch p { margin:4px 0 0; } +.agenda-replan-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:10px; } +.agenda-replan-actions button { min-height:44px; width:100%; } +.agenda-replan-actions label { grid-column:1/-1; } +.agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; } +@media(max-width:360px) { .agenda-replan-launch { align-items:stretch; flex-direction:column; } .agenda-replan-actions { grid-template-columns:1fr; } .agenda-replan-actions label { grid-column:auto; } } .my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; } .my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; } .my-work-card:hover { border-color:var(--accent); } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 3bf330b..1cf8f6c 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -149,6 +149,7 @@ let notificationPagination = { page: 1, total: 0, has_more: false }; let workPagination = {}; let agendaChecking = false; + let agendaReplan = null; let hasContextSnapshot = false; let selectedReview = null; let reviewTrigger = null; @@ -329,6 +330,62 @@ let reviewController = null; let wrapPreference = null; const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage }); + function overdueAgendaItems() { + return agendaMyWork(activeMyWork).filter(item => item.agenda_group === 'Overdue'); + } + function renderAgendaReplan() { + const state = agendaReplan.snapshot(); + qs('#agenda-replan-controls').hidden = !state.active; + if (!state.active) return; + qs('#agenda-replan-progress').textContent = + 'Overdue deadline ' + (state.index + 1) + ' of ' + state.total + ' ยท ' + (agendaReplan.current()?.key || ''); + } + function openAgendaReplanCurrent() { + const item = agendaReplan.current(); + if (!item) { + closeIssueSheet(false); + renderAgendaReplan(); + qs('#my-work-action-status').textContent = 'Overdue sweep complete. Agenda updated.'; + return; + } + renderAgendaReplan(); + openIssueSheet(item, qs('#start-agenda-replan')); + } + agendaReplan = createAgendaReplan({ + update: async (item, dueDate) => { + const confirmed = await issueController.updateDueDate(item, dueDate); + lastContextSnapshot = buildMyWork.replaceIssueDueDate( + lastContextSnapshot, item.repository, item.number, confirmed.due_date + ); + paintMyWork(lastContextSnapshot); + return confirmed; + }, + }); + qs('#start-agenda-replan').addEventListener('click', () => { + const overdue = overdueAgendaItems(); + agendaReplan.start(overdue); + openAgendaReplanCurrent(); + }); + async function runAgendaReplan(action) { + const result = await action(); + if (!result.ok) { + qs('#agenda-replan-progress').textContent = result.error + ' Current deadline retained; retry.'; + return; + } + openAgendaReplanCurrent(); + } + qs('#agenda-replan-keep').addEventListener('click', () => runAgendaReplan(() => agendaReplan.keep())); + qs('#agenda-replan-tomorrow').addEventListener('click', () => runAgendaReplan(() => agendaReplan.tomorrow())); + qs('#agenda-replan-choose').addEventListener('click', () => + runAgendaReplan(() => agendaReplan.choose(qs('#agenda-replan-date').value)) + ); + qs('#agenda-replan-cancel').addEventListener('click', () => { + agendaReplan.cancel(); + closeIssueSheet(false); + renderAgendaReplan(); + window.location.hash = '#/my-work/agenda'; + qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.'; + }); const issueAttachmentController = issueAttachment.mount({ input: qs('#issue-attachment'), preview: qs('#issue-attachment-preview'), @@ -2449,6 +2506,13 @@ filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork) : filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone); + const overdue = queueItems.filter(item => item.agenda_group === 'Overdue'); + const replanPanel = qs('#agenda-replan'); + replanPanel.hidden = selectedWorkFilter !== 'agenda' || overdue.length === 0; + if (!agendaReplan?.snapshot().active) { + qs('#start-agenda-replan').textContent = 'Replan overdue (' + overdue.length + ')'; + qs('#agenda-replan-controls').hidden = true; + } const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more); const visible = findQueueItems(queueItems, queueFindQuery); const emptyWorkStart = qs('#empty-work-start'); diff --git a/frontend/index.html b/frontend/index.html index fe8bab5..f9b0d1b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -193,6 +193,23 @@ +
@@ -974,6 +991,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 8e86097..59f43b3 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -32,6 +32,7 @@ const SHELL = [ BASE + 'static/offline-work.js', BASE + 'static/offline-today.js', BASE + 'static/my-work.js', + BASE + 'static/agenda-replan.js', BASE + 'static/notification-undo.js', BASE + 'static/card-planning.js', BASE + 'static/work-selection.js', diff --git a/tests/test_mobile_agenda_replan.py b/tests/test_mobile_agenda_replan.py new file mode 100644 index 0000000..d6929f0 --- /dev/null +++ b/tests/test_mobile_agenda_replan.py @@ -0,0 +1,101 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from tests.dashboard_bundle import dashboard + + +ROOT = Path(__file__).resolve().parents[1] +REPLAN = ROOT / "frontend" / "agenda-replan.js" + + +def run_node(script): + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_overdue_replan_sweep_orders_items_and_only_advances_after_confirmed_change(): + script = f""" +const createSweep = require({json.dumps(str(REPLAN))}); +const calls = []; +let fail = true; +const items = [ + {{key:'o/r#3', repository:'o/r', number:3, due_date:'2026-08-11T18:00:00Z'}}, + {{key:'o/r#1', repository:'o/r', number:1, due_date:'2026-08-09T18:00:00Z'}}, + {{key:'o/r#2', repository:'o/r', number:2, due_date:'2026-08-10T18:00:00Z'}}, +]; +const sweep = createSweep({{ + now: () => new Date('2026-08-12T12:00:00'), + update: async (item, due) => {{ calls.push([item.key, due]); if (fail) throw new Error('offline'); return {{due_date:due}}; }}, +}}); +async function run() {{ + const started = sweep.start(items); + const failed = await sweep.tomorrow(); + const afterFailure = sweep.snapshot(); + fail = false; + const changed = await sweep.tomorrow(); + const afterChange = sweep.snapshot(); + const kept = await sweep.keep(); + process.stdout.write(JSON.stringify({{started, failed, afterFailure, changed, afterChange, kept, calls}})); +}} +run(); +""" + assert run_node(script) == { + "started": {"active": True, "index": 0, "total": 3, "current": "o/r#1", "pending": False}, + "failed": {"ok": False, "error": "offline"}, + "afterFailure": {"active": True, "index": 0, "total": 3, "current": "o/r#1", "pending": False}, + "changed": {"ok": True, "done": False}, + "afterChange": {"active": True, "index": 1, "total": 3, "current": "o/r#2", "pending": False}, + "kept": {"ok": True, "done": False}, + "calls": [["o/r#1", "2026-08-13T23:59:59Z"], ["o/r#1", "2026-08-13T23:59:59Z"]], + } + + +def test_overdue_replan_rejects_non_future_choice_and_prevents_parallel_updates(): + script = f""" +const createSweep = require({json.dumps(str(REPLAN))}); +let resolveUpdate; +let calls = 0; +const sweep = createSweep({{ + now: () => new Date('2026-08-12T12:00:00'), + update: async () => {{ calls += 1; await new Promise(resolve => resolveUpdate = resolve); return {{}}; }}, +}}); +async function run() {{ + sweep.start([{{key:'o/r#1', due_date:'2026-08-10T18:00:00Z'}}]); + const invalid = await sweep.choose('2026-08-12'); + const first = sweep.choose('2026-08-14'); + const duplicate = await sweep.choose('2026-08-15'); + resolveUpdate(); + const confirmed = await first; + process.stdout.write(JSON.stringify({{invalid, duplicate, confirmed, calls, final:sweep.snapshot()}})); +}} +run(); +""" + assert run_node(script) == { + "invalid": {"ok": False, "error": "Choose a future date."}, + "duplicate": {"ok": False, "error": "Deadline update already in progress."}, + "confirmed": {"ok": True, "done": True}, + "calls": 1, + "final": {"active": False, "index": 1, "total": 1, "current": None, "pending": False}, + } + + +@pytest.mark.anyio +async def test_mobile_agenda_exposes_thumb_safe_replan_controls_and_wires_existing_mutation(): + html = await dashboard() + + assert 'id="start-agenda-replan"' in html + assert 'id="agenda-replan-controls"' in html + assert 'id="agenda-replan-progress"' in html + assert 'id="agenda-replan-keep"' in html + assert 'id="agenda-replan-tomorrow"' in html + assert 'id="agenda-replan-date"' in html + assert 'id="agenda-replan-choose"' in html + assert 'id="agenda-replan-cancel"' in html + assert "agendaReplan.start(overdue)" in html + assert "issueController.updateDueDate(item, dueDate)" in html + assert ".agenda-replan-actions button { min-height:44px;" in html + assert "@media(max-width:360px)" in html diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index b0ec602..e947291 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -695,6 +695,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/offline-work.js", "/dashboard/static/offline-today.js", "/dashboard/static/my-work.js", + "/dashboard/static/agenda-replan.js", "/dashboard/static/notification-undo.js", "/dashboard/static/card-planning.js", "/dashboard/static/work-selection.js",