diff --git a/README.md b/README.md index 04b6e1e..abeb359 100644 --- a/README.md +++ b/README.md @@ -147,24 +147,35 @@ Four color themes are available in settings: **Ocean**, **Beach**, **Rose**, **C │ ├── main.go Entrypoint, polling/webhook, scheduler │ └── webhook.go Webhook mode with TLS support ├── internal/ -│ ├── bot/ -│ │ ├── handlers.go Message/callback routing, views, menu builders -│ │ ├── clock.go Clock in/out, day off, report +│ ├── domain/ Pure business logic, no external dependencies +│ │ ├── types.go Core domain types (User, Event, Day, RPGStats, ...) +│ │ ├── constants.go App-wide constants and thresholds +│ │ ├── dateutil.go Gregorian/Jalali/Hijri calendar conversions │ │ ├── totals.go Break/work computation state machine +│ │ ├── rpg.go XP curves, levels, streaks +│ │ ├── burnout.go Burnout trend computation +│ │ ├── salary.go Salary estimation, currency helpers +│ │ └── sanitize.go Input sanitization helpers +│ ├── handler/ Telegram bot handlers (one per feature) +│ │ ├── handler.go Core handler, routing, helpers +│ │ ├── clock.go Clock in/out, day off, report │ │ ├── calendar.go History view and inline event editing │ │ ├── export.go Excel (.xlsx) report generation -│ │ ├── dateutil.go Gregorian/Jalali/Hijri conversions & calendars -│ │ ├── rpg.go XP, levels, streaks, achievements +│ │ ├── rpg.go RPG stats, achievements, aggregates │ │ ├── league.go WorkTime League leaderboard -│ │ ├── salary.go Salary estimation, rate/currency commands -│ │ ├── burnout.go Burnout assessment model +│ │ ├── salary.go Salary estimation view +│ │ ├── burnout.go Burnout assessment view │ │ ├── settings.go Settings menu with inline pickers │ │ ├── summary.go Monthly summary -│ │ ├── report.go Daily report builder -│ │ └── sanitize.go Input sanitization helpers -│ └── db/ -│ ├── store.go SQLite store (data access layer) -│ └── migrations/ Goose-managed SQL migrations +│ │ ├── report.go Daily report builder, status text +│ │ ├── note.go Note command, edit command +│ │ ├── worktype.go Work type selection +│ │ └── keyboard.go Inline keyboard builders +│ └── repo/ Data access layer +│ ├── interface.go Repository interface +│ ├── store.go SQLite implementation +│ └── migrations.go Goose-managed migration runner +│ └── migrations/ SQL migration files │ ├── 001_init.sql Initial schema │ └── 002_features.sql RPG, achievements, user_settings ├── .gitea/workflows/ diff --git a/cmd/bot/main.go b/cmd/bot/main.go index c53362b..ecddcbe 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -14,8 +14,8 @@ import ( "github.com/go-telegram/bot/models" "github.com/joho/godotenv" - "worktimeBot/internal/bot" - "worktimeBot/internal/db" + "worktimeBot/internal/handler" + "worktimeBot/internal/repo" ) func main() { @@ -23,12 +23,13 @@ func main() { AddSource: true, Level: slog.LevelInfo, }))) + godotenv.Load() token := os.Getenv("BOT_TOKEN") dbPath := getEnvDefault("DB_PATH", "db.sqlite3") - dbStore, err := db.NewStore(dbPath) + store, err := repo.NewStore(dbPath) if err != nil { slog.Error("failed to open database", "path", dbPath, "error", err) os.Exit(1) @@ -36,7 +37,7 @@ func main() { allowedUsers := parseAllowedUsers(os.Getenv("BOT_ALLOWED_USERS")) - var h *bot.Handler + var h *handler.Handler dispatch := func(ctx context.Context, b *tgbot.Bot, update *models.Update) { if update.Message != nil { @@ -53,7 +54,7 @@ func main() { os.Exit(1) } - h = bot.NewHandler(b, dbStore, allowedUsers) + h = handler.NewHandler(b, store, allowedUsers) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() @@ -64,7 +65,7 @@ func main() { if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil { slog.Warn("failed to delete webhook", "error", err) } - go dailyReportScheduler(ctx, h, dbStore) + go dailyReportScheduler(ctx, h, store) slog.Info("bot started", "allowed_users", len(allowedUsers), "mode", "polling", @@ -72,11 +73,11 @@ func main() { b.Start(ctx) } - dbStore.Close() + store.Close() } // dailyReportScheduler runs a periodic ticker that triggers daily report checks every 30 seconds. -func dailyReportScheduler(ctx context.Context, h *bot.Handler, store *db.Store) { +func dailyReportScheduler(ctx context.Context, h *handler.Handler, store repo.Repository) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() @@ -91,7 +92,7 @@ func dailyReportScheduler(ctx context.Context, h *bot.Handler, store *db.Store) } // checkDailyReports iterates all users and sends a daily report to those whose report time has arrived. -func checkDailyReports(ctx context.Context, h *bot.Handler, store *db.Store) { +func checkDailyReports(ctx context.Context, h *handler.Handler, store repo.Repository) { users, err := store.GetAllUsers() if err != nil { slog.Error("report scheduler: get users", "error", err) @@ -131,6 +132,7 @@ func checkDailyReports(ctx context.Context, h *bot.Handler, store *db.Store) { "user_id", u.ID, "chat_id", u.ChatID, "timezone", u.Timezone, ) h.SendDailyReport(ctx, u.ID, u.ChatID) + store.MarkReportSent(u.ID, today) } } diff --git a/cmd/bot/webhook.go b/cmd/bot/webhook.go index 85546ae..4d790bf 100644 --- a/cmd/bot/webhook.go +++ b/cmd/bot/webhook.go @@ -8,11 +8,11 @@ import ( tgbot "github.com/go-telegram/bot" - "worktimeBot/internal/bot" + "worktimeBot/internal/handler" ) // startWebhook registers the webhook with Telegram and starts the HTTPS/HTTP listener loop. -func startWebhook(ctx context.Context, b *tgbot.Bot, h *bot.Handler, webhookURL string) { +func startWebhook(ctx context.Context, b *tgbot.Bot, h *handler.Handler, webhookURL string) { if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil { slog.Warn("failed to delete webhook", "error", err) } diff --git a/internal/bot/burnout.go b/internal/bot/burnout.go deleted file mode 100644 index d099c49..0000000 --- a/internal/bot/burnout.go +++ /dev/null @@ -1,328 +0,0 @@ -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 olderAvg < 1 { - if recentAvg > 0 { - return 15, "new" - } - return 5, "stable" - } - - changePct := math.Round((recentAvg - olderAvg) / olderAvg * 100) - if recentAvg > olderAvg*1.2 { - return 15, fmt.Sprintf("+%.0f%%", changePct) - } - if olderAvg > recentAvg*1.2 { - return 0, fmt.Sprintf("%.0f%%", changePct) - } - return 5, "stable" -} - -// computeTrendFromAvgs is a pure version of the trend computation for testing. -func computeTrendFromAvgs(recentAvg, olderAvg float64) (pts int, direction string) { - if olderAvg < 1 { - if recentAvg > 0 { - return 15, "new" - } - return 5, "stable" - } - changePct := math.Round((recentAvg - olderAvg) / olderAvg * 100) - if recentAvg > olderAvg*1.2 { - return 15, fmt.Sprintf("+%.0f%%", changePct) - } - if olderAvg > recentAvg*1.2 { - return 0, fmt.Sprintf("%.0f%%", changePct) - } - 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 - if ratio > 100 { - ratio = 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/clock.go b/internal/bot/clock.go deleted file mode 100644 index 2a6ed00..0000000 --- a/internal/bot/clock.go +++ /dev/null @@ -1,142 +0,0 @@ -package bot - -import ( - "database/sql" - "errors" - "fmt" - "time" -) - -// clockIn records a clock-in event for today. -// Returns the formatted time or an error if already clocked in today. -func (h *Handler) clockIn(chatID int64) (string, error) { - user, day, err := h.getUserToday(chatID) - if err != nil { - return "", err - } - - last, err := h.DB.GetLastEvent(user.ID) - if err != nil && err != sql.ErrNoRows { - return "", err - } - - if last != nil && last.EventType == "in" { - loc := loadLocation(user.Timezone) - now := time.Now().In(loc) - lastTime := time.Unix(last.OccurredAt, 0).In(loc) - if lastTime.Format(DateLayout) == now.Format(DateLayout) { - return "", errors.New("already clocked in") - } - } - - loc := loadLocation(user.Timezone) - now := time.Now().In(loc) - wt := day.CurrentWorkTypeID - 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(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 { - return "", err - } - last, err := h.DB.GetLastEvent(user.ID) - if err != nil { - return "", err - } - if last == nil { - return "", errors.New("no clock-in found — start with /clockin first") - } - if last.EventType == "out" { - return "", errors.New("already clocked out") - } - loc := loadLocation(user.Timezone) - now := time.Now().In(loc) - if err := h.DB.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil { - return "", err - } - text := fmt.Sprintf("clocked out at %s", now.Format(TimeLayout)) - - // Award XP and update streaks if RPG enabled - st, err := h.DB.GetOrCreateSettings(user.ID) - if err == nil && st.RPGEnabled { - sessionWork := now.Unix() - last.OccurredAt - today := now.Format(DateLayout) - isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday - - stats, err := h.DB.GetOrCreateRPGStats(user.ID) - if err == nil { - h.updateStreak(stats, user.ID, today) - xp, leveledUp, newLevel, _ := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today) - totalHours := float64(stats.TotalWorkSeconds) / secondsPerHour - unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours) - - 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. -func (h *Handler) dayOff(chatID int64) (string, error) { - user, day, err := h.getUserToday(chatID) - if err != nil { - return "", err - } - if day.IsDayOff { - if err := h.DB.RemoveDayOff(user.ID, day.Date); err != nil { - return "", err - } - return "day off removed", nil - } - if err := h.DB.SetDayOff(user.ID, day.Date, ""); err != nil { - return "", err - } - return "today marked as day off", nil -} - -// report returns today's work summary via buildDailyReport. -func (h *Handler) report(chatID int64) (string, error) { - user, day, err := h.getUserToday(chatID) - if err != nil { - return "", err - } - events, err := h.DB.EventsForDayByDayID(day.ID) - if err != nil { - return "", err - } - return h.buildDailyReport(user, day, events), nil -} - -// toggleReport enables or disables the automatic daily report. -func (h *Handler) toggleReport(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 - } - 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/dateutil.go b/internal/bot/dateutil.go deleted file mode 100644 index 87867b5..0000000 --- a/internal/bot/dateutil.go +++ /dev/null @@ -1,354 +0,0 @@ -// Package bot implements the Telegram bot logic: message routing, calendar rendering, -// time tracking (multi-calendar), Excel export, and inline editing of events. -package bot - -import ( - "fmt" - "time" -) - -var gregMonthDays = []int{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} -var jalaliMonthDays = []int{31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29} - -var gregMonthNames = []string{ - "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December", -} -var jalaliMonthNames = []string{ - "Farvardin", "Ordibehesht", "Khordad", "Tir", "Mordad", "Shahrivar", - "Mehr", "Aban", "Azar", "Dey", "Bahman", "Esfand", -} -var hijriMonthNames = []string{ - "Muharram", "Safar", "Rabi' I", "Rabi' II", "Jumada I", "Jumada II", - "Rajab", "Sha'ban", "Ramadan", "Shawwal", "Dhu al-Qa'dah", "Dhu al-Hijjah", -} - -func isGregorianLeap(year int) bool { - return year%4 == 0 && (year%100 != 0 || year%400 == 0) -} - -func isJalaliLeap(year int) bool { - r := year % 33 - return r == 1 || r == 5 || r == 9 || r == 13 || r == 17 || r == 22 || r == 26 || r == 30 -} - -func monthLenGreg(year, month int) int { - if month == 2 && isGregorianLeap(year) { - return 29 - } - return gregMonthDays[month-1] -} - -func monthLenJalali(year, month int) int { - if month == 12 && isJalaliLeap(year) { - return 30 - } - return jalaliMonthDays[month-1] -} - -func isHijriLeap(year int) bool { - switch year % 30 { - case 2, 5, 7, 10, 13, 16, 18, 21, 24, 27, 29: - return true - } - return false -} - -func monthLenHijri(year, month int) int { - if month == 12 && isHijriLeap(year) { - return 30 - } - if month%2 == 1 { - return 30 - } - return 29 -} - -// JDN for March 21, 622 CE (Jalali epoch: 1 Farvardin 1) -// jalaliEpochJDN is the Julian Day Number for 1 Farvardin 1 (March 21, 622 CE). -const jalaliEpochJDN = 1948320 - -// gregorianToJDN converts a Gregorian date to a Julian Day Number. -func gregorianToJDN(gy, gm, gd int) int { - a := (14 - gm) / 12 - y := gy + 4800 - a - m := gm + 12*a - 3 - return gd + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045 -} - -// jdnToGregorian converts a Julian Day Number to a Gregorian date (year, month, day). -func jdnToGregorian(jdn int) (int, int, int) { - f := jdn + 1401 + ((4*jdn+274277)/146097)*3/4 - 38 - e := 4*f + 3 - g := (e % 1461) / 4 - h := 5*g + 2 - day := (h%153)/5 + 1 - month := ((h/153 + 2) % 12) + 1 - year := e/1461 - 4716 + (14-month)/12 - return year, month, day -} - -// gregorianToJalali converts a Gregorian date to Jalali (Persian/Solar Hijri). -func gregorianToJalali(gy, gm, gd int) (int, int, int) { - jdn := gregorianToJDN(gy, gm, gd) - days := jdn - jalaliEpochJDN - if days < 0 { - return 0, 0, 0 - } - - jy := 1 - for { - yearDays := 365 - if isJalaliLeap(jy) { - yearDays = 366 - } - if days < yearDays { - break - } - days -= yearDays - jy++ - } - - jm := 1 - for mm := 0; mm < 12; mm++ { - md := monthLenJalali(jy, mm+1) - if days < md { - break - } - days -= md - jm++ - } - jd := days + 1 - return jy, jm, jd -} - -// jalaliToGregorian converts a Jalali (Persian/Solar Hijri) date to Gregorian. -func jalaliToGregorian(jy, jm, jd int) (int, int, int) { - days := 0 - for y := 1; y < jy; y++ { - if isJalaliLeap(y) { - days += 366 - } else { - days += 365 - } - } - for m := 1; m < jm; m++ { - days += monthLenJalali(jy, m) - } - days += jd - 1 - jdn := jalaliEpochJDN + days - return jdnToGregorian(jdn) -} - -// hijriToGregorian converts a Hijri (Islamic) date to Gregorian. -func hijriToGregorian(hy, hm, hd int) (int, int, int) { - days := (hy-1)*354 + (hy-1)*11/30 - for m := 1; m < hm; m++ { - days += monthLenHijri(hy, m) - } - days += hd - 1 - - jdn := 1948440 + days - - f := jdn + 1401 + ((4*jdn+274277)/146097)*3/4 - 38 - e := 4*f + 3 - g := (e % 1461) / 4 - h := 5*g + 2 - day := (h%153)/5 + 1 - month := ((h/153 + 2) % 12) + 1 - year := e/1461 - 4716 + (14-month)/12 - return year, month, day -} - -// gregorianToHijri converts a Gregorian date to Hijri (Islamic). -func gregorianToHijri(gy, gm, gd int) (int, int, int) { - jdn := gregorianToJDN(gy, gm, gd) - days := jdn - 1948440 - - hy := 1 - for { - yearDays := 354 - if isHijriLeap(hy) { - yearDays = 355 - } - if days < yearDays { - break - } - days -= yearDays - hy++ - } - - hm := 1 - for mm := 1; mm <= 12; mm++ { - md := monthLenHijri(hy, mm) - if days < md { - break - } - days -= md - hm++ - } - hd := days + 1 - return hy, hm, hd -} - -func dayOfWeekGregorian(gy, gm, gd int) int { - // Zeller-like: returns 0=Sat,1=Sun,2=Mon,3=Tue,4=Wed,5=Thu,6=Fri - if gm < 3 { - gm += 12 - gy-- - } - return (gd + (13*(gm+1))/5 + gy + gy/4 - gy/100 + gy/400) % 7 -} - -// calDay represents a single day cell in a calendar month view. -type calDay struct { - dayNum int - date string // YYYY-MM-DD gregorian key -} - -// calMonth represents a rendered calendar month with title, weekday headers, and day cells. -type calMonth struct { - title string - weekDays []string - days []calDay -} - -// buildCalendarMonth builds a calMonth struct for the given calendar type, year and month. -func buildCalendarMonth(cal string, year, month int) calMonth { - var title string - var weekDays []string - var totalDays int - - if cal == "jalali" { - title = fmt.Sprintf("%s %d", jalaliMonthNames[month-1], year) - weekDays = []string{"Sh", "Ye", "Do", "Se", "Ch", "Pa", "Jo"} - totalDays = monthLenJalali(year, month) - - gy, gm, gd := jalaliToGregorian(year, month, 1) - dow := dayOfWeekGregorian(gy, gm, gd) - startOffset := dow - - var days []calDay - for i := 0; i < startOffset; i++ { - days = append(days, calDay{dayNum: 0, date: ""}) - } - for d := 1; d <= totalDays; d++ { - gy, gm, gd = jalaliToGregorian(year, month, d) - days = append(days, calDay{ - dayNum: d, - date: fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd), - }) - } - return calMonth{title: title, weekDays: weekDays, days: days} - } - - if cal == "hijri" { - title = fmt.Sprintf("%s %d", hijriMonthNames[month-1], year) - weekDays = []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} - totalDays = monthLenHijri(year, month) - - gy, gm, gd := hijriToGregorian(year, month, 1) - dow := dayOfWeekGregorian(gy, gm, gd) - startOffset := (dow + 5) % 7 - - var days []calDay - for i := 0; i < startOffset; i++ { - days = append(days, calDay{dayNum: 0, date: ""}) - } - for d := 1; d <= totalDays; d++ { - gy, gm, gd = hijriToGregorian(year, month, d) - days = append(days, calDay{ - dayNum: d, - date: fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd), - }) - } - return calMonth{title: title, weekDays: weekDays, days: days} - } - - title = fmt.Sprintf("%s %d", gregMonthNames[month-1], year) - weekDays = []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} - totalDays = monthLenGreg(year, month) - - dow := dayOfWeekGregorian(year, month, 1) - startOffset := (dow + 5) % 7 - - var days []calDay - for i := 0; i < startOffset; i++ { - days = append(days, calDay{dayNum: 0, date: ""}) - } - for d := 1; d <= totalDays; d++ { - days = append(days, calDay{ - dayNum: d, - date: fmt.Sprintf("%04d-%02d-%02d", year, month, d), - }) - } - return calMonth{title: title, weekDays: weekDays, days: days} -} - -// parseGregorianDateKey parses a "YYYY-MM-DD" string into year, month, day integers. -func parseGregorianDateKey(key string) (int, int, int) { - var y, m, d int - fmt.Sscanf(key, "%04d-%02d-%02d", &y, &m, &d) - return y, m, d -} - -// formatDateForCalendar converts a Gregorian YYYY-MM-DD date string into the target calendar representation. -func formatDateForCalendar(date, calType string) string { - y, m, d := parseGregorianDateKey(date) - switch calType { - case "jalali": - jy, jm, jd := gregorianToJalali(y, m, d) - return fmt.Sprintf("%04d-%02d-%02d", jy, jm, jd) - case "hijri": - hy, hm, hd := gregorianToHijri(y, m, d) - return fmt.Sprintf("%04d-%02d-%02d", hy, hm, hd) - default: - return date - } -} - -// userDateToGregorian converts a YYYY-MM-DD string in the user's calendar -// to the corresponding Gregorian YYYY-MM-DD string. -func userDateToGregorian(dateStr, cal string) string { - y, m, d := parseGregorianDateKey(dateStr) - switch cal { - case "jalali": - y, m, d = jalaliToGregorian(y, m, d) - case "hijri": - y, m, d = hijriToGregorian(y, m, d) - } - return fmt.Sprintf("%04d-%02d-%02d", y, m, d) -} - -// monthGregorianRange returns the Gregorian start and end date strings -// for a given calendar month (cal=gregorian|jalali|hijri). -func monthGregorianRange(cal string, year, month int) (start, end string) { - switch cal { - case "jalali": - gy, gm, gd := jalaliToGregorian(year, month, 1) - start = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd) - lastDay := monthLenJalali(year, month) - gy, gm, gd = jalaliToGregorian(year, month, lastDay) - end = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd) - case "hijri": - gy, gm, gd := hijriToGregorian(year, month, 1) - start = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd) - lastDay := monthLenHijri(year, month) - gy, gm, gd = hijriToGregorian(year, month, lastDay) - end = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd) - default: - start = fmt.Sprintf("%04d-%02d-%02d", year, month, 1) - lastDay := monthLenGreg(year, month) - end = fmt.Sprintf("%04d-%02d-%02d", year, month, lastDay) - } - return -} - -// loadLocation loads a timezone, falling back to UTC on error. -func loadLocation(tz string) *time.Location { - loc, err := time.LoadLocation(tz) - if err != nil { - return time.UTC - } - return loc -} diff --git a/internal/bot/dateutil_test.go b/internal/bot/dateutil_test.go deleted file mode 100644 index 70abd77..0000000 --- a/internal/bot/dateutil_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package bot - -import ( - "testing" -) - -func TestGregorianToJDN_RoundTrip(t *testing.T) { - cases := []struct{ y, m, d int }{ - {2024, 1, 1}, - {2024, 12, 31}, - {1970, 1, 1}, - {1, 1, 1}, - {2024, 2, 29}, - {1900, 2, 28}, - {2000, 2, 29}, - } - for _, c := range cases { - jdn := gregorianToJDN(c.y, c.m, c.d) - y, m, d := jdnToGregorian(jdn) - if y != c.y || m != c.m || d != c.d { - t.Errorf("round-trip %04d-%02d-%02d -> JDN %d -> %04d-%02d-%02d", c.y, c.m, c.d, jdn, y, m, d) - } - } -} - -func TestGregorianToJalali_RoundTrip(t *testing.T) { - cases := []struct{ gy, gm, gd int }{ - {2024, 3, 20}, - {2024, 3, 21}, - {2024, 9, 22}, - {2025, 3, 20}, - {1979, 2, 11}, - {622, 3, 22}, - {2024, 12, 31}, - {2024, 6, 21}, - {2024, 1, 1}, - } - for _, c := range cases { - jy, jm, jd := gregorianToJalali(c.gy, c.gm, c.gd) - if jy == 0 { - t.Errorf("gregorianToJalali(%d, %d, %d) returned 0", c.gy, c.gm, c.gd) - continue - } - gy2, gm2, gd2 := jalaliToGregorian(jy, jm, jd) - if gy2 != c.gy || gm2 != c.gm || gd2 != c.gd { - t.Errorf("round-trip Gregorian %04d-%02d-%02d -> Jalali %04d-%02d-%02d -> Gregorian %04d-%02d-%02d", - c.gy, c.gm, c.gd, jy, jm, jd, gy2, gm2, gd2) - } - } -} - -func TestGregorianToHijri_RoundTrip(t *testing.T) { - cases := []struct{ gy, gm, gd int }{ - {2024, 3, 21}, - {2024, 1, 1}, - {2024, 12, 31}, - {1979, 2, 11}, - {622, 7, 16}, - {2024, 7, 7}, - {2024, 6, 21}, - {2000, 1, 1}, - } - for _, c := range cases { - hy, hm, hd := gregorianToHijri(c.gy, c.gm, c.gd) - if hy == 0 { - t.Errorf("gregorianToHijri(%d, %d, %d) returned 0", c.gy, c.gm, c.gd) - continue - } - gy2, gm2, gd2 := hijriToGregorian(hy, hm, hd) - diff := (gy2-c.gy)*365 + (gm2-c.gm)*30 + (gd2 - c.gd) - if diff < 0 { - diff = -diff - } - if diff > 1 { - t.Errorf("round-trip off by %d days: Gregorian %04d-%02d-%02d -> Hijri %04d-%02d-%02d -> Gregorian %04d-%02d-%02d", - diff, c.gy, c.gm, c.gd, hy, hm, hd, gy2, gm2, gd2) - } - } -} - -func TestIsLeapYear(t *testing.T) { - gregLeaps := map[int]bool{ - 2000: true, 2020: true, 2024: true, 1900: false, 2100: false, 2023: false, - } - for y, want := range gregLeaps { - if got := isGregorianLeap(y); got != want { - t.Errorf("isGregorianLeap(%d) = %v, want %v", y, got, want) - } - } - - jalaliLeaps := map[int]bool{ - 1: true, 5: true, 9: true, 13: true, 17: true, 22: true, 26: true, 30: true, - 2: false, 33: false, 1403: true, - } - for y, want := range jalaliLeaps { - if got := isJalaliLeap(y); got != want { - t.Errorf("isJalaliLeap(%d) = %v, want %v", y, got, want) - } - } - - hijriLeaps := map[int]bool{ - 2: true, 5: true, 7: true, 10: true, 13: true, 16: true, - 18: true, 21: true, 24: true, 27: true, 29: true, - 1: false, 3: false, 30: false, - } - for y, want := range hijriLeaps { - if got := isHijriLeap(y); got != want { - t.Errorf("isHijriLeap(%d) = %v, want %v", y, got, want) - } - } -} - -func TestMonthLengths(t *testing.T) { - if got := monthLenGreg(2024, 2); got != 29 { - t.Errorf("monthLenGreg(2024, 2) = %d, want 29", got) - } - if got := monthLenGreg(2023, 2); got != 28 { - t.Errorf("monthLenGreg(2023, 2) = %d, want 28", got) - } - if got := monthLenGreg(2024, 1); got != 31 { - t.Errorf("monthLenGreg(2024, 1) = %d, want 31", got) - } - - if got := monthLenJalali(1, 12); got != 30 { - t.Errorf("monthLenJalali(1, 12) = %d, want 30 (leap)", got) - } - if got := monthLenJalali(2, 12); got != 29 { - t.Errorf("monthLenJalali(2, 12) = %d, want 29 (non-leap)", got) - } - if got := monthLenJalali(1403, 1); got != 31 { - t.Errorf("monthLenJalali(1403, 1) = %d, want 31", got) - } - - if got := monthLenHijri(1, 1); got != 30 { - t.Errorf("monthLenHijri(1, 1) = %d, want 30 (odd month)", got) - } - if got := monthLenHijri(1, 2); got != 29 { - t.Errorf("monthLenHijri(1, 2) = %d, want 29 (even non-leap)", got) - } - if got := monthLenHijri(2, 12); got != 30 { - t.Errorf("monthLenHijri(2, 12) = %d, want 30 (leap year, month 12)", got) - } -} - -func TestParseGregorianDateKey(t *testing.T) { - y, m, d := parseGregorianDateKey("2024-03-21") - if y != 2024 || m != 3 || d != 21 { - t.Errorf("parseGregorianDateKey got %d-%d-%d, want 2024-3-21", y, m, d) - } -} - -func TestFormatDateForCalendar(t *testing.T) { - if got := formatDateForCalendar("2024-03-21", "gregorian"); got != "2024-03-21" { - t.Errorf("formatDateForCalendar gregorian: got %s", got) - } - if got := formatDateForCalendar("2024-03-21", "jalali"); got != "1403-01-02" { - t.Errorf("formatDateForCalendar jalali: got %s, want 1403-01-02", got) - } - if got := formatDateForCalendar("2024-03-21", "hijri"); got != "1445-09-11" { - t.Errorf("formatDateForCalendar hijri: got %s, want 1445-09-11", got) - } -} - -func TestUserDateToGregorian(t *testing.T) { - if got := userDateToGregorian("1403-01-02", "jalali"); got != "2024-03-21" { - t.Errorf("userDateToGregorian jalali: got %s, want 2024-03-21", got) - } - if got := userDateToGregorian("1445-09-11", "hijri"); got != "2024-03-21" { - t.Errorf("userDateToGregorian hijri: got %s, want 2024-03-21", got) - } -} - -func TestMonthGregorianRange(t *testing.T) { - start, end := monthGregorianRange("gregorian", 2024, 1) - if start != "2024-01-01" || end != "2024-01-31" { - t.Errorf("gregorian range: got %s - %s", start, end) - } - - start, end = monthGregorianRange("jalali", 1403, 1) - if start != "2024-03-20" || end != "2024-04-19" { - t.Errorf("jalali range: got %s - %s", start, end) - } - - start, end = monthGregorianRange("hijri", 1445, 9) - if start != "2024-03-11" || end != "2024-04-09" { - t.Errorf("hijri range: got %s - %s", start, end) - } -} - -func TestBuildCalendarMonth(t *testing.T) { - cm := buildCalendarMonth("gregorian", 2024, 1) - if cm.title != "January 2024" { - t.Errorf("title: got %q", cm.title) - } - if len(cm.weekDays) != 7 { - t.Errorf("weekDays count: %d", len(cm.weekDays)) - } - if len(cm.days) < 28 { - t.Errorf("too few days: %d", len(cm.days)) - } - - cm2 := buildCalendarMonth("jalali", 1403, 1) - if cm2.title != "Farvardin 1403" { - t.Errorf("jalali title: got %q", cm2.title) - } - if len(cm2.days) < 31 { - t.Errorf("too few jalali days: %d", len(cm2.days)) - } - - cm3 := buildCalendarMonth("hijri", 1445, 9) - if cm3.title != "Ramadan 1445" { - t.Errorf("hijri title: got %q", cm3.title) - } -} - -func TestLoadLocation(t *testing.T) { - loc := loadLocation("Asia/Tehran") - if loc == nil { - t.Fatal("loadLocation returned nil") - } - if loc.String() != "Asia/Tehran" { - t.Errorf("unexpected location: %s", loc) - } - - loc = loadLocation("Invalid/Timezone") - if loc.String() != "UTC" { - t.Errorf("expected UTC fallback, got %s", loc) - } -} - -func TestFormatDuration(t *testing.T) { - if got := formatDuration(3600); got != "1h 0m" { - t.Errorf("formatDuration(3600) = %q, want %q", got, "1h 0m") - } - if got := formatDuration(3660); got != "1h 1m" { - t.Errorf("formatDuration(3660) = %q, want %q", got, "1h 1m") - } - if got := formatDuration(59); got != "0m" { - t.Errorf("formatDuration(59) = %q, want %q", got, "0m") - } - if got := formatDuration(1800); got != "30m" { - t.Errorf("formatDuration(1800) = %q, want %q", got, "30m") - } - if got := formatDuration(0); got != "0m" { - t.Errorf("formatDuration(0) = %q, want %q", got, "0m") - } -} diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go deleted file mode 100644 index 3739c02..0000000 --- a/internal/bot/handlers.go +++ /dev/null @@ -1,700 +0,0 @@ -package bot - -import ( - "context" - "fmt" - "log/slog" - "strings" - "sync" - "time" - - bot "github.com/go-telegram/bot" - "github.com/go-telegram/bot/models" - - "worktimeBot/internal/db" -) - -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 - createdAt time.Time -} - -// Handler holds the bot instance, database store, user allowlist, and rate-limit state. -type Handler struct { - Bot *bot.Bot - DB *db.Store - AllowedUsers map[int64]bool - lastMsgTime map[int64]time.Time - pendingNotes map[int64]*pendingNote - mu sync.Mutex -} - -// NewHandler creates a Handler and starts the rate-limit cleanup goroutine. -func NewHandler(bot *bot.Bot, store *db.Store, allowed map[int64]bool) *Handler { - h := &Handler{ - Bot: bot, - DB: store, - AllowedUsers: allowed, - lastMsgTime: make(map[int64]time.Time), - pendingNotes: make(map[int64]*pendingNote), - } - go h.periodicCleanup() - return h -} - -// periodicCleanup runs in the background, purging stale rate-limit entries and pending notes every 10 seconds. -func (h *Handler) periodicCleanup() { - for { - time.Sleep(10 * time.Second) - h.mu.Lock() - cutoff := time.Now().Add(-rateLimitInterval * 2) - for uid, t := range h.lastMsgTime { - if t.Before(cutoff) { - delete(h.lastMsgTime, uid) - } - } - noteCutoff := time.Now().Add(-5 * time.Minute) - for chatID, pn := range h.pendingNotes { - if pn.createdAt.Before(noteCutoff) { - delete(h.pendingNotes, chatID) - } - } - h.mu.Unlock() - } -} - -type actionFunc func(int64) (string, error) - -// isRateLimited returns true if the user has sent a message within the last rateLimitInterval. -func (h *Handler) isRateLimited(userID int64) bool { - h.mu.Lock() - defer h.mu.Unlock() - now := time.Now() - if last, ok := h.lastMsgTime[userID]; ok && now.Sub(last) < rateLimitInterval { - return true - } - h.lastMsgTime[userID] = now - return false -} - -// handleActionMsg runs an action function and sends the result as a new message. -func (h *Handler) handleActionMsg(ctx context.Context, msg *models.Message, fn actionFunc) { - text, err := fn(msg.Chat.ID) - if err != nil { - h.sendText(ctx, msg.Chat.ID, err.Error()) - return - } - kb := mainKeyboard() - h.sendWithKB(ctx, msg.Chat.ID, text, kb) -} - -// handleActionCallback runs an action function and edits the callback message with the result. -func (h *Handler) handleActionCallback(ctx context.Context, chatID int64, msgID int, callbackID string, fn actionFunc) { - defer h.answerCb(ctx, callbackID) - text, err := fn(chatID) - if err != nil { - text = err.Error() - } - kb := backKeyboard() - h.editText(ctx, chatID, msgID, text, &kb) -} - -// HandleMessage routes incoming text messages to the appropriate handler. -func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) { - if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] { - return - } - if msg.Text == "" { - return - } - user, err := h.getOrCreateUser(msg.Chat.ID) - if err != nil { - return - } - if h.isRateLimited(user.ID) { - return - } - slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text) - - // Check for pending note — intercepts plain text only, commands always pass through. - h.mu.Lock() - pn, hasPN := h.pendingNotes[msg.Chat.ID] - if hasPN { - delete(h.pendingNotes, msg.Chat.ID) - } - h.mu.Unlock() - if hasPN && msg.Text[0] != '/' { - loc := loadLocation(user.Timezone) - event, err := h.getEventByID(user.ID, pn.eventID) - if 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(DateLayout) - h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true) - } - return - } - - if msg.Text[0] != '/' { - h.sendText(ctx, msg.Chat.ID, "Unknown command") - return - } - - cmd := msg.Text - if i := strings.IndexByte(msg.Text, ' '); i >= 0 { - cmd = msg.Text[:i] - } - - switch cmd { - case "/start": - h.handleStart(ctx, msg) - case "/help": - h.handleHelp(ctx, msg) - case "/clockin": - h.handleActionMsg(ctx, msg, h.clockIn) - case "/clockout": - h.handleActionMsg(ctx, msg, h.clockOut) - case "/worktype": - h.handleWorkTypeMsg(ctx, msg) - case "/report": - h.handleActionMsg(ctx, msg, h.report) - case "/export": - h.handleExport(ctx, msg) - case "/dayoff": - h.handleActionMsg(ctx, msg, h.dayOff) - case "/history": - h.handleHistoryMsg(ctx, msg) - case "/edit": - h.handleEditMsg(ctx, msg) - case "/reporttoggle": - h.handleActionMsg(ctx, msg, h.toggleReport) - case "/setreporttime": - h.handleSetReportTime(ctx, msg) - case "/setaccent": - h.handleAccentMsg(ctx, msg) - case "/settimezone": - h.handleSetTimezone(ctx, msg) - case "/note": - h.handleNoteMsg(ctx, msg) - case "/summary": - h.handleSummaryMsg(ctx, msg) - case "/setbreak": - h.handleSetBreakMsg(ctx, msg) - case "/rpg": - h.handleRPGCmd(ctx, msg) - 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.handleLeagueToggleCmd(ctx, msg) - case "/setusername": - h.handleSetUsername(ctx, msg) - default: - h.sendText(ctx, msg.Chat.ID, "Unknown command") - } -} - -// HandleCallback routes inline callback queries to the appropriate handler. -func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery) { - if len(h.AllowedUsers) > 0 && !h.AllowedUsers[cb.Message.Message.Chat.ID] { - h.answerCbWithText(ctx, cb.ID, "Unauthorized") - return - } - user, err := h.getOrCreateUser(cb.Message.Message.Chat.ID) - if err != nil { - h.answerCb(ctx, cb.ID) - return - } - if h.isRateLimited(user.ID) { - h.answerCb(ctx, cb.ID) - return - } - slog.Info("callback", "chat_id", cb.Message.Message.Chat.ID, "data", cb.Data) - chatID := cb.Message.Message.Chat.ID - msgID := cb.Message.Message.ID - - switch cb.Data { - case "clockin": - h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.clockIn) - case "clockout": - h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.clockOut) - case "worktype": - h.workTypeCallback(ctx, chatID, msgID, cb.ID) - case "report": - h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.report) - case "export": - h.exportCallback(ctx, chatID, msgID, cb.ID) - case "noop": - h.answerCb(ctx, cb.ID) - case "dayoff": - h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.dayOff) - case "settings": - h.settingsCallback(ctx, chatID, msgID, cb.ID) - case "caltype": - h.calTypeCallback(ctx, chatID, msgID, cb.ID) - case "accent": - h.accentCallback(ctx, chatID, msgID, cb.ID) - case "timezone": - h.timezoneCallback(ctx, chatID, msgID, cb.ID) - case "history": - h.historyCallback(ctx, chatID, msgID, cb.ID) - case "reporttime": - h.reportTimeCallback(ctx, chatID, msgID, cb.ID) - case "breakthreshold": - h.breakThresholdCallback(ctx, chatID, msgID, cb.ID) - case "back_menu": - 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 "rpg_achievements": - h.rpgAchievementsCallback(ctx, chatID, msgID, cb.ID) - case "rpg_back": - h.rpgBackCallback(ctx, chatID, msgID, cb.ID) - case "username": - h.usernameCallback(ctx, chatID, msgID, cb.ID) - case "currency": - h.currencyCallback(ctx, chatID, msgID, cb.ID) - case "setrate": - h.rateCallback(ctx, chatID, msgID, cb.ID) - default: - 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 - } - if len(data) > 9 && data[:9] == "currency_" { - h.selectCurrency(ctx, chatID, msgID, callbackID, data[9:]) - 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) - } -} - -// getOrCreateUser is a shortcut that delegates to the store. -func (h *Handler) getOrCreateUser(chatID int64) (*db.User, error) { - return h.DB.GetOrCreateUser(chatID) -} - -// getUserToday fetches the user and today's day record, using the user's timezone. -func (h *Handler) getUserToday(chatID int64) (*db.User, *db.Day, error) { - user, err := h.getOrCreateUser(chatID) - if err != nil { - return nil, nil, err - } - loc, err := time.LoadLocation(user.Timezone) - if err != nil { - loc = time.UTC - } - today := time.Now().In(loc).Format(DateLayout) - day, err := h.DB.GetOrCreateDay(user.ID, today) - if err != nil { - return nil, nil, err - } - return user, day, nil -} - -// handleStart shows the main menu with the user's current status. -func (h *Handler) handleStart(ctx context.Context, msg *models.Message) { - user, err := h.getOrCreateUser(msg.Chat.ID) - if err != nil { - slog.Error("start: create user", "error", err) - h.sendText(ctx, msg.Chat.ID, "Error setting up your profile") - return - } - slog.Info("start", "user_id", user.ID, "chat_id", msg.Chat.ID) - kb := mainKeyboard() - h.sendWithKB(ctx, msg.Chat.ID, h.buildStatusText(msg.Chat.ID), kb) -} - -// handleHelp shows the list of available commands. -func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) { - text := `Commands: -/start - Show main menu -/clockin - Clock in -/clockout - Clock out -/worktype - Select work type -/dayoff - Toggle day off -/report - Show today report -/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 -/reporttoggle - Enable/disable auto report -/setreporttime HH:MM - Set daily report time -/setaccent - Select accent color -/settimezone - Set timezone (e.g. Asia/Tehran) -/setbreak - Set break threshold in minutes -/setusername - Set your display name -/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 - 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.` - h.sendText(ctx, msg.Chat.ID, text) -} - -// handleSetUsername handles /setusername . -func (h *Handler) handleSetUsername(ctx context.Context, msg *models.Message) { - name := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setusername")) - if name == "" { - user, err := h.getOrCreateUser(msg.Chat.ID) - if err != nil { - h.sendText(ctx, msg.Chat.ID, "Error loading profile") - return - } - h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current username: %s\nUsage: /setusername ", user.Username)) - return - } - name = SanitizeDisplayName(name) - if name == "" { - h.sendText(ctx, msg.Chat.ID, "Invalid username.") - return - } - user, err := h.getOrCreateUser(msg.Chat.ID) - if err != nil { - h.sendText(ctx, msg.Chat.ID, "Error loading profile") - return - } - user.Username = name - if err := h.DB.UpdateUser(user); err != nil { - h.sendText(ctx, msg.Chat.ID, "Error saving username") - return - } - h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Username set to: %s", name)) -} - -// handleNoteMsg processes the /note command: /note [YYYY-MM-DD] HH:MM -func (h *Handler) handleNoteMsg(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 - } - loc := loadLocation(user.Timezone) - - parts := strings.Fields(msg.Text) - if len(parts) < 3 { - h.sendText(ctx, msg.Chat.ID, "Usage: /note [YYYY-MM-DD] HH:MM ") - return - } - - var dateStr string - var timeStr string - var noteParts []string - - // Check if first argument looks like a date - if len(parts[1]) == 10 && parts[1][4] == '-' && parts[1][7] == '-' { - dateStr = parts[1] - timeStr = parts[2] - noteParts = parts[3:] - } else { - dateStr = time.Now().In(loc).Format(DateLayout) - timeStr = parts[1] - noteParts = parts[2:] - } - - events, err := h.DB.EventsForDayByDate(user.ID, dateStr) - if err != nil || len(events) == 0 { - h.sendText(ctx, msg.Chat.ID, "No events found for that day") - return - } - - var targetEvent *db.Event - for i := range events { - t := time.Unix(events[i].OccurredAt, 0).In(loc).Format(TimeLayout) - if t == timeStr { - targetEvent = &events[i] - break - } - } - if targetEvent == nil { - h.sendText(ctx, msg.Chat.ID, "No event found at that time") - return - } - - 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 - } - h.sendText(ctx, msg.Chat.ID, "Note saved") -} - -// handleWorkTypeMsg shows the work type picker for today. -func (h *Handler) handleWorkTypeMsg(ctx context.Context, msg *models.Message) { - user, day, err := h.getUserToday(msg.Chat.ID) - if err != nil { - h.sendText(ctx, msg.Chat.ID, "Error loading work types") - return - } - text, kb := h.buildWorkTypeKeyboard(user, day) - h.sendWithKB(ctx, msg.Chat.ID, text, kb) -} - -// workTypeCallback shows the work type picker (inline edit). -func (h *Handler) workTypeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { - defer h.answerCb(ctx, callbackID) - user, day, err := h.getUserToday(chatID) - if err != nil { - kb := backKeyboard() - h.editText(ctx, chatID, msgID, "Error loading work types", &kb) - return - } - text, kb := h.buildWorkTypeKeyboard(user, day) - h.editText(ctx, chatID, msgID, text, &kb) -} - -// buildWorkTypeKeyboard returns the work type selection keyboard. -func (h *Handler) buildWorkTypeKeyboard(user *db.User, day *db.Day) (string, models.InlineKeyboardMarkup) { - wts, err := h.DB.GetWorkTypes() - if err != nil || len(wts) == 0 { - return "No work types available.", backKeyboard() - } - rows := [][]models.InlineKeyboardButton{} - for _, wt := range wts { - label := wt.Name - if wt.ID == day.CurrentWorkTypeID { - label = "> " + wt.Name - } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: label, CallbackData: fmt.Sprintf("worktype_%d", wt.ID)}, - }) - } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: "Back to Menu", CallbackData: "back_menu"}, - }) - return "Select work type:", models.InlineKeyboardMarkup{InlineKeyboard: rows} -} - -// selectWorkType updates the day's work type from a callback. -func (h *Handler) selectWorkType(ctx context.Context, chatID int64, msgID int, callbackID, idStr string) { - defer h.answerCb(ctx, callbackID) - _, day, err := h.getUserToday(chatID) - if err != nil { - return - } - var wtID int64 - if _, err := fmt.Sscanf(idStr, "%d", &wtID); err != nil { - return - } - wt, err := h.DB.GetWorkType(wtID) - if err != nil { - return - } - day.CurrentWorkTypeID = wtID - if err := h.DB.UpdateDay(day); err != nil { - return - } - kb := backKeyboard() - h.editText(ctx, chatID, msgID, fmt.Sprintf("Work type set to: %s", wt.Name), &kb) -} - -// mainKeyboard returns the persistent inline menu. -func mainKeyboard() models.InlineKeyboardMarkup { - return models.InlineKeyboardMarkup{ - InlineKeyboard: [][]models.InlineKeyboardButton{ - { - {Text: "Clock In", CallbackData: "clockin"}, - {Text: "Clock Out", CallbackData: "clockout"}, - }, - { - {Text: "Work Type", CallbackData: "worktype"}, - {Text: "Day Off", CallbackData: "dayoff"}, - }, - { - {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"}, - }, - }, - } -} - -// backKeyboard returns a simple "Back to Menu" button. -func backKeyboard() models.InlineKeyboardMarkup { - return models.InlineKeyboardMarkup{ - InlineKeyboard: [][]models.InlineKeyboardButton{ - {{Text: "Back to Menu", CallbackData: "back_menu"}}, - }, - } -} - -// formatDuration formats seconds as a human-readable duration (e.g. "7h 30m"). -func formatDuration(seconds int64) string { - hours := seconds / secondsPerHour - mins := (seconds % secondsPerHour) / 60 - if hours > 0 { - return fmt.Sprintf("%dh %dm", hours, mins) - } - return fmt.Sprintf("%dm", mins) -} - -// sendText sends a plain text message to the given chat. -func (h *Handler) sendText(ctx context.Context, chatID int64, text string) { - if _, err := h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text}); err != nil { - slog.Warn("send text failed", "chat_id", chatID, "error", err) - } -} - -// editText edits an existing message, logging errors. -func (h *Handler) editText(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) { - p := &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text} - if kb != nil { - p.ReplyMarkup = kb - } - if _, err := h.Bot.EditMessageText(ctx, p); err != nil { - slog.Warn("edit text failed", "chat_id", chatID, "msg_id", msgID, "error", err) - } -} - -// sendWithKB sends a message with an inline keyboard, logging errors. -func (h *Handler) sendWithKB(ctx context.Context, chatID int64, text string, kb models.InlineKeyboardMarkup) { - if _, err := h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text, ReplyMarkup: kb}); err != nil { - slog.Warn("send with keyboard failed", "chat_id", chatID, "error", err) - } -} - -// answerCb answers a callback query, logging errors. -func (h *Handler) answerCb(ctx context.Context, callbackID string) { - if _, err := h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}); err != nil { - slog.Debug("answer callback failed", "error", err) - } -} - -// answerCbWithText answers a callback query with a toast message, logging errors. -func (h *Handler) answerCbWithText(ctx context.Context, callbackID, text string) { - if _, err := h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID, Text: text}); err != nil { - slog.Debug("answer callback with text failed", "error", err) - } -} - -// navMonth returns the (prevY, prevM, nextY, nextM) for month navigation. -func navMonth(year, month int) (prevY, prevM, nextY, nextM int) { - prevY, prevM = year, month-1 - if prevM < 1 { - prevM = 12 - prevY-- - } - nextY, nextM = year, month+1 - if nextM > 12 { - nextM = 1 - nextY++ - } - return -} - -// sendOrEdit sends a new message or edits an existing one depending on msgID. -func (h *Handler) sendOrEdit(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) { - if msgID == 0 { - if kb != nil { - h.sendWithKB(ctx, chatID, text, *kb) - } else { - h.sendText(ctx, chatID, text) - } - } else { - h.editText(ctx, chatID, msgID, text, kb) - } -} diff --git a/internal/bot/report.go b/internal/bot/report.go deleted file mode 100644 index e056204..0000000 --- a/internal/bot/report.go +++ /dev/null @@ -1,187 +0,0 @@ -package bot - -import ( - "context" - "fmt" - "log/slog" - "time" - - bot "github.com/go-telegram/bot" - - "worktimeBot/internal/db" -) - -// buildStatusText returns the main menu status text for the current user. -func (h *Handler) buildStatusText(chatID int64) string { - user, day, err := h.getUserToday(chatID) - if err != nil { - return "Welcome. Choose an action:" - } - - text := fmt.Sprintf("Today: %s", formatDateForCalendar(day.Date, user.Calendar)) - - if day.IsDayOff { - text += "\nDay Off" - } - - wt, _ := h.DB.GetWorkType(day.CurrentWorkTypeID) - if wt != nil { - text += fmt.Sprintf("\nWork type: %s", wt.Name) - } - - 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(TimeLayout) - text += fmt.Sprintf("\nStatus: working since %s", t) - } else { - text += "\nStatus: not working" - } - - events, err := h.DB.EventsForDayByDayID(day.ID) - if 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) - text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds)) - } - - // 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 - } - } - } - - // 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("\n %s", rankText) - } - } - } - - text += "\n\nChoose an action:" - return text -} - -// backToMenu returns to the main menu from any sub-menu. -func (h *Handler) backToMenu(ctx context.Context, chatID int64, msgID int, callbackID string) { - defer h.answerCb(ctx, callbackID) - kb := mainKeyboard() - h.editText(ctx, chatID, msgID, h.buildStatusText(chatID), &kb) -} - -// buildDailyReport builds a formatted daily report string for a given user/day. -func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event) string { - loc, err := time.LoadLocation(user.Timezone) - if err != nil { - loc = time.UTC - } - - if len(events) == 0 { - if day.IsDayOff { - return fmt.Sprintf("%s - Day Off", formatDateForCalendar(day.Date, user.Calendar)) - } - return fmt.Sprintf("%s - No events recorded.", formatDateForCalendar(day.Date, user.Calendar)) - } - - 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(DateLayout) - dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix() - if day.Date == todayStr { - dayEnd = now.Unix() - } - totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) - - lines := fmt.Sprintf("Report for %s", formatDateForCalendar(day.Date, user.Calendar)) - if day.IsDayOff { - lines += "\nDay Off: Yes" - } - lines += "\n\n" - - for _, e := range events { - t := time.Unix(e.OccurredAt, 0).In(loc).Format(TimeLayout) - label := "IN" - if e.EventType == "out" { - label = "OUT" - } - suffix := "" - if e.WorkTypeID != nil { - if wtObj, err := h.DB.GetWorkType(*e.WorkTypeID); err == nil { - suffix = " [" + wtObj.Name + "]" - } - } - if e.Note != "" { - suffix += " (" + e.Note + ")" - } - lines += fmt.Sprintf("%s %s%s\n", label, t, suffix) - } - - lines += fmt.Sprintf("\nSummary:\n Work: %s\n Break: %s\n", - 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 -} - -// SendDailyReport sends the automated daily report to the user. -func (h *Handler) SendDailyReport(ctx context.Context, userID int64, chatID int64) { - user, err := h.DB.GetUserByChatID(chatID) - if err != nil { - slog.Error("daily report: user not found", "user_id", userID) - return - } - loc, err := time.LoadLocation(user.Timezone) - if err != nil { - loc = time.UTC - } - now := time.Now().In(loc) - today := now.Format(DateLayout) - - day, err := h.DB.GetOrCreateDay(user.ID, today) - if err != nil { - slog.Error("daily report: get day", "error", err) - return - } - events, err := h.DB.EventsForDayByDayID(day.ID) - if err != nil { - slog.Error("daily report: get events", "error", err) - return - } - text := h.buildDailyReport(user, day, events) - _, err = h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text}) - if err != nil { - slog.Error("daily report: send message failed", "chat_id", chatID, "error", err) - return - } - if err := h.DB.MarkReportSent(user.ID, today); err != nil { - slog.Error("daily report: mark sent failed", "chat_id", chatID, "error", err) - } -} diff --git a/internal/bot/rpg.go b/internal/bot/rpg.go deleted file mode 100644 index 7d13b55..0000000 --- a/internal/bot/rpg.go +++ /dev/null @@ -1,450 +0,0 @@ -package bot - -import ( - "context" - "fmt" - "log/slog" - "strings" - "time" - - "github.com/go-telegram/bot/models" - - "worktimeBot/internal/db" -) - -// 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 -} - -// 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 -} - -// computeStreakFromDates computes current and longest streak from a sorted list of date strings. -// today is the reference date for the current streak. -func computeStreakFromDates(dates []string, today string) (current, longest int) { - if len(dates) == 0 { - return 0, 0 - } - - dateSet := make(map[string]struct{}, len(dates)) - for _, d := range dates { - dateSet[d] = struct{}{} - } - - // Current streak: count consecutive days backwards from today - if _, ok := dateSet[today]; ok { - current = 1 - for i := 1; ; i++ { - t, _ := time.Parse(DateLayout, today) - prev := t.AddDate(0, 0, -i).Format(DateLayout) - if _, ok := dateSet[prev]; ok { - current++ - } else { - break - } - } - } - - // Longest streak: find longest consecutive run in sorted dates - longest = 0 - run := 1 - for i := 1; i < len(dates); i++ { - prev, _ := time.Parse(DateLayout, dates[i-1]) - curr, _ := time.Parse(DateLayout, dates[i]) - if curr.Sub(prev).Hours() == 24 { - run++ - } else { - if run > longest { - longest = run - } - run = 1 - } - } - if run > longest { - longest = run - } - - return current, longest -} - -// computeAndAwardXP calculates XP for a work session and persists it. -// stats must already have the streak updated before calling. -// Returns the new total XP, whether the user leveled up, and the new level. -func (h *Handler) computeAndAwardXP(stats *db.RPGStats, userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, err error) { - 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. -// Only called from clockOut — incremental update, not a full scan. -func (h *Handler) updateStreak(stats *db.RPGStats, userID int64, date string) { - t, err := time.Parse(DateLayout, date) - if err != nil { - return - } - 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 - } -} - -// recalcDayWorkSeconds recomputes work_seconds for a single day from events and updates daily_xp. -func (h *Handler) recalcDayWorkSeconds(userID int64, date string, loc *time.Location) { - events, err := h.DB.EventsForDayByDate(userID, date) - if err != nil { - return - } - day, err := h.DB.GetDay(userID, date) - if err != nil { - return - } - 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) - if err := h.DB.SetDailyWorkSeconds(userID, date, totals.TotalSeconds); err != nil { - slog.Warn("failed to set daily work seconds", "user_id", userID, "date", date, "error", err) - } -} - -// recalcAggregates recomputes total_work_seconds and streak from scratch after event edits. -func (h *Handler) recalcAggregates(userID int64, loc *time.Location) { - total, err := h.DB.SumDailyWorkSeconds(userID) - if err != nil { - slog.Warn("failed to sum daily work seconds", "user_id", userID, "error", err) - return - } - - stats, err := h.DB.GetOrCreateRPGStats(userID) - if err != nil { - slog.Warn("failed to get rpg stats for recalc", "user_id", userID, "error", err) - return - } - stats.TotalWorkSeconds = total - - // Recompute streak from scratch - dates, err := h.DB.GetDatesWithEvents(userID) - today := time.Now().In(loc).Format(DateLayout) - if err == nil { - stats.CurrentStreak, stats.LongestStreak = computeStreakFromDates(dates, today) - } else { - stats.CurrentStreak = 0 - stats.LongestStreak = 0 - } - - if err := h.DB.UpdateRPGStats(stats); err != nil { - slog.Warn("failed to update rpg stats after recalc", "user_id", userID, "error", err) - } -} - -// checkAchievements evaluates and unlocks any newly earned achievements. -func (h *Handler) checkAchievements(userID int64, stats *db.RPGStats, workSeconds int64, date string, isWeekend bool, totalHours float64) []string { - unlocker := newAchievementUnlocker(h, userID) - h.checkEarlyAchievements(unlocker, workSeconds) - h.checkEventTimeAchievements(unlocker, userID, date) - h.checkStreakAchievements(unlocker, stats) - h.checkHourAchievements(unlocker, stats, totalHours) - 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) { - 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, loc *time.Location) 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 - } - loc := loadLocation(user.Timezone) - return h.buildRPGStatus(user.ID, loc), nil -} - -// handleRPGCmd handles /rpg — shows stats with an Achievements button. -func (h *Handler) handleRPGCmd(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.RPGEnabled { - h.sendText(ctx, msg.Chat.ID, "RPG system is disabled. Enable it in Settings or use /rpgtoggle.") - return - } - loc := loadLocation(user.Timezone) - text := h.buildRPGStatus(user.ID, loc) - kb := models.InlineKeyboardMarkup{ - InlineKeyboard: [][]models.InlineKeyboardButton{ - {{Text: "Achievements", CallbackData: "rpg_achievements"}}, - {{Text: "Back to Menu", CallbackData: "back_menu"}}, - }, - } - h.sendWithKB(ctx, msg.Chat.ID, text, kb) -} - -// rpgAchievementsCallback shows achievements from the RPG view. -func (h *Handler) rpgAchievementsCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { - defer h.answerCb(ctx, callbackID) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - text := h.buildAchievementsList(user.ID) - kb := models.InlineKeyboardMarkup{ - InlineKeyboard: [][]models.InlineKeyboardButton{ - {{Text: "Back to RPG", CallbackData: "rpg_back"}}, - }, - } - h.editText(ctx, chatID, msgID, text, &kb) -} - -// rpgBackCallback returns from achievements to the RPG stats view. -func (h *Handler) rpgBackCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { - defer h.answerCb(ctx, callbackID) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - loc := loadLocation(user.Timezone) - text := h.buildRPGStatus(user.ID, loc) - kb := models.InlineKeyboardMarkup{ - InlineKeyboard: [][]models.InlineKeyboardButton{ - {{Text: "Achievements", CallbackData: "rpg_achievements"}}, - {{Text: "Back to Menu", CallbackData: "back_menu"}}, - }, - } - h.editText(ctx, chatID, msgID, text, &kb) -} - -// 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/salary.go b/internal/bot/salary.go deleted file mode 100644 index a3af332..0000000 --- a/internal/bot/salary.go +++ /dev/null @@ -1,304 +0,0 @@ -package bot - -import ( - "context" - "fmt" - "math" - "strings" - "time" - "unicode" - - "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)) -} - -// parseCurrencyInput validates and normalizes currency input (symbol + code). -func parseCurrencyInput(s string) (string, string) { - parts := strings.Fields(s) - if len(parts) < 2 { - return "", "Please provide both symbol and code.\nUsage: /setcurrency \nExamples:\n/setcurrency $ USD\n/setcurrency T Toman\n/setcurrency IRR Rial" - } - for _, p := range parts[:2] { - if len(p) > 10 || strings.ContainsFunc(p, unicode.IsControl) { - return "", "Invalid currency. Use short printable strings like $ USD, T Toman, IRR Rial." - } - } - return parts[0] + " " + parts[1], "" -} - -// handleSetCurrencyMsg handles /setcurrency . -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 \nExamples:\n/setcurrency $ USD\n/setcurrency T Toman\n/setcurrency IRR Rial", st.Currency)) - return - } - currency, errMsg := parseCurrencyInput(args) - if errMsg != "" { - h.sendText(ctx, msg.Chat.ID, errMsg) - 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 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 the currency picker (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, kb := buildCurrencyKeyboard(st.Currency) - h.editText(ctx, chatID, msgID, text, &kb) -} - -// buildCurrencyKeyboard returns the currency preset picker keyboard. -func buildCurrencyKeyboard(current string) (string, models.InlineKeyboardMarkup) { - presets := []string{"$ USD", "T Toman", "IRR Rial", "€ EUR", "£ GBP"} - rows := [][]models.InlineKeyboardButton{} - for _, p := range presets { - label := p - if p == current { - label = "> " + label - } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: label, CallbackData: "currency_" + p}, - }) - } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: "Back to Settings", CallbackData: "back_settings"}, - }) - return "Select currency:", models.InlineKeyboardMarkup{InlineKeyboard: rows} -} - -// selectCurrency saves the chosen currency preset. -func (h *Handler) selectCurrency(ctx context.Context, chatID int64, msgID int, callbackID, currency 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 - } - st.Currency = currency - 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) -} - -// 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/settings.go b/internal/bot/settings.go deleted file mode 100644 index 00c33de..0000000 --- a/internal/bot/settings.go +++ /dev/null @@ -1,684 +0,0 @@ -package bot - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "github.com/go-telegram/bot/models" - - "worktimeBot/internal/db" -) - -// 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 { - args = strings.TrimSpace(msg.Text[15:]) - } - 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 report time: %s\nUsage: /setreporttime HH:MM (24-hour)", st.ReportTime)) - return - } - if len(args) != 5 || args[2] != ':' { - h.sendText(ctx, msg.Chat.ID, "Invalid format. Usage: /setreporttime HH:MM (24-hour)") - return - } - hours, err1 := strconv.Atoi(args[:2]) - mins, err2 := strconv.Atoi(args[3:]) - if err1 != nil || err2 != nil || hours < 0 || hours > 23 || mins < 0 || mins > 59 { - h.sendText(ctx, msg.Chat.ID, "Invalid time. Usage: /setreporttime HH:MM (24-hour)") - return - } - user, err := h.getOrCreateUser(msg.Chat.ID) - if err != nil { - h.sendText(ctx, msg.Chat.ID, "Error updating report time") - return - } - 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 - } - h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Report time set to %s", args)) -} - -// handleAccentMsg shows the accent color picker. -func (h *Handler) handleAccentMsg(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 profile") - return - } - text, kb := h.buildAccentKeyboard(st) - h.sendWithKB(ctx, msg.Chat.ID, text, kb) -} - -// handleSetTimezone validates and updates the user's IANA timezone. -func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message) { - args := "" - if len(msg.Text) > 13 { // "/settimezone " is 12 chars - args = strings.TrimSpace(msg.Text[13:]) - } - if args == "" { - user, err := h.getOrCreateUser(msg.Chat.ID) - if err != nil { - h.sendText(ctx, msg.Chat.ID, "Error loading profile") - return - } - h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current timezone: %s\nUsage: /settimezone \nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone)) - return - } - loc, err := time.LoadLocation(args) - if err != nil { - h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran, Europe/London", args)) - return - } - user, err := h.getOrCreateUser(msg.Chat.ID) - if err != nil { - h.sendText(ctx, msg.Chat.ID, "Error loading profile") - return - } - user.Timezone = args - if err := h.DB.UpdateUser(user); err != nil { - h.sendText(ctx, msg.Chat.ID, "Error saving timezone") - return - } - now := time.Now().In(loc) - 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 defaultBreakThreshold - } - 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 defaultBreakThreshold - } - return day.MinBreakThreshold -} - -// handleSetBreakMsg sets the minimum break threshold for today. -func (h *Handler) handleSetBreakMsg(ctx context.Context, msg *models.Message) { - args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setbreak")) - if args == "" { - _, day, err := h.getUserToday(msg.Chat.ID) - if err != nil { - h.sendText(ctx, msg.Chat.ID, "Error loading profile") - return - } - h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current break threshold: %dm\nUsage: /setbreak (e.g. /setbreak 15)", day.MinBreakThreshold/60)) - return - } - mins, err := strconv.Atoi(args) - if err != nil || mins < 0 || mins > 480 { - h.sendText(ctx, msg.Chat.ID, "Invalid value. Must be between 0 and 480 minutes (8 hours).") - return - } - user, err := h.getOrCreateUser(msg.Chat.ID) - if err != nil { - h.sendText(ctx, msg.Chat.ID, "Error loading profile") - return - } - loc := loadLocation(user.Timezone) - now := time.Now().In(loc) - 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") - return - } - day.MinBreakThreshold = int64(mins) * 60 - if err := h.DB.UpdateDay(day); err != nil { - h.sendText(ctx, msg.Chat.ID, "Error saving break threshold") - return - } - h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Break threshold set to %d minutes", mins)) -} - -// settingsCallback shows the settings inline menu. -func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, callbackID 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 - } - bt := h.getTodayBreakThreshold(chatID) - 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, st *db.UserSettings, breakThreshold int64) (string, models.InlineKeyboardMarkup) { - reportStatus := "off" - if st.ReportEnabled { - reportStatus = "on" - } - rpgStatus := "off" - if st.RPGEnabled { - rpgStatus = "on" - } - leagueStatus := "off" - if !st.RPGEnabled { - leagueStatus = "off" - } else if st.LeagueOptIn { - leagueStatus = "on" - } - rows := [][]models.InlineKeyboardButton{ - { - {Text: fmt.Sprintf("Username: %s", user.Username), CallbackData: "username"}, - }, - { - {Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"}, - {Text: fmt.Sprintf("Calendar: %s", user.Calendar), CallbackData: "caltype"}, - }, - { - {Text: fmt.Sprintf("Report: %s", reportStatus), CallbackData: "reporttoggle"}, - {Text: fmt.Sprintf("Report time: %s", st.ReportTime), CallbackData: "reporttime"}, - }, - { - {Text: fmt.Sprintf("Break: %dm", breakThreshold/60), CallbackData: "breakthreshold"}, - {Text: fmt.Sprintf("Accent: %s", st.ExportAccent), CallbackData: "accent"}, - }, - { - {Text: fmt.Sprintf("RPG: %s", rpgStatus), CallbackData: "rpgtoggle"}, - {Text: fmt.Sprintf("League: %s", leagueStatus), CallbackData: "leaguetoggle"}, - }, - { - {Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"}, - {Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"}, - }, - {{Text: "Back to Menu", CallbackData: "back_menu"}}, - } - return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows} -} - -// usernameCallback shows the current username and how to change it. -func (h *Handler) usernameCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { - defer h.answerCb(ctx, callbackID) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - text := fmt.Sprintf("Username: %s\n\nUse /setusername to change it.", user.Username) - kb := models.InlineKeyboardMarkup{ - InlineKeyboard: [][]models.InlineKeyboardButton{ - {{Text: "Back to Settings", CallbackData: "back_settings"}}, - }, - } - h.editText(ctx, chatID, msgID, text, &kb) -} - -// accentCallback shows the accent color picker (inline edit). -func (h *Handler) accentCallback(ctx context.Context, chatID int64, msgID int, callbackID 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 - } - 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) { - defer h.answerCb(ctx, callbackID) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - text := fmt.Sprintf("Current timezone: %s\n\nUse /settimezone to change it.\nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone) - kb := models.InlineKeyboardMarkup{ - InlineKeyboard: [][]models.InlineKeyboardButton{ - {{Text: "Back to Settings", CallbackData: "back_settings"}}, - }, - } - h.editText(ctx, chatID, msgID, text, &kb) -} - -// buildAccentKeyboard returns the accent color picker keyboard. -func (h *Handler) buildAccentKeyboard(st *db.UserSettings) (string, models.InlineKeyboardMarkup) { - type accentOption struct { - name string - color string - } - accents := []accentOption{ - {"ocean", "Blue"}, - {"beach", "Warm"}, - {"rose", "Pink"}, - {"catppuccin", "Purple"}, - } - rows := [][]models.InlineKeyboardButton{} - for _, a := range accents { - label := fmt.Sprintf("%s (%s)", a.name, a.color) - if a.name == st.ExportAccent { - label = "> " + label - } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: label, CallbackData: "accent_" + a.name}, - }) - } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: "Back to Settings", CallbackData: "back_settings"}, - }) - return "Select accent color:", models.InlineKeyboardMarkup{InlineKeyboard: rows} -} - -// selectAccent sets the user's export accent color after validation. -func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, callbackID, name string) { - defer h.answerCb(ctx, callbackID) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - valid := map[string]bool{"ocean": true, "beach": true, "rose": true, "catppuccin": true} - if !valid[name] { - return - } - 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{ - InlineKeyboard: [][]models.InlineKeyboardButton{ - {{Text: "Back to Settings", CallbackData: "back_settings"}}, - }, - } - h.editText(ctx, chatID, msgID, fmt.Sprintf("Accent set to: %s", name), &kb) -} - -// reportTimeCallback shows the current report time setting. -func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID int, callbackID 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 - } - 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"}}, - }, - } - h.editText(ctx, chatID, msgID, text, &kb) -} - -// breakThresholdCallback shows the break threshold picker. -func (h *Handler) breakThresholdCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { - defer h.answerCb(ctx, callbackID) - bt := h.getTodayBreakThreshold(chatID) - text, kb := h.buildBreakThresholdKeyboard(bt) - h.editText(ctx, chatID, msgID, text, &kb) -} - -// buildBreakThresholdKeyboard returns the break threshold picker. -func (h *Handler) buildBreakThresholdKeyboard(current int64) (string, models.InlineKeyboardMarkup) { - values := []int{0, 5, 10, 15, 30, 60} - labels := map[int]string{ - 0: "0m", - 5: "5m", - 10: "10m", - 15: "15m", - 30: "30m", - 60: "1h", - } - rows := [][]models.InlineKeyboardButton{} - for _, v := range values { - label := labels[v] - if int(current/60) == v { - label = "> " + label - } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: label, CallbackData: fmt.Sprintf("breakthreshold_%d", v)}, - }) - } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: "Back to Settings", CallbackData: "back_settings"}, - }) - return "Select break threshold:", models.InlineKeyboardMarkup{InlineKeyboard: rows} -} - -// selectBreakThreshold saves the chosen break threshold and returns to settings. -func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID int, callbackID, valStr string) { - defer h.answerCb(ctx, callbackID) - mins, err := strconv.Atoi(valStr) - if err != nil || mins < 0 || mins > maxBreakThresholdMins { - return - } - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - now := time.Now() - date := now.Format(DateLayout) - day, err := h.DB.GetOrCreateDay(user.ID, date) - if err != nil { - return - } - day.MinBreakThreshold = int64(mins) * 60 - if err := h.DB.UpdateDay(day); err != nil { - return - } - bt := h.getTodayBreakThreshold(chatID) - 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) { - 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, 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) { - defer h.answerCb(ctx, callbackID) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - text, kb := h.buildCalendarKeyboard(user) - h.editText(ctx, chatID, msgID, text, &kb) -} - -// buildCalendarKeyboard returns the calendar type selector keyboard. -func (h *Handler) buildCalendarKeyboard(user *db.User) (string, models.InlineKeyboardMarkup) { - cals := []string{"gregorian", "jalali", "hijri"} - calLabels := map[string]string{ - "gregorian": "Gregorian", - "jalali": "Jalali", - "hijri": "Hijri", - } - rows := [][]models.InlineKeyboardButton{} - for _, c := range cals { - label := calLabels[c] - if c == user.Calendar { - label = "> " + label - } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: label, CallbackData: "caltype_" + c}, - }) - } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: "Back to Settings", CallbackData: "back_settings"}, - }) - return "Select calendar type:", models.InlineKeyboardMarkup{InlineKeyboard: rows} -} - -// selectCalendar sets the user's calendar after validation. -func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, callbackID, name string) { - defer h.answerCb(ctx, callbackID) - valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true} - if !valid[name] { - return - } - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - user.Calendar = name - if err := h.DB.UpdateUser(user); err != nil { - return - } - bt := h.getTodayBreakThreshold(chatID) - 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) -} - -// handleLeagueToggleCmd handles /leaguetoggle — checks RPG is enabled first. -func (h *Handler) handleLeagueToggleCmd(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.RPGEnabled { - h.sendText(ctx, msg.Chat.ID, "League requires the RPG system. Enable RPG first with /rpgtoggle.") - 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 - } - st, err := h.DB.GetOrCreateSettings(user.ID) - if err != nil { - return - } - if !st.RPGEnabled { - h.editText(ctx, chatID, msgID, "League requires the RPG system. Enable RPG first in Settings.", 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) - } else { - st.LeagueOptIn = false - } - case "league_enable": - if value && !st.RPGEnabled { - h.answerCbWithText(ctx, callbackID, "Enable RPG first") - return - } - 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_test.go b/internal/bot/summary_test.go deleted file mode 100644 index 1981b02..0000000 --- a/internal/bot/summary_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package bot - -import ( - "testing" -) - -func TestFormatMonthTitle(t *testing.T) { - tests := []struct { - cal string - year, m int - want string - }{ - {"gregorian", 2026, 6, "June 2026"}, - {"gregorian", 2024, 1, "January 2024"}, - {"jalali", 1404, 1, "Farvardin 1404"}, - {"jalali", 1405, 12, "Esfand 1405"}, - {"hijri", 1447, 1, "Muharram 1447"}, - {"hijri", 1447, 12, "Dhu al-Hijjah 1447"}, - } - for _, tc := range tests { - t.Run(tc.want, func(t *testing.T) { - got := formatMonthTitle(tc.cal, tc.year, tc.m) - if got != tc.want { - t.Errorf("formatMonthTitle(%q, %d, %d) = %q, want %q", - tc.cal, tc.year, tc.m, got, tc.want) - } - }) - } -} diff --git a/internal/bot/totals.go b/internal/bot/totals.go deleted file mode 100644 index 1a8d9ed..0000000 --- a/internal/bot/totals.go +++ /dev/null @@ -1,90 +0,0 @@ -package bot - -import ( - "sort" - - "worktimeBot/internal/db" -) - -// State represents the current tracking state during daily totals computation. -type State int - -const ( - // StateIdle indicates no active work session. - StateIdle State = iota - // StateWorking indicates an active work session. - StateWorking - // StateOnBreak indicates an active break session. - StateOnBreak -) - -// DailyTotals holds the computed total work and break seconds for a single day. -type DailyTotals struct { - TotalSeconds int64 - BreakSeconds int64 -} - -// ComputeDailyTotals calculates the total work and break time from a list of events. -// It sorts events chronologically, inserts synthetic start/end events when needed, -// and tracks state transitions between idle, working, and on-break. -func ComputeDailyTotals(events []db.Event, minBreakThreshold int64, dayStart, dayEnd int64) DailyTotals { - if len(events) == 0 { - return DailyTotals{} - } - sorted := make([]db.Event, len(events)) - copy(sorted, events) - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].OccurredAt < sorted[j].OccurredAt - }) - - if dayStart > 0 && len(sorted) > 0 && sorted[0].EventType == "out" { - first := db.Event{EventType: "in", OccurredAt: dayStart} - sorted = append([]db.Event{first}, sorted...) - } - if dayEnd > 0 && len(sorted) > 0 && sorted[len(sorted)-1].EventType == "in" { - last := db.Event{EventType: "out", OccurredAt: dayEnd} - sorted = append(sorted, last) - } - - var workTotal, breakTotal int64 - var workStart, breakStart int64 - state := StateIdle - - for _, e := range sorted { - switch e.EventType { - case "in": - switch state { - case StateIdle: - workStart = e.OccurredAt - state = StateWorking - case StateOnBreak: - breakDuration := e.OccurredAt - breakStart - if minBreakThreshold > 0 && breakDuration <= minBreakThreshold { - workStart = breakStart - } else { - breakTotal += breakDuration - workStart = e.OccurredAt - } - state = StateWorking - } - case "out": - switch state { - case StateWorking: - workTotal += e.OccurredAt - workStart - workStart = 0 - breakStart = e.OccurredAt - state = StateOnBreak - case StateIdle: - breakStart = e.OccurredAt - state = StateOnBreak - case StateOnBreak: - breakStart = e.OccurredAt - } - } - } - - return DailyTotals{ - TotalSeconds: workTotal, - BreakSeconds: breakTotal, - } -} diff --git a/internal/db/store.go b/internal/db/store.go deleted file mode 100644 index a569c9a..0000000 --- a/internal/db/store.go +++ /dev/null @@ -1,1087 +0,0 @@ -// Package db provides the SQLite-backed data store for users, work types, days, and events. -package db - -import ( - "database/sql" - "embed" - "errors" - "fmt" - "log/slog" - "time" - - "github.com/pressly/goose/v3" - _ "modernc.org/sqlite" -) - -const driverName = "sqlite" - -//go:embed migrations/*.sql -var migrationFS embed.FS - -// Store wraps a SQLite database connection and provides methods for all data access. -type Store struct { - db *sql.DB -} - -// User represents a bot user with identity, timezone, and calendar preferences. -type User struct { - 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 - 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 - Name string - CreatedAt int64 -} - -// Day represents a single day entry for a user, including day-off status and work type. -type Day struct { - ID int64 - UserID int64 - Date string - IsDayOff bool - DayOffReason string - CurrentWorkTypeID int64 - MinBreakThreshold int64 - CreatedAt int64 - UpdatedAt int64 -} - -// Event represents a single timestamped event ("in" or "out") for a user on a given day. -type Event struct { - ID int64 - UserID int64 - DayID int64 - EventType string - WorkTypeID *int64 - OccurredAt int64 - Note string - CreatedAt int64 -} - -// NewStore opens or creates the SQLite database at path, runs migrations, and returns a Store. -func NewStore(path string) (*Store, error) { - db, err := sql.Open(driverName, path) - if err != nil { - return nil, err - } - if err := db.Ping(); err != nil { - return nil, err - } - if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { - slog.Warn("failed to set WAL mode", "error", err) - } - if err := runMigrations(db); err != nil { - return nil, err - } - return &Store{db: db}, nil -} - -// Close closes the underlying SQLite database connection. -func (s *Store) Close() error { - return s.db.Close() -} - -// DB returns the underlying *sql.DB for advanced use. -func (s *Store) DB() *sql.DB { - return s.db -} - -func hasTable(db *sql.DB, name string) bool { - var exists int - if err := db.QueryRow("SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&exists); err != nil { - slog.Warn("hasTable query failed", "table", name, "error", err) - return false - } - return exists > 0 -} - -// runMigrations handles all database migrations using goose. -func runMigrations(db *sql.DB) error { - // Step 1: Handle the ancient single-table schema (pre-goose era). - // Rename old tables so goose can create the current schema cleanly. - needsOldMigrate := hasTable(db, "events_old") || (hasTable(db, "events") && !hasCol(db, "events", "user_id")) - if needsOldMigrate { - slog.Info("migrating from old schema to new schema") - for _, t := range []string{"events", "days_off", "settings"} { - if hasTable(db, t) { - if _, err := db.Exec("ALTER TABLE " + t + " RENAME TO " + t + "_old"); err != nil { - return err - } - } - } - } - - // Step 2: Run goose (creates goose_db_version table if needed, - // applies pending migrations). - goose.SetBaseFS(migrationFS) - if err := goose.SetDialect("sqlite3"); err != nil { - return err - } - if err := goose.Up(db, "migrations"); err != nil { - return err - } - - // Step 3: Copy old data into the freshly created tables. - if needsOldMigrate { - if err := migrateFromOldSchemaData(db); err != nil { - return err - } - } - - return nil -} - -func hasCol(db *sql.DB, table, column string) bool { - rows, err := db.Query("PRAGMA table_info(" + table + ")") - if err != nil { - slog.Warn("hasCol query failed", "table", table, "error", err) - return false - } - defer func() { - if err := rows.Close(); err != nil { - slog.Warn("hasCol rows.Close failed", "table", table, "error", err) - } - }() - for rows.Next() { - var cid int - var name, ctype string - var notnull, pk int - var dflt sql.NullString - if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { - slog.Warn("hasCol scan failed", "table", table, "error", err) - return false - } - if name == column { - return true - } - } - return false -} - -func migrateFromOldSchemaData(db *sql.DB) error { - migrateUsers(db) - migrateDaysOff(db) - migrateEvents(db) - migrateSettings(db) - - for _, t := range []string{"days_off_old", "settings_old", "events_old"} { - if hasTable(db, t) { - if _, err := db.Exec("DROP TABLE IF EXISTS " + t); err != nil { - slog.Warn("failed to drop old table", "table", t, "error", err) - } - } - } - - slog.Info("migration complete") - return nil -} - -func migrateUsers(db *sql.DB) { - if !hasTable(db, "events_old") { - return - } - rows, err := db.Query("SELECT DISTINCT chat_id FROM events_old") - if err != nil { - slog.Warn("migration: failed to query events_old for users", "error", err) - return - } - defer func() { - if err := rows.Close(); err != nil { - slog.Warn("migration: rows.Close failed in migrateUsers", "error", err) - } - }() - - for rows.Next() { - var chatID int64 - if err := rows.Scan(&chatID); err != nil { - slog.Warn("migration: scan failed in migrateUsers", "error", err) - continue - } - if _, err := db.Exec( - "INSERT OR IGNORE INTO users (chat_id, timezone) VALUES (?, 'Asia/Tehran')", - chatID, - ); err != nil { - slog.Warn("migration: failed to insert user", "chat_id", chatID, "error", err) - } - } -} - -func migrateDaysOff(db *sql.DB) { - if !hasTable(db, "days_off_old") { - return - } - rows, err := db.Query("SELECT chat_id, date, reason FROM days_off_old") - if err != nil { - slog.Warn("migration: failed to query days_off_old", "error", err) - return - } - defer func() { - if err := rows.Close(); err != nil { - slog.Warn("migration: rows.Close failed in migrateDaysOff", "error", err) - } - }() - - for rows.Next() { - var chatID int64 - var date, reason string - if err := rows.Scan(&chatID, &date, &reason); err != nil { - slog.Warn("migration: scan failed in migrateDaysOff", "error", err) - continue - } - var userID int64 - err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID) - if err != nil { - slog.Warn("migration: user not found for days_off", "chat_id", chatID, "error", err) - continue - } - if _, err := db.Exec( - "INSERT INTO days (user_id, date, is_day_off, day_off_reason) VALUES (?, ?, 1, ?) ON CONFLICT(user_id, date) DO UPDATE SET is_day_off=1, day_off_reason=excluded.day_off_reason", - userID, date, reason, - ); err != nil { - slog.Warn("migration: failed to insert day_off", "user_id", userID, "date", date, "error", err) - } - } -} - -func migrateEvents(db *sql.DB) { - if !hasTable(db, "events_old") { - return - } - rows, err := db.Query("SELECT chat_id, event_type, occurred_at, note, created_at FROM events_old ORDER BY occurred_at ASC") - if err != nil { - slog.Warn("migration: failed to query events_old", "error", err) - return - } - defer func() { - if err := rows.Close(); err != nil { - slog.Warn("migration: rows.Close failed in migrateEvents", "error", err) - } - }() - - for rows.Next() { - var chatID int64 - var eventType, note string - var occurredAt, createdAt int64 - if err := rows.Scan(&chatID, &eventType, &occurredAt, ¬e, &createdAt); err != nil { - slog.Warn("migration: scan failed in migrateEvents", "error", err) - continue - } - - var userID int64 - err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID) - if err != nil { - slog.Warn("migration: user not found for event", "chat_id", chatID, "error", err) - continue - } - - date := time.Unix(occurredAt, 0).UTC().Format("2006-01-02") - if _, err := db.Exec("INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)", userID, date); err != nil { - slog.Warn("migration: failed to insert day", "user_id", userID, "date", date, "error", err) - continue - } - - var dayID int64 - if err := db.QueryRow("SELECT id FROM days WHERE user_id=? AND date=?", userID, date).Scan(&dayID); err != nil { - slog.Warn("migration: day not found after insert", "user_id", userID, "date", date, "error", err) - continue - } - - if _, err := db.Exec( - "INSERT INTO events (user_id, day_id, event_type, occurred_at, note, created_at) VALUES (?, ?, ?, ?, ?, ?)", - userID, dayID, eventType, occurredAt, note, createdAt, - ); err != nil { - slog.Warn("migration: failed to insert event", "user_id", userID, "error", err) - } - } -} - -func migrateSettings(db *sql.DB) { - if !hasTable(db, "settings_old") { - return - } - rows, err := db.Query("SELECT chat_id, key, value FROM settings_old") - if err != nil { - slog.Warn("migration: failed to query settings_old", "error", err) - return - } - defer func() { - if err := rows.Close(); err != nil { - slog.Warn("migration: rows.Close failed in migrateSettings", "error", err) - } - }() - - for rows.Next() { - var chatID int64 - var key, value string - if err := rows.Scan(&chatID, &key, &value); err != nil { - slog.Warn("migration: scan failed in migrateSettings", "error", err) - continue - } - if key == "remote_flag" { - var userID int64 - err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID) - if err != nil { - slog.Warn("migration: user not found for setting", "chat_id", chatID, "error", err) - continue - } - wt := int64(1) - if value == "remote" { - wt = 2 - } - if _, err := db.Exec("UPDATE days SET current_work_type_id=? WHERE user_id=? AND date=?", wt, userID, time.Now().UTC().Format("2006-01-02")); err != nil { - slog.Warn("migration: failed to update work type", "user_id", userID, "error", err) - } - } - } -} - -// 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, username) VALUES (?, ?)", - chatID, randomUsername(), - ) - if err != nil { - return nil, err - } - return s.GetUserByChatID(chatID) -} - -// 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, username, timezone, is_active, calendar, created_at, updated_at - FROM users WHERE chat_id = ? - `, chatID) - var u User - err := row.Scan( - &u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive, - &u.Calendar, &u.CreatedAt, &u.UpdatedAt, - ) - if err != nil { - return nil, err - } - return &u, nil -} - -// UpdateUser persists core fields of the user record. -func (s *Store) UpdateUser(user *User) error { - _, err := s.db.Exec( - "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 -} - -// GetAllUsers returns all users in the database. -func (s *Store) GetAllUsers() ([]User, error) { - rows, err := s.db.Query(` - SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at - FROM users - `) - if err != nil { - return nil, err - } - defer rows.Close() - - var users []User - for rows.Next() { - var u User - if err := rows.Scan( - &u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive, - &u.Calendar, &u.CreatedAt, &u.UpdatedAt, - ); err != nil { - return nil, err - } - 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 and work seconds for a user/date. -// xpEarned and workSeconds are accumulated on conflict. -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 -} - -// SetDailyWorkSeconds overwrites the work_seconds for a specific day (used during recalc on edit). -func (s *Store) SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error { - _, err := s.db.Exec(` - INSERT INTO daily_xp (user_id, date, xp_earned, work_seconds) - VALUES (?, ?, 0, ?) - ON CONFLICT(user_id, date) DO UPDATE SET - work_seconds = ? - `, userID, date, workSeconds, workSeconds) - return err -} - -// SumDailyWorkSeconds returns the sum of work_seconds across all days for the user. -func (s *Store) SumDailyWorkSeconds(userID int64) (int64, error) { - var total int64 - err := s.db.QueryRow( - "SELECT COALESCE(SUM(work_seconds), 0) FROM daily_xp WHERE user_id=?", userID, - ).Scan(&total) - return total, 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") - if err != nil { - return nil, err - } - defer rows.Close() - var wts []WorkType - for rows.Next() { - var wt WorkType - if err := rows.Scan(&wt.ID, &wt.Name, &wt.CreatedAt); err != nil { - return nil, err - } - wts = append(wts, wt) - } - return wts, rows.Err() -} - -// GetWorkType returns a single work type by its ID. -func (s *Store) GetWorkType(id int64) (*WorkType, error) { - var wt WorkType - err := s.db.QueryRow("SELECT id, name, created_at FROM work_types WHERE id=?", id).Scan(&wt.ID, &wt.Name, &wt.CreatedAt) - if err != nil { - return nil, err - } - return &wt, nil -} - -// GetOrCreateDay retrieves a day record for a user/date, creating one if it does not exist. -func (s *Store) GetOrCreateDay(userID int64, date string) (*Day, error) { - _, err := s.db.Exec( - "INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)", - userID, date, - ) - if err != nil { - return nil, err - } - return s.GetDay(userID, date) -} - -// GetDay returns a single day record for the given user and date. -func (s *Store) GetDay(userID int64, date string) (*Day, error) { - row := s.db.QueryRow(` - SELECT id, user_id, date, is_day_off, day_off_reason, - current_work_type_id, min_break_threshold, created_at, updated_at - FROM days WHERE user_id=? AND date=? - `, userID, date) - var d Day - err := row.Scan( - &d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason, - &d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt, - ) - if err != nil { - return nil, err - } - return &d, nil -} - -// UpdateDay persists the given day record's editable fields. -func (s *Store) UpdateDay(day *Day) error { - _, err := s.db.Exec( - "UPDATE days SET is_day_off=?, day_off_reason=?, current_work_type_id=?, min_break_threshold=?, updated_at=unixepoch() WHERE id=?", - day.IsDayOff, day.DayOffReason, day.CurrentWorkTypeID, day.MinBreakThreshold, day.ID, - ) - return err -} - -// SetDayOff marks a user's day as a day off with an optional reason. -func (s *Store) SetDayOff(userID int64, date string, reason string) error { - _, err := s.GetOrCreateDay(userID, date) - if err != nil { - return err - } - _, err = s.db.Exec( - "UPDATE days SET is_day_off=1, day_off_reason=?, updated_at=unixepoch() WHERE user_id=? AND date=?", - reason, userID, date, - ) - return err -} - -// RemoveDayOff clears the day-off flag for a user on the given date. -func (s *Store) RemoveDayOff(userID int64, date string) error { - _, err := s.db.Exec( - "UPDATE days SET is_day_off=0, day_off_reason='', updated_at=unixepoch() WHERE user_id=? AND date=?", - userID, date, - ) - return err -} - -// IsDayOff checks whether the given date is a day off for the user. -func (s *Store) IsDayOff(userID int64, date string) (bool, error) { - var isOff bool - err := s.db.QueryRow( - "SELECT is_day_off FROM days WHERE user_id=? AND date=?", - userID, date, - ).Scan(&isOff) - if err == sql.ErrNoRows { - return false, nil - } - if err != nil { - return false, err - } - return isOff, nil -} - -// GetUserDaysInRange returns all day records for a user within a date range (inclusive). -func (s *Store) GetUserDaysInRange(userID int64, startDate, endDate string) ([]Day, error) { - rows, err := s.db.Query(` - SELECT id, user_id, date, is_day_off, day_off_reason, - current_work_type_id, min_break_threshold, created_at, updated_at - FROM days - WHERE user_id=? AND date >= ? AND date <= ? - ORDER BY date ASC - `, userID, startDate, endDate) - if err != nil { - return nil, err - } - defer rows.Close() - var days []Day - for rows.Next() { - var d Day - if err := rows.Scan( - &d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason, - &d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt, - ); err != nil { - return nil, err - } - days = append(days, d) - } - return days, rows.Err() -} - -// GetDatesWithEvents returns all distinct dates (from the days table) on which -// the user has at least one event, ordered ascending. -func (s *Store) GetDatesWithEvents(userID int64) ([]string, error) { - rows, err := s.db.Query(` - SELECT DISTINCT d.date - FROM days d - JOIN events e ON e.day_id = d.id - WHERE d.user_id = ? - ORDER BY d.date ASC - `, userID) - if err != nil { - return nil, err - } - defer rows.Close() - var dates []string - for rows.Next() { - var date string - if err := rows.Scan(&date); err != nil { - return nil, err - } - dates = append(dates, date) - } - return dates, rows.Err() -} - -// CreateEvent inserts a new event record (typically "in" or "out"). -func (s *Store) CreateEvent(userID int64, dayID int64, eventType string, workTypeID *int64, occurredAt int64, note string) error { - _, err := s.db.Exec( - "INSERT INTO events (user_id, day_id, event_type, work_type_id, occurred_at, note) VALUES (?, ?, ?, ?, ?, ?)", - userID, dayID, eventType, workTypeID, occurredAt, note, - ) - return err -} - -// GetDistinctEventDates returns distinct dates from the days table on which the user has events within range. -func (s *Store) GetDistinctEventDates(userID int64, startDate, endDate string) ([]string, error) { - rows, err := s.db.Query( - "SELECT DISTINCT d.date FROM days d JOIN events e ON e.day_id = d.id WHERE d.user_id=? AND d.date >= ? AND d.date <= ? ORDER BY d.date", - userID, startDate, endDate, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var dates []string - for rows.Next() { - var d string - if err := rows.Scan(&d); err != nil { - return nil, err - } - dates = append(dates, d) - } - return dates, rows.Err() -} - -// UpdateEventWorkType sets the work type for an event. -func (s *Store) UpdateEventWorkType(eventID, workTypeID int64) error { - _, err := s.db.Exec("UPDATE events SET work_type_id=? WHERE id=?", workTypeID, eventID) - return err -} - -// UpdateEventTime sets the occurred_at timestamp for an event. -func (s *Store) UpdateEventTime(eventID, occurredAt int64) error { - _, err := s.db.Exec("UPDATE events SET occurred_at=? WHERE id=?", occurredAt, eventID) - return err -} - -// DeleteEvent removes an event by ID. -func (s *Store) DeleteEvent(eventID int64) error { - _, err := s.db.Exec("DELETE FROM events WHERE id=?", eventID) - return err -} - -// GetEventByID returns a single event scoped to a user. -func (s *Store) GetEventByID(eventID, userID int64) (*Event, error) { - row := s.db.QueryRow( - "SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at FROM events WHERE id=? AND user_id=?", - eventID, userID, - ) - var e Event - var wt sql.NullInt64 - if err := row.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { - return nil, err - } - if wt.Valid { - e.WorkTypeID = &wt.Int64 - } - return &e, nil -} - -// GetLastEvent returns the most recent event for the user, ordered by occurrence time. -func (s *Store) GetLastEvent(userID int64) (*Event, error) { - row := s.db.QueryRow(` - SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at - FROM events - WHERE user_id = ? - ORDER BY occurred_at DESC - LIMIT 1 - `, userID) - var e Event - var wt sql.NullInt64 - if err := row.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, sql.ErrNoRows - } - return nil, err - } - if wt.Valid { - e.WorkTypeID = &wt.Int64 - } - return &e, nil -} - -// EventsForDayByDate returns all events for a user on a specific date, ordered by occurrence. -func (s *Store) EventsForDayByDate(userID int64, date string) ([]Event, error) { - rows, err := s.db.Query(` - SELECT e.id, e.user_id, e.day_id, e.event_type, e.work_type_id, e.occurred_at, e.note, e.created_at - FROM events e - JOIN days d ON d.id = e.day_id - WHERE d.user_id = ? AND d.date = ? - ORDER BY e.occurred_at ASC - `, userID, date) - if err != nil { - return nil, err - } - defer rows.Close() - var events []Event - for rows.Next() { - var e Event - var wt sql.NullInt64 - if err := rows.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { - return nil, err - } - if wt.Valid { - e.WorkTypeID = &wt.Int64 - } - events = append(events, e) - } - return events, rows.Err() -} - -// SetEventNote updates the note for an event. -func (s *Store) SetEventNote(eventID int64, note string) error { - _, err := s.db.Exec("UPDATE events SET note=? WHERE id=?", note, eventID) - return err -} - -// EventsForDayByDayID returns all events for a day record, ordered by occurrence. -func (s *Store) EventsForDayByDayID(dayID int64) ([]Event, error) { - rows, err := s.db.Query(` - SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at - FROM events - WHERE day_id = ? - ORDER BY occurred_at ASC - `, dayID) - if err != nil { - return nil, err - } - defer rows.Close() - var events []Event - for rows.Next() { - var e Event - var wt sql.NullInt64 - if err := rows.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { - return nil, err - } - if wt.Valid { - e.WorkTypeID = &wt.Int64 - } - events = append(events, e) - } - return events, rows.Err() -} diff --git a/internal/domain/burnout.go b/internal/domain/burnout.go new file mode 100644 index 0000000..3927fff --- /dev/null +++ b/internal/domain/burnout.go @@ -0,0 +1,24 @@ +package domain + +import ( + "fmt" + "math" +) + +// ComputeTrendFromAvgs is a pure version of the trend computation for testing. +func ComputeTrendFromAvgs(recentAvg, olderAvg float64) (pts int, direction string) { + if olderAvg < 1 { + if recentAvg > 0 { + return 15, "new" + } + return 5, "stable" + } + changePct := math.Round((recentAvg - olderAvg) / olderAvg * 100) + if recentAvg > olderAvg*1.2 { + return 15, fmt.Sprintf("+%.0f%%", changePct) + } + if olderAvg > recentAvg*1.2 { + return 0, fmt.Sprintf("%.0f%%", changePct) + } + return 5, "stable" +} diff --git a/internal/domain/constants.go b/internal/domain/constants.go new file mode 100644 index 0000000..569be47 --- /dev/null +++ b/internal/domain/constants.go @@ -0,0 +1,19 @@ +package domain + +import "time" + +const ( + DateLayout = "2006-01-02" + TimeLayout = "15:04" + SecondsPerHour = 3600 + SecondsPerDay = 86400 + DefaultBreakThreshold = 300 + MaxBreakThresholdMins = 480 + StreakBonusCap = 500 + XPCurveMultiplier = 50 + SalaryRoundFactor = 100 + MaxHourlyRate = 999999 + RateLimitInterval = 1 * time.Second + MaxNoteLength = 500 + MaxUsernameLength = 64 +) diff --git a/internal/domain/dateutil.go b/internal/domain/dateutil.go new file mode 100644 index 0000000..86520da --- /dev/null +++ b/internal/domain/dateutil.go @@ -0,0 +1,326 @@ +package domain + +import "fmt" + +var GregMonthDays = []int{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} +var JalaliMonthDays = []int{31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29} + +var GregMonthNames = []string{ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +} +var JalaliMonthNames = []string{ + "Farvardin", "Ordibehesht", "Khordad", "Tir", "Mordad", "Shahrivar", + "Mehr", "Aban", "Azar", "Dey", "Bahman", "Esfand", +} +var HijriMonthNames = []string{ + "Muharram", "Safar", "Rabi' I", "Rabi' II", "Jumada I", "Jumada II", + "Rajab", "Sha'ban", "Ramadan", "Shawwal", "Dhu al-Qa'dah", "Dhu al-Hijjah", +} + +func IsGregorianLeap(year int) bool { + return year%4 == 0 && (year%100 != 0 || year%400 == 0) +} + +func IsJalaliLeap(year int) bool { + r := year % 33 + return r == 1 || r == 5 || r == 9 || r == 13 || r == 17 || r == 22 || r == 26 || r == 30 +} + +func MonthLenGreg(year, month int) int { + if month == 2 && IsGregorianLeap(year) { + return 29 + } + return GregMonthDays[month-1] +} + +func MonthLenJalali(year, month int) int { + if month == 12 && IsJalaliLeap(year) { + return 30 + } + return JalaliMonthDays[month-1] +} + +func IsHijriLeap(year int) bool { + switch year % 30 { + case 2, 5, 7, 10, 13, 16, 18, 21, 24, 27, 29: + return true + } + return false +} + +func MonthLenHijri(year, month int) int { + if month == 12 && IsHijriLeap(year) { + return 30 + } + if month%2 == 1 { + return 30 + } + return 29 +} + +const jalaliEpochJDN = 1948320 + +func GregorianToJDN(gy, gm, gd int) int { + a := (14 - gm) / 12 + y := gy + 4800 - a + m := gm + 12*a - 3 + return gd + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045 +} + +func JDNToGregorian(jdn int) (int, int, int) { + f := jdn + 1401 + ((4*jdn+274277)/146097)*3/4 - 38 + e := 4*f + 3 + g := (e % 1461) / 4 + h := 5*g + 2 + day := (h%153)/5 + 1 + month := ((h/153 + 2) % 12) + 1 + year := e/1461 - 4716 + (14-month)/12 + return year, month, day +} + +func GregorianToJalali(gy, gm, gd int) (int, int, int) { + jdn := GregorianToJDN(gy, gm, gd) + days := jdn - jalaliEpochJDN + if days < 0 { + return 0, 0, 0 + } + jy := 1 + for { + yearDays := 365 + if IsJalaliLeap(jy) { + yearDays = 366 + } + if days < yearDays { + break + } + days -= yearDays + jy++ + } + jm := 1 + for mm := 0; mm < 12; mm++ { + md := MonthLenJalali(jy, mm+1) + if days < md { + break + } + days -= md + jm++ + } + jd := days + 1 + return jy, jm, jd +} + +func JalaliToGregorian(jy, jm, jd int) (int, int, int) { + days := 0 + for y := 1; y < jy; y++ { + if IsJalaliLeap(y) { + days += 366 + } else { + days += 365 + } + } + for m := 1; m < jm; m++ { + days += MonthLenJalali(jy, m) + } + days += jd - 1 + jdn := jalaliEpochJDN + days + return JDNToGregorian(jdn) +} + +func HijriToGregorian(hy, hm, hd int) (int, int, int) { + days := (hy-1)*354 + (hy-1)*11/30 + for m := 1; m < hm; m++ { + days += MonthLenHijri(hy, m) + } + days += hd - 1 + jdn := 1948440 + days + f := jdn + 1401 + ((4*jdn+274277)/146097)*3/4 - 38 + e := 4*f + 3 + g := (e % 1461) / 4 + h := 5*g + 2 + day := (h%153)/5 + 1 + month := ((h/153 + 2) % 12) + 1 + year := e/1461 - 4716 + (14-month)/12 + return year, month, day +} + +func GregorianToHijri(gy, gm, gd int) (int, int, int) { + jdn := GregorianToJDN(gy, gm, gd) + days := jdn - 1948440 + hy := 1 + for { + yearDays := 354 + if IsHijriLeap(hy) { + yearDays = 355 + } + if days < yearDays { + break + } + days -= yearDays + hy++ + } + hm := 1 + for mm := 1; mm <= 12; mm++ { + md := MonthLenHijri(hy, mm) + if days < md { + break + } + days -= md + hm++ + } + hd := days + 1 + return hy, hm, hd +} + +func DayOfWeekGregorian(gy, gm, gd int) int { + if gm < 3 { + gm += 12 + gy-- + } + return (gd + (13*(gm+1))/5 + gy + gy/4 - gy/100 + gy/400) % 7 +} + +type CalDay struct { + DayNum int + Date string +} + +type CalMonth struct { + Title string + WeekDays []string + Days []CalDay +} + +func BuildCalendarMonth(cal string, year, month int) CalMonth { + var title string + var weekDays []string + var totalDays int + + if cal == "jalali" { + title = fmt.Sprintf("%s %d", JalaliMonthNames[month-1], year) + weekDays = []string{"Sh", "Ye", "Do", "Se", "Ch", "Pa", "Jo"} + totalDays = MonthLenJalali(year, month) + gy, gm, gd := JalaliToGregorian(year, month, 1) + dow := DayOfWeekGregorian(gy, gm, gd) + startOffset := dow + var days []CalDay + for i := 0; i < startOffset; i++ { + days = append(days, CalDay{DayNum: 0, Date: ""}) + } + for d := 1; d <= totalDays; d++ { + gy, gm, gd = JalaliToGregorian(year, month, d) + days = append(days, CalDay{DayNum: d, Date: fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)}) + } + return CalMonth{Title: title, WeekDays: weekDays, Days: days} + } + + if cal == "hijri" { + title = fmt.Sprintf("%s %d", HijriMonthNames[month-1], year) + weekDays = []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} + totalDays = MonthLenHijri(year, month) + gy, gm, gd := HijriToGregorian(year, month, 1) + dow := DayOfWeekGregorian(gy, gm, gd) + startOffset := (dow + 5) % 7 + var days []CalDay + for i := 0; i < startOffset; i++ { + days = append(days, CalDay{DayNum: 0, Date: ""}) + } + for d := 1; d <= totalDays; d++ { + gy, gm, gd = HijriToGregorian(year, month, d) + days = append(days, CalDay{DayNum: d, Date: fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)}) + } + return CalMonth{Title: title, WeekDays: weekDays, Days: days} + } + + title = fmt.Sprintf("%s %d", GregMonthNames[month-1], year) + weekDays = []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} + totalDays = MonthLenGreg(year, month) + dow := DayOfWeekGregorian(year, month, 1) + startOffset := (dow + 5) % 7 + var days []CalDay + for i := 0; i < startOffset; i++ { + days = append(days, CalDay{DayNum: 0, Date: ""}) + } + for d := 1; d <= totalDays; d++ { + days = append(days, CalDay{DayNum: d, Date: fmt.Sprintf("%04d-%02d-%02d", year, month, d)}) + } + return CalMonth{Title: title, WeekDays: weekDays, Days: days} +} + +func ParseGregorianDateKey(key string) (int, int, int) { + var y, m, d int + fmt.Sscanf(key, "%04d-%02d-%02d", &y, &m, &d) + return y, m, d +} + +func FormatDateForCalendar(date, calType string) string { + y, m, d := ParseGregorianDateKey(date) + switch calType { + case "jalali": + jy, jm, jd := GregorianToJalali(y, m, d) + return fmt.Sprintf("%04d-%02d-%02d", jy, jm, jd) + case "hijri": + hy, hm, hd := GregorianToHijri(y, m, d) + return fmt.Sprintf("%04d-%02d-%02d", hy, hm, hd) + default: + return date + } +} + +func UserDateToGregorian(dateStr, cal string) string { + y, m, d := ParseGregorianDateKey(dateStr) + switch cal { + case "jalali": + y, m, d = JalaliToGregorian(y, m, d) + case "hijri": + y, m, d = HijriToGregorian(y, m, d) + } + return fmt.Sprintf("%04d-%02d-%02d", y, m, d) +} + +func MonthGregorianRange(cal string, year, month int) (start, end string) { + switch cal { + case "jalali": + gy, gm, gd := JalaliToGregorian(year, month, 1) + start = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd) + lastDay := MonthLenJalali(year, month) + gy, gm, gd = JalaliToGregorian(year, month, lastDay) + end = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd) + case "hijri": + gy, gm, gd := HijriToGregorian(year, month, 1) + start = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd) + lastDay := MonthLenHijri(year, month) + gy, gm, gd = HijriToGregorian(year, month, lastDay) + end = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd) + default: + start = fmt.Sprintf("%04d-%02d-%02d", year, month, 1) + lastDay := MonthLenGreg(year, month) + end = fmt.Sprintf("%04d-%02d-%02d", year, month, lastDay) + } + return +} + +func FormatMonthTitle(cal string, year, month int) string { + switch cal { + case "jalali": + return fmt.Sprintf("%s %d", JalaliMonthNames[month-1], year) + case "hijri": + return fmt.Sprintf("%s %d", HijriMonthNames[month-1], year) + default: + return fmt.Sprintf("%s %d", GregMonthNames[month-1], year) + } +} + +func NavMonth(year, month int) (prevY, prevM, nextY, nextM int) { + prevY, prevM = year, month-1 + if prevM < 1 { + prevM = 12 + prevY-- + } + nextY, nextM = year, month+1 + if nextM > 12 { + nextM = 1 + nextY++ + } + return +} diff --git a/internal/domain/dateutil_test.go b/internal/domain/dateutil_test.go new file mode 100644 index 0000000..627b270 --- /dev/null +++ b/internal/domain/dateutil_test.go @@ -0,0 +1,238 @@ +package domain + +import ( + "testing" +) + +func TestGregorianToJDN_RoundTrip(t *testing.T) { + cases := []struct{ y, m, d int }{ + {2024, 1, 1}, + {2024, 12, 31}, + {1970, 1, 1}, + {1, 1, 1}, + {2024, 2, 29}, + {1900, 2, 28}, + {2000, 2, 29}, + } + for _, c := range cases { + jdn := GregorianToJDN(c.y, c.m, c.d) + y, m, d := JDNToGregorian(jdn) + if y != c.y || m != c.m || d != c.d { + t.Errorf("round-trip %04d-%02d-%02d -> JDN %d -> %04d-%02d-%02d", c.y, c.m, c.d, jdn, y, m, d) + } + } +} + +func TestGregorianToJalali_RoundTrip(t *testing.T) { + cases := []struct{ gy, gm, gd int }{ + {2024, 3, 20}, + {2024, 3, 21}, + {2024, 9, 22}, + {2025, 3, 20}, + {1979, 2, 11}, + {622, 3, 22}, + {2024, 12, 31}, + {2024, 6, 21}, + {2024, 1, 1}, + } + for _, c := range cases { + jy, jm, jd := GregorianToJalali(c.gy, c.gm, c.gd) + if jy == 0 { + t.Errorf("GregorianToJalali(%d, %d, %d) returned 0", c.gy, c.gm, c.gd) + continue + } + gy2, gm2, gd2 := JalaliToGregorian(jy, jm, jd) + if gy2 != c.gy || gm2 != c.gm || gd2 != c.gd { + t.Errorf("round-trip Gregorian %04d-%02d-%02d -> Jalali %04d-%02d-%02d -> Gregorian %04d-%02d-%02d", + c.gy, c.gm, c.gd, jy, jm, jd, gy2, gm2, gd2) + } + } +} + +func TestGregorianToHijri_RoundTrip(t *testing.T) { + cases := []struct{ gy, gm, gd int }{ + {2024, 3, 21}, + {2024, 1, 1}, + {2024, 12, 31}, + {1979, 2, 11}, + {622, 7, 16}, + {2024, 7, 7}, + {2024, 6, 21}, + {2000, 1, 1}, + } + for _, c := range cases { + hy, hm, hd := GregorianToHijri(c.gy, c.gm, c.gd) + if hy == 0 { + t.Errorf("GregorianToHijri(%d, %d, %d) returned 0", c.gy, c.gm, c.gd) + continue + } + gy2, gm2, gd2 := HijriToGregorian(hy, hm, hd) + diff := (gy2-c.gy)*365 + (gm2-c.gm)*30 + (gd2 - c.gd) + if diff < 0 { + diff = -diff + } + if diff > 1 { + t.Errorf("round-trip off by %d days: Gregorian %04d-%02d-%02d -> Hijri %04d-%02d-%02d -> Gregorian %04d-%02d-%02d", + diff, c.gy, c.gm, c.gd, hy, hm, hd, gy2, gm2, gd2) + } + } +} + +func TestIsLeapYear(t *testing.T) { + gregLeaps := map[int]bool{ + 2000: true, 2020: true, 2024: true, 1900: false, 2100: false, 2023: false, + } + for y, want := range gregLeaps { + if got := IsGregorianLeap(y); got != want { + t.Errorf("IsGregorianLeap(%d) = %v, want %v", y, got, want) + } + } + + jalaliLeaps := map[int]bool{ + 1: true, 5: true, 9: true, 13: true, 17: true, 22: true, 26: true, 30: true, + 2: false, 33: false, 1403: true, + } + for y, want := range jalaliLeaps { + if got := IsJalaliLeap(y); got != want { + t.Errorf("IsJalaliLeap(%d) = %v, want %v", y, got, want) + } + } + + hijriLeaps := map[int]bool{ + 2: true, 5: true, 7: true, 10: true, 13: true, 16: true, + 18: true, 21: true, 24: true, 27: true, 29: true, + 1: false, 3: false, 30: false, + } + for y, want := range hijriLeaps { + if got := IsHijriLeap(y); got != want { + t.Errorf("IsHijriLeap(%d) = %v, want %v", y, got, want) + } + } +} + +func TestMonthLengths(t *testing.T) { + if got := MonthLenGreg(2024, 2); got != 29 { + t.Errorf("MonthLenGreg(2024, 2) = %d, want 29", got) + } + if got := MonthLenGreg(2023, 2); got != 28 { + t.Errorf("MonthLenGreg(2023, 2) = %d, want 28", got) + } + if got := MonthLenGreg(2024, 1); got != 31 { + t.Errorf("MonthLenGreg(2024, 1) = %d, want 31", got) + } + + if got := MonthLenJalali(1, 12); got != 30 { + t.Errorf("MonthLenJalali(1, 12) = %d, want 30 (leap)", got) + } + if got := MonthLenJalali(2, 12); got != 29 { + t.Errorf("MonthLenJalali(2, 12) = %d, want 29 (non-leap)", got) + } + if got := MonthLenJalali(1403, 1); got != 31 { + t.Errorf("MonthLenJalali(1403, 1) = %d, want 31", got) + } + + if got := MonthLenHijri(1, 1); got != 30 { + t.Errorf("MonthLenHijri(1, 1) = %d, want 30 (odd month)", got) + } + if got := MonthLenHijri(1, 2); got != 29 { + t.Errorf("MonthLenHijri(1, 2) = %d, want 29 (even non-leap)", got) + } + if got := MonthLenHijri(2, 12); got != 30 { + t.Errorf("MonthLenHijri(2, 12) = %d, want 30 (leap year, month 12)", got) + } +} + +func TestParseGregorianDateKey(t *testing.T) { + y, m, d := ParseGregorianDateKey("2024-03-21") + if y != 2024 || m != 3 || d != 21 { + t.Errorf("ParseGregorianDateKey got %d-%d-%d, want 2024-3-21", y, m, d) + } +} + +func TestFormatDateForCalendar(t *testing.T) { + if got := FormatDateForCalendar("2024-03-21", "gregorian"); got != "2024-03-21" { + t.Errorf("FormatDateForCalendar gregorian: got %s", got) + } + if got := FormatDateForCalendar("2024-03-21", "jalali"); got != "1403-01-02" { + t.Errorf("FormatDateForCalendar jalali: got %s, want 1403-01-02", got) + } + if got := FormatDateForCalendar("2024-03-21", "hijri"); got != "1445-09-11" { + t.Errorf("FormatDateForCalendar hijri: got %s, want 1445-09-11", got) + } +} + +func TestUserDateToGregorian(t *testing.T) { + if got := UserDateToGregorian("1403-01-02", "jalali"); got != "2024-03-21" { + t.Errorf("UserDateToGregorian jalali: got %s, want 2024-03-21", got) + } + if got := UserDateToGregorian("1445-09-11", "hijri"); got != "2024-03-21" { + t.Errorf("UserDateToGregorian hijri: got %s, want 2024-03-21", got) + } +} + +func TestMonthGregorianRange(t *testing.T) { + start, end := MonthGregorianRange("gregorian", 2024, 1) + if start != "2024-01-01" || end != "2024-01-31" { + t.Errorf("gregorian range: got %s - %s", start, end) + } + + start, end = MonthGregorianRange("jalali", 1403, 1) + if start != "2024-03-20" || end != "2024-04-19" { + t.Errorf("jalali range: got %s - %s", start, end) + } + + start, end = MonthGregorianRange("hijri", 1445, 9) + if start != "2024-03-11" || end != "2024-04-09" { + t.Errorf("hijri range: got %s - %s", start, end) + } +} + +func TestBuildCalendarMonth(t *testing.T) { + cm := BuildCalendarMonth("gregorian", 2024, 1) + if cm.Title != "January 2024" { + t.Errorf("title: got %q", cm.Title) + } + if len(cm.WeekDays) != 7 { + t.Errorf("weekDays count: %d", len(cm.WeekDays)) + } + if len(cm.Days) < 28 { + t.Errorf("too few days: %d", len(cm.Days)) + } + + cm2 := BuildCalendarMonth("jalali", 1403, 1) + if cm2.Title != "Farvardin 1403" { + t.Errorf("jalali title: got %q", cm2.Title) + } + if len(cm2.Days) < 31 { + t.Errorf("too few jalali days: %d", len(cm2.Days)) + } + + cm3 := BuildCalendarMonth("hijri", 1445, 9) + if cm3.Title != "Ramadan 1445" { + t.Errorf("hijri title: got %q", cm3.Title) + } +} + +func TestFormatMonthTitle(t *testing.T) { + tests := []struct { + cal string + year, m int + want string + }{ + {"gregorian", 2026, 6, "June 2026"}, + {"gregorian", 2024, 1, "January 2024"}, + {"jalali", 1404, 1, "Farvardin 1404"}, + {"jalali", 1405, 12, "Esfand 1405"}, + {"hijri", 1447, 1, "Muharram 1447"}, + {"hijri", 1447, 12, "Dhu al-Hijjah 1447"}, + } + for _, tc := range tests { + t.Run(tc.want, func(t *testing.T) { + got := FormatMonthTitle(tc.cal, tc.year, tc.m) + if got != tc.want { + t.Errorf("FormatMonthTitle(%q, %d, %d) = %q, want %q", + tc.cal, tc.year, tc.m, got, tc.want) + } + }) + } +} diff --git a/internal/domain/rpg.go b/internal/domain/rpg.go new file mode 100644 index 0000000..7f5e528 --- /dev/null +++ b/internal/domain/rpg.go @@ -0,0 +1,76 @@ +package domain + +import "time" + +// XpForLevel returns the total XP required to reach level n. +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. +func XpForWorkSeconds(sec int64) int64 { + return sec +} + +// StreakBonus returns bonus XP for maintaining a streak. +func StreakBonus(streak int) int64 { + b := int64(streak) * XPCurveMultiplier + if b > StreakBonusCap { + b = StreakBonusCap + } + return b +} + +// ComputeStreakFromDates computes current and longest streak from a sorted list of date strings. +func ComputeStreakFromDates(dates []string, today string) (current, longest int) { + if len(dates) == 0 { + return 0, 0 + } + + dateSet := make(map[string]struct{}, len(dates)) + for _, d := range dates { + dateSet[d] = struct{}{} + } + + if _, ok := dateSet[today]; ok { + current = 1 + for i := 1; ; i++ { + t, _ := time.Parse(DateLayout, today) + prev := t.AddDate(0, 0, -i).Format(DateLayout) + if _, ok := dateSet[prev]; ok { + current++ + } else { + break + } + } + } + + longest = 0 + run := 1 + for i := 1; i < len(dates); i++ { + prev, _ := time.Parse(DateLayout, dates[i-1]) + curr, _ := time.Parse(DateLayout, dates[i]) + if curr.Sub(prev).Hours() == 24 { + run++ + } else { + if run > longest { + longest = run + } + run = 1 + } + } + if run > longest { + longest = run + } + + return current, longest +} diff --git a/internal/bot/rpg_test.go b/internal/domain/rpg_test.go similarity index 64% rename from internal/bot/rpg_test.go rename to internal/domain/rpg_test.go index b9e79a3..efd2be2 100644 --- a/internal/bot/rpg_test.go +++ b/internal/domain/rpg_test.go @@ -1,4 +1,4 @@ -package bot +package domain import ( "fmt" @@ -20,9 +20,9 @@ func TestXpForLevel(t *testing.T) { {50, 127500}, } for _, c := range cases { - got := xpForLevel(c.n) + got := XpForLevel(c.n) if got != c.want { - t.Errorf("xpForLevel(%d) = %d, want %d", c.n, got, c.want) + t.Errorf("XpForLevel(%d) = %d, want %d", c.n, got, c.want) } } } @@ -43,32 +43,32 @@ func TestLevelForXP(t *testing.T) { {127500, 50}, } for _, c := range cases { - got := levelForXP(c.xp) + got := LevelForXP(c.xp) if got != c.want { - t.Errorf("levelForXP(%d) = %d, want %d", c.xp, 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) + xp := XpForLevel(n) + l := LevelForXP(xp) if l != n { - t.Errorf("levelForXP(xpForLevel(%d)) = %d, want %d", n, 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(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(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) + if got := XpForWorkSeconds(1800); got != 1800 { + t.Errorf("XpForWorkSeconds(1800) = %d, want 1800", got) } } @@ -84,9 +84,9 @@ func TestStreakBonus(t *testing.T) { {20, 500}, } for _, c := range cases { - got := streakBonus(c.streak) + got := StreakBonus(c.streak) if got != c.want { - t.Errorf("streakBonus(%d) = %d, want %d", c.streak, got, c.want) + t.Errorf("StreakBonus(%d) = %d, want %d", c.streak, got, c.want) } } } @@ -99,67 +99,59 @@ func TestCurrencySymbol(t *testing.T) { {"", "$"}, {"$", "$"}, {"$ USD", "$"}, - {"€ EUR", "€"}, - {"£", "£"}, + {"\u20AC EUR", "\u20AC"}, + {"\u00A3", "\u00A3"}, {"Toman", "Toman"}, {" ", "$"}, } for _, c := range cases { - got := currencySymbol(c.currency) + got := CurrencySymbol(c.currency) if got != c.want { - t.Errorf("currencySymbol(%q) = %q, want %q", c.currency, 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) + got := ComputeDailySalary(3600, 25.0) if got != 25.0 { - t.Errorf("computeDailySalary(3600, 25) = %.2f, want 25.00", got) + t.Errorf("ComputeDailySalary(3600, 25) = %.2f, want 25.00", got) } - // 2.5 hours at $20/hr = $50 - got = computeDailySalary(9000, 20.0) + got = ComputeDailySalary(9000, 20.0) if got != 50.0 { - t.Errorf("computeDailySalary(9000, 20) = %.2f, want 50.00", got) + t.Errorf("ComputeDailySalary(9000, 20) = %.2f, want 50.00", got) } - // 0 seconds = $0 - got = computeDailySalary(0, 50.0) + got = ComputeDailySalary(0, 50.0) if got != 0 { - t.Errorf("computeDailySalary(0, 50) = %.2f, want 0", got) + 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) + got = ComputeDailySalary(3599, 10.0) if got != 10.0 { - t.Errorf("computeDailySalary(3599, 10) = %.2f, want 10.00", got) + 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)) + long := make([]byte, 600) for i := range long { - long = long[:i] + "a" + long[i+1:] + long[i] = 'a' } - got := SanitizeNote(long) + got := SanitizeNote(string(long)) if len(got) > 500 { t.Errorf("SanitizeNote length: got %d, want <= 500", len(got)) } @@ -186,19 +178,19 @@ func TestParseCurrencyInput(t *testing.T) { {"$ USD", "$ USD", true}, {"T Toman", "T Toman", true}, {"IRR Rial", "IRR Rial", true}, - {"€ EUR", "€ EUR", true}, + {"\u20AC EUR", "\u20AC EUR", true}, {"", "", false}, {"$", "", false}, {" ", "", false}, {"$$$ TooLongCode", "", false}, } for _, c := range cases { - got, errMsg := parseCurrencyInput(c.input) + got, errMsg := ParseCurrencyInput(c.input) if c.ok && (got != c.want || errMsg != "") { - t.Errorf("parseCurrencyInput(%q) = (%q, %q), want (%q, \"\")", c.input, got, errMsg, c.want) + t.Errorf("ParseCurrencyInput(%q) = (%q, %q), want (%q, \"\")", c.input, got, errMsg, c.want) } if !c.ok && errMsg == "" { - t.Errorf("parseCurrencyInput(%q) = (%q, \"\"), want error", c.input, got) + t.Errorf("ParseCurrencyInput(%q) = (%q, \"\"), want error", c.input, got) } } } @@ -217,18 +209,18 @@ func TestComputeTrendFromAvgs(t *testing.T) { {100, 130, 0, "-23"}, } for _, c := range cases { - pts, dir := computeTrendFromAvgs(c.recent, c.older) + pts, dir := ComputeTrendFromAvgs(c.recent, c.older) if pts != c.wantPts { - t.Errorf("computeTrendFromAvgs(%.0f, %.0f) pts = %d, want %d", c.recent, c.older, pts, c.wantPts) + t.Errorf("ComputeTrendFromAvgs(%.0f, %.0f) pts = %d, want %d", c.recent, c.older, pts, c.wantPts) } if !strings.Contains(dir, c.wantDirSub) { - t.Errorf("computeTrendFromAvgs(%.0f, %.0f) dir = %q, want containing %q", c.recent, c.older, dir, c.wantDirSub) + t.Errorf("ComputeTrendFromAvgs(%.0f, %.0f) dir = %q, want containing %q", c.recent, c.older, dir, c.wantDirSub) } } } func TestComputeStreakFromDates_Empty(t *testing.T) { - cur, longest := computeStreakFromDates(nil, "2026-06-25") + cur, longest := ComputeStreakFromDates(nil, "2026-06-25") if cur != 0 || longest != 0 { t.Errorf("empty dates: got (%d, %d), want (0, 0)", cur, longest) } @@ -236,7 +228,7 @@ func TestComputeStreakFromDates_Empty(t *testing.T) { func TestComputeStreakFromDates_CurrentOnly(t *testing.T) { dates := []string{"2026-06-25", "2026-06-26"} - cur, longest := computeStreakFromDates(dates, "2026-06-26") + cur, longest := ComputeStreakFromDates(dates, "2026-06-26") if cur != 2 { t.Errorf("current streak = %d, want 2", cur) } @@ -247,7 +239,7 @@ func TestComputeStreakFromDates_CurrentOnly(t *testing.T) { func TestComputeStreakFromDates_GapBreaksStreak(t *testing.T) { dates := []string{"2026-06-23", "2026-06-25", "2026-06-26"} - cur, longest := computeStreakFromDates(dates, "2026-06-26") + cur, longest := ComputeStreakFromDates(dates, "2026-06-26") if cur != 2 { t.Errorf("current streak = %d, want 2 (today and yesterday)", cur) } @@ -258,7 +250,7 @@ func TestComputeStreakFromDates_GapBreaksStreak(t *testing.T) { func TestComputeStreakFromDates_NoEventsToday(t *testing.T) { dates := []string{"2026-06-24", "2026-06-25"} - cur, longest := computeStreakFromDates(dates, "2026-06-26") + cur, longest := ComputeStreakFromDates(dates, "2026-06-26") if cur != 0 { t.Errorf("current streak = %d, want 0 (no events today)", cur) } @@ -268,11 +260,11 @@ func TestComputeStreakFromDates_NoEventsToday(t *testing.T) { } func TestComputeStreakFromDates_LongStreak(t *testing.T) { - dates := []string{} + dates := make([]string, 0, 10) for d := 1; d <= 10; d++ { dates = append(dates, fmt.Sprintf("2026-06-%02d", d)) } - cur, longest := computeStreakFromDates(dates, "2026-06-10") + cur, longest := ComputeStreakFromDates(dates, "2026-06-10") if cur != 10 { t.Errorf("current streak = %d, want 10", cur) } @@ -284,10 +276,10 @@ func TestComputeStreakFromDates_LongStreak(t *testing.T) { func TestComputeStreakFromDates_LongestNotCurrent(t *testing.T) { dates := []string{} for d := 1; d <= 5; d++ { - dates = append(dates, fmt.Sprintf("2026-06-%02d", d)) + dates = append(dates, formatDateInt(2026, 6, d)) } dates = append(dates, "2026-06-09", "2026-06-10") - cur, longest := computeStreakFromDates(dates, "2026-06-10") + cur, longest := ComputeStreakFromDates(dates, "2026-06-10") if cur != 2 { t.Errorf("current streak = %d, want 2", cur) } @@ -295,3 +287,21 @@ func TestComputeStreakFromDates_LongestNotCurrent(t *testing.T) { t.Errorf("longest streak = %d, want 5", longest) } } + +func formatDateInt(y, m, d int) string { + return FormatDateForCalendar( + func() string { + s := make([]byte, 10) + s[0] = byte('0' + y/1000) + s[1] = byte('0' + (y/100)%10) + s[2] = byte('0' + (y/10)%10) + s[3] = byte('0' + y%10) + s[4] = '-' + s[5] = byte('0' + m/10) + s[6] = byte('0' + m%10) + s[7] = '-' + s[8] = byte('0' + d/10) + s[9] = byte('0' + d%10) + return string(s) + }(), "gregorian") +} diff --git a/internal/domain/salary.go b/internal/domain/salary.go new file mode 100644 index 0000000..a0b543f --- /dev/null +++ b/internal/domain/salary.go @@ -0,0 +1,51 @@ +package domain + +import ( + "fmt" + "math" + "strings" + "unicode" +) + +// 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 +} + +// 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) +} + +// ParseCurrencyInput validates and normalizes currency input (symbol + code). +func ParseCurrencyInput(s string) (string, string) { + parts := strings.Fields(s) + if len(parts) < 2 { + return "", "Please provide both symbol and code.\nUsage: /setcurrency \nExamples:\n/setcurrency $ USD\n/setcurrency T Toman\n/setcurrency IRR Rial" + } + for _, p := range parts[:2] { + if len(p) > 10 || strings.ContainsFunc(p, unicode.IsControl) { + return "", "Invalid currency. Use short printable strings like $ USD, T Toman, IRR Rial." + } + } + return parts[0] + " " + parts[1], "" +} diff --git a/internal/bot/sanitize.go b/internal/domain/sanitize.go similarity index 78% rename from internal/bot/sanitize.go rename to internal/domain/sanitize.go index 703bc30..fbc02ad 100644 --- a/internal/bot/sanitize.go +++ b/internal/domain/sanitize.go @@ -1,19 +1,16 @@ -package bot +package domain 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] + if len(s) > MaxNoteLength { + s = s[:MaxNoteLength] } return strings.Map(func(r rune) rune { if r == '\n' || r == '\t' || r == ' ' { @@ -29,8 +26,8 @@ func SanitizeNote(s string) string { // 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] + if len(s) > MaxUsernameLength { + s = s[:MaxUsernameLength] } return strings.Map(func(r rune) rune { if unicode.IsControl(r) { diff --git a/internal/domain/totals.go b/internal/domain/totals.go new file mode 100644 index 0000000..b5ca6b7 --- /dev/null +++ b/internal/domain/totals.go @@ -0,0 +1,68 @@ +package domain + +import "sort" + +// ComputeDailyTotals calculates the total work and break time from a list of events. +func ComputeDailyTotals(events []Event, minBreakThreshold int64, dayStart, dayEnd int64) DailyTotals { + if len(events) == 0 { + return DailyTotals{} + } + + sorted := make([]Event, len(events)) + copy(sorted, events) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].OccurredAt < sorted[j].OccurredAt + }) + + var totalWork, totalBreak int64 + state := StateIdle + var workStart, breakStart int64 + + if dayStart > 0 { + state = StateWorking + workStart = dayStart + } else if sorted[0].EventType == "out" { + state = StateOnBreak + } + + for _, e := range sorted { + switch { + case e.EventType == "in" && state == StateIdle: + state = StateWorking + workStart = e.OccurredAt + case e.EventType == "in" && state == StateOnBreak: + breakElapsed := e.OccurredAt - breakStart + if breakElapsed >= minBreakThreshold { + totalBreak += breakElapsed + } else { + totalWork += breakElapsed + } + state = StateWorking + workStart = e.OccurredAt + case e.EventType == "in" && state == StateWorking: + case e.EventType == "out" && state == StateWorking: + totalWork += e.OccurredAt - workStart + state = StateOnBreak + breakStart = e.OccurredAt + case e.EventType == "out" && state == StateOnBreak: + breakStart = e.OccurredAt + case e.EventType == "out" && state == StateIdle: + state = StateOnBreak + breakStart = e.OccurredAt + } + } + + if state == StateWorking && dayEnd > 0 { + totalWork += dayEnd - workStart + } + if state == StateOnBreak && dayEnd > 0 { + breakElapsed := dayEnd - breakStart + if breakElapsed >= minBreakThreshold { + totalBreak += breakElapsed + } else { + totalWork += breakElapsed + } + } + + return DailyTotals{TotalSeconds: totalWork, BreakSeconds: totalBreak} +} diff --git a/internal/bot/totals_test.go b/internal/domain/totals_test.go similarity index 77% rename from internal/bot/totals_test.go rename to internal/domain/totals_test.go index 6c8c10b..0c4696d 100644 --- a/internal/bot/totals_test.go +++ b/internal/domain/totals_test.go @@ -1,13 +1,11 @@ -package bot +package domain import ( "testing" - - "worktimeBot/internal/db" ) -func mkEvent(typ string, ts int64) db.Event { - return db.Event{EventType: typ, OccurredAt: ts} +func mkEvent(typ string, ts int64) Event { + return Event{EventType: typ, OccurredAt: ts} } func TestComputeDailyTotals_Empty(t *testing.T) { @@ -18,14 +16,14 @@ func TestComputeDailyTotals_Empty(t *testing.T) { } func TestComputeDailyTotals_NoEvents(t *testing.T) { - got := ComputeDailyTotals([]db.Event{}, 0, 0, 0) + got := ComputeDailyTotals([]Event{}, 0, 0, 0) if got.TotalSeconds != 0 || got.BreakSeconds != 0 { t.Errorf("no events: got %+v", got) } } func TestComputeDailyTotals_SingleInOut(t *testing.T) { - events := []db.Event{ + events := []Event{ mkEvent("in", 100), mkEvent("out", 200), } @@ -39,12 +37,11 @@ func TestComputeDailyTotals_SingleInOut(t *testing.T) { } func TestComputeDailyTotals_MissingFirstIn(t *testing.T) { - events := []db.Event{ + events := []Event{ mkEvent("out", 200), mkEvent("in", 300), mkEvent("out", 400), } - // synthetic "in" at dayStart=100 => in(100), out(200)=100s work, break(200-300), in(300), out(400)=100s work got := ComputeDailyTotals(events, 0, 100, 500) if got.TotalSeconds != 200 { t.Errorf("missing first in: TotalSeconds=%d, want 200", got.TotalSeconds) @@ -52,12 +49,11 @@ func TestComputeDailyTotals_MissingFirstIn(t *testing.T) { } func TestComputeDailyTotals_MissingLastOut(t *testing.T) { - events := []db.Event{ + events := []Event{ mkEvent("in", 100), mkEvent("out", 200), mkEvent("in", 300), } - // synthetic "out" at dayEnd=500 => 100s + 200s got := ComputeDailyTotals(events, 0, 0, 500) if got.TotalSeconds != 300 { t.Errorf("missing last out: TotalSeconds=%d, want 300", got.TotalSeconds) @@ -65,14 +61,12 @@ func TestComputeDailyTotals_MissingLastOut(t *testing.T) { } func TestComputeDailyTotals_BreakUnderThreshold(t *testing.T) { - events := []db.Event{ + events := []Event{ mkEvent("in", 100), mkEvent("out", 200), mkEvent("in", 250), mkEvent("out", 400), } - // break = 50s, threshold = 60s => break absorbed into work - // work: 100-200(100s), then 200-250 absorbed(50s), 250-400(150s) = 300s got := ComputeDailyTotals(events, 60, 0, 0) if got.TotalSeconds != 300 { t.Errorf("break under threshold: TotalSeconds=%d, want 300", got.TotalSeconds) @@ -83,13 +77,12 @@ func TestComputeDailyTotals_BreakUnderThreshold(t *testing.T) { } func TestComputeDailyTotals_BreakOverThreshold(t *testing.T) { - events := []db.Event{ + events := []Event{ mkEvent("in", 100), mkEvent("out", 200), mkEvent("in", 300), mkEvent("out", 400), } - // break = 100s, threshold = 60s => counted as break got := ComputeDailyTotals(events, 60, 0, 0) if got.TotalSeconds != 200 { t.Errorf("break over threshold: TotalSeconds=%d, want 200", got.TotalSeconds) @@ -100,17 +93,13 @@ func TestComputeDailyTotals_BreakOverThreshold(t *testing.T) { } func TestComputeDailyTotals_ConsecutiveOuts(t *testing.T) { - events := []db.Event{ + events := []Event{ mkEvent("in", 100), mkEvent("out", 200), mkEvent("out", 250), mkEvent("in", 300), mkEvent("out", 400), } - // in(100)..out(200)=100s work, state=OnBreak breakStart=200 - // out(250) while OnBreak => reset breakStart=250 - // in(300) => state=Working, workStart=300 - // out(400) => work=100s got := ComputeDailyTotals(events, 0, 0, 0) if got.TotalSeconds != 200 { t.Errorf("consecutive outs: TotalSeconds=%d, want 200", got.TotalSeconds) @@ -118,12 +107,11 @@ func TestComputeDailyTotals_ConsecutiveOuts(t *testing.T) { } func TestComputeDailyTotals_ConsecutiveIns(t *testing.T) { - events := []db.Event{ + events := []Event{ mkEvent("in", 100), mkEvent("in", 150), mkEvent("out", 300), } - // second "in" while already working is ignored got := ComputeDailyTotals(events, 0, 0, 0) if got.TotalSeconds != 200 { t.Errorf("consecutive ins: TotalSeconds=%d, want 200", got.TotalSeconds) @@ -131,10 +119,9 @@ func TestComputeDailyTotals_ConsecutiveIns(t *testing.T) { } func TestComputeDailyTotals_SingleInEndOfDay(t *testing.T) { - events := []db.Event{ + events := []Event{ mkEvent("in", 100), } - // missing out => synthetic out at dayEnd got := ComputeDailyTotals(events, 0, 0, 500) if got.TotalSeconds != 400 { t.Errorf("single in with dayEnd: TotalSeconds=%d, want 400", got.TotalSeconds) @@ -142,10 +129,9 @@ func TestComputeDailyTotals_SingleInEndOfDay(t *testing.T) { } func TestComputeDailyTotals_SingleOutStartOfDay(t *testing.T) { - events := []db.Event{ + events := []Event{ mkEvent("out", 200), } - // missing in => synthetic in at dayStart got := ComputeDailyTotals(events, 0, 100, 500) if got.TotalSeconds != 100 { t.Errorf("single out with dayStart: TotalSeconds=%d, want 100", got.TotalSeconds) @@ -153,14 +139,12 @@ func TestComputeDailyTotals_SingleOutStartOfDay(t *testing.T) { } func TestComputeDailyTotals_ZeroThreshold(t *testing.T) { - events := []db.Event{ + events := []Event{ mkEvent("in", 100), mkEvent("out", 200), mkEvent("in", 250), mkEvent("out", 400), } - // threshold = 0, any break is counted - // work: 100-200(100s) + 250-400(150s) = 250s, break: 200-250(50s) got := ComputeDailyTotals(events, 0, 0, 0) if got.TotalSeconds != 250 { t.Errorf("zero threshold: TotalSeconds=%d, want 250", got.TotalSeconds) @@ -171,7 +155,7 @@ func TestComputeDailyTotals_ZeroThreshold(t *testing.T) { } func TestComputeDailyTotals_LargeBreak(t *testing.T) { - events := []db.Event{ + events := []Event{ mkEvent("in", 100), mkEvent("out", 200), mkEvent("in", 10000), @@ -187,7 +171,7 @@ func TestComputeDailyTotals_LargeBreak(t *testing.T) { } func TestComputeDailyTotals_MultipleSessions(t *testing.T) { - events := []db.Event{ + events := []Event{ mkEvent("in", 100), mkEvent("out", 200), mkEvent("in", 300), @@ -195,7 +179,6 @@ func TestComputeDailyTotals_MultipleSessions(t *testing.T) { mkEvent("in", 500), mkEvent("out", 600), } - // 3 work sessions: 100+100+100 = 300s got := ComputeDailyTotals(events, 0, 0, 0) if got.TotalSeconds != 300 { t.Errorf("multiple sessions: TotalSeconds=%d, want 300", got.TotalSeconds) @@ -206,10 +189,9 @@ func TestComputeDailyTotals_MultipleSessions(t *testing.T) { } func TestComputeDailyTotals_OnlyOut(t *testing.T) { - events := []db.Event{ + events := []Event{ mkEvent("out", 200), } - // no dayStart => no synthetic in => idle -> out triggers OnBreak got := ComputeDailyTotals(events, 0, 0, 0) if got.TotalSeconds != 0 { t.Errorf("only out no dayStart: TotalSeconds=%d, want 0", got.TotalSeconds) @@ -217,10 +199,9 @@ func TestComputeDailyTotals_OnlyOut(t *testing.T) { } func TestComputeDailyTotals_OnlyIn(t *testing.T) { - events := []db.Event{ + events := []Event{ mkEvent("in", 100), } - // no dayEnd => no synthetic out => still working at end got := ComputeDailyTotals(events, 0, 0, 0) if got.TotalSeconds != 0 { t.Errorf("only in no dayEnd: TotalSeconds=%d, want 0", got.TotalSeconds) diff --git a/internal/domain/types.go b/internal/domain/types.go new file mode 100644 index 0000000..07b1b98 --- /dev/null +++ b/internal/domain/types.go @@ -0,0 +1,188 @@ +package domain + +// User represents a bot user with identity, timezone, and calendar preferences. +type User struct { + 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 + 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 + Name string + CreatedAt int64 +} + +// Day represents a single day entry for a user, including day-off status and work type. +type Day struct { + ID int64 + UserID int64 + Date string + IsDayOff bool + DayOffReason string + CurrentWorkTypeID int64 + MinBreakThreshold int64 + CreatedAt int64 + UpdatedAt int64 +} + +// Event represents a single timestamped event ("in" or "out") for a user on a given day. +type Event struct { + ID int64 + UserID int64 + DayID int64 + EventType string + WorkTypeID *int64 + OccurredAt int64 + Note string + CreatedAt int64 +} + +// LeagueEntry holds a user's league ranking data. +type LeagueEntry struct { + UserID int64 + Username string + XP int64 + Level int + Streak int + TotalHours float64 +} + +// SalaryEstimate holds a salary calculation result. +type SalaryEstimate struct { + Currency string + HourlyRate float64 + WorkSeconds int64 + WorkHours float64 + GrossEarning float64 + WorkDays int +} + +// 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 +} + +// DailyTotals holds the computed total work and break seconds for a single day. +type DailyTotals struct { + TotalSeconds int64 + BreakSeconds int64 +} + +// State represents the current tracking state during daily totals computation. +type State int + +const ( + StateIdle State = iota + StateWorking + StateOnBreak +) + +// LeagueSortOption represents a valid league sort column. +type LeagueSortOption string + +const ( + LeagueSortXP LeagueSortOption = "xp" + LeagueSortLevel LeagueSortOption = "level" + LeagueSortStreak LeagueSortOption = "streak" + LeagueSortHours LeagueSortOption = "hours" +) diff --git a/internal/handler/burnout.go b/internal/handler/burnout.go new file mode 100644 index 0000000..8109937 --- /dev/null +++ b/internal/handler/burnout.go @@ -0,0 +1,207 @@ +package handler + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/go-telegram/bot/models" + + "worktimeBot/internal/domain" +) + +// -- Command -- + +func (h *Handler) handleBurnout(ctx context.Context, msg *models.Message, user *domain.User) { + text := h.buildBurnoutText(user) + h.sendWithKB(ctx, msg.Chat.ID, text, backKeyboard()) +} + +// -- Callback -- + +func (h *Handler) burnoutCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + text := h.buildBurnoutText(user) + kb := backKeyboard() + h.editText(ctx, chatID, msgID, text, &kb) +} + +// -- Assessment -- + +func (h *Handler) buildBurnoutText(user *domain.User) string { + res, err := h.assessBurnout(user) + 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) / domain.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 + if ratio > 100 { + ratio = 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 domain.BurnoutHealthy: + b.WriteString(" Keep up the balanced routine!") + case domain.BurnoutMild: + b.WriteString(" Consider taking short breaks throughout the day.") + b.WriteString("\n Make sure to disconnect after work hours.") + case domain.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 domain.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() +} + +func (h *Handler) assessBurnout(user *domain.User) (*domain.BurnoutResult, error) { + loc := h.loadLocation(user.Timezone) + now := time.Now().In(loc) + today := now.Format(domain.DateLayout) + result := &domain.BurnoutResult{} + + stats, err := h.Repo.GetOrCreateRPGStats(user.ID) + 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 + } + + todayEvents, _ := h.Repo.EventsForDayByDate(user.ID, today) + if len(todayEvents) > 0 { + day, _ := h.Repo.GetDay(user.ID, today) + if day != nil { + y, m, d := now.Date() + dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix() + totals := domain.ComputeDailyTotals(todayEvents, day.MinBreakThreshold, dayStart, now.Unix()) + result.WorkSeconds = totals.TotalSeconds + result.BreakSeconds = totals.BreakSeconds + workHours := float64(totals.TotalSeconds) / domain.SecondsPerHour + switch { + case workHours >= 12: + result.LongHoursPts = 20 + case workHours >= 10: + result.LongHoursPts = 15 + case workHours >= 8: + result.LongHoursPts = 10 + } + 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 + } + } + 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 + } + } + } + } + + weekday := now.Weekday() + if weekday == time.Saturday || weekday == time.Sunday { + if result.WorkSeconds > 0 { + result.WeekendPts = 15 + } + } + + result.TrendPts, result.TrendDirection = h.computeTrend(user.ID, today, loc) + result.Score = result.ConsecutivePts + result.LongHoursPts + result.BreakRatioPts + + result.WeekendPts + result.TrendPts + result.LateNightPts + switch { + case result.Score >= 61: + result.Level = domain.BurnoutHigh + case result.Score >= 41: + result.Level = domain.BurnoutModerate + case result.Score >= 21: + result.Level = domain.BurnoutMild + default: + result.Level = domain.BurnoutHealthy + } + return result, nil +} + +func (h *Handler) computeTrend(userID int64, today string, loc *time.Location) (int, string) { + t, err := time.Parse(domain.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(domain.DateLayout) + recent[2-i] = h.dayWorkSeconds(userID, d, loc) + } + for i := 0; i < 3; i++ { + d := t.AddDate(0, 0, -(i + 4)).Format(domain.DateLayout) + older[2-i] = h.dayWorkSeconds(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 + return domain.ComputeTrendFromAvgs(recentAvg, olderAvg) +} + +func (h *Handler) dayWorkSeconds(userID int64, date string, loc *time.Location) int64 { + events, err := h.Repo.EventsForDayByDate(userID, date) + if err != nil || len(events) == 0 { + return 0 + } + day, err := h.Repo.GetDay(userID, date) + if err != nil || day == nil { + return 0 + } + y, m, d := domain.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 := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) + return totals.TotalSeconds +} diff --git a/internal/bot/calendar.go b/internal/handler/calendar.go similarity index 51% rename from internal/bot/calendar.go rename to internal/handler/calendar.go index 8afac1b..f81202a 100644 --- a/internal/bot/calendar.go +++ b/internal/handler/calendar.go @@ -1,4 +1,4 @@ -package bot +package handler import ( "context" @@ -10,245 +10,37 @@ import ( "github.com/go-telegram/bot/models" - "worktimeBot/internal/db" + "worktimeBot/internal/domain" ) -// handleHistoryMsg shows the calendar view (new message). -func (h *Handler) handleHistoryMsg(ctx context.Context, msg *models.Message) { - h.sendCalendar(ctx, msg.Chat.ID, 0, 0, 0) +// -- History command and callback -- + +func (h *Handler) handleHistory(ctx context.Context, msg *models.Message, user *domain.User) { + h.editCalendar(ctx, msg.Chat.ID, 0, 0, 0) } -// historyCallback opens or refreshes the calendar view (inline). -func (h *Handler) historyCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { - defer h.answerCb(ctx, callbackID) +func (h *Handler) historyCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) h.editCalendar(ctx, chatID, msgID, 0, 0) } -// handleEditMsg parses a /edit YYYY-MM-DD command and shows that day's events. -func (h *Handler) handleEditMsg(ctx context.Context, msg *models.Message) { - date := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/edit")) - if date == "" { - h.sendText(ctx, msg.Chat.ID, "Usage: /edit YYYY-MM-DD (your calendar type)") - return - } - if len(date) != 10 || date[4] != '-' || date[7] != '-' { - h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /edit YYYY-MM-DD (your calendar type)") - return - } - _, m, d := parseGregorianDateKey(date) - if m < 1 || m > 12 || d < 1 || d > 31 { - h.sendText(ctx, msg.Chat.ID, "Invalid date.") - return - } - user, err := h.getOrCreateUser(msg.Chat.ID) - if err != nil { - h.sendText(ctx, msg.Chat.ID, "Error loading profile") - return - } - loc := loadLocation(user.Timezone) - date = userDateToGregorian(date, user.Calendar) - h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true) -} +// -- Calendar rendering -- -// handleHistoryCallback routes calendar-related callback data to the right handler. -func (h *Handler) handleHistoryCallback(ctx context.Context, chatID int64, msgID int, callbackID, data string) { - defer h.answerCb(ctx, callbackID) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - loc := loadLocation(user.Timezone) - - if data == "history" { - h.editCalendar(ctx, chatID, msgID, 0, 0) - return - } - - 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 - } -} - -// 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 true - } - if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 { - h.editCalendar(ctx, chatID, msgID, y, m) - return true - } - if n, _ := fmt.Sscanf(data, "cal_prev_year_%d_%d", &y, &m); n == 2 { - h.editCalendar(ctx, chatID, msgID, y, m) - return true - } - if n, _ := fmt.Sscanf(data, "cal_next_year_%d_%d", &y, &m); n == 2 { - h.editCalendar(ctx, chatID, msgID, y, m) - return true - } - 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 true - } - 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 true - } - if n, _ := fmt.Sscanf(data, "cal_year_%d", &y); n == 1 { - h.editCalendar(ctx, chatID, msgID, y, 1) - return true - } - if n, _ := fmt.Sscanf(data, "cal_years_back_%d_%d", &y, &m); n == 2 { - h.editCalendar(ctx, chatID, msgID, y, m) - return true - } - 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 true - } - var eid int64 - if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 { - h.backToDayView(ctx, chatID, msgID, user, eid, loc) - return true - } - 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 true - } - if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 { - h.editEventType(ctx, chatID, msgID, user, eid, loc) - return true - } - if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 { - h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc) - return true - } - if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 { - h.deleteEventConfirm(ctx, chatID, msgID, user, eid, loc) - return true - } - if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 { - h.editEventTime(ctx, chatID, msgID, user, eid, loc) - return true - } - 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, callbackID) - return true - } - if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 { - h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc) - return true - } - if n, _ := fmt.Sscanf(data, "note_%d", &eid); n == 1 { - h.promptNote(ctx, chatID, msgID, user, eid, loc) - return true - } - if n, _ := fmt.Sscanf(data, "note_cancel_%d", &eid); n == 1 { - h.backToDayView(ctx, chatID, msgID, user, eid, loc) - return true - } - 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) - case strings.HasPrefix(data, "addout_"): - h.addEventTime(ctx, chatID, msgID, user, data[7:], "out", loc) - case strings.HasPrefix(data, "addtm_"): - h.handleAddEventTime(ctx, chatID, msgID, user, data[6:], loc) - default: - return false - } - return true -} - -// sendCalendar sends a calendar view as a new message. -func (h *Handler) sendCalendar(ctx context.Context, chatID int64, msgID int, year, month int) { - h.editCalendar(ctx, chatID, msgID, year, month) -} - -// buildYearPicker returns a 12-year grid keyboard centered on centerYear. -// cbPrefix is "cal_" or "exp_". backData is the callback for the Back button. -// navSuffix is appended to the nav callbacks to carry reference info (e.g. "_2026_3"). -func buildYearPicker(cbPrefix, backData, navSuffix string, centerYear int) models.InlineKeyboardMarkup { - blockStart := centerYear - 6 - kbRows := [][]models.InlineKeyboardButton{ - { - {Text: "<", CallbackData: fmt.Sprintf("%syears_%d%s", cbPrefix, blockStart-12, navSuffix)}, - {Text: fmt.Sprintf("%d", centerYear), CallbackData: "noop"}, - {Text: ">", CallbackData: fmt.Sprintf("%syears_%d%s", cbPrefix, blockStart+12, navSuffix)}, - }, - } - for i := 0; i < 12; i += 4 { - row := []models.InlineKeyboardButton{} - for j := i; j < i+4; j++ { - y := blockStart + j - row = append(row, models.InlineKeyboardButton{ - Text: fmt.Sprintf("%d", y), CallbackData: fmt.Sprintf("%syear_%d", cbPrefix, y), - }) - } - kbRows = append(kbRows, row) - } - if backData != "" { - kbRows = append(kbRows, []models.InlineKeyboardButton{ - {Text: "Back", CallbackData: backData}, - }) - } - return models.InlineKeyboardMarkup{InlineKeyboard: kbRows} -} - -// editCalendar renders a monthly calendar grid with event indicators and navigation. func (h *Handler) editCalendar(ctx context.Context, chatID int64, msgID int, year, month int) { user, err := h.getOrCreateUser(chatID) if err != nil { return } - loc := loadLocation(user.Timezone) + loc := h.loadLocation(user.Timezone) now := time.Now().In(loc) if year == 0 || month == 0 { switch user.Calendar { case "jalali": - jy, jm, _ := gregorianToJalali(now.Year(), int(now.Month()), now.Day()) + jy, jm, _ := domain.GregorianToJalali(now.Year(), int(now.Month()), now.Day()) year, month = jy, jm case "hijri": - hy, hm, _ := gregorianToHijri(now.Year(), int(now.Month()), now.Day()) + hy, hm, _ := domain.GregorianToHijri(now.Year(), int(now.Month()), now.Day()) year, month = hy, hm default: year, month = now.Year(), int(now.Month()) @@ -256,52 +48,47 @@ func (h *Handler) editCalendar(ctx context.Context, chatID int64, msgID int, yea } cal := user.Calendar - cm := buildCalendarMonth(cal, year, month) - - text := cm.title - todayKey := now.Format(DateLayout) + cm := domain.BuildCalendarMonth(cal, year, month) + text := cm.Title + todayKey := now.Format(domain.DateLayout) hasEvent := make(map[string]bool) - startDate, endDate := monthGregorianRange(cal, year, month) - dates, err := h.DB.GetDistinctEventDates(user.ID, startDate, endDate) + startDate, endDate := domain.MonthGregorianRange(cal, year, month) + dates, err := h.Repo.GetDistinctEventDates(user.ID, startDate, endDate) if err == nil { for _, d := range dates { hasEvent[d] = true } } - kbRows := [][]models.InlineKeyboardButton{} + kbRows := [][]models.InlineKeyboardButton{ + { + {Text: "<", CallbackData: fmt.Sprintf("cal_prev_year_%d_%d", year-1, month)}, + {Text: fmt.Sprintf("%d", year), CallbackData: fmt.Sprintf("cal_show_years_%d_%d", year, month)}, + {Text: ">", CallbackData: fmt.Sprintf("cal_next_year_%d_%d", year+1, month)}, + }, + } - // Year navigation row - kbRows = append(kbRows, []models.InlineKeyboardButton{ - {Text: fmt.Sprint("<"), CallbackData: fmt.Sprintf("cal_prev_year_%d_%d", year-1, month)}, - {Text: fmt.Sprintf("%d", year), CallbackData: fmt.Sprintf("cal_show_years_%d_%d", year, month)}, - {Text: fmt.Sprint(">"), CallbackData: fmt.Sprintf("cal_next_year_%d_%d", year+1, month)}, - }) - - // Weekday header row headerRow := []models.InlineKeyboardButton{} - for _, wn := range cm.weekDays { + for _, wn := range cm.WeekDays { headerRow = append(headerRow, models.InlineKeyboardButton{Text: wn, CallbackData: "noop"}) } kbRows = append(kbRows, headerRow) - // Day cells row := []models.InlineKeyboardButton{} - for i, d := range cm.days { - if d.dayNum == 0 { + for i, d := range cm.Days { + if d.DayNum == 0 { row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "noop"}) } else { - label := fmt.Sprintf("%d", d.dayNum) - if d.date == todayKey { - label = fmt.Sprintf("[%d]", d.dayNum) - } else if hasEvent[d.date] { - label = fmt.Sprintf("%d*", d.dayNum) + label := fmt.Sprintf("%d", d.DayNum) + if d.Date == todayKey { + label = fmt.Sprintf("[%d]", d.DayNum) + } else if hasEvent[d.Date] { + label = fmt.Sprintf("%d*", d.DayNum) } - row = append(row, models.InlineKeyboardButton{Text: label, CallbackData: "cal_day_" + d.date}) + row = append(row, models.InlineKeyboardButton{Text: label, CallbackData: "cal_day_" + d.Date}) } - if len(row) == 7 || i == len(cm.days)-1 { - // Pad the last row to 7 columns so buttons render evenly + if len(row) == 7 || i == len(cm.Days)-1 { for len(row) < 7 { row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "noop"}) } @@ -310,15 +97,12 @@ func (h *Handler) editCalendar(ctx context.Context, chatID int64, msgID int, yea } } - prevY, prevM, nextY, nextM := navMonth(year, month) - - // Navigation row + prevY, prevM, nextY, nextM := domain.NavMonth(year, month) kbRows = append(kbRows, []models.InlineKeyboardButton{ {Text: "<", CallbackData: fmt.Sprintf("cal_prev_%d_%d", prevY, prevM)}, {Text: "Today", CallbackData: "history"}, {Text: ">", CallbackData: fmt.Sprintf("cal_next_%d_%d", nextY, nextM)}, }) - kbRows = append(kbRows, []models.InlineKeyboardButton{ {Text: "Back to Menu", CallbackData: "back_menu"}, }) @@ -327,8 +111,115 @@ func (h *Handler) editCalendar(ctx context.Context, chatID int64, msgID int, yea h.sendOrEdit(ctx, chatID, msgID, text, &kb) } -// editCalendarYearPicker shows a 12-year grid for year selection. -// refY/refM encode the view to return to via the Back button. +// -- Calendar callbacks routing -- + +func (h *Handler) routeHistoryCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, data string) { + loc := h.loadLocation(user.Timezone) + + if data == "history" { + h.editCalendar(ctx, chatID, msgID, 0, 0) + return + } + + 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 + } + if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 { + h.editCalendar(ctx, chatID, msgID, y, m) + return + } + if n, _ := fmt.Sscanf(data, "cal_prev_year_%d_%d", &y, &m); n == 2 { + h.editCalendar(ctx, chatID, msgID, y, m) + return + } + if n, _ := fmt.Sscanf(data, "cal_next_year_%d_%d", &y, &m); n == 2 { + h.editCalendar(ctx, chatID, msgID, y, m) + return + } + + if n, _ := fmt.Sscanf(data, "cal_show_years_%d_%d", &y, &m); n == 2 { + h.editCalendarYearPicker(ctx, chatID, msgID, y, m, y) + return + } + 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 + } + if n, _ := fmt.Sscanf(data, "cal_year_%d", &y); n == 1 { + h.editCalendar(ctx, chatID, msgID, y, 1) + return + } + if n, _ := fmt.Sscanf(data, "cal_years_back_%d_%d", &y, &m); n == 2 { + h.editCalendar(ctx, chatID, msgID, y, m) + return + } + + 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 + } + + var eid int64 + if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 { + h.backToDayView(ctx, chatID, msgID, user, eid, loc) + return + } + + var wtid int64 + if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 { + h.setEventWorkType(ctx, chatID, msgID, user, eid, wtid, loc) + return + } + if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 { + h.editEventType(ctx, chatID, msgID, user, eid, loc) + return + } + if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 { + h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc) + return + } + if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 { + h.deleteEventConfirm(ctx, chatID, msgID, user, eid, loc) + return + } + if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 { + h.editEventTime(ctx, chatID, msgID, user, eid, loc) + return + } + 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, cbID) + return + } + if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 { + h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc) + return + } + if n, _ := fmt.Sscanf(data, "note_%d", &eid); n == 1 { + h.promptNoteInput(ctx, chatID, msgID, user, eid, loc) + return + } + if n, _ := fmt.Sscanf(data, "note_cancel_%d", &eid); n == 1 { + h.backToDayView(ctx, chatID, msgID, user, eid, loc) + return + } + + switch { + case strings.HasPrefix(data, "addin_"): + h.addEventTime(ctx, chatID, msgID, user, data[6:], "in", loc) + case strings.HasPrefix(data, "addout_"): + h.addEventTime(ctx, chatID, msgID, user, data[7:], "out", loc) + case strings.HasPrefix(data, "addtm_"): + h.handleAddEventTime(ctx, chatID, msgID, user, data[6:], loc) + } +} + +// -- Calendar year picker -- + func (h *Handler) editCalendarYearPicker(ctx context.Context, chatID int64, msgID int, centerYear, refM, refY int) { backData := fmt.Sprintf("cal_years_back_%d_%d", refY, refM) navSuffix := fmt.Sprintf("_%d_%d", refY, refM) @@ -336,22 +227,21 @@ func (h *Handler) editCalendarYearPicker(ctx context.Context, chatID int64, msgI h.editText(ctx, chatID, msgID, "Select year:", &kb) } -// editDayView opens an existing message as a day view. -func (h *Handler) editDayView(ctx context.Context, chatID int64, msgID int, user *db.User, date string, loc *time.Location) { +// -- Day view -- + +func (h *Handler) editDayView(ctx context.Context, chatID int64, msgID int, user *domain.User, date string, loc *time.Location) { h.sendDayView(ctx, chatID, msgID, user, date, loc, false) } -// sendDayView displays all events for a given date with inline edit/delete buttons. -func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user *db.User, date string, loc *time.Location, isNewMsg bool) { - events, err := h.DB.EventsForDayByDate(user.ID, date) +func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user *domain.User, date string, loc *time.Location, isNewMsg bool) { + events, err := h.Repo.EventsForDayByDate(user.ID, date) if err != nil { if isNewMsg { h.sendText(ctx, chatID, "Error loading events") } return } - - text := fmt.Sprintf("Date: %s", formatDateForCalendar(date, user.Calendar)) + text := fmt.Sprintf("Date: %s", domain.FormatDateForCalendar(date, user.Calendar)) if len(events) == 0 { text += "\nNo events for this day." kb := models.InlineKeyboardMarkup{ @@ -368,14 +258,14 @@ 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(TimeLayout) + t := time.Unix(e.OccurredAt, 0).In(loc).Format(domain.TimeLayout) label := "IN" if e.EventType == "out" { label = "OUT" } wt := "" if e.WorkTypeID != nil { - if wtObj, err := h.DB.GetWorkType(*e.WorkTypeID); err == nil { + if wtObj, err := h.Repo.GetWorkType(*e.WorkTypeID); err == nil { wt = " [" + wtObj.Name + "]" } } @@ -384,7 +274,6 @@ func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user note = " (" + e.Note + ")" } text += fmt.Sprintf("\n%s %s%s%s", label, t, wt, note) - kbRows = append(kbRows, []models.InlineKeyboardButton{ {Text: fmt.Sprintf("Time %s", t), CallbackData: fmt.Sprintf("edit_time_%d", e.ID)}, {Text: "Type", CallbackData: fmt.Sprintf("edit_type_%d", e.ID)}, @@ -392,12 +281,10 @@ func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user {Text: "DEL", CallbackData: fmt.Sprintf("delete_%d", e.ID), Style: "danger"}, }) } - kbRows = append(kbRows, []models.InlineKeyboardButton{ {Text: "Add IN", CallbackData: "addin_" + date}, {Text: "Add OUT", CallbackData: "addout_" + date}, }) - kbRows = append(kbRows, []models.InlineKeyboardButton{ {Text: "Back to Calendar", CallbackData: "history"}, }) @@ -405,9 +292,10 @@ func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user h.sendOrEdit(ctx, chatID, msgID, text, &kb) } -// editEventType shows a work type picker for a specific event. -func (h *Handler) editEventType(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { - wts, err := h.DB.GetWorkTypes() +// -- Event editing -- + +func (h *Handler) editEventType(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) { + wts, err := h.Repo.GetWorkTypes() if err != nil { return } @@ -425,54 +313,21 @@ func (h *Handler) editEventType(ctx context.Context, chatID int64, msgID int, us h.editText(ctx, chatID, msgID, text, &kb) } -// setEventWorkType updates an event's work type and returns to the day view. -func (h *Handler) setEventWorkType(ctx context.Context, chatID int64, msgID int, user *db.User, eventID, workTypeID int64, loc *time.Location) { - if err := h.DB.UpdateEventWorkType(eventID, workTypeID); err != nil { +func (h *Handler) setEventWorkType(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID, workTypeID int64, loc *time.Location) { + if err := h.Repo.UpdateEventWorkType(eventID, workTypeID); err != nil { slog.Error("failed to update event work type", "event_id", eventID, "error", err) h.sendDayView(ctx, chatID, msgID, user, "", loc, false) return } - event, err := h.getEventByID(user.ID, eventID) + event, err := h.Repo.GetEventByID(eventID, user.ID) if err != nil { return } t := time.Unix(event.OccurredAt, 0).In(loc) - h.sendDayView(ctx, chatID, msgID, user, t.Format(DateLayout), loc, false) + h.sendDayView(ctx, chatID, msgID, user, t.Format(domain.DateLayout), loc, false) } -// buildHourPickerKeyboard creates the 24-hour grid for time selection. -func buildHourPickerKeyboard(cb func(int) string, backData string) models.InlineKeyboardMarkup { - kbRows := [][]models.InlineKeyboardButton{} - hourRow := []models.InlineKeyboardButton{} - for hh := 0; hh < 24; hh++ { - hourRow = append(hourRow, models.InlineKeyboardButton{ - Text: fmt.Sprintf("%02d", hh), CallbackData: cb(hh), - }) - if len(hourRow) == 6 || hh == 23 { - kbRows = append(kbRows, hourRow) - hourRow = nil - } - } - kbRows = append(kbRows, []models.InlineKeyboardButton{{Text: "Back", CallbackData: backData}}) - return models.InlineKeyboardMarkup{InlineKeyboard: kbRows} -} - -// buildMinutePickerKeyboard creates the 15-min interval row for minute selection. -func buildMinutePickerKeyboard(cb func(int) string, backData string) models.InlineKeyboardMarkup { - kbRows := [][]models.InlineKeyboardButton{} - minRow := []models.InlineKeyboardButton{} - for _, mm := range []int{0, 10, 20, 30, 40, 50} { - minRow = append(minRow, models.InlineKeyboardButton{ - Text: fmt.Sprintf("%02d", mm), CallbackData: cb(mm), - }) - } - kbRows = append(kbRows, minRow) - kbRows = append(kbRows, []models.InlineKeyboardButton{{Text: "Back", CallbackData: backData}}) - return models.InlineKeyboardMarkup{InlineKeyboard: kbRows} -} - -// editEventTime shows an hour picker for changing an event's time. -func (h *Handler) editEventTime(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { +func (h *Handler) editEventTime(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) { kb := buildHourPickerKeyboard( func(hh int) string { return fmt.Sprintf("edittm_%d_%02d", eventID, hh) }, fmt.Sprintf("back_day_%d", eventID), @@ -480,8 +335,7 @@ func (h *Handler) editEventTime(ctx context.Context, chatID int64, msgID int, us h.editText(ctx, chatID, msgID, "Select hour:", &kb) } -// editEventTimeMin shows a minute picker (15-min intervals) after hour selection. -func (h *Handler) editEventTimeMin(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, hh int, loc *time.Location) { +func (h *Handler) editEventTimeMin(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, hh int, loc *time.Location) { kb := buildMinutePickerKeyboard( func(mm int) string { return fmt.Sprintf("edittm_%d_%02d_%02d", eventID, hh, mm) }, fmt.Sprintf("edit_time_%d", eventID), @@ -489,18 +343,16 @@ func (h *Handler) editEventTimeMin(ctx context.Context, chatID int64, msgID int, h.editText(ctx, chatID, msgID, fmt.Sprintf("Select minute for hour %02d:", hh), &kb) } -// 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, callbackID string) { - event, err := h.getEventByID(user.ID, eventID) +func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, hh, mm int, loc *time.Location, cbID string) { + event, err := h.Repo.GetEventByID(eventID, user.ID) 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 := newTime.Format(DateLayout) + date := newTime.Format(domain.DateLayout) - // Reject if the new time collides with another event's timestamp - events, err := h.DB.EventsForDayByDate(user.ID, date) + events, err := h.Repo.EventsForDayByDate(user.ID, date) if err == nil { for _, e := range events { if e.ID == eventID { @@ -508,13 +360,11 @@ 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, callbackID) + h.answerCb(ctx, cbID) h.sendDayView(ctx, chatID, msgID, user, date, loc, false) return } } - - // Validate that in/out events still alternate after the change type eventPos struct { id int64 typ string @@ -528,7 +378,6 @@ func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, } sorted = append(sorted, eventPos{e.ID, e.EventType, et}) } - // Bubble sort by timestamp, then by ID for determinism for i := 0; i < len(sorted); i++ { for j := i + 1; j < len(sorted); j++ { if sorted[j].t < sorted[i].t || (sorted[j].t == sorted[i].t && sorted[j].id < sorted[i].id) { @@ -538,14 +387,14 @@ func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, } for i := 1; i < len(sorted); i++ { if sorted[i].typ == sorted[i-1].typ { - h.editText(ctx, chatID, msgID, "Edit rejected: overlapping events. Two consecutive events must be different types (in/out).", nil) + h.editText(ctx, chatID, msgID, "Edit rejected: overlapping events.", nil) return } } } - if err = h.DB.UpdateEventTime(eventID, newTime.Unix()); err != nil { - slog.Error("failed to update event occurred_at", "event_id", eventID, "error", err) + if err = h.Repo.UpdateEventTime(eventID, newTime.Unix()); err != nil { + slog.Error("failed to update event time", "event_id", eventID, "error", err) h.sendDayView(ctx, chatID, msgID, user, date, loc, false) return } @@ -554,33 +403,9 @@ func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, h.sendDayView(ctx, chatID, msgID, user, date, loc, false) } -// acknowledgeCallback sends an empty acknowledgement for a callback query. -func (h *Handler) acknowledgeCallback(ctx context.Context, callbackID string) { - h.answerCb(ctx, callbackID) -} +// -- Event deletion -- -// promptNote asks the user to type a note for the event, then re-renders the day view. -func (h *Handler) promptNote(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { - event, err := h.getEventByID(user.ID, eventID) - if err != nil { - return - } - t := time.Unix(event.OccurredAt, 0).In(loc).Format(TimeLayout) - - h.mu.Lock() - h.pendingNotes[chatID] = &pendingNote{eventID: eventID, createdAt: time.Now()} - h.mu.Unlock() - - kb := models.InlineKeyboardMarkup{ - InlineKeyboard: [][]models.InlineKeyboardButton{ - {{Text: "Cancel", CallbackData: fmt.Sprintf("note_cancel_%d", eventID)}}, - }, - } - h.editText(ctx, chatID, msgID, fmt.Sprintf("Send the note for event at %s:", t), &kb) -} - -// deleteEventPrompt asks the user to confirm event deletion. -func (h *Handler) deleteEventPrompt(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { +func (h *Handler) deleteEventPrompt(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) { kb := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ { @@ -592,23 +417,21 @@ func (h *Handler) deleteEventPrompt(ctx context.Context, chatID int64, msgID int h.editText(ctx, chatID, msgID, "Delete this event?", &kb) } -// deleteEventConfirm deletes an event and warns if the timeline becomes invalid. -func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { - event, err := h.getEventByID(user.ID, eventID) +func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) { + event, err := h.Repo.GetEventByID(eventID, user.ID) if err != nil { return } t := time.Unix(event.OccurredAt, 0).In(loc) - date := t.Format(DateLayout) + date := t.Format(domain.DateLayout) - if err = h.DB.DeleteEvent(eventID); err != nil { + if err = h.Repo.DeleteEvent(eventID); err != nil { slog.Error("failed to delete event", "event_id", eventID, "error", err) h.sendDayView(ctx, chatID, msgID, user, date, loc, false) return } - // Warn if consecutive events now have the same type - events, err := h.DB.EventsForDayByDate(user.ID, date) + events, err := h.Repo.EventsForDayByDate(user.ID, date) if err == nil && len(events) > 1 { hasIssue := false for i := 1; i < len(events); i++ { @@ -618,7 +441,7 @@ func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID in } } if hasIssue { - h.sendText(ctx, chatID, "Warning: Timeline has consecutive events of same type after deletion. Manual repair needed.") + h.sendText(ctx, chatID, "Warning: Timeline has consecutive events of same type after deletion.") } } @@ -627,23 +450,36 @@ func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID in h.sendDayView(ctx, chatID, msgID, user, date, loc, false) } -// backToDayView returns from event editing to the day view. -func (h *Handler) backToDayView(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { - event, err := h.getEventByID(user.ID, eventID) +func (h *Handler) backToDayView(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) { + event, err := h.Repo.GetEventByID(eventID, user.ID) if err != nil { return } t := time.Unix(event.OccurredAt, 0).In(loc) - h.sendDayView(ctx, chatID, msgID, user, t.Format(DateLayout), loc, false) + h.sendDayView(ctx, chatID, msgID, user, t.Format(domain.DateLayout), loc, false) } -// getEventByID fetches a single event by ID, scoped to the user. -func (h *Handler) getEventByID(userID, eventID int64) (*db.Event, error) { - return h.DB.GetEventByID(eventID, userID) +// -- Note via pending input -- + +func (h *Handler) promptNoteInput(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) { + event, err := h.Repo.GetEventByID(eventID, user.ID) + if err != nil { + return + } + t := time.Unix(event.OccurredAt, 0).In(loc).Format(domain.TimeLayout) + + kb := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Cancel", CallbackData: fmt.Sprintf("note_cancel_%d", eventID)}}, + }, + } + h.editText(ctx, chatID, msgID, fmt.Sprintf("Send the note for event at %s:", t), &kb) + h.setPending(ctx, chatID, msgID, user.ID, PendingNote, map[string]string{"event_id": fmt.Sprintf("%d", eventID)}) } -// addEventTime shows an hour picker for adding a new event to a day. -func (h *Handler) addEventTime(ctx context.Context, chatID int64, msgID int, user *db.User, date, eventType string, loc *time.Location) { +// -- Adding events -- + +func (h *Handler) addEventTime(ctx context.Context, chatID int64, msgID int, user *domain.User, date, eventType string, loc *time.Location) { kb := buildHourPickerKeyboard( func(hh int) string { return fmt.Sprintf("addtm_%s_%s_%02d", eventType, date, hh) }, "cal_day_"+date, @@ -651,46 +487,7 @@ func (h *Handler) addEventTime(ctx context.Context, chatID int64, msgID int, use h.editText(ctx, chatID, msgID, fmt.Sprintf("Select hour for %s event:", eventType), &kb) } -// addEventTimeMin shows a minute picker (15-min intervals) after hour selection for adding an event. -func (h *Handler) addEventTimeMin(ctx context.Context, chatID int64, msgID int, user *db.User, date, eventType string, hh int, loc *time.Location) { - kb := buildMinutePickerKeyboard( - func(mm int) string { return fmt.Sprintf("addtm_%s_%s_%02d_%02d", eventType, date, hh, mm) }, - "add"+eventType+"_"+date, - ) - h.editText(ctx, chatID, msgID, fmt.Sprintf("Select minute for hour %02d:", hh), &kb) -} - -// addEventDo creates a new event at the specified time and returns to the day view. -func (h *Handler) addEventDo(ctx context.Context, chatID int64, msgID int, user *db.User, date, eventType string, hh, mm int, loc *time.Location) { - t, err := time.ParseInLocation("2006-01-02 15:04", date+" "+fmt.Sprintf("%02d:%02d", hh, mm), loc) - if err != nil { - return - } - - day, err := h.DB.GetOrCreateDay(user.ID, date) - if err != nil { - return - } - - var wt *int64 - if eventType == "in" { - wt = &day.CurrentWorkTypeID - if *wt == 0 { - wt = nil - } - } - - if err := h.DB.CreateEvent(user.ID, day.ID, eventType, wt, t.Unix(), ""); err != nil { - return - } - - h.recalcDayWorkSeconds(user.ID, date, loc) - h.recalcAggregates(user.ID, loc) - h.sendDayView(ctx, chatID, msgID, user, date, loc, false) -} - -// handleAddEventTime parses the addtm_ callback and dispatches to the minute picker or event creation. -func (h *Handler) handleAddEventTime(ctx context.Context, chatID int64, msgID int, user *db.User, payload string, loc *time.Location) { +func (h *Handler) handleAddEventTime(ctx context.Context, chatID int64, msgID int, user *domain.User, payload string, loc *time.Location) { parts := strings.SplitN(payload, "_", 4) if len(parts) < 3 { return @@ -701,7 +498,6 @@ func (h *Handler) handleAddEventTime(ctx context.Context, chatID int64, msgID in if err != nil { return } - if len(parts) == 3 { h.addEventTimeMin(ctx, chatID, msgID, user, addDate, eventType, hh, loc) return @@ -712,3 +508,35 @@ func (h *Handler) handleAddEventTime(ctx context.Context, chatID int64, msgID in } h.addEventDo(ctx, chatID, msgID, user, addDate, eventType, hh, mm, loc) } + +func (h *Handler) addEventTimeMin(ctx context.Context, chatID int64, msgID int, user *domain.User, date, eventType string, hh int, loc *time.Location) { + kb := buildMinutePickerKeyboard( + func(mm int) string { return fmt.Sprintf("addtm_%s_%s_%02d_%02d", eventType, date, hh, mm) }, + "add"+eventType+"_"+date, + ) + h.editText(ctx, chatID, msgID, fmt.Sprintf("Select minute for hour %02d:", hh), &kb) +} + +func (h *Handler) addEventDo(ctx context.Context, chatID int64, msgID int, user *domain.User, date, eventType string, hh, mm int, loc *time.Location) { + t, err := time.ParseInLocation("2006-01-02 15:04", date+" "+fmt.Sprintf("%02d:%02d", hh, mm), loc) + if err != nil { + return + } + day, err := h.Repo.GetOrCreateDay(user.ID, date) + if err != nil { + return + } + var wt *int64 + if eventType == "in" { + wt = &day.CurrentWorkTypeID + if *wt == 0 { + wt = nil + } + } + if err := h.Repo.CreateEvent(user.ID, day.ID, eventType, wt, t.Unix(), ""); err != nil { + return + } + h.recalcDayWorkSeconds(user.ID, date, loc) + h.recalcAggregates(user.ID, loc) + h.sendDayView(ctx, chatID, msgID, user, date, loc, false) +} diff --git a/internal/handler/clock.go b/internal/handler/clock.go new file mode 100644 index 0000000..7fb0ed0 --- /dev/null +++ b/internal/handler/clock.go @@ -0,0 +1,222 @@ +package handler + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/go-telegram/bot/models" + + "worktimeBot/internal/domain" +) + +// -- Commands -- + +func (h *Handler) handleClockIn(ctx context.Context, msg *models.Message, user *domain.User) { + text, err := h.doClockIn(user) + if err != nil { + h.sendText(ctx, msg.Chat.ID, err.Error()) + return + } + h.sendWithKB(ctx, msg.Chat.ID, text, mainKeyboard()) +} + +func (h *Handler) handleClockOut(ctx context.Context, msg *models.Message, user *domain.User) { + text, err := h.doClockOut(user) + if err != nil { + h.sendText(ctx, msg.Chat.ID, err.Error()) + return + } + h.sendWithKB(ctx, msg.Chat.ID, text, mainKeyboard()) +} + +func (h *Handler) handleDayOff(ctx context.Context, msg *models.Message, user *domain.User) { + text, err := h.doDayOff(user) + if err != nil { + h.sendText(ctx, msg.Chat.ID, err.Error()) + return + } + h.sendWithKB(ctx, msg.Chat.ID, text, mainKeyboard()) +} + +func (h *Handler) handleReport(ctx context.Context, msg *models.Message, user *domain.User) { + text, err := h.doReport(user) + if err != nil { + h.sendText(ctx, msg.Chat.ID, err.Error()) + return + } + h.sendWithKB(ctx, msg.Chat.ID, text, mainKeyboard()) +} + +func (h *Handler) handleReportToggle(ctx context.Context, msg *models.Message, user *domain.User) { + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error loading settings") + return + } + st.ReportEnabled = !st.ReportEnabled + if err := h.Repo.UpdateSettings(st); err != nil { + h.sendText(ctx, msg.Chat.ID, "Error saving settings") + return + } + status := "disabled" + if st.ReportEnabled { + status = "enabled" + } + h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Daily report %s", status)) +} + +// -- Callbacks -- + +func (h *Handler) clockInCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + text, err := h.doClockIn(user) + if err != nil { + text = err.Error() + } + kb := backKeyboard() + h.editText(ctx, chatID, msgID, text, &kb) +} + +func (h *Handler) clockOutCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + text, err := h.doClockOut(user) + if err != nil { + text = err.Error() + } + kb := backKeyboard() + h.editText(ctx, chatID, msgID, text, &kb) +} + +func (h *Handler) dayOffCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + text, err := h.doDayOff(user) + if err != nil { + text = err.Error() + } + kb := backKeyboard() + h.editText(ctx, chatID, msgID, text, &kb) +} + +func (h *Handler) reportCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + text, err := h.doReport(user) + if err != nil { + text = err.Error() + } + kb := backKeyboard() + h.editText(ctx, chatID, msgID, text, &kb) +} + +// -- Business logic (shared between commands and callbacks) -- + +func (h *Handler) doClockIn(user *domain.User) (string, error) { + day, err := h.getTodayDay(user) + if err != nil { + return "", err + } + last, err := h.Repo.GetLastEvent(user.ID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return "", err + } + if last != nil && last.EventType == "in" { + loc := h.loadLocation(user.Timezone) + now := time.Now().In(loc) + lastTime := time.Unix(last.OccurredAt, 0).In(loc) + if lastTime.Format(domain.DateLayout) == now.Format(domain.DateLayout) { + return "", errors.New("already clocked in") + } + } + loc := h.loadLocation(user.Timezone) + now := time.Now().In(loc) + wt := day.CurrentWorkTypeID + if err := h.Repo.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil { + return "", err + } + return fmt.Sprintf("Clocked in at %s", now.Format(domain.TimeLayout)), nil +} + +func (h *Handler) doClockOut(user *domain.User) (string, error) { + day, err := h.getTodayDay(user) + if err != nil { + return "", err + } + last, err := h.Repo.GetLastEvent(user.ID) + if err != nil { + return "", err + } + if last == nil { + return "", errors.New("no clock-in found - start with /clockin first") + } + if last.EventType == "out" { + return "", errors.New("already clocked out") + } + loc := h.loadLocation(user.Timezone) + now := time.Now().In(loc) + if err := h.Repo.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil { + return "", err + } + text := fmt.Sprintf("Clocked out at %s", now.Format(domain.TimeLayout)) + + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err == nil && st.RPGEnabled { + sessionWork := now.Unix() - last.OccurredAt + today := now.Format(domain.DateLayout) + isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday + + stats, err := h.Repo.GetOrCreateRPGStats(user.ID) + if err == nil { + text += h.awardXPAndCheckAchievements(user, stats, sessionWork, today, isWeekend) + } + } + return text, nil +} + +func (h *Handler) doDayOff(user *domain.User) (string, error) { + loc := h.loadLocation(user.Timezone) + today := time.Now().In(loc).Format(domain.DateLayout) + day, err := h.Repo.GetOrCreateDay(user.ID, today) + if err != nil { + return "", err + } + if day.IsDayOff { + if err := h.Repo.RemoveDayOff(user.ID, day.Date); err != nil { + return "", err + } + return "Day off removed", nil + } + if err := h.Repo.SetDayOff(user.ID, day.Date, ""); err != nil { + return "", err + } + return "Today marked as day off", nil +} + +func (h *Handler) doReport(user *domain.User) (string, error) { + day, err := h.getTodayDay(user) + if err != nil { + return "", err + } + events, err := h.Repo.EventsForDayByDayID(day.ID) + if err != nil { + return "", err + } + return h.buildDailyReport(user, day, events), nil +} + +func (h *Handler) getTodayDay(user *domain.User) (*domain.Day, error) { + loc := h.loadLocation(user.Timezone) + today := time.Now().In(loc).Format(domain.DateLayout) + return h.Repo.GetOrCreateDay(user.ID, today) +} + +func (h *Handler) getTodayBreakThreshold(user *domain.User) int64 { + loc := h.loadLocation(user.Timezone) + today := time.Now().In(loc).Format(domain.DateLayout) + day, err := h.Repo.GetDay(user.ID, today) + if err != nil { + return domain.DefaultBreakThreshold + } + return day.MinBreakThreshold +} diff --git a/internal/bot/export.go b/internal/handler/export.go similarity index 68% rename from internal/bot/export.go rename to internal/handler/export.go index 8653417..d18f4e1 100644 --- a/internal/bot/export.go +++ b/internal/handler/export.go @@ -1,4 +1,4 @@ -package bot +package handler import ( "bytes" @@ -8,11 +8,11 @@ import ( "strings" "time" - bot "github.com/go-telegram/bot" + tgbot "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" "github.com/xuri/excelize/v2" - "worktimeBot/internal/db" + "worktimeBot/internal/domain" ) type themeColors struct { @@ -92,16 +92,107 @@ func newStyle(f *excelize.File, s *excelize.Style) int { return id } -func writeReportHeader(f *excelize.File, sheet, title string, s reportStyles) { +func (h *Handler) generateMonthlyReport(user *domain.User, calYear, calMonth int, start, end time.Time) ([]byte, error) { + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + return nil, err + } + accent := st.ExportAccent + theme, ok := themes[accent] + if !ok { + theme = themes["ocean"] + } + + f := excelize.NewFile() + defer f.Close() + + sheet := "Report" + index, err := f.NewSheet(sheet) + if err != nil { + return nil, err + } + f.SetActiveSheet(index) + f.DeleteSheet("Sheet1") + + styles := newReportStyles(f, theme) + monthName := domain.FormatMonthTitle(user.Calendar, calYear, calMonth) + h.writeReportHeader(f, sheet, fmt.Sprintf("Work Time Report - %s", monthName), styles) + + loc := h.loadLocation(user.Timezone) + row := 3 + + userDays, err := h.Repo.GetUserDaysInRange(user.ID, start.Format(domain.DateLayout), end.Format(domain.DateLayout)) + if err != nil { + return nil, err + } + dayMap := make(map[string]*domain.Day) + for i := range userDays { + dayMap[userDays[i].Date] = &userDays[i] + } + + var totalWork, totalBreak int64 + layout := start.Format(domain.DateLayout) + endLayout := end.Format(domain.DateLayout) + + cur := start + for !cur.After(end) { + dateStr := cur.Format(domain.DateLayout) + displayDate := domain.FormatDateForCalendar(dateStr, user.Calendar) + + var ( + day *domain.Day + events []domain.Event + hasDayOff bool + ) + if existing, ok := dayMap[dateStr]; ok { + day = existing + events, err = h.Repo.EventsForDayByDayID(day.ID) + if err != nil { + return nil, err + } + hasDayOff = day.IsDayOff + } + + var typeLabel string + if hasDayOff { + if day.DayOffReason != "" { + typeLabel = "Day Off (" + day.DayOffReason + ")" + } else { + typeLabel = "Day Off" + } + } else if len(events) > 0 { + typeLabel = h.workTypeLabel(day, events) + } + + ws, bs := h.writeReportDayRow(f, sheet, row, displayDate, typeLabel, events, day, loc, hasDayOff, styles) + totalWork += ws + totalBreak += bs + row++ + cur = cur.AddDate(0, 0, 1) + } + + _ = layout + _ = endLayout + + h.writeReportTotalRow(f, sheet, row, totalWork, totalBreak, styles) + + buf, err := f.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func (h *Handler) 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 { + for i, hdr := range headers { cell, _ := excelize.CoordinatesToCellName(i+1, 2) - f.SetCellValue(sheet, cell, h) + f.SetCellValue(sheet, cell, hdr) f.SetCellStyle(sheet, cell, cell, s.header) } f.SetRowHeight(sheet, 2, 22) @@ -114,21 +205,21 @@ func writeReportHeader(f *excelize.File, sheet, title string, s reportStyles) { 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) { +func (h *Handler) writeReportDayRow(f *excelize.File, sheet string, row int, displayDate, typeLabel string, events []domain.Event, day *domain.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.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).In(loc).Format(domain.TimeLayout)) f.SetCellStyle(sheet, cellIn, cellIn, s.data) } - if len(events) > 0 { + if len(events) > 1 { 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.SetCellValue(sheet, cellOut, time.Unix(lastEvent.OccurredAt, 0).In(loc).Format(domain.TimeLayout)) f.SetCellStyle(sheet, cellOut, cellOut, s.data) } } @@ -138,13 +229,14 @@ func writeReportDayRow(f *excelize.File, sheet string, row int, displayDate, typ 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 + d, err := time.Parse(domain.DateLayout, day.Date) + if err == 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 := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) + workSec = totals.TotalSeconds + breakSec = totals.BreakSeconds + } } cellWork, _ := excelize.CoordinatesToCellName(5, row) @@ -164,7 +256,7 @@ func writeReportDayRow(f *excelize.File, sheet string, row int, displayDate, typ return workSec, breakSec } -func writeReportTotalRow(f *excelize.File, sheet string, row int, totalWork, totalBreak int64, s reportStyles) { +func (h *Handler) 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) @@ -174,88 +266,21 @@ func writeReportTotalRow(f *excelize.File, sheet string, row int, totalWork, tot 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 { - theme = themes["ocean"] - } - - f := excelize.NewFile() - defer f.Close() - - sheet := "Report" - index, err := f.NewSheet(sheet) - if err != nil { - return nil, err - } - f.SetActiveSheet(index) - f.DeleteSheet("Sheet1") - - styles := newReportStyles(f, theme) - - monthName := formatMonthTitle(calendar, calYear, calMonth) - writeReportHeader(f, sheet, fmt.Sprintf("Work Time Report - %s", monthName), styles) - - loc := loadLocation(timezone) - row := 3 - - userDays, err := store.GetUserDaysInRange(userID, start.Format(DateLayout), end.Format(DateLayout)) - if err != nil { - return nil, err - } - - dayMap := make(map[string]*db.Day) - for i := range userDays { - dayMap[userDays[i].Date] = &userDays[i] - } - - var totalWork, totalBreak int64 - - for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { - dateStr := d.Format(DateLayout) - displayDate := formatDateForCalendar(dateStr, calendar) - - var ( - day *db.Day - events []db.Event - hasDayOff bool - ) - if existing, ok := dayMap[dateStr]; ok { - day = existing - events, err = store.EventsForDayByDayID(day.ID) - if err != nil { - return nil, err - } - hasDayOff = day.IsDayOff - } - - var typeLabel string - if hasDayOff { - if day.DayOffReason != "" { - typeLabel = "Day Off (" + day.DayOffReason + ")" - } else { - typeLabel = "Day Off" - } - } else if len(events) > 0 { - typeLabel = workTypeLabel(store, day, events) - } - - ws, bs := writeReportDayRow(f, sheet, row, displayDate, typeLabel, events, day, loc, hasDayOff, styles) - totalWork += ws - totalBreak += bs - row++ - } - - writeReportTotalRow(f, sheet, row, totalWork, totalBreak, styles) - - buf, err := f.WriteToBuffer() - if err != nil { - return nil, err - } - return buf.Bytes(), nil +func fmtHHMM(seconds int64) string { + hours := seconds / domain.SecondsPerHour + mins := (seconds % domain.SecondsPerHour) / 60 + return fmt.Sprintf("%d:%02d", hours, mins) } -func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string { +func fmtDDHHMM(seconds int64) string { + days := seconds / domain.SecondsPerDay + rem := seconds % domain.SecondsPerDay + hours := rem / domain.SecondsPerHour + mins := (rem % domain.SecondsPerHour) / 60 + return fmt.Sprintf("%d:%02d:%02d", days, hours, mins) +} + +func (h *Handler) workTypeLabel(day *domain.Day, events []domain.Event) string { wtSeen := make(map[int64]bool) for _, e := range events { if e.WorkTypeID != nil { @@ -263,7 +288,7 @@ func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string { } } if len(wtSeen) == 0 { - wt, err := store.GetWorkType(day.CurrentWorkTypeID) + wt, err := h.Repo.GetWorkType(day.CurrentWorkTypeID) if err == nil { return wt.Name } @@ -271,7 +296,7 @@ func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string { } if len(wtSeen) == 1 { for id := range wtSeen { - wt, err := store.GetWorkType(id) + wt, err := h.Repo.GetWorkType(id) if err == nil { return wt.Name } @@ -280,85 +305,64 @@ func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string { return "mixed" } -func fmtHHMM(seconds int64) string { - hours := seconds / secondsPerHour - mins := (seconds % secondsPerHour) / 60 - return fmt.Sprintf("%d:%02d", hours, mins) -} +// -- Commands -- -func fmtDDHHMM(seconds int64) string { - days := seconds / secondsPerDay - rem := seconds % secondsPerDay - hours := rem / secondsPerHour - mins := (rem % secondsPerHour) / 60 - return fmt.Sprintf("%d:%02d:%02d", days, hours, mins) -} - -// handleExport processes the /export command, showing the month picker or exporting directly. -func (h *Handler) handleExport(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 - } - loc := loadLocation(user.Timezone) +func (h *Handler) handleExport(ctx context.Context, msg *models.Message, user *domain.User) { + loc := h.loadLocation(user.Timezone) now := time.Now().In(loc) - cy, cm := now.Year(), int(now.Month()) + calY, calM := now.Year(), int(now.Month()) switch user.Calendar { case "jalali": - cy, cm, _ = gregorianToJalali(cy, cm, now.Day()) + calY, calM, _ = domain.GregorianToJalali(calY, calM, now.Day()) case "hijri": - cy, cm, _ = gregorianToHijri(cy, cm, now.Day()) + calY, calM, _ = domain.GregorianToHijri(calY, calM, now.Day()) } args := strings.Fields(msg.Text) - if len(args) > 0 && args[0][0] == '/' { + if len(args) >= 1 && args[0][0] == '/' { args = args[1:] } if len(args) >= 1 { - if n, _ := fmt.Sscanf(args[0], "%d-%d", &cy, &cm); n == 2 { - if cy < 0 || cm < 1 || cm > 12 { + if n, _ := fmt.Sscanf(args[0], "%d-%d", &calY, &calM); n == 2 { + if calY < 0 || calM < 1 || calM > 12 { h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /export YYYY-MM") return } } } - h.sendExportDirect(ctx, msg.Chat.ID, user, cy, cm) + h.sendExportDirect(ctx, msg.Chat.ID, user, calY, calM) } -// sendExportDirect generates and sends an Excel report for the given calendar month. -func (h *Handler) sendExportDirect(ctx context.Context, chatID int64, user *db.User, calYear, calMonth int) { - last, err := h.DB.GetLastEvent(user.ID) +func (h *Handler) sendExportDirect(ctx context.Context, chatID int64, user *domain.User, calYear, calMonth int) { + last, err := h.Repo.GetLastEvent(user.ID) if err == nil && last != nil && last.EventType == "in" { h.sendText(ctx, chatID, "You are currently clocked in. Please clock out first, then try again.") return } - loc := loadLocation(user.Timezone) - startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth) - start, err := time.Parse(DateLayout, startStr) + loc := h.loadLocation(user.Timezone) + startStr, endStr := domain.MonthGregorianRange(user.Calendar, calYear, calMonth) + start, err := time.Parse(domain.DateLayout, startStr) if err != nil { start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc) } - end, err := time.Parse(DateLayout, endStr) + end, err := time.Parse(domain.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, accent, user.Calendar, calYear, calMonth, start, end) + data, err := h.generateMonthlyReport(user, calYear, calMonth, start, end) if err != nil { slog.Error("generate report", "error", err) h.sendText(ctx, chatID, "Error generating report") return } slog.Info("report generated", "bytes", len(data)) - if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{ + if _, err := h.Bot.SendDocument(ctx, &tgbot.SendDocumentParams{ ChatID: chatID, Document: &models.InputFileUpload{ Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)), @@ -369,37 +373,32 @@ func (h *Handler) sendExportDirect(ctx context.Context, chatID int64, user *db.U } } -// exportCallback opens the export month picker. -func (h *Handler) exportCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { - defer h.answerCb(ctx, callbackID) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - loc := loadLocation(user.Timezone) +// -- Callbacks -- + +func (h *Handler) exportCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + loc := h.loadLocation(user.Timezone) now := time.Now().In(loc) cy, cm := now.Year(), int(now.Month()) switch user.Calendar { case "jalali": - cy, cm, _ = gregorianToJalali(cy, cm, now.Day()) + cy, cm, _ = domain.GregorianToJalali(cy, cm, now.Day()) case "hijri": - cy, cm, _ = gregorianToHijri(cy, cm, now.Day()) + cy, cm, _ = domain.GregorianToHijri(cy, cm, now.Day()) } h.editExportMonthPicker(ctx, chatID, msgID, cy, cm, user) } -// editExportMonthPicker renders a year-based month picker with 12 month buttons. -func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, _ int, user *db.User) { - months := gregMonthNames +func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, _ int, user *domain.User) { + months := domain.GregMonthNames switch user.Calendar { case "jalali": - months = jalaliMonthNames + months = domain.JalaliMonthNames case "hijri": - months = hijriMonthNames + months = domain.HijriMonthNames } kbRows := [][]models.InlineKeyboardButton{ - // Year navigation { {Text: "<", CallbackData: fmt.Sprintf("exp_year_prev_%d", year)}, {Text: fmt.Sprintf("%d", year), CallbackData: fmt.Sprintf("exp_show_years_%d", year)}, @@ -407,7 +406,6 @@ func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID }, } - // 4 columns x 3 rows of month buttons for i := 0; i < 12; i += 4 { row := []models.InlineKeyboardButton{} for j := i; j < i+4 && j < 12; j++ { @@ -423,50 +421,18 @@ func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID }) kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows} - if msgID == 0 { - h.sendWithKB(ctx, chatID, "Select month to export:", kb) - } else { - h.editText(ctx, chatID, msgID, "Select month to export:", &kb) - } + h.sendOrEdit(ctx, chatID, msgID, "Select month to export:", &kb) } -// editExportYearPicker shows a 12-year grid for year selection in the export flow. -// refY is the year to return to via the Back button. -func (h *Handler) editExportYearPicker(ctx context.Context, chatID int64, msgID int, centerYear, refY int) { - backData := fmt.Sprintf("exp_years_back_%d", refY) - navSuffix := fmt.Sprintf("_%d", refY) - kb := buildYearPicker("exp_", backData, navSuffix, centerYear) - h.editText(ctx, chatID, msgID, "Select year:", &kb) -} - -// formatMonthTitle returns the localized month name and year for a given calendar. -func formatMonthTitle(cal string, year, month int) string { - switch cal { - case "jalali": - return fmt.Sprintf("%s %d", jalaliMonthNames[month-1], year) - case "hijri": - return fmt.Sprintf("%s %d", hijriMonthNames[month-1], year) - default: - return fmt.Sprintf("%s %d", gregMonthNames[month-1], year) - } -} - -// handleExportCallback routes export inline button presses (year nav, month select). -func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID int, callbackID, data string) { - defer h.answerCb(ctx, callbackID) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - +func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, data string) { + defer h.answerCb(ctx, cbID) if h.handleExportNav(ctx, chatID, msgID, user, data) { return } h.handleExportDo(ctx, chatID, msgID, user, data) } -// 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 { +func (h *Handler) handleExportNav(ctx context.Context, chatID int64, msgID int, user *domain.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) @@ -496,14 +462,13 @@ func (h *Handler) handleExportNav(ctx context.Context, chatID int64, msgID int, 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) { +func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, user *domain.User, data string) { var y, m int if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n != 2 { return } - last, err := h.DB.GetLastEvent(user.ID) + last, err := h.Repo.GetLastEvent(user.ID) if err == nil && last != nil && last.EventType == "in" { backKB := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ @@ -514,21 +479,20 @@ func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, u return } - startStr, endStr := monthGregorianRange(user.Calendar, y, m) - start, err := time.Parse(DateLayout, startStr) + startStr, endStr := domain.MonthGregorianRange(user.Calendar, y, m) + start, err := time.Parse(domain.DateLayout, startStr) if err != nil { h.sendText(ctx, chatID, "Error processing date") return } - end, err := time.Parse(DateLayout, endStr) + end, err := time.Parse(domain.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) + reportData, err := h.generateMonthlyReport(user, y, m, start, end) if err != nil { slog.Error("generate report", "error", err) backKB := models.InlineKeyboardMarkup{ @@ -548,7 +512,7 @@ func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, u } h.editText(ctx, chatID, msgID, "Report ready:", &backKB) - if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{ + if _, err := h.Bot.SendDocument(ctx, &tgbot.SendDocumentParams{ ChatID: chatID, Document: &models.InputFileUpload{ Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)), @@ -558,3 +522,10 @@ func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, u slog.Error("send document", "error", err) } } + +func (h *Handler) editExportYearPicker(ctx context.Context, chatID int64, msgID int, centerYear, refY int) { + backData := fmt.Sprintf("exp_years_back_%d", refY) + navSuffix := fmt.Sprintf("_%d", refY) + kb := buildYearPicker("exp_", backData, navSuffix, centerYear) + h.editText(ctx, chatID, msgID, "Select year:", &kb) +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go new file mode 100644 index 0000000..2ec94cf --- /dev/null +++ b/internal/handler/handler.go @@ -0,0 +1,453 @@ +package handler + +import ( + "context" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + tgbot "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "worktimeBot/internal/domain" + "worktimeBot/internal/repo" +) + +const rateLimitInterval = 1 * time.Second + +type PendingKind string + +const ( + PendingNote PendingKind = "note" + PendingRate PendingKind = "rate" + PendingCurrency PendingKind = "currency" + PendingTimezone PendingKind = "timezone" + PendingBreak PendingKind = "break_threshold" + PendingUsername PendingKind = "username" + PendingReportTime PendingKind = "report_time" +) + +type PendingInput struct { + Kind PendingKind + UserID int64 + CreatedAt time.Time + MsgID int + Data map[string]string +} + +type Handler struct { + Bot *tgbot.Bot + Repo repo.Repository + AllowedUsers map[int64]bool + lastMsgTime map[int64]time.Time + pendingInput map[int64]*PendingInput + mu sync.Mutex +} + +func NewHandler(bot *tgbot.Bot, store repo.Repository, allowed map[int64]bool) *Handler { + h := &Handler{ + Bot: bot, + Repo: store, + AllowedUsers: allowed, + lastMsgTime: make(map[int64]time.Time), + pendingInput: make(map[int64]*PendingInput), + } + go h.periodicCleanup() + return h +} + +func (h *Handler) periodicCleanup() { + for { + time.Sleep(10 * time.Second) + h.mu.Lock() + cutoff := time.Now().Add(-rateLimitInterval * 2) + for uid, t := range h.lastMsgTime { + if t.Before(cutoff) { + delete(h.lastMsgTime, uid) + } + } + pendingCutoff := time.Now().Add(-5 * time.Minute) + for chatID, pi := range h.pendingInput { + if pi.CreatedAt.Before(pendingCutoff) { + delete(h.pendingInput, chatID) + } + } + h.mu.Unlock() + } +} + +func (h *Handler) isRateLimited(userID int64) bool { + h.mu.Lock() + defer h.mu.Unlock() + now := time.Now() + if last, ok := h.lastMsgTime[userID]; ok && now.Sub(last) < rateLimitInterval { + return true + } + h.lastMsgTime[userID] = now + return false +} + +func (h *Handler) getOrCreateUser(chatID int64) (*domain.User, error) { + return h.Repo.GetOrCreateUser(chatID) +} + +func (h *Handler) loadLocation(tz string) *time.Location { + loc, err := time.LoadLocation(tz) + if err != nil { + return time.UTC + } + return loc +} + +func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) { + if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] { + return + } + if msg.Text == "" { + return + } + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + return + } + if h.isRateLimited(user.ID) { + return + } + slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text) + + if msg.Text[0] == '/' { + h.mu.Lock() + delete(h.pendingInput, msg.Chat.ID) + h.mu.Unlock() + h.routeCommand(ctx, msg, user) + return + } + + h.mu.Lock() + pi, hasPI := h.pendingInput[msg.Chat.ID] + if hasPI { + delete(h.pendingInput, msg.Chat.ID) + } + h.mu.Unlock() + + if hasPI { + h.processPendingInput(ctx, msg, user, pi) + return + } + + h.sendText(ctx, msg.Chat.ID, "Unknown command") +} + +func (h *Handler) routeCommand(ctx context.Context, msg *models.Message, user *domain.User) { + cmd := msg.Text + if i := strings.IndexByte(msg.Text, ' '); i >= 0 { + cmd = msg.Text[:i] + } + switch cmd { + case "/start": + h.handleStart(ctx, msg, user) + case "/help": + h.handleHelp(ctx, msg) + case "/clockin": + h.handleClockIn(ctx, msg, user) + case "/clockout": + h.handleClockOut(ctx, msg, user) + case "/worktype": + h.handleWorkType(ctx, msg, user) + case "/report": + h.handleReport(ctx, msg, user) + case "/export": + h.handleExport(ctx, msg, user) + case "/dayoff": + h.handleDayOff(ctx, msg, user) + case "/history": + h.handleHistory(ctx, msg, user) + case "/edit": + h.handleEdit(ctx, msg, user) + case "/reporttoggle": + h.handleReportToggle(ctx, msg, user) + case "/setreporttime": + h.handleSetReportTime(ctx, msg, user) + case "/setaccent": + h.handleSetAccent(ctx, msg, user) + case "/settimezone": + h.handleSetTimezone(ctx, msg, user) + case "/note": + h.handleNote(ctx, msg, user) + case "/summary": + h.handleSummary(ctx, msg, user) + case "/setbreak": + h.handleSetBreak(ctx, msg, user) + case "/rpg": + h.handleRPG(ctx, msg, user) + case "/achievements": + h.handleAchievements(ctx, msg, user) + case "/salary": + h.handleSalary(ctx, msg, user) + case "/burnout": + h.handleBurnout(ctx, msg, user) + case "/league": + h.handleLeague(ctx, msg, user) + case "/setrate": + h.handleSetRate(ctx, msg, user) + case "/setcurrency": + h.handleSetCurrency(ctx, msg, user) + case "/rpgtoggle": + h.handleRPGSettings(ctx, msg, user) + case "/leaguetoggle": + h.handleLeagueToggle(ctx, msg, user) + case "/setusername": + h.handleSetUsername(ctx, msg, user) + default: + h.sendText(ctx, msg.Chat.ID, "Unknown command") + } +} + +func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery) { + chatID := cb.Message.Message.Chat.ID + if len(h.AllowedUsers) > 0 && !h.AllowedUsers[chatID] { + h.answerCbWithText(ctx, cb.ID, "Unauthorized") + return + } + user, err := h.getOrCreateUser(chatID) + if err != nil { + h.answerCb(ctx, cb.ID) + return + } + if h.isRateLimited(user.ID) { + h.answerCb(ctx, cb.ID) + return + } + slog.Info("callback", "chat_id", chatID, "data", cb.Data) + + h.mu.Lock() + delete(h.pendingInput, chatID) + h.mu.Unlock() + + msgID := cb.Message.Message.ID + data := cb.Data + + switch { + case data == "pending_cancel": + h.backToMenu(ctx, chatID, msgID, cb.ID) + case data == "clockin": + h.clockInCallback(ctx, chatID, msgID, cb.ID, user) + case data == "clockout": + h.clockOutCallback(ctx, chatID, msgID, cb.ID, user) + case data == "dayoff": + h.dayOffCallback(ctx, chatID, msgID, cb.ID, user) + case data == "report": + h.reportCallback(ctx, chatID, msgID, cb.ID, user) + case data == "worktype": + h.workTypeCallback(ctx, chatID, msgID, cb.ID, user) + case data == "export": + h.exportCallback(ctx, chatID, msgID, cb.ID, user) + case data == "settings": + h.settingsCallback(ctx, chatID, msgID, cb.ID, user) + case data == "history": + h.historyCallback(ctx, chatID, msgID, cb.ID, user) + case data == "noop": + h.answerCb(ctx, cb.ID) + case data == "rpg": + h.rpgCallback(ctx, chatID, msgID, cb.ID, user) + case data == "achievements": + h.achievementsCallback(ctx, chatID, msgID, cb.ID, user) + case data == "salary": + h.salaryCallback(ctx, chatID, msgID, cb.ID, user) + case data == "burnout": + h.burnoutCallback(ctx, chatID, msgID, cb.ID, user) + case data == "league": + h.leagueCallback(ctx, chatID, msgID, cb.ID, user, string(domain.LeagueSortXP)) + case data == "back_menu": + h.backToMenu(ctx, chatID, msgID, cb.ID) + default: + h.routePrefixedCallback(ctx, chatID, msgID, cb.ID, user, data) + } +} + +func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, data string) { + switch { + case strings.HasPrefix(data, "worktype_"): + h.selectWorkType(ctx, chatID, msgID, cbID, user, data[9:]) + case strings.HasPrefix(data, "accent_"): + h.selectAccent(ctx, chatID, msgID, cbID, user, data[7:]) + case strings.HasPrefix(data, "caltype_"): + h.selectCalendar(ctx, chatID, msgID, cbID, user, data[8:]) + case strings.HasPrefix(data, "exp_"): + h.handleExportCallback(ctx, chatID, msgID, cbID, user, data) + case strings.HasPrefix(data, "breakthreshold_"): + h.selectBreakThreshold(ctx, chatID, msgID, cbID, user, data[15:]) + case strings.HasPrefix(data, "currency_"): + h.selectCurrency(ctx, chatID, msgID, cbID, user, data[9:]) + case data == "rpg_enable": + h.setRPGCallback(ctx, chatID, msgID, cbID, user, true) + case data == "rpg_disable": + h.setRPGCallback(ctx, chatID, msgID, cbID, user, false) + case data == "league_enable": + h.setLeagueCallback(ctx, chatID, msgID, cbID, user, true) + case data == "league_disable": + h.setLeagueCallback(ctx, chatID, msgID, cbID, user, false) + case data == "report_enable": + h.setReportToggleCallback(ctx, chatID, msgID, cbID, user, true) + case data == "report_disable": + h.setReportToggleCallback(ctx, chatID, msgID, cbID, user, false) + case data == "back_settings": + h.backToSettings(ctx, chatID, msgID, cbID, user) + case data == "rpgtoggle": + h.rpgToggleCallback(ctx, chatID, msgID, cbID, user) + case data == "leaguetoggle": + h.leagueToggleCallback(ctx, chatID, msgID, cbID, user) + case data == "reporttoggle": + h.reportToggleCallback(ctx, chatID, msgID, cbID, user) + case data == "rpg_achievements": + h.rpgAchievementsCallback(ctx, chatID, msgID, cbID, user) + case data == "rpg_back": + h.rpgBackCallback(ctx, chatID, msgID, cbID, user) + case data == "username": + h.usernameCallback(ctx, chatID, msgID, cbID, user) + case data == "currency": + h.currencyCallback(ctx, chatID, msgID, cbID, user) + case data == "setrate": + h.rateCallback(ctx, chatID, msgID, cbID, user) + case data == "timezone": + h.timezoneCallback(ctx, chatID, msgID, cbID, user) + case data == "caltype": + h.calTypeCallback(ctx, chatID, msgID, cbID, user) + case data == "accent": + h.accentCallback(ctx, chatID, msgID, cbID, user) + case data == "reporttime": + h.reportTimeCallback(ctx, chatID, msgID, cbID, user) + case data == "breakthreshold": + h.breakThresholdCallback(ctx, chatID, msgID, cbID, user) + case strings.HasPrefix(data, "league_"): + sortBy := data[7:] + h.leagueCallback(ctx, chatID, msgID, cbID, user, sortBy) + default: + h.routeHistoryCallback(ctx, chatID, msgID, cbID, user, data) + } +} + +func (h *Handler) setPending(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, data map[string]string) { + h.mu.Lock() + h.pendingInput[chatID] = &PendingInput{ + Kind: kind, + UserID: userID, + CreatedAt: time.Now(), + MsgID: msgID, + Data: data, + } + h.mu.Unlock() +} + +func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput) { + text := strings.TrimSpace(msg.Text) + switch pi.Kind { + case PendingNote: + h.processNoteInput(ctx, msg, user, pi, text) + case PendingRate: + h.processRateInput(ctx, msg, user, pi, text) + case PendingCurrency: + h.processCurrencyInput(ctx, msg, user, pi, text) + case PendingTimezone: + h.processTimezoneInput(ctx, msg, user, pi, text) + case PendingBreak: + h.processBreakInput(ctx, msg, user, pi, text) + case PendingUsername: + h.processUsernameInput(ctx, msg, user, pi, text) + case PendingReportTime: + h.processReportTimeInput(ctx, msg, user, pi, text) + default: + h.sendText(ctx, msg.Chat.ID, "Unknown input type") + } +} + +func (h *Handler) promptForInput(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, prompt string, data map[string]string) { + kb := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Cancel", CallbackData: "pending_cancel"}}, + }, + } + if msgID == 0 { + msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ + ChatID: chatID, Text: prompt, ReplyMarkup: kb, + }) + if err != nil { + slog.Warn("send prompt failed", "chat_id", chatID, "error", err) + return + } + h.setPending(ctx, chatID, msg.ID, userID, kind, data) + } else { + h.editText(ctx, chatID, msgID, prompt, &kb) + h.setPending(ctx, chatID, msgID, userID, kind, data) + } +} + +// sendOrEdit sends a new message or edits an existing one depending on msgID. +func (h *Handler) sendOrEdit(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) { + if msgID == 0 { + if kb != nil { + h.sendWithKB(ctx, chatID, text, *kb) + } else { + h.sendText(ctx, chatID, text) + } + } else { + h.editText(ctx, chatID, msgID, text, kb) + } +} + +// -- helpers -- + +func (h *Handler) sendText(ctx context.Context, chatID int64, text string) { + if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text}); err != nil { + slog.Warn("send text failed", "chat_id", chatID, "error", err) + } +} + +func (h *Handler) editText(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) { + p := &tgbot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text} + if kb != nil { + p.ReplyMarkup = kb + } + if _, err := h.Bot.EditMessageText(ctx, p); err != nil { + slog.Warn("edit text failed", "chat_id", chatID, "msg_id", msgID, "error", err) + } +} + +func (h *Handler) sendWithKB(ctx context.Context, chatID int64, text string, kb models.InlineKeyboardMarkup) { + if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text, ReplyMarkup: kb}); err != nil { + slog.Warn("send with keyboard failed", "chat_id", chatID, "error", err) + } +} + +func (h *Handler) answerCb(ctx context.Context, callbackID string) { + if _, err := h.Bot.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}); err != nil { + slog.Debug("answer callback failed", "error", err) + } +} + +func (h *Handler) answerCbWithText(ctx context.Context, callbackID, text string) { + if _, err := h.Bot.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{CallbackQueryID: callbackID, Text: text}); err != nil { + slog.Debug("answer callback with text failed", "error", err) + } +} + +func (h *Handler) tryUnlockAchievement(userID int64, code string) { + a, err := h.Repo.GetAchievementByCode(code) + if err != nil { + return + } + if _, err := h.Repo.UnlockAchievement(userID, a.ID); err != nil { + slog.Error("unlock achievement", "code", code, "error", err) + } +} + +func formatDuration(seconds int64) string { + hours := seconds / domain.SecondsPerHour + mins := (seconds % domain.SecondsPerHour) / 60 + if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, mins) + } + return fmt.Sprintf("%dm", mins) +} diff --git a/internal/handler/keyboard.go b/internal/handler/keyboard.go new file mode 100644 index 0000000..78cf776 --- /dev/null +++ b/internal/handler/keyboard.go @@ -0,0 +1,111 @@ +package handler + +import ( + "fmt" + + "github.com/go-telegram/bot/models" +) + +func mainKeyboard() models.InlineKeyboardMarkup { + return models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + { + {Text: "Clock In", CallbackData: "clockin"}, + {Text: "Clock Out", CallbackData: "clockout"}, + }, + { + {Text: "Work Type", CallbackData: "worktype"}, + {Text: "Day Off", CallbackData: "dayoff"}, + }, + { + {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"}, + }, + }, + } +} + +func backKeyboard() models.InlineKeyboardMarkup { + return models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Back to Menu", CallbackData: "back_menu"}}, + }, + } +} + +func settingsBackKeyboard() models.InlineKeyboardMarkup { + return models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Back to Settings", CallbackData: "back_settings"}}, + }, + } +} + +// buildYearPicker returns a 12-year grid keyboard centered on centerYear. +func buildYearPicker(cbPrefix, backData, navSuffix string, centerYear int) models.InlineKeyboardMarkup { + blockStart := centerYear - 6 + kbRows := [][]models.InlineKeyboardButton{ + { + {Text: "<", CallbackData: fmt.Sprintf("%syears_%d%s", cbPrefix, blockStart-12, navSuffix)}, + {Text: fmt.Sprintf("%d", centerYear), CallbackData: "noop"}, + {Text: ">", CallbackData: fmt.Sprintf("%syears_%d%s", cbPrefix, blockStart+12, navSuffix)}, + }, + } + for i := 0; i < 12; i += 4 { + row := []models.InlineKeyboardButton{} + for j := i; j < i+4; j++ { + y := blockStart + j + row = append(row, models.InlineKeyboardButton{ + Text: fmt.Sprintf("%d", y), CallbackData: fmt.Sprintf("%syear_%d", cbPrefix, y), + }) + } + kbRows = append(kbRows, row) + } + if backData != "" { + kbRows = append(kbRows, []models.InlineKeyboardButton{ + {Text: "Back", CallbackData: backData}, + }) + } + return models.InlineKeyboardMarkup{InlineKeyboard: kbRows} +} + +// buildHourPickerKeyboard creates the 24-hour grid for time selection. +func buildHourPickerKeyboard(cb func(int) string, backData string) models.InlineKeyboardMarkup { + kbRows := [][]models.InlineKeyboardButton{} + hourRow := []models.InlineKeyboardButton{} + for hh := 0; hh < 24; hh++ { + hourRow = append(hourRow, models.InlineKeyboardButton{ + Text: fmt.Sprintf("%02d", hh), CallbackData: cb(hh), + }) + if len(hourRow) == 6 || hh == 23 { + kbRows = append(kbRows, hourRow) + hourRow = nil + } + } + kbRows = append(kbRows, []models.InlineKeyboardButton{{Text: "Back", CallbackData: backData}}) + return models.InlineKeyboardMarkup{InlineKeyboard: kbRows} +} + +// buildMinutePickerKeyboard creates the 15-min interval row for minute selection. +func buildMinutePickerKeyboard(cb func(int) string, backData string) models.InlineKeyboardMarkup { + kbRows := [][]models.InlineKeyboardButton{} + minRow := []models.InlineKeyboardButton{} + for _, mm := range []int{0, 10, 20, 30, 40, 50} { + minRow = append(minRow, models.InlineKeyboardButton{ + Text: fmt.Sprintf("%02d", mm), CallbackData: cb(mm), + }) + } + kbRows = append(kbRows, minRow) + kbRows = append(kbRows, []models.InlineKeyboardButton{{Text: "Back", CallbackData: backData}}) + return models.InlineKeyboardMarkup{InlineKeyboard: kbRows} +} diff --git a/internal/bot/league.go b/internal/handler/league.go similarity index 56% rename from internal/bot/league.go rename to internal/handler/league.go index 689d8b7..965f72d 100644 --- a/internal/bot/league.go +++ b/internal/handler/league.go @@ -1,4 +1,4 @@ -package bot +package handler import ( "context" @@ -6,37 +6,58 @@ import ( "strings" "github.com/go-telegram/bot/models" + + "worktimeBot/internal/domain" ) -// LeagueSortOption represents a valid league sort column. -type LeagueSortOption string +// -- Command -- -const ( - LeagueSortXP LeagueSortOption = "xp" - LeagueSortLevel LeagueSortOption = "level" - LeagueSortStreak LeagueSortOption = "streak" - LeagueSortHours LeagueSortOption = "hours" -) +func (h *Handler) handleLeague(ctx context.Context, msg *models.Message, user *domain.User) { + st, err := h.Repo.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(domain.LeagueSortXP) + kb := h.buildLeagueKeyboard(string(domain.LeagueSortXP)) + h.sendWithKB(ctx, msg.Chat.ID, text, kb) +} -// buildLeagueText returns the formatted league rankings (mobile-friendly narrow layout). -func (h *Handler) buildLeagueText(orderBy LeagueSortOption) string { - entries, err := h.DB.GetLeagueRankings(string(orderBy)) +// -- Callback -- + +func (h *Handler) leagueCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, orderBy string) { + defer h.answerCb(ctx, cbID) + st, err := h.Repo.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(domain.LeagueSortOption(orderBy)) + kb := h.buildLeagueKeyboard(orderBy) + h.editText(ctx, chatID, msgID, text, &kb) +} + +// -- Builders -- + +func (h *Handler) buildLeagueText(orderBy domain.LeagueSortOption) string { + entries, err := h.Repo.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": "Hours", - } - b.WriteString(fmt.Sprintf("WorkTime League — %s\n\n", orderLabel[string(orderBy)])) - + orderLabel := map[string]string{"xp": "XP", "level": "Level", "streak": "Streak", "hours": "Hours"} + b.WriteString(fmt.Sprintf("WorkTime League - %s\n\n", orderLabel[string(orderBy)])) for i, e := range entries { rank := i + 1 name := e.Username @@ -46,19 +67,15 @@ func (h *Handler) buildLeagueText(orderBy LeagueSortOption) string { b.WriteString(fmt.Sprintf("%d %s\n", rank, name)) b.WriteString(fmt.Sprintf(" XP:%d Lv:%d S:%d %.1fh\n", 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") + entries, err := h.Repo.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)) @@ -67,61 +84,14 @@ func (h *Handler) buildLeagueRankText(userID int64) string { 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 (2x2 sort grid). func (h *Handler) buildLeagueKeyboard(currentOrder string) models.InlineKeyboardMarkup { - type sortBtn struct { - label string - data string - } + type sortBtn struct{ label, data string } orders := []sortBtn{ {"XP", "league_xp"}, {"Level", "league_level"}, {"Streak", "league_streak"}, {"Hours", "league_hours"}, } - rows := [][]models.InlineKeyboardButton{} for i := 0; i < len(orders); i += 2 { row := []models.InlineKeyboardButton{} diff --git a/internal/handler/note.go b/internal/handler/note.go new file mode 100644 index 0000000..fc7cbea --- /dev/null +++ b/internal/handler/note.go @@ -0,0 +1,91 @@ +package handler + +import ( + "context" + "strings" + "time" + + "github.com/go-telegram/bot/models" + + "worktimeBot/internal/domain" +) + +// handleNote handles /note [YYYY-MM-DD] HH:MM command. +func (h *Handler) handleNote(ctx context.Context, msg *models.Message, user *domain.User) { + loc := h.loadLocation(user.Timezone) + parts := strings.Fields(msg.Text) + if len(parts) < 3 { + h.promptForNote(ctx, msg, user) + return + } + + var dateStr string + var timeStr string + var noteParts []string + + if len(parts[1]) == 10 && parts[1][4] == '-' && parts[1][7] == '-' { + dateStr = parts[1] + timeStr = parts[2] + noteParts = parts[3:] + } else { + dateStr = time.Now().In(loc).Format(domain.DateLayout) + timeStr = parts[1] + noteParts = parts[2:] + } + + if len(noteParts) == 0 { + h.promptForNote(ctx, msg, user) + return + } + + events, err := h.Repo.EventsForDayByDate(user.ID, dateStr) + if err != nil || len(events) == 0 { + h.sendText(ctx, msg.Chat.ID, "No events found for that day") + return + } + + var targetEvent *domain.Event + for i := range events { + t := time.Unix(events[i].OccurredAt, 0).In(loc).Format(domain.TimeLayout) + if t == timeStr { + targetEvent = &events[i] + break + } + } + if targetEvent == nil { + h.sendText(ctx, msg.Chat.ID, "No event found at that time") + return + } + + note := domain.SanitizeNote(strings.Join(noteParts, " ")) + if err := h.Repo.SetEventNote(targetEvent.ID, note); err != nil { + h.sendText(ctx, msg.Chat.ID, "Error saving note") + return + } + h.sendText(ctx, msg.Chat.ID, "Note saved") +} + +func (h *Handler) promptForNote(ctx context.Context, msg *models.Message, user *domain.User) { + h.sendText(ctx, msg.Chat.ID, "Usage: /note [YYYY-MM-DD] HH:MM \nUse the History menu to add notes visually.") +} + +// handleEdit handles /edit YYYY-MM-DD command. +func (h *Handler) handleEdit(ctx context.Context, msg *models.Message, user *domain.User) { + date := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/edit")) + if date == "" { + h.sendText(ctx, msg.Chat.ID, "Usage: /edit YYYY-MM-DD (your calendar type)") + return + } + if len(date) != 10 || date[4] != '-' || date[7] != '-' { + h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /edit YYYY-MM-DD (your calendar type)") + return + } + _, m, d := domain.ParseGregorianDateKey(date) + if m < 1 || m > 12 || d < 1 || d > 31 { + h.sendText(ctx, msg.Chat.ID, "Invalid date.") + return + } + loc := h.loadLocation(user.Timezone) + date = domain.UserDateToGregorian(date, user.Calendar) + h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true) +} diff --git a/internal/handler/report.go b/internal/handler/report.go new file mode 100644 index 0000000..4b0b968 --- /dev/null +++ b/internal/handler/report.go @@ -0,0 +1,169 @@ +package handler + +import ( + "context" + "fmt" + "log/slog" + "time" + + tgbot "github.com/go-telegram/bot" + + "worktimeBot/internal/domain" +) + +func (h *Handler) buildStatusText(user *domain.User) string { + loc := h.loadLocation(user.Timezone) + now := time.Now().In(loc) + today := now.Format(domain.DateLayout) + day, err := h.Repo.GetOrCreateDay(user.ID, today) + if err != nil { + return "Welcome. Choose an action:" + } + + text := fmt.Sprintf("Today: %s", domain.FormatDateForCalendar(day.Date, user.Calendar)) + if day.IsDayOff { + text += "\nDay Off" + } + wt, _ := h.Repo.GetWorkType(day.CurrentWorkTypeID) + if wt != nil { + text += fmt.Sprintf("\nWork type: %s", wt.Name) + } + last, err := h.Repo.GetLastEvent(user.ID) + if err == nil && last != nil && last.EventType == "in" { + t := time.Unix(last.OccurredAt, 0).In(loc).Format(domain.TimeLayout) + text += fmt.Sprintf("\nStatus: working since %s", t) + } else { + text += "\nStatus: not working" + } + events, err := h.Repo.EventsForDayByDayID(day.ID) + if err == nil && len(events) > 0 { + y, m, d := now.Date() + dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix() + totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, now.Unix()) + text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds)) + } + st, _ := h.Repo.GetOrCreateSettings(user.ID) + if st != nil && st.HourlyRate > 0 { + if events, err := h.Repo.EventsForDayByDayID(day.ID); err == nil && len(events) > 0 { + y, m, d := now.Date() + dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix() + totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, now.Unix()) + sal := h.buildSalaryStatus(user, totals.TotalSeconds) + if sal != "" { + text += sal + } + } + } + if st != nil && st.RPGEnabled { + stats, _ := h.Repo.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 += "\n " + rankText + } + } + } + text += "\n\nChoose an action:" + return text +} + +func (h *Handler) buildDailyReport(user *domain.User, day *domain.Day, events []domain.Event) string { + loc := h.loadLocation(user.Timezone) + + if len(events) == 0 { + if day.IsDayOff { + return fmt.Sprintf("%s - Day Off", domain.FormatDateForCalendar(day.Date, user.Calendar)) + } + return fmt.Sprintf("%s - No events recorded.", domain.FormatDateForCalendar(day.Date, user.Calendar)) + } + + y, m, d := domain.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(domain.DateLayout) + dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix() + if day.Date == todayStr { + dayEnd = now.Unix() + } + totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) + + lines := fmt.Sprintf("Report for %s", domain.FormatDateForCalendar(day.Date, user.Calendar)) + if day.IsDayOff { + lines += "\nDay Off: Yes" + } + lines += "\n\n" + for _, e := range events { + t := time.Unix(e.OccurredAt, 0).In(loc).Format(domain.TimeLayout) + label := "IN" + if e.EventType == "out" { + label = "OUT" + } + suffix := "" + if e.WorkTypeID != nil { + if wtObj, err := h.Repo.GetWorkType(*e.WorkTypeID); err == nil { + suffix = " [" + wtObj.Name + "]" + } + } + if e.Note != "" { + suffix += " (" + e.Note + ")" + } + lines += fmt.Sprintf("%s %s%s\n", label, t, suffix) + } + lines += fmt.Sprintf("\nSummary:\n Work: %s\n Break: %s\n", + formatDuration(totals.TotalSeconds), formatDuration(totals.BreakSeconds)) + + st, _ := h.Repo.GetOrCreateSettings(user.ID) + if st != nil && st.HourlyRate > 0 { + est := domain.ComputeDailySalary(totals.TotalSeconds, st.HourlyRate) + s := domain.SalaryEstimate{WorkSeconds: totals.TotalSeconds, GrossEarning: est} + lines += fmt.Sprintf(" Salary: %s\n", s.FormatSalaryShort(st.Currency)) + } + return lines +} + +func (h *Handler) buildSalaryStatus(user *domain.User, workSeconds int64) string { + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil || st.HourlyRate <= 0 { + return "" + } + est := domain.ComputeDailySalary(workSeconds, st.HourlyRate) + s := domain.SalaryEstimate{WorkSeconds: workSeconds, GrossEarning: est, HourlyRate: st.HourlyRate} + return fmt.Sprintf("\nSalary: %s (%.2f/hr)", s.FormatSalaryShort(st.Currency), st.HourlyRate) +} + +func (h *Handler) backToMenu(ctx context.Context, chatID int64, msgID int, cbID string) { + defer h.answerCb(ctx, cbID) + user, err := h.getOrCreateUser(chatID) + if err != nil { + h.editText(ctx, chatID, msgID, "Error loading profile", nil) + return + } + kb := mainKeyboard() + h.editText(ctx, chatID, msgID, h.buildStatusText(user), &kb) +} + +// SendDailyReport sends a daily report to a specific user (called by scheduler). +func (h *Handler) SendDailyReport(ctx context.Context, userID int64, chatID int64) { + user, err := h.Repo.GetOrCreateUser(chatID) + if err != nil { + slog.Error("SendDailyReport: get user", "chat_id", chatID, "error", err) + return + } + loc := h.loadLocation(user.Timezone) + today := time.Now().In(loc).Format(domain.DateLayout) + day, err := h.Repo.GetOrCreateDay(user.ID, today) + if err != nil { + slog.Error("SendDailyReport: get day", "chat_id", chatID, "error", err) + return + } + events, err := h.Repo.EventsForDayByDayID(day.ID) + if err != nil { + slog.Error("SendDailyReport: events", "chat_id", chatID, "error", err) + return + } + text := h.buildDailyReport(user, day, events) + if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text}); err != nil { + slog.Warn("SendDailyReport: send", "chat_id", chatID, "error", err) + } +} diff --git a/internal/handler/rpg.go b/internal/handler/rpg.go new file mode 100644 index 0000000..ad37c30 --- /dev/null +++ b/internal/handler/rpg.go @@ -0,0 +1,336 @@ +package handler + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/go-telegram/bot/models" + + "worktimeBot/internal/domain" +) + +// -- Commands -- + +func (h *Handler) handleRPG(ctx context.Context, msg *models.Message, user *domain.User) { + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error loading settings") + return + } + if !st.RPGEnabled { + h.sendText(ctx, msg.Chat.ID, "RPG system is disabled. Enable it in Settings or use /rpgtoggle.") + return + } + text := h.buildRPGStatus(user) + kb := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Achievements", CallbackData: "rpg_achievements"}}, + {{Text: "Back to Menu", CallbackData: "back_menu"}}, + }, + } + h.sendWithKB(ctx, msg.Chat.ID, text, kb) +} + +func (h *Handler) handleAchievements(ctx context.Context, msg *models.Message, user *domain.User) { + text := h.buildAchievementsList(user) + h.sendWithKB(ctx, msg.Chat.ID, text, backKeyboard()) +} + +// -- Callbacks -- + +func (h *Handler) rpgCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + text := h.buildRPGStatus(user) + kb := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Achievements", CallbackData: "rpg_achievements"}}, + {{Text: "Back to Menu", CallbackData: "back_menu"}}, + }, + } + h.editText(ctx, chatID, msgID, text, &kb) +} + +func (h *Handler) achievementsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + text := h.buildAchievementsList(user) + kb := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Back to Menu", CallbackData: "back_menu"}}, + }, + } + h.editText(ctx, chatID, msgID, text, &kb) +} + +func (h *Handler) rpgAchievementsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + text := h.buildAchievementsList(user) + kb := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Back to RPG", CallbackData: "rpg_back"}}, + }, + } + h.editText(ctx, chatID, msgID, text, &kb) +} + +func (h *Handler) rpgBackCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + text := h.buildRPGStatus(user) + kb := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Achievements", CallbackData: "rpg_achievements"}}, + {{Text: "Back to Menu", CallbackData: "back_menu"}}, + }, + } + h.editText(ctx, chatID, msgID, text, &kb) +} + +// -- RPG business logic -- + +func (h *Handler) buildRPGStatus(user *domain.User) string { + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil || !st.RPGEnabled { + return "RPG system is disabled." + } + stats, err := h.Repo.GetOrCreateRPGStats(user.ID) + if err != nil { + return "RPG stats unavailable" + } + nextLevelXP := domain.XpForLevel(stats.Level + 1) + currentLevelXP := domain.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) / domain.SecondsPerHour + b.WriteString(fmt.Sprintf("Total work: %.1f hours\n", hoursTotal)) + achs, _ := h.Repo.GetUserAchievements(user.ID) + if len(achs) > 0 { + b.WriteString(fmt.Sprintf("Achievements: %d unlocked\n", len(achs))) + } + return b.String() +} + +func (h *Handler) buildAchievementsList(user *domain.User) string { + achs, err := h.Repo.GetUserAchievements(user.ID) + 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(domain.DateLayout))) + } + return b.String() +} + +func (h *Handler) awardXPAndCheckAchievements(user *domain.User, stats *domain.RPGStats, sessionWork int64, today string, isWeekend bool) string { + text := "" + h.updateStreak(stats, user.ID, today) + xp, leveledUp, newLevel, _ := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today) + totalHours := float64(stats.TotalWorkSeconds) / domain.SecondsPerHour + unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours) + 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 +} + +func (h *Handler) computeAndAwardXP(stats *domain.RPGStats, userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, _ error) { + baseXP := domain.XpForWorkSeconds(workSeconds) + bonus := domain.StreakBonus(streak) + totalXP := baseXP + bonus + if totalXP < 0 { + totalXP = 0 + } + stats.XP += totalXP + newLevel = domain.LevelForXP(stats.XP) + leveledUp = newLevel > stats.Level + stats.Level = newLevel + stats.TotalWorkSeconds += workSeconds + if err := h.Repo.UpdateRPGStats(stats); err != nil { + return 0, false, 0, err + } + if err := h.Repo.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 +} + +func (h *Handler) updateStreak(stats *domain.RPGStats, userID int64, date string) { + t, err := time.Parse(domain.DateLayout, date) + if err != nil { + return + } + yesterday := t.AddDate(0, 0, -1).Format(domain.DateLayout) + prevEvents, err := h.Repo.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 + } +} + +func (h *Handler) recalcDayWorkSeconds(userID int64, date string, loc *time.Location) { + events, err := h.Repo.EventsForDayByDate(userID, date) + if err != nil { + return + } + day, err := h.Repo.GetDay(userID, date) + if err != nil { + return + } + y, m, d := domain.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 := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) + if err := h.Repo.SetDailyWorkSeconds(userID, date, totals.TotalSeconds); err != nil { + slog.Warn("failed to set daily work seconds", "user_id", userID, "date", date, "error", err) + } +} + +func (h *Handler) recalcAggregates(userID int64, loc *time.Location) { + total, err := h.Repo.SumDailyWorkSeconds(userID) + if err != nil { + slog.Warn("failed to sum daily work seconds", "user_id", userID, "error", err) + return + } + stats, err := h.Repo.GetOrCreateRPGStats(userID) + if err != nil { + slog.Warn("failed to get rpg stats for recalc", "user_id", userID, "error", err) + return + } + stats.TotalWorkSeconds = total + dates, err := h.Repo.GetDatesWithEvents(userID) + today := time.Now().In(loc).Format(domain.DateLayout) + if err == nil { + stats.CurrentStreak, stats.LongestStreak = domain.ComputeStreakFromDates(dates, today) + } else { + stats.CurrentStreak = 0 + stats.LongestStreak = 0 + } + if err := h.Repo.UpdateRPGStats(stats); err != nil { + slog.Warn("failed to update rpg stats after recalc", "user_id", userID, "error", err) + } +} + +// -- Achievements -- + +func (h *Handler) checkAchievements(userID int64, stats *domain.RPGStats, workSeconds int64, date string, isWeekend bool, totalHours float64) []string { + u := &achievementUnlocker{h: h, userID: userID} + h.checkEarlyAchievements(u, workSeconds) + h.checkEventTimeAchievements(u, userID, date) + h.checkStreakAchievements(u, stats) + h.checkHourAchievements(u, stats, totalHours) + h.checkSessionAchievements(u, workSeconds, isWeekend) + h.checkLevelAchievements(u, stats) + return u.unlocked +} + +type achievementUnlocker struct { + h *Handler + userID int64 + unlocked []string +} + +func (u *achievementUnlocker) unlock(code string) { + a, err := u.h.Repo.GetAchievementByCode(code) + if err != nil { + return + } + ok, err := u.h.Repo.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.Repo.HasAchievement(u.userID, "first_clockin") + if !has && workSeconds > 0 { + u.unlock("first_clockin") + } +} + +func (h *Handler) checkEventTimeAchievements(u *achievementUnlocker, userID int64, date string) { + events, err := h.Repo.EventsForDayByDate(userID, date) + if err != nil { + return + } + for _, e := range events { + 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 *domain.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 *domain.RPGStats, hoursWorked float64) { + 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*domain.SecondsPerHour { + u.unlock("overtime_hero") + } + if isWeekend && workSeconds > 0 { + u.unlock("weekend_warrior") + } +} + +func (h *Handler) checkLevelAchievements(u *achievementUnlocker, stats *domain.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") + } +} diff --git a/internal/handler/salary.go b/internal/handler/salary.go new file mode 100644 index 0000000..410a8cd --- /dev/null +++ b/internal/handler/salary.go @@ -0,0 +1,95 @@ +package handler + +import ( + "context" + "fmt" + "time" + + "github.com/go-telegram/bot/models" + + "worktimeBot/internal/domain" +) + +// -- Command -- + +func (h *Handler) handleSalary(ctx context.Context, msg *models.Message, user *domain.User) { + text, err := h.buildSalaryEstimate(user) + if err != nil { + h.sendText(ctx, msg.Chat.ID, err.Error()) + return + } + h.sendWithKB(ctx, msg.Chat.ID, text, backKeyboard()) +} + +// -- Callback -- + +func (h *Handler) salaryCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + text, err := h.buildSalaryEstimate(user) + if err != nil { + text = err.Error() + } + kb := backKeyboard() + h.editText(ctx, chatID, msgID, text, &kb) +} + +// -- Builders -- + +func (h *Handler) buildSalaryEstimate(user *domain.User) (string, error) { + st, err := h.Repo.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 := h.loadLocation(user.Timezone) + now := time.Now().In(loc) + calY, calM := now.Year(), int(now.Month()) + switch user.Calendar { + case "jalali": + calY, calM, _ = domain.GregorianToJalali(calY, calM, now.Day()) + case "hijri": + calY, calM, _ = domain.GregorianToHijri(calY, calM, now.Day()) + } + startStr, endStr := domain.MonthGregorianRange(user.Calendar, calY, calM) + days, err := h.Repo.GetUserDaysInRange(user.ID, startStr, endStr) + if err != nil { + return "", err + } + est := h.computeMonthlySalary(days, user.ID, st.HourlyRate, loc) + est.Currency = st.Currency + est.HourlyRate = st.HourlyRate + title := domain.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), domain.CurrencySymbol(st.Currency), st.HourlyRate, + est.WorkDays, est.WorkHours), nil +} + +func (h *Handler) computeMonthlySalary(days []domain.Day, userID int64, hourlyRate float64, loc *time.Location) domain.SalaryEstimate { + var totalWork int64 + workDayCount := 0 + for _, day := range days { + if day.IsDayOff { + continue + } + events, err := h.Repo.EventsForDayByDayID(day.ID) + if err != nil || len(events) == 0 { + continue + } + y, m, d := domain.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 := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) + if totals.TotalSeconds > 0 { + totalWork += totals.TotalSeconds + workDayCount++ + } + } + hours := float64(totalWork) / domain.SecondsPerHour + gross := domain.ComputeDailySalary(totalWork, hourlyRate) + return domain.SalaryEstimate{ + WorkSeconds: totalWork, WorkHours: hours, + GrossEarning: gross, WorkDays: workDayCount, + } +} diff --git a/internal/handler/settings.go b/internal/handler/settings.go new file mode 100644 index 0000000..1f19708 --- /dev/null +++ b/internal/handler/settings.go @@ -0,0 +1,720 @@ +package handler + +import ( + "context" + "fmt" + "log/slog" + "strconv" + "strings" + "time" + + "github.com/go-telegram/bot/models" + + "worktimeBot/internal/domain" +) + +// -- /start and /help -- + +func (h *Handler) handleStart(ctx context.Context, msg *models.Message, user *domain.User) { + slog.Info("start", "user_id", user.ID, "chat_id", msg.Chat.ID) + kb := mainKeyboard() + h.sendWithKB(ctx, msg.Chat.ID, h.buildStatusText(user), kb) +} + +func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) { + text := `Commands: +/start - Show main menu +/clockin - Clock in +/clockout - Clock out +/worktype - Select work type +/dayoff - Toggle day off +/report - Show today report +/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 +/reporttoggle - Enable/disable auto report +/setreporttime HH:MM - Set daily report time +/setaccent - Select accent color +/settimezone - Set timezone (e.g. Asia/Tehran) +/setbreak - Set break threshold in minutes +/setusername - Set your display name +/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 - Set currency +/rpgtoggle - Enable/disable RPG system +/leaguetoggle - Enable/disable league participation` + h.sendText(ctx, msg.Chat.ID, text) +} + +// -- Settings menu callbacks -- + +func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + return + } + bt := h.getTodayBreakThreshold(user) + text, kb := h.buildSettingsKeyboard(user, st, bt) + h.editText(ctx, chatID, msgID, text, &kb) +} + +func (h *Handler) buildSettingsKeyboard(user *domain.User, st *domain.UserSettings, breakThreshold int64) (string, models.InlineKeyboardMarkup) { + reportStatus := "off" + if st.ReportEnabled { + reportStatus = "on" + } + rpgStatus := "off" + if st.RPGEnabled { + rpgStatus = "on" + } + leagueStatus := "off" + if st.RPGEnabled && st.LeagueOptIn { + leagueStatus = "on" + } + rows := [][]models.InlineKeyboardButton{ + {{Text: fmt.Sprintf("Username: %s", user.Username), CallbackData: "username"}}, + { + {Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"}, + {Text: fmt.Sprintf("Calendar: %s", user.Calendar), CallbackData: "caltype"}, + }, + { + {Text: fmt.Sprintf("Report: %s", reportStatus), CallbackData: "reporttoggle"}, + {Text: fmt.Sprintf("Report time: %s", st.ReportTime), CallbackData: "reporttime"}, + }, + { + {Text: fmt.Sprintf("Break: %dm", breakThreshold/60), CallbackData: "breakthreshold"}, + {Text: fmt.Sprintf("Accent: %s", st.ExportAccent), CallbackData: "accent"}, + }, + { + {Text: fmt.Sprintf("RPG: %s", rpgStatus), CallbackData: "rpgtoggle"}, + {Text: fmt.Sprintf("League: %s", leagueStatus), CallbackData: "leaguetoggle"}, + }, + { + {Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"}, + {Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"}, + }, + {{Text: "Back to Menu", CallbackData: "back_menu"}}, + } + return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows} +} + +func (h *Handler) backToSettings(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + return + } + bt := h.getTodayBreakThreshold(user) + text, kb := h.buildSettingsKeyboard(user, st, bt) + h.editText(ctx, chatID, msgID, text, &kb) +} + +// -- Toggle callbacks (binary select pickers) -- + +func (h *Handler) rpgToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + text, kb := h.buildToggleKeyboard(user.ID, "rpg", "RPG System") + h.editText(ctx, chatID, msgID, text, &kb) +} + +func (h *Handler) leagueToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + return + } + if !st.RPGEnabled { + h.editText(ctx, chatID, msgID, "League requires the RPG system. Enable RPG first in Settings.", nil) + return + } + text, kb := h.buildToggleKeyboard(user.ID, "league", "WorkTime League") + h.editText(ctx, chatID, msgID, text, &kb) +} + +func (h *Handler) reportToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + text, kb := h.buildToggleKeyboard(user.ID, "report", "Daily Report") + h.editText(ctx, chatID, msgID, text, &kb) +} + +func (h *Handler) buildToggleKeyboard(userID int64, setting, label string) (string, models.InlineKeyboardMarkup) { + st, err := h.Repo.GetOrCreateSettings(userID) + current := false + if err == nil { + switch setting { + case "rpg": + current = st.RPGEnabled + case "league": + current = st.LeagueOptIn + case "report": + current = st.ReportEnabled + } + } + options := []struct { + label string + data string + value bool + }{ + {"Enabled", setting + "_enable", true}, + {"Disabled", setting + "_disable", false}, + } + rows := [][]models.InlineKeyboardButton{} + for _, opt := range options { + l := opt.label + if opt.value == current { + l = "> " + l + } + rows = append(rows, []models.InlineKeyboardButton{ + {Text: l, CallbackData: opt.data}, + }) + } + rows = append(rows, []models.InlineKeyboardButton{ + {Text: "Back to Settings", CallbackData: "back_settings"}, + }) + return label + ":", models.InlineKeyboardMarkup{InlineKeyboard: rows} +} + +func (h *Handler) setRPGCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, enable bool) { + defer h.answerCb(ctx, cbID) + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + return + } + st.RPGEnabled = enable + if enable { + h.Repo.GetOrCreateRPGStats(user.ID) + h.tryUnlockAchievement(user.ID, "rpg_pioneer") + } else { + st.LeagueOptIn = false + } + h.Repo.UpdateSettings(st) + bt := h.getTodayBreakThreshold(user) + text, kb := h.buildSettingsKeyboard(user, st, bt) + h.editText(ctx, chatID, msgID, text, &kb) +} + +func (h *Handler) setLeagueCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, enable bool) { + defer h.answerCb(ctx, cbID) + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + return + } + if enable && !st.RPGEnabled { + h.answerCbWithText(ctx, cbID, "Enable RPG first") + return + } + st.LeagueOptIn = enable + h.Repo.UpdateSettings(st) + bt := h.getTodayBreakThreshold(user) + text, kb := h.buildSettingsKeyboard(user, st, bt) + h.editText(ctx, chatID, msgID, text, &kb) +} + +func (h *Handler) setReportToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, enable bool) { + defer h.answerCb(ctx, cbID) + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + return + } + st.ReportEnabled = enable + h.Repo.UpdateSettings(st) + bt := h.getTodayBreakThreshold(user) + text, kb := h.buildSettingsKeyboard(user, st, bt) + h.editText(ctx, chatID, msgID, text, &kb) +} + +// -- Prompt callbacks (settings items that need text input) -- + +func (h *Handler) usernameCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + h.promptForInput(ctx, chatID, msgID, user.ID, PendingUsername, + fmt.Sprintf("Current username: %s\n\nSend me the new username:", user.Username), nil) +} + +func (h *Handler) rateCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + h.promptForInput(ctx, chatID, msgID, user.ID, PendingRate, + "Send me the hourly rate (e.g. 25.50):", nil) +} + +func (h *Handler) currencyCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + h.promptForInput(ctx, chatID, msgID, user.ID, PendingCurrency, + "Send me the currency (symbol and code, e.g. $ USD):\nPresets: $ USD, T Toman, IRR Rial, EUR, GBP", nil) +} + +func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + h.promptForInput(ctx, chatID, msgID, user.ID, PendingTimezone, + fmt.Sprintf("Current timezone: %s\n\nSend me the IANA timezone (e.g. Asia/Tehran, Europe/London):", user.Timezone), nil) +} + +func (h *Handler) breakThresholdCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + h.promptForInput(ctx, chatID, msgID, user.ID, PendingBreak, + "Send me the break threshold in minutes (0-480, e.g. 15):", nil) +} + +func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + st, _ := h.Repo.GetOrCreateSettings(user.ID) + current := "08:00" + if st != nil { + current = st.ReportTime + } + h.promptForInput(ctx, chatID, msgID, user.ID, PendingReportTime, + fmt.Sprintf("Current report time: %s\n\nSend me the report time in HH:MM (24-hour):", current), nil) +} + +func (h *Handler) calTypeCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + text, kb := h.buildCalendarTypeKeyboard(user) + h.editText(ctx, chatID, msgID, text, &kb) +} + +func (h *Handler) accentCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + return + } + text, kb := h.buildAccentKeyboard(st) + h.editText(ctx, chatID, msgID, text, &kb) +} + +// -- Accent picker -- + +func (h *Handler) buildAccentKeyboard(st *domain.UserSettings) (string, models.InlineKeyboardMarkup) { + type accentOption struct{ name, color string } + accents := []accentOption{ + {"ocean", "Blue"}, + {"beach", "Warm"}, + {"rose", "Pink"}, + {"catppuccin", "Purple"}, + } + rows := [][]models.InlineKeyboardButton{} + for _, a := range accents { + label := fmt.Sprintf("%s (%s)", a.name, a.color) + if a.name == st.ExportAccent { + label = "> " + label + } + rows = append(rows, []models.InlineKeyboardButton{ + {Text: label, CallbackData: "accent_" + a.name}, + }) + } + rows = append(rows, []models.InlineKeyboardButton{ + {Text: "Back to Settings", CallbackData: "back_settings"}, + }) + return "Select accent color:", models.InlineKeyboardMarkup{InlineKeyboard: rows} +} + +func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, name string) { + defer h.answerCb(ctx, cbID) + valid := map[string]bool{"ocean": true, "beach": true, "rose": true, "catppuccin": true} + if !valid[name] { + return + } + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + return + } + st.ExportAccent = name + h.Repo.UpdateSettings(st) + bt := h.getTodayBreakThreshold(user) + text, kb := h.buildSettingsKeyboard(user, st, bt) + h.editText(ctx, chatID, msgID, text, &kb) +} + +// -- Calendar type picker -- + +func (h *Handler) buildCalendarTypeKeyboard(user *domain.User) (string, models.InlineKeyboardMarkup) { + cals := []string{"gregorian", "jalali", "hijri"} + calLabels := map[string]string{ + "gregorian": "Gregorian", + "jalali": "Jalali", + "hijri": "Hijri", + } + rows := [][]models.InlineKeyboardButton{} + for _, c := range cals { + label := calLabels[c] + if c == user.Calendar { + label = "> " + label + } + rows = append(rows, []models.InlineKeyboardButton{ + {Text: label, CallbackData: "caltype_" + c}, + }) + } + rows = append(rows, []models.InlineKeyboardButton{ + {Text: "Back to Settings", CallbackData: "back_settings"}, + }) + return "Select calendar type:", models.InlineKeyboardMarkup{InlineKeyboard: rows} +} + +func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, name string) { + defer h.answerCb(ctx, cbID) + valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true} + if !valid[name] { + return + } + user.Calendar = name + if err := h.Repo.UpdateUser(user); err != nil { + return + } + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + return + } + bt := h.getTodayBreakThreshold(user) + text, kb := h.buildSettingsKeyboard(user, st, bt) + h.editText(ctx, chatID, msgID, text, &kb) +} + +// -- Break threshold select (preset picker) -- + +func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, valStr string) { + defer h.answerCb(ctx, cbID) + mins, err := strconv.Atoi(valStr) + if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins { + return + } + loc := h.loadLocation(user.Timezone) + today := time.Now().In(loc).Format(domain.DateLayout) + day, err := h.Repo.GetOrCreateDay(user.ID, today) + if err != nil { + return + } + day.MinBreakThreshold = int64(mins) * 60 + h.Repo.UpdateDay(day) + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + return + } + bt := h.getTodayBreakThreshold(user) + text, kb := h.buildSettingsKeyboard(user, st, bt) + h.editText(ctx, chatID, msgID, text, &kb) +} + +// -- Currency preset picker -- + +func (h *Handler) selectCurrency(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, currency string) { + defer h.answerCb(ctx, cbID) + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + return + } + st.Currency = currency + h.Repo.UpdateSettings(st) + bt := h.getTodayBreakThreshold(user) + text, kb := h.buildSettingsKeyboard(user, st, bt) + h.editText(ctx, chatID, msgID, text, &kb) +} + +// -- Command implementations for settings (prompt-and-wait OR direct args) -- + +func (h *Handler) handleSetRate(ctx context.Context, msg *models.Message, user *domain.User) { + args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setrate")) + if args == "" { + h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingRate, + "Send me the hourly rate (e.g. 25.50):", nil) + return + } + var rate float64 + if _, err := fmt.Sscanf(args, "%f", &rate); err != nil || rate < 0 || rate > domain.MaxHourlyRate { + h.sendText(ctx, msg.Chat.ID, "Invalid rate. Use a positive number (e.g. /setrate 25.50)") + return + } + h.saveRate(ctx, msg.Chat.ID, user, rate) +} + +func (h *Handler) handleSetCurrency(ctx context.Context, msg *models.Message, user *domain.User) { + args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcurrency")) + if args == "" { + h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingCurrency, + "Send me the currency (symbol and code, e.g. $ USD):", nil) + return + } + currency, errMsg := domain.ParseCurrencyInput(args) + if errMsg != "" { + h.sendText(ctx, msg.Chat.ID, errMsg) + return + } + h.saveCurrency(ctx, msg.Chat.ID, user, currency) +} + +func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message, user *domain.User) { + args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/settimezone")) + if args == "" { + h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingTimezone, + fmt.Sprintf("Current timezone: %s\n\nSend me the IANA timezone:", user.Timezone), nil) + return + } + if _, err := time.LoadLocation(args); err != nil { + h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran", args)) + return + } + h.saveTimezone(ctx, msg.Chat.ID, user, args) +} + +func (h *Handler) handleSetBreak(ctx context.Context, msg *models.Message, user *domain.User) { + args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setbreak")) + if args == "" { + h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingBreak, + "Send me the break threshold in minutes (0-480):", nil) + return + } + mins, err := strconv.Atoi(args) + if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins { + h.sendText(ctx, msg.Chat.ID, "Invalid value. Must be between 0 and 480 minutes.") + return + } + h.saveBreakThreshold(ctx, msg.Chat.ID, user, mins) +} + +func (h *Handler) handleSetUsername(ctx context.Context, msg *models.Message, user *domain.User) { + name := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setusername")) + if name == "" { + h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingUsername, + fmt.Sprintf("Current username: %s\n\nSend me the new username:", user.Username), nil) + return + } + h.saveUsername(ctx, msg.Chat.ID, user, name) +} + +func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message, user *domain.User) { + args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setreporttime")) + if args == "" { + st, _ := h.Repo.GetOrCreateSettings(user.ID) + current := "08:00" + if st != nil { + current = st.ReportTime + } + h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingReportTime, + fmt.Sprintf("Current report time: %s\n\nSend me the report time in HH:MM:", current), nil) + return + } + if err := h.validateAndSaveReportTime(ctx, msg.Chat.ID, user, args); err != nil { + h.sendText(ctx, msg.Chat.ID, err.Error()) + } +} + +func (h *Handler) handleSetAccent(ctx context.Context, msg *models.Message, user *domain.User) { + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error loading settings") + return + } + text, kb := h.buildAccentKeyboard(st) + h.sendWithKB(ctx, msg.Chat.ID, text, kb) +} + +func (h *Handler) handleRPGSettings(ctx context.Context, msg *models.Message, user *domain.User) { + text, kb := h.buildToggleKeyboard(user.ID, "rpg", "RPG System") + h.sendWithKB(ctx, msg.Chat.ID, text, kb) +} + +func (h *Handler) handleLeagueToggle(ctx context.Context, msg *models.Message, user *domain.User) { + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error loading settings") + return + } + if !st.RPGEnabled { + h.sendText(ctx, msg.Chat.ID, "League requires the RPG system. Enable RPG first with /rpgtoggle.") + return + } + text, kb := h.buildToggleKeyboard(user.ID, "league", "WorkTime League") + h.sendWithKB(ctx, msg.Chat.ID, text, kb) +} + +// -- Pending input processors -- + +func (h *Handler) processRateInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) { + var rate float64 + if _, err := fmt.Sscanf(text, "%f", &rate); err != nil || rate < 0 || rate > domain.MaxHourlyRate { + h.editText(ctx, msg.Chat.ID, pi.MsgID, "Invalid rate. Use a positive number.", &models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Try again", CallbackData: "setrate"}}, + {{Text: "Cancel", CallbackData: "back_settings"}}, + }, + }) + return + } + h.saveRate(ctx, msg.Chat.ID, user, rate) + backKB := settingsBackKeyboard() + h.editText(ctx, msg.Chat.ID, pi.MsgID, + fmt.Sprintf("Hourly rate set to %s%.2f", domain.CurrencySymbol(""), rate), &backKB) +} + +func (h *Handler) processCurrencyInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) { + currency, errMsg := domain.ParseCurrencyInput(text) + if errMsg != "" { + h.editText(ctx, msg.Chat.ID, pi.MsgID, errMsg, &models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Try again", CallbackData: "currency"}}, + {{Text: "Cancel", CallbackData: "back_settings"}}, + }, + }) + return + } + h.saveCurrency(ctx, msg.Chat.ID, user, currency) + backKB := settingsBackKeyboard() + h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Currency set to: %s", currency), &backKB) +} + +func (h *Handler) processTimezoneInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) { + if _, err := time.LoadLocation(text); err != nil { + h.editText(ctx, msg.Chat.ID, pi.MsgID, + fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran", text), &models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Try again", CallbackData: "timezone"}}, + {{Text: "Cancel", CallbackData: "back_settings"}}, + }, + }) + return + } + h.saveTimezone(ctx, msg.Chat.ID, user, text) + loc := h.loadLocation(text) + now := time.Now().In(loc) + backKB := settingsBackKeyboard() + h.editText(ctx, msg.Chat.ID, pi.MsgID, + fmt.Sprintf("Timezone set to %s (current time: %s)", text, now.Format(domain.TimeLayout)), &backKB) +} + +func (h *Handler) processBreakInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) { + mins, err := strconv.Atoi(text) + if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins { + h.editText(ctx, msg.Chat.ID, pi.MsgID, + "Invalid value. Must be between 0 and 480 minutes.", &models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Try again", CallbackData: "breakthreshold"}}, + {{Text: "Cancel", CallbackData: "back_settings"}}, + }, + }) + return + } + h.saveBreakThreshold(ctx, msg.Chat.ID, user, mins) + st, _ := h.Repo.GetOrCreateSettings(user.ID) + bt := h.getTodayBreakThreshold(user) + text2, kb := h.buildSettingsKeyboard(user, st, bt) + h.editText(ctx, msg.Chat.ID, pi.MsgID, text2, &kb) +} + +func (h *Handler) processUsernameInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) { + h.saveUsername(ctx, msg.Chat.ID, user, text) + backKB := settingsBackKeyboard() + h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Username set to: %s", text), &backKB) +} + +func (h *Handler) processReportTimeInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) { + if err := h.validateAndSaveReportTime(ctx, msg.Chat.ID, user, text); err != nil { + h.editText(ctx, msg.Chat.ID, pi.MsgID, err.Error(), &models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Try again", CallbackData: "reporttime"}}, + {{Text: "Cancel", CallbackData: "back_settings"}}, + }, + }) + return + } + backKB := settingsBackKeyboard() + h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Report time set to %s", text), &backKB) +} + +func (h *Handler) processNoteInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) { + eventIDStr, ok := pi.Data["event_id"] + if !ok { + return + } + var eventID int64 + if _, err := fmt.Sscanf(eventIDStr, "%d", &eventID); err != nil { + return + } + note := domain.SanitizeNote(text) + if err := h.Repo.SetEventNote(eventID, note); err != nil { + slog.Error("failed to save event note", "event_id", eventID, "error", err) + return + } + event, err := h.Repo.GetEventByID(eventID, user.ID) + if err != nil { + return + } + loc := h.loadLocation(user.Timezone) + date := time.Unix(event.OccurredAt, 0).In(loc).Format(domain.DateLayout) + h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true) +} + +// -- Shared save helpers -- + +func (h *Handler) saveRate(ctx context.Context, chatID int64, user *domain.User, rate float64) { + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + h.sendText(ctx, chatID, "Error saving rate") + return + } + wasZero := st.HourlyRate == 0 + st.HourlyRate = float64(int(rate*domain.SalaryRoundFactor)) / 100.0 + if err := h.Repo.UpdateSettings(st); err != nil { + h.sendText(ctx, chatID, "Error saving rate") + return + } + if wasZero { + h.tryUnlockAchievement(user.ID, "salary_setter") + } +} + +func (h *Handler) saveCurrency(_ context.Context, _ int64, user *domain.User, currency string) { + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + return + } + st.Currency = currency + h.Repo.UpdateSettings(st) +} + +func (h *Handler) saveTimezone(_ context.Context, _ int64, user *domain.User, tz string) { + user.Timezone = tz + h.Repo.UpdateUser(user) +} + +func (h *Handler) saveBreakThreshold(_ context.Context, _ int64, user *domain.User, mins int) { + loc := h.loadLocation(user.Timezone) + today := time.Now().In(loc).Format(domain.DateLayout) + day, err := h.Repo.GetOrCreateDay(user.ID, today) + if err != nil { + return + } + day.MinBreakThreshold = int64(mins) * 60 + h.Repo.UpdateDay(day) +} + +func (h *Handler) saveUsername(_ context.Context, _ int64, user *domain.User, name string) { + name = domain.SanitizeDisplayName(name) + if name == "" { + return + } + user.Username = name + h.Repo.UpdateUser(user) +} + +func (h *Handler) validateAndSaveReportTime(_ context.Context, _ int64, user *domain.User, timeStr string) error { + if len(timeStr) != 5 || timeStr[2] != ':' { + return fmt.Errorf("invalid format. Use HH:MM (24-hour)") + } + hours, err1 := strconv.Atoi(timeStr[:2]) + mins, err2 := strconv.Atoi(timeStr[3:]) + if err1 != nil || err2 != nil || hours < 0 || hours > 23 || mins < 0 || mins > 59 { + return fmt.Errorf("invalid time. Use HH:MM (24-hour)") + } + st, err := h.Repo.GetOrCreateSettings(user.ID) + if err != nil { + return fmt.Errorf("error saving report time") + } + st.ReportTime = timeStr + return h.Repo.UpdateSettings(st) +} diff --git a/internal/bot/summary.go b/internal/handler/summary.go similarity index 61% rename from internal/bot/summary.go rename to internal/handler/summary.go index 83f8867..a014d6b 100644 --- a/internal/bot/summary.go +++ b/internal/handler/summary.go @@ -1,4 +1,4 @@ -package bot +package handler import ( "context" @@ -8,21 +8,15 @@ import ( "github.com/go-telegram/bot/models" - "worktimeBot/internal/db" + "worktimeBot/internal/domain" ) -// handleSummaryMsg parses /summary [YYYY-MM] and sends a monthly summary. -func (h *Handler) handleSummaryMsg(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 - } - loc := loadLocation(user.Timezone) +func (h *Handler) handleSummary(ctx context.Context, msg *models.Message, user *domain.User) { + loc := h.loadLocation(user.Timezone) now := time.Now().In(loc) args := strings.Fields(msg.Text) - if len(args) > 0 && args[0][0] == '/' { + if len(args) >= 1 && args[0][0] == '/' { args = args[1:] } @@ -36,25 +30,24 @@ func (h *Handler) handleSummaryMsg(ctx context.Context, msg *models.Message) { calY, calM = now.Year(), int(now.Month()) switch user.Calendar { case "jalali": - calY, calM, _ = gregorianToJalali(calY, calM, now.Day()) + calY, calM, _ = domain.GregorianToJalali(calY, calM, now.Day()) case "hijri": - calY, calM, _ = gregorianToHijri(calY, calM, now.Day()) + calY, calM, _ = domain.GregorianToHijri(calY, calM, now.Day()) } } - startStr, endStr := monthGregorianRange(user.Calendar, calY, calM) + startStr, endStr := domain.MonthGregorianRange(user.Calendar, calY, calM) summary := h.buildMonthlySummary(user, calY, calM, startStr, endStr, loc) h.sendText(ctx, msg.Chat.ID, summary) } -// buildMonthlySummary returns a formatted summary for a calendar month. -func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, endStr string, loc *time.Location) string { - days, err := h.DB.GetUserDaysInRange(user.ID, startStr, endStr) +func (h *Handler) buildMonthlySummary(user *domain.User, calY, calM int, startStr, endStr string, loc *time.Location) string { + days, err := h.Repo.GetUserDaysInRange(user.ID, startStr, endStr) if err != nil { return "Error loading days" } - title := formatMonthTitle(user.Calendar, calY, calM) + title := domain.FormatMonthTitle(user.Calendar, calY, calM) totalWork := int64(0) totalBreak := int64(0) @@ -63,13 +56,13 @@ func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, e dayDetails := []string{} for _, day := range days { - dayLabel := formatDateForCalendar(day.Date, user.Calendar) + dayLabel := domain.FormatDateForCalendar(day.Date, user.Calendar) if day.IsDayOff { dayOffCount++ } - events, err := h.DB.EventsForDayByDayID(day.ID) + events, err := h.Repo.EventsForDayByDayID(day.ID) if err != nil || len(events) == 0 { if day.IsDayOff { dayDetails = append(dayDetails, fmt.Sprintf(" %s - Day Off", dayLabel)) @@ -77,10 +70,10 @@ func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, e continue } - y, m, d := parseGregorianDateKey(day.Date) + y, m, d := domain.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) + totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) if totals.TotalSeconds == 0 { if day.IsDayOff { dayDetails = append(dayDetails, fmt.Sprintf(" %s - Day Off", dayLabel)) @@ -109,13 +102,13 @@ func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, e b.WriteString(fmt.Sprintf(", %s break", formatDuration(totalBreak))) } - st, _ := h.DB.GetOrCreateSettings(user.ID) + st, _ := h.Repo.GetOrCreateSettings(user.ID) if st.HourlyRate > 0 { - est := computeMonthlySalary(days, h.DB, user.ID, st.HourlyRate, loc) + est := h.computeMonthlySalary(days, user.ID, st.HourlyRate, loc) b.WriteString(fmt.Sprintf("\nSalary: %s (%.1f hours, %d days)", - est.formatSalary(st.Currency), est.WorkHours, est.WorkDays)) + est.FormatSalaryShort(st.Currency), est.WorkHours, est.WorkDays)) } else { - b.WriteString("\nSalary: No rate configured — use /setrate ") + b.WriteString("\nSalary: No rate configured. Use /setrate ") } b.WriteString("\n\nDetails:\n") diff --git a/internal/handler/worktype.go b/internal/handler/worktype.go new file mode 100644 index 0000000..d68d860 --- /dev/null +++ b/internal/handler/worktype.go @@ -0,0 +1,75 @@ +package handler + +import ( + "context" + "fmt" + + "github.com/go-telegram/bot/models" + + "worktimeBot/internal/domain" +) + +func (h *Handler) handleWorkType(ctx context.Context, msg *models.Message, user *domain.User) { + day, err := h.getTodayDay(user) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error loading work types") + return + } + text, kb := h.buildWorkTypeKeyboard(user, day) + h.sendWithKB(ctx, msg.Chat.ID, text, kb) +} + +func (h *Handler) workTypeCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + day, err := h.getTodayDay(user) + if err != nil { + kb := backKeyboard() + h.editText(ctx, chatID, msgID, "Error loading work types", &kb) + return + } + text, kb := h.buildWorkTypeKeyboard(user, day) + h.editText(ctx, chatID, msgID, text, &kb) +} + +func (h *Handler) buildWorkTypeKeyboard(user *domain.User, day *domain.Day) (string, models.InlineKeyboardMarkup) { + wts, err := h.Repo.GetWorkTypes() + if err != nil || len(wts) == 0 { + return "No work types available.", backKeyboard() + } + rows := [][]models.InlineKeyboardButton{} + for _, wt := range wts { + label := wt.Name + if wt.ID == day.CurrentWorkTypeID { + label = "> " + wt.Name + } + rows = append(rows, []models.InlineKeyboardButton{ + {Text: label, CallbackData: fmt.Sprintf("worktype_%d", wt.ID)}, + }) + } + rows = append(rows, []models.InlineKeyboardButton{ + {Text: "Back to Menu", CallbackData: "back_menu"}, + }) + return "Select work type:", models.InlineKeyboardMarkup{InlineKeyboard: rows} +} + +func (h *Handler) selectWorkType(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, idStr string) { + defer h.answerCb(ctx, cbID) + day, err := h.getTodayDay(user) + if err != nil { + return + } + var wtID int64 + if _, err := fmt.Sscanf(idStr, "%d", &wtID); err != nil { + return + } + wt, err := h.Repo.GetWorkType(wtID) + if err != nil { + return + } + day.CurrentWorkTypeID = wtID + if err := h.Repo.UpdateDay(day); err != nil { + return + } + kb := backKeyboard() + h.editText(ctx, chatID, msgID, fmt.Sprintf("Work type set to: %s", wt.Name), &kb) +} diff --git a/internal/repo/interface.go b/internal/repo/interface.go new file mode 100644 index 0000000..5679e1e --- /dev/null +++ b/internal/repo/interface.go @@ -0,0 +1,59 @@ +package repo + +import "worktimeBot/internal/domain" + +type Repository interface { + // Users + GetOrCreateUser(chatID int64) (*domain.User, error) + UpdateUser(user *domain.User) error + GetAllUsers() ([]domain.User, error) + + // Settings + GetOrCreateSettings(userID int64) (*domain.UserSettings, error) + UpdateSettings(st *domain.UserSettings) error + MarkReportSent(userID int64, date string) error + + // Days + GetOrCreateDay(userID int64, date string) (*domain.Day, error) + GetDay(userID int64, date string) (*domain.Day, error) + UpdateDay(day *domain.Day) error + GetUserDaysInRange(userID int64, start, end string) ([]domain.Day, error) + SetDayOff(userID int64, date, reason string) error + RemoveDayOff(userID int64, date string) error + + // Events + CreateEvent(userID, dayID int64, eventType string, workTypeID *int64, occurredAt int64, note string) error + GetLastEvent(userID int64) (*domain.Event, error) + EventsForDayByDate(userID int64, date string) ([]domain.Event, error) + EventsForDayByDayID(dayID int64) ([]domain.Event, error) + GetEventByID(eventID, userID int64) (*domain.Event, error) + UpdateEventTime(eventID int64, occurredAt int64) error + UpdateEventWorkType(eventID, workTypeID int64) error + SetEventNote(eventID int64, note string) error + DeleteEvent(eventID int64) error + GetDistinctEventDates(userID int64, start, end string) ([]string, error) + GetDatesWithEvents(userID int64) ([]string, error) + + // Work Types + GetWorkTypes() ([]domain.WorkType, error) + GetWorkType(id int64) (*domain.WorkType, error) + + // RPG + GetOrCreateRPGStats(userID int64) (*domain.RPGStats, error) + UpdateRPGStats(stats *domain.RPGStats) error + UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error + SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error + SumDailyWorkSeconds(userID int64) (int64, error) + + // Achievements + GetAchievementByCode(code string) (*domain.Achievement, error) + UnlockAchievement(userID, achievementID int64) (bool, error) + HasAchievement(userID int64, code string) (bool, error) + GetUserAchievements(userID int64) ([]domain.UserAchievement, error) + + // League + GetLeagueRankings(orderBy string) ([]domain.LeagueEntry, error) + + // Lifecycle + Close() error +} diff --git a/internal/repo/migrations.go b/internal/repo/migrations.go new file mode 100644 index 0000000..b9863d1 --- /dev/null +++ b/internal/repo/migrations.go @@ -0,0 +1,221 @@ +package repo + +import ( + "database/sql" + "embed" + "log/slog" + "time" + + "github.com/pressly/goose/v3" +) + +//go:embed migrations/*.sql +var migrationFS embed.FS + +// runMigrations handles all database migrations using goose. +func runMigrations(db *sql.DB) error { + needsOldMigrate := hasTable(db, "events_old") || (hasTable(db, "events") && !hasCol(db, "events", "user_id")) + if needsOldMigrate { + slog.Info("migrating from old schema to new schema") + for _, t := range []string{"events", "days_off", "settings"} { + if hasTable(db, t) { + if _, err := db.Exec("ALTER TABLE " + t + " RENAME TO " + t + "_old"); err != nil { + return err + } + } + } + } + + goose.SetBaseFS(migrationFS) + if err := goose.SetDialect("sqlite3"); err != nil { + return err + } + if err := goose.Up(db, "migrations"); err != nil { + return err + } + + if needsOldMigrate { + if err := migrateFromOldSchemaData(db); err != nil { + return err + } + } + + return nil +} + +func hasTable(db *sql.DB, name string) bool { + var exists int + if err := db.QueryRow("SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&exists); err != nil { + return false + } + return exists > 0 +} + +func hasCol(db *sql.DB, table, column string) bool { + rows, err := db.Query("PRAGMA table_info(" + table + ")") + if err != nil { + return false + } + defer rows.Close() + for rows.Next() { + var cid int + var name, ctype string + var notnull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + return false + } + if name == column { + return true + } + } + return false +} + +func migrateFromOldSchemaData(db *sql.DB) error { + migrateUsers(db) + migrateDaysOff(db) + migrateEvents(db) + migrateSettings(db) + + for _, t := range []string{"days_off_old", "settings_old", "events_old"} { + if hasTable(db, t) { + if _, err := db.Exec("DROP TABLE IF EXISTS " + t); err != nil { + slog.Warn("failed to drop old table", "table", t, "error", err) + } + } + } + + slog.Info("migration complete") + return nil +} + +func migrateUsers(db *sql.DB) { + if !hasTable(db, "events_old") { + return + } + rows, err := db.Query("SELECT DISTINCT chat_id FROM events_old") + if err != nil { + slog.Warn("migration: failed to query events_old for users", "error", err) + return + } + defer rows.Close() + for rows.Next() { + var chatID int64 + if err := rows.Scan(&chatID); err != nil { + slog.Warn("migration: scan failed", "error", err) + continue + } + if _, err := db.Exec( + "INSERT OR IGNORE INTO users (chat_id, timezone) VALUES (?, 'Asia/Tehran')", + chatID, + ); err != nil { + slog.Warn("migration: failed to insert user", "chat_id", chatID, "error", err) + } + } +} + +func migrateDaysOff(db *sql.DB) { + if !hasTable(db, "days_off_old") { + return + } + rows, err := db.Query("SELECT chat_id, date, reason FROM days_off_old") + if err != nil { + slog.Warn("migration: failed to query days_off_old", "error", err) + return + } + defer rows.Close() + for rows.Next() { + var chatID int64 + var date, reason string + if err := rows.Scan(&chatID, &date, &reason); err != nil { + slog.Warn("migration: scan failed", "error", err) + continue + } + var userID int64 + if err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID); err != nil { + slog.Warn("migration: user not found", "chat_id", chatID, "error", err) + continue + } + if _, err := db.Exec( + "INSERT INTO days (user_id, date, is_day_off, day_off_reason) VALUES (?, ?, 1, ?) ON CONFLICT(user_id, date) DO UPDATE SET is_day_off=1, day_off_reason=excluded.day_off_reason", + userID, date, reason, + ); err != nil { + slog.Warn("migration: failed to insert day_off", "user_id", userID, "date", date, "error", err) + } + } +} + +func migrateEvents(db *sql.DB) { + if !hasTable(db, "events_old") { + return + } + rows, err := db.Query("SELECT chat_id, event_type, occurred_at, note, created_at FROM events_old ORDER BY occurred_at ASC") + if err != nil { + slog.Warn("migration: failed to query events_old", "error", err) + return + } + defer rows.Close() + for rows.Next() { + var chatID int64 + var eventType, note string + var occurredAt, createdAt int64 + if err := rows.Scan(&chatID, &eventType, &occurredAt, ¬e, &createdAt); err != nil { + slog.Warn("migration: scan failed", "error", err) + continue + } + var userID int64 + if err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID); err != nil { + slog.Warn("migration: user not found", "chat_id", chatID, "error", err) + continue + } + date := time.Unix(occurredAt, 0).UTC().Format("2006-01-02") + if _, err := db.Exec("INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)", userID, date); err != nil { + slog.Warn("migration: failed to insert day", "user_id", userID, "date", date, "error", err) + continue + } + var dayID int64 + if err := db.QueryRow("SELECT id FROM days WHERE user_id=? AND date=?", userID, date).Scan(&dayID); err != nil { + slog.Warn("migration: day not found", "user_id", userID, "date", date, "error", err) + continue + } + if _, err := db.Exec( + "INSERT INTO events (user_id, day_id, event_type, occurred_at, note, created_at) VALUES (?, ?, ?, ?, ?, ?)", + userID, dayID, eventType, occurredAt, note, createdAt, + ); err != nil { + slog.Warn("migration: failed to insert event", "user_id", userID, "error", err) + } + } +} + +func migrateSettings(db *sql.DB) { + if !hasTable(db, "settings_old") { + return + } + rows, err := db.Query("SELECT chat_id, key, value FROM settings_old") + if err != nil { + slog.Warn("migration: failed to query settings_old", "error", err) + return + } + defer rows.Close() + for rows.Next() { + var chatID int64 + var key, value string + if err := rows.Scan(&chatID, &key, &value); err != nil { + slog.Warn("migration: scan failed", "error", err) + continue + } + if key == "remote_flag" { + var userID int64 + if err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID); err != nil { + slog.Warn("migration: user not found", "chat_id", chatID, "error", err) + continue + } + wt := int64(1) + if value == "remote" { + wt = 2 + } + db.Exec("UPDATE days SET current_work_type_id=? WHERE user_id=? AND date=?", wt, userID, time.Now().UTC().Format("2006-01-02")) + } + } +} diff --git a/internal/db/migrations/001_init.sql b/internal/repo/migrations/001_init.sql similarity index 100% rename from internal/db/migrations/001_init.sql rename to internal/repo/migrations/001_init.sql diff --git a/internal/db/migrations/002_features.sql b/internal/repo/migrations/002_features.sql similarity index 100% rename from internal/db/migrations/002_features.sql rename to internal/repo/migrations/002_features.sql diff --git a/internal/repo/store.go b/internal/repo/store.go new file mode 100644 index 0000000..c8d083b --- /dev/null +++ b/internal/repo/store.go @@ -0,0 +1,607 @@ +package repo + +import ( + "database/sql" + "errors" + "fmt" + "log/slog" + "time" + + _ "modernc.org/sqlite" + + "worktimeBot/internal/domain" +) + +const driverName = "sqlite" + +type Store struct { + db *sql.DB +} + +func NewStore(path string) (*Store, error) { + db, err := sql.Open(driverName, path) + if err != nil { + return nil, err + } + if err := db.Ping(); err != nil { + return nil, err + } + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + slog.Warn("failed to set WAL mode", "error", err) + } + if err := runMigrations(db); err != nil { + return nil, err + } + return &Store{db: db}, nil +} + +func (s *Store) Close() error { + return s.db.Close() +} + +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))] +} + +func (s *Store) GetOrCreateUser(chatID int64) (*domain.User, error) { + if _, err := s.db.Exec( + "INSERT OR IGNORE INTO users (chat_id, username) VALUES (?, ?)", + chatID, randomUsername(), + ); err != nil { + return nil, err + } + return s.GetUserByChatID(chatID) +} + +func (s *Store) GetUserByChatID(chatID int64) (*domain.User, error) { + row := s.db.QueryRow(` + SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at + FROM users WHERE chat_id = ? + `, chatID) + var u domain.User + if err := row.Scan( + &u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive, + &u.Calendar, &u.CreatedAt, &u.UpdatedAt, + ); err != nil { + return nil, err + } + return &u, nil +} + +func (s *Store) UpdateUser(user *domain.User) error { + _, err := s.db.Exec( + "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 +} + +func (s *Store) GetAllUsers() ([]domain.User, error) { + rows, err := s.db.Query(` + SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at + FROM users + `) + if err != nil { + return nil, err + } + defer rows.Close() + var users []domain.User + for rows.Next() { + var u domain.User + if err := rows.Scan( + &u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive, + &u.Calendar, &u.CreatedAt, &u.UpdatedAt, + ); err != nil { + return nil, err + } + users = append(users, u) + } + return users, rows.Err() +} + +// Settings + +func (s *Store) GetOrCreateSettings(userID int64) (*domain.UserSettings, error) { + if _, err := s.db.Exec( + "INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)", + userID, + ); err != nil { + return nil, err + } + return s.getSettings(userID) +} + +func (s *Store) getSettings(userID int64) (*domain.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 domain.UserSettings + if 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, + ); err != nil { + return nil, err + } + return &st, nil +} + +func (s *Store) UpdateSettings(st *domain.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 +} + +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) +} + +// RPG Stats + +func (s *Store) GetOrCreateRPGStats(userID int64) (*domain.RPGStats, error) { + if _, err := s.db.Exec( + "INSERT OR IGNORE INTO user_rpg (user_id) VALUES (?)", + userID, + ); err != nil { + return nil, err + } + return s.getRPGStats(userID) +} + +func (s *Store) getRPGStats(userID int64) (*domain.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 domain.RPGStats + if err := row.Scan( + &st.UserID, &st.XP, &st.Level, &st.CurrentStreak, &st.LongestStreak, + &st.TotalWorkSeconds, &st.CreatedAt, &st.UpdatedAt, + ); err != nil { + return nil, err + } + return &st, nil +} + +func (s *Store) UpdateRPGStats(st *domain.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 + +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 +} + +func (s *Store) SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error { + _, err := s.db.Exec(` + INSERT INTO daily_xp (user_id, date, xp_earned, work_seconds) + VALUES (?, ?, 0, ?) + ON CONFLICT(user_id, date) DO UPDATE SET + work_seconds = ? + `, userID, date, workSeconds, workSeconds) + return err +} + +func (s *Store) SumDailyWorkSeconds(userID int64) (int64, error) { + var total int64 + err := s.db.QueryRow( + "SELECT COALESCE(SUM(work_seconds), 0) FROM daily_xp WHERE user_id=?", userID, + ).Scan(&total) + return total, err +} + +// Achievements + +func (s *Store) GetAchievementByCode(code string) (*domain.Achievement, error) { + var a domain.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 +} + +func (s *Store) GetUserAchievements(userID int64) ([]domain.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 []domain.UserAchievement + for rows.Next() { + var ua domain.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() +} + +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 +} + +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 + +func (s *Store) GetLeagueRankings(orderBy string) ([]domain.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 []domain.LeagueEntry + for rows.Next() { + var e domain.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() +} + +// Work Types + +func (s *Store) GetWorkTypes() ([]domain.WorkType, error) { + rows, err := s.db.Query("SELECT id, name, created_at FROM work_types ORDER BY id ASC") + if err != nil { + return nil, err + } + defer rows.Close() + var wts []domain.WorkType + for rows.Next() { + var wt domain.WorkType + if err := rows.Scan(&wt.ID, &wt.Name, &wt.CreatedAt); err != nil { + return nil, err + } + wts = append(wts, wt) + } + return wts, rows.Err() +} + +func (s *Store) GetWorkType(id int64) (*domain.WorkType, error) { + var wt domain.WorkType + err := s.db.QueryRow("SELECT id, name, created_at FROM work_types WHERE id=?", id).Scan(&wt.ID, &wt.Name, &wt.CreatedAt) + if err != nil { + return nil, err + } + return &wt, nil +} + +// Days + +func (s *Store) GetOrCreateDay(userID int64, date string) (*domain.Day, error) { + if _, err := s.db.Exec( + "INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)", + userID, date, + ); err != nil { + return nil, err + } + return s.GetDay(userID, date) +} + +func (s *Store) GetDay(userID int64, date string) (*domain.Day, error) { + row := s.db.QueryRow(` + SELECT id, user_id, date, is_day_off, day_off_reason, + current_work_type_id, min_break_threshold, created_at, updated_at + FROM days WHERE user_id=? AND date=? + `, userID, date) + var d domain.Day + if err := row.Scan( + &d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason, + &d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt, + ); err != nil { + return nil, err + } + return &d, nil +} + +func (s *Store) UpdateDay(day *domain.Day) error { + _, err := s.db.Exec( + "UPDATE days SET is_day_off=?, day_off_reason=?, current_work_type_id=?, min_break_threshold=?, updated_at=unixepoch() WHERE id=?", + day.IsDayOff, day.DayOffReason, day.CurrentWorkTypeID, day.MinBreakThreshold, day.ID, + ) + return err +} + +func (s *Store) SetDayOff(userID int64, date string, reason string) error { + if _, err := s.GetOrCreateDay(userID, date); err != nil { + return err + } + _, err := s.db.Exec( + "UPDATE days SET is_day_off=1, day_off_reason=?, updated_at=unixepoch() WHERE user_id=? AND date=?", + reason, userID, date, + ) + return err +} + +func (s *Store) RemoveDayOff(userID int64, date string) error { + _, err := s.db.Exec( + "UPDATE days SET is_day_off=0, day_off_reason='', updated_at=unixepoch() WHERE user_id=? AND date=?", + userID, date, + ) + return err +} + +func (s *Store) GetUserDaysInRange(userID int64, startDate, endDate string) ([]domain.Day, error) { + rows, err := s.db.Query(` + SELECT id, user_id, date, is_day_off, day_off_reason, + current_work_type_id, min_break_threshold, created_at, updated_at + FROM days + WHERE user_id=? AND date >= ? AND date <= ? + ORDER BY date ASC + `, userID, startDate, endDate) + if err != nil { + return nil, err + } + defer rows.Close() + var days []domain.Day + for rows.Next() { + var d domain.Day + if err := rows.Scan( + &d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason, + &d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt, + ); err != nil { + return nil, err + } + days = append(days, d) + } + return days, rows.Err() +} + +func (s *Store) GetDatesWithEvents(userID int64) ([]string, error) { + rows, err := s.db.Query(` + SELECT DISTINCT d.date + FROM days d + JOIN events e ON e.day_id = d.id + WHERE d.user_id = ? + ORDER BY d.date ASC + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var dates []string + for rows.Next() { + var date string + if err := rows.Scan(&date); err != nil { + return nil, err + } + dates = append(dates, date) + } + return dates, rows.Err() +} + +func (s *Store) GetDistinctEventDates(userID int64, startDate, endDate string) ([]string, error) { + rows, err := s.db.Query( + "SELECT DISTINCT d.date FROM days d JOIN events e ON e.day_id = d.id WHERE d.user_id=? AND d.date >= ? AND d.date <= ? ORDER BY d.date", + userID, startDate, endDate, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var dates []string + for rows.Next() { + var d string + if err := rows.Scan(&d); err != nil { + return nil, err + } + dates = append(dates, d) + } + return dates, rows.Err() +} + +// Events + +func (s *Store) CreateEvent(userID int64, dayID int64, eventType string, workTypeID *int64, occurredAt int64, note string) error { + _, err := s.db.Exec( + "INSERT INTO events (user_id, day_id, event_type, work_type_id, occurred_at, note) VALUES (?, ?, ?, ?, ?, ?)", + userID, dayID, eventType, workTypeID, occurredAt, note, + ) + return err +} + +func (s *Store) UpdateEventWorkType(eventID, workTypeID int64) error { + _, err := s.db.Exec("UPDATE events SET work_type_id=? WHERE id=?", workTypeID, eventID) + return err +} + +func (s *Store) UpdateEventTime(eventID, occurredAt int64) error { + _, err := s.db.Exec("UPDATE events SET occurred_at=? WHERE id=?", occurredAt, eventID) + return err +} + +func (s *Store) DeleteEvent(eventID int64) error { + _, err := s.db.Exec("DELETE FROM events WHERE id=?", eventID) + return err +} + +func (s *Store) GetEventByID(eventID, userID int64) (*domain.Event, error) { + row := s.db.QueryRow( + "SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at FROM events WHERE id=? AND user_id=?", + eventID, userID, + ) + var e domain.Event + var wt sql.NullInt64 + if err := row.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { + return nil, err + } + if wt.Valid { + e.WorkTypeID = &wt.Int64 + } + return &e, nil +} + +func (s *Store) GetLastEvent(userID int64) (*domain.Event, error) { + row := s.db.QueryRow(` + SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at + FROM events + WHERE user_id = ? + ORDER BY occurred_at DESC + LIMIT 1 + `, userID) + var e domain.Event + var wt sql.NullInt64 + if err := row.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, err + } + if wt.Valid { + e.WorkTypeID = &wt.Int64 + } + return &e, nil +} + +func (s *Store) EventsForDayByDate(userID int64, date string) ([]domain.Event, error) { + rows, err := s.db.Query(` + SELECT e.id, e.user_id, e.day_id, e.event_type, e.work_type_id, e.occurred_at, e.note, e.created_at + FROM events e + JOIN days d ON d.id = e.day_id + WHERE d.user_id = ? AND d.date = ? + ORDER BY e.occurred_at ASC + `, userID, date) + if err != nil { + return nil, err + } + defer rows.Close() + var events []domain.Event + for rows.Next() { + var e domain.Event + var wt sql.NullInt64 + if err := rows.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { + return nil, err + } + if wt.Valid { + e.WorkTypeID = &wt.Int64 + } + events = append(events, e) + } + return events, rows.Err() +} + +func (s *Store) EventsForDayByDayID(dayID int64) ([]domain.Event, error) { + rows, err := s.db.Query(` + SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at + FROM events + WHERE day_id = ? + ORDER BY occurred_at ASC + `, dayID) + if err != nil { + return nil, err + } + defer rows.Close() + var events []domain.Event + for rows.Next() { + var e domain.Event + var wt sql.NullInt64 + if err := rows.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { + return nil, err + } + if wt.Valid { + e.WorkTypeID = &wt.Int64 + } + events = append(events, e) + } + return events, rows.Err() +} + +func (s *Store) SetEventNote(eventID int64, note string) error { + _, err := s.db.Exec("UPDATE events SET note=? WHERE id=?", note, eventID) + return err +}