diff --git a/backend/cmd/web/main.go b/backend/cmd/web/main.go index b7377e3..bdbba18 100644 --- a/backend/cmd/web/main.go +++ b/backend/cmd/web/main.go @@ -148,9 +148,10 @@ func main() { fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB} etapeDeps := web.EtapesDeps{Queries: q} + eventDeps := web.EventsDeps{Queries: q} // D-09: pass the embedded static FS — binary has zero runtime file dependencies. - router, err := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, etapeDeps, fileDeps, csrfKey, env) + router, err := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, etapeDeps, eventDeps, fileDeps, csrfKey, env) if err != nil { slog.Error("router init failed", "err", err) os.Exit(1) diff --git a/backend/internal/db/queries/events.sql b/backend/internal/db/queries/events.sql new file mode 100644 index 0000000..dfa7ce6 --- /dev/null +++ b/backend/internal/db/queries/events.sql @@ -0,0 +1,17 @@ +-- name: CreateEvent :one +INSERT INTO events (tablo_id, title, event_date, start_time, end_time, description, location) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, tablo_id, title, event_date, start_time, end_time, description, location, created_at, updated_at; + +-- name: GetEventByID :one +SELECT id, tablo_id, title, event_date, start_time, end_time, description, location, created_at, updated_at +FROM events +WHERE id = $1 AND tablo_id = $2; + +-- name: ListEventsByTabloRange :many +SELECT id, tablo_id, title, event_date, start_time, end_time, description, location, created_at, updated_at +FROM events +WHERE tablo_id = $1 + AND event_date >= $2 + AND event_date <= $3 +ORDER BY event_date, start_time, title; diff --git a/backend/internal/web/csrf_test.go b/backend/internal/web/csrf_test.go index 32fcb6a..4c8e0f8 100644 --- a/backend/internal/web/csrf_test.go +++ b/backend/internal/web/csrf_test.go @@ -22,7 +22,7 @@ func newTestRouterWithCSRF(q *sqlc.Queries, store *auth.Store) http.Handler { csrfKey[i] = byte(i + 1) } deps := AuthDeps{Queries: q, Store: store, Secure: false} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost") if err != nil { panic("newTestRouterWithCSRF: " + err.Error()) } diff --git a/backend/internal/web/handlers_auth_test.go b/backend/internal/web/handlers_auth_test.go index 7c61adf..dc6bdde 100644 --- a/backend/internal/web/handlers_auth_test.go +++ b/backend/internal/web/handlers_auth_test.go @@ -36,7 +36,7 @@ var testCSRFKey = func() []byte { // Referer header are accepted. func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { deps := AuthDeps{Queries: q, Store: store, Secure: false} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newTestRouter: " + err.Error()) } @@ -47,7 +47,7 @@ func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { // enabling rate-limit tests to use a fake clock. func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler { deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newTestRouterWithLimiter: " + err.Error()) } @@ -56,7 +56,7 @@ func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.Limit func newAuthPageRouter(t *testing.T, deps AuthDeps) http.Handler { t.Helper() - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } diff --git a/backend/internal/web/handlers_etapes_test.go b/backend/internal/web/handlers_etapes_test.go index 8ce27d0..c086416 100644 --- a/backend/internal/web/handlers_etapes_test.go +++ b/backend/internal/web/handlers_etapes_test.go @@ -20,7 +20,7 @@ func newEtapeTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} etapeDeps := EtapesDeps{Queries: q} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, EventsDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newEtapeTestRouter: " + err.Error()) } diff --git a/backend/internal/web/handlers_events.go b/backend/internal/web/handlers_events.go new file mode 100644 index 0000000..bdae3ca --- /dev/null +++ b/backend/internal/web/handlers_events.go @@ -0,0 +1,242 @@ +package web + +import ( + "log/slog" + "net/http" + "strings" + "time" + + "backend/internal/db/sqlc" + "backend/templates" + + "github.com/google/uuid" + "github.com/gorilla/csrf" + "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"), + NextMonth: monthStart.AddDate(0, 1, 0).Format("2006-01"), + 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 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 + } + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, calendar, "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 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 +} diff --git a/backend/internal/web/handlers_files.go b/backend/internal/web/handlers_files.go index 2decced..b70ffe1 100644 --- a/backend/internal/web/handlers_files.go +++ b/backend/internal/web/handlers_files.go @@ -95,7 +95,7 @@ func TabloFilesTabHandler(deps FilesDeps) http.HandlerFunc { _ = templates.FilesTabFragment(tablo, fileList, csrf.Token(r)).Render(r.Context(), w) return } - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, fileList, "files").Render(r.Context(), w) + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, fileList, templates.EventsCalendar{}, "files").Render(r.Context(), w) } } @@ -119,7 +119,7 @@ func TabloTasksTabHandler(deps FilesDeps) http.HandlerFunc { _ = templates.TasksTabFragment(tablo, tasks, etapes, counts, filter, csrf.Token(r)).Render(r.Context(), w) return } - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, etapes, counts, filter, nil, "tasks").Render(r.Context(), w) + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, etapes, counts, filter, nil, templates.EventsCalendar{}, "tasks").Render(r.Context(), w) } } diff --git a/backend/internal/web/handlers_files_test.go b/backend/internal/web/handlers_files_test.go index b2f07e6..382fff6 100644 --- a/backend/internal/web/handlers_files_test.go +++ b/backend/internal/web/handlers_files_test.go @@ -61,7 +61,7 @@ func newFileTestRouter(q *sqlc.Queries, store *auth.Store, fileStore files.FileS tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} fileDeps := FilesDeps{Queries: q, Files: fileStore, MaxUploadMB: 25} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost") if err != nil { panic("newFileTestRouter: " + err.Error()) } @@ -170,7 +170,7 @@ func TestFileUploadTooLarge(t *testing.T) { tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} fileDeps := FilesDeps{Queries: q, Files: stub, MaxUploadMB: 1} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } diff --git a/backend/internal/web/handlers_social_test.go b/backend/internal/web/handlers_social_test.go index a2ca4cf..6c7e5fa 100644 --- a/backend/internal/web/handlers_social_test.go +++ b/backend/internal/web/handlers_social_test.go @@ -72,7 +72,7 @@ func withGoogleClaims(deps AuthDeps, claims auth.ProviderClaims) AuthDeps { func newSocialRouter(t *testing.T, deps AuthDeps) http.Handler { t.Helper() - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } diff --git a/backend/internal/web/handlers_tablos.go b/backend/internal/web/handlers_tablos.go index 2c89b4e..acb6425 100644 --- a/backend/internal/web/handlers_tablos.go +++ b/backend/internal/web/handlers_tablos.go @@ -202,7 +202,7 @@ func TabloDetailHandler(deps TablosDeps) http.HandlerFunc { tasks = []sqlc.Task{} } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, "overview").Render(r.Context(), w) + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, templates.EventsCalendar{}, "overview").Render(r.Context(), w) } } @@ -308,7 +308,7 @@ func TabloUpdateHandler(deps TablosDeps) http.HandlerFunc { if tasks == nil { tasks = []sqlc.Task{} } - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, "overview").Render(ctx, w) + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, templates.EventsCalendar{}, "overview").Render(ctx, w) return } diff --git a/backend/internal/web/handlers_tablos_test.go b/backend/internal/web/handlers_tablos_test.go index c4adc62..cb3ca81 100644 --- a/backend/internal/web/handlers_tablos_test.go +++ b/backend/internal/web/handlers_tablos_test.go @@ -27,7 +27,7 @@ import ( func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newTabloTestRouter: " + err.Error()) } diff --git a/backend/internal/web/handlers_tasks_test.go b/backend/internal/web/handlers_tasks_test.go index ad403df..c4302bb 100644 --- a/backend/internal/web/handlers_tasks_test.go +++ b/backend/internal/web/handlers_tasks_test.go @@ -30,7 +30,7 @@ func newTaskTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newTaskTestRouter: " + err.Error()) } diff --git a/backend/internal/web/handlers_test.go b/backend/internal/web/handlers_test.go index 41067a9..838f29f 100644 --- a/backend/internal/web/handlers_test.go +++ b/backend/internal/web/handlers_test.go @@ -92,7 +92,7 @@ func TestReadyz_Down(t *testing.T) { // was public. The HTMX demo content is tested by // TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go. func TestIndex_UnauthRedirects(t *testing.T) { - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } @@ -110,7 +110,7 @@ func TestIndex_UnauthRedirects(t *testing.T) { } func TestDemoTime_Fragment(t *testing.T) { - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } @@ -136,7 +136,7 @@ func TestDemoTime_Fragment(t *testing.T) { } func TestRequestID_HeaderSet(t *testing.T) { - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index 8e6d00d..9a43db4 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -48,7 +48,7 @@ type Pinger interface { // trustedOrigins is an optional list of additional origins for the CSRF // referer check (used in integration tests to allow localhost requests without // a Referer header). In production, pass no extra args — leave empty. -func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, etapeDeps EtapesDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) (http.Handler, error) { +func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, etapeDeps EtapesDeps, eventDeps EventsDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) (http.Handler, error) { r := chi.NewRouter() r.Use(RequestIDMiddleware) r.Use(chimw.RealIP) @@ -114,6 +114,11 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep r.Post("/tablos/{id}/etapes/{etape_id}", EtapeUpdateHandler(etapeDeps)) r.Get("/tablos/{id}/etapes/{etape_id}/delete-confirm", EtapeDeleteConfirmHandler(etapeDeps)) r.Post("/tablos/{id}/etapes/{etape_id}/delete", EtapeDeleteHandler(etapeDeps)) + // Events tab and event routes — static segments BEFORE future parametric routes. + r.Get("/tablos/{id}/events", TabloEventsTabHandler(eventDeps)) + r.Get("/tablos/{id}/events/new", EventNewFormHandler(eventDeps)) + r.Get("/tablos/{id}/events/cancel-new", EventCancelNewHandler(eventDeps)) + r.Post("/tablos/{id}/events", EventCreateHandler(eventDeps)) // Parametric task routes — must come after static task segments. r.Get("/tablos/{id}/tasks/{task_id}/show", TaskShowHandler(taskDeps)) r.Get("/tablos/{id}/tasks/{task_id}/edit", TaskEditHandler(taskDeps)) diff --git a/backend/migrations/0008_events.sql b/backend/migrations/0008_events.sql new file mode 100644 index 0000000..b6157c9 --- /dev/null +++ b/backend/migrations/0008_events.sql @@ -0,0 +1,28 @@ +-- migrations/0008_events.sql +-- Phase 10: Events + +-- +goose Up + +CREATE TABLE events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tablo_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, + title text NOT NULL, + event_date date NOT NULL, + start_time time NOT NULL, + end_time time, + description text, + location text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT events_title_not_blank CHECK (length(trim(title)) > 0), + CONSTRAINT events_end_after_start CHECK (end_time IS NULL OR end_time > start_time) +); + +CREATE INDEX events_tablo_month_idx ON events(tablo_id, event_date, start_time, title); +CREATE INDEX events_date_idx ON events(event_date, start_time); + +-- +goose Down + +DROP INDEX IF EXISTS events_date_idx; +DROP INDEX IF EXISTS events_tablo_month_idx; +DROP TABLE IF EXISTS events; diff --git a/backend/templates/events.templ b/backend/templates/events.templ new file mode 100644 index 0000000..294755d --- /dev/null +++ b/backend/templates/events.templ @@ -0,0 +1,167 @@ +package templates + +import ( + "backend/internal/db/sqlc" + "backend/internal/web/ui" + "github.com/google/uuid" +) + +templ EventsTabFragment(tablo sqlc.Tablo, calendar EventsCalendar, csrfToken string) { +
{ calendar.MonthLabel }
+{ EventMoreLabel(day.MoreCount) }
+ } +