feat: 个人轻量看板工具 MVP

从零实现一个 local-first 的个人任务看板,数据只存在浏览器本地,不依赖后端。

功能
- 固定三列看板(待办 / 进行中 / 已完成),支持拖拽改状态与列内排序
- 快速新增、详情编辑、删除撤销、已完成归档
- 搜索与筛选(关键词 / 优先级 / 标签 / 今日到期 / 逾期)
- 今日视图:按紧急度分组(逾期 > 今天到期 > 进行中)+ 统计卡片
- 导出导入 JSON;启动时自动留快照,保留最近 3 份
- 键盘快捷键(N / 「/」/ ? / Esc)
- PWA:可安装、离线可用、更新提示

技术要点
- 持久化采用 TaskRepository 端口 + 整份快照读写,schemaVersion 迁移表
  显式登记升级规则(当前 v2,v1 -> v2 为归档功能补 archivedAt)
- 同列排序用相邻中点插入,间距不足时按列 normalize,避免浮点精度耗尽
- 拖拽只消费 Sortable 的事件,store 保持唯一数据源,不接管数据
- 数据损坏时先备份到 *.corrupt.<时间戳> 再报错,不覆盖用户数据
- 引入 vue-router 拆分看板与今日两个视图(见 docs/adr.md ADR-005)

验证
- 单元测试 437 个(33 个文件),行覆盖率 96.8%
- 端到端测试 23 个(Playwright),含真实拖拽与断网离线场景
- npm run build 通过,产物可静态托管

注:部署时需配置 SPA 回落,否则直接访问 /today 会 404(详见 README)。
This commit is contained in:
chenshoulin
2026-10-18 11:59:49 +08:00
commit 4e064e82ea
98 changed files with 20084 additions and 0 deletions
+262
View File
@@ -0,0 +1,262 @@
import { describe, expect, it } from 'vitest'
import {
DataValidationError,
SCHEMA_VERSION,
coerceTask,
coerceTaskList,
isValidDateString,
parseImportJson,
parsePersistedState,
serializeState,
validateImportPayload,
} from './schema'
function validTask(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'task-1',
title: '写方案',
description: '细节',
status: 'todo',
priority: 2,
tags: ['设计'],
dueDate: '2026-10-20',
sortOrder: 1024,
createdAt: '2026-10-18T00:00:00.000Z',
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
...overrides,
}
}
describe('isValidDateString', () => {
it('接受合法日期', () => {
expect(isValidDateString('2026-10-18')).toBe(true)
expect(isValidDateString('2024-02-29')).toBe(true)
})
it('拒绝格式不对的值', () => {
expect(isValidDateString('2026-1-1')).toBe(false)
expect(isValidDateString('2026/10/18')).toBe(false)
expect(isValidDateString('')).toBe(false)
expect(isValidDateString(20261018)).toBe(false)
expect(isValidDateString(null)).toBe(false)
})
it('拒绝「格式正确但日期不存在」的值', () => {
expect(isValidDateString('2026-02-31')).toBe(false)
expect(isValidDateString('2026-13-01')).toBe(false)
expect(isValidDateString('2025-02-29')).toBe(false)
})
})
describe('coerceTask', () => {
it('接受完整合法任务', () => {
expect(coerceTask(validTask())).toEqual(validTask())
})
it('缺少 id 或 title 时返回 null', () => {
expect(coerceTask(validTask({ id: undefined }))).toBeNull()
expect(coerceTask(validTask({ title: ' ' }))).toBeNull()
expect(coerceTask(validTask({ title: 123 }))).toBeNull()
})
it('status 或 priority 非法时返回 null', () => {
expect(coerceTask(validTask({ status: 'archived' }))).toBeNull()
expect(coerceTask(validTask({ priority: 9 }))).toBeNull()
expect(coerceTask(validTask({ priority: '2' }))).toBeNull()
})
it('非对象一律返回 null', () => {
expect(coerceTask(null)).toBeNull()
expect(coerceTask('task')).toBeNull()
expect(coerceTask([])).toBeNull()
})
it('可选项缺失时补默认值', () => {
const result = coerceTask({
id: 'a',
title: '只有必填项',
status: 'doing',
priority: 1,
})
expect(result).not.toBeNull()
expect(result?.description).toBe('')
expect(result?.tags).toEqual([])
expect(result?.dueDate).toBeNull()
expect(result?.sortOrder).toBe(0)
expect(result?.completedAt).toBeNull()
expect(result?.createdAt).toBeTruthy()
})
it('非法 dueDate 收敛为 null,非法 sortOrder 收敛为 0', () => {
const result = coerceTask(validTask({ dueDate: '2026-02-31', sortOrder: 'abc' }))
expect(result?.dueDate).toBeNull()
expect(result?.sortOrder).toBe(0)
})
it('tags 过滤掉非字符串与空白项', () => {
const result = coerceTask(validTask({ tags: ['ok', 1, null, ' ', 'ok2'] }))
expect(result?.tags).toEqual(['ok', 'ok2'])
})
it('非法 archivedAt 收敛为 null', () => {
expect(coerceTask(validTask({ archivedAt: 123 }))?.archivedAt).toBeNull()
expect(coerceTask(validTask({ archivedAt: null }))?.archivedAt).toBeNull()
})
it('保留合法的 archivedAt', () => {
expect(coerceTask(validTask({ archivedAt: '2026-10-01T00:00:00.000Z' }))?.archivedAt).toBe(
'2026-10-01T00:00:00.000Z',
)
})
})
describe('coerceTaskList', () => {
it('跳过损坏条目但保留合法条目', () => {
const list = coerceTaskList([validTask(), { id: 'broken' }, validTask({ id: 'task-2' })])
expect(list.map((task) => task.id)).toEqual(['task-1', 'task-2'])
})
it('非数组返回空数组', () => {
expect(coerceTaskList(null)).toEqual([])
expect(coerceTaskList({})).toEqual([])
})
})
describe('parsePersistedState', () => {
it('空值返回空看板', () => {
expect(parsePersistedState(null)).toEqual({ schemaVersion: SCHEMA_VERSION, tasks: [] })
expect(parsePersistedState(' ')).toEqual({ schemaVersion: SCHEMA_VERSION, tasks: [] })
})
it('非 JSON 抛 DataValidationError', () => {
expect(() => parsePersistedState('{oops')).toThrow(DataValidationError)
})
it('非对象抛 DataValidationError', () => {
expect(() => parsePersistedState('[1,2,3]')).toThrow(DataValidationError)
})
it('缺少 schemaVersion 的旧数据迁移为当前版本', () => {
const legacy = JSON.stringify({ tasks: [validTask()] })
const state = parsePersistedState(legacy)
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
expect(state.tasks).toHaveLength(1)
})
it('版本高于当前支持时抛错(不静默降级)', () => {
const future = JSON.stringify({ schemaVersion: SCHEMA_VERSION + 1, tasks: [] })
expect(() => parsePersistedState(future)).toThrow(/高于当前支持/)
})
it('损坏条目被跳过,其余数据仍可用', () => {
const mixed = JSON.stringify({
schemaVersion: SCHEMA_VERSION,
tasks: [validTask(), { id: 'x', title: '' }],
})
expect(parsePersistedState(mixed).tasks).toHaveLength(1)
})
})
describe('版本迁移', () => {
it('v1 数据迁移到 v2 时补上 archivedAt,其余字段不变', () => {
const v1 = JSON.stringify({
schemaVersion: 1,
tasks: [
{
id: 'legacy',
title: '旧任务',
status: 'done',
priority: 3,
sortOrder: 1024,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
completedAt: '2026-01-02T00:00:00.000Z',
},
],
})
const state = parsePersistedState(v1)
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
expect(state.tasks[0]?.archivedAt).toBeNull()
expect(state.tasks[0]?.title).toBe('旧任务')
expect(state.tasks[0]?.priority).toBe(3)
expect(state.tasks[0]?.completedAt).toBe('2026-01-02T00:00:00.000Z')
})
it('无版本号的最早期数据能一路迁移到当前版本', () => {
const legacy = JSON.stringify({
tasks: [{ id: 'a', title: '最早的任务', status: 'todo', priority: 2 }],
})
const state = parsePersistedState(legacy)
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
expect(state.tasks[0]?.archivedAt).toBeNull()
})
it('归档状态在序列化往返后保留', () => {
const tasks = coerceTaskList([validTask({ archivedAt: '2026-10-01T00:00:00.000Z' })])
const roundTrip = parsePersistedState(serializeState(tasks))
expect(roundTrip.tasks[0]?.archivedAt).toBe('2026-10-01T00:00:00.000Z')
})
it('多版本迁移对多条任务都生效', () => {
const v1 = JSON.stringify({
schemaVersion: 1,
tasks: [
{ id: 'a', title: 'A', status: 'todo', priority: 1 },
{ id: 'b', title: 'B', status: 'done', priority: 2 },
],
})
const state = parsePersistedState(v1)
expect(state.tasks.map((task) => task.archivedAt)).toEqual([null, null])
})
})
describe('序列化往返', () => {
it('serializeState 与 parsePersistedState 互逆', () => {
const tasks = coerceTaskList([validTask(), validTask({ id: 'task-2', status: 'done' })])
const roundTrip = parsePersistedState(serializeState(tasks))
expect(roundTrip.tasks).toEqual(tasks)
expect(roundTrip.schemaVersion).toBe(SCHEMA_VERSION)
})
it('pretty 模式仍可解析', () => {
const tasks = coerceTaskList([validTask()])
const pretty = serializeState(tasks, true)
expect(pretty).toContain('\n')
expect(parsePersistedState(pretty).tasks).toEqual(tasks)
})
})
describe('导入校验(严格)', () => {
it('任何一条任务不合法即整体拒绝', () => {
expect(() => validateImportPayload({ tasks: [validTask(), { id: 'broken' }] })).toThrow(
/第 2 条任务格式不合法/,
)
})
it('缺少 tasks 数组时抛错', () => {
expect(() => validateImportPayload({ schemaVersion: 1 })).toThrow(/缺少 tasks 数组/)
})
it('非对象时抛错', () => {
expect(() => validateImportPayload([])).toThrow(/必须是一个 JSON 对象/)
})
it('版本过高时拒绝导入', () => {
expect(() => validateImportPayload({ schemaVersion: 99, tasks: [] })).toThrow(/高于当前支持/)
})
it('允许省略 schemaVersion', () => {
expect(validateImportPayload({ tasks: [validTask()] })).toHaveLength(1)
})
it('parseImportJson 对非法 JSON 给出明确错误', () => {
expect(() => parseImportJson('not json')).toThrow(/不是合法的 JSON/)
})
})