@@ -174,141 +150,117 @@ function Dashboard() {
{/* Activity ---------------------------------------------------------- */}
活动
-
-
+ d.steps)}
- detail={today.stepGoal ? `目标 ${today.stepGoal.toLocaleString()}` : undefined}
- progress={
- today.steps != null && today.stepGoal ? today.steps / today.stepGoal : null
- }
/>
- d.intensityMinutes)}
+ />
+ d.floorsAscended)}
+ />
+ d.distanceMeters)}
value={today.distanceMeters != null ? today.distanceMeters / 1000 : null}
unit="km"
decimals={2}
- />
-
- d.intensityMinutes)}
- value={today.intensityMinutes}
- unit="分钟"
- detail="中等以上强度"
- />
- d.caloriesBurned)}
- value={round(today.caloriesBurned)}
- unit="kcal"
+ trend={days.map((d) => d.distanceMeters)}
detail={
- today.activeCalories != null
- ? `其中活动 ${Math.round(today.activeCalories)}`
+ today.caloriesBurned != null
+ ? `消耗 ${Math.round(today.caloriesBurned).toLocaleString()} kcal`
: undefined
}
/>
- d.sedentarySeconds)}
- value={today.sedentarySeconds != null ? today.sedentarySeconds / 3600 : null}
- unit="小时"
- decimals={1}
- />
{/* Heart & stress ---------------------------------------------------- */}
心率与压力
-
-
+ d.heartRate)}
value={today.heartRate}
unit="bpm"
- status={rhrTone}
- statusLabel={rhrWord}
- detail={avgRhr != null ? `30 日均 ${Math.round(avgRhr)}` : undefined}
+ trend={days.map((d) => d.heartRate)}
/>
-
- d.heartRateVariability)}
- value={round(today.heartRateVariability)}
- unit="ms"
- detail={avgHrv != null ? `30 日均 ${Math.round(avgHrv)}` : undefined}
/>
- d.stress)}
value={today.stress}
- detail={today.stressMax != null ? `峰值 ${today.stressMax}` : undefined}
+ trend={days.map((d) => d.stress)}
/>
- d.trainingReadiness)}
/>
-
{/* Sleep & breathing -------------------------------------------------- */}
睡眠与呼吸
-
-
+ d.sleepDuration)}
value={today.sleepDuration}
unit="小时"
decimals={1}
- status={sleepTone}
- statusLabel={sleepWord}
- detail={avgSleep != null ? `30 日均 ${avgSleep.toFixed(1)}` : undefined}
+ trend={days.map((d) => d.sleepDuration)}
/>
-
- d.spo2Avg)}
- value={round(today.spo2Avg)}
- unit="%"
- detail={today.spo2Min != null ? `最低 ${today.spo2Min}%` : undefined}
- />
- d.respirationAvg)}
- value={round(today.respirationAvg)}
- unit="次/分"
- detail={
- today.respirationMin != null && today.respirationMax != null
- ? `${today.respirationMin}–${today.respirationMax}`
- : undefined
- }
+ d.sleepQuality)}
/>
+
+
+
+
+
查看睡眠分期详情 →
diff --git a/client/src/pages/Health.tsx b/client/src/pages/Health.tsx
new file mode 100644
index 0000000..30e520d
--- /dev/null
+++ b/client/src/pages/Health.tsx
@@ -0,0 +1,195 @@
+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([]);
+ 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 (
+
+
健康
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ 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, date: null };
+ };
+
+ return (
+
+
+
+ {SECTIONS.map((section) => (
+
+ {section.title}
+
+ {section.items.map((item) => {
+ const { value, date } = latest(item.pick);
+ const stale = date != null && date !== days[days.length - 1].date;
+ return (
+
+ );
+ })}
+
+
+ ))}
+
+
+ 参考区间为一般人群的定位范围,非诊断标准。如有健康疑问请咨询专业医师。
+
+
+ );
+}
+
+export default Health;
diff --git a/client/src/pages/HealthPage.tsx b/client/src/pages/HealthPage.tsx
new file mode 100644
index 0000000..886f35a
--- /dev/null
+++ b/client/src/pages/HealthPage.tsx
@@ -0,0 +1,189 @@
+import { useEffect, useState } from 'react';
+import { Link } from 'framework7-react';
+import { apiClient, errorMessage, HealthDay } from '../services/api';
+import MetricCard from '../components/charts/MetricCard';
+import Skeleton from '../components/Skeleton';
+import Screen from '../components/Screen';
+
+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 HealthPage() {
+ const [days, setDays] = useState([]);
+ 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 (
+
+ 健康
+
+
+ );
+ }
+
+ 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, date: null };
+ };
+
+ return (
+
+
+ {SECTIONS.map((section) => (
+
+ {section.title}
+
+ {section.items.map((item) => {
+ const { value, date } = latest(item.pick);
+ const stale = date != null && date !== days[days.length - 1].date;
+ return (
+
+ );
+ })}
+
+
+ ))}
+
+
+ 参考区间为一般人群的定位范围,非诊断标准。如有健康疑问请咨询专业医师。
+
+
+ );
+}
+
+export default HealthPage;
diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..c566b06
--- /dev/null
+++ b/client/src/pages/LoginPage.tsx
@@ -0,0 +1,239 @@
+import React, { useEffect, useState } from 'react';
+import { Page, f7 } from 'framework7-react';
+import { apiClient, errorMessage } from '../services/api';
+import './Login.css';
+
+type TabType = 'login' | 'register';
+
+function Login() {
+ const [activeTab, setActiveTab] = useState('login');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ // Sign-up closes once an account exists, so the tab is hidden rather than
+ // offering something the server will refuse.
+ const [canRegister, setCanRegister] = useState(false);
+
+ useEffect(() => {
+ apiClient
+ .getRegistrationStatus()
+ .then(setCanRegister)
+ .catch(() => setCanRegister(false));
+ }, []);
+
+ // Login form
+ const [loginEmail, setLoginEmail] = useState('');
+ const [loginPassword, setLoginPassword] = useState('');
+
+ // Register form
+ const [regEmail, setRegEmail] = useState('');
+ const [regGarminEmail, setRegGarminEmail] = useState('');
+ const [regPassword, setRegPassword] = useState('');
+ const [regConfirmPassword, setRegConfirmPassword] = useState('');
+
+ const validateEmail = (email: string): boolean => {
+ const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return re.test(email);
+ };
+
+ const validatePassword = (password: string): boolean => {
+ return password.length >= 6;
+ };
+
+ const handleLogin = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+
+ if (!validateEmail(loginEmail)) {
+ setError('Please enter a valid email');
+ return;
+ }
+
+ if (!validatePassword(loginPassword)) {
+ setError('Password must be at least 6 characters');
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ const { token } = await apiClient.login(loginEmail, loginPassword);
+ apiClient.setSession(token);
+ f7.views.main.router.navigate('/', { reloadAll: true });
+ } catch (err: any) {
+ setError(errorMessage(err, '登录失败'));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleRegister = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+
+ if (!validateEmail(regEmail)) {
+ setError('Please enter a valid email');
+ return;
+ }
+
+ if (!validateEmail(regGarminEmail)) {
+ setError('Please enter a valid Garmin email');
+ return;
+ }
+
+ if (!validatePassword(regPassword)) {
+ setError('Password must be at least 6 characters');
+ return;
+ }
+
+ if (regPassword !== regConfirmPassword) {
+ setError('Passwords do not match');
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ const { token } = await apiClient.register(regEmail, regGarminEmail, regPassword);
+ apiClient.setSession(token);
+ f7.views.main.router.navigate('/', { reloadAll: true });
+ } catch (err: any) {
+ setError(errorMessage(err, '注册失败'));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
🏃 Garmin Health Lab
+
健康数据分析平台
+
+
+
+ {
+ setActiveTab('login');
+ setError('');
+ }}
+ >
+ 登录
+
+ {canRegister && (
+ {
+ setActiveTab('register');
+ setError('');
+ }}
+ >
+ 注册
+
+ )}
+
+
+ {error &&
{error}
}
+
+ {activeTab === 'login' && (
+
+ )}
+
+ {activeTab === 'register' && canRegister && (
+
+ )}
+
+
+
+ );
+}
+
+export default Login;
diff --git a/client/src/pages/NotFoundPage.tsx b/client/src/pages/NotFoundPage.tsx
new file mode 100644
index 0000000..e9ed7a1
--- /dev/null
+++ b/client/src/pages/NotFoundPage.tsx
@@ -0,0 +1,13 @@
+import Screen from '../components/Screen';
+
+function NotFoundPage() {
+ return (
+
+
+
+ );
+}
+
+export default NotFoundPage;
diff --git a/client/src/pages/Pages.css b/client/src/pages/Pages.css
index 4b1b18c..87e1164 100644
--- a/client/src/pages/Pages.css
+++ b/client/src/pages/Pages.css
@@ -531,3 +531,13 @@
transform: none;
}
}
+
+.disclaimer {
+ margin-top: 2rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--border);
+ font-size: 0.76rem;
+ color: var(--text-muted);
+ text-align: center;
+ line-height: 1.7;
+}
diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx
new file mode 100644
index 0000000..3cd91a9
--- /dev/null
+++ b/client/src/pages/SettingsPage.tsx
@@ -0,0 +1,102 @@
+import Screen from '../components/Screen';
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { apiClient, ModelInfo } from '../services/api';
+import { FEATURES } from '../features';
+import './Settings.css';
+
+function SettingsPage() {
+ const navigate = useNavigate();
+ const [models, setModels] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (!FEATURES.ai) {
+ setLoading(false);
+ return;
+ }
+ apiClient
+ .getModels()
+ .then(setModels)
+ .catch(() => setModels([]))
+ .finally(() => setLoading(false));
+ }, []);
+
+ const handleLogout = async () => {
+ await apiClient.logout();
+ navigate('/login');
+ };
+
+ return (
+
+ 设置
+
+ {FEATURES.ai && (
+
+ AI 模型
+
+ 模型清单与优先级由后端 backend/.env 决定。
+ 填入对应厂商的密钥后,模型会自动变为可用;
+ AI_MODEL_CHAIN 控制自动模式下的尝试顺序。
+
+
+ {loading ? (
+ 加载中…
+ ) : models.length === 0 ? (
+ 无法获取模型列表。
+ ) : (
+
+
+
+ ID
+ 模型
+ 上下文
+ 状态
+
+
+
+ {models.map((m) => (
+
+
+ {m.id}
+ {m.default && 默认 }
+
+ {m.model}
+ {(m.contextWindow / 1000).toLocaleString()}k
+
+
+ {m.configured ? '已配置' : '缺少密钥'}
+
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+ 数据与隐私
+
+ 健康数据保存在自建数据库中,不上传第三方服务。
+
+ Garmin 账号通过 OAuth 令牌授权,密码不会被保存;令牌约一年后过期,
+ 届时在「同步」页重新绑定一次即可。
+
+ 本站登录密码以 PBKDF2 加盐哈希存储,无法还原。
+
+
+
+
+
+
+ );
+}
+
+export default SettingsPage;
diff --git a/client/src/pages/SleepPage.tsx b/client/src/pages/SleepPage.tsx
new file mode 100644
index 0000000..2362c03
--- /dev/null
+++ b/client/src/pages/SleepPage.tsx
@@ -0,0 +1,179 @@
+import { useEffect, useState } from 'react';
+import { Link } from 'framework7-react';
+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 Screen from '../components/Screen';
+
+const RANGES = [7, 14, 30, 90];
+const H = 3600;
+
+function avg(values: Array): 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 SleepPage() {
+ const [days, setDays] = useState([]);
+ // 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(
+ (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 (
+
+
+
+
范围
+
+ {RANGES.map((r) => (
+ setRange(r)}>
+ {r} 天
+
+ ))}
+
+
+
+ {error && {error}
}
+ {loading && (
+ <>
+
+
+
+ >
+ )}
+
+ {!loading && !error && nights.length === 0 && (
+
+ )}
+
+ {!loading && !error && nights.length > 0 && (
+ <>
+
+ {nights.length} 晚平均
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+export default SleepPage;
diff --git a/client/src/pages/SyncPage.tsx b/client/src/pages/SyncPage.tsx
new file mode 100644
index 0000000..0bbfcca
--- /dev/null
+++ b/client/src/pages/SyncPage.tsx
@@ -0,0 +1,366 @@
+import Screen from '../components/Screen';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ apiClient, errorMessage, GarminLoginStatus, parseUtc, SyncStatus,
+} from '../services/api';
+import './DataSync.css';
+
+const POLL_MS = 2000;
+
+function SyncPage() {
+ const [syncStatus, setSyncStatus] = useState(null);
+ const [hasToken, setHasToken] = useState(null);
+
+ // Garmin login (only needed until a token is stored)
+ const [password, setPassword] = useState('');
+ const [session, setSession] = useState(null);
+ const [loginState, setLoginState] = useState(null);
+ const [code, setCode] = useState('');
+ const [codeSubmitted, setCodeSubmitted] = useState(false);
+
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [message, setMessage] = useState('');
+
+ const pollRef = useRef(null);
+
+ const stopPolling = useCallback(() => {
+ if (pollRef.current) {
+ window.clearInterval(pollRef.current);
+ pollRef.current = null;
+ }
+ }, []);
+
+ const loadSyncStatus = useCallback(async () => {
+ try {
+ setSyncStatus(await apiClient.getGarminSyncStatus());
+ } catch (err) {
+ // A failed status poll should not blank the page.
+ console.error('Failed to load sync status:', err);
+ }
+ }, []);
+
+ useEffect(() => {
+ // A backfill outlives the page, so a reload must pick the progress back up.
+ apiClient.getGarminSyncStatus().then((s) => {
+ setSyncStatus(s);
+ if (s.status === 'syncing') beginSyncPolling();
+ }).catch(() => undefined);
+ apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false));
+ return stopPolling;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // --- Garmin login -------------------------------------------------------
+ const startLogin = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setMessage('');
+ if (!password) {
+ setError('请输入 Garmin 密码');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const sid = await apiClient.startGarminLogin(password);
+ // The password is only ever needed for this one request.
+ setPassword('');
+ setSession(sid);
+ setLoginState('starting');
+ setCodeSubmitted(false);
+ beginPolling(sid);
+ } catch (err: any) {
+ setError(errorMessage(err, '登录失败'));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const beginPolling = (sid: string) => {
+ stopPolling();
+ pollRef.current = window.setInterval(async () => {
+ try {
+ const { status, error: loginError } = await apiClient.getGarminLoginStatus(sid);
+ setLoginState(status);
+
+ if (status === 'done') {
+ stopPolling();
+ setSession(null);
+ setHasToken(true);
+ setMessage('Garmin 登录成功,之后同步不再需要密码或验证码。');
+ } else if (status === 'failed') {
+ stopPolling();
+ setSession(null);
+ setCodeSubmitted(false);
+ setError(loginError || '登录失败,请重试');
+ }
+ } catch (err: any) {
+ stopPolling();
+ setSession(null);
+ setError(errorMessage(err, '登录状态查询失败'));
+ }
+ }, POLL_MS);
+ };
+
+ const submitCode = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!session || !code.trim()) return;
+
+ setError('');
+ setLoading(true);
+ try {
+ const { ok, message: msg } = await apiClient.submitGarminMfa(session, code.trim());
+ if (ok) {
+ setCodeSubmitted(true);
+ setCode('');
+ } else {
+ setError(msg);
+ }
+ } catch (err: any) {
+ setError(errorMessage(err, '验证码提交失败'));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const cancelLogin = async () => {
+ if (session) {
+ try {
+ await apiClient.cancelGarminLogin(session);
+ } catch {
+ // Cancelling is best-effort; the session expires on its own anyway.
+ }
+ }
+ stopPolling();
+ setSession(null);
+ setLoginState(null);
+ setCode('');
+ setCodeSubmitted(false);
+ };
+
+ // --- sync ---------------------------------------------------------------
+ const handleSync = async (days: number) => {
+ setError('');
+ setMessage('');
+ setLoading(true);
+ try {
+ await apiClient.syncGarminData(days);
+ await loadSyncStatus();
+ beginSyncPolling();
+ } catch (err: any) {
+ setError(errorMessage(err, '同步失败'));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // The sync runs in the background, so the page follows it by polling
+ // rather than by holding a request open for the whole backfill.
+ const beginSyncPolling = () => {
+ stopPolling();
+ pollRef.current = window.setInterval(async () => {
+ try {
+ const s = await apiClient.getGarminSyncStatus();
+ setSyncStatus(s);
+ if (s.status !== 'syncing') {
+ stopPolling();
+ if (s.status === 'error') setError(s.lastError || '同步失败');
+ else setMessage(`同步完成,已更新 ${s.recordsSynced} 天数据`);
+ }
+ } catch {
+ stopPolling();
+ }
+ }, 2000);
+ };
+
+ const statusLabel: Record = {
+ idle: '就绪',
+ syncing: '正在同步…',
+ error: '上次同步失败',
+ };
+
+ const syncing = syncStatus?.status === 'syncing';
+ const busy = loading || syncing;
+ const awaitingCode = loginState === 'awaiting_code' || codeSubmitted;
+ const current = syncStatus?.progressCurrent ?? 0;
+ const total = syncStatus?.progressTotal ?? 0;
+ const pct = total > 0 ? Math.round((current / total) * 100) : 0;
+
+ return (
+
+ 数据同步
+ 从 Garmin Connect 拉取最近 7 天的健康数据
+
+
+
+ 同步状态
+ {syncStatus ? (
+
+
+ 状态
+
+ {statusLabel[syncStatus.status] ?? syncStatus.status}
+
+
+
+ 最后同步
+
+ {parseUtc(syncStatus.lastSyncTime)?.toLocaleString('zh-CN')
+ ?? '从未同步'}
+
+
+
+ 已同步天数
+ {syncStatus.recordsSynced}
+
+ {syncStatus.lastError && (
+
+ 错误
+ {syncStatus.lastError}
+
+ )}
+
+ ) : (
+ 加载中…
+ )}
+
+
+ {/* Step 1 — link the Garmin account, once. */}
+ {hasToken === false && !session && (
+
+ )}
+
+ {/* Step 2 — the two-factor code. */}
+ {session && (
+
+ {loginState === 'starting' && !codeSubmitted && (
+ 正在连接 Garmin…
+ )}
+
+ {awaitingCode && (
+
+ )}
+
+ {loginState === 'finishing' && (
+ 验证通过,正在完成登录…
+ )}
+
+ )}
+
+ {/* Step 3 — sync, once linked. */}
+ {hasToken === true && (
+
+ 拉取数据
+
+ 已绑定 Garmin 账号,同步无需密码。首次建议回补一段历史,
+ 之后日常只需拉最近 7 天。
+
+
+ {syncing && total > 0 ? (
+
+
+ 正在同步…
+ {current} / {total} 天
+
+
+
+ 在后台运行,可以离开本页。约每天 3 秒,
+ {total > 60 ? `预计 ${Math.ceil((total * 3) / 60)} 分钟左右。` : ''}
+
+
+ ) : (
+
+ {[7, 30, 90, 365].map((d) => (
+ handleSync(d)}
+ className={`btn ${d === 7 ? 'btn-primary' : 'btn-plain'}`}
+ disabled={busy}
+ >
+ {d === 365 ? '回补一年' : `最近 ${d} 天`}
+
+ ))}
+
+ )}
+
+ )}
+
+ {error &&
{error}
}
+ {message &&
{message}
}
+
+
+ 关于数据同步
+
+ 每次同步获取最近 7 天的每日汇总与运动记录
+ 同一天重复同步会更新原有记录,不会产生重复数据
+ 数据保存在本机数据库,不经过第三方服务
+ Garmin 授权令牌约一年过期,届时重新绑定一次即可
+
+
+
+
+ );
+}
+
+export default SyncPage;
diff --git a/client/src/pages/Dashboard.css b/client/src/pages/Today.css
similarity index 100%
rename from client/src/pages/Dashboard.css
rename to client/src/pages/Today.css
diff --git a/client/src/pages/TodayPage.tsx b/client/src/pages/TodayPage.tsx
new file mode 100644
index 0000000..6836da8
--- /dev/null
+++ b/client/src/pages/TodayPage.tsx
@@ -0,0 +1,163 @@
+import { useEffect, useState } from 'react';
+import { Link } from 'framework7-react';
+import { apiClient, errorMessage, HealthDay } from '../services/api';
+import Screen from '../components/Screen';
+import Ring from '../components/charts/Ring';
+import MetricCard from '../components/charts/MetricCard';
+import MetricStrip from '../components/charts/MetricStrip';
+import Skeleton from '../components/Skeleton';
+import { useCountUp } from '../lib/motion';
+import './Today.css';
+
+const DAYS = 30;
+
+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 (
+
+
+
+ {steps == null ? '—' : Math.round(animated ?? steps).toLocaleString()}
+
+ 步
+
+
+
+
+ {progress == null
+ ? '今日暂无步数记录'
+ : progress >= 1
+ ? '今日目标已完成'
+ : `距目标还差 ${remaining!.toLocaleString()} 步`}
+
+
+
目标 {goal ? goal.toLocaleString() : '—'}
+
近 7 日均 {weekAvg ? weekAvg.toLocaleString() : '—'}
+
完成度 {progress != null ? `${Math.round(progress * 100)}%` : '—'}
+
+
+
+ );
+}
+
+function TodayPage() {
+ const [days, setDays] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ 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();
+ }, []);
+
+ const today = days[days.length - 1];
+
+ return (
+
+ {loading && (
+ <>
+
+
+ >
+ )}
+
+ {!loading && error && {error}
}
+
+ {!loading && !error && !today && (
+
+
还没有任何健康数据。
+
+ 去同步 Garmin 数据
+
+
+ )}
+
+ {!loading && !error && today && (
+ <>
+
+
+
+ 活动
+
+ d.steps)} />
+ d.intensityMinutes)} />
+ d.floorsAscended)} />
+ d.distanceMeters)}
+ detail={today.caloriesBurned != null
+ ? `消耗 ${Math.round(today.caloriesBurned).toLocaleString()} kcal` : undefined} />
+
+
+
+
+ 心率与压力
+
+ d.heartRate)} />
+ d.heartRateVariability)} />
+ d.stress)} />
+ d.trainingReadiness)} />
+
+
+
+
+ 睡眠
+
+ d.sleepDuration)} />
+ d.sleepQuality)} />
+
+ 查看睡眠分期详情 →
+
+
+
+ >
+ )}
+
+ );
+}
+
+export default TodayPage;
diff --git a/client/src/pages/TrendsPage.tsx b/client/src/pages/TrendsPage.tsx
new file mode 100644
index 0000000..71c593b
--- /dev/null
+++ b/client/src/pages/TrendsPage.tsx
@@ -0,0 +1,343 @@
+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 Screen from '../components/Screen';
+
+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;
+ 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) {
+ 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 TrendsPage() {
+ const [days, setDays] = useState([]);
+ const [range, setRange] = useState(365);
+ const [granularity, setGranularity] = useState(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>(() => {
+ try {
+ return new Set(JSON.parse(localStorage.getItem(HIDDEN_KEY) || '[]'));
+ } catch {
+ return new Set();
+ }
+ });
+
+ 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 = { 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 (
+
+
+
+
范围
+
+ {RANGES.map((r) => (
+ setRange(r.days)}>{r.label}
+ ))}
+
+
+
+
+
周期
+
+ {GRANULARITIES.filter((g) => g.days <= Math.max(range / 2, 1)).map((g) => (
+ setGranularity(g.id)}>{g.label}
+ ))}
+
+
+
+
+
+
显示
+
+ setHidden(new Set())}>
+ 全选
+
+ setHidden(new Set(GROUPS.map((g) => g.id)))}
+ >
+ 全不选
+
+
+
+
+ {GROUPS.map((g) => {
+ const on = !hidden.has(g.id);
+ return (
+ toggle(g.id)}
+ aria-pressed={on}
+ >
+ {/* A mark, not colour alone, carries the on/off state. */}
+ {on ? '✓' : '+'}
+ {g.label}
+
+ );
+ })}
+
+
+
+ {error && {error}
}
+ {loading && }
+
+ {!loading && !error && visible.length === 0 && (
+ 所有指标都已隐藏,点上面的标签重新显示。
+ )}
+
+ {!loading && !error && visible.length > 0 && (
+
+ {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 (
+
+ {meanWord} {fmt(s.mean)}
+ 低 {fmt(s.min)}
+ 高 {fmt(s.max)}
+ 后半段 {s.delta >= 0 ? '+' : ''}{fmt(s.delta)}
+
+ ) : (
+ g.note
+ )
+ }
+ />
+ );
+ })}
+
+ )}
+
+ );
+}
+
+export default TrendsPage;
diff --git a/client/src/routes.ts b/client/src/routes.ts
new file mode 100644
index 0000000..829c36d
--- /dev/null
+++ b/client/src/routes.ts
@@ -0,0 +1,32 @@
+import { Router } from 'framework7/types';
+
+import TodayPage from './pages/TodayPage';
+import HealthPage from './pages/HealthPage';
+import DailyPage from './pages/DailyPage';
+import TrendsPage from './pages/TrendsPage';
+import AchievementsPage from './pages/AchievementsPage';
+import SleepPage from './pages/SleepPage';
+import SyncPage from './pages/SyncPage';
+import SettingsPage from './pages/SettingsPage';
+import LoginPage from './pages/LoginPage';
+import NotFoundPage from './pages/NotFoundPage';
+
+/**
+ * Secondary screens are reachable from more than one tab, so they are
+ * registered on every view's router rather than pinned to one — pushing 睡眠
+ * from 今日 should stay inside 今日's stack.
+ */
+const routes: Router.RouteParameters[] = [
+ { path: '/', component: TodayPage },
+ { path: '/health/', component: HealthPage },
+ { path: '/daily/', component: DailyPage },
+ { path: '/trends/', component: TrendsPage },
+ { path: '/achievements/', component: AchievementsPage },
+ { path: '/sleep/', component: SleepPage },
+ { path: '/sync/', component: SyncPage },
+ { path: '/settings/', component: SettingsPage },
+ { path: '/login/', component: LoginPage },
+ { path: '(.*)', component: NotFoundPage },
+];
+
+export default routes;
diff --git a/client/tsconfig.json b/client/tsconfig.json
index 72729fe..6537f88 100644
--- a/client/tsconfig.json
+++ b/client/tsconfig.json
@@ -13,7 +13,7 @@
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
- "moduleResolution": "node",
+ "moduleResolution": "bundler",
"jsx": "react-jsx"
},
"include": ["src"],
diff --git a/package-lock.json b/package-lock.json
index 6783452..55dea24 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,9 @@
"dependencies": {
"axios": "^1.5.0",
"date-fns": "^2.30.0",
+ "framework7": "^9.1.2",
+ "framework7-icons": "^5.0.5",
+ "framework7-react": "^9.1.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.14.2",
@@ -7664,6 +7667,21 @@
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
+ "node_modules/dom7": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/dom7/-/dom7-4.0.6.tgz",
+ "integrity": "sha512-emjdpPLhpNubapLFdjNL9tP06Sr+GZkrIHEXLWvOGsytACUrkbeIdjO5g77m00BrHTznnlcNqgmn7pCN192TBA==",
+ "license": "MIT",
+ "dependencies": {
+ "ssr-window": "^4.0.0"
+ }
+ },
+ "node_modules/dom7/node_modules/ssr-window": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/ssr-window/-/ssr-window-4.0.2.tgz",
+ "integrity": "sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==",
+ "license": "MIT"
+ },
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
@@ -9448,6 +9466,49 @@
"url": "https://github.com/sponsors/rawify"
}
},
+ "node_modules/framework7": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/framework7/-/framework7-9.1.2.tgz",
+ "integrity": "sha512-XXvIeiRri3imFiz4wZZjBgr0b7mqVosFX1rRABM86iUyaWqj1GQEuQsKzaGXFw1nzEuiX7+r39vtF7Fq5bUiJA==",
+ "license": "MIT",
+ "dependencies": {
+ "dom7": "^4.0.6",
+ "htm": "^3.1.1",
+ "path-to-regexp": "^6.3.0",
+ "skeleton-elements": "^4.0.1",
+ "ssr-window": "^5.0.1",
+ "swiper": "^12.1.2"
+ },
+ "funding": {
+ "type": "patreon",
+ "url": "https://www.patreon.com/framework7"
+ }
+ },
+ "node_modules/framework7-icons": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/framework7-icons/-/framework7-icons-5.0.5.tgz",
+ "integrity": "sha512-bvHMLyujV9TFuudehd3ORZ/EvNp19Ir3ckVzYAOf3MkLymHba/9oHLsgopCh0x5UsrYZUpkrE+fd7ggj5y4wRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10.0"
+ }
+ },
+ "node_modules/framework7-react": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/framework7-react/-/framework7-react-9.1.2.tgz",
+ "integrity": "sha512-uKLA29qObVZfG1OPtnLUdkxtnkqlqVJAh6GybIfE0WWGgLgAYb9X43WCeTGznRqDjfgdPVHlVesBDDBrfZqs3g==",
+ "license": "MIT",
+ "funding": {
+ "type": "patreon",
+ "url": "https://www.patreon.com/framework7"
+ }
+ },
+ "node_modules/framework7/node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "license": "MIT"
+ },
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -10012,6 +10073,12 @@
"safe-buffer": "~5.1.0"
}
},
+ "node_modules/htm": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz",
+ "integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/html-encoding-sniffer": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
@@ -16647,6 +16714,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/skeleton-elements": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/skeleton-elements/-/skeleton-elements-4.0.1.tgz",
+ "integrity": "sha512-T7YSF/Vu/raUcM6v3HiE4VSY/OvrNflg8Dur3Zza6VVJkq4slxm4pJRpGLNhoOfblIPZLQKh1cu7ADKveyqm/Q==",
+ "license": "MIT"
+ },
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -16844,6 +16917,12 @@
"dev": true,
"license": "BSD-3-Clause"
},
+ "node_modules/ssr-window": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ssr-window/-/ssr-window-5.0.1.tgz",
+ "integrity": "sha512-WVXlhQsm54HC+FnJfEbccEgNF7mKXtnFUB8Xn7rx2dsWHOlBdqezdX88Vjh6pVGaa0ZvL+PoSu7rEcBuNmxt6g==",
+ "license": "MIT"
+ },
"node_modules/stable": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
@@ -17449,6 +17528,25 @@
"node": ">=4"
}
},
+ "node_modules/swiper": {
+ "version": "12.2.0",
+ "resolved": "https://registry.npmjs.org/swiper/-/swiper-12.2.0.tgz",
+ "integrity": "sha512-K8uXsBZU6ME97Ia3xbBge8IRCnR1lOmIILzvY/jGVic7dSTQ530s3uO8RvXbPUtkkXLWIwmZLRPbtDxRWVAFdg==",
+ "funding": [
+ {
+ "type": "custom",
+ "url": "https://sponsors.nolimits4web.com"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nolimits4web"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.7.0"
+ }
+ },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",