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

@@ -65,123 +65,138 @@ func (h *Handler) handleHistoryCallback(ctx context.Context, chatID int64, msgID
return
}
var y, m int
var date string
var eid int64
if h.handleHistoryCalendarNav(ctx, chatID, msgID, data) {
return
}
if h.handleHistoryYearPicker(ctx, chatID, msgID, data) {
return
}
if h.handleHistoryDayView(ctx, chatID, msgID, user, data, loc) {
return
}
if h.handleHistoryEventEdit(ctx, chatID, msgID, user, data, loc, callbackID) {
return
}
if h.handleHistoryEventAdd(ctx, chatID, msgID, user, data, loc) {
return
}
}
// Navigate to previous month
// handleHistoryCalendarNav handles calendar navigation callbacks.
func (h *Handler) handleHistoryCalendarNav(ctx context.Context, chatID int64, msgID int, data string) bool {
var y, m int
if n, _ := fmt.Sscanf(data, "cal_prev_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
return true
}
// Navigate to next month
if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
return true
}
// Navigate to previous year
if n, _ := fmt.Sscanf(data, "cal_prev_year_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
return true
}
// Navigate to next year
if n, _ := fmt.Sscanf(data, "cal_next_year_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
return true
}
// Show year picker from calendar
return false
}
// handleHistoryYearPicker handles year picker callbacks.
func (h *Handler) handleHistoryYearPicker(ctx context.Context, chatID int64, msgID int, data string) bool {
var y, m, startY, refY, refM int
if n, _ := fmt.Sscanf(data, "cal_show_years_%d_%d", &y, &m); n == 2 {
h.editCalendarYearPicker(ctx, chatID, msgID, y, m, y)
return
return true
}
// Navigate year block in picker
var startY, refY, refM int
if n, _ := fmt.Sscanf(data, "cal_years_%d_%d_%d", &startY, &refY, &refM); n == 3 {
h.editCalendarYearPicker(ctx, chatID, msgID, startY+6, refM, refY)
return
return true
}
// Select year from picker
if n, _ := fmt.Sscanf(data, "cal_year_%d", &y); n == 1 {
h.editCalendar(ctx, chatID, msgID, y, 1)
return
return true
}
// Back from year picker to calendar
if n, _ := fmt.Sscanf(data, "cal_years_back_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
return true
}
// Open a specific day
return false
}
// handleHistoryDayView handles day-view-related callbacks.
func (h *Handler) handleHistoryDayView(ctx context.Context, chatID int64, msgID int, user *db.User, data string, loc *time.Location) bool {
var date string
if n, _ := fmt.Sscanf(data, "cal_day_%s", &date); n == 1 && len(date) == 10 {
h.editDayView(ctx, chatID, msgID, user, date, loc)
return
return true
}
// Back to day view from event editing
var eid int64
if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 {
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
return
return true
}
// Set event work type
var wtid int64
return false
}
// handleHistoryEventEdit handles event editing callbacks.
func (h *Handler) handleHistoryEventEdit(ctx context.Context, chatID int64, msgID int, user *db.User, data string, loc *time.Location, callbackID string) bool {
var eid, wtid int64
if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 {
h.setEventWorkType(ctx, chatID, msgID, user, eid, wtid, loc)
return
return true
}
// Show work type picker for an event
if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 {
h.editEventType(ctx, chatID, msgID, user, eid, loc)
return
return true
}
// Delete event confirmation prompt
if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 {
h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc)
return
return true
}
// Confirm event deletion
if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 {
h.deleteEventConfirm(ctx, chatID, msgID, user, eid, loc)
return
return true
}
// Show hour picker for event time
if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 {
h.editEventTime(ctx, chatID, msgID, user, eid, loc)
return
return true
}
// Set event time (hour+minute)
var hh, mm int
if n, _ := fmt.Sscanf(data, "edittm_%d_%d_%d", &eid, &hh, &mm); n == 3 {
h.editEventTimeSet(ctx, chatID, msgID, user, eid, hh, mm, loc)
return
h.editEventTimeSet(ctx, chatID, msgID, user, eid, hh, mm, loc, callbackID)
return true
}
// Show minute picker (hour already chosen)
if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 {
h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc)
return
return true
}
// Set note for an event — prompt user for text
if n, _ := fmt.Sscanf(data, "note_%d", &eid); n == 1 {
h.promptNote(ctx, chatID, msgID, user, eid, loc)
return
return true
}
// Cancel note prompt — return to day view
if n, _ := fmt.Sscanf(data, "note_cancel_%d", &eid); n == 1 {
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
return
return true
}
// Add IN event — show hour picker
if strings.HasPrefix(data, "addin_") {
return false
}
// handleHistoryEventAdd handles event-adding callbacks.
func (h *Handler) handleHistoryEventAdd(ctx context.Context, chatID int64, msgID int, user *db.User, data string, loc *time.Location) bool {
switch {
case strings.HasPrefix(data, "addin_"):
h.addEventTime(ctx, chatID, msgID, user, data[6:], "in", loc)
return
}
// Add OUT event — show hour picker
if strings.HasPrefix(data, "addout_") {
case strings.HasPrefix(data, "addout_"):
h.addEventTime(ctx, chatID, msgID, user, data[7:], "out", loc)
return
}
// Add event hour/minute callbacks
if strings.HasPrefix(data, "addtm_") {
case strings.HasPrefix(data, "addtm_"):
h.handleAddEventTime(ctx, chatID, msgID, user, data[6:], loc)
return
default:
return false
}
return true
}
// sendCalendar sends a calendar view as a new message.
@@ -245,7 +260,7 @@ func (h *Handler) editCalendar(ctx context.Context, chatID int64, msgID int, yea
cm := buildCalendarMonth(cal, year, month)
text := cm.title
todayKey := now.Format("2006-01-02")
todayKey := now.Format(DateLayout)
hasEvent := make(map[string]bool)
startDate, endDate := monthGregorianRange(cal, year, month)
@@ -365,7 +380,7 @@ func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user
text += "\n\nEvents:"
kbRows := [][]models.InlineKeyboardButton{}
for _, e := range events {
t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04")
t := time.Unix(e.OccurredAt, 0).In(loc).Format(TimeLayout)
label := "IN"
if e.EventType == "out" {
label = "OUT"
@@ -435,7 +450,7 @@ func (h *Handler) setEventWorkType(ctx context.Context, chatID int64, msgID int,
return
}
t := time.Unix(event.OccurredAt, 0).In(loc)
h.sendDayView(ctx, chatID, msgID, user, t.Format("2006-01-02"), loc, false)
h.sendDayView(ctx, chatID, msgID, user, t.Format(DateLayout), loc, false)
}
// buildHourPickerKeyboard creates the 24-hour grid for time selection.
@@ -488,14 +503,14 @@ func (h *Handler) editEventTimeMin(ctx context.Context, chatID int64, msgID int,
}
// editEventTimeSet applies a new time to an event, checking for overlaps.
func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, hh, mm int, loc *time.Location) {
func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, hh, mm int, loc *time.Location, callbackID string) {
event, err := h.getEventByID(user.ID, eventID)
if err != nil {
return
}
t := time.Unix(event.OccurredAt, 0).In(loc)
newTime := time.Date(t.Year(), t.Month(), t.Day(), hh, mm, 0, 0, loc)
date := t.Format("2006-01-02")
date := t.Format(DateLayout)
// Reject if the new time collides with another event's timestamp
events, err := h.DB.EventsForDayByDate(user.ID, date)
@@ -506,7 +521,7 @@ func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int,
}
eTime := time.Unix(e.OccurredAt, 0)
if newTime.Unix() == eTime.Unix() {
h.acknowledgeCallback(ctx, chatID, msgID)
h.acknowledgeCallback(ctx, callbackID)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
return
}
@@ -552,8 +567,8 @@ func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int,
}
// acknowledgeCallback sends an empty acknowledgement for a callback query.
func (h *Handler) acknowledgeCallback(ctx context.Context, chatID int64, msgID int) {
h.answerCb(ctx, fmt.Sprintf("cb_%d_%d", chatID, msgID))
func (h *Handler) acknowledgeCallback(ctx context.Context, callbackID string) {
h.answerCb(ctx, callbackID)
}
// promptNote asks the user to type a note for the event, then re-renders the day view.
@@ -562,7 +577,7 @@ func (h *Handler) promptNote(ctx context.Context, chatID int64, msgID int, user
if err != nil {
return
}
t := time.Unix(event.OccurredAt, 0).In(loc).Format("15:04")
t := time.Unix(event.OccurredAt, 0).In(loc).Format(TimeLayout)
h.mu.Lock()
h.pendingNotes[chatID] = &pendingNote{eventID: eventID, createdAt: time.Now()}
@@ -596,7 +611,7 @@ func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID in
return
}
t := time.Unix(event.OccurredAt, 0).In(loc)
date := t.Format("2006-01-02")
date := t.Format(DateLayout)
_, err = h.DB.DB().Exec("DELETE FROM events WHERE id=?", eventID)
if err != nil {
@@ -630,7 +645,7 @@ func (h *Handler) backToDayView(ctx context.Context, chatID int64, msgID int, us
return
}
t := time.Unix(event.OccurredAt, 0).In(loc)
h.sendDayView(ctx, chatID, msgID, user, t.Format("2006-01-02"), loc, false)
h.sendDayView(ctx, chatID, msgID, user, t.Format(DateLayout), loc, false)
}
// getEventByID fetches a single event by ID, scoped to the user.