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. BookingCreator does 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. Use HoldCreator to 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

License

MIT.

Repository

marten-scheduling

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 3
  • about 5 hours ago
  • May 27, 2026
License

MIT License

Links
Synced at

Wed, 27 May 2026 19:53:46 GMT

Languages