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) {
+28 -3
View File
@@ -6,7 +6,7 @@ import {
Settings, Trash2, X, Repeat2, StickyNote, TimerReset,
} from 'lucide-vue-next'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, groupTrashTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type AfterCompletionUnit, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readCountdownCache, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
import { captureListDragPointer, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
@@ -22,7 +22,7 @@ import FloatingAddButton from './components/FloatingAddButton.vue'
import CompletedFilterPill from './components/CompletedFilterPill.vue'
import CalendarPicker from './components/CalendarPicker.vue'
import TaskDueDisplay from './components/TaskDueDisplay.vue'
import TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'
import TodayEnvironmentStrip, { type TodayCountdown, type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'
import AppSheet from './components/AppSheet.vue'
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
@@ -126,6 +126,7 @@ const todayTaskCompleted = ref(0)
const todayHabitTotal = ref(0)
const todayHabitCompleted = ref(0)
const todayEnvironment = ref<TodayEnvironment | null>(null)
const todayCountdown = ref<TodayCountdown | null>(null)
const todayEnvironmentLoading = ref(false)
const todayEnvironmentError = ref(false)
const todayEnvironmentDateKey = ref('')
@@ -617,10 +618,34 @@ async function loadTodayTaskSummary() {
todayTaskTotal.value = overdue + open + completed
} catch { /* 概览统计失败不阻断今天页 */ }
}
type CachedCountdown = { title?: unknown; days?: unknown; day_text?: unknown; pinned?: unknown }
function pickNextTodayCountdown(): TodayCountdown | null {
const cache = readCountdownCache<CachedCountdown>()
const upcoming = (cache?.items ?? []).filter(
(item): item is CachedCountdown & { title: string; days: number } =>
!!item && typeof item.title === 'string' && typeof item.days === 'number' && item.days >= 0,
)
if (!upcoming.length) return null
upcoming.sort((a, b) => a.days - b.days || Number(!!b.pinned) - Number(!!a.pinned))
const next = upcoming[0]
return {
title: next.title,
days: next.days,
day_text: typeof next.day_text === 'string' && next.day_text
? next.day_text
: next.days === 0 ? '就是今天' : `还有 ${next.days} 天`,
}
}
async function refreshTodayCountdown(token: number) {
await preloadCountdowns()
if (token !== todayEnvironmentLoadToken || activeView.value !== 'today') return
todayCountdown.value = pickNextTodayCountdown()
}
async function loadTodayEnvironment() {
const token = ++todayEnvironmentLoadToken
todayEnvironmentLoading.value = true
todayEnvironmentError.value = false
void refreshTodayCountdown(token)
try {
const data = await api('/today/environment') as TodayEnvironment
if (token !== todayEnvironmentLoadToken || activeView.value !== 'today') return
@@ -1838,7 +1863,7 @@ onUnmounted(() => {
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
<template v-else>
<section v-if="activeView==='today'" class="today-context" aria-label="今日概览">
<TodayEnvironmentStrip :environment="todayEnvironment" :loading="todayEnvironmentLoading" :failed="todayEnvironmentError" />
<TodayEnvironmentStrip :environment="todayEnvironment" :countdown="todayCountdown" :loading="todayEnvironmentLoading" :failed="todayEnvironmentError" />
<div class="today-heading">
<div><h1 class="today-page-title">今天</h1><p class="today-remaining">还有 {{ todayTaskRemaining + todayHabitRemaining }} 项待完成</p></div>
<CompletedFilterPill v-model="showCompleted" class="today-inline-filter" />
+13 -3
View File
@@ -6,13 +6,17 @@ const css = readFileSync('src/style.css', 'utf8')
describe('Today environment integration', () => {
it('loads environment independently alongside Today task work', () => {
expect(app).toContain("import TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'")
expect(app).toContain("import TodayEnvironmentStrip, { type TodayCountdown, type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'")
expect(app).toContain('let todayEnvironmentLoadToken = 0')
expect(app).toContain("api('/today/environment')")
expect(app).toContain('void loadTodayEnvironment()')
expect(app).toContain('++todayEnvironmentLoadToken')
expect(app).toContain("activeView.value !== 'today'")
expect(app).toContain('<TodayEnvironmentStrip :environment="todayEnvironment" :loading="todayEnvironmentLoading" :failed="todayEnvironmentError" />')
expect(app).toContain('<TodayEnvironmentStrip :environment="todayEnvironment" :countdown="todayCountdown" :loading="todayEnvironmentLoading" :failed="todayEnvironmentError" />')
expect(app).toContain('const todayCountdown = ref<TodayCountdown | null>(null)')
expect(app).toContain('void refreshTodayCountdown(token)')
expect(app).toContain('readCountdownCache<CachedCountdown>()')
expect(app).toContain('upcoming.sort((a, b) => a.days - b.days || Number(!!b.pinned) - Number(!!a.pinned))')
})
it('keeps environment failures local and retains old values during refresh', () => {
@@ -38,8 +42,12 @@ describe('Today environment integration', () => {
expect(app).toContain('if (todayEnvironmentDateKey.value !== shanghaiDateKey()) void loadTodayEnvironment()')
})
it('keeps mobile environment copy to two rows including refresh states', () => {
it('keeps mobile environment copy to four rows including refresh states', () => {
expect(css).toMatch(/@media\(max-width:720px\)\{\.today-environment\{[^}]*grid-template-rows:auto auto/)
expect(css).toContain('.today-environment__oil,.today-environment__ashare{grid-row:3;')
expect(css).toContain('.today-environment__oil{grid-column:1;padding-left:0;padding-right:var(--space-10);border-left:0!important}')
expect(css).toContain('.today-environment__ashare{grid-column:2;padding-left:var(--space-12)}')
expect(css).toContain('.today-environment__countdown{grid-column:1/-1;grid-row:4;')
expect(css).not.toMatch(/\.today-board\{[^}]*grid-template-rows:/)
expect(css).not.toContain('@container(max-width:720px){.today-environment')
expect(css).not.toContain('@container(min-width:721px){.today-environment')
@@ -105,6 +113,8 @@ describe('Today environment integration', () => {
expect(css).toMatch(/\.today-environment__weather\{[^}]*grid-column:1[^}]*grid-row:2/)
expect(css).toMatch(/\.today-environment__gold\{[^}]*grid-column:2[^}]*grid-row:2/)
expect(css).toMatch(/\.today-environment__weather,\.today-environment__gold\{[^}]*flex-direction:column/)
expect(css).toContain('.today-environment__item:nth-child(3n+1){border-left:0;padding-left:0}')
expect(css).toContain('.today-environment__item:nth-child(n+4){border-top:1px solid var(--border-hairline)}')
expect(css).not.toMatch(/\.today-environment[^}]*overflow-x:auto/)
expect(css).not.toMatch(/@media\(max-width:720px\)[\s\S]*?\.today-environment[^}]*text-overflow:ellipsis/)
})
@@ -2,11 +2,11 @@ import { afterEach, describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createApp, h, nextTick } from 'vue'
import TodayEnvironmentStrip, { type TodayEnvironment } from './TodayEnvironmentStrip.vue'
import TodayEnvironmentStrip, { type TodayCountdown, type TodayEnvironment } from './TodayEnvironmentStrip.vue'
const cleanups: Array<() => void> = []
async function mountStrip(props: { environment: TodayEnvironment | null; loading?: boolean; failed?: boolean }) {
async function mountStrip(props: { environment: TodayEnvironment | null; countdown?: TodayCountdown | null; loading?: boolean; failed?: boolean }) {
const host = document.createElement('div')
document.body.append(host)
const app = createApp(() => h(TodayEnvironmentStrip, props))
@@ -151,4 +151,104 @@ describe('TodayEnvironmentStrip', () => {
const mobileBlock = css.slice(css.indexOf('@media(max-width:720px){.today-environment'), css.indexOf('/* Solid cream material system'))
expect(mobileBlock).not.toContain('text-overflow:ellipsis')
})
it('shows umbrella probability and Chinese AQI instead of the observation time', async () => {
const host = await mountStrip({ environment: {
calendar: environment.calendar,
weather: { status: 'fresh', text: '多云', temperature_c: 27, precipitation_probability: 60, observed_at: '2026-09-16T09:00:00+08:00' },
gold: environment.gold,
air: { status: 'fresh', aqi: 36, level: '优' },
} })
const weather = host.querySelector('.today-environment__weather')!
expect(weather.querySelector('strong')?.textContent).toBe('宁波 27°C 多云')
expect(weather.querySelector('small')?.textContent).toBe('降雨60% · AQI 36 优')
expect(host.textContent).not.toContain('海曙')
})
it('marks stale weather with cached data after the rain and AQI segments', async () => {
const host = await mountStrip({ environment: {
calendar: environment.calendar,
weather: { status: 'stale', text: '小雨', temperature_c: 24, precipitation_probability: 20 },
gold: environment.gold,
air: { status: 'stale', aqi: 85, level: '良' },
} })
expect(host.querySelector('.today-environment__weather small')?.textContent).toBe('降雨20% · AQI 85 良 · 缓存')
})
it('falls back to the observation time when rain and AQI are unavailable', async () => {
const host = await mountStrip({ environment: {
calendar: environment.calendar,
weather: { status: 'fresh', text: '晴', temperature_c: 30, observed_at: '2026-09-16T09:00:00+08:00' },
gold: environment.gold,
air: null,
} })
expect(host.querySelector('.today-environment__weather small')?.textContent).toBe('海曙 09:00')
})
it('renders Zhejiang oil and the SSE index with compact metadata', async () => {
const host = await mountStrip({ environment: {
calendar: environment.calendar,
weather: environment.weather,
gold: environment.gold,
oil: { status: 'fresh', gas_92: '8.58', gas_95: 9.12, gas_98: '11.12', diesel_0: '8.28', effective_at: '2026-09-24T00:12:00' },
ashare: { status: 'fresh', name: '上证指数', price: '3888.3738', prev_close: '3936.5199', change_percent: -1.22, as_of: '2026-09-24' },
} })
const oil = host.querySelector('.today-environment__oil')!
expect(oil.classList.contains('is-fresh')).toBe(true)
expect(oil.querySelector('strong')?.textContent).toBe('92# 8.58 · 95# 9.12')
expect(oil.querySelector('small')?.textContent).toBe('98# 11.12 · 09-24生效')
const ashare = host.querySelector('.today-environment__ashare')!
expect(ashare.querySelector('strong')?.textContent).toBe('上证 3888.37')
expect(ashare.querySelector('small')?.textContent).toBe('-1.22% · 09-24')
})
it('signs index gains and marks stale oil', async () => {
const host = await mountStrip({ environment: {
calendar: environment.calendar,
weather: environment.weather,
gold: environment.gold,
oil: { status: 'stale', gas_92: '8.58', gas_95: '9.12', gas_98: '11.12', effective_at: '2026-09-24T00:12:00', stale: true },
ashare: { status: 'fresh', name: '上证指数', price: '3900.5', change_percent: 0.35, as_of: '2026-09-25' },
} })
expect(host.querySelector('.today-environment__ashare small')?.textContent).toBe('+0.35% · 09-25')
const oil = host.querySelector('.today-environment__oil')!
expect(oil.classList.contains('is-stale')).toBe(true)
expect(oil.querySelector('small')?.textContent).toBe('98# 11.12 · 09-24生效 · 缓存')
})
it('renders unavailable oil and index sources as two-line placeholders', async () => {
const host = await mountStrip({ environment: {
calendar: environment.calendar,
weather: environment.weather,
gold: environment.gold,
oil: null,
ashare: null,
} })
expect(host.querySelectorAll('.today-environment__oil > .today-environment__skeleton')).toHaveLength(2)
expect(host.querySelectorAll('.today-environment__ashare > .today-environment__skeleton')).toHaveLength(2)
expect(host.textContent).toContain('浙江油价暂不可用')
expect(host.textContent).toContain('上证指数暂不可用')
})
it('hides the new slots when an older payload does not carry their keys', async () => {
const host = await mountStrip({ environment })
expect(host.querySelector('.today-environment__oil')).toBeNull()
expect(host.querySelector('.today-environment__ashare')).toBeNull()
expect(host.querySelector('.today-environment__countdown')).toBeNull()
})
it('shows the next countdown and truncates very long titles', async () => {
const host = await mountStrip({ environment, countdown: { title: '国庆节', days: 6, day_text: '还有 6 天' } })
const countdown = host.querySelector('.today-environment__countdown')!
expect(countdown.querySelector('strong')?.textContent).toBe('国庆节')
expect(countdown.querySelector('small')?.textContent).toBe('还有 6 天')
const long = await mountStrip({ environment, countdown: { title: '一二三四五六七八九十甲乙丙', days: 3, day_text: '还有 3 天' } })
expect(long.querySelector('.today-environment__countdown strong')?.textContent).toBe('一二三四五六七八九十甲…')
})
it('keeps a today countdown slot with its own copy', async () => {
const host = await mountStrip({ environment, countdown: { title: '中秋节', days: 0, day_text: '就是今天' } })
expect(host.querySelector('.today-environment__countdown small')?.textContent).toBe('就是今天')
})
})
@@ -10,6 +10,7 @@ type WeatherValue = {
text?: string | null
temperature_c?: number | null
weather_code?: number | null
precipitation_probability?: number | null
observed_at?: string | null
source?: string | null
stale?: boolean
@@ -26,19 +27,54 @@ type GoldValue = {
delayed?: boolean
stale?: boolean
}
type AirValue = {
status?: EnvironmentStatus
aqi?: number | null
level?: string | null
pm2_5?: number | null
pm10?: number | null
observed_at?: string | null
source?: string | null
stale?: boolean
}
type OilValue = {
status?: EnvironmentStatus
gas_92?: number | string | null
gas_95?: number | string | null
gas_98?: number | string | null
diesel_0?: number | string | null
effective_at?: string | null
source?: string | null
stale?: boolean
}
type AshareValue = {
status?: EnvironmentStatus
name?: string | null
price?: number | string | null
prev_close?: number | string | null
change_percent?: number | null
as_of?: string | null
source?: string | null
stale?: boolean
}
export type TodayCountdown = { title: string; days: number; day_text: string }
export type TodayEnvironment = {
calendar?: CalendarValue
date?: ApiDateValue
weather: WeatherValue | null
gold: GoldValue | null
air?: AirValue | null
oil?: OilValue | null
ashare?: AshareValue | null
errors?: Record<string, string>
}
const props = withDefaults(defineProps<{
environment: TodayEnvironment | null
countdown?: TodayCountdown | null
loading?: boolean
failed?: boolean
}>(), { loading: false, failed: false })
}>(), { countdown: null, loading: false, failed: false })
const calendar = computed<CalendarValue | null>(() => {
if (props.environment?.calendar) return props.environment.calendar
@@ -95,6 +131,79 @@ const weatherObservationTime = computed(() => {
if (Number.isNaN(observed.getTime())) return ''
return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', hour12: false }).format(observed)
})
function joinSegments(segments: Array<string | null>): string {
return segments.filter((segment): segment is string => Boolean(segment)).join(' · ')
}
const weatherMeta = computed(() => {
const weather = props.environment?.weather
if (!weather || weatherStatus.value === 'unavailable') return ''
const rain = typeof weather.precipitation_probability === 'number' ? weather.precipitation_probability : null
const air = props.environment?.air
const aqi = air && typeof air.aqi === 'number' ? air.aqi : null
const segments: Array<string | null> = []
if (rain != null) segments.push(`降雨${rain}%`)
if (aqi != null) segments.push(`AQI ${aqi}${air?.level ? ` ${air.level}` : ''}`)
if (!segments.length && weatherObservationTime.value) segments.push(`海曙 ${weatherObservationTime.value}`)
if (weatherStatus.value === 'stale') segments.push('缓存')
return joinSegments(segments)
})
function sourceStatus(value: AirValue | OilValue | AshareValue | null | undefined): EnvironmentStatus {
if (!value) return 'unavailable'
return value.status ?? (value.stale ? 'stale' : 'fresh')
}
const hasOil = computed(() => props.environment != null && 'oil' in props.environment)
const hasAshare = computed(() => props.environment != null && 'ashare' in props.environment)
const oilStatus = computed(() => sourceStatus(props.environment?.oil))
const ashareStatus = computed(() => sourceStatus(props.environment?.ashare))
function priceText(value: number | string | null | undefined): string | null {
const numeric = typeof value === 'string' ? Number(value) : value
if (typeof numeric !== 'number' || !Number.isFinite(numeric)) return null
return numeric.toFixed(2)
}
const oilPrimary = computed(() => {
const oil = props.environment?.oil
if (!oil) return null
const gas92 = priceText(oil.gas_92)
const gas95 = priceText(oil.gas_95)
return joinSegments([gas92 && `92# ${gas92}`, gas95 && `95# ${gas95}`]) || null
})
const oilMeta = computed(() => {
const oil = props.environment?.oil
if (!oil || oilStatus.value === 'unavailable') return ''
const gas98 = priceText(oil.gas_98)
const effective = typeof oil.effective_at === 'string'
? oil.effective_at.match(/^\d{4}-(\d{2}-\d{2})/)?.[1] ?? null
: null
return joinSegments([
gas98 && `98# ${gas98}`,
effective && `${effective}生效`,
oilStatus.value === 'stale' ? '缓存' : null,
])
})
const ashareLabel = computed(() => {
const name = props.environment?.ashare?.name
return name ? name.replace('指数', '') : '上证'
})
const asharePrice = computed(() => priceText(props.environment?.ashare?.price))
const ashareMeta = computed(() => {
const ashare = props.environment?.ashare
if (!ashare || ashareStatus.value === 'unavailable') return ''
const change = typeof ashare.change_percent === 'number' && Number.isFinite(ashare.change_percent)
? `${ashare.change_percent > 0 ? '+' : ''}${ashare.change_percent.toFixed(2)}%`
: null
const asOf = typeof ashare.as_of === 'string' ? ashare.as_of.match(/^\d{4}-(\d{2}-\d{2})/)?.[1] ?? null : null
return joinSegments([change, asOf, ashareStatus.value === 'stale' ? '缓存' : null])
})
const countdownTitle = computed(() => {
const title = props.countdown?.title?.trim() ?? ''
if (title.length > 12) return `${title.slice(0, 11)}…`
return title
})
</script>
<template>
@@ -116,7 +225,7 @@ const weatherObservationTime = computed(() => {
<span class="today-environment__item today-environment__weather" :class="`is-${weatherStatus}`">
<template v-if="weatherStatus !== 'unavailable'">
<strong>宁波 <template v-if="environment.weather?.temperature_c != null">{{ environment.weather.temperature_c }}°C </template>{{ weatherText }}</strong>
<small>{{ `海曙${weatherObservationTime ? ` ${weatherObservationTime}` : ''}` }}<template v-if="weatherStatus === 'stale'"> · 缓存</template></small>
<small v-if="weatherMeta">{{ weatherMeta }}</small>
</template>
<template v-else>
<span class="today-environment__skeleton" aria-hidden="true"></span>
@@ -135,6 +244,32 @@ const weatherObservationTime = computed(() => {
<span class="sr-only">Au99.99 暂不可用</span>
</template>
</span>
<span v-if="countdown && countdownTitle" class="today-environment__item today-environment__countdown">
<strong>{{ countdownTitle }}</strong>
<small>{{ countdown.day_text }}</small>
</span>
<span v-if="hasOil" class="today-environment__item today-environment__oil" :class="`is-${oilStatus}`">
<template v-if="oilStatus !== 'unavailable' && oilPrimary">
<strong>{{ oilPrimary }}</strong>
<small v-if="oilMeta">{{ oilMeta }}</small>
</template>
<template v-else>
<span class="today-environment__skeleton" aria-hidden="true"></span>
<span class="today-environment__skeleton today-environment__skeleton--short" aria-hidden="true"></span>
<span class="sr-only">浙江油价暂不可用</span>
</template>
</span>
<span v-if="hasAshare" class="today-environment__item today-environment__ashare" :class="`is-${ashareStatus}`">
<template v-if="ashareStatus !== 'unavailable' && asharePrice">
<strong>{{ ashareLabel }} {{ asharePrice }}</strong>
<small v-if="ashareMeta">{{ ashareMeta }}</small>
</template>
<template v-else>
<span class="today-environment__skeleton" aria-hidden="true"></span>
<span class="today-environment__skeleton today-environment__skeleton--short" aria-hidden="true"></span>
<span class="sr-only">上证指数暂不可用</span>
</template>
</span>
</template>
</div>
</template>
+5 -2
View File
@@ -184,13 +184,16 @@ main.today-main .topbar{margin-bottom:var(--space-12)}
@media(min-width:721px){.today-environment__calendar{flex-direction:column;align-items:flex-start;gap:0}.today-environment__calendar small{margin-top:var(--space-2);font-size:11px}}
.today-environment__weather strong,.today-environment__gold strong,.today-environment__gold-primary{margin-top:0;font-size:13px;font-weight:600}
.today-environment__item small{margin-top:var(--space-2);font-size:11px}
.today-environment__item:nth-child(3n+1){border-left:0;padding-left:0}
.today-environment__item:nth-child(n+4){border-top:1px solid var(--border-hairline)}
.today-environment__countdown strong,.today-environment__oil strong,.today-environment__ashare strong{display:block;color:var(--text-primary);font-size:13px;line-height:1.2;font-weight:600}
.today-main :is(.task-row,.habit-row){height:58px;min-height:58px;max-height:58px;padding:0;background:transparent;border:0;border-bottom:1px solid var(--border-hairline);border-radius:0;box-shadow:none}
.today-main .task-row{gap:0}.today-main .task-main{padding:0}.today-main .task-main strong,.today-main .habit-name{font-size:15px;font-weight:400}
.today-main .task-tail{padding-left:var(--space-12);font-size:12px}.today-main .habit-row{gap:0;row-gap:5px}.today-main .habit-main{min-width:0;padding:0}
.shell.today-active .unified-fab{box-shadow:0 8px 18px rgba(174,65,29,.22)}.shell.today-active .unified-fab>svg{width:30px;height:30px;stroke-width:1.5}
@media(max-width:930px){main.today-main{padding-left:max(44px,calc((100% - 630px)/2));padding-right:max(44px,calc((100% - 630px)/2))}}
@media(max-width:720.98px){main.today-main{padding-left:var(--space-28)!important;padding-right:var(--space-28)!important;padding-bottom:calc(78px + var(--safe-area-bottom))}.today-environment{grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:21px auto;align-items:stretch;column-gap:0;row-gap:7px;padding-bottom:var(--space-10)}.today-environment__calendar{grid-column:1/-1;grid-row:1;height:21px!important;padding:0;justify-content:space-between;align-items:start;border-left:0}.today-environment__weather,.today-environment__gold{grid-row:2;height:clamp(38px,calc(25vw - 55.75px),42px);padding-top:var(--space-6);border-top:1px solid var(--border-hairline)}.today-environment__weather{grid-column:1;padding-left:0;padding-right:var(--space-10);border-left:0}.today-environment__gold{grid-column:2;padding-left:var(--space-12)}.today-environment__weather strong,.today-environment__gold strong,.today-environment__gold-primary{line-height:16px}.today-environment__item small{line-height:13px}.today-heading{margin-top:var(--space-16)}.today-page-title{font-size:24px}.today-heading .today-remaining{margin-top:var(--space-4)}.today-main .task-row,.today-main .habit-row{height:58px;min-height:58px;max-height:58px}.today-context .completed-filter-pill{width:87px;height:44px;min-height:44px}.today-context .completed-filter-pill__track{width:30px;height:18px}.today-context .completed-filter-pill__thumb{width:14px;height:14px}.today-context .completed-filter-pill[aria-checked="true"] .completed-filter-pill__thumb{transform:translateX(12px)}}
@media(max-width:380px){.today-environment__weather,.today-environment__gold{height:40px;padding-top:var(--space-4);padding-bottom:var(--space-4)}}
@media(max-width:720.98px){main.today-main{padding-left:var(--space-28)!important;padding-right:var(--space-28)!important;padding-bottom:calc(78px + var(--safe-area-bottom))}.today-environment{grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:21px auto;align-items:stretch;column-gap:0;row-gap:7px;padding-bottom:var(--space-10)}.today-environment__calendar{grid-column:1/-1;grid-row:1;height:21px!important;padding:0;justify-content:space-between;align-items:start;border-left:0}.today-environment__weather,.today-environment__gold{grid-row:2;height:clamp(38px,calc(25vw - 55.75px),42px);padding-top:var(--space-6);border-top:1px solid var(--border-hairline)}.today-environment__weather{grid-column:1;padding-left:0;padding-right:var(--space-10);border-left:0}.today-environment__gold{grid-column:2;padding-left:var(--space-12)}.today-environment__weather strong,.today-environment__gold strong,.today-environment__gold-primary{line-height:16px}.today-environment__oil,.today-environment__ashare{grid-row:3;height:clamp(38px,calc(25vw - 55.75px),42px);padding-top:var(--space-6);padding-bottom:0}.today-environment__oil{grid-column:1;padding-left:0;padding-right:var(--space-10);border-left:0!important}.today-environment__ashare{grid-column:2;padding-left:var(--space-12)}.today-environment__countdown{grid-column:1/-1;grid-row:4;height:21px;padding:0;flex-direction:row;align-items:baseline;justify-content:space-between;gap:10px}.today-environment__countdown strong,.today-environment__oil strong,.today-environment__ashare strong{line-height:16px}.today-environment__item small{line-height:13px}.today-heading{margin-top:var(--space-16)}.today-page-title{font-size:24px}.today-heading .today-remaining{margin-top:var(--space-4)}.today-main .task-row,.today-main .habit-row{height:58px;min-height:58px;max-height:58px}.today-context .completed-filter-pill{width:87px;height:44px;min-height:44px}.today-context .completed-filter-pill__track{width:30px;height:18px}.today-context .completed-filter-pill__thumb{width:14px;height:14px}.today-context .completed-filter-pill[aria-checked="true"] .completed-filter-pill__thumb{transform:translateX(12px)}}
@media(max-width:380px){.today-environment__weather,.today-environment__gold,.today-environment__oil,.today-environment__ashare{height:40px;padding-top:var(--space-4);padding-bottom:var(--space-4)}}
/* Approved Settings 01: continuous paper ledger. */
main:has(>.mvp-view .settings-sections){background:var(--surface-raised)}