package handler import ( "context" "fmt" "log/slog" "strings" "time" "github.com/go-telegram/bot/models" "worktimeBot/internal/domain" ) // -- Commands -- func (h *Handler) handleRPG(ctx context.Context, msg *models.Message, user *domain.User) { st, err := h.Repo.GetOrCreateSettings(user.ID) if err != nil { h.sendText(ctx, msg.Chat.ID, "Error loading settings") return } if !st.RPGEnabled { h.sendText(ctx, msg.Chat.ID, "RPG system is disabled. Enable it in Settings or use /rpgtoggle.") return } text := h.buildRPGStatus(user) kb := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ {{Text: "Achievements", CallbackData: "rpg_achievements"}}, {{Text: "Back to Menu", CallbackData: "back_menu"}}, }, } h.sendWithKB(ctx, msg.Chat.ID, text, kb) } func (h *Handler) handleAchievements(ctx context.Context, msg *models.Message, user *domain.User) { text := h.buildAchievementsList(user) h.sendWithKB(ctx, msg.Chat.ID, text, backKeyboard()) } // -- Callbacks -- func (h *Handler) rpgCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { defer h.answerCb(ctx, cbID) text := h.buildRPGStatus(user) kb := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ {{Text: "Achievements", CallbackData: "rpg_achievements"}}, {{Text: "Back to Menu", CallbackData: "back_menu"}}, }, } h.editText(ctx, chatID, msgID, text, &kb) } func (h *Handler) achievementsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { defer h.answerCb(ctx, cbID) text := h.buildAchievementsList(user) kb := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ {{Text: "Back to Menu", CallbackData: "back_menu"}}, }, } h.editText(ctx, chatID, msgID, text, &kb) } func (h *Handler) rpgAchievementsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { defer h.answerCb(ctx, cbID) text := h.buildAchievementsList(user) kb := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ {{Text: "Back to RPG", CallbackData: "rpg_back"}}, }, } h.editText(ctx, chatID, msgID, text, &kb) } func (h *Handler) rpgBackCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { defer h.answerCb(ctx, cbID) text := h.buildRPGStatus(user) kb := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ {{Text: "Achievements", CallbackData: "rpg_achievements"}}, {{Text: "Back to Menu", CallbackData: "back_menu"}}, }, } h.editText(ctx, chatID, msgID, text, &kb) } // -- RPG business logic -- func (h *Handler) buildRPGStatus(user *domain.User) string { st, err := h.Repo.GetOrCreateSettings(user.ID) if err != nil || !st.RPGEnabled { return "RPG system is disabled." } stats, err := h.Repo.GetOrCreateRPGStats(user.ID) if err != nil { return "RPG stats unavailable" } nextLevelXP := domain.XpForLevel(stats.Level + 1) currentLevelXP := domain.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) / domain.SecondsPerHour b.WriteString(fmt.Sprintf("Total work: %.1f hours\n", hoursTotal)) achs, _ := h.Repo.GetUserAchievements(user.ID) if len(achs) > 0 { b.WriteString(fmt.Sprintf("Achievements: %d unlocked\n", len(achs))) } return b.String() } func (h *Handler) buildAchievementsList(user *domain.User) string { achs, err := h.Repo.GetUserAchievements(user.ID) 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(domain.DateLayout))) } return b.String() } func (h *Handler) awardXPAndCheckAchievements(user *domain.User, stats *domain.RPGStats, sessionWork int64, today string, isWeekend bool) string { h.updateStreak(stats, user.ID, today) xp, leveledUp, newLevel, err := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today) if err != nil { slog.Error("compute and award xp failed", "user_id", user.ID, "error", err) return "" } totalHours := float64(stats.TotalWorkSeconds) / domain.SecondsPerHour unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours) text := fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel) if leveledUp { text += "\nLevel up!" } for _, a := range unlocked { text += fmt.Sprintf("\nAchievement: %s", a) } return text } func (h *Handler) computeAndAwardXP(stats *domain.RPGStats, userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, _ error) { baseXP := domain.XpForWorkSeconds(workSeconds) bonus := domain.StreakBonus(streak) totalXP := baseXP + bonus if totalXP < 0 { totalXP = 0 } stats.XP += totalXP newLevel = domain.LevelForXP(stats.XP) leveledUp = newLevel > stats.Level stats.Level = newLevel stats.TotalWorkSeconds += workSeconds if err := h.Repo.UpdateRPGStats(stats); err != nil { return 0, false, 0, err } if err := h.Repo.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 } func (h *Handler) updateStreak(stats *domain.RPGStats, userID int64, date string) { t, err := time.Parse(domain.DateLayout, date) if err != nil { return } yesterday := t.AddDate(0, 0, -1).Format(domain.DateLayout) prevEvents, err := h.Repo.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 } } func (h *Handler) recalcDayWorkSeconds(userID int64, date string, loc *time.Location) { events, err := h.Repo.EventsForDayByDate(userID, date) if err != nil { return } day, err := h.Repo.GetDay(userID, date) if err != nil { return } y, m, d := domain.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 := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) if err := h.Repo.SetDailyWorkSeconds(userID, date, totals.TotalSeconds); err != nil { slog.Warn("failed to set daily work seconds", "user_id", userID, "date", date, "error", err) } } func (h *Handler) recalcAggregates(userID int64, loc *time.Location) { total, err := h.Repo.SumDailyWorkSeconds(userID) if err != nil { slog.Warn("failed to sum daily work seconds", "user_id", userID, "error", err) return } stats, err := h.Repo.GetOrCreateRPGStats(userID) if err != nil { slog.Warn("failed to get rpg stats for recalc", "user_id", userID, "error", err) return } stats.TotalWorkSeconds = total dates, err := h.Repo.GetDatesWithEvents(userID) today := time.Now().In(loc).Format(domain.DateLayout) if err == nil { stats.CurrentStreak, stats.LongestStreak = domain.ComputeStreakFromDates(dates, today) } else { stats.CurrentStreak = 0 stats.LongestStreak = 0 } if err := h.Repo.UpdateRPGStats(stats); err != nil { slog.Warn("failed to update rpg stats after recalc", "user_id", userID, "error", err) } } // -- Achievements -- func (h *Handler) checkAchievements(userID int64, stats *domain.RPGStats, workSeconds int64, date string, isWeekend bool, totalHours float64) []string { u := &achievementUnlocker{h: h, userID: userID} h.checkEarlyAchievements(u, workSeconds) h.checkEventTimeAchievements(u, userID, date) h.checkStreakAchievements(u, stats) h.checkHourAchievements(u, stats, totalHours) h.checkSessionAchievements(u, workSeconds, isWeekend) h.checkLevelAchievements(u, stats) h.checkConsistencyAchievements(u, userID, date) h.checkPerfectWeekAchievements(u, userID, date) h.checkWorkTypeAchievements(u, userID) return u.unlocked } type achievementUnlocker struct { h *Handler userID int64 unlocked []string } func (u *achievementUnlocker) unlock(code string) { a, err := u.h.Repo.GetAchievementByCode(code) if err != nil { return } ok, err := u.h.Repo.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.Repo.HasAchievement(u.userID, "first_clockin") if !has && workSeconds > 0 { u.unlock("first_clockin") } } func (h *Handler) checkEventTimeAchievements(u *achievementUnlocker, userID int64, date string) { events, err := h.Repo.EventsForDayByDate(userID, date) if err != nil { return } for _, e := range events { 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 *domain.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 *domain.RPGStats, hoursWorked float64) { 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*domain.SecondsPerHour { u.unlock("overtime_hero") } if isWeekend && workSeconds > 0 { u.unlock("weekend_warrior") } } func (h *Handler) checkLevelAchievements(u *achievementUnlocker, stats *domain.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") } } func (h *Handler) checkConsistencyAchievements(u *achievementUnlocker, userID int64, today string) { t, err := time.Parse(domain.DateLayout, today) if err != nil { return } sevenDaysAgo := t.AddDate(0, 0, -7).Format(domain.DateLayout) yesterday := t.AddDate(0, 0, -1).Format(domain.DateLayout) records, err := h.Repo.GetDailyXPInRange(userID, sevenDaysAgo, yesterday) if err != nil { return } if len(records) < 7 { return } allHaveSixHours := true for _, r := range records { if r.WorkSeconds < 6*domain.SecondsPerHour { allHaveSixHours = false break } } if allHaveSixHours { u.unlock("consistency_master") } } func (h *Handler) checkPerfectWeekAchievements(u *achievementUnlocker, userID int64, today string) { t, err := time.Parse(domain.DateLayout, today) if err != nil { return } start := t.AddDate(0, 0, -6).Format(domain.DateLayout) days, err := h.Repo.GetUserDaysInRange(userID, start, today) if err != nil { return } if len(days) < 7 { return } for _, d := range days { if d.IsDayOff { return } } u.unlock("perfect_week") } func (h *Handler) checkWorkTypeAchievements(u *achievementUnlocker, userID int64) { rem, ons, err := h.Repo.SumWorkSecondsByWorkType(userID) if err != nil { return } if rem >= 100*domain.SecondsPerHour { u.unlock("remote_veteran") } if ons >= 100*domain.SecondsPerHour { u.unlock("onsite_master") } }