fix: currency validation, league mobile layout, settings 2-col, burnout ratio, add setusername

- Require both symbol+code in /setcurrency; add preset picker ($ USD,
  T Toman, IRR Rial, EUR, GBP) in settings inline menu
- League: compact 2-line per entry for mobile; sort buttons in 2x2 grid
- Settings: 2-column layout with grouped buttons (timezone+calendar,
  report+report time, break+accent, rpg+league, currency+rate)
- Burnout: cap break ratio at 100% to prevent nonsense values
- Add /setusername command (uses existing SanitizeDisplayName)
- Update /help with new/changed commands
This commit is contained in:
2026-06-25 01:46:07 +03:30
parent 344c615666
commit 605c41ab88
5 changed files with 136 additions and 66 deletions

View File

@@ -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 {

View File

@@ -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 <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 (e.g. $ USD)
/setcurrency <symbol> <code> - 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 <name>.
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 <name>", 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 <text>
func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
user, err := h.getOrCreateUser(msg.Chat.ID)

View File

@@ -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 {
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
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: o.data},
})
row = append(row, models.InlineKeyboardButton{Text: label, CallbackData: orders[j].data})
}
rows = append(rows, row)
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Menu", CallbackData: "back_menu"},

View File

@@ -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 <symbol> [code].
// handleSetCurrencyMsg handles /setcurrency <symbol> <code>.
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 <symbol> [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 <symbol> <code>\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 <symbol> <code>\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 <symbol> [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)
}

View File

@@ -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}