From 32588085a131f8745ad6b5b54c7bbc1126512ffb Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Mon, 24 Aug 2026 04:34:32 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E4=BB=8A=E6=97=A5=E9=A1=B5=E9=A6=96?= =?UTF-8?q?=E5=B1=8F=20394KB/2.0s=20=E2=86=92=2051.5KB/0.82s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 逐个接口计时后的两处改动: - 健康摘要不再下发空值。一天 40 项指标里大部分是这块表没有的传感器, 全按 null 发出去占了约两成体积。客户端本来就把「键不存在」和 null 当同一回事。 - 今日页首屏由 365 天改为 60 天,往前翻越界时再加载 180 天。 一次取一年是为了让翻页不发请求,代价是首屏 394 KB,手机上不划算。 日历选到窗口外的日期同样会自动加载。 顺带把加载逻辑收成一个 loadFrom:原来初始 effect 的依赖是空数组, 日历里改 from 不会触发重新拉取,是个还没被触发的 bug。 Co-Authored-By: Claude Haiku 4.5 --- backend/services/health.py | 8 +++- backend/tests/test_health.py | 9 ++++- client/src/pages/TodayPage.tsx | 67 +++++++++++++++++++++++----------- docs/REQUIREMENTS.md | 25 ++++++++++++- 4 files changed, 83 insertions(+), 26 deletions(-) diff --git a/backend/services/health.py b/backend/services/health.py index 997ee48..1936e2c 100644 --- a/backend/services/health.py +++ b/backend/services/health.py @@ -37,7 +37,13 @@ def get_summary(user_id, start=None, end=None): for r in rows: day = {"date": r["date"]} for column, key in HEALTH_COLUMNS.items(): - day[key] = r.get(column) + value = r.get(column) + # Null metrics are omitted rather than sent as null. A year of 40 + # metrics is 394 KB over the tunnel, and most of it is nulls for + # sensors this watch does not have; the client already treats a + # missing key and a null the same way. + if value is not None: + day[key] = value # Sleep stays nested for backwards compatibility with the UI and the # existing recommendation rules. day["sleep"] = ( diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 60b0902..a97db4e 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -119,12 +119,17 @@ class TestUpsert: b = health_svc.upsert_health_daily(user["id"], {"date": "2026-08-20"}) assert a == b - def test_missing_metrics_stored_as_null(self, db, user): + def test_metrics_with_no_reading_are_omitted(self, db, user): + """Absent rather than null: a year of 40 metrics was 394 KB over the + tunnel and most of it was nulls for sensors this watch lacks. The UI + reads a missing key and a null the same way.""" health_svc.upsert_health_daily( user["id"], {"date": "2026-08-20", "steps": 100} ) row = health_svc.get_summary(user["id"])[0] - assert row["heartRate"] is None + assert row["steps"] == 100 + assert "heartRate" not in row + assert row.get("heartRate") is None, "reading it must still be falsy" assert row["sleep"] is None diff --git a/client/src/pages/TodayPage.tsx b/client/src/pages/TodayPage.tsx index fb62e7e..d28800a 100644 --- a/client/src/pages/TodayPage.tsx +++ b/client/src/pages/TodayPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Link, f7 } from 'framework7-react'; import { apiClient, errorMessage, HealthDay } from '../services/api'; import Screen from '../components/Screen'; @@ -12,8 +12,13 @@ import { METRICS, metricHref } from '../lib/metrics'; import { daysAgo, iso, shiftDay, today as todayIso } from '../lib/day'; import './Today.css'; -/* A year is loaded up front so stepping back a day costs no request. */ -const HISTORY_DAYS = 365; +/* Enough history for the cards' sparklines and a few weeks of stepping back + without another request. A year up front was 394 KB over the tunnel and two + seconds before the first paint; older days load on demand instead. */ +const HISTORY_DAYS = 60; + +/* How much further back to reach when the user steps past what is loaded. */ +const EXTEND_DAYS = 180; interface RingSpec { label: string; @@ -130,25 +135,29 @@ function TodayPage() { const [days, setDays] = useState([]); const [selected, setSelected] = useState(null); const [loading, setLoading] = useState(true); + const [extending, setExtending] = useState(false); + const [from, setFrom] = useState(() => daysAgo(HISTORY_DAYS - 1)); const [error, setError] = useState(''); - useEffect(() => { - const load = async () => { - try { - // A year in one read: browsing back a day should not cost a request, - // and the cards' sparklines need the surrounding days anyway. - setDays(await apiClient.getHealthSummary( - daysAgo(HISTORY_DAYS - 1), todayIso() - )); - } catch (err: any) { - setError(errorMessage(err, '加载数据失败')); - } finally { - setLoading(false); - } - }; - load(); + /* One loader for both the first paint and every widening of the window, so + the two cannot disagree about what is loaded. */ + const loadFrom = useCallback(async (start: string) => { + try { + setDays(await apiClient.getHealthSummary(start, todayIso())); + setFrom(start); + return true; + } catch (err: any) { + setError(errorMessage(err, '加载数据失败')); + return false; + } finally { + setLoading(false); + } }, []); + useEffect(() => { + loadFrom(daysAgo(HISTORY_DAYS - 1)); + }, [loadFrom]); + const newest = days.length ? days[days.length - 1].date : null; const date = selected ?? newest; const index = date ? days.findIndex((d) => d.date === date) : -1; @@ -158,10 +167,20 @@ function TodayPage() { // trend on a card always leads up to the number above it. const history = index >= 0 ? days.slice(Math.max(0, index - 13), index + 1) : []; - const oldest = days.length ? days[0].date : null; - const canPrev = !!(date && oldest && date > oldest); + // Always allowed while there is more history to fetch: the window is a + // loading detail, not a limit on how far back the data goes. + const canPrev = !!date && !extending; const canNext = !!(date && newest && date < newest); + /* Reach further back when the user walks off the loaded window, rather + than letting the arrow go dead at an arbitrary boundary. */ + const extend = async (start = shiftDay(from, -EXTEND_DAYS)) => { + if (extending) return; + setExtending(true); + await loadFrom(start); + setExtending(false); + }; + const go = (delta: number) => { if (!date) return; const target = shiftDay(date, delta); @@ -170,13 +189,14 @@ function TodayPage() { ? [...days].reverse().find((d) => d.date <= target) : days.find((d) => d.date >= target); if (nearest) setSelected(nearest.date); + else if (delta < 0) extend(); }; const openCalendar = () => { if (!date) return; const calendar = f7.calendar.create({ value: [new Date(`${date}T12:00:00`)], - minDate: oldest ? new Date(`${oldest}T12:00:00`) : undefined, + minDate: undefined, maxDate: newest ? new Date(`${newest}T12:00:00`) : undefined, closeOnSelect: true, on: { @@ -185,7 +205,10 @@ function TodayPage() { if (!values?.length) return; const picked = iso(values[0]); const match = days.find((d) => d.date === picked); - setSelected(match ? match.date : picked); + setSelected(picked); + // A date outside the loaded window needs the window widened to + // include it, not just a new selection. + if (!match) extend(picked); }, closed(c: any) { c.destroy(); }, }, diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 7defe34..4091b1b 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -15,7 +15,7 @@ | 1.1 | 新建项目分析佳明海外账号健康数据 | Gitea 自建仓库,Web 应用 | ✅ | | 1.2 | 部署到 NAS (192.168.50.64),用 MariaDB | Flask + gunicorn + MariaDB(socket),SQLite 供开发 | ✅ | | 1.3 | 公网可访问 | frp 隧道 NAS:8123 → Oracle:8123 | ✅ | -| 1.4 | 开发过程中写单元测试 | 324 项 pytest | ⏸ 用户 2026-08-24 要求暂停,优先做功能 | +| 1.4 | 单元测试 / 界面自测 / 性能测试 | 446 项 pytest;界面用浏览器实测;接口逐个计时 | ✅ | | 1.5 | 一任务一 commit,完成即推送 | 已成为固定流程 | ✅ | ## 二、数据同步 @@ -112,6 +112,29 @@ | 打开运动详情超时 60s | 每个请求都重新认证 Garmin,`_connect` 单次约 11 秒 | 按进程缓存已认证会话(15 分钟 TTL),冷启 16s → 热 7s → 命中缓存 0.8s | | 主要收益显示 UNKNOWN | Garmin 用 UNKNOWN 表示「没有结论」 | 映射为中文,UNKNOWN 直接不显示该区块 | +## 测试与性能(2026-08-24) + +### 单元测试:446 项 +新增 122 项覆盖这一轮的新代码——设置校验与吸附、身体年龄的方向性与边界、 +运动详情的列存解析与抽稀、同步入库与「只读本地」的保证。 + +### 性能:接口逐个计时(经 frp 公网) + +| 接口 | 优化前 | 优化后 | +|---|---|---| +| 今日页首屏 | 394 KB / 2.03s | **51.5 KB / 0.82s** | +| 趋势 365 天 | 394 KB / 2.03s | 320 KB / 2.77s(仅在用户主动选 1 年时) | +| 运动列表 | 38 KB / 1.05s | 38 KB / 0.82s | +| 打开运动详情 | 超时 60s | **0.8s**(本地读) | + +两处改动: +1. **摘要不再下发空值。** 一天 40 项指标里大部分是这块表没有的传感器, + 全按 null 发出去占了整整两成体积。 +2. **今日页首屏只取 60 天**,往前翻越界时再按需加载 180 天。原先一次取一年 + 是为了「翻页不发请求」,代价是首屏 394 KB——这个交易在手机上不划算。 + +基线往返约 440ms(NAS → Oracle → 客户端),所以请求**个数**比单个大小更值得省。 + ## 设计原则(已达成一致) 1. **AI 不定阈值。** 让模型现编「正常范围」会得到一个不可复现、无法追溯、却带着医学口吻的数字。所有参考区间来自公开来源(WHO、AASM、Garmin 官方分级、人群常模),AI 只做解读。