Particle.
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.
There is no application server, database, browser API, booking flow, or Ruby process at request time. Nginx serves the files in public/ directly.
private ICS feeds → Ruby generator → public/index.html → Nginx → HTTPS
See the live sample calendar for a generated page showing split availability, busy days, unavailable weekdays, and different weekend hours. It uses only synthetic calendar data. The HTML source is also available in the repository.

Privacy and failure model
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.
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.
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.
Quick start
Particle requires Ruby 3.4 or newer. Install the gem and create a deployment directory:
gem install particle-calendar
mkdir particle
cd particle
particle setup
Edit particle.yml to add your calendar URLs and availability hours, then generate the page:
particle generate
The generated page is public/index.html. For a server deployment, install the generated particle.nginx.conf after reviewing it and configuring TLS. See VPS deployment for permissions, Nginx, and scheduled regeneration.
particle setup also creates public/, log/, and a long random URL path. It protects particle.yml with mode 0600 and refuses to replace existing setup files.
Configuration
Customize every destination when needed:
particle setup \
--config /opt/particle/particle.yml \
--output /opt/particle/public \
--nginx /opt/particle/particle.nginx.conf \
--server-name calendar.example.com \
--url-path /replace-with-a-long-random-value/
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.
enabled: true
timezone: Europe/Berlin
calendar_urls:
- "${CALENDAR_MAIN_URL}"
- "${CALENDAR_EXTRA_URL}"
days_to_show: 28
minimum_slot_minutes: 60
first_day_of_week: monday
event_buffer:
before_minutes: 30
after_minutes: 30
availability:
default:
- start: "09:00"
end: "13:00"
- start: "14:00"
end: "00:00"
sunday:
- start: "10:00"
end: "18:00"
monday:
unavailable: true
Calendar feeds and secrets
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.
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.
A URL may be literal, or an exact ${UPPERCASE_ENV_NAME} placeholder. No ERB is evaluated, so the YAML file cannot execute Ruby code.
For simple VPS use, put literal URLs only in the deployment configuration and keep it protected (particle setup applies this mode automatically):
chmod 600 /opt/particle/particle.yml
For environment-based configuration:
export CALENDAR_MAIN_URL='https://calendar.example/private-a.ics?token=...'
export CALENDAR_EXTRA_URL='https://calendar.example/private-b.ics?token=...'
particle generate
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.
Availability windows
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:
availability:
default:
- start: "09:00"
end: "13:00"
- start: "14:00"
end: "00:00"
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.
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.
availability:
default:
- start: "09:00"
end: "00:00"
sunday:
- start: "10:00"
end: "18:00"
monday:
unavailable: true
Calendar week start
first_day_of_week controls the first column of the rendered calendar and its week labels. It defaults to monday; use any lowercase weekday name, for example:
first_day_of_week: sunday
Minimum slots and event buffers
minimum_slot_minutes removes shorter free fragments after subtraction. The default is 0.
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.
Disabling the page
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.
Running
Generate manually:
particle generate
Useful options:
particle generate --config /etc/particle.yml --output /var/www/particle
Successful output looks like:
[2026-08-26 09:17:01] Fetching 2 calendars
[2026-08-26 09:17:02] Calendar 1 fetched and parsed successfully
[2026-08-26 09:17:02] Calendar 2 ignored 1 malformed event
[2026-08-26 09:17:02] Calendar 2 fetched and parsed successfully
[2026-08-26 09:17:02] Calculating availability for 2026-08-26..2026-09-22
[2026-08-26 09:17:02] Generated /var/www/particle/index.html
Development and testing
Source development uses the Ruby 3.4.8 version pinned in .ruby-version and Bundler 2.6 or newer. After cloning the repository:
bundle install
bundle exec bin/generate
bundle exec bin/generate --config /path/to/config.yml --output /path/to/public
The core libraries are:
icalendarto parse RFC 5545 data;icalendar-recurrence, backed byice_cube, to expand only occurrences intersecting the output range;ActiveSupport::TimeWithZoneandtzinfoto retain named time zones and wall-clock recurrence times across DST;- standard-library
Net::HTTP, ERB, YAML, and filesystem primitives; - RSpec and WebMock for tests.
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.
Refresh the deterministic sample page with synthetic data:
bundle exec bin/generate-sample
Run the complete test suite:
bundle exec rspec
bundle exec rubocop
# Runs both RSpec and RuboCop:
bundle exec rake
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.
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.
VPS deployment
The following example keeps the generator installation out of Nginx's document tree:
Ruby generator
↓
/opt/particle/public
↓
Nginx
↓
HTTPS
Install Ruby 3.4 or newer and the gem as shown in the quick start. Then create /opt/particle as a directory owned by a dedicated account or another unprivileged service user and initialize the deployment there:
cd /opt/particle
particle setup --server-name calendar.example.com
# Edit particle.yml and replace the example calendar placeholders.
particle generate --config /opt/particle/particle.yml --output /opt/particle/public
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.
Nginx and a random path
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:
https://example.com/a8f2c9e71d4b/
Copy and edit it, then validate and reload:
sudo cp /opt/particle/particle.nginx.conf /etc/nginx/sites-available/particle
sudo ln -s /etc/nginx/sites-available/particle /etc/nginx/sites-enabled/particle
sudo nginx -t
sudo systemctl reload nginx
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.
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:
sudo htpasswd -c /etc/nginx/particle.htpasswd availability
sudo chown root:www-data /etc/nginx/particle.htpasswd
sudo chmod 640 /etc/nginx/particle.htpasswd
sudo nginx -t
sudo systemctl reload nginx
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.
Hourly cron regeneration
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:
PATH=/usr/local/bin:/usr/bin:/bin
17 * * * * /usr/local/bin/particle generate --config /opt/particle/particle.yml --output /opt/particle/public >> /opt/particle/log/generator.log 2>&1
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.
The repository includes a 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.
Troubleshooting
- Configuration file not found / invalid YAML: verify
PARTICLE_CONFIGor--config, indentation, and that secrets are readable by the generator user. - Missing environment variable: an exact
${NAME}URL placeholder requiresNAMEin the generator process, including cron/systemd. - Unknown timezone: use an IANA identifier such as
Europe/Berlin, not an informal abbreviation. - 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.
- 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.
- 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.
- Old update timestamp: inspect cron logs. A stale page usually means a later run failed safely.
- Permission denied while publishing: the generator needs write permission on
public/; Nginx needs read permission only.
Known limits
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.
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.
License
Copyright (C) 2026 Timur Yanberdin.
Particle is free software licensed under the GNU General Public License, version 3 only.