diff --git a/docs/plans/2026-01-27-health-module-implementation.md b/docs/plans/2026-01-27-health-module-implementation.md new file mode 100644 index 0000000..c3a8069 --- /dev/null +++ b/docs/plans/2026-01-27-health-module-implementation.md @@ -0,0 +1,1019 @@ +# 健康模块增强实现计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 为运动、饮食、睡眠三个模块添加健康分析功能,包括 BMI 分析、营养建议区间、睡眠预警提示。 + +**Architecture:** +- 新增 3 个 API 端点提供分析数据 +- 修改 3 个页面 HTML 模板添加新 UI 组件 +- 复用现有 `UserConfig.tdee` 和 `User.bmi` 计算逻辑 + +**Tech Stack:** FastAPI, Chart.js, 原生 HTML/CSS/JS + +--- + +## 任务概览 + +| 任务 | 模块 | 预估步骤 | +|-----|------|---------| +| Task 1 | API: BMI 分析端点 | 5 步 | +| Task 2 | API: 营养建议端点 | 5 步 | +| Task 3 | API: 睡眠评估端点 | 5 步 | +| Task 4 | UI: 运动页面 BMI 卡片 | 5 步 | +| Task 5 | UI: 饮食页面营养建议 | 5 步 | +| Task 6 | UI: 睡眠页面预警卡片 | 5 步 | + +--- + +## Task 1: API - BMI 分析端点 + +**Files:** +- Modify: `src/vitals/web/app.py:1163` (在 `/api/exercises/stats` 后添加) +- Test: `tests/test_web.py` + +### Step 1: 写失败测试 + +在 `tests/test_web.py` 末尾添加: + +```python +def test_bmi_analysis_endpoint(client): + """测试 BMI 分析 API""" + response = client.get("/api/bmi/analysis") + assert response.status_code == 200 + data = response.json() + assert "current_weight" in data + assert "current_bmi" in data + assert "bmi_status" in data + assert "target_weight" in data + assert "estimated_days" in data +``` + +### Step 2: 运行测试确认失败 + +```bash +cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_bmi_analysis_endpoint -v +``` +预期: FAIL - 404 Not Found + +### Step 3: 实现 API 端点 + +在 `src/vitals/web/app.py` 第 1163 行后添加: + +```python +@app.get("/api/bmi/analysis") +async def get_bmi_analysis(): + """获取 BMI 分析数据""" + active_user = db.get_active_user() + if not active_user: + raise HTTPException(status_code=400, detail="没有激活的用户") + + config = db.get_config(active_user.id) + + # 获取最新体重记录 + today = date.today() + weight_records = db.get_weight_records( + start_date=today - timedelta(days=30), + end_date=today, + user_id=active_user.id + ) + current_weight = weight_records[0].weight_kg if weight_records else config.weight + + # 计算 BMI + height_m = (config.height or 170) / 100 + current_bmi = round(current_weight / (height_m * height_m), 1) if current_weight else None + + # BMI 状态 + def get_bmi_status(bmi): + if bmi is None: + return None + if bmi < 18.5: + return {"status": "偏瘦", "color": "#60A5FA", "range": "<18.5"} + elif bmi < 24: + return {"status": "正常", "color": "#34D399", "range": "18.5-24"} + elif bmi < 28: + return {"status": "偏重", "color": "#FBBF24", "range": "24-28"} + else: + return {"status": "肥胖", "color": "#F87171", "range": ">28"} + + bmi_info = get_bmi_status(current_bmi) + + # 目标体重(从用户配置或默认 BMI 22 计算) + target_weight = getattr(config, 'target_weight', None) + if not target_weight: + # 默认目标 BMI 22 + target_weight = round(22 * height_m * height_m, 1) + + target_bmi = round(target_weight / (height_m * height_m), 1) + weight_diff = round(current_weight - target_weight, 1) if current_weight else None + + # 计算预估达成天数 + estimated_days = None + daily_deficit = None + calculation_basis = None + + if current_weight and target_weight and config.tdee: + # 获取近30天饮食数据 + meals = db.get_meals( + start_date=today - timedelta(days=30), + end_date=today, + user_id=active_user.id + ) + # 获取近30天运动数据 + exercises = db.get_exercises( + start_date=today - timedelta(days=30), + end_date=today, + user_id=active_user.id + ) + + # 计算平均每日摄入 + total_intake = sum(m.calories for m in meals) + days_with_data = len(set(m.date for m in meals)) or 1 + avg_daily_intake = total_intake / days_with_data + + # 计算平均每日运动消耗 + total_exercise = sum(e.calories for e in exercises) + avg_daily_exercise = total_exercise / 30 + + # 每日净消耗 = TDEE - 摄入 + 运动 + daily_deficit = round(config.tdee - avg_daily_intake + avg_daily_exercise) + + if daily_deficit > 0 and weight_diff > 0: + # 7700 卡 ≈ 1 kg + estimated_days = round(weight_diff * 7700 / daily_deficit) + calculation_basis = { + "tdee": config.tdee, + "avg_intake": round(avg_daily_intake), + "avg_exercise": round(avg_daily_exercise), + "daily_deficit": daily_deficit + } + + return { + "current_weight": current_weight, + "current_bmi": current_bmi, + "bmi_status": bmi_info, + "height": config.height, + "target_weight": target_weight, + "target_bmi": target_bmi, + "weight_diff": weight_diff, + "estimated_days": estimated_days, + "calculation_basis": calculation_basis, + "has_sufficient_data": bool(calculation_basis) + } +``` + +### Step 4: 运行测试确认通过 + +```bash +cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_bmi_analysis_endpoint -v +``` +预期: PASS + +### Step 5: 提交 + +```bash +git add src/vitals/web/app.py tests/test_web.py +git commit -m "feat(api): 添加 BMI 分析端点 /api/bmi/analysis" +``` + +--- + +## Task 2: API - 营养建议端点 + +**Files:** +- Modify: `src/vitals/web/app.py` (在 BMI API 后添加) +- Test: `tests/test_web.py` + +### Step 1: 写失败测试 + +```python +def test_nutrition_recommendations_endpoint(client): + """测试营养建议 API""" + response = client.get("/api/nutrition/recommendations") + assert response.status_code == 200 + data = response.json() + assert "daily_targets" in data + assert "today_intake" in data + assert "gaps" in data + assert "suggestions" in data +``` + +### Step 2: 运行测试确认失败 + +```bash +cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_nutrition_recommendations_endpoint -v +``` +预期: FAIL - 404 Not Found + +### Step 3: 实现 API 端点 + +```python +@app.get("/api/nutrition/recommendations") +async def get_nutrition_recommendations(): + """获取营养建议数据""" + active_user = db.get_active_user() + if not active_user: + raise HTTPException(status_code=400, detail="没有激活的用户") + + config = db.get_config(active_user.id) + tdee = config.tdee or 1800 + + # 根据目标调整热量目标 + goal_multipliers = { + "lose": 0.8, # 减重: 80% TDEE + "maintain": 1.0, # 维持: 100% TDEE + "gain": 1.15 # 增重: 115% TDEE + } + calorie_target = int(tdee * goal_multipliers.get(config.goal, 1.0)) + + # 营养素目标 (基于 TDEE) + # 蛋白质 25%, 碳水 50%, 脂肪 25% + daily_targets = { + "calories": calorie_target, + "protein": round(calorie_target * 0.25 / 4), # 每克蛋白质 4 卡 + "carbs": round(calorie_target * 0.50 / 4), # 每克碳水 4 卡 + "fat": round(calorie_target * 0.25 / 9), # 每克脂肪 9 卡 + } + + # 建议区间 + recommended_ranges = { + "protein": {"min": round(calorie_target * 0.20 / 4), "max": round(calorie_target * 0.30 / 4)}, + "carbs": {"min": round(calorie_target * 0.45 / 4), "max": round(calorie_target * 0.55 / 4)}, + "fat": {"min": round(calorie_target * 0.20 / 9), "max": round(calorie_target * 0.30 / 9)}, + } + + # 今日摄入 + today = date.today() + today_meals = db.get_meals(start_date=today, end_date=today, user_id=active_user.id) + + today_intake = { + "calories": sum(m.calories for m in today_meals), + "protein": sum(m.protein or 0 for m in today_meals), + "carbs": sum(m.carbs or 0 for m in today_meals), + "fat": sum(m.fat or 0 for m in today_meals), + } + + # 计算差距 + gaps = { + "calories": daily_targets["calories"] - today_intake["calories"], + "protein": daily_targets["protein"] - today_intake["protein"], + "carbs": daily_targets["carbs"] - today_intake["carbs"], + "fat": daily_targets["fat"] - today_intake["fat"], + } + + # 餐次分布 + meal_type_calories = {} + for m in today_meals: + meal_type_calories[m.meal_type] = meal_type_calories.get(m.meal_type, 0) + m.calories + + meal_distribution = { + "早餐": {"actual": meal_type_calories.get("早餐", 0), "recommended_pct": "25-30%"}, + "午餐": {"actual": meal_type_calories.get("午餐", 0), "recommended_pct": "35-40%"}, + "晚餐": {"actual": meal_type_calories.get("晚餐", 0), "recommended_pct": "25-30%"}, + "加餐": {"actual": meal_type_calories.get("加餐", 0), "recommended_pct": "0-10%"}, + } + + # 生成建议 + suggestions = [] + if gaps["protein"] > 20: + suggestions.append({ + "type": "protein", + "message": f"蛋白质缺口较大 ({gaps['protein']}g),建议增加:", + "options": [ + "鸡胸肉 150g (+46g 蛋白质, 165 卡)", + "鸡蛋 3 个 (+18g 蛋白质, 210 卡)", + "豆腐 200g (+16g 蛋白质, 160 卡)", + ] + }) + if gaps["calories"] > 300: + suggestions.append({ + "type": "calories", + "message": f"热量摄入不足 ({gaps['calories']} 卡),注意补充能量", + "options": [] + }) + + # 近 7 天趋势 + week_start = today - timedelta(days=6) + week_meals = db.get_meals(start_date=week_start, end_date=today, user_id=active_user.id) + + weekly_trend = [] + for i in range(7): + day = week_start + timedelta(days=i) + day_meals = [m for m in week_meals if m.date == day] + weekly_trend.append({ + "date": day.isoformat(), + "calories": sum(m.calories for m in day_meals), + "protein": sum(m.protein or 0 for m in day_meals), + "carbs": sum(m.carbs or 0 for m in day_meals), + "fat": sum(m.fat or 0 for m in day_meals), + }) + + return { + "tdee": tdee, + "goal": config.goal, + "daily_targets": daily_targets, + "recommended_ranges": recommended_ranges, + "today_intake": today_intake, + "gaps": gaps, + "meal_distribution": meal_distribution, + "suggestions": suggestions, + "weekly_trend": weekly_trend, + } +``` + +### Step 4: 运行测试确认通过 + +```bash +cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_nutrition_recommendations_endpoint -v +``` +预期: PASS + +### Step 5: 提交 + +```bash +git add src/vitals/web/app.py tests/test_web.py +git commit -m "feat(api): 添加营养建议端点 /api/nutrition/recommendations" +``` + +--- + +## Task 3: API - 睡眠评估端点 + +**Files:** +- Modify: `src/vitals/web/app.py` (在营养 API 后添加) +- Test: `tests/test_web.py` + +### Step 1: 写失败测试 + +```python +def test_sleep_assessment_endpoint(client): + """测试睡眠评估 API""" + response = client.get("/api/sleep/assessment") + assert response.status_code == 200 + data = response.json() + assert "avg_duration" in data + assert "status" in data + assert "health_impacts" in data + assert "ideal_days_count" in data +``` + +### Step 2: 运行测试确认失败 + +```bash +cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_sleep_assessment_endpoint -v +``` +预期: FAIL - 404 Not Found + +### Step 3: 实现 API 端点 + +```python +@app.get("/api/sleep/assessment") +async def get_sleep_assessment(): + """获取睡眠评估数据""" + active_user = db.get_active_user() + if not active_user: + raise HTTPException(status_code=400, detail="没有激活的用户") + + today = date.today() + week_start = today - timedelta(days=6) + month_start = today - timedelta(days=29) + + # 获取近 7 天和近 30 天睡眠数据 + week_records = db.get_sleep_records(start_date=week_start, end_date=today, user_id=active_user.id) + month_records = db.get_sleep_records(start_date=month_start, end_date=today, user_id=active_user.id) + + # 计算平均睡眠时长 + week_durations = [r.duration for r in week_records] + month_durations = [r.duration for r in month_records] + + avg_week = sum(week_durations) / len(week_durations) if week_durations else 0 + avg_month = sum(month_durations) / len(month_durations) if month_durations else 0 + + # 判断睡眠状态 + def get_sleep_status(avg_hours): + if avg_hours < 5: + return { + "level": "severe", + "label": "严重不足", + "color": "#EF4444", + "icon": "alert-circle" + } + elif avg_hours < 6: + return { + "level": "insufficient", + "label": "睡眠不足", + "color": "#F59E0B", + "icon": "alert-triangle" + } + elif avg_hours <= 9: + return { + "level": "ideal", + "label": "睡眠充足", + "color": "#10B981", + "icon": "check-circle" + } + else: + return { + "level": "excessive", + "label": "睡眠过多", + "color": "#3B82F6", + "icon": "info" + } + + status = get_sleep_status(avg_week) + + # 统计理想天数 + ideal_days = sum(1 for d in week_durations if 7 <= d <= 9) + insufficient_days = sum(1 for d in week_durations if d < 7) + + month_ideal_days = sum(1 for d in month_durations if 7 <= d <= 9) + month_insufficient_days = sum(1 for d in month_durations if d < 7) + + # 健康影响信息 + warning_impacts = { + "cognitive": { + "title": "认知能力", + "effects": ["注意力下降 40%", "记忆力减退", "决策能力受损"] + }, + "physical": { + "title": "身体健康", + "effects": ["免疫力下降", "肥胖风险增加 33%", "心血管疾病风险上升"] + }, + "emotional": { + "title": "情绪状态", + "effects": ["焦虑抑郁风险增加", "情绪波动大", "压力耐受力降低"] + }, + "exercise": { + "title": "运动表现", + "effects": ["反应速度下降", "肌肉恢复减慢", "受伤风险增加"] + } + } + + benefit_impacts = { + "cognitive": { + "title": "认知提升", + "effects": ["记忆巩固增强", "专注力提高", "创造力活跃"] + }, + "physical": { + "title": "身体修复", + "effects": ["免疫系统强化", "肌肉组织修复", "激素分泌平衡"] + }, + "emotional": { + "title": "情绪稳定", + "effects": ["情绪调节能力增强", "压力抵抗力提升", "心态积极"] + }, + "exercise": { + "title": "运动增益", + "effects": ["运动表现提升 15%", "恢复速度加快", "耐力增强"] + } + } + + health_impacts = warning_impacts if status["level"] in ["severe", "insufficient"] else benefit_impacts + + # 建议 + if status["level"] == "severe": + suggestion = "建议立即调整作息,每天提前 1 小时入睡" + elif status["level"] == "insufficient": + suggestion = "尝试提前 30 分钟入睡,逐步调整作息" + elif status["level"] == "ideal": + suggestion = "继续保持,规律作息是健康的基石!" + else: + suggestion = "睡眠时间过长也可能影响健康,建议控制在 7-9 小时" + + # 距理想的差距 + ideal_target = 7.5 + gap_hours = round(ideal_target - avg_week, 1) if avg_week < 7 else 0 + + return { + "avg_duration": round(avg_week, 1), + "avg_duration_month": round(avg_month, 1), + "status": status, + "gap_hours": gap_hours, + "ideal_days_count": ideal_days, + "insufficient_days_count": insufficient_days, + "total_days": len(week_durations), + "month_stats": { + "ideal_days": month_ideal_days, + "insufficient_days": month_insufficient_days, + "total_days": len(month_durations) + }, + "health_impacts": health_impacts, + "suggestion": suggestion, + "thresholds": { + "severe": 5, + "insufficient": 6, + "ideal_min": 7, + "ideal_max": 9 + } + } +``` + +### Step 4: 运行测试确认通过 + +```bash +cd /Users/rocky/Projects/vitals && python -m pytest tests/test_web.py::test_sleep_assessment_endpoint -v +``` +预期: PASS + +### Step 5: 提交 + +```bash +git add src/vitals/web/app.py tests/test_web.py +git commit -m "feat(api): 添加睡眠评估端点 /api/sleep/assessment" +``` + +--- + +## Task 4: UI - 运动页面 BMI 分析卡片 + +**Files:** +- Modify: `src/vitals/web/app.py:4104` (`get_exercise_page_html` 函数) + +### Step 1: 定位插入位置 + +在 `get_exercise_page_html()` 函数中,找到统计卡片 grid 结束位置(约第 4404 行 `` 之后),插入 BMI 分析卡片。 + +### Step 2: 添加 BMI 分析卡片 HTML + +在第 4404 行后插入: + +```html + +
${intro}
+ +