perf: 今日页首屏 394KB/2.0s → 51.5KB/0.82s
逐个接口计时后的两处改动: - 健康摘要不再下发空值。一天 40 项指标里大部分是这块表没有的传感器, 全按 null 发出去占了约两成体积。客户端本来就把「键不存在」和 null 当同一回事。 - 今日页首屏由 365 天改为 60 天,往前翻越界时再加载 180 天。 一次取一年是为了让翻页不发请求,代价是首屏 394 KB,手机上不划算。 日历选到窗口外的日期同样会自动加载。 顺带把加载逻辑收成一个 loadFrom:原来初始 effect 的依赖是空数组, 日历里改 from 不会触发重新拉取,是个还没被触发的 bug。 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,13 @@ def get_summary(user_id, start=None, end=None):
|
|||||||
for r in rows:
|
for r in rows:
|
||||||
day = {"date": r["date"]}
|
day = {"date": r["date"]}
|
||||||
for column, key in HEALTH_COLUMNS.items():
|
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
|
# Sleep stays nested for backwards compatibility with the UI and the
|
||||||
# existing recommendation rules.
|
# existing recommendation rules.
|
||||||
day["sleep"] = (
|
day["sleep"] = (
|
||||||
|
|||||||
@@ -119,12 +119,17 @@ class TestUpsert:
|
|||||||
b = health_svc.upsert_health_daily(user["id"], {"date": "2026-08-20"})
|
b = health_svc.upsert_health_daily(user["id"], {"date": "2026-08-20"})
|
||||||
assert a == b
|
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(
|
health_svc.upsert_health_daily(
|
||||||
user["id"], {"date": "2026-08-20", "steps": 100}
|
user["id"], {"date": "2026-08-20", "steps": 100}
|
||||||
)
|
)
|
||||||
row = health_svc.get_summary(user["id"])[0]
|
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
|
assert row["sleep"] is None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Link, f7 } from 'framework7-react';
|
import { Link, f7 } from 'framework7-react';
|
||||||
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
||||||
import Screen from '../components/Screen';
|
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 { daysAgo, iso, shiftDay, today as todayIso } from '../lib/day';
|
||||||
import './Today.css';
|
import './Today.css';
|
||||||
|
|
||||||
/* A year is loaded up front so stepping back a day costs no request. */
|
/* Enough history for the cards' sparklines and a few weeks of stepping back
|
||||||
const HISTORY_DAYS = 365;
|
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 {
|
interface RingSpec {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -130,25 +135,29 @@ function TodayPage() {
|
|||||||
const [days, setDays] = useState<HealthDay[]>([]);
|
const [days, setDays] = useState<HealthDay[]>([]);
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [extending, setExtending] = useState(false);
|
||||||
|
const [from, setFrom] = useState(() => daysAgo(HISTORY_DAYS - 1));
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
/* One loader for both the first paint and every widening of the window, so
|
||||||
const load = async () => {
|
the two cannot disagree about what is loaded. */
|
||||||
try {
|
const loadFrom = useCallback(async (start: string) => {
|
||||||
// A year in one read: browsing back a day should not cost a request,
|
try {
|
||||||
// and the cards' sparklines need the surrounding days anyway.
|
setDays(await apiClient.getHealthSummary(start, todayIso()));
|
||||||
setDays(await apiClient.getHealthSummary(
|
setFrom(start);
|
||||||
daysAgo(HISTORY_DAYS - 1), todayIso()
|
return true;
|
||||||
));
|
} catch (err: any) {
|
||||||
} catch (err: any) {
|
setError(errorMessage(err, '加载数据失败'));
|
||||||
setError(errorMessage(err, '加载数据失败'));
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
load();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFrom(daysAgo(HISTORY_DAYS - 1));
|
||||||
|
}, [loadFrom]);
|
||||||
|
|
||||||
const newest = days.length ? days[days.length - 1].date : null;
|
const newest = days.length ? days[days.length - 1].date : null;
|
||||||
const date = selected ?? newest;
|
const date = selected ?? newest;
|
||||||
const index = date ? days.findIndex((d) => d.date === date) : -1;
|
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.
|
// 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 history = index >= 0 ? days.slice(Math.max(0, index - 13), index + 1) : [];
|
||||||
|
|
||||||
const oldest = days.length ? days[0].date : null;
|
// Always allowed while there is more history to fetch: the window is a
|
||||||
const canPrev = !!(date && oldest && date > oldest);
|
// loading detail, not a limit on how far back the data goes.
|
||||||
|
const canPrev = !!date && !extending;
|
||||||
const canNext = !!(date && newest && date < newest);
|
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) => {
|
const go = (delta: number) => {
|
||||||
if (!date) return;
|
if (!date) return;
|
||||||
const target = shiftDay(date, delta);
|
const target = shiftDay(date, delta);
|
||||||
@@ -170,13 +189,14 @@ function TodayPage() {
|
|||||||
? [...days].reverse().find((d) => d.date <= target)
|
? [...days].reverse().find((d) => d.date <= target)
|
||||||
: days.find((d) => d.date >= target);
|
: days.find((d) => d.date >= target);
|
||||||
if (nearest) setSelected(nearest.date);
|
if (nearest) setSelected(nearest.date);
|
||||||
|
else if (delta < 0) extend();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCalendar = () => {
|
const openCalendar = () => {
|
||||||
if (!date) return;
|
if (!date) return;
|
||||||
const calendar = f7.calendar.create({
|
const calendar = f7.calendar.create({
|
||||||
value: [new Date(`${date}T12:00:00`)],
|
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,
|
maxDate: newest ? new Date(`${newest}T12:00:00`) : undefined,
|
||||||
closeOnSelect: true,
|
closeOnSelect: true,
|
||||||
on: {
|
on: {
|
||||||
@@ -185,7 +205,10 @@ function TodayPage() {
|
|||||||
if (!values?.length) return;
|
if (!values?.length) return;
|
||||||
const picked = iso(values[0]);
|
const picked = iso(values[0]);
|
||||||
const match = days.find((d) => d.date === picked);
|
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(); },
|
closed(c: any) { c.destroy(); },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
| 1.1 | 新建项目分析佳明海外账号健康数据 | Gitea 自建仓库,Web 应用 | ✅ |
|
| 1.1 | 新建项目分析佳明海外账号健康数据 | Gitea 自建仓库,Web 应用 | ✅ |
|
||||||
| 1.2 | 部署到 NAS (192.168.50.64),用 MariaDB | Flask + gunicorn + MariaDB(socket),SQLite 供开发 | ✅ |
|
| 1.2 | 部署到 NAS (192.168.50.64),用 MariaDB | Flask + gunicorn + MariaDB(socket),SQLite 供开发 | ✅ |
|
||||||
| 1.3 | 公网可访问 | frp 隧道 NAS:8123 → Oracle:8123 | ✅ |
|
| 1.3 | 公网可访问 | frp 隧道 NAS:8123 → Oracle:8123 | ✅ |
|
||||||
| 1.4 | 开发过程中写单元测试 | 324 项 pytest | ⏸ 用户 2026-08-24 要求暂停,优先做功能 |
|
| 1.4 | 单元测试 / 界面自测 / 性能测试 | 446 项 pytest;界面用浏览器实测;接口逐个计时 | ✅ |
|
||||||
| 1.5 | 一任务一 commit,完成即推送 | 已成为固定流程 | ✅ |
|
| 1.5 | 一任务一 commit,完成即推送 | 已成为固定流程 | ✅ |
|
||||||
|
|
||||||
## 二、数据同步
|
## 二、数据同步
|
||||||
@@ -112,6 +112,29 @@
|
|||||||
| 打开运动详情超时 60s | 每个请求都重新认证 Garmin,`_connect` 单次约 11 秒 | 按进程缓存已认证会话(15 分钟 TTL),冷启 16s → 热 7s → 命中缓存 0.8s |
|
| 打开运动详情超时 60s | 每个请求都重新认证 Garmin,`_connect` 单次约 11 秒 | 按进程缓存已认证会话(15 分钟 TTL),冷启 16s → 热 7s → 命中缓存 0.8s |
|
||||||
| 主要收益显示 UNKNOWN | Garmin 用 UNKNOWN 表示「没有结论」 | 映射为中文,UNKNOWN 直接不显示该区块 |
|
| 主要收益显示 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 只做解读。
|
1. **AI 不定阈值。** 让模型现编「正常范围」会得到一个不可复现、无法追溯、却带着医学口吻的数字。所有参考区间来自公开来源(WHO、AASM、Garmin 官方分级、人群常模),AI 只做解读。
|
||||||
|
|||||||
Reference in New Issue
Block a user