From f89f5607aa582846e28786c7cc1fee660aa98764 Mon Sep 17 00:00:00 2001 From: db123 Date: Wed, 24 Jun 2026 11:07:53 +0330 Subject: [PATCH] fix: calendar day-of-week offset off by one, nav double-jump, race conditions - Fix Jalali/Gregorian/Hijri day-of-week startOffset formulas (dow returns 0=Sat, not 0=Sun as the old comment claimed) - Fix calendar and export month picker navigation jumping 2 months (navMonth buttons already have pre-computed values; handlers were decrementing/incrementing them again) - Fix GetOrCreateUser/GetOrCreateDay race condition with INSERT OR IGNORE - Add month/day bounds validation in handleEditMsg to prevent panic --- internal/bot/dateutil.go | 35 +++++++++++++++--- internal/bot/handlers.go | 78 ++++++++++++++++++++++++---------------- internal/db/store.go | 22 +++--------- 3 files changed, 83 insertions(+), 52 deletions(-) diff --git a/internal/bot/dateutil.go b/internal/bot/dateutil.go index 00d4d4a..2d50d31 100644 --- a/internal/bot/dateutil.go +++ b/internal/bot/dateutil.go @@ -180,7 +180,7 @@ func gregorianToHijri(gy, gm, gd int) (int, int, int) { } func dayOfWeekGregorian(gy, gm, gd int) int { - // Zeller-like: returns 0=Sun,1=Mon,...,6=Sat + // Zeller-like: returns 0=Sat,1=Sun,2=Mon,3=Tue,4=Wed,5=Thu,6=Fri if gm < 3 { gm += 12 gy-- @@ -211,7 +211,7 @@ func buildCalendarMonth(cal string, year, month int) calMonth { gy, gm, gd := jalaliToGregorian(year, month, 1) dow := dayOfWeekGregorian(gy, gm, gd) - startOffset := (dow + 1) % 7 + startOffset := dow var days []calDay for i := 0; i < startOffset; i++ { @@ -234,7 +234,7 @@ func buildCalendarMonth(cal string, year, month int) calMonth { gy, gm, gd := hijriToGregorian(year, month, 1) dow := dayOfWeekGregorian(gy, gm, gd) - startOffset := (dow + 6) % 7 + startOffset := (dow + 5) % 7 var days []calDay for i := 0; i < startOffset; i++ { @@ -255,7 +255,7 @@ func buildCalendarMonth(cal string, year, month int) calMonth { totalDays = monthLenGreg(year, month) dow := dayOfWeekGregorian(year, month, 1) - startOffset := (dow + 6) % 7 + startOffset := (dow + 5) % 7 var days []calDay for i := 0; i < startOffset; i++ { @@ -290,6 +290,33 @@ func formatDateForCalendar(date, calType string) string { } } +// userDateToGregorian converts a YYYY-MM-DD string in the user's calendar +// to the corresponding Gregorian YYYY-MM-DD string. +func userDateToGregorian(dateStr, cal string) string { + y, m, d := parseGregorianDateKey(dateStr) + switch cal { + case "jalali": + y, m, d = jalaliToGregorian(y, m, d) + case "hijri": + y, m, d = hijriToGregorian(y, m, d) + } + return fmt.Sprintf("%04d-%02d-%02d", y, m, d) +} + +// userYearMonthToGregorian converts a (year, month) in the user's calendar +// to the corresponding Gregorian (year, month). The day is assumed to be 1. +func userYearMonthToGregorian(year, month int, cal string) (int, int) { + switch cal { + case "jalali": + gy, gm, _ := jalaliToGregorian(year, month, 1) + return gy, gm + case "hijri": + gy, gm, _ := hijriToGregorian(year, month, 1) + return gy, gm + } + return year, month +} + // monthGregorianRange returns the Gregorian start and end date strings // for a given calendar month (cal=gregorian|jalali|hijri). func monthGregorianRange(cal string, year, month int) (start, end string) { diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 99dbacb..de79b48 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -403,7 +403,7 @@ func (h *Handler) handleHelp(msg *tgbotapi.Message) { /worktype - Select work type /dayoff - Toggle day off /report - Show today report -/export - Export monthly report (Excel) +/export [YYYY-MM] - Export monthly report (current month if omitted) /history - View calendar history /edit YYYY-MM-DD - Edit a specific day /reporttoggle - Enable/disable auto report @@ -411,6 +411,7 @@ func (h *Handler) handleHelp(msg *tgbotapi.Message) { /setaccent - Select accent color /settimezone - Set your timezone (e.g. Asia/Tehran) +Dates use your calendar type (Gregorian/Jalali/Hijri). Buttons on the main menu also work.` h.sendText(msg.Chat.ID, text) } @@ -708,11 +709,16 @@ func (h *Handler) historyCallback(chatID int64, msgID int, callbackID string) { func (h *Handler) handleEditMsg(msg *tgbotapi.Message) { date := msg.CommandArguments() if date == "" { - h.sendText(msg.Chat.ID, "Usage: /edit YYYY-MM-DD") + h.sendText(msg.Chat.ID, "Usage: /edit YYYY-MM-DD (your calendar type)") return } if len(date) != 10 || date[4] != '-' || date[7] != '-' { - h.sendText(msg.Chat.ID, "Invalid date. Use: /edit YYYY-MM-DD") + h.sendText(msg.Chat.ID, "Invalid date. Use: /edit YYYY-MM-DD (your calendar type)") + return + } + _, m, d := parseGregorianDateKey(date) + if m < 1 || m > 12 || d < 1 || d > 31 { + h.sendText(msg.Chat.ID, "Invalid date.") return } user, err := h.getOrCreateUser(msg.Chat.ID) @@ -721,6 +727,7 @@ func (h *Handler) handleEditMsg(msg *tgbotapi.Message) { return } loc, _ := time.LoadLocation(user.Timezone) + date = userDateToGregorian(date, user.Calendar) h.sendDayView(msg.Chat.ID, 0, user, date, loc, true) } @@ -743,21 +750,11 @@ func (h *Handler) handleHistoryCallback(chatID int64, msgID int, callbackID, dat // cal_prev_YYYY_M if n, _ := fmt.Sscanf(data, "cal_prev_%d_%d", &y, &m); n == 2 { - m-- - if m < 1 { - m = 12 - y-- - } h.editCalendar(chatID, msgID, y, m) return } // cal_next_YYYY_M if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 { - m++ - if m > 12 { - m = 1 - y++ - } h.editCalendar(chatID, msgID, y, m) return } @@ -1189,14 +1186,45 @@ func (h *Handler) handleExport(msg *tgbotapi.Message) { } loc, _ := time.LoadLocation(user.Timezone) now := time.Now().In(loc) - cy, cm := now.Year(), int(now.Month()) - switch user.Calendar { - case "jalali": - cy, cm, _ = gregorianToJalali(cy, cm, now.Day()) - case "hijri": - cy, cm, _ = gregorianToHijri(cy, cm, now.Day()) + + // Parse optional YYYY-MM argument (in user's calendar) + gy, gm := now.Year(), int(now.Month()) + if len(msg.Text) > 7 { + if n, _ := fmt.Sscanf(msg.Text[7:], "%d-%d", &gy, &gm); n == 2 { + if gy < 0 || gm < 1 || gm > 12 { + h.sendText(msg.Chat.ID, "Invalid date. Use: /export YYYY-MM") + return + } + gy, gm = userYearMonthToGregorian(gy, gm, user.Calendar) + } + } + + h.sendExportDirect(msg.Chat.ID, user, gy, gm) +} + +func (h *Handler) sendExportDirect(chatID int64, user *db.User, gy, gm int) { + // Check if clocked in + last, err := h.DB.GetLastEvent(user.ID) + if err == nil && last != nil && last.EventType == "in" { + h.sendText(chatID, "You are currently clocked in. Please clock out first, then try again.") + return + } + + slog.Info("export", "user_id", user.ID, "year", gy, "month", gm) + data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, gy, time.Month(gm)) + if err != nil { + slog.Error("generate report", "error", err) + h.sendText(chatID, "Error generating report") + return + } + slog.Info("report generated", "bytes", len(data)) + doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{ + Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", gy, gm)), + Bytes: data, + }) + if _, err := h.Bot.Send(doc); err != nil { + slog.Error("send document", "error", err) } - h.sendExportMonthPicker(msg.Chat.ID, 0, cy, cm, user) } func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) { @@ -1262,20 +1290,10 @@ func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data var y, m int if n, _ := fmt.Sscanf(data, "exp_prev_%d_%d", &y, &m); n == 2 { - m-- - if m < 1 { - m = 12 - y-- - } h.editExportMonthPicker(chatID, msgID, y, m, user) return } if n, _ := fmt.Sscanf(data, "exp_next_%d_%d", &y, &m); n == 2 { - m++ - if m > 12 { - m = 1 - y++ - } h.editExportMonthPicker(chatID, msgID, y, m, user) return } diff --git a/internal/db/store.go b/internal/db/store.go index 8739b52..57316f2 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -289,15 +289,8 @@ func migrateSettings(db *sql.DB) { } func (s *Store) GetOrCreateUser(chatID int64) (*User, error) { - user, err := s.GetUserByChatID(chatID) - if err == nil { - return user, nil - } - if err != sql.ErrNoRows { - return nil, err - } - _, err = s.db.Exec( - "INSERT INTO users (chat_id) VALUES (?)", + _, err := s.db.Exec( + "INSERT OR IGNORE INTO users (chat_id) VALUES (?)", chatID, ) if err != nil { @@ -399,15 +392,8 @@ func (s *Store) GetWorkType(id int64) (*WorkType, error) { } func (s *Store) GetOrCreateDay(userID int64, date string) (*Day, error) { - day, err := s.GetDay(userID, date) - if err == nil { - return day, nil - } - if err != sql.ErrNoRows { - return nil, err - } - _, err = s.db.Exec( - "INSERT INTO days (user_id, date) VALUES (?, ?)", + _, err := s.db.Exec( + "INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)", userID, date, ) if err != nil {