Files
worktimeBot/internal/bot/burnout.go

329 lines
8.9 KiB
Go

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
}