| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 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. |
| 28 | type 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. |
| 50 | func 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. |
| 101 | func 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 | |
| 131 | func 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. |
| 141 | func 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. |
| 153 | func 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 | |
| 165 | func 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 | |
| 172 | func pluralMinutes(m int) string { |
| 173 | if m == 1 { |
| 174 | return "one minute" |
| 175 | } |
| 176 | return fmt.Sprintf("%d minutes", m) |
| 177 | } |
| 178 | |
| 179 | func 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 | |