refactor: break up large functions, add RPG/league/salary/burnout, fix bugs, add tests
- Refactor GenerateMonthlyReport (198→70), HandleCallback (128→85), handleHistoryCallback (131→38), handleExportCallback (100→25), checkAchievements (86→25) into extracted helper functions - Add RPG system (XP, levels, streak, achievements, burnout) - Add WorkTime League leaderboard - Add salary estimation with configurable currency/rate - Add input sanitization (SanitizeNote, SanitizeDisplayName) - Add settings select-style pickers for toggles (report, RPG, league) - Add break threshold inline picker UI - Add event notes with pending state and /note command - Add multi-calendar support (Jalali, Hijri) - Add Excel export with theme picker - Fix: getTodayBreakThreshold uses user's timezone (was UTC) - Fix: acknowledgeCallback passes real callback ID - Fix: currency symbol safety with currencySymbol() helper - Fix: count work hours in summary on day-off days - Fix: unsilence all error returns from SQL Exec/Bot API/migrations - Remove stale db/schema.sql and unreferenced computeWeekTotals - Extract constants: DateLayout, TimeLayout, secondsPerHour, etc. - Add 12 new tests (XP, salary, sanitize, currency, dateutil) - Remove duplicate package doc comment in totals.go
This commit is contained in:
299
internal/bot/burnout.go
Normal file
299
internal/bot/burnout.go
Normal file
@@ -0,0 +1,299 @@
|
||||
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 recentAvg > olderAvg*1.2 {
|
||||
return 15, fmt.Sprintf("+%.0f%%", math.Round((recentAvg-olderAvg)/math.Max(olderAvg, 1)*100))
|
||||
}
|
||||
if olderAvg > recentAvg*1.2 {
|
||||
return 0, fmt.Sprintf("-%.0f%%", math.Round((olderAvg-recentAvg)/math.Max(olderAvg, 1)*100))
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user