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 --%> +

+ <%!-- Timeline navigation --%> +
+
+ + + + + {Calendar.strftime(@timeline_start, "%b %-d")} – {Calendar.strftime( + @timeline_end, + "%b %-d, %Y" + )} + +
+ + {t("2 Weeks")} + +
+ + <%!-- Layer timelines --%> + <%= if @layer_timelines != [] do %> +
+

+ {t("Configuration Layers")} +

+ + <%= for lt <- @layer_timelines do %> +
+ <%!-- Day headers --%> +
+
+
+ <%= for day <- @timeline_days do %> +
+ + {Calendar.strftime(day, "%-d %a")} + +
+ <% end %> +
+
+ <%!-- Timeline bar --%> +
+
+ + {lt.layer.name} + +
+
+ <%= for span <- lt.spans do %> +
+ + {display_name(span.user)} + +
+ <% end %> + <%!-- Today marker --%> + <% today_offset = Date.diff(Date.utc_today(), @timeline_start) %> + <%= if today_offset >= 0 and today_offset < @timeline_days do %> +
+
+ <% end %> +
+
+
+ <% end %> +
+ + <%!-- Final Schedule --%> +
+

+ {t("Final Schedule")} +

+ <%!-- Day headers --%> +
+
+
+ <%= for day <- @timeline_days do %> +
+ + {Calendar.strftime(day, "%-d %a")} + +
+ <% end %> +
+
+ <%!-- Timeline bar --%> +
+
+
+ <%= for span <- @final_spans do %> +
+ + {display_name(span.user)} + +
+ <% end %> + <%!-- Today marker --%> + <% today_offset = Date.diff(Date.utc_today(), @timeline_start) %> + <%= if today_offset >= 0 and today_offset < @timeline_days do %> +
+
+ <% end %> +
+
+
+ <% end %> +
+ <%!-- Layers --%>
@@ -127,6 +272,45 @@ label={t("Start Date")} />
+ + <%!-- User selection for new layer --%> +
+ + <%= if @new_layer_members != [] do %> +
+ <%= for user <- @new_layer_members do %> + + {display_name(user)} + + + <% end %> +
+ <% end %> + <% new_member_ids = Enum.map(@new_layer_members, & &1.id) %> + <% available = Enum.reject(@org_users, &(&1.id in new_member_ids)) %> + <%= unless Enum.empty?(available) do %> + + <% end %> +
+
<.button type="submit" phx-disable-with={t("Saving...")}> {t("Add Layer")} @@ -192,7 +376,7 @@
<%= for member <- Enum.sort_by(layer.members, & &1.position) do %> - {member.user.email} + {display_name(member.user)} - + <% end %>
@@ -257,7 +433,7 @@ field={@override_form[:user_id]} type="select" label={t("User")} - options={Enum.map(@org_users, &{&1.email, &1.id})} + options={Enum.map(@org_users, &{display_name(&1), &1.id})} prompt={t("Select user...")} /> <.input @@ -314,7 +490,7 @@ <%= for override <- @schedule.overrides do %> - {override.user.email} + {display_name(override.user)} {Calendar.strftime(override.start_time, "%Y-%m-%d %H:%M")}