Replan overdue Agenda deadlines in one mobile sweep #708

Merged
timmy merged 1 commits from timmy/707-mobile-agenda-replan into main 2026-08-13 04:29:07 +00:00
7 changed files with 272 additions and 0 deletions

79
frontend/agenda-replan.js Normal file
View 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;

View File

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

View File

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

View File

@ -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 &amp; next</button>
<button id="agenda-replan-tomorrow" type="button">Tomorrow &amp; 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 &amp; 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>

View File

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

View 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

View File

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