Files
dodo/frontend/src/lib/task-due.ts
T
bboysoul ab8c0a5138
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m29s
fix: simplify date-only due labels
2026-09-10 17:22:06 +08:00

187 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}
}