feat(11-01): implement planning agenda page

This commit is contained in:
Arthur Belleville 2026-05-16 07:26:49 +02:00
parent c5c3bbe2d0
commit 2989c0b917
No known key found for this signature in database
14 changed files with 239 additions and 16 deletions

View file

@ -149,9 +149,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} eventDeps := web.EventsDeps{Queries: q}
planningDeps := web.PlanningDeps{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, eventDeps, fileDeps, csrfKey, env) router, err := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, etapeDeps, eventDeps, planningDeps, 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)

View file

@ -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}, 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 { if err != nil {
panic("newTestRouterWithCSRF: " + err.Error()) panic("newTestRouterWithCSRF: " + err.Error())
} }

View file

@ -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}, 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 { 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}, 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 { 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{}, 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 { if err != nil {
t.Fatalf("NewRouter: %v", err) 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} 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, 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 { if err != nil {
panic("newEtapeTestRouter: " + err.Error()) panic("newEtapeTestRouter: " + err.Error())
} }

View file

@ -26,7 +26,7 @@ func newEventTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
taskDeps := TasksDeps{Queries: q} taskDeps := TasksDeps{Queries: q}
etapeDeps := EtapesDeps{Queries: q} etapeDeps := EtapesDeps{Queries: q}
eventDeps := EventsDeps{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 { if err != nil {
panic("newEventTestRouter: " + err.Error()) panic("newEventTestRouter: " + err.Error())
} }

View file

@ -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}, 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 { 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}, 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 { if err != nil {
t.Fatalf("NewRouter: %v", err) t.Fatalf("NewRouter: %v", err)
} }

View file

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

View file

@ -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{}, 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 { if err != nil {
t.Fatalf("NewRouter: %v", err) t.Fatalf("NewRouter: %v", err)
} }

View file

@ -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}, 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 { if err != nil {
panic("newTabloTestRouter: " + err.Error()) 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} 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}, 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 { if err != nil {
panic("newTaskTestRouter: " + err.Error()) 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 // 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{}, 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 { 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{}, 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 { 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{}, 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 { if err != nil {
t.Fatalf("NewRouter: %v", err) 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 // 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, 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 := chi.NewRouter()
r.Use(RequestIDMiddleware) r.Use(RequestIDMiddleware)
r.Use(chimw.RealIP) r.Use(chimw.RealIP)
@ -84,6 +84,7 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep
r.Get("/", TablosListHandler(tabloDeps)) r.Get("/", TablosListHandler(tabloDeps))
r.Post("/logout", LogoutHandler(deps)) r.Post("/logout", LogoutHandler(deps))
r.Get("/account/providers", AccountProvidersHandler(deps)) r.Get("/account/providers", AccountProvidersHandler(deps))
r.Get("/planning", PlanningPageHandler(planningDeps))
// Static segments BEFORE parametric (Pitfall 1 — chi v5 route resolution). // Static segments BEFORE parametric (Pitfall 1 — chi v5 route resolution).
r.Get("/tablos/new", TablosNewHandler(tabloDeps)) r.Get("/tablos/new", TablosNewHandler(tabloDeps))
r.Post("/tablos", TablosCreateHandler(tabloDeps)) r.Post("/tablos", TablosCreateHandler(tabloDeps))

View file

@ -0,0 +1,67 @@
package templates
import (
"backend/internal/auth"
)
templ PlanningPage(user *auth.User, csrfToken string, agenda PlanningAgenda) {
@Layout("Planning - Xtablo", user, csrfToken) {
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-[28px] font-semibold leading-tight">Planning</h1>
<p class="mt-1 text-sm text-slate-600">{ agenda.RangeLabel }</p>
</div>
<nav class="flex flex-wrap items-center gap-2" aria-label="Planning navigation">
<a href={ templ.SafeURL(agenda.PrevURL) } class="ui-button ui-button-soft-neutral-md">Previous 14 days</a>
<a
href={ templ.SafeURL(agenda.TodayURL) }
if agenda.ShowingToday {
class="ui-button ui-button-soft-neutral-md"
} else {
class="ui-button ui-button-solid-default-md"
}
>Today</a>
<a href={ templ.SafeURL(agenda.NextURL) } class="ui-button ui-button-soft-neutral-md">Next 14 days</a>
</nav>
</div>
if len(agenda.Events) == 0 {
<div class="border border-slate-200 bg-slate-50 px-4 py-12">
<h2 class="text-xl font-semibold leading-snug text-slate-800">No events in this range</h2>
<p class="mt-2 text-base text-slate-600">Use the navigation controls to browse another 14-day window.</p>
</div>
} else {
<ul class="divide-y divide-slate-200 border-y border-slate-200" aria-label="Planning agenda">
for _, event := range agenda.Events {
@PlanningEventListItem(event)
}
</ul>
}
</div>
}
}
templ PlanningEventListItem(event PlanningEventRow) {
<li class="bg-white px-4 py-3 hover:bg-slate-50">
<a href={ templ.SafeURL(event.URL) } class="block focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2" aria-label={ "Open event in tablo: " + event.Title }>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
<span class="text-sm font-medium text-slate-700">{ event.TimeRange }</span>
<span class="text-base font-semibold text-slate-900">{ event.Title }</span>
</div>
<div class="flex flex-wrap items-center gap-2 text-sm text-slate-600">
<span class="inline-flex items-center gap-1">
if event.HasColor {
<span class="inline-block h-2.5 w-2.5 rounded-full" style={ "background-color: " + event.TabloColor }></span>
}
<span>{ event.TabloTitle }</span>
</span>
if event.HasLocation {
<span aria-hidden="true">·</span>
<span>{ event.Location }</span>
}
</div>
</div>
</a>
</li>
}

View file

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