feat(web): add calendar event detail view
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user