eintopf.info@v0.13.16/web/eventlist.go (about) 1 // Copyright (C) 2022 The Eintopf authors 2 // 3 // This program is free software: you can redistribute it and/or modify 4 // it under the terms of the GNU Affero General Public License as 5 // published by the Free Software Foundation, either version 3 of the 6 // License, or (at your option) any later version. 7 // 8 // This program is distributed in the hope that it will be useful, 9 // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 // GNU Affero General Public License for more details. 12 // 13 // You should have received a copy of the GNU Affero General Public License 14 // along with this program. If not, see <https://www.gnu.org/licenses/>. 15 16 package web 17 18 import ( 19 "context" 20 "fmt" 21 "net/http" 22 "net/url" 23 "strconv" 24 "time" 25 26 "github.com/goodsign/monday" 27 28 "eintopf.info/internal/xhttp" 29 "eintopf.info/internal/xtime" 30 "eintopf.info/service/eventsearch" 31 "eintopf.info/service/revent" 32 "eintopf.info/service/search" 33 ) 34 35 // EventListPage renders the eventlist page. 36 func (renderer *Renderer) EventListPage(w http.ResponseWriter, r *http.Request) { 37 options := eventListOptionsFromRequest(r, renderer.tz) 38 pages, err := renderer.getEventListPagination(r.Context(), xtime.Date(time.Now()), options) 39 if err != nil { 40 renderer.errorPage(w, r, err) 41 return 42 } 43 options = options.setPages(pages) 44 45 result, err := renderer.eventSearch.Search(r.Context(), options.eventSearchOptions(true)) 46 if err != nil { 47 renderer.errorPage(w, r, err) 48 return 49 } 50 51 daysWithEvents, ok := result.Buckets["day"].(search.TermsBucket) 52 if !ok { 53 renderer.errorPage(w, r, fmt.Errorf("invalid day aggregation")) 54 return 55 } 56 tags, ok := result.Buckets["tags"].(search.TermsBucket) 57 if !ok { 58 renderer.errorPage(w, r, fmt.Errorf("invalid tags aggregation")) 59 return 60 } 61 62 err = renderer.renderPage(w, r, "eventlist", map[string]interface{}{ 63 "SearchAction": options.URL(), 64 65 "Options": options, 66 67 "Today": time.Now(), 68 69 "Days": groupEventsByDays(result.Events), 70 "Tags": tags, 71 72 "Calendar": calendarMonth(options.Calendar.Year(), options.Calendar.Month(), daysWithEvents, time.UTC), 73 }, nil) 74 if err != nil { 75 renderer.errorPage(w, r, err) 76 return 77 } 78 } 79 80 // pageSize definesthe minimum number of events per page. 81 const pageSize = 15 82 83 // Page is a range of days. A page contains a minimum of pageSize events. 84 type Page struct { 85 // Start date of the page 86 Start time.Time `json:"start"` 87 // End date of the page 88 End time.Time `json:"end"` 89 } 90 91 func (p Page) MarshalJSON() ([]byte, error) { 92 return []byte(fmt.Sprintf(`{"start": "%s","end":"%s"}`, p.Start.Format("2006-01-02"), p.End.Format("2006-01-02"))), nil 93 } 94 95 // Algorithm for pagniated eventlist: 96 // 97 // Problems: 98 // - multiday events 99 // - page ends in the middle of the day when paginating with fixed page size 100 func (renderer *Renderer) getEventListPagination(ctx context.Context, dateMin time.Time, options eventListOptions) ([]Page, error) { 101 result, err := renderer.eventSearch.Search(ctx, eventsearch.Options{ 102 Query: options.Query, 103 Sort: "", 104 Page: 0, 105 PageSize: 1, 106 Aggregations: eventsearch.Aggregations{ 107 "day": eventsearch.DayTermsAggregation{ 108 Filters: eventsearch.Filters{ 109 eventsearch.ListedFilter{Listed: true}, 110 eventsearch.TagsFilter{Tags: options.Tags}, 111 eventsearch.DateRangeFilter{DateMin: dateMin}, 112 }, 113 }, 114 }, 115 }) 116 if err != nil { 117 return nil, err 118 } 119 120 rawDays, ok := result.Buckets["day"].(search.TermsBucket) 121 if !ok { 122 return nil, fmt.Errorf("invalid day aggregation") 123 } 124 125 type day struct { 126 Date time.Time 127 Events int 128 } 129 130 days := []day{} 131 132 for _, d := range rawDays { 133 date, err := time.Parse(time.RFC3339, d.Term) 134 if err != nil { 135 return nil, fmt.Errorf("failed to parse date '%s': %s", d.Term, err) 136 } 137 days = append(days, day{Date: date, Events: d.Count}) 138 } 139 140 pages := []Page{} 141 i := 0 142 start := time.Time{} 143 for _, d := range days { 144 if start.IsZero() { 145 start = d.Date 146 } 147 148 i += d.Events 149 if i >= pageSize { 150 i = 0 151 pages = append(pages, Page{Start: start, End: d.Date}) 152 start = time.Time{} 153 } 154 } 155 if i > 0 { 156 pages = append(pages, Page{Start: start, End: days[len(days)-1].Date}) 157 } 158 159 return pages, nil 160 } 161 162 const partialEventListErrorMsg = "Weitere Events konnten nicht automatisch geladen werden, nutze bitte die Buttons um mehr Events zu sehen." 163 164 // PartialEventList renders a paginated part of the eventlist. 165 // This is used by the infinite scroll javascript. 166 func (renderer *Renderer) PartialEventList(w http.ResponseWriter, r *http.Request) { 167 options := eventListOptionsFromRequest(r, renderer.tz) 168 result, err := renderer.eventSearch.Search(r.Context(), options.eventSearchOptions(false)) 169 if err != nil { 170 renderer.errorPartial(w, r, err, partialEventListErrorMsg) 171 return 172 } 173 174 err = renderer.engine.Render(r.Context(), w, []string{"partial/eventlist"}, map[string]any{ 175 "Options": options, 176 "Days": groupEventsByDays(result.Events), 177 }, nil, true) 178 if err != nil { 179 renderer.errorPartial(w, r, err, partialEventListErrorMsg) 180 return 181 } 182 } 183 184 // PartialCalendar renders the calendar container 185 // This is used to reload the calendar widget with javascript. 186 func (renderer *Renderer) PartialCalendar(w http.ResponseWriter, r *http.Request) { 187 options := eventListOptionsFromRequest(r, renderer.tz) 188 189 result, err := renderer.eventSearch.Search(r.Context(), options.eventSearchOptions(true)) 190 if err != nil { 191 renderer.errorPage(w, r, err) 192 return 193 } 194 datesWithEvents, ok := result.Buckets["day"].(search.TermsBucket) 195 if !ok { 196 renderer.errorPage(w, r, fmt.Errorf("invalid day aggregation")) 197 return 198 } 199 200 err = renderer.engine.Render(r.Context(), w, []string{"partial/calendar"}, map[string]any{ 201 "Today": time.Now(), 202 "Options": options, 203 "Calendar": calendarMonth(options.Calendar.Year(), options.Calendar.Month(), datesWithEvents, time.UTC), 204 }, nil, true) 205 if err != nil { 206 renderer.errorPage(w, r, err) 207 return 208 } 209 } 210 211 // eventListOptions are options that change the result of the event list page. 212 type eventListOptions struct { 213 Query string // Query defines a search query. 214 Date time.Time // Date is the currently selected date. 215 MaxDate time.Time 216 MinDate time.Time 217 Calendar time.Time // Calendar defines the selected month on the calendar picker. 218 Tags []string // Tags is a list of tags, that filter the event list. 219 Page int // Page is the current page. The fist page starts at 0. 220 Pages []Page 221 222 originalDate time.Time // originalDate keeps track of the date as set in the request. 223 224 tz *time.Location 225 } 226 227 // eventListOptionsFromRequest takes an http request object to create a new eventListOptions. 228 // A valid request uri might look light this: 229 // - /?date=2006-01-02&calendar=2006-01&tags=foo&tags=bar 230 // 231 // The function is best effort, all errors are ignored. 232 func eventListOptionsFromRequest(r *http.Request, tz *time.Location) eventListOptions { 233 date, _ := xhttp.ReadQueryTime(r, "date", "2006-01-02", time.Time{}, time.UTC) 234 dateMin, _ := xhttp.ReadQueryTime(r, "dateMin", "2006-01-02", time.Time{}, time.UTC) 235 dateMax, _ := xhttp.ReadQueryTime(r, "dateMax", "2006-01-02", time.Time{}, time.UTC) 236 calendar, err := xhttp.ReadQueryTime(r, "calendar", "2006-01", xtime.Date(time.Now()), time.UTC) 237 if err != nil { 238 calendar = xtime.Date(time.Now()) 239 } 240 page, err := xhttp.ReadQueryInt(r, "page") 241 return eventListOptions{ 242 Query: r.URL.Query().Get("query"), 243 Date: date, 244 MinDate: dateMin, 245 MaxDate: dateMax, 246 Calendar: calendar, 247 Tags: r.URL.Query()["tags"], 248 Page: page, 249 originalDate: date, 250 tz: tz, 251 } 252 } 253 254 // eventSearchOptions converts eventListOptions to eventsearch.Options 255 func (e eventListOptions) eventSearchOptions(aggregate bool) eventsearch.Options { 256 var aggregations eventsearch.Aggregations 257 if aggregate { 258 aggregations = eventsearch.Aggregations{ 259 "tags": eventsearch.TagTermsAggregation{ 260 Filters: eventsearch.Filters{ 261 eventsearch.ListedFilter{Listed: true}, 262 eventsearch.DateRangeFilter{ 263 DateMin: e.DateMin(false), 264 DateMax: e.DateMax(false), 265 }, 266 }, 267 }, 268 "day": eventsearch.DayTermsAggregation{ 269 Filters: eventsearch.Filters{ 270 eventsearch.ListedFilter{Listed: true}, 271 eventsearch.TagsFilter{Tags: e.Tags}, 272 }, 273 }, 274 } 275 } 276 277 return eventsearch.Options{ 278 Query: e.Query, 279 Sort: "date", 280 Filters: []eventsearch.Filter{ 281 eventsearch.ListedFilter{Listed: true}, 282 eventsearch.DateRangeFilter{ 283 DateMin: e.DateMin(true), 284 DateMax: e.DateMax(true), 285 }, 286 eventsearch.TagsFilter{Tags: e.Tags}, 287 // Do not search for raw multiday events 288 eventsearch.MultidayRangeFilter{MultidayMin: iptr(-1)}, 289 }, 290 Aggregations: aggregations, 291 } 292 } 293 294 func (e eventListOptions) setPages(pages []Page) eventListOptions { 295 e.Pages = pages 296 return e 297 } 298 299 // DateMin returns the start of the current date range. 300 func (e eventListOptions) DateMin(pagination bool) time.Time { 301 if !e.MinDate.IsZero() { 302 return xtime.InLocation(xtime.StartOfDay(e.MinDate), e.tz) 303 } 304 if !e.Date.IsZero() { 305 return xtime.InLocation(xtime.StartOfDay(e.Date), e.tz) 306 } 307 if pagination && e.Pages != nil && len(e.Pages) > e.Page { 308 page := e.Pages[e.Page] 309 return xtime.InLocation(xtime.StartOfDay(page.Start), e.tz) 310 } 311 return xtime.InLocation(xtime.StartOfDay(time.Now()), e.tz) 312 } 313 314 // DateMax returns the end of the current date range. 315 func (e eventListOptions) DateMax(pagination bool) time.Time { 316 if !e.MaxDate.IsZero() { 317 return xtime.InLocation(xtime.EndOfDay(e.MaxDate), e.tz) 318 } 319 if !e.Date.IsZero() { 320 return xtime.InLocation(xtime.EndOfDay(e.Date), e.tz) 321 } 322 if pagination && e.Pages != nil && len(e.Pages) > e.Page { 323 page := e.Pages[e.Page] 324 return xtime.InLocation(xtime.EndOfDay(page.End), e.tz) 325 } 326 return time.Time{} 327 } 328 329 // ToggleDate sets the date option. If the date is the same as the current 330 // date, set the date to now. 331 // This resets the page to 0. 332 // It returns a new copy of options. 333 func (e eventListOptions) ToggleDate(date time.Time) eventListOptions { 334 if sameDay(date, e.Date) { 335 e.Date = time.Now() 336 } else { 337 e.Date = date 338 } 339 e.Page = 0 340 return e 341 } 342 343 // DateIsDefault returns true, when the date is zero or today. 344 func (e eventListOptions) DateIsDefault() bool { 345 return e.Date.IsZero() || sameDay(time.Now(), e.Date) 346 } 347 348 // SetCalendar sets the calendar option and returns a new copy of options. 349 // This resets the page to 0. 350 func (e eventListOptions) SetCalendar(calendar time.Time) eventListOptions { 351 e.Page = 0 352 e.Calendar = calendar 353 return e 354 } 355 356 // CalendarIsDefault returns true, when the date is zero or in the current month. 357 func (e eventListOptions) CalendarIsDefault() bool { 358 return e.Calendar.IsZero() || sameMonth(e.Calendar, time.Now()) 359 } 360 361 // PrevCalendar sets the calendar to the previous month and returns a copy of 362 // options. 363 func (e eventListOptions) PrevCalendar() eventListOptions { 364 e.Calendar = e.Calendar.AddDate(0, -1, 0) 365 return e 366 } 367 368 // NextCalendar sets the calendar to the next month and returns a copy of options. 369 func (e eventListOptions) NextCalendar() eventListOptions { 370 e.Calendar = e.Calendar.AddDate(0, 1, 0) 371 return e 372 } 373 374 // SetTag sets the tag option and returns a new copy of options. 375 // This resets the page to 0. 376 func (e eventListOptions) SetTag(tag string) eventListOptions { 377 e.Page = 0 378 e.Tags = []string{tag} 379 return e 380 } 381 382 // ToggleTag adds/removes the given tag from the tag option and returns a new 383 // copy of options. 384 // This resets the page to 0. 385 func (e eventListOptions) ToggleTag(tag string) eventListOptions { 386 e.Page = 0 387 tags := make([]string, len(e.Tags)) 388 copy(tags, e.Tags) 389 for i, t := range tags { 390 if t == tag { 391 e.Tags = append(tags[:i], tags[i+1:]...) 392 return e 393 } 394 } 395 e.Tags = append(tags, tag) 396 return e 397 } 398 399 // HasTag returns true, if the given tag is set. 400 func (e eventListOptions) HasTag(tag string) bool { 401 for _, t := range e.Tags { 402 if t == tag { 403 return true 404 } 405 } 406 return false 407 } 408 409 // PrevPage decreases the page by one and returns a new copy of options. 410 func (e eventListOptions) PrevPage() eventListOptions { 411 e.Page -= 1 412 return e 413 } 414 415 func (e eventListOptions) HasPrevPage() bool { 416 if e.Pages == nil { 417 return false 418 } 419 return e.Page > 0 420 } 421 422 // NextPage increases the page by one and returns a new copy of options. 423 func (e eventListOptions) NextPage() eventListOptions { 424 e.Page += 1 425 return e 426 } 427 428 func (e eventListOptions) HasNextPage() bool { 429 if e.Pages == nil { 430 return false 431 } 432 return e.Page < len(e.Pages)-1 433 } 434 435 // URL converts the options to a url. 436 // It only adds an option value, when it is not its zero/default value. 437 func (e eventListOptions) URL() string { 438 u := url.URL{} 439 u.Path = "/" 440 q := u.Query() 441 if e.Query != "" { 442 q.Set("query", e.Query) 443 } 444 if !e.Date.IsZero() && !sameDay(e.Date, time.Now()) { 445 q.Set("date", e.Date.Format("2006-01-02")) 446 } 447 if !e.CalendarIsDefault() { 448 q.Set("calendar", e.Calendar.Format("2006-01")) 449 } 450 for _, tag := range e.Tags { 451 q.Add("tags", tag) 452 } 453 if e.Page > 0 { 454 q.Add("page", strconv.Itoa(e.Page)) 455 } 456 u.RawQuery = q.Encode() 457 u.RawQuery, _ = url.QueryUnescape(q.Encode()) 458 return u.String() 459 } 460 461 // groupEventsByDays maps a list of events to days to a set of days with events. 462 // Multiday events get inserted at the start of each day. 463 func groupEventsByDays(events []*eventsearch.EventDocument) map[time.Time][]*eventsearch.EventDocument { 464 days := map[time.Time][]*eventsearch.EventDocument{} 465 for _, e := range events { 466 d := date(e.Date.Year(), e.Date.Month(), e.Date.Day(), time.UTC) 467 468 if days[d] == nil { 469 days[d] = []*eventsearch.EventDocument{} 470 } 471 if e.MultiDay > 0 { 472 // Insert multiday events at the beginning of the day 473 days[d] = append([]*eventsearch.EventDocument{e}, days[d]...) 474 } else { 475 days[d] = append(days[d], e) 476 } 477 } 478 return days 479 } 480 481 func date(year int, month time.Month, day int, tz *time.Location) time.Time { 482 return time.Date(year, month, day, 0, 0, 0, 0, tz) 483 } 484 485 func sameDay(t1, t2 time.Time) bool { 486 return t1.Year() == t2.Year() && t1.Month() == t2.Month() && t1.Day() == t2.Day() 487 } 488 489 func sameMonth(t1, t2 time.Time) bool { 490 return t1.Year() == t2.Year() && t1.Month() == t2.Month() 491 } 492 493 type calendarDay struct { 494 Date time.Time // Date of the day 495 InMonth bool // InMonth is true when the day is in the selected month 496 HasEvents bool // HasEvents is true when there are events for this day 497 } 498 499 // calendarMonth creates a list of days for a given month and year. The days 500 // will start the monday of the first week of the month and end with the sunday 501 // of the last week of the month. 502 func calendarMonth(year int, month time.Month, datesWithEvents search.TermsBucket, tz *time.Location) []calendarDay { 503 calendar := make([]calendarDay, 0) 504 505 d := time.Date(year, month, 0, 0, 0, 0, 0, tz) 506 // Find first monday from start of the month 507 for d.Weekday() != time.Monday { 508 d = d.Add(-time.Hour * 24) 509 } 510 lastMonth := d.Month() 511 512 for d.Month() == lastMonth || d.Month() == month { 513 hasEvents := false 514 n := date(d.Year(), d.Month(), d.Day(), time.UTC) 515 dateString := n.Format(search.DateLayout) 516 for _, dateWithEvent := range datesWithEvents { 517 if dateWithEvent.Term == dateString { 518 hasEvents = true 519 break 520 } 521 } 522 calendar = append(calendar, calendarDay{ 523 Date: d, 524 InMonth: month == d.Month(), 525 HasEvents: hasEvents, 526 }) 527 d = d.Add(time.Hour * 24) 528 } 529 530 for d.Weekday() != time.Monday { 531 calendar = append(calendar, calendarDay{ 532 Date: d, 533 InMonth: false, 534 HasEvents: false, 535 }) 536 d = d.Add(time.Hour * 24) 537 } 538 539 return calendar 540 } 541 542 func formatInterval(interval revent.Interval) string { 543 switch interval.Type { 544 case revent.IntervalDay: 545 if interval.Interval == 1 { 546 return "Jeden Tag" 547 } 548 return fmt.Sprintf("Jeden %s Tag", repeatingToString(interval.Interval, true)) 549 case revent.IntervalWeek: 550 if interval.Interval == 1 { 551 return fmt.Sprintf("Jede Woche %s", weekDayString(interval.WeekDay)) 552 } 553 return fmt.Sprintf("Jeden %s Woche %s", repeatingToString(interval.Interval, false), weekDayString(interval.WeekDay)) 554 default: 555 return "" 556 } 557 } 558 559 func weekDayString(weekDay time.Weekday) string { 560 // Convert weekday format to german convention. 561 if weekDay == 0 { 562 weekDay = 7 563 } else { 564 weekDay -= 1 565 } 566 return monday.GetLongDays(monday.LocaleDeDE)[weekDay] 567 } 568 569 func repeatingToString(interval int, suffixN bool) string { 570 str := "" 571 switch interval { 572 case 1: 573 str = "erste" 574 case 2: 575 str = "zweite" 576 case 3: 577 str = "dritte" 578 case 4: 579 str = "vierte" 580 case 5: 581 str = "fünfte" 582 case 6: 583 str = "sechste" 584 case 7: 585 str = "siebte" 586 } 587 if suffixN { 588 return str + "n" 589 } 590 return str 591 }