From a8b849f8fdd7956229a19571355a2486a28c3074 Mon Sep 17 00:00:00 2001 From: db123 Date: Wed, 24 Jun 2026 11:33:17 +0330 Subject: [PATCH] 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) --- cmd/bot/main.go | 5 + cmd/bot/webhook.go | 1 + internal/bot/calendar.go | 496 ++++++++++++++++ internal/bot/clock.go | 111 ++++ internal/bot/dateutil.go | 14 + internal/bot/export.go | 219 +++++++ internal/bot/handlers.go | 1202 +------------------------------------- internal/bot/report.go | 149 +++++ internal/bot/settings.go | 308 ++++++++++ internal/bot/totals.go | 10 + internal/db/store.go | 27 + 11 files changed, 1366 insertions(+), 1176 deletions(-) create mode 100644 internal/bot/calendar.go create mode 100644 internal/bot/clock.go create mode 100644 internal/bot/report.go create mode 100644 internal/bot/settings.go diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 9e3ab4c..e02c207 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -87,6 +87,7 @@ func main() { dbStore.Close() } +// startPolling runs the long-polling update loop, dispatching messages and callbacks to the handler. func startPolling(botAPI *tgbotapi.BotAPI, handler *bot.Handler, stop chan struct{}) { u := tgbotapi.NewUpdate(0) u.Timeout = 30 @@ -109,6 +110,7 @@ func startPolling(botAPI *tgbotapi.BotAPI, handler *bot.Handler, stop chan struc } } +// parseAllowedUsers parses a comma-separated list of Telegram user IDs into a set. func parseAllowedUsers(raw string) map[int64]bool { m := make(map[int64]bool) for _, s := range strings.Split(raw, ",") { @@ -126,6 +128,7 @@ func parseAllowedUsers(raw string) map[int64]bool { return m } +// dailyReportScheduler runs a periodic ticker that triggers daily report checks every 30 seconds. func dailyReportScheduler(h *bot.Handler, store *db.Store, stop chan struct{}) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() @@ -140,6 +143,7 @@ func dailyReportScheduler(h *bot.Handler, store *db.Store, stop chan struct{}) { } } +// checkDailyReports iterates all users and sends a daily report to those whose report time has arrived. func checkDailyReports(h *bot.Handler, store *db.Store) { users, err := store.GetAllUsers() if err != nil { @@ -177,6 +181,7 @@ func checkDailyReports(h *bot.Handler, store *db.Store) { } } +// getEnvDefault returns the environment variable value or a default if unset or empty. func getEnvDefault(key, defaultValue string) string { if val := os.Getenv(key); val != "" { return val diff --git a/cmd/bot/webhook.go b/cmd/bot/webhook.go index e0b30a7..4941db8 100644 --- a/cmd/bot/webhook.go +++ b/cmd/bot/webhook.go @@ -10,6 +10,7 @@ import ( "worktimeBot/internal/bot" ) +// startWebhook registers the webhook with Telegram and starts the HTTPS/HTTP listener loop. func startWebhook(botAPI *tgbotapi.BotAPI, handler *bot.Handler, webhookURL string) { if _, err := botAPI.Request(tgbotapi.DeleteWebhookConfig{}); err != nil { slog.Error("delete webhook", "error", err) diff --git a/internal/bot/calendar.go b/internal/bot/calendar.go new file mode 100644 index 0000000..229f10b --- /dev/null +++ b/internal/bot/calendar.go @@ -0,0 +1,496 @@ +package bot + +import ( + "database/sql" + "fmt" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "worktimeBot/internal/db" +) + +// handleHistoryMsg shows the calendar view (new message). +func (h *Handler) handleHistoryMsg(msg *tgbotapi.Message) { + h.sendCalendar(msg.Chat.ID, 0, 0, 0) +} + +// historyCallback opens or refreshes the calendar view (inline). +func (h *Handler) historyCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + h.editCalendar(chatID, msgID, 0, 0) +} + +// handleEditMsg parses a /edit YYYY-MM-DD command and shows that day's events. +func (h *Handler) handleEditMsg(msg *tgbotapi.Message) { + date := msg.CommandArguments() + if date == "" { + h.sendText(msg.Chat.ID, "Usage: /edit YYYY-MM-DD (your calendar type)") + return + } + if len(date) != 10 || date[4] != '-' || date[7] != '-' { + h.sendText(msg.Chat.ID, "Invalid date. Use: /edit YYYY-MM-DD (your calendar type)") + return + } + _, m, d := parseGregorianDateKey(date) + if m < 1 || m > 12 || d < 1 || d > 31 { + h.sendText(msg.Chat.ID, "Invalid date.") + return + } + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + h.sendText(msg.Chat.ID, "Error loading profile") + return + } + loc := loadLocation(user.Timezone) + date = userDateToGregorian(date, user.Calendar) + h.sendDayView(msg.Chat.ID, 0, user, date, loc, true) +} + +// handleHistoryCallback routes calendar-related callback data to the right handler. +func (h *Handler) handleHistoryCallback(chatID int64, msgID int, callbackID, data string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + loc := loadLocation(user.Timezone) + + if data == "history" { + h.editCalendar(chatID, msgID, 0, 0) + return + } + + var y, m int + var date string + var eid int64 + + // Navigate to previous month + if n, _ := fmt.Sscanf(data, "cal_prev_%d_%d", &y, &m); n == 2 { + h.editCalendar(chatID, msgID, y, m) + return + } + // Navigate to next month + if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 { + h.editCalendar(chatID, msgID, y, m) + return + } + // Open a specific day + if n, _ := fmt.Sscanf(data, "cal_day_%s", &date); n == 1 && len(date) == 10 { + h.editDayView(chatID, msgID, user, date, loc) + return + } + // Back to day view from event editing + if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 { + h.backToDayView(chatID, msgID, user, eid, loc) + return + } + // Set event work type + var wtid int64 + if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 { + h.setEventWorkType(chatID, msgID, user, eid, wtid, loc) + return + } + // Show work type picker for an event + if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 { + h.editEventType(chatID, msgID, user, eid, loc) + return + } + // Delete event confirmation prompt + if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 { + h.deleteEventPrompt(chatID, msgID, user, eid, loc) + return + } + // Confirm event deletion + if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 { + h.deleteEventConfirm(chatID, msgID, user, eid, loc) + return + } + // Show hour picker for event time + if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 { + h.editEventTime(chatID, msgID, user, eid, loc) + return + } + // Set event time (hour+minute) + var hh, mm int + if n, _ := fmt.Sscanf(data, "edittm_%d_%d_%d", &eid, &hh, &mm); n == 3 { + h.editEventTimeSet(chatID, msgID, user, eid, hh, mm, loc) + return + } + // Show minute picker (hour already chosen) + if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 { + h.editEventTimeMin(chatID, msgID, user, eid, hh, loc) + return + } +} + +// sendCalendar sends a calendar view as a new message. +func (h *Handler) sendCalendar(chatID int64, msgID int, year, month int) { + h.editCalendar(chatID, msgID, year, month) +} + +// editCalendar renders a monthly calendar grid with event indicators and navigation. +func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) { + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + loc := loadLocation(user.Timezone) + now := time.Now().In(loc) + + if year == 0 || month == 0 { + switch user.Calendar { + case "jalali": + jy, jm, _ := gregorianToJalali(now.Year(), int(now.Month()), now.Day()) + year, month = jy, jm + case "hijri": + hy, hm, _ := gregorianToHijri(now.Year(), int(now.Month()), now.Day()) + year, month = hy, hm + default: + year, month = now.Year(), int(now.Month()) + } + } + + cal := user.Calendar + cm := buildCalendarMonth(cal, year, month) + + text := cm.title + todayKey := now.Format("2006-01-02") + + hasEvent := make(map[string]bool) + 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() + } + + kbRows := [][]tgbotapi.InlineKeyboardButton{} + + // Weekday header row + headerRow := []tgbotapi.InlineKeyboardButton{} + for _, wn := range cm.weekDays { + headerRow = append(headerRow, tgbotapi.NewInlineKeyboardButtonData(wn, "noop")) + } + kbRows = append(kbRows, headerRow) + + // Day cells + row := []tgbotapi.InlineKeyboardButton{} + for i, d := range cm.days { + if d.dayNum == 0 { + row = append(row, tgbotapi.NewInlineKeyboardButtonData(" ", "noop")) + } else { + label := fmt.Sprintf("%d", d.dayNum) + if d.date == todayKey { + label = fmt.Sprintf("[%d]", d.dayNum) + } else if hasEvent[d.date] { + label = fmt.Sprintf("%d*", d.dayNum) + } + row = append(row, tgbotapi.NewInlineKeyboardButtonData(label, "cal_day_"+d.date)) + } + if len(row) == 7 || i == len(cm.days)-1 { + // Pad the last row to 7 columns so buttons render evenly + for len(row) < 7 { + row = append(row, tgbotapi.NewInlineKeyboardButtonData(" ", "noop")) + } + kbRows = append(kbRows, row) + row = nil + } + } + + prevY, prevM, nextY, nextM := navMonth(year, month) + + // Navigation row + kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("<", fmt.Sprintf("cal_prev_%d_%d", prevY, prevM)), + tgbotapi.NewInlineKeyboardButtonData("Today", "history"), + tgbotapi.NewInlineKeyboardButtonData(">", fmt.Sprintf("cal_next_%d_%d", nextY, nextM)), + )) + + kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), + )) + + kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) + h.sendOrEdit(chatID, msgID, text, &kb) +} + +// editDayView opens an existing message as a day view. +func (h *Handler) editDayView(chatID int64, msgID int, user *db.User, date string, loc *time.Location) { + h.sendDayView(chatID, msgID, user, date, loc, false) +} + +// sendDayView displays all events for a given date with inline edit/delete buttons. +func (h *Handler) sendDayView(chatID int64, msgID int, user *db.User, date string, loc *time.Location, isNewMsg bool) { + events, err := h.DB.EventsForDayByDate(user.ID, date) + if err != nil { + if isNewMsg { + h.sendText(chatID, "Error loading events") + } + return + } + + text := fmt.Sprintf("Date: %s", formatDateForCalendar(date, user.Calendar)) + if len(events) == 0 { + text += "\nNo events for this day." + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"), + ), + ) + h.sendOrEdit(chatID, msgID, text, &kb) + return + } + + text += "\n\nEvents:" + kbRows := [][]tgbotapi.InlineKeyboardButton{} + for _, e := range events { + t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04") + label := "IN" + if e.EventType == "out" { + label = "OUT" + } + wt := "" + if e.WorkTypeID != nil { + if wtObj, err := h.DB.GetWorkType(*e.WorkTypeID); err == nil { + wt = " [" + wtObj.Name + "]" + } + } + note := "" + if e.Note != "" { + note = " (" + e.Note + ")" + } + text += fmt.Sprintf("\n%s %s%s%s", label, t, wt, note) + + kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Time %s", t), fmt.Sprintf("edit_time_%d", e.ID)), + tgbotapi.NewInlineKeyboardButtonData("Type", fmt.Sprintf("edit_type_%d", e.ID)), + tgbotapi.NewInlineKeyboardButtonData("DEL", fmt.Sprintf("delete_%d", e.ID)), + )) + } + + kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"), + )) + kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) + h.sendOrEdit(chatID, msgID, text, &kb) +} + +// editEventType shows a work type picker for a specific event. +func (h *Handler) editEventType(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { + wts, err := h.DB.GetWorkTypes() + if err != nil { + return + } + text := "Select new work type:" + kbRows := [][]tgbotapi.InlineKeyboardButton{} + for _, wt := range wts { + kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(wt.Name, fmt.Sprintf("settype_%d_%d", eventID, wt.ID)), + )) + } + kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("back_day_%d", eventID)), + )) + kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +// setEventWorkType updates an event's work type and returns to the day view. +func (h *Handler) setEventWorkType(chatID int64, msgID int, user *db.User, eventID, workTypeID int64, loc *time.Location) { + h.DB.DB().Exec("UPDATE events SET work_type_id=? WHERE id=?", workTypeID, eventID) + event, err := h.getEventByID(user.ID, eventID) + if err != nil { + return + } + t := time.Unix(event.OccurredAt, 0).In(loc) + h.sendDayView(chatID, msgID, user, t.Format("2006-01-02"), loc, false) +} + +// editEventTime shows an hour picker for changing an event's time. +func (h *Handler) editEventTime(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { + text := "Select hour:" + kbRows := [][]tgbotapi.InlineKeyboardButton{} + hourRow := []tgbotapi.InlineKeyboardButton{} + for hh := 0; hh < 24; hh++ { + hourRow = append(hourRow, tgbotapi.NewInlineKeyboardButtonData( + fmt.Sprintf("%02d", hh), + fmt.Sprintf("edittm_%d_%02d", eventID, hh), + )) + if len(hourRow) == 6 || hh == 23 { + kbRows = append(kbRows, hourRow) + hourRow = nil + } + } + kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("back_day_%d", eventID)), + )) + kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +// editEventTimeMin shows a minute picker (15-min intervals) after hour selection. +func (h *Handler) editEventTimeMin(chatID int64, msgID int, user *db.User, eventID int64, hh int, loc *time.Location) { + text := fmt.Sprintf("Select minute for hour %02d:", hh) + kbRows := [][]tgbotapi.InlineKeyboardButton{} + minRow := []tgbotapi.InlineKeyboardButton{} + for _, mm := range []int{0, 15, 30, 45} { + minRow = append(minRow, tgbotapi.NewInlineKeyboardButtonData( + fmt.Sprintf("%02d", mm), + fmt.Sprintf("edittm_%d_%02d_%02d", eventID, hh, mm), + )) + } + kbRows = append(kbRows, minRow) + kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("edit_time_%d", eventID)), + )) + kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +// editEventTimeSet applies a new time to an event, checking for overlaps. +func (h *Handler) editEventTimeSet(chatID int64, msgID int, user *db.User, eventID int64, hh, mm int, loc *time.Location) { + event, err := h.getEventByID(user.ID, eventID) + if err != nil { + return + } + t := time.Unix(event.OccurredAt, 0).In(loc) + newTime := time.Date(t.Year(), t.Month(), t.Day(), hh, mm, 0, 0, loc) + date := t.Format("2006-01-02") + + // Reject if the new time collides with another event's timestamp + events, err := h.DB.EventsForDayByDate(user.ID, date) + if err == nil { + for _, e := range events { + if e.ID == eventID { + continue + } + eTime := time.Unix(e.OccurredAt, 0) + if newTime.Unix() == eTime.Unix() { + h.acknowledgeCallback(chatID, msgID) + h.sendDayView(chatID, msgID, user, date, loc, false) + return + } + } + + // Validate that in/out events still alternate after the change + type eventPos struct { + id int64 + typ string + t int64 + } + sorted := []eventPos{} + for _, e := range events { + et := e.OccurredAt + if e.ID == eventID { + et = newTime.Unix() + } + sorted = append(sorted, eventPos{e.ID, e.EventType, et}) + } + // Bubble sort by timestamp, then by ID for determinism + for i := 0; i < len(sorted); i++ { + for j := i + 1; j < len(sorted); j++ { + if sorted[j].t < sorted[i].t || (sorted[j].t == sorted[i].t && sorted[j].id < sorted[i].id) { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + } + } + for i := 1; i < len(sorted); i++ { + if sorted[i].typ == sorted[i-1].typ { + edit := tgbotapi.NewEditMessageText(chatID, msgID, "Edit rejected: overlapping events. Two consecutive events must be different types (in/out).") + h.Bot.Send(edit) + return + } + } + } + + h.DB.DB().Exec("UPDATE events SET occurred_at=? WHERE id=?", newTime.Unix(), eventID) + h.sendDayView(chatID, msgID, user, date, loc, false) +} + +// acknowledgeCallback sends an empty acknowledgement for a callback query. +func (h *Handler) acknowledgeCallback(chatID int64, msgID int) { + h.Bot.Request(tgbotapi.NewCallback(fmt.Sprintf("cb_%d_%d", chatID, msgID), "")) +} + +// deleteEventPrompt asks the user to confirm event deletion. +func (h *Handler) deleteEventPrompt(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { + edit := tgbotapi.NewEditMessageText(chatID, msgID, "Delete this event?") + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Yes, DEL", fmt.Sprintf("delconf_%d", eventID)), + tgbotapi.NewInlineKeyboardButtonData("Cancel", fmt.Sprintf("back_day_%d", eventID)), + ), + ) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +// deleteEventConfirm deletes an event and warns if the timeline becomes invalid. +func (h *Handler) deleteEventConfirm(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { + event, err := h.getEventByID(user.ID, eventID) + if err != nil { + return + } + t := time.Unix(event.OccurredAt, 0).In(loc) + date := t.Format("2006-01-02") + + h.DB.DB().Exec("DELETE FROM events WHERE id=?", eventID) + + // Warn if consecutive events now have the same type + events, err := h.DB.EventsForDayByDate(user.ID, date) + if err == nil && len(events) > 1 { + hasIssue := false + for i := 1; i < len(events); i++ { + if events[i].EventType == events[i-1].EventType { + hasIssue = true + break + } + } + if hasIssue { + h.sendText(chatID, "Warning: Timeline has consecutive events of same type after deletion. Manual repair needed.") + } + } + + h.sendDayView(chatID, msgID, user, date, loc, false) +} + +// backToDayView returns from event editing to the day view. +func (h *Handler) backToDayView(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { + event, err := h.getEventByID(user.ID, eventID) + if err != nil { + return + } + t := time.Unix(event.OccurredAt, 0).In(loc) + h.sendDayView(chatID, msgID, user, t.Format("2006-01-02"), loc, false) +} + +// getEventByID fetches a single event by ID, scoped to the user. +func (h *Handler) getEventByID(userID, eventID int64) (*db.Event, error) { + row := h.DB.DB().QueryRow( + "SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at FROM events WHERE id=? AND user_id=?", + eventID, userID, + ) + var e db.Event + var wt sql.NullInt64 + if err := row.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { + return nil, err + } + if wt.Valid { + e.WorkTypeID = &wt.Int64 + } + return &e, nil +} diff --git a/internal/bot/clock.go b/internal/bot/clock.go new file mode 100644 index 0000000..dea56bd --- /dev/null +++ b/internal/bot/clock.go @@ -0,0 +1,111 @@ +package bot + +import ( + "database/sql" + "errors" + "fmt" + "time" +) + +// clockIn records a clock-in event for today. +// Returns the formatted time or an error if already clocked in today. +func (h *Handler) clockIn(chatID int64) (string, error) { + user, day, err := h.getUserToday(chatID) + if err != nil { + return "", err + } + + last, err := h.DB.GetLastEvent(user.ID) + if err != nil && err != sql.ErrNoRows { + return "", err + } + + if last != nil && last.EventType == "in" { + loc := loadLocation(user.Timezone) + now := time.Now().In(loc) + lastTime := time.Unix(last.OccurredAt, 0).In(loc) + if lastTime.Format("2006-01-02") == now.Format("2006-01-02") { + return "", errors.New("already clocked in") + } + } + + loc := loadLocation(user.Timezone) + now := time.Now().In(loc) + wt := day.CurrentWorkTypeID + if err := h.DB.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil { + return "", err + } + return fmt.Sprintf("clocked in at %s", now.Format("15:04")), nil +} + +// clockOut records a clock-out event for today. +// Returns the formatted time or an error if not clocked in. +func (h *Handler) clockOut(chatID int64) (string, error) { + user, day, err := h.getUserToday(chatID) + if err != nil { + return "", err + } + last, err := h.DB.GetLastEvent(user.ID) + if err != nil { + return "", err + } + if last == nil { + return "", errors.New("no clock-in found — start with /clockin first") + } + if last.EventType == "out" { + return "", errors.New("already clocked out") + } + loc := loadLocation(user.Timezone) + now := time.Now().In(loc) + if err := h.DB.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil { + return "", err + } + return fmt.Sprintf("clocked out at %s", now.Format("15:04")), nil +} + +// dayOff toggles today as a day off. +func (h *Handler) dayOff(chatID int64) (string, error) { + user, day, err := h.getUserToday(chatID) + if err != nil { + return "", err + } + if day.IsDayOff { + if err := h.DB.RemoveDayOff(user.ID, day.Date); err != nil { + return "", err + } + return "day off removed", nil + } + if err := h.DB.SetDayOff(user.ID, day.Date, ""); err != nil { + return "", err + } + return "today marked as day off", nil +} + +// report returns today's work summary via buildDailyReport. +func (h *Handler) report(chatID int64) (string, error) { + user, day, err := h.getUserToday(chatID) + if err != nil { + return "", err + } + events, err := h.DB.EventsForDayByDayID(day.ID) + if err != nil { + return "", err + } + return h.buildDailyReport(user, day, events), nil +} + +// toggleReport enables or disables the automatic daily report. +func (h *Handler) toggleReport(chatID int64) (string, error) { + user, err := h.getOrCreateUser(chatID) + if err != nil { + return "", err + } + user.ReportEnabled = !user.ReportEnabled + if err := h.DB.UpdateUser(user); err != nil { + return "", err + } + if user.ReportEnabled { + return "daily report enabled", nil + } + return "daily report disabled", nil +} diff --git a/internal/bot/dateutil.go b/internal/bot/dateutil.go index 1919802..87867b5 100644 --- a/internal/bot/dateutil.go +++ b/internal/bot/dateutil.go @@ -1,3 +1,5 @@ +// Package bot implements the Telegram bot logic: message routing, calendar rendering, +// time tracking (multi-calendar), Excel export, and inline editing of events. package bot import ( @@ -63,8 +65,10 @@ func monthLenHijri(year, month int) int { } // JDN for March 21, 622 CE (Jalali epoch: 1 Farvardin 1) +// jalaliEpochJDN is the Julian Day Number for 1 Farvardin 1 (March 21, 622 CE). const jalaliEpochJDN = 1948320 +// gregorianToJDN converts a Gregorian date to a Julian Day Number. func gregorianToJDN(gy, gm, gd int) int { a := (14 - gm) / 12 y := gy + 4800 - a @@ -72,6 +76,7 @@ func gregorianToJDN(gy, gm, gd int) int { return gd + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045 } +// jdnToGregorian converts a Julian Day Number to a Gregorian date (year, month, day). func jdnToGregorian(jdn int) (int, int, int) { f := jdn + 1401 + ((4*jdn+274277)/146097)*3/4 - 38 e := 4*f + 3 @@ -83,6 +88,7 @@ func jdnToGregorian(jdn int) (int, int, int) { return year, month, day } +// gregorianToJalali converts a Gregorian date to Jalali (Persian/Solar Hijri). func gregorianToJalali(gy, gm, gd int) (int, int, int) { jdn := gregorianToJDN(gy, gm, gd) days := jdn - jalaliEpochJDN @@ -116,6 +122,7 @@ func gregorianToJalali(gy, gm, gd int) (int, int, int) { return jy, jm, jd } +// jalaliToGregorian converts a Jalali (Persian/Solar Hijri) date to Gregorian. func jalaliToGregorian(jy, jm, jd int) (int, int, int) { days := 0 for y := 1; y < jy; y++ { @@ -133,6 +140,7 @@ func jalaliToGregorian(jy, jm, jd int) (int, int, int) { return jdnToGregorian(jdn) } +// hijriToGregorian converts a Hijri (Islamic) date to Gregorian. func hijriToGregorian(hy, hm, hd int) (int, int, int) { days := (hy-1)*354 + (hy-1)*11/30 for m := 1; m < hm; m++ { @@ -152,6 +160,7 @@ func hijriToGregorian(hy, hm, hd int) (int, int, int) { return year, month, day } +// gregorianToHijri converts a Gregorian date to Hijri (Islamic). func gregorianToHijri(gy, gm, gd int) (int, int, int) { jdn := gregorianToJDN(gy, gm, gd) days := jdn - 1948440 @@ -191,17 +200,20 @@ func dayOfWeekGregorian(gy, gm, gd int) int { return (gd + (13*(gm+1))/5 + gy + gy/4 - gy/100 + gy/400) % 7 } +// calDay represents a single day cell in a calendar month view. type calDay struct { dayNum int date string // YYYY-MM-DD gregorian key } +// calMonth represents a rendered calendar month with title, weekday headers, and day cells. type calMonth struct { title string weekDays []string days []calDay } +// buildCalendarMonth builds a calMonth struct for the given calendar type, year and month. func buildCalendarMonth(cal string, year, month int) calMonth { var title string var weekDays []string @@ -273,12 +285,14 @@ func buildCalendarMonth(cal string, year, month int) calMonth { return calMonth{title: title, weekDays: weekDays, days: days} } +// parseGregorianDateKey parses a "YYYY-MM-DD" string into year, month, day integers. func parseGregorianDateKey(key string) (int, int, int) { var y, m, d int fmt.Sscanf(key, "%04d-%02d-%02d", &y, &m, &d) return y, m, d } +// formatDateForCalendar converts a Gregorian YYYY-MM-DD date string into the target calendar representation. func formatDateForCalendar(date, calType string) string { y, m, d := parseGregorianDateKey(date) switch calType { diff --git a/internal/bot/export.go b/internal/bot/export.go index 5466779..4c8abc6 100644 --- a/internal/bot/export.go +++ b/internal/bot/export.go @@ -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) + } + } +} diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index c698f26..fb984fe 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -1,11 +1,8 @@ package bot import ( - "database/sql" - "errors" "fmt" "log/slog" - "strconv" "sync" "time" @@ -16,6 +13,7 @@ import ( const rateLimitInterval = 2 * time.Second +// Handler holds the bot instance, database store, user allowlist, and rate-limit state. type Handler struct { Bot *tgbotapi.BotAPI DB *db.Store @@ -24,6 +22,7 @@ type Handler struct { mu sync.Mutex } +// NewHandler creates a Handler and starts the rate-limit cleanup goroutine. func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, allowed map[int64]bool) *Handler { h := &Handler{ Bot: bot, @@ -35,6 +34,7 @@ func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, allowed map[int64]bool) * return h } +// periodicCleanup runs in the background, purging stale rate-limit entries every 10 seconds. func (h *Handler) periodicCleanup() { for { time.Sleep(10 * time.Second) @@ -51,6 +51,7 @@ func (h *Handler) periodicCleanup() { type actionFunc func(int64) (string, error) +// isRateLimited returns true if the user has sent a message within the last rateLimitInterval. func (h *Handler) isRateLimited(userID int64) bool { h.mu.Lock() defer h.mu.Unlock() @@ -62,6 +63,7 @@ func (h *Handler) isRateLimited(userID int64) bool { return false } +// handleActionMsg runs an action function and sends the result as a new message. func (h *Handler) handleActionMsg(msg *tgbotapi.Message, fn actionFunc) { text, err := fn(msg.Chat.ID) if err != nil { @@ -73,6 +75,7 @@ func (h *Handler) handleActionMsg(msg *tgbotapi.Message, fn actionFunc) { h.Bot.Send(reply) } +// handleActionCallback runs an action function and edits the callback message with the result. func (h *Handler) handleActionCallback(chatID int64, msgID int, callbackID string, fn actionFunc) { defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) text, err := fn(chatID) @@ -85,6 +88,7 @@ func (h *Handler) handleActionCallback(chatID int64, msgID int, callbackID strin h.Bot.Send(edit) } +// HandleMessage routes incoming text messages to the appropriate handler. func (h *Handler) HandleMessage(update tgbotapi.Update) { msg := update.Message if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] { @@ -132,6 +136,7 @@ func (h *Handler) HandleMessage(update tgbotapi.Update) { } } +// HandleCallback routes inline callback queries to the appropriate handler. func (h *Handler) HandleCallback(update tgbotapi.Update) { cb := update.CallbackQuery if len(h.AllowedUsers) > 0 && !h.AllowedUsers[cb.Message.Chat.ID] { @@ -185,6 +190,7 @@ func (h *Handler) HandleCallback(update tgbotapi.Update) { case "back_settings": h.backToSettings(chatID, msgID, cb.ID) default: + // Delegate prefixed callbacks to the appropriate sub-handler if len(cb.Data) > 9 && cb.Data[:9] == "worktype_" { h.selectWorkType(chatID, msgID, cb.ID, cb.Data[9:]) return @@ -205,10 +211,12 @@ func (h *Handler) HandleCallback(update tgbotapi.Update) { } } +// getOrCreateUser is a shortcut that delegates to the store. func (h *Handler) getOrCreateUser(chatID int64) (*db.User, error) { return h.DB.GetOrCreateUser(chatID) } +// getUserToday fetches the user and today's day record, using the user's timezone. func (h *Handler) getUserToday(chatID int64) (*db.User, *db.Day, error) { user, err := h.getOrCreateUser(chatID) if err != nil { @@ -226,178 +234,7 @@ func (h *Handler) getUserToday(chatID int64) (*db.User, *db.Day, error) { return user, day, nil } -func (h *Handler) clockIn(chatID int64) (string, error) { - user, day, err := h.getUserToday(chatID) - if err != nil { - return "", err - } - - last, err := h.DB.GetLastEvent(user.ID) - if err != nil && err != sql.ErrNoRows { - return "", err - } - - if last != nil && last.EventType == "in" { - loc := loadLocation(user.Timezone) - now := time.Now().In(loc) - lastTime := time.Unix(last.OccurredAt, 0).In(loc) - if lastTime.Format("2006-01-02") == now.Format("2006-01-02") { - return "", errors.New("Already clocked in") - } - } - - loc := loadLocation(user.Timezone) - now := time.Now().In(loc) - wt := day.CurrentWorkTypeID - if err := h.DB.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil { - return "", err - } - return fmt.Sprintf("Clocked in at %s", now.Format("15:04")), nil -} - -func (h *Handler) clockOut(chatID int64) (string, error) { - user, day, err := h.getUserToday(chatID) - if err != nil { - return "", err - } - last, err := h.DB.GetLastEvent(user.ID) - if err != nil { - return "", err - } - if last == nil { - return "", errors.New("No clock-in found. Start with /clockin first") - } - if last.EventType == "out" { - return "", errors.New("Already clocked out") - } - loc := loadLocation(user.Timezone) - now := time.Now().In(loc) - if err := h.DB.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil { - return "", err - } - return fmt.Sprintf("Clocked out at %s", now.Format("15:04")), nil -} - -func (h *Handler) dayOff(chatID int64) (string, error) { - user, day, err := h.getUserToday(chatID) - if err != nil { - return "", err - } - if day.IsDayOff { - if err := h.DB.RemoveDayOff(user.ID, day.Date); err != nil { - return "", err - } - return "Day off removed", nil - } - if err := h.DB.SetDayOff(user.ID, day.Date, ""); err != nil { - return "", err - } - return "Today marked as day off", nil -} - -func (h *Handler) report(chatID int64) (string, error) { - user, day, err := h.getUserToday(chatID) - if err != nil { - return "", err - } - events, err := h.DB.EventsForDayByDayID(day.ID) - if err != nil { - return "", err - } - return h.buildDailyReport(user, day, events), nil -} - -func (h *Handler) toggleReport(chatID int64) (string, error) { - user, err := h.getOrCreateUser(chatID) - if err != nil { - return "", err - } - user.ReportEnabled = !user.ReportEnabled - if err := h.DB.UpdateUser(user); err != nil { - return "", err - } - if user.ReportEnabled { - return "Daily report enabled", nil - } - return "Daily report disabled", nil -} - -func (h *Handler) handleSetReportTime(msg *tgbotapi.Message) { - arg := msg.CommandArguments() - if arg == "" { - user, err := h.getOrCreateUser(msg.Chat.ID) - if err != nil { - h.sendText(msg.Chat.ID, "Error loading profile") - return - } - h.sendText(msg.Chat.ID, fmt.Sprintf("Current report time: %s\nUsage: /setreporttime HH:MM (24-hour)", user.ReportTime)) - return - } - if len(arg) != 5 || arg[2] != ':' { - h.sendText(msg.Chat.ID, "Invalid format. Usage: /setreporttime HH:MM (24-hour)") - return - } - hours, err1 := strconv.Atoi(arg[:2]) - mins, err2 := strconv.Atoi(arg[3:]) - if err1 != nil || err2 != nil || hours < 0 || hours > 23 || mins < 0 || mins > 59 { - h.sendText(msg.Chat.ID, "Invalid time. Usage: /setreporttime HH:MM (24-hour)") - return - } - user, err := h.getOrCreateUser(msg.Chat.ID) - if err != nil { - h.sendText(msg.Chat.ID, "Error updating report time") - return - } - user.ReportTime = arg - if err := h.DB.UpdateUser(user); err != nil { - h.sendText(msg.Chat.ID, "Error updating report time") - return - } - h.sendText(msg.Chat.ID, fmt.Sprintf("Report time set to %s", arg)) -} - -func (h *Handler) handleAccentMsg(msg *tgbotapi.Message) { - user, err := h.getOrCreateUser(msg.Chat.ID) - if err != nil { - h.sendText(msg.Chat.ID, "Error loading profile") - return - } - text, kb := h.buildAccentKeyboard(user) - reply := tgbotapi.NewMessage(msg.Chat.ID, text) - reply.ReplyMarkup = &kb - h.Bot.Send(reply) -} - -func (h *Handler) handleSetTimezone(msg *tgbotapi.Message) { - arg := msg.CommandArguments() - if arg == "" { - user, err := h.getOrCreateUser(msg.Chat.ID) - if err != nil { - h.sendText(msg.Chat.ID, "Error loading profile") - return - } - h.sendText(msg.Chat.ID, fmt.Sprintf("Current timezone: %s\nUsage: /settimezone \nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone)) - return - } - loc, err := time.LoadLocation(arg) - if err != nil { - h.sendText(msg.Chat.ID, fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran, Europe/London", arg)) - return - } - user, err := h.getOrCreateUser(msg.Chat.ID) - if err != nil { - h.sendText(msg.Chat.ID, "Error loading profile") - return - } - user.Timezone = arg - if err := h.DB.UpdateUser(user); err != nil { - h.sendText(msg.Chat.ID, "Error saving timezone") - return - } - now := time.Now().In(loc) - h.sendText(msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", arg, now.Format("15:04"))) -} - +// handleStart shows the main menu with the user's current status. func (h *Handler) handleStart(msg *tgbotapi.Message) { user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { @@ -411,6 +248,7 @@ func (h *Handler) handleStart(msg *tgbotapi.Message) { h.Bot.Send(menu) } +// handleHelp shows the list of available commands. func (h *Handler) handleHelp(msg *tgbotapi.Message) { text := `Commands: /start - Show main menu @@ -432,6 +270,7 @@ Buttons on the main menu also work.` h.sendText(msg.Chat.ID, text) } +// handleWorkTypeMsg shows the work type picker for today. func (h *Handler) handleWorkTypeMsg(msg *tgbotapi.Message) { user, day, err := h.getUserToday(msg.Chat.ID) if err != nil { @@ -444,6 +283,7 @@ func (h *Handler) handleWorkTypeMsg(msg *tgbotapi.Message) { h.Bot.Send(reply) } +// workTypeCallback shows the work type picker (inline edit). func (h *Handler) workTypeCallback(chatID int64, msgID int, callbackID string) { defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) user, day, err := h.getUserToday(chatID) @@ -460,13 +300,13 @@ func (h *Handler) workTypeCallback(chatID int64, msgID int, callbackID string) { h.Bot.Send(edit) } +// buildWorkTypeKeyboard returns the work type selection keyboard. func (h *Handler) buildWorkTypeKeyboard(user *db.User, day *db.Day) (string, tgbotapi.InlineKeyboardMarkup) { wts, err := h.DB.GetWorkTypes() if err != nil || len(wts) == 0 { return "No work types available.", backKeyboard() } - text := "Select work type:" - var rows [][]tgbotapi.InlineKeyboardButton + rows := [][]tgbotapi.InlineKeyboardButton{} for _, wt := range wts { label := wt.Name if wt.ID == day.CurrentWorkTypeID { @@ -479,9 +319,10 @@ func (h *Handler) buildWorkTypeKeyboard(user *db.User, day *db.Day) (string, tgb rows = append(rows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), )) - return text, tgbotapi.NewInlineKeyboardMarkup(rows...) + return "Select work type:", tgbotapi.NewInlineKeyboardMarkup(rows...) } +// selectWorkType updates the day's work type from a callback. func (h *Handler) selectWorkType(chatID int64, msgID int, callbackID, idStr string) { defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) _, day, err := h.getUserToday(chatID) @@ -500,1009 +341,13 @@ func (h *Handler) selectWorkType(chatID int64, msgID int, callbackID, idStr stri if err := h.DB.UpdateDay(day); err != nil { return } - text := fmt.Sprintf("Work type set to: %s", wt.Name) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit := tgbotapi.NewEditMessageText(chatID, msgID, fmt.Sprintf("Work type set to: %s", wt.Name)) kb := backKeyboard() edit.ReplyMarkup = &kb h.Bot.Send(edit) } -func (h *Handler) settingsCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - text, kb := h.buildSettingsKeyboard(user) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) buildSettingsKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) { - reportStatus := "disabled" - if user.ReportEnabled { - reportStatus = "enabled" - } - var rows [][]tgbotapi.InlineKeyboardButton - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Timezone: %s", user.Timezone), "timezone"), - )) - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Accent: %s", user.ExportAccent), "accent"), - )) - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Calendar: %s", user.Calendar), "caltype"), - )) - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Report: %s", reportStatus), "reporttoggle"), - )) - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Report time: %s", user.ReportTime), "reporttime"), - )) - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("History", "history"), - )) - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), - )) - return "Settings:", tgbotapi.NewInlineKeyboardMarkup(rows...) -} - -func (h *Handler) accentCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - text, kb := h.buildAccentKeyboard(user) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) timezoneCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - text := fmt.Sprintf("Current timezone: %s\n\nUse /settimezone to change it.\nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone) - kb := tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), - ), - ) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) buildAccentKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) { - accents := []struct { - name string - color string - }{ - {"ocean", "Blue"}, - {"beach", "Warm"}, - {"rose", "Pink"}, - {"catppuccin", "Purple"}, - } - var rows [][]tgbotapi.InlineKeyboardButton - for _, a := range accents { - label := fmt.Sprintf("%s (%s)", a.name, a.color) - if a.name == user.ExportAccent { - label = "> " + label - } - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(label, "accent_"+a.name), - )) - } - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), - )) - return "Select accent color:", tgbotapi.NewInlineKeyboardMarkup(rows...) -} - -func (h *Handler) selectAccent(chatID int64, msgID int, callbackID, name string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - valid := map[string]bool{"ocean": true, "beach": true, "rose": true, "catppuccin": true} - if !valid[name] { - return - } - user.ExportAccent = name - if err := h.DB.UpdateUser(user); err != nil { - return - } - text := fmt.Sprintf("Accent set to: %s", name) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - kb := tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), - ), - ) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) reportTimeCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - text := fmt.Sprintf("Current report time: %s\nUse /setreporttime HH:MM to change it.", user.ReportTime) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - kb := tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), - ), - ) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) backToSettings(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - text, kb := h.buildSettingsKeyboard(user) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) calTypeCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - text, kb := h.buildCalendarKeyboard(user) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) buildCalendarKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) { - cals := []string{"gregorian", "jalali", "hijri"} - calLabels := map[string]string{ - "gregorian": "Gregorian", - "jalali": "Jalali", - "hijri": "Hijri", - } - var rows [][]tgbotapi.InlineKeyboardButton - for _, c := range cals { - label := calLabels[c] - if c == user.Calendar { - label = "> " + label - } - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(label, "caltype_"+c), - )) - } - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), - )) - return "Select calendar type:", tgbotapi.NewInlineKeyboardMarkup(rows...) -} - -func (h *Handler) selectCalendar(chatID int64, msgID int, callbackID, name string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true} - if !valid[name] { - return - } - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - user.Calendar = name - if err := h.DB.UpdateUser(user); err != nil { - return - } - text, kb := h.buildSettingsKeyboard(user) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) handleHistoryMsg(msg *tgbotapi.Message) { - h.sendCalendar(msg.Chat.ID, 0, 0, 0) -} - -func (h *Handler) historyCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - h.editCalendar(chatID, msgID, 0, 0) -} - -func (h *Handler) handleEditMsg(msg *tgbotapi.Message) { - date := msg.CommandArguments() - if date == "" { - h.sendText(msg.Chat.ID, "Usage: /edit YYYY-MM-DD (your calendar type)") - return - } - if len(date) != 10 || date[4] != '-' || date[7] != '-' { - h.sendText(msg.Chat.ID, "Invalid date. Use: /edit YYYY-MM-DD (your calendar type)") - return - } - _, m, d := parseGregorianDateKey(date) - if m < 1 || m > 12 || d < 1 || d > 31 { - h.sendText(msg.Chat.ID, "Invalid date.") - return - } - user, err := h.getOrCreateUser(msg.Chat.ID) - if err != nil { - h.sendText(msg.Chat.ID, "Error loading profile") - return - } - loc := loadLocation(user.Timezone) - date = userDateToGregorian(date, user.Calendar) - h.sendDayView(msg.Chat.ID, 0, user, date, loc, true) -} - -func (h *Handler) handleHistoryCallback(chatID int64, msgID int, callbackID, data string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - loc := loadLocation(user.Timezone) - - if data == "history" { - h.editCalendar(chatID, msgID, 0, 0) - return - } - - var y, m int - var date string - var eid int64 - - // cal_prev_YYYY_M - if n, _ := fmt.Sscanf(data, "cal_prev_%d_%d", &y, &m); n == 2 { - h.editCalendar(chatID, msgID, y, m) - return - } - // cal_next_YYYY_M - if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 { - h.editCalendar(chatID, msgID, y, m) - return - } - // cal_day_DATE - if n, _ := fmt.Sscanf(data, "cal_day_%s", &date); n == 1 && len(date) == 10 { - h.editDayView(chatID, msgID, user, date, loc) - return - } - // back_day_EVENTID -> go back to day view - if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 { - h.backToDayView(chatID, msgID, user, eid, loc) - return - } - // settype_EVENTID_WTID - var wtid int64 - if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 { - h.setEventWorkType(chatID, msgID, user, eid, wtid, loc) - return - } - // edit_type_EVENTID -> show work type picker - if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 { - h.editEventType(chatID, msgID, user, eid, loc) - return - } - // delete_EVENTID - if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 { - h.deleteEventPrompt(chatID, msgID, user, eid, loc) - return - } - // delconf_EVENTID - if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 { - h.deleteEventConfirm(chatID, msgID, user, eid, loc) - return - } - // edit_time_EVENTID -> show hour picker - if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 { - h.editEventTime(chatID, msgID, user, eid, loc) - return - } - // edittm_EVENTID_HH_MM -> set time - var hh, mm int - if n, _ := fmt.Sscanf(data, "edittm_%d_%d_%d", &eid, &hh, &mm); n == 3 { - h.editEventTimeSet(chatID, msgID, user, eid, hh, mm, loc) - return - } - // edittm_EVENTID_HH -> show minute picker - if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 { - h.editEventTimeMin(chatID, msgID, user, eid, hh, loc) - return - } -} - -func (h *Handler) sendCalendar(chatID int64, msgID int, year, month int) { - h.editCalendar(chatID, msgID, year, month) -} - -func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) { - user, err := h.getOrCreateUser(chatID) - if err != nil { - return - } - loc := loadLocation(user.Timezone) - now := time.Now().In(loc) - - if year == 0 || month == 0 { - switch user.Calendar { - case "jalali": - jy, jm, _ := gregorianToJalali(now.Year(), int(now.Month()), now.Day()) - year, month = jy, jm - case "hijri": - hy, hm, _ := gregorianToHijri(now.Year(), int(now.Month()), now.Day()) - year, month = hy, hm - default: - year, month = now.Year(), int(now.Month()) - } - } - - cal := user.Calendar - cm := buildCalendarMonth(cal, year, month) - - text := cm.title - todayKey := now.Format("2006-01-02") - - hasEvent := make(map[string]bool) - 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 - - var headerRow []tgbotapi.InlineKeyboardButton - for _, wn := range cm.weekDays { - headerRow = append(headerRow, tgbotapi.NewInlineKeyboardButtonData(wn, "noop")) - } - kbRows = append(kbRows, headerRow) - - var row []tgbotapi.InlineKeyboardButton - for i, d := range cm.days { - if d.dayNum == 0 { - row = append(row, tgbotapi.NewInlineKeyboardButtonData(" ", "noop")) - } else { - label := fmt.Sprintf("%d", d.dayNum) - if d.date == todayKey { - label = fmt.Sprintf("[%d]", d.dayNum) - } else if hasEvent[d.date] { - label = fmt.Sprintf("%d*", d.dayNum) - } - row = append(row, tgbotapi.NewInlineKeyboardButtonData(label, "cal_day_"+d.date)) - } - if len(row) == 7 || i == len(cm.days)-1 { - kbRows = append(kbRows, row) - row = nil - } - } - - prevY, prevM, nextY, nextM := navMonth(year, month) - - navRow := tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("<", fmt.Sprintf("cal_prev_%d_%d", prevY, prevM)), - tgbotapi.NewInlineKeyboardButtonData("Today", "history"), - tgbotapi.NewInlineKeyboardButtonData(">", fmt.Sprintf("cal_next_%d_%d", nextY, nextM)), - ) - kbRows = append(kbRows, navRow) - - kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), - )) - - kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) - - h.sendOrEdit(chatID, msgID, text, &kb) -} - -func (h *Handler) editDayView(chatID int64, msgID int, user *db.User, date string, loc *time.Location) { - h.sendDayView(chatID, msgID, user, date, loc, false) -} - -func (h *Handler) sendDayView(chatID int64, msgID int, user *db.User, date string, loc *time.Location, isNewMsg bool) { - events, err := h.DB.EventsForDayByDate(user.ID, date) - if err != nil { - if isNewMsg { - h.sendText(chatID, "Error loading events") - } - return - } - - text := fmt.Sprintf("Date: %s", formatDateForCalendar(date, user.Calendar)) - if len(events) == 0 { - text += "\nNo events for this day." - kb := tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"), - ), - ) - h.sendOrEdit(chatID, msgID, text, &kb) - return - } - - text += "\n\nEvents:" - var kbRows [][]tgbotapi.InlineKeyboardButton - for _, e := range events { - t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04") - label := "IN" - if e.EventType == "out" { - label = "OUT" - } - wt := "" - if e.WorkTypeID != nil { - if wtObj, err := h.DB.GetWorkType(*e.WorkTypeID); err == nil { - wt = " [" + wtObj.Name + "]" - } - } - note := "" - if e.Note != "" { - note = " (" + e.Note + ")" - } - text += fmt.Sprintf("\n%s %s%s%s", label, t, wt, note) - - kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Time %s", t), fmt.Sprintf("edit_time_%d", e.ID)), - tgbotapi.NewInlineKeyboardButtonData("Type", fmt.Sprintf("edit_type_%d", e.ID)), - tgbotapi.NewInlineKeyboardButtonData("DEL", fmt.Sprintf("delete_%d", e.ID)), - )) - } - - kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"), - )) - kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) - - h.sendOrEdit(chatID, msgID, text, &kb) -} - -func (h *Handler) editEventType(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { - wts, err := h.DB.GetWorkTypes() - if err != nil { - return - } - text := "Select new work type:" - var kbRows [][]tgbotapi.InlineKeyboardButton - for _, wt := range wts { - kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(wt.Name, fmt.Sprintf("settype_%d_%d", eventID, wt.ID)), - )) - } - kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("back_day_%d", eventID)), - )) - kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) setEventWorkType(chatID int64, msgID int, user *db.User, eventID, workTypeID int64, loc *time.Location) { - h.DB.DB().Exec("UPDATE events SET work_type_id=? WHERE id=?", workTypeID, eventID) - event, err := h.getEventByID(user.ID, eventID) - if err != nil { - return - } - t := time.Unix(event.OccurredAt, 0).In(loc) - date := t.Format("2006-01-02") - h.sendDayView(chatID, msgID, user, date, loc, false) -} - -func (h *Handler) editEventTime(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { - text := "Select hour:" - var kbRows [][]tgbotapi.InlineKeyboardButton - var hourRow []tgbotapi.InlineKeyboardButton - for hh := 0; hh < 24; hh++ { - hourRow = append(hourRow, tgbotapi.NewInlineKeyboardButtonData( - fmt.Sprintf("%02d", hh), - fmt.Sprintf("edittm_%d_%02d", eventID, hh), - )) - if len(hourRow) == 6 || hh == 23 { - kbRows = append(kbRows, hourRow) - hourRow = nil - } - } - kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("back_day_%d", eventID)), - )) - kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) editEventTimeMin(chatID int64, msgID int, user *db.User, eventID int64, hh int, loc *time.Location) { - text := fmt.Sprintf("Select minute for hour %02d:", hh) - var kbRows [][]tgbotapi.InlineKeyboardButton - var minRow []tgbotapi.InlineKeyboardButton - minutes := []int{0, 15, 30, 45} - for _, mm := range minutes { - minRow = append(minRow, tgbotapi.NewInlineKeyboardButtonData( - fmt.Sprintf("%02d", mm), - fmt.Sprintf("edittm_%d_%02d_%02d", eventID, hh, mm), - )) - } - kbRows = append(kbRows, minRow) - kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("edit_time_%d", eventID)), - )) - kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) editEventTimeSet(chatID int64, msgID int, user *db.User, eventID int64, hh, mm int, loc *time.Location) { - event, err := h.getEventByID(user.ID, eventID) - if err != nil { - return - } - t := time.Unix(event.OccurredAt, 0).In(loc) - newTime := time.Date(t.Year(), t.Month(), t.Day(), hh, mm, 0, 0, loc) - date := t.Format("2006-01-02") - - // Check for overlaps with adjacent events - events, err := h.DB.EventsForDayByDate(user.ID, date) - if err == nil { - for _, e := range events { - if e.ID == eventID { - continue - } - eTime := time.Unix(e.OccurredAt, 0) - if newTime.Unix() == eTime.Unix() { - h.acknowledgeCallback(chatID, msgID) - h.sendDayView(chatID, msgID, user, date, loc, false) - return - } - } - type eventPos struct { - id int64 - typ string - t int64 - } - var sorted []eventPos - for _, e := range events { - et := e.OccurredAt - if e.ID == eventID { - et = newTime.Unix() - } - sorted = append(sorted, eventPos{e.ID, e.EventType, et}) - } - for i := 0; i < len(sorted); i++ { - for j := i + 1; j < len(sorted); j++ { - if sorted[j].t < sorted[i].t || (sorted[j].t == sorted[i].t && sorted[j].id < sorted[i].id) { - sorted[i], sorted[j] = sorted[j], sorted[i] - } - } - } - for i := 1; i < len(sorted); i++ { - if sorted[i].typ == sorted[i-1].typ { - text := "Edit rejected: overlapping events. Two consecutive events must be different types (in/out)." - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - h.Bot.Send(edit) - return - } - } - } - - h.DB.DB().Exec("UPDATE events SET occurred_at=? WHERE id=?", newTime.Unix(), eventID) - h.sendDayView(chatID, msgID, user, date, loc, false) -} - -func (h *Handler) acknowledgeCallback(chatID int64, msgID int) { - h.Bot.Request(tgbotapi.NewCallback(fmt.Sprintf("cb_%d_%d", chatID, msgID), "")) -} - -func (h *Handler) deleteEventPrompt(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { - text := "Delete this event?" - kbRows := tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Yes, DEL", fmt.Sprintf("delconf_%d", eventID)), - tgbotapi.NewInlineKeyboardButtonData("Cancel", fmt.Sprintf("back_day_%d", eventID)), - ), - ) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kbRows - h.Bot.Send(edit) -} - -func (h *Handler) deleteEventConfirm(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { - event, err := h.getEventByID(user.ID, eventID) - if err != nil { - return - } - t := time.Unix(event.OccurredAt, 0).In(loc) - date := t.Format("2006-01-02") - - h.DB.DB().Exec("DELETE FROM events WHERE id=?", eventID) - - // Check if remaining events need repair - events, err := h.DB.EventsForDayByDate(user.ID, date) - if err == nil && len(events) > 1 { - var hasIssue bool - for i := 1; i < len(events); i++ { - if events[i].EventType == events[i-1].EventType { - hasIssue = true - break - } - } - if hasIssue { - h.sendText(chatID, "Warning: Timeline has consecutive events of same type after deletion. Timeline requires manual repair.") - } - } - - h.sendDayView(chatID, msgID, user, date, loc, false) -} - -func (h *Handler) backToDayView(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { - event, err := h.getEventByID(user.ID, eventID) - if err != nil { - return - } - t := time.Unix(event.OccurredAt, 0).In(loc) - date := t.Format("2006-01-02") - h.sendDayView(chatID, msgID, user, date, loc, false) -} - -func (h *Handler) getEventByID(userID, eventID int64) (*db.Event, error) { - row := h.DB.DB().QueryRow( - "SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at FROM events WHERE id=? AND user_id=?", - eventID, userID, - ) - var e db.Event - var wt sql.NullInt64 - if err := row.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { - return nil, err - } - if wt.Valid { - e.WorkTypeID = &wt.Int64 - } - return &e, nil -} - -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) - - // Default to current month in user's calendar - 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()) - } - - // Parse optional YYYY-MM argument (in user's calendar) - 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) -} - -func (h *Handler) sendExportDirect(chatID int64, user *db.User, calYear, calMonth int) { - // Check if clocked in - 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) - } -} - -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) -} - -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 { - return - } - - var y, m int - - if n, _ := fmt.Sscanf(data, "exp_prev_%d_%d", &y, &m); n == 2 { - h.editExportMonthPicker(chatID, msgID, y, m, user) - return - } - if n, _ := fmt.Sscanf(data, "exp_next_%d_%d", &y, &m); n == 2 { - 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 full Gregorian range - 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) - } - return - } -} - -func (h *Handler) buildStatusText(chatID int64) string { - user, day, err := h.getUserToday(chatID) - if err != nil { - return "Welcome. Choose an action:" - } - - text := fmt.Sprintf("Today: %s", formatDateForCalendar(day.Date, user.Calendar)) - - if day.IsDayOff { - text += "\nDay Off" - return text + "\n\nChoose an action:" - } - - wt, _ := h.DB.GetWorkType(day.CurrentWorkTypeID) - if wt != nil { - text += fmt.Sprintf("\nWork type: %s", wt.Name) - } - - last, err := h.DB.GetLastEvent(user.ID) - if err == nil && last != nil && last.EventType == "in" { - loc := loadLocation(user.Timezone) - t := time.Unix(last.OccurredAt, 0).In(loc).Format("15:04") - text += fmt.Sprintf("\nStatus: working since %s", t) - } else { - text += "\nStatus: not working" - } - - events, err := h.DB.EventsForDayByDayID(day.ID) - if err == nil && len(events) > 0 { - loc := loadLocation(user.Timezone) - now := time.Now().In(loc) - y, m, d := now.Date() - dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix() - dayEnd := now.Unix() - totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) - text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds)) - } - - text += "\n\nChoose an action:" - return text -} - -func (h *Handler) backToMenu(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - text := h.buildStatusText(chatID) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - kb := mainKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event) string { - loc, err := time.LoadLocation(user.Timezone) - if err != nil { - loc = time.UTC - } - - if len(events) == 0 { - if day.IsDayOff { - return fmt.Sprintf("%s - Day Off", formatDateForCalendar(day.Date, user.Calendar)) - } - return fmt.Sprintf("%s - No events recorded.", formatDateForCalendar(day.Date, user.Calendar)) - } - - 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 { - lines += "\nDay Off: Yes" - } - lines += "\n\n" - - for _, e := range events { - t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04") - label := "IN" - if e.EventType == "out" { - label = "OUT" - } - suffix := "" - if e.WorkTypeID != nil { - if wtObj, err := h.DB.GetWorkType(*e.WorkTypeID); err == nil { - suffix = " [" + wtObj.Name + "]" - } - } - if e.Note != "" { - suffix += " (" + e.Note + ")" - } - lines += fmt.Sprintf("%s %s%s\n", label, t, suffix) - } - - workStr := formatDuration(totals.TotalSeconds) - breakStr := formatDuration(totals.BreakSeconds) - lines += fmt.Sprintf("\nSummary:\n Work: %s\n Break: %s\n", workStr, breakStr) - - return lines -} - -func (h *Handler) SendDailyReport(userID int64, chatID int64) { - user, err := h.DB.GetUserByChatID(chatID) - if err != nil { - slog.Error("daily report: user not found", "user_id", userID) - return - } - loc, err := time.LoadLocation(user.Timezone) - if err != nil { - loc = time.UTC - } - now := time.Now().In(loc) - today := now.Format("2006-01-02") - - day, err := h.DB.GetOrCreateDay(user.ID, today) - if err != nil { - slog.Error("daily report: get day", "error", err) - return - } - events, err := h.DB.EventsForDayByDayID(day.ID) - if err != nil { - slog.Error("daily report: get events", "error", err) - return - } - text := h.buildDailyReport(user, day, events) - reply := tgbotapi.NewMessage(chatID, text) - h.Bot.Send(reply) - h.DB.MarkReportSent(user.ID, today) -} - +// mainKeyboard returns the persistent inline menu. func mainKeyboard() tgbotapi.InlineKeyboardMarkup { return tgbotapi.NewInlineKeyboardMarkup( tgbotapi.NewInlineKeyboardRow( @@ -1523,6 +368,7 @@ func mainKeyboard() tgbotapi.InlineKeyboardMarkup { ) } +// backKeyboard returns a simple "Back to Menu" button. func backKeyboard() tgbotapi.InlineKeyboardMarkup { return tgbotapi.NewInlineKeyboardMarkup( tgbotapi.NewInlineKeyboardRow( @@ -1531,6 +377,7 @@ func backKeyboard() tgbotapi.InlineKeyboardMarkup { ) } +// formatDuration formats seconds as a human-readable duration (e.g. "7h 30m"). func formatDuration(seconds int64) string { hours := seconds / 3600 mins := (seconds % 3600) / 60 @@ -1540,10 +387,12 @@ func formatDuration(seconds int64) string { return fmt.Sprintf("%dm", mins) } +// sendText sends a plain text message to the given chat. func (h *Handler) sendText(chatID int64, text string) { h.Bot.Send(tgbotapi.NewMessage(chatID, text)) } +// navMonth returns the (prevY, prevM, nextY, nextM) for month navigation. func navMonth(year, month int) (prevY, prevM, nextY, nextM int) { prevY, prevM = year, month-1 if prevM < 1 { @@ -1558,6 +407,7 @@ func navMonth(year, month int) (prevY, prevM, nextY, nextM int) { return } +// sendOrEdit sends a new message or edits an existing one depending on msgID. func (h *Handler) sendOrEdit(chatID int64, msgID int, text string, kb *tgbotapi.InlineKeyboardMarkup) { if msgID == 0 { msg := tgbotapi.NewMessage(chatID, text) diff --git a/internal/bot/report.go b/internal/bot/report.go new file mode 100644 index 0000000..d27e593 --- /dev/null +++ b/internal/bot/report.go @@ -0,0 +1,149 @@ +package bot + +import ( + "fmt" + "log/slog" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "worktimeBot/internal/db" +) + +// buildStatusText returns the main menu status text for the current user. +func (h *Handler) buildStatusText(chatID int64) string { + user, day, err := h.getUserToday(chatID) + if err != nil { + return "Welcome. Choose an action:" + } + + text := fmt.Sprintf("Today: %s", formatDateForCalendar(day.Date, user.Calendar)) + + if day.IsDayOff { + text += "\nDay Off" + return text + "\n\nChoose an action:" + } + + wt, _ := h.DB.GetWorkType(day.CurrentWorkTypeID) + if wt != nil { + text += fmt.Sprintf("\nWork type: %s", wt.Name) + } + + last, err := h.DB.GetLastEvent(user.ID) + if err == nil && last != nil && last.EventType == "in" { + loc := loadLocation(user.Timezone) + t := time.Unix(last.OccurredAt, 0).In(loc).Format("15:04") + text += fmt.Sprintf("\nStatus: working since %s", t) + } else { + text += "\nStatus: not working" + } + + events, err := h.DB.EventsForDayByDayID(day.ID) + if err == nil && len(events) > 0 { + loc := loadLocation(user.Timezone) + now := time.Now().In(loc) + y, m, d := now.Date() + dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix() + dayEnd := now.Unix() + totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) + text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds)) + } + + text += "\n\nChoose an action:" + return text +} + +// backToMenu returns to the main menu from any sub-menu. +func (h *Handler) backToMenu(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + text := h.buildStatusText(chatID) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + kb := mainKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +// buildDailyReport builds a formatted daily report string for a given user/day. +func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event) string { + loc, err := time.LoadLocation(user.Timezone) + if err != nil { + loc = time.UTC + } + + if len(events) == 0 { + if day.IsDayOff { + return fmt.Sprintf("%s - Day Off", formatDateForCalendar(day.Date, user.Calendar)) + } + return fmt.Sprintf("%s - No events recorded.", formatDateForCalendar(day.Date, user.Calendar)) + } + + 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 { + lines += "\nDay Off: Yes" + } + lines += "\n\n" + + for _, e := range events { + t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04") + label := "IN" + if e.EventType == "out" { + label = "OUT" + } + suffix := "" + if e.WorkTypeID != nil { + if wtObj, err := h.DB.GetWorkType(*e.WorkTypeID); err == nil { + suffix = " [" + wtObj.Name + "]" + } + } + if e.Note != "" { + suffix += " (" + e.Note + ")" + } + lines += fmt.Sprintf("%s %s%s\n", label, t, suffix) + } + + lines += fmt.Sprintf("\nSummary:\n Work: %s\n Break: %s\n", + formatDuration(totals.TotalSeconds), + formatDuration(totals.BreakSeconds)) + + return lines +} + +// SendDailyReport sends the automated daily report to the user. +func (h *Handler) SendDailyReport(userID int64, chatID int64) { + user, err := h.DB.GetUserByChatID(chatID) + if err != nil { + slog.Error("daily report: user not found", "user_id", userID) + return + } + loc, err := time.LoadLocation(user.Timezone) + if err != nil { + loc = time.UTC + } + now := time.Now().In(loc) + today := now.Format("2006-01-02") + + day, err := h.DB.GetOrCreateDay(user.ID, today) + if err != nil { + slog.Error("daily report: get day", "error", err) + return + } + events, err := h.DB.EventsForDayByDayID(day.ID) + if err != nil { + slog.Error("daily report: get events", "error", err) + return + } + text := h.buildDailyReport(user, day, events) + reply := tgbotapi.NewMessage(chatID, text) + h.Bot.Send(reply) + h.DB.MarkReportSent(user.ID, today) +} diff --git a/internal/bot/settings.go b/internal/bot/settings.go new file mode 100644 index 0000000..07829ba --- /dev/null +++ b/internal/bot/settings.go @@ -0,0 +1,308 @@ +package bot + +import ( + "fmt" + "strconv" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "worktimeBot/internal/db" +) + +// handleSetReportTime parses a HH:MM argument and updates the user's report time. +func (h *Handler) handleSetReportTime(msg *tgbotapi.Message) { + arg := msg.CommandArguments() + if arg == "" { + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + h.sendText(msg.Chat.ID, "Error loading profile") + return + } + h.sendText(msg.Chat.ID, fmt.Sprintf("Current report time: %s\nUsage: /setreporttime HH:MM (24-hour)", user.ReportTime)) + return + } + if len(arg) != 5 || arg[2] != ':' { + h.sendText(msg.Chat.ID, "Invalid format. Usage: /setreporttime HH:MM (24-hour)") + return + } + hours, err1 := strconv.Atoi(arg[:2]) + mins, err2 := strconv.Atoi(arg[3:]) + if err1 != nil || err2 != nil || hours < 0 || hours > 23 || mins < 0 || mins > 59 { + h.sendText(msg.Chat.ID, "Invalid time. Usage: /setreporttime HH:MM (24-hour)") + return + } + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + h.sendText(msg.Chat.ID, "Error updating report time") + return + } + user.ReportTime = arg + if err := h.DB.UpdateUser(user); err != nil { + h.sendText(msg.Chat.ID, "Error updating report time") + return + } + h.sendText(msg.Chat.ID, fmt.Sprintf("Report time set to %s", arg)) +} + +// handleAccentMsg shows the accent color picker. +func (h *Handler) handleAccentMsg(msg *tgbotapi.Message) { + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + h.sendText(msg.Chat.ID, "Error loading profile") + return + } + text, kb := h.buildAccentKeyboard(user) + reply := tgbotapi.NewMessage(msg.Chat.ID, text) + reply.ReplyMarkup = &kb + h.Bot.Send(reply) +} + +// handleSetTimezone validates and updates the user's IANA timezone. +func (h *Handler) handleSetTimezone(msg *tgbotapi.Message) { + arg := msg.CommandArguments() + if arg == "" { + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + h.sendText(msg.Chat.ID, "Error loading profile") + return + } + h.sendText(msg.Chat.ID, fmt.Sprintf("Current timezone: %s\nUsage: /settimezone \nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone)) + return + } + loc, err := time.LoadLocation(arg) + if err != nil { + h.sendText(msg.Chat.ID, fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran, Europe/London", arg)) + return + } + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + h.sendText(msg.Chat.ID, "Error loading profile") + return + } + user.Timezone = arg + if err := h.DB.UpdateUser(user); err != nil { + h.sendText(msg.Chat.ID, "Error saving timezone") + return + } + now := time.Now().In(loc) + h.sendText(msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", arg, now.Format("15:04"))) +} + +// settingsCallback shows the settings inline menu. +func (h *Handler) settingsCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + text, kb := h.buildSettingsKeyboard(user) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +// buildSettingsKeyboard returns the settings menu text and inline keyboard. +func (h *Handler) buildSettingsKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) { + reportStatus := "disabled" + if user.ReportEnabled { + reportStatus = "enabled" + } + rows := [][]tgbotapi.InlineKeyboardButton{ + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Timezone: %s", user.Timezone), "timezone"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Accent: %s", user.ExportAccent), "accent"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Calendar: %s", user.Calendar), "caltype"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Report: %s", reportStatus), "reporttoggle"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Report time: %s", user.ReportTime), "reporttime"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("History", "history"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), + ), + } + return "Settings:", tgbotapi.NewInlineKeyboardMarkup(rows...) +} + +// accentCallback shows the accent color picker (inline edit). +func (h *Handler) accentCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + text, kb := h.buildAccentKeyboard(user) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +// timezoneCallback shows the current timezone and instructions to change it. +func (h *Handler) timezoneCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + text := fmt.Sprintf("Current timezone: %s\n\nUse /settimezone to change it.\nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone) + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), + ), + ) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +// buildAccentKeyboard returns the accent color picker keyboard. +func (h *Handler) buildAccentKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) { + type accentOption struct { + name string + color string + } + accents := []accentOption{ + {"ocean", "Blue"}, + {"beach", "Warm"}, + {"rose", "Pink"}, + {"catppuccin", "Purple"}, + } + rows := [][]tgbotapi.InlineKeyboardButton{} + for _, a := range accents { + label := fmt.Sprintf("%s (%s)", a.name, a.color) + if a.name == user.ExportAccent { + label = "> " + label + } + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(label, "accent_"+a.name), + )) + } + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), + )) + return "Select accent color:", tgbotapi.NewInlineKeyboardMarkup(rows...) +} + +// selectAccent sets the user's export accent color after validation. +func (h *Handler) selectAccent(chatID int64, msgID int, callbackID, name string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + valid := map[string]bool{"ocean": true, "beach": true, "rose": true, "catppuccin": true} + if !valid[name] { + return + } + user.ExportAccent = name + if err := h.DB.UpdateUser(user); err != nil { + return + } + edit := tgbotapi.NewEditMessageText(chatID, msgID, fmt.Sprintf("Accent set to: %s", name)) + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), + ), + ) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +// reportTimeCallback shows the current report time setting. +func (h *Handler) reportTimeCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + text := fmt.Sprintf("Current report time: %s\nUse /setreporttime HH:MM to change it.", user.ReportTime) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), + ), + ) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +// backToSettings returns to the main settings menu. +func (h *Handler) backToSettings(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + text, kb := h.buildSettingsKeyboard(user) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +// calTypeCallback shows the calendar type selector. +func (h *Handler) calTypeCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + text, kb := h.buildCalendarKeyboard(user) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +// buildCalendarKeyboard returns the calendar type selector keyboard. +func (h *Handler) buildCalendarKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) { + cals := []string{"gregorian", "jalali", "hijri"} + calLabels := map[string]string{ + "gregorian": "Gregorian", + "jalali": "Jalali", + "hijri": "Hijri", + } + rows := [][]tgbotapi.InlineKeyboardButton{} + for _, c := range cals { + label := calLabels[c] + if c == user.Calendar { + label = "> " + label + } + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(label, "caltype_"+c), + )) + } + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), + )) + return "Select calendar type:", tgbotapi.NewInlineKeyboardMarkup(rows...) +} + +// selectCalendar sets the user's calendar after validation. +func (h *Handler) selectCalendar(chatID int64, msgID int, callbackID, name string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true} + if !valid[name] { + return + } + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + user.Calendar = name + if err := h.DB.UpdateUser(user); err != nil { + return + } + text, kb := h.buildSettingsKeyboard(user) + 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 e15610a..6f6433c 100644 --- a/internal/bot/totals.go +++ b/internal/bot/totals.go @@ -1,3 +1,5 @@ +// Package bot implements the Telegram bot logic: message routing, time tracking, +// calendar rendering, Excel export, and inline editing of events. package bot import ( @@ -6,19 +8,27 @@ import ( "worktimeBot/internal/db" ) +// State represents the current tracking state during daily totals computation. type State int const ( + // StateIdle indicates no active work session. StateIdle State = iota + // StateWorking indicates an active work session. StateWorking + // StateOnBreak indicates an active break session. StateOnBreak ) +// DailyTotals holds the computed total work and break seconds for a single day. type DailyTotals struct { TotalSeconds int64 BreakSeconds int64 } +// ComputeDailyTotals calculates the total work and break time from a list of events. +// It sorts events chronologically, inserts synthetic start/end events when needed, +// and tracks state transitions between idle, working, and on-break. func ComputeDailyTotals(events []db.Event, minBreakThreshold int64, dayStart, dayEnd int64) DailyTotals { if len(events) == 0 { return DailyTotals{} diff --git a/internal/db/store.go b/internal/db/store.go index 6fe909f..cc87ebe 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -1,3 +1,4 @@ +// Package db provides the SQLite-backed data store for users, work types, days, and events. package db import ( @@ -20,10 +21,12 @@ func init() { } } +// Store wraps a SQLite database connection and provides methods for all data access. type Store struct { db *sql.DB } +// User represents a bot user with timezone, reporting preferences, and calendar type. type User struct { ID int64 ChatID int64 @@ -38,12 +41,14 @@ type User struct { UpdatedAt int64 } +// WorkType represents a type of work (e.g. "office", "remote") that can be assigned to a day. type WorkType struct { ID int64 Name string CreatedAt int64 } +// Day represents a single day entry for a user, including day-off status and work type. type Day struct { ID int64 UserID int64 @@ -56,6 +61,7 @@ type Day struct { UpdatedAt int64 } +// Event represents a single timestamped event ("in" or "out") for a user on a given day. type Event struct { ID int64 UserID int64 @@ -67,6 +73,7 @@ type Event struct { CreatedAt int64 } +// NewStore opens or creates the SQLite database at path, runs migrations, and returns a Store. func NewStore(path string) (*Store, error) { db, err := sql.Open(driverName, path) if err != nil { @@ -85,10 +92,12 @@ func NewStore(path string) (*Store, error) { return &Store{db: db}, nil } +// Close closes the underlying SQLite database connection. func (s *Store) Close() error { return s.db.Close() } +// DB returns the underlying *sql.DB for advanced use. func (s *Store) DB() *sql.DB { return s.db } @@ -295,6 +304,7 @@ func migrateSettings(db *sql.DB) { } } +// GetOrCreateUser retrieves a user by chat ID, creating a new record if none exists. func (s *Store) GetOrCreateUser(chatID int64) (*User, error) { _, err := s.db.Exec( "INSERT OR IGNORE INTO users (chat_id) VALUES (?)", @@ -306,6 +316,7 @@ func (s *Store) GetOrCreateUser(chatID int64) (*User, error) { return s.GetUserByChatID(chatID) } +// GetUserByChatID retrieves a single user by their Telegram chat ID. func (s *Store) GetUserByChatID(chatID int64) (*User, error) { row := s.db.QueryRow(` SELECT id, chat_id, timezone, is_active, report_enabled, report_time, @@ -327,6 +338,7 @@ func (s *Store) GetUserByChatID(chatID int64) (*User, error) { return &u, nil } +// UpdateUser persists all fields of the given user record. func (s *Store) UpdateUser(user *User) error { _, err := s.db.Exec( "UPDATE users SET timezone=?, is_active=?, report_enabled=?, report_time=?, last_report_date=?, export_accent=?, calendar=?, updated_at=unixepoch() WHERE id=?", @@ -335,6 +347,7 @@ func (s *Store) UpdateUser(user *User) error { return err } +// MarkReportSent records the last report date for a user to prevent duplicate daily reports. func (s *Store) MarkReportSent(userID int64, date string) error { _, err := s.db.Exec( "UPDATE users SET last_report_date=?, updated_at=unixepoch() WHERE id=?", @@ -343,6 +356,7 @@ func (s *Store) MarkReportSent(userID int64, date string) error { return err } +// GetAllUsers returns all users in the database. func (s *Store) GetAllUsers() ([]User, error) { rows, err := s.db.Query(` SELECT id, chat_id, timezone, is_active, report_enabled, report_time, @@ -372,6 +386,7 @@ func (s *Store) GetAllUsers() ([]User, error) { return users, rows.Err() } +// GetWorkTypes returns all work types ordered by ID. func (s *Store) GetWorkTypes() ([]WorkType, error) { rows, err := s.db.Query("SELECT id, name, created_at FROM work_types ORDER BY id ASC") if err != nil { @@ -389,6 +404,7 @@ func (s *Store) GetWorkTypes() ([]WorkType, error) { return wts, rows.Err() } +// GetWorkType returns a single work type by its ID. func (s *Store) GetWorkType(id int64) (*WorkType, error) { var wt WorkType err := s.db.QueryRow("SELECT id, name, created_at FROM work_types WHERE id=?", id).Scan(&wt.ID, &wt.Name, &wt.CreatedAt) @@ -398,6 +414,7 @@ func (s *Store) GetWorkType(id int64) (*WorkType, error) { return &wt, nil } +// GetOrCreateDay retrieves a day record for a user/date, creating one if it does not exist. func (s *Store) GetOrCreateDay(userID int64, date string) (*Day, error) { _, err := s.db.Exec( "INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)", @@ -409,6 +426,7 @@ func (s *Store) GetOrCreateDay(userID int64, date string) (*Day, error) { return s.GetDay(userID, date) } +// GetDay returns a single day record for the given user and date. func (s *Store) GetDay(userID int64, date string) (*Day, error) { row := s.db.QueryRow(` SELECT id, user_id, date, is_day_off, day_off_reason, @@ -426,6 +444,7 @@ func (s *Store) GetDay(userID int64, date string) (*Day, error) { return &d, nil } +// UpdateDay persists the given day record's editable fields. func (s *Store) UpdateDay(day *Day) error { _, err := s.db.Exec( "UPDATE days SET is_day_off=?, day_off_reason=?, current_work_type_id=?, min_break_threshold=?, updated_at=unixepoch() WHERE id=?", @@ -434,6 +453,7 @@ func (s *Store) UpdateDay(day *Day) error { return err } +// SetDayOff marks a user's day as a day off with an optional reason. func (s *Store) SetDayOff(userID int64, date string, reason string) error { _, err := s.GetOrCreateDay(userID, date) if err != nil { @@ -446,6 +466,7 @@ func (s *Store) SetDayOff(userID int64, date string, reason string) error { return err } +// RemoveDayOff clears the day-off flag for a user on the given date. func (s *Store) RemoveDayOff(userID int64, date string) error { _, err := s.db.Exec( "UPDATE days SET is_day_off=0, day_off_reason='', updated_at=unixepoch() WHERE user_id=? AND date=?", @@ -454,6 +475,7 @@ func (s *Store) RemoveDayOff(userID int64, date string) error { return err } +// IsDayOff checks whether the given date is a day off for the user. func (s *Store) IsDayOff(userID int64, date string) (bool, error) { var isOff bool err := s.db.QueryRow( @@ -469,6 +491,7 @@ func (s *Store) IsDayOff(userID int64, date string) (bool, error) { return isOff, nil } +// GetUserDaysInRange returns all day records for a user within a date range (inclusive). func (s *Store) GetUserDaysInRange(userID int64, startDate, endDate string) ([]Day, error) { rows, err := s.db.Query(` SELECT id, user_id, date, is_day_off, day_off_reason, @@ -495,6 +518,7 @@ func (s *Store) GetUserDaysInRange(userID int64, startDate, endDate string) ([]D return days, rows.Err() } +// CreateEvent inserts a new event record (typically "in" or "out"). func (s *Store) CreateEvent(userID int64, dayID int64, eventType string, workTypeID *int64, occurredAt int64, note string) error { _, err := s.db.Exec( "INSERT INTO events (user_id, day_id, event_type, work_type_id, occurred_at, note) VALUES (?, ?, ?, ?, ?, ?)", @@ -503,6 +527,7 @@ func (s *Store) CreateEvent(userID int64, dayID int64, eventType string, workTyp return err } +// GetLastEvent returns the most recent event for the user, ordered by occurrence time. func (s *Store) GetLastEvent(userID int64) (*Event, error) { row, err := s.db.Query(` SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at @@ -529,6 +554,7 @@ func (s *Store) GetLastEvent(userID int64) (*Event, error) { return &e, nil } +// EventsForDayByDate returns all events for a user on a specific date, ordered by occurrence. func (s *Store) EventsForDayByDate(userID int64, date string) ([]Event, error) { rows, err := s.db.Query(` SELECT e.id, e.user_id, e.day_id, e.event_type, e.work_type_id, e.occurred_at, e.note, e.created_at @@ -556,6 +582,7 @@ func (s *Store) EventsForDayByDate(userID int64, date string) ([]Event, error) { return events, rows.Err() } +// EventsForDayByDayID returns all events for a day record, ordered by occurrence. func (s *Store) EventsForDayByDayID(dayID int64) ([]Event, error) { rows, err := s.db.Query(` SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at