reading 0.6.0 → 0.7.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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +8 -8
  3. data/bin/readingfile +31 -0
  4. data/lib/reading/config.rb +115 -148
  5. data/lib/reading/errors.rb +11 -64
  6. data/lib/reading/item/time_length.rb +138 -0
  7. data/lib/reading/parsing/attributes/attribute.rb +26 -0
  8. data/lib/reading/parsing/attributes/author.rb +15 -0
  9. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +106 -0
  10. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +452 -0
  11. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +149 -0
  12. data/lib/reading/parsing/attributes/experiences.rb +27 -0
  13. data/lib/reading/parsing/attributes/genres.rb +16 -0
  14. data/lib/reading/parsing/attributes/notes.rb +22 -0
  15. data/lib/reading/parsing/attributes/rating.rb +17 -0
  16. data/lib/reading/parsing/attributes/shared.rb +62 -0
  17. data/lib/reading/parsing/attributes/title.rb +21 -0
  18. data/lib/reading/parsing/attributes/variants.rb +77 -0
  19. data/lib/reading/parsing/csv.rb +101 -0
  20. data/lib/reading/parsing/parser.rb +292 -0
  21. data/lib/reading/parsing/rows/column.rb +131 -0
  22. data/lib/reading/parsing/rows/comment.rb +26 -0
  23. data/lib/reading/parsing/rows/compact_planned.rb +30 -0
  24. data/lib/reading/parsing/rows/compact_planned_columns/head.rb +60 -0
  25. data/lib/reading/parsing/rows/regular.rb +33 -0
  26. data/lib/reading/parsing/rows/regular_columns/end_dates.rb +20 -0
  27. data/lib/reading/parsing/rows/regular_columns/genres.rb +20 -0
  28. data/lib/reading/parsing/rows/regular_columns/head.rb +45 -0
  29. data/lib/reading/parsing/rows/regular_columns/history.rb +143 -0
  30. data/lib/reading/parsing/rows/regular_columns/length.rb +35 -0
  31. data/lib/reading/parsing/rows/regular_columns/notes.rb +32 -0
  32. data/lib/reading/parsing/rows/regular_columns/rating.rb +15 -0
  33. data/lib/reading/parsing/rows/regular_columns/sources.rb +94 -0
  34. data/lib/reading/parsing/rows/regular_columns/start_dates.rb +35 -0
  35. data/lib/reading/parsing/transformer.rb +70 -0
  36. data/lib/reading/util/hash_compact_by_template.rb +1 -0
  37. data/lib/reading/util/hash_deep_merge.rb +1 -1
  38. data/lib/reading/util/hash_to_struct.rb +1 -0
  39. data/lib/reading/util/numeric_to_i_if_whole.rb +12 -0
  40. data/lib/reading/util/string_truncate.rb +13 -4
  41. data/lib/reading/version.rb +1 -1
  42. data/lib/reading.rb +18 -0
  43. metadata +58 -41
  44. data/lib/reading/attribute/all_attributes.rb +0 -83
  45. data/lib/reading/attribute/attribute.rb +0 -25
  46. data/lib/reading/attribute/experiences/dates_validator.rb +0 -94
  47. data/lib/reading/attribute/experiences/experiences_attribute.rb +0 -74
  48. data/lib/reading/attribute/experiences/progress_subattribute.rb +0 -48
  49. data/lib/reading/attribute/experiences/spans_subattribute.rb +0 -82
  50. data/lib/reading/attribute/variants/extra_info_subattribute.rb +0 -44
  51. data/lib/reading/attribute/variants/length_subattribute.rb +0 -45
  52. data/lib/reading/attribute/variants/series_subattribute.rb +0 -57
  53. data/lib/reading/attribute/variants/sources_subattribute.rb +0 -78
  54. data/lib/reading/attribute/variants/variants_attribute.rb +0 -69
  55. data/lib/reading/csv.rb +0 -67
  56. data/lib/reading/line.rb +0 -23
  57. data/lib/reading/row/blank_row.rb +0 -23
  58. data/lib/reading/row/compact_planned_row.rb +0 -130
  59. data/lib/reading/row/regular_row.rb +0 -94
  60. data/lib/reading/row/row.rb +0 -88
@@ -0,0 +1,106 @@
1
+ require_relative "spans_validator"
2
+
3
+ module Reading
4
+ module Parsing
5
+ module Attributes
6
+ class Experiences < Attribute
7
+ # Experiences#transform_from_parsed delegates to this class when the
8
+ # History column is blank (i.e. when experiences should be extracted
9
+ # from the Start Dates, End Dates, and Head columns).
10
+ class DatesAndHeadTransformer
11
+ using Util::HashArrayDeepFetch
12
+
13
+ private attr_reader :config, :parsed_row, :head_index
14
+
15
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
16
+ # @param head_index [Integer] current item's position in the Head column.
17
+ # @param config [Hash] an entire config.
18
+ def initialize(parsed_row, head_index, config)
19
+ @config = config
20
+ @parsed_row = parsed_row
21
+ @head_index = head_index
22
+ end
23
+
24
+ # Extracts experiences from the parsed row.
25
+ # @return [Array<Hash>] an array of experiences; see
26
+ # Config#default_config[:item_template][:experiences]
27
+ def transform
28
+ size = [parsed_row[:start_dates]&.count || 0, parsed_row[:end_dates]&.count || 0].max
29
+ # Pad start dates with {} and end dates with nil up to the size of
30
+ # the larger of the two.
31
+ start_dates = Array.new(size) { |i| parsed_row[:start_dates]&.dig(i) || {} }
32
+ end_dates = Array.new(size) { |i| parsed_row[:end_dates]&.dig(i) || nil }
33
+
34
+ start_end_dates = start_dates.zip(end_dates).presence || [[{}, nil]]
35
+
36
+ experiences_with_dates = start_end_dates.map { |start_entry, end_entry|
37
+ {
38
+ spans: spans(start_entry, end_entry),
39
+ group: start_entry[:group],
40
+ variant_index: (start_entry[:variant] || 1).to_i - 1,
41
+ }.map { |k, v| [k, v || template.fetch(k)] }.to_h
42
+ }.presence
43
+
44
+ if experiences_with_dates
45
+ # Raises an error if any sequence of dates does not make sense.
46
+ Experiences::SpansValidator.validate(experiences_with_dates, config)
47
+ end
48
+
49
+ experiences_with_dates
50
+ end
51
+
52
+ private
53
+
54
+ # A shortcut to the experience template.
55
+ # @return [Hash]
56
+ def template
57
+ config.deep_fetch(:item_template, :experiences).first
58
+ end
59
+
60
+ # A shortcut to the span template.
61
+ # @return [Hash]
62
+ def span_template
63
+ config.deep_fetch(:item_template, :experiences, 0, :spans).first
64
+ end
65
+
66
+ # The :spans sub-attribute for the given pair of date entries.
67
+ # single span in an array.
68
+ # @param start_entry [Hash] a parsed entry in the Start Dates column.
69
+ # @param end_entry [Hash] a parsed entry in the End Dates column.
70
+ # @return [Array(Hash)] an array containing a single span representing
71
+ # the start and end date.
72
+ def spans(start_entry, end_entry)
73
+ if !start_entry&.dig(:date) && !end_entry&.dig(:date)
74
+ dates = nil
75
+ else
76
+ dates = [start_entry, end_entry].map { |date_hash|
77
+ begin
78
+ Date.parse(date_hash[:date]) if date_hash&.dig(:date)
79
+ rescue Date::Error
80
+ raise InvalidDateError, "Unparsable date \"#{date_hash[:date]}\""
81
+ end
82
+ }
83
+ dates = dates[0]..dates[1]
84
+ end
85
+
86
+ variant_index = (start_entry[:variant] || 1).to_i - 1
87
+ length = Attributes::Shared.length(parsed_row[:sources]&.dig(variant_index)) ||
88
+ Attributes::Shared.length(parsed_row[:length])
89
+
90
+ [
91
+ {
92
+ dates: dates,
93
+ amount: (length if dates),
94
+ progress: Attributes::Shared.progress(start_entry) ||
95
+ Attributes::Shared.progress(parsed_row[:head][head_index]) ||
96
+ (1.0 if end_entry),
97
+ name: span_template.fetch(:name),
98
+ favorite?: span_template.fetch(:favorite?),
99
+ }.map { |k, v| [k, v || span_template.fetch(k)] }.to_h
100
+ ]
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,452 @@
1
+ require_relative "spans_validator"
2
+
3
+ module Reading
4
+ module Parsing
5
+ module Attributes
6
+ class Experiences < Attribute
7
+ # Experiences#transform_from_parsed delegates to this class when the
8
+ # History column is not blank (i.e. when experiences should be extracted
9
+ # from History and not the Start Dates, End Dates, and Head columns).
10
+ class HistoryTransformer
11
+ using Util::HashArrayDeepFetch
12
+ using Util::NumericToIIfWhole
13
+
14
+ # Rational numbers are used here and in #distribute_amount_across_date_range
15
+ # below so as not to lose precision when dividing a small amount by
16
+ # many days, for example.
17
+ AVERAGE_DAYS_IN_A_MONTH = 30.437r
18
+
19
+ private attr_reader :parsed_row, :config
20
+
21
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
22
+ # @param config [Hash] an entire config
23
+ def initialize(parsed_row, config)
24
+ @parsed_row = parsed_row
25
+ @config = config
26
+ end
27
+
28
+ # Extracts experiences from the parsed row.
29
+ # @return [Array<Hash>] an array of experiences; see
30
+ # Config#default_config[:item_template][:experiences]
31
+ def transform
32
+ experiences = parsed_row[:history].map { |entries|
33
+ {
34
+ spans: spans_from_history_entries(entries),
35
+ group: entries.first[:group],
36
+ variant_index: (entries.first[:variant] || 1).to_i - 1,
37
+ }
38
+ }
39
+
40
+ # Raises an error if experiences overlap or are out of order.
41
+ Experiences::SpansValidator.validate(experiences, config, history_column: true)
42
+
43
+ experiences
44
+ end
45
+
46
+ private
47
+
48
+ # A shortcut to the span template.
49
+ # @return [Hash]
50
+ def span_template
51
+ @span_template ||= config.deep_fetch(:item_template, :experiences, 0, :spans).first
52
+ end
53
+
54
+ # The :spans sub-attribute for the given History column entries.
55
+ # @param entries [Array<Hash>] History entries for one experience.
56
+ # @return [Array<Hash>] an array of spans; see
57
+ # Config#default_config[:item_template][:experiences].first[:spans]
58
+ def spans_from_history_entries(entries)
59
+ daily_spans = {}
60
+ active = {
61
+ year: nil,
62
+ month: nil,
63
+ day: nil,
64
+ after_single_date: false,
65
+ open_range: false,
66
+ planned: false,
67
+ amount: nil,
68
+ last_start_year: nil,
69
+ last_start_month: nil,
70
+ }
71
+
72
+ entries.each do |entry|
73
+ if entry[:except_dates]
74
+ reject_exception_dates!(entry, daily_spans, active)
75
+ next
76
+ end
77
+
78
+ add_to_daily_spans!(entry, daily_spans, active)
79
+ end
80
+
81
+ spans = merge_daily_spans(daily_spans)
82
+
83
+ fix_open_ranges!(spans)
84
+
85
+ relativize_amounts_from_progress!(spans)
86
+
87
+ spans
88
+ end
89
+
90
+ # Removes the given entry's exception dates from daily_spans.
91
+ # @param entry [Hash] a History entry of exception dates ("not <list of dates>").
92
+ # @param daily_spans [Hash{Array(Date, String) => Hash}] one span per
93
+ # date-and-name combination.
94
+ # @param active [Hash] variables that persist across entries, such as
95
+ # amount and implied date.
96
+ def reject_exception_dates!(entry, daily_spans, active)
97
+ except_active = {
98
+ year: active[:last_start_year],
99
+ month: active[:last_start_month],
100
+ day: nil,
101
+ }
102
+
103
+ except_dates = entry[:except_dates].flat_map { |except_entry|
104
+ start_year = except_entry[:start_year]&.to_i
105
+ start_month = except_entry[:start_month]&.to_i
106
+ # (Start) day is required in an exception date.
107
+ except_active[:day] = except_entry[:start_day].to_i
108
+
109
+ # Increment year if month is earlier than previous regular entry's month.
110
+ if except_active[:month] && start_month && start_month < except_active[:month]
111
+ start_year ||= except_active[:year] + 1
112
+ end
113
+
114
+ except_active[:year] = start_year if start_year
115
+ except_active[:month] = start_month if start_month
116
+
117
+ date_range = date_range(except_entry, except_active)
118
+
119
+ date_range&.to_a ||
120
+ Date.new(except_active[:year], except_active[:month], except_active[:day])
121
+ }
122
+
123
+ daily_spans.reject! do |(date, _name), _span|
124
+ except_dates.include?(date)
125
+ end
126
+ end
127
+
128
+ # Expands the given entry into one span per day, then adds them to daily_spans.
129
+ # @param entry [Hash] a regular History entry (not exception dates).
130
+ # @param daily_spans [Hash{Array(Date, String) => Hash}] one span per
131
+ # date-and-name combination.
132
+ # @param active [Hash] variables that persist across entries, such as
133
+ # amount and implied date.
134
+ def add_to_daily_spans!(entry, daily_spans, active)
135
+ start_year = entry[:start_year]&.to_i
136
+ start_month = entry[:start_month]&.to_i
137
+ start_day = entry[:start_day]&.to_i
138
+
139
+ # Increment year if start month is earlier than previous entry's month.
140
+ if active[:month] && start_month && start_month < active[:month]
141
+ start_year ||= active[:year] + 1
142
+ end
143
+
144
+ active[:year] = start_year if start_year
145
+ active[:last_start_year] = active[:year]
146
+ active[:month] = start_month if start_month
147
+ active[:last_start_month] = active[:month]
148
+ if start_day
149
+ active[:open_range] = false
150
+ active[:day] = start_day
151
+ end
152
+
153
+ unless active[:day] && active[:month] && active[:year]
154
+ raise InvalidHistoryError, "Missing or incomplete first date"
155
+ end
156
+
157
+ duplicate_open_range = !start_day && active[:open_range]
158
+ date_range = date_range(entry, active, duplicate_open_range:)
159
+
160
+ # A startless date range (i.e. with an implied start date) appearing
161
+ # immediately after a single date has its start date bumped forward
162
+ # by one, so that its start date is not the same as the single date.
163
+ if date_range && !start_day && active[:after_single_date]
164
+ date_range = (date_range.begin + 1)..date_range.end
165
+ end
166
+ active[:after_single_date] = !date_range
167
+
168
+ amount =
169
+ Attributes::Shared.length(entry, key_name: :amount, ignore_repetitions: true) ||
170
+ Attributes::Shared.length(parsed_row[:length], episodic: true)
171
+ active[:amount] = amount if amount
172
+
173
+ progress = Attributes::Shared.progress(entry)
174
+
175
+ # If the entry has no amount and the item has no episodic length,
176
+ # then use progress as amount instead. The typical scenario for this
177
+ # is when tracking fixed-length items such as books. See
178
+ # https://github.com/fpsvogel/reading/blob/main/doc/csv-format.md#history-pages-and-stopping-points-books
179
+ if !amount && progress
180
+ if progress.is_a? Float
181
+ total_length = Attributes::Shared.length(parsed_row[:length])
182
+ amount = total_length * progress
183
+ else
184
+ amount = progress
185
+ end
186
+ amount_from_progress = true
187
+ end
188
+
189
+ repetitions = entry[:repetitions]&.to_i || 1
190
+ frequency = entry[:frequency]
191
+
192
+ amounts_by_date = distribute_amount_across_date_range(
193
+ date_range || Date.new(active[:year], active[:month], active[:day]),
194
+ amount || active[:amount],
195
+ repetitions,
196
+ frequency,
197
+ )
198
+
199
+ in_open_range = active[:open_range] || duplicate_open_range
200
+
201
+ daily_spans_from_entry = amounts_by_date.map { |date, daily_amount|
202
+ span_without_dates = {
203
+ dates: nil,
204
+ amount: daily_amount || span_template[:amount],
205
+ progress: (progress unless amount_from_progress) || span_template[:progress],
206
+ name: entry[:name] || span_template[:name],
207
+ favorite?: !!entry[:favorite] || span_template[:favorite?],
208
+ # Temporary keys (not in the final item data) for marking
209
+ # spans to ...
210
+ # ... be distributed evenly across an open date range.
211
+ in_open_range: in_open_range,
212
+ # ... have their amounts adjusted to be relative to previous progress.
213
+ amount_from_progress: amount_from_progress,
214
+ }
215
+
216
+ if entry[:planned] || (active[:planned] && !start_day)
217
+ date = nil
218
+ active[:planned] = true
219
+ end
220
+
221
+ key = [date, span_without_dates[:name]]
222
+
223
+ # When any entry in an open range lacks a name, add a random
224
+ # number to the key so that it does not overwrite a different
225
+ # entry in the open range that also lacks a name.
226
+ if in_open_range && !entry[:name]
227
+ key << rand
228
+ end
229
+
230
+ [key, span_without_dates]
231
+ }.to_h
232
+
233
+ daily_spans.merge!(daily_spans_from_entry)
234
+ end
235
+
236
+ # Changes daily spans into normal spans, with chunks of daily spans
237
+ # with contiguous dates compressed into single spans with date ranges.
238
+ # @param daily_spans [Hash{Array(Date, String) => Hash}] one span per
239
+ # date-and-name combination.
240
+ # @return [Array<Hash>]
241
+ def merge_daily_spans(daily_spans)
242
+ daily_spans
243
+ .chunk_while { |((date_a, _name_a), span_a), ((date_b, _name_b), span_b)|
244
+ date_b && date_a && # in case of planned entry
245
+ date_b == date_a.next_day &&
246
+ span_b == span_a
247
+ }.map { |chunked_spans|
248
+ # or chunked_spans.to_h.values.first.first
249
+ span = chunked_spans.first[1]
250
+ first_daily_date = chunked_spans.first[0][0]
251
+ last_daily_date = chunked_spans.last[0][0]
252
+
253
+ unless chunked_spans.first[0][0].nil? # in case of planned entry
254
+ # or chunked_spans.to_h.keys.first[0]..chunked_spans.to_h.keys.last[0]
255
+ span[:dates] = first_daily_date..last_daily_date
256
+ end
257
+
258
+ span[:amount] *= chunked_spans.count
259
+
260
+ span
261
+ }.reject { |span|
262
+ span[:amount].zero?
263
+ }
264
+ end
265
+
266
+ # Builds the date range for the given entry, if any. Also may update
267
+ # the dates in `active` according to the entry's end date.
268
+ # @param entry [Hash] a History entry.
269
+ # @param active [Hash]variables that persist across entries, such as
270
+ # amount and implied date.
271
+ # @param duplicate_open_range [Boolean] whether this entry is a
272
+ # continuation of an open range, in which case it doesn't need to
273
+ # be a range explicitly.
274
+ # @return [Range<Date>]
275
+ def date_range(entry, active, duplicate_open_range: false)
276
+ return nil unless entry[:range] || duplicate_open_range
277
+
278
+ if entry[:end_day]
279
+ active[:open_range] = false
280
+
281
+ end_year = entry[:end_year]&.to_i
282
+ end_month = entry[:end_month]&.to_i
283
+ end_day = entry[:end_day].to_i
284
+
285
+ # Increment year if end month is earlier than start month.
286
+ if active[:month] && end_month && end_month < active[:month]
287
+ end_year ||= active[:year] + 1
288
+ end
289
+
290
+ date_range = Date.new(active[:year], active[:month], active[:day])..
291
+ Date.new(end_year || active[:year], end_month || active[:month], end_day)
292
+
293
+ active[:day] = end_day + 1
294
+ active[:month] = end_month if end_month
295
+ active[:year] = end_year if end_year
296
+ else # either starting or continuing (duplicating) an open range
297
+ active[:open_range] ||= true
298
+ date_range = Date.new(active[:year], active[:month], active[:day])..Date.today
299
+ end
300
+
301
+ date_range
302
+ end
303
+
304
+ # Distributes an amount across the given date(s).
305
+ # @param date_or_range [Date, Range<Date>] the date or range across
306
+ # which the amount will be split up.
307
+ # @param amount [Float, Integer, Reading::Item::TimeLength] amount in
308
+ # pages or time.
309
+ # @param repetitions [Integer] e.g. "x4" in a History entry.
310
+ # @param frequency [Integer] e.g. "/week" in a History entry.
311
+ # @return [Hash{Date => Float, Integer, Reading::Item::TimeLength}]
312
+ def distribute_amount_across_date_range(date_or_range, amount, repetitions, frequency)
313
+ unless amount
314
+ raise InvalidHistoryError, "Missing length or amount"
315
+ end
316
+
317
+ if date_or_range.is_a? Date
318
+ if frequency
319
+ # e.g. " -- x1/week"
320
+ date_range = date_or_range..Date.today
321
+ else
322
+ date_range = date_or_range..date_or_range
323
+ end
324
+ else
325
+ date_range = date_or_range
326
+ end
327
+
328
+ total_amount = amount * repetitions
329
+
330
+ case frequency
331
+ when "month"
332
+ months = date_range.count / AVERAGE_DAYS_IN_A_MONTH
333
+ total_amount *= months
334
+ when "week"
335
+ weeks = date_range.count / 7r
336
+ total_amount *= weeks
337
+ when "day"
338
+ days = date_range.count
339
+ total_amount *= days
340
+ end
341
+
342
+ days ||= date_range.count
343
+ if days.zero?
344
+ raise InvalidHistoryError,
345
+ "Backward date range in the History column: #{date_range}"
346
+ end
347
+
348
+ amount_per_date = (total_amount / days.to_r).to_i_if_whole if total_amount
349
+
350
+ amounts_by_date = date_range.to_a.map { |date|
351
+ [date, amount_per_date]
352
+ }.to_h
353
+
354
+ amounts_by_date
355
+ end
356
+
357
+ # Set each open date range's last end date (wherever it's today, i.e.
358
+ # it wasn't defined) to the day before the next entry's start date.
359
+ # At the same time, distribute each open range's spans evenly.
360
+ # Lastly, remove the :in_open_range key from spans.
361
+ # @param spans [Array<Hash>] spans after being merged from daily_spans.
362
+ # @return [Array<Hash>]
363
+ def fix_open_ranges!(spans)
364
+ chunked_by_open_range = spans.chunk_while { |a, b|
365
+ a[:dates] && b[:dates] && # in case of planned entry
366
+ a[:dates].begin == b[:dates].begin &&
367
+ a[:in_open_range] == b[:in_open_range]
368
+ }
369
+
370
+ next_chunk_start_date = nil
371
+ chunked_by_open_range
372
+ .reverse_each { |chunk|
373
+ unless chunk.first[:in_open_range] && chunk.any? { _1[:dates].end == Date.today }
374
+ # safe nav. in case of planned entry
375
+ next_chunk_start_date = chunk.first[:dates]&.begin
376
+ next
377
+ end
378
+
379
+ # Set last end date.
380
+ if chunk.last[:dates].end == Date.today && next_chunk_start_date
381
+ chunk.last[:dates] = chunk.last[:dates].begin..next_chunk_start_date.prev_day
382
+ end
383
+ next_chunk_start_date = chunk.first[:dates].begin
384
+
385
+ # Distribute spans across the open date range.
386
+ total_amount = chunk.sum { |c| c[:amount] }
387
+ dates = chunk.last[:dates]
388
+ amount_per_day = total_amount / dates.count.to_f
389
+
390
+ # Save the last end date to restore it after it becomes nil below.
391
+ last_end_date = chunk.last[:dates].end
392
+
393
+ span = nil
394
+ amount_acc = 0
395
+ span_needing_end = nil
396
+ dates.each do |date|
397
+ if span_needing_end && amount_acc < amount_per_day
398
+ span_needing_end[:dates] = span_needing_end[:dates].begin..date
399
+ span_needing_end = nil
400
+ end
401
+
402
+ while amount_acc < amount_per_day
403
+ break if chunk.empty?
404
+ span = chunk.shift
405
+ amount_acc += span[:amount]
406
+
407
+ if amount_acc < amount_per_day
408
+ end_date = date
409
+ else
410
+ end_date = nil
411
+ span_needing_end = span
412
+ end
413
+
414
+ span[:dates] = date..end_date
415
+ end
416
+
417
+ amount_acc -= amount_per_day
418
+ end
419
+
420
+ span[:dates] = span[:dates].begin..last_end_date
421
+ }
422
+
423
+ spans.each do |span|
424
+ span.delete(:in_open_range)
425
+ end
426
+ end
427
+
428
+ # Changes amounts taken from progress, from absolute to relative,
429
+ # e.g. at p20 on 2/11 then at p30 on 2/12 (absolute) to
430
+ # 20 pages on 2/11 then 10 pages on 2/12 (relative). Also, remove the
431
+ # :amount_from_progress key key from spans.
432
+ # @param spans [Array<Hash>] spans after being merged from daily_spans.
433
+ # @return [Array<Hash>]
434
+ def relativize_amounts_from_progress!(spans)
435
+ amount_acc = 0
436
+ spans.each do |span|
437
+ if span[:amount_from_progress]
438
+ span[:amount] -= amount_acc
439
+ end
440
+
441
+ amount_acc += span[:amount]
442
+ end
443
+
444
+ spans.each do |span|
445
+ span.delete(:amount_from_progress)
446
+ end
447
+ end
448
+ end
449
+ end
450
+ end
451
+ end
452
+ end