feat(10-01): add events calendar creation slice
This commit is contained in:
parent
4fbd960621
commit
0bfe8cfbb4
18 changed files with 636 additions and 20 deletions
|
|
@ -148,9 +148,10 @@ func main() {
|
||||||
|
|
||||||
fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB}
|
fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB}
|
||||||
etapeDeps := web.EtapesDeps{Queries: q}
|
etapeDeps := web.EtapesDeps{Queries: q}
|
||||||
|
eventDeps := web.EventsDeps{Queries: q}
|
||||||
|
|
||||||
// D-09: pass the embedded static FS — binary has zero runtime file dependencies.
|
// 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 {
|
if err != nil {
|
||||||
slog.Error("router init failed", "err", err)
|
slog.Error("router init failed", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
|
||||||
17
backend/internal/db/queries/events.sql
Normal file
17
backend/internal/db/queries/events.sql
Normal 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;
|
||||||
|
|
@ -22,7 +22,7 @@ func newTestRouterWithCSRF(q *sqlc.Queries, store *auth.Store) http.Handler {
|
||||||
csrfKey[i] = byte(i + 1)
|
csrfKey[i] = byte(i + 1)
|
||||||
}
|
}
|
||||||
deps := AuthDeps{Queries: q, Store: store, Secure: false}
|
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 {
|
if err != nil {
|
||||||
panic("newTestRouterWithCSRF: " + err.Error())
|
panic("newTestRouterWithCSRF: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ var testCSRFKey = func() []byte {
|
||||||
// Referer header are accepted.
|
// Referer header are accepted.
|
||||||
func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
||||||
deps := AuthDeps{Queries: q, Store: store, Secure: false}
|
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 {
|
if err != nil {
|
||||||
panic("newTestRouter: " + err.Error())
|
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.
|
// enabling rate-limit tests to use a fake clock.
|
||||||
func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler {
|
func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler {
|
||||||
deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl}
|
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 {
|
if err != nil {
|
||||||
panic("newTestRouterWithLimiter: " + err.Error())
|
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 {
|
func newAuthPageRouter(t *testing.T, deps AuthDeps) http.Handler {
|
||||||
t.Helper()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("NewRouter: %v", err)
|
t.Fatalf("NewRouter: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ func newEtapeTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
||||||
tabloDeps := TablosDeps{Queries: q}
|
tabloDeps := TablosDeps{Queries: q}
|
||||||
taskDeps := TasksDeps{Queries: q}
|
taskDeps := TasksDeps{Queries: q}
|
||||||
etapeDeps := EtapesDeps{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 {
|
if err != nil {
|
||||||
panic("newEtapeTestRouter: " + err.Error())
|
panic("newEtapeTestRouter: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
242
backend/internal/web/handlers_events.go
Normal file
242
backend/internal/web/handlers_events.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -95,7 +95,7 @@ func TabloFilesTabHandler(deps FilesDeps) http.HandlerFunc {
|
||||||
_ = templates.FilesTabFragment(tablo, fileList, csrf.Token(r)).Render(r.Context(), w)
|
_ = templates.FilesTabFragment(tablo, fileList, csrf.Token(r)).Render(r.Context(), w)
|
||||||
return
|
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)
|
_ = templates.TasksTabFragment(tablo, tasks, etapes, counts, filter, csrf.Token(r)).Render(r.Context(), w)
|
||||||
return
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ func newFileTestRouter(q *sqlc.Queries, store *auth.Store, fileStore files.FileS
|
||||||
tabloDeps := TablosDeps{Queries: q}
|
tabloDeps := TablosDeps{Queries: q}
|
||||||
taskDeps := TasksDeps{Queries: q}
|
taskDeps := TasksDeps{Queries: q}
|
||||||
fileDeps := FilesDeps{Queries: q, Files: fileStore, MaxUploadMB: 25}
|
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 {
|
if err != nil {
|
||||||
panic("newFileTestRouter: " + err.Error())
|
panic("newFileTestRouter: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
@ -170,7 +170,7 @@ func TestFileUploadTooLarge(t *testing.T) {
|
||||||
tabloDeps := TablosDeps{Queries: q}
|
tabloDeps := TablosDeps{Queries: q}
|
||||||
taskDeps := TasksDeps{Queries: q}
|
taskDeps := TasksDeps{Queries: q}
|
||||||
fileDeps := FilesDeps{Queries: q, Files: stub, MaxUploadMB: 1}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("NewRouter: %v", err)
|
t.Fatalf("NewRouter: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ func withGoogleClaims(deps AuthDeps, claims auth.ProviderClaims) AuthDeps {
|
||||||
|
|
||||||
func newSocialRouter(t *testing.T, deps AuthDeps) http.Handler {
|
func newSocialRouter(t *testing.T, deps AuthDeps) http.Handler {
|
||||||
t.Helper()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("NewRouter: %v", err)
|
t.Fatalf("NewRouter: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -202,7 +202,7 @@ func TabloDetailHandler(deps TablosDeps) http.HandlerFunc {
|
||||||
tasks = []sqlc.Task{}
|
tasks = []sqlc.Task{}
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
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 {
|
if tasks == nil {
|
||||||
tasks = []sqlc.Task{}
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import (
|
||||||
func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
||||||
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
|
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
|
||||||
tabloDeps := TablosDeps{Queries: q}
|
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 {
|
if err != nil {
|
||||||
panic("newTabloTestRouter: " + err.Error())
|
panic("newTabloTestRouter: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ func newTaskTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
||||||
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
|
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
|
||||||
tabloDeps := TablosDeps{Queries: q}
|
tabloDeps := TablosDeps{Queries: q}
|
||||||
taskDeps := TasksDeps{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 {
|
if err != nil {
|
||||||
panic("newTaskTestRouter: " + err.Error())
|
panic("newTaskTestRouter: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ func TestReadyz_Down(t *testing.T) {
|
||||||
// was public. The HTMX demo content is tested by
|
// was public. The HTMX demo content is tested by
|
||||||
// TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go.
|
// TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go.
|
||||||
func TestIndex_UnauthRedirects(t *testing.T) {
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("NewRouter: %v", err)
|
t.Fatalf("NewRouter: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +110,7 @@ func TestIndex_UnauthRedirects(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDemoTime_Fragment(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 {
|
if err != nil {
|
||||||
t.Fatalf("NewRouter: %v", err)
|
t.Fatalf("NewRouter: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +136,7 @@ func TestDemoTime_Fragment(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequestID_HeaderSet(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 {
|
if err != nil {
|
||||||
t.Fatalf("NewRouter: %v", err)
|
t.Fatalf("NewRouter: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ type Pinger interface {
|
||||||
// trustedOrigins is an optional list of additional origins for the CSRF
|
// trustedOrigins is an optional list of additional origins for the CSRF
|
||||||
// referer check (used in integration tests to allow localhost requests without
|
// referer check (used in integration tests to allow localhost requests without
|
||||||
// a Referer header). In production, pass no extra args — leave empty.
|
// 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 := chi.NewRouter()
|
||||||
r.Use(RequestIDMiddleware)
|
r.Use(RequestIDMiddleware)
|
||||||
r.Use(chimw.RealIP)
|
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.Post("/tablos/{id}/etapes/{etape_id}", EtapeUpdateHandler(etapeDeps))
|
||||||
r.Get("/tablos/{id}/etapes/{etape_id}/delete-confirm", EtapeDeleteConfirmHandler(etapeDeps))
|
r.Get("/tablos/{id}/etapes/{etape_id}/delete-confirm", EtapeDeleteConfirmHandler(etapeDeps))
|
||||||
r.Post("/tablos/{id}/etapes/{etape_id}/delete", EtapeDeleteHandler(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.
|
// 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}/show", TaskShowHandler(taskDeps))
|
||||||
r.Get("/tablos/{id}/tasks/{task_id}/edit", TaskEditHandler(taskDeps))
|
r.Get("/tablos/{id}/tasks/{task_id}/edit", TaskEditHandler(taskDeps))
|
||||||
|
|
|
||||||
28
backend/migrations/0008_events.sql
Normal file
28
backend/migrations/0008_events.sql
Normal 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;
|
||||||
167
backend/templates/events.templ
Normal file
167
backend/templates/events.templ
Normal 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>
|
||||||
|
}
|
||||||
142
backend/templates/events_forms.go
Normal file
142
backend/templates/events_forms.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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).
|
// 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}.
|
// UI-SPEC §3 Interaction Contract — GET /tablos/{id}.
|
||||||
// D-07: signature includes activeTab string param; D-08: tab bar links carry hx-push-url.
|
// 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) {
|
@Layout("Tablos — Xtablo", user, csrfToken) {
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<a href="/" class="text-sm text-slate-600 hover:underline">← Back to tablos</a>
|
<a href="/" class="text-sm text-slate-600 hover:underline">← 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"
|
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>
|
>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>
|
</nav>
|
||||||
<!-- Tab content area — HTMX tab switches target this div -->
|
<!-- Tab content area — HTMX tab switches target this div -->
|
||||||
<div id="tab-content" class="mt-6">
|
<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)
|
@TasksTabFragment(tablo, tasks, etapes, counts, filter, csrfToken)
|
||||||
} else if activeTab == "files" {
|
} else if activeTab == "files" {
|
||||||
@FilesTabFragment(tablo, files, csrfToken)
|
@FilesTabFragment(tablo, files, csrfToken)
|
||||||
|
} else if activeTab == "events" {
|
||||||
|
@EventsTabFragment(tablo, events, csrfToken)
|
||||||
} else {
|
} else {
|
||||||
@TabloOverviewTabFragment(tablo, csrfToken)
|
@TabloOverviewTabFragment(tablo, csrfToken)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue