opening_hours_converter 1.15.0 → 1.16.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: 7659377f7e7ef73af1ca26a915754cbef93f4a4e2658c3cbc462cbfa3e2c971b
4
- data.tar.gz: 35a9a9c602b4c6384d77d18d80bfa7b037974aec26df132c69f0811ad1cdc0a5
3
+ metadata.gz: 3b633bae1e9cb5898370921f08cd07dc343b837c7139236883930769b91e3738
4
+ data.tar.gz: 675e149ec88ead4dee904967288dc36f9ea40708c3742f9afab291375c75f33b
5
5
  SHA512:
6
- metadata.gz: 34714ed65dd7b9050b1ca7efd2a9500659b19e1628e0b4acf6568528f17657a3f7ab17a02f5edb4d69bd9648144c8161352f8c849b4d9746071bbc2c61dd1676
7
- data.tar.gz: 17ea0e6894eb017bac3ff00ee64c2972c6322108b522ead40587f4efb46090b7b498eca884f9049450327b81382e9904dba8d430dfa334c01aa7c29b3929ae2d
6
+ metadata.gz: 8348466a3dab6669c2b1ae059dabc1bf32c9ccb81161066f35118a01860e88be85b53e1446e035dfa2f12a76d9c6b3298c7f1b5556fec460feb24f34df850a65
7
+ data.tar.gz: a44a50c9bbc2080d8bc7c31e504149732a47277036ddcaec7e50e5700eb7357259d7e9921175e4508dc468ff25aed6785583fc90792a7346b712d3f97298d78b
@@ -112,6 +112,16 @@ module OpeningHoursConverter
112
112
  datetime_result.sort_by { |a| a[:start] }
113
113
  end
114
114
 
115
+ # Open intervals of an opening hours string inside a concrete window, as
116
+ # [{ start: Time, end: Time }], sorted and clamped to [from, to].
117
+ #
118
+ # Unlike get_time_iterator, this applies "off" rules and intervals crossing
119
+ # midnight, and covers every year the window touches rather than the
120
+ # current one.
121
+ def get_open_intervals(opening_hours_string, from, to)
122
+ OpeningHoursConverter::OpenIntervals.call(opening_hours_string, from, to)
123
+ end
124
+
115
125
  def get_datetime_iterator(date_ranges)
116
126
  result = get_iterator(date_ranges)
117
127
  datetime_result = []
@@ -0,0 +1,193 @@
1
+ require 'opening_hours_converter/constants'
2
+
3
+ module OpeningHoursConverter
4
+ # Projects parsed date ranges onto a concrete window and returns the open
5
+ # intervals it contains.
6
+ #
7
+ # Rules are applied in the order they were parsed, and a later rule overrides
8
+ # an earlier one on the minutes it selects, as the OpenStreetMap
9
+ # specification requires: "Jul-Aug off; Mo-Fr 09:00-17:00" is open in July,
10
+ # while "Mo-Fr 09:00-17:00; Jul-Aug off" is closed.
11
+ class OpenIntervals
12
+ include Constants
13
+ include Utils
14
+
15
+ def self.call(opening_hours_string, from, to)
16
+ date_ranges = OpeningHoursConverter::OpeningHoursParser.new.parse(opening_hours_string)
17
+ new(from, to).apply(date_ranges).intervals
18
+ end
19
+
20
+ def initialize(from, to)
21
+ @from = from
22
+ @to = to
23
+ # One day before the window, so an interval crossing midnight into it is
24
+ # not lost.
25
+ @first_date = from.to_date - 1
26
+ @last_date = to.to_date
27
+ @masks = {}
28
+ @holidays = {}
29
+ end
30
+
31
+ def apply(date_ranges)
32
+ expand(date_ranges).each { |date_range, bounds| apply_date_range(date_range, bounds) }
33
+ self
34
+ end
35
+
36
+ def intervals
37
+ runs.select { |run| run[:end] > @from && run[:start] < @to }
38
+ .map { |run| { start: [run[:start], @from].max, end: [run[:end], @to].min } }
39
+ end
40
+
41
+ private
42
+
43
+ # Week and holiday selectors carry several date ranges rather than one, and
44
+ # WideInterval#to_day resolves them for Time.now.year only. They are
45
+ # expanded here for every year the window touches instead.
46
+ #
47
+ # Returns [date_range, bounds] pairs, bounds being the dates the range is
48
+ # allowed to cover (nil for the whole window).
49
+ def expand(date_ranges)
50
+ date_ranges.flat_map do |date_range|
51
+ case date_range.wide_interval.type
52
+ when 'week' then expand_to_days(date_range, :get_weeks_for_year)
53
+ when 'holiday' then expand_to_days(date_range, :get_public_holidays_for_year)
54
+ else [[date_range, nil]]
55
+ end
56
+ end
57
+ end
58
+
59
+ # An ISO week declared with a year can start in the previous year or end in
60
+ # the next one ("2025 week 1" starts on 2025-12-29), so the neighbouring
61
+ # years are expanded too and their surplus days are cut back by the declared
62
+ # years. Without a declared year the window itself selects the years.
63
+ def expand_to_days(date_range, builder)
64
+ wide_interval = date_range.wide_interval
65
+ bounds = declared_bounds(wide_interval)
66
+ years = if bounds
67
+ (bounds.first.year - 1)..(bounds.last.year + 1)
68
+ else
69
+ @first_date.year..@last_date.year
70
+ end
71
+
72
+ years.flat_map { |year| wide_interval.send(builder, year) }
73
+ .map { |day| [with_range(date_range, day), bounds] }
74
+ end
75
+
76
+ def declared_bounds(wide_interval)
77
+ return nil if wide_interval.start.nil? || wide_interval.start[:year].nil?
78
+
79
+ last_year = wide_interval.end && wide_interval.end[:year] || wide_interval.start[:year]
80
+ Date.new(wide_interval.start[:year], 1, 1)..Date.new(last_year, 12, 31)
81
+ end
82
+
83
+ def with_range(date_range, wide_interval)
84
+ copy = date_range.dup
85
+ copy.update_range(wide_interval)
86
+ copy
87
+ end
88
+
89
+ def apply_date_range(date_range, bounds = nil)
90
+ intervals = date_range.typical.intervals.compact
91
+ return if intervals.empty?
92
+
93
+ weekly = date_range.typical.is_a?(OpeningHoursConverter::Week)
94
+ days = covered_days(date_range)
95
+ days = days.select { |date| bounds.cover?(date) } if bounds
96
+
97
+ days.each do |date|
98
+ intervals.each do |interval|
99
+ next if weekly && !selects?(interval, date)
100
+
101
+ write(date, interval)
102
+ end
103
+ end
104
+ end
105
+
106
+ # A day array keyed by "always" is a yearly pattern and applies to every
107
+ # year of the window; one keyed by a year applies to that year only.
108
+ def covered_days(date_range)
109
+ years = OpeningHoursConverter::Year.build_day_array_from_date_range(date_range, false)
110
+
111
+ (@first_date..@last_date).select do |date|
112
+ months = years[date.year] || years['always']
113
+ next false if months.nil?
114
+
115
+ days = months[date.month - 1]
116
+ # February rows are MONTH_END_DAY long, so Feb 29 follows Feb 28.
117
+ !days.nil? && days[[date.day, days.size].min - 1]
118
+ end
119
+ end
120
+
121
+ def selects?(interval, date)
122
+ return public_holiday?(date) if interval.day_start == PH_WEEKDAY
123
+
124
+ interval.day_start == reindex_sunday_week_to_monday_week(date.wday)
125
+ end
126
+
127
+ def public_holiday?(date)
128
+ @holidays[date.year] ||= OpeningHoursConverter::PublicHoliday.ph_for_year(date.year)
129
+ .map { |holiday| [holiday.month, holiday.day] }
130
+ @holidays[date.year].include?([date.month, date.day])
131
+ end
132
+
133
+ # An interval runs from (day_start, start) to (day_end, end). Only midnight
134
+ # crossings give day_end > day_start, and their remaining minutes are
135
+ # written on the dates that follow.
136
+ def write(date, interval)
137
+ remaining = (interval.day_end - interval.day_start) * MINUTES_MAX + interval.end
138
+ first_minute = interval.start
139
+ current = date
140
+ open = !interval.is_off
141
+
142
+ while remaining > 0
143
+ mask = mask_for(current)
144
+ ([first_minute, 0].max...[remaining, MINUTES_MAX].min).each { |minute| mask[minute] = open } unless mask.nil?
145
+ remaining -= MINUTES_MAX
146
+ first_minute = 0
147
+ current += 1
148
+ end
149
+ end
150
+
151
+ def mask_for(date)
152
+ return nil unless (@first_date..@last_date).cover?(date)
153
+
154
+ @masks[date] ||= Array.new(MINUTES_MAX, false)
155
+ end
156
+
157
+ # Walks the window as a continuous timeline, so a run of open minutes that
158
+ # spans midnight comes out as a single interval.
159
+ def runs
160
+ result = []
161
+ start = nil
162
+
163
+ (@first_date..(@last_date + 1)).each do |date|
164
+ mask = @masks[date]
165
+
166
+ if mask.nil?
167
+ next if start.nil?
168
+
169
+ result << { start: start, end: time_at(date, 0) }
170
+ start = nil
171
+ next
172
+ end
173
+
174
+ (0...MINUTES_MAX).each do |minute|
175
+ if mask[minute]
176
+ start ||= time_at(date, minute)
177
+ elsif start
178
+ result << { start: start, end: time_at(date, minute) }
179
+ start = nil
180
+ end
181
+ end
182
+ end
183
+
184
+ result
185
+ end
186
+
187
+ # Built from wall clock components rather than by adding seconds, so a
188
+ # daylight saving change does not shift the time of day.
189
+ def time_at(date, minute)
190
+ Time.new(date.year, date.month, date.day, minute / 60, minute % 60)
191
+ end
192
+ end
193
+ end
@@ -62,7 +62,7 @@ module OpeningHoursConverter
62
62
  def handle_string
63
63
  type = :string
64
64
  start_index = @index
65
- value = ''
65
+ value = String.new
66
66
 
67
67
  while string? && current_character?
68
68
  value << current_character
@@ -75,7 +75,7 @@ module OpeningHoursConverter
75
75
  def handle_integer
76
76
  type = :integer
77
77
  start_index = @index
78
- value = ''
78
+ value = String.new
79
79
 
80
80
  while integer? && current_character?
81
81
  value << current_character
@@ -680,25 +680,17 @@ module OpeningHoursConverter
680
680
  weeks_as_days = []
681
681
  @indexes.each do |week_index|
682
682
  if week_index.is_a?(Integer)
683
- week = OpeningHoursConverter::WeekIndex.week_from_index(week_index, year)
684
- weeks_as_days << OpeningHoursConverter::WideInterval.new.day(week[:from].day, week[:from].month, week[:from].year,
685
- week[:to].day, week[:to].month, week[:to].year)
683
+ week_as_day(week_index, year, weeks_as_days)
686
684
  else
687
685
  if week_index.key?(:modifier)
688
686
  i = 0
689
687
  (week_index[:from]..week_index[:to]).map do |index|
690
- if i % week_index[:modifier] == 0
691
- week = OpeningHoursConverter::WeekIndex.week_from_index(index, year)
692
- weeks_as_days << OpeningHoursConverter::WideInterval.new.day(week[:from].day, week[:from].month, week[:from].year,
693
- week[:to].day, week[:to].month, week[:to].year)
694
- end
688
+ week_as_day(index, year, weeks_as_days) if i % week_index[:modifier] == 0
695
689
  i += 1
696
690
  end
697
691
  else
698
692
  (week_index[:from]..week_index[:to]).map do |index|
699
- week = OpeningHoursConverter::WeekIndex.week_from_index(index, year)
700
- weeks_as_days << OpeningHoursConverter::WideInterval.new.day(week[:from].day, week[:from].month, week[:from].year,
701
- week[:to].day, week[:to].month, week[:to].year)
693
+ week_as_day(index, year, weeks_as_days)
702
694
  end
703
695
  end
704
696
  end
@@ -706,6 +698,16 @@ module OpeningHoursConverter
706
698
 
707
699
  weeks_as_days
708
700
  end
701
+
702
+ # A year has 52 or 53 ISO weeks, so an index past the last one selects
703
+ # nothing rather than a week borrowed from the next year.
704
+ def week_as_day(index, year, weeks_as_days)
705
+ return if index > OpeningHoursConverter::WeekIndex.week_count(year)
706
+
707
+ week = OpeningHoursConverter::WeekIndex.week_from_index(index, year)
708
+ weeks_as_days << OpeningHoursConverter::WideInterval.new.day(week[:from].day, week[:from].month, week[:from].year,
709
+ week[:to].day, week[:to].month, week[:to].year)
710
+ end
709
711
  def get_public_holidays_for_year(year = Time.now.year)
710
712
  OpeningHoursConverter::PublicHoliday.ph_for_year(year).map do |holiday|
711
713
  OpeningHoursConverter::WideInterval.new.day(holiday.day, holiday.month, holiday.year)
@@ -371,6 +371,21 @@ module OpeningHoursConverter
371
371
  years[wide_interval.start[:year]][wide_interval.start[:month]-1][day] = true
372
372
  end
373
373
  end
374
+ elsif wide_interval.end[:month] < wide_interval.start[:month]
375
+ # A range such as "2025 Dec 26-2025 Jan 13" wraps around New Year and
376
+ # covers both ends of the same year.
377
+ for month in wide_interval.start[:month]-1..11
378
+ first_day = month == wide_interval.start[:month]-1 ? wide_interval.start[:day]-1 : 0
379
+ for day in first_day...MONTH_END_DAY[month]
380
+ years[wide_interval.start[:year]][month][day] = true
381
+ end
382
+ end
383
+ for month in 0..wide_interval.end[:month]-1
384
+ last_day = month == wide_interval.end[:month]-1 ? wide_interval.end[:day]-1 : MONTH_END_DAY[month]-1
385
+ for day in 0..last_day
386
+ years[wide_interval.start[:year]][month][day] = true
387
+ end
388
+ end
374
389
  else
375
390
  for month in wide_interval.start[:month]-1..wide_interval.end[:month]-1
376
391
  if month == wide_interval.start[:month]-1
@@ -12,6 +12,7 @@ module OpeningHoursConverter
12
12
  require_relative './opening_hours_converter/year'
13
13
  require_relative './opening_hours_converter/public_holiday'
14
14
  require_relative './opening_hours_converter/interval'
15
+ require_relative './opening_hours_converter/open_intervals'
15
16
  require_relative './opening_hours_converter/iterator'
16
17
  require_relative './opening_hours_converter/opening_hours_builder'
17
18
  require_relative './opening_hours_converter/opening_hours_date'
data/readme.md CHANGED
@@ -21,6 +21,34 @@ parsed_oh = OpeningHoursConverter::OpeningHoursParser.new.parse('Mo 10:00-12:00'
21
21
  oh_string = OpeningHoursConverter::OpeningHoursBuilder.new.build(parsed_oh)
22
22
  ```
23
23
 
24
+ ### Open intervals over a period
25
+
26
+ `get_open_intervals` returns the concrete opening periods of an opening hours
27
+ string inside a window, as `[{ start: Time, end: Time }]`, sorted by start and
28
+ clamped to `[from, to]`. It is the equivalent of `getOpenIntervals` in
29
+ [opening_hours.js](https://github.com/opening-hours/opening_hours.js).
30
+
31
+ ```ruby
32
+ OpeningHoursConverter::Iterator.new.get_open_intervals(
33
+ 'Mo-Fr 08:00-12:00; Tu off',
34
+ Time.new(2026, 6, 1),
35
+ Time.new(2026, 6, 8)
36
+ )
37
+ # => [{ start: 2026-06-01 08:00, end: 2026-06-01 12:00 },
38
+ # { start: 2026-06-03 08:00, end: 2026-06-03 12:00 },
39
+ # { start: 2026-06-04 08:00, end: 2026-06-04 12:00 },
40
+ # { start: 2026-06-05 08:00, end: 2026-06-05 12:00 }]
41
+ ```
42
+
43
+ Rules are applied in the order they were written, so a later one overrides an
44
+ earlier one: `Jul-Aug off; Mo-Fr 09:00-17:00` is open in July, while
45
+ `Mo-Fr 09:00-17:00; Jul-Aug off` is closed. Intervals crossing midnight end on
46
+ the following day, and `Time` values are built from wall clock components, so a
47
+ daylight saving change does not shift the time of day.
48
+
49
+ Unlike `get_time_iterator`, it applies `off` rules, handles midnight crossings
50
+ and covers every year the window touches rather than the current one.
51
+
24
52
  ## Test
25
53
 
26
54
  Install the dependencies with:
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: opening_hours_converter
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.15.0
4
+ version: 1.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Publidata
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-01-28 00:00:00.000000000 Z
11
+ date: 2026-08-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json
@@ -80,6 +80,7 @@ files:
80
80
  - lib/opening_hours_converter/errors.rb
81
81
  - lib/opening_hours_converter/interval.rb
82
82
  - lib/opening_hours_converter/iterator.rb
83
+ - lib/opening_hours_converter/open_intervals.rb
83
84
  - lib/opening_hours_converter/opening_hours_builder.rb
84
85
  - lib/opening_hours_converter/opening_hours_date.rb
85
86
  - lib/opening_hours_converter/opening_hours_parser.rb