aboutsummaryrefslogtreecommitdiff
path: root/official_climb/list.go
blob: 33b12e87d77c86da9669046bd23b53d730353b97 (plain) (blame)
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

package official_climb

import (
	"database/sql"
	"fmt"
	"time"

	"github.com/martinlehoux/biking_home/climb"
)

// SortColumn is a climbs-list ordering key.
type SortColumn string

const (
	SortName     SortColumn = "name"
	SortAttempts SortColumn = "attempts"
	SortDistance SortColumn = "distance"
	SortSlope    SortColumn = "slope"
	SortCotacol  SortColumn = "cotacol"
	SortBestTime SortColumn = "best_time"
)

// ListedClimb is one climbs-list row: official climb identity plus aggregates
// over its recorded attempts. Medians are computed in Go from the attempt
// rows; best time is the minimum attempt duration.
type ListedClimb struct {
	ID             int64
	Name           string
	Attempts       int
	DistanceM      float64
	ElevationGainM float64
	SlopePercent   float64
	Cotacol        float64
	BestDurationS  float64
	HasBest        bool
}

func (c ListedClimb) Category() string {
	return climb.CategoryOf(c.Cotacol).String()
}

// ListWithAggregates returns every official climb with aggregates over its
// attempts. Attempts default to zero for climbs never ridden; climbs without
// attempts show no distance/slope stats.
func ListWithAggregates(db *sql.DB) ([]ListedClimb, error) {
	climbRows, err := db.Query("SELECT id, name FROM official_climbs ORDER BY name ASC")
	if err != nil {
		return nil, fmt.Errorf("list climbs: %w", err)
	}
	defer climbRows.Close()
	climbs := make([]ListedClimb, 0)
	index := make(map[int64]int)
	for climbRows.Next() {
		var row ListedClimb
		if err := climbRows.Scan(&row.ID, &row.Name); err != nil {
			return nil, err
		}
		index[row.ID] = len(climbs)
		climbs = append(climbs, row)
	}
	if err := climbRows.Err(); err != nil {
		return nil, fmt.Errorf("iterate climbs: %w", err)
	}

	attemptRows, err := db.Query(`
		SELECT climb_id, distance_m, elevation_gain_m, slope_percent, cotacol, started_at, ended_at
		FROM ride_official_climb_attempts`)
	if err != nil {
		return nil, fmt.Errorf("list climb attempts: %w", err)
	}
	defer attemptRows.Close()
	samples := make(map[int64]*attemptSamples)
	for attemptRows.Next() {
		var (
			climbID   int64
			distanceM float64
			gainM     float64
			slope     float64
			cotacol   float64
			startedAt string
			endedAt   string
		)
		if err := attemptRows.Scan(&climbID, &distanceM, &gainM, &slope, &cotacol, &startedAt, &endedAt); err != nil {
			return nil, err
		}
		sample, known := samples[climbID]
		if !known {
			sample = &attemptSamples{}
			samples[climbID] = sample
		}
		sample.distances = append(sample.distances, distanceM)
		sample.gains = append(sample.gains, gainM)
		sample.slopes = append(sample.slopes, slope)
		sample.cotacols = append(sample.cotacols, cotacol)
		start, err := time.Parse(time.RFC3339, startedAt)
		if err != nil {
			return nil, fmt.Errorf("invalid ride_official_climb_attempts.started_at: %w", err)
		}
		end, err := time.Parse(time.RFC3339, endedAt)
		if err != nil {
			return nil, fmt.Errorf("invalid ride_official_climb_attempts.ended_at: %w", err)
		}
		durationS := end.Sub(start).Seconds()
		if durationS > 0 && (sample.bestDurationS == 0 || durationS < sample.bestDurationS) {
			sample.bestDurationS = durationS
		}
	}
	if err := attemptRows.Err(); err != nil {
		return nil, fmt.Errorf("iterate climb attempts: %w", err)
	}

	for id, sample := range samples {
		row := &climbs[index[id]]
		row.Attempts = len(sample.distances)
		row.DistanceM = Median(sample.distances)
		row.ElevationGainM = Median(sample.gains)
		row.SlopePercent = Median(sample.slopes)
		row.Cotacol = Median(sample.cotacols)
		if sample.bestDurationS > 0 {
			row.BestDurationS = sample.bestDurationS
			row.HasBest = true
		}
	}
	return climbs, nil
}

type attemptSamples struct {
	distances     []float64
	gains         []float64
	slopes        []float64
	cotacols      []float64
	bestDurationS float64
}