package bot import ( "context" "database/sql" "fmt" "strconv" "strings" "time" bot "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" "worktimeBot/internal/db" ) // handleHistoryMsg shows the calendar view (new message). func (h *Handler) handleHistoryMsg(ctx context.Context, msg *models.Message) { h.sendCalendar(ctx, msg.Chat.ID, 0, 0, 0) } // historyCallback opens or refreshes the calendar view (inline). func (h *Handler) historyCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) h.editCalendar(ctx, chatID, msgID, 0, 0) } // handleEditMsg parses a /edit YYYY-MM-DD command and shows that day's events. func (h *Handler) handleEditMsg(ctx context.Context, msg *models.Message) { date := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/edit")) if date == "" { h.sendText(ctx, msg.Chat.ID, "Usage: /edit YYYY-MM-DD (your calendar type)") return } if len(date) != 10 || date[4] != '-' || date[7] != '-' { h.sendText(ctx, 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(ctx, msg.Chat.ID, "Invalid date.") return } user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { h.sendText(ctx, msg.Chat.ID, "Error loading profile") return } loc := loadLocation(user.Timezone) date = userDateToGregorian(date, user.Calendar) h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true) } // handleHistoryCallback routes calendar-related callback data to the right handler. func (h *Handler) handleHistoryCallback(ctx context.Context, chatID int64, msgID int, callbackID, data string) { defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) user, err := h.getOrCreateUser(chatID) if err != nil { return } loc := loadLocation(user.Timezone) if data == "history" { h.editCalendar(ctx, 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(ctx, chatID, msgID, y, m) return } // Navigate to next month if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 { h.editCalendar(ctx, 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(ctx, 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(ctx, 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(ctx, 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(ctx, chatID, msgID, user, eid, loc) return } // Delete event confirmation prompt if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 { h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc) return } // Confirm event deletion if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 { h.deleteEventConfirm(ctx, 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(ctx, 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(ctx, 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(ctx, chatID, msgID, user, eid, hh, loc) return } // Add IN event — show hour picker if strings.HasPrefix(data, "addin_") { h.addEventTime(ctx, chatID, msgID, user, data[6:], "in", loc) return } // Add OUT event — show hour picker if strings.HasPrefix(data, "addout_") { h.addEventTime(ctx, chatID, msgID, user, data[7:], "out", loc) return } // Add event hour/minute callbacks if strings.HasPrefix(data, "addtm_") { h.handleAddEventTime(ctx, chatID, msgID, user, data[6:], loc) return } } // sendCalendar sends a calendar view as a new message. func (h *Handler) sendCalendar(ctx context.Context, chatID int64, msgID int, year, month int) { h.editCalendar(ctx, chatID, msgID, year, month) } // editCalendar renders a monthly calendar grid with event indicators and navigation. func (h *Handler) editCalendar(ctx context.Context, 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 := [][]models.InlineKeyboardButton{} // Weekday header row headerRow := []models.InlineKeyboardButton{} for _, wn := range cm.weekDays { headerRow = append(headerRow, models.InlineKeyboardButton{Text: wn, CallbackData: "noop"}) } kbRows = append(kbRows, headerRow) // Day cells row := []models.InlineKeyboardButton{} for i, d := range cm.days { if d.dayNum == 0 { row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "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, models.InlineKeyboardButton{Text: label, CallbackData: "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, models.InlineKeyboardButton{Text: " ", CallbackData: "noop"}) } kbRows = append(kbRows, row) row = nil } } prevY, prevM, nextY, nextM := navMonth(year, month) // Navigation row kbRows = append(kbRows, []models.InlineKeyboardButton{ {Text: "<", CallbackData: fmt.Sprintf("cal_prev_%d_%d", prevY, prevM)}, {Text: "Today", CallbackData: "history"}, {Text: ">", CallbackData: fmt.Sprintf("cal_next_%d_%d", nextY, nextM)}, }) kbRows = append(kbRows, []models.InlineKeyboardButton{ {Text: "Back to Menu", CallbackData: "back_menu"}, }) kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows} h.sendOrEdit(ctx, chatID, msgID, text, &kb) } // editDayView opens an existing message as a day view. func (h *Handler) editDayView(ctx context.Context, chatID int64, msgID int, user *db.User, date string, loc *time.Location) { h.sendDayView(ctx, chatID, msgID, user, date, loc, false) } // sendDayView displays all events for a given date with inline edit/delete buttons. func (h *Handler) sendDayView(ctx context.Context, 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(ctx, 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 := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ {{Text: "Add IN", CallbackData: "addin_" + date}}, {{Text: "Add OUT", CallbackData: "addout_" + date}}, {{Text: "Back to Calendar", CallbackData: "history"}}, }, } h.sendOrEdit(ctx, chatID, msgID, text, &kb) return } text += "\n\nEvents:" kbRows := [][]models.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, []models.InlineKeyboardButton{ {Text: fmt.Sprintf("Time %s", t), CallbackData: fmt.Sprintf("edit_time_%d", e.ID)}, {Text: "Type", CallbackData: fmt.Sprintf("edit_type_%d", e.ID)}, {Text: "DEL", CallbackData: fmt.Sprintf("delete_%d", e.ID), Style: "danger"}, }) } kbRows = append(kbRows, []models.InlineKeyboardButton{ {Text: "Add IN", CallbackData: "addin_" + date}, {Text: "Add OUT", CallbackData: "addout_" + date}, }) kbRows = append(kbRows, []models.InlineKeyboardButton{ {Text: "Back to Calendar", CallbackData: "history"}, }) kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows} h.sendOrEdit(ctx, chatID, msgID, text, &kb) } // editEventType shows a work type picker for a specific event. func (h *Handler) editEventType(ctx context.Context, 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 := [][]models.InlineKeyboardButton{} for _, wt := range wts { kbRows = append(kbRows, []models.InlineKeyboardButton{ {Text: wt.Name, CallbackData: fmt.Sprintf("settype_%d_%d", eventID, wt.ID)}, }) } kbRows = append(kbRows, []models.InlineKeyboardButton{ {Text: "Back", CallbackData: fmt.Sprintf("back_day_%d", eventID)}, }) kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows} h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text, ReplyMarkup: &kb}) } // setEventWorkType updates an event's work type and returns to the day view. func (h *Handler) setEventWorkType(ctx context.Context, 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(ctx, chatID, msgID, user, t.Format("2006-01-02"), loc, false) } // buildHourPickerKeyboard creates the 24-hour grid for time selection. func buildHourPickerKeyboard(cb func(int) string, backData string) models.InlineKeyboardMarkup { kbRows := [][]models.InlineKeyboardButton{} hourRow := []models.InlineKeyboardButton{} for hh := 0; hh < 24; hh++ { hourRow = append(hourRow, models.InlineKeyboardButton{ Text: fmt.Sprintf("%02d", hh), CallbackData: cb(hh), }) if len(hourRow) == 6 || hh == 23 { kbRows = append(kbRows, hourRow) hourRow = nil } } kbRows = append(kbRows, []models.InlineKeyboardButton{{Text: "Back", CallbackData: backData}}) return models.InlineKeyboardMarkup{InlineKeyboard: kbRows} } // buildMinutePickerKeyboard creates the 15-min interval row for minute selection. func buildMinutePickerKeyboard(cb func(int) string, backData string) models.InlineKeyboardMarkup { kbRows := [][]models.InlineKeyboardButton{} minRow := []models.InlineKeyboardButton{} for _, mm := range []int{0, 15, 30, 45} { minRow = append(minRow, models.InlineKeyboardButton{ Text: fmt.Sprintf("%02d", mm), CallbackData: cb(mm), }) } kbRows = append(kbRows, minRow) kbRows = append(kbRows, []models.InlineKeyboardButton{{Text: "Back", CallbackData: backData}}) return models.InlineKeyboardMarkup{InlineKeyboard: kbRows} } // editEventTime shows an hour picker for changing an event's time. func (h *Handler) editEventTime(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { kb := buildHourPickerKeyboard( func(hh int) string { return fmt.Sprintf("edittm_%d_%02d", eventID, hh) }, fmt.Sprintf("back_day_%d", eventID), ) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: "Select hour:", ReplyMarkup: &kb}) } // editEventTimeMin shows a minute picker (15-min intervals) after hour selection. func (h *Handler) editEventTimeMin(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, hh int, loc *time.Location) { kb := buildMinutePickerKeyboard( func(mm int) string { return fmt.Sprintf("edittm_%d_%02d_%02d", eventID, hh, mm) }, fmt.Sprintf("edit_time_%d", eventID), ) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: fmt.Sprintf("Select minute for hour %02d:", hh), ReplyMarkup: &kb}) } // editEventTimeSet applies a new time to an event, checking for overlaps. func (h *Handler) editEventTimeSet(ctx context.Context, 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(ctx, chatID, msgID) h.sendDayView(ctx, 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 { h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: "Edit rejected: overlapping events. Two consecutive events must be different types (in/out)."}) return } } } h.DB.DB().Exec("UPDATE events SET occurred_at=? WHERE id=?", newTime.Unix(), eventID) h.sendDayView(ctx, chatID, msgID, user, date, loc, false) } // acknowledgeCallback sends an empty acknowledgement for a callback query. func (h *Handler) acknowledgeCallback(ctx context.Context, chatID int64, msgID int) { h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: fmt.Sprintf("cb_%d_%d", chatID, msgID)}) } // deleteEventPrompt asks the user to confirm event deletion. func (h *Handler) deleteEventPrompt(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { kb := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ { {Text: "Yes, DEL", CallbackData: fmt.Sprintf("delconf_%d", eventID), Style: "danger"}, {Text: "Cancel", CallbackData: fmt.Sprintf("back_day_%d", eventID)}, }, }, } h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: "Delete this event?", ReplyMarkup: &kb}) } // deleteEventConfirm deletes an event and warns if the timeline becomes invalid. func (h *Handler) deleteEventConfirm(ctx context.Context, 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(ctx, chatID, "Warning: Timeline has consecutive events of same type after deletion. Manual repair needed.") } } h.sendDayView(ctx, chatID, msgID, user, date, loc, false) } // backToDayView returns from event editing to the day view. func (h *Handler) backToDayView(ctx context.Context, 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(ctx, 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 } // addEventTime shows an hour picker for adding a new event to a day. func (h *Handler) addEventTime(ctx context.Context, chatID int64, msgID int, user *db.User, date, eventType string, loc *time.Location) { kb := buildHourPickerKeyboard( func(hh int) string { return fmt.Sprintf("addtm_%s_%s_%02d", eventType, date, hh) }, "cal_day_"+date, ) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ ChatID: chatID, MessageID: msgID, Text: fmt.Sprintf("Select hour for %s event:", eventType), ReplyMarkup: &kb, }) } // addEventTimeMin shows a minute picker (15-min intervals) after hour selection for adding an event. func (h *Handler) addEventTimeMin(ctx context.Context, chatID int64, msgID int, user *db.User, date, eventType string, hh int, loc *time.Location) { kb := buildMinutePickerKeyboard( func(mm int) string { return fmt.Sprintf("addtm_%s_%s_%02d_%02d", eventType, date, hh, mm) }, "add"+eventType+"_"+date, ) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ ChatID: chatID, MessageID: msgID, Text: fmt.Sprintf("Select minute for hour %02d:", hh), ReplyMarkup: &kb, }) } // addEventDo creates a new event at the specified time and returns to the day view. func (h *Handler) addEventDo(ctx context.Context, chatID int64, msgID int, user *db.User, date, eventType string, hh, mm int, loc *time.Location) { t, err := time.ParseInLocation("2006-01-02 15:04", date+" "+fmt.Sprintf("%02d:%02d", hh, mm), loc) if err != nil { return } day, err := h.DB.GetOrCreateDay(user.ID, date) if err != nil { return } var wt *int64 if eventType == "in" { wt = &day.CurrentWorkTypeID if *wt == 0 { wt = nil } } if err := h.DB.CreateEvent(user.ID, day.ID, eventType, wt, t.Unix(), ""); err != nil { return } h.sendDayView(ctx, chatID, msgID, user, date, loc, false) } // handleAddEventTime parses the addtm_ callback and dispatches to the minute picker or event creation. func (h *Handler) handleAddEventTime(ctx context.Context, chatID int64, msgID int, user *db.User, payload string, loc *time.Location) { parts := strings.SplitN(payload, "_", 4) if len(parts) < 3 { return } eventType := parts[0] addDate := parts[1] hh, err := strconv.Atoi(parts[2]) if err != nil { return } if len(parts) == 3 { h.addEventTimeMin(ctx, chatID, msgID, user, addDate, eventType, hh, loc) return } mm, err := strconv.Atoi(parts[3]) if err != nil { return } h.addEventDo(ctx, chatID, msgID, user, addDate, eventType, hh, mm, loc) }