From 2989c0b9171446e0f2c0f822b3dcb81a741edfdd Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 16 May 2026 07:26:49 +0200 Subject: [PATCH] feat(11-01): implement planning agenda page --- backend/cmd/web/main.go | 3 +- backend/internal/web/csrf_test.go | 2 +- backend/internal/web/handlers_auth_test.go | 6 +- backend/internal/web/handlers_etapes_test.go | 2 +- backend/internal/web/handlers_events_test.go | 2 +- backend/internal/web/handlers_files_test.go | 4 +- backend/internal/web/handlers_planning.go | 59 ++++++++++++ backend/internal/web/handlers_social_test.go | 2 +- backend/internal/web/handlers_tablos_test.go | 2 +- backend/internal/web/handlers_tasks_test.go | 2 +- backend/internal/web/handlers_test.go | 6 +- backend/internal/web/router.go | 3 +- backend/templates/planning.templ | 67 ++++++++++++++ backend/templates/planning_forms.go | 95 ++++++++++++++++++++ 14 files changed, 239 insertions(+), 16 deletions(-) create mode 100644 backend/internal/web/handlers_planning.go create mode 100644 backend/templates/planning.templ create mode 100644 backend/templates/planning_forms.go diff --git a/backend/cmd/web/main.go b/backend/cmd/web/main.go index bdbba18..cfe04fc 100644 --- a/backend/cmd/web/main.go +++ b/backend/cmd/web/main.go @@ -149,9 +149,10 @@ func main() { fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB} etapeDeps := web.EtapesDeps{Queries: q} eventDeps := web.EventsDeps{Queries: q} + planningDeps := web.PlanningDeps{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, eventDeps, fileDeps, csrfKey, env) + router, err := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, etapeDeps, eventDeps, planningDeps, fileDeps, csrfKey, env) if err != nil { slog.Error("router init failed", "err", err) os.Exit(1) diff --git a/backend/internal/web/csrf_test.go b/backend/internal/web/csrf_test.go index 4c8e0f8..fc54a51 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}, EventsDeps{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}, PlanningDeps{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 dc6bdde..3367114 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}, EventsDeps{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}, PlanningDeps{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}, EventsDeps{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}, PlanningDeps{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{}, EventsDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, PlanningDeps{}, 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 c086416..b226e71 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, EventsDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newEtapeTestRouter: " + err.Error()) } diff --git a/backend/internal/web/handlers_events_test.go b/backend/internal/web/handlers_events_test.go index 1aed581..ad97be9 100644 --- a/backend/internal/web/handlers_events_test.go +++ b/backend/internal/web/handlers_events_test.go @@ -26,7 +26,7 @@ func newEventTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { taskDeps := TasksDeps{Queries: q} etapeDeps := EtapesDeps{Queries: q} eventDeps := EventsDeps{Queries: q} - router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, eventDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, eventDeps, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") if err != nil { panic("newEventTestRouter: " + err.Error()) } diff --git a/backend/internal/web/handlers_files_test.go b/backend/internal/web/handlers_files_test.go index 382fff6..53a029c 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}, EventsDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{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}, EventsDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } diff --git a/backend/internal/web/handlers_planning.go b/backend/internal/web/handlers_planning.go new file mode 100644 index 0000000..1b7d0c2 --- /dev/null +++ b/backend/internal/web/handlers_planning.go @@ -0,0 +1,59 @@ +package web + +import ( + "log/slog" + "net/http" + "strings" + "time" + + "backend/internal/auth" + "backend/internal/db/sqlc" + "backend/templates" + + "github.com/gorilla/csrf" +) + +type PlanningDeps struct { + Queries *sqlc.Queries + Now func() time.Time +} + +func parsePlanningStart(raw string, now time.Time) time.Time { + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + raw = strings.TrimSpace(raw) + if raw == "" { + return today + } + parsed, err := time.Parse("2006-01-02", raw) + if err != nil { + return today + } + return time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.Local) +} + +func PlanningPageHandler(deps PlanningDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, user, _ := auth.Authed(r.Context()) + now := time.Now + if deps.Now != nil { + now = deps.Now + } + start := parsePlanningStart(r.URL.Query().Get("start"), now()) + end := start.AddDate(0, 0, 13) + + rows, err := deps.Queries.ListUserEventsRange(r.Context(), sqlc.ListUserEventsRangeParams{ + UserID: user.ID, + StartDate: pgDateFromTime(start), + EndDate: pgDateFromTime(end), + }) + if err != nil { + slog.Default().Error("planning: ListUserEventsRange failed", "user_id", user.ID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + agenda := templates.NewPlanningAgenda(start, end, parsePlanningStart("", now()), rows) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.PlanningPage(user, csrf.Token(r), agenda).Render(r.Context(), w) + } +} diff --git a/backend/internal/web/handlers_social_test.go b/backend/internal/web/handlers_social_test.go index 6c7e5fa..6fd33dc 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{}, EventsDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") if err != nil { t.Fatalf("NewRouter: %v", err) } diff --git a/backend/internal/web/handlers_tablos_test.go b/backend/internal/web/handlers_tablos_test.go index cb3ca81..bb7a909 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}, EventsDeps{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}, PlanningDeps{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 c4302bb..8fbdcc6 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}, EventsDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{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 838f29f..0354120 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{}, EventsDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, PlanningDeps{}, 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{}, EventsDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, PlanningDeps{}, 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{}, EventsDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, PlanningDeps{}, 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 454f747..01b8605 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, eventDeps EventsDeps, 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, planningDeps PlanningDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) (http.Handler, error) { r := chi.NewRouter() r.Use(RequestIDMiddleware) r.Use(chimw.RealIP) @@ -84,6 +84,7 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep r.Get("/", TablosListHandler(tabloDeps)) r.Post("/logout", LogoutHandler(deps)) r.Get("/account/providers", AccountProvidersHandler(deps)) + r.Get("/planning", PlanningPageHandler(planningDeps)) // Static segments BEFORE parametric (Pitfall 1 โ€” chi v5 route resolution). r.Get("/tablos/new", TablosNewHandler(tabloDeps)) r.Post("/tablos", TablosCreateHandler(tabloDeps)) diff --git a/backend/templates/planning.templ b/backend/templates/planning.templ new file mode 100644 index 0000000..a3bb862 --- /dev/null +++ b/backend/templates/planning.templ @@ -0,0 +1,67 @@ +package templates + +import ( + "backend/internal/auth" +) + +templ PlanningPage(user *auth.User, csrfToken string, agenda PlanningAgenda) { + @Layout("Planning - Xtablo", user, csrfToken) { +
+
+
+

Planning

+

{ agenda.RangeLabel }

+
+ +
+ if len(agenda.Events) == 0 { +
+

No events in this range

+

Use the navigation controls to browse another 14-day window.

+
+ } else { + + } +
+ } +} + +templ PlanningEventListItem(event PlanningEventRow) { +
  • + +
    +
    + { event.TimeRange } + { event.Title } +
    +
    + + if event.HasColor { + + } + { event.TabloTitle } + + if event.HasLocation { + + { event.Location } + } +
    +
    +
    +
  • +} diff --git a/backend/templates/planning_forms.go b/backend/templates/planning_forms.go new file mode 100644 index 0000000..5ffa88c --- /dev/null +++ b/backend/templates/planning_forms.go @@ -0,0 +1,95 @@ +package templates + +import ( + "time" + + "backend/internal/db/sqlc" +) + +type PlanningAgenda struct { + Start time.Time + End time.Time + Today time.Time + RangeLabel string + PrevURL string + TodayURL string + NextURL string + ShowingToday bool + Events []PlanningEventRow +} + +type PlanningEventRow struct { + Title string + TimeRange string + TabloTitle string + TabloColor string + HasColor bool + Location string + HasLocation bool + URL string +} + +func NewPlanningAgenda(start time.Time, end time.Time, today time.Time, rows []sqlc.ListUserEventsRangeRow) PlanningAgenda { + events := make([]PlanningEventRow, 0, len(rows)) + for _, row := range rows { + location := "" + if row.Location.Valid { + location = row.Location.String + } + color := "" + if row.TabloColor.Valid { + color = row.TabloColor.String + } + events = append(events, PlanningEventRow{ + Title: row.Title, + TimeRange: PlanningEventTimeRange(row), + TabloTitle: row.TabloTitle, + TabloColor: color, + HasColor: color != "", + Location: location, + HasLocation: location != "", + URL: PlanningEventURL(row), + }) + } + return PlanningAgenda{ + Start: start, + End: end, + Today: today, + RangeLabel: PlanningRangeLabel(start, end), + PrevURL: PlanningURL(start.AddDate(0, 0, -14)), + TodayURL: "/planning", + NextURL: PlanningURL(start.AddDate(0, 0, 14)), + ShowingToday: samePlanningDay(start, today), + Events: events, + } +} + +func PlanningURL(start time.Time) string { + return "/planning?start=" + start.Format("2006-01-02") +} + +func PlanningEventURL(row sqlc.ListUserEventsRangeRow) string { + return "/tablos/" + row.TabloID.String() + "/events?month=" + row.EventDate.Time.Format("2006-01") +} + +func PlanningEventTimeRange(row sqlc.ListUserEventsRangeRow) string { + start := FormatEventTime(row.StartTime) + if start == "" { + return "" + } + if !row.EndTime.Valid { + return start + } + return start + "-" + FormatEventTime(row.EndTime) +} + +func PlanningRangeLabel(start time.Time, end time.Time) string { + if start.Year() == end.Year() { + return start.Format("January 2") + " - " + end.Format("January 2, 2006") + } + return start.Format("January 2, 2006") + " - " + end.Format("January 2, 2006") +} + +func samePlanningDay(a time.Time, b time.Time) bool { + return a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day() +}