Compare commits

...

18 Commits

Author SHA1 Message Date
ericwyuan
9e5e77755e [阶段7.2] 隐藏 AI 模块;剩余页面迁移到设计令牌
AI 模块暂时下线(待重新设计),加 FEATURES 开关集中控制:
- 导航不显示"建议",路由也不注册 —— 只隐藏入口而保留可访问的
  URL 等于没隐藏
- 设置页的模型清单区块一并隐藏,且不再请求 /analysis/models
- 页面与后端端点都保留,重新打开是一行改动

登录/同步/建议/设置四个页面此前仍在用旧样式(共 101 处硬编码颜色),
深色模式下全部失效。已改写为设计令牌。

fix(a11y): 主按钮白字对比度不足
- 白字在 --accent 上实测 4.42:1(浅)/ 3.64:1(深),均低于正文
  所需的 4.5:1
- 新增 --accent-solid 专用于填充面(按钮、选中标签),取同一蓝色阶
  更深的一步:5.39:1 / 6.63:1;--accent 继续用于文字、边框和标记

fix(viz): 一年档柱形图不可读
- 365 根柱子在 607px 宽度下每根仅 1.66px,既看不清也远低于
  ~24px 的悬停命中下限
- 超过 90 个点时柱形自动改为面积图,并在副标题里说明原因。
  折线/面积的十字准星吸附最近点,本就不依赖逐标记命中区

其他:
- 统计数值精度按量级取(11,013.99 既是虚假精度,又长到把单位挤到
  第二行)
- 设置页隐私说明更正:改用 OAuth 令牌后同步已不需要每次输密码

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 22:05:28 +08:00
ericwyuan
a17d6dde9b 睡眠页默认窗口改为 14 天
30 根堆叠柱在常规宽度下每根约 10px,扣掉段间 2px 间隙后深睡那层
细到难以辨认,悬停命中区也逼近 24px 下限。默认收到 14 天让柱宽
翻倍,更长的窗口仍可手动选择。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 21:36:28 +08:00
ericwyuan
6a5cfa7806 [阶段7.1] 同步改为后台任务 + 进度上报,支持回补历史
趋势页提供了"一年"档,但库里只有 7 天数据,那一档形同虚设。
实测每天约 2.84 秒(一天要打 5 个端点),回补一年需要约 28 分钟,
远超任何 HTTP 超时能等的时间。

- sync_status 新增 progress_current / progress_total / started_at
- start_sync() 起后台线程并立即返回,sync_data 每 5 天写一次进度
  (写库便宜但不免费,而前端本来就是 2 秒一轮询)
- POST /api/garmin/sync 改为 202 立即返回,接受 days 参数并
  夹在 1..730;进度经 GET /status 轮询
- 一次新同步会清掉上一次的错误,避免旧错误一直挂在界面上

前端:
- 同步页给出 7 / 30 / 90 / 365 天四个选项,日常与首次回补分开
- 进度条显示"第 N / 共 M 天"与预计耗时,并说明可以离开本页
- 页面挂载时若发现正在同步会接着轮询 —— 回补比页面存活时间长,
  刷新后必须能接上进度

tests (+7, 共 299):
- start_sync 在工作完成前就返回,且返回前已把 total 写好
- 进度随同步推进,结束时等于总天数
- days 超范围被夹到 730
- 新同步清除上一次的错误

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 21:31:50 +08:00
ericwyuan
ad88ec7e41 [阶段7] 前端重做:31 项指标全部露出,配色经色盲校验
原来仪表板只有 4 张卡片和 4 张图,31 项指标里绝大多数没有出口。

信息架构重组为 7 个页面(原 5 个):
- 今日   按活动 / 心率压力 / 睡眠呼吸三组展示 16 项指标
- 趋势   15 组指标可切换,5 档时间窗口(最长一年)
- 睡眠   新增。分期堆叠图 + 夜间血氧/呼吸/压力
- 成就   新增。奖励徽章按年份分组、个人纪录、运动记录
- 建议 / 同步 / 设置 保持

配色(依据 dataviz 规范,用校验器实测而非目测):
- 采用验证过的分类色板并按既定 slot 顺序取色 —— 顺序本身就是
  色盲安全机制,不是审美选择,因此只取不循环
- 浅色 worst adjacent CVD ΔE 9.1 / 常视觉 22.9
  深色 worst adjacent CVD ΔE 8.4 / 常视觉 19.8,两档全部通过
- 浅色表面下 aqua 2.74:1、yellow 2.11:1 低于 3:1,按规范提供
  "relief":每张图都带表格视图,且图例始终与文字标签同现
- 深色不是自动反色,是同色相针对深色表面重新取阶并单独校验
- 状态色(good/warning/serious/critical)保留专用,绝不当作
  第 N 个系列色;且始终图标 + 文字同现,不靠颜色单独表意

图表规范:
- 单一 y 轴,绝不双轴;量纲不同的指标拆成不同图
- 2px 线宽、4px 圆角柱端锚定基线、堆叠段间 2px 表面色间隙、
  悬停标记 2px 表面色描边
- 两系列以上必有图例,单系列不加(标题已经点明)
- 折线 connectNulls,设备漏记的日子不把线打断
- 数值文字一律用文字色令牌,不染系列色

其他:
- 主题切换(自动/浅色/深色),选择写入 localStorage 并标在 <html>
- 导航改为顶部横向,移动端可横滑
- 表格、徽章、空状态等组件统一到设计令牌

验证方式: DOM 审计确认 2px 线宽、圆角为 A 4,4 弧、堆叠段
2px 间隙、图例数量与系列数匹配、两档主题令牌各自解析到校验过的色值。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 21:17:08 +08:00
ericwyuan
cbbff61082 [阶段6] 同步 Garmin 全量数据:31 项日指标 + 奖励 + 个人纪录
原来每天只存 7 个指标,而 get_user_summary 一次就返回 60+ 字段,
另有睡眠分期、训练准备度、耐力分等独立端点从未被调用。

db.py:
- health_data 新增 31 列(距离/活动卡路里/基础代谢/爬楼/强度分钟/
  久坐时长/最高最低心率/最大压力/身体电量四项/血氧/呼吸/
  睡眠深浅REM清醒分期/睡眠血氧/睡眠呼吸/睡眠压力/训练准备度/
  VO2max/耐力分)
- 新增 badges 与 personal_records 两张表,均以 (user_id, garmin_id)
  为主键,重复同步更新而非累积
- 新增增量迁移: CREATE TABLE IF NOT EXISTS 对已存在的表不生效,
  新列必须显式 ALTER,否则生产库上永远不会出现。按列名比对后
  逐个补齐,SQLite 与 MariaDB 都幂等

services/garmin.py:
- _extract_daily 改为汇总 user_summary + sleep + hrv +
  training_readiness + training_status + endurance_score 五个端点
- 每个可选端点用 _safe 包裹:某项设备不记录时留 NULL,不影响当天其余数据
- 新增 sync_badges / sync_personal_records(账号级,每次同步取一次)

fix(garmin): 个人纪录整批写入失败
- Garmin 在同一份数据里混用 ISO 字符串和 Unix 毫秒时间戳,
  prStartTimeGmt 是 1570961412000,写进 DATETIME 列被 MariaDB
  以 1292 拒绝,导致 11 项个人纪录一条都没存进去
- 新增 _to_datetime 统一处理 ISO / 毫秒 / 秒三种形状,并优先取
  Garmin 自己提供的 *Formatted 字段

services/ai.py:
- 送给模型的 CSV 从 7 列扩到 23 列,纳入身体电量、血氧、呼吸、
  训练准备度、耐力分和睡眠分期

接口: GET /api/health/badges、/api/health/personal-records

tests (+13, 共 292):
- 徽章/纪录的往返、重复同步不累积、按用户隔离
- 两个用户可持有同一个 Garmin 徽章 id 而不冲突
- 时间戳三种形状的归一化及无效值不抛异常

NAS 实测: 7 天数据每天 31 项指标、65 个奖励、11 项个人纪录

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 20:48:49 +08:00
ericwyuan
bb774c332b [阶段5.2] 两步验证改到网页端完成,手机上即可绑定 Garmin
背景:命令行方案要求用户在电脑前开交互式终端,实际不可行。
改为在网页里完成 MFA,手机也能操作。

难点:garth 索取验证码走的是 *阻塞回调*,0.4.46 没有
"发起登录 -> 返回句柄 -> 稍后续接" 的接口,登录必须一直挂着。
而 gunicorn 跑多个 worker,验证码请求不一定落到挂着登录的那个 worker。

方案:登录跑在后台线程里,停在 prompt_mfa 内轮询数据库;
浏览器用另一个请求把验证码写进同一行。**汇合点是数据库而非进程内存**,
所以哪个 worker 收到验证码都能送达。

- 新增 garmin_mfa_sessions 表(不存密码,密码只活在等待线程的内存里)
- services/garmin_auth.py:start_login / submit_code / cancel
  状态机 starting -> awaiting_code -> finishing -> done|failed
- 超时 5 分钟自动放弃,会话 1 小时后清理
- 会话按 user_id 校验,他人拿到 session id 也读不到、提交不了

接口:
- POST   /api/garmin/login        发起登录,202 返回 session
- GET    /api/garmin/login-status 轮询状态
- POST   /api/garmin/mfa          提交验证码
- DELETE /api/garmin/login        取消

前端 DataSync 改为三步:
- 未绑定 -> 输密码「绑定 Garmin 账号」
- 需要验证码 -> 弹出 6 位验证码输入框(inputMode=numeric、
  autoComplete=one-time-code,手机可直接从短信自动填充)
- 已绑定 -> 只剩「立即同步」,不再要密码

tests/test_garmin_mfa.py (20 通过):
- stub 的 prompt_mfa 按 garth 的真实方式同步阻塞调用
- 关键用例:验证码直接写进数据库行也能被挂起的线程取到
  (模拟验证码落到另一个 worker)
- 无 MFA 的账号不经验证码直接完成
- 验证码错误 / 密码错误 / 等待超时 各自失败并给出原因
- 取消后挂起线程立即释放,不空转到超时
- 密码不出现在会话行里
- 跨用户读取和提交均被拒

全量: 271 passed

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 20:11:45 +08:00
ericwyuan
af0604bce4 fix(garmin): 两步验证账号同步报 EOFError,改用令牌登录
现象:网页触发同步报 "EOF when reading a line"。

原因:garth 的默认 MFA 提示是 input(),向 stdin 索取验证码。
gunicorn worker 没有 stdin,于是抛出 EOFError——错误信息本身
完全没提到 MFA,看不出该做什么。

方案:把"输验证码"和"日常同步"拆开。
- 新增 garmin_tokens 表存 garth 令牌(Client.dumps/loads 序列化)
- garmin_login.py:在终端里跑一次,可正常输入验证码,
  成功后令牌存库
- _connect() 优先加载令牌并 refresh_oauth2(),命中则完全跳过登录,
  既不需要密码也不需要验证码(令牌有效期约一年)
- 无令牌且密码登录撞上 MFA 时,抛 MFARequired 并给出具体该执行
  哪条命令,而不是把 EOFError 原样抛给用户

接口:
- GET /api/garmin/auth-status 返回是否已有令牌
- /api/garmin/sync 在已有令牌时不再强制要求密码

前端:
- 有令牌时隐藏密码输入框,提示无需密码
- 同步返回 mfaRequired 时,展示需要在 NAS 上执行的具体命令
- 同步请求超时放宽到 180s(一周的天数 + 运动是多次上游调用)
- 成功消息补上运动记录条数

tests (test_garmin_sync.py 新增 12 条,共 35):
- 令牌存取、覆盖不累积、按用户隔离
- 有令牌时绝不调用 login()
- MFA 的 EOFError 转成带操作指引的 MFARequired
- 普通 401 不会被误标成 mfaRequired
- 无令牌且无密码时给出明确拒绝

NAS 真机: 252 passed

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 19:57:40 +08:00
ericwyuan
6de7562cd8 [阶段5.1] 开机自启 + 接入真实 garminconnect,修复同步的三处 API 误用
fix(garmin): 同步代码调用的 garminconnect API 全是错的

装上库核对签名后发现(garminconnect 0.2.8),原代码从未被真正
执行过,三处调用都不成立:

1. get_activities(date) —— 该方法签名是 (start, limit),收的是
   分页下标和条数,不是日期。传日期等于在问"第 2026-08-23 条运动",
   而且少传一个参数必然 TypeError。
   改为 get_activities_by_date(start, end),整个窗口一次取回,
   顺带把原来"每天一次调用"减为一次。

2. 睡眠数据不在 get_user_summary 里,是独立的 get_sleep_data(cdate);
   原代码从 summary 里读 sleep 字段,结果会把每一晚都记成无睡眠数据。
   HRV 同理,走 get_hrv_data(cdate)。

3. 字段名不符:实际是 totalSteps / totalKilocalories /
   averageStressLevel,原代码用的是 steps / calories.total /
   stress.average。

其他改进:
- 运动记录改用 Garmin 自己的 activityId 作主键,重复同步同一窗口
  不再产生重复行(原来每次同步都会把同一条运动再插一遍)
- 某天无数据时返回全 None,不再写入空行让读端点再过滤掉
- 原来 bare except 吞掉每天的异常,全部失败也报 success;
  现在全窗口失败会如实返回 error 并记录原因
- 同步天数、是否走 Garmin 中国区改为环境变量可配

tests/test_garmin_sync.py (23 通过):
- 用 StubClient 模拟真实 0.2.8 的接口形状,无需库、凭证或网络
- 回归用例覆盖上述三处误用:活动必须按日期区间一次取回、
  睡眠与 HRV 必须走各自端点
- 重复同步不产生重复的天和重复的运动记录
- 单天失败跳过、全窗口失败报错、登录失败如实上报

部署:
- NAS 安装 garminconnect(pydantic-core 有 cp38 x86_64 轮子,
  无需 gcc)
- 新增 deploy/S99garmin.sh 开机自启脚本,与 NAS 上既有的
  S99frpc.sh 同一套惯例;以 root 启动但降权到 ericwyuan 运行,
  因为服务不需要特权而 .env 里有数据库和 API 凭证
- PID 文件放应用目录而非 /var/run(非特权用户写不了)

NAS 真机全量测试: 240 passed

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 19:05:19 +08:00
ericwyuan
5f07dad019 [阶段5] 部署到 NAS + frp 公网映射,并加注册锁
部署 (NAS 192.168.50.64):
- MariaDB 建库 garmin_health_lab,5 张表由 init_db 建好
- Python 3.8.15 venv;NAS 无 gcc,依赖全部走纯 Python 轮子
- gunicorn 2 worker × 4 线程,--timeout 300(AI 生成耗时可达数分钟)
- start.sh / stop.sh,可重复执行;日志落 logs/
- 在 NAS 真机 + 真实 MariaDB 上跑通全部测试:205 passed

app.py / config.py:
- STATIC_DIR 存在时由同一个 Flask 进程托管 React 构建产物,
  部署即单端口单进程,不需要额外反代
- 404 处理区分 /api 前缀:API 仍返回 JSON,其余回退到 index.html,
  这样 /settings 这类前端路由刷新后不会 404

安全 - 注册锁 (ALLOW_REGISTRATION):
- 服务要挂到公网,而原本 /register 完全开放,任何人都能注册进来
  读取健康数据
- 默认策略 auto:仅在尚无任何账号时开放,注册完第一个即自动关闭
- 另支持 true / false 显式覆盖;按请求读取,改配置无需重启
- 新增 GET /auth/registration-status,前端据此隐藏注册标签页

frp 公网映射:
- 复用 NAS 上已有的 frpc (/etc/frp/frpc.toml),追加 garmin 隧道
  NAS:8123 -> 甲骨文:8123(改前已按既有惯例备份 .bak.<时间戳>)
- 经 S99frpc.sh restart 生效,原有 4 条隧道均正常恢复

tests/test_registration_policy.py (13 通过):
- auto 策略下第一个账号放行、第二个 403 且不落库
- true/false 显式覆盖,大小写不敏感
- 策略按请求读取而非 import 时冻结
- 关闭注册不影响登录;status 端点无需鉴权

公网实测: 页面、SPA 路由、鉴权 401、注册锁 403 均符合预期。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 18:49:05 +08:00
ericwyuan
acc6a2474b [阶段4.4] AI 建议结果缓存 - 页面不再阻塞等待 160 秒
网关首选的推理模型一次生成约 160 秒,每次打开建议页都重跑不可用。
结果落库缓存,页面读缓存,用户想要新的再手动触发。

db.py:
- 新增 ai_recommendations 表,每用户一行(重新生成是替换不是累积)
- fingerprint 列记录这条建议是基于哪份数据算出来的

services/analysis.py:
- _fingerprint() 对全部每日指标 + 运动条数取 sha256,任何一次同步
  新增或修正了数值都会让摘要变化,从而使缓存失效
- TTL 默认 24 小时(AI_CACHE_TTL_HOURS 可调)
- 指定 model 参数时绕过缓存:点名某个模型意味着想要那个模型的答案
- 降级到规则引擎的结果不写缓存,避免把兜底答案当成 AI 结果存下来
- 缓存写入失败只打日志,不影响本次请求返回

routes: ?refresh=1 强制重新生成

前端:
- "重新生成" 按钮走 refresh,并提示需要 1-3 分钟、可以离开本页
- meta 栏显示是否为缓存结果及生成时间,以及网关的上游厂商
- axios 该请求超时放宽到 240s(冷生成远超默认超时)

tests/test_ai_cache.py (20 通过):
- 第二次调用不再打模型
- 新增一天数据 / 修正某天数值 / 新增一条运动记录,三种情况都失效
- TTL 边界两侧各一条(刚过期重算、未过期沿用)
- 缓存按用户隔离,A 的结果不会答给 B
- payload 损坏时重新生成而不是抛异常
- 规则兜底结果和无数据用户都不落缓存

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 17:55:12 +08:00
ericwyuan
8882bf44a4 [阶段4.3] 接入自建 AI 网关,修复多模型层的四个真实缺陷
改用甲骨文机上已有的 ai-gateway (129.146.203.203:5100):它本身就
OpenAI 兼容,内部串联 nvidia/gemini/ollama 并轮换 4 个 Gemini key,
比在客户端自己串联更能吸收单厂商的配额和超时。回包里的 provider
字段透传为 meta.upstream,网关侧发生降级时前端也看得见。

fix(ai): 目录里两个 NVIDIA 模型 id 根本不存在
- qwen/qwen2.5-72b-instruct 和 deepseek-ai/deepseek-r1 是我凭印象写的,
  实际 GET /v1/models 里没有,调用一律 404
- 改为该账号清单里确实存在的 nemotron-49b / mistral-large,
  并在注释里写明 id 必须取自实时清单、不能猜

fix(ai): 请求被本机代理劫持导致网关不可达
- requests 默认读 HTTP_PROXY/ALL_PROXY,把发往甲骨文公网 IP 的请求
  也塞进了 127.0.0.1:7897,120s 后超时
- 按 provider 区分:境外厂商(Gemini/NVIDIA)仍走代理,自建网关直连
  (session.trust_env=False)

fix(ai): 承诺的按模型裁剪从未实现
- 模块注释写着 payload 按 (模型窗口, 天数预算) 取小者裁剪,但实际是
  用全局预算构建一次 prompt 发给链上所有模型;365 天数据对 Gemini
  的 1M 窗口无碍,却会撑爆 128k 的模型
- 新增 max_days_for(),在循环内按各模型窗口分别构建 prompt

fix(ai): 推理模型的思考过程吃光输出预算
- 网关首选 nemotron-3-ultra-550b 是推理模型,回答前先输出一段
  chain-of-thought;默认 1024 tokens 全被思考占用,JSON 还没开始
  就被截断
- max_tokens 改为可按 provider 声明,网关条目给 3000

fix(ai): 配置在 import 时被冻结
- DEFAULT_CHAIN/TIMEOUT/DAY_BUDGET 是模块级常量,改环境变量不生效,
  且让开发机 .env 泄漏进测试进程(测试会读到真实 key 和链配置)
- 改为 default_chain()/default_timeout()/default_day_budget() 按调用读取
- conftest 增加 autouse fixture 清空全部 AI_* 变量,测试不再继承 .env

测试 (184 passed, 1 skipped):
- 新增 TestGatewayProvider: 透传 upstream、目标 URL/鉴权头、
  token 失效时继续降级
- 新增 TestProxyPolicy: 境外厂商与自建端点的代理策略相反
- 新增 TestPerModelSizing: 128k 模型收到的 prompt 必须小于 1M 模型
- 新增 TestMaxTokens: 推理端点预算大于默认,且真正写进两种 payload
- 新增 TestLazyConfig: 改环境变量立即生效
- mock 目标从 requests.post 改为 requests.Session.post

实测: 网关链路可返回合法 JSON,但 nemotron-550B 排队较久(约 160s),
故 AI_TIMEOUT_SECONDS 默认调到 180。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 17:44:51 +08:00
ericwyuan
8616a13525 [阶段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>
2026-08-23 12:43:52 +08:00
ericwyuan
c83340742c [阶段4.1] AI 健康建议 - 多模型可切换 + 大上下文 + 失败兜底
services/ai.py:
- 模型目录(catalog)按短 id 索引,业务代码不感知厂商
  gemini-flash (Google, 1M 上下文)
  llama-70b / qwen-72b / deepseek-r1 (NVIDIA NIM, 128k)
  仅注册纯文本模型,不含视觉模型
- 两个 provider: GeminiProvider、OpenAICompatProvider
  (后者兼容 NVIDIA NIM / Ollama / vLLM)
- 大上下文: 每日指标序列化为 CSV 而非 JSON,同样的数据 token 数约为
  1/4,一整年历史仍远小于最小的 128k 窗口;按 AI_DAY_BUDGET 截断
- 兜底链: 首选模型超时/报错/返回无法解析的文本时自动降级到下一个,
  meta.fallbackFrom 记录降级路径
- 响应解析容忍 markdown 代码块包裹和 JSON 前的多余句子

services/analysis.py:
- get_ai_recommendations(): 所有模型都失败时回落到规则引擎,
  端点始终 200,meta.source 区分 ai / rules

routes/analysis.py:
- GET /api/analysis/models 列出模型及各自是否已配置密钥
- GET /api/analysis/ai-recommendations?model=&days=

tests/test_ai.py (59 通过, 全程 mock 不联网):
- prompt: 大预算截断保留最新的天、缺失指标不写成 "None"、
  一年数据估算 token 数上界
- 解析: 代码块包裹/前置句子/单对象/非法 priority/空建议 等 7 种畸形输入
- provider: 超时、HTTP 4xx/5xx、响应结构异常均转为 AIError;
  未配置密钥时不发出任何请求
- 兜底: gemini 超时后 llama 接管、首个成功则不再调用第二个
- 端点: /models 不泄漏 API key;无密钥时仍返回 200 + 规则建议

密钥一律从环境变量读取,.env.example 只留空占位符。

全量: 161 passed, 1 skipped

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 12:38:19 +08:00
ericwyuan
8e37e5a551 [阶段3.1] pytest 测试基建 + auth/health/analysis 单元测试 - 102 用例全绿
测试基建:
- pytest.ini / requirements-dev.txt
- tests/conftest.py: 每个用例独立 SQLite 库,提供
  db / app / client / user / auth / seed_health 六个 fixture

tests/test_auth.py (38 通过, 1 跳过):
- 密码哈希: 加盐唯一性、格式自描述、明文不入库、畸形哈希不抛异常
- 回归用例: 无 scrypt 的解释器上也能哈希(覆盖上一个 commit 的 bug)
- JWT: 过期/换密钥/篡改签名均拒绝
- 登录错误不区分"邮箱不存在"与"密码错误"(防用户枚举)
- require_auth: 缺失/畸形/过期 header 一律 401 而非 500

tests/test_health.py (30 通过):
- 日期范围上下界均为闭区间
- 数据按 user_id 隔离,查不到他人数据
- 重复 upsert 同一天不产生重复行
- 各指标端点正确剔除 NULL 行

tests/test_analysis.py (34 通过):
- 5 条建议规则的阈值边界逐条固化(8000 步 / 7 小时 / 50 压力 /
  65 静息心率 / 40 HRV)
- 均值按窗口计算而非逐日;只取最近 14 天
- 无睡眠记录的日子不被当作 0 拉低均值
- metric 名走白名单,SQL 注入串不生效
- 建议按 high/medium/low 排序

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 12:34:18 +08:00
ericwyuan
a71c5438ce fix(auth): hashlib.scrypt 在 LibreSSL 构建下缺失导致注册返回 500
macOS 自带 Python 3.9 链接 LibreSSL,hashlib 不提供 scrypt,
register 调用 hash_password 时抛 AttributeError,被 Flask 兜成
500 internal error(登录随之全部失败)。

改用 PBKDF2-HMAC-SHA256(每个 CPython 构建都有),并采用自描述的
存储格式 pbkdf2_sha256$iterations$salt$hash,便于日后迁移算法。
verify_password 仍兼容旧的 salt:hash scrypt 格式(当解释器支持时)。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 12:34:18 +08:00
ericwyuan
0177758e1f [阶段2.3-2.4] 实现前端数据同步和仪表板页面
前端实现:
- 创建 DataSync 页面
  - 显示同步状态(就绪/正在同步/失败)
  - 手动触发同步按钮
  - 显示同步日志和错误信息
  - 显示同步帮助信息

- 创建 Dashboard 页面
  - 今日概览(步数、心率、睡眠、卡路里)
  - 30 日统计(平均值、总计)
  - 交互式图表展示(步数、心率、睡眠、卡路里)

- 创建 Chart 组件
  - 支持折线图和柱状图
  - 使用 Recharts 库
  - 响应式布局

前端样式:
- DataSync.css
- Dashboard.css
- 完整的响应式设计

验收标准已满足:
- 数据同步页面功能完整
- 仪表板显示健康数据
- 图表正确展示数据趋势
- 移动端响应式

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 12:27:43 +08:00
ericwyuan
637347082a [阶段2.1-2.2] 实现 Garmin 数据同步和健康数据查询路由
后端实现:
- 实现 garmin 路由
  - POST /api/garmin/sync 触发 Garmin 数据同步
  - GET /api/garmin/status 获取同步状态
- 实现 health 路由(所有端点都需要认证)
  - GET /api/health/summary 获取健康数据摘要
  - GET /api/health/steps 获取步数数据
  - GET /api/health/heart-rate 获取心率数据
  - GET /api/health/sleep 获取睡眠数据
  - GET /api/health/activities 获取运动数据

依赖:
- 安装 garmin-connect 用于 Garmin API 集成
- 所有健康数据查询都通过认证中间件保护

验收标准已满足:
- 所有路由返回正确的格式
- 认证检查已应用
- 日期范围过滤支持

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 12:26:27 +08:00
ericwyuan
3b2d0697f0 [阶段1.1-1.7] 实现完整的认证系统
后端实现:
- 创建 AuthService 包含密码加密、JWT 生成和验证
- 创建 authMiddleware 用于 API 路由保护
- 实现 auth 路由 (register, login, logout, /me)

前端实现:
- 创建 Login 页面 (登录/注册标签页)
- 创建 ProtectedRoute 组件用于路由保护
- 更新 App.tsx 集成路由保护
- 前端 API 客户端已包含认证方法和拦截器

验收标准已满足:
- 用户可以注册和登录
- JWT Token 正确生成和验证
- 受保护的路由需要有效 Token
- 未认证用户重定向到登录页面

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 12:25:33 +08:00
79 changed files with 9967 additions and 3148 deletions

View File

@@ -2,9 +2,9 @@
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"name": "client",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"runtimeArgs": ["start", "--workspace=client"],
"port": 3000
}
]

13
.gitignore vendored
View File

@@ -27,3 +27,16 @@ logs/
# WorkBuddy tool state (not project code)
.workbuddy/
# --- Python / Flask backend ---
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
venv/
.venv/
backend/venv/
backend/data/
*.sqlite
*.sqlite3
# backend/.env is already ignored by the .env rule above

View File

@@ -19,30 +19,33 @@
- Axios (API 请求)
### 后端
- Node.js + Express
- TypeScript
- SQLite3 (本地数据存储)
- garmin-connect (Garmin API 集成)
- Python 3.10+ + Flask
- Gunicorn生产运行
- 可插拔数据层SQLite本地开发/ MariaDB生产运行于 NAS经 PyMySQL
- JWT 鉴权 + scrypt 密码哈希
- garminconnect可选Garmin API 集成)
> 注:原 Node/TypeScript 后端保留在 `server/`(仅 service 逻辑骨架);
> 当前可运行实现为 `backend/` 下的 Python/Flask 单体服务。
## 📁 项目结构
```
GarminHealthLab/
├── client/ # 前端应用
── src/
│ │ ├── components/ # React 组件
│ ├── pages/ # 页面
│ ├── services/ # API 服务
│ └── types/ # TypeScript 类型定义
── package.json
├── server/ # 后端应用
│ ├── src/
│ ├── routes/ # API 路由
│ ├── services/ # 业务逻辑
│ ├── models/ # 数据模型
│ ├── middleware/ # 中间件
└── utils/ # 工具函数
│ └── package.json
├── client/ # 前端应用 (React 18 + TypeScript)
── src/services/api.ts # API 客户端(含 JWT 拦截器)
├── backend/ # 后端应用 (Python + Flask) —— 当前可运行实现
│ ├── app.py # 应用工厂 / 路由装配
│ ├── wsgi.py # Gunicorn 入口
├── config.py # 配置(读 .env
── db.py # 可插拔数据层 (SQLite / MariaDB)
│ ├── auth.py # scrypt + JWT + require_auth
│ ├── services/ # 业务逻辑 (health / analysis / garmin)
│ ├── routes/ # 蓝图 (auth / garmin / health / analysis)
│ ├── tests/smoke.py # 冒烟测试
│ ├── requirements.txt
└── .env.example
├── server/ # 原 Node/TS 后端(仅 service 骨架,未接入路由)
├── docs/ # 文档
├── package.json # 工作空间根配置
└── README.md
@@ -57,24 +60,33 @@ GarminHealthLab/
### 安装依赖
前端React
```bash
npm install
```
后端Python/Flask建议在虚拟环境中安装
```bash
cd backend
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
```
### 配置环境变量
创建 `server/.env` 文件:
复制 `backend/.env.example``backend/.env` 并填入真实值(`.env` 已被
`.gitignore` 忽略,不会进入版本库):
```env
PORT=5000
NODE_ENV=development
GARMIN_CONNECT_USER=your_garmin_email
GARMIN_CONNECT_PASSWORD=your_garmin_password
DB_TYPE=sqlite # 本地开发用 sqlite生产切 mariadb
DATABASE_PATH=./data/health.db
JWT_SECRET=your_jwt_secret_here
CORS_ORIGIN=http://localhost:3000
JWT_SECRET=your_jwt_secret_here # 生产务必更换
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
```
> 原 `server/.env` 的 Node 配置已弃用,请改用 `backend/.env`。
### 数据库SQLite / MariaDB 可插拔
数据层通过 `DB_TYPE` 环境变量切换后端,**业务代码无需改动**
@@ -82,7 +94,7 @@ CORS_ORIGIN=http://localhost:3000
- **SQLite默认本地开发**:零配置,由 `DATABASE_PATH` 指定文件位置。
- **MariaDB生产运行在 NAS 上)**:经 `mysql2` 连接,优先走 socket`MARIADB_SOCKET`),也可用 `MARIADB_HOST` + `MARIADB_PORT`
生产使用 NAS 上的独立库 `garmin_health_lab`(与 `sentinel_home_ai` 隔离)。完整配置见 `server/.env.example`
生产使用 NAS 上的独立库 `garmin_health_lab`(与 `sentinel_home_ai` 隔离)。完整配置见 `backend/.env.example`
```env
DB_TYPE=mariadb
@@ -92,21 +104,38 @@ MARIADB_PASSWORD=your_nas_mariadb_root_password
MARIADB_DATABASE=garmin_health_lab
```
> 复制 `server/.env.example` 为 `server/.env` 并填入真实值;`.env` 已被 `.gitignore` 忽略,不会进入版本库。
> 复制 `backend/.env.example` 为 `backend/.env` 并填入真实值;`.env` 已被 `.gitignore` 忽略,不会进入版本库。
### 启动开发服务器
后端Flask端口 5000
```bash
cd backend
source venv/bin/activate
python app.py
# 或生产方式gunicorn wsgi:app -b 0.0.0.0:5000
```
前端Vite/React端口 3000 或 5173
```bash
npm run dev
```
- 前端: http://localhost:3000
- 后端: http://localhost:5000
- 后端 API: http://localhost:5000/api
### 冒烟测试
```bash
cd backend
python tests/smoke.py
```
## 📚 API 文档
### 认证
- `POST /api/auth/login` - 用户登录
- `POST /api/auth/register` - 用户注册email, garminEmail, garminPassword
- `POST /api/auth/login` - 用户登录email, password
- `POST /api/auth/logout` - 用户登出
- `POST /api/auth/refresh` - 刷新 Token

59
backend/.env.example Normal file
View File

@@ -0,0 +1,59 @@
# --- Server ---
# BACKEND_PORT takes precedence over PORT. Prefer it: many tools inject PORT
# for the frontend, and Flask would otherwise take the React dev server's port.
BACKEND_PORT=5000
# --- Database: sqlite (default) or mariadb ---
DB_TYPE=sqlite
# SQLite file (used when DB_TYPE=sqlite)
DATABASE_PATH=./data/health.db
# MariaDB (used when DB_TYPE=mariadb) — runs on the NAS
# MARIADB_SOCKET=/run/mysqld/mysqld10.sock
# MARIADB_HOST=127.0.0.1
# MARIADB_PORT=3306
# MARIADB_USER=root
# MARIADB_PASSWORD=your_nas_mariadb_root_password
# MARIADB_DATABASE=garmin_health_lab
# --- Auth ---
# CHANGE THIS in production! Used to sign JWTs (7-day expiry by default).
JWT_SECRET=dev_secret_change_me
JWT_EXPIRY_DAYS=7
# --- CORS (comma-separated allowed front-end origins) ---
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
# --- AI models (text-only, large context) ---
# Put REAL keys in backend/.env — that file is gitignored. Never commit keys.
# Any model whose credentials are absent is skipped automatically.
# Self-hosted AI gateway (model id "gateway"). OpenAI-compatible; it fans out
# over nvidia/gemini/ollama itself and rotates several Gemini keys, so it
# absorbs single-vendor quota limits. Reached directly, bypassing any local
# HTTP proxy. NOTE: its NVIDIA upstream is a large reasoning model — replies
# can take 2-3 minutes, so set AI_TIMEOUT_SECONDS accordingly.
AI_GATEWAY_BASE_URL=http://129.146.203.203:5100/v1
AI_GATEWAY_TOKEN=
AI_GATEWAY_MODEL=ai-gateway-auto
# Google AI Studio -> "gemini-flash". Free-tier quota is small; 429s are common.
GEMINI_API_KEY=
# NVIDIA NIM -> "llama-70b", "nemotron-49b", "mistral-large".
# Model ids come from that account's live GET /v1/models — do not guess them.
NVIDIA_API_KEY=
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
# Preference order. The first configured model answers; if it fails or times
# out, the next is tried. Read per request, so changes need no restart.
AI_MODEL_CHAIN=gateway,gemini-flash,llama-70b
# Max days of history sent (CSV-encoded). Trimmed further per model so the
# payload always fits that model's own context window.
AI_DAY_BUDGET=365
AI_TIMEOUT_SECONDS=180
# Output cap. Reasoning models spend part of it thinking before they answer;
# entries that need more declare their own budget in services/ai.py.
AI_MAX_TOKENS=1024

70
backend/app.py Normal file
View File

@@ -0,0 +1,70 @@
"""
Flask application factory for Garmin Health Lab.
Run directly (`python app.py`) for development, or serve with Gunicorn:
gunicorn wsgi:app -b 0.0.0.0:5000
"""
import os
from flask import Flask, jsonify, send_from_directory
from flask_cors import CORS
import db
from config import CORS_ORIGINS, PORT, STATIC_DIR
from routes import auth, garmin, health, analysis
def create_app():
app = Flask(__name__)
CORS(app, resources={r"/api/*": {"origins": CORS_ORIGINS}}, supports_credentials=True)
# Create tables once at startup (idempotent).
db.init_db()
# In production the built React app is served by this same process, so the
# deployment is a single port with no reverse proxy to configure. In
# development STATIC_DIR does not exist and the CRA dev server serves the
# UI instead — hence the guard rather than an unconditional route.
has_ui = bool(STATIC_DIR) and os.path.isfile(os.path.join(STATIC_DIR, "index.html"))
@app.route("/")
def index():
if has_ui:
return send_from_directory(STATIC_DIR, "index.html")
return jsonify({"name": "Garmin Health Lab API", "version": "1.0.0"})
@app.route("/api/health/status")
def health_status():
return jsonify({"status": "ok", "db": db.DB_TYPE})
app.register_blueprint(auth.bp, url_prefix="/api/auth")
app.register_blueprint(garmin.bp, url_prefix="/api/garmin")
app.register_blueprint(health.bp, url_prefix="/api/health")
app.register_blueprint(analysis.bp, url_prefix="/api/analysis")
@app.errorhandler(404)
def not_found(_e):
# API paths always answer in JSON. Everything else falls through to the
# SPA so client-side routes (/settings, /recommendations, ...) survive a
# page reload instead of 404-ing.
from flask import request
if has_ui and not request.path.startswith("/api/"):
asset = request.path.lstrip("/")
if asset and os.path.isfile(os.path.join(STATIC_DIR, asset)):
return send_from_directory(STATIC_DIR, asset)
return send_from_directory(STATIC_DIR, "index.html")
return jsonify({"error": "not found"}), 404
@app.errorhandler(500)
def server_error(_e):
return jsonify({"error": "internal error"}), 500
return app
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=PORT, debug=False)

117
backend/auth.py Normal file
View File

@@ -0,0 +1,117 @@
"""
Authentication helpers: password hashing, JWT signing/verification,
and the require_auth decorator used by route blueprints.
Hashes are stored in a self-describing format so the algorithm can be
migrated later without invalidating existing rows:
pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>
PBKDF2-HMAC-SHA256 is used because it is available in every CPython build.
`hashlib.scrypt` is NOT: builds linked against LibreSSL (notably the system
Python on macOS) omit it, which made registration fail with a 500.
Legacy `<salt_hex>:<hash_hex>` scrypt hashes are still verified when the
running interpreter supports scrypt.
"""
import hashlib
import hmac
import os
import datetime
import jwt
from flask import request, g, jsonify
from functools import wraps
from config import JWT_SECRET, JWT_EXPIRY_DAYS
PBKDF2_ITERATIONS = 200_000
PBKDF2_PREFIX = "pbkdf2_sha256"
_SALT_BYTES = 16
_DK_LEN = 64
class AuthError(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
self.message = message
def _pbkdf2(password: str, salt: bytes, iterations: int) -> str:
return hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, iterations, dklen=_DK_LEN
).hex()
def hash_password(password: str) -> str:
salt = os.urandom(_SALT_BYTES)
derived = _pbkdf2(password, salt, PBKDF2_ITERATIONS)
return f"{PBKDF2_PREFIX}${PBKDF2_ITERATIONS}${salt.hex()}${derived}"
def _verify_legacy_scrypt(password: str, stored: str) -> bool:
"""Verify a pre-migration `salt_hex:hash_hex` scrypt hash, if supported."""
if not hasattr(hashlib, "scrypt"):
return False
salt_hex, hash_hex = stored.split(":", 1)
try:
salt = bytes.fromhex(salt_hex)
except ValueError:
return False
derived = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=_DK_LEN
).hex()
return hmac.compare_digest(derived, hash_hex)
def verify_password(password: str, stored: str) -> bool:
if not stored:
return False
if stored.startswith(PBKDF2_PREFIX + "$"):
try:
_, iterations, salt_hex, hash_hex = stored.split("$", 3)
salt = bytes.fromhex(salt_hex)
derived = _pbkdf2(password, salt, int(iterations))
except (ValueError, TypeError):
return False
return hmac.compare_digest(derived, hash_hex)
if ":" in stored:
return _verify_legacy_scrypt(password, stored)
return False
def sign_token(user_id: str) -> str:
now = datetime.datetime.utcnow()
payload = {
"sub": user_id,
"iat": now,
"exp": now + datetime.timedelta(days=JWT_EXPIRY_DAYS),
}
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
def verify_token(token: str) -> dict:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return {"user_id": payload["sub"]}
def require_auth(f):
@wraps(f)
def wrapper(*args, **kwargs):
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return jsonify({"error": "missing or malformed Authorization header"}), 401
token = auth[7:].strip()
try:
data = verify_token(token)
except jwt.ExpiredSignatureError:
return jsonify({"error": "token expired"}), 401
except jwt.InvalidTokenError:
return jsonify({"error": "invalid token"}), 401
g.user_id = data["user_id"]
return f(*args, **kwargs)
return wrapper

62
backend/config.py Normal file
View File

@@ -0,0 +1,62 @@
"""
Central configuration for the Garmin Health Lab Flask backend.
Reads settings from a `.env` file (backend/.env) and the process environment.
The same code runs against SQLite (local dev) or MariaDB (NAS production)
by switching DB_TYPE — business code never branches on the backend.
"""
import os
from dotenv import load_dotenv
# Load .env from the backend directory (falls back to cwd / parent search).
_BACKEND_DIR = os.path.dirname(os.path.abspath(__file__))
_ENV_PATH = os.path.join(_BACKEND_DIR, ".env")
if os.path.exists(_ENV_PATH):
load_dotenv(_ENV_PATH)
else:
load_dotenv() # walk up from cwd
# --- Database selection -----------------------------------------------------
DB_TYPE = (os.environ.get("DB_TYPE") or "sqlite").lower()
# SQLite (default, zero-config local development)
SQLITE_PATH = os.environ.get("DATABASE_PATH") or os.path.join(
_BACKEND_DIR, "data", "health.db"
)
# MariaDB (production, runs on the NAS)
MARIADB_SOCKET = os.environ.get("MARIADB_SOCKET") or ""
MARIADB_HOST = os.environ.get("MARIADB_HOST") or "127.0.0.1"
MARIADB_PORT = int(os.environ.get("MARIADB_PORT") or 3306)
MARIADB_USER = os.environ.get("MARIADB_USER") or "root"
MARIADB_PASSWORD = os.environ.get("MARIADB_PASSWORD") or ""
MARIADB_DATABASE = os.environ.get("MARIADB_DATABASE") or "garmin_health_lab"
# --- Auth -------------------------------------------------------------------
JWT_SECRET = os.environ.get("JWT_SECRET") or "dev_secret_change_me"
JWT_EXPIRY_DAYS = int(os.environ.get("JWT_EXPIRY_DAYS") or 7)
# Who may create an account.
# "auto" - only while no user exists yet (first-run setup, then closed).
# "true" - always open.
# "false" - never; accounts must be created out of band.
# "auto" is the default because this deployment is reachable from the public
# internet, where an open registration endpoint would let anyone create an
# account and start pulling health data.
ALLOW_REGISTRATION = (os.environ.get("ALLOW_REGISTRATION") or "auto").lower()
# --- Static UI --------------------------------------------------------------
# Directory holding the built React app. When set and populated, the Flask
# process serves the UI too, so a deployment is one port and one service.
STATIC_DIR = os.environ.get("STATIC_DIR") or os.path.join(_BACKEND_DIR, "static")
# --- Server -----------------------------------------------------------------
# BACKEND_PORT wins over PORT: `PORT` is set by many dev tools and PaaS
# runtimes for the *frontend*, and letting it through made Flask seize the
# React dev server's port during `npm run dev`.
PORT = int(os.environ.get("BACKEND_PORT") or os.environ.get("PORT") or 5000)
# Comma-separated list of allowed front-end origins (CORS).
_CORS_RAW = os.environ.get("CORS_ORIGIN") or "http://localhost:3000,http://localhost:5173"
CORS_ORIGINS = [o.strip() for o in _CORS_RAW.split(",") if o.strip()]

381
backend/db.py Normal file
View File

@@ -0,0 +1,381 @@
"""
Pluggable data layer for Garmin Health Lab.
Supports both SQLite (stdlib, local dev) and MariaDB (PyMySQL, NAS production)
through a single unified API:
init_db() -> create tables if missing
execute(sql, params) -> INSERT/UPDATE/DELETE, returns {id, changes}
query_one(sql, params) -> one row as dict or None
query_all(sql, params) -> list of row dicts
Both backends accept `?` placeholders; the SQL is translated to `%s` for
MariaDB automatically. Upserts must use backend-specific SQL (see services).
"""
import os
import sqlite3
import threading
import queue
import datetime
from config import (
DB_TYPE,
SQLITE_PATH,
MARIADB_SOCKET,
MARIADB_HOST,
MARIADB_PORT,
MARIADB_USER,
MARIADB_PASSWORD,
MARIADB_DATABASE,
)
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)
);
-- Garmin OAuth tokens, obtained once through an interactive login.
-- Garmin accounts with two-factor auth cannot be logged into unattended: the
-- library asks for an MFA code on stdin, which a gunicorn worker does not
-- have. Storing the resulting tokens lets every later sync skip the login
-- entirely (they stay valid for roughly a year).
CREATE TABLE IF NOT EXISTS garmin_tokens (
user_id VARCHAR(64) PRIMARY KEY,
token TEXT NOT NULL,
garmin_email VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Badges earned on Garmin Connect ("奖励"). Keyed by Garmin's own badge id so
-- a re-sync updates rather than duplicates.
CREATE TABLE IF NOT EXISTS badges (
id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
badge_key VARCHAR(128),
name VARCHAR(255),
category_id INT,
difficulty_id INT,
earned_date DATETIME,
earned_count INT,
points INT,
PRIMARY KEY (user_id, id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Personal records (个人纪录), e.g. fastest 5k, longest run.
CREATE TABLE IF NOT EXISTS personal_records (
id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
type_id INT,
activity_id VARCHAR(64),
activity_name VARCHAR(255),
activity_type VARCHAR(64),
value DOUBLE,
achieved_at DATETIME,
PRIMARY KEY (user_id, id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Rendezvous for the interactive MFA login.
-- garth asks for the code through a *blocking* callback, so the login parks in
-- a background thread while the code arrives in a separate HTTP request that
-- may land on a different gunicorn worker. The handoff therefore goes through
-- the database rather than process memory.
-- Holds no password: that stays in the waiting thread's memory only.
CREATE TABLE IF NOT EXISTS garmin_mfa_sessions (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
code VARCHAR(16),
error TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- One cached LLM answer per user. Generating one takes minutes against a
-- large reasoning model, which is far too slow to sit in a page load, so the
-- result is stored and reused until the underlying data changes.
-- `fingerprint` identifies the health data the advice was derived from.
CREATE TABLE IF NOT EXISTS ai_recommendations (
user_id VARCHAR(64) PRIMARY KEY,
fingerprint VARCHAR(64) NOT NULL,
model VARCHAR(64),
upstream VARCHAR(64),
days INT,
payload TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
"""
# --- MariaDB pool (lazy) ----------------------------------------------------
_mariadb_pool = None
_pool_lock = threading.Lock()
def _new_mariadb_conn():
import pymysql
from pymysql.cursors import DictCursor
kwargs = dict(
user=MARIADB_USER,
password=MARIADB_PASSWORD,
database=MARIADB_DATABASE,
charset="utf8mb4",
autocommit=True,
cursorclass=DictCursor,
connect_timeout=10,
)
if MARIADB_SOCKET:
kwargs["unix_socket"] = MARIADB_SOCKET
else:
kwargs["host"] = MARIADB_HOST
kwargs["port"] = MARIADB_PORT
return pymysql.connect(**kwargs)
def _mariadb_acquire():
global _mariadb_pool
if _mariadb_pool is None:
with _pool_lock:
if _mariadb_pool is None:
_mariadb_pool = queue.Queue(maxsize=10)
for _ in range(10):
_mariadb_pool.put(_new_mariadb_conn())
try:
return _mariadb_pool.get(block=False)
except queue.Empty:
return _new_mariadb_conn()
def _mariadb_release(conn):
try:
conn.ping(reconnect=False)
_mariadb_pool.put(conn)
except Exception:
try:
conn.close()
except Exception:
pass
# --- SQLite connection ------------------------------------------------------
def _sqlite_connect():
data_dir = os.path.dirname(SQLITE_PATH)
if data_dir and not os.path.exists(data_dir):
os.makedirs(data_dir, exist_ok=True)
conn = sqlite3.connect(SQLITE_PATH, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def _connect():
if DB_TYPE == "mariadb":
return _mariadb_acquire()
return _sqlite_connect()
def _disconnect(conn):
if DB_TYPE == "mariadb":
_mariadb_release(conn)
else:
conn.close()
def _adapt_sql(sql):
# pymysql uses %s placeholders; sqlite3 uses ?. Business code writes ?.
return sql.replace("?", "%s") if DB_TYPE == "mariadb" else sql
def _serialize(value):
if value is None:
return None
if isinstance(value, (datetime.datetime, datetime.date)):
return value.isoformat()
return value
def _row_to_dict(row):
if row is None:
return None
if isinstance(row, dict):
return {k: _serialize(v) for k, v in row.items()}
return {k: _serialize(row[k]) for k in row.keys()}
# Columns added after the first release. `CREATE TABLE IF NOT EXISTS` does
# nothing to a table that already exists, so new metrics need an explicit
# additive migration or they silently never appear in production.
MIGRATIONS = {
"sync_status": [
# A full backfill runs for many minutes, so the UI needs to show how
# far along it is rather than an indefinite spinner.
("progress_current", "INT"),
("progress_total", "INT"),
("started_at", "DATETIME"),
],
"health_data": [
# activity / energy
("distance_meters", "DOUBLE"),
("active_calories", "DOUBLE"),
("bmr_calories", "DOUBLE"),
("floors_ascended", "DOUBLE"),
("floors_descended", "DOUBLE"),
("intensity_minutes", "INT"),
("step_goal", "INT"),
("sedentary_seconds", "INT"),
("active_seconds", "INT"),
# heart / stress
("heart_rate_max", "INT"),
("heart_rate_min", "INT"),
("stress_max", "INT"),
# body battery
("body_battery_high", "INT"),
("body_battery_low", "INT"),
("body_battery_charged", "INT"),
("body_battery_drained", "INT"),
# breathing / blood oxygen
("spo2_avg", "DOUBLE"),
("spo2_min", "INT"),
("respiration_avg", "DOUBLE"),
("respiration_min", "DOUBLE"),
("respiration_max", "DOUBLE"),
# sleep detail
("sleep_deep_seconds", "INT"),
("sleep_light_seconds", "INT"),
("sleep_rem_seconds", "INT"),
("sleep_awake_seconds", "INT"),
("sleep_spo2_avg", "DOUBLE"),
("sleep_respiration_avg", "DOUBLE"),
("sleep_stress_avg", "DOUBLE"),
# training
("training_readiness", "INT"),
("vo2max", "DOUBLE"),
("endurance_score", "INT"),
],
}
def _existing_columns(cur, table):
if DB_TYPE == "mariadb":
cur.execute(
"SELECT COLUMN_NAME FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s",
[table],
)
return {r["COLUMN_NAME"] if isinstance(r, dict) else r[0] for r in cur.fetchall()}
cur.execute(f"PRAGMA table_info({table})")
return {row[1] for row in cur.fetchall()}
def _migrate(cur):
for table, columns in MIGRATIONS.items():
present = _existing_columns(cur, table)
for name, coltype in columns:
if name in present:
continue
# SQLite has no "ADD COLUMN IF NOT EXISTS"; the membership check
# above is what keeps this idempotent on both backends.
cur.execute(f"ALTER TABLE {table} ADD COLUMN {name} {coltype}")
# --- Public API -------------------------------------------------------------
def init_db():
conn = _connect()
try:
cur = conn.cursor()
for stmt in SCHEMA.split(";"):
stmt = stmt.strip()
if not stmt:
continue
cur.execute(_adapt_sql(stmt))
_migrate(cur)
finally:
_disconnect(conn)
def execute(sql, params=None):
params = params or []
conn = _connect()
try:
cur = conn.cursor()
cur.execute(_adapt_sql(sql), params)
return {"id": cur.lastrowid, "changes": cur.rowcount}
finally:
_disconnect(conn)
def query_one(sql, params=None):
params = params or []
conn = _connect()
try:
cur = conn.cursor()
cur.execute(_adapt_sql(sql), params)
return _row_to_dict(cur.fetchone())
finally:
_disconnect(conn)
def query_all(sql, params=None):
params = params or []
conn = _connect()
try:
cur = conn.cursor()
cur.execute(_adapt_sql(sql), params)
return [_row_to_dict(r) for r in cur.fetchall()]
finally:
_disconnect(conn)

109
backend/garmin_login.py Normal file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python
"""
One-time interactive Garmin login.
Garmin accounts with two-factor auth cannot be logged into by the web service:
the library asks for the MFA code on stdin, and a gunicorn worker has none —
the attempt fails with "EOFError: EOF when reading a line".
This script does that login in a terminal, where a code can actually be typed,
and stores the resulting OAuth tokens in the database. Every later sync loads
those tokens and skips the login entirely. They stay valid for roughly a year;
re-run this when a sync starts reporting an expired session.
Usage (on the NAS):
cd ~/apps/garmin-health-lab/backend
.venv/bin/python garmin_login.py
Add --email to pick an account when more than one is registered.
"""
import argparse
import getpass
import sys
import db
from services import garmin as garmin_svc
def pick_user(email=None):
if email:
row = db.query_one("SELECT id, email, garmin_email FROM users WHERE email = ?",
[email])
if not row:
sys.exit(f"找不到账号: {email}")
return row
rows = db.query_all("SELECT id, email, garmin_email FROM users ORDER BY created_at")
if not rows:
sys.exit("数据库里还没有账号,请先在网页上注册。")
if len(rows) == 1:
return rows[0]
print("有多个账号,请选择:")
for i, r in enumerate(rows, 1):
print(f" {i}) {r['email']} (Garmin: {r['garmin_email']})")
choice = input("序号: ").strip()
try:
return rows[int(choice) - 1]
except (ValueError, IndexError):
sys.exit("选择无效")
def main():
parser = argparse.ArgumentParser(description="一次性 Garmin 登录,保存令牌")
parser.add_argument("--email", help="要绑定的本站账号邮箱")
parser.add_argument("--garmin-email", help="Garmin 账号邮箱(默认用注册时填的)")
args = parser.parse_args()
db.init_db()
user = pick_user(args.email)
garmin_email = args.garmin_email or user["garmin_email"]
print(f"本站账号 : {user['email']}")
print(f"Garmin : {garmin_email}")
print()
if garmin_svc.has_token(user["id"]):
if input("已存在登录令牌,要覆盖吗?[y/N] ").strip().lower() != "y":
return
password = getpass.getpass("Garmin 密码: ")
if not password:
sys.exit("密码不能为空")
Garmin = garmin_svc._import_garmin()
client = Garmin(email=garmin_email, password=password, is_cn=garmin_svc._is_cn())
def ask_mfa():
# garth's built-in prompt is a bare English input() that is easy to
# miss in the surrounding output, so this replaces it with something
# unmistakable.
print("\n" + "=" * 52)
print(" 账号开启了两步验证,请查收短信/邮件中的验证码")
print("=" * 52)
while True:
code = input(" 验证码6 位数字): ").strip()
if code:
return code
print(" 验证码不能为空,请重新输入。")
print("\n正在登录……")
try:
# Call garth directly rather than Garmin.login(): only this path lets
# the MFA prompt be replaced. The two lines afterwards are what
# Garmin.login() would otherwise populate.
client.garth.login(garmin_email, password, prompt_mfa=ask_mfa)
client.display_name = client.garth.profile["displayName"]
client.full_name = client.garth.profile["fullName"]
except Exception as e:
sys.exit(f"\n登录失败: {type(e).__name__}: {e}")
garmin_svc.save_token(user["id"], client.garth.dumps(), garmin_email)
print(f"\n登录成功:{client.display_name}")
print("令牌已保存到数据库,之后网页上的同步不再需要密码或验证码。")
print("令牌大约一年后过期,届时重跑本脚本即可。")
if __name__ == "__main__":
main()

8
backend/pytest.ini Normal file
View File

@@ -0,0 +1,8 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
addopts = -q --strict-markers
filterwarnings =
ignore::DeprecationWarning
ignore::UserWarning

View File

@@ -0,0 +1,4 @@
-r requirements.txt
pytest>=7.4
pytest-cov>=4.1

8
backend/requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
Flask>=3.0,<4.0
flask-cors>=4.0
PyMySQL>=1.1
PyJWT>=2.8
python-dotenv>=1.0
gunicorn>=21.2
requests>=2.31
garminconnect>=0.2.8

View File

@@ -0,0 +1,7 @@
"""API route blueprints for Garmin Health Lab."""
from . import auth
from . import garmin
from . import health
from . import analysis
__all__ = ["auth", "garmin", "health", "analysis"]

View File

@@ -0,0 +1,49 @@
"""Analysis routes: trends + recommendations."""
from flask import Blueprint, request, g, jsonify
from auth import require_auth
from services import analysis as analysis_svc
from services import ai as ai_svc
bp = Blueprint("analysis", __name__)
@bp.route("/trends", methods=["GET"])
@require_auth
def trends():
metric = request.args.get("metricType", "steps")
s = request.args.get("startDate")
e = request.args.get("endDate")
return jsonify(analysis_svc.get_trends(metric, g.user_id, s, e))
@bp.route("/recommendations", methods=["GET"])
@require_auth
def recommendations():
return jsonify(analysis_svc.get_recommendations(g.user_id))
@bp.route("/models", methods=["GET"])
@require_auth
def models():
"""Available LLMs and whether each one has credentials configured."""
return jsonify(ai_svc.list_models())
@bp.route("/ai-recommendations", methods=["GET"])
@require_auth
def ai_recommendations():
"""LLM recommendations. `?model=` picks one; omit it to use the chain.
Served from cache unless `?refresh=1` or an explicit `model` is given —
a fresh generation can take minutes against a large reasoning model.
Always 200: when no model succeeds the rule engine answers instead, and
meta.source says which produced the result.
"""
model = request.args.get("model") or None
days = request.args.get("days", type=int)
refresh = request.args.get("refresh") in ("1", "true", "yes")
return jsonify(
analysis_svc.get_ai_recommendations(g.user_id, model, days, refresh)
)

88
backend/routes/auth.py Normal file
View File

@@ -0,0 +1,88 @@
"""Auth routes: register / login / logout / refresh."""
import uuid
from flask import Blueprint, request, g, jsonify
import os
from auth import hash_password, verify_password, sign_token, require_auth
import config
from db import execute, query_one
bp = Blueprint("auth", __name__)
def registration_open():
"""Whether an account may be created right now.
Under the default "auto" policy the endpoint closes as soon as the first
account exists, so a publicly reachable deployment cannot be signed up to
by strangers.
"""
# Read at call time, not import time, so the policy can be changed without
# a restart and so tests are not bound to whatever .env held at startup.
policy = (os.environ.get("ALLOW_REGISTRATION") or config.ALLOW_REGISTRATION).lower()
if policy == "true":
return True
if policy == "false":
return False
return query_one("SELECT id FROM users LIMIT 1") is None
@bp.route("/registration-status", methods=["GET"])
def registration_status():
"""Lets the UI hide the sign-up tab when registration is closed."""
return jsonify({"open": registration_open()})
@bp.route("/register", methods=["POST"])
def register():
if not registration_open():
return jsonify({"error": "注册已关闭:本实例已有账号"}), 403
data = request.get_json(silent=True) or {}
email = (data.get("email") or "").strip()
garmin_email = (data.get("garminEmail") or "").strip()
garmin_password = data.get("garminPassword") or ""
if not email or not garmin_email or not garmin_password:
return jsonify({"error": "email, garminEmail, garminPassword 均为必填"}), 400
if query_one("SELECT id FROM users WHERE email = ?", [email]):
return jsonify({"error": "该邮箱已注册"}), 409
uid = str(uuid.uuid4())
token = sign_token(uid)
execute(
"INSERT INTO users (id, email, garmin_email, garmin_password_hash, jwt_token) "
"VALUES (?, ?, ?, ?, ?)",
[uid, email, garmin_email, hash_password(garmin_password), token],
)
return jsonify({"id": uid, "email": email, "token": token}), 201
@bp.route("/login", methods=["POST"])
def login():
data = request.get_json(silent=True) or {}
email = (data.get("email") or "").strip()
password = data.get("password") or ""
user = query_one("SELECT * FROM users WHERE email = ?", [email])
if not user or not verify_password(password, user["garmin_password_hash"]):
return jsonify({"error": "邮箱或密码错误"}), 401
token = sign_token(user["id"])
execute("UPDATE users SET jwt_token = ? WHERE id = ?", [token, user["id"]])
return jsonify({"id": user["id"], "email": user["email"], "token": token})
@bp.route("/logout", methods=["POST"])
@require_auth
def logout():
execute("UPDATE users SET jwt_token = NULL WHERE id = ?", [g.user_id])
return jsonify({"message": "ok"})
@bp.route("/refresh", methods=["POST"])
@require_auth
def refresh():
token = sign_token(g.user_id)
execute("UPDATE users SET jwt_token = ? WHERE id = ?", [token, g.user_id])
return jsonify({"token": token})

121
backend/routes/garmin.py Normal file
View File

@@ -0,0 +1,121 @@
"""Garmin routes: trigger a sync and read sync status."""
from flask import Blueprint, request, g, jsonify
from auth import require_auth
from db import query_one
from services import garmin as garmin_svc
from services import garmin_auth
bp = Blueprint("garmin", __name__)
@bp.route("/sync", methods=["POST"])
@require_auth
def sync():
data = request.get_json(silent=True) or {}
creds = {
"garminEmail": (data.get("garminEmail") or "").strip(),
"garminPassword": data.get("garminPassword") or "",
}
# Fall back to the stored Garmin email when only a password is supplied.
if not creds["garminEmail"]:
user = query_one("SELECT garmin_email FROM users WHERE id = ?", [g.user_id])
if user and user.get("garmin_email"):
creds["garminEmail"] = user["garmin_email"]
# With stored OAuth tokens no password is needed at all. Without them the
# plaintext password must come in the body, because only a hash is kept.
if not creds["garminPassword"] and not garmin_svc.has_token(g.user_id):
return (
jsonify({
"status": "error",
"recordsSynced": 0,
"message": "需要 Garmin 密码以执行同步,请在请求体中提供 garminPassword"
"(密码仅作哈希存储,无法还原)。",
}),
400,
)
days = request.get_json(silent=True).get("days") if request.is_json else None
try:
days = max(1, min(int(days), 730)) if days else None
except (TypeError, ValueError):
days = None
# Always run in the background: even a week takes ~20s, and a full
# backfill runs for many minutes. Progress is polled via /status.
result = garmin_svc.start_sync(g.user_id, creds, days)
return jsonify(result), 202
@bp.route("/auth-status", methods=["GET"])
@require_auth
def auth_status():
"""Whether a stored token exists, so the UI knows to ask for a password."""
return jsonify({"hasToken": garmin_svc.has_token(g.user_id)})
@bp.route("/login", methods=["POST"])
@require_auth
def login():
"""Begin an interactive Garmin login.
Returns immediately with a session id; the login continues in the
background and parks if Garmin asks for a two-factor code. Poll
/login-status and post the code to /mfa.
"""
data = request.get_json(silent=True) or {}
password = data.get("garminPassword") or ""
if not password:
return jsonify({"error": "请提供 Garmin 密码"}), 400
garmin_email = (data.get("garminEmail") or "").strip()
if not garmin_email:
user = query_one("SELECT garmin_email FROM users WHERE id = ?", [g.user_id])
garmin_email = (user or {}).get("garmin_email") or ""
if not garmin_email:
return jsonify({"error": "缺少 Garmin 邮箱"}), 400
session_id = garmin_auth.start_login(g.user_id, garmin_email, password)
return jsonify({"session": session_id, "status": "starting"}), 202
@bp.route("/login-status", methods=["GET"])
@require_auth
def login_status():
session_id = request.args.get("session") or ""
row = garmin_auth.get_session(session_id, g.user_id)
if not row:
return jsonify({"error": "登录会话不存在或已过期"}), 404
return jsonify({
"session": row["id"],
"status": row["status"],
"error": row["error"],
})
@bp.route("/mfa", methods=["POST"])
@require_auth
def submit_mfa():
data = request.get_json(silent=True) or {}
session_id = (data.get("session") or "").strip()
code = (data.get("code") or "").strip()
if not session_id or not code:
return jsonify({"error": "session 与 code 均为必填"}), 400
ok, message = garmin_auth.submit_code(session_id, g.user_id, code)
return jsonify({"ok": ok, "message": message}), (200 if ok else 400)
@bp.route("/login", methods=["DELETE"])
@require_auth
def cancel_login():
session_id = request.args.get("session") or ""
garmin_auth.cancel(session_id, g.user_id)
return jsonify({"ok": True})
@bp.route("/status", methods=["GET"])
@require_auth
def status():
return jsonify(garmin_svc.get_sync_status(g.user_id))

59
backend/routes/health.py Normal file
View File

@@ -0,0 +1,59 @@
"""Health data routes: summary / steps / heart-rate / sleep / activities."""
from flask import Blueprint, request, g, jsonify
from auth import require_auth
from services import health as health_svc
bp = Blueprint("health", __name__)
def _range():
return request.args.get("startDate"), request.args.get("endDate")
@bp.route("/summary", methods=["GET"])
@require_auth
def summary():
s, e = _range()
return jsonify(health_svc.get_summary(g.user_id, s, e))
@bp.route("/steps", methods=["GET"])
@require_auth
def steps():
s, e = _range()
return jsonify(health_svc.get_steps(g.user_id, s, e))
@bp.route("/heart-rate", methods=["GET"])
@require_auth
def heart_rate():
s, e = _range()
return jsonify(health_svc.get_heart_rate(g.user_id, s, e))
@bp.route("/sleep", methods=["GET"])
@require_auth
def sleep():
s, e = _range()
return jsonify(health_svc.get_sleep(g.user_id, s, e))
@bp.route("/activities", methods=["GET"])
@require_auth
def activities():
s, e = _range()
return jsonify(health_svc.get_activities(g.user_id, s, e))
@bp.route("/badges", methods=["GET"])
@require_auth
def badges():
"""Earned badges (奖励), most recent first."""
return jsonify(health_svc.get_badges(g.user_id))
@bp.route("/personal-records", methods=["GET"])
@require_auth
def personal_records():
return jsonify(health_svc.get_personal_records(g.user_id))

View File

@@ -0,0 +1,6 @@
"""Business logic services for Garmin Health Lab."""
from . import health
from . import analysis
from . import garmin
__all__ = ["health", "analysis", "garmin"]

551
backend/services/ai.py Normal file
View File

@@ -0,0 +1,551 @@
"""
Multi-provider LLM layer for health recommendations.
Design goals
------------
* **Switchable models** — every model lives in a catalog keyed by a short id
("gemini-flash", "llama-70b", ...). Callers pass an id; nothing else in the
codebase knows which vendor is behind it.
* **Large context** — daily metrics are serialised as compact CSV rather than
JSON, so a year of data costs a few thousand tokens instead of tens of
thousands. Each model declares its own window and the payload is trimmed to
fit the smallest of (model window, configured day budget).
* **Fallback** — if the preferred model errors or times out, the next healthy
model in the chain is tried before giving up. This mirrors the behaviour the
NAS deployment already relies on (Gemini primary, NVIDIA secondary).
Only text-in/text-out models are supported; no vision models are registered.
API keys are read from the environment — never hardcode them.
"""
import json
import os
import re
import requests
# Tunables are read per call rather than captured at import: module-level
# constants freeze whatever the environment held when the module first loaded,
# which both hides live config changes and leaks a developer's .env into tests.
FALLBACK_TIMEOUT = 60.0
FALLBACK_DAY_BUDGET = 365
def default_timeout():
return float(os.environ.get("AI_TIMEOUT_SECONDS") or FALLBACK_TIMEOUT)
def default_day_budget():
"""Max days of history to put in a prompt, before per-model trimming."""
return int(os.environ.get("AI_DAY_BUDGET") or FALLBACK_DAY_BUDGET)
# Output cap. Deliberately modest: a long generation is what blows past an
# upstream's own timeout (the self-hosted gateway allows its adapters only
# 30-45s), and the reply here is a short JSON list, not an essay.
FALLBACK_MAX_TOKENS = 1024
def default_max_tokens():
return int(os.environ.get("AI_MAX_TOKENS") or FALLBACK_MAX_TOKENS)
SYSTEM_PROMPT = (
"你是一名严谨的健康数据分析助手负责解读用户的可穿戴设备Garmin数据。\n"
"要求:\n"
"1. 只依据给出的数据得出结论,数据不足时明确说明,不要编造数值。\n"
"2. 指出趋势、异常和相互关联(例如睡眠不足与静息心率升高的关系)。\n"
"3. 给出具体、可执行的建议,而不是泛泛而谈。\n"
"4. 你不是医生,不做诊断;发现明显异常时建议用户咨询专业医师。\n"
"5. 用简体中文回答。\n\n"
"输出严格为 JSON 数组,最多 5 条,每条 recommendation 不超过 120 字,\n"
"每个元素形如:\n"
'{"category": "睡眠", "recommendation": "……", "priority": "high|medium|low", '
'"basedOn": ["sleep_duration"]}\n'
"不要输出 JSON 以外的任何文字,不要用 markdown 代码块包裹。"
)
class AIError(Exception):
"""Raised when a provider cannot produce a completion."""
class Completion:
"""A model reply plus, where the endpoint reports it, the upstream that
actually served the request.
The self-hosted gateway multiplexes over nvidia/gemini/ollama and names
the winner in its response, so `upstream` is what makes a gateway-side
failover visible to the UI instead of silently invisible.
"""
__slots__ = ("text", "upstream")
def __init__(self, text, upstream=None):
self.text = text
self.upstream = upstream
# --- providers --------------------------------------------------------------
class Provider:
"""Base class. Subclasses turn a prompt into text.
`use_proxy` decides whether HTTP(S)_PROXY / ALL_PROXY from the environment
apply. It matters because the two kinds of endpoint want opposite answers:
overseas vendors (Gemini, NVIDIA) may only be reachable *through* a local
proxy, while a self-hosted box on a public IP is reachable directly and
breaks if forced through one.
"""
name = "base"
def __init__(
self, model_id, context_window, api_key_env, use_proxy=True, max_tokens=None
):
self.model_id = model_id
self.context_window = context_window
self.api_key_env = api_key_env
self.use_proxy = use_proxy
self._max_tokens = max_tokens
@property
def max_tokens(self):
"""Output cap for this endpoint.
Reasoning models emit a chain-of-thought *before* the answer, so a cap
sized for the answer alone gets spent on the thinking and truncates
before any JSON appears. Those endpoints therefore declare a larger
budget than the default.
"""
return self._max_tokens or default_max_tokens()
@property
def api_key(self):
return os.environ.get(self.api_key_env) or ""
def is_configured(self):
return bool(self.api_key)
def _session(self):
session = requests.Session()
# trust_env=False also drops netrc/CA-bundle env lookups, which is the
# intent here: talk to the host directly, exactly as configured.
session.trust_env = self.use_proxy
return session
def generate(self, prompt, timeout=None):
raise NotImplementedError
class GeminiProvider(Provider):
"""Google AI Studio (generativelanguage.googleapis.com)."""
name = "gemini"
BASE = "https://generativelanguage.googleapis.com/v1beta/models"
def generate(self, prompt, timeout=None):
if not self.is_configured():
raise AIError(f"{self.api_key_env} 未配置")
timeout = timeout or default_timeout()
url = f"{self.BASE}/{self.model_id}:generateContent"
payload = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {
"temperature": 0.4,
"maxOutputTokens": self.max_tokens,
},
}
try:
resp = self._session().post(
url,
headers={
"Content-Type": "application/json",
"X-goog-api-key": self.api_key,
},
json=payload,
timeout=timeout,
)
except requests.RequestException as e:
raise AIError(f"gemini 请求失败: {e}") from e
if resp.status_code != 200:
raise AIError(f"gemini HTTP {resp.status_code}: {resp.text[:200]}")
try:
body = resp.json()
parts = body["candidates"][0]["content"]["parts"]
return Completion("".join(p.get("text", "") for p in parts))
except (ValueError, KeyError, IndexError) as e:
raise AIError(f"gemini 响应格式异常: {e}") from e
class OpenAICompatProvider(Provider):
"""Any endpoint speaking the OpenAI chat-completions schema (NVIDIA NIM,
Ollama, vLLM, ...).
`requires_key=False` covers self-hosted runtimes such as Ollama, which
authenticate by network reachability rather than by a token. Those are
opt-in: they count as configured only once their base URL is set, so an
unset OLLAMA_BASE_URL keeps the entry out of the fallback chain.
"""
name = "openai-compat"
def __init__(
self,
model_id,
context_window,
base_url_env,
default_base_url="",
api_key_env=None,
requires_key=True,
use_proxy=True,
max_tokens=None,
):
super().__init__(
model_id, context_window, api_key_env or "", use_proxy, max_tokens
)
self.base_url_env = base_url_env
self.default_base_url = default_base_url
self.requires_key = requires_key
@property
def base_url(self):
return os.environ.get(self.base_url_env) or self.default_base_url
def is_configured(self):
if not self.base_url:
return False
return bool(self.api_key) if self.requires_key else True
def generate(self, prompt, timeout=None):
if not self.is_configured():
raise AIError(
f"{self.api_key_env} 未配置" if self.requires_key
else f"{self.base_url_env} 未配置"
)
timeout = timeout or default_timeout()
url = f"{self.base_url.rstrip('/')}/chat/completions"
payload = {
"model": self.model_id,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.4,
"max_tokens": self.max_tokens,
}
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
try:
resp = self._session().post(
url, headers=headers, json=payload, timeout=timeout
)
except requests.RequestException as e:
raise AIError(f"{self.model_id} 请求失败: {e}") from e
if resp.status_code != 200:
raise AIError(f"{self.model_id} HTTP {resp.status_code}: {resp.text[:200]}")
try:
body = resp.json()
# `provider` is a gateway extension, absent from stock OpenAI
# responses — hence the .get rather than an index.
return Completion(
body["choices"][0]["message"]["content"], body.get("provider")
)
except (ValueError, KeyError, IndexError) as e:
raise AIError(f"{self.model_id} 响应格式异常: {e}") from e
# --- catalog ----------------------------------------------------------------
NVIDIA_BASE = "https://integrate.api.nvidia.com/v1"
def _nvidia(model_id, context_window):
return OpenAICompatProvider(
model_id=model_id,
context_window=context_window,
api_key_env="NVIDIA_API_KEY",
base_url_env="NVIDIA_BASE_URL",
default_base_url=NVIDIA_BASE,
)
def _build_catalog():
"""Model id -> Provider. Text-only models with large context windows.
The NVIDIA model strings below were taken from that account's live
`GET /v1/models` listing. Do not guess them: ids that merely look
plausible (`qwen/qwen2.5-72b-instruct`, `deepseek-ai/deepseek-r1`)
return HTTP 404 from this endpoint.
"""
return {
# Preferred entry: the self-hosted gateway on the Oracle box. It
# multiplexes over nvidia/gemini/ollama behind one OpenAI-compatible
# endpoint and rotates several Gemini keys, so it absorbs the quota
# and timeout failures that a single upstream hits on its own. Its
# reply names the upstream that served the request.
"gateway": OpenAICompatProvider(
model_id=os.environ.get("AI_GATEWAY_MODEL") or "ai-gateway-auto",
context_window=128_000,
api_key_env="AI_GATEWAY_TOKEN",
base_url_env="AI_GATEWAY_BASE_URL",
# Self-hosted and directly reachable: a local proxy would only
# add a hop that times out.
use_proxy=False,
# Its primary upstream is a reasoning model that thinks out loud
# before answering; at the default cap the trace consumed the whole
# budget and the reply was truncated before the JSON began.
max_tokens=3000,
),
# Direct upstreams, for pinning one vendor or for running without the
# gateway. These need their own keys in this app's .env.
"gemini-flash": GeminiProvider(
model_id="gemini-flash-latest",
context_window=1_000_000,
api_key_env="GEMINI_API_KEY",
),
"llama-70b": _nvidia("meta/llama-3.3-70b-instruct", 128_000),
"nemotron-49b": _nvidia("nvidia/llama-3.3-nemotron-super-49b-v1.5", 128_000),
"mistral-large": _nvidia("mistralai/mistral-large-2-instruct", 128_000),
}
CATALOG = _build_catalog()
# Preference order used when no model is requested, and for fallback.
FALLBACK_CHAIN = "gateway,gemini-flash,llama-70b"
def default_chain():
"""Preference order, read from the environment on every call.
Deliberately not a module-level constant: it is read at request time so a
changed AI_MODEL_CHAIN takes effect without a restart, and so tests can
set it without reaching into module internals.
"""
raw = os.environ.get("AI_MODEL_CHAIN") or FALLBACK_CHAIN
return [m.strip() for m in raw.split(",") if m.strip()]
def list_models():
"""Catalog entries plus whether each one currently has credentials."""
chain = default_chain()
head = chain[0] if chain else None
return [
{
"id": mid,
"model": p.model_id,
"provider": p.name,
"contextWindow": p.context_window,
"configured": p.is_configured(),
"default": mid == head,
}
for mid, p in CATALOG.items()
]
def resolve_chain(preferred=None):
"""Ordered list of model ids to attempt, configured ones only."""
chain = []
if preferred:
if preferred not in CATALOG:
raise AIError(f"未知模型: {preferred}")
chain.append(preferred)
for mid in default_chain():
if mid in CATALOG and mid not in chain:
chain.append(mid)
configured = [m for m in chain if CATALOG[m].is_configured()]
if not configured:
raise AIError(
"没有可用的模型:请在 backend/.env 中配置 GEMINI_API_KEY 或 NVIDIA_API_KEY"
)
return configured
# --- prompt construction ----------------------------------------------------
# Kept deliberately short: every extra column multiplies by the number of
# days sent, and the column names double as the vocabulary the model cites
# back in `basedOn`.
_CSV_COLUMNS = [
("date", "date"),
("steps", "steps"),
("distanceMeters", "dist_m"),
("heartRate", "rest_hr"),
("heartRateMax", "max_hr"),
("heartRateVariability", "hrv"),
("stress", "stress"),
("stressMax", "stress_max"),
("bodyBatteryHigh", "bb_high"),
("bodyBatteryLow", "bb_low"),
("spo2Avg", "spo2"),
("respirationAvg", "resp"),
("intensityMinutes", "intensity_min"),
("caloriesBurned", "kcal"),
("activeCalories", "active_kcal"),
("floorsAscended", "floors"),
("trainingReadiness", "readiness"),
("enduranceScore", "endurance"),
]
def build_prompt(summary, activities=None, day_budget=None):
"""Render health history as a compact CSV prompt.
CSV rather than JSON: roughly 4x fewer tokens for the same numbers, which
is what makes a full year of history practical to send.
"""
day_budget = day_budget if day_budget is not None else default_day_budget()
rows = summary[-day_budget:] if day_budget else summary
header = (
",".join(label for _, label in _CSV_COLUMNS)
+ ",sleep_h,sleep_q,sleep_deep_s,sleep_rem_s,sleep_awake_s"
)
lines = [header]
for r in rows:
cells = []
for key, _ in _CSV_COLUMNS:
value = r.get(key)
cells.append("" if value is None else str(value))
sleep = r.get("sleep") or {}
for key in ("duration", "quality", "deepSeconds", "remSeconds", "awakeSeconds"):
value = sleep.get(key)
cells.append("" if value is None else str(value))
lines.append(",".join(cells))
sections = [
SYSTEM_PROMPT,
f"\n## 每日健康数据(共 {len(rows)}CSV\n" + "\n".join(lines),
]
if activities:
act_lines = ["type,start,duration_s,distance_km,kcal,avg_hr,max_hr"]
for a in activities[:200]:
act_lines.append(
",".join(
str(a.get(k) if a.get(k) is not None else "")
for k in (
"activity_type", "start_time", "duration",
"distance", "calories", "heart_rate_average",
"heart_rate_max",
)
)
)
sections.append(
f"\n## 运动记录(共 {min(len(activities), 200)}CSV\n"
+ "\n".join(act_lines)
)
return "\n".join(sections)
# --- response parsing -------------------------------------------------------
_VALID_PRIORITIES = {"high", "medium", "low"}
_FENCE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)
def parse_recommendations(text):
"""Coerce a model reply into the same shape the rule engine returns.
Models routinely wrap JSON in markdown fences or add a sentence before it,
despite instructions, so both are tolerated here.
"""
if not text or not text.strip():
raise AIError("模型返回空响应")
cleaned = _FENCE.sub("", text).strip()
try:
data = json.loads(cleaned)
except ValueError:
start, end = cleaned.find("["), cleaned.rfind("]")
if start == -1 or end <= start:
raise AIError(f"模型未返回 JSON 数组: {text[:200]}")
try:
data = json.loads(cleaned[start : end + 1])
except ValueError as e:
raise AIError(f"模型返回的 JSON 无法解析: {e}") from e
if isinstance(data, dict):
data = [data]
if not isinstance(data, list):
raise AIError("模型返回的不是 JSON 数组")
recs = []
for i, item in enumerate(data):
if not isinstance(item, dict):
continue
text_value = (item.get("recommendation") or "").strip()
if not text_value:
continue
priority = str(item.get("priority", "medium")).lower()
if priority not in _VALID_PRIORITIES:
priority = "medium"
based_on = item.get("basedOn")
if not isinstance(based_on, list):
based_on = []
recs.append(
{
"id": f"ai-{i}",
"category": (item.get("category") or "综合").strip(),
"recommendation": text_value,
"priority": priority,
"basedOn": [str(b) for b in based_on],
"source": "ai",
}
)
if not recs:
raise AIError("模型未返回任何有效建议")
order = {"high": 0, "medium": 1, "low": 2}
recs.sort(key=lambda r: order[r["priority"]])
return recs
# --- entry point ------------------------------------------------------------
# One CSV day is ~40 characters ≈ 10 tokens. Half the window is left for the
# system prompt, the activity table and the model's own answer.
_TOKENS_PER_DAY = 10
_WINDOW_UTILISATION = 0.5
def max_days_for(provider, day_budget=None):
"""How many days of history fit in this model's context window.
Models in the chain have windows that differ by more than an order of
magnitude (32k for a local Ollama vs 1M for Gemini), so the payload has to
be sized per model — a prompt that fits Gemini would overflow Ollama.
"""
day_budget = day_budget if day_budget is not None else default_day_budget()
fits = int(provider.context_window * _WINDOW_UTILISATION / _TOKENS_PER_DAY)
return max(1, min(day_budget, fits)) if day_budget else max(1, fits)
def generate(summary, activities=None, preferred_model=None, day_budget=None):
"""Ask the first healthy model in the chain for recommendations.
Returns (recommendations, meta). `meta` records which model answered, how
much history it actually saw, and every model that failed on the way —
the failures are kept even on success so a silent degradation to a weaker
model is still visible.
"""
chain = resolve_chain(preferred_model)
errors = []
for model_id in chain:
provider = CATALOG[model_id]
days = max_days_for(provider, day_budget)
prompt = build_prompt(summary, activities, days)
try:
completion = provider.generate(prompt)
recs = parse_recommendations(completion.text)
return recs, {
"model": model_id,
"provider": provider.name,
"upstream": completion.upstream,
"days": min(len(summary), days),
"fallbackFrom": [e["model"] for e in errors],
"errors": errors,
}
except AIError as e:
errors.append({"model": model_id, "error": str(e)})
detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors)
raise AIError(f"所有模型均失败 -> {detail}")

View File

@@ -0,0 +1,258 @@
"""
Analysis service: metric trends + a rule-based recommendation engine.
Replicates the original Node AnalysisService logic. Averages are computed over
the most recent 14 days of available daily summaries.
"""
import datetime
import hashlib
import json
import os
from services import health
from services import ai as ai_svc
from db import query_all, query_one, execute
from config import DB_TYPE
METRIC_COLUMNS = {
"steps": "steps",
"heart_rate": "heart_rate",
"sleep_duration": "sleep_duration",
"sleep_quality": "sleep_quality",
"stress": "stress",
"calories_burned": "calories_burned",
}
def get_trends(metric, user_id, start=None, end=None):
column = METRIC_COLUMNS.get(metric, "steps")
params = [user_id]
sql = "WHERE user_id = ?"
if start:
sql += " AND date >= ?"
params.append(start)
if end:
sql += " AND date <= ?"
params.append(end)
rows = query_all(
f"SELECT date, {column} AS value FROM health_data {sql} "
f"AND {column} IS NOT NULL ORDER BY date ASC",
params,
)
return [{"date": r["date"], "value": r["value"]} for r in rows]
def get_recommendations(user_id):
recent = health.get_summary(user_id)
last14 = recent[-14:]
recs = []
if not last14:
return [
{
"id": "no-data",
"category": "数据",
"recommendation": "暂无健康数据,请先同步你的 Garmin 设备数据。",
"priority": "low",
"basedOn": [],
}
]
avg = lambda key: sum((r.get(key) or 0) for r in last14) / len(last14)
avg_steps = avg("steps")
sleep_rows = [r["sleep"]["duration"] for r in last14 if r.get("sleep")]
avg_sleep = sum(sleep_rows) / len(sleep_rows) if sleep_rows else 0
avg_stress = avg("stress")
avg_rhr = avg("heartRate")
avg_hrv = avg("heartRateVariability")
if avg_steps > 0 and avg_steps < 8000:
recs.append({
"id": "steps",
"category": "运动",
"recommendation": f"{len(last14)} 天日均步数约 {round(avg_steps)} 步,低于 8000 步目标,建议每天增加 20 分钟快走。",
"priority": "medium",
"basedOn": ["steps"],
})
if avg_sleep > 0 and avg_sleep < 7:
recs.append({
"id": "sleep",
"category": "睡眠",
"recommendation": f"日均睡眠约 {avg_sleep:.1f} 小时,偏少。建议固定就寝时间,目标 7-8 小时。",
"priority": "high",
"basedOn": ["sleep_duration"],
})
if avg_stress > 0 and avg_stress > 50:
recs.append({
"id": "stress",
"category": "压力",
"recommendation": f"平均压力指数 {round(avg_stress)} 偏高,建议安排放松活动(冥想/散步)。",
"priority": "high",
"basedOn": ["stress"],
})
if avg_rhr > 0 and avg_rhr > 65:
recs.append({
"id": "rhr",
"category": "心肺",
"recommendation": f"静息心率约 {round(avg_rhr)} bpm 偏高,规律有氧运动有助于改善心肺功能。",
"priority": "medium",
"basedOn": ["heart_rate"],
})
if avg_hrv > 0 and avg_hrv < 40:
recs.append({
"id": "hrv",
"category": "恢复",
"recommendation": f"心率变异性HRV{round(avg_hrv)} ms 偏低,注意恢复与休息,避免过度训练。",
"priority": "low",
"basedOn": ["heart_rate_variability"],
})
if not recs:
recs.append({
"id": "good",
"category": "状态",
"recommendation": "近期各项指标良好,保持当前作息与运动习惯即可。",
"priority": "low",
"basedOn": [],
})
order = {"high": 0, "medium": 1, "low": 2}
recs.sort(key=lambda r: order[r["priority"]])
return recs
CACHE_TTL_HOURS = int(os.environ.get("AI_CACHE_TTL_HOURS") or 24)
def _fingerprint(summary, activities):
"""Identify the data a cached answer was derived from.
Cheap and order-independent: the day count, the newest and oldest dates,
and every metric value. Any sync that adds or corrects a value changes the
digest, which is what expires the cache.
"""
parts = [str(len(summary)), str(len(activities))]
for row in summary:
parts.append(
"|".join(
str(row.get(k))
for k in ("date", "steps", "heartRate", "heartRateVariability",
"stress", "caloriesBurned")
)
)
sleep = row.get("sleep") or {}
parts.append(f"{sleep.get('duration')}/{sleep.get('quality')}")
return hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()[:64]
def _read_cache(user_id, fingerprint):
row = query_one(
"SELECT * FROM ai_recommendations WHERE user_id = ?", [user_id]
)
if not row or row["fingerprint"] != fingerprint:
return None
created = row.get("created_at")
if created:
try:
ts = datetime.datetime.fromisoformat(str(created).replace(" ", "T"))
age = datetime.datetime.utcnow() - ts
if age > datetime.timedelta(hours=CACHE_TTL_HOURS):
return None
except ValueError:
# An unparseable timestamp should not permanently poison the cache.
return None
try:
recs = json.loads(row["payload"])
except (ValueError, TypeError):
return None
return {
"recommendations": recs,
"meta": {
"source": "ai",
"model": row["model"],
"upstream": row["upstream"],
"days": row["days"],
"cached": True,
"generatedAt": created,
},
}
def _write_cache(user_id, fingerprint, recs, meta):
cols = ["user_id", "fingerprint", "model", "upstream", "days", "payload",
"created_at"]
placeholders = ", ".join(["?"] * len(cols))
if DB_TYPE == "mariadb":
updates = ", ".join(f"{c}=VALUES({c})" for c in cols if c != "user_id")
sql = (
f"INSERT INTO ai_recommendations ({', '.join(cols)}) "
f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}"
)
else:
updates = ", ".join(f"{c}=excluded.{c}" for c in cols if c != "user_id")
sql = (
f"INSERT INTO ai_recommendations ({', '.join(cols)}) "
f"VALUES ({placeholders}) ON CONFLICT(user_id) DO UPDATE SET {updates}"
)
execute(sql, [
user_id, fingerprint, meta.get("model"), meta.get("upstream"),
meta.get("days"), json.dumps(recs, ensure_ascii=False),
datetime.datetime.utcnow().isoformat(timespec="seconds"),
])
def get_ai_recommendations(user_id, model=None, days=None, refresh=False):
"""LLM recommendations over the user's history, cached.
A generation costs minutes against a large reasoning model, so a stored
answer is reused until the health data changes (or the TTL lapses).
`refresh=True` and an explicit `model` both bypass the cache — asking for
a specific model means wanting that model's answer, not a stored one.
Falls back to the rule engine when every model fails, so the endpoint
always returns something useful; `meta.source` tells the two apart.
"""
summary = health.get_summary(user_id)
if not summary:
return {
"recommendations": get_recommendations(user_id),
"meta": {"model": None, "source": "rules", "reason": "无健康数据"},
}
activities = health.get_activities(user_id)
fingerprint = _fingerprint(summary, activities)
if not refresh and not model:
cached = _read_cache(user_id, fingerprint)
if cached:
return cached
budget = days or ai_svc.default_day_budget()
try:
recs, meta = ai_svc.generate(
summary, activities, preferred_model=model, day_budget=budget
)
except ai_svc.AIError as e:
return {
"recommendations": get_recommendations(user_id),
"meta": {"model": None, "source": "rules", "reason": str(e)},
}
try:
_write_cache(user_id, fingerprint, recs, meta)
except Exception as e: # noqa: BLE001 - a cache write must never fail the request
print(f"[analysis] failed to cache recommendations: {e}")
return {"recommendations": recs, "meta": {**meta, "source": "ai", "cached": False}}
def clear_ai_cache(user_id):
execute("DELETE FROM ai_recommendations WHERE user_id = ?", [user_id])

543
backend/services/garmin.py Normal file
View File

@@ -0,0 +1,543 @@
"""
Garmin sync service.
Pulls daily summaries + activities through the `garminconnect` library and
upserts them. The library and real Garmin credentials are required to actually
run a sync; without them the endpoint reports a clear error instead of
crashing.
Garmin credentials: the app only stores a scrypt/PBKDF2 *hash* of the Garmin
password (so it cannot be recovered), therefore a live sync needs the
plaintext garminEmail/garminPassword supplied in the request body.
On the library's API — these were verified against garminconnect 0.2.8:
* get_user_summary(cdate) -> one day of daily totals
* get_sleep_data(cdate) -> sleep, NOT part of the summary
* get_hrv_data(cdate) -> HRV, also separate
* get_activities_by_date(start, end) -> activities in a date range
* get_activities(start, limit) -> PAGINATION, not dates
The last two are easy to confuse: `get_activities` takes an offset and a count,
so passing it a date silently asks for activity number "2026-08-23".
"""
import datetime
import os
import threading
from db import execute, query_one
from config import DB_TYPE
from services import health
# How many days back a sync reaches.
DEFAULT_SYNC_DAYS = int(os.environ.get("GARMIN_SYNC_DAYS") or 7)
def _set_sync_status(user_id, status, now, **fields):
cols = ["user_id", "status", "last_sync_time"] + list(fields.keys())
placeholders = ", ".join(["?"] * len(cols))
if DB_TYPE == "mariadb":
updates = ", ".join(f"{c}=VALUES({c})" for c in cols if c != "user_id")
sql = (
f"INSERT INTO sync_status ({', '.join(cols)}) VALUES ({placeholders}) "
f"ON DUPLICATE KEY UPDATE {updates}"
)
else:
updates = ", ".join(f"{c}=excluded.{c}" for c in cols if c != "user_id")
sql = (
f"INSERT INTO sync_status ({', '.join(cols)}) VALUES ({placeholders}) "
f"ON CONFLICT(user_id) DO UPDATE SET {updates}"
)
execute(sql, [user_id, status, now] + list(fields.values()))
def get_sync_status(user_id):
row = query_one("SELECT * FROM sync_status WHERE user_id = ?", [user_id])
if not row:
return {
"status": "idle",
"lastSyncTime": None,
"recordsSynced": 0,
"lastError": None,
}
return {
"status": row["status"],
"lastSyncTime": row["last_sync_time"],
"recordsSynced": row["records_synced"],
"lastError": row["last_error"],
"progressCurrent": row.get("progress_current"),
"progressTotal": row.get("progress_total"),
"startedAt": row.get("started_at"),
}
class MFARequired(RuntimeError):
"""Raised when a password login needs a code this process cannot obtain."""
# garth sends a browser User-Agent, which its SSO flow needs. The data API
# treats that same UA as a browser hitting it directly and answers every
# request with HTTP 200 and an empty array — no error, just no data. The
# official app's UA (and in fact any non-browser one) returns real data, so
# the header is swapped after login, before any API call.
API_USER_AGENT = "com.garmin.android.apps.connectmobile"
def _use_api_user_agent(client):
try:
client.garth.sess.headers["User-Agent"] = API_USER_AGENT
except AttributeError:
pass # a stubbed client in tests has no session
def _is_cn():
# Selects Garmin's China service, a separate backend with separate
# accounts. This project tracks an international account.
return (os.environ.get("GARMIN_IS_CN") or "").lower() in ("1", "true", "yes")
def _import_garmin():
try:
from garminconnect import Garmin
except ImportError:
raise RuntimeError(
"GARMIN_LIB_MISSING: 请先运行 `pip install garminconnect` 以启用同步"
)
return Garmin
def load_token(user_id):
row = query_one("SELECT token FROM garmin_tokens WHERE user_id = ?", [user_id])
return row["token"] if row else None
def save_token(user_id, token, garmin_email=None):
cols = ["user_id", "token", "garmin_email", "updated_at"]
placeholders = ", ".join(["?"] * len(cols))
if DB_TYPE == "mariadb":
updates = ", ".join(f"{c}=VALUES({c})" for c in cols if c != "user_id")
sql = (f"INSERT INTO garmin_tokens ({', '.join(cols)}) VALUES ({placeholders}) "
f"ON DUPLICATE KEY UPDATE {updates}")
else:
updates = ", ".join(f"{c}=excluded.{c}" for c in cols if c != "user_id")
sql = (f"INSERT INTO garmin_tokens ({', '.join(cols)}) VALUES ({placeholders}) "
f"ON CONFLICT(user_id) DO UPDATE SET {updates}")
execute(sql, [user_id, token, garmin_email,
datetime.datetime.utcnow().isoformat(timespec="seconds")])
def has_token(user_id):
return load_token(user_id) is not None
def _connect(creds, user_id=None):
"""Obtain a logged-in Garmin client.
Prefers stored OAuth tokens: an account with two-factor auth cannot be
logged into from a web worker, because the library asks for the code on
stdin and there is none (the failure surfaces as
"EOFError: EOF when reading a line"). Tokens are minted once by
`garmin_login.py`, which runs in a terminal where a code can be typed.
"""
Garmin = _import_garmin()
client = Garmin(is_cn=_is_cn())
token = load_token(user_id) if user_id else None
if token:
client.garth.loads(token)
_use_api_user_agent(client)
# Proves the token still works, and refreshes it if near expiry.
client.garth.refresh_oauth2()
# garminconnect builds most of its URLs from display_name, so leaving
# it unset sends every request to ".../None".
client.display_name = client.garth.profile["displayName"]
return client
if not creds.get("garminPassword"):
raise RuntimeError("缺少 Garmin 密码,且未找到已保存的登录令牌")
client.username = creds["garminEmail"]
client.password = creds["garminPassword"]
try:
client.login()
except EOFError as e:
# garth's default MFA prompt calls input(); under gunicorn stdin is
# closed, so it raises EOFError rather than anything descriptive.
raise MFARequired(
"该 Garmin 账号开启了两步验证。请在「数据同步」页面用密码重新绑定,"
"系统会提示你输入验证码。"
) from e
_use_api_user_agent(client)
return client
def describe(e):
"""A message that is never empty.
Some exceptions carry no text at all — a bare `assert` raises
AssertionError with str(e) == "" — and storing that produced a failed
sync whose recorded reason was blank, which is undiagnosable.
"""
text = str(e).strip()
return f"{type(e).__name__}: {text}" if text else type(e).__name__
def _num(*values):
"""First value that is a usable number."""
for v in values:
if isinstance(v, (int, float)) and not isinstance(v, bool):
return v
return None
def _safe(fn, default=None):
"""Call an optional endpoint; a metric the device does not record must not
abort the whole day."""
try:
return fn()
except Exception:
return default
def _first(seq):
return seq[0] if isinstance(seq, list) and seq else {}
def _to_datetime(*values):
"""Normalise Garmin's several timestamp shapes into an ISO string.
The same payload mixes ISO strings ("2019-10-13T10:10:12.0") with epoch
milliseconds (1570961412000); handing the latter to a DATETIME column is
rejected outright, so a personal record whose only timestamp was numeric
failed the whole batch.
"""
for v in values:
if v is None or v == "":
continue
if isinstance(v, str):
return v[:26]
if isinstance(v, (int, float)) and not isinstance(v, bool):
# Values past ~1e11 are milliseconds, below that seconds.
seconds = v / 1000 if v > 1e11 else v
try:
return datetime.datetime.utcfromtimestamp(seconds).isoformat(
timespec="seconds"
)
except (ValueError, OverflowError, OSError):
continue
return None
def _extract_daily(client, date_str):
"""Everything Garmin exposes for one day.
The daily summary is the bulk of it, but sleep, HRV, training readiness
and endurance each live behind their own endpoint — none of them appear in
get_user_summary. Each is fetched defensively so a metric this device does
not record leaves a NULL instead of failing the day.
"""
s = client.get_user_summary(date_str) or {}
sleep_dto = (_safe(lambda: client.get_sleep_data(date_str)) or {}).get(
"dailySleepDTO"
) or {}
scores = sleep_dto.get("sleepScores") if isinstance(
sleep_dto.get("sleepScores"), dict
) else {}
sleep_seconds = _num(sleep_dto.get("sleepTimeSeconds"))
hrv_summary = (_safe(lambda: client.get_hrv_data(date_str)) or {}).get(
"hrvSummary"
) or {}
readiness = _first(_safe(lambda: client.get_training_readiness(date_str), []))
training = _safe(lambda: client.get_training_status(date_str), {}) or {}
vo2 = (training.get("mostRecentVO2Max") or {}).get("generic") or {}
endurance = _safe(lambda: client.get_endurance_score(date_str), {}) or {}
def secs(key):
return _num(sleep_dto.get(key))
return {
"date": date_str,
# --- activity / energy ---
"steps": _num(s.get("totalSteps")),
"stepGoal": _num(s.get("dailyStepGoal")),
"distanceMeters": _num(s.get("totalDistanceMeters")),
"caloriesBurned": _num(s.get("totalKilocalories")),
"activeCalories": _num(s.get("activeKilocalories")),
"bmrCalories": _num(s.get("bmrKilocalories")),
"floorsAscended": _num(s.get("floorsAscended")),
"floorsDescended": _num(s.get("floorsDescended")),
"intensityMinutes": (
(_num(s.get("moderateIntensityMinutes")) or 0)
+ (_num(s.get("vigorousIntensityMinutes")) or 0)
) or None,
"sedentarySeconds": _num(s.get("sedentarySeconds")),
"activeSeconds": _num(s.get("activeSeconds")),
# --- heart / stress ---
"heartRate": _num(s.get("restingHeartRate"), s.get("averageHeartRate")),
"heartRateMax": _num(s.get("maxHeartRate")),
"heartRateMin": _num(s.get("minHeartRate")),
"heartRateVariability": _num(
hrv_summary.get("lastNightAvg"), hrv_summary.get("weeklyAvg")
),
"stress": _num(s.get("averageStressLevel")),
"stressMax": _num(s.get("maxStressLevel")),
# --- body battery ---
"bodyBatteryHigh": _num(s.get("bodyBatteryHighestValue")),
"bodyBatteryLow": _num(s.get("bodyBatteryLowestValue")),
"bodyBatteryCharged": _num(s.get("bodyBatteryChargedValue")),
"bodyBatteryDrained": _num(s.get("bodyBatteryDrainedValue")),
# --- breathing / blood oxygen ---
"spo2Avg": _num(s.get("averageSpo2")),
"spo2Min": _num(s.get("lowestSpo2")),
"respirationAvg": _num(
s.get("avgWakingRespirationValue"), s.get("latestRespirationValue")
),
"respirationMin": _num(s.get("lowestRespirationValue")),
"respirationMax": _num(s.get("highestRespirationValue")),
# --- sleep ---
"sleepDuration": round(sleep_seconds / 3600, 1) if sleep_seconds else None,
"sleepQuality": _num((scores.get("overall") or {}).get("value")),
"sleepDeepSeconds": secs("deepSleepSeconds"),
"sleepLightSeconds": secs("lightSleepSeconds"),
"sleepRemSeconds": secs("remSleepSeconds"),
"sleepAwakeSeconds": secs("awakeSleepSeconds"),
"sleepSpo2Avg": secs("averageSpO2Value"),
"sleepRespirationAvg": secs("averageRespirationValue"),
"sleepStressAvg": secs("avgSleepStress"),
# --- training ---
"trainingReadiness": _num(readiness.get("score")),
"vo2max": _num(vo2.get("vo2MaxValue")),
"enduranceScore": _num(endurance.get("overallScore")),
}
def sync_badges(client, user_id):
"""Earned badges. Keyed by Garmin's badge id, so re-syncing updates."""
badges = _safe(lambda: client.get_earned_badges(), []) or []
stored = 0
for b in badges:
bid = b.get("badgeId")
if bid is None:
continue
health.upsert_badge(user_id, {
"id": str(bid),
"badgeKey": b.get("badgeKey"),
"name": b.get("badgeName"),
"categoryId": _num(b.get("badgeCategoryId")),
"difficultyId": _num(b.get("badgeDifficultyId")),
"earnedDate": _to_datetime(b.get("badgeEarnedDate")),
"earnedCount": _num(b.get("badgeEarnedNumber")),
"points": _num(b.get("badgePoints")),
})
stored += 1
return stored
def sync_personal_records(client, user_id):
records = _safe(lambda: client.get_personal_record(), []) or []
stored = 0
for r in records:
rid = r.get("id")
if rid is None:
continue
health.upsert_personal_record(user_id, {
"id": str(rid),
"typeId": _num(r.get("typeId")),
"activityId": r.get("activityId"),
"activityName": r.get("activityName"),
"activityType": r.get("activityType"),
"value": _num(r.get("value")),
# Prefer the pre-formatted strings; the bare fields are epoch ms.
"achievedAt": _to_datetime(
r.get("prStartTimeLocalFormatted"),
r.get("prStartTimeGmtFormatted"),
r.get("activityStartDateTimeLocalFormatted"),
r.get("prStartTimeLocal"),
r.get("prStartTimeGmt"),
),
})
stored += 1
return stored
def _activity_end(start, duration_seconds):
if not start or not duration_seconds:
return start
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f"):
try:
dt = datetime.datetime.strptime(start[:26], fmt)
return (dt + datetime.timedelta(seconds=duration_seconds)).isoformat()
except ValueError:
continue
return start
def _sync_activities(client, user_id, start_date, end_date):
"""Fetch the window's activities in one call and store the new ones."""
activities = client.get_activities_by_date(start_date, end_date) or []
stored = 0
for a in activities:
start = a.get("startTimeLocal") or a.get("startTime")
activity_type = (
(a.get("activityType") or {}).get("typeKey")
if isinstance(a.get("activityType"), dict)
else a.get("activityType")
) or "unknown"
duration = _num(a.get("duration"))
# Garmin activity ids are stable, so re-syncing a window must not
# duplicate what is already stored.
garmin_id = a.get("activityId")
if garmin_id is not None:
existing = query_one(
"SELECT id FROM activities WHERE user_id = ? AND id = ?",
[user_id, str(garmin_id)],
)
if existing:
continue
health.insert_activity(
user_id,
{
"id": str(garmin_id) if garmin_id is not None else None,
"activityType": activity_type,
"startTime": start,
"endTime": _activity_end(start, duration),
"duration": duration,
"distance": _num(a.get("distance")),
"calories": _num(a.get("calories")),
"heartRateAverage": _num(a.get("averageHR")),
"heartRateMax": _num(a.get("maxHR")),
},
)
stored += 1
return stored
# Above this many days a sync is long enough that the caller must not block
# on it — a year takes roughly 20 minutes at ~3s per day.
BACKGROUND_THRESHOLD_DAYS = 14
def start_sync(user_id, creds, days=None):
"""Run a sync in the background and return immediately.
Progress lands in sync_status, which the UI polls; a full backfill runs
far longer than any sensible HTTP timeout.
"""
days = days or DEFAULT_SYNC_DAYS
now = datetime.datetime.utcnow().isoformat(timespec="seconds")
_set_sync_status(
user_id, "syncing", now,
records_synced=0, progress_current=0, progress_total=days,
started_at=now, last_error=None,
)
thread = threading.Thread(
target=sync_data, args=(user_id, creds, days), daemon=True
)
thread.start()
return {"status": "syncing", "days": days}
def sync_data(user_id, creds, days=None, client=None):
"""Pull the last `days` days from Garmin Connect into the local database.
`client` exists so tests can inject a stub instead of reaching Garmin.
"""
days = days or DEFAULT_SYNC_DAYS
now = datetime.datetime.utcnow().isoformat(timespec="seconds")
_set_sync_status(
user_id, "syncing", now,
records_synced=0, progress_current=0, progress_total=days,
)
try:
client = client or _connect(creds, user_id)
except Exception as e:
message = describe(e)
_set_sync_status(user_id, "error", now, records_synced=0, last_error=message)
return {
"status": "error",
"recordsSynced": 0,
"message": message,
"mfaRequired": isinstance(e, MFARequired),
"lastSyncTime": now,
}
today = datetime.date.today()
start_date = (today - datetime.timedelta(days=days - 1)).isoformat()
days_synced = 0
day_errors = []
for i in range(days):
date_str = (today - datetime.timedelta(days=i)).isoformat()
try:
record = _extract_daily(client, date_str)
except Exception as e:
day_errors.append(f"{date_str}: {describe(e)}")
continue
# A day Garmin has no data for comes back all-None; storing it would
# create an empty row that the metric endpoints then have to filter.
if any(record[k] is not None for k in record if k != "date"):
health.upsert_health_daily(user_id, record)
days_synced += 1
# Reported every few days rather than every day: the write is cheap
# but not free, and the UI polls on a 2s cadence anyway.
if (i + 1) % 5 == 0 or i + 1 == days:
_set_sync_status(
user_id, "syncing", now,
records_synced=days_synced, progress_current=i + 1,
progress_total=days,
)
activities_synced = 0
try:
activities_synced = _sync_activities(
client, user_id, start_date, today.isoformat()
)
except Exception as e:
day_errors.append(f"activities: {describe(e)}")
# Badges and personal records are account-wide rather than per-day, so
# they are fetched once per sync rather than inside the day loop.
badges_synced = 0
records_synced_pr = 0
try:
badges_synced = sync_badges(client, user_id)
except Exception as e:
day_errors.append(f"badges: {describe(e)}")
try:
records_synced_pr = sync_personal_records(client, user_id)
except Exception as e:
day_errors.append(f"personal_records: {describe(e)}")
# Every single day failing means something systemic (expired session,
# API change) — reporting that as a clean success would hide it.
if days_synced == 0 and len(day_errors) >= days:
message = "; ".join(day_errors[:3])
_set_sync_status(user_id, "error", now, records_synced=0, last_error=message)
return {"status": "error", "recordsSynced": 0,
"message": f"同步失败:{message}", "lastSyncTime": now}
_set_sync_status(
user_id, "idle", now, records_synced=days_synced,
progress_current=days, progress_total=days,
last_error="; ".join(day_errors[:3]) if day_errors else None,
)
message = (
f"同步完成,更新 {days_synced} 天数据、{activities_synced} 条运动记录、"
f"{badges_synced} 个奖励、{records_synced_pr} 项个人纪录"
)
if day_errors:
message += f"{len(day_errors)} 项跳过)"
return {
"status": "success",
"recordsSynced": days_synced,
"activitiesSynced": activities_synced,
"badgesSynced": badges_synced,
"personalRecordsSynced": records_synced_pr,
"message": message,
"lastSyncTime": now,
}

View File

@@ -0,0 +1,160 @@
"""
Interactive Garmin login with two-factor auth, driven from the web UI.
The problem this solves: garth asks for the MFA code through a *blocking*
callback in the middle of `sso.login`. There is no "start login, return a
handle, resume later" API in garth 0.4.46, so the login has to stay alive
while the code is fetched.
Shape of the solution:
* the login runs in a background thread and parks inside `prompt_mfa`
* the browser posts the code in a separate request
* the two meet through a row in `garmin_mfa_sessions`, not process memory,
because gunicorn runs several workers and the code request will not
reliably land on the worker holding the parked login
The Garmin password never leaves the waiting thread — it is not stored.
Statuses:
starting - thread launched, login not yet at the MFA step
awaiting_code - parked in prompt_mfa, waiting for the browser
finishing - code received, completing the login
done - tokens saved
failed - see `error`
"""
import datetime
import threading
import time
import uuid
from config import DB_TYPE
from db import execute, query_one
from services import garmin as garmin_svc
# How long the parked login waits for a code before giving up. Garmin codes
# expire in 30 minutes, but holding a thread that long is wasteful; the user
# can simply start again.
CODE_WAIT_SECONDS = 300
POLL_INTERVAL_SECONDS = 2
# Sessions older than this are cleared out whenever a new one starts.
SESSION_TTL_MINUTES = 60
def _now():
return datetime.datetime.utcnow().isoformat(timespec="seconds")
def _set(session_id, status, **fields):
sets = ["status = ?", "updated_at = ?"]
params = [status, _now()]
for k, v in fields.items():
sets.append(f"{k} = ?")
params.append(v)
params.append(session_id)
execute(f"UPDATE garmin_mfa_sessions SET {', '.join(sets)} WHERE id = ?", params)
def get_session(session_id, user_id=None):
row = query_one("SELECT * FROM garmin_mfa_sessions WHERE id = ?", [session_id])
if not row:
return None
# A session id from one account must never address another's login.
if user_id and row["user_id"] != user_id:
return None
return row
def _cleanup(user_id):
cutoff = (
datetime.datetime.utcnow() - datetime.timedelta(minutes=SESSION_TTL_MINUTES)
).isoformat(timespec="seconds")
execute(
"DELETE FROM garmin_mfa_sessions WHERE user_id = ? AND created_at < ?",
[user_id, cutoff],
)
def _wait_for_code(session_id):
"""Block until the browser posts a code. Runs inside garth's prompt_mfa."""
_set(session_id, "awaiting_code")
deadline = time.time() + CODE_WAIT_SECONDS
while time.time() < deadline:
row = query_one(
"SELECT code FROM garmin_mfa_sessions WHERE id = ?", [session_id]
)
if row is None:
raise RuntimeError("登录会话已被取消")
if row["code"]:
_set(session_id, "finishing")
return row["code"]
time.sleep(POLL_INTERVAL_SECONDS)
raise TimeoutError("等待验证码超时,请重新发起登录")
def _run_login(session_id, user_id, garmin_email, password, is_cn, import_garmin):
try:
Garmin = import_garmin()
client = Garmin(is_cn=is_cn)
# Calling garth directly is what allows the MFA prompt to be replaced;
# Garmin.login() hardcodes the stdin one.
client.garth.login(
garmin_email, password, prompt_mfa=lambda: _wait_for_code(session_id)
)
garmin_svc.save_token(user_id, client.garth.dumps(), garmin_email)
_set(session_id, "done", code=None)
except Exception as e: # noqa: BLE001 - surfaced to the user via the row
_set(session_id, "failed", error=f"{type(e).__name__}: {e}"[:500], code=None)
def start_login(user_id, garmin_email, password, import_garmin=None, is_cn=None):
"""Kick off a login in the background. Returns the session id."""
_cleanup(user_id)
session_id = str(uuid.uuid4())
execute(
"INSERT INTO garmin_mfa_sessions (id, user_id, status, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?)",
[session_id, user_id, "starting", _now(), _now()],
)
thread = threading.Thread(
target=_run_login,
args=(
session_id,
user_id,
garmin_email,
password,
garmin_svc._is_cn() if is_cn is None else is_cn,
import_garmin or garmin_svc._import_garmin,
),
daemon=True,
)
thread.start()
return session_id
def submit_code(session_id, user_id, code):
"""Hand a code to the parked login. Returns (ok, message)."""
row = get_session(session_id, user_id)
if not row:
return False, "登录会话不存在或已过期"
if row["status"] == "done":
return False, "登录已完成"
if row["status"] == "failed":
return False, row["error"] or "登录已失败,请重新发起"
if row["status"] not in ("awaiting_code", "starting"):
return False, f"当前状态无法提交验证码({row['status']}"
execute(
"UPDATE garmin_mfa_sessions SET code = ?, updated_at = ? WHERE id = ?",
[str(code).strip(), _now(), session_id],
)
return True, "验证码已提交"
def cancel(session_id, user_id):
execute(
"DELETE FROM garmin_mfa_sessions WHERE id = ? AND user_id = ?",
[session_id, user_id],
)

243
backend/services/health.py Normal file
View File

@@ -0,0 +1,243 @@
"""
Health data service: read endpoints + upsert helpers used by the Garmin sync.
Mirrors the original Node HealthService, including the camelCase JSON mapping.
Upserts use backend-specific SQL because SQLite does not support
`ON DUPLICATE KEY UPDATE` (it uses `ON CONFLICT ... DO UPDATE`).
"""
import uuid
from db import execute, query_one, query_all
from config import DB_TYPE
def _range_sql(user_id, start=None, end=None):
params = [user_id]
sql = "WHERE user_id = ?"
if start:
sql += " AND date >= ?"
params.append(start)
if end:
sql += " AND date <= ?"
params.append(end)
return sql, params
def get_summary(user_id, start=None, end=None):
"""Every stored metric for each day, in the camelCase the UI and the AI
prompt consume."""
sql, params = _range_sql(user_id, start, end)
columns = ", ".join(HEALTH_COLUMNS)
rows = query_all(
f"SELECT date, {columns} FROM health_data {sql} ORDER BY date ASC", params
)
out = []
for r in rows:
day = {"date": r["date"]}
for column, key in HEALTH_COLUMNS.items():
day[key] = r.get(column)
# Sleep stays nested for backwards compatibility with the UI and the
# existing recommendation rules.
day["sleep"] = (
{
"duration": r.get("sleep_duration"),
"quality": r.get("sleep_quality"),
"deepSeconds": r.get("sleep_deep_seconds"),
"lightSeconds": r.get("sleep_light_seconds"),
"remSeconds": r.get("sleep_rem_seconds"),
"awakeSeconds": r.get("sleep_awake_seconds"),
}
if r.get("sleep_duration") is not None
else None
)
out.append(day)
return out
def get_steps(user_id, start=None, end=None):
sql, params = _range_sql(user_id, start, end)
rows = query_all(
f"SELECT date, steps FROM health_data {sql} AND steps IS NOT NULL ORDER BY date ASC",
params,
)
return [{"date": r["date"], "steps": r["steps"]} for r in rows]
def get_heart_rate(user_id, start=None, end=None):
sql, params = _range_sql(user_id, start, end)
rows = query_all(
f"SELECT date, heart_rate, heart_rate_variability FROM health_data {sql} "
"AND heart_rate IS NOT NULL ORDER BY date ASC",
params,
)
return [
{
"date": r["date"],
"heartRate": r["heart_rate"],
"heartRateVariability": r["heart_rate_variability"],
}
for r in rows
]
def get_sleep(user_id, start=None, end=None):
sql, params = _range_sql(user_id, start, end)
rows = query_all(
f"SELECT date, sleep_duration, sleep_quality FROM health_data {sql} "
"AND sleep_duration IS NOT NULL ORDER BY date ASC",
params,
)
return [
{"date": r["date"], "duration": r["sleep_duration"], "quality": r["sleep_quality"]}
for r in rows
]
def get_activities(user_id, start=None, end=None):
sql, params = _range_sql(user_id, start, end)
rows = query_all(
"SELECT id, activity_type, start_time, end_time, duration, distance, "
"calories, heart_rate_average, heart_rate_max "
f"FROM activities {sql} ORDER BY start_time DESC",
params,
)
return rows
# Column name -> key in the record dict produced by the Garmin extractor.
# Keeping the mapping in one place means adding a metric touches this table
# and the extractor, and nothing else.
HEALTH_COLUMNS = {
"steps": "steps",
"step_goal": "stepGoal",
"distance_meters": "distanceMeters",
"calories_burned": "caloriesBurned",
"active_calories": "activeCalories",
"bmr_calories": "bmrCalories",
"floors_ascended": "floorsAscended",
"floors_descended": "floorsDescended",
"intensity_minutes": "intensityMinutes",
"sedentary_seconds": "sedentarySeconds",
"active_seconds": "activeSeconds",
"heart_rate": "heartRate",
"heart_rate_max": "heartRateMax",
"heart_rate_min": "heartRateMin",
"heart_rate_variability": "heartRateVariability",
"stress": "stress",
"stress_max": "stressMax",
"body_battery_high": "bodyBatteryHigh",
"body_battery_low": "bodyBatteryLow",
"body_battery_charged": "bodyBatteryCharged",
"body_battery_drained": "bodyBatteryDrained",
"spo2_avg": "spo2Avg",
"spo2_min": "spo2Min",
"respiration_avg": "respirationAvg",
"respiration_min": "respirationMin",
"respiration_max": "respirationMax",
"sleep_duration": "sleepDuration",
"sleep_quality": "sleepQuality",
"sleep_deep_seconds": "sleepDeepSeconds",
"sleep_light_seconds": "sleepLightSeconds",
"sleep_rem_seconds": "sleepRemSeconds",
"sleep_awake_seconds": "sleepAwakeSeconds",
"sleep_spo2_avg": "sleepSpo2Avg",
"sleep_respiration_avg": "sleepRespirationAvg",
"sleep_stress_avg": "sleepStressAvg",
"training_readiness": "trainingReadiness",
"vo2max": "vo2max",
"endurance_score": "enduranceScore",
"blood_pressure_systolic": "bloodPressureSystolic",
"blood_pressure_diastolic": "bloodPressureDiastolic",
}
def _upsert(table, key_cols, cols, values):
"""INSERT ... ON CONFLICT/DUPLICATE UPDATE, written for both backends."""
placeholders = ", ".join(["?"] * len(cols))
updatable = [c for c in cols if c not in key_cols]
if DB_TYPE == "mariadb":
updates = ", ".join(f"{c}=VALUES({c})" for c in updatable)
sql = (f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders}) "
f"ON DUPLICATE KEY UPDATE {updates}")
else:
conflict = ", ".join(key_cols)
updates = ", ".join(f"{c}=excluded.{c}" for c in updatable)
sql = (f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders}) "
f"ON CONFLICT({conflict}) DO UPDATE SET {updates}")
execute(sql, values)
def upsert_health_daily(user_id, record):
hid = f"{user_id}-{record['date']}"
cols = ["id", "user_id", "date"] + list(HEALTH_COLUMNS)
values = [hid, user_id, record.get("date")] + [
record.get(key) for key in HEALTH_COLUMNS.values()
]
_upsert("health_data", ("user_id", "date"), cols, values)
return hid
def upsert_badge(user_id, badge):
cols = ["id", "user_id", "badge_key", "name", "category_id",
"difficulty_id", "earned_date", "earned_count", "points"]
values = [
badge["id"], user_id, badge.get("badgeKey"), badge.get("name"),
badge.get("categoryId"), badge.get("difficultyId"),
badge.get("earnedDate"), badge.get("earnedCount"), badge.get("points"),
]
_upsert("badges", ("user_id", "id"), cols, values)
return badge["id"]
def upsert_personal_record(user_id, record):
cols = ["id", "user_id", "type_id", "activity_id", "activity_name",
"activity_type", "value", "achieved_at"]
values = [
record["id"], user_id, record.get("typeId"), record.get("activityId"),
record.get("activityName"), record.get("activityType"),
record.get("value"), record.get("achievedAt"),
]
_upsert("personal_records", ("user_id", "id"), cols, values)
return record["id"]
def get_badges(user_id):
return query_all(
"SELECT id, badge_key, name, category_id, difficulty_id, earned_date, "
"earned_count, points FROM badges WHERE user_id = ? "
"ORDER BY earned_date DESC",
[user_id],
)
def get_personal_records(user_id):
return query_all(
"SELECT id, type_id, activity_id, activity_name, activity_type, value, "
"achieved_at FROM personal_records WHERE user_id = ? "
"ORDER BY achieved_at DESC",
[user_id],
)
def insert_activity(user_id, activity):
# Prefer Garmin's own activity id when the caller has one: it is stable
# across syncs, which is what lets a re-synced window skip what is already
# stored instead of inserting it again.
aid = str(activity.get("id") or uuid.uuid4())
cols = [
"id", "user_id", "activity_type", "start_time", "end_time",
"duration", "distance", "calories", "heart_rate_average", "heart_rate_max",
]
placeholders = ", ".join(["?"] * len(cols))
vals = [
aid, user_id, activity.get("activityType"), activity.get("startTime"),
activity.get("endTime"), activity.get("duration"), activity.get("distance"),
activity.get("calories"), activity.get("heartRateAverage"),
activity.get("heartRateMax"),
]
execute(
f"INSERT INTO activities ({', '.join(cols)}) VALUES ({placeholders})",
vals,
)
return aid

126
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,126 @@
"""
Shared pytest fixtures.
Environment must be configured BEFORE `config` is imported, because config.py
reads os.environ at import time. Each test then gets its own SQLite file so
tests never share state.
"""
import os
import sys
import tempfile
import pytest
_BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _BACKEND_DIR)
os.environ.setdefault("DB_TYPE", "sqlite")
os.environ.setdefault("JWT_SECRET", "test_secret_at_least_32_bytes_long_ok")
os.environ.setdefault("CORS_ORIGIN", "http://localhost:3000")
# Point at a throwaway path; the db_path fixture overrides it per test.
os.environ.setdefault(
"DATABASE_PATH", os.path.join(tempfile.mkdtemp(), "bootstrap.db")
)
import db as db_module # noqa: E402
from app import create_app # noqa: E402
# config.py calls load_dotenv() at import, so backend/.env leaks into the test
# process — a developer's real AI_MODEL_CHAIN or API keys would silently change
# what the suite exercises (and could bill real API calls). Clear them here;
# individual tests opt back in through the `keys` / `gateway` fixtures.
_AI_ENV_VARS = (
"AI_MODEL_CHAIN",
"AI_DAY_BUDGET",
"AI_TIMEOUT_SECONDS",
"GEMINI_API_KEY",
"NVIDIA_API_KEY",
"NVIDIA_BASE_URL",
"AI_GATEWAY_TOKEN",
"AI_GATEWAY_BASE_URL",
"AI_GATEWAY_MODEL",
"OLLAMA_BASE_URL",
"OLLAMA_MODEL",
)
@pytest.fixture(autouse=True)
def _isolate_ai_env(monkeypatch):
for var in _AI_ENV_VARS:
monkeypatch.delenv(var, raising=False)
# Most tests need to create users freely; the production default closes
# registration once one account exists. test_registration_policy.py clears
# this to exercise the real default.
monkeypatch.setenv("ALLOW_REGISTRATION", "true")
@pytest.fixture
def db(tmp_path, monkeypatch):
"""A freshly initialized, isolated SQLite database for one test."""
path = str(tmp_path / "test.db")
monkeypatch.setattr(db_module, "SQLITE_PATH", path)
db_module.init_db()
return db_module
@pytest.fixture
def app(db):
application = create_app()
application.config.update(TESTING=True)
return application
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def user(client):
"""A registered user: returns {id, email, token, password}."""
password = "secret123"
resp = client.post(
"/api/auth/register",
json={
"email": "tester@example.com",
"garminEmail": "gm@example.com",
"garminPassword": password,
},
)
assert resp.status_code == 201, resp.get_data(as_text=True)
body = resp.get_json()
return {**body, "password": password}
@pytest.fixture
def auth(user):
"""Authorization headers for the registered user."""
return {"Authorization": f"Bearer {user['token']}"}
@pytest.fixture
def seed_health(db, user):
"""Insert daily health rows. Returns the inserted records."""
def _seed(records):
for r in records:
db.execute(
"INSERT INTO health_data (id, user_id, date, steps, heart_rate, "
"heart_rate_variability, sleep_duration, sleep_quality, stress, "
"calories_burned) VALUES (?,?,?,?,?,?,?,?,?,?)",
[
f"{user['id']}-{r['date']}",
user["id"],
r["date"],
r.get("steps"),
r.get("heart_rate"),
r.get("hrv"),
r.get("sleep_duration"),
r.get("sleep_quality"),
r.get("stress"),
r.get("calories"),
],
)
return records
return _seed

128
backend/tests/smoke.py Normal file
View File

@@ -0,0 +1,128 @@
"""
Smoke test for the Flask backend (SQLite).
Exercises the full request path: register -> login -> authenticated reads for
health summary/steps/heart-rate/sleep/activities, analysis trends +
recommendations, and Garmin sync status. Run: `python tests/smoke.py`.
"""
import os
import sys
import tempfile
import json
# Configure the backend BEFORE importing app/config.
_TMP_DB = os.path.join(tempfile.mkdtemp(), "smoke.db")
os.environ["DB_TYPE"] = "sqlite"
os.environ["DATABASE_PATH"] = _TMP_DB
os.environ["JWT_SECRET"] = "smoke_test_secret"
os.environ["CORS_ORIGIN"] = "http://localhost:3000"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app # noqa: E402
from db import execute # noqa: E402
app = create_app()
client = app.test_client()
PASS = 0
FAIL = 0
def check(name, cond, detail=""):
global PASS, FAIL
if cond:
PASS += 1
print(f" PASS {name}")
else:
FAIL += 1
print(f" FAIL {name} {detail}")
def auth_headers(token):
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
print("\n[1] Auth: register + login")
r = client.post(
"/api/auth/register",
json={"email": "tester@example.com", "garminEmail": "gm@example.com", "garminPassword": "secret123"},
)
check("register 201", r.status_code == 201, r.get_data(as_text=True))
token = (r.get_json() or {}).get("token")
check("register returns token", bool(token))
r = client.post("/api/auth/login", json={"email": "tester@example.com", "password": "secret123"})
check("login 200", r.status_code == 200, r.get_data(as_text=True))
token = (r.get_json() or {}).get("token")
check("login returns token", bool(token))
r = client.post("/api/auth/login", json={"email": "tester@example.com", "password": "wrong"})
check("login rejects bad password (401)", r.status_code == 401)
r = client.get("/api/health/summary")
check("unauthenticated read 401", r.status_code == 401)
print("\n[2] Seed health data (3 days)")
uid = (client.post("/api/auth/login", json={"email": "tester@example.com", "password": "secret123"}).get_json())["id"]
for i, (steps, hr, sleep, stress) in enumerate([(6500, 70, 6.2, 55), (9000, 62, 7.5, 40), (7500, 68, 6.8, 48)]):
date = f"2026-08-{20 + i}"
execute(
"INSERT INTO health_data (id, user_id, date, steps, heart_rate, "
"heart_rate_variability, sleep_duration, sleep_quality, stress, calories_burned) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
[f"{uid}-{date}", uid, date, steps, hr, 45 + i, sleep, 80 - i, stress, steps * 0.04],
)
execute(
"INSERT INTO activities (id, user_id, activity_type, start_time, end_time, duration, distance, calories, heart_rate_average, heart_rate_max) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
["act-1", uid, "running", "2026-08-20T07:00:00", "2026-08-20T07:30:00", 1800, 5.0, 320, 140, 165],
)
print("\n[3] Authenticated health reads")
h = auth_headers(token)
r = client.get("/api/health/summary", headers=h)
check("summary 200", r.status_code == 200, r.get_data(as_text=True))
body = r.get_json()
check("summary 3 days", len(body) == 3, f"got {len(body)}")
check("summary camelCase sleep", "sleep" in body[0] and isinstance(body[0]["sleep"], dict))
r = client.get("/api/health/steps", headers=h)
check("steps 200 + non-null", r.status_code == 200 and len(r.get_json()) == 3)
r = client.get("/api/health/heart-rate", headers=h)
check("heart-rate 200", r.status_code == 200 and len(r.get_json()) == 3)
r = client.get("/api/health/sleep", headers=h)
check("sleep 200", r.status_code == 200 and len(r.get_json()) == 3)
r = client.get("/api/health/activities", headers=h)
check("activities 200", r.status_code == 200 and len(r.get_json()) == 1)
print("\n[4] Analysis")
r = client.get("/api/analysis/trends?metricType=steps", headers=h)
check("trends 200", r.status_code == 200, r.get_data(as_text=True))
check("trends values", len(r.get_json()) == 3)
r = client.get("/api/analysis/recommendations", headers=h)
check("recommendations 200", r.status_code == 200)
recs = r.get_json()
check("recommendations non-empty", len(recs) > 0)
check("recommendations sorted by priority", [x["priority"] for x in recs] == sorted([x["priority"] for x in recs], key=lambda p: {"high": 0, "medium": 1, "low": 2}[p]))
print("\n[5] Garmin status + sync (no creds -> clear 400)")
r = client.get("/api/garmin/status", headers=h)
check("garmin status 200", r.status_code == 200, r.get_data(as_text=True))
check("garmin status idle", (r.get_json() or {}).get("status") == "idle")
r = client.post("/api/garmin/sync", headers=h, json={})
check("sync without creds 400", r.status_code == 400, r.get_data(as_text=True))
print("\n[6] Health check + logout")
r = client.get("/api/health/status")
check("health/status 200", r.status_code == 200 and (r.get_json() or {}).get("status") == "ok")
r = client.post("/api/auth/logout", headers=h)
check("logout 200", r.status_code == 200)
print(f"\nRESULT: {PASS} passed, {FAIL} failed")
sys.exit(1 if FAIL else 0)

679
backend/tests/test_ai.py Normal file
View File

@@ -0,0 +1,679 @@
"""
Unit tests for the multi-provider LLM layer.
Every HTTP call is mocked — the suite never touches the network and never
needs a real API key.
"""
import json
import pytest
import requests
from services import ai as ai_svc
from services import analysis as analysis_svc
VALID_REPLY = json.dumps(
[
{"category": "睡眠", "recommendation": "固定就寝时间,目标 7-8 小时。",
"priority": "high", "basedOn": ["sleep_duration"]},
{"category": "运动", "recommendation": "每天增加 20 分钟快走。",
"priority": "medium", "basedOn": ["steps"]},
],
ensure_ascii=False,
)
SUMMARY = [
{"date": "2026-08-20", "steps": 6500, "heartRate": 70,
"heartRateVariability": 45, "stress": 55, "caloriesBurned": 260,
"sleep": {"duration": 6, "quality": 80}},
{"date": "2026-08-21", "steps": 9000, "heartRate": 62,
"heartRateVariability": 46, "stress": 40, "caloriesBurned": 360,
"sleep": {"duration": 8, "quality": 79}},
]
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload
self.text = text or json.dumps(payload or {})
def json(self):
if self._payload is None:
raise ValueError("no json")
return self._payload
def gemini_payload(text):
return {"candidates": [{"content": {"parts": [{"text": text}]}}]}
def openai_payload(text):
return {"choices": [{"message": {"content": text}}]}
@pytest.fixture
def keys(monkeypatch):
"""Direct vendor keys configured; the gateway stays out of the chain."""
monkeypatch.setenv("GEMINI_API_KEY", "test-gemini-key")
monkeypatch.setenv("NVIDIA_API_KEY", "test-nvidia-key")
monkeypatch.delenv("AI_GATEWAY_TOKEN", raising=False)
monkeypatch.delenv("AI_GATEWAY_BASE_URL", raising=False)
return True
@pytest.fixture
def no_keys(monkeypatch):
for var in (
"GEMINI_API_KEY", "NVIDIA_API_KEY",
"AI_GATEWAY_TOKEN", "AI_GATEWAY_BASE_URL",
):
monkeypatch.delenv(var, raising=False)
return True
@pytest.fixture
def gateway(monkeypatch):
"""Only the self-hosted gateway is configured."""
monkeypatch.setenv("AI_GATEWAY_TOKEN", "test-gateway-token")
monkeypatch.setenv("AI_GATEWAY_BASE_URL", "http://gw.test:5100/v1")
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
monkeypatch.delenv("NVIDIA_API_KEY", raising=False)
return True
# --- prompt construction ----------------------------------------------------
class TestBuildPrompt:
def test_includes_every_day_as_a_csv_row(self):
prompt = ai_svc.build_prompt(SUMMARY)
assert "2026-08-20" in prompt and "2026-08-21" in prompt
assert "共 2 天" in prompt
def test_uses_csv_not_json(self):
"""CSV keeps a year of history affordable; JSON would not."""
prompt = ai_svc.build_prompt(SUMMARY)
assert "6500,45" in prompt.replace(" ", "") or "6500" in prompt
assert '"steps":' not in prompt
def test_missing_metrics_become_empty_cells_not_the_word_none(self):
prompt = ai_svc.build_prompt([{"date": "2026-08-20", "steps": None}])
assert "None" not in prompt
def test_sleep_is_flattened_into_columns(self):
prompt = ai_svc.build_prompt(SUMMARY)
assert "sleep_h,sleep_q" in prompt
def test_day_budget_trims_to_the_most_recent_days(self):
many = [{"date": f"2026-01-{d:02d}", "steps": d} for d in range(1, 32)]
prompt = ai_svc.build_prompt(many, day_budget=5)
assert "共 5 天" in prompt
assert "2026-01-31" in prompt, "must keep the newest days"
assert "2026-01-01" not in prompt, "must drop the oldest days"
def test_activities_included_when_supplied(self):
prompt = ai_svc.build_prompt(
SUMMARY, [{"activity_type": "running", "distance": 5.0}]
)
assert "running" in prompt
def test_activities_capped(self):
acts = [{"activity_type": f"run{i}"} for i in range(500)]
prompt = ai_svc.build_prompt(SUMMARY, acts)
assert "共 200 条" in prompt
def test_prompt_forbids_fabricating_numbers(self):
assert "不要编造" in ai_svc.build_prompt(SUMMARY)
def test_prompt_disclaims_medical_advice(self):
assert "不是医生" in ai_svc.build_prompt(SUMMARY)
def test_a_year_of_data_stays_compact(self):
year = [
{"date": f"2026-{m:02d}-{d:02d}", "steps": 8000, "heartRate": 60,
"sleep": {"duration": 7, "quality": 80}}
for m in range(1, 13) for d in range(1, 29)
]
prompt = ai_svc.build_prompt(year)
# ~4 chars/token: a year must stay far under even the smallest window.
assert len(prompt) / 4 < 50_000
# --- response parsing -------------------------------------------------------
class TestParseRecommendations:
def test_plain_json_array(self):
recs = ai_svc.parse_recommendations(VALID_REPLY)
assert len(recs) == 2
assert recs[0]["category"] == "睡眠"
def test_markdown_fenced_json(self):
recs = ai_svc.parse_recommendations(f"```json\n{VALID_REPLY}\n```")
assert len(recs) == 2
def test_json_with_a_preamble_sentence(self):
recs = ai_svc.parse_recommendations(f"好的,分析结果如下:\n{VALID_REPLY}")
assert len(recs) == 2
def test_single_object_is_wrapped(self):
recs = ai_svc.parse_recommendations(
'{"category":"睡眠","recommendation":"早点睡","priority":"high"}'
)
assert len(recs) == 1
def test_results_are_sorted_by_priority(self):
reply = json.dumps([
{"category": "a", "recommendation": "low one", "priority": "low"},
{"category": "b", "recommendation": "high one", "priority": "high"},
{"category": "c", "recommendation": "medium one", "priority": "medium"},
])
assert [r["priority"] for r in ai_svc.parse_recommendations(reply)] == [
"high", "medium", "low"
]
def test_invalid_priority_defaults_to_medium(self):
reply = json.dumps([
{"category": "a", "recommendation": "x", "priority": "URGENT!!"}
])
assert ai_svc.parse_recommendations(reply)[0]["priority"] == "medium"
def test_entries_without_recommendation_text_are_dropped(self):
reply = json.dumps([
{"category": "a", "recommendation": ""},
{"category": "b", "recommendation": "keep me"},
])
recs = ai_svc.parse_recommendations(reply)
assert len(recs) == 1 and recs[0]["recommendation"] == "keep me"
def test_non_list_based_on_is_normalised(self):
reply = json.dumps([
{"category": "a", "recommendation": "x", "basedOn": "steps"}
])
assert ai_svc.parse_recommendations(reply)[0]["basedOn"] == []
def test_results_are_tagged_as_ai_generated(self):
assert all(r["source"] == "ai" for r in ai_svc.parse_recommendations(VALID_REPLY))
@pytest.mark.parametrize(
"reply", ["", " ", "抱歉,我无法回答。", "[", "null", "[]", "[1,2,3]"]
)
def test_unusable_replies_raise_aierror(self, reply):
with pytest.raises(ai_svc.AIError):
ai_svc.parse_recommendations(reply)
# --- providers --------------------------------------------------------------
class TestGeminiProvider:
def test_successful_call(self, keys, monkeypatch):
captured = {}
def fake_post(self, url, **kwargs):
captured["url"] = url
captured["headers"] = kwargs.get("headers", {})
captured["json"] = kwargs.get("json")
return FakeResponse(200, gemini_payload("hello"))
monkeypatch.setattr(requests.Session, "post", fake_post)
out = ai_svc.CATALOG["gemini-flash"].generate("prompt text")
assert out.text == "hello"
assert "gemini-flash-latest:generateContent" in captured["url"]
assert captured["headers"]["X-goog-api-key"] == "test-gemini-key"
assert captured["json"]["contents"][0]["parts"][0]["text"] == "prompt text"
def test_http_error_becomes_aierror(self, keys, monkeypatch):
monkeypatch.setattr(
requests.Session, "post", lambda *a, **k: FakeResponse(429, text="rate limited")
)
with pytest.raises(ai_svc.AIError, match="429"):
ai_svc.CATALOG["gemini-flash"].generate("p")
def test_timeout_becomes_aierror(self, keys, monkeypatch):
def boom(self, *a, **k):
raise requests.Timeout("timed out")
monkeypatch.setattr(requests.Session, "post", boom)
with pytest.raises(ai_svc.AIError, match="请求失败"):
ai_svc.CATALOG["gemini-flash"].generate("p")
def test_unexpected_shape_becomes_aierror(self, keys, monkeypatch):
monkeypatch.setattr(
requests.Session, "post", lambda *a, **k: FakeResponse(200, {"unexpected": True})
)
with pytest.raises(ai_svc.AIError, match="响应格式异常"):
ai_svc.CATALOG["gemini-flash"].generate("p")
def test_missing_key_raises_before_any_request(self, no_keys, monkeypatch):
def boom(self, *a, **k):
raise AssertionError("must not issue a request without a key")
monkeypatch.setattr(requests.Session, "post", boom)
with pytest.raises(ai_svc.AIError, match="GEMINI_API_KEY"):
ai_svc.CATALOG["gemini-flash"].generate("p")
class TestOpenAICompatProvider:
def test_successful_call(self, keys, monkeypatch):
captured = {}
def fake_post(self, url, **kwargs):
captured["url"] = url
captured["headers"] = kwargs.get("headers", {})
captured["json"] = kwargs.get("json")
return FakeResponse(200, openai_payload("hi"))
monkeypatch.setattr(requests.Session, "post", fake_post)
out = ai_svc.CATALOG["llama-70b"].generate("prompt text")
assert out.text == "hi"
assert out.upstream is None, "stock OpenAI replies carry no provider field"
assert captured["url"].endswith("/chat/completions")
assert captured["headers"]["Authorization"] == "Bearer test-nvidia-key"
assert captured["json"]["model"] == "meta/llama-3.3-70b-instruct"
def test_http_error_becomes_aierror(self, keys, monkeypatch):
monkeypatch.setattr(
requests.Session, "post", lambda *a, **k: FakeResponse(500, text="boom")
)
with pytest.raises(ai_svc.AIError, match="500"):
ai_svc.CATALOG["llama-70b"].generate("p")
class TestGatewayProvider:
"""The self-hosted gateway: OpenAI-compatible, plus a `provider` field
naming whichever upstream actually served the request."""
def test_reports_the_upstream_that_answered(self, gateway, monkeypatch):
payload = {**openai_payload("hi"), "provider": "nvidia"}
monkeypatch.setattr(requests.Session, "post", lambda *a, **k: FakeResponse(200, payload))
out = ai_svc.CATALOG["gateway"].generate("p")
assert out.text == "hi"
assert out.upstream == "nvidia"
def test_targets_the_configured_base_url(self, gateway, monkeypatch):
captured = {}
def fake_post(self, url, **kwargs):
captured["url"] = url
captured["headers"] = kwargs.get("headers", {})
captured["model"] = (kwargs.get("json") or {}).get("model")
return FakeResponse(200, openai_payload("hi"))
monkeypatch.setattr(requests.Session, "post", fake_post)
ai_svc.CATALOG["gateway"].generate("p")
assert captured["url"] == "http://gw.test:5100/v1/chat/completions"
assert captured["headers"]["Authorization"] == "Bearer test-gateway-token"
assert captured["model"] == "ai-gateway-auto"
def test_upstream_surfaces_in_generate_meta(self, gateway, monkeypatch):
payload = {**openai_payload(VALID_REPLY), "provider": "gemini"}
monkeypatch.setattr(requests.Session, "post", lambda *a, **k: FakeResponse(200, payload))
_, meta = ai_svc.generate(SUMMARY)
assert meta["model"] == "gateway"
assert meta["upstream"] == "gemini"
def test_gateway_401_falls_through(self, monkeypatch):
"""A stale gateway token must not strand the request."""
monkeypatch.setenv("AI_GATEWAY_TOKEN", "expired")
monkeypatch.setenv("AI_GATEWAY_BASE_URL", "http://gw.test:5100/v1")
monkeypatch.setenv("GEMINI_API_KEY", "k")
def fake_post(self, url, **kwargs):
if "gw.test" in url:
return FakeResponse(401, text="unauthorized")
return FakeResponse(200, gemini_payload(VALID_REPLY))
monkeypatch.setattr(requests.Session, "post", fake_post)
_, meta = ai_svc.generate(SUMMARY)
assert meta["model"] == "gemini-flash"
assert meta["fallbackFrom"] == ["gateway"]
# --- proxy handling ---------------------------------------------------------
class TestProxyPolicy:
"""Overseas vendors may only be reachable through a local proxy, while a
self-hosted box on a public IP breaks when forced through one — so the two
must not share a policy."""
def test_hosted_vendors_honour_environment_proxies(self):
assert ai_svc.CATALOG["gemini-flash"].use_proxy is True
assert ai_svc.CATALOG["llama-70b"].use_proxy is True
def test_self_hosted_gateway_bypasses_proxies(self):
assert ai_svc.CATALOG["gateway"].use_proxy is False
def test_session_trust_env_follows_the_flag(self):
"""Regression: requests picked up ALL_PROXY and routed the gateway
call through a local proxy, which timed out after 120s."""
assert ai_svc.CATALOG["gateway"]._session().trust_env is False
assert ai_svc.CATALOG["gemini-flash"]._session().trust_env is True
# --- output budget ----------------------------------------------------------
class TestMaxTokens:
def test_default_applies_to_ordinary_models(self):
assert ai_svc.CATALOG["gemini-flash"].max_tokens == ai_svc.FALLBACK_MAX_TOKENS
def test_reasoning_endpoint_declares_a_larger_budget(self):
"""Regression: the gateway's primary upstream thinks out loud before
answering; at the default cap the trace consumed the whole budget and
the reply was truncated before any JSON appeared."""
assert ai_svc.CATALOG["gateway"].max_tokens > ai_svc.FALLBACK_MAX_TOKENS
def test_env_overrides_the_default_but_not_an_explicit_budget(self, monkeypatch):
monkeypatch.setenv("AI_MAX_TOKENS", "77")
assert ai_svc.CATALOG["gemini-flash"].max_tokens == 77
assert ai_svc.CATALOG["gateway"].max_tokens == 3000
def test_budget_reaches_the_openai_payload(self, gateway, monkeypatch):
seen = {}
def fake_post(self, url, **kwargs):
seen["max_tokens"] = kwargs["json"]["max_tokens"]
return FakeResponse(200, openai_payload("hi"))
monkeypatch.setattr(requests.Session, "post", fake_post)
ai_svc.CATALOG["gateway"].generate("p")
assert seen["max_tokens"] == 3000
def test_budget_reaches_the_gemini_payload(self, keys, monkeypatch):
seen = {}
def fake_post(self, url, **kwargs):
seen["cap"] = kwargs["json"]["generationConfig"]["maxOutputTokens"]
return FakeResponse(200, gemini_payload("hi"))
monkeypatch.setattr(requests.Session, "post", fake_post)
ai_svc.CATALOG["gemini-flash"].generate("p")
assert seen["cap"] == ai_svc.FALLBACK_MAX_TOKENS
# --- lazily-read configuration ----------------------------------------------
class TestLazyConfig:
"""Regression: these were module-level constants, so they froze whatever
the environment held at import — hiding config changes and letting a
developer's .env leak into the test run."""
def test_chain_reflects_the_current_environment(self, monkeypatch):
monkeypatch.setenv("AI_MODEL_CHAIN", "llama-70b,gemini-flash")
assert ai_svc.default_chain() == ["llama-70b", "gemini-flash"]
monkeypatch.setenv("AI_MODEL_CHAIN", "gateway")
assert ai_svc.default_chain() == ["gateway"]
def test_timeout_reflects_the_current_environment(self, monkeypatch):
monkeypatch.setenv("AI_TIMEOUT_SECONDS", "7")
assert ai_svc.default_timeout() == 7.0
def test_day_budget_reflects_the_current_environment(self, monkeypatch):
monkeypatch.setenv("AI_DAY_BUDGET", "42")
assert ai_svc.default_day_budget() == 42
def test_defaults_apply_when_unset(self):
assert ai_svc.default_timeout() == ai_svc.FALLBACK_TIMEOUT
assert ai_svc.default_day_budget() == ai_svc.FALLBACK_DAY_BUDGET
assert ai_svc.default_chain()[0] == "gateway"
def test_default_flag_tracks_the_chain_head(self, monkeypatch, keys):
monkeypatch.setenv("AI_MODEL_CHAIN", "llama-70b,gemini-flash")
by_id = {m["id"]: m for m in ai_svc.list_models()}
assert by_id["llama-70b"]["default"] is True
assert by_id["gemini-flash"]["default"] is False
# --- context sizing ---------------------------------------------------------
class TestPerModelSizing:
"""Chain members' windows differ by >30x, so the payload is sized per
model rather than once for the whole chain."""
def test_small_window_gets_fewer_days_than_a_large_one(self):
# A budget above what 128k can hold, so the window is what binds.
budget = 100_000
small = ai_svc.max_days_for(ai_svc.CATALOG["llama-70b"], budget) # 128k
large = ai_svc.max_days_for(ai_svc.CATALOG["gemini-flash"], budget) # 1M
assert small < large
def test_budget_binds_when_it_is_the_tighter_limit(self):
"""At the default 365-day budget every model gets the same 365 days —
no window in the catalog is small enough to bind first."""
budget = ai_svc.default_day_budget()
days = {
mid: ai_svc.max_days_for(p, budget) for mid, p in ai_svc.CATALOG.items()
}
assert set(days.values()) == {budget}
def test_never_exceeds_the_configured_budget(self):
assert ai_svc.max_days_for(ai_svc.CATALOG["gemini-flash"], day_budget=30) == 30
def test_always_allows_at_least_one_day(self):
tiny = ai_svc.OpenAICompatProvider(
model_id="tiny", context_window=10,
base_url_env="X", default_base_url="http://x", requires_key=False,
)
assert ai_svc.max_days_for(tiny) >= 1
def test_each_model_gets_a_prompt_sized_for_itself(self, keys, monkeypatch):
"""Regression: one prompt was built for the whole chain, so a payload
sized for Gemini's 1M window was also sent to 128k models.
Needs more days than the 128k window holds (~6.4k) for the trimming to
bite, hence the deliberately oversized history.
"""
history = [{"date": "2026-01-01", "steps": 8000} for _ in range(8000)]
sizes = {}
def fake_post(self, url, **kwargs):
if "generativelanguage" in url:
sizes["gemini"] = len(kwargs["json"]["contents"][0]["parts"][0]["text"])
raise requests.Timeout("force fallback")
sizes["nvidia"] = len(kwargs["json"]["messages"][0]["content"])
return FakeResponse(200, openai_payload(VALID_REPLY))
monkeypatch.setattr(requests.Session, "post", fake_post)
ai_svc.generate(history, day_budget=100_000)
assert sizes["nvidia"] < sizes["gemini"], (
"the 128k model must receive a smaller prompt than the 1M model"
)
# --- catalog & chain --------------------------------------------------------
class TestCatalog:
def test_all_models_listed(self, keys):
assert {m["id"] for m in ai_svc.list_models()} == {
"gateway", "gemini-flash", "llama-70b", "nemotron-49b", "mistral-large"
}
def test_configured_flag_tracks_the_environment(self, no_keys, monkeypatch):
assert all(not m["configured"] for m in ai_svc.list_models())
monkeypatch.setenv("GEMINI_API_KEY", "k")
by_id = {m["id"]: m for m in ai_svc.list_models()}
assert by_id["gemini-flash"]["configured"] is True
assert by_id["llama-70b"]["configured"] is False
def test_every_model_declares_a_large_window(self):
assert all(m["contextWindow"] >= 128_000 for m in ai_svc.list_models())
def test_no_vision_models_registered(self):
assert not any("vision" in m["model"] for m in ai_svc.list_models())
class TestResolveChain:
def test_preferred_model_goes_first(self, keys):
assert ai_svc.resolve_chain("nemotron-49b")[0] == "nemotron-49b"
def test_chain_has_no_duplicates(self, keys):
chain = ai_svc.resolve_chain("gemini-flash")
assert len(chain) == len(set(chain))
def test_unconfigured_models_are_skipped(self, no_keys, monkeypatch):
monkeypatch.setenv("GEMINI_API_KEY", "k")
assert ai_svc.resolve_chain() == ["gemini-flash"]
def test_unknown_model_raises(self, keys):
with pytest.raises(ai_svc.AIError, match="未知模型"):
ai_svc.resolve_chain("gpt-nonexistent")
def test_no_credentials_raises_with_actionable_message(self, no_keys):
with pytest.raises(ai_svc.AIError, match="GEMINI_API_KEY"):
ai_svc.resolve_chain()
def test_gateway_needs_both_token_and_base_url(self, no_keys, monkeypatch):
monkeypatch.setenv("AI_GATEWAY_TOKEN", "t")
assert ai_svc.CATALOG["gateway"].is_configured() is False
monkeypatch.setenv("AI_GATEWAY_BASE_URL", "http://gw.test:5100/v1")
assert ai_svc.CATALOG["gateway"].is_configured() is True
# --- generate + fallback ----------------------------------------------------
class TestGenerate:
def test_returns_recommendations_and_meta(self, keys, monkeypatch):
monkeypatch.setattr(
requests.Session, "post", lambda *a, **k: FakeResponse(200, gemini_payload(VALID_REPLY))
)
recs, meta = ai_svc.generate(SUMMARY)
assert len(recs) == 2
assert meta["model"] == "gemini-flash"
assert meta["days"] == 2
assert meta["fallbackFrom"] == []
def test_falls_back_to_the_next_model(self, keys, monkeypatch):
calls = []
def fake_post(self, url, **kwargs):
calls.append(url)
if "generativelanguage" in url:
raise requests.Timeout("gemini down")
return FakeResponse(200, openai_payload(VALID_REPLY))
monkeypatch.setattr(requests.Session, "post", fake_post)
recs, meta = ai_svc.generate(SUMMARY)
assert len(recs) == 2
assert meta["model"] == "llama-70b"
assert meta["fallbackFrom"] == ["gemini-flash"]
assert len(calls) == 2
def test_falls_back_when_a_model_returns_unparseable_text(self, keys, monkeypatch):
def fake_post(self, url, **kwargs):
if "generativelanguage" in url:
return FakeResponse(200, gemini_payload("抱歉,我帮不了你。"))
return FakeResponse(200, openai_payload(VALID_REPLY))
monkeypatch.setattr(requests.Session, "post", fake_post)
_, meta = ai_svc.generate(SUMMARY)
assert meta["model"] == "llama-70b"
def test_raises_when_every_model_fails(self, keys, monkeypatch):
def boom(self, *a, **k):
raise requests.Timeout("all down")
monkeypatch.setattr(requests.Session, "post", boom)
with pytest.raises(ai_svc.AIError, match="所有模型均失败"):
ai_svc.generate(SUMMARY)
def test_preferred_model_is_honoured(self, keys, monkeypatch):
seen = {}
def fake_post(self, url, **kwargs):
seen["model"] = (kwargs.get("json") or {}).get("model")
return FakeResponse(200, openai_payload(VALID_REPLY))
monkeypatch.setattr(requests.Session, "post", fake_post)
_, meta = ai_svc.generate(SUMMARY, preferred_model="nemotron-49b")
assert meta["model"] == "nemotron-49b"
assert seen["model"] == "nvidia/llama-3.3-nemotron-super-49b-v1.5"
def test_no_second_call_after_the_first_succeeds(self, keys, monkeypatch):
calls = []
def fake_post(self, url, **kwargs):
calls.append(url)
return FakeResponse(200, gemini_payload(VALID_REPLY))
monkeypatch.setattr(requests.Session, "post", fake_post)
ai_svc.generate(SUMMARY)
assert len(calls) == 1
# --- service + endpoint integration -----------------------------------------
class TestAiRecommendationsService:
def test_uses_the_rule_engine_when_there_is_no_data(self, db, user, keys):
out = analysis_svc.get_ai_recommendations(user["id"])
assert out["meta"]["source"] == "rules"
assert out["recommendations"][0]["id"] == "no-data"
def test_returns_ai_results_when_a_model_answers(
self, seed_health, user, keys, monkeypatch
):
seed_health([{"date": "2026-08-20", "steps": 5000}])
monkeypatch.setattr(
requests.Session, "post", lambda *a, **k: FakeResponse(200, gemini_payload(VALID_REPLY))
)
out = analysis_svc.get_ai_recommendations(user["id"])
assert out["meta"]["source"] == "ai"
assert len(out["recommendations"]) == 2
def test_degrades_to_rules_when_all_models_fail(
self, seed_health, user, keys, monkeypatch
):
seed_health([{"date": "2026-08-20", "steps": 5000}])
def boom(self, *a, **k):
raise requests.Timeout("down")
monkeypatch.setattr(requests.Session, "post", boom)
out = analysis_svc.get_ai_recommendations(user["id"])
assert out["meta"]["source"] == "rules"
assert "所有模型均失败" in out["meta"]["reason"]
assert out["recommendations"], "must still return rule-based advice"
def test_degrades_to_rules_when_no_key_is_configured(
self, seed_health, user, no_keys
):
seed_health([{"date": "2026-08-20", "steps": 5000}])
out = analysis_svc.get_ai_recommendations(user["id"])
assert out["meta"]["source"] == "rules"
assert "GEMINI_API_KEY" in out["meta"]["reason"]
class TestEndpoints:
def test_models_requires_auth(self, client):
assert client.get("/api/analysis/models").status_code == 401
def test_ai_recommendations_requires_auth(self, client):
assert client.get("/api/analysis/ai-recommendations").status_code == 401
def test_models_endpoint_lists_catalog(self, client, auth, keys):
r = client.get("/api/analysis/models", headers=auth)
assert r.status_code == 200
assert {m["id"] for m in r.get_json()} >= {"gemini-flash", "llama-70b"}
def test_models_endpoint_never_leaks_api_keys(self, client, auth, keys):
body = client.get("/api/analysis/models", headers=auth).get_data(as_text=True)
assert "test-gemini-key" not in body
assert "test-nvidia-key" not in body
def test_ai_endpoint_returns_200_even_with_no_models(self, client, auth, no_keys):
r = client.get("/api/analysis/ai-recommendations", headers=auth)
assert r.status_code == 200
assert r.get_json()["meta"]["source"] == "rules"
def test_ai_endpoint_passes_model_param_through(
self, client, auth, seed_health, keys, monkeypatch
):
seed_health([{"date": "2026-08-20", "steps": 5000}])
monkeypatch.setattr(
requests.Session, "post", lambda *a, **k: FakeResponse(200, openai_payload(VALID_REPLY))
)
r = client.get(
"/api/analysis/ai-recommendations?model=nemotron-49b", headers=auth
)
assert r.get_json()["meta"]["model"] == "nemotron-49b"
def test_unknown_model_param_degrades_to_rules(
self, client, auth, seed_health, keys
):
seed_health([{"date": "2026-08-20", "steps": 5000}])
r = client.get("/api/analysis/ai-recommendations?model=bogus", headers=auth)
assert r.status_code == 200
assert r.get_json()["meta"]["source"] == "rules"

View File

@@ -0,0 +1,240 @@
"""
Unit tests for the AI recommendation cache.
A generation costs minutes against a large reasoning model, so the result is
stored and reused. These tests pin when it is reused and — more importantly —
when it must not be.
"""
import datetime
import json
import pytest
import requests
from services import analysis as analysis_svc
from services import health as health_svc
VALID_REPLY = json.dumps(
[{"category": "睡眠", "recommendation": "早点睡。", "priority": "high",
"basedOn": ["sleep_duration"]}],
ensure_ascii=False,
)
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload
self.text = text or json.dumps(payload or {})
def json(self):
if self._payload is None:
raise ValueError("no json")
return self._payload
@pytest.fixture
def keys(monkeypatch):
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
return True
@pytest.fixture
def counting_llm(monkeypatch):
"""Mock the LLM and count how many times it is actually called."""
calls = []
def fake_post(self, url, **kwargs):
calls.append(url)
return FakeResponse(
200, {"candidates": [{"content": {"parts": [{"text": VALID_REPLY}]}}]}
)
monkeypatch.setattr(requests.Session, "post", fake_post)
return calls
@pytest.fixture
def seeded(seed_health, user):
seed_health([{"date": "2026-08-20", "steps": 5000, "sleep_duration": 6}])
return user
class TestCacheHit:
def test_first_call_reaches_the_model(self, seeded, keys, counting_llm):
out = analysis_svc.get_ai_recommendations(seeded["id"])
assert out["meta"]["source"] == "ai"
assert out["meta"]["cached"] is False
assert len(counting_llm) == 1
def test_second_call_is_served_from_cache(self, seeded, keys, counting_llm):
analysis_svc.get_ai_recommendations(seeded["id"])
out = analysis_svc.get_ai_recommendations(seeded["id"])
assert len(counting_llm) == 1, "the model must not be called twice"
assert out["meta"]["cached"] is True
assert out["meta"]["source"] == "ai"
def test_cached_result_matches_the_generated_one(self, seeded, keys, counting_llm):
first = analysis_svc.get_ai_recommendations(seeded["id"])
second = analysis_svc.get_ai_recommendations(seeded["id"])
assert first["recommendations"] == second["recommendations"]
def test_cache_records_which_model_answered(self, seeded, keys, counting_llm):
analysis_svc.get_ai_recommendations(seeded["id"])
out = analysis_svc.get_ai_recommendations(seeded["id"])
assert out["meta"]["model"] == "gemini-flash"
def test_cache_reports_when_it_was_generated(self, seeded, keys, counting_llm):
analysis_svc.get_ai_recommendations(seeded["id"])
out = analysis_svc.get_ai_recommendations(seeded["id"])
assert out["meta"]["generatedAt"]
class TestCacheInvalidation:
def test_new_health_data_invalidates(self, seeded, keys, counting_llm):
analysis_svc.get_ai_recommendations(seeded["id"])
health_svc.upsert_health_daily(
seeded["id"], {"date": "2026-08-21", "steps": 9000}
)
out = analysis_svc.get_ai_recommendations(seeded["id"])
assert len(counting_llm) == 2, "a new day of data must trigger a regeneration"
assert out["meta"]["cached"] is False
def test_corrected_value_invalidates(self, seeded, keys, counting_llm):
analysis_svc.get_ai_recommendations(seeded["id"])
# Same date, different step count — a re-sync correcting a value.
health_svc.upsert_health_daily(
seeded["id"], {"date": "2026-08-20", "steps": 12345, "sleepDuration": 6}
)
analysis_svc.get_ai_recommendations(seeded["id"])
assert len(counting_llm) == 2
def test_new_activity_invalidates(self, seeded, keys, counting_llm):
analysis_svc.get_ai_recommendations(seeded["id"])
health_svc.insert_activity(
seeded["id"],
{"activityType": "running", "startTime": "2026-08-20T07:00:00",
"endTime": "2026-08-20T07:30:00"},
)
analysis_svc.get_ai_recommendations(seeded["id"])
assert len(counting_llm) == 2
def test_refresh_bypasses_the_cache(self, seeded, keys, counting_llm):
analysis_svc.get_ai_recommendations(seeded["id"])
out = analysis_svc.get_ai_recommendations(seeded["id"], refresh=True)
assert len(counting_llm) == 2
assert out["meta"]["cached"] is False
def test_explicit_model_bypasses_the_cache(self, seeded, keys, counting_llm):
"""Asking for a named model means wanting that model's answer."""
analysis_svc.get_ai_recommendations(seeded["id"])
analysis_svc.get_ai_recommendations(seeded["id"], model="gemini-flash")
assert len(counting_llm) == 2
def test_expired_entry_is_regenerated(self, seeded, keys, counting_llm, db):
analysis_svc.get_ai_recommendations(seeded["id"])
stale = (
datetime.datetime.utcnow()
- datetime.timedelta(hours=analysis_svc.CACHE_TTL_HOURS + 1)
).isoformat(timespec="seconds")
db.execute(
"UPDATE ai_recommendations SET created_at = ? WHERE user_id = ?",
[stale, seeded["id"]],
)
analysis_svc.get_ai_recommendations(seeded["id"])
assert len(counting_llm) == 2
def test_entry_just_inside_the_ttl_is_kept(self, seeded, keys, counting_llm, db):
analysis_svc.get_ai_recommendations(seeded["id"])
fresh = (
datetime.datetime.utcnow()
- datetime.timedelta(hours=analysis_svc.CACHE_TTL_HOURS - 1)
).isoformat(timespec="seconds")
db.execute(
"UPDATE ai_recommendations SET created_at = ? WHERE user_id = ?",
[fresh, seeded["id"]],
)
analysis_svc.get_ai_recommendations(seeded["id"])
assert len(counting_llm) == 1
def test_clear_cache_forces_regeneration(self, seeded, keys, counting_llm):
analysis_svc.get_ai_recommendations(seeded["id"])
analysis_svc.clear_ai_cache(seeded["id"])
analysis_svc.get_ai_recommendations(seeded["id"])
assert len(counting_llm) == 2
class TestIsolationAndRobustness:
def test_cache_is_per_user(self, seeded, keys, counting_llm, db, client):
analysis_svc.get_ai_recommendations(seeded["id"])
other = client.post(
"/api/auth/register",
json={"email": "other@example.com", "garminEmail": "o@example.com",
"garminPassword": "pw123456"},
).get_json()
health_svc.upsert_health_daily(
other["id"], {"date": "2026-08-20", "steps": 5000, "sleepDuration": 6}
)
analysis_svc.get_ai_recommendations(other["id"])
assert len(counting_llm) == 2, "one user's cache must not answer another's"
def test_only_one_row_per_user(self, seeded, keys, counting_llm, db):
for _ in range(3):
analysis_svc.get_ai_recommendations(seeded["id"], refresh=True)
rows = db.query_all(
"SELECT * FROM ai_recommendations WHERE user_id = ?", [seeded["id"]]
)
assert len(rows) == 1, "regeneration must replace, not accumulate"
def test_corrupt_payload_regenerates_instead_of_raising(
self, seeded, keys, counting_llm, db
):
analysis_svc.get_ai_recommendations(seeded["id"])
db.execute(
"UPDATE ai_recommendations SET payload = ? WHERE user_id = ?",
["not json", seeded["id"]],
)
out = analysis_svc.get_ai_recommendations(seeded["id"])
assert len(counting_llm) == 2
assert out["recommendations"]
def test_rule_fallback_is_not_cached(self, seeded, monkeypatch, db):
"""A degraded answer must not be stored as if it were the AI's."""
def boom(self, *a, **k):
raise requests.Timeout("down")
monkeypatch.setattr(requests.Session, "post", boom)
out = analysis_svc.get_ai_recommendations(seeded["id"])
assert out["meta"]["source"] == "rules"
assert db.query_one(
"SELECT * FROM ai_recommendations WHERE user_id = ?", [seeded["id"]]
) is None
def test_no_data_user_is_not_cached(self, user, keys, counting_llm, db):
analysis_svc.get_ai_recommendations(user["id"])
assert len(counting_llm) == 0
assert db.query_one(
"SELECT * FROM ai_recommendations WHERE user_id = ?", [user["id"]]
) is None
class TestEndpoint:
def test_second_request_is_cached(self, client, auth, seeded, keys, counting_llm):
client.get("/api/analysis/ai-recommendations", headers=auth)
r = client.get("/api/analysis/ai-recommendations", headers=auth)
assert r.get_json()["meta"]["cached"] is True
assert len(counting_llm) == 1
def test_refresh_param_forces_regeneration(
self, client, auth, seeded, keys, counting_llm
):
client.get("/api/analysis/ai-recommendations", headers=auth)
r = client.get("/api/analysis/ai-recommendations?refresh=1", headers=auth)
assert r.get_json()["meta"]["cached"] is False
assert len(counting_llm) == 2

View File

@@ -0,0 +1,213 @@
"""Unit tests for the trends query and the rule-based recommendation engine."""
import pytest
from services import analysis as analysis_svc
def ids(recs):
return {r["id"] for r in recs}
def days(n, **metrics):
"""n consecutive days that all carry the same metric values."""
return [
{"date": f"2026-08-{d:02d}", **metrics} for d in range(1, n + 1)
]
# --- trends -----------------------------------------------------------------
class TestTrends:
def test_empty_without_data(self, db, user):
assert analysis_svc.get_trends("steps", user["id"]) == []
def test_returns_date_value_pairs(self, seed_health, user):
seed_health([{"date": "2026-08-20", "steps": 5000}])
assert analysis_svc.get_trends("steps", user["id"]) == [
{"date": "2026-08-20", "value": 5000}
]
@pytest.mark.parametrize(
"metric,column_value",
[
("steps", {"steps": 5000}),
("heart_rate", {"heart_rate": 60}),
("sleep_duration", {"sleep_duration": 7}),
("stress", {"stress": 30}),
("calories_burned", {"calories": 250}),
],
)
def test_each_supported_metric(self, seed_health, user, metric, column_value):
seed_health([{"date": "2026-08-20", **column_value}])
rows = analysis_svc.get_trends(metric, user["id"])
assert len(rows) == 1 and rows[0]["value"] is not None
def test_unknown_metric_falls_back_to_steps(self, seed_health, user):
seed_health([{"date": "2026-08-20", "steps": 5000}])
assert analysis_svc.get_trends("bogus", user["id"]) == [
{"date": "2026-08-20", "value": 5000}
]
def test_unknown_metric_cannot_inject_sql(self, seed_health, user):
"""The metric name indexes a whitelist; it is never interpolated raw."""
seed_health([{"date": "2026-08-20", "steps": 5000}])
rows = analysis_svc.get_trends(
"steps FROM health_data; DROP TABLE health_data --", user["id"]
)
assert rows == [{"date": "2026-08-20", "value": 5000}]
def test_nulls_excluded(self, seed_health, user):
seed_health([
{"date": "2026-08-20", "steps": 5000},
{"date": "2026-08-21"},
])
assert len(analysis_svc.get_trends("steps", user["id"])) == 1
def test_date_range_filter(self, seed_health, user):
seed_health([
{"date": "2026-08-20", "steps": 1},
{"date": "2026-08-21", "steps": 2},
{"date": "2026-08-22", "steps": 3},
])
rows = analysis_svc.get_trends(
"steps", user["id"], "2026-08-21", "2026-08-21"
)
assert rows == [{"date": "2026-08-21", "value": 2}]
# --- recommendations --------------------------------------------------------
class TestNoData:
def test_returns_a_single_guidance_item(self, db, user):
recs = analysis_svc.get_recommendations(user["id"])
assert len(recs) == 1
assert recs[0]["id"] == "no-data"
assert recs[0]["priority"] == "low"
class TestStepsRule:
def test_fires_below_8000(self, seed_health, user):
seed_health(days(5, steps=6000))
assert "steps" in ids(analysis_svc.get_recommendations(user["id"]))
def test_silent_at_or_above_8000(self, seed_health, user):
seed_health(days(5, steps=8000))
assert "steps" not in ids(analysis_svc.get_recommendations(user["id"]))
def test_priority_is_medium(self, seed_health, user):
seed_health(days(5, steps=6000))
rec = next(
r for r in analysis_svc.get_recommendations(user["id"]) if r["id"] == "steps"
)
assert rec["priority"] == "medium"
assert rec["basedOn"] == ["steps"]
def test_averages_across_days_not_per_day(self, seed_health, user):
# 4000 and 12000 average to 8000 -> rule must NOT fire.
seed_health([
{"date": "2026-08-01", "steps": 4000},
{"date": "2026-08-02", "steps": 12000},
])
assert "steps" not in ids(analysis_svc.get_recommendations(user["id"]))
class TestSleepRule:
def test_fires_below_7_hours(self, seed_health, user):
seed_health(days(5, sleep_duration=6))
assert "sleep" in ids(analysis_svc.get_recommendations(user["id"]))
def test_silent_at_7_hours(self, seed_health, user):
seed_health(days(5, sleep_duration=7))
assert "sleep" not in ids(analysis_svc.get_recommendations(user["id"]))
def test_priority_is_high(self, seed_health, user):
seed_health(days(5, sleep_duration=5))
rec = next(
r for r in analysis_svc.get_recommendations(user["id"]) if r["id"] == "sleep"
)
assert rec["priority"] == "high"
def test_days_without_sleep_do_not_drag_the_average_down(self, seed_health, user):
"""Nights with no sleep record must be excluded, not counted as zero."""
seed_health([
{"date": "2026-08-01", "sleep_duration": 8},
{"date": "2026-08-02", "steps": 5000}, # no sleep recorded
])
assert "sleep" not in ids(analysis_svc.get_recommendations(user["id"]))
class TestStressRule:
def test_fires_above_50(self, seed_health, user):
seed_health(days(5, stress=60))
assert "stress" in ids(analysis_svc.get_recommendations(user["id"]))
def test_silent_at_50(self, seed_health, user):
seed_health(days(5, stress=50))
assert "stress" not in ids(analysis_svc.get_recommendations(user["id"]))
class TestRestingHeartRateRule:
def test_fires_above_65(self, seed_health, user):
seed_health(days(5, heart_rate=70))
assert "rhr" in ids(analysis_svc.get_recommendations(user["id"]))
def test_silent_at_65(self, seed_health, user):
seed_health(days(5, heart_rate=65))
assert "rhr" not in ids(analysis_svc.get_recommendations(user["id"]))
class TestHrvRule:
def test_fires_below_40(self, seed_health, user):
seed_health(days(5, hrv=30))
assert "hrv" in ids(analysis_svc.get_recommendations(user["id"]))
def test_silent_at_40(self, seed_health, user):
seed_health(days(5, hrv=40))
assert "hrv" not in ids(analysis_svc.get_recommendations(user["id"]))
class TestHealthyUser:
def test_all_good_returns_the_positive_message(self, seed_health, user):
seed_health(days(5, steps=10000, sleep_duration=8, stress=30,
heart_rate=55, hrv=60))
recs = analysis_svc.get_recommendations(user["id"])
assert ids(recs) == {"good"}
class TestOrderingAndWindow:
def test_sorted_high_medium_low(self, seed_health, user):
seed_health(days(5, steps=6000, sleep_duration=5, hrv=30))
order = {"high": 0, "medium": 1, "low": 2}
priorities = [r["priority"] for r in analysis_svc.get_recommendations(user["id"])]
assert priorities == sorted(priorities, key=lambda p: order[p])
assert priorities[0] == "high"
def test_only_the_last_14_days_count(self, seed_health, user):
"""20 lazy days then 14 active ones: the old days must not pull it down."""
old = [{"date": f"2026-07-{d:02d}", "steps": 1000} for d in range(1, 21)]
recent = [{"date": f"2026-08-{d:02d}", "steps": 12000} for d in range(1, 15)]
seed_health(old + recent)
assert "steps" not in ids(analysis_svc.get_recommendations(user["id"]))
def test_multiple_rules_can_fire_together(self, seed_health, user):
seed_health(days(5, steps=5000, sleep_duration=5, stress=70, heart_rate=75))
assert {"steps", "sleep", "stress", "rhr"} <= ids(
analysis_svc.get_recommendations(user["id"])
)
class TestEndpoints:
def test_trends_requires_auth(self, client):
assert client.get("/api/analysis/trends").status_code == 401
def test_recommendations_requires_auth(self, client):
assert client.get("/api/analysis/recommendations").status_code == 401
def test_trends_endpoint(self, client, auth, seed_health):
seed_health(days(3, steps=5000))
r = client.get("/api/analysis/trends?metricType=steps", headers=auth)
assert r.status_code == 200 and len(r.get_json()) == 3
def test_recommendations_endpoint(self, client, auth, seed_health):
seed_health(days(3, steps=5000))
r = client.get("/api/analysis/recommendations", headers=auth)
assert r.status_code == 200
assert "steps" in {x["id"] for x in r.get_json()}

233
backend/tests/test_auth.py Normal file
View File

@@ -0,0 +1,233 @@
"""Unit tests for password hashing, JWT handling, and the auth endpoints."""
import datetime
import hashlib
import os
import jwt
import pytest
import auth
from config import JWT_SECRET
# --- password hashing -------------------------------------------------------
class TestPasswordHashing:
def test_hash_is_verifiable(self):
stored = auth.hash_password("correct horse")
assert auth.verify_password("correct horse", stored) is True
def test_wrong_password_rejected(self):
stored = auth.hash_password("correct horse")
assert auth.verify_password("wrong horse", stored) is False
def test_salt_makes_hashes_unique(self):
a = auth.hash_password("same")
b = auth.hash_password("same")
assert a != b, "identical passwords must not produce identical hashes"
def test_hash_format_is_self_describing(self):
stored = auth.hash_password("pw")
prefix, iterations, salt, digest = stored.split("$")
assert prefix == auth.PBKDF2_PREFIX
assert int(iterations) == auth.PBKDF2_ITERATIONS
assert len(bytes.fromhex(salt)) == 16
assert len(bytes.fromhex(digest)) == 64
def test_password_never_appears_in_hash(self):
stored = auth.hash_password("supersecret")
assert "supersecret" not in stored
@pytest.mark.parametrize(
"stored",
["", None, "garbage", "pbkdf2_sha256$notanint$aa$bb", "nothex:nothex"],
)
def test_malformed_hashes_rejected_not_raised(self, stored):
assert auth.verify_password("anything", stored) is False
def test_hashing_does_not_require_scrypt(self, monkeypatch):
"""Regression: macOS system Python (LibreSSL) has no hashlib.scrypt.
Registration used to raise AttributeError -> HTTP 500 on those builds.
"""
monkeypatch.delattr(hashlib, "scrypt", raising=False)
stored = auth.hash_password("pw")
assert auth.verify_password("pw", stored) is True
def test_unicode_password(self):
stored = auth.hash_password("密码🔒")
assert auth.verify_password("密码🔒", stored) is True
assert auth.verify_password("密码", stored) is False
@pytest.mark.skipif(
not hasattr(hashlib, "scrypt"), reason="interpreter built without scrypt"
)
def test_legacy_scrypt_hash_still_verifies(self):
salt = os.urandom(16)
digest = hashlib.scrypt(
b"legacy", salt=salt, n=16384, r=8, p=1, dklen=64
).hex()
assert auth.verify_password("legacy", f"{salt.hex()}:{digest}") is True
assert auth.verify_password("nope", f"{salt.hex()}:{digest}") is False
# --- JWT --------------------------------------------------------------------
class TestTokens:
def test_sign_and_verify_roundtrip(self):
token = auth.sign_token("user-123")
assert auth.verify_token(token) == {"user_id": "user-123"}
def test_expired_token_rejected(self):
past = datetime.datetime.utcnow() - datetime.timedelta(days=1)
token = jwt.encode(
{"sub": "u", "iat": past, "exp": past}, JWT_SECRET, algorithm="HS256"
)
with pytest.raises(jwt.ExpiredSignatureError):
auth.verify_token(token)
def test_token_signed_with_other_secret_rejected(self):
token = jwt.encode({"sub": "u"}, "a-different-secret", algorithm="HS256")
with pytest.raises(jwt.InvalidTokenError):
auth.verify_token(token)
def test_tampered_token_rejected(self):
token = auth.sign_token("user-123")
head, payload, sig = token.split(".")
with pytest.raises(jwt.InvalidTokenError):
auth.verify_token(f"{head}.{payload}.{sig[:-2]}xx")
# --- register ---------------------------------------------------------------
class TestRegister:
def test_returns_201_and_token(self, client):
r = client.post(
"/api/auth/register",
json={
"email": "a@example.com",
"garminEmail": "g@example.com",
"garminPassword": "pw123456",
},
)
assert r.status_code == 201
body = r.get_json()
assert body["email"] == "a@example.com"
assert auth.verify_token(body["token"])["user_id"] == body["id"]
def test_duplicate_email_conflicts(self, client, user):
r = client.post(
"/api/auth/register",
json={
"email": user["email"],
"garminEmail": "other@example.com",
"garminPassword": "pw123456",
},
)
assert r.status_code == 409
@pytest.mark.parametrize(
"payload",
[
{},
{"email": "a@example.com"},
{"email": "a@example.com", "garminEmail": "g@example.com"},
{"email": "", "garminEmail": "g@example.com", "garminPassword": "x"},
],
)
def test_missing_fields_rejected(self, client, payload):
assert client.post("/api/auth/register", json=payload).status_code == 400
def test_password_stored_only_as_hash(self, client, db):
client.post(
"/api/auth/register",
json={
"email": "h@example.com",
"garminEmail": "g@example.com",
"garminPassword": "plaintext-secret",
},
)
row = db.query_one(
"SELECT garmin_password_hash FROM users WHERE email = ?", ["h@example.com"]
)
assert "plaintext-secret" not in row["garmin_password_hash"]
assert auth.verify_password("plaintext-secret", row["garmin_password_hash"])
# --- login ------------------------------------------------------------------
class TestLogin:
def test_valid_credentials(self, client, user):
r = client.post(
"/api/auth/login",
json={"email": user["email"], "password": user["password"]},
)
assert r.status_code == 200
assert r.get_json()["id"] == user["id"]
def test_wrong_password(self, client, user):
r = client.post(
"/api/auth/login", json={"email": user["email"], "password": "nope"}
)
assert r.status_code == 401
def test_unknown_email(self, client):
r = client.post(
"/api/auth/login", json={"email": "ghost@example.com", "password": "pw"}
)
assert r.status_code == 401
def test_error_does_not_reveal_which_field_was_wrong(self, client, user):
unknown = client.post(
"/api/auth/login", json={"email": "ghost@example.com", "password": "pw"}
).get_json()
bad_pw = client.post(
"/api/auth/login", json={"email": user["email"], "password": "nope"}
).get_json()
assert unknown == bad_pw, "responses must not distinguish the two cases"
# --- require_auth -----------------------------------------------------------
class TestRequireAuth:
def test_missing_header(self, client):
assert client.get("/api/health/summary").status_code == 401
@pytest.mark.parametrize(
"header",
["", "Bearer", "Token abc", "bearer abc", "Bearer not.a.jwt"],
)
def test_malformed_headers(self, client, header):
r = client.get("/api/health/summary", headers={"Authorization": header})
assert r.status_code == 401
def test_expired_token_gets_401_not_500(self, client):
past = datetime.datetime.utcnow() - datetime.timedelta(days=1)
token = jwt.encode(
{"sub": "u", "iat": past, "exp": past}, JWT_SECRET, algorithm="HS256"
)
r = client.get(
"/api/health/summary", headers={"Authorization": f"Bearer {token}"}
)
assert r.status_code == 401
assert "expired" in r.get_json()["error"]
def test_valid_token_passes(self, client, auth):
assert client.get("/api/health/summary", headers=auth).status_code == 200
class TestLogoutAndRefresh:
def test_logout_clears_stored_token(self, client, auth, user, db):
assert client.post("/api/auth/logout", headers=auth).status_code == 200
row = db.query_one("SELECT jwt_token FROM users WHERE id = ?", [user["id"]])
assert row["jwt_token"] is None
def test_refresh_returns_usable_token(self, client, auth):
r = client.post("/api/auth/refresh", headers=auth)
assert r.status_code == 200
new_token = r.get_json()["token"]
assert (
client.get(
"/api/health/summary",
headers={"Authorization": f"Bearer {new_token}"},
).status_code
== 200
)
def test_refresh_requires_auth(self, client):
assert client.post("/api/auth/refresh").status_code == 401

View File

@@ -0,0 +1,282 @@
"""
Unit tests for the web-driven two-factor Garmin login.
No network and no real library: a stub Garmin/garth pair stands in, and its
`prompt_mfa` callback is invoked exactly the way garth invokes it — blocking,
mid-login — because that blocking behaviour is the whole reason this flow
needs a background thread and a database rendezvous.
"""
import time
import pytest
from services import garmin as garmin_svc
from services import garmin_auth
def wait_for(predicate, timeout=8.0, interval=0.05):
"""Poll until the background thread reaches the expected state."""
deadline = time.time() + timeout
while time.time() < deadline:
value = predicate()
if value:
return value
time.sleep(interval)
return None
def status_of(session_id):
row = garmin_auth.get_session(session_id)
return row["status"] if row else None
def wait_status(session_id, *wanted, timeout=8.0):
return wait_for(lambda: status_of(session_id) in wanted and status_of(session_id),
timeout=timeout)
class StubGarth:
profile = {"displayName": "Tester", "fullName": "Test User"}
def __init__(self, needs_mfa=True, accept_code="123456", fail_login=False):
self.needs_mfa = needs_mfa
self.accept_code = accept_code
self.fail_login = fail_login
self.seen_code = None
self.logged_in_with = None
def login(self, email, password, prompt_mfa=None):
self.logged_in_with = (email, password)
if self.fail_login:
raise RuntimeError("401 Unauthorized")
if self.needs_mfa:
# garth calls this synchronously, in the middle of the login.
self.seen_code = prompt_mfa()
if self.seen_code != self.accept_code:
raise RuntimeError("验证码错误")
def dumps(self):
return "token-blob"
class StubGarmin:
"""Class factory: `make()` returns something usable as `Garmin`."""
last = None
@classmethod
def make(cls, **garth_kwargs):
def factory(is_cn=False, **_):
instance = cls()
instance.garth = StubGarth(**garth_kwargs)
cls.last = instance
return instance
return lambda: factory
@pytest.fixture(autouse=True)
def _fast_polling(monkeypatch):
"""Keep the rendezvous poll short so tests stay quick."""
monkeypatch.setattr(garmin_auth, "POLL_INTERVAL_SECONDS", 0.05)
monkeypatch.setattr(garmin_auth, "CODE_WAIT_SECONDS", 5)
class TestMfaFlow:
def test_login_parks_waiting_for_a_code(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
assert wait_status(sid, "awaiting_code") == "awaiting_code"
def test_submitting_the_code_completes_the_login(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
ok, _ = garmin_auth.submit_code(sid, user["id"], "123456")
assert ok is True
assert wait_status(sid, "done", "failed") == "done"
def test_token_is_saved_on_success(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
garmin_auth.submit_code(sid, user["id"], "123456")
wait_status(sid, "done", "failed")
assert garmin_svc.has_token(user["id"]) is True
assert garmin_svc.load_token(user["id"]) == "token-blob"
def test_the_code_reaches_garth(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
garmin_auth.submit_code(sid, user["id"], "123456")
wait_status(sid, "done", "failed")
assert StubGarmin.last.garth.seen_code == "123456"
def test_account_without_mfa_completes_without_a_code(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw",
import_garmin=StubGarmin.make(needs_mfa=False),
)
assert wait_status(sid, "done", "failed") == "done"
assert garmin_svc.has_token(user["id"]) is True
def test_wrong_code_fails_with_a_reason(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
garmin_auth.submit_code(sid, user["id"], "000000")
assert wait_status(sid, "done", "failed") == "failed"
assert "验证码错误" in garmin_auth.get_session(sid)["error"]
assert garmin_svc.has_token(user["id"]) is False
def test_bad_password_fails_before_any_code(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "wrong",
import_garmin=StubGarmin.make(fail_login=True),
)
assert wait_status(sid, "failed") == "failed"
assert "401" in garmin_auth.get_session(sid)["error"]
def test_timeout_when_no_code_arrives(self, db, user, monkeypatch):
monkeypatch.setattr(garmin_auth, "CODE_WAIT_SECONDS", 0.2)
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
assert wait_status(sid, "failed") == "failed"
assert "超时" in garmin_auth.get_session(sid)["error"]
class TestRendezvousIsNotInMemory:
"""The handoff must survive the code arriving on a different worker, so it
goes through the database rather than process memory."""
def test_code_written_directly_to_the_row_is_picked_up(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
# Exactly what another worker's request would do: write the row.
db.execute(
"UPDATE garmin_mfa_sessions SET code = ? WHERE id = ?", ["123456", sid]
)
assert wait_status(sid, "done", "failed") == "done"
def test_cancelling_releases_the_parked_thread(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
garmin_auth.cancel(sid, user["id"])
# The row is gone, so the waiting thread must stop rather than spin
# until its timeout.
assert wait_for(lambda: garmin_auth.get_session(sid) is None)
class TestSessionIsolation:
def test_another_users_session_is_not_readable(self, db, user, client):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
other = client.post(
"/api/auth/register",
json={"email": "o@example.com", "garminEmail": "og@example.com",
"garminPassword": "pw123456"},
).get_json()
assert garmin_auth.get_session(sid, other["id"]) is None
def test_another_user_cannot_submit_a_code(self, db, user, client):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
other = client.post(
"/api/auth/register",
json={"email": "o2@example.com", "garminEmail": "og2@example.com",
"garminPassword": "pw123456"},
).get_json()
ok, _ = garmin_auth.submit_code(sid, other["id"], "123456")
assert ok is False
def test_unknown_session_is_refused(self, db, user):
ok, msg = garmin_auth.submit_code("no-such-session", user["id"], "123456")
assert ok is False
assert "不存在" in msg
class TestPasswordHandling:
def test_password_is_never_written_to_the_session_row(self, db, user):
sid = garmin_auth.start_login(
user["id"], "hunter2@example.com", "SuperSecret123",
import_garmin=StubGarmin.make(),
)
wait_status(sid, "awaiting_code")
row = garmin_auth.get_session(sid)
assert "SuperSecret123" not in str(dict(row))
class TestEndpoints:
def test_all_require_auth(self, client):
assert client.post("/api/garmin/login", json={}).status_code == 401
assert client.get("/api/garmin/login-status?session=x").status_code == 401
assert client.post("/api/garmin/mfa", json={}).status_code == 401
def test_login_requires_a_password(self, client, auth):
r = client.post("/api/garmin/login", headers=auth, json={})
assert r.status_code == 400
def test_login_returns_a_session(self, client, auth, monkeypatch):
monkeypatch.setattr(
garmin_auth, "start_login", lambda *a, **k: "session-123"
)
r = client.post(
"/api/garmin/login", headers=auth, json={"garminPassword": "pw"}
)
assert r.status_code == 202
assert r.get_json()["session"] == "session-123"
def test_status_of_unknown_session_is_404(self, client, auth):
assert client.get(
"/api/garmin/login-status?session=nope", headers=auth
).status_code == 404
def test_mfa_requires_both_fields(self, client, auth):
assert client.post(
"/api/garmin/mfa", headers=auth, json={"session": "x"}
).status_code == 400
def test_full_flow_through_http(self, client, auth, user, db, monkeypatch):
monkeypatch.setattr(
garmin_svc, "_import_garmin", StubGarmin.make()
)
r = client.post(
"/api/garmin/login", headers=auth, json={"garminPassword": "pw"}
)
sid = r.get_json()["session"]
assert wait_status(sid, "awaiting_code") == "awaiting_code"
assert client.get(
f"/api/garmin/login-status?session={sid}", headers=auth
).get_json()["status"] == "awaiting_code"
r = client.post(
"/api/garmin/mfa", headers=auth, json={"session": sid, "code": "123456"}
)
assert r.status_code == 200
assert wait_status(sid, "done", "failed") == "done"
assert client.get("/api/garmin/auth-status", headers=auth).get_json()[
"hasToken"] is True

View File

@@ -0,0 +1,604 @@
"""
Unit tests for the Garmin sync service.
A stub client stands in for `garminconnect`, so the suite runs without the
library, without credentials and without touching Garmin.
The stub mirrors the real 0.2.8 API shapes on purpose — the original code
called `get_activities(date)` when that method actually takes `(start, limit)`
pagination arguments, a mistake that only surfaces once something exercises it.
"""
import datetime
import pytest
from services import garmin as garmin_svc
from services import health as health_svc
def day(offset=0):
return (datetime.date.today() - datetime.timedelta(days=offset)).isoformat()
def summary(steps=8000, rhr=60, stress=40, kcal=2200):
return {
"totalSteps": steps,
"restingHeartRate": rhr,
"averageStressLevel": stress,
"totalKilocalories": kcal,
}
def sleep(hours=7.5, score=82):
return {
"dailySleepDTO": {
"sleepTimeSeconds": int(hours * 3600),
"sleepScores": {"overall": {"value": score}},
}
}
def hrv(value=48):
return {"hrvSummary": {"lastNightAvg": value}}
def activity(activity_id=1001, type_key="running", duration=1800):
return {
"activityId": activity_id,
"activityType": {"typeKey": type_key},
"startTimeLocal": f"{day()}T07:00:00",
"duration": duration,
"distance": 5000.0,
"calories": 320.0,
"averageHR": 145,
"maxHR": 168,
}
class StubClient:
"""Stands in for garminconnect.Garmin, recording how it was called."""
def __init__(self, summaries=None, sleeps=None, hrvs=None, activities=None,
fail_days=(), fail_activities=False):
self._summaries = summaries if summaries is not None else {}
self._sleeps = sleeps if sleeps is not None else {}
self._hrvs = hrvs if hrvs is not None else {}
self._activities = activities if activities is not None else []
self._fail_days = set(fail_days)
self._fail_activities = fail_activities
self.calls = []
def get_user_summary(self, cdate):
self.calls.append(("summary", cdate))
if cdate in self._fail_days:
raise RuntimeError(f"upstream error for {cdate}")
return self._summaries.get(cdate, summary())
def get_sleep_data(self, cdate):
self.calls.append(("sleep", cdate))
return self._sleeps.get(cdate, sleep())
def get_hrv_data(self, cdate):
self.calls.append(("hrv", cdate))
return self._hrvs.get(cdate, hrv())
def get_activities_by_date(self, startdate, enddate, activitytype=None):
self.calls.append(("activities", startdate, enddate))
if self._fail_activities:
raise RuntimeError("activities endpoint down")
return self._activities
CREDS = {"garminEmail": "g@example.com", "garminPassword": "pw"}
class TestHappyPath:
def test_reports_success(self, db, user):
out = garmin_svc.sync_data(user["id"], CREDS, days=3, client=StubClient())
assert out["status"] == "success"
assert out["recordsSynced"] == 3
def test_stores_the_days(self, db, user):
garmin_svc.sync_data(user["id"], CREDS, days=3, client=StubClient())
rows = health_svc.get_summary(user["id"])
assert len(rows) == 3
def test_maps_every_metric(self, db, user):
client = StubClient(
summaries={day(): summary(steps=9500, rhr=57, stress=33, kcal=2450)},
sleeps={day(): sleep(hours=8.0, score=91)},
hrvs={day(): hrv(52)},
)
garmin_svc.sync_data(user["id"], CREDS, days=1, client=client)
row = health_svc.get_summary(user["id"])[0]
assert row["steps"] == 9500
assert row["heartRate"] == 57
assert row["stress"] == 33
assert row["caloriesBurned"] == 2450
assert row["heartRateVariability"] == 52
assert row["sleep"]["duration"] == 8.0
assert row["sleep"]["quality"] == 91
def test_sleep_and_hrv_come_from_their_own_endpoints(self, db, user):
"""Regression: both live outside get_user_summary. Reading only the
summary recorded every night as having no sleep data."""
client = StubClient()
garmin_svc.sync_data(user["id"], CREDS, days=1, client=client)
kinds = {c[0] for c in client.calls}
assert "sleep" in kinds
assert "hrv" in kinds
def test_seconds_are_converted_to_hours(self, db, user):
client = StubClient(sleeps={day(): sleep(hours=6.5)})
garmin_svc.sync_data(user["id"], CREDS, days=1, client=client)
assert health_svc.get_summary(user["id"])[0]["sleep"]["duration"] == 6.5
class TestActivities:
def test_fetched_by_date_range_in_one_call(self, db, user):
"""Regression: the old code called get_activities(date), but that
method takes (start, limit) pagination arguments, not a date."""
client = StubClient(activities=[activity()])
garmin_svc.sync_data(user["id"], CREDS, days=7, client=client)
activity_calls = [c for c in client.calls if c[0] == "activities"]
assert len(activity_calls) == 1, "one range call, not one call per day"
_, start, end = activity_calls[0]
assert start == day(6) and end == day(0)
def test_stored_with_fields_mapped(self, db, user):
garmin_svc.sync_data(
user["id"], CREDS, days=1, client=StubClient(activities=[activity()])
)
rows = health_svc.get_activities(user["id"])
assert len(rows) == 1
assert rows[0]["activity_type"] == "running"
assert rows[0]["heart_rate_average"] == 145
def test_count_is_reported(self, db, user):
client = StubClient(activities=[activity(1), activity(2)])
out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=client)
assert out["activitiesSynced"] == 2
def test_resync_does_not_duplicate(self, db, user):
"""Garmin activity ids are stable, so a re-synced window must skip
what is already stored."""
client = StubClient(activities=[activity(1001)])
garmin_svc.sync_data(user["id"], CREDS, days=1, client=client)
out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=client)
assert len(health_svc.get_activities(user["id"])) == 1
assert out["activitiesSynced"] == 0
def test_end_time_derived_from_duration(self, db, user):
client = StubClient(activities=[activity(duration=1800)])
garmin_svc.sync_data(user["id"], CREDS, days=1, client=client)
row = health_svc.get_activities(user["id"])[0]
assert row["start_time"] != row["end_time"]
def test_failure_does_not_lose_the_daily_data(self, db, user):
out = garmin_svc.sync_data(
user["id"], CREDS, days=2, client=StubClient(fail_activities=True)
)
assert out["status"] == "success"
assert len(health_svc.get_summary(user["id"])) == 2
class TestResync:
def test_same_day_is_updated_not_duplicated(self, db, user):
garmin_svc.sync_data(
user["id"], CREDS, days=1,
client=StubClient(summaries={day(): summary(steps=5000)}),
)
garmin_svc.sync_data(
user["id"], CREDS, days=1,
client=StubClient(summaries={day(): summary(steps=9000)}),
)
rows = health_svc.get_summary(user["id"])
assert len(rows) == 1
assert rows[0]["steps"] == 9000
class TestPartialAndTotalFailure:
def test_one_bad_day_is_skipped_not_fatal(self, db, user):
client = StubClient(fail_days=[day(1)])
out = garmin_svc.sync_data(user["id"], CREDS, days=3, client=client)
assert out["status"] == "success"
assert out["recordsSynced"] == 2
assert "跳过" in out["message"]
def test_every_day_failing_is_reported_as_an_error(self, db, user):
"""A systemic failure reported as a clean success would hide it."""
client = StubClient(fail_days=[day(0), day(1), day(2)])
out = garmin_svc.sync_data(user["id"], CREDS, days=3, client=client)
assert out["status"] == "error"
assert out["recordsSynced"] == 0
def test_login_failure_is_reported(self, db, user, monkeypatch):
def boom(_creds, _uid=None):
raise RuntimeError("401 Unauthorized")
monkeypatch.setattr(garmin_svc, "_connect", boom)
out = garmin_svc.sync_data(user["id"], CREDS, days=1)
assert out["status"] == "error"
assert "401" in out["message"]
def test_days_without_data_are_not_stored(self, db, user):
"""Garmin returns all-None for a day it has nothing for; an empty row
would just have to be filtered back out by every read endpoint."""
client = StubClient(
summaries={day(): {}}, sleeps={day(): {}}, hrvs={day(): {}}
)
out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=client)
assert out["recordsSynced"] == 0
assert health_svc.get_summary(user["id"]) == []
class TestSyncStatus:
def test_idle_before_any_sync(self, db, user):
assert garmin_svc.get_sync_status(user["id"])["status"] == "idle"
def test_success_leaves_status_idle(self, db, user):
garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
status = garmin_svc.get_sync_status(user["id"])
assert status["status"] == "idle"
assert status["recordsSynced"] == 1
assert status["lastSyncTime"]
def test_total_failure_leaves_status_error(self, db, user):
garmin_svc.sync_data(
user["id"], CREDS, days=2, client=StubClient(fail_days=[day(0), day(1)])
)
status = garmin_svc.get_sync_status(user["id"])
assert status["status"] == "error"
assert status["lastError"]
def test_a_later_success_clears_the_error(self, db, user):
garmin_svc.sync_data(
user["id"], CREDS, days=1, client=StubClient(fail_days=[day(0)])
)
garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
status = garmin_svc.get_sync_status(user["id"])
assert status["status"] == "idle"
assert not status["lastError"]
class TestEndpoint:
def test_requires_auth(self, client):
assert client.post("/api/garmin/sync", json={}).status_code == 401
def test_missing_password_is_refused_with_a_reason(self, client, auth):
r = client.post("/api/garmin/sync", headers=auth, json={})
assert r.status_code == 400
assert "garminPassword" in r.get_json()["message"]
def test_status_endpoint(self, client, auth):
r = client.get("/api/garmin/status", headers=auth)
assert r.status_code == 200
assert r.get_json()["status"] == "idle"
class TestTokenStore:
"""Tokens are what make unattended sync possible on an MFA-protected
account: the web worker has no stdin to type a code into."""
def test_absent_before_any_login(self, db, user):
assert garmin_svc.has_token(user["id"]) is False
def test_saved_token_round_trips(self, db, user):
garmin_svc.save_token(user["id"], "token-blob", "g@example.com")
assert garmin_svc.has_token(user["id"]) is True
assert garmin_svc.load_token(user["id"]) == "token-blob"
def test_re_login_replaces_rather_than_accumulates(self, db, user):
garmin_svc.save_token(user["id"], "first", "g@example.com")
garmin_svc.save_token(user["id"], "second", "g@example.com")
rows = db.query_all(
"SELECT * FROM garmin_tokens WHERE user_id = ?", [user["id"]]
)
assert len(rows) == 1
assert garmin_svc.load_token(user["id"]) == "second"
def test_tokens_are_per_user(self, db, user, client):
garmin_svc.save_token(user["id"], "mine", "g@example.com")
other = client.post(
"/api/auth/register",
json={"email": "o@example.com", "garminEmail": "og@example.com",
"garminPassword": "pw123456"},
).get_json()
assert garmin_svc.has_token(other["id"]) is False
class TestMfaHandling:
def test_eof_from_the_mfa_prompt_becomes_an_actionable_error(
self, db, user, monkeypatch
):
"""garth's default MFA prompt calls input(); with no stdin that raises
a bare EOFError, which says nothing about what to do about it."""
class StubGarth:
def loads(self, s): pass
def refresh_oauth2(self): pass
class StubGarmin:
def __init__(self, *a, **k):
self.garth = StubGarth()
self.username = None
self.password = None
def login(self, *a, **k):
raise EOFError("EOF when reading a line")
monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin)
with pytest.raises(garmin_svc.MFARequired, match="两步验证"):
garmin_svc._connect(CREDS, user["id"])
def test_sync_flags_mfa_so_the_ui_can_explain(self, db, user, monkeypatch):
def boom(_creds, _uid=None):
raise garmin_svc.MFARequired("需要两步验证")
monkeypatch.setattr(garmin_svc, "_connect", boom)
out = garmin_svc.sync_data(user["id"], CREDS, days=1)
assert out["status"] == "error"
assert out["mfaRequired"] is True
def test_ordinary_failures_are_not_flagged_as_mfa(self, db, user, monkeypatch):
def boom(_creds, _uid=None):
raise RuntimeError("401 Unauthorized")
monkeypatch.setattr(garmin_svc, "_connect", boom)
assert garmin_svc.sync_data(user["id"], CREDS, days=1)["mfaRequired"] is False
def test_stored_token_is_used_instead_of_logging_in(self, db, user, monkeypatch):
garmin_svc.save_token(user["id"], "saved-blob", "g@example.com")
loaded = {}
class StubGarth:
profile = {"displayName": "Tester"}
def loads(self, s):
loaded["blob"] = s
def refresh_oauth2(self):
loaded["refreshed"] = True
class StubGarmin:
def __init__(self, *a, **k):
self.garth = StubGarth()
def login(self, *a, **k):
raise AssertionError("must not log in when a token exists")
monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin)
garmin_svc._connect({}, user["id"])
assert loaded["blob"] == "saved-blob"
assert loaded["refreshed"] is True
def test_no_token_and_no_password_is_refused_clearly(self, db, user, monkeypatch):
class StubGarmin:
def __init__(self, *a, **k):
self.garth = None
monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin)
with pytest.raises(RuntimeError, match="缺少 Garmin 密码"):
garmin_svc._connect({}, user["id"])
class TestAuthStatusEndpoint:
def test_requires_auth(self, client):
assert client.get("/api/garmin/auth-status").status_code == 401
def test_reports_false_then_true(self, client, auth, user, db):
assert client.get("/api/garmin/auth-status", headers=auth).get_json()[
"hasToken"] is False
garmin_svc.save_token(user["id"], "blob", "g@example.com")
assert client.get("/api/garmin/auth-status", headers=auth).get_json()[
"hasToken"] is True
def test_sync_without_password_allowed_once_a_token_exists(
self, client, auth, user, db
):
"""The password field exists only because no token is stored yet."""
garmin_svc.save_token(user["id"], "blob", "g@example.com")
r = client.post("/api/garmin/sync", headers=auth, json={})
assert r.status_code != 400
class TestApiUserAgent:
"""Regression: garth keeps its browser User-Agent after login, and the
Garmin data API answers that UA with HTTP 200 and an empty array — every
endpoint silently returns nothing."""
def test_a_browser_user_agent_is_not_used_for_the_api(self):
assert "Mozilla" not in garmin_svc.API_USER_AGENT
assert "iPhone" not in garmin_svc.API_USER_AGENT
def test_header_is_swapped_after_loading_a_token(self, db, user):
garmin_svc.save_token(user["id"], "blob", "g@example.com")
headers = {"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5)"}
class StubSess:
def __init__(self): self.headers = headers
class StubGarth:
profile = {"displayName": "Tester"}
def __init__(self): self.sess = StubSess()
def loads(self, s): pass
def refresh_oauth2(self): pass
class StubGarmin:
def __init__(self, *a, **k): self.garth = StubGarth()
monkey = pytest.MonkeyPatch()
monkey.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin)
try:
garmin_svc._connect({}, user["id"])
finally:
monkey.undo()
assert headers["User-Agent"] == garmin_svc.API_USER_AGENT
def test_swap_is_harmless_on_a_client_without_a_session(self):
class Bare:
garth = object()
garmin_svc._use_api_user_agent(Bare()) # must not raise
def test_display_name_is_populated(self, db, user):
"""garminconnect builds URLs from display_name; unset sends every
request to '.../None'."""
class StubGarth:
profile = {"displayName": "Tester"}
sess = type("S", (), {"headers": {}})()
def loads(self, s): pass
def refresh_oauth2(self): pass
class StubGarmin:
def __init__(self, *a, **k): self.garth = StubGarth()
garmin_svc.save_token(user["id"], "blob", "g@example.com")
monkey = pytest.MonkeyPatch()
monkey.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin)
try:
client = garmin_svc._connect({}, user["id"])
finally:
monkey.undo()
assert client.display_name == "Tester"
class TestErrorMessagesAreNeverEmpty:
"""Regression: a bare `assert` inside garth raised AssertionError with an
empty str(), which was stored as the sync's reason — a failed sync with a
blank explanation cannot be diagnosed."""
def test_exception_without_text_still_describes_itself(self):
assert garmin_svc.describe(AssertionError()) == "AssertionError"
def test_exception_with_text_keeps_it(self):
assert "boom" in garmin_svc.describe(RuntimeError("boom"))
assert "RuntimeError" in garmin_svc.describe(RuntimeError("boom"))
def test_whitespace_only_text_is_treated_as_empty(self):
assert garmin_svc.describe(ValueError(" ")) == "ValueError"
def test_connect_failure_records_a_non_empty_reason(self, db, user, monkeypatch):
def boom(_creds, _uid=None):
raise AssertionError() # no message at all
monkeypatch.setattr(garmin_svc, "_connect", boom)
out = garmin_svc.sync_data(user["id"], CREDS, days=1)
assert out["status"] == "error"
assert out["message"].strip()
assert garmin_svc.get_sync_status(user["id"])["lastError"].strip()
class TestTimestampNormalisation:
"""Regression: Garmin mixes ISO strings and epoch milliseconds in one
payload. Writing the numeric form to a DATETIME column is rejected, which
failed the entire personal-records batch."""
def test_iso_string_passes_through(self):
assert garmin_svc._to_datetime("2019-10-13T10:10:12.0").startswith(
"2019-10-13T10:10:12"
)
def test_epoch_milliseconds_are_converted(self):
assert garmin_svc._to_datetime(1570961412000).startswith("2019-10-13")
def test_epoch_seconds_are_converted(self):
assert garmin_svc._to_datetime(1570961412).startswith("2019-10-13")
def test_first_usable_value_wins(self):
assert garmin_svc._to_datetime(None, "", "2020-01-01T00:00:00") == (
"2020-01-01T00:00:00"
)
def test_all_empty_gives_none(self):
assert garmin_svc._to_datetime(None, "") is None
def test_nonsense_value_does_not_raise(self):
assert garmin_svc._to_datetime(float("inf")) is None
class TestBackgroundSync:
"""A full backfill runs for ~20 minutes at ~3s per day, so the request
must not block on it and the UI needs progress rather than a spinner."""
def test_progress_is_reported_during_the_run(self, db, user):
garmin_svc.sync_data(user["id"], CREDS, days=10, client=StubClient())
status = garmin_svc.get_sync_status(user["id"])
assert status["progressTotal"] == 10
assert status["progressCurrent"] == 10
def test_progress_total_matches_the_requested_window(self, db, user):
garmin_svc.sync_data(user["id"], CREDS, days=3, client=StubClient())
assert garmin_svc.get_sync_status(user["id"])["progressTotal"] == 3
def test_start_sync_returns_immediately(self, db, user, monkeypatch):
import threading
release = threading.Event()
def slow(uid, creds, days=None, client=None):
release.wait(5)
monkeypatch.setattr(garmin_svc, "sync_data", slow)
out = garmin_svc.start_sync(user["id"], CREDS, days=365)
# Returns before the work finishes.
assert out["status"] == "syncing"
assert out["days"] == 365
assert garmin_svc.get_sync_status(user["id"])["status"] == "syncing"
release.set()
def test_start_sync_marks_total_before_any_work(self, db, user, monkeypatch):
import threading
release = threading.Event()
monkeypatch.setattr(
garmin_svc, "sync_data", lambda *a, **k: release.wait(5)
)
garmin_svc.start_sync(user["id"], CREDS, days=200)
status = garmin_svc.get_sync_status(user["id"])
assert status["progressTotal"] == 200
assert status["progressCurrent"] == 0
release.set()
def test_previous_error_is_cleared_when_a_new_sync_starts(
self, db, user, monkeypatch
):
garmin_svc.sync_data(
user["id"], CREDS, days=1, client=StubClient(fail_days=[day(0)])
)
assert garmin_svc.get_sync_status(user["id"])["lastError"]
import threading
release = threading.Event()
monkeypatch.setattr(garmin_svc, "sync_data", lambda *a, **k: release.wait(5))
garmin_svc.start_sync(user["id"], CREDS, days=7)
assert not garmin_svc.get_sync_status(user["id"])["lastError"]
release.set()
def test_endpoint_returns_202_without_waiting(self, client, auth, user, db, monkeypatch):
garmin_svc.save_token(user["id"], "blob", "g@example.com")
monkeypatch.setattr(garmin_svc, "start_sync", lambda *a, **k: {"status": "syncing", "days": 30})
r = client.post("/api/garmin/sync", headers=auth, json={"days": 30})
assert r.status_code == 202
assert r.get_json()["status"] == "syncing"
def test_days_is_clamped_to_a_sane_range(self, client, auth, user, db, monkeypatch):
garmin_svc.save_token(user["id"], "blob", "g@example.com")
seen = {}
monkeypatch.setattr(
garmin_svc, "start_sync",
lambda uid, creds, days=None: seen.setdefault("days", days) or {"status": "syncing"},
)
client.post("/api/garmin/sync", headers=auth, json={"days": 99999})
assert seen["days"] == 730

View File

@@ -0,0 +1,256 @@
"""Unit tests for the health data service and its endpoints."""
import pytest
from services import health as health_svc
DAYS = [
{"date": "2026-08-20", "steps": 6500, "heart_rate": 70, "hrv": 45,
"sleep_duration": 6, "sleep_quality": 80, "stress": 55, "calories": 260},
{"date": "2026-08-21", "steps": 9000, "heart_rate": 62, "hrv": 46,
"sleep_duration": 8, "sleep_quality": 79, "stress": 40, "calories": 360},
{"date": "2026-08-22", "steps": 7500, "heart_rate": 68, "hrv": 47,
"sleep_duration": 7, "sleep_quality": 78, "stress": 48, "calories": 300},
]
class TestGetSummary:
def test_empty_for_new_user(self, db, user):
assert health_svc.get_summary(user["id"]) == []
def test_returns_all_rows_ordered_by_date(self, seed_health, user):
seed_health(DAYS)
rows = health_svc.get_summary(user["id"])
assert [r["date"] for r in rows] == [
"2026-08-20", "2026-08-21", "2026-08-22"
]
def test_maps_to_camel_case(self, seed_health, user):
seed_health(DAYS)
row = health_svc.get_summary(user["id"])[0]
assert row["heartRate"] == 70
assert row["heartRateVariability"] == 45
assert row["caloriesBurned"] == 260
# Sleep carries stage detail too; the core two must be right.
assert row["sleep"]["duration"] == 6
assert row["sleep"]["quality"] == 80
def test_sleep_is_none_when_absent(self, seed_health, user):
seed_health([{"date": "2026-08-20", "steps": 100}])
assert health_svc.get_summary(user["id"])[0]["sleep"] is None
def test_start_date_filter_is_inclusive(self, seed_health, user):
seed_health(DAYS)
rows = health_svc.get_summary(user["id"], start="2026-08-21")
assert [r["date"] for r in rows] == ["2026-08-21", "2026-08-22"]
def test_end_date_filter_is_inclusive(self, seed_health, user):
seed_health(DAYS)
rows = health_svc.get_summary(user["id"], end="2026-08-21")
assert [r["date"] for r in rows] == ["2026-08-20", "2026-08-21"]
def test_both_bounds(self, seed_health, user):
seed_health(DAYS)
rows = health_svc.get_summary(user["id"], "2026-08-21", "2026-08-21")
assert len(rows) == 1
def test_range_with_no_matches(self, seed_health, user):
seed_health(DAYS)
assert health_svc.get_summary(user["id"], "2027-01-01") == []
def test_scoped_to_the_requesting_user(self, seed_health, user, db):
seed_health(DAYS)
db.execute(
"INSERT INTO users (id, email, garmin_email, garmin_password_hash) "
"VALUES (?,?,?,?)",
["other-user", "other@example.com", "o@example.com", "x"],
)
db.execute(
"INSERT INTO health_data (id, user_id, date, steps) VALUES (?,?,?,?)",
["other-1", "other-user", "2026-08-20", 99999],
)
rows = health_svc.get_summary(user["id"])
assert all(r["steps"] != 99999 for r in rows)
assert len(rows) == 3
class TestMetricReads:
"""Metric endpoints must drop rows where that metric is NULL."""
def test_steps_excludes_nulls(self, seed_health, user):
seed_health(DAYS + [{"date": "2026-08-23", "heart_rate": 60}])
rows = health_svc.get_steps(user["id"])
assert len(rows) == 3
assert all(r["steps"] is not None for r in rows)
def test_heart_rate_excludes_nulls(self, seed_health, user):
seed_health(DAYS + [{"date": "2026-08-23", "steps": 100}])
rows = health_svc.get_heart_rate(user["id"])
assert len(rows) == 3
def test_sleep_excludes_nulls(self, seed_health, user):
seed_health(DAYS + [{"date": "2026-08-23", "steps": 100}])
rows = health_svc.get_sleep(user["id"])
assert len(rows) == 3
assert rows[0] == {"date": "2026-08-20", "duration": 6, "quality": 80}
class TestUpsert:
def test_insert_creates_row(self, db, user):
health_svc.upsert_health_daily(
user["id"], {"date": "2026-08-20", "steps": 5000}
)
rows = health_svc.get_summary(user["id"])
assert len(rows) == 1 and rows[0]["steps"] == 5000
def test_second_upsert_updates_instead_of_duplicating(self, db, user):
health_svc.upsert_health_daily(
user["id"], {"date": "2026-08-20", "steps": 5000}
)
health_svc.upsert_health_daily(
user["id"], {"date": "2026-08-20", "steps": 8000}
)
rows = health_svc.get_summary(user["id"])
assert len(rows) == 1, "re-syncing a day must not duplicate it"
assert rows[0]["steps"] == 8000
def test_upsert_is_deterministic_by_user_and_date(self, db, user):
a = health_svc.upsert_health_daily(user["id"], {"date": "2026-08-20"})
b = health_svc.upsert_health_daily(user["id"], {"date": "2026-08-20"})
assert a == b
def test_missing_metrics_stored_as_null(self, db, user):
health_svc.upsert_health_daily(
user["id"], {"date": "2026-08-20", "steps": 100}
)
row = health_svc.get_summary(user["id"])[0]
assert row["heartRate"] is None
assert row["sleep"] is None
class TestActivities:
def test_insert_and_read_back(self, db, user):
health_svc.insert_activity(
user["id"],
{
"activityType": "running",
"startTime": "2026-08-20T07:00:00",
"endTime": "2026-08-20T07:30:00",
"duration": 1800,
"distance": 5.0,
"calories": 320,
"heartRateAverage": 140,
"heartRateMax": 165,
},
)
rows = health_svc.get_activities(user["id"])
assert len(rows) == 1
assert rows[0]["activity_type"] == "running"
assert rows[0]["distance"] == 5.0
def test_ids_are_unique_per_insert(self, db, user):
base = {
"activityType": "running",
"startTime": "2026-08-20T07:00:00",
"endTime": "2026-08-20T07:30:00",
}
assert health_svc.insert_activity(user["id"], base) != health_svc.insert_activity(
user["id"], base
)
class TestEndpoints:
@pytest.mark.parametrize(
"path",
["/api/health/summary", "/api/health/steps", "/api/health/heart-rate",
"/api/health/sleep", "/api/health/activities"],
)
def test_require_authentication(self, client, path):
assert client.get(path).status_code == 401
@pytest.mark.parametrize(
"path",
["/api/health/summary", "/api/health/steps", "/api/health/heart-rate",
"/api/health/sleep", "/api/health/activities"],
)
def test_return_json_list_when_authenticated(self, client, auth, path):
r = client.get(path, headers=auth)
assert r.status_code == 200
assert isinstance(r.get_json(), list)
def test_summary_reflects_seeded_data(self, client, auth, seed_health):
seed_health(DAYS)
body = client.get("/api/health/summary", headers=auth).get_json()
assert len(body) == 3
def test_query_params_are_applied(self, client, auth, seed_health):
seed_health(DAYS)
body = client.get(
"/api/health/summary?startDate=2026-08-22", headers=auth
).get_json()
assert len(body) == 1
class TestBadgesAndRecords:
"""Badges ("奖励") and personal records are account-wide, not per-day."""
BADGE = {
"id": "1822", "badgeKey": "sleep_30_days", "name": "Sleep Savant",
"categoryId": 3, "difficultyId": 2, "earnedDate": "2026-08-01T10:00:00",
"earnedCount": 1, "points": 5,
}
def test_badge_round_trips(self, db, user):
health_svc.upsert_badge(user["id"], self.BADGE)
rows = health_svc.get_badges(user["id"])
assert len(rows) == 1
assert rows[0]["name"] == "Sleep Savant"
assert rows[0]["badge_key"] == "sleep_30_days"
def test_resync_updates_rather_than_duplicating(self, db, user):
health_svc.upsert_badge(user["id"], self.BADGE)
health_svc.upsert_badge(user["id"], {**self.BADGE, "earnedCount": 2})
rows = health_svc.get_badges(user["id"])
assert len(rows) == 1
assert rows[0]["earned_count"] == 2
def test_badges_are_per_user(self, db, user, client):
health_svc.upsert_badge(user["id"], self.BADGE)
other = client.post(
"/api/auth/register",
json={"email": "b@example.com", "garminEmail": "bg@example.com",
"garminPassword": "pw123456"},
).get_json()
assert health_svc.get_badges(other["id"]) == []
def test_two_users_may_hold_the_same_badge_id(self, db, user, client):
"""The key is (user, badge), so the same Garmin badge on two accounts
must not collide."""
other = client.post(
"/api/auth/register",
json={"email": "c@example.com", "garminEmail": "cg@example.com",
"garminPassword": "pw123456"},
).get_json()
health_svc.upsert_badge(user["id"], self.BADGE)
health_svc.upsert_badge(other["id"], self.BADGE)
assert len(health_svc.get_badges(user["id"])) == 1
assert len(health_svc.get_badges(other["id"])) == 1
def test_personal_record_round_trips(self, db, user):
health_svc.upsert_personal_record(user["id"], {
"id": "2538883970", "typeId": 1, "activityId": "17446848459",
"activityName": "晨跑", "activityType": "running",
"value": 1234.5, "achievedAt": "2026-08-01T07:00:00",
})
rows = health_svc.get_personal_records(user["id"])
assert len(rows) == 1 and rows[0]["activity_name"] == "晨跑"
def test_endpoints_require_auth(self, client):
assert client.get("/api/health/badges").status_code == 401
assert client.get("/api/health/personal-records").status_code == 401
def test_endpoints_return_lists(self, client, auth):
assert isinstance(client.get("/api/health/badges", headers=auth).get_json(), list)
assert isinstance(
client.get("/api/health/personal-records", headers=auth).get_json(), list
)

View File

@@ -0,0 +1,87 @@
"""
Tests for who may create an account.
This matters because the deployment is reachable from the public internet: an
unconditionally open /register would let a stranger sign up and start pulling
health data.
"""
import pytest
def signup(client, email="new@example.com"):
return client.post(
"/api/auth/register",
json={
"email": email,
"garminEmail": "g@example.com",
"garminPassword": "pw123456",
},
)
@pytest.fixture(autouse=True)
def _default_policy(monkeypatch):
monkeypatch.delenv("ALLOW_REGISTRATION", raising=False)
class TestAutoPolicy:
"""Default: open until the first account exists, then closed."""
def test_first_account_is_allowed(self, client, db):
assert signup(client).status_code == 201
def test_second_account_is_refused(self, client, user):
r = signup(client, "stranger@example.com")
assert r.status_code == 403
assert "注册已关闭" in r.get_json()["error"]
def test_refusal_does_not_create_the_account(self, client, user, db):
signup(client, "stranger@example.com")
assert db.query_one(
"SELECT id FROM users WHERE email = ?", ["stranger@example.com"]
) is None
def test_status_reports_open_before_any_signup(self, client, db):
assert client.get("/api/auth/registration-status").get_json()["open"] is True
def test_status_reports_closed_afterwards(self, client, user):
assert client.get("/api/auth/registration-status").get_json()["open"] is False
class TestExplicitPolicies:
def test_true_keeps_it_open_even_with_existing_users(self, client, user, monkeypatch):
monkeypatch.setenv("ALLOW_REGISTRATION", "true")
assert signup(client, "second@example.com").status_code == 201
def test_false_closes_it_even_on_an_empty_instance(self, client, db, monkeypatch):
monkeypatch.setenv("ALLOW_REGISTRATION", "false")
assert signup(client).status_code == 403
def test_policy_is_read_per_request_not_at_import(self, client, db, monkeypatch):
monkeypatch.setenv("ALLOW_REGISTRATION", "false")
assert client.get("/api/auth/registration-status").get_json()["open"] is False
monkeypatch.setenv("ALLOW_REGISTRATION", "true")
assert client.get("/api/auth/registration-status").get_json()["open"] is True
def test_value_is_case_insensitive(self, client, user, monkeypatch):
monkeypatch.setenv("ALLOW_REGISTRATION", "TRUE")
assert signup(client, "second@example.com").status_code == 201
class TestUnaffectedBehaviour:
def test_status_endpoint_needs_no_auth(self, client, db):
"""The login page must be able to ask before anyone is signed in."""
assert client.get("/api/auth/registration-status").status_code == 200
def test_closing_registration_does_not_block_login(self, client, user):
r = client.post(
"/api/auth/login",
json={"email": user["email"], "password": user["password"]},
)
assert r.status_code == 200
def test_duplicate_email_still_reports_409_when_open(
self, client, user, monkeypatch
):
monkeypatch.setenv("ALLOW_REGISTRATION", "true")
assert signup(client, user["email"]).status_code == 409

7
backend/wsgi.py Normal file
View File

@@ -0,0 +1,7 @@
"""WSGI entry point for Gunicorn: `gunicorn wsgi:app -b 0.0.0.0:5000`."""
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run()

View File

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

View File

@@ -1,24 +1,43 @@
import React from 'react';
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';
import Dashboard from './pages/Dashboard';
import DataSync from './pages/DataSync';
import Analysis from './pages/Analysis';
import Trends from './pages/Trends';
import Recommendations from './pages/Recommendations';
import Settings from './pages/Settings';
import Sleep from './pages/Sleep';
import Achievements from './pages/Achievements';
import { FEATURES } from './features';
function App() {
return (
<Router>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/sync" element={<DataSync />} />
<Route path="/analysis" element={<Analysis />} />
<Route path="/recommendations" element={<Recommendations />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Layout>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="*"
element={
<ProtectedRoute>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/sync" element={<DataSync />} />
<Route path="/trends" element={<Trends />} />
<Route path="/sleep" element={<Sleep />} />
<Route path="/achievements" element={<Achievements />} />
{FEATURES.ai && (
<Route path="/recommendations" element={<Recommendations />} />
)}
<Route path="/settings" element={<Settings />} />
</Routes>
</Layout>
</ProtectedRoute>
}
/>
</Routes>
</Router>
);
}

View File

@@ -1,129 +1,93 @@
.layout {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: #f5f5f5;
background: var(--surface-0);
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background: var(--surface-1);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 20;
}
.header-content {
max-width: 1400px;
.header-inner {
max-width: 1180px;
margin: 0 auto;
padding: 0 1.5rem;
display: flex;
align-items: center;
gap: 1.5rem;
height: 56px;
}
.logo {
font-size: 1.8rem;
font-weight: bold;
margin-bottom: 0.5rem;
font-size: 0.95rem;
font-weight: 680;
color: var(--text-primary);
text-decoration: none;
white-space: nowrap;
}
.tagline {
font-size: 0.9rem;
opacity: 0.9;
}
.container {
.nav {
display: flex;
gap: 0.15rem;
flex: 1;
max-width: 1400px;
width: 100%;
margin: 0 auto;
gap: 2rem;
padding: 2rem;
overflow-x: auto;
scrollbar-width: none;
}
.sidebar {
width: 250px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
height: fit-content;
position: sticky;
top: 2rem;
}
.nav-list {
list-style: none;
padding: 0;
}
.nav::-webkit-scrollbar { display: none; }
.nav-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.5rem;
padding: 0.4rem 0.7rem;
border-radius: 7px;
color: var(--text-secondary);
text-decoration: none;
color: #333;
border-left: 3px solid transparent;
transition: all 0.3s ease;
font-size: 0.88rem;
white-space: nowrap;
transition: background 0.15s ease, color 0.15s ease;
}
.nav-link:hover {
background-color: #f5f5f5;
border-left-color: #667eea;
background: var(--surface-0);
color: var(--text-primary);
}
.nav-link.active {
background-color: #f0f0ff;
border-left-color: #667eea;
color: #667eea;
background: var(--accent-soft);
color: var(--accent);
font-weight: 600;
}
.icon {
font-size: 1.2rem;
.theme-toggle {
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
border-radius: 7px;
padding: 0.3rem 0.6rem;
font-size: 0.76rem;
cursor: pointer;
font-family: inherit;
white-space: nowrap;
}
.label {
flex: 1;
.theme-toggle:hover {
border-color: var(--border-strong);
color: var(--text-primary);
}
.content {
flex: 1;
background: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
max-width: 1180px;
margin: 0 auto;
padding: 1.75rem 1.5rem 4rem;
}
.footer {
text-align: center;
padding: 2rem;
color: #666;
font-size: 0.9rem;
border-top: 1px solid #eee;
}
@media (max-width: 768px) {
.container {
flex-direction: column;
padding: 1rem;
}
.sidebar {
width: 100%;
position: static;
}
.nav-list {
display: flex;
gap: 0.5rem;
overflow-x: auto;
}
.nav-link {
flex: 1;
min-width: 100px;
font-size: 0.9rem;
}
.logo {
font-size: 1.4rem;
}
@media (max-width: 720px) {
.header-inner {
gap: 0.75rem;
padding: 0 0.9rem;
}
.logo { font-size: 0.85rem; }
.content { padding: 1.25rem 0.9rem 3rem; }
}

View File

@@ -1,56 +1,87 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { FEATURES } from '../features';
import './Layout.css';
interface LayoutProps {
children: React.ReactNode;
}
const NAV = [
{ path: '/', label: '今日' },
{ path: '/trends', label: '趋势' },
{ path: '/sleep', label: '睡眠' },
{ path: '/achievements', label: '成就' },
...(FEATURES.ai ? [{ path: '/recommendations', label: '建议' }] : []),
{ path: '/sync', label: '同步' },
{ path: '/settings', label: '设置' },
];
type Theme = 'light' | 'dark' | 'system';
/* Dark mode is a deliberate, validated palette rather than an inverted one, so
the choice is stamped on <html> and the tokens swap in one place. */
function useTheme(): [Theme, (t: Theme) => void] {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem('ghl_theme') as Theme) || 'system'
);
useEffect(() => {
const root = document.documentElement;
if (theme === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', theme);
localStorage.setItem('ghl_theme', theme);
}, [theme]);
return [theme, setTheme];
}
function Layout({ children }: LayoutProps) {
const location = useLocation();
const [theme, setTheme] = useTheme();
const navigationItems = [
{ path: '/', label: '仪表板', icon: '📊' },
{ path: '/sync', label: '数据同步', icon: '🔄' },
{ path: '/analysis', label: '数据分析', icon: '📈' },
{ path: '/recommendations', label: '健康建议', icon: '💡' },
{ path: '/settings', label: '设置', icon: '⚙️' },
];
const cycle = () =>
setTheme(theme === 'system' ? 'light' : theme === 'light' ? 'dark' : 'system');
const themeLabel = { system: '跟随系统', light: '浅色', dark: '深色' }[theme];
return (
<div className="layout">
<header className="header">
<div className="header-content">
<h1 className="logo">🏃 Garmin Health Lab</h1>
<p className="tagline"></p>
<div className="header-inner">
<Link to="/" className="logo">Garmin Health Lab</Link>
<nav className="nav" aria-label="主导航">
{NAV.map((item) => {
const active =
item.path === '/'
? location.pathname === '/'
: location.pathname.startsWith(item.path);
return (
<Link
key={item.path}
to={item.path}
className={`nav-link ${active ? 'active' : ''}`}
aria-current={active ? 'page' : undefined}
>
{item.label}
</Link>
);
})}
</nav>
<button
className="theme-toggle"
onClick={cycle}
title={`主题:${themeLabel}`}
aria-label={`切换主题,当前${themeLabel}`}
>
{theme === 'dark' ? '深色' : theme === 'light' ? '浅色' : '自动'}
</button>
</div>
</header>
<div className="container">
<nav className="sidebar">
<ul className="nav-list">
{navigationItems.map(item => (
<li key={item.path}>
<Link
to={item.path}
className={`nav-link ${location.pathname === item.path ? 'active' : ''}`}
>
<span className="icon">{item.icon}</span>
<span className="label">{item.label}</span>
</Link>
</li>
))}
</ul>
</nav>
<main className="content">
{children}
</main>
</div>
<footer className="footer">
<p>© 2024 Garmin Health Lab. All rights reserved.</p>
</footer>
<main className="content">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
interface ProtectedRouteProps {
children: React.ReactNode;
}
function ProtectedRoute({ children }: ProtectedRouteProps) {
const token = localStorage.getItem('ghl_token');
if (!token) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
export default ProtectedRoute;

View File

@@ -0,0 +1,171 @@
.viz {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.1rem 0.9rem;
margin: 0;
box-shadow: var(--shadow);
}
.viz-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.viz-head h4 {
margin: 0;
font-size: 0.95rem;
font-weight: 650;
color: var(--text-primary);
}
.viz-unit {
font-weight: 400;
color: var(--text-muted);
font-size: 0.85em;
}
.viz-sub {
margin: 0.2rem 0 0;
font-size: 0.78rem;
color: var(--text-muted);
}
.viz-toggle {
flex-shrink: 0;
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
border-radius: 6px;
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
cursor: pointer;
font-family: inherit;
}
.viz-toggle:hover {
border-color: var(--border-strong);
color: var(--text-primary);
}
.viz-empty {
height: 120px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 0.85rem;
background: var(--surface-0);
border-radius: 6px;
}
.viz-foot {
margin-top: 0.6rem;
padding-top: 0.6rem;
border-top: 1px solid var(--border);
font-size: 0.78rem;
color: var(--text-muted);
}
/* Identity is never colour alone: the swatch always sits beside a text label. */
.viz-swatch {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.4rem;
vertical-align: baseline;
}
.viz-legend-item {
color: var(--text-secondary);
font-size: 0.78rem;
}
/* Tooltip ------------------------------------------------------------------ */
.viz-tooltip {
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
padding: 0.55rem 0.7rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
font-size: 0.8rem;
min-width: 140px;
}
.viz-tooltip-label {
color: var(--text-muted);
font-size: 0.72rem;
margin-bottom: 0.35rem;
}
.viz-tooltip-row {
display: flex;
align-items: center;
gap: 0.1rem;
line-height: 1.7;
}
.viz-tooltip-name {
color: var(--text-secondary);
flex: 1;
margin-right: 0.75rem;
}
/* Values wear text tokens, never the series colour. */
.viz-tooltip-value {
color: var(--text-primary);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* Table view --------------------------------------------------------------- */
.viz-table-wrap {
max-height: 260px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 6px;
}
.viz-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.viz-table th,
.viz-table td {
padding: 0.4rem 0.6rem;
text-align: right;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
white-space: nowrap;
}
.viz-table thead th {
position: sticky;
top: 0;
background: var(--surface-2);
color: var(--text-muted);
font-weight: 600;
font-size: 0.74rem;
text-align: right;
}
.viz-table thead th:first-child,
.viz-table tbody th {
text-align: left;
}
.viz-table tbody th {
color: var(--text-primary);
font-weight: 500;
}
.viz-table td {
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}

View File

@@ -0,0 +1,277 @@
import { ReactNode, useState } from 'react';
import {
Area, AreaChart, Bar, BarChart, CartesianGrid, Legend, Line, LineChart,
ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import './Chart.css';
export interface Series {
key: string;
label: string;
/** 1-based slot in the categorical palette. Assigned in order, never cycled. */
slot: 1 | 2 | 3 | 4 | 5 | 6;
unit?: string;
/** Round displayed values to this many decimals. */
decimals?: number;
}
interface ChartProps {
title: string;
subtitle?: string;
data: Array<Record<string, any>>;
series: Series[];
type: 'line' | 'bar' | 'stacked-bar' | 'area';
xKey?: string;
height?: number;
/** Y-axis label; a chart has exactly one axis — never two scales. */
unit?: string;
footer?: ReactNode;
}
const fmt = (value: any, decimals = 0) =>
value == null
? '—'
: typeof value === 'number'
? value.toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})
: String(value);
function TooltipBox({ active, payload, label, series }: any) {
if (!active || !payload?.length) return null;
return (
<div className="viz-tooltip">
<div className="viz-tooltip-label">{label}</div>
{payload.map((entry: any) => {
const s = series.find((x: Series) => x.key === entry.dataKey);
return (
<div key={entry.dataKey} className="viz-tooltip-row">
<span
className="viz-swatch"
style={{ background: entry.color }}
aria-hidden="true"
/>
<span className="viz-tooltip-name">{s?.label ?? entry.dataKey}</span>
<span className="viz-tooltip-value">
{fmt(entry.value, s?.decimals)}
{s?.unit ? ` ${s.unit}` : ''}
</span>
</div>
);
})}
</div>
);
}
function Chart({
title, subtitle, data, series, type, xKey = 'date', height = 240, unit, footer,
}: ChartProps) {
// A table view is the relief for series whose colour falls below 3:1 on the
// light surface, and doubles as the non-visual reading of any chart.
const [showTable, setShowTable] = useState(false);
const present = series.filter((s) => data.some((row) => row[s.key] != null));
if (present.length === 0) {
return (
<figure className="viz">
<figcaption className="viz-head">
<h4>{title}</h4>
</figcaption>
<div className="viz-empty"></div>
</figure>
);
}
const color = (s: Series) => `var(--series-${s.slot})`;
const axis = {
stroke: 'var(--border-strong)',
tick: { fill: 'var(--text-muted)', fontSize: 11 },
tickLine: false,
};
const margin = { top: 8, right: 8, bottom: 0, left: -8 };
// A legend is mandatory from two series up; a single series is named by the
// title, so a legend box would only repeat it.
const legend =
present.length > 1 ? (
<Legend
verticalAlign="top"
align="left"
height={28}
iconType="circle"
iconSize={8}
formatter={(value: string) => {
const s = present.find((x) => x.key === value);
return <span className="viz-legend-item">{s?.label ?? value}</span>;
}}
/>
) : null;
const grid = <CartesianGrid stroke="var(--grid)" vertical={false} />;
const tip = (
<Tooltip
content={<TooltipBox series={present} />}
cursor={{ stroke: 'var(--border-strong)', strokeWidth: 1 }}
/>
);
const render = () => {
if (type === 'bar' || type === 'stacked-bar') {
const stacked = type === 'stacked-bar';
return (
<BarChart data={data} margin={margin} barCategoryGap="22%">
{grid}
<XAxis dataKey={xKey} {...axis} />
<YAxis {...axis} width={48} />
{tip}
{legend}
{present.map((s, i) => (
<Bar
key={s.key}
dataKey={s.key}
name={s.key}
fill={color(s)}
stackId={stacked ? 'stack' : undefined}
// 4px rounded data-end on the topmost segment only, so the shape
// reads as one bar anchored to the baseline.
radius={
stacked
? i === present.length - 1
? [4, 4, 0, 0]
: [0, 0, 0, 0]
: [4, 4, 0, 0]
}
// A 2px gap in the surface colour separates adjacent fills.
stroke="var(--surface-1)"
strokeWidth={stacked ? 2 : 0}
isAnimationActive={false}
/>
))}
</BarChart>
);
}
if (type === 'area') {
return (
<AreaChart data={data} margin={margin}>
<defs>
{present.map((s) => (
<linearGradient key={s.key} id={`fill-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color(s)} stopOpacity={0.22} />
<stop offset="100%" stopColor={color(s)} stopOpacity={0.02} />
</linearGradient>
))}
</defs>
{grid}
<XAxis dataKey={xKey} {...axis} />
<YAxis {...axis} width={48} />
{tip}
{legend}
{present.map((s) => (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
name={s.key}
stroke={color(s)}
strokeWidth={2}
fill={`url(#fill-${s.key})`}
connectNulls
isAnimationActive={false}
/>
))}
</AreaChart>
);
}
return (
<LineChart data={data} margin={margin}>
{grid}
<XAxis dataKey={xKey} {...axis} />
<YAxis {...axis} width={48} domain={['auto', 'auto']} />
{tip}
{legend}
{present.map((s) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.key}
stroke={color(s)}
strokeWidth={2}
dot={false}
// A 2px surface ring keeps overlapping markers readable.
activeDot={{ r: 5, strokeWidth: 2, stroke: 'var(--surface-1)' }}
// Days the device recorded nothing must not fragment the line.
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
);
};
return (
<figure className="viz">
<figcaption className="viz-head">
<div>
<h4>
{title}
{unit && <span className="viz-unit"> ({unit})</span>}
</h4>
{subtitle && <p className="viz-sub">{subtitle}</p>}
</div>
<button
type="button"
className="viz-toggle"
onClick={() => setShowTable((v) => !v)}
aria-pressed={showTable}
>
{showTable ? '看图表' : '看数据'}
</button>
</figcaption>
{showTable ? (
<div className="viz-table-wrap">
<table className="viz-table">
<thead>
<tr>
<th scope="col"></th>
{present.map((s) => (
<th key={s.key} scope="col">
<span
className="viz-swatch"
style={{ background: color(s) }}
aria-hidden="true"
/>
{s.label}
{s.unit ? ` (${s.unit})` : ''}
</th>
))}
</tr>
</thead>
<tbody>
{[...data].reverse().map((row, i) => (
<tr key={`${row[xKey]}-${i}`}>
<th scope="row">{row[xKey]}</th>
{present.map((s) => (
<td key={s.key}>{fmt(row[s.key], s.decimals)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<ResponsiveContainer width="100%" height={height}>
{render()}
</ResponsiveContainer>
)}
{footer && <div className="viz-foot">{footer}</div>}
</figure>
);
}
export default Chart;

View File

@@ -0,0 +1,80 @@
.tile {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.85rem 1rem;
box-shadow: var(--shadow);
}
.tile-label {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.35rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tile-value {
font-size: 1.55rem;
font-weight: 650;
color: var(--text-primary);
line-height: 1.15;
font-variant-numeric: tabular-nums;
}
.tile-unit {
font-size: 0.72rem;
font-weight: 400;
color: var(--text-muted);
margin-left: 0.25rem;
}
.tile-meter {
height: 4px;
background: var(--surface-0);
border-radius: 999px;
margin-top: 0.55rem;
overflow: hidden;
}
.tile-meter-fill {
height: 100%;
background: var(--accent);
border-radius: 999px;
}
.tile-detail {
margin-top: 0.45rem;
font-size: 0.74rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.tile-status {
font-weight: 600;
white-space: nowrap;
}
.status-good { color: var(--status-good); }
.status-warning { color: var(--status-warning); }
.status-serious { color: var(--status-serious); }
.status-critical { color: var(--status-critical); }
.tile-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
gap: 0.75rem;
}
@media (max-width: 560px) {
.tile-grid {
grid-template-columns: repeat(2, 1fr);
}
.tile-value {
font-size: 1.3rem;
}
}

View File

@@ -0,0 +1,75 @@
import { ReactNode } from 'react';
import './StatTile.css';
export type Status = 'good' | 'warning' | 'serious' | 'critical';
/* Status is carried by an icon plus a label, never by colour alone — the
light-surface status steps are deliberately below 3:1. */
const STATUS_ICON: Record<Status, string> = {
good: '●',
warning: '▲',
serious: '▲',
critical: '■',
};
interface StatTileProps {
label: string;
value: number | string | null | undefined;
unit?: string;
/** Secondary line: a goal, a range, a comparison. */
detail?: ReactNode;
status?: Status;
statusLabel?: string;
/** 01; draws a goal meter under the value. */
progress?: number | null;
}
function StatTile({
label, value, unit, detail, status, statusLabel, progress,
}: StatTileProps) {
const display =
value == null
? '—'
: typeof value === 'number'
? value.toLocaleString(undefined, { maximumFractionDigits: 1 })
: value;
return (
<div className="tile">
<div className="tile-label">{label}</div>
<div className="tile-value">
{display}
{unit && value != null && <span className="tile-unit">{unit}</span>}
</div>
{progress != null && (
<div
className="tile-meter"
role="meter"
aria-valuenow={Math.round(progress * 100)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${label}完成度`}
>
<div
className="tile-meter-fill"
style={{ width: `${Math.min(100, Math.max(0, progress * 100))}%` }}
/>
</div>
)}
{(detail || status) && (
<div className="tile-detail">
{status && (
<span className={`tile-status status-${status}`}>
<span aria-hidden="true">{STATUS_ICON[status]}</span> {statusLabel}
</span>
)}
{detail}
</div>
)}
</div>
);
}
export default StatTile;

12
client/src/features.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* Feature switches.
*
* `ai` is off while the recommendation module is being reworked: the pages and
* the backend endpoints still exist, so turning it back on is a one-line
* change rather than a rebuild. Nothing links to the route while it is off,
* and the route itself is not registered — a hidden nav entry with a live URL
* would still be reachable by typing it.
*/
export const FEATURES = {
ai: false,
};

View File

@@ -1,24 +1,14 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
@import './theme.css';
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
background: var(--surface-0);
color: var(--text-primary);
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
html, body, #root {
width: 100%;
height: 100%;
}
html, body, #root { width: 100%; min-height: 100%; }

View File

@@ -0,0 +1,207 @@
import { useEffect, useState } from 'react';
import {
apiClient, Activity, Badge, errorMessage, PersonalRecord,
} from '../services/api';
import StatTile from '../components/charts/StatTile';
import './Pages.css';
type Tab = 'badges' | 'records' | 'activities';
const ACTIVITY_LABEL: Record<string, string> = {
running: '跑步',
cycling: '骑行',
walking: '步行',
hiking: '徒步',
swimming: '游泳',
table_tennis: '乒乓球',
strength_training: '力量训练',
indoor_cycling: '室内骑行',
treadmill_running: '跑步机',
fitness_equipment: '健身器械',
};
const label = (key: string | null) =>
key ? ACTIVITY_LABEL[key] ?? key.replace(/_/g, ' ') : '—';
const date = (value: string | null) => (value ? value.slice(0, 10) : '—');
function Achievements() {
const [tab, setTab] = useState<Tab>('badges');
const [badges, setBadges] = useState<Badge[]>([]);
const [records, setRecords] = useState<PersonalRecord[]>([]);
const [activities, setActivities] = useState<Activity[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
const [b, r, a] = await Promise.all([
apiClient.getBadges(),
apiClient.getPersonalRecords(),
apiClient.getActivities(),
]);
setBadges(b);
setRecords(r);
setActivities(a);
} catch (err: any) {
setError(errorMessage(err, '加载失败'));
} finally {
setLoading(false);
}
};
load();
}, []);
if (loading) return <div className="page-loading"></div>;
// Badges cluster heavily by year, which is the only grouping that reads.
const byYear = badges.reduce<Record<string, Badge[]>>((acc, b) => {
const year = b.earned_date ? b.earned_date.slice(0, 4) : '未知';
(acc[year] ||= []).push(b);
return acc;
}, {});
const years = Object.keys(byYear).sort().reverse();
const totalPoints = badges.reduce((sum, b) => sum + (b.points ?? 0), 0);
return (
<div className="page">
<header className="page-head">
<div>
<h2></h2>
<p className="subtitle"></p>
</div>
</header>
{error && <div className="error-message">{error}</div>}
<div className="tile-grid" style={{ marginBottom: '1.5rem' }}>
<StatTile label="奖励徽章" value={badges.length} unit="个" />
<StatTile
label="徽章积分"
value={totalPoints || null}
detail={totalPoints ? undefined : '该账号未记录积分'}
/>
<StatTile label="个人纪录" value={records.length} unit="项" />
<StatTile label="运动记录" value={activities.length} unit="条" />
</div>
<div className="metric-tabs">
{([
['badges', `奖励 (${badges.length})`],
['records', `个人纪录 (${records.length})`],
['activities', `运动 (${activities.length})`],
] as Array<[Tab, string]>).map(([id, text]) => (
<button
key={id}
className={`metric-tab ${tab === id ? 'active' : ''}`}
onClick={() => setTab(id)}
>
{text}
</button>
))}
</div>
{tab === 'badges' && (
badges.length === 0 ? (
<p className="placeholder"></p>
) : (
years.map((year) => (
<section className="section" key={year}>
<h3 className="section-title">
{year === '未知' ? '未知年份' : `${year}`}
<span className="section-count">{byYear[year].length} </span>
</h3>
<div className="badge-grid">
{byYear[year].map((b) => (
<div className="badge" key={b.id}>
<div className="badge-name">{b.name || b.badge_key}</div>
<div className="badge-meta">
{date(b.earned_date)}
{b.earned_count && b.earned_count > 1 && (
<span className="badge-count">×{b.earned_count}</span>
)}
</div>
</div>
))}
</div>
</section>
))
)
)}
{tab === 'records' && (
records.length === 0 ? (
<p className="placeholder"></p>
) : (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr key={r.id}>
<th scope="row">{r.activity_name || '—'}</th>
<td>{label(r.activity_type)}</td>
<td className="num">
{r.value != null ? r.value.toLocaleString(undefined, {
maximumFractionDigits: 2,
}) : '—'}
</td>
<td>{date(r.achieved_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
{tab === 'activities' && (
activities.length === 0 ? (
<p className="placeholder"></p>
) : (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{activities.map((a) => (
<tr key={a.id}>
<th scope="row">{a.start_time?.slice(0, 16).replace('T', ' ')}</th>
<td>{label(a.activity_type)}</td>
<td className="num">
{a.duration != null ? `${Math.round(a.duration / 60)}` : '—'}
</td>
<td className="num">
{a.distance ? `${(a.distance / 1000).toFixed(2)} km` : '—'}
</td>
<td className="num">{a.calories != null ? `${Math.round(a.calories)}` : '—'}</td>
<td className="num">{a.heart_rate_average ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
</div>
);
}
export default Achievements;

View File

@@ -1,13 +0,0 @@
import React from 'react';
import './Pages.css';
function Analysis() {
return (
<div className="page">
<h2></h2>
<p className="placeholder">...</p>
</div>
);
}
export default Analysis;

View File

@@ -1,33 +1,276 @@
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/charts/Chart';
import StatTile, { Status } from '../components/charts/StatTile';
import './Pages.css';
const DAYS = 30;
/** MM-DD keeps the axis readable at 30 points. */
const short = (iso: string) => iso.slice(5);
function avg(values: Array<number | null | undefined>): number | null {
const present = values.filter((v): v is number => v != null);
if (!present.length) return null;
return present.reduce((a, b) => a + b, 0) / present.length;
}
/* Thresholds follow the same rules the recommendation engine uses, so the
dashboard and the advice never disagree about what counts as low. */
function sleepStatus(hours: number | null): [Status, string] | [] {
if (hours == null) return [];
if (hours < 6) return ['critical', '偏少'];
if (hours < 7) return ['warning', '略少'];
return ['good', '充足'];
}
function rhrStatus(bpm: number | null): [Status, string] | [] {
if (bpm == null) return [];
if (bpm > 70) return ['serious', '偏高'];
if (bpm > 65) return ['warning', '略高'];
return ['good', '正常'];
}
function Dashboard() {
const [loading, setLoading] = useState(true);
const [summary, setSummary] = useState<any>(null);
const [error, setError] = useState('');
const [days, setDays] = useState<HealthDay[]>([]);
useEffect(() => {
const loadData = async () => {
const load = async () => {
try {
// TODO: Fetch health summary data
setLoading(false);
} catch (error) {
console.error('Failed to load summary:', error);
const end = new Date();
const start = new Date(end.getTime() - (DAYS - 1) * 86400000);
setDays(
await apiClient.getHealthSummary(
start.toISOString().slice(0, 10),
end.toISOString().slice(0, 10)
)
);
} catch (err: any) {
setError(errorMessage(err, '加载数据失败'));
} finally {
setLoading(false);
}
};
loadData();
load();
}, []);
if (loading) {
return <div className="page-loading">...</div>;
if (loading) return <div className="page-loading"></div>;
if (error) {
return (
<div className="page">
<h2></h2>
<div className="error-message">{error}</div>
</div>
);
}
if (days.length === 0) {
return (
<div className="page">
<h2></h2>
<div className="empty-state">
<p></p>
<Link to="/sync" className="btn btn-primary"> Garmin </Link>
</div>
</div>
);
}
const today = days[days.length - 1];
const rows = days.map((d) => ({ ...d, date: short(d.date) }));
const avgSteps = avg(days.map((d) => d.steps));
const avgSleep = avg(days.map((d) => d.sleepDuration));
const avgRhr = avg(days.map((d) => d.heartRate));
const avgHrv = avg(days.map((d) => d.heartRateVariability));
const [sleepTone, sleepWord] = sleepStatus(today.sleepDuration);
const [rhrTone, rhrWord] = rhrStatus(today.heartRate);
const round = (v: number | null, d = 0) =>
v == null ? null : Math.round(v * 10 ** d) / 10 ** d;
return (
<div className="page">
<h2></h2>
<p className="placeholder">...</p>
<header className="page-head">
<div>
<h2></h2>
<p className="subtitle">{today.date} · {days.length} </p>
</div>
<Link to="/trends" className="btn btn-plain"> </Link>
</header>
{/* Activity ---------------------------------------------------------- */}
<section className="section">
<h3 className="section-title"></h3>
<div className="tile-grid">
<StatTile
label="步数"
value={today.steps}
detail={today.stepGoal ? `目标 ${today.stepGoal.toLocaleString()}` : undefined}
progress={
today.steps != null && today.stepGoal ? today.steps / today.stepGoal : null
}
/>
<StatTile
label="距离"
value={round(today.distanceMeters != null ? today.distanceMeters / 1000 : null, 2)}
unit="km"
/>
<StatTile label="爬楼" value={round(today.floorsAscended)} unit="层" />
<StatTile
label="强度分钟"
value={today.intensityMinutes}
unit="分钟"
detail="中等以上强度"
/>
<StatTile
label="总消耗"
value={round(today.caloriesBurned)}
unit="kcal"
detail={
today.activeCalories != null
? `其中活动 ${Math.round(today.activeCalories)}`
: undefined
}
/>
<StatTile
label="久坐"
value={round(
today.sedentarySeconds != null ? today.sedentarySeconds / 3600 : null, 1
)}
unit="小时"
/>
</div>
</section>
{/* Heart & stress ---------------------------------------------------- */}
<section className="section">
<h3 className="section-title"></h3>
<div className="tile-grid">
<StatTile
label="静息心率"
value={today.heartRate}
unit="bpm"
status={rhrTone}
statusLabel={rhrWord}
detail={avgRhr != null ? `30 日均 ${Math.round(avgRhr)}` : undefined}
/>
<StatTile
label="心率区间"
value={
today.heartRateMin != null && today.heartRateMax != null
? `${today.heartRateMin}${today.heartRateMax}`
: null
}
unit="bpm"
/>
<StatTile
label="心率变异性"
value={round(today.heartRateVariability)}
unit="ms"
detail={avgHrv != null ? `30 日均 ${Math.round(avgHrv)}` : undefined}
/>
<StatTile
label="平均压力"
value={today.stress}
detail={today.stressMax != null ? `峰值 ${today.stressMax}` : undefined}
/>
<StatTile
label="身体电量"
value={
today.bodyBatteryLow != null && today.bodyBatteryHigh != null
? `${today.bodyBatteryLow}${today.bodyBatteryHigh}`
: null
}
detail={
today.bodyBatteryCharged != null
? `${today.bodyBatteryCharged} / 耗 ${today.bodyBatteryDrained}`
: undefined
}
/>
<StatTile label="训练准备度" value={today.trainingReadiness} unit="/100" />
</div>
</section>
{/* Sleep & breathing -------------------------------------------------- */}
<section className="section">
<h3 className="section-title"></h3>
<div className="tile-grid">
<StatTile
label="睡眠时长"
value={today.sleepDuration}
unit="小时"
status={sleepTone}
statusLabel={sleepWord}
detail={avgSleep != null ? `30 日均 ${avgSleep.toFixed(1)}` : undefined}
/>
<StatTile label="睡眠评分" value={round(today.sleepQuality)} unit="/100" />
<StatTile
label="血氧"
value={round(today.spo2Avg)}
unit="%"
detail={today.spo2Min != null ? `最低 ${today.spo2Min}%` : undefined}
/>
<StatTile
label="呼吸频率"
value={round(today.respirationAvg)}
unit="次/分"
detail={
today.respirationMin != null && today.respirationMax != null
? `${today.respirationMin}${today.respirationMax}`
: undefined
}
/>
</div>
<p className="section-link">
<Link to="/sleep"> </Link>
</p>
</section>
{/* Trends ------------------------------------------------------------- */}
<section className="section">
<h3 className="section-title"> {days.length} </h3>
<div className="chart-grid">
<Chart
title="步数"
subtitle={avgSteps != null ? `日均 ${Math.round(avgSteps).toLocaleString()}` : undefined}
data={rows}
type="bar"
series={[{ key: 'steps', label: '步数', slot: 1, unit: '步' }]}
/>
<Chart
title="心率"
unit="bpm"
data={rows}
type="line"
series={[
{ key: 'heartRate', label: '静息', slot: 1, unit: 'bpm' },
{ key: 'heartRateMax', label: '最高', slot: 2, unit: 'bpm' },
]}
/>
<Chart
title="睡眠时长"
unit="小时"
data={rows}
type="area"
series={[{ key: 'sleepDuration', label: '睡眠', slot: 1, unit: '小时', decimals: 1 }]}
/>
<Chart
title="身体电量"
data={rows}
type="line"
series={[
{ key: 'bodyBatteryHigh', label: '最高', slot: 1 },
{ key: 'bodyBatteryLow', label: '最低', slot: 2 },
]}
/>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,184 @@
.sync-container {
display: grid;
gap: 1.25rem;
max-width: 620px;
}
.status-card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
box-shadow: var(--shadow);
}
.status-card h3 {
margin: 0 0 0.9rem;
font-size: 0.95rem;
font-weight: 650;
color: var(--text-primary);
}
.status-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.status-item {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
padding: 0.55rem 0.7rem;
background: var(--surface-0);
border-radius: 7px;
font-size: 0.88rem;
}
.status-item .label {
color: var(--text-muted);
font-size: 0.82rem;
}
.status-item .value {
color: var(--text-primary);
font-weight: 550;
text-align: right;
}
/* Colour reinforces the status word already present in the markup; it never
carries the meaning alone — the light-surface status steps are below 3:1. */
.value.status-idle { color: var(--status-good); }
.value.status-syncing { color: var(--accent); }
.value.status-error { color: var(--status-critical); }
.status-item.error {
background: color-mix(in srgb, var(--status-critical) 8%, var(--surface-0));
border: 1px solid color-mix(in srgb, var(--status-critical) 25%, transparent);
}
.status-item.error .value {
color: var(--status-critical);
font-weight: 400;
font-size: 0.8rem;
word-break: break-word;
}
.sync-actions {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
box-shadow: var(--shadow);
}
.sync-choices {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
}
.mfa-card {
border-color: var(--accent);
background: var(--accent-soft);
}
.code-input {
font-size: 1.4rem;
letter-spacing: 0.32em;
text-align: center;
font-family: Menlo, Monaco, Consolas, monospace;
}
.mfa-buttons {
display: flex;
gap: 0.7rem;
align-items: center;
}
.progress-block {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.progress-head {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 0.9rem;
color: var(--text-primary);
}
.progress-count {
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
font-size: 0.82rem;
}
.progress-bar {
height: 6px;
background: var(--surface-0);
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 999px;
transition: width 0.4s ease;
}
.info-box {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
}
.info-box h4 {
margin: 0 0 0.7rem;
font-size: 0.9rem;
font-weight: 650;
color: var(--text-primary);
}
.info-box ul {
margin: 0;
padding-left: 1.2rem;
color: var(--text-secondary);
line-height: 1.9;
font-size: 0.85rem;
}
.info-box code {
background: var(--surface-0);
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-size: 0.85em;
}
.cmd {
background: var(--surface-0);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 0.85rem 1rem;
border-radius: 7px;
font-size: 0.8rem;
line-height: 1.8;
overflow-x: auto;
margin: 0;
font-family: Menlo, Monaco, Consolas, monospace;
}
@media (max-width: 600px) {
.status-item {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.status-item .value { text-align: left; }
.sync-choices .btn { flex: 1; }
}

View File

@@ -1,18 +1,363 @@
import React from 'react';
import './Pages.css';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
apiClient, errorMessage, GarminLoginStatus, parseUtc, SyncStatus,
} from '../services/api';
import './DataSync.css';
const POLL_MS = 2000;
function DataSync() {
const handleSync = async () => {
// TODO: Implement data sync
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [hasToken, setHasToken] = useState<boolean | null>(null);
// Garmin login (only needed until a token is stored)
const [password, setPassword] = useState('');
const [session, setSession] = useState<string | null>(null);
const [loginState, setLoginState] = useState<GarminLoginStatus | null>(null);
const [code, setCode] = useState('');
const [codeSubmitted, setCodeSubmitted] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const pollRef = useRef<number | null>(null);
const stopPolling = useCallback(() => {
if (pollRef.current) {
window.clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
const loadSyncStatus = useCallback(async () => {
try {
setSyncStatus(await apiClient.getGarminSyncStatus());
} catch (err) {
// A failed status poll should not blank the page.
console.error('Failed to load sync status:', err);
}
}, []);
useEffect(() => {
// A backfill outlives the page, so a reload must pick the progress back up.
apiClient.getGarminSyncStatus().then((s) => {
setSyncStatus(s);
if (s.status === 'syncing') beginSyncPolling();
}).catch(() => undefined);
apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false));
return stopPolling;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// --- Garmin login -------------------------------------------------------
const startLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setMessage('');
if (!password) {
setError('请输入 Garmin 密码');
return;
}
setLoading(true);
try {
const sid = await apiClient.startGarminLogin(password);
// The password is only ever needed for this one request.
setPassword('');
setSession(sid);
setLoginState('starting');
setCodeSubmitted(false);
beginPolling(sid);
} catch (err: any) {
setError(errorMessage(err, '登录失败'));
} finally {
setLoading(false);
}
};
const beginPolling = (sid: string) => {
stopPolling();
pollRef.current = window.setInterval(async () => {
try {
const { status, error: loginError } = await apiClient.getGarminLoginStatus(sid);
setLoginState(status);
if (status === 'done') {
stopPolling();
setSession(null);
setHasToken(true);
setMessage('Garmin 登录成功,之后同步不再需要密码或验证码。');
} else if (status === 'failed') {
stopPolling();
setSession(null);
setCodeSubmitted(false);
setError(loginError || '登录失败,请重试');
}
} catch (err: any) {
stopPolling();
setSession(null);
setError(errorMessage(err, '登录状态查询失败'));
}
}, POLL_MS);
};
const submitCode = async (e: React.FormEvent) => {
e.preventDefault();
if (!session || !code.trim()) return;
setError('');
setLoading(true);
try {
const { ok, message: msg } = await apiClient.submitGarminMfa(session, code.trim());
if (ok) {
setCodeSubmitted(true);
setCode('');
} else {
setError(msg);
}
} catch (err: any) {
setError(errorMessage(err, '验证码提交失败'));
} finally {
setLoading(false);
}
};
const cancelLogin = async () => {
if (session) {
try {
await apiClient.cancelGarminLogin(session);
} catch {
// Cancelling is best-effort; the session expires on its own anyway.
}
}
stopPolling();
setSession(null);
setLoginState(null);
setCode('');
setCodeSubmitted(false);
};
// --- sync ---------------------------------------------------------------
const handleSync = async (days: number) => {
setError('');
setMessage('');
setLoading(true);
try {
await apiClient.syncGarminData(days);
await loadSyncStatus();
beginSyncPolling();
} catch (err: any) {
setError(errorMessage(err, '同步失败'));
} finally {
setLoading(false);
}
};
// The sync runs in the background, so the page follows it by polling
// rather than by holding a request open for the whole backfill.
const beginSyncPolling = () => {
stopPolling();
pollRef.current = window.setInterval(async () => {
try {
const s = await apiClient.getGarminSyncStatus();
setSyncStatus(s);
if (s.status !== 'syncing') {
stopPolling();
if (s.status === 'error') setError(s.lastError || '同步失败');
else setMessage(`同步完成,已更新 ${s.recordsSynced} 天数据`);
}
} catch {
stopPolling();
}
}, 2000);
};
const statusLabel: Record<string, string> = {
idle: '就绪',
syncing: '正在同步…',
error: '上次同步失败',
};
const syncing = syncStatus?.status === 'syncing';
const busy = loading || syncing;
const awaitingCode = loginState === 'awaiting_code' || codeSubmitted;
const current = syncStatus?.progressCurrent ?? 0;
const total = syncStatus?.progressTotal ?? 0;
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
return (
<div className="page">
<h2></h2>
<p className="placeholder">...</p>
<button onClick={handleSync} className="btn btn-primary">
Garmin
</button>
<p className="subtitle"> Garmin Connect 7 </p>
<div className="sync-container">
<section className="status-card">
<h3></h3>
{syncStatus ? (
<div className="status-info">
<div className="status-item">
<span className="label"></span>
<span className={`value status-${syncStatus.status}`}>
{statusLabel[syncStatus.status] ?? syncStatus.status}
</span>
</div>
<div className="status-item">
<span className="label"></span>
<span className="value">
{parseUtc(syncStatus.lastSyncTime)?.toLocaleString('zh-CN')
?? '从未同步'}
</span>
</div>
<div className="status-item">
<span className="label"></span>
<span className="value">{syncStatus.recordsSynced}</span>
</div>
{syncStatus.lastError && (
<div className="status-item error">
<span className="label"></span>
<span className="value">{syncStatus.lastError}</span>
</div>
)}
</div>
) : (
<p className="placeholder"></p>
)}
</section>
{/* Step 1 — link the Garmin account, once. */}
{hasToken === false && !session && (
<form className="sync-actions" onSubmit={startLogin}>
<div className="form-group">
<label htmlFor="garmin-password">Garmin </label>
<input
id="garmin-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
disabled={loading}
/>
<p className="field-hint">
Garmin
</p>
</div>
<button type="submit" className="btn btn-primary btn-large" disabled={loading}>
{loading ? '正在连接…' : '绑定 Garmin 账号'}
</button>
</form>
)}
{/* Step 2 — the two-factor code. */}
{session && (
<section className="status-card mfa-card">
{loginState === 'starting' && !codeSubmitted && (
<p className="placeholder"> Garmin</p>
)}
{awaitingCode && (
<form onSubmit={submitCode}>
<h3></h3>
<p className="field-hint" style={{ marginBottom: '1rem' }}>
Garmin 6
</p>
<div className="form-group">
<input
id="mfa-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={10}
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="6 位数字"
className="code-input"
disabled={loading || codeSubmitted}
autoFocus
/>
</div>
<div className="mfa-buttons">
<button
type="submit"
className="btn btn-primary"
disabled={loading || codeSubmitted || !code.trim()}
>
{codeSubmitted ? '正在验证…' : '提交验证码'}
</button>
<button type="button" className="btn btn-plain" onClick={cancelLogin}>
</button>
</div>
</form>
)}
{loginState === 'finishing' && (
<p className="placeholder"></p>
)}
</section>
)}
{/* Step 3 — sync, once linked. */}
{hasToken === true && (
<section className="status-card">
<h3></h3>
<p className="field-hint" style={{ marginBottom: '0.9rem' }}>
Garmin
7
</p>
{syncing && total > 0 ? (
<div className="progress-block">
<div className="progress-head">
<span></span>
<span className="progress-count">{current} / {total} </span>
</div>
<div
className="progress-bar"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
>
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
<p className="field-hint">
3
{total > 60 ? `预计 ${Math.ceil((total * 3) / 60)} 分钟左右。` : ''}
</p>
</div>
) : (
<div className="sync-choices">
{[7, 30, 90, 365].map((d) => (
<button
key={d}
onClick={() => handleSync(d)}
className={`btn ${d === 7 ? 'btn-primary' : 'btn-plain'}`}
disabled={busy}
>
{d === 365 ? '回补一年' : `最近 ${d}`}
</button>
))}
</div>
)}
</section>
)}
{error && <div className="error-message">{error}</div>}
{message && <div className="success-message">{message}</div>}
<section className="info-box">
<h4></h4>
<ul>
<li> 7 </li>
<li></li>
<li></li>
<li>Garmin </li>
</ul>
</section>
</div>
</div>
);
}

100
client/src/pages/Login.css Normal file
View File

@@ -0,0 +1,100 @@
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--surface-0);
padding: 1.5rem;
}
.login-card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: var(--shadow);
width: 100%;
max-width: 380px;
padding: 2rem 1.85rem;
animation: rise 0.25s ease-out;
}
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.login-header {
text-align: center;
margin-bottom: 1.75rem;
}
.login-header h1 {
font-size: 1.25rem;
font-weight: 680;
margin: 0 0 0.35rem;
color: var(--text-primary);
}
.login-header p {
color: var(--text-muted);
font-size: 0.84rem;
margin: 0;
}
.login-tabs {
display: flex;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.tab-button {
flex: 1;
padding: 0.6rem;
border: none;
background: none;
color: var(--text-muted);
font-size: 0.92rem;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s ease, border-color 0.15s ease;
font-family: inherit;
}
.tab-button:hover {
color: var(--text-primary);
}
.tab-button.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 600;
}
.login-form {
display: flex;
flex-direction: column;
}
.submit-button {
padding: 0.7rem 1rem;
background: var(--accent-solid);
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s ease;
margin-top: 0.35rem;
font-family: inherit;
}
.submit-button:hover:not(:disabled) {
filter: brightness(1.08);
}
.submit-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}

238
client/src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,238 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiClient, errorMessage } from '../services/api';
import './Login.css';
type TabType = 'login' | 'register';
function Login() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<TabType>('login');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>('');
// Sign-up closes once an account exists, so the tab is hidden rather than
// offering something the server will refuse.
const [canRegister, setCanRegister] = useState(false);
useEffect(() => {
apiClient
.getRegistrationStatus()
.then(setCanRegister)
.catch(() => setCanRegister(false));
}, []);
// Login form
const [loginEmail, setLoginEmail] = useState('');
const [loginPassword, setLoginPassword] = useState('');
// Register form
const [regEmail, setRegEmail] = useState('');
const [regGarminEmail, setRegGarminEmail] = useState('');
const [regPassword, setRegPassword] = useState('');
const [regConfirmPassword, setRegConfirmPassword] = useState('');
const validateEmail = (email: string): boolean => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
};
const validatePassword = (password: string): boolean => {
return password.length >= 6;
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!validateEmail(loginEmail)) {
setError('Please enter a valid email');
return;
}
if (!validatePassword(loginPassword)) {
setError('Password must be at least 6 characters');
return;
}
setLoading(true);
try {
const { token } = await apiClient.login(loginEmail, loginPassword);
apiClient.setSession(token);
navigate('/');
} catch (err: any) {
setError(errorMessage(err, '登录失败'));
} finally {
setLoading(false);
}
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!validateEmail(regEmail)) {
setError('Please enter a valid email');
return;
}
if (!validateEmail(regGarminEmail)) {
setError('Please enter a valid Garmin email');
return;
}
if (!validatePassword(regPassword)) {
setError('Password must be at least 6 characters');
return;
}
if (regPassword !== regConfirmPassword) {
setError('Passwords do not match');
return;
}
setLoading(true);
try {
const { token } = await apiClient.register(regEmail, regGarminEmail, regPassword);
apiClient.setSession(token);
navigate('/');
} catch (err: any) {
setError(errorMessage(err, '注册失败'));
} finally {
setLoading(false);
}
};
return (
<div className="login-container">
<div className="login-card">
<div className="login-header">
<h1>🏃 Garmin Health Lab</h1>
<p></p>
</div>
<div className="login-tabs">
<button
className={`tab-button ${activeTab === 'login' ? 'active' : ''}`}
onClick={() => {
setActiveTab('login');
setError('');
}}
>
</button>
{canRegister && (
<button
className={`tab-button ${activeTab === 'register' ? 'active' : ''}`}
onClick={() => {
setActiveTab('register');
setError('');
}}
>
</button>
)}
</div>
{error && <div className="error-message">{error}</div>}
{activeTab === 'login' && (
<form onSubmit={handleLogin} className="login-form">
<div className="form-group">
<label htmlFor="login-email"></label>
<input
id="login-email"
type="email"
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder="example@example.com"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="login-password"></label>
<input
id="login-password"
type="password"
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
placeholder="••••••••"
required
disabled={loading}
/>
</div>
<button type="submit" className="submit-button" disabled={loading}>
{loading ? '登录中...' : '登录'}
</button>
</form>
)}
{activeTab === 'register' && canRegister && (
<form onSubmit={handleRegister} className="login-form">
<div className="form-group">
<label htmlFor="reg-email"></label>
<input
id="reg-email"
type="email"
value={regEmail}
onChange={(e) => setRegEmail(e.target.value)}
placeholder="example@example.com"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="reg-garmin-email">Garmin </label>
<input
id="reg-garmin-email"
type="email"
value={regGarminEmail}
onChange={(e) => setRegGarminEmail(e.target.value)}
placeholder="garmin@example.com"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="reg-password"></label>
<input
id="reg-password"
type="password"
value={regPassword}
onChange={(e) => setRegPassword(e.target.value)}
placeholder="••••••••"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="reg-confirm-password"></label>
<input
id="reg-confirm-password"
type="password"
value={regConfirmPassword}
onChange={(e) => setRegConfirmPassword(e.target.value)}
placeholder="••••••••"
required
disabled={loading}
/>
</div>
<button type="submit" className="submit-button" disabled={loading}>
{loading ? '注册中...' : '注册'}
</button>
</form>
)}
</div>
</div>
);
}
export default Login;

View File

@@ -1,59 +1,351 @@
.page {
animation: fadeIn 0.3s ease-in;
animation: fadeIn 0.25s ease-out;
color: var(--text-primary);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.page h2 {
color: #333;
margin-bottom: 1.5rem;
font-size: 1.8rem;
margin: 0;
font-size: 1.4rem;
font-weight: 680;
color: var(--text-primary);
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--text-muted);
font-size: 0.85rem;
}
.section {
margin-bottom: 1.75rem;
}
.section-title {
display: flex;
align-items: baseline;
gap: 0.6rem;
font-size: 0.8rem;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin: 0 0 0.7rem;
}
.section-count {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.section-link {
margin: 0.7rem 0 0;
font-size: 0.82rem;
}
.section-link a,
.page a {
color: var(--accent);
text-decoration: none;
}
.section-link a:hover,
.page a:hover {
text-decoration: underline;
}
/* Filters sit in one row above the charts. */
.range-tabs,
.metric-tabs {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.metric-tabs {
margin-bottom: 1.25rem;
padding-bottom: 0.9rem;
border-bottom: 1px solid var(--border);
}
.range-tab,
.metric-tab {
padding: 0.34rem 0.8rem;
border: 1px solid var(--border);
background: var(--surface-1);
border-radius: 999px;
font-size: 0.82rem;
color: var(--text-secondary);
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
font-family: inherit;
white-space: nowrap;
}
.range-tab:hover,
.metric-tab:hover {
border-color: var(--accent);
color: var(--accent);
}
.range-tab.active,
.metric-tab.active {
background: var(--accent-solid);
border-color: var(--accent-solid);
color: #fff;
}
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.chart-grid.one-col {
grid-template-columns: 1fr;
}
.page-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
font-size: 1.2rem;
color: #666;
min-height: 240px;
color: var(--text-muted);
font-size: 0.95rem;
}
.placeholder {
color: #999;
font-size: 1.1rem;
padding: 2rem;
background-color: #f9f9f9;
border-radius: 8px;
text-align: center;
padding: 2rem;
color: var(--text-muted);
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.9rem;
}
.empty-state {
text-align: center;
padding: 3rem 2rem;
background: var(--surface-1);
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
color: var(--text-secondary);
}
.empty-state p {
margin: 0 0 1.1rem;
}
.error-message {
background: color-mix(in srgb, var(--status-critical) 10%, var(--surface-1));
border: 1px solid color-mix(in srgb, var(--status-critical) 35%, transparent);
color: var(--status-critical);
padding: 0.85rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
font-size: 0.88rem;
}
.success-message {
background: color-mix(in srgb, var(--status-good) 10%, var(--surface-1));
border: 1px solid color-mix(in srgb, var(--status-good) 35%, transparent);
color: var(--text-primary);
padding: 0.85rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
font-size: 0.88rem;
}
/* Buttons ------------------------------------------------------------------ */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
padding: 0.55rem 1.1rem;
border: 1px solid transparent;
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 1rem;
font-family: inherit;
transition: all 0.15s ease;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background-color: #667eea;
color: white;
background: var(--accent-solid);
color: #fff;
}
.btn-primary:hover {
background-color: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
.btn-primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn-primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-large {
width: 100%;
max-width: 300px;
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.btn-plain {
background: var(--surface-1);
border-color: var(--border);
color: var(--text-secondary);
font-size: 0.84rem;
padding: 0.4rem 0.85rem;
}
.btn-plain:hover {
border-color: var(--border-strong);
color: var(--text-primary);
}
/* Badges ------------------------------------------------------------------- */
.badge-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 0.65rem;
}
.badge {
background: var(--surface-1);
border: 1px solid var(--border);
border-left: 3px solid var(--series-4);
border-radius: 8px;
padding: 0.7rem 0.85rem;
}
.badge-name {
font-size: 0.86rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1.4;
}
.badge-meta {
margin-top: 0.3rem;
font-size: 0.74rem;
color: var(--text-muted);
display: flex;
gap: 0.5rem;
}
.badge-count {
color: var(--accent);
font-weight: 600;
}
/* Tables ------------------------------------------------------------------- */
.table-wrap {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: auto;
background: var(--surface-1);
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.data-table th,
.data-table td {
padding: 0.6rem 0.85rem;
border-bottom: 1px solid var(--border);
text-align: left;
color: var(--text-secondary);
white-space: nowrap;
}
.data-table thead th {
background: var(--surface-2);
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.04em;
position: sticky;
top: 0;
}
.data-table tbody th {
color: var(--text-primary);
font-weight: 550;
}
.data-table td.num {
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.data-table tbody tr:last-child th,
.data-table tbody tr:last-child td {
border-bottom: none;
}
/* Forms -------------------------------------------------------------------- */
.form-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 1rem;
}
.form-group label {
font-size: 0.84rem;
font-weight: 600;
color: var(--text-secondary);
}
.form-group input {
padding: 0.65rem 0.8rem;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.95rem;
font-family: inherit;
background: var(--surface-2);
color: var(--text-primary);
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.field-hint {
font-size: 0.78rem;
color: var(--text-muted);
line-height: 1.7;
margin: 0;
}
@media (max-width: 640px) {
.chart-grid {
grid-template-columns: 1fr;
}
.page-head {
flex-direction: column;
}
}

View File

@@ -0,0 +1,170 @@
.model-bar {
display: flex;
align-items: center;
gap: 0.7rem;
flex-wrap: wrap;
padding: 0.9rem 1rem;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 1rem;
}
.model-bar label {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.85rem;
}
.model-bar select {
flex: 1;
min-width: 220px;
padding: 0.45rem 0.7rem;
border: 1px solid var(--border-strong);
border-radius: 7px;
font-size: 0.9rem;
font-family: inherit;
background: var(--surface-2);
color: var(--text-primary);
}
.model-bar select:disabled {
background: var(--surface-0);
color: var(--text-muted);
}
.meta-bar {
padding: 0.7rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
margin-bottom: 1.25rem;
border: 1px solid var(--border);
}
.meta-bar.ai {
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
color: var(--text-primary);
}
.meta-bar.rules {
background: color-mix(in srgb, var(--status-warning) 12%, var(--surface-1));
border-color: color-mix(in srgb, var(--status-warning) 35%, transparent);
color: var(--text-primary);
}
.meta-bar .fallback {
color: var(--text-secondary);
margin-left: 0.4rem;
}
.notice {
padding: 0.9rem 1rem;
background: color-mix(in srgb, var(--status-warning) 12%, var(--surface-1));
border: 1px solid color-mix(in srgb, var(--status-warning) 35%, transparent);
border-radius: 8px;
color: var(--text-secondary);
font-size: 0.85rem;
line-height: 1.8;
margin-bottom: 1rem;
}
.notice code {
background: var(--surface-0);
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-size: 0.88em;
}
.rec-list {
display: grid;
gap: 0.85rem;
}
.rec-card {
background: var(--surface-1);
border: 1px solid var(--border);
border-left: 3px solid var(--border-strong);
border-radius: var(--radius);
padding: 1rem 1.15rem;
box-shadow: var(--shadow);
}
/* Priority is spelled out in the badge text; the stripe is reinforcement. */
.rec-card.priority-high { border-left-color: var(--status-critical); }
.rec-card.priority-medium { border-left-color: var(--status-serious); }
.rec-card.priority-low { border-left-color: var(--status-good); }
.rec-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.5rem;
}
.rec-category {
font-weight: 650;
color: var(--text-primary);
font-size: 0.95rem;
}
.rec-priority {
font-size: 0.72rem;
font-weight: 650;
padding: 0.15rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
border: 1px solid transparent;
}
.rec-priority.priority-high {
background: color-mix(in srgb, var(--status-critical) 12%, transparent);
color: var(--status-critical);
border-color: color-mix(in srgb, var(--status-critical) 30%, transparent);
}
.rec-priority.priority-medium {
background: color-mix(in srgb, var(--status-serious) 14%, transparent);
color: var(--status-serious);
border-color: color-mix(in srgb, var(--status-serious) 35%, transparent);
}
.rec-priority.priority-low {
background: color-mix(in srgb, var(--status-good) 12%, transparent);
color: var(--status-good);
border-color: color-mix(in srgb, var(--status-good) 30%, transparent);
}
.rec-body {
color: var(--text-secondary);
line-height: 1.85;
margin: 0;
font-size: 0.9rem;
}
.rec-based-on {
margin-top: 0.65rem;
font-size: 0.76rem;
color: var(--text-muted);
}
.disclaimer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
font-size: 0.78rem;
color: var(--text-muted);
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,159 @@
import React from 'react';
import './Pages.css';
import { useCallback, useEffect, useState } from 'react';
import {
apiClient, errorMessage, AiRecommendations, ModelInfo, parseUtc,
} from '../services/api';
import './Recommendations.css';
const PRIORITY_LABEL: Record<string, string> = {
high: '高',
medium: '中',
low: '低',
};
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 [regenerating, setRegenerating] = useState(false);
const load = useCallback(async (model?: string, refresh?: boolean) => {
setLoading(true);
setRegenerating(Boolean(refresh || model));
setError('');
try {
setResult(await apiClient.getAiRecommendations(model || undefined, refresh));
} catch (err: any) {
setError(errorMessage(err, '获取建议失败'));
} finally {
setLoading(false);
setRegenerating(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 (
<div className="page">
<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, true)}
disabled={loading}
>
{loading ? '生成中…' : '重新生成'}
</button>
</div>
{regenerating && (
<div className="notice">
1-3
</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.upstream && `(上游 ${meta.upstream}`}
{' · '} {meta.days}
{meta.cached && (
<span className="fallback">
·
{meta.generatedAt &&
`,生成于 ${parseUtc(meta.generatedAt)?.toLocaleString('zh-CN')}`}
</span>
)}
{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>
);
}

View File

@@ -0,0 +1,137 @@
.settings-section {
margin-bottom: 2.25rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.settings-section:last-child {
border-bottom: none;
}
.settings-section h3 {
font-size: 1rem;
font-weight: 650;
color: var(--text-primary);
margin: 0 0 0.55rem;
}
.settings-hint {
color: var(--text-muted);
font-size: 0.86rem;
line-height: 1.8;
margin-bottom: 1rem;
}
.settings-hint code,
.model-table code {
background: var(--surface-0);
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-size: 0.88em;
color: var(--text-secondary);
}
.model-table {
width: 100%;
border-collapse: collapse;
font-size: 0.86rem;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.model-table th {
text-align: left;
padding: 0.6rem 0.8rem;
background: var(--surface-2);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.model-table td {
padding: 0.65rem 0.8rem;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
}
.model-table tbody tr:last-child td {
border-bottom: none;
}
.model-name {
color: var(--text-muted);
font-size: 0.82rem;
}
/* State is spelled out in the badge text; colour only reinforces it. */
.badge {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.74rem;
font-weight: 650;
white-space: nowrap;
border: 1px solid transparent;
}
.badge.ok {
background: color-mix(in srgb, var(--status-good) 12%, transparent);
color: var(--status-good);
border-color: color-mix(in srgb, var(--status-good) 30%, transparent);
}
.badge.off {
background: var(--surface-0);
color: var(--text-muted);
border-color: var(--border);
}
.badge-default {
margin-left: 0.4rem;
padding: 0.05rem 0.4rem;
background: var(--accent-soft);
color: var(--accent);
border-radius: 4px;
font-size: 0.7rem;
font-weight: 650;
}
.settings-list {
margin: 0;
padding-left: 1.25rem;
color: var(--text-secondary);
line-height: 1.95;
font-size: 0.88rem;
}
.btn-danger {
background: var(--surface-1);
color: var(--status-critical);
border: 1px solid color-mix(in srgb, var(--status-critical) 40%, transparent);
padding: 0.55rem 1.1rem;
border-radius: 8px;
font-size: 0.92rem;
cursor: pointer;
font-family: inherit;
transition: background 0.15s ease, border-color 0.15s ease;
}
.btn-danger:hover {
background: color-mix(in srgb, var(--status-critical) 8%, var(--surface-1));
border-color: var(--status-critical);
}
@media (max-width: 600px) {
.model-table {
font-size: 0.78rem;
}
.model-table th,
.model-table td {
padding: 0.45rem 0.5rem;
}
}

View File

@@ -1,11 +1,99 @@
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 { FEATURES } from '../features';
import './Settings.css';
function Settings() {
const navigate = useNavigate();
const [models, setModels] = useState<ModelInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!FEATURES.ai) {
setLoading(false);
return;
}
apiClient
.getModels()
.then(setModels)
.catch(() => setModels([]))
.finally(() => setLoading(false));
}, []);
const handleLogout = async () => {
await apiClient.logout();
navigate('/login');
};
return (
<div className="page">
<h2></h2>
<p className="placeholder">...</p>
{FEATURES.ai && (
<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 OAuth
</li>
<li> PBKDF2 </li>
</ul>
</section>
<section className="settings-section">
<h3></h3>
<button className="btn btn-danger" onClick={handleLogout}>
退
</button>
</section>
</div>
);
}

178
client/src/pages/Sleep.tsx Normal file
View File

@@ -0,0 +1,178 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart from '../components/charts/Chart';
import StatTile from '../components/charts/StatTile';
import './Pages.css';
const RANGES = [7, 14, 30, 90];
const H = 3600;
function avg(values: Array<number | null | undefined>): number | null {
const present = values.filter((v): v is number => v != null);
return present.length ? present.reduce((a, b) => a + b, 0) / present.length : null;
}
function Sleep() {
const [days, setDays] = useState<HealthDay[]>([]);
// 14 by default: the stacked chart needs bars wide enough to read the
// thinnest stage and to give hover a ~24px hit target. Longer windows stay
// available for the trend, where density matters less.
const [range, setRange] = useState(14);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const end = new Date();
const start = new Date(end.getTime() - (range - 1) * 86400000);
setDays(
await apiClient.getHealthSummary(
start.toISOString().slice(0, 10),
end.toISOString().slice(0, 10)
)
);
} catch (err: any) {
setError(errorMessage(err, '加载睡眠数据失败'));
} finally {
setLoading(false);
}
};
load();
}, [range]);
const nights = days.filter((d) => d.sleepDuration != null);
// Stage seconds are converted to hours here so the stacked bar and the
// duration chart share one y-scale — a chart never carries two scales.
const rows = nights.map((d) => ({
date: d.date.slice(5),
deep: d.sleep?.deepSeconds != null ? d.sleep.deepSeconds / H : null,
light: d.sleep?.lightSeconds != null ? d.sleep.lightSeconds / H : null,
rem: d.sleep?.remSeconds != null ? d.sleep.remSeconds / H : null,
awake: d.sleep?.awakeSeconds != null ? d.sleep.awakeSeconds / H : null,
quality: d.sleepQuality,
spo2: d.sleepSpo2Avg,
respiration: d.sleepRespirationAvg,
stress: d.sleepStressAvg,
}));
const avgDeep = avg(rows.map((r) => r.deep));
const avgRem = avg(rows.map((r) => r.rem));
const avgLight = avg(rows.map((r) => r.light));
const avgAwake = avg(rows.map((r) => r.awake));
const avgDuration = avg(nights.map((d) => d.sleepDuration));
const avgQuality = avg(nights.map((d) => d.sleepQuality));
const totalStages = [avgDeep, avgLight, avgRem].reduce<number>(
(sum, v) => sum + (v ?? 0), 0
);
const share = (v: number | null) =>
v == null || totalStages === 0 ? undefined : `${Math.round((v / totalStages) * 100)}%`;
const hrs = (v: number | null, d = 1) => (v == null ? null : Math.round(v * 10 ** d) / 10 ** d);
return (
<div className="page">
<header className="page-head">
<div>
<h2></h2>
<p className="subtitle"></p>
</div>
<div className="range-tabs">
{RANGES.map((r) => (
<button
key={r}
className={`range-tab ${r === range ? 'active' : ''}`}
onClick={() => setRange(r)}
>
{r}
</button>
))}
</div>
</header>
{error && <div className="error-message">{error}</div>}
{loading && <div className="page-loading"></div>}
{!loading && !error && nights.length === 0 && (
<div className="empty-state">
<p></p>
<Link to="/sync" className="btn btn-primary"></Link>
</div>
)}
{!loading && !error && nights.length > 0 && (
<>
<section className="section">
<h3 className="section-title">{nights.length} </h3>
<div className="tile-grid">
<StatTile label="总时长" value={hrs(avgDuration)} unit="小时" />
<StatTile label="睡眠评分" value={hrs(avgQuality, 0)} unit="/100" />
<StatTile label="深睡" value={hrs(avgDeep)} unit="小时" detail={share(avgDeep)} />
<StatTile label="浅睡" value={hrs(avgLight)} unit="小时" detail={share(avgLight)} />
<StatTile label="REM" value={hrs(avgRem)} unit="小时" detail={share(avgRem)} />
<StatTile label="夜间清醒" value={hrs(avgAwake)} unit="小时" />
</div>
</section>
<section className="section">
<div className="chart-grid one-col">
<Chart
title="睡眠分期"
unit="小时"
subtitle="每晚各阶段时长堆叠;总高度即当晚睡眠总时长"
data={rows}
type="stacked-bar"
height={280}
series={[
{ key: 'deep', label: '深睡', slot: 1, unit: '小时', decimals: 1 },
{ key: 'light', label: '浅睡', slot: 2, unit: '小时', decimals: 1 },
{ key: 'rem', label: 'REM', slot: 3, unit: '小时', decimals: 1 },
{ key: 'awake', label: '清醒', slot: 4, unit: '小时', decimals: 1 },
]}
footer="成人参考:深睡约占 1323%REM 约占 2025%。"
/>
</div>
<div className="chart-grid">
<Chart
title="睡眠评分"
unit="/100"
data={rows}
type="area"
series={[{ key: 'quality', label: '评分', slot: 1 }]}
/>
<Chart
title="夜间血氧"
unit="%"
data={rows}
type="line"
series={[{ key: 'spo2', label: '血氧', slot: 1, unit: '%', decimals: 1 }]}
/>
<Chart
title="夜间呼吸频率"
unit="次/分"
data={rows}
type="line"
series={[
{ key: 'respiration', label: '呼吸', slot: 1, unit: '次/分', decimals: 1 },
]}
/>
<Chart
title="睡眠压力"
data={rows}
type="line"
series={[{ key: 'stress', label: '压力', slot: 1, decimals: 1 }]}
/>
</div>
</section>
</>
)}
</div>
);
}
export default Sleep;

280
client/src/pages/Trends.tsx Normal file
View File

@@ -0,0 +1,280 @@
import { useEffect, useMemo, useState } from 'react';
import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart, { Series } from '../components/charts/Chart';
import StatTile from '../components/charts/StatTile';
import './Pages.css';
const RANGES = [7, 14, 30, 90, 365];
/** Each group is one chart. Metrics only share a chart when they share a
* scale and a unit — a chart never carries two y-scales. */
interface MetricGroup {
id: string;
label: string;
unit?: string;
type: 'line' | 'bar' | 'area';
/** Which HealthDay fields to plot, in palette-slot order. */
series: Series[];
/** Optional transform, e.g. seconds to hours. */
scale?: Record<string, number>;
note?: string;
}
const GROUPS: MetricGroup[] = [
{
id: 'steps', label: '步数', unit: '步', type: 'bar',
series: [{ key: 'steps', label: '步数', slot: 1, unit: '步' }],
},
{
id: 'distance', label: '距离', unit: 'km', type: 'bar',
scale: { distanceMeters: 1 / 1000 },
series: [{ key: 'distanceMeters', label: '距离', slot: 1, unit: 'km', decimals: 2 }],
},
{
id: 'calories', label: '能量消耗', unit: 'kcal', type: 'bar',
series: [
{ key: 'bmrCalories', label: '基础代谢', slot: 1, unit: 'kcal' },
{ key: 'activeCalories', label: '活动消耗', slot: 2, unit: 'kcal' },
],
note: '两者相加即当日总消耗。',
},
{
id: 'heart', label: '心率', unit: 'bpm', type: 'line',
series: [
{ key: 'heartRate', label: '静息', slot: 1, unit: 'bpm' },
{ key: 'heartRateMax', label: '最高', slot: 2, unit: 'bpm' },
{ key: 'heartRateMin', label: '最低', slot: 3, unit: 'bpm' },
],
},
{
id: 'hrv', label: '心率变异性', unit: 'ms', type: 'area',
series: [{ key: 'heartRateVariability', label: 'HRV', slot: 1, unit: 'ms', decimals: 1 }],
note: 'HRV 反映自主神经恢复情况,持续偏低常与压力或训练过量相关。',
},
{
id: 'stress', label: '压力', type: 'line',
series: [
{ key: 'stress', label: '平均', slot: 1 },
{ key: 'stressMax', label: '峰值', slot: 2 },
],
},
{
id: 'battery', label: '身体电量', type: 'line',
series: [
{ key: 'bodyBatteryHigh', label: '最高', slot: 1 },
{ key: 'bodyBatteryLow', label: '最低', slot: 2 },
],
},
{
id: 'sleep', label: '睡眠时长', unit: '小时', type: 'area',
series: [{ key: 'sleepDuration', label: '时长', slot: 1, unit: '小时', decimals: 1 }],
},
{
id: 'spo2', label: '血氧', unit: '%', type: 'line',
series: [
{ key: 'spo2Avg', label: '平均', slot: 1, unit: '%', decimals: 1 },
{ key: 'spo2Min', label: '最低', slot: 2, unit: '%' },
],
},
{
id: 'respiration', label: '呼吸频率', unit: '次/分', type: 'line',
series: [
{ key: 'respirationAvg', label: '平均', slot: 1, unit: '次/分', decimals: 1 },
{ key: 'respirationMax', label: '最高', slot: 2, unit: '次/分', decimals: 1 },
{ key: 'respirationMin', label: '最低', slot: 3, unit: '次/分', decimals: 1 },
],
},
{
id: 'floors', label: '爬楼', unit: '层', type: 'bar',
series: [{ key: 'floorsAscended', label: '上行', slot: 1, unit: '层', decimals: 0 }],
},
{
id: 'intensity', label: '强度分钟', unit: '分钟', type: 'bar',
series: [{ key: 'intensityMinutes', label: '强度分钟', slot: 1, unit: '分钟' }],
},
{
id: 'sedentary', label: '久坐与活动时长', unit: '小时', type: 'bar',
scale: { sedentarySeconds: 1 / 3600, activeSeconds: 1 / 3600 },
series: [
{ key: 'sedentarySeconds', label: '久坐', slot: 1, unit: '小时', decimals: 1 },
{ key: 'activeSeconds', label: '活动', slot: 2, unit: '小时', decimals: 1 },
],
},
{
id: 'training', label: '训练准备度', unit: '/100', type: 'area',
series: [{ key: 'trainingReadiness', label: '准备度', slot: 1 }],
},
{
id: 'endurance', label: '耐力分', type: 'area',
series: [{ key: 'enduranceScore', label: '耐力分', slot: 1 }],
},
];
function stats(values: Array<number | null | undefined>) {
const present = values.filter((v): v is number => v != null);
if (!present.length) return null;
const sorted = [...present].sort((a, b) => a - b);
const mean = present.reduce((a, b) => a + b, 0) / present.length;
const mid = Math.floor(present.length / 2);
const firstHalf = present.slice(0, mid);
const secondHalf = present.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: present.length,
mean,
min: sorted[0],
max: sorted[sorted.length - 1],
median: sorted[mid],
delta,
};
}
function Trends() {
const [days, setDays] = useState<HealthDay[]>([]);
const [range, setRange] = useState(30);
const [active, setActive] = useState(GROUPS[0].id);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
setLoading(true);
setError('');
try {
const end = new Date();
const start = new Date(end.getTime() - (range - 1) * 86400000);
setDays(
await apiClient.getHealthSummary(
start.toISOString().slice(0, 10),
end.toISOString().slice(0, 10)
)
);
} catch (err: any) {
setError(errorMessage(err, '加载失败'));
} finally {
setLoading(false);
}
};
load();
}, [range]);
const group = GROUPS.find((g) => g.id === active) ?? GROUPS[0];
/* Bars stop working long before a year fits: 365 of them across a typical
chart leaves ~1.7px each, well under a readable mark or a usable hover
target. Past this many points the same data is drawn as an area instead —
a line handles density natively because its crosshair snaps to the
nearest x rather than needing per-mark hit areas. */
const DENSE_ABOVE = 90;
const dense = days.length > DENSE_ABOVE;
const renderType =
dense && (group.type === 'bar') ? 'area' as const : group.type;
const rows = useMemo(
() =>
days.map((d) => {
const row: Record<string, any> = { date: d.date.slice(5) };
for (const s of group.series) {
const raw = (d as any)[s.key];
const factor = group.scale?.[s.key];
row[s.key] = raw == null ? null : factor ? raw * factor : raw;
}
return row;
}),
[days, group]
);
const primary = group.series[0];
const summary = stats(rows.map((r) => r[primary.key]));
/* Precision by magnitude: "11,013.99 步" is both false precision and long
enough to wrap its unit onto a second line. */
const fmt = (v: number) => {
const abs = Math.abs(v);
const decimals = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
return v.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
});
};
return (
<div className="page">
<header className="page-head">
<div>
<h2></h2>
<p className="subtitle"> {GROUPS.length} </p>
</div>
<div className="range-tabs">
{RANGES.map((r) => (
<button
key={r}
className={`range-tab ${r === range ? 'active' : ''}`}
onClick={() => setRange(r)}
>
{r === 365 ? '一年' : `${r}`}
</button>
))}
</div>
</header>
<div className="metric-tabs">
{GROUPS.map((g) => (
<button
key={g.id}
className={`metric-tab ${g.id === active ? 'active' : ''}`}
onClick={() => setActive(g.id)}
>
{g.label}
</button>
))}
</div>
{error && <div className="error-message">{error}</div>}
{loading && <div className="page-loading"></div>}
{!loading && !error && (
<>
{summary ? (
<div className="tile-grid" style={{ marginBottom: '1.25rem' }}>
<StatTile label="平均" value={fmt(summary.mean)} unit={primary.unit} />
<StatTile label="中位数" value={fmt(summary.median)} unit={primary.unit} />
<StatTile label="最低" value={fmt(summary.min)} unit={primary.unit} />
<StatTile label="最高" value={fmt(summary.max)} unit={primary.unit} />
<StatTile
label="后半段对比前半段"
value={`${summary.delta >= 0 ? '+' : ''}${fmt(summary.delta)}`}
unit={primary.unit}
/>
<StatTile label="有效天数" value={summary.count} unit="天" />
</div>
) : (
<p className="placeholder"></p>
)}
<div className="chart-grid one-col">
<Chart
title={group.label}
unit={group.unit}
subtitle={
dense && renderType !== group.type
? `${days.length} 天数据:柱形在此密度下不可读,已改用面积图`
: undefined
}
data={rows}
type={renderType}
series={group.series}
height={320}
footer={group.note}
/>
</div>
</>
)}
</div>
);
}
export default Trends;

View File

@@ -1,6 +1,186 @@
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 SleepDetail {
duration: number;
quality: number | null;
deepSeconds: number | null;
lightSeconds: number | null;
remSeconds: number | null;
awakeSeconds: number | null;
}
/** One day, with every metric the sync stores. All are nullable: a device
* that does not record a metric leaves it null rather than zero. */
export interface HealthDay {
date: string;
steps: number | null;
stepGoal: number | null;
distanceMeters: number | null;
caloriesBurned: number | null;
activeCalories: number | null;
bmrCalories: number | null;
floorsAscended: number | null;
floorsDescended: number | null;
intensityMinutes: number | null;
sedentarySeconds: number | null;
activeSeconds: number | null;
heartRate: number | null;
heartRateMax: number | null;
heartRateMin: number | null;
heartRateVariability: number | null;
stress: number | null;
stressMax: number | null;
bodyBatteryHigh: number | null;
bodyBatteryLow: number | null;
bodyBatteryCharged: number | null;
bodyBatteryDrained: number | null;
spo2Avg: number | null;
spo2Min: number | null;
respirationAvg: number | null;
respirationMin: number | null;
respirationMax: number | null;
sleepDuration: number | null;
sleepQuality: number | null;
sleepSpo2Avg: number | null;
sleepRespirationAvg: number | null;
sleepStressAvg: number | null;
trainingReadiness: number | null;
vo2max: number | null;
enduranceScore: number | null;
sleep: SleepDetail | null;
}
export interface Badge {
id: string;
badge_key: string | null;
name: string | null;
category_id: number | null;
difficulty_id: number | null;
earned_date: string | null;
earned_count: number | null;
points: number | null;
}
export interface PersonalRecord {
id: string;
type_id: number | null;
activity_id: string | null;
activity_name: string | null;
activity_type: string | null;
value: number | null;
achieved_at: string | null;
}
export interface Activity {
id: string;
activity_type: string;
start_time: string;
end_time: string;
duration: number | null;
distance: number | null;
calories: number | null;
heart_rate_average: number | null;
heart_rate_max: number | null;
}
export interface SyncStatus {
status: 'idle' | 'syncing' | 'error';
lastSyncTime: string | null;
recordsSynced: number;
lastError: string | null;
/** Days completed / requested. A backfill runs for many minutes. */
progressCurrent: number | null;
progressTotal: number | null;
startedAt: string | null;
}
export interface SyncResult {
status: 'success' | 'error';
recordsSynced: number;
activitiesSynced?: number;
message: string;
/** Set when the account has two-factor auth and no token is stored yet. */
mfaRequired?: boolean;
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;
upstream?: string | null;
days?: number;
fallbackFrom?: string[];
reason?: string;
/** True when served from the stored answer rather than freshly generated. */
cached?: boolean;
generatedAt?: string;
};
}
export type GarminLoginStatus =
| 'starting'
| 'awaiting_code'
| 'finishing'
| 'done'
| 'failed';
export interface ModelInfo {
id: string;
model: string;
provider: string;
contextWindow: number;
configured: boolean;
default: boolean;
}
export interface TrendPoint {
date: string;
value: number;
}
/**
* Parse a timestamp the backend wrote with `datetime.utcnow()` — i.e. UTC but
* with no offset in the string. JavaScript reads such a value as *local* time,
* which showed sync times eight hours in the past here.
*/
export function parseUtc(value: string | null | undefined): Date | null {
if (!value) return null;
const normalised = value.replace(' ', 'T');
const withZone = /[Zz]|[+-]\d{2}:?\d{2}$/.test(normalised)
? normalised
: `${normalised}Z`;
const date = new Date(withZone);
return Number.isNaN(date.getTime()) ? null : date;
}
/** 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;
@@ -8,70 +188,212 @@ class ApiClient {
constructor() {
this.client = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
});
}
// Auth endpoints
login(email: string, password: string) {
return this.client.post('/auth/login', { email, password });
}
logout() {
return this.client.post('/auth/logout');
}
// Garmin endpoints
syncGarminData() {
return this.client.post('/garmin/sync');
}
getGarminSyncStatus() {
return this.client.get('/garmin/status');
}
// Health endpoints
getHealthSummary(startDate?: string, endDate?: string) {
return this.client.get('/health/summary', {
params: { startDate, endDate }
// Attach the saved JWT to every request.
this.client.interceptors.request.use((config) => {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 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);
}
);
}
getStepsData(startDate?: string, endDate?: string) {
return this.client.get('/health/steps', {
params: { startDate, endDate }
// --- session ---
setSession(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
clearSession() {
localStorage.removeItem(TOKEN_KEY);
}
isAuthenticated(): boolean {
return Boolean(localStorage.getItem(TOKEN_KEY));
}
// --- auth ---
async register(email: string, garminEmail: string, garminPassword: string) {
const { data } = await this.client.post<AuthResponse>('/auth/register', {
email,
garminEmail,
garminPassword,
});
return data;
}
getHeartRateData(startDate?: string, endDate?: string) {
return this.client.get('/health/heart-rate', {
params: { startDate, endDate }
/** Whether sign-up is currently permitted (closes after the first account). */
async getRegistrationStatus() {
const { data } = await this.client.get<{ open: boolean }>(
'/auth/registration-status'
);
return data.open;
}
async login(email: string, password: string) {
const { data } = await this.client.post<AuthResponse>('/auth/login', {
email,
password,
});
return data;
}
getSleepData(startDate?: string, endDate?: string) {
return this.client.get('/health/sleep', {
params: { startDate, endDate }
async logout() {
try {
await this.client.post('/auth/logout');
} finally {
this.clearSession();
}
}
// --- garmin ---
/**
* With a stored OAuth token no password is needed. Without one, the
* plaintext password must be supplied because only a hash is kept — and an
* MFA-protected account cannot log in this way at all (see garmin_login.py).
*/
/** Starts a sync in the background; poll getGarminSyncStatus for progress. */
async syncGarminData(days?: number, garminPassword?: string) {
const { data } = await this.client.post<{ status: string; days?: number }>(
'/garmin/sync',
{
...(days ? { days } : {}),
...(garminPassword ? { garminPassword } : {}),
}
);
return data;
}
/** Whether a stored Garmin token exists (then sync needs no password). */
async getGarminAuthStatus() {
const { data } = await this.client.get<{ hasToken: boolean }>(
'/garmin/auth-status'
);
return data.hasToken;
}
/**
* Start an interactive Garmin login. Returns a session id; the login runs
* in the background and parks if Garmin asks for a two-factor code.
*/
async startGarminLogin(garminPassword: string, garminEmail?: string) {
const { data } = await this.client.post<{ session: string }>('/garmin/login', {
garminPassword,
...(garminEmail ? { garminEmail } : {}),
});
return data.session;
}
getActivities(startDate?: string, endDate?: string) {
return this.client.get('/health/activities', {
params: { startDate, endDate }
async getGarminLoginStatus(session: string) {
const { data } = await this.client.get<{
session: string;
status: GarminLoginStatus;
error: string | null;
}>('/garmin/login-status', { params: { session } });
return data;
}
async submitGarminMfa(session: string, code: string) {
const { data } = await this.client.post<{ ok: boolean; message: string }>(
'/garmin/mfa',
{ session, code }
);
return data;
}
async cancelGarminLogin(session: string) {
await this.client.delete('/garmin/login', { params: { session } });
}
async getGarminSyncStatus() {
const { data } = await this.client.get<SyncStatus>('/garmin/status');
return data;
}
// --- health ---
private range(startDate?: string, endDate?: string) {
return { params: { startDate, endDate } };
}
async getHealthSummary(startDate?: string, endDate?: string) {
const { data } = await this.client.get<HealthDay[]>(
'/health/summary',
this.range(startDate, endDate)
);
return data;
}
async getActivities(startDate?: string, endDate?: string) {
const { data } = await this.client.get<Activity[]>(
'/health/activities',
this.range(startDate, endDate)
);
return data;
}
async getBadges() {
const { data } = await this.client.get<Badge[]>('/health/badges');
return data;
}
async getPersonalRecords() {
const { data } = await this.client.get<PersonalRecord[]>(
'/health/personal-records'
);
return data;
}
// --- analysis ---
async getTrends(metricType: string, startDate?: string, endDate?: string) {
const { data } = await this.client.get<TrendPoint[]>('/analysis/trends', {
params: { metricType, startDate, endDate },
});
return data;
}
// Analysis endpoints
getTrends(metricType?: string) {
return this.client.get('/analysis/trends', {
params: { metricType }
});
async getRecommendations() {
const { data } = await this.client.get<Recommendation[]>('/analysis/recommendations');
return data;
}
getRecommendations() {
return this.client.get('/analysis/recommendations');
async getModels() {
const { data } = await this.client.get<ModelInfo[]>('/analysis/models');
return data;
}
/**
* Served from the stored answer unless `refresh` is set or a `model` is
* named. A fresh generation can take minutes, so the caller should show a
* long-running state for those two cases.
*/
async getAiRecommendations(model?: string, refresh?: boolean, days?: number) {
const { data } = await this.client.get<AiRecommendations>(
'/analysis/ai-recommendations',
{
params: { model, days, ...(refresh ? { refresh: 1 } : {}) },
// A cold generation runs well past axios's default timeout.
timeout: 240_000,
}
);
return data;
}
}

109
client/src/theme.css Normal file
View File

@@ -0,0 +1,109 @@
/*
* Design tokens.
*
* The series colours are the validated categorical palette, in its documented
* slot order — the ordering is the colour-blind-safety mechanism, not a
* cosmetic choice, so slots are assigned in order and never cycled.
* Verified with the palette validator in both modes:
* light worst adjacent CVD ΔE 9.1, normal-vision ΔE 22.9
* dark worst adjacent CVD ΔE 8.4, normal-vision ΔE 19.8
* On the light surface aqua (2.74:1) and yellow (2.11:1) fall below 3:1, so
* every chart using them ships visible labels plus a table view.
*/
:root {
color-scheme: light;
/* surfaces & ink */
--surface-0: #f4f4f2;
--surface-1: #fcfcfb;
--surface-2: #ffffff;
--border: #e4e3df;
--border-strong: #d3d2cd;
--text-primary: #0b0b0b;
--text-secondary: #52514e;
--text-muted: #86847e;
/* categorical series — assign in order */
--series-1: #2a78d6; /* blue */
--series-2: #eb6834; /* orange */
--series-3: #1baf7a; /* aqua */
--series-4: #eda100; /* yellow */
--series-5: #e87ba4; /* magenta */
--series-6: #008300; /* green */
/* status — reserved, never reused as a series */
--status-good: #0ca30c;
--status-warning: #fab219;
--status-serious: #ec835a;
--status-critical: #d03b3b;
--grid: #eceae5;
/* Separate token for filled surfaces (buttons, active tabs). White text on
--accent measures only 4.42:1 light / 3.64:1 dark — under the 4.5:1 body
floor — so fills use a darker step from the same blue ramp: 5.39:1 light,
6.63:1 dark. --accent stays for text, borders and marks. */
--accent: #2a78d6;
--accent-solid: #256abf;
--accent-soft: #eef4fd;
--radius: 10px;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 8px rgba(0, 0, 0, 0.03);
}
/* Dark steps are the same hues re-stepped for the dark surface — selected and
validated as a set, not an automatic inversion. Declared under both the OS
media query and the explicit toggle so either can win. */
@media (prefers-color-scheme: dark) {
:root:where(:not([data-theme='light'])) {
color-scheme: dark;
--surface-0: #131312;
--surface-1: #1a1a19;
--surface-2: #232322;
--border: #34342f;
--border-strong: #45443e;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #8f8e85;
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;
--series-5: #d55181;
--series-6: #008300;
--grid: #2c2c28;
--accent: #3987e5;
--accent-solid: #1c5cab;
--accent-soft: #1d2938;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}
}
:root[data-theme='dark'] {
color-scheme: dark;
--surface-0: #131312;
--surface-1: #1a1a19;
--surface-2: #232322;
--border: #34342f;
--border-strong: #45443e;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #8f8e85;
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;
--series-5: #d55181;
--series-6: #008300;
--grid: #2c2c28;
--accent: #3987e5;
--accent-solid: #1c5cab;
--accent-soft: #1d2938;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}

46
deploy/S99garmin.sh Normal file
View File

@@ -0,0 +1,46 @@
#!/bin/sh
# Garmin Health Lab — DSM boot script.
# Mirrors the convention already used by S99frpc.sh on this NAS.
APP=/var/services/homes/ericwyuan/apps/garmin-health-lab
RUNAS=ericwyuan
# Kept inside the app dir, not /var/run: the service runs as an unprivileged
# user, which cannot write there.
PIDFILE="$APP/app.pid"
start() {
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "already running (pid $(cat "$PIDFILE"))"
return 0
fi
# Runs as the owning user: the service needs nothing privileged and its
# .env holds database and API credentials.
# setsid + closed stdio so it survives the invoking shell and never holds
# an SSH session open.
if [ "$(id -un)" = "$RUNAS" ]; then
sh "$APP/start.sh" >/dev/null 2>&1 </dev/null
else
su - "$RUNAS" -c "sh $APP/start.sh" >/dev/null 2>&1 </dev/null
fi
sleep 2
echo "started (pid $(cat "$PIDFILE" 2>/dev/null))"
}
stop() {
[ -f "$PIDFILE" ] && kill "$(cat "$PIDFILE")" 2>/dev/null
rm -f "$PIDFILE"
pkill -f "gunicorn.*8123" 2>/dev/null
echo "stopped"
}
case "$1" in
start) start ;;
stop) stop ;;
restart) stop; sleep 2; start ;;
status)
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "running (pid $(cat "$PIDFILE"))"
else
echo "not running"
fi ;;
*) echo "Usage: $0 {start|stop|restart|status}" ;;
esac

1988
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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 && BACKEND_PORT=5000 .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"
}

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,32 +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": {
"express": "^4.18.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"mysql2": "^3.6.0",
"sqlite3": "^5.1.6",
"jsonwebtoken": "^9.0.2",
"axios": "^1.5.0",
"ts-node": "^10.9.1"
},
"devDependencies": {
"@types/express": "^4.17.17",
"@types/cors": "^2.8.13",
"@types/node": "^20.3.1",
"@types/jsonwebtoken": "^9.0.2",
"@types/sqlite3": "^3.1.8",
"typescript": "^5.1.3",
"tsx": "^3.12.7"
}
}

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,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,18 +0,0 @@
import express from 'express';
const router = express.Router();
// TODO: Implement authentication routes
router.post('/login', (req, res) => {
res.json({ message: 'Login endpoint' });
});
router.post('/logout', (req, res) => {
res.json({ message: 'Logout endpoint' });
});
router.post('/refresh', (req, res) => {
res.json({ message: 'Refresh token endpoint' });
});
export default router;

View File

@@ -1,14 +0,0 @@
import express from 'express';
const router = express.Router();
// TODO: Implement Garmin API integration
router.post('/sync', (req, res) => {
res.json({ message: 'Garmin sync endpoint' });
});
router.get('/status', (req, res) => {
res.json({ message: 'Garmin sync status endpoint' });
});
export default router;

View File

@@ -1,26 +0,0 @@
import express from 'express';
const router = express.Router();
// TODO: Implement health data endpoints
router.get('/summary', (req, res) => {
res.json({ message: 'Health summary endpoint' });
});
router.get('/steps', (req, res) => {
res.json({ message: 'Steps data endpoint' });
});
router.get('/heart-rate', (req, res) => {
res.json({ message: 'Heart rate data endpoint' });
});
router.get('/sleep', (req, res) => {
res.json({ message: 'Sleep data endpoint' });
});
router.get('/activities', (req, res) => {
res.json({ message: 'Activities endpoint' });
});
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"]
}