aboutsummaryrefslogtreecommitdiff
path: root/official_climb/attempt.go
blob: 43778f05bcba1fd2cc876ffd2d0b2151c14f4b87 (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
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

package official_climb

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

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

// Attempt is one recorded passage of an official climb during one ride,
// identified by (rideID, climbID, occurrence). Scalar stats are snapshotted at
// recording time so climb listings never re-parse ride files.
type Attempt struct {
	RideID             int64
	ClimbID            int64
	Occurrence         int
	DistanceM          float64
	ElevationGainM     float64
	SlopePercent       float64
	Cotacol            float64
	CotacolAlgoVersion string
	StartedAt          time.Time
	EndedAt            time.Time
	CreatedAt          time.Time
}

const attemptColumns = "a.ride_id, a.climb_id, a.occurrence, a.distance_m, a.elevation_gain_m, a.slope_percent, a.cotacol, a.cotacol_algo_version, a.started_at, a.ended_at, a.created_at"

// AttemptsForClimb returns every recorded attempt of one official climb,
// oldest ride first.
func AttemptsForClimb(db *sql.DB, climbID int64) ([]Attempt, error) {
	return listAttempts(db, "climb_id = ?", climbID, "r.start_date, a.ride_id, a.occurrence")
}

// AttemptsForRide returns every recorded attempt within one ride, in route
// order.
func AttemptsForRide(db *sql.DB, rideID int64) ([]Attempt, error) {
	return listAttempts(db, "ride_id = ?", rideID, "a.occurrence")
}

func listAttempts(db *sql.DB, where string, arg any, order string) ([]Attempt, error) {
	rows, err := db.Query(`
		SELECT `+attemptColumns+`
		FROM ride_official_climb_attempts a
		JOIN rides r ON r.id = a.ride_id
		WHERE `+where+`
		ORDER BY `+order, arg)
	if err != nil {
		return nil, fmt.Errorf("list official climb attempts: %w", err)
	}
	defer rows.Close()
	attempts := make([]Attempt, 0)
	for rows.Next() {
		attempt, err := scanAttempt(rows)
		if err != nil {
			return nil, err
		}
		attempts = append(attempts, attempt)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate official climb attempts: %w", err)
	}
	return attempts, nil
}

// ReplaceRideAttempts atomically rewrites every attempt row of one ride,
// dropping stale matches and inserting the freshly analyzed ones.
func ReplaceRideAttempts(db *sql.DB, rideID int64, attempts []Attempt) error {
	tx, err := db.Begin()
	if err != nil {
		return fmt.Errorf("replace ride attempts: %w", err)
	}
	defer func() { _ = tx.Rollback() }()
	if _, err := tx.Exec("DELETE FROM ride_official_climb_attempts WHERE ride_id = ?", rideID); err != nil {
		return fmt.Errorf("delete ride attempts: %w", err)
	}
	for _, attempt := range attempts {
		if attempt.RideID != rideID {
			return fmt.Errorf("replace ride attempts: attempt references ride %d, expected %d", attempt.RideID, rideID)
		}
		if _, err := tx.Exec(`
			INSERT INTO ride_official_climb_attempts (ride_id, climb_id, occurrence, distance_m, elevation_gain_m, slope_percent, cotacol, cotacol_algo_version, started_at, ended_at)
			VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		`, attempt.RideID, attempt.ClimbID, attempt.Occurrence, attempt.DistanceM, attempt.ElevationGainM, attempt.SlopePercent, attempt.Cotacol, attempt.CotacolAlgoVersion,
			attempt.StartedAt.UTC().Format(time.RFC3339), attempt.EndedAt.UTC().Format(time.RFC3339)); err != nil {
			return fmt.Errorf("insert ride attempt: %w", err)
		}
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit ride attempts: %w", err)
	}
	return nil
}

type attemptScanner interface {
	Scan(dest ...any) error
}

func scanAttempt(s attemptScanner) (Attempt, error) {
	var (
		attempt            Attempt
		startedAt, endedAt string
		createdAt          sql.NullString
	)
	if err := s.Scan(&attempt.RideID, &attempt.ClimbID, &attempt.Occurrence, &attempt.DistanceM, &attempt.ElevationGainM, &attempt.SlopePercent, &attempt.Cotacol, &attempt.CotacolAlgoVersion, &startedAt, &endedAt, &createdAt); err != nil {
		return Attempt{}, err
	}
	var err error
	if attempt.StartedAt, err = time.Parse(time.RFC3339, startedAt); err != nil {
		return Attempt{}, fmt.Errorf("invalid ride_official_climb_attempts.started_at: %w", err)
	}
	if attempt.EndedAt, err = time.Parse(time.RFC3339, endedAt); err != nil {
		return Attempt{}, fmt.Errorf("invalid ride_official_climb_attempts.ended_at: %w", err)
	}
	if createdAt.Valid {
		if attempt.CreatedAt, err = time.Parse(time.RFC3339, createdAt.String); err != nil {
			return Attempt{}, fmt.Errorf("invalid ride_official_climb_attempts.created_at: %w", err)
		}
	}
	return attempt, nil
}

// AttemptStats carries the analyzed values snapshotted into an attempt row.
type AttemptStats struct {
	OfficialClimbID int64
	DistanceM       float64
	ElevationGainM  float64
	SlopePercent    float64
	Cotacol         float64
	StartIndex      int
	EndIndex        int
}

// AttemptFromStats builds the persisted snapshot of one analyzed climb
// occurrence. Timestamps come from the ride's recorded points bounding the
// climb interval.
func AttemptFromStats(rideID int64, occurrence int, stats AttemptStats, timestamps []time.Time) Attempt {
	startedAt, endedAt := time.Time{}, time.Time{}
	if stats.StartIndex < len(timestamps) {
		startedAt = timestamps[stats.StartIndex]
	}
	if stats.EndIndex < len(timestamps) {
		endedAt = timestamps[stats.EndIndex]
	}
	return Attempt{
		RideID:             rideID,
		ClimbID:            stats.OfficialClimbID,
		Occurrence:         occurrence,
		DistanceM:          stats.DistanceM,
		ElevationGainM:     stats.ElevationGainM,
		SlopePercent:       stats.SlopePercent,
		Cotacol:            stats.Cotacol,
		CotacolAlgoVersion: climb.CotacolAlgorithmVersion,
		StartedAt:          startedAt,
		EndedAt:            endedAt,
	}
}