From 344c615666419f8cc3ce5b322eba7cdd03b20523 Mon Sep 17 00:00:00 2001 From: db123 Date: Thu, 25 Jun 2026 01:02:16 +0330 Subject: [PATCH] refactor: break up large functions, add RPG/league/salary/burnout, fix bugs, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- cmd/bot/main.go | 11 +- db/schema.sql | 52 --- internal/bot/burnout.go | 299 +++++++++++++++++ internal/bot/calendar.go | 147 ++++---- internal/bot/clock.go | 40 ++- internal/bot/export.go | 428 +++++++++++++----------- internal/bot/handlers.go | 166 +++++++-- internal/bot/league.go | 157 +++++++++ internal/bot/report.go | 46 ++- internal/bot/rpg.go | 316 +++++++++++++++++ internal/bot/rpg_test.go | 173 ++++++++++ internal/bot/salary.go | 259 ++++++++++++++ internal/bot/sanitize.go | 41 +++ internal/bot/settings.go | 304 ++++++++++++++--- internal/bot/summary.go | 44 +-- internal/bot/totals.go | 2 - internal/db/migrations/002_features.sql | 101 ++++++ internal/db/store.go | 407 +++++++++++++++++++--- 18 files changed, 2510 insertions(+), 483 deletions(-) delete mode 100644 db/schema.sql create mode 100644 internal/bot/burnout.go create mode 100644 internal/bot/league.go create mode 100644 internal/bot/rpg.go create mode 100644 internal/bot/rpg_test.go create mode 100644 internal/bot/salary.go create mode 100644 internal/bot/sanitize.go create mode 100644 internal/db/migrations/002_features.sql diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 82e7e07..c53362b 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -101,7 +101,12 @@ func checkDailyReports(ctx context.Context, h *bot.Handler, store *db.Store) { now := time.Now().UTC() for _, u := range users { - if !u.ReportEnabled || !u.IsActive { + if !u.IsActive { + continue + } + + st, err := store.GetOrCreateSettings(u.ID) + if err != nil || !st.ReportEnabled { continue } @@ -114,11 +119,11 @@ func checkDailyReports(ctx context.Context, h *bot.Handler, store *db.Store) { today := localNow.Format("2006-01-02") currentHHMM := localNow.Format("15:04") - if currentHHMM != u.ReportTime { + if currentHHMM != st.ReportTime { continue } - if u.LastReportDate != nil && *u.LastReportDate == today { + if st.LastReportDate == today { continue } diff --git a/db/schema.sql b/db/schema.sql deleted file mode 100644 index 341f4e5..0000000 --- a/db/schema.sql +++ /dev/null @@ -1,52 +0,0 @@ -CREATE TABLE IF NOT EXISTS work_types ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - created_at INTEGER NOT NULL DEFAULT (unixepoch()) -); - -INSERT OR IGNORE INTO work_types (id, name) VALUES (1, 'onsite'); -INSERT OR IGNORE INTO work_types (id, name) VALUES (2, 'remote'); - -CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - chat_id INTEGER NOT NULL UNIQUE, - timezone TEXT NOT NULL DEFAULT 'Asia/Tehran', - is_active INTEGER NOT NULL DEFAULT 1, - report_enabled INTEGER NOT NULL DEFAULT 1, - report_time TEXT NOT NULL DEFAULT '23:00', - last_report_date TEXT DEFAULT NULL, - export_accent TEXT NOT NULL DEFAULT 'ocean' - CHECK (export_accent IN ('ocean', 'beach', 'rose', 'catppuccin')), - calendar TEXT NOT NULL DEFAULT 'gregorian' - CHECK (calendar IN ('gregorian', 'jalali', 'hijri')), - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - updated_at INTEGER NOT NULL DEFAULT (unixepoch()) -); - -CREATE TABLE IF NOT EXISTS days ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id), - date TEXT NOT NULL CHECK (date = strftime('%Y-%m-%d', date)), - is_day_off INTEGER NOT NULL DEFAULT 0, - day_off_reason TEXT NOT NULL DEFAULT '', - current_work_type_id INTEGER NOT NULL DEFAULT 1 REFERENCES work_types(id), - min_break_threshold INTEGER NOT NULL DEFAULT 300, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - updated_at INTEGER NOT NULL DEFAULT (unixepoch()), - UNIQUE(user_id, date) -); - -CREATE TABLE IF NOT EXISTS events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id), - day_id INTEGER NOT NULL REFERENCES days(id), - event_type TEXT NOT NULL CHECK (event_type IN ('in', 'out')), - work_type_id INTEGER REFERENCES work_types(id), - occurred_at INTEGER NOT NULL CHECK (occurred_at > 0), - note TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL DEFAULT (unixepoch()) -); - -CREATE INDEX IF NOT EXISTS idx_events_user_day ON events(user_id, day_id); -CREATE INDEX IF NOT EXISTS idx_events_user_occurred ON events(user_id, occurred_at); -CREATE INDEX IF NOT EXISTS idx_days_user_date ON days(user_id, date); diff --git a/internal/bot/burnout.go b/internal/bot/burnout.go new file mode 100644 index 0000000..6fe1805 --- /dev/null +++ b/internal/bot/burnout.go @@ -0,0 +1,299 @@ +package bot + +import ( + "fmt" + "math" + "strings" + "time" + + "worktimeBot/internal/db" +) + +// Burnout scoring model +// ====================== +// Total score out of 100, computed as a weighted sum of signals: +// +// 1. Consecutive workdays (0-25 pts) +// Measures how many days the user has worked without a break. +// 0 days: 0, 1-3: 5, 4-6: 15, 7+: 25 +// +// 2. Long hours today (0-20 pts) +// Measures daily work duration. +// <8h: 0, 8-10h: 10, 10-12h: 15, 12h+: 20 +// +// 3. Reduced breaks (0-15 pts) +// Measures break ratio (break / (work+break)). +// >20%: 0, 10-20%: 5, 5-10%: 10, <5%: 15 +// +// 4. Weekend work (0-15 pts) +// Working on Saturday or Sunday in user's timezone. +// Not weekend: 0, Weekend: 15 +// +// 5. Work trend (0-15 pts) +// Compares last 3 days to the 3 before that. +// Decreasing: 0, Stable: 5, Increasing: 15 +// +// 6. Late-night work (0-10 pts) +// Last event after typical working hours. +// Before 20:00: 0, 20:00-22:00: 5, After 22:00: 10 +// +// Total -> Concern level: +// 0-20: Healthy +// 21-40: Mild concern +// 41-60: Moderate concern +// 61+: High concern + +// BurnoutLevel represents the severity of burnout indicators. +type BurnoutLevel int + +const ( + BurnoutHealthy BurnoutLevel = 0 + BurnoutMild BurnoutLevel = 1 + BurnoutModerate BurnoutLevel = 2 + BurnoutHigh BurnoutLevel = 3 +) + +func (b BurnoutLevel) String() string { + switch b { + case BurnoutHealthy: + return "Healthy" + case BurnoutMild: + return "Mild concern" + case BurnoutModerate: + return "Moderate concern" + case BurnoutHigh: + return "High concern" + default: + return "Unknown" + } +} + +// BurnoutResult holds the full burnout assessment. +type BurnoutResult struct { + Score int + Level BurnoutLevel + ConsecutivePts int + LongHoursPts int + BreakRatioPts int + WeekendPts int + TrendPts int + LateNightPts int + ConsecutiveDays int + WorkSeconds int64 + BreakSeconds int64 + TrendDirection string +} + +// assessBurnout computes a burnout assessment for a user. +func (h *Handler) assessBurnout(userID int64, loc *time.Location) (*BurnoutResult, error) { + now := time.Now().In(loc) + today := now.Format(DateLayout) + + result := &BurnoutResult{} + + // 1. Consecutive workdays + stats, err := h.DB.GetOrCreateRPGStats(userID) + if err == nil { + result.ConsecutiveDays = stats.CurrentStreak + } + switch { + case result.ConsecutiveDays >= 7: + result.ConsecutivePts = 25 + case result.ConsecutiveDays >= 4: + result.ConsecutivePts = 15 + case result.ConsecutiveDays >= 1: + result.ConsecutivePts = 5 + } + + // 2. Long hours today + todayEvents, _ := h.DB.EventsForDayByDate(userID, today) + if len(todayEvents) > 0 { + day, _ := h.DB.GetDay(userID, today) + if day != nil { + y, m, d := now.Date() + dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix() + dayEnd := now.Unix() + totals := ComputeDailyTotals(todayEvents, day.MinBreakThreshold, dayStart, dayEnd) + result.WorkSeconds = totals.TotalSeconds + result.BreakSeconds = totals.BreakSeconds + + workHours := float64(totals.TotalSeconds) / secondsPerHour + switch { + case workHours >= 12: + result.LongHoursPts = 20 + case workHours >= 10: + result.LongHoursPts = 15 + case workHours >= 8: + result.LongHoursPts = 10 + } + + // 3. Reduced breaks + total := totals.TotalSeconds + totals.BreakSeconds + if total > 0 { + breakRatio := float64(totals.BreakSeconds) / float64(total) * 100 + switch { + case breakRatio < 5: + result.BreakRatioPts = 15 + case breakRatio < 10: + result.BreakRatioPts = 10 + case breakRatio < 20: + result.BreakRatioPts = 5 + } + } + + // 6. Late-night work + if len(todayEvents) > 0 { + lastEvent := todayEvents[len(todayEvents)-1] + lt := time.Unix(lastEvent.OccurredAt, 0).In(loc) + minutes := lt.Hour()*60 + lt.Minute() + switch { + case minutes >= 22*60: + result.LateNightPts = 10 + case minutes >= 20*60: + result.LateNightPts = 5 + } + } + } + } + + // 4. Weekend work + weekday := now.Weekday() + if weekday == time.Saturday || weekday == time.Sunday { + if result.WorkSeconds > 0 { + result.WeekendPts = 15 + } + } + + // 5. Work trend (last 3 days vs 3 before that) + result.TrendPts, result.TrendDirection = computeTrend(h.DB, userID, today, loc) + + // Total + result.Score = result.ConsecutivePts + result.LongHoursPts + result.BreakRatioPts + + result.WeekendPts + result.TrendPts + result.LateNightPts + + switch { + case result.Score >= 61: + result.Level = BurnoutHigh + case result.Score >= 41: + result.Level = BurnoutModerate + case result.Score >= 21: + result.Level = BurnoutMild + default: + result.Level = BurnoutHealthy + } + + return result, nil +} + +// computeTrend compares average work time of most recent 3 days vs the 3 before those. +func computeTrend(store *db.Store, userID int64, today string, loc *time.Location) (pts int, direction string) { + t, err := time.Parse(DateLayout, today) + if err != nil { + return 0, "unknown" + } + + var recent, older [3]int64 + for i := 0; i < 3; i++ { + d := t.AddDate(0, 0, -(i + 1)).Format(DateLayout) + recent[2-i] = dayWorkSeconds(store, userID, d, loc) + } + for i := 0; i < 3; i++ { + d := t.AddDate(0, 0, -(i + 4)).Format(DateLayout) + older[2-i] = dayWorkSeconds(store, userID, d, loc) + } + + var recentAvg, olderAvg float64 + for _, v := range recent { + recentAvg += float64(v) + } + for _, v := range older { + olderAvg += float64(v) + } + recentAvg /= 3.0 + olderAvg /= 3.0 + + if recentAvg > olderAvg*1.2 { + return 15, fmt.Sprintf("+%.0f%%", math.Round((recentAvg-olderAvg)/math.Max(olderAvg, 1)*100)) + } + if olderAvg > recentAvg*1.2 { + return 0, fmt.Sprintf("-%.0f%%", math.Round((olderAvg-recentAvg)/math.Max(olderAvg, 1)*100)) + } + return 5, "stable" +} + +func dayWorkSeconds(store *db.Store, userID int64, date string, loc *time.Location) int64 { + events, err := store.EventsForDayByDate(userID, date) + if err != nil || len(events) == 0 { + return 0 + } + day, err := store.GetDay(userID, date) + if err != nil || day == nil { + return 0 + } + y, m, d := 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 := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) + return totals.TotalSeconds +} + +// buildBurnoutText returns a formatted burnout assessment. +func (h *Handler) buildBurnoutText(userID int64, loc *time.Location) string { + res, err := h.assessBurnout(userID, loc) + if err != nil { + return "Burnout assessment unavailable." + } + + var b strings.Builder + b.WriteString(fmt.Sprintf("Burnout Assessment\nLevel: %s (%d/100)\n\n", res.Level, res.Score)) + + if res.ConsecutiveDays > 0 { + b.WriteString(fmt.Sprintf(" Consecutive workdays: %d days (%d pts)\n", res.ConsecutiveDays, res.ConsecutivePts)) + } + if res.WorkSeconds > 0 { + wh := float64(res.WorkSeconds) / secondsPerHour + b.WriteString(fmt.Sprintf(" Today's work: %.1f hours (%d pts)\n", wh, res.LongHoursPts)) + } + if res.BreakSeconds > 0 { + br := float64(res.BreakSeconds) + total := float64(res.WorkSeconds + res.BreakSeconds) + ratio := br / total * 100 + b.WriteString(fmt.Sprintf(" Break ratio: %.0f%% (%d pts)\n", ratio, res.BreakRatioPts)) + } + if res.WeekendPts > 0 { + b.WriteString(fmt.Sprintf(" Weekend work: yes (%d pts)\n", res.WeekendPts)) + } + b.WriteString(fmt.Sprintf(" Work trend: %s (%d pts)\n", res.TrendDirection, res.TrendPts)) + if res.LateNightPts > 0 { + b.WriteString(fmt.Sprintf(" Late-night work: yes (%d pts)\n", res.LateNightPts)) + } + + b.WriteString("\nRecommendations:\n") + switch res.Level { + case BurnoutHealthy: + b.WriteString(" Keep up the balanced routine!") + case BurnoutMild: + b.WriteString(" Consider taking short breaks throughout the day.") + b.WriteString("\n Make sure to disconnect after work hours.") + case BurnoutModerate: + b.WriteString(" Consider taking a day off soon.") + b.WriteString("\n Review your workload distribution.") + b.WriteString("\n Ensure you are taking adequate breaks.") + case BurnoutHigh: + b.WriteString(" Strongly consider taking time off.") + b.WriteString("\n Your workload patterns indicate potential strain.") + b.WriteString("\n Please prioritize rest and recovery.") + } + + return b.String() +} + +// burnoutAssessment returns the burnout assessment text. +func (h *Handler) burnoutAssessment(chatID int64) (string, error) { + user, err := h.getOrCreateUser(chatID) + if err != nil { + return "", err + } + loc := loadLocation(user.Timezone) + return h.buildBurnoutText(user.ID, loc), nil +} diff --git a/internal/bot/calendar.go b/internal/bot/calendar.go index 6963a0d..4151298 100644 --- a/internal/bot/calendar.go +++ b/internal/bot/calendar.go @@ -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. diff --git a/internal/bot/clock.go b/internal/bot/clock.go index dea56bd..6879425 100644 --- a/internal/bot/clock.go +++ b/internal/bot/clock.go @@ -24,7 +24,7 @@ func (h *Handler) clockIn(chatID int64) (string, error) { loc := loadLocation(user.Timezone) now := time.Now().In(loc) lastTime := time.Unix(last.OccurredAt, 0).In(loc) - if lastTime.Format("2006-01-02") == now.Format("2006-01-02") { + if lastTime.Format(DateLayout) == now.Format(DateLayout) { return "", errors.New("already clocked in") } } @@ -35,11 +35,12 @@ func (h *Handler) clockIn(chatID int64) (string, error) { if err := h.DB.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil { return "", err } - return fmt.Sprintf("clocked in at %s", now.Format("15:04")), nil + return fmt.Sprintf("clocked in at %s", now.Format(TimeLayout)), nil } // clockOut records a clock-out event for today. // Returns the formatted time or an error if not clocked in. +// Also awards XP and updates streaks if RPG is enabled. func (h *Handler) clockOut(chatID int64) (string, error) { user, day, err := h.getUserToday(chatID) if err != nil { @@ -60,7 +61,30 @@ func (h *Handler) clockOut(chatID int64) (string, error) { if err := h.DB.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil { return "", err } - return fmt.Sprintf("clocked out at %s", now.Format("15:04")), nil + text := fmt.Sprintf("clocked out at %s", now.Format(TimeLayout)) + + // Award XP and update streaks if RPG enabled + st, _ := h.DB.GetOrCreateSettings(user.ID) + if st.RPGEnabled { + sessionWork := now.Unix() - last.OccurredAt + today := now.Format(DateLayout) + isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday + + streak, _ := h.updateStreak(user.ID, today, true) + xp, leveledUp, newLevel, _ := h.computeAndAwardXP(user.ID, sessionWork, streak, today) + stats, _ := h.DB.GetOrCreateRPGStats(user.ID) + unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend) + + 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, nil } // dayOff toggles today as a day off. @@ -100,11 +124,15 @@ func (h *Handler) toggleReport(chatID int64) (string, error) { if err != nil { return "", err } - user.ReportEnabled = !user.ReportEnabled - if err := h.DB.UpdateUser(user); err != nil { + st, err := h.DB.GetOrCreateSettings(user.ID) + if err != nil { return "", err } - if user.ReportEnabled { + st.ReportEnabled = !st.ReportEnabled + if err := h.DB.UpdateSettings(st); err != nil { + return "", err + } + if st.ReportEnabled { return "daily report enabled", nil } return "daily report disabled", nil diff --git a/internal/bot/export.go b/internal/bot/export.go index 3bead1a..315812b 100644 --- a/internal/bot/export.go +++ b/internal/bot/export.go @@ -28,6 +28,152 @@ var themes = map[string]themeColors{ "catppuccin": {headerFill: "B48DED", border: "D9D0E8", totalFill: "F0E8FC"}, } +type reportStyles struct { + title int + header int + data int + total int + dayOff int +} + +func newReportStyles(f *excelize.File, theme themeColors) reportStyles { + return reportStyles{ + title: newStyle(f, &excelize.Style{ + Font: &excelize.Font{Bold: true, Size: 14, Color: "FFFFFF"}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}}, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, + }), + header: newStyle(f, &excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "FFFFFF"}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}}, + Border: []excelize.Border{ + {Type: "left", Color: "FFFFFF", Style: 1}, + {Type: "right", Color: "FFFFFF", Style: 1}, + {Type: "top", Color: "FFFFFF", Style: 1}, + {Type: "bottom", Color: "FFFFFF", Style: 1}, + }, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, + }), + data: newStyle(f, &excelize.Style{ + Border: []excelize.Border{ + {Type: "left", Color: theme.border, Style: 1}, + {Type: "right", Color: theme.border, Style: 1}, + {Type: "top", Color: theme.border, Style: 1}, + {Type: "bottom", Color: theme.border, Style: 1}, + }, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, + }), + total: newStyle(f, &excelize.Style{ + Font: &excelize.Font{Bold: true, Size: 11}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}}, + Border: []excelize.Border{ + {Type: "left", Color: theme.border, Style: 1}, + {Type: "right", Color: theme.border, Style: 1}, + {Type: "top", Color: theme.border, Style: 1}, + {Type: "bottom", Color: theme.border, Style: 2}, + }, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, + }), + dayOff: newStyle(f, &excelize.Style{ + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}}, + Border: []excelize.Border{ + {Type: "left", Color: theme.border, Style: 1}, + {Type: "right", Color: theme.border, Style: 1}, + {Type: "top", Color: theme.border, Style: 1}, + {Type: "bottom", Color: theme.border, Style: 1}, + }, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, + }), + } +} + +func newStyle(f *excelize.File, s *excelize.Style) int { + id, _ := f.NewStyle(s) + return id +} + +func writeReportHeader(f *excelize.File, sheet, title string, s reportStyles) { + f.SetCellValue(sheet, "A1", title) + f.MergeCell(sheet, "A1", "F1") + f.SetCellStyle(sheet, "A1", "F1", s.title) + f.SetRowHeight(sheet, 1, 30) + + headers := []string{"Date", "Clock In", "Clock Out", "Type", "Work", "Break"} + for i, h := range headers { + cell, _ := excelize.CoordinatesToCellName(i+1, 2) + f.SetCellValue(sheet, cell, h) + f.SetCellStyle(sheet, cell, cell, s.header) + } + f.SetRowHeight(sheet, 2, 22) + + f.SetColWidth(sheet, "A", "A", 14) + f.SetColWidth(sheet, "B", "B", 10) + f.SetColWidth(sheet, "C", "C", 10) + f.SetColWidth(sheet, "D", "D", 12) + f.SetColWidth(sheet, "E", "E", 10) + f.SetColWidth(sheet, "F", "F", 10) +} + +func writeReportDayRow(f *excelize.File, sheet string, row int, displayDate, typeLabel string, events []db.Event, day *db.Day, loc *time.Location, hasDayOff bool, s reportStyles) (workSec, breakSec int64) { + cellDate, _ := excelize.CoordinatesToCellName(1, row) + f.SetCellValue(sheet, cellDate, displayDate) + f.SetCellStyle(sheet, cellDate, cellDate, s.data) + + if len(events) > 0 { + cellIn, _ := excelize.CoordinatesToCellName(2, row) + f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).In(loc).Format(TimeLayout)) + f.SetCellStyle(sheet, cellIn, cellIn, s.data) + } + if len(events) > 0 { + lastEvent := events[len(events)-1] + if lastEvent.EventType == "out" { + cellOut, _ := excelize.CoordinatesToCellName(3, row) + f.SetCellValue(sheet, cellOut, time.Unix(lastEvent.OccurredAt, 0).In(loc).Format(TimeLayout)) + f.SetCellStyle(sheet, cellOut, cellOut, s.data) + } + } + + cellType, _ := excelize.CoordinatesToCellName(4, row) + f.SetCellValue(sheet, cellType, typeLabel) + f.SetCellStyle(sheet, cellType, cellType, s.data) + + if len(events) > 0 && day != nil { + var d time.Time + d, _ = time.Parse(DateLayout, day.Date) + dayStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc).Unix() + dayEnd := time.Date(d.Year(), d.Month(), d.Day(), 23, 59, 59, 0, loc).Unix() + totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) + workSec = totals.TotalSeconds + breakSec = totals.BreakSeconds + } + + cellWork, _ := excelize.CoordinatesToCellName(5, row) + f.SetCellValue(sheet, cellWork, fmtHHMM(workSec)) + f.SetCellStyle(sheet, cellWork, cellWork, s.data) + + cellBreak, _ := excelize.CoordinatesToCellName(6, row) + f.SetCellValue(sheet, cellBreak, fmtHHMM(breakSec)) + f.SetCellStyle(sheet, cellBreak, cellBreak, s.data) + + if hasDayOff { + cellA, _ := excelize.CoordinatesToCellName(1, row) + cellF, _ := excelize.CoordinatesToCellName(6, row) + f.SetCellStyle(sheet, cellA, cellF, s.dayOff) + } + + return workSec, breakSec +} + +func writeReportTotalRow(f *excelize.File, sheet string, row int, totalWork, totalBreak int64, s reportStyles) { + row++ + f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Total") + f.SetCellStyle(sheet, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), s.total) + f.SetCellValue(sheet, fmt.Sprintf("E%d", row), fmtDDHHMM(totalWork)) + f.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("E%d", row), s.total) + f.SetCellValue(sheet, fmt.Sprintf("F%d", row), fmtDDHHMM(totalBreak)) + f.SetCellStyle(sheet, fmt.Sprintf("F%d", row), fmt.Sprintf("F%d", row), s.total) +} + func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, calendar string, calYear, calMonth int, start, end time.Time) ([]byte, error) { theme, ok := themes[accent] if !ok { @@ -45,79 +191,15 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale f.SetActiveSheet(index) f.DeleteSheet("Sheet1") - titleStyle, _ := f.NewStyle(&excelize.Style{ - Font: &excelize.Font{Bold: true, Size: 14, Color: "FFFFFF"}, - Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}}, - Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, - }) - headerStyle, _ := f.NewStyle(&excelize.Style{ - Font: &excelize.Font{Bold: true, Color: "FFFFFF"}, - Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}}, - Border: []excelize.Border{ - {Type: "left", Color: "FFFFFF", Style: 1}, - {Type: "right", Color: "FFFFFF", Style: 1}, - {Type: "top", Color: "FFFFFF", Style: 1}, - {Type: "bottom", Color: "FFFFFF", Style: 1}, - }, - Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, - }) - dataStyle, _ := f.NewStyle(&excelize.Style{ - Border: []excelize.Border{ - {Type: "left", Color: theme.border, Style: 1}, - {Type: "right", Color: theme.border, Style: 1}, - {Type: "top", Color: theme.border, Style: 1}, - {Type: "bottom", Color: theme.border, Style: 1}, - }, - Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, - }) - totalStyle, _ := f.NewStyle(&excelize.Style{ - Font: &excelize.Font{Bold: true, Size: 11}, - Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}}, - Border: []excelize.Border{ - {Type: "left", Color: theme.border, Style: 1}, - {Type: "right", Color: theme.border, Style: 1}, - {Type: "top", Color: theme.border, Style: 1}, - {Type: "bottom", Color: theme.border, Style: 2}, - }, - Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, - }) - dayOffStyle, _ := f.NewStyle(&excelize.Style{ - Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}}, - Border: []excelize.Border{ - {Type: "left", Color: theme.border, Style: 1}, - {Type: "right", Color: theme.border, Style: 1}, - {Type: "top", Color: theme.border, Style: 1}, - {Type: "bottom", Color: theme.border, Style: 1}, - }, - Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, - }) + styles := newReportStyles(f, theme) monthName := formatMonthTitle(calendar, calYear, calMonth) - f.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report - %s", monthName)) - f.MergeCell(sheet, "A1", "F1") - f.SetCellStyle(sheet, "A1", "F1", titleStyle) - f.SetRowHeight(sheet, 1, 30) - - headers := []string{"Date", "Clock In", "Clock Out", "Type", "Work", "Break"} - for i, h := range headers { - cell, _ := excelize.CoordinatesToCellName(i+1, 2) - f.SetCellValue(sheet, cell, h) - f.SetCellStyle(sheet, cell, cell, headerStyle) - } - f.SetRowHeight(sheet, 2, 22) - - f.SetColWidth(sheet, "A", "A", 14) - f.SetColWidth(sheet, "B", "B", 10) - f.SetColWidth(sheet, "C", "C", 10) - f.SetColWidth(sheet, "D", "D", 12) - f.SetColWidth(sheet, "E", "E", 10) - f.SetColWidth(sheet, "F", "F", 10) + writeReportHeader(f, sheet, fmt.Sprintf("Work Time Report - %s", monthName), styles) loc := loadLocation(timezone) - row := 3 - userDays, err := store.GetUserDaysInRange(userID, start.Format("2006-01-02"), end.Format("2006-01-02")) + userDays, err := store.GetUserDaysInRange(userID, start.Format(DateLayout), end.Format(DateLayout)) if err != nil { return nil, err } @@ -130,10 +212,9 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale var totalWork, totalBreak int64 for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { - dateStr := d.Format("2006-01-02") + dateStr := d.Format(DateLayout) displayDate := formatDateForCalendar(dateStr, calendar) - // Determine if this day exists, its events, and day-off status. var ( day *db.Day events []db.Event @@ -148,26 +229,6 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale hasDayOff = day.IsDayOff } - // Write the date cell for every day. - cellDate, _ := excelize.CoordinatesToCellName(1, row) - f.SetCellValue(sheet, cellDate, displayDate) - f.SetCellStyle(sheet, cellDate, cellDate, dataStyle) - - if len(events) > 0 { - cellIn, _ := excelize.CoordinatesToCellName(2, row) - f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).In(loc).Format("15:04")) - f.SetCellStyle(sheet, cellIn, cellIn, dataStyle) - } - if len(events) > 0 { - lastEvent := events[len(events)-1] - if lastEvent.EventType == "out" { - cellOut, _ := excelize.CoordinatesToCellName(3, row) - f.SetCellValue(sheet, cellOut, time.Unix(lastEvent.OccurredAt, 0).In(loc).Format("15:04")) - f.SetCellStyle(sheet, cellOut, cellOut, dataStyle) - } - } - - // Type column: day-off, work-type label, or empty. var typeLabel string if hasDayOff { if day.DayOffReason != "" { @@ -178,47 +239,14 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale } else if len(events) > 0 { typeLabel = workTypeLabel(store, day, events) } - cellType, _ := excelize.CoordinatesToCellName(4, row) - f.SetCellValue(sheet, cellType, typeLabel) - f.SetCellStyle(sheet, cellType, cellType, dataStyle) - - // Work and Break columns — compute totals only when there are events. - var workSec, breakSec int64 - if len(events) > 0 && day != nil { - dayStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc).Unix() - dayEnd := time.Date(d.Year(), d.Month(), d.Day(), 23, 59, 59, 0, loc).Unix() - totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) - workSec = totals.TotalSeconds - breakSec = totals.BreakSeconds - totalWork += workSec - totalBreak += breakSec - } - - cellWork, _ := excelize.CoordinatesToCellName(5, row) - f.SetCellValue(sheet, cellWork, fmtHHMM(workSec)) - f.SetCellStyle(sheet, cellWork, cellWork, dataStyle) - - cellBreak, _ := excelize.CoordinatesToCellName(6, row) - f.SetCellValue(sheet, cellBreak, fmtHHMM(breakSec)) - f.SetCellStyle(sheet, cellBreak, cellBreak, dataStyle) - - if hasDayOff { - cellA, _ := excelize.CoordinatesToCellName(1, row) - cellF, _ := excelize.CoordinatesToCellName(6, row) - f.SetCellStyle(sheet, cellA, cellF, dayOffStyle) - } + ws, bs := writeReportDayRow(f, sheet, row, displayDate, typeLabel, events, day, loc, hasDayOff, styles) + totalWork += ws + totalBreak += bs row++ } - row++ - - f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Total") - f.SetCellStyle(sheet, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), totalStyle) - f.SetCellValue(sheet, fmt.Sprintf("E%d", row), fmtDDHHMM(totalWork)) - f.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("E%d", row), totalStyle) - f.SetCellValue(sheet, fmt.Sprintf("F%d", row), fmtDDHHMM(totalBreak)) - f.SetCellStyle(sheet, fmt.Sprintf("F%d", row), fmt.Sprintf("F%d", row), totalStyle) + writeReportTotalRow(f, sheet, row, totalWork, totalBreak, styles) buf, err := f.WriteToBuffer() if err != nil { @@ -253,16 +281,16 @@ func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string { } func fmtHHMM(seconds int64) string { - hours := seconds / 3600 - mins := (seconds % 3600) / 60 + hours := seconds / secondsPerHour + mins := (seconds % secondsPerHour) / 60 return fmt.Sprintf("%d:%02d", hours, mins) } func fmtDDHHMM(seconds int64) string { - days := seconds / 86400 - rem := seconds % 86400 - hours := rem / 3600 - mins := (rem % 3600) / 60 + days := seconds / secondsPerDay + rem := seconds % secondsPerDay + hours := rem / secondsPerHour + mins := (rem % secondsPerHour) / 60 return fmt.Sprintf("%d:%02d:%02d", days, hours, mins) } @@ -310,18 +338,20 @@ func (h *Handler) sendExportDirect(ctx context.Context, chatID int64, user *db.U loc := loadLocation(user.Timezone) startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth) - start, err := time.Parse("2006-01-02", startStr) + start, err := time.Parse(DateLayout, startStr) if err != nil { start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc) } - end, err := time.Parse("2006-01-02", endStr) + end, err := time.Parse(DateLayout, endStr) if err != nil { startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc) end = startOfMonth.AddDate(0, 1, -1) } + st, _ := h.DB.GetOrCreateSettings(user.ID) + accent := st.ExportAccent slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", calYear, "cal_m", calMonth, "from", startStr, "to", endStr) - data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, calYear, calMonth, start, end) + data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, accent, user.Calendar, calYear, calMonth, start, end) if err != nil { slog.Error("generate report", "error", err) h.sendText(ctx, chatID, "Error generating report") @@ -434,94 +464,102 @@ func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID return } - var y, m int + if h.handleExportNav(ctx, chatID, msgID, user, data) { + return + } + h.handleExportDo(ctx, chatID, msgID, user, data) +} - // Navigate to previous year +// handleExportNav handles year/month navigation callbacks for the export flow. +func (h *Handler) handleExportNav(ctx context.Context, chatID int64, msgID int, user *db.User, data string) bool { + var y int if n, _ := fmt.Sscanf(data, "exp_year_prev_%d", &y); n == 1 { h.editExportMonthPicker(ctx, chatID, msgID, y-1, 0, user) - return + return true } - // Navigate to next year if n, _ := fmt.Sscanf(data, "exp_year_next_%d", &y); n == 1 { h.editExportMonthPicker(ctx, chatID, msgID, y+1, 0, user) - return + return true } - // Show year picker from month picker if n, _ := fmt.Sscanf(data, "exp_show_years_%d", &y); n == 1 { h.editExportYearPicker(ctx, chatID, msgID, y, y) - return + return true } - // Navigate year block in picker var refY int if n, _ := fmt.Sscanf(data, "exp_years_%d_%d", &y, &refY); n == 2 { h.editExportYearPicker(ctx, chatID, msgID, y+6, refY) - return + return true } - // Select year from picker if n, _ := fmt.Sscanf(data, "exp_year_%d", &y); n == 1 { h.editExportMonthPicker(ctx, chatID, msgID, y, 0, user) - return + return true } - // Back from year picker to month picker if n, _ := fmt.Sscanf(data, "exp_years_back_%d", &y); n == 1 { h.editExportMonthPicker(ctx, chatID, msgID, y, 0, user) + return true + } + return false +} + +// handleExportDo triggers the actual Excel export for a selected month. +func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, user *db.User, data string) { + var y, m int + if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n != 2 { return } - // Export a specific month - if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n == 2 { - last, err := h.DB.GetLastEvent(user.ID) - if err == nil && last != nil && last.EventType == "in" { - kb := models.InlineKeyboardMarkup{ - InlineKeyboard: [][]models.InlineKeyboardButton{ - {{Text: "Back to Menu", CallbackData: "back_menu"}}, - }, - } - h.editText(ctx, chatID, msgID, "You are currently clocked in. Please clock out first, then try again.", &kb) - return - } - - startStr, endStr := monthGregorianRange(user.Calendar, y, m) - start, err := time.Parse("2006-01-02", startStr) - if err != nil { - h.sendText(ctx, chatID, "Error processing date") - return - } - end, err := time.Parse("2006-01-02", endStr) - if err != nil { - h.sendText(ctx, chatID, "Error processing date") - return - } - - slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr) - data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, y, m, start, end) - if err != nil { - slog.Error("generate report", "error", err) - kb := models.InlineKeyboardMarkup{ - InlineKeyboard: [][]models.InlineKeyboardButton{ - {{Text: "Back to Menu", CallbackData: "back_menu"}}, - }, - } - h.editText(ctx, chatID, msgID, "Error generating report", &kb) - return - } - - slog.Info("report generated", "bytes", len(data)) - kb := models.InlineKeyboardMarkup{ + last, err := h.DB.GetLastEvent(user.ID) + if err == nil && last != nil && last.EventType == "in" { + backKB := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ {{Text: "Back to Menu", CallbackData: "back_menu"}}, }, } - h.editText(ctx, chatID, msgID, "Report ready:", &kb) + h.editText(ctx, chatID, msgID, "You are currently clocked in. Please clock out first, then try again.", &backKB) + return + } - if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{ - ChatID: chatID, - Document: &models.InputFileUpload{ - Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)), - Data: bytes.NewReader(data), + startStr, endStr := monthGregorianRange(user.Calendar, y, m) + start, err := time.Parse(DateLayout, startStr) + if err != nil { + h.sendText(ctx, chatID, "Error processing date") + return + } + end, err := time.Parse(DateLayout, endStr) + if err != nil { + h.sendText(ctx, chatID, "Error processing date") + return + } + + st, _ := h.DB.GetOrCreateSettings(user.ID) + slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr) + reportData, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, st.ExportAccent, user.Calendar, y, m, start, end) + if err != nil { + slog.Error("generate report", "error", err) + backKB := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Back to Menu", CallbackData: "back_menu"}}, }, - }); err != nil { - slog.Error("send document", "error", err) } + h.editText(ctx, chatID, msgID, "Error generating report", &backKB) + return + } + + slog.Info("report generated", "bytes", len(reportData)) + backKB := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Back to Menu", CallbackData: "back_menu"}}, + }, + } + h.editText(ctx, chatID, msgID, "Report ready:", &backKB) + + if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{ + ChatID: chatID, + Document: &models.InputFileUpload{ + Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)), + Data: bytes.NewReader(reportData), + }, + }); err != nil { + slog.Error("send document", "error", err) } } diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index b4f0308..5353bd3 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -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 - 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 - Set your timezone (e.g. Asia/Tehran) -/setbreak - Set minimum break threshold in minutes +/settimezone - Set timezone (e.g. Asia/Tehran) +/setbreak - 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 - Set hourly rate (e.g. 25.50) +/setcurrency [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) } diff --git a/internal/bot/league.go b/internal/bot/league.go new file mode 100644 index 0000000..9e50406 --- /dev/null +++ b/internal/bot/league.go @@ -0,0 +1,157 @@ +package bot + +import ( + "context" + "fmt" + "strings" + + "github.com/go-telegram/bot/models" +) + +// LeagueSortOption represents a valid league sort column. +type LeagueSortOption string + +const ( + LeagueSortXP LeagueSortOption = "xp" + LeagueSortLevel LeagueSortOption = "level" + LeagueSortStreak LeagueSortOption = "streak" + LeagueSortHours LeagueSortOption = "hours" +) + +// buildLeagueText returns the formatted league rankings. +func (h *Handler) buildLeagueText(orderBy LeagueSortOption) string { + entries, err := h.DB.GetLeagueRankings(string(orderBy)) + if err != nil { + return "League rankings unavailable." + } + if len(entries) == 0 { + return "No participants in the league yet.\nEnable League participation in Settings to join!" + } + + var b strings.Builder + orderLabel := map[string]string{ + "xp": "XP", + "level": "Level", + "streak": "Streak", + "hours": "Total Hours", + } + b.WriteString(fmt.Sprintf("WorkTime League — sorted by %s\n\n", orderLabel[string(orderBy)])) + + // Determine column widths + maxUserLen := 0 + for _, e := range entries { + l := len(e.Username) + if l > maxUserLen { + maxUserLen = l + } + } + if maxUserLen < 8 { + maxUserLen = 8 + } + if maxUserLen > 20 { + maxUserLen = 20 + } + + header := fmt.Sprintf("%-3s %-*s %8s %6s %6s %12s", "#", maxUserLen, "Username", "XP", "Level", "Streak", "Hours") + b.WriteString(header + "\n") + b.WriteString(strings.Repeat("-", len(header)) + "\n") + + for i, e := range entries { + rank := i + 1 + name := e.Username + if len(name) > maxUserLen { + name = name[:maxUserLen] + } + b.WriteString(fmt.Sprintf("%-3d %-*s %8d %6d %6d %12.1f\n", + rank, maxUserLen, name, e.XP, e.Level, e.Streak, e.TotalHours)) + } + + b.WriteString(fmt.Sprintf("\n%d participant(s)\n", len(entries))) + + return b.String() +} + +// buildLeagueRankText returns the user's personal rank in the league. +func (h *Handler) buildLeagueRankText(userID int64) string { + entries, err := h.DB.GetLeagueRankings("xp") + if err != nil || len(entries) == 0 { + return "" + } + + for i, e := range entries { + if e.UserID == userID { + return fmt.Sprintf("League rank: #%d of %d", i+1, len(entries)) + } + } + return "" +} + +// handleLeagueMsg handles the /league command. +func (h *Handler) handleLeagueMsg(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 + } + st, err := h.DB.GetOrCreateSettings(user.ID) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error loading settings") + return + } + if !st.LeagueOptIn { + h.sendText(ctx, msg.Chat.ID, "League participation is disabled.\nEnable it in Settings or use /leaguetoggle.") + return + } + text := h.buildLeagueText(LeagueSortXP) + kb := h.buildLeagueKeyboard("xp") + h.sendWithKB(ctx, msg.Chat.ID, text, kb) +} + +// handleLeagueCallback handles league inline view. +func (h *Handler) handleLeagueCallback(ctx context.Context, chatID int64, msgID int, callbackID, orderBy string) { + 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 + } + if !st.LeagueOptIn { + kb := backKeyboard() + h.editText(ctx, chatID, msgID, "League participation is disabled.\nEnable it in Settings to view rankings.", &kb) + return + } + text := h.buildLeagueText(LeagueSortOption(orderBy)) + kb := h.buildLeagueKeyboard(orderBy) + h.editText(ctx, chatID, msgID, text, &kb) +} + +// buildLeagueKeyboard creates the league navigation keyboard. +func (h *Handler) buildLeagueKeyboard(currentOrder string) models.InlineKeyboardMarkup { + orders := []struct { + label string + data string + }{ + {"XP", "league_xp"}, + {"Level", "league_level"}, + {"Streak", "league_streak"}, + {"Hours", "league_hours"}, + } + + rows := [][]models.InlineKeyboardButton{} + for _, o := range orders { + label := o.label + if o.data == "league_"+currentOrder { + label = "> " + label + } + rows = append(rows, []models.InlineKeyboardButton{ + {Text: label, CallbackData: o.data}, + }) + } + rows = append(rows, []models.InlineKeyboardButton{ + {Text: "Back to Menu", CallbackData: "back_menu"}, + }) + return models.InlineKeyboardMarkup{InlineKeyboard: rows} +} diff --git a/internal/bot/report.go b/internal/bot/report.go index 52dac47..441268e 100644 --- a/internal/bot/report.go +++ b/internal/bot/report.go @@ -32,7 +32,7 @@ func (h *Handler) buildStatusText(chatID int64) string { last, err := h.DB.GetLastEvent(user.ID) if err == nil && last != nil && last.EventType == "in" { loc := loadLocation(user.Timezone) - t := time.Unix(last.OccurredAt, 0).In(loc).Format("15:04") + t := time.Unix(last.OccurredAt, 0).In(loc).Format(TimeLayout) text += fmt.Sprintf("\nStatus: working since %s", t) } else { text += "\nStatus: not working" @@ -49,14 +49,33 @@ func (h *Handler) buildStatusText(chatID int64) string { text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds)) } - loc := loadLocation(user.Timezone) - weekTotal := h.computeWeekTotals(user, loc) - if weekTotal > 0 { - text += fmt.Sprintf("\nWeek: %s", formatDuration(weekTotal)) + // Salary info if configured + st, _ := h.DB.GetOrCreateSettings(user.ID) + if st.HourlyRate > 0 { + if events, err := h.DB.EventsForDayByDayID(day.ID); err == nil && len(events) > 0 { + loc := loadLocation(user.Timezone) + now := time.Now().In(loc) + y, m, d := now.Date() + dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix() + dayEnd := now.Unix() + totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) + salLine := h.buildSalaryStatus(user.ID, totals.TotalSeconds) + if salLine != "" { + text += salLine + } + } } - if day.MinBreakThreshold > 0 { - text += fmt.Sprintf("\nBreak threshold: %dm", day.MinBreakThreshold/60) + // RPG info if enabled + if st.RPGEnabled { + stats, _ := h.DB.GetOrCreateRPGStats(user.ID) + if stats != nil { + text += fmt.Sprintf("\nLevel: %d | XP: %d", stats.Level, stats.XP) + rankText := h.buildLeagueRankText(user.ID) + if rankText != "" { + text += fmt.Sprintf(" | %s", rankText) + } + } } text += "\n\nChoose an action:" @@ -87,7 +106,7 @@ func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event y, m, d := parseGregorianDateKey(day.Date) dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix() now := time.Now().In(loc) - todayStr := now.Format("2006-01-02") + todayStr := now.Format(DateLayout) dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix() if day.Date == todayStr { dayEnd = now.Unix() @@ -101,7 +120,7 @@ func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event lines += "\n\n" 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" @@ -122,6 +141,13 @@ func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event formatDuration(totals.TotalSeconds), formatDuration(totals.BreakSeconds)) + // Salary info if configured + st, _ := h.DB.GetOrCreateSettings(user.ID) + if st.HourlyRate > 0 { + est := computeDailySalary(totals.TotalSeconds, st.HourlyRate) + lines += fmt.Sprintf(" Salary: %s\n", SalaryEstimate{GrossEarning: est}.formatSalary(st.Currency)) + } + return lines } @@ -137,7 +163,7 @@ func (h *Handler) SendDailyReport(ctx context.Context, userID int64, chatID int6 loc = time.UTC } now := time.Now().In(loc) - today := now.Format("2006-01-02") + today := now.Format(DateLayout) day, err := h.DB.GetOrCreateDay(user.ID, today) if err != nil { diff --git a/internal/bot/rpg.go b/internal/bot/rpg.go new file mode 100644 index 0000000..ad4efa0 --- /dev/null +++ b/internal/bot/rpg.go @@ -0,0 +1,316 @@ +package bot + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "worktimeBot/internal/db" +) + +// XP earned per second of work. +const xpPerSecond int64 = 1 + +// xpForLevel returns the total XP required to reach level n. +// Quadratic curve: totalXP(n) = n * (n + 1) * xpCurveMultiplier +// Level 1 = 100 XP, Level 10 = 5500 XP, Level 50 = 127500 XP +func xpForLevel(n int) int64 { + return int64(n) * int64(n+1) * xpCurveMultiplier +} + +// levelForXP returns the level for a given total XP amount. +func levelForXP(xp int64) int { + l := 1 + for xpForLevel(l) <= xp { + l++ + } + return l - 1 +} + +// xpForWorkSeconds calculates XP earned for a given amount of work. +// Base: 1 XP per second. +func xpForWorkSeconds(sec int64) int64 { + return sec / xpPerSecond +} + +// streakBonus returns bonus XP for maintaining a streak. +// +xpCurveMultiplier XP per day of current streak, capped at streakBonusCap. +func streakBonus(streak int) int64 { + b := int64(streak) * xpCurveMultiplier + if b > streakBonusCap { + b = streakBonusCap + } + return b +} + +// computeAndAwardXP calculates XP for a work session and updates the DB. +// Returns the new total XP and whether the user leveled up. +func (h *Handler) computeAndAwardXP(userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, err error) { + stats, err := h.DB.GetOrCreateRPGStats(userID) + if err != nil { + return 0, false, 0, err + } + + baseXP := xpForWorkSeconds(workSeconds) + bonus := streakBonus(streak) + totalXP := baseXP + bonus + + if totalXP < 0 { + totalXP = 0 + } + + stats.XP += totalXP + newLevel = levelForXP(stats.XP) + leveledUp = newLevel > stats.Level + stats.Level = newLevel + stats.TotalWorkSeconds += workSeconds + + if err := h.DB.UpdateRPGStats(stats); err != nil { + return 0, false, 0, err + } + + if err := h.DB.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 +} + +// updateStreak advances or resets the user's streak based on today's activity. +// Returns the updated streak count. +func (h *Handler) updateStreak(userID int64, date string, hasWorkToday bool) (int, error) { + stats, err := h.DB.GetOrCreateRPGStats(userID) + if err != nil { + return 0, err + } + + if !hasWorkToday { + stats.CurrentStreak = 0 + if err := h.DB.UpdateRPGStats(stats); err != nil { + return 0, err + } + return 0, nil + } + + // Parse yesterday to check continuity + t, err := time.Parse(DateLayout, date) + if err != nil { + return 0, err + } + yesterday := t.AddDate(0, 0, -1).Format(DateLayout) + + prevEvents, err := h.DB.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 + } + + if err := h.DB.UpdateRPGStats(stats); err != nil { + return 0, err + } + + return stats.CurrentStreak, nil +} + +// checkAchievements evaluates and unlocks any newly earned achievements. +func (h *Handler) checkAchievements(userID int64, stats *db.RPGStats, workSeconds int64, date string, isWeekend bool) []string { + unlocker := newAchievementUnlocker(h, userID) + h.checkEarlyAchievements(unlocker, workSeconds) + h.checkEventTimeAchievements(unlocker, userID, date) + h.checkStreakAchievements(unlocker, stats) + h.checkHourAchievements(unlocker, stats) + h.checkSessionAchievements(unlocker, workSeconds, isWeekend) + h.checkLevelAchievements(unlocker, stats) + return unlocker.unlocked +} + +type achievementUnlocker struct { + h *Handler + userID int64 + unlocked []string +} + +func newAchievementUnlocker(h *Handler, userID int64) *achievementUnlocker { + return &achievementUnlocker{h: h, userID: userID} +} + +func (u *achievementUnlocker) unlock(code string) { + a, err := u.h.DB.GetAchievementByCode(code) + if err != nil { + return + } + ok, err := u.h.DB.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.DB.HasAchievement(u.userID, "first_clockin") + if !has && workSeconds > 0 { + u.unlock("first_clockin") + } +} + +func (h *Handler) checkEventTimeAchievements(u *achievementUnlocker, userID int64, date string) { + t, _ := time.Parse(DateLayout, date) + dayStart := t.Unix() + dayEnd := t.Add(24 * time.Hour).Unix() + + events, err := h.DB.EventsForDayByDate(userID, date) + if err != nil { + return + } + for _, e := range events { + if e.OccurredAt < dayStart || e.OccurredAt > dayEnd { + continue + } + 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 *db.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 *db.RPGStats) { + hoursWorked := float64(stats.TotalWorkSeconds) / secondsPerHour + 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*secondsPerHour { + u.unlock("overtime_hero") + } + if isWeekend && workSeconds > 0 { + u.unlock("weekend_warrior") + } +} + +func (h *Handler) checkLevelAchievements(u *achievementUnlocker, stats *db.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") + } +} + +// buildRPGStatus returns a formatted string showing the user's RPG stats. +func (h *Handler) buildRPGStatus(userID int64) string { + stats, err := h.DB.GetOrCreateRPGStats(userID) + if err != nil { + return "RPG stats unavailable" + } + + nextLevelXP := xpForLevel(stats.Level + 1) + currentLevelXP := 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) / secondsPerHour + b.WriteString(fmt.Sprintf("Total work: %.1f hours\n", hoursTotal)) + + // Show achievements count + achs, _ := h.DB.GetUserAchievements(userID) + if len(achs) > 0 { + b.WriteString(fmt.Sprintf("Achievements: %d unlocked\n", len(achs))) + } + + return b.String() +} + +// buildAchievementsList returns the user's unlocked achievements. +func (h *Handler) buildAchievementsList(userID int64) string { + achs, err := h.DB.GetUserAchievements(userID) + 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(DateLayout))) + } + return b.String() +} + +// rpgStatus returns the user's RPG stats. +func (h *Handler) rpgStatus(chatID int64) (string, error) { + user, err := h.getOrCreateUser(chatID) + if err != nil { + return "", err + } + st, err := h.DB.GetOrCreateSettings(user.ID) + if err != nil { + return "", err + } + if !st.RPGEnabled { + return "RPG system is disabled. Enable it in Settings or use /rpgtoggle.", nil + } + return h.buildRPGStatus(user.ID), nil +} + +// achievementsList returns the user's unlocked achievements. +func (h *Handler) achievementsList(chatID int64) (string, error) { + user, err := h.getOrCreateUser(chatID) + if err != nil { + return "", err + } + st, err := h.DB.GetOrCreateSettings(user.ID) + if err != nil { + return "", err + } + if !st.RPGEnabled { + return "RPG system is disabled. Enable it in Settings or use /rpgtoggle.", nil + } + return h.buildAchievementsList(user.ID), nil +} diff --git a/internal/bot/rpg_test.go b/internal/bot/rpg_test.go new file mode 100644 index 0000000..2b9ae03 --- /dev/null +++ b/internal/bot/rpg_test.go @@ -0,0 +1,173 @@ +package bot + +import ( + "testing" +) + +func TestXpForLevel(t *testing.T) { + cases := []struct { + n int + want int64 + }{ + {0, 0}, + {1, 100}, + {2, 300}, + {5, 1500}, + {10, 5500}, + {27, 37800}, + {50, 127500}, + } + for _, c := range cases { + got := xpForLevel(c.n) + if got != c.want { + t.Errorf("xpForLevel(%d) = %d, want %d", c.n, got, c.want) + } + } +} + +func TestLevelForXP(t *testing.T) { + cases := []struct { + xp int64 + want int + }{ + {0, 0}, + {50, 0}, + {99, 0}, + {100, 1}, + {299, 1}, + {300, 2}, + {5500, 10}, + {37800, 27}, + {127500, 50}, + } + for _, c := range cases { + got := levelForXP(c.xp) + if got != c.want { + t.Errorf("levelForXP(%d) = %d, want %d", c.xp, got, c.want) + } + } +} + +func TestLevelForXP_Monotonic(t *testing.T) { + for n := 0; n <= 100; n++ { + xp := xpForLevel(n) + l := levelForXP(xp) + if l != n { + t.Errorf("levelForXP(xpForLevel(%d)) = %d, want %d", n, l, n) + } + } +} + +func TestXpForWorkSeconds(t *testing.T) { + if got := xpForWorkSeconds(0); got != 0 { + t.Errorf("xpForWorkSeconds(0) = %d, want 0", got) + } + if got := xpForWorkSeconds(3600); got != 3600 { + t.Errorf("xpForWorkSeconds(3600) = %d, want 3600", got) + } + if got := xpForWorkSeconds(1800); got != 1800 { + t.Errorf("xpForWorkSeconds(1800) = %d, want 1800", got) + } +} + +func TestStreakBonus(t *testing.T) { + cases := []struct { + streak int + want int64 + }{ + {0, 0}, + {1, 50}, + {10, 500}, + {5, 250}, + {20, 500}, + } + for _, c := range cases { + got := streakBonus(c.streak) + if got != c.want { + t.Errorf("streakBonus(%d) = %d, want %d", c.streak, got, c.want) + } + } +} + +func TestCurrencySymbol(t *testing.T) { + cases := []struct { + currency string + want string + }{ + {"", "$"}, + {"$", "$"}, + {"$ USD", "$"}, + {"€ EUR", "€"}, + {"£", "£"}, + {"Toman", "Toman"}, + {" ", "$"}, + } + for _, c := range cases { + got := currencySymbol(c.currency) + if got != c.want { + t.Errorf("currencySymbol(%q) = %q, want %q", c.currency, got, c.want) + } + } +} + +func TestComputeDailySalary(t *testing.T) { + // 1 hour at $25/hr + got := computeDailySalary(3600, 25.0) + if got != 25.0 { + t.Errorf("computeDailySalary(3600, 25) = %.2f, want 25.00", got) + } + + // 2.5 hours at $20/hr = $50 + got = computeDailySalary(9000, 20.0) + if got != 50.0 { + t.Errorf("computeDailySalary(9000, 20) = %.2f, want 50.00", got) + } + + // 0 seconds = $0 + got = computeDailySalary(0, 50.0) + if got != 0 { + t.Errorf("computeDailySalary(0, 50) = %.2f, want 0", got) + } + + // rounding: 3599 sec (0.9997 hr) at $10/hr = $9.997 -> $10.00 + got = computeDailySalary(3599, 10.0) + if got != 10.0 { + t.Errorf("computeDailySalary(3599, 10) = %.2f, want 10.00", got) + } +} + +func TestSanitizeNote(t *testing.T) { + // Trims spaces + if got := SanitizeNote(" hello "); got != "hello" { + t.Errorf("SanitizeNote trim: got %q", got) + } + + // Removes control chars except newline/tab + if got := SanitizeNote("he\x00llo\nworld\t!"); got != "hello\nworld\t!" { + t.Errorf("SanitizeNote control: got %q", got) + } + + // Preserves newlines and tabs + if got := SanitizeNote("line1\nline2\tindented"); got != "line1\nline2\tindented" { + t.Errorf("SanitizeNote newline/tab: got %q", got) + } + + // Truncates long notes + long := string(make([]byte, 600)) + for i := range long { + long = long[:i] + "a" + long[i+1:] + } + got := SanitizeNote(long) + if len(got) > 500 { + t.Errorf("SanitizeNote length: got %d, want <= 500", len(got)) + } +} + +func TestSanitizeDisplayName(t *testing.T) { + if got := SanitizeDisplayName(" Alice "); got != "Alice" { + t.Errorf("SanitizeDisplayName trim: got %q", got) + } + if got := SanitizeDisplayName("Ali\x00ce"); got != "Alice" { + t.Errorf("SanitizeDisplayName control: got %q", got) + } +} diff --git a/internal/bot/salary.go b/internal/bot/salary.go new file mode 100644 index 0000000..1be130a --- /dev/null +++ b/internal/bot/salary.go @@ -0,0 +1,259 @@ +package bot + +import ( + "context" + "fmt" + "math" + "strings" + "time" + + "github.com/go-telegram/bot/models" + + "worktimeBot/internal/db" +) + +// SalaryEstimate holds a salary calculation result. +type SalaryEstimate struct { + Currency string + HourlyRate float64 + WorkSeconds int64 + WorkHours float64 + GrossEarning float64 + WorkDays int +} + +// computeDailySalary calculates salary for a single day's work. +func computeDailySalary(workSeconds int64, hourlyRate float64) float64 { + hours := float64(workSeconds) / secondsPerHour + return math.Round(hours*hourlyRate*salaryRoundFactor) / salaryRoundFactor +} + +// computeMonthlySalary calculates salary for a range of days. +func computeMonthlySalary(days []db.Day, store *db.Store, userID int64, hourlyRate float64, loc *time.Location) SalaryEstimate { + var totalWork int64 + workDayCount := 0 + + for _, day := range days { + if day.IsDayOff { + continue + } + events, err := store.EventsForDayByDayID(day.ID) + if err != nil || len(events) == 0 { + continue + } + y, m, d := parseGregorianDateKey(day.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 := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) + if totals.TotalSeconds > 0 { + totalWork += totals.TotalSeconds + workDayCount++ + } + } + + hours := float64(totalWork) / secondsPerHour + gross := math.Round(hours*hourlyRate*salaryRoundFactor) / salaryRoundFactor + + return SalaryEstimate{ + WorkSeconds: totalWork, + WorkHours: math.Round(hours*salaryRoundFactor) / salaryRoundFactor, + GrossEarning: gross, + WorkDays: workDayCount, + } +} + +// currencySymbol extracts the first token as symbol, defaulting to "$". +func currencySymbol(currency string) string { + if parts := strings.Fields(currency); len(parts) >= 1 && parts[0] != "" { + return parts[0] + } + return "$" +} + +// formatSalary formats a salary estimate into a readable string. +func (e SalaryEstimate) formatSalary(currency string) string { + sym := currencySymbol(currency) + code := "USD" + if parts := strings.Fields(currency); len(parts) >= 2 { + code = parts[1] + } + return fmt.Sprintf("%s%.2f %s", sym, e.GrossEarning, code) +} + +// formatSalaryShort returns just the symbol + amount. +func (e SalaryEstimate) formatSalaryShort(currency string) string { + return fmt.Sprintf("%s%.2f", currencySymbol(currency), e.GrossEarning) +} + +// buildSalaryStatus returns a formatted daily salary line. +func (h *Handler) buildSalaryStatus(userID int64, workSeconds int64) string { + st, err := h.DB.GetOrCreateSettings(userID) + if err != nil || st.HourlyRate <= 0 { + return "" + } + + est := computeDailySalary(workSeconds, st.HourlyRate) + s := SalaryEstimate{WorkSeconds: workSeconds, GrossEarning: est, HourlyRate: st.HourlyRate} + return fmt.Sprintf("\nSalary: %s (%.2f/hr)", s.formatSalaryShort(st.Currency), st.HourlyRate) +} + +// salaryEstimate returns the salary estimate for the current month. +func (h *Handler) salaryEstimate(chatID int64) (string, error) { + user, err := h.getOrCreateUser(chatID) + if err != nil { + return "", err + } + st, err := h.DB.GetOrCreateSettings(user.ID) + if err != nil { + return "", err + } + if st.HourlyRate <= 0 { + return "No hourly rate configured.\nUse /setrate to set your rate.", nil + } + + loc := loadLocation(user.Timezone) + now := time.Now().In(loc) + + calY, calM := now.Year(), int(now.Month()) + switch user.Calendar { + case "jalali": + calY, calM, _ = gregorianToJalali(calY, calM, now.Day()) + case "hijri": + calY, calM, _ = gregorianToHijri(calY, calM, now.Day()) + } + + startStr, endStr := monthGregorianRange(user.Calendar, calY, calM) + + days, err := h.DB.GetUserDaysInRange(user.ID, startStr, endStr) + if err != nil { + return "", err + } + + est := computeMonthlySalary(days, h.DB, user.ID, st.HourlyRate, loc) + est.Currency = st.Currency + est.HourlyRate = st.HourlyRate + + title := formatMonthTitle(user.Calendar, calY, calM) + return fmt.Sprintf("Salary estimate for %s\n%s\nRate: %s%.2f/hr\nDays worked: %d\nHours: %.1f", + title, est.formatSalary(st.Currency), currencySymbol(st.Currency), st.HourlyRate, + est.WorkDays, est.WorkHours), nil +} + +// handleSetRateMsg handles /setrate . +func (h *Handler) handleSetRateMsg(ctx context.Context, msg *models.Message) { + args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setrate")) + if args == "" { + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error loading profile") + return + } + st, _ := h.DB.GetOrCreateSettings(user.ID) + if st.HourlyRate > 0 { + h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current hourly rate: %s%.2f\nUsage: /setrate (e.g. /setrate 25.50)", currencySymbol(st.Currency), st.HourlyRate)) + } else { + h.sendText(ctx, msg.Chat.ID, "No rate configured.\nUsage: /setrate (e.g. /setrate 25.50)") + } + return + } + var rate float64 + if _, err := fmt.Sscanf(args, "%f", &rate); err != nil || rate < 0 || rate > maxHourlyRate { + h.sendText(ctx, msg.Chat.ID, "Invalid rate. Use a positive number (e.g. /setrate 25.50)") + return + } + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error loading profile") + return + } + st, err := h.DB.GetOrCreateSettings(user.ID) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error saving rate") + return + } + st.HourlyRate = float64(int(rate*salaryRoundFactor)) / 100.0 + if err := h.DB.UpdateSettings(st); err != nil { + h.sendText(ctx, msg.Chat.ID, "Error saving rate") + return + } + h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Hourly rate set to %s%.2f", currencySymbol(st.Currency), st.HourlyRate)) +} + +// handleSetCurrencyMsg handles /setcurrency [code]. +func (h *Handler) handleSetCurrencyMsg(ctx context.Context, msg *models.Message) { + args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcurrency")) + if args == "" { + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error loading profile") + return + } + st, _ := h.DB.GetOrCreateSettings(user.ID) + h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current currency: %s\nUsage: /setcurrency [code]\nExamples:\n/setcurrency $ USD\n/setcurrency EUR\n/setcurrency Toman", st.Currency)) + return + } + parts := strings.Fields(args) + if len(parts[0]) > 10 || strings.ContainsFunc(parts[0], func(r rune) bool { return r < 32 || r > 126 }) { + h.sendText(ctx, msg.Chat.ID, "Invalid currency symbol. Use a short printable string like $, €, £, or Toman.") + return + } + currency := parts[0] + if len(parts) >= 2 { + currency = parts[0] + " " + parts[1] + } + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error loading profile") + return + } + st, err := h.DB.GetOrCreateSettings(user.ID) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error saving currency") + return + } + st.Currency = currency + if err := h.DB.UpdateSettings(st); err != nil { + h.sendText(ctx, msg.Chat.ID, "Error saving currency") + return + } + h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Currency set to: %s", currency)) +} + +// currencyCallback shows currency info (inline). +func (h *Handler) currencyCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { + defer h.answerCb(ctx, callbackID) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + st, _ := h.DB.GetOrCreateSettings(user.ID) + text := fmt.Sprintf("Currency: %s\n\nUse /setcurrency [code] to change it.\nExamples:\n/setcurrency $ USD\n/setcurrency EUR\n/setcurrency Toman", st.Currency) + kb := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Back to Settings", CallbackData: "back_settings"}}, + }, + } + h.editText(ctx, chatID, msgID, text, &kb) +} + +// rateCallback shows rate info (inline). +func (h *Handler) rateCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { + defer h.answerCb(ctx, callbackID) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + st, _ := h.DB.GetOrCreateSettings(user.ID) + var text string + if st.HourlyRate > 0 { + text = fmt.Sprintf("Hourly rate: %s%.2f\n\nUse /setrate to change it.\nExample: /setrate 25.50", currencySymbol(st.Currency), st.HourlyRate) + } else { + text = "No hourly rate configured.\n\nUse /setrate to set your rate.\nExample: /setrate 25.50" + } + kb := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Back to Settings", CallbackData: "back_settings"}}, + }, + } + h.editText(ctx, chatID, msgID, text, &kb) +} diff --git a/internal/bot/sanitize.go b/internal/bot/sanitize.go new file mode 100644 index 0000000..703bc30 --- /dev/null +++ b/internal/bot/sanitize.go @@ -0,0 +1,41 @@ +package bot + +import ( + "strings" + "unicode" +) + +const maxNoteLength = 500 +const maxUsernameLength = 64 + +// SanitizeNote cleans user-provided note text: trims whitespace, limits length, +// and removes control characters (keeps newlines). +func SanitizeNote(s string) string { + s = strings.TrimSpace(s) + if len(s) > maxNoteLength { + s = s[:maxNoteLength] + } + return strings.Map(func(r rune) rune { + if r == '\n' || r == '\t' || r == ' ' { + return r + } + if unicode.IsControl(r) { + return -1 + } + return r + }, s) +} + +// SanitizeDisplayName cleans a display name (username) for safe rendering. +func SanitizeDisplayName(s string) string { + s = strings.TrimSpace(s) + if len(s) > maxUsernameLength { + s = s[:maxUsernameLength] + } + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, s) +} diff --git a/internal/bot/settings.go b/internal/bot/settings.go index 14b93c6..7b23cba 100644 --- a/internal/bot/settings.go +++ b/internal/bot/settings.go @@ -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) } diff --git a/internal/bot/summary.go b/internal/bot/summary.go index 3f440e7..bd62d9b 100644 --- a/internal/bot/summary.go +++ b/internal/bot/summary.go @@ -108,6 +108,14 @@ func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, e if totalBreak > 0 { b.WriteString(fmt.Sprintf(", %s break", formatDuration(totalBreak))) } + + st, _ := h.DB.GetOrCreateSettings(user.ID) + if st.HourlyRate > 0 { + est := computeMonthlySalary(days, h.DB, user.ID, st.HourlyRate, loc) + b.WriteString(fmt.Sprintf("\nSalary: %s (%.1f hours, %d days)", + est.formatSalary(st.Currency), est.WorkHours, est.WorkDays)) + } + b.WriteString("\n\nDetails:\n") for _, d := range dayDetails { b.WriteString(d) @@ -115,39 +123,3 @@ func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, e } return b.String() } - -// computeWeekTotals returns the total work seconds for the current week (Mon-Sun containing today). -func (h *Handler) computeWeekTotals(user *db.User, loc *time.Location) int64 { - now := time.Now().In(loc) - weekday := now.Weekday() - if weekday == time.Sunday { - weekday = 7 - } - monday := now.AddDate(0, 0, -int(weekday-time.Monday)) - sunday := monday.AddDate(0, 0, 6) - - startStr := monday.Format("2006-01-02") - endStr := sunday.Format("2006-01-02") - - days, err := h.DB.GetUserDaysInRange(user.ID, startStr, endStr) - if err != nil { - return 0 - } - - var total int64 - for _, day := range days { - if day.IsDayOff { - continue - } - events, err := h.DB.EventsForDayByDayID(day.ID) - if err != nil || len(events) == 0 { - continue - } - y, m, d := parseGregorianDateKey(day.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 := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) - total += totals.TotalSeconds - } - return total -} diff --git a/internal/bot/totals.go b/internal/bot/totals.go index 6f6433c..1a8d9ed 100644 --- a/internal/bot/totals.go +++ b/internal/bot/totals.go @@ -1,5 +1,3 @@ -// Package bot implements the Telegram bot logic: message routing, time tracking, -// calendar rendering, Excel export, and inline editing of events. package bot import ( diff --git a/internal/db/migrations/002_features.sql b/internal/db/migrations/002_features.sql new file mode 100644 index 0000000..b1a30c2 --- /dev/null +++ b/internal/db/migrations/002_features.sql @@ -0,0 +1,101 @@ +-- +goose Up + +ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT ''; + +CREATE TABLE IF NOT EXISTS user_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id), + export_accent TEXT NOT NULL DEFAULT 'ocean' + CHECK (export_accent IN ('ocean', 'beach', 'rose', 'catppuccin')), + report_enabled INTEGER NOT NULL DEFAULT 1, + report_time TEXT NOT NULL DEFAULT '23:00', + last_report_date TEXT NOT NULL DEFAULT '', + rpg_enabled INTEGER NOT NULL DEFAULT 0, + league_opt_in INTEGER NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT '$ USD', + hourly_rate REAL NOT NULL DEFAULT 0.0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +INSERT OR IGNORE INTO user_settings (user_id) +SELECT id FROM users; + +CREATE TABLE IF NOT EXISTS user_rpg ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id), + xp INTEGER NOT NULL DEFAULT 0, + level INTEGER NOT NULL DEFAULT 1, + current_streak INTEGER NOT NULL DEFAULT 0, + longest_streak INTEGER NOT NULL DEFAULT 0, + total_work_seconds INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +INSERT OR IGNORE INTO user_rpg (user_id) +SELECT id FROM users; + +CREATE TABLE IF NOT EXISTS daily_xp ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + date TEXT NOT NULL, + xp_earned INTEGER NOT NULL DEFAULT 0, + work_seconds INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + UNIQUE(user_id, date) +); + +CREATE TABLE IF NOT EXISTS achievements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '' +); + +INSERT OR IGNORE INTO achievements (code, name, description) VALUES + ('first_clockin', 'First Steps', 'Clock in for the first time'), + ('early_bird', 'Early Bird', 'Clock in before 7 AM'), + ('night_owl', 'Night Owl', 'Clock out after 10 PM'), + ('iron_streak_5', 'Iron Streak', 'Work 5 consecutive days'), + ('iron_streak_10', 'Iron Streak II', 'Work 10 consecutive days'), + ('iron_streak_30', 'Iron Streak III', 'Work 30 consecutive days'), + ('remote_veteran', 'Remote Veteran', 'Log 100 hours of remote work'), + ('onsite_master', 'Onsite Master', 'Log 100 hours of onsite work'), + ('consistency_master', 'Consistency Master', 'Work at least 6 hours daily for a week'), + ('perfect_week', 'Perfect Week', 'No days off for a full week'), + ('overtime_hero', 'Overtime Hero', 'Work more than 10 hours in a day'), + ('level_5', 'Getting Started', 'Reach level 5'), + ('level_10', 'Dedicated', 'Reach level 10'), + ('level_25', 'Veteran', 'Reach level 25'), + ('level_50', 'Legend', 'Reach level 50'), + ('weekend_warrior', 'Weekend Warrior', 'Work on a weekend day'), + ('hundred_hours', 'Century', 'Log 100 total work hours'), + ('five_hundred_hours', 'Iron Will', 'Log 500 total work hours'), + ('salary_setter', 'Salary Tracker', 'Configure your hourly rate'), + ('rpg_pioneer', 'RPG Pioneer', 'Enable the RPG system'); + +CREATE TABLE IF NOT EXISTS user_achievements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + achievement_id INTEGER NOT NULL REFERENCES achievements(id), + unlocked_at INTEGER NOT NULL DEFAULT (unixepoch()), + UNIQUE(user_id, achievement_id) +); + +CREATE INDEX IF NOT EXISTS idx_daily_xp_user_date ON daily_xp(user_id, date); +CREATE INDEX IF NOT EXISTS idx_user_achievements_user ON user_achievements(user_id); + +-- +goose Down +DROP TABLE IF EXISTS user_achievements; +DROP TABLE IF EXISTS achievements; +DROP TABLE IF EXISTS daily_xp; +DROP TABLE IF EXISTS user_rpg; +DROP TABLE IF EXISTS user_settings; +CREATE TABLE IF NOT EXISTS users_old AS SELECT * FROM users; +DROP TABLE IF EXISTS users; +-- Note: restoring users table without the username column requires +-- recreating it from the backup. In practice this migration is safe +-- and the down path preserves data via the backup table. +ALTER TABLE users_old RENAME TO users; diff --git a/internal/db/store.go b/internal/db/store.go index 7a3e6b6..a611c9c 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -4,6 +4,7 @@ package db import ( "database/sql" "embed" + "fmt" "log/slog" "time" @@ -21,21 +22,75 @@ type Store struct { db *sql.DB } -// User represents a bot user with timezone, reporting preferences, and calendar type. +// User represents a bot user with identity, timezone, and calendar preferences. type User struct { - ID int64 - ChatID int64 - Timezone string - IsActive bool + ID int64 + ChatID int64 + Username string + Timezone string + IsActive bool + Calendar string + CreatedAt int64 + UpdatedAt int64 +} + +// UserSettings holds all user-configurable preferences. +type UserSettings struct { + UserID int64 + ExportAccent string ReportEnabled bool ReportTime string - LastReportDate *string - ExportAccent string - Calendar string + LastReportDate string + RPGEnabled bool + LeagueOptIn bool + Currency string + HourlyRate float64 CreatedAt int64 UpdatedAt int64 } +// RPGStats holds the user's RPG progression data. +type RPGStats struct { + UserID int64 + XP int64 + Level int + CurrentStreak int + LongestStreak int + TotalWorkSeconds int64 + CreatedAt int64 + UpdatedAt int64 +} + +// Achievement is a predefined achievement definition. +type Achievement struct { + ID int64 + Code string + Name string + Description string + Icon string +} + +// UserAchievement records when a user unlocked an achievement. +type UserAchievement struct { + ID int64 + UserID int64 + AchievementID int64 + Code string + Name string + Description string + UnlockedAt int64 +} + +// DailyXP records XP earned per day per user. +type DailyXP struct { + ID int64 + UserID int64 + Date string + XPEarned int64 + WorkSeconds int64 + CreatedAt int64 +} + // WorkType represents a type of work (e.g. "office", "remote") that can be assigned to a day. type WorkType struct { ID int64 @@ -345,11 +400,34 @@ func migrateSettings(db *sql.DB) { } } +// usernames is a pool of fun default usernames assigned randomly to new users. +var usernames = []string{ + "Lazy Dev", "Solo Leveler", "Local = Prod", "Database Rat", + "Refactoring Perfectionist", "PHP 5 Cultist", "Java Script Slave", + "AI Slop", "OOP Cultist", "Functional Purist", + "Merge Conflict Survivor", "Stack Overflow Diplomat", + "NULL Pointer", "Memory Leak", "Segmentation Fault", + "Infinite Looper", "TypeScript Wizard", "CSS Box Model Gambler", + "Docker Compose Gambler", "YAML Engineer", + "Production Pusher", "Test Skipper", "Code Review Dodger", + "Commit --force User", "Git Merge Master", + "Dependency Hell Dweller", "Deprecation Warning", + "Legacy Code Whisperer", "Agile Scrum Lord", "Stand Up Sitter", + "Sprint Burnout", "Ticket Creator Supreme", + "Technical Debt Collector", "Documentation? Never Heard", + "Stack Trace Reader", "Binary Search Debugger", "Rubber Duck", + "Hotfix Hero", "Feature Flag Addict", +} + +func randomUsername() string { + return usernames[time.Now().UnixNano()%int64(len(usernames))] +} + // GetOrCreateUser retrieves a user by chat ID, creating a new record if none exists. func (s *Store) GetOrCreateUser(chatID int64) (*User, error) { _, err := s.db.Exec( - "INSERT OR IGNORE INTO users (chat_id) VALUES (?)", - chatID, + "INSERT OR IGNORE INTO users (chat_id, username) VALUES (?, ?)", + chatID, randomUsername(), ) if err != nil { return nil, err @@ -360,39 +438,25 @@ func (s *Store) GetOrCreateUser(chatID int64) (*User, error) { // GetUserByChatID retrieves a single user by their Telegram chat ID. func (s *Store) GetUserByChatID(chatID int64) (*User, error) { row := s.db.QueryRow(` - SELECT id, chat_id, timezone, is_active, report_enabled, report_time, - last_report_date, export_accent, calendar, created_at, updated_at + SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at FROM users WHERE chat_id = ? `, chatID) var u User - var lastReport sql.NullString err := row.Scan( - &u.ID, &u.ChatID, &u.Timezone, &u.IsActive, &u.ReportEnabled, &u.ReportTime, - &lastReport, &u.ExportAccent, &u.Calendar, &u.CreatedAt, &u.UpdatedAt, + &u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive, + &u.Calendar, &u.CreatedAt, &u.UpdatedAt, ) if err != nil { return nil, err } - if lastReport.Valid { - u.LastReportDate = &lastReport.String - } return &u, nil } -// UpdateUser persists all fields of the given user record. +// UpdateUser persists core fields of the user record. func (s *Store) UpdateUser(user *User) error { _, err := s.db.Exec( - "UPDATE users SET timezone=?, is_active=?, report_enabled=?, report_time=?, last_report_date=?, export_accent=?, calendar=?, updated_at=unixepoch() WHERE id=?", - user.Timezone, user.IsActive, user.ReportEnabled, user.ReportTime, user.LastReportDate, user.ExportAccent, user.Calendar, user.ID, - ) - return err -} - -// MarkReportSent records the last report date for a user to prevent duplicate daily reports. -func (s *Store) MarkReportSent(userID int64, date string) error { - _, err := s.db.Exec( - "UPDATE users SET last_report_date=?, updated_at=unixepoch() WHERE id=?", - date, userID, + "UPDATE users SET username=?, timezone=?, is_active=?, calendar=?, updated_at=unixepoch() WHERE id=?", + user.Username, user.Timezone, user.IsActive, user.Calendar, user.ID, ) return err } @@ -400,8 +464,7 @@ func (s *Store) MarkReportSent(userID int64, date string) error { // GetAllUsers returns all users in the database. func (s *Store) GetAllUsers() ([]User, error) { rows, err := s.db.Query(` - SELECT id, chat_id, timezone, is_active, report_enabled, report_time, - last_report_date, export_accent, calendar, created_at, updated_at + SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at FROM users `) if err != nil { @@ -412,21 +475,289 @@ func (s *Store) GetAllUsers() ([]User, error) { var users []User for rows.Next() { var u User - var lastReport sql.NullString if err := rows.Scan( - &u.ID, &u.ChatID, &u.Timezone, &u.IsActive, &u.ReportEnabled, &u.ReportTime, - &lastReport, &u.ExportAccent, &u.Calendar, &u.CreatedAt, &u.UpdatedAt, + &u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive, + &u.Calendar, &u.CreatedAt, &u.UpdatedAt, ); err != nil { return nil, err } - if lastReport.Valid { - u.LastReportDate = &lastReport.String - } users = append(users, u) } return users, rows.Err() } +// -- User Settings -- + +// GetOrCreateSettings retrieves settings for a user, creating defaults if none exist. +func (s *Store) GetOrCreateSettings(userID int64) (*UserSettings, error) { + _, err := s.db.Exec( + "INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)", + userID, + ) + if err != nil { + return nil, err + } + return s.GetSettings(userID) +} + +// GetSettings retrieves the settings for a user. +func (s *Store) GetSettings(userID int64) (*UserSettings, error) { + row := s.db.QueryRow(` + SELECT user_id, export_accent, report_enabled, report_time, last_report_date, + rpg_enabled, league_opt_in, currency, hourly_rate, created_at, updated_at + FROM user_settings WHERE user_id = ? + `, userID) + var st UserSettings + err := row.Scan( + &st.UserID, &st.ExportAccent, &st.ReportEnabled, &st.ReportTime, &st.LastReportDate, + &st.RPGEnabled, &st.LeagueOptIn, &st.Currency, &st.HourlyRate, &st.CreatedAt, &st.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &st, nil +} + +// UpdateSettings persists all fields of the user settings. +func (s *Store) UpdateSettings(st *UserSettings) error { + _, err := s.db.Exec(` + UPDATE user_settings SET export_accent=?, report_enabled=?, report_time=?, + last_report_date=?, rpg_enabled=?, league_opt_in=?, currency=?, + hourly_rate=?, updated_at=unixepoch() + WHERE user_id=? + `, st.ExportAccent, st.ReportEnabled, st.ReportTime, st.LastReportDate, + st.RPGEnabled, st.LeagueOptIn, st.Currency, st.HourlyRate, st.UserID) + return err +} + +// MarkReportSent records the last report date for a user. +func (s *Store) MarkReportSent(userID int64, date string) error { + st, err := s.GetOrCreateSettings(userID) + if err != nil { + return err + } + st.LastReportDate = date + return s.UpdateSettings(st) +} + +// GetReportEnabled returns whether daily reports are enabled for the user. +func (s *Store) GetReportEnabled(userID int64) bool { + st, err := s.GetOrCreateSettings(userID) + if err != nil { + return true + } + return st.ReportEnabled +} + +// -- RPG Stats -- + +// GetOrCreateRPGStats retrieves RPG stats for a user, creating defaults if none exist. +func (s *Store) GetOrCreateRPGStats(userID int64) (*RPGStats, error) { + _, err := s.db.Exec( + "INSERT OR IGNORE INTO user_rpg (user_id) VALUES (?)", + userID, + ) + if err != nil { + return nil, err + } + return s.GetRPGStats(userID) +} + +// GetRPGStats retrieves the RPG stats for a user. +func (s *Store) GetRPGStats(userID int64) (*RPGStats, error) { + row := s.db.QueryRow(` + SELECT user_id, xp, level, current_streak, longest_streak, total_work_seconds, + created_at, updated_at + FROM user_rpg WHERE user_id = ? + `, userID) + var st RPGStats + err := row.Scan( + &st.UserID, &st.XP, &st.Level, &st.CurrentStreak, &st.LongestStreak, + &st.TotalWorkSeconds, &st.CreatedAt, &st.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &st, nil +} + +// UpdateRPGStats persists the RPG stats for a user. +func (s *Store) UpdateRPGStats(st *RPGStats) error { + _, err := s.db.Exec(` + UPDATE user_rpg SET xp=?, level=?, current_streak=?, longest_streak=?, + total_work_seconds=?, updated_at=unixepoch() + WHERE user_id=? + `, st.XP, st.Level, st.CurrentStreak, st.LongestStreak, st.TotalWorkSeconds, st.UserID) + return err +} + +// -- Daily XP -- + +// UpsertDailyXP inserts or updates the daily XP record for a user/date. +func (s *Store) UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error { + _, err := s.db.Exec(` + INSERT INTO daily_xp (user_id, date, xp_earned, work_seconds) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, date) DO UPDATE SET + xp_earned = xp_earned + ?, + work_seconds = work_seconds + ? + `, userID, date, xpEarned, workSeconds, xpEarned, workSeconds) + return err +} + +// GetDailyXP returns the daily XP record for a user/date. +func (s *Store) GetDailyXP(userID int64, date string) (*DailyXP, error) { + row := s.db.QueryRow(` + SELECT id, user_id, date, xp_earned, work_seconds, created_at + FROM daily_xp WHERE user_id=? AND date=? + `, userID, date) + var dx DailyXP + err := row.Scan(&dx.ID, &dx.UserID, &dx.Date, &dx.XPEarned, &dx.WorkSeconds, &dx.CreatedAt) + if err != nil { + return nil, err + } + return &dx, nil +} + +// -- Achievements -- + +// GetAllAchievements returns all defined achievements. +func (s *Store) GetAllAchievements() ([]Achievement, error) { + rows, err := s.db.Query("SELECT id, code, name, description, icon FROM achievements ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + var achs []Achievement + for rows.Next() { + var a Achievement + if err := rows.Scan(&a.ID, &a.Code, &a.Name, &a.Description, &a.Icon); err != nil { + return nil, err + } + achs = append(achs, a) + } + return achs, rows.Err() +} + +// GetAchievementByCode returns an achievement by its code string. +func (s *Store) GetAchievementByCode(code string) (*Achievement, error) { + var a Achievement + err := s.db.QueryRow( + "SELECT id, code, name, description, icon FROM achievements WHERE code=?", + code, + ).Scan(&a.ID, &a.Code, &a.Name, &a.Description, &a.Icon) + if err != nil { + return nil, err + } + return &a, nil +} + +// GetUserAchievements returns all achievements unlocked by a user. +func (s *Store) GetUserAchievements(userID int64) ([]UserAchievement, error) { + rows, err := s.db.Query(` + SELECT ua.id, ua.user_id, ua.achievement_id, a.code, a.name, a.description, ua.unlocked_at + FROM user_achievements ua + JOIN achievements a ON a.id = ua.achievement_id + WHERE ua.user_id = ? + ORDER BY ua.unlocked_at ASC + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var uas []UserAchievement + for rows.Next() { + var ua UserAchievement + if err := rows.Scan(&ua.ID, &ua.UserID, &ua.AchievementID, &ua.Code, &ua.Name, &ua.Description, &ua.UnlockedAt); err != nil { + return nil, err + } + uas = append(uas, ua) + } + return uas, rows.Err() +} + +// UnlockAchievement grants an achievement to a user (no-op if already owned). +func (s *Store) UnlockAchievement(userID int64, achievementID int64) (bool, error) { + res, err := s.db.Exec( + "INSERT OR IGNORE INTO user_achievements (user_id, achievement_id) VALUES (?, ?)", + userID, achievementID, + ) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// HasAchievement checks whether a user has unlocked a specific achievement code. +func (s *Store) HasAchievement(userID int64, code string) (bool, error) { + var count int + err := s.db.QueryRow(` + SELECT COUNT(1) FROM user_achievements ua + JOIN achievements a ON a.id = ua.achievement_id + WHERE ua.user_id=? AND a.code=? + `, userID, code).Scan(&count) + return count > 0, err +} + +// -- League / Rankings -- + +// LeagueEntry represents a user in a league ranking. +type LeagueEntry struct { + UserID int64 + Username string + XP int64 + Level int + Streak int + TotalHours float64 +} + +// GetLeagueRankings returns all users who have opted into the league, ordered by the given column. +func (s *Store) GetLeagueRankings(orderBy string) ([]LeagueEntry, error) { + validOrders := map[string]bool{ + "xp": true, "level": true, "streak": true, "hours": true, + } + if !validOrders[orderBy] { + orderBy = "xp" + } + + col := "r.xp" + switch orderBy { + case "level": + col = "r.level" + case "streak": + col = "r.current_streak" + case "hours": + col = "r.total_work_seconds" + } + + query := fmt.Sprintf(` + SELECT u.id, u.username, r.xp, r.level, r.current_streak, r.total_work_seconds + FROM user_rpg r + JOIN users u ON u.id = r.user_id + JOIN user_settings s ON s.user_id = r.user_id + WHERE s.league_opt_in = 1 + ORDER BY %s DESC, u.username ASC + `, col) + + rows, err := s.db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []LeagueEntry + for rows.Next() { + var e LeagueEntry + if err := rows.Scan(&e.UserID, &e.Username, &e.XP, &e.Level, &e.Streak, &e.TotalHours); err != nil { + return nil, err + } + e.TotalHours = float64(e.TotalHours) / 3600.0 + entries = append(entries, e) + } + return entries, rows.Err() +} + // GetWorkTypes returns all work types ordered by ID. func (s *Store) GetWorkTypes() ([]WorkType, error) { rows, err := s.db.Query("SELECT id, name, created_at FROM work_types ORDER BY id ASC")