- Require both symbol+code in /setcurrency; add preset picker ($ USD, T Toman, IRR Rial, EUR, GBP) in settings inline menu - League: compact 2-line per entry for mobile; sort buttons in 2x2 grid - Settings: 2-column layout with grouped buttons (timezone+calendar, report+report time, break+accent, rpg+league, currency+rate) - Burnout: cap break ratio at 100% to prevent nonsense values - Add /setusername command (uses existing SanitizeDisplayName) - Update /help with new/changed commands
303 lines
8.4 KiB
Go
303 lines
8.4 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 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
|
|
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
|
|
}
|