feat: align upcoming with list layout
ci / gitleaks (push) Successful in 1m3s
ci / docker (push) Successful in 4m48s

This commit is contained in:
2026-09-19 19:43:32 +08:00
parent 915d690397
commit 0cf1c49094
6 changed files with 124 additions and 28 deletions
+20 -16
View File
@@ -369,7 +369,6 @@ function updateTodayHabitSummary(value: { total: number; completed: number }) {
const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value : tasks.value)
const visibleTasks = computed(() => {
const now = new Date()
const end = new Date(now); end.setDate(end.getDate() + 7)
let result = sourceTasks.value
if (['habits','settings','countdowns','memos'].includes(activeView.value)) return []
if (activeView.value === 'today') result = result.filter((task) => {
@@ -377,7 +376,7 @@ const visibleTasks = computed(() => {
const completedToday = task.completed_at && new Date(task.completed_at).toDateString() === now.toDateString()
return Boolean(dueToday || completedToday)
})
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8))
return query.value.trim() ? filterTasks(result, query.value) : result
})
const selectedTaskSubtasks = computed(() => selectedTask.value?.subtasks ?? [])
@@ -505,11 +504,14 @@ async function loadTaskPages(path: string) {
} while (cursor)
return rows
}
function isoAtLocalDayOffset(offset: number) {
function startOfLocalDay(offset: number) {
const day = new Date()
day.setDate(day.getDate() + offset)
day.setHours(0, 0, 0, 0)
return day.toISOString()
return day
}
function isoAtLocalDayOffset(offset: number) {
return startOfLocalDay(offset).toISOString()
}
async function loadTodayTaskSummary() {
const token = ++todaySummaryLoadToken
@@ -585,15 +587,18 @@ async function loadTasksPage(request = beginLatestRequest('tasks')) {
if (!isLatestRequest('tasks', request)) return
tasks.value = data.items ?? []
totalTasks.value = data.total ?? tasks.value.length
if (activeView.value === 'upcoming' && !showCompleted.value && !query.value) taskOpenTotal.value = totalTasks.value
if (activeView.value === 'tasks' && !showCompleted.value && !query.value) taskOpenTotal.value = totalTasks.value
if (activeView.value === 'tasks' && (showCompleted.value || query.value)) {
if ((activeView.value === 'tasks' || activeView.value === 'upcoming') && (showCompleted.value || query.value)) {
const token = ++taskOpenTotalLoadToken
const listId = activeList.value
const openParams = new URLSearchParams({ page: '1', page_size: '1', completed: 'false' })
if (listId) openParams.set('list_id', listId)
if (activeView.value === 'tasks' && listId) openParams.set('list_id', listId)
if (activeView.value === 'upcoming') { openParams.set('due_from', isoAtLocalDayOffset(0)); openParams.set('due_to', isoAtLocalDayOffset(8)) }
const openView = activeView.value
loadTaskOpenTotal(
() => api(`/tasks?${openParams}`),
() => token === taskOpenTotalLoadToken && isLatestRequest('tasks', request) && activeView.value === 'tasks' && activeList.value === listId,
() => token === taskOpenTotalLoadToken && isLatestRequest('tasks', request) && activeView.value === openView && (openView !== 'tasks' || activeList.value === listId),
value => { taskOpenTotal.value = value },
)
}
@@ -1685,16 +1690,15 @@ onUnmounted(() => {
</AppSheet>
</aside>
<main :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='habits'}" @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
<main :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='upcoming'||activeView==='habits'}" @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
<header class="topbar" :class="{'settings-topbar':activeView==='settings'}" :inert="memoBackgroundInert ? true : undefined">
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
<div v-if="!['today','tasks','habits','settings'].includes(activeView)" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
<div v-if="!['today','tasks','upcoming','habits','settings'].includes(activeView)" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
<div v-if="['today', 'tasks', 'upcoming', 'habits'].includes(activeView)" class="topbar-actions">
<CompletedFilterPill v-if="activeView==='upcoming'" v-model="showCompleted" class="topbar-filter" />
<button class="icon topbar-refresh" :class="{spinning:refreshing}" type="button" aria-label="刷新当前页面" title="刷新" :disabled="refreshing || loading" @click="refreshCurrentView"><RefreshCw /></button>
</div>
<button v-if="activeView==='settings'" class="icon topbar-refresh settings-refresh" :class="{spinning:refreshing}" type="button" aria-label="刷新设置" title="刷新" :disabled="refreshing || loading" @click="refreshCurrentView"><RefreshCw /></button>
<div v-if="taskSearchAvailable && activeView!=='tasks'" class="search-reveal" :class="{'mobile-search-open':mobileSearchOpen,'mobile-search-pulling':searchPullDistance>0}" :style="searchRevealStyle">
<div v-if="taskSearchAvailable && !['tasks','upcoming'].includes(activeView)" class="search-reveal" :class="{'mobile-search-open':mobileSearchOpen,'mobile-search-pulling':searchPullDistance>0}" :style="searchRevealStyle">
<button ref="taskSearchToggle" class="task-search-toggle" type="button" :aria-label="mobileSearchOpen ? '收起搜索任务' : '展开搜索任务'" :aria-expanded="mobileSearchOpen" aria-controls="task-search-panel" @click="toggleTaskSearch"><Search/></button>
<span class="search-pull-hint" aria-hidden="true">{{searchPullDistance >= 56 ? '松开搜索' : '下拉搜索'}}</span>
<label id="task-search-panel" class="search"><Search/><input ref="searchInput" v-model="query" placeholder="搜索任务" aria-label="搜索任务" @keydown="handleTaskSearchKeydown"><kbd>⌘ K</kbd></label>
@@ -1713,11 +1717,11 @@ onUnmounted(() => {
<CompletedFilterPill v-model="showCompleted" class="today-inline-filter" />
</div>
</section>
<section v-if="activeView==='tasks'" class="list-page-context">
<section v-if="activeView==='tasks' || activeView==='upcoming'" class="list-page-context">
<div><h1 class="list-page-title" :title="activeName">{{ activeName }}</h1><p class="list-page-summary">{{ taskOpenTotal === null ? '待完成统计暂不可用' : `还有 ${taskOpenTotal} 项待完成` }}</p></div>
<CompletedFilterPill v-model="showCompleted" class="list-inline-filter" />
</section>
<div v-if="activeView==='tasks'" class="list-search-reveal" :class="{'mobile-search-open':mobileSearchOpen}">
<div v-if="activeView==='tasks' || activeView==='upcoming'" class="list-search-reveal" :class="{'mobile-search-open':mobileSearchOpen}">
<label id="task-list-search-panel" class="list-search"><Search/><input ref="searchInput" v-model="query" placeholder="搜索任务" aria-label="搜索任务" @keydown="handleTaskSearchKeydown"><kbd>⌘ K</kbd></label>
<button v-if="query" class="list-search-clear" type="button" @click="query=''">清除搜索</button>
<button ref="taskSearchToggle" class="task-search-toggle" type="button" :aria-label="mobileSearchOpen ? '收起搜索任务' : '展开搜索任务'" :aria-expanded="mobileSearchOpen" aria-controls="task-list-search-panel" @click="toggleTaskSearch"><Search/></button>
@@ -1733,11 +1737,11 @@ onUnmounted(() => {
</section>
<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">今天</span><span class="today-section-summary">{{totalTasks}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.tasks ? '' : '⌄' }}</span></button>
</template>
<div v-if="activeView==='tasks'" id="task-list-heading" class="list-section-heading"><span id="task-list-title" class="list-section-title">任务</span><span class="list-section-count">{{ totalTasks }}</span><button v-if="taskReorderAvailable" class="list-section-action" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></div>
<div v-if="activeView!=='today' && activeView!=='tasks'" class="list-toolbar"><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><span class="list-toolbar-actions"><button v-if="query" class="link" @click="query=''">清除搜索</button><button v-if="taskReorderAvailable" class="soft-button reorder-mode-toggle task-reorder-toggle" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></span></div>
<div v-if="activeView==='tasks' || activeView==='upcoming'" id="task-list-heading" class="list-section-heading"><span id="task-list-title" class="list-section-title">任务</span><span class="list-section-count">{{ totalTasks }}</span><button v-if="activeView==='tasks' && taskReorderAvailable" class="list-section-action" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></div>
<div v-if="activeView==='trash'" class="list-toolbar"><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><span class="list-toolbar-actions"><button v-if="query" class="link" @click="query=''">清除搜索</button><button v-if="taskReorderAvailable" class="soft-button reorder-mode-toggle task-reorder-toggle" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></span></div>
<div v-if="activeView==='tasks' && totalPages > 1" class="list-page-meta"><span>第 {{ page }} / {{ totalPages }} 页 · 共 {{ totalTasks }} 项</span></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 :id="activeView==='today' ? 'today-tasks' : undefined" class="task-list plain-list" :class="{loading}" v-show="activeView!=='today' || !todaySectionCollapse.tasks" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : activeView==='tasks' ? 'task-list-title' : undefined">
<section :id="activeView==='today' ? 'today-tasks' : undefined" class="task-list plain-list" :class="{loading}" v-show="activeView!=='today' || !todaySectionCollapse.tasks" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : 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,'task-row--trash':activeView==='trash','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="taskReorderMode" 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>
+3 -3
View File
@@ -47,7 +47,7 @@ describe('Today environment integration', () => {
})
it('renders the approved plain-list composition without a board or progress tracks', () => {
expect(app).toContain("<main :class=\"{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='habits'}\"")
expect(app).toContain("<main :class=\"{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='upcoming'||activeView==='habits'}\"")
expect(app).toContain('<section v-if="activeView===\'today\'" class="today-context" aria-label="今日概览">')
expect(app).toContain('<div class="today-heading">')
expect(app).toContain('<p class="today-remaining">还有 {{ todayTaskRemaining + todayHabitRemaining }} 项待完成</p>')
@@ -90,9 +90,9 @@ describe('Today environment integration', () => {
expect(filter).toBeGreaterThan(remaining)
expect(overdue).toBeGreaterThan(filter)
expect(main.match(/>今天<\/h1>/g)).toHaveLength(1)
expect(main).toContain("<div v-if=\"!['today','tasks','habits','settings'].includes(activeView)\" class=\"topbar-title\"><h1")
expect(main).toContain("<div v-if=\"!['today','tasks','upcoming','habits','settings'].includes(activeView)\" class=\"topbar-title\"><h1")
expect(main).toContain("<div v-if=\"['today', 'tasks', 'upcoming', 'habits'].includes(activeView)\" class=\"topbar-actions\">")
expect(main).toContain('<CompletedFilterPill v-if="activeView===\'upcoming\'" v-model="showCompleted" class="topbar-filter" />')
expect(main).not.toContain('class="topbar-filter"')
expect(main).toContain('aria-label="刷新当前页面"')
})
+1 -1
View File
@@ -23,7 +23,7 @@ describe('Today independent collapsible sections', () => {
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' : activeView==='tasks' ? 'task-list-title' : undefined\"")
expect(app).toContain(":aria-labelledby=\"activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined\"")
} else {
expect(app).toContain(`id="today-${section}"`)
expect(app).toContain(`aria-labelledby="today-${section}-heading"`)
+3 -3
View File
@@ -159,7 +159,7 @@ describe('approved Settings 01 paper ledger', () => {
it('moves Settings identity into the body and suppresses the duplicate shell title', () => {
expect(app).not.toContain("'settings-main':activeView==='settings'")
expect(app).toContain(":class=\"{'settings-topbar':activeView==='settings'}\"")
expect(app).toContain("v-if=\"!['today','tasks','habits','settings'].includes(activeView)\" class=\"topbar-title\"")
expect(app).toContain("v-if=\"!['today','tasks','upcoming','habits','settings'].includes(activeView)\" class=\"topbar-title\"")
expect(app).toContain('v-if="activeView===\'settings\'" class="icon topbar-refresh settings-refresh"')
expect(mvpPanel).toContain('<header class="settings-heading"><h1>设置</h1><p>管理数据、账户与登录设备</p></header>')
expect(mvpPanel).toContain('<h2>数据与恢复</h2><small>完整备份</small>')
@@ -932,8 +932,8 @@ describe('task and habit row decoration', () => {
it('uses one completed filter per supported view and keeps App as the only preference owner', () => {
expect(app).toContain("import CompletedFilterPill from './components/CompletedFilterPill.vue'")
const topbar = app.slice(app.indexOf('<header class="topbar"'), app.indexOf('</header>', app.indexOf('<header class="topbar"')))
expect(topbar.match(/<CompletedFilterPill/g)).toHaveLength(1)
expect(topbar).toContain('<CompletedFilterPill v-if="activeView===\'upcoming\'" v-model="showCompleted" class="topbar-filter" />')
expect(topbar.match(/<CompletedFilterPill/g) ?? []).toHaveLength(0)
expect(topbar).not.toContain('topbar-filter')
expect(app).toContain("v-if=\"['today', 'tasks', 'upcoming', 'habits'].includes(activeView)\"")
expect(app).toContain('class="today-inline-filter"')
expect(app).toContain('class="list-inline-filter"')
+8 -5
View File
@@ -73,21 +73,24 @@ describe('approved five-detail polish', () => {
expect(css).toMatch(/@media\(max-width:930px\)\{[\s\S]*?\.countdown-focus\{[^}]*height:140px/)
})
it('uses approved Today-sourced page headers for task lists without changing Trash IA', () => {
expect(app).toContain('v-if="activeView===\'tasks\'" class="list-page-context"')
it('uses approved Today-sourced page headers for task lists and Upcoming without changing Trash IA', () => {
expect(app).toContain('v-if="activeView===\'tasks\' || activeView===\'upcoming\'" class="list-page-context"')
expect(app).toContain('<h1 class="list-page-title" :title="activeName">{{ activeName }}</h1>')
expect(app).toContain('<p class="list-page-summary">{{ taskOpenTotal === null ? \'待完成统计暂不可用\' : `还有 ${taskOpenTotal} 项待完成` }}</p>')
expect(app).toContain('<CompletedFilterPill v-model="showCompleted" class="list-inline-filter" />')
expect(app).toContain('id="task-list-heading" class="list-section-heading"')
expect(app).toContain("v-if=\"activeView==='tasks' || activeView==='upcoming'\" id=\"task-list-heading\" class=\"list-section-heading\"")
expect(app).toContain('<span id="task-list-title" class="list-section-title">任务</span>')
expect(app).toContain("v-if=\"activeView==='tasks' && taskReorderAvailable\"")
expect(app).toContain('<span class="list-section-count">{{ totalTasks }}</span>')
expect(app).toContain("v-if=\"activeView==='tasks' && totalPages > 1\" class=\"list-page-meta\"")
expect(app).toContain("if (activeView.value === 'upcoming') { openParams.set('due_from', isoAtLocalDayOffset(0)); openParams.set('due_to', isoAtLocalDayOffset(8)) }")
expect(app).toContain("new Date(task.due_at) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8)")
expect(app).toContain('v-if="query" class="list-search-clear"')
expect(app).not.toContain("activeView==='tasks' && (totalPages > 1 || totalTasks > 0)")
expect(app).toContain("activeView==='tasks' ? 'task-list-title' : undefined")
expect(app).toContain(":aria-labelledby=\"activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined\"")
const taskTopbar = app.slice(app.indexOf('<header class="topbar"'), app.indexOf('</header>'))
expect(taskTopbar).not.toContain('CompletedFilterPill v-if="activeView!==\'today\'"')
expect(app).toContain('v-if="activeView!==\'today\' && activeView!==\'tasks\'" class="list-toolbar"')
expect(app).toContain('v-if="activeView===\'trash\'" class="list-toolbar"')
expect(css).toContain('.list-page-context{width:min(100%,630px);margin:0 auto 0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:0 16px}')
expect(css).toContain('.list-page-title{margin:0;font-size:34px;line-height:1.15;font-weight:700;letter-spacing:-.035em}')
expect(css).toContain('.list-page-summary{margin:8px 0 18px;color:var(--muted);font-size:13px}')