go-htmx-gsd #1

Merged
arthur merged 558 commits from go-htmx-gsd into main 2026-05-23 15:16:44 +00:00
18 changed files with 636 additions and 20 deletions
Showing only changes of commit 0bfe8cfbb4 - Show all commits

View file

@ -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)

View file

@ -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;

View file

@ -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())
}

View file

@ -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)
}

View file

@ -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())
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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())
}

View file

@ -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())
}

View file

@ -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)
}

View file

@ -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))

View file

@ -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;

View file

@ -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) {
<div id="events-tab" class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold leading-snug text-slate-800">Events</h2>
<p class="text-sm text-slate-600">{ calendar.MonthLabel }</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<a
href={ templ.SafeURL(EventMonthURL(tablo.ID, calendar.PrevMonth)) }
hx-get={ EventMonthURL(tablo.ID, calendar.PrevMonth) }
hx-target="#tab-content"
hx-swap="innerHTML"
hx-push-url={ EventMonthURL(tablo.ID, calendar.PrevMonth) }
class="ui-button ui-button-soft-neutral-md"
aria-label={ "Previous month: " + calendar.PrevMonth }
>Previous month</a>
<a
href={ templ.SafeURL(EventMonthURL(tablo.ID, calendar.NextMonth)) }
hx-get={ EventMonthURL(tablo.ID, calendar.NextMonth) }
hx-target="#tab-content"
hx-swap="innerHTML"
hx-push-url={ EventMonthURL(tablo.ID, calendar.NextMonth) }
class="ui-button ui-button-soft-neutral-md"
aria-label={ "Next month: " + calendar.NextMonth }
>Next month</a>
@ui.Button(ui.ButtonProps{
Label: "New event",
Variant: ui.ButtonVariantDefault,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
"hx-get": EventNewURL(tablo.ID, calendar.Month),
"hx-target": "#event-form-slot",
"hx-swap": "innerHTML",
"aria-label": "New event",
},
})
</div>
</div>
<div id="event-form-slot"></div>
<div class="overflow-x-auto">
<div class="grid min-w-[680px] grid-cols-7 gap-px rounded border border-slate-200 bg-slate-200">
for _, label := range calendar.WeekdayLabs {
<div class="bg-slate-50 px-2 py-2 text-sm font-semibold text-slate-700">{ label }</div>
}
for _, day := range calendar.Days {
@EventDayCell(tablo.ID, calendar.Month, day)
}
</div>
</div>
</div>
}
templ EventDayCell(tabloID uuid.UUID, month string, day EventCalendarDay) {
<div
class="min-h-24 bg-white p-2"
if !day.InMonth {
class="min-h-24 bg-slate-50 p-2 text-slate-400"
}
>
<div class="flex items-start justify-between gap-2">
<span class="text-sm font-semibold">{ day.DayNumber }</span>
if day.InMonth {
<button
type="button"
class="text-xs text-slate-500 hover:text-slate-800"
hx-get={ EventNewForDayURL(tabloID, day.Date, month) }
hx-target="#event-form-slot"
hx-swap="innerHTML"
aria-label={ "Create event on " + day.Date }
>+</button>
}
</div>
if len(day.Events) > 0 {
<div class="mt-2 space-y-1">
for _, event := range EventDayEvents(day.Events) {
<button
type="button"
class="block w-full truncate rounded border border-slate-200 bg-slate-50 px-2 py-1 text-left text-sm text-slate-800 hover:border-slate-400"
hx-get={ "/tablos/" + tabloID.String() + "/events/" + event.ID.String() + "/edit?month=" + month }
hx-target="#event-form-slot"
hx-swap="innerHTML"
aria-label={ "Edit event: " + event.Title }
>{ event.Title }</button>
}
if day.MoreCount > 0 {
<p class="text-xs text-slate-500">{ EventMoreLabel(day.MoreCount) }</p>
}
</div>
}
</div>
}
templ EventCreateFormFragment(tabloID uuid.UUID, form EventCreateForm, errs EventCreateErrors, csrfToken string, month string) {
<form
method="POST"
action={ templ.SafeURL(EventPostURL(tabloID, month)) }
hx-post={ EventPostURL(tabloID, month) }
hx-target="#events-tab"
hx-swap="outerHTML"
class="rounded border border-slate-200 bg-white p-3 shadow-sm space-y-3"
>
@ui.CSRFField(csrfToken)
@GeneralError(errs.General)
<div>
<label for="event-title" class="block text-sm font-medium text-slate-700">Title</label>
<input id="event-title" type="text" name="title" value={ form.Title } maxlength="255" required class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"/>
@FieldError(errs.Title)
</div>
<div class="grid gap-3 sm:grid-cols-3">
<div>
<label for="event-date" class="block text-sm font-medium text-slate-700">Date</label>
<input id="event-date" type="date" name="event_date" value={ form.EventDate } required class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-slate-500 focus:outline-none"/>
@FieldError(errs.EventDate)
</div>
<div>
<label for="event-start-time" class="block text-sm font-medium text-slate-700">Start time</label>
<input id="event-start-time" type="time" name="start_time" value={ form.StartTime } required class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-slate-500 focus:outline-none"/>
@FieldError(errs.StartTime)
</div>
<div>
<label for="event-end-time" class="block text-sm font-medium text-slate-700">End time <span class="text-slate-400">(optional)</span></label>
<input id="event-end-time" type="time" name="end_time" value={ form.EndTime } class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-slate-500 focus:outline-none"/>
@FieldError(errs.EndTime)
</div>
</div>
<div>
<label for="event-location" class="block text-sm font-medium text-slate-700">Location <span class="text-slate-400">(optional)</span></label>
<input id="event-location" type="text" name="location" value={ form.Location } maxlength="255" class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"/>
</div>
<div>
<label for="event-description" class="block text-sm font-medium text-slate-700">Description <span class="text-slate-400">(optional)</span></label>
<textarea id="event-description" name="description" rows="3" class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none">{ form.Description }</textarea>
</div>
<div class="flex items-center gap-2">
@ui.Button(ui.ButtonProps{
Label: "Create event",
Variant: ui.ButtonVariantDefault,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "submit",
})
@ui.Button(ui.ButtonProps{
Label: "Close form",
Variant: ui.ButtonVariantNeutral,
Tone: ui.ButtonToneSoft,
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/" + tabloID.String() + "/events/cancel-new",
"hx-target": "#event-form-slot",
"hx-swap": "innerHTML",
},
})
</div>
</form>
}

View file

@ -0,0 +1,142 @@
package templates
import (
"strconv"
"time"
"backend/internal/db/sqlc"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type EventCreateForm struct {
Title string
EventDate string
StartTime string
EndTime string
Location string
Description string
}
type EventCreateErrors struct {
Title string
EventDate string
StartTime string
EndTime string
General string
}
type EventsCalendar struct {
Month string
MonthLabel string
PrevMonth string
NextMonth string
Days []EventCalendarDay
WeekdayLabs []string
}
type EventCalendarDay struct {
Date string
DayNumber int
InMonth bool
Events []sqlc.Event
MoreCount int
}
func DefaultWeekdayLabels() []string {
return []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
}
func EventFormFromEvent(event sqlc.Event) EventCreateForm {
return EventCreateForm{
Title: event.Title,
EventDate: FormatEventDate(event.EventDate),
StartTime: FormatEventTime(event.StartTime),
EndTime: FormatOptionalEventTime(event.EndTime),
Location: event.Location.String,
Description: event.Description.String,
}
}
func FormatEventDate(date pgtype.Date) string {
if !date.Valid {
return ""
}
return date.Time.Format("2006-01-02")
}
func FormatEventTime(value pgtype.Time) string {
if !value.Valid {
return ""
}
minutes := value.Microseconds / int64(time.Minute/time.Microsecond)
hours := minutes / 60
mins := minutes % 60
return twoDigits(int(hours)) + ":" + twoDigits(int(mins))
}
func FormatOptionalEventTime(value pgtype.Time) string {
if !value.Valid {
return ""
}
return FormatEventTime(value)
}
func EventTimeRange(event sqlc.Event) string {
start := FormatEventTime(event.StartTime)
if start == "" {
return ""
}
if !event.EndTime.Valid {
return start
}
return start + "-" + FormatEventTime(event.EndTime)
}
func EventDayEvents(events []sqlc.Event) []sqlc.Event {
if len(events) <= 3 {
return events
}
return events[:3]
}
func EventMoreLabel(count int) string {
if count <= 0 {
return ""
}
return "+" + strconv.Itoa(count) + " more"
}
func EventNewURL(tabloID uuid.UUID, month string) string {
if month == "" {
return "/tablos/" + tabloID.String() + "/events/new"
}
return "/tablos/" + tabloID.String() + "/events/new?month=" + month
}
func EventNewForDayURL(tabloID uuid.UUID, date string, month string) string {
url := "/tablos/" + tabloID.String() + "/events/new?date=" + date
if month != "" {
url += "&month=" + month
}
return url
}
func EventPostURL(tabloID uuid.UUID, month string) string {
if month == "" {
return "/tablos/" + tabloID.String() + "/events"
}
return "/tablos/" + tabloID.String() + "/events?month=" + month
}
func EventMonthURL(tabloID uuid.UUID, month string) string {
return "/tablos/" + tabloID.String() + "/events?month=" + month
}
func twoDigits(value int) string {
if value < 10 {
return "0" + strconv.Itoa(value)
}
return strconv.Itoa(value)
}

View file

@ -171,7 +171,7 @@ templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) {
// files and tasks are pre-fetched slices for the active tab (may be nil for inactive tabs).
// UI-SPEC §3 Interaction Contract — GET /tablos/{id}.
// D-07: signature includes activeTab string param; D-08: tab bar links carry hx-push-url.
templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, files []sqlc.TabloFile, activeTab string) {
templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, files []sqlc.TabloFile, events EventsCalendar, activeTab string) {
@Layout("Tablos — Xtablo", user, csrfToken) {
<div class="mb-4">
<a href="/" class="text-sm text-slate-600 hover:underline">&larr; Back to tablos</a>
@ -223,6 +223,18 @@ templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks
class="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 hover:border-b-2 hover:border-slate-400 -mb-px"
}
>Files</a>
<a
href={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/events") }
hx-get={ "/tablos/" + tablo.ID.String() + "/events" }
hx-target="#tab-content"
hx-swap="innerHTML"
hx-push-url={ "/tablos/" + tablo.ID.String() + "/events" }
if activeTab == "events" {
class="px-4 py-2 text-sm font-semibold border-b-2 border-slate-800 text-slate-800 -mb-px"
} else {
class="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 hover:border-b-2 hover:border-slate-400 -mb-px"
}
>Events</a>
</nav>
<!-- Tab content area — HTMX tab switches target this div -->
<div id="tab-content" class="mt-6">
@ -230,6 +242,8 @@ templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks
@TasksTabFragment(tablo, tasks, etapes, counts, filter, csrfToken)
} else if activeTab == "files" {
@FilesTabFragment(tablo, files, csrfToken)
} else if activeTab == "events" {
@EventsTabFragment(tablo, events, csrfToken)
} else {
@TabloOverviewTabFragment(tablo, csrfToken)
}