Files
Kanban/e2e/stats.spec.ts
chenshoulin 23fed50734
CI / 部署到服务器 (push) Successful in 28s
CI / verify (push) Successful in 1m55s
feat: 新增统计视图(ADR-012)
- 新增 /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 的三入口断言。
2026-10-25 01:14:19 +08:00

145 lines
5.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('当天')
})