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).
This commit is contained in:
parent
e4686f31ce
commit
e7bca6174c
11 changed files with 689 additions and 62 deletions
|
|
@ -1,3 +1,22 @@
|
|||
2026-04-28
|
||||
feat(on_call): schedules list with gantt timeline (chunk 2)
|
||||
- Resolver.resolve_range/3 returns contiguous on-call segments across a
|
||||
date window; merges adjacent same-user segments; drops gap segments.
|
||||
- New Towerops.OnCall.UserColor module with deterministic id->color mapping
|
||||
(Tailwind class + matching #RRGGBB hex). ScheduleLive.Show refactored to
|
||||
use it (drops the per-page index-based palette).
|
||||
- ScheduleLive.Index: date navigation (Today / prev / next + 1/2/4-week
|
||||
range selector), URL-driven (?start=YYYY-MM-DD&range=N) for shareable
|
||||
views, per-row gantt strip with day headers + Today marker, on-call-now
|
||||
avatar swatch matching the gantt color.
|
||||
- 5 new resolver tests (range coverage, multi-segment, merging, gap
|
||||
handling, arg validation), 9 new UserColor tests, 5 new ScheduleLive
|
||||
integration tests.
|
||||
Files: lib/towerops/on_call/resolver.ex, lib/towerops/on_call/user_color.ex,
|
||||
lib/towerops_web/live/schedule_live/{index.ex,index.html.heex,show.ex},
|
||||
test/towerops/on_call/{resolver_test.exs,user_color_test.exs},
|
||||
test/towerops_web/live/schedule_live_test.exs
|
||||
|
||||
2026-04-28
|
||||
feat(on_call): PagerDuty-style escalation policy redesign (chunk 1)
|
||||
- Added handoff_notifications_mode field to escalation_policies
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ metadata:
|
|||
name: towerops
|
||||
namespace: towerops
|
||||
spec:
|
||||
replicas: 2 # Run 3 replicas for high availability
|
||||
replicas: 2
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1 # Limit to 3 pods during rollout to avoid exceeding PG max_connections
|
||||
maxSurge: 1 # Allow 3rd pod during rollout to avoid downtime
|
||||
maxUnavailable: 0 # Keep all pods running during rollout
|
||||
minReadySeconds: 10 # Wait 10s after pod is ready before considering it available
|
||||
selector:
|
||||
|
|
|
|||
|
|
@ -26,6 +26,106 @@ defmodule Towerops.OnCall.Resolver do
|
|||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the contiguous on-call segments for `schedule` between `start_at` and
|
||||
`end_at`. Each segment is `%{user: user, start_at: dt, end_at: dt}`.
|
||||
|
||||
Adjacent segments belonging to the same user are merged. Periods where
|
||||
nobody is on call are omitted (no `nil`-user segments).
|
||||
|
||||
The schedule must have `layers` (with `:members`) and `overrides` preloaded.
|
||||
"""
|
||||
@spec resolve_range(Schedule.t(), DateTime.t(), DateTime.t()) ::
|
||||
[%{user: map(), start_at: DateTime.t(), end_at: DateTime.t()}]
|
||||
def resolve_range(%Schedule{} = schedule, %DateTime{} = start_at, %DateTime{} = end_at)
|
||||
when is_struct(start_at, DateTime) and is_struct(end_at, DateTime) do
|
||||
if DateTime.compare(start_at, end_at) != :lt do
|
||||
raise FunctionClauseError,
|
||||
module: __MODULE__,
|
||||
function: :resolve_range,
|
||||
arity: 3
|
||||
end
|
||||
|
||||
schedule
|
||||
|> handoff_points(start_at, end_at)
|
||||
|> Enum.chunk_every(2, 1, [end_at])
|
||||
|> Enum.map(fn [seg_start, seg_end] ->
|
||||
{seg_start, seg_end, resolve(schedule, seg_start)}
|
||||
end)
|
||||
|> Enum.reject(fn {_s, _e, user} -> is_nil(user) end)
|
||||
|> merge_adjacent()
|
||||
|> Enum.map(fn {s, e, user} -> %{user: user, start_at: s, end_at: e} end)
|
||||
end
|
||||
|
||||
# Returns a sorted, deduplicated list of points in `[start_at, end_at)` where
|
||||
# the on-call user could change: the start of the range, every layer's
|
||||
# start_date, every rotation handoff within the range, and every override
|
||||
# boundary.
|
||||
defp handoff_points(schedule, start_at, end_at) do
|
||||
layer_points = Enum.flat_map(schedule.layers, &layer_handoff_points(&1, start_at, end_at))
|
||||
|
||||
override_points =
|
||||
Enum.flat_map(schedule.overrides, fn o -> [o.start_time, o.end_time] end)
|
||||
|
||||
[start_at | layer_points ++ override_points]
|
||||
|> Enum.filter(fn dt ->
|
||||
DateTime.compare(dt, start_at) != :lt and DateTime.before?(dt, end_at)
|
||||
end)
|
||||
|> Enum.uniq_by(&DateTime.to_unix(&1, :second))
|
||||
|> Enum.sort(DateTime)
|
||||
end
|
||||
|
||||
defp layer_handoff_points(layer, start_at, end_at) do
|
||||
rotation_seconds = rotation_duration_seconds(layer)
|
||||
layer_start = layer.start_date
|
||||
|
||||
cond do
|
||||
DateTime.compare(layer_start, end_at) != :lt ->
|
||||
# Layer starts after the range ends — irrelevant.
|
||||
[]
|
||||
|
||||
DateTime.compare(layer_start, start_at) != :lt ->
|
||||
# Layer starts inside the range — first point is its start_date.
|
||||
layer_start
|
||||
|> Stream.iterate(&DateTime.add(&1, rotation_seconds, :second))
|
||||
|> Enum.take_while(fn dt -> DateTime.before?(dt, end_at) end)
|
||||
|
||||
true ->
|
||||
# Layer started before the range — fast-forward to first handoff
|
||||
# at or after start_at, then enumerate.
|
||||
elapsed = DateTime.diff(start_at, layer_start, :second)
|
||||
windows_passed = div(elapsed, rotation_seconds)
|
||||
first_inside = DateTime.add(layer_start, windows_passed * rotation_seconds, :second)
|
||||
|
||||
first_inside =
|
||||
if DateTime.before?(first_inside, start_at) do
|
||||
DateTime.add(first_inside, rotation_seconds, :second)
|
||||
else
|
||||
first_inside
|
||||
end
|
||||
|
||||
first_inside
|
||||
|> Stream.iterate(&DateTime.add(&1, rotation_seconds, :second))
|
||||
|> Enum.take_while(fn dt -> DateTime.before?(dt, end_at) end)
|
||||
end
|
||||
end
|
||||
|
||||
defp merge_adjacent(segments) do
|
||||
segments
|
||||
|> Enum.reduce([], fn
|
||||
{s, e, user}, [] ->
|
||||
[{s, e, user}]
|
||||
|
||||
{s, e, user}, [{ps, pe, puser} | rest] ->
|
||||
if puser.id == user.id and DateTime.compare(pe, s) == :eq do
|
||||
[{ps, e, puser} | rest]
|
||||
else
|
||||
[{s, e, user}, {ps, pe, puser} | rest]
|
||||
end
|
||||
end)
|
||||
|> Enum.reverse()
|
||||
end
|
||||
|
||||
defp resolve_override(overrides, datetime) do
|
||||
overrides
|
||||
|> Enum.find(fn override ->
|
||||
|
|
|
|||
51
lib/towerops/on_call/user_color.ex
Normal file
51
lib/towerops/on_call/user_color.ex
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
defmodule Towerops.OnCall.UserColor do
|
||||
@moduledoc """
|
||||
Deterministic user → color mapping for on-call timelines and avatars.
|
||||
|
||||
The palette intentionally matches the Tailwind classes already used by
|
||||
`ToweropsWeb.ScheduleLive.Show`; pulling it into a single place lets the
|
||||
schedule list, schedule detail, and any future on-call surface render the
|
||||
same color for the same person without having to thread a per-page index
|
||||
map through assigns.
|
||||
|
||||
Two helpers are exposed:
|
||||
|
||||
* `class_for/1` — returns a `bg-...-500` Tailwind utility class.
|
||||
* `hex_for/1` — returns a `#RRGGBB` string (useful for inline SVG fills).
|
||||
"""
|
||||
|
||||
@palette [
|
||||
{"bg-blue-500", "#3B82F6"},
|
||||
{"bg-purple-500", "#A855F7"},
|
||||
{"bg-green-500", "#22C55E"},
|
||||
{"bg-orange-500", "#F97316"},
|
||||
{"bg-pink-500", "#EC4899"},
|
||||
{"bg-teal-500", "#14B8A6"},
|
||||
{"bg-indigo-500", "#6366F1"},
|
||||
{"bg-red-500", "#EF4444"},
|
||||
{"bg-cyan-500", "#06B6D4"},
|
||||
{"bg-amber-500", "#F59E0B"}
|
||||
]
|
||||
@palette_size length(@palette)
|
||||
|
||||
@doc """
|
||||
Returns the Tailwind background-color utility class for `user_id`.
|
||||
|
||||
`user_id` may be either a string UUID or a struct/map with an `:id` field
|
||||
(so `class_for(user)` works directly).
|
||||
"""
|
||||
@spec class_for(map() | binary() | nil) :: String.t()
|
||||
def class_for(nil), do: "bg-gray-400"
|
||||
def class_for(%{id: id}), do: class_for(id)
|
||||
def class_for(id) when is_binary(id), do: @palette |> Enum.at(palette_index(id)) |> elem(0)
|
||||
|
||||
@doc """
|
||||
Returns the `#RRGGBB` hex code paired with `class_for/1`.
|
||||
"""
|
||||
@spec hex_for(map() | binary() | nil) :: String.t()
|
||||
def hex_for(nil), do: "#9CA3AF"
|
||||
def hex_for(%{id: id}), do: hex_for(id)
|
||||
def hex_for(id) when is_binary(id), do: @palette |> Enum.at(palette_index(id)) |> elem(1)
|
||||
|
||||
defp palette_index(id) when is_binary(id), do: rem(:erlang.phash2(id), @palette_size)
|
||||
end
|
||||
|
|
@ -3,30 +3,64 @@ defmodule ToweropsWeb.ScheduleLive.Index do
|
|||
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
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, t("Schedules"))
|
||||
|> assign(:active_page, "schedules")
|
||||
|> assign(:tab, "schedules")
|
||||
|> load_data(organization.id)}
|
||||
|> 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" ->
|
||||
|
|
@ -34,21 +68,103 @@ defmodule ToweropsWeb.ScheduleLive.Index do
|
|||
assign(socket, :policies, policies)
|
||||
|
||||
_ ->
|
||||
schedules = OnCall.list_schedules(org_id)
|
||||
|
||||
schedules_with_on_call =
|
||||
Enum.map(schedules, fn schedule ->
|
||||
on_call = OnCall.who_is_on_call(schedule.id)
|
||||
|
||||
schedule
|
||||
|> Map.put(:current_on_call, on_call)
|
||||
|> Map.put(:current_on_call_name, display_name(on_call))
|
||||
end)
|
||||
|
||||
assign(socket, :schedules, schedules_with_on_call)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -102,6 +102,45 @@
|
|||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%!-- Date navigation header --%>
|
||||
<div class="flex items-center justify-between flex-wrap gap-3 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
phx-click="today"
|
||||
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
{t("Today")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="prev"
|
||||
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 p-1.5 text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
title={t("Previous range")}
|
||||
>
|
||||
<.icon name="hero-chevron-left" class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="next"
|
||||
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 p-1.5 text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
title={t("Next range")}
|
||||
>
|
||||
<.icon name="hero-chevron-right" class="h-4 w-4" />
|
||||
</button>
|
||||
<span class="ml-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{window_label(@start_date, @range_days)}
|
||||
</span>
|
||||
</div>
|
||||
<form phx-change="set_range" class="inline-flex">
|
||||
<select
|
||||
name="range"
|
||||
class="rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-sm py-1.5"
|
||||
>
|
||||
<option value="7" selected={@range_days == 7}>{t("1 Week")}</option>
|
||||
<option value="14" selected={@range_days == 14}>{t("2 Weeks")}</option>
|
||||
<option value="28" selected={@range_days == 28}>{t("4 Weeks")}</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<%= if Enum.empty?(@schedules) do %>
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="text-center">
|
||||
|
|
@ -118,47 +157,85 @@
|
|||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Name")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Timezone")}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{t("Currently On-Call")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
|
||||
<%= for schedule <- @schedules do %>
|
||||
<tr
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
|
||||
phx-click={JS.navigate(~p"/schedules/#{schedule.id}")}
|
||||
>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<div class="space-y-4">
|
||||
<%= for schedule <- @schedules do %>
|
||||
<% days = days_in_window(@start_date, @range_days) %>
|
||||
<% today_idx = today_offset(@start_date, @range_days) %>
|
||||
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<.link
|
||||
navigate={~p"/schedules/#{schedule.id}"}
|
||||
class="text-base font-semibold text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{schedule.name}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
</.link>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{schedule.timezone}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<%= if schedule.current_on_call do %>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="h-2 w-2 rounded-full bg-green-500"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{t("On-Call Now")}</p>
|
||||
<%= if schedule.current_on_call do %>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class={[
|
||||
"h-2 w-2 rounded-full",
|
||||
schedule.current_on_call_color
|
||||
]}>
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{schedule.current_on_call_name}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-gray-400 dark:text-gray-500">{t("No one")}</span>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-sm text-gray-400 dark:text-gray-500">{t("No one")}</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Day header row --%>
|
||||
<div
|
||||
class="grid gap-px text-xs text-gray-500 dark:text-gray-400 mb-1"
|
||||
style={"grid-template-columns: repeat(#{@range_days}, minmax(0, 1fr));"}
|
||||
>
|
||||
<%= for {day, idx} <- Enum.with_index(days) do %>
|
||||
<div class={[
|
||||
"px-1 py-0.5 text-center",
|
||||
today_idx == idx && "font-bold text-red-600 dark:text-red-400"
|
||||
]}>
|
||||
<div class="leading-tight">{Calendar.strftime(day, "%a")}</div>
|
||||
<div class="leading-tight">{Calendar.strftime(day, "%-d")}</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Gantt strip --%>
|
||||
<div
|
||||
class="relative grid gap-px h-8 rounded-md overflow-hidden bg-gray-100 dark:bg-gray-800"
|
||||
style={"grid-template-columns: repeat(#{@range_days}, minmax(0, 1fr));"}
|
||||
>
|
||||
<%= for segment <- schedule.segments do %>
|
||||
<div
|
||||
class={[
|
||||
"flex items-center justify-center text-white text-xs font-medium truncate px-1",
|
||||
segment.color_class
|
||||
]}
|
||||
style={"grid-column: #{segment.day_offset + 1} / span #{segment.day_span};"}
|
||||
title={segment.label}
|
||||
>
|
||||
{segment.label}
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if today_idx do %>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 bottom-0 w-px bg-red-500"
|
||||
style={"left: calc((100% / #{@range_days}) * #{today_idx});"}
|
||||
>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -6,13 +6,10 @@ defmodule ToweropsWeb.ScheduleLive.Show do
|
|||
alias Towerops.OnCall.Layer
|
||||
alias Towerops.OnCall.Override
|
||||
alias Towerops.OnCall.Resolver
|
||||
alias Towerops.OnCall.UserColor
|
||||
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
|
||||
|
|
@ -288,10 +285,7 @@ defmodule ToweropsWeb.ScheduleLive.Show do
|
|||
|> 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)
|
||||
user_colors = Map.new(all_users, fn user -> {user.id, UserColor.class_for(user)} end)
|
||||
|
||||
socket
|
||||
|> assign(:timeline_days, days)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
2026-04-28 — Schedules Timeline View
|
||||
* New gantt-style schedules list shows who's on call across a date window
|
||||
* Date navigation (Today / prev / next) and 1/2/4-week range selector
|
||||
* Each schedule row shows the current on-call user with a color-coded avatar
|
||||
* Per-user colors are now consistent across the schedules list and detail pages
|
||||
|
||||
2026-04-28 — Escalation Policy Improvements
|
||||
* Redesigned escalation policy detail page with numbered Level cards and inline reorder controls
|
||||
* Added a "Services" sidebar showing the devices that use each policy
|
||||
|
|
|
|||
|
|
@ -302,6 +302,119 @@ defmodule Towerops.OnCall.ResolverTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "resolve_range/3" do
|
||||
test "returns one segment when a single user covers the whole range", ctx do
|
||||
schedule = schedule_fixture(ctx.organization.id)
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-03-01 09:00:00Z],
|
||||
position: 1
|
||||
})
|
||||
|
||||
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
|
||||
schedule = preload_schedule(schedule)
|
||||
|
||||
segments =
|
||||
Resolver.resolve_range(schedule, ~U[2026-03-01 09:00:00Z], ~U[2026-03-01 21:00:00Z])
|
||||
|
||||
assert [%{user: %{id: uid}, start_at: s, end_at: e}] = segments
|
||||
assert uid == ctx.user1.id
|
||||
assert DateTime.compare(s, ~U[2026-03-01 09:00:00Z]) == :eq
|
||||
assert DateTime.compare(e, ~U[2026-03-01 21:00:00Z]) == :eq
|
||||
end
|
||||
|
||||
test "splits into multiple segments across daily rotation handoffs", ctx do
|
||||
schedule = schedule_fixture(ctx.organization.id)
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-03-01 09:00:00Z],
|
||||
position: 1
|
||||
})
|
||||
|
||||
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
|
||||
layer_member_fixture(layer.id, ctx.user2.id, %{position: 1})
|
||||
|
||||
schedule = preload_schedule(schedule)
|
||||
|
||||
# Cover 3 days starting at the rotation start
|
||||
segments =
|
||||
Resolver.resolve_range(schedule, ~U[2026-03-01 09:00:00Z], ~U[2026-03-04 09:00:00Z])
|
||||
|
||||
assert length(segments) == 3
|
||||
|
||||
[s0, s1, s2] = segments
|
||||
assert s0.user.id == ctx.user1.id
|
||||
assert s1.user.id == ctx.user2.id
|
||||
assert s2.user.id == ctx.user1.id
|
||||
|
||||
assert DateTime.compare(s0.end_at, s1.start_at) == :eq
|
||||
assert DateTime.compare(s1.end_at, s2.start_at) == :eq
|
||||
end
|
||||
|
||||
test "merges adjacent segments belonging to the same user", ctx do
|
||||
schedule = schedule_fixture(ctx.organization.id)
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-03-01 09:00:00Z],
|
||||
position: 1
|
||||
})
|
||||
|
||||
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
|
||||
schedule = preload_schedule(schedule)
|
||||
|
||||
segments =
|
||||
Resolver.resolve_range(schedule, ~U[2026-03-01 09:00:00Z], ~U[2026-03-04 09:00:00Z])
|
||||
|
||||
assert length(segments) == 1
|
||||
assert hd(segments).user.id == ctx.user1.id
|
||||
end
|
||||
|
||||
test "drops gaps when no one is on call", ctx do
|
||||
schedule = schedule_fixture(ctx.organization.id)
|
||||
|
||||
# Layer starts in the middle of the requested range
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
rotation_type: "daily",
|
||||
rotation_interval: 1,
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-03-02 09:00:00Z],
|
||||
position: 1
|
||||
})
|
||||
|
||||
layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
|
||||
schedule = preload_schedule(schedule)
|
||||
|
||||
segments =
|
||||
Resolver.resolve_range(schedule, ~U[2026-03-01 09:00:00Z], ~U[2026-03-03 09:00:00Z])
|
||||
|
||||
# Only one segment, starting from when the layer kicked in
|
||||
assert [%{user: %{id: uid}, start_at: start_at}] = segments
|
||||
assert uid == ctx.user1.id
|
||||
assert DateTime.compare(start_at, ~U[2026-03-02 09:00:00Z]) == :eq
|
||||
end
|
||||
|
||||
test "raises when start_at is not before end_at", ctx do
|
||||
schedule = ctx.organization.id |> schedule_fixture() |> preload_schedule()
|
||||
|
||||
assert_raise FunctionClauseError, fn ->
|
||||
Resolver.resolve_range(schedule, ~U[2026-03-02 09:00:00Z], ~U[2026-03-01 09:00:00Z])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp preload_schedule(schedule) do
|
||||
Towerops.Repo.preload(schedule, overrides: :user, layers: [members: :user])
|
||||
end
|
||||
|
|
|
|||
80
test/towerops/on_call/user_color_test.exs
Normal file
80
test/towerops/on_call/user_color_test.exs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
defmodule Towerops.OnCall.UserColorTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Towerops.OnCall.UserColor
|
||||
|
||||
describe "class_for/1" do
|
||||
test "returns the same Tailwind class for the same id across calls" do
|
||||
id = "11111111-1111-1111-1111-111111111111"
|
||||
assert UserColor.class_for(id) == UserColor.class_for(id)
|
||||
end
|
||||
|
||||
test "accepts a struct/map with an :id field" do
|
||||
id = "22222222-2222-2222-2222-222222222222"
|
||||
assert UserColor.class_for(%{id: id}) == UserColor.class_for(id)
|
||||
end
|
||||
|
||||
test "returns a gray fallback for nil" do
|
||||
assert UserColor.class_for(nil) == "bg-gray-400"
|
||||
end
|
||||
|
||||
test "always returns a class from the palette" do
|
||||
palette =
|
||||
~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)
|
||||
|
||||
for _ <- 1..50 do
|
||||
id = Ecto.UUID.generate()
|
||||
assert UserColor.class_for(id) in palette
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "hex_for/1" do
|
||||
test "is deterministic for the same id" do
|
||||
id = "33333333-3333-3333-3333-333333333333"
|
||||
assert UserColor.hex_for(id) == UserColor.hex_for(id)
|
||||
end
|
||||
|
||||
test "accepts a struct/map with an :id field" do
|
||||
id = "44444444-4444-4444-4444-444444444444"
|
||||
assert UserColor.hex_for(%{id: id}) == UserColor.hex_for(id)
|
||||
end
|
||||
|
||||
test "returns a gray fallback for nil" do
|
||||
assert UserColor.hex_for(nil) == "#9CA3AF"
|
||||
end
|
||||
|
||||
test "every result is a 7-character #RRGGBB hex string" do
|
||||
for _ <- 1..50 do
|
||||
id = Ecto.UUID.generate()
|
||||
hex = UserColor.hex_for(id)
|
||||
assert String.starts_with?(hex, "#")
|
||||
assert String.length(hex) == 7
|
||||
assert String.match?(hex, ~r/^#[0-9A-F]{6}$/)
|
||||
end
|
||||
end
|
||||
|
||||
test "class_for and hex_for stay paired for the same id" do
|
||||
# The palette is a list of {class, hex} pairs; both helpers should pick
|
||||
# the same index. Smoke-test that pairing for a few ids.
|
||||
pairs = %{
|
||||
"bg-blue-500" => "#3B82F6",
|
||||
"bg-purple-500" => "#A855F7",
|
||||
"bg-green-500" => "#22C55E",
|
||||
"bg-orange-500" => "#F97316",
|
||||
"bg-pink-500" => "#EC4899",
|
||||
"bg-teal-500" => "#14B8A6",
|
||||
"bg-indigo-500" => "#6366F1",
|
||||
"bg-red-500" => "#EF4444",
|
||||
"bg-cyan-500" => "#06B6D4",
|
||||
"bg-amber-500" => "#F59E0B"
|
||||
}
|
||||
|
||||
for _ <- 1..50 do
|
||||
id = Ecto.UUID.generate()
|
||||
assert pairs[UserColor.class_for(id)] == UserColor.hex_for(id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -53,6 +53,77 @@ defmodule ToweropsWeb.ScheduleLiveTest do
|
|||
assert html =~ user.email
|
||||
end
|
||||
|
||||
test "renders date navigation controls", %{conn: conn, organization: organization} do
|
||||
_ = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, html} = live(conn, ~p"/schedules")
|
||||
|
||||
assert html =~ "Today"
|
||||
assert html =~ "2 Weeks"
|
||||
assert has_element?(view, "button[phx-click=\"prev\"]")
|
||||
assert has_element?(view, "button[phx-click=\"next\"]")
|
||||
end
|
||||
|
||||
test "renders a per-schedule gantt strip with the on-call user's name", %{
|
||||
conn: conn,
|
||||
organization: organization,
|
||||
user: user
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id, %{name: "Primary"})
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
start_date: ~U[2025-01-01 09:00:00Z],
|
||||
handoff_time: ~T[09:00:00],
|
||||
rotation_type: "weekly"
|
||||
})
|
||||
|
||||
layer_member_fixture(layer.id, user.id)
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules")
|
||||
|
||||
assert html =~ "On-Call Now"
|
||||
assert html =~ user.email
|
||||
# Gantt cell uses inline grid-column style derived from segment offsets.
|
||||
assert html =~ ~r/grid-column:\s*\d+\s*\/\s*span\s*\d+/
|
||||
end
|
||||
|
||||
test "navigates the date window when prev/next/today are clicked", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
_ = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules?start=2026-04-01&range=14")
|
||||
|
||||
view |> element("button[phx-click=\"next\"]") |> render_click()
|
||||
assert_patched(view, ~p"/schedules?#{[tab: "schedules", start: "2026-04-15", range: 14]}")
|
||||
|
||||
view |> element("button[phx-click=\"prev\"]") |> render_click()
|
||||
assert_patched(view, ~p"/schedules?#{[tab: "schedules", start: "2026-04-01", range: 14]}")
|
||||
end
|
||||
|
||||
test "respects the range query param", %{conn: conn, organization: organization} do
|
||||
_ = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules?range=7")
|
||||
|
||||
assert html =~ "1 Week"
|
||||
# The range select should preselect 1 Week
|
||||
assert html =~ ~r/<option value="7" selected/
|
||||
end
|
||||
|
||||
test "falls back to defaults for malformed range/start params", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
_ = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/schedules?start=not-a-date&range=99")
|
||||
|
||||
assert html =~ "2 Weeks"
|
||||
end
|
||||
|
||||
test "shows 'No one' when nobody is on-call", %{conn: conn, organization: organization} do
|
||||
schedule_fixture(organization.id)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue