- 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
317 lines
8.3 KiB
Go
317 lines
8.3 KiB
Go
package bot
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"worktimeBot/internal/db"
|
|
)
|
|
|
|
// XP earned per second of work.
|
|
const xpPerSecond int64 = 1
|
|
|
|
// xpForLevel returns the total XP required to reach level n.
|
|
// Quadratic curve: totalXP(n) = n * (n + 1) * xpCurveMultiplier
|
|
// Level 1 = 100 XP, Level 10 = 5500 XP, Level 50 = 127500 XP
|
|
func xpForLevel(n int) int64 {
|
|
return int64(n) * int64(n+1) * xpCurveMultiplier
|
|
}
|
|
|
|
// levelForXP returns the level for a given total XP amount.
|
|
func levelForXP(xp int64) int {
|
|
l := 1
|
|
for xpForLevel(l) <= xp {
|
|
l++
|
|
}
|
|
return l - 1
|
|
}
|
|
|
|
// xpForWorkSeconds calculates XP earned for a given amount of work.
|
|
// Base: 1 XP per second.
|
|
func xpForWorkSeconds(sec int64) int64 {
|
|
return sec / xpPerSecond
|
|
}
|
|
|
|
// streakBonus returns bonus XP for maintaining a streak.
|
|
// +xpCurveMultiplier XP per day of current streak, capped at streakBonusCap.
|
|
func streakBonus(streak int) int64 {
|
|
b := int64(streak) * xpCurveMultiplier
|
|
if b > streakBonusCap {
|
|
b = streakBonusCap
|
|
}
|
|
return b
|
|
}
|
|
|
|
// computeAndAwardXP calculates XP for a work session and updates the DB.
|
|
// Returns the new total XP and whether the user leveled up.
|
|
func (h *Handler) computeAndAwardXP(userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, err error) {
|
|
stats, err := h.DB.GetOrCreateRPGStats(userID)
|
|
if err != nil {
|
|
return 0, false, 0, err
|
|
}
|
|
|
|
baseXP := xpForWorkSeconds(workSeconds)
|
|
bonus := streakBonus(streak)
|
|
totalXP := baseXP + bonus
|
|
|
|
if totalXP < 0 {
|
|
totalXP = 0
|
|
}
|
|
|
|
stats.XP += totalXP
|
|
newLevel = levelForXP(stats.XP)
|
|
leveledUp = newLevel > stats.Level
|
|
stats.Level = newLevel
|
|
stats.TotalWorkSeconds += workSeconds
|
|
|
|
if err := h.DB.UpdateRPGStats(stats); err != nil {
|
|
return 0, false, 0, err
|
|
}
|
|
|
|
if err := h.DB.UpsertDailyXP(userID, date, totalXP, workSeconds); err != nil {
|
|
slog.Warn("failed to upsert daily xp", "user_id", userID, "error", err)
|
|
}
|
|
|
|
return stats.XP, leveledUp, newLevel, nil
|
|
}
|
|
|
|
// updateStreak advances or resets the user's streak based on today's activity.
|
|
// Returns the updated streak count.
|
|
func (h *Handler) updateStreak(userID int64, date string, hasWorkToday bool) (int, error) {
|
|
stats, err := h.DB.GetOrCreateRPGStats(userID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if !hasWorkToday {
|
|
stats.CurrentStreak = 0
|
|
if err := h.DB.UpdateRPGStats(stats); err != nil {
|
|
return 0, err
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
// Parse yesterday to check continuity
|
|
t, err := time.Parse(DateLayout, date)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
yesterday := t.AddDate(0, 0, -1).Format(DateLayout)
|
|
|
|
prevEvents, err := h.DB.EventsForDayByDate(userID, yesterday)
|
|
hadWorkYesterday := err == nil && len(prevEvents) > 0
|
|
|
|
if hadWorkYesterday {
|
|
stats.CurrentStreak++
|
|
} else {
|
|
stats.CurrentStreak = 1
|
|
}
|
|
|
|
if stats.CurrentStreak > stats.LongestStreak {
|
|
stats.LongestStreak = stats.CurrentStreak
|
|
}
|
|
|
|
if err := h.DB.UpdateRPGStats(stats); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return stats.CurrentStreak, nil
|
|
}
|
|
|
|
// checkAchievements evaluates and unlocks any newly earned achievements.
|
|
func (h *Handler) checkAchievements(userID int64, stats *db.RPGStats, workSeconds int64, date string, isWeekend bool) []string {
|
|
unlocker := newAchievementUnlocker(h, userID)
|
|
h.checkEarlyAchievements(unlocker, workSeconds)
|
|
h.checkEventTimeAchievements(unlocker, userID, date)
|
|
h.checkStreakAchievements(unlocker, stats)
|
|
h.checkHourAchievements(unlocker, stats)
|
|
h.checkSessionAchievements(unlocker, workSeconds, isWeekend)
|
|
h.checkLevelAchievements(unlocker, stats)
|
|
return unlocker.unlocked
|
|
}
|
|
|
|
type achievementUnlocker struct {
|
|
h *Handler
|
|
userID int64
|
|
unlocked []string
|
|
}
|
|
|
|
func newAchievementUnlocker(h *Handler, userID int64) *achievementUnlocker {
|
|
return &achievementUnlocker{h: h, userID: userID}
|
|
}
|
|
|
|
func (u *achievementUnlocker) unlock(code string) {
|
|
a, err := u.h.DB.GetAchievementByCode(code)
|
|
if err != nil {
|
|
return
|
|
}
|
|
ok, err := u.h.DB.UnlockAchievement(u.userID, a.ID)
|
|
if err != nil {
|
|
slog.Error("unlock achievement", "code", code, "error", err)
|
|
return
|
|
}
|
|
if ok {
|
|
u.unlocked = append(u.unlocked, a.Name)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) checkEarlyAchievements(u *achievementUnlocker, workSeconds int64) {
|
|
has, _ := h.DB.HasAchievement(u.userID, "first_clockin")
|
|
if !has && workSeconds > 0 {
|
|
u.unlock("first_clockin")
|
|
}
|
|
}
|
|
|
|
func (h *Handler) checkEventTimeAchievements(u *achievementUnlocker, userID int64, date string) {
|
|
t, _ := time.Parse(DateLayout, date)
|
|
dayStart := t.Unix()
|
|
dayEnd := t.Add(24 * time.Hour).Unix()
|
|
|
|
events, err := h.DB.EventsForDayByDate(userID, date)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, e := range events {
|
|
if e.OccurredAt < dayStart || e.OccurredAt > dayEnd {
|
|
continue
|
|
}
|
|
et := time.Unix(e.OccurredAt, 0)
|
|
hh, mm, _ := et.Clock()
|
|
minutes := hh*60 + mm
|
|
if e.EventType == "in" && minutes < 7*60 {
|
|
u.unlock("early_bird")
|
|
}
|
|
if e.EventType == "out" && minutes >= 22*60 {
|
|
u.unlock("night_owl")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *Handler) checkStreakAchievements(u *achievementUnlocker, stats *db.RPGStats) {
|
|
if stats.CurrentStreak >= 5 {
|
|
u.unlock("iron_streak_5")
|
|
}
|
|
if stats.CurrentStreak >= 10 {
|
|
u.unlock("iron_streak_10")
|
|
}
|
|
if stats.CurrentStreak >= 30 {
|
|
u.unlock("iron_streak_30")
|
|
}
|
|
}
|
|
|
|
func (h *Handler) checkHourAchievements(u *achievementUnlocker, stats *db.RPGStats) {
|
|
hoursWorked := float64(stats.TotalWorkSeconds) / secondsPerHour
|
|
if hoursWorked >= 100.0 {
|
|
u.unlock("hundred_hours")
|
|
}
|
|
if hoursWorked >= 500.0 {
|
|
u.unlock("five_hundred_hours")
|
|
}
|
|
}
|
|
|
|
func (h *Handler) checkSessionAchievements(u *achievementUnlocker, workSeconds int64, isWeekend bool) {
|
|
if workSeconds > 10*secondsPerHour {
|
|
u.unlock("overtime_hero")
|
|
}
|
|
if isWeekend && workSeconds > 0 {
|
|
u.unlock("weekend_warrior")
|
|
}
|
|
}
|
|
|
|
func (h *Handler) checkLevelAchievements(u *achievementUnlocker, stats *db.RPGStats) {
|
|
if stats.Level >= 5 {
|
|
u.unlock("level_5")
|
|
}
|
|
if stats.Level >= 10 {
|
|
u.unlock("level_10")
|
|
}
|
|
if stats.Level >= 25 {
|
|
u.unlock("level_25")
|
|
}
|
|
if stats.Level >= 50 {
|
|
u.unlock("level_50")
|
|
}
|
|
}
|
|
|
|
// buildRPGStatus returns a formatted string showing the user's RPG stats.
|
|
func (h *Handler) buildRPGStatus(userID int64) string {
|
|
stats, err := h.DB.GetOrCreateRPGStats(userID)
|
|
if err != nil {
|
|
return "RPG stats unavailable"
|
|
}
|
|
|
|
nextLevelXP := xpForLevel(stats.Level + 1)
|
|
currentLevelXP := xpForLevel(stats.Level)
|
|
xpIntoLevel := stats.XP - currentLevelXP
|
|
xpNeeded := nextLevelXP - currentLevelXP
|
|
|
|
var b strings.Builder
|
|
b.WriteString(fmt.Sprintf("Level: %d\n", stats.Level))
|
|
b.WriteString(fmt.Sprintf("XP: %d / %d (%d%%)\n", stats.XP, nextLevelXP, xpIntoLevel*100/xpNeeded))
|
|
b.WriteString(fmt.Sprintf("Streak: %d days (best: %d)\n", stats.CurrentStreak, stats.LongestStreak))
|
|
|
|
hoursTotal := float64(stats.TotalWorkSeconds) / secondsPerHour
|
|
b.WriteString(fmt.Sprintf("Total work: %.1f hours\n", hoursTotal))
|
|
|
|
// Show achievements count
|
|
achs, _ := h.DB.GetUserAchievements(userID)
|
|
if len(achs) > 0 {
|
|
b.WriteString(fmt.Sprintf("Achievements: %d unlocked\n", len(achs)))
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// buildAchievementsList returns the user's unlocked achievements.
|
|
func (h *Handler) buildAchievementsList(userID int64) string {
|
|
achs, err := h.DB.GetUserAchievements(userID)
|
|
if err != nil {
|
|
return "Achievements unavailable"
|
|
}
|
|
if len(achs) == 0 {
|
|
return "No achievements unlocked yet.\nWork consistently to earn them!"
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(fmt.Sprintf("Achievements (%d):\n\n", len(achs)))
|
|
for _, a := range achs {
|
|
t := time.Unix(a.UnlockedAt, 0)
|
|
b.WriteString(fmt.Sprintf(" %s\n %s - %s\n", a.Name, a.Description, t.Format(DateLayout)))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// rpgStatus returns the user's RPG stats.
|
|
func (h *Handler) rpgStatus(chatID int64) (string, error) {
|
|
user, err := h.getOrCreateUser(chatID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
st, err := h.DB.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !st.RPGEnabled {
|
|
return "RPG system is disabled. Enable it in Settings or use /rpgtoggle.", nil
|
|
}
|
|
return h.buildRPGStatus(user.ID), nil
|
|
}
|
|
|
|
// achievementsList returns the user's unlocked achievements.
|
|
func (h *Handler) achievementsList(chatID int64) (string, error) {
|
|
user, err := h.getOrCreateUser(chatID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
st, err := h.DB.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !st.RPGEnabled {
|
|
return "RPG system is disabled. Enable it in Settings or use /rpgtoggle.", nil
|
|
}
|
|
return h.buildAchievementsList(user.ID), nil
|
|
}
|