From 8616a13525a7982ac853fc02b910682d75ffe16b Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sun, 23 Aug 2026 12:43:52 +0800 Subject: [PATCH] =?UTF-8?q?[=E9=98=B6=E6=AE=B54.2]=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=AF=B9=E6=8E=A5=20Flask=20API=20+=20=E5=BB=BA=E8=AE=AE/?= =?UTF-8?q?=E5=88=86=E6=9E=90/=E8=AE=BE=E7=BD=AE=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E9=99=A4=E5=BA=9F=E5=BC=83=E7=9A=84=20Node?= =?UTF-8?q?=20=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 选型确认为 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 --- client/package.json | 1 + client/src/App.tsx | 3 +- client/src/components/Chart.tsx | 50 ++-- client/src/pages/Analysis.css | 84 +++++++ client/src/pages/Analysis.tsx | 152 ++++++++++++- client/src/pages/Dashboard.tsx | 288 +++++++++--------------- client/src/pages/DataSync.tsx | 186 +++++++-------- client/src/pages/Login.tsx | 22 +- client/src/pages/Recommendations.css | 171 ++++++++++++++ client/src/pages/Recommendations.tsx | 134 ++++++++++- client/src/pages/Settings.css | 123 ++++++++++ client/src/pages/Settings.tsx | 86 ++++++- client/src/services/api.ts | 200 ++++++++++++---- package.json | 21 +- server/.env.example | 21 -- server/package.json | 34 --- server/src/index.ts | 47 ---- server/src/middleware/authMiddleware.ts | 65 ------ server/src/middleware/errorHandler.ts | 31 --- server/src/routes/analysis.ts | 14 -- server/src/routes/auth.ts | 154 ------------- server/src/routes/garmin.ts | 76 ------- server/src/routes/health.ts | 170 -------------- server/src/services/AnalysisService.ts | 130 ----------- server/src/services/AuthService.ts | 89 -------- server/src/services/GarminService.ts | 121 ---------- server/src/services/HealthService.ts | 134 ----------- server/src/types/index.ts | 59 ----- server/src/utils/database.ts | 226 ------------------- server/tsconfig.json | 20 -- 30 files changed, 1135 insertions(+), 1777 deletions(-) create mode 100644 client/src/pages/Analysis.css create mode 100644 client/src/pages/Recommendations.css create mode 100644 client/src/pages/Settings.css delete mode 100644 server/.env.example delete mode 100644 server/package.json delete mode 100644 server/src/index.ts delete mode 100644 server/src/middleware/authMiddleware.ts delete mode 100644 server/src/middleware/errorHandler.ts delete mode 100644 server/src/routes/analysis.ts delete mode 100644 server/src/routes/auth.ts delete mode 100644 server/src/routes/garmin.ts delete mode 100644 server/src/routes/health.ts delete mode 100644 server/src/services/AnalysisService.ts delete mode 100644 server/src/services/AuthService.ts delete mode 100644 server/src/services/GarminService.ts delete mode 100644 server/src/services/HealthService.ts delete mode 100644 server/src/types/index.ts delete mode 100644 server/src/utils/database.ts delete mode 100644 server/tsconfig.json diff --git a/client/package.json b/client/package.json index 443611c..0a4ef30 100644 --- a/client/package.json +++ b/client/package.json @@ -22,6 +22,7 @@ "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", + "typecheck": "tsc --noEmit", "eject": "react-scripts eject" }, "eslintConfig": { diff --git a/client/src/App.tsx b/client/src/App.tsx index 159858a..b796fea 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import Layout from './components/Layout'; import ProtectedRoute from './components/ProtectedRoute'; import Login from './pages/Login'; diff --git a/client/src/components/Chart.tsx b/client/src/components/Chart.tsx index 773e7f4..6e6519f 100644 --- a/client/src/components/Chart.tsx +++ b/client/src/components/Chart.tsx @@ -1,9 +1,11 @@ -import React from 'react'; -import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; +import { + LineChart, Line, BarChart, Bar, XAxis, YAxis, + CartesianGrid, Tooltip, ResponsiveContainer, +} from 'recharts'; interface ChartProps { title: string; - data: any[]; + data: Array>; type: 'line' | 'bar'; dataKey: string; stroke?: string; @@ -18,9 +20,13 @@ function Chart({ dataKey, stroke = '#667eea', fill = '#667eea', - height = 300, + height = 260, }: 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 (

{title}

@@ -29,27 +35,37 @@ function Chart({ ); } + const axisProps = { fontSize: 12, stroke: '#999' }; + return (

{title}

{type === 'line' ? ( - - - - + + + + - - + {/* connectNulls keeps the line continuous across days the device + did not record, instead of breaking it into fragments. */} + ) : ( - - - - + + + + - - + )} diff --git a/client/src/pages/Analysis.css b/client/src/pages/Analysis.css new file mode 100644 index 0000000..75fdda9 --- /dev/null +++ b/client/src/pages/Analysis.css @@ -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); + } +} diff --git a/client/src/pages/Analysis.tsx b/client/src/pages/Analysis.tsx index 00dc8dd..cf6b247 100644 --- a/client/src/pages/Analysis.tsx +++ b/client/src/pages/Analysis.tsx @@ -1,11 +1,157 @@ -import React from 'react'; -import './Pages.css'; +import { useCallback, useEffect, useState } from 'react'; +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() { + const [metric, setMetric] = useState(METRICS[0]); + const [days, setDays] = useState(30); + const [points, setPoints] = useState([]); + 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 (

数据分析

-

数据分析页面即将推出...

+

按指标查看趋势与统计

+ +
+
+ {METRICS.map((m) => ( + + ))} +
+ +
+ {RANGES.map((r) => ( + + ))} +
+
+ + {error &&
{error}
} + {loading &&
加载中…
} + + {!loading && !error && ( + <> + {summary ? ( +
+ + + + + = 0 ? '+' : ''}${fmt(summary.delta)} ${metric.unit}`} + tone={summary.delta >= 0 ? 'up' : 'down'} + /> + +
+ ) : ( +

该指标在所选区间内暂无数据。

+ )} + + + + )} +
+ ); +} + +function Stat({ label, value, tone }: { label: string; value: string; tone?: 'up' | 'down' }) { + return ( +
+
{label}
+
{value}
); } diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 364406a..3c21eef 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -1,218 +1,142 @@ -import React, { useEffect, useState } from 'react'; -import { apiClient } from '../services/api'; +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { apiClient, errorMessage, HealthDay } from '../services/api'; import Chart from '../components/Chart'; import './Dashboard.css'; -interface HealthData { +/** Flattened row so every chart can address its metric with a plain key. */ +interface ChartRow { date: string; - steps?: number; - heartRate?: number; - sleep?: { - duration: number; - quality: number; - }; - caloriesBurned?: number; + steps: number | null; + heartRate: number | null; + sleepHours: number | null; + calories: number | null; +} + +function average(values: Array): 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() { const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [summary, setSummary] = useState([]); - const [today, setToday] = useState(null); - const [stats, setStats] = useState({ - avgSteps: 0, - avgHeartRate: 0, - avgSleep: 0, - totalCalories: 0, - }); + const [error, setError] = useState(''); + const [rows, setRows] = useState([]); + const [today, setToday] = useState(null); useEffect(() => { - loadData(); - }, []); + const load = async () => { + try { + const end = new Date(); + const start = new Date(end.getTime() - 29 * 24 * 60 * 60 * 1000); + const endStr = end.toISOString().slice(0, 10); - const loadData = async () => { - try { - setLoading(true); - setError(''); - - // Load 30-day summary - const now = new Date(); - const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - - 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) + const summary = await apiClient.getHealthSummary( + start.toISOString().slice(0, 10), + endStr ); - setStats({ avgSteps, avgHeartRate, avgSleep, totalCalories }); + setRows( + summary.map((d) => ({ + date: d.date.slice(5), // MM-DD keeps the axis readable + 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) { + setError(errorMessage(err, '加载数据失败')); + } finally { + setLoading(false); } + }; + load(); + }, []); - setLoading(false); - } catch (err: any) { - const errorMsg = err.response?.data?.error?.message || 'Failed to load data'; - setError(errorMsg); - setLoading(false); - } + if (loading) return
加载中…
; + + 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) + ), }; - if (loading) { - return
⏳ 加载中...
; - } + const show = (v: number | null | undefined, suffix = '') => + v == null ? '—' : `${v.toLocaleString()}${suffix}`; return (
-

📊 健康仪表板

+

健康仪表板

{error &&
{error}
} - {/* Today's Summary */} - {today ? ( -
-

今日概览

-
-
-
🚶
-
-
步数
-
{today.steps || '-'}
-
-
- -
-
❤️
-
-
心率
-
{today.heartRate || '-'} bpm
-
-
- -
-
😴
-
-
睡眠
-
{today.sleep?.duration || '-'} 小时
-
-
- -
-
🔥
-
-
卡路里
-
{today.caloriesBurned || '-'} kcal
-
-
-
+ {!hasData && !error && ( +
+

还没有任何健康数据。

+ + 去同步 Garmin 数据 +
- ) : ( -
暂无今日数据
)} - {/* 30-Day Statistics */} -
-

30 日统计

-
-
-
📈
-
-
平均步数
-
{stats.avgSteps.toLocaleString()}
-
-
+ {hasData && ( + <> +
+

今日

+ {today ? ( +
+ + + + +
+ ) : ( +

今日暂无数据,最近一次记录见下方趋势。

+ )} +
-
-
❤️
-
-
平均心率
-
{stats.avgHeartRate} bpm
+
+

近 30 天

+
+ + + +
-
+ -
-
😴
-
-
平均睡眠
-
{stats.avgSleep}h
+
+

趋势

+
+ + + +
-
- -
-
🔥
-
-
总卡路里消耗
-
{stats.totalCalories.toLocaleString()}
-
-
-
-
- - {/* Charts */} - {summary.length > 0 && ( -
-

数据趋势

-
- - d.heartRate)} - type="line" - dataKey="heartRate" - stroke="#ff6b9d" - /> - d.sleep)} - type="line" - dataKey={(d: any) => d.sleep?.duration} - stroke="#ffd93d" - /> - -
-
+ + )}
); } +function StatCard({ icon, label, value }: { icon: string; label: string; value: string }) { + return ( +
+
{icon}
+
+
{label}
+
{value}
+
+
+ ); +} + export default Dashboard; diff --git a/client/src/pages/DataSync.tsx b/client/src/pages/DataSync.tsx index 89c2d7a..a20fd56 100644 --- a/client/src/pages/DataSync.tsx +++ b/client/src/pages/DataSync.tsx @@ -1,160 +1,140 @@ -import React, { useEffect, useState } from 'react'; -import { apiClient } from '../services/api'; +import React, { useCallback, useEffect, useState } from 'react'; +import { apiClient, errorMessage, SyncStatus } from '../services/api'; import './DataSync.css'; -interface SyncStatus { - status: 'idle' | 'syncing' | 'error'; - lastSyncTime: string | null; - recordsSynced: number; - lastError?: string; -} - function DataSync() { const [syncStatus, setSyncStatus] = useState(null); + const [garminPassword, setGarminPassword] = useState(''); const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); - // Load sync status on component mount - useEffect(() => { - loadSyncStatus(); - }, []); - - const loadSyncStatus = async () => { + const loadSyncStatus = useCallback(async () => { try { - const response = await apiClient.getGarminSyncStatus(); - setSyncStatus(response.data.data); - } catch (err: any) { + setSyncStatus(await apiClient.getGarminSyncStatus()); + } catch (err) { + // 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); } - }; + }, []); - const handleSync = async () => { - setLoading(true); + useEffect(() => { + loadSyncStatus(); + }, [loadSyncStatus]); + + const handleSync = async (e: React.FormEvent) => { + e.preventDefault(); setError(''); setMessage(''); - try { - const response = await apiClient.syncGarminData(); + if (!garminPassword) { + setError('请输入 Garmin 密码'); + return; + } - if (response.data.success) { - setMessage(`✅ 同步完成!新增/更新 ${response.data.data.recordsSynced} 天数据`); - // Reload sync status - loadSyncStatus(); + setLoading(true); + try { + const result = await apiClient.syncGarminData(garminPassword); + if (result.status === 'success') { + setMessage(`同步完成,新增/更新 ${result.recordsSynced} 天数据`); } else { - setError(`❌ 同步失败:${response.data.data.message}`); + 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) { - const errorMsg = err.response?.data?.error?.message || 'Sync failed'; - setError(`❌ 同步失败:${errorMsg}`); + setError(errorMessage(err, '同步失败')); } finally { setLoading(false); } }; - const getStatusColor = (status: string) => { - switch (status) { - case 'idle': - return '#999'; - case 'syncing': - return '#667eea'; - case 'error': - return '#c33'; - default: - return '#999'; - } + const statusLabel: Record = { + idle: '就绪', + syncing: '正在同步…', + error: '上次同步失败', }; - const getStatusText = (status: string) => { - switch (status) { - case 'idle': - return '就绪'; - case 'syncing': - return '正在同步...'; - case 'error': - return '同步失败'; - default: - return '未知'; - } - }; + const busy = loading || syncStatus?.status === 'syncing'; return (
-

📱 数据同步

-

从 Garmin Connect 同步你的健康数据

+

数据同步

+

从 Garmin Connect 拉取最近 7 天的健康数据

- {/* Status Card */} -
+

同步状态

- {syncStatus ? (
状态 - - {getStatusText(syncStatus.status)} + + {statusLabel[syncStatus.status] ?? syncStatus.status}
- - {syncStatus.lastSyncTime && ( -
- 最后同步 - - {new Date(syncStatus.lastSyncTime).toLocaleString('zh-CN')} - -
- )} -
- 已同步数据 - {syncStatus.recordsSynced} 天 + 最后同步 + + {syncStatus.lastSyncTime + ? new Date(syncStatus.lastSyncTime).toLocaleString('zh-CN') + : '从未同步'} + +
+
+ 已同步天数 + {syncStatus.recordsSynced}
- {syncStatus.lastError && (
- 错误信息 + 错误 {syncStatus.lastError}
)}
) : ( -

加载中...

+

加载中…

)} -
+ - {/* Sync Button */} -
- + -

- 数据同步会获取你最近 7 天的 Garmin 健康数据,包括步数、心率、睡眠、运动等信息。 -

-
- - {/* Messages */} {error &&
{error}
} {message &&
{message}
} - {/* Info Box */} -
-

💡 关于数据同步

+
+

关于数据同步

    -
  • 首次同步会获取你最近 7 天的数据
  • -
  • 之后同步会增量更新最新的数据
  • -
  • 数据会保存到本地数据库
  • -
  • 同步频率受 Garmin API 限制
  • +
  • 每次同步获取最近 7 天的每日汇总与运动记录
  • +
  • 同一天重复同步会更新原有记录,不会产生重复数据
  • +
  • 数据保存在本机数据库,不经过第三方服务
  • +
  • 需要先安装 garminconnect 库才能执行真实同步
-
+
); diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx index 3709de3..7e11ba5 100644 --- a/client/src/pages/Login.tsx +++ b/client/src/pages/Login.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { apiClient } from '../services/api'; +import { apiClient, errorMessage } from '../services/api'; import './Login.css'; type TabType = 'login' | 'register'; @@ -47,17 +47,11 @@ function Login() { setLoading(true); try { - const response = await apiClient.login(loginEmail, loginPassword); - const { token } = response.data.data; - - // Store token + const { token } = await apiClient.login(loginEmail, loginPassword); apiClient.setSession(token); - - // Redirect to dashboard navigate('/'); } catch (err: any) { - const message = err.response?.data?.error?.message || 'Login failed'; - setError(message); + setError(errorMessage(err, '登录失败')); } finally { setLoading(false); } @@ -90,17 +84,11 @@ function Login() { setLoading(true); try { - const response = await apiClient.register(regEmail, regGarminEmail, regPassword); - const { token } = response.data.data; - - // Store token + const { token } = await apiClient.register(regEmail, regGarminEmail, regPassword); apiClient.setSession(token); - - // Redirect to dashboard navigate('/'); } catch (err: any) { - const message = err.response?.data?.error?.message || 'Registration failed'; - setError(message); + setError(errorMessage(err, '注册失败')); } finally { setLoading(false); } diff --git a/client/src/pages/Recommendations.css b/client/src/pages/Recommendations.css new file mode 100644 index 0000000..9190c04 --- /dev/null +++ b/client/src/pages/Recommendations.css @@ -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%; + } +} diff --git a/client/src/pages/Recommendations.tsx b/client/src/pages/Recommendations.tsx index ee61382..223cb3f 100644 --- a/client/src/pages/Recommendations.tsx +++ b/client/src/pages/Recommendations.tsx @@ -1,11 +1,139 @@ -import React from 'react'; -import './Pages.css'; +import { useCallback, useEffect, useState } from 'react'; +import { + apiClient, errorMessage, AiRecommendations, ModelInfo, +} from '../services/api'; +import './Recommendations.css'; + +const PRIORITY_LABEL: Record = { + high: '高', + medium: '中', + low: '低', +}; function Recommendations() { + const [models, setModels] = useState([]); + const [selected, setSelected] = useState(''); + const [result, setResult] = useState(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 (

健康建议

-

健康建议页面即将推出...

+

基于你的历史数据生成,可切换不同的大模型

+ +
+ + + + +
+ + {configured.length === 0 && models.length > 0 && ( +
+ 尚未配置任何模型密钥。在 backend/.env 中填入 + GEMINI_API_KEYNVIDIA_API_KEY 即可启用 AI 建议; + 在此之前下面显示的是规则引擎的结果。 +
+ )} + + {error &&
{error}
} + + {meta && ( +
+ {meta.source === 'ai' ? ( + <> + AI 生成 · 模型 {meta.model} · 分析了 {meta.days} 天数据 + {meta.fallbackFrom && meta.fallbackFrom.length > 0 && ( + + ({meta.fallbackFrom.join('、')} 失败后自动切换) + + )} + + ) : ( + <> + 规则引擎 + {meta.reason && · {meta.reason}} + + )} +
+ )} + + {loading && !result &&
正在分析…
} + +
+ {result?.recommendations.map((rec) => ( +
+
+ {rec.category} + + {PRIORITY_LABEL[rec.priority] ?? rec.priority} + +
+

{rec.recommendation}

+ {rec.basedOn.length > 0 && ( +
+ 依据:{rec.basedOn.join('、')} +
+ )} +
+ ))} +
+ +

+ 以上内容由数据分析生成,不构成医疗建议。如有健康问题请咨询专业医师。 +

); } diff --git a/client/src/pages/Settings.css b/client/src/pages/Settings.css new file mode 100644 index 0000000..df5dc6d --- /dev/null +++ b/client/src/pages/Settings.css @@ -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; + } +} diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index de09326..d96a990 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -1,11 +1,91 @@ -import React from 'react'; -import './Pages.css'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { apiClient, ModelInfo } from '../services/api'; +import './Settings.css'; function Settings() { + const navigate = useNavigate(); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + apiClient + .getModels() + .then(setModels) + .catch(() => setModels([])) + .finally(() => setLoading(false)); + }, []); + + const handleLogout = async () => { + await apiClient.logout(); + navigate('/login'); + }; + return (

设置

-

设置页面即将推出...

+ +
+

AI 模型

+

+ 模型清单与优先级由后端 backend/.env 决定。 + 填入对应厂商的密钥后,模型会自动变为可用; + AI_MODEL_CHAIN 控制自动模式下的尝试顺序。 +

+ + {loading ? ( +

加载中…

+ ) : models.length === 0 ? ( +

无法获取模型列表。

+ ) : ( + + + + + + + + + + + {models.map((m) => ( + + + + + + + ))} + +
ID模型上下文状态
+ {m.id} + {m.default && 默认} + {m.model}{(m.contextWindow / 1000).toLocaleString()}k + + {m.configured ? '已配置' : '缺少密钥'} + +
+ )} +
+ +
+

数据与隐私

+
    +
  • 健康数据保存在自建数据库中,不上传第三方服务。
  • +
  • Garmin 密码只以哈希形式存储,无法还原,每次同步需重新输入。
  • +
  • + 使用 AI 建议时,你的每日指标会以匿名 CSV 形式发送给所选模型厂商; + 其中不含姓名、邮箱或设备标识。 +
  • +
+
+ +
+

账户

+ +
); } diff --git a/client/src/services/api.ts b/client/src/services/api.ts index 6b9c2f0..f95228c 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -3,15 +3,86 @@ import axios, { AxiosInstance } from 'axios'; const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api'; 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 { private client: AxiosInstance; constructor() { this.client = axios.create({ baseURL: API_BASE_URL, - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, }); // Attach the saved JWT to every request. @@ -24,36 +95,23 @@ class ApiClient { 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( (resp) => resp, (error) => { if (error.response?.status === 401) { localStorage.removeItem(TOKEN_KEY); + if (window.location.pathname !== '/login') { + window.location.href = '/login'; + } } return Promise.reject(error); } ); } - // --- Auth --- - 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. + // --- session --- setSession(token: string) { localStorage.setItem(TOKEN_KEY, token); } @@ -62,47 +120,99 @@ class ApiClient { localStorage.removeItem(TOKEN_KEY); } - // --- Garmin --- - syncGarminData(garminEmail?: string, garminPassword?: string) { - const body = - garminEmail || garminPassword ? { garminEmail, garminPassword } : {}; - return this.client.post('/garmin/sync', body); + isAuthenticated(): boolean { + return Boolean(localStorage.getItem(TOKEN_KEY)); } - getGarminSyncStatus() { - return this.client.get('/garmin/status'); + // --- auth --- + async register(email: string, garminEmail: string, garminPassword: string) { + const { data } = await this.client.post('/auth/register', { + email, + garminEmail, + garminPassword, + }); + return data; } - // --- Health --- - getHealthSummary(startDate?: string, endDate?: string) { - return this.client.get('/health/summary', { params: { startDate, endDate } }); + async login(email: string, password: string) { + const { data } = await this.client.post('/auth/login', { + email, + password, + }); + return data; } - getStepsData(startDate?: string, endDate?: string) { - return this.client.get('/health/steps', { params: { startDate, endDate } }); + async logout() { + try { + await this.client.post('/auth/logout'); + } finally { + this.clearSession(); + } } - getHeartRateData(startDate?: string, endDate?: string) { - return this.client.get('/health/heart-rate', { params: { startDate, endDate } }); + // --- garmin --- + /** + * 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('/garmin/sync', { + garminPassword, + ...(garminEmail ? { garminEmail } : {}), + }); + return data; } - getSleepData(startDate?: string, endDate?: string) { - return this.client.get('/health/sleep', { params: { startDate, endDate } }); + async getGarminSyncStatus() { + const { data } = await this.client.get('/garmin/status'); + return data; } - getActivities(startDate?: string, endDate?: string) { - return this.client.get('/health/activities', { params: { startDate, endDate } }); + // --- health --- + private range(startDate?: string, endDate?: string) { + return { params: { startDate, endDate } }; } - // --- Analysis --- - getTrends(metricType?: string, startDate?: string, endDate?: string) { - return this.client.get('/analysis/trends', { + async getHealthSummary(startDate?: string, endDate?: string) { + const { data } = await this.client.get( + '/health/summary', + this.range(startDate, endDate) + ); + return data; + } + + async getActivities(startDate?: string, endDate?: string) { + const { data } = await this.client.get( + '/health/activities', + this.range(startDate, endDate) + ); + return data; + } + + // --- analysis --- + async getTrends(metricType: string, startDate?: string, endDate?: string) { + const { data } = await this.client.get('/analysis/trends', { params: { metricType, startDate, endDate }, }); + return data; } - getRecommendations() { - return this.client.get('/analysis/recommendations'); + async getRecommendations() { + const { data } = await this.client.get('/analysis/recommendations'); + return data; + } + + async getModels() { + const { data } = await this.client.get('/analysis/models'); + return data; + } + + async getAiRecommendations(model?: string, days?: number) { + const { data } = await this.client.get( + '/analysis/ai-recommendations', + { params: { model, days } } + ); + return data; } } diff --git a/package.json b/package.json index a09fd4d..71cd6aa 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,20 @@ { "name": "garmin-health-lab", "version": "0.1.0", - "description": "佳明健康数据分析平台", + "description": "佳明健康数据分析平台 - React 前端 + Flask 后端", "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": [ - "server", "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": { "concurrently": "^8.2.0" } diff --git a/server/.env.example b/server/.env.example deleted file mode 100644 index 8a5b7e0..0000000 --- a/server/.env.example +++ /dev/null @@ -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 diff --git a/server/package.json b/server/package.json deleted file mode 100644 index 2e5e479..0000000 --- a/server/package.json +++ /dev/null @@ -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" - } -} diff --git a/server/src/index.ts b/server/src/index.ts deleted file mode 100644 index de0f764..0000000 --- a/server/src/index.ts +++ /dev/null @@ -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); - }); diff --git a/server/src/middleware/authMiddleware.ts b/server/src/middleware/authMiddleware.ts deleted file mode 100644 index 0cab916..0000000 --- a/server/src/middleware/authMiddleware.ts +++ /dev/null @@ -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(); -} diff --git a/server/src/middleware/errorHandler.ts b/server/src/middleware/errorHandler.ts deleted file mode 100644 index 8210117..0000000 --- a/server/src/middleware/errorHandler.ts +++ /dev/null @@ -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; - } -} diff --git a/server/src/routes/analysis.ts b/server/src/routes/analysis.ts deleted file mode 100644 index c144703..0000000 --- a/server/src/routes/analysis.ts +++ /dev/null @@ -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; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts deleted file mode 100644 index 49b4a1f..0000000 --- a/server/src/routes/auth.ts +++ /dev/null @@ -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; diff --git a/server/src/routes/garmin.ts b/server/src/routes/garmin.ts deleted file mode 100644 index f2d37b4..0000000 --- a/server/src/routes/garmin.ts +++ /dev/null @@ -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; diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts deleted file mode 100644 index a64e9f8..0000000 --- a/server/src/routes/health.ts +++ /dev/null @@ -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; diff --git a/server/src/services/AnalysisService.ts b/server/src/services/AnalysisService.ts deleted file mode 100644 index 739dc43..0000000 --- a/server/src/services/AnalysisService.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { allAsync } from '../utils/database'; -import { getSummary } from './HealthService'; - -const METRIC_COLUMNS: Record = { - 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 { - // 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; -} diff --git a/server/src/services/AuthService.ts b/server/src/services/AuthService.ts deleted file mode 100644 index 801e41f..0000000 --- a/server/src/services/AuthService.ts +++ /dev/null @@ -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 { - 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 { - 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 }; -} diff --git a/server/src/services/GarminService.ts b/server/src/services/GarminService.ts deleted file mode 100644 index 02e4cba..0000000 --- a/server/src/services/GarminService.ts +++ /dev/null @@ -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 { - 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, - }; -} diff --git a/server/src/services/HealthService.ts b/server/src/services/HealthService.ts deleted file mode 100644 index 9c55e3c..0000000 --- a/server/src/services/HealthService.ts +++ /dev/null @@ -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; -} diff --git a/server/src/types/index.ts b/server/src/types/index.ts deleted file mode 100644 index 17c2b6f..0000000 --- a/server/src/types/index.ts +++ /dev/null @@ -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; -} diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts deleted file mode 100644 index afda62b..0000000 --- a/server/src/utils/database.ts +++ /dev/null @@ -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 { - 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((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 { - 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 { - 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 { - 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 || []); - }); - }); -} diff --git a/server/tsconfig.json b/server/tsconfig.json deleted file mode 100644 index c71688c..0000000 --- a/server/tsconfig.json +++ /dev/null @@ -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"] -}