fix: 实机验证发现的七个缺陷
部署与时区 - client/.env.production 写死 REACT_APP_API_URL=/api。之前没有这个文件, 构建靠命令行临时传参,一旦忘了就把开发默认值 localhost:5000 打进包里, 部署后整站 Network Error。 - 新增 lib/day.ts,所有日期改用本地日历日。原先用 toISOString() 取的是 UTC 日期, 在 UTC+8 每天前 8 小时都会少查一天——当天的数据佳明已经有了,应用却够不到。 界面 - 覆盖 Framework7 9 给 .navbar .left/.right 加的 frosted pill, 就是各页右上角和返回键旁边那个半透明椭圆。 - .metric-tab 显式 width:auto。F7 把每个 button 渲染成整宽块元素, 运动详情的四个 Tab 因此竖着堆成四行。 - 主要收益为 UNKNOWN 时不显示该区块,那是「没有结论」的哨兵值。 正确性 - 心率区间百分比改用整次运动时长作分母。原先除以「落在区间内的总时长」, 把低于区间 1 的时间挤掉了:44:06 的登山里区间 1 占 23:03, 手表显示 52%,我算成了 90%。现在对上了。 性能 - 按进程缓存已认证的 Garmin 会话(15 分钟 TTL)。实测 _connect 单次 11 秒, 而七个数据接口加起来才 4 秒——瓶颈全在每次重新认证。 冷启 16s → 热 7s → 命中缓存 0.8s,不再撞客户端超时。 - get_activity_details 的 maxchart 由 2000 降到 500,反正写入时抽稀到 300。 - 重新绑定账号时丢弃缓存会话。 删除前端重做前遗留的 5 个无引用页面文件。 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -123,12 +123,44 @@ def save_token(user_id, token, garmin_email=None):
|
||||
f"ON CONFLICT(user_id) DO UPDATE SET {updates}")
|
||||
execute(sql, [user_id, token, garmin_email,
|
||||
datetime.datetime.utcnow().isoformat(timespec="seconds")])
|
||||
# A re-bind means the old session is stale; the next call must build a
|
||||
# fresh one rather than keep using the session the old token minted.
|
||||
forget_client(user_id)
|
||||
|
||||
|
||||
def has_token(user_id):
|
||||
return load_token(user_id) is not None
|
||||
|
||||
|
||||
# An authenticated client, reused across requests in this process.
|
||||
#
|
||||
# Building one costs ~11s against Garmin — loading the token, refreshing the
|
||||
# OAuth2 grant and fetching the profile — which dwarfed the ~4s of actual data
|
||||
# fetching behind an activity-detail request. The session is a requests.Session
|
||||
# underneath, so it is reusable; it is dropped after CLIENT_TTL so a refreshed
|
||||
# or revoked token is picked up rather than being cached indefinitely.
|
||||
CLIENT_TTL_SECONDS = 900
|
||||
_clients = {}
|
||||
_clients_lock = threading.Lock()
|
||||
|
||||
|
||||
def _cached_client(user_id):
|
||||
entry = _clients.get(user_id)
|
||||
if entry and (datetime.datetime.utcnow() - entry[1]).total_seconds() < CLIENT_TTL_SECONDS:
|
||||
return entry[0]
|
||||
return None
|
||||
|
||||
|
||||
def _cache_client(user_id, client):
|
||||
if user_id:
|
||||
_clients[user_id] = (client, datetime.datetime.utcnow())
|
||||
|
||||
|
||||
def forget_client(user_id):
|
||||
"""Drop the cached session — call after re-binding an account."""
|
||||
_clients.pop(user_id, None)
|
||||
|
||||
|
||||
def _connect(creds, user_id=None):
|
||||
"""Obtain a logged-in Garmin client.
|
||||
|
||||
@@ -138,6 +170,12 @@ def _connect(creds, user_id=None):
|
||||
"EOFError: EOF when reading a line"). Tokens are minted once by
|
||||
`garmin_login.py`, which runs in a terminal where a code can be typed.
|
||||
"""
|
||||
if user_id:
|
||||
with _clients_lock:
|
||||
cached = _cached_client(user_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
Garmin = _import_garmin()
|
||||
client = Garmin(is_cn=_is_cn())
|
||||
|
||||
@@ -150,6 +188,8 @@ def _connect(creds, user_id=None):
|
||||
# garminconnect builds most of its URLs from display_name, so leaving
|
||||
# it unset sends every request to ".../None".
|
||||
client.display_name = client.garth.profile["displayName"]
|
||||
with _clients_lock:
|
||||
_cache_client(user_id, client)
|
||||
return client
|
||||
|
||||
if not creds.get("garminPassword"):
|
||||
@@ -167,6 +207,8 @@ def _connect(creds, user_id=None):
|
||||
"系统会提示你输入验证码。"
|
||||
) from e
|
||||
_use_api_user_agent(client)
|
||||
with _clients_lock:
|
||||
_cache_client(user_id, client)
|
||||
return client
|
||||
|
||||
|
||||
@@ -519,8 +561,10 @@ def _build_detail(client, activity_id):
|
||||
rest of the page intact rather than fail the request.
|
||||
"""
|
||||
summary = _safe(lambda: client.get_activity_evaluation(activity_id), {}) or {}
|
||||
# 500 is already more samples than the 300 we keep, and asking for 2000
|
||||
# triples the payload for points that get thinned away anyway.
|
||||
details = _safe(
|
||||
lambda: client.get_activity_details(activity_id, maxchart=2000, maxpoly=0), {}
|
||||
lambda: client.get_activity_details(activity_id, maxchart=500, maxpoly=0), {}
|
||||
) or {}
|
||||
|
||||
return {
|
||||
|
||||
5
client/.env.production
Normal file
5
client/.env.production
Normal file
@@ -0,0 +1,5 @@
|
||||
# In production the Flask app serves the built client from its own origin, so
|
||||
# the API is a same-origin path. Without this the build bakes in the
|
||||
# development default (localhost:5000) and every request fails with
|
||||
# "Network Error" on any machine that is not the developer's.
|
||||
REACT_APP_API_URL=/api
|
||||
@@ -398,6 +398,13 @@
|
||||
}
|
||||
|
||||
.metric-tab {
|
||||
/* Framework7 styles every <button> as a full-width block, which turned this
|
||||
row of pills into a vertical stack. */
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.34rem 0.8rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-1);
|
||||
|
||||
@@ -144,3 +144,17 @@
|
||||
.nav-progress { transition: none; }
|
||||
.nav-progress.on .nav-progress-bar { animation: none; transform: scaleX(0.9); }
|
||||
}
|
||||
|
||||
/* Framework7 9 gives .navbar .left/.right/.navbar-pane a frosted pill
|
||||
(--f7-glass-bg-color + 64px radius). Around a lone back chevron it reads as
|
||||
a stray translucent ellipse floating in the corner, so the panes go back to
|
||||
being plain containers. The navbar itself keeps its own blur. */
|
||||
.ios .navbar .left,
|
||||
.ios .navbar .right,
|
||||
.ios .navbar .navbar-pane {
|
||||
background: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HealthDay } from '../services/api';
|
||||
import { iso } from './day';
|
||||
|
||||
export type Granularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
||||
|
||||
@@ -89,7 +90,7 @@ export function aggregate(
|
||||
const groups = new Map<string, { start: Date; rows: HealthDay[] }>();
|
||||
for (const row of days) {
|
||||
const start = startOfBucket(new Date(row.date), granularity, anchor);
|
||||
const key = start.toISOString().slice(0, 10);
|
||||
const key = iso(start);
|
||||
if (!groups.has(key)) groups.set(key, { start, rows: [] });
|
||||
groups.get(key)!.rows.push(row);
|
||||
}
|
||||
|
||||
32
client/src/lib/day.ts
Normal file
32
client/src/lib/day.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Local calendar dates.
|
||||
*
|
||||
* `toISOString().slice(0, 10)` gives the date in UTC, which is not the date
|
||||
* the user is living in. East of Greenwich it is yesterday's date for the
|
||||
* first hours of every day: at 00:30 in UTC+8 the app asked Garmin for data
|
||||
* "up to yesterday", so the current day was unreachable until 08:00 — and
|
||||
* Garmin, which stamps days in the watch's local time, already had it.
|
||||
*
|
||||
* Everything that turns a Date into a YYYY-MM-DD goes through here.
|
||||
*/
|
||||
export function iso(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
/** Today, in the user's own timezone. */
|
||||
export const today = () => iso(new Date());
|
||||
|
||||
/** `days` before today (positive numbers go back), as YYYY-MM-DD. */
|
||||
export function daysAgo(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
return iso(d);
|
||||
}
|
||||
|
||||
/** Shift a YYYY-MM-DD by whole days without tripping over DST. */
|
||||
export function shiftDay(date: string, delta: number): string {
|
||||
const d = new Date(`${date}T12:00:00`);
|
||||
d.setDate(d.getDate() + delta);
|
||||
return iso(d);
|
||||
}
|
||||
@@ -21,6 +21,16 @@ const TYPE_LABEL: Record<string, string> = {
|
||||
fitness_equipment: '健身器械',
|
||||
};
|
||||
|
||||
/* Garmin's primary-benefit labels. UNKNOWN is its sentinel for "no verdict"
|
||||
— usually a session too short or too easy to classify — and must not be
|
||||
printed as if it were one. */
|
||||
const BENEFIT: Record<string, string | undefined> = {
|
||||
RECOVERY: '恢复', BASE: '基础耐力', TEMPO: '节奏', THRESHOLD: '乳酸阈值',
|
||||
VO2MAX: '最大摄氧量', ANAEROBIC_CAPACITY: '无氧能力', SPRINT: '冲刺',
|
||||
AEROBIC_BASE: '有氧基础', LACTATE_THRESHOLD: '乳酸阈值',
|
||||
UNKNOWN: undefined, NO_BENEFIT: undefined,
|
||||
};
|
||||
|
||||
/* --- formatting ---------------------------------------------------------- */
|
||||
|
||||
const hms = (seconds?: number | null) => {
|
||||
@@ -77,7 +87,7 @@ function statGroups(s: Record<string, any>) {
|
||||
['最高心率', num(s.maxHR, 0, 'bpm')],
|
||||
]],
|
||||
['训练效果', [
|
||||
['主要收益', s.trainingEffectLabel ?? '—'],
|
||||
['主要收益', BENEFIT[s.trainingEffectLabel] ?? '—'],
|
||||
['有氧', num(s.trainingEffect, 1)],
|
||||
['无氧', num(s.anaerobicTrainingEffect, 1)],
|
||||
['运动负荷', num(s.activityTrainingLoad, 0)],
|
||||
@@ -124,9 +134,18 @@ function statGroups(s: Record<string, any>) {
|
||||
|
||||
const ZONE_NAME = ['', '热身', '轻松', '有氧', '阈值', '最大'];
|
||||
|
||||
function Zones({ zones }: { zones: ActivityDetail['hrZones'] }) {
|
||||
const total = zones.reduce((sum, z) => sum + (z.seconds || 0), 0);
|
||||
if (!total) return null;
|
||||
function Zones({ zones, duration }: {
|
||||
zones: ActivityDetail['hrZones'];
|
||||
duration?: number | null;
|
||||
}) {
|
||||
const inZones = zones.reduce((sum, z) => sum + (z.seconds || 0), 0);
|
||||
if (!inZones) return null;
|
||||
|
||||
/* The share is of the whole activity, not of the time that landed in a zone.
|
||||
Dividing by the zone sum drops the minutes spent below zone 1 and inflates
|
||||
everything: a walk with 23:03 in zone 1 out of 44:06 is 52%, which is what
|
||||
the watch shows, not the 90% that the zone-sum denominator produces. */
|
||||
const total = duration && duration >= inZones ? duration : inZones;
|
||||
|
||||
return (
|
||||
<section className="sec">
|
||||
@@ -287,17 +306,17 @@ function ActivityDetailPage({ id, f7route }: Props) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{s.trainingEffectLabel && (
|
||||
{BENEFIT[s.trainingEffectLabel] && (
|
||||
<section className="sec">
|
||||
<h3 className="sec-title">评估</h3>
|
||||
<div className="ad-eval">
|
||||
<div className="ad-eval-name">{s.trainingEffectLabel}</div>
|
||||
<div className="ad-eval-name">{BENEFIT[s.trainingEffectLabel]}</div>
|
||||
<div className="ad-eval-note">主要收益</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Zones zones={detail.hrZones} />
|
||||
<Zones zones={detail.hrZones} duration={s.duration} />
|
||||
|
||||
{!!detail.gear.length && (
|
||||
<section className="sec">
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { apiClient, Activity, errorMessage, HealthDay } from '../services/api';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './Daily.css';
|
||||
import './Pages.css';
|
||||
|
||||
/** Every stored metric, grouped the way the device groups them. */
|
||||
interface Field {
|
||||
key: keyof HealthDay | 'sleepDeep' | 'sleepLight' | 'sleepRem' | 'sleepAwake';
|
||||
label: string;
|
||||
unit?: string;
|
||||
/** Convert the raw stored value for display. */
|
||||
transform?: (v: number) => number;
|
||||
decimals?: number;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
const SECONDS_TO_HOURS = (v: number) => v / 3600;
|
||||
const SECONDS_TO_MINUTES = (v: number) => v / 60;
|
||||
|
||||
const GROUPS: Array<{ title: string; fields: Field[] }> = [
|
||||
{
|
||||
title: '活动',
|
||||
fields: [
|
||||
{ key: 'steps', label: '步数', unit: '步' },
|
||||
{ key: 'stepGoal', label: '步数目标', unit: '步' },
|
||||
{ key: 'distanceMeters', label: '距离', unit: 'km', transform: (v) => v / 1000, decimals: 2 },
|
||||
{ key: 'floorsAscended', label: '爬楼上行', unit: '层' },
|
||||
{ key: 'floorsDescended', label: '爬楼下行', unit: '层' },
|
||||
{ key: 'intensityMinutes', label: '强度分钟', unit: '分钟', hint: '中等及以上强度' },
|
||||
{ key: 'activeSeconds', label: '活动时长', unit: '小时', transform: SECONDS_TO_HOURS, decimals: 1 },
|
||||
{ key: 'sedentarySeconds', label: '久坐时长', unit: '小时', transform: SECONDS_TO_HOURS, decimals: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '能量',
|
||||
fields: [
|
||||
{ key: 'caloriesBurned', label: '总消耗', unit: 'kcal' },
|
||||
{ key: 'activeCalories', label: '活动消耗', unit: 'kcal' },
|
||||
{ key: 'bmrCalories', label: '基础代谢', unit: 'kcal' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '心率',
|
||||
fields: [
|
||||
{ key: 'heartRate', label: '静息心率', unit: 'bpm' },
|
||||
{ key: 'heartRateMin', label: '最低心率', unit: 'bpm' },
|
||||
{ key: 'heartRateMax', label: '最高心率', unit: 'bpm' },
|
||||
{ key: 'heartRateVariability', label: '心率变异性', unit: 'ms', decimals: 1, hint: 'HRV,反映恢复情况' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '压力与身体电量',
|
||||
fields: [
|
||||
{ key: 'stress', label: '平均压力' },
|
||||
{ key: 'stressMax', label: '最高压力' },
|
||||
{ key: 'bodyBatteryHigh', label: '身体电量最高' },
|
||||
{ key: 'bodyBatteryLow', label: '身体电量最低' },
|
||||
{ key: 'bodyBatteryCharged', label: '当日充能' },
|
||||
{ key: 'bodyBatteryDrained', label: '当日消耗' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '睡眠',
|
||||
fields: [
|
||||
{ key: 'sleepDuration', label: '总时长', unit: '小时', decimals: 1 },
|
||||
{ key: 'sleepQuality', label: '睡眠评分', unit: '/100' },
|
||||
{ key: 'sleepDeep', label: '深睡', unit: '分钟', transform: SECONDS_TO_MINUTES },
|
||||
{ key: 'sleepLight', label: '浅睡', unit: '分钟', transform: SECONDS_TO_MINUTES },
|
||||
{ key: 'sleepRem', label: 'REM', unit: '分钟', transform: SECONDS_TO_MINUTES },
|
||||
{ key: 'sleepAwake', label: '夜间清醒', unit: '分钟', transform: SECONDS_TO_MINUTES },
|
||||
{ key: 'sleepSpo2Avg', label: '睡眠血氧', unit: '%', decimals: 1 },
|
||||
{ key: 'sleepRespirationAvg', label: '睡眠呼吸', unit: '次/分', decimals: 1 },
|
||||
{ key: 'sleepStressAvg', label: '睡眠压力', decimals: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '血氧与呼吸',
|
||||
fields: [
|
||||
{ key: 'spo2Avg', label: '平均血氧', unit: '%', decimals: 1 },
|
||||
{ key: 'spo2Min', label: '最低血氧', unit: '%' },
|
||||
{ key: 'respirationAvg', label: '平均呼吸', unit: '次/分', decimals: 1 },
|
||||
{ key: 'respirationMin', label: '最低呼吸', unit: '次/分', decimals: 1 },
|
||||
{ key: 'respirationMax', label: '最高呼吸', unit: '次/分', decimals: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '训练',
|
||||
fields: [
|
||||
{ key: 'trainingReadiness', label: '训练准备度', unit: '/100' },
|
||||
{ key: 'vo2max', label: 'VO2max', decimals: 1 },
|
||||
{ key: 'enduranceScore', label: '耐力分' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const ACTIVITY_LABEL: Record<string, string> = {
|
||||
running: '跑步', cycling: '骑行', walking: '步行', hiking: '徒步',
|
||||
swimming: '游泳', table_tennis: '乒乓球', strength_training: '力量训练',
|
||||
indoor_cycling: '室内骑行', treadmill_running: '跑步机',
|
||||
};
|
||||
|
||||
function valueOf(day: HealthDay, key: Field['key']): number | null {
|
||||
if (key === 'sleepDeep') return day.sleep?.deepSeconds ?? null;
|
||||
if (key === 'sleepLight') return day.sleep?.lightSeconds ?? null;
|
||||
if (key === 'sleepRem') return day.sleep?.remSeconds ?? null;
|
||||
if (key === 'sleepAwake') return day.sleep?.awakeSeconds ?? null;
|
||||
const v = (day as any)[key];
|
||||
return typeof v === 'number' ? v : null;
|
||||
}
|
||||
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
function Daily() {
|
||||
const [date, setDate] = useState(() => iso(new Date()));
|
||||
const [day, setDay] = useState<HealthDay | null>(null);
|
||||
const [activities, setActivities] = useState<Activity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [onlyRecorded, setOnlyRecorded] = useState(true);
|
||||
|
||||
const load = useCallback(async (target: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [summary, acts] = await Promise.all([
|
||||
apiClient.getHealthSummary(target, target),
|
||||
apiClient.getActivities(target, target),
|
||||
]);
|
||||
setDay(summary[0] ?? null);
|
||||
setActivities(acts);
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '加载失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(date); }, [date, load]);
|
||||
|
||||
const shift = (delta: number) => {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + delta);
|
||||
if (d > new Date()) return;
|
||||
setDate(iso(d));
|
||||
};
|
||||
|
||||
const recorded = useMemo(() => {
|
||||
if (!day) return 0;
|
||||
return GROUPS.reduce(
|
||||
(n, g) => n + g.fields.filter((f) => valueOf(day, f.key) != null).length, 0
|
||||
);
|
||||
}, [day]);
|
||||
|
||||
const totalFields = GROUPS.reduce((n, g) => n + g.fields.length, 0);
|
||||
const isToday = date === iso(new Date());
|
||||
|
||||
const fmt = (f: Field, raw: number) => {
|
||||
const v = f.transform ? f.transform(raw) : raw;
|
||||
const decimals = f.decimals ?? 0;
|
||||
return v.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>每日数据</h2>
|
||||
<p className="subtitle">
|
||||
{day
|
||||
? `已记录 ${recorded} / ${totalFields} 项指标`
|
||||
: '该日无数据'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="day-nav">
|
||||
<button className="btn btn-plain" onClick={() => shift(-1)} aria-label="前一天">
|
||||
‹ 前一天
|
||||
</button>
|
||||
<input
|
||||
type="date"
|
||||
className="day-input"
|
||||
value={date}
|
||||
max={iso(new Date())}
|
||||
onChange={(e) => e.target.value && setDate(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-plain"
|
||||
onClick={() => shift(1)}
|
||||
disabled={isToday}
|
||||
aria-label="后一天"
|
||||
>
|
||||
后一天 ›
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{loading && <Skeleton count={8} variant="row" />}
|
||||
|
||||
{!loading && !error && !day && (
|
||||
<div className="empty-state">
|
||||
<p>{date} 没有数据。可能当天未佩戴设备,或尚未同步到这一天。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && day && (
|
||||
<>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyRecorded}
|
||||
onChange={(e) => setOnlyRecorded(e.target.checked)}
|
||||
/>
|
||||
只显示有数据的指标
|
||||
</label>
|
||||
|
||||
{GROUPS.map((g) => {
|
||||
const fields = onlyRecorded
|
||||
? g.fields.filter((f) => valueOf(day, f.key) != null)
|
||||
: g.fields;
|
||||
if (fields.length === 0) return null;
|
||||
return (
|
||||
<section className="section" key={g.title}>
|
||||
<h3 className="section-title">
|
||||
{g.title}
|
||||
<span className="section-count">{fields.length} 项</span>
|
||||
</h3>
|
||||
<dl className="metric-list">
|
||||
{fields.map((f) => {
|
||||
const raw = valueOf(day, f.key);
|
||||
return (
|
||||
<div className="metric-row" key={String(f.key)}>
|
||||
<dt>
|
||||
{f.label}
|
||||
{f.hint && <span className="metric-hint">{f.hint}</span>}
|
||||
</dt>
|
||||
<dd>
|
||||
{raw == null ? (
|
||||
<span className="metric-empty">未记录</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="metric-value">{fmt(f, raw)}</span>
|
||||
{f.unit && <span className="metric-unit">{f.unit}</span>}
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<section className="section">
|
||||
<h3 className="section-title">
|
||||
运动记录
|
||||
<span className="section-count">{activities.length} 条</span>
|
||||
</h3>
|
||||
{activities.length === 0 ? (
|
||||
<p className="placeholder">当天没有运动记录。</p>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">开始</th>
|
||||
<th scope="col">类型</th>
|
||||
<th scope="col">时长</th>
|
||||
<th scope="col">距离</th>
|
||||
<th scope="col">消耗</th>
|
||||
<th scope="col">平均心率</th>
|
||||
<th scope="col">最高心率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activities.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<th scope="row">{a.start_time?.slice(11, 16)}</th>
|
||||
<td>
|
||||
{ACTIVITY_LABEL[a.activity_type] ??
|
||||
a.activity_type?.replace(/_/g, ' ')}
|
||||
</td>
|
||||
<td className="num">
|
||||
{a.duration != null ? `${Math.round(a.duration / 60)} 分` : '—'}
|
||||
</td>
|
||||
<td className="num">
|
||||
{a.distance ? `${(a.distance / 1000).toFixed(2)} km` : '—'}
|
||||
</td>
|
||||
<td className="num">
|
||||
{a.calories != null ? Math.round(a.calories) : '—'}
|
||||
</td>
|
||||
<td className="num">{a.heart_rate_average ?? '—'}</td>
|
||||
<td className="num">{a.heart_rate_max ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Daily;
|
||||
@@ -3,6 +3,7 @@ import { apiClient, Activity, errorMessage, HealthDay } from '../services/api';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './Daily.css';
|
||||
import Screen from '../components/Screen';
|
||||
import { iso, today as todayIso } from '../lib/day';
|
||||
|
||||
/** Every stored metric, grouped the way the device groups them. */
|
||||
interface Field {
|
||||
@@ -109,10 +110,8 @@ function valueOf(day: HealthDay, key: Field['key']): number | null {
|
||||
return typeof v === 'number' ? v : null;
|
||||
}
|
||||
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
function DailyPage() {
|
||||
const [date, setDate] = useState(() => iso(new Date()));
|
||||
const [date, setDate] = useState(todayIso);
|
||||
const [day, setDay] = useState<HealthDay | null>(null);
|
||||
const [activities, setActivities] = useState<Activity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
||||
import Chart from '../components/charts/Chart';
|
||||
import MetricCard from '../components/charts/MetricCard';
|
||||
import MetricStrip from '../components/charts/MetricStrip';
|
||||
import Ring from '../components/charts/Ring';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import { useCountUp } from '../lib/motion';
|
||||
import './Dashboard.css';
|
||||
import './Pages.css';
|
||||
|
||||
const DAYS = 30;
|
||||
|
||||
/** MM-DD keeps the axis readable at 30 points. */
|
||||
const short = (iso: string) => iso.slice(5);
|
||||
|
||||
function avg(values: Array<number | null | undefined>): number | null {
|
||||
const present = values.filter((v): v is number => v != null);
|
||||
if (!present.length) return null;
|
||||
return present.reduce((a, b) => a + b, 0) / present.length;
|
||||
}
|
||||
|
||||
/** The one hero figure on this view: today's steps against the day's goal. */
|
||||
function StepHero({ today, history }: { today: HealthDay; history: HealthDay[] }) {
|
||||
const goal = today.stepGoal ?? null;
|
||||
const steps = today.steps ?? null;
|
||||
const progress = steps != null && goal ? steps / goal : null;
|
||||
const animated = useCountUp(steps);
|
||||
|
||||
const week = history.slice(-7).map((d) => d.steps).filter((v): v is number => v != null);
|
||||
const weekAvg = week.length
|
||||
? Math.round(week.reduce((a, b) => a + b, 0) / week.length)
|
||||
: null;
|
||||
|
||||
const remaining = steps != null && goal ? goal - steps : null;
|
||||
|
||||
return (
|
||||
<section className="hero">
|
||||
<Ring
|
||||
progress={progress}
|
||||
label={`步数完成度 ${progress != null ? Math.round(progress * 100) : 0}%`}
|
||||
>
|
||||
<span className="hero-value">
|
||||
{steps == null ? '—' : Math.round(animated ?? steps).toLocaleString()}
|
||||
</span>
|
||||
<span className="hero-caption">步</span>
|
||||
</Ring>
|
||||
|
||||
<div className="hero-facts">
|
||||
<div className="hero-headline">
|
||||
{progress == null
|
||||
? '今日暂无步数记录'
|
||||
: progress >= 1
|
||||
? '今日目标已完成'
|
||||
: `距目标还差 ${remaining!.toLocaleString()} 步`}
|
||||
</div>
|
||||
<dl className="hero-list">
|
||||
<div>
|
||||
<dt>目标</dt>
|
||||
<dd>{goal ? goal.toLocaleString() : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>近 7 日均</dt>
|
||||
<dd>{weekAvg ? weekAvg.toLocaleString() : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>完成度</dt>
|
||||
<dd>{progress != null ? `${Math.round(progress * 100)}%` : '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [days, setDays] = useState<HealthDay[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - (DAYS - 1) * 86400000);
|
||||
setDays(
|
||||
await apiClient.getHealthSummary(
|
||||
start.toISOString().slice(0, 10),
|
||||
end.toISOString().slice(0, 10)
|
||||
)
|
||||
);
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '加载数据失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>今日概览</h2>
|
||||
<div className="hero-skeleton" aria-hidden="true" />
|
||||
<Skeleton count={6} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>今日概览</h2>
|
||||
<div className="error-message">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (days.length === 0) {
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>今日概览</h2>
|
||||
<div className="empty-state">
|
||||
<p>还没有任何健康数据。</p>
|
||||
<Link to="/sync" className="btn btn-primary">去同步 Garmin 数据</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const today = days[days.length - 1];
|
||||
const rows = days.map((d) => ({ ...d, date: short(d.date) }));
|
||||
|
||||
const avgSteps = avg(days.map((d) => d.steps));
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>今日概览</h2>
|
||||
<p className="subtitle">{today.date} · 近 {days.length} 天数据</p>
|
||||
</div>
|
||||
<Link to="/trends" className="btn btn-plain">查看全部趋势 →</Link>
|
||||
</header>
|
||||
|
||||
<StepHero today={today} history={days} />
|
||||
|
||||
{/* Activity ---------------------------------------------------------- */}
|
||||
<section className="section">
|
||||
<h3 className="section-title">活动</h3>
|
||||
<div className="mcard-grid">
|
||||
<MetricCard
|
||||
metric="steps"
|
||||
label="步数"
|
||||
value={today.steps}
|
||||
unit="步"
|
||||
trend={days.map((d) => d.steps)}
|
||||
/>
|
||||
<MetricCard
|
||||
metric="intensityMinutes"
|
||||
label="强度分钟"
|
||||
value={today.intensityMinutes}
|
||||
unit="分钟"
|
||||
trend={days.map((d) => d.intensityMinutes)}
|
||||
/>
|
||||
<MetricCard
|
||||
metric="floorsAscended"
|
||||
label="爬楼"
|
||||
value={today.floorsAscended}
|
||||
unit="层"
|
||||
trend={days.map((d) => d.floorsAscended)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="距离"
|
||||
value={today.distanceMeters != null ? today.distanceMeters / 1000 : null}
|
||||
unit="km"
|
||||
decimals={2}
|
||||
trend={days.map((d) => d.distanceMeters)}
|
||||
detail={
|
||||
today.caloriesBurned != null
|
||||
? `消耗 ${Math.round(today.caloriesBurned).toLocaleString()} kcal`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Heart & stress ---------------------------------------------------- */}
|
||||
<section className="section">
|
||||
<h3 className="section-title">心率与压力</h3>
|
||||
<div className="mcard-grid">
|
||||
<MetricCard
|
||||
metric="heartRate"
|
||||
label="静息心率"
|
||||
value={today.heartRate}
|
||||
unit="bpm"
|
||||
trend={days.map((d) => d.heartRate)}
|
||||
/>
|
||||
<MetricCard
|
||||
metric="heartRateVariability"
|
||||
label="心率变异性"
|
||||
value={today.heartRateVariability}
|
||||
unit="ms"
|
||||
decimals={1}
|
||||
trend={days.map((d) => d.heartRateVariability)}
|
||||
/>
|
||||
<MetricCard
|
||||
metric="stress"
|
||||
label="平均压力"
|
||||
value={today.stress}
|
||||
trend={days.map((d) => d.stress)}
|
||||
/>
|
||||
<MetricCard
|
||||
metric="trainingReadiness"
|
||||
label="训练准备度"
|
||||
value={today.trainingReadiness}
|
||||
unit="/100"
|
||||
trend={days.map((d) => d.trainingReadiness)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sleep & breathing -------------------------------------------------- */}
|
||||
<section className="section">
|
||||
<h3 className="section-title">睡眠与呼吸</h3>
|
||||
<div className="mcard-grid">
|
||||
<MetricCard
|
||||
metric="sleepDuration"
|
||||
label="睡眠时长"
|
||||
value={today.sleepDuration}
|
||||
unit="小时"
|
||||
decimals={1}
|
||||
trend={days.map((d) => d.sleepDuration)}
|
||||
/>
|
||||
<MetricCard
|
||||
metric="sleepQuality"
|
||||
label="睡眠评分"
|
||||
value={today.sleepQuality}
|
||||
unit="/100"
|
||||
trend={days.map((d) => d.sleepQuality)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ height: '0.75rem' }} />
|
||||
|
||||
<MetricStrip
|
||||
title="身体指标"
|
||||
items={[
|
||||
{ metric: 'heartRateVariability', icon: '💓', label: 'HRV',
|
||||
value: today.heartRateVariability, unit: 'ms' },
|
||||
{ metric: 'heartRate', icon: '❤️', label: '静息心率',
|
||||
value: today.heartRate, unit: 'bpm' },
|
||||
{ metric: 'respirationAvg', icon: '🫁', label: '呼吸',
|
||||
value: today.respirationAvg, unit: '次/分', decimals: 1 },
|
||||
{ metric: 'spo2Avg', icon: '🩸', label: '血氧',
|
||||
value: today.spo2Avg, unit: '%' },
|
||||
{ metric: 'bodyBatteryHigh', icon: '🔋', label: '身体电量',
|
||||
value: today.bodyBatteryHigh, unit: '峰值' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<p className="section-link">
|
||||
<Link to="/sleep">查看睡眠分期详情 →</Link>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Trends ------------------------------------------------------------- */}
|
||||
<section className="section">
|
||||
<h3 className="section-title">近 {days.length} 天趋势</h3>
|
||||
<div className="chart-grid">
|
||||
<Chart
|
||||
title="步数"
|
||||
subtitle={avgSteps != null ? `日均 ${Math.round(avgSteps).toLocaleString()} 步` : undefined}
|
||||
data={rows}
|
||||
type="bar"
|
||||
series={[{ key: 'steps', label: '步数', slot: 1, unit: '步' }]}
|
||||
/>
|
||||
<Chart
|
||||
title="心率"
|
||||
unit="bpm"
|
||||
data={rows}
|
||||
type="line"
|
||||
series={[
|
||||
{ key: 'heartRate', label: '静息', slot: 1, unit: 'bpm' },
|
||||
{ key: 'heartRateMax', label: '最高', slot: 2, unit: 'bpm' },
|
||||
]}
|
||||
/>
|
||||
<Chart
|
||||
title="睡眠时长"
|
||||
unit="小时"
|
||||
data={rows}
|
||||
type="area"
|
||||
series={[{ key: 'sleepDuration', label: '睡眠', slot: 1, unit: '小时', decimals: 1 }]}
|
||||
/>
|
||||
<Chart
|
||||
title="身体电量"
|
||||
data={rows}
|
||||
type="line"
|
||||
series={[
|
||||
{ key: 'bodyBatteryHigh', label: '最高', slot: 1 },
|
||||
{ key: 'bodyBatteryLow', label: '最低', slot: 2 },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
apiClient, Activity, Badge, errorMessage, HealthDay, PersonalRecord,
|
||||
} from '../services/api';
|
||||
import Screen from '../components/Screen';
|
||||
import { daysAgo, today as todayIso } from '../lib/day';
|
||||
import MetricCard from '../components/charts/MetricCard';
|
||||
import Chart from '../components/charts/Chart';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
@@ -50,14 +51,12 @@ function ExercisePage() {
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - (WINDOW_DAYS - 1) * 86400000);
|
||||
const [a, r, b, d] = await Promise.all([
|
||||
apiClient.getActivities(),
|
||||
apiClient.getPersonalRecords(),
|
||||
apiClient.getBadges(),
|
||||
apiClient.getHealthSummary(
|
||||
start.toISOString().slice(0, 10), end.toISOString().slice(0, 10)
|
||||
daysAgo(WINDOW_DAYS - 1), todayIso()
|
||||
),
|
||||
]);
|
||||
setActivities(a);
|
||||
@@ -74,8 +73,7 @@ function ExercisePage() {
|
||||
}, []);
|
||||
|
||||
const recent = useMemo(() => {
|
||||
const cutoff = new Date(Date.now() - WINDOW_DAYS * 86400000)
|
||||
.toISOString().slice(0, 10);
|
||||
const cutoff = daysAgo(WINDOW_DAYS);
|
||||
return activities.filter((a) => (a.start_time ?? '') >= cutoff);
|
||||
}, [activities]);
|
||||
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
||||
import MetricCard from '../components/charts/MetricCard';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './Pages.css';
|
||||
|
||||
const WINDOW_DAYS = 30;
|
||||
|
||||
interface Item {
|
||||
metric?: string;
|
||||
label: string;
|
||||
pick: (d: HealthDay) => number | null;
|
||||
unit?: string;
|
||||
decimals?: number;
|
||||
detail?: (d: HealthDay) => string | undefined;
|
||||
}
|
||||
|
||||
const SECTIONS: Array<{ title: string; items: Item[] }> = [
|
||||
{
|
||||
title: '身体指标',
|
||||
items: [
|
||||
{ metric: 'heartRate', label: '静息心率', pick: (d) => d.heartRate, unit: 'bpm' },
|
||||
{
|
||||
metric: 'heartRateVariability', label: '心率变异性',
|
||||
pick: (d) => d.heartRateVariability, unit: 'ms', decimals: 1,
|
||||
},
|
||||
{ metric: 'respirationAvg', label: '呼吸频率', pick: (d) => d.respirationAvg, unit: '次/分', decimals: 1 },
|
||||
{ metric: 'spo2Avg', label: '血氧', pick: (d) => d.spo2Avg, unit: '%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '恢复',
|
||||
items: [
|
||||
{ metric: 'bodyBatteryHigh', label: '身体电量峰值', pick: (d) => d.bodyBatteryHigh },
|
||||
{ metric: 'stress', label: '平均压力', pick: (d) => d.stress },
|
||||
{ metric: 'trainingReadiness', label: '训练准备度', pick: (d) => d.trainingReadiness, unit: '/100' },
|
||||
{ label: '耐力分', pick: (d) => d.enduranceScore },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '睡眠',
|
||||
items: [
|
||||
{ metric: 'sleepDuration', label: '睡眠时长', pick: (d) => d.sleepDuration, unit: '小时', decimals: 1 },
|
||||
{ metric: 'sleepQuality', label: '睡眠评分', pick: (d) => d.sleepQuality, unit: '/100' },
|
||||
{
|
||||
label: '深睡占比', unit: '%',
|
||||
pick: (d) =>
|
||||
d.sleep?.deepSeconds != null && d.sleepDuration
|
||||
? (d.sleep.deepSeconds / 3600 / d.sleepDuration) * 100
|
||||
: null,
|
||||
decimals: 0,
|
||||
},
|
||||
{
|
||||
label: 'REM 占比', unit: '%',
|
||||
pick: (d) =>
|
||||
d.sleep?.remSeconds != null && d.sleepDuration
|
||||
? (d.sleep.remSeconds / 3600 / d.sleepDuration) * 100
|
||||
: null,
|
||||
decimals: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '活动',
|
||||
items: [
|
||||
{ metric: 'steps', label: '步数', pick: (d) => d.steps, unit: '步' },
|
||||
{ metric: 'intensityMinutes', label: '强度分钟', pick: (d) => d.intensityMinutes, unit: '分钟' },
|
||||
{ metric: 'floorsAscended', label: '爬楼', pick: (d) => d.floorsAscended, unit: '层' },
|
||||
{
|
||||
label: '距离', unit: 'km', decimals: 2,
|
||||
pick: (d) => (d.distanceMeters != null ? d.distanceMeters / 1000 : null),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '能量',
|
||||
items: [
|
||||
{ label: '总消耗', pick: (d) => d.caloriesBurned, unit: 'kcal' },
|
||||
{ label: '活动消耗', pick: (d) => d.activeCalories, unit: 'kcal' },
|
||||
{ label: '基础代谢', pick: (d) => d.bmrCalories, unit: 'kcal' },
|
||||
{
|
||||
label: '久坐', unit: '小时', decimals: 1,
|
||||
pick: (d) => (d.sedentarySeconds != null ? d.sedentarySeconds / 3600 : null),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function Health() {
|
||||
const [days, setDays] = useState<HealthDay[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - (WINDOW_DAYS - 1) * 86400000);
|
||||
setDays(
|
||||
await apiClient.getHealthSummary(
|
||||
start.toISOString().slice(0, 10),
|
||||
end.toISOString().slice(0, 10)
|
||||
)
|
||||
);
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '加载失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>健康</h2>
|
||||
<Skeleton count={8} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>健康</h2>
|
||||
<div className="error-message">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (days.length === 0) {
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>健康</h2>
|
||||
<div className="empty-state">
|
||||
<p>还没有任何健康数据。</p>
|
||||
<Link to="/sync" className="btn btn-primary">去同步 Garmin 数据</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The most recent day that actually recorded a given metric — showing "—"
|
||||
// because today's sleep has not synced yet would hide data that exists.
|
||||
const latest = (pick: (d: HealthDay) => number | null) => {
|
||||
for (let i = days.length - 1; i >= 0; i--) {
|
||||
const v = pick(days[i]);
|
||||
if (v != null) return { value: v, date: days[i].date };
|
||||
}
|
||||
return { value: null, date: null };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>健康</h2>
|
||||
<p className="subtitle">每项指标的最新值与参考区间</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{SECTIONS.map((section) => (
|
||||
<section className="section" key={section.title}>
|
||||
<h3 className="section-title">{section.title}</h3>
|
||||
<div className="mcard-grid">
|
||||
{section.items.map((item) => {
|
||||
const { value, date } = latest(item.pick);
|
||||
const stale = date != null && date !== days[days.length - 1].date;
|
||||
return (
|
||||
<MetricCard
|
||||
key={item.label}
|
||||
metric={item.metric}
|
||||
label={item.label}
|
||||
value={value}
|
||||
unit={item.unit}
|
||||
decimals={item.decimals}
|
||||
trend={days.map(item.pick)}
|
||||
detail={stale ? `最近记录 ${date!.slice(5)}` : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<p className="disclaimer">
|
||||
参考区间为一般人群的定位范围,非诊断标准。如有健康疑问请咨询专业医师。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Health;
|
||||
@@ -5,6 +5,7 @@ import { METRICS, metricHref } from '../lib/metrics';
|
||||
import MetricCard from '../components/charts/MetricCard';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import Screen from '../components/Screen';
|
||||
import { daysAgo, today as todayIso } from '../lib/day';
|
||||
import './Health.css';
|
||||
|
||||
const WINDOW_DAYS = 30;
|
||||
@@ -73,12 +74,9 @@ function HealthPage() {
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - (WINDOW_DAYS - 1) * 86400000);
|
||||
setDays(
|
||||
await apiClient.getHealthSummary(
|
||||
start.toISOString().slice(0, 10),
|
||||
end.toISOString().slice(0, 10)
|
||||
daysAgo(WINDOW_DAYS - 1), todayIso()
|
||||
)
|
||||
);
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiClient, errorMessage, HealthDay, RatingBasis } from '../services/api
|
||||
import { METRICS } from '../lib/metrics';
|
||||
import { classify, formatTarget, RANGES } from '../lib/ranges';
|
||||
import Screen from '../components/Screen';
|
||||
import { daysAgo, today as todayIso } from '../lib/day';
|
||||
import Chart from '../components/charts/Chart';
|
||||
import BandBar from '../components/charts/BandBar';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
@@ -37,10 +38,8 @@ function MetricDetailPage({ id, f7route }: Props) {
|
||||
setLoading(true);
|
||||
const load = async () => {
|
||||
try {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - (window - 1) * 86400000);
|
||||
const rows = await apiClient.getHealthSummary(
|
||||
start.toISOString().slice(0, 10), end.toISOString().slice(0, 10)
|
||||
daysAgo(window - 1), todayIso()
|
||||
);
|
||||
if (!cancelled) setDays(rows);
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '../services/api';
|
||||
import { FEATURES } from '../features';
|
||||
import Screen from '../components/Screen';
|
||||
import { today as todayIso } from '../lib/day';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './Settings.css';
|
||||
|
||||
@@ -145,7 +146,7 @@ function SettingsPage() {
|
||||
className="set-input"
|
||||
type="date"
|
||||
value={s.birthDate ?? ''}
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
max={todayIso()}
|
||||
onChange={(e) => save({ birthDate: e.target.value || null })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
||||
import Chart from '../components/charts/Chart';
|
||||
import StatTile from '../components/charts/StatTile';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './Pages.css';
|
||||
|
||||
const RANGES = [7, 14, 30, 90];
|
||||
const H = 3600;
|
||||
|
||||
function avg(values: Array<number | null | undefined>): number | null {
|
||||
const present = values.filter((v): v is number => v != null);
|
||||
return present.length ? present.reduce((a, b) => a + b, 0) / present.length : null;
|
||||
}
|
||||
|
||||
function Sleep() {
|
||||
const [days, setDays] = useState<HealthDay[]>([]);
|
||||
// 14 by default: the stacked chart needs bars wide enough to read the
|
||||
// thinnest stage and to give hover a ~24px hit target. Longer windows stay
|
||||
// available for the trend, where density matters less.
|
||||
const [range, setRange] = useState(14);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - (range - 1) * 86400000);
|
||||
setDays(
|
||||
await apiClient.getHealthSummary(
|
||||
start.toISOString().slice(0, 10),
|
||||
end.toISOString().slice(0, 10)
|
||||
)
|
||||
);
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '加载睡眠数据失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [range]);
|
||||
|
||||
const nights = days.filter((d) => d.sleepDuration != null);
|
||||
|
||||
// Stage seconds are converted to hours here so the stacked bar and the
|
||||
// duration chart share one y-scale — a chart never carries two scales.
|
||||
const rows = nights.map((d) => ({
|
||||
date: d.date.slice(5),
|
||||
deep: d.sleep?.deepSeconds != null ? d.sleep.deepSeconds / H : null,
|
||||
light: d.sleep?.lightSeconds != null ? d.sleep.lightSeconds / H : null,
|
||||
rem: d.sleep?.remSeconds != null ? d.sleep.remSeconds / H : null,
|
||||
awake: d.sleep?.awakeSeconds != null ? d.sleep.awakeSeconds / H : null,
|
||||
quality: d.sleepQuality,
|
||||
spo2: d.sleepSpo2Avg,
|
||||
respiration: d.sleepRespirationAvg,
|
||||
stress: d.sleepStressAvg,
|
||||
}));
|
||||
|
||||
const avgDeep = avg(rows.map((r) => r.deep));
|
||||
const avgRem = avg(rows.map((r) => r.rem));
|
||||
const avgLight = avg(rows.map((r) => r.light));
|
||||
const avgAwake = avg(rows.map((r) => r.awake));
|
||||
const avgDuration = avg(nights.map((d) => d.sleepDuration));
|
||||
const avgQuality = avg(nights.map((d) => d.sleepQuality));
|
||||
|
||||
const totalStages = [avgDeep, avgLight, avgRem].reduce<number>(
|
||||
(sum, v) => sum + (v ?? 0), 0
|
||||
);
|
||||
const share = (v: number | null) =>
|
||||
v == null || totalStages === 0 ? undefined : `占 ${Math.round((v / totalStages) * 100)}%`;
|
||||
|
||||
const hrs = (v: number | null, d = 1) => (v == null ? null : Math.round(v * 10 ** d) / 10 ** d);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>睡眠</h2>
|
||||
<p className="subtitle">分期、评分与夜间生理指标</p>
|
||||
</div>
|
||||
<div className="range-tabs">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
className={`range-tab ${r === range ? 'active' : ''}`}
|
||||
onClick={() => setRange(r)}
|
||||
>
|
||||
{r} 天
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{loading && (
|
||||
<>
|
||||
<Skeleton count={6} />
|
||||
<div style={{ height: '1rem' }} />
|
||||
<Skeleton count={2} variant="chart" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !error && nights.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>所选区间内没有睡眠记录。</p>
|
||||
<Link to="/sync" className="btn btn-primary">去同步数据</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && nights.length > 0 && (
|
||||
<>
|
||||
<section className="section">
|
||||
<h3 className="section-title">{nights.length} 晚平均</h3>
|
||||
<div className="tile-grid">
|
||||
<StatTile label="总时长" value={hrs(avgDuration)} unit="小时" />
|
||||
<StatTile label="睡眠评分" value={hrs(avgQuality, 0)} unit="/100" />
|
||||
<StatTile label="深睡" value={hrs(avgDeep)} unit="小时" detail={share(avgDeep)} />
|
||||
<StatTile label="浅睡" value={hrs(avgLight)} unit="小时" detail={share(avgLight)} />
|
||||
<StatTile label="REM" value={hrs(avgRem)} unit="小时" detail={share(avgRem)} />
|
||||
<StatTile label="夜间清醒" value={hrs(avgAwake)} unit="小时" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="chart-grid one-col">
|
||||
<Chart
|
||||
title="睡眠分期"
|
||||
unit="小时"
|
||||
subtitle="每晚各阶段时长堆叠;总高度即当晚睡眠总时长"
|
||||
data={rows}
|
||||
type="stacked-bar"
|
||||
height={280}
|
||||
series={[
|
||||
{ key: 'deep', label: '深睡', slot: 1, unit: '小时', decimals: 1 },
|
||||
{ key: 'light', label: '浅睡', slot: 2, unit: '小时', decimals: 1 },
|
||||
{ key: 'rem', label: 'REM', slot: 3, unit: '小时', decimals: 1 },
|
||||
{ key: 'awake', label: '清醒', slot: 4, unit: '小时', decimals: 1 },
|
||||
]}
|
||||
footer="成人参考:深睡约占 13–23%,REM 约占 20–25%。"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="chart-grid">
|
||||
<Chart
|
||||
title="睡眠评分"
|
||||
unit="/100"
|
||||
data={rows}
|
||||
type="area"
|
||||
series={[{ key: 'quality', label: '评分', slot: 1 }]}
|
||||
/>
|
||||
<Chart
|
||||
title="夜间血氧"
|
||||
unit="%"
|
||||
data={rows}
|
||||
type="line"
|
||||
series={[{ key: 'spo2', label: '血氧', slot: 1, unit: '%', decimals: 1 }]}
|
||||
/>
|
||||
<Chart
|
||||
title="夜间呼吸频率"
|
||||
unit="次/分"
|
||||
data={rows}
|
||||
type="line"
|
||||
series={[
|
||||
{ key: 'respiration', label: '呼吸', slot: 1, unit: '次/分', decimals: 1 },
|
||||
]}
|
||||
/>
|
||||
<Chart
|
||||
title="睡眠压力"
|
||||
data={rows}
|
||||
type="line"
|
||||
series={[{ key: 'stress', label: '压力', slot: 1, decimals: 1 }]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sleep;
|
||||
@@ -5,6 +5,7 @@ import Chart from '../components/charts/Chart';
|
||||
import StatTile from '../components/charts/StatTile';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import Screen from '../components/Screen';
|
||||
import { daysAgo, today as todayIso } from '../lib/day';
|
||||
|
||||
const RANGES = [7, 14, 30, 90];
|
||||
const H = 3600;
|
||||
@@ -27,12 +28,9 @@ function SleepPage() {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - (range - 1) * 86400000);
|
||||
setDays(
|
||||
await apiClient.getHealthSummary(
|
||||
start.toISOString().slice(0, 10),
|
||||
end.toISOString().slice(0, 10)
|
||||
daysAgo(range - 1), todayIso()
|
||||
)
|
||||
);
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import Skeleton from '../components/Skeleton';
|
||||
import { useCountUp } from '../lib/motion';
|
||||
import { RANGES } from '../lib/ranges';
|
||||
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. */
|
||||
@@ -125,10 +126,6 @@ const SECTIONS: Array<{ title: string; items: string[] }> = [
|
||||
{ title: '睡眠', items: ['sleepDuration', 'sleepQuality'] },
|
||||
];
|
||||
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const shift = (date: string, days: number) =>
|
||||
iso(new Date(new Date(`${date}T12:00:00`).getTime() + days * 86400000));
|
||||
|
||||
function TodayPage() {
|
||||
const [days, setDays] = useState<HealthDay[]>([]);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
@@ -140,9 +137,9 @@ function TodayPage() {
|
||||
try {
|
||||
// A year in one read: browsing back a day should not cost a request,
|
||||
// and the cards' sparklines need the surrounding days anyway.
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - (HISTORY_DAYS - 1) * 86400000);
|
||||
setDays(await apiClient.getHealthSummary(iso(start), iso(end)));
|
||||
setDays(await apiClient.getHealthSummary(
|
||||
daysAgo(HISTORY_DAYS - 1), todayIso()
|
||||
));
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '加载数据失败'));
|
||||
} finally {
|
||||
@@ -167,7 +164,7 @@ function TodayPage() {
|
||||
|
||||
const go = (delta: number) => {
|
||||
if (!date) return;
|
||||
const target = shift(date, delta);
|
||||
const target = shiftDay(date, delta);
|
||||
// Days with no record at all are skipped over rather than shown blank.
|
||||
const nearest = delta < 0
|
||||
? [...days].reverse().find((d) => d.date <= target)
|
||||
@@ -196,7 +193,7 @@ function TodayPage() {
|
||||
calendar.open();
|
||||
};
|
||||
|
||||
const isToday = date === iso(new Date());
|
||||
const isToday = date === todayIso();
|
||||
const weekday = date
|
||||
? ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][
|
||||
new Date(`${date}T12:00:00`).getDay()
|
||||
@@ -206,7 +203,7 @@ function TodayPage() {
|
||||
const open = (id: string) => f7.views.current.router.navigate(metricHref(id));
|
||||
|
||||
return (
|
||||
<Screen title={isToday ? '今日' : '每日数据'} subtitle={date ?? undefined}>
|
||||
<Screen title={!date || isToday ? '今日' : '每日数据'} subtitle={date ?? undefined}>
|
||||
{loading && (
|
||||
<>
|
||||
<div className="hero-skeleton" aria-hidden="true" />
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
||||
import Chart, { Series } from '../components/charts/Chart';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import {
|
||||
aggregate, Granularity, GRANULARITIES, isCumulative, suggestGranularity,
|
||||
} from '../lib/aggregate';
|
||||
import './Pages.css';
|
||||
|
||||
const RANGES = [
|
||||
{ days: 30, label: '近一月' },
|
||||
{ days: 91, label: '近一季' },
|
||||
{ days: 182, label: '近半年' },
|
||||
{ days: 365, label: '近一年' },
|
||||
{ days: 730, label: '近两年' },
|
||||
];
|
||||
|
||||
const HIDDEN_KEY = 'ghl_hidden_metrics';
|
||||
|
||||
/** Each group is one chart. Metrics only share a chart when they share a
|
||||
* scale and a unit — a chart never carries two y-scales. */
|
||||
interface MetricGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
unit?: string;
|
||||
type: 'line' | 'bar' | 'area';
|
||||
series: Series[];
|
||||
/** Optional transform, e.g. metres to kilometres. */
|
||||
scale?: Record<string, number>;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
const GROUPS: MetricGroup[] = [
|
||||
{
|
||||
id: 'steps', label: '步数', unit: '步', type: 'bar',
|
||||
series: [{ key: 'steps', label: '步数', slot: 1, unit: '步' }],
|
||||
},
|
||||
{
|
||||
id: 'distance', label: '距离', unit: 'km', type: 'bar',
|
||||
scale: { distanceMeters: 1 / 1000 },
|
||||
series: [{ key: 'distanceMeters', label: '距离', slot: 1, unit: 'km', decimals: 2 }],
|
||||
},
|
||||
{
|
||||
id: 'calories', label: '能量消耗', unit: 'kcal', type: 'bar',
|
||||
series: [
|
||||
{ key: 'bmrCalories', label: '基础代谢', slot: 1, unit: 'kcal' },
|
||||
{ key: 'activeCalories', label: '活动消耗', slot: 2, unit: 'kcal' },
|
||||
],
|
||||
note: '两者相加即当日总消耗。',
|
||||
},
|
||||
{
|
||||
id: 'heart', label: '心率', unit: 'bpm', type: 'line',
|
||||
series: [
|
||||
{ key: 'heartRate', label: '静息', slot: 1, unit: 'bpm' },
|
||||
{ key: 'heartRateMax', label: '最高', slot: 2, unit: 'bpm' },
|
||||
{ key: 'heartRateMin', label: '最低', slot: 3, unit: 'bpm' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hrv', label: '心率变异性', unit: 'ms', type: 'area',
|
||||
series: [{ key: 'heartRateVariability', label: 'HRV', slot: 1, unit: 'ms', decimals: 1 }],
|
||||
note: 'HRV 反映自主神经恢复情况,持续偏低常与压力或训练过量相关。',
|
||||
},
|
||||
{
|
||||
id: 'stress', label: '压力', type: 'line',
|
||||
series: [
|
||||
{ key: 'stress', label: '平均', slot: 1 },
|
||||
{ key: 'stressMax', label: '峰值', slot: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'battery', label: '身体电量', type: 'line',
|
||||
series: [
|
||||
{ key: 'bodyBatteryHigh', label: '最高', slot: 1 },
|
||||
{ key: 'bodyBatteryLow', label: '最低', slot: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sleep', label: '睡眠时长', unit: '小时', type: 'area',
|
||||
series: [{ key: 'sleepDuration', label: '时长', slot: 1, unit: '小时', decimals: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'spo2', label: '血氧', unit: '%', type: 'line',
|
||||
series: [
|
||||
{ key: 'spo2Avg', label: '平均', slot: 1, unit: '%', decimals: 1 },
|
||||
{ key: 'spo2Min', label: '最低', slot: 2, unit: '%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'respiration', label: '呼吸频率', unit: '次/分', type: 'line',
|
||||
series: [
|
||||
{ key: 'respirationAvg', label: '平均', slot: 1, unit: '次/分', decimals: 1 },
|
||||
{ key: 'respirationMax', label: '最高', slot: 2, unit: '次/分', decimals: 1 },
|
||||
{ key: 'respirationMin', label: '最低', slot: 3, unit: '次/分', decimals: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'floors', label: '爬楼', unit: '层', type: 'bar',
|
||||
series: [{ key: 'floorsAscended', label: '上行', slot: 1, unit: '层' }],
|
||||
},
|
||||
{
|
||||
id: 'intensity', label: '强度分钟', unit: '分钟', type: 'bar',
|
||||
series: [{ key: 'intensityMinutes', label: '强度分钟', slot: 1, unit: '分钟' }],
|
||||
},
|
||||
{
|
||||
id: 'sedentary', label: '久坐与活动时长', unit: '小时', type: 'bar',
|
||||
scale: { sedentarySeconds: 1 / 3600, activeSeconds: 1 / 3600 },
|
||||
series: [
|
||||
{ key: 'sedentarySeconds', label: '久坐', slot: 1, unit: '小时', decimals: 1 },
|
||||
{ key: 'activeSeconds', label: '活动', slot: 2, unit: '小时', decimals: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'training', label: '训练准备度', unit: '/100', type: 'area',
|
||||
series: [{ key: 'trainingReadiness', label: '准备度', slot: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'endurance', label: '耐力分', type: 'area',
|
||||
series: [{ key: 'enduranceScore', label: '耐力分', slot: 1 }],
|
||||
},
|
||||
];
|
||||
|
||||
function summarise(values: Array<number | null | undefined>) {
|
||||
const present = values.filter((v): v is number => v != null);
|
||||
if (!present.length) return null;
|
||||
const sorted = [...present].sort((a, b) => a - b);
|
||||
const mean = present.reduce((a, b) => a + b, 0) / present.length;
|
||||
const mid = Math.floor(present.length / 2);
|
||||
const delta =
|
||||
present.length > 1
|
||||
? present.slice(mid).reduce((a, b) => a + b, 0) / (present.length - mid) -
|
||||
present.slice(0, mid).reduce((a, b) => a + b, 0) / Math.max(mid, 1)
|
||||
: 0;
|
||||
return { mean, min: sorted[0], max: sorted[sorted.length - 1], delta };
|
||||
}
|
||||
|
||||
const fmt = (v: number) => {
|
||||
const abs = Math.abs(v);
|
||||
const decimals = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
|
||||
return v.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
};
|
||||
|
||||
function Trends() {
|
||||
const [days, setDays] = useState<HealthDay[]>([]);
|
||||
const [range, setRange] = useState(365);
|
||||
const [granularity, setGranularity] = useState<Granularity | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Which charts are hidden. Persisted: a selection that resets on every
|
||||
// reload is not really a preference.
|
||||
const [hidden, setHidden] = useState<Set<string>>(() => {
|
||||
try {
|
||||
return new Set<string>(JSON.parse(localStorage.getItem(HIDDEN_KEY) || '[]'));
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(HIDDEN_KEY, JSON.stringify([...hidden]));
|
||||
}, [hidden]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - (range - 1) * 86400000);
|
||||
setDays(
|
||||
await apiClient.getHealthSummary(
|
||||
start.toISOString().slice(0, 10),
|
||||
end.toISOString().slice(0, 10)
|
||||
)
|
||||
);
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '加载失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [range]);
|
||||
|
||||
const effective = granularity ?? suggestGranularity(days.length);
|
||||
const visible = GROUPS.filter((g) => !hidden.has(g.id));
|
||||
|
||||
// Aggregated once for every metric, so all charts read the same slice — a
|
||||
// filter row that scoped only some of them would be misleading.
|
||||
const allKeys = useMemo(() => GROUPS.flatMap((g) => g.series.map((s) => s.key)), []);
|
||||
const buckets = useMemo(
|
||||
() => aggregate(days, effective, allKeys),
|
||||
[days, effective, allKeys]
|
||||
);
|
||||
|
||||
const rowsFor = (group: MetricGroup) =>
|
||||
buckets.map((b) => {
|
||||
const row: Record<string, any> = { date: b.label };
|
||||
for (const s of group.series) {
|
||||
const raw = b.values[s.key];
|
||||
const factor = group.scale?.[s.key];
|
||||
row[s.key] = raw == null ? null : factor ? raw * factor : raw;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setHidden((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const granLabel =
|
||||
GRANULARITIES.find((g) => g.id === effective)?.label.replace('每', '') ?? '';
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>趋势</h2>
|
||||
<p className="subtitle">
|
||||
{visible.length} / {GROUPS.length} 组指标
|
||||
{days.length > 0 && ` · ${days.length} 天数据`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="control-stack">
|
||||
<div className="control-row">
|
||||
<span className="control-label">范围</span>
|
||||
<div className="range-tabs">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r.days}
|
||||
className={`range-tab ${r.days === range ? 'active' : ''}`}
|
||||
onClick={() => setRange(r.days)}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="control-row">
|
||||
<span className="control-label">周期</span>
|
||||
<div className="range-tabs">
|
||||
{GRANULARITIES.filter((g) => g.days <= Math.max(range / 2, 1)).map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
className={`range-tab ${g.id === effective ? 'active' : ''}`}
|
||||
onClick={() => setGranularity(g.id)}
|
||||
>
|
||||
{g.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="metric-picker">
|
||||
<div className="picker-head">
|
||||
<span className="control-label">显示</span>
|
||||
<div className="picker-actions">
|
||||
<button className="link-button" onClick={() => setHidden(new Set())}>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
className="link-button"
|
||||
onClick={() => setHidden(new Set(GROUPS.map((g) => g.id)))}
|
||||
>
|
||||
全不选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="picker-chips">
|
||||
{GROUPS.map((g) => {
|
||||
const on = !hidden.has(g.id);
|
||||
return (
|
||||
<button
|
||||
key={g.id}
|
||||
className={`chip ${on ? 'on' : 'off'}`}
|
||||
onClick={() => toggle(g.id)}
|
||||
aria-pressed={on}
|
||||
>
|
||||
{/* A mark, not colour alone, carries the on/off state. */}
|
||||
<span className="chip-mark" aria-hidden="true">{on ? '✓' : '+'}</span>
|
||||
{g.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{loading && <Skeleton count={6} variant="chart" />}
|
||||
|
||||
{!loading && !error && visible.length === 0 && (
|
||||
<p className="placeholder">所有指标都已隐藏,点上面的标签重新显示。</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && visible.length > 0 && (
|
||||
<div className="chart-grid">
|
||||
{visible.map((g) => {
|
||||
const rows = rowsFor(g);
|
||||
const primary = g.series[0];
|
||||
const s = summarise(rows.map((r) => r[primary.key]));
|
||||
/* Bars and areas both encode magnitude by extent, so both must
|
||||
start at zero — which makes a year of monthly step averages,
|
||||
all between 9.6k and 12.7k, render as near-identical shapes and
|
||||
hides exactly the change the reader came for. Once days are
|
||||
bucketed the question is "how is this trending", and that is a
|
||||
line's job: it encodes position rather than extent, so a
|
||||
non-zero axis is legitimate and the variation becomes visible.
|
||||
Dense daily views switch for the same reason plus hit size. */
|
||||
const aggregated = effective !== 'day';
|
||||
const type =
|
||||
aggregated || (g.type === 'bar' && rows.length > 90)
|
||||
? ('line' as const)
|
||||
: g.type;
|
||||
const meanWord =
|
||||
effective !== 'day' && isCumulative(primary.key) ? '日均' : '平均';
|
||||
|
||||
return (
|
||||
<Chart
|
||||
key={g.id}
|
||||
title={g.label}
|
||||
unit={g.unit}
|
||||
subtitle={
|
||||
effective === 'day'
|
||||
? undefined
|
||||
: `每点为一个${granLabel}周期的日均值,共 ${rows.length} 个周期`
|
||||
}
|
||||
data={rows}
|
||||
type={type}
|
||||
series={g.series}
|
||||
height={210}
|
||||
footer={
|
||||
s ? (
|
||||
<span className="chart-stats">
|
||||
<span>{meanWord} <b>{fmt(s.mean)}</b></span>
|
||||
<span>低 <b>{fmt(s.min)}</b></span>
|
||||
<span>高 <b>{fmt(s.max)}</b></span>
|
||||
<span>后半段 <b>{s.delta >= 0 ? '+' : ''}{fmt(s.delta)}</b></span>
|
||||
</span>
|
||||
) : (
|
||||
g.note
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Trends;
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
aggregate, Granularity, GRANULARITIES, isCumulative, suggestGranularity,
|
||||
} from '../lib/aggregate';
|
||||
import Screen from '../components/Screen';
|
||||
import { daysAgo, today as todayIso } from '../lib/day';
|
||||
|
||||
const RANGES = [
|
||||
{ days: 30, label: '近一月' },
|
||||
@@ -171,12 +172,9 @@ function TrendsPage() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - (range - 1) * 86400000);
|
||||
setDays(
|
||||
await apiClient.getHealthSummary(
|
||||
start.toISOString().slice(0, 10),
|
||||
end.toISOString().slice(0, 10)
|
||||
daysAgo(range - 1), todayIso()
|
||||
)
|
||||
);
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -483,7 +483,9 @@ class ApiClient {
|
||||
async getActivityDetail(activityId: string, refresh = false) {
|
||||
const { data } = await this.client.get<ActivityDetail>(
|
||||
`/garmin/activities/${activityId}/detail`,
|
||||
{ params: refresh ? { refresh: 1 } : {}, timeout: 60000 }
|
||||
// The first open goes out to Garmin for seven endpoints; only the
|
||||
// cached reads afterwards are fast.
|
||||
{ params: refresh ? { refresh: 1 } : {}, timeout: 120000 }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,18 @@
|
||||
|
||||
---
|
||||
|
||||
## 已修复的缺陷(实机验证发现)
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|---|---|---|
|
||||
| 部署后整站 Network Error | 构建时没带 `REACT_APP_API_URL`,打包进了开发默认值 localhost:5000 | 加 `client/.env.production`,写死 `/api`,不再依赖构建时手输 |
|
||||
| 凌晨打不开当天数据 | 各页用 `toISOString()` 取日期,那是 UTC。UTC+8 每天前 8 小时都在查昨天 | 新增 `lib/day.ts`,全部改用本地日历日期 |
|
||||
| 右上角/返回键的半透明椭圆 | Framework7 9 给 `.navbar .left/.right` 加了 frosted pill | 在 `f7theme.css` 覆盖掉 |
|
||||
| 运动详情四个 Tab 竖着排 | F7 把每个 `<button>` 渲染成整宽块元素 | `.metric-tab` 显式 `width: auto` |
|
||||
| 心率区间百分比偏高(区间1 显示 90%,手表是 52%) | 分母用了「落在区间内的总时长」,应为整次运动时长 | 改用运动时长,低于区间1 的时间不再被挤掉 |
|
||||
| 打开运动详情超时 60s | 每个请求都重新认证 Garmin,`_connect` 单次约 11 秒 | 按进程缓存已认证会话(15 分钟 TTL),冷启 16s → 热 7s → 命中缓存 0.8s |
|
||||
| 主要收益显示 UNKNOWN | Garmin 用 UNKNOWN 表示「没有结论」 | 映射为中文,UNKNOWN 直接不显示该区块 |
|
||||
|
||||
## 设计原则(已达成一致)
|
||||
|
||||
1. **AI 不定阈值。** 让模型现编「正常范围」会得到一个不可复现、无法追溯、却带着医学口吻的数字。所有参考区间来自公开来源(WHO、AASM、Garmin 官方分级、人群常模),AI 只做解读。
|
||||
|
||||
Reference in New Issue
Block a user