package handler import ( "context" "fmt" "log/slog" "strconv" "strings" "time" "github.com/go-telegram/bot/models" "worktimeBot/internal/domain" ) // -- History command and callback -- func (h *Handler) handleHistory(ctx context.Context, msg *models.Message, user *domain.User) { h.editCalendar(ctx, msg.Chat.ID, 0, 0, 0) } func (h *Handler) historyCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { defer h.answerCb(ctx, cbID) h.editCalendar(ctx, chatID, msgID, 0, 0) } // -- Calendar rendering -- func (h *Handler) editCalendar(ctx context.Context, chatID int64, msgID int, year, month int) { user, err := h.getOrCreateUser(chatID) if err != nil { return } loc := h.loadLocation(user.Timezone) now := time.Now().In(loc) if year == 0 || month == 0 { switch user.Calendar { case "jalali": jy, jm, _ := domain.GregorianToJalali(now.Year(), int(now.Month()), now.Day()) year, month = jy, jm case "hijri": hy, hm, _ := domain.GregorianToHijri(now.Year(), int(now.Month()), now.Day()) year, month = hy, hm default: year, month = now.Year(), int(now.Month()) } } cal := user.Calendar cm := domain.BuildCalendarMonth(cal, year, month) text := cm.Title todayKey := now.Format(domain.DateLayout) hasEvent := make(map[string]bool) startDate, endDate := domain.MonthGregorianRange(cal, year, month) dates, err := h.Repo.GetDistinctEventDates(user.ID, startDate, endDate) if err == nil { for _, d := range dates { hasEvent[d] = true } } kbRows := [][]models.InlineKeyboardButton{ { {Text: "<", 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: ">", CallbackData: fmt.Sprintf("cal_next_year_%d_%d", year+1, month)}, }, } headerRow := []models.InlineKeyboardButton{} for _, wn := range cm.WeekDays { headerRow = append(headerRow, models.InlineKeyboardButton{Text: wn, CallbackData: "noop"}) } kbRows = append(kbRows, headerRow) 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 { for len(row) < 7 { row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "noop"}) } kbRows = append(kbRows, row) row = nil } } prevY, prevM, nextY, nextM := domain.NavMonth(year, month) 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) } // -- Calendar callbacks routing -- func (h *Handler) routeHistoryCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, data string) { loc := h.loadLocation(user.Timezone) if data == "history" { h.editCalendar(ctx, chatID, msgID, 0, 0) return } 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 } if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 { h.editCalendar(ctx, chatID, msgID, y, m) return } if n, _ := fmt.Sscanf(data, "cal_prev_year_%d_%d", &y, &m); n == 2 { h.editCalendar(ctx, chatID, msgID, y, m) return } if n, _ := fmt.Sscanf(data, "cal_next_year_%d_%d", &y, &m); n == 2 { h.editCalendar(ctx, chatID, msgID, y, m) return } if n, _ := fmt.Sscanf(data, "cal_show_years_%d_%d", &y, &m); n == 2 { h.editCalendarYearPicker(ctx, chatID, msgID, y, m, y) return } var startY, refY, refM int 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 } if n, _ := fmt.Sscanf(data, "cal_year_%d", &y); n == 1 { h.editCalendar(ctx, chatID, msgID, y, 1) return } if n, _ := fmt.Sscanf(data, "cal_years_back_%d_%d", &y, &m); n == 2 { h.editCalendar(ctx, chatID, msgID, y, m) return } 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 } var eid int64 if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 { h.backToDayView(ctx, chatID, msgID, user, eid, loc) return } 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 } if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 { h.editEventType(ctx, chatID, msgID, user, eid, loc) return } if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 { h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc) return } if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 { h.deleteEventConfirm(ctx, chatID, msgID, user, eid, loc) return } if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 { h.editEventTime(ctx, chatID, msgID, user, eid, loc) return } 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, cbID) return } if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 { h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc) return } if n, _ := fmt.Sscanf(data, "note_%d", &eid); n == 1 { h.promptNoteInput(ctx, chatID, msgID, user, eid, loc) return } if n, _ := fmt.Sscanf(data, "note_cancel_%d", &eid); n == 1 { h.backToDayView(ctx, chatID, msgID, user, eid, loc) return } 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) } } // -- Calendar year picker -- 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) } // -- Day view -- func (h *Handler) editDayView(ctx context.Context, chatID int64, msgID int, user *domain.User, date string, loc *time.Location) { h.sendDayView(ctx, chatID, msgID, user, date, loc, false) } func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user *domain.User, date string, loc *time.Location, isNewMsg bool) { events, err := h.Repo.EventsForDayByDate(user.ID, date) if err != nil { if isNewMsg { h.sendText(ctx, chatID, "Error loading events") } return } text := fmt.Sprintf("Date: %s", domain.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(domain.TimeLayout) label := "IN" if e.EventType == "out" { label = "OUT" } wt := "" if e.WorkTypeID != nil { if wtObj, err := h.Repo.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) } // -- Event editing -- func (h *Handler) editEventType(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) { wts, err := h.Repo.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) } func (h *Handler) setEventWorkType(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID, workTypeID int64, loc *time.Location) { if err := h.Repo.UpdateEventWorkType(eventID, workTypeID); 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.Repo.GetEventByID(eventID, user.ID) if err != nil { return } t := time.Unix(event.OccurredAt, 0).In(loc) h.sendDayView(ctx, chatID, msgID, user, t.Format(domain.DateLayout), loc, false) } func (h *Handler) editEventTime(ctx context.Context, chatID int64, msgID int, user *domain.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) } func (h *Handler) editEventTimeMin(ctx context.Context, chatID int64, msgID int, user *domain.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) } func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, hh, mm int, loc *time.Location, cbID string) { event, err := h.Repo.GetEventByID(eventID, user.ID) 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 := newTime.Format(domain.DateLayout) events, err := h.Repo.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.answerCb(ctx, cbID) h.sendDayView(ctx, chatID, msgID, user, date, loc, false) return } } 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}) } 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.", nil) return } } } if err = h.Repo.UpdateEventTime(eventID, newTime.Unix()); err != nil { slog.Error("failed to update event time", "event_id", eventID, "error", err) h.sendDayView(ctx, chatID, msgID, user, date, loc, false) return } h.recalcDayWorkSeconds(user.ID, date, loc) h.recalcAggregates(user.ID, loc) h.sendDayView(ctx, chatID, msgID, user, date, loc, false) } // -- Event deletion -- func (h *Handler) deleteEventPrompt(ctx context.Context, chatID int64, msgID int, user *domain.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) } func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) { event, err := h.Repo.GetEventByID(eventID, user.ID) if err != nil { return } t := time.Unix(event.OccurredAt, 0).In(loc) date := t.Format(domain.DateLayout) if err = h.Repo.DeleteEvent(eventID); err != nil { slog.Error("failed to delete event", "event_id", eventID, "error", err) h.sendDayView(ctx, chatID, msgID, user, date, loc, false) return } events, err := h.Repo.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.") } } h.recalcDayWorkSeconds(user.ID, date, loc) h.recalcAggregates(user.ID, loc) h.sendDayView(ctx, chatID, msgID, user, date, loc, false) } func (h *Handler) backToDayView(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) { event, err := h.Repo.GetEventByID(eventID, user.ID) if err != nil { return } t := time.Unix(event.OccurredAt, 0).In(loc) h.sendDayView(ctx, chatID, msgID, user, t.Format(domain.DateLayout), loc, false) } // -- Note via pending input -- func (h *Handler) promptNoteInput(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) { event, err := h.Repo.GetEventByID(eventID, user.ID) if err != nil { return } t := time.Unix(event.OccurredAt, 0).In(loc).Format(domain.TimeLayout) 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) h.setPending(ctx, chatID, msgID, user.ID, PendingNote, map[string]string{"event_id": fmt.Sprintf("%d", eventID)}) } // -- Adding events -- func (h *Handler) addEventTime(ctx context.Context, chatID int64, msgID int, user *domain.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) } func (h *Handler) handleAddEventTime(ctx context.Context, chatID int64, msgID int, user *domain.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) } func (h *Handler) addEventTimeMin(ctx context.Context, chatID int64, msgID int, user *domain.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) } func (h *Handler) addEventDo(ctx context.Context, chatID int64, msgID int, user *domain.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.Repo.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.Repo.CreateEvent(user.ID, day.ID, eventType, wt, t.Unix(), ""); err != nil { return } h.recalcDayWorkSeconds(user.ID, date, loc) h.recalcAggregates(user.ID, loc) h.sendDayView(ctx, chatID, msgID, user, date, loc, false) }