particle-calendar 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: b9ce49a6213f463b1903aada3e6bdf5bcc28a0563617934738603e14e8d83e2c
4
+ data.tar.gz: f27791d9393c905ca3e29d46709866100de32a3033fc9aef326f2e48f6c0dda9
5
+ SHA512:
6
+ metadata.gz: 4b1abcf266c1caf0d698a9817506d81d10eb44f23bd7c640e2cf763e9e8e597b8ebfbd3398b70db5411174d1dc5a647ae8c4022189e213ee96bb83f228155c98
7
+ data.tar.gz: 006ab70996c606074c88668426be2ad0d185f2dc4c63d1cf389cfd758d4831a76cdfd922b11a0071b8c5a5a7e7e6a2081409e5a26cb148e0e186f6f0c7177e3e
data/README.md ADDED
@@ -0,0 +1,305 @@
1
+ <h1>
2
+ <img src="assets/favicon.svg" alt="" width="48" height="48" align="center">
3
+ Particle.
4
+ </h1>
5
+
6
+ Particle, from Party Cal, is a Ruby command-line static-site generator. It downloads one or more private iCalendar subscriptions, treats every valid event in every feed as busy, merges the busy periods, subtracts them from configured availability hours, and writes a mobile-friendly calendar view containing only free time.
7
+
8
+ There is no application server, database, browser API, booking flow, or Ruby process at request time. Nginx serves the files in `public/` directly.
9
+
10
+ ```text
11
+ private ICS feeds → Ruby generator → public/index.html → Nginx → HTTPS
12
+ ```
13
+
14
+ See the [live sample calendar](https://mendab1e.github.io/particle/sample/) for a generated page showing split availability, busy days, unavailable weekdays, and different weekend hours. It uses only synthetic calendar data. The [HTML source](docs/sample/index.html) is also available in the repository.
15
+
16
+ ![Sample calendar output](docs/sample/screenshot.png)
17
+
18
+ ## Privacy and failure model
19
+
20
+ Calendar URLs are used only by the generator. The generated HTML contains dates, calculated free intervals, and an update timestamp. It never renders titles, descriptions, locations, attendees, UIDs, calendar names, source URLs, or raw ICS. Logs refer to feeds only as `Calendar 1`, `Calendar 2`, and so on.
21
+
22
+ All configured feeds must download and be parseable as calendars. If any feed fails, generation exits non-zero before publishing. Individual malformed events are ignored, and logs report only a count such as `Calendar 1 ignored 2 malformed events`. No event values are logged. Skipping an event can display false free time if that event was intended to be busy. Output is prepared in temporary files on the same filesystem and `index.html` is atomically renamed only after the complete calculation and render succeed, preserving the previously known-good page.
23
+
24
+ The page includes `noindex, nofollow, noarchive, nosnippet, noimageindex` plus a no-referrer policy, and `public/robots.txt` disallows all crawling. The Nginx example reinforces those directives with response headers, disables shared/browser caching, and serves the robots policy from the required origin-wide `/robots.txt` location. These are requests to well-behaved crawlers, **not authentication or access control**. A long random path reduces accidental discovery but does not prevent a recipient from sharing the URL. Enable HTTP authentication in Nginx if non-discoverability must be enforced against arbitrary scrapers.
25
+
26
+ ## Requirements and installation
27
+
28
+ - Ruby 3.4 or newer
29
+ - A normal Linux VPS for production
30
+
31
+ Install the gem to add the `particle` command:
32
+
33
+ ```bash
34
+ gem install particle-calendar
35
+ particle --help
36
+ ```
37
+
38
+ The core libraries are:
39
+
40
+ - [`icalendar`](https://github.com/icalendar/icalendar) to parse RFC 5545 data;
41
+ - [`icalendar-recurrence`](https://github.com/icalendar/icalendar-recurrence), backed by `ice_cube`, to expand only occurrences intersecting the output range;
42
+ - `ActiveSupport::TimeWithZone` and `tzinfo` to retain named time zones and wall-clock recurrence times across DST;
43
+ - standard-library `Net::HTTP`, ERB, YAML, and filesystem primitives;
44
+ - RSpec and WebMock for tests.
45
+
46
+ `icalendar` parses recurrence properties but does not itself produce occurrence instances. `icalendar-recurrence` supplies date-bounded `occurrences_between` expansion, including common `RRULE`, `RDATE`, and `EXDATE` behavior. Particle additionally chunks dense secondly/minutely rules and applies event/calendar occurrence-count limits before retaining expanded results. The generator also handles exact detached `RECURRENCE-ID` overrides and cancellations so a moved instance replaces its original occurrence.
47
+
48
+ ## Configuration
49
+
50
+ Create a starter configuration, an Nginx server-block sample, and the static output directory:
51
+
52
+ ```bash
53
+ particle setup
54
+ ```
55
+
56
+ By default this writes `particle.yml` with mode `0600`, `particle.nginx.conf`, and `public/` in the current directory. It generates a long random URL path for the Nginx sample and refuses to replace either setup file if it already exists.
57
+
58
+ Customize every destination when needed:
59
+
60
+ ```bash
61
+ particle setup \
62
+ --config /opt/particle/availability.yml \
63
+ --output /var/www/particle \
64
+ --nginx /opt/particle/particle.nginx.conf \
65
+ --server-name calendar.example.com \
66
+ --url-path /replace-with-a-long-random-value/
67
+ ```
68
+
69
+ Run `particle setup --help` for all options. Generation defaults to `particle.yml` and `public/` in the current directory. Select other locations with `--config` and `--output`, or with `PARTICLE_CONFIG` and `PARTICLE_OUTPUT`. `AVAILABILITY_CONFIG` remains accepted for compatibility.
70
+
71
+ ```yaml
72
+ enabled: true
73
+ timezone: Europe/Berlin
74
+
75
+ calendar_urls:
76
+ - "${CALENDAR_MAIN_URL}"
77
+ - "${CALENDAR_EXTRA_URL}"
78
+
79
+ days_to_show: 28
80
+ minimum_slot_minutes: 60
81
+
82
+ event_buffer:
83
+ before_minutes: 30
84
+ after_minutes: 30
85
+
86
+ availability:
87
+ default:
88
+ - start: "09:00"
89
+ end: "13:00"
90
+ - start: "14:00"
91
+ end: "00:00"
92
+
93
+ sunday:
94
+ - start: "10:00"
95
+ end: "18:00"
96
+
97
+ monday:
98
+ unavailable: true
99
+ ```
100
+
101
+ ### Calendar feeds and secrets
102
+
103
+ `calendar_urls` accepts HTTPS, HTTP, or `webcal://` subscription URLs. Webcal URLs are downloaded over HTTPS. Every feed contributes busy time with OR semantics: if any calendar is busy, the shared view is busy. Overlapping and adjacent periods are merged before subtraction.
104
+
105
+ Configuration keys are strict: unknown top-level, `event_buffer`, weekday, and window keys fail generation instead of silently applying defaults. At most 20 calendars and 366 displayed days are accepted. Calendar feeds are also limited to 10,000 events, 10,000 expanded occurrences per event, and 50,000 expanded occurrences per calendar. Exceeding a feed limit fails the run and preserves the known-good page.
106
+
107
+ A URL may be literal, or an exact `${UPPERCASE_ENV_NAME}` placeholder. No ERB is evaluated, so the YAML file cannot execute Ruby code.
108
+
109
+ For simple VPS use, put literal URLs only in the deployment configuration and keep it protected (`particle setup` applies this mode automatically):
110
+
111
+ ```bash
112
+ chmod 600 /opt/particle/particle.yml
113
+ ```
114
+
115
+ For environment-based configuration:
116
+
117
+ ```bash
118
+ export CALENDAR_MAIN_URL='https://calendar.example/private-a.ics?token=...'
119
+ export CALENDAR_EXTRA_URL='https://calendar.example/private-b.ics?token=...'
120
+ particle generate
121
+ ```
122
+
123
+ Do not paste private subscription URLs into source control, shell history, issue trackers, or Nginx configuration. The generator never prints them, even when a request fails.
124
+
125
+ ### Availability windows
126
+
127
+ `availability.default` is required and defines potential availability. Times use strict local 24-hour `HH:MM` syntax. One mapping is accepted for convenience, but the documented array form supports split windows without redesign:
128
+
129
+ ```yaml
130
+ availability:
131
+ default:
132
+ - start: "09:00"
133
+ end: "13:00"
134
+ - start: "14:00"
135
+ end: "00:00"
136
+ ```
137
+
138
+ The 13:00–14:00 gap is intentionally unavailable. `00:00` is accepted as a window end and means midnight at the end of that calendar day, matching calendar UI conventions. Windows may touch but cannot overlap, and each start must be earlier than its end. Other overnight availability windows are deliberately not accepted; express availability on each calendar day separately.
139
+
140
+ Any weekday can replace the default for that entire weekday: `monday`, `tuesday`, `wednesday`, `thursday`, `friday`, `saturday`, or `sunday`. An override is a replacement, not a merge with `default`.
141
+
142
+ ```yaml
143
+ availability:
144
+ default:
145
+ - start: "09:00"
146
+ end: "00:00"
147
+ sunday:
148
+ - start: "10:00"
149
+ end: "18:00"
150
+ monday:
151
+ unavailable: true
152
+ ```
153
+
154
+ ### Minimum slots and event buffers
155
+
156
+ `minimum_slot_minutes` removes shorter free fragments after subtraction. The default is `0`.
157
+
158
+ `event_buffer.before_minutes` and `after_minutes` enlarge each event before busy periods are merged. Both default to `0`. Buffers are clipped during subtraction and can never create free time outside configured windows.
159
+
160
+ ### Disabling the page
161
+
162
+ Set `enabled: false` to skip all network access and render a “Calendar not available” page. `calendar_urls` may be empty in this mode. This is useful when availability should be withdrawn without changing Nginx.
163
+
164
+ ## Running
165
+
166
+ Generate manually:
167
+
168
+ ```bash
169
+ particle generate
170
+ ```
171
+
172
+ Useful options:
173
+
174
+ ```bash
175
+ particle generate --config /etc/particle.yml --output /var/www/particle
176
+ ```
177
+
178
+ Successful output looks like:
179
+
180
+ ```text
181
+ [2026-08-26 09:17:01] Fetching 2 calendars
182
+ [2026-08-26 09:17:02] Calendar 1 fetched and parsed successfully
183
+ [2026-08-26 09:17:02] Calendar 2 ignored 1 malformed event
184
+ [2026-08-26 09:17:02] Calendar 2 fetched and parsed successfully
185
+ [2026-08-26 09:17:02] Calculating availability for 2026-08-26..2026-09-22
186
+ [2026-08-26 09:17:02] Generated /var/www/particle/index.html
187
+ ```
188
+
189
+ ## Development and testing
190
+
191
+ Source development uses the Ruby 3.4.8 version pinned in `.ruby-version` and Bundler 2.6 or newer. After cloning the repository:
192
+
193
+ ```bash
194
+ bundle install
195
+ bundle exec bin/generate
196
+ bundle exec bin/generate --config /path/to/config.yml --output /path/to/public
197
+ ```
198
+
199
+ Refresh the deterministic sample page with synthetic data:
200
+
201
+ ```bash
202
+ bundle exec bin/generate-sample
203
+ ```
204
+
205
+ Run the complete test suite:
206
+
207
+ ```bash
208
+ bundle exec rspec
209
+ bundle exec rubocop
210
+
211
+ # Runs both RSpec and RuboCop:
212
+ bundle exec rake
213
+ ```
214
+
215
+ RuboCop targets Ruby 3.4 and loads the official RSpec, Rake, and performance plugins. New cops are enabled. To apply safe formatting corrections locally, run `bundle exec rubocop -a`; review behavior-changing corrections from `bundle exec rubocop -A` before keeping them.
216
+
217
+ Tests cover interval subtraction, overlap and adjacency merging, multiple-calendar semantics, availability boundaries, events spanning midnight, all-day events, weekday replacement/unavailable days, minimum duration, buffers, split windows, recurrence exclusions and detached overrides, UTC/source/custom-zone conversion, unresolved zones, parser isolation, recurrence limits, atomic publish failures, and the Europe/Berlin DST transition. Integration tests also assert that fixture metadata and source URL secrets never reach HTML or parser diagnostics.
218
+
219
+ ## VPS deployment
220
+
221
+ The following example keeps the generator installation out of Nginx's document tree:
222
+
223
+ ```text
224
+ Ruby generator
225
+
226
+ /opt/particle/public
227
+
228
+ Nginx
229
+
230
+ HTTPS
231
+ ```
232
+
233
+ Create a dedicated account or deploy as an unprivileged service user, install Ruby 3.4, and install the gem. Create `/opt/particle` as a directory owned by that user, then initialize the deployment:
234
+
235
+ ```bash
236
+ gem install particle-calendar
237
+ cd /opt/particle
238
+ mkdir -p log
239
+ particle setup --server-name calendar.example.com
240
+ # Edit particle.yml and replace the example calendar placeholders.
241
+ particle generate --config /opt/particle/particle.yml --output /opt/particle/public
242
+ ```
243
+
244
+ Ensure the generator user can replace files in `public/`, while the Nginx worker can read them. Do not set Nginx `root` or `alias` to `/opt/particle`; only map the generated public files.
245
+
246
+ ### Nginx and a random path
247
+
248
+ `particle setup` creates `particle.nginx.conf` with exact-match locations exposing `public/index.html` and its generated `public/favicon.svg` at a random path such as:
249
+
250
+ ```text
251
+ https://example.com/a8f2c9e71d4b/
252
+ ```
253
+
254
+ Copy and edit it, then validate and reload:
255
+
256
+ ```bash
257
+ sudo cp /opt/particle/particle.nginx.conf /etc/nginx/sites-available/particle
258
+ sudo ln -s /etc/nginx/sites-available/particle /etc/nginx/sites-enabled/particle
259
+ sudo nginx -t
260
+ sudo systemctl reload nginx
261
+ ```
262
+
263
+ Configure TLS certificates separately. The random path belongs only in Nginx; calendar calculation and generated links do not depend on it. The example serves `public/robots.txt` at the origin-wide `/robots.txt`, which is the only standards-defined location for crawler policy. Use a dedicated hostname: on a shared hostname, this policy would also disallow crawling unrelated pages.
264
+
265
+ The generated directives cover compliant search engines, AI crawlers, and other robots through the wildcard `User-agent: *` rule. They cannot stop clients that ignore `robots.txt`, spoof a browser, follow a user-provided URL, or learn the URL elsewhere. To enforce privacy after a URL is discovered, create a password file and enable the commented `auth_basic` lines in the page location:
266
+
267
+ ```bash
268
+ sudo htpasswd -c /etc/nginx/particle.htpasswd availability
269
+ sudo chown root:www-data /etc/nginx/particle.htpasswd
270
+ sudo chmod 640 /etc/nginx/particle.htpasswd
271
+ sudo nginx -t
272
+ sudo systemctl reload nginx
273
+ ```
274
+
275
+ Replace `www-data` with the Nginx worker group used by your distribution. HTTP authentication is the protection boundary; the random path and crawler directives remain defense in depth.
276
+
277
+ ### Hourly cron regeneration
278
+
279
+ Confirm the absolute executable path with `command -v particle`, then add a crontab entry for the unprivileged generator user. The example assumes `/usr/local/bin/particle`; replace it with the path reported on your server. It intentionally does not run at minute zero:
280
+
281
+ ```cron
282
+ PATH=/usr/local/bin:/usr/bin:/bin
283
+ 17 * * * * /usr/local/bin/particle generate --config /opt/particle/particle.yml --output /opt/particle/public >> /opt/particle/log/generator.log 2>&1
284
+ ```
285
+
286
+ If the Ruby installation lives elsewhere, use the absolute `particle` path reported by `command -v particle`. Cron has a minimal environment. When YAML uses `${...}` placeholders, load protected environment values through a small root-owned wrapper or use a systemd timer with `EnvironmentFile=`; do not put secret URLs directly in the crontab.
287
+
288
+ The repository includes a [`deploy/availability.logrotate`](deploy/availability.logrotate) example that can be adapted to `/opt/particle/log/generator.log`, or send output to syslog/systemd instead. A failed cron run exits non-zero and leaves the last successfully generated page online; monitor the exit code or log rather than assuming hourly freshness.
289
+
290
+ ## Troubleshooting
291
+
292
+ - **Configuration file not found / invalid YAML:** verify `PARTICLE_CONFIG` or `--config`, indentation, and that secrets are readable by the generator user.
293
+ - **Missing environment variable:** an exact `${NAME}` URL placeholder requires `NAME` in the generator process, including cron/systemd.
294
+ - **Unknown timezone:** use an IANA identifier such as `Europe/Berlin`, not an informal abbreviation.
295
+ - **Calendar download failed:** test outbound DNS/TLS access from the generator account and confirm the subscription was not revoked. Logs intentionally omit the URL and query token.
296
+ - **Calendar parse failed:** download the feed securely and validate that it is ICS, not an HTML sign-in/error page. Individual malformed events are ignored, but a feed that cannot be parsed as a calendar still fails. Never paste it into public diagnostics.
297
+ - **Calendar exceeded a safe limit:** reduce an unusually large displayed range or inspect the feed privately for excessive event counts or dense recurrence rules. The generator intentionally fails before publishing rather than risking resource exhaustion.
298
+ - **Old update timestamp:** inspect cron logs. A stale page usually means a later run failed safely.
299
+ - **Permission denied while publishing:** the generator needs write permission on `public/`; Nginx needs read permission only.
300
+
301
+ ## Known limits
302
+
303
+ The generator supports normal timed, all-day, overnight, multi-day, recurring, `EXDATE`, `RDATE`, and exact detached `RECURRENCE-ID` events. Floating timed events without a `TZID` are interpreted in the configured timezone. Custom `VTIMEZONE` definitions are used when the parser can resolve them; events with unresolved timezone identifiers are treated as malformed and ignored. Rare recurrence features such as `RANGE=THISANDFUTURE` and malformed feed-level structure may still require feed-specific work.
304
+
305
+ HTTP validators (`ETag` and `Last-Modified`) are not persisted in this intentionally stateless version. Every successful run downloads every feed, prioritizing freshness and safe all-or-nothing generation.
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect x="1" y="1" width="62" height="62" rx="14" fill="#eaf4fb"/>
3
+ <rect x="6" y="9" width="52" height="49" rx="11" fill="#0f172a"/>
4
+ <rect x="10" y="13" width="44" height="41" rx="7" fill="#f8fafc"/>
5
+ <rect x="8" y="23" width="48" height="4" fill="#0f172a"/>
6
+ <rect x="17" y="5" width="4" height="12" rx="2" fill="#0f172a"/>
7
+ <rect x="43" y="5" width="4" height="12" rx="2" fill="#0f172a"/>
8
+ <circle cx="18" cy="34" r="4" fill="#0284c7"/>
9
+ <circle cx="32" cy="34" r="4" fill="#0284c7"/>
10
+ <circle cx="46" cy="34" r="4" fill="#0284c7"/>
11
+ <circle cx="18" cy="46" r="4" fill="#0284c7"/>
12
+ <circle cx="32" cy="46" r="4" fill="#0284c7"/>
13
+ <rect x="42" y="42" width="8" height="8" rx="1.5" fill="#0284c7"/>
14
+ </svg>
@@ -0,0 +1,29 @@
1
+ enabled: true
2
+ timezone: Europe/Berlin
3
+
4
+ # Use literal private URLs locally, or exact environment placeholders in production.
5
+ calendar_urls:
6
+ - "${CALENDAR_MAIN_URL}"
7
+ - "${CALENDAR_EXTRA_URL}"
8
+
9
+ # Safety limits: at most 20 calendar URLs and 366 displayed days.
10
+ days_to_show: 28
11
+ minimum_slot_minutes: 60
12
+
13
+ event_buffer:
14
+ before_minutes: 0
15
+ after_minutes: 0
16
+
17
+ availability:
18
+ default:
19
+ - start: "09:00"
20
+ end: "13:00"
21
+ - start: "14:00"
22
+ end: "00:00"
23
+
24
+ sunday:
25
+ - start: "10:00"
26
+ end: "18:00"
27
+
28
+ # monday:
29
+ # unavailable: true
data/exe/particle ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'availability'
5
+
6
+ exit Availability::CLI.new(args: ARGV).run
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Availability
4
+ # Coordinates configuration, calendar ingestion, calculation, and publishing.
5
+ class Application
6
+ DEFAULT_CLOCK = -> { Time.now.utc }
7
+ DEFAULT_FAVICON_PATH = Assets.favicon_path
8
+
9
+ def initialize(config_path:, output_dir:, template_path:, output: $stdout, clock: DEFAULT_CLOCK,
10
+ fetcher: CalendarFetcher.new, favicon_path: DEFAULT_FAVICON_PATH)
11
+ @config_path = config_path
12
+ @output_dir = output_dir
13
+ @template_path = template_path
14
+ @favicon_path = favicon_path
15
+ @output = output
16
+ @clock = clock
17
+ @fetcher = fetcher
18
+ end
19
+
20
+ def run
21
+ config = Config.load(@config_path)
22
+ now = @clock.call.utc
23
+ today = config.timezone.to_local(now).to_date
24
+ days = config.enabled ? calculate_days(config, now, today) : disabled_days(config, now)
25
+
26
+ publish(config, now, today, days)
27
+ true
28
+ end
29
+
30
+ private
31
+
32
+ def calculate_days(config, now, today)
33
+ log(config, now, "Fetching #{config.calendar_urls.length} calendars")
34
+ range_start, range_end, final_date = expansion_range(config, today)
35
+ parser = CalendarParser.new(timezone: config.timezone)
36
+ busy_periods = fetch_periods(config, parser, range_start, range_end, now)
37
+
38
+ log(config, now, "Calculating availability for #{today}..#{final_date - 1}")
39
+ AvailabilityCalculator.new(config).calculate(start_date: today, busy_periods: busy_periods)
40
+ end
41
+
42
+ def disabled_days(config, now)
43
+ log(config, now, 'Availability is disabled; calendars were not fetched')
44
+ []
45
+ end
46
+
47
+ def expansion_range(config, today)
48
+ final_date = today + config.days_to_show
49
+ range_start = local_midnight(config, today) - (config.buffer_after_minutes * 60)
50
+ range_end = local_midnight(config, final_date) + (config.buffer_before_minutes * 60)
51
+ [range_start, range_end, final_date]
52
+ end
53
+
54
+ def local_midnight(config, date)
55
+ config.timezone.local_time(date.year, date.month, date.day, 0, 0, 0).utc
56
+ end
57
+
58
+ def publish(config, now, today, days)
59
+ html = render_page(config, now, today, days)
60
+ AtomicWriter.write_all(@output_dir, generated_files(html))
61
+ log(config, now, "Generated #{File.join(@output_dir, 'index.html')}")
62
+ end
63
+
64
+ def render_page(config, now, today, days)
65
+ Renderer.new(template_path: @template_path).render(
66
+ days: days,
67
+ generated_at: now,
68
+ timezone: config.timezone,
69
+ enabled: config.enabled,
70
+ today: today,
71
+ days_to_show: config.days_to_show
72
+ )
73
+ end
74
+
75
+ def generated_files(html)
76
+ {
77
+ 'favicon.svg' => File.binread(@favicon_path),
78
+ 'robots.txt' => Renderer::ROBOTS,
79
+ 'index.html' => html
80
+ }
81
+ end
82
+
83
+ def fetch_periods(config, parser, range_start, range_end, now)
84
+ config.calendar_urls.each_with_index.flat_map do |url, index|
85
+ label = "Calendar #{index + 1}"
86
+ body = @fetcher.fetch(url, label: label)
87
+ periods = parser.parse(body, range_start: range_start, range_end: range_end, label: label)
88
+ log_ignored_events(config, now, label, parser.ignored_event_count)
89
+ log(config, now, "#{label} fetched and parsed successfully")
90
+ periods
91
+ end
92
+ end
93
+
94
+ def log_ignored_events(config, now, label, count)
95
+ return if count.zero?
96
+
97
+ noun = count == 1 ? 'event' : 'events'
98
+ log(config, now, "#{label} ignored #{count} malformed #{noun}")
99
+ end
100
+
101
+ def log(config, moment, message)
102
+ timestamp = config.timezone.to_local(moment).strftime('%Y-%m-%d %H:%M:%S')
103
+ @output.puts("[#{timestamp}] #{message}")
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Availability
4
+ # Resolves runtime files both from a source checkout and an installed gem.
5
+ module Assets
6
+ ROOT = File.expand_path('../..', __dir__)
7
+
8
+ module_function
9
+
10
+ def example_config_path
11
+ File.join(ROOT, 'config', 'availability.example.yml')
12
+ end
13
+
14
+ def favicon_path
15
+ File.join(ROOT, 'assets', 'favicon.svg')
16
+ end
17
+
18
+ def index_template_path
19
+ File.join(ROOT, 'templates', 'index.html.erb')
20
+ end
21
+
22
+ def nginx_template_path
23
+ File.join(ROOT, 'templates', 'nginx.conf.erb')
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+
6
+ module Availability
7
+ # Writes generated assets through same-filesystem temporary files and renames.
8
+ class AtomicWriter
9
+ def self.write_all(output_dir, files)
10
+ FileUtils.mkdir_p(output_dir)
11
+ temporary = prepare_files(output_dir, files)
12
+ publish_files(output_dir, files.keys, temporary)
13
+ ensure
14
+ cleanup(temporary)
15
+ end
16
+
17
+ def self.prepare_files(output_dir, files)
18
+ temporary = {}
19
+ files.each do |name, contents|
20
+ temporary[name] = prepare_file(output_dir, name, contents)
21
+ end
22
+ temporary
23
+ rescue StandardError
24
+ cleanup(temporary)
25
+ raise
26
+ end
27
+
28
+ def self.prepare_file(output_dir, name, contents)
29
+ file = Tempfile.new([".#{name}", '.tmp'], output_dir)
30
+ write_contents(file, contents)
31
+ file
32
+ rescue StandardError
33
+ file&.close!
34
+ raise
35
+ end
36
+
37
+ def self.write_contents(file, contents)
38
+ file.binmode
39
+ file.write(contents)
40
+ file.flush
41
+ file.fsync
42
+ file.chmod(0o644)
43
+ file.close
44
+ end
45
+
46
+ def self.publish_files(output_dir, names, temporary)
47
+ # Publishing index last keeps the known-good page if preparation fails.
48
+ (names - ['index.html'] + ['index.html']).each do |name|
49
+ File.rename(temporary.fetch(name).path, File.join(output_dir, name))
50
+ end
51
+ end
52
+
53
+ def self.cleanup(temporary)
54
+ temporary&.each_value do |file|
55
+ file.close unless file.closed?
56
+ file.unlink if File.exist?(file.path)
57
+ end
58
+ end
59
+
60
+ private_class_method :prepare_files, :prepare_file, :write_contents, :publish_files, :cleanup
61
+ end
62
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Availability
4
+ # Subtracts merged busy periods from configured per-day availability windows.
5
+ class AvailabilityCalculator
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ def calculate(start_date:, busy_periods:)
11
+ expanded_busy = busy_periods.map { |period| apply_buffer(period) }
12
+
13
+ Array.new(@config.days_to_show) do |offset|
14
+ date = start_date + offset
15
+ DayAvailability.new(date, available_slots(date, expanded_busy))
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def apply_buffer(period)
22
+ BusyPeriod.new(
23
+ period.starts_at - (@config.buffer_before_minutes * 60),
24
+ period.ends_at + (@config.buffer_after_minutes * 60)
25
+ )
26
+ end
27
+
28
+ def available_slots(date, busy_periods)
29
+ slots = @config.windows_for(date).flat_map do |window|
30
+ slots_for_window(date, window, busy_periods)
31
+ end
32
+ slots.select { |slot| long_enough?(slot) }
33
+ end
34
+
35
+ def slots_for_window(date, window, busy_periods)
36
+ starts_at, ends_at = window_boundaries(date, window)
37
+ relevant = busy_periods.select { |period| period.intersects?(starts_at, ends_at) }
38
+ subtract(starts_at, ends_at, merge_and_clip(relevant, starts_at, ends_at))
39
+ end
40
+
41
+ def window_boundaries(date, window)
42
+ window.map do |minutes|
43
+ day_offset, minutes_within_day = minutes.divmod(24 * 60)
44
+ hour, minute = minutes_within_day.divmod(60)
45
+ boundary_date = date + day_offset
46
+ @config.timezone.local_time(boundary_date.year, boundary_date.month, boundary_date.day, hour, minute, 0).utc
47
+ end
48
+ rescue TZInfo::PeriodNotFound, TZInfo::AmbiguousTime => e
49
+ raise Error, "availability boundary is invalid on #{date} because of daylight saving time (#{e.class})"
50
+ end
51
+
52
+ def merge_and_clip(periods, starts_at, ends_at)
53
+ clipped = periods.map { |period| clip(period, starts_at, ends_at) }.sort_by(&:first)
54
+
55
+ clipped.each_with_object([]) do |interval, merged|
56
+ merge_interval(merged, interval)
57
+ end
58
+ end
59
+
60
+ def clip(period, starts_at, ends_at)
61
+ [[period.starts_at, starts_at].max, [period.ends_at, ends_at].min]
62
+ end
63
+
64
+ def merge_interval(merged, interval)
65
+ return merged << interval if merged.empty? || interval.first > merged.last.last
66
+
67
+ merged.last[1] = interval.last if interval.last > merged.last.last
68
+ end
69
+
70
+ def subtract(starts_at, ends_at, busy)
71
+ cursor = starts_at
72
+ slots = []
73
+ busy.each do |busy_start, busy_end|
74
+ slots << Slot.new(cursor, busy_start) if busy_start > cursor
75
+ cursor = busy_end if busy_end > cursor
76
+ end
77
+ slots << Slot.new(cursor, ends_at) if cursor < ends_at
78
+ slots
79
+ end
80
+
81
+ def long_enough?(slot)
82
+ (slot.ends_at - slot.starts_at) >= (@config.minimum_slot_minutes * 60)
83
+ end
84
+ end
85
+ end