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:
Graham McIntire 2026-04-28 12:48:52 -05:00
parent e4686f31ce
commit e7bca6174c
11 changed files with 689 additions and 62 deletions

View file

@ -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

View file

@ -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:

View file

@ -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 ->

View 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

View file

@ -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

View file

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

View file

@ -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)

View file

@ -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

View file

@ -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

View 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

View file

@ -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)