diff --git a/docs/dashboard-pages.md b/docs/dashboard-pages.md index 645f4b332..e1a2ef81b 100644 --- a/docs/dashboard-pages.md +++ b/docs/dashboard-pages.md @@ -40,6 +40,8 @@ CI、PR、Issue 数据不可读取时显示“状态未知”,不会显示为 - **口径**:按 UTC 日期统计。存量按每个 Issue/PR 当前的创建、关闭时间回推,关闭后又重开的从创建起都算开放。历史尚未回填完时,说明里会提示较早日期可能偏少。 - 范围和开关只保存在当前页面,重新打开回到最近 30 天;定时刷新数据时保留。 +列表正上方是**开启时长**饼图:按创建时间把当前开放项分进 `<7d`、`7-30d`、`30-90d`、`>90d` 四桶(边界与采集器的年龄统计一致),图例同时给出数量和占比;`age_days` 缺失的项不计入,数量为 0 的桶只在图例里列出、不可点。点击扇区或图例只筛选本页下方的开放列表(表格和看板都吃同一个筛选),再点一次选中的扇区取消;筛选与搜索、标签、认领人等条件同时生效(取交集),Issue 与 PR 的选择互不影响。筛选生效时筛选条出现当前时长桶,可点 × 清除;结果为 0 时列表显示空状态,饼图保持可见。“最近更新”“最久未动”“最近合并”等旁路表不受这个筛选影响。Issue 页用这张图代替原来的“年龄分布”条形图。 + 数据由看板仓的采集在 Actions 中算好写进快照,不读 Bot 的数据库,计算方式见[增量同步与历史](incremental-history.md)。 列表可在“表格”和“看板”之间切换,选择保存在本浏览器。 @@ -89,6 +91,7 @@ node .e2e/node_modules/playwright/cli.js test --config test/playwright.config.cj - 总览的问题排序与数据缺失时显示“状态未知”; - Issue / PR 的表格 / 看板切换、按类型隔离、本地字段与导入导出; +- Issue / PR 开启时长饼图:桶边界与数量、扇区/图例点击与再点取消、与文本搜索取交、两页选择互不影响、看板视图同一筛选、空结果与旁路表不受影响; - Issue / PR 每日趋势:默认 30 天、预设与自定义范围、刷新后保留范围、数据开关、悬停与方向键查看、整数刻度、按日表格,以及 390px 下不溢出; - 看板列独立滚动与刷新后保留位置; - 覆盖率目录树的展开、收起、过滤、按覆盖率排序,以及无逐文件数据时按分组展示; diff --git a/static/app.js b/static/app.js index 2e0342e15..a29290906 100644 --- a/static/app.js +++ b/static/app.js @@ -52,7 +52,7 @@ const loadViews = () => { try { return { issues: 'table', prs: 'table', ...JSON.parse(localStorage.getItem(VIEW_KEY) || '{}') }; } catch (e) { return { issues: 'table', prs: 'table' }; } }; const loadLine = () => { try { return localStorage.getItem(LINE_KEY); } catch (e) { return null; } }; const loadCovSort = () => { try { return localStorage.getItem(COV_SORT_KEY) === 'lines' ? 'lines' : 'name'; } catch (e) { return 'name'; } }; - const STATE = { snap: null, status: null, tab: 'overview', views: loadViews(), line: loadLine(), covOpen: new Set(), covSort: loadCovSort(), sort: {}, filters: { issueQ: '', issueLabel: '', issueAssignee: '', runBranch: '' }, coverageWeekOffset: 0, e2e: null, e2eSteps: {}, e2eFilter: { status: '', q: '' }, e2eLoading: new Set(), e2eErrors: new Set(), e2eCollapsed: new Set(), pollTimer: null }; + const STATE = { snap: null, status: null, tab: 'overview', views: loadViews(), line: loadLine(), covOpen: new Set(), covSort: loadCovSort(), sort: {}, filters: { issueQ: '', issueLabel: '', issueAssignee: '', runBranch: '' }, ageFilter: { issues: null, prs: null }, coverageWeekOffset: 0, e2e: null, e2eSteps: {}, e2eFilter: { status: '', q: '' }, e2eLoading: new Set(), e2eErrors: new Set(), e2eCollapsed: new Set(), pollTimer: null }; const returnParams = new URLSearchParams(location.search); const returnFocus = { line: returnParams.get('returnLine'), caseName: returnParams.get('returnCase'), pending: !!returnParams.get('returnCase'), e2eKey: returnParams.get('returnE2E'), e2ePending: !!returnParams.get('returnE2E') }; @@ -328,6 +328,61 @@ const viewSwitch = (tab) => `
${[['table', '表格'], ['board', '看板']].map(([v, l]) => ``).join('')}
`; const listHead = (tab, title, sub) => `

${esc(title)}

${sub}${viewSwitch(tab)}
`; const boardMount = (scope) => `
装配看板…
`; + + // Open-age pie above each open list: same bucket boundaries as the collector's aging + // summary ([0,7), [7,30), [30,90), [90,∞)), computed from the items the list shows. + const AGE_BUCKETS = [['<7d', 0, 7], ['7-30d', 7, 30], ['30-90d', 30, 90], ['>90d', 90, null]]; + const AGE_BUCKET_COLOR = ['seq-3', 'seq-4', 'seq-5', 'seq-6']; + const ageBucketOf = (age) => { + if (age == null || Number.isNaN(Number(age))) return -1; + return AGE_BUCKETS.findIndex(([, lo, hi]) => age >= lo && (hi == null || age < hi)); + }; + const ageBucketCounts = (items) => { + const counts = AGE_BUCKETS.map(() => 0); + items.forEach((i) => { const k = ageBucketOf(i.age_days); if (k >= 0) counts[k] += 1; }); + return counts; + }; + // Donut chart: every drawn sector is a focusable button; zero-count buckets only appear + // in the legend as plain text. Clicking a sector or legend entry filters the list below. + const agePie = (tab, items, unit) => { + const counts = ageBucketCounts(items), total = counts.reduce((a, b) => a + b, 0); + if (!total) return empty(`开放${unit}都没有年龄数据`); + const sel = STATE.ageFilter[tab]; + const cx = 90, cy = 90, rO = 82, rI = 46; + const point = (r, a) => [cx + r * Math.cos(a), cy + r * Math.sin(a)]; + const sectors = []; + let angle = -Math.PI / 2; + counts.forEach((count, k) => { + if (!count) return; + const span = (count / total) * Math.PI * 2, a0 = angle, a1 = angle + span; + angle = a1; + const on = sel === k, share = (count / total) * 100; + const [x0, y0] = point(rO, a0), [u0, v0] = point(rI, a0); + let d; + if (span >= Math.PI * 2 - 1e-9) { + // A lone sector is a full ring: two half arcs each way, since a 360° arc collapses. + const [x1, y1] = point(rO, a0 + Math.PI), [u1, v1] = point(rI, a0 + Math.PI); + d = `M${x0.toFixed(2)},${y0.toFixed(2)} A${rO},${rO} 0 1 1 ${x1.toFixed(2)},${y1.toFixed(2)} A${rO},${rO} 0 1 1 ${x0.toFixed(2)},${y0.toFixed(2)} L${u0.toFixed(2)},${v0.toFixed(2)} A${rI},${rI} 0 1 0 ${u1.toFixed(2)},${v1.toFixed(2)} A${rI},${rI} 0 1 0 ${u0.toFixed(2)},${v0.toFixed(2)} Z`; + } else { + const [x1, y1] = point(rO, a1), [u1, v1] = point(rI, a1), large = span > Math.PI ? 1 : 0; + d = `M${x0.toFixed(2)},${y0.toFixed(2)} A${rO},${rO} 0 ${large} 1 ${x1.toFixed(2)},${y1.toFixed(2)} L${u1.toFixed(2)},${v1.toFixed(2)} A${rI},${rI} 0 ${large} 0 ${u0.toFixed(2)},${v0.toFixed(2)} Z`; + } + const mid = (a0 + a1) / 2, offset = on ? ` transform="translate(${(Math.cos(mid) * 5).toFixed(2)},${(Math.sin(mid) * 5).toFixed(2)})"` : ''; + sectors.push(``); + }); + const center = `${n(total)}开放${unit}`; + const legend = AGE_BUCKETS.map(([label], k) => { + const share = ((counts[k] / total) * 100).toFixed(1), sw = ``, text = `${esc(label)}${n(counts[k])}${share}%`; + if (!counts[k]) return `
  • ${sw}${text}
  • `; + const on = sel === k; + return `
  • `; + }).join(''); + return `
    ${sectors.join('')}${center}
    `; + }; + const ageChip = (tab) => { + const sel = STATE.ageFilter[tab]; + return sel == null ? '' : `开启时长:${esc(AGE_BUCKETS[sel][0])} `; + }; // Totals across test reports, or null when any report has no readable counts. const sumCounts = (reports) => (!reports.length || reports.some((r) => !r.counts) ? null : Object.fromEntries(['tests', 'passed', 'failed', 'skipped', 'flaky'].map((k) => [k, reports.reduce((sum, r) => sum + (r.counts[k] || 0), 0)]))); @@ -569,7 +624,6 @@ const issueRow = (i) => `${link(i.url, `#${i.number} ${esc(i.title)}`)}${esc(i.author)} · ${labelChips(i.labels)}`; html += `
    ${card('标签分布', bars(d.labels.map((l) => ({ label: l.name, labelHtml: `${esc(l.name)}`, value: l.count })), { emptyText: '开放 Issue 都没有标签' }) + (d.unused_labels.length ? `
    仓库另有 ${d.unused_labels.length} 个标签未用于任何开放 Issue:${esc(d.unused_labels.join('、'))}
    ` : ''), { sub: '按开放 Issue 计数' })} - ${card('年龄分布', bars(d.aging.map((a) => ({ label: a.bucket, value: a.count }))), { sub: '按创建时间' })} ${card('认领负载', bars(d.assignee_load.map((a) => ({ label: a.login, value: a.count })), { emptyText: '所有开放 Issue 均无人认领' }), { sub: '按 assignee' })} ${card('里程碑', bars(d.milestones.map((m) => ({ label: m.name, value: m.count }))))}
    `; @@ -595,13 +649,16 @@ else if (f.issueLabel) rows = rows.filter((i) => i.labels.some((l) => l.name === f.issueLabel)); if (f.issueAssignee === '__none__') rows = rows.filter((i) => !i.assignees.length); else if (f.issueAssignee) rows = rows.filter((i) => i.assignees.includes(f.issueAssignee)); + if (STATE.ageFilter.issues != null) rows = rows.filter((i) => ageBucketOf(i.age_days) === STATE.ageFilter.issues); const preview = d.preview_total > d.items.length ? ` · 列表显示最近更新的 ${d.items.length} / ${d.preview_total} 个` : ''; + html += card('开启时长', agePie('issues', d.items, 'Issue'), { sub: STATE.ageFilter.issues != null ? '只筛选下方开放 Issue;再点一次选中的扇区或图例取消' : '点击扇区或图例筛选下方开放 Issue' }); html += listHead('issues', '开放 Issue', STATE.views.issues === 'board' ? `看板含最近关闭的 Issue${preview}` : `${rows.length} / ${d.items.length}${preview}`); if (STATE.views.issues === 'board') return html + boardMount('issue'); html += `
    + ${ageChip('issues')}
    ${table('iss-all', [ { key: 'number', label: '#', num: true, render: (i) => link(i.url, `#${i.number}`) }, { key: 'title', label: '标题', render: (i) => `${link(i.url, esc(i.title))}
    ${labelChips(i.labels)}${i.milestone ? ` · ${esc(i.milestone)}` : ''}
    ` }, @@ -610,7 +667,7 @@ { key: 'comments', label: '评论', num: true }, { key: 'created_at', label: '创建', render: (i) => ago(i.created_at) }, { key: 'updated_at', label: '更新', render: (i) => ago(i.updated_at) }, - ], rows, { defaultSort: { key: 'updated_at', dir: 'desc' } })}
    `; + ], rows, { defaultSort: { key: 'updated_at', dir: 'desc' }, emptyText: '没有符合筛选条件的开放 Issue' })}`; return html; } @@ -644,9 +701,12 @@ ${card('评审决定', bars(Object.entries(d.review_decisions).map(([k, v]) => ({ label: (DECISION[k] || [k])[0], value: v }))))} ${card('CI 状态', bars(Object.entries(d.ci_states).map(([k, v]) => ({ label: CONCLUSION_NAME[k] || k, value: v }))))} `; - html += listHead('prs', '开放 PR', STATE.views.prs === 'board' ? '看板含最近关闭与合并的 PR' : `${d.items.length} 个`); + let rows = d.items; + if (STATE.ageFilter.prs != null) rows = rows.filter((p) => ageBucketOf(p.age_days) === STATE.ageFilter.prs); + html += card('开启时长', agePie('prs', d.items, 'PR'), { sub: STATE.ageFilter.prs != null ? '只筛选下方开放 PR;再点一次选中的扇区或图例取消' : '点击扇区或图例筛选下方开放 PR' }); + html += listHead('prs', '开放 PR', STATE.views.prs === 'board' ? '看板含最近关闭与合并的 PR' : STATE.ageFilter.prs != null ? `${rows.length} / ${d.items.length} 个` : `${d.items.length} 个`); if (STATE.views.prs === 'board') html += boardMount('pr'); - else html += `
    ${table('pr-open', [ + else html += `
    ${STATE.ageFilter.prs != null ? `
    ${ageChip('prs')}
    ` : ''}${table('pr-open', [ { key: 'number', label: '#', num: true, render: (p) => link(p.url, `#${p.number}`) }, { key: 'title', label: '标题', render: (p) => `${link(p.url, esc(p.title))}
    ${esc(p.author)} · ${esc(p.head)} → ${esc(p.base)}${p.labels.length ? ' · ' + labelChips(p.labels) : ''}
    ` }, { key: 'age_days', label: '年龄', num: true, render: (p) => days(p.age_days) }, @@ -654,7 +714,7 @@ { key: 'ci', label: 'CI', render: ciCell, sort: (p) => p.ci.state }, { key: 'requested_reviewers', label: '评审人', render: (p) => (p.requested_reviewers.length ? esc(p.requested_reviewers.join(', ')) : p.reviews.length ? esc([...new Set(p.reviews.map((r) => r.author))].join(', ')) : '未指定'), sort: (p) => p.requested_reviewers.length }, { key: 'updated_at', label: '更新', render: (p) => ago(p.updated_at) }, - ], d.items, { emptyText: '当前没有开放 PR' })}
    `; + ], rows, { emptyText: STATE.ageFilter.prs != null ? '没有符合筛选条件的开放 PR' : '当前没有开放 PR' })}
    `; html += sectionHead('最近合并', '按合并时间'); html += `
    ${table('pr-merged', [ { key: 'number', label: '#', num: true, render: (p) => link(p.url, `#${p.number}`) }, @@ -1518,6 +1578,16 @@ renderTabs(); return; } + const age = ev.target.closest('[data-age-tab]'); + if (age) { + const tab = age.dataset.ageTab, prev = STATE.ageFilter[tab]; + const next = 'ageClear' in age.dataset ? null : Number(age.dataset.ageFilter); + STATE.ageFilter[tab] = prev === next ? null : next; + renderTabs(); + const keep = STATE.ageFilter[tab] ?? prev; + document.querySelector(`.age-key[data-age-tab="${tab}"][data-age-filter="${keep}"]`)?.focus(); + return; + } const lineButton = ev.target.closest('[data-line]'); if (lineButton) { STATE.line = lineButton.dataset.line; @@ -1608,6 +1678,14 @@ Object.assign(STATE.trend[kind], { range: 'custom', from: box.querySelector('[data-trend-from]').value, to: box.querySelector('[data-trend-to]').value }); drawTrend(kind); }); + // SVG sectors are not native buttons: activate them with Enter / Space like the legend buttons. + document.addEventListener('keydown', (ev) => { + if (ev.key !== 'Enter' && ev.key !== ' ') return; + const sector = ev.target.closest?.('.age-sector[data-age-tab]'); + if (!sector) return; + ev.preventDefault(); + sector.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); // Remember expanded coverage directories across the periodic refresh. document.addEventListener('toggle', (ev) => { const dir = ev.target; @@ -1616,7 +1694,7 @@ }, true); // Shared helpers for the board module (static/board.js). - window.GSB = { esc, ago, days, date, n, badge, labelChips, link, codeify, conclusionBadge, tip, empty, kv, CONCLUSION_NAME, refresh, snapshot:()=>STATE.snap }; + window.GSB = { esc, ago, days, date, n, badge, labelChips, link, codeify, conclusionBadge, tip, empty, kv, CONCLUSION_NAME, refresh, snapshot:()=>STATE.snap, ageBucketOf, AGE_BUCKETS, ageFilter:()=>STATE.ageFilter, setAgeFilter:(tab,value)=>{ STATE.ageFilter[tab] = value; renderTabs(); } }; STATE.tab = location.hash.slice(1) || 'overview'; load(); })(); diff --git a/static/board.js b/static/board.js index 781cbe17c..724b22962 100644 --- a/static/board.js +++ b/static/board.js @@ -59,8 +59,11 @@ function byId() { const m = {}; (B.data?.items || []).forEach((i) => { m[i.id] = i; }); return m; } function filtered() { const f = B.prefs.filters, q = (f.q || '').trim().toLowerCase(); + // The open-age pie on the owning page filters these cards too (Issue and PR stay apart). + const ageSel = H.ageFilter ? H.ageFilter()[B.scope === 'issue' ? 'issues' : 'prs'] : null; return (B.data?.items || []).filter((i) => { if (i.kind !== B.scope) return false; + if (ageSel != null && H.ageBucketOf(i.age_days) !== ageSel) return false; if (f.state === 'open' && i.state !== 'open') return false; if (f.state === 'closed' && i.state !== 'closed') return false; if (f.label && !i.labels.some((l) => l.name === f.label)) return false; @@ -139,6 +142,10 @@ const d = B.data, f = B.prefs.filters, p = B.prefs; const sel = (name, list, cur, allLabel, noneLabel) => ``; const chips = Object.entries(f).filter(([k, v]) => v).map(([k, v]) => `${esc({ q: '搜索', state: '状态', label: '标签', assignee: '负责人', author: '作者', priority: '优先级', iteration: '迭代', milestone: '里程碑' }[k])}: ${esc(v === NONE ? '无' : v)} `).join(''); + // The age filter lives on the page (pie above the list); mirror it here so board readers + // see the active bucket and can clear it. The × is wired by the page's own delegation. + const ageSel = H.ageFilter ? H.ageFilter()[B.scope === 'issue' ? 'issues' : 'prs'] : null; + const ageChip = ageSel == null ? '' : `开启时长:${esc(H.AGE_BUCKETS[ageSel][0])} `; const summary = p.group === 'status' ? columns(items).map((c) => `${esc(c.label)} ${c.items.length}${c.limit ? `/${c.limit}` : ''}`).join('') : ''; return `
    分组${Object.entries(GROUPS).map(([k, l]) => ``).join('')}
    @@ -156,7 +163,7 @@ ${sel('iteration', d.facets.iterations, f.iteration, '全部迭代', '未设置')}${sel('milestone', d.facets.milestones, f.milestone, '全部里程碑', '无里程碑')} ${items.length} / ${d.items.filter((i) => i.kind === B.scope).length} 张卡
    - ${chips ? `
    ${chips}
    ` : ''} + ${chips || ageChip ? `
    ${ageChip}${chips}
    ` : ''} ${summary ? `
    ${summary}规则:加入→${esc(d.rules.default_status)} · 指派→${esc(d.rules.doing_status)} · 关联 PR→${esc(d.rules.review_status)} · 关闭/合并→${esc(d.rules.done_status)}
    ` : ''} `; } @@ -389,7 +396,12 @@ if (a === 'reload') load(); else if (a === 'history') { B.historyOpen = !B.historyOpen; render(); } else if (a === 'settings') openSettings(); - else if (a === 'clear-filters') { Object.keys(B.prefs.filters).forEach((k) => { B.prefs.filters[k] = ''; }); savePrefs(); render(); } + else if (a === 'clear-filters') { + Object.keys(B.prefs.filters).forEach((k) => { B.prefs.filters[k] = ''; }); + savePrefs(); + const tab = B.scope === 'issue' ? 'issues' : 'prs'; + if (H.ageFilter && H.ageFilter()[tab] != null) H.setAgeFilter(tab, null); else render(); + } else if (a === 'close-drawer') closeDrawer(); else if (a === 'cancel-settings') $('#board-settings').close(); else if (a === 'add-status') $('#status-rows').insertAdjacentHTML('beforeend', ``); diff --git a/static/index.html b/static/index.html index d053da4ac..00c6f2d19 100644 --- a/static/index.html +++ b/static/index.html @@ -5,7 +5,7 @@ GitHub 状态看板 - +
    @@ -48,8 +48,8 @@ - - - + + + diff --git a/static/style.css b/static/style.css index ae72c63b9..ce6a95d17 100644 --- a/static/style.css +++ b/static/style.css @@ -162,6 +162,25 @@ td .sub { color: var(--muted); font-size: 12px; } .stack span { display: block; height: 100%; min-width: 3px; border-radius: 3px; } .legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--ink-2); margin-top: 6px; } .legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; } +/* Open-age donut on the Issue / PR pages; clicking a sector or legend entry filters the list below. */ +.age-pie { display: flex; align-items: center; gap: 12px 22px; flex-wrap: wrap; } +.age-pie-svg { width: 160px; height: 160px; flex: none; } +.age-sector { cursor: pointer; stroke: var(--surface); stroke-width: 1.5; } +.age-sector:hover { filter: brightness(1.07); } +.age-sector:focus-visible { outline: 2px solid var(--link); outline-offset: 1px; } +.age-sector.on { stroke: var(--ink); stroke-width: 2.5; } +.age-total { fill: var(--ink); font-size: 24px; font-weight: 650; font-variant-numeric: tabular-nums; } +.age-total-sub { fill: var(--muted); font-size: 11px; } +.age-legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; min-width: 190px; } +.age-key { display: flex; align-items: center; gap: 7px; width: 100%; font: inherit; font-size: 12.5px; color: var(--ink-2); padding: 3px 10px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); } +button.age-key { cursor: pointer; } +button.age-key:hover { filter: brightness(0.97); } +.age-key.on { border-color: var(--s1); box-shadow: inset 0 0 0 1px var(--s1); color: var(--ink); } +.age-key.zero { opacity: .55; } +.age-key i { width: 10px; height: 10px; border-radius: 2px; flex: none; } +.age-key .age-name { flex: 1 1 auto; text-align: left; } +.age-key b { font-variant-numeric: tabular-nums; } +.age-key .muted { font-variant-numeric: tabular-nums; } /* CI history lanes. Every row repeats one grid template, so day columns line up across lanes; a day's runs stack top-down and scroll inside that column. */ /* Tagged tests: dimension × profile matrix. */ diff --git a/test/journey-board.spec.cjs b/test/journey-board.spec.cjs index acd6dc691..b303d8f29 100644 --- a/test/journey-board.spec.cjs +++ b/test/journey-board.spec.cjs @@ -44,7 +44,7 @@ test('all existing pages render; only static requests and no promotional copy', test('issue distribution and filters; PR reviews and current commit checks', async ({page})=>{ await page.goto('/github-status-board/#issues'); - await expect(page.locator('#tab-issues')).toContainText('年龄分布'); + await expect(page.locator('#tab-issues')).toContainText('开启时长'); await expect(page.locator('#tab-issues')).toContainText(''); expect(await page.locator('#tab-issues script').count()).toBe(0); await page.locator('[data-filter="issueQ"]').fill('不存在'); @@ -66,7 +66,7 @@ test('board grouping, drawer edits and reload retain browser fields', async ({pa await showBoard(page, 'issues'); // Each page shows only its own kind; statistics stay above the list. await expect(page.locator('#tab-issues .bcard')).toHaveCount(1); - await expect(page.locator('#tab-issues')).toContainText('年龄分布'); + await expect(page.locator('#tab-issues')).toContainText('开启时长'); await page.locator('[data-open="issue:1"]').click(); await expect(page.locator('#drawer')).toContainText('Reproduction'); await expect(page.locator('#drawer')).toContainText(''); @@ -99,6 +99,138 @@ test('board grouping, drawer edits and reload retain browser fields', async ({pa await expect(page.locator('#tab-issues .work-table')).toContainText('处理超时问题'); }); +// Open-age pie: bucket counts come from the page's own open items, and clicking a sector +// or legend entry filters only that page's open list. Boundary ages arrive via a patched +// snapshot so the shared fixture keeps its single issue and PR. +const withAgedSnapshot = async (page) => { + await page.route('**/github-status-board/data/snapshot.json*', async (route) => { + const res = await route.fetch(); + const doc = await res.json(); + const t = '2026-09-20T12:00:00Z', base = doc.repo_url; + const mkIssue = (number, age_days) => ({ + number, title: age_days == null ? '样本 无年龄' : `样本 ${age_days}d`, url: `${base}/issues/${number}`, + state: 'open', author: 'dev', labels: [], assignees: [], milestone: null, comments: 0, + created_at: t, updated_at: t, closed_at: null, age_days, idle_days: age_days, reactions: 0, body: '', + }); + const mkPr = (number, age_days) => ({ + number, title: `PR 样本 ${age_days}d`, url: `${base}/pull/${number}`, state: 'open', author: 'dev', + labels: [], assignees: [], milestone: null, comments: 0, created_at: t, updated_at: t, closed_at: null, + age_days, idle_days: age_days, draft: false, review_decision: 'NONE', reviews: [], requested_reviewers: [], + waiting_review: false, mergeable: 'MERGEABLE', ci: { state: 'success', checks: [] }, head: `aged-${number}`, base: 'main', + linked_issues: [], head_sha: 'b'.repeat(40), body: '', + }); + const ages = [0, 6.9, 7, 29.9, 30, 89.9, 90, 365, null]; + const issues = doc.sections.issues.data, prs = doc.sections.prs.data; + ages.forEach((age, i) => issues.items.push(mkIssue(100 + i, age))); + issues.open_count = issues.items.length; + prs.items.push(mkPr(50, 45), mkPr(51, 100)); + prs.open_count = prs.items.length; + prs.recent_merged = [{ number: 99, title: '早前合并的 PR', url: `${base}/pull/99`, author: 'dev', merged_at: t, time_to_merge_h: 5 }]; + const boardItem = (kind, x) => ({ + id: `${kind}:${x.number}`, kind, number: x.number, title: x.title, url: x.url, state: 'open', author: x.author, + labels: [], assignees: [], milestone: null, comments: 0, created_at: t, updated_at: t, closed_at: null, + age_days: x.age_days, idle_days: x.age_days, linked: [], draft: false, review_decision: null, ci_state: null, merged: false, + ...(kind === 'pr' ? { head: x.head, base: x.base } : {}), + }); + ages.forEach((age, i) => doc.board.items.push(boardItem('issue', mkIssue(100 + i, age)))); + [mkPr(50, 45), mkPr(51, 100)].forEach((p) => doc.board.items.push(boardItem('pr', p))); + await route.fulfill({ response: res, body: JSON.stringify(doc) }); + }); +}; + +test('open-age pie filters only the open list on its own page', async ({page}) => { + await withAgedSnapshot(page); + await page.goto('/github-status-board/#issues'); + const pie = page.locator('#tab-issues [data-age-pie="issues"]'); + await expect(pie).toBeVisible(); + const key = (name) => pie.locator('.age-key', {hasText: name}); + const rows = page.locator('#tab-issues .work-table tbody tr'); + // Buckets follow the collector boundaries [0,7) [7,30) [30,90) [90,∞); the item without + // age_days stays in the list but is not counted in any sector. + await expect(rows).toHaveCount(10); + await expect(pie.locator('.age-total')).toHaveText('9'); + await expect(pie.locator('.age-sector')).toHaveCount(4); + await expect(key('<7d')).toContainText('3'); + await expect(key('<7d')).toContainText('33.3%'); + await expect(key('7-30d')).toContainText('2'); + await expect(key('7-30d')).toContainText('22.2%'); + await expect(key('30-90d')).toContainText('22.2%'); + await expect(key('>90d')).toContainText('22.2%'); + // Legend click filters the open Issue table; side cards keep their own rows. + await key('7-30d').click(); + await expect(rows).toHaveCount(2); + await expect(page.locator('#tab-issues .list-head')).toContainText('2 / 10'); + await expect(page.locator('#tab-issues .work-table')).toContainText('样本 7d'); + await expect(page.locator('#tab-issues .work-table')).toContainText('样本 29.9d'); + await expect(page.locator('#tab-issues .work-table')).not.toContainText('样本 0d'); + await expect(page.locator('#tab-issues .work-table')).not.toContainText('样本 无年龄'); + await expect(key('7-30d')).toHaveAttribute('aria-pressed', 'true'); + await expect(pie.locator('.age-sector[data-age-filter="1"]')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('#tab-issues .filters .chip')).toContainText('开启时长:7-30d'); + await expect(page.locator('#tab-issues .grid.wide')).toContainText('处理超时问题'); + await page.screenshot({path:shot('issue-age-pie'),fullPage:true}); + // The age bucket intersects with the text search; an empty result keeps the pie. + await page.locator('[data-filter="issueQ"]').fill('29.9'); + await expect(rows).toHaveCount(1); + await expect(page.locator('#tab-issues .work-table')).toContainText('样本 29.9d'); + await page.locator('[data-filter="issueQ"]').fill('不存在'); + await expect(rows).toHaveCount(0); + await expect(page.locator('#tab-issues .work-table')).toContainText('没有符合筛选条件的开放 Issue'); + await expect(pie).toBeVisible(); + await page.locator('[data-filter="issueQ"]').fill(''); + // Clicking the selected legend entry again clears the filter. + await key('7-30d').click(); + await expect(rows).toHaveCount(10); + await expect(page.locator('#tab-issues .filters .chip')).toHaveCount(0); + await expect(key('7-30d')).toHaveAttribute('aria-pressed', 'false'); + // A sector click works too, and the chip clears it. + await pie.locator('.age-sector[data-age-filter="3"]').click(); + await expect(rows).toHaveCount(2); + await expect(page.locator('#tab-issues .work-table')).toContainText('样本 90d'); + await expect(page.locator('#tab-issues .work-table')).toContainText('样本 365d'); + await page.locator('#tab-issues .filters .chip button').click(); + await expect(rows).toHaveCount(10); + // Sectors are keyboard-operable controls. + await pie.locator('.age-sector[data-age-filter="2"]').focus(); + await page.keyboard.press('Enter'); + await expect(rows).toHaveCount(2); + await expect(page.locator('#tab-issues .work-table')).toContainText('样本 30d'); + // The PR page keeps its own pie and selection; zero-count buckets are not clickable. + await page.locator('[data-tab="prs"]').click(); + const pPie = page.locator('#tab-prs [data-age-pie="prs"]'); + const pRows = page.locator('#tab-prs .work-table tbody tr'); + await expect(pRows).toHaveCount(3); + await expect(pPie.locator('.age-total')).toHaveText('3'); + await expect(pPie.locator('.age-sector')).toHaveCount(3); + await expect(pPie.locator('button.age-key')).toHaveCount(3); + await expect(pPie.locator('span.age-key.zero')).toHaveCount(1); + await expect(pPie.locator('span.age-key.zero')).toContainText('7-30d'); + await pPie.locator('button.age-key', {hasText: '>90d'}).click(); + await expect(pRows).toHaveCount(1); + await expect(page.locator('#tab-prs .work-table')).toContainText('PR 样本 100d'); + await expect(page.locator('#tab-prs .filters .chip')).toContainText('开启时长:>90d'); + // Recently merged PRs are not touched by the open-list filter. + await expect(page.locator('#tab-prs')).toContainText('早前合并的 PR'); + // Back on Issues the earlier selection still holds; the PR choice did not follow. + await page.locator('[data-tab="issues"]').click(); + await expect(rows).toHaveCount(2); + await expect(page.locator('#tab-issues .work-table')).toContainText('样本 89.9d'); + await expect(key('30-90d')).toHaveAttribute('aria-pressed', 'true'); + await expect(key('>90d')).toHaveAttribute('aria-pressed', 'false'); + // The board view of the same page reads the very same filter. + await showBoard(page, 'issues'); + await expect(page.locator('#tab-issues .bcard')).toHaveCount(2); + await expect(page.locator('#tab-issues .chips .chip')).toContainText('开启时长:30-90d'); + await page.locator('#tab-issues .chips .chip button').click(); + await expect(page.locator('#tab-issues .bcard')).toHaveCount(10); + await page.locator('[data-tab="prs"]').click(); + await showBoard(page, 'prs'); + await expect(page.locator('#tab-prs .bcard')).toHaveCount(1); + await expect(page.locator('#tab-prs .bcard')).toContainText('PR 样本 100d'); + await page.locator('#tab-prs .chips .chip button').click(); + await expect(page.locator('#tab-prs .bcard')).toHaveCount(3); +}); + test('drag a card, configure renamed column and WIP, preserve manual status',async({page})=>{ await page.goto('/github-status-board/#issues'); await showBoard(page, 'issues');