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
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h } from 'vue'
import { useTaskDueClock } from './task-due-clock'
const cleanups: Array<() => void> = []
afterEach(() => {
cleanups.splice(0).forEach((cleanup) => cleanup())
vi.useRealTimers()
vi.restoreAllMocks()
})
function mountClock() {
let clock: ReturnType<typeof useTaskDueClock> | undefined
const host = document.createElement('div')
const app = createApp({
setup() {
clock = useTaskDueClock()
return () => h('span', String(clock!.value))
},
})
app.mount(host)
cleanups.push(() => app.unmount())
return { app, clock: clock! }
}
describe('useTaskDueClock', () => {
it('uses one self-rescheduling timeout aligned to the next minute', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 8, 10, 12, 34, 45, 250))
const timeout = vi.spyOn(window, 'setTimeout')
const { clock } = mountClock()
expect(clock.value).toBe(Date.now())
expect(timeout).toHaveBeenCalledTimes(1)
expect(timeout.mock.calls[0][1]).toBe(14_750)
vi.advanceTimersByTime(14_750)
expect(clock.value).toBe(new Date(2026, 8, 10, 12, 35).getTime())
expect(timeout).toHaveBeenCalledTimes(2)
})
it('stops while hidden and immediately resyncs when visible', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 8, 10, 12, 0, 10))
let hidden = false
vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden)
const clear = vi.spyOn(window, 'clearTimeout')
const { clock } = mountClock()
hidden = true
document.dispatchEvent(new Event('visibilitychange'))
expect(clear).toHaveBeenCalled()
vi.setSystemTime(new Date(2026, 8, 10, 13, 10))
hidden = false
document.dispatchEvent(new Event('visibilitychange'))
expect(clock.value).toBe(Date.now())
expect(vi.getTimerCount()).toBe(1)
})
it.each(['focus', 'pageshow'])('resyncs on %s without accumulating timers', (eventName) => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 8, 10, 12))
const { clock } = mountClock()
vi.setSystemTime(new Date(2026, 8, 10, 12, 5))
window.dispatchEvent(new Event(eventName))
expect(clock.value).toBe(Date.now())
expect(vi.getTimerCount()).toBe(1)
})
it('removes visibility, focus and pageshow listeners and clears the timer on unmount', () => {
vi.useFakeTimers()
const removeDocument = vi.spyOn(document, 'removeEventListener')
const removeWindow = vi.spyOn(window, 'removeEventListener')
const clear = vi.spyOn(window, 'clearTimeout')
const { app } = mountClock()
app.unmount()
expect(removeDocument).toHaveBeenCalledWith('visibilitychange', expect.any(Function))
expect(removeWindow).toHaveBeenCalledWith('focus', expect.any(Function))
expect(removeWindow).toHaveBeenCalledWith('pageshow', expect.any(Function))
expect(clear).toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
})
+57
View File
@@ -0,0 +1,57 @@
import { onMounted, onUnmounted, ref, type Ref } from 'vue'
function nextRefreshDelay(nowMs: number) {
const now = new Date(nowMs)
const nextMinute = new Date(nowMs)
nextMinute.setSeconds(0, 0)
nextMinute.setMinutes(nextMinute.getMinutes() + 1)
const nextMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime()
return Math.max(1, Math.min(nextMinute.getTime(), nextMidnight) - nowMs)
}
export function useTaskDueClock(): Ref<number> {
const nowMs = ref(Date.now())
let timer: number | undefined
function clearTimer() {
if (timer !== undefined) {
window.clearTimeout(timer)
timer = undefined
}
}
function schedule() {
clearTimer()
if (document.hidden) return
timer = window.setTimeout(() => {
nowMs.value = Date.now()
schedule()
}, nextRefreshDelay(Date.now()))
}
function sync() {
nowMs.value = Date.now()
schedule()
}
function handleVisibilityChange() {
if (document.hidden) clearTimer()
else sync()
}
onMounted(() => {
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('focus', sync)
window.addEventListener('pageshow', sync)
schedule()
})
onUnmounted(() => {
clearTimer()
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('focus', sync)
window.removeEventListener('pageshow', sync)
})
return nowMs
}
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest'
import { getTaskDuePresentation, parseTaskDue } from './task-due'
const localMs = (year: number, month: number, day: number, hour = 12, minute = 0, second = 0) =>
new Date(year, month - 1, day, hour, minute, second).getTime()
function timed(deltaMs: number, completed = false) {
const nowMs = localMs(2026, 9, 10, 12)
return getTaskDuePresentation({
dueAt: new Date(nowMs + deltaMs).toISOString(),
dueHasTime: true,
completed,
nowMs,
})
}
describe('parseTaskDue', () => {
it.each([
'', ' ', '2026/09/10', '2026-9-10', '2026-02-30', '2025-02-29',
'2026-13-01', '2026-00-01', '2026-09-31', '2026-09-10junk',
'2026-09-10T25:00', '2026-09-10T12:60', '2026-09-10T12:30:60',
'2026-09-10T12:30+24:00', '2026-09-10T12:30+14:01', '2026-09-10T12:30+08:60',
'2026-09-10T12:30Zjunk',
])('rejects invalid input without throwing: %s', (value) => {
expect(() => parseTaskDue(value, true)).not.toThrow()
expect(parseTaskDue(value, true)).toBeNull()
})
it('accepts leap dates, ISO seconds, fractions, Z and offsets', () => {
expect(parseTaskDue('2024-02-29', false)?.datetime).toBe('2024-02-29')
expect(parseTaskDue('2026-09-10T12:30:45.123Z', true)?.datetime).toBe('2026-09-10T12:30:45.123Z')
expect(parseTaskDue('2026-09-10T12:30+08:00', true)?.datetime).toBe(new Date('2026-09-10T04:30:00.000Z').toISOString())
})
it('constructs date literals as local calendar dates without UTC drift', () => {
const parsed = parseTaskDue('2026-09-10', false)
expect(parsed?.date.getFullYear()).toBe(2026)
expect(parsed?.date.getMonth()).toBe(8)
expect(parsed?.date.getDate()).toBe(10)
})
it('round-trips local datetimes and rejects normalized local values', () => {
const parsed = parseTaskDue('2026-09-10T12:30:45.5', true)
expect(parsed?.date.getFullYear()).toBe(2026)
expect(parsed?.date.getHours()).toBe(12)
expect(parsed?.date.getMinutes()).toBe(30)
expect(parseTaskDue('2026-02-30T12:00', true)).toBeNull()
})
it('uses the local calendar day of an API timestamp for date-only tasks', () => {
const instant = '2026-09-10T23:30:00.000Z'
const expected = new Date(instant)
const parsed = parseTaskDue(instant, false)
expect(parsed?.date.getFullYear()).toBe(expected.getFullYear())
expect(parsed?.date.getMonth()).toBe(expected.getMonth())
expect(parsed?.date.getDate()).toBe(expected.getDate())
})
})
describe('getTaskDuePresentation date-only tasks', () => {
const nowMs = localMs(2026, 9, 10, 18)
it('returns null for absent or invalid due dates', () => {
expect(getTaskDuePresentation({ dueAt: null, dueHasTime: false, completed: false, nowMs })).toBeNull()
expect(getTaskDuePresentation({ dueAt: '2026-02-30', dueHasTime: false, completed: false, nowMs })).toBeNull()
})
it.each([
['2026-09-09', '9月9日', '已过期1天', 'overdue'],
['2026-09-10', '9月10日', '今天', 'neutral'],
['2026-09-11', '9月11日', '还有1天', 'neutral'],
['2027-01-02', '2027年1月2日', '还有114天', 'neutral'],
] as const)('formats %s by local calendar day', (dueAt, absoluteText, relativeText, state) => {
const result = getTaskDuePresentation({ dueAt, dueHasTime: false, completed: false, nowMs })
expect(result).toMatchObject({ datetime: dueAt, absoluteText, relativeText, state })
expect(result?.fullText).toContain('截止日期:')
expect(result?.fullText).toContain('2026年' === absoluteText.slice(0, 5) ? absoluteText : dueAt.startsWith('2026') ? `2026年${absoluteText}` : absoluteText)
})
it('compares calendar dates across a DST-sized 23 hour gap', () => {
const before = new Date(2026, 2, 8, 23, 30)
const after = new Date(2026, 2, 9, 22, 30)
const result = getTaskDuePresentation({
dueAt: `${after.getFullYear()}-${String(after.getMonth() + 1).padStart(2, '0')}-${String(after.getDate()).padStart(2, '0')}`,
dueHasTime: false,
completed: false,
nowMs: before.getTime(),
})
expect(result?.relativeText).toBe('还有1天')
})
it.each([
['2026-09-09', '已完成(原定1天前)'],
['2026-09-10', '已完成(原定今天)'],
['2026-09-12', '已完成(原定2天后)'],
])('uses completed neutral wording for %s', (dueAt, relativeText) => {
expect(getTaskDuePresentation({ dueAt, dueHasTime: false, completed: true, nowMs })).toMatchObject({
relativeText,
state: 'completed',
})
})
})
describe('getTaskDuePresentation timed boundaries', () => {
const minute = 60_000
const hour = 60 * minute
const day = 24 * hour
it.each([
[59_000, '还有1分钟'], [60_000, '还有1分钟'], [59 * minute, '还有59分钟'],
[60 * minute, '还有1小时'], [hour + 1, '还有2小时'], [23 * hour + 59 * minute, '还有24小时'], [24 * hour, '还有1天'],
[-1, '刚过期'], [-59_000, '刚过期'], [-60_000, '已过期1分钟'],
[-(59 * minute), '已过期59分钟'], [-(60 * minute), '已过期1小时'],
[-(23 * hour + 59 * minute), '已过期23小时'], [-24 * hour, '已过期1天'],
] as const)('formats delta %d as %s', (delta, relativeText) => {
const result = timed(delta)
expect(result?.relativeText).toBe(relativeText)
expect(result?.state).toBe(delta < 0 ? 'overdue' : 'neutral')
})
it('formats an exact deadline', () => {
expect(timed(0)?.relativeText).toBe('现在到期')
})
it('uses absolute local time, canonical ISO and a full accessible description', () => {
const result = timed(2 * hour)
expect(result?.absoluteText).toMatch(/^9月10日 \d{2}:\d{2}$/)
expect(result?.datetime).toMatch(/^2026-09-10T/)
expect(result?.fullText).toMatch(/^截止时间:2026年9月10日 \d{2}:\d{2},还有2小时$/)
})
it('uses calendar-day completed wording even when the task has a time', () => {
expect(timed(-hour, true)).toMatchObject({ relativeText: '已完成(原定今天)', state: 'completed' })
})
})
+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,
}
}