package web import ( "errors" "log/slog" "net/http" "strings" "time" "backend/internal/db/sqlc" "backend/templates" "github.com/google/uuid" "github.com/go-chi/chi/v5" "github.com/gorilla/csrf" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) type EventsDeps struct { Queries *sqlc.Queries } func parseCalendarMonth(raw string, now time.Time) time.Time { raw = strings.TrimSpace(raw) if raw != "" { if parsed, err := time.Parse("2006-01", raw); err == nil { return time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, time.Local) } } return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local) } func pgDateFromTime(t time.Time) pgtype.Date { return pgtype.Date{Time: time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local), Valid: true} } func parseEventDate(raw string) (pgtype.Date, bool) { parsed, err := time.Parse("2006-01-02", strings.TrimSpace(raw)) if err != nil { return pgtype.Date{}, false } return pgDateFromTime(parsed), true } func parseEventTime(raw string) (pgtype.Time, bool) { raw = strings.TrimSpace(raw) if raw == "" { return pgtype.Time{}, false } parsed, err := time.Parse("15:04", raw) if err != nil { return pgtype.Time{}, false } micros := int64(parsed.Hour())*int64(time.Hour/time.Microsecond) + int64(parsed.Minute())*int64(time.Minute/time.Microsecond) return pgtype.Time{Microseconds: micros, Valid: true}, true } func buildEventsCalendar(month time.Time, events []sqlc.Event) templates.EventsCalendar { monthStart := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.Local) monthEnd := monthStart.AddDate(0, 1, -1) leading := (int(monthStart.Weekday()) + 6) % 7 gridStart := monthStart.AddDate(0, 0, -leading) days := make([]templates.EventCalendarDay, 0, 42) byDate := make(map[string][]sqlc.Event) for _, event := range events { key := templates.FormatEventDate(event.EventDate) byDate[key] = append(byDate[key], event) } for i := 0; i < 42; i++ { day := gridStart.AddDate(0, 0, i) key := day.Format("2006-01-02") dayEvents := byDate[key] more := 0 if len(dayEvents) > 3 { more = len(dayEvents) - 3 } days = append(days, templates.EventCalendarDay{ Date: key, DayNumber: day.Day(), InMonth: !day.Before(monthStart) && !day.After(monthEnd), Events: dayEvents, MoreCount: more, }) } return templates.EventsCalendar{ Month: monthStart.Format("2006-01"), MonthLabel: monthStart.Format("January 2006"), PrevMonth: monthStart.AddDate(0, -1, 0).Format("2006-01"), PrevMonthLabel: monthStart.AddDate(0, -1, 0).Format("January 2006"), NextMonth: monthStart.AddDate(0, 1, 0).Format("2006-01"), NextMonthLabel: monthStart.AddDate(0, 1, 0).Format("January 2006"), Days: days, WeekdayLabs: templates.DefaultWeekdayLabels(), } } func loadEventsCalendar(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo) (templates.EventsCalendar, bool) { month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()) monthStart := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.Local) monthEnd := monthStart.AddDate(0, 1, -1) events, err := q.ListEventsByTabloRange(r.Context(), sqlc.ListEventsByTabloRangeParams{ TabloID: tablo.ID, EventDate: pgDateFromTime(monthStart), EventDate_2: pgDateFromTime(monthEnd), }) if err != nil { slog.Default().Error("events tab: ListEventsByTabloRange failed", "tablo_id", tablo.ID, "err", err) http.Error(w, "internal server error", http.StatusInternalServerError) return templates.EventsCalendar{}, false } return buildEventsCalendar(month, events), true } func loadOwnedEvent(w http.ResponseWriter, r *http.Request, deps EventsDeps) (sqlc.Tablo, sqlc.Event, bool) { tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}) if !ok { return sqlc.Tablo{}, sqlc.Event{}, false } eventID, err := uuid.Parse(chi.URLParam(r, "event_id")) if err != nil { http.NotFound(w, r) return sqlc.Tablo{}, sqlc.Event{}, false } event, err := deps.Queries.GetEventByID(r.Context(), sqlc.GetEventByIDParams{ ID: eventID, TabloID: tablo.ID, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.NotFound(w, r) return sqlc.Tablo{}, sqlc.Event{}, false } slog.Default().Error("events: GetEventByID failed", "tablo_id", tablo.ID, "event_id", eventID, "err", err) http.Error(w, "internal server error", http.StatusInternalServerError) return sqlc.Tablo{}, sqlc.Event{}, false } return tablo, event, true } func renderEventsTab(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo) { calendar, ok := loadEventsCalendar(w, r, q, tablo) if !ok { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Retarget", "#events-tab") w.Header().Set("HX-Reswap", "outerHTML") } _ = templates.EventsTabFragment(tablo, calendar, csrf.Token(r)).Render(r.Context(), w) } func TabloEventsTabHandler(deps EventsDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tablo, user, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}) if !ok { return } calendar, ok := loadEventsCalendar(w, r, deps.Queries, tablo) if !ok { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if r.Header.Get("HX-Request") == "true" { _ = templates.EventsTabFragment(tablo, calendar, csrf.Token(r)).Render(r.Context(), w) return } eventsSidebarTablos, sidebarErr := deps.Queries.ListTablosByUser(r.Context(), user.ID) if sidebarErr != nil { slog.Default().Error("events: ListTablosByUser failed", "user_id", user.ID, "err", sidebarErr) eventsSidebarTablos = []sqlc.Tablo{} } if eventsSidebarTablos == nil { eventsSidebarTablos = []sqlc.Tablo{} } _ = templates.TabloDetailPage(user, csrf.Token(r), "", eventsSidebarTablos, tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, calendar, templates.DiscussionTabData{}, "events").Render(r.Context(), w) } } func EventNewFormHandler(deps EventsDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}) if !ok { return } month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01") form := templates.EventCreateForm{EventDate: strings.TrimSpace(r.URL.Query().Get("date"))} w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.EventCreateFormFragment(tablo.ID, form, templates.EventCreateErrors{}, csrf.Token(r), month).Render(r.Context(), w) } } func EventCancelNewHandler(deps EventsDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if _, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}); !ok { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte("")) } } func EventCreateHandler(deps EventsDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}) if !ok { return } form, params, errs, valid := parseEventCreateForm(r, tablo.ID) if !valid { month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01") w.Header().Set("Content-Type", "text/html; charset=utf-8") if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Retarget", "#event-form-slot") w.Header().Set("HX-Reswap", "innerHTML") } w.WriteHeader(http.StatusUnprocessableEntity) _ = templates.EventCreateFormFragment(tablo.ID, form, errs, csrf.Token(r), month).Render(r.Context(), w) return } if _, err := deps.Queries.CreateEvent(r.Context(), params); err != nil { slog.Default().Error("events create: CreateEvent failed", "tablo_id", tablo.ID, "err", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } renderEventsTab(w, r, deps.Queries, tablo) } } func EventEditFormHandler(deps EventsDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tablo, event, ok := loadOwnedEvent(w, r, deps) if !ok { return } month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01") w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.EventEditFormFragment(tablo.ID, event, templates.EventFormFromEvent(event), templates.EventCreateErrors{}, csrf.Token(r), month).Render(r.Context(), w) } } func EventUpdateHandler(deps EventsDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tablo, event, ok := loadOwnedEvent(w, r, deps) if !ok { return } form, createParams, errs, valid := parseEventCreateForm(r, tablo.ID) month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01") if !valid { w.Header().Set("Content-Type", "text/html; charset=utf-8") if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Retarget", "#event-form-slot") w.Header().Set("HX-Reswap", "innerHTML") } w.WriteHeader(http.StatusUnprocessableEntity) _ = templates.EventEditFormFragment(tablo.ID, event, form, errs, csrf.Token(r), month).Render(r.Context(), w) return } if _, err := deps.Queries.UpdateEvent(r.Context(), sqlc.UpdateEventParams{ ID: event.ID, TabloID: tablo.ID, Title: createParams.Title, EventDate: createParams.EventDate, StartTime: createParams.StartTime, EndTime: createParams.EndTime, Description: createParams.Description, Location: createParams.Location, }); err != nil { slog.Default().Error("events update: UpdateEvent failed", "tablo_id", tablo.ID, "event_id", event.ID, "err", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } renderEventsTab(w, r, deps.Queries, tablo) } } func EventDeleteConfirmHandler(deps EventsDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tablo, event, ok := loadOwnedEvent(w, r, deps) if !ok { return } month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01") w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.EventDeleteConfirmFragment(tablo.ID, event, csrf.Token(r), month).Render(r.Context(), w) } } func EventDeleteHandler(deps EventsDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tablo, event, ok := loadOwnedEvent(w, r, deps) if !ok { return } if err := deps.Queries.DeleteEvent(r.Context(), sqlc.DeleteEventParams{ ID: event.ID, TabloID: tablo.ID, }); err != nil { slog.Default().Error("events delete: DeleteEvent failed", "tablo_id", tablo.ID, "event_id", event.ID, "err", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } renderEventsTab(w, r, deps.Queries, tablo) } } func parseEventCreateForm(r *http.Request, tabloID uuid.UUID) (templates.EventCreateForm, sqlc.CreateEventParams, templates.EventCreateErrors, bool) { title := strings.TrimSpace(r.PostFormValue("title")) eventDate := strings.TrimSpace(r.PostFormValue("event_date")) startTime := strings.TrimSpace(r.PostFormValue("start_time")) endTime := strings.TrimSpace(r.PostFormValue("end_time")) location := strings.TrimSpace(r.PostFormValue("location")) description := strings.TrimSpace(r.PostFormValue("description")) form := templates.EventCreateForm{ Title: title, EventDate: eventDate, StartTime: startTime, EndTime: endTime, Location: location, Description: description, } var errs templates.EventCreateErrors if title == "" { errs.Title = "Title is required." } else if len(title) > 255 { errs.Title = "Title must be 255 characters or fewer." } dateValue, ok := parseEventDate(eventDate) if !ok { errs.EventDate = "Date is required." } startValue, ok := parseEventTime(startTime) if !ok { errs.StartTime = "Start time is required." } var endValue pgtype.Time if endTime != "" { var endOK bool endValue, endOK = parseEventTime(endTime) if !endOK { errs.EndTime = "End time must use HH:MM." } else if startValue.Valid && endValue.Microseconds <= startValue.Microseconds { errs.EndTime = "End time must be after the start time." } } valid := errs.Title == "" && errs.EventDate == "" && errs.StartTime == "" && errs.EndTime == "" params := sqlc.CreateEventParams{ TabloID: tabloID, Title: title, EventDate: dateValue, StartTime: startValue, EndTime: endValue, Description: pgtype.Text{String: description, Valid: description != ""}, Location: pgtype.Text{String: location, Valid: location != ""}, } return form, params, errs, valid }