feat: 新增统计视图(ADR-012)
CI / 部署到服务器 (push) Successful in 28s
CI / verify (push) Successful in 1m55s

- 新增 /stats 路由与导航入口,与看板、今日视图平级
- 完成热力图:近一年 53 周,按每天完成数固定阈值分五档着色
- 汇总指标:今天 / 连续 / 本月 / 累计;连续天数允许「今天还没开始」
- 「时间花在哪」按标签分布(多标签任务每个标签各计一次)
- 「一些细节」:完成周期中位数 / 最长连续 / 有记录天数 / 平均每天
- 统计只从 completedAt 推算,不新增计数表,数字随删除偏少但永不自相矛盾

热力图的布局约束记在 ADR-012 结论 9:
- 月份标签绝对定位,避免 whitespace-nowrap 撑宽列而多出横向滚动条
- 富余宽度交给列间隙(justify-between),格子保持 11×11 正方
- 右侧补与星期标签列等宽的留白,使左右对称并给 ring 描边留出余量

新增 utils/activity.ts(纯函数)、views/StatsView.vue 及两个 spec、e2e/stats.spec.ts;
同步更新 README、docs/adr.md 与 AppNav 的三入口断言。
This commit is contained in:
chenshoulin
2026-10-25 01:14:19 +08:00
parent 61fa5515b8
commit 23fed50734
10 changed files with 1715 additions and 5 deletions
+144
View File
@@ -0,0 +1,144 @@
import { expect, test } from '@playwright/test'
import { addTask } from './helpers'
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
/** 点卡片左侧的勾选按钮标记完成 */
async function completeTask(page: Parameters<typeof addTask>[0], title: string): Promise<void> {
await page.locator('article').filter({ hasText: title }).locator('button').first().click()
}
/** 本地日期 YYYY-MM-DD,与热力图的口径一致 */
async function todayIso(page: Parameters<typeof addTask>[0]): Promise<string> {
return page.evaluate(() => {
const now = new Date()
const pad = (value: number) => String(value).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
})
}
test('统计页可从导航进入,汇总数字与热力图同步更新', async ({ page }) => {
await addTask(page, '统计任务 A')
await completeTask(page, '统计任务 A')
await addTask(page, '统计任务 B')
await completeTask(page, '统计任务 B')
await page.getByRole('link', { name: '统计' }).click()
await expect(page).toHaveURL(/\/stats$/)
await expect(page.getByRole('heading', { name: '统计' })).toBeVisible()
// 累计与「今天完成」都是 2
await expect(
page.locator('dl > div').filter({ hasText: '累计完成' }).locator('dd'),
).toContainText('2')
await expect(
page.locator('dl > div').filter({ hasText: '今天完成' }).locator('dd'),
).toContainText('2')
// 一年 53 周 × 7 天
await expect(page.locator('[data-role="heatmap-cell"]')).toHaveCount(53 * 7)
const today = await todayIso(page)
await expect(page.locator(`[data-role="heatmap-cell"][data-date="${today}"]`)).toHaveAttribute(
'data-count',
'2',
)
})
test('深链 /stats 可以直接打开,空数据时给出引导', async ({ page }) => {
await page.goto('/stats')
await expect(page.getByRole('heading', { name: '统计' })).toBeVisible()
await expect(page.getByText('还没有完成记录')).toBeVisible()
// 空数据也渲染完整网格,只是全部为最浅档
await expect(page.locator('[data-role="heatmap-cell"]')).toHaveCount(53 * 7)
})
test('回收站里的任务不计入统计', async ({ page }) => {
await addTask(page, '要删掉的任务')
await completeTask(page, '要删掉的任务')
// 删除入口在详情抽屉底部(卡片上只有完成勾选)
await page.locator('article').filter({ hasText: '要删掉的任务' }).click()
await page.getByRole('dialog').getByRole('button', { name: '删除' }).click()
await page.goto('/stats')
await expect(
page.locator('dl > div').filter({ hasText: '累计完成' }).locator('dd'),
).toContainText('0')
})
test('热力图左右留白对称,不产生横向滚动条也不裁切描边', async ({ page }) => {
await page.goto('/stats')
await page.waitForSelector('[data-role="heatmap-cell"]')
const metrics = await page.evaluate(() => {
const scroller = document.querySelector('[role="img"]') as HTMLElement
const cells = [...document.querySelectorAll('[data-role="heatmap-cell"]')]
const box = scroller.getBoundingClientRect()
const style = getComputedStyle(scroller)
const innerLeft = box.left + parseFloat(style.paddingLeft)
const innerRight = box.right - parseFloat(style.paddingRight)
const first = cells[0]!.getBoundingClientRect()
const last = cells[cells.length - 1]!.getBoundingClientRect()
// 今天那格带 ring-1:描边画在元素外侧 1px,贴边就会被 overflow 容器裁掉
const today = document.querySelector('[data-role="heatmap-cell"].ring-1')
return {
clientWidth: scroller.clientWidth,
scrollWidth: scroller.scrollWidth,
leftGap: Math.round(first.left - innerLeft),
rightGap: Math.round(innerRight - last.right),
ringOverflow: today
? Math.round(today.getBoundingClientRect().right + 1 - innerRight)
: null,
}
})
// 宽屏(e2e 默认 1280)下内容不溢出,也就没有滚动条
expect(metrics.scrollWidth).toBe(metrics.clientWidth)
// 左右留白相等:左侧是星期标签列(一 / 三 / 五)的宽度,右侧补了同样的宽度。
// 少了它右侧会一直贴到卡片边缘,既不对称也会把 ring 描边切掉一条。
expect(metrics.leftGap).toBeGreaterThan(0)
expect(metrics.rightGap).toBe(metrics.leftGap)
// ring 必须留有余量,不能顶到容器的裁剪边界
expect(metrics.ringOverflow).toBeLessThan(0)
})
test('标签分布与细节指标随完成情况更新', async ({ page }) => {
// 建一个带标签的任务,再从卡片上勾完成
await addTask(page, '带标签的活')
await page.locator('article').filter({ hasText: '带标签的活' }).click()
const tagInput = page.getByLabel('添加标签')
await tagInput.fill('设计')
await tagInput.press('Enter')
await page.getByRole('button', { name: '保存' }).click()
await completeTask(page, '带标签的活')
await page.getByRole('link', { name: '统计' }).click()
// 标签分布:一条记录算在该标签名下
const row = page.locator('[data-role="tag-bar"][data-tag="设计"]')
await expect(row).toHaveAttribute('data-count', '1')
await expect(row).toContainText('#设计')
// 细节卡片:只有今天这一天有记录,连续 1 天,平均每天 1.0 个
await expect(page.locator('dl > div').filter({ hasText: '有记录' }).locator('dd')).toHaveText(
'1 天',
)
await expect(page.locator('dl > div').filter({ hasText: '最长连续' }).locator('dd')).toHaveText(
'1 天',
)
await expect(page.locator('dl > div').filter({ hasText: '平均每天' }).locator('dd')).toHaveText(
'1.0 个',
)
// 刚建完就完成 → 周期不足 1 天,显示「当天」
await expect(
page.locator('dl > div').filter({ hasText: '平均完成周期' }).locator('dd'),
).toHaveText('当天')
})