Add week view to web calendar with Month/Week toggle

Extract the shared per-calendar event-collection logic out of
buildMonthView into collectCalendarEvents, keyed by day so it can feed
either the existing 42-cell month grid or a new 7-cell week grid.

Add templates.WeekDay/WeekViewData, a WeekView templ, and a
viewSwitcher component shown in both views' headers to switch between
Month and Week. Add buildWeekView, handleCalendarWeek, parseWeekStart,
and mondayOf in internal/web/calendar.go, and register the new
/calendar/week route.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-21 06:19:59 +02:00
co-authored by Copilot
parent 28c55804d8
commit 1c039e6396
5 changed files with 1010 additions and 255 deletions
+169 -42
View File
@@ -256,6 +256,41 @@ func parseYearMonth(r *http.Request) (year int, month time.Month) {
return year, month
}
// handleCalendarWeek renders the week view, mirroring
// handleCalendarMonth but for a single 7-day week.
func (s *Server) handleCalendarWeek(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())
weekStart := parseWeekStart(r)
data, err := s.buildWeekView(username, weekStart)
if err != nil {
s.logger.Error("building week view", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.WeekView(username, data).Render(context.Background(), w)
}
// parseWeekStart reads ?date=YYYY-MM-DD and returns the Monday of that
// date's week, defaulting to the current week if the parameter is
// absent or invalid.
func parseWeekStart(r *http.Request) time.Time {
if v := r.URL.Query().Get("date"); v != "" {
if d, err := time.ParseInLocation(dateLayout, v, time.Local); err == nil {
return mondayOf(d)
}
}
return mondayOf(time.Now())
}
// buildMonthView loads every event from every calendar visible to
// username (own + shared), then places each occurrence's days onto a
// 6-week grid covering the requested month (plus enough leading/trailing
@@ -263,11 +298,6 @@ func parseYearMonth(r *http.Request) (year int, month time.Month) {
// (RRULE) are not expanded — only an event's own DTSTART/DTEND span is
// considered.
func (s *Server) buildMonthView(username string, year int, month time.Month) (templates.MonthViewData, error) {
entries, err := s.listCalendarEntries(username)
if err != nil {
return templates.MonthViewData{}, err
}
loc := time.Local
first := time.Date(year, month, 1, 0, 0, 0, 0, loc)
// Monday-first offset: time.Weekday has Sunday=0..Saturday=6.
@@ -278,7 +308,6 @@ func (s *Server) buildMonthView(username string, year int, month time.Month) (te
today := time.Now().In(loc).Format(dateLayout)
days := make([]templates.MonthDay, totalDays)
dayIndex := make(map[string]int, totalDays)
for i := 0; i < totalDays; i++ {
d := gridStart.AddDate(0, 0, i)
dateStr := d.Format(dateLayout)
@@ -288,12 +317,128 @@ func (s *Server) buildMonthView(username string, year int, month time.Month) (te
InMonth: d.Month() == month,
IsToday: dateStr == today,
}
dayIndex[dateStr] = i
}
gridEnd := gridStart.AddDate(0, 0, totalDays-1)
hasWritable := false
var calSummaries []templates.CalendarSummary
eventsByDay, calSummaries, hasWritable, err := s.collectCalendarEvents(username, gridStart, gridEnd, loc)
if err != nil {
return templates.MonthViewData{}, err
}
for i := range days {
days[i].Events = sortedDayEvents(eventsByDay[days[i].Date])
}
weeks := make([][]templates.MonthDay, 0, 6)
for i := 0; i < totalDays; i += 7 {
weeks = append(weeks, days[i:i+7])
}
prevMonth := first.AddDate(0, -1, 0)
nextMonth := first.AddDate(0, 1, 0)
monthURL := func(y int, m time.Month) string {
return fmt.Sprintf("/web/calendar?year=%d&month=%d", y, int(m))
}
return templates.MonthViewData{
Calendars: calSummaries,
HasWritable: hasWritable,
MonthLabel: first.Format("January 2006"),
Weeks: weeks,
PrevMonthURL: monthURL(prevMonth.Year(), prevMonth.Month()),
NextMonthURL: monthURL(nextMonth.Year(), nextMonth.Month()),
TodayURL: "/web/calendar",
WeekURL: "/web/calendar/week",
}, nil
}
// buildWeekView loads every event from every calendar visible to
// username (own + shared) that falls within the 7-day week starting on
// weekStart (which must already be a Monday — see mondayOf), placing
// each occurrence's days onto a single 7-column row.
func (s *Server) buildWeekView(username string, weekStart time.Time) (templates.WeekViewData, error) {
loc := time.Local
gridEnd := weekStart.AddDate(0, 0, 6)
today := time.Now().In(loc).Format(dateLayout)
eventsByDay, calSummaries, hasWritable, err := s.collectCalendarEvents(username, weekStart, gridEnd, loc)
if err != nil {
return templates.WeekViewData{}, err
}
days := make([]templates.WeekDay, 7)
for i := 0; i < 7; i++ {
d := weekStart.AddDate(0, 0, i)
dateStr := d.Format(dateLayout)
days[i] = templates.WeekDay{
Date: dateStr,
Weekday: d.Format("Monday"),
Day: d.Day(),
Month: d.Format("Jan"),
IsToday: dateStr == today,
Events: sortedDayEvents(eventsByDay[dateStr]),
}
}
prevWeek := weekStart.AddDate(0, 0, -7)
nextWeek := weekStart.AddDate(0, 0, 7)
weekURL := func(t time.Time) string {
return "/web/calendar/week?date=" + t.Format(dateLayout)
}
rangeLabel := fmt.Sprintf("%s %s", weekStart.Format("Jan 2"), gridEnd.Format("Jan 2, 2006"))
return templates.WeekViewData{
Calendars: calSummaries,
HasWritable: hasWritable,
RangeLabel: rangeLabel,
Days: days,
PrevWeekURL: weekURL(prevWeek),
NextWeekURL: weekURL(nextWeek),
TodayURL: "/web/calendar/week",
MonthURL: "/web/calendar",
}, nil
}
// sortedDayEvents returns evs (which may be nil) sorted with all-day
// events first, then timed events by start time — the stable, readable
// order used by both the month and week grids.
func sortedDayEvents(evs []templates.EventSummary) []templates.EventSummary {
sort.SliceStable(evs, func(a, b int) bool {
if evs[a].AllDay != evs[b].AllDay {
return evs[a].AllDay
}
return evs[a].TimeText < evs[b].TimeText
})
return evs
}
// collectCalendarEvents gathers every event, across every calendar
// visible to username (own, shared, birthdays, ICS subscriptions), whose
// day-span overlaps [gridStart, gridEnd] (inclusive, in loc), keyed by
// day (dateLayout) so callers can place them into either a month or a
// week grid. It also returns the calendar summary list (used for the
// legend/sidebar) and whether any calendar is writable (for "new event"
// links).
func (s *Server) collectCalendarEvents(username string, gridStart, gridEnd time.Time, loc *time.Location) (eventsByDay map[string][]templates.EventSummary, calSummaries []templates.CalendarSummary, hasWritable bool, err error) {
entries, err := s.listCalendarEntries(username)
if err != nil {
return nil, nil, false, err
}
// Cap how many days any single event can add, in case of malformed
// data with a wildly distant DTEND.
totalDays := int(gridEnd.Sub(gridStart).Hours()/24) + 1
dayIndex := make(map[string]int, totalDays)
days := make([]templates.MonthDay, totalDays)
for i := 0; i < totalDays; i++ {
d := gridStart.AddDate(0, 0, i)
dateStr := d.Format(dateLayout)
days[i] = templates.MonthDay{Date: dateStr}
dayIndex[dateStr] = i
}
eventsByDay = make(map[string][]templates.EventSummary)
for _, entry := range entries {
if entry.Writable {
hasWritable = true
@@ -344,8 +489,6 @@ func (s *Server) buildMonthView(username string, year int, month time.Month) (te
if endDay.After(gridEnd) {
endDay = gridEnd
}
// Cap the number of days a single event can add to the grid,
// in case of malformed data with a wildly distant DTEND.
for d, n := startDay, 0; !d.After(endDay) && n < totalDays; d, n = d.AddDate(0, 0, 1), n+1 {
idx, ok := dayIndex[d.Format(dateLayout)]
if !ok {
@@ -367,40 +510,24 @@ func (s *Server) buildMonthView(username string, year int, month time.Month) (te
}
}
// Keep each day's events in a stable, readable order: all-day events
// first, then timed events sorted by start time.
for i := range days {
evs := days[i].Events
sort.SliceStable(evs, func(a, b int) bool {
if evs[a].AllDay != evs[b].AllDay {
return evs[a].AllDay
}
return evs[a].TimeText < evs[b].TimeText
})
for _, d := range days {
if len(d.Events) > 0 {
eventsByDay[d.Date] = d.Events
}
}
weeks := make([][]templates.MonthDay, 0, 6)
for i := 0; i < totalDays; i += 7 {
weeks = append(weeks, days[i:i+7])
}
prevMonth := first.AddDate(0, -1, 0)
nextMonth := first.AddDate(0, 1, 0)
monthURL := func(y int, m time.Month) string {
return fmt.Sprintf("/web/calendar?year=%d&month=%d", y, int(m))
}
return templates.MonthViewData{
Calendars: calSummaries,
HasWritable: hasWritable,
MonthLabel: first.Format("January 2006"),
Weeks: weeks,
PrevMonthURL: monthURL(prevMonth.Year(), prevMonth.Month()),
NextMonthURL: monthURL(nextMonth.Year(), nextMonth.Month()),
TodayURL: "/web/calendar",
}, nil
return eventsByDay, calSummaries, hasWritable, nil
}
// mondayOf returns the Monday (in loc, at midnight) of the week
// containing t.
func mondayOf(t time.Time) time.Time {
// time.Weekday has Sunday=0..Saturday=6; convert to a Monday-first
// offset so Monday itself maps to 0.
offset := (int(t.Weekday()) + 6) % 7
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, -offset)
}
// eventDayRange returns the inclusive [start, end] calendar-day span an
// event occupies, in loc, for placing it on the month grid.
func eventDayRange(form templates.EventFormData, loc *time.Location) (start, end time.Time, err error) {