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  }