Files
worktimeBot/internal/handler/export.go
db123 d15ed46066 fix: resolve go vet error and dayStart logic in ComputeDailyTotals
- Fix 'string(d)' int-to-rune conversion in rpg_test.go by using fmt.Sprintf
- Fix ComputeDailyTotals dayStart logic: initial state should be StateWorking
  when dayStart > 0, correctly counting work from dayStart to first out event
- Update README project structure to reflect new domain/handler/repo layout
- Implement missing achievements: rpg_pioneer (on RPG enable) and
  salary_setter (on first rate set)
- Add tryUnlockAchievement helper to Handler
2026-06-25 09:54:22 +03:30

532 lines
16 KiB
Go

package handler
import (
"bytes"
"context"
"fmt"
"log/slog"
"strings"
"time"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/xuri/excelize/v2"
"worktimeBot/internal/domain"
)
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 (h *Handler) generateMonthlyReport(user *domain.User, calYear, calMonth int, start, end time.Time) ([]byte, error) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return nil, err
}
accent := st.ExportAccent
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 := domain.FormatMonthTitle(user.Calendar, calYear, calMonth)
h.writeReportHeader(f, sheet, fmt.Sprintf("Work Time Report - %s", monthName), styles)
loc := h.loadLocation(user.Timezone)
row := 3
userDays, err := h.Repo.GetUserDaysInRange(user.ID, start.Format(domain.DateLayout), end.Format(domain.DateLayout))
if err != nil {
return nil, err
}
dayMap := make(map[string]*domain.Day)
for i := range userDays {
dayMap[userDays[i].Date] = &userDays[i]
}
var totalWork, totalBreak int64
layout := start.Format(domain.DateLayout)
endLayout := end.Format(domain.DateLayout)
cur := start
for !cur.After(end) {
dateStr := cur.Format(domain.DateLayout)
displayDate := domain.FormatDateForCalendar(dateStr, user.Calendar)
var (
day *domain.Day
events []domain.Event
hasDayOff bool
)
if existing, ok := dayMap[dateStr]; ok {
day = existing
events, err = h.Repo.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 = h.workTypeLabel(day, events)
}
ws, bs := h.writeReportDayRow(f, sheet, row, displayDate, typeLabel, events, day, loc, hasDayOff, styles)
totalWork += ws
totalBreak += bs
row++
cur = cur.AddDate(0, 0, 1)
}
_ = layout
_ = endLayout
h.writeReportTotalRow(f, sheet, row, totalWork, totalBreak, styles)
buf, err := f.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (h *Handler) 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, hdr := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 2)
f.SetCellValue(sheet, cell, hdr)
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 (h *Handler) writeReportDayRow(f *excelize.File, sheet string, row int, displayDate, typeLabel string, events []domain.Event, day *domain.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(domain.TimeLayout))
f.SetCellStyle(sheet, cellIn, cellIn, s.data)
}
if len(events) > 1 {
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(domain.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 {
d, err := time.Parse(domain.DateLayout, day.Date)
if err == nil {
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 := domain.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 (h *Handler) 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 fmtHHMM(seconds int64) string {
hours := seconds / domain.SecondsPerHour
mins := (seconds % domain.SecondsPerHour) / 60
return fmt.Sprintf("%d:%02d", hours, mins)
}
func fmtDDHHMM(seconds int64) string {
days := seconds / domain.SecondsPerDay
rem := seconds % domain.SecondsPerDay
hours := rem / domain.SecondsPerHour
mins := (rem % domain.SecondsPerHour) / 60
return fmt.Sprintf("%d:%02d:%02d", days, hours, mins)
}
func (h *Handler) workTypeLabel(day *domain.Day, events []domain.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 := h.Repo.GetWorkType(day.CurrentWorkTypeID)
if err == nil {
return wt.Name
}
return ""
}
if len(wtSeen) == 1 {
for id := range wtSeen {
wt, err := h.Repo.GetWorkType(id)
if err == nil {
return wt.Name
}
}
}
return "mixed"
}
// -- Commands --
func (h *Handler) handleExport(ctx context.Context, msg *models.Message, user *domain.User) {
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
calY, calM := now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
calY, calM, _ = domain.GregorianToJalali(calY, calM, now.Day())
case "hijri":
calY, calM, _ = domain.GregorianToHijri(calY, calM, now.Day())
}
args := strings.Fields(msg.Text)
if len(args) >= 1 && args[0][0] == '/' {
args = args[1:]
}
if len(args) >= 1 {
if n, _ := fmt.Sscanf(args[0], "%d-%d", &calY, &calM); n == 2 {
if calY < 0 || calM < 1 || calM > 12 {
h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /export YYYY-MM")
return
}
}
}
h.sendExportDirect(ctx, msg.Chat.ID, user, calY, calM)
}
func (h *Handler) sendExportDirect(ctx context.Context, chatID int64, user *domain.User, calYear, calMonth int) {
last, err := h.Repo.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 := h.loadLocation(user.Timezone)
startStr, endStr := domain.MonthGregorianRange(user.Calendar, calYear, calMonth)
start, err := time.Parse(domain.DateLayout, startStr)
if err != nil {
start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
}
end, err := time.Parse(domain.DateLayout, 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 := h.generateMonthlyReport(user, 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, &tgbot.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)
}
}
// -- Callbacks --
func (h *Handler) exportCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
cy, cm := now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
cy, cm, _ = domain.GregorianToJalali(cy, cm, now.Day())
case "hijri":
cy, cm, _ = domain.GregorianToHijri(cy, cm, now.Day())
}
h.editExportMonthPicker(ctx, chatID, msgID, cy, cm, user)
}
func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, _ int, user *domain.User) {
months := domain.GregMonthNames
switch user.Calendar {
case "jalali":
months = domain.JalaliMonthNames
case "hijri":
months = domain.HijriMonthNames
}
kbRows := [][]models.InlineKeyboardButton{
{
{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)},
},
}
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}
h.sendOrEdit(ctx, chatID, msgID, "Select month to export:", &kb)
}
func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, data string) {
defer h.answerCb(ctx, cbID)
if h.handleExportNav(ctx, chatID, msgID, user, data) {
return
}
h.handleExportDo(ctx, chatID, msgID, user, data)
}
func (h *Handler) handleExportNav(ctx context.Context, chatID int64, msgID int, user *domain.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
}
func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, user *domain.User, data string) {
var y, m int
if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n != 2 {
return
}
last, err := h.Repo.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 := domain.MonthGregorianRange(user.Calendar, y, m)
start, err := time.Parse(domain.DateLayout, startStr)
if err != nil {
h.sendText(ctx, chatID, "Error processing date")
return
}
end, err := time.Parse(domain.DateLayout, endStr)
if err != nil {
h.sendText(ctx, 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)
reportData, err := h.generateMonthlyReport(user, 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, &tgbot.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)
}
}
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)
}