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
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 <name> - 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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user