-
Notifications
You must be signed in to change notification settings - Fork 5
/
spaces.go
422 lines (357 loc) · 12.8 KB
/
spaces.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
package cinnabot
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
fs "github.com/usdevs/cinnabot/firestore"
"github.com/usdevs/cinnabot/utils"
)
// Displaying:
// FormatDate formats a time.Time into date in a standardised format. Does not change timezone.
func FormatDate(t time.Time) string {
return t.Format("Mon 02 Jan 06")
}
// FormatTime formats a time.Time into a time in a standardised format. Does not change timezone.
func FormatTime(localT time.Time) string {
if localT.Minute() == 0 {
return localT.Format("03PM")
}
return localT.Format("03:04PM")
}
// FormatTimeDate formats a time.Time into a full time and date, in a standardised format. Does not change timezone.
func FormatTimeDate(t time.Time) string {
return fmt.Sprintf("%s, %s", FormatTime(t), FormatDate(t))
}
// Event represents a booking
type Event struct {
Name string
Venue string
Start time.Time
End time.Time
}
// timeInfo returns the time info of an Event in a readable format with duplicate info minimised
func (event *Event) timeInfo() string {
start := event.Start
end := event.End
y1, m1, d1 := start.Date()
y2, m2, d2 := end.Date()
if y1 == y2 && m1 == m2 && d1 == d2 {
return fmt.Sprintf("%s to %s, %s", FormatTime(start), FormatTime(end), FormatDate(start))
}
return fmt.Sprintf("%s to %s", FormatTimeDate(start), FormatTimeDate(end))
}
// toString returns a string with the name and date/time information of an Event, with event name bolded.
func (event Event) toString() string {
return fmt.Sprintf("*%s:* %s", event.Name, event.timeInfo())
}
// Space represents a list of Events at the same venue
type Space []Event
// getName returns the name of the Space (ie. venue). It is assumed all events have been correctly added to the Space.
// If a Space has no Events, then an empty string is returned.
func (space Space) getName() string {
if len(space) > 0 {
return space[0].Venue
} else {
return ""
}
}
// addEvent adds an Event to a Space. Does not check whether the event to be added has the same venue as the other events in that Space.
func (space *Space) addEvent(event Event) {
*space = append(*space, event)
}
// byStartDate implements sort.Interface for a Events in a Space based on space[i].Start field
type byStartDate Space
func (events byStartDate) Len() int { return len(events) }
func (events byStartDate) Swap(i, j int) { events[i], events[j] = events[j], events[i] }
func (events byStartDate) Less(i, j int) bool { return events[i].Start.Before(events[j].Start) }
// sortByStartDate returns a new Space sorted in ascending order by Event.Start
func (space Space) sortByStartDate() Space {
sort.Sort(byStartDate(space))
return space
}
// toString returns a string with the name of the space, followed by a list of the Events occuring. Only the name of the space will be printed if the space contains no Events.
func (space Space) toString() string {
sortedSpaces := space.sortByStartDate()
var sb strings.Builder
sb.WriteString(fmt.Sprintf("=======================\n%s\n=======================\n", space.getName()))
for _, event := range sortedSpaces {
sb.WriteString(event.toString())
sb.WriteString("\n\n")
}
return sb.String()
}
// Spaces is a list of spaces
type Spaces []Space
// addEvent adds an Event to the appropriate Space, if it exists. Otherwise, a new Space is created.
func (spaces *Spaces) addEvent(event Event) {
for i, space := range *spaces {
if space.getName() == event.Venue {
(*spaces)[i].addEvent(event)
return
}
}
// space not created yet
// create new space
space := make([]Event, 1)
space[0] = event
*spaces = append(*spaces, space)
}
func (spaces Spaces) sortByStartDate() Spaces {
sortedSpaces := make(Spaces, len(spaces))
for i, space := range spaces {
sortedSpaces[i] = space.sortByStartDate()
}
return sortedSpaces
}
// toString returns a string with a list of spaces (using Space.toString). If Spaces is empty [No bookings
func (spaces Spaces) toString() string {
if len(spaces) == 0 {
return "[No bookings recorded]"
}
var sb strings.Builder
for _, space := range spaces {
sb.WriteString(space.toString())
}
return sb.String()
}
// Backend:
// Firestore
const uscWebsiteProjectID = "usc-website-206715"
type eventData struct {
Name fs.String `json:"name"`
Venue fs.String `json:"venueName"`
Start fs.Time `json:"startDate"`
End fs.Time `json:"endDate"`
}
func (e eventData) toEvent() Event {
return Event{
Name: e.Name.Value(),
Venue: e.Venue.Value(),
Start: e.Start.Value(),
End: e.End.Value(),
}
}
// getSpacesAfter returns the Events (as Spaces) whose endDate is after the specified date
func getSpacesAfter(date time.Time) Spaces {
query := fs.Query{
From: []fs.CollectionSelector{{CollectionId: "events", AllDescendants: false}},
Where: []fs.Filter{{
Field: "endDate",
Op: fs.GreaterThan,
FirestoreValue: fs.Time(date),
}},
}
parse := func(raw json.RawMessage) (interface{}, error) {
data := eventData{}
err := json.Unmarshal(raw, &data)
return data, err
}
spaces := make(Spaces, 0)
docs, err := fs.RunQueryAndParse(uscWebsiteProjectID, query, parse, false)
if err != nil {
return spaces
}
for _, doc := range docs {
spaces.addEvent(doc.Data.(eventData).toEvent())
}
return spaces
}
// Filtering
// startOfDay returns a new time.Time with the time set to 00:00 (SG time)
func startOfDay(date time.Time) time.Time {
localDate := date.Local()
return time.Date(localDate.Year(), localDate.Month(), localDate.Day(), 0, 0, 0, 0, localDate.Location())
}
// endOfDay returns the next day 00:00
func endOfDay(date time.Time) time.Time {
return startOfDay(date.AddDate(0, 0, 1))
}
// used with filter functions
type eventPredicate func(Event) bool
// eventBetween returns true if an event occurs between the 2 times given. assumes firstDate and lastDate are not equal
func eventBetween(firstDate, lastDate time.Time) eventPredicate {
return func(e Event) bool {
if e.Start.Before(firstDate) {
return e.End.After(firstDate)
}
return e.Start.Before(lastDate)
}
}
func eventDuring(date time.Time) eventPredicate {
return func(e Event) bool {
return (e.Start.Before(date) && e.End.After(date)) || e.Start.Equal(date)
}
}
func eventOnDay(date time.Time) eventPredicate {
return eventBetween(startOfDay(date), endOfDay(date))
}
// eventBetweenDays returns true if an event occurs between the start of the first day and the end of the last day
func eventBetweenDays(firstDate, lastDate time.Time) eventPredicate {
return eventBetween(startOfDay(firstDate), endOfDay(lastDate))
}
// filter returns a new Space with only Events which satisfy the predicate.
func (space Space) filter(predicate eventPredicate) Space {
filteredEvents := make(Space, 0, len(space))
for _, event := range space {
if predicate(event) {
filteredEvents = append(filteredEvents, event)
}
}
return filteredEvents
}
// filter returns a new Spaces with only Events which satisfy the predicate
func (spaces Spaces) filter(predicate eventPredicate) Spaces {
filteredSpaces := make(Spaces, 0, len(spaces))
for _, space := range spaces {
filteredSpace := space.filter(predicate)
//do not add spaces with no events
if len(filteredSpace) > 0 {
filteredSpaces = append(filteredSpaces, filteredSpace)
}
}
return filteredSpaces
}
// Spaces command:
// bookingsNowMessage returns currently ongoing events
func bookingsNowMessage() string {
spaces := getSpacesAfter(time.Now())
message := fmt.Sprintf("Displaying bookings ongoing right now (%s):\n\n", FormatTimeDate(time.Now().In(utils.SgLocation())))
message += spaces.filter(eventDuring(time.Now())).toString()
return message
}
// bookingsTodayMessage returns events which are happening today. Excludes events which have already finished.
func bookingsTodayMessage() string {
spaces := getSpacesAfter(time.Now())
message := "Displaying bookings for today:\n\n"
message += spaces.filter(eventOnDay(time.Now())).toString()
return message
}
// bookingsComingWeekMessage returns events which will happen/are happening in the next 7 days. Excludes events which have already finished.
func bookingsComingWeekMessage() string {
now := time.Now().In(utils.SgLocation())
weekLater := now.AddDate(0, 0, 7)
spaces := getSpacesAfter(time.Now())
message := fmt.Sprintf("Displaying bookings 7 days from now (%s to %s):\n\n", FormatDate(now), FormatDate(weekLater))
message += spaces.filter(eventBetweenDays(now, weekLater)).toString()
return message
}
// bookingsBetweenMessage returns events which will occur between the specified dates. May include events which have already finished as of now. Assumes that dates are in Sg time.
func bookingsBetweenMessage(firstDate, lastDate time.Time) string {
spaces := getSpacesAfter(startOfDay(firstDate))
message := fmt.Sprintf("Displaying bookings from %s to %s:\n\n", FormatDate(firstDate), FormatDate(lastDate))
message += spaces.filter(eventBetweenDays(firstDate, lastDate)).toString()
return message
}
// bookingsOnDateMessage returns events which will occur on the specified date. May include events which have already finished as of now. Assumes that date is in Sg time.
func bookingsOnDateMessage(date time.Time) string {
spaces := getSpacesAfter(startOfDay(date))
message := fmt.Sprintf("Displaying all bookings on %s:\n\n", FormatDate(date))
message += spaces.filter(eventOnDay(date)).toString()
return message
}
// ParseDDMMYYDate parses user-inputted dd/mm/yy date into time.Time
func ParseDDMMYYDate(date string) (time.Time, error) {
loc := utils.SgLocation()
//Attempt to parse as dd/mm/yy
format := "02/01/06"
t, err := time.ParseInLocation(format, date, loc)
if err != nil {
// Attempt to parse as dd/m/yy
format = "02/1/06"
t, err = time.ParseInLocation(format, date, loc)
}
if err != nil {
// Attempt to parse as d/mm/yy
format = "2/01/06"
t, err = time.ParseInLocation(format, date, loc)
}
if err != nil {
// Attempt to parse as d/m/yy
format = "2/1/06"
t, err = time.ParseInLocation(format, date, loc)
}
if err != nil {
// Attempt to parse as some form of dd/mm
// Attempt to parse as dd/mm
format = "02/01"
t, err = time.ParseInLocation(format, date, loc)
if err != nil {
// Attempt to parse as dd/m
format = "02/1"
t, err = time.ParseInLocation(format, date, loc)
}
if err != nil {
// Attempt to parse as d/mm
format = "2/01"
t, err = time.ParseInLocation(format, date, loc)
}
if err != nil {
// Attempt to parse as d/m
format = "2/1"
t, err = time.ParseInLocation(format, date, loc)
}
// Check if one of the dd/mm checks have worked
if err == nil {
// return t, but using the current year
year := time.Now().Year()
t = time.Date(year, t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
}
return t, err
}
// for easier debugging
func spacesMsg(msg *message) string {
toSend := ""
if len(msg.Args) == 0 || msg.Args[0] == "today" {
toSend += bookingsTodayMessage()
} else if msg.Args[0] == "now" {
toSend += bookingsNowMessage()
} else if msg.Args[0] == "week" {
toSend += bookingsComingWeekMessage()
} else if msg.Args[0] == "tomorrow" {
toSend += bookingsOnDateMessage(time.Now().AddDate(0, 0, 1))
} else {
//try to parse date
t0, err0 := ParseDDMMYYDate(msg.Args[0])
if err0 == nil {
// First argument is a valid date
// Attempt to parse second argument, if exists, and show BookingsBetween(t0, t1)
if len(msg.Args) >= 2 {
t1, err1 := ParseDDMMYYDate(msg.Args[1])
if err1 == nil {
// Check if the interval is too long
if t0.AddDate(0, 0, 33).Before(t1) {
toSend += "The time interval is too long. Please restrict it to at most one month."
} else {
toSend += bookingsBetweenMessage(t0, t1)
}
}
} else {
toSend += bookingsOnDateMessage(t0)
}
}
}
if toSend == "" {
// i.e., if arguments could not be parsed as above
if msg.Args[0] != "help" {
toSend += "Cinnabot was unable to understand your command.\n\n"
}
toSend += "To use the '/spaces' command, type one of the following:\n'/spaces' : to view all bookings for today\n'/spaces now' : to view bookings active at this very moment\n'/spaces week' : to view all bookings for this week\n'/spaces dd/mm(/yy)' : to view all bookings on a specific day\n'/spaces dd/mm(/yy) dd/mm(/yy)' : to view all bookings in a specific range of dates"
}
return toSend
}
//Spaces is the primary Cinnabot Spaces method that the end user interacts with.
// "/spaces" displays bookings for today.
// "/spaces now" displays bookings right this moment.
// "/spaces week" displays bookings in the next 7 days.
// "/spaces dd/mm/yy" displays bookings on the given date.
// "/spaces dd/mm/yy dd/mm/yy" displays bookings in the given interval (limited to one month++).
// "/spaces help" informs the user of available commands.
//
// Extra arguments are ignored.
// Unparseable commands return the help menu.
func (cb *Cinnabot) Spaces(msg *message) {
cb.SendTextMessage(msg.From.ID, spacesMsg(msg))
}