From 4165839477af4c93868c46026b6fb705ab5f45bb Mon Sep 17 00:00:00 2001 From: db123 Date: Wed, 24 Jun 2026 19:49:32 +0330 Subject: [PATCH] feat: add event notes with pending state, 5-min TTL, cancel, and /note command --- internal/bot/calendar.go | 34 ++++++++++++++ internal/bot/handlers.go | 95 +++++++++++++++++++++++++++++++++++++++- internal/db/store.go | 6 +++ 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/internal/bot/calendar.go b/internal/bot/calendar.go index 68d7086..8f8b0dd 100644 --- a/internal/bot/calendar.go +++ b/internal/bot/calendar.go @@ -157,6 +157,16 @@ func (h *Handler) handleHistoryCallback(ctx context.Context, chatID int64, msgID h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc) return } + // Set note for an event — prompt user for text + if n, _ := fmt.Sscanf(data, "note_%d", &eid); n == 1 { + h.promptNote(ctx, chatID, msgID, user, eid, loc) + return + } + // Cancel note prompt — return to day view + if n, _ := fmt.Sscanf(data, "note_cancel_%d", &eid); n == 1 { + h.backToDayView(ctx, chatID, msgID, user, eid, loc) + return + } // Add IN event — show hour picker if strings.HasPrefix(data, "addin_") { h.addEventTime(ctx, chatID, msgID, user, data[6:], "in", loc) @@ -372,6 +382,7 @@ func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user 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"}, }) } @@ -532,6 +543,29 @@ func (h *Handler) acknowledgeCallback(ctx context.Context, chatID int64, msgID i h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: fmt.Sprintf("cb_%d_%d", chatID, msgID)}) } +// 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("15:04") + + 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.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chatID, MessageID: msgID, + Text: fmt.Sprintf("Send the note for event at %s:", t), ReplyMarkup: &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{ diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index d19037a..637fbdd 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strings" "sync" "time" @@ -15,12 +16,18 @@ import ( const rateLimitInterval = 1 * time.Second +type pendingNote struct { + eventID int64 + createdAt time.Time +} + // Handler holds the bot instance, database store, user allowlist, and rate-limit state. type Handler struct { Bot *bot.Bot DB *db.Store AllowedUsers map[int64]bool lastMsgTime map[int64]time.Time + pendingNotes map[int64]*pendingNote mu sync.Mutex } @@ -31,12 +38,13 @@ func NewHandler(bot *bot.Bot, store *db.Store, allowed map[int64]bool) *Handler DB: store, AllowedUsers: allowed, lastMsgTime: make(map[int64]time.Time), + pendingNotes: make(map[int64]*pendingNote), } go h.periodicCleanup() return h } -// periodicCleanup runs in the background, purging stale rate-limit entries every 10 seconds. +// periodicCleanup runs in the background, purging stale rate-limit entries and pending notes every 10 seconds. func (h *Handler) periodicCleanup() { for { time.Sleep(10 * time.Second) @@ -47,6 +55,12 @@ func (h *Handler) periodicCleanup() { delete(h.lastMsgTime, uid) } } + noteCutoff := time.Now().Add(-5 * time.Minute) + for chatID, pn := range h.pendingNotes { + if pn.createdAt.Before(noteCutoff) { + delete(h.pendingNotes, chatID) + } + } h.mu.Unlock() } } @@ -100,6 +114,25 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) { return } slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text) + + // Check for pending note — intercepts plain text only, commands always pass through. + h.mu.Lock() + pn, hasPN := h.pendingNotes[msg.Chat.ID] + if hasPN { + delete(h.pendingNotes, msg.Chat.ID) + } + h.mu.Unlock() + if hasPN && msg.Text != "" && msg.Text[0] != '/' { + loc := loadLocation(user.Timezone) + event, err := h.getEventByID(user.ID, pn.eventID) + if err == nil { + h.DB.SetEventNote(pn.eventID, msg.Text) + date := time.Unix(event.OccurredAt, 0).In(loc).Format("2006-01-02") + h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true) + } + return + } + switch msg.Text { case "/start": h.handleStart(ctx, msg) @@ -129,6 +162,8 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) { h.handleAccentMsg(ctx, msg) case "/settimezone": h.handleSetTimezone(ctx, msg) + case "/note": + h.handleNoteMsg(ctx, msg) default: h.sendText(ctx, msg.Chat.ID, "Unknown command") } @@ -256,6 +291,7 @@ func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) { /export [YYYY-MM] - Export monthly report (current month if omitted) /history - View calendar history /edit YYYY-MM-DD - Edit a specific day +/note [YYYY-MM-DD] HH:MM - Set note for an event /reporttoggle - Enable/disable auto report /setreporttime HH:MM - Set daily report time /setaccent - Select accent color @@ -266,6 +302,63 @@ Buttons on the main menu also work.` h.sendText(ctx, msg.Chat.ID, text) } +// handleNoteMsg processes the /note command: /note [YYYY-MM-DD] HH:MM +func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) { + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error loading profile") + return + } + loc := loadLocation(user.Timezone) + + parts := strings.Fields(msg.Text) + if len(parts) < 3 { + h.sendText(ctx, msg.Chat.ID, "Usage: /note [YYYY-MM-DD] HH:MM ") + return + } + + var dateStr string + var timeStr string + var noteParts []string + + // Check if first argument looks like a date + if len(parts[1]) == 10 && parts[1][4] == '-' && parts[1][7] == '-' { + dateStr = parts[1] + timeStr = parts[2] + noteParts = parts[3:] + } else { + dateStr = time.Now().In(loc).Format("2006-01-02") + timeStr = parts[1] + noteParts = parts[2:] + } + + events, err := h.DB.EventsForDayByDate(user.ID, dateStr) + if err != nil || len(events) == 0 { + h.sendText(ctx, msg.Chat.ID, "No events found for that day") + return + } + + var targetEvent *db.Event + for i := range events { + t := time.Unix(events[i].OccurredAt, 0).In(loc).Format("15:04") + if t == timeStr { + targetEvent = &events[i] + break + } + } + if targetEvent == nil { + h.sendText(ctx, msg.Chat.ID, "No event found at that time") + return + } + + note := strings.Join(noteParts, " ") + if err := h.DB.SetEventNote(targetEvent.ID, note); err != nil { + h.sendText(ctx, msg.Chat.ID, "Error saving note") + return + } + h.sendText(ctx, msg.Chat.ID, "Note saved") +} + // handleWorkTypeMsg shows the work type picker for today. func (h *Handler) handleWorkTypeMsg(ctx context.Context, msg *models.Message) { user, day, err := h.getUserToday(msg.Chat.ID) diff --git a/internal/db/store.go b/internal/db/store.go index bab7918..ab37cc9 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -570,6 +570,12 @@ func (s *Store) EventsForDayByDate(userID int64, date string) ([]Event, error) { return events, rows.Err() } +// SetEventNote updates the note for an event. +func (s *Store) SetEventNote(eventID int64, note string) error { + _, err := s.db.Exec("UPDATE events SET note=? WHERE id=?", note, eventID) + return err +} + // EventsForDayByDayID returns all events for a day record, ordered by occurrence. func (s *Store) EventsForDayByDayID(dayID int64) ([]Event, error) { rows, err := s.db.Query(`