Commit Graph

82 Commits

Author SHA1 Message Date
ericwyuan
fa865ca8a6 fix: 实机验证发现的七个缺陷
部署与时区
- client/.env.production 写死 REACT_APP_API_URL=/api。之前没有这个文件,
  构建靠命令行临时传参,一旦忘了就把开发默认值 localhost:5000 打进包里,
  部署后整站 Network Error。
- 新增 lib/day.ts,所有日期改用本地日历日。原先用 toISOString() 取的是 UTC 日期,
  在 UTC+8 每天前 8 小时都会少查一天——当天的数据佳明已经有了,应用却够不到。

界面
- 覆盖 Framework7 9 给 .navbar .left/.right 加的 frosted pill,
  就是各页右上角和返回键旁边那个半透明椭圆。
- .metric-tab 显式 width:auto。F7 把每个 button 渲染成整宽块元素,
  运动详情的四个 Tab 因此竖着堆成四行。
- 主要收益为 UNKNOWN 时不显示该区块,那是「没有结论」的哨兵值。

正确性
- 心率区间百分比改用整次运动时长作分母。原先除以「落在区间内的总时长」,
  把低于区间 1 的时间挤掉了:44:06 的登山里区间 1 占 23:03,
  手表显示 52%,我算成了 90%。现在对上了。

性能
- 按进程缓存已认证的 Garmin 会话(15 分钟 TTL)。实测 _connect 单次 11 秒,
  而七个数据接口加起来才 4 秒——瓶颈全在每次重新认证。
  冷启 16s → 热 7s → 命中缓存 0.8s,不再撞客户端超时。
- get_activity_details 的 maxchart 由 2000 降到 500,反正写入时抽稀到 300。
- 重新绑定账号时丢弃缓存会话。

删除前端重做前遗留的 5 个无引用页面文件。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-24 01:16:44 +08:00
ericwyuan
8430ff335e feat(ui): 今日页日期切换 + 卡片可点 + 路由切换进度条
需求 3.7 / 4.5 / 4.6 / 4.7

今日页
- 左右箭头切换日期,中间点开日历选任意一天,范围限定在已有数据内
- 没有记录的日期直接跳过,不会停在一个空白页上
- 一次读一年:往回翻一天不该产生一次请求,卡片的迷你曲线本来也需要前后几天
- 曲线窗口跟着所看的那天结束,卡片上的走势总是通向它上面那个数字
- 卡片改为从 lib/metrics.ts 渲染,点击进入指标详情

路由切换进度条
- 切换本身是瞬时的,用户等的是新页面的第一次请求,没有指示就像点了没反应
- 跑到 90% 停住等页面就位,不谎报完成
- 有最短显示时长,快速跳转时不会闪一下

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-24 00:49:38 +08:00
ericwyuan
0c7fecc124 feat(ui): 设置页重做 + 同步页简化 + 评分依据页
需求 2.5 / 2.6 / 2.7 / 2.8 / 5.1 / 5.2 / 5.3

设置页
- 个人资料:身高/体重/出生日期/性别,另显示算出的 BMI 与年龄
- 单位:公制 / 英制
- 同步:自动同步开关、频率、历史范围
- 改动即存,不放「保存」按钮——每项都是单值且效果直观,
  而能保持脏状态的表单就是会悄悄丢编辑的表单
- 开关用原生 checkbox 只换绘制,键盘与读屏行为保持不变

/rating-basis/ 评分依据
- 11 项参考区间逐条列出边界与出处,包括「一般性参考,无权威标准」这种
  诚实的答案。判断从哪来必须能查到。

同步页
- 首要按钮「同步最新数据」,等几秒直接返回结果
- 次要按钮「同步历史」,按设置里的历史范围后台跑
- 状态压缩成三格:上次同步 / 自动同步 / 已同步天数
- 删掉原来四段说明文字,它们把两个动作埋在了下面

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-24 00:44:07 +08:00
ericwyuan
b0e0799a97 feat(ui): 卡片可点进详情 + 身体年龄 + 运动详情 + 指标选择弹窗
需求 3.5 / 3.6 / 4.4 / 4.5 / 4.8 / 4.9

- 删掉导航栏右上角那两个 Link,就是它们渲染成半透明椭圆的。
  睡眠入口改为点健康页的睡眠卡片,每日入口移进趋势页内容里。
- 新增 lib/metrics.ts 作为唯一的指标注册表。label/unit/取值函数原本在
  今日、健康、趋势各写一份,改一处要改三处,也就有三次写不一致的机会。
- /metric/:id/ 指标详情:大数值 + 参考区间 + 7/30/90/365 趋势图 +
  平均最高最低达标天数 + 这个指标是什么 + 评分依据(取自后端,不在前端另写一份)
- /activity/:id/ 运动详情:概览/数据/分段/图表,数据分组照搬手表的排法,
  心率区间用单色顺序色阶(区间是有序刻度,不是分类,不能用分类色)
- /body-age/ 身体年龄:逐步展示 VO₂max 基准与各项修正,以及每步的出处
- 趋势页 15 个 chip 占满一屏且像张表单,改成弹窗选择,页面上只留一行摘要

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-24 00:39:33 +08:00
ericwyuan
6b4c5375dc docs: 建立需求文档,记录全部需求与进度
每完成一条需求更新状态并推送。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-24 00:28:48 +08:00
ericwyuan
c70e7ced80 feat(api): 个人资料/单位/同步偏好 + 运动详情 + 身体年龄
设置 (services/settings.py, routes/settings.py)
- user_settings 表:身高/体重/出生日期/性别/单位/自动同步开关/同步频率/历史范围
- GET|PUT /api/settings,GET /api/settings/options(取值由后端给,前端不臆造)
- GET /api/settings/rating-basis:把每条参考区间的来源公开出来。
  一个把数字标成「偏低」的区间是在下判断,用户有权看到依据。

运动详情 (services/garmin.py)
- GET /api/garmin/activities/<id>/detail:概览/分段/心率区间/天气/装备/采样曲线
- 首次打开回源 Garmin 并落库,之后走缓存;?refresh=1 强制刷新
- 采样点在写入时抽稀到 300,手机图表画不了更多,也免得整行撑大

身体年龄 (services/fitness_age.py)
- 0.2.8 版 garminconnect 没有 fitnessage 接口,改为本地按公开常模推算:
  VO₂max 对应年龄为基准,静息心率与 BMI 做有上限的修正
- 返回每一步的中间值,界面照实展示,不做成一个不可追溯的分数
- 高于参考表最年轻一档时按 20 岁计——那里外推会得到「11 岁」这种结果

调度器改为每 5 分钟 tick,是否该同步按各账号自己的频率判断

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-24 00:26:26 +08:00
ericwyuan
12ef5ca06b feat(sync): 每小时后台自动同步 + 手动拉取最新接口
- services/scheduler.py:通过 job_locks 表跨 worker 抢占,
  gunicorn 多进程下一个周期只跑一次;claim 超时 30 分钟自动释放,
  避免 worker 中途挂掉把任务永久卡死
- POST /api/garmin/sync-latest:同步执行,窗口 clamp 到 1..7 天
- GET  /api/garmin/auto-sync:返回上次/下次运行时间
- db.py:注释里的分号会被 SCHEMA.split(";") 截断,改为先剥注释再切分

19 项调度器测试,全量 324 项通过

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-24 00:17:04 +08:00
ericwyuan
85c00759f8 fix(ui): 三处实机问题 + hero 改为三圆环
fix: 标题在窄栏里一行一个字
- Framework7 对裸 <button> 有全局样式,把「看数据」按钮拉成整行宽,
  标题/副标题容器被挤成 0 宽,文字只能竖排。给按钮显式 width:auto

fix: 标题旁边有个灰色方块
- 今日页没有右侧动作,但 NavRight 容器仍然渲染并带半透明白底。
  改为有内容才渲染

hero 改为三圆环(步数 / 睡眠 / HRV):
- 每个环以「进入自己的参考区间」为满环,所以三个环的满度含义一致,
  尽管单位不可比
- 并列而非同心:Apple 的同心环成立是因为三环共享一个概念
  (动/练/站),而这三项是互不相关的度量,分开更好读

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-24 00:01:27 +08:00
ericwyuan
fd9b610810 [阶段10.1] Tab 改为 今日/健康/趋势/运动/设置
按指定结构调整导航:每日归入趋势、睡眠归入健康、同步归入设置,
它们是各自 Tab 的详情视图而非独立目的地。新增「运动」Tab。

运动页(新):
- 近 30 天运动时长/距离/消耗,以及今日强度分钟
- 项目分布:按累计时长排序,横条表示占比。只用一个色相 —— 每个项目
  一种颜色会让人以为分类另有含义
- 强度分钟趋势图,附 WHO 每周 150 分钟的参考
- 记录 / 个人纪录 / 奖励三个分页,运动记录 174 条

fix(nav): 五个 Tab 全部加载了同一个页面
- browserHistory 开在每个 View 上,导致它们都去读浏览器地址栏(当时是
  "/")而不是各自的 url,五个 Tab 渲染出五份「今日」
- 浏览器历史只绑定主 View,其余 Tab 用自己的 url 独立加载

fix(nav): 导航栏右侧对所有页面塞同一组图标
- 睡眠/同步/设置三个图标出现在每个页面上,其中两个在多数页面无意义。
  改为按页面声明:趋势 → 每日,健康 → 睡眠,设置 → 同步

fix(viz): 图表标题在窄栏里竖排
- 标题与「看数据」按钮争抢宽度,短标题被压成竖排字符。
  标题允许收缩省略,按钮不收缩

fix: 未映射的运动类型直接暴露 snake_case
- 补充登山/划船/椭圆机等映射;仍未覆盖的转为空格分词并首字母大写,
  而不是把 Garmin 的原始 key 摆给用户看

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 23:52:18 +08:00
ericwyuan
7ab150537d [阶段10] 前端以 Framework7 重建,采用 iOS 原生形态
按要求废弃手写外壳,改用 Framework7 React(theme=ios)。参照 PeakWatch
的信息架构与卡片语言。

保留(这些是资产,不该重来):
- 数据层 services/api.ts、聚合 lib/aggregate.ts、参考区间 lib/ranges.ts
- 图表组件 Chart / Ring / Sparkline / BandBar / MetricCard / MetricStrip
- 经校验的配色令牌(色盲安全 + 对比度,浅深两档)

替换:
- 路由与外壳交给 F7:五个 Tab 各自独立导航栈,推入详情页不影响其他 Tab
- 页面转场、橡皮筋滚动、大标题折叠、半透明栏 —— 这些正是换框架的理由,
  手写做不像
- 底部标签栏改用 F7 Toolbar,触控目标与安全区由框架处理

配色接入:
- 新增 f7theme.css 把我们的令牌映射到 F7 的 CSS 变量,让它的导航栏/
  列表/面板与我们的图表同属一套设计,而不是两种视觉打架
- F7 的深色靠 .dark 类,我们的靠 data-theme,两者在 App 里同步切换

fix: 图标显示为原始名称(squ/hea/cale…)
- iconIos/iconMd 引用的是 framework7-icons 字体,没装就只会渲染出名字

其他:
- tsconfig moduleResolution 改为 bundler —— F7 用 exports 映射,
  node 解析方式找不到它的类型
- 登录页不套 Tab 外壳,未登录时不该出现导航

桌面与手机都要好看:内容在宽屏收进 1100px 居中列并加密卡片列数,
窄屏走底部标签栏;两档都已实机核对。

bundle 190KB -> 399KB,是换取原生手感的代价。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 23:38:21 +08:00
ericwyuan
757afdc941 [阶段9] 界面打磨:hero 圆环、sparkline、骨架屏、动效
参考了 itsdrchen/Garmin-AI-Coach 与主流健康 App(Apple 活动圆环、
Oura/Whoop 的评分环、Garmin Connect 的卡内趋势)的做法。

hero 区(每个视图只有一个 hero 数字):
- 步数目标圆环,超额时转为 status-good 色并保持满环 + 端点标记,
  而不是绕第二圈 —— 绕圈会让 101% 看起来比 100% 还少
- 圆环轨道用填充色同色系的浅阶而非中性灰,两者才读作同一个量器

sparkline(12 点,卡片内嵌):
- 线用去强调色,只有最新一点用强调色,让它成为数字的背景而非对手
- null 处断开而不插值 —— 设备没记录的那天连一条平线过去等于编数据

骨架屏取代转圈:
- 形状与将要出现的内容一致,数据到位时布局不跳
- 用扫光而非闪烁,读起来像进度而不是元素在求关注

动效:
- 卡片/图表依次入场(45ms 递增),引导视线扫过而不是整屏同时砸下来
- 数字滚动到位、圆环扫过、sparkline 描线、悬停抬升、按下微缩
- 全部尊重 prefers-reduced-motion:直接跳过而非缩短时长 ——
  引起不适的是位移本身,不是它持续多久

深色改为冷调(借鉴参考项目):
- 表面从暖近黑 #1a1a19 改为冷近黑 #181b21,更像运动 App
- 换表面意味着原有校验作废,已用校验器对新表面重跑:四个系列色
  全部通过含 3:1 对比度;文字亦逐个复核(正文 14.56:1、
  次要 7.98、弱化 4.38、按钮白字 6.63)

fix(a11y): 大号独立数字不应使用 tabular-nums
- 等宽数字让每个字形都占 0 的宽度,大字号下显得松散;
  等宽只保留给需要纵向对齐的表格列与坐标轴刻度

fix: 数字滚动动画吞掉了小数位
- StatTile 内部按 decimals 格式化,而调用方已经先 round 过一次,
  距离显示成 "9 km"、久坐成 "11 小时"。改为传原值 + decimals

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 22:48:09 +08:00
ericwyuan
bfda1cd017 [阶段8] 新增每日数据模块;趋势改为全指标并列 + 周期聚合
每日数据(新页面 /daily):
- 按活动/能量/心率/压力/睡眠/血氧呼吸/训练七组,列出全部 40 项指标
- 日期选择器 + 前后一天翻页;"只显示有数据的指标"开关
- 附当天的运动记录明细
- 标题处显示当天记录到多少项,缺数据一目了然

趋势(重写):
- 15 组指标全部并列展示,不再一次只能看一个
- 标签可逐个隐藏/显示,选择存入 localStorage(每次刷新都重置的
  选择算不上偏好);提供全选/全不选
- 两级筛选:范围(一月/一季/半年/一年/两年)× 周期(每天/每 7 天/
  每月/每季度)。周期选项按范围过滤,避免出现"近一月按季度聚合"
- 所有图表共用同一份聚合结果,一行筛选器统摄全部图表

聚合口径(lib/aggregate.ts):
- 无论累计型还是速率型指标,一律折算为"周期内日均",这样 30 天的
  月份和 31 天的月份不会仅因日历差 3%
- 周按最新一天往回切,而不是按自然周一 —— 否则开头会出现一个半空
  的桶,看起来像低谷,其实只是窗口起点
- 累计型指标的统计标签写作"日均"而非"平均",读者不必猜口径

fix(viz): 聚合后柱形/面积图掩盖了变化
- 柱形与面积都以延展量编码大小,必须从 0 起;而一年的月均步数都在
  9,590~12,701 之间,画出来几乎一样高,恰恰看不见要看的变化
- 聚合视图改用折线:折线编码位置而非延展量,非零轴是正当的。
  改后 y 轴自动落在 9350~12750,走势清晰可读

fix(health): 运动记录按日期筛选时 500
- get_activities 复用了按 date 列过滤的子句,但 activities 表只有
  start_time,报 "Unknown column 'date'"。此前唯一的调用方不传
  日期,所以一直没暴露,每日数据页一传就炸
- 上界改用次日零点的开区间:SQLite 按字符串比较,而存储的分隔符
  可能是 'T'(0x54) 也可能是空格(0x20),写成 "end 23:59:59" 会让
  当天 18:30 的记录排在上界之后而被排除在自己那天之外

tests (+6, 共 305): 运动记录的单日范围、上下界闭合、仅起点/仅终点、
不传范围返回全部、端点级验证

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 22:33:59 +08:00
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
ericwyuan
6b05d04773 数据层可插拔(SQLite/MariaDB) + 后端 services 骨架
- database.ts 改为按 DB_TYPE 切换 SQLite / MariaDB(mysql2),sqlite3 按需动态导入
- index.ts 改为初始化数据库后再监听端口
- 新增 AuthService / HealthService / AnalysisService / GarminService
- 新增 .gitignore、server/.env(忽略) 与 .env.example
- README 增加数据库配置说明;package.json 增加 mysql2/@types
2026-08-23 11:58:07 +08:00
ericwyuan
d73405decb Initial commit: Set up Garmin Health Lab project structure
- Initialize monorepo with root workspace configuration
- Set up Express.js backend with TypeScript
- Set up React 18 frontend with TypeScript
- Create database schema with SQLite
- Implement project architecture and documentation
- Add development and deployment guidelines

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 11:11:29 +08:00