package bot import ( "fmt" "math" "strings" "time" "worktimeBot/internal/db" ) // Burnout scoring model // ====================== // Total score out of 100, computed as a weighted sum of signals: // // 1. Consecutive workdays (0-25 pts) // Measures how many days the user has worked without a break. // 0 days: 0, 1-3: 5, 4-6: 15, 7+: 25 // // 2. Long hours today (0-20 pts) // Measures daily work duration. // <8h: 0, 8-10h: 10, 10-12h: 15, 12h+: 20 // // 3. Reduced breaks (0-15 pts) // Measures break ratio (break / (work+break)). // >20%: 0, 10-20%: 5, 5-10%: 10, <5%: 15 // // 4. Weekend work (0-15 pts) // Working on Saturday or Sunday in user's timezone. // Not weekend: 0, Weekend: 15 // // 5. Work trend (0-15 pts) // Compares last 3 days to the 3 before that. // Decreasing: 0, Stable: 5, Increasing: 15 // // 6. Late-night work (0-10 pts) // Last event after typical working hours. // Before 20:00: 0, 20:00-22:00: 5, After 22:00: 10 // // Total -> Concern level: // 0-20: Healthy // 21-40: Mild concern // 41-60: Moderate concern // 61+: High concern // BurnoutLevel represents the severity of burnout indicators. type BurnoutLevel int const ( BurnoutHealthy BurnoutLevel = 0 BurnoutMild BurnoutLevel = 1 BurnoutModerate BurnoutLevel = 2 BurnoutHigh BurnoutLevel = 3 ) func (b BurnoutLevel) String() string { switch b { case BurnoutHealthy: return "Healthy" case BurnoutMild: return "Mild concern" case BurnoutModerate: return "Moderate concern" case BurnoutHigh: return "High concern" default: return "Unknown" } } // BurnoutResult holds the full burnout assessment. type BurnoutResult struct { Score int Level BurnoutLevel ConsecutivePts int LongHoursPts int BreakRatioPts int WeekendPts int TrendPts int LateNightPts int ConsecutiveDays int WorkSeconds int64 BreakSeconds int64 TrendDirection string } // assessBurnout computes a burnout assessment for a user. func (h *Handler) assessBurnout(userID int64, loc *time.Location) (*BurnoutResult, error) { now := time.Now().In(loc) today := now.Format(DateLayout) result := &BurnoutResult{} // 1. Consecutive workdays stats, err := h.DB.GetOrCreateRPGStats(userID) if err == nil { result.ConsecutiveDays = stats.CurrentStreak } switch { case result.ConsecutiveDays >= 7: result.ConsecutivePts = 25 case result.ConsecutiveDays >= 4: result.ConsecutivePts = 15 case result.ConsecutiveDays >= 1: result.ConsecutivePts = 5 } // 2. Long hours today todayEvents, _ := h.DB.EventsForDayByDate(userID, today) if len(todayEvents) > 0 { day, _ := h.DB.GetDay(userID, today) if day != nil { y, m, d := now.Date() dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix() dayEnd := now.Unix() totals := ComputeDailyTotals(todayEvents, day.MinBreakThreshold, dayStart, dayEnd) result.WorkSeconds = totals.TotalSeconds result.BreakSeconds = totals.BreakSeconds workHours := float64(totals.TotalSeconds) / secondsPerHour switch { case workHours >= 12: result.LongHoursPts = 20 case workHours >= 10: result.LongHoursPts = 15 case workHours >= 8: result.LongHoursPts = 10 } // 3. Reduced breaks total := totals.TotalSeconds + totals.BreakSeconds if total > 0 { breakRatio := float64(totals.BreakSeconds) / float64(total) * 100 switch { case breakRatio < 5: result.BreakRatioPts = 15 case breakRatio < 10: result.BreakRatioPts = 10 case breakRatio < 20: result.BreakRatioPts = 5 } } // 6. Late-night work if len(todayEvents) > 0 { lastEvent := todayEvents[len(todayEvents)-1] lt := time.Unix(lastEvent.OccurredAt, 0).In(loc) minutes := lt.Hour()*60 + lt.Minute() switch { case minutes >= 22*60: result.LateNightPts = 10 case minutes >= 20*60: result.LateNightPts = 5 } } } } // 4. Weekend work weekday := now.Weekday() if weekday == time.Saturday || weekday == time.Sunday { if result.WorkSeconds > 0 { result.WeekendPts = 15 } } // 5. Work trend (last 3 days vs 3 before that) result.TrendPts, result.TrendDirection = computeTrend(h.DB, userID, today, loc) // Total result.Score = result.ConsecutivePts + result.LongHoursPts + result.BreakRatioPts + result.WeekendPts + result.TrendPts + result.LateNightPts switch { case result.Score >= 61: result.Level = BurnoutHigh case result.Score >= 41: result.Level = BurnoutModerate case result.Score >= 21: result.Level = BurnoutMild default: result.Level = BurnoutHealthy } return result, nil } // computeTrend compares average work time of most recent 3 days vs the 3 before those. func computeTrend(store *db.Store, userID int64, today string, loc *time.Location) (pts int, direction string) { t, err := time.Parse(DateLayout, today) if err != nil { return 0, "unknown" } var recent, older [3]int64 for i := 0; i < 3; i++ { d := t.AddDate(0, 0, -(i + 1)).Format(DateLayout) recent[2-i] = dayWorkSeconds(store, userID, d, loc) } for i := 0; i < 3; i++ { d := t.AddDate(0, 0, -(i + 4)).Format(DateLayout) older[2-i] = dayWorkSeconds(store, userID, d, loc) } var recentAvg, olderAvg float64 for _, v := range recent { recentAvg += float64(v) } for _, v := range older { olderAvg += float64(v) } recentAvg /= 3.0 olderAvg /= 3.0 if olderAvg < 1 { if recentAvg > 0 { return 15, "new" } return 5, "stable" } changePct := math.Round((recentAvg - olderAvg) / olderAvg * 100) if recentAvg > olderAvg*1.2 { return 15, fmt.Sprintf("+%.0f%%", changePct) } if olderAvg > recentAvg*1.2 { return 0, fmt.Sprintf("%.0f%%", changePct) } return 5, "stable" } // computeTrendFromAvgs is a pure version of the trend computation for testing. func computeTrendFromAvgs(recentAvg, olderAvg float64) (pts int, direction string) { if olderAvg < 1 { if recentAvg > 0 { return 15, "new" } return 5, "stable" } changePct := math.Round((recentAvg - olderAvg) / olderAvg * 100) if recentAvg > olderAvg*1.2 { return 15, fmt.Sprintf("+%.0f%%", changePct) } if olderAvg > recentAvg*1.2 { return 0, fmt.Sprintf("%.0f%%", changePct) } return 5, "stable" } func dayWorkSeconds(store *db.Store, userID int64, date string, loc *time.Location) int64 { events, err := store.EventsForDayByDate(userID, date) if err != nil || len(events) == 0 { return 0 } day, err := store.GetDay(userID, date) if err != nil || day == nil { return 0 } y, m, d := parseGregorianDateKey(date) dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix() dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix() totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) return totals.TotalSeconds } // buildBurnoutText returns a formatted burnout assessment. func (h *Handler) buildBurnoutText(userID int64, loc *time.Location) string { res, err := h.assessBurnout(userID, loc) if err != nil { return "Burnout assessment unavailable." } var b strings.Builder b.WriteString(fmt.Sprintf("Burnout Assessment\nLevel: %s (%d/100)\n\n", res.Level, res.Score)) if res.ConsecutiveDays > 0 { b.WriteString(fmt.Sprintf(" Consecutive workdays: %d days (%d pts)\n", res.ConsecutiveDays, res.ConsecutivePts)) } if res.WorkSeconds > 0 { wh := float64(res.WorkSeconds) / secondsPerHour b.WriteString(fmt.Sprintf(" Today's work: %.1f hours (%d pts)\n", wh, res.LongHoursPts)) } if res.BreakSeconds > 0 { br := float64(res.BreakSeconds) total := float64(res.WorkSeconds + res.BreakSeconds) ratio := br / total * 100 if ratio > 100 { ratio = 100 } b.WriteString(fmt.Sprintf(" Break ratio: %.0f%% (%d pts)\n", ratio, res.BreakRatioPts)) } if res.WeekendPts > 0 { b.WriteString(fmt.Sprintf(" Weekend work: yes (%d pts)\n", res.WeekendPts)) } b.WriteString(fmt.Sprintf(" Work trend: %s (%d pts)\n", res.TrendDirection, res.TrendPts)) if res.LateNightPts > 0 { b.WriteString(fmt.Sprintf(" Late-night work: yes (%d pts)\n", res.LateNightPts)) } b.WriteString("\nRecommendations:\n") switch res.Level { case BurnoutHealthy: b.WriteString(" Keep up the balanced routine!") case BurnoutMild: b.WriteString(" Consider taking short breaks throughout the day.") b.WriteString("\n Make sure to disconnect after work hours.") case BurnoutModerate: b.WriteString(" Consider taking a day off soon.") b.WriteString("\n Review your workload distribution.") b.WriteString("\n Ensure you are taking adequate breaks.") case BurnoutHigh: b.WriteString(" Strongly consider taking time off.") b.WriteString("\n Your workload patterns indicate potential strain.") b.WriteString("\n Please prioritize rest and recovery.") } return b.String() } // burnoutAssessment returns the burnout assessment text. func (h *Handler) burnoutAssessment(chatID int64) (string, error) { user, err := h.getOrCreateUser(chatID) if err != nil { return "", err } loc := loadLocation(user.Timezone) return h.buildBurnoutText(user.ID, loc), nil }