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:
@@ -0,0 +1,446 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createMemoryTaskRepository, type TaskRepository } from '@/data/taskRepository'
|
||||
|
||||
import { useTaskStore } from './tasks'
|
||||
|
||||
/**
|
||||
* 每次调用都换一个全新的 Pinia 实例,以模拟「重新打开页面」。
|
||||
* crossTab 关闭,避免测试环境创建 BroadcastChannel。
|
||||
*/
|
||||
function freshStore(repository: TaskRepository = createMemoryTaskRepository()) {
|
||||
setActivePinia(createPinia())
|
||||
const store = useTaskStore()
|
||||
store.init({ repository, crossTab: false })
|
||||
return store
|
||||
}
|
||||
|
||||
function titles(tasks: readonly { title: string }[]): string[] {
|
||||
return tasks.map((task) => task.title)
|
||||
}
|
||||
|
||||
describe('初始化', () => {
|
||||
it('从仓库加载已有数据', () => {
|
||||
const repository = createMemoryTaskRepository([
|
||||
{
|
||||
id: 'existing',
|
||||
title: '已有任务',
|
||||
description: '',
|
||||
status: 'doing',
|
||||
priority: 3,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-18T00:00:00.000Z',
|
||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||
completedAt: null,
|
||||
archivedAt: null,
|
||||
},
|
||||
])
|
||||
const store = freshStore(repository)
|
||||
|
||||
expect(store.ready).toBe(true)
|
||||
expect(store.tasks).toHaveLength(1)
|
||||
expect(store.byColumn.doing.map((task) => task.title)).toEqual(['已有任务'])
|
||||
})
|
||||
|
||||
it('counts 反映各列数量', () => {
|
||||
const store = freshStore()
|
||||
store.createTask({ title: 'A' })
|
||||
store.createTask({ title: 'B', status: 'doing' })
|
||||
store.createTask({ title: 'C', status: 'done' })
|
||||
expect(store.counts).toEqual({ todo: 1, doing: 1, done: 1 })
|
||||
})
|
||||
|
||||
it('重复调用 init 不会清空数据', () => {
|
||||
const store = freshStore()
|
||||
store.createTask({ title: 'A' })
|
||||
store.init({ crossTab: false })
|
||||
expect(store.tasks).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('快速新增', () => {
|
||||
it('新任务落到待办列首', () => {
|
||||
const store = freshStore()
|
||||
store.createTask({ title: '先建的' })
|
||||
store.createTask({ title: '后建的' })
|
||||
expect(titles(store.byColumn.todo)).toEqual(['后建的', '先建的'])
|
||||
})
|
||||
|
||||
it('空标题或纯空白不创建任务', () => {
|
||||
const store = freshStore()
|
||||
expect(store.createTask({ title: ' ' })).toBeNull()
|
||||
expect(store.createTask({ title: '' })).toBeNull()
|
||||
expect(store.tasks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('标题首尾空白被去除', () => {
|
||||
const store = freshStore()
|
||||
const task = store.createTask({ title: ' 写方案 ' })
|
||||
expect(task?.title).toBe('写方案')
|
||||
})
|
||||
|
||||
it('标签去重并去除空白项', () => {
|
||||
const store = freshStore()
|
||||
const task = store.createTask({ title: 'A', tags: ['设计', ' 设计 ', '', '前端'] })
|
||||
expect(task?.tags).toEqual(['设计', '前端'])
|
||||
})
|
||||
|
||||
it('直接建到已完成列时写入 completedAt', () => {
|
||||
const store = freshStore()
|
||||
const task = store.createTask({ title: 'A', status: 'done' })
|
||||
expect(task?.completedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('默认优先级为中', () => {
|
||||
const store = freshStore()
|
||||
expect(store.createTask({ title: 'A' })?.priority).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('编辑', () => {
|
||||
it('修改标题并刷新 updatedAt', async () => {
|
||||
const store = freshStore()
|
||||
const task = store.createTask({ title: '旧标题' })
|
||||
const before = task?.updatedAt ?? ''
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2))
|
||||
const updated = store.updateTask(task?.id ?? '', { title: '新标题' })
|
||||
|
||||
expect(updated?.title).toBe('新标题')
|
||||
expect(updated?.updatedAt).not.toBe(before)
|
||||
})
|
||||
|
||||
it('标题被清空时保留原标题', () => {
|
||||
const store = freshStore()
|
||||
const task = store.createTask({ title: '原标题' })
|
||||
expect(store.updateTask(task?.id ?? '', { title: ' ' })?.title).toBe('原标题')
|
||||
})
|
||||
|
||||
it('移到已完成写入 completedAt,移出时清空', () => {
|
||||
const store = freshStore()
|
||||
const task = store.createTask({ title: 'A' })
|
||||
const id = task?.id ?? ''
|
||||
|
||||
store.updateTask(id, { status: 'done' })
|
||||
expect(store.tasks.find((item) => item.id === id)?.completedAt).not.toBeNull()
|
||||
|
||||
store.updateTask(id, { status: 'todo' })
|
||||
expect(store.tasks.find((item) => item.id === id)?.completedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('状态变更后落到目标列末尾', () => {
|
||||
const store = freshStore()
|
||||
const first = store.createTask({ title: '已在进行中', status: 'doing' })
|
||||
const moved = store.createTask({ title: '从待办移入' })
|
||||
store.updateTask(moved?.id ?? '', { status: 'doing' })
|
||||
|
||||
expect(titles(store.byColumn.doing)).toEqual(['已在进行中', '从待办移入'])
|
||||
expect(first?.status).toBe('doing')
|
||||
})
|
||||
|
||||
it('对不存在的 id 返回 null', () => {
|
||||
const store = freshStore()
|
||||
expect(store.updateTask('missing', { title: 'x' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('删除与撤销', () => {
|
||||
it('删除后返回被删任务', () => {
|
||||
const store = freshStore()
|
||||
const task = store.createTask({ title: 'A' })
|
||||
expect(store.removeTask(task?.id ?? '')?.title).toBe('A')
|
||||
expect(store.tasks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('撤销后连原顺序一起恢复', () => {
|
||||
const store = freshStore()
|
||||
store.createTask({ title: 'A' })
|
||||
const middle = store.createTask({ title: 'B' })
|
||||
store.createTask({ title: 'C' })
|
||||
expect(titles(store.byColumn.todo)).toEqual(['C', 'B', 'A'])
|
||||
|
||||
const removed = store.removeTask(middle?.id ?? '')
|
||||
expect(titles(store.byColumn.todo)).toEqual(['C', 'A'])
|
||||
|
||||
store.restoreTask(removed!)
|
||||
expect(titles(store.byColumn.todo)).toEqual(['C', 'B', 'A'])
|
||||
})
|
||||
|
||||
it('重复撤销不会产生重复任务', () => {
|
||||
const store = freshStore()
|
||||
const task = store.createTask({ title: 'A' })
|
||||
const removed = store.removeTask(task?.id ?? '')
|
||||
store.restoreTask(removed!)
|
||||
store.restoreTask(removed!)
|
||||
expect(store.tasks).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('拖拽移动', () => {
|
||||
it('跨列拖动更新 status 与 completedAt', () => {
|
||||
const store = freshStore()
|
||||
const task = store.createTask({ title: 'A' })
|
||||
store.moveTask(task?.id ?? '', 'done', 0)
|
||||
|
||||
const moved = store.tasks.find((item) => item.id === task?.id)
|
||||
expect(moved?.status).toBe('done')
|
||||
expect(moved?.completedAt).not.toBeNull()
|
||||
expect(titles(store.byColumn.done)).toEqual(['A'])
|
||||
expect(store.byColumn.todo).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('从已完成拖回待办时清空 completedAt', () => {
|
||||
const store = freshStore()
|
||||
const task = store.createTask({ title: 'A', status: 'done' })
|
||||
store.moveTask(task?.id ?? '', 'todo', 0)
|
||||
expect(store.tasks.find((item) => item.id === task?.id)?.completedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('跨列拖动到指定下标', () => {
|
||||
const store = freshStore()
|
||||
store.createTask({ title: '目标列第一', status: 'doing' })
|
||||
store.createTask({ title: '目标列第二', status: 'doing' })
|
||||
const incoming = store.createTask({ title: '移入' })
|
||||
|
||||
// 快速新增是「插入列首」,因此目标列当前顺序为:目标列第二, 目标列第一
|
||||
expect(titles(store.byColumn.doing)).toEqual(['目标列第二', '目标列第一'])
|
||||
|
||||
store.moveTask(incoming?.id ?? '', 'doing', 1)
|
||||
|
||||
expect(titles(store.byColumn.doing)).toEqual(['目标列第二', '移入', '目标列第一'])
|
||||
})
|
||||
|
||||
it('列内拖动改变顺序', () => {
|
||||
const store = freshStore()
|
||||
const a = store.createTask({ title: 'A' })
|
||||
store.createTask({ title: 'B' })
|
||||
store.createTask({ title: 'C' })
|
||||
expect(titles(store.byColumn.todo)).toEqual(['C', 'B', 'A'])
|
||||
|
||||
store.moveTask(a?.id ?? '', 'todo', 0)
|
||||
expect(titles(store.byColumn.todo)).toEqual(['A', 'C', 'B'])
|
||||
})
|
||||
|
||||
it('列内拖动后重建 store 顺序仍保持(刷新不丢)', () => {
|
||||
const repository = createMemoryTaskRepository()
|
||||
const first = freshStore(repository)
|
||||
const a = first.createTask({ title: 'A' })
|
||||
first.createTask({ title: 'B' })
|
||||
first.createTask({ title: 'C' })
|
||||
first.moveTask(a?.id ?? '', 'todo', 0)
|
||||
|
||||
const second = freshStore(repository)
|
||||
expect(titles(second.byColumn.todo)).toEqual(['A', 'C', 'B'])
|
||||
})
|
||||
|
||||
it('跨列拖动后重建 store,状态与位置都保持', () => {
|
||||
const repository = createMemoryTaskRepository()
|
||||
const first = freshStore(repository)
|
||||
const task = first.createTask({ title: 'A' })
|
||||
first.moveTask(task?.id ?? '', 'doing', 0)
|
||||
|
||||
const second = freshStore(repository)
|
||||
expect(second.byColumn.doing.map((item) => item.title)).toEqual(['A'])
|
||||
expect(second.byColumn.todo).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('对不存在的 id 无副作用', () => {
|
||||
const store = freshStore()
|
||||
store.createTask({ title: 'A' })
|
||||
store.moveTask('missing', 'done', 0)
|
||||
expect(titles(store.byColumn.todo)).toEqual(['A'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('持久化', () => {
|
||||
it('新增后重建 store 能读回(刷新不丢)', () => {
|
||||
const repository = createMemoryTaskRepository()
|
||||
const first = freshStore(repository)
|
||||
first.createTask({ title: '持久化任务' })
|
||||
|
||||
const second = freshStore(repository)
|
||||
expect(titles(second.tasks)).toEqual(['持久化任务'])
|
||||
})
|
||||
|
||||
it('删除后重建 store 不会复活任务', () => {
|
||||
const repository = createMemoryTaskRepository()
|
||||
const first = freshStore(repository)
|
||||
const task = first.createTask({ title: 'A' })
|
||||
first.removeTask(task?.id ?? '')
|
||||
|
||||
const second = freshStore(repository)
|
||||
expect(second.tasks).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('导出与导入', () => {
|
||||
it('导出后再导入,数据一致', () => {
|
||||
const store = freshStore()
|
||||
store.createTask({ title: 'A', priority: 3, tags: ['x', 'x', 'y'] })
|
||||
store.createTask({ title: 'B', status: 'doing' })
|
||||
const json = store.exportJson()
|
||||
|
||||
const other = freshStore()
|
||||
expect(other.importJson(json)).toBe(2)
|
||||
expect(titles(other.tasks).sort()).toEqual(['A', 'B'])
|
||||
expect(other.tasks.find((task) => task.title === 'A')?.tags).toEqual(['x', 'y'])
|
||||
expect(other.tasks.find((task) => task.title === 'B')?.status).toBe('doing')
|
||||
})
|
||||
|
||||
it('导入非法数据时抛错且现有数据不变', () => {
|
||||
const store = freshStore()
|
||||
store.createTask({ title: '保留我' })
|
||||
|
||||
expect(() => store.importJson('{"tasks":[{"id":"x"}]}')).toThrow()
|
||||
expect(titles(store.tasks)).toEqual(['保留我'])
|
||||
|
||||
expect(() => store.importJson('不是 JSON')).toThrow()
|
||||
expect(titles(store.tasks)).toEqual(['保留我'])
|
||||
})
|
||||
|
||||
it('导入为覆盖式', () => {
|
||||
const store = freshStore()
|
||||
store.createTask({ title: '旧数据' })
|
||||
const payload = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
tasks: [
|
||||
{
|
||||
id: 'new',
|
||||
title: '新数据',
|
||||
status: 'todo',
|
||||
priority: 2,
|
||||
sortOrder: 1024,
|
||||
},
|
||||
],
|
||||
})
|
||||
store.importJson(payload)
|
||||
expect(titles(store.tasks)).toEqual(['新数据'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('归档', () => {
|
||||
it('新建任务默认未归档', () => {
|
||||
const store = freshStore()
|
||||
expect(store.createTask({ title: 'A' })?.archivedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('归档写入 archivedAt 并返回数量', () => {
|
||||
const store = freshStore()
|
||||
const task = store.createTask({ title: 'A', status: 'done' })
|
||||
|
||||
expect(store.archiveTasks([task?.id ?? ''])).toBe(1)
|
||||
expect(store.tasks[0]?.archivedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('重复归档同一任务不重复计数', () => {
|
||||
const store = freshStore()
|
||||
const task = store.createTask({ title: 'A', status: 'done' })
|
||||
const id = task?.id ?? ''
|
||||
|
||||
expect(store.archiveTasks([id])).toBe(1)
|
||||
expect(store.archiveTasks([id])).toBe(0)
|
||||
})
|
||||
|
||||
it('对不存在的 id 无副作用', () => {
|
||||
const store = freshStore()
|
||||
store.createTask({ title: 'A' })
|
||||
expect(store.archiveTasks(['missing'])).toBe(0)
|
||||
expect(store.tasks[0]?.archivedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('取消归档清空 archivedAt', () => {
|
||||
const store = freshStore()
|
||||
const task = store.createTask({ title: 'A', status: 'done' })
|
||||
const id = task?.id ?? ''
|
||||
|
||||
store.archiveTasks([id])
|
||||
expect(store.unarchiveTasks([id])).toBe(1)
|
||||
expect(store.tasks[0]?.archivedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('取消未归档的任务不计数', () => {
|
||||
const store = freshStore()
|
||||
const task = store.createTask({ title: 'A' })
|
||||
expect(store.unarchiveTasks([task?.id ?? ''])).toBe(0)
|
||||
})
|
||||
|
||||
it('归档状态在重建 store 后保留(刷新不丢)', () => {
|
||||
const repository = createMemoryTaskRepository()
|
||||
const first = freshStore(repository)
|
||||
const task = first.createTask({ title: 'A', status: 'done' })
|
||||
first.archiveTasks([task?.id ?? ''])
|
||||
|
||||
const second = freshStore(repository)
|
||||
expect(second.tasks[0]?.archivedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('批量归档返回实际归档数量', () => {
|
||||
const store = freshStore()
|
||||
const a = store.createTask({ title: 'A', status: 'done' })
|
||||
const b = store.createTask({ title: 'B', status: 'done' })
|
||||
const c = store.createTask({ title: 'C' })
|
||||
|
||||
const count = store.archiveTasks([a?.id ?? '', b?.id ?? '', c?.id ?? ''])
|
||||
expect(count).toBe(3)
|
||||
})
|
||||
|
||||
it('归档不影响任务内容本身', () => {
|
||||
const store = freshStore()
|
||||
const task = store.createTask({ title: '保留标题', description: '保留描述', status: 'done' })
|
||||
store.archiveTasks([task?.id ?? ''])
|
||||
|
||||
expect(store.tasks[0]?.title).toBe('保留标题')
|
||||
expect(store.tasks[0]?.description).toBe('保留描述')
|
||||
expect(store.tasks[0]?.status).toBe('done')
|
||||
})
|
||||
})
|
||||
|
||||
describe('规模与稳定性', () => {
|
||||
it('200 个任务时新增、拖动、归档后数据仍自洽', () => {
|
||||
const store = freshStore()
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
store.createTask({ title: `任务 ${index}` })
|
||||
}
|
||||
expect(store.byColumn.todo).toHaveLength(200)
|
||||
|
||||
// 反复把最后一张卡片拖到列首,验证下标换算与精度保护不退化
|
||||
for (let round = 0; round < 50; round += 1) {
|
||||
const last = store.byColumn.todo[199]
|
||||
if (!last) break
|
||||
store.moveTask(last.id, 'todo', 0)
|
||||
}
|
||||
|
||||
const column = store.byColumn.todo
|
||||
expect(column).toHaveLength(200)
|
||||
expect(new Set(column.map((task) => task.id)).size).toBe(200)
|
||||
|
||||
const orders = column.map((task) => task.sortOrder)
|
||||
expect([...orders].sort((a, b) => a - b)).toEqual(orders)
|
||||
for (let index = 1; index < orders.length; index += 1) {
|
||||
const previous = orders[index - 1] ?? 0
|
||||
const current = orders[index] ?? 0
|
||||
expect(current - previous).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('批量跨列移动后各列数量守恒', () => {
|
||||
const store = freshStore()
|
||||
const ids: string[] = []
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
const task = store.createTask({ title: `任务 ${index}` })
|
||||
if (task) ids.push(task.id)
|
||||
}
|
||||
|
||||
for (const [index, id] of ids.entries()) {
|
||||
store.moveTask(id, index % 2 === 0 ? 'doing' : 'done', 0)
|
||||
}
|
||||
|
||||
expect(store.byColumn.todo).toHaveLength(0)
|
||||
expect(store.byColumn.doing).toHaveLength(30)
|
||||
expect(store.byColumn.done).toHaveLength(30)
|
||||
expect(store.tasks).toHaveLength(60)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user