hron 1.0.0 → 2.1.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: 7d473172aff0a9bd7a16cfdc2e43e978b060ab34068c2171eeb0df086a164061
4
- data.tar.gz: 91f5ba0d255821fc2bbb9bc479d4784bf6a29d0b493b4dd1bb2f6a4da284b528
3
+ metadata.gz: 7b86b962f2f1a4ea1f9d78eccd38e02c5a7c98173e1ea246d132f989aaecc832
4
+ data.tar.gz: bf56a14f1874cfdcf5904445dbcacf771c5c5d67cd4e9595eb409a831aab89ec
5
5
  SHA512:
6
- metadata.gz: 5a85275560b5a597f84c92c5c995e7e6ad59bff97f1748c941c9ae61684f131df7416705a1e45264c6c6bac3e0376e15372f69b4f9a3541e2312be1ccd397d51
7
- data.tar.gz: 07dce6bdff312778441771040b89154567f7a70186729f47dadb2bd84e271d07d3f3a8d6cbd3020b3fa92e93ac2e188009adae437bdf9e23b13e5128549ced3f
6
+ metadata.gz: 169fb279979c7ce3a32fd71dac48186d45acb3f73417651a8db376d98034e6d37a072397cfd086d93685b92d7b343eb3fe7c317865b4323c7515dc5124a2a73a
7
+ data.tar.gz: df1da08a823a003f6bcdeb9df1ab72c96c4a6e15b8e6e675d126a1bb93f39f548fc451ba8e57167e0c3b63eb0436f71c7ae9392897f7786645cce01a7e59605e
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # hron
2
2
 
3
- **Human-readable cron** — scheduling expressions that are a superset of what cron can express.
3
+ **Human-readable cron** — scheduling expressions that read like English and convert to and from cron.
4
4
 
5
5
  ```ruby
6
6
  require 'hron'
@@ -28,17 +28,18 @@ require 'hron'
28
28
  # Parse an expression
29
29
  schedule = Hron::Schedule.parse("every weekday at 9:00 except dec 25, jan 1 in America/New_York")
30
30
 
31
- # Get next occurrence
32
- now = Time.now
33
- next_time = schedule.next_from(now)
34
- puts next_time
31
+ # Get the next occurrence, in the schedule's timezone
32
+ now = Time.new(2026, 2, 6, 21, 0, 0, "+09:00") # 07:00 in New York
33
+ puts schedule.next_from(now) # 2026-02-06 09:00:00 -0500
35
34
 
36
- # Get next 5 occurrences
37
- upcoming = schedule.next_n_from(now, 5)
38
- upcoming.each { |t| puts t }
35
+ # Get the next 3 occurrences
36
+ schedule.next_n_from(now, 3).each { |t| puts t }
37
+ # 2026-02-06 09:00:00 -0500
38
+ # 2026-02-09 09:00:00 -0500
39
+ # 2026-02-10 09:00:00 -0500
39
40
 
40
41
  # Check if a time matches
41
- schedule.matches(Time.new(2026, 2, 9, 9, 0, 0)) # true
42
+ schedule.matches(Time.new(2026, 2, 9, 9, 0, 0, "-05:00")) # true: 09:00 in New York
42
43
 
43
44
  # Convert to/from cron
44
45
  simple = Hron::Schedule.parse("every day at 9:00")
@@ -50,48 +51,152 @@ puts from_cron # "every 30 min from 00:00 to 23:59"
50
51
  # Validate without exceptions
51
52
  Hron::Schedule.validate("every day at 9:00") # true
52
53
  Hron::Schedule.validate("invalid") # false
54
+
55
+ # Build from parts, checked as parse checks text
56
+ data = Hron::ScheduleData.new(
57
+ expression: Hron::DayRepeat.new(1, Hron::DayFilterWeekday.new, [Hron::TimeOfDay.new(9, 0)]),
58
+ timezone: "america/new_york"
59
+ )
60
+ puts Hron::Schedule.new(data) # every weekday at 09:00 in America/New_York
61
+ begin
62
+ Hron::Schedule.new(data.with(timezone: "EST"))
63
+ rescue Hron::HronError => e
64
+ puts e.message # timezone must be UTC or an Area/Location name such as America/New_York, got EST
65
+ end
53
66
  ```
54
67
 
55
68
  ## Expression Syntax
56
69
 
57
- See the full [expression reference](https://github.com/prasrvenkat/hron#expression-syntax).
70
+ See the full [expression reference](https://github.com/simpllyf/hron#expression-syntax).
58
71
 
59
72
  ## API
60
73
 
61
74
  ### `Hron::Schedule.parse(input) -> Schedule`
62
- Parse an hron expression string.
75
+ Parse an hron expression string. Raises `Hron::HronError` with `kind` `:lex` or `:parse` when it is invalid (see [Errors](#errors)), and `TypeError` unless `input` is a `String`.
63
76
 
64
77
  ### `Hron::Schedule.from_cron(cron_expr) -> Schedule`
65
- Convert a 5-field cron expression to a Schedule.
78
+ Convert a 5-field cron expression to a Schedule that fires at the same times. This ignores the timezone and DST transitions, where cron schedulers differ. Raises `Hron::HronError` with `kind` `:cron` for invalid cron, for crons that restrict both the day of month and the day of week (`0 9 15 * 1`), and for more than 24 times a day, unless they are evenly spaced on days an interval can carry (`*/7 * * * *` fires 216 times at uneven gaps). Raises `TypeError` unless `cron_expr` is a `String`.
66
79
 
67
80
  ### `Hron::Schedule.validate(input) -> Boolean`
68
- Check if an input string is a valid hron expression.
81
+ `true` when `parse` accepts `input`, `false` when it raises `Hron::HronError`. Raises `TypeError` unless `input` is a `String`.
82
+
83
+ ### `Hron::Schedule.new(data) -> Schedule`
84
+ Build a schedule from a `Hron::ScheduleData` (see [Building a schedule](#building-a-schedule)), checked by the rules `parse` applies, so it evaluates, displays and converts as a parsed one does. Raises `Hron::HronError` with `kind` `:eval` and no `span`, `input` or `suggestion` for the first part that breaks a rule, with the message and in the order of the [spec](https://github.com/simpllyf/hron/blob/main/spec/README.md#schedules-built-in-code), and `TypeError` for a part of the wrong type. The schedule keeps a frozen copy of `data`, so changing `data`'s lists or strings afterwards does not change it.
69
85
 
70
86
  ### `schedule.next_from(now) -> Time | nil`
71
87
  Compute the next occurrence after `now`.
72
88
 
89
+ ### `schedule.previous_from(now) -> Time | nil`
90
+ Compute the most recent occurrence before `now`.
91
+
73
92
  ### `schedule.next_n_from(now, n) -> Array<Time>`
74
- Compute the next `n` occurrences after `now`.
93
+ Compute up to `n` occurrences after `now`: none when `n <= 0`, and every one through the end of the supported range when there are fewer than `n`. Raises `TypeError` when `n` is not an `Integer`.
75
94
 
76
95
  ### `schedule.matches(time) -> Boolean`
77
96
  Check if a time matches this schedule.
78
97
 
98
+ ### `schedule.occurrences(from) -> Enumerator::Lazy<Time>`
99
+ Every occurrence after `from`, computed as it is taken.
100
+
101
+ ### `schedule.between(from, to) -> Enumerator::Lazy<Time>`
102
+ Every occurrence after `from` and up to and including `to`.
103
+
104
+ ### Times
105
+
106
+ Every method takes a `Time` in any zone or offset; only its instant counts, and the methods never change it. Anything else, such as a `String`, `Date`, `DateTime` or `nil`, raises `TypeError`. Every `Time` returned is in the schedule's timezone, with that `TZInfo::Timezone` as its `zone`, or UTC when the schedule has none.
107
+
79
108
  ### `schedule.to_cron -> String`
80
- Convert to a 5-field cron expression. Raises `Hron::HronError` if the schedule can't be expressed as cron.
109
+ Convert to a 5-field cron expression that fires at the same times. Yearly schedules, ordinal weekdays and partial-day intervals convert (`every 15 min from 09:00 to 17:45 on weekday` is `*/15 9-17 * * 1-5`). Raises `Hron::HronError` with `kind` `:cron` when no cron fires at the same times, as for `except`, `until`, `starting`, ISO dates, repeats every `n > 1` days, weeks, months or years, a directional nearest weekday, a `during` that excludes a yearly or named date's month, and times that are not every combination of their minutes and hours (`at 09:00, 17:30`). The timezone is not part of the cron: run it in the schedule's timezone. The [spec](https://github.com/simpllyf/hron/blob/main/spec/README.md#cron-conversion) has the full rules and every error message.
81
110
 
82
111
  ### `schedule.to_s -> String`
83
112
  Render as the canonical string form (roundtrip-safe).
84
113
 
85
- ### `schedule.timezone -> String | nil`
86
- The timezone, if specified.
114
+ ### Getters
115
+
116
+ Each getter returns one part of the schedule, with the classes under [Building a schedule](#building-a-schedule). What they return is frozen, so it cannot change the schedule.
117
+
118
+ | Getter | Returns |
119
+ |---|---|
120
+ | `schedule.expression` | The expression: an `IntervalRepeat`, `DayRepeat`, `WeekRepeat`, `MonthRepeat`, `SingleDateExpr` or `YearRepeat`. |
121
+ | `schedule.timezone` | The IANA timezone name with its canonical capitalization (`in utc` gives `"UTC"`), or `nil`. |
122
+ | `schedule.except` | An `Array` of `NamedException` and `IsoException` in the order written; empty without an `except` clause. |
123
+ | `schedule.until` | An `IsoUntil` or `NamedUntil`, or `nil`. |
124
+ | `schedule.starting` | The `starting` date as a `"YYYY-MM-DD"` `String`, or `nil`. |
125
+ | `schedule.during` | An `Array` of month `Symbol`s in the order written; empty without a `during` clause. |
126
+
127
+ ```ruby
128
+ schedule = Hron::Schedule.parse("every weekday at 9:00 except dec 25 starting 2026-01-05 during jan, feb")
129
+ schedule.expression # #<data Hron::DayRepeat interval=1, days=#<data Hron::DayFilterWeekday>, times=[#<data Hron::TimeOfDay hour=9, minute=0>]>
130
+ schedule.except # [#<data Hron::NamedException month=:dec, day=25>]
131
+ schedule.until # nil
132
+ schedule.starting # "2026-01-05"
133
+ schedule.during # [:jan, :feb]
134
+ schedule.timezone # nil
135
+ ```
136
+
137
+ ### `schedule.data -> ScheduleData`
138
+ All the parts at once, frozen, with the timezone in its IANA capitalization. `Hron::Schedule.new(schedule.data.with(timezone: "UTC"))` builds a changed copy.
139
+
140
+ ### `schedule == other`, `schedule.eql?(other)` and `schedule.hash`
141
+ Schedules are equal when their parts are, with lists compared in order and duplicates included, and equal schedules have equal hashes, so a schedule works as a `Hash` key. `Hron::Schedule.parse("every day at 9:00")` equals `Hron::Schedule.parse("every day at 09:00")` and the schedule built from the same parts. Comparing a schedule with `nil` or anything but a schedule gives `false`.
142
+
143
+ ### Usage errors
144
+
145
+ A value of the wrong type where an argument goes raises `TypeError`, never `Hron::HronError`, and `validate` never returns `false` for it: an input to `parse`, `validate` or `from_cron` that is not a `String` (`nil` included), a time that is not a `Time`, an `n` that is not an `Integer`, a part of the wrong type in `Hron::Schedule.new`, and a message, span, input or suggestion of the wrong type in the [`Hron::HronError` factories](#errors).
146
+
147
+ ## Building a schedule
148
+
149
+ `Hron::ScheduleData.new(expression:, timezone: nil, except: [], until: nil, starting: nil, during: [])` holds a schedule's parts; every class here is a `Data` and takes its fields in this order, positionally or by name. `Hron::Schedule.new` checks them.
150
+
151
+ | Field | Value |
152
+ |---|---|
153
+ | `expression` | One expression below. |
154
+ | `timezone` | `"UTC"` or an IANA `Area/Location` name in any case, or `nil` for UTC. |
155
+ | `except` | An `Array` of `NamedException(month, day)` and `IsoException(date)`; empty means no `except` clause. |
156
+ | `until` | `NamedUntil(month, day)`, which needs `starting`, `IsoUntil(date)`, or `nil`. |
157
+ | `starting` | The `starting` date, or `nil`. |
158
+ | `during` | An `Array` of months; empty means no `during` clause. |
159
+
160
+ | Expression | Fields |
161
+ |---|---|
162
+ | `IntervalRepeat` | `interval`, `unit` (`:min` or `:hours`), `from_time`, `to_time`, `day_filter` (a day filter or `nil`) |
163
+ | `DayRepeat` | `interval`, `days` (a day filter, `DayFilterEvery` when `interval` is above 1), `times` |
164
+ | `WeekRepeat` | `interval`, `days` (an `Array` of weekdays), `times` |
165
+ | `MonthRepeat` | `interval`, `target`: `DaysTarget(specs)` of `SingleDay(day)` and `DayRange(start, end_day)`, `LastDayTarget`, `LastWeekdayTarget`, `NearestWeekdayTarget(day, direction)` with `direction` `nil`, `:next` or `:previous`, or `OrdinalWeekdayTarget(ordinal, weekday)`; `times` |
166
+ | `SingleDateExpr` | `date`: `NamedDate(month, day)` or `IsoDate(date)`; `times` |
167
+ | `YearRepeat` | `interval`, `target`: `YearDateTarget(month, day)`, `YearOrdinalWeekdayTarget(ordinal, weekday, month)`, `YearDayOfMonthTarget(day, month)` or `YearLastWeekdayTarget(month)`; `times` |
168
+
169
+ - `interval`, `day` and the `hour` and `minute` of `TimeOfDay(hour, minute)` are `Integer`s, `times` is an `Array` of `TimeOfDay`, and a date is a `"YYYY-MM-DD"` `String`.
170
+ - A day filter is `DayFilterEvery.new`, `DayFilterWeekday.new`, `DayFilterWeekend.new` or `DayFilterDays(days)`, with `days` an `Array` of weekdays.
171
+ - Names are `Symbol`s: weekdays `:monday` to `:sunday` (`Hron::Weekday::ALL`), months `:jan` to `:dec` (`Hron::MonthName::ALL`), and ordinals `:first` to `:fifth` and `:last` (`Hron::OrdinalPosition::ALL`). `Hron::OrdinalPosition.to_n` gives 1 to 5 for `:first` to `:fifth` and -1 for `:last`.
172
+ - A name of another type, such as `"monday"`, is a `TypeError`; an unknown `Symbol`, such as `:january`, is an `:eval` error (`unknown month :january`), as is any value where an expression, day filter, day spec, target, date, exception or until goes that is none of its classes.
173
+
174
+ ## Errors
175
+
176
+ Every hron error is a `Hron::HronError`, a `StandardError` with:
177
+
178
+ | Member | Value |
179
+ |---|---|
180
+ | `kind` | `:lex` or `:parse` from `parse`, `:eval` from `Hron::Schedule.new` (see [above](#hronschedulenewdata---schedule)), `:cron` from `from_cron` and `to_cron`; the values of `Hron::ErrorKind`. |
181
+ | `message` | The exact message of the [spec](https://github.com/simpllyf/hron/blob/main/spec/README.md#error-message-format). |
182
+ | `span` | A `Hron::Span` with `start` and `end_pos` for `:lex` and `:parse`, otherwise `nil`. |
183
+ | `input` | The input as given for `:lex` and `:parse`, otherwise `nil`. |
184
+ | `suggestion` | A suggested fix for some `:parse` errors, otherwise `nil`. |
185
+ | `display_rich` | `error: {message}`, and for `:lex` and `:parse` the input with carets under the span and any suggestion. |
186
+ | `Hron::HronError.lex(message, span, input)`, `.parse(message, span, input, suggestion: nil)`, `.eval(message)`, `.cron(message)` | Build an error of each kind. Each raises `TypeError` for a `message` or `input` that is not a `String`, a `span` that is not a `Hron::Span`, and a `suggestion` that is neither a `String` nor `nil`. |
187
+
188
+ ```text
189
+ error: until dec 31 has no year: add a starting date, or use an ISO date
190
+ every weekday at 09:00 until dec 31
191
+ ^^^^^^^^^^^^ try: "until dec 31 starting YYYY-MM-DD"
192
+ ```
87
193
 
88
- ### `schedule.expression -> ScheduleExpr`
89
- The underlying schedule expression AST.
194
+ `span.start` and `span.end_pos` mark `[start, end_pos)` in code points, which for a valid UTF-8 string are Ruby's own character indices: `error.input[error.span.start...error.span.end_pos]` is the text the error points at. Each byte of invalid UTF-8 counts as one code point and is reported as `unexpected character U+FFFD`. A binary (`ASCII-8BIT`) string is read as UTF-8 bytes, and a string in any other encoding is converted to UTF-8 first, so a lone UTF-16 surrogate is also U+FFFD.
90
195
 
91
196
  ## Requirements
92
197
 
93
- - Ruby >= 3.2
94
- - TZInfo gem for timezone support
198
+ - Ruby >= 4.0
199
+ - TZInfo gem for timezone support. TZInfo generates DST rules only about 100 years ahead of the current year; past that it keeps the zone's last offset, which is summer time for a southern-hemisphere zone such as Australia/Sydney.
95
200
 
96
201
  ## License
97
202
 
data/hron.gemspec CHANGED
@@ -6,19 +6,19 @@ Gem::Specification.new do |spec|
6
6
  spec.name = "hron"
7
7
  spec.version = Hron::VERSION
8
8
  spec.authors = ["Prasanna Venkataraman"]
9
- spec.email = ["prasrvenkat@gmail.com"]
9
+ spec.email = ["pras@simpllyf.io"]
10
10
 
11
- spec.summary = "Human-readable cron — a scheduling expression language that is a superset of cron"
11
+ spec.summary = "Human-readable cron — scheduling expressions that read like English and convert to and from cron"
12
12
  spec.description = "hron (human-readable cron) is a scheduling expression language " \
13
- "that is designed to be easy to read, write, and understand. It is a superset of cron, " \
14
- "meaning any valid cron expression can be converted to and from hron."
15
- spec.homepage = "https://github.com/prasrvenkat/hron"
13
+ "that is designed to be easy to read, write, and understand. It converts to and from cron " \
14
+ "exactly where both can express a schedule, and expresses schedules cron cannot."
15
+ spec.homepage = "https://hron.io"
16
16
  spec.license = "MIT"
17
17
  spec.required_ruby_version = ">= 4.0.0"
18
18
 
19
19
  spec.metadata["homepage_uri"] = spec.homepage
20
- spec.metadata["source_code_uri"] = "https://github.com/prasrvenkat/hron"
21
- spec.metadata["changelog_uri"] = "https://github.com/prasrvenkat/hron/releases"
20
+ spec.metadata["source_code_uri"] = "https://github.com/simpllyf/hron"
21
+ spec.metadata["changelog_uri"] = "https://github.com/simpllyf/hron/releases"
22
22
  spec.metadata["rubygems_mfa_required"] = "true"
23
23
 
24
24
  spec.files = Dir.chdir(__dir__) do
@@ -32,8 +32,4 @@ Gem::Specification.new do |spec|
32
32
  spec.require_paths = ["lib"]
33
33
 
34
34
  spec.add_dependency "tzinfo", "~> 2.0"
35
-
36
- spec.add_development_dependency "minitest", "~> 5.20"
37
- spec.add_development_dependency "rake", "~> 13.0"
38
- spec.add_development_dependency "standard", "~> 1.43"
39
35
  end
data/lib/hron/ast.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hron
4
- # Weekday enumeration (ISO 8601: Monday=1, Sunday=7)
5
4
  module Weekday
6
5
  MONDAY = :monday
7
6
  TUESDAY = :tuesday
@@ -37,10 +36,12 @@ module Hron
37
36
  "sunday" => SUNDAY, "sun" => SUNDAY
38
37
  }.freeze
39
38
 
39
+ # ISO 8601: Monday=1, Sunday=7.
40
40
  def self.number(day)
41
41
  NUMBERS[day]
42
42
  end
43
43
 
44
+ # Sunday=0 … Saturday=6.
44
45
  def self.cron_dow(day)
45
46
  CRON_DOW[day]
46
47
  end
@@ -58,7 +59,6 @@ module Hron
58
59
  end
59
60
  end
60
61
 
61
- # Month name enumeration
62
62
  module MonthName
63
63
  JAN = :jan
64
64
  FEB = :feb
@@ -101,6 +101,15 @@ module Hron
101
101
  NUMBERS[month]
102
102
  end
103
103
 
104
+ # A named date has no year, so February has 29.
105
+ def self.max_day(month)
106
+ case month
107
+ when FEB then 29
108
+ when APR, JUN, SEP, NOV then 30
109
+ else 31
110
+ end
111
+ end
112
+
104
113
  def self.from_number(n)
105
114
  NUMBER_TO_MONTH[n]
106
115
  end
@@ -114,17 +123,17 @@ module Hron
114
123
  end
115
124
  end
116
125
 
117
- # Interval unit (minutes or hours)
118
126
  module IntervalUnit
119
127
  MIN = :min
120
128
  HOURS = :hours
121
129
 
130
+ ALL = [MIN, HOURS].freeze
131
+
122
132
  def self.to_s(unit)
123
133
  unit.to_s
124
134
  end
125
135
  end
126
136
 
127
- # Ordinal position (first, second, etc.)
128
137
  module OrdinalPosition
129
138
  FIRST = :first
130
139
  SECOND = :second
@@ -133,10 +142,13 @@ module Hron
133
142
  FIFTH = :fifth
134
143
  LAST = :last
135
144
 
145
+ ALL = [FIRST, SECOND, THIRD, FOURTH, FIFTH, LAST].freeze
146
+
136
147
  TO_N = {
137
- FIRST => 1, SECOND => 2, THIRD => 3, FOURTH => 4, FIFTH => 5
148
+ FIRST => 1, SECOND => 2, THIRD => 3, FOURTH => 4, FIFTH => 5, LAST => -1
138
149
  }.freeze
139
150
 
151
+ # 1-5 for first to fifth, and -1 for last.
140
152
  def self.to_n(ord)
141
153
  TO_N[ord]
142
154
  end
@@ -146,64 +158,48 @@ module Hron
146
158
  end
147
159
  end
148
160
 
149
- # Time of day (hour and minute)
150
161
  TimeOfDay = Data.define(:hour, :minute) do
151
162
  def to_s
152
163
  format("%02d:%02d", hour, minute)
153
164
  end
154
165
  end
155
166
 
156
- # --- Day filter variants ---
157
-
158
167
  DayFilterEvery = Data.define
159
168
  DayFilterWeekday = Data.define
160
169
  DayFilterWeekend = Data.define
161
- DayFilterDays = Data.define(:days) # days: Array<Weekday>
162
-
163
- # --- Day of month spec ---
170
+ DayFilterDays = Data.define(:days)
164
171
 
165
172
  SingleDay = Data.define(:day)
166
173
  DayRange = Data.define(:start, :end_day) # end_day to avoid Ruby keyword
167
174
 
168
- # --- Direction for nearest weekday ---
169
-
170
175
  module NearestDirection
171
176
  NEXT = :next
172
177
  PREVIOUS = :previous
173
- end
174
178
 
175
- # --- Month target variants ---
179
+ ALL = [NEXT, PREVIOUS].freeze
180
+ end
176
181
 
177
- DaysTarget = Data.define(:specs) # specs: Array<DayOfMonthSpec>
182
+ DaysTarget = Data.define(:specs)
178
183
  LastDayTarget = Data.define
179
184
  LastWeekdayTarget = Data.define
180
- NearestWeekdayTarget = Data.define(:day, :direction) # day: 1-31, direction: nil or NearestDirection
185
+ # A nil direction stays in the month, as cron W does; a direction can cross into the adjacent month.
186
+ NearestWeekdayTarget = Data.define(:day, :direction)
181
187
  OrdinalWeekdayTarget = Data.define(:ordinal, :weekday)
182
188
 
183
- # --- Year target variants ---
184
-
185
189
  YearDateTarget = Data.define(:month, :day)
186
190
  YearOrdinalWeekdayTarget = Data.define(:ordinal, :weekday, :month)
187
191
  YearDayOfMonthTarget = Data.define(:day, :month)
188
192
  YearLastWeekdayTarget = Data.define(:month)
189
193
 
190
- # --- Date spec variants ---
191
-
192
194
  NamedDate = Data.define(:month, :day)
193
- IsoDate = Data.define(:date) # date: String (YYYY-MM-DD)
194
-
195
- # --- Exception spec variants ---
195
+ IsoDate = Data.define(:date)
196
196
 
197
197
  NamedException = Data.define(:month, :day)
198
198
  IsoException = Data.define(:date)
199
199
 
200
- # --- Until spec variants ---
201
-
202
200
  IsoUntil = Data.define(:date)
203
201
  NamedUntil = Data.define(:month, :day)
204
202
 
205
- # --- Schedule expression variants ---
206
-
207
203
  IntervalRepeat = Data.define(:interval, :unit, :from_time, :to_time, :day_filter)
208
204
  DayRepeat = Data.define(:interval, :days, :times)
209
205
  WeekRepeat = Data.define(:interval, :days, :times)
@@ -211,33 +207,11 @@ module Hron
211
207
  SingleDateExpr = Data.define(:date, :times)
212
208
  YearRepeat = Data.define(:interval, :target, :times)
213
209
 
214
- # --- Schedule data (top-level) ---
215
-
216
- ScheduleData = Data.define(:expr, :timezone, :except, :until, :anchor, :during) do
217
- def initialize(expr:, timezone: nil, except: [], until: nil, anchor: nil, during: [])
210
+ # The parts of a schedule, for Schedule.new to check. starting is a YYYY-MM-DD String; an
211
+ # empty except or during list is no clause.
212
+ ScheduleData = Data.define(:expression, :timezone, :except, :until, :starting, :during) do
213
+ def initialize(expression:, timezone: nil, except: [], until: nil, starting: nil, during: [])
218
214
  super
219
215
  end
220
216
  end
221
-
222
- # --- Helper functions ---
223
-
224
- def self.expand_day_spec(spec)
225
- case spec
226
- when SingleDay
227
- [spec.day]
228
- when DayRange
229
- (spec.start..spec.end_day).to_a
230
- else
231
- []
232
- end
233
- end
234
-
235
- def self.expand_month_target(target)
236
- case target
237
- when DaysTarget
238
- target.specs.flat_map { |spec| expand_day_spec(spec) }
239
- else
240
- []
241
- end
242
- end
243
217
  end