From 475917ea6c79d8c87892d7e679eb3c19cc92d8fc Mon Sep 17 00:00:00 2001
From: zhaozhaozz <6450679+zhaozhaozz@users.noreply.github.com>
Date: Thu, 15 Oct 2026 08:55:29 +0000
Subject: [PATCH] Filter open Issues and PRs by open age from a clickable pie
chart
---
docs/dashboard-pages.md | 3 +
static/app.js | 92 ++++++++++++++++++++++--
static/board.js | 16 ++++-
static/index.html | 8 +--
static/style.css | 19 +++++
test/journey-board.spec.cjs | 136 +++++++++++++++++++++++++++++++++++-
6 files changed, 259 insertions(+), 15 deletions(-)
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 ``;
+ };
+ 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 `
- ${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 @@
-
-
-
+
+
+