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
4 changes: 2 additions & 2 deletions 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 天;定时刷新数据时保留。

**开启时长**饼图与开放列表标题、切换及筛选条同一行(窄屏上下堆叠),四桶使用分类色 `--s2`、`--s4`、`--s7`、`--s3`:按创建时间把当前开放项分进 `<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 页去掉;开放 Issue 表默认按更新时间 `updated_at` 从新到旧,缺失时间排最后,用户可点表头改排序,刷新页面恢复默认。PR 的“最近合并”仍不受开放时长筛选。Issue 页用这张图代替原来的“年龄分布”条形图。

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

Expand Down Expand Up @@ -91,7 +91,7 @@ node .e2e/node_modules/playwright/cli.js test --config test/playwright.config.cj

- 总览的问题排序与数据缺失时显示“状态未知”;
- Issue / PR 的表格 / 看板切换、按类型隔离、本地字段与导入导出;
- Issue / PR 开启时长饼图:桶边界与数量、扇区/图例点击与再点取消、与文本搜索取交、两页选择互不影响、看板视图同一筛选、空结果与旁路表不受影响;
- Issue / PR 开启时长饼图:桶边界与数量、扇区/图例点击与再点取消、与文本搜索取交、两页选择互不影响、看板视图同一筛选、空结果、Issue 更新时间默认降序与 PR 最近合并不受影响;
- Issue / PR 每日趋势:默认 30 天、预设与自定义范围、刷新后保留范围、数据开关、悬停与方向键查看、整数刻度、按日表格,以及 390px 下不溢出;
- 看板列独立滚动与刷新后保留位置;
- 覆盖率目录树的展开、收起、过滤、按覆盖率排序,以及无逐文件数据时按分组展示;
Expand Down
13 changes: 0 additions & 13 deletions static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -637,24 +637,11 @@
{ label: '中位年龄', value: days(d.median_age_days), sub: `最老 ${days(d.oldest_age_days)}` },
]);
html += trendSection('issues');
const issueRow = (i) => `<span class="title">${link(i.url, `#${i.number} ${esc(i.title)}`)}</span><span class="sub">${esc(i.author)} · ${labelChips(i.labels)}</span>`;
html += `<div class="grid">
${card('标签分布', bars(d.labels.map((l) => ({ label: l.name, labelHtml: `<span class="label-chip"><span class="sw" style="background:#${esc(l.color || '999')}"></span>${esc(l.name)}</span>`, value: l.count })), { emptyText: '开放 Issue 都没有标签' }) + (d.unused_labels.length ? `<div class="muted" style="margin-top:8px">仓库另有 ${d.unused_labels.length} 个标签未用于任何开放 Issue:${esc(d.unused_labels.join('、'))}</div>` : ''), { sub: '按开放 Issue 计数' })}
${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 }))))}
</div>`;
html += `<div class="grid wide" style="margin-top:12px">
${card('最近更新', table('iss-recent', [
{ key: 'number', label: 'Issue', render: issueRow, sortable: false },
{ key: 'updated_at', label: '更新', render: (i) => ago(i.updated_at) },
{ key: 'comments', label: '评论', num: true },
], d.recent, { limit: 10 }))}
${card('最久未动', table('iss-stale', [
{ key: 'number', label: 'Issue', render: issueRow, sortable: false },
{ key: 'idle_days', label: '闲置', num: true, render: (i) => days(i.idle_days) },
{ key: 'age_days', label: '年龄', num: true, render: (i) => days(i.age_days) },
], (d.stale.length ? d.stale : d.oldest), { limit: 10, emptyText: '没有陈旧 Issue' }), { sub: d.stale.length ? `≥${d.stale_days_threshold} 天无更新` : '暂无陈旧 Issue,显示最老的开放 Issue' })}
</div>`;
// Drill-down list with filters.
const f = STATE.filters;
const labels = [...new Set(d.items.flatMap((i) => i.labels.map((l) => l.name)))].sort();
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.4">
<link rel="stylesheet" href="./style.css?v=20261015.5">
</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.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>
<script src="./board-local.js?v=20261015.5"></script><script src="./app.js?v=20261015.5"></script>
<script src="./board.js?v=20261015.5"></script>
<script src="./gitcode-sync.js?v=20261015.5"></script>
</body>
</html>
49 changes: 47 additions & 2 deletions test/journey-board.spec.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,49 @@ test('issue distribution and filters; PR reviews and current commit checks', asy
await expect(page.locator('#tab-prs .work-table details')).toContainText('E2E');
});

test('open Issue table defaults to newest updates and resets sorting on reload', async ({page}) => {
await page.route('**/github-status-board/data/snapshot.json*', async route => {
const response = await route.fetch(), doc = await response.json();
const issues = doc.sections.issues.data, original = issues.items[0];
const item = (number, title, updated_at) => ({...original, number, title, updated_at,
url: `${doc.repo_url}/issues/${number}`});
const missing = item(31, '缺失更新时间', undefined);
// Keep the shared Issue untouched and deliberately return an unsorted list.
issues.items = [item(30, '空更新时间', null), item(10, '较早更新的 Issue', '2026-09-21T10:00:00Z'),
original, item(20, '较新更新的 Issue', '2026-09-22T10:00:00Z'), missing];
issues.open_count = issues.items.length;
await route.fulfill({response, json: doc});
});
await page.goto('/github-status-board/#issues');
const tab = page.locator('#tab-issues');
const table = tab.locator('.work-table');
const numbers = table.locator('tbody tr td:first-child');
const updateHead = table.locator('th[data-key="updated_at"]');
const defaultOrder = ['#20', '#10', '#1', '#30', '#31'];
await expect(numbers).toHaveText(defaultOrder);
await expect(table.locator('tbody tr').nth(0)).toContainText('较新更新的 Issue');
await expect(table.locator('tbody tr').nth(1)).toContainText('较早更新的 Issue');
await expect(updateHead).toHaveText('更新 ▼');
await expect(tab.getByRole('heading', {name: /^最近更新/})).toHaveCount(0);
await expect(tab.getByRole('heading', {name: /^最久未动/})).toHaveCount(0);
await expect(tab.locator('.tiles')).toContainText('陈旧(≥30 天无更新)');
await expect(tab.getByRole('heading', {name: /^标签分布/})).toBeVisible();
await expect(tab.getByRole('heading', {name: /^认领负载/})).toBeVisible();
await expect(tab.getByRole('heading', {name: '里程碑', exact: true})).toBeVisible();
await table.locator('th[data-key="number"]').click();
await expect(numbers).toHaveText(['#31', '#30', '#20', '#10', '#1']);
await expect(updateHead).toHaveText('更新');
await page.reload();
await expect(numbers).toHaveText(defaultOrder);
await expect(updateHead).toHaveText('更新 ▼');
await page.screenshot({path: shot('issue-list-updated-desktop'), fullPage: true});
await page.setViewportSize({width: 390, height: 1000});
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({path: shot('issue-list-updated-narrow'), fullPage: true});
await page.locator('[data-tab="prs"]').click();
await expect(page.locator('#tab-prs').getByRole('heading', {name: '最近合并', exact: true})).toBeVisible();
});

// Issue and PR lists switch between the table and the local board.
const showBoard = async (page, tab) => { await page.locator(`[data-work-view="${tab}"] [data-val="board"]`).click(); await expect(page.locator(`#tab-${tab} .work-board .board`)).toBeVisible(); };
test('board grouping, drawer edits and reload retain browser fields', async ({page})=>{
Expand Down Expand Up @@ -188,7 +231,7 @@ test('open-age pie filters only the open list on its own page', async ({page}) =
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.
// Legend click filters the open Issue table; summary counts remain unchanged.
await stableClick('issues', key('7-30d'));
await expect(rows).toHaveCount(2);
await expect(page.locator('#tab-issues .list-head')).toContainText('2 / 10');
Expand All @@ -199,7 +242,9 @@ test('open-age pie filters only the open list on its own page', async ({page}) =
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 .age-filter-slot .chip')).toContainText('开启时长:7-30d');
await expect(page.locator('#tab-issues .grid.wide')).toContainText('处理超时问题');
await expect(page.locator('#tab-issues').getByRole('heading', {name: /^最近更新/})).toHaveCount(0);
await expect(page.locator('#tab-issues').getByRole('heading', {name: /^最久未动/})).toHaveCount(0);
await expect(page.locator('#tab-issues .tiles .tile').filter({hasText: /^开放/})).toContainText('10');
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');
Expand Down
Loading