towerops/lib/towerops_web/live/schedule_live/show.ex
Graham McIntire e7bca6174c feat(on_call): schedules list with gantt timeline (chunk 2)
Resolver
- Resolver.resolve_range/3: returns the contiguous on-call segments for a
  schedule across [start_at, end_at). Walks layer handoffs + override
  boundaries, drops gap segments, and merges adjacent same-user segments.

UserColor module
- New Towerops.OnCall.UserColor with deterministic id -> color mapping
  (Tailwind class + matching #RRGGBB hex). Refactored ScheduleLive.Show
  to use it instead of its own per-page index palette so the same person
  gets the same color across the schedules list and detail pages.

ScheduleLive.Index
- Date navigation: Today / prev / next + 1/2/4-week range selector.
- URL-driven: ?start=YYYY-MM-DD&range=N for shareable views; falls back
  to defaults on malformed values.
- Per-row card: schedule name link, on-call-now avatar swatch, day-of-week
  header strip, gantt strip rendered as a CSS grid with one column per day,
  Today marker overlay.

Tests: 5 new resolver tests, 9 new UserColor tests, 5 new ScheduleLive
integration tests. Full suite: 10,210 / 0 failures. Format/dialyzer clean.

Also: cleaned up two stale inline comments in k8s/deployment.yaml that
disagreed with the values they sat next to (replicas/maxSurge).
2026-04-28 12:48:52 -05:00

364 lines
11 KiB
Elixir

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)
|> assign(:show_add_layer, false)
|> 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("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
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