refactor: split handlers.go into 5 files, redesign export month picker, pad calendar rows, improve comments

- Split 1571-line handlers.go into: handlers.go (core), clock.go, settings.go, calendar.go, report.go
- Redesigned export month picker: year navigation + 12-month grid instead of prev/next month
- Fixed calendar last row: pad remaining cells with empty buttons to ensure 7 columns
- Added Go doc comments across all files (dateutil.go, totals.go, store.go, main.go, webhook.go)
This commit is contained in:
2026-06-24 11:33:17 +03:30
parent d8599657f4
commit a8b849f8fd
11 changed files with 1366 additions and 1176 deletions

View File

@@ -2,8 +2,10 @@ package bot
import (
"fmt"
"log/slog"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/xuri/excelize/v2"
"worktimeBot/internal/db"
@@ -220,3 +222,220 @@ func fmtDDHHMM(seconds int64) string {
mins := (rem % 3600) / 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(msg *tgbotapi.Message) {
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(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())
}
if len(msg.Text) > 7 {
if n, _ := fmt.Sscanf(msg.Text[7:], "%d-%d", &cy, &cm); n == 2 {
if cy < 0 || cm < 1 || cm > 12 {
h.sendText(msg.Chat.ID, "Invalid date. Use: /export YYYY-MM")
return
}
}
}
h.sendExportDirect(msg.Chat.ID, user, cy, cm)
}
// sendExportDirect generates and sends an Excel report for the given calendar month.
func (h *Handler) sendExportDirect(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(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("2006-01-02", startStr)
if err != nil {
start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
}
end, err := time.Parse("2006-01-02", endStr)
if err != nil {
startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
end = startOfMonth.AddDate(0, 1, -1)
}
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, user.ExportAccent, user.Calendar, calYear, calMonth, start, end)
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", calYear, calMonth)),
Bytes: data,
})
if _, err := h.Bot.Send(doc); err != nil {
slog.Error("send document", "error", err)
}
}
// exportCallback opens the export month picker.
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 {
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(chatID, msgID, cy, cm, user)
}
// sendExportMonthPicker sends the export month picker as a new message.
func (h *Handler) sendExportMonthPicker(chatID int64, msgID int, year, month int, user *db.User) {
h.editExportMonthPicker(chatID, msgID, year, month, user)
}
// editExportMonthPicker renders a year-based month picker with 12 month buttons.
func (h *Handler) editExportMonthPicker(chatID int64, msgID int, year, _ int, user *db.User) {
months := gregMonthNames
switch user.Calendar {
case "jalali":
months = jalaliMonthNames
case "hijri":
months = hijriMonthNames
}
kbRows := [][]tgbotapi.InlineKeyboardButton{
// Year navigation
{
tgbotapi.NewInlineKeyboardButtonData("<", fmt.Sprintf("exp_year_prev_%d", year)),
tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("%d", year), "noop"),
tgbotapi.NewInlineKeyboardButtonData(">", fmt.Sprintf("exp_year_next_%d", year)),
},
}
// 4 columns x 3 rows of month buttons
for i := 0; i < 12; i += 4 {
row := []tgbotapi.InlineKeyboardButton{}
for j := i; j < i+4 && j < 12; j++ {
row = append(row, tgbotapi.NewInlineKeyboardButtonData(
months[j],
fmt.Sprintf("exp_do_%d_%d", year, j+1),
))
}
kbRows = append(kbRows, row)
}
kbRows = append(kbRows, []tgbotapi.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"),
})
kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...)
h.sendOrEdit(chatID, msgID, "Select month to export:", &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(chatID int64, msgID int, callbackID, data string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, ""))
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
var y, m int
// Navigate to previous year
if n, _ := fmt.Sscanf(data, "exp_year_prev_%d", &y); n == 1 {
h.editExportMonthPicker(chatID, msgID, y-1, 0, user)
return
}
// Navigate to next year
if n, _ := fmt.Sscanf(data, "exp_year_next_%d", &y); n == 1 {
h.editExportMonthPicker(chatID, msgID, y+1, 0, user)
return
}
// Export a specific month
if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n == 2 {
last, err := h.DB.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" {
kb := tgbotapi.NewInlineKeyboardMarkup(
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"),
),
)
edit := tgbotapi.NewEditMessageText(chatID, msgID, "You are currently clocked in. Please clock out first, then try again.")
edit.ReplyMarkup = &kb
h.Bot.Send(edit)
return
}
startStr, endStr := monthGregorianRange(user.Calendar, y, m)
start, err := time.Parse("2006-01-02", startStr)
if err != nil {
h.sendText(chatID, "Error processing date")
return
}
end, err := time.Parse("2006-01-02", endStr)
if err != nil {
h.sendText(chatID, "Error processing date")
return
}
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr)
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, y, m, start, end)
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", y, m)),
Bytes: data,
})
if _, err := h.Bot.Send(doc); err != nil {
slog.Error("send document", "error", err)
}
}
}