feat: show overdue tasks on today view
ci / docker (push) Successful in 4m16s

This commit is contained in:
2026-09-08 09:05:19 +08:00
parent 0dd67f2824
commit 5e0c168da1
3 changed files with 36 additions and 2 deletions
+23
View File
@@ -28,6 +28,7 @@ const folders = ref<FolderItem[]>([])
const lists = ref<TaskList[]>([]) const lists = ref<TaskList[]>([])
const archivedLists = ref<TaskList[]>([]) const archivedLists = ref<TaskList[]>([])
const tasks = ref<Task[]>([]) const tasks = ref<Task[]>([])
const overdueTasks = ref<Task[]>([])
const trash = ref<Task[]>([]) const trash = ref<Task[]>([])
const activeList = ref('') const activeList = ref('')
const activeView = ref<View>(defaultView()) const activeView = ref<View>(defaultView())
@@ -227,6 +228,7 @@ const visibleTasks = computed(() => {
const selectedTaskSubtasks = computed(() => selectedTask.value?.subtasks ?? []) const selectedTaskSubtasks = computed(() => selectedTask.value?.subtasks ?? [])
const filteredTaskTree = computed(() => groupTaskTree(visibleTasks.value)) const filteredTaskTree = computed(() => groupTaskTree(visibleTasks.value))
const taskTree = computed(() => filteredTaskTree.value) const taskTree = computed(() => filteredTaskTree.value)
const overdueTaskTree = computed(() => groupTaskTree(overdueTasks.value))
let searchTimer: number | undefined let searchTimer: number | undefined
watch(query, () => { watch(query, () => {
if (searchTimer) window.clearTimeout(searchTimer) if (searchTimer) window.clearTimeout(searchTimer)
@@ -321,6 +323,12 @@ function isoAtLocalDayOffset(offset: number) {
day.setHours(0, 0, 0, 0) day.setHours(0, 0, 0, 0)
return day.toISOString() return day.toISOString()
} }
async function loadOverdueTasks() {
const params = new URLSearchParams()
params.set('due_to', isoAtLocalDayOffset(0))
params.set('completed', 'false')
overdueTasks.value = await loadTaskPages(`/tasks?${params}`)
}
async function loadTasksPage() { async function loadTasksPage() {
const params = new URLSearchParams({ page: String(page.value), page_size: String(pageSize) }) const params = new URLSearchParams({ page: String(page.value), page_size: String(pageSize) })
if (query.value) params.set('q', query.value) if (query.value) params.set('q', query.value)
@@ -356,6 +364,8 @@ async function loadAll() {
try { try {
if (!navigationLoaded.value) await loadNavigation() if (!navigationLoaded.value) await loadNavigation()
await loadTasksPage() await loadTasksPage()
if (activeView.value === 'today') await loadOverdueTasks()
else overdueTasks.value = []
if (page.value > totalPages.value) { page.value = totalPages.value; await loadTasksPage() } if (page.value > totalPages.value) { page.value = totalPages.value; await loadTasksPage() }
} catch (reason) { fail(reason) } finally { loading.value = false } } catch (reason) { fail(reason) } finally { loading.value = false }
} }
@@ -389,6 +399,11 @@ async function patchTask(task: Task, patch: Partial<Task>) {
const updated = await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ ...patch, version: task.version }) }) const updated = await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ ...patch, version: task.version }) })
const index = tasks.value.findIndex((item) => item.id === task.id) const index = tasks.value.findIndex((item) => item.id === task.id)
if (index >= 0) tasks.value[index] = { ...tasks.value[index], ...updated } if (index >= 0) tasks.value[index] = { ...tasks.value[index], ...updated }
const overdueIndex = overdueTasks.value.findIndex((item) => item.id === task.id)
if (overdueIndex >= 0) {
if (updated.completed) overdueTasks.value.splice(overdueIndex, 1)
else overdueTasks.value[overdueIndex] = { ...overdueTasks.value[overdueIndex], ...updated }
}
if (selectedTask.value?.id === task.id) selectedTask.value = { ...selectedTask.value, ...updated } if (selectedTask.value?.id === task.id) selectedTask.value = { ...selectedTask.value, ...updated }
return updated as Task return updated as Task
} }
@@ -682,6 +697,14 @@ onMounted(bootstrap)
<div class="today-stat muted"><strong>{{ totalTasks }}</strong><span>今日任务</span></div> <div class="today-stat muted"><strong>{{ totalTasks }}</strong><span>今日任务</span></div>
</section> </section>
<template v-if="activeView==='today'"> <template v-if="activeView==='today'">
<section v-if="overdueTaskTree.length" class="overdue-section">
<h3 class="section-heading overdue-heading"><CalendarDays/>已过期 <span>{{overdueTaskTree.length}}</span></h3>
<div class="task-list overdue-list">
<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)}}</span></span></div><span class="overdue-badge">已过期</span></article>
</template>
</div>
</section>
<h3 class="section-heading"><ListTodo/>任务</h3> <h3 class="section-heading"><ListTodo/>任务</h3>
</template> </template>
<div class="list-toolbar"><label v-if="activeView!=='trash'"><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><button v-if="query" class="link" @click="query=''">清除搜索</button></div> <div class="list-toolbar"><label v-if="activeView!=='trash'"><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><button v-if="query" class="link" @click="query=''">清除搜索</button></div>
File diff suppressed because one or more lines are too long
+12 -1
View File
@@ -81,7 +81,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(2) expect(app.match(/class="task-check"/g)?.length).toBe(3)
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{')
@@ -112,6 +112,17 @@ describe('task and habit row decoration', () => {
expect(css).toContain('.habit-toolbar{') expect(css).toContain('.habit-toolbar{')
}) })
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))")
expect(app).toContain("params.set('completed', 'false')")
expect(app).toContain('class="overdue-section"')
expect(app).toContain('已过期')
expect(app).toContain('v-for="node in overdueTaskTree"')
expect(css).toContain('.overdue-section{')
expect(css).toContain('.overdue-heading{')
})
it('keeps Today as a low-noise dashboard with direct empty-state actions', () => { it('keeps Today as a low-noise dashboard with direct empty-state actions', () => {
expect(app).toContain('class="today-board"') expect(app).toContain('class="today-board"')
expect(app).toContain('todayOpenTasks') expect(app).toContain('todayOpenTasks')