diff --git a/packages/webui/src/public/tasks-board.js b/packages/webui/src/public/tasks-board.js index 9bdc95273..00ca60c5d 100644 --- a/packages/webui/src/public/tasks-board.js +++ b/packages/webui/src/public/tasks-board.js @@ -41,7 +41,12 @@ // The layout is remembered per viewer and per width: a phone opens on List until its viewer picks // Kanban, a wide screen on Kanban until its viewer picks List. const layouts = (() => { try { const v = JSON.parse(load(LAYOUT) || '{}'); return v && typeof v === 'object' ? v : {}; } catch { return {}; } })(); - const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '' }; + // done: 'week' shows the tasks the Dashboard's Done this week tile counts. It stays in the browser: + // the rows already carry done and their last change, the same test the tile's count uses. + const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '', done: '' }; + const WEEK_MS = 7 * 24 * 3600 * 1000; + const doneThisWeek = t => t.done && t.changedAt && Date.now() - Date.parse(t.changedAt) <= WEEK_MS; + const DONE_NOTE = 'Done this week counts done tasks by their last change; the bus records no time a task was done.'; // The board style in force: the filtered project's when it has one, else the one organization's the // view is narrowed to (by the org chip or by a project of that org), else none. function activeBoard() { @@ -167,6 +172,7 @@ && (!filter.assignee || (filter.assignee === '-' ? !t.assignees.length : t.assignees.includes(filter.assignee))) && (!filter.state || stateOf(t) === filter.state) && (!filter.label || t.labels.map(String).includes(filter.label)) + && (!filter.done || doneThisWeek(t)) && (!q || !needle || `${t.title ?? ''} ${shortId(t.ref)} ${projectName(t) ?? ''}`.toLowerCase().includes(needle))); } const select = (name, label, options, focus) => ``; @@ -189,6 +195,7 @@ const labelSel = labels.length ? select('label', 'Label', opt('', 'Any', filter.label) + labels.map(l => opt(l, l, filter.label)).join(''), 'tb-label') : ''; return `
${scope ? '' : `
${chips}
`} + ${filter.done ? '' : ''} ${select('project', 'Project', projectOpts, 'tb-project')}${select('assignee', 'Assignee', assigneeOpts, 'tb-assignee')}${select('state', 'State', stateOpts, 'tb-state')}${labelSel}
`; @@ -244,8 +251,9 @@ // total; with one of those on, the line says how many match rather than inventing an "of N". // The scope's org= is where the board reads, not a filter. const onServer = new URLSearchParams(readQuery()).size > (scope ? 2 : 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 note = filter.done ? ` ${DONE_NOTE}` : ''; + const line = onServer ? `

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

` + : n !== all ? `

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

` : note ? `

${note.trim()}

` : ''; // An empty result is only exhaustive when every organization's tasks were read. const unread = snap.orgs.filter(o => !tasksRead(o)); const none = what => (unread.length ? `${what} in the organizations that answered. Tasks from ${unread.map(o => o.label).join(', ')} could not be read.` : `${what}.`); @@ -349,6 +357,7 @@ const org = String(q.get('org') || ''), project = String(q.get('project') || ''); filter.org = scope || (ORG_ID.test(org) ? org : ''); filter.project = PROJECT_SELECTOR.test(project) || project === '-' ? project : ''; + filter.done = q.get('done') === 'week' ? 'week' : ''; if (filter.org && filter.project && filter.project !== '-' && !filter.project.startsWith(`${filter.org}:`)) filter.project = ''; if (dragging) await Promise.race([dragEnded, new Promise(r => setTimeout(r, 30000))]); // Two renders at once (a first load, a poll during a filter change): the replaced one draws from the newer read. @@ -592,6 +601,7 @@ ws()?.setQuery?.({ org: org || null, ...(clear ? { project: null } : {}) }, true); return; } + if (e.target.closest('[data-tb-done]')) { filter.done = ''; paint(); ws()?.setQuery?.({ done: null }, true); 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, t); return; } const add = e.target.closest('[data-tb-new]'); diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 851de9047..9ce8dd145 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -308,7 +308,7 @@ ${orgTile('agents', 'Agents working', `#/agents?${oq}&state=working`, agentsWhy, { big: working, small: `of ${orgAgents?.length ?? 0}`, delta: orgAgents ? agentNote(runningOf(orgAgents), waiting) : '' })} ${orgTile('tasks', 'Open tasks', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.openTasks) })} ${orgTile('needs', 'Needs you', `#/inbox?${oq}`, whyNot(['inbox']), { big: num(mine?.needsYou), small: num(mine?.needsYou) === 1 ? 'item' : 'items', delta: num(mine?.needsYou) && mine?.oldestNeedsYouAt ? `oldest ${span(mine.oldestNeedsYouAt)}` : 'nothing waiting' })} - ${orgTile('done', 'Done this week', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.doneThisWeek) })} + ${orgTile('done', 'Done this week', `#/tasks?${oq}&done=week`, whyNot(['tasks']), { big: num(mine?.doneThisWeek) })} `; const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model; // SP11: the sessions are the running rows the tile counts: the organization's instances and the @@ -683,7 +683,7 @@ ${kpiTile(o, { key: 'orgs', name: 'Organizations', href: '#/dashboard', big: answered, small: `of ${o.rows.length} running`, delta: o.unread.length ? andList(o.unread.map(r => `${esc(o.name(r.org))} ${offWord(r)}`)) : 'every bus answered', attrs: ' data-ws-jump="orgs"' })} ${kpiTile(o, { key: 'needs', name: 'Needs you', href: '#/inbox', big: needsN, small: needsN === 1 ? 'item' : 'items', parts: ps.needs, delta: needsN && t?.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting', miss: notIn(SRC.needs), ...noFig })} ${kpiTile(o, { key: 'tasks', name: 'Open tasks', href: '#/tasks', big: fig('tasks', ps.tasks), parts: ps.tasks, miss: notIn(SRC.tasks), ...noFig })} - ${kpiTile(o, { key: 'done', name: 'Done this week', href: '#/tasks', big: fig('done', ps.done), parts: ps.done, miss: notIn(SRC.done), ...noFig })} + ${kpiTile(o, { key: 'done', name: 'Done this week', href: '#/tasks?done=week', big: fig('done', ps.done), parts: ps.done, miss: notIn(SRC.done), ...noFig })} ${kpiTile(o, { key: 'projects', name: 'Projects', href: selHref('/projects'), big: fig('projects', ps.projects), small: 'active', parts: ps.projects, delta: `${archived} archived`, miss: notIn(SRC.projects), ...noFig })} `; diff --git a/packages/webui/tests/kpi-tiles-browser.test.mjs b/packages/webui/tests/kpi-tiles-browser.test.mjs index 63d06cac0..c65988ac3 100644 --- a/packages/webui/tests/kpi-tiles-browser.test.mjs +++ b/packages/webui/tests/kpi-tiles-browser.test.mjs @@ -110,7 +110,7 @@ test('The Dashboard tiles add both answering organizations\' numbers, one row, e assert.equal(links.orgs, '#/dashboard'); assert.equal(links.needs, '#/inbox'); assert.equal(links.tasks, '#/tasks'); - assert.equal(links.done, '#/tasks'); + assert.equal(links.done, '#/tasks?done=week'); assert.match(links.projects, /^#\/orgs\/[a-z0-9._-]+\/projects$/); // No sample numbers and no trend line: no tile draws an svg sparkline. assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-tiles] svg:not(.i)").length'), 0); @@ -143,6 +143,11 @@ test('Following a tile lands on its page showing what the tile counted', async t await wait('location.hash === "#/inbox" && document.querySelectorAll("[data-ws-item]").length === 3'); await b.evaluate('location.hash = "#/dashboard"'); await wait(homeReady); + // SP11: Done this week lands on the board showing the one task it counted, beta's Logo. + await b.evaluate('document.querySelector("[data-ws-tile=done]").click()'); + await wait('location.hash === "#/tasks?done=week" && JSON.stringify([...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask)) === JSON.stringify(["beta|local:9"])'); + await b.evaluate('location.hash = "#/dashboard"'); + await wait(homeReady); // The Organizations tile's list is this page's own table: following it keeps the page and moves focus there. await b.evaluate('document.querySelector("[data-ws-tile=orgs]").click()'); await wait('document.activeElement?.closest?.("[data-ws-orgs-h]")'); @@ -183,7 +188,7 @@ test('An organization page leads with its own tiles, each linking to the system assert.equal(await b.evaluate(big('[data-org-tiles]', 'tasks')), '2'); assert.equal(await b.evaluate(big('[data-org-tiles]', 'needs')), '2 items'); assert.equal(await b.evaluate(big('[data-org-tiles]', 'done')), '1'); - assert.deepEqual(await b.evaluate(hrefs('[data-org-tiles]')), { agents: '#/agents?org=beta&state=working', tasks: '#/tasks?org=beta', needs: '#/inbox?org=beta', done: '#/tasks?org=beta' }); + assert.deepEqual(await b.evaluate(hrefs('[data-org-tiles]')), { agents: '#/agents?org=beta&state=working', tasks: '#/tasks?org=beta', needs: '#/inbox?org=beta', done: '#/tasks?org=beta&done=week' }); // The empty KPI and metric placeholders are gone; there is one tile row. assert.doesNotMatch(await text('[data-org-dashboard]'), /No KPIs defined|No metrics recorded yet/); assert.equal(await b.evaluate('document.querySelectorAll("[data-org-dashboard] .ws-tiles, [data-org-dashboard] [data-org-tiles]").length'), 1); diff --git a/packages/webui/tests/system-tasks-browser.test.mjs b/packages/webui/tests/system-tasks-browser.test.mjs index 70082c723..9ddc5712b 100644 --- a/packages/webui/tests/system-tasks-browser.test.mjs +++ b/packages/webui/tests/system-tasks-browser.test.mjs @@ -18,7 +18,7 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++) const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString(); const D = n => String(n).repeat(64); -function fixture({ empty = false, archived = [] } = {}) { +function fixture({ empty = false, archived = [], done = false } = {}) { const LIST = [ { id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }, { id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true }, @@ -33,9 +33,11 @@ function fixture({ empty = false, archived = [] } = {}) { beta: [{ n: 2, title: 'Price page', state: 'in-progress', group: 'shop', assignees: [] }, { n: 3, title: 'Spring banner', state: 'todo', group: 'ads', assignees: [] }], }; if (empty) { STORE.acme = []; STORE.beta = []; } + // `done`: one task finished two days ago and one ten days ago; only the first is done this week. + if (done) { STORE.acme.push({ n: 4, title: 'Old logo', state: 'done', group: 'site', assignees: [], ago: 60 * 24 * 10 }); STORE.beta.push({ n: 5, title: 'Launch email', state: 'done', group: 'shop', assignees: [], ago: 60 * 24 * 2 }); } for (const [org, list] of Object.entries(PROJECTS)) for (const p of list) p.archived = archived.includes(`${org}:${p.id}`); - const tasks = id => (STORE[id] || []).map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: at(10), priority: 0, digest: D(t.n), requirement: 'REQ-WEB-1', - fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: null } })); + const tasks = id => (STORE[id] || []).map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: at(t.ago ?? 10), priority: 0, digest: D(t.n), requirement: 'REQ-WEB-1', + fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: null, ...(t.state === 'done' ? { done: true } : {}) } })); const writes = []; const taskWrite = org => async (verb, args) => { writes.push([org, verb, args]); return { task_ref: args.task_ref, digest: D(9) }; }; const opts = id => ({ @@ -81,8 +83,8 @@ const STUB = noWrite => `window.__noWrite = ${JSON.stringify(noWrite)}; }; })();`; -async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = false, noWrite = [], archived = [] } = {}) { - const orgs = fixture({ empty, archived }); +async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = false, noWrite = [], archived = [], done = false } = {}) { + const orgs = fixture({ empty, archived, done }); const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, send: async () => ({}), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close()); @@ -118,6 +120,26 @@ test('#/tasks shows both organizations\' tasks on one board, each card with its assert.equal(await b.evaluate('document.querySelector("#bus-view .s1-stale")'), null); }); +// SP11 (#1873): the Dashboard's Done this week tile opens the board on the tasks it counted: done, and +// changed in the last seven days. The bus keeps no time a task was done, so the filter says it reads +// the last change, the time the tile counts by. The filter lives in the address and clears. +test('#/tasks?done=week shows only the tasks Done this week counted, says what it counts, and clears', async t => { + const { b, base, wait, text } = await setup(t, { hash: '#/tasks?done=week', done: true }); + await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:5']))}`); + assert.match(await text('[data-tb-done]'), /^Done this week\s+×$/); + assert.match(await b.evaluate('document.querySelector("[data-tb-done]").getAttribute("aria-label")'), /^Clear Done this week: done tasks last changed in the past 7 days$/); + assert.match(await text('[data-tasks-board] .tb-count'), /^Showing 1 of 5 tasks\. Done this week counts done tasks by their last change; the bus records no time a task was done\.$/); + // It survives a reload and joins the organization filter. + await b.navigate(`${base}/#/tasks?org=acme&done=week`); + await wait('document.querySelector("[data-tb-done]") && document.querySelector("[data-tasks-board] .ws-panel")?.textContent.includes("No tasks match these filters")'); + await b.evaluate('location.hash = "#/tasks?done=week"'); + await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:5']))}`); + // Clearing it drops it from the address and shows every task again. + await b.evaluate('document.querySelector("[data-tb-done]").click()'); + await wait(`location.hash === "#/tasks" && ${cards} === ${JSON.stringify(JSON.stringify(['acme|task:1', 'acme|task:4', 'beta|task:2', 'beta|task:3', 'beta|task:5']))}`); + assert.equal(await b.evaluate('document.querySelector("[data-tb-done]")'), null); +}); + test('the organization and project filters live in the address and survive a reload', async t => { const { b, base, wait } = await setup(t); await wait(`${cards} === ${ALL}`);