package bot import ( "bytes" "context" "fmt" "log/slog" "strings" "time" bot "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" "github.com/xuri/excelize/v2" "worktimeBot/internal/db" ) type themeColors struct { headerFill string border string totalFill string } var themes = map[string]themeColors{ "ocean": {headerFill: "4472C4", border: "D9D9D9", totalFill: "D9E2F3"}, "beach": {headerFill: "C87D3C", border: "E8D5B7", totalFill: "FFF0E0"}, "rose": {headerFill: "B86C80", border: "F0D0E0", totalFill: "FCE8F0"}, "catppuccin": {headerFill: "B48DED", border: "D9D0E8", totalFill: "F0E8FC"}, } type reportStyles struct { title int header int data int total int dayOff int } func newReportStyles(f *excelize.File, theme themeColors) reportStyles { return reportStyles{ title: newStyle(f, &excelize.Style{ Font: &excelize.Font{Bold: true, Size: 14, Color: "FFFFFF"}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}}, Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, }), header: newStyle(f, &excelize.Style{ Font: &excelize.Font{Bold: true, Color: "FFFFFF"}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}}, Border: []excelize.Border{ {Type: "left", Color: "FFFFFF", Style: 1}, {Type: "right", Color: "FFFFFF", Style: 1}, {Type: "top", Color: "FFFFFF", Style: 1}, {Type: "bottom", Color: "FFFFFF", Style: 1}, }, Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, }), data: newStyle(f, &excelize.Style{ Border: []excelize.Border{ {Type: "left", Color: theme.border, Style: 1}, {Type: "right", Color: theme.border, Style: 1}, {Type: "top", Color: theme.border, Style: 1}, {Type: "bottom", Color: theme.border, Style: 1}, }, Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, }), total: newStyle(f, &excelize.Style{ Font: &excelize.Font{Bold: true, Size: 11}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}}, Border: []excelize.Border{ {Type: "left", Color: theme.border, Style: 1}, {Type: "right", Color: theme.border, Style: 1}, {Type: "top", Color: theme.border, Style: 1}, {Type: "bottom", Color: theme.border, Style: 2}, }, Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, }), dayOff: newStyle(f, &excelize.Style{ Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}}, Border: []excelize.Border{ {Type: "left", Color: theme.border, Style: 1}, {Type: "right", Color: theme.border, Style: 1}, {Type: "top", Color: theme.border, Style: 1}, {Type: "bottom", Color: theme.border, Style: 1}, }, Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, }), } } func newStyle(f *excelize.File, s *excelize.Style) int { id, _ := f.NewStyle(s) return id } func writeReportHeader(f *excelize.File, sheet, title string, s reportStyles) { f.SetCellValue(sheet, "A1", title) f.MergeCell(sheet, "A1", "F1") f.SetCellStyle(sheet, "A1", "F1", s.title) f.SetRowHeight(sheet, 1, 30) headers := []string{"Date", "Clock In", "Clock Out", "Type", "Work", "Break"} for i, h := range headers { cell, _ := excelize.CoordinatesToCellName(i+1, 2) f.SetCellValue(sheet, cell, h) f.SetCellStyle(sheet, cell, cell, s.header) } f.SetRowHeight(sheet, 2, 22) f.SetColWidth(sheet, "A", "A", 14) f.SetColWidth(sheet, "B", "B", 10) f.SetColWidth(sheet, "C", "C", 10) f.SetColWidth(sheet, "D", "D", 12) f.SetColWidth(sheet, "E", "E", 10) f.SetColWidth(sheet, "F", "F", 10) } func writeReportDayRow(f *excelize.File, sheet string, row int, displayDate, typeLabel string, events []db.Event, day *db.Day, loc *time.Location, hasDayOff bool, s reportStyles) (workSec, breakSec int64) { cellDate, _ := excelize.CoordinatesToCellName(1, row) f.SetCellValue(sheet, cellDate, displayDate) f.SetCellStyle(sheet, cellDate, cellDate, s.data) if len(events) > 0 { cellIn, _ := excelize.CoordinatesToCellName(2, row) f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).In(loc).Format(TimeLayout)) f.SetCellStyle(sheet, cellIn, cellIn, s.data) } if len(events) > 0 { lastEvent := events[len(events)-1] if lastEvent.EventType == "out" { cellOut, _ := excelize.CoordinatesToCellName(3, row) f.SetCellValue(sheet, cellOut, time.Unix(lastEvent.OccurredAt, 0).In(loc).Format(TimeLayout)) f.SetCellStyle(sheet, cellOut, cellOut, s.data) } } cellType, _ := excelize.CoordinatesToCellName(4, row) f.SetCellValue(sheet, cellType, typeLabel) f.SetCellStyle(sheet, cellType, cellType, s.data) if len(events) > 0 && day != nil { var d time.Time d, _ = time.Parse(DateLayout, day.Date) dayStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc).Unix() dayEnd := time.Date(d.Year(), d.Month(), d.Day(), 23, 59, 59, 0, loc).Unix() totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) workSec = totals.TotalSeconds breakSec = totals.BreakSeconds } cellWork, _ := excelize.CoordinatesToCellName(5, row) f.SetCellValue(sheet, cellWork, fmtHHMM(workSec)) f.SetCellStyle(sheet, cellWork, cellWork, s.data) cellBreak, _ := excelize.CoordinatesToCellName(6, row) f.SetCellValue(sheet, cellBreak, fmtHHMM(breakSec)) f.SetCellStyle(sheet, cellBreak, cellBreak, s.data) if hasDayOff { cellA, _ := excelize.CoordinatesToCellName(1, row) cellF, _ := excelize.CoordinatesToCellName(6, row) f.SetCellStyle(sheet, cellA, cellF, s.dayOff) } return workSec, breakSec } func writeReportTotalRow(f *excelize.File, sheet string, row int, totalWork, totalBreak int64, s reportStyles) { row++ f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Total") f.SetCellStyle(sheet, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), s.total) f.SetCellValue(sheet, fmt.Sprintf("E%d", row), fmtDDHHMM(totalWork)) f.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("E%d", row), s.total) f.SetCellValue(sheet, fmt.Sprintf("F%d", row), fmtDDHHMM(totalBreak)) f.SetCellStyle(sheet, fmt.Sprintf("F%d", row), fmt.Sprintf("F%d", row), s.total) } func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, calendar string, calYear, calMonth int, start, end time.Time) ([]byte, error) { theme, ok := themes[accent] if !ok { theme = themes["ocean"] } f := excelize.NewFile() defer f.Close() sheet := "Report" index, err := f.NewSheet(sheet) if err != nil { return nil, err } f.SetActiveSheet(index) f.DeleteSheet("Sheet1") styles := newReportStyles(f, theme) monthName := formatMonthTitle(calendar, calYear, calMonth) writeReportHeader(f, sheet, fmt.Sprintf("Work Time Report - %s", monthName), styles) loc := loadLocation(timezone) row := 3 userDays, err := store.GetUserDaysInRange(userID, start.Format(DateLayout), end.Format(DateLayout)) if err != nil { return nil, err } dayMap := make(map[string]*db.Day) for i := range userDays { dayMap[userDays[i].Date] = &userDays[i] } var totalWork, totalBreak int64 for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { dateStr := d.Format(DateLayout) displayDate := formatDateForCalendar(dateStr, calendar) var ( day *db.Day events []db.Event hasDayOff bool ) if existing, ok := dayMap[dateStr]; ok { day = existing events, err = store.EventsForDayByDayID(day.ID) if err != nil { return nil, err } hasDayOff = day.IsDayOff } var typeLabel string if hasDayOff { if day.DayOffReason != "" { typeLabel = "Day Off (" + day.DayOffReason + ")" } else { typeLabel = "Day Off" } } else if len(events) > 0 { typeLabel = workTypeLabel(store, day, events) } ws, bs := writeReportDayRow(f, sheet, row, displayDate, typeLabel, events, day, loc, hasDayOff, styles) totalWork += ws totalBreak += bs row++ } writeReportTotalRow(f, sheet, row, totalWork, totalBreak, styles) buf, err := f.WriteToBuffer() if err != nil { return nil, err } return buf.Bytes(), nil } func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string { wtSeen := make(map[int64]bool) for _, e := range events { if e.WorkTypeID != nil { wtSeen[*e.WorkTypeID] = true } } if len(wtSeen) == 0 { wt, err := store.GetWorkType(day.CurrentWorkTypeID) if err == nil { return wt.Name } return "" } if len(wtSeen) == 1 { for id := range wtSeen { wt, err := store.GetWorkType(id) if err == nil { return wt.Name } } } return "mixed" } func fmtHHMM(seconds int64) string { hours := seconds / secondsPerHour mins := (seconds % secondsPerHour) / 60 return fmt.Sprintf("%d:%02d", hours, mins) } func fmtDDHHMM(seconds int64) string { days := seconds / secondsPerDay rem := seconds % secondsPerDay hours := rem / secondsPerHour mins := (rem % secondsPerHour) / 60 return fmt.Sprintf("%d:%02d:%02d", days, hours, mins) } // handleExport processes the /export command, showing the month picker or exporting directly. func (h *Handler) handleExport(ctx context.Context, msg *models.Message) { user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { h.sendText(ctx, msg.Chat.ID, "Error loading profile") return } loc := 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()) } args := strings.Fields(msg.Text) if len(args) > 0 && args[0][0] == '/' { args = args[1:] } if len(args) >= 1 { if n, _ := fmt.Sscanf(args[0], "%d-%d", &cy, &cm); n == 2 { if cy < 0 || cm < 1 || cm > 12 { h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /export YYYY-MM") return } } } h.sendExportDirect(ctx, msg.Chat.ID, user, cy, cm) } // sendExportDirect generates and sends an Excel report for the given calendar month. func (h *Handler) sendExportDirect(ctx context.Context, chatID int64, user *db.User, calYear, calMonth int) { last, err := h.DB.GetLastEvent(user.ID) if err == nil && last != nil && last.EventType == "in" { h.sendText(ctx, chatID, "You are currently clocked in. Please clock out first, then try again.") return } loc := loadLocation(user.Timezone) startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth) start, err := time.Parse(DateLayout, startStr) if err != nil { start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc) } end, err := time.Parse(DateLayout, endStr) if err != nil { startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc) end = startOfMonth.AddDate(0, 1, -1) } st, _ := h.DB.GetOrCreateSettings(user.ID) accent := st.ExportAccent slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", calYear, "cal_m", calMonth, "from", startStr, "to", endStr) data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, accent, user.Calendar, calYear, calMonth, start, end) if err != nil { slog.Error("generate report", "error", err) h.sendText(ctx, chatID, "Error generating report") return } slog.Info("report generated", "bytes", len(data)) if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{ ChatID: chatID, Document: &models.InputFileUpload{ Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)), Data: bytes.NewReader(data), }, }); err != nil { slog.Error("send document", "error", err) } } // exportCallback opens the export month picker. func (h *Handler) exportCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { defer h.answerCb(ctx, callbackID) user, err := h.getOrCreateUser(chatID) if err != nil { return } loc := 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()) } h.editExportMonthPicker(ctx, chatID, msgID, cy, cm, user) } // sendExportMonthPicker sends the export month picker as a new message. func (h *Handler) sendExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, month int, user *db.User) { h.editExportMonthPicker(ctx, chatID, msgID, year, month, user) } // editExportMonthPicker renders a year-based month picker with 12 month buttons. func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, _ int, user *db.User) { months := gregMonthNames switch user.Calendar { case "jalali": months = jalaliMonthNames case "hijri": months = hijriMonthNames } kbRows := [][]models.InlineKeyboardButton{ // Year navigation { {Text: "<", CallbackData: fmt.Sprintf("exp_year_prev_%d", year)}, {Text: fmt.Sprintf("%d", year), CallbackData: fmt.Sprintf("exp_show_years_%d", year)}, {Text: ">", CallbackData: fmt.Sprintf("exp_year_next_%d", year)}, }, } // 4 columns x 3 rows of month buttons for i := 0; i < 12; i += 4 { row := []models.InlineKeyboardButton{} for j := i; j < i+4 && j < 12; j++ { row = append(row, models.InlineKeyboardButton{ Text: months[j], CallbackData: fmt.Sprintf("exp_do_%d_%d", year, j+1), }) } kbRows = append(kbRows, row) } kbRows = append(kbRows, []models.InlineKeyboardButton{ {Text: "Back to Menu", CallbackData: "back_menu"}, }) kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows} if msgID == 0 { h.sendWithKB(ctx, chatID, "Select month to export:", kb) } else { h.editText(ctx, chatID, msgID, "Select month to export:", &kb) } } // editExportYearPicker shows a 12-year grid for year selection in the export flow. // refY is the year to return to via the Back button. func (h *Handler) editExportYearPicker(ctx context.Context, chatID int64, msgID int, centerYear, refY int) { backData := fmt.Sprintf("exp_years_back_%d", refY) navSuffix := fmt.Sprintf("_%d", refY) kb := buildYearPicker("exp_", backData, navSuffix, centerYear) h.editText(ctx, chatID, msgID, "Select year:", &kb) } // formatMonthTitle returns the localized month name and year for a given calendar. 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) } } // handleExportCallback routes export inline button presses (year nav, month select). func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID int, callbackID, data string) { defer h.answerCb(ctx, callbackID) user, err := h.getOrCreateUser(chatID) if err != nil { return } if h.handleExportNav(ctx, chatID, msgID, user, data) { return } h.handleExportDo(ctx, chatID, msgID, user, data) } // handleExportNav handles year/month navigation callbacks for the export flow. func (h *Handler) handleExportNav(ctx context.Context, chatID int64, msgID int, user *db.User, data string) bool { var y int if n, _ := fmt.Sscanf(data, "exp_year_prev_%d", &y); n == 1 { h.editExportMonthPicker(ctx, chatID, msgID, y-1, 0, user) return true } if n, _ := fmt.Sscanf(data, "exp_year_next_%d", &y); n == 1 { h.editExportMonthPicker(ctx, chatID, msgID, y+1, 0, user) return true } if n, _ := fmt.Sscanf(data, "exp_show_years_%d", &y); n == 1 { h.editExportYearPicker(ctx, chatID, msgID, y, y) return true } var refY int if n, _ := fmt.Sscanf(data, "exp_years_%d_%d", &y, &refY); n == 2 { h.editExportYearPicker(ctx, chatID, msgID, y+6, refY) return true } if n, _ := fmt.Sscanf(data, "exp_year_%d", &y); n == 1 { h.editExportMonthPicker(ctx, chatID, msgID, y, 0, user) return true } if n, _ := fmt.Sscanf(data, "exp_years_back_%d", &y); n == 1 { h.editExportMonthPicker(ctx, chatID, msgID, y, 0, user) return true } return false } // handleExportDo triggers the actual Excel export for a selected month. func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, user *db.User, data string) { var y, m int if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n != 2 { return } last, err := h.DB.GetLastEvent(user.ID) if err == nil && last != nil && last.EventType == "in" { backKB := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ {{Text: "Back to Menu", CallbackData: "back_menu"}}, }, } h.editText(ctx, chatID, msgID, "You are currently clocked in. Please clock out first, then try again.", &backKB) return } startStr, endStr := monthGregorianRange(user.Calendar, y, m) start, err := time.Parse(DateLayout, startStr) if err != nil { h.sendText(ctx, chatID, "Error processing date") return } end, err := time.Parse(DateLayout, endStr) if err != nil { h.sendText(ctx, chatID, "Error processing date") return } st, _ := h.DB.GetOrCreateSettings(user.ID) slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr) reportData, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, st.ExportAccent, user.Calendar, y, m, start, end) if err != nil { slog.Error("generate report", "error", err) backKB := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ {{Text: "Back to Menu", CallbackData: "back_menu"}}, }, } h.editText(ctx, chatID, msgID, "Error generating report", &backKB) return } slog.Info("report generated", "bytes", len(reportData)) backKB := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ {{Text: "Back to Menu", CallbackData: "back_menu"}}, }, } h.editText(ctx, chatID, msgID, "Report ready:", &backKB) if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{ ChatID: chatID, Document: &models.InputFileUpload{ Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)), Data: bytes.NewReader(reportData), }, }); err != nil { slog.Error("send document", "error", err) } }