fix: resolve go vet error and dayStart logic in ComputeDailyTotals
- Fix 'string(d)' int-to-rune conversion in rpg_test.go by using fmt.Sprintf - Fix ComputeDailyTotals dayStart logic: initial state should be StateWorking when dayStart > 0, correctly counting work from dayStart to first out event - Update README project structure to reflect new domain/handler/repo layout - Implement missing achievements: rpg_pioneer (on RPG enable) and salary_setter (on first rate set) - Add tryUnlockAchievement helper to Handler
This commit is contained in:
720
internal/handler/settings.go
Normal file
720
internal/handler/settings.go
Normal file
@@ -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 <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: "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)
|
||||
}
|
||||
Reference in New Issue
Block a user