feat: sort tasks by due time
This commit is contained in:
+35
-6
@@ -5,7 +5,7 @@ import {
|
||||
Ellipsis, GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
|
||||
Settings, Trash2, X, Repeat2,
|
||||
} 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, isSameTaskSortTier, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig } from './lib/task-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 { createCompletionPulse } from './lib/completion-motion'
|
||||
@@ -100,6 +100,7 @@ const taskPointerStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||||
const taskSwipeOffsets = ref<Record<string, number>>({})
|
||||
const taskReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null)
|
||||
const taskReorderTarget = ref('')
|
||||
const taskReorderBlocked = ref(false)
|
||||
const taskComposeOpen = ref(false)
|
||||
const composeTitle = ref('')
|
||||
const composeTitleError = ref('')
|
||||
@@ -622,6 +623,7 @@ function startTaskReorder(task: Task, event: PointerEvent) {
|
||||
if (activeView.value === 'trash' || loading.value || query.value || totalPages.value > 1) return
|
||||
taskReorder.value = { id: task.id, startY: event.clientY, offsetY: 0 }
|
||||
taskReorderTarget.value = task.id
|
||||
taskReorderBlocked.value = false
|
||||
try { (event.currentTarget as Element).setPointerCapture(event.pointerId) } catch { /* synthetic events */ }
|
||||
}
|
||||
function taskById(id: string) {
|
||||
@@ -636,16 +638,33 @@ function moveTaskReorder(task: Task, event: PointerEvent) {
|
||||
const row = document.elementsFromPoint(event.clientX, event.clientY)
|
||||
.map((element) => element.closest<HTMLElement>('[data-task-id]'))
|
||||
.find((element) => element && element !== handle.closest('[data-task-id]'))
|
||||
if (row?.dataset.taskId) taskReorderTarget.value = row.dataset.taskId
|
||||
const target = row?.dataset.taskId ? taskById(row.dataset.taskId) : undefined
|
||||
if (target && target.list_id === task.list_id && (target.parent_id ?? null) === (task.parent_id ?? null) && isSameTaskSortTier(task, target)) {
|
||||
taskReorderTarget.value = target.id
|
||||
taskReorderBlocked.value = false
|
||||
} else {
|
||||
taskReorderTarget.value = ''
|
||||
taskReorderBlocked.value = Boolean(target)
|
||||
}
|
||||
}
|
||||
async function finishTaskReorder(task: Task, event: PointerEvent) {
|
||||
const drag = taskReorder.value
|
||||
const targetId = taskReorderTarget.value
|
||||
const blocked = taskReorderBlocked.value
|
||||
taskReorder.value = null
|
||||
taskReorderTarget.value = ''
|
||||
taskReorderBlocked.value = false
|
||||
if (blocked) {
|
||||
toast('只能调整相同完成状态和截止时间档的任务顺序')
|
||||
return
|
||||
}
|
||||
if (!drag || drag.id !== task.id || !targetId || targetId === task.id) return
|
||||
const target = taskById(targetId)
|
||||
if (!target || (target.parent_id ?? null) !== (task.parent_id ?? null)) return
|
||||
if (!target || target.list_id !== task.list_id || (target.parent_id ?? null) !== (task.parent_id ?? null)) return
|
||||
if (!isSameTaskSortTier(task, target)) {
|
||||
toast('只能调整相同完成状态和截止时间档的任务顺序')
|
||||
return
|
||||
}
|
||||
const placement = event.clientY >= drag.startY ? 'after' : 'before'
|
||||
const previous = tasks.value
|
||||
const previousSelected = selectedTask.value
|
||||
@@ -657,12 +676,12 @@ async function finishTaskReorder(task: Task, event: PointerEvent) {
|
||||
if (reordered === parent.subtasks) return
|
||||
tasks.value = tasks.value.map((item) => item.id === parent.id ? { ...item, subtasks: reordered } : item)
|
||||
if (selectedTask.value?.id === parent.id) selectedTask.value = { ...selectedTask.value, subtasks: reordered }
|
||||
ids = reordered.map((item) => item.id)
|
||||
ids = reordered.filter((item) => isSameTaskSortTier(task, item)).map((item) => item.id)
|
||||
} else {
|
||||
const next = moveItemWithinScope(previous, task.id, targetId, placement)
|
||||
if (next === previous) return
|
||||
tasks.value = next
|
||||
ids = next.map((item) => item.id)
|
||||
ids = next.filter((item) => isSameTaskSortTier(task, item)).map((item) => item.id)
|
||||
}
|
||||
try {
|
||||
await api('/tasks/reorder', { method: 'PUT', body: JSON.stringify({ task_ids: ids }) })
|
||||
@@ -676,6 +695,7 @@ async function finishTaskReorder(task: Task, event: PointerEvent) {
|
||||
function cancelTaskReorder() {
|
||||
taskReorder.value = null
|
||||
taskReorderTarget.value = ''
|
||||
taskReorderBlocked.value = false
|
||||
}
|
||||
|
||||
function startTaskSwipe(task: Task, event: TouchEvent) {
|
||||
@@ -771,6 +791,15 @@ function selectTaskUnlessSwiped(task: Task, toggleChildren = false) {
|
||||
if (toggleChildren) toggleTaskChildren(task)
|
||||
selectTask(task)
|
||||
}
|
||||
async function refreshTodayAfterTaskSave() {
|
||||
if (activeView.value !== 'today') return
|
||||
const request = beginLatestRequest('tasks')
|
||||
await Promise.all([
|
||||
loadTasksPage(request),
|
||||
loadOverdueTasks(request),
|
||||
loadTodayTaskSummary(),
|
||||
])
|
||||
}
|
||||
async function saveTask() {
|
||||
if (!selectedTask.value) return
|
||||
const normalized = normalizeRequiredName(selectedTask.value.title)
|
||||
@@ -793,7 +822,7 @@ async function saveTask() {
|
||||
}
|
||||
}
|
||||
if (selectedTask.value) selectedTask.value = { ...selectedTask.value, ...updated }
|
||||
if (activeView.value === 'today') void loadTodayTaskSummary()
|
||||
await refreshTodayAfterTaskSave()
|
||||
toast('已保存')
|
||||
} catch (reason) { fail(reason) }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTaskRrule, defaultTaskDueAt, filterTasks, groupTaskTree, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
import { buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
|
||||
type SearchTask = {
|
||||
id: string
|
||||
@@ -33,6 +33,25 @@ describe('task utilities', () => {
|
||||
expect(groupTaskTree([parent])).toEqual([{ task: parent, subtasks: [child] }])
|
||||
})
|
||||
|
||||
it('classifies due edits for Today membership', () => {
|
||||
const start = new Date('2026-09-10T00:00:00+08:00')
|
||||
const end = new Date('2026-09-11T00:00:00+08:00')
|
||||
expect(classifyTaskForToday({ completed: false, due_at: '2026-09-09T12:00:00+08:00' }, start, end)).toBe('overdue')
|
||||
expect(classifyTaskForToday({ completed: false, due_at: '2026-09-10T12:00:00+08:00' }, start, end)).toBe('today')
|
||||
expect(classifyTaskForToday({ completed: false, due_at: '2026-09-11T12:00:00+08:00' }, start, end)).toBe('outside')
|
||||
expect(classifyTaskForToday({ completed: true, due_at: '2026-09-09T12:00:00+08:00' }, start, end)).toBe('outside')
|
||||
})
|
||||
|
||||
it('only allows manual movement within the same completion and deadline tier', () => {
|
||||
const noDueOpen = { completed: false, due_at: null }
|
||||
expect(isSameTaskSortTier(noDueOpen, { completed: false, due_at: null })).toBe(true)
|
||||
expect(isSameTaskSortTier({ completed: false, due_at: '2026-09-10T08:00:00Z' }, { completed: false, due_at: '2026-09-10T08:00:00Z' })).toBe(true)
|
||||
expect(isSameTaskSortTier({ completed: false, due_at: '2026-09-10T08:00:00Z' }, { completed: false, due_at: '2026-09-10T16:00:00+08:00' })).toBe(true)
|
||||
expect(isSameTaskSortTier(noDueOpen, { completed: false, due_at: '2026-09-10T08:00:00Z' })).toBe(false)
|
||||
expect(isSameTaskSortTier(noDueOpen, { completed: true, due_at: null })).toBe(false)
|
||||
expect(isSameTaskSortTier({ completed: false, due_at: '2026-09-10T08:00:00Z' }, { completed: false, due_at: '2026-09-11T08:00:00Z' })).toBe(false)
|
||||
})
|
||||
|
||||
it('moves an item before or after another item without changing other scopes', () => {
|
||||
const rows = [
|
||||
{ id: 'a', parent_id: null },
|
||||
|
||||
@@ -4,6 +4,7 @@ export type MinimalTask = {
|
||||
description?: string
|
||||
parent_id?: string | null
|
||||
completed?: boolean
|
||||
due_at?: string | null
|
||||
list_name?: string
|
||||
subtasks?: MinimalTask[]
|
||||
}
|
||||
@@ -92,6 +93,30 @@ export function groupTaskTree<T extends MinimalTask>(tasks: T[]) {
|
||||
}))
|
||||
}
|
||||
|
||||
export function classifyTaskForToday(
|
||||
task: Pick<MinimalTask, 'completed' | 'due_at'>,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): 'overdue' | 'today' | 'outside' {
|
||||
if (task.completed || !task.due_at) return 'outside'
|
||||
const due = Date.parse(task.due_at)
|
||||
if (!Number.isFinite(due)) return 'outside'
|
||||
if (due < start.valueOf()) return 'overdue'
|
||||
return due < end.valueOf() ? 'today' : 'outside'
|
||||
}
|
||||
|
||||
export function isSameTaskSortTier(
|
||||
source: Pick<MinimalTask, 'completed' | 'due_at'>,
|
||||
target: Pick<MinimalTask, 'completed' | 'due_at'>,
|
||||
) {
|
||||
if (Boolean(source.completed) !== Boolean(target.completed)) return false
|
||||
if (!source.due_at && !target.due_at) return true
|
||||
if (!source.due_at || !target.due_at) return false
|
||||
const sourceTime = Date.parse(source.due_at)
|
||||
const targetTime = Date.parse(target.due_at)
|
||||
return Number.isFinite(sourceTime) && Number.isFinite(targetTime) && sourceTime === targetTime
|
||||
}
|
||||
|
||||
export function moveItemWithinScope<T extends { id: string; parent_id?: string | null }>(items: T[], sourceId: string, targetId: string, placement: 'before' | 'after') {
|
||||
if (sourceId === targetId) return items
|
||||
const source = items.find((item) => item.id === sourceId)
|
||||
|
||||
@@ -629,6 +629,16 @@ describe('task and habit row decoration', () => {
|
||||
expect(mvpPanel).toContain("!showCompleted && habits.length ? '已完成的习惯已隐藏。'")
|
||||
})
|
||||
|
||||
it('reclassifies Today tasks after due edits without toggling page loading', () => {
|
||||
const saveBlock = app.slice(app.indexOf('async function saveTask()'), app.indexOf('async function removeTask'))
|
||||
const refreshBlock = app.slice(app.indexOf('async function refreshTodayAfterTaskSave()'), app.indexOf('async function saveTask()'))
|
||||
expect(saveBlock).toContain('await refreshTodayAfterTaskSave()')
|
||||
expect(refreshBlock).toContain('loadTasksPage(request)')
|
||||
expect(refreshBlock).toContain('loadOverdueTasks(request)')
|
||||
expect(refreshBlock).toContain('loadTodayTaskSummary()')
|
||||
expect(refreshBlock).not.toContain('loading.value = true')
|
||||
})
|
||||
|
||||
it('shows unfinished overdue tasks as a separate list inside Today', () => {
|
||||
expect(app).toContain('const overdueTasks = ref<Task[]>([])')
|
||||
expect(app).toContain("params.set('due_to', isoAtLocalDayOffset(0))")
|
||||
|
||||
Reference in New Issue
Block a user