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 "time"
6)
7
8// MonthPage is a month as a table of weeks.
9//
10// A <table>, deliberately, and not a grid of divs. A month IS tabular
11// data — seven weekday columns against week rows — so the table gives a
12// screen reader the row and column headers for free, and gives a
13// scriptless page working semantics that a div soup would have to
14// reconstruct with ARIA.
15type MonthPage struct {
16 Month time.Time
17 ISO string // "2026-09"
18 Title string // "September 2026"
19
20 // Weekdays are the column headers, already rotated to the person's
21 // week start.
22 Weekdays []string
23
24 Weeks []MonthWeek
25
26 PrevISO string
27 NextISO string
28}
29
30type MonthWeek struct {
31 Days []MonthDay
32}
33
34type MonthDay struct {
35 ISO string
36 Day int
37 Href string
38 Chips []Chip
39
40 // More is how many chips did not fit. The link goes to the day,
41 // which is the only place that can show them all — a month cell
42 // that grew to fit its fullest day would make every other row that
43 // tall.
44 More int
45
46 InMonth bool
47 IsToday bool
48
49 // Weekend is a rendering hint only. It never carries meaning alone:
50 // the date is printed in every cell regardless.
51 Weekend bool
52}
53
54// MaxChipsPerMonthCell is how many events a month cell shows before it
55// says "+N more". Three keeps every row the same height at any
56// realistic density, which is what makes a month scannable at all.
57const MaxChipsPerMonthCell = 3
58
59// BuildMonth assembles the month grid.
60//
61// Events are bucketed by the LOCAL day they fall on in the viewer's
62// zone, and a multi-day event appears in each day it covers. It does
63// NOT get a spanning bar across the row: a bar has to be laid out
64// against every other event in the week, it breaks the moment a week
65// boundary splits it, and it is the single largest source of complexity
66// in a month view. Each day says what is happening that day.
67func BuildMonth(month time.Time, weekStart int, events []EventView, zone string, now int64) (MonthPage, error) {
68 loc, err := Zone(zone)
69 if err != nil {
70 return MonthPage{}, err
71 }
72 first := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, loc)
73
74 p := MonthPage{
75 Month: first,
76 ISO: first.Format("2006-01"),
77 Title: first.Format("January 2006"),
78 PrevISO: first.AddDate(0, -1, 0).Format("2006-01"),
79 NextISO: first.AddDate(0, 1, 0).Format("2006-01"),
80 }
81
82 gridStart, err := WeekStartFor(first, weekStart, zone)
83 if err != nil {
84 return MonthPage{}, err
85 }
86 for i := 0; i < 7; i++ {
87 p.Weekdays = append(p.Weekdays, gridStart.AddDate(0, 0, i).Format("Mon"))
88 }
89
90 byDay := bucketByLocalDay(events, zone)
91 todayISO := time.Unix(now, 0).In(loc).Format(LayoutDate)
92
93 // Six rows always. A month needs five or six depending on where it
94 // starts, and a grid that changes height between months makes the
95 // whole page jump when you click "next".
96 for w := 0; w < 6; w++ {
97 var week MonthWeek
98 for d := 0; d < 7; d++ {
99 day := gridStart.AddDate(0, 0, w*7+d)
100 iso := day.Format(LayoutDate)
101 cell := MonthDay{
102 ISO: iso,
103 Day: day.Day(),
104 Href: "/day/" + iso,
105 InMonth: day.Month() == first.Month(),
106 IsToday: iso == todayISO,
107 Weekend: day.Weekday() == time.Saturday || day.Weekday() == time.Sunday,
108 }
109 all := byDay[iso]
110 for i, e := range all {
111 if i >= MaxChipsPerMonthCell {
112 cell.More = len(all) - MaxChipsPerMonthCell
113 break
114 }
115 cell.Chips = append(cell.Chips, chipFor(e, zone, now))
116 }
117 week.Days = append(week.Days, cell)
118 }
119 p.Weeks = append(p.Weeks, week)
120 }
121 return p, nil
122}
123
124// bucketByLocalDay puts each event under every local date it covers.
125//
126// The end is exclusive, so an event ending at midnight belongs to the
127// day before and not to both — which is the arithmetic that otherwise
128// shows a Tuesday meeting on Wednesday as well.
129func bucketByLocalDay(events []EventView, zone string) map[string][]EventView {
130 out := map[string][]EventView{}
131 loc, err := Zone(zone)
132 if err != nil {
133 return out
134 }
135 for _, e := range events {
136 start := time.Unix(e.OccStartUTC, 0).In(loc)
137 end := time.Unix(e.OccEndUTC, 0).In(loc)
138
139 day := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, loc)
140 for n := 0; n < 400; n++ {
141 iso := day.Format(LayoutDate)
142 out[iso] = append(out[iso], e)
143
144 next := day.AddDate(0, 0, 1)
145 // Exclusive end: an event finishing exactly at midnight
146 // does not reach the following day.
147 if !next.Before(end) {
148 break
149 }
150 day = next
151 }
152 }
153 for k := range out {
154 sortOccurrences(out[k])
155 }
156 return out
157}
158
159// AgendaPage is the next fourteen days as a list — the view that works
160// on any screen, needs no grid, and is what a phone actually wants.
161type AgendaPage struct {
162 FromISO string
163 Days []AgendaDay
164}
165
166type AgendaDay struct {
167 ISO string
168 Title string
169 Href string
170 Chips []Chip
171 Today bool
172}
173
174// AgendaDays is how far the agenda looks ahead.
175const AgendaDays = 14
176
177// BuildAgenda lists the days that have something in them, and says so
178// when none do.
179func BuildAgenda(from time.Time, events []EventView, zone string, now int64) (AgendaPage, error) {
180 loc, err := Zone(zone)
181 if err != nil {
182 return AgendaPage{}, err
183 }
184 start := time.Date(from.In(loc).Year(), from.In(loc).Month(), from.In(loc).Day(), 0, 0, 0, 0, loc)
185 byDay := bucketByLocalDay(events, zone)
186 todayISO := time.Unix(now, 0).In(loc).Format(LayoutDate)
187
188 p := AgendaPage{FromISO: start.Format(LayoutDate)}
189 for i := 0; i < AgendaDays; i++ {
190 day := start.AddDate(0, 0, i)
191 iso := day.Format(LayoutDate)
192 all := byDay[iso]
193 if len(all) == 0 {
194 continue
195 }
196 d := AgendaDay{
197 ISO: iso,
198 Title: day.Format("Monday 2 January"),
199 Href: "/day/" + iso,
200 Today: iso == todayISO,
201 }
202 for _, e := range all {
203 d.Chips = append(d.Chips, chipFor(e, zone, now))
204 }
205 p.Days = append(p.Days, d)
206 }
207 return p, nil
208}
209
210// MonthBounds is the half-open UTC range a month grid needs, which is
211// six whole weeks and therefore wider than the month itself.
212func MonthBounds(month time.Time, weekStart int, zone string) (int64, int64, error) {
213 loc, err := Zone(zone)
214 if err != nil {
215 return 0, 0, err
216 }
217 first := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, loc)
218 gridStart, err := WeekStartFor(first, weekStart, zone)
219 if err != nil {
220 return 0, 0, err
221 }
222 return gridStart.Unix(), gridStart.AddDate(0, 0, 42).Unix(), nil
223}
224
225// ParseMonth reads "2006-01".
226func ParseMonth(s string, zone string) (time.Time, error) {
227 loc, err := Zone(zone)
228 if err != nil {
229 return time.Time{}, err
230 }
231 t, err := time.ParseInLocation("2006-01", s, loc)
232 if err != nil {
233 return time.Time{}, fmt.Errorf("calendar: %q is not a month", s)
234 }
235 return t, nil
236}
237
238// ParseDate reads "2006-01-02" in the viewer's zone.
239func ParseDate(s string, zone string) (time.Time, error) {
240 loc, err := Zone(zone)
241 if err != nil {
242 return time.Time{}, err
243 }
244 t, err := time.ParseInLocation(LayoutDate, s, loc)
245 if err != nil {
246 return time.Time{}, fmt.Errorf("calendar: %q is not a date", s)
247 }
248 return t, nil
249}
250