postrunner 0.0.6 → 0.0.7

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.
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = NavButtonRow.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2015 by Chris Schlaeger <cs@taskjuggler.org>
7
+ #
8
+ # This program is free software; you can redistribute it and/or modify
9
+ # it under the terms of version 2 of the GNU General Public License as
10
+ # published by the Free Software Foundation.
11
+ #
12
+
13
+ require 'postrunner/HTMLBuilder'
14
+
15
+ module PostRunner
16
+
17
+ # Auxilliary class that stores the name of an icon file and a URL as a
18
+ # String. It is used to describe a NavButtonRow button.
19
+ class NavButtonDef < Struct.new(:icon, :url)
20
+ end
21
+
22
+ # A NavButtonRow is a row of buttons used to navigate between HTML pages.
23
+ class NavButtonRow
24
+
25
+ # A class to store the icon and URL of a button in the NavButtonRow
26
+ # objects.
27
+ class Button
28
+
29
+ # Create a Button object.
30
+ # @param icon [String] File name of the icon file
31
+ # @param url [String] URL of the page to change to
32
+ def initialize(icon, url = nil)
33
+ @icon = icon
34
+ @url = url
35
+ end
36
+
37
+ # Add the object as HTML Elements to the document.
38
+ # @param doc [HTMLBuilder] XML Document
39
+ def to_html(doc)
40
+ if @url
41
+ doc.a({ :href => @url }) {
42
+ doc.img({ :src => "icons/#{@icon}", :class => 'active_button' })
43
+ }
44
+ else
45
+ doc.img({ :src => "icons/#{@icon}", :class => 'inactive_button' })
46
+ end
47
+ end
48
+
49
+ end
50
+
51
+ # Create a new NavButtonRow object.
52
+ # @param float [String, Nil] specifies if the HTML representation should
53
+ # be a floating object that floats left or right.
54
+ def initialize(float = nil)
55
+ unless float.nil? || %w( left right ).include?(float)
56
+ raise ArgumentError "float argument must be nil, 'left' or 'right'"
57
+ end
58
+
59
+ @float = float
60
+ @buttons = []
61
+ end
62
+
63
+ # Add a new button to the NavButtonRow object.
64
+ # @param icon [String] File name of the icon file
65
+ # @param url [String] URL of the page to change to
66
+ def addButton(icon, url = nil)
67
+ @buttons << Button.new(icon, url)
68
+ end
69
+
70
+ # Add the object as HTML Elements to the document.
71
+ # @param doc [HTMLBuilder] XML Document
72
+ def to_html(doc)
73
+ doc.unique(:nav_button_row_style) {
74
+ doc.head { doc.style(style) }
75
+ }
76
+ doc.div({ :class => 'nav_button_row',
77
+ :style => "width: #{@buttons.length * (32 + 10)}px; " +
78
+ "#{@float ? "float: #{@float};" :
79
+ 'margin-left: auto; margin-right: auto'}"}) {
80
+ @buttons.each { |btn| btn.to_html(doc) }
81
+ }
82
+ end
83
+
84
+ private
85
+
86
+ def style
87
+ <<"EOT"
88
+ .nav_button_row {
89
+ padding: 3px 30px;
90
+ }
91
+ .active_button {
92
+ padding: 5px;
93
+ }
94
+ .inactive_button {
95
+ padding: 5px;
96
+ opacity: 0.4;
97
+ }
98
+ EOT
99
+ end
100
+
101
+ end
102
+
103
+ end
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = PagingButtons.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2015 by Chris Schlaeger <cs@taskjuggler.org>
7
+ #
8
+ # This program is free software; you can redistribute it and/or modify
9
+ # it under the terms of version 2 of the GNU General Public License as
10
+ # published by the Free Software Foundation.
11
+ #
12
+
13
+ require 'postrunner/NavButtonRow'
14
+
15
+ module PostRunner
16
+
17
+ # A class to generate a set of forward/backward buttons for an HTML page. It
18
+ # can also include jump to first/last buttons.
19
+ class PagingButtons
20
+
21
+ # Create a new PagingButtons object.
22
+ # @param page_urls [Array of String] Sorted list of all possible pages
23
+ # @param end_buttons [Boolean] If true jump to first/last buttons are
24
+ # included
25
+ def initialize(page_urls, end_buttons = true)
26
+ if page_urls.empty?
27
+ raise ArgumentError.new("'page_urls' must not be empty")
28
+ end
29
+ @pages = page_urls
30
+ @current_page_index = 0
31
+ @end_buttons = end_buttons
32
+ end
33
+
34
+ # Return the URL of the current page
35
+ def current_page
36
+ @pages[@current_page_index]
37
+ end
38
+
39
+ # Set the URL for the current page. It must be included in the URL set
40
+ # passed at creation time. The forward/backward links will be derived from
41
+ # the setting of the current page.
42
+ # @param page_url [String] URL of the page
43
+ def current_page=(page_url)
44
+ unless (@current_page_index = @pages.index(page_url))
45
+ raise ArgumentError.new("URL #{page_url} is not a known page URL")
46
+ end
47
+ end
48
+
49
+ # Iterate over all buttons. A NavButtonDef object is passed to the block
50
+ # that contains the icon and URL for the button. If no URL is set, the
51
+ # button is inactive.
52
+ def each
53
+ %w( first back forward last ).each do |button_name|
54
+ button = NavButtonDef.new
55
+ button.icon = button_name + '.png'
56
+ button.url =
57
+ case button_name
58
+ when 'first'
59
+ @current_page_index == 0 || !@end_buttons ? nil : @pages.first
60
+ when 'back'
61
+ @current_page_index == 0 ? nil :
62
+ @pages[@current_page_index - 1]
63
+ when 'forward'
64
+ @current_page_index == @pages.length - 1 ? nil :
65
+ @pages[@current_page_index + 1]
66
+ when 'last'
67
+ @current_page_index == @pages.length - 1 ||
68
+ !@end_buttons ? nil : @pages.last
69
+ end
70
+
71
+ yield(button)
72
+ end
73
+ end
74
+
75
+ end
76
+
77
+ end
@@ -3,7 +3,7 @@
3
3
  #
4
4
  # = PersonalRecords.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,115 +14,349 @@ require 'fileutils'
14
14
  require 'yaml'
15
15
 
16
16
  require 'fit4ruby'
17
+ require 'postrunner/BackedUpFile'
18
+ require 'postrunner/RecordListPageView'
19
+ require 'postrunner/ActivityLink'
17
20
 
18
21
  module PostRunner
19
22
 
20
23
  class PersonalRecords
21
24
 
25
+ include Fit4Ruby::Converters
26
+
27
+ SpeedRecordDistances = {
28
+ 'cycling' => {
29
+ 5000.0 => '5 km',
30
+ 8000.0 => '8 km',
31
+ 9000.0 => '9 km',
32
+ 10000.0 => '10 km',
33
+ 20000.0 => '20 km',
34
+ 40000.0 => '40 km',
35
+ 80000.0 => '80 km',
36
+ 90000.0 => '90 km',
37
+ 12000.0 => '120 km',
38
+ 18000.0 => '180 km',
39
+ },
40
+ 'running' => {
41
+ 1000.0 => '1 km',
42
+ 1609.0 => '1 mi',
43
+ 2000.0 => '2 km',
44
+ 3000.0 => '3 km',
45
+ 5000.0 => '5 km',
46
+ 10000.0 => '10 km',
47
+ 20000.0 => '20 km',
48
+ 30000.0 => '30 km',
49
+ 21097.5 => 'Half Marathon',
50
+ 42195.0 => 'Marathon'
51
+ },
52
+ 'swimming' => {
53
+ 100.0 => '100 m',
54
+ 300.0 => '300 m',
55
+ 400.0 => '400 m',
56
+ 750.0 => '750 m',
57
+ 1500.0 => '1.5 km',
58
+ 1930.0 => '1.2 mi',
59
+ 3000.0 => '3 km',
60
+ 4000.0 => '4 km',
61
+ 3860.0 => '2.4 mi'
62
+ },
63
+ 'walking' => {
64
+ 1000.0 => '1 km',
65
+ 1609.0 => '1 mi',
66
+ 5000.0 => '5 km',
67
+ 10000.0 => '10 km',
68
+ 21097.5 => 'Half Marathon',
69
+ 42195.0 => 'Marathon'
70
+ }
71
+ }
72
+
22
73
  class Record
23
74
 
24
- attr_accessor :distance, :duration, :start_time, :fit_file
75
+ include Fit4Ruby::Converters
76
+
77
+ attr_accessor :activity, :sport, :distance, :duration, :start_time
25
78
 
26
- def initialize(distance, duration, start_time, fit_file)
79
+ def initialize(activity, sport, distance, duration, start_time)
80
+ @activity = activity
81
+ @sport = sport
27
82
  @distance = distance
28
83
  @duration = duration
29
84
  @start_time = start_time
30
- @fit_file = fit_file
85
+ end
86
+
87
+ def to_table_row(t)
88
+ t.row((@duration.nil? ?
89
+ [ 'Longest Run', '%.1f m' % @distance, '-' ] :
90
+ [ PersonalRecords::SpeedRecordDistances[@sport][@distance],
91
+ secsToHMS(@duration),
92
+ speedToPace(@distance / @duration) ]) +
93
+ [ @activity.db.ref_by_fit_file(@activity.fit_file),
94
+ ActivityLink.new(@activity, false),
95
+ @start_time.strftime("%Y-%m-%d") ])
31
96
  end
32
97
 
33
98
  end
34
99
 
35
- include Fit4Ruby::Converters
100
+ class RecordSet
36
101
 
37
- def initialize(activities)
38
- @activities = activities
39
- @db_dir = activities.db_dir
40
- @records_file = File.join(@db_dir, 'records.yml')
41
- @records = []
102
+ include Fit4Ruby::Converters
42
103
 
43
- load_records
44
- end
104
+ attr_reader :year
45
105
 
46
- def register_result(distance, duration, start_time, fit_file)
47
- @records.each do |record|
48
- if record.duration > 0
49
- if duration > 0
50
- # This is a speed record for a popular distance.
51
- if distance == record.distance
52
- if duration < record.duration
53
- record.duration = duration
54
- record.start_time = start_time
55
- record.fit_file = fit_file
56
- Log.info "New record for #{distance} m in " +
57
- "#{secsToHMS(duration)}"
58
- return true
59
- else
60
- # No new record for this distance.
61
- return false
62
- end
63
- end
106
+ def initialize(sport, year)
107
+ @sport = sport
108
+ @year = year
109
+ @distance = nil
110
+ @speed_records = {}
111
+ PersonalRecords::SpeedRecordDistances[@sport].each_key do |dist|
112
+ @speed_records[dist] = nil
113
+ end
114
+ end
115
+
116
+ def register_result(result)
117
+ if result.duration
118
+ # We have a potential speed record for a known distance.
119
+ unless PersonalRecords::SpeedRecordDistances[@sport].
120
+ include?(result.distance)
121
+ Log.fatal "Unknown record distance #{result.distance}"
122
+ end
123
+
124
+ old_record = @speed_records[result.distance]
125
+ if old_record.nil? || old_record.duration > result.duration
126
+ @speed_records[result.distance] = result
127
+ Log.info "New #{@year ? @year.to_s : 'all-time'} " +
128
+ "#{result.sport} speed record for " +
129
+ "#{PersonalRecords::SpeedRecordDistances[@sport][
130
+ result.distance]}: " +
131
+ "#{secsToHMS(result.duration)}"
132
+ return true
64
133
  end
65
134
  else
66
- if distance > record.distance
67
- # This is a new distance record.
68
- record.distance = distance
69
- record.duration = 0
70
- record.start_time = start_time
71
- record.fit_file = fit_file
72
- Log.info "New distance record #{distance} m"
135
+ # We have a potential distance record.
136
+ if @distance.nil? || result.distance > @distance.distance
137
+ @distance = result
138
+ Log.info "New #{@year ? @year.to_s : 'all-time'} " +
139
+ "#{result.sport} distance record: #{result.distance} m"
73
140
  return true
74
- else
75
- # No new distance record.
76
- return false
77
141
  end
78
142
  end
143
+
144
+ false
145
+ end
146
+
147
+ def delete_activity(activity)
148
+ if @distance && @distance.activity == activity
149
+ @distance = nil
150
+ end
151
+ PersonalRecords::SpeedRecordDistances[@sport].each_key do |dist|
152
+ if @speed_records[dist] && @speed_records[dist].activity == activity
153
+ @speed_records[dist] = nil
154
+ end
155
+ end
156
+ end
157
+
158
+ # Return true if no Record is stored in this RecordSet object.
159
+ def empty?
160
+ return false if @distance
161
+ @speed_records.each_value { |r| return false if r }
162
+
163
+ true
164
+ end
165
+
166
+ # Iterator for all Record objects that are stored in this data structure.
167
+ def each(&block)
168
+ yield(@distance) if @distance
169
+ @speed_records.each_value do |record|
170
+ yield(record) if record
171
+ end
172
+ end
173
+
174
+ def to_s
175
+ return '' if empty?
176
+
177
+ generate_table.to_s + "\n"
178
+ end
179
+
180
+ def to_html(doc)
181
+ generate_table.to_html(doc)
182
+ end
183
+
184
+ private
185
+
186
+ def generate_table
187
+ t = FlexiTable.new
188
+ t.head
189
+ t.row([ 'Record', 'Time/Dist.', 'Avg. Pace', 'Ref.', 'Activity',
190
+ 'Date' ],
191
+ { :halign => :center })
192
+ t.set_column_attributes([
193
+ {},
194
+ { :halign => :right },
195
+ { :halign => :right },
196
+ { :halign => :right },
197
+ { :halign => :left },
198
+ { :halign => :left }
199
+ ])
200
+ t.body
201
+
202
+ records = @speed_records.values.delete_if { |r| r.nil? }
203
+ records << @distance if @distance
204
+
205
+ records.sort { |r1, r2| r1.distance <=> r2.distance }.each do |r|
206
+ r.to_table_row(t)
207
+ end
208
+
209
+ t
210
+ end
211
+
212
+
213
+ end
214
+
215
+ class SportRecords
216
+
217
+ attr_reader :sport, :all_time, :yearly
218
+
219
+ def initialize(sport)
220
+ @sport = sport
221
+ @all_time = RecordSet.new(@sport, nil)
222
+ @yearly = {}
223
+ end
224
+
225
+ def register_result(result)
226
+ year = result.start_time.year
227
+ unless @yearly[year]
228
+ @yearly[year] = RecordSet.new(@sport, year)
229
+ end
230
+
231
+ new_at = @all_time.register_result(result)
232
+ new_yr = @yearly[year].register_result(result)
233
+
234
+ new_at || new_yr
235
+ end
236
+
237
+ def delete_activity(activity)
238
+ ([ @all_time ] + @yearly.values).each do |r|
239
+ r.delete_activity(activity)
240
+ end
241
+ end
242
+
243
+ # Return true if no record is stored in this SportRecords object.
244
+ def empty?
245
+ return false unless @all_time.empty?
246
+ @yearly.each_value { |r| return false unless r.empty? }
247
+
248
+ true
249
+ end
250
+
251
+ # Iterator for all Record objects that are stored in this data structure.
252
+ def each(&block)
253
+ records = @yearly.values
254
+ records << @all_time if @all_time
255
+ records.each { |r| r.each(&block) }
256
+ end
257
+
258
+ def to_s
259
+ return '' if empty?
260
+
261
+ str = "All-time records:\n\n#{@all_time.to_s}" unless @all_time.empty?
262
+ @yearly.values.sort{ |r1, r2| r2.year <=> r1.year }.each do |record|
263
+ unless record.empty?
264
+ str += "Records of #{record.year}:\n\n#{record.to_s}"
265
+ end
266
+ end
267
+
268
+ str
269
+ end
270
+
271
+ def to_html(doc)
272
+ return nil if empty?
273
+
274
+ doc.div {
275
+ doc.h3('All-time records')
276
+ @all_time.to_html(doc)
277
+ @yearly.values.sort{ |r1, r2| r2.year <=> r1.year }.each do |record|
278
+ puts record.year
279
+ unless record.empty?
280
+ doc.h3("Records of #{record.year}")
281
+ record.to_html(doc)
282
+ end
283
+ end
284
+ }
79
285
  end
80
286
 
81
- # We have not found a record.
82
- @records << Record.new(distance, duration, start_time, fit_file)
83
- if duration == 0
84
- Log.info "New distance record #{distance} m"
85
- else
86
- Log.info "New record for #{distance}m in #{secsToHMS(duration)}"
287
+ end
288
+
289
+ def initialize(activities)
290
+ @activities = activities
291
+ @db_dir = activities.db_dir
292
+ @records_file = File.join(@db_dir, 'records.yml')
293
+ delete_all_records
294
+
295
+ load_records
296
+ end
297
+
298
+ def register_result(activity, sport, distance, duration, start_time)
299
+ unless @sport_records.include?(sport)
300
+ Log.info "Ignoring records for activity type '#{sport}' in " +
301
+ "#{activity.fit_file}"
302
+ return false
87
303
  end
88
304
 
89
- true
305
+ result = Record.new(activity, sport, distance, duration, start_time)
306
+ @sport_records[sport].register_result(result)
307
+ end
308
+
309
+ def delete_all_records
310
+ @sport_records = {}
311
+ SpeedRecordDistances.keys.each do |sport|
312
+ @sport_records[sport] = SportRecords.new(sport)
313
+ end
90
314
  end
91
315
 
92
- def delete_activity(fit_file)
93
- @records.delete_if { |r| r.fit_file == fit_file }
316
+ def delete_activity(activity)
317
+ @sport_records.each_value { |r| r.delete_activity(activity) }
94
318
  end
95
319
 
96
320
  def sync
97
321
  save_records
322
+
323
+ non_empty_records = @sport_records.select { |s, r| !r.empty? }
324
+ max = non_empty_records.length
325
+ i = 0
326
+ non_empty_records.each do |sport, record|
327
+ output_file = File.join(@activities.cfg[:html_dir],
328
+ "records-#{i}.html")
329
+ RecordListPageView.new(@activities, record, max, i).
330
+ write(output_file)
331
+ end
98
332
  end
99
333
 
100
334
  def to_s
101
- record_names = { 1000.0 => '1 km', 1609.0 => '1 mi', 5000.0 => '5 km',
102
- 21097.5 => '1/2 Marathon', 42195.0 => 'Marathon' }
103
- t = FlexiTable.new
104
- t.head
105
- t.row([ 'Record', 'Time/Dist.', 'Avg. Pace', 'Ref.', 'Activity', 'Date' ],
106
- { :halign => :center })
107
- t.set_column_attributes([
108
- {},
109
- { :halign => :right },
110
- { :halign => :right },
111
- { :halign => :right },
112
- { :halign => :right },
113
- { :halign => :left }
114
- ])
115
- t.body
116
- @records.sort { |r1, r2| r1.distance <=> r2.distance }.each do |r|
117
- activity = @activities.activity_by_fit_file(r.fit_file)
118
- t.row((r.duration == 0 ?
119
- [ 'Longest Run', '%.1f m' % r.distance, '-' ] :
120
- [ record_names[r.distance], secsToHMS(r.duration),
121
- speedToPace(r.distance / r.duration) ]) +
122
- [ @activities.ref_by_fit_file(r.fit_file),
123
- activity.name, r.start_time.strftime("%Y-%m-%d") ])
124
- end
125
- t.to_s
335
+ str = ''
336
+ @sport_records.each do |sport, record|
337
+ next if record.empty?
338
+ str += "Records for activity type #{sport}:\n\n#{record.to_s}"
339
+ end
340
+
341
+ str
342
+ end
343
+
344
+ # Iterator for all Record objects that are stored in this data structure.
345
+ def each(&block)
346
+ @sport_records.each_value { |r| r.each(&block) }
347
+ end
348
+
349
+ # Return an Array of all the records associated with the given Activity.
350
+ def activity_records(activity)
351
+ records = []
352
+ each do |record|
353
+ # puts record.activity
354
+ if record.activity.equal?(activity) && !records.include?(record)
355
+ records << record
356
+ end
357
+ end
358
+
359
+ records
126
360
  end
127
361
 
128
362
  private
@@ -130,25 +364,50 @@ module PostRunner
130
364
  def load_records
131
365
  begin
132
366
  if File.exists?(@records_file)
133
- @records = YAML.load_file(@records_file)
367
+ @sport_records = YAML.load_file(@records_file)
134
368
  else
135
369
  Log.info "No records file found at '#{@records_file}'"
136
370
  end
137
- rescue StandardError
371
+ rescue IOError
138
372
  Log.fatal "Cannot load records file '#{@records_file}': #{$!}"
139
373
  end
140
374
 
141
- unless @records.is_a?(Array)
375
+ unless @sport_records.is_a?(Hash)
142
376
  Log.fatal "The personal records file '#{@records_file}' is corrupted"
143
377
  end
378
+ fit_file_names_to_activity_refs
144
379
  end
145
380
 
146
381
  def save_records
382
+ activity_refs_to_fit_file_names
147
383
  begin
148
- File.open(@records_file, 'w') { |f| f.write(@records.to_yaml) }
149
- rescue StandardError
384
+ BackedUpFile.open(@records_file, 'w') do |f|
385
+ f.write(@sport_records.to_yaml)
386
+ end
387
+ rescue IOError
150
388
  Log.fatal "Cannot write records file '#{@records_file}': #{$!}"
151
389
  end
390
+ fit_file_names_to_activity_refs
391
+ end
392
+
393
+ # Convert FIT file names in all Record objects into Activity references.
394
+ def fit_file_names_to_activity_refs
395
+ each do |record|
396
+ # Record objects can be referenced multiple times.
397
+ if record.activity.is_a?(String)
398
+ record.activity = @activities.activity_by_fit_file(record.activity)
399
+ end
400
+ end
401
+ end
402
+
403
+ # Convert Activity references in all Record objects into FIT file names.
404
+ def activity_refs_to_fit_file_names
405
+ each do |record|
406
+ # Record objects can be referenced multiple times.
407
+ unless record.activity.is_a?(String)
408
+ record.activity = record.activity.fit_file
409
+ end
410
+ end
152
411
  end
153
412
 
154
413
  end