- 新增 /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 的三入口断言。
478 lines
33 KiB
Markdown
478 lines
33 KiB
Markdown
# 架构决策记录(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 的已知限制,也写在统计页脚注里 ——
|
||
用户看到数字变少时能立刻知道原因,而不是怀疑数据坏了。
|