feedkit 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74138d59abacb0faf60572624e29afc713f1fdf3e1fed0c553206cc86e357d71
4
- data.tar.gz: dd88f9f5876b4e65ea21c92aad49aa351c7359aebd00f41b78ff479fa4710bb6
3
+ metadata.gz: 4f0ec94f0abe5c6c410bdd27edecee22b20145084f57ae72ea1b5c03c0ca03a4
4
+ data.tar.gz: 5da833a5d1ebd6ad7ea4c460671b55bc2b2c4ecaddc1bc52fbcda5a9ed46fa79
5
5
  SHA512:
6
- metadata.gz: a2b97d983d035764a00b2dda527710269644737e5812e2bcdb4c119d3b03c6e7f9c1e1482ed3caa9b0a7a201aa58c292dd29394281b501d3b01a30411ba6145e
7
- data.tar.gz: 805e9ed65e6fb34adbd42cdd8a0a36d6b5be2742605b1d76d6950782932f1c79c5538fd88b85e3f55d1cb7a581685a627fb1c71b381b3ac3b53e938e5670c33b
6
+ metadata.gz: 06225bdd9489fd9047a270af79eb88d9a1243c7787c77fdfcc1cdcc7d33c5888efdcb33511433840603d856efc1d77c028fc45ac1c08239bd39d304aa3dbf884
7
+ data.tar.gz: 19860c221a55b164880d2d8b28374e99df665ee0ef3ba2eba4f5270a9c6d9af359209ef6b62b9b92a2b39de406e1c135bcd74abc450154bcae69e1bee21814eb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-02-10
4
+
5
+ ### Changed
6
+
7
+ - **Breaking:** Weekday numbering switched from Ruby `wday` (0=Sunday..6=Saturday) to ISO `cwday` (1=Monday..7=Sunday)
8
+ - Simplified `PeriodStartCalculator`: removed `unit` parameter, unified `window_bounds`, and inlined single-use helpers
9
+
3
10
  ## [0.2.0] - 2026-02-09
4
11
 
5
12
  ### Added
data/README.md CHANGED
@@ -253,7 +253,7 @@ Conditions are AND-ed together. All must match for the schedule to fire.
253
253
  |-----------|--------|---------|
254
254
  | `hour:` | `0..23` | `hour: 6`, `hour: [6, 12, 18]`, `hour: 9..17` |
255
255
  | `day:` | `1..31`, `:first`, `:last` | `day: 1`, `day: :last`, `day: 1..15` |
256
- | `weekday:` | `0..6` (Ruby/Rails `wday`: `0` = Sunday), `:sunday`..`:saturday` | `weekday: :monday`, `weekday: :monday..:friday` |
256
+ | `weekday:` | `1..7` (ISO: Monday = `1`, Sunday = `7`), `:monday`..`:sunday` | `weekday: :monday`, `weekday: :monday..:friday` |
257
257
  | `week:` | `:odd`, `:even` (ISO week parity, `Date#cweek`) | `week: :odd` |
258
258
  | `month:` | `1..12`, `:january`..`:december` | `month: :january`, `month: :january..:march` |
259
259
 
@@ -8,20 +8,20 @@ module Feedkit
8
8
 
9
9
  VALID_HOUR_RANGE = (0..23)
10
10
  VALID_DAY_RANGE = (1..31)
11
- VALID_WEEKDAY_RANGE = (0..6)
11
+ VALID_WEEKDAY_RANGE = (1..7)
12
12
  VALID_MONTH_RANGE = (1..12)
13
13
 
14
14
  SYMBOLIC_DAY_VALUES = %i[first last].freeze
15
15
  SYMBOLIC_WEEK_VALUES = %i[odd even].freeze
16
16
 
17
17
  WEEKDAYS = {
18
- sunday: 0,
19
18
  monday: 1,
20
19
  tuesday: 2,
21
20
  wednesday: 3,
22
21
  thursday: 4,
23
22
  friday: 5,
24
- saturday: 6
23
+ saturday: 6,
24
+ sunday: 7
25
25
  }.freeze
26
26
 
27
27
  MONTHS = {
@@ -16,7 +16,7 @@ module Feedkit
16
16
  case type
17
17
  when :hour then time.hour
18
18
  when :day then time.day
19
- when :weekday then time.wday
19
+ when :weekday then time.to_date.cwday
20
20
  when :week then time.to_date.cweek
21
21
  when :month then time.month
22
22
  end
@@ -13,12 +13,13 @@ module Feedkit
13
13
  class PeriodStartCalculator # rubocop:disable Metrics/ClassLength
14
14
  include Feedkit::Schedule::Normalization
15
15
 
16
+ # Safety limit to prevent infinite iteration. 600 weeks ≈ 11.5 years,
17
+ # far beyond any realistic schedule lookback.
16
18
  MAX_WINDOWS = 600
17
19
 
18
- def initialize(schedule:, time:, unit:)
20
+ def initialize(schedule:, time:)
19
21
  @schedule = schedule
20
22
  @time = time
21
- @unit = unit
22
23
  end
23
24
 
24
25
  def call
@@ -27,22 +28,24 @@ module Feedkit
27
28
  candidate = find_windowed_candidate
28
29
  return candidate if candidate
29
30
 
30
- raise ArgumentError,
31
- "No schedule occurrence found for #{schedule.period.inspect} / #{schedule.conditions.inspect}"
31
+ raise ArgumentError, "No schedule occurrence found for #{unit.inspect} / #{schedule.conditions.inspect}"
32
32
  end
33
33
 
34
34
  private
35
35
 
36
- attr_reader :schedule, :time, :unit
36
+ attr_reader :schedule, :time
37
+
38
+ def unit
39
+ schedule.period
40
+ end
37
41
 
38
42
  def find_windowed_candidate
39
43
  cursor = time
40
44
 
41
45
  MAX_WINDOWS.times do
42
46
  window_start, window_end = window_bounds(cursor)
43
- upper_bound = time < window_end ? time : window_end - 1.second
44
47
 
45
- candidate = latest_candidate_in_window(window_start, window_end, upper_bound)
48
+ candidate = tick_candidates_for_window(window_start, window_end).reverse_each.find { |t| t <= time }
46
49
  return candidate if candidate
47
50
 
48
51
  cursor = window_start - 1.second
@@ -51,9 +54,11 @@ module Feedkit
51
54
  nil
52
55
  end
53
56
 
54
- def latest_candidate_in_window(window_start, window_end, upper_bound)
55
- candidates = tick_candidates_for_window(window_start, window_end)
56
- candidates.reverse_each.find { |t| t <= upper_bound }
57
+ def window_bounds(cursor_time)
58
+ args = unit == :week ? [:monday] : []
59
+ start_time = cursor_time.public_send(:"beginning_of_#{unit}", *args)
60
+ end_time = cursor_time.public_send(:"end_of_#{unit}", *args)
61
+ [start_time, end_time]
57
62
  end
58
63
 
59
64
  def tick_candidates_for_window(window_start, window_end)
@@ -61,53 +66,12 @@ module Feedkit
61
66
  dates.flat_map { |date| tick_candidates_for_date(window_start, window_end, date) }.sort
62
67
  end
63
68
 
64
- def tick_candidates_for_date(window_start, window_end, date)
65
- candidate_hours.filter_map do |hour|
66
- candidate = build_candidate_time(window_start, date, hour)
67
- next unless candidate
68
- next unless candidate >= window_start && candidate < window_end
69
- next unless schedule.due?(candidate)
70
-
71
- candidate
72
- end
73
- end
74
-
75
- def build_candidate_time(window_start, date, hour)
76
- window_start.change(
77
- year: date.year,
78
- month: date.month,
79
- day: date.day,
80
- hour:,
81
- min: 0,
82
- sec: 0
83
- )
84
- rescue TZInfo::PeriodNotFound, TZInfo::AmbiguousTime
85
- # In DST transitions, some local times may not exist or may be ambiguous
86
- # depending on the time zone rules. If Rails can't construct a stable
87
- # TimeWithZone for the candidate tick, skip this candidate.
88
- nil
89
- end
90
-
91
- def candidate_hours
92
- value = schedule.effective_conditions[:hour]
93
- return [0] unless value
94
-
95
- hours = case value
96
- when Range then value.to_a
97
- when Array then value
98
- else [value]
99
- end
100
-
101
- hours.map(&:to_i).uniq.sort
102
- end
103
-
104
69
  def candidate_dates_for_window(window_start)
105
70
  case unit
106
71
  when :day then candidate_day_dates(window_start)
107
72
  when :week then candidate_week_dates(window_start)
108
73
  when :month then candidate_month_dates(window_start)
109
74
  when :year then candidate_year_dates(window_start)
110
- else raise ArgumentError, "Unknown unit: #{unit.inspect}"
111
75
  end
112
76
  end
113
77
 
@@ -117,95 +81,74 @@ module Feedkit
117
81
 
118
82
  def candidate_week_dates(window_start)
119
83
  week_start = window_start.to_date # Monday (ISO week start)
120
- candidate_weekdays.map { |wday| week_start + weekday_offset_from_monday(wday) }.uniq.sort
84
+ candidate_weekdays.map { |wday| week_start + (wday - 1) }.uniq.sort
121
85
  end
122
86
 
123
87
  def candidate_month_dates(window_start)
124
- month_time = window_start
125
- candidate_days_in_month(month_time).filter_map do |day|
126
- safe_date(month_time.year, month_time.month, day)
88
+ candidate_days_in_month(window_start).filter_map do |day|
89
+ safe_date(window_start.year, window_start.month, day)
127
90
  end.uniq.sort
128
91
  end
129
92
 
130
93
  def candidate_year_dates(window_start)
131
- year = window_start.year
132
94
  candidate_months.flat_map do |month|
133
95
  month_time = window_start.change(month:, day: 1)
134
- candidate_days_in_month(month_time).filter_map { |day| safe_date(year, month, day) }
96
+ candidate_days_in_month(month_time).filter_map { |day| safe_date(window_start.year, month, day) }
135
97
  end.uniq.sort
136
98
  end
137
99
 
138
- def safe_date(year, month, day)
139
- Date.new(year, month, day)
140
- rescue Date::Error
100
+ def tick_candidates_for_date(window_start, window_end, date)
101
+ candidate_hours.filter_map do |hour|
102
+ candidate = build_candidate_time(window_start, date, hour)
103
+ next unless candidate
104
+ next unless candidate.between?(window_start, window_end)
105
+ next unless schedule.due?(candidate)
106
+
107
+ candidate
108
+ end
109
+ end
110
+
111
+ def build_candidate_time(window_start, date, hour)
112
+ window_start.change(
113
+ year: date.year,
114
+ month: date.month,
115
+ day: date.day,
116
+ hour:,
117
+ min: 0,
118
+ sec: 0
119
+ )
120
+ rescue TZInfo::PeriodNotFound, TZInfo::AmbiguousTime
121
+ # In DST transitions, some local times may not exist or may be ambiguous
122
+ # depending on the time zone rules. If Rails can't construct a stable
123
+ # TimeWithZone for the candidate tick, skip this candidate.
141
124
  nil
142
125
  end
143
126
 
127
+ def candidate_hours
128
+ value = schedule.effective_conditions[:hour]
129
+ Array(value.is_a?(Range) ? value.to_a : value).map(&:to_i).uniq.sort
130
+ end
131
+
144
132
  def candidate_weekdays
145
133
  value = schedule.effective_conditions[:weekday]
146
- return [WEEKDAYS.fetch(:monday)] unless value
147
-
148
134
  normalize_weekday_value_list(value).uniq.sort
149
135
  end
150
136
 
151
- def candidate_months
152
- value = schedule.effective_conditions[:month]
153
- return [MONTHS.fetch(:january)] unless value
154
-
155
- normalize_month_value_list(value).uniq.sort
156
- end
157
-
158
137
  def candidate_days_in_month(time)
159
138
  value = schedule.effective_conditions[:day]
160
- return [1] unless value
161
-
162
139
  normalize_day_value_list(value, time).uniq.sort
163
140
  end
164
141
 
165
- def weekday_offset_from_monday(wday)
166
- (wday.to_i - 1) % 7
167
- end
168
-
169
- def window_bounds(cursor_time)
170
- case unit
171
- when :day then day_window_bounds(cursor_time)
172
- when :week then week_window_bounds(cursor_time)
173
- when :month then month_window_bounds(cursor_time)
174
- when :year then year_window_bounds(cursor_time)
175
- else raise ArgumentError, "Unknown unit: #{unit.inspect}"
176
- end
177
- end
178
-
179
- def day_window_bounds(cursor_time)
180
- start_time = cursor_time.beginning_of_day
181
- [start_time, start_time + 1.day]
182
- end
183
-
184
- def week_window_bounds(cursor_time)
185
- start_date = iso_week_start_date(cursor_time.to_date)
186
- start_time = cursor_time.change(
187
- year: start_date.year,
188
- month: start_date.month,
189
- day: start_date.day,
190
- hour: 0,
191
- min: 0,
192
- sec: 0
193
- )
194
- [start_time, start_time + 1.week]
195
- end
196
-
197
- def month_window_bounds(cursor_time)
198
- start_time = cursor_time.beginning_of_month
199
- [start_time, start_time + 1.month]
200
- end
201
-
202
- def year_window_bounds(cursor_time)
203
- start_time = cursor_time.beginning_of_year
204
- [start_time, start_time + 1.year]
142
+ def candidate_months
143
+ value = schedule.effective_conditions[:month]
144
+ normalize_month_value_list(value).uniq.sort
205
145
  end
206
146
 
207
- def iso_week_start_date(date)
208
- date - (date.cwday - 1)
147
+ # Returns nil for invalid dates like Feb 30 instead of raising.
148
+ def safe_date(year, month, day)
149
+ Date.new(year, month, day)
150
+ rescue Date::Error
151
+ nil
209
152
  end
210
153
  end
211
154
  end
@@ -70,7 +70,7 @@ module Feedkit
70
70
  return if value.is_a?(Integer) && VALID_WEEKDAY_RANGE.cover?(value)
71
71
 
72
72
  raise ArgumentError,
73
- "Invalid weekday value: #{value}. Must be integer 0-6 or symbol (#{WEEKDAYS.keys.join(", ")})"
73
+ "Invalid weekday value: #{value}. Must be integer 1-7 or symbol (#{WEEKDAYS.keys.join(", ")})"
74
74
  end
75
75
 
76
76
  def validate_week_value!(value)
@@ -37,7 +37,7 @@ module Feedkit
37
37
  # based on schedule boundaries rather than a sliding window (e.g. `1.day.ago`).
38
38
  def period_start_at(time = Time.current)
39
39
  time = normalize_time(time)
40
- PeriodStartCalculator.new(schedule: self, time:, unit: period).call
40
+ PeriodStartCalculator.new(schedule: self, time:).call
41
41
  end
42
42
 
43
43
  def effective_conditions
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Feedkit
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: feedkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ali Hamdi Ali Fadel