feat: 今天环境条新增下一个倒数日、浙江油价、上证指数、降雨/AQI
ci / gitleaks (push) Successful in 11s
ci / docker (push) Successful in 3m52s

- 后端新增三源:中石化浙江油价(3步会话)、新浪上证行情、Open-Meteo 空气质量(国标AQI换算),按源独立超时与状态
- 天气行改为 降雨概率% · AQI 等级(无雨无AQI时保留观测时间),stale 补缓存标记
- 前端 strip 六格两行(桌面)/四行(移动),倒数日取本地缓存最近一条,超12字截断
- 修复两处 AppSheet 动画竞态存量 e2e 失败(modal contract footer 时序、scrollable 重开残留 dialog)
This commit is contained in:
2026-09-25 09:50:32 +08:00
parent 4c145a2621
commit 273cd7aac7
11 changed files with 1212 additions and 34 deletions
+7
View File
@@ -59,6 +59,8 @@ test('desktop task composer stays compact, centered, and scrollable on short scr
expect(metrics.footerBottom).toBeLessThanOrEqual(metrics.viewport.height + 1)
await page.keyboard.press('Escape')
// AppSheet 关闭有 160ms leave 动画,等旧 dialog 完全移除再重开(否则 strict mode 命中残留按钮)
await expect(page.getByRole('dialog', { name: /添加(?:今天)?任务/ })).toHaveCount(0)
await page.setViewportSize({ width: 930, height: 844 })
dialog = await openTaskComposer(page)
const mobile = await dialog.evaluate(element => {
@@ -346,6 +348,11 @@ test('task, habit, countdown, memo, action and confirmation overlays share the m
const assertModal = async (dialog: ReturnType<Page['getByRole']>) => {
await expect(dialog).toBeVisible()
await expect(page.locator('#app')).toHaveAttribute('inert', '')
const footer = dialog.locator('.app-sheet__footer, footer').first()
if (await footer.count()) {
// AppSheet 打开有 210ms 滑入动画,先等 footer 落位再测量(否则测在动画中途)
await expect.poll(async () => footer.evaluate(element => element.getBoundingClientRect().bottom - innerHeight)).toBeLessThanOrEqual(1)
}
const metrics = await dialog.evaluate(element => {
const panel = element as HTMLElement
const header = panel.querySelector<HTMLElement>('.app-sheet__header, header')
+53 -14
View File
@@ -20,25 +20,39 @@ test('Today plain-list layout matches the approved responsive geometry', async (
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', {
const formatShanghai = (date: Date) => new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date())
}).format(date)
const today = formatShanghai(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
const countdownTitle = `原型对齐倒数日-${runId}`
const countdownResponse = await mutate(request, baseURL!, '/api/v1/countdowns', {
title: countdownTitle,
event_date: formatShanghai(new Date(Date.now() + 30 * 86400000)),
calendar_mode: 'solar',
kind: 'countdown',
repeat_rule: 'none',
icon: '📅',
})
expect(countdownResponse.ok(), await countdownResponse.text()).toBeTruthy()
await page.route('**/api/v1/today/environment', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
date: { solar_date: '2026-09-19', weekday: '星期六', lunar: '八月初九' },
weather: { status: 'fresh', temperature_c: 26.4, text: '多云', observed_at: '2026-09-19T20:45:00+08:00' },
weather: { status: 'fresh', temperature_c: 26.4, text: '多云', observed_at: '2026-09-19T20:45:00+08:00', precipitation_probability: 60 },
gold: { status: 'fresh', contract: 'Au99.99', price_cny_per_gram: 835.62, market_date: '2026-09-18' },
air: { status: 'fresh', aqi: 36, level: '优' },
oil: { status: 'fresh', gas_92: '8.58', gas_95: '9.12', gas_98: '11.12', diesel_0: '8.28', effective_at: '2026-09-17T00:12:00+08:00' },
ashare: { status: 'fresh', name: '上证指数', price: '3888.37', change_percent: -1.22, as_of: '2026-09-18' },
}),
}))
await page.goto('/')
@@ -61,7 +75,7 @@ test('Today plain-list layout matches the approved responsive geometry', async (
})).toEqual({
media720: width <= 720,
contentWidth: width >= 1440 ? 1080 : width > 930 ? Math.min(1080, width - 324) : width === 721 ? 630 : width - 56,
environmentHeight: width >= 721 ? 55 : width === 375 ? 79 : 81,
environmentHeight: width >= 721 ? 91 : width === 375 ? 154 : 158,
})
await expect(page.locator('.today-heading')).toHaveCount(1)
await expect(page.locator('.today-inline-add')).toHaveCount(0)
@@ -72,12 +86,19 @@ test('Today plain-list layout matches the approved responsive geometry', async (
await expect(page.getByRole('button', { name: /刷新|搜索/ })).toHaveCount(0)
await expect(page.getByRole('switch', { name: '显示已完成' })).toHaveCount(1)
await expect(page.locator('.today-environment__gold')).toContainText('Au99.99')
await expect(page.locator('.today-environment__weather')).toContainText('降雨60% · AQI 36 优')
await expect(page.locator('.today-environment__countdown')).toContainText(/还有 \d+ 天|就是今天/)
await expect(page.locator('.today-environment__oil')).toContainText('92# 8.58')
await expect(page.locator('.today-environment__ashare')).toContainText('上证 3888.37')
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')!
const weather = document.querySelector<HTMLElement>('.today-environment__weather')!
const gold = document.querySelector<HTMLElement>('.today-environment__gold')!
const countdown = document.querySelector<HTMLElement>('.today-environment__countdown')!
const oil = document.querySelector<HTMLElement>('.today-environment__oil')!
const ashare = document.querySelector<HTMLElement>('.today-environment__ashare')!
const title = document.querySelector<HTMLElement>('.today-page-title')!
const remaining = document.querySelector<HTMLElement>('.today-remaining')!
const filter = document.querySelector<HTMLElement>('.today-inline-filter')!
@@ -121,7 +142,7 @@ test('Today plain-list layout matches the approved responsive geometry', async (
.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 collisions = [calendar, weather, gold, countdown, oil, ashare].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
}))
@@ -135,12 +156,12 @@ test('Today plain-list layout matches the approved responsive geometry', async (
content: { left: content.left, right: content.right, width: content.width },
menuButton: { left: menuButton.getBoundingClientRect().left, width: menuButton.getBoundingClientRect().width },
environment: { ...environment.getBoundingClientRect().toJSON(), scrollWidth: environment.scrollWidth, clientWidth: environment.clientWidth },
directions: { calendar: getComputedStyle(calendar).flexDirection, weather: getComputedStyle(weather).flexDirection, gold: getComputedStyle(gold).flexDirection },
itemWidths: [calendar, weather, gold].map((element) => element.getBoundingClientRect().width),
itemHeights: [calendar, weather, gold].map((element) => element.getBoundingClientRect().height),
directions: { calendar: getComputedStyle(calendar).flexDirection, weather: getComputedStyle(weather).flexDirection, gold: getComputedStyle(gold).flexDirection, countdown: getComputedStyle(countdown).flexDirection, oil: getComputedStyle(oil).flexDirection, ashare: getComputedStyle(ashare).flexDirection },
itemWidths: [calendar, weather, gold, countdown, oil, ashare].map((element) => element.getBoundingClientRect().width),
itemHeights: [calendar, weather, gold, countdown, oil, ashare].map((element) => element.getBoundingClientRect().height),
uniqueRows,
collisions,
collisionRects: [calendar, weather, gold].map(element => element.getBoundingClientRect().toJSON()),
collisionRects: [calendar, weather, gold, countdown, oil, ashare].map(element => element.getBoundingClientRect().toJSON()),
secondary,
ordered,
orderedPairs,
@@ -148,6 +169,9 @@ test('Today plain-list layout matches the approved responsive geometry', async (
insideContent,
weatherText: weather.innerText,
goldText: gold.innerText,
oilText: oil.innerText,
ashareText: ashare.innerText,
countdownText: countdown.innerText,
goldLabel: gold.querySelector('.today-environment__gold-date')?.getAttribute('aria-label') ?? '',
summaries,
styles: {
@@ -208,6 +232,10 @@ test('Today plain-list layout matches the approved responsive geometry', async (
expect(metrics.insideContent).toBeTruthy()
expect(metrics.orderedPairs.every(pair => pair.separated)).toBeTruthy()
expect(metrics.goldText).toContain('Au99.99')
expect(metrics.weatherText).toContain('降雨60% · AQI 36 优')
expect(metrics.oilText).toContain('92# 8.58')
expect(metrics.ashareText).toContain('上证 3888.37')
expect(metrics.countdownText).toMatch(/还有 \d+ 天|就是今天/)
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')
@@ -247,18 +275,18 @@ test('Today plain-list layout matches the approved responsive geometry', async (
expect(metrics.styles.filterTop).toBeGreaterThanOrEqual(metrics.ordered[1].top)
expect(metrics.styles.filterTop).toBeLessThanOrEqual(metrics.ordered[1].bottom - metrics.styles.filterHeight)
expect(metrics.styles.filterRight).toBeCloseTo(metrics.content.right, 0)
if (width <= 720) expect(metrics.styles.contextHeight).toBeLessThanOrEqual(210)
if (width <= 720) expect(metrics.styles.contextHeight).toBeLessThanOrEqual(240)
expect(metrics.styles.fabShadow).toContain('8px 18px')
expect(metrics.styles.fabIconWidth).toBeCloseTo(30, 0)
if (width >= 721) {
expect(metrics.uniqueRows).toHaveLength(1)
expect(metrics.uniqueRows).toHaveLength(2)
expect(metrics.content.width).toBeCloseTo(width >= 1440 ? 1080 : width > 930 ? Math.min(1080, width - 324) : 630, 0)
expect(Math.max(...metrics.itemWidths) - Math.min(...metrics.itemWidths)).toBeLessThanOrEqual(1)
expect(Math.max(...metrics.itemHeights) - Math.min(...metrics.itemHeights)).toBeLessThanOrEqual(1)
expect(metrics.environment.height).toBeLessThanOrEqual(72)
expect(Math.round(metrics.environment.height)).toBe(91)
expect(metrics.styles.titleFontSize).toBe('34px')
} else {
expect(metrics.uniqueRows).toHaveLength(2)
expect(metrics.uniqueRows).toHaveLength(4)
expect(metrics.collisionRects[0].width).toBeCloseTo(metrics.content.width, 0)
expect(metrics.collisionRects[0].left).toBeCloseTo(metrics.content.left, 0)
expect(metrics.collisionRects[0].right).toBeCloseTo(metrics.content.right, 0)
@@ -266,15 +294,26 @@ test('Today plain-list layout matches the approved responsive geometry', async (
expect(metrics.collisionRects[1].left).toBeCloseTo(metrics.content.left, 0)
expect(metrics.collisionRects[2].right).toBeCloseTo(metrics.content.right, 0)
expect(metrics.collisionRects[1].right).toBeCloseTo(metrics.collisionRects[2].left, 0)
expect(metrics.collisionRects[3].width).toBeCloseTo(metrics.content.width, 0)
expect(metrics.collisionRects[3].left).toBeCloseTo(metrics.content.left, 0)
expect(metrics.collisionRects[4].width).toBeCloseTo(metrics.collisionRects[5].width, 0)
expect(metrics.collisionRects[4].left).toBeCloseTo(metrics.content.left, 0)
expect(metrics.collisionRects[5].right).toBeCloseTo(metrics.content.right, 0)
expect(metrics.collisionRects[4].right).toBeCloseTo(metrics.collisionRects[5].left, 0)
expect(metrics.collisionRects[4].top).toBeCloseTo(metrics.collisionRects[5].top, 0)
expect(metrics.collisionRects[3].top).toBeGreaterThan(metrics.collisionRects[4].top)
expect(metrics.directions.calendar).toBe('row')
expect(metrics.directions.weather).toBe('column')
expect(metrics.directions.gold).toBe('column')
expect(metrics.directions.countdown).toBe('row')
expect(metrics.directions.oil).toBe('column')
expect(metrics.directions.ashare).toBe('column')
if (width <= 720) {
expect(metrics.content.width).toBeCloseTo(width - 56, 0)
expect(metrics.content.left).toBeCloseTo(28, 0)
expect(metrics.styles.mainPaddingLeft).toBe('28px')
expect(metrics.styles.titleFontSize).toBe('24px')
expect(metrics.environment.height).toBeCloseTo(width === 375 ? 79 : 81, 0)
expect(Math.round(metrics.environment.height)).toBe(width === 375 ? 154 : 158)
}
}
if (width >= 721) {