Files
tgex-backend/tg_bot/screens/ui/date_time_picker.go
2026-02-28 13:06:30 +03:00

721 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import (
"fmt"
"regexp"
"strings"
"time"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/olebedev/when"
"github.com/olebedev/when/rules/common"
"github.com/olebedev/when/rules/ru"
)
const (
pickerViewCalendar = "calendar"
pickerViewMonths = "months"
pickerViewTimeCombined = "time_combined"
pickerViewConfirm = "confirm"
dtpCallbackPrefix = "dtp:"
)
var MskLocation = time.FixedZone("MSK", 3*60*60)
type DateTimePickerConfig struct {
Title string
Key string
IncludeTime bool
AllowPast bool
Selected *time.Time
BackState bot.State
}
type DateTimeSelectionTarget interface {
SetDateTimeSelection(key string, value time.Time)
}
type DateTimePicker struct {
Title string
Key string
IncludeTime bool
AllowPast bool
BackState bot.State
view string
year int
month time.Month
selectedDate *time.Time
selectedHour *int
selectedMinute *int
}
func NewDateTimePicker(cfg DateTimePickerConfig) *DateTimePicker {
now := time.Now().In(MskLocation)
year := now.Year()
month := now.Month()
var selected *time.Time
if cfg.Selected != nil {
value := cfg.Selected.In(MskLocation)
selected = &value
year = value.Year()
month = value.Month()
}
return &DateTimePicker{
Title: cfg.Title,
Key: cfg.Key,
IncludeTime: cfg.IncludeTime,
AllowPast: cfg.AllowPast,
BackState: cfg.BackState,
view: pickerViewCalendar,
year: year,
month: month,
selectedDate: selected,
}
}
func (s *DateTimePicker) Enter(b *bot.Bot, mode bot.RenderMode) {
text := fmt.Sprintf("<b>%s</b>\n\n", s.Title)
text += fmt.Sprintf("%s\n\n", s.formatSelectedDateTime())
switch s.view {
case pickerViewCalendar:
text += s.renderCalendarTitle()
b.Render(text, s.calendarKeyboard(), mode)
case pickerViewMonths:
text += "Выберите месяц\n\n"
b.Render(text, s.monthsKeyboard(), mode)
case pickerViewTimeCombined:
b.Render(text, s.timeCombinedKeyboard(), mode)
case pickerViewConfirm:
text += s.renderSelectedDateTime()
b.Render(text, s.confirmKeyboard(), mode)
}
}
func (s *DateTimePicker) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
if data == "back" {
s.handleBack(b)
return
}
if data == "cancel" {
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
return
}
if !strings.HasPrefix(data, dtpCallbackPrefix) {
s.Enter(b, bot.EditMessage)
return
}
payload := strings.TrimPrefix(data, dtpCallbackPrefix)
switch {
case payload == "month_prev":
s.prevMonth()
case payload == "month_next":
s.nextMonth()
case payload == "open_months":
s.view = pickerViewMonths
case payload == "year_prev":
s.year--
case payload == "year_next":
s.year++
case strings.HasPrefix(payload, "month:"):
month := parseInt(strings.TrimPrefix(payload, "month:"))
if month >= 1 && month <= 12 {
s.month = time.Month(month)
s.view = pickerViewCalendar
}
case strings.HasPrefix(payload, "day:"):
s.handleDaySelect(payload, b)
return // confirmSelection уже делает Enter
case strings.HasPrefix(payload, "hour:"):
s.handleHourSelect(payload)
case strings.HasPrefix(payload, "min:"):
s.handleMinuteSelect(payload)
case payload == "confirm":
s.confirmSelection(b)
return
}
// Если не было подтверждения (для дат без времени), перерисовываем
s.Enter(b, bot.EditMessage)
}
func (s *DateTimePicker) handleBack(b *bot.Bot) {
switch s.view {
case pickerViewMonths:
s.view = pickerViewCalendar
case pickerViewTimeCombined:
s.view = pickerViewCalendar
case pickerViewConfirm:
if s.IncludeTime {
s.view = pickerViewTimeCombined
} else {
s.view = pickerViewCalendar
}
default:
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
return
}
}
s.Enter(b, bot.EditMessage)
}
func (s *DateTimePicker) renderCalendarTitle() string {
if s.year == time.Now().In(MskLocation).Year() {
return "\n"
}
return fmt.Sprintf("%d\n\n", s.year)
}
func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
rows = append(rows, []echotron.InlineKeyboardButton{
s.monthPrevButton(),
{Text: monthName(s.month), CallbackData: dtpCallbackPrefix + "open_months"},
{Text: "→", CallbackData: dtpCallbackPrefix + "month_next"},
})
rows = append(rows, []echotron.InlineKeyboardButton{
{Text: "Пн", CallbackData: "empty"},
{Text: "Вт", CallbackData: "empty"},
{Text: "Ср", CallbackData: "empty"},
{Text: "Чт", CallbackData: "empty"},
{Text: "Пт", CallbackData: "empty"},
{Text: "Сб", CallbackData: "empty"},
{Text: "Вс", CallbackData: "empty"},
})
firstOfMonth := time.Date(s.year, s.month, 1, 0, 0, 0, 0, MskLocation)
weekday := int(firstOfMonth.Weekday())
if weekday == 0 {
weekday = 7
}
daysInMonth := daysInMonth(s.year, s.month)
today := time.Now().In(MskLocation)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
var row []echotron.InlineKeyboardButton
for i := 1; i < weekday; i++ {
row = append(row, echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"})
}
for day := 1; day <= daysInMonth; day++ {
date := time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation)
if !s.AllowPast && date.Before(todayDate) {
row = append(row, echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"})
} else {
label := fmt.Sprintf("%d", day)
row = append(row, echotron.InlineKeyboardButton{
Text: label,
CallbackData: fmt.Sprintf("%sday:%04d-%02d-%02d", dtpCallbackPrefix, s.year, int(s.month), day),
})
}
if len(row) == 7 {
rows = append(rows, row)
row = nil
}
}
if len(row) > 0 {
for len(row) < 7 {
row = append(row, echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"})
}
rows = append(rows, row)
}
rows = append(rows, []echotron.InlineKeyboardButton{
{Text: "← Назад", CallbackData: "back"},
{Text: "Отмена", CallbackData: "cancel"},
})
return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
months := []string{"Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"}
for i := 0; i < 12; i += 3 {
rows = append(rows, []echotron.InlineKeyboardButton{
{Text: months[i], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+1)},
{Text: months[i+1], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+2)},
{Text: months[i+2], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+3)},
})
}
rows = append(rows, []echotron.InlineKeyboardButton{
{Text: "← Назад", CallbackData: "back"},
})
return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (s *DateTimePicker) timeCombinedKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
rows = append(rows, []echotron.InlineKeyboardButton{{Text: "Часы", CallbackData: "empty"}})
for i := 0; i < 24; i += 6 {
rows = append(rows, []echotron.InlineKeyboardButton{
s.hourButton(i),
s.hourButton(i + 1),
s.hourButton(i + 2),
s.hourButton(i + 3),
s.hourButton(i + 4),
s.hourButton(i + 5),
})
}
rows = append(rows, []echotron.InlineKeyboardButton{{Text: "Минуты", CallbackData: "empty"}})
minutes := []int{0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}
for i := 0; i < len(minutes); i += 6 {
rows = append(rows, []echotron.InlineKeyboardButton{
s.minuteButton(minutes[i]),
s.minuteButton(minutes[i+1]),
s.minuteButton(minutes[i+2]),
s.minuteButton(minutes[i+3]),
s.minuteButton(minutes[i+4]),
s.minuteButton(minutes[i+5]),
})
}
rows = append(rows, []echotron.InlineKeyboardButton{
{Text: "← Назад", CallbackData: "back"},
{Text: "Готово", CallbackData: dtpCallbackPrefix + "confirm"},
})
return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (s *DateTimePicker) confirmKeyboard() echotron.InlineKeyboardMarkup {
return echotron.InlineKeyboardMarkup{
InlineKeyboard: [][]echotron.InlineKeyboardButton{
{
{Text: "← Назад", CallbackData: "back"},
{Text: "Готово", CallbackData: dtpCallbackPrefix + "confirm"},
},
},
}
}
func (s *DateTimePicker) renderSelectedDateTime() string {
if s.selectedDate == nil {
return "—"
}
hour := 0
minute := 0
if s.selectedHour != nil {
hour = *s.selectedHour
}
if s.selectedMinute != nil {
minute = *s.selectedMinute
}
value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, MskLocation)
return fmt.Sprintf("<b>%s</b>", formatCompactDateTime(value))
}
func (s *DateTimePicker) formatTimePreview(showPlaceholders bool) string {
hour := "__"
minute := "__"
if s.selectedHour != nil {
hour = fmt.Sprintf("%02d", *s.selectedHour)
} else if !showPlaceholders {
hour = "00"
}
if s.selectedMinute != nil {
minute = fmt.Sprintf("%02d", *s.selectedMinute)
} else if !showPlaceholders {
minute = "00"
}
return fmt.Sprintf("%s:%s", hour, minute)
}
func (s *DateTimePicker) formatSelectedDateTime() string {
displayDate, ok := s.displayDate()
if !ok {
return "—"
}
timeLabel := "--:--"
if s.selectedHour != nil && s.selectedMinute != nil {
timeLabel = fmt.Sprintf("%02d:%02d", *s.selectedHour, *s.selectedMinute)
}
weekday := WeekdayName(displayDate.Weekday())
month := MonthShort(displayDate.Month())
return fmt.Sprintf("%s %02d %s %s %dг.", weekday, displayDate.Day(), month, timeLabel, displayDate.Year())
}
func (s *DateTimePicker) displayDate() (time.Time, bool) {
if s.selectedDate == nil {
if s.view == pickerViewCalendar || s.view == pickerViewMonths {
return time.Date(s.year, s.month, 1, 0, 0, 0, 0, MskLocation), true
}
return time.Time{}, false
}
if (s.view == pickerViewCalendar || s.view == pickerViewMonths) &&
(s.selectedDate.Year() != s.year || s.selectedDate.Month() != s.month) {
day := s.selectedDate.Day()
maxDay := daysInMonth(s.year, s.month)
if day > maxDay {
day = maxDay
}
return time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation), true
}
return s.selectedDate.In(MskLocation), true
}
func (s *DateTimePicker) handleDaySelect(payload string, b *bot.Bot) {
datePart := strings.TrimPrefix(payload, "day:")
parsed, err := time.ParseInLocation("2006-01-02", datePart, MskLocation)
if err != nil {
return
}
s.selectedDate = &parsed
if s.IncludeTime {
s.view = pickerViewTimeCombined
s.selectedHour = nil
s.selectedMinute = nil
s.Enter(b, bot.EditMessage)
} else {
// Сразу подтверждаем выбор для даты без времени
s.confirmSelection(b)
}
}
func (s *DateTimePicker) handleHourSelect(payload string) {
hour := parseInt(strings.TrimPrefix(payload, "hour:"))
if hour < 0 || hour > 23 {
return
}
if s.isHourDisabled(hour) {
return
}
s.selectedHour = &hour
}
func (s *DateTimePicker) handleMinuteSelect(payload string) {
minute := parseInt(strings.TrimPrefix(payload, "min:"))
if minute < 0 || minute > 59 {
return
}
if s.isMinuteDisabled(minute) {
return
}
s.selectedMinute = &minute
}
func (s *DateTimePicker) confirmSelection(b *bot.Bot) {
if s.selectedDate == nil {
s.Enter(b, bot.EditMessage)
return
}
if s.IncludeTime && (s.selectedHour == nil || s.selectedMinute == nil) {
s.view = pickerViewTimeCombined
s.Enter(b, bot.EditMessage)
return
}
hour := 0
minute := 0
if s.selectedHour != nil {
hour = *s.selectedHour
}
if s.selectedMinute != nil {
minute = *s.selectedMinute
}
value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, MskLocation)
if target, ok := s.BackState.(DateTimeSelectionTarget); ok {
target.SetDateTimeSelection(s.Key, value)
}
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
}
func (s *DateTimePicker) hourButton(hour int) echotron.InlineKeyboardButton {
label := fmt.Sprintf("%02d", hour)
if s.isHourDisabled(hour) {
return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
}
if s.selectedHour != nil && *s.selectedHour == hour {
label = "● " + label
}
return echotron.InlineKeyboardButton{
Text: label,
CallbackData: fmt.Sprintf("%shour:%02d", dtpCallbackPrefix, hour),
}
}
func (s *DateTimePicker) minuteButton(minute int) echotron.InlineKeyboardButton {
label := fmt.Sprintf("%02d", minute)
if s.isMinuteDisabled(minute) {
return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
}
if s.selectedMinute != nil && *s.selectedMinute == minute {
label = "● " + label
}
return echotron.InlineKeyboardButton{
Text: label,
CallbackData: fmt.Sprintf("%smin:%02d", dtpCallbackPrefix, minute),
}
}
func (s *DateTimePicker) isHourDisabled(hour int) bool {
if s.AllowPast {
return false
}
if s.selectedDate == nil {
return false
}
today := time.Now().In(MskLocation)
date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, MskLocation)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
if date.After(todayDate) {
return false
}
return hour < today.Hour()
}
func (s *DateTimePicker) isMinuteDisabled(minute int) bool {
if s.AllowPast {
return false
}
if s.selectedDate == nil || s.selectedHour == nil {
return false
}
today := time.Now().In(MskLocation)
date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, MskLocation)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
if date.After(todayDate) {
return false
}
if *s.selectedHour > today.Hour() {
return false
}
return minute < today.Minute()
}
func (s *DateTimePicker) prevMonth() {
if s.AllowPast {
if s.month == time.January {
s.month = time.December
s.year--
} else {
s.month--
}
return
}
now := time.Now().In(MskLocation)
if s.year == now.Year() && s.month == now.Month() {
return
}
if s.month == time.January {
s.month = time.December
s.year--
} else {
s.month--
}
}
func (s *DateTimePicker) nextMonth() {
if s.month == time.December {
s.month = time.January
s.year++
} else {
s.month++
}
}
func (s *DateTimePicker) monthPrevButton() echotron.InlineKeyboardButton {
if s.AllowPast {
return echotron.InlineKeyboardButton{Text: "←", CallbackData: dtpCallbackPrefix + "month_prev"}
}
now := time.Now().In(MskLocation)
if s.year == now.Year() && s.month == now.Month() {
return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
}
return echotron.InlineKeyboardButton{Text: "←", CallbackData: dtpCallbackPrefix + "month_prev"}
}
func daysInMonth(year int, month time.Month) int {
return time.Date(year, month+1, 0, 0, 0, 0, 0, MskLocation).Day()
}
func monthName(month time.Month) string {
names := []string{
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь",
}
if int(month) < 1 || int(month) > len(names) {
return ""
}
return names[int(month)-1]
}
func parseInt(value string) int {
result := 0
for _, r := range value {
if r < '0' || r > '9' {
return 0
}
result = result*10 + int(r-'0')
}
return result
}
func (s *DateTimePicker) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil || strings.TrimSpace(u.Message.Text) == "" {
return
}
input := strings.TrimSpace(u.Message.Text)
switch s.view {
case pickerViewCalendar:
value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute, s.AllowPast)
if !hasDate {
s.Enter(b, bot.NewMessage)
return
}
s.selectedDate = &value
if hasTime {
hour := value.Hour()
minute := value.Minute()
s.selectedHour = &hour
s.selectedMinute = &minute
}
if s.IncludeTime {
s.view = pickerViewTimeCombined
} else {
s.view = pickerViewConfirm
}
s.Enter(b, bot.NewMessage)
case pickerViewTimeCombined:
value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute, s.AllowPast)
if !hasTime {
s.Enter(b, bot.NewMessage)
return
}
if hasDate {
s.selectedDate = &value
}
hour := value.Hour()
minute := value.Minute()
s.selectedHour = &hour
s.selectedMinute = &minute
s.Enter(b, bot.NewMessage)
default:
value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute, s.AllowPast)
if !hasDate && !hasTime {
s.Enter(b, bot.NewMessage)
return
}
s.selectedDate = &value
hour := value.Hour()
minute := value.Minute()
s.selectedHour = &hour
s.selectedMinute = &minute
s.view = pickerViewConfirm
s.Enter(b, bot.NewMessage)
}
}
func (s *DateTimePicker) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *DateTimePicker) Exit() {}
var whenParser *when.Parser
func init() {
whenParser = when.New(nil)
whenParser.Add(ru.All...)
whenParser.Add(common.All...)
}
var (
timeIndicator = regexp.MustCompile(`\d{1,2}[:.]\d{2}|утр|вечер|днём|ночь|час|минут|полдень|полночь`)
dateIndicator = regexp.MustCompile(`\d{1,2}[./-]\d|сегодня|завтра|вчера|послезавтра|понедельн|вторник|сред[уыа]|четверг|пятниц|суббот|воскресен|янв|фев|мар|апр|ма[йя]|июн|июл|авг|сен|окт|ноя|дек|через.*дн|через.*недел|через.*месяц|через.*год`)
)
func detectTime(matched string) bool {
return timeIndicator.MatchString(matched)
}
func detectDate(matched string) bool {
return dateIndicator.MatchString(matched)
}
func parseDateTimeInput(
input string,
_ *time.Time,
_ *int,
_ *int,
allowPast bool,
) (time.Time, bool, bool) {
now := time.Now().In(MskLocation)
r, err := whenParser.Parse(input, now)
if err != nil || r == nil {
return time.Time{}, false, false
}
result := r.Time.In(MskLocation)
matched := strings.ToLower(r.Text)
hasDate := detectDate(matched)
hasTime := detectTime(matched)
if !allowPast && result.Before(now) {
return time.Time{}, false, false
}
return result, hasDate, hasTime
}
func WeekdayName(day time.Weekday) string {
switch day {
case time.Monday:
return "Пн"
case time.Tuesday:
return "Вт"
case time.Wednesday:
return "Ср"
case time.Thursday:
return "Чт"
case time.Friday:
return "Пт"
case time.Saturday:
return "Сб"
case time.Sunday:
return "Вс"
default:
return ""
}
}
func formatCompactDateTime(value time.Time) string {
weekday := WeekdayName(value.Weekday())
month := MonthShort(value.Month())
return fmt.Sprintf("%s %02d %s %s", weekday, value.Day(), month, value.Format("15:04"))
}
func MonthShort(month time.Month) string {
months := []string{
"янв", "фев", "мар", "апр", "май", "июн",
"июл", "авг", "сен", "окт", "ноя", "дек",
}
if int(month) < 1 || int(month) > len(months) {
return ""
}
return months[int(month)-1]
}