power-hours 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 120c0ef0265c61867c8bcf0d6506442e50f4ecad7037b3663f3f355dfc9fe605
4
+ data.tar.gz: 3e39ab4635b914147372f6ad1aad6d03bd5c9f20006d5262fa7cb9b8f9b62152
5
+ SHA512:
6
+ metadata.gz: '09bbc89cd542d4231045c51cdeb34b0cc585752c5e91c81f4ffcfcbc6ec52753731639c22c21e319868342744223eb24b4753b6efac584bc394197219526886c'
7
+ data.tar.gz: a6553f5ac3a66a4fc637c1c470eee3c36dd9fe648c88d19a7d0ea71e90e107ca0781cc653658905f89223b79f5ad8e2d20244048d6e337cb0d6795469306e467
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Max Power
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # Power Hours
2
+
3
+ Power Hours provides a small, explicit DSL for weekly opening-hours schedules.
4
+
5
+ It supports:
6
+ - multiple windows per day
7
+ - overnight windows (for example `22:00-02:00`)
8
+ - serialization to/from hashes
9
+ - optional model mixin helpers via `OpeningHours::Model`
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ bundle add power-hours
15
+ ```
16
+
17
+ or
18
+
19
+ ```bash
20
+ gem install power-hours
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ### Build a schedule
26
+
27
+ ```ruby
28
+ require "opening_hours"
29
+
30
+ schedule = OpeningHours::Schedule.build do
31
+ mon "09:00".."12:00", "13:00".."17:00"
32
+ fri "22:00".."02:00"
33
+ end
34
+
35
+ schedule.open?(at: Time.new(2024, 1, 1, 10, 0, 0)) # => true (Monday)
36
+ schedule.open?(at: Time.new(2024, 1, 6, 1, 0, 0)) # => true (Saturday, from Friday overnight)
37
+ ```
38
+
39
+ ### Construct directly from hash data
40
+
41
+ ```ruby
42
+ schedule = OpeningHours::Schedule.from_hash(
43
+ "mon" => ["09:00-17:00"],
44
+ "fri" => ["22:00-02:00"]
45
+ )
46
+ ```
47
+
48
+ ### Serialize for persistence
49
+
50
+ ```ruby
51
+ schedule.to_h
52
+ # {
53
+ # sun: [],
54
+ # mon: ["09:00-17:00"],
55
+ # tue: [],
56
+ # wed: [],
57
+ # thu: [],
58
+ # fri: ["22:00-02:00"],
59
+ # sat: []
60
+ # }
61
+
62
+ schedule.as_json
63
+ # {
64
+ # mon: ["09:00-17:00"],
65
+ # fri: ["22:00-02:00"]
66
+ # }
67
+
68
+ schedule.as_json(include_empty: true) # include empty weekdays too
69
+ ```
70
+
71
+ ### Optional mixin for app models
72
+
73
+ `OpeningHours::Model` is a Rails integration mixin (uses typed attributes).
74
+
75
+ ```ruby
76
+ class Venue < ApplicationRecord
77
+ include OpeningHours::Model
78
+ # expects an `opening_hours` attribute (for example json/jsonb)
79
+ end
80
+
81
+ venue = Venue.new
82
+ venue.define_hours do
83
+ mon "09:00".."17:00"
84
+ end
85
+
86
+ venue.open?(at: Time.new(2024, 1, 1, 10, 0, 0)) # => true
87
+ ```
88
+
89
+ ### Use a custom backing column
90
+
91
+ ```ruby
92
+ class Venue < ApplicationRecord
93
+ include OpeningHours::Model
94
+ opening_hours_column :business_hours
95
+ end
96
+ ```
97
+
98
+ ### Use the value type directly
99
+
100
+ ```ruby
101
+ class Venue < ApplicationRecord
102
+ attribute :opening_hours, OpeningHours::Type.new
103
+ end
104
+ ```
105
+
106
+ ## Development
107
+
108
+ ```bash
109
+ bin/setup
110
+ bundle exec rake test
111
+ bundle exec gem build power-hours.gemspec
112
+ ```
113
+
114
+ ## License
115
+
116
+ Released under the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ task default: :test
@@ -0,0 +1,275 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Opening Hours Multi-Window Test</title>
7
+ <style>
8
+ :root {
9
+ --paper: #f8fafc;
10
+ --ink: #1f2937;
11
+ --muted: #6b7280;
12
+ --line: #e5e7eb;
13
+ --panel: #ffffff;
14
+ --accent: #4f46e5;
15
+ --danger: #b91c1c;
16
+ }
17
+
18
+ * {
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ body {
23
+ margin: 0;
24
+ padding: 16px 12px;
25
+ font-family: "Avenir Next", "Segoe UI", sans-serif;
26
+ color: var(--ink);
27
+ background: var(--paper);
28
+ accent-color: var(--accent);
29
+ }
30
+
31
+ .playground-grid {
32
+ max-width: 980px;
33
+ margin: 0 auto;
34
+ display: grid;
35
+ grid-template-columns: repeat(2, minmax(0, 1fr));
36
+ gap: 12px;
37
+ align-items: start;
38
+ }
39
+
40
+ .hours-editor {
41
+ background: var(--panel);
42
+ border: 1px solid var(--line);
43
+ border-radius: 10px;
44
+ padding: 12px;
45
+ box-shadow: 0 3px 10px rgba(17, 24, 39, 0.06);
46
+ }
47
+
48
+ .editor-header {
49
+ margin-bottom: 10px;
50
+ border-bottom: 1px solid var(--line);
51
+ padding-bottom: 8px;
52
+ }
53
+
54
+ .editor-header h2 {
55
+ margin: 0;
56
+ font-size: 1.05rem;
57
+ letter-spacing: 0.01em;
58
+ }
59
+
60
+ .editor-header p {
61
+ margin: 4px 0 0;
62
+ color: var(--muted);
63
+ font-size: 0.82rem;
64
+ }
65
+
66
+ .day-card {
67
+ border-bottom: 1px solid var(--line);
68
+ padding: 8px 0;
69
+ margin-bottom: 0;
70
+ background: transparent;
71
+ }
72
+
73
+ .day-grid {
74
+ display: grid;
75
+ grid-template-columns: 120px minmax(0, 1fr);
76
+ gap: 8px;
77
+ align-items: start;
78
+ }
79
+
80
+ .day-label {
81
+ font-weight: 700;
82
+ text-transform: none;
83
+ font-size: 0.78rem;
84
+ letter-spacing: 0.01em;
85
+ color: #374151;
86
+ }
87
+
88
+ .day-content {
89
+ min-width: 0;
90
+ }
91
+
92
+ .window-row {
93
+ display: flex;
94
+ width: fit-content;
95
+ align-items: stretch;
96
+ gap: 0;
97
+ margin-bottom: 6px;
98
+ border-radius: 7px;
99
+ }
100
+
101
+ .time-range {
102
+ display: inline-flex;
103
+ align-items: center;
104
+ border: 1px solid #cbd5e1;
105
+ border-right: 0;
106
+ border-radius: 7px 0 0 7px;
107
+ background: #fff;
108
+ overflow: hidden;
109
+ }
110
+
111
+ .time-range input[type="time"] {
112
+ width: fit-content;
113
+ padding: 5px 6px;
114
+ border: 0;
115
+ border-radius: 0;
116
+ background: transparent;
117
+ font-family: "SFMono-Regular", Consolas, monospace;
118
+ font-size: 0.78rem;
119
+ }
120
+
121
+ .window-row:focus-within {
122
+ box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2);
123
+ }
124
+
125
+ .window-row:focus-within .time-range,
126
+ .window-row:focus-within .btn-remove {
127
+ border-color: var(--accent);
128
+ }
129
+
130
+ .window-sep {
131
+ padding: 0 2px;
132
+ color: #6b7280;
133
+ font-weight: 700;
134
+ }
135
+
136
+ .btn {
137
+ border: 1px solid transparent;
138
+ border-radius: 6px;
139
+ padding: 4px 8px;
140
+ cursor: pointer;
141
+ font-size: 0.72rem;
142
+ font-weight: 600;
143
+ }
144
+
145
+ .btn-add {
146
+ border: 1px solid #cbd5e1;
147
+ border-radius: 999px;
148
+ background: #fff;
149
+ padding: 2px 8px;
150
+ color: #4b5563;
151
+ font-size: 0.74rem;
152
+ font-weight: 600;
153
+ text-decoration: none;
154
+ }
155
+
156
+ .btn-add:hover {
157
+ color: #374151;
158
+ border-color: #9ca3af;
159
+ background: #f9fafb;
160
+ }
161
+
162
+ .btn-remove {
163
+ padding: 0 8px;
164
+ border-radius: 0 7px 7px 0;
165
+ border-color: #d1d5db;
166
+ background: #fff;
167
+ color: #6b7280;
168
+ font-size: 0.9rem;
169
+ line-height: 1;
170
+ display: inline-flex;
171
+ align-items: center;
172
+ justify-content: center;
173
+ align-self: stretch;
174
+ }
175
+
176
+ .btn-remove:hover {
177
+ color: #374151;
178
+ border-color: #9ca3af;
179
+ background: #f9fafb;
180
+ }
181
+
182
+ .day-actions {
183
+ margin-top: 4px;
184
+ display: flex;
185
+ justify-content: flex-start;
186
+ }
187
+
188
+ .debug-panel {
189
+ padding: 10px;
190
+ background: #111827;
191
+ color: #d1d5db;
192
+ border-radius: 8px;
193
+ border: 1px solid #374151;
194
+ }
195
+
196
+ .debug-panel strong {
197
+ display: block;
198
+ margin-bottom: 6px;
199
+ color: #f3f4f6;
200
+ }
201
+
202
+ .debug-tools {
203
+ margin-bottom: 8px;
204
+ font-size: 0.75rem;
205
+ }
206
+
207
+ .debug-tools label {
208
+ display: inline-flex;
209
+ align-items: center;
210
+ gap: 0.4rem;
211
+ color: #cbd5e1;
212
+ cursor: pointer;
213
+ }
214
+
215
+ pre {
216
+ margin: 0;
217
+ white-space: pre-wrap;
218
+ font-size: 0.75rem;
219
+ }
220
+
221
+ @media (max-width: 900px) {
222
+ .playground-grid {
223
+ grid-template-columns: 1fr;
224
+ }
225
+ }
226
+
227
+ @media (max-width: 700px) {
228
+ .day-grid {
229
+ grid-template-columns: 1fr;
230
+ gap: 6px;
231
+ }
232
+ }
233
+ </style>
234
+ </head>
235
+ <body>
236
+ <div data-controller="opening-hours">
237
+ <div class="playground-grid">
238
+ <div class="hours-editor">
239
+ <div class="editor-header">
240
+ <h2>Business Availability</h2>
241
+ <p>
242
+ Multiple time windows per day, directly mapped to Schedule ranges.
243
+ </p>
244
+ </div>
245
+
246
+ <div id="hours-container" data-opening-hours-target="container"></div>
247
+ </div>
248
+
249
+ <div class="debug-panel">
250
+ <strong
251
+ >Rails Params Payload (business[opening_hours][day][])
252
+ </strong>
253
+ <div class="debug-tools">
254
+ <label>
255
+ <input
256
+ type="checkbox"
257
+ data-opening-hours-target="includeEmpty"
258
+ data-action="change->opening-hours#refreshPayload"
259
+ />
260
+ Include empty weekdays in payload
261
+ </label>
262
+ </div>
263
+ <pre data-opening-hours-target="debugOutput">{}</pre>
264
+ </div>
265
+ </div>
266
+ </div>
267
+
268
+ <script src="https://unpkg.com/@hotwired/stimulus/dist/stimulus.umd.js"></script>
269
+ <script src="./opening_hours_controller.js"></script>
270
+ <script>
271
+ const application = Stimulus.Application.start();
272
+ application.register("opening-hours", window.OpeningHoursController);
273
+ </script>
274
+ </body>
275
+ </html>
@@ -0,0 +1,175 @@
1
+ (function () {
2
+ const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
3
+
4
+ class OpeningHoursController extends Stimulus.Controller {
5
+ static targets = ["container", "debugOutput", "includeEmpty"];
6
+
7
+ connect() {
8
+ this.nextId = 1;
9
+ this.state = Object.fromEntries(DAYS.map((day) => [day, []]));
10
+ this.weekdayFormatter = new Intl.DateTimeFormat(undefined, { weekday: "long" });
11
+
12
+ this.insertWindow("mon", "09:00", "12:00");
13
+ this.insertWindow("mon", "13:00", "17:00");
14
+ this.insertWindow("fri", "22:00", "02:00");
15
+
16
+ this.render();
17
+ }
18
+
19
+ addWindow(event) {
20
+ this.insertWindow(event.currentTarget.dataset.day);
21
+ this.render();
22
+ }
23
+
24
+ removeWindow(event) {
25
+ const row = event.currentTarget.closest(".window-row");
26
+ if (!row) return;
27
+
28
+ const day = row.dataset.day;
29
+ const id = Number(row.dataset.id);
30
+ this.state[day] = this.state[day].filter((window) => window.id !== id);
31
+ this.render();
32
+ }
33
+
34
+ updateWindow(event) {
35
+ const input = event.currentTarget;
36
+ const row = input.closest(".window-row");
37
+ if (!row) return;
38
+
39
+ const day = row.dataset.day;
40
+ const id = Number(row.dataset.id);
41
+ const window = this.state[day].find((item) => item.id === id);
42
+ if (!window) return;
43
+
44
+ window[input.dataset.field] = input.value;
45
+ this.renderPayload();
46
+ }
47
+
48
+ refreshPayload() {
49
+ this.renderPayload();
50
+ }
51
+
52
+ insertWindow(day, start = "09:00", end = "17:00") {
53
+ this.state[day].push({ id: this.nextId++, start, end });
54
+ }
55
+
56
+ isComplete(window) {
57
+ return Boolean(window.start && window.end);
58
+ }
59
+
60
+ toRangeString(window) {
61
+ return `${window.start}-${window.end}`;
62
+ }
63
+
64
+ buildOpeningHours(includeEmpty) {
65
+ const openingHours = {};
66
+
67
+ DAYS.forEach((day) => {
68
+ const ranges = this.state[day]
69
+ .filter((window) => this.isComplete(window))
70
+ .map((window) => this.toRangeString(window));
71
+
72
+ if (includeEmpty || ranges.length > 0) {
73
+ openingHours[day] = ranges;
74
+ }
75
+ });
76
+
77
+ return openingHours;
78
+ }
79
+
80
+ formatDayLabel(day) {
81
+ try {
82
+ const index = DAYS.indexOf(day);
83
+ if (index < 0) return day;
84
+
85
+ // Jan 1, 2024 is a Monday; use local noon to avoid TZ rollover.
86
+ const date = new Date(2024, 0, 1 + index, 12, 0, 0, 0);
87
+ return this.weekdayFormatter.format(date);
88
+ } catch (_error) {
89
+ return day;
90
+ }
91
+ }
92
+
93
+ dayMarkup(day) {
94
+ const windows = this.state[day];
95
+
96
+ const windowsMarkup = windows
97
+ .map((window) => {
98
+ return `
99
+ <div class="window-row" data-day="${day}" data-id="${window.id}">
100
+ <div class="time-range">
101
+ <input
102
+ type="time"
103
+ step="900"
104
+ value="${window.start}"
105
+ data-field="start"
106
+ data-action="input->opening-hours#updateWindow"
107
+ />
108
+ <span class="window-sep">&ndash;</span>
109
+ <input
110
+ type="time"
111
+ step="900"
112
+ value="${window.end}"
113
+ data-field="end"
114
+ data-action="input->opening-hours#updateWindow"
115
+ />
116
+ </div>
117
+ <button
118
+ type="button"
119
+ class="btn btn-remove"
120
+ data-action="click->opening-hours#removeWindow"
121
+ aria-label="Remove window"
122
+ title="Remove window"
123
+ >
124
+ ×
125
+ </button>
126
+ </div>
127
+ `;
128
+ })
129
+ .join("");
130
+
131
+ const addButtonMarkup = `
132
+ <button
133
+ type="button"
134
+ class="btn btn-add"
135
+ data-day="${day}"
136
+ data-action="click->opening-hours#addWindow"
137
+ >
138
+ + add window
139
+ </button>
140
+ `;
141
+
142
+ const bodyMarkup =
143
+ windows.length === 0
144
+ ? `<div class="day-actions">${addButtonMarkup}</div>`
145
+ : `${windowsMarkup}<div class="day-actions">${addButtonMarkup}</div>`;
146
+
147
+ return `
148
+ <section class="day-card" data-day-card="${day}">
149
+ <div class="day-grid">
150
+ <span class="day-label">${this.formatDayLabel(day)}</span>
151
+ <div class="day-content">${bodyMarkup}</div>
152
+ </div>
153
+ </section>
154
+ `;
155
+ }
156
+
157
+ renderPayload() {
158
+ const includeEmpty = this.includeEmptyTarget.checked;
159
+ const payload = {
160
+ business: {
161
+ opening_hours: this.buildOpeningHours(includeEmpty),
162
+ },
163
+ };
164
+
165
+ this.debugOutputTarget.textContent = JSON.stringify(payload, null, 2);
166
+ }
167
+
168
+ render() {
169
+ this.containerTarget.innerHTML = DAYS.map((day) => this.dayMarkup(day)).join("");
170
+ this.renderPayload();
171
+ }
172
+ }
173
+
174
+ window.OpeningHoursController = OpeningHoursController;
175
+ })();
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpeningHours
4
+ class Builder
5
+ attr_reader :schedule
6
+
7
+ def initialize
8
+ @schedule = Schedule.new
9
+ end
10
+
11
+ Schedule.members.each do |day|
12
+ define_method(day) do |*ranges|
13
+ windows = ranges.map { |range| TimeWindow[range] }.freeze
14
+ @schedule = @schedule.with(day => windows)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/module/delegation"
5
+ require "active_support/core_ext/time"
6
+
7
+ module OpeningHours
8
+ module Model
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ class_attribute :opening_hours_column_name, instance_accessor: false, default: :opening_hours
13
+ register_opening_hours_attribute!
14
+ delegate :opening_hours_column, to: :class
15
+ end
16
+
17
+ class_methods do
18
+ def opening_hours_column(name = nil)
19
+ unless name.nil?
20
+ self.opening_hours_column_name = name.to_sym
21
+ register_opening_hours_attribute!
22
+ end
23
+
24
+ opening_hours_column_name
25
+ end
26
+
27
+ private
28
+
29
+ def register_opening_hours_attribute!
30
+ attribute(opening_hours_column_name, OpeningHours::Type.new, default: -> { OpeningHours::Schedule.new })
31
+ end
32
+ end
33
+
34
+ def define_hours(&block)
35
+ OpeningHours::Schedule.build(&block).tap do |schedule|
36
+ public_send(:"#{opening_hours_column}=", schedule)
37
+ end
38
+ end
39
+
40
+ def open?(at: Time.current)
41
+ zone = respond_to?(:timezone) ? timezone.presence : nil
42
+ public_send(opening_hours_column).open?(at: zone ? at.in_time_zone(zone) : at)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpeningHours
4
+ class Schedule < Data.define(:sun, :mon, :tue, :wed, :thu, :fri, :sat)
5
+ class << self
6
+ def build(&block)
7
+ return new unless block_given?
8
+
9
+ builder = Builder.new
10
+ builder.instance_eval(&block)
11
+ builder.schedule
12
+ end
13
+
14
+ def from_hash(hash)
15
+ new(**hash.to_h.transform_keys(&:to_sym))
16
+ end
17
+ end
18
+
19
+ def initialize(mon: [], tue: [], wed: [], thu: [], fri: [], sat: [], sun: [])
20
+ super(
21
+ sun: normalize_day(sun),
22
+ mon: normalize_day(mon),
23
+ tue: normalize_day(tue),
24
+ wed: normalize_day(wed),
25
+ thu: normalize_day(thu),
26
+ fri: normalize_day(fri),
27
+ sat: normalize_day(sat)
28
+ )
29
+ end
30
+
31
+ def open?(at: Time.now)
32
+ current_time = at.strftime("%H:%M")
33
+ windows_to_check(at).any? { |window| window.cover?(current_time) }
34
+ end
35
+
36
+ def to_h
37
+ super.transform_values { |windows| windows.map(&:to_s) }
38
+ end
39
+
40
+ def as_json(options = nil)
41
+ include_empty = options.is_a?(Hash) && options[:include_empty]
42
+ include_empty ? to_h : to_h.reject { |_day, windows| windows.empty? }
43
+ end
44
+
45
+ private
46
+
47
+ def normalize_day(values)
48
+ Array(values).map { TimeWindow[it] }.freeze
49
+ end
50
+
51
+ # Check current day windows + previous day's overnight leftovers
52
+ def windows_to_check(time)
53
+ curr_day = members[time.wday]
54
+ prev_day = members[(time.wday - 1) % 7]
55
+
56
+ public_send(curr_day) + public_send(prev_day).select(&:overnight?)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,50 @@
1
+ module OpeningHours
2
+ class TimeOfDay
3
+ include Comparable
4
+ REGEX = /\A(\d{1,2})(?::(\d{1,2}))?\z/.freeze
5
+
6
+ def self.[](input)
7
+ input.is_a?(self) ? input : new(input)
8
+ end
9
+
10
+ attr_reader :hour, :min
11
+
12
+ def initialize(input)
13
+ case input
14
+ in Time
15
+ @hour = input.hour
16
+ @min = input.min
17
+ else
18
+ @hour, @min = parse(input)
19
+ end
20
+
21
+ validate!
22
+ end
23
+
24
+ def <=>(other)
25
+ to_minutes <=> TimeOfDay[other].to_minutes
26
+ end
27
+
28
+ def to_minutes
29
+ hour * 60 + min
30
+ end
31
+
32
+ def to_s
33
+ format("%02d:%02d", hour, min)
34
+ end
35
+
36
+ private
37
+
38
+ def parse(input)
39
+ match = REGEX.match(input.to_s.strip)
40
+ raise ArgumentError, "Invalid time: #{input.inspect}" unless match
41
+
42
+ [match[1].to_i, (match[2] || 0).to_i]
43
+ end
44
+
45
+ def validate!
46
+ raise ArgumentError unless hour.between?(0, 23)
47
+ raise ArgumentError unless min.between?(0, 59)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpeningHours
4
+ class TimeWindow < Data.define(:opens, :closes)
5
+ def self.[](input)
6
+ case input
7
+ in TimeWindow => tw
8
+ tw
9
+ in Range => r
10
+ new(r.begin, r.end)
11
+ in [opens, closes]
12
+ new(opens, closes)
13
+ in String => s if s.include?("-")
14
+ new(*s.split("-", 2))
15
+ else
16
+ raise ArgumentError, "Cannot build TimeWindow from #{input.inspect}"
17
+ end
18
+ end
19
+
20
+ def initialize(opens:, closes:)
21
+ opens = TimeOfDay[opens]
22
+ closes = TimeOfDay[closes]
23
+ super
24
+ end
25
+
26
+ def to_a = deconstruct
27
+ def to_s = deconstruct.uniq.join("-")
28
+
29
+ def overnight?
30
+ closes < opens
31
+ end
32
+
33
+ def cover?(value)
34
+ time = TimeOfDay[value]
35
+
36
+ if overnight?
37
+ time >= opens || time <= closes
38
+ else
39
+ time >= opens && time <= closes
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module OpeningHours
6
+ class Type < ActiveModel::Type::Value
7
+ def type
8
+ :opening_hours
9
+ end
10
+
11
+ def cast(value)
12
+ coerce(value)
13
+ end
14
+
15
+ def serialize(value)
16
+ coerce(value).as_json(include_empty: false)
17
+ end
18
+
19
+ def changed_in_place?(raw_old_value, new_value)
20
+ deserialize(raw_old_value) != cast(new_value)
21
+ end
22
+
23
+ private
24
+
25
+ def coerce(value)
26
+ case value
27
+ in nil
28
+ OpeningHours::Schedule.new
29
+ in OpeningHours::Schedule => schedule
30
+ schedule
31
+ in Hash => hash
32
+ OpeningHours::Schedule.from_hash(hash)
33
+ in candidate if (hash = hash_from(candidate))
34
+ OpeningHours::Schedule.from_hash(hash)
35
+ else
36
+ raise ArgumentError, "Cannot cast #{value.inspect} to OpeningHours::Schedule"
37
+ end
38
+ end
39
+
40
+ def hash_from(value)
41
+ return unless value.respond_to?(:to_h)
42
+
43
+ hash = value.to_h
44
+ hash if hash.is_a?(Hash)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,3 @@
1
+ module OpeningHours
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,9 @@
1
+ module OpeningHours
2
+ require_relative "opening_hours/version"
3
+ require_relative "opening_hours/time_of_day"
4
+ require_relative "opening_hours/time_window"
5
+ require_relative "opening_hours/schedule"
6
+ require_relative "opening_hours/builder"
7
+ autoload :Type, "opening_hours/type"
8
+ autoload :Model, "opening_hours/model"
9
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opening_hours"
@@ -0,0 +1,94 @@
1
+ module OpeningHours
2
+ type day = :sun | :mon | :tue | :wed | :thu | :fri | :sat
3
+ type time_input = Integer | String | Time | OpeningHours::TimeOfDay
4
+ type time_window_input = OpeningHours::TimeWindow | Range[time_input] | [time_input, time_input] | String
5
+ type day_windows_input = nil | time_window_input | Array[time_window_input]
6
+ type serialized_schedule = Hash[day, Array[String]]
7
+
8
+ VERSION: String
9
+
10
+ class TimeOfDay
11
+ include Comparable[time_input]
12
+
13
+ attr_reader hour: Integer
14
+ attr_reader min: Integer
15
+
16
+ def self.[]: (time_input input) -> OpeningHours::TimeOfDay
17
+ def initialize: (time_input input) -> void
18
+
19
+ def <=>: (time_input other) -> Integer?
20
+ def to_minutes: () -> Integer
21
+ def to_s: () -> String
22
+ end
23
+
24
+ class TimeWindow
25
+ attr_reader opens: OpeningHours::TimeOfDay
26
+ attr_reader closes: OpeningHours::TimeOfDay
27
+
28
+ def self.[]: (time_window_input input) -> OpeningHours::TimeWindow
29
+ def initialize: (opens: time_input, closes: time_input) -> void
30
+
31
+ def to_a: () -> [OpeningHours::TimeOfDay, OpeningHours::TimeOfDay]
32
+ def to_s: () -> String
33
+ def overnight?: () -> bool
34
+ def cover?: (time_input value) -> bool
35
+ end
36
+
37
+ class Schedule
38
+ attr_reader sun: Array[OpeningHours::TimeWindow]
39
+ attr_reader mon: Array[OpeningHours::TimeWindow]
40
+ attr_reader tue: Array[OpeningHours::TimeWindow]
41
+ attr_reader wed: Array[OpeningHours::TimeWindow]
42
+ attr_reader thu: Array[OpeningHours::TimeWindow]
43
+ attr_reader fri: Array[OpeningHours::TimeWindow]
44
+ attr_reader sat: Array[OpeningHours::TimeWindow]
45
+
46
+ def self.build: () -> OpeningHours::Schedule
47
+ | () { () -> untyped } -> OpeningHours::Schedule
48
+ def self.from_hash: (Hash[Symbol | String, untyped] hash) -> OpeningHours::Schedule
49
+ def initialize: (
50
+ ?mon: day_windows_input,
51
+ ?tue: day_windows_input,
52
+ ?wed: day_windows_input,
53
+ ?thu: day_windows_input,
54
+ ?fri: day_windows_input,
55
+ ?sat: day_windows_input,
56
+ ?sun: day_windows_input
57
+ ) -> void
58
+
59
+ def open?: (?at: untyped) -> bool
60
+ def to_h: () -> serialized_schedule
61
+ def as_json: (?Hash[Symbol, untyped] options) -> serialized_schedule
62
+ end
63
+
64
+ class Builder
65
+ attr_reader schedule: OpeningHours::Schedule
66
+
67
+ def initialize: () -> void
68
+
69
+ def sun: (*time_window_input ranges) -> OpeningHours::Schedule
70
+ def mon: (*time_window_input ranges) -> OpeningHours::Schedule
71
+ def tue: (*time_window_input ranges) -> OpeningHours::Schedule
72
+ def wed: (*time_window_input ranges) -> OpeningHours::Schedule
73
+ def thu: (*time_window_input ranges) -> OpeningHours::Schedule
74
+ def fri: (*time_window_input ranges) -> OpeningHours::Schedule
75
+ def sat: (*time_window_input ranges) -> OpeningHours::Schedule
76
+ end
77
+
78
+ class Type
79
+ def type: () -> Symbol
80
+ def cast: (untyped value) -> OpeningHours::Schedule
81
+ def serialize: (untyped value) -> serialized_schedule
82
+ def changed_in_place?: (untyped raw_old_value, untyped new_value) -> bool
83
+ end
84
+
85
+ module Model
86
+ module ClassMethods
87
+ def opening_hours_column: (?Symbol name) -> Symbol
88
+ end
89
+
90
+ def define_hours: () -> OpeningHours::Schedule
91
+ | () { () -> untyped } -> OpeningHours::Schedule
92
+ def open?: (?at: untyped) -> bool
93
+ end
94
+ end
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: power-hours
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Max Power
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Power Hours models weekly opening windows, including overnight ranges,
13
+ with a small Ruby DSL and optional model mixin helpers.
14
+ email:
15
+ - kevin.melchert@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE.txt
21
+ - README.md
22
+ - Rakefile
23
+ - examples/opening_hours.html
24
+ - examples/opening_hours_controller.js
25
+ - lib/opening_hours.rb
26
+ - lib/opening_hours/builder.rb
27
+ - lib/opening_hours/model.rb
28
+ - lib/opening_hours/schedule.rb
29
+ - lib/opening_hours/time_of_day.rb
30
+ - lib/opening_hours/time_window.rb
31
+ - lib/opening_hours/type.rb
32
+ - lib/opening_hours/version.rb
33
+ - lib/power/hours.rb
34
+ - sig/power/hours.rbs
35
+ homepage: https://github.com/kevin/power-hours
36
+ licenses:
37
+ - MIT
38
+ metadata:
39
+ homepage_uri: https://github.com/kevin/power-hours
40
+ source_code_uri: https://github.com/kevin/power-hours/tree/main
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: 3.2.0
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ requirements: []
55
+ rubygems_version: 4.0.5
56
+ specification_version: 4
57
+ summary: Simple opening-hours DSL with overnight window support.
58
+ test_files: []