feat(web): add calendar event detail view

This commit is contained in:
2026-09-01 19:10:17 +02:00
parent 1f383878ab
commit e18c83b618
7 changed files with 1025 additions and 326 deletions
+65
View File
@@ -18,6 +18,7 @@ import (
"git.arnef.de/arnef/nidus/internal/birthdays" "git.arnef.de/arnef/nidus/internal/birthdays"
"git.arnef.de/arnef/nidus/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icalfix" "git.arnef.de/arnef/nidus/internal/icalfix"
"git.arnef.de/arnef/nidus/internal/store"
"git.arnef.de/arnef/nidus/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
ical "github.com/emersion/go-ical" 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) { func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) {
username := userFromContext(r.Context()) username := userFromContext(r.Context())
ref := r.PathValue("ref") ref := r.PathValue("ref")
+209
View File
@@ -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())
}
}
+1
View File
@@ -65,6 +65,7 @@ func (s *Server) Handler(staticFS http.FileSystem) http.Handler {
mux.HandleFunc("/calendar", s.requireLogin(s.handleCalendarMonth)) mux.HandleFunc("/calendar", s.requireLogin(s.handleCalendarMonth))
mux.HandleFunc("/calendar/week", s.requireLogin(s.handleCalendarWeek)) mux.HandleFunc("/calendar/week", s.requireLogin(s.handleCalendarWeek))
mux.HandleFunc("/calendar/new", s.requireLogin(s.handleEventNew)) 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}/import", s.requireLogin(s.handleCalendarImport))
mux.HandleFunc("/calendar/{ref}/export", s.requireLogin(s.handleCalendarExportAll)) mux.HandleFunc("/calendar/{ref}/export", s.requireLogin(s.handleCalendarExportAll))
mux.HandleFunc("/calendar/{ref}/{id}/edit", s.requireLogin(s.handleEventEdit)) mux.HandleFunc("/calendar/{ref}/{id}/edit", s.requireLogin(s.handleEventEdit))
+126 -4
View File
@@ -3,6 +3,7 @@ package templates
import "fmt" import "fmt"
import "strconv" import "strconv"
import "strings" import "strings"
import "time"
// CalendarSummary is one calendar (own or shared) shown in the combined // CalendarSummary is one calendar (own or shared) shown in the combined
// month view's legend. // month view's legend.
@@ -24,9 +25,9 @@ type EventSummary struct {
Summary string Summary string
TimeText string // e.g. "14:00" or "" for all-day events TimeText string // e.g. "14:00" or "" for all-day events
AllDay bool AllDay bool
// LinkURL overrides the default "/web/calendar/{CalRef}/{ID}/edit" // LinkURL overrides the default "/web/calendar/{CalRef}/{ID}" (detail
// link target, used by virtual/read-only calendars (e.g. birthdays) // view) link target, used by virtual/read-only calendars (e.g.
// that don't have an editable event object of their own. // birthdays) that don't have an editable event object of their own.
LinkURL string LinkURL string
} }
@@ -100,6 +101,14 @@ type EventFormData struct {
EndTime string // "HH:MM", empty when AllDay 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) { templ MonthView(username string, data MonthViewData) {
@Layout("Calendar", username) { @Layout("Calendar", username) {
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap"> <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 != "" { if ev.LinkURL != "" {
return 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 // 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">&larr; 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) { templ EventForm(username string, data EventFormData, errMsg string) {
@Layout("Calendar", username) { @Layout("Calendar", username) {
<a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">&larr; Calendar</a> <a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">&larr; Calendar</a>
File diff suppressed because it is too large Load Diff
+48
View File
@@ -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) { func TestEventTextColorFallsBackForInvalidInput(t *testing.T) {
if got := eventTextColor(""); got != colorOrDefault("") { if got := eventTextColor(""); got != colorOrDefault("") {
t.Errorf("eventTextColor(\"\") = %s, want the same default colorOrDefault returns", got) t.Errorf("eventTextColor(\"\") = %s, want the same default colorOrDefault returns", got)
+1 -1
View File
File diff suppressed because one or more lines are too long