From 61368e5c5e94bc7f0f5008a39d1d4091338678c4 Mon Sep 17 00:00:00 2001
From: Graham McIntire
Date: Wed, 11 Mar 2026 12:59:23 -0500
Subject: [PATCH] enhance: schedule show with PagerDuty-style timeline and UX
improvements
- Add 2-week visual timeline with per-layer and final schedule views
- Color-coded user spans with today marker and prev/next navigation
- Inline user picker when adding new layers (auto-add on select)
- Auto-add members on select for existing layers (no Add button)
- Display user names (first last) instead of emails throughout
- Default handoff time to 8:00 AM and start date to today
---
lib/towerops_web/live/schedule_live/show.ex | 193 ++++++++++++++-
.../live/schedule_live/show.html.heex | 222 ++++++++++++++++--
2 files changed, 388 insertions(+), 27 deletions(-)
diff --git a/lib/towerops_web/live/schedule_live/show.ex b/lib/towerops_web/live/schedule_live/show.ex
index 4bc6fdb1..bca433ac 100644
--- a/lib/towerops_web/live/schedule_live/show.ex
+++ b/lib/towerops_web/live/schedule_live/show.ex
@@ -5,8 +5,15 @@ defmodule ToweropsWeb.ScheduleLive.Show do
alias Towerops.OnCall
alias Towerops.OnCall.Layer
alias Towerops.OnCall.Override
+ alias Towerops.OnCall.Resolver
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
schedule = OnCall.get_schedule!(id)
@@ -14,6 +21,8 @@ defmodule ToweropsWeb.ScheduleLive.Show do
org_id = socket.assigns.current_scope.organization.id
members = Organizations.list_organization_members(org_id)
users = Enum.map(members, & &1.user)
+ today = Date.utc_today()
+ timeline_start = Date.beginning_of_week(today, :sunday)
{:ok,
socket
@@ -25,10 +34,41 @@ defmodule ToweropsWeb.ScheduleLive.Show do
|> assign(:org_users, users)
|> assign(:show_add_layer, false)
|> assign(:show_add_override, false)
- |> assign(:layer_form, to_form(Layer.changeset(%Layer{}, %{})))
+ |> assign(:new_layer_members, [])
+ |> assign(:timeline_start, timeline_start)
+ |> assign_timeline(schedule, timeline_start)
+ |> assign(:layer_form, to_form(Layer.changeset(%Layer{}, default_layer_attrs())))
|> assign(:override_form, to_form(Override.changeset(%Override{}, %{})))}
end
+ def handle_event("timeline_prev", _params, socket) do
+ new_start = Date.add(socket.assigns.timeline_start, -@timeline_days)
+
+ {:noreply,
+ socket
+ |> assign(:timeline_start, new_start)
+ |> assign_timeline(socket.assigns.schedule, new_start)}
+ end
+
+ def handle_event("timeline_next", _params, socket) do
+ new_start = Date.add(socket.assigns.timeline_start, @timeline_days)
+
+ {:noreply,
+ socket
+ |> assign(:timeline_start, new_start)
+ |> assign_timeline(socket.assigns.schedule, new_start)}
+ end
+
+ def handle_event("timeline_today", _params, socket) do
+ today = Date.utc_today()
+ new_start = Date.beginning_of_week(today, :sunday)
+
+ {:noreply,
+ socket
+ |> assign(:timeline_start, new_start)
+ |> assign_timeline(socket.assigns.schedule, new_start)}
+ end
+
@impl true
def handle_event("delete", _params, socket) do
case OnCall.delete_schedule(socket.assigns.schedule) do
@@ -44,7 +84,27 @@ defmodule ToweropsWeb.ScheduleLive.Show do
end
def handle_event("toggle_add_layer", _params, socket) do
- {:noreply, assign(socket, :show_add_layer, !socket.assigns.show_add_layer)}
+ {:noreply,
+ socket
+ |> assign(:show_add_layer, !socket.assigns.show_add_layer)
+ |> assign(:new_layer_members, [])}
+ end
+
+ def handle_event("new_layer_add_user", %{"user_id" => ""}, socket), do: {:noreply, socket}
+
+ def handle_event("new_layer_add_user", %{"user_id" => user_id}, socket) do
+ user = Enum.find(socket.assigns.org_users, &(&1.id == user_id))
+
+ if user && user.id not in Enum.map(socket.assigns.new_layer_members, & &1.id) do
+ {:noreply, assign(socket, :new_layer_members, socket.assigns.new_layer_members ++ [user])}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_event("new_layer_remove_user", %{"id" => user_id}, socket) do
+ members = Enum.reject(socket.assigns.new_layer_members, &(&1.id == user_id))
+ {:noreply, assign(socket, :new_layer_members, members)}
end
def handle_event("toggle_add_override", _params, socket) do
@@ -70,11 +130,18 @@ defmodule ToweropsWeb.ScheduleLive.Show do
|> Map.put("position", next_position)
case OnCall.create_layer(params) do
- {:ok, _layer} ->
+ {:ok, layer} ->
+ socket.assigns.new_layer_members
+ |> Enum.with_index()
+ |> Enum.each(fn {user, idx} ->
+ OnCall.add_layer_member(%{layer_id: layer.id, user_id: user.id, position: idx})
+ end)
+
{:noreply,
socket
|> assign(:show_add_layer, false)
- |> assign(:layer_form, to_form(Layer.changeset(%Layer{}, %{})))
+ |> assign(:new_layer_members, [])
+ |> assign(:layer_form, to_form(Layer.changeset(%Layer{}, default_layer_attrs())))
|> reload_schedule()}
{:error, changeset} ->
@@ -95,6 +162,8 @@ defmodule ToweropsWeb.ScheduleLive.Show do
end
end
+ def handle_event("add_member", %{"user_id" => ""}, socket), do: {:noreply, socket}
+
def handle_event("add_member", %{"layer_id" => layer_id, "user_id" => user_id}, socket) do
layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id))
@@ -174,6 +243,21 @@ defmodule ToweropsWeb.ScheduleLive.Show do
end
end
+ defp display_name(%{first_name: first, last_name: last}) when first != nil and last != nil do
+ "#{first} #{last}"
+ end
+
+ defp display_name(%{email: email}), do: email
+
+ defp default_layer_attrs do
+ today = Date.utc_today()
+
+ %{
+ handoff_time: ~T[08:00:00],
+ start_date: DateTime.new!(today, ~T[08:00:00], "Etc/UTC")
+ }
+ end
+
defp reload_schedule(socket) do
schedule = OnCall.get_schedule!(socket.assigns.schedule.id)
on_call = OnCall.who_is_on_call(schedule.id)
@@ -181,5 +265,106 @@ defmodule ToweropsWeb.ScheduleLive.Show do
socket
|> assign(:schedule, schedule)
|> assign(:on_call, on_call)
+ |> assign_timeline(schedule, socket.assigns.timeline_start)
end
+
+ defp assign_timeline(socket, schedule, start_date) do
+ end_date = Date.add(start_date, @timeline_days - 1)
+ days = start_date |> Date.range(end_date) |> Enum.to_list()
+
+ layer_timelines =
+ schedule.layers
+ |> Enum.sort_by(& &1.position)
+ |> Enum.map(fn layer ->
+ spans = build_layer_spans(layer, start_date, @timeline_days)
+ %{layer: layer, spans: spans}
+ end)
+
+ final_spans = build_final_spans(schedule, start_date, @timeline_days)
+
+ all_users =
+ (Enum.flat_map(layer_timelines, fn lt -> Enum.map(lt.spans, & &1.user) end) ++
+ Enum.map(final_spans, & &1.user))
+ |> 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)
+
+ socket
+ |> assign(:timeline_days, days)
+ |> assign(:timeline_end, end_date)
+ |> assign(:layer_timelines, layer_timelines)
+ |> assign(:final_spans, final_spans)
+ |> assign(:user_colors, user_colors)
+ end
+
+ defp build_layer_spans(layer, start_date, num_days) do
+ start_dt = DateTime.new!(start_date, ~T[00:00:00], "Etc/UTC")
+ total_hours = num_days * 24
+
+ 0..(total_hours - 1)
+ |> Enum.map(fn hour_offset ->
+ dt = DateTime.add(start_dt, hour_offset * 3600, :second)
+ user = resolve_layer_only(layer, dt)
+ {hour_offset, user}
+ end)
+ |> Enum.chunk_by(fn {_, user} -> user && user.id end)
+ |> Enum.map(fn chunk ->
+ {start_offset, user} = List.first(chunk)
+ duration = length(chunk)
+
+ %{
+ user: user,
+ start_offset: start_offset,
+ duration: duration,
+ total_hours: total_hours
+ }
+ end)
+ |> Enum.reject(fn span -> is_nil(span.user) end)
+ end
+
+ defp build_final_spans(schedule, start_date, num_days) do
+ start_dt = DateTime.new!(start_date, ~T[00:00:00], "Etc/UTC")
+ total_hours = num_days * 24
+
+ 0..(total_hours - 1)
+ |> Enum.map(fn hour_offset ->
+ dt = DateTime.add(start_dt, hour_offset * 3600, :second)
+ user = Resolver.resolve(schedule, dt)
+ {hour_offset, user}
+ end)
+ |> Enum.chunk_by(fn {_, user} -> user && user.id end)
+ |> Enum.map(fn chunk ->
+ {start_offset, user} = List.first(chunk)
+ duration = length(chunk)
+
+ %{
+ user: user,
+ start_offset: start_offset,
+ duration: duration,
+ total_hours: total_hours
+ }
+ end)
+ |> Enum.reject(fn span -> is_nil(span.user) end)
+ end
+
+ defp resolve_layer_only(layer, datetime) do
+ with true <- DateTime.compare(datetime, layer.start_date) in [:gt, :eq],
+ members = Enum.sort_by(layer.members, & &1.position),
+ true <- members != [] do
+ elapsed = DateTime.diff(datetime, layer.start_date, :second)
+ rotation_secs = rotation_seconds(layer)
+ position = elapsed |> div(rotation_secs) |> rem(length(members))
+ Enum.at(members, position).user
+ else
+ _ -> nil
+ end
+ end
+
+ defp rotation_seconds(%{rotation_type: "daily", rotation_interval: i}), do: i * 86_400
+ defp rotation_seconds(%{rotation_type: "weekly", rotation_interval: i}), do: i * 604_800
+ defp rotation_seconds(%{rotation_interval: i}), do: i * 86_400
end
diff --git a/lib/towerops_web/live/schedule_live/show.html.heex b/lib/towerops_web/live/schedule_live/show.html.heex
index 77a8a7bb..fd6628b9 100644
--- a/lib/towerops_web/live/schedule_live/show.html.heex
+++ b/lib/towerops_web/live/schedule_live/show.html.heex
@@ -61,7 +61,9 @@
{t("Currently On-Call")}
<%= if @on_call do %>
- {@on_call.email}
+
+ {display_name(@on_call)}
+
<% else %>
{t("No one is currently on-call")}
@@ -71,6 +73,149 @@
+ <%!-- Timeline --%>
+
+ <%!-- Timeline navigation --%>
+
+
+
+
+
+
+ {Calendar.strftime(@timeline_start, "%b %-d")} – {Calendar.strftime(
+ @timeline_end,
+ "%b %-d, %Y"
+ )}
+
+
+
+ {t("2 Weeks")}
+
+
+
+ <%!-- Layer timelines --%>
+ <%= if @layer_timelines != [] do %>
+
+
+ {t("Configuration Layers")}
+
+
+ <%= for lt <- @layer_timelines do %>
+
+ <%!-- Day headers --%>
+
+
+
+ <%= for day <- @timeline_days do %>
+
+
+ {Calendar.strftime(day, "%-d %a")}
+
+
+ <% end %>
+
+
+ <%!-- Timeline bar --%>
+
+
+
+ {lt.layer.name}
+
+
+
+ <%= for span <- lt.spans do %>
+
+
+ {display_name(span.user)}
+
+
+ <% end %>
+ <%!-- Today marker --%>
+ <% today_offset = Date.diff(Date.utc_today(), @timeline_start) %>
+ <%= if today_offset >= 0 and today_offset < @timeline_days do %>
+
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+ <%!-- Final Schedule --%>
+
+
+ {t("Final Schedule")}
+
+ <%!-- Day headers --%>
+
+
+
+ <%= for day <- @timeline_days do %>
+
+
+ {Calendar.strftime(day, "%-d %a")}
+
+
+ <% end %>
+
+
+ <%!-- Timeline bar --%>
+
+
+
+ <%= for span <- @final_spans do %>
+
+
+ {display_name(span.user)}
+
+
+ <% end %>
+ <%!-- Today marker --%>
+ <% today_offset = Date.diff(Date.utc_today(), @timeline_start) %>
+ <%= if today_offset >= 0 and today_offset < @timeline_days do %>
+
+
+ <% end %>
+
+
+
+ <% end %>
+
+
<%!-- Layers --%>
@@ -127,6 +272,45 @@
label={t("Start Date")}
/>
+
+ <%!-- User selection for new layer --%>
+
+
+ <%= if @new_layer_members != [] do %>
+
+ <%= for user <- @new_layer_members do %>
+
+ {display_name(user)}
+
+
+ <% end %>
+
+ <% end %>
+ <% new_member_ids = Enum.map(@new_layer_members, & &1.id) %>
+ <% available = Enum.reject(@org_users, &(&1.id in new_member_ids)) %>
+ <%= unless Enum.empty?(available) do %>
+
+ <% end %>
+
+
<.button type="submit" phx-disable-with={t("Saving...")}>
{t("Add Layer")}
@@ -192,7 +376,7 @@
<%= for member <- Enum.sort_by(layer.members, & &1.position) do %>
- {member.user.email}
+ {display_name(member.user)}
@@ -257,7 +433,7 @@
field={@override_form[:user_id]}
type="select"
label={t("User")}
- options={Enum.map(@org_users, &{&1.email, &1.id})}
+ options={Enum.map(@org_users, &{display_name(&1), &1.id})}
prompt={t("Select user...")}
/>
<.input
@@ -314,7 +490,7 @@
<%= for override <- @schedule.overrides do %>
|
- {override.user.email}
+ {display_name(override.user)}
|
{Calendar.strftime(override.start_time, "%Y-%m-%d %H:%M")}
|