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

@@ -14,7 +14,21 @@ import (
"worktimeBot/internal/db"
)
const rateLimitInterval = 1 * time.Second
const (
rateLimitInterval = 1 * time.Second
DateLayout = "2006-01-02"
TimeLayout = "15:04"
secondsPerHour = 3600
secondsPerDay = 86400
defaultBreakThreshold = 300
maxBreakThresholdMins = 480
streakBonusCap = 500
xpCurveMultiplier = 50
salaryRoundFactor = 100
maxHourlyRate = 999999
)
type pendingNote struct {
eventID int64
@@ -129,10 +143,10 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
loc := loadLocation(user.Timezone)
event, err := h.getEventByID(user.ID, pn.eventID)
if err == nil {
if err := h.DB.SetEventNote(pn.eventID, msg.Text); err != nil {
if err := h.DB.SetEventNote(pn.eventID, SanitizeNote(msg.Text)); err != nil {
slog.Error("failed to save event note", "event_id", pn.eventID, "error", err)
}
date := time.Unix(event.OccurredAt, 0).In(loc).Format("2006-01-02")
date := time.Unix(event.OccurredAt, 0).In(loc).Format(DateLayout)
h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true)
}
return
@@ -183,6 +197,24 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
h.handleSummaryMsg(ctx, msg)
case "/setbreak":
h.handleSetBreakMsg(ctx, msg)
case "/rpg":
h.handleActionMsg(ctx, msg, h.rpgStatus)
case "/achievements":
h.handleActionMsg(ctx, msg, h.achievementsList)
case "/salary":
h.handleActionMsg(ctx, msg, h.salaryEstimate)
case "/burnout":
h.handleActionMsg(ctx, msg, h.burnoutAssessment)
case "/league":
h.handleLeagueMsg(ctx, msg)
case "/setrate":
h.handleSetRateMsg(ctx, msg)
case "/setcurrency":
h.handleSetCurrencyMsg(ctx, msg)
case "/rpgtoggle":
h.handleRPGSettingsMsg(ctx, msg)
case "/leaguetoggle":
h.handleLeagueSettingsMsg(ctx, msg)
default:
h.sendText(ctx, msg.Chat.ID, "Unknown command")
}
@@ -232,8 +264,6 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery)
h.timezoneCallback(ctx, chatID, msgID, cb.ID)
case "history":
h.historyCallback(ctx, chatID, msgID, cb.ID)
case "reporttoggle":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.toggleReport)
case "reporttime":
h.reportTimeCallback(ctx, chatID, msgID, cb.ID)
case "breakthreshold":
@@ -242,29 +272,75 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery)
h.backToMenu(ctx, chatID, msgID, cb.ID)
case "back_settings":
h.backToSettings(ctx, chatID, msgID, cb.ID)
case "rpg":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.rpgStatus)
case "achievements":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.achievementsList)
case "salary":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.salaryEstimate)
case "burnout":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.burnoutAssessment)
case "league":
h.handleLeagueCallback(ctx, chatID, msgID, cb.ID, "xp")
case "league_xp":
h.handleLeagueCallback(ctx, chatID, msgID, cb.ID, "xp")
case "league_level":
h.handleLeagueCallback(ctx, chatID, msgID, cb.ID, "level")
case "league_streak":
h.handleLeagueCallback(ctx, chatID, msgID, cb.ID, "streak")
case "league_hours":
h.handleLeagueCallback(ctx, chatID, msgID, cb.ID, "hours")
case "rpgtoggle":
h.rpgToggleCallback(ctx, chatID, msgID, cb.ID)
case "leaguetoggle":
h.leagueToggleCallback(ctx, chatID, msgID, cb.ID)
case "reporttoggle":
h.reportToggleCallback(ctx, chatID, msgID, cb.ID)
case "currency":
h.currencyCallback(ctx, chatID, msgID, cb.ID)
case "setrate":
h.rateCallback(ctx, chatID, msgID, cb.ID)
default:
// Delegate prefixed callbacks to the appropriate sub-handler
if len(cb.Data) > 9 && cb.Data[:9] == "worktype_" {
h.selectWorkType(ctx, chatID, msgID, cb.ID, cb.Data[9:])
return
}
if len(cb.Data) > 7 && cb.Data[:7] == "accent_" {
h.selectAccent(ctx, chatID, msgID, cb.ID, cb.Data[7:])
return
}
if len(cb.Data) > 8 && cb.Data[:8] == "caltype_" {
h.selectCalendar(ctx, chatID, msgID, cb.ID, cb.Data[8:])
return
}
if len(cb.Data) > 4 && cb.Data[:4] == "exp_" {
h.handleExportCallback(ctx, chatID, msgID, cb.ID, cb.Data)
return
}
if len(cb.Data) > 15 && cb.Data[:15] == "breakthreshold_" {
h.selectBreakThreshold(ctx, chatID, msgID, cb.ID, cb.Data[15:])
return
}
h.handleHistoryCallback(ctx, chatID, msgID, cb.ID, cb.Data)
h.handlePrefixedCallback(ctx, chatID, msgID, cb.ID, cb.Data)
}
}
func (h *Handler) handlePrefixedCallback(ctx context.Context, chatID int64, msgID int, callbackID, data string) {
if len(data) > 9 && data[:9] == "worktype_" {
h.selectWorkType(ctx, chatID, msgID, callbackID, data[9:])
return
}
if len(data) > 7 && data[:7] == "accent_" {
h.selectAccent(ctx, chatID, msgID, callbackID, data[7:])
return
}
if len(data) > 8 && data[:8] == "caltype_" {
h.selectCalendar(ctx, chatID, msgID, callbackID, data[8:])
return
}
if len(data) > 4 && data[:4] == "exp_" {
h.handleExportCallback(ctx, chatID, msgID, callbackID, data)
return
}
if len(data) > 15 && data[:15] == "breakthreshold_" {
h.selectBreakThreshold(ctx, chatID, msgID, callbackID, data[15:])
return
}
switch data {
case "rpg_enable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "rpg_enable", true)
case "rpg_disable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "rpg_enable", false)
case "league_enable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "league_enable", true)
case "league_disable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "league_enable", false)
case "report_enable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "report_enable", true)
case "report_disable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "report_enable", false)
default:
h.handleHistoryCallback(ctx, chatID, msgID, callbackID, data)
}
}
@@ -283,7 +359,7 @@ func (h *Handler) getUserToday(chatID int64) (*db.User, *db.Day, error) {
if err != nil {
loc = time.UTC
}
today := time.Now().In(loc).Format("2006-01-02")
today := time.Now().In(loc).Format(DateLayout)
day, err := h.DB.GetOrCreateDay(user.ID, today)
if err != nil {
return nil, nil, err
@@ -313,16 +389,25 @@ func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) {
/worktype - Select work type
/dayoff - Toggle day off
/report - Show today report
/export [YYYY-MM] - Export monthly report (current month if omitted)
/export [YYYY-MM] - Export monthly report
/history - View calendar history
/edit YYYY-MM-DD - Edit a specific day
/note [YYYY-MM-DD] HH:MM <text> - Set note for an event
/summary [YYYY-MM] - Show monthly summary (current month if omitted)
/summary [YYYY-MM] - Show monthly summary
/reporttoggle - Enable/disable auto report
/setreporttime HH:MM - Set daily report time
/setaccent - Select accent color
/settimezone <name> - Set your timezone (e.g. Asia/Tehran)
/setbreak <minutes> - Set minimum break threshold in minutes
/settimezone <name> - Set timezone (e.g. Asia/Tehran)
/setbreak <minutes> - Set break threshold in minutes
/rpg - Show RPG stats and XP
/achievements - View unlocked achievements
/salary - Show salary estimate
/burnout - View burnout assessment
/league - View WorkTime League rankings
/setrate <amount> - Set hourly rate (e.g. 25.50)
/setcurrency <symbol> [code] - Set currency (e.g. $ USD)
/rpgtoggle - Enable/disable RPG system
/leaguetoggle - Enable/disable league participation
Dates use your calendar type (Gregorian/Jalali/Hijri).
Buttons on the main menu also work.`
@@ -354,7 +439,7 @@ func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
timeStr = parts[2]
noteParts = parts[3:]
} else {
dateStr = time.Now().In(loc).Format("2006-01-02")
dateStr = time.Now().In(loc).Format(DateLayout)
timeStr = parts[1]
noteParts = parts[2:]
}
@@ -367,7 +452,7 @@ func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
var targetEvent *db.Event
for i := range events {
t := time.Unix(events[i].OccurredAt, 0).In(loc).Format("15:04")
t := time.Unix(events[i].OccurredAt, 0).In(loc).Format(TimeLayout)
if t == timeStr {
targetEvent = &events[i]
break
@@ -378,7 +463,7 @@ func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
return
}
note := strings.Join(noteParts, " ")
note := SanitizeNote(strings.Join(noteParts, " "))
if err := h.DB.SetEventNote(targetEvent.ID, note); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving note")
return
@@ -470,8 +555,15 @@ func mainKeyboard() models.InlineKeyboardMarkup {
{
{Text: "Report", CallbackData: "report"},
{Text: "Export", CallbackData: "export"},
{Text: "History", CallbackData: "history"},
},
{
{Text: "RPG", CallbackData: "rpg"},
{Text: "League", CallbackData: "league"},
{Text: "Burnout", CallbackData: "burnout"},
},
{
{Text: "Salary", CallbackData: "salary"},
{Text: "Settings", CallbackData: "settings"},
},
},
@@ -489,8 +581,8 @@ func backKeyboard() models.InlineKeyboardMarkup {
// formatDuration formats seconds as a human-readable duration (e.g. "7h 30m").
func formatDuration(seconds int64) string {
hours := seconds / 3600
mins := (seconds % 3600) / 60
hours := seconds / secondsPerHour
mins := (seconds % secondsPerHour) / 60
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}