package web import ( "net/url" "slices" "strconv" "time" "github.com/martinlehoux/biking_home/ride" ) const ( rideFilterDistanceMinKm = 10 rideFilterDistanceMaxKm = 300 rideFilterDistanceStepKm = 10 rideFilterDurationMinMinutes = 0 rideFilterDurationMaxMinutes = 24 * 60 rideFilterDurationStepMinutes = 30 rideFilterCotacolMinPoints = 0 rideFilterCotacolMaxPoints = 5000 rideFilterCotacolStepPoints = 50 ) const ( rideFilterDistanceMinParam = "distance_min" rideFilterDistanceMaxParam = "distance_max" rideFilterDurationMinParam = "duration_min" rideFilterDurationMaxParam = "duration_max" rideFilterDateFromParam = "date_from" rideFilterDateToParam = "date_to" rideFilterCotacolMinParam = "cotacol_min" rideFilterCotacolMaxParam = "cotacol_max" rideFilterBikeTypeParam = "bike_type" rideFilterRideTypeParam = "ride_type" ) type RideFilters struct { DistanceMinKm *int DistanceMaxKm *int DurationMinMinutes *int DurationMaxMinutes *int DateFrom *time.Time DateToExclusive *time.Time CotacolMinPoints *int CotacolMaxPoints *int BikeType *ride.BikeType RideTypes []ride.RideType } type RideFilterView struct { Active bool DistanceMinKm int DistanceMaxKm int DurationMinMinutes int DurationMaxMinutes int DateFrom string DateTo string CotacolMinPoints int CotacolMaxPoints int BikeType string RideTypes []ride.RideType ResetURL string } type RangeFilterConfig struct { Legend string IDPrefix string Format string ParamMin string ParamMax string BoundMin int BoundMax int Step int ValueMin int ValueMax int LabelMin string LabelMax string FormatValue func(int) string } func rideDistanceFilterConfig(view RideFilterView) RangeFilterConfig { return RangeFilterConfig{ Legend: "Distance", IDPrefix: "distance", Format: "distance", ParamMin: rideFilterDistanceMinParam, ParamMax: rideFilterDistanceMaxParam, BoundMin: rideFilterDistanceMinKm, BoundMax: rideFilterDistanceMaxKm, Step: rideFilterDistanceStepKm, ValueMin: view.DistanceMinKm, ValueMax: view.DistanceMaxKm, LabelMin: "Minimum distance", LabelMax: "Maximum distance", FormatValue: formatRideFilterDistance, } } func rideDurationFilterConfig(view RideFilterView) RangeFilterConfig { return RangeFilterConfig{ Legend: "Moving duration", IDPrefix: "duration", Format: "duration", ParamMin: rideFilterDurationMinParam, ParamMax: rideFilterDurationMaxParam, BoundMin: rideFilterDurationMinMinutes, BoundMax: rideFilterDurationMaxMinutes, Step: rideFilterDurationStepMinutes, ValueMin: view.DurationMinMinutes, ValueMax: view.DurationMaxMinutes, LabelMin: "Minimum moving duration", LabelMax: "Maximum moving duration", FormatValue: formatRideFilterDuration, } } func rideCotacolFilterConfig(view RideFilterView) RangeFilterConfig { return RangeFilterConfig{ Legend: "Cotacol", IDPrefix: "cotacol", Format: "cotacol", ParamMin: rideFilterCotacolMinParam, ParamMax: rideFilterCotacolMaxParam, BoundMin: rideFilterCotacolMinPoints, BoundMax: rideFilterCotacolMaxPoints, Step: rideFilterCotacolStepPoints, ValueMin: view.CotacolMinPoints, ValueMax: view.CotacolMaxPoints, LabelMin: "Minimum Cotacol", LabelMax: "Maximum Cotacol", FormatValue: formatRideFilterCotacol, } } func parseRideFilters(query url.Values) RideFilters { filters := RideFilters{ DistanceMinKm: parseRideFilterInt(query.Get(rideFilterDistanceMinParam), rideFilterDistanceMinKm, rideFilterDistanceMaxKm, rideFilterDistanceStepKm), DistanceMaxKm: parseRideFilterInt(query.Get(rideFilterDistanceMaxParam), rideFilterDistanceMinKm, rideFilterDistanceMaxKm, rideFilterDistanceStepKm), DurationMinMinutes: parseRideFilterInt(query.Get(rideFilterDurationMinParam), rideFilterDurationMinMinutes, rideFilterDurationMaxMinutes, rideFilterDurationStepMinutes), DurationMaxMinutes: parseRideFilterInt(query.Get(rideFilterDurationMaxParam), rideFilterDurationMinMinutes, rideFilterDurationMaxMinutes, rideFilterDurationStepMinutes), CotacolMinPoints: parseRideFilterInt(query.Get(rideFilterCotacolMinParam), rideFilterCotacolMinPoints, rideFilterCotacolMaxPoints, rideFilterCotacolStepPoints), CotacolMaxPoints: parseRideFilterInt(query.Get(rideFilterCotacolMaxParam), rideFilterCotacolMinPoints, rideFilterCotacolMaxPoints, rideFilterCotacolStepPoints), BikeType: parseRideBikeTypeFilter(query.Get(rideFilterBikeTypeParam)), RideTypes: parseRideRideTypesFilter(query[rideFilterRideTypeParam]), DateFrom: parseRideFilterDate(query.Get(rideFilterDateFromParam), false), DateToExclusive: parseRideFilterDate(query.Get(rideFilterDateToParam), true)} filters.DistanceMinKm = clearRideFilterBound(filters.DistanceMinKm, rideFilterDistanceMinKm) filters.DistanceMaxKm = clearRideFilterBound(filters.DistanceMaxKm, rideFilterDistanceMaxKm) filters.DurationMinMinutes = clearRideFilterBound(filters.DurationMinMinutes, rideFilterDurationMinMinutes) filters.DurationMaxMinutes = clearRideFilterBound(filters.DurationMaxMinutes, rideFilterDurationMaxMinutes) filters.CotacolMinPoints = clearRideFilterBound(filters.CotacolMinPoints, rideFilterCotacolMinPoints) filters.CotacolMaxPoints = clearRideFilterBound(filters.CotacolMaxPoints, rideFilterCotacolMaxPoints) if filters.DateFrom != nil && filters.DateToExclusive != nil && !filters.DateFrom.Before(*filters.DateToExclusive) { filters.DateFrom = nil filters.DateToExclusive = nil } if filters.DistanceMinKm != nil && filters.DistanceMaxKm != nil && *filters.DistanceMinKm > *filters.DistanceMaxKm { filters.DistanceMinKm = nil filters.DistanceMaxKm = nil } if filters.DurationMinMinutes != nil && filters.DurationMaxMinutes != nil && *filters.DurationMinMinutes > *filters.DurationMaxMinutes { filters.DurationMinMinutes = nil filters.DurationMaxMinutes = nil } if filters.CotacolMinPoints != nil && filters.CotacolMaxPoints != nil && *filters.CotacolMinPoints > *filters.CotacolMaxPoints { filters.CotacolMinPoints = nil filters.CotacolMaxPoints = nil } return filters } func clearRideFilterBound(value *int, bound int) *int { if value != nil && *value == bound { return nil } return value } func parseRideFilterInt(value string, min, max, step int) *int { if value == "" { return nil } parsed, err := strconv.Atoi(value) if err != nil || parsed < min || parsed > max || (parsed-min)%step != 0 { return nil } return &parsed } func parseRideFilterDate(value string, end bool) *time.Time { if value == "" { return nil } parsed, err := time.Parse(dateFormat, value) if err != nil { return nil } if end { parsed = parsed.AddDate(0, 0, 1) } return &parsed } func parseRideBikeTypeFilter(value string) *ride.BikeType { if value == "" { return nil } if !ride.IsValidBikeType(value) { return nil } bikeType := ride.BikeType(value) return &bikeType } func parseRideRideTypesFilter(values []string) []ride.RideType { set := make([]ride.RideType, 0, len(values)) for _, value := range values { if !ride.IsValidRideType(value) { continue } rideType := ride.RideType(value) if slices.Contains(set, rideType) { continue } set = append(set, rideType) } if len(set) == 0 { return defaultRideTypes() } return set } // defaultRideTypes is the ride type set represented by an absent ride_type // filter: every ride type except transport. func defaultRideTypes() []ride.RideType { return []ride.RideType{ ride.RideTypeTraining, ride.RideTypeIndoor, ride.RideTypeRace, } } func rideTypesEqual(a, b []ride.RideType) bool { if len(a) != len(b) { return false } for _, candidate := range a { if !slices.Contains(b, candidate) { return false } } return true } func rideTypeFilterActive(types []ride.RideType) bool { if len(types) == 0 { return false } return !rideTypesEqual(types, defaultRideTypes()) } func buildRideFilterView(filters RideFilters, sort RideSort) RideFilterView { dateFrom := "" if filters.DateFrom != nil { dateFrom = filters.DateFrom.Format(dateFormat) } dateTo := "" if filters.DateToExclusive != nil { dateTo = filters.DateToExclusive.AddDate(0, 0, -1).Format(dateFormat) } bikeType := "" if filters.BikeType != nil { bikeType = string(*filters.BikeType) } return RideFilterView{ Active: filters.Active(), DistanceMinKm: rideFilterValue(filters.DistanceMinKm, rideFilterDistanceMinKm), DistanceMaxKm: rideFilterValue(filters.DistanceMaxKm, rideFilterDistanceMaxKm), DurationMinMinutes: rideFilterValue(filters.DurationMinMinutes, rideFilterDurationMinMinutes), DurationMaxMinutes: rideFilterValue(filters.DurationMaxMinutes, rideFilterDurationMaxMinutes), DateFrom: dateFrom, DateTo: dateTo, CotacolMinPoints: rideFilterValue(filters.CotacolMinPoints, rideFilterCotacolMinPoints), CotacolMaxPoints: rideFilterValue(filters.CotacolMaxPoints, rideFilterCotacolMaxPoints), BikeType: bikeType, RideTypes: filters.RideTypes, ResetURL: rideFiltersResetURL(sort), } } func rideFilterValue(value *int, fallback int) int { if value == nil { return fallback } return *value } func formatRideFilterInt(value int) string { return strconv.Itoa(value) } func formatRideFilterDistance(value int) string { return strconv.Itoa(value) + " km" } func formatRideFilterDuration(value int) string { hours := value / 60 minutes := value % 60 return strconv.Itoa(hours) + "h " + formatTwoDigitNumber(minutes) + "m" } func formatRideFilterCotacol(value int) string { return strconv.Itoa(value) + " points" } func formatTwoDigitNumber(value int) string { if value < 10 { return "0" + strconv.Itoa(value) } return strconv.Itoa(value) } func rideFilterQuery(filters RideFilters) url.Values { query := url.Values{} if filters.DistanceMinKm != nil { query.Set(rideFilterDistanceMinParam, formatRideFilterInt(*filters.DistanceMinKm)) } if filters.DistanceMaxKm != nil { query.Set(rideFilterDistanceMaxParam, formatRideFilterInt(*filters.DistanceMaxKm)) } if filters.DurationMinMinutes != nil { query.Set(rideFilterDurationMinParam, formatRideFilterInt(*filters.DurationMinMinutes)) } if filters.DurationMaxMinutes != nil { query.Set(rideFilterDurationMaxParam, formatRideFilterInt(*filters.DurationMaxMinutes)) } if filters.DateFrom != nil { query.Set(rideFilterDateFromParam, filters.DateFrom.Format(dateFormat)) } if filters.DateToExclusive != nil { query.Set(rideFilterDateToParam, filters.DateToExclusive.AddDate(0, 0, -1).Format(dateFormat)) } if filters.CotacolMinPoints != nil { query.Set(rideFilterCotacolMinParam, formatRideFilterInt(*filters.CotacolMinPoints)) } if filters.CotacolMaxPoints != nil { query.Set(rideFilterCotacolMaxParam, formatRideFilterInt(*filters.CotacolMaxPoints)) } if filters.BikeType != nil { query.Set(rideFilterBikeTypeParam, string(*filters.BikeType)) } if len(filters.RideTypes) > 0 && !rideTypesEqual(filters.RideTypes, defaultRideTypes()) { for _, rideType := range filters.RideTypes { query.Add(rideFilterRideTypeParam, string(rideType)) } } return query } func rideFiltersResetURL(sort RideSort) string { if sort.Column == rideSortStarted && sort.Descending { return "/" } query := url.Values{} query.Set("sort", sort.Column) query.Set("dir", rideSortDirection(sort)) return "/?" + query.Encode() } func (filters RideFilters) Active() bool { return filters.DistanceMinKm != nil || filters.DistanceMaxKm != nil || filters.DurationMinMinutes != nil || filters.DurationMaxMinutes != nil || filters.DateFrom != nil || filters.DateToExclusive != nil || filters.CotacolMinPoints != nil || filters.CotacolMaxPoints != nil || filters.BikeType != nil || rideTypeFilterActive(filters.RideTypes) } func filterRides(items []ride.StoredRide, filters RideFilters) []ride.StoredRide { filtered := make([]ride.StoredRide, 0, len(items)) for _, item := range items { if !item.IsCategorised() { continue } if !rideMatchesFilters(item, filters) { continue } filtered = append(filtered, item) } return filtered } func rideMatchesFilters(item ride.StoredRide, filters RideFilters) bool { if filters.DistanceMinKm != nil && item.DistanceM < float64(*filters.DistanceMinKm)*1000 { return false } if filters.DistanceMaxKm != nil && item.DistanceM > float64(*filters.DistanceMaxKm)*1000 { return false } if filters.DurationMinMinutes != nil && item.EffectiveMovingTimeS() < int64(*filters.DurationMinMinutes)*60 { return false } if filters.DurationMaxMinutes != nil && item.EffectiveMovingTimeS() > int64(*filters.DurationMaxMinutes)*60 { return false } startDate := item.StartDate.UTC() if filters.DateFrom != nil && startDate.Before(*filters.DateFrom) { return false } if filters.DateToExclusive != nil && !startDate.Before(*filters.DateToExclusive) { return false } if filters.CotacolMinPoints != nil || filters.CotacolMaxPoints != nil { cotacol, ready := item.CotacolScore() if !ready || item.CotacolAlgorithmVersion() != ride.CotacolAlgorithmVersion || item.DistanceM <= 0 { return false } if filters.CotacolMinPoints != nil && cotacol < float64(*filters.CotacolMinPoints) { return false } if filters.CotacolMaxPoints != nil && cotacol > float64(*filters.CotacolMaxPoints) { return false } } if filters.BikeType != nil && item.BikeType != *filters.BikeType { return false } if len(filters.RideTypes) > 0 && !slices.Contains(filters.RideTypes, item.RideType) { return false } return true }