defmodule ToweropsWeb.ScheduleLive.Show do @moduledoc false use ToweropsWeb, :live_view alias Towerops.OnCall alias Towerops.OnCall.Layer alias Towerops.OnCall.Override alias Towerops.OnCall.Resolver alias Towerops.OnCall.UserColor alias Towerops.Organizations @timeline_days 14 @impl true def mount(%{"id" => id}, _session, socket) do org_id = socket.assigns.current_scope.organization.id schedule = OnCall.get_schedule!(id, org_id) on_call = OnCall.who_is_on_call(schedule.id) members = Organizations.list_organization_members(org_id) users = Enum.map(members, & &1.user) today = Date.utc_today() timeline_start = Date.beginning_of_week(today, :sunday) {:ok, socket |> assign(:page_title, schedule.name) |> assign(:active_page, "schedules") |> assign(:schedule, schedule) |> assign(:on_call, on_call) |> assign(:timezone, socket.assigns.current_scope.timezone) |> assign(:org_users, users) # Pre-open the Add Layer form for freshly-created schedules so the # new-schedule -> add-layers flow lands in a state ready to act on. |> assign(:show_add_layer, schedule.layers == []) |> assign(:show_add_override, false) |> assign(:new_layer_members, []) |> assign(:timeline_start, timeline_start) |> assign_timeline(schedule, timeline_start) |> assign(:layer_form, to_form(Layer.changeset(%Layer{}, default_layer_attrs()))) |> assign(:override_form, to_form(Override.changeset(%Override{}, %{})))} end def handle_event("timeline_prev", _params, socket) do new_start = Date.add(socket.assigns.timeline_start, -@timeline_days) {:noreply, socket |> assign(:timeline_start, new_start) |> assign_timeline(socket.assigns.schedule, new_start)} end def handle_event("timeline_next", _params, socket) do new_start = Date.add(socket.assigns.timeline_start, @timeline_days) {:noreply, socket |> assign(:timeline_start, new_start) |> assign_timeline(socket.assigns.schedule, new_start)} end def handle_event("timeline_today", _params, socket) do today = Date.utc_today() new_start = Date.beginning_of_week(today, :sunday) {:noreply, socket |> assign(:timeline_start, new_start) |> assign_timeline(socket.assigns.schedule, new_start)} end @impl true def handle_event("delete", _params, socket) do case OnCall.delete_schedule(socket.assigns.schedule) do {:ok, _} -> {:noreply, socket |> put_flash(:info, t("Schedule deleted")) |> push_navigate(to: ~p"/schedules")} {:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to delete schedule"))} end end def handle_event("toggle_add_layer", _params, socket) do {:noreply, socket |> assign(:show_add_layer, !socket.assigns.show_add_layer) |> assign(:new_layer_members, [])} end def handle_event("new_layer_add_user", %{"user_id" => ""}, socket), do: {:noreply, socket} def handle_event("new_layer_add_user", %{"user_id" => user_id}, socket) do user = Enum.find(socket.assigns.org_users, &(&1.id == user_id)) if user && user.id not in Enum.map(socket.assigns.new_layer_members, & &1.id) do {:noreply, assign(socket, :new_layer_members, socket.assigns.new_layer_members ++ [user])} else {:noreply, socket} end end def handle_event("new_layer_remove_user", %{"id" => user_id}, socket) do members = Enum.reject(socket.assigns.new_layer_members, &(&1.id == user_id)) {:noreply, assign(socket, :new_layer_members, members)} end def handle_event("toggle_add_override", _params, socket) do {:noreply, assign(socket, :show_add_override, !socket.assigns.show_add_override)} end def handle_event("validate_layer", %{"layer" => params}, socket) do changeset = %Layer{} |> Layer.changeset(params) |> Map.put(:action, :validate) {:noreply, assign(socket, :layer_form, to_form(changeset))} end def handle_event("save_layer", %{"layer" => params}, socket) do schedule = socket.assigns.schedule next_position = length(schedule.layers) params = params |> Map.put("schedule_id", schedule.id) |> Map.put("position", next_position) case OnCall.create_layer(params) do {:ok, layer} -> socket.assigns.new_layer_members |> Enum.with_index() |> Enum.each(fn {user, idx} -> OnCall.add_layer_member(%{layer_id: layer.id, user_id: user.id, position: idx}) end) {:noreply, socket |> assign(:show_add_layer, false) |> assign(:new_layer_members, []) |> assign(:layer_form, to_form(Layer.changeset(%Layer{}, default_layer_attrs()))) |> reload_schedule()} {:error, changeset} -> {:noreply, assign(socket, :layer_form, to_form(changeset))} end end def handle_event("move_layer", %{"id" => id, "direction" => direction}, socket) when direction in ["up", "down"] do layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == id)) if layer do {:ok, _} = OnCall.move_layer(layer, String.to_existing_atom(direction)) {:noreply, reload_schedule(socket)} else {:noreply, socket} end end def handle_event("reorder_layers", %{"ordered_ids" => ordered_ids}, socket) when is_list(ordered_ids) do case OnCall.reorder_layers(socket.assigns.schedule.id, ordered_ids) do :ok -> {:noreply, reload_schedule(socket)} {:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to reorder layers"))} end end def handle_event("set_layer_restriction", params, socket) do %{"layer_id" => layer_id, "restriction_type" => restriction_type} = params layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id)) if layer do apply_layer_restriction(socket, layer, restriction_type, params) else {:noreply, socket} end end def handle_event("delete_layer", %{"id" => layer_id}, socket) do layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id)) if layer do case OnCall.delete_layer(layer) do {:ok, _} -> {:noreply, reload_schedule(socket)} {:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to delete layer"))} end else {:noreply, socket} end end def handle_event("add_member", %{"user_id" => ""}, socket), do: {:noreply, socket} def handle_event("add_member", %{"layer_id" => layer_id, "user_id" => user_id}, socket) do layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id)) if layer do next_position = length(layer.members) case OnCall.add_layer_member(%{ layer_id: layer_id, user_id: user_id, position: next_position }) do {:ok, _} -> {:noreply, reload_schedule(socket)} {:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to add member"))} end else {:noreply, socket} end end def handle_event("remove_member", %{"id" => member_id}, socket) do member = socket.assigns.schedule.layers |> Enum.flat_map(& &1.members) |> Enum.find(&(&1.id == member_id)) if member do case OnCall.remove_layer_member(member) do {:ok, _} -> {:noreply, reload_schedule(socket)} {:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to remove member"))} end else {:noreply, socket} end end def handle_event("validate_override", %{"override" => params}, socket) do changeset = %Override{} |> Override.changeset(params) |> Map.put(:action, :validate) {:noreply, assign(socket, :override_form, to_form(changeset))} end def handle_event("save_override", %{"override" => params}, socket) do schedule = socket.assigns.schedule user_id = socket.assigns.current_scope.user.id params = params |> Map.put("schedule_id", schedule.id) |> Map.put("created_by_id", user_id) case OnCall.create_override(params) do {:ok, _} -> {:noreply, socket |> assign(:show_add_override, false) |> assign(:override_form, to_form(Override.changeset(%Override{}, %{}))) |> reload_schedule()} {:error, changeset} -> {:noreply, assign(socket, :override_form, to_form(changeset))} end end def handle_event("delete_override", %{"id" => override_id}, socket) do override = Enum.find(socket.assigns.schedule.overrides, &(&1.id == override_id)) if override do case OnCall.delete_override(override) do {:ok, _} -> {:noreply, reload_schedule(socket)} {:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to delete override"))} end else {:noreply, socket} end end 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 default_layer_attrs do today = Date.utc_today() %{ handoff_time: ~T[08:00:00], start_date: DateTime.new!(today, ~T[08:00:00], "Etc/UTC") } end defp reload_schedule(socket) do schedule = OnCall.get_schedule!(socket.assigns.schedule.id) on_call = OnCall.who_is_on_call(schedule.id) socket |> assign(:schedule, schedule) |> assign(:on_call, on_call) |> assign_timeline(schedule, socket.assigns.timeline_start) end defp assign_timeline(socket, schedule, start_date) do end_date = Date.add(start_date, @timeline_days - 1) days = start_date |> Date.range(end_date) |> Enum.to_list() layer_timelines = schedule.layers |> Enum.sort_by(& &1.position) |> Enum.map(fn layer -> spans = build_layer_spans(layer, start_date, @timeline_days) %{layer: layer, spans: spans} end) final_spans = build_final_spans(schedule, start_date, @timeline_days) all_users = (Enum.flat_map(layer_timelines, fn lt -> Enum.map(lt.spans, & &1.user) end) ++ Enum.map(final_spans, & &1.user)) |> Enum.reject(&is_nil/1) |> Enum.uniq_by(& &1.id) user_colors = Map.new(all_users, fn user -> {user.id, UserColor.class_for(user)} end) socket |> assign(:timeline_days, days) |> assign(:timeline_end, end_date) |> assign(:layer_timelines, layer_timelines) |> assign(:final_spans, final_spans) |> assign(:user_colors, user_colors) end defp build_layer_spans(layer, start_date, num_days) do # Resolve a single-layer "schedule" so we get per-layer coverage # without overrides bleeding in. The Resolver only reads :layers and # :overrides off the struct, so a stub keyword-style struct is fine. layer_schedule = %Towerops.OnCall.Schedule{layers: [layer], overrides: []} segments_to_spans(layer_schedule, start_date, num_days) end defp build_final_spans(schedule, start_date, num_days) do segments_to_spans(schedule, start_date, num_days) end defp segments_to_spans(schedule, start_date, num_days) do total_hours = num_days * 24 {:ok, start_at} = DateTime.new(start_date, ~T[00:00:00], "Etc/UTC") end_at = DateTime.add(start_at, total_hours * 3600, :second) schedule |> Resolver.resolve_range(start_at, end_at) |> Enum.map(fn segment -> start_offset = max(div(DateTime.diff(segment.start_at, start_at, :second), 3600), 0) end_offset = min(div(DateTime.diff(segment.end_at, start_at, :second), 3600), total_hours) duration = max(end_offset - start_offset, 1) %{ user: segment.user, start_offset: start_offset, duration: duration, total_hours: total_hours } end) end # --- Layer restriction helpers --- defp apply_layer_restriction(socket, layer, restriction_type, params) do restrictions = build_restrictions(restriction_type, params) result = if is_nil(restrictions) do OnCall.clear_layer_restrictions(layer) else OnCall.update_layer_restrictions(layer, %{ restriction_type: restriction_type, restrictions: restrictions }) end case result do {:ok, _} -> {:noreply, reload_schedule(socket)} {:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to update restrictions"))} end end defp build_restrictions("", _params), do: nil defp build_restrictions("time_of_day", params) do %{ "start_time" => params["start_time"] || "09:00", "end_time" => params["end_time"] || "17:00" } end defp build_restrictions("time_of_week", params) do %{ "start_time" => params["start_time"] || "09:00", "end_time" => params["end_time"] || "17:00", "days" => normalize_days(params["days"]) } end defp build_restrictions(_, _params), do: nil defp normalize_days(nil), do: [] defp normalize_days(list) when is_list(list), do: list defp normalize_days(map) when is_map(map), do: map |> Map.keys() |> Enum.filter(&(map[&1] == "true")) defp normalize_days(other) when is_binary(other), do: [other] defp normalize_days(_), do: [] end