diff --git a/lib/towerops_web/live/schedule_live/show.ex b/lib/towerops_web/live/schedule_live/show.ex index 4bc6fdb1..bca433ac 100644 --- a/lib/towerops_web/live/schedule_live/show.ex +++ b/lib/towerops_web/live/schedule_live/show.ex @@ -5,8 +5,15 @@ defmodule ToweropsWeb.ScheduleLive.Show do alias Towerops.OnCall alias Towerops.OnCall.Layer alias Towerops.OnCall.Override + alias Towerops.OnCall.Resolver alias Towerops.Organizations + @timeline_days 14 + @user_colors ~w( + bg-blue-500 bg-purple-500 bg-green-500 bg-orange-500 bg-pink-500 + bg-teal-500 bg-indigo-500 bg-red-500 bg-cyan-500 bg-amber-500 + ) + @impl true def mount(%{"id" => id}, _session, socket) do schedule = OnCall.get_schedule!(id) @@ -14,6 +21,8 @@ defmodule ToweropsWeb.ScheduleLive.Show do org_id = socket.assigns.current_scope.organization.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 @@ -25,10 +34,41 @@ defmodule ToweropsWeb.ScheduleLive.Show do |> assign(:org_users, users) |> assign(:show_add_layer, false) |> assign(:show_add_override, false) - |> assign(:layer_form, to_form(Layer.changeset(%Layer{}, %{}))) + |> 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 @@ -44,7 +84,27 @@ defmodule ToweropsWeb.ScheduleLive.Show do end def handle_event("toggle_add_layer", _params, socket) do - {:noreply, assign(socket, :show_add_layer, !socket.assigns.show_add_layer)} + {: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 @@ -70,11 +130,18 @@ defmodule ToweropsWeb.ScheduleLive.Show do |> Map.put("position", next_position) case OnCall.create_layer(params) do - {:ok, _layer} -> + {: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(:layer_form, to_form(Layer.changeset(%Layer{}, %{}))) + |> assign(:new_layer_members, []) + |> assign(:layer_form, to_form(Layer.changeset(%Layer{}, default_layer_attrs()))) |> reload_schedule()} {:error, changeset} -> @@ -95,6 +162,8 @@ defmodule ToweropsWeb.ScheduleLive.Show do 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)) @@ -174,6 +243,21 @@ defmodule ToweropsWeb.ScheduleLive.Show do 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) @@ -181,5 +265,106 @@ defmodule ToweropsWeb.ScheduleLive.Show do 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 = + all_users + |> Enum.with_index() + |> Map.new(fn {user, idx} -> {user.id, Enum.at(@user_colors, rem(idx, length(@user_colors)))} 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 + start_dt = DateTime.new!(start_date, ~T[00:00:00], "Etc/UTC") + total_hours = num_days * 24 + + 0..(total_hours - 1) + |> Enum.map(fn hour_offset -> + dt = DateTime.add(start_dt, hour_offset * 3600, :second) + user = resolve_layer_only(layer, dt) + {hour_offset, user} + end) + |> Enum.chunk_by(fn {_, user} -> user && user.id end) + |> Enum.map(fn chunk -> + {start_offset, user} = List.first(chunk) + duration = length(chunk) + + %{ + user: user, + start_offset: start_offset, + duration: duration, + total_hours: total_hours + } + end) + |> Enum.reject(fn span -> is_nil(span.user) end) + end + + defp build_final_spans(schedule, start_date, num_days) do + start_dt = DateTime.new!(start_date, ~T[00:00:00], "Etc/UTC") + total_hours = num_days * 24 + + 0..(total_hours - 1) + |> Enum.map(fn hour_offset -> + dt = DateTime.add(start_dt, hour_offset * 3600, :second) + user = Resolver.resolve(schedule, dt) + {hour_offset, user} + end) + |> Enum.chunk_by(fn {_, user} -> user && user.id end) + |> Enum.map(fn chunk -> + {start_offset, user} = List.first(chunk) + duration = length(chunk) + + %{ + user: user, + start_offset: start_offset, + duration: duration, + total_hours: total_hours + } + end) + |> Enum.reject(fn span -> is_nil(span.user) end) + end + + defp resolve_layer_only(layer, datetime) do + with true <- DateTime.compare(datetime, layer.start_date) in [:gt, :eq], + members = Enum.sort_by(layer.members, & &1.position), + true <- members != [] do + elapsed = DateTime.diff(datetime, layer.start_date, :second) + rotation_secs = rotation_seconds(layer) + position = elapsed |> div(rotation_secs) |> rem(length(members)) + Enum.at(members, position).user + else + _ -> nil + end + end + + defp rotation_seconds(%{rotation_type: "daily", rotation_interval: i}), do: i * 86_400 + defp rotation_seconds(%{rotation_type: "weekly", rotation_interval: i}), do: i * 604_800 + defp rotation_seconds(%{rotation_interval: i}), do: i * 86_400 end diff --git a/lib/towerops_web/live/schedule_live/show.html.heex b/lib/towerops_web/live/schedule_live/show.html.heex index 77a8a7bb..fd6628b9 100644 --- a/lib/towerops_web/live/schedule_live/show.html.heex +++ b/lib/towerops_web/live/schedule_live/show.html.heex @@ -61,7 +61,9 @@ {t("Currently On-Call")}
<%= if @on_call do %> -{@on_call.email}
++ {display_name(@on_call)} +
<% else %>{t("No one is currently on-call")} @@ -71,6 +73,149 @@ + <%!-- Timeline --%> +