feat: add /setcalendar command, store tests, and improve error handling
All checks were successful
CI / build (push) Successful in 48s
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:
@@ -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:
|
||||
|
||||
70
internal/handler/handler_test.go
Normal file
70
internal/handler/handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user