feat: collapse Today sections
This commit is contained in:
+31
-11
@@ -22,6 +22,7 @@ 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 TaskDueDisplay from './components/TaskDueDisplay.vue'
|
||||||
import { useTaskDueClock } from './lib/task-due-clock'
|
import { useTaskDueClock } from './lib/task-due-clock'
|
||||||
|
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
|
||||||
|
|
||||||
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 }
|
||||||
@@ -97,6 +98,8 @@ const markdownPreview = ref(false)
|
|||||||
const taskNoteEditor = ref<HTMLTextAreaElement | null>(null)
|
const taskNoteEditor = ref<HTMLTextAreaElement | null>(null)
|
||||||
const SHOW_COMPLETED_STORAGE_KEY = 'dodo.show-completed'
|
const SHOW_COMPLETED_STORAGE_KEY = 'dodo.show-completed'
|
||||||
const showCompleted = ref(readStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, true))
|
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 page = ref(1)
|
||||||
const pageSize = 50
|
const pageSize = 50
|
||||||
const totalTasks = ref(0)
|
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 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 todayTaskProgressPercent = computed(() => progressWidth(todayTaskCompleted.value, todayTaskTotal.value))
|
||||||
const todayHabitProgressPercent = computed(() => progressWidth(todayHabitCompleted.value, todayHabitTotal.value))
|
const todayHabitProgressPercent = computed(() => progressWidth(todayHabitCompleted.value, todayHabitTotal.value))
|
||||||
function scrollTodaySection(id: 'today-tasks' | 'today-habits') {
|
function persistTodaySectionCollapse() {
|
||||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
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 }) {
|
function updateTodayHabitSummary(value: { total: number; completed: number }) {
|
||||||
todayHabitTotal.value = value.total
|
todayHabitTotal.value = value.total
|
||||||
@@ -1438,29 +1456,29 @@ onUnmounted(() => {
|
|||||||
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
|
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section v-if="activeView==='today'" class="today-board" aria-label="今日进度">
|
<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-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>
|
<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>
|
||||||
<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-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>
|
<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>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
<template v-if="activeView==='today'">
|
<template v-if="activeView==='today'">
|
||||||
<section v-if="overdueTaskTree.length" class="overdue-section">
|
<section v-if="overdueTaskTree.length" class="overdue-section today-collapsible-section">
|
||||||
<h3 class="section-heading overdue-heading"><CalendarDays/>已过期 <span>{{overdueTaskTree.length}}</span></h3>
|
<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 class="task-list overdue-list">
|
<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}`">
|
<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>
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</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!=='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>
|
<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">
|
<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)">
|
<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>
|
<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,10 +1490,12 @@ 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-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>
|
<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>
|
</section>
|
||||||
<div v-if="activeView==='today'" id="today-habits" class="today-section-anchor">
|
<div v-if="activeView==='today'" class="today-habits-section today-section-anchor">
|
||||||
<h3 class="section-heading"><Repeat2/>习惯</h3>
|
<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" />
|
<MvpPanel ref="habitComposer" view="today-habits" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" @summary="updateTodayHabitSummary" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const app = readFileSync('src/App.vue', 'utf8')
|
||||||
|
const style = readFileSync('src/style.css', 'utf8')
|
||||||
|
const css = readFileSync('src/today-sections.css', 'utf8')
|
||||||
|
|
||||||
|
describe('Today independent collapsible sections', () => {
|
||||||
|
it('owns and persists one three-field collapse state in App', () => {
|
||||||
|
expect(app).toContain("const TODAY_SECTION_COLLAPSE_KEY = 'dodo.today-section-collapse.v1'")
|
||||||
|
expect(app).toContain('readTodaySectionCollapse(window.localStorage, TODAY_SECTION_COLLAPSE_KEY)')
|
||||||
|
expect(app).toContain('writeTodaySectionCollapse(window.localStorage, TODAY_SECTION_COLLAPSE_KEY, todaySectionCollapse.value)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('imports the section stylesheet from the global stylesheet', () => {
|
||||||
|
expect(style).toContain('@import "./today-sections.css";')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders full-row accessible toggles and labelled regions for all three sections', () => {
|
||||||
|
for (const section of ['overdue', 'tasks', 'habits']) {
|
||||||
|
expect(app).toContain(`id="today-${section}-heading"`)
|
||||||
|
expect(app).toContain(`aria-controls="today-${section}"`)
|
||||||
|
expect(app).toContain(`:aria-expanded="!todaySectionCollapse.${section}"`)
|
||||||
|
if (section === 'tasks') {
|
||||||
|
expect(app).toContain(":id=\"activeView==='today' ? 'today-tasks' : undefined\"")
|
||||||
|
expect(app).toContain(":aria-labelledby=\"activeView==='today' ? 'today-tasks-heading' : undefined\"")
|
||||||
|
} else {
|
||||||
|
expect(app).toContain(`id="today-${section}"`)
|
||||||
|
expect(app).toContain(`aria-labelledby="today-${section}-heading"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(app.match(/class="today-section-toggle(?: today-section-anchor)?"/g)).toHaveLength(3)
|
||||||
|
expect(app).toContain('<span class="today-section-summary">{{totalTasks}} 项</span>')
|
||||||
|
expect(app).not.toContain('<span class="today-section-summary">{{taskTree.length}} 项</span>')
|
||||||
|
expect(app).toContain('<ChevronRight v-if="todaySectionCollapse.overdue"')
|
||||||
|
expect(app).toContain('<ChevronDown v-else')
|
||||||
|
expect(css).toMatch(/\.today-section-toggle\{[^}]*min-height:44px/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the habit panel mounted while collapsed and omits an empty overdue section', () => {
|
||||||
|
expect(app).toContain('<section v-if="overdueTaskTree.length" class="overdue-section today-collapsible-section">')
|
||||||
|
expect(app).toContain('<div v-show="!todaySectionCollapse.habits" id="today-habits"')
|
||||||
|
const habits = app.slice(app.indexOf('id="today-habits-heading"'), app.indexOf('</div>', app.indexOf('<MvpPanel ref="habitComposer" view="today-habits"')) + 6)
|
||||||
|
expect(habits).toContain('<MvpPanel ref="habitComposer" view="today-habits"')
|
||||||
|
expect(habits).not.toContain('v-if="!todaySectionCollapse.habits"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expands a collapsed destination before next-tick scrolling and focus, respecting reduced motion', () => {
|
||||||
|
expect(app).toContain("async function navigateTodaySection(section: 'tasks' | 'habits')")
|
||||||
|
expect(app).toContain('todaySectionCollapse.value[section] = false')
|
||||||
|
expect(app).toContain('await nextTick()')
|
||||||
|
expect(app).toContain('await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))')
|
||||||
|
expect(app).toContain('heading?.focus({ preventScroll: true })')
|
||||||
|
expect(app).toContain("window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'")
|
||||||
|
expect(app).toContain("@click=\"navigateTodaySection('tasks')\"")
|
||||||
|
expect(app).toContain("@click=\"navigateTodaySection('habits')\"")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { readTodaySectionCollapse, writeTodaySectionCollapse } from './today-section-collapse'
|
||||||
|
|
||||||
|
const defaults = { overdue: false, tasks: false, habits: false }
|
||||||
|
|
||||||
|
function fakeStorage(initial?: string) {
|
||||||
|
const values = new Map<string, string>()
|
||||||
|
if (initial !== undefined) values.set('dodo.today-section-collapse.v1', initial)
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => values.set(key, value),
|
||||||
|
value: () => values.get('dodo.today-section-collapse.v1'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Today section collapse persistence', () => {
|
||||||
|
it('defaults every section to expanded when storage is absent or malformed', () => {
|
||||||
|
expect(readTodaySectionCollapse(fakeStorage(), 'dodo.today-section-collapse.v1')).toEqual(defaults)
|
||||||
|
expect(readTodaySectionCollapse(fakeStorage('{bad'), 'dodo.today-section-collapse.v1')).toEqual(defaults)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts only boolean fields and falls back invalid or missing fields independently', () => {
|
||||||
|
expect(readTodaySectionCollapse(fakeStorage(JSON.stringify({ overdue: true, tasks: 'yes', extra: true })), 'dodo.today-section-collapse.v1')).toEqual({
|
||||||
|
overdue: true,
|
||||||
|
tasks: false,
|
||||||
|
habits: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not break collapsing when storage rejects a write', () => {
|
||||||
|
const storage = {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => { throw new Error('quota exceeded') },
|
||||||
|
}
|
||||||
|
expect(() => writeTodaySectionCollapse(storage, 'dodo.today-section-collapse.v1', defaults)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes all three independent section states under the single versioned key', () => {
|
||||||
|
const storage = fakeStorage()
|
||||||
|
writeTodaySectionCollapse(storage, 'dodo.today-section-collapse.v1', { overdue: true, tasks: false, habits: true })
|
||||||
|
expect(storage.value()).toBe(JSON.stringify({ overdue: true, tasks: false, habits: true }))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export type TodaySectionCollapse = {
|
||||||
|
overdue: boolean
|
||||||
|
tasks: boolean
|
||||||
|
habits: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>
|
||||||
|
|
||||||
|
const expandedDefaults = (): TodaySectionCollapse => ({ overdue: false, tasks: false, habits: false })
|
||||||
|
|
||||||
|
export function readTodaySectionCollapse(storage: StorageLike, key: string): TodaySectionCollapse {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(storage.getItem(key) ?? '') as Partial<Record<keyof TodaySectionCollapse, unknown>>
|
||||||
|
return {
|
||||||
|
overdue: typeof parsed.overdue === 'boolean' ? parsed.overdue : false,
|
||||||
|
tasks: typeof parsed.tasks === 'boolean' ? parsed.tasks : false,
|
||||||
|
habits: typeof parsed.habits === 'boolean' ? parsed.habits : false,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return expandedDefaults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeTodaySectionCollapse(storage: StorageLike, key: string, value: TodaySectionCollapse) {
|
||||||
|
try {
|
||||||
|
storage.setItem(key, JSON.stringify(value))
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable (privacy mode/quota); collapsing must still work in memory.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "./today-sections.css";
|
||||||
|
|
||||||
:root{font-family:-apple-system,BlinkMacSystemFont,"Avenir Next","PingFang SC","Hiragino Sans GB","Segoe UI",sans-serif;color:#2d2924;background:#f8f3e8;--accent:#f15a29;--accent-soft:#fbe6dc;--paper:#fffdf8;--sidebar:#f6f0e4;--line:#e7ddcc;--muted:#8b8275;--danger:#bd3827;--shadow:0 12px 36px rgba(78,58,34,.10);--sheet-radius:20px;--sheet-scrim:rgba(45,38,31,.4)}
|
:root{font-family:-apple-system,BlinkMacSystemFont,"Avenir Next","PingFang SC","Hiragino Sans GB","Segoe UI",sans-serif;color:#2d2924;background:#f8f3e8;--accent:#f15a29;--accent-soft:#fbe6dc;--paper:#fffdf8;--sidebar:#f6f0e4;--line:#e7ddcc;--muted:#8b8275;--danger:#bd3827;--shadow:0 12px 36px rgba(78,58,34,.10);--sheet-radius:20px;--sheet-scrim:rgba(45,38,31,.4)}
|
||||||
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%;text-size-adjust:100%}body{margin:0;background:#f8f3e8}button,input,textarea,select{font:inherit;color:inherit}@media(max-width:800px){input,textarea,select{font-size:16px}}button{cursor:pointer}svg{width:18px;height:18px;stroke-width:1.8}.center,.auth-shell{min-height:100vh;display:grid;place-items:center}.center{gap:12px;align-content:center;color:var(--muted)}.loading-brand{grid-template-columns:auto;justify-items:center;gap:13px}.loading-logo{width:40px;height:40px;display:block;margin-bottom:3px}.loading-brand>span:last-child{font-size:13px}.loader{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
|
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%;text-size-adjust:100%}body{margin:0;background:#f8f3e8}button,input,textarea,select{font:inherit;color:inherit}@media(max-width:800px){input,textarea,select{font-size:16px}}button{cursor:pointer}svg{width:18px;height:18px;stroke-width:1.8}.center,.auth-shell{min-height:100vh;display:grid;place-items:center}.center{gap:12px;align-content:center;color:var(--muted)}.loading-brand{grid-template-columns:auto;justify-items:center;gap:13px}.loading-logo{width:40px;height:40px;display:block;margin-bottom:3px}.loading-brand>span:last-child{font-size:13px}.loader{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
|||||||
@@ -209,7 +209,8 @@ describe('solid cream material system', () => {
|
|||||||
|
|
||||||
describe('approved UI detail direction', () => {
|
describe('approved UI detail direction', () => {
|
||||||
it('uses one More detail entry for ordinary parent rows while preserving Trash and overdue actions', () => {
|
it('uses one More detail entry for ordinary parent rows while preserving Trash and overdue actions', () => {
|
||||||
const ordinaryRows = app.slice(app.indexOf('<section class="task-list"'), app.indexOf('</section>', app.indexOf('<section class="task-list"')))
|
const ordinaryStart = app.indexOf('<section :id="activeView===\'today\' ? \'today-tasks\' : undefined"')
|
||||||
|
const ordinaryRows = app.slice(ordinaryStart, app.indexOf('</section>', ordinaryStart))
|
||||||
expect(ordinaryRows.match(/aria-label="打开任务详情"/g)).toHaveLength(1)
|
expect(ordinaryRows.match(/aria-label="打开任务详情"/g)).toHaveLength(1)
|
||||||
expect(ordinaryRows).toContain('@click.stop="selectTask(node.task)"><Ellipsis/>')
|
expect(ordinaryRows).toContain('@click.stop="selectTask(node.task)"><Ellipsis/>')
|
||||||
expect(ordinaryRows).not.toContain('selectTask(subtask)')
|
expect(ordinaryRows).not.toContain('selectTask(subtask)')
|
||||||
@@ -217,7 +218,7 @@ describe('approved UI detail direction', () => {
|
|||||||
expect(ordinaryRows).toContain('v-if="activeView===\'trash\'" class="task-actions"')
|
expect(ordinaryRows).toContain('v-if="activeView===\'trash\'" class="task-actions"')
|
||||||
expect(ordinaryRows).toContain('restoreTask(node.task)')
|
expect(ordinaryRows).toContain('restoreTask(node.task)')
|
||||||
expect(ordinaryRows).toContain('purgeTask(node.task)')
|
expect(ordinaryRows).toContain('purgeTask(node.task)')
|
||||||
const overdue = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('<h3 id="today-tasks"'))
|
const overdue = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('id="today-tasks-heading"'))
|
||||||
expect(overdue).not.toContain('aria-label="打开任务详情"')
|
expect(overdue).not.toContain('aria-label="打开任务详情"')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -774,7 +775,7 @@ describe('task and habit row decoration', () => {
|
|||||||
expect(app).toContain('const overdueTasks = ref<Task[]>([])')
|
expect(app).toContain('const overdueTasks = ref<Task[]>([])')
|
||||||
expect(app).toContain("params.set('due_to', isoAtLocalDayOffset(0))")
|
expect(app).toContain("params.set('due_to', isoAtLocalDayOffset(0))")
|
||||||
expect(app).toContain("params.set('completed', 'false')")
|
expect(app).toContain("params.set('completed', 'false')")
|
||||||
expect(app).toContain('class="overdue-section"')
|
expect(app).toContain('class="overdue-section today-collapsible-section"')
|
||||||
expect(app).toContain('已过期')
|
expect(app).toContain('已过期')
|
||||||
expect(app).toContain('v-for="node in overdueTaskTree"')
|
expect(app).toContain('v-for="node in overdueTaskTree"')
|
||||||
expect(css).toContain('.overdue-section{')
|
expect(css).toContain('.overdue-section{')
|
||||||
@@ -807,8 +808,8 @@ describe('task and habit row decoration', () => {
|
|||||||
expect(app).not.toContain('<p v-if="activeView===\'today\'">')
|
expect(app).not.toContain('<p v-if="activeView===\'today\'">')
|
||||||
expect(app).not.toContain('class="paper today-summary"')
|
expect(app).not.toContain('class="paper today-summary"')
|
||||||
expect(app).not.toContain('today-summary-grid')
|
expect(app).not.toContain('today-summary-grid')
|
||||||
expect(app).toContain("scrollTodaySection('today-tasks')")
|
expect(app).toContain("navigateTodaySection('tasks')")
|
||||||
expect(app).toContain("scrollTodaySection('today-habits')")
|
expect(app).toContain("navigateTodaySection('habits')")
|
||||||
expect(app).not.toContain('<div class="today-stat"')
|
expect(app).not.toContain('<div class="today-stat"')
|
||||||
expect(app).toContain('@summary="updateTodayHabitSummary"')
|
expect(app).toContain('@summary="updateTodayHabitSummary"')
|
||||||
expect(mvpPanel).toContain("summary: [value: { total: number; completed: number }]")
|
expect(mvpPanel).toContain("summary: [value: { total: number; completed: number }]")
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
.today-section-toggle{width:100%;min-height:44px;display:flex;align-items:center;gap:10px;padding:8px 4px;border:0;background:transparent;color:var(--text-primary);text-align:left;border-radius:10px}.today-section-toggle:hover{background:var(--surface-raised)}.today-section-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.today-section-title{display:flex;align-items:center;gap:8px;font-weight:800;font-size:15px}.today-section-title svg{width:17px;height:17px;color:var(--accent)}.today-section-summary{margin-left:auto;color:var(--muted);font-size:13px;font-weight:650}.today-section-toggle>svg{flex:0 0 auto;color:var(--muted)}.today-collapsible-section,.today-habits-section{min-width:0}.today-habits-section.today-section-anchor{scroll-margin-top:18px}
|
||||||
Reference in New Issue
Block a user