diff --git a/packages/webui/src/public/tasks-board.js b/packages/webui/src/public/tasks-board.js index dd0fec473..c6dd6f302 100644 --- a/packages/webui/src/public/tasks-board.js +++ b/packages/webui/src/public/tasks-board.js @@ -1,7 +1,9 @@ // U4b (#1873, PRD 1.20): one task board across every organization (sample 06). // // Reads: GET /api/workspace?sections=tasks,projects, where each org's tasks come from its own tracker -// module (the built-in store or Vikunja), merged and tagged with the org. An org whose bus is down, +// module (the built-in store or Vikunja), merged and tagged with the org. The project, state, assignee +// and label filters go to the server, which applies them before its per-org cap, so a filter reaches +// every task an org holds; the org chips, the search, Nobody and No project narrow in the page. An org whose bus is down, // slow or invalid keeps its U1 state on the Trackers line and the others still render. // Writes: POST /api/workspace/tasks, one org per request, through that org's bus and its person // verbs (tasks.create, tasks.edit, tasks.archive). The bus runs its own human check; a refusal is @@ -28,7 +30,9 @@ const layouts = (() => { try { const v = JSON.parse(load(LAYOUT) || '{}'); return v && typeof v === 'object' ? v : {}; } catch { return {}; } })(); const layoutNow = () => { const v = layouts[phone() ? 'phone' : 'wide']; return v === 'kanban' || v === 'list' ? v : phone() ? 'list' : 'kanban'; }; const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '' }; - let snap = null, canWrite = false, notice = null, dragging = null, dragEnded = null; + // `opened` is the task as the edit dialog showed it: Save diffs against it and sends its digest, so a + // poll that reads a newer task while the dialog is open turns a stale save into a task-conflict. + let snap = null, canWrite = false, notice = null, dragging = null, dragEnded = null, opened = null; const mark = () => window.MosaicWorkspace?.orgMark; const orgOf = id => snap?.orgs.find(o => o.id === id) || { id, label: id, theme: null }; @@ -40,7 +44,9 @@ const shortId = ref => { let m = /^task:(\d+)$/.exec(ref); if (m) return `#${m[1]}`; m = /^vikunja:\d+\/(\d+)$/.exec(ref); return m ? `V-${m[1]}` : ref; }; const trk = tracker => (tracker === 'vikunja' ? 'Vikunja' : 'built-in'); const stateOf = t => (STATE_LABEL[t.state] ? t.state : UNREAD); - const orgWritable = o => canWrite && o?.state === 'ok' && o.tracker === 'built-in'; + // An org can answer while its tasks read fails (errors.tasks): its tasks are unavailable, not zero. + const tasksRead = o => o?.state === 'ok' && !o.errors?.tasks; + const orgWritable = o => canWrite && tasksRead(o) && o.tracker === 'built-in'; const taskWritable = t => t.writable === true && typeof t.digest === 'string' && orgWritable(orgOf(t.org)); const projectName = t => t.project?.name || t.project?.key || null; const dueText = iso => { @@ -52,9 +58,18 @@ const overdue = t => t.dueDate && t.state !== 'done' && Date.parse(t.dueDate) < Date.now(); // ---------- reads ---------- + const PROJECT_SELECTOR = /^[a-z0-9][a-z0-9._-]{0,63}:[A-Za-z0-9._-]{1,128}$/, ROLE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + function readQuery() { + const q = new URLSearchParams({ sections: 'tasks,projects' }); + if (PROJECT_SELECTOR.test(filter.project)) q.set('project', filter.project); + if (filter.state) q.set('task-state', filter.state === UNREAD ? 'open' : filter.state); + if (ROLE.test(filter.assignee)) q.set('assignee', filter.assignee); + if (/^[1-9]\d{0,14}$/.test(filter.label)) q.set('label', filter.label); + return q.toString(); + } async function read() { const [response, list] = await Promise.all([ - fetch('/api/workspace?sections=tasks,projects', { cache: 'no-store', redirect: 'manual' }), + fetch(`/api/workspace?${readQuery()}`, { cache: 'no-store', redirect: 'manual' }), data.organizations().then(b => (Array.isArray(b) ? b : null)).catch(() => null), mark()?.ready().catch(() => null), window.MosaicConsole?.ready, @@ -70,7 +85,10 @@ const orgs = body.orgs.map(o => ({ ...o, id: o.org, label: named.get(o.org)?.label || o.org, theme: named.get(o.org)?.theme ?? o.theme ?? null, roles: Array.isArray(o.roles) ? o.roles : [] })); return { orgs, tasks: body.tasks, projects: body.projects, summary: body.summary, readAt: body.readAt }; } - const reload = async () => { snap = await read(); prune(); }; + // Only the latest read lands: a filter change and the poll can both be reading. + let readSeq = 0; + const reload = async () => { const n = ++readSeq, next = await read(); if (n !== readSeq) return false; snap = next; prune(); return true; }; + async function refilter() { paint(); try { if (await reload()) paint(); } catch { /* the next poll shows the failed read */ } } // A filter naming an org or project that is gone falls back to all. function prune() { if (filter.org && !snap.orgs.some(o => o.id === filter.org)) filter.org = ''; @@ -94,8 +112,8 @@ for (const t of snap.tasks) counts.set(t.org, (counts.get(t.org) ?? 0) + 1); const chip = (id, inner, label) => ``; const chips = [chip('', `All ${snap.tasks.length}`)].concat(snap.orgs.map(o => chip(o.id, - `${av(o.id)}${esc(o.label)}${o.state === 'ok' ? counts.get(o.id) : 'unavailable'}`, - `${o.label}, ${o.state === 'ok' ? `${counts.get(o.id)} tasks` : 'tasks unavailable'}`))).join(''); + `${av(o.id)}${esc(o.label)}${tasksRead(o) ? counts.get(o.id) : 'unavailable'}`, + `${o.label}, ${tasksRead(o) ? `${counts.get(o.id)} tasks` : 'tasks unavailable'}`))).join(''); const scope = snap.orgs.filter(o => !filter.org || o.id === filter.org); const projectOpts = opt('', `All ${snap.projects.filter(p => !filter.org || p.org === filter.org).length}`, filter.project) + opt('-', 'No project', filter.project) + scope.map(o => { const ps = snap.projects.filter(p => p.org === o.id); return ps.length ? `${ps.map(p => opt(`${p.org}:${p.id}`, p.name || p.key || p.id, filter.project)).join('')}` : ''; }).join(''); @@ -155,9 +173,13 @@ } function results() { const rows = filtered(), all = snap.tasks.length; - const line = rows.length !== all ? `

Showing ${rows.length} of ${all} tasks.

` : ''; + // Project, state, assignee and label filter on the server, so the board never holds the unfiltered + // total; with one of those on, the line says how many match rather than inventing an "of N". + const onServer = new URLSearchParams(readQuery()).size > 1, n = rows.length; + const line = onServer ? `

Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.

` + : n !== all ? `

Showing ${n} of ${all} tasks.

` : ''; const body = !snap.orgs.length ? '

No organizations yet.

' - : !all ? `

${snap.summary?.read ? 'No tasks yet.' : 'No organization answered, so no tasks could be read.'}

` + : !all ? `

${snap.orgs.some(tasksRead) ? 'No tasks yet.' : 'No organization answered with its tasks, so no tasks could be read.'}

` : !rows.length ? '

No tasks match these filters.

' : layoutNow() === 'list' ? list(rows) : kanban(rows); return `
${line}${body}
`; @@ -166,9 +188,10 @@ // ---------- page ---------- function trackers() { const items = snap.orgs.map(o => { - const s = ORG_STATE[o.state]; - const st = s ? ` ${esc(s[2])}${o.reason ? `${esc(o.reason)}` : ''}` : ''; - const cut = o.tasksHeld ? `shows the first 500 of ${esc(o.tasksHeld)} tasks` : ''; + const s = ORG_STATE[o.state] ?? (o.state === 'ok' && o.errors?.tasks ? ['s-error', '▲', 'tasks not read'] : null); + const why = ORG_STATE[o.state] ? o.reason : o.errors?.tasks; + const st = s ? ` ${esc(s[2])}${why ? `${esc(why)}` : ''}` : ''; + const cut = o.tasksHeld ? `shows the 500 most recently changed of ${esc(o.tasksHeld)} matching tasks` : ''; return `${av(o.id)}${esc(o.label)} ${o.tracker ? trk(o.tracker) : ''}${st}${cut}`; }).join(''); return `
Trackers${items}Ids keep their tracker's form: V-12 is Vikunja, #41 is built in.
`; @@ -181,7 +204,7 @@ const add = ``; let sub = ''; if (snap) { - const n = snap.tasks.length, m = snap.summary?.read ?? 0, of = snap.summary?.of ?? snap.orgs.length; + const n = snap.tasks.length, m = snap.orgs.filter(tasksRead).length, of = snap.summary?.of ?? snap.orgs.length; sub = `${n} ${n === 1 ? 'task' : 'tasks'} from ${m} of ${of} ${of === 1 ? 'organization' : 'organizations'}. Each organization keeps its own tasks; a change made here goes only to that organization's bus.`; if (snap.orgs.some(o => o.tracker === 'vikunja')) sub += ' Vikunja tasks are read only here.'; if (!canWrite) sub += ' This Console takes no task changes, so the board is read only.'; @@ -204,12 +227,14 @@ scroll: root ? [...root.querySelectorAll('.tb-board,.tb-list')].map(e => [e.matches('.tb-board') ? 'board' : 'list', e.scrollLeft]) : [], }; } - function putBack(root, kept) { + // With focus false (after bus.js's own repaint, which puts focus back by key) the caret is put back + // only if focus is still on the search box it was taken from. + function putBack(root, kept, { focus = true } = {}) { if (!root || !kept) return; for (const [which, left] of kept.scroll) { const e = root.querySelector(which === 'board' ? '.tb-board' : '.tb-list'); if (e) e.scrollLeft = left; } const el = kept.focus ? root.querySelector(`[data-focus-key="${CSS.escape(kept.focus)}"]`) : null; - if (el && document.activeElement !== el) el.focus({ preventScroll: true }); - if (el && kept.caret && el.matches('[data-tb-q]')) el.setSelectionRange(kept.caret[0], kept.caret[1]); + if (focus && el && document.activeElement !== el) el.focus({ preventScroll: true }); + if (el && document.activeElement === el && kept.caret && el.matches('[data-tb-q]')) el.setSelectionRange(kept.caret[0], kept.caret[1]); } // Repaint in place, for the board's own changes (filters, layout, a write). function paint(part = 'all') { @@ -224,21 +249,28 @@ // bus.js's view for #/tasks. A drag in progress holds the poll's repaint until it ends. async function view() { if (dragging) await Promise.race([dragEnded, new Promise(r => setTimeout(r, 30000))]); - const kept = snapshotView(document.querySelector('[data-tasks-board]')); try { await reload(); } catch (err) { snap = null; return failed(err); } - setTimeout(() => putBack(document.querySelector('[data-tasks-board]'), kept), 0); + // Taken after the read, just before bus.js replaces the view, so focus, caret or scroll moved while + // the read was in flight is what is kept. bus.js puts focus back by key; this puts back the rest. + const kept = snapshotView(document.querySelector('[data-tasks-board]')); + setTimeout(() => putBack(document.querySelector('[data-tasks-board]'), kept, { focus: false }), 0); return markup(); } // ---------- writes ---------- + const UNKNOWN = Object.freeze({ ok: false, error: 'outcome-unknown', unknown: true, message: 'The Console\'s answer could not be read. The change may or may not have been written; the board shows it on its next read.' }); async function write(body) { let response, answer = null; try { response = await fetch('/api/workspace/tasks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' }); answer = await response.json().catch(() => null); - } catch { return { ok: false, error: 'outcome-unknown', message: 'The Console did not answer. The change may or may not have been written; the board shows it on its next read.' }; } + } catch { return UNKNOWN; } + if (response.type === 'opaqueredirect') return { ok: false, error: 'signed-out', message: 'The Console asked to sign in again. Nothing was written.' }; if (response.ok && answer?.ok) return answer; - return { ok: false, error: answer?.error || `http-${response.status}`, message: answer?.message || `The Console refused the change (${response.status}). Nothing was written.` }; + // A refusal names its code. An answer that cannot be read, or one that says the outcome is open, + // is not a refusal: the change may have reached the bus. + if (typeof answer?.error !== 'string' || answer.sent === 'unknown') return { ...UNKNOWN, message: answer?.sent === 'unknown' && answer.message ? answer.message : UNKNOWN.message }; + return { ok: false, error: answer.error, message: answer.message || `The Console refused the change (${response.status}). Nothing was written.` }; } const find = k => snap?.tasks.find(t => key(t) === k) || null; async function move(k, state) { @@ -246,7 +278,7 @@ if (!t || !taskWritable(t) || t.state === state) return; notice = { text: `Moving ${shortId(t.ref)} to ${STATE_LABEL[state]}…` }; paint(); const r = await write({ op: 'edit', org: t.org, task_ref: t.ref, expect: t.digest, state }); - notice = r.ok ? { text: `${shortId(t.ref)} moved to ${STATE_LABEL[state]} in ${orgOf(t.org).label}.` } : { error: true, text: `${shortId(t.ref)} was not moved. ${r.message}` }; + notice = r.ok ? { text: `${shortId(t.ref)} moved to ${STATE_LABEL[state]} in ${orgOf(t.org).label}.` } : { error: true, text: r.unknown ? `${shortId(t.ref)}: ${r.message}` : `${shortId(t.ref)} was not moved. ${r.message}` }; try { await reload(); } catch {} paint(); } @@ -258,7 +290,7 @@ d = document.createElement('dialog'); d.id = 'tb-dialog'; d.className = 'tb-dialog'; d.setAttribute('aria-labelledby', 'tb-dialog-h'); document.body.append(d); - d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); }); + d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); }); } return d; } @@ -306,9 +338,9 @@
`; } - function openDialog(html, back) { + function openDialog(html, back, task = null) { const d = dialogEl(); - d.innerHTML = html; d.dataset.return = back || ''; + d.innerHTML = html; d.dataset.return = back || ''; opened = task ? structuredClone(task) : null; if (!d.open) d.showModal(); d.querySelector('input:not([type=checkbox]),select,button')?.focus(); } @@ -335,7 +367,7 @@ const group = String(f.get('group') || ''); if (group) body.group = group; const d = due0(String(f.get('due_date') || '')); if (d) body.due_date = d; } else { - t = find(form.dataset.tbKey); + t = opened && key(opened) === form.dataset.tbKey ? opened : null; if (!t) { formStatus(form, 'The task is no longer on the board. Nothing was written.'); return; } body = editBody(form, t); if (Object.keys(body).length === 4) { formStatus(form, 'Nothing changed.'); return; } @@ -349,9 +381,9 @@ if (r.error === 'task-conflict' && t) { try { await reload(); } catch {} const now = find(key(t)); - if (now) { const msg = r.message; openDialog(editForm(now), dialogEl().dataset.return); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, msg); } + if (now) { const msg = r.message; openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, msg); } paint(); - } + } else if (r.unknown) { try { await reload(); } catch {} paint(); } return; } const label = orgOf(body.org).label; @@ -361,7 +393,7 @@ paint(); } async function archive(form) { - const t = find(form.dataset.tbKey); + const t = opened && key(opened) === form.dataset.tbKey ? opened : null; if (!t) return; if (!form.querySelector('[data-tb-archive]').hasAttribute('data-armed')) { form.querySelector('[data-tb-archive]').setAttribute('data-armed', ''); @@ -369,7 +401,7 @@ return; } const r = await write({ op: 'archive', org: t.org, task_ref: t.ref, expect: t.digest }); - if (!r.ok) { formStatus(form, r.message); return; } + if (!r.ok) { formStatus(form, r.message); if (r.unknown) { try { await reload(); } catch {} paint(); } return; } notice = { text: `${shortId(t.ref)} archived in ${orgOf(t.org).label}.` }; dialogEl().close(); try { await reload(); } catch {} @@ -389,9 +421,13 @@ const lay = e.target.closest('[data-tb-layout]'); if (lay) { layouts[phone() ? 'phone' : 'wide'] = lay.dataset.tbLayout; save(LAYOUT, JSON.stringify(layouts)); paint(); return; } const chip = e.target.closest('[data-tb-org]'); - if (chip) { filter.org = chip.dataset.tbOrg; if (filter.project && filter.project !== '-' && filter.org && !filter.project.startsWith(`${filter.org}:`)) filter.project = ''; paint(); return; } + if (chip) { + filter.org = chip.dataset.tbOrg; + if (filter.project && filter.project !== '-' && filter.org && !filter.project.startsWith(`${filter.org}:`)) { filter.project = ''; refilter(); } else paint(); + return; + } const open = e.target.closest('[data-tb-open]'); - if (open) { const t = find(open.dataset.tbOpen); if (t) openDialog(editForm(t), open.dataset.focusKey); return; } + if (open) { const t = find(open.dataset.tbOpen); if (t) openDialog(editForm(t), open.dataset.focusKey, t); return; } const add = e.target.closest('[data-tb-new]'); if (add && !add.disabled) openDialog(createForm(filter.org), 'tb-new'); }); @@ -404,7 +440,7 @@ } if (!inBoard(e) || !snap) return; const f = e.target.dataset?.tbFilter; - if (f) { filter[f] = e.target.value; paint(); return; } + if (f) { filter[f] = e.target.value; refilter(); return; } if (e.target.matches('[data-tb-move]')) move(e.target.dataset.tbMove, e.target.value); }); document.addEventListener('input', e => { diff --git a/packages/webui/src/workspace-read.mjs b/packages/webui/src/workspace-read.mjs index fa2191ff7..ca0b1f4db 100644 --- a/packages/webui/src/workspace-read.mjs +++ b/packages/webui/src/workspace-read.mjs @@ -25,9 +25,11 @@ const WEEK_MS = 7 * 24 * 3600 * 1000; const PER_ORG_CONCURRENCY = 3; // U4b (#1873): the task board's states, the built-in store's (broker STORE_STATES), in board order. export const TASK_STATES = Object.freeze(['todo', 'in-progress', 'in-review', 'blocked', 'done']); -// U4b: at most this many tasks per organization reach one snapshot. The bus's tasks read has no -// cursor for the merged form, so the cap bounds the answer and the board's DOM; an org past it is -// marked with how many it holds, and the board says the list is cut. +// U4b: at most this many tasks per organization reach one answer. The bus's tasks read has no +// cursor for the merged form, so the cap bounds the answer and the board's DOM. It applies last: +// projects, metrics, sessions and the inbox count every task, and the filters run before it, so a +// filter reaches every task the org holds. An org past it keeps its most recently changed tasks +// and says how many matched (`tasksHeld`); the board says the list is cut. export const TASKS_PER_ORG = 500; const ROLE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; const DIGEST = /^[0-9a-f]{64}$/; @@ -208,10 +210,7 @@ function orgRows(org, data, doc, nowMs) { const byId = new Map(projects.map(p => [p.id, p])); const ref = p => (p ? { id: p.id, key: p.repo ?? null, name: p.name ?? null } : null); const threadProject = new Map(arr(data.threads).map(t => [t.id, byId.get(t.project) ?? null])); - const allTasks = arr(data.tasks).filter(t => t && typeof t.task_ref === 'string' && !t.fields?.gone && t.archived !== true); - // U4b: past the cap, the org says how many it holds and the snapshot keeps the first TASKS_PER_ORG. - if (allTasks.length > TASKS_PER_ORG) org.tasksHeld = allTasks.length; - const taskRows = allTasks.slice(0, TASKS_PER_ORG); + const taskRows = arr(data.tasks).filter(t => t && typeof t.task_ref === 'string' && !t.fields?.gone && t.archived !== true); const bots = new Map(Object.entries(doc?.roles ?? {}).filter(([, r]) => r?.tracker?.botId !== undefined).map(([role, r]) => [r.tracker.botId, role])); const tasks = taskRows.map(t => { const f = t.fields ?? {}; @@ -337,11 +336,20 @@ export async function readWorkspace({ organizations, session, query = parseWorks if (query.sections.has('inbox')) body.inbox = merged.inbox.sort((a, b) => (a.class === 'gated' ? 0 : 1) - (b.class === 'gated' ? 0 : 1) || (a.at ?? '').localeCompare(b.at ?? '')); if (query.sections.has('tasks')) { const p = query.project; - body.tasks = merged.tasks.filter(t => (!p || (t.org === p.org && (t.project?.id === p.key || t.project?.key === p.key))) + const matched = merged.tasks.filter(t => (!p || (t.org === p.org && (t.project?.id === p.key || t.project?.key === p.key))) && (!query.taskState || query.taskState.has(t.state ?? 'open')) && (!query.assignee || t.assignees.some(a => query.assignee.has(a))) - && (!query.label || t.labels.some(l => query.label.has(l)))) - .sort((a, b) => a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref, undefined, { numeric: true })); + && (!query.label || t.labels.some(l => query.label.has(l)))); + // The cap, per org, after the filters: the most recently changed tasks stay. + const byOrg = Map.groupBy(matched, t => t.org), kept = []; + for (const r of read) { + const rows = byOrg.get(r.org.org) ?? []; + if (rows.length > TASKS_PER_ORG) { + r.org.tasksHeld = rows.length; + kept.push(...rows.sort((a, b) => (b.changedAt ?? '').localeCompare(a.changedAt ?? '') || b.ref.localeCompare(a.ref, undefined, { numeric: true })).slice(0, TASKS_PER_ORG)); + } else kept.push(...rows); + } + body.tasks = kept.sort((a, b) => a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref, undefined, { numeric: true })); } if (query.sections.has('metrics')) { const m = merged.metrics; diff --git a/packages/webui/src/workspace-tasks.mjs b/packages/webui/src/workspace-tasks.mjs index 504493692..298784e4e 100644 --- a/packages/webui/src/workspace-tasks.mjs +++ b/packages/webui/src/workspace-tasks.mjs @@ -24,8 +24,10 @@ const DUE = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/; // assignees, priority and due date; a create also names the requirement and the project (group). const OPS = { create: { verb: 'tasks.create', keys: ['title', 'requirement', 'group', 'state', 'assignees', 'priority', 'due_date'], need: ['title', 'requirement'] }, - edit: { verb: 'tasks.edit', keys: ['task_ref', 'expect', 'title', 'state', 'assignees', 'priority', 'due_date'], need: ['task_ref'] }, - archive: { verb: 'tasks.archive', keys: ['task_ref', 'expect'], need: ['task_ref'] }, + // An edit or archive names the digest the board read, so a change made since is a task-conflict + // and never a silent overwrite. The bus checks it; the route refuses a write without one. + edit: { verb: 'tasks.edit', keys: ['task_ref', 'expect', 'title', 'state', 'assignees', 'priority', 'due_date'], need: ['task_ref', 'expect'] }, + archive: { verb: 'tasks.archive', keys: ['task_ref', 'expect'], need: ['task_ref', 'expect'] }, }; const FIELD_CHECKS = { task_ref: v => typeof v === 'string' && STORE_REF.test(v), diff --git a/packages/webui/tests/browser.test.mjs b/packages/webui/tests/browser.test.mjs index c270961d2..3c8137792 100644 --- a/packages/webui/tests/browser.test.mjs +++ b/packages/webui/tests/browser.test.mjs @@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4'); await b.evaluate('document.querySelector("#hide-offline").click()'); assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4); - // Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js. - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']); + // Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1); assert.equal(await b.evaluate('!!window.injected'), false); diff --git a/packages/webui/tests/tasks-board-browser.test.mjs b/packages/webui/tests/tasks-board-browser.test.mjs index 4074b4b54..1f55aab56 100644 --- a/packages/webui/tests/tasks-board-browser.test.mjs +++ b/packages/webui/tests/tasks-board-browser.test.mjs @@ -11,7 +11,7 @@ import { close } from './fixture.mjs'; const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); const D = n => String(n).repeat(64); -function boardOrgs({ refuse = null } = {}) { +function boardOrgs({ refuse = null, tasksFail = [] } = {}) { const ORGS = [ { id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true }, { id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true }, @@ -30,9 +30,11 @@ function boardOrgs({ refuse = null } = {}) { { n: 41, title: 'Rate limit the public API', state: 'todo', group: 'api-gateway', assignees: ['coder'], priority: 4, due: '2026-11-20T00:00:00.000Z' }, { n: 42, title: 'Invoice export', state: 'in-progress', group: 'billing', assignees: [], priority: 0, due: null }, { n: 43, title: 'Retire the v1 gateway', state: 'done', group: 'api-gateway', assignees: ['reviewer'], priority: 2, due: null }, - ]; + ].map(t => ({ ...t, rev: 0 })); + // A task's digest changes with every write to it, as the store's does. + const digest = t => D((t.n + t.rev) % 10); const TASKS = { - forge: () => store.map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: t.priority, digest: D(t.n % 10), requirement: 'REQ-API-1', + forge: () => store.map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: t.priority, digest: digest(t), requirement: 'REQ-API-1', fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: t.due } })), ddigital: () => [ { task_ref: 'vikunja:3/112', project_key: 'spring', priority: 3, at: '2026-11-09T11:00:00.000Z', fields: { title: 'Launch email, draft 2', done: false, assignees: [7], bucket: 12, labels: ['launch'] } }, @@ -44,17 +46,18 @@ function boardOrgs({ refuse = null } = {}) { writes.push([org, verb, args]); if (refuse) throw Object.assign(new Error('refused'), { code: refuse }); const t = store.find(s => `task:${s.n}` === args.task_ref); - if (verb === 'tasks.edit' && t && args.state) t.state = args.state; - return { task_ref: args.task_ref, digest: D(9) }; + if (t && args.expect && args.expect !== digest(t)) throw Object.assign(new Error('conflict'), { code: 'task-conflict' }); + if (verb === 'tasks.edit' && t) { for (const f of ['title', 'state', 'assignees']) if (args[f] !== undefined) t[f] = args[f]; t.rev++; } + return { task_ref: args.task_ref, digest: t ? digest(t) : D(9) }; }; const options = id => ({ - bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => TASKS[id](), messages: async () => ({ messages: [], unread: 0 }) }, + bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => { if (tasksFail.includes(id)) throw Object.assign(new Error('tasks'), { code: 'tracker-unavailable' }); return TASKS[id](); }, messages: async () => ({ messages: [], unread: 0 }) }, chat: { projects: async () => PROJECTS[id], threads: async () => [] }, }); const healthy = async () => ({ healthy: true, pid: 4242, boot: null }); const down = async () => ({ healthy: false, pid: null, boot: null }); return { - writes, + writes, store, list: async () => ORGS, members: async () => ORGS.map(o => ({ id: o.id, doc: DOCS[o.id], status: o.busHealthy ? healthy : down, options: () => options(o.id) })), resolve: async id => { @@ -65,7 +68,7 @@ function boardOrgs({ refuse = null } = {}) { }; } -async function open(t, { refuse = null, send = true, width = 1440, height = 900, orgs = boardOrgs({ refuse }) } = {}) { +async function open(t, { refuse = null, tasksFail = [], send = true, width = 1440, height = 900, orgs = boardOrgs({ refuse, tasksFail }) } = {}) { const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, ...(send ? { send: async () => ({}) } : {}), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); @@ -105,12 +108,13 @@ test('org and project filters narrow the board', async t => { await b.evaluate('document.querySelector("[data-tb-org=forge]").click()'); await wait('document.querySelector("[data-tb-org=forge]").getAttribute("aria-pressed") === "true"'); assert.deepEqual((await cards(b)).sort(), ['forge|task:41', 'forge|task:42', 'forge|task:43']); + assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 3 of 5 tasks.'); // The project list follows the org filter. assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-filter=project] option")].map(o => o.value).sort()'), ['', '-', 'forge:api-gateway', 'forge:billing']); await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "forge:billing"; s.dispatchEvent(new Event("change", { bubbles: true })); })()'); await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1'); assert.deepEqual(await cards(b), ['forge|task:42']); - assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 1 of 5 tasks.'); + assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 1 task that matches the filters.'); // Back to every org; the project filter stays on forge's project. await b.evaluate('document.querySelector("[data-tb-org=\'\']").click()'); await wait('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed") === "true"'); @@ -190,3 +194,94 @@ test('a Console without --send shows a read-only board and the write route refus assert.equal(status, 403); assert.deepEqual(orgs.writes, []); }); + +// bus.js repaints the view on its own 10 s poll; this resolves once that repaint has replaced the board. +const POLLED = '(async () => { const before = document.querySelector("[data-tasks-board]"); for (let i = 0; i < 140 && document.querySelector("[data-tasks-board]") === before; i++) await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 50)); return document.querySelector("[data-tasks-board]") !== before; })()'; + +test('an edit dialog left open while another writer changes the task saves against what it showed and gets a conflict', async t => { + const { b, wait, orgs } = await open(t); + await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()'); + await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")'); + // Another writer renames the task and assigns it; a poll then reads the new version. + const t42 = orgs.store.find(s => s.n === 42); + Object.assign(t42, { title: 'Invoice export (renamed elsewhere)', assignees: ['coder'], rev: t42.rev + 1 }); + assert.equal(await b.evaluate(POLLED), true); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export'); + // Save sends the digest and the changes against the task as the dialog showed it. + await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()'); + await wait('/changed after the board read it/.test(document.querySelector("#tb-dialog [data-tb-form-status]")?.textContent)'); + assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]]); + assert.equal(t42.title, 'Invoice export (renamed elsewhere)'); + // The dialog reopens on the current task, so the next save is checked against what it now shows. + assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export (renamed elsewhere)'); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog input[name=assignees]:checked")].map(i => i.value)'), ['coder']); + await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()'); + await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)'); + assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), false); +}); + +test('a poll that answers after focus has moved leaves focus where the person put it', async t => { + const { b, wait } = await open(t); + await b.evaluate('document.querySelector("[data-tb-layout=list]").click()'); + await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")'); + // Hold the next workspace read until focus has moved from the search to a row's State control. + await b.evaluate(`(() => { + const real = window.fetch; + window.__held = null; + window.fetch = (u, o) => String(u).startsWith('/api/workspace?') && !window.__held + ? new Promise(go => { window.__held = () => real(u, o).then(go); }) + : real(u, o); + document.querySelector("[data-tb-q]").focus(); + })()`); + // bus.js polls every 10 s, longer than waitFor allows. + assert.equal(await b.evaluate('(async () => { for (let i = 0; i < 140 && !window.__held; i++) await new Promise(r => setTimeout(r, 100)); return !!window.__held; })()'), true); + await b.evaluate('(window.__board = document.querySelector("[data-tasks-board]"), document.querySelector("[data-tb-move=\'forge|task:43\']").focus())'); + await b.evaluate('window.__held()'); + await wait('document.querySelector("[data-tasks-board]") !== window.__board'); + await b.evaluate('new Promise(r => setTimeout(r, 100))'); + assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'move:forge|task:43'); +}); + +test('an org whose tasks read fails shows its tasks as not read, not as none', async t => { + const { b } = await open(t, { tasksFail: ['forge'] }); + assert.deepEqual((await cards(b)).sort(), ['ddigital|vikunja:3/112', 'ddigital|vikunja:3/118']); + assert.match(await b.evaluate('document.querySelector("[data-tb-tracker=forge]").textContent'), /Forge.*tasks not read.*tracker-unavailable/s); + assert.match(await b.evaluate('document.querySelector("[data-tb-org=forge]").textContent'), /Forge\s*unavailable/); + assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /2 tasks from 1 of 3 organizations/); + // No org that can take a write is left, so New task is off. + assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), true); +}); + +test('a board with no tasks read at all says so instead of showing an empty board', async t => { + const { b } = await open(t, { tasksFail: ['forge', 'ddigital'] }); + assert.deepEqual(await cards(b), []); + assert.match(await b.evaluate('document.querySelector("[data-tasks-board]").textContent'), /No organization answered with its tasks, so no tasks could be read\./); +}); + +test('a committed write whose answer cannot be read is shown as unknown, not as refused', async t => { + const { b, wait, orgs } = await open(t); + await b.evaluate('document.querySelector("[data-tb-layout=list]").click()'); + await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")'); + // The write reaches the bus; a proxy then replaces the answer with a page that is not JSON. + await b.evaluate(`(() => { const real = window.fetch; window.fetch = async (u, o) => { const r = await real(u, o); return String(u) === '/api/workspace/tasks' ? new Response('Bad gateway', { status: 502, headers: { 'content-type': 'text/html' } }) : r; }; })()`); + await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:41\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()'); + await wait('/may or may not have been written/.test(document.querySelector("[data-tb-notice]").textContent)'); + const text = await b.evaluate('document.querySelector("[data-tb-notice]").textContent'); + assert.doesNotMatch(text, /was not moved|Nothing was written/); + assert.match(text, /^#41: /); + assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:41', expect: D(1), state: 'in-review' }]]); + // The board reads again and shows where the bus has the task. + await wait('document.querySelector("[data-tb-move=\'forge|task:41\']").value === "in-review"'); +}); + +test('a filter change asks the server for the matching tasks', async t => { + const { b, wait } = await open(t); + await b.evaluate(`(() => { const real = window.fetch; window.__reads = []; window.fetch = (u, o) => { if (String(u).startsWith('/api/workspace?')) window.__reads.push(String(u)); return real(u, o); }; })()`); + await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "forge:billing"; s.dispatchEvent(new Event("change", { bubbles: true })); })()'); + await wait('window.__reads.length'); + const q = new URLSearchParams(await b.evaluate('window.__reads[0].split("?")[1]')); + assert.equal(q.get('project'), 'forge:billing'); + await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1'); + assert.deepEqual(await cards(b), ['forge|task:42']); +}); diff --git a/packages/webui/tests/workspace-tasks.test.mjs b/packages/webui/tests/workspace-tasks.test.mjs index 5b21416a6..c26dd63a9 100644 --- a/packages/webui/tests/workspace-tasks.test.mjs +++ b/packages/webui/tests/workspace-tasks.test.mjs @@ -103,32 +103,34 @@ test('parseTaskWrite takes the three ops strictly and refuses before any bus', ( { op: 'create', org: 'forge', verb: 'tasks.create', args: { title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', state: 'todo', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' } }); assert.deepEqual(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', expect: D(1), state: 'in-review' }), { op: 'edit', org: 'forge', verb: 'tasks.edit', args: { task_ref: 'task:4', expect: D(1), state: 'in-review' } }); - assert.deepEqual(parseTaskWrite({ op: 'archive', org: 'forge', task_ref: 'task:4' }), { op: 'archive', org: 'forge', verb: 'tasks.archive', args: { task_ref: 'task:4' } }); - assert.equal(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', due_date: null }).args.due_date, null); + assert.deepEqual(parseTaskWrite({ op: 'archive', org: 'forge', task_ref: 'task:4', expect: D(1) }), { op: 'archive', org: 'forge', verb: 'tasks.archive', args: { task_ref: 'task:4', expect: D(1) } }); + assert.equal(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', expect: D(1), due_date: null }).args.due_date, null); const refused = [ [{ op: 'delete', org: 'forge', task_ref: 'task:1' }, /^op takes create, edit, archive$/], [{ op: 'unarchive', org: 'forge', task_ref: 'task:1' }, /^op takes/], [{ op: 'edit', org: 'Forge!', task_ref: 'task:1', title: 'x' }, /^org takes/], [{ op: 'edit', task_ref: 'task:1', title: 'x' }, /^org takes/], - [{ op: 'edit', org: 'forge', task_ref: 'task:1', description: 'x' }, /^edit does not take description$/], + [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', description: 'x' }, /^edit does not take description$/], [{ op: 'archive', org: 'forge', task_ref: 'task:1', title: 'x' }, /^archive does not take title$/], [{ op: 'create', org: 'forge', title: 'x' }, /^create needs requirement$/], [{ op: 'edit', org: 'forge', title: 'x' }, /^edit needs task_ref$/], - [{ op: 'edit', org: 'forge', task_ref: 'task:1' }, /^edit needs a field to change$/], + // An edit or archive without the digest the board read is refused before any bus (no silent overwrite). + [{ op: 'edit', org: 'forge', task_ref: 'task:1', title: 'x' }, /^edit needs expect$/], + [{ op: 'archive', org: 'forge', task_ref: 'task:1' }, /^archive needs expect$/], [{ op: 'edit', org: 'forge', task_ref: 'task:1', expect: D(2) }, /^edit needs a field to change$/], // A Vikunja task is never written from the board: its ref is refused here. - [{ op: 'edit', org: 'ddigital', task_ref: 'vikunja:3/112', title: 'x' }, /^task_ref is not valid$/], - [{ op: 'edit', org: 'forge', task_ref: 'task:0', title: 'x' }, /^task_ref is not valid$/], + [{ op: 'edit', org: 'ddigital', expect: D(1), task_ref: 'vikunja:3/112', title: 'x' }, /^task_ref is not valid$/], + [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:0', title: 'x' }, /^task_ref is not valid$/], [{ op: 'edit', org: 'forge', task_ref: 'task:1', expect: 'abc', title: 'x' }, /^expect is not valid$/], - [{ op: 'edit', org: 'forge', task_ref: 'task:1', title: ' ' }, /^title is not valid$/], - [{ op: 'edit', org: 'forge', task_ref: 'task:1', title: 'x'.repeat(251) }, /^title is not valid$/], - [{ op: 'edit', org: 'forge', task_ref: 'task:1', state: 'doing' }, /^state is not valid$/], - [{ op: 'edit', org: 'forge', task_ref: 'task:1', priority: 6 }, /^priority is not valid$/], - [{ op: 'edit', org: 'forge', task_ref: 'task:1', priority: 1.5 }, /^priority is not valid$/], - [{ op: 'edit', org: 'forge', task_ref: 'task:1', assignees: ['coder', 'bad name'] }, /^assignees is not valid$/], - [{ op: 'edit', org: 'forge', task_ref: 'task:1', assignees: 'coder' }, /^assignees is not valid$/], - [{ op: 'edit', org: 'forge', task_ref: 'task:1', due_date: '2026-11-20' }, /^due_date is not valid$/], - [{ op: 'edit', org: 'forge', task_ref: 'task:1', due_date: '2026-02-30T00:00:00.000Z' }, /^due_date is not valid$/], + [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', title: ' ' }, /^title is not valid$/], + [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', title: 'x'.repeat(251) }, /^title is not valid$/], + [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', state: 'doing' }, /^state is not valid$/], + [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', priority: 6 }, /^priority is not valid$/], + [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', priority: 1.5 }, /^priority is not valid$/], + [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', assignees: ['coder', 'bad name'] }, /^assignees is not valid$/], + [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', assignees: 'coder' }, /^assignees is not valid$/], + [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', due_date: '2026-11-20' }, /^due_date is not valid$/], + [{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', due_date: '2026-02-30T00:00:00.000Z' }, /^due_date is not valid$/], [{ op: 'create', org: 'forge', title: 'x', requirement: 'req-1' }, /^requirement is not valid$/], [{ op: 'create', org: 'forge', title: 'x', requirement: 'REQ-TASK-1', group: 'a\nb' }, /^group is not valid$/], ]; @@ -138,7 +140,7 @@ test('parseTaskWrite takes the three ops strictly and refuses before any bus', ( test('route: the fence, the owner and --send come before any org is resolved', async t => { const r = await registry(t); const { post } = await serve(t, { organizations: r.organizations }); - const body = { op: 'edit', org: 'forge', task_ref: 'task:1', title: 'x' }; + const body = { op: 'edit', org: 'forge', task_ref: 'task:1', expect: D(1), title: 'x' }; const fenced = [ [{ origin: null }, 'cross-origin request refused'], [{ origin: 'http://evil.test' }, 'cross-origin request refused'], @@ -197,14 +199,14 @@ test('route: each write reaches only the org it names, through that org\'s real assert.equal(stale.status, 409); assert.equal(stale.body.error, 'task-conflict'); assert.equal(stale.body.sent, false); assert.match(stale.body.message, /Nothing was written/); assert.equal(r.forge.read()[0].fields.title, 'Rate limits'); - const missing = await post({ op: 'edit', org: 'forge', task_ref: 'task:9', title: 'x' }); + const missing = await post({ op: 'edit', org: 'forge', task_ref: 'task:9', expect: D(1), title: 'x' }); assert.equal(missing.status, 404); assert.equal(missing.body.error, 'task-not-found'); - const role = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', assignees: ['nobody'] }); + const role = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: moved.body.digest, assignees: ['nobody'] }); assert.equal(role.status, 400); assert.equal(role.body.error, 'unknown-role'); const archived = await post({ op: 'archive', org: 'forge', task_ref: 'task:1', expect: moved.body.digest }); assert.equal(archived.status, 200); assert.deepEqual(r.forge.read(), [], 'an archived task leaves the read'); - const again = await post({ op: 'archive', org: 'forge', task_ref: 'task:1' }); + const again = await post({ op: 'archive', org: 'forge', task_ref: 'task:1', expect: archived.body.digest }); assert.equal(again.status, 409); assert.equal(again.body.error, 'task-already-archived'); // A Vikunja organization's bus refuses the store verbs; the board never writes to Vikunja. @@ -221,22 +223,22 @@ test('route: each write reaches only the org it names, through that org\'s real // An org outside the session, an archived org, an unreadable registry, a down bus, a Console // without the write: each is refused before or without a bus call. const before = r.sent.length; - const outside = await post({ op: 'edit', org: 'acme', task_ref: 'task:1', title: 'x' }); + const outside = await post({ op: 'edit', org: 'acme', task_ref: 'task:1', expect: D(1), title: 'x' }); assert.equal(outside.status, 404); assert.deepEqual(outside.body, { error: 'org-unavailable', sent: false }); - const old = await post({ op: 'edit', org: 'old', task_ref: 'task:1', title: 'x' }); + const old = await post({ op: 'edit', org: 'old', task_ref: 'task:1', expect: D(1), title: 'x' }); assert.equal(old.status, 409); assert.equal(old.body.error, 'org-archived'); - const broken = await post({ op: 'edit', org: 'broken', task_ref: 'task:1', title: 'x' }); + const broken = await post({ op: 'edit', org: 'broken', task_ref: 'task:1', expect: D(1), title: 'x' }); assert.equal(broken.status, 404); assert.deepEqual(broken.body, { error: 'org-unavailable', sent: false }); - const down = await post({ op: 'edit', org: 'home', task_ref: 'task:1', title: 'x' }); + const down = await post({ op: 'edit', org: 'home', task_ref: 'task:1', expect: D(1), title: 'x' }); assert.equal(down.status, 503); assert.equal(down.body.error, 'no-bus-host'); assert.equal(down.body.sent, false); - const quiet = await post({ op: 'edit', org: 'quiet', task_ref: 'task:1', title: 'x' }); + const quiet = await post({ op: 'edit', org: 'quiet', task_ref: 'task:1', expect: D(1), title: 'x' }); assert.equal(quiet.status, 403); assert.equal(quiet.body.error, 'read-only'); assert.equal(r.sent.length, before); // A lost answer is not a refusal: the change may have been written, and the person is told so. - const lost = await post({ op: 'edit', org: 'lost', task_ref: 'task:1', title: 'x' }); + const lost = await post({ op: 'edit', org: 'lost', task_ref: 'task:1', expect: D(1), title: 'x' }); assert.equal(lost.status, 503); assert.equal(lost.body.error, 'outcome-unknown'); assert.equal(lost.body.sent, 'unknown'); - const odd = await post({ op: 'edit', org: 'odd', task_ref: 'task:1', title: 'x' }); + const odd = await post({ op: 'edit', org: 'odd', task_ref: 'task:1', expect: D(1), title: 'x' }); assert.equal(odd.status, 502); assert.equal(odd.body.error, 'brand-new-code'); assert.equal(odd.body.sent, false); // Every resolve named exactly the org in the body, and each bus call went to that org. @@ -314,14 +316,31 @@ test('read: filters, the per-org cap, and rows the read refuses', async () => { for (const search of ['task-state=doing', 'assignee=', 'assignee=a b', 'label=0', 'label=x', 'task-state=todo&task-state=done']) assert.throws(() => parseWorkspaceQuery(search), WorkspaceQueryError, search); - // Past the cap the org keeps TASKS_PER_ORG rows and says how many it holds; another org is untouched. - const many = Array.from({ length: TASKS_PER_ORG + 7 }, (_, i) => store(i + 1)); - const capped = await readTasks([member('big', { tasks: many }), member('small', { tasks: [store(1)] })]); - assert.equal(capped.tasks.filter(x => x.org === 'big').length, TASKS_PER_ORG); + // Past the cap the org keeps its TASKS_PER_ORG most recently changed rows and says how many + // matched; another org is untouched. Task n changed n minutes after midnight. + const minute = n => new Date(Date.parse('2026-11-09T00:00:00.000Z') + n * 60000).toISOString(); + const many = Array.from({ length: TASKS_PER_ORG + 7 }, (_, i) => store(i + 1, i + 1 > TASKS_PER_ORG ? { group: 'late', state: 'blocked' } : { group: 'p1' }, { at: minute(i + 1), project_key: 'k' })); + const projects = [{ id: 'p1', repo: 'one', name: 'One', archived: false, at: minute(0) }, { id: 'late', repo: 'late', name: 'Late', archived: false, at: minute(0) }]; + const big = [member('big', { tasks: many, projects }), member('small', { tasks: [store(1)] })]; + const capped = await readTasks(big, 'sections=tasks,projects,metrics'); + const bigRows = capped.tasks.filter(x => x.org === 'big'); + assert.equal(bigRows.length, TASKS_PER_ORG); assert.equal(capped.orgs.find(o => o.org === 'big').tasksHeld, TASKS_PER_ORG + 7); assert.equal(capped.orgs.find(o => o.org === 'small').tasksHeld, undefined); + assert.equal(bigRows.some(x => x.ref === 'task:7'), false, 'the oldest changes are the ones cut'); + assert.equal(bigRows.some(x => x.ref === 'task:8'), true); // Store refs sort by number, so task:10 follows task:9. - assert.deepEqual(capped.tasks.filter(x => x.org === 'big').slice(8, 11).map(x => x.ref), ['task:9', 'task:10', 'task:11']); + assert.deepEqual(bigRows.slice(1, 4).map(x => x.ref), ['task:9', 'task:10', 'task:11']); + // Projects and metrics count every task the org holds, not only the ones the answer carries. + const proj = Object.fromEntries(capped.projects.filter(x => x.org === 'big').map(x => [x.id, x])); + assert.equal(proj.p1.openTasks, TASKS_PER_ORG); assert.equal(proj.late.openTasks, 7); + assert.equal(capped.metrics.orgs.find(m => m.org === 'big').openTasks, TASKS_PER_ORG + 7); + // A filter runs before the cap, so it reaches tasks past row TASKS_PER_ORG. + for (const search of ['sections=tasks&project=big:late', 'sections=tasks&task-state=blocked']) { + const filtered = await readTasks(big, search); + assert.deepEqual(filtered.tasks.map(x => x.ref), Array.from({ length: 7 }, (_, i) => `task:${TASKS_PER_ORG + i + 1}`), search); + assert.equal(filtered.orgs.find(o => o.org === 'big').tasksHeld, undefined, search); + } }); test('read: a Vikunja org\'s tasks come through the real tasks adapter from a fake Vikunja on port 0', async t => {