diff --git a/internal/bot/dateutil.go b/internal/bot/dateutil.go index 89b71b6..00d4d4a 100644 --- a/internal/bot/dateutil.go +++ b/internal/bot/dateutil.go @@ -211,9 +211,12 @@ func buildCalendarMonth(cal string, year, month int) calMonth { gy, gm, gd := jalaliToGregorian(year, month, 1) dow := dayOfWeekGregorian(gy, gm, gd) - _ = dow + startOffset := (dow + 1) % 7 var days []calDay + for i := 0; i < startOffset; i++ { + days = append(days, calDay{dayNum: 0, date: ""}) + } for d := 1; d <= totalDays; d++ { gy, gm, gd = jalaliToGregorian(year, month, d) days = append(days, calDay{ @@ -267,10 +270,6 @@ func buildCalendarMonth(cal string, year, month int) calMonth { return calMonth{title: title, weekDays: weekDays, days: days} } -func gregorianDateKey(year, month, day int) string { - return fmt.Sprintf("%04d-%02d-%02d", year, month, day) -} - func parseGregorianDateKey(key string) (int, int, int) { var y, m, d int fmt.Sscanf(key, "%04d-%02d-%02d", &y, &m, &d) @@ -290,3 +289,27 @@ func formatDateForCalendar(date, calType string) string { return date } } + +// 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) { + switch cal { + case "jalali": + gy, gm, gd := jalaliToGregorian(year, month, 1) + start = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd) + lastDay := monthLenJalali(year, month) + gy, gm, gd = jalaliToGregorian(year, month, lastDay) + end = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd) + case "hijri": + gy, gm, gd := hijriToGregorian(year, month, 1) + start = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd) + lastDay := monthLenHijri(year, month) + gy, gm, gd = hijriToGregorian(year, month, lastDay) + end = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd) + default: + start = fmt.Sprintf("%04d-%02d-%02d", year, month, 1) + lastDay := monthLenGreg(year, month) + end = fmt.Sprintf("%04d-%02d-%02d", year, month, lastDay) + } + return +} diff --git a/internal/bot/export.go b/internal/bot/export.go index 519f152..1aaf372 100644 --- a/internal/bot/export.go +++ b/internal/bot/export.go @@ -76,17 +76,16 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, }) - var monthName string + var jy, jm int switch calendar { case "jalali": - jy, jm, _ := gregorianToJalali(year, int(month), 1) - monthName = fmt.Sprintf("%s %d", jalaliMonthNames[jm-1], jy) + jy, jm, _ = gregorianToJalali(year, int(month), 1) case "hijri": - hy, hm, _ := gregorianToHijri(year, int(month), 1) - monthName = fmt.Sprintf("%s %d", hijriMonthNames[hm-1], hy) + jy, jm, _ = gregorianToHijri(year, int(month), 1) default: - monthName = time.Date(year, month, 1, 0, 0, 0, 0, time.UTC).Format("January 2006") + jy, jm = year, int(month) } + monthName := formatMonthTitle(calendar, jy, jm) f.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report - %s", monthName)) f.MergeCell(sheet, "A1", "F1") f.SetCellStyle(sheet, "A1", "F1", titleStyle) @@ -130,16 +129,7 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale for d := firstDay; !d.After(lastDay); d = d.AddDate(0, 0, 1) { dateStr := d.Format("2006-01-02") - displayDate := dateStr - if calendar == "jalali" { - gy, gm, gd := parseGregorianDateKey(dateStr) - jy, jm, jd := gregorianToJalali(gy, gm, gd) - displayDate = fmt.Sprintf("%04d-%02d-%02d", jy, jm, jd) - } else if calendar == "hijri" { - gy, gm, gd := parseGregorianDateKey(dateStr) - hy, hm, hd := gregorianToHijri(gy, gm, gd) - displayDate = fmt.Sprintf("%04d-%02d-%02d", hy, hm, hd) - } + displayDate := formatDateForCalendar(dateStr, calendar) day, exists := dayMap[dateStr] if !exists { diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 6b58c5c..99dbacb 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -181,6 +181,10 @@ func (h *Handler) HandleCallback(update tgbotapi.Update) { h.selectCalendar(chatID, msgID, cb.ID, cb.Data[8:]) return } + if len(cb.Data) > 4 && cb.Data[:4] == "exp_" { + h.handleExportCallback(chatID, msgID, cb.ID, cb.Data) + return + } h.handleHistoryCallback(chatID, msgID, cb.ID, cb.Data) } } @@ -838,57 +842,18 @@ func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) { todayKey := now.Format("2006-01-02") hasEvent := make(map[string]bool) - if cal == "jalali" { - gy, gm, _ := jalaliToGregorian(year, month, 1) - lastDay := monthLenJalali(year, month) - gyEnd, gmEnd, gdEnd := jalaliToGregorian(year, month, lastDay) - startDate := fmt.Sprintf("%04d-%02d-%02d", gy, gm, 1) - endDate := fmt.Sprintf("%04d-%02d-%02d", gyEnd, gmEnd, gdEnd) - rows, err := h.DB.DB().Query( - "SELECT DISTINCT date FROM days WHERE user_id=? AND date >= ? AND date <= ?", - user.ID, startDate, endDate, - ) - if err == nil { - for rows.Next() { - var d string - rows.Scan(&d) - hasEvent[d] = true - } - rows.Close() - } - } else if cal == "hijri" { - gy, gm, _ := hijriToGregorian(year, month, 1) - lastDay := monthLenHijri(year, month) - gyEnd, gmEnd, gdEnd := hijriToGregorian(year, month, lastDay) - startDate := fmt.Sprintf("%04d-%02d-%02d", gy, gm, 1) - endDate := fmt.Sprintf("%04d-%02d-%02d", gyEnd, gmEnd, gdEnd) - rows, err := h.DB.DB().Query( - "SELECT DISTINCT date FROM days WHERE user_id=? AND date >= ? AND date <= ?", - user.ID, startDate, endDate, - ) - if err == nil { - for rows.Next() { - var d string - rows.Scan(&d) - hasEvent[d] = true - } - rows.Close() - } - } else { - startDate := fmt.Sprintf("%04d-%02d-%02d", year, month, 1) - endDate := fmt.Sprintf("%04d-%02d-%02d", year, month, monthLenGreg(year, month)) - rows, err := h.DB.DB().Query( - "SELECT DISTINCT date FROM days WHERE user_id=? AND date >= ? AND date <= ?", - user.ID, startDate, endDate, - ) - if err == nil { - for rows.Next() { - var d string - rows.Scan(&d) - hasEvent[d] = true - } - rows.Close() + startDate, endDate := monthGregorianRange(cal, year, month) + rows, err := h.DB.DB().Query( + "SELECT DISTINCT date FROM days WHERE user_id=? AND date >= ? AND date <= ?", + user.ID, startDate, endDate, + ) + if err == nil { + for rows.Next() { + var d string + rows.Scan(&d) + hasEvent[d] = true } + rows.Close() } var kbRows [][]tgbotapi.InlineKeyboardButton @@ -918,17 +883,7 @@ func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) { } } - var prevY, prevM, nextY, nextM int - prevY, prevM = year, month-1 - if prevM < 1 { - prevM = 12 - prevY-- - } - nextY, nextM = year, month+1 - if nextM > 12 { - nextM = 1 - nextY++ - } + prevY, prevM, nextY, nextM := navMonth(year, month) navRow := tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("<", fmt.Sprintf("cal_prev_%d_%d", prevY, prevM)), @@ -943,15 +898,7 @@ func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) { kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) - if msgID == 0 { - reply := tgbotapi.NewMessage(chatID, text) - reply.ReplyMarkup = &kb - h.Bot.Send(reply) - } else { - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) - } + h.sendOrEdit(chatID, msgID, text, &kb) } func (h *Handler) editDayView(chatID int64, msgID int, user *db.User, date string, loc *time.Location) { @@ -1242,57 +1189,144 @@ func (h *Handler) handleExport(msg *tgbotapi.Message) { } loc, _ := time.LoadLocation(user.Timezone) now := time.Now().In(loc) - slog.Info("export", "user_id", user.ID, "year", now.Year(), "month", now.Month()) - data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, now.Year(), now.Month()) - if err != nil { - slog.Error("generate report", "error", err) - h.sendText(msg.Chat.ID, "Error generating report") - return - } - doc := tgbotapi.NewDocument(msg.Chat.ID, tgbotapi.FileBytes{ - Name: fmt.Sprintf("worktime_%s.xlsx", now.Format("2006_01")), - Bytes: data, - }) - if _, err := h.Bot.Send(doc); err != nil { - slog.Error("send document", "error", err) - h.sendText(msg.Chat.ID, "Error sending file") + 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()) } + h.sendExportMonthPicker(msg.Chat.ID, 0, cy, cm, user) } func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) { defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) user, err := h.getOrCreateUser(chatID) if err != nil { - edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error loading profile") - kb := backKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) return } loc, _ := time.LoadLocation(user.Timezone) now := time.Now().In(loc) - slog.Info("export", "user_id", user.ID, "year", now.Year(), "month", now.Month()) - data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, now.Year(), now.Month()) + 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()) + } + h.editExportMonthPicker(chatID, msgID, cy, cm, user) +} + +func (h *Handler) sendExportMonthPicker(chatID int64, msgID int, year, month int, user *db.User) { + h.editExportMonthPicker(chatID, msgID, year, month, user) +} + +func (h *Handler) editExportMonthPicker(chatID int64, msgID int, year, month int, user *db.User) { + text := "Select month to export:" + text += fmt.Sprintf("\n\n%s", formatMonthTitle(user.Calendar, year, month)) + + prevY, prevM, nextY, nextM := navMonth(year, month) + + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("<", fmt.Sprintf("exp_prev_%d_%d", prevY, prevM)), + tgbotapi.NewInlineKeyboardButtonData("Export", fmt.Sprintf("exp_do_%d_%d", year, month)), + tgbotapi.NewInlineKeyboardButtonData(">", fmt.Sprintf("exp_next_%d_%d", nextY, nextM)), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), + ), + ) + + h.sendOrEdit(chatID, msgID, text, &kb) +} + +func formatMonthTitle(cal string, year, month int) string { + switch cal { + case "jalali": + return fmt.Sprintf("%s %d", jalaliMonthNames[month-1], year) + case "hijri": + return fmt.Sprintf("%s %d", hijriMonthNames[month-1], year) + default: + return fmt.Sprintf("%s %d", gregMonthNames[month-1], year) + } +} + +func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, err := h.getOrCreateUser(chatID) if err != nil { - slog.Error("generate report", "error", err) - edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error generating report") + return + } + + 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 + } + + if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n == 2 { + // Check if clocked in + last, err := h.DB.GetLastEvent(user.ID) + if err == nil && last != nil && last.EventType == "in" { + text := "You are currently clocked in. Please clock out first, then try again." + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), + ), + ) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) + return + } + // Convert from user's calendar to Gregorian for export + gy, gm := y, m + switch user.Calendar { + case "jalali": + gy, gm, _ = jalaliToGregorian(y, m, 1) + case "hijri": + gy, gm, _ = hijriToGregorian(y, m, 1) + } + slog.Info("export", "user_id", user.ID, "year", gy, "month", gm, "cal", user.Calendar, "cal_y", y, "cal_m", m) + 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) + edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error generating report") + kb := backKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) + return + } + slog.Info("report generated", "bytes", len(data)) + edit := tgbotapi.NewEditMessageText(chatID, msgID, "Report ready:") kb := backKeyboard() edit.ReplyMarkup = &kb h.Bot.Send(edit) + 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) + } return } - slog.Info("report generated", "bytes", len(data)) - edit := tgbotapi.NewEditMessageText(chatID, msgID, "Report ready:") - kb := backKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) - doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{ - Name: fmt.Sprintf("worktime_%s.xlsx", now.Format("2006_01")), - Bytes: data, - }) - if _, err := h.Bot.Send(doc); err != nil { - slog.Error("send document", "error", err) - } } func (h *Handler) buildStatusText(chatID int64) string { @@ -1361,7 +1395,12 @@ func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event y, m, d := parseGregorianDateKey(day.Date) dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix() + now := time.Now().In(loc) + todayStr := now.Format("2006-01-02") dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix() + if day.Date == todayStr { + dayEnd = now.Unix() + } totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) lines := fmt.Sprintf("Report for %s", formatDateForCalendar(day.Date, user.Calendar)) if day.IsDayOff { @@ -1463,3 +1502,29 @@ func formatDuration(seconds int64) string { func (h *Handler) sendText(chatID int64, text string) { h.Bot.Send(tgbotapi.NewMessage(chatID, text)) } + +func navMonth(year, month int) (prevY, prevM, nextY, nextM int) { + prevY, prevM = year, month-1 + if prevM < 1 { + prevM = 12 + prevY-- + } + nextY, nextM = year, month+1 + if nextM > 12 { + nextM = 1 + nextY++ + } + return +} + +func (h *Handler) sendOrEdit(chatID int64, msgID int, text string, kb *tgbotapi.InlineKeyboardMarkup) { + if msgID == 0 { + msg := tgbotapi.NewMessage(chatID, text) + msg.ReplyMarkup = kb + h.Bot.Send(msg) + } else { + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = kb + h.Bot.Send(edit) + } +} diff --git a/internal/bot/totals.go b/internal/bot/totals.go index 8da0de8..e15610a 100644 --- a/internal/bot/totals.go +++ b/internal/bot/totals.go @@ -17,7 +17,6 @@ const ( type DailyTotals struct { TotalSeconds int64 BreakSeconds int64 - PairCount int } func ComputeDailyTotals(events []db.Event, minBreakThreshold int64, dayStart, dayEnd int64) DailyTotals { @@ -41,7 +40,6 @@ func ComputeDailyTotals(events []db.Event, minBreakThreshold int64, dayStart, da var workTotal, breakTotal int64 var workStart, breakStart int64 - pairs := 0 state := StateIdle for _, e := range sorted { @@ -66,7 +64,6 @@ func ComputeDailyTotals(events []db.Event, minBreakThreshold int64, dayStart, da case StateWorking: workTotal += e.OccurredAt - workStart workStart = 0 - pairs++ breakStart = e.OccurredAt state = StateOnBreak case StateIdle: @@ -81,6 +78,5 @@ func ComputeDailyTotals(events []db.Event, minBreakThreshold int64, dayStart, da return DailyTotals{ TotalSeconds: workTotal, BreakSeconds: breakTotal, - PairCount: pairs, } }