diff --git a/internal/bot/burnout.go b/internal/bot/burnout.go index 6fe1805..da1226c 100644 --- a/internal/bot/burnout.go +++ b/internal/bot/burnout.go @@ -258,6 +258,9 @@ func (h *Handler) buildBurnoutText(userID int64, loc *time.Location) string { 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 { diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 5353bd3..3d0b57e 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -215,6 +215,8 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) { h.handleRPGSettingsMsg(ctx, msg) case "/leaguetoggle": h.handleLeagueSettingsMsg(ctx, msg) + case "/setusername": + h.handleSetUsername(ctx, msg) default: h.sendText(ctx, msg.Chat.ID, "Unknown command") } @@ -326,6 +328,10 @@ func (h *Handler) handlePrefixedCallback(ctx context.Context, chatID int64, msgI 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) @@ -399,13 +405,14 @@ func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) { /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 [code] - Set currency (e.g. $ USD) +/setcurrency - Set currency (e.g. $ USD) /rpgtoggle - Enable/disable RPG system /leaguetoggle - Enable/disable league participation @@ -414,6 +421,36 @@ 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) diff --git a/internal/bot/league.go b/internal/bot/league.go index 9e50406..983c244 100644 --- a/internal/bot/league.go +++ b/internal/bot/league.go @@ -18,7 +18,7 @@ const ( LeagueSortHours LeagueSortOption = "hours" ) -// buildLeagueText returns the formatted league rankings. +// buildLeagueText returns the formatted league rankings (mobile-friendly narrow layout). func (h *Handler) buildLeagueText(orderBy LeagueSortOption) string { entries, err := h.DB.GetLeagueRankings(string(orderBy)) if err != nil { @@ -33,37 +33,15 @@ func (h *Handler) buildLeagueText(orderBy LeagueSortOption) string { "xp": "XP", "level": "Level", "streak": "Streak", - "hours": "Total Hours", + "hours": "Hours", } - b.WriteString(fmt.Sprintf("WorkTime League — sorted by %s\n\n", orderLabel[string(orderBy)])) - - // Determine column widths - maxUserLen := 0 - for _, e := range entries { - l := len(e.Username) - if l > maxUserLen { - maxUserLen = l - } - } - if maxUserLen < 8 { - maxUserLen = 8 - } - if maxUserLen > 20 { - maxUserLen = 20 - } - - header := fmt.Sprintf("%-3s %-*s %8s %6s %6s %12s", "#", maxUserLen, "Username", "XP", "Level", "Streak", "Hours") - b.WriteString(header + "\n") - b.WriteString(strings.Repeat("-", len(header)) + "\n") + b.WriteString(fmt.Sprintf("WorkTime League — %s\n\n", orderLabel[string(orderBy)])) for i, e := range entries { rank := i + 1 name := e.Username - if len(name) > maxUserLen { - name = name[:maxUserLen] - } - b.WriteString(fmt.Sprintf("%-3d %-*s %8d %6d %6d %12.1f\n", - rank, maxUserLen, name, e.XP, e.Level, e.Streak, e.TotalHours)) + b.WriteString(fmt.Sprintf("%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))) @@ -128,12 +106,13 @@ func (h *Handler) handleLeagueCallback(ctx context.Context, chatID int64, msgID h.editText(ctx, chatID, msgID, text, &kb) } -// buildLeagueKeyboard creates the league navigation keyboard. +// buildLeagueKeyboard creates the league navigation keyboard (2x2 sort grid). func (h *Handler) buildLeagueKeyboard(currentOrder string) models.InlineKeyboardMarkup { - orders := []struct { + type sortBtn struct { label string data string - }{ + } + orders := []sortBtn{ {"XP", "league_xp"}, {"Level", "league_level"}, {"Streak", "league_streak"}, @@ -141,14 +120,16 @@ func (h *Handler) buildLeagueKeyboard(currentOrder string) models.InlineKeyboard } rows := [][]models.InlineKeyboardButton{} - for _, o := range orders { - label := o.label - if o.data == "league_"+currentOrder { - label = "> " + label + for i := 0; i < len(orders); i += 2 { + row := []models.InlineKeyboardButton{} + for j := i; j < i+2 && j < len(orders); j++ { + label := orders[j].label + if orders[j].data == "league_"+currentOrder { + label = "> " + label + } + row = append(row, models.InlineKeyboardButton{Text: label, CallbackData: orders[j].data}) } - rows = append(rows, []models.InlineKeyboardButton{ - {Text: label, CallbackData: o.data}, - }) + rows = append(rows, row) } rows = append(rows, []models.InlineKeyboardButton{ {Text: "Back to Menu", CallbackData: "back_menu"}, diff --git a/internal/bot/salary.go b/internal/bot/salary.go index 1be130a..bf326ac 100644 --- a/internal/bot/salary.go +++ b/internal/bot/salary.go @@ -179,7 +179,7 @@ func (h *Handler) handleSetRateMsg(ctx context.Context, msg *models.Message) { h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Hourly rate set to %s%.2f", currencySymbol(st.Currency), st.HourlyRate)) } -// handleSetCurrencyMsg handles /setcurrency [code]. +// handleSetCurrencyMsg handles /setcurrency . func (h *Handler) handleSetCurrencyMsg(ctx context.Context, msg *models.Message) { args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcurrency")) if args == "" { @@ -189,18 +189,23 @@ func (h *Handler) handleSetCurrencyMsg(ctx context.Context, msg *models.Message) return } st, _ := h.DB.GetOrCreateSettings(user.ID) - h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current currency: %s\nUsage: /setcurrency [code]\nExamples:\n/setcurrency $ USD\n/setcurrency EUR\n/setcurrency Toman", st.Currency)) + 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 } parts := strings.Fields(args) - if len(parts[0]) > 10 || strings.ContainsFunc(parts[0], func(r rune) bool { return r < 32 || r > 126 }) { - h.sendText(ctx, msg.Chat.ID, "Invalid currency symbol. Use a short printable string like $, €, £, or Toman.") + if len(parts) < 2 { + h.sendText(ctx, msg.Chat.ID, "Please provide both symbol and code.\nUsage: /setcurrency \nExamples:\n/setcurrency $ USD\n/setcurrency T Toman\n/setcurrency IRR Rial") return } - currency := parts[0] - if len(parts) >= 2 { - currency = parts[0] + " " + parts[1] + if len(parts[0]) > 10 || strings.ContainsFunc(parts[0], func(r rune) bool { return r < 32 || r > 126 }) { + h.sendText(ctx, msg.Chat.ID, "Invalid currency symbol. Use a short printable string like $, €, £.") + return } + if len(parts[1]) > 10 || strings.ContainsFunc(parts[1], func(r rune) bool { return r < 32 || r > 126 }) { + h.sendText(ctx, msg.Chat.ID, "Invalid currency code.") + return + } + currency := parts[0] + " " + parts[1] user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { h.sendText(ctx, msg.Chat.ID, "Error loading profile") @@ -219,7 +224,7 @@ func (h *Handler) handleSetCurrencyMsg(ctx context.Context, msg *models.Message) h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Currency set to: %s", currency)) } -// currencyCallback shows currency info (inline). +// 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) @@ -227,12 +232,46 @@ func (h *Handler) currencyCallback(ctx context.Context, chatID int64, msgID int, return } st, _ := h.DB.GetOrCreateSettings(user.ID) - text := fmt.Sprintf("Currency: %s\n\nUse /setcurrency [code] to change it.\nExamples:\n/setcurrency $ USD\n/setcurrency EUR\n/setcurrency Toman", st.Currency) - kb := models.InlineKeyboardMarkup{ - InlineKeyboard: [][]models.InlineKeyboardButton{ - {{Text: "Back to Settings", CallbackData: "back_settings"}}, - }, + 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) } diff --git a/internal/bot/settings.go b/internal/bot/settings.go index 7b23cba..ce1f600 100644 --- a/internal/bot/settings.go +++ b/internal/bot/settings.go @@ -179,29 +179,39 @@ func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, // 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 := "disabled" + reportStatus := "off" if st.ReportEnabled { - reportStatus = "enabled" + reportStatus = "on" } - rpgStatus := "disabled" + rpgStatus := "off" if st.RPGEnabled { - rpgStatus = "enabled" + rpgStatus = "on" } - leagueStatus := "disabled" + leagueStatus := "off" if st.LeagueOptIn { leagueStatus = "on" } rows := [][]models.InlineKeyboardButton{ - {{Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"}}, - {{Text: fmt.Sprintf("Accent: %s", st.ExportAccent), CallbackData: "accent"}}, - {{Text: fmt.Sprintf("Calendar: %s", user.Calendar), CallbackData: "caltype"}}, - {{Text: fmt.Sprintf("Report: %s", reportStatus), CallbackData: "reporttoggle"}}, - {{Text: fmt.Sprintf("Report time: %s", st.ReportTime), CallbackData: "reporttime"}}, - {{Text: fmt.Sprintf("Break threshold: %dm", breakThreshold/60), CallbackData: "breakthreshold"}}, - {{Text: fmt.Sprintf("RPG: %s", rpgStatus), CallbackData: "rpgtoggle"}}, - {{Text: fmt.Sprintf("League: %s", leagueStatus), CallbackData: "leaguetoggle"}}, - {{Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"}}, - {{Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"}}, + { + {Text: 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("Currency: %s", st.Currency), CallbackData: "currency"}, + {Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"}, + }, {{Text: "Back to Menu", CallbackData: "back_menu"}}, } return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}