feat: add /setcalendar command, store tests, and improve error handling
All checks were successful
CI / build (push) Successful in 48s

- Add /setcalendar command for calendar type parity (Settings UI)
- Add 17 store tests covering all repository methods
- Add handler tests for formatDuration, fmtHHMM, fmtDDHHMM
- Add domain tests for BurnoutLevel.String, NavMonth, FormatSalary
- Fix silent error discarding in save* helpers (log write failures)
- Fix computeAndAwardXP error handling instead of discarding
- Wrap critical store.go SQL errors with fmt.Errorf context
- Update README commands table with /setcalendar
This commit is contained in:
2026-06-25 15:56:00 +03:30
parent d9e13cb51d
commit 7dbd9fda12
8 changed files with 816 additions and 22 deletions

View File

@@ -24,6 +24,7 @@ const (
PendingRate PendingKind = "rate"
PendingCurrency PendingKind = "currency"
PendingTimezone PendingKind = "timezone"
PendingCalendar PendingKind = "calendar"
PendingBreak PendingKind = "break_threshold"
PendingUsername PendingKind = "username"
PendingReportTime PendingKind = "report_time"
@@ -176,6 +177,8 @@ func (h *Handler) routeCommand(ctx context.Context, msg *models.Message, user *d
h.handleSetAccent(ctx, msg, user)
case "/settimezone":
h.handleSetTimezone(ctx, msg, user)
case "/setcalendar":
h.handleSetCalendar(ctx, msg, user)
case "/note":
h.handleNote(ctx, msg, user)
case "/summary":
@@ -358,6 +361,8 @@ func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message,
h.processCurrencyInput(ctx, msg, user, pi, text)
case PendingTimezone:
h.processTimezoneInput(ctx, msg, user, pi, text)
case PendingCalendar:
h.processCalendarInput(ctx, msg, user, pi, text)
case PendingBreak:
h.processBreakInput(ctx, msg, user, pi, text)
case PendingUsername:

View File

@@ -0,0 +1,70 @@
package handler
import (
"testing"
)
func TestFormatDuration(t *testing.T) {
tests := []struct {
seconds int64
want string
}{
{0, "0m"},
{30, "0m"},
{59, "0m"},
{60, "1m"},
{3599, "59m"},
{3600, "1h 0m"},
{3661, "1h 1m"},
{7200, "2h 0m"},
{7260, "2h 1m"},
{86399, "23h 59m"},
{86400, "24h 0m"},
}
for _, tc := range tests {
got := formatDuration(tc.seconds)
if got != tc.want {
t.Errorf("formatDuration(%d) = %q, want %q", tc.seconds, got, tc.want)
}
}
}
func TestFmtHHMM(t *testing.T) {
tests := []struct {
seconds int64
want string
}{
{0, "0:00"},
{60, "0:01"},
{3600, "1:00"},
{3661, "1:01"},
{86399, "23:59"},
{86400, "24:00"},
}
for _, tc := range tests {
got := fmtHHMM(tc.seconds)
if got != tc.want {
t.Errorf("fmtHHMM(%d) = %q, want %q", tc.seconds, got, tc.want)
}
}
}
func TestFmtDDHHMM(t *testing.T) {
tests := []struct {
seconds int64
want string
}{
{0, "0:00:00"},
{60, "0:00:01"},
{3600, "0:01:00"},
{86400, "1:00:00"},
{90061, "1:01:01"},
{172800, "2:00:00"},
}
for _, tc := range tests {
got := fmtDDHHMM(tc.seconds)
if got != tc.want {
t.Errorf("fmtDDHHMM(%d) = %q, want %q", tc.seconds, got, tc.want)
}
}
}

View File

@@ -134,12 +134,15 @@ func (h *Handler) buildAchievementsList(user *domain.User) string {
}
func (h *Handler) awardXPAndCheckAchievements(user *domain.User, stats *domain.RPGStats, sessionWork int64, today string, isWeekend bool) string {
text := ""
h.updateStreak(stats, user.ID, today)
xp, leveledUp, newLevel, _ := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today)
xp, leveledUp, newLevel, err := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today)
if err != nil {
slog.Error("compute and award xp failed", "user_id", user.ID, "error", err)
return ""
}
totalHours := float64(stats.TotalWorkSeconds) / domain.SecondsPerHour
unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours)
text += fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel)
text := fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel)
if leveledUp {
text += "\nLevel up!"
}

View File

@@ -600,6 +600,48 @@ func (h *Handler) processTimezoneInput(ctx context.Context, msg *models.Message,
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 {
@@ -684,15 +726,20 @@ func (h *Handler) saveRate(ctx context.Context, chatID int64, user *domain.User,
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
h.Repo.UpdateSettings(st)
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
h.Repo.UpdateUser(user)
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) {
@@ -700,10 +747,13 @@ func (h *Handler) saveBreakThreshold(_ context.Context, _ int64, user *domain.Us
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
h.Repo.UpdateDay(day)
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) {
@@ -712,7 +762,9 @@ func (h *Handler) saveUsername(_ context.Context, _ int64, user *domain.User, na
return
}
user.Username = name
h.Repo.UpdateUser(user)
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 {