feat(web): add calendar event detail view
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
||||
"git.arnef.de/arnef/nidus/internal/birthdays"
|
||||
"git.arnef.de/arnef/nidus/internal/db"
|
||||
"git.arnef.de/arnef/nidus/internal/icalfix"
|
||||
"git.arnef.de/arnef/nidus/internal/store"
|
||||
"git.arnef.de/arnef/nidus/internal/web/templates"
|
||||
ical "github.com/emersion/go-ical"
|
||||
)
|
||||
@@ -636,6 +637,70 @@ func newEventFormDefaults(calRef, dateParam string) templates.EventFormData {
|
||||
}
|
||||
}
|
||||
|
||||
// handleEventView renders the read-only detail view for a single event. It
|
||||
// is the landing page when a user clicks an event in the month/week grid;
|
||||
// writable calendars link from here to the edit page.
|
||||
func (s *Server) handleEventView(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
username := userFromContext(r.Context())
|
||||
ref := r.PathValue("ref")
|
||||
id := r.PathValue("id")
|
||||
|
||||
if !eventIDRe.MatchString(id) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
color, form, err := s.eventForDisplay(username, ref, id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.handleCalRefError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = templates.EventDetail(username, templates.EventDetailData{Form: form, Color: color}).Render(context.Background(), w)
|
||||
}
|
||||
|
||||
// eventForDisplay resolves a calendar reference for read access, loads and
|
||||
// decodes the event with the given id, populates the form's display fields
|
||||
// (CalRef, CalendarLabel, Writable), and returns the calendar's color for
|
||||
// the detail view's header dot.
|
||||
func (s *Server) eventForDisplay(username, ref, id string) (color string, form templates.EventFormData, err error) {
|
||||
owner, name, err := s.resolveCalRef(username, ref, false)
|
||||
if err != nil {
|
||||
return "", form, err
|
||||
}
|
||||
data, err := s.store.GetObject(owner, "cal-"+name, id)
|
||||
if err != nil {
|
||||
return "", form, err
|
||||
}
|
||||
form, err = eventFormFromICS(id, data)
|
||||
if err != nil {
|
||||
s.logger.Error("decoding event", "error", err)
|
||||
return "", form, err
|
||||
}
|
||||
form.CalRef = ref
|
||||
label := name
|
||||
if owner != username {
|
||||
label = name + " (" + s.dbase.DisplayName(owner) + ")"
|
||||
}
|
||||
form.CalendarLabel = label
|
||||
form.Writable = owner == username
|
||||
if !form.Writable {
|
||||
if share, err := s.dbase.CalendarShareFor(owner, name, username); err == nil {
|
||||
form.Writable = share.Permission == db.PermWrite
|
||||
}
|
||||
}
|
||||
color, _ = s.dbase.GetCalendarColor(owner, name)
|
||||
return color, form, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) {
|
||||
username := userFromContext(r.Context())
|
||||
ref := r.PathValue("ref")
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.arnef.de/arnef/nidus/internal/db"
|
||||
)
|
||||
|
||||
// seedEvent stores an all-day event (one-day span) in owner's calendar cal
|
||||
// under object id, with the given summary/location/description. Used to set
|
||||
// up events that handleEventView should render.
|
||||
func seedEvent(t *testing.T, s *Server, owner, cal, id, summary, location, description string) {
|
||||
t.Helper()
|
||||
data := "BEGIN:VCALENDAR\r\n" +
|
||||
"VERSION:2.0\r\n" +
|
||||
"PRODID:-//nidus//test//EN\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:" + strings.TrimSuffix(id, ".ics") + "\r\n" +
|
||||
"DTSTAMP:20260101T000000Z\r\n" +
|
||||
"SUMMARY:" + summary + "\r\n" +
|
||||
"LOCATION:" + location + "\r\n" +
|
||||
"DESCRIPTION:" + description + "\r\n" +
|
||||
"DTSTART;VALUE=DATE:20260805\r\n" +
|
||||
"DTEND;VALUE=DATE:20260806\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
if err := s.store.PutObject(owner, "cal-"+cal, id, []byte(data)); err != nil {
|
||||
t.Fatalf("PutObject: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// getEventDetail issues GET /calendar/{ref}/{id} as the session identified by
|
||||
// cookie and returns the recorded response.
|
||||
func getEventDetail(t *testing.T, handler http.Handler, cookie *http.Cookie, ref, id string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
path := "/calendar/" + url.PathEscape(ref) + "/" + url.PathEscape(id)
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.AddCookie(cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestEventDetailShowsOwnEvent(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
handler := s.Handler(emptyStaticFS{})
|
||||
cookie := loginAs(t, handler, "alice", "password")
|
||||
|
||||
id := "aabbccddeeff.ics"
|
||||
seedEvent(t, s, "alice", "work", id, "Team standup", "Meetroom A", "Daily sync with the team")
|
||||
|
||||
rr := getEventDetail(t, handler, cookie, "work", id)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
"Team standup", // summary / title
|
||||
"Meetroom A", // location
|
||||
"Daily sync with the team", // description
|
||||
"work", // calendar label
|
||||
">Edit", // writable → Edit link present
|
||||
"Export .ics",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("expected body to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventDetailReadOnlySharedHasNoEdit(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
handler := s.Handler(emptyStaticFS{})
|
||||
aliceCookie := loginAs(t, handler, "alice", "password")
|
||||
bobCookie := loginAs(t, handler, "bob", "password")
|
||||
|
||||
id := "123456.ics"
|
||||
seedEvent(t, s, "bob", "personal", id, "Bob lunch", "Cafe", "Lunch plans")
|
||||
if err := s.dbase.ShareCalendar("bob", "personal", "alice", db.PermRead); err != nil {
|
||||
t.Fatalf("ShareCalendar: %v", err)
|
||||
}
|
||||
|
||||
// alice (read share) can view but not edit.
|
||||
rr := getEventDetail(t, handler, aliceCookie, "bob~personal", id)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("alice view: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "Bob lunch") {
|
||||
t.Errorf("alice: expected body to contain summary, got:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "shared with you as read-only") {
|
||||
t.Errorf("alice: expected read-only notice, got:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, ">Edit") {
|
||||
t.Errorf("alice: Edit link should not be present on a read-only share, got:\n%s", body)
|
||||
}
|
||||
|
||||
// bob (owner) can still see the Edit link.
|
||||
rrBob := getEventDetail(t, handler, bobCookie, "personal", id)
|
||||
if rrBob.Code != http.StatusOK {
|
||||
t.Fatalf("bob view own: expected 200, got %d", rrBob.Code)
|
||||
}
|
||||
if !strings.Contains(rrBob.Body.String(), ">Edit") {
|
||||
t.Errorf("bob: expected Edit link, got:\n%s", rrBob.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventDetailWriteShareHasEditLink(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
handler := s.Handler(emptyStaticFS{})
|
||||
cookie := loginAs(t, handler, "alice", "password")
|
||||
|
||||
seedEvent(t, s, "bob", "personal", "a1b2c3.ics", "Bob meeting", "Office", "Sync")
|
||||
if err := s.dbase.ShareCalendar("bob", "personal", "alice", db.PermWrite); err != nil {
|
||||
t.Fatalf("ShareCalendar: %v", err)
|
||||
}
|
||||
rr := getEventDetail(t, handler, cookie, "bob~personal", "a1b2c3.ics")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, ">Edit") {
|
||||
t.Errorf("write share should expose Edit link, got:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "shared with you as read-only") {
|
||||
t.Errorf("write share should not show read-only notice, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventDetailNotFound(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
handler := s.Handler(emptyStaticFS{})
|
||||
cookie := loginAs(t, handler, "alice", "password")
|
||||
|
||||
// Valid calendar, missing object.
|
||||
if rr := getEventDetail(t, handler, cookie, "work", "doesnotexist.ics"); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing event: expected 404, got %d", rr.Code)
|
||||
}
|
||||
// Unknown calendar ref.
|
||||
if rr := getEventDetail(t, handler, cookie, "does_not_exist", "0000.ics"); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown calendar: expected 404, got %d", rr.Code)
|
||||
}
|
||||
// Invalid id shape (rejected by eventIDRe before store access).
|
||||
if rr := getEventDetail(t, handler, cookie, "work", "bad/../etc/passwd.ics"); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("invalid id: expected 404, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventDetailRequiresLogin(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
handler := s.Handler(emptyStaticFS{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar/work/anything.ics", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect to login, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventDetailRejectsNonGet(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
handler := s.Handler(emptyStaticFS{})
|
||||
cookie := loginAs(t, handler, "alice", "password")
|
||||
req := httptest.NewRequest(http.MethodPost, "/calendar/work/anything.ics", nil)
|
||||
req.AddCookie(cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405 for POST, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditRouteStillResolves guards against the new detail route
|
||||
// (/calendar/{ref}/{id}) shadowing the more specific edit/delete/export
|
||||
// routes under the same {ref}+{id} prefix in Go's ServeMux.
|
||||
func TestEditRouteStillResolves(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
handler := s.Handler(emptyStaticFS{})
|
||||
cookie := loginAs(t, handler, "alice", "password")
|
||||
|
||||
id := "cafebabe00.ics"
|
||||
seedEvent(t, s, "alice", "work", id, "Edit me", "Room", "Note")
|
||||
|
||||
// GET the edit form — must still hit handleEventEdit, not the detail view.
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar/work/cafebabe00.ics/edit", nil)
|
||||
req.AddCookie(cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("edit GET: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "Edit event") || !strings.Contains(rr.Body.String(), "<form") {
|
||||
t.Fatalf("expected the edit form to render, got:\n%s", rr.Body.String())
|
||||
}
|
||||
|
||||
// The delete route still works (redirect to /web/calendar on success).
|
||||
req = httptest.NewRequest(http.MethodPost, "/calendar/work/cafebabe00.ics/delete", nil)
|
||||
req.AddCookie(cookie)
|
||||
rr = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("delete: expected 303 redirect, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ func (s *Server) Handler(staticFS http.FileSystem) http.Handler {
|
||||
mux.HandleFunc("/calendar", s.requireLogin(s.handleCalendarMonth))
|
||||
mux.HandleFunc("/calendar/week", s.requireLogin(s.handleCalendarWeek))
|
||||
mux.HandleFunc("/calendar/new", s.requireLogin(s.handleEventNew))
|
||||
mux.HandleFunc("/calendar/{ref}/{id}", s.requireLogin(s.handleEventView))
|
||||
mux.HandleFunc("/calendar/{ref}/import", s.requireLogin(s.handleCalendarImport))
|
||||
mux.HandleFunc("/calendar/{ref}/export", s.requireLogin(s.handleCalendarExportAll))
|
||||
mux.HandleFunc("/calendar/{ref}/{id}/edit", s.requireLogin(s.handleEventEdit))
|
||||
|
||||
@@ -3,6 +3,7 @@ package templates
|
||||
import "fmt"
|
||||
import "strconv"
|
||||
import "strings"
|
||||
import "time"
|
||||
|
||||
// CalendarSummary is one calendar (own or shared) shown in the combined
|
||||
// month view's legend.
|
||||
@@ -24,9 +25,9 @@ type EventSummary struct {
|
||||
Summary string
|
||||
TimeText string // e.g. "14:00" or "" for all-day events
|
||||
AllDay bool
|
||||
// LinkURL overrides the default "/web/calendar/{CalRef}/{ID}/edit"
|
||||
// link target, used by virtual/read-only calendars (e.g. birthdays)
|
||||
// that don't have an editable event object of their own.
|
||||
// LinkURL overrides the default "/web/calendar/{CalRef}/{ID}" (detail
|
||||
// view) link target, used by virtual/read-only calendars (e.g.
|
||||
// birthdays) that don't have an editable event object of their own.
|
||||
LinkURL string
|
||||
}
|
||||
|
||||
@@ -100,6 +101,14 @@ type EventFormData struct {
|
||||
EndTime string // "HH:MM", empty when AllDay
|
||||
}
|
||||
|
||||
// EventDetailData is everything the read-only event detail view needs: the
|
||||
// decoded event itself (Form, including its ID/CalRef/Writable display
|
||||
// metadata) plus the calendar's color for the header dot.
|
||||
type EventDetailData struct {
|
||||
Form EventFormData
|
||||
Color string // calendar color, "" if unset
|
||||
}
|
||||
|
||||
templ MonthView(username string, data MonthViewData) {
|
||||
@Layout("Calendar", username) {
|
||||
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
||||
@@ -301,7 +310,7 @@ func eventLinkURL(ev EventSummary) string {
|
||||
if ev.LinkURL != "" {
|
||||
return ev.LinkURL
|
||||
}
|
||||
return "/web/calendar/" + ev.CalRef + "/" + ev.ID + "/edit"
|
||||
return "/web/calendar/" + ev.CalRef + "/" + ev.ID
|
||||
}
|
||||
|
||||
// eventTextColor returns a color derived from hex, darkened if needed so
|
||||
@@ -355,6 +364,119 @@ func clampByte(v float64) int {
|
||||
|
||||
|
||||
|
||||
// EventDetail is the read-only detail view for a single event, opened when
|
||||
// the user clicks an event in the month or week grid. It shows the
|
||||
// event's fields and, when the calendar is writable, offers an Edit link.
|
||||
templ EventDetail(username string, data EventDetailData) {
|
||||
@Layout("Calendar", username) {
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap mb-6">
|
||||
<a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">← Calendar</a>
|
||||
<div class="flex items-center gap-3">
|
||||
if data.Form.Writable {
|
||||
<a href={ templ.URL("/web/calendar/" + data.Form.CalRef + "/" + data.Form.ID + "/edit") }
|
||||
class="bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
|
||||
Edit
|
||||
</a>
|
||||
}
|
||||
<a href={ templ.URL("/web/calendar/" + data.Form.CalRef + "/" + data.Form.ID + "/export") }
|
||||
class="bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Export .ics
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6 max-w-2xl">
|
||||
<div class="flex items-start gap-3 border-b border-gray-100 pb-5 mb-5">
|
||||
<span class="w-3 h-3 rounded-full mt-2 shrink-0 ring-1 ring-inset ring-black/10" style={ "background-color: " + colorOrDefault(data.Color) }></span>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-semibold break-words leading-tight">{ data.Form.Summary }</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">{ data.Form.CalendarLabel }</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if !data.Form.Writable {
|
||||
<p class="mb-5 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2">
|
||||
This calendar was shared with you as read-only — you can view this event but not change it.
|
||||
</p>
|
||||
}
|
||||
|
||||
<dl class="space-y-4">
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">When</dt>
|
||||
<dd class="text-base text-gray-900">{ eventRangeText(data.Form) }</dd>
|
||||
</div>
|
||||
if data.Form.Location != "" {
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Location</dt>
|
||||
<dd class="text-base text-gray-900 break-words">{ data.Form.Location }</dd>
|
||||
</div>
|
||||
}
|
||||
if data.Form.Description != "" {
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-gray-400 mb-1">Description</dt>
|
||||
<dd class="text-base text-gray-900 whitespace-pre-wrap break-words">{ data.Form.Description }</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// eventRangeText renders a human-readable "when" string for an event:
|
||||
// a single timed event reads "Aug 5, 2026, 14:00 – 15:00"; a multi-day
|
||||
// range reads "Aug 5 – 7, 2026"; a single all-day date reads
|
||||
// "August 5, 2026".
|
||||
func eventRangeText(f EventFormData) string {
|
||||
if f.AllDay {
|
||||
if f.StartDate != f.EndDate {
|
||||
s, err := dateOnly(f.StartDate)
|
||||
if err != nil {
|
||||
return f.StartDate
|
||||
}
|
||||
e, err := dateOnly(f.EndDate)
|
||||
if err != nil {
|
||||
return f.EndDate
|
||||
}
|
||||
if s.Year() == e.Year() && s.Month() == e.Month() {
|
||||
return fmt.Sprintf("%s – %s, %d", s.Format("Jan 2"), e.Format("2"), s.Year())
|
||||
}
|
||||
return fmt.Sprintf("%s – %s", s.Format("Jan 2, 2006"), e.Format("Jan 2, 2006"))
|
||||
}
|
||||
d, err := dateOnly(f.StartDate)
|
||||
if err != nil {
|
||||
return f.StartDate
|
||||
}
|
||||
return d.Format("January 2, 2006")
|
||||
}
|
||||
|
||||
s, err := dateTime(f.StartDate, f.StartTime)
|
||||
if err != nil {
|
||||
return f.StartDate
|
||||
}
|
||||
e, err := dateTime(f.EndDate, f.EndTime)
|
||||
if err != nil {
|
||||
return s.Format("January 2, 2006, 15:04")
|
||||
}
|
||||
if s.Day() == e.Day() {
|
||||
return fmt.Sprintf("%s, %s – %s", s.Format("January 2, 2006"), s.Format("15:04"), e.Format("15:04"))
|
||||
}
|
||||
if s.Year() == e.Year() && s.Month() == e.Month() {
|
||||
return fmt.Sprintf("%s – %s, %d", s.Format("Jan 2, 15:04"), e.Format("15:04"), s.Year())
|
||||
}
|
||||
return fmt.Sprintf("%s – %s", s.Format("Jan 2, 2006, 15:04"), e.Format("Jan 2, 2006, 15:04"))
|
||||
}
|
||||
|
||||
func dateOnly(ds string) (time.Time, error) {
|
||||
return time.ParseInLocation("2006-01-02", ds, time.Local)
|
||||
}
|
||||
|
||||
func dateTime(ds, ts string) (time.Time, error) {
|
||||
if ts == "" {
|
||||
return time.ParseInLocation("2006-01-02", ds, time.Local)
|
||||
}
|
||||
return time.ParseInLocation("2006-01-02T15:04", ds+"T"+ts, time.Local)
|
||||
}
|
||||
|
||||
templ EventForm(username string, data EventFormData, errMsg string) {
|
||||
@Layout("Calendar", username) {
|
||||
<a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">← Calendar</a>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,54 @@ func TestEventTextColorDarkensLightColors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventRangeText pins the human-readable "When" strings shown on the
|
||||
// event detail view so any change to date formatting is intentional.
|
||||
func TestEventRangeText(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
form EventFormData
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single allday date",
|
||||
form: EventFormData{AllDay: true, StartDate: "2026-08-05", EndDate: "2026-08-05"},
|
||||
want: "August 5, 2026",
|
||||
},
|
||||
{
|
||||
name: "all-day multi-day same month",
|
||||
form: EventFormData{AllDay: true, StartDate: "2026-08-05", EndDate: "2026-08-07"},
|
||||
want: "Aug 5 – 7, 2026",
|
||||
},
|
||||
{
|
||||
name: "all-day multi-day different months",
|
||||
form: EventFormData{AllDay: true, StartDate: "2026-08-30", EndDate: "2026-09-02"},
|
||||
want: "Aug 30, 2026 – Sep 2, 2026",
|
||||
},
|
||||
{
|
||||
name: "timed same day",
|
||||
form: EventFormData{AllDay: false, StartDate: "2026-08-05", StartTime: "14:00", EndDate: "2026-08-05", EndTime: "15:00"},
|
||||
want: "August 5, 2026, 14:00 – 15:00",
|
||||
},
|
||||
{
|
||||
name: "timed different days same month",
|
||||
form: EventFormData{AllDay: false, StartDate: "2026-08-05", StartTime: "23:30", EndDate: "2026-08-06", EndTime: "01:00"},
|
||||
want: "Aug 5, 23:30 – 01:00, 2026",
|
||||
},
|
||||
{
|
||||
name: "timed different months",
|
||||
form: EventFormData{AllDay: false, StartDate: "2026-08-31", StartTime: "10:00", EndDate: "2026-09-01", EndTime: "11:00"},
|
||||
want: "Aug 31, 2026, 10:00 – Sep 1, 2026, 11:00",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := eventRangeText(c.form); got != c.want {
|
||||
t.Errorf("eventRangeText(%+v) = %q, want %q", c.form, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventTextColorFallsBackForInvalidInput(t *testing.T) {
|
||||
if got := eventTextColor(""); got != colorOrDefault("") {
|
||||
t.Errorf("eventTextColor(\"\") = %s, want the same default colorOrDefault returns", got)
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user