marten-scheduling
Marten Scheduling
Reusable scheduling primitives for the Marten web framework. The shard provides resources, schedules, availability rules, event types, bookings, booking holds, and slot generation. It is a headless engine — host applications own the UI, emails, payments, and access control.
About this shard
marten-scheduling is meant to power appointment booking flows, room booking systems, consulting call schedulers, office hours, vehicle scheduling, and Calendly-like products. It is not itself a Calendly clone.
The shard owns:
- Resources, schedules, availability rules, availability overrides
- Event types, bookings, booking holds
- Slot generation, conflict checking, booking lifecycle services
The host application owns:
- Users, workspaces, tenants, accounts
- Public routes, forms, schemas
- Emails, templates, branding
- Payments, access control
- External calendar integration
Installation
Add the dependency to shard.yml:
dependencies:
marten_scheduling:
github: treagod/marten-scheduling
Then shards install.
Require the shard in src/project.cr:
require "marten_scheduling"
Register the app in config/settings/base.cr:
Marten.configure do |config|
config.installed_apps = [
# ...
MartenScheduling::App,
]
end
Run migrations:
marten migrate
Configuration
MartenScheduling.configure do |config|
config.default_time_zone = "UTC"
config.default_slot_step_minutes = 15
config.default_min_notice_minutes = 60
config.default_booking_window_days = 30
config.default_buffer_before_minutes = 0
config.default_buffer_after_minutes = 0
config.default_hold_duration = 5.minutes
config.max_slot_generation_days = 365
config.generate_tokens = true
config.booking_token_bytes = 32
config.check_booking_holds_for_conflicts = true
end
Concepts
Resource — the thing being booked. Can stand for a user, a room, a doctor, a vehicle, a support team. The shard does not depend on a host user model; instead resources are linked through external_type and external_id.
Schedule — a named container of availability rules belonging to a resource.
Availability rule — a recurring weekly window (weekday 1..7, ISO-8601 Monday..Sunday).
Availability override — a per-date exception. Either marks the day as unavailable or replaces the weekly rule with a one-off window.
Event type — a bookable offering on top of a schedule. Owns duration, slot step, buffers, minimum notice, and booking window.
Booking — a confirmed (or formerly confirmed) reservation. Status is one of scheduled, cancelled, rescheduled.
Booking hold — a short-lived reservation that blocks a slot while an invitee fills out a form. Confirmed holds belong to a booking; unconfirmed expired holds can be released.
Linking a host model
class Auth::User < MartenAuth::User
include MartenScheduling::Schedulable
scheduling_external_type "user"
end
resource = MartenScheduling::Resource.create_for!(
current_user,
name: "Marvin",
slug: "marvin",
time_zone: "Europe/Berlin"
)
Without scheduling_external_type, the Crystal class name is stored verbatim. Convenient for prototypes; fragile if you ever rename the host class — prefer a stable alias for anything that ships.
Setting up availability
schedule = MartenScheduling::Schedule.create!(
resource: resource,
name: "Default working hours",
time_zone: "Europe/Berlin"
)
MartenScheduling::AvailabilityRule.create!(
schedule: schedule,
weekday: 1,
start_minute: 9 * 60,
end_minute: 12 * 60
)
event_type = MartenScheduling::EventType.create!(
resource: resource,
schedule: schedule,
name: "Intro call",
slug: "intro-call",
duration_minutes: 30,
slot_step_minutes: 15,
min_notice_minutes: 60,
booking_window_days: 30,
buffer_after_minutes: 15
)
Generating slots
slots = MartenScheduling::SlotGenerator.new(
event_type: event_type,
from: Time.utc,
to: 14.days.from_now
).call
SlotGenerator applies minimum notice, booking window, buffers, existing scheduled bookings, and active unconfirmed holds. Inactive event types/resources/schedules return an empty array (no exception) so public booking pages can degrade silently.
Creating bookings
booking = MartenScheduling::BookingCreator.new(
event_type: event_type,
starts_at: selected_time,
invitee_name: "Jane Doe",
invitee_email: "jane@example.com",
invitee_time_zone: "Europe/Berlin"
).call
Errors raised:
SlotUnavailableError— outside availability, minimum notice, or booking window.BookingConflictError— slot is taken by a scheduled booking or active hold.
Cancelling bookings
MartenScheduling::BookingCanceller.new(
booking: booking,
reason: "Cancelled by invitee"
).call
Idempotent: calling on an already-cancelled booking is a no-op.
Rescheduling bookings
new_booking = MartenScheduling::BookingRescheduler.new(
booking: old_booking,
starts_at: new_starts_at
).call
Creates a fresh scheduled booking and marks the old one rescheduled. The old booking is ignored during conflict checking, so overlapping reschedules work.
Booking holds
hold = MartenScheduling::HoldCreator.new(
event_type: event_type,
starts_at: selected_time
).call
# Later, when the invitee submits the form:
MartenScheduling::BookingCreator.new(
event_type: event_type,
starts_at: hold.starts_at,
invitee_name: "Jane Doe",
invitee_email: "jane@example.com",
hold: hold
).call
CLI commands
marten scheduling_doctor
marten scheduling_slots EVENT_TYPE_ID --from=2026-06-01 --to=2026-06-07
marten scheduling_release_holds
marten scheduling_release_holds --dry-run
What this shard does not do
Out of scope for v1:
- Full SaaS product
- Dashboard UI
- Public booking pages
- Email templates / delivery
- Payment handling
- Google Calendar / Outlook sync
- Authorization rules
- Workspaces / tenants
- Team round-robin or collective scheduling
- Recurring appointments
Known limitations
- Concurrency.
BookingCreatordoes not yet wrap conflict checking and insertion in a database transaction. Two clients racing for the same slot can both pass the check and both insert. UseHoldCreatorto serialize via a short-lived hold, or add an application-level unique constraint. - Time zones. The resolver relies on Crystal's
Time::Location.load. On environments without a full IANA tzdata install (e.g. some minimal containers) unknown zones fall back to UTC with a logged warning. - DST. Local-minute math is correct across DST jumps when tzdata is present; specs cover only a small set of cases. Audit before relying on it in production.
Development
shards install
crystal tool format
crystal spec
./bin/ameba
Authors
- Marvin Ahlgrimm marv.ahlgrimm@gmail.com
License
MIT.
marten-scheduling
- 0
- 0
- 0
- 0
- 3
- about 5 hours ago
- May 27, 2026
MIT License
Wed, 27 May 2026 19:53:46 GMT