From fcc9ead33ef68f783df5c26da032836dfe38be3e Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 13 Aug 2026 08:27:01 +0000 Subject: [PATCH] feat: protect Today from urgent deadlines (Closes #721) --- frontend/dashboard.css | 4 ++ frontend/dashboard.js | 29 +++++++++- frontend/index.html | 9 +++ frontend/protect-today.js | 46 +++++++++++++++ frontend/service-worker.js | 1 + src/frontend_bundle.py | 2 +- tests/test_protect_today.py | 106 +++++++++++++++++++++++++++++++++++ tests/test_service_worker.py | 1 + 8 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 frontend/protect-today.js create mode 100644 tests/test_protect_today.py diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 1a438d8..dcc1905 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -253,7 +253,11 @@ textarea { resize: vertical; min-height: 120px; } .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%; } +.protect-today { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; } +.protect-today p { margin:4px 0; } +.protect-today button { min-height:44px; flex:0 0 auto; } @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; } } +@media(max-width:430px) { .protect-today { align-items:stretch; flex-direction:column; } .protect-today button { width:100%; } } .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 3b1b666..bffbdf7 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1533,6 +1533,7 @@ } let planTodayTrigger = null; + let pendingProtectToday = null; function formatPlanMinutes(minutes) { if (!Number.isInteger(minutes)) return 'Not set'; const absolute = Math.abs(minutes); @@ -1972,7 +1973,7 @@ return; } if (trigger) planTodayTrigger = trigger; - qs('#plan-today-title').textContent = rolloverReviewPlan ? 'New day review' : 'Plan Today'; + qs('#plan-today-title').textContent = pendingProtectToday ? 'Protect Today' : (rolloverReviewPlan ? 'New day review' : 'Plan Today'); if (actualMinutes) pendingPlanActualMinutes = actualMinutes; if (navigate) { taskOverlayHistory.open('plan-today'); @@ -1980,15 +1981,33 @@ } const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes; pendingPlanActualMinutes = null; - planToday.open(todayMyWork, activeMyWork, todayWork.planning(), recommendations); + const protectProposal = pendingProtectToday; + const selected = protectProposal?.selected || todayMyWork; + planToday.open(selected, activeMyWork, todayWork.planning(), recommendations); + pendingProtectToday = null; qs('#discard-recap-replan').hidden = !todayRecapView.pendingReplan(); qs('#plan-today-error').textContent = ''; qs('#plan-today-sheet').hidden = false; document.body.classList.add('task-overlay-open'); renderPlanToday(); + if (protectProposal) qs('#plan-today-build-status').textContent = protectProposal.summary + + (protectProposal.displaced.length ? '. Displaced work remains unchanged until you save.' : '. Review estimates and capacity before saving.'); qs('#cancel-plan-today').focus(); } + qs('#protect-today').addEventListener('click', event => { + const proposal = protectToday.propose({ + agenda:agendaMyWork(activeMyWork), today:todayMyWork, + identity:item => todayWork.identity(item), limit:todayWork.limit, + }); + if (!proposal.protected.length) { + qs('#protect-today-status').textContent = 'No overdue or due-today work needs protection.'; + return; + } + pendingProtectToday = proposal; + openPlanToday(event.currentTarget); + }); + qs('#discard-recap-replan').addEventListener('click', () => { todayRecapView.discardReplan(); planToday.open(todayMyWork, activeMyWork, todayWork.planning()); @@ -2515,6 +2534,12 @@ agendaMyWork(activeMyWork) : filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone); const overdue = queueItems.filter(item => item.agenda_group === 'Overdue'); + const urgentAgenda = selectedWorkFilter === 'agenda' ? queueItems.filter(item => + item.agenda_group === 'Overdue' || item.agenda_group === 'Today') : []; + const protectPanel = qs('#protect-today-panel'); + protectPanel.hidden = selectedWorkFilter !== 'agenda' || urgentAgenda.length === 0; + qs('#protect-today-status').textContent = urgentAgenda.length ? + urgentAgenda.length + ' urgent ' + (urgentAgenda.length === 1 ? 'deadline is' : 'deadlines are') + ' ready to reconcile with Today.' : ''; const replanPanel = qs('#agenda-replan'); replanPanel.hidden = selectedWorkFilter !== 'agenda' || overdue.length === 0; if (!agendaReplan?.snapshot().active) { diff --git a/frontend/index.html b/frontend/index.html index 3f3de8c..d753cf7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -221,6 +221,14 @@ +
@@ -1003,6 +1011,7 @@ + diff --git a/frontend/protect-today.js b/frontend/protect-today.js new file mode 100644 index 0000000..5b0e350 --- /dev/null +++ b/frontend/protect-today.js @@ -0,0 +1,46 @@ +function createProtectToday() { + function propose({ agenda = [], today = [], identity, limit = 5 }) { + const key = item => String(identity?.(item) || ''); + const urgent = []; + const seen = new Set(); + for (const item of [...agenda, ...today]) { + const id = key(item); + const eligible = item?.kind === 'issue' && item.is_assigned === true && item.state !== 'closed' && + ['Overdue', 'Today'].includes(item.agenda_group); + if (!eligible || !id || seen.has(id)) continue; + seen.add(id); + urgent.push(item); + } + urgent.sort((left, right) => + String(left.due_date || '').localeCompare(String(right.due_date || '')) || + String(left.repository || '').localeCompare(String(right.repository || '')) || + Number(left.number || 0) - Number(right.number || 0) + ); + if (!urgent.length) return { + selected:[], protected:[], displaced:[], + summary:'No overdue or due-today work needs protection.', + }; + const existing = []; + for (const item of today) { + const id = key(item); + if (!id || seen.has(id)) continue; + seen.add(id); + existing.push(item); + } + const combined = [...urgent, ...existing]; + const selected = combined.slice(0, limit); + const selectedIds = new Set(selected.map(key)); + const displaced = today.filter(item => !selectedIds.has(key(item)) && !urgent.some(candidate => key(candidate) === key(item))); + return { + selected, + protected:urgent, + displaced, + summary:urgent.length + ' urgent ' + (urgent.length === 1 ? 'item' : 'items') + ' protected · ' + + displaced.length + (displaced.length ? ' displaced for review' : ' displaced'), + }; + } + return { propose }; +} + +const protectToday = createProtectToday(); +if (typeof module !== 'undefined' && module.exports) module.exports = protectToday; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 5757aa9..9ea40cc 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -33,6 +33,7 @@ const SHELL = [ BASE + 'static/offline-today.js', BASE + 'static/my-work.js', BASE + 'static/agenda-replan.js', + BASE + 'static/protect-today.js', BASE + 'static/notification-undo.js', BASE + 'static/card-planning.js', BASE + 'static/work-selection.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index de621f1..498e618 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -28,7 +28,7 @@ FEATURE_SOURCES = { "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), "security-center": ("static/security-center.js",), "today-timer": ( - "static/my-work.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", + "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js", diff --git a/tests/test_protect_today.py b/tests/test_protect_today.py new file mode 100644 index 0000000..440f81f --- /dev/null +++ b/tests/test_protect_today.py @@ -0,0 +1,106 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).parents[1] +PROTECT_TODAY = ROOT / "frontend" / "protect-today.js" + + +def run_node(script: str) -> dict: + result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + return json.loads(result.stdout) + + +def test_protect_today_prioritizes_and_deduplicates_urgent_deadlines_before_existing_plan(): + script = f""" +const protectToday = require({json.dumps(str(PROTECT_TODAY))}); +const issue = (number, group, due) => ({{ + kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number, + is_assigned:true, state:'open', agenda_group:group, due_date:due, +}}); +const urgentLater = issue(3, 'Today', '2026-08-13'); +const urgentFirst = issue(2, 'Overdue', '2026-08-12'); +const duplicate = issue(2, 'Overdue', '2026-08-12'); +const existingUrgent = issue(1, 'Today', '2026-08-13'); +const existingNormal = issue(8, null, null); +const result = protectToday.propose({{ + agenda:[urgentLater, duplicate, urgentFirst, issue(4, 'Tomorrow', '2026-08-14')], + today:[existingNormal, existingUrgent], + identity:item => item.repository + '#' + item.number, + limit:5, +}}); +process.stdout.write(JSON.stringify({{ + selected:result.selected.map(item => item.number), + protected:result.protected.map(item => item.number), + displaced:result.displaced.map(item => item.number), + summary:result.summary, +}})); +""" + assert run_node(script) == { + "selected": [2, 1, 3, 8], + "protected": [2, 1, 3], + "displaced": [], + "summary": "3 urgent items protected · 0 displaced", + } + + +def test_protect_today_reports_existing_work_displaced_by_five_item_limit(): + script = f""" +const protectToday = require({json.dumps(str(PROTECT_TODAY))}); +const issue = (number, group = null) => ({{ + kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number, + is_assigned:true, state:'open', agenda_group:group, + due_date:group === 'Overdue' ? '2026-08-12' : group === 'Today' ? '2026-08-13' : null, +}}); +const result = protectToday.propose({{ + agenda:[issue(10, 'Overdue'), issue(11, 'Today'), issue(12, 'Today')], + today:[issue(1), issue(2), issue(3), issue(4), issue(5)], + identity:item => item.repository + '#' + item.number, + limit:5, +}}); +process.stdout.write(JSON.stringify({{ + selected:result.selected.map(item => item.number), + displaced:result.displaced.map(item => item.number), + summary:result.summary, +}})); +""" + assert run_node(script) == { + "selected": [10, 11, 12, 1, 2], + "displaced": [3, 4, 5], + "summary": "3 urgent items protected · 3 displaced for review", + } + + +def test_protect_today_excludes_closed_unassigned_and_nonurgent_items(): + script = f""" +const protectToday = require({json.dumps(str(PROTECT_TODAY))}); +const items = [ + {{kind:'issue',repository:'r',number:1,is_assigned:true,state:'closed',agenda_group:'Overdue',due_date:'2026-08-01'}}, + {{kind:'issue',repository:'r',number:2,is_assigned:false,state:'open',agenda_group:'Today',due_date:'2026-08-13'}}, + {{kind:'issue',repository:'r',number:3,is_assigned:true,state:'open',agenda_group:'Tomorrow',due_date:'2026-08-14'}}, + {{kind:'pull',repository:'r',number:4,is_assigned:true,state:'open',agenda_group:'Overdue',due_date:'2026-08-01'}}, +]; +const result = protectToday.propose({{agenda:items,today:[],identity:item => item.repository + '#' + item.number}}); +process.stdout.write(JSON.stringify(result)); +""" + assert run_node(script) == { + "selected": [], "protected": [], "displaced": [], + "summary": "No overdue or due-today work needs protection.", + } + + +def test_dashboard_renders_mobile_protect_today_entrypoint_and_preview_context(): + html = (ROOT / "frontend" / "index.html").read_text() + dashboard = (ROOT / "frontend" / "dashboard.js").read_text() + css = (ROOT / "frontend" / "dashboard.css").read_text() + bundle = (ROOT / "src" / "frontend_bundle.py").read_text() + + assert 'id="protect-today"' in html + assert 'id="protect-today-status"' in html + assert "protectToday.propose" in dashboard + assert "pendingProtectToday" in dashboard + assert "Protect Today" in dashboard + assert ".protect-today" in css + assert "min-height:44px" in css.replace(" ", "") + assert '"static/protect-today.js"' in bundle diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index dd01c63..c037435 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -722,6 +722,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/offline-today.js", "/dashboard/static/my-work.js", "/dashboard/static/agenda-replan.js", + "/dashboard/static/protect-today.js", "/dashboard/static/notification-undo.js", "/dashboard/static/card-planning.js", "/dashboard/static/work-selection.js", -- 2.43.0