towerops/lib/towerops_web/live/schedule_live/index.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

174 lines
5.3 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
@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)}
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"])
org_id = socket.assigns.current_scope.organization.id
{:noreply,
socket
|> assign(:tab, tab)
|> assign(:start_date, start_date)
|> assign(:range_days, range_days)
|> 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
defp navigate_to_window(socket, start_date, range_days) do
push_patch(socket,
to: ~p"/schedules?#{[tab: socket.assigns.tab, start: Date.to_iso8601(start_date), range: range_days]}"
)
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)
_ ->
schedules = load_schedules_with_timeline(org_id, socket.assigns.start_date, socket.assigns.range_days)
assign(socket, :schedules, schedules)
end
end
defp load_schedules_with_timeline(org_id, 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")
org_id
|> OnCall.list_schedules()
|> Enum.map(fn schedule ->
preloaded = OnCall.get_schedule!(schedule.id)
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
# 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