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}")
|
f"ON CONFLICT(user_id) DO UPDATE SET {updates}")
|
||||||
execute(sql, [user_id, token, garmin_email,
|
execute(sql, [user_id, token, garmin_email,
|
||||||
datetime.datetime.utcnow().isoformat(timespec="seconds")])
|
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):
|
def has_token(user_id):
|
||||||
return load_token(user_id) is not None
|
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):
|
def _connect(creds, user_id=None):
|
||||||
"""Obtain a logged-in Garmin client.
|
"""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
|
"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.
|
`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()
|
Garmin = _import_garmin()
|
||||||
client = Garmin(is_cn=_is_cn())
|
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
|
# garminconnect builds most of its URLs from display_name, so leaving
|
||||||
# it unset sends every request to ".../None".
|
# it unset sends every request to ".../None".
|
||||||
client.display_name = client.garth.profile["displayName"]
|
client.display_name = client.garth.profile["displayName"]
|
||||||
|
with _clients_lock:
|
||||||
|
_cache_client(user_id, client)
|
||||||
return client
|
return client
|
||||||
|
|
||||||
if not creds.get("garminPassword"):
|
if not creds.get("garminPassword"):
|
||||||
@@ -167,6 +207,8 @@ def _connect(creds, user_id=None):
|
|||||||
"系统会提示你输入验证码。"
|
"系统会提示你输入验证码。"
|
||||||
) from e
|
) from e
|
||||||
_use_api_user_agent(client)
|
_use_api_user_agent(client)
|
||||||
|
with _clients_lock:
|
||||||
|
_cache_client(user_id, client)
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
@@ -519,8 +561,10 @@ def _build_detail(client, activity_id):
|
|||||||
rest of the page intact rather than fail the request.
|
rest of the page intact rather than fail the request.
|
||||||
"""
|
"""
|
||||||
summary = _safe(lambda: client.get_activity_evaluation(activity_id), {}) or {}
|
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(
|
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 {}
|
) or {}
|
||||||
|
|
||||||
return {
|
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 {
|
.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;
|
padding: 0.34rem 0.8rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--surface-1);
|
background: var(--surface-1);
|
||||||
|
|||||||
@@ -144,3 +144,17 @@
|
|||||||
.nav-progress { transition: none; }
|
.nav-progress { transition: none; }
|
||||||
.nav-progress.on .nav-progress-bar { animation: none; transform: scaleX(0.9); }
|
.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 { HealthDay } from '../services/api';
|
||||||
|
import { iso } from './day';
|
||||||
|
|
||||||
export type Granularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
export type Granularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ export function aggregate(
|
|||||||
const groups = new Map<string, { start: Date; rows: HealthDay[] }>();
|
const groups = new Map<string, { start: Date; rows: HealthDay[] }>();
|
||||||
for (const row of days) {
|
for (const row of days) {
|
||||||
const start = startOfBucket(new Date(row.date), granularity, anchor);
|
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: [] });
|
if (!groups.has(key)) groups.set(key, { start, rows: [] });
|
||||||
groups.get(key)!.rows.push(row);
|
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: '健身器械',
|
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 ---------------------------------------------------------- */
|
/* --- formatting ---------------------------------------------------------- */
|
||||||
|
|
||||||
const hms = (seconds?: number | null) => {
|
const hms = (seconds?: number | null) => {
|
||||||
@@ -77,7 +87,7 @@ function statGroups(s: Record<string, any>) {
|
|||||||
['最高心率', num(s.maxHR, 0, 'bpm')],
|
['最高心率', num(s.maxHR, 0, 'bpm')],
|
||||||
]],
|
]],
|
||||||
['训练效果', [
|
['训练效果', [
|
||||||
['主要收益', s.trainingEffectLabel ?? '—'],
|
['主要收益', BENEFIT[s.trainingEffectLabel] ?? '—'],
|
||||||
['有氧', num(s.trainingEffect, 1)],
|
['有氧', num(s.trainingEffect, 1)],
|
||||||
['无氧', num(s.anaerobicTrainingEffect, 1)],
|
['无氧', num(s.anaerobicTrainingEffect, 1)],
|
||||||
['运动负荷', num(s.activityTrainingLoad, 0)],
|
['运动负荷', num(s.activityTrainingLoad, 0)],
|
||||||
@@ -124,9 +134,18 @@ function statGroups(s: Record<string, any>) {
|
|||||||
|
|
||||||
const ZONE_NAME = ['', '热身', '轻松', '有氧', '阈值', '最大'];
|
const ZONE_NAME = ['', '热身', '轻松', '有氧', '阈值', '最大'];
|
||||||
|
|
||||||
function Zones({ zones }: { zones: ActivityDetail['hrZones'] }) {
|
function Zones({ zones, duration }: {
|
||||||
const total = zones.reduce((sum, z) => sum + (z.seconds || 0), 0);
|
zones: ActivityDetail['hrZones'];
|
||||||
if (!total) return null;
|
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 (
|
return (
|
||||||
<section className="sec">
|
<section className="sec">
|
||||||
@@ -287,17 +306,17 @@ function ActivityDetailPage({ id, f7route }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{s.trainingEffectLabel && (
|
{BENEFIT[s.trainingEffectLabel] && (
|
||||||
<section className="sec">
|
<section className="sec">
|
||||||
<h3 className="sec-title">评估</h3>
|
<h3 className="sec-title">评估</h3>
|
||||||
<div className="ad-eval">
|
<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 className="ad-eval-note">主要收益</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Zones zones={detail.hrZones} />
|
<Zones zones={detail.hrZones} duration={s.duration} />
|
||||||
|
|
||||||
{!!detail.gear.length && (
|
{!!detail.gear.length && (
|
||||||
<section className="sec">
|
<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 Skeleton from '../components/Skeleton';
|
||||||
import './Daily.css';
|
import './Daily.css';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import { iso, today as todayIso } from '../lib/day';
|
||||||
|
|
||||||
/** Every stored metric, grouped the way the device groups them. */
|
/** Every stored metric, grouped the way the device groups them. */
|
||||||
interface Field {
|
interface Field {
|
||||||
@@ -109,10 +110,8 @@ function valueOf(day: HealthDay, key: Field['key']): number | null {
|
|||||||
return typeof v === 'number' ? v : null;
|
return typeof v === 'number' ? v : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
function DailyPage() {
|
function DailyPage() {
|
||||||
const [date, setDate] = useState(() => iso(new Date()));
|
const [date, setDate] = useState(todayIso);
|
||||||
const [day, setDay] = useState<HealthDay | null>(null);
|
const [day, setDay] = useState<HealthDay | null>(null);
|
||||||
const [activities, setActivities] = useState<Activity[]>([]);
|
const [activities, setActivities] = useState<Activity[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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,
|
apiClient, Activity, Badge, errorMessage, HealthDay, PersonalRecord,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import { daysAgo, today as todayIso } from '../lib/day';
|
||||||
import MetricCard from '../components/charts/MetricCard';
|
import MetricCard from '../components/charts/MetricCard';
|
||||||
import Chart from '../components/charts/Chart';
|
import Chart from '../components/charts/Chart';
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
@@ -50,14 +51,12 @@ function ExercisePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const end = new Date();
|
const [a, r, b, d] = await Promise.all([
|
||||||
const start = new Date(end.getTime() - (WINDOW_DAYS - 1) * 86400000);
|
|
||||||
const [a, r, b, d] = await Promise.all([
|
|
||||||
apiClient.getActivities(),
|
apiClient.getActivities(),
|
||||||
apiClient.getPersonalRecords(),
|
apiClient.getPersonalRecords(),
|
||||||
apiClient.getBadges(),
|
apiClient.getBadges(),
|
||||||
apiClient.getHealthSummary(
|
apiClient.getHealthSummary(
|
||||||
start.toISOString().slice(0, 10), end.toISOString().slice(0, 10)
|
daysAgo(WINDOW_DAYS - 1), todayIso()
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
setActivities(a);
|
setActivities(a);
|
||||||
@@ -74,8 +73,7 @@ function ExercisePage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const recent = useMemo(() => {
|
const recent = useMemo(() => {
|
||||||
const cutoff = new Date(Date.now() - WINDOW_DAYS * 86400000)
|
const cutoff = daysAgo(WINDOW_DAYS);
|
||||||
.toISOString().slice(0, 10);
|
|
||||||
return activities.filter((a) => (a.start_time ?? '') >= cutoff);
|
return activities.filter((a) => (a.start_time ?? '') >= cutoff);
|
||||||
}, [activities]);
|
}, [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 MetricCard from '../components/charts/MetricCard';
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import { daysAgo, today as todayIso } from '../lib/day';
|
||||||
import './Health.css';
|
import './Health.css';
|
||||||
|
|
||||||
const WINDOW_DAYS = 30;
|
const WINDOW_DAYS = 30;
|
||||||
@@ -73,12 +74,9 @@ function HealthPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const end = new Date();
|
setDays(
|
||||||
const start = new Date(end.getTime() - (WINDOW_DAYS - 1) * 86400000);
|
|
||||||
setDays(
|
|
||||||
await apiClient.getHealthSummary(
|
await apiClient.getHealthSummary(
|
||||||
start.toISOString().slice(0, 10),
|
daysAgo(WINDOW_DAYS - 1), todayIso()
|
||||||
end.toISOString().slice(0, 10)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { apiClient, errorMessage, HealthDay, RatingBasis } from '../services/api
|
|||||||
import { METRICS } from '../lib/metrics';
|
import { METRICS } from '../lib/metrics';
|
||||||
import { classify, formatTarget, RANGES } from '../lib/ranges';
|
import { classify, formatTarget, RANGES } from '../lib/ranges';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import { daysAgo, today as todayIso } from '../lib/day';
|
||||||
import Chart from '../components/charts/Chart';
|
import Chart from '../components/charts/Chart';
|
||||||
import BandBar from '../components/charts/BandBar';
|
import BandBar from '../components/charts/BandBar';
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
@@ -37,10 +38,8 @@ function MetricDetailPage({ id, f7route }: Props) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const end = new Date();
|
const rows = await apiClient.getHealthSummary(
|
||||||
const start = new Date(end.getTime() - (window - 1) * 86400000);
|
daysAgo(window - 1), todayIso()
|
||||||
const rows = await apiClient.getHealthSummary(
|
|
||||||
start.toISOString().slice(0, 10), end.toISOString().slice(0, 10)
|
|
||||||
);
|
);
|
||||||
if (!cancelled) setDays(rows);
|
if (!cancelled) setDays(rows);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import { FEATURES } from '../features';
|
import { FEATURES } from '../features';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import { today as todayIso } from '../lib/day';
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
import './Settings.css';
|
import './Settings.css';
|
||||||
|
|
||||||
@@ -145,7 +146,7 @@ function SettingsPage() {
|
|||||||
className="set-input"
|
className="set-input"
|
||||||
type="date"
|
type="date"
|
||||||
value={s.birthDate ?? ''}
|
value={s.birthDate ?? ''}
|
||||||
max={new Date().toISOString().slice(0, 10)}
|
max={todayIso()}
|
||||||
onChange={(e) => save({ birthDate: e.target.value || null })}
|
onChange={(e) => save({ birthDate: e.target.value || null })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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 StatTile from '../components/charts/StatTile';
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import { daysAgo, today as todayIso } from '../lib/day';
|
||||||
|
|
||||||
const RANGES = [7, 14, 30, 90];
|
const RANGES = [7, 14, 30, 90];
|
||||||
const H = 3600;
|
const H = 3600;
|
||||||
@@ -27,12 +28,9 @@ function SleepPage() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const end = new Date();
|
setDays(
|
||||||
const start = new Date(end.getTime() - (range - 1) * 86400000);
|
|
||||||
setDays(
|
|
||||||
await apiClient.getHealthSummary(
|
await apiClient.getHealthSummary(
|
||||||
start.toISOString().slice(0, 10),
|
daysAgo(range - 1), todayIso()
|
||||||
end.toISOString().slice(0, 10)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Skeleton from '../components/Skeleton';
|
|||||||
import { useCountUp } from '../lib/motion';
|
import { useCountUp } from '../lib/motion';
|
||||||
import { RANGES } from '../lib/ranges';
|
import { RANGES } from '../lib/ranges';
|
||||||
import { METRICS, metricHref } from '../lib/metrics';
|
import { METRICS, metricHref } from '../lib/metrics';
|
||||||
|
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. */
|
/* 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'] },
|
{ 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() {
|
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);
|
||||||
@@ -140,9 +137,9 @@ function TodayPage() {
|
|||||||
try {
|
try {
|
||||||
// A year in one read: browsing back a day should not cost a request,
|
// A year in one read: browsing back a day should not cost a request,
|
||||||
// and the cards' sparklines need the surrounding days anyway.
|
// and the cards' sparklines need the surrounding days anyway.
|
||||||
const end = new Date();
|
setDays(await apiClient.getHealthSummary(
|
||||||
const start = new Date(end.getTime() - (HISTORY_DAYS - 1) * 86400000);
|
daysAgo(HISTORY_DAYS - 1), todayIso()
|
||||||
setDays(await apiClient.getHealthSummary(iso(start), iso(end)));
|
));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(errorMessage(err, '加载数据失败'));
|
setError(errorMessage(err, '加载数据失败'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -167,7 +164,7 @@ function TodayPage() {
|
|||||||
|
|
||||||
const go = (delta: number) => {
|
const go = (delta: number) => {
|
||||||
if (!date) return;
|
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.
|
// Days with no record at all are skipped over rather than shown blank.
|
||||||
const nearest = delta < 0
|
const nearest = delta < 0
|
||||||
? [...days].reverse().find((d) => d.date <= target)
|
? [...days].reverse().find((d) => d.date <= target)
|
||||||
@@ -196,7 +193,7 @@ function TodayPage() {
|
|||||||
calendar.open();
|
calendar.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isToday = date === iso(new Date());
|
const isToday = date === todayIso();
|
||||||
const weekday = date
|
const weekday = date
|
||||||
? ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][
|
? ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][
|
||||||
new Date(`${date}T12:00:00`).getDay()
|
new Date(`${date}T12:00:00`).getDay()
|
||||||
@@ -206,7 +203,7 @@ function TodayPage() {
|
|||||||
const open = (id: string) => f7.views.current.router.navigate(metricHref(id));
|
const open = (id: string) => f7.views.current.router.navigate(metricHref(id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen title={isToday ? '今日' : '每日数据'} subtitle={date ?? undefined}>
|
<Screen title={!date || isToday ? '今日' : '每日数据'} subtitle={date ?? undefined}>
|
||||||
{loading && (
|
{loading && (
|
||||||
<>
|
<>
|
||||||
<div className="hero-skeleton" aria-hidden="true" />
|
<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,
|
aggregate, Granularity, GRANULARITIES, isCumulative, suggestGranularity,
|
||||||
} from '../lib/aggregate';
|
} from '../lib/aggregate';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import { daysAgo, today as todayIso } from '../lib/day';
|
||||||
|
|
||||||
const RANGES = [
|
const RANGES = [
|
||||||
{ days: 30, label: '近一月' },
|
{ days: 30, label: '近一月' },
|
||||||
@@ -171,12 +172,9 @@ function TrendsPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const end = new Date();
|
setDays(
|
||||||
const start = new Date(end.getTime() - (range - 1) * 86400000);
|
|
||||||
setDays(
|
|
||||||
await apiClient.getHealthSummary(
|
await apiClient.getHealthSummary(
|
||||||
start.toISOString().slice(0, 10),
|
daysAgo(range - 1), todayIso()
|
||||||
end.toISOString().slice(0, 10)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -483,7 +483,9 @@ class ApiClient {
|
|||||||
async getActivityDetail(activityId: string, refresh = false) {
|
async getActivityDetail(activityId: string, refresh = false) {
|
||||||
const { data } = await this.client.get<ActivityDetail>(
|
const { data } = await this.client.get<ActivityDetail>(
|
||||||
`/garmin/activities/${activityId}/detail`,
|
`/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;
|
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 只做解读。
|
1. **AI 不定阈值。** 让模型现编「正常范围」会得到一个不可复现、无法追溯、却带着医学口吻的数字。所有参考区间来自公开来源(WHO、AASM、Garmin 官方分级、人群常模),AI 只做解读。
|
||||||
|
|||||||
Reference in New Issue
Block a user