feat: unify task and habit pages with Today
This commit is contained in:
@@ -135,6 +135,20 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq
|
|||||||
|
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await openSidebarView(page, '收集箱')
|
await openSidebarView(page, '收集箱')
|
||||||
|
const mobileGeometry = await page.evaluate(() => {
|
||||||
|
const main = document.querySelector('main.list-main')!.getBoundingClientRect()
|
||||||
|
const header = document.querySelector('.list-page-context')!.getBoundingClientRect()
|
||||||
|
const filter = document.querySelector('.list-inline-filter')!.getBoundingClientRect()
|
||||||
|
const search = document.querySelector('.list-search-reveal')!.getBoundingClientRect()
|
||||||
|
const toggle = document.querySelector('.list-search-reveal .task-search-toggle')!.getBoundingClientRect()
|
||||||
|
return { main: { left: main.left, right: main.right }, header: { left: header.left, right: header.right, bottom: header.bottom }, filter: { left: filter.left, bottom: filter.bottom }, search: { left: search.left, right: search.right, top: search.top }, toggle: { left: toggle.left, top: toggle.top } }
|
||||||
|
})
|
||||||
|
expect(mobileGeometry.header.left - mobileGeometry.main.left).toBeCloseTo(29, 0)
|
||||||
|
expect(mobileGeometry.main.right - mobileGeometry.header.right).toBeCloseTo(29, 0)
|
||||||
|
expect(mobileGeometry.search.left).toBeGreaterThanOrEqual(mobileGeometry.header.left)
|
||||||
|
expect(mobileGeometry.search.right).toBeLessThanOrEqual(mobileGeometry.header.right)
|
||||||
|
expect(mobileGeometry.toggle.top).toBeGreaterThanOrEqual(mobileGeometry.header.bottom - 1)
|
||||||
|
expect(mobileGeometry.toggle.left).toBeGreaterThanOrEqual(mobileGeometry.filter.left)
|
||||||
const searchToggle = page.getByRole('button', { name: '展开搜索任务' })
|
const searchToggle = page.getByRole('button', { name: '展开搜索任务' })
|
||||||
await expect(searchToggle).toHaveAttribute('aria-expanded', 'false')
|
await expect(searchToggle).toHaveAttribute('aria-expanded', 'false')
|
||||||
await searchToggle.click()
|
await searchToggle.click()
|
||||||
@@ -219,10 +233,17 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq
|
|||||||
await expect(page.getByRole('button', { name: /展开搜索任务|收起搜索任务/ })).toBeHidden()
|
await expect(page.getByRole('button', { name: /展开搜索任务|收起搜索任务/ })).toBeHidden()
|
||||||
const desktopSearch = page.getByRole('textbox', { name: '搜索任务' })
|
const desktopSearch = page.getByRole('textbox', { name: '搜索任务' })
|
||||||
await expect(desktopSearch).toBeVisible()
|
await expect(desktopSearch).toBeVisible()
|
||||||
const desktopPanel = page.locator('.search-reveal')
|
const desktopPanel = page.locator('.list-search-reveal')
|
||||||
const desktopBox = await desktopPanel.boundingBox()
|
const desktopBox = await desktopPanel.boundingBox()
|
||||||
expect(desktopBox).not.toBeNull()
|
expect(desktopBox).not.toBeNull()
|
||||||
expect(desktopBox!.width).toBeGreaterThanOrEqual(320)
|
expect(desktopBox!.width).toBeGreaterThanOrEqual(320)
|
||||||
|
const desktopHeader = page.locator('.list-page-context')
|
||||||
|
const desktopList = page.locator('.task-list')
|
||||||
|
const desktopGeometry = await Promise.all([desktopHeader, desktopPanel, desktopList].map(async locator => locator.boundingBox()))
|
||||||
|
for (const box of desktopGeometry) expect(box).not.toBeNull()
|
||||||
|
expect(Math.abs(desktopGeometry[0]!.x - desktopGeometry[1]!.x)).toBeLessThanOrEqual(1)
|
||||||
|
expect(Math.abs(desktopGeometry[1]!.x - desktopGeometry[2]!.x)).toBeLessThanOrEqual(1)
|
||||||
|
expect(desktopGeometry[0]!.width).toBeLessThanOrEqual(900)
|
||||||
await desktopSearch.fill(`搜索保留-${suffix}`)
|
await desktopSearch.fill(`搜索保留-${suffix}`)
|
||||||
const desktopTaskRow = page.locator('.task-row').filter({ hasText: taskTitle })
|
const desktopTaskRow = page.locator('.task-row').filter({ hasText: taskTitle })
|
||||||
await expect(desktopTaskRow).toHaveCount(1)
|
await expect(desktopTaskRow).toHaveCount(1)
|
||||||
|
|||||||
+39
-7
@@ -25,6 +25,7 @@ import AppSheet from './components/AppSheet.vue'
|
|||||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||||
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
|
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
|
||||||
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
|
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
|
||||||
|
import { loadTaskOpenTotal } from './lib/task-open-total'
|
||||||
|
|
||||||
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 }
|
||||||
@@ -103,6 +104,7 @@ const todaySectionCollapse = ref<TodaySectionCollapse>(readTodaySectionCollapse(
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 50
|
const pageSize = 50
|
||||||
const totalTasks = ref(0)
|
const totalTasks = ref(0)
|
||||||
|
const taskOpenTotal = ref<number | null>(null)
|
||||||
const hiddenCompletedTaskCount = ref(0)
|
const hiddenCompletedTaskCount = ref(0)
|
||||||
const todayTaskTotal = ref(0)
|
const todayTaskTotal = ref(0)
|
||||||
const todayTaskCompleted = 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: '日' }]
|
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 recurrenceLoadToken = 0
|
||||||
let todaySummaryLoadToken = 0
|
let todaySummaryLoadToken = 0
|
||||||
|
let taskOpenTotalLoadToken = 0
|
||||||
let todayEnvironmentLoadToken = 0
|
let todayEnvironmentLoadToken = 0
|
||||||
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
|
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
|
||||||
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | 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
|
if (!isLatestRequest('tasks', request)) return
|
||||||
tasks.value = data.items ?? []
|
tasks.value = data.items ?? []
|
||||||
totalTasks.value = data.total ?? tasks.value.length
|
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
|
hiddenCompletedTaskCount.value = 0
|
||||||
if (!showCompleted.value && !query.value && activeView.value !== 'trash' && tasks.value.length === 0) {
|
if (!showCompleted.value && !query.value && activeView.value !== 'trash' && tasks.value.length === 0) {
|
||||||
const completedParams = new URLSearchParams(params)
|
const completedParams = new URLSearchParams(params)
|
||||||
@@ -589,6 +604,10 @@ async function preloadCountdowns() {
|
|||||||
}
|
}
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
const request = beginLatestRequest('tasks')
|
const request = beginLatestRequest('tasks')
|
||||||
|
if (activeView.value === 'tasks') {
|
||||||
|
taskOpenTotal.value = null
|
||||||
|
++taskOpenTotalLoadToken
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -655,6 +674,8 @@ async function switchView(view: View, listId?: string) {
|
|||||||
todayEnvironmentLoading.value = false
|
todayEnvironmentLoading.value = false
|
||||||
}
|
}
|
||||||
activeView.value = view
|
activeView.value = view
|
||||||
|
taskOpenTotal.value = null
|
||||||
|
++taskOpenTotalLoadToken
|
||||||
if (!query.value) mobileSearchOpen.value = false
|
if (!query.value) mobileSearchOpen.value = false
|
||||||
searchPullDistance.value = 0
|
searchPullDistance.value = 0
|
||||||
if (view !== 'trash') beginLatestRequest('trash')
|
if (view !== 'trash') beginLatestRequest('trash')
|
||||||
@@ -1504,22 +1525,22 @@ onUnmounted(() => {
|
|||||||
</AppSheet>
|
</AppSheet>
|
||||||
</aside>
|
</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">
|
<header class="topbar" :inert="memoBackgroundInert ? true : undefined">
|
||||||
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
|
<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">
|
<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>
|
<button class="icon topbar-refresh" :class="{spinning:refreshing}" type="button" aria-label="刷新当前页面" title="刷新" :disabled="refreshing || loading" @click="refreshCurrentView"><RefreshCw /></button>
|
||||||
</div>
|
</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>
|
<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>
|
<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>
|
<label id="task-search-panel" class="search"><Search/><input ref="searchInput" v-model="query" placeholder="搜索任务…" aria-label="搜索任务" @keydown="handleTaskSearchKeydown"><kbd>⌘ K</kbd></label>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<template v-if="['habits','settings'].includes(activeView)">
|
<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>
|
</template>
|
||||||
<MemoPanel v-else-if="activeView==='memos'" ref="memoPanel" :request="api" @scope="memoTrash=$event==='trash'" @detail="memoDetailOpen=$event" @notice="toast" />
|
<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" />
|
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
|
||||||
@@ -1530,6 +1551,15 @@ onUnmounted(() => {
|
|||||||
<p class="today-remaining">还有 {{ todayTaskRemaining + todayHabitRemaining }} 项待完成</p>
|
<p class="today-remaining">还有 {{ todayTaskRemaining + todayHabitRemaining }} 项待完成</p>
|
||||||
<CompletedFilterPill v-model="showCompleted" class="today-inline-filter" />
|
<CompletedFilterPill v-model="showCompleted" class="today-inline-filter" />
|
||||||
</section>
|
</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'">
|
<template v-if="activeView==='today'">
|
||||||
<section v-if="overdueTaskTree.length" class="overdue-section today-collapsible-section">
|
<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>
|
<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>
|
</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>
|
<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>
|
</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>
|
<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">
|
<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)">
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionEx
|
|||||||
import { backupFileSnapshot, isCurrentBackupSnapshot, isLegacyBackup, shouldCommitBackupPreflight, type BackupFileSnapshot } from './lib/backup-preflight-state'
|
import { backupFileSnapshot, isCurrentBackupSnapshot, isLegacyBackup, shouldCommitBackupPreflight, type BackupFileSnapshot } from './lib/backup-preflight-state'
|
||||||
import AppSheet from './components/AppSheet.vue'
|
import AppSheet from './components/AppSheet.vue'
|
||||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||||
|
import CompletedFilterPill from './components/CompletedFilterPill.vue'
|
||||||
|
|
||||||
type View = 'habits' | 'today-habits' | 'settings'
|
type View = 'habits' | 'today-habits' | 'settings'
|
||||||
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
|
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
|
||||||
@@ -20,6 +21,7 @@ const emit = defineEmits<{
|
|||||||
changed: []
|
changed: []
|
||||||
notice: [message: string]
|
notice: [message: string]
|
||||||
summary: [value: { total: number; completed: number }]
|
summary: [value: { total: number; completed: number }]
|
||||||
|
'update:showCompleted': [value: boolean]
|
||||||
}>()
|
}>()
|
||||||
const habits = ref<Habit[]>([])
|
const habits = ref<Habit[]>([])
|
||||||
const archivedHabits = ref<Habit[]>([])
|
const archivedHabits = ref<Habit[]>([])
|
||||||
@@ -743,6 +745,10 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</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">
|
<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>
|
<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">
|
<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>
|
<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>
|
</AppSheet>
|
||||||
|
|
||||||
|
<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="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="habit-list plain-list">
|
<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)">
|
<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)">
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ describe('Today environment integration', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('renders the approved plain-list composition without a board or progress tracks', () => {
|
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('<section v-if="activeView===\'today\'" class="today-context" aria-label="今日概览">')
|
||||||
expect(app).toContain('<p class="today-remaining">还有 {{ todayTaskRemaining + todayHabitRemaining }} 项待完成</p>')
|
expect(app).toContain('<p class="today-remaining">还有 {{ todayTaskRemaining + todayHabitRemaining }} 项待完成</p>')
|
||||||
expect(app).not.toContain('class="today-board"')
|
expect(app).not.toContain('class="today-board"')
|
||||||
@@ -84,9 +84,9 @@ describe('Today environment integration', () => {
|
|||||||
expect(filter).toBeGreaterThan(remaining)
|
expect(filter).toBeGreaterThan(remaining)
|
||||||
expect(overdue).toBeGreaterThan(filter)
|
expect(overdue).toBeGreaterThan(filter)
|
||||||
expect(main.match(/>今天<\/h1>/g)).toHaveLength(1)
|
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("<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="刷新当前页面"')
|
expect(main).toContain('aria-label="刷新当前页面"')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe('Today independent collapsible sections', () => {
|
|||||||
expect(app).toContain(`:aria-expanded="!todaySectionCollapse.${section}"`)
|
expect(app).toContain(`:aria-expanded="!todaySectionCollapse.${section}"`)
|
||||||
if (section === 'tasks') {
|
if (section === 'tasks') {
|
||||||
expect(app).toContain(":id=\"activeView==='today' ? 'today-tasks' : undefined\"")
|
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 {
|
} else {
|
||||||
expect(app).toContain(`id="today-${section}"`)
|
expect(app).toContain(`id="today-${section}"`)
|
||||||
expect(app).toContain(`aria-labelledby="today-${section}-heading"`)
|
expect(app).toContain(`aria-labelledby="today-${section}-heading"`)
|
||||||
|
|||||||
@@ -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. */ })
|
||||||
|
}
|
||||||
@@ -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{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}
|
.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. */
|
/* Approved Today plain-list prototype parity. */
|
||||||
main.today-main .topbar{margin-bottom:18px}
|
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}
|
.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}
|
||||||
|
|||||||
@@ -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', () => {
|
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 habitReorderMode = ref(false)')
|
||||||
expect(mvpPanel).toContain("const habitReorderAvailable = computed(() => props.view === 'habits' && visibleHabits.value.length > 1)")
|
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('v-if="habitReorderAvailable" class="list-section-action"')
|
||||||
expect(mvpPanel).toContain('class="soft-button reorder-mode-toggle habit-reorder-toggle"')
|
expect(mvpPanel).toContain('class="list-section-heading habit-section-heading"')
|
||||||
expect(mvpPanel).toContain("{{ habitReorderMode ? '完成' : '调整顺序' }}")
|
expect(mvpPanel).toContain("{{ habitReorderMode ? '完成' : '调整顺序' }}")
|
||||||
expect(mvpPanel).toContain('v-if="habitReorderMode && habitReorderAvailable" class="drag-handle habit-drag-handle"')
|
expect(mvpPanel).toContain('v-if="habitReorderMode && habitReorderAvailable" class="drag-handle habit-drag-handle"')
|
||||||
const todayHabits = mvpPanel.slice(mvpPanel.indexOf('<!-- 今日习惯'), mvpPanel.indexOf('<!-- 完整习惯列表'))
|
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', () => {
|
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).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("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="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('v-model="showCompleted"')
|
||||||
expect(app).toContain('class="topbar-actions"')
|
expect(app).toContain('class="topbar-actions"')
|
||||||
expect(app).toContain('topbar-refresh')
|
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("readStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, true)")
|
||||||
expect(app).toContain("writeStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, value)")
|
expect(app).toContain("writeStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, value)")
|
||||||
expect(app).toContain(':show-completed="showCompleted"')
|
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).toContain('showCompleted: boolean')
|
||||||
expect(mvpPanel).not.toContain("'update:showCompleted': [value: boolean]")
|
expect(mvpPanel).toContain("'update:showCompleted': [value: boolean]")
|
||||||
expect(completedFilterPill).not.toContain('localStorage')
|
expect(completedFilterPill).not.toContain('localStorage')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { loadTaskOpenTotal } from './lib/task-open-total'
|
||||||
|
|
||||||
const app = readFileSync('src/App.vue', 'utf8')
|
const app = readFileSync('src/App.vue', 'utf8')
|
||||||
const countdown = readFileSync('src/CountdownPanel.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 memoPanel = readFileSync('src/MemoPanel.vue', 'utf8')
|
||||||
const memoRow = readFileSync('src/components/MemoRow.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', () => {
|
describe('approved five-detail polish', () => {
|
||||||
it('uses an independent accessible task-search panel toggle', () => {
|
it('uses an independent accessible task-search panel toggle', () => {
|
||||||
expect(app).toContain(":aria-label=\"mobileSearchOpen ? '收起搜索任务' : '展开搜索任务'\"")
|
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/)
|
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', () => {
|
it('uses one 58px plain-list contract for active task and habit rows', () => {
|
||||||
expect(app).toContain("class=\"task-list plain-list\"")
|
expect(app).toContain("class=\"task-list plain-list\"")
|
||||||
expect(app).toContain("'task-row--trash':activeView==='trash'")
|
expect(app).toContain("'task-row--trash':activeView==='trash'")
|
||||||
|
|||||||
Reference in New Issue
Block a user