feat: show task due distance
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 3m36s

This commit is contained in:
2026-09-10 16:14:09 +08:00
parent 3e80f2ecbb
commit 0345fbe73c
9 changed files with 604 additions and 18 deletions
+184
View File
@@ -0,0 +1,184 @@
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 relativeText = input.completed
? completedRelative(dayDelta)
: input.dueHasTime
? timedRelative(parsed.date.getTime() - input.nowMs)
: dayDelta === 0
? '今天'
: dayDelta > 0 ? `还有${dayDelta}` : `已过期${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}${relativeText}`,
state,
}
}