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 "strings"
6 "time"
7)
8
9// DaySummary is the sentence at the top of Today, and the one piece of
10// this app that has an opinion.
11//
12// A calendar usually answers "what is on" and leaves "how full is this"
13// to the reader's eye on a grid. But the feeling a day produces is not
14// a list — it is whether there is room in it — and that is arithmetic
15// the server already has. So Today opens by saying it:
16//
17// Wednesday. Four things; the first at 09:30, the last ends at 17:00.
18// Two hours and fifteen minutes unbooked between them.
19//
20// The unbooked figure is the gap BETWEEN the first start and the last
21// end, not the whole day: hours before your first meeting are not a
22// gap, they are the morning. And when there are none:
23//
24// There is no unbooked hour today.
25//
26// which is a true and slightly alarming sentence, said plainly, because
27// that is the day the person is actually having.
28type DaySummary struct {
29 // Weekday is the day's own name, so the sentence can open with it.
30 Weekday string
31
32 // Count is how many things occupy time. Notes are excluded: a
33 // birthday does not make a Tuesday fuller.
34 Count int
35
36 FirstStart string // "09:30", empty when nothing is timed
37 LastEnd string // "17:00"
38
39 // Unbooked is the free time between the first start and the last
40 // end, in minutes. Meaningless when Count < 2.
41 UnbookedMin int
42
43 // Sentence is the whole thing, assembled. Templates print this
44 // rather than reassembling the parts, so the wording lives in one
45 // place and can be read as prose in review.
46 Sentence string
47}
48
49// SummariseDay builds the sentence for one day's events.
50func SummariseDay(day time.Time, events []EventView, dayStart, dayEnd int64, zone string) DaySummary {
51 s := DaySummary{Weekday: day.Format("Monday")}
52
53 var busy []EventView
54 for _, e := range events {
55 if OccupiesTime(e.Kind) && !e.AllDay {
56 busy = append(busy, e)
57 }
58 }
59 s.Count = len(busy)
60
61 if len(busy) == 0 {
62 s.Sentence = fmt.Sprintf("%s. Nothing booked.", s.Weekday)
63 return s
64 }
65
66 sortOccurrences(busy)
67 first := clamp(busy[0].OccStartUTC, dayStart, dayEnd)
68 last := first
69 for _, e := range busy {
70 if end := clamp(e.OccEndUTC, dayStart, dayEnd); end > last {
71 last = end
72 }
73 }
74 s.FirstStart = hhmm(first, zone)
75 s.LastEnd = hhmm(last, zone)
76
77 // Booked minutes, counting overlapping meetings once — two things
78 // at the same time do not consume two hours of a day.
79 booked := mergedSeconds(busy, dayStart, dayEnd)
80 s.UnbookedMin = int((last - first - booked) / 60)
81 if s.UnbookedMin < 0 {
82 s.UnbookedMin = 0
83 }
84
85 var b strings.Builder
86 fmt.Fprintf(&b, "%s. %s; the first at %s, the last ends at %s.",
87 s.Weekday, countThings(s.Count), s.FirstStart, s.LastEnd)
88 if s.Count > 1 {
89 if s.UnbookedMin == 0 {
90 b.WriteString(" There is no unbooked hour between them.")
91 } else {
92 fmt.Fprintf(&b, " That leaves %s unbooked between them.", humanDuration(s.UnbookedMin))
93 }
94 }
95 s.Sentence = b.String()
96 return s
97}
98
99// mergedSeconds is the union of the events' spans — overlapping
100// meetings counted once. Input need not be sorted.
101func mergedSeconds(events []EventView, lo, hi int64) int64 {
102 sorted := append([]EventView(nil), events...)
103 sortOccurrences(sorted)
104
105 var total int64
106 var curStart, curEnd int64 = -1, -1
107 for _, e := range sorted {
108 s, en := clamp(e.OccStartUTC, lo, hi), clamp(e.OccEndUTC, lo, hi)
109 if en <= s {
110 continue
111 }
112 if curStart < 0 {
113 curStart, curEnd = s, en
114 continue
115 }
116 if s > curEnd {
117 total += curEnd - curStart
118 curStart, curEnd = s, en
119 continue
120 }
121 if en > curEnd {
122 curEnd = en
123 }
124 }
125 if curStart >= 0 {
126 total += curEnd - curStart
127 }
128 return total
129}
130
131func countThings(n int) string {
132 if n == 1 {
133 return "One thing"
134 }
135 return fmt.Sprintf("%s things", spellSmall(n))
136}
137
138// spellSmall writes the numbers a day realistically holds as words, and
139// falls back to digits past that. "Seventeen things" reads as a
140// flourish; "17 things" reads as a warning, which by then it is.
141func spellSmall(n int) string {
142 words := []string{"Zero", "One", "Two", "Three", "Four", "Five",
143 "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve"}
144 if n >= 0 && n < len(words) {
145 return words[n]
146 }
147 return fmt.Sprintf("%d", n)
148}
149
150// humanDuration writes a gap the way a person would say it, in lower
151// case — the sentence that uses it supplies its own opening, so this
152// never has to start one and never has to begin with a digit.
153func humanDuration(min int) string {
154 h, m := min/60, min%60
155 switch {
156 case h == 0:
157 return pluralMinutes(m)
158 case m == 0:
159 return pluralHours(h)
160 default:
161 return pluralHours(h) + " and " + pluralMinutes(m)
162 }
163}
164
165func pluralHours(h int) string {
166 if h == 1 {
167 return "one hour"
168 }
169 return fmt.Sprintf("%s hours", strings.ToLower(spellSmall(h)))
170}
171
172func pluralMinutes(m int) string {
173 if m == 1 {
174 return "one minute"
175 }
176 return fmt.Sprintf("%d minutes", m)
177}
178
179func hhmm(unix int64, zone string) string {
180 t, err := InZone(unix, zone)
181 if err != nil {
182 return ""
183 }
184 return t.Format("15:04")
185}
186