Files
Kanban/docs/adr.md
T
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

478 lines
33 KiB
Markdown
Raw 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.
# 架构决策记录(ADR)
本文件记录个人看板工具 MVP 的关键技术决策。每条包含:背景、结论、被否决的方案及原因。
---
## ADR-001 · 看板列固定为三列,不支持配置
**背景**
初版设计在设置页提供「列配置」,但数据模型中 `status` 为固定枚举 `todo | doing | done`,两者互相矛盾。
**结论**
MVP 固定三列:`todo`(待办)/ `doing`(进行中)/ `done`(已完成)。`status` 使用 TypeScript 字面量联合类型,**不使用 `enum`**(同时满足 `erasableSyntaxOnly` 编译约束)。
**被否决的方案**
- _引入 `columns` 表 + `task.columnId`_:需要额外的列 CRUD、列删除时的任务迁移、列顺序持久化。对单人任务管理而言,收益为零,成本约为原方案的两倍。
- _用 `enum` 定义列_:与 `erasableSyntaxOnly: true` 冲突,且 `enum` 在纯类型场景下没有优势。
**影响**
若未来确需自定义列,需一次性迁移 `status` → `columnId`,并在 `schemaVersion` 中登记。
---
## ADR-002 · 存储采用 `TaskRepository` 接口 + localStorage 实现
**背景**
初版方案直接选用 Dexie(IndexedDB)。但个人看板在 200 个任务规模下,数据量约 60KB,远低于 localStorage 5MB 上限。
**结论**
定义 `TaskRepository` 接口(`list / get / create / update / remove / replaceAll`),MVP 只提供 localStorage 实现。UI 与 Store 仅依赖接口,不感知具体实现。
**被否决的方案**
- _直接用 Dexie_:收益是索引查询与大容量,在该规模下用不上;代价是 Pinia 与 DB 双份状态、异步竞态、多标签页同步、测试需引入 `fake-indexeddb`,约多花 1.5 人天。
- _直接在组件里读写 localStorage_:数据访问散落各处,无法测试,也无法替换实现。
**影响**
数据量增长到万级或需要复杂查询时,新增一个 Dexie 实现并切换注入即可,接口不变。
---
## ADR-003 · `dueDate` 只表示日期,不表示时刻
**背景**
`dueDate` 若同时承载日期与时刻,需要处理时区转换(本机为 Asia/Hong_Kong),并会在「今天到期」的判定上产生歧义。
**结论**
`dueDate` 类型为 `string | null`,格式固定 `YYYY-MM-DD`,不含时刻与时区信息。「是否逾期」以本地日历日比较得出。
**被否决的方案**
- _存储 ISO 8601 完整时间戳_:需要引入时区处理,且个人看板不需要精确到分钟的截止时间。
- _存储 `Date` 对象_:JSON 序列化后仍会退化为字符串,且容易在反序列化时引入时区偏移。
**影响**
若未来需要提醒功能,应新增独立字段(如 `remindAt`),而不是改变 `dueDate` 的语义。
---
## ADR-004 · 不引入 vue-router
> **状态:已被 ADR-005 取代。** 本决策在 MVP 阶段成立;当「今日视图」真的落地后,
> 它预判的触发条件("若未来增加今日视图、统计等真正的独立页面")已经满足。
**背景**
初版信息架构包含「主看板页」「任务详情」「设置页」三个页面。
**结论**
MVP 为单视图应用:主看板常驻,任务详情使用 Drawer,导入导出与快捷键说明使用 Modal。不引入 `vue-router`。
**被否决的方案**
- _引入 vue-router_:三个「页面」中有两个本质是覆盖层(overlay),用路由表达会让 URL 与状态同步复杂化,且多一个运行时依赖。
- _用 URL query 承载筛选状态_:MVP 不做分享与深链需求,暂不需要。
**影响**
若未来增加「今日视图」「统计」等真正的独立页面,再引入路由;届时筛选状态可一并迁移到 query。
---
## ADR-005 · 引入 vue-router,拆分看板与今日两个视图
**背景**
ADR-004 决定 MVP 不引入路由,并注明「若未来增加今日视图、统计等真正的独立页面,再引入路由」。
现在补做了「今日视图 + 简单统计」,触发条件成立。
「今日视图」与看板的关系值得说清楚:它不是看板的另一种筛选,而是**换了一个组织维度** ——
看板按「状态」组织(待办/进行中/已完成),今日视图按「紧急度」组织(逾期 → 今天到期 → 进行中)。
同一批数据,两种视角,用户需要在它们之间来回切换,而不是叠加在同一屏里。
**结论**
引入 `vue-router`,两条路由:
- `/` — 看板
- `/today` — 今日视图(含统计卡片与按紧急度排序的待办列表)
使用 `createWebHistory`(而非 hash),并导出 `createAppRouter(history)` 工厂函数而非单例,
以便测试注入 memory history。
**被否决的方案**
- _把今日视图做成一个 Drawer / Modal_:Drawer 适合「聚焦单条记录」(任务详情、备份面板),
不适合承载一个完整的列表视图;而且它无法被收藏或分享,PWA 的 manifest shortcuts 也无处可指。
- _在工具栏加一个「今日」筛选按钮_:那只是筛选,不是视图。用户仍然面对三列结构,
还得自己在心里把「逾期」和「进行中」拼起来。
- _用 hash 路由避免服务器配置_:URL 会变成 `/#/today`,不美观,且 PWA 的 `start_url`
与 shortcuts 指向带 hash 的地址会让某些平台的安装体验变差。代价是部署时必须配置 SPA 回落 ——
这一点已在 README 的部署章节明确写出。
**影响**
- **部署要求变化**:服务器必须把未知路径回落到 `index.html`,否则直接访问 `/today` 会 404。
README 已给出 Nginx 与 Netlify 的配置。
- 今日视图按路由懒加载,不进入首屏包(独立 chunk 约 4KB)。
- Service Worker 的 `navigateFallback` 设为 `index.html`,离线时深链 `/today` 同样可用。
- 看板原有的筛选状态仍留在 Pinia 中,未迁移到 query —— 目前没有分享带筛选链接的需求,
迁移只会增加复杂度。
---
## ADR-006 · 覆盖式操作前自动快照,归档任务提供恢复入口
**背景**
MVP 有「启动快照 + 手动导出」两道保护,但两个整份覆盖的动作没有退路:导入 JSON 与从快照恢复
都会直接替换当前数据。归档功能的设计说明写的是「可随时恢复」,实际只有归档后 5 秒内的 Toast
撤销能恢复,界面上找不到入口。另外,详情抽屉不能改状态、未保存的修改点一下遮罩就丢,
是日常使用里最高频的两处摩擦。
**结论**
1. `importJson` / `restoreSnapshot` 在覆盖前调用 `repository.snapshot(当前数据)`;
快照写入失败不阻断主流程。空看板不写,避免产生无意义备份。
2. 导入校验增加 id 唯一性检查:严格导入路径直接拒绝并指出重复的两条;
宽容读取路径保留先出现的那条,避免重复 id 破坏 `v-for` 与 `findIndex`。
3. 详情抽屉增加「状态」三列切换,并展示归档状态与「恢复」入口。
4. 详情抽屉维护 dirty 状态:有未保存修改时关闭需要二次确认,不再静默丢弃草稿。
5. Toast 自动关闭支持悬停/聚焦暂停,移开后按剩余时间继续;撤销不再「刚要点击就消失」。
6. 存储写失败时把 `persistent` 置为 false 并把提示提升到 App 级,两个视图都能看到告警。
**被否决的方案**
- _给删除加二次确认弹窗_:与现有「立即删除 + Toast 撤销」的约定冲突,且删除频率高,
弹窗会拖慢操作;改为延长撤销的可操作性(暂停计时)成本更低。
- _把归档任务从列内排序序列中剥离,让归档后排序仍可用_:需要同时改 `sortOrder` 计算、
拖拽下标映射与渲染顺序,收益仅是「已完成列可排序」。当前保留禁用策略,
把原因写进工具栏提示(「归档 N 个,列内排序已暂停」),让用户知道为什么拖不动。
- _导入后自动生成版本快照_:没有服务端与版本库,快照只是本地兜底,不应膨胀成版本历史。
**影响**
- 快照最多占用 4 份全量数据(主数据 + 3 份快照);任务量接近 localStorage 配额时
需要清理旧快照或按 ADR-002 迁移到 IndexedDB。
- `persistent` 现在会随写入成功/失败翻转,UI 可据此判断「改动是否真的落盘」。
- 关闭详情抽屉的默认行为从「直接关闭」变为「有改动先确认」,相关组件测试已同步调整。
- 新增 `.gitea/workflows/ci.yml`,把 lint / typecheck / test / build 纳入推送与合并请求检查。
---
## ADR-007 · 删除改为软删除,导入提供覆盖 / 合并两种模式
**背景**
删除此前只有 5 秒内的 Toast 撤销,超过窗口后只能去自动备份里碰运气;而多设备、多次导出之后,
覆盖式导入又只能二选一,「把两份数据拼起来」需要手工处理。本批同时解决这两件事,并顺带把多选
从「批量删除工具」升级为「批量编辑器」。
**结论**
1. `Task` 增加 `deletedAt`,`schemaVersion` 升到 3;删除写 `deletedAt`(软删除),任务进入回收站,
30 天后在启动时自动清理。永久删除只允许作用于回收站里的任务,且需要二次确认。
2. 软删除是**数据状态**而不是筛选维度:store 提供 `activeTasks`,列、计数、统计、今日视图、
标签下拉、归档候选全部消费它;`matchesFilter` 仍然只负责归档可见性。回收站数据由 `trashTasks`
单独提供,按删除时间倒序。
3. 恢复只清除 `deletedAt`,保留原 `sortOrder` 与 `archivedAt`,因此位置和归档态都会还原。
导出仍包含已删除任务,保证换设备后回收站也能带走。
4. 批量编辑与单条编辑共用 `applyTaskPatch`(`src/utils/taskPatch.ts`),状态进出「已完成」、
`updatedAt` 刷新、标签去重、标题回退只有一份规则。批量标签采用**增 / 删**语义,
避免统一加标签时把每条任务原有的其它标签抹掉。
5. 导入增加合并模式:按 `id` 匹配,同 id 以 `updatedAt` 较新的一方为准;文件新增项追加到对应列末尾
并重算 `sortOrder`;本地独有任务保留;两种模式执行前都调用同一套快照逻辑。
6. 标签治理(重命名 / 合并 / 删除)放在 `src/utils/tags.ts` 的纯函数里,store 只负责找出真正变化的
任务、刷新 `updatedAt` 并一次落盘。回收站里的任务不参与改写。
**被否决的方案**
- _把 `deletedAt` 塞进 `TaskFilter`_:回收站不是「筛掉一部分」,而是一条独立的数据状态。
放进筛选会让每个调用点都要记得带上「排除已删除」,漏一处就会把删除项泄漏到看板或统计里。
改为在 `activeTasks` 层统一剔除,失败模式从「N 处可能漏」收敛为「一处必须对」。
- _给删除加二次确认_:删除是高频操作,弹窗会拖慢节奏;软删除 + 回收站已经提供了比确认框更好的退路。
- _批量改属性做 `[{id, before}]` 快照式撤销_:需要额外的内存态与 UI 语义,收益低于成本;
标签是增删语义,改错了再改回来即可。批量删除 / 归档仍有 Toast 撤销。
(**已被 [ADR-010](#adr-010--引入全局撤销栈取代逐个操作做撤销) 推翻**:批量编辑器扩张到
四类属性之后,这个取舍不再成立;但实现方式改为整份快照,而非字段级 diff。)
- _合并时对整列 `sortOrder` 做 normalize_:会悄悄重排用户已有数据。新增项追加列尾已经足够,
既有冲突留给后续显式整理。
- _给回收站做搜索 / 筛选_:列表按删除时间倒序已经能覆盖「刚删错」这个主场景,先不加。
**影响**
- 数据面变大:回收站会占用 localStorage 配额,靠 30 天自动清理与手动清空兜底;导出文件也会包含
已删除任务,体积略增。
- 所有「看板语义」的读取点必须走 `activeTasks`。新增读取点时若直接用 `tasks`,会把已删除任务
泄漏到 UI;可见性矩阵已写进测试(列、统计、标签、今日视图、归档候选)。
- 迁移链新增 `2 → 3`,且 `parsePersistedState` / 快照恢复共用同一套解析,旧备份仍可读。
- 详情抽屉的编辑对象改为在 `activeTasks` 里查找,删除后抽屉会立即关闭,而不是停留在一条已删除的任务上。
---
## ADR-008 · 任务时间只读展示,不参与排序与筛选
**背景**
`Task` 从 T03 起就有 `createdAt` / `updatedAt` / `completedAt`,后者还承担「本周完成」统计与归档候选的判断,
但界面上没有任何入口能把它们读出来 —— 想知道任务哪天创建、哪天完成,只能导出 JSON 自己找。
时间字段是只写不读的,等于白存。
**结论**
1. 详情抽屉底部新增只读「时间」区块:创建 / 更新常驻,完成时间在 `completedAt` 为空时显示「未完成」,
归档任务追加一行归档时间。时间不属于可编辑字段,不进入草稿与 dirty 判定。
2. 已完成卡片在元信息行右侧显示 `完成于 M月D日`(跨年补年份);未完成卡片不渲染该行,避免给待办增加噪音。
3. 卡片根元素带 `title`,汇总创建 / 完成 / 归档时间(精确到分钟)。悬停即得,不占版面;
移动端与键盘用户由详情抽屉兜底。
4. 回收站条目同时显示创建时间与删除时间,原先内联的 `formatTime` 被 `@/utils/date` 的 `formatDateTime` 取代。
5. 格式化统一收敛到 `src/utils/date.ts`:`formatDateTime` 输出 `YYYY-MM-DD HH:mm`,`formatCompactDate`
按是否跨年输出 `M月D日` / `YYYY年M月D日`。两者对空值与非法时间戳返回 `null`,由调用方决定占位符
—— 界面上永远不会出现 `Invalid Date`。
**被否决的方案**
- _卡片同时显示创建时间_:待办卡片的元信息行已有优先级、截止日期与标签,再加一行会挤掉真正的决策信息。
创建时间对「先做哪件」几乎没有影响,放进 `title` 与详情抽屉足够。
- _增加「按创建 / 完成时间排序」开关_:完成列的顺序由手动 `sortOrder` 表达,再引入第二种排序会让
「看到的顺序」与「拖拽写回的顺序」产生两个真相;当前也没有对应使用场景。
(其中「完成列按完成时间排序」后来被 [ADR-009](#adr-009--已完成列改按完成时间倒序展示) 部分推翻。)
- _用相对时间(「3 天前」)_:文案依赖渲染时刻,页面开一整天就会和实际完成日对不上;
复盘场景下具体日期更有用。
- _提升 `schemaVersion` 做迁移_:字段早已存在,且 `coerceTask` 已把它们收敛为字符串或 `null`,无需迁移。
**影响**
- 时间展示纯只读;`completedAt` 的写入仍收敛在状态流转处 —— 单条 / 批量编辑走 `applyTaskPatch`,
拖拽换列走 `moveTask`,两者规则一致(进入「已完成」写时间、离开即清空),展示值与状态不会互相矛盾。
- 新增两个纯函数与对应单测;`TrashPanel` 少了一个私有格式化函数。
- 卡片 `title` 是纯提示信息,不参与可访问性命名(`aria-label` 仅在多选态给出),读屏语义不变。
---
## ADR-009 · 已完成列改按完成时间倒序展示
**背景**
ADR-008 否掉了「完成列按时间排序」,理由是维持单一顺序来源。但实际使用中这个取舍站不住:
完成任务时 `sortOrder` 是「追加到列尾」,于是**刚勾完的任务沉到列表最底下** ——
打开看板想问「我刚才做完什么」,得滚到列底;做完几十条之后,已完成列变成一条只有
「按完成先后升序」的考古记录。对已完成的任务来说,用户唯一关心的顺序就是完成时间。
**结论**
1. 已完成列的展示顺序由 `completedAt` 倒序决定,不再消费 `sortOrder`。
排序键收敛为一个纯函数 `sortCompletedDesc`(`src/utils/sortOrder.ts`),
转换只在 `BoardView` 的 `visibleColumns` 单点发生,store 与数据层语义不变。
2. 该列停用列内排序:拖拽与 `↑` `↓` 只允许跨列(`sortDisabled` 对 `done` 恒为 true),
避免写出「永远看不见结果」的 `sortOrder`。
3. 列头标注「按完成时间倒序」,把口径写在界面上,用户不会误以为拖不动是故障。
4. 不做数据迁移:`sortOrder`、导入导出、快照结构与 `schemaVersion` 全都不动。
`completedAt` 缺失的旧数据(手工导入、更早的备份)排在有完成时间的任务之后,
彼此之间退回 `sortOrder` 倒序,至少保持「新完成的在上面」。
**被否决的方案**
- _一次性按完成时间重排 `sortOrder`,新完成插列首_:需要迁移存量数据,且用户只要拖过一次,
顺序就再次和时间脱钩 —— 等于保留两个真相,还多了写操作。
- _只改插入位置(新完成插列首)_:改动最小,但存量列表仍是旧的升序,首屏出现
「新任务在顶、旧任务按升序续在后面」的断裂顺序。
- _加排序开关_:一个开关多一份状态、多一组需要测试的组合,而「已完成」只有一种合理顺序。
**影响**
- 落入已完成列的落点不再由拖拽位置决定(`completedAt` 刚写入即最新),这是预期行为。
- `store.byColumn.done` 仍是 `sortOrder` 语义,只有视图层按时间倒序;两者差异集中在
`BoardView.visibleColumns`,测试覆盖了「顺序与 `sortOrder` 无关」这一条。
- 已完成列成为只读顺序:拖拽、`↑` `↓`、批量移动(只能移出该列)都不会改变其内部顺序。
---
## ADR-010 · 引入全局撤销栈,取代逐个操作做撤销
**背景**
ADR-007 明确否决了「批量改属性做 `[{id, before}]` 快照式撤销」,理由是「需要额外的内存态与 UI 语义,
收益低于成本」。当时这个判断成立:批量编辑器只能改标签,改错了再改回来即可。
但现在批量编辑器已经能一次改动目标列、优先级、截止日期、归档状态四类属性 —— 一次误操作波及的是
几十条任务的多个字段,逐条手工回滚已不现实。同时 `Ctrl+Z` 是所有人对「撤销」的肌肉记忆,
按下去没反应本身就是一个缺陷。重新评估之后,结论反过来了。
**结论**
1. 撤销 / 重做基于**整份 `tasks` 数组快照**,而不是命令模式。`stores/tasks.ts` 的所有变更都是
不可变替换(`tasks.value = [...]`),未被改动的 Task 对象在相邻快照之间共享引用 ——
50 步历史的实际内存增量只是「被改过的那几个对象」,与任务总数无关。
2. 快照的采集点是 `commit()` 这唯一的写入出口:维护一个 `lastCommitted` 指针指向
「上一次提交后的状态」,`commit()` 时把它压栈并更新指针。**三十多个写方法一行都没改**,
因此不存在「新增操作忘了写 undo」这类漏点。
3. `undo()` / `redo()` 刻意**不走 `commit()`**:撤销本身不是一次新的业务变更。若也压栈,
用户按 `Ctrl+Z` 就会在两步之间来回跳。
4. `commit()` 同时清空 redo 栈 —— 撤销之后又做了新操作,原来那条重做分支已经与当前状态分叉。
5. 上限 50 步,超出丢弃最旧的;历史不落盘,刷新即清空。
6. 快捷键单开一条通道(`matchUndoShortcut`),不复用 `matchShortcut`:后者的语义是「裸键」,
带修饰键的组合一律交还浏览器(`Ctrl+F` 查找、`Cmd+R` 刷新)。把撤销混进去,这条放行规则就失效了。
7. 在输入框内不拦截 `Ctrl+Z`:编辑任务详情时应当撤销文字输入,而不是把整个任务的状态回滚。
8. 历史为空时弹一句短提示(2 秒);成功时不提示 —— 列表已经变了,本身就是反馈。
**被否决的方案**
- _命令模式(每个操作记录逆操作)_:需要为 `createTasks` / `updateTasks` / `moveTask` /
`archiveTasks` / `renameTag` 等三十多个方法各写一份逆操作,且之后每加一个写方法都要记得补。
快照方案把「必须做对」的点从 N 处收敛到 `commit()` 一处。
- _字段级 diff 栈_:字段之间有关联(`status` 与 `completedAt` 必须同步、换列要重算 `sortOrder`),
字段级还原极易留下不一致的中间态。整份快照天然自洽。
- _持久化撤销历史到 localStorage_:刷新后仍能撤销「上一次会话」的操作,会让历史与当前数据的
边界变模糊,也显著放大配额占用。编辑器的撤销本来就是会话级的。
- _复用 `matchShortcut` 处理 `Ctrl+Z`_:会迫使该函数区分「允许哪些修饰键」,`Ctrl+F` 之类的
放行规则随之含糊。
- _撤销时弹 Toast 说明撤销了什么_:需要给 `commit()` 加操作名参数,进而改动所有调用点;
收益只是「多说一句话」,而视觉变化已经足够。
**影响**
- `stores/tasks.ts` 新增 `undo` / `redo` / `canUndo` / `canRedo` / `resetHistory`;
`undoStack` / `redoStack` 只存在于内存。
- 跨标签页收到同步消息后(`reloadFromRepository`)调用 `resetHistory`:本页的历史已与最新数据分叉,
保留下来会在下次撤销时抹掉对方的改动。
- 启动时的过期回收站清理(`purgeExpiredTrash`)也走 `commit()`,因此在 `init()` 末尾显式
`resetHistory()`,避免「打开页面就能撤销一次数据清理」。
- ADR-007 中「_批量改属性不做撤销_」一条作废;其余结论(软删除、`activeTasks` 单一剔除点、
标签增删语义)不变。
---
## ADR-011 · 标签颜色独立存储,不进入任务数据
**背景**
「已知限制」里长期挂着一条「标签还没有颜色」。标签是纯字符串,在卡片上与优先级、截止日期挤在同一行,
标签一多就难以扫读,上色是最直接的缓解手段,成本也远低于子任务、多设备同步这类结构性改动。
真正的决策点不是「怎么上色」,而是**颜色存在哪里**:放进 `PersistedState`(跟随导出、快照、导入),
还是单独一个 localStorage 键。
**结论**
1. 颜色存独立的 `kanban.tagColors`,结构为 `{ [tag: string]: 色板id }`,**不进 `PersistedState`**,
因此 `schemaVersion` 保持 3,不需要任何迁移。
2. 存的是**色板 id**(`rose` / `sky` / …)而不是 class 字符串。以后调整视觉样式只改代码,不用动用户数据。
3. 色板固定 8 色,每个色板在源码里写**完整类名字符串**。Tailwind v4 构建时扫描源码,
动态拼接的 `bg-${color}-500` 不会被生成,样式会静默失效 —— 这是这个功能唯一的硬坑。
4. **未设置颜色的标签不画圆点**,也不在映射里占位。默认给每个标签一个灰点只是把噪音放大,等于没上色。
5. 标签治理(重命名 / 合并 / 删除)在 `stores/tasks.ts` 的三个方法里顺带调用颜色 store,
把颜色的键名一起搬走。放在这里而不是面板组件里,是为了以后新增一种治理操作时不会漏掉颜色迁移。
6. 目标标签已经有颜色时**保留目标的**:那是用户为它单独设过的,来源方更像「顺手被并过来」的一方。
7. 读写失败静默降级(隐私模式 / 配额满)。颜色丢了不影响任何任务,不值得弹告警打断使用。
**被否决的方案**
- _放进 `PersistedState`,跟随导出与快照_:好处是换设备能带走。代价是导入语义变浑 ——
覆盖导入会变成「连别人的审美一起覆盖」,合并导入又该不该合并颜色?为了让导出多带几个键,
引入一套「导入时忽略某字段」的特例,不划算。而且颜色是**可重建的**,任务不是。
- _允许任意取色器_:会带来对比度失控(深色模式尤甚)、需要对存储值额外校验,以及一堆无法被
Tailwind 静态扫描的动态类名。8 个手挑的色板足够区分,每档都验证过明暗两套主题。
- _卡片上用整块 chip 背景着色_:标签行与优先级、截止日期共处一行,加背景色会把行撑高,
也会与「红色 = 逾期」的语义打架。改成小圆点 + 文字保持中性,既区分了标签,又不干扰紧急度判断。
**影响**
- 新增 `src/utils/tagColors.ts`(纯逻辑)与 `src/stores/tagColors.ts`(持久化),共 41 个单测。
- `TaskCard` / `TagFilter` / `TagManagerPanel` 三处消费颜色;前两者改为依赖 store,
因此对应组件测试需要 `setActivePinia`。
- 标签圆点带 `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 的已知限制,也写在统计页脚注里 ——
用户看到数字变少时能立刻知道原因,而不是怀疑数据坏了。