feat: show task due distance
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user