187 lines
6.5 KiB
TypeScript
187 lines
6.5 KiB
TypeScript
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,
|
||
}
|
||
}
|