import { useEffect, useState } from 'react';
import { Link, f7 } from 'framework7-react';
import { apiClient, errorMessage, FitnessAge, HealthDay } from '../services/api';
import { METRICS, metricHref } from '../lib/metrics';
import MetricCard from '../components/charts/MetricCard';
import Skeleton from '../components/Skeleton';
import Screen from '../components/Screen';
import AiPanel from '../components/AiPanel';
import { FEATURES } from '../features';
import { daysAgo, today as todayIso } from '../lib/day';
import './Health.css';
import './Settings.css';
const WINDOW_DAYS = 30;
/* Sections name metric ids; the labels, units and accessors come from the
registry so 今日 / 健康 / 趋势 cannot disagree about what a metric is. */
const SECTIONS: Array<{ title: string; items: string[] }> = [
{ title: '身体指标', items: ['heartRate', 'heartRateVariability', 'respirationAvg', 'spo2Avg'] },
{ title: '恢复', items: ['bodyBatteryHigh', 'stress', 'trainingReadiness', 'enduranceScore'] },
{ title: '睡眠', items: ['sleepDuration', 'sleepQuality', 'deepShare', 'remShare'] },
{ title: '活动', items: ['steps', 'intensityMinutes', 'floorsAscended', 'distance'] },
{ title: '能量', items: ['caloriesBurned', 'activeCalories', 'bmrCalories', 'sedentary'] },
{ title: '身体成分', items: ['weight', 'bodyFat', 'hydration', 'hillScore'] },
];
/* Screens that are not a single metric, so they get their own entries. */
const LINKS: Array<[string, string, string]> = [
['/body/', '身体成分与血压', '体重、体脂、肌肉量、血压记录'],
['/race/', '成绩预测', '5 公里到全马的预测完赛时间'],
['/challenges/', '挑战赛', '徽章挑战与好友挑战'],
];
function BodyAge({ data }: { data: FitnessAge | null }) {
if (!data) return null;
const go = () => f7.views.current.router.navigate('/body-age/');
if (data.value == null) {
return (
身体年龄
);
}
const delta = data.delta ?? 0;
return (
身体年龄
);
}
function HealthPage() {
const [days, setDays] = useState([]);
const [bodyAge, setBodyAge] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
setDays(
await apiClient.getHealthSummary(
daysAgo(WINDOW_DAYS - 1), todayIso()
)
);
} catch (err: any) {
setError(errorMessage(err, '加载失败'));
} finally {
setLoading(false);
}
};
load();
// Body age is a separate, optional read: an empty profile must not stop
// the rest of the page from rendering.
apiClient.getFitnessAge().then(setBodyAge).catch(() => setBodyAge(null));
}, []);
if (loading) {
return (
);
}
if (error) {
return (
{error}
);
}
if (days.length === 0) {
return (
还没有任何健康数据。
去同步 Garmin 数据
);
}
// 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 as number | null, date: null as string | null };
};
return (
{FEATURES.ai && }
{SECTIONS.map((section) => (
{section.title}
{section.items.map((id) => {
const def = METRICS[id];
if (!def) return null;
const { value, date } = latest(def.pick);
const stale = date != null && date !== days[days.length - 1].date;
return (
f7.views.current.router.navigate(metricHref(id))}
/>
);
})}