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
+7 -10
View File
@@ -17,6 +17,8 @@ import CountdownPanel from './CountdownPanel.vue'
import FloatingAddButton from './components/FloatingAddButton.vue' import FloatingAddButton from './components/FloatingAddButton.vue'
import CompletedFilterPill from './components/CompletedFilterPill.vue' import CompletedFilterPill from './components/CompletedFilterPill.vue'
import CalendarPicker from './components/CalendarPicker.vue' import CalendarPicker from './components/CalendarPicker.vue'
import TaskDueDisplay from './components/TaskDueDisplay.vue'
import { useTaskDueClock } from './lib/task-due-clock'
type FolderItem = { id: string; name: string } type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean } type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
@@ -67,6 +69,7 @@ const query = ref('')
const error = ref('') const error = ref('')
const notice = ref('') const notice = ref('')
const loading = ref(false) const loading = ref(false)
const taskDueNowMs = useTaskDueClock()
const mobileSidebar = ref(false) const mobileSidebar = ref(false)
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false)
const mobileDetail = ref(false) const mobileDetail = ref(false)
@@ -1104,13 +1107,6 @@ function moveListWithinScope(item: TaskList, direction: 'up' | 'down') {
void persistListMove(item, item.folder_id, move.targetId, move.placement) void persistListMove(item, item.folder_id, move.targetId, move.placement)
closeSidebarAction() closeSidebarAction()
} }
function formatDue(value: string | null, hasTime = true) {
if (!value) return ''
const date = new Date(value)
return hasTime
? new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(date)
: new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric' }).format(date)
}
function previousPage() { function previousPage() {
if (page.value <= 1 || loading.value) return if (page.value <= 1 || loading.value) return
page.value -= 1 page.value -= 1
@@ -1207,7 +1203,8 @@ onUnmounted(() => {
<h3 class="section-heading overdue-heading"><CalendarDays/>已过期 <span>{{overdueTaskTree.length}}</span></h3> <h3 class="section-heading overdue-heading"><CalendarDays/>已过期 <span>{{overdueTaskTree.length}}</span></h3>
<div class="task-list overdue-list"> <div class="task-list overdue-list">
<template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`"> <template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`">
<article class="task-row overdue-task"><button class="task-check" :aria-label="`完成${node.task.title}`" :aria-pressed="false" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"></span></button><div class="task-main" role="button" tabindex="0" @click="selectTask(node.task)" @keydown.enter="selectTask(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span><CalendarDays/>{{formatDue(node.task.due_at,node.task.due_has_time)}}</span></span></div><span class="overdue-badge">已过期</span></article> <article class="task-row overdue-task"><button class="task-check" :aria-label="`完成${node.task.title}`" :aria-pressed="false" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"></span></button><div class="task-main" role="button" tabindex="0" @click="selectTask(node.task)" @keydown.enter="selectTask(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span class="meta"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div></article>
<article v-for="subtask in node.subtasks" :key="`overdue-subtask-${subtask.id}`" :data-task-id="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,'just-completed': justCompletedTaskIds.has(subtask.id),ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64,reordering:taskReorder?.id===subtask.id,'reorder-target':taskReorderTarget===subtask.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === subtask.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><button class="task-check" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong :title="subtask.title">{{subtask.title}}</strong><span v-if="subtask.due_at" class="meta"><TaskDueDisplay :due-at="subtask.due_at" :due-has-time="subtask.due_has_time" :completed="subtask.completed" :now-ms="taskDueNowMs" /></span></div></article>
</template> </template>
</div> </div>
</section> </section>
@@ -1220,13 +1217,13 @@ onUnmounted(() => {
<article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,'just-completed': justCompletedTaskIds.has(node.task.id),selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)"> <article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,'just-completed': justCompletedTaskIds.has(node.task.id),selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
<button v-if="activeView!=='trash'" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button> <button v-if="activeView!=='trash'" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
<button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button> <button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
<div class="task-main" role="button" tabindex="0" :aria-expanded="!collapsedTaskIds.has(node.task.id)" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at,node.task.due_has_time)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div> <div class="task-main" role="button" tabindex="0" :aria-expanded="!collapsedTaskIds.has(node.task.id)" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)"><strong :title="node.task.title">{{node.task.title}}</strong><span class="meta"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div>
<span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span> <span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span>
<button v-if="activeView==='trash'" class="restore" @click="restoreTask(node.task)"><ArchiveRestore/>恢复</button> <button v-if="activeView==='trash'" class="restore" @click="restoreTask(node.task)"><ArchiveRestore/>恢复</button>
<button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button> <button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button>
<button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button> <button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
</article> </article>
<article v-if="!collapsedTaskIds.has(node.task.id)" v-for="subtask in node.subtasks" :key="subtask.id" :data-task-id="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,'just-completed': justCompletedTaskIds.has(subtask.id),ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64,reordering:taskReorder?.id===subtask.id,'reorder-target':taskReorderTarget===subtask.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === subtask.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><button class="drag-handle task-drag-handle" aria-label="上下拖动子任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(subtask, $event)" @pointermove.stop="moveTaskReorder(subtask, $event)" @pointerup.stop="finishTaskReorder(subtask, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button><button class="task-check" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div></article> <article v-if="!collapsedTaskIds.has(node.task.id)" v-for="subtask in node.subtasks" :key="subtask.id" :data-task-id="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,'just-completed': justCompletedTaskIds.has(subtask.id),ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64,reordering:taskReorder?.id===subtask.id,'reorder-target':taskReorderTarget===subtask.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === subtask.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><button class="drag-handle task-drag-handle" aria-label="上下拖动子任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(subtask, $event)" @pointermove.stop="moveTaskReorder(subtask, $event)" @pointerup.stop="finishTaskReorder(subtask, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button><button class="task-check" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong :title="subtask.title">{{subtask.title}}</strong><span v-if="subtask.due_at" class="meta"><TaskDueDisplay :due-at="subtask.due_at" :due-has-time="subtask.due_has_time" :completed="subtask.completed" :now-ms="taskDueNowMs" /></span></div></article>
</template> </template>
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ query ? '没有匹配的任务' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{query?'换个关键词试试':hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span><button v-if="activeView==='today' && !query && hiddenCompletedTaskCount === 0" class="soft-button empty-action" @click="openTaskCompose"><Plus/>添加今天任务</button></div> <div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ query ? '没有匹配的任务' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{query?'换个关键词试试':hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span><button v-if="activeView==='today' && !query && hiddenCompletedTaskCount === 0" class="soft-button empty-action" @click="openTaskCompose"><Plus/>添加今天任务</button></div>
</section> </section>
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createApp, h, nextTick, ref } from 'vue'
import TaskDueDisplay from './TaskDueDisplay.vue'
const cleanups: Array<() => void> = []
const nowMs = new Date(2026, 8, 10, 12).getTime()
async function mountDue(props: { dueAt: string | null; dueHasTime: boolean; completed: boolean; nowMs: number }) {
const host = document.createElement('div')
document.body.append(host)
const values = ref(props)
const app = createApp({ setup: () => () => h(TaskDueDisplay, values.value) })
app.mount(host)
cleanups.push(() => { app.unmount(); host.remove() })
await nextTick()
return { host, values }
}
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
describe('TaskDueDisplay', () => {
it.each([null, '', '2026-02-30'])('renders no DOM for %s', async (dueAt) => {
const { host } = await mountDue({ dueAt, dueHasTime: false, completed: false, nowMs })
expect(host.innerHTML).toBe('<!--v-if-->')
})
it('renders semantic canonical time with absolute and relative copy', async () => {
const { host } = await mountDue({ dueAt: '2026-09-10', dueHasTime: false, completed: false, nowMs })
const root = host.querySelector('.task-due')!
const time = root.querySelector('time')!
expect(root.classList.contains('task-due--neutral')).toBe(true)
expect(time.getAttribute('datetime')).toBe('2026-09-10')
expect(time.textContent).toContain('9月10日')
expect(time.textContent).toContain('今天')
expect(time.getAttribute('title')).toBe(time.getAttribute('aria-label'))
expect(time.getAttribute('title')).toContain('2026年9月10日')
expect(root.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true')
})
it('distinguishes incomplete overdue and completed neutral states', async () => {
const overdue = await mountDue({ dueAt: '2026-09-09', dueHasTime: false, completed: false, nowMs })
expect(overdue.host.querySelector('.task-due--overdue')).not.toBeNull()
const completed = await mountDue({ dueAt: '2026-09-09', dueHasTime: false, completed: true, nowMs })
expect(completed.host.querySelector('.task-due--completed')).not.toBeNull()
expect(completed.host.querySelector('.task-due--overdue')).toBeNull()
expect(completed.host.textContent).toContain('已完成(原定1天前)')
})
it('reacts when the shared nowMs prop advances', async () => {
const dueAt = new Date(nowMs + 60_000).toISOString()
const { host, values } = await mountDue({ dueAt, dueHasTime: true, completed: false, nowMs })
expect(host.textContent).toContain('还有1分钟')
values.value = { ...values.value, nowMs: nowMs + 60_001 }
await nextTick()
expect(host.textContent).toContain('刚过期')
})
})
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CalendarDays } from 'lucide-vue-next'
import { getTaskDuePresentation } from '../lib/task-due'
const props = defineProps<{
dueAt: string | null
dueHasTime: boolean
completed: boolean
nowMs: number
}>()
const presentation = computed(() => getTaskDuePresentation(props))
</script>
<template>
<span v-if="presentation" class="task-due" :class="`task-due--${presentation.state}`">
<CalendarDays class="task-due__icon" aria-hidden="true" />
<time
class="task-due__text"
:datetime="presentation.datetime"
:title="presentation.fullText"
:aria-label="presentation.fullText"
>
<span class="task-due__absolute">{{ presentation.absoluteText }}</span>
<span aria-hidden="true"> · </span>
<span class="task-due__relative">{{ presentation.relativeText }}</span>
</time>
</span>
</template>
+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,
}
}
File diff suppressed because one or more lines are too long
+50 -5
View File
@@ -8,6 +8,40 @@ const mvpPanel = readFileSync('src/MvpPanel.vue', 'utf8')
const countdownPanel = readFileSync('src/CountdownPanel.vue', 'utf8') const countdownPanel = readFileSync('src/CountdownPanel.vue', 'utf8')
const floatingAdd = readFileSync('src/components/FloatingAddButton.vue', 'utf8') const floatingAdd = readFileSync('src/components/FloatingAddButton.vue', 'utf8')
const completedFilterPill = readFileSync('src/components/CompletedFilterPill.vue', 'utf8') const completedFilterPill = readFileSync('src/components/CompletedFilterPill.vue', 'utf8')
const taskDueDisplay = readFileSync('src/components/TaskDueDisplay.vue', 'utf8')
describe('unified task due display', () => {
it('uses the shared display for overdue, ordinary parent, and child task rows', () => {
expect(app).toContain("import TaskDueDisplay from './components/TaskDueDisplay.vue'")
expect(app.match(/<TaskDueDisplay/g)).toHaveLength(4)
expect(app).toContain(':due-at="node.task.due_at"')
expect(app).toContain(':due-at="subtask.due_at"')
expect(app).not.toContain('formatDue(')
expect(app).not.toContain('overdue-badge')
expect(taskDueDisplay).not.toMatch(/setInterval|setTimeout/)
})
it('keeps overdue parent subtasks interactive without duplicating ordinary rows', () => {
const overdue = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('<h3 id="today-tasks"'))
expect(overdue).toContain('v-for="subtask in node.subtasks"')
expect(overdue).toContain('@click.stop="toggle(subtask)"')
expect(overdue).toContain('@click="selectTaskUnlessSwiped(subtask)"')
expect(overdue).not.toContain('class="drag-handle task-drag-handle"')
expect(overdue).not.toContain('startTaskReorder(subtask, $event)')
expect(overdue.match(/v-for="subtask in node.subtasks"/g)).toHaveLength(1)
expect(overdue).not.toContain('swipe-bg')
})
it('uses semantic due colors and preserves task content and action tracks', () => {
expect(css).toContain('.task-due--overdue{color:var(--danger)}')
expect(css).toContain('.task-due--neutral,.task-due--completed{color:var(--muted)}')
expect(css).toContain('.task-main{border:0;background:transparent;flex:1;min-width:0;')
expect(css).toContain('.icon,.ghost{min-width:44px;min-height:44px;')
expect(css).toContain('@media(max-width:930px){.task-main .meta{display:flex;flex-wrap:wrap;')
expect(css).not.toContain('.overdue-task .meta')
expect(css).not.toContain('.overdue-badge')
})
})
describe('mobile navigation styles', () => { describe('mobile navigation styles', () => {
it('renames the bottom More tab to a direct Settings tab', () => { it('renames the bottom More tab to a direct Settings tab', () => {
@@ -351,11 +385,22 @@ describe('task and habit row decoration', () => {
expect(mvpPanel.match(/'--reorder-y': `\$\{habitReorder\?\.id === h\.id \? habitReorder\.offsetY : 0\}px`/g)?.length).toBe(1) expect(mvpPanel.match(/'--reorder-y': `\$\{habitReorder\?\.id === h\.id \? habitReorder\.offsetY : 0\}px`/g)?.length).toBe(1)
}) })
it('exposes complete task titles on every ellipsized parent and child title node', () => {
expect(app.match(/<strong :title="node\.task\.title">\{\{node\.task\.title\}\}<\/strong>/g)).toHaveLength(2)
expect(app.match(/<strong :title="subtask\.title">\{\{subtask\.title\}\}<\/strong>/g)).toHaveLength(2)
expect(app).not.toContain('<strong>{{node.task.title}}</strong>')
expect(app).not.toContain('<strong>{{subtask.title}}</strong>')
})
it('keeps the Trash restore action at least 44px tall', () => {
expect(css).toMatch(/\.restore\{[^}]*min-height:44px/)
})
it('supports pointer dragging for desktop task and subtask completion', () => { it('supports pointer dragging for desktop task and subtask completion', () => {
expect(app.match(/@pointerdown="startTaskPointer/g)?.length).toBe(2) expect(app.match(/@pointerdown="startTaskPointer/g)?.length).toBe(3)
expect(app.match(/@pointermove="moveTaskPointer/g)?.length).toBe(2) expect(app.match(/@pointermove="moveTaskPointer/g)?.length).toBe(3)
expect(app.match(/@pointerup="finishTaskPointer/g)?.length).toBe(2) expect(app.match(/@pointerup="finishTaskPointer/g)?.length).toBe(3)
expect(app.match(/@pointercancel="cancelTaskPointer/g)?.length).toBe(2) expect(app.match(/@pointercancel="cancelTaskPointer/g)?.length).toBe(3)
}) })
it('supports pointer dragging for desktop habit rows in Today and Habits views', () => { it('supports pointer dragging for desktop habit rows in Today and Habits views', () => {
@@ -400,7 +445,7 @@ describe('task and habit row decoration', () => {
expect(app).not.toContain('class="sr-only" :aria-label="node.task.completed') expect(app).not.toContain('class="sr-only" :aria-label="node.task.completed')
expect(app).not.toContain('class="sr-only" :aria-label="subtask.completed') expect(app).not.toContain('class="sr-only" :aria-label="subtask.completed')
expect(app).toContain('class="task-check"') expect(app).toContain('class="task-check"')
expect(app.match(/class="task-check"/g)?.length).toBe(3) expect(app.match(/class="task-check"/g)?.length).toBe(4)
expect(app).toContain('class="task-check-mark"') expect(app).toContain('class="task-check-mark"')
expect(css).toContain('.task-check{') expect(css).toContain('.task-check{')
expect(css).toContain('.task-check-mark{') expect(css).toContain('.task-check-mark{')