From 7176f9b81d5063ef02435851412ded3fbc08cf80 Mon Sep 17 00:00:00 2001 From: db123 Date: Tue, 23 Jun 2026 20:22:54 +0330 Subject: [PATCH] =?UTF-8?q?Bot=20handlers:=20clock=E2=80=91in/out,=20remot?= =?UTF-8?q?e=20flag,=20day=E2=80=91off=20toggle,=20daily=20report,=20month?= =?UTF-8?q?ly=20Excel=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/bot/export.go | 76 ++++++++++++++++ internal/bot/handlers.go | 190 +++++++++++++++++++++++++++++++++++++++ internal/bot/totals.go | 80 +++++++++++++++++ 3 files changed, 346 insertions(+) create mode 100644 internal/bot/export.go create mode 100644 internal/bot/handlers.go create mode 100644 internal/bot/totals.go diff --git a/internal/bot/export.go b/internal/bot/export.go new file mode 100644 index 0000000..8777fcb --- /dev/null +++ b/internal/bot/export.go @@ -0,0 +1,76 @@ +package bot + +import ( + "fmt" + "time" + + "github.com/xuri/excelize/v2" + + "worktimeBot/internal/db" +) + +// GenerateMonthlyReport creates an Excel spreadsheet for the given month. +func GenerateMonthlyReport(store *db.Store, year int, month time.Month) ([]byte, error) { + f := excelize.NewFile() + defer func() { _ = f.Close() }() + + sheet := "Report" + // Create a new sheet + index, err := f.NewSheet(sheet) + if err != nil { + return nil, err + } + f.SetActiveSheet(index) + + // Header row + headers := []string{"Date", "Work Duration (h)", "Pairs"} + for i, h := range headers { + cell, _ := excelize.CoordinatesToCellName(i+1, 1) + if err := f.SetCellValue(sheet, cell, h); err != nil { + return nil, err + } + } + + row := 2 + loc := time.Now().Location() + firstDay := time.Date(year, month, 1, 0, 0, 0, 0, loc) + lastDay := firstDay.AddDate(0, 1, -1) + + for d := firstDay; d.Before(lastDay.AddDate(0, 0, 1)); d = d.AddDate(0, 0, 1) { + dateStr := d.Format("2006-01-02") + isOff, err := store.IsDayOff(dateStr) + if err != nil { + return nil, err + } + if isOff { + continue // skip day‑offs + } + events, err := store.EventsForDay(dateStr) + if err != nil { + return nil, err + } + totals := ComputeDailyTotals(events) + hours := float64(totals.TotalSeconds) / 3600.0 + + cellDate, _ := excelize.CoordinatesToCellName(1, row) + cellHours, _ := excelize.CoordinatesToCellName(2, row) + cellPairs, _ := excelize.CoordinatesToCellName(3, row) + + if err := f.SetCellValue(sheet, cellDate, dateStr); err != nil { + return nil, err + } + if err := f.SetCellValue(sheet, cellHours, fmt.Sprintf("%.2f", hours)); err != nil { + return nil, err + } + if err := f.SetCellValue(sheet, cellPairs, totals.PairCount); err != nil { + return nil, err + } + row++ + } + + buf, err := f.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go new file mode 100644 index 0000000..e5a1ce8 --- /dev/null +++ b/internal/bot/handlers.go @@ -0,0 +1,190 @@ +package bot + +import ( + "database/sql" + "fmt" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "worktimeBot/internal/db" + "worktimeBot/pkg/i18n" +) + +type Handler struct { + Bot *tgbotapi.BotAPI + DB *db.Store + Translator *i18n.Translator +} + +func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, tr *i18n.Translator) *Handler { + return &Handler{Bot: bot, DB: store, Translator: tr} +} + +// HandleMessage routes incoming messages. +func (h *Handler) HandleMessage(update tgbotapi.Update) { + msg := update.Message + switch msg.Text { + case "/clockin": + if err := h.handleClockIn(); err != nil { + h.replyWithError(msg.Chat.ID, err.Error()) + } else { + h.replyWithText(msg.Chat.ID, "clock_in") + } + case "/clockout": + if err := h.handleClockOut(); err != nil { + h.replyWithError(msg.Chat.ID, err.Error()) + } else { + h.replyWithText(msg.Chat.ID, "clock_out") + } + case "/remote": + h.toggleRemote(msg) + case "/report": + h.handleReport(msg) + case "/export": + h.handleExport(msg) + case "/dayoff": + h.handleDayOff(msg) + default: + h.replyWithText(msg.Chat.ID, "unknown_command") + } +} + +// handleClockIn validates and records a clock‑in event. +func (h *Handler) handleClockIn() error { + last, err := h.DB.GetLastEvent() + if err != nil && err != sql.ErrNoRows { + return err + } + if last != nil && last.EventType == "in" { + return fmt.Errorf(h.Translator.Get("already_clocked_in", "en")) + } + // Set default remote flag after first clock‑in. + if _, err := h.DB.GetSetting("remote_flag"); err != nil { + _ = h.DB.SetSetting("remote_flag", "onsite") + } + return h.DB.CreateEvent("in", time.Now().Unix(), "") +} + +// handleClockOut validates and records a clock‑out event. +func (h *Handler) handleClockOut() error { + last, err := h.DB.GetLastEvent() + if err != nil { + return err + } + if last == nil { + return fmt.Errorf(h.Translator.Get("error", "en")) + } + switch last.EventType { + case "in": + return h.DB.CreateEvent("out", time.Now().Unix(), "") + case "out": + return fmt.Errorf(h.Translator.Get("already_clocked_out", "en")) + default: + return fmt.Errorf(h.Translator.Get("error", "en")) + } +} + +// toggleRemote switches the remote/onsite flag. +func (h *Handler) toggleRemote(msg *tgbotapi.Message) { + current, err := h.DB.GetSetting("remote_flag") + if err != nil { + current = "onsite" + } + newMode := "remote" + if current == "remote" { + newMode = "onsite" + } + if err := h.DB.SetSetting("remote_flag", newMode); err != nil { + h.replyWithError(msg.Chat.ID, "error") + return + } + key := "remote_switched_on" + if newMode == "onsite" { + key = "remote_switched_off" + } + h.replyWithText(msg.Chat.ID, key) +} + +// handleReport sends daily work time summary. +func (h *Handler) handleReport(msg *tgbotapi.Message) { + today := time.Now().Format("2006-01-02") + events, err := h.DB.EventsForDay(today) + if err != nil { + h.replyWithError(msg.Chat.ID, "error") + return + } + totals := ComputeDailyTotals(events) + if totals.PairCount == 0 { + h.replyWithText(msg.Chat.ID, "total_work_time") + return + } + workStr := formatDuration(totals.TotalSeconds) + breakStr := formatDuration(totals.BreakSeconds) + text := fmt.Sprintf("Work: %s | Break: %s", workStr, breakStr) + reply := tgbotapi.NewMessage(msg.Chat.ID, text) + _, _ = h.Bot.Send(reply) +} + +// formatDuration converts seconds to a human‑readable string. +func formatDuration(seconds int64) string { + hours := seconds / 3600 + mins := (seconds % 3600) / 60 + if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, mins) + } + return fmt.Sprintf("%dm", mins) +} + +// handleExport generates and sends a monthly Excel report. +func (h *Handler) handleExport(msg *tgbotapi.Message) { + now := time.Now() + year, month := now.Year(), now.Month() + data, err := GenerateMonthlyReport(h.DB, year, month) + if err != nil { + h.replyWithError(msg.Chat.ID, "error") + 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 { + h.replyWithError(msg.Chat.ID, "error") + } +} + +// handleDayOff toggles today as a day off. +func (h *Handler) handleDayOff(msg *tgbotapi.Message) { + today := time.Now().Format("2006-01-02") + isOff, err := h.DB.IsDayOff(today) + if err != nil { + h.replyWithError(msg.Chat.ID, "error") + return + } + if isOff { + if err := h.DB.RemoveDayOff(today); err != nil { + h.replyWithError(msg.Chat.ID, "error") + return + } + h.replyWithText(msg.Chat.ID, "dayoff_removed") + } else { + if err := h.DB.SetDayOff(today, ""); err != nil { + h.replyWithError(msg.Chat.ID, "error") + return + } + h.replyWithText(msg.Chat.ID, "dayoff_set") + } +} + +func (h *Handler) replyWithText(chatID int64, key string) { + text := h.Translator.Get(key, "en") + reply := tgbotapi.NewMessage(chatID, text) + _, _ = h.Bot.Send(reply) +} + +func (h *Handler) replyWithError(chatID int64, key string) { + text := h.Translator.Get(key, "en") + reply := tgbotapi.NewMessage(chatID, text) + _, _ = h.Bot.Send(reply) +} diff --git a/internal/bot/totals.go b/internal/bot/totals.go new file mode 100644 index 0000000..2ea575c --- /dev/null +++ b/internal/bot/totals.go @@ -0,0 +1,80 @@ +package bot + +import ( + "sort" + + "worktimeBot/internal/db" +) + +type State int + +const ( + StateIdle State = iota + StateWorking + StateOnBreak +) + +// DailyTotals holds aggregated times for a single day. +type DailyTotals struct { + TotalSeconds int64 + BreakSeconds int64 + PairCount int +} + +// ComputeDailyTotals pairs events with break inference. +// Multi‑out sequences: first out ends work, second out starts break, +// next in ends break, next out ends work, etc. +func ComputeDailyTotals(events []db.Event) DailyTotals { + if len(events) == 0 { + return DailyTotals{} + } + sorted := make([]db.Event, len(events)) + copy(sorted, events) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].OccurredAt < sorted[j].OccurredAt + }) + + var workTotal, breakTotal int64 + var workStart, breakStart int64 + pairs := 0 + state := StateIdle + + for _, e := range sorted { + switch { + case e.EventType == "in": + switch state { + case StateIdle, StateOnBreak: + if state == StateOnBreak { + breakTotal += e.OccurredAt - breakStart + breakStart = 0 + } + workStart = e.OccurredAt + state = StateWorking + // Working → ignore duplicate in (shouldn't happen) + case StateWorking: + // already working, ignore + } + case e.EventType == "out": + switch state { + case StateWorking: + // End of work period + workTotal += e.OccurredAt - workStart + workStart = 0 + pairs++ + state = StateIdle + case StateIdle: + // Second consecutive out → break start + breakStart = e.OccurredAt + state = StateOnBreak + case StateOnBreak: + // Duplicate break start – ignore + } + } + } + + return DailyTotals{ + TotalSeconds: workTotal, + BreakSeconds: breakTotal, + PairCount: pairs, + } +}