# 架构决策记录(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` 里查找,删除后抽屉会立即关闭,而不是停留在一条已删除的任务上。 --- ## 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` 表达,再引入第二种排序会让 「看到的顺序」与「拖拽写回的顺序」产生两个真相;当前也没有对应使用场景。 - _用相对时间(「3 天前」)_:文案依赖渲染时刻,页面开一整天就会和实际完成日对不上; 复盘场景下具体日期更有用。 - _提升 `schemaVersion` 做迁移_:字段早已存在,且 `coerceTask` 已把它们收敛为字符串或 `null`,无需迁移。 **影响** - 时间展示纯只读;`completedAt` 的写入仍收敛在状态流转处 —— 单条 / 批量编辑走 `applyTaskPatch`, 拖拽换列走 `moveTask`,两者规则一致(进入「已完成」写时间、离开即清空),展示值与状态不会互相矛盾。 - 新增两个纯函数与对应单测;`TrashPanel` 少了一个私有格式化函数。 - 卡片 `title` 是纯提示信息,不参与可访问性命名(`aria-label` 仅在多选态给出),读屏语义不变。