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// 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.
12type 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.
42func 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
70func 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.
80func 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.
91func 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.
103func 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.
120type 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.
138type 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.
154const shortChipSeconds = 3600
155
156// ClickSlot is one empty half-hour, as a link.
157type 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.
172const slotMinutes = 30
173
174// BuildDay assembles one day's page data.
175func 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).
236func 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