- 新增 /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 的三入口断言。
145 lines
5.8 KiB
TypeScript
145 lines
5.8 KiB
TypeScript
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('当天')
|
||
})
|