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 --%> +
| - {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 %> - |
-