export type TaskDueState = 'neutral' | 'overdue' | 'completed' export type ParsedTaskDue = { date: Date datetime: string } export type TaskDuePresentation = { datetime: string absoluteText: string relativeText: string fullText: string state: TaskDueState } type DateParts = { year: number month: number day: number hour: number minute: number second: number millisecond: number } const DAY_MS = 86_400_000 const MINUTE_MS = 60_000 const HOUR_MS = 3_600_000 const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/ const DATETIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|[+-]\d{2}:\d{2})?$/ function daysInMonth(year: number, month: number) { return new Date(Date.UTC(year, month, 0)).getUTCDate() } function validParts(parts: DateParts) { return parts.month >= 1 && parts.month <= 12 && parts.day >= 1 && parts.day <= daysInMonth(parts.year, parts.month) && parts.hour >= 0 && parts.hour <= 23 && parts.minute >= 0 && parts.minute <= 59 && parts.second >= 0 && parts.second <= 59 } function dateParts(match: RegExpMatchArray): DateParts { return { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]), hour: Number(match[4] ?? 0), minute: Number(match[5] ?? 0), second: Number(match[6] ?? 0), millisecond: Number((match[7] ?? '').padEnd(3, '0')), } } function sameLocalParts(date: Date, parts: DateParts) { return date.getFullYear() === parts.year && date.getMonth() + 1 === parts.month && date.getDate() === parts.day && date.getHours() === parts.hour && date.getMinutes() === parts.minute && date.getSeconds() === parts.second && date.getMilliseconds() === parts.millisecond } export function parseTaskDue(value: string, dueHasTime: boolean): ParsedTaskDue | null { try { if (typeof value !== 'string' || !value || value.trim() !== value) return null const dateMatch = value.match(DATE_PATTERN) if (dateMatch) { const parts = dateParts(dateMatch) if (!validParts(parts)) return null const date = new Date(parts.year, parts.month - 1, parts.day) if (!Number.isFinite(date.getTime()) || !sameLocalParts(date, parts)) return null return { date, datetime: value } } const match = value.match(DATETIME_PATTERN) if (!match) return null const parts = dateParts(match) if (!validParts(parts)) return null const zone = match[8] if (zone && zone !== 'Z') { const [offsetHour, offsetMinute] = zone.slice(1).split(':').map(Number) if (offsetHour > 14 || offsetMinute > 59 || (offsetHour === 14 && offsetMinute !== 0)) return null } const date = zone ? new Date(value) : new Date(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, parts.millisecond) if (!Number.isFinite(date.getTime())) return null if (!zone && !sameLocalParts(date, parts)) return null if (!dueHasTime) { const localDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()) return { date: localDate, datetime: localDateLiteral(localDate) } } return { date, datetime: date.toISOString() } } catch { return null } } function localDateLiteral(date: Date) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` } function calendarDay(date: Date) { return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS } function calendarDelta(due: Date, now: Date) { return calendarDay(due) - calendarDay(now) } function absoluteDate(date: Date, currentYear: number) { const dateText = `${date.getMonth() + 1}月${date.getDate()}日` return date.getFullYear() === currentYear ? dateText : `${date.getFullYear()}年${dateText}` } function fullDate(date: Date) { return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日` } function localTime(date: Date) { return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` } function completedRelative(dayDelta: number) { if (dayDelta === 0) return '已完成(原定今天)' return dayDelta < 0 ? `已完成(原定${Math.abs(dayDelta)}天前)` : `已完成(原定${dayDelta}天后)` } function timedRelative(delta: number) { if (delta > 0) { if (delta < HOUR_MS) return `还有${Math.ceil(delta / MINUTE_MS)}分钟` if (delta < DAY_MS) return `还有${Math.ceil(delta / HOUR_MS)}小时` return `还有${Math.ceil(delta / DAY_MS)}天` } if (delta === 0) return '现在到期' const elapsed = Math.abs(delta) if (elapsed < MINUTE_MS) return '刚过期' if (elapsed < HOUR_MS) return `已过期${Math.max(1, Math.floor(elapsed / MINUTE_MS))}分钟` if (elapsed < DAY_MS) return `已过期${Math.floor(elapsed / HOUR_MS)}小时` return `已过期${Math.floor(elapsed / DAY_MS)}天` } export function getTaskDuePresentation(input: { dueAt: string | null dueHasTime: boolean completed: boolean nowMs: number }): TaskDuePresentation | null { if (!input.dueAt || !Number.isFinite(input.nowMs)) return null const parsed = parseTaskDue(input.dueAt, input.dueHasTime) if (!parsed) return null const now = new Date(input.nowMs) if (!Number.isFinite(now.getTime())) return null const dayDelta = calendarDelta(parsed.date, now) const absoluteDateText = absoluteDate(parsed.date, now.getFullYear()) const absoluteText = input.dueHasTime ? `${absoluteDateText} ${localTime(parsed.date)}` : absoluteDateText const completedDescription = completedRelative(dayDelta) const dateOnlyDescription = dayDelta === 0 ? '今天' : dayDelta > 0 ? `还有${dayDelta}天` : `已过期${Math.abs(dayDelta)}天` const relativeText = input.completed ? (input.dueHasTime ? completedDescription : '已完成') : input.dueHasTime ? timedRelative(parsed.date.getTime() - input.nowMs) : dayDelta === 0 ? '今天' : `${Math.abs(dayDelta)}天` const state: TaskDueState = input.completed ? 'completed' : input.dueHasTime ? (parsed.date.getTime() < input.nowMs ? 'overdue' : 'neutral') : (dayDelta < 0 ? 'overdue' : 'neutral') const fullAbsolute = `${fullDate(parsed.date)}${input.dueHasTime ? ` ${localTime(parsed.date)}` : ''}` const kind = input.dueHasTime ? '截止时间' : '截止日期' return { datetime: parsed.datetime, absoluteText, relativeText, fullText: `${kind}:${fullAbsolute},${input.completed ? completedDescription : input.dueHasTime ? relativeText : dateOnlyDescription}`, state, } }