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)
|