diff --git a/CHANGELOG.md b/CHANGELOG.md index ed68882d..5cc093cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * [#103](https://github.com/dblock/strava-ruby-client/pull/103): Migrate Danger to use the `danger-pr-comment` reusable workflow - [@dblock](https://github.com/dblock), [@Copilot](https://github.com/apps/copilot-swe-agent). * [#104](https://github.com/dblock/strava-ruby-client/pull/104): Fixes `Danger Comment` workflow failing with a `contents: none` permissions error - [@dblock](https://github.com/dblock). * [#102](https://github.com/dblock/strava-ruby-client/pull/102): Changes default API base URL to `https://www.api-v3.strava.com` and adds `revoke`, deprecating `deauthorize`, per Strava's June 2026/2027 developer program changes - [@dblock](https://github.com/dblock). +* [#105](https://github.com/dblock/strava-ruby-client/pull/105): Adds `total_elevation_gain`, `total_elevation_loss` and formatted helpers to `Strava::Models::Stream`, computed from altitude stream data - [@dblock](https://github.com/dblock). * Your contribution here. ### 3.0.0 (2025/10/24) diff --git a/README.md b/README.md index 4f6b9c62..33ed032c 100644 --- a/README.md +++ b/README.md @@ -684,6 +684,17 @@ streams.latlng # => Strava::Models::Stream streams.altitude # => Strava::Models::Stream ``` +The Strava API doesn't return a total elevation loss (descent) property for streams, but it can be computed from an altitude stream's data. + +```ruby +streams = client.activity_streams(1946417534, keys: %w[altitude]) # => Strava::Models::StreamSet + +streams.altitude.total_elevation_gain # => 725.3 +streams.altitude.total_elevation_loss # => 680.1 +streams.altitude.total_elevation_gain_s # => "725.3m" +streams.altitude.total_elevation_loss_s # => "680.1m" +``` + #### Get Activity Streams Returns the given activity's streams. diff --git a/lib/strava/models/stream.rb b/lib/strava/models/stream.rb index ce39f7bd..87c99103 100644 --- a/lib/strava/models/stream.rb +++ b/lib/strava/models/stream.rb @@ -26,6 +26,11 @@ module Models # puts "#{lat}, #{lng}" # end # + # @example Computing elevation gain and loss from an altitude stream + # streams = client.activity_streams(1234567890, keys: 'altitude') + # puts streams.altitude.total_elevation_gain_s # => "725.3m" + # puts streams.altitude.total_elevation_loss_s # => "680.1m" + # class Stream < Strava::Models::Response # @return [Integer] Total number of data points in the original stream before any resampling property 'original_size' @@ -49,6 +54,66 @@ class Stream < Strava::Models::Response # - moving: Array of booleans (true if moving, false if stopped) # - grade_smooth: Array of floats (grade as percentage) property 'data' + + # + # Returns the total elevation gain, calculated as the sum of all positive + # elevation increases between consecutive data points. + # + # This is only meaningful when the stream represents altitude data. + # + # @return [Float, nil] Total elevation gain in meters, or nil if data is not present + # + # @example + # streams = client.activity_streams(1234567890, keys: 'altitude') + # puts streams.altitude.total_elevation_gain # => 725.3 + # + def total_elevation_gain + return if data.nil? + + data.each_cons(2).sum { |a, b| b > a ? b - a : 0 } + end + + # + # Returns the total elevation loss, calculated as the sum of all negative + # elevation decreases between consecutive data points. + # + # This is only meaningful when the stream represents altitude data. + # + # @return [Float, nil] Total elevation loss in meters, or nil if data is not present + # + # @example + # streams = client.activity_streams(1234567890, keys: 'altitude') + # puts streams.altitude.total_elevation_loss # => 680.1 + # + def total_elevation_loss + return if data.nil? + + data.each_cons(2).sum { |a, b| a > b ? a - b : 0 } + end + + # + # Returns formatted total elevation gain in meters. + # + # @return [String, nil] Formatted elevation (e.g., "725.3m") + # + def total_elevation_gain_s + gain = total_elevation_gain + return if gain.nil? + + format('%gm', format('%.1f', gain)) + end + + # + # Returns formatted total elevation loss in meters. + # + # @return [String, nil] Formatted elevation (e.g., "680.1m") + # + def total_elevation_loss_s + loss = total_elevation_loss + return if loss.nil? + + format('%gm', format('%.1f', loss)) + end end end end diff --git a/spec/strava/api/client/endpoints/streams/segment_streams_spec.rb b/spec/strava/api/client/endpoints/streams/segment_streams_spec.rb index cee02049..6db091bb 100644 --- a/spec/strava/api/client/endpoints/streams/segment_streams_spec.rb +++ b/spec/strava/api/client/endpoints/streams/segment_streams_spec.rb @@ -32,6 +32,10 @@ expect(streams.distance).to be_a Strava::Models::Stream expect(streams.latlng).to be_a Strava::Models::Stream expect(streams.altitude).to be_a Strava::Models::Stream + expect(streams.altitude.total_elevation_gain).to eq 0 + expect(streams.altitude.total_elevation_loss).to eq 3.3 + expect(streams.altitude.total_elevation_gain_s).to eq '0m' + expect(streams.altitude.total_elevation_loss_s).to eq '3.3m' end it 'returns multiple keys by id', vcr: { cassette_name: 'client/segment_streams_keys' } do @@ -46,5 +50,7 @@ expect(streams.distance).to be_a Strava::Models::Stream expect(streams.latlng).to be_a Strava::Models::Stream expect(streams.altitude).to be_a Strava::Models::Stream + expect(streams.altitude.total_elevation_gain).to eq 0 + expect(streams.altitude.total_elevation_loss).to eq 3.3 end end diff --git a/spec/strava/models/stream_spec.rb b/spec/strava/models/stream_spec.rb new file mode 100644 index 00000000..e0e8ccff --- /dev/null +++ b/spec/strava/models/stream_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Strava::Models::Stream do + describe '#total_elevation_gain' do + it 'sums positive elevation changes' do + stream = described_class.new('data' => [100, 105, 102, 110, 108]) + expect(stream.total_elevation_gain).to eq 13 + end + + it 'returns 0 when there is no elevation gain' do + stream = described_class.new('data' => [100, 90, 80]) + expect(stream.total_elevation_gain).to eq 0 + end + + it 'returns nil when data is nil' do + stream = described_class.new(nil) + expect(stream.total_elevation_gain).to be_nil + end + end + + describe '#total_elevation_loss' do + it 'sums negative elevation changes' do + stream = described_class.new('data' => [100, 105, 102, 110, 108]) + expect(stream.total_elevation_loss).to eq 5 + end + + it 'returns 0 when there is no elevation loss' do + stream = described_class.new('data' => [80, 90, 100]) + expect(stream.total_elevation_loss).to eq 0 + end + + it 'returns nil when data is nil' do + stream = described_class.new(nil) + expect(stream.total_elevation_loss).to be_nil + end + end + + describe '#total_elevation_gain_s' do + it 'returns formatted elevation gain' do + stream = described_class.new('data' => [100.0, 105.3, 102.1, 110.7, 108.2]) + expect(stream.total_elevation_gain_s).to eq '13.9m' + end + + it 'returns nil when data is nil' do + stream = described_class.new(nil) + expect(stream.total_elevation_gain_s).to be_nil + end + end + + describe '#total_elevation_loss_s' do + it 'returns formatted elevation loss' do + stream = described_class.new('data' => [100.0, 105.3, 102.1, 110.7, 108.2]) + expect(stream.total_elevation_loss_s).to eq '5.7m' + end + + it 'returns nil when data is nil' do + stream = described_class.new(nil) + expect(stream.total_elevation_loss_s).to be_nil + end + end +end