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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +116 -0
- data/Rakefile +8 -0
- data/examples/opening_hours.html +275 -0
- data/examples/opening_hours_controller.js +175 -0
- data/lib/opening_hours/builder.rb +18 -0
- data/lib/opening_hours/model.rb +45 -0
- data/lib/opening_hours/schedule.rb +59 -0
- data/lib/opening_hours/time_of_day.rb +50 -0
- data/lib/opening_hours/time_window.rb +43 -0
- data/lib/opening_hours/type.rb +47 -0
- data/lib/opening_hours/version.rb +3 -0
- data/lib/opening_hours.rb +9 -0
- data/lib/power/hours.rb +3 -0
- data/sig/power/hours.rbs +94 -0
- metadata +58 -0
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,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">–</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,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
|
data/lib/power/hours.rb
ADDED
data/sig/power/hours.rbs
ADDED
|
@@ -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: []
|