Skip to main content

lib/phoenix_live_calendar.ex

defmodule PhoenixLiveCalendar do
  @moduledoc """
  A comprehensive calendar and scheduling component library for Phoenix LiveView.

  PhoenixLiveCalendar provides server-rendered calendar views (month, week, day, year,
  N-day, agenda, timeline, resource) with optional JavaScript hooks for drag
  interactions, optional PubSub for real-time sync, and optional Ecto persistence.

  ## Quick start

      # In your LiveView template
      <.live_component
        module={PhoenixLiveCalendar.CalendarComponent}
        id="my-calendar"
        events={@events}
        on_date_select={fn date -> send(self(), {:date_selected, date}) end}
        on_event_click={fn id -> send(self(), {:event_clicked, id}) end}
      />

  ## Architecture

  PhoenixLiveCalendar is built in layers, each optional:

  - **Layer 0** — Pure Elixir/HEEx views (zero JS required)
  - **Layer 1** — JS hooks for drag-to-select, drag-to-move, resize
  - **Layer 2** — PubSub for real-time multi-user updates
  - **Layer 3** — Booking constraints (availability, slots, buffers)
  - **Layer 4** — Ecto persistence (optional, Oban-style migrations)

  ## Views

  | View | Module | Description |
  |------|--------|-------------|
  | Month | `PhoenixLiveCalendar.Views.MonthGrid` | Traditional 42-cell grid |
  | Week | `PhoenixLiveCalendar.Views.WeekGrid` | 7 days with time axis |
  | Day | `PhoenixLiveCalendar.Views.DayView` | Single day time grid |
  | N-day | `PhoenixLiveCalendar.Views.NDayView` | Configurable day count |
  | Year | `PhoenixLiveCalendar.Views.YearView` | 12 mini-months |
  | Agenda | `PhoenixLiveCalendar.Views.Agenda` | Chronological list |
  | Timeline | `PhoenixLiveCalendar.Views.Timeline` | Horizontal time, resource rows |
  | Resource | `PhoenixLiveCalendar.Views.ResourceView` | Resource columns |

  > #### View maturity {: .info}
  >
  > All eight views render server-side and are usable today. **Month** is the most
  > polished — it is the primary view and the one tuned for small screens. The
  > remaining views (week, day, N-day, year, agenda, timeline, resource) are
  > functional but less refined; in particular the time-grid views are not yet
  > optimised for phone widths. Expect them to gain polish in later releases.

  ## Data structures

  - `PhoenixLiveCalendar.Event` — Calendar events
  - `PhoenixLiveCalendar.Resource` — Rooms, people, equipment
  - `PhoenixLiveCalendar.Availability` — Business hours, available windows
  - `PhoenixLiveCalendar.BookingConfig` — Slot constraints and rules

  ## CSS setup

  Add to your `assets/css/app.css`:

      @source "../../deps/phoenix_live_calendar";

  ## JS hooks setup (optional)

  Add to your `assets/js/app.js`:

      import "../../deps/phoenix_live_calendar/priv/static/assets/phoenix_live_calendar.js"

      let liveSocket = new LiveSocket("/live", Socket, {
        hooks: { ...window.PhoenixLiveCalendarHooks, ...Hooks }
      })
  """

  alias PhoenixLiveCalendar.{Availability, BookingConfig, DayMarker, Event, Resource}

  @doc """
  Returns whether PhoenixLiveCalendar CSS integration has been wired up.

  Looks for the package name in the common `app.css` locations **and** in any
  `assets/css/*.css` — so it also recognises a generated Tailwind sources file
  (e.g. PhoenixKit's `_phoenix_kit_sources.css`, which `@source`s the package
  automatically). Used at compile time to warn developers who haven't run
  `mix phoenix_live_calendar.install` and aren't wiring it some other way.
  """
  @spec installed?() :: boolean()
  def installed? do
    (["assets/css/app.css", "priv/static/assets/app.css", "assets/app.css"] ++
       Path.wildcard("assets/css/*.css"))
    |> Enum.uniq()
    |> Enum.any?(fn path ->
      case File.read(path) do
        {:ok, content} -> String.contains?(content, "phoenix_live_calendar")
        _ -> false
      end
    end)
  end

  @doc """
  Creates a new event struct.

  ## Examples

      PhoenixLiveCalendar.event("1", ~D[2026-04-01], title: "My Event")
      PhoenixLiveCalendar.event("2", ~U[2026-04-01 10:00:00Z], title: "Meeting", end: ~U[2026-04-01 11:00:00Z])
  """
  @spec event(term(), Date.t() | DateTime.t() | NaiveDateTime.t(), keyword()) :: Event.t()
  def event(id, start, opts \\ []) do
    struct!(Event, [{:id, id}, {:start, start} | opts])
  end

  @doc """
  Creates a new resource struct.

  ## Examples

      PhoenixLiveCalendar.resource("room-a", "Conference Room A")
      PhoenixLiveCalendar.resource("dr-smith", "Dr. Smith", type: :person, color: "bg-accent")
  """
  @spec resource(term(), String.t(), keyword()) :: Resource.t()
  def resource(id, title, opts \\ []) do
    struct!(Resource, [{:id, id}, {:title, title} | opts])
  end

  @doc """
  Creates an availability window.

  ## Examples

      # Monday through Friday, 9am to 5pm
      PhoenixLiveCalendar.availability([1, 2, 3, 4, 5], ~T[09:00:00], ~T[17:00:00])

      # Specific date override
      PhoenixLiveCalendar.availability(~D[2026-04-15], ~T[10:00:00], ~T[14:00:00])
  """
  @spec availability([integer()] | Date.t(), Time.t(), Time.t(), keyword()) :: Availability.t()
  def availability(days_or_date, start_time, end_time, opts \\ [])

  def availability(%Date{} = date, start_time, end_time, opts) do
    struct!(Availability, [
      {:date, date},
      {:start_time, start_time},
      {:end_time, end_time}
      | opts
    ])
  end

  def availability(days, start_time, end_time, opts) when is_list(days) do
    struct!(Availability, [
      {:days_of_week, days},
      {:start_time, start_time},
      {:end_time, end_time}
      | opts
    ])
  end

  @doc """
  Creates a booking configuration.

  ## Examples

      PhoenixLiveCalendar.booking_config(duration: 30, buffer_after: 5, min_notice: 60)
  """
  @spec booking_config(keyword()) :: BookingConfig.t()
  def booking_config(opts \\ []) do
    struct!(BookingConfig, opts)
  end

  @doc """
  Creates a day marker (date annotation).

  ## Examples

      # Holiday
      PhoenixLiveCalendar.day_marker("xmas", "Christmas Day", ~D[2026-12-25], type: :holiday, available: false)

      # Multi-day notice
      PhoenixLiveCalendar.day_marker("winter", "Winter Hours", ~D[2026-12-20],
        end_date: ~D[2027-01-05],
        type: :notice,
        color: "bg-info/10",
        description: "Reduced hours: 10am-3pm"
      )
  """
  @spec day_marker(term(), String.t(), Date.t(), keyword()) :: DayMarker.t()
  def day_marker(id, label, start_date, opts \\ []) do
    struct!(DayMarker, [{:id, id}, {:label, label}, {:start_date, start_date} | opts])
  end

  @doc """
  Converts a list of items to events using the `Eventable` protocol.

  Items that already are `PhoenixLiveCalendar.Event` structs pass through unchanged.
  """
  @spec to_events([term()]) :: [Event.t()]
  def to_events(items) do
    Enum.map(items, &PhoenixLiveCalendar.Eventable.to_event/1)
  end
end