oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
4 "fmt"
5 "net/http"
6 "time"
7
8 "github.com/go-chi/chi/v5"
9)
10
11// today redirects to the day page for the viewer's own today.
12//
13// A redirect rather than its own rendering, so "today" and "2 September"
14// are one page with one set of behaviours. The alternative — a Today
15// page that is subtly different from the Day page — is how an
16// affordance ends up existing in one and not the other.
17func (h *handlers) today(w http.ResponseWriter, r *http.Request) {
18 p, _ := CurrentPerson(r)
19 loc, err := Zone(p.Zone)
20 if err != nil {
21 loc = time.UTC
22 }
23 http.Redirect(w, r, "/day/"+time.Now().In(loc).Format(LayoutDate), http.StatusSeeOther)
24}
25
26// day renders one day: the sentence, the all-day strip, the packed
27// column, and a half-hour link in every empty slot.
28func (h *handlers) day(w http.ResponseWriter, r *http.Request) {
29 p, _ := CurrentPerson(r)
30 day, err := ParseDate(chi.URLParam(r, "date"), p.Zone)
31 if err != nil {
32 h.errorPage(w, r, http.StatusNotFound, "")
33 return
34 }
35 from, to, err := DayBounds(day, p.Zone)
36 if err != nil {
37 h.fail(w, r, "day bounds", err)
38 return
39 }
40 events, err := h.store.EventsInRange(r.Context(), p.ID, from, to)
41 if err != nil {
42 h.fail(w, r, "day range", err)
43 return
44 }
45 defaultCal, err := h.store.DefaultCalendarID(r.Context(), p.ID)
46 if err != nil {
47 h.fail(w, r, "default calendar", err)
48 return
49 }
50 page, err := BuildDay(day, events, p.Zone, time.Now().Unix(), defaultCal)
51 if err != nil {
52 h.fail(w, r, "build day", err)
53 return
54 }
55
56 loc, _ := Zone(p.Zone)
57 data := map[string]any{
58 "Day": page,
59 "PrevISO": day.In(loc).AddDate(0, 0, -1).Format(LayoutDate),
60 "NextISO": day.In(loc).AddDate(0, 0, 1).Format(LayoutDate),
61 "TodayISO": time.Now().In(loc).Format(LayoutDate),
62 "Zone": p.Zone,
63 }
64 render(w, "day", h.page(r, page.Title, data))
65}
66
67// week renders seven packed columns. The date in the URL is any day in
68// the week, so "the week of the 2nd" and "the week of the 4th" are the
69// same page rather than two.
70func (h *handlers) week(w http.ResponseWriter, r *http.Request) {
71 p, _ := CurrentPerson(r)
72 anyDay, err := ParseDate(chi.URLParam(r, "date"), p.Zone)
73 if err != nil {
74 h.errorPage(w, r, http.StatusNotFound, "")
75 return
76 }
77 start, err := WeekStartFor(anyDay, p.WeekStart, p.Zone)
78 if err != nil {
79 h.fail(w, r, "week start", err)
80 return
81 }
82 from := start.Unix()
83 to := start.AddDate(0, 0, 7).Unix()
84
85 events, err := h.store.EventsInRange(r.Context(), p.ID, from, to)
86 if err != nil {
87 h.fail(w, r, "week range", err)
88 return
89 }
90 defaultCal, err := h.store.DefaultCalendarID(r.Context(), p.ID)
91 if err != nil {
92 h.fail(w, r, "default calendar", err)
93 return
94 }
95
96 now := time.Now().Unix()
97 var days []DayPage
98 anyAllDay := false
99 for i := 0; i < 7; i++ {
100 d := start.AddDate(0, 0, i)
101 ds, de, err := DayBounds(d, p.Zone)
102 if err != nil {
103 h.fail(w, r, "week day bounds", err)
104 return
105 }
106 // Each column gets only the events overlapping its own day, so
107 // the packer never lays out a Tuesday meeting against
108 // Wednesday's.
109 var inDay []EventView
110 for _, e := range events {
111 if e.OccEndUTC > ds && e.OccStartUTC < de {
112 inDay = append(inDay, e)
113 }
114 }
115 page, err := BuildDay(d, inDay, p.Zone, now, defaultCal)
116 if err != nil {
117 h.fail(w, r, "build week day", err)
118 return
119 }
120 days = append(days, page)
121 anyAllDay = anyAllDay || len(page.AllDay) > 0
122 }
123
124 loc, _ := Zone(p.Zone)
125 title := fmt.Sprintf("Week of %s", start.Format("2 January 2006"))
126 data := map[string]any{
127 "Days": days,
128 "AnyAllDay": anyAllDay,
129 "StartISO": start.Format(LayoutDate),
130 "PrevISO": start.AddDate(0, 0, -7).Format(LayoutDate),
131 "NextISO": start.AddDate(0, 0, 7).Format(LayoutDate),
132 "TodayISO": time.Now().In(loc).Format(LayoutDate),
133 "Zone": p.Zone,
134 }
135 render(w, "week", h.page(r, title, data))
136}
137
138// month renders the grid.
139func (h *handlers) month(w http.ResponseWriter, r *http.Request) {
140 p, _ := CurrentPerson(r)
141 month, err := ParseMonth(chi.URLParam(r, "month"), p.Zone)
142 if err != nil {
143 h.errorPage(w, r, http.StatusNotFound, "")
144 return
145 }
146 from, to, err := MonthBounds(month, p.WeekStart, p.Zone)
147 if err != nil {
148 h.fail(w, r, "month bounds", err)
149 return
150 }
151 events, err := h.store.EventsInRange(r.Context(), p.ID, from, to)
152 if err != nil {
153 h.fail(w, r, "month range", err)
154 return
155 }
156 page, err := BuildMonth(month, p.WeekStart, events, p.Zone, time.Now().Unix())
157 if err != nil {
158 h.fail(w, r, "build month", err)
159 return
160 }
161 // The day the view switcher carries (?date=, from Day, Week or Agenda),
162 // so Month's Day and Week tabs go back to it. Outside this month, or
163 // absent: today if this is the current month, else the 1st.
164 now := time.Now().In(month.Location())
165 selected := month.Format("2006-01") + "-01"
166 if now.Format("2006-01") == month.Format("2006-01") {
167 selected = now.Format("2006-01-02")
168 }
169 if s := r.URL.Query().Get("date"); s != "" {
170 if d, err := ParseDate(s, p.Zone); err == nil && d.Format("2006-01") == month.Format("2006-01") {
171 selected = d.Format("2006-01-02")
172 }
173 }
174 render(w, "month", h.page(r, page.Title, map[string]any{"Month": page, "TodayMonth": now.Format("2006-01"), "SelectedISO": selected}))
175}
176
177// agenda lists the next fortnight.
178func (h *handlers) agenda(w http.ResponseWriter, r *http.Request) {
179 p, _ := CurrentPerson(r)
180 loc, err := Zone(p.Zone)
181 if err != nil {
182 loc = time.UTC
183 }
184 from := time.Now().In(loc)
185 if s := r.URL.Query().Get("from"); s != "" {
186 if d, err := ParseDate(s, p.Zone); err == nil {
187 from = d
188 }
189 }
190 start := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, loc)
191 events, err := h.store.EventsInRange(r.Context(), p.ID,
192 start.Unix(), start.AddDate(0, 0, AgendaDays).Unix())
193 if err != nil {
194 h.fail(w, r, "agenda range", err)
195 return
196 }
197 page, err := BuildAgenda(start, events, p.Zone, time.Now().Unix())
198 if err != nil {
199 h.fail(w, r, "build agenda", err)
200 return
201 }
202 data := map[string]any{
203 "Agenda": page,
204 "NextISO": start.AddDate(0, 0, AgendaDays).Format(LayoutDate),
205 "PrevISO": start.AddDate(0, 0, -AgendaDays).Format(LayoutDate),
206 }
207 render(w, "agenda", h.page(r, "Agenda", data))
208}
209
210// fail logs an internal error and renders the shared error page.
211//
212// The log line carries a stage name and the error, never a title, an
213// address or a date somebody chose: `carlos logs` is platform-held
214// storage we cannot grep afterwards, so nothing that identifies a
215// person or their content goes into it.
216func (h *handlers) fail(w http.ResponseWriter, r *http.Request, stage string, err error) {
217 h.logger.Error("calendar: "+stage, "err", err)
218 h.errorPage(w, r, http.StatusInternalServerError, "")
219}
220