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 }