defmodule ToweropsWeb.ScheduleLive.Index do @moduledoc false use ToweropsWeb, :live_view alias Towerops.OnCall alias Towerops.OnCall.Resolver alias Towerops.OnCall.UserColor @valid_ranges [7, 14, 28] @default_range 14 @per_page 10 @impl true def mount(_params, _session, socket) do {:ok, socket |> assign(:page_title, t("Schedules")) |> assign(:active_page, "schedules") |> assign(:tab, "schedules") |> assign(:start_date, default_start_date()) |> assign(:range_days, @default_range) |> assign(:search, "") |> assign(:page, 1)} end @impl true def handle_params(params, _url, socket) do tab = Map.get(params, "tab", "schedules") start_date = parse_start_date(params["start"]) range_days = parse_range_days(params["range"]) search = params["search"] |> to_string() |> String.trim() page = parse_page(params["page"]) org_id = socket.assigns.current_scope.organization.id {:noreply, socket |> assign(:tab, tab) |> assign(:start_date, start_date) |> assign(:range_days, range_days) |> assign(:search, search) |> assign(:page, page) |> load_data(org_id)} end @impl true def handle_event("today", _params, socket) do {:noreply, navigate_to_window(socket, default_start_date(), socket.assigns.range_days)} end def handle_event("prev", _params, socket) do new_start = Date.add(socket.assigns.start_date, -socket.assigns.range_days) {:noreply, navigate_to_window(socket, new_start, socket.assigns.range_days)} end def handle_event("next", _params, socket) do new_start = Date.add(socket.assigns.start_date, socket.assigns.range_days) {:noreply, navigate_to_window(socket, new_start, socket.assigns.range_days)} end def handle_event("set_range", %{"range" => range}, socket) do parsed = parse_range_days(range) {:noreply, navigate_to_window(socket, socket.assigns.start_date, parsed)} end def handle_event("search", %{"search" => search}, socket) do overrides = %{search: String.trim(search), page: 1} {:noreply, push_patch(socket, to: ~p"/schedules?#{base_query_params(socket, overrides)}" )} end defp navigate_to_window(socket, start_date, range_days) do overrides = %{start: Date.to_iso8601(start_date), range: range_days, page: 1} push_patch(socket, to: ~p"/schedules?#{base_query_params(socket, overrides)}" ) end defp base_query_params(socket, overrides) do %{ tab: socket.assigns.tab, start: Date.to_iso8601(socket.assigns.start_date), range: socket.assigns.range_days, search: socket.assigns.search, page: socket.assigns.page } |> Map.merge(overrides) |> Enum.reject(fn {_k, v} -> v in [nil, "", 1] end) |> Map.new() end defp load_data(socket, org_id) do case socket.assigns.tab do "escalation-policies" -> policies = OnCall.list_escalation_policies(org_id) assign(socket, :policies, policies) _ -> all_schedules = OnCall.list_schedules(org_id) filtered = filter_schedules(all_schedules, socket.assigns.search) total_count = length(filtered) total_pages = max(1, ceil(total_count / @per_page)) page = max(1, min(socket.assigns.page, total_pages)) page_schedules = filtered |> Enum.slice((page - 1) * @per_page, @per_page) |> hydrate_schedules(socket.assigns.start_date, socket.assigns.range_days) socket |> assign(:schedules, page_schedules) |> assign(:page, page) |> assign(:pagination, %{ page: page, per_page: @per_page, total_count: total_count, total_pages: total_pages }) end end defp filter_schedules(schedules, ""), do: schedules defp filter_schedules(schedules, term) when is_binary(term) do needle = String.downcase(term) Enum.filter(schedules, fn s -> String.contains?(String.downcase(s.name || ""), needle) end) end defp hydrate_schedules(schedules, start_date, range_days) do end_date = Date.add(start_date, range_days) {:ok, start_at} = DateTime.new(start_date, ~T[00:00:00], "Etc/UTC") {:ok, end_at} = DateTime.new(end_date, ~T[00:00:00], "Etc/UTC") preloaded_schedules = Towerops.Repo.preload(schedules, overrides: :user, layers: [members: :user]) Enum.map(preloaded_schedules, fn preloaded -> schedule = preloaded on_call = Resolver.resolve(preloaded, DateTime.utc_now()) segments = preloaded |> Resolver.resolve_range(start_at, end_at) |> Enum.map(fn segment -> Map.merge(segment, %{ color_class: UserColor.class_for(segment.user), label: display_name(segment.user), day_offset: day_offset(segment.start_at, start_date), day_span: day_span(segment.start_at, segment.end_at) }) end) schedule |> Map.put(:current_on_call, on_call) |> Map.put(:current_on_call_name, display_name(on_call)) |> Map.put(:current_on_call_color, UserColor.class_for(on_call)) |> Map.put(:segments, segments) end) end defp parse_page(nil), do: 1 defp parse_page(value) when is_binary(value) do case Integer.parse(value) do {int, ""} when int >= 1 -> int _ -> 1 end end defp parse_page(value) when is_integer(value) and value >= 1, do: value defp parse_page(_), do: 1 # Day index (0-based) within the visible window. defp day_offset(%DateTime{} = at, %Date{} = window_start) do at |> DateTime.to_date() |> Date.diff(window_start) |> max(0) end # How many day-columns this segment spans (minimum 1). defp day_span(%DateTime{} = start_at, %DateTime{} = end_at) do start_date = DateTime.to_date(start_at) end_date = DateTime.to_date(end_at) end_date |> Date.diff(start_date) |> max(1) end def days_in_window(start_date, range_days) do Enum.map(0..(range_days - 1), &Date.add(start_date, &1)) end def window_label(start_date, range_days) do end_date = Date.add(start_date, range_days - 1) "#{format_short(start_date)} – #{format_short(end_date)}" end def today_offset(start_date, range_days) do today = Date.utc_today() cond do Date.before?(today, start_date) -> nil Date.diff(today, start_date) >= range_days -> nil true -> Date.diff(today, start_date) end end defp format_short(date), do: Calendar.strftime(date, "%b %-d") defp default_start_date do Date.beginning_of_week(Date.utc_today(), :sunday) end defp parse_start_date(nil), do: default_start_date() defp parse_start_date(value) when is_binary(value) do case Date.from_iso8601(value) do {:ok, date} -> date _ -> default_start_date() end end defp parse_range_days(nil), do: @default_range defp parse_range_days(value) when is_binary(value) do case Integer.parse(value) do {int, ""} when int in @valid_ranges -> int _ -> @default_range end end defp parse_range_days(value) when is_integer(value) and value in @valid_ranges, do: value defp parse_range_days(_), do: @default_range defp display_name(%{first_name: first, last_name: last}) when first != nil and last != nil do "#{first} #{last}" end defp display_name(%{email: email}), do: email defp display_name(nil), do: nil end