feat: simplify today into plain list
This commit is contained in:
@@ -20,42 +20,47 @@ async function assertInsideViewport(locator: Locator, page: Page) {
|
|||||||
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height + 1)
|
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
test('Today paper composition has no overlap or horizontal overflow', async ({ page }) => {
|
test('Today plain-list composition has no overlap or horizontal overflow', async ({ page }) => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await expect(await bottomTab(page, '今天')).toHaveAttribute('aria-current', 'page')
|
await expect(await bottomTab(page, '今天')).toHaveAttribute('aria-current', 'page')
|
||||||
const board = page.locator('.today-board')
|
|
||||||
const environment = page.locator('.today-environment')
|
|
||||||
const tracks = board.locator('.today-track')
|
|
||||||
await expect(board).toBeVisible()
|
|
||||||
await expect(environment).toBeVisible()
|
|
||||||
await expect(tracks).toHaveCount(2)
|
|
||||||
|
|
||||||
const geometry = await board.evaluate((node) => {
|
const context = page.locator('.today-context')
|
||||||
const boardRect = node.getBoundingClientRect()
|
const environment = page.locator('.today-environment')
|
||||||
const environmentRect = node.querySelector('.today-environment')!.getBoundingClientRect()
|
const title = page.locator('.today-page-title')
|
||||||
const trackRects = [...node.querySelectorAll('.today-track')].map(item => item.getBoundingClientRect())
|
const remaining = page.locator('.today-remaining')
|
||||||
|
const filter = page.locator('.today-inline-filter')
|
||||||
|
const firstSection = page.locator('.today-section-toggle').first()
|
||||||
|
|
||||||
|
for (const locator of [context, environment, title, remaining, filter, firstSection]) {
|
||||||
|
await expect(locator).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = await page.locator('main').evaluate((main) => {
|
||||||
|
const selectors = ['.today-environment', '.today-page-title', '.today-remaining', '.today-inline-filter', '.today-section-toggle']
|
||||||
|
const rects = selectors.map(selector => main.querySelector(selector)!.getBoundingClientRect())
|
||||||
|
const context = main.querySelector('.today-context')!
|
||||||
|
const contextRect = context.getBoundingClientRect()
|
||||||
return {
|
return {
|
||||||
boardRight: boardRect.right,
|
mainScrollWidth: main.scrollWidth,
|
||||||
boardBottom: boardRect.bottom,
|
mainClientWidth: main.clientWidth,
|
||||||
boardScrollWidth: node.scrollWidth,
|
contextScrollWidth: context.scrollWidth,
|
||||||
boardClientWidth: node.clientWidth,
|
contextClientWidth: context.clientWidth,
|
||||||
environmentBottom: environmentRect.bottom,
|
contextLeft: contextRect.left,
|
||||||
trackTops: trackRects.map(rect => rect.top),
|
contextRight: contextRect.right,
|
||||||
trackRights: trackRects.map(rect => rect.right),
|
rects: rects.map(rect => ({ left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height })),
|
||||||
trackBottoms: trackRects.map(rect => rect.bottom),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
expect(geometry.boardScrollWidth).toBeLessThanOrEqual(geometry.boardClientWidth)
|
|
||||||
expect(Math.min(...geometry.trackTops)).toBeGreaterThanOrEqual(geometry.environmentBottom - 1)
|
|
||||||
expect(Math.max(...geometry.trackRights)).toBeLessThanOrEqual(geometry.boardRight + 1)
|
|
||||||
expect(Math.max(...geometry.trackBottoms)).toBeLessThanOrEqual(geometry.boardBottom + 1)
|
|
||||||
|
|
||||||
for (const track of await tracks.all()) {
|
expect(geometry.mainScrollWidth).toBeLessThanOrEqual(geometry.mainClientWidth)
|
||||||
const head = track.locator('.today-track-head')
|
expect(geometry.contextScrollWidth).toBeLessThanOrEqual(geometry.contextClientWidth)
|
||||||
const labels = head.locator('strong, span')
|
for (const rect of geometry.rects) {
|
||||||
await expect(labels).toHaveCount(2)
|
expect(rect.width).toBeGreaterThan(0)
|
||||||
const boxes = await labels.evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect()))
|
expect(rect.height).toBeGreaterThan(0)
|
||||||
expect(Math.abs(boxes[0].top - boxes[1].top)).toBeLessThan(4)
|
expect(rect.left).toBeGreaterThanOrEqual(geometry.contextLeft - 1)
|
||||||
|
expect(rect.right).toBeLessThanOrEqual(geometry.contextRight + 1)
|
||||||
|
}
|
||||||
|
for (let index = 0; index < geometry.rects.length - 1; index += 1) {
|
||||||
|
expect(geometry.rects[index].bottom).toBeLessThanOrEqual(geometry.rects[index + 1].top + 1)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
test('Today task persists through detail, completion and reopen', async ({ page }, testInfo) => {
|
test('Today task persists through detail, completion and reopen', async ({ page }, testInfo) => {
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { expect, test } from './fixtures'
|
||||||
|
|
||||||
|
const widths = [721, 720, 390, 375]
|
||||||
|
|
||||||
|
test('Today plain-list layout matches the approved responsive geometry', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
for (const width of widths) {
|
||||||
|
await page.setViewportSize({ width, height: width <= 390 ? 844 : 900 })
|
||||||
|
await expect(page.locator('.today-environment')).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: '刷新当前页面' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('switch', { name: '显示已完成' })).toHaveCount(1)
|
||||||
|
await expect(page.locator('.today-environment__gold')).toContainText('Au99.99')
|
||||||
|
const metrics = await page.evaluate(() => {
|
||||||
|
const rect = (selector: string) => document.querySelector<HTMLElement>(selector)!.getBoundingClientRect()
|
||||||
|
const environment = document.querySelector<HTMLElement>('.today-environment')!
|
||||||
|
const calendar = document.querySelector<HTMLElement>('.today-environment__calendar')!
|
||||||
|
const weather = document.querySelector<HTMLElement>('.today-environment__weather')!
|
||||||
|
const gold = document.querySelector<HTMLElement>('.today-environment__gold')!
|
||||||
|
const title = document.querySelector<HTMLElement>('.today-page-title')!
|
||||||
|
const remaining = document.querySelector<HTMLElement>('.today-remaining')!
|
||||||
|
const filter = document.querySelector<HTMLElement>('.today-inline-filter')!
|
||||||
|
const firstSection = document.querySelector<HTMLElement>('.today-section-toggle')!
|
||||||
|
const main = document.querySelector<HTMLElement>('main.today-main')!
|
||||||
|
const content = rect('.today-context')
|
||||||
|
const orderedElements = [environment, title, remaining, filter, firstSection]
|
||||||
|
const ordered = orderedElements.map((element) => {
|
||||||
|
const box = element.getBoundingClientRect()
|
||||||
|
return { left: box.left, right: box.right, top: box.top, bottom: box.bottom, width: box.width, height: box.height }
|
||||||
|
})
|
||||||
|
const tolerance = 1
|
||||||
|
const orderedPairs = ordered.slice(0, -1).map((current, index) => ({
|
||||||
|
previousBottom: current.bottom,
|
||||||
|
nextTop: ordered[index + 1].top,
|
||||||
|
separated: current.bottom <= ordered[index + 1].top + tolerance,
|
||||||
|
}))
|
||||||
|
const positiveGeometry = ordered.every(box => box.width > 0 && box.height > 0)
|
||||||
|
const insideContent = ordered.every(box => box.left >= content.left - tolerance && box.right <= content.right + tolerance)
|
||||||
|
const visible = (element: HTMLElement) => {
|
||||||
|
const style = getComputedStyle(element)
|
||||||
|
return style.visibility !== 'hidden' && style.display !== 'none' && element.getBoundingClientRect().width > 0
|
||||||
|
}
|
||||||
|
const rows = [...environment.children]
|
||||||
|
.filter((node): node is HTMLElement => node instanceof HTMLElement && visible(node) && !node.classList.contains('sr-only'))
|
||||||
|
.map(node => Math.round(node.getBoundingClientRect().top))
|
||||||
|
const uniqueRows = [...new Set(rows)]
|
||||||
|
const collisions = [calendar, weather, gold].some((a, index, items) => items.slice(index + 1).some(b => {
|
||||||
|
const ar = a.getBoundingClientRect(); const br = b.getBoundingClientRect()
|
||||||
|
return ar.left < br.right && ar.right > br.left && ar.top < br.bottom && ar.bottom > br.top
|
||||||
|
}))
|
||||||
|
const secondary = [...environment.querySelectorAll<HTMLElement>('small')].map(node => ({ scrollWidth: node.scrollWidth, clientWidth: node.clientWidth, visible: visible(node) }))
|
||||||
|
return {
|
||||||
|
viewport: innerWidth,
|
||||||
|
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||||
|
mainOverflow: main.scrollWidth - main.clientWidth,
|
||||||
|
content: { left: content.left, right: content.right, width: content.width },
|
||||||
|
environment: { ...environment.getBoundingClientRect().toJSON(), scrollWidth: environment.scrollWidth, clientWidth: environment.clientWidth },
|
||||||
|
directions: { calendar: getComputedStyle(calendar).flexDirection, weather: getComputedStyle(weather).flexDirection, gold: getComputedStyle(gold).flexDirection },
|
||||||
|
uniqueRows,
|
||||||
|
collisions,
|
||||||
|
secondary,
|
||||||
|
ordered,
|
||||||
|
orderedPairs,
|
||||||
|
positiveGeometry,
|
||||||
|
insideContent,
|
||||||
|
weatherText: weather.innerText,
|
||||||
|
goldText: gold.innerText,
|
||||||
|
goldLabel: gold.querySelector('.today-environment__gold-date')?.getAttribute('aria-label'),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log(`[today-geometry][${width}] ${JSON.stringify(metrics)}`)
|
||||||
|
expect(metrics.documentOverflow).toBe(0)
|
||||||
|
expect(metrics.mainOverflow).toBeLessThanOrEqual(0)
|
||||||
|
expect(metrics.environment.scrollWidth).toBeLessThanOrEqual(metrics.environment.clientWidth)
|
||||||
|
expect(metrics.collisions).toBeFalsy()
|
||||||
|
expect(metrics.positiveGeometry).toBeTruthy()
|
||||||
|
expect(metrics.insideContent).toBeTruthy()
|
||||||
|
expect(metrics.orderedPairs.every(pair => pair.separated)).toBeTruthy()
|
||||||
|
expect(metrics.goldText).toContain('Au99.99')
|
||||||
|
expect(metrics.goldLabel).toMatch(/市场日期 \d{4}-\d{2}-\d{2}/)
|
||||||
|
expect(metrics.secondary.every(line => line.visible && line.scrollWidth <= line.clientWidth)).toBeTruthy()
|
||||||
|
if (width === 721) {
|
||||||
|
expect(metrics.uniqueRows).toHaveLength(1)
|
||||||
|
expect(metrics.environment.height).toBeLessThan(80)
|
||||||
|
} else {
|
||||||
|
expect(metrics.uniqueRows).toHaveLength(2)
|
||||||
|
expect(metrics.directions.calendar).toBe('row')
|
||||||
|
expect(metrics.directions.weather).toBe('column')
|
||||||
|
expect(metrics.directions.gold).toBe('column')
|
||||||
|
}
|
||||||
|
if (width >= 721) {
|
||||||
|
expect(metrics.content.width).toBeLessThanOrEqual(721)
|
||||||
|
expect(Math.abs(metrics.content.left - (width - metrics.content.width) / 2)).toBeLessThanOrEqual(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
+10
-33
@@ -321,12 +321,8 @@ const activeName = computed(() => {
|
|||||||
if (activeView.value === 'settings') return '设置与数据'
|
if (activeView.value === 'settings') return '设置与数据'
|
||||||
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
|
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
|
||||||
})
|
})
|
||||||
const todayHabitProgress = computed(() => `${todayHabitCompleted.value} / ${todayHabitTotal.value}`)
|
|
||||||
const todayTaskRemaining = computed(() => Math.max(0, todayTaskTotal.value - todayTaskCompleted.value))
|
const todayTaskRemaining = computed(() => Math.max(0, todayTaskTotal.value - todayTaskCompleted.value))
|
||||||
const todayHabitRemaining = computed(() => Math.max(0, todayHabitTotal.value - todayHabitCompleted.value))
|
const todayHabitRemaining = computed(() => Math.max(0, todayHabitTotal.value - todayHabitCompleted.value))
|
||||||
const progressWidth = (completed: number, total: number) => `${total > 0 ? Math.min(100, Math.round(completed / total * 100)) : 0}%`
|
|
||||||
const todayTaskProgressPercent = computed(() => progressWidth(todayTaskCompleted.value, todayTaskTotal.value))
|
|
||||||
const todayHabitProgressPercent = computed(() => progressWidth(todayHabitCompleted.value, todayHabitTotal.value))
|
|
||||||
function persistTodaySectionCollapse() {
|
function persistTodaySectionCollapse() {
|
||||||
writeTodaySectionCollapse(window.localStorage, TODAY_SECTION_COLLAPSE_KEY, todaySectionCollapse.value)
|
writeTodaySectionCollapse(window.localStorage, TODAY_SECTION_COLLAPSE_KEY, todaySectionCollapse.value)
|
||||||
}
|
}
|
||||||
@@ -334,17 +330,6 @@ function toggleTodaySection(section: keyof TodaySectionCollapse) {
|
|||||||
todaySectionCollapse.value[section] = !todaySectionCollapse.value[section]
|
todaySectionCollapse.value[section] = !todaySectionCollapse.value[section]
|
||||||
persistTodaySectionCollapse()
|
persistTodaySectionCollapse()
|
||||||
}
|
}
|
||||||
async function navigateTodaySection(section: 'tasks' | 'habits') {
|
|
||||||
if (todaySectionCollapse.value[section]) {
|
|
||||||
todaySectionCollapse.value[section] = false
|
|
||||||
persistTodaySectionCollapse()
|
|
||||||
}
|
|
||||||
await nextTick()
|
|
||||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
|
||||||
const heading = document.getElementById(`today-${section}-heading`)
|
|
||||||
heading?.focus({ preventScroll: true })
|
|
||||||
document.getElementById(`today-${section}`)?.scrollIntoView({ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'start' })
|
|
||||||
}
|
|
||||||
function updateTodayHabitSummary(value: { total: number; completed: number }) {
|
function updateTodayHabitSummary(value: { total: number; completed: number }) {
|
||||||
todayHabitTotal.value = value.total
|
todayHabitTotal.value = value.total
|
||||||
todayHabitCompleted.value = value.completed
|
todayHabitCompleted.value = value.completed
|
||||||
@@ -1519,12 +1504,12 @@ onUnmounted(() => {
|
|||||||
</AppSheet>
|
</AppSheet>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
|
<main :class="{'today-main':activeView==='today'}" @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 class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
|
<div v-if="activeView!=='today'" 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-model="showCompleted" class="topbar-filter" />
|
<CompletedFilterPill v-if="activeView!=='today'" 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" class="search-reveal" :class="{'mobile-search-open':mobileSearchOpen,'mobile-search-pulling':searchPullDistance>0}" :style="searchRevealStyle">
|
||||||
@@ -1539,30 +1524,22 @@ onUnmounted(() => {
|
|||||||
<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" />
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section v-if="activeView==='today'" class="today-board" aria-labelledby="today-board-title">
|
<section v-if="activeView==='today'" class="today-context" aria-label="今日概览">
|
||||||
<h2 id="today-board-title" class="sr-only">今日纸笺</h2>
|
|
||||||
<TodayEnvironmentStrip :environment="todayEnvironment" :loading="todayEnvironmentLoading" :failed="todayEnvironmentError" />
|
<TodayEnvironmentStrip :environment="todayEnvironment" :loading="todayEnvironmentLoading" :failed="todayEnvironmentError" />
|
||||||
<div class="today-board__progress" aria-label="今日任务与习惯进度">
|
<h1 class="today-page-title">今天</h1>
|
||||||
<button class="today-track today-task-track" type="button" aria-controls="today-tasks" @click="navigateTodaySection('tasks')">
|
<p class="today-remaining">还有 {{ todayTaskRemaining + todayHabitRemaining }} 项待完成</p>
|
||||||
<span class="today-track-head"><strong>任务 {{ todayTaskCompleted }} / {{ todayTaskTotal }}</strong><span>还有 {{ todayTaskRemaining }} 项</span></span>
|
<CompletedFilterPill v-model="showCompleted" class="today-inline-filter" />
|
||||||
<span class="today-track-rail" role="progressbar" aria-label="今日任务进度" aria-valuemin="0" :aria-valuemax="todayTaskTotal" :aria-valuenow="todayTaskCompleted"><i class="today-track-fill" :style="{ width: todayTaskProgressPercent }" /></span>
|
|
||||||
</button>
|
|
||||||
<button class="today-track today-habit-track" type="button" aria-controls="today-habits" @click="navigateTodaySection('habits')">
|
|
||||||
<span class="today-track-head"><strong>习惯 {{ todayHabitProgress }}</strong><span>还有 {{ todayHabitRemaining }} 项</span></span>
|
|
||||||
<span class="today-track-rail" role="progressbar" aria-label="今日习惯进度" aria-valuemin="0" :aria-valuemax="todayHabitTotal" :aria-valuenow="todayHabitCompleted"><i class="today-track-fill" :style="{ width: todayHabitProgressPercent }" /></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
<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"><CalendarDays/>已过期</span><span class="today-section-summary">{{overdueTaskTree.length}} 项</span><ChevronRight v-if="todaySectionCollapse.overdue" aria-hidden="true"/><ChevronDown v-else aria-hidden="true"/></button>
|
<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><ChevronRight v-if="todaySectionCollapse.overdue" aria-hidden="true"/><ChevronDown v-else aria-hidden="true"/></button>
|
||||||
<div v-show="!todaySectionCollapse.overdue" id="today-overdue" class="task-list overdue-list" role="region" aria-labelledby="today-overdue-heading">
|
<div v-show="!todaySectionCollapse.overdue" id="today-overdue" class="task-list overdue-list" role="region" aria-labelledby="today-overdue-heading">
|
||||||
<template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`">
|
<template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`">
|
||||||
<article :data-task-id="node.task.id" class="task-row overdue-task swipeable" :class="{'just-completed':justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 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 class="task-check" :aria-label="`完成${node.task.title}`" :aria-pressed="false" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"></span></button><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(node.task)" @keydown.enter="selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="selectTaskUnlessSwiped(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div><span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span></article>
|
<article :data-task-id="node.task.id" class="task-row overdue-task swipeable" :class="{'just-completed':justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 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 class="task-check" :aria-label="`完成${node.task.title}`" :aria-pressed="false" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"></span></button><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(node.task)" @keydown.enter="selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="selectTaskUnlessSwiped(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div><span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span></article>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<button id="today-tasks-heading" class="today-section-toggle today-section-anchor" type="button" :aria-expanded="!todaySectionCollapse.tasks" aria-controls="today-tasks" @click="toggleTodaySection('tasks')"><span class="today-section-title"><ListTodo/>任务</span><span class="today-section-summary">{{totalTasks}} 项</span><ChevronRight v-if="todaySectionCollapse.tasks" aria-hidden="true"/><ChevronDown v-else aria-hidden="true"/></button>
|
<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><ChevronRight v-if="todaySectionCollapse.tasks" aria-hidden="true"/><ChevronDown v-else aria-hidden="true"/></button>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="activeView!=='today'" class="list-toolbar"><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><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!=='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!=='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>
|
||||||
@@ -1579,7 +1556,7 @@ onUnmounted(() => {
|
|||||||
<div v-else-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ query ? '没有匹配的任务' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{query?'换个关键词试试':hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span><button v-if="activeView==='today' && !query && hiddenCompletedTaskCount === 0" class="soft-button empty-action" @click="openTaskCompose"><Plus/>添加今天任务</button></div>
|
<div v-else-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ query ? '没有匹配的任务' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{query?'换个关键词试试':hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span><button v-if="activeView==='today' && !query && hiddenCompletedTaskCount === 0" class="soft-button empty-action" @click="openTaskCompose"><Plus/>添加今天任务</button></div>
|
||||||
</section>
|
</section>
|
||||||
<div v-if="activeView==='today'" class="today-habits-section today-section-anchor">
|
<div v-if="activeView==='today'" class="today-habits-section today-section-anchor">
|
||||||
<button id="today-habits-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.habits" aria-controls="today-habits" @click="toggleTodaySection('habits')"><span class="today-section-title"><Repeat2/>习惯</span><span class="today-section-summary">{{todayHabitCompleted}} / {{todayHabitTotal}}</span><ChevronRight v-if="todaySectionCollapse.habits" aria-hidden="true"/><ChevronDown v-else aria-hidden="true"/></button>
|
<button id="today-habits-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.habits" aria-controls="today-habits" @click="toggleTodaySection('habits')"><span class="today-section-title">习惯</span><span class="today-section-summary">{{todayHabitCompleted}} / {{todayHabitTotal}}</span><ChevronRight v-if="todaySectionCollapse.habits" aria-hidden="true"/><ChevronDown v-else aria-hidden="true"/></button>
|
||||||
<div v-show="!todaySectionCollapse.habits" id="today-habits" role="region" aria-labelledby="today-habits-heading">
|
<div v-show="!todaySectionCollapse.habits" id="today-habits" role="region" aria-labelledby="today-habits-heading">
|
||||||
<MvpPanel ref="habitComposer" view="today-habits" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" @summary="updateTodayHabitSummary" />
|
<MvpPanel ref="habitComposer" view="today-habits" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" @summary="updateTodayHabitSummary" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,23 +46,42 @@ describe('Today environment integration', () => {
|
|||||||
expect(css).not.toContain('.today-environment__refreshing')
|
expect(css).not.toContain('.today-environment__refreshing')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders方案 A as the selected landing-page paper composition', () => {
|
it('renders the approved plain-list composition without a board or progress tracks', () => {
|
||||||
expect(app.match(/class="today-board"/g)).toHaveLength(1)
|
expect(app).toContain('<main :class="{\'today-main\':activeView===\'today\'}"')
|
||||||
expect(app).toContain('<section v-if="activeView===\'today\'" class="today-board" aria-labelledby="today-board-title">')
|
expect(app).toContain('<section v-if="activeView===\'today\'" class="today-context" aria-label="今日概览">')
|
||||||
expect(app).toContain('<h2 id="today-board-title" class="sr-only">今日纸笺</h2>')
|
expect(app).toContain('<p class="today-remaining">还有 {{ todayTaskRemaining + todayHabitRemaining }} 项待完成</p>')
|
||||||
expect(app).toContain('<div class="today-board__progress" aria-label="今日任务与习惯进度">')
|
expect(app).not.toContain('class="today-board"')
|
||||||
expect(app).toContain('<span>还有 {{ todayTaskRemaining }} 项</span>')
|
expect(app).not.toContain('today-board-title')
|
||||||
expect(app).toContain('<span>还有 {{ todayHabitRemaining }} 项</span>')
|
expect(app).not.toContain('today-board__progress')
|
||||||
expect(app).toMatch(/today-board__progress[\s\S]*today-task-track[\s\S]*today-habit-track[\s\S]*<\/div>/)
|
expect(app).not.toContain('today-task-track')
|
||||||
expect(css).toMatch(/\.today-board\{[^}]*padding:0 18px 16px/)
|
expect(app).not.toContain('today-habit-track')
|
||||||
expect(css).toMatch(/\.today-board__progress\{[^}]*display:grid[^}]*grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/)
|
expect(app).not.toContain('role="progressbar"')
|
||||||
expect(css).toContain('.today-track:first-child{padding-left:0;padding-right:18px}')
|
expect(css).toContain('main.today-main{padding-left:max(34px,calc((100% - 720px)/2));padding-right:max(34px,calc((100% - 720px)/2))}')
|
||||||
expect(css).toContain('.today-track:last-child{padding-right:0;padding-left:18px;border-left:1px solid var(--line)}')
|
expect(css).toContain('.today-context{margin:0 0 8px;border-bottom:1px solid var(--line)}')
|
||||||
expect(css).not.toMatch(/@media\(max-width:720px\)\{\.today-board\{[^}]*padding:/)
|
expect(css).toContain('.today-remaining{margin:7px 0 10px;color:var(--muted);font-size:13px}')
|
||||||
expect(css).not.toMatch(/@media\(max-width:930px\)[\s\S]*?\.today-track-head\{[^}]*flex-direction:column/)
|
expect(css).not.toContain('.today-board{')
|
||||||
expect(css).toContain('.today-track-rail{display:block;height:5px;overflow:hidden;border-radius:999px;background:#eee5d9}')
|
expect(css).not.toContain('.today-board__progress{')
|
||||||
expect(css).toContain('.today-track-fill{display:block;width:0;height:100%;border-radius:inherit;background:var(--success)')
|
expect(css).not.toContain('.today-track{')
|
||||||
expect(css).not.toMatch(/\.today-(?:board|track)[^{]*\{[^}]*(?:gradient|backdrop-filter)/)
|
expect(css).not.toContain('.today-track-rail{')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the approved plain-list DOM in prototype order without duplicating the Today title', () => {
|
||||||
|
const main = app.slice(app.indexOf('<main :class='), app.indexOf('</main>'))
|
||||||
|
const environment = main.indexOf('<TodayEnvironmentStrip')
|
||||||
|
const title = main.indexOf('<h1 class="today-page-title">今天</h1>')
|
||||||
|
const remaining = main.indexOf('<p class="today-remaining">')
|
||||||
|
const filter = main.indexOf('<CompletedFilterPill v-model="showCompleted" class="today-inline-filter" />')
|
||||||
|
const overdue = main.indexOf('id="today-overdue-heading"')
|
||||||
|
expect(environment).toBeGreaterThan(-1)
|
||||||
|
expect(title).toBeGreaterThan(environment)
|
||||||
|
expect(remaining).toBeGreaterThan(title)
|
||||||
|
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', 'upcoming', 'habits'].includes(activeView)\" class=\"topbar-actions\">")
|
||||||
|
expect(main).toContain('<CompletedFilterPill v-if="activeView!==\'today\'" v-model="showCompleted" class="topbar-filter" />')
|
||||||
|
expect(main).toContain('aria-label="刷新当前页面"')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the editorial environment layout readable at desktop and narrow widths', () => {
|
it('keeps the editorial environment layout readable at desktop and narrow widths', () => {
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ describe('Today independent collapsible sections', () => {
|
|||||||
expect(app).toContain(`aria-labelledby="today-${section}-heading"`)
|
expect(app).toContain(`aria-labelledby="today-${section}-heading"`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
expect(app).toContain('<span class="today-section-title">逾期</span>')
|
||||||
|
expect(app).toContain('<span class="today-section-title">今天</span>')
|
||||||
|
expect(app).toContain('<span class="today-section-title">习惯</span>')
|
||||||
|
expect(app).not.toMatch(/today-section-title"><(?:CalendarDays|ListTodo|Repeat2)\/>/)
|
||||||
expect(app.match(/class="today-section-toggle(?: today-section-anchor)?"/g)).toHaveLength(3)
|
expect(app.match(/class="today-section-toggle(?: today-section-anchor)?"/g)).toHaveLength(3)
|
||||||
expect(app).toContain('<span class="today-section-summary">{{totalTasks}} 项</span>')
|
expect(app).toContain('<span class="today-section-summary">{{totalTasks}} 项</span>')
|
||||||
expect(app).not.toContain('<span class="today-section-summary">{{taskTree.length}} 项</span>')
|
expect(app).not.toContain('<span class="today-section-summary">{{taskTree.length}} 项</span>')
|
||||||
@@ -45,14 +49,9 @@ describe('Today independent collapsible sections', () => {
|
|||||||
expect(habits).not.toContain('v-if="!todaySectionCollapse.habits"')
|
expect(habits).not.toContain('v-if="!todaySectionCollapse.habits"')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('expands a collapsed destination before next-tick scrolling and focus, respecting reduced motion', () => {
|
it('keeps section navigation local to each collapse toggle', () => {
|
||||||
expect(app).toContain("async function navigateTodaySection(section: 'tasks' | 'habits')")
|
expect(app).not.toContain("async function navigateTodaySection(section: 'tasks' | 'habits')")
|
||||||
expect(app).toContain('todaySectionCollapse.value[section] = false')
|
expect(app).toContain("@click=\"toggleTodaySection('tasks')\"")
|
||||||
expect(app).toContain('await nextTick()')
|
expect(app).toContain("@click=\"toggleTodaySection('habits')\"")
|
||||||
expect(app).toContain('await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))')
|
|
||||||
expect(app).toContain('heading?.focus({ preventScroll: true })')
|
|
||||||
expect(app).toContain("window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'")
|
|
||||||
expect(app).toContain("@click=\"navigateTodaySection('tasks')\"")
|
|
||||||
expect(app).toContain("@click=\"navigateTodaySection('habits')\"")
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,11 +30,12 @@ describe('TodayEnvironmentStrip', () => {
|
|||||||
expect(host.querySelector('.today-environment')?.getAttribute('aria-label')).toBe('今日环境信息')
|
expect(host.querySelector('.today-environment')?.getAttribute('aria-label')).toBe('今日环境信息')
|
||||||
expect(host.textContent).toContain('9月16日 周三')
|
expect(host.textContent).toContain('9月16日 周三')
|
||||||
expect(host.textContent).toContain('农历八月初六')
|
expect(host.textContent).toContain('农历八月初六')
|
||||||
expect(host.textContent).toContain('宁波海曙')
|
const weather = host.querySelector('.today-environment__weather')!
|
||||||
expect(host.textContent).toContain('27° 多云')
|
expect(weather.children).toHaveLength(2)
|
||||||
expect(host.textContent).toContain('Au99.99 延时')
|
expect(weather.querySelector('strong')?.textContent).toBe('宁波 27°C 多云')
|
||||||
expect(host.textContent).toContain('¥782.35/g')
|
expect(weather.querySelector('small')?.textContent).toBe('海曙 09:00')
|
||||||
expect(host.textContent).toContain('市场日期 09-15')
|
expect(host.textContent).toContain('Au99.99 ¥782.35/g')
|
||||||
|
expect(host.textContent).toContain('上金所延迟价 09-15')
|
||||||
expect(host.querySelector('.today-environment__gold')?.classList.contains('is-stale')).toBe(true)
|
expect(host.querySelector('.today-environment__gold')?.classList.contains('is-stale')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -45,11 +46,9 @@ describe('TodayEnvironmentStrip', () => {
|
|||||||
gold: { contract: 'Au99.99', latest_price: '935.990', market_date: '2026-09-16', delayed: true, source: '上海黄金交易所', stale: true },
|
gold: { contract: 'Au99.99', latest_price: '935.990', market_date: '2026-09-16', delayed: true, source: '上海黄金交易所', stale: true },
|
||||||
} as TodayEnvironment })
|
} as TodayEnvironment })
|
||||||
expect(host.textContent).toContain('9月16日 周三')
|
expect(host.textContent).toContain('9月16日 周三')
|
||||||
expect(host.textContent).toContain('宁波海曙')
|
expect(host.textContent).toContain('宁波 28.4°C 多云')
|
||||||
expect(host.textContent).toMatch(/28\.4°\s+多云/)
|
expect(host.textContent).toContain('Au99.99 ¥935.99/g')
|
||||||
expect(host.textContent).toContain('Au99.99 延时')
|
expect(host.textContent).toContain('上金所延迟价 09-16')
|
||||||
expect(host.textContent).toContain('¥935.99/g')
|
|
||||||
expect(host.textContent).toContain('市场日期 09-16')
|
|
||||||
expect(host.querySelector('.today-environment__gold')?.classList.contains('is-stale')).toBe(true)
|
expect(host.querySelector('.today-environment__gold')?.classList.contains('is-stale')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -70,9 +69,8 @@ describe('TodayEnvironmentStrip', () => {
|
|||||||
|
|
||||||
it('keeps existing values visible while a refresh is in progress', async () => {
|
it('keeps existing values visible while a refresh is in progress', async () => {
|
||||||
const host = await mountStrip({ environment, loading: true })
|
const host = await mountStrip({ environment, loading: true })
|
||||||
expect(host.textContent).toContain('27° 多云')
|
expect(host.textContent).toContain('宁波 27°C 多云')
|
||||||
expect(host.textContent).toContain('Open-Meteo 09:00')
|
expect(host.textContent).toContain('海曙 09:00')
|
||||||
expect(host.textContent).not.toContain('Open-Meteo · 09:00')
|
|
||||||
expect(host.textContent).toContain('¥782.35/g')
|
expect(host.textContent).toContain('¥782.35/g')
|
||||||
expect(host.querySelector('.today-environment')?.getAttribute('aria-busy')).toBe('true')
|
expect(host.querySelector('.today-environment')?.getAttribute('aria-busy')).toBe('true')
|
||||||
const status = host.querySelector('.today-environment__status')
|
const status = host.querySelector('.today-environment__status')
|
||||||
@@ -141,10 +139,9 @@ describe('TodayEnvironmentStrip', () => {
|
|||||||
|
|
||||||
it('matches the selected two-row mobile composition', () => {
|
it('matches the selected two-row mobile composition', () => {
|
||||||
const css = readFileSync(resolve(process.cwd(), 'src/style.css'), 'utf8')
|
const css = readFileSync(resolve(process.cwd(), 'src/style.css'), 'utf8')
|
||||||
expect(css).toContain('.today-track-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;color:#776c60;font-size:12px}')
|
expect(css).not.toContain('.today-track-head{')
|
||||||
expect(css).toContain('.today-track-head strong{font-size:14px;line-height:1.2;color:#403a32}')
|
|
||||||
expect(css).toContain('@media(max-width:720px){.today-environment{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:auto auto}')
|
expect(css).toContain('@media(max-width:720px){.today-environment{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:auto auto}')
|
||||||
expect(css).toContain('.today-environment__calendar{grid-column:1/-1;grid-row:1;display:flex;flex-direction:column;align-items:flex-start')
|
expect(css).toContain('.today-environment__calendar{grid-column:1/-1;grid-row:1;display:flex;flex-direction:row;align-items:baseline;justify-content:space-between')
|
||||||
expect(css).toContain('.today-environment__weather{grid-column:1;grid-row:2;border-top:1px solid var(--line);border-left:0!important}')
|
expect(css).toContain('.today-environment__weather{grid-column:1;grid-row:2;border-top:1px solid var(--line);border-left:0!important}')
|
||||||
expect(css).toContain('.today-environment__gold{grid-column:2;grid-row:2;border-top:1px solid var(--line)}')
|
expect(css).toContain('.today-environment__gold{grid-column:2;grid-row:2;border-top:1px solid var(--line)}')
|
||||||
expect(css).toContain('.today-environment__weather,.today-environment__gold{padding:13px 10px;display:flex;flex-direction:column;')
|
expect(css).toContain('.today-environment__weather,.today-environment__gold{padding:13px 10px;display:flex;flex-direction:column;')
|
||||||
|
|||||||
@@ -88,14 +88,12 @@ const goldPrice = computed(() => {
|
|||||||
const numeric = typeof value === 'string' ? Number(value) : value
|
const numeric = typeof value === 'string' ? Number(value) : value
|
||||||
return typeof numeric === 'number' && Number.isFinite(numeric) ? numeric.toFixed(2) : null
|
return typeof numeric === 'number' && Number.isFinite(numeric) ? numeric.toFixed(2) : null
|
||||||
})
|
})
|
||||||
const weatherObservation = computed(() => {
|
const weatherObservationTime = computed(() => {
|
||||||
const value = props.environment?.weather
|
const value = props.environment?.weather
|
||||||
if (!value || weatherStatus.value === 'unavailable') return ''
|
if (!value || weatherStatus.value === 'unavailable' || !value.observed_at) return ''
|
||||||
const source = value.source || 'Open-Meteo'
|
|
||||||
if (!value.observed_at) return source
|
|
||||||
const observed = new Date(value.observed_at)
|
const observed = new Date(value.observed_at)
|
||||||
if (Number.isNaN(observed.getTime())) return source
|
if (Number.isNaN(observed.getTime())) return ''
|
||||||
return `${source} ${new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', hour12: false }).format(observed)}`
|
return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', hour12: false }).format(observed)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -117,9 +115,8 @@ const weatherObservation = computed(() => {
|
|||||||
</span>
|
</span>
|
||||||
<span class="today-environment__item today-environment__weather" :class="`is-${weatherStatus}`">
|
<span class="today-environment__item today-environment__weather" :class="`is-${weatherStatus}`">
|
||||||
<template v-if="weatherStatus !== 'unavailable'">
|
<template v-if="weatherStatus !== 'unavailable'">
|
||||||
<span class="today-environment__eyeline">{{ environment.weather?.location || '宁波海曙' }}</span>
|
<strong>宁波 <template v-if="environment.weather?.temperature_c != null">{{ environment.weather.temperature_c }}°C </template>{{ weatherText }}</strong>
|
||||||
<strong><template v-if="environment.weather?.temperature_c != null">{{ environment.weather.temperature_c }}° </template>{{ weatherText }}</strong>
|
<small>{{ `海曙${weatherObservationTime ? ` ${weatherObservationTime}` : ''}` }}<template v-if="weatherStatus === 'stale'"> · 缓存</template></small>
|
||||||
<small>{{ weatherObservation }}<template v-if="weatherStatus === 'stale'"> · 缓存</template></small>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="today-environment__skeleton" aria-hidden="true"></span>
|
<span class="today-environment__skeleton" aria-hidden="true"></span>
|
||||||
@@ -129,9 +126,8 @@ const weatherObservation = computed(() => {
|
|||||||
</span>
|
</span>
|
||||||
<span class="today-environment__item today-environment__gold" :class="`is-${goldStatus}`">
|
<span class="today-environment__item today-environment__gold" :class="`is-${goldStatus}`">
|
||||||
<template v-if="goldStatus !== 'unavailable' && goldPrice">
|
<template v-if="goldStatus !== 'unavailable' && goldPrice">
|
||||||
<span class="today-environment__eyeline">{{ environment.gold?.symbol || environment.gold?.contract || 'Au99.99' }} 延时</span>
|
<span class="today-environment__gold-primary"><b>{{ environment.gold?.symbol || environment.gold?.contract || 'Au99.99' }}</b> <strong>¥{{ goldPrice }}/g</strong></span>
|
||||||
<strong>¥{{ goldPrice }}/g</strong>
|
<small>上金所延迟价 <template v-if="goldMarketDate"><span class="today-environment__gold-date today-environment__gold-date--desktop" :title="`市场日期 ${goldMarketDate}`" :aria-label="`市场日期 ${goldMarketDate}`">{{ mobileGoldMarketDate }}</span><span class="today-environment__gold-date today-environment__gold-date--mobile" :title="`市场日期 ${goldMarketDate}`" :aria-label="`市场日期 ${goldMarketDate}`">{{ mobileGoldMarketDate }}</span></template><template v-if="goldStatus === 'stale'"> · 缓存</template></small>
|
||||||
<small><template v-if="goldMarketDate">市场日期 <span class="today-environment__gold-date today-environment__gold-date--desktop" :title="`市场日期 ${goldMarketDate}`" :aria-label="`市场日期 ${goldMarketDate}`">{{ mobileGoldMarketDate }}</span><span class="today-environment__gold-date today-environment__gold-date--mobile" :title="`市场日期 ${goldMarketDate}`" :aria-label="`市场日期 ${goldMarketDate}`">{{ mobileGoldMarketDate }}</span></template><template v-else>上金所延时</template><template v-if="goldStatus === 'stale'"> · 缓存</template></small>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="today-environment__skeleton" aria-hidden="true"></span>
|
<span class="today-environment__skeleton" aria-hidden="true"></span>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+19
-22
@@ -789,10 +789,12 @@ describe('task and habit row decoration', () => {
|
|||||||
expect(app).toContain('if (restoredNavigation.view === \'tasks\' && restoredNavigation.listId)')
|
expect(app).toContain('if (restoredNavigation.view === \'tasks\' && restoredNavigation.listId)')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses one topbar pill 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(1)
|
expect(app.match(/<CompletedFilterPill/g)).toHaveLength(2)
|
||||||
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('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')
|
||||||
@@ -912,18 +914,20 @@ describe('task and habit row decoration', () => {
|
|||||||
expect(app).toContain("params.set('due_to', isoAtLocalDayOffset(0))")
|
expect(app).toContain("params.set('due_to', isoAtLocalDayOffset(0))")
|
||||||
expect(app).toContain("params.set('completed', 'false')")
|
expect(app).toContain("params.set('completed', 'false')")
|
||||||
expect(app).toContain('class="overdue-section today-collapsible-section"')
|
expect(app).toContain('class="overdue-section today-collapsible-section"')
|
||||||
expect(app).toContain('已过期')
|
expect(app).toContain('<span class="today-section-title">逾期</span>')
|
||||||
expect(app).toContain('v-for="node in overdueTaskTree"')
|
expect(app).toContain('v-for="node in overdueTaskTree"')
|
||||||
expect(css).toContain('.overdue-section{')
|
expect(css).toContain('.overdue-section{')
|
||||||
expect(css).toContain('.overdue-heading{')
|
expect(css).toContain('.overdue-heading{')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps Today as a low-noise dashboard with direct empty-state actions', () => {
|
it('keeps Today as a low-noise continuous list with direct empty-state actions', () => {
|
||||||
expect(app).toContain('class="today-board"')
|
expect(app).toContain('class="today-context"')
|
||||||
expect(app).toContain('class="today-track today-task-track"')
|
expect(app).toContain('还有 {{ todayTaskRemaining + todayHabitRemaining }} 项待完成')
|
||||||
expect(app).toContain('class="today-track today-habit-track"')
|
expect(app).not.toContain('class="today-board"')
|
||||||
expect(app).toContain(':style="{ width: todayTaskProgressPercent }"')
|
expect(app).not.toContain('class="today-track today-task-track"')
|
||||||
expect(app).toContain(':style="{ width: todayHabitProgressPercent }"')
|
expect(app).not.toContain('class="today-track today-habit-track"')
|
||||||
|
expect(app).not.toContain(':style="{ width: todayTaskProgressPercent }"')
|
||||||
|
expect(app).not.toContain(':style="{ width: todayHabitProgressPercent }"')
|
||||||
expect(app).toContain('async function loadTodayTaskSummary()')
|
expect(app).toContain('async function loadTodayTaskSummary()')
|
||||||
expect(app).toContain('const token = ++todaySummaryLoadToken')
|
expect(app).toContain('const token = ++todaySummaryLoadToken')
|
||||||
expect(app).toContain("params.set('completed', String(completed))")
|
expect(app).toContain("params.set('completed', String(completed))")
|
||||||
@@ -931,21 +935,13 @@ describe('task and habit row decoration', () => {
|
|||||||
expect(app).toContain('await Promise.all([summaryTotal(false), summaryTotal(true), overdueTotal()])')
|
expect(app).toContain('await Promise.all([summaryTotal(false), summaryTotal(true), overdueTotal()])')
|
||||||
expect(app).toContain('todayTaskTotal.value = overdue + open + completed')
|
expect(app).toContain('todayTaskTotal.value = overdue + open + completed')
|
||||||
expect(app).toContain("if (activeView.value === 'today') void loadTodayTaskSummary()")
|
expect(app).toContain("if (activeView.value === 'today') void loadTodayTaskSummary()")
|
||||||
expect(app).toContain('aria-controls="today-tasks"')
|
expect(app).not.toContain('role="progressbar"')
|
||||||
expect(app).toContain('aria-controls="today-habits"')
|
|
||||||
expect(app).toContain('role="progressbar"')
|
|
||||||
expect(app).toContain(':aria-valuemax="todayTaskTotal"')
|
|
||||||
expect(app).toContain(':aria-valuenow="todayHabitCompleted"')
|
|
||||||
expect(app).toContain('任务 {{ todayTaskCompleted }} / {{ todayTaskTotal }}')
|
|
||||||
expect(app).toContain('习惯 {{ todayHabitProgress }}')
|
|
||||||
expect(app).not.toContain('const todayProgressText = computed')
|
expect(app).not.toContain('const todayProgressText = computed')
|
||||||
expect(app).not.toContain('const todayTaskProgressHint = computed')
|
expect(app).not.toContain('const todayTaskProgressHint = computed')
|
||||||
expect(app).not.toContain('const todayHabitProgressHint = computed')
|
expect(app).not.toContain('const todayHabitProgressHint = computed')
|
||||||
expect(app).not.toContain('<p v-if="activeView===\'today\'">')
|
expect(app).not.toContain('<p v-if="activeView===\'today\'">')
|
||||||
expect(app).not.toContain('class="paper today-summary"')
|
expect(app).not.toContain('class="paper today-summary"')
|
||||||
expect(app).not.toContain('today-summary-grid')
|
expect(app).not.toContain('today-summary-grid')
|
||||||
expect(app).toContain("navigateTodaySection('tasks')")
|
|
||||||
expect(app).toContain("navigateTodaySection('habits')")
|
|
||||||
expect(app).not.toContain('<div class="today-stat"')
|
expect(app).not.toContain('<div class="today-stat"')
|
||||||
expect(app).toContain('@summary="updateTodayHabitSummary"')
|
expect(app).toContain('@summary="updateTodayHabitSummary"')
|
||||||
expect(mvpPanel).toContain("summary: [value: { total: number; completed: number }]")
|
expect(mvpPanel).toContain("summary: [value: { total: number; completed: number }]")
|
||||||
@@ -956,10 +952,11 @@ describe('task and habit row decoration', () => {
|
|||||||
expect(app).toContain('v-if="activeView===\'trash\' || totalPages > 1 || totalTasks > 0"')
|
expect(app).toContain('v-if="activeView===\'trash\' || totalPages > 1 || totalTasks > 0"')
|
||||||
expect(mvpPanel).toContain('class="empty-panel today-empty-panel"')
|
expect(mvpPanel).toContain('class="empty-panel today-empty-panel"')
|
||||||
expect(mvpPanel).toContain('添加习惯')
|
expect(mvpPanel).toContain('添加习惯')
|
||||||
expect(css).toContain('.today-board{')
|
expect(css).toContain('.today-context{')
|
||||||
expect(css).toContain('.today-track{')
|
expect(css).toContain('.today-remaining{')
|
||||||
expect(css).toContain('.today-track-fill{')
|
expect(css).not.toContain('.today-board{')
|
||||||
expect(css).toContain('.today-habit-track .today-track-fill{')
|
expect(css).not.toContain('.today-track{')
|
||||||
|
expect(css).not.toContain('.today-track-fill{')
|
||||||
expect(css).not.toContain('.today-stat{')
|
expect(css).not.toContain('.today-stat{')
|
||||||
expect(css).not.toContain('.today-summary{')
|
expect(css).not.toContain('.today-summary{')
|
||||||
expect(css).not.toContain('.today-summary-grid{')
|
expect(css).not.toContain('.today-summary-grid{')
|
||||||
|
|||||||
Reference in New Issue
Block a user