From 04e0d3f946fee9200b73df43a03c7b0cd2c37747 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sun, 15 Nov 2026 18:28:45 +0800 Subject: [PATCH] ui: split the Work page into summary, list and actions Fixes #804 --- changelog.d/804-work-page.changed.md | 1 + docs/architecture.md | 17 ++++--- public/app.css | 9 ++++ src/views/pages.ts | 70 +++++++++++++++++++--------- test/pages.test.ts | 4 ++ test/tableBandAffordance.test.ts | 21 ++++----- test/tableCards.test.ts | 12 +---- 7 files changed, 79 insertions(+), 55 deletions(-) create mode 100644 changelog.d/804-work-page.changed.md diff --git a/changelog.d/804-work-page.changed.md b/changelog.d/804-work-page.changed.md new file mode 100644 index 00000000..e0ca8e01 --- /dev/null +++ b/changelog.d/804-work-page.changed.md @@ -0,0 +1 @@ +The Work page now shows a compact state summary with counts and shares, keeps row actions behind one disclosure, moves the new-work-item form into a header dialog and names why ready work is waiting; the work-item table stays the compact list it is. diff --git a/docs/architecture.md b/docs/architecture.md index 6a2b8765..56ad940c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -821,12 +821,12 @@ becomes a card whose cells carry their column name in a `data-label` attribute behind a horizontal scroll on a phone, and a new column must emit `data-label` in `jobRows` to stay readable there. The rest of the data tables reuse the same block through one class, `table-cards`: the `/admin` users and audit tables, the -dashboard repository table, the repository page's delivery, board and work-item -tables, and the support view's repository and job tables. Adding the class and a +dashboard repository table, the repository page's delivery and work-item tables, +and the support view's repository and job tables. Adding the class and a `data-label` per cell is the whole change; a table must not grow a second card -mechanism. The delivery board is the one exception, `table-cards table-cards-head`: -its header row is the stage filter, so it stays in the flow and reflows as a -wrapping row of links above the cards instead of disappearing. Above 640px the scroller is also bounded vertically +mechanism. The delivery board is a compact summary of state links with counts +and shares, not a carded table; it wraps above the work-item list and each state +link filters the list. Above 640px the scroller is also bounded vertically (`100dvh` minus `--header-h` and `--space-7`, and minus the repository subnav's `--subnav-h` on a repository page) and its `thead th` is sticky on an opaque `--panel`, so column names stay above the rows they label; the two rules @@ -852,9 +852,8 @@ attribute states, the meta shapes, the 44px control and the two palette tokens the sticky header and the account pill used to hardcode. Signed-out pages honour the saved palette but render no control: the chooser rides inside `#main-nav`, so below 961px the compact header's panel carries it and the phone row gains no -child. Between 641px and 960px a wide table (the -delivery board is 952px wide at 768) can still be wider than its `.table-scroll` -box, so that band draws an always-present classic scrollbar — an explicit track +child. Between 641px and 960px a wide data table can still be wider than its +`.table-scroll` box, so that band draws an always-present classic scrollbar — an explicit track plus `scrollbar-width`/`scrollbar-color` for Firefox — instead of relying on a thumb that is 1.5:1 on the panel. The band has its own `(min-width: 641px) and (max-width: 960px)` block in `public/app.css`; the cards @@ -907,7 +906,7 @@ timeline as the token to search, and it is the `job_events.job_id` of that job, so one search returns the whole job. Pass-level lines (a scheduler pass, the shutdown check) have no job in scope and deliberately carry no `trace`. -The repository page adds a compact delivery board grouped by work-item state; each state header links to a filtered work-item list (`?workState=`). The dashboard has two scopes. `/dashboard?window=7|30` shows the signed-in +The repository page adds a compact delivery board grouped by work-item state; each state tile links to a filtered work-item list (`?workState=`) and shows its share of the total. The work-item rows keep their actions behind one disclosure, and the create form is a header button and dialog. The dashboard has two scopes. `/dashboard?window=7|30` shows the signed-in user's own delivery metrics: jobs, success rate, PRs, merged PRs, cost, open work items and the last job. `/admin/metrics` shows the global window with the product funnel, delivery efficiency (success and merge rate, median job and diff --git a/public/app.css b/public/app.css index 9338c641..0bd38649 100644 --- a/public/app.css +++ b/public/app.css @@ -514,6 +514,15 @@ p { margin: 0 0 12px; } inner-id offset, so the two cannot drift apart. */ .repo-page { --subnav-h: calc(var(--touch) + 2 * var(--space-2) + 1px); } .repo-page [id] { scroll-margin-top: calc(var(--header-h) + var(--subnav-h) + var(--space-4)); } +.work-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 8px; margin: 12px 0 8px; } +.work-summary a { display: grid; gap: 2px; min-width: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--field); color: var(--text); text-decoration: none; } +.work-summary a:hover, .work-summary a.is-active { border-color: var(--accent); } +.work-summary strong { font-size: var(--text-md); font-variant-numeric: tabular-nums; } +.work-summary span { font-size: var(--text-sm); color: var(--muted); } +.work-summary small { font-size: var(--text-sm); color: var(--muted-2); } +.work-actions summary { cursor: pointer; color: var(--accent); font-size: var(--text-sm); font-weight: 650; } +.work-actions .row { margin-top: 8px; } +@media (max-width: 680px) { .work-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .repo-subnav { position: sticky; top: var(--header-h); z-index: 15; display: flex; gap: var(--space-2); margin: 0 0 var(--space-4); min-height: var(--subnav-h); diff --git a/src/views/pages.ts b/src/views/pages.ts index c6da7eb5..fc28f81c 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -1556,37 +1556,63 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer ${item.priority} ${item.issue_url === null ? '' : `Issue`}${item.issue_url !== null && item.pr_url !== null ? ' · ' : ''}${item.pr_url === null ? '' : `PR`} ${item.job_id === null ? '—' : `${escapeHtml(item.job_id)}`} -
+
${escapeHtml(t('操作', 'Actions'))}
${item.state === 'merged' || item.state === 'closed' ? '' : `
${csrf(viewer)}${commandHidden(commands[`work-run:${item.id}`])}
`} ${item.state === 'merged' || item.state === 'closed' ? '' : `
${csrf(viewer)}${commandHidden(commands[`work-scope:${item.id}`])}
`} ${item.pr_url === null ? '' : `
${csrf(viewer)}${commandHidden(commands[`work-verify:${item.id}`])}
`} ${stateOptions === '' ? '' : `
${csrf(viewer)}
`} -
+
` }).join('') const workCounts = new Map() for (const item of workItems) workCounts.set(item.state, (workCounts.get(item.state) ?? 0) + 1) - const workBoard = `
-

${escapeHtml(t('交付看板', 'Delivery board'))}

${workState === undefined ? escapeHtml(t('按工作项状态汇总;点击状态可筛选下面的列表。', 'Grouped by state; click a state to filter the list below.')) : `${escapeHtml(t('清除筛选', 'Clear filter'))}`}
-
${WORK_ITEM_STATES.map((state) => ``).join('')}${WORK_ITEM_STATES.map((state) => ``).join('')}
${escapeHtml(workItemStateLabel(t, state))}
${workCounts.get(state) ?? 0}
-
` - + const workTotal = workItems.length + const workSummary = `
${WORK_ITEM_STATES.map((state) => { + const count = workCounts.get(state) ?? 0 + const share = workTotal === 0 ? 0 : Math.round((count / workTotal) * 100) + const active = workState === state + return `${count}${escapeHtml(workItemStateLabel(t, state))}${share}%` + }).join('')}
` + const workQueueReason = !repoEnabled + ? t('仓库已停用;工作项不会开始。', 'The repository is disabled; work items will not start.') + : !credentialOk + ? t('GitHub 凭据失效;工作项不会开始。', 'The GitHub credential is invalid; work items will not start.') + : flybotAvailable && !flybotReady + ? t('FlyBot 实例未就绪;工作项不会开始。', 'The FlyBot instance is not ready; work items will not start.') + : repo.auto_iterate !== 1 + ? t('自动工作已关闭;可以从操作里手动开始。', 'Automatic work is off; start an item manually from its actions.') + : input.activeJob === true + ? t('已有任务在运行;队列会在它结束后继续。', 'A job is already running; the queue continues when it finishes.') + : t('工作项会在可运行时开始。', 'Work items start when they are runnable.') const workCard = `
-

${escapeHtml(t('工作项', 'Work items'))}

${workState === undefined ? escapeHtml(t('Issue / PR / 状态 / 优先级', 'Issue / PR / state / priority')) : escapeHtml(t(`筛选:${workItemStateLabel(t, workState)}`, `Filter: ${workItemStateLabel(t, workState)}`))}
-
- ${csrf(viewer)} -
- - -
-
- +
+
+

${escapeHtml(t('工作项', 'Work items'))}

+

${workState === undefined ? escapeHtml(t('按状态汇总;点击状态可筛选下面的列表。', 'Grouped by state; click a state to filter the list.')) : `${escapeHtml(t('清除筛选', 'Clear filter'))}`}

- - - -
${workRows}
${escapeHtml(t('工作项', 'Work item'))}${escapeHtml(t('状态', 'State'))}${escapeHtml(t('优先级', 'Priority'))}Issue / PR${escapeHtml(t('任务', 'Job'))}
-
`; + + +

${escapeHtml(t('交付看板', 'Delivery board'))}

${escapeHtml(t('按状态汇总', 'Grouped by state'))}
+ ${workSummary} +

${escapeHtml(workQueueReason)}

+ +

${escapeHtml(t('新建工作项', 'New work item'))}

+
+ ${csrf(viewer)} +
+ + +
+
+ +
+ + +
+
+ +
${workRows}
${escapeHtml(t('工作项', 'Work item'))}${escapeHtml(t('状态', 'State'))}${escapeHtml(t('优先级', 'Priority'))}Issue / PR${escapeHtml(t('任务', 'Job'))}${escapeHtml(t('操作', 'Actions'))}
+ ` const prRows = prJobs.length === 0 ? stateCell(7, { kind: 'empty', title: t('没有待处理的 PR。', 'No pull requests waiting.'), body: t('任务产出 PR 后会出现在这里;没有就是真的没有在等。', 'A pull request appears here once a job opens one; an empty list means nothing is waiting.'), action: { href: `/repos/${escapeHtml(repo.id)}`, label: t('回到仓库', 'Back to repository') } }) : prJobs.map((job) => { @@ -1817,7 +1843,7 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer return `
  • ${escapeHtml(release.tag_name)} ${escapeHtml(release.target_sha.slice(0, 12))} · ${escapeHtml(release.status)}${run}${external}${draft}
    ${csrf(viewer)}
  • ` }).join('')}`} ` - const body = `
    ${repoHeader}${repoTabs}${repoSection('overview', `${triageCard}${inboxSection(locale, viewer, questions, `/repos/${repo.id}`)}${chiefCard}${memoryCard}${gateCard}${rulesCard}${securityFindingsCard(t, viewer, repo, securityFindings)}`)}${repoSection('delivery', `${releaseCard}${workflowFailuresCard}${postmortemCard}${reviewTrendCard}${deliveryCard}${jobsCard}`)}${repoSection('work', `${workBoard}${workCard}`)}${repoSection('team', `${roleCard}${instanceCard}`)}${repoSection('settings', settingsCards)}${repoSection('danger', dangerCard)}
    ` + const body = `
    ${repoHeader}${repoTabs}${repoSection('overview', `${triageCard}${inboxSection(locale, viewer, questions, `/repos/${repo.id}`)}${chiefCard}${memoryCard}${gateCard}${rulesCard}${securityFindingsCard(t, viewer, repo, securityFindings)}`)}${repoSection('delivery', `${releaseCard}${workflowFailuresCard}${postmortemCard}${reviewTrendCard}${deliveryCard}${jobsCard}`)}${repoSection('work', `${workCard}`)}${repoSection('team', `${roleCard}${instanceCard}`)}${repoSection('settings', settingsCards)}${repoSection('danger', dangerCard)}
    ` return layout({ title: `${upstreamOwner}/${upstreamName}`, viewer, body: `${body}${advanced ? '' : ``}`, config, locale, path: `/repos/${repo.id}`, active: 'dashboard', notice, error, notifications: pageMessages }) } diff --git a/test/pages.test.ts b/test/pages.test.ts index 53e6d3c8..f764bd1e 100644 --- a/test/pages.test.ts +++ b/test/pages.test.ts @@ -1163,6 +1163,10 @@ test('the repository page shows a delivery board grouped by work-item state', () assert.match(page, /Fix the board/) assert.doesNotMatch(page, /Other work/) assert.match(page, /Clear filter/) + assert.match(page, /data-open-dialog="new-work-item"/, 'the create form is behind a header button') + assert.match(page, /id="new-work-item"/, 'the create form is a dialog') + assert.match(page, /data-work-queue/, 'the page names why an item is waiting') + assert.match(page, /
    [\s\S]*name="state"/, 'the row actions are folded into one disclosure') } finally { store.close() } diff --git a/test/tableBandAffordance.test.ts b/test/tableBandAffordance.test.ts index 52fffd6a..8386affa 100644 --- a/test/tableBandAffordance.test.ts +++ b/test/tableBandAffordance.test.ts @@ -253,23 +253,18 @@ test('the delivery board keeps every state filter reachable at every band width' jobs: [], workItems: [], }) - const board = /
    ([\s\S]*?)<\/section>/.exec(page) - assert.ok(board !== null, `${locale} page must keep the #board section`) + const board = /
    ]*>([\s\S]*?)<\/div>/.exec(page) + assert.ok(board !== null, `${locale} page must keep the #board summary`) const markup = board[1] ?? '' - // The board's header row is the only filter for the list below it: every - // state must still be a link, and the row must still be a table row, at - // every width — the CSS change may not remove one to make the table fit. - const links = [...markup.matchAll(//g)].map((match) => match[1]) - assert.deepEqual(links, [...WORK_ITEM_STATES], `${locale} board must keep one filter link per state, in order`) - assert.equal((markup.match(//g) ?? []).length, WORK_ITEM_STATES.length, `${locale} board must keep one th per state`) - assert.match(markup, /class="table-scroll"/, 'the board stays inside the scroller the affordance is attached to') + // The summary is the only filter for the list below it: every state must + // still be a link, with its count, translated label and share. + const links = [...markup.matchAll(/href="\/repos\/[^"]*\?workState=([a-z_]+)#work"/g)].map((match) => match[1]) + assert.deepEqual(links, [...WORK_ITEM_STATES], `${locale} summary must keep one filter link per state, in order`) const t: Translate = (zh, en) => (locale === 'zh' ? zh : en) for (const state of WORK_ITEM_STATES) { - assert.ok( - markup.includes(`>${workItemStateLabel(t, state)}`), - `${locale} board must link the ${state} column with its translated label`, - ) + assert.ok(markup.includes(workItemStateLabel(t, state)), `${locale} summary must name ${state}`) } + assert.equal((markup.match(/%/g) ?? []).length, WORK_ITEM_STATES.length, `${locale} summary must show a share per state`) } } finally { store.close() diff --git a/test/tableCards.test.ts b/test/tableCards.test.ts index 3eb6b487..3d146702 100644 --- a/test/tableCards.test.ts +++ b/test/tableCards.test.ts @@ -2,11 +2,9 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { Store } from '../src/db.js' -import { WORK_ITEM_STATES, workItemStateLabel } from '../src/lib/workItems.js' import { adminPage, adminSupportPage, dashboardPage, repoPage } from '../src/views/pages.js' import { lastValue, parseRules, pxValue } from './cssRules.js' import { makeUser, testConfig } from './helpers.js' -import { translator } from '../src/i18n.js' // #130: the tables the phone matrix measured as clipped inside `.table-scroll` // reuse the mechanism #116 built for the job table — a `data-label` on every @@ -182,21 +180,13 @@ for (const locale of ['en', 'zh'] as const) { try { const expected = locale === 'en' ? EN_LABELS : ZH_LABELS const pages = pagesOf(input, locale) - const boardLabels = WORK_ITEM_STATES.map((state) => workItemStateLabel(translator(locale), state)) for (const page of pages) { const tables = cardTables(page.html) const wanted = expected[page.name] ?? [] - // The board adds one carded table (its stage filter row plus five cells). - const extra = page.name === 'repos' ? 1 : 0 - assert.equal(tables.length, wanted.length + extra, `the ${page.name} page must render ${String(wanted.length + extra)} carded table(s), got ${String(tables.length)}`) + assert.equal(tables.length, wanted.length, `the ${page.name} page must render ${String(wanted.length)} carded table(s), got ${String(tables.length)}`) for (const table of tables) { assert.equal(unlabelledCells(table), 0, `every cell of every ${page.name} carded table must carry data-label`) } - if (page.name === 'repos') { - const board = tables.find((table) => table.includes('table-cards-head')) ?? '' - assert.notEqual(board, '', 'the board keeps its own class so its filter row can stay in the flow') - assert.deepEqual(labelsOf(board), boardLabels, 'the board prints the stage name above each count') - } for (const labels of wanted) { const match = tables.find((table) => labelsOf(table).slice(0, labels.length).every((label, index) => label === labels[index])) assert.ok(match !== undefined, `the ${page.name} page must label its table with ${labels.join(' / ')}`)