Files
worktimeBot/internal/handler/settings.go
db123 6f7f5878bf
All checks were successful
CI / build (push) Successful in 38s
fix: correct daily work/break calculations and league back button
- ComputeDailyTotals: only assume working from dayStart when first event
  is 'out' (cross-midnight session); otherwise start idle. Fixes inflated
  work/break counts in reports, status, export, burnout, salary, and RPG.
- leagueToggleCallback: add settings back keyboard when RPG is disabled.
- webhook: add graceful HTTP server shutdown on context cancellation.
2026-06-27 23:15:12 +03:30

810 lines
28 KiB
Go

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 <text> - 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 <name> - Set timezone (e.g. Asia/Tehran)
/setbreak <minutes> - Set break threshold in minutes
/setusername <name> - 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 <amount> - Set hourly rate (e.g. 25.50)
/setcurrency <symbol> <code> - 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)
}
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) 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)
}