| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 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. |
| 17 | func (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. |
| 28 | func (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. |
| 70 | func (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. |
| 139 | func (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. |
| 178 | func (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. |
| 216 | func (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 | |