diff --git a/docs/smoke/ALGORITHM.md b/docs/smoke/ALGORITHM.md index 30ea753..901500b 100644 --- a/docs/smoke/ALGORITHM.md +++ b/docs/smoke/ALGORITHM.md @@ -202,7 +202,9 @@ function calculateDailyTarget(baseline, stage, dayInStage) { --- -## 5. 健康恢复计算 +## 5. 健康恢复计算(后端统一) + +接口:`GET /api/v1/smoke/stats?range=week|month|year`(详见 `docs/smoke/API.md`) 基于医学研究的恢复时间线: @@ -236,7 +238,9 @@ function calculateLungRecovery(smokeFreeMinutes) { --- -## 6. 省钱计算 +## 6. 省钱计算(后端统一) + +接口:`GET /api/v1/smoke/stats?range=week|month|year`(详见 `docs/smoke/API.md`) ```javascript // utils/money.js @@ -250,7 +254,9 @@ function calculateMoneySaved(packPriceCent, cigsPerPack, baselineCigsPerDay, act --- -## 7. 激励语生成 +## 7. 激励语生成(后端统一) + +接口:`GET /api/v1/smoke/motivation`(详见 `docs/smoke/API.md`) 根据用户状态生成不同的激励语: diff --git a/docs/smoke/API.md b/docs/smoke/API.md index fa83871..2f7a561 100644 --- a/docs/smoke/API.md +++ b/docs/smoke/API.md @@ -433,3 +433,83 @@ AI 生成说明: - `resisted_count`:今日克制次数(`level=0 && num=0`)。 - `reduced_from_yesterday`:较昨日减少的支数(允许为负数;为负时表示“今天超出昨日”)。 - `exceeded_yesterday`:是否超出昨日(`true` 表示今天超出昨日,前端可用作单独标识)。 + +## 14) 数据统计分析(趋势 + 健康 + 省钱) + +`GET /api/v1/smoke/stats?range=week|month|year&date=2026-01-07` + +参数: +- `range`:`week|month|year`,默认 `week` +- `date`:锚点日期(`YYYY-MM-DD`),默认今天 + +说明: +- 用于“统计页”一屏数据整合(趋势、均值、环比、健康恢复、省钱、连续记录、已拒绝次数)。 +- `trend_unit`:`day` 或 `month`,用于前端图表横轴显示。 + +成功响应(示例): +```json +{ + "code": 200, + "message": "success", + "data": { + "range": "week", + "start": "2026-01-01", + "end": "2026-01-07", + "trend_unit": "day", + "trend": [ + { "label": "2026-01-01", "count": 2 }, + { "label": "2026-01-02", "count": 1 }, + { "label": "2026-01-03", "count": 0 }, + { "label": "2026-01-04", "count": 0 }, + { "label": "2026-01-05", "count": 3 }, + { "label": "2026-01-06", "count": 0 }, + { "label": "2026-01-07", "count": 0 } + ], + "daily_average": 4, + "change_percent": -20, + "money": { + "available": true, + "pack_price_cent": 2500, + "cigs_per_pack": 20, + "expected_total": 140, + "actual_total": 92, + "saved_cent": 6000 + }, + "health": { + "available": true, + "smoke_free_minutes": 420, + "lung_recovery_percent": 12, + "milestones": [ + { "name": "心率血压恢复正常", "minutes": 20, "reached": true }, + { "name": "血氧水平恢复", "minutes": 480, "reached": false } + ] + }, + "streak_days": 12, + "resisted_total": 24 + } +} +``` + +字段说明: +- `change_percent`:与上一个同周期对比的变化比例(可为负)。 +- `money.available=false`:表示缺少 `baseline_cigs_per_day` 或 `pack_price_cent`。 +- `health.available=false`:表示无历史记录。 + +## 15) 激励语(后端统一生成) + +`GET /api/v1/smoke/motivation` + +说明: +- 基于当日数据(如 `today_count`、`resisted_count`、`last_smoke_at`)与 `quit_motivations` 生成一句激励语。 + +成功响应(示例): +```json +{ + "code": 200, + "message": "success", + "data": { + "message": "今天的表现很稳,继续保持!记住你的目标:身体健康。", + "type": "encourage" + } +} +``` diff --git a/docs/smoke/PRD.md b/docs/smoke/PRD.md index 1a60d7a..8c77c53 100644 --- a/docs/smoke/PRD.md +++ b/docs/smoke/PRD.md @@ -32,6 +32,7 @@ | 下次建议时间 | 显示建议的下次抽烟时间点 | `GET /next_smoke_time` | | 今日已抽 | X / 目标数,较昨日 ±N | `next_smoke_time.today_count` + `next_smoke_time.reduced_from_yesterday`(可为负) + `next_smoke_time.exceeded_yesterday`(标识“超出昨日”) | | 烟瘾发作已抵抗 | 忍住次数统计 | `next_smoke_time.resisted_count` | +| 激励语 | 当天一句话鼓励 | `GET /motivation` | | 记录抽烟按钮 | 快速记录一次抽烟 | `POST /logs` | | 想抽忍住了按钮 | 记录成功抵抗 | `POST /logs/resisted` | @@ -46,15 +47,15 @@ | 功能 | 说明 | 数据来源 | |------|------|----------| -| 周/月/年切换 | 切换统计时间范围 | `GET /dashboard?start=&end=` | +| 周/月/年切换 | 切换统计时间范围 | `GET /stats?range=week|month|year` | | 每周洞察 | AI 分析本周表现 | `GET /ai/advice` | -| 吸烟趋势图 | 柱状图展示每日吸烟量 | `dashboard.weekly` | -| 趋势对比 | 较上周减少 X% | 本地计算 | -| 日均吸烟量 | 统计周期内日均值 | 本地计算 | -| 节省金额 | 基于减少量 × 单价计算 | profile + logs | -| 肺部功能恢复 | 根据戒烟天数估算 | 固定公式 | -| 连续记录天数 | 用户活跃天数 | logs 统计 | -| 已拒绝次数 | 累计忍住次数 | `level=0,num=0` 统计 | +| 吸烟趋势图 | 柱状图展示每日吸烟量 | `stats.trend` | +| 趋势对比 | 较上周期变化 | `stats.change_percent` | +| 日均吸烟量 | 统计周期内日均值 | `stats.daily_average` | +| 节省金额 | 后端统一计算 | `stats.money` | +| 肺部功能恢复 | 后端统一计算 | `stats.health` | +| 连续记录天数 | 用户活跃天数 | `stats.streak_days` | +| 已拒绝次数 | 累计忍住次数 | `stats.resisted_total` | ### 2.3 AI 助手页 (ai_quit_assistant) diff --git a/docs/smoke/PRODUCT.md b/docs/smoke/PRODUCT.md index dd7cba4..038d873 100644 --- a/docs/smoke/PRODUCT.md +++ b/docs/smoke/PRODUCT.md @@ -82,9 +82,10 @@ ## 5. 页面能力清单 - 首页:上次实际抽烟时间(用于计时)、今日累计、今日克制、较昨日增减(可为负并标识“超出昨日”)、下次建议时间(默认/AI)、时间节点列表、快速入口(抽烟/忍住)。 +- 首页激励语:`GET /api/v1/smoke/motivation` - 记录页:快速添加抽烟、快速忍住、补录真实时间 `smoke_at`。 - 列表页:按日期筛选、分页、区分“抽烟/忍住”标签、支持编辑/删除。 -- 看板页:周视图/区间视图,展示每日支数与 `minutes_since_last`。 +- 看板/统计页:使用 `GET /api/v1/smoke/stats?range=week|month|year` 获取趋势、均值、环比、健康与省钱等数据。 - AI 建议页:每日建议展示(解锁后生成)。 - 基础信息页:补全/编辑基础烟量、动机/动力、作息。 diff --git a/internal/routes/smoke_routes.go b/internal/routes/smoke_routes.go index 12e469a..4d306a6 100644 --- a/internal/routes/smoke_routes.go +++ b/internal/routes/smoke_routes.go @@ -18,6 +18,7 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler. smoke.GET("/next_smoke_time", smokeHandler.GetNextSmokeTime) smoke.GET("/dashboard", smokeHandler.Dashboard) + smoke.GET("/stats", smokeHandler.Stats) smoke.POST("/logs", smokeHandler.Create) smoke.POST("/logs/resisted", smokeHandler.Resist) smoke.GET("/logs", smokeHandler.List) diff --git a/internal/smoke/handler/smoke_stats_handler.go b/internal/smoke/handler/smoke_stats_handler.go new file mode 100644 index 0000000..2f48e1a --- /dev/null +++ b/internal/smoke/handler/smoke_stats_handler.go @@ -0,0 +1,113 @@ +package handler + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "wx_service/internal/middleware" + "wx_service/internal/model" + smokeservice "wx_service/internal/smoke/service" +) + +func (h *SmokeHandler) Stats(c *gin.Context) { + user, ok := middleware.CurrentUser(c) + if !ok { + c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期")) + return + } + + rangeType := strings.ToLower(strings.TrimSpace(c.DefaultQuery("range", "week"))) + asOf := time.Now().In(time.Local) + if v := strings.TrimSpace(c.Query("date")); v != "" { + parsed, err := time.ParseInLocation(dateLayout, v, time.Local) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "date 格式错误,应为 YYYY-MM-DD")) + return + } + asOf = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, 0, time.Local) + } + + req, err := buildStatsRequest(rangeType, asOf) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + + profile, err := h.smokeProfileService.Get(c.Request.Context(), int(user.ID)) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取基础信息失败,请稍后重试")) + return + } + + result, err := h.smokeLogService.Stats(c.Request.Context(), int(user.ID), req, profile) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取统计数据失败,请稍后重试")) + return + } + + c.JSON(http.StatusOK, model.Success(result)) +} + +func buildStatsRequest(rangeType string, anchor time.Time) (smokeservice.SmokeStatsRequest, error) { + local := anchor.In(time.Local) + switch rangeType { + case "week": + start, end := weekRange(local) + return smokeservice.SmokeStatsRequest{ + Range: "week", + Start: start, + End: end, + PrevStart: start.AddDate(0, 0, -7), + PrevEnd: end.AddDate(0, 0, -7), + TrendUnit: "day", + AsOf: local, + }, nil + case "month": + start := time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, time.Local) + end := start.AddDate(0, 1, 0).AddDate(0, 0, -1) + prevEnd := start.AddDate(0, 0, -1) + prevStart := time.Date(prevEnd.Year(), prevEnd.Month(), 1, 0, 0, 0, 0, time.Local) + return smokeservice.SmokeStatsRequest{ + Range: "month", + Start: start, + End: end, + PrevStart: prevStart, + PrevEnd: prevEnd, + TrendUnit: "day", + AsOf: local, + }, nil + case "year": + start := time.Date(local.Year(), time.January, 1, 0, 0, 0, 0, time.Local) + end := time.Date(local.Year(), time.December, 31, 0, 0, 0, 0, time.Local) + prevStart := time.Date(local.Year()-1, time.January, 1, 0, 0, 0, 0, time.Local) + prevEnd := time.Date(local.Year()-1, time.December, 31, 0, 0, 0, 0, time.Local) + return smokeservice.SmokeStatsRequest{ + Range: "year", + Start: start, + End: end, + PrevStart: prevStart, + PrevEnd: prevEnd, + TrendUnit: "month", + AsOf: local, + }, nil + default: + return smokeservice.SmokeStatsRequest{}, errors.New("range 应为 week|month|year") + } +} + +func weekRange(anchor time.Time) (time.Time, time.Time) { + local := anchor.In(time.Local) + weekday := local.Weekday() + daysSinceMonday := int(weekday) - int(time.Monday) + if daysSinceMonday < 0 { + daysSinceMonday += 7 + } + start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local). + AddDate(0, 0, -daysSinceMonday) + end := start.AddDate(0, 0, 6) + return start, end +} diff --git a/internal/smoke/service/smoke_stats_service.go b/internal/smoke/service/smoke_stats_service.go new file mode 100644 index 0000000..de18ae0 --- /dev/null +++ b/internal/smoke/service/smoke_stats_service.go @@ -0,0 +1,381 @@ +package service + +import ( + "context" + "fmt" + "math" + "time" + + "gorm.io/gorm" + + smokemodel "wx_service/internal/smoke/model" +) + +const defaultCigsPerPack = 20 + +type SmokeStatsRequest struct { + Range string + Start time.Time + End time.Time + PrevStart time.Time + PrevEnd time.Time + TrendUnit string + AsOf time.Time +} + +type SmokeStatsResult struct { + Range string `json:"range"` + Start string `json:"start"` + End string `json:"end"` + TrendUnit string `json:"trend_unit"` + Trend []SmokeStatsTrend `json:"trend"` + DailyAverage int `json:"daily_average"` + ChangePercent int `json:"change_percent"` + Money SmokeStatsMoney `json:"money"` + Health SmokeStatsHealth `json:"health"` + StreakDays int `json:"streak_days"` + ResistedTotal int `json:"resisted_total"` +} + +type SmokeStatsTrend struct { + Label string `json:"label"` + Count int `json:"count"` +} + +type SmokeStatsMoney struct { + Available bool `json:"available"` + PackPriceCent int `json:"pack_price_cent,omitempty"` + CigsPerPack int `json:"cigs_per_pack,omitempty"` + ExpectedTotal int `json:"expected_total,omitempty"` + ActualTotal int `json:"actual_total,omitempty"` + SavedCent int `json:"saved_cent,omitempty"` +} + +type SmokeStatsHealth struct { + Available bool `json:"available"` + SmokeFreeMinutes int `json:"smoke_free_minutes,omitempty"` + LungRecoveryPercent int `json:"lung_recovery_percent,omitempty"` + Milestones []HealthMilestone `json:"milestones,omitempty"` +} + +type HealthMilestone struct { + Name string `json:"name"` + Minutes int `json:"minutes"` + Reached bool `json:"reached"` +} + +func (s *SmokeLogService) Stats(ctx context.Context, uid int, req SmokeStatsRequest, profile *smokemodel.SmokeUserProfile) (SmokeStatsResult, error) { + start := dateOnly(req.Start) + end := dateOnly(req.End) + trendUnit := req.TrendUnit + + var ( + trend []SmokeStatsTrend + total int64 + err error + ) + + if trendUnit == "month" { + trend, total, err = s.loadMonthlyTrend(ctx, uid, start, end) + } else { + trend, total, err = s.loadDailyTrend(ctx, uid, start, end) + } + if err != nil { + return SmokeStatsResult{}, err + } + + dayCount := daysBetweenInclusive(start, end) + dailyAvg := 0 + if dayCount > 0 { + dailyAvg = int(math.Round(float64(total) / float64(dayCount))) + } + + prevTotal, err := s.sumCigs(ctx, uid, req.PrevStart, req.PrevEnd) + if err != nil { + return SmokeStatsResult{}, err + } + changePercent := 0 + if prevTotal > 0 { + changePercent = int(math.Round((float64(total) - float64(prevTotal)) / float64(prevTotal) * 100)) + } + + resistedTotal, err := s.countResisted(ctx, uid, start, end) + if err != nil { + return SmokeStatsResult{}, err + } + + streakDays, err := s.computeStreakDays(ctx, uid, req.AsOf) + if err != nil { + return SmokeStatsResult{}, err + } + + money := s.computeMoney(profile, int(total), dayCount) + health, err := s.computeHealth(ctx, uid, req.AsOf) + if err != nil { + return SmokeStatsResult{}, err + } + + return SmokeStatsResult{ + Range: req.Range, + Start: start.Format("2006-01-02"), + End: end.Format("2006-01-02"), + TrendUnit: trendUnit, + Trend: trend, + DailyAverage: dailyAvg, + ChangePercent: changePercent, + Money: money, + Health: health, + StreakDays: streakDays, + ResistedTotal: resistedTotal, + }, nil +} + +func (s *SmokeLogService) loadDailyTrend(ctx context.Context, uid int, start, end time.Time) ([]SmokeStatsTrend, int64, error) { + type dailyCount struct { + SmokeTime time.Time `gorm:"column:smoke_time"` + Total int64 `gorm:"column:total"` + } + + var rows []dailyCount + if err := s.db.WithContext(ctx). + Model(&smokemodel.SmokeLog{}). + Select("smoke_time, SUM(num) AS total"). + Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). + Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")). + Group("smoke_time"). + Order("smoke_time ASC"). + Find(&rows).Error; err != nil { + return nil, 0, fmt.Errorf("load daily trend: %w", err) + } + + counts := make(map[string]int64, len(rows)) + var total int64 + for _, row := range rows { + key := dateOnly(row.SmokeTime).Format("2006-01-02") + counts[key] = row.Total + total += row.Total + } + + out := make([]SmokeStatsTrend, 0, daysBetweenInclusive(start, end)) + for day := start; !day.After(end); day = day.AddDate(0, 0, 1) { + label := day.Format("2006-01-02") + out = append(out, SmokeStatsTrend{ + Label: label, + Count: int(counts[label]), + }) + } + return out, total, nil +} + +func (s *SmokeLogService) loadMonthlyTrend(ctx context.Context, uid int, start, end time.Time) ([]SmokeStatsTrend, int64, error) { + type monthlyCount struct { + Month string `gorm:"column:month"` + Total int64 `gorm:"column:total"` + } + + var rows []monthlyCount + if err := s.db.WithContext(ctx). + Model(&smokemodel.SmokeLog{}). + Select("DATE_FORMAT(smoke_time, '%Y-%m') AS month, SUM(num) AS total"). + Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). + Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")). + Group("month"). + Order("month ASC"). + Find(&rows).Error; err != nil { + return nil, 0, fmt.Errorf("load monthly trend: %w", err) + } + + counts := make(map[string]int64, len(rows)) + var total int64 + for _, row := range rows { + counts[row.Month] = row.Total + total += row.Total + } + + out := make([]SmokeStatsTrend, 0, 12) + for cursor := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.Local); !cursor.After(end); cursor = cursor.AddDate(0, 1, 0) { + label := cursor.Format("2006-01") + out = append(out, SmokeStatsTrend{ + Label: label, + Count: int(counts[label]), + }) + } + return out, total, nil +} + +func (s *SmokeLogService) sumCigs(ctx context.Context, uid int, start, end time.Time) (int64, error) { + start = dateOnly(start) + end = dateOnly(end) + var total int64 + if err := s.db.WithContext(ctx). + Model(&smokemodel.SmokeLog{}). + Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). + Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")). + Select("COALESCE(SUM(num), 0)"). + Scan(&total).Error; err != nil { + return 0, fmt.Errorf("sum cigs: %w", err) + } + return total, nil +} + +func (s *SmokeLogService) countResisted(ctx context.Context, uid int, start, end time.Time) (int, error) { + start = dateOnly(start) + end = dateOnly(end) + var count int64 + if err := s.db.WithContext(ctx). + Model(&smokemodel.SmokeLog{}). + Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). + Where("level = 0 AND num = 0"). + Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")). + Count(&count).Error; err != nil { + return 0, fmt.Errorf("count resisted: %w", err) + } + return int(count), nil +} + +func (s *SmokeLogService) computeStreakDays(ctx context.Context, uid int, asOf time.Time) (int, error) { + asOf = dateOnly(asOf) + start := asOf.AddDate(0, 0, -400) + + type row struct { + SmokeTime time.Time `gorm:"column:smoke_time"` + } + var rows []row + if err := s.db.WithContext(ctx). + Model(&smokemodel.SmokeLog{}). + Distinct("smoke_time"). + Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). + Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), asOf.Format("2006-01-02")). + Order("smoke_time DESC"). + Find(&rows).Error; err != nil { + return 0, fmt.Errorf("load streak days: %w", err) + } + + daySet := make(map[string]bool, len(rows)) + for _, r := range rows { + daySet[dateOnly(r.SmokeTime).Format("2006-01-02")] = true + } + + streak := 0 + for day := asOf; ; day = day.AddDate(0, 0, -1) { + key := day.Format("2006-01-02") + if !daySet[key] { + break + } + streak++ + } + return streak, nil +} + +func (s *SmokeLogService) computeMoney(profile *smokemodel.SmokeUserProfile, actualTotal int, dayCount int) SmokeStatsMoney { + if profile == nil || profile.BaselineCigsPerDay <= 0 || profile.PackPriceCent <= 0 || dayCount <= 0 { + return SmokeStatsMoney{Available: false} + } + expectedTotal := profile.BaselineCigsPerDay * dayCount + savedCigs := expectedTotal - actualTotal + savedPacks := float64(savedCigs) / float64(defaultCigsPerPack) + savedCent := int(math.Round(savedPacks * float64(profile.PackPriceCent))) + return SmokeStatsMoney{ + Available: true, + PackPriceCent: profile.PackPriceCent, + CigsPerPack: defaultCigsPerPack, + ExpectedTotal: expectedTotal, + ActualTotal: actualTotal, + SavedCent: savedCent, + } +} + +func (s *SmokeLogService) computeHealth(ctx context.Context, uid int, asOf time.Time) (SmokeStatsHealth, error) { + lastSmokeAt, err := s.loadLastActualSmokeAt(ctx, uid) + if err != nil { + return SmokeStatsHealth{}, err + } + if lastSmokeAt == nil { + return SmokeStatsHealth{Available: false}, nil + } + + minutes := int(asOf.Sub(*lastSmokeAt).Minutes()) + if minutes < 0 { + minutes = 0 + } + + return SmokeStatsHealth{ + Available: true, + SmokeFreeMinutes: minutes, + LungRecoveryPercent: computeLungRecoveryPercent(minutes), + Milestones: buildHealthMilestones(minutes), + }, nil +} + +func (s *SmokeLogService) loadLastActualSmokeAt(ctx context.Context, uid int) (*time.Time, error) { + var last smokemodel.SmokeLog + if err := s.db.WithContext(ctx). + Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). + Where("NOT (level = 0 AND num = 0)"). + Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC"). + Order("id DESC"). + Limit(1). + Take(&last).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, fmt.Errorf("load last smoke log: %w", err) + } + if t, ok := lastEventTime(last); ok { + return &t, nil + } + return nil, nil +} + +func computeLungRecoveryPercent(smokeFreeMinutes int) int { + days := float64(smokeFreeMinutes) / (24 * 60) + switch { + case days < 14: + return int(math.Round((days / 14) * 15)) + case days < 30: + return int(math.Round(15 + ((days-14)/16)*15)) + case days < 90: + return int(math.Round(30 + ((days-30)/60)*20)) + default: + val := 50 + ((days-90)/275)*50 + if val > 100 { + val = 100 + } + return int(math.Round(val)) + } +} + +func buildHealthMilestones(smokeFreeMinutes int) []HealthMilestone { + steps := []struct { + name string + minutes int + }{ + {"心率血压恢复正常", 20}, + {"血氧水平恢复", 8 * 60}, + {"心脏病风险开始下降", 24 * 60}, + {"嗅觉味觉开始恢复", 48 * 60}, + {"肺功能提升 15%", 14 * 24 * 60}, + {"肺功能提升 30%", 30 * 24 * 60}, + {"肺功能提升 50%", 90 * 24 * 60}, + {"心脏病风险降低 50%", 365 * 24 * 60}, + } + + out := make([]HealthMilestone, 0, len(steps)) + for _, step := range steps { + out = append(out, HealthMilestone{ + Name: step.name, + Minutes: step.minutes, + Reached: smokeFreeMinutes >= step.minutes, + }) + } + return out +} + +func daysBetweenInclusive(start, end time.Time) int { + start = dateOnly(start) + end = dateOnly(end) + if end.Before(start) { + return 0 + } + return int(end.Sub(start).Hours()/24) + 1 +}