- 删除于 {{ formatTime(task.deletedAt ?? task.updatedAt) }} · 原列 + 删除于 {{ formatDateTime(task.deletedAt ?? task.updatedAt) ?? '—' }} · 创建于 + {{ formatDateTime(task.createdAt) ?? '—' }} · 原列 {{ columnTitles[task.status] }}
diff --git a/src/utils/date.spec.ts b/src/utils/date.spec.ts index e55bb4d..fb30477 100644 --- a/src/utils/date.spec.ts +++ b/src/utils/date.spec.ts @@ -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() + }) +}) diff --git a/src/utils/date.ts b/src/utils/date.ts index 0d04d5a..27759b9 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -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}` +}