feat(board): 展示任务时间,并修正已完成任务的逾期基准
时间展示(ADR-008): - 详情抽屉新增只读「时间」区块:创建 / 更新常驻,完成时间缺失时标注「未完成」,归档任务追加归档时间;时间不进入草稿与 dirty 判定 - 已完成卡片在元信息行右侧显示「完成于 M月D日」,卡片根元素 title 汇总创建 / 完成 / 归档时间(精确到分钟) - 回收站条目补创建时间,删掉内联 formatTime,统一走 @/utils/date - date.ts 新增 formatDateTime(YYYY-MM-DD HH:mm)与 formatCompactDate(同年 M月D日 / 跨年带年份),空值与非法时间戳返回 null 逾期基准修复: - 新增 formatDueDateForTask:未完成任务仍以「今天」推算;已完成任务改以「完成日」推算,19 号截止、19 号当天完成的任务不会在 20 号显示「逾期 1 天」,完成时确实超期则定格为「逾期 N 天完成」,不再随日期增长 - 新增 localDateOf:completedAt 存 UTC 时间戳,按本地时区取日期,避免跨日偏差 - TaskCard 改用 dueLabel computed 取代模板内直接调用 formatDueDate;颜色仍由 isOverdue 决定,已完成卡片保持中性色 测试:date / TaskCard 补时间格式化与逾期基准用例(当天完成、提前完成、完成时超期定格、缺 completedAt、跨年),全量 728 单测通过;vue-tsc 与 eslint 干净。
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
- **深色模式**:跟随系统 / 浅色 / 深色三态
|
||||
- 导出导入 JSON(**覆盖 / 合并**两种模式)+ 启动自动备份;覆盖或合并前都会自动留档
|
||||
- 已完成任务可批量归档,归档任务可在详情里随时恢复
|
||||
- **任务时间**:详情抽屉显示创建 / 完成 / 更新 / 归档时间;已完成卡片标注完成日期,悬停卡片可看精确时间
|
||||
- **可安装为 PWA,支持离线使用**
|
||||
|
||||
---
|
||||
@@ -156,6 +157,8 @@ python scripts/generate-icons.py
|
||||
|
||||
任务详情里的「状态」可以直接切换待办 / 进行中 / 已完成,不必回到看板拖拽。编辑后如果没保存就关闭,抽屉会先提醒,不会静默丢弃草稿。
|
||||
|
||||
抽屉底部的「时间」区块是只读的:**创建** 与 **更新** 一律显示;切到「已完成」并保存后写入 **完成** 时间(未完成时标注「未完成」);归档任务额外显示归档时间。看板上已完成的卡片会在右下角标出完成日期,鼠标悬停任意卡片可看到精确到分钟的时间;回收站条目则同时给出创建时间与删除时间。
|
||||
|
||||
> 如果需要在多设备间同步,需要自行接入后端;`TaskRepository` 接口就是为此预留的扩展点(见下)。
|
||||
|
||||
---
|
||||
|
||||
+39
@@ -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` 仅在多选态给出),读屏语义不变。
|
||||
|
||||
@@ -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'] })
|
||||
|
||||
@@ -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(() => {
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="task.dueDate"
|
||||
v-if="dueLabel"
|
||||
:class="
|
||||
overdue
|
||||
? 'font-medium text-rose-600 dark:text-rose-400'
|
||||
: 'text-slate-500 dark:text-zinc-400'
|
||||
"
|
||||
>
|
||||
{{ formatDueDate(task.dueDate) }}
|
||||
{{ dueLabel }}
|
||||
</span>
|
||||
|
||||
<span v-for="tag in visibleTags" :key="tag" class="text-slate-400 dark:text-zinc-500">
|
||||
@@ -202,6 +228,15 @@ const priorityTextClass = computed(() => {
|
||||
<span v-if="hiddenTagCount > 0" class="text-slate-400 dark:text-zinc-500">
|
||||
+{{ hiddenTagCount }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="completedAtShort"
|
||||
data-role="completed-at"
|
||||
:title="`完成于 ${completedAtFull}`"
|
||||
class="ml-auto text-emerald-700/80 dark:text-emerald-400/80"
|
||||
>
|
||||
完成于 {{ completedAtShort }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">标签</span>
|
||||
<TagInput v-model="tags" :suggestions="store.allTags" />
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="flex flex-col gap-2 rounded-md border border-slate-200 bg-slate-50/70 px-3 py-2.5 dark:border-zinc-800 dark:bg-zinc-800/40"
|
||||
aria-labelledby="task-time-heading"
|
||||
>
|
||||
<h3 id="task-time-heading" class="text-sm font-medium text-slate-600 dark:text-zinc-400">
|
||||
时间
|
||||
</h3>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
<dt class="text-slate-400 dark:text-zinc-500">创建</dt>
|
||||
<dd data-role="created-at" class="text-slate-600 dark:text-zinc-300">
|
||||
{{ createdAtText ?? '—' }}
|
||||
</dd>
|
||||
|
||||
<dt class="text-slate-400 dark:text-zinc-500">完成</dt>
|
||||
<dd data-role="completed-at" class="text-slate-600 dark:text-zinc-300">
|
||||
<span :class="completedAtText ? '' : 'text-slate-400 dark:text-zinc-500'">
|
||||
{{ completedLabel }}
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt class="text-slate-400 dark:text-zinc-500">更新</dt>
|
||||
<dd data-role="updated-at" class="text-slate-600 dark:text-zinc-300">
|
||||
{{ updatedAtText ?? '—' }}
|
||||
</dd>
|
||||
|
||||
<template v-if="archivedAtText">
|
||||
<dt class="text-slate-400 dark:text-zinc-500">归档</dt>
|
||||
<dd data-role="archived-at" class="text-slate-600 dark:text-zinc-300">
|
||||
{{ archivedAtText }}
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
|
||||
@@ -44,6 +44,7 @@ describe('TrashPanel 展示', () => {
|
||||
expect(wrapper.text()).toContain('被删除的任务')
|
||||
expect(wrapper.text()).toContain('原列 进行中')
|
||||
expect(wrapper.text()).toContain('删除于')
|
||||
expect(wrapper.text()).toContain('创建于')
|
||||
expect(wrapper.text()).toContain('共 1 个任务')
|
||||
})
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
* 恢复则保留原 sortOrder 与归档状态,回到原来的位置。
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { COLUMNS, type Task } from '@/types/task'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
|
||||
import SideDrawer from './SideDrawer.vue'
|
||||
|
||||
@@ -30,10 +30,6 @@ const columnTitles = Object.fromEntries(COLUMNS.map((column) => [column.id, colu
|
||||
|
||||
const count = computed(() => store.trashTasks.length)
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return dayjs(iso).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
function restore(task: Task): void {
|
||||
store.restoreTask(task)
|
||||
toast.show({ message: `已恢复「${task.title}」` })
|
||||
@@ -133,7 +129,8 @@ watch(
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-slate-400 dark:text-zinc-500">
|
||||
删除于 {{ formatTime(task.deletedAt ?? task.updatedAt) }} · 原列
|
||||
删除于 {{ formatDateTime(task.deletedAt ?? task.updatedAt) ?? '—' }} · 创建于
|
||||
{{ formatDateTime(task.createdAt) ?? '—' }} · 原列
|
||||
{{ columnTitles[task.status] }}
|
||||
</p>
|
||||
|
||||
|
||||
+98
-1
@@ -1,7 +1,16 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatDueDate, isDueToday, isOverdue, todayIso } from './date'
|
||||
import {
|
||||
formatCompactDate,
|
||||
formatDateTime,
|
||||
formatDueDate,
|
||||
formatDueDateForTask,
|
||||
isDueToday,
|
||||
isOverdue,
|
||||
localDateOf,
|
||||
todayIso,
|
||||
} from './date'
|
||||
|
||||
const today = todayIso()
|
||||
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD')
|
||||
@@ -67,3 +76,91 @@ describe('formatDueDate', () => {
|
||||
expect(formatDueDate('2026-11-01', '2026-10-31')).toBe('明天')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDueDateForTask', () => {
|
||||
it('未完成任务沿用「今天」为基准', () => {
|
||||
expect(formatDueDateForTask('2026-10-18', 'todo', null, '2026-10-19')).toBe('逾期 1 天')
|
||||
expect(formatDueDateForTask('2026-10-19', 'doing', null, '2026-10-19')).toBe('今天')
|
||||
})
|
||||
|
||||
it('当天完成的任务第二天不再显示逾期', () => {
|
||||
// 19 号截止、19 号 10:00 完成,20 号查看
|
||||
const completedAt = new Date(2026, 9, 19, 10, 0).toISOString()
|
||||
expect(formatDueDateForTask('2026-10-19', 'done', completedAt, '2026-10-20')).toBe(
|
||||
'原定 10月19日',
|
||||
)
|
||||
})
|
||||
|
||||
it('提前完成也只报原定日期', () => {
|
||||
const completedAt = new Date(2026, 9, 17, 9, 0).toISOString()
|
||||
expect(formatDueDateForTask('2026-10-19', 'done', completedAt, '2026-10-20')).toBe(
|
||||
'原定 10月19日',
|
||||
)
|
||||
})
|
||||
|
||||
it('完成时确实超期则定格为「逾期 N 天完成」,不随今天继续增长', () => {
|
||||
const completedAt = new Date(2026, 9, 19, 9, 0).toISOString()
|
||||
expect(formatDueDateForTask('2026-10-18', 'done', completedAt, '2026-10-20')).toBe(
|
||||
'逾期 1 天完成',
|
||||
)
|
||||
expect(formatDueDateForTask('2026-10-18', 'done', completedAt, '2026-11-30')).toBe(
|
||||
'逾期 1 天完成',
|
||||
)
|
||||
})
|
||||
|
||||
it('缺少 completedAt 的历史数据不做逾期推断', () => {
|
||||
expect(formatDueDateForTask('2026-10-18', 'done', null, '2026-10-20')).toBe('原定 10月18日')
|
||||
})
|
||||
|
||||
it('跨年的截止日期带上年份', () => {
|
||||
const completedAt = new Date(2026, 0, 2, 9, 0).toISOString()
|
||||
// 截止日在去年 → 补年份
|
||||
expect(formatDueDateForTask('2025-12-31', 'done', completedAt, '2026-01-03')).toBe(
|
||||
'逾期 2 天完成',
|
||||
)
|
||||
// 查看时已跨到次年 → 原定日期也要补年份
|
||||
expect(formatDueDateForTask('2026-01-02', 'done', completedAt, '2027-01-05')).toBe(
|
||||
'原定 2026年1月2日',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('localDateOf', () => {
|
||||
it('按本地时区截取日期,避免 UTC 跨日', () => {
|
||||
expect(localDateOf(new Date(2026, 9, 19, 0, 30).toISOString())).toBe('2026-10-19')
|
||||
})
|
||||
|
||||
it('空值与非法值返回 null', () => {
|
||||
expect(localDateOf(null)).toBeNull()
|
||||
expect(localDateOf('not-a-date')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDateTime', () => {
|
||||
it('按本地时区格式化为 YYYY-MM-DD HH:mm', () => {
|
||||
expect(formatDateTime(new Date(2026, 9, 18, 9, 5).toISOString())).toBe('2026-10-18 09:05')
|
||||
})
|
||||
|
||||
it('空值与非法值返回 null,交给调用方兜底', () => {
|
||||
expect(formatDateTime(null)).toBeNull()
|
||||
expect(formatDateTime('')).toBeNull()
|
||||
expect(formatDateTime('not-a-date')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatCompactDate', () => {
|
||||
const now = new Date(2026, 9, 19)
|
||||
|
||||
it('同年只显示月日', () => {
|
||||
expect(formatCompactDate(new Date(2026, 9, 18, 10, 30).toISOString(), now)).toBe('10月18日')
|
||||
})
|
||||
|
||||
it('跨年补上年份', () => {
|
||||
expect(formatCompactDate(new Date(2025, 11, 31).toISOString(), now)).toBe('2025年12月31日')
|
||||
})
|
||||
|
||||
it('空值与非法值返回 null', () => {
|
||||
expect(formatCompactDate(null, now)).toBeNull()
|
||||
expect(formatCompactDate('not-a-date', now)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,32 @@ export function isOverdue(
|
||||
return dueDate < today
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整时间戳(ISO 8601)→ `YYYY-MM-DD HH:mm`。
|
||||
*
|
||||
* 与 `dueDate` 不同,`createdAt` / `completedAt` 是带时区的时刻,
|
||||
* 这里按本机时区展示;无效值返回 null,由调用方决定占位文案,
|
||||
* 避免把 `Invalid Date` 直接甩到界面上。
|
||||
*/
|
||||
export function formatDateTime(iso: string | null): string | null {
|
||||
if (!iso) return null
|
||||
const parsed = dayjs(iso)
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片上使用的紧凑日期:今年内只给「M月D日」,跨年补上年份。
|
||||
* 卡片空间有限,精确到分钟的时间放在 `title` 提示与详情抽屉里。
|
||||
*/
|
||||
export function formatCompactDate(iso: string | null, now: Date = new Date()): string | null {
|
||||
if (!iso) return null
|
||||
const parsed = dayjs(iso)
|
||||
if (!parsed.isValid()) return null
|
||||
return parsed.year() === dayjs(now).year()
|
||||
? parsed.format('M月D日')
|
||||
: parsed.format('YYYY年M月D日')
|
||||
}
|
||||
|
||||
/** 今天到期:截止日期正是今天,且任务尚未完成。today 参数用于测试注入 */
|
||||
export function isDueToday(
|
||||
dueDate: string | null,
|
||||
@@ -34,6 +60,13 @@ export function isDueToday(
|
||||
return dueDate === today
|
||||
}
|
||||
|
||||
/** 时间戳(ISO 8601)→ 本地日期字符串 YYYY-MM-DD;空值或非法值返回 null */
|
||||
export function localDateOf(iso: string | null): string | null {
|
||||
if (!iso) return null
|
||||
const parsed = dayjs(iso)
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 人类可读的截止日期文案。
|
||||
* today 参数用于测试注入,业务代码不应传入。
|
||||
@@ -48,3 +81,30 @@ export function formatDueDate(dueDate: string, today: string = todayIso()): stri
|
||||
if (diff <= 7) return `${diff} 天后`
|
||||
return dayjs(dueDate).format('M月D日')
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片上的截止日期文案。
|
||||
*
|
||||
* 未完成任务按「今天」推算;已完成任务必须改用「完成日」推算 ——
|
||||
* 否则 19 号截止、19 号完成的任务,到 20 号会被说成「逾期 1 天」,
|
||||
* 逾期天数还会一天天变大。逾期一旦完成就该定格,按时完成则只报原定日期。
|
||||
*/
|
||||
export function formatDueDateForTask(
|
||||
dueDate: string,
|
||||
status: ColumnId,
|
||||
completedAt: string | null,
|
||||
today: string = todayIso(),
|
||||
): string {
|
||||
if (status !== 'done') return formatDueDate(dueDate, today)
|
||||
|
||||
const original = formatCompactDate(dueDate, dayjs(today).toDate()) ?? dueDate
|
||||
const completedDate = localDateOf(completedAt)
|
||||
// 历史数据可能缺 completedAt:不猜完成时刻,只报原定日期
|
||||
if (!completedDate) return `原定 ${original}`
|
||||
|
||||
const lateDays = dayjs(completedDate)
|
||||
.startOf('day')
|
||||
.diff(dayjs(dueDate).startOf('day'), 'day')
|
||||
|
||||
return lateDays > 0 ? `逾期 ${lateDays} 天完成` : `原定 ${original}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user