fix: align task due dates to the right
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m34s

This commit is contained in:
2026-09-10 17:01:24 +08:00
parent 0345fbe73c
commit 1366f302ec
8 changed files with 183 additions and 24 deletions
+26 -12
View File
@@ -6,7 +6,7 @@ import {
Settings, Trash2, X, Repeat2, Settings, Trash2, X, Repeat2,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig } from './lib/task-utils' import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig } from './lib/task-utils'
import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, normalizeRequiredName, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils' import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, performTrashMutation, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf' import { csrfHeader } from './lib/csrf'
import { createCompletionPulse } from './lib/completion-motion' import { createCompletionPulse } from './lib/completion-motion'
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag' import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
@@ -549,7 +549,7 @@ async function loadTrashPage() {
async function loadTrash() { async function loadTrash() {
loading.value = true loading.value = true
error.value = '' error.value = ''
await runLatestRequest('trash', loadTrashPage, { return await runLatestRequest('trash', loadTrashPage, {
success: (data) => { success: (data) => {
trash.value = data.items ?? [] trash.value = data.items ?? []
totalTasks.value = data.total ?? trash.value.length totalTasks.value = data.total ?? trash.value.length
@@ -808,12 +808,29 @@ async function removeTask(task: Task) {
toast('已移到回收站') toast('已移到回收站')
} catch (reason) { fail(reason) } } catch (reason) { fail(reason) }
} }
async function mutateTrashTask(task: Task, mutation: () => Promise<unknown>, successMessage: string) {
const result = await performTrashMutation(
mutation,
() => {
trash.value = trash.value.filter((item) => item.id !== task.id)
totalTasks.value = nextTotalAfterLocalTaskRemoval(totalTasks.value)
if (page.value > totalPages.value) page.value = totalPages.value
},
loadTrash,
)
if (!result.mutated) {
fail(result.error)
return
}
if (!result.refreshed) toast(`${successMessage},但列表刷新失败,请重试刷新`)
else toast(successMessage)
}
async function restoreTask(task: Task) { async function restoreTask(task: Task) {
try { await api(`/tasks/${task.id}/restore`, { method: 'POST' }); trash.value = trash.value.filter((item) => item.id !== task.id); toast('任务已恢复') } catch (reason) { fail(reason) } await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复')
} }
async function purgeTask(task: Task) { async function purgeTask(task: Task) {
if (!window.confirm(`永久删除“${task.title}”?这个操作不能撤销。`)) return if (!window.confirm(`永久删除“${task.title}”?这个操作不能撤销。`)) return
try { await api(`/trash/${task.id}`, { method: 'DELETE' }); trash.value = trash.value.filter((item) => item.id !== task.id); toast('已永久删除') } catch (reason) { fail(reason) } await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除')
} }
async function addSubtask() { async function addSubtask() {
if (!selectedTask.value) return if (!selectedTask.value) return
@@ -1203,8 +1220,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 :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 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 v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div><span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span></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> <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></div><span v-if="subtask.due_at" class="task-tail"><TaskDueDisplay :due-at="subtask.due_at" :due-has-time="subtask.due_has_time" :completed="subtask.completed" :now-ms="taskDueNowMs" /></span></article>
</template> </template>
</div> </div>
</section> </section>
@@ -1217,13 +1234,10 @@ 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 :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> <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 v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span><span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span></div>
<span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span> <span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView==='trash'" class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button></span><span v-else class="task-actions"><button class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button></span>
<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-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 :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> <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 v-if="activeView!=='trash'" 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 v-if="activeView!=='trash'" 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="activeView==='trash'?undefined:selectTaskUnlessSwiped(subtask)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(subtask)"><strong :title="subtask.title">{{subtask.title}}</strong></div><span v-if="subtask.due_at" class="task-tail"><TaskDueDisplay :due-at="subtask.due_at" :due-has-time="subtask.due_has_time" :completed="subtask.completed" :now-ms="taskDueNowMs" /></span></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>
@@ -34,7 +34,7 @@ describe('TaskDueDisplay', () => {
expect(time.textContent).toContain('今天') expect(time.textContent).toContain('今天')
expect(time.getAttribute('title')).toBe(time.getAttribute('aria-label')) expect(time.getAttribute('title')).toBe(time.getAttribute('aria-label'))
expect(time.getAttribute('title')).toContain('2026年9月10日') expect(time.getAttribute('title')).toContain('2026年9月10日')
expect(root.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true') expect(root.querySelector('svg')).toBeNull()
}) })
it('distinguishes incomplete overdue and completed neutral states', async () => { it('distinguishes incomplete overdue and completed neutral states', async () => {
+1 -3
View File
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { CalendarDays } from 'lucide-vue-next'
import { getTaskDuePresentation } from '../lib/task-due' import { getTaskDuePresentation } from '../lib/task-due'
const props = defineProps<{ const props = defineProps<{
@@ -15,7 +14,6 @@ const presentation = computed(() => getTaskDuePresentation(props))
<template> <template>
<span v-if="presentation" class="task-due" :class="`task-due--${presentation.state}`"> <span v-if="presentation" class="task-due" :class="`task-due--${presentation.state}`">
<CalendarDays class="task-due__icon" aria-hidden="true" />
<time <time
class="task-due__text" class="task-due__text"
:datetime="presentation.datetime" :datetime="presentation.datetime"
@@ -23,7 +21,7 @@ const presentation = computed(() => getTaskDuePresentation(props))
:aria-label="presentation.fullText" :aria-label="presentation.fullText"
> >
<span class="task-due__absolute">{{ presentation.absoluteText }}</span> <span class="task-due__absolute">{{ presentation.absoluteText }}</span>
<span aria-hidden="true"> · </span> <span class="task-due__separator" aria-hidden="true"> · </span>
<span class="task-due__relative">{{ presentation.relativeText }}</span> <span class="task-due__relative">{{ presentation.relativeText }}</span>
</time> </time>
</span> </span>
+40 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, normalizeRequiredName, numericHabitInputValue, performHabitRestore, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils' import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, numericHabitInputValue, performHabitRestore, performTrashMutation, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
describe('MVP view utilities', () => { describe('MVP view utilities', () => {
it('formats a local date as YYYY-MM-DD', () => { it('formats a local date as YYYY-MM-DD', () => {
@@ -17,6 +17,45 @@ describe('MVP view utilities', () => {
expect(['habits', 'settings', 'trash'].filter(isTaskView)).toEqual([]) expect(['habits', 'settings', 'trash'].filter(isTaskView)).toEqual([])
}) })
it('keeps Trash totals aligned after restoring or purging visible rows', () => {
expect(nextTotalAfterLocalTaskRemoval(2, 1)).toBe(1)
expect(nextTotalAfterLocalTaskRemoval(0, 1)).toBe(0)
expect(nextTotalAfterLocalTaskRemoval(Number.NaN, 1)).toBe(0)
})
it('does not reconcile Trash state when the mutation fails', async () => {
let reconciled = 0
const result = await performTrashMutation(
async () => { throw new Error('mutation failed') },
() => { reconciled += 1 },
async () => true,
)
expect(result).toEqual({ mutated: false, refreshed: false, error: expect.any(Error) })
expect(reconciled).toBe(0)
})
it('keeps confirmed Trash reconciliation when the refresh fails', async () => {
let reconciled = 0
const result = await performTrashMutation(
async () => undefined,
() => { reconciled += 1 },
async () => false,
)
expect(result).toEqual({ mutated: true, refreshed: false })
expect(reconciled).toBe(1)
})
it('reports a fully reconciled Trash mutation after refresh succeeds', async () => {
let reconciled = 0
const result = await performTrashMutation(
async () => undefined,
() => { reconciled += 1 },
async () => true,
)
expect(result).toEqual({ mutated: true, refreshed: true })
expect(reconciled).toBe(1)
})
it('restores the last page and selected task list after refresh', () => { it('restores the last page and selected task list after refresh', () => {
const storage = new Map<string, string>() const storage = new Map<string, string>()
const fakeStorage = { const fakeStorage = {
+28 -1
View File
@@ -141,6 +141,28 @@ export function nextTotalAfterLocalTaskAdd(total: number) {
return Math.max(0, Number(total) || 0) + 1 return Math.max(0, Number(total) || 0) + 1
} }
export function nextTotalAfterLocalTaskRemoval(total: number, removed = 1) {
return Math.max(0, (Number(total) || 0) - Math.max(0, removed))
}
export type TrashMutationResult =
| { mutated: false; refreshed: false; error: unknown }
| { mutated: true; refreshed: boolean }
export async function performTrashMutation(
mutate: () => Promise<unknown>,
reconcileLocal: () => void,
refresh: () => Promise<boolean>,
): Promise<TrashMutationResult> {
try {
await mutate()
} catch (error) {
return { mutated: false, refreshed: false, error }
}
reconcileLocal()
return { mutated: true, refreshed: await refresh() }
}
export function clampFabPosition(x: number, y: number, viewportWidth: number, viewportHeight: number, size = 52, margin = 14, bottomReserved = 74) { export function clampFabPosition(x: number, y: number, viewportWidth: number, viewportHeight: number, size = 52, margin = 14, bottomReserved = 74) {
return { return {
x: Math.min(Math.max(x, margin), Math.max(margin, viewportWidth - size - margin)), x: Math.min(Math.max(x, margin), Math.max(margin, viewportWidth - size - margin)),
@@ -190,14 +212,19 @@ export async function runLatestRequest<T>(
}, },
) { ) {
const generation = beginLatestRequest(key) const generation = beginLatestRequest(key)
let committed = false
try { try {
const value = await request() const value = await request()
if (isLatestRequest(key, generation)) callbacks.success(value) if (isLatestRequest(key, generation)) {
callbacks.success(value)
committed = true
}
} catch (reason) { } catch (reason) {
if (isLatestRequest(key, generation)) callbacks.error(reason) if (isLatestRequest(key, generation)) callbacks.error(reason)
} finally { } finally {
if (isLatestRequest(key, generation)) callbacks.finally() if (isLatestRequest(key, generation)) callbacks.finally()
} }
return committed
} }
export type MutationReconciler<TContext> = { export type MutationReconciler<TContext> = {
+25
View File
@@ -159,6 +159,31 @@ describe('request generation protection', () => {
expect(events).toEqual(['new:new', 'new:finally']) expect(events).toEqual(['new:new', 'new:finally'])
}) })
it('returns whether a latest request committed successfully', async () => {
await expect(runLatestRequest('trash-result-success', async () => 'ok', {
success: () => undefined,
error: () => undefined,
finally: () => undefined,
})).resolves.toBe(true)
await expect(runLatestRequest('trash-result-failure', async () => { throw new Error('offline') }, {
success: () => undefined,
error: () => undefined,
finally: () => undefined,
})).resolves.toBe(false)
})
it('returns false when a request becomes stale before it settles', async () => {
let finish!: () => void
const request = runLatestRequest('trash-result-stale', () => new Promise<void>((resolve) => { finish = resolve }), {
success: () => undefined,
error: () => undefined,
finally: () => undefined,
})
beginLatestRequest('trash-result-stale')
finish()
await expect(request).resolves.toBe(false)
})
it('prevents a stale Trash rejection from surfacing after a new view starts', async () => { it('prevents a stale Trash rejection from surfacing after a new view starts', async () => {
const events: string[] = [] const events: string[] = []
let rejectOld!: (reason: Error) => void let rejectOld!: (reason: Error) => void
File diff suppressed because one or more lines are too long
+59 -3
View File
@@ -32,12 +32,50 @@ describe('unified task due display', () => {
expect(overdue).not.toContain('swipe-bg') expect(overdue).not.toContain('swipe-bg')
}) })
it('uses semantic due colors and preserves task content and action tracks', () => { it('places every due display in a right tail before stable actions', () => {
expect(app.match(/<span v-if="[^\"]+\.due_at" class="task-tail"><TaskDueDisplay/g)).toHaveLength(4)
expect(app).not.toContain('class="meta"><TaskDueDisplay')
expect(app).toContain('</div><span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay')
expect(app).toContain('</span><span v-if="activeView===\'trash\'" class="task-actions">')
const ordinaryChildren = app.slice(app.indexOf('<article v-if="!collapsedTaskIds.has(node.task.id)"'), app.indexOf('</template>', app.indexOf('<article v-if="!collapsedTaskIds.has(node.task.id)"')))
expect(ordinaryChildren).toContain('v-if="activeView!==\'trash\'" class="drag-handle task-drag-handle"')
expect(ordinaryChildren).toContain('v-if="activeView!==\'trash\'" class="task-check"')
expect(ordinaryChildren).not.toContain('restoreTask(subtask)')
expect(ordinaryChildren).not.toContain('purgeTask(subtask)')
expect(ordinaryChildren).not.toContain('v-if="activeView===\'trash\'" class="task-actions"')
expect(app).toContain('<span v-else class="task-actions"><button class="icon ghost"')
expect(app).toContain('<span v-if="node.task.priority" class="priority"')
expect(app).toContain('v-if="node.subtasks.length" class="meta"')
expect(app).toContain('<span class="meta-item"><ListChecks/>')
})
it('uses a right-aligned desktop tail and a compact two-line mobile tail without empty slots', () => {
expect(css).toContain('.task-tail{flex:0 1 auto;min-width:0;max-width:')
expect(css).toContain('text-align:right;white-space:nowrap')
expect(css).toContain('.task-actions{display:flex;align-items:center;flex:0 0 auto}')
expect(css).toContain('.task-row>.drag-handle,.task-row>.task-check,.task-actions>*{flex-shrink:0}')
expect(css).toContain('@media(max-width:930px){.task-tail{flex:0 1 76px;max-width:76px;')
expect(css).toContain('.task-row:has(>.task-tail){height:66px;max-height:68px;}')
expect(css).toContain('.task-main{padding:4px 0}')
expect(css).toContain('.task-due__text{display:grid;justify-items:end;overflow:hidden;text-overflow:ellipsis;')
expect(css).toContain('.task-due__absolute,.task-due__relative{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}')
expect(css).toContain('.task-due__separator{display:none}')
expect(css).not.toContain('.task-main .meta{display:flex;flex-wrap:wrap;')
expect(css).toContain('.task-check{width:44px;height:44px;')
expect(css).toContain('.icon,.ghost{min-width:44px;min-height:44px;')
expect(css).toMatch(/\.restore\{[^}]*min-height:44px/)
})
it('scopes content metadata and keeps date semantics without a visible calendar icon', () => {
expect(css).toContain('.task-due--overdue{color:var(--danger)}') 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-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('.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('.meta-item{display:flex;align-items:center;gap:3px}')
expect(css).toContain('@media(max-width:930px){.task-main .meta{display:flex;flex-wrap:wrap;') expect(css).not.toContain('.meta span{display:flex;')
expect(taskDueDisplay).not.toContain('CalendarDays')
expect(taskDueDisplay).toContain('<time')
expect(taskDueDisplay).toContain(':aria-label="presentation.fullText"')
expect(taskDueDisplay).toContain('class="task-due__separator"')
expect(css).not.toContain('.overdue-task .meta') expect(css).not.toContain('.overdue-task .meta')
expect(css).not.toContain('.overdue-badge') expect(css).not.toContain('.overdue-badge')
}) })
@@ -396,6 +434,24 @@ describe('task and habit row decoration', () => {
expect(css).toMatch(/\.restore\{[^}]*min-height:44px/) expect(css).toMatch(/\.restore\{[^}]*min-height:44px/)
}) })
it('reconciles only top-level Trash mutations and distinguishes refresh failure', () => {
const loadBlock = app.slice(app.indexOf('async function loadTrash()'), app.indexOf('async function switchView'))
const mutationBlock = app.slice(app.indexOf('async function mutateTrashTask'), app.indexOf('async function restoreTask'))
const restoreBlock = app.slice(app.indexOf('async function restoreTask'), app.indexOf('async function purgeTask'))
const purgeBlock = app.slice(app.indexOf('async function purgeTask'), app.indexOf('async function addSubtask'))
expect(loadBlock).toContain('return await runLatestRequest')
expect(mutationBlock).toContain('const result = await performTrashMutation(')
expect(mutationBlock).toContain('trash.value = trash.value.filter((item) => item.id !== task.id)')
expect(mutationBlock).toContain('totalTasks.value = nextTotalAfterLocalTaskRemoval(totalTasks.value)')
expect(mutationBlock).toContain('if (page.value > totalPages.value) page.value = totalPages.value')
expect(mutationBlock).toContain('if (!result.mutated)')
expect(mutationBlock).toContain('fail(result.error)')
expect(mutationBlock).toContain('if (!result.refreshed)')
expect(mutationBlock).toContain('请重试刷新')
expect(restoreBlock).toContain("await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`")
expect(purgeBlock).toContain("await mutateTrashTask(task, () => api(`/trash/${task.id}`")
})
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(3) expect(app.match(/@pointerdown="startTaskPointer/g)?.length).toBe(3)
expect(app.match(/@pointermove="moveTaskPointer/g)?.length).toBe(3) expect(app.match(/@pointermove="moveTaskPointer/g)?.length).toBe(3)