Compare commits
No commits in common. "4209834d6dea4a68c5df5dd51567ac7ccfc993bf" and "7d7abefb0bb7292c97f6674c1f01f5835bf0e67f" have entirely different histories.
4209834d6d
...
7d7abefb0b
|
|
@ -1,79 +0,0 @@
|
||||||
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,14 +243,6 @@ 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,7 +149,6 @@
|
||||||
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;
|
||||||
|
|
@ -330,62 +329,6 @@
|
||||||
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'),
|
||||||
|
|
@ -2506,13 +2449,6 @@
|
||||||
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,23 +193,6 @@
|
||||||
<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>
|
||||||
|
|
@ -991,7 +974,6 @@
|
||||||
<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,7 +32,6 @@ 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',
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
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,7 +695,6 @@ 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