[阶段4.2] 前端对接 Flask API + 建议/分析/设置页面,移除废弃的 Node 后端

选型确认为 Python Flask 后,删除 server/ 整套 Node 实现,
根 package.json 改为只管理 client workspace,
npm run dev 同时拉起 Flask 与 React。

fix: 前端读取响应的方式与后端不符
- Flask 返回裸数组/对象,而前端读的是 response.data.data
  (Node 那套 {success,data} 包装),登录后拿到 undefined 直接崩
- api.ts 重写为返回 response.data,并补齐全部端点的 TypeScript 类型
- 401 拦截器改为清除会话并跳转 /login,而不是留在空白页

fix: 同步页缺少 Garmin 密码输入
- 后端只存密码哈希、无法还原,/garmin/sync 要求请求体带明文密码,
  原页面没有该输入框,点同步必然 400
- DataSync 增加密码字段,请求结束后立即清空,并说明为何每次都要输入

新增页面:
- Recommendations: 模型下拉切换(未配置密钥的模型置灰),
  展示本次由哪个模型作答、是否发生了降级、分析了多少天数据;
  按优先级配色,底部附免责声明
- Analysis: 6 个指标 × 4 个时间窗口,展示均值/中位数/极值/
  前后半段差值,并绘制趋势图
- Settings: 模型清单与配置状态、数据隐私说明、退出登录

Dashboard:
- 修正响应结构,数据扁平化后再传给图表
  (原先给 dataKey 传了函数,与 Chart 的 string 类型不符)
- 无数据时引导用户去同步,而不是显示一堆空图表

Chart:
- 按指标而非按行判断是否为空,某天缺某项指标不再让整张图判空
- 折线图 connectNulls,避免设备漏记的日子把线断成碎片

验证: 前端 tsc --noEmit 通过;后端 161 passed / 1 skipped;
实机启动 Flask 后 register/login/models/ai-recommendations 均正常,
未配置密钥时正确降级到规则引擎。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 12:43:52 +08:00
parent c83340742c
commit 8616a13525
30 changed files with 1135 additions and 1777 deletions

View File

@@ -22,6 +22,7 @@
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"typecheck": "tsc --noEmit",
"eject": "react-scripts eject" "eject": "react-scripts eject"
}, },
"eslintConfig": { "eslintConfig": {

View File

@@ -1,5 +1,4 @@
import React from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout'; import Layout from './components/Layout';
import ProtectedRoute from './components/ProtectedRoute'; import ProtectedRoute from './components/ProtectedRoute';
import Login from './pages/Login'; import Login from './pages/Login';

View File

@@ -1,9 +1,11 @@
import React from 'react'; import {
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; LineChart, Line, BarChart, Bar, XAxis, YAxis,
CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts';
interface ChartProps { interface ChartProps {
title: string; title: string;
data: any[]; data: Array<Record<string, any>>;
type: 'line' | 'bar'; type: 'line' | 'bar';
dataKey: string; dataKey: string;
stroke?: string; stroke?: string;
@@ -18,9 +20,13 @@ function Chart({
dataKey, dataKey,
stroke = '#667eea', stroke = '#667eea',
fill = '#667eea', fill = '#667eea',
height = 300, height = 260,
}: ChartProps) { }: ChartProps) {
if (!data || data.length === 0) { // A row may carry some metrics and not others, so emptiness is decided per
// metric rather than by the length of `data`.
const hasValues = data.some((row) => row[dataKey] != null);
if (!hasValues) {
return ( return (
<div className="chart-container"> <div className="chart-container">
<h4>{title}</h4> <h4>{title}</h4>
@@ -29,27 +35,37 @@ function Chart({
); );
} }
const axisProps = { fontSize: 12, stroke: '#999' };
return ( return (
<div className="chart-container"> <div className="chart-container">
<h4>{title}</h4> <h4>{title}</h4>
<ResponsiveContainer width="100%" height={height}> <ResponsiveContainer width="100%" height={height}>
{type === 'line' ? ( {type === 'line' ? (
<LineChart data={data}> <LineChart data={data} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" stroke="#eee" />
<XAxis dataKey="date" fontSize={12} /> <XAxis dataKey="date" {...axisProps} />
<YAxis fontSize={12} /> <YAxis {...axisProps} domain={['auto', 'auto']} />
<Tooltip /> <Tooltip />
<Legend /> {/* connectNulls keeps the line continuous across days the device
<Line type="monotone" dataKey={dataKey} stroke={stroke} dot={{ r: 4 }} /> did not record, instead of breaking it into fragments. */}
<Line
type="monotone"
dataKey={dataKey}
stroke={stroke}
strokeWidth={2}
dot={{ r: 3 }}
connectNulls
name={title}
/>
</LineChart> </LineChart>
) : ( ) : (
<BarChart data={data}> <BarChart data={data} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" stroke="#eee" />
<XAxis dataKey="date" fontSize={12} /> <XAxis dataKey="date" {...axisProps} />
<YAxis fontSize={12} /> <YAxis {...axisProps} />
<Tooltip /> <Tooltip />
<Legend /> <Bar dataKey={dataKey} fill={fill} radius={[3, 3, 0, 0]} name={title} />
<Bar dataKey={dataKey} fill={fill} />
</BarChart> </BarChart>
)} )}
</ResponsiveContainer> </ResponsiveContainer>

View File

@@ -0,0 +1,84 @@
.analysis-controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.metric-tabs,
.range-tabs {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.metric-tab,
.range-tab {
padding: 0.45rem 0.9rem;
border: 1px solid #ddd;
background: white;
border-radius: 999px;
font-size: 0.9rem;
color: #555;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.metric-tab:hover,
.range-tab:hover {
border-color: #667eea;
color: #667eea;
}
.metric-tab.active,
.range-tab.active {
background: #667eea;
border-color: #667eea;
color: white;
}
.range-tabs {
border-top: 1px solid #f0f0f0;
padding-top: 0.75rem;
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.stat-box {
background: #f9f9fb;
border: 1px solid #eee;
border-radius: 8px;
padding: 0.85rem 1rem;
}
.stat-box-label {
font-size: 0.78rem;
color: #999;
margin-bottom: 0.3rem;
}
.stat-box-value {
font-size: 1.15rem;
font-weight: 700;
color: #333;
}
.stat-box-value.up {
color: #2f8f4a;
}
.stat-box-value.down {
color: #c04747;
}
@media (max-width: 600px) {
.stats-row {
grid-template-columns: repeat(2, 1fr);
}
}

View File

@@ -1,11 +1,157 @@
import React from 'react'; import { useCallback, useEffect, useState } from 'react';
import './Pages.css'; import { apiClient, errorMessage, TrendPoint } from '../services/api';
import Chart from '../components/Chart';
import './Analysis.css';
const METRICS = [
{ id: 'steps', label: '步数', unit: '步', type: 'bar' as const },
{ id: 'heart_rate', label: '静息心率', unit: 'bpm', type: 'line' as const },
{ id: 'sleep_duration', label: '睡眠时长', unit: '小时', type: 'line' as const },
{ id: 'sleep_quality', label: '睡眠质量', unit: '分', type: 'line' as const },
{ id: 'stress', label: '压力指数', unit: '', type: 'line' as const },
{ id: 'calories_burned', label: '卡路里消耗', unit: 'kcal', type: 'bar' as const },
];
const RANGES = [
{ days: 7, label: '7 天' },
{ days: 30, label: '30 天' },
{ days: 90, label: '90 天' },
{ days: 365, label: '一年' },
];
function stats(points: TrendPoint[]) {
const values = points.map((p) => p.value).filter((v): v is number => v != null);
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const mean = values.reduce((a, b) => a + b, 0) / values.length;
// Compare the two halves of the window to describe direction of travel.
const mid = Math.floor(values.length / 2);
const firstHalf = values.slice(0, mid);
const secondHalf = values.slice(mid);
const delta =
firstHalf.length && secondHalf.length
? secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length -
firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
: 0;
return {
count: values.length,
mean,
min: sorted[0],
max: sorted[sorted.length - 1],
median: sorted[Math.floor(sorted.length / 2)],
delta,
};
}
function Analysis() { function Analysis() {
const [metric, setMetric] = useState(METRICS[0]);
const [days, setDays] = useState(30);
const [points, setPoints] = useState<TrendPoint[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async (metricId: string, windowDays: number) => {
setLoading(true);
setError('');
try {
const end = new Date();
const start = new Date(end.getTime() - (windowDays - 1) * 86400000);
setPoints(
await apiClient.getTrends(
metricId,
start.toISOString().slice(0, 10),
end.toISOString().slice(0, 10)
)
);
} catch (err: any) {
setError(errorMessage(err, '加载趋势失败'));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load(metric.id, days);
}, [load, metric.id, days]);
const summary = stats(points);
const chartData = points.map((p) => ({ date: p.date.slice(5), value: p.value }));
const fmt = (v: number) => (Math.round(v * 10) / 10).toLocaleString();
return ( return (
<div className="page"> <div className="page">
<h2></h2> <h2></h2>
<p className="placeholder">...</p> <p className="subtitle"></p>
<div className="analysis-controls">
<div className="metric-tabs">
{METRICS.map((m) => (
<button
key={m.id}
className={`metric-tab ${m.id === metric.id ? 'active' : ''}`}
onClick={() => setMetric(m)}
>
{m.label}
</button>
))}
</div>
<div className="range-tabs">
{RANGES.map((r) => (
<button
key={r.days}
className={`range-tab ${r.days === days ? 'active' : ''}`}
onClick={() => setDays(r.days)}
>
{r.label}
</button>
))}
</div>
</div>
{error && <div className="error-message">{error}</div>}
{loading && <div className="page-loading"></div>}
{!loading && !error && (
<>
{summary ? (
<div className="stats-row">
<Stat label="平均" value={`${fmt(summary.mean)} ${metric.unit}`} />
<Stat label="中位数" value={`${fmt(summary.median)} ${metric.unit}`} />
<Stat label="最低" value={`${fmt(summary.min)} ${metric.unit}`} />
<Stat label="最高" value={`${fmt(summary.max)} ${metric.unit}`} />
<Stat
label="后半段较前半段"
value={`${summary.delta >= 0 ? '+' : ''}${fmt(summary.delta)} ${metric.unit}`}
tone={summary.delta >= 0 ? 'up' : 'down'}
/>
<Stat label="有效天数" value={`${summary.count}`} />
</div>
) : (
<p className="placeholder"></p>
)}
<Chart
title={`${metric.label}${metric.unit ? ` (${metric.unit})` : ''}`}
data={chartData}
type={metric.type}
dataKey="value"
height={320}
/>
</>
)}
</div>
);
}
function Stat({ label, value, tone }: { label: string; value: string; tone?: 'up' | 'down' }) {
return (
<div className="stat-box">
<div className="stat-box-label">{label}</div>
<div className={`stat-box-value ${tone ?? ''}`}>{value}</div>
</div> </div>
); );
} }

View File

@@ -1,218 +1,142 @@
import React, { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { apiClient } from '../services/api'; import { Link } from 'react-router-dom';
import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart from '../components/Chart'; import Chart from '../components/Chart';
import './Dashboard.css'; import './Dashboard.css';
interface HealthData { /** Flattened row so every chart can address its metric with a plain key. */
interface ChartRow {
date: string; date: string;
steps?: number; steps: number | null;
heartRate?: number; heartRate: number | null;
sleep?: { sleepHours: number | null;
duration: number; calories: number | null;
quality: number; }
};
caloriesBurned?: number; function average(values: Array<number | null | undefined>): number {
const present = values.filter((v): v is number => v != null);
if (present.length === 0) return 0;
return present.reduce((a, b) => a + b, 0) / present.length;
} }
function Dashboard() { function Dashboard() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>(''); const [error, setError] = useState('');
const [summary, setSummary] = useState<HealthData[]>([]); const [rows, setRows] = useState<ChartRow[]>([]);
const [today, setToday] = useState<HealthData | null>(null); const [today, setToday] = useState<HealthDay | null>(null);
const [stats, setStats] = useState({
avgSteps: 0,
avgHeartRate: 0,
avgSleep: 0,
totalCalories: 0,
});
useEffect(() => { useEffect(() => {
loadData(); const load = async () => {
}, []);
const loadData = async () => {
try { try {
setLoading(true); const end = new Date();
setError(''); const start = new Date(end.getTime() - 29 * 24 * 60 * 60 * 1000);
const endStr = end.toISOString().slice(0, 10);
// Load 30-day summary const summary = await apiClient.getHealthSummary(
const now = new Date(); start.toISOString().slice(0, 10),
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); endStr
const startDate = thirtyDaysAgo.toISOString().split('T')[0];
const endDate = now.toISOString().split('T')[0];
const response = await apiClient.getHealthSummary(startDate, endDate);
const data = response.data.data || [];
setSummary(data);
// Get today's data
const todayData = data.find((d: HealthData) => d.date === endDate);
setToday(todayData || null);
// Calculate stats
const validData = data.filter((d: HealthData) => d.steps || d.heartRate);
if (validData.length > 0) {
const avgSteps =
Math.round(
validData.reduce((sum: number, d: HealthData) => sum + (d.steps || 0), 0) /
validData.length
) || 0;
const heartRates = validData
.map((d: HealthData) => d.heartRate)
.filter((hr) => hr);
const avgHeartRate =
heartRates.length > 0
? Math.round(heartRates.reduce((a, b) => a + b, 0) / heartRates.length)
: 0;
const sleeps = validData
.map((d: HealthData) => d.sleep?.duration)
.filter((s) => s);
const avgSleep =
sleeps.length > 0
? Math.round((sleeps.reduce((a, b) => a + b, 0) / sleeps.length) * 10) / 10
: 0;
const totalCalories = Math.round(
validData.reduce((sum: number, d: HealthData) => sum + (d.caloriesBurned || 0), 0)
); );
setStats({ avgSteps, avgHeartRate, avgSleep, totalCalories }); setRows(
} summary.map((d) => ({
date: d.date.slice(5), // MM-DD keeps the axis readable
setLoading(false); steps: d.steps,
heartRate: d.heartRate,
sleepHours: d.sleep?.duration ?? null,
calories: d.caloriesBurned,
}))
);
setToday(summary.find((d) => d.date === endStr) ?? null);
} catch (err: any) { } catch (err: any) {
const errorMsg = err.response?.data?.error?.message || 'Failed to load data'; setError(errorMessage(err, '加载数据失败'));
setError(errorMsg); } finally {
setLoading(false); setLoading(false);
} }
}; };
load();
}, []);
if (loading) { if (loading) return <div className="page-loading"></div>;
return <div className="page-loading"> ...</div>;
} const hasData = rows.length > 0;
const stats = {
steps: Math.round(average(rows.map((r) => r.steps))),
heartRate: Math.round(average(rows.map((r) => r.heartRate))),
sleep: Math.round(average(rows.map((r) => r.sleepHours)) * 10) / 10,
calories: Math.round(
rows.reduce((sum, r) => sum + (r.calories ?? 0), 0)
),
};
const show = (v: number | null | undefined, suffix = '') =>
v == null ? '—' : `${v.toLocaleString()}${suffix}`;
return ( return (
<div className="page"> <div className="page">
<h2>📊 </h2> <h2></h2>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
{/* Today's Summary */} {!hasData && !error && (
<div className="empty-state">
<p></p>
<Link to="/sync" className="btn btn-primary">
Garmin
</Link>
</div>
)}
{hasData && (
<>
<section className="today-summary">
<h3></h3>
{today ? ( {today ? (
<div className="today-summary">
<h3></h3>
<div className="stats-grid"> <div className="stats-grid">
<div className="stat-card"> <StatCard icon="🚶" label="步数" value={show(today.steps)} />
<div className="stat-icon">🚶</div> <StatCard icon="❤️" label="静息心率" value={show(today.heartRate, ' bpm')} />
<div className="stat-content"> <StatCard icon="😴" label="睡眠" value={show(today.sleep?.duration, ' 小时')} />
<div className="stat-label"></div> <StatCard icon="🔥" label="消耗" value={show(today.caloriesBurned, ' kcal')} />
<div className="stat-value">{today.steps || '-'}</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{today.heartRate || '-'} bpm</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">😴</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{today.sleep?.duration || '-'} </div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">🔥</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{today.caloriesBurned || '-'} kcal</div>
</div>
</div>
</div>
</div> </div>
) : ( ) : (
<div className="placeholder"></div> <p className="placeholder"></p>
)} )}
</section>
{/* 30-Day Statistics */} <section className="statistics">
<div className="statistics"> <h3> 30 </h3>
<h3>30 </h3>
<div className="stats-grid"> <div className="stats-grid">
<div className="stat-card"> <StatCard icon="📈" label="日均步数" value={show(stats.steps)} />
<div className="stat-icon">📈</div> <StatCard icon="❤️" label="平均静息心率" value={show(stats.heartRate, ' bpm')} />
<div className="stat-content"> <StatCard icon="😴" label="日均睡眠" value={show(stats.sleep, ' 小时')} />
<div className="stat-label"></div> <StatCard icon="🔥" label="累计消耗" value={show(stats.calories, ' kcal')} />
<div className="stat-value">{stats.avgSteps.toLocaleString()}</div>
</div>
</div> </div>
</section>
<div className="stat-card"> <section className="charts-section">
<div className="stat-icon"></div> <h3></h3>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{stats.avgHeartRate} bpm</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">😴</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{stats.avgSleep}h</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">🔥</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{stats.totalCalories.toLocaleString()}</div>
</div>
</div>
</div>
</div>
{/* Charts */}
{summary.length > 0 && (
<div className="charts-section">
<h3></h3>
<div className="charts-grid"> <div className="charts-grid">
<Chart title="步数" data={summary} type="bar" dataKey="steps" fill="#667eea" /> <Chart title="步数" data={rows} type="bar" dataKey="steps" fill="#667eea" />
<Chart <Chart title="静息心率 (bpm)" data={rows} type="line" dataKey="heartRate" stroke="#ff6b9d" />
title="心率" <Chart title="睡眠时长 (小时)" data={rows} type="line" dataKey="sleepHours" stroke="#f0a500" />
data={summary.filter((d) => d.heartRate)} <Chart title="卡路里消耗" data={rows} type="bar" dataKey="calories" fill="#4caf50" />
type="line"
dataKey="heartRate"
stroke="#ff6b9d"
/>
<Chart
title="睡眠时长"
data={summary.filter((d) => d.sleep)}
type="line"
dataKey={(d: any) => d.sleep?.duration}
stroke="#ffd93d"
/>
<Chart
title="卡路里消耗"
data={summary}
type="bar"
dataKey="caloriesBurned"
fill="#6bcf7f"
/>
</div>
</div> </div>
</section>
</>
)} )}
</div> </div>
); );
} }
function StatCard({ icon, label, value }: { icon: string; label: string; value: string }) {
return (
<div className="stat-card">
<div className="stat-icon">{icon}</div>
<div className="stat-content">
<div className="stat-label">{label}</div>
<div className="stat-value">{value}</div>
</div>
</div>
);
}
export default Dashboard; export default Dashboard;

View File

@@ -1,160 +1,140 @@
import React, { useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { apiClient } from '../services/api'; import { apiClient, errorMessage, SyncStatus } from '../services/api';
import './DataSync.css'; import './DataSync.css';
interface SyncStatus {
status: 'idle' | 'syncing' | 'error';
lastSyncTime: string | null;
recordsSynced: number;
lastError?: string;
}
function DataSync() { function DataSync() {
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null); const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [garminPassword, setGarminPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>(''); const [error, setError] = useState('');
const [message, setMessage] = useState<string>(''); const [message, setMessage] = useState('');
// Load sync status on component mount const loadSyncStatus = useCallback(async () => {
useEffect(() => {
loadSyncStatus();
}, []);
const loadSyncStatus = async () => {
try { try {
const response = await apiClient.getGarminSyncStatus(); setSyncStatus(await apiClient.getGarminSyncStatus());
setSyncStatus(response.data.data); } catch (err) {
} catch (err: any) { // A failed status poll should not blank the page; the sync button
// remains usable and will surface its own errors.
console.error('Failed to load sync status:', err); console.error('Failed to load sync status:', err);
} }
}; }, []);
const handleSync = async () => { useEffect(() => {
setLoading(true); loadSyncStatus();
}, [loadSyncStatus]);
const handleSync = async (e: React.FormEvent) => {
e.preventDefault();
setError(''); setError('');
setMessage(''); setMessage('');
try { if (!garminPassword) {
const response = await apiClient.syncGarminData(); setError('请输入 Garmin 密码');
return;
if (response.data.success) {
setMessage(`✅ 同步完成!新增/更新 ${response.data.data.recordsSynced} 天数据`);
// Reload sync status
loadSyncStatus();
} else {
setError(`❌ 同步失败:${response.data.data.message}`);
} }
setLoading(true);
try {
const result = await apiClient.syncGarminData(garminPassword);
if (result.status === 'success') {
setMessage(`同步完成,新增/更新 ${result.recordsSynced} 天数据`);
} else {
setError(result.message);
}
// Clear the password as soon as the request is done — it is only ever
// held in memory for the duration of the call.
setGarminPassword('');
loadSyncStatus();
} catch (err: any) { } catch (err: any) {
const errorMsg = err.response?.data?.error?.message || 'Sync failed'; setError(errorMessage(err, '同步失败'));
setError(`❌ 同步失败:${errorMsg}`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const getStatusColor = (status: string) => { const statusLabel: Record<string, string> = {
switch (status) { idle: '就绪',
case 'idle': syncing: '正在同步…',
return '#999'; error: '上次同步失败',
case 'syncing':
return '#667eea';
case 'error':
return '#c33';
default:
return '#999';
}
}; };
const getStatusText = (status: string) => { const busy = loading || syncStatus?.status === 'syncing';
switch (status) {
case 'idle':
return '就绪';
case 'syncing':
return '正在同步...';
case 'error':
return '同步失败';
default:
return '未知';
}
};
return ( return (
<div className="page"> <div className="page">
<h2>📱 </h2> <h2></h2>
<p className="subtitle"> Garmin Connect </p> <p className="subtitle"> Garmin Connect 7 </p>
<div className="sync-container"> <div className="sync-container">
{/* Status Card */} <section className="status-card">
<div className="status-card">
<h3></h3> <h3></h3>
{syncStatus ? ( {syncStatus ? (
<div className="status-info"> <div className="status-info">
<div className="status-item"> <div className="status-item">
<span className="label"></span> <span className="label"></span>
<span <span className={`value status-${syncStatus.status}`}>
className="value" {statusLabel[syncStatus.status] ?? syncStatus.status}
style={{ color: getStatusColor(syncStatus.status) }}
>
{getStatusText(syncStatus.status)}
</span> </span>
</div> </div>
{syncStatus.lastSyncTime && (
<div className="status-item"> <div className="status-item">
<span className="label"></span> <span className="label"></span>
<span className="value"> <span className="value">
{new Date(syncStatus.lastSyncTime).toLocaleString('zh-CN')} {syncStatus.lastSyncTime
? new Date(syncStatus.lastSyncTime).toLocaleString('zh-CN')
: '从未同步'}
</span> </span>
</div> </div>
)}
<div className="status-item"> <div className="status-item">
<span className="label"></span> <span className="label"></span>
<span className="value">{syncStatus.recordsSynced} </span> <span className="value">{syncStatus.recordsSynced}</span>
</div> </div>
{syncStatus.lastError && ( {syncStatus.lastError && (
<div className="status-item error"> <div className="status-item error">
<span className="label"></span> <span className="label"></span>
<span className="value">{syncStatus.lastError}</span> <span className="value">{syncStatus.lastError}</span>
</div> </div>
)} )}
</div> </div>
) : ( ) : (
<p className="placeholder">...</p> <p className="placeholder"></p>
)} )}
</div> </section>
{/* Sync Button */} <form className="sync-actions" onSubmit={handleSync}>
<div className="sync-actions"> <div className="form-group">
<button <label htmlFor="garmin-password">Garmin </label>
onClick={handleSync} <input
disabled={loading || (syncStatus?.status === 'syncing')} id="garmin-password"
className="btn btn-primary btn-large" type="password"
> value={garminPassword}
{loading || syncStatus?.status === 'syncing' ? '✋ 正在同步...' : '🔄 立即同步'} onChange={(e) => setGarminPassword(e.target.value)}
</button> placeholder="••••••••"
autoComplete="current-password"
<p className="help-text"> disabled={busy}
7 Garmin />
<p className="field-hint">
Garmin
</p> </p>
</div> </div>
{/* Messages */} <button type="submit" className="btn btn-primary btn-large" disabled={busy}>
{busy ? '正在同步…' : '立即同步'}
</button>
</form>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
{message && <div className="success-message">{message}</div>} {message && <div className="success-message">{message}</div>}
{/* Info Box */} <section className="info-box">
<div className="info-box"> <h4></h4>
<h4>💡 </h4>
<ul> <ul>
<li> 7 </li> <li> 7 </li>
<li></li> <li></li>
<li></li> <li></li>
<li> Garmin API </li> <li> <code>garminconnect</code> </li>
</ul> </ul>
</div> </section>
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { apiClient } from '../services/api'; import { apiClient, errorMessage } from '../services/api';
import './Login.css'; import './Login.css';
type TabType = 'login' | 'register'; type TabType = 'login' | 'register';
@@ -47,17 +47,11 @@ function Login() {
setLoading(true); setLoading(true);
try { try {
const response = await apiClient.login(loginEmail, loginPassword); const { token } = await apiClient.login(loginEmail, loginPassword);
const { token } = response.data.data;
// Store token
apiClient.setSession(token); apiClient.setSession(token);
// Redirect to dashboard
navigate('/'); navigate('/');
} catch (err: any) { } catch (err: any) {
const message = err.response?.data?.error?.message || 'Login failed'; setError(errorMessage(err, '登录失败'));
setError(message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -90,17 +84,11 @@ function Login() {
setLoading(true); setLoading(true);
try { try {
const response = await apiClient.register(regEmail, regGarminEmail, regPassword); const { token } = await apiClient.register(regEmail, regGarminEmail, regPassword);
const { token } = response.data.data;
// Store token
apiClient.setSession(token); apiClient.setSession(token);
// Redirect to dashboard
navigate('/'); navigate('/');
} catch (err: any) { } catch (err: any) {
const message = err.response?.data?.error?.message || 'Registration failed'; setError(errorMessage(err, '注册失败'));
setError(message);
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -0,0 +1,171 @@
.model-bar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 1rem;
background: #f9f9fb;
border: 1px solid #eee;
border-radius: 8px;
margin-bottom: 1rem;
}
.model-bar label {
font-weight: 600;
color: #555;
font-size: 0.9rem;
}
.model-bar select {
flex: 1;
min-width: 220px;
padding: 0.5rem 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.95rem;
font-family: inherit;
background: white;
}
.model-bar select:disabled {
background: #f0f0f0;
color: #999;
}
.meta-bar {
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: 0.9rem;
margin-bottom: 1.25rem;
}
.meta-bar.ai {
background: #eef2ff;
border: 1px solid #d6dfff;
color: #3b4a94;
}
.meta-bar.rules {
background: #fff8e6;
border: 1px solid #ffe4a3;
color: #7a5a10;
}
.meta-bar .fallback {
opacity: 0.8;
margin-left: 0.4rem;
}
.notice {
padding: 1rem;
background: #fff8e6;
border: 1px solid #ffe4a3;
border-radius: 8px;
color: #7a5a10;
font-size: 0.9rem;
line-height: 1.7;
margin-bottom: 1rem;
}
.notice code {
background: rgba(0, 0, 0, 0.06);
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-size: 0.85em;
}
.rec-list {
display: grid;
gap: 1rem;
}
.rec-card {
background: white;
border: 1px solid #eee;
border-left: 4px solid #ccc;
border-radius: 8px;
padding: 1.1rem 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.rec-card.priority-high {
border-left-color: #e05252;
}
.rec-card.priority-medium {
border-left-color: #e0a052;
}
.rec-card.priority-low {
border-left-color: #6bbf7f;
}
.rec-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.5rem;
}
.rec-category {
font-weight: 700;
color: #333;
font-size: 1rem;
}
.rec-priority {
font-size: 0.75rem;
font-weight: 700;
padding: 0.15rem 0.6rem;
border-radius: 999px;
white-space: nowrap;
}
.rec-priority.priority-high {
background: #fdecec;
color: #b03030;
}
.rec-priority.priority-medium {
background: #fdf3e6;
color: #9a6412;
}
.rec-priority.priority-low {
background: #eaf6ed;
color: #35733f;
}
.rec-body {
color: #444;
line-height: 1.75;
margin: 0;
}
.rec-based-on {
margin-top: 0.7rem;
font-size: 0.8rem;
color: #999;
}
.disclaimer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #eee;
font-size: 0.82rem;
color: #999;
text-align: center;
}
@media (max-width: 600px) {
.model-bar {
flex-direction: column;
align-items: stretch;
}
.model-bar select,
.model-bar .btn {
width: 100%;
}
}

View File

@@ -1,11 +1,139 @@
import React from 'react'; import { useCallback, useEffect, useState } from 'react';
import './Pages.css'; import {
apiClient, errorMessage, AiRecommendations, ModelInfo,
} from '../services/api';
import './Recommendations.css';
const PRIORITY_LABEL: Record<string, string> = {
high: '高',
medium: '中',
low: '低',
};
function Recommendations() { function Recommendations() {
const [models, setModels] = useState<ModelInfo[]>([]);
const [selected, setSelected] = useState<string>('');
const [result, setResult] = useState<AiRecommendations | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async (model?: string) => {
setLoading(true);
setError('');
try {
setResult(await apiClient.getAiRecommendations(model || undefined));
} catch (err: any) {
setError(errorMessage(err, '获取建议失败'));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const init = async () => {
try {
const list = await apiClient.getModels();
setModels(list);
setSelected(list.find((m) => m.default && m.configured)?.id ?? '');
} catch {
// The model list is a convenience; recommendations still work without it.
}
load();
};
init();
}, [load]);
const configured = models.filter((m) => m.configured);
const meta = result?.meta;
return ( return (
<div className="page"> <div className="page">
<h2></h2> <h2></h2>
<p className="placeholder">...</p> <p className="subtitle"></p>
<div className="model-bar">
<label htmlFor="model-select"></label>
<select
id="model-select"
value={selected}
onChange={(e) => {
setSelected(e.target.value);
load(e.target.value);
}}
disabled={loading || configured.length === 0}
>
<option value=""></option>
{models.map((m) => (
<option key={m.id} value={m.id} disabled={!m.configured}>
{m.id} · {(m.contextWindow / 1000).toLocaleString()}k
{m.configured ? '' : '(未配置密钥)'}
</option>
))}
</select>
<button
className="btn btn-primary"
onClick={() => load(selected)}
disabled={loading}
>
{loading ? '生成中…' : '重新生成'}
</button>
</div>
{configured.length === 0 && models.length > 0 && (
<div className="notice">
<code>backend/.env</code>
<code>GEMINI_API_KEY</code> <code>NVIDIA_API_KEY</code> AI
</div>
)}
{error && <div className="error-message">{error}</div>}
{meta && (
<div className={`meta-bar ${meta.source}`}>
{meta.source === 'ai' ? (
<>
<strong>AI </strong> · {meta.model} · {meta.days}
{meta.fallbackFrom && meta.fallbackFrom.length > 0 && (
<span className="fallback">
{meta.fallbackFrom.join('、')}
</span>
)}
</>
) : (
<>
<strong></strong>
{meta.reason && <span className="fallback">· {meta.reason}</span>}
</>
)}
</div>
)}
{loading && !result && <div className="page-loading"></div>}
<div className="rec-list">
{result?.recommendations.map((rec) => (
<article key={rec.id} className={`rec-card priority-${rec.priority}`}>
<header className="rec-header">
<span className="rec-category">{rec.category}</span>
<span className={`rec-priority priority-${rec.priority}`}>
{PRIORITY_LABEL[rec.priority] ?? rec.priority}
</span>
</header>
<p className="rec-body">{rec.recommendation}</p>
{rec.basedOn.length > 0 && (
<footer className="rec-based-on">
{rec.basedOn.join('、')}
</footer>
)}
</article>
))}
</div>
<p className="disclaimer">
</p>
</div> </div>
); );
} }

View File

@@ -0,0 +1,123 @@
.settings-section {
margin-bottom: 2.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #f0f0f0;
}
.settings-section:last-child {
border-bottom: none;
}
.settings-section h3 {
font-size: 1.1rem;
color: #333;
margin: 0 0 0.6rem 0;
}
.settings-hint {
color: #777;
font-size: 0.9rem;
line-height: 1.7;
margin-bottom: 1rem;
}
.settings-hint code,
.model-table code {
background: #f2f2f5;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-size: 0.85em;
}
.model-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.model-table th {
text-align: left;
padding: 0.6rem 0.75rem;
border-bottom: 2px solid #eee;
color: #888;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.model-table td {
padding: 0.7rem 0.75rem;
border-bottom: 1px solid #f4f4f4;
color: #444;
}
.model-name {
color: #777;
font-size: 0.85rem;
}
.badge {
display: inline-block;
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.badge.ok {
background: #eaf6ed;
color: #35733f;
}
.badge.off {
background: #f2f2f2;
color: #999;
}
.badge-default {
margin-left: 0.4rem;
padding: 0.05rem 0.4rem;
background: #eef2ff;
color: #4a5aa8;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
}
.settings-list {
margin: 0;
padding-left: 1.3rem;
color: #555;
line-height: 1.9;
font-size: 0.9rem;
}
.btn-danger {
background: #fff;
color: #c04747;
border: 1px solid #e0b4b4;
padding: 0.6rem 1.2rem;
border-radius: 6px;
font-size: 0.95rem;
cursor: pointer;
font-family: inherit;
transition: all 0.2s ease;
}
.btn-danger:hover {
background: #fdecec;
border-color: #c04747;
}
@media (max-width: 600px) {
.model-table {
font-size: 0.8rem;
}
.model-table th,
.model-table td {
padding: 0.5rem 0.4rem;
}
}

View File

@@ -1,11 +1,91 @@
import React from 'react'; import { useEffect, useState } from 'react';
import './Pages.css'; import { useNavigate } from 'react-router-dom';
import { apiClient, ModelInfo } from '../services/api';
import './Settings.css';
function Settings() { function Settings() {
const navigate = useNavigate();
const [models, setModels] = useState<ModelInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
apiClient
.getModels()
.then(setModels)
.catch(() => setModels([]))
.finally(() => setLoading(false));
}, []);
const handleLogout = async () => {
await apiClient.logout();
navigate('/login');
};
return ( return (
<div className="page"> <div className="page">
<h2></h2> <h2></h2>
<p className="placeholder">...</p>
<section className="settings-section">
<h3>AI </h3>
<p className="settings-hint">
<code>backend/.env</code>
<code>AI_MODEL_CHAIN</code>
</p>
{loading ? (
<p className="placeholder"></p>
) : models.length === 0 ? (
<p className="placeholder"></p>
) : (
<table className="model-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{models.map((m) => (
<tr key={m.id}>
<td>
<code>{m.id}</code>
{m.default && <span className="badge-default"></span>}
</td>
<td className="model-name">{m.model}</td>
<td>{(m.contextWindow / 1000).toLocaleString()}k</td>
<td>
<span className={`badge ${m.configured ? 'ok' : 'off'}`}>
{m.configured ? '已配置' : '缺少密钥'}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="settings-section">
<h3></h3>
<ul className="settings-list">
<li></li>
<li>Garmin </li>
<li>
使 AI CSV
</li>
</ul>
</section>
<section className="settings-section">
<h3></h3>
<button className="btn btn-danger" onClick={handleLogout}>
退
</button>
</section>
</div> </div>
); );
} }

View File

@@ -3,15 +3,86 @@ import axios, { AxiosInstance } from 'axios';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api'; const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
const TOKEN_KEY = 'ghl_token'; const TOKEN_KEY = 'ghl_token';
// The Flask backend returns bare JSON (an array, or the object itself) and
// signals failure with `{ error: "..." }` plus a non-2xx status. There is no
// {success, data} envelope, so responses are read as `response.data` directly.
export interface AuthResponse {
id: string;
email: string;
token: string;
}
export interface HealthDay {
date: string;
steps: number | null;
heartRate: number | null;
heartRateVariability: number | null;
sleep: { duration: number; quality: number | null } | null;
stress: number | null;
caloriesBurned: number | null;
}
export interface SyncStatus {
status: 'idle' | 'syncing' | 'error';
lastSyncTime: string | null;
recordsSynced: number;
lastError: string | null;
}
export interface SyncResult {
status: 'success' | 'error';
recordsSynced: number;
message: string;
lastSyncTime: string;
}
export interface Recommendation {
id: string;
category: string;
recommendation: string;
priority: 'high' | 'medium' | 'low';
basedOn: string[];
source?: 'ai';
}
export interface AiRecommendations {
recommendations: Recommendation[];
meta: {
source: 'ai' | 'rules';
model: string | null;
provider?: string;
days?: number;
fallbackFrom?: string[];
reason?: string;
};
}
export interface ModelInfo {
id: string;
model: string;
provider: string;
contextWindow: number;
configured: boolean;
default: boolean;
}
export interface TrendPoint {
date: string;
value: number;
}
/** Pull a human-readable message out of an axios error. */
export function errorMessage(err: any, fallback = '请求失败'): string {
return err?.response?.data?.error || err?.message || fallback;
}
class ApiClient { class ApiClient {
private client: AxiosInstance; private client: AxiosInstance;
constructor() { constructor() {
this.client = axios.create({ this.client = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
},
}); });
// Attach the saved JWT to every request. // Attach the saved JWT to every request.
@@ -24,36 +95,23 @@ class ApiClient {
return config; return config;
}); });
// On 401, drop the stored session so the UI can redirect to login. // On 401 drop the session and bounce to login, so an expired token does
// not leave the user staring at empty pages.
this.client.interceptors.response.use( this.client.interceptors.response.use(
(resp) => resp, (resp) => resp,
(error) => { (error) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
} }
return Promise.reject(error); return Promise.reject(error);
} }
); );
} }
// --- Auth --- // --- session ---
register(email: string, garminEmail: string, garminPassword: string) {
return this.client.post('/auth/register', { email, garminEmail, garminPassword });
}
login(email: string, password: string) {
return this.client.post('/auth/login', { email, password });
}
logout() {
return this.client.post('/auth/logout');
}
refresh() {
return this.client.post('/auth/refresh');
}
// Persist the JWT returned by register/login.
setSession(token: string) { setSession(token: string) {
localStorage.setItem(TOKEN_KEY, token); localStorage.setItem(TOKEN_KEY, token);
} }
@@ -62,47 +120,99 @@ class ApiClient {
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
} }
// --- Garmin --- isAuthenticated(): boolean {
syncGarminData(garminEmail?: string, garminPassword?: string) { return Boolean(localStorage.getItem(TOKEN_KEY));
const body =
garminEmail || garminPassword ? { garminEmail, garminPassword } : {};
return this.client.post('/garmin/sync', body);
} }
getGarminSyncStatus() { // --- auth ---
return this.client.get('/garmin/status'); async register(email: string, garminEmail: string, garminPassword: string) {
const { data } = await this.client.post<AuthResponse>('/auth/register', {
email,
garminEmail,
garminPassword,
});
return data;
} }
// --- Health --- async login(email: string, password: string) {
getHealthSummary(startDate?: string, endDate?: string) { const { data } = await this.client.post<AuthResponse>('/auth/login', {
return this.client.get('/health/summary', { params: { startDate, endDate } }); email,
password,
});
return data;
} }
getStepsData(startDate?: string, endDate?: string) { async logout() {
return this.client.get('/health/steps', { params: { startDate, endDate } }); try {
await this.client.post('/auth/logout');
} finally {
this.clearSession();
}
} }
getHeartRateData(startDate?: string, endDate?: string) { // --- garmin ---
return this.client.get('/health/heart-rate', { params: { startDate, endDate } }); /**
* The backend stores only a hash of the Garmin password, so a live sync
* needs the plaintext password supplied here each time.
*/
async syncGarminData(garminPassword: string, garminEmail?: string) {
const { data } = await this.client.post<SyncResult>('/garmin/sync', {
garminPassword,
...(garminEmail ? { garminEmail } : {}),
});
return data;
} }
getSleepData(startDate?: string, endDate?: string) { async getGarminSyncStatus() {
return this.client.get('/health/sleep', { params: { startDate, endDate } }); const { data } = await this.client.get<SyncStatus>('/garmin/status');
return data;
} }
getActivities(startDate?: string, endDate?: string) { // --- health ---
return this.client.get('/health/activities', { params: { startDate, endDate } }); private range(startDate?: string, endDate?: string) {
return { params: { startDate, endDate } };
} }
// --- Analysis --- async getHealthSummary(startDate?: string, endDate?: string) {
getTrends(metricType?: string, startDate?: string, endDate?: string) { const { data } = await this.client.get<HealthDay[]>(
return this.client.get('/analysis/trends', { '/health/summary',
this.range(startDate, endDate)
);
return data;
}
async getActivities(startDate?: string, endDate?: string) {
const { data } = await this.client.get<any[]>(
'/health/activities',
this.range(startDate, endDate)
);
return data;
}
// --- analysis ---
async getTrends(metricType: string, startDate?: string, endDate?: string) {
const { data } = await this.client.get<TrendPoint[]>('/analysis/trends', {
params: { metricType, startDate, endDate }, params: { metricType, startDate, endDate },
}); });
return data;
} }
getRecommendations() { async getRecommendations() {
return this.client.get('/analysis/recommendations'); const { data } = await this.client.get<Recommendation[]>('/analysis/recommendations');
return data;
}
async getModels() {
const { data } = await this.client.get<ModelInfo[]>('/analysis/models');
return data;
}
async getAiRecommendations(model?: string, days?: number) {
const { data } = await this.client.get<AiRecommendations>(
'/analysis/ai-recommendations',
{ params: { model, days } }
);
return data;
} }
} }

View File

@@ -1,21 +1,20 @@
{ {
"name": "garmin-health-lab", "name": "garmin-health-lab",
"version": "0.1.0", "version": "0.1.0",
"description": "佳明健康数据分析平台", "description": "佳明健康数据分析平台 - React 前端 + Flask 后端",
"private": true, "private": true,
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:server": "cd server && npm run dev",
"dev:client": "cd client && npm start",
"build": "npm run build:server && npm run build:client",
"build:server": "cd server && npm run build",
"build:client": "cd client && npm run build",
"start": "node server/dist/index.js"
},
"workspaces": [ "workspaces": [
"server",
"client" "client"
], ],
"scripts": {
"dev": "concurrently -n backend,client -c blue,green \"npm run dev:backend\" \"npm run dev:client\"",
"dev:backend": "cd backend && .venv/bin/python app.py",
"dev:client": "npm start --workspace=client",
"build": "npm run build --workspace=client",
"typecheck": "npm run typecheck --workspace=client",
"test": "cd backend && .venv/bin/python -m pytest",
"setup:backend": "cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt"
},
"devDependencies": { "devDependencies": {
"concurrently": "^8.2.0" "concurrently": "^8.2.0"
} }

View File

@@ -1,21 +0,0 @@
# App
PORT=5000
NODE_ENV=development
JWT_SECRET=generate_a_random_string
CORS_ORIGIN=http://localhost:3000
# --- Database ---
# Local/dev: SQLite (zero config)
# DB_TYPE=sqlite
# DATABASE_PATH=./data/health.db
#
# Production: MariaDB on the NAS
DB_TYPE=mariadb
MARIADB_SOCKET=/run/mysqld/mysqld10.sock
MARIADB_USER=root
MARIADB_PASSWORD=your_nas_mariadb_root_password
MARIADB_DATABASE=garmin_health_lab
# Garmin Connect
GARMIN_CONNECT_USER=your_garmin_email
GARMIN_CONNECT_PASSWORD=your_garmin_password

View File

@@ -1,34 +0,0 @@
{
"name": "garmin-health-lab-server",
"version": "0.1.0",
"description": "Garmin Health Lab Backend",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts"
},
"dependencies": {
"axios": "^1.5.0",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"garmin-connect": "^1.6.2",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.6.0",
"sqlite3": "^5.1.6",
"ts-node": "^10.9.1"
},
"devDependencies": {
"@types/cors": "^2.8.13",
"@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.2",
"@types/node": "^20.3.1",
"@types/sqlite3": "^3.1.8",
"tsx": "^3.12.7",
"typescript": "^5.1.3"
}
}

View File

@@ -1,47 +0,0 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { initializeDatabase } from './utils/database';
import authRoutes from './routes/auth';
import garminRoutes from './routes/garmin';
import healthRoutes from './routes/health';
import analysisRoutes from './routes/analysis';
import { errorHandler } from './middleware/errorHandler';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 5000;
// Middleware
app.use(express.json());
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
credentials: true
}));
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/garmin', garminRoutes);
app.use('/api/health', healthRoutes);
app.use('/api/analysis', analysisRoutes);
// Health check
app.get('/api/health/status', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Error handling
app.use(errorHandler);
// Initialize database, then start accepting requests
initializeDatabase()
.then(() => {
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
})
.catch((err) => {
console.error('[db] Failed to initialize database:', err);
process.exit(1);
});

View File

@@ -1,65 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { verifyToken } from '../services/AuthService';
import { AppError } from './errorHandler';
export interface AuthRequest extends Request {
userId?: string;
userEmail?: string;
}
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({
error: {
status: 401,
message: 'Missing authorization header',
},
});
}
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return res.status(401).json({
error: {
status: 401,
message: 'Invalid authorization header format',
},
});
}
const token = parts[1];
try {
const payload = verifyToken(token);
req.userId = payload.userId;
next();
} catch (error: any) {
return res.status(401).json({
error: {
status: 401,
message: 'Invalid or expired token',
},
});
}
}
export function optionalAuthMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (authHeader) {
const parts = authHeader.split(' ');
if (parts.length === 2 && parts[0] === 'Bearer') {
const token = parts[1];
try {
const payload = verifyToken(token);
req.userId = payload.userId;
} catch (error) {
// Silently fail - continue without authentication
}
}
}
next();
}

View File

@@ -1,31 +0,0 @@
import { Request, Response, NextFunction } from 'express';
export function errorHandler(
err: any,
req: Request,
res: Response,
next: NextFunction
) {
console.error('Error:', err);
const status = err.status || 500;
const message = err.message || 'Internal server error';
res.status(status).json({
error: {
status,
message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
}
export class AppError extends Error {
constructor(
public status: number,
public message: string
) {
super(message);
this.name = this.constructor.name;
}
}

View File

@@ -1,14 +0,0 @@
import express from 'express';
const router = express.Router();
// TODO: Implement analysis endpoints
router.get('/trends', (req, res) => {
res.json({ message: 'Trends analysis endpoint' });
});
router.get('/recommendations', (req, res) => {
res.json({ message: 'Recommendations endpoint' });
});
export default router;

View File

@@ -1,154 +0,0 @@
import express from 'express';
import { register, login, logout, getUserById } from '../services/AuthService';
import { authMiddleware, AuthRequest } from '../middleware/authMiddleware';
const router = express.Router();
/**
* POST /api/auth/register
* Register a new user
*/
router.post('/register', async (req, res, next) => {
try {
const { email, garminEmail, garminPassword } = req.body;
if (!email || !garminEmail || !garminPassword) {
return res.status(400).json({
error: {
status: 400,
message: 'Missing required fields: email, garminEmail, garminPassword',
},
});
}
const result = await register({ email, garminEmail, garminPassword });
res.status(201).json({
success: true,
data: {
userId: result.id,
email: result.email,
token: result.token,
},
});
} catch (error: any) {
if (error.code === 'EMAIL_TAKEN') {
return res.status(409).json({
error: {
status: 409,
message: error.message,
},
});
}
next(error);
}
});
/**
* POST /api/auth/login
* Login with email and password
*/
router.post('/login', async (req, res, next) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({
error: {
status: 400,
message: 'Missing required fields: email, password',
},
});
}
const result = await login(email, password);
res.json({
success: true,
data: {
userId: result.id,
email: result.email,
token: result.token,
},
});
} catch (error: any) {
if (error.code === 'INVALID_CREDENTIALS') {
return res.status(401).json({
error: {
status: 401,
message: error.message,
},
});
}
next(error);
}
});
/**
* POST /api/auth/logout
* Logout (requires authentication)
*/
router.post('/logout', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const userId = req.userId;
if (!userId) {
return res.status(401).json({
error: {
status: 401,
message: 'User not authenticated',
},
});
}
await logout(userId);
res.json({
success: true,
message: 'Logged out successfully',
});
} catch (error) {
next(error);
}
});
/**
* GET /api/auth/me
* Get current user info (requires authentication)
*/
router.get('/me', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const userId = req.userId;
if (!userId) {
return res.status(401).json({
error: {
status: 401,
message: 'User not authenticated',
},
});
}
const user = await getUserById(userId);
if (!user) {
return res.status(404).json({
error: {
status: 404,
message: 'User not found',
},
});
}
res.json({
success: true,
data: {
id: user.id,
email: user.email,
garminEmail: user.garmin_email,
createdAt: user.created_at,
},
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -1,76 +0,0 @@
import express from 'express';
import { syncData, getSyncStatus } from '../services/GarminService';
import { getUserById } from '../services/AuthService';
import { authMiddleware, AuthRequest } from '../middleware/authMiddleware';
const router = express.Router();
/**
* POST /api/garmin/sync
* Trigger Garmin data synchronization (requires authentication)
*/
router.post('/sync', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const userId = req.userId;
if (!userId) {
return res.status(401).json({
error: {
status: 401,
message: 'User not authenticated',
},
});
}
const user = await getUserById(userId);
if (!user) {
return res.status(404).json({
error: {
status: 404,
message: 'User not found',
},
});
}
// Sync Garmin data using stored credentials
const result = await syncData(userId, {
garminEmail: user.garminEmail,
garminPassword: req.body.garminPassword || '',
});
res.json({
success: result.status === 'success',
data: result,
});
} catch (error) {
next(error);
}
});
/**
* GET /api/garmin/status
* Get Garmin synchronization status (requires authentication)
*/
router.get('/status', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const userId = req.userId;
if (!userId) {
return res.status(401).json({
error: {
status: 401,
message: 'User not authenticated',
},
});
}
const status = await getSyncStatus(userId);
res.json({
success: true,
data: status,
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -1,170 +0,0 @@
import express from 'express';
import { getSummary, getSteps, getHeartRate, getSleep, getActivities } from '../services/HealthService';
import { authMiddleware, AuthRequest } from '../middleware/authMiddleware';
const router = express.Router();
// Apply authentication middleware to all health routes
router.use(authMiddleware);
/**
* GET /api/health/summary
* Get health data summary for a date range
* Query params: startDate, endDate (ISO 8601 format, e.g., 2024-08-23)
*/
router.get('/summary', async (req: AuthRequest, res, next) => {
try {
const userId = req.userId;
if (!userId) {
return res.status(401).json({
error: {
status: 401,
message: 'User not authenticated',
},
});
}
const { startDate, endDate } = req.query;
const data = await getSummary(userId, {
startDate: startDate as string | undefined,
endDate: endDate as string | undefined,
});
res.json({
success: true,
data,
});
} catch (error) {
next(error);
}
});
/**
* GET /api/health/steps
* Get steps data for a date range
* Query params: startDate, endDate
*/
router.get('/steps', async (req: AuthRequest, res, next) => {
try {
const userId = req.userId;
if (!userId) {
return res.status(401).json({
error: {
status: 401,
message: 'User not authenticated',
},
});
}
const { startDate, endDate } = req.query;
const data = await getSteps(userId, {
startDate: startDate as string | undefined,
endDate: endDate as string | undefined,
});
res.json({
success: true,
data,
});
} catch (error) {
next(error);
}
});
/**
* GET /api/health/heart-rate
* Get heart rate data for a date range
* Query params: startDate, endDate
*/
router.get('/heart-rate', async (req: AuthRequest, res, next) => {
try {
const userId = req.userId;
if (!userId) {
return res.status(401).json({
error: {
status: 401,
message: 'User not authenticated',
},
});
}
const { startDate, endDate } = req.query;
const data = await getHeartRate(userId, {
startDate: startDate as string | undefined,
endDate: endDate as string | undefined,
});
res.json({
success: true,
data,
});
} catch (error) {
next(error);
}
});
/**
* GET /api/health/sleep
* Get sleep data for a date range
* Query params: startDate, endDate
*/
router.get('/sleep', async (req: AuthRequest, res, next) => {
try {
const userId = req.userId;
if (!userId) {
return res.status(401).json({
error: {
status: 401,
message: 'User not authenticated',
},
});
}
const { startDate, endDate } = req.query;
const data = await getSleep(userId, {
startDate: startDate as string | undefined,
endDate: endDate as string | undefined,
});
res.json({
success: true,
data,
});
} catch (error) {
next(error);
}
});
/**
* GET /api/health/activities
* Get activities for a date range
* Query params: startDate, endDate
*/
router.get('/activities', async (req: AuthRequest, res, next) => {
try {
const userId = req.userId;
if (!userId) {
return res.status(401).json({
error: {
status: 401,
message: 'User not authenticated',
},
});
}
const { startDate, endDate } = req.query;
const data = await getActivities(userId, {
startDate: startDate as string | undefined,
endDate: endDate as string | undefined,
});
res.json({
success: true,
data,
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -1,130 +0,0 @@
import { allAsync } from '../utils/database';
import { getSummary } from './HealthService';
const METRIC_COLUMNS: Record<string, string> = {
steps: 'steps',
heart_rate: 'heart_rate',
sleep_duration: 'sleep_duration',
sleep_quality: 'sleep_quality',
stress: 'stress',
calories_burned: 'calories_burned',
};
export async function getTrends(metric: string, userId: string, startDate?: string, endDate?: string) {
const column = METRIC_COLUMNS[metric] || 'steps';
const params: any[] = [userId];
let sql = 'WHERE user_id = ?';
if (startDate) { sql += ' AND date >= ?'; params.push(startDate); }
if (endDate) { sql += ' AND date <= ?'; params.push(endDate); }
const rows = await allAsync(
`SELECT date, ${column} AS value FROM health_data ${sql}
AND ${column} IS NOT NULL ORDER BY date ASC`,
params
);
return rows.map((r: any) => ({ date: r.date, value: r.value }));
}
export interface Recommendation {
id: string;
category: string;
recommendation: string;
priority: 'high' | 'medium' | 'low';
basedOn: string[];
}
export async function getRecommendations(userId: string): Promise<Recommendation[]> {
// Look at the most recent 14 days of data.
const recent = await getSummary(userId);
const last14 = recent.slice(-14);
const recs: Recommendation[] = [];
if (last14.length === 0) {
return [
{
id: 'no-data',
category: '数据',
recommendation: '暂无健康数据,请先同步你的 Garmin 设备数据。',
priority: 'low',
basedOn: [],
},
];
}
const avg = (key: string) =>
last14.reduce((sum, r) => sum + (r[key] ?? 0), 0) / last14.length;
const avgSteps = avg('steps');
const avgSleep = last14.filter((r) => r.sleep).reduce(
(s, r) => s + (r.sleep?.duration ?? 0),
0
) / Math.max(1, last14.filter((r) => r.sleep).length);
const avgStress = avg('stress');
const avgRestingHr = avg('heartRate');
const avgHrv = avg('heartRateVariability');
if (avgSteps > 0 && avgSteps < 8000) {
recs.push({
id: 'steps',
category: '运动',
recommendation: `${last14.length} 天日均步数约 ${Math.round(avgSteps)} 步,低于 8000 步目标,建议每天增加 20 分钟快走。`,
priority: 'medium',
basedOn: ['steps'],
});
}
if (avgSleep > 0 && avgSleep < 7) {
recs.push({
id: 'sleep',
category: '睡眠',
recommendation: `日均睡眠约 ${avgSleep.toFixed(1)} 小时,偏少。建议固定就寝时间,目标 7-8 小时。`,
priority: 'high',
basedOn: ['sleep_duration'],
});
}
if (avgStress > 0 && avgStress > 50) {
recs.push({
id: 'stress',
category: '压力',
recommendation: `平均压力指数 ${Math.round(avgStress)} 偏高,建议安排放松活动(冥想/散步)。`,
priority: 'high',
basedOn: ['stress'],
});
}
if (avgRestingHr > 0 && avgRestingHr > 65) {
recs.push({
id: 'rhr',
category: '心肺',
recommendation: `静息心率约 ${Math.round(avgRestingHr)} bpm 偏高,规律有氧运动有助于改善心肺功能。`,
priority: 'medium',
basedOn: ['heart_rate'],
});
}
if (avgHrv > 0 && avgHrv < 40) {
recs.push({
id: 'hrv',
category: '恢复',
recommendation: `心率变异性HRV${Math.round(avgHrv)} ms 偏低,注意恢复与休息,避免过度训练。`,
priority: 'low',
basedOn: ['heart_rate_variability'],
});
}
if (recs.length === 0) {
recs.push({
id: 'good',
category: '状态',
recommendation: '近期各项指标良好,保持当前作息与运动习惯即可。',
priority: 'low',
basedOn: [],
});
}
// Sort by priority
const order = { high: 0, medium: 1, low: 2 } as const;
recs.sort((a, b) => order[a.priority] - order[b.priority]);
return recs;
}

View File

@@ -1,89 +0,0 @@
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
import { runAsync, getAsync } from '../utils/database';
const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_change_me';
const TOKEN_EXPIRY = '7d';
export class AuthError extends Error {
constructor(public code: string, message: string) {
super(message);
this.name = 'AuthError';
}
}
function hashPassword(password: string): Promise<string> {
return new Promise((resolve, reject) => {
const salt = crypto.randomBytes(16).toString('hex');
crypto.scrypt(password, salt, 64, (err, derived) => {
if (err) reject(err);
else resolve(`${salt}:${derived.toString('hex')}`);
});
});
}
function verifyPassword(password: string, stored: string): Promise<boolean> {
return new Promise((resolve, reject) => {
const [salt, hash] = stored.split(':');
if (!salt || !hash) return resolve(false);
crypto.scrypt(password, salt, 64, (err, derived) => {
if (err) reject(err);
else resolve(derived.toString('hex') === hash);
});
});
}
function signToken(userId: string): string {
return jwt.sign({ sub: userId }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY });
}
export async function register(input: {
email: string;
garminEmail: string;
garminPassword: string;
}) {
const existing = await getAsync('SELECT id FROM users WHERE email = ?', [input.email]);
if (existing) {
throw new AuthError('EMAIL_TAKEN', '该邮箱已注册');
}
const id = crypto.randomUUID();
const garminPasswordHash = await hashPassword(input.garminPassword);
const token = signToken(id);
await runAsync(
`INSERT INTO users (id, email, garmin_email, garmin_password_hash, jwt_token)
VALUES (?, ?, ?, ?, ?)`,
[id, input.email, input.garminEmail, garminPasswordHash, token]
);
return { id, email: input.email, token };
}
export async function login(email: string, password: string) {
const user = await getAsync('SELECT * FROM users WHERE email = ?', [email]);
if (!user) {
throw new AuthError('INVALID_CREDENTIALS', '邮箱或密码错误');
}
const ok = await verifyPassword(password, user.garmin_password_hash);
if (!ok) {
throw new AuthError('INVALID_CREDENTIALS', '邮箱或密码错误');
}
const token = signToken(user.id);
await runAsync('UPDATE users SET jwt_token = ? WHERE id = ?', [token, user.id]);
return { id: user.id, email: user.email, token };
}
export async function logout(userId: string) {
await runAsync('UPDATE users SET jwt_token = NULL WHERE id = ?', [userId]);
}
export async function getUserById(userId: string) {
const user = await getAsync(
'SELECT id, email, garmin_email, created_at FROM users WHERE id = ?',
[userId]
);
return user || null;
}
export function verifyToken(token: string): { userId: string } {
const payload = jwt.verify(token, JWT_SECRET) as { sub: string };
return { userId: payload.sub };
}

View File

@@ -1,121 +0,0 @@
import { getAsync, runAsync } from '../utils/database';
import { upsertHealthDaily, insertActivity } from './HealthService';
export interface SyncResult {
status: 'success' | 'error';
recordsSynced: number;
message: string;
lastSyncTime: string;
}
export async function syncData(
userId: string,
creds: { garminEmail: string; garminPassword: string }
): Promise<SyncResult> {
const now = new Date().toISOString();
await runAsync(
`INSERT INTO sync_status (user_id, status, last_sync_time, records_synced)
VALUES (?, 'syncing', ?, 0)
ON DUPLICATE KEY UPDATE status='syncing', last_sync_time=?, records_synced=0`,
[userId, now, now]
);
try {
let GarminConnect: any;
try {
GarminConnect = require('garmin-connect');
} catch {
throw new Error('GARMIN_LIB_MISSING: 请先运行 `npm install garmin-connect` 以启用同步');
}
const Client = GarminConnect.default || GarminConnect.GarminConnect || GarminConnect;
const client = new Client({ username: creds.garminEmail, password: creds.garminPassword });
await client.login();
let recordsSynced = 0;
for (let i = 0; i < 7; i++) {
const d = new Date();
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().slice(0, 10);
try {
const daily =
(await client.getUserDailySummary?.(dateStr)) ||
(await client.getDailySummary?.(dateStr)) ||
(await client.getStats?.(dateStr));
if (daily) {
const sleepSec = daily.sleep?.sleepingSeconds ?? daily.sleepingSeconds;
await upsertHealthDaily(userId, {
date: dateStr,
steps: daily.steps ?? null,
heartRate: daily.restingHeartRate ?? daily.averageHeartRate ?? null,
heartRateVariability: daily.hrv ?? daily.heartRateVariability ?? null,
sleepDuration: sleepSec ? Math.round(sleepSec / 3600 * 10) / 10 : null,
sleepQuality: daily.sleep?.sleepQuality ?? null,
stress: daily.stress?.average ?? daily.averageStress ?? null,
caloriesBurned: daily.calories?.total ?? daily.totalCalories ?? null,
});
recordsSynced++;
}
const activities =
(await client.getActivities?.(dateStr)) ||
(await client.getActivitiesByDate?.(dateStr)) ||
[];
for (const a of activities || []) {
const start = a.startTimeLocal || a.startTime;
const startMs = start ? new Date(start).getTime() : NaN;
await insertActivity(userId, {
activityType: a.activityType?.typeKey || a.type || 'unknown',
startTime: start,
endTime:
isNaN(startMs) || !a.duration ? start : new Date(startMs + (a.duration || 0) * 1000).toISOString(),
duration: a.duration ?? null,
distance: a.distance ?? null,
calories: a.calories ?? null,
heartRateAverage: a.averageHR ?? null,
heartRateMax: a.maxHR ?? null,
});
}
} catch {
// skip a single bad day and continue
continue;
}
}
await runAsync(
`INSERT INTO sync_status (user_id, status, last_sync_time, records_synced)
VALUES (?, 'idle', ?, ?)
ON DUPLICATE KEY UPDATE status='idle', last_sync_time=?, records_synced=?`,
[userId, now, recordsSynced, now, recordsSynced]
);
return {
status: 'success',
recordsSynced,
message: `同步完成,新增/更新 ${recordsSynced} 天数据`,
lastSyncTime: now,
};
} catch (err: any) {
const message = err?.message || String(err);
await runAsync(
`INSERT INTO sync_status (user_id, status, last_sync_time, last_error, records_synced)
VALUES (?, 'error', ?, ?, 0)
ON DUPLICATE KEY UPDATE status='error', last_sync_time=?, last_error=?, records_synced=0`,
[userId, now, message, now, message]
);
return { status: 'error', recordsSynced: 0, message, lastSyncTime: now };
}
}
export async function getSyncStatus(userId: string) {
const row = await getAsync('SELECT * FROM sync_status WHERE user_id = ?', [userId]);
if (!row) return { status: 'idle', lastSyncTime: null, recordsSynced: 0, lastError: null };
return {
status: row.status,
lastSyncTime: row.last_sync_time,
recordsSynced: row.records_synced,
lastError: row.last_error,
};
}

View File

@@ -1,134 +0,0 @@
import crypto from 'crypto';
import { allAsync, runAsync } from '../utils/database';
interface DateRange {
startDate?: string;
endDate?: string;
}
function rangeParams(userId: string, range?: DateRange): { sql: string; params: any[] } {
const params: any[] = [userId];
let sql = 'WHERE user_id = ?';
if (range?.startDate) {
sql += ' AND date >= ?';
params.push(range.startDate);
}
if (range?.endDate) {
sql += ' AND date <= ?';
params.push(range.endDate);
}
return { sql, params };
}
export async function getSummary(userId: string, range?: DateRange) {
const { sql, params } = rangeParams(userId, range);
const rows = await allAsync(
`SELECT date, steps, heart_rate, heart_rate_variability,
sleep_duration, sleep_quality, stress, calories_burned
FROM health_data ${sql} ORDER BY date ASC`,
params
);
return rows.map((r: any) => ({
date: r.date,
steps: r.steps,
heartRate: r.heart_rate,
heartRateVariability: r.heart_rate_variability,
sleep: r.sleep_duration != null ? { duration: r.sleep_duration, quality: r.sleep_quality } : null,
stress: r.stress,
caloriesBurned: r.calories_burned,
}));
}
export async function getSteps(userId: string, range?: DateRange) {
const { sql, params } = rangeParams(userId, range);
const rows = await allAsync(
`SELECT date, steps FROM health_data ${sql} AND steps IS NOT NULL ORDER BY date ASC`,
params
);
return rows.map((r: any) => ({ date: r.date, steps: r.steps }));
}
export async function getHeartRate(userId: string, range?: DateRange) {
const { sql, params } = rangeParams(userId, range);
const rows = await allAsync(
`SELECT date, heart_rate, heart_rate_variability FROM health_data ${sql}
AND heart_rate IS NOT NULL ORDER BY date ASC`,
params
);
return rows.map((r: any) => ({
date: r.date,
heartRate: r.heart_rate,
heartRateVariability: r.heart_rate_variability,
}));
}
export async function getSleep(userId: string, range?: DateRange) {
const { sql, params } = rangeParams(userId, range);
const rows = await allAsync(
`SELECT date, sleep_duration, sleep_quality FROM health_data ${sql}
AND sleep_duration IS NOT NULL ORDER BY date ASC`,
params
);
return rows.map((r: any) => ({
date: r.date,
duration: r.sleep_duration,
quality: r.sleep_quality,
}));
}
export async function getActivities(userId: string, range?: DateRange) {
const { sql, params } = rangeParams(userId, range);
const rows = await allAsync(
`SELECT id, activity_type, start_time, end_time, duration, distance,
calories, heart_rate_average, heart_rate_max
FROM activities ${sql} ORDER BY start_time DESC`,
params
);
return rows;
}
// Used by GarminService to persist daily health records (upsert by user+date).
export async function upsertHealthDaily(userId: string, record: any) {
const id = `${userId}-${record.date}`;
await runAsync(
`INSERT INTO health_data
(id, user_id, date, steps, heart_rate, heart_rate_variability,
blood_pressure_systolic, blood_pressure_diastolic, sleep_duration,
sleep_quality, stress, calories_burned)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
steps = VALUES(steps),
heart_rate = VALUES(heart_rate),
heart_rate_variability = VALUES(heart_rate_variability),
blood_pressure_systolic = VALUES(blood_pressure_systolic),
blood_pressure_diastolic = VALUES(blood_pressure_diastolic),
sleep_duration = VALUES(sleep_duration),
sleep_quality = VALUES(sleep_quality),
stress = VALUES(stress),
calories_burned = VALUES(calories_burned),
updated_at = CURRENT_TIMESTAMP`,
[
id, userId, record.date, record.steps ?? null, record.heartRate ?? null,
record.heartRateVariability ?? null, record.bloodPressureSystolic ?? null,
record.bloodPressureDiastolic ?? null, record.sleepDuration ?? null,
record.sleepQuality ?? null, record.stress ?? null, record.caloriesBurned ?? null,
]
);
return id;
}
export async function insertActivity(userId: string, activity: any) {
const id = crypto.randomUUID();
await runAsync(
`INSERT INTO activities
(id, user_id, activity_type, start_time, end_time, duration, distance,
calories, heart_rate_average, heart_rate_max)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id, userId, activity.activityType, activity.startTime, activity.endTime,
activity.duration ?? null, activity.distance ?? null, activity.calories ?? null,
activity.heartRateAverage ?? null, activity.heartRateMax ?? null,
]
);
return id;
}

View File

@@ -1,59 +0,0 @@
export interface User {
id: string;
email: string;
garminEmail: string;
createdAt: Date;
updatedAt: Date;
}
export interface HealthData {
id: string;
userId: string;
date: Date;
steps: number;
heartRate?: number;
heartRateVariability?: number;
bloodPressure?: {
systolic: number;
diastolic: number;
};
sleep?: {
duration: number;
quality: number;
};
stress?: number;
caloriesBurned?: number;
createdAt: Date;
updatedAt: Date;
}
export interface Activity {
id: string;
userId: string;
activityType: string;
startTime: Date;
endTime: Date;
duration: number;
distance?: number;
calories?: number;
heartRateAverage?: number;
heartRateMax?: number;
createdAt: Date;
}
export interface HealthRecommendation {
id: string;
userId: string;
category: string;
recommendation: string;
priority: 'high' | 'medium' | 'low';
basedOn: string[];
createdAt: Date;
}
export interface SyncStatus {
lastSyncTime: Date;
status: 'idle' | 'syncing' | 'error';
lastError?: string;
recordsSynced: number;
}

View File

@@ -1,226 +0,0 @@
import mysql from 'mysql2/promise';
import path from 'path';
import fs from 'fs';
const DB_TYPE = (process.env.DB_TYPE || 'sqlite').toLowerCase();
// ---------------------------------------------------------------------------
// SQLite (local / development)
// Loaded lazily so the MariaDB build never depends on the native sqlite3 module.
// ---------------------------------------------------------------------------
let sqliteDb: any = null;
const SQLITE_SCHEMA = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
garmin_email TEXT NOT NULL,
garmin_password_hash TEXT NOT NULL,
jwt_token TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS health_data (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
date DATE NOT NULL,
steps INTEGER,
heart_rate INTEGER,
heart_rate_variability REAL,
blood_pressure_systolic INTEGER,
blood_pressure_diastolic INTEGER,
sleep_duration INTEGER,
sleep_quality REAL,
stress INTEGER,
calories_burned REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, date)
);
CREATE TABLE IF NOT EXISTS activities (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
activity_type TEXT NOT NULL,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
duration INTEGER,
distance REAL,
calories REAL,
heart_rate_average INTEGER,
heart_rate_max INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS sync_status (
user_id TEXT PRIMARY KEY,
last_sync_time DATETIME,
status TEXT DEFAULT 'idle',
last_error TEXT,
records_synced INTEGER DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
`;
// ---------------------------------------------------------------------------
// MariaDB (production, runs on the NAS)
// NOTE: TEXT cannot be a PRIMARY KEY in MariaDB, so ids use VARCHAR(64).
// ---------------------------------------------------------------------------
let mariadbPool: mysql.Pool | null = null;
const MARIADB_SCHEMA = `
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(64) PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
garmin_email VARCHAR(255) NOT NULL,
garmin_password_hash TEXT NOT NULL,
jwt_token TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS health_data (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
date DATE NOT NULL,
steps INT,
heart_rate INT,
heart_rate_variability DOUBLE,
blood_pressure_systolic INT,
blood_pressure_diastolic INT,
sleep_duration INT,
sleep_quality DOUBLE,
stress INT,
calories_burned DOUBLE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, date),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS activities (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
activity_type VARCHAR(255) NOT NULL,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
duration INT,
distance DOUBLE,
calories DOUBLE,
heart_rate_average INT,
heart_rate_max INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS sync_status (
user_id VARCHAR(64) PRIMARY KEY,
last_sync_time DATETIME,
status VARCHAR(32) DEFAULT 'idle',
last_error TEXT,
records_synced INT DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
`;
function getMariadbConfig(): mysql.PoolOptions {
const config: mysql.PoolOptions = {
user: process.env.MARIADB_USER || 'root',
password: process.env.MARIADB_PASSWORD || '',
database: process.env.MARIADB_DATABASE || 'garmin_health_lab',
connectionLimit: 10,
waitForConnections: true,
};
if (process.env.MARIADB_SOCKET) {
config.socketPath = process.env.MARIADB_SOCKET;
} else {
config.host = process.env.MARIADB_HOST || '127.0.0.1';
config.port = process.env.MARIADB_PORT ? Number(process.env.MARIADB_PORT) : 3306;
}
return config;
}
export async function initializeDatabase(): Promise<void> {
if (DB_TYPE === 'mariadb') {
mariadbPool = mysql.createPool(getMariadbConfig());
const conn = await mariadbPool.getConnection();
try {
const statements = MARIADB_SCHEMA
.split(';')
.map((s) => s.trim())
.filter(Boolean);
for (const stmt of statements) {
await conn.query(stmt);
}
} finally {
conn.release();
}
console.log('[db] MariaDB database initialized successfully');
} else {
const sqlitePath = process.env.DATABASE_PATH || './data/health.db';
const dataDir = path.dirname(sqlitePath);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const sqlite3 = await import('sqlite3');
const SqliteDb = (sqlite3 as any).default?.Database || (sqlite3 as any).Database;
sqliteDb = new SqliteDb(sqlitePath);
await new Promise<void>((resolve, reject) => {
sqliteDb.serialize(() => {
sqliteDb.exec(SQLITE_SCHEMA, (err: Error | null) => {
if (err) reject(err);
else resolve();
});
});
});
console.log('[db] SQLite database initialized successfully');
}
}
// Unified query helpers — same signatures for both backends.
// `?` placeholders are supported by both sqlite3 and mysql2.
export async function runAsync(sql: string, params: any[] = []): Promise<any> {
if (DB_TYPE === 'mariadb') {
const [result] = await mariadbPool!.execute(sql, params);
const r = result as any;
return { id: r.insertId, changes: r.affectedRows };
}
return new Promise((resolve, reject) => {
sqliteDb.run(sql, params, function (this: any, err: Error | null) {
if (err) reject(err);
else resolve({ id: this.lastID, changes: this.changes });
});
});
}
export async function getAsync(sql: string, params: any[] = []): Promise<any> {
if (DB_TYPE === 'mariadb') {
const [rows] = await mariadbPool!.execute(sql, params);
return Array.isArray(rows) ? (rows as any[])[0] : undefined;
}
return new Promise((resolve, reject) => {
sqliteDb.get(sql, params, (err: Error | null, row: any) => {
if (err) reject(err);
else resolve(row);
});
});
}
export async function allAsync(sql: string, params: any[] = []): Promise<any[]> {
if (DB_TYPE === 'mariadb') {
const [rows] = await mariadbPool!.execute(sql, params);
return (rows as any[]) || [];
}
return new Promise((resolve, reject) => {
sqliteDb.all(sql, params, (err: Error | null, rows: any[]) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}

View File

@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}