diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 64e06c5e..0e45959e 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -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 diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 52a73001..9626aa47 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -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: diff --git a/lib/towerops/on_call/resolver.ex b/lib/towerops/on_call/resolver.ex index ff042079..a2b5198e 100644 --- a/lib/towerops/on_call/resolver.ex +++ b/lib/towerops/on_call/resolver.ex @@ -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 -> diff --git a/lib/towerops/on_call/user_color.ex b/lib/towerops/on_call/user_color.ex new file mode 100644 index 00000000..5149ff38 --- /dev/null +++ b/lib/towerops/on_call/user_color.ex @@ -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 diff --git a/lib/towerops_web/live/schedule_live/index.ex b/lib/towerops_web/live/schedule_live/index.ex index 87c952fe..e7f95acd 100644 --- a/lib/towerops_web/live/schedule_live/index.ex +++ b/lib/towerops_web/live/schedule_live/index.ex @@ -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 diff --git a/lib/towerops_web/live/schedule_live/index.html.heex b/lib/towerops_web/live/schedule_live/index.html.heex index da9a98d2..a72b01bb 100644 --- a/lib/towerops_web/live/schedule_live/index.html.heex +++ b/lib/towerops_web/live/schedule_live/index.html.heex @@ -102,6 +102,45 @@ <% end %> <% else %> + <%!-- Date navigation header --%> +
+
+ + + + + {window_label(@start_date, @range_days)} + +
+
+ +
+
+ <%= if Enum.empty?(@schedules) do %>
@@ -118,47 +157,85 @@
<% else %> -
- - - - - - - - - - <%= for schedule <- @schedules do %> - - - - - - <% end %> - -
- {t("Name")} - - {t("Timezone")} - - {t("Currently On-Call")} -
+
+ <%= for schedule <- @schedules do %> + <% days = days_in_window(@start_date, @range_days) %> + <% today_idx = today_offset(@start_date, @range_days) %> +
+
+
+ <.link + navigate={~p"/schedules/#{schedule.id}"} + class="text-base font-semibold text-blue-600 dark:text-blue-400 hover:underline" + > {schedule.name} -
+ +

{schedule.timezone} -

- <%= if schedule.current_on_call do %> - - +

+ +
+

{t("On-Call Now")}

+ <%= if schedule.current_on_call do %> + + + + {schedule.current_on_call_name} - <% else %> - {t("No one")} - <% end %> -
+ + <% else %> + {t("No one")} + <% end %> +
+ + + <%!-- Day header row --%> +
+ <%= for {day, idx} <- Enum.with_index(days) do %> +
+
{Calendar.strftime(day, "%a")}
+
{Calendar.strftime(day, "%-d")}
+
+ <% end %> +
+ + <%!-- Gantt strip --%> +
+ <%= for segment <- schedule.segments do %> +
+ {segment.label} +
+ <% end %> + <%= if today_idx do %> +
+
+ <% end %> +
+ + <% end %> <% end %> <% end %> diff --git a/lib/towerops_web/live/schedule_live/show.ex b/lib/towerops_web/live/schedule_live/show.ex index a534a263..e15d7189 100644 --- a/lib/towerops_web/live/schedule_live/show.ex +++ b/lib/towerops_web/live/schedule_live/show.ex @@ -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) diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index 1526aa8b..3f2a9d2a 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -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 diff --git a/test/towerops/on_call/resolver_test.exs b/test/towerops/on_call/resolver_test.exs index d6ce8134..388aa67d 100644 --- a/test/towerops/on_call/resolver_test.exs +++ b/test/towerops/on_call/resolver_test.exs @@ -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 diff --git a/test/towerops/on_call/user_color_test.exs b/test/towerops/on_call/user_color_test.exs new file mode 100644 index 00000000..0840429e --- /dev/null +++ b/test/towerops/on_call/user_color_test.exs @@ -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 diff --git a/test/towerops_web/live/schedule_live_test.exs b/test/towerops_web/live/schedule_live_test.exs index e428e82c..269f98c7 100644 --- a/test/towerops_web/live/schedule_live_test.exs +++ b/test/towerops_web/live/schedule_live_test.exs @@ -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/