Replan overdue Agenda deadlines in one mobile sweep #708
79
frontend/agenda-replan.js
Normal file
79
frontend/agenda-replan.js
Normal file
|
|
@ -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;
|
||||||
|
|
@ -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 { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||||
.draft-filing-session-actions button { min-height:44px; width:100%; }
|
.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); }
|
.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 { 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); }
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,7 @@
|
||||||
let notificationPagination = { page: 1, total: 0, has_more: false };
|
let notificationPagination = { page: 1, total: 0, has_more: false };
|
||||||
let workPagination = {};
|
let workPagination = {};
|
||||||
let agendaChecking = false;
|
let agendaChecking = false;
|
||||||
|
let agendaReplan = null;
|
||||||
let hasContextSnapshot = false;
|
let hasContextSnapshot = false;
|
||||||
let selectedReview = null;
|
let selectedReview = null;
|
||||||
let reviewTrigger = null;
|
let reviewTrigger = null;
|
||||||
|
|
@ -329,6 +330,62 @@
|
||||||
let reviewController = null;
|
let reviewController = null;
|
||||||
let wrapPreference = null;
|
let wrapPreference = null;
|
||||||
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
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({
|
const issueAttachmentController = issueAttachment.mount({
|
||||||
input: qs('#issue-attachment'),
|
input: qs('#issue-attachment'),
|
||||||
preview: qs('#issue-attachment-preview'),
|
preview: qs('#issue-attachment-preview'),
|
||||||
|
|
@ -2449,6 +2506,13 @@
|
||||||
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'agenda' ?
|
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'agenda' ?
|
||||||
agendaMyWork(activeMyWork) :
|
agendaMyWork(activeMyWork) :
|
||||||
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
|
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 incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||||||
const visible = findQueueItems(queueItems, queueFindQuery);
|
const visible = findQueueItems(queueItems, queueFindQuery);
|
||||||
const emptyWorkStart = qs('#empty-work-start');
|
const emptyWorkStart = qs('#empty-work-start');
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,23 @@
|
||||||
<button id="empty-work-create" type="button">Create an issue</button>
|
<button id="empty-work-create" type="button">Create an issue</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="agenda-replan" id="agenda-replan" aria-labelledby="agenda-replan-heading" hidden>
|
||||||
|
<div class="agenda-replan-launch">
|
||||||
|
<div><strong id="agenda-replan-heading">Overdue deadlines</strong><p class="small muted">Make the Agenda credible without leaving the queue.</p></div>
|
||||||
|
<button id="start-agenda-replan" type="button">Replan overdue</button>
|
||||||
|
</div>
|
||||||
|
<div id="agenda-replan-controls" hidden>
|
||||||
|
<div class="small" id="agenda-replan-progress" role="status" aria-live="assertive"></div>
|
||||||
|
<div class="agenda-replan-actions">
|
||||||
|
<button id="agenda-replan-keep" type="button">Keep date & next</button>
|
||||||
|
<button id="agenda-replan-tomorrow" type="button">Tomorrow & next</button>
|
||||||
|
<label for="agenda-replan-date">Choose a future date</label>
|
||||||
|
<input id="agenda-replan-date" type="date" />
|
||||||
|
<button id="agenda-replan-choose" type="button">Choose date & next</button>
|
||||||
|
<button id="agenda-replan-cancel" type="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
@ -974,6 +991,7 @@
|
||||||
<script src="static/offline-work.js"></script>
|
<script src="static/offline-work.js"></script>
|
||||||
<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/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>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ const SHELL = [
|
||||||
BASE + 'static/offline-work.js',
|
BASE + 'static/offline-work.js',
|
||||||
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/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',
|
||||||
|
|
|
||||||
101
tests/test_mobile_agenda_replan.py
Normal file
101
tests/test_mobile_agenda_replan.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -695,6 +695,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/offline-work.js",
|
"/dashboard/static/offline-work.js",
|
||||||
"/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/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