fix: match Today approved prototype
ci / gitleaks (push) Successful in 56s
ci / docker (push) Successful in 7m25s

This commit is contained in:
2026-09-17 20:43:59 +08:00
parent 66589dd458
commit e4e1d96b30
6 changed files with 158 additions and 18 deletions
+123 -4
View File
@@ -1,16 +1,65 @@
import type { APIRequestContext } from '@playwright/test'
import { expect, test } from './fixtures'
const widths = [721, 720, 390, 375]
test('Today plain-list layout matches the approved responsive geometry', async ({ page }) => {
async function csrf(request: APIRequestContext) {
const state = await request.storageState()
return state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value ?? ''
}
async function mutate(request: APIRequestContext, baseURL: string, path: string, data: Record<string, unknown>) {
return request.post(path, { data, headers: { 'x-csrf-token': await csrf(request), origin: baseURL } })
}
test('Today plain-list layout matches the approved responsive geometry', async ({ page, request, baseURL }, testInfo) => {
const runId = `${testInfo.project.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const taskTitle = `原型对齐今日任务-${runId}`
const habitName = `原型对齐习惯-${runId}`
const bootstrap = await request.get('/api/v1/bootstrap')
expect(bootstrap.ok(), await bootstrap.text()).toBeTruthy()
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
expect(inbox).toBeTruthy()
const today = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date())
const taskResponse = await mutate(request, baseURL!, '/api/v1/tasks', { title: taskTitle, list_id: inbox.id, due_at: `${today}T23:59:00+08:00`, due_has_time: false })
expect(taskResponse.ok(), await taskResponse.text()).toBeTruthy()
const taskId = (await taskResponse.json()).id as string
const habitResponse = await mutate(request, baseURL!, '/api/v1/habits', { name: habitName, kind: 'numeric', target: 8, max_value: 8, schedule_type: 'daily' })
expect(habitResponse.ok(), await habitResponse.text()).toBeTruthy()
const habitId = (await habitResponse.json()).id as string
await page.goto('/')
const taskRowLocator = page.locator('#today-tasks .task-row').filter({ hasText: taskTitle })
const habitRowLocator = page.locator('.today-habit-row').filter({ hasText: habitName })
await expect(taskRowLocator).toHaveCount(1)
await expect(habitRowLocator).toHaveCount(1)
for (const width of widths) {
await page.setViewportSize({ width, height: width <= 390 ? 844 : 900 })
await expect.poll(async () => page.evaluate(() => {
const content = document.querySelector<HTMLElement>('.today-context')?.getBoundingClientRect()
const environment = document.querySelector<HTMLElement>('.today-environment')?.getBoundingClientRect()
if (!content || !environment) return null
return {
media720: matchMedia('(max-width: 720.98px)').matches,
contentWidth: Math.round(content.width),
environmentHeight: Math.round(environment.height),
}
})).toEqual({
media720: width <= 720,
contentWidth: width === 721 ? 630 : width - 58,
environmentHeight: width === 721 ? 55 : width === 375 ? 90 : 93,
})
await expect(taskRowLocator).toHaveCount(1)
await expect(habitRowLocator).toHaveCount(1)
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 metrics = await page.evaluate(({ taskId, habitId }) => {
const rect = (selector: string) => document.querySelector<HTMLElement>(selector)!.getBoundingClientRect()
const environment = document.querySelector<HTMLElement>('.today-environment')!
const calendar = document.querySelector<HTMLElement>('.today-environment__calendar')!
@@ -22,6 +71,20 @@ test('Today plain-list layout matches the approved responsive geometry', async (
const firstSection = document.querySelector<HTMLElement>('.today-section-toggle')!
const main = document.querySelector<HTMLElement>('main.today-main')!
const content = rect('.today-context')
const sectionTitle = firstSection.querySelector<HTMLElement>('.today-section-title')!
const sectionSummary = firstSection.querySelector<HTMLElement>('.today-section-summary')!
const sectionIcon = firstSection.querySelector<HTMLElement>('.today-section-chevron')!
const summaries = [...document.querySelectorAll<HTMLElement>('.today-section-summary')].map(summary => ({
text: summary.innerText.trim(),
expected: summary.closest<HTMLButtonElement>('.today-section-toggle')?.getAttribute('aria-controls') === 'today-habits'
? document.querySelectorAll('.today-habit-row').length
: summary.closest<HTMLButtonElement>('.today-section-toggle')?.getAttribute('aria-controls') === 'today-overdue'
? document.querySelectorAll('#today-overdue .task-row').length
: document.querySelectorAll('#today-tasks .task-row').length,
}))
const taskRow = document.querySelector<HTMLElement>(`#today-tasks .task-row[data-task-id="${taskId}"]`)!
const habitRow = document.querySelector<HTMLElement>(`.today-habit-row[data-habit-id="${habitId}"]`)!
const fab = document.querySelector<HTMLElement>('.unified-fab')!
const orderedElements = [environment, title, remaining, filter, firstSection]
const ordered = orderedElements.map((element) => {
const box = element.getBoundingClientRect()
@@ -50,13 +113,16 @@ test('Today plain-list layout matches the approved responsive geometry', async (
const secondary = [...environment.querySelectorAll<HTMLElement>('small')].map(node => ({ scrollWidth: node.scrollWidth, clientWidth: node.clientWidth, visible: visible(node) }))
return {
viewport: innerWidth,
media720: matchMedia('(max-width: 720.98px)').matches,
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
mainOverflow: main.scrollWidth - main.clientWidth,
main: { ...main.getBoundingClientRect().toJSON(), clientWidth: main.clientWidth, paddingLeft: getComputedStyle(main).paddingLeft, paddingRight: getComputedStyle(main).paddingRight },
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,
collisionRects: [calendar, weather, gold].map(element => element.getBoundingClientRect().toJSON()),
secondary,
ordered,
orderedPairs,
@@ -65,8 +131,32 @@ test('Today plain-list layout matches the approved responsive geometry', async (
weatherText: weather.innerText,
goldText: gold.innerText,
goldLabel: gold.querySelector('.today-environment__gold-date')?.getAttribute('aria-label'),
summaries,
styles: {
mainPaddingLeft: getComputedStyle(main).paddingLeft,
titleFontSize: getComputedStyle(title).fontSize,
titleFontWeight: getComputedStyle(title).fontWeight,
filterWidth: filter.getBoundingClientRect().width,
filterHeight: filter.getBoundingClientRect().height,
filterVisualHeight: filter.querySelector<HTMLElement>('.completed-filter-pill__track')!.getBoundingClientRect().height,
filterBackground: getComputedStyle(filter).backgroundColor,
filterBorderRadius: getComputedStyle(filter).borderRadius,
sectionBorderBottom: getComputedStyle(firstSection).borderBottomWidth,
sectionTitleSize: getComputedStyle(sectionTitle).fontSize,
sectionTitleWeight: getComputedStyle(sectionTitle).fontWeight,
sectionSummaryText: sectionSummary.innerText,
sectionIconText: sectionIcon.textContent,
taskHeight: taskRow.getBoundingClientRect().height,
taskBackground: getComputedStyle(taskRow).backgroundColor,
taskBorderBottom: getComputedStyle(taskRow).borderBottomWidth,
habitHeight: habitRow.getBoundingClientRect().height,
habitBackground: getComputedStyle(habitRow).backgroundColor,
habitBorderBottom: getComputedStyle(habitRow).borderBottomWidth,
fabShadow: getComputedStyle(fab).boxShadow,
fabIconWidth: fab.querySelector('svg')?.getBoundingClientRect().width,
},
}
})
}, { taskId, habitId })
console.log(`[today-geometry][${width}] ${JSON.stringify(metrics)}`)
expect(metrics.documentOverflow).toBe(0)
expect(metrics.mainOverflow).toBeLessThanOrEqual(0)
@@ -78,14 +168,43 @@ test('Today plain-list layout matches the approved responsive geometry', async (
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()
expect(metrics.styles.titleFontWeight).toBe('700')
expect(metrics.styles.sectionTitleSize).toBe('13px')
expect(metrics.styles.sectionTitleWeight).toBe('700')
expect(metrics.styles.sectionSummaryText).not.toContain('项')
expect(metrics.summaries.every(summary => /^\d+$/.test(summary.text) && Number(summary.text) === summary.expected)).toBeTruthy()
expect(metrics.styles.sectionIconText).not.toContain('项')
expect(metrics.styles.sectionBorderBottom).toBe('1px')
expect(metrics.styles.taskHeight).toBeCloseTo(58, 0)
expect(metrics.styles.habitHeight).toBeCloseTo(58, 0)
expect(metrics.styles.taskBackground).toBe('rgba(0, 0, 0, 0)')
expect(metrics.styles.habitBackground).toBe('rgba(0, 0, 0, 0)')
expect(metrics.styles.taskBorderBottom).toBe('1px')
expect(metrics.styles.habitBorderBottom).toBe('1px')
expect(metrics.styles.filterWidth).toBeLessThanOrEqual(90)
expect(metrics.styles.filterHeight).toBeGreaterThanOrEqual(width <= 720 ? 44 : 36)
expect(metrics.styles.filterVisualHeight).toBeLessThanOrEqual(36)
expect(metrics.styles.filterBackground).toBe('rgba(0, 0, 0, 0)')
expect(metrics.styles.filterBorderRadius).toBe('0px')
expect(metrics.styles.fabShadow).toContain('8px 18px')
expect(metrics.styles.fabIconWidth).toBeCloseTo(30, 0)
if (width === 721) {
expect(metrics.uniqueRows).toHaveLength(1)
expect(metrics.environment.height).toBeLessThan(80)
expect(metrics.content.width).toBeCloseTo(630, 0)
expect(metrics.environment.height).toBeLessThanOrEqual(72)
expect(metrics.styles.titleFontSize).toBe('34px')
} 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 <= 720) {
expect(metrics.content.width).toBeCloseTo(width - 58, 0)
expect(metrics.content.left).toBeCloseTo(29, 0)
expect(metrics.styles.mainPaddingLeft).toBe('29px')
expect(metrics.styles.titleFontSize).toBe('24px')
expect(metrics.environment.height).toBeCloseTo(width === 375 ? 90 : 93, 0)
}
}
if (width >= 721) {
expect(metrics.content.width).toBeLessThanOrEqual(721)
+4 -4
View File
@@ -1432,7 +1432,7 @@ onUnmounted(() => {
<small v-if="error" role="alert">{{ error }}</small>
</section>
</div>
<div v-else class="shell" :class="{ 'sidebar-collapsed': sidebarCollapsed, 'detail-open': Boolean(selectedTask), 'memo-detail-open': activeView==='memos' && memoDetailOpen, 'mobile-sidebar-open': mobileSidebar }">
<div v-else class="shell" :class="{ 'today-active': activeView==='today', 'sidebar-collapsed': sidebarCollapsed, 'detail-open': Boolean(selectedTask), 'memo-detail-open': activeView==='memos' && memoDetailOpen, 'mobile-sidebar-open': mobileSidebar }">
<div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" />
<aside class="sidebar" :inert="memoBackgroundInert ? true : undefined" :class="{ open: mobileSidebar }" @keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()">
<div class="brand-row"><div class="brand small brand-lockup"><img class="brand-logo" src="/dodo-logo.svg" alt=""><span class="brand-wordmark">dodo</span></div><button class="icon mobile-only" aria-label="关闭菜单" @click="mobileSidebar=false"><X /></button></div>
@@ -1532,14 +1532,14 @@ onUnmounted(() => {
</section>
<template v-if="activeView==='today'">
<section v-if="overdueTaskTree.length" class="overdue-section today-collapsible-section">
<button id="today-overdue-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.overdue" aria-controls="today-overdue" @click="toggleTodaySection('overdue')"><span class="today-section-title">逾期</span><span class="today-section-summary">{{overdueTaskTree.length}} </span><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><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.overdue ? '' : '⌄' }}</span></button>
<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}`">
<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>
</div>
</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><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><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.tasks ? '' : '⌄' }}</span></button>
</template>
<div v-if="activeView!=='today'" class="list-toolbar"><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><span class="list-toolbar-actions"><button v-if="query" class="link" @click="query=''">清除搜索</button><button v-if="taskReorderAvailable" class="soft-button reorder-mode-toggle task-reorder-toggle" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></span></div>
<div v-if="activeView!=='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>
@@ -1556,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>
</section>
<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">习惯</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">{{todayHabitTotal}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.habits ? '' : '⌄' }}</span></button>
<div v-show="!todaySectionCollapse.habits" id="today-habits" role="region" aria-labelledby="today-habits-heading">
<MvpPanel ref="habitComposer" view="today-habits" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" @summary="updateTodayHabitSummary" />
</div>
+6 -3
View File
@@ -56,9 +56,12 @@ describe('Today environment integration', () => {
expect(app).not.toContain('today-task-track')
expect(app).not.toContain('today-habit-track')
expect(app).not.toContain('role="progressbar"')
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-context{margin:0 0 8px;border-bottom:1px solid var(--line)}')
expect(css).toContain('.today-remaining{margin:7px 0 10px;color:var(--muted);font-size:13px}')
expect(app).toContain("'today-active': activeView==='today'")
expect(css).toContain('.shell.today-active .unified-fab{box-shadow:0 8px 18px rgba(174,65,29,.22)}')
expect(css).not.toContain('\n.unified-fab{box-shadow:0 8px 18px rgba(174,65,29,.22)}')
expect(css).toContain('@media(max-width:720.98px){main.today-main{padding-left:29px!important;padding-right:29px!important}')
expect(css).toContain('.today-context{margin:0 0 14px;border-bottom:0}')
expect(css).toContain('.today-remaining{margin:0 0 24px;color:var(--muted);font-size:13px}')
expect(css).not.toContain('.today-board{')
expect(css).not.toContain('.today-board__progress{')
expect(css).not.toContain('.today-track{')
+3 -3
View File
@@ -34,10 +34,10 @@ describe('Today independent collapsible sections', () => {
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).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).toContain('<ChevronRight v-if="todaySectionCollapse.overdue"')
expect(app).toContain('<ChevronDown v-else')
expect(app).toContain("{{ todaySectionCollapse.overdue ? '' : '⌄' }}")
expect(app).not.toContain('<ChevronRight v-if="todaySectionCollapse.overdue"')
expect(css).toMatch(/\.today-section-toggle\{[^}]*min-height:44px/)
})
+21 -2
View File
File diff suppressed because one or more lines are too long
+1 -2
View File
@@ -1,2 +1 @@
.today-section-toggle{width:100%;min-height:44px;display:flex;align-items:center;gap:10px;padding:8px 4px;border:0;background:transparent;color:var(--text-primary);text-align:left;border-radius:10px}.today-section-toggle:hover{background:var(--surface-raised)}.today-section-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.today-section-title{display:flex;align-items:center;gap:8px;font-weight:800;font-size:15px}.today-section-title svg{width:17px;height:17px;color:var(--accent)}.today-section-summary{margin-left:auto;color:var(--muted);font-size:13px;font-weight:650}.today-section-toggle>svg{flex:0 0 auto;color:var(--muted)}.today-collapsible-section,.today-habits-section{min-width:0}.today-habits-section.today-section-anchor{scroll-margin-top:18px}
.today-section-toggle{width:100%;min-height:44px;display:flex;align-items:center;padding:0;border:0;border-bottom:1px solid #e8e0d5;background:transparent;color:var(--text-primary);text-align:left;border-radius:0}.today-section-toggle:hover{background:transparent}.today-section-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.today-section-title{display:flex;align-items:center;font-weight:700;font-size:13px;letter-spacing:.02em}.today-section-summary{margin-left:auto;color:var(--muted);font-size:12px;font-weight:400}.today-section-chevron{width:14px;margin-left:3px;color:var(--muted);font-size:13px;line-height:1;text-align:right}.today-collapsible-section,.today-habits-section{min-width:0}.today-habits-section.today-section-anchor{scroll-margin-top:18px}