diff --git a/README.md b/README.md index 26a9323..eafae70 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - **深色模式**:跟随系统 / 浅色 / 深色三态 - 导出导入 JSON(**覆盖 / 合并**两种模式)+ 启动自动备份;覆盖或合并前都会自动留档 - 已完成任务可批量归档,归档任务可在详情里随时恢复 +- **任务时间**:详情抽屉显示创建 / 完成 / 更新 / 归档时间;已完成卡片标注完成日期,悬停卡片可看精确时间 - **可安装为 PWA,支持离线使用** --- @@ -156,6 +157,8 @@ python scripts/generate-icons.py 任务详情里的「状态」可以直接切换待办 / 进行中 / 已完成,不必回到看板拖拽。编辑后如果没保存就关闭,抽屉会先提醒,不会静默丢弃草稿。 +抽屉底部的「时间」区块是只读的:**创建** 与 **更新** 一律显示;切到「已完成」并保存后写入 **完成** 时间(未完成时标注「未完成」);归档任务额外显示归档时间。看板上已完成的卡片会在右下角标出完成日期,鼠标悬停任意卡片可看到精确到分钟的时间;回收站条目则同时给出创建时间与删除时间。 + > 如果需要在多设备间同步,需要自行接入后端;`TaskRepository` 接口就是为此预留的扩展点(见下)。 --- diff --git a/docs/adr.md b/docs/adr.md index cbab649..2cc78d2 100644 --- a/docs/adr.md +++ b/docs/adr.md @@ -203,3 +203,42 @@ MVP 有「启动快照 + 手动导出」两道保护,但两个整份覆盖的 泄漏到 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` 仅在多选态给出),读屏语义不变。 diff --git a/src/components/TaskCard.spec.ts b/src/components/TaskCard.spec.ts index 9aacc97..eb3d434 100644 --- a/src/components/TaskCard.spec.ts +++ b/src/components/TaskCard.spec.ts @@ -81,11 +81,32 @@ describe('截止日期', () => { it('已完成任务不再标记逾期', () => { const yesterday = dayjs().subtract(2, 'day').format('YYYY-MM-DD') - const wrapper = mountCard({ dueDate: yesterday, status: 'done' }) + const completedAt = dayjs().subtract(1, 'day').hour(10).minute(0).toISOString() + const wrapper = mountCard({ dueDate: yesterday, status: 'done', completedAt }) const badge = wrapper.findAll('span').find((node) => node.text().includes('逾期')) + expect(badge?.text()).toBe('逾期 1 天完成') expect(badge?.classes().join(' ')).not.toContain('rose') }) + it('当天完成的任务第二天不再显示逾期天数', () => { + // 复现:19 号截止、19 号完成,20 号查看时曾显示「逾期 1 天」 + const dueDate = dayjs().subtract(1, 'day').format('YYYY-MM-DD') + const completedAt = dayjs().subtract(1, 'day').hour(10).minute(0).toISOString() + const wrapper = mountCard({ dueDate, status: 'done', completedAt }) + + expect(wrapper.text()).not.toContain('逾期') + expect(wrapper.text()).toContain('原定') + expect(wrapper.text()).toMatch(/原定 \d+月\d+日/) + }) + + it('完成时已超期的任务显示「逾期 N 天完成」,不再随日期增长', () => { + const dueDate = dayjs().subtract(3, 'day').format('YYYY-MM-DD') + const completedAt = dayjs().subtract(2, 'day').hour(9).minute(0).toISOString() + const wrapper = mountCard({ dueDate, status: 'done', completedAt }) + + expect(wrapper.text()).toContain('逾期 1 天完成') + }) + it('今天到期显示「今天」', () => { expect(mountCard({ dueDate: dayjs().format('YYYY-MM-DD') }).text()).toContain('今天') }) @@ -105,6 +126,36 @@ describe('已完成样式', () => { }) }) +describe('时间信息', () => { + it('已完成卡片显示完成日期', () => { + const completedAt = new Date(2026, 9, 18, 10, 20).toISOString() + const wrapper = mountCard({ status: 'done', completedAt }) + expect(wrapper.text()).toContain('完成于') + expect(wrapper.text()).toContain('10月18日') + }) + + it('未完成卡片不显示完成时间', () => { + expect(mountCard({ completedAt: null }).text()).not.toContain('完成于') + }) + + it('卡片 title 暴露完整创建与完成时间', () => { + const createdAt = new Date(2026, 9, 18, 10, 8).toISOString() + const completedAt = new Date(2026, 9, 18, 10, 20).toISOString() + const title = mountCard({ status: 'done', createdAt, completedAt }) + .find('article') + .attributes('title') + + expect(title).toContain('创建于 2026-10-18 10:08') + expect(title).toContain('完成于 2026-10-18 10:20') + }) + + it('归档任务在 title 中补充归档时间', () => { + const archivedAt = new Date(2026, 9, 19, 8, 0).toISOString() + const title = mountCard({ archivedAt }).find('article').attributes('title') + expect(title).toContain('归档于 2026-10-19 08:00') + }) +}) + describe('标签展示', () => { it('最多显示 3 个标签', () => { const wrapper = mountCard({ tags: ['a', 'b', 'c', 'd', 'e'] }) diff --git a/src/components/TaskCard.vue b/src/components/TaskCard.vue index b5a576e..097588d 100644 --- a/src/components/TaskCard.vue +++ b/src/components/TaskCard.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import { PRIORITY_LABELS, type MoveDirection, type Task } from '@/types/task' -import { formatDueDate, isOverdue } from '@/utils/date' +import { formatCompactDate, formatDateTime, formatDueDateForTask, isOverdue } from '@/utils/date' const MAX_VISIBLE_TAGS = 3 @@ -60,9 +60,34 @@ function onKeydown(event: KeyboardEvent): void { const isDone = computed(() => props.task.status === 'done') const overdue = computed(() => isOverdue(props.task.dueDate, props.task.status)) + +/** + * 截止日期文案:已完成的任务以「完成日」为基准,未完成的以「今天」为基准。 + * 这样 19 号截止、19 号完成的任务第二天不会再被说成「逾期 1 天」。 + */ +const dueLabel = computed(() => + props.task.dueDate + ? formatDueDateForTask(props.task.dueDate, props.task.status, props.task.completedAt) + : null, +) const visibleTags = computed(() => props.task.tags.slice(0, MAX_VISIBLE_TAGS)) const hiddenTagCount = computed(() => Math.max(0, props.task.tags.length - MAX_VISIBLE_TAGS)) +/** + * 完成时间:已完成卡片上显示一行短日期,精确到分钟的时间留给 `title` 与详情抽屉。 + * 未完成时 completedAt 为 null,这一行整体不渲染,避免给待办卡片增加噪音。 + */ +const completedAtFull = computed(() => formatDateTime(props.task.completedAt)) +const completedAtShort = computed(() => formatCompactDate(props.task.completedAt)) + +/** 悬停卡片即可看到完整时间,不必先打开详情抽屉 */ +const timeTitle = computed(() => { + const lines = [`创建于 ${formatDateTime(props.task.createdAt) ?? '—'}`] + if (completedAtFull.value) lines.push(`完成于 ${completedAtFull.value}`) + if (props.task.archivedAt) lines.push(`归档于 ${formatDateTime(props.task.archivedAt) ?? '—'}`) + return lines.join('\n') +}) + /** * 卡片外观。选中态与「已完成」都要覆盖边框/背景, * 因此这些颜色放在条件分支里,而不是和基础类各写一份靠优先级取胜。 @@ -111,6 +136,7 @@ const priorityTextClass = computed(() => { :role="selectable ? 'checkbox' : undefined" :aria-checked="selectable ? selected === true : undefined" :aria-label="selectable ? `选择任务「${task.title}」` : undefined" + :title="timeTitle" tabindex="0" @click="activate" @keydown="onKeydown" @@ -186,14 +212,14 @@ const priorityTextClass = computed(() => { - {{ formatDueDate(task.dueDate) }} + {{ dueLabel }} @@ -202,6 +228,15 @@ const priorityTextClass = computed(() => { +{{ hiddenTagCount }} + + + 完成于 {{ completedAtShort }} + diff --git a/src/components/TaskDetailDrawer.spec.ts b/src/components/TaskDetailDrawer.spec.ts index 66fae01..3debea5 100644 --- a/src/components/TaskDetailDrawer.spec.ts +++ b/src/components/TaskDetailDrawer.spec.ts @@ -209,6 +209,46 @@ describe('TaskDetailDrawer 状态', () => { }) }) +describe('TaskDetailDrawer 时间信息', () => { + /** `YYYY-MM-DD HH:mm` */ + const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/ + + it('显示创建与更新时间,未完成时完成时间标注为「未完成」', () => { + const { wrapper } = setup() + + expect(wrapper.find('[data-role="created-at"]').text()).toMatch(DATE_TIME_PATTERN) + expect(wrapper.find('[data-role="updated-at"]').text()).toMatch(DATE_TIME_PATTERN) + expect(wrapper.find('[data-role="completed-at"]').text()).toBe('未完成') + }) + + it('任务完成后显示完成时间', async () => { + const { store, wrapper, taskId } = setup() + + store.updateTask(taskId, { status: 'done' }) + await wrapper.setProps({ task: store.tasks.find((item) => item.id === taskId) ?? null }) + + expect(wrapper.find('[data-role="completed-at"]').text()).toMatch(DATE_TIME_PATTERN) + }) + + it('已完成但缺少完成时间时显示「未记录」', async () => { + const { store, wrapper, taskId } = setup() + + store.updateTask(taskId, { status: 'done', completedAt: null }) + await wrapper.setProps({ task: store.tasks.find((item) => item.id === taskId) ?? null }) + + expect(wrapper.find('[data-role="completed-at"]').text()).toBe('未记录') + }) + + it('归档任务额外显示归档时间', async () => { + const { store, wrapper, taskId } = setup() + + store.archiveTasks([taskId]) + await wrapper.setProps({ task: store.tasks.find((item) => item.id === taskId) ?? null }) + + expect(wrapper.find('[data-role="archived-at"]').text()).toMatch(DATE_TIME_PATTERN) + }) +}) + describe('TaskDetailDrawer 归档', () => { it('归档任务显示恢复入口,点击后取消归档', async () => { const { store, toast, wrapper, taskId } = setup() diff --git a/src/components/TaskDetailDrawer.vue b/src/components/TaskDetailDrawer.vue index 5f15e5f..5f12236 100644 --- a/src/components/TaskDetailDrawer.vue +++ b/src/components/TaskDetailDrawer.vue @@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue' import { useTaskStore } from '@/stores/tasks' import { useToastStore } from '@/stores/toast' import { COLUMNS, PRIORITY_LABELS, type ColumnId, type Priority, type Task } from '@/types/task' +import { formatDateTime } from '@/utils/date' import DatePicker from './DatePicker.vue' import SideDrawer from './SideDrawer.vue' @@ -53,6 +54,29 @@ watch( const archived = computed(() => props.task !== null && props.task.archivedAt !== null) +/** + * 只读时间信息。 + * + * createdAt 与 updatedAt 由 store 维护,completedAt 在状态进出「已完成」时 + * 由 applyTaskPatch 写入 / 清除;这里只负责展示,不参与草稿与 dirty 判定 + * —— 时间不是用户能编辑的字段,改了标题也不该让「创建于」看起来被改过。 + */ +const createdAtText = computed(() => formatDateTime(props.task?.createdAt ?? null)) +const updatedAtText = computed(() => formatDateTime(props.task?.updatedAt ?? null)) +const completedAtText = computed(() => formatDateTime(props.task?.completedAt ?? null)) +const archivedAtText = computed(() => formatDateTime(props.task?.archivedAt ?? null)) + +/** + * 完成时间的兜底文案。 + * + * 旧数据或手工导入的数据可能出现「status 是已完成、completedAt 却是空」的情况, + * 此时显示「未记录」而不是「未完成」—— 后者会和卡片上的已完成状态自相矛盾。 + */ +const completedLabel = computed(() => { + if (completedAtText.value) return completedAtText.value + return props.task?.status === 'done' ? '未记录' : '未完成' +}) + function sameTags(left: readonly string[], right: readonly string[]): boolean { return left.length === right.length && left.every((tag, index) => tag === right[index]) } @@ -224,6 +248,40 @@ function remove(): void { 标签 + +
+

+ 时间 +

+
+
创建
+
+ {{ createdAtText ?? '—' }} +
+ +
完成
+
+ + {{ completedLabel }} + +
+ +
更新
+
+ {{ updatedAtText ?? '—' }} +
+ + +
+