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).
174 lines
5.3 KiB
Elixir
174 lines
5.3 KiB
Elixir
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
|