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// The repeat vocabulary the form offers.
10//
11// Six choices, not an RRULE builder. A repeat picker that exposes
12// FREQ, INTERVAL, BYDAY, BYSETPOS and COUNT as separate controls is a
13// picker that makes somebody learn a specification to book a standup.
14// These six cover what people actually schedule, and each maps to one
15// unambiguous rule anchored on the event's own start date.
16//
17// Anything more expressive arrives by import, is stored verbatim, and
18// is shown on the event's page as the rule it is.
19const (
20 RepeatNone = "none"
21 RepeatDaily = "daily"
22 RepeatWeekdays = "weekdays"
23 RepeatWeekly = "weekly"
24 RepeatFortnight = "fortnightly"
25 RepeatMonthly = "monthly"
26 RepeatYearly = "yearly"
27)
28
29// RepeatChoice is one option in the picker.
30type RepeatChoice struct {
31 Value string
32 Label string
33}
34
35// RepeatChoicesFor labels the options against the event's own start
36// date, so the picker says "Every Wednesday" rather than "Weekly" and
37// nobody has to work out what the rule will mean.
38func RepeatChoicesFor(startLocal, zone string, allDay bool) []RepeatChoice {
39 out := []RepeatChoice{
40 {RepeatNone, "Does not repeat"},
41 {RepeatDaily, "Every day"},
42 {RepeatWeekdays, "Every weekday (Monday to Friday)"},
43 }
44 loc, err := Zone(zone)
45 if err != nil {
46 loc = time.UTC
47 }
48 t, _, err := ParseLocal(startLocal, loc)
49 if err != nil {
50 return append(out,
51 RepeatChoice{RepeatWeekly, "Every week"},
52 RepeatChoice{RepeatFortnight, "Every two weeks"},
53 RepeatChoice{RepeatMonthly, "Every month"},
54 RepeatChoice{RepeatYearly, "Every year"},
55 )
56 }
57 return append(out,
58 RepeatChoice{RepeatWeekly, "Every " + t.Format("Monday")},
59 RepeatChoice{RepeatFortnight, "Every second " + t.Format("Monday")},
60 RepeatChoice{RepeatMonthly, "Every month on the " + ordinal(t.Day())},
61 RepeatChoice{RepeatYearly, "Every year on " + t.Format("2 January")},
62 )
63}
64
65// RuleFromRepeat turns a picker choice into an RRULE anchored on the
66// event's start.
67//
68// repeatEnd is an optional "until" date from the form. It is emitted as
69// a bare local date rather than a UTC instant, because that is what it
70// is — somebody said "until the 31st", not "until 23:59:59Z on the
71// 31st", and converting it would move the last day for anyone whose
72// zone is far enough from UTC.
73func RuleFromRepeat(repeat, repeatEnd, startLocal, zone string, allDay bool) string {
74 var rule string
75 switch repeat {
76 case "", RepeatNone:
77 return ""
78 case RepeatDaily:
79 rule = "FREQ=DAILY"
80 case RepeatWeekdays:
81 rule = "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
82 case RepeatWeekly:
83 rule = "FREQ=WEEKLY"
84 case RepeatFortnight:
85 rule = "FREQ=WEEKLY;INTERVAL=2"
86 case RepeatMonthly:
87 rule = "FREQ=MONTHLY"
88 case RepeatYearly:
89 rule = "FREQ=YEARLY"
90 default:
91 return ""
92 }
93 if end := strings.TrimSpace(repeatEnd); end != "" {
94 if t, err := time.Parse(LayoutDate, end); err == nil {
95 rule += ";UNTIL=" + t.Format("20060102")
96 }
97 }
98 return rule
99}
100
101// RepeatFromRule is the inverse, so the edit form can reopen showing
102// what was chosen. A rule outside the six reads back as "none" and the
103// form says so rather than silently offering to replace it.
104func RepeatFromRule(rrule string) string {
105 if strings.TrimSpace(rrule) == "" {
106 return RepeatNone
107 }
108 r, err := ParseRule(rrule)
109 if err != nil {
110 return RepeatNone
111 }
112 switch r.Freq {
113 case FreqDaily:
114 if r.Interval == 1 {
115 return RepeatDaily
116 }
117 case FreqWeekly:
118 weekdaysOnly := len(r.ByDay) == 5 &&
119 containsWeekday(r.ByDay, time.Monday) && containsWeekday(r.ByDay, time.Friday) &&
120 !containsWeekday(r.ByDay, time.Saturday) && !containsWeekday(r.ByDay, time.Sunday)
121 switch {
122 case weekdaysOnly && r.Interval == 1:
123 return RepeatWeekdays
124 case len(r.ByDay) == 0 && r.Interval == 1:
125 return RepeatWeekly
126 case len(r.ByDay) == 0 && r.Interval == 2:
127 return RepeatFortnight
128 }
129 case FreqMonthly:
130 if r.Interval == 1 && r.MonthDay == 0 && r.Ordinal == 0 {
131 return RepeatMonthly
132 }
133 case FreqYearly:
134 if r.Interval == 1 {
135 return RepeatYearly
136 }
137 }
138 return RepeatNone
139}
140
141// DescribeRule writes a rule as a sentence for the event's page.
142//
143// A rule this app cannot express is described as itself — the raw
144// RRULE, labelled — rather than approximated. An approximation of a
145// recurrence is a lie about when something happens.
146func DescribeRule(rrule string) string {
147 if strings.TrimSpace(rrule) == "" {
148 return ""
149 }
150 r, err := ParseRule(rrule)
151 if err != nil {
152 return "Repeats on an imported rule this app does not edit: " + rrule
153 }
154
155 var base string
156 switch r.Freq {
157 case FreqDaily:
158 base = everyN(r.Interval, "day")
159 case FreqWeekly:
160 if len(r.ByDay) > 0 {
161 var names []string
162 for _, d := range r.ByDay {
163 names = append(names, d.String())
164 }
165 base = everyN(r.Interval, "week") + " on " + joinWords(names)
166 } else {
167 base = everyN(r.Interval, "week")
168 }
169 case FreqMonthly:
170 switch {
171 case r.Ordinal != 0:
172 base = fmt.Sprintf("%s on the %s %s", everyN(r.Interval, "month"),
173 ordinalWord(r.Ordinal), r.OrdinalDay.String())
174 case r.MonthDay > 0:
175 base = fmt.Sprintf("%s on the %s", everyN(r.Interval, "month"), ordinal(r.MonthDay))
176 default:
177 base = everyN(r.Interval, "month")
178 }
179 case FreqYearly:
180 base = everyN(r.Interval, "year")
181 }
182
183 switch {
184 case r.Count > 0:
185 return fmt.Sprintf("%s, %d times.", base, r.Count)
186 case r.HasUntil:
187 return fmt.Sprintf("%s, until %s.", base, r.Until.Format("2 January 2006"))
188 default:
189 return base + "."
190 }
191}
192
193func everyN(n int, unit string) string {
194 switch n {
195 case 1:
196 return "Every " + unit
197 case 2:
198 return "Every second " + unit
199 default:
200 return fmt.Sprintf("Every %d %ss", n, unit)
201 }
202}
203
204func ordinal(n int) string {
205 suffix := "th"
206 // 11th, 12th and 13th are the exceptions the naive rule gets wrong.
207 if n%100 < 11 || n%100 > 13 {
208 switch n % 10 {
209 case 1:
210 suffix = "st"
211 case 2:
212 suffix = "nd"
213 case 3:
214 suffix = "rd"
215 }
216 }
217 return fmt.Sprintf("%d%s", n, suffix)
218}
219
220func ordinalWord(n int) string {
221 switch n {
222 case 1:
223 return "first"
224 case 2:
225 return "second"
226 case 3:
227 return "third"
228 case 4:
229 return "fourth"
230 case -1:
231 return "last"
232 default:
233 return ordinal(n)
234 }
235}
236
237func joinWords(w []string) string {
238 switch len(w) {
239 case 0:
240 return ""
241 case 1:
242 return w[0]
243 case 2:
244 return w[0] + " and " + w[1]
245 default:
246 return strings.Join(w[:len(w)-1], ", ") + " and " + w[len(w)-1]
247 }
248}
249