package bot import ( "context" "database/sql" "fmt" "log/slog" "strconv" "strings" "time" "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.answerCb(ctx, 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.answerCb(ctx, 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 } if h.handleHistoryCalendarNav(ctx, chatID, msgID, data) { return } if h.handleHistoryYearPicker(ctx, chatID, msgID, data) { return } if h.handleHistoryDayView(ctx, chatID, msgID, user, data, loc) { return } if h.handleHistoryEventEdit(ctx, chatID, msgID, user, data, loc, callbackID) { return } if h.handleHistoryEventAdd(ctx, chatID, msgID, user, data, loc) { return } } // handleHistoryCalendarNav handles calendar navigation callbacks. func (h *Handler) handleHistoryCalendarNav(ctx context.Context, chatID int64, msgID int, data string) bool { var y, m int if n, _ := fmt.Sscanf(data, "cal_prev_%d_%d", &y, &m); n == 2 { h.editCalendar(ctx, chatID, msgID, y, m) return true } if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 { h.editCalendar(ctx, chatID, msgID, y, m) return true } if n, _ := fmt.Sscanf(data, "cal_prev_year_%d_%d", &y, &m); n == 2 { h.editCalendar(ctx, chatID, msgID, y, m) return true } if n, _ := fmt.Sscanf(data, "cal_next_year_%d_%d", &y, &m); n == 2 { h.editCalendar(ctx, chatID, msgID, y, m) return true } return false } // handleHistoryYearPicker handles year picker callbacks. func (h *Handler) handleHistoryYearPicker(ctx context.Context, chatID int64, msgID int, data string) bool { var y, m, startY, refY, refM int if n, _ := fmt.Sscanf(data, "cal_show_years_%d_%d", &y, &m); n == 2 { h.editCalendarYearPicker(ctx, chatID, msgID, y, m, y) return true } if n, _ := fmt.Sscanf(data, "cal_years_%d_%d_%d", &startY, &refY, &refM); n == 3 { h.editCalendarYearPicker(ctx, chatID, msgID, startY+6, refM, refY) return true } if n, _ := fmt.Sscanf(data, "cal_year_%d", &y); n == 1 { h.editCalendar(ctx, chatID, msgID, y, 1) return true } if n, _ := fmt.Sscanf(data, "cal_years_back_%d_%d", &y, &m); n == 2 { h.editCalendar(ctx, chatID, msgID, y, m) return true } return false } // handleHistoryDayView handles day-view-related callbacks. func (h *Handler) handleHistoryDayView(ctx context.Context, chatID int64, msgID int, user *db.User, data string, loc *time.Location) bool { var date string if n, _ := fmt.Sscanf(data, "cal_day_%s", &date); n == 1 && len(date) == 10 { h.editDayView(ctx, chatID, msgID, user, date, loc) return true } var eid int64 if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 { h.backToDayView(ctx, chatID, msgID, user, eid, loc) return true } return false } // handleHistoryEventEdit handles event editing callbacks. func (h *Handler) handleHistoryEventEdit(ctx context.Context, chatID int64, msgID int, user *db.User, data string, loc *time.Location, callbackID string) bool { var eid, wtid int64 if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 { h.setEventWorkType(ctx, chatID, msgID, user, eid, wtid, loc) return true } if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 { h.editEventType(ctx, chatID, msgID, user, eid, loc) return true } if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 { h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc) return true } if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 { h.deleteEventConfirm(ctx, chatID, msgID, user, eid, loc) return true } if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 { h.editEventTime(ctx, chatID, msgID, user, eid, loc) return true } 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, callbackID) return true } if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 { h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc) return true } if n, _ := fmt.Sscanf(data, "note_%d", &eid); n == 1 { h.promptNote(ctx, chatID, msgID, user, eid, loc) return true } if n, _ := fmt.Sscanf(data, "note_cancel_%d", &eid); n == 1 { h.backToDayView(ctx, chatID, msgID, user, eid, loc) return true } return false } // handleHistoryEventAdd handles event-adding callbacks. func (h *Handler) handleHistoryEventAdd(ctx context.Context, chatID int64, msgID int, user *db.User, data string, loc *time.Location) bool { switch { case strings.HasPrefix(data, "addin_"): h.addEventTime(ctx, chatID, msgID, user, data[6:], "in", loc) case strings.HasPrefix(data, "addout_"): h.addEventTime(ctx, chatID, msgID, user, data[7:], "out", loc) case strings.HasPrefix(data, "addtm_"): h.handleAddEventTime(ctx, chatID, msgID, user, data[6:], loc) default: return false } return true } // 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) } // buildYearPicker returns a 12-year grid keyboard centered on centerYear. // cbPrefix is "cal_" or "exp_". backData is the callback for the Back button. // navSuffix is appended to the nav callbacks to carry reference info (e.g. "_2026_3"). func buildYearPicker(cbPrefix, backData, navSuffix string, centerYear int) models.InlineKeyboardMarkup { blockStart := centerYear - 6 kbRows := [][]models.InlineKeyboardButton{ { {Text: "<", CallbackData: fmt.Sprintf("%syears_%d%s", cbPrefix, blockStart-12, navSuffix)}, {Text: fmt.Sprintf("%d", centerYear), CallbackData: "noop"}, {Text: ">", CallbackData: fmt.Sprintf("%syears_%d%s", cbPrefix, blockStart+12, navSuffix)}, }, } for i := 0; i < 12; i += 4 { row := []models.InlineKeyboardButton{} for j := i; j < i+4; j++ { y := blockStart + j row = append(row, models.InlineKeyboardButton{ Text: fmt.Sprintf("%d", y), CallbackData: fmt.Sprintf("%syear_%d", cbPrefix, y), }) } kbRows = append(kbRows, row) } if backData != "" { kbRows = append(kbRows, []models.InlineKeyboardButton{ {Text: "Back", CallbackData: backData}, }) } return models.InlineKeyboardMarkup{InlineKeyboard: kbRows} } // 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(DateLayout) 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 if err := rows.Scan(&d); err != nil { slog.Error("failed to scan day row", "error", err) continue } hasEvent[d] = true } if err := rows.Close(); err != nil { slog.Error("failed to close rows", "error", err) } } kbRows := [][]models.InlineKeyboardButton{} // Year navigation row kbRows = append(kbRows, []models.InlineKeyboardButton{ {Text: fmt.Sprint("<"), CallbackData: fmt.Sprintf("cal_prev_year_%d_%d", year-1, month)}, {Text: fmt.Sprintf("%d", year), CallbackData: fmt.Sprintf("cal_show_years_%d_%d", year, month)}, {Text: fmt.Sprint(">"), CallbackData: fmt.Sprintf("cal_next_year_%d_%d", year+1, month)}, }) // 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) } // editCalendarYearPicker shows a 12-year grid for year selection. // refY/refM encode the view to return to via the Back button. func (h *Handler) editCalendarYearPicker(ctx context.Context, chatID int64, msgID int, centerYear, refM, refY int) { backData := fmt.Sprintf("cal_years_back_%d_%d", refY, refM) navSuffix := fmt.Sprintf("_%d_%d", refY, refM) kb := buildYearPicker("cal_", backData, navSuffix, centerYear) h.editText(ctx, chatID, msgID, "Select year:", &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(TimeLayout) 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: "Note", CallbackData: fmt.Sprintf("note_%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.editText(ctx, chatID, msgID, text, &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) { _, err := h.DB.DB().Exec("UPDATE events SET work_type_id=? WHERE id=?", workTypeID, eventID) if err != nil { slog.Error("failed to update event work type", "event_id", eventID, "error", err) h.sendDayView(ctx, chatID, msgID, user, "", loc, false) return } 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(DateLayout), 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, 10, 20, 30, 40, 50} { 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.editText(ctx, chatID, msgID, "Select hour:", &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.editText(ctx, chatID, msgID, fmt.Sprintf("Select minute for hour %02d:", hh), &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, callbackID string) { 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(DateLayout) // 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, callbackID) 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.editText(ctx, chatID, msgID, "Edit rejected: overlapping events. Two consecutive events must be different types (in/out).", nil) return } } } _, err = h.DB.DB().Exec("UPDATE events SET occurred_at=? WHERE id=?", newTime.Unix(), eventID) if err != nil { slog.Error("failed to update event occurred_at", "event_id", eventID, "error", err) h.sendDayView(ctx, chatID, msgID, user, date, loc, false) return } 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, callbackID string) { h.answerCb(ctx, callbackID) } // promptNote asks the user to type a note for the event, then re-renders the day view. func (h *Handler) promptNote(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).Format(TimeLayout) h.mu.Lock() h.pendingNotes[chatID] = &pendingNote{eventID: eventID, createdAt: time.Now()} h.mu.Unlock() kb := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ {{Text: "Cancel", CallbackData: fmt.Sprintf("note_cancel_%d", eventID)}}, }, } h.editText(ctx, chatID, msgID, fmt.Sprintf("Send the note for event at %s:", t), &kb) } // 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.editText(ctx, chatID, msgID, "Delete this event?", &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(DateLayout) _, err = h.DB.DB().Exec("DELETE FROM events WHERE id=?", eventID) if err != nil { slog.Error("failed to delete event", "event_id", eventID, "error", err) h.sendDayView(ctx, chatID, msgID, user, date, loc, false) return } // 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(DateLayout), 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.editText(ctx, chatID, msgID, fmt.Sprintf("Select hour for %s event:", eventType), &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.editText(ctx, chatID, msgID, fmt.Sprintf("Select minute for hour %02d:", hh), &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) }