Files
Kanban/docs/adr.md
T
chenshoulin 777815704d
CI / verify (push) Canceled after 10s
docs: 更新 README 与 ADR-007
- README 补充回收站、批量编辑、合并导入、标签管理与 schema v3 说明
- 新增 ADR-007,记录软删除、可见性矩阵与合并冲突策略的取舍
2026-10-19 18:33:00 +08:00

206 lines
12 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 撤销。
- _合并时对整列 `sortOrder` 做 normalize_:会悄悄重排用户已有数据。新增项追加列尾已经足够,
既有冲突留给后续显式整理。
- _给回收站做搜索 / 筛选_:列表按删除时间倒序已经能覆盖「刚删错」这个主场景,先不加。
**影响**
- 数据面变大:回收站会占用 localStorage 配额,靠 30 天自动清理与手动清空兜底;导出文件也会包含
已删除任务,体积略增。
- 所有「看板语义」的读取点必须走 `activeTasks`。新增读取点时若直接用 `tasks`,会把已删除任务
泄漏到 UI;可见性矩阵已写进测试(列、统计、标签、今日视图、归档候选)。
- 迁移链新增 `2 → 3`,且 `parsePersistedState` / 快照恢复共用同一套解析,旧备份仍可读。
- 详情抽屉的编辑对象改为在 `activeTasks` 里查找,删除后抽屉会立即关闭,而不是停留在一条已删除的任务上。