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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user