reading 0.9.0 → 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 +4 -4
- data/bin/reading +33 -18
- data/lib/reading/config.rb +2 -2
- data/lib/reading/item/time_length.rb +2 -2
- data/lib/reading/item/view.rb +2 -2
- data/lib/reading/item.rb +15 -12
- data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +8 -3
- data/lib/reading/parsing/attributes/experiences/history_transformer.rb +151 -48
- data/lib/reading/parsing/attributes/experiences/spans_validator.rb +2 -2
- data/lib/reading/parsing/attributes/experiences.rb +3 -3
- data/lib/reading/parsing/attributes/shared.rb +4 -1
- data/lib/reading/parsing/csv.rb +4 -4
- data/lib/reading/parsing/parser.rb +6 -6
- data/lib/reading/parsing/rows/compact_planned.rb +4 -4
- data/lib/reading/parsing/rows/regular.rb +10 -10
- data/lib/reading/parsing/rows/regular_columns/sources.rb +1 -1
- data/lib/reading/parsing/rows/regular_columns/start_dates.rb +5 -1
- data/lib/reading/parsing/transformer.rb +9 -9
- data/lib/reading/stats/filter.rb +55 -51
- data/lib/reading/stats/grouping.rb +18 -4
- data/lib/reading/stats/operation.rb +104 -22
- data/lib/reading/stats/query.rb +7 -7
- data/lib/reading/stats/result_formatters.rb +140 -0
- data/lib/reading/util/hash_array_deep_fetch.rb +1 -23
- data/lib/reading/version.rb +1 -1
- data/lib/reading.rb +7 -7
- metadata +46 -22
- data/lib/reading/stats/terminal_result_formatters.rb +0 -91
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: 45b4a37abfdadf33aafc7cd1af59affdd341ebc006d07797c38fcde27d1f2198
         | 
| 4 | 
            +
              data.tar.gz: 6214d4783c4b861e162d6df141c991a324072b1ad749bc1370d400c6b6d860f1
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 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 | 
            -
             | 
| 17 | 
            -
            require_relative  | 
| 18 | 
            -
             | 
| 19 | 
            -
            require  | 
| 20 | 
            -
            require  | 
| 21 | 
            -
            require  | 
| 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
         | 
| @@ -38,26 +38,33 @@ GROUP_HEADING_FORMATTERS = [ | |
| 38 38 | 
             
            def print_grouped_results(grouped_results, group_heading_formatters)
         | 
| 39 39 | 
             
              indent_level = GROUP_HEADING_FORMATTERS.count - group_heading_formatters.count
         | 
| 40 40 |  | 
| 41 | 
            -
              if grouped_results.nil? || grouped_results.empty?
         | 
| 41 | 
            +
              if grouped_results.nil? || (grouped_results.respond_to?(:empty?) && grouped_results.empty?)
         | 
| 42 42 | 
             
                puts "  " * indent_level + PASTEL.bright_black("none") + "\n"
         | 
| 43 43 | 
             
                return
         | 
| 44 | 
            -
              elsif !grouped_results.is_a? Hash
         | 
| 45 | 
            -
                puts "  " * indent_level + grouped_results.gsub("\n", "\n" + "  " * indent_level) + "\n"
         | 
| 46 | 
            -
                return
         | 
| 47 44 | 
             
              end
         | 
| 48 45 |  | 
| 49 | 
            -
              grouped_results. | 
| 50 | 
            -
                 | 
| 51 | 
            -
             | 
| 46 | 
            +
              if grouped_results.is_a?(Hash) ||
         | 
| 47 | 
            +
                (grouped_results.is_a?(Array) && grouped_results.first.length == 2)
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                grouped_results.each do |group_name, grouped|
         | 
| 50 | 
            +
                  puts "  " * indent_level + group_heading_formatters.first.call(group_name)
         | 
| 51 | 
            +
                  print_grouped_results(grouped, group_heading_formatters[1..])
         | 
| 52 | 
            +
                end
         | 
| 53 | 
            +
              elsif grouped_results.is_a?(Array)
         | 
| 54 | 
            +
                numbered_results = grouped_results.map.with_index { |v, i| "#{i + 1}. #{v}" }
         | 
| 55 | 
            +
             | 
| 56 | 
            +
                puts "  " * indent_level + numbered_results.join("\n" + "  " * indent_level) + "\n"
         | 
| 57 | 
            +
              else
         | 
| 58 | 
            +
                puts "  " * indent_level + grouped_results.to_s + "\n"
         | 
| 52 59 | 
             
              end
         | 
| 53 60 | 
             
            end
         | 
| 54 61 |  | 
| 55 62 | 
             
            input = ARGV[0]
         | 
| 56 63 | 
             
            unless input
         | 
| 57 64 | 
             
              raise ArgumentError,
         | 
| 58 | 
            -
                "Argument required, either a CSV file path or a CSV string | 
| 59 | 
            -
                " | 
| 60 | 
            -
                " | 
| 65 | 
            +
                "Argument required, either a CSV file path or a CSV string.\nExamples:\n" \
         | 
| 66 | 
            +
                "reading /home/felipe/reading.csv\n" \
         | 
| 67 | 
            +
                "reading '3|📕Trying|Little Library 1970147288'"
         | 
| 61 68 | 
             
            end
         | 
| 62 69 |  | 
| 63 70 | 
             
            if ARGV[1]
         | 
| @@ -65,7 +72,7 @@ if ARGV[1] | |
| 65 72 | 
             
              Reading::Config.build(enabled_columns:)
         | 
| 66 73 | 
             
            end
         | 
| 67 74 |  | 
| 68 | 
            -
            input_is_csv_path = input.end_with?( | 
| 75 | 
            +
            input_is_csv_path = input.end_with?(".csv") || input.end_with?(".txt")
         | 
| 69 76 |  | 
| 70 77 | 
             
            if input_is_csv_path
         | 
| 71 78 | 
             
              error_handler = ->(e) { puts "Skipped a row due to a parsing error: #{e}" }
         | 
| @@ -86,11 +93,19 @@ if input_is_csv_path | |
| 86 93 | 
             
                  result_formatters: Reading::Stats::ResultFormatters::TERMINAL,
         | 
| 87 94 | 
             
                )
         | 
| 88 95 |  | 
| 89 | 
            -
                 | 
| 96 | 
            +
                if results.is_a?(Array) && results.first.is_a?(Reading::Item) # `debug` operation
         | 
| 97 | 
            +
                  r = results
         | 
| 98 | 
            +
                  puts PASTEL.red.bold("Enter 'c' to leave the debugger.")
         | 
| 99 | 
            +
                  debugger
         | 
| 100 | 
            +
                else
         | 
| 101 | 
            +
                  print_grouped_results(results, GROUP_HEADING_FORMATTERS)
         | 
| 102 | 
            +
                end
         | 
| 90 103 | 
             
              rescue Reading::Error => e
         | 
| 91 104 | 
             
                puts e
         | 
| 92 105 | 
             
              end
         | 
| 93 106 | 
             
            else # CSV string arg
         | 
| 107 | 
            +
              input = input.gsub("\\|", "|") # because some pipes are escaped when pasting into the terminal
         | 
| 108 | 
            +
             | 
| 94 109 | 
             
              begin
         | 
| 95 110 | 
             
                item_hashes = Reading.parse(lines: input, hash_output: true, item_view: false)
         | 
| 96 111 | 
             
              rescue Reading::Error => e
         | 
    
        data/lib/reading/config.rb
    CHANGED
    
    | @@ -1,4 +1,4 @@ | |
| 1 | 
            -
            require_relative  | 
| 1 | 
            +
            require_relative "errors"
         | 
| 2 2 |  | 
| 3 3 | 
             
            module Reading
         | 
| 4 4 | 
             
              # Builds a singleton hash config.
         | 
| @@ -178,8 +178,8 @@ module Reading | |
| 178 178 | 
             
                                spans:
         | 
| 179 179 | 
             
                                  [{
         | 
| 180 180 | 
             
                                    dates: nil,
         | 
| 181 | 
            -
                                    progress: 1.0,
         | 
| 182 181 | 
             
                                    amount: 0,
         | 
| 182 | 
            +
                                    progress: 1.0,
         | 
| 183 183 | 
             
                                    name: nil,
         | 
| 184 184 | 
             
                                    favorite?: false,
         | 
| 185 185 | 
             
                                  }],
         | 
| @@ -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( | 
| 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,  | 
| 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.
         | 
    
        data/lib/reading/item/view.rb
    CHANGED
    
    | @@ -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( | 
| 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  | 
| 1 | 
            +
            require "forwardable"
         | 
| 2 2 |  | 
| 3 | 
            -
            require_relative  | 
| 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
         | 
| @@ -119,12 +119,11 @@ module Reading | |
| 119 119 | 
             
                  before_index = nil
         | 
| 120 120 | 
             
                  middle_indices = experiences.map.with_index { |experience, i|
         | 
| 121 121 | 
             
                    if experience.spans.first.dates &&
         | 
| 122 | 
            -
                      experience.spans.first.dates.begin < date | 
| 123 | 
            -
                      experience.last_end_date
         | 
| 122 | 
            +
                      experience.spans.first.dates.begin < date
         | 
| 124 123 |  | 
| 125 124 | 
             
                      before_index = i
         | 
| 126 125 |  | 
| 127 | 
            -
                      if experience.last_end_date >= date
         | 
| 126 | 
            +
                      if (experience.last_end_date || Date.today) >= date
         | 
| 128 127 | 
             
                        i
         | 
| 129 128 | 
             
                      else
         | 
| 130 129 | 
             
                        nil
         | 
| @@ -133,15 +132,14 @@ module Reading | |
| 133 132 | 
             
                  }
         | 
| 134 133 | 
             
                  .compact
         | 
| 135 134 |  | 
| 136 | 
            -
                  # There are no experiences with  | 
| 135 | 
            +
                  # There are no experiences with spans that overlap the date.
         | 
| 137 136 | 
             
                  if middle_indices.none?
         | 
| 138 137 | 
             
                    # The Item is planned.
         | 
| 139 138 | 
             
                    return [] if experiences.none? { _1.spans.first.dates }
         | 
| 140 139 | 
             
                    # date is after all spans.
         | 
| 141 140 | 
             
                    return [self, nil] if experiences.all? { _1.last_end_date && date > _1.last_end_date }
         | 
| 142 | 
            -
                    # date is before all spans | 
| 143 | 
            -
                    return [nil, self] if experiences.all? { _1.spans.first.dates.begin >= date } | 
| 144 | 
            -
                      experiences.any? { _1.spans.first.dates.begin < date && _1.last_end_date.nil? }
         | 
| 141 | 
            +
                    # date is before all spans.
         | 
| 142 | 
            +
                    return [nil, self] if experiences.all? { _1.spans.first.dates.begin >= date }
         | 
| 145 143 |  | 
| 146 144 | 
             
                    # Date is in between experiences.
         | 
| 147 145 | 
             
                    if before_index
         | 
| @@ -173,7 +171,7 @@ module Reading | |
| 173 171 | 
             
                        if span.dates && span.dates.begin < date
         | 
| 174 172 | 
             
                          before_index = i
         | 
| 175 173 |  | 
| 176 | 
            -
                          span.dates.end >= date
         | 
| 174 | 
            +
                          (span.dates.end || Date.today) >= date
         | 
| 177 175 | 
             
                        end
         | 
| 178 176 | 
             
                      }
         | 
| 179 177 |  | 
| @@ -183,15 +181,20 @@ module Reading | |
| 183 181 | 
             
                    else
         | 
| 184 182 | 
             
                      span_middle = experience_middle.spans[span_middle_index]
         | 
| 185 183 |  | 
| 184 | 
            +
                      unless span_middle.dates.end
         | 
| 185 | 
            +
                        end_today_instead_of_endless = { dates: span_middle.dates.begin..Date.today }
         | 
| 186 | 
            +
                        span_middle = span_middle.to_h.merge(end_today_instead_of_endless).to_data
         | 
| 187 | 
            +
                      end
         | 
| 188 | 
            +
             | 
| 186 189 | 
             
                      dates_before = span_middle.dates.begin..date.prev_day
         | 
| 187 | 
            -
                      amount_before = span_middle.amount * (dates_before.count / span_middle.dates.count.to_f)
         | 
| 190 | 
            +
                      amount_before = (span_middle.amount || 0) * (dates_before.count / span_middle.dates.count.to_f)
         | 
| 188 191 | 
             
                      span_middle_before = span_middle.with(
         | 
| 189 192 | 
             
                        dates: dates_before,
         | 
| 190 193 | 
             
                        amount: amount_before,
         | 
| 191 194 | 
             
                      )
         | 
| 192 195 |  | 
| 193 196 | 
             
                      dates_after = date..span_middle.dates.end
         | 
| 194 | 
            -
                      amount_after = span_middle.amount * (dates_after.count / span_middle.dates.count.to_f)
         | 
| 197 | 
            +
                      amount_after = (span_middle.amount || 0) * (dates_after.count / span_middle.dates.count.to_f)
         | 
| 195 198 | 
             
                      span_middle_after = span_middle.with(
         | 
| 196 199 | 
             
                        dates: dates_after,
         | 
| 197 200 | 
             
                        amount: amount_after,
         | 
| @@ -1,4 +1,4 @@ | |
| 1 | 
            -
            require_relative  | 
| 1 | 
            +
            require_relative "spans_validator"
         | 
| 2 2 |  | 
| 3 3 | 
             
            module Reading
         | 
| 4 4 | 
             
              module Parsing
         | 
| @@ -29,7 +29,10 @@ module Reading | |
| 29 29 | 
             
                        start_dates = Array.new(size) { |i| parsed_row[:start_dates]&.dig(i) || {} }
         | 
| 30 30 | 
             
                        end_dates = Array.new(size) { |i| parsed_row[:end_dates]&.dig(i) || nil }
         | 
| 31 31 |  | 
| 32 | 
            -
                        start_end_dates = start_dates | 
| 32 | 
            +
                        start_end_dates = start_dates
         | 
| 33 | 
            +
                          .reject { _1[:planned] }
         | 
| 34 | 
            +
                          .zip(end_dates)
         | 
| 35 | 
            +
                          .presence || [[{}, nil]]
         | 
| 33 36 |  | 
| 34 37 | 
             
                        experiences_with_dates = start_end_dates.map { |start_entry, end_entry|
         | 
| 35 38 | 
             
                          {
         | 
| @@ -85,12 +88,14 @@ module Reading | |
| 85 88 | 
             
                          parsed_row[:head][head_index][:format]
         | 
| 86 89 | 
             
                        length = Attributes::Shared.length(parsed_row[:sources]&.dig(variant_index), format:) ||
         | 
| 87 90 | 
             
                          Attributes::Shared.length(parsed_row[:length], format:)
         | 
| 91 | 
            +
                        no_end_date = !dates.end if dates &&
         | 
| 92 | 
            +
                          Config.hash.fetch(:enabled_columns).include?(:end_dates)
         | 
| 88 93 |  | 
| 89 94 | 
             
                        [
         | 
| 90 95 | 
             
                          {
         | 
| 91 96 | 
             
                            dates: dates,
         | 
| 92 97 | 
             
                            amount: (length if dates),
         | 
| 93 | 
            -
                            progress: Attributes::Shared.progress(start_entry) ||
         | 
| 98 | 
            +
                            progress: Attributes::Shared.progress(start_entry, no_end_date:) ||
         | 
| 94 99 | 
             
                              Attributes::Shared.progress(parsed_row[:head][head_index]) ||
         | 
| 95 100 | 
             
                              (1.0 if end_entry),
         | 
| 96 101 | 
             
                            name: span_template.fetch(:name),
         | 
| @@ -1,5 +1,16 @@ | |
| 1 | 
            -
            require_relative  | 
| 2 | 
            -
             | 
| 1 | 
            +
            require_relative "spans_validator"
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            # TODO Refactor! This entire file has become 🤢🤮 with the accumulation of new
         | 
| 4 | 
            +
            # features in the History column.
         | 
| 5 | 
            +
            #
         | 
| 6 | 
            +
            # Goals of the refactor:
         | 
| 7 | 
            +
            #   - if possible, avoid daily_spans; build spans with date ranges directly.
         | 
| 8 | 
            +
            #   - validate spans at every step; that way the origin of bugs will be easier
         | 
| 9 | 
            +
            #     to find, e.g. for the bug fixed in 6310639, spans became invalid in
         | 
| 10 | 
            +
            #     #fix_open_ranges! and led to an error elsewhere that didn't give a trace
         | 
| 11 | 
            +
            #     back to the origin.
         | 
| 12 | 
            +
            #   - to facilitate the points above, create a class ExperienceBuilder to
         | 
| 13 | 
            +
            #     contain much of the logic that is currently in this file.
         | 
| 3 14 | 
             
            module Reading
         | 
| 4 15 | 
             
              module Parsing
         | 
| 5 16 | 
             
                module Attributes
         | 
| @@ -16,13 +27,14 @@ module Reading | |
| 16 27 | 
             
                      # many days, for example.
         | 
| 17 28 | 
             
                      AVERAGE_DAYS_IN_A_MONTH = 30.437r
         | 
| 18 29 |  | 
| 19 | 
            -
                      private attr_reader :parsed_row, :head_index
         | 
| 30 | 
            +
                      private attr_reader :parsed_row, :head_index, :next_open_range_id
         | 
| 20 31 |  | 
| 21 32 | 
             
                      # @param parsed_row [Hash] a parsed row (the intermediate hash).
         | 
| 22 33 | 
             
                      # @param head_index [Integer] current item's position in the Head column.
         | 
| 23 34 | 
             
                      def initialize(parsed_row, head_index)
         | 
| 24 35 | 
             
                        @parsed_row = parsed_row
         | 
| 25 36 | 
             
                        @head_index = head_index
         | 
| 37 | 
            +
                        @next_open_range_id = 0
         | 
| 26 38 | 
             
                      end
         | 
| 27 39 |  | 
| 28 40 | 
             
                      # Extracts experiences from the parsed row.
         | 
| @@ -61,16 +73,21 @@ module Reading | |
| 61 73 | 
             
                          month: nil,
         | 
| 62 74 | 
             
                          day: nil,
         | 
| 63 75 | 
             
                          after_single_date: false,
         | 
| 64 | 
            -
                           | 
| 76 | 
            +
                          open_range_id: nil,
         | 
| 65 77 | 
             
                          planned: false,
         | 
| 66 78 | 
             
                          amount: nil,
         | 
| 79 | 
            +
                          repetitions: nil,
         | 
| 80 | 
            +
                          frequency: nil,
         | 
| 67 81 | 
             
                          last_start_year: nil,
         | 
| 68 82 | 
             
                          last_start_month: nil,
         | 
| 69 83 | 
             
                        }
         | 
| 70 84 |  | 
| 85 | 
            +
                        # Dates after "not" entries.
         | 
| 86 | 
            +
                        except_dates = []
         | 
| 87 | 
            +
             | 
| 71 88 | 
             
                        entries.each do |entry|
         | 
| 72 89 | 
             
                          if entry[:except_dates]
         | 
| 73 | 
            -
                            reject_exception_dates!(entry, daily_spans, active)
         | 
| 90 | 
            +
                            except_dates += reject_exception_dates!(entry, daily_spans, active)
         | 
| 74 91 | 
             
                            next
         | 
| 75 92 | 
             
                          end
         | 
| 76 93 |  | 
| @@ -79,10 +96,14 @@ module Reading | |
| 79 96 |  | 
| 80 97 | 
             
                        spans = merge_daily_spans(daily_spans)
         | 
| 81 98 |  | 
| 82 | 
            -
                        fix_open_ranges | 
| 99 | 
            +
                        spans = fix_open_ranges(spans, except_dates)
         | 
| 83 100 |  | 
| 84 101 | 
             
                        relativize_amounts_from_progress!(spans)
         | 
| 85 102 |  | 
| 103 | 
            +
                        remove_last_end_date_of_today_if_open_range!(spans)
         | 
| 104 | 
            +
             | 
| 105 | 
            +
                        remove_temporary_keys!(spans)
         | 
| 106 | 
            +
             | 
| 86 107 | 
             
                        spans
         | 
| 87 108 | 
             
                      end
         | 
| 88 109 |  | 
| @@ -92,6 +113,7 @@ module Reading | |
| 92 113 | 
             
                      #   date-and-name combination.
         | 
| 93 114 | 
             
                      # @param active [Hash] variables that persist across entries, such as
         | 
| 94 115 | 
             
                      #   amount and implied date.
         | 
| 116 | 
            +
                      # @return [Array<Date>] the rejected dates.
         | 
| 95 117 | 
             
                      def reject_exception_dates!(entry, daily_spans, active)
         | 
| 96 118 | 
             
                        except_active = {
         | 
| 97 119 | 
             
                          year: active[:last_start_year],
         | 
| @@ -122,6 +144,8 @@ module Reading | |
| 122 144 | 
             
                        daily_spans.reject! do |(date, _name), _span|
         | 
| 123 145 | 
             
                          except_dates.include?(date)
         | 
| 124 146 | 
             
                        end
         | 
| 147 | 
            +
             | 
| 148 | 
            +
                        except_dates
         | 
| 125 149 | 
             
                      end
         | 
| 126 150 |  | 
| 127 151 | 
             
                      # Expands the given entry into one span per day, then adds them to daily_spans.
         | 
| @@ -145,7 +169,7 @@ module Reading | |
| 145 169 | 
             
                        active[:month] = start_month if start_month
         | 
| 146 170 | 
             
                        active[:last_start_month] = active[:month]
         | 
| 147 171 | 
             
                        if start_day
         | 
| 148 | 
            -
                          active[: | 
| 172 | 
            +
                          active[:open_range_id] = nil
         | 
| 149 173 | 
             
                          active[:day] = start_day
         | 
| 150 174 | 
             
                        end
         | 
| 151 175 |  | 
| @@ -159,8 +183,8 @@ module Reading | |
| 159 183 | 
             
                          active[:planned] = false
         | 
| 160 184 | 
             
                        end
         | 
| 161 185 |  | 
| 162 | 
            -
                         | 
| 163 | 
            -
                        date_range = date_range(entry, active, duplicate_open_range:)
         | 
| 186 | 
            +
                        duplicate_open_range_id = active[:open_range_id] if !start_day
         | 
| 187 | 
            +
                        date_range = date_range(entry, active, duplicate_open_range: !!duplicate_open_range_id)
         | 
| 164 188 |  | 
| 165 189 | 
             
                        # A startless date range (i.e. with an implied start date) appearing
         | 
| 166 190 | 
             
                        # immediately after a single date has its start date bumped forward
         | 
| @@ -174,13 +198,15 @@ module Reading | |
| 174 198 | 
             
                        format = parsed_row[:sources]&.dig(variant_index)&.dig(:format) ||
         | 
| 175 199 | 
             
                          parsed_row[:head][head_index][:format]
         | 
| 176 200 |  | 
| 177 | 
            -
                        amount =
         | 
| 178 | 
            -
                          Attributes::Shared.length(entry, format:, key_name: :amount, ignore_repetitions: true) ||
         | 
| 179 | 
            -
                          Attributes::Shared.length(parsed_row[:length], format:, episodic: true)
         | 
| 180 | 
            -
                        active[:amount] = amount if amount
         | 
| 181 | 
            -
             | 
| 182 201 | 
             
                        progress = Attributes::Shared.progress(entry)
         | 
| 183 202 |  | 
| 203 | 
            +
                        amount_from_entry =
         | 
| 204 | 
            +
                          Attributes::Shared.length(entry, format:, key_name: :amount, ignore_repetitions: true)
         | 
| 205 | 
            +
                        amount_from_length =
         | 
| 206 | 
            +
                          Attributes::Shared.length(parsed_row[:length], format:, episodic: progress.nil? || parsed_row.dig(:length, :repetitions).nil?)
         | 
| 207 | 
            +
                        amount = amount_from_entry || amount_from_length
         | 
| 208 | 
            +
                        active[:amount] = amount if amount
         | 
| 209 | 
            +
             | 
| 184 210 | 
             
                        # If the entry has no amount and the item has no episodic length,
         | 
| 185 211 | 
             
                        # then use progress as amount instead. The typical scenario for this
         | 
| 186 212 | 
             
                        # is when tracking fixed-length items such as books. See
         | 
| @@ -195,17 +221,27 @@ module Reading | |
| 195 221 | 
             
                          amount_from_progress = true
         | 
| 196 222 | 
             
                        end
         | 
| 197 223 |  | 
| 198 | 
            -
                        repetitions = entry[:repetitions]&.to_i | 
| 224 | 
            +
                        repetitions = entry[:repetitions]&.to_i
         | 
| 199 225 | 
             
                        frequency = entry[:frequency]
         | 
| 200 226 |  | 
| 227 | 
            +
                        # If the entry has no amount or progress, default to the previous
         | 
| 228 | 
            +
                        # repetitions and frequency.
         | 
| 229 | 
            +
                        unless amount_from_entry || progress || repetitions
         | 
| 230 | 
            +
                          repetitions = active[:repetitions]
         | 
| 231 | 
            +
                          frequency = active[:frequency]
         | 
| 232 | 
            +
                        end
         | 
| 233 | 
            +
             | 
| 234 | 
            +
                        active[:repetitions] = repetitions if repetitions
         | 
| 235 | 
            +
                        active[:frequency] = frequency if frequency
         | 
| 236 | 
            +
             | 
| 201 237 | 
             
                        amounts_by_date = distribute_amount_across_date_range(
         | 
| 202 238 | 
             
                          date_range || Date.new(active[:year], active[:month], active[:day]),
         | 
| 203 239 | 
             
                          amount || active[:amount],
         | 
| 204 | 
            -
                          repetitions,
         | 
| 240 | 
            +
                          repetitions || 1,
         | 
| 205 241 | 
             
                          frequency,
         | 
| 206 242 | 
             
                        )
         | 
| 207 243 |  | 
| 208 | 
            -
                         | 
| 244 | 
            +
                        open_range_id = active[:open_range_id] || duplicate_open_range_id
         | 
| 209 245 |  | 
| 210 246 | 
             
                        daily_spans_from_entry = amounts_by_date.map { |date, daily_amount|
         | 
| 211 247 | 
             
                          span_without_dates = {
         | 
| @@ -219,9 +255,11 @@ module Reading | |
| 219 255 | 
             
                            # Temporary keys (not in the final item data) for marking
         | 
| 220 256 | 
             
                            # spans to ...
         | 
| 221 257 | 
             
                            # ... be distributed evenly across an open date range.
         | 
| 222 | 
            -
                             | 
| 258 | 
            +
                            open_range_id:,
         | 
| 223 259 | 
             
                            # ... have their amounts adjusted to be relative to previous progress.
         | 
| 224 | 
            -
                            amount_from_progress | 
| 260 | 
            +
                            amount_from_progress?: amount_from_progress,
         | 
| 261 | 
            +
                            amount_from_frequency?: !!frequency,
         | 
| 262 | 
            +
                            implied_date_range?: !date_range && !!frequency,
         | 
| 225 263 | 
             
                          }
         | 
| 226 264 |  | 
| 227 265 | 
             
                          if entry[:planned] || active[:planned]
         | 
| @@ -232,7 +270,7 @@ module Reading | |
| 232 270 |  | 
| 233 271 | 
             
                          # For entries in an open range, add a random number to the key to
         | 
| 234 272 | 
             
                          # avoid overwriting entries with the same name, or lacking a name.
         | 
| 235 | 
            -
                          if  | 
| 273 | 
            +
                          if open_range_id
         | 
| 236 274 | 
             
                            key << rand
         | 
| 237 275 | 
             
                          end
         | 
| 238 276 |  | 
| @@ -285,7 +323,7 @@ module Reading | |
| 285 323 | 
             
                        return nil unless entry[:range] || duplicate_open_range
         | 
| 286 324 |  | 
| 287 325 | 
             
                        if entry[:end_day]
         | 
| 288 | 
            -
                          active[: | 
| 326 | 
            +
                          active[:open_range_id] = nil
         | 
| 289 327 |  | 
| 290 328 | 
             
                          end_year = entry[:end_year]&.to_i
         | 
| 291 329 | 
             
                          end_month = entry[:end_month]&.to_i
         | 
| @@ -299,11 +337,17 @@ module Reading | |
| 299 337 | 
             
                          date_range = Date.new(active[:year], active[:month], active[:day])..
         | 
| 300 338 | 
             
                            Date.new(end_year || active[:year], end_month || active[:month], end_day)
         | 
| 301 339 |  | 
| 302 | 
            -
                           | 
| 303 | 
            -
             | 
| 304 | 
            -
                          active[: | 
| 340 | 
            +
                          date_after_end = date_range.end.next_day
         | 
| 341 | 
            +
             | 
| 342 | 
            +
                          active[:day] = date_after_end.day
         | 
| 343 | 
            +
                          active[:month] = date_after_end.month if end_month
         | 
| 344 | 
            +
                          active[:year] = date_after_end.year if end_year
         | 
| 305 345 | 
             
                        else # either starting or continuing (duplicating) an open range
         | 
| 306 | 
            -
                          active[: | 
| 346 | 
            +
                          unless active[:open_range_id]
         | 
| 347 | 
            +
                            active[:open_range_id] = next_open_range_id
         | 
| 348 | 
            +
                            @next_open_range_id += 1
         | 
| 349 | 
            +
                          end
         | 
| 350 | 
            +
             | 
| 307 351 | 
             
                          date_range = Date.new(active[:year], active[:month], active[:day])..Date.today
         | 
| 308 352 | 
             
                        end
         | 
| 309 353 |  | 
| @@ -316,7 +360,7 @@ module Reading | |
| 316 360 | 
             
                      # @param amount [Float, Integer, Item::TimeLength] amount in
         | 
| 317 361 | 
             
                      #   pages or time.
         | 
| 318 362 | 
             
                      # @param repetitions [Integer] e.g. "x4" in a History entry.
         | 
| 319 | 
            -
                      # @param frequency [ | 
| 363 | 
            +
                      # @param frequency [String] e.g. "/week" in a History entry.
         | 
| 320 364 | 
             
                      # @return [Hash{Date => Float, Integer, Item::TimeLength}]
         | 
| 321 365 | 
             
                      def distribute_amount_across_date_range(date_or_range, amount, repetitions, frequency)
         | 
| 322 366 | 
             
                        unless amount
         | 
| @@ -366,33 +410,64 @@ module Reading | |
| 366 410 | 
             
                      # Set each open date range's last end date (wherever it's today, i.e.
         | 
| 367 411 | 
             
                      # it wasn't defined) to the day before the next entry's start date.
         | 
| 368 412 | 
             
                      # At the same time, distribute each open range's spans evenly.
         | 
| 369 | 
            -
                      # Lastly, remove the : | 
| 413 | 
            +
                      # Lastly, remove the :open_range_id key from spans.
         | 
| 370 414 | 
             
                      # @param spans [Array<Hash>] spans after being merged from daily_spans.
         | 
| 415 | 
            +
                      # @param except_dates [Date] dates after "not" entries which were
         | 
| 416 | 
            +
                      #   rejected from spans.
         | 
| 371 417 | 
             
                      # @return [Array<Hash>]
         | 
| 372 | 
            -
                      def fix_open_ranges | 
| 418 | 
            +
                      def fix_open_ranges(spans, except_dates)
         | 
| 373 419 | 
             
                        chunked_by_open_range = spans.chunk_while { |a, b|
         | 
| 374 420 | 
             
                          a[:dates] && b[:dates] && # in case of planned entry
         | 
| 375 | 
            -
             | 
| 376 | 
            -
                            a[:in_open_range] == b[:in_open_range]
         | 
| 421 | 
            +
                            a[:open_range_id] == b[:open_range_id]
         | 
| 377 422 | 
             
                        }
         | 
| 378 423 |  | 
| 379 | 
            -
                         | 
| 424 | 
            +
                        next_start_date = nil
         | 
| 380 425 | 
             
                        chunked_by_open_range
         | 
| 381 | 
            -
                          . | 
| 382 | 
            -
                            unless chunk.first[: | 
| 426 | 
            +
                          .to_a.reverse.map { |chunk|
         | 
| 427 | 
            +
                            unless chunk.first[:open_range_id]
         | 
| 383 428 | 
             
                              # safe nav. in case of planned entry
         | 
| 384 | 
            -
                               | 
| 385 | 
            -
                              next
         | 
| 429 | 
            +
                              next_start_date = chunk.first[:dates]&.begin
         | 
| 430 | 
            +
                              next chunk
         | 
| 386 431 | 
             
                            end
         | 
| 387 432 |  | 
| 388 | 
            -
                            #  | 
| 389 | 
            -
                            if  | 
| 390 | 
            -
                              chunk. | 
| 433 | 
            +
                            # Filter out spans that begin after the next chunk's start date.
         | 
| 434 | 
            +
                            if next_start_date
         | 
| 435 | 
            +
                              chunk.reject! do |span|
         | 
| 436 | 
            +
                                span[:dates].begin >= next_start_date
         | 
| 437 | 
            +
                              end
         | 
| 391 438 | 
             
                            end
         | 
| 392 | 
            -
             | 
| 439 | 
            +
             | 
| 440 | 
            +
                            # For the remaining spans (which begin before the next chunk's
         | 
| 441 | 
            +
                            # start date), bound each end date to that date.
         | 
| 442 | 
            +
                            chunk.reverse_each do |span|
         | 
| 443 | 
            +
                              if !next_start_date
         | 
| 444 | 
            +
                                next_start_date = span[:dates].begin
         | 
| 445 | 
            +
                                next
         | 
| 446 | 
            +
                              end
         | 
| 447 | 
            +
             | 
| 448 | 
            +
                              if span[:dates].end >= next_start_date
         | 
| 449 | 
            +
                                new_dates = span[:dates].begin..next_start_date.prev_day
         | 
| 450 | 
            +
             | 
| 451 | 
            +
                                if span[:amount_from_frequency?]
         | 
| 452 | 
            +
                                  new_to_old_dates_ratio = new_dates.count / span[:dates].count.to_f
         | 
| 453 | 
            +
                                  span[:amount] = (span[:amount] * new_to_old_dates_ratio).to_i_if_whole
         | 
| 454 | 
            +
                                end
         | 
| 455 | 
            +
             | 
| 456 | 
            +
                                span[:dates] = new_dates
         | 
| 457 | 
            +
                              end
         | 
| 458 | 
            +
             | 
| 459 | 
            +
                              next_start_date = span[:dates].begin if span[:dates].begin < next_start_date
         | 
| 460 | 
            +
                            end
         | 
| 461 | 
            +
             | 
| 462 | 
            +
                            if !next_start_date || chunk.first[:dates].begin < next_start_date
         | 
| 463 | 
            +
                              next_start_date = chunk.first[:dates].begin
         | 
| 464 | 
            +
                            end
         | 
| 465 | 
            +
             | 
| 466 | 
            +
                            next chunk if chunk.map { |span| span[:open_range_id] }.uniq.count == 1 &&
         | 
| 467 | 
            +
                              chunk.map { |span| span[:dates].begin }.uniq.count > 1
         | 
| 393 468 |  | 
| 394 469 | 
             
                            # Distribute spans across the open date range.
         | 
| 395 | 
            -
                            total_amount = chunk.sum { | | 
| 470 | 
            +
                            total_amount = chunk.sum { |span| span[:amount] }
         | 
| 396 471 | 
             
                            dates = chunk.last[:dates]
         | 
| 397 472 | 
             
                            amount_per_day = total_amount / dates.count.to_f
         | 
| 398 473 |  | 
| @@ -400,6 +475,7 @@ module Reading | |
| 400 475 | 
             
                            last_end_date = chunk.last[:dates].end
         | 
| 401 476 |  | 
| 402 477 | 
             
                            span = nil
         | 
| 478 | 
            +
                            chunk_dup = chunk.dup
         | 
| 403 479 | 
             
                            amount_acc = 0
         | 
| 404 480 | 
             
                            span_needing_end = nil
         | 
| 405 481 | 
             
                            dates.each do |date|
         | 
| @@ -409,8 +485,8 @@ module Reading | |
| 409 485 | 
             
                              end
         | 
| 410 486 |  | 
| 411 487 | 
             
                              while amount_acc < amount_per_day
         | 
| 412 | 
            -
                                break if  | 
| 413 | 
            -
                                span =  | 
| 488 | 
            +
                                break if chunk_dup.empty?
         | 
| 489 | 
            +
                                span = chunk_dup.shift
         | 
| 414 490 | 
             
                                amount_acc += span[:amount]
         | 
| 415 491 |  | 
| 416 492 | 
             
                                if amount_acc < amount_per_day
         | 
| @@ -427,11 +503,11 @@ module Reading | |
| 427 503 | 
             
                            end
         | 
| 428 504 |  | 
| 429 505 | 
             
                            span[:dates] = span[:dates].begin..last_end_date
         | 
| 430 | 
            -
                          }
         | 
| 431 506 |  | 
| 432 | 
            -
             | 
| 433 | 
            -
                           | 
| 434 | 
            -
             | 
| 507 | 
            +
                            chunk
         | 
| 508 | 
            +
                          }
         | 
| 509 | 
            +
                          .reverse
         | 
| 510 | 
            +
                          .flatten
         | 
| 435 511 | 
             
                      end
         | 
| 436 512 |  | 
| 437 513 | 
             
                      # Changes amounts taken from progress, from absolute to relative,
         | 
| @@ -443,15 +519,42 @@ module Reading | |
| 443 519 | 
             
                      def relativize_amounts_from_progress!(spans)
         | 
| 444 520 | 
             
                        amount_acc = 0
         | 
| 445 521 | 
             
                        spans.each do |span|
         | 
| 446 | 
            -
                          if span[:amount_from_progress]
         | 
| 522 | 
            +
                          if span[:amount_from_progress?]
         | 
| 447 523 | 
             
                            span[:amount] -= amount_acc
         | 
| 448 524 | 
             
                          end
         | 
| 449 525 |  | 
| 450 526 | 
             
                          amount_acc += span[:amount]
         | 
| 451 527 | 
             
                        end
         | 
| 528 | 
            +
                      end
         | 
| 529 | 
            +
             | 
| 530 | 
            +
                      # Removes the end date from the last span if it's today, and if it was
         | 
| 531 | 
            +
                      # written as an open range.
         | 
| 532 | 
            +
                      # @param spans [Array<Hash>] spans after being merged from daily_spans.
         | 
| 533 | 
            +
                      # @return [Array<Hash>]
         | 
| 534 | 
            +
                      def remove_last_end_date_of_today_if_open_range!(spans)
         | 
| 535 | 
            +
                        if spans.last[:dates] &&
         | 
| 536 | 
            +
                          spans.last[:dates].end == Date.today &&
         | 
| 537 | 
            +
                          (spans.last[:open_range_id] || spans.last[:implied_date_range?])
         | 
| 538 | 
            +
             | 
| 539 | 
            +
                          spans.last[:dates] = spans.last[:dates].begin..
         | 
| 540 | 
            +
                        end
         | 
| 541 | 
            +
                      end
         | 
| 542 | 
            +
             | 
| 543 | 
            +
                      # Removes all keys that shouldn't be in the final item data.
         | 
| 544 | 
            +
                      # @param spans [Array<Hash>] spans after being merged from daily_spans.
         | 
| 545 | 
            +
                      # @return [Array<Hash>]
         | 
| 546 | 
            +
                      def remove_temporary_keys!(spans)
         | 
| 547 | 
            +
                        temporary_keys = %i[
         | 
| 548 | 
            +
                          open_range_id
         | 
| 549 | 
            +
                          amount_from_progress?
         | 
| 550 | 
            +
                          amount_from_frequency?
         | 
| 551 | 
            +
                          implied_date_range?
         | 
| 552 | 
            +
                        ]
         | 
| 452 553 |  | 
| 453 554 | 
             
                        spans.each do |span|
         | 
| 454 | 
            -
                           | 
| 555 | 
            +
                          temporary_keys.each do |key|
         | 
| 556 | 
            +
                            span.delete(key)
         | 
| 557 | 
            +
                          end
         | 
| 455 558 | 
             
                        end
         | 
| 456 559 | 
             
                      end
         | 
| 457 560 | 
             
                    end
         | 
| @@ -15,7 +15,7 @@ module Reading | |
| 15 15 | 
             
                        #   experiences from the History column.
         | 
| 16 16 | 
             
                        # @raise [InvalidDateError] if any date is invalid.
         | 
| 17 17 | 
             
                        def validate(experiences, history_column: false)
         | 
| 18 | 
            -
                          if both_date_columns?
         | 
| 18 | 
            +
                          if both_date_columns? && !history_column
         | 
| 19 19 | 
             
                            validate_number_of_start_dates_and_end_dates(experiences)
         | 
| 20 20 | 
             
                          end
         | 
| 21 21 |  | 
| @@ -131,7 +131,7 @@ module Reading | |
| 131 131 | 
             
                                  end
         | 
| 132 132 | 
             
                                end
         | 
| 133 133 | 
             
                                .each_cons(2) do |a, b|
         | 
| 134 | 
            -
                                  if a.begin > b.begin || a.end > b.end
         | 
| 134 | 
            +
                                  if a.begin > b.begin || (a.end || Date.today) > (b.end || Date.today)
         | 
| 135 135 | 
             
                                    raise InvalidDateError, "Dates are not in order"
         | 
| 136 136 | 
             
                                  end
         | 
| 137 137 | 
             
                                  if a.cover?(b.begin + 1)
         |