feat: protect Today from urgent deadlines (Closes #721)
This commit is contained in:
parent
0d29b29bef
commit
fcc9ead33e
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -221,6 +221,14 @@
|
|||
</div>
|
||||
</div>
|
||||
</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="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>
|
||||
|
|
@ -1003,6 +1011,7 @@
|
|||
<script src="static/offline-today.js"></script>
|
||||
<script src="static/my-work.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/card-planning.js"></script>
|
||||
<script src="static/work-selection.js"></script>
|
||||
|
|
|
|||
46
frontend/protect-today.js
Normal file
46
frontend/protect-today.js
Normal 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;
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
106
tests/test_protect_today.py
Normal file
106
tests/test_protect_today.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user