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
+93
View File
@@ -382,3 +382,96 @@ ADR-007 明确否决了「批量改属性做 `[{id, before}]` 快照式撤销」
- 标签圆点带 `data-role="tag-dot"`:卡片上本来就有别的圆点元素(状态点、勾选框),
用 `.rounded-full` 这类样式选择器会误伤,单测与 e2e 一律按 `data-role` 定位。
- 导出 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 的已知限制,也写在统计页脚注里 ——
用户看到数字变少时能立刻知道原因,而不是怀疑数据坏了。