| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "time" |
| 6 | ) |
| 7 | |
| 8 | // Chip is one event as a view renders it: already converted into the |
| 9 | // viewer's zone, already formatted, already told whether it is in the |
| 10 | // past. Templates do no arithmetic — everything they print is decided |
| 11 | // here, where it can be tested. |
| 12 | type Chip struct { |
| 13 | Event EventView |
| 14 | |
| 15 | // Href is where the chip goes. An occurrence of a series carries |
| 16 | // its own occurrence key, so editing "this one" knows which one. |
| 17 | Href string |
| 18 | |
| 19 | Start string // "09:30" in the viewer's zone |
| 20 | End string // "10:00" |
| 21 | |
| 22 | // TimeLabel is what the chip prints. Past events are written in the |
| 23 | // past tense — "Standup, ended 09:30" — because a calendar that |
| 24 | // describes this morning in the future tense is a calendar that |
| 25 | // makes you read the clock to know where you are. |
| 26 | TimeLabel string |
| 27 | |
| 28 | AllDay bool |
| 29 | Past bool |
| 30 | |
| 31 | // AuthoredZone is set only when the event's own zone differs from |
| 32 | // the viewer's. The primary time shown is always the viewer's; the |
| 33 | // authored one is a whisper beside it, never the headline. |
| 34 | AuthoredZone string |
| 35 | AuthoredTime string |
| 36 | |
| 37 | Hue int |
| 38 | Kind string |
| 39 | } |
| 40 | |
| 41 | // chipFor converts one event into the viewer's zone. |
| 42 | func chipFor(e EventView, zone string, now int64) Chip { |
| 43 | c := Chip{ |
| 44 | Event: e, |
| 45 | Href: eventHref(e), |
| 46 | AllDay: e.AllDay, |
| 47 | Past: e.OccEndUTC <= now, |
| 48 | Hue: e.CalendarHue, |
| 49 | Kind: e.Kind, |
| 50 | } |
| 51 | if e.AllDay { |
| 52 | c.TimeLabel = "All day" |
| 53 | return c |
| 54 | } |
| 55 | c.Start = hhmm(e.OccStartUTC, zone) |
| 56 | c.End = hhmm(e.OccEndUTC, zone) |
| 57 | if c.Past { |
| 58 | c.TimeLabel = "ended " + c.End |
| 59 | } else { |
| 60 | c.TimeLabel = c.Start |
| 61 | } |
| 62 | // The whisper: only when the event was authored somewhere else. |
| 63 | if e.TZ != "" && e.TZ != zone { |
| 64 | c.AuthoredZone = e.TZ |
| 65 | c.AuthoredTime = hhmm(e.OccStartUTC, e.TZ) |
| 66 | } |
| 67 | return c |
| 68 | } |
| 69 | |
| 70 | func eventHref(e EventView) string { |
| 71 | if e.OccurrenceLocal == "" { |
| 72 | return fmt.Sprintf("/events/%d", e.ID) |
| 73 | } |
| 74 | return fmt.Sprintf("/events/%d/%s", occurrenceOwner(e), compactLocal(e.OccurrenceLocal)) |
| 75 | } |
| 76 | |
| 77 | // occurrenceOwner is the series a chip belongs to: the master's id for |
| 78 | // an expanded occurrence, and for an override the series it overrides — |
| 79 | // so both forms of the same occurrence share one URL. |
| 80 | func occurrenceOwner(e EventView) int64 { |
| 81 | if e.SeriesID != nil { |
| 82 | return *e.SeriesID |
| 83 | } |
| 84 | return e.ID |
| 85 | } |
| 86 | |
| 87 | // compactLocal turns "2026-09-02T09:00" into "20260902T0900", which is |
| 88 | // what the occurrence route matches: a URL segment with no colons, |
| 89 | // because a colon in a path is legal, ugly, and encoded differently by |
| 90 | // different clients. |
| 91 | func compactLocal(local string) string { |
| 92 | out := make([]byte, 0, len(local)) |
| 93 | for i := 0; i < len(local); i++ { |
| 94 | if local[i] == '-' || local[i] == ':' { |
| 95 | continue |
| 96 | } |
| 97 | out = append(out, local[i]) |
| 98 | } |
| 99 | return string(out) |
| 100 | } |
| 101 | |
| 102 | // expandLocal is compactLocal's inverse. |
| 103 | func expandCompact(compact string) (string, bool) { |
| 104 | // 20260902T0900 → 2026-09-02T09:00; 20260902 → 2026-09-02 |
| 105 | switch len(compact) { |
| 106 | case 13: |
| 107 | if compact[8] != 'T' { |
| 108 | return "", false |
| 109 | } |
| 110 | return fmt.Sprintf("%s-%s-%sT%s:%s", |
| 111 | compact[0:4], compact[4:6], compact[6:8], compact[9:11], compact[11:13]), true |
| 112 | case 8: |
| 113 | return fmt.Sprintf("%s-%s-%s", compact[0:4], compact[4:6], compact[6:8]), true |
| 114 | default: |
| 115 | return "", false |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // DayPage is one day, packed. |
| 120 | type DayPage struct { |
| 121 | Date time.Time |
| 122 | ISO string |
| 123 | Title string |
| 124 | Summary DaySummary |
| 125 | |
| 126 | AllDay []Chip |
| 127 | Placed []PlacedChip |
| 128 | |
| 129 | // Slots are the half-hour click-targets that make "create at this |
| 130 | // time" work with no JavaScript. Every one is an ordinary link to a |
| 131 | // prefilled form. |
| 132 | Slots []ClickSlot |
| 133 | |
| 134 | Hours []string |
| 135 | } |
| 136 | |
| 137 | // PlacedChip is a Chip with its position in the day column. |
| 138 | type PlacedChip struct { |
| 139 | Chip |
| 140 | TopPct float64 |
| 141 | HeightPct float64 |
| 142 | LeftPct float64 |
| 143 | WidthPct float64 |
| 144 | |
| 145 | // Short is a chip under an hour tall. The grid gives an hour 3rem, |
| 146 | // which holds two lines; anything shorter lays its time and title |
| 147 | // on ONE line, or the title — the line that says what the event |
| 148 | // is — is the one clipped away. |
| 149 | Short bool |
| 150 | } |
| 151 | |
| 152 | // shortChipSeconds is the height below which a placed chip has room |
| 153 | // for one line of text, not two. |
| 154 | const shortChipSeconds = 3600 |
| 155 | |
| 156 | // ClickSlot is one empty half-hour, as a link. |
| 157 | type ClickSlot struct { |
| 158 | Href string |
| 159 | |
| 160 | // Label is for screen readers — "Create an event at 09:30 on |
| 161 | // Wednesday 2 September". The visible slot is an empty box, so |
| 162 | // without this it would be an unlabelled link. |
| 163 | Label string |
| 164 | |
| 165 | TopPct float64 |
| 166 | HeightPct float64 |
| 167 | } |
| 168 | |
| 169 | // slotMinutes is how finely a day can be clicked. Thirty minutes gives |
| 170 | // 48 links per day column: enough to be useful, few enough that the |
| 171 | // page stays small and a keyboard user can tab past them. |
| 172 | const slotMinutes = 30 |
| 173 | |
| 174 | // BuildDay assembles one day's page data. |
| 175 | func BuildDay(day time.Time, events []EventView, zone string, now int64, calendarID int64) (DayPage, error) { |
| 176 | dayStart, dayEnd, err := DayBounds(day, zone) |
| 177 | if err != nil { |
| 178 | return DayPage{}, err |
| 179 | } |
| 180 | loc, err := Zone(zone) |
| 181 | if err != nil { |
| 182 | return DayPage{}, err |
| 183 | } |
| 184 | local := day.In(loc) |
| 185 | |
| 186 | p := DayPage{ |
| 187 | Date: local, |
| 188 | ISO: local.Format(LayoutDate), |
| 189 | Title: local.Format("Monday 2 January"), |
| 190 | Summary: SummariseDay(local, events, dayStart, dayEnd, zone), |
| 191 | } |
| 192 | |
| 193 | for _, e := range events { |
| 194 | if e.AllDay { |
| 195 | p.AllDay = append(p.AllDay, chipFor(e, zone, now)) |
| 196 | } |
| 197 | } |
| 198 | for _, pl := range PackDay(events, dayStart, dayEnd) { |
| 199 | p.Placed = append(p.Placed, PlacedChip{ |
| 200 | Chip: chipFor(pl.Event, zone, now), |
| 201 | TopPct: pl.TopPct, |
| 202 | HeightPct: pl.HeightPct, |
| 203 | LeftPct: pl.LeftPct, |
| 204 | WidthPct: pl.WidthPct, |
| 205 | Short: pl.HeightPct/100*float64(dayEnd-dayStart) < shortChipSeconds, |
| 206 | }) |
| 207 | } |
| 208 | |
| 209 | span := float64(dayEnd - dayStart) |
| 210 | for m := 0; m < 24*60; m += slotMinutes { |
| 211 | t := time.Date(local.Year(), local.Month(), local.Day(), 0, m, 0, 0, loc) |
| 212 | // AddDate-safe: on a DST day some slots do not exist, and a |
| 213 | // slot whose instant falls outside the day is not offered. |
| 214 | if t.Unix() < dayStart || t.Unix() >= dayEnd { |
| 215 | continue |
| 216 | } |
| 217 | endT := t.Add(slotMinutes * time.Minute) |
| 218 | p.Slots = append(p.Slots, ClickSlot{ |
| 219 | Href: fmt.Sprintf("/events/new?start=%s&end=%s&calendar=%d", |
| 220 | t.Format(LayoutLocal), endT.Format(LayoutLocal), calendarID), |
| 221 | Label: fmt.Sprintf("Create an event at %s on %s", t.Format("15:04"), local.Format("Monday 2 January")), |
| 222 | TopPct: round2(float64(t.Unix()-dayStart) / span * 100), |
| 223 | HeightPct: round2(float64(slotMinutes*60) / span * 100), |
| 224 | }) |
| 225 | } |
| 226 | |
| 227 | for h := 0; h < 24; h++ { |
| 228 | p.Hours = append(p.Hours, fmt.Sprintf("%02d:00", h)) |
| 229 | } |
| 230 | return p, nil |
| 231 | } |
| 232 | |
| 233 | // WeekStartFor returns the first day of the week containing day, for a |
| 234 | // person whose week starts on weekStart (1 Monday, 7 Sunday, 6 |
| 235 | // Saturday). |
| 236 | func WeekStartFor(day time.Time, weekStart int, zone string) (time.Time, error) { |
| 237 | loc, err := Zone(zone) |
| 238 | if err != nil { |
| 239 | return time.Time{}, err |
| 240 | } |
| 241 | d := day.In(loc) |
| 242 | d = time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc) |
| 243 | |
| 244 | // time.Weekday is Sunday=0; this app's column uses Sunday=7 so that |
| 245 | // an unset zero is distinguishable from a chosen Sunday. |
| 246 | cur := int(d.Weekday()) |
| 247 | if cur == 0 { |
| 248 | cur = 7 |
| 249 | } |
| 250 | if weekStart < 1 || weekStart > 7 { |
| 251 | weekStart = WeekStartMonday |
| 252 | } |
| 253 | back := (cur - weekStart + 7) % 7 |
| 254 | return d.AddDate(0, 0, -back), nil |
| 255 | } |
| 256 | |