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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +1 -1
- data/lib/feedkit/schedule/constants.rb +3 -3
- data/lib/feedkit/schedule/matching.rb +1 -1
- data/lib/feedkit/schedule/period_start_calculator.rb +56 -113
- data/lib/feedkit/schedule/validation.rb +1 -1
- data/lib/feedkit/schedule.rb +1 -1
- data/lib/feedkit/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4f0ec94f0abe5c6c410bdd27edecee22b20145084f57ae72ea1b5c03c0ca03a4
|
|
4
|
+
data.tar.gz: 5da833a5d1ebd6ad7ea4c460671b55bc2b2c4ecaddc1bc52fbcda5a9ed46fa79
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:` | `
|
|
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 = (
|
|
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 = {
|
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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 +
|
|
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
|
-
|
|
125
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
|
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)
|
data/lib/feedkit/schedule.rb
CHANGED
|
@@ -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
|
|
40
|
+
PeriodStartCalculator.new(schedule: self, time:).call
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def effective_conditions
|
data/lib/feedkit/version.rb
CHANGED