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
+25 -3
View File
@@ -13,6 +13,7 @@
- **标签管理**:全局重命名、合并、删除标签,不必逐个任务改 - **标签管理**:全局重命名、合并、删除标签,不必逐个任务改
- **标签颜色**:8 色色板,设过颜色的标签在卡片与筛选下拉里带一个圆点;重命名 / 合并时颜色自动跟随 - **标签颜色**:8 色色板,设过颜色的标签在卡片与筛选下拉里带一个圆点;重命名 / 合并时颜色自动跟随
- **今日视图**:按紧急度分组汇总(逾期 / 今天到期 / 进行中 / 待办),含本周完成等统计 - **今日视图**:按紧急度分组汇总(逾期 / 今天到期 / 进行中 / 待办),含本周完成等统计
- **统计视图**:近一年完成热力图(按当天完成数分五档着色)、连续完成天数、本月与累计完成,附带标签分布与完成周期
- 键盘快捷键;任务卡片可用方向键换列与调序 - 键盘快捷键;任务卡片可用方向键换列与调序
- **深色模式**:跟随系统 / 浅色 / 深色三态 - **深色模式**:跟随系统 / 浅色 / 深色三态
- 导出导入 JSON(**覆盖 / 合并**两种模式)+ 启动自动备份;覆盖或合并前都会自动留档 - 导出导入 JSON(**覆盖 / 合并**两种模式)+ 启动自动备份;覆盖或合并前都会自动留档
@@ -112,14 +113,15 @@ npm run dev
--- ---
## 两个视图 ## 三个视图
顶部导航可在两个视图之间切换: 顶部导航可在三个视图之间切换:
| 视图 | 路径 | 组织方式 | | 视图 | 路径 | 组织方式 |
| ---- | -------- | ----------------------------------------------- | | ---- | -------- | ----------------------------------------------- |
| 看板 | `/` | 按**状态**:待办 / 进行中 / 已完成 | | 看板 | `/` | 按**状态**:待办 / 进行中 / 已完成 |
| 今日 | `/today` | 按**紧急度**:已逾期 → 今天到期 → 进行中 → 待办 | | 今日 | `/today` | 按**紧急度**:已逾期 → 今天到期 → 进行中 → 待办 |
| 统计 | `/stats` | 按**时间**:近一年的每日完成量 |
今日视图顶部有四张统计卡片(逾期 / 今天到期 / 进行中 / 本周完成),下方是可直接勾选完成的待办列表。 今日视图顶部有四张统计卡片(逾期 / 今天到期 / 进行中 / 本周完成),下方是可直接勾选完成的待办列表。
@@ -127,6 +129,24 @@ npm run dev
只要任务未完成且未归档,就一定会出现在某个分组里 —— 包括没设截止日期的待办(归入「待办」组)。 只要任务未完成且未归档,就一定会出现在某个分组里 —— 包括没设截止日期的待办(归入「待办」组)。
### 统计视图
今日视图往前看(今天该做什么),统计视图往回看(这些天做过什么)。
- **顶部四张卡片**:今天完成 / 连续完成天数 / 本月完成 / 累计完成
- **完成热力图**:近一年(53 周),一列一周、一行一天,颜色分五档(0 / 1 / 2 / 3 / 4 个及以上)。悬停任意格子可看当天日期与完成数;今天那格带一圈描边
- **最忙的一天**:完成数最多的一天(并列时取最近的)
- **时间花在哪**:已完成任务按标签分布(前 6 项,条形长度按最大项归一)。一个任务带多个标签时每个标签各计一次,没有标签的归入「未分类」
- **一些细节**:平均完成周期(中位数)/ 最长连续 / 有记录天数 / 平均每天
「连续完成」允许今天还没开始:只要昨天有记录就继续算,不会在零点一过就把成绩清零。
平均完成周期取**中位数**而不是平均值:一条躺了半年的任务就能把平均值拉偏到没法看,中位数描述的是「通常要多久」。不足一天显示「当天」。
「平均每天」按**有记录的天数**算,不是按自然日 —— 否则周末不干活会被算成效率下降,而这只是没上班。
热力图只根据任务上的 `completedAt` 推算,**不额外维护计数表**。代价是永久删除或清空回收站会连同那天的记录一起消失,所以这里的数字通常比实际做过的偏少 —— 换来的是「数字永远和任务对得上」,不会出现恢复一条任务后计数却对不上的情况。
--- ---
## 安装与离线(PWA) ## 安装与离线(PWA)
@@ -358,7 +378,9 @@ PLAYWRIGHT_CHANNEL=chromium npm run test:e2e # 用自带 Chromium(需先 npx
- 「已完成」列固定按完成时间倒序,不支持手动调序(拖拽与方向键只能跨列)。 - 「已完成」列固定按完成时间倒序,不支持手动调序(拖拽与方向键只能跨列)。
- 归档阈值固定为「已完成超过 7 天」,暂不可配置。 - 归档阈值固定为「已完成超过 7 天」,暂不可配置。
- 今日视图的分组规则固定(逾期 > 今天到期 > 进行中 > 待办),不可自定义。 - 今日视图的分组规则固定(逾期 > 今天到期 > 进行中 > 待办),不可自定义。
- 统计只覆盖本周完成数等基础指标,没有趋势图或历史留存。 - 统计只根据任务上的完成时间推算,永久删除或清空回收站会带走相应记录,因此数字比实际偏少。
- 统计没有按优先级拆分,也不支持自定义时间范围。
- 统计的标签分布按「每个标签各计一次」汇总,因此各项之和会大于任务数(多标签任务对多个方向都算贡献)。
- 深色模式只跟随系统或手动三态切换,没有按时间段自动切换。 - 深色模式只跟随系统或手动三态切换,没有按时间段自动切换。
- 回收站固定保留 30 天,没有搜索 / 筛选,只有「按删除时间」一种顺序。 - 回收站固定保留 30 天,没有搜索 / 筛选,只有「按删除时间」一种顺序。
- 撤销栈只保留最近 50 步,且仅存在于当前会话与当前标签页:刷新或换标签页即清空,不跨设备同步;另一标签页改动数据后,本页的历史会作废。 - 撤销栈只保留最近 50 步,且仅存在于当前会话与当前标签页:刷新或换标签页即清空,不跨设备同步;另一标签页改动数据后,本页的历史会作废。
+93
View File
@@ -382,3 +382,96 @@ ADR-007 明确否决了「批量改属性做 `[{id, before}]` 快照式撤销」
- 标签圆点带 `data-role="tag-dot"`:卡片上本来就有别的圆点元素(状态点、勾选框), - 标签圆点带 `data-role="tag-dot"`:卡片上本来就有别的圆点元素(状态点、勾选框),
用 `.rounded-full` 这类样式选择器会误伤,单测与 e2e 一律按 `data-role` 定位。 用 `.rounded-full` 这类样式选择器会误伤,单测与 e2e 一律按 `data-role` 定位。
- 导出 JSON 不含颜色,README 的已知限制已写明。 - 导出 JSON 不含颜色,README 的已知限制已写明。
---
## ADR-012 · 统计视图:完成趋势与热力图
**背景**
README 的「已知限制」里长期挂着一条「统计只覆盖本周完成数等基础指标,没有趋势图或历史留存」。
而 `completedAt` 从 T03 起就在数据里,除了算「本周完成」和判断归档候选之外没有任何出口 ——
用户想知道「我这几周到底做了多少」,只能导出 JSON 自己数。
ADR-005 在引入路由时就写明:「若未来增加今日视图、统计等真正的独立页面,再引入路由」。
统计视图正是那句话里的第二个页面。
**结论**
1. **独立路由 `/stats`**,与今日视图平级。今日视图往前看(今天该做什么),统计往回看(做过什么),
两者的信息密度和使用时机都不同,塞在同一屏会互相稀释。按 ADR-005 的既有惯例懒加载。
2. **数据只从 `completedAt` 推算**,不引入「每日完成计数」表。按本地日期分组,
逐条走 `localDateOf` —— `completedAt` 存的是 UTC ISO,直接截前 10 位在东八区会把晚上完成的任务算到前一天。
3. **热力图 53 周(一年)**,列为一周、行为周一到周日(周一为一周之始,与 `utils/stats.ts` 口径一致)。
先用 26 周试过:只占卡片一半宽,右侧大片留白,且看不出季节性起伏。
4. **强度固定阈值分档**(0 / 1 / 2 / 3 / 4+)。按最大值相对分档会让数据一变多就所有格子一起变深,
反而看不出「哪天特别猛」;固定阈值可预测,也便于测试。
5. **月份标签只打在该月第一周**(周一落在 1–7 号)。最初用「月份变了就打」,结果 4 月与 5 月
这种相邻两周各打一个,而每列只有 14px 宽,两个标签直接叠在一起。每月一次后间隔必然 ≥ 4 列。
6. **连续完成天数允许「今天还没开始」**:今天没有记录就从昨天起算。否则每天零点一过 streak 归零,
等于在用户还没开始工作的时候先把成绩抹掉。
7. 回收站里的任务不计入(软删除的语义是「这件事不算了」),已归档的计入(归档只是隐藏)。
8. 没有完成记录时仍然渲染完整网格,只把脚注换成引导文案 —— 空白的热力图本身就在表达「还没有开始」。
9. **月份标签绝对定位**。53 列时容器 `clientWidth` 814px、`scrollWidth` 828px,凭空多出一条横向滚动条。
根因不是格子尺寸(11px + 3px 间距本是按 814px 排的),而是月份标签的 `whitespace-nowrap` 把所在列
从 11px 撑到 20px 以上 —— 流内文字参与了列宽计算。改为 `absolute` 定位后列宽恒定,原位置只留
`h-[14px]` 占位块保住行高,1280 / 1024 / 900 三种宽度下 `scrollWidth === clientWidth`。
**_2026-10-25 修正_**:当时用 `inline-flex w-fit mx-auto` 想让整块居中,但 `inline-flex` 是**行内级**
元素,auto margin 在水平方向按 0 处理,居中根本没生效 —— 实测 814 的容器里左边贴 15px、右边空 60px,
整块偏左,右侧一大块空白。
改为让周列容器 `flex-1` 占满剩余宽度、**`justify-between` 把富余空间均分到 52 个间隙**:
宽屏时间隙从 3px 涨到 4.15px,格子保持 11×11 正方。
窄屏用 `minWidth: weeks × 14 − 3`(53 列 = 739px)兜住 3px 最小间隙,装不下就横向滚动 ——
格子尺寸在任何宽度下都不变,只是间隙变化。
**_同日二次修正_**:铺满后最右列顶死在容器右边界(`rightGap = 0`),把今天那格的 `ring-1` 切掉一条 ——
`ring` 是画在元素外侧的 box-shadow,超出 `overflow-x-auto` 边界即被裁(实测超出 `innerRight` 恰好 1px)。
给内容补 `pr-[15px]`,即左侧星期标签列的镜像(`w-3` 12px + 父级 gap 3px),
方块阵列距内容左右边界各 15px,几何上真正对称,同时给描边留出余量(`ringOverflow` 由 +1 变 −14)。
`pt-[1px]` 同理,为落在最上面一行的 today 描边留出上侧空间。
_不靠 `justify-center` 居中_:内容超宽时它会从左边缘开始裁,第一列看不见。
_不把富余宽度摊给格子_:那会让格子变成 12.1×11 的扁方块,14px 见方的阵列里 1px 的偏差是能看出来的。
_不给 `overflow` 容器直接加 `padding-right`_:历史实现里 padding 不计入 `scrollWidth`,
放在内层 flex 上可以绕开这个差异。
10. **热力图之后补两块内容**:标签分布(「时间花在哪」)与四项细节指标(平均完成周期 / 最长连续 /
有记录 / 平均每天)。理由:热力图只回答「哪几天动了手」,回答不了「时间花在什么方向」。
两块都是既有数据的第二种切法,没有引入新存储。
11. **标签分布按「每个标签各计一次」**,而不是把任务分桶到某个标签。一个任务同时属于「后端」和「安全」,
对两个方向都算贡献;因此各项之和会大于完成任务数,这一点在卡片脚注与 README 里都写明,
免得用户拿它跟「累计完成」对不上账。
12. **完成周期取中位数**,并且不足一天显示「当天」。平均值会被一条躺了半年的任务拉到 60 天以上,
中位数仍然描述「大多数活多久能做完」。负数跨度(时钟回拨或被手工改过的数据)直接跳过。
13. **「平均每天」按有记录的天数算**,不按自然日。按自然日会把周末与休假算成效率下降。
**被否决的方案**
- _新增 `dailyCompletions` 计数表_:能得到不随删除变化的准确历史,代价是「两份真相」——
恢复一条被删的任务要不要把计数加回去?撤销删除、合并导入、快照恢复都会撞上这个问题。
宁可数字偏保守,也不要出现「热力图说完成了 5 个,任务列表只有 3 个」。
- _把热力图放进今日视图顶部_:今日视图腾不开(一排 7 个分组 + 统计卡片已经够满),
而且它回答的不是「今天该做什么」。做成独立页面才能给足高度和一年的宽度。
- _画柱状趋势图_:热力图本身就是趋势的可视化,再叠一个按周聚合的柱状图是同一组数据的第二遍表达。
确实要看「最近几周在变多还是变少」时,热力图右侧那几列的深浅已经能看出来。
- _按最大值相对分档上色_:见结论 4。
- _给热力图每个格子加独立的可访问名称_:一年 371 个格子逐个朗读只会变成噪音,
改为容器一个 `role="img"` 汇总「有多少天产生过记录、累计多少个」,格子本身 `aria-hidden`,
悬停的 `title` 作为视觉用户的补充。
- _把统计入口做成今日视图里的一个按钮_:不可深链、无法收藏,PWA 的 shortcuts 也没有落点。
- _完成周期用平均值_:见结论 12。平均值仍然算出来留在 `completionCycle().mean` 里供后续使用,
只是不作为主指标展示 —— 将来若要做「异常长尾」的提示,那个字段就是入口。
- _标签分布用饼图_:六个标签的饼图无法按长度比较,且标签名必须挤在图例里。横向条形按长度对齐,
标签名左侧留白足够,还能顺便带上标签自己的颜色圆点。
- _压缩热力图格子来消除滚动条_:把 11px 压到 8px 确实能塞下,但 8px 的方块已经分不清是「一格」
还是「两个挨着的格子」。滚动条是果,列宽被文字撑开才是因,改因不改果。
**影响**
- 新增 `src/utils/activity.ts`(纯函数:分组、热力图排布、汇总、连续天数、标签分布、完成周期、
最长连续)与 `src/views/StatsView.vue`。
组件的职责只有取数与渲染,所有口径都在纯函数里,时间可注入,便于测跨周与跨月的边界。
- 路由新增 `/stats`;`AppNav` 的入口从两个变三个,`AppNav.spec.ts` 的断言同步更新。
- 热力图格子带 `data-role="heatmap-cell"` 与 `data-date` / `data-count` / `data-level`,
标签条形带 `data-role="tag-bar"` 与 `data-tag` / `data-count`:
单测和 e2e 都靠它定位,避免依赖 `size-[11px]` 这类会被改动的样式类。
- 窄屏(< 768px)热力图仍然横向滚动,这是有意的:一年 53 列在手机上无法在不失真的前提下压缩。
- 数值会随删除而减少。这一点写进了 README 的已知限制,也写在统计页脚注里 ——
用户看到数字变少时能立刻知道原因,而不是怀疑数据坏了。
+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('当天')
})
+2 -2
View File
@@ -25,9 +25,9 @@ async function setup(initialPath = '/') {
} }
describe('AppNav 导航', () => { describe('AppNav 导航', () => {
it('渲染看板与今日两个入口', async () => { it('渲染看板、今日与统计三个入口', async () => {
const { wrapper } = await setup() const { wrapper } = await setup()
expect(wrapper.findAll('a').map((node) => node.text())).toEqual(['看板', '今日']) expect(wrapper.findAll('a').map((node) => node.text())).toEqual(['看板', '今日', '统计'])
}) })
it('当前路由的入口标记 aria-current', async () => { it('当前路由的入口标记 aria-current', async () => {
+1
View File
@@ -12,6 +12,7 @@ const store = useTaskStore()
const LINKS = [ const LINKS = [
{ to: '/', label: '看板' }, { to: '/', label: '看板' },
{ to: '/today', label: '今日' }, { to: '/today', label: '今日' },
{ to: '/stats', label: '统计' },
] as const ] as const
</script> </script>
+6
View File
@@ -26,6 +26,12 @@ export const routes = [
component: () => import('@/views/TodayView.vue'), component: () => import('@/views/TodayView.vue'),
meta: { title: '今日' }, meta: { title: '今日' },
}, },
{
path: '/stats',
name: 'stats',
component: () => import('@/views/StatsView.vue'),
meta: { title: '统计' },
},
{ path: '/:pathMatch(.*)*', redirect: '/' }, { path: '/:pathMatch(.*)*', redirect: '/' },
] ]
+492
View File
@@ -0,0 +1,492 @@
import { describe, expect, it } from 'vitest'
import type { Task } from '@/types/task'
import {
breakdownByTag,
buildHeatmap,
completionCycle,
computeActivitySummary,
computeStreak,
countCompletionsByDate,
DEFAULT_HEATMAP_WEEKS,
levelFor,
longestStreak,
startOfWeekIso,
UNTAGGED_LABEL,
} from './activity'
/** 2026-10-24 是周六,本周一为 2026-10-19 */
const NOW = new Date(2026, 9, 24, 12, 0, 0)
function makeTask(completedAt: string | null, overrides: Partial<Task> = {}): Task {
return {
id: `task-${completedAt ?? 'open'}-${overrides.title ?? ''}`,
title: '任务',
description: '',
status: completedAt ? 'done' : 'todo',
priority: 2,
tags: [],
dueDate: null,
sortOrder: 1024,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
completedAt,
archivedAt: null,
deletedAt: null,
...overrides,
}
}
/** 用本地时间构造 ISO,避免测试断言受运行环境时区影响 */
function localIso(year: number, month: number, day: number, hour = 12): string {
return new Date(year, month - 1, day, hour, 0, 0).toISOString()
}
describe('levelFor', () => {
it('0 个为最浅档', () => {
expect(levelFor(0)).toBe(0)
})
it('1 到 3 个逐级加深', () => {
expect(levelFor(1)).toBe(1)
expect(levelFor(2)).toBe(2)
expect(levelFor(3)).toBe(3)
})
it('4 个及以上为最深档', () => {
expect(levelFor(4)).toBe(4)
expect(levelFor(12)).toBe(4)
})
it('负数按 0 处理', () => {
expect(levelFor(-1)).toBe(0)
})
})
describe('countCompletionsByDate', () => {
it('没有已完成任务时为空', () => {
const counts = countCompletionsByDate([makeTask(null)])
expect(counts.size).toBe(0)
})
it('同一天完成多条会累加', () => {
const counts = countCompletionsByDate([
makeTask(localIso(2026, 10, 20)),
makeTask(localIso(2026, 10, 20), { title: '第二件' }),
makeTask(localIso(2026, 10, 21)),
])
expect(counts.get('2026-10-20')).toBe(2)
expect(counts.get('2026-10-21')).toBe(1)
})
it('晚上完成的任务算在当天,不会被 UTC 推到前一天', () => {
const counts = countCompletionsByDate([makeTask(localIso(2026, 10, 24, 23))])
expect(counts.get('2026-10-24')).toBe(1)
})
it('清空完成时间后不计入', () => {
const counts = countCompletionsByDate([makeTask(localIso(2026, 10, 20)), makeTask(null)])
expect(counts.get('2026-10-20')).toBe(1)
expect(counts.size).toBe(1)
})
})
describe('startOfWeekIso', () => {
it('周六所在周的周一是 10 月 19 日', () => {
expect(startOfWeekIso(NOW)).toBe('2026-10-19')
})
it('周一当天返回自己', () => {
expect(startOfWeekIso(new Date(2026, 9, 19, 9))).toBe('2026-10-19')
})
it('周日属于上一周,不开启新的一周', () => {
expect(startOfWeekIso(new Date(2026, 9, 25, 9))).toBe('2026-10-19')
})
})
describe('buildHeatmap', () => {
it('默认生成 53 周(一年),每周 7 天', () => {
const weeks = buildHeatmap(new Map(), { now: NOW })
expect(weeks).toHaveLength(53)
expect(DEFAULT_HEATMAP_WEEKS).toBe(53)
expect(weeks.every((week) => week.cells.length === 7)).toBe(true)
})
it('列按时间正序,每列都是周一到周日', () => {
const weeks = buildHeatmap(new Map(), { weeks: 3, now: NOW })
expect(weeks.map((week) => week.key)).toEqual(['2026-10-05', '2026-10-12', '2026-10-19'])
expect(weeks[2]?.cells[0]?.date).toBe('2026-10-19')
expect(weeks[2]?.cells[6]?.date).toBe('2026-10-25')
})
it('今天之后的格子标记为 future', () => {
const weeks = buildHeatmap(new Map(), { weeks: 1, now: NOW })
const cells = weeks[0]?.cells ?? []
// 本周一 19 日到周日 25 日;今天是 24 日(下标 5)
expect(cells[5]?.today).toBe(true)
expect(cells.slice(0, 6).every((cell) => !cell.future)).toBe(true)
expect(cells[6]?.future).toBe(true)
})
it('带入完成数并换算成等级', () => {
const counts = new Map([
['2026-10-20', 1],
['2026-10-21', 3],
['2026-10-22', 7],
])
const cells = buildHeatmap(counts, { weeks: 1, now: NOW })[0]?.cells ?? []
expect(cells[1]?.count).toBe(1)
expect(cells[1]?.level).toBe(1)
expect(cells[2]?.level).toBe(3)
expect(cells[3]?.level).toBe(4)
expect(cells[0]?.count).toBe(0)
expect(cells[0]?.level).toBe(0)
})
it('每个月只在第一周打一个标签', () => {
const weeks = buildHeatmap(new Map(), { weeks: 53, now: NOW })
const labeled = weeks.filter((week) => week.monthLabel !== null)
// 一年覆盖 12 个月以上
expect(labeled.length).toBeGreaterThanOrEqual(12)
// 同一个月不会出现两次
const months = labeled.map((week) => week.monthLabel as string)
expect(new Set(months).size).toBe(months.length)
// 标签都落在该月第一周(周一在 1–7 号)
for (const week of labeled) {
expect(Number(week.key.slice(-2))).toBeLessThanOrEqual(7)
}
})
it('相邻标签至少隔 4 列,14px 的列宽不会叠字', () => {
const weeks = buildHeatmap(new Map(), { weeks: 53, now: NOW })
const indexes = weeks.flatMap((week, index) => (week.monthLabel === null ? [] : [index]))
for (let position = 1; position < indexes.length; position += 1) {
expect(indexes[position]! - indexes[position - 1]!).toBeGreaterThanOrEqual(4)
}
})
it('周数下限为 1', () => {
expect(buildHeatmap(new Map(), { weeks: 0, now: NOW })).toHaveLength(1)
})
})
describe('computeStreak', () => {
it('今天有完成时从今天开始数', () => {
const counts = new Map([
['2026-10-24', 1],
['2026-10-23', 2],
['2026-10-22', 1],
])
expect(computeStreak(counts, NOW)).toBe(3)
})
it('今天还没完成时从昨天开始数,不直接归零', () => {
const counts = new Map([
['2026-10-23', 1],
['2026-10-22', 1],
])
expect(computeStreak(counts, NOW)).toBe(2)
})
it('断档处停止', () => {
const counts = new Map([
['2026-10-24', 1],
['2026-10-22', 1],
])
expect(computeStreak(counts, NOW)).toBe(1)
})
it('从未完成过为 0', () => {
expect(computeStreak(new Map(), NOW)).toBe(0)
})
it('今天与昨天都没有完成时为 0', () => {
const counts = new Map([['2026-10-22', 5]])
expect(computeStreak(counts, NOW)).toBe(0)
})
})
describe('computeActivitySummary', () => {
const counts = new Map([
['2026-10-24', 2], // 今天
['2026-10-20', 1], // 本周
['2026-10-05', 3], // 本月,非本周
['2026-09-28', 4], // 上个月
])
it('汇总今天、本周、本月与总数', () => {
const summary = computeActivitySummary(counts, NOW)
expect(summary.today).toBe(2)
expect(summary.thisWeek).toBe(3)
expect(summary.thisMonth).toBe(6)
expect(summary.total).toBe(10)
expect(summary.activeDays).toBe(4)
})
it('连续天数复用 computeStreak', () => {
expect(computeActivitySummary(counts, NOW).streak).toBe(1)
})
it('最忙的一天取完成数最多的那天', () => {
expect(computeActivitySummary(counts, NOW).bestDay).toEqual({
date: '2026-09-28',
count: 4,
})
})
it('并列时取较晚的一天', () => {
const tie = new Map([
['2026-10-20', 3],
['2026-10-22', 3],
])
expect(computeActivitySummary(tie, NOW).bestDay).toEqual({ date: '2026-10-22', count: 3 })
})
it('没有完成记录时各项为 0,bestDay 为 null', () => {
const summary = computeActivitySummary(new Map(), NOW)
expect(summary).toEqual({
today: 0,
thisWeek: 0,
thisMonth: 0,
total: 0,
streak: 0,
bestDay: null,
activeDays: 0,
})
})
it('未来日期的记录不计入本周与本月', () => {
const future = new Map([['2026-10-30', 5]])
const summary = computeActivitySummary(future, NOW)
expect(summary.total).toBe(5)
expect(summary.thisWeek).toBe(0)
expect(summary.thisMonth).toBe(0)
})
})
describe('breakdownByTag', () => {
it('没有已完成任务时为空数组', () => {
expect(breakdownByTag([makeTask(null)])).toEqual([])
})
it('未完成的任务不计入', () => {
const items = breakdownByTag([
makeTask(localIso(2026, 10, 20), { tags: ['前端'] }),
makeTask(null, { title: '还没做', tags: ['前端'] }),
])
expect(items).toEqual([{ tag: '前端', count: 1 }])
})
it('多标签任务在每个标签下各计一次', () => {
const items = breakdownByTag([
makeTask(localIso(2026, 10, 20), { tags: ['后端', '安全'] }),
makeTask(localIso(2026, 10, 21), { title: 'b', tags: ['安全'] }),
])
expect(items).toEqual([
{ tag: '安全', count: 2 },
{ tag: '后端', count: 1 },
])
// 各项之和大于任务数,这是刻意的:多标签任务对多个方向都算贡献
expect(items.reduce((sum, item) => sum + item.count, 0)).toBe(3)
})
it('没有标签的已完成任务归入「未分类」', () => {
const items = breakdownByTag([
makeTask(localIso(2026, 10, 20)),
makeTask(localIso(2026, 10, 21), { title: 'b' }),
makeTask(localIso(2026, 10, 22), { title: 'c', tags: ['前端'] }),
])
expect(items).toContainEqual({ tag: UNTAGGED_LABEL, count: 2 })
expect(UNTAGGED_LABEL).toBe('未分类')
})
it('全部未分类时不额外产生空标签项', () => {
const items = breakdownByTag([makeTask(localIso(2026, 10, 20))])
expect(items).toEqual([{ tag: UNTAGGED_LABEL, count: 1 }])
})
it('按数量降序排列', () => {
const items = breakdownByTag([
makeTask(localIso(2026, 10, 20), { tags: ['少'] }),
makeTask(localIso(2026, 10, 21), { title: 'b', tags: ['多'] }),
makeTask(localIso(2026, 10, 22), { title: 'c', tags: ['多'] }),
makeTask(localIso(2026, 10, 23), { title: 'd', tags: ['多'] }),
])
expect(items.map((item) => item.tag)).toEqual(['多', '少'])
})
it('数量相同时按标签名排序,保证渲染顺序稳定', () => {
const first = breakdownByTag([
makeTask(localIso(2026, 10, 20), { tags: ['b'] }),
makeTask(localIso(2026, 10, 21), { title: 'x', tags: ['a'] }),
])
const second = breakdownByTag([
makeTask(localIso(2026, 10, 20), { tags: ['a'] }),
makeTask(localIso(2026, 10, 21), { title: 'x', tags: ['b'] }),
])
expect(first.map((item) => item.tag)).toEqual(['a', 'b'])
expect(second.map((item) => item.tag)).toEqual(['a', 'b'])
})
})
describe('completionCycle', () => {
it('没有已完成任务时返回 null', () => {
expect(completionCycle([makeTask(null)])).toBeNull()
})
it('单个样本时中位数、平均都等于该跨度', () => {
const cycle = completionCycle([
makeTask(localIso(2026, 10, 3, 0), { createdAt: localIso(2026, 10, 1, 0) }),
])
expect(cycle).toEqual({ median: 2, mean: 2, sample: 1 })
})
it('奇数个样本取中间值', () => {
const cycle = completionCycle([
makeTask(localIso(2026, 10, 3, 0), { title: 'a', createdAt: localIso(2026, 10, 1, 0) }),
makeTask(localIso(2026, 10, 13, 0), { title: 'b', createdAt: localIso(2026, 10, 1, 0) }),
makeTask(localIso(2026, 10, 8, 0), { title: 'c', createdAt: localIso(2026, 10, 1, 0) }),
])
// 跨度为 2、12、7 → 排序后 2、7、12,中位数 7
expect(cycle?.median).toBe(7)
expect(cycle?.sample).toBe(3)
expect(cycle?.mean).toBeCloseTo(7, 5)
})
it('偶数个样本取中间两个的平均', () => {
const cycle = completionCycle([
makeTask(localIso(2026, 10, 3, 0), { title: 'a', createdAt: localIso(2026, 10, 1, 0) }),
makeTask(localIso(2026, 10, 11, 0), { title: 'b', createdAt: localIso(2026, 10, 1, 0) }),
])
// 跨度为 2、10 → 中位数 6
expect(cycle?.median).toBe(6)
expect(cycle?.sample).toBe(2)
})
it('中位数不被极端值拉偏', () => {
const cycle = completionCycle([
makeTask(localIso(2026, 10, 2, 0), { title: 'a', createdAt: localIso(2026, 10, 1, 0) }),
makeTask(localIso(2026, 10, 3, 0), { title: 'b', createdAt: localIso(2026, 10, 1, 0) }),
makeTask(localIso(2027, 4, 1, 0), { title: '躺了半年', createdAt: localIso(2026, 10, 1, 0) }),
])
expect(cycle?.median).toBe(2)
// 平均被那条躺了半年的任务拉到 60 天以上 —— 这正是主指标用中位数的原因
expect(cycle?.mean).toBeGreaterThan(50)
})
it('完成时间早于创建时间的脏数据直接跳过', () => {
const cycle = completionCycle([
makeTask(localIso(2026, 10, 1, 0), { title: '回溯', createdAt: localIso(2026, 10, 5, 0) }),
makeTask(localIso(2026, 10, 3, 0), { title: '正常', createdAt: localIso(2026, 10, 1, 0) }),
])
expect(cycle?.sample).toBe(1)
expect(cycle?.median).toBe(2)
})
it('当天创建当天完成时跨度不足 1 天', () => {
const cycle = completionCycle([
makeTask(localIso(2026, 10, 1, 20), { createdAt: localIso(2026, 10, 1, 9) }),
])
// 9:00 → 20:00 共 11 小时 = 0.46 天;视图据此显示「当天」而不是「0.5 天」
expect(cycle?.median).toBeCloseTo(11 / 24, 5)
expect(cycle?.median).toBeLessThan(1)
})
it('未完成的任务不参与统计', () => {
const cycle = completionCycle([
makeTask(localIso(2026, 10, 3, 0), { createdAt: localIso(2026, 10, 1, 0) }),
makeTask(null, { title: '还在做', createdAt: localIso(2026, 10, 1, 0) }),
])
expect(cycle?.sample).toBe(1)
})
})
describe('longestStreak', () => {
it('没有任何记录时为 0', () => {
expect(longestStreak(new Map())).toBe(0)
})
it('连续的几天取最长的一段', () => {
const counts = new Map([
['2026-10-01', 1],
['2026-10-02', 3],
['2026-10-03', 1],
['2026-10-10', 1],
['2026-10-11', 2],
])
expect(longestStreak(counts)).toBe(3)
})
it('跨月连续合并计算', () => {
const counts = new Map([
['2026-09-29', 1],
['2026-09-30', 1],
['2026-10-01', 1],
['2026-10-02', 1],
])
expect(longestStreak(counts)).toBe(4)
})
it('计数为 0 的日期不算连续', () => {
const counts = new Map([
['2026-10-01', 1],
['2026-10-02', 0],
['2026-10-03', 1],
])
expect(longestStreak(counts)).toBe(1)
})
it('全部不连续时为 1', () => {
const counts = new Map([
['2026-10-01', 1],
['2026-10-05', 1],
['2026-10-20', 1],
])
expect(longestStreak(counts)).toBe(1)
})
it('不受 Map 插入顺序影响', () => {
const forward = new Map([
['2026-10-01', 1],
['2026-10-02', 1],
['2026-10-03', 1],
])
const backward = new Map([
['2026-10-03', 1],
['2026-10-02', 1],
['2026-10-01', 1],
])
expect(longestStreak(backward)).toBe(longestStreak(forward))
expect(longestStreak(backward)).toBe(3)
})
})
+326
View File
@@ -0,0 +1,326 @@
/**
* 完成活动统计:按日汇总、热力图、汇总指标(见 ADR-012)
*
* 数据来源**只有** `Task.completedAt`,不额外存「每日完成计数」。
* 因此这里的统计是「基于现存任务的近似」:永久删除、清空回收站、
* 超过 30 天自动清理的任务,会连同它那天的完成记录一起消失。
*
* 这是刻意取舍:引入第二份计数数据能得到不随删除变化的准确历史,
* 代价是「两份真相」—— 恢复一条被删的任务时该不该把计数加回去?
* 撤销删除、合并导入都会遇到这个问题。宁可数字偏保守,也不要自相矛盾。
*/
import dayjs from 'dayjs'
import type { Task } from '@/types/task'
import { localDateOf } from '@/utils/date'
/** 热力图默认展示的周数(一年)。半年只占卡片一半宽,且看不出季节性的起伏 */
export const DEFAULT_HEATMAP_WEEKS = 53
export type HeatmapLevel = 0 | 1 | 2 | 3 | 4
/**
* 强度分档:当天完成数 → 等级。
* 固定阈值比按最大值分档更可预测 —— 后者在数据变多时会让所有格子一起变深,
* 反而看不出「哪天特别猛」。
*/
export function levelFor(count: number): HeatmapLevel {
if (count <= 0) return 0
if (count === 1) return 1
if (count === 2) return 2
if (count === 3) return 3
return 4
}
export interface HeatmapCell {
/** YYYY-MM-DD(本地日期) */
date: string
count: number
level: HeatmapLevel
/** 今天之后的日期:留白,避免出现「未来已经完成了 0 个」这种噪音 */
future: boolean
/** 是否为今天 */
today: boolean
}
export interface HeatmapWeek {
/** 该周周一的日期,作为渲染 key */
key: string
/** 周一到周日,共 7 格 */
cells: HeatmapCell[]
/** 需要显示月份标签时给出(如「9月」),否则为 null */
monthLabel: string | null
}
/**
* 按本地日期汇总完成数。
*
* 必须逐条走 `localDateOf`:`completedAt` 存的是 UTC ISO,
* 直接截取前 10 位在东八区会把晚上完成的任务算到前一天。
*/
export function countCompletionsByDate(tasks: readonly Task[]): Map<string, number> {
const counts = new Map<string, number>()
for (const task of tasks) {
if (task.completedAt === null) continue
const date = localDateOf(task.completedAt)
if (date === null) continue
counts.set(date, (counts.get(date) ?? 0) + 1)
}
return counts
}
/** 本周周一的日期(周一为一周之始,与 `utils/stats.ts` 的口径一致) */
export function startOfWeekIso(now: Date = new Date()): string {
const current = dayjs(now).startOf('day')
return current.subtract((current.day() + 6) % 7, 'day').format('YYYY-MM-DD')
}
/**
* 生成热力图:列为一周,行为周一到周日,按时间正序。
*
* 传入 `counts` 而不是任务列表,是为了让这个函数只负责「排布」,
* 汇总与排布可以分开测试。
*/
export function buildHeatmap(
counts: ReadonlyMap<string, number>,
options: { weeks?: number; now?: Date } = {},
): HeatmapWeek[] {
const weeks = Math.max(1, options.weeks ?? DEFAULT_HEATMAP_WEEKS)
const today = dayjs(options.now ?? new Date()).startOf('day')
const currentWeekStart = today.subtract((today.day() + 6) % 7, 'day')
const firstWeekStart = currentWeekStart.subtract(weeks - 1, 'week')
const result: HeatmapWeek[] = []
for (let index = 0; index < weeks; index += 1) {
const weekStart = firstWeekStart.add(index, 'week')
const cells: HeatmapCell[] = []
for (let offset = 0; offset < 7; offset += 1) {
const day = weekStart.add(offset, 'day')
const date = day.format('YYYY-MM-DD')
const count = counts.get(date) ?? 0
cells.push({
date,
count,
level: levelFor(count),
future: day.isAfter(today, 'day'),
today: day.isSame(today, 'day'),
})
}
/**
* 只在该月第一周打标签(周一落在 1–7 号)。
*
* 用「月份变了就打」会让 4 月与 5 月这种相邻的两周各打一个,
* 而每列只有 14px 宽,两个标签会叠在一起。每个月恰好一次,
* 间隔必然在 4 列以上,也就不会撞车。
*/
const labelMonth = weekStart.date() <= 7 ? weekStart.month() + 1 : null
result.push({
key: weekStart.format('YYYY-MM-DD'),
cells,
monthLabel: labelMonth === null ? null : `${labelMonth}月`,
})
}
return result
}
export interface ActivitySummary {
/** 今天完成数 */
today: number
/** 本周(周一起)完成数 */
thisWeek: number
/** 本月完成数 */
thisMonth: number
/** 全部完成数 */
total: number
/** 连续完成天数 */
streak: number
/** 完成最多的一天;从未完成过时为 null */
bestDay: { date: string; count: number } | null
/** 有完成记录的天数 */
activeDays: number
}
/**
* 连续完成天数。
*
* 今天还没完成时从昨天开始数 —— 否则每天零点一过 streak 就归零,
* 会在用户还没开始工作的时候先把成绩抹掉。
*/
export function computeStreak(counts: ReadonlyMap<string, number>, now: Date = new Date()): number {
let cursor = dayjs(now).startOf('day')
if ((counts.get(cursor.format('YYYY-MM-DD')) ?? 0) === 0) {
cursor = cursor.subtract(1, 'day')
}
let streak = 0
while ((counts.get(cursor.format('YYYY-MM-DD')) ?? 0) > 0) {
streak += 1
cursor = cursor.subtract(1, 'day')
}
return streak
}
export function computeActivitySummary(
counts: ReadonlyMap<string, number>,
now: Date = new Date(),
): ActivitySummary {
const todayIso = dayjs(now).format('YYYY-MM-DD')
const weekStart = startOfWeekIso(now)
const monthStart = dayjs(now).startOf('month').format('YYYY-MM-DD')
let total = 0
let thisWeek = 0
let thisMonth = 0
let activeDays = 0
let bestDay: { date: string; count: number } | null = null
for (const [date, count] of counts) {
if (count <= 0) continue
total += count
activeDays += 1
if (date >= weekStart && date <= todayIso) thisWeek += count
if (date >= monthStart && date <= todayIso) thisMonth += count
// 并列时取较晚的一天:最近的高光比上个月的更有参考价值
if (
bestDay === null ||
count > bestDay.count ||
(count === bestDay.count && date > bestDay.date)
) {
bestDay = { date, count }
}
}
return {
today: counts.get(todayIso) ?? 0,
thisWeek,
thisMonth,
total,
streak: computeStreak(counts, now),
bestDay,
activeDays,
}
}
/** 没有标签的已完成任务在分布里归到这个名字 */
export const UNTAGGED_LABEL = '未分类'
export interface TagBreakdownItem {
tag: string
count: number
}
/**
* 已完任务按标签分布,按数量降序。
*
* 一个任务有多个标签时每个标签各 +1,因此各项之和会大于任务数。
* 这里回答的是「这类活做了多少件」,不是「任务如何分桶」——
* 一件同时属于「后端」和「安全」的活,对两个方向都算贡献。
*/
export function breakdownByTag(tasks: readonly Task[]): TagBreakdownItem[] {
const counter = new Map<string, number>()
let untagged = 0
for (const task of tasks) {
if (task.completedAt === null) continue
if (task.tags.length === 0) {
untagged += 1
continue
}
for (const tag of task.tags) {
counter.set(tag, (counter.get(tag) ?? 0) + 1)
}
}
const items: TagBreakdownItem[] = [...counter.entries()].map(([tag, count]) => ({ tag, count }))
if (untagged > 0) items.push({ tag: UNTAGGED_LABEL, count: untagged })
// 数量相同时按标签名排,保证每次渲染顺序稳定
return items.sort(
(a, b) => b.count - a.count || a.tag.localeCompare(b.tag, 'zh-Hans-CN'),
)
}
export interface CompletionCycle {
/** 中位数天数 —— 抗极端值,代表「通常要多久」 */
median: number
/** 平均天数 */
mean: number
/** 参与统计的任务数 */
sample: number
}
/**
* 从创建到完成要多久,只统计已完成的任务。
*
* 用中位数而不是平均作为主指标:一条躺了半年的任务就能把平均值拉到离谱,
* 而中位数仍然描述「大多数活多久能做完」。没有可统计的任务时返回 null。
*/
export function completionCycle(tasks: readonly Task[]): CompletionCycle | null {
const days: number[] = []
for (const task of tasks) {
if (task.completedAt === null) continue
const created = dayjs(task.createdAt)
const completed = dayjs(task.completedAt)
if (!created.isValid() || !completed.isValid()) continue
const span = completed.diff(created, 'day', true)
// 时钟回拨或被手工改过的数据会出现负数,直接跳过而不是让它污染中位数
if (span < 0) continue
days.push(span)
}
if (days.length === 0) return null
days.sort((a, b) => a - b)
const middle = Math.floor(days.length / 2)
const median =
days.length % 2 === 0
? ((days[middle - 1] ?? 0) + (days[middle] ?? 0)) / 2
: (days[middle] ?? 0)
const mean = days.reduce((sum, value) => sum + value, 0) / days.length
return { median, mean, sample: days.length }
}
/**
* 历史最长连续完成天数。
*
* 与 `computeStreak`(当前连续)不同:这个看整个记录期,
* 用来回答「我最拼的那段时间坚持了多久」。
*/
export function longestStreak(counts: ReadonlyMap<string, number>): number {
const dates = [...counts.entries()]
.filter(([, count]) => count > 0)
.map(([date]) => date)
.sort()
let best = 0
let run = 0
let previous: string | null = null
for (const date of dates) {
// 用 dayjs 算相邻差,避免自己处理月份与闰年
const adjacent = previous !== null && dayjs(date).diff(dayjs(previous), 'day') === 1
run = adjacent ? run + 1 : 1
if (run > best) best = run
previous = date
}
return best
}
+301
View File
@@ -0,0 +1,301 @@
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks'
import { DEFAULT_HEATMAP_WEEKS } from '@/utils/activity'
import type { Task } from '@/types/task'
import StatsView from './StatsView.vue'
/** 2026-10-24 是周六 */
const NOW = new Date(2026, 9, 24, 12, 0, 0)
function localIso(year: number, month: number, day: number, hour = 12): string {
return new Date(year, month - 1, day, hour, 0, 0).toISOString()
}
function makeTask(completedAt: string | null, overrides: Partial<Task> = {}): Task {
return {
id: `task-${completedAt ?? 'open'}-${overrides.title ?? ''}`,
title: '任务',
description: '',
status: completedAt ? 'done' : 'todo',
priority: 2,
tags: [],
dueDate: null,
sortOrder: 1024,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
completedAt,
archivedAt: null,
deletedAt: null,
...overrides,
}
}
function mountView(tasks: Task[] = []): VueWrapper {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(tasks), crossTab: false })
return mount(StatsView, { global: { plugins: [pinia] } })
}
/**
* 读取某张统计卡片的数字。
* 不能用「包含 dt 的第一个 div」——外层 grid 也满足,会永远读到第一张卡。
*/
function cardValue(wrapper: VueWrapper, label: string): string | undefined {
const dt = wrapper.findAll('dt').find((node) => node.text() === label)
const dd = dt?.element.parentElement?.querySelector('dd')
return dd?.textContent?.trim()
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
describe('统计视图 · 空状态', () => {
it('没有任何完成记录时各项为 0', () => {
const wrapper = mountView([makeTask(null)])
expect(cardValue(wrapper, '今天完成')).toContain('0')
expect(cardValue(wrapper, '累计完成')).toContain('0')
})
it('提示如何让热力图亮起来', () => {
const wrapper = mountView()
expect(wrapper.text()).toContain('还没有完成记录')
})
it('仍然渲染完整的热力图网格', () => {
const wrapper = mountView()
expect(wrapper.findAll('[data-role="heatmap-cell"]')).toHaveLength(DEFAULT_HEATMAP_WEEKS * 7)
})
})
describe('统计视图 · 汇总数字', () => {
const tasks = [
makeTask(localIso(2026, 10, 24)), // 今天
makeTask(localIso(2026, 10, 24), { title: '今天第二件' }),
makeTask(localIso(2026, 10, 20)), // 本周
makeTask(localIso(2026, 10, 5)), // 本月
makeTask(localIso(2026, 9, 28)), // 上月
]
it('今天完成数', () => {
expect(cardValue(mountView(tasks), '今天完成')).toContain('2')
})
it('本月完成数包含本周但不含上月', () => {
expect(cardValue(mountView(tasks), '本月完成')).toContain('4')
})
it('累计完成数包含所有月份', () => {
expect(cardValue(mountView(tasks), '累计完成')).toContain('5')
})
it('连续天数按今天与昨天计算', () => {
// 今天有记录,昨天没有 → 连续 1 天
expect(cardValue(mountView(tasks), '连续完成')).toContain('1')
})
it('显示最忙的一天', () => {
const wrapper = mountView(tasks)
expect(wrapper.text()).toContain('最忙的一天')
// 今天完成了 2 个,是这批数据里最多的一天
expect(wrapper.text()).toContain('10月24日 · 2 个')
})
})
describe('统计视图 · 热力图', () => {
it('每个格子带上日期与完成数,供悬停查看', () => {
const wrapper = mountView([
makeTask(localIso(2026, 10, 22)),
makeTask(localIso(2026, 10, 22), { title: 'b' }),
])
const cell = wrapper.find('[data-role="heatmap-cell"][data-date="2026-10-22"]')
expect(cell.exists()).toBe(true)
expect(cell.attributes('data-count')).toBe('2')
expect(cell.attributes('data-level')).toBe('2')
expect(cell.attributes('title')).toContain('完成 2 个')
})
it('今天之后的格子不可见', () => {
const wrapper = mountView()
const future = wrapper.find('[data-role="heatmap-cell"][data-date="2026-10-25"]')
expect(future.classes()).toContain('invisible')
})
it('今天有标记但与未来格子区分开', () => {
const wrapper = mountView()
const today = wrapper.find('[data-role="heatmap-cell"][data-date="2026-10-24"]')
expect(today.classes()).not.toContain('invisible')
expect(today.classes()).toContain('ring-1')
})
it('没有完成的那天用最浅档', () => {
const wrapper = mountView()
const cell = wrapper.find('[data-role="heatmap-cell"][data-date="2026-10-01"]')
expect(cell.attributes('data-level')).toBe('0')
})
})
describe('统计视图 · 回收站', () => {
it('不统计回收站里的任务', () => {
const deleted = makeTask(localIso(2026, 10, 24), {
title: '删掉的',
deletedAt: localIso(2026, 10, 24),
})
const wrapper = mountView([deleted])
expect(cardValue(wrapper, '累计完成')).toContain('0')
})
it('已归档的任务仍然计入', () => {
const archived = makeTask(localIso(2026, 10, 20), {
title: '归档的',
archivedAt: localIso(2026, 10, 21),
})
const wrapper = mountView([archived])
expect(cardValue(wrapper, '累计完成')).toContain('1')
})
})
describe('统计视图 · 时间花在哪', () => {
/** 读取标签条形图的行,返回 [标签, 数量] 对 */
function tagRows(wrapper: VueWrapper): Array<[string, number]> {
return wrapper
.findAll('[data-role="tag-bar"]')
.map((node) => [
node.attributes('data-tag') as string,
Number(node.attributes('data-count')),
])
}
it('没有完成记录时显示空状态', () => {
const wrapper = mountView()
expect(wrapper.text()).toContain('还没有带标签的完成记录')
})
it('按标签汇总,数量多的排在前面', () => {
const wrapper = mountView([
makeTask(localIso(2026, 10, 20), { tags: ['前端'] }),
makeTask(localIso(2026, 10, 21), { title: 'b', tags: ['后端'] }),
makeTask(localIso(2026, 10, 22), { title: 'c', tags: ['后端'] }),
])
expect(tagRows(wrapper)).toEqual([
['后端', 2],
['前端', 1],
])
})
it('一个任务带多个标签时,每个标签各计一次', () => {
const wrapper = mountView([makeTask(localIso(2026, 10, 20), { tags: ['后端', '安全'] })])
expect(tagRows(wrapper)).toEqual([
['安全', 1],
['后端', 1],
])
})
it('没有标签的完成记录归入「未分类」', () => {
const wrapper = mountView([makeTask(localIso(2026, 10, 20))])
expect(tagRows(wrapper)).toEqual([['未分类', 1]])
})
it('最多只展示前 6 个标签', () => {
const tasks = Array.from({ length: 8 }, (_, index) =>
makeTask(localIso(2026, 10, 10 + index), { title: `t${index}`, tags: [`标签${index}`] }),
)
const wrapper = mountView(tasks)
expect(tagRows(wrapper)).toHaveLength(6)
expect(wrapper.text()).toContain('前 6 项')
})
it('标签条宽度按最大项归一', () => {
const wrapper = mountView([
makeTask(localIso(2026, 10, 20), { tags: ['多'] }),
makeTask(localIso(2026, 10, 21), { title: 'b', tags: ['多'] }),
makeTask(localIso(2026, 10, 22), { title: 'c', tags: ['少'] }),
])
const bars = wrapper.findAll('[data-role="tag-bar"]')
const width = (index: number): string =>
bars[index]!.find('[style]').attributes('style') ?? ''
// 最大项占满、次项占一半
expect(width(0)).toContain('width: 100%')
expect(width(1)).toContain('width: 50%')
})
it('未完成的任务不进入分布', () => {
const wrapper = mountView([makeTask(null, { tags: ['还没做'] })])
expect(tagRows(wrapper)).toEqual([])
})
})
describe('统计视图 · 一些细节', () => {
it('没有完成记录时周期显示为占位符', () => {
const wrapper = mountView()
expect(cardValue(wrapper, '平均完成周期')).toBe('—')
expect(cardValue(wrapper, '最长连续')).toBe('0 天')
})
it('平均完成周期取中位数', () => {
const wrapper = mountView([
makeTask(localIso(2026, 10, 22, 0), { title: 'a', createdAt: localIso(2026, 10, 20, 0) }),
makeTask(localIso(2026, 10, 23, 0), { title: 'b', createdAt: localIso(2026, 10, 21, 0) }),
makeTask(localIso(2027, 4, 1, 0), { title: '躺了很久', createdAt: localIso(2026, 10, 1, 0) }),
])
// 跨度为 2、2、182 天,中位数 2;平均会被拉偏(60 天以上),所以不用平均
expect(cardValue(wrapper, '平均完成周期')).toBe('2.0 天')
})
it('当天完成显示「当天」而不是 0 天', () => {
const wrapper = mountView([
makeTask(localIso(2026, 10, 24, 18), { createdAt: localIso(2026, 10, 24, 9) }),
])
expect(cardValue(wrapper, '平均完成周期')).toBe('当天')
})
it('最长连续与有记录天数', () => {
const wrapper = mountView([
makeTask(localIso(2026, 10, 22)),
makeTask(localIso(2026, 10, 23), { title: 'b' }),
makeTask(localIso(2026, 10, 24), { title: 'c' }),
makeTask(localIso(2026, 10, 10), { title: 'd' }),
])
expect(cardValue(wrapper, '最长连续')).toBe('3 天')
expect(cardValue(wrapper, '有记录')).toBe('4 天')
})
it('平均每天按「有记录的天数」而不是自然日计算', () => {
const wrapper = mountView([
makeTask(localIso(2026, 10, 22)),
makeTask(localIso(2026, 10, 22), { title: 'b' }),
makeTask(localIso(2026, 10, 24), { title: 'c' }),
])
// 3 个任务分布在 2 天 → 1.5;不是 3 / 30 天
expect(cardValue(wrapper, '平均每天')).toBe('1.5 个')
})
})
+325
View File
@@ -0,0 +1,325 @@
<script setup lang="ts">
/**
* 统计视图:完成趋势与热力图(ADR-012)
*
* 与今日视图的分工:今日视图往前看(该做什么),这里往回看(做过什么)。
* 因此做成独立路由而不是塞进今日视图 —— 两者的信息密度和使用时机都不同。
*/
import dayjs from 'dayjs'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useTagColorStore } from '@/stores/tagColors'
import { useTaskStore } from '@/stores/tasks'
import {
breakdownByTag,
buildHeatmap,
completionCycle,
computeActivitySummary,
countCompletionsByDate,
DEFAULT_HEATMAP_WEEKS,
longestStreak,
UNTAGGED_LABEL,
type HeatmapLevel,
} from '@/utils/activity'
/**
* 只显示 周一 / 周三 / 周五,其余留空占位。
* 七个标签全写会挤成一列噪音,GitHub 的惯例只标奇数行。
*/
const WEEKDAY_LABELS = ['一', '', '三', '', '五', '', ''] as const
/**
* 强度色阶。必须是**完整类名字符串** —— Tailwind v4 构建时扫描源码,
* 动态拼接(`bg-emerald-${level}00`)不会被生成,样式会静默失效。
*
* 用 emerald 而不是蓝色:项目里「完成」一律是绿色,和看板已完成列的语义一致。
*/
const LEVEL_CLASSES: Record<HeatmapLevel, string> = {
0: 'bg-slate-100 dark:bg-zinc-800',
1: 'bg-emerald-200 dark:bg-emerald-900',
2: 'bg-emerald-300 dark:bg-emerald-700',
3: 'bg-emerald-400 dark:bg-emerald-600',
4: 'bg-emerald-500 dark:bg-emerald-400',
}
const LEVELS: readonly HeatmapLevel[] = [0, 1, 2, 3, 4]
const store = useTaskStore()
const tagColors = useTagColorStore()
const { activeTasks } = storeToRefs(store)
/** 只展示前 6 个标签,再多就成一张读不完的清单了 */
const TOP_TAGS = 6
/** 回收站里的任务不计入:软删除的语义是「这件事不算了」 */
const counts = computed(() => countCompletionsByDate(activeTasks.value))
const summary = computed(() => computeActivitySummary(counts.value))
const heatmap = computed(() => buildHeatmap(counts.value, { weeks: DEFAULT_HEATMAP_WEEKS }))
const cycle = computed(() => completionCycle(activeTasks.value))
const longest = computed(() => longestStreak(counts.value))
const tagBreakdown = computed(() => breakdownByTag(activeTasks.value).slice(0, TOP_TAGS))
/** 条长按最大项归一;全为 0 时兜底 1,避免除零 */
const tagMax = computed(() => Math.max(1, ...tagBreakdown.value.map((item) => item.count)))
const averagePerActiveDay = computed(() => {
const days = summary.value.activeDays
return days === 0 ? 0 : summary.value.total / days
})
/** 小数只保留一位;超过 10 天再带小数就没意义了 */
function formatDays(value: number): string {
return value < 10 ? value.toFixed(1) : String(Math.round(value))
}
const cycleLabel = computed(() => {
const stats = cycle.value
if (stats === null) return '—'
if (stats.median < 1) return '当天'
return `${formatDays(stats.median)} 天`
})
const detailCards = computed(() => [
{ label: '平均完成周期', value: cycleLabel.value },
{ label: '最长连续', value: `${longest.value} 天` },
{ label: '有记录', value: `${summary.value.activeDays} 天` },
{ label: '平均每天', value: `${formatDays(averagePerActiveDay.value)} 个` },
])
/** 标签条的颜色跟随标签圆点;未分类与未设色的标签用中性灰 */
function barClass(tag: string): string {
if (tag === UNTAGGED_LABEL) return 'bg-slate-400'
return tagColors.dotClassOf(tag) ?? 'bg-slate-400'
}
const cards = computed(() => [
{ label: '今天完成', value: summary.value.today, unit: '个', dot: 'bg-sky-500' },
{ label: '连续完成', value: summary.value.streak, unit: '天', dot: 'bg-emerald-500' },
{ label: '本月完成', value: summary.value.thisMonth, unit: '个', dot: 'bg-violet-500' },
{ label: '累计完成', value: summary.value.total, unit: '个', dot: 'bg-slate-400' },
])
const bestDayLabel = computed(() => {
const best = summary.value.bestDay
if (!best) return null
return `${dayjs(best.date).format('M月D日')} · ${best.count} 个`
})
/** 热力图的读屏摘要:几百个格子逐个念出来只会变成噪音,聚合到容器上 */
const heatmapLabel = computed(
() =>
`最近一年的完成热力图,` +
`有 ${summary.value.activeDays} 天产生过完成记录,累计 ${summary.value.total} 个任务`,
)
</script>
<template>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div class="mx-auto flex w-full max-w-4xl flex-col gap-5 p-4 md:p-6">
<header class="flex flex-col gap-0.5">
<h1
class="text-xl font-semibold tracking-tight text-slate-900 md:text-2xl dark:text-zinc-100"
>
统计
</h1>
<p class="text-sm text-slate-500 dark:text-zinc-400">
基于任务的完成时间统计,不含回收站里的任务
</p>
</header>
<dl class="grid grid-cols-2 gap-3 md:grid-cols-4">
<div
v-for="card in cards"
:key="card.label"
class="rounded-xl border border-slate-200 bg-white px-4 py-3.5 shadow-card dark:border-zinc-800 dark:bg-zinc-900"
>
<dt class="flex items-center gap-1.5 text-xs text-slate-500 dark:text-zinc-400">
<span class="size-1.5 rounded-full" :class="card.dot" aria-hidden="true" />
{{ card.label }}
</dt>
<dd
class="mt-1.5 text-2xl font-bold tracking-tight tabular-nums text-slate-900 dark:text-zinc-100"
>
{{ card.value
}}<span class="ml-0.5 text-sm font-normal text-slate-400 dark:text-zinc-500">{{
card.unit
}}</span>
</dd>
</div>
</dl>
<section
class="rounded-xl border border-slate-200 bg-white px-4 py-4 shadow-card dark:border-zinc-800 dark:bg-zinc-900"
>
<div class="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<h2 class="text-sm font-medium text-slate-900 dark:text-zinc-100">
完成热力图
<span class="ml-1 text-xs font-normal text-slate-400 dark:text-zinc-500"> 近一年 </span>
</h2>
<div class="flex items-center gap-1 text-[10px] text-slate-400 dark:text-zinc-500">
<span>少</span>
<span
v-for="level in LEVELS"
:key="level"
class="size-[11px] rounded-[2px]"
:class="LEVEL_CLASSES[level]"
aria-hidden="true"
/>
<span>多</span>
</div>
</div>
<!--
格子固定 11px(压到 8px 以下就分不清了),多出来的宽度交给列间距:
周列容器用 `flex-1` 占满剩余宽度,`justify-between` 把富余空间均分到 52 个间隙。
宽屏下间隙从 3px 涨到 4.1px,整块热力图铺满卡片、左右不再偏斜。
`pr-[15px]` 是左侧星期标签列的镜像(`w-3` 12px + 父级 gap 3px):
补上它,方块阵列距内容左右边界各 15px,视觉才真正对称;
同时也给最后一列今天那格的 `ring-1` 留出位置 —— ring 画在元素外侧 1px,
贴到 overflow 容器边界上会被裁掉(实测超出 1px,右侧描边缺一条)。
不能靠 `mx-auto` 居中:`inline-flex` 是行内级元素,auto margin 在水平方向
按 0 处理,实测 814 的容器里左边贴 15px、右边空 60px,整块偏左。
-->
<div class="overflow-x-auto pt-[1px] pb-1" role="img" :aria-label="heatmapLabel">
<div class="flex gap-[3px] pr-[15px]">
<div class="mt-[17px] flex shrink-0 flex-col gap-[3px]" aria-hidden="true">
<span
v-for="(label, index) in WEEKDAY_LABELS"
:key="index"
class="h-[11px] w-3 text-[10px] leading-[11px] text-slate-400 dark:text-zinc-500"
>
{{ label }}
</span>
</div>
<!-- 53 列 × 11px + 52 × 3px = 739px:容器窄于它时保持最小间隙,让外层滚动 -->
<div
class="flex min-w-0 flex-1 justify-between"
:style="{ minWidth: `${heatmap.length * 14 - 3}px` }"
>
<!--
月份标签必须绝对定位:它是 whitespace-nowrap 的文字,宽度远超 11px 的格子,
留在流内会把所在列撑宽 —— 实测 53 列会被撑到 828px,容器只有 814px,
于是凭空多出一条滚动条。用 h-[14px] 的占位块保住行高即可。
-->
<div v-for="week in heatmap" :key="week.key" class="relative flex flex-col gap-[3px]">
<span
v-if="week.monthLabel"
class="absolute top-0 left-0 h-[14px] text-[10px] leading-[14px] whitespace-nowrap text-slate-400 dark:text-zinc-500"
>
{{ week.monthLabel }}
</span>
<div class="h-[14px]" aria-hidden="true" />
<div
v-for="cell in week.cells"
:key="cell.date"
data-role="heatmap-cell"
:data-date="cell.date"
:data-count="cell.count"
:data-level="cell.level"
class="size-[11px] rounded-[2px]"
:class="[
cell.future ? 'invisible' : LEVEL_CLASSES[cell.level],
cell.today ? 'ring-1 ring-slate-400 dark:ring-zinc-500' : '',
]"
:title="`${cell.date} · 完成 ${cell.count} 个`"
aria-hidden="true"
/>
</div>
</div>
</div>
</div>
<p class="mt-3 text-xs text-slate-400 dark:text-zinc-500">
<template v-if="summary.bestDay">最忙的一天:{{ bestDayLabel }}</template>
<template v-else>还没有完成记录 —— 把任务拖到「已完成」,这里就会亮起来。</template>
</p>
</section>
<div class="grid gap-3 md:grid-cols-2">
<section
class="flex flex-col rounded-xl border border-slate-200 bg-white px-4 py-4 shadow-card dark:border-zinc-800 dark:bg-zinc-900"
>
<div class="mb-3 flex items-baseline justify-between gap-2">
<h2 class="text-sm font-medium text-slate-900 dark:text-zinc-100">时间花在哪</h2>
<span class="text-[10px] text-slate-400 dark:text-zinc-500">
按标签 · 前 {{ TOP_TAGS }} 项
</span>
</div>
<p v-if="tagBreakdown.length === 0" class="py-8 text-center text-xs text-slate-400 dark:text-zinc-500">
还没有带标签的完成记录
</p>
<ul v-else class="flex flex-col gap-2.5">
<li
v-for="item in tagBreakdown"
:key="item.tag"
data-role="tag-bar"
:data-tag="item.tag"
:data-count="item.count"
>
<div class="flex items-baseline justify-between gap-2 text-xs">
<span class="flex min-w-0 items-center gap-1.5 text-slate-600 dark:text-zinc-300">
<span
class="size-1.5 shrink-0 rounded-full"
:class="barClass(item.tag)"
aria-hidden="true"
/>
<span class="truncate">#{{ item.tag }}</span>
</span>
<span class="shrink-0 tabular-nums text-slate-400 dark:text-zinc-500">
{{ item.count }}
</span>
</div>
<div class="mt-1 h-1.5 overflow-hidden rounded-full bg-slate-100 dark:bg-zinc-800">
<div
class="h-full rounded-full"
:class="barClass(item.tag)"
:style="{ width: `${(item.count / tagMax) * 100}%` }"
/>
</div>
</li>
</ul>
<p class="mt-auto pt-3 text-[10px] leading-relaxed text-slate-400 dark:text-zinc-500">
一个任务有多个标签时,每个标签各计一次。
</p>
</section>
<section
class="flex flex-col rounded-xl border border-slate-200 bg-white px-4 py-4 shadow-card dark:border-zinc-800 dark:bg-zinc-900"
>
<h2 class="mb-3 text-sm font-medium text-slate-900 dark:text-zinc-100">一些细节</h2>
<dl class="grid grid-cols-2 gap-x-3 gap-y-4">
<div v-for="item in detailCards" :key="item.label">
<dt class="text-xs text-slate-500 dark:text-zinc-400">{{ item.label }}</dt>
<dd
class="mt-1 text-lg font-semibold tracking-tight tabular-nums text-slate-900 dark:text-zinc-100"
>
{{ item.value }}
</dd>
</div>
</dl>
<p class="mt-auto pt-3 text-[10px] leading-relaxed text-slate-400 dark:text-zinc-500">
完成周期取中位数 —— 一条躺了半年的任务就能把平均值拉偏,中位数描述的是「通常要多久」。
</p>
</section>
</div>
<p class="text-xs leading-relaxed text-slate-400 dark:text-zinc-500">
统计只根据任务上记录的完成时间推算。永久删除或清空回收站会连同那天的记录一起消失,
因此这里的数字通常比实际做过的偏少 —— 宁可保守,也不维护一份会与任务对不上的计数。
</p>
</div>
</div>
</template>