ericwyuan
|
617c8325a6
|
fix(analysis): refill_backlog 活动查询去掉 CAST(... AS TEXT)
MariaDB 不支持 CAST 到 TEXT(仅 SQLite 支持),导致该语句在 prepare 阶段
直接 1064,activity 分支每次 idle 循环都报错刷屏——且无论 backlog 是否有
数据都会失败(语法错误先于执行)。activities.id 本身是 VARCHAR(64),
ai_jobs/ai_insights.subject 是 VARCHAR(96),两侧同类型,CAST 本就不必要。
改为直接比较 ai.subject = a.id / aj.subject = a.id,双后端(SQLite/MariaDB)
语义一致。
已在生产 MariaDB 上只读验证两段 SQL 均正常执行(daily-backlog=0,
activity-backlog=0),本地 analysis/ai/coach 测试全绿。
|
2026-09-02 20:18:40 +08:00 |
|
ericwyuan
|
224144915c
|
fix(sync): 限流拦截不再把'上次同步'刷成现在 + 设置页新增立即同步按钮
- _set_sync_status 支持保留旧 last_sync_time;两处限流拦截(start_sync /
sync_data)改为传旧值——被拒的同步零请求发生,不能把 UI 的'上次同步'
拨快成'刚刚',掩盖数据早已停更的事实
- SettingsPage 同步区块新增'立即同步'行:走 /garmin/sync-latest(最近2天,
秒级阻塞返回),成功/限流/未绑定三种状态都有明确反馈;限流中后端零请求
拦截并回传恢复倒计时文案
|
2026-09-02 20:14:11 +08:00 |
|
ericwyuan
|
2d9a2be185
|
docs: 文档/配置/测试同步 NAS :8124 生产现状,清除 Node.js 与甲骨文残留
背景:文档停在 Node.js 时代或甲骨文 8123 部署,与生产(NAS :8124 + Flask +
auth-hub + ai-gateway)严重脱节,曾导致凭旧记忆误判'无线上环境'。
- CLAUDE.md 重写:技术栈/结构/命令/部署事实/关键坑(F7 button、UTC 日期、
429 退避以 DB 为准、迁移幂等、AI 生成耗时)
- docs/ARCHITECTURE.md 重写为 Flask 蓝图+services+可插拔数据层 + NAS 部署
- docs/DEVELOPMENT.md 重写为 Flask/CRA 开发指南 + push.sh 部署流程
- docs/REQUIREMENTS.md:部署条目改 NAS 8124;补 auth-hub/AI 教练/新修复
- docs/AUTH_HUB_INTEGRATION.md 新增(补 .env.example 悬空引用)
- README.md:技术栈/DB/auth-hub/API 清单/部署节修正
- backend/config.py 与 .env.example:AUTH_HUB_REDIRECT_URI 默认 8123→8124,
MariaDB 注释 Oracle→NAS
- tests:GatewayCourtesy 并发测试对齐 MAX_CONCURRENT(AI_JOB_CONCURRENCY=2);
conftest 禁用 create_app 后台队列线程,修整库测试 flaky(585 passed)
|
2026-09-02 19:34:32 +08:00 |
|
ericwyuan
|
68363957ec
|
fix(ui): Copilot 浮窗按钮被 F7 全局 width:100% 撑满,显式覆盖为 width:auto
Framework7 自带 button { width: 100% } 且打包后排在 Copilot 规则之后,把
面板内 ✕ 与发送按钮拉满父容器,input 被挤成 21px、✕ 跑到面板中部。
在 .copilot-close / .copilot-compose button 上显式 width:auto + flex 固定,
重建并同步 backend/static 产物(main.00d05e81.css)。
|
2026-09-02 19:34:24 +08:00 |
|
ericwyuan
|
bc91f328c9
|
docs: 记录 deploy.sh 修复前端构建复制
|
2026-09-01 19:00:58 +08:00 |
|
ericwyuan
|
317cb4996d
|
fix(deploy): 构建前端后复制到 backend/static/,避免部署旧版
之前 deploy.sh 只打包了 backend/ 目录,但前端构建输出在
client/build/,从未被复制到 backend/static/,导致部署的
始终是旧版前端。现在在 build 后自动复制。
|
2026-09-01 19:00:42 +08:00 |
|
ericwyuan
|
dd4105eb36
|
docs: 更新进度,记录每日/运动详情持续后台排队功能
|
2026-09-01 18:15:59 +08:00 |
|
ericwyuan
|
7fd4106b0a
|
feat(ai): 每日和运动详情在后台持续排队生成,不限量
- 移除 prefetch_insights 中 daily 的 LIMIT 30 和 activity 的 LIMIT 50
- 新增 refill_backlog(),在队列空闲时自动查找未排队的 daily/activity 项
- 新增 jobs.set_refiller() 机制,worker 空闲时调用 refiller 补充队列
- 队列空闲时每 5 个轮询周期触发一次 refill,持续填充 backlog
|
2026-09-01 18:15:26 +08:00 |
|
ericwyuan
|
05f55e695b
|
feat(ai): 设置里加「AI 生成队列」,看得见后台在算什么
队列本来是完全不可见的:页面上一句「排队生成中」说不出自己是下一个、第二十
个,还是已经放弃了——网关挂掉的时候,「还在生成」和「永远不会好」长得一模
一样。今天排查就是这么排的。
- GET /analysis/insight/queue 返回队列(running 在前,其次按优先级和年龄,
和 worker 实际取任务的顺序一致)、已生成的解读、scope 名到中文标签的映射
(前端不必再抄一份),以及消费者的限流配置
- POST /analysis/insight/queue/retry:手动把「已放弃」的重新排队,不等冷却。
自动重试要等冷却是为了不去捶一个正在抽风的上游;人按下重试是他自己判断值得
再试一次
- 页面在 设置 → AI 生成队列。插队的任务标「插队」——这是整个界面最想让人看见
的一件事:为什么是它排在最前面
- 「已生成」单独列:队列空了意味着「没有待办」,不是「什么都没生成过」,
没有这一节这两件事在界面上没法区分
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 16:02:05 +08:00 |
|
ericwyuan
|
a2d6a5c57f
|
fix(ai): 队列会把共用的网关打到 502,加并发上限和间隔
排查生产上一直生成不出来,发现网关在返 502。上去看:机器好好的、systemd
说 active、5100 端口在监听——但它是 `gunicorn -w 1 --threads 4`,全部并发
就四个,而且 fam-edge 和摄像头项目也在用同一个。
我们这边一个请求占一个线程 2~5 分钟,NVIDIA 链重试起来最坏十七分钟(它自己
README 已知问题 #3)。而我写的 worker 是跑完一个立刻拉下一个,同步后还有八
个 scope 排队——等于拿满线程不撒手。这个 502 大概率是我打出来的,而且顺带
把另外两个项目也打下线了。
- 并发按整个部署计算,不是每个 gunicorn worker 一个:claim 前先数全局
running(两个 worker 各跑「一个」就是两个并发)
- 每跑完一个任务停 20 秒,不只是空闲时才停
- 两个都可用环境变量调,注释里写清楚调大的代价是什么
补齐的历史数据晚二十分钟到没有任何人受影响;网关不响应是三个项目一起受影响。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 15:46:42 +08:00 |
|
ericwyuan
|
2488956f36
|
fix(ai): 折叠态只显示一句话,那句话得说清是哪个指标
精简版的卡片正文就是第一条 highlight,标题不渲染。于是趋势页折叠起来是
「395 天内由 4376.0分 到 4905.0分(改善)」——什么的 4376 分?身体成分、
运动详情同样。
每个 builder 的第一条 detail 现在都自带主语,并加了不变量测试:detail 必须
包含它自己的 title(纯汇总性标题除外)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 15:41:28 +08:00 |
|
ericwyuan
|
6dd070ec9b
|
fix(ai): subject 里塞了行数,每轮询一次就新建一个任务
生产上 trends 队列里积了 36 个任务,subject 是 2026-09-01:1033、:1039、
:1044……一路涨。这台账号当时正在补历史,get_summary 的行数每隔几分钟就变,
而我把 len(rows) 写进了 subject——subject 同时是缓存键和任务队列的键,一变
就是一条全新的任务,轮询几次就刷出十几条。
subject 该回答的是「这条解读是关于什么的」,不是「当时有多少行数据」。
数据变化本来就由 fingerprint 负责。
- trends 的 subject 改成快照日期;sleep 用配置的窗口常量而不是实际夜数
(缺一晚也不该换键);challenges 用固定键
- 加了不变量测试:补一天历史数据后 subject 不许变;任何 subject 段都不许
长得像行数
顺带加一层兜底 jobs.supersede():单实例 scope 只该有一个在跑的 subject,
队列里同 kind 的其它 pending 任务是关于已经不存在的快照的,跑完也没人看。
per_item 的 daily / activity 不受影响——它们本来就一天一条、一次运动一条。
兜底不是机制,机制是 subject 稳定;它存在只是因为这次 subject 不稳定,而
36 条任务堆在那里之前没人发现。
顺带按要求把 AiPanel 改成默认精简:只显示标题、来源和一句话结论,点「展开
详细」才出要点/建议/依据,可再收起——和今日晨报卡片一致。这些面板压在本来
就很密的图表页上面,全部默认展开会把真正的数据一次性挤到屏幕外。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 15:33:49 +08:00 |
|
ericwyuan
|
241ae0d6a3
|
feat(ai): 每个数据页面都有 AI 解读,靠一条带优先级的生产者/消费者队列
原来只有今日页有晨报、指标详情页有归因,其余页面一片空白。现在除设置外
的 10 个页面都有:健康、睡眠、运动、趋势、每日、身体成分、成绩预测、
身体年龄、挑战赛、运动详情。
不是给每个页面写一套,而是一个通用管线:
- services/scopes.py:一个页面一个 context builder,返回同一个信封。
context["highlights"] 是已经算好的白话事实——模型负责解读它们,模型不
可用时规则引擎原样渲染。两者引用同一批数字,所以降级读起来不像换了个 App。
没数据的页面返回 None,宁可不出卡片,也不让模型对着空表格发挥。
- coach.scope_messages / parse_scope_insight:一套提示词吃所有页面,页面
的差异全在 context 里,加页面 = 加一个 builder。
- 前端 <AiPanel scope="…">:一个组件渲染所有页面,轮询逻辑抽成
lib/insight.ts 的 usePolledInsight,晨报卡也改用它。
## 队列
一次生成 40 秒到 4.5 分钟,所以什么都不能在请求里生成。页面只负责入队,
worker 负责消费(services/jobs.py)。
优先级才是用队列而不是后台线程的理由:同步完成后 prefetch 把所有页面按
背景优先级排进去,可能要跑半小时;而用户一打开某个页面,那个页面的任务
立刻提到队首、下一个就跑。你在看什么,队列就在算什么。
队列放在数据库而不是内存里,因为 gunicorn 有两个 worker:任务带 holder
声明后回读确认,和 scheduler.py 抢 tick 是同一套做法。id 由
user+kind+subject 推导,所以每几秒一次的轮询是幂等的入队,不会每几秒堆一
个任务。
## 网关中断时踩到的两个坑(当场修了)
写完正好赶上 oracle 那台机器不通,于是看到:
- 三次失败后任务被永久标 failed,网关恢复了也不会重试——一次瞬时中断就把
那个页面的解读判了死刑,直到它的数据碰巧变化。加了冷却期,过期后重置
尝试次数再排一次。
- 队列已经放弃了,页面还在 pending 转圈,要转满 8 分钟才停。meta.pending
现在跟着队列状态走,并把失败原因带给卡片。
顺带把 BAND_SOURCES 从 routes/settings.py 下沉到 services/insights.py:
教练要拿它做参照,而 services 不该反向依赖 routes。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 15:17:17 +08:00 |
|
ericwyuan
|
984a06a098
|
fix(deploy): deploy/ 自己没跟着上机,改它等于没改
重启跑的是 NAS 上那份 stop.sh / start.sh,而 push.sh 只同步 backend/ 和
client/build/。结果就是:修好 stop.sh 的 pgrep、提交、部署、报成功,远端
照样跑着旧脚本——上一个提交正是这么被架空的。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 14:34:59 +08:00 |
|
ericwyuan
|
14b6bbecb4
|
fix(deploy): 群晖没有 pgrep,那三处「守卫」一直是空转
上一版给 push.sh 加的 pid 比对刚跑就露馅了:`sh: pgrep: command not found`,
于是 BEFORE 和 AFTER 都是空字符串,`[ -n "$BEFORE" ]` 直接跳过检查——我刚加
的安全网自己就是个摆设。
顺手查了一圈,stop.sh 和 start.sh 里同样的 pgrep 也一直在空转:
- stop.sh 的 `pgrep ... || exit 0` 每次都以 127 失败,永远匹配不到那个提前
返回,所以每次停服都白等满 15 秒,也从没真正确认过残留 worker 已经没了。
- start.sh 的等待循环同理,白等 20 秒。
DSM 有 pkill 没有 pgrep,而且非特权的 ps 看不见 root 起的进程(服务是开机
以 root 启动的),两者任一都会让检查静默返回「没有」——这正是最危险的答案,
因为它长得和「已经停干净了」一模一样。
- push.sh 改用 `sudo ps -eo pid,args | grep`;并且 AFTER 为空时直接报错退出,
不再把「看不见」当成「通过」
- stop.sh / start.sh 改用 `ps -eo args | grep -q`
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 14:34:27 +08:00 |
|
ericwyuan
|
570d1590eb
|
fix(router): 深链接全被塞进「今日」,点底部标签页显示的是别人的内容
打开 …/sleep/ 之后,底部点「今日」出来的是睡眠,点「健康」出来的健康从来
没去过地址栏说的地方。
原因:browserHistory 只开在主视图(今日)上,而 Framework7 在启动时也会用
它去消费地址栏——它只认识这一个视图。于是不管哪个标签页真正拥有那个路径,
睡眠都被压进了**今日**的栈,还顺手伪造了一份两条记录的 history。
- View 加 browserHistoryOnLoad={false}。注意不是 browserHistoryInitialMatch
——那个只决定两条记录里渲染哪一条,伪造的栈照样留着(读 router-class.js
的 getInitialUrl 才看明白)。之后的导航追踪不受影响。
- routes.ts 加 tabForPath():冷启动的 URL 归哪个标签页。只有深链接查这张表,
应用内点击照旧——从今日卡片点进睡眠,仍然留在今日。
- App.tsx 的 useDeepLink 负责派发初始地址,并把目标页压在该标签页自己的根
之上,这样返回箭头回到的是健康,而不是空栈。
派发要延后一帧再做、并在 unmount 时取消:StrictMode 会把 <View> 挂两次,
第一次挂载的 F7 视图会被销毁重建,在它上面导航的结果全丢——症状是一个睡眠
页孤零零留在 DOM 里,router 却坚称自己在健康。另外比较路径要先归一化斜杠,
每个页面都注册了带斜杠和不带斜杠两种写法,直接比会把趋势压在趋势上面。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 14:31:34 +08:00 |
|
ericwyuan
|
f6687d2b5d
|
fix(deploy): push.sh 报「部署成功」,但跑的还是旧代码
服务是开机时以 root 起的(DSM 任务计划 → S99garmin.sh),所以 logs/
error.log 和 access.log 归 root。以 ericwyuan 重启会立刻失败——gunicorn
连自己的错误日志都打不开;而 stop.sh 同样杀不掉 root 进程,于是旧 master
继续占着 :8124,start.sh 的健康检查看到 200,高高兴兴报了「started」。
这次 AI 教练部署就撞上了:文件推上去了,接口却一直 404,查了半天才发现
在跑的是几小时前那个进程。
- 重启改走 sudo,和服务实际的运行身份一致(密码问一次,或走 NAS_PASSWORD)
- 健康检查加一条:只有 200 不算数——那正是旧 master 会给的答案。比对重启
前后 gunicorn 的 pid,没变就报错退出
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 14:11:17 +08:00 |
|
ericwyuan
|
c57c930949
|
feat(ai): AI 教练 —— 晨间简报、运动处方、趋势归因与 Copilot
数值全部在服务端算好再交给模型,模型只做解读。让模型从 CSV 里自己推
z 分数,它算错的次数足以让简报引用图表反驳它的数字。
- services/insights.py:z 分数(28 天个人基线,且**排除当天**——用一个
值参与算出来的均值去衡量它自己,会把真实离群点摊平)、13 个月趋势斜率
(按序数日期最小二乘,手表放充电器上一周不会压缩 x 轴)、近 7 天活动量
对比。
- services/coach.py:三套提示词 + 回复解析,每套都配一个规则引擎版本。
网关一次生成要几分钟,上游被限流时给一个朴素的答案,好过给一张空卡片。
- services/ai.py:多轮 chat()、SSE stream()、complete()/stream_chat(),
以及 extract_json()——上游是推理模型,可见输出以思维链开头,所以从末尾
倒着找最后一个配平的 JSON(字符串感知,扛得住引号里的 } 和转义引号)。
- 接口 briefing / trend-insight / copilot(SSE),缓存表 ai_insights。
- 前端:今日页晨报卡(后台生成 + 轮询升级)、全局 Copilot 浮窗、指标详情
页归因面板。features.ai 打开。
实测(对着自建 ai-gateway):晨报一次 273 秒,缓存命中 18 毫秒——所以简报
绝不能同步阻塞首屏。网关的流式通道比阻塞通道更不可靠:同一条提示词流式
139 秒后返回「所有模型均不可用」,阻塞则成功,因此 stream_chat() 在流式零
输出时对同一模型退回非流式重试。Copilot 实测 TTFB 9ms、全程 40 秒。
顺带修两处:refresh 原来只跳过缓存读、不删行,导致「重新生成」后的轮询读
到旧行、看到 cached 就停了,用户一直盯着他刚要求替换掉的那段字;基线零方差
时原来返回 z=0.0,把「和每一条观测都不同」标成「完全正常」,改为 z=null。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 13:57:35 +08:00 |
|
ericwyuan
|
a746327560
|
fix(ui): 奖励卡片被 Framework7 的 .badge 压成灰色小药丸
.badge 在三处定义:Screen.css 的奖励卡、Settings.css 的状态药丸、以及
Framework7 自带的消息计数小圆点。F7 的样式表在打包顺序里排最后,于是
它赢了——每张奖励卡都被强制成 display:inline-flex、固定 20px 高、灰底
白字的圆点,名字和日期硬塞在里面。Screen.css 精心写的卡片样式基本全被
覆盖。65 个奖励堆在一起,看着自然不成样子。
* 奖励卡改名 .award/.award-grid/.award-name/.award-meta/.award-count,
彻底避开 F7 的全局类名。
* Settings.css 里的 .badge/.badge.ok/.badge.off 没有任何地方引用,是
死规则,唯一作用就是参与撞车——删掉。
* 顺手让卡片等高、日期用等宽数字对齐、×N 做成右侧小标签。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 09:34:53 +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
|
4d55fb4776
|
chore(deploy): 别把 macOS 的 xattr 塞进 tar,群晖读不懂只会刷警告
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 09:09:21 +08:00 |
|
ericwyuan
|
1c48435b9d
|
chore(deploy): NAS 的 ssh 端口是 2222
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 09:05:35 +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
|
9503fca370
|
feat(auth): 接入 auth-hub 统一登录,网页登录与 Garmin 同步彻底分离
网页身份改由 auth-hub 做 OAuth2 + PKCE 单点登录,本地邮箱/密码登录与注册整条链路删除
(routes/auth.py、auth.py 的密码哈希、config.py 的 ALLOW_REGISTRATION)。Garmin 账号绑定/
同步保持完全独立、可选:routes/garmin.py 不再直接查 users 表,Garmin 邮箱回退统一走新增
的 services/garmin.py::get_remembered_email()(优先读 garmin_tokens 当前绑定,兼容早期账号
落在 users.garmin_email 的历史值),彻底把「你是谁」和「你绑没绑 Garmin」两件事拆开。
- db.py: users 表新增 auth_hub_sub/auth_hub_username,MIGRATIONS 补上这两列(此前遗漏导致
已存在的生产 MariaDB 表永远不会自动加列);同时把历史遗留的 garmin_email/
garmin_password_hash NOT NULL 约束在线迁移为可空,因为新账号不再在注册时收集这些字段。
- routes/auth.py: 修掉 /callback 路由重复拼接 /api/auth 前缀导致 404 的 bug。
- client: LoginPage 去掉本地登录/注册标签页,只保留 auth-hub 统一登录;登录成功/失败后都
用 history.replaceState 清理地址栏,修掉 Framework7 browserHistory 读取
/auth/callback?code=... 导致「找不到页面」的问题。
- 新增 test_auth_hub_client.py 锁定 find_or_create_user 按 auth_hub_sub 幂等——生产上曾经因为
这个函数在没有该测试保护时被测试触发,误建过一个空账号,靠手工核对 health_data 计数才发现。
- 生产 auth-hub 侧另行为该项目注册了正式 client(未随本次提交变更,凭证只存在服务器 .env)。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
2026-08-31 23:12:17 +08:00 |
|
ericwyuan
|
7e51223eb9
|
fix(rate-limit): _is_rate_limited 识别 RetryError 的 429 形态,登录 429 真正触发自动退避
生产实测:登录时 garth 把 429 折进 RetryError('too many 429 error responses'),无 status_code、无 'rate limit' 字样,旧检测漏判 → 走 else 分支存原始报错、不调 _note_rate_limit,冷却永远不落库,用户可反复撞 Garmin。\n\n- garmin.py: _is_rate_limited 沿 __cause__ 链找 429 响应,并接受文本中的 '429' 标记。\n- garmin_auth.py: 退避文案 6h 改为 24h。\n- 生产:已补挂 24h 冷却(2026-08-30 10:00 北京),RetryError 形状验证识别为 429。
|
2026-08-29 10:00:30 +08:00 |
|
ericwyuan
|
30ae431ed9
|
fix(rate-limit): 退避 24h 且守卫以 DB 为准,终结限流死循环
根因:Garmin 限流窗口 >6h,旧 6h 退避每到期就再撞一次 429,永久循环无法退出;且 _note_rate_limit 的 DB 写入被 except:pass 静默吞掉,worker 内存揣着未来冷却时间、DB 停在旧值,守卫被陈旧内存卡死(用户冷却已过仍被拦)。\n\n- RATE_LIMIT_BACKOFF 6h->24h:一次 429 静默一天,真正超过 Garmin 窗口。\n- rate_limited_until() 改为 DB 为准(内存仅在无行时兜底):杜绝陈旧内存永久拦截。\n- _note_rate_limit 失败改为 logging 输出,不再静默吞错。\n- 生产已把冷却设到 2026-08-30 09:45 北京时间。
|
2026-08-29 09:45:49 +08:00 |
|
ericwyuan
|
ae126f90d4
|
fix(rate-limit): 退避延长至 6h 且登录路径同样遵守,打破 Garmin 限流死循环
根因:迁移后自动同步+旧 oauth2 刷新轰炸把 Garmin 账号撞进限流黑洞;退避原仅 60min 且登录路径完全不检查,导致每小时退避到期又撞一次 429、登录也直接连 Garmin 被 429,窗口永远退不出。
- garmin.py: RATE_LIMIT_BACKOFF 60min->6h;_note_rate_limit 改为固定 now+6h(不再短延期)。
- garmin_auth.py: _run_login 入口先查 rate_limited_until 拦截(不撞 Garmin),遇 429 也调 _note_rate_limit 延长退避。
- 已在生产把 sync_status.rate_limited_until 设到 now+6h,强制 Garmin 安静以让窗口真正关闭。
|
2026-08-28 17:18:37 +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
|
11869c6a8d
|
fix(sync): 限流退避持久化到 DB,调度器与手动同步均尊重退避期
db.py: 修复迁移非幂等——gunicorn 双 worker 并发 init_db 时后到者遇 Duplicate column 会让整个服务起不来,改为吞掉 duplicate column 错误。garmin.py/scheduler.py: 退避期写入 sync_status.rate_limited_until,自动同步与手动同步在退避期内跳过,不再反复撞击 Garmin 把限流窗口越撞越深。
|
2026-08-28 14:54:10 +08:00 |
|
ericwyuan
|
a4bedc263f
|
fix(sync): 限流检测漏掉了它真实的样子
上一版加了 429 识别,但点同步按钮报的还是 JSONDecodeError——因为检测
只看 `e.response.status_code` 和异常消息,而真实到达我们手里的异常两样
都不满足:garth 对着 429 调 .json(),抛出的 JSONDecodeError 里状态码已经
丢了,消息是无用的 "Expecting value: line 1 column 1"。
响应体「Rate limited」唯一幸存的地方是异常的 `doc` 属性,改为从那里取。
同时改正上一版的测试:它用 ValueError("Rate limited") 构造,那个形态现实中
根本不会出现,所以测试通过、检测却漏掉了每一个真实的限流。现在用真的
json.JSONDecodeError 构造,并先断言「光看消息不够」。
实测同步现在返回:
RateLimited: Garmin 暂时限制了请求频率…令牌本身没有失效
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-28 14:27:15 +08:00 |
|
ericwyuan
|
042c4d52be
|
fix(sync): 被 Garmin 限流时说人话,并停止把限流越撞越深
服务器上的数据停在 8-25,而同步状态是 error、卡在「连接 Garmin」,
记录的原因是 `JSONDecodeError: Expecting value: line 1 column 1`。
真相:Garmin 返回的是 HTTP 429,响应体是纯文本 "Rate limited"(12 字节),
garth 把它交给 json.loads,于是限流被伪装成了解析错误。令牌本身好好的,
oauth1 有效期到 2027-08-23,账号也没问题。
- 新增 RateLimited 异常并识别 429(按状态码或响应体),同步状态里显示
「Garmin 暂时限制了请求频率…令牌本身没有失效」,不再是一句解析报错。
- 命中限流后整个进程对该账号退避 30 分钟。重试正是把限流撞得更深的原因,
原来失败后每小时还接着试。
- 只在 oauth2 令牌确实过期时才 refresh_oauth2()。原先每次 _connect 都无条件
刷新一次,白白消耗配额——正是这个把账号一步步推到了 429。
排查过程中我一度误判:先以为是数据库迁移把令牌 base64 包了一层,
改了解码逻辑反而把能用的令牌弄坏(garth.loads 本来就要 base64 形式),
已还原。真正的定位靠拦截 requests.Session 打印出原始响应。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-28 14:20:43 +08:00 |
|
ericwyuan
|
ac99c1342d
|
docs: 迁移收尾——部署信息同步到 Oracle 新机 129.146.26.249
- backend/.env.example: AI 网关地址 129.146.203.203 → 129.146.26.249;
MariaDB 注释段更新为生产实际(garmin 专用账号 @ 127.0.0.1:3306,socket 已不用)
- README: 生产数据层/数据库说明改为 Oracle 新机本地 MariaDB 10.3
- docs/REQUIREMENTS: 部署目标 NAS → Oracle 新机;公网方式 frp 隧道 → gunicorn 直绑 8123
- docs/ARCHITECTURE: 部署架构改为真实生产栈(Flask+gunicorn+MariaDB+SPA)
|
2026-08-25 23:59:47 +08:00 |
|
ericwyuan
|
e7c336b9df
|
fix(db): 数据库晚一步启动就会让服务永久挂掉
今天 11:33 站点整个不可用。不是代码的问题,是启动顺序:
NAS 上 MariaDB 在 11:34 才起来,而应用 11:33 就尝试连接,
init_db() 抛异常 → gunicorn 报 Worker failed to boot → master 退出。
一分钟后数据库好了,但已经没有进程在跑,没人会去重试——
站点就一直躺到有人手工重启为止。
init_db 改为在 DB_INIT_RETRY_SECONDS(默认 120 秒)内重试等待数据库,
超时仍然如实抛错,不会假装启动成功。NAS 重启时应用和数据库一起起来,
这个竞争是常态而不是意外。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-25 14:23:07 +08:00 |
|
ericwyuan
|
d2d6143ed6
|
fix(ui): 切换日期后数字不更新,卡片显示的是前一天的数据
useCountUp 只靠 requestAnimationFrame 推进,没有兜底。帧不来的时候
(后台标签页、被节流的 webview)setDisplay 一次都不会调用,组件继续
渲染上一个值——往前翻一天,标题的日期变了,但每一个数字还停在前一天。
不是动画没播,是把另一天的数据当成这一天理直气壮地显示出来。
实测(造的数据):08-24 → 08-23 → 08-22 三天,三张卡片全都显示 8,826,
而真实值是 8,826 / 10,232 / 10,715。
- 动画结束时间点加一个 setTimeout 兜底,保证最终值一定落地
- document.hidden 时直接跳过动画
- from 引用在收敛时同步更新,避免动画被打断后从错误的起点继续
动画是装饰,数字不是。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-24 09:33:22 +08:00 |
|
ericwyuan
|
564aaef6c1
|
fix(auth): 登录页不停刷新、无法输入
未登录时五个 Tab 视图会同时挂载各自的 Screen,每个都判定「没登录」,
于是每个都对**当前**视图发一次 navigate('/login/', {reloadAll: true})。
每次 reloadAll 又让其他几个重新挂载,再各发一次——登录表单被持续拆掉重建,
根本打不出字。关掉页面过渡动画后这个循环变紧,症状才明显起来。
判断登录与否是外壳的职责,不是每个页面各自抢着跳转:
- App 持有会话状态:没有会话就只渲染一个 /login/ 视图,连 Tab 栏都不出;
有会话才渲染五个 Tab。
- Screen 不再做任何跳转。
- setSession / clearSession 派发 ghl:auth 事件。storage 事件只在**其他**
标签页触发,同标签页的登录登出需要自己的信号。
- 登录成功后不再手动 navigate:外壳会换掉整个视图,从一个正要被卸载的
视图里发起路由是在和它抢。
实测:登出后登录页只有 1 个视图、无 Tab 栏,输入的内容 2.5 秒后仍在,
且输入框还是同一个 DOM 节点(没有重挂);恢复会话后自动切回 5 个 Tab。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-24 09:08:02 +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
|
67a7c4b87e
|
fix(ui): 自动同步开关不可点、返回键热区过小、路由卡死
开关
- 我的 .toggle 类名和 Framework7 自带的 .toggle 组件撞了,元素被压成 0×0;
另外 F7 全局给 input[type=checkbox] 加了 display:none,胜过我的 opacity:0,
所以那个开关根本点不到。改名 set-switch 并显式恢复 input 的 display。
- 设置项改为乐观更新:先动控件,再发请求,失败回滚并报错。
经隧道一个来回约半秒,开关在那半秒里纹丝不动会被当成坏了,
用户再按一次,两个写入就打架了。
返回键
- 去掉导航栏毛玻璃背景时也把尺寸一起去掉了,链接缩成 44×16,
手机上很难点中,按下也没有反馈。恢复 44px 高的热区并加按下态。
路由卡死
- F7 启动时给根节点加 framework7-initializing 来抑制动画,靠下一帧移除。
这一帧在后台标签页或被节流的 webview 里不会到来,于是该类一直挂着,
它的 transition-duration: 0ms !important 让过渡永远不发 transitionend,
路由等的就是这个事件——allowPageChange 置 false 后再也没恢复,
之后每一次导航(包括每一次返回)都被静默丢弃,只能刷新页面。
- 启动后主动清掉该类,并加一个看门狗:过渡类存活超过 1.6 秒即视为卡住,
清理残留状态并放行路由。
进度条改用 portal 挂到 document.body。它原本是 F7 根节点的第一个子元素,
就这一个多余的节点会干扰 F7 的初始化。
部署脚本改为原子替换 static:原先 rm -rf 后再解包,中间有一段时间
文件是缺的,正好撞上就是 502。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-24 07:32:35 +08:00 |
|
ericwyuan
|
32588085a1
|
perf: 今日页首屏 394KB/2.0s → 51.5KB/0.82s
逐个接口计时后的两处改动:
- 健康摘要不再下发空值。一天 40 项指标里大部分是这块表没有的传感器,
全按 null 发出去占了约两成体积。客户端本来就把「键不存在」和 null
当同一回事。
- 今日页首屏由 365 天改为 60 天,往前翻越界时再加载 180 天。
一次取一年是为了让翻页不发请求,代价是首屏 394 KB,手机上不划算。
日历选到窗口外的日期同样会自动加载。
顺带把加载逻辑收成一个 loadFrom:原来初始 effect 的依赖是空数组,
日历里改 from 不会触发重新拉取,是个还没被触发的 bug。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-24 04:34:32 +08:00 |
|
ericwyuan
|
283d530f82
|
fix(deploy): 启动脚本等端口真正释放,并确认服务可用后才报成功
上一次部署里旧 worker 没随 master 退出,一直占着 8123。新 master 启动、
打印 started、然后什么都不服务——日志每一行看起来都正常,站点却是挂的。
- stop.sh 等进程真正退出,超时后 -9
- start.sh 停完等端口释放,起来后 curl 健康检查通过才算成功,否则返回非零
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-24 04:23:05 +08:00 |
|
ericwyuan
|
f1319a6171
|
feat: 补齐 Garmin 未同步的数据,并各自配上界面
审计 57 个接口后,把账号里真有数据却从未入库的部分补上。全部走同步模块,
界面只读本地库。
新增数据
- 体重与身体成分(体脂率/肌肉量/体水分/骨量/内脏脂肪/代谢年龄)
- 血压(接口通,账号暂无记录)
- 跑步成绩预测(5 公里 / 10 公里 / 半马 / 全马)
- 爬坡分、饮水量、出汗量 → health_data 新增七列
- 全天曲线:心率 / 压力 / 身体电量 / 呼吸 / 血氧
- 挑战赛(徽章挑战与好友挑战,与一次性的徽章不同,有周期和进度)
- 已配对设备
新增界面
- /body/ 身体成分:体重大数字 + BMI 分级 + 体脂肌肉曲线 + 血压表格
- /race/ 成绩预测:四个距离的预测成绩与配速,以及预测随时间的变化
- /challenges/ 挑战赛:按类型筛选,有目标的显示进度条
- /devices/ 已配对设备
- 每日页新增「全天曲线」,这是存日内采样的主要目的
- 健康页新增「身体成分」分组与「更多」入口,运动页加挑战赛与成绩预测入口
同步开销
- 日内曲线每天五个请求,14 天以内的同步顺带拉,更长的历史交给后台
「补齐详细数据」,否则一年的同步会多出约 1800 个请求
- 原来的「补齐运动详情」扩展为统一的补齐任务,分阶段上报进度
日内采样抽稀到每天 240 点:手机图表分辨不出更多,只会把行撑大。
全量 446 项测试通过。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-24 04:16:33 +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
|
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 |
|