feat: protect Today from urgent deadlines (Closes #721)
All checks were successful
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-13 08:27:01 +00:00
parent 0d29b29bef
commit fcc9ead33e
8 changed files with 195 additions and 3 deletions

View File

@ -253,7 +253,11 @@ textarea { resize: vertical; min-height: 120px; }
.agenda-replan-actions button { min-height:44px; width:100%; } .agenda-replan-actions button { min-height:44px; width:100%; }
.agenda-replan-actions label { grid-column:1/-1; } .agenda-replan-actions label { grid-column:1/-1; }
.agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; } .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: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 { 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-main.review-trigger { width:100%; text-align:left; font:inherit; }
.my-work-card:hover { border-color:var(--accent); } .my-work-card:hover { border-color:var(--accent); }

View File

@ -1533,6 +1533,7 @@
} }
let planTodayTrigger = null; let planTodayTrigger = null;
let pendingProtectToday = null;
function formatPlanMinutes(minutes) { function formatPlanMinutes(minutes) {
if (!Number.isInteger(minutes)) return 'Not set'; if (!Number.isInteger(minutes)) return 'Not set';
const absolute = Math.abs(minutes); const absolute = Math.abs(minutes);
@ -1972,7 +1973,7 @@
return; return;
} }
if (trigger) planTodayTrigger = trigger; 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 (actualMinutes) pendingPlanActualMinutes = actualMinutes;
if (navigate) { if (navigate) {
taskOverlayHistory.open('plan-today'); taskOverlayHistory.open('plan-today');
@ -1980,15 +1981,33 @@
} }
const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes; const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes;
pendingPlanActualMinutes = null; 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('#discard-recap-replan').hidden = !todayRecapView.pendingReplan();
qs('#plan-today-error').textContent = ''; qs('#plan-today-error').textContent = '';
qs('#plan-today-sheet').hidden = false; qs('#plan-today-sheet').hidden = false;
document.body.classList.add('task-overlay-open'); document.body.classList.add('task-overlay-open');
renderPlanToday(); 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('#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', () => { qs('#discard-recap-replan').addEventListener('click', () => {
todayRecapView.discardReplan(); todayRecapView.discardReplan();
planToday.open(todayMyWork, activeMyWork, todayWork.planning()); planToday.open(todayMyWork, activeMyWork, todayWork.planning());
@ -2515,6 +2534,12 @@
agendaMyWork(activeMyWork) : agendaMyWork(activeMyWork) :
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone); filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
const overdue = queueItems.filter(item => item.agenda_group === 'Overdue'); 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'); const replanPanel = qs('#agenda-replan');
replanPanel.hidden = selectedWorkFilter !== 'agenda' || overdue.length === 0; replanPanel.hidden = selectedWorkFilter !== 'agenda' || overdue.length === 0;
if (!agendaReplan?.snapshot().active) { if (!agendaReplan?.snapshot().active) {

View File

@ -221,6 +221,14 @@
</div> </div>
</div> </div>
</section> </section>
<section class="protect-today" id="protect-today-panel" aria-labelledby="protect-today-heading" hidden>
<div>
<strong id="protect-today-heading">Deadlines need a plan</strong>
<p class="small muted">Put overdue and due-today work first, then review what fits.</p>
<div class="small" id="protect-today-status" role="status" aria-live="polite"></div>
</div>
<button id="protect-today" type="button">Protect Today</button>
</section>
<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>
@ -1003,6 +1011,7 @@
<script src="static/offline-today.js"></script> <script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script> <script src="static/my-work.js"></script>
<script src="static/agenda-replan.js"></script> <script src="static/agenda-replan.js"></script>
<script src="static/protect-today.js"></script>
<script src="static/notification-undo.js"></script> <script src="static/notification-undo.js"></script>
<script src="static/card-planning.js"></script> <script src="static/card-planning.js"></script>
<script src="static/work-selection.js"></script> <script src="static/work-selection.js"></script>

46
frontend/protect-today.js Normal file
View File

@ -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;

View File

@ -33,6 +33,7 @@ const SHELL = [
BASE + 'static/offline-today.js', BASE + 'static/offline-today.js',
BASE + 'static/my-work.js', BASE + 'static/my-work.js',
BASE + 'static/agenda-replan.js', BASE + 'static/agenda-replan.js',
BASE + 'static/protect-today.js',
BASE + 'static/notification-undo.js', BASE + 'static/notification-undo.js',
BASE + 'static/card-planning.js', BASE + 'static/card-planning.js',
BASE + 'static/work-selection.js', BASE + 'static/work-selection.js',

View File

@ -28,7 +28,7 @@ FEATURE_SOURCES = {
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",), "security-center": ("static/security-center.js",),
"today-timer": ( "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/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/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", "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",

106
tests/test_protect_today.py Normal file
View File

@ -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

View File

@ -722,6 +722,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/offline-today.js", "/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js", "/dashboard/static/my-work.js",
"/dashboard/static/agenda-replan.js", "/dashboard/static/agenda-replan.js",
"/dashboard/static/protect-today.js",
"/dashboard/static/notification-undo.js", "/dashboard/static/notification-undo.js",
"/dashboard/static/card-planning.js", "/dashboard/static/card-planning.js",
"/dashboard/static/work-selection.js", "/dashboard/static/work-selection.js",