feat(board): 已完成列按完成时间倒序排列
CI / verify (push) Successful in 1m45s
CI / 部署到服务器 (push) Successful in 27s

- 已完成列的展示顺序改由 completedAt 倒序决定(新增 sortCompletedDesc 纯函数),
  最近完成的永远在最上面;缺 completedAt 的旧数据排在有完成时间的任务之后,
  彼此退回 sortOrder 倒序
- 该列停用列内排序:拖拽与 ↑↓ 只能跨列,列头标注「按完成时间倒序」说明口径
- 数据层不动:不迁移 sortOrder,导入导出、快照结构与 schemaVersion 均不变,
  倒序只在 BoardView.visibleColumns 单点发生
- 新增 ADR-009,并在 ADR-008 的否决项里标注被部分推翻
- 单测 705 → 738(sortOrder +5、BoardView +5、BoardColumn +1),README 同步
This commit is contained in:
chenshoulin
2026-10-22 13:15:25 +08:00
parent b39267fba7
commit 51235a59a2
8 changed files with 228 additions and 5 deletions
+5 -2
View File
@@ -3,6 +3,7 @@
一个轻量的个人看板工具:打开即用、数据存在本地、不依赖后端。 一个轻量的个人看板工具:打开即用、数据存在本地、不依赖后端。
- 固定三列:**待办 / 进行中 / 已完成** - 固定三列:**待办 / 进行中 / 已完成**
- **已完成列按完成时间倒序**:最近完成的永远在最上面(列内不支持手动调序)
- 快速新增、拖拽排序、详情编辑、删除撤销;详情里可直接切换状态 - 快速新增、拖拽排序、详情编辑、删除撤销;详情里可直接切换状态
- **批量添加**:一次录入多行清单,整批共用目标列 / 优先级 / 标签 / 截止日期 - **批量添加**:一次录入多行清单,整批共用目标列 / 优先级 / 标签 / 截止日期
- **批量编辑**:多选后可统一移动列、改优先级、加/减标签、设截止日期、归档 - **批量编辑**:多选后可统一移动列、改优先级、加/减标签、设截止日期、归档
@@ -92,7 +93,8 @@ npm run dev
| `↑` `↓` | 在列内上移 / 下移 | | `↑` `↓` | 在列内上移 / 下移 |
| `Enter` | 打开任务详情 | | `Enter` | 打开任务详情 |
在输入框内输入时不会触发全局快捷键。方向键移动在筛选态下只允许跨列(列内排序需要看到完整列表才有意义)。 在输入框内输入时不会触发全局快捷键。方向键移动在筛选态下只允许跨列(列内排序需要看到完整列表才有意义);
「已完成」列的列内顺序由完成时间决定,`↑` `↓` 在该列不生效(拖拽同理只能跨列)。
--- ---
@@ -316,7 +318,7 @@ E2E 需要真实浏览器,默认不放进 CI(要求 runner 预装 Chrome)
## 测试 ## 测试
- 单元测试:705 个用例,覆盖 `src/` 下的纯逻辑与组件行为。 - 单元测试:738 个用例,覆盖 `src/` 下的纯逻辑与组件行为。
- 端到端测试:28 个用例,分两组运行 —— 交互类跑 dev server,PWA 类跑生产构建(Service Worker 只在构建产物中生成)。覆盖「新增 → 拖拽 → 完成 → 刷新保持」「批量添加」「多选批量删除 + 撤销」「导出 → 清空 → 导入还原」「删除撤销」「搜索筛选」「键盘快捷键与方向键移动」「深色模式切换」「今日视图」「离线打开」等主链路,每个用例使用独立的浏览器上下文。 - 端到端测试:28 个用例,分两组运行 —— 交互类跑 dev server,PWA 类跑生产构建(Service Worker 只在构建产物中生成)。覆盖「新增 → 拖拽 → 完成 → 刷新保持」「批量添加」「多选批量删除 + 撤销」「导出 → 清空 → 导入还原」「删除撤销」「搜索筛选」「键盘快捷键与方向键移动」「深色模式切换」「今日视图」「离线打开」等主链路,每个用例使用独立的浏览器上下文。
```bash ```bash
@@ -339,6 +341,7 @@ PLAYWRIGHT_CHANNEL=chromium npm run test:e2e # 用自带 Chromium(需先 npx
- 单用户、单设备,无协作与同步能力(离线可用,但多设备之间不会同步)。 - 单用户、单设备,无协作与同步能力(离线可用,但多设备之间不会同步)。
- 看板列固定为三列,不支持自定义。 - 看板列固定为三列,不支持自定义。
- 「已完成」列固定按完成时间倒序,不支持手动调序(拖拽与方向键只能跨列)。
- 归档阈值固定为「已完成超过 7 天」,暂不可配置。 - 归档阈值固定为「已完成超过 7 天」,暂不可配置。
- 今日视图的分组规则固定(逾期 > 今天到期 > 进行中 > 待办),不可自定义。 - 今日视图的分组规则固定(逾期 > 今天到期 > 进行中 > 待办),不可自定义。
- 统计只覆盖本周完成数等基础指标,没有趋势图或历史留存。 - 统计只覆盖本周完成数等基础指标,没有趋势图或历史留存。
+40
View File
@@ -232,6 +232,7 @@ MVP 有「启动快照 + 手动导出」两道保护,但两个整份覆盖的
创建时间对「先做哪件」几乎没有影响,放进 `title` 与详情抽屉足够。 创建时间对「先做哪件」几乎没有影响,放进 `title` 与详情抽屉足够。
- _增加「按创建 / 完成时间排序」开关_:完成列的顺序由手动 `sortOrder` 表达,再引入第二种排序会让 - _增加「按创建 / 完成时间排序」开关_:完成列的顺序由手动 `sortOrder` 表达,再引入第二种排序会让
「看到的顺序」与「拖拽写回的顺序」产生两个真相;当前也没有对应使用场景。 「看到的顺序」与「拖拽写回的顺序」产生两个真相;当前也没有对应使用场景。
(其中「完成列按完成时间排序」后来被 [ADR-009](#adr-009--已完成列改按完成时间倒序展示) 部分推翻。)
- _用相对时间(「3 天前」)_:文案依赖渲染时刻,页面开一整天就会和实际完成日对不上; - _用相对时间(「3 天前」)_:文案依赖渲染时刻,页面开一整天就会和实际完成日对不上;
复盘场景下具体日期更有用。 复盘场景下具体日期更有用。
- _提升 `schemaVersion` 做迁移_:字段早已存在,且 `coerceTask` 已把它们收敛为字符串或 `null`,无需迁移。 - _提升 `schemaVersion` 做迁移_:字段早已存在,且 `coerceTask` 已把它们收敛为字符串或 `null`,无需迁移。
@@ -242,3 +243,42 @@ MVP 有「启动快照 + 手动导出」两道保护,但两个整份覆盖的
拖拽换列走 `moveTask`,两者规则一致(进入「已完成」写时间、离开即清空),展示值与状态不会互相矛盾。 拖拽换列走 `moveTask`,两者规则一致(进入「已完成」写时间、离开即清空),展示值与状态不会互相矛盾。
- 新增两个纯函数与对应单测;`TrashPanel` 少了一个私有格式化函数。 - 新增两个纯函数与对应单测;`TrashPanel` 少了一个私有格式化函数。
- 卡片 `title` 是纯提示信息,不参与可访问性命名(`aria-label` 仅在多选态给出),读屏语义不变。 - 卡片 `title` 是纯提示信息,不参与可访问性命名(`aria-label` 仅在多选态给出),读屏语义不变。
---
## ADR-009 · 已完成列改按完成时间倒序展示
**背景**
ADR-008 否掉了「完成列按时间排序」,理由是维持单一顺序来源。但实际使用中这个取舍站不住:
完成任务时 `sortOrder` 是「追加到列尾」,于是**刚勾完的任务沉到列表最底下** ——
打开看板想问「我刚才做完什么」,得滚到列底;做完几十条之后,已完成列变成一条只有
「按完成先后升序」的考古记录。对已完成的任务来说,用户唯一关心的顺序就是完成时间。
**结论**
1. 已完成列的展示顺序由 `completedAt` 倒序决定,不再消费 `sortOrder`。
排序键收敛为一个纯函数 `sortCompletedDesc`(`src/utils/sortOrder.ts`),
转换只在 `BoardView` 的 `visibleColumns` 单点发生,store 与数据层语义不变。
2. 该列停用列内排序:拖拽与 `↑` `↓` 只允许跨列(`sortDisabled` 对 `done` 恒为 true),
避免写出「永远看不见结果」的 `sortOrder`。
3. 列头标注「按完成时间倒序」,把口径写在界面上,用户不会误以为拖不动是故障。
4. 不做数据迁移:`sortOrder`、导入导出、快照结构与 `schemaVersion` 全都不动。
`completedAt` 缺失的旧数据(手工导入、更早的备份)排在有完成时间的任务之后,
彼此之间退回 `sortOrder` 倒序,至少保持「新完成的在上面」。
**被否决的方案**
- _一次性按完成时间重排 `sortOrder`,新完成插列首_:需要迁移存量数据,且用户只要拖过一次,
顺序就再次和时间脱钩 —— 等于保留两个真相,还多了写操作。
- _只改插入位置(新完成插列首)_:改动最小,但存量列表仍是旧的升序,首屏出现
「新任务在顶、旧任务按升序续在后面」的断裂顺序。
- _加排序开关_:一个开关多一份状态、多一组需要测试的组合,而「已完成」只有一种合理顺序。
**影响**
- 落入已完成列的落点不再由拖拽位置决定(`completedAt` 刚写入即最新),这是预期行为。
- `store.byColumn.done` 仍是 `sortOrder` 语义,只有视图层按时间倒序;两者差异集中在
`BoardView.visibleColumns`,测试覆盖了「顺序与 `sortOrder` 无关」这一条。
- 已完成列成为只读顺序:拖拽、`↑` `↓`、批量移动(只能移出该列)都不会改变其内部顺序。
+18
View File
@@ -77,6 +77,24 @@ describe('BoardColumn 渲染', () => {
const { wrapper } = setup([]) const { wrapper } = setup([])
expect(wrapper.find('input[aria-label="添加任务"]').exists()).toBe(true) expect(wrapper.find('input[aria-label="添加任务"]').exists()).toBe(true)
}) })
it('已完成列标注排序口径,其它列不标注', () => {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
const mountWith = (column: ColumnDef) =>
mount(BoardColumn, {
props: { column, tasks: [], sortDisabled: column.id === 'done' },
global: { plugins: [pinia] },
})
expect(mountWith({ id: 'done', title: '已完成' }).find('[data-role="column-sort-hint"]').text()).toBe(
'按完成时间倒序',
)
expect(mountWith(TODO_COLUMN).find('[data-role="column-sort-hint"]').exists()).toBe(false)
})
}) })
describe('BoardColumn 拖拽契约', () => { describe('BoardColumn 拖拽契约', () => {
+8
View File
@@ -72,6 +72,14 @@ useDraggable(listRef, {
<header class="flex items-center gap-2 px-3 pt-3 pb-2"> <header class="flex items-center gap-2 px-3 pt-3 pb-2">
<span class="size-1.5 shrink-0 rounded-full" :class="dotClass" aria-hidden="true" /> <span class="size-1.5 shrink-0 rounded-full" :class="dotClass" aria-hidden="true" />
<h2 class="text-sm font-medium text-slate-700 dark:text-zinc-300">{{ column.title }}</h2> <h2 class="text-sm font-medium text-slate-700 dark:text-zinc-300">{{ column.title }}</h2>
<span
v-if="column.id === 'done'"
data-role="column-sort-hint"
class="text-[11px] text-slate-400 dark:text-zinc-500"
title="已完成列固定按完成时间倒序,最近完成的在最上面,不支持手动调序"
>
按完成时间倒序
</span>
<span <span
data-role="column-count" data-role="column-count"
class="ml-auto rounded-full border border-slate-200 bg-white px-2 py-0.5 text-xs font-medium text-slate-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400" class="ml-auto rounded-full border border-slate-200 bg-white px-2 py-0.5 text-xs font-medium text-slate-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400"
+53
View File
@@ -11,6 +11,7 @@ import {
needsNormalize, needsNormalize,
normalizeColumn, normalizeColumn,
sortByOrder, sortByOrder,
sortCompletedDesc,
sortOrderForPosition, sortOrderForPosition,
} from './sortOrder' } from './sortOrder'
@@ -220,3 +221,55 @@ describe('sortByOrder', () => {
expect(titles(tasks)).toEqual(['B', 'A']) expect(titles(tasks)).toEqual(['B', 'A'])
}) })
}) })
describe('sortCompletedDesc', () => {
it('按完成时间倒序,最近完成的最上面', () => {
const tasks = [
makeTask({ title: '早', status: 'done', completedAt: '2026-10-01T09:00:00.000Z' }),
makeTask({ title: '晚', status: 'done', completedAt: '2026-10-20T09:00:00.000Z' }),
makeTask({ title: '中', status: 'done', completedAt: '2026-10-10T09:00:00.000Z' }),
]
expect(titles(sortCompletedDesc(tasks))).toEqual(['晚', '中', '早'])
})
it('与 sortOrder 无关:sortOrder 更大的不会因此排前面', () => {
const tasks = [
makeTask({
title: '刚完成',
status: 'done',
sortOrder: 1024,
completedAt: '2026-10-20T09:00:00.000Z',
}),
makeTask({
title: '早完成',
status: 'done',
sortOrder: 9999,
completedAt: '2026-10-01T09:00:00.000Z',
}),
]
expect(titles(sortCompletedDesc(tasks))).toEqual(['刚完成', '早完成'])
})
it('缺 completedAt 的旧数据排在后面,彼此按 sortOrder 倒序', () => {
const tasks = [
makeTask({ title: '无完成时间小', status: 'done', sortOrder: 1024 }),
makeTask({
title: '有时间',
status: 'done',
sortOrder: 5000,
completedAt: '2026-10-20T09:00:00.000Z',
}),
makeTask({ title: '无完成时间大', status: 'done', sortOrder: 2048 }),
]
expect(titles(sortCompletedDesc(tasks))).toEqual(['有时间', '无完成时间大', '无完成时间小'])
})
it('不修改入参', () => {
const tasks = [
makeTask({ title: '早', status: 'done', completedAt: '2026-10-01T09:00:00.000Z' }),
makeTask({ title: '晚', status: 'done', completedAt: '2026-10-20T09:00:00.000Z' }),
]
sortCompletedDesc(tasks)
expect(titles(tasks)).toEqual(['早', '晚'])
})
})
+23
View File
@@ -42,6 +42,29 @@ export function sortByOrder(tasks: readonly Task[]): Task[] {
return [...tasks].sort(compareBySortOrder) return [...tasks].sort(compareBySortOrder)
} }
/**
* 已完成的展示顺序:按完成时间**倒序**,最近完成的永远在最上面。
*
* 为什么不沿用 sortOrder:完成时间本身就是用户唯一关心的「顺序语义」,
* 手调只会让「刚勾完的任务跑到列表末尾」这种反直觉的情况重新出现。
* 因此已完成列放弃手动排序(拖拽与方向键在该列不生效),
* 排序键固定为 completedAt。
*
* completedAt 缺失(旧数据、手工导入)时退回 sortOrder 倒序:
* 至少保证「新完成的在上面」这个观感不被一条脏数据打断。
*/
export function compareByCompletedDesc(a: Task, b: Task): number {
const left = a.completedAt ?? ''
const right = b.completedAt ?? ''
if (left !== right) return right.localeCompare(left)
return b.sortOrder - a.sortOrder
}
/** 按完成时间倒序排列(不修改入参) */
export function sortCompletedDesc(tasks: readonly Task[]): Task[] {
return [...tasks].sort(compareByCompletedDesc)
}
/** 按 SORT_ORDER_STEP 重新均匀分配同列 sortOrder,保持相对顺序不变 */ /** 按 SORT_ORDER_STEP 重新均匀分配同列 sortOrder,保持相对顺序不变 */
export function normalizeColumn(sorted: readonly Task[]): Task[] { export function normalizeColumn(sorted: readonly Task[]): Task[] {
return sorted.map((task, index) => ({ ...task, sortOrder: (index + 1) * SORT_ORDER_STEP })) return sorted.map((task, index) => ({ ...task, sortOrder: (index + 1) * SORT_ORDER_STEP }))
+63
View File
@@ -853,3 +853,66 @@ describe('BoardView 批量编辑', () => {
expect(store.activeTasks.every((task) => task.archivedAt === null)).toBe(true) expect(store.activeTasks.every((task) => task.archivedAt === null)).toBe(true)
}) })
}) })
describe('BoardView 已完成列倒序', () => {
/** 建一条已完成任务并指定完成时刻 */
function createDone(
store: ReturnType<typeof useTaskStore>,
title: string,
completedAt: string,
): void {
const task = store.createTask({ title, status: 'done' })
store.updateTask(task?.id ?? '', { completedAt })
}
function doneTitles(wrapper: ReturnType<typeof mountBoard>): string[] {
return wrapper.findAll('section')[2]?.findAll('article h3').map((node) => node.text()) ?? []
}
it('按完成时间倒序渲染,最近完成的最上面(与 sortOrder 无关)', () => {
const { pinia, store } = setup()
// 先完成「早」,后完成「刚」:sortOrder 与完成时间的先后一致,只能验证倒序
createDone(store, '早完成', dayjs().subtract(5, 'day').toISOString())
createDone(store, '刚完成', dayjs().toISOString())
expect(doneTitles(mountBoard(pinia))).toEqual(['刚完成', '早完成'])
})
it('完成顺序与 sortOrder 相反时,仍以完成时间为准', () => {
const { pinia, store } = setup()
// 「早」先建(sortOrder 更小),但完成时间更晚
createDone(store, '早完成', dayjs().toISOString())
createDone(store, '刚完成', dayjs().subtract(5, 'day').toISOString())
expect(doneTitles(mountBoard(pinia))).toEqual(['早完成', '刚完成'])
})
it('已完成列始终停用列内排序(拖拽只允许跨列)', () => {
const { pinia } = setup()
const columns = mountBoard(pinia).findAllComponents(BoardColumn)
expect(columns[2]?.props('sortDisabled')).toBe(true)
expect(columns[0]?.props('sortDisabled')).toBe(false)
})
it('已完成列不响应上下方向键(列内顺序由完成时间决定)', async () => {
const { pinia, store } = setup()
createDone(store, '刚完成', dayjs().toISOString())
createDone(store, '早完成', dayjs().subtract(5, 'day').toISOString())
const wrapper = mountBoard(pinia)
const top = store.byColumn.done[0]
const before = store.byColumn.done.map((task) => task.title)
wrapper.findAllComponents(BoardColumn)[2]?.vm.$emit('keymove', { id: top?.id ?? '', direction: 'down' })
await nextTick()
expect(store.byColumn.done.map((task) => task.title)).toEqual(before)
expect(doneTitles(wrapper)).toEqual(['刚完成', '早完成'])
})
it('列头标注排序口径,避免用户以为拖不动是 bug', () => {
const { pinia } = setup()
const hint = mountBoard(pinia).find('[data-role="column-sort-hint"]')
expect(hint.text()).toBe('按完成时间倒序')
})
})
+18 -3
View File
@@ -15,7 +15,7 @@ import { useUiStore } from '@/stores/ui'
import { COLUMNS, PRIORITY_LABELS, type ColumnId, type MoveDirection, type Priority } from '@/types/task' import { COLUMNS, PRIORITY_LABELS, type ColumnId, type MoveDirection, type Priority } from '@/types/task'
import { findArchivableTasks } from '@/utils/archive' import { findArchivableTasks } from '@/utils/archive'
import { filterTasks } from '@/utils/filter' import { filterTasks } from '@/utils/filter'
import { groupByColumn } from '@/utils/sortOrder' import { groupByColumn, sortCompletedDesc } from '@/utils/sortOrder'
const store = useTaskStore() const store = useTaskStore()
const filters = useFilterStore() const filters = useFilterStore()
@@ -45,7 +45,11 @@ function setColumnRef(element: unknown, index: number): void {
} }
/** 实际渲染的看板:先按条件筛选,再按列分组,列计数随之反映结果 */ /** 实际渲染的看板:先按条件筛选,再按列分组,列计数随之反映结果 */
const visibleColumns = computed(() => groupByColumn(filterTasks(store.activeTasks, filter.value))) const visibleColumns = computed(() => {
const grouped = groupByColumn(filterTasks(store.activeTasks, filter.value))
// 已完成列按完成时间倒序展示,与 sortOrder 无关(见 sortCompletedDesc)
return { ...grouped, done: sortCompletedDesc(grouped.done) }
})
/** /**
* 每列被隐藏的数量(被筛选掉 + 已归档)。 * 每列被隐藏的数量(被筛选掉 + 已归档)。
@@ -64,6 +68,15 @@ const hiddenTotal = computed(() =>
COLUMNS.reduce((total, column) => total + hiddenCounts.value[column.id], 0), COLUMNS.reduce((total, column) => total + hiddenCounts.value[column.id], 0),
) )
/**
* 列内排序是否停用:
* - 该列渲染的是子集(被筛选 / 归档隐藏)时,Sortable 给的是子集下标,写回会坏全量顺序
* - 已完成列的顺序由完成时间决定(倒序),手动排序没有意义,一律停用
*/
function isSortDisabled(columnId: ColumnId): boolean {
return columnId === 'done' || hiddenCounts.value[columnId] > 0
}
/** /**
* 被「隐藏归档」挡掉、且本来能通过当前筛选的任务数。 * 被「隐藏归档」挡掉、且本来能通过当前筛选的任务数。
* *
@@ -163,6 +176,8 @@ function handleKeyMove(payload: { id: string; direction: MoveDirection }): void
if (!target) return if (!target) return
store.moveTask(task.id, target.id, store.byColumn[target.id].length) store.moveTask(task.id, target.id, store.byColumn[target.id].length)
} else { } else {
// 已完成列按完成时间倒序,列内调序没有意义,也不写 sortOrder
if (task.status === 'done') return
if (hiddenCounts.value[task.status] > 0) return if (hiddenCounts.value[task.status] > 0) return
const column = store.byColumn[task.status] const column = store.byColumn[task.status]
const currentIndex = column.findIndex((item) => item.id === task.id) const currentIndex = column.findIndex((item) => item.id === task.id)
@@ -336,7 +351,7 @@ watch(filter, () => {
:ref="(element) => setColumnRef(element, index)" :ref="(element) => setColumnRef(element, index)"
:column="column" :column="column"
:tasks="visibleColumns[column.id]" :tasks="visibleColumns[column.id]"
:sort-disabled="hiddenCounts[column.id] > 0" :sort-disabled="isSortDisabled(column.id)"
:selectable="selecting" :selectable="selecting"
:selected-ids="selectedIds" :selected-ids="selectedIds"
@toggle="toggleTask" @toggle="toggleTask"