calcpace 1.12.1 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 696b589bb12da943fd5743ca9e89cb5e7dcb58b44556da987fa980df5b66fcfb
4
- data.tar.gz: 61bef5c083477c847359ec7241f825435b6f95d3cbbbe6549f1b081b0988192b
3
+ metadata.gz: c3fb2c004f6564fef1686e1c92db831e7c54a47360dd9c540a90bb334582ee95
4
+ data.tar.gz: 31c20dd413fe2df72d57f7516bb613c7661724bba196ff81da3af3b2c17c0ee0
5
5
  SHA512:
6
- metadata.gz: '038847f970200daddced3523e1300266be054517a057ceaec9815ad68356553ce3f75e356a39eccad98cfa6a5e189950197912f8669e23478908dc58d448cb80'
7
- data.tar.gz: 4cfa40790a572b649bfd7b87ca6d7d9f898672bc109b40209f207461e1e7f3cab7bff2cc45fdd92446bab87e32b997f678b043b4d259471facc8ea3daa28ecf6
6
+ metadata.gz: 784831b687993538654becf398906fc798ff089e8344ed1cd8768f3b42735111b490f99f185b0535f09d84ad15b0d207703e49d494c6e61ca1c3311630016b9a
7
+ data.tar.gz: 3f4170b7d5a3c4f71ed40ab732fb02be5ea2f1daf494c179aedab9b63e1837b90631ebf8db8f1484e83fbd9b55b569b7c9db31f66d915ca3228dae745eb7d619
data/CHANGELOG.md CHANGED
@@ -7,6 +7,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.14.0] - 2026-08-29
11
+
12
+ ### Added
13
+ - `compact:` keyword on every pace-producing method, so a caller can ask for the
14
+ display format `convert_to_clocktime(compact: true)` introduced in 1.13.0
15
+ without reformatting the string itself
16
+ - `convert_pace(pace, conversion, compact: false)`
17
+ - `pace_km_to_mi(pace_per_km, compact: false)`
18
+ - `pace_mi_to_km(pace_per_mi, compact: false)`
19
+ - `track_splits(points, split_km = 1.0, compact: false)` — only the `:pace`
20
+ value changes; `:km` and `:elapsed` are numbers and stay as they are
21
+
22
+ ```ruby
23
+ calc.pace_km_to_mi('05:00') # => "00:08:02"
24
+ calc.pace_km_to_mi('05:00', compact: true) # => "8:02"
25
+ calc.track_splits(points, 1.0, compact: true)
26
+ # => [{ km: 1.0, elapsed: 312, pace: "5:12" }, ...]
27
+ ```
28
+
29
+ The default stays `compact: false` everywhere, byte-for-byte the previous
30
+ output — the one exception is the negative-split fix below, which corrects a
31
+ value that was arithmetically wrong. Input validation is untouched:
32
+ a zero or negative pace still raises `Calcpace::NonPositiveInputError` and an
33
+ unknown conversion still raises `ArgumentError` in both modes.
34
+
35
+ Three places the two formats disagree about more than padding, all of them
36
+ now reachable through the pace APIs:
37
+
38
+ - A split pace slower than an hour per unit: the padded format keeps counting
39
+ minutes (`"66:33"`), as `track_splits` always has, while the compact one
40
+ rolls them into an hour field (`"1:06:33"`), consistent with every other
41
+ compact duration in the gem. Past 24 hours per unit the gap widens —
42
+ `"2248:18"` padded against `"37:28:18"` compact.
43
+ - Durations past 24 hours, the day-prefix rule 1.13.0 documented for
44
+ `convert_to_clocktime` alone, now visible through `convert_pace` too:
45
+ `convert_pace(100_000, :km_to_mi)` #=> `"1 20:42:14"`, against
46
+ `"44:42:14"` compact.
47
+ - A negative split (see Fixed below), signed in both formats but padded to a
48
+ different width: `"-00:40"` against `"-0:40"`.
49
+
50
+ ### Fixed
51
+ - `track_splits` no longer misreports a negative split pace. A GPS track can
52
+ step backwards in time — a watch resyncing its clock, a device paused and
53
+ restarted, two segments merged out of order — which makes a split's elapsed
54
+ time negative. The padded format rendered that through Ruby's floor division,
55
+ so a −40 s split printed as `"-1:20"`; it now prints `"-00:40"`, and the
56
+ compact format prints `"-0:40"`. Neither mode raises: bad GPS data has always
57
+ been reported rather than blown up, and `compact: true` does not change that.
58
+ - `convert_pace`, `pace_km_to_mi` and `pace_mi_to_km` documented their return
59
+ value as `'08:02'` when they have always returned the padded `'00:08:02'`.
60
+ The docs now match the code; the code is unchanged.
61
+ - README and YARD examples for `track_distance`, `haversine_distance` and
62
+ `track_splits` printed numbers their own input never produced (`0.87` km for
63
+ a 1.51 km track, a `"05:12"` split for a `"06:55"` one). Every example is now
64
+ the real output of the code above it.
65
+
66
+ ## [1.13.0] - 2026-08-28
67
+
68
+ ### Added
69
+ - `convert_to_clocktime(seconds, compact: true)` — the display format a runner
70
+ reads, next to the padded format the gem already returned
71
+ - drops the hour when it is zero and the leading zero of the most significant
72
+ component, keeping two digits on everything after it:
73
+ `convert_to_clocktime(292, compact: true)` #=> `'4:52'`,
74
+ `convert_to_clocktime(45, compact: true)` #=> `'0:45'`,
75
+ `convert_to_clocktime(5025, compact: true)` #=> `'1:23:45'`
76
+ - past 24 hours it keeps counting hours (`100_000` #=> `'27:46:40'`) instead
77
+ of the padded format's day prefix (`'1 03:46:40'`) — a day count brings back
78
+ the padding and the extra unit the compact format exists to strip
79
+ - fractional seconds truncate, as they already did in the padded format:
80
+ `292.9` #=> `'4:52'`
81
+
82
+ The default stays `compact: false`, byte-for-byte the previous output, so every
83
+ existing caller is unaffected.
84
+
85
+ ### Fixed
86
+ - `convert_to_clocktime` with a negative number now raises
87
+ `Calcpace::NonPositiveInputError` instead of silently wrapping around
88
+ (`-5` used to return `'23:59:55'`, a `Time.at` artifact). Zero remains a valid
89
+ duration in both formats. Non-numeric input keeps raising as before.
90
+
10
91
  ## [1.12.1] - 2026-08-15
11
92
 
12
93
  ### Changed
@@ -300,7 +381,9 @@ predictors are untouched.
300
381
 
301
382
  See git history for changes in earlier versions.
302
383
 
303
- [Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.12.0...HEAD
384
+ [Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.14.0...HEAD
385
+ [1.14.0]: https://github.com/0jonjo/calcpace/compare/v1.13.0...v1.14.0
386
+ [1.13.0]: https://github.com/0jonjo/calcpace/compare/v1.12.1...v1.13.0
304
387
  [1.12.0]: https://github.com/0jonjo/calcpace/compare/v1.11.0...v1.12.0
305
388
  [1.11.0]: https://github.com/0jonjo/calcpace/compare/v1.10.0...v1.11.0
306
389
  [1.10.0]: https://github.com/0jonjo/calcpace/compare/v1.9.10...v1.10.0
data/README.md CHANGED
@@ -93,6 +93,16 @@ See all units: `calc.list_all`, `calc.list_distance`, `calc.list_speed`.
93
93
  ```ruby
94
94
  calc.pace_km_to_mi('05:00') # => "00:08:02"
95
95
  calc.pace_mi_to_km('08:00') # => "00:04:58"
96
+ calc.convert_pace(300, :km_to_mi) # => "00:08:02"
97
+ ```
98
+
99
+ All three take a `compact:` keyword for the display format a runner reads on a
100
+ screen, the same one `convert_to_clocktime` offers:
101
+
102
+ ```ruby
103
+ calc.pace_km_to_mi('05:00', compact: true) # => "8:02"
104
+ calc.pace_mi_to_km(480, compact: true) # => "4:58"
105
+ calc.convert_pace('05:00', :km_to_mi, compact: true) # => "8:02"
96
106
  ```
97
107
 
98
108
  ---
@@ -152,12 +162,33 @@ points = [
152
162
  { lat: -23.5520, lon: -46.6480, ele: 758.0, time: Time.parse('2024-01-01 07:10:00') },
153
163
  ]
154
164
 
155
- calc.haversine_distance(-23.5505, -46.6333, -23.5510, -46.6340) # => 0.089 km
156
- calc.track_distance(points) # => 0.87 km
157
- calc.elevation_gain(points) # => { gain: 5.0, loss: 7.0 }
158
- calc.track_splits(points, 1.0) # => [{ km: 1, elapsed: 312, pace: "05:12" }, ...]
165
+ calc.haversine_distance(-23.5505, -46.6333, -23.5510, -46.6340)
166
+ # => 0.09045636644035066 (km)
167
+
168
+ calc.track_distance(points) # => 1.51 (km)
169
+ calc.elevation_gain(points) # => { gain: 5.0, loss: 7.0 }
170
+
171
+ calc.track_splits(points, 1.0)
172
+ # => [{ km: 1.0, elapsed: 415, pace: "06:55" },
173
+ # { km: 1.51, elapsed: 600, pace: "06:04" }]
174
+
175
+ # Compact pace for display; :km and :elapsed are unchanged
176
+ calc.track_splits(points, 1.0, compact: true)
177
+ # => [{ km: 1.0, elapsed: 415, pace: "6:55" },
178
+ # { km: 1.51, elapsed: 600, pace: "6:04" }]
159
179
  ```
160
180
 
181
+ The last entry is the partial split — the leftover distance after the last full
182
+ one, so its `:km` is the track total rather than a multiple of `split_km`.
183
+
184
+ Two things to know about `compact:` here. A split slower than an hour per unit
185
+ is where the formats stop differing by padding alone: the padded one keeps
186
+ counting minutes (`"66:33"`), as `track_splits` always has, while the compact
187
+ one rolls them into an hour field (`"1:06:33"`). And a track that steps
188
+ backwards in time — a watch resyncing its clock, a paused device, two segments
189
+ merged out of order — produces a negative split, reported with a leading minus
190
+ in both formats (`"-00:40"` / `"-0:40"`) rather than raising.
191
+
161
192
  **Haversine formula** — great-circle distance on a sphere (R = 6,371 km). Accuracy: ~0.3% of GPS/WGS84. Best for running and cycling distances; not for geodetic surveying.
162
193
 
163
194
  ---
@@ -367,6 +398,25 @@ calc.convert_to_clocktime(3600) # => "01:00:00"
367
398
  calc.check_time('01:00:00') # => nil (valid)
368
399
  ```
369
400
 
401
+ `convert_to_clocktime` takes a `compact:` keyword for the format a runner reads
402
+ on a screen — no zero hour, no leading zero on the most significant component:
403
+
404
+ ```ruby
405
+ calc.convert_to_clocktime(292, compact: true) # => "4:52"
406
+ calc.convert_to_clocktime(45, compact: true) # => "0:45"
407
+ calc.convert_to_clocktime(5025, compact: true) # => "1:23:45"
408
+ calc.convert_to_clocktime(100_000, compact: true) # => "27:46:40"
409
+ ```
410
+
411
+ Past 24 hours the compact format keeps counting hours, where the padded one
412
+ prefixes a day count (`"1 03:46:40"`). Fractional seconds truncate in both.
413
+ A negative number of seconds raises `Calcpace::NonPositiveInputError`; zero is a
414
+ valid duration (`"00:00:00"` / `"0:00"`).
415
+
416
+ The same `compact:` keyword is accepted by `convert_pace`, `pace_km_to_mi`,
417
+ `pace_mi_to_km`, and `track_splits`. It always defaults to `false`, so every
418
+ call without it returns exactly what it returned before.
419
+
370
420
  ---
371
421
 
372
422
  ### Errors
@@ -99,13 +99,38 @@ module Converter
99
99
 
100
100
  # Converts seconds to a clocktime string
101
101
  #
102
- # @param seconds [Numeric] total seconds
103
- # @return [String] time in HH:MM:SS format, or "D HH:MM:SS" for durations over 24 hours
102
+ # The default (padded) format is the machine-readable one: always HH:MM:SS,
103
+ # with a day prefix past 24 hours. The compact format is the one a runner
104
+ # reads on a screen — it drops a zero hour and the leading zero of the most
105
+ # significant component, keeping two digits on everything after it.
104
106
  #
105
- # @example
107
+ # Fractional seconds are truncated, not rounded, in both formats, so a
108
+ # predictor returning 292.9 s prints the same 4:52 either way. Past 24 hours
109
+ # the compact format keeps counting hours ('27:46:40') instead of adding the
110
+ # padded format's day prefix: a day count reintroduces the very padding and
111
+ # the extra unit the compact format exists to strip, and ultra finish times
112
+ # are read as a running hour count.
113
+ #
114
+ # @param seconds [Numeric] total seconds, zero or more
115
+ # @param compact [Boolean] when true, return the compact display format
116
+ # @return [String] time in HH:MM:SS format, or "D HH:MM:SS" for durations over
117
+ # 24 hours; with compact: true, "M:SS" or "H:MM:SS"
118
+ # @raise [Calcpace::NonPositiveInputError] if seconds is negative
119
+ #
120
+ # @example padded (default)
106
121
  # convert_to_clocktime(3600) #=> '01:00:00' (1 hour)
122
+ # convert_to_clocktime(292) #=> '00:04:52'
107
123
  # convert_to_clocktime(100000) #=> '1 03:46:40' (1 day, 3 hours, 46 minutes, 40 seconds)
108
- def convert_to_clocktime(seconds)
124
+ #
125
+ # @example compact
126
+ # convert_to_clocktime(45, compact: true) #=> '0:45'
127
+ # convert_to_clocktime(292, compact: true) #=> '4:52'
128
+ # convert_to_clocktime(5025, compact: true) #=> '1:23:45'
129
+ # convert_to_clocktime(100000, compact: true) #=> '27:46:40'
130
+ def convert_to_clocktime(seconds, compact: false)
131
+ check_not_negative(seconds)
132
+ return compact_clocktime(seconds) if compact
133
+
109
134
  days = (seconds / 86_400).to_i
110
135
  format = days.positive? ? "#{days} %H:%M:%S" : '%H:%M:%S'
111
136
  Time.at(seconds).utc.strftime(format)
@@ -149,6 +174,28 @@ module Converter
149
174
 
150
175
  private
151
176
 
177
+ # Formats a duration without padding the most significant component, dropping
178
+ # the hour when there is none. Hours accumulate past 24 rather than rolling
179
+ # over into a day count.
180
+ def compact_clocktime(seconds)
181
+ total = seconds.to_i
182
+ parts = { hours: total / 3600, minutes: (total % 3600) / 60, seconds: total % 60 }
183
+ return format('%<hours>d:%<minutes>02d:%<seconds>02d', parts) if parts[:hours].positive?
184
+
185
+ format('%<minutes>d:%<seconds>02d', parts)
186
+ end
187
+
188
+ # Guards against negative durations. Unlike Checker#check_positive, zero is a
189
+ # legitimate duration here — a zero split prints as 00:00:00 — so only a
190
+ # negative value is rejected. Non-numeric input is left to raise on its own,
191
+ # as it always has.
192
+ def check_not_negative(seconds)
193
+ return unless seconds.is_a?(Numeric) && seconds.negative?
194
+
195
+ raise Calcpace::NonPositiveInputError,
196
+ 'Seconds must not be a negative number'
197
+ end
198
+
152
199
  # Guards the "race name + distance_unit" combination. A standard race already
153
200
  # carries its own distance, so the keyword can only be a caller mistake —
154
201
  # better to say so than to ignore it silently.
@@ -7,48 +7,61 @@
7
7
  module PaceConverter
8
8
  # Converts pace from one unit to another
9
9
  #
10
+ # The pace comes back in the same two formats #convert_to_clocktime offers:
11
+ # the padded HH:MM:SS by default, and the compact display format a runner
12
+ # reads on a screen with <tt>compact: true</tt>.
13
+ #
10
14
  # @param pace [Numeric, String] pace in seconds per unit or time string (MM:SS)
11
15
  # @param conversion [Symbol, String] conversion type (:km_to_mi, :mi_to_km, 'km to mi', 'mi to km')
12
- # @return [String] converted pace in MM:SS format
16
+ # @param compact [Boolean] when true, return the compact display format
17
+ # @return [String] converted pace in HH:MM:SS format, or 'M:SS' / 'H:MM:SS' with compact: true
13
18
  # @raise [ArgumentError] if conversion type is not supported
14
19
  # @raise [Calcpace::NonPositiveInputError] if pace is not positive
15
20
  #
16
- # @example
17
- # convert_pace('05:00', :km_to_mi) #=> '08:02' (5:00/km = 8:02/mi)
18
- # convert_pace('08:00', :mi_to_km) #=> '04:58' (8:00/mi ≈ 4:58/km)
19
- # convert_pace(300, 'km to mi') #=> '08:02' (300s/km = 482s/mi)
20
- def convert_pace(pace, conversion)
21
+ # @example padded (default)
22
+ # convert_pace('05:00', :km_to_mi) #=> '00:08:02' (5:00/km = 8:02/mi)
23
+ # convert_pace('08:00', :mi_to_km) #=> '00:04:58' (8:00/mi ≈ 4:58/km)
24
+ # convert_pace(300, 'km to mi') #=> '00:08:02' (300s/km = 482s/mi)
25
+ #
26
+ # @example compact
27
+ # convert_pace('05:00', :km_to_mi, compact: true) #=> '8:02'
28
+ # convert_pace(300, 'km to mi', compact: true) #=> '8:02'
29
+ def convert_pace(pace, conversion, compact: false)
21
30
  pace_seconds = pace.is_a?(String) ? convert_to_seconds(pace) : pace
22
31
  check_positive(pace_seconds, 'Pace')
23
32
 
24
33
  conversion_type = normalize_conversion(conversion)
25
34
  converted_seconds = apply_pace_conversion(pace_seconds, conversion_type)
26
35
 
27
- convert_to_clocktime(converted_seconds)
36
+ convert_to_clocktime(converted_seconds, compact: compact)
28
37
  end
29
38
 
30
39
  # Converts pace from kilometers to miles
31
40
  #
32
41
  # @param pace_per_km [Numeric, String] pace in seconds per km or time string (MM:SS)
33
- # @return [String] pace per mile in MM:SS format
42
+ # @param compact [Boolean] when true, return the compact display format
43
+ # @return [String] pace per mile in HH:MM:SS format, or 'M:SS' / 'H:MM:SS' with compact: true
34
44
  #
35
45
  # @example
36
- # pace_km_to_mi('05:00') #=> '08:02' (5:00/km = 8:02/mi)
37
- # pace_km_to_mi(300) #=> '08:02' (300s/km = 482s/mi)
38
- def pace_km_to_mi(pace_per_km)
39
- convert_pace(pace_per_km, :km_to_mi)
46
+ # pace_km_to_mi('05:00') #=> '00:08:02' (5:00/km = 8:02/mi)
47
+ # pace_km_to_mi(300) #=> '00:08:02' (300s/km = 482s/mi)
48
+ # pace_km_to_mi('05:00', compact: true) #=> '8:02'
49
+ def pace_km_to_mi(pace_per_km, compact: false)
50
+ convert_pace(pace_per_km, :km_to_mi, compact: compact)
40
51
  end
41
52
 
42
53
  # Converts pace from miles to kilometers
43
54
  #
44
55
  # @param pace_per_mi [Numeric, String] pace in seconds per mile or time string (MM:SS)
45
- # @return [String] pace per kilometer in MM:SS format
56
+ # @param compact [Boolean] when true, return the compact display format
57
+ # @return [String] pace per kilometer in HH:MM:SS format, or 'M:SS' / 'H:MM:SS' with compact: true
46
58
  #
47
59
  # @example
48
- # pace_mi_to_km('08:00') #=> '04:58' (8:00/mi ≈ 4:58/km)
49
- # pace_mi_to_km(480) #=> '04:58' (480s/mi = 298s/km)
50
- def pace_mi_to_km(pace_per_mi)
51
- convert_pace(pace_per_mi, :mi_to_km)
60
+ # pace_mi_to_km('08:00') #=> '00:04:58' (8:00/mi ≈ 4:58/km)
61
+ # pace_mi_to_km(480) #=> '00:04:58' (480s/mi = 298s/km)
62
+ # pace_mi_to_km('08:00', compact: true) #=> '4:58'
63
+ def pace_mi_to_km(pace_per_mi, compact: false)
64
+ convert_pace(pace_per_mi, :mi_to_km, compact: compact)
52
65
  end
53
66
 
54
67
  private
@@ -14,7 +14,7 @@
14
14
  # { lat: -23.5510, lon: -46.6340 },
15
15
  # { lat: -23.5520, lon: -46.6350 }
16
16
  # ]
17
- # calc.track_distance(points) #=> 0.17 (km)
17
+ # calc.track_distance(points) #=> 0.24 (km)
18
18
  #
19
19
  # @example Calculate elevation gain and loss
20
20
  # points = [
@@ -49,7 +49,7 @@ module TrackCalculator
49
49
  #
50
50
  # @example Distance between two points in São Paulo
51
51
  # haversine_distance(-23.5505, -46.6333, -23.5510, -46.6340)
52
- # #=> 0.089 (km)
52
+ # #=> 0.09045636644035066 (km)
53
53
  def haversine_distance(lat1, lon1, lat2, lon2)
54
54
  validate_coordinates(lat1, lon1)
55
55
  validate_coordinates(lat2, lon2)
@@ -69,7 +69,7 @@ module TrackCalculator
69
69
  # { lat: -23.5510, lon: -46.6340 },
70
70
  # { lat: -23.5520, lon: -46.6350 }
71
71
  # ]
72
- # track_distance(points) #=> 0.17
72
+ # track_distance(points) #=> 0.24
73
73
  def track_distance(points)
74
74
  return 0.0 if points.nil? || points.size < 2
75
75
 
@@ -114,13 +114,26 @@ module TrackCalculator
114
114
  # split distance is reached, then records elapsed time and pace for that split.
115
115
  # Any remaining distance at the end is included as a partial split.
116
116
  #
117
+ # Only :pace changes with compact: — :km and :elapsed are numbers, not
118
+ # formatted strings, and are the same in both modes.
119
+ #
120
+ # The two pace formats differ by more than padding once a split is slower than
121
+ # an hour per unit: the padded format keeps counting minutes ('66:33'), as it
122
+ # always has, while the compact one rolls them into an hour field ('1:06:33'),
123
+ # like every other compact duration in the gem. A track that steps backwards
124
+ # in time (a watch clock resync, a paused device, merged segments) yields a
125
+ # negative split, reported with a leading minus in both formats ('-00:40' /
126
+ # '-0:40') rather than raising.
127
+ #
117
128
  # @param points [Array<Hash>] array of points with :lat, :lon, and :time keys.
118
129
  # :time must respond to #to_f (Unix timestamp) or be a Time object.
119
130
  # @param split_km [Numeric] split interval in kilometers (default: 1.0)
131
+ # @param compact [Boolean] when true, :pace uses the compact display format
120
132
  # @return [Array<Hash>] array of split hashes, each with:
121
133
  # - :km [Float] cumulative distance at split end
122
134
  # - :elapsed [Integer] elapsed seconds from start of track to end of split
123
- # - :pace [String] pace for this split in MM:SS format
135
+ # - :pace [String] pace for this split in MM:SS format, or 'M:SS' / 'H:MM:SS'
136
+ # with compact: true
124
137
  # @raise [ArgumentError] if split_km is not positive
125
138
  # @raise [ArgumentError] if any point is missing a :time key
126
139
  #
@@ -131,12 +144,16 @@ module TrackCalculator
131
144
  # { km: 2.0, elapsed: 624, pace: "05:12" },
132
145
  # ...
133
146
  # ]
134
- def track_splits(points, split_km = 1.0)
147
+ #
148
+ # @example compact pace
149
+ # calc.track_splits(points, 1.0, compact: true)
150
+ # #=> [{ km: 1.0, elapsed: 312, pace: "5:12" }, ...]
151
+ def track_splits(points, split_km = 1.0, compact: false)
135
152
  raise ArgumentError, 'split_km must be positive' unless split_km.is_a?(Numeric) && split_km.positive?
136
153
  return [] if points.nil? || points.size < 2
137
154
 
138
155
  validate_points_have_time(points)
139
- collect_splits(points, split_km)
156
+ collect_splits(points, split_km, compact: compact)
140
157
  end
141
158
 
142
159
  private
@@ -209,17 +226,37 @@ module TrackCalculator
209
226
  t_a + ((t_b - t_a) * (distance_into_segment / segment_km))
210
227
  end
211
228
 
212
- def seconds_to_pace(seconds, km)
213
- return '00:00' if km.zero?
214
-
229
+ # Formats a split pace, in either format, without ever raising.
230
+ #
231
+ # A GPS track can step backwards in time — a watch resyncing its clock, a
232
+ # device paused and restarted, two segments merged out of order — which makes
233
+ # a split elapsed time negative. That is bad data, not a caller error, and
234
+ # track_splits has always reported it rather than blowing up; #sign_of keeps
235
+ # it that way now that the compact format goes through #convert_to_clocktime,
236
+ # which rejects negative durations.
237
+ def seconds_to_pace(seconds, km, compact: false)
215
238
  pace_seconds = (seconds.to_f / km).round
239
+ "#{sign_of(pace_seconds)}#{format_pace(pace_seconds.abs, compact: compact)}"
240
+ end
241
+
242
+ def sign_of(pace_seconds)
243
+ pace_seconds.negative? ? '-' : ''
244
+ end
245
+
246
+ # The padded format keeps the historical MM:SS, where the minutes keep
247
+ # counting past 60 (a 66-minute hiking split reads '66:33'); the compact
248
+ # format defers to #convert_to_clocktime, which rolls those minutes into an
249
+ # hour field ('1:06:33') exactly as it does everywhere else.
250
+ def format_pace(pace_seconds, compact:)
251
+ return convert_to_clocktime(pace_seconds, compact: true) if compact
252
+
216
253
  format('%<min>02d:%<sec>02d', min: pace_seconds / 60, sec: pace_seconds % 60)
217
254
  end
218
255
 
219
- def collect_splits(points, split_km)
256
+ def collect_splits(points, split_km, compact:)
220
257
  state = { splits: [], start_time: point_time(points.first),
221
258
  split_start_time: point_time(points.first),
222
- accumulated_km: 0.0, split_number: 1 }
259
+ accumulated_km: 0.0, split_number: 1, compact: compact }
223
260
 
224
261
  points.each_cons(2) { |a, b| process_segment(a, b, split_km, state) }
225
262
  append_partial_split(points.last, split_km, state)
@@ -249,7 +286,7 @@ module TrackCalculator
249
286
  {
250
287
  km: (split_km * state[:split_number]).round(2),
251
288
  elapsed: (boundary_time - state[:start_time]).round,
252
- pace: seconds_to_pace(split_elapsed, split_km)
289
+ pace: seconds_to_pace(split_elapsed, split_km, compact: state[:compact])
253
290
  }
254
291
  end
255
292
 
@@ -261,7 +298,8 @@ module TrackCalculator
261
298
  state[:splits] << {
262
299
  km: state[:accumulated_km].round(2),
263
300
  elapsed: (last_time - state[:start_time]).round,
264
- pace: seconds_to_pace((last_time - state[:split_start_time]).round, remaining_km)
301
+ pace: seconds_to_pace((last_time - state[:split_start_time]).round, remaining_km,
302
+ compact: state[:compact])
265
303
  }
266
304
  end
267
305
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Calcpace
4
- VERSION = '1.12.1'
4
+ VERSION = '1.14.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: calcpace
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.12.1
4
+ version: 1.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - João Gilberto Saraiva