postrunner 0.0.6 → 0.0.7

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 5c193dbf151045522cfe8f47b729d244eb87f957
4
- data.tar.gz: fb92f5b286258cac2c17e96dae821d858face419
3
+ metadata.gz: dd23d401a204deb3a2bdb839097e78c7eafc9b17
4
+ data.tar.gz: a97ce94a450b07cb1744d6b65ee42f463144ae2a
5
5
  SHA512:
6
- metadata.gz: 2a01b57478175e684b1253e30174e46ee5b3119dd8a2941a8cf6141255e7955c6628fff6a20167c3421feaee0ca7c857370ae8c05a4d7ae114fb6730df909953
7
- data.tar.gz: b3f8c2ad6fa0effd9958a4102b6280e373c5bf11a3c90982bfdaaffaeb14d90f03fff37460963d51e6600cd9432f36b11338a86e3b5eb19536252fcd37e4c048
6
+ metadata.gz: beb4337b53ade2b2ff109f95f59a1d4bc3f21c1d1abc7f97101f046e45db78cf3d3bcc677a88b6ce1d14373a582397540ac8c0192a429d3056bf578e820e55ac
7
+ data.tar.gz: 32b1f9ef0128568d5d0e909c205477e955fcedca800f12d47406e499366eb4e26bdabae8b9811ce5d66a1197a7b9d0a28da919522be787ecd31fd7bb4b8ad6f3
@@ -3,7 +3,7 @@
3
3
  #
4
4
  # = ActivitiesDB.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
5
  #
6
- # Copyright (c) 2014 by Chris Schlaeger <cs@taskjuggler.org>
6
+ # Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org>
7
7
  #
8
8
  # This program is free software; you can redistribute it and/or modify
9
9
  # it under the terms of version 2 of the GNU General Public License as
@@ -14,22 +14,24 @@ require 'fileutils'
14
14
  require 'yaml'
15
15
 
16
16
  require 'fit4ruby'
17
+ require 'postrunner/BackedUpFile'
17
18
  require 'postrunner/Activity'
18
19
  require 'postrunner/PersonalRecords'
19
20
  require 'postrunner/ActivityListView'
21
+ require 'postrunner/ViewButtons'
20
22
 
21
23
  module PostRunner
22
24
 
23
25
  class ActivitiesDB
24
26
 
25
- attr_reader :db_dir, :cfg, :fit_dir, :html_dir, :activities
27
+ attr_reader :db_dir, :cfg, :fit_dir, :activities, :records, :views
26
28
 
27
29
  def initialize(db_dir, cfg)
28
30
  @db_dir = db_dir
29
31
  @cfg = cfg
30
32
  @fit_dir = File.join(@db_dir, 'fit')
31
- @html_dir = File.join(@db_dir, 'html')
32
33
  @archive_file = File.join(@db_dir, 'archive.yml')
34
+ @auxilliary_dirs = %w( icons jquery flot openlayers )
33
35
 
34
36
  create_directories
35
37
  begin
@@ -38,7 +40,7 @@ module PostRunner
38
40
  else
39
41
  @activities = []
40
42
  end
41
- rescue StandardError
43
+ rescue RuntimeError
42
44
  Log.fatal "Cannot load archive file '#{@archive_file}': #{$!}"
43
45
  end
44
46
 
@@ -58,10 +60,31 @@ module PostRunner
58
60
  sync_needed |= !a.fit_activity.nil?
59
61
  end
60
62
 
63
+ # Define which View objects the HTML output will contain off. This
64
+ # doesn't really belong in ActivitiesDB but for now it's the best place
65
+ # to put it.
66
+ @views = ViewButtons.new([
67
+ NavButtonDef.new('activities.png', 'index.html'),
68
+ NavButtonDef.new('record.png', "records-0.html")
69
+ ])
70
+
61
71
  @records = PersonalRecords.new(self)
62
72
  sync if sync_needed
63
73
  end
64
74
 
75
+ # Ensure that all necessary directories are present to store the output
76
+ # files. This method is idempotent and can be called even when directories
77
+ # exist already.
78
+ def create_directories
79
+ create_directory(@db_dir, 'data')
80
+ create_directory(@fit_dir, 'fit')
81
+ create_directory(@cfg[:html_dir], 'html')
82
+
83
+ @auxilliary_dirs.each do |dir|
84
+ create_auxdir(dir)
85
+ end
86
+ end
87
+
65
88
  # Add a new FIT file to the database.
66
89
  # @param fit_file [String] Name of the FIT file.
67
90
  # @return [TrueClass or FalseClass] True if the file could be added. False
@@ -84,6 +107,10 @@ module PostRunner
84
107
  Log.error $!
85
108
  return false
86
109
  end
110
+ unless fit_activity
111
+ Log.error "#{fit_file} does not contain any activity records"
112
+ return false
113
+ end
87
114
 
88
115
  begin
89
116
  FileUtils.cp(fit_file, @fit_dir)
@@ -97,7 +124,10 @@ module PostRunner
97
124
  a2.timestamp <=> a1.timestamp
98
125
  end
99
126
 
100
- activity.register_records(@records)
127
+ activity.register_records
128
+
129
+ # Generate HTML file for this activity.
130
+ activity.generate_html_view
101
131
 
102
132
  # The HTML activity views contain links to their predecessors and
103
133
  # successors. After inserting a new activity, we need to re-generate
@@ -116,10 +146,11 @@ module PostRunner
116
146
  end
117
147
 
118
148
  def delete(activity)
119
- pred = predecessor(activities)
120
- succ = successor(activities)
149
+ pred = predecessor(activity)
150
+ succ = successor(activity)
121
151
 
122
152
  @activities.delete(activity)
153
+ @records.delete_activity(activity)
123
154
 
124
155
  # The HTML activity views contain links to their predecessors and
125
156
  # successors. After deleting an activity, we need to re-generate these
@@ -135,8 +166,19 @@ module PostRunner
135
166
  sync
136
167
  end
137
168
 
169
+ def set(activity, attribute, value)
170
+ activity.set(attribute, value)
171
+ sync
172
+ end
173
+
138
174
  def check
139
- @activities.each { |a| a.check }
175
+ @records.delete_all_records
176
+ @activities.sort do |a1, a2|
177
+ a1.timestamp <=> a2.timestamp
178
+ end.each { |a| a.check }
179
+ @records.sync
180
+ # Ensure that HTML index is up-to-date.
181
+ ActivityListView.new(self).update_index_pages
140
182
  end
141
183
 
142
184
  def ref_by_fit_file(fit_file)
@@ -189,11 +231,12 @@ module PostRunner
189
231
  @activities[idx - 1]
190
232
  end
191
233
 
192
- # Return the previous Activity before the provided activity. Note that
193
- # this has a higher index. If none is found, return nil.
234
+ # Return the previous Activity before the provided activity.
235
+ # If none is found, return nil.
194
236
  def predecessor(activity)
195
237
  idx = @activities.index(activity)
196
- return nil if idx.nil? || idx >= @activities.length - 2
238
+ return nil if idx.nil?
239
+ # Activities indexes are reversed. The predecessor has a higher index.
197
240
  @activities[idx + 1]
198
241
  end
199
242
 
@@ -231,8 +274,8 @@ module PostRunner
231
274
 
232
275
  # Show the activity list in a web browser.
233
276
  def show_list_in_browser
234
- ActivityListView.new(self).update_html_index
235
- show_in_browser(File.join(@html_dir, 'index.html'))
277
+ ActivityListView.new(self).update_index_pages
278
+ show_in_browser(File.join(@cfg[:html_dir], 'index.html'))
236
279
  end
237
280
 
238
281
  def list
@@ -243,6 +286,10 @@ module PostRunner
243
286
  puts @records.to_s
244
287
  end
245
288
 
289
+ def activity_records(activity)
290
+ @records.activity_records(activity)
291
+ end
292
+
246
293
  # Launch a web browser and show an HTML file.
247
294
  # @param html_file [String] file name of the HTML file to show
248
295
  def show_in_browser(html_file)
@@ -262,32 +309,39 @@ module PostRunner
262
309
  @activities.each { |a| a.generate_html_view }
263
310
  Log.info "All HTML report files have been re-generated."
264
311
  # (Re-)generate index files.
265
- ActivityListView.new(self).update_html_index
312
+ ActivityListView.new(self).update_index_pages
266
313
  Log.info "HTML index files have been updated."
267
314
  end
268
315
 
316
+ # Take all necessary steps to convert user data to match an updated
317
+ # PostRunner version.
318
+ def handle_version_update
319
+ # An updated version may bring new auxilliary directories. We remove the
320
+ # old directories and create new copies.
321
+ Log.warn('Removing old HTML auxilliary directories')
322
+ @auxilliary_dirs.each do |dir|
323
+ auxdir = File.join(@cfg[:html_dir], dir)
324
+ FileUtils.rm_rf(auxdir)
325
+ end
326
+ create_directories
327
+
328
+ Log.warn('Updating HTML files...')
329
+ generate_all_html_reports
330
+ end
331
+
269
332
  private
270
333
 
271
334
  def sync
272
335
  begin
273
- File.open(@archive_file, 'w') { |f| f.write(@activities.to_yaml) }
336
+ BackedUpFile.open(@archive_file, 'w') do |f|
337
+ f.write(@activities.to_yaml)
338
+ end
274
339
  rescue StandardError
275
340
  Log.fatal "Cannot write archive file '#{@archive_file}': #{$!}"
276
341
  end
277
342
 
278
343
  @records.sync
279
- ActivityListView.new(self).update_html_index
280
- end
281
-
282
- def create_directories
283
- create_directory(@db_dir, 'data')
284
- create_directory(@fit_dir, 'fit')
285
- create_directory(@html_dir, 'html')
286
-
287
- create_symlink('icons')
288
- create_symlink('jquery')
289
- create_symlink('flot')
290
- create_symlink('openlayers')
344
+ ActivityListView.new(self).update_index_pages
291
345
  end
292
346
 
293
347
  def create_directory(dir, name)
@@ -301,7 +355,7 @@ module PostRunner
301
355
  end
302
356
  end
303
357
 
304
- def create_symlink(dir)
358
+ def create_auxdir(dir)
305
359
  # This file should be in lib/postrunner. The 'misc' directory should be
306
360
  # found in '../../misc'.
307
361
  misc_dir = File.realpath(File.join(File.dirname(__FILE__),
@@ -313,12 +367,13 @@ module PostRunner
313
367
  unless Dir.exists?(src_dir)
314
368
  Log.fatal "Cannot find '#{src_dir}': #{$!}"
315
369
  end
316
- dst_dir = File.join(@html_dir, dir)
370
+ dst_dir = File.join(@cfg[:html_dir], dir)
317
371
  unless File.exists?(dst_dir)
318
372
  begin
319
- FileUtils.ln_s(src_dir, dst_dir)
373
+ #FileUtils.ln_s(src_dir, dst_dir)
374
+ FileUtils.cp_r(src_dir, dst_dir)
320
375
  rescue IOError
321
- Log.fatal "Cannot create symbolic link to '#{dst_dir}': #{$!}"
376
+ Log.fatal "Cannot create auxilliary directory '#{dst_dir}': #{$!}"
322
377
  end
323
378
  end
324
379
  end
@@ -3,7 +3,7 @@
3
3
  #
4
4
  # = Activity.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
5
  #
6
- # Copyright (c) 2014 by Chris Schlaeger <cs@taskjuggler.org>
6
+ # Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org>
7
7
  #
8
8
  # This program is free software; you can redistribute it and/or modify
9
9
  # it under the terms of version 2 of the GNU General Public License as
@@ -19,19 +19,73 @@ module PostRunner
19
19
 
20
20
  class Activity
21
21
 
22
- attr_reader :db, :fit_file, :name, :fit_activity, :html_dir, :html_file
22
+ attr_reader :db, :fit_file, :name, :fit_activity
23
23
 
24
24
  # This is a list of variables that provide data from the fit file. To
25
25
  # speed up access to it, we cache the data in the activity database.
26
- @@CachedActivityValues = %w( sport timestamp total_distance
27
- total_timer_time avg_speed )
26
+ @@CachedActivityValues = %w( sport sub_sport timestamp total_distance
27
+ total_timer_time avg_speed )
28
28
  # We also store some additional information in the archive index.
29
29
  @@CachedAttributes = @@CachedActivityValues + %w( fit_file name )
30
30
 
31
+ ActivityTypes = {
32
+ 'generic' => 'Generic',
33
+ 'running' => 'Running',
34
+ 'cycling' => 'Cycling',
35
+ 'transition' => 'Transition',
36
+ 'fitness_equipment' => 'Fitness Equipment',
37
+ 'swimming' => 'Swimming',
38
+ 'basketball' => 'Basketball',
39
+ 'soccer' => 'Soccer',
40
+ 'tennis' => 'Tennis',
41
+ 'american_football' => 'American Football',
42
+ 'walking' => 'Walking',
43
+ 'cross_country_skiing' => 'Cross Country Skiing',
44
+ 'alpine_skiing' => 'Alpine Skiing',
45
+ 'snowboarding' => 'Snowboarding',
46
+ 'rowing' => 'Rowing',
47
+ 'mountaineering' => 'Mountaneering',
48
+ 'hiking' => 'Hiking',
49
+ 'multisport' => 'Multisport',
50
+ 'paddling' => 'Paddling',
51
+ 'all' => 'All'
52
+ }
53
+ ActivitySubTypes = {
54
+ 'generic' => 'Generic',
55
+ 'treadmill' => 'Treadmill',
56
+ 'street' => 'Street',
57
+ 'trail' => 'Trail',
58
+ 'track' => 'Track',
59
+ 'spin' => 'Spin',
60
+ 'indoor_cycling' => 'Indoor Cycling',
61
+ 'road' => 'Road',
62
+ 'mountain' => 'Mountain',
63
+ 'downhill' => 'Downhill',
64
+ 'recumbent' => 'Recumbent',
65
+ 'cyclocross' => 'Cyclocross',
66
+ 'hand_cycling' => 'Hand Cycling',
67
+ 'track_cycling' => 'Track Cycling',
68
+ 'indoor_rowing' => 'Indoor Rowing',
69
+ 'elliptical' => 'Elliptical',
70
+ 'stair_climbing' => 'Stair Climbing',
71
+ 'lap_swimming' => 'Lap Swimming',
72
+ 'open_water' => 'Open Water',
73
+ 'flexibility_training' => 'Flexibility Training',
74
+ 'strength_training' => 'Strength Training',
75
+ 'warm_up' => 'Warm up',
76
+ 'match' => 'Match',
77
+ 'exercise' => 'Excersize',
78
+ 'challenge' => 'Challenge',
79
+ 'indoor_skiing' => 'Indoor Skiing',
80
+ 'cardio_training' => 'Cardio Training',
81
+ 'all' => 'All'
82
+ }
83
+
31
84
  def initialize(db, fit_file, fit_activity, name = nil)
32
85
  @fit_file = fit_file
33
86
  @fit_activity = fit_activity
34
87
  @name = name || fit_file
88
+ @unset_variables = []
35
89
  late_init(db)
36
90
 
37
91
  @@CachedActivityValues.each do |v|
@@ -39,8 +93,6 @@ module PostRunner
39
93
  instance_variable_set(v_str, fit_activity.send(v))
40
94
  self.class.send(:attr_reader, v.to_sym)
41
95
  end
42
- # Generate HTML file for this activity.
43
- generate_html_view
44
96
  end
45
97
 
46
98
  # YAML::load() does not call initialize(). We don't have all attributes
@@ -48,12 +100,21 @@ module PostRunner
48
100
  # after a YAML::load().
49
101
  def late_init(db)
50
102
  @db = db
51
- @html_dir = File.join(@db.db_dir, 'html')
52
- @html_file = File.join(@html_dir, "#{@fit_file[0..-5]}.html")
103
+ @html_file = File.join(@db.cfg[:html_dir], "#{@fit_file[0..-5]}.html")
104
+
105
+ @unset_variables.each do |name_without_at|
106
+ # The YAML file does not yet have the instance variable cached.
107
+ # Load the Activity data and extract the value to set the instance
108
+ # variable.
109
+ @fit_activity = load_fit_file unless @fit_activity
110
+ instance_variable_set('@' + name_without_at,
111
+ @fit_activity.send(name_without_at))
112
+ end
53
113
  end
54
114
 
55
115
  def check
56
- @fit_activity = load_fit_file
116
+ generate_html_view
117
+ register_records
57
118
  Log.info "FIT file #{@fit_file} is OK"
58
119
  end
59
120
 
@@ -65,26 +126,21 @@ module PostRunner
65
126
  # objects. The initialize() is NOT called during YAML::load(). Any
66
127
  # additional initialization work is done in late_init().
67
128
  def init_with(coder)
129
+ @unset_variables = []
68
130
  @@CachedAttributes.each do |name_without_at|
69
- name_with_at = '@' + name_without_at
70
131
  # Create attr_readers for cached variables.
71
132
  self.class.send(:attr_reader, name_without_at.to_sym)
72
133
 
73
134
  if coder.map.include?(name_without_at)
74
135
  # The YAML file has a value for the instance variable. So just set
75
136
  # it.
76
- instance_variable_set(name_with_at, coder[name_without_at])
137
+ instance_variable_set('@' + name_without_at, coder[name_without_at])
77
138
  else
78
139
  if @@CachedActivityValues.include?(name_without_at)
79
- # The YAML file does not yet have the instance variable cached.
80
- # Load the Activity data and extract the value to set the instance
81
- # variable.
82
- @fit_activity = load_fit_file unless @fit_activity
83
- instance_variable_set(name_with_at,
84
- @fit_activity.send(name_without_at))
140
+ @unset_variables << name_without_at
85
141
  else
86
142
  Log.fatal "Don't know how to initialize the instance variable " +
87
- "#{name_with_at}."
143
+ "#{name_without_at}."
88
144
  end
89
145
  end
90
146
  end
@@ -111,7 +167,10 @@ module PostRunner
111
167
 
112
168
  def summary
113
169
  @fit_activity = load_fit_file unless @fit_activity
114
- puts ActivitySummary.new(@fit_activity, name, @db.cfg[:unit_system]).to_s
170
+ puts ActivitySummary.new(@fit_activity, @db.cfg[:unit_system],
171
+ { :name => @name,
172
+ :type => activity_type,
173
+ :sub_type => activity_sub_type }).to_s
115
174
  end
116
175
 
117
176
  def rename(name)
@@ -119,22 +178,149 @@ module PostRunner
119
178
  generate_html_view
120
179
  end
121
180
 
122
- def register_records(db)
123
- @fit_activity.personal_records.each do |r|
124
- if r.longest_distance == 1
125
- # In case longest_distance is 1 the distance is stored in the
126
- # duration field in 10-th of meters.
127
- db.register_result(r.duration * 10.0 , 0, r.start_time, @fit_file)
128
- else
129
- db.register_result(r.distance, r.duration, r.start_time, @fit_file)
181
+ def set(attribute, value)
182
+ case attribute
183
+ when 'name'
184
+ @name = value
185
+ when 'type'
186
+ @fit_activity = load_fit_file unless @fit_activity
187
+ unless ActivityTypes.values.include?(value)
188
+ Log.fatal "Unknown activity type '#{value}'. Must be one of " +
189
+ ActivityTypes.values.join(', ')
190
+ end
191
+ @sport = ActivityTypes.invert[value]
192
+ # Since the activity changes the records from this Activity need to be
193
+ # removed and added again.
194
+ @db.records.delete_activity(self)
195
+ register_records
196
+ when 'subtype'
197
+ unless ActivitySubTypes.values.include?(value)
198
+ Log.fatal "Unknown activity subtype '#{value}'. Must be one of " +
199
+ ActivitySubTypes.values.join(', ')
200
+ end
201
+ @sub_sport = ActivitySubTypes.invert[value]
202
+ else
203
+ Log.fatal "Unknown activity attribute '#{attribute}'. Must be one of " +
204
+ 'name, type or subtype'
205
+ end
206
+ generate_html_view
207
+ end
208
+
209
+ def register_records
210
+ distance_record = 0.0
211
+ distance_record_sport = nil
212
+ # Array with popular distances (in meters) in ascending order.
213
+ record_distances = nil
214
+ # Speed records for popular distances (seconds hashed by distance in
215
+ # meters)
216
+ speed_records = {}
217
+
218
+ segment_start_time = @fit_activity.sessions[0].start_time
219
+ segment_start_distance = 0.0
220
+
221
+ sport = nil
222
+ last_timestamp = nil
223
+ last_distance = nil
224
+
225
+ @fit_activity.records.each do |record|
226
+ if record.distance.nil?
227
+ # All records must have a valid distance mark or the activity does
228
+ # not qualify for a personal record.
229
+ Log.warn "Found a record without a valid distance"
230
+ return
231
+ end
232
+ if record.timestamp.nil?
233
+ Log.warn "Found a record without a valid timestamp"
234
+ return
235
+ end
236
+
237
+ unless sport
238
+ # If the Activity has sport set to 'multisport' or 'all' we pick up
239
+ # the sport from the FIT records. Otherwise, we just use whatever
240
+ # sport the Activity provides.
241
+ if @sport == 'multisport' || @sport == 'all'
242
+ sport = record.activity_type
243
+ else
244
+ sport = @sport
245
+ end
246
+ return unless PersonalRecords::SpeedRecordDistances.include?(sport)
247
+
248
+ record_distances = PersonalRecords::SpeedRecordDistances[sport].
249
+ keys.sort
130
250
  end
251
+
252
+ segment_start_distance = record.distance unless segment_start_distance
253
+ segment_start_time = record.timestamp unless segment_start_time
254
+
255
+ # Total distance covered in this segment so far
256
+ segment_distance = record.distance - segment_start_distance
257
+ # Check if we have reached the next popular distance.
258
+ if record_distances.first &&
259
+ segment_distance >= record_distances.first
260
+ segment_duration = record.timestamp - segment_start_time
261
+ # The distance may be somewhat larger than a popular distance. We
262
+ # normalize the time to the norm distance.
263
+ norm_duration = segment_duration / segment_distance *
264
+ record_distances.first
265
+ # Save the time for this distance.
266
+ speed_records[record_distances.first] = {
267
+ :time => norm_duration, :sport => sport
268
+ }
269
+ # Switch to the next popular distance.
270
+ record_distances.shift
271
+ end
272
+
273
+ # We've reached the end of a segment if the sport type changes, we
274
+ # detect a pause of more than 30 seconds or when we've reached the
275
+ # last record.
276
+ if (record.activity_type && sport && record.activity_type != sport) ||
277
+ (last_timestamp && (record.timestamp - last_timestamp) > 30) ||
278
+ record.equal?(@fit_activity.records.last)
279
+
280
+ # Check for a total distance record
281
+ if segment_distance > distance_record
282
+ distance_record = segment_distance
283
+ distance_record_sport = sport
284
+ end
285
+
286
+ # Prepare for the next segment in this Activity
287
+ segment_start_distance = nil
288
+ segment_start_time = nil
289
+ sport = nil
290
+ end
291
+
292
+ last_timestamp = record.timestamp
293
+ last_distance = record.distance
294
+ end
295
+
296
+ # Store the found records
297
+ start_time = @fit_activity.sessions[0].timestamp
298
+ if @distance_record_sport
299
+ @db.records.register_result(self, distance_record_sport,
300
+ distance_record, nil, start_time)
131
301
  end
302
+ speed_records.each do |dist, info|
303
+ @db.records.register_result(self, info[:sport], dist, info[:time],
304
+ start_time)
305
+ end
306
+ end
307
+
308
+ # Return true if this activity generated any personal records.
309
+ def has_records?
310
+ !@db.records.activity_records(self).empty?
132
311
  end
133
312
 
134
313
  def generate_html_view
135
314
  @fit_activity = load_fit_file unless @fit_activity
136
- ActivityView.new(self, @db.cfg[:unit_system], @db.predecessor(self),
137
- @db.successor(self))
315
+ ActivityView.new(self, @db.cfg[:unit_system])
316
+ end
317
+
318
+ def activity_type
319
+ ActivityTypes[@sport] || 'Undefined'
320
+ end
321
+
322
+ def activity_sub_type
323
+ ActivitySubTypes[@sub_sport] || 'Undefined'
138
324
  end
139
325
 
140
326
  private
@@ -142,10 +328,16 @@ module PostRunner
142
328
  def load_fit_file(filter = nil)
143
329
  fit_file = File.join(@db.fit_dir, @fit_file)
144
330
  begin
145
- return Fit4Ruby.read(fit_file, filter)
331
+ fit_activity = Fit4Ruby.read(fit_file, filter)
146
332
  rescue Fit4Ruby::Error
147
333
  Log.fatal $!
148
334
  end
335
+
336
+ unless fit_activity
337
+ Log.fatal "#{fit_file} does not contain any activity records"
338
+ end
339
+
340
+ fit_activity
149
341
  end
150
342
 
151
343
  end