feat: unify task and habit pages with Today
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 6m49s

This commit is contained in:
2026-09-18 10:51:57 +08:00
parent 6d568273ef
commit 978c4b87ed
9 changed files with 204 additions and 20 deletions
+39 -7
View File
@@ -25,6 +25,7 @@ import AppSheet from './components/AppSheet.vue'
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
import { loadTaskOpenTotal } from './lib/task-open-total'
type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
@@ -103,6 +104,7 @@ const todaySectionCollapse = ref<TodaySectionCollapse>(readTodaySectionCollapse(
const page = ref(1)
const pageSize = 50
const totalTasks = ref(0)
const taskOpenTotal = ref<number | null>(null)
const hiddenCompletedTaskCount = ref(0)
const todayTaskTotal = ref(0)
const todayTaskCompleted = ref(0)
@@ -165,6 +167,7 @@ const selectedRepeatConfig = ref<TaskRepeatConfig>(defaultRepeatConfig())
const weekdayOptions = [{ value: 'MO', label: '一' }, { value: 'TU', label: '二' }, { value: 'WE', label: '三' }, { value: 'TH', label: '四' }, { value: 'FR', label: '五' }, { value: 'SA', label: '六' }, { value: 'SU', label: '日' }]
let recurrenceLoadToken = 0
let todaySummaryLoadToken = 0
let taskOpenTotalLoadToken = 0
let todayEnvironmentLoadToken = 0
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
@@ -553,6 +556,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 === 'tasks' && !showCompleted.value && !query.value) taskOpenTotal.value = totalTasks.value
if (activeView.value === 'tasks' && (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)
loadTaskOpenTotal(
() => api(`/tasks?${openParams}`),
() => token === taskOpenTotalLoadToken && isLatestRequest('tasks', request) && activeView.value === 'tasks' && activeList.value === listId,
value => { taskOpenTotal.value = value },
)
}
hiddenCompletedTaskCount.value = 0
if (!showCompleted.value && !query.value && activeView.value !== 'trash' && tasks.value.length === 0) {
const completedParams = new URLSearchParams(params)
@@ -589,6 +604,10 @@ async function preloadCountdowns() {
}
async function loadAll() {
const request = beginLatestRequest('tasks')
if (activeView.value === 'tasks') {
taskOpenTotal.value = null
++taskOpenTotalLoadToken
}
loading.value = true
error.value = ''
try {
@@ -655,6 +674,8 @@ async function switchView(view: View, listId?: string) {
todayEnvironmentLoading.value = false
}
activeView.value = view
taskOpenTotal.value = null
++taskOpenTotalLoadToken
if (!query.value) mobileSearchOpen.value = false
searchPullDistance.value = 0
if (view !== 'trash') beginLatestRequest('trash')
@@ -1504,22 +1525,22 @@ onUnmounted(() => {
</AppSheet>
</aside>
<main :class="{'today-main':activeView==='today'}" @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
<main :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='habits'}" @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
<header class="topbar" :inert="memoBackgroundInert ? true : undefined">
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
<div v-if="activeView!=='today'" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
<div v-if="!['today','tasks','habits'].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!=='today'" v-model="showCompleted" class="topbar-filter" />
<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>
<div v-if="taskSearchAvailable" class="search-reveal" :class="{'mobile-search-open':mobileSearchOpen,'mobile-search-pulling':searchPullDistance>0}" :style="searchRevealStyle">
<div v-if="taskSearchAvailable && activeView!=='tasks'" 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>
</div>
</header>
<template v-if="['habits','settings'].includes(activeView)">
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" />
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" @update:show-completed="showCompleted=$event" @changed="refreshAll" @notice="toast" />
</template>
<MemoPanel v-else-if="activeView==='memos'" ref="memoPanel" :request="api" @scope="memoTrash=$event==='trash'" @detail="memoDetailOpen=$event" @notice="toast" />
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
@@ -1530,6 +1551,15 @@ onUnmounted(() => {
<p class="today-remaining">还有 {{ todayTaskRemaining + todayHabitRemaining }} 项待完成</p>
<CompletedFilterPill v-model="showCompleted" class="today-inline-filter" />
</section>
<section v-if="activeView==='tasks'" 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}">
<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>
</div>
<template v-if="activeView==='today'">
<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">逾期</span><span class="today-section-summary">{{overdueTaskTree.length}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.overdue ? '' : '⌄' }}</span></button>
@@ -1541,9 +1571,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!=='today'" 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'" 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' && 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' : 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' ? '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>
+7 -2
View File
@@ -9,6 +9,7 @@ import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionEx
import { backupFileSnapshot, isCurrentBackupSnapshot, isLegacyBackup, shouldCommitBackupPreflight, type BackupFileSnapshot } from './lib/backup-preflight-state'
import AppSheet from './components/AppSheet.vue'
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
import CompletedFilterPill from './components/CompletedFilterPill.vue'
type View = 'habits' | 'today-habits' | 'settings'
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
@@ -20,6 +21,7 @@ const emit = defineEmits<{
changed: []
notice: [message: string]
summary: [value: { total: number; completed: number }]
'update:showCompleted': [value: boolean]
}>()
const habits = ref<Habit[]>([])
const archivedHabits = ref<Habit[]>([])
@@ -743,6 +745,10 @@ onBeforeUnmount(() => {
</div>
<!-- 完整习惯列表 -->
<section v-if="view === 'habits'" class="habit-page-context">
<div><h1 class="list-page-title">习惯</h1><p class="list-page-summary">今天还有 {{ todayHabitSummary.total - todayHabitSummary.completed }} </p></div>
<CompletedFilterPill :model-value="showCompleted" class="completed-filter-pill habit-inline-filter" @update:model-value="$emit('update:showCompleted', $event)" />
</section>
<AppSheet :open="habitComposerOpen" variant="create" panel-class="task-compose-sheet habit-compose-sheet" title-id="habit-compose-title" initial-focus="input[aria-label='新习惯名称']" :busy="busy" :style="habitComposeStyle" @close="closeHabitComposer" @submit.prevent="saveHabit">
<header class="app-sheet__header"><div><h2 id="habit-compose-title">{{ habitComposerTitle }}</h2></div><button class="icon" type="button" :aria-label="`关闭${habitComposerTitle}`" @click="closeHabitComposer"><X /></button></header>
<div class="app-sheet__body">
@@ -757,8 +763,7 @@ onBeforeUnmount(() => {
<footer class="app-sheet__footer"><span v-if="habitFormInvalid" class="field-error" role="status">请修正表单中的错误后再保存</span><button type="button" class="secondary" @click="closeHabitComposer">取消</button><button class="primary-small" :disabled="busy || habitFormInvalid">{{ busy ? '保存中' : editingHabit ? '保存修改' : '添加习惯' }}</button></footer>
</AppSheet>
<div v-if="habitReorderAvailable" class="habit-reorder-toolbar"><button class="soft-button reorder-mode-toggle habit-reorder-toggle" type="button" :aria-pressed="habitReorderMode" @click="habitReorderMode=!habitReorderMode;cancelHabitReorder()">{{ habitReorderMode ? '完成' : '调整顺序' }}</button></div>
<div v-if="view === 'habits'" class="list-section-heading habit-section-heading"><span class="list-section-title">今日习惯</span><span class="list-section-count">{{ todayHabitSummary.total }}</span><button v-if="habitReorderAvailable" class="list-section-action" type="button" :aria-pressed="habitReorderMode" @click="habitReorderMode=!habitReorderMode;cancelHabitReorder()">{{ habitReorderMode ? '完成' : '调整顺序' }}</button></div>
<!-- 习惯列表支持整行滑动记录 -->
<div v-if="view === 'habits'" class="habit-list plain-list">
<article v-for="h in visibleHabits" :key="h.id" :data-habit-id="h.id" class="habit-row habit-row--full swipeable" :class="{ done: isDone(h, todayKey), 'just-completed': justCompletedHabitIds.has(h.id), 'completion-exiting': completionExitingHabitIds.has(h.id), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64, reordering: habitReorder?.id === h.id, 'reorder-target': habitReorderTarget === h.id }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--reorder-y': `${habitReorder?.id === h.id ? habitReorder.offsetY : 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
+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\'}"')
expect(app).toContain("<main :class=\"{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='habits'}\"")
expect(app).toContain('<section v-if="activeView===\'today\'" class="today-context" aria-label="今日概览">')
expect(app).toContain('<p class="today-remaining">还有 {{ todayTaskRemaining + todayHabitRemaining }} 项待完成</p>')
expect(app).not.toContain('class="today-board"')
@@ -84,9 +84,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=\"activeView!=='today'\" class=\"topbar-title\"><h1")
expect(main).toContain("<div v-if=\"!['today','tasks','habits'].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!==\'today\'" v-model="showCompleted" class="topbar-filter" />')
expect(main).toContain('<CompletedFilterPill v-if="activeView===\'upcoming\'" v-model="showCompleted" 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' : undefined\"")
expect(app).toContain(":aria-labelledby=\"activeView==='today' ? 'today-tasks-heading' : activeView==='tasks' ? 'task-list-title' : undefined\"")
} else {
expect(app).toContain(`id="today-${section}"`)
expect(app).toContain(`aria-labelledby="today-${section}-heading"`)
+13
View File
@@ -0,0 +1,13 @@
type OpenTotalPayload = { total?: number; items?: unknown[] }
export function loadTaskOpenTotal(
request: () => Promise<OpenTotalPayload>,
isCurrent: () => boolean,
commit: (value: number) => void,
) {
void request()
.then((data) => {
if (isCurrent()) commit(Number(data.total ?? data.items?.length ?? 0))
})
.catch(() => { /* Summary failure is intentionally non-fatal. */ })
}
+24
View File
@@ -117,6 +117,30 @@ input,select,textarea,.search{background:var(--surface-raised);border-color:var(
.task-row--trash{grid-template-columns:minmax(0,1fr) auto;padding-left:12px!important}
.task-row--trash .task-main{cursor:default}.task-row--trash .task-actions{min-width:0}
/* Approved task-list and full-Habits 01 parity. */
main.list-main{background:#fdfaf3}
main.list-main>.list-page-context,main.list-main>.list-search-reveal,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px);margin-left:auto;margin-right:auto}
main.list-main>.mvp-view{gap:0}
main.list-main>.mvp-view>.habit-archive-section{border-top:1px solid #e8e0d5}
.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}
.habit-page-context{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:0 16px}
.habit-section-heading{margin-top:0}
.list-page-title{margin:0;font-size:34px;line-height:1.15;font-weight:700;letter-spacing:-.035em}
.list-page-summary{margin:8px 0 18px;color:var(--muted);font-size:13px}
.list-inline-filter,.habit-inline-filter{align-self:start;min-width:0;width:auto;height:44px;padding:0 2px;border:0;border-radius:0;background:transparent;box-shadow:none;font-weight:400}.list-inline-filter .completed-filter-pill__track,.habit-inline-filter .completed-filter-pill__track{width:31px;height:18px;background:#d8d0c5}.list-inline-filter .completed-filter-pill__thumb,.habit-inline-filter .completed-filter-pill__thumb{width:14px;height:14px}.list-inline-filter[aria-checked="true"] .completed-filter-pill__track,.habit-inline-filter[aria-checked="true"] .completed-filter-pill__track{background:var(--accent)}
.list-search-reveal{position:relative;min-height:44px;margin-bottom:0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center}
.list-search{min-height:44px;display:flex;align-items:center;gap:9px;border:0;border-bottom:1px solid #e8e0d5;background:transparent;border-radius:0;box-shadow:none;color:var(--muted)}
.list-search input{width:100%;min-width:0;border:0;outline:0;background:transparent;box-shadow:none}.list-search kbd{font-size:11px;color:var(--muted)}
.list-search-clear{min-height:44px;margin-left:12px;border:0;background:transparent;color:var(--accent);font-size:12px}
.list-search-reveal>.task-search-toggle{display:none}
.list-section-heading{width:min(100%,630px);min-height:44px;margin:0 auto;display:flex;align-items:center;border-bottom:1px solid #e8e0d5}
.list-section-title{font-size:13px;font-weight:700}.list-section-count{margin-left:7px;font-size:12px;font-weight:400;color:var(--muted)}
.list-section-action{min-height:44px;margin-left:auto;padding:0;border:0;background:transparent;color:var(--accent);font-size:12px;font-weight:650}
.list-page-meta{min-height:36px;display:flex;align-items:center;justify-content:flex-end;gap:10px;color:var(--muted);font-size:12px}
@media(min-width:1440px){main.list-main>.list-page-context,main.list-main>.list-search-reveal,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,900px)}}
@media(min-width:721px) and (max-width:1439px){main.list-main>.list-page-context,main.list-main>.list-search-reveal,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px)}}
@media(max-width:720px){main.list-main{padding-left:29px;padding-right:29px}main.list-main>.list-page-context,main.list-main>.list-search-reveal,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:100%}.list-page-title{font-size:24px}.list-search-reveal>.task-search-toggle{display:grid;position:relative;justify-self:end}.list-search-reveal:not(.mobile-search-open){min-height:0}.list-search-reveal:not(.mobile-search-open)>.list-search,.list-search-reveal:not(.mobile-search-open)>.list-search-clear{display:none}.list-search kbd{display:none}}
/* Approved Today plain-list prototype parity. */
main.today-main .topbar{margin-bottom:18px}
.today-environment{grid-template-columns:repeat(3,minmax(0,1fr));align-items:center;gap:0;padding:0 0 18px;border-bottom:1px solid #e8e0d5;color:#8a8178}
+9 -6
View File
@@ -611,8 +611,8 @@ describe('task and habit row decoration', () => {
it('shows habit drag handles only in explicit available reorder mode and never changes Today habits', () => {
expect(mvpPanel).toContain('const habitReorderMode = ref(false)')
expect(mvpPanel).toContain("const habitReorderAvailable = computed(() => props.view === 'habits' && visibleHabits.value.length > 1)")
expect(mvpPanel).toContain('v-if="habitReorderAvailable" class="habit-reorder-toolbar"')
expect(mvpPanel).toContain('class="soft-button reorder-mode-toggle habit-reorder-toggle"')
expect(mvpPanel).toContain('v-if="habitReorderAvailable" class="list-section-action"')
expect(mvpPanel).toContain('class="list-section-heading habit-section-heading"')
expect(mvpPanel).toContain("{{ habitReorderMode ? '完成' : '调整顺序' }}")
expect(mvpPanel).toContain('v-if="habitReorderMode && habitReorderAvailable" class="drag-handle habit-drag-handle"')
const todayHabits = mvpPanel.slice(mvpPanel.indexOf('<!-- 今日习惯'), mvpPanel.indexOf('<!-- 完整习惯列表'))
@@ -782,10 +782,13 @@ 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'")
expect(app.match(/<CompletedFilterPill/g)).toHaveLength(2)
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(app).toContain("v-if=\"['today', 'tasks', 'upcoming', 'habits'].includes(activeView)\"")
expect(app).toContain('<CompletedFilterPill v-if="activeView!==\'today\'" v-model="showCompleted" class="topbar-filter" />')
expect(app).toContain('class="today-inline-filter"')
expect(app).toContain('class="list-inline-filter"')
expect(mvpPanel).toContain('class="completed-filter-pill habit-inline-filter"')
expect(app).toContain('v-model="showCompleted"')
expect(app).toContain('class="topbar-actions"')
expect(app).toContain('topbar-refresh')
@@ -799,9 +802,9 @@ describe('task and habit row decoration', () => {
expect(app).toContain("readStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, true)")
expect(app).toContain("writeStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, value)")
expect(app).toContain(':show-completed="showCompleted"')
expect(app).not.toContain('@update:show-completed')
expect(app).toContain('@update:show-completed="showCompleted=$event"')
expect(mvpPanel).toContain('showCompleted: boolean')
expect(mvpPanel).not.toContain("'update:showCompleted': [value: boolean]")
expect(mvpPanel).toContain("'update:showCompleted': [value: boolean]")
expect(completedFilterPill).not.toContain('localStorage')
})
+86
View File
@@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { loadTaskOpenTotal } from './lib/task-open-total'
const app = readFileSync('src/App.vue', 'utf8')
const countdown = readFileSync('src/CountdownPanel.vue', 'utf8')
@@ -9,6 +10,44 @@ const memoCss = readFileSync('src/memo.css', 'utf8')
const memoPanel = readFileSync('src/MemoPanel.vue', 'utf8')
const memoRow = readFileSync('src/components/MemoRow.vue', 'utf8')
describe('task-list open summary reconciliation', () => {
it('returns primary data without waiting for the open-total request', async () => {
let resolveSummary!: (value: { total: number }) => void
const summary = new Promise<{ total: number }>((resolve) => { resolveSummary = resolve })
const committed: Array<number | null> = []
const result = await loadTaskOpenTotal(
async () => summary,
() => true,
value => committed.push(value),
)
expect(result).toBeUndefined()
expect(committed).toEqual([])
resolveSummary({ total: 4 })
await summary
await Promise.resolve()
expect(committed).toEqual([4])
})
it('keeps null on failure and ignores stale list responses', async () => {
const failed: Array<number | null> = []
loadTaskOpenTotal(async () => { throw new Error('summary failed') }, () => true, value => failed.push(value))
await Promise.resolve()
await Promise.resolve()
expect(failed).toEqual([])
let current = true
let resolveSummary!: (value: { total: number }) => void
const summary = new Promise<{ total: number }>((resolve) => { resolveSummary = resolve })
const committed: Array<number | null> = []
loadTaskOpenTotal(async () => summary, () => current, value => committed.push(value))
current = false
resolveSummary({ total: 9 })
await summary
await Promise.resolve()
expect(committed).toEqual([])
})
})
describe('approved five-detail polish', () => {
it('uses an independent accessible task-search panel toggle', () => {
expect(app).toContain(":aria-label=\"mobileSearchOpen ? '收起搜索任务' : '展开搜索任务'\"")
@@ -34,6 +73,53 @@ 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"')
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('<span id="task-list-title" class="list-section-title">任务</span>')
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('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")
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(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}')
expect(css).toContain('@media(min-width:1440px){main.list-main>.list-page-context')
expect(css).toContain('@media(max-width:720px){main.list-main{padding-left:29px;padding-right:29px}main.list-main>.list-page-context,main.list-main>.list-search-reveal,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:100%}')
expect(css).toContain('@media(min-width:721px) and (max-width:1439px){main.list-main>.list-page-context,main.list-main>.list-search-reveal,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px)}')
expect(css).toContain('.list-search-reveal{position:relative;min-height:44px;margin-bottom:0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center}')
expect(css).toContain('@media(max-width:720px){')
expect(css).not.toContain('top:-62px')
expect(css).toContain('.list-inline-filter .completed-filter-pill__track,.habit-inline-filter .completed-filter-pill__track{width:31px;height:18px;background:#d8d0c5}')
expect(css).toContain('.list-inline-filter .completed-filter-pill__thumb,.habit-inline-filter .completed-filter-pill__thumb{width:14px;height:14px}')
expect(css).toContain('.list-section-heading{width:min(100%,630px);min-height:44px;margin:0 auto;display:flex;align-items:center;border-bottom:1px solid #e8e0d5}')
})
it('uses approved Today-sourced page headers for full Habits and keeps archive lazy section', () => {
expect(habits).toContain('v-if="view === \'habits\'" class="habit-page-context"')
expect(habits).toContain('<h1 class="list-page-title">习惯</h1>')
expect(habits).toContain('<p class="list-page-summary">今天还有 {{ todayHabitSummary.total - todayHabitSummary.completed }} 项</p>')
expect(habits).toContain('class="completed-filter-pill habit-inline-filter"')
expect(habits).toContain('@update:model-value="$emit(\'update:showCompleted\', $event)"')
expect(habits).toContain('class="list-section-heading habit-section-heading"')
expect(habits).toContain('<span class="list-section-title">今日习惯</span>')
expect(habits).toContain('<span class="list-section-count">{{ todayHabitSummary.total }}</span>')
expect(habits).not.toContain('<span class="list-section-count">{{ visibleHabits.length }}</span>')
expect(habits).not.toContain('今日完成 {{')
expect(habits).toContain('class="habit-archive-section"')
expect(css).toContain('.habit-page-context{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:0 16px}')
expect(css).toContain('.habit-section-heading{margin-top:0}')
expect(css).toContain('main.list-main>.mvp-view{gap:0}')
expect(css).toContain('main.list-main>.mvp-view>.habit-archive-section{border-top:1px solid #e8e0d5}')
})
it('uses one 58px plain-list contract for active task and habit rows', () => {
expect(app).toContain("class=\"task-list plain-list\"")
expect(app).toContain("'task-row--trash':activeView==='trash'")