Skip to main content

lib/phoenix_live_calendar/event.ex

defmodule PhoenixLiveCalendar.Event do
  @moduledoc """
  Represents a calendar event.

  Consumers map their database records to this struct before passing them

  to calendar components. Only `id` and `start` are required — everything
  else has sensible defaults.

  ## End time semantics

  End times are **exclusive** (half-open interval `[start, end)`).

  - An all-day event on April 1st: `start: ~D[2026-04-01], end: ~D[2026-04-02]`
  - A 1-hour meeting at 10am: `start: ~U[2026-04-01 10:00:00Z], end: ~U[2026-04-01 11:00:00Z]`

  If `end` is `nil`, a default duration is applied:
  - All-day events default to 1 day
  - Timed events default to 30 minutes

  ## Examples

      # Minimal event
      %PhoenixLiveCalendar.Event{id: "1", start: ~D[2026-04-01]}

      # Timed event with details
      %PhoenixLiveCalendar.Event{
        id: "meeting-1",
        title: "Team Standup",
        start: ~U[2026-04-01 09:00:00Z],
        end: ~U[2026-04-01 09:30:00Z],
        color: "bg-primary"
      }

      # All-day event spanning multiple days
      %PhoenixLiveCalendar.Event{
        id: "vacation-1",
        title: "Spring Break",
        start: ~D[2026-04-06],
        end: ~D[2026-04-11],
        all_day: true
      }

      # Booking with resource and constraints
      %PhoenixLiveCalendar.Event{
        id: "booking-1",
        title: "Dr. Smith - Consultation",
        start: ~U[2026-04-01 14:00:00Z],
        end: ~U[2026-04-01 15:00:00Z],
        resource_id: "room-a",
        editable: false,
        overlap: false,
        extra: %{patient_id: "p-123", type: :consultation}
      }

  ## Visibility tiers

  The `visibility` field controls which views an event appears in.
  Higher values mean the event appears in more zoomed-out views.
  Uses multiples of 10 for granularity between tiers.

  | Visibility | Shows in                          | Example use          |
  |-----------|-----------------------------------|----------------------|
  | 10        | Day only                          | Lunch breaks, focus time |
  | 20        | Day + Week (default)              | Regular meetings     |
  | 25        | Day + Week                        | Slightly important   |
  | 30        | Day + Week + Month                | Key deadlines        |
  | 35        | Day + Week + Month                | High priority        |
  | 40        | Day + Week + Month + Year         | Company milestones   |

  The CalendarComponent applies view-specific thresholds automatically.
  Override with `min_visibility` attribute on the component.
  """

  require Logger

  @enforce_keys [:id, :start]
  defstruct [
    # Identity
    :id,

    # Content
    :title,
    :description,
    :location,
    :url,

    # Timing
    :start,
    :end,

    # Display
    :color,
    :text_color,
    :class,

    # Grouping
    :group_id,
    :resource_id,
    :resource_ids,
    :category,

    # Recurrence
    :rrule,
    :recurrence_id,

    # Visual hints (optional — used for conditional rendering)
    :icon,
    :badge,
    :border_color,

    # Fields with defaults (must come after bare fields)
    visibility: 20,
    all_day: false,
    display: :auto,
    editable: true,
    overlap: true,
    status: :confirmed,
    transparency: :opaque,
    priority: :normal,
    urgency: :none,
    extra: %{}
  ]

  @type display :: :auto | :background | :inverse_background | :none
  @type status :: :confirmed | :tentative | :cancelled | :pending_approval | :no_show
  @type transparency :: :opaque | :transparent
  @type priority :: :low | :normal | :high | :urgent
  @type urgency :: :none | :attention | :warning | :critical

  @type t :: %__MODULE__{
          id: term(),
          title: String.t() | nil,
          description: String.t() | nil,
          location: String.t() | nil,
          url: String.t() | nil,
          start: Date.t() | DateTime.t() | NaiveDateTime.t(),
          end: Date.t() | DateTime.t() | NaiveDateTime.t() | nil,
          visibility: pos_integer(),
          all_day: boolean(),
          color: String.t() | nil,
          text_color: String.t() | nil,
          class: String.t() | nil,
          display: display(),
          group_id: term() | nil,
          resource_id: term() | nil,
          resource_ids: [term()] | nil,
          category: String.t() | atom() | nil,
          editable: boolean(),
          overlap: boolean(),
          status: status(),
          transparency: transparency(),
          priority: priority(),
          urgency: urgency(),
          rrule: String.t() | nil,
          recurrence_id: term() | nil,
          icon: String.t() | nil,
          badge: String.t() | nil,
          border_color: String.t() | nil,
          extra: map()
        }

  @doc """
  Returns whether this event meets a minimum visibility threshold.

  Events with `visibility >= min_visibility` are considered visible.
  Default event visibility is 20 (shows in day and week views).

  ## View defaults

  | View     | Threshold | Shows events with visibility >= |
  |----------|-----------|-------------------------------|
  | Day      | 10        | 10+ (almost everything)       |
  | Week     | 20        | 20+ (default events)          |
  | Month    | 30        | 30+ (important only)          |
  | Year     | 40        | 40+ (highest importance)      |

  ## Examples

      iex> Event.visible_at?(%Event{id: 1, start: ~D[2026-04-01], visibility: 20}, 30)
      false

      iex> Event.visible_at?(%Event{id: 1, start: ~D[2026-04-01], visibility: 30}, 30)
      true
  """
  @spec visible_at?(t(), pos_integer()) :: boolean()
  def visible_at?(%__MODULE__{visibility: vis}, min_visibility), do: vis >= min_visibility

  @doc """
  Returns whether this event is an all-day event.

  An event is considered all-day if `all_day` is `true` or if
  `start` is a `Date` (not a `DateTime` or `NaiveDateTime`).
  """
  @spec all_day?(t()) :: boolean()
  def all_day?(%__MODULE__{all_day: true}), do: true
  def all_day?(%__MODULE__{start: %Date{}}), do: true
  def all_day?(%__MODULE__{}), do: false

  @doc """
  Returns the effective end time/date for this event.

  If `end` is nil, applies a default duration:
  - All-day events: start + 1 day
  - Timed events: start + 30 minutes
  """
  @spec effective_end(t()) :: Date.t() | DateTime.t() | NaiveDateTime.t()
  def effective_end(%__MODULE__{end: end_time}) when not is_nil(end_time), do: end_time

  def effective_end(%__MODULE__{start: %Date{} = start}),
    do: Date.add(start, 1)

  def effective_end(%__MODULE__{start: %DateTime{} = start}),
    do: DateTime.add(start, 30 * 60, :second)

  def effective_end(%__MODULE__{start: %NaiveDateTime{} = start}),
    do: NaiveDateTime.add(start, 30 * 60, :second)

  @doc """
  Returns the duration of the event in seconds.

  For all-day events, returns the number of days multiplied by 86400.
  """
  @spec duration_seconds(t()) :: integer()
  def duration_seconds(%__MODULE__{} = event) do
    start = event.start
    end_time = effective_end(event)

    case {start, end_time} do
      {%Date{} = s, %Date{} = e} ->
        Date.diff(e, s) * 86_400

      {%DateTime{} = s, %DateTime{} = e} ->
        DateTime.diff(e, s, :second)

      {%NaiveDateTime{} = s, %NaiveDateTime{} = e} ->
        NaiveDateTime.diff(e, s, :second)

      _ ->
        # Mismatched types — convert both to date and return days in seconds
        Logger.warning(
          "[PhoenixLiveCalendar] Mismatched date types in duration_seconds for event #{inspect(event.id)}"
        )

        Date.diff(to_date(end_time), to_date(start)) * 86_400
    end
  end

  @doc """
  Returns whether this event spans multiple days.
  """
  @spec multi_day?(t()) :: boolean()
  def multi_day?(%__MODULE__{} = event) do
    start_date = to_date(event.start)
    end_date = to_date(effective_end(event))
    Date.diff(end_date, start_date) > 1
  end

  @doc """
  Returns whether this event overlaps with the given date range `[range_start, range_end)`.
  """
  @spec overlaps_range?(t(), Date.t() | DateTime.t(), Date.t() | DateTime.t()) :: boolean()
  def overlaps_range?(%__MODULE__{} = event, range_start, range_end) do
    event_start = event.start
    event_end = effective_end(event)

    compare(event_start, range_end) == :lt and compare(event_end, range_start) == :gt
  end

  @doc """
  Returns whether this event falls on the given date.
  """
  @spec on_date?(t(), Date.t()) :: boolean()
  def on_date?(%__MODULE__{} = event, %Date{} = date) do
    event_start = to_date(event.start)
    event_end = to_date(effective_end(event))

    # For timed events (not all-day), the end date needs special handling:
    # A timed event ending at 10:30 on April 1 still occupies April 1.
    # Only all-day events use exclusive end dates at the date level.
    # For timed events, if end is on the same date as start, it occupies that date.
    effective_end_date =
      if all_day?(event) do
        event_end
      else
        # Timed event: add 1 day for comparison because the event occupies
        # at least part of that day. UNLESS the end time is exactly midnight
        # (00:00:00), which means the event ended at the boundary and should
        # NOT count as occupying the next day.
        end_time = end_time_of(effective_end(event))

        if end_time != nil and end_time == ~T[00:00:00] do
          event_end
        else
          Date.add(event_end, 1)
        end
      end

    Date.compare(event_start, date) != :gt and Date.compare(effective_end_date, date) == :gt
  end

  # Compare two date/datetime values regardless of type
  defp compare(%Date{} = a, %Date{} = b), do: Date.compare(a, b)
  defp compare(%DateTime{} = a, %DateTime{} = b), do: DateTime.compare(a, b)
  defp compare(%NaiveDateTime{} = a, %NaiveDateTime{} = b), do: NaiveDateTime.compare(a, b)
  defp compare(a, b), do: Date.compare(to_date(a), to_date(b))

  defp to_date(%Date{} = d), do: d
  defp to_date(%DateTime{} = dt), do: DateTime.to_date(dt)
  defp to_date(%NaiveDateTime{} = ndt), do: NaiveDateTime.to_date(ndt)

  defp end_time_of(%DateTime{} = dt), do: DateTime.to_time(dt)
  defp end_time_of(%NaiveDateTime{} = ndt), do: NaiveDateTime.to_time(ndt)
  defp end_time_of(%Date{}), do: nil
end