reading 0.9.1 → 1.0.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: e30d2e08f85d4ecb58352862165f63b62dbcbeb436103cf0761f318592359571
4
- data.tar.gz: 7991e2a241345bd8f5b8fe268c462b721d610157d2c2652aec16882d3833c65f
3
+ metadata.gz: 45b4a37abfdadf33aafc7cd1af59affdd341ebc006d07797c38fcde27d1f2198
4
+ data.tar.gz: 6214d4783c4b861e162d6df141c991a324072b1ad749bc1370d400c6b6d860f1
5
5
  SHA512:
6
- metadata.gz: 1357b2ecd226209ff58e5fa7215ca073bfda99225a9d1623b111417d9bf01ecc9ec61406b2bde9fb3a26f1c7ce6a24e9495eb2bedca403a45d029d9e94e83c0e
7
- data.tar.gz: 10285aa58757b8fa07e94945811a9195f6e5015e06ace666c963885b5a5eb19839bae855013a54a76fb4d2a038a9dd1fc53611b2ea0d28a4385073811064cb46
6
+ metadata.gz: 999323aac5ada251e74eec14107200e570fa52bf829ef35c52101647b81b1245888b0d50dca3c7eec0c1b1bcd3442f320085d46b28fcff3b1c3e0e8953fe797d
7
+ data.tar.gz: fcfd05d49225306a73ad3f3dbdae29aa311d9b2e29a05118e2b0cbd02ac7948a6b8781849d5680ceb4ea7a2e219bffc410cbd771d07e27d3b81621e0db6e67d9
data/bin/reading CHANGED
@@ -13,12 +13,12 @@
13
13
  # reading '3|📕Trying|Little Library 1970147288'
14
14
  # reading '📕Trying|Little Library 1970147288' 'head, sources'
15
15
 
16
- require_relative '../lib/reading'
17
- require_relative '../lib/reading/stats/terminal_result_formatters'
18
- require 'debug'
19
- require 'amazing_print'
20
- require 'readline'
21
- require 'pastel'
16
+ require_relative "../lib/reading"
17
+ require_relative "../lib/reading/stats/result_formatters"
18
+ require "debug"
19
+ require "amazing_print"
20
+ require "readline"
21
+ require "pastel"
22
22
 
23
23
  EXIT_COMMANDS = %w[exit e quit q]
24
24
  PASTEL = Pastel.new
@@ -72,7 +72,7 @@ if ARGV[1]
72
72
  Reading::Config.build(enabled_columns:)
73
73
  end
74
74
 
75
- input_is_csv_path = input.end_with?('.csv')
75
+ input_is_csv_path = input.end_with?(".csv") || input.end_with?(".txt")
76
76
 
77
77
  if input_is_csv_path
78
78
  error_handler = ->(e) { puts "Skipped a row due to a parsing error: #{e}" }
@@ -1,4 +1,4 @@
1
- require_relative 'errors'
1
+ require_relative "errors"
2
2
 
3
3
  module Reading
4
4
  # Builds a singleton hash config.
@@ -18,7 +18,7 @@ module Reading
18
18
  def self.parse(string)
19
19
  return nil unless string.match? /\A\d+:\d\d\z/
20
20
 
21
- hours, minutes = string.split(':').map(&:to_i)
21
+ hours, minutes = string.split(":").map(&:to_i)
22
22
  new((hours * 60) + minutes)
23
23
  end
24
24
 
@@ -51,7 +51,7 @@ module Reading
51
51
  # A string in "h:mm" format.
52
52
  # @return [String]
53
53
  def to_s
54
- "#{hours}:#{minutes.round.to_s.rjust(2, '0')} or #{(value / 60.0 * Config.hash.fetch(:pages_per_hour)).round} pages"
54
+ "#{hours}:#{minutes.round.to_s.rjust(2, "0")} or #{(value / 60.0 * Config.hash.fetch(:pages_per_hour)).round} pages"
55
55
  end
56
56
 
57
57
  # To pages.
@@ -47,7 +47,7 @@ module Reading
47
47
  item.variants.map { |variant|
48
48
  isbn = variant.isbn
49
49
  if isbn
50
- url = Config.hash.deep_fetch(:item, :view, :url_from_isbn).sub('%{isbn}', isbn)
50
+ url = Config.hash.deep_fetch(:item, :view, :url_from_isbn).sub("%{isbn}", isbn)
51
51
  else
52
52
  url = variant.sources.map { |source| source.url }.compact.first
53
53
  end
@@ -108,7 +108,7 @@ module Reading
108
108
  if item.done?
109
109
  item.last_end_date&.strftime("%Y-%m-%d")
110
110
  else
111
- item.status.to_s.gsub('_', ' ')
111
+ item.status.to_s.gsub("_", " ")
112
112
  end
113
113
  end
114
114
  end
data/lib/reading/item.rb CHANGED
@@ -1,6 +1,6 @@
1
- require 'forwardable'
1
+ require "forwardable"
2
2
 
3
- require_relative 'item/view'
3
+ require_relative "item/view"
4
4
 
5
5
  module Reading
6
6
  # A wrapper for an item parsed from a CSV reading log, providing convenience
@@ -1,4 +1,4 @@
1
- require_relative 'spans_validator'
1
+ require_relative "spans_validator"
2
2
 
3
3
  module Reading
4
4
  module Parsing
@@ -1,4 +1,4 @@
1
- require_relative 'spans_validator'
1
+ require_relative "spans_validator"
2
2
 
3
3
  # TODO Refactor! This entire file has become 🤢🤮 with the accumulation of new
4
4
  # features in the History column.
@@ -1,6 +1,6 @@
1
- require 'date'
2
- require_relative 'experiences/history_transformer'
3
- require_relative 'experiences/dates_and_head_transformer'
1
+ require "date"
2
+ require_relative "experiences/history_transformer"
3
+ require_relative "experiences/dates_and_head_transformer"
4
4
 
5
5
  module Reading
6
6
  module Parsing
@@ -1,7 +1,7 @@
1
- require 'pastel'
2
- require_relative '../item'
3
- require_relative 'parser'
4
- require_relative 'transformer'
1
+ require "pastel"
2
+ require_relative "../item"
3
+ require_relative "parser"
4
+ require_relative "transformer"
5
5
 
6
6
  module Reading
7
7
  module Parsing
@@ -1,8 +1,8 @@
1
- require_relative 'rows/blank'
2
- require_relative 'rows/regular'
3
- require_relative 'rows/compact_planned'
4
- require_relative 'rows/custom_config'
5
- require_relative 'rows/comment'
1
+ require_relative "rows/blank"
2
+ require_relative "rows/regular"
3
+ require_relative "rows/compact_planned"
4
+ require_relative "rows/custom_config"
5
+ require_relative "rows/comment"
6
6
 
7
7
  module Reading
8
8
  module Parsing
@@ -145,7 +145,7 @@ module Reading
145
145
  # Parse each format-plus-string into an array of segments.
146
146
  heads = format_strings.map { |string|
147
147
  format_emoji = string[Config.hash.deep_fetch(:regex, :formats)]
148
- string.sub!(format_emoji, '')
148
+ string.sub!(format_emoji, "")
149
149
  format = Config.hash.fetch(:formats).key(format_emoji)
150
150
 
151
151
  parse_segments(column_class, string)
@@ -1,7 +1,7 @@
1
- require_relative 'column'
2
- require_relative 'compact_planned_columns/head'
3
- require_relative 'regular_columns/sources'
4
- require_relative 'regular_columns/length'
1
+ require_relative "column"
2
+ require_relative "compact_planned_columns/head"
3
+ require_relative "regular_columns/sources"
4
+ require_relative "regular_columns/length"
5
5
 
6
6
  module Reading
7
7
  module Parsing
@@ -1,13 +1,13 @@
1
- require_relative 'column'
2
- require_relative 'regular_columns/rating'
3
- require_relative 'regular_columns/head'
4
- require_relative 'regular_columns/sources'
5
- require_relative 'regular_columns/start_dates'
6
- require_relative 'regular_columns/end_dates'
7
- require_relative 'regular_columns/genres'
8
- require_relative 'regular_columns/length'
9
- require_relative 'regular_columns/notes'
10
- require_relative 'regular_columns/history'
1
+ require_relative "column"
2
+ require_relative "regular_columns/rating"
3
+ require_relative "regular_columns/head"
4
+ require_relative "regular_columns/sources"
5
+ require_relative "regular_columns/start_dates"
6
+ require_relative "regular_columns/end_dates"
7
+ require_relative "regular_columns/genres"
8
+ require_relative "regular_columns/length"
9
+ require_relative "regular_columns/notes"
10
+ require_relative "regular_columns/history"
11
11
 
12
12
  module Reading
13
13
  module Parsing
@@ -1,12 +1,12 @@
1
- require_relative 'attributes/shared'
2
- require_relative 'attributes/attribute'
3
- require_relative 'attributes/rating'
4
- require_relative 'attributes/author'
5
- require_relative 'attributes/title'
6
- require_relative 'attributes/genres'
7
- require_relative 'attributes/variants'
8
- require_relative 'attributes/experiences'
9
- require_relative 'attributes/notes'
1
+ require_relative "attributes/shared"
2
+ require_relative "attributes/attribute"
3
+ require_relative "attributes/rating"
4
+ require_relative "attributes/author"
5
+ require_relative "attributes/title"
6
+ require_relative "attributes/genres"
7
+ require_relative "attributes/variants"
8
+ require_relative "attributes/experiences"
9
+ require_relative "attributes/notes"
10
10
 
11
11
  module Reading
12
12
  module Parsing
@@ -82,7 +82,7 @@ module Reading
82
82
  end
83
83
  }
84
84
 
85
- positive_operator = operator == :'!=' ? :== : operator
85
+ positive_operator = operator == :"!=" ? :== : operator
86
86
 
87
87
  matches = items.select { |item|
88
88
  ratings.any? { |rating|
@@ -95,34 +95,39 @@ module Reading
95
95
  # Instead of using item.rating.send(operator, format) above, invert
96
96
  # the matches here to ensure multiple values after a negative operator
97
97
  # have an "and" relation: "not(x and y)", rather than "not(x or y)".
98
- if operator == :'!='
98
+ if operator == :"!="
99
99
  matches = items - matches
100
100
  end
101
101
 
102
102
  matches
103
103
  },
104
- done: proc { |values, operator, items|
105
- done_progresses = values.map { |value|
106
- (value.match(/\A(\d+)?%/).captures.first.to_f.clamp(0.0, 100.0) / 100) ||
104
+ progress: proc { |values, operator, items|
105
+ progresses = values.map { |value|
106
+ value = "0%" if value == "0"
107
+ match = value.match(/\A(\d+)?%/)
108
+
109
+ (match && match.captures.first.to_f.clamp(0.0, 100.0) / 100) ||
107
110
  (raise InputError, "Progress must be a percentage")
108
111
  }
109
112
 
110
113
  filtered_items = items.map { |item|
111
114
  # Ensure multiple values after a negative operator have an "and"
112
115
  # relation: "not(x and y)", rather than "not(x or y)".
113
- if operator == :'!='
114
- item_done_progresses = item.experiences.map { |experience|
115
- experience.spans.last.progress if experience.status == :done
116
+ if operator == :"!="
117
+ item_progresses = item.experiences.map { |experience|
118
+ experience.spans.last.progress || 0.0
116
119
  }
117
120
 
118
- next if (item_done_progresses - done_progresses).empty?
121
+ next if (item_progresses - progresses).empty?
119
122
  end
120
123
 
121
124
  # Filter out non-matching experiences.
125
+ # TODO: Make this more accurate by calculating the average progress
126
+ # across spans? Currently it checks just the last span's progress.
122
127
  filtered_experiences = item.experiences.select { |experience|
123
- done_progresses.any? { |done_progress|
124
- experience.status == :done &&
125
- experience.spans.last.progress.send(operator, done_progress)
128
+ progresses.any? { |progress|
129
+ experience.spans.any? &&
130
+ (experience.spans.last.progress || 0.0).send(operator, progress)
126
131
  }
127
132
  }
128
133
 
@@ -138,7 +143,7 @@ module Reading
138
143
  filtered_items = items.map { |item|
139
144
  # Treat empty variants as if they were a variant with a nil format.
140
145
  if item.variants.empty?
141
- if operator == :'!='
146
+ if operator == :"!="
142
147
  next item unless formats.include?(nil)
143
148
  else
144
149
  next item if formats.include?(nil)
@@ -147,7 +152,7 @@ module Reading
147
152
 
148
153
  # Ensure multiple values after a negative operator have an "and"
149
154
  # relation: "not(x and y)", rather than "not(x or y)".
150
- if operator == :'!='
155
+ if operator == :"!="
151
156
  item_formats = item.variants.map(&:format)
152
157
 
153
158
  next if (item_formats - formats).empty?
@@ -169,14 +174,14 @@ module Reading
169
174
  author: proc { |values, operator, items|
170
175
  authors = values
171
176
  .map { _1.downcase if _1 }
172
- .map { _1.gsub(/[^a-zA-Z ]/, '').gsub(/\s/, '') if _1 }
177
+ .map { _1.gsub(/[^a-zA-Z ]/, "").gsub(/\s/, "") if _1 }
173
178
 
174
179
  matches = items.select { |item|
175
180
  author = item
176
181
  &.author
177
182
  &.downcase
178
- &.gsub(/[^a-zA-Z ]/, '')
179
- &.gsub(/\s/, '')
183
+ &.gsub(/[^a-zA-Z ]/, "")
184
+ &.gsub(/\s/, "")
180
185
 
181
186
  if %i[include? exclude?].include? operator
182
187
  authors.any? {
@@ -200,7 +205,7 @@ module Reading
200
205
  title: proc { |values, operator, items|
201
206
  titles = values
202
207
  .map(&:downcase)
203
- .map { _1.gsub(/[^a-zA-Z0-9 ]|\ba\b|\bthe\b/, '').gsub(/\s/, '') }
208
+ .map { _1.gsub(/[^a-zA-Z0-9 ]|\ba\b|\bthe\b/, "").gsub(/\s/, "") }
204
209
 
205
210
  matches = items.select { |item|
206
211
  next unless item.title
@@ -208,8 +213,8 @@ module Reading
208
213
  title = item
209
214
  .title
210
215
  .downcase
211
- .gsub(/[^a-zA-Z0-9 ]|\ba\b|\bthe\b/, '')
212
- .gsub(/\s/, '')
216
+ .gsub(/[^a-zA-Z0-9 ]|\ba\b|\bthe\b/, "")
217
+ .gsub(/\s/, "")
213
218
 
214
219
  if %i[include? exclude?].include? operator
215
220
  titles.any? { title.include? _1 }
@@ -228,8 +233,8 @@ module Reading
228
233
  format_name = ->(str) {
229
234
  str
230
235
  &.downcase
231
- &.gsub(/[^a-zA-Z0-9 ]|\ba\b|\bthe\b/, '')
232
- &.gsub(/\s/, '')
236
+ &.gsub(/[^a-zA-Z0-9 ]|\ba\b|\bthe\b/, "")
237
+ &.gsub(/\s/, "")
233
238
  }
234
239
 
235
240
  series_names = values.map { format_name.call(_1) }
@@ -251,7 +256,7 @@ module Reading
251
256
  # Ensure multiple values after a negative operator have an "and"
252
257
  # relation: "not(x and y)", rather than "not(x or y)".
253
258
  if %i[!= exclude?].include? operator
254
- next if operator == :'!=' && (item_series_names - series_names).empty?
259
+ next if operator == :"!=" && (item_series_names - series_names).empty?
255
260
  next if operator == :exclude? &&
256
261
  item_series_names.all? { |item_series_name|
257
262
  series_names.any? { |series_name|
@@ -280,7 +285,7 @@ module Reading
280
285
 
281
286
  series_names.any? {
282
287
  if _1.nil?
283
- nil_operator = { include?: :==, exclude?: :'!=' }[operator]
288
+ nil_operator = { include?: :==, exclude?: :"!=" }[operator]
284
289
  end
285
290
 
286
291
  item_series_name.send(nil_operator || operator, _1)
@@ -322,7 +327,7 @@ module Reading
322
327
  (url.nil? ? url_nil_match : sources.include?(url))
323
328
  }
324
329
 
325
- next if operator == :'!=' && remainder_names_and_urls.empty?
330
+ next if operator == :"!=" && remainder_names_and_urls.empty?
326
331
  next if operator == :exclude? &&
327
332
  item_source_names_and_urls.all? { |item_source_name_and_url|
328
333
  sources.any? { |source|
@@ -347,7 +352,7 @@ module Reading
347
352
  variant.sources.any? { |source|
348
353
  sources.any? {
349
354
  if _1.nil?
350
- nil_operator = { include?: :==, exclude?: :'!=' }[operator]
355
+ nil_operator = { include?: :==, exclude?: :"!=" }[operator]
351
356
  end
352
357
 
353
358
  source.name&.downcase&.send(nil_operator || operator, _1) ||
@@ -362,7 +367,7 @@ module Reading
362
367
 
363
368
  filtered_items
364
369
  },
365
- :'end-date' => proc { |values, operator, items|
370
+ :"end-date" => proc { |values, operator, items|
366
371
  end_date_ranges = values.map { |value|
367
372
  match = value.match(DATES_REGEX) ||
368
373
  (raise InputError,
@@ -396,7 +401,7 @@ module Reading
396
401
  filtered_items = items.map { |item|
397
402
  # Ensure multiple values after a negative operator have an "and"
398
403
  # relation: "not(x and y)", rather than "not(x or y)".
399
- if operator == :'!='
404
+ if operator == :"!="
400
405
  item_end_dates = item.experiences.map(&:last_end_date)
401
406
 
402
407
  next if item_end_dates.all? { |item_end_date|
@@ -468,7 +473,7 @@ module Reading
468
473
  without_before_or_after
469
474
  }
470
475
  .compact
471
- when :'!='
476
+ when :"!="
472
477
  split_item = item
473
478
 
474
479
  date_ranges.each do |date_range|
@@ -506,7 +511,7 @@ module Reading
506
511
  (raise InputError, "Experience count must be an integer")
507
512
  }
508
513
 
509
- positive_operator = operator == :'!=' ? :== : operator
514
+ positive_operator = operator == :"!=" ? :== : operator
510
515
 
511
516
  matches = items.select { |item|
512
517
  experience_counts.any? { |experience_count|
@@ -514,19 +519,19 @@ module Reading
514
519
  }
515
520
  }
516
521
 
517
- if operator == :'!='
522
+ if operator == :"!="
518
523
  matches = items - matches
519
524
  end
520
525
 
521
526
  matches
522
527
  },
523
528
  status: proc { |values, operator, items|
524
- statuses = values.map { _1.squeeze(' ').gsub(' ', '_').to_sym }
529
+ statuses = values.map { _1.squeeze(" ").gsub(" ", "_").to_sym }
525
530
 
526
531
  filtered_items = items.map { |item|
527
532
  # Ensure multiple values after a negative operator have an "and"
528
533
  # relation: "not(x and y)", rather than "not(x or y)".
529
- if operator == :'!='
534
+ if operator == :"!="
530
535
  item_statuses = item.experiences.map(&:status).presence || [:planned]
531
536
 
532
537
  next unless (item_statuses - statuses).any?
@@ -550,7 +555,7 @@ module Reading
550
555
  filtered_items
551
556
  },
552
557
  genre: proc { |values, operator, items|
553
- genres = values.map { _1 ? _1.split('+').map(&:strip) : [_1] }
558
+ genres = values.map { _1 ? _1.split("+").map(&:strip) : [_1] }
554
559
 
555
560
  matches = items.select { |item|
556
561
  genres.any? { |and_genres|
@@ -560,7 +565,7 @@ module Reading
560
565
  }
561
566
  }
562
567
 
563
- if operator == :'!='
568
+ if operator == :"!="
564
569
  matches = items - matches
565
570
  end
566
571
 
@@ -578,7 +583,7 @@ module Reading
578
583
  filtered_items = items.map { |item|
579
584
  # Treat empty variants as if they were a variant with a nil length.
580
585
  if item.variants.empty?
581
- if operator == :'!='
586
+ if operator == :"!="
582
587
  next item unless lengths.include?(nil)
583
588
  else
584
589
  next item if lengths.include?(nil)
@@ -587,7 +592,7 @@ module Reading
587
592
 
588
593
  # Ensure multiple values after a negative operator have an "and"
589
594
  # relation: "not(x and y)", rather than "not(x or y)".
590
- if operator == :'!='
595
+ if operator == :"!="
591
596
  item_lengths = item.variants.map(&:length)
592
597
 
593
598
  next if (item_lengths - lengths).empty?
@@ -609,13 +614,13 @@ module Reading
609
614
  note: proc { |values, operator, items|
610
615
  notes = values
611
616
  .map { _1.downcase if _1 }
612
- .map { _1.gsub(/[^a-zA-Z0-9 ]/, '') if _1 }
617
+ .map { _1.gsub(/[^a-zA-Z0-9 ]/, "") if _1 }
613
618
 
614
619
  matches = items.select { |item|
615
620
  item.notes.any? { |original_note|
616
621
  note = original_note
617
622
  .downcase
618
- .gsub(/[^a-zA-Z0-9 ]/, '')
623
+ .gsub(/[^a-zA-Z0-9 ]/, "")
619
624
 
620
625
  if %i[include? exclude?].include? operator
621
626
  notes.any? { _1.nil? ? note == _1 : note.include?(_1) }
@@ -635,11 +640,10 @@ module Reading
635
640
 
636
641
  NUMERIC_OPERATORS = {
637
642
  rating: true,
638
- done: true,
639
643
  progress: true,
640
644
  experience: true,
641
645
  date: true,
642
- :'end-date' => true,
646
+ :"end-date" => true,
643
647
  length: true,
644
648
  }
645
649
 
@@ -650,18 +654,18 @@ module Reading
650
654
  }
651
655
 
652
656
  PROHIBIT_NONE_VALUE = {
653
- done: true,
657
+ progress: true,
654
658
  title: true,
655
- :'end-date' => true,
659
+ :"end-date" => true,
656
660
  date: true,
657
661
  experience: true,
658
662
  status: true,
659
663
  }
660
664
 
661
665
  PROHIBIT_MULTIPLE_VALUES_AFTER_NOT = {
662
- done: true,
666
+ progress: true,
663
667
  title: true,
664
- :'end-date' => true,
668
+ :"end-date" => true,
665
669
  date: true,
666
670
  experience: true,
667
671
  status: true,
@@ -699,20 +703,20 @@ module Reading
699
703
 
700
704
  unless allowed_operators.include? operator_str
701
705
  raise InputError, "Operator \"#{operator_str}\" not allowed in the " \
702
- "\"#{key}\" filter, only #{allowed_operators.join(', ')} allowed"
706
+ "\"#{key}\" filter, only #{allowed_operators.join(", ")} allowed"
703
707
  end
704
708
 
705
709
  operator = operator_str.to_sym
706
- operator = :== if operator == :'='
710
+ operator = :== if operator == :"="
707
711
  operator = :include? if operator == :~
708
- operator = :exclude? if operator == :'!~'
712
+ operator = :exclude? if operator == :"!~"
709
713
 
710
714
  values = predicate
711
- .split(',')
715
+ .split(",")
712
716
  .map(&:strip)
713
- .map { _1.downcase == 'none' ? nil : _1 }
717
+ .map { _1.downcase == "none" ? nil : _1 }
714
718
 
715
- # if values.count > 1 && operator == :'!=' && PROHIBIT_MULTIPLE_VALUES_AFTER_NOT[key]
719
+ # if values.count > 1 && operator == :"!=" && PROHIBIT_MULTIPLE_VALUES_AFTER_NOT[key]
716
720
  # end
717
721
 
718
722
  if values.count > 1 && %i[> < >= <=].include?(operator)
@@ -16,10 +16,10 @@ module Reading
16
16
 
17
17
  if match
18
18
  group_names = match[:groups]
19
- .split(',')
19
+ .split(",")
20
20
  .tap { _1.last.sub!(/(\w)\s+\w+/, '\1') }
21
21
  .map(&:strip)
22
- .map { _1.delete_suffix('s') }
22
+ .map { _1.delete_suffix("s") }
23
23
  .map(&:to_sym)
24
24
 
25
25
  if group_names.uniq.count < group_names.count
@@ -1,3 +1,5 @@
1
+ require "bigdecimal/util"
2
+
1
3
  module Reading
2
4
  module Stats
3
5
  # The beginning of a query which specifies what it does, e.g.
@@ -115,96 +117,145 @@ module Reading
115
117
  items.sum { |item|
116
118
  item.experiences.sum { |experience|
117
119
  experience.spans.sum { |span|
118
- (span.amount * span.progress).to_i_if_whole
120
+ (span.amount * (span.progress || 0.0)).to_i_if_whole
119
121
  }
120
122
  }
121
123
  }
122
124
  },
123
125
  top_rating: proc { |items, number_arg|
124
126
  items
125
- .max_by(number_arg || DEFAULT_NUMBER_ARG) { _1.rating || 0}
126
127
  .map { |item| [author_and_title(item), item.rating] }
128
+ .max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, rating|
129
+ rating || 0
130
+ }
127
131
  },
128
132
  top_length: proc { |items, number_arg|
129
133
  items
130
134
  .map { |item|
131
- [
132
- author_and_title(item),
133
- # Longest length, or if undefined length then longest experience
134
- # (code adapted from top_amount below).
135
- item.variants.map(&:length).max ||
136
- item.experiences.map { |experience|
137
- experience.spans.sum { |span|
138
- (span.amount * span.progress).to_i_if_whole
139
- }
140
- }.max,
141
- ]
135
+ # Longest length, or if undefined length then longest experience
136
+ # (code adapted from top_amount below).
137
+ length = item.variants.map(&:length).max ||
138
+ item.experiences.map { |experience|
139
+ experience.spans.sum { |span|
140
+ (span.amount * (span.progress || 0.0)).to_i_if_whole
141
+ }
142
+ }.max
143
+
144
+ [author_and_title(item), length]
142
145
  }
143
146
  .reject { |_title, length| length.nil? }
144
- .max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, length| length }
147
+ .max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, length|
148
+ length
149
+ }
145
150
  },
146
151
  top_amount: proc { |items, number_arg|
147
152
  items
148
153
  .map { |item|
149
154
  amount = item.experiences.sum { |experience|
150
155
  experience.spans.sum { |span|
151
- (span.amount * span.progress).to_i_if_whole
156
+ (span.amount * (span.progress || 0.0)).to_i_if_whole
152
157
  }
153
158
  }
154
159
 
155
160
  [author_and_title(item), amount]
156
161
  }
157
162
  .reject { |_title, amount| amount.zero? }
158
- .max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, amount| amount }
163
+ .max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, amount|
164
+ amount
165
+ }
159
166
  },
160
167
  top_speed: proc { |items, number_arg|
161
168
  items
162
- .map { |item| calculate_speed(item) }
169
+ .map { |item|
170
+ speed = calculate_speed(item)
171
+ [author_and_title(item), speed] if speed
172
+ }
163
173
  .compact
164
174
  .max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, speed_hash|
165
175
  speed_hash[:amount] / speed_hash[:days].to_f
166
176
  }
167
177
  },
178
+ top_experience: proc { |items, number_arg|
179
+ items
180
+ .map { |item|
181
+ experience_count = item
182
+ .experiences
183
+ .count { |experience|
184
+ experience.spans.all? { _1.progress.to_d == "1.0".to_d }
185
+ }
186
+
187
+ [author_and_title(item), [experience_count, item.rating || 0]]
188
+ }
189
+ .max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, experience_count_and_rating|
190
+ experience_count_and_rating
191
+ }
192
+ .map { |title, (experience_count, _rating)|
193
+ [title, experience_count]
194
+ }
195
+ },
196
+ top_note: proc { |items, number_arg|
197
+ items
198
+ .map { |item|
199
+ notes_word_count = item
200
+ .notes
201
+ .sum { |note|
202
+ note.content.scan(/[\w[:punct:]]+/).count
203
+ }
204
+
205
+ [author_and_title(item), notes_word_count]
206
+ }
207
+ .max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, notes_word_count|
208
+ notes_word_count
209
+ }
210
+ },
168
211
  bottom_rating: proc { |items, number_arg|
169
212
  items
170
- .min_by(number_arg || DEFAULT_NUMBER_ARG) { _1.rating || 0}
171
213
  .map { |item| [author_and_title(item), item.rating] }
214
+ .min_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, rating|
215
+ rating || 0
216
+ }
172
217
  },
173
218
  bottom_length: proc { |items, number_arg|
174
219
  items
175
220
  .map { |item|
176
- [
177
- author_and_title(item),
178
- # Longest length, or if undefined length then longest experience
179
- # (code adapted from bottom_amount below).
180
- item.variants.map(&:length).max ||
181
- item.experiences.map { |experience|
182
- experience.spans.sum { |span|
183
- (span.amount * span.progress).to_i_if_whole
184
- }
185
- }.max,
186
- ]
221
+ # Longest length, or if undefined length then longest experience
222
+ # (code adapted from bottom_amount below).
223
+ length = item.variants.map(&:length).max ||
224
+ item.experiences.map { |experience|
225
+ experience.spans.sum { |span|
226
+ (span.amount * (span.progress || 0.0)).to_i_if_whole
227
+ }
228
+ }.max
229
+
230
+ [author_and_title(item), length]
187
231
  }
188
232
  .reject { |_title, length| length.nil? }
189
- .min_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, length| length }
233
+ .min_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, length|
234
+ length
235
+ }
190
236
  },
191
237
  bottom_amount: proc { |items, number_arg|
192
238
  items
193
239
  .map { |item|
194
240
  amount = item.experiences.sum { |experience|
195
241
  experience.spans.sum { |span|
196
- (span.amount * span.progress).to_i_if_whole
242
+ (span.amount * (span.progress || 0.0)).to_i_if_whole
197
243
  }
198
244
  }
199
245
 
200
246
  [author_and_title(item), amount]
201
247
  }
202
248
  .reject { |_title, amount| amount.zero? }
203
- .min_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, amount| amount }
249
+ .min_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, amount|
250
+ amount
251
+ }
204
252
  },
205
253
  bottom_speed: proc { |items, number_arg|
206
254
  items
207
- .map { |item| calculate_speed(item) }
255
+ .map { |item|
256
+ speed = calculate_speed(item)
257
+ [author_and_title(item), speed] if speed
258
+ }
208
259
  .compact
209
260
  .min_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, speed_hash|
210
261
  speed_hash[:amount] / speed_hash[:days].to_f
@@ -227,6 +278,8 @@ module Reading
227
278
  top_length: %w[tl],
228
279
  top_amount: %w[ta],
229
280
  top_speed: %w[ts],
281
+ top_experience: %w[te],
282
+ top_note: %w[tn],
230
283
  bottom_rating: %w[br],
231
284
  bottom_length: %w[bl],
232
285
  bottom_amount: %w[ba],
@@ -235,7 +288,7 @@ module Reading
235
288
  }
236
289
 
237
290
  REGEXES = ACTIONS.map { |key, _action|
238
- first_word, second_word = key.to_s.split('_')
291
+ first_word, second_word = key.to_s.split("_")
239
292
  aliases = ALIASES.fetch(key)
240
293
 
241
294
  regex =
@@ -260,7 +313,7 @@ module Reading
260
313
  (
261
314
  \A
262
315
  \s*
263
- (#{aliases.join('|')})
316
+ (#{aliases.join("|")})
264
317
  s?
265
318
  \s*
266
319
  (?<number_arg>
@@ -328,10 +381,7 @@ module Reading
328
381
 
329
382
  return nil unless speeds.any?
330
383
 
331
- speed = speeds
332
- .max_by { |hash| hash[:amount] / hash[:days].to_f }
333
-
334
- [author_and_title(item), speed]
384
+ speeds.max_by { |hash| hash[:amount] / hash[:days].to_f }
335
385
  end
336
386
 
337
387
  # A shorter version of Item::View#name.
@@ -1,7 +1,8 @@
1
- require 'pastel'
2
- require_relative 'operation'
3
- require_relative 'filter'
4
- require_relative 'grouping'
1
+ require "pastel"
2
+ require_relative "operation"
3
+ require_relative "filter"
4
+ require_relative "grouping"
5
+ require_relative "result_formatters"
5
6
 
6
7
  module Reading
7
8
  module Stats
@@ -13,9 +14,8 @@ module Reading
13
14
  # @param items [Array<Item>] the Items to be queried.
14
15
  # @param result_formatters [Boolean, Hash{Symbol => Proc}] to alter the
15
16
  # appearance of results; keys should be from among the keys of
16
- # Operation::ACTIONS. Pre-made formatters for terminal output are in
17
- # terminal_result_formatters.rb.
18
- def initialize(input:, items:, result_formatters: {})
17
+ # Operation::ACTIONS. Pre-made formatters are in result_formatters.rb.
18
+ def initialize(input:, items:, result_formatters: Reading::Stats::ResultFormatters::TRUNCATED_TITLES)
19
19
  @input = input
20
20
  @items = items
21
21
  @result_formatters = result_formatters
@@ -0,0 +1,140 @@
1
+ require "pastel"
2
+
3
+ module Reading
4
+ module Stats
5
+ module ResultFormatters
6
+ TRUNCATED_TITLES = {
7
+ top_length: ->(result) { with_truncated_title(result) },
8
+ top_amount: ->(result) { with_truncated_title(result) },
9
+ top_speed: ->(result) { with_truncated_title(result) },
10
+ top_experience: ->(result) { with_truncated_title(result) },
11
+ top_note: ->(result) { with_truncated_title(result) },
12
+ bottom_length: ->(result) { with_truncated_title(result) },
13
+ botom_amount: ->(result) { with_truncated_title(result) },
14
+ bottom_speed: ->(result) { with_truncated_title(result) },
15
+ }
16
+
17
+ TERMINAL = {
18
+ average_length: ->(result) { length_to_s(result) },
19
+ average_amount: ->(result) { length_to_s(result) },
20
+ :"average_daily-amount" => ->(result) { "#{length_to_s(result)} per day" },
21
+ total_item: ->(result) {
22
+ if result.zero?
23
+ PASTEL.bright_black("none")
24
+ else
25
+ color("#{result} #{result == 1 ? "item" : "items"}")
26
+ end
27
+ },
28
+ total_amount: ->(result) { length_to_s(result) },
29
+ top_rating: ->(result) { top_or_bottom_numbers_string(result, noun: "star") },
30
+ top_length: ->(result) { top_or_bottom_lengths_string(result) },
31
+ top_amount: ->(result) { top_or_bottom_lengths_string(result) },
32
+ top_speed: ->(result) { top_or_bottom_speeds_string(result) },
33
+ top_experience: ->(result) { top_or_bottom_numbers_string(result, noun: "experience") },
34
+ top_note: ->(result) { top_or_bottom_numbers_string(result, noun: "word") },
35
+ bottom_rating: ->(result) { top_or_bottom_numbers_string(result, noun: "star") },
36
+ bottom_length: ->(result) { top_or_bottom_lengths_string(result) },
37
+ botom_amount: ->(result) { top_or_bottom_lengths_string(result) },
38
+ bottom_speed: ->(result) { top_or_bottom_speeds_string(result) },
39
+ }
40
+
41
+ private
42
+
43
+ PASTEL = Pastel.new
44
+
45
+ # Applies a terminal color.
46
+ # @param string [String]
47
+ # @return [String]
48
+ private_class_method def self.color(string)
49
+ PASTEL.bright_blue(string)
50
+ end
51
+
52
+ # Converts a length/amount (pages or time) into a string.
53
+ # @param length [Numeric, Reading::Item::TimeLength]
54
+ # @param color [Boolean] whether a terminal color should be applied.
55
+ # @return [String]
56
+ private_class_method def self.length_to_s(length, color: true)
57
+ if length.is_a?(Numeric)
58
+ length_string = "#{length.round} pages"
59
+ else
60
+ length_string = length.to_s
61
+ end
62
+
63
+ color ? color(length_string) : length_string
64
+ end
65
+
66
+ # Formats a list of top/bottom length results as a string.
67
+ # @param result [Array]
68
+ # @return [String]
69
+ private_class_method def self.top_or_bottom_lengths_string(result)
70
+ offset = result.count.digits.count
71
+
72
+ result
73
+ .map.with_index { |(title, length), index|
74
+ pad = " " * (offset - (index + 1).digits.count)
75
+
76
+ title_line = "#{index + 1}. #{pad}#{title}"
77
+ indent = " #{" " * offset}"
78
+
79
+ "#{title_line}\n#{indent}#{length_to_s(length)}"
80
+ }
81
+ .join("\n")
82
+ end
83
+
84
+ # Formats a list of top/bottom speed results as a string.
85
+ # @param result [Array]
86
+ # @return [String]
87
+ private_class_method def self.top_or_bottom_speeds_string(result)
88
+ offset = result.count.digits.count
89
+
90
+ result
91
+ .map.with_index { |(title, hash), index|
92
+ amount = length_to_s(hash[:amount], color: false)
93
+ days = "#{hash[:days]} #{hash[:days] == 1 ? "day" : "days"}"
94
+ pad = " " * (offset - (index + 1).digits.count)
95
+
96
+ title_line = "#{index + 1}. #{pad}#{title}"
97
+ indent = " #{" " * offset}"
98
+ colored_speed = color("#{amount} in #{days}")
99
+
100
+ "#{title_line}\n#{indent}#{colored_speed}"
101
+ }
102
+ .join("\n")
103
+ end
104
+
105
+ # Formats a list of top/bottom number results as a string.
106
+ private_class_method def self.top_or_bottom_numbers_string(result, noun:)
107
+ offset = result.count.digits.count
108
+
109
+ result
110
+ .map.with_index { |(title, number), index|
111
+ pad = " " * (offset - (index + 1).digits.count)
112
+
113
+ title_line = "#{index + 1}. #{pad}#{title}"
114
+ indent = " #{" " * offset}"
115
+ number_string = color("#{number} #{number == 1 ? noun : "#{noun}s"}")
116
+
117
+ "#{title_line}\n#{indent}#{number_string}"
118
+ }
119
+ .join("\n")
120
+ end
121
+
122
+ # Truncates the title of each result to a specified length.
123
+ # @param result [Array]
124
+ # @param length [Integer] the maximum length of the title.
125
+ # @return [Array]
126
+ private_class_method def self.with_truncated_title(result, length: 45)
127
+ result.map do |title, value|
128
+ truncated_title =
129
+ if title.length + 1 > length
130
+ "#{title[0...length]}…"
131
+ else
132
+ title
133
+ end
134
+
135
+ [truncated_title, value]
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -1,3 +1,3 @@
1
1
  module Reading
2
- VERSION = '0.9.1'
2
+ VERSION = "1.0.0"
3
3
  end
data/lib/reading.rb CHANGED
@@ -1,10 +1,10 @@
1
- Dir[File.join(__dir__, 'reading', 'util', '*.rb')].each { |file| require file }
2
- require_relative 'reading/errors'
3
- require_relative 'reading/config'
4
- require_relative 'reading/parsing/csv'
5
- require_relative 'reading/filter'
6
- require_relative 'reading/stats/query'
7
- require_relative 'reading/item/time_length.rb'
1
+ Dir[File.join(__dir__, "reading", "util", "*.rb")].each { |file| require file }
2
+ require_relative "reading/errors"
3
+ require_relative "reading/config"
4
+ require_relative "reading/parsing/csv"
5
+ require_relative "reading/filter"
6
+ require_relative "reading/stats/query"
7
+ require_relative "reading/item/time_length.rb"
8
8
 
9
9
  # The gem's public API. See https://github.com/fpsvogel/reading#usage
10
10
  #
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reading
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Felipe Vogel
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-07-29 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: pastel
@@ -30,56 +29,70 @@ dependencies:
30
29
  requirements:
31
30
  - - "~>"
32
31
  - !ruby/object:Gem::Version
33
- version: '1.4'
32
+ version: '1.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: bigdecimal
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.0'
34
47
  type: :runtime
35
48
  prerelease: false
36
49
  version_requirements: !ruby/object:Gem::Requirement
37
50
  requirements:
38
51
  - - "~>"
39
52
  - !ruby/object:Gem::Version
40
- version: '1.4'
53
+ version: '3.0'
41
54
  - !ruby/object:Gem::Dependency
42
55
  name: debug
43
56
  requirement: !ruby/object:Gem::Requirement
44
57
  requirements:
45
58
  - - "~>"
46
59
  - !ruby/object:Gem::Version
47
- version: '1.7'
60
+ version: '1.0'
48
61
  type: :development
49
62
  prerelease: false
50
63
  version_requirements: !ruby/object:Gem::Requirement
51
64
  requirements:
52
65
  - - "~>"
53
66
  - !ruby/object:Gem::Version
54
- version: '1.7'
67
+ version: '1.0'
55
68
  - !ruby/object:Gem::Dependency
56
69
  name: minitest
57
70
  requirement: !ruby/object:Gem::Requirement
58
71
  requirements:
59
72
  - - "~>"
60
73
  - !ruby/object:Gem::Version
61
- version: '5.18'
74
+ version: '5.0'
62
75
  type: :development
63
76
  prerelease: false
64
77
  version_requirements: !ruby/object:Gem::Requirement
65
78
  requirements:
66
79
  - - "~>"
67
80
  - !ruby/object:Gem::Version
68
- version: '5.18'
81
+ version: '5.0'
69
82
  - !ruby/object:Gem::Dependency
70
83
  name: minitest-reporters
71
84
  requirement: !ruby/object:Gem::Requirement
72
85
  requirements:
73
86
  - - "~>"
74
87
  - !ruby/object:Gem::Version
75
- version: '1.6'
88
+ version: '1.0'
76
89
  type: :development
77
90
  prerelease: false
78
91
  version_requirements: !ruby/object:Gem::Requirement
79
92
  requirements:
80
93
  - - "~>"
81
94
  - !ruby/object:Gem::Version
82
- version: '1.6'
95
+ version: '1.0'
83
96
  - !ruby/object:Gem::Dependency
84
97
  name: shoulda-context
85
98
  requirement: !ruby/object:Gem::Requirement
@@ -108,21 +121,34 @@ dependencies:
108
121
  - - "~>"
109
122
  - !ruby/object:Gem::Version
110
123
  version: '1.0'
124
+ - !ruby/object:Gem::Dependency
125
+ name: rake
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '13.0'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '13.0'
111
138
  - !ruby/object:Gem::Dependency
112
139
  name: rubycritic
113
140
  requirement: !ruby/object:Gem::Requirement
114
141
  requirements:
115
142
  - - "~>"
116
143
  - !ruby/object:Gem::Version
117
- version: '4.7'
144
+ version: '4.0'
118
145
  type: :development
119
146
  prerelease: false
120
147
  version_requirements: !ruby/object:Gem::Requirement
121
148
  requirements:
122
149
  - - "~>"
123
150
  - !ruby/object:Gem::Version
124
- version: '4.7'
125
- description:
151
+ version: '4.0'
126
152
  email:
127
153
  - fps.vogel@gmail.com
128
154
  executables:
@@ -173,7 +199,7 @@ files:
173
199
  - lib/reading/stats/grouping.rb
174
200
  - lib/reading/stats/operation.rb
175
201
  - lib/reading/stats/query.rb
176
- - lib/reading/stats/terminal_result_formatters.rb
202
+ - lib/reading/stats/result_formatters.rb
177
203
  - lib/reading/util/blank.rb
178
204
  - lib/reading/util/exclude.rb
179
205
  - lib/reading/util/hash_array_deep_fetch.rb
@@ -190,7 +216,6 @@ metadata:
190
216
  homepage_uri: https://github.com/fpsvogel/reading
191
217
  source_code_uri: https://github.com/fpsvogel/reading
192
218
  changelog_uri: https://github.com/fpsvogel/reading/blob/main/CHANGELOG.md
193
- post_install_message:
194
219
  rdoc_options: []
195
220
  require_paths:
196
221
  - lib
@@ -198,15 +223,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
198
223
  requirements:
199
224
  - - "~>"
200
225
  - !ruby/object:Gem::Version
201
- version: 3.3.0
226
+ version: 3.4.4
202
227
  required_rubygems_version: !ruby/object:Gem::Requirement
203
228
  requirements:
204
229
  - - ">="
205
230
  - !ruby/object:Gem::Version
206
231
  version: '0'
207
232
  requirements: []
208
- rubygems_version: 3.5.15
209
- signing_key:
233
+ rubygems_version: 3.6.7
210
234
  specification_version: 4
211
235
  summary: Parses a CSV reading log.
212
236
  test_files: []
@@ -1,91 +0,0 @@
1
- require 'pastel'
2
-
3
- module Reading
4
- module Stats
5
- module ResultFormatters
6
- TERMINAL = {
7
- average_length: ->(result) { length_to_s(result) },
8
- average_amount: ->(result) { length_to_s(result) },
9
- :'average_daily-amount' => ->(result) { "#{length_to_s(result)} per day" },
10
- total_item: ->(result) {
11
- if result.zero?
12
- PASTEL.bright_black("none")
13
- else
14
- color("#{result} #{result == 1 ? "item" : "items"}")
15
- end
16
- },
17
- total_amount: ->(result) { length_to_s(result) },
18
- top_length: ->(result) { top_or_bottom_lengths(result) },
19
- top_amount: ->(result) { top_or_bottom_lengths(result) },
20
- top_speed: ->(result) { top_or_bottom_speeds(result) },
21
- bottom_length: ->(result) { top_or_bottom_lengths(result) },
22
- botom_amount: ->(result) { top_or_bottom_lengths(result) },
23
- bottom_speed: ->(result) { top_or_bottom_speeds(result) },
24
- }
25
-
26
- private
27
-
28
- PASTEL = Pastel.new
29
-
30
- # Applies a terminal color.
31
- # @param string [String]
32
- # @return [String]
33
- private_class_method def self.color(string)
34
- PASTEL.bright_blue(string)
35
- end
36
-
37
- # Converts a length/amount (pages or time) into a string.
38
- # @param length [Numeric, Reading::Item::TimeLength]
39
- # @param color [Boolean] whether a terminal color should be applied.
40
- # @return [String]
41
- private_class_method def self.length_to_s(length, color: true)
42
- if length.is_a?(Numeric)
43
- length_string = "#{length.round} pages"
44
- else
45
- length_string = length.to_s
46
- end
47
-
48
- color ? color(length_string) : length_string
49
- end
50
-
51
- # Formats a list of top/bottom length results as a string.
52
- # @param result [Array]
53
- # @return [String]
54
- private_class_method def self.top_or_bottom_lengths(result)
55
- offset = result.count.digits.count
56
-
57
- result
58
- .map.with_index { |(title, length), index|
59
- pad = ' ' * (offset - (index + 1).digits.count)
60
-
61
- title_line = "#{index + 1}. #{pad}#{title}"
62
- indent = " #{' ' * offset}"
63
-
64
- "#{title_line}\n#{indent}#{length_to_s(length)}"
65
- }
66
- .join("\n")
67
- end
68
-
69
- # Formats a list of top/bottom speed results as a string.
70
- # @param result [Array]
71
- # @return [String]
72
- private_class_method def self.top_or_bottom_speeds(result)
73
- offset = result.count.digits.count
74
-
75
- result
76
- .map.with_index { |(title, hash), index|
77
- amount = length_to_s(hash[:amount], color: false)
78
- days = "#{hash[:days]} #{hash[:days] == 1 ? "day" : "days"}"
79
- pad = ' ' * (offset - (index + 1).digits.count)
80
-
81
- title_line = "#{index + 1}. #{pad}#{title}"
82
- indent = " #{' ' * offset}"
83
- colored_speed = color("#{amount} in #{days}")
84
-
85
- "#{title_line}\n#{indent}#{colored_speed}"
86
- }
87
- .join("\n")
88
- end
89
- end
90
- end
91
- end