Commit Graph

11 Commits

Author SHA1 Message Date
ericwyuan
5e6fc01f76 feat(sync-history): 新增同步结果查询(每次自动/手动/立即同步的记录)
后端:
- db.py SCHEMA 新增 sync_history 表(不可变,每次同步尝试一行) + 索引
- garmin.py: _log_sync_history() 在 sync_data 全部出口记录; trigger 区分
  auto(调度器)/manual(同步页开始同步)/quick(设置页立即同步); start_sync
  限流拦截分支同样留档; 写入失败只告警不影响同步
- scheduler.py 自动同步传 trigger="auto"; routes 新增 GET /garmin/sync-history
  按时间倒序返回(默认 50 条,上限 200)

前端:
- api.ts 增加 SyncHistoryItem 类型 + getSyncHistory()
- 新页面 /sync-history/ 同步记录: 卡片列表, 状态(成功/失败/被限流)chip 配色,
  时间本地化(今天/昨天/X月X日), 触发类型标签, 范围与耗时
- 同步页与设置页同步区块均加入口链接
2026-09-02 20:42:26 +08:00
ericwyuan
92ea33b800 feat(sync): 全部历史真的是全部,不再截断在两年
730 天是个凭空写死的上限。账号有七年数据的人选「全部历史」,拿到的是
最近两年,而且没有任何提示说剩下的被丢掉了。

* 全部历史现在一直回溯到账号最早的数据:连续 EMPTY_RUN_STOP(120) 天
  完全没有内容就停,所以既不会截断,也不会去问手表存在之前的年份。
  MAX_HISTORY_DAYS(3650) 只是兜底,可用环境变量覆盖。
* 已经存过的日期跳过(最近 3 天除外,它们还在写入中)。这让多年的
  回填变成可续传的:撞上限流停下来,冷却过后再点一次就从断点继续,
  而不是每次都从今天重新爬。
* 选择器补上 3 年 / 5 年。
* 前端把每个范围的实际代价写出来,并说明中断可续。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 09:30:03 +08:00
ericwyuan
526ece7d14 refactor(sync): 手动同步与自动同步彻底分开
「历史范围」原本放在设置页,却只对同步页的一个按钮起作用;而同步页最
显眼的主按钮「同步最新数据」写死 2 天,根本不看这个设置。选了「全部
历史」再点主按钮,表现就是应用无视你 —— 这正是反复出现的「只同步下来
两天」。

现在两条链路各管各的:

* 自动同步:只在设置页配置(开关 + 频率),窗口固定 SYNC_DAYS,不再
  读 history_days。措辞也改成「拉取最近几天」,不再暗示会补历史。
* 手动同步:范围就在同步页当场选,紧挨着用它的按钮,并标出每个范围的
  实际代价(自上次同步 / 7 天 / … / 全部历史约 730 天、20-40 分钟)。
  两个按钮合成一个「开始同步」,写死 2 天的那个删掉。

history_days 保留为「上次手动选的范围」,只有同步页读它;默认值改成
-1(自上次同步),对日常使用是正确的起点。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 09:20:52 +08:00
ericwyuan
41b7ae82e4 fix(sync): 「全部历史」真的拉全部历史,自动同步不再每次静默失败
四个独立的 bug 叠在一起,表现为「只同步两天、没有进度」:

* 前端 `...(days ? { days } : {})` 把 days=0 当成未传。「全部历史」
  存的就是 0,请求体里根本没有 days,后端退回 7 天默认值。
* scheduler 用 `s[0]` 读 query_one 返回的 dict,抛 KeyError 后被
  per-account 的 except 吞掉。只要用户存过一次设置,每 30 分钟的
  自动同步就一次都没成功过——库里那 2 天全是手动点出来的。
* 增量同步查 `health_daily`(表其实叫 health_data),后台线程直接
  死掉,状态永远卡在 syncing,进度条不动。
* UI 完全不看 /sync 的返回值,rate_limited 时按钮点了没反应;轮询
  结束时又把 rate_limited 归进 else 分支报「同步完成」。

顺带:
* 日循环遇到 429 立即退避并保留已拉到的天数,而不是当成「跳过一天」
  继续往下捶 700 天——这正是之前限流死循环的来源之一。
* 定时循环显式传 SYNC_DAYS。历史范围按 UI 文案只描述手动全量同步,
  让半小时一次的 tick 重拉 730 天必然把限流撞得更深。
* 短同步逐天上报进度(原来每 5 天一次,7 天的同步全程停在 0)。
* /sync 路由重复解析 body,空 body 会 None.get 崩。
* 4 个 StubGarth 缺 configure(),7 个测试在此之前一直是红的。

新增 deploy/push.sh:NAS 只认密码,脚本开一个 ssh 复用连接,密码只
输一次,后面推送 / 重启 / 健康检查全走它。不碰 .env、.venv 和数据库。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:11:24 +08:00
ericwyuan
15fba8c25c feat: NAS 部署 + auth-hub 认证 + 同步逻辑修复
部署:
- 后端 Python/Flask,数据层可插拔 SQLite/MariaDB
- NAS 部署路径 /volume1/web/garmin-health-lab,端口 8124
- Gunicorn 生产服务器 (2 workers / 4 threads)
- 开机自启脚本 deploy/S99garmin.sh
- frp 隧道甲骨文 8124 → NAS 8124,外网访问
- 前端构建产物纳入版本管理 (backend/static/)

认证:
- auth-hub OAuth2/OIDC 统一登录接入
- 新建 NAS 专用 client,注册内外网回调地址
- 前端 LoginPage 支持回调路由 /auth/callback
- 数据同步页增加 Garmin 邮箱输入框

同步逻辑修复:
- 自动同步调度器读取用户 history_days 设置,不再固定 2 天
- 前端 0(全部历史)不再被 || 吞掉,改为 ?? 处理
- 后端路由和 sync_data 中 0 不再被当成 falsy 回退默认值
- sync_data 和调度器中 0 → 730 天(全部历史=最大范围)
- 已同步天数显示数据库实际总天数 (totalDays)
- 历史范围新增「自上次同步」增量选项 (days=-1)
- 后端 settings.py HISTORY 添加 -1 值

项目文档:
- 创建 PROGRESS.md 跟踪项目进度
2026-09-01 07:39:09 +08:00
ericwyuan
bec45414a7 feat(garmin): 增加「退出 Garmin 账号」功能,删除已保存令牌以便重新登录
backend: services/garmin.py 新增 delete_token(),删除 garmin_tokens 行并 forget_client 丢弃缓存会话;routes/garmin.py 新增 POST /api/garmin/disconnect(require_auth),返回 ok + 提示文案。
frontend: api.ts 增加 disconnectGarmin();SyncPage 增加「退出 Garmin 账号」按钮(带二次确认弹窗);DataSync.css 增加危险色样式。
设计:仅删除 OAuth 令牌,保留 users.garmin_email,下次重登只需密码;已同步的健康数据不受影响。
2026-08-28 15:13:32 +08:00
ericwyuan
6ad87115ab fix: 返回键失效的根因 + 同步页重做 + 连接池会永久阻塞线程
返回键
- 根因是 Framework7 的页面过渡由动画事件驱动:push 时把 allowPageChange
  置 false,等动画报告结束再恢复。这个报告不来,路由就永久卡住,之后每次
  导航都被静默丢弃,back() 还会把上一页重建一份而不是弹出。
  实测对照:navigate({animate:false}) 前后状态完全正确,带动画则必卡。
  因此关掉页面过渡动画——导航同步完成,处处正确。动效改由内容承担
  (卡片入场、hero 揭示、顶部进度条),这个取舍里正确性优先。
- Screen 的返回改为显式 handler,先清掉残留过渡状态再 back(),
  不依赖路由自己的闸门。注意只清视图上的 router-transition 类:
  页面自身的 page-previous 是 F7 判断「回到哪一页」的依据,
  一并清掉会导致重建出一个重复的页面(中途踩过这个坑)。

同步页
- .btn 系列样式原本只定义在 pages/Pages.css,而那个文件只被一个没有路由的
  遗留页面引用,所以真实页面上按钮全都退化成 Framework7 的默认样式——
  就是你看到的三条链接。样式移进每个界面都会加载的 Screen.css。
- 主次分明:一个填充主按钮 + 两个带副标题的次按钮;补上「同步会取哪些数据」
  说明,页面不再是一大片空白。
- 进度条显示当前阶段(每日数据 2026-08-01 / 运动详情 12/174 / 身体成分…),
  原来只有「0 / 730 天」,几分钟里完全看不出在做什么。

后端
- MariaDB 连接池:_mariadb_release 用的是阻塞 put(),而队列 maxsize=10,
  _mariadb_acquire 在池空时又会新建连接。并发超过 10 之后,归还的线程会
  永久停在 put() 上,请求就此挂死。改为 put_nowait,多出来的连接直接关闭。
- 进程重启会带走同步线程却留下 status=syncing 的行,界面上是一个永远不动
  的进度条,还拒绝开始新同步。启动时清理。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-24 08:41:48 +08:00
ericwyuan
34940cc387 feat(sync): 运动详情改为同步入库,详情页只读本地
按需回源是错的:点一次运动要等七个 Garmin 接口,网络好的时候慢,
网络差的时候直接超时(实测公网下 Network Error)。

- sync_data 顺带补齐缺详情的运动
- POST /api/garmin/sync-details 后台补齐存量,GET 查进度
- 详情页只读本地库;没有就提示去同步,不再回源
- 同步页新增「补齐运动详情」按钮,带进度

身体年龄:加入公开的阻尼系数
- 34 岁 VO₂max 46 原本算出 21 岁。不是算错,是方法本身会饱和:
  人与人之间的 VO₂max 标准差约 7,而年龄每年只带来约 0.35 的衰减,
  于是稍微能练的人都会撞到参考表最年轻一档。
- 按 50% 向实际年龄收拢,收敛范围 ±20 → ±12 岁,同一算例现在给 27 岁。
- 去掉「高于最年轻一档按 20 岁计」的硬地板,那是一道正好落在用户身上的悬崖。
- 界面同时显示未收拢的原始值,阻尼系数写进评分依据。

路由:为每个路径补无斜杠别名
- F7 写地址栏时去掉尾斜杠,于是 /daily/ 在地址栏是 /daily,
  而那个地址匹配不到任何路由,刷新或分享就落到「找不到页面」。

布局:让页面结构上无法被撑宽
- 网格改用 minmax(min(210px,100%),1fr):裸的 minmax(210px,1fr) 允许
  两列加起来超过窄屏宽度,第二张卡就被切掉在屏幕外。
- .ring-row 用 minmax(0,1fr),1fr 会以 min-content 兜底,一句长说明就能
  把整行顶宽。
- .page-inner 加 overflow-x: clip。
- html/body 用 100dvh:手机浏览器把自己的地址栏盖在布局视口上,
  100% 高的应用会把底部 Tab 栏顶到它们下面——对用户来说就是没有 Tab 栏。

测试:新增 122 项(设置 44、身体年龄 44、运动详情 42),全量 446 项通过。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-24 04:03:49 +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
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