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:
ericwyuan
2026-08-24 01:16:44 +08:00
parent 8430ff335e
commit fa865ca8a6
22 changed files with 173 additions and 1416 deletions

View File

@@ -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
View 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

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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
View 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);
}

View File

@@ -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">

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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]);

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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="成人参考:深睡约占 1323%REM 约占 2025%。"
/>
</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;

View File

@@ -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) {

View File

@@ -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" />

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;
} }

View File

@@ -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 只做解读。