Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/dashboard-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ CI、PR、Issue 数据不可读取时显示“状态未知”,不会显示为
- **口径**:按 UTC 日期统计。存量按每个 Issue/PR 当前的创建、关闭时间回推,关闭后又重开的从创建起都算开放。历史尚未回填完时,说明里会提示较早日期可能偏少。
- 范围和开关只保存在当前页面,重新打开回到最近 30 天;定时刷新数据时保留。

列表正上方是**开启时长**饼图:按创建时间把当前开放项分进 `<7d`、`7-30d`、`30-90d`、`>90d` 四桶(边界与采集器的年龄统计一致),图例同时给出数量和占比;`age_days` 缺失的项不计入,数量为 0 的桶只在图例里列出、不可点。点击扇区或图例只筛选本页下方的开放列表(表格和看板都吃同一个筛选),再点一次选中的扇区取消;筛选与搜索、标签、认领人等条件同时生效(取交集),Issue 与 PR 的选择互不影响。筛选生效时筛选条出现当前时长桶,可点 × 清除;结果为 0 时列表显示空状态,饼图保持可见。“最近更新”“最久未动”“最近合并”等旁路表不受这个筛选影响。Issue 页用这张图代替原来的“年龄分布”条形图。
**开启时长**饼图与开放列表标题、切换及筛选条同一行(窄屏上下堆叠),四桶使用分类色 `--s2`、`--s4`、`--s7`、`--s3`:按创建时间把当前开放项分进 `<7d`、`7-30d`、`30-90d`、`>90d` 四桶(边界与采集器的年龄统计一致),图例同时给出数量和占比;`age_days` 缺失的项不计入,数量为 0 的桶只在图例里列出、不可点。点击扇区或图例只筛选本页下方的开放列表(表格和看板都吃同一个筛选),再点一次选中的扇区取消;筛选与搜索、标签、认领人等条件同时生效(取交集),Issue 与 PR 的选择互不影响。筛选生效时筛选条出现当前时长桶,可点 × 清除;结果为 0 时列表显示空状态,饼图保持可见。“最近更新”“最久未动”“最近合并”等旁路表不受这个筛选影响。Issue 页用这张图代替原来的“年龄分布”条形图。

数据由看板仓的采集在 Actions 中算好写进快照,不读 Bot 的数据库,计算方式见[增量同步与历史](incremental-history.md)。

Expand Down
46 changes: 29 additions & 17 deletions static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@
// 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 AGE_BUCKET_COLOR = ['s2', 's4', 's7', 's3'];
const ageBucketOf = (age) => {
if (age == null || Number.isNaN(Number(age))) return -1;
return AGE_BUCKETS.findIndex(([, lo, hi]) => age >= lo && (hi == null || age < hi));
Expand Down Expand Up @@ -367,8 +367,7 @@
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(`<path class="age-sector${on ? ' on' : ''}" d="${d}" fill="var(--${AGE_BUCKET_COLOR[k]})"${offset} role="button" tabindex="0" data-age-tab="${tab}" data-age-filter="${k}" aria-pressed="${on}" aria-label="筛选开启时长 ${esc(AGE_BUCKETS[k][0])} 的${unit}:${n(count)} 个,占 ${share.toFixed(1)}%"${tip(`${AGE_BUCKETS[k][0]}:${n(count)} 个(${share.toFixed(1)}%)`)}></path>`);
sectors.push(`<path class="age-sector${on ? ' on' : ''}" d="${d}" fill="var(--${AGE_BUCKET_COLOR[k]})" role="button" tabindex="0" data-age-tab="${tab}" data-age-filter="${k}" aria-pressed="${on}" aria-label="筛选开启时长 ${esc(AGE_BUCKETS[k][0])} 的${unit}:${n(count)} 个,占 ${share.toFixed(1)}%"${tip(`${AGE_BUCKETS[k][0]}:${n(count)} 个(${share.toFixed(1)}%)`)}></path>`);
});
const center = `<text class="age-total" x="${cx}" y="${cy + 1}" text-anchor="middle">${n(total)}</text><text class="age-total-sub" x="${cx}" y="${cy + 18}" text-anchor="middle">开放${unit}</text>`;
const legend = AGE_BUCKETS.map(([label], k) => {
Expand All @@ -383,6 +382,23 @@
const sel = STATE.ageFilter[tab];
return sel == null ? '' : `<span class="chip">开启时长:${esc(AGE_BUCKETS[sel][0])} <button type="button" data-age-tab="${tab}" data-age-clear aria-label="清除开启时长筛选">×</button></span>`;
};
const openListHeader = (tab, items, unit, sub, filters = '') => `<div class="card open-list-header">
<div class="age-overview"><h3>开启时长</h3><p class="sub">点击扇区或图例筛选,再次点击取消</p>${agePie(tab, items, unit)}</div>
<div class="open-list-controls">${listHead(tab, `开放 ${unit}`, sub)}
${STATE.views[tab] === 'board' ? '<div class="open-board-controls"></div>' : filters}
<div class="age-filter-slot">${ageChip(tab)}</div>
</div>
</div>`;
function setAgeFilter(tab, value, focusBucket) {
const { scrollX, scrollY } = window, panel = $(`#tab-${tab}`);
// Shorter results must still leave enough document height to restore this viewport.
const top = panel.getBoundingClientRect().top + scrollY;
panel.style.minHeight = `${Math.max(0, scrollY + window.innerHeight - top)}px`;
STATE.ageFilter[tab] = value;
renderTabs();
if (focusBucket != null) document.querySelector(`.age-key[data-age-tab="${tab}"][data-age-filter="${focusBucket}"]`)?.focus({ preventScroll: true });
window.scrollTo({ left: scrollX, top: scrollY, behavior: 'instant' });
}
// 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)])));
Expand Down Expand Up @@ -651,15 +667,14 @@
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 += `<div class="card work-table"><div class="filters">
const filters = `<div class="filters">
<input type="text" data-filter="issueQ" placeholder="搜索编号 / 标题 / 作者" value="${esc(f.issueQ)}">
<select data-filter="issueLabel"><option value="">全部标签</option><option value="__none__" ${f.issueLabel === '__none__' ? 'selected' : ''}>无标签</option>${labels.map((l) => `<option ${f.issueLabel === l ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select>
<select data-filter="issueAssignee"><option value="">全部认领人</option><option value="__none__" ${f.issueAssignee === '__none__' ? 'selected' : ''}>无人认领</option>${assignees.map((a) => `<option ${f.issueAssignee === a ? 'selected' : ''}>${esc(a)}</option>`).join('')}</select>
${ageChip('issues')}
</div>${table('iss-all', [
</div>`;
html += openListHeader('issues', d.items, 'Issue', STATE.views.issues === 'board' ? `看板含最近关闭的 Issue${preview}` : `${rows.length} / ${d.items.length}${preview}`, filters);
if (STATE.views.issues === 'board') return html + boardMount('issue');
html += `<div class="card work-table">${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))}<div class="sub">${labelChips(i.labels)}${i.milestone ? ` · ${esc(i.milestone)}` : ''}</div>` },
{ key: 'author', label: '作者' },
Expand Down Expand Up @@ -703,10 +718,9 @@
</div>`;
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} 个`);
html += openListHeader('prs', d.items, 'PR', STATE.views.prs === 'board' ? '看板含最近关闭与合并的 PR' : `${rows.length} / ${d.items.length} 个`);
if (STATE.views.prs === 'board') html += boardMount('pr');
else html += `<div class="card work-table">${STATE.ageFilter.prs != null ? `<div class="filters">${ageChip('prs')}</div>` : ''}${table('pr-open', [
else html += `<div class="card work-table">${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))}<div class="sub">${esc(p.author)} · <code>${esc(p.head)}</code> → <code>${esc(p.base)}</code>${p.labels.length ? ' · ' + labelChips(p.labels) : ''}</div>` },
{ key: 'age_days', label: '年龄', num: true, render: (p) => days(p.age_days) },
Expand Down Expand Up @@ -1582,10 +1596,8 @@
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();
const value = prev === next ? null : next;
setAgeFilter(tab, value, value ?? prev);
return;
}
const lineButton = ev.target.closest('[data-line]');
Expand Down Expand Up @@ -1694,7 +1706,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, ageBucketOf, AGE_BUCKETS, ageFilter:()=>STATE.ageFilter, setAgeFilter:(tab,value)=>{ STATE.ageFilter[tab] = value; renderTabs(); } };
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 };
STATE.tab = location.hash.slice(1) || 'overview';
load();
})();
9 changes: 4 additions & 5 deletions static/board.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,6 @@
const d = B.data, f = B.prefs.filters, p = B.prefs;
const sel = (name, list, cur, allLabel, noneLabel) => `<select data-bfilter="${name}"><option value="">${allLabel}</option>${noneLabel ? `<option value="${NONE}"${cur === NONE ? ' selected' : ''}>${noneLabel}</option>` : ''}${list.map((x) => { const [v, l] = Array.isArray(x) ? x : [x, x]; return `<option value="${esc(v)}"${v === cur ? ' selected' : ''}>${esc(l)}</option>`; }).join('')}</select>`;
const chips = Object.entries(f).filter(([k, v]) => v).map(([k, v]) => `<span class="chip">${esc({ q: '搜索', state: '状态', label: '标签', assignee: '负责人', author: '作者', priority: '优先级', iteration: '迭代', milestone: '里程碑' }[k])}: ${esc(v === NONE ? '无' : v)} <button data-bclear="${k}">×</button></span>`).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 ? '' : `<span class="chip">开启时长:${esc(H.AGE_BUCKETS[ageSel][0])} <button type="button" data-age-tab="${B.scope === 'issue' ? 'issues' : 'prs'}" data-age-clear aria-label="清除开启时长筛选">×</button></span>`;
const summary = p.group === 'status' ? columns(items).map((c) => `<span class="sum ${c.over ? 'over' : ''}" style="--col:${c.color ? COLOR_VAR(c.color) : 'var(--axis)'}"><i></i>${esc(c.label)} <b>${c.items.length}</b>${c.limit ? `<span class="muted">/${c.limit}</span>` : ''}</span>`).join('') : '';
return `<div class="board-toolbar">
<div class="seg" data-bpref="group"><span class="seg-label">分组</span>${Object.entries(GROUPS).map(([k, l]) => `<button class="${p.group === k ? 'on' : ''}" data-val="${k}">${l}</button>`).join('')}</div>
Expand All @@ -163,7 +159,7 @@
${sel('iteration', d.facets.iterations, f.iteration, '全部迭代', '未设置')}${sel('milestone', d.facets.milestones, f.milestone, '全部里程碑', '无里程碑')}
<span class="muted small">${items.length} / ${d.items.filter((i) => i.kind === B.scope).length} 张卡</span>
</div>
${chips || ageChip ? `<div class="chips">${ageChip}${chips}<button class="btn small" data-baction="clear-filters">清除全部</button></div>` : ''}
${chips ? `<div class="chips">${chips}<button class="btn small" data-baction="clear-filters">清除全部</button></div>` : ''}
${summary ? `<div class="board-summary">${summary}<span class="muted small">规则:加入→${esc(d.rules.default_status)} · 指派→${esc(d.rules.doing_status)} · 关联 PR→${esc(d.rules.review_status)} · 关闭/合并→${esc(d.rules.done_status)}</span></div>` : ''}
<div id="board-flash" class="banner error hidden"></div>`;
}
Expand All @@ -190,6 +186,9 @@
if (B.error) html += `<div class="banner warn"><span class="icon">▲</span><div>${esc(B.error)}</div></div>`;
if (B.historyOpen) html += historyHtml();
html += '</div>';
// The page owns the shared chart/list header; keep the board itself full width.
const controls = $(`#tab-${SCOPES[B.scope].tab} .open-board-controls`);
if (controls) { controls.innerHTML = html; html = ''; }
const cols = columns(items);
html += `<div class="board ${B.prefs.equal ? 'equal' : ''}">${cols.map((c) => columnHtml(c, B.prefs.group)).join('')}</div>`;
html += `<div class="muted small board-foot">数据:快照 ${ago(B.data.snapshot_generated_at)} 的 ${SCOPES[B.scope].name} + 本地字段(${B.scope === 'pr' ? B.data.counts.prs : B.data.counts.issues} 个,含最近 ${esc(String(B.data.rules.closed_window_days))} 天关闭的)。拖拽只改本地字段,不写回 GitHub。</div>`;
Expand Down
8 changes: 4 additions & 4 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GitHub 状态看板</title>
<!-- Keep the UI bundle on one cache version so GitHub Pages cannot mix old CSS with new JS. -->
<link rel="stylesheet" href="./style.css?v=20261015.3">
<link rel="stylesheet" href="./style.css?v=20261015.4">
</head>
<body>
<header class="topbar">
Expand Down Expand Up @@ -48,8 +48,8 @@
<aside id="drawer" class="drawer hidden" aria-label="卡片详情"></aside>
<dialog id="board-settings" class="settings"></dialog>
<div id="tooltip" class="tooltip hidden"></div>
<script src="./board-local.js?v=20261015.3"></script><script src="./app.js?v=20261015.3"></script>
<script src="./board.js?v=20261015.3"></script>
<script src="./gitcode-sync.js?v=20261015.3"></script>
<script src="./board-local.js?v=20261015.4"></script><script src="./app.js?v=20261015.4"></script>
<script src="./board.js?v=20261015.4"></script>
<script src="./gitcode-sync.js?v=20261015.4"></script>
</body>
</html>
19 changes: 17 additions & 2 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,19 @@ td .sub { color: var(--muted); font-size: 12px; }
.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; }
.open-list-header { display: grid; grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); gap: 24px; margin: 12px 0; align-items: start; }
.age-overview, .open-list-controls { min-width: 0; }
.age-overview > .sub { color: var(--muted); font-size: 12px; margin: 0 0 8px; }
.open-list-controls .list-head { margin: 0 0 12px; }
.open-list-controls .filters { margin: 0 0 8px; }
.open-list-controls input, .open-list-controls select { max-width: 100%; }
.age-filter-slot { min-height: 30px; display: flex; align-items: center; }
.age-pie { display: flex; align-items: center; gap: 12px; }
.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-sector.on { stroke: #0b0b0b; stroke-width: 3; }
.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; }
Expand All @@ -181,6 +188,14 @@ button.age-key:hover { filter: brightness(0.97); }
.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; }
@media (max-width: 1000px) {
.open-list-header { grid-template-columns: minmax(0, 1fr); gap: 16px; }
}
@media (max-width: 500px) {
.age-pie-svg { width: 120px; height: 120px; }
.age-legend { min-width: 0; flex: 1; }
.age-key { padding: 3px 7px; gap: 5px; }
}
/* 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. */
Expand Down
Loading
Loading