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 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); }
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
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/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',
|
||||||
|
|
|
||||||
|
|
@ -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
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/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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user