25 KiB
架构决策记录(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 撤销能恢复,界面上找不到入口。另外,详情抽屉不能改状态、未保存的修改点一下遮罩就丢, 是日常使用里最高频的两处摩擦。
结论
importJson/restoreSnapshot在覆盖前调用repository.snapshot(当前数据); 快照写入失败不阻断主流程。空看板不写,避免产生无意义备份。- 导入校验增加 id 唯一性检查:严格导入路径直接拒绝并指出重复的两条;
宽容读取路径保留先出现的那条,避免重复 id 破坏
v-for与findIndex。 - 详情抽屉增加「状态」三列切换,并展示归档状态与「恢复」入口。
- 详情抽屉维护 dirty 状态:有未保存修改时关闭需要二次确认,不再静默丢弃草稿。
- Toast 自动关闭支持悬停/聚焦暂停,移开后按剩余时间继续;撤销不再「刚要点击就消失」。
- 存储写失败时把
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 撤销,超过窗口后只能去自动备份里碰运气;而多设备、多次导出之后, 覆盖式导入又只能二选一,「把两份数据拼起来」需要手工处理。本批同时解决这两件事,并顺带把多选 从「批量删除工具」升级为「批量编辑器」。
结论
Task增加deletedAt,schemaVersion升到 3;删除写deletedAt(软删除),任务进入回收站, 30 天后在启动时自动清理。永久删除只允许作用于回收站里的任务,且需要二次确认。- 软删除是数据状态而不是筛选维度:store 提供
activeTasks,列、计数、统计、今日视图、 标签下拉、归档候选全部消费它;matchesFilter仍然只负责归档可见性。回收站数据由trashTasks单独提供,按删除时间倒序。 - 恢复只清除
deletedAt,保留原sortOrder与archivedAt,因此位置和归档态都会还原。 导出仍包含已删除任务,保证换设备后回收站也能带走。 - 批量编辑与单条编辑共用
applyTaskPatch(src/utils/taskPatch.ts),状态进出「已完成」、updatedAt刷新、标签去重、标题回退只有一份规则。批量标签采用增 / 删语义, 避免统一加标签时把每条任务原有的其它标签抹掉。 - 导入增加合并模式:按
id匹配,同 id 以updatedAt较新的一方为准;文件新增项追加到对应列末尾 并重算sortOrder;本地独有任务保留;两种模式执行前都调用同一套快照逻辑。 - 标签治理(重命名 / 合并 / 删除)放在
src/utils/tags.ts的纯函数里,store 只负责找出真正变化的 任务、刷新updatedAt并一次落盘。回收站里的任务不参与改写。
被否决的方案
- 把
deletedAt塞进TaskFilter:回收站不是「筛掉一部分」,而是一条独立的数据状态。 放进筛选会让每个调用点都要记得带上「排除已删除」,漏一处就会把删除项泄漏到看板或统计里。 改为在activeTasks层统一剔除,失败模式从「N 处可能漏」收敛为「一处必须对」。 - 给删除加二次确认:删除是高频操作,弹窗会拖慢节奏;软删除 + 回收站已经提供了比确认框更好的退路。
- 批量改属性做
[{id, before}]快照式撤销:需要额外的内存态与 UI 语义,收益低于成本; 标签是增删语义,改错了再改回来即可。批量删除 / 归档仍有 Toast 撤销。 (已被 ADR-010 推翻:批量编辑器扩张到 四类属性之后,这个取舍不再成立;但实现方式改为整份快照,而非字段级 diff。) - 合并时对整列
sortOrder做 normalize:会悄悄重排用户已有数据。新增项追加列尾已经足够, 既有冲突留给后续显式整理。 - 给回收站做搜索 / 筛选:列表按删除时间倒序已经能覆盖「刚删错」这个主场景,先不加。
影响
- 数据面变大:回收站会占用 localStorage 配额,靠 30 天自动清理与手动清空兜底;导出文件也会包含 已删除任务,体积略增。
- 所有「看板语义」的读取点必须走
activeTasks。新增读取点时若直接用tasks,会把已删除任务 泄漏到 UI;可见性矩阵已写进测试(列、统计、标签、今日视图、归档候选)。 - 迁移链新增
2 → 3,且parsePersistedState/ 快照恢复共用同一套解析,旧备份仍可读。 - 详情抽屉的编辑对象改为在
activeTasks里查找,删除后抽屉会立即关闭,而不是停留在一条已删除的任务上。
ADR-008 · 任务时间只读展示,不参与排序与筛选
背景
Task 从 T03 起就有 createdAt / updatedAt / completedAt,后者还承担「本周完成」统计与归档候选的判断,
但界面上没有任何入口能把它们读出来 —— 想知道任务哪天创建、哪天完成,只能导出 JSON 自己找。
时间字段是只写不读的,等于白存。
结论
- 详情抽屉底部新增只读「时间」区块:创建 / 更新常驻,完成时间在
completedAt为空时显示「未完成」, 归档任务追加一行归档时间。时间不属于可编辑字段,不进入草稿与 dirty 判定。 - 已完成卡片在元信息行右侧显示
完成于 M月D日(跨年补年份);未完成卡片不渲染该行,避免给待办增加噪音。 - 卡片根元素带
title,汇总创建 / 完成 / 归档时间(精确到分钟)。悬停即得,不占版面; 移动端与键盘用户由详情抽屉兜底。 - 回收站条目同时显示创建时间与删除时间,原先内联的
formatTime被@/utils/date的formatDateTime取代。 - 格式化统一收敛到
src/utils/date.ts:formatDateTime输出YYYY-MM-DD HH:mm,formatCompactDate按是否跨年输出M月D日/YYYY年M月D日。两者对空值与非法时间戳返回null,由调用方决定占位符 —— 界面上永远不会出现Invalid Date。
被否决的方案
- 卡片同时显示创建时间:待办卡片的元信息行已有优先级、截止日期与标签,再加一行会挤掉真正的决策信息。
创建时间对「先做哪件」几乎没有影响,放进
title与详情抽屉足够。 - 增加「按创建 / 完成时间排序」开关:完成列的顺序由手动
sortOrder表达,再引入第二种排序会让 「看到的顺序」与「拖拽写回的顺序」产生两个真相;当前也没有对应使用场景。 (其中「完成列按完成时间排序」后来被 ADR-009 部分推翻。) - 用相对时间(「3 天前」):文案依赖渲染时刻,页面开一整天就会和实际完成日对不上; 复盘场景下具体日期更有用。
- 提升
schemaVersion做迁移:字段早已存在,且coerceTask已把它们收敛为字符串或null,无需迁移。
影响
- 时间展示纯只读;
completedAt的写入仍收敛在状态流转处 —— 单条 / 批量编辑走applyTaskPatch, 拖拽换列走moveTask,两者规则一致(进入「已完成」写时间、离开即清空),展示值与状态不会互相矛盾。 - 新增两个纯函数与对应单测;
TrashPanel少了一个私有格式化函数。 - 卡片
title是纯提示信息,不参与可访问性命名(aria-label仅在多选态给出),读屏语义不变。
ADR-009 · 已完成列改按完成时间倒序展示
背景
ADR-008 否掉了「完成列按时间排序」,理由是维持单一顺序来源。但实际使用中这个取舍站不住:
完成任务时 sortOrder 是「追加到列尾」,于是刚勾完的任务沉到列表最底下 ——
打开看板想问「我刚才做完什么」,得滚到列底;做完几十条之后,已完成列变成一条只有
「按完成先后升序」的考古记录。对已完成的任务来说,用户唯一关心的顺序就是完成时间。
结论
- 已完成列的展示顺序由
completedAt倒序决定,不再消费sortOrder。 排序键收敛为一个纯函数sortCompletedDesc(src/utils/sortOrder.ts), 转换只在BoardView的visibleColumns单点发生,store 与数据层语义不变。 - 该列停用列内排序:拖拽与
↑↓只允许跨列(sortDisabled对done恒为 true), 避免写出「永远看不见结果」的sortOrder。 - 列头标注「按完成时间倒序」,把口径写在界面上,用户不会误以为拖不动是故障。
- 不做数据迁移:
sortOrder、导入导出、快照结构与schemaVersion全都不动。completedAt缺失的旧数据(手工导入、更早的备份)排在有完成时间的任务之后, 彼此之间退回sortOrder倒序,至少保持「新完成的在上面」。
被否决的方案
- 一次性按完成时间重排
sortOrder,新完成插列首:需要迁移存量数据,且用户只要拖过一次, 顺序就再次和时间脱钩 —— 等于保留两个真相,还多了写操作。 - 只改插入位置(新完成插列首):改动最小,但存量列表仍是旧的升序,首屏出现 「新任务在顶、旧任务按升序续在后面」的断裂顺序。
- 加排序开关:一个开关多一份状态、多一组需要测试的组合,而「已完成」只有一种合理顺序。
影响
- 落入已完成列的落点不再由拖拽位置决定(
completedAt刚写入即最新),这是预期行为。 store.byColumn.done仍是sortOrder语义,只有视图层按时间倒序;两者差异集中在BoardView.visibleColumns,测试覆盖了「顺序与sortOrder无关」这一条。- 已完成列成为只读顺序:拖拽、
↑↓、批量移动(只能移出该列)都不会改变其内部顺序。
ADR-010 · 引入全局撤销栈,取代逐个操作做撤销
背景
ADR-007 明确否决了「批量改属性做 [{id, before}] 快照式撤销」,理由是「需要额外的内存态与 UI 语义,
收益低于成本」。当时这个判断成立:批量编辑器只能改标签,改错了再改回来即可。
但现在批量编辑器已经能一次改动目标列、优先级、截止日期、归档状态四类属性 —— 一次误操作波及的是
几十条任务的多个字段,逐条手工回滚已不现实。同时 Ctrl+Z 是所有人对「撤销」的肌肉记忆,
按下去没反应本身就是一个缺陷。重新评估之后,结论反过来了。
结论
- 撤销 / 重做基于整份
tasks数组快照,而不是命令模式。stores/tasks.ts的所有变更都是 不可变替换(tasks.value = [...]),未被改动的 Task 对象在相邻快照之间共享引用 —— 50 步历史的实际内存增量只是「被改过的那几个对象」,与任务总数无关。 - 快照的采集点是
commit()这唯一的写入出口:维护一个lastCommitted指针指向 「上一次提交后的状态」,commit()时把它压栈并更新指针。三十多个写方法一行都没改, 因此不存在「新增操作忘了写 undo」这类漏点。 undo()/redo()刻意不走commit():撤销本身不是一次新的业务变更。若也压栈, 用户按Ctrl+Z就会在两步之间来回跳。commit()同时清空 redo 栈 —— 撤销之后又做了新操作,原来那条重做分支已经与当前状态分叉。- 上限 50 步,超出丢弃最旧的;历史不落盘,刷新即清空。
- 快捷键单开一条通道(
matchUndoShortcut),不复用matchShortcut:后者的语义是「裸键」, 带修饰键的组合一律交还浏览器(Ctrl+F查找、Cmd+R刷新)。把撤销混进去,这条放行规则就失效了。 - 在输入框内不拦截
Ctrl+Z:编辑任务详情时应当撤销文字输入,而不是把整个任务的状态回滚。 - 历史为空时弹一句短提示(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 键。
结论
- 颜色存独立的
kanban.tagColors,结构为{ [tag: string]: 色板id },不进PersistedState, 因此schemaVersion保持 3,不需要任何迁移。 - 存的是色板 id(
rose/sky/ …)而不是 class 字符串。以后调整视觉样式只改代码,不用动用户数据。 - 色板固定 8 色,每个色板在源码里写完整类名字符串。Tailwind v4 构建时扫描源码,
动态拼接的
bg-${color}-500不会被生成,样式会静默失效 —— 这是这个功能唯一的硬坑。 - 未设置颜色的标签不画圆点,也不在映射里占位。默认给每个标签一个灰点只是把噪音放大,等于没上色。
- 标签治理(重命名 / 合并 / 删除)在
stores/tasks.ts的三个方法里顺带调用颜色 store, 把颜色的键名一起搬走。放在这里而不是面板组件里,是为了以后新增一种治理操作时不会漏掉颜色迁移。 - 目标标签已经有颜色时保留目标的:那是用户为它单独设过的,来源方更像「顺手被并过来」的一方。
- 读写失败静默降级(隐私模式 / 配额满)。颜色丢了不影响任何任务,不值得弹告警打断使用。
被否决的方案
- 放进
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 的已知限制已写明。