| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 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. |
| 15 | type 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 | |
| 30 | type MonthWeek struct { |
| 31 | Days []MonthDay |
| 32 | } |
| 33 | |
| 34 | type 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. |
| 57 | const 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. |
| 67 | func 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. |
| 129 | func 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. |
| 161 | type AgendaPage struct { |
| 162 | FromISO string |
| 163 | Days []AgendaDay |
| 164 | } |
| 165 | |
| 166 | type 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. |
| 175 | const AgendaDays = 14 |
| 176 | |
| 177 | // BuildAgenda lists the days that have something in them, and says so |
| 178 | // when none do. |
| 179 | func 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. |
| 212 | func 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". |
| 226 | func 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. |
| 239 | func 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 | |