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:
2026-06-25 01:02:16 +03:30
parent 0d942c51db
commit 344c615666
18 changed files with 2510 additions and 483 deletions

View File

@@ -15,7 +15,7 @@ import (
// handleSetReportTime parses a HH:MM argument and updates the user's report time.
func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message) {
args := ""
if len(msg.Text) > 16 { // "/setreporttime " is 15 chars
if len(msg.Text) > 16 {
args = strings.TrimSpace(msg.Text[15:])
}
if args == "" {
@@ -24,7 +24,8 @@ func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message)
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current report time: %s\nUsage: /setreporttime HH:MM (24-hour)", user.ReportTime))
st, _ := h.DB.GetOrCreateSettings(user.ID)
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current report time: %s\nUsage: /setreporttime HH:MM (24-hour)", st.ReportTime))
return
}
if len(args) != 5 || args[2] != ':' {
@@ -42,8 +43,13 @@ func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message)
h.sendText(ctx, msg.Chat.ID, "Error updating report time")
return
}
user.ReportTime = args
if err := h.DB.UpdateUser(user); err != nil {
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error updating report time")
return
}
st.ReportTime = args
if err := h.DB.UpdateSettings(st); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error updating report time")
return
}
@@ -57,7 +63,12 @@ func (h *Handler) handleAccentMsg(ctx context.Context, msg *models.Message) {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
text, kb := h.buildAccentKeyboard(user)
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
text, kb := h.buildAccentKeyboard(st)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
@@ -92,20 +103,23 @@ func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message) {
return
}
now := time.Now().In(loc)
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", args, now.Format("15:04")))
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", args, now.Format(TimeLayout)))
}
// getTodayBreakThreshold returns today's break threshold in seconds for the given chat.
func (h *Handler) getTodayBreakThreshold(chatID int64) int64 {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return 300
return defaultBreakThreshold
}
now := time.Now()
date := now.Format("2006-01-02")
loc, err := time.LoadLocation(user.Timezone)
if err != nil {
loc = time.UTC
}
date := time.Now().In(loc).Format(DateLayout)
day, err := h.DB.GetDay(user.ID, date)
if err != nil {
return 300
return defaultBreakThreshold
}
return day.MinBreakThreshold
}
@@ -133,7 +147,7 @@ func (h *Handler) handleSetBreakMsg(ctx context.Context, msg *models.Message) {
return
}
now := time.Now()
date := now.Format("2006-01-02")
date := now.Format(DateLayout)
day, err := h.DB.GetOrCreateDay(user.ID, date)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error updating break threshold")
@@ -149,30 +163,45 @@ func (h *Handler) handleSetBreakMsg(ctx context.Context, msg *models.Message) {
// settingsCallback shows the settings inline menu.
func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
h.answerCb(ctx, callbackID)
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, bt)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// buildSettingsKeyboard returns the settings menu text and inline keyboard.
func (h *Handler) buildSettingsKeyboard(user *db.User, breakThreshold int64) (string, models.InlineKeyboardMarkup) {
func (h *Handler) buildSettingsKeyboard(user *db.User, st *db.UserSettings, breakThreshold int64) (string, models.InlineKeyboardMarkup) {
reportStatus := "disabled"
if user.ReportEnabled {
if st.ReportEnabled {
reportStatus = "enabled"
}
rpgStatus := "disabled"
if st.RPGEnabled {
rpgStatus = "enabled"
}
leagueStatus := "disabled"
if st.LeagueOptIn {
leagueStatus = "on"
}
rows := [][]models.InlineKeyboardButton{
{{Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"}},
{{Text: fmt.Sprintf("Accent: %s", user.ExportAccent), CallbackData: "accent"}},
{{Text: fmt.Sprintf("Accent: %s", st.ExportAccent), CallbackData: "accent"}},
{{Text: fmt.Sprintf("Calendar: %s", user.Calendar), CallbackData: "caltype"}},
{{Text: fmt.Sprintf("Report: %s", reportStatus), CallbackData: "reporttoggle"}},
{{Text: fmt.Sprintf("Report time: %s", user.ReportTime), CallbackData: "reporttime"}},
{{Text: fmt.Sprintf("Report time: %s", st.ReportTime), CallbackData: "reporttime"}},
{{Text: fmt.Sprintf("Break threshold: %dm", breakThreshold/60), CallbackData: "breakthreshold"}},
{{Text: "History", CallbackData: "history"}},
{{Text: fmt.Sprintf("RPG: %s", rpgStatus), CallbackData: "rpgtoggle"}},
{{Text: fmt.Sprintf("League: %s", leagueStatus), CallbackData: "leaguetoggle"}},
{{Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"}},
{{Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
}
return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
@@ -180,18 +209,22 @@ func (h *Handler) buildSettingsKeyboard(user *db.User, breakThreshold int64) (st
// accentCallback shows the accent color picker (inline edit).
func (h *Handler) accentCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
h.answerCb(ctx, callbackID)
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
text, kb := h.buildAccentKeyboard(user)
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
text, kb := h.buildAccentKeyboard(st)
h.editText(ctx, chatID, msgID, text, &kb)
}
// timezoneCallback shows the current timezone and instructions to change it.
func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
h.answerCb(ctx, callbackID)
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
@@ -206,7 +239,7 @@ func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int,
}
// buildAccentKeyboard returns the accent color picker keyboard.
func (h *Handler) buildAccentKeyboard(user *db.User) (string, models.InlineKeyboardMarkup) {
func (h *Handler) buildAccentKeyboard(st *db.UserSettings) (string, models.InlineKeyboardMarkup) {
type accentOption struct {
name string
color string
@@ -220,7 +253,7 @@ func (h *Handler) buildAccentKeyboard(user *db.User) (string, models.InlineKeybo
rows := [][]models.InlineKeyboardButton{}
for _, a := range accents {
label := fmt.Sprintf("%s (%s)", a.name, a.color)
if a.name == user.ExportAccent {
if a.name == st.ExportAccent {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
@@ -235,7 +268,7 @@ func (h *Handler) buildAccentKeyboard(user *db.User) (string, models.InlineKeybo
// selectAccent sets the user's export accent color after validation.
func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, callbackID, name string) {
h.answerCb(ctx, callbackID)
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
@@ -244,8 +277,12 @@ func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, cal
if !valid[name] {
return
}
user.ExportAccent = name
if err := h.DB.UpdateUser(user); err != nil {
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.ExportAccent = name
if err := h.DB.UpdateSettings(st); err != nil {
return
}
kb := models.InlineKeyboardMarkup{
@@ -258,12 +295,16 @@ func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, cal
// reportTimeCallback shows the current report time setting.
func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
h.answerCb(ctx, callbackID)
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
text := fmt.Sprintf("Current report time: %s\nUse /setreporttime HH:MM to change it.", user.ReportTime)
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
text := fmt.Sprintf("Current report time: %s\nUse /setreporttime HH:MM to change it.", st.ReportTime)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Settings", CallbackData: "back_settings"}},
@@ -274,7 +315,7 @@ func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID in
// breakThresholdCallback shows the break threshold picker.
func (h *Handler) breakThresholdCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
h.answerCb(ctx, callbackID)
defer h.answerCb(ctx, callbackID)
bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildBreakThresholdKeyboard(bt)
h.editText(ctx, chatID, msgID, text, &kb)
@@ -309,9 +350,9 @@ func (h *Handler) buildBreakThresholdKeyboard(current int64) (string, models.Inl
// selectBreakThreshold saves the chosen break threshold and returns to settings.
func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID int, callbackID, valStr string) {
h.answerCb(ctx, callbackID)
defer h.answerCb(ctx, callbackID)
mins, err := strconv.Atoi(valStr)
if err != nil || mins < 0 || mins > 480 {
if err != nil || mins < 0 || mins > maxBreakThresholdMins {
return
}
user, err := h.getOrCreateUser(chatID)
@@ -319,7 +360,7 @@ func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID
return
}
now := time.Now()
date := now.Format("2006-01-02")
date := now.Format(DateLayout)
day, err := h.DB.GetOrCreateDay(user.ID, date)
if err != nil {
return
@@ -329,25 +370,33 @@ func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID
return
}
bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, bt)
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// backToSettings returns to the main settings menu.
func (h *Handler) backToSettings(ctx context.Context, chatID int64, msgID int, callbackID string) {
h.answerCb(ctx, callbackID)
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, bt)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// calTypeCallback shows the calendar type selector.
func (h *Handler) calTypeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
h.answerCb(ctx, callbackID)
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
@@ -382,7 +431,7 @@ func (h *Handler) buildCalendarKeyboard(user *db.User) (string, models.InlineKey
// selectCalendar sets the user's calendar after validation.
func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, callbackID, name string) {
h.answerCb(ctx, callbackID)
defer h.answerCb(ctx, callbackID)
valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true}
if !valid[name] {
return
@@ -396,6 +445,185 @@ func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, c
return
}
bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, bt)
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// handleRPGSettingsMsg shows the RPG enable/disable picker (command).
func (h *Handler) handleRPGSettingsMsg(ctx context.Context, msg *models.Message) {
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
text, kb := h.buildRPGToggleKeyboard(user.ID)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// handleLeagueSettingsMsg shows the League enable/disable picker (command).
func (h *Handler) handleLeagueSettingsMsg(ctx context.Context, msg *models.Message) {
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
text, kb := h.buildLeagueToggleKeyboard(user.ID)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// rpgToggleCallback shows the RPG enable/disable picker (inline).
func (h *Handler) rpgToggleCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
text, kb := h.buildRPGToggleKeyboard(user.ID)
h.editText(ctx, chatID, msgID, text, &kb)
}
// leagueToggleCallback shows the League enable/disable picker (inline).
func (h *Handler) leagueToggleCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
text, kb := h.buildLeagueToggleKeyboard(user.ID)
h.editText(ctx, chatID, msgID, text, &kb)
}
// reportToggleCallback shows the Report enable/disable picker (inline) — replaces simple toggle.
func (h *Handler) reportToggleCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
text, kb := h.buildReportToggleKeyboard(user.ID)
h.editText(ctx, chatID, msgID, text, &kb)
}
// buildRPGToggleKeyboard returns the RPG enable/disable picker.
func (h *Handler) buildRPGToggleKeyboard(userID int64) (string, models.InlineKeyboardMarkup) {
st, err := h.DB.GetOrCreateSettings(userID)
current := false
if err == nil {
current = st.RPGEnabled
}
rows := [][]models.InlineKeyboardButton{}
for _, opt := range []struct {
label string
data string
value bool
}{
{"Enabled", "rpg_enable", true},
{"Disabled", "rpg_disable", false},
} {
label := opt.label
if opt.value == current {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: opt.data},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "RPG System:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// buildLeagueToggleKeyboard returns the League enable/disable picker.
func (h *Handler) buildLeagueToggleKeyboard(userID int64) (string, models.InlineKeyboardMarkup) {
st, err := h.DB.GetOrCreateSettings(userID)
current := false
if err == nil {
current = st.LeagueOptIn
}
rows := [][]models.InlineKeyboardButton{}
for _, opt := range []struct {
label string
data string
value bool
}{
{"Enabled", "league_enable", true},
{"Disabled", "league_disable", false},
} {
label := opt.label
if opt.value == current {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: opt.data},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "WorkTime League:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// buildReportToggleKeyboard returns the Report enable/disable picker.
func (h *Handler) buildReportToggleKeyboard(userID int64) (string, models.InlineKeyboardMarkup) {
st, err := h.DB.GetOrCreateSettings(userID)
current := true
if err == nil {
current = st.ReportEnabled
}
rows := [][]models.InlineKeyboardButton{}
for _, opt := range []struct {
label string
data string
value bool
}{
{"Enabled", "report_enable", true},
{"Disabled", "report_disable", false},
} {
label := opt.label
if opt.value == current {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: opt.data},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Daily Report:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// handleSelectCallback handles binary select callbacks (report_enable, rpg_enable, etc.).
func (h *Handler) handleSelectCallback(ctx context.Context, chatID int64, msgID int, callbackID string, setting string, value bool) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
switch setting {
case "report_enable":
st.ReportEnabled = value
case "rpg_enable":
st.RPGEnabled = value
if value {
h.DB.GetOrCreateRPGStats(user.ID)
}
case "league_enable":
st.LeagueOptIn = value
}
if err := h.DB.UpdateSettings(st); err != nil {
return
}
bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}