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) handleSettings(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 } bt := h.getTodayBreakThreshold(user) text, kb := h.buildSettingsKeyboard(user, st, bt) h.sendWithKB(ctx, msg.Chat.ID, text, kb) } func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) { text := `Commands: /start - Show main menu /settings - Open settings /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: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}}, {{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 { kb := settingsBackKeyboard() h.editText(ctx, chatID, msgID, "League requires the RPG system. Enable RPG first in Settings.", &kb) 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) st, _ := h.Repo.GetOrCreateSettings(user.ID) sym := "$" if st != nil && st.Currency != "" { sym = domain.CurrencySymbol(st.Currency) } h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Hourly rate set to %s%.2f", sym, 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) h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Currency set to: %s", 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) loc := h.loadLocation(args) now := time.Now().In(loc).Format(domain.TimeLayout) h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", args, now)) } 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) h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Break threshold set to %d minutes.", 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) h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Username set to: %s", 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()) return } h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Report time set to %s", args)) } 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() st, _ := h.Repo.GetOrCreateSettings(user.ID) sym := "$" if st != nil && st.Currency != "" { sym = domain.CurrencySymbol(st.Currency) } h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Hourly rate set to %s%.2f", sym, 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) handleSetCalendar(ctx context.Context, msg *models.Message, user *domain.User) { args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcalendar")) if args == "" { h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingCalendar, fmt.Sprintf("Current calendar: %s\n\nValid options: gregorian, jalali, hijri", user.Calendar), nil) return } args = strings.ToLower(args) valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true} if !valid[args] { h.sendText(ctx, msg.Chat.ID, "Invalid calendar. Valid options: gregorian, jalali, hijri") return } h.saveCalendar(ctx, msg.Chat.ID, user, args) } func (h *Handler) processCalendarInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) { text = strings.ToLower(strings.TrimSpace(text)) valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true} if !valid[text] { h.editText(ctx, msg.Chat.ID, pi.MsgID, "Invalid calendar. Valid options: gregorian, jalali, hijri", &models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ {{Text: "Try again", CallbackData: "caltype"}}, {{Text: "Cancel", CallbackData: "back_settings"}}, }, }) return } h.saveCalendar(ctx, msg.Chat.ID, user, text) backKB := settingsBackKeyboard() h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Calendar set to %s", text), &backKB) } func (h *Handler) saveCalendar(_ context.Context, _ int64, user *domain.User, cal string) { user.Calendar = cal if err := h.Repo.UpdateUser(user); err != nil { slog.Error("save calendar failed", "user_id", user.ID, "error", err) } } 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 { slog.Error("save currency: get settings failed", "user_id", user.ID, "error", err) return } st.Currency = currency if err := h.Repo.UpdateSettings(st); err != nil { slog.Error("save currency: update settings failed", "user_id", user.ID, "error", err) } } func (h *Handler) saveTimezone(_ context.Context, _ int64, user *domain.User, tz string) { user.Timezone = tz if err := h.Repo.UpdateUser(user); err != nil { slog.Error("save timezone failed", "user_id", user.ID, "error", err) } } 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 { slog.Error("save break threshold: get day failed", "user_id", user.ID, "error", err) return } day.MinBreakThreshold = int64(mins) * 60 if err := h.Repo.UpdateDay(day); err != nil { slog.Error("save break threshold: update day failed", "user_id", user.ID, "error", err) } } func (h *Handler) saveUsername(_ context.Context, _ int64, user *domain.User, name string) { name = domain.SanitizeDisplayName(name) if name == "" { return } user.Username = name if err := h.Repo.UpdateUser(user); err != nil { slog.Error("save username failed", "user_id", user.ID, "error", err) } } 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) } func (h *Handler) deleteAccountPrompt(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { defer h.answerCb(ctx, cbID) kb := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ { {Text: "Yes, delete everything", CallbackData: fmt.Sprintf("delaccount_%d", user.ID), Style: "danger"}, {Text: "Cancel", CallbackData: "back_settings"}, }, }, } h.editText(ctx, chatID, msgID, "This will permanently delete ALL your data (events, days, RPG progress, settings, achievements).\nAre you sure?", &kb) } func (h *Handler) deleteAccountConfirm(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { defer h.answerCb(ctx, cbID) if err := h.Repo.DeleteUserData(user.ID); err != nil { slog.Error("delete user data", "user_id", user.ID, "error", err) h.editText(ctx, chatID, msgID, "Error deleting data. Please try again.", nil) return } h.editText(ctx, chatID, msgID, "All your data has been deleted. You can start fresh by sending /start.", nil) }