feat: collapse Today sections
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 6m1s

This commit is contained in:
2026-09-15 09:47:02 +08:00
parent 4e8045159f
commit f5a1521a4f
7 changed files with 172 additions and 17 deletions
+32 -12
View File
@@ -22,6 +22,7 @@ import CompletedFilterPill from './components/CompletedFilterPill.vue'
import CalendarPicker from './components/CalendarPicker.vue'
import TaskDueDisplay from './components/TaskDueDisplay.vue'
import { useTaskDueClock } from './lib/task-due-clock'
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
@@ -97,6 +98,8 @@ const markdownPreview = ref(false)
const taskNoteEditor = ref<HTMLTextAreaElement | null>(null)
const SHOW_COMPLETED_STORAGE_KEY = 'dodo.show-completed'
const showCompleted = ref(readStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, true))
const TODAY_SECTION_COLLAPSE_KEY = 'dodo.today-section-collapse.v1'
const todaySectionCollapse = ref<TodaySectionCollapse>(readTodaySectionCollapse(window.localStorage, TODAY_SECTION_COLLAPSE_KEY))
const page = ref(1)
const pageSize = 50
const totalTasks = ref(0)
@@ -334,8 +337,23 @@ const todayHabitProgress = computed(() => `${todayHabitCompleted.value} / ${toda
const progressWidth = (completed: number, total: number) => `${total > 0 ? Math.min(100, Math.round(completed / total * 100)) : 0}%`
const todayTaskProgressPercent = computed(() => progressWidth(todayTaskCompleted.value, todayTaskTotal.value))
const todayHabitProgressPercent = computed(() => progressWidth(todayHabitCompleted.value, todayHabitTotal.value))
function scrollTodaySection(id: 'today-tasks' | 'today-habits') {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
function persistTodaySectionCollapse() {
writeTodaySectionCollapse(window.localStorage, TODAY_SECTION_COLLAPSE_KEY, todaySectionCollapse.value)
}
function toggleTodaySection(section: keyof TodaySectionCollapse) {
todaySectionCollapse.value[section] = !todaySectionCollapse.value[section]
persistTodaySectionCollapse()
}
async function navigateTodaySection(section: 'tasks' | 'habits') {
if (todaySectionCollapse.value[section]) {
todaySectionCollapse.value[section] = false
persistTodaySectionCollapse()
}
await nextTick()
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
const heading = document.getElementById(`today-${section}-heading`)
heading?.focus({ preventScroll: true })
document.getElementById(`today-${section}`)?.scrollIntoView({ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'start' })
}
function updateTodayHabitSummary(value: { total: number; completed: number }) {
todayHabitTotal.value = value.total
@@ -1438,29 +1456,29 @@ onUnmounted(() => {
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
<template v-else>
<section v-if="activeView==='today'" class="today-board" aria-label="今日进度">
<button class="today-track today-task-track" type="button" aria-controls="today-tasks" @click="scrollTodaySection('today-tasks')">
<button class="today-track today-task-track" type="button" aria-controls="today-tasks" @click="navigateTodaySection('tasks')">
<span class="today-track-head"><strong>任务 {{ todayTaskCompleted }} / {{ todayTaskTotal }}</strong></span>
<span class="today-track-rail" role="progressbar" aria-label="今日任务进度" aria-valuemin="0" :aria-valuemax="todayTaskTotal" :aria-valuenow="todayTaskCompleted"><i class="today-track-fill" :style="{ width: todayTaskProgressPercent }" /></span>
</button>
<button class="today-track today-habit-track" type="button" aria-controls="today-habits" @click="scrollTodaySection('today-habits')">
<button class="today-track today-habit-track" type="button" aria-controls="today-habits" @click="navigateTodaySection('habits')">
<span class="today-track-head"><strong>习惯 {{ todayHabitProgress }}</strong></span>
<span class="today-track-rail" role="progressbar" aria-label="今日习惯进度" aria-valuemin="0" :aria-valuemax="todayHabitTotal" :aria-valuenow="todayHabitCompleted"><i class="today-track-fill" :style="{ width: todayHabitProgressPercent }" /></span>
</button>
</section>
<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">
<section v-if="overdueTaskTree.length" class="overdue-section today-collapsible-section">
<button id="today-overdue-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.overdue" aria-controls="today-overdue" @click="toggleTodaySection('overdue')"><span class="today-section-title"><CalendarDays/>已过期</span><span class="today-section-summary">{{overdueTaskTree.length}}</span><ChevronRight v-if="todaySectionCollapse.overdue" aria-hidden="true"/><ChevronDown v-else aria-hidden="true"/></button>
<div v-show="!todaySectionCollapse.overdue" id="today-overdue" class="task-list overdue-list" role="region" aria-labelledby="today-overdue-heading">
<template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`">
<article :data-task-id="node.task.id" class="task-row overdue-task" :class="{'completion-exiting':completionExitingTaskIds.has(node.task.id)}"><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>
</template>
</div>
</section>
<h3 id="today-tasks" class="section-heading today-section-anchor"><ListTodo/>任务</h3>
<button id="today-tasks-heading" class="today-section-toggle today-section-anchor" type="button" :aria-expanded="!todaySectionCollapse.tasks" aria-controls="today-tasks" @click="toggleTodaySection('tasks')"><span class="today-section-title"><ListTodo/>任务</span><span class="today-section-summary">{{totalTasks}} 项</span><ChevronRight v-if="todaySectionCollapse.tasks" aria-hidden="true"/><ChevronDown v-else aria-hidden="true"/></button>
</template>
<div v-if="activeView!=='today'" class="list-toolbar"><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 v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
<section class="task-list" :class="{loading}">
<section :id="activeView==='today' ? 'today-tasks' : undefined" class="task-list" :class="{loading}" v-show="activeView!=='today' || !todaySectionCollapse.tasks" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : undefined">
<template v-for="node in taskTree" :key="node.task.id">
<article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,'just-completed': justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.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>
@@ -1472,9 +1490,11 @@ onUnmounted(() => {
<div v-if="activeView==='today' && !query && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading" class="today-filtered-empty-note"><span>已隐藏已完成任务</span><button type="button" class="link" @click="showCompleted=true">显示</button></div>
<div v-else-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>
<div v-if="activeView==='today'" id="today-habits" class="today-section-anchor">
<h3 class="section-heading"><Repeat2/>习惯</h3>
<MvpPanel ref="habitComposer" view="today-habits" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" @summary="updateTodayHabitSummary" />
<div v-if="activeView==='today'" class="today-habits-section today-section-anchor">
<button id="today-habits-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.habits" aria-controls="today-habits" @click="toggleTodaySection('habits')"><span class="today-section-title"><Repeat2/>习惯</span><span class="today-section-summary">{{todayHabitCompleted}} / {{todayHabitTotal}}</span><ChevronRight v-if="todaySectionCollapse.habits" aria-hidden="true"/><ChevronDown v-else aria-hidden="true"/></button>
<div v-show="!todaySectionCollapse.habits" id="today-habits" role="region" aria-labelledby="today-habits-heading">
<MvpPanel ref="habitComposer" view="today-habits" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" @summary="updateTodayHabitSummary" />
</div>
</div>
</template>
</main>