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
This commit is contained in:
Graham McIntire 2026-03-11 12:59:23 -05:00
parent 0cd2ed3567
commit 61368e5c5e
No known key found for this signature in database
2 changed files with 388 additions and 27 deletions

View file

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

View file

@ -61,7 +61,9 @@
{t("Currently On-Call")}
</p>
<%= if @on_call do %>
<p class="text-sm font-semibold text-gray-900 dark:text-white">{@on_call.email}</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white">
{display_name(@on_call)}
</p>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400">
{t("No one is currently on-call")}
@ -71,6 +73,149 @@
</div>
</div>
<%!-- Timeline --%>
<div class="mb-8">
<%!-- Timeline navigation --%>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<button
phx-click="timeline_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-300 hover:bg-gray-50 dark:hover:bg-gray-700"
>
{t("Today")}
</button>
<button
phx-click="timeline_prev"
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 p-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
>
<.icon name="hero-chevron-left" class="h-4 w-4" />
</button>
<button
phx-click="timeline_next"
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 p-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
>
<.icon name="hero-chevron-right" class="h-4 w-4" />
</button>
<span class="text-sm font-semibold text-gray-900 dark:text-white ml-2">
{Calendar.strftime(@timeline_start, "%b %-d")} {Calendar.strftime(
@timeline_end,
"%b %-d, %Y"
)}
</span>
</div>
<span class="text-sm text-gray-500 dark:text-gray-400">
{t("2 Weeks")}
</span>
</div>
<%!-- Layer timelines --%>
<%= if @layer_timelines != [] do %>
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4 mb-4">
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4">
{t("Configuration Layers")}
</h3>
<%= for lt <- @layer_timelines do %>
<div class="mb-4 last:mb-0">
<%!-- Day headers --%>
<div class="flex mb-1">
<div class="w-28 shrink-0"></div>
<div class="flex-1 flex">
<%= for day <- @timeline_days do %>
<div class="flex-1 text-center">
<span class="text-[10px] text-gray-400 dark:text-gray-500">
{Calendar.strftime(day, "%-d %a")}
</span>
</div>
<% end %>
</div>
</div>
<%!-- Timeline bar --%>
<div class="flex items-center">
<div class="w-28 shrink-0 pr-2 text-right">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">
{lt.layer.name}
</span>
</div>
<div class="flex-1 relative h-8 bg-gray-100 dark:bg-gray-800 rounded overflow-hidden">
<%= for span <- lt.spans do %>
<div
class={[
"absolute inset-y-0 flex items-center px-1 overflow-hidden",
@user_colors[span.user.id] || "bg-gray-400"
]}
style={"left: #{span.start_offset / span.total_hours * 100}%; width: #{span.duration / span.total_hours * 100}%;"}
>
<span class="text-[10px] font-medium text-white truncate">
{display_name(span.user)}
</span>
</div>
<% end %>
<%!-- Today marker --%>
<% today_offset = Date.diff(Date.utc_today(), @timeline_start) %>
<%= if today_offset >= 0 and today_offset < @timeline_days do %>
<div
class="absolute inset-y-0 w-px bg-red-500 z-10"
style={"left: #{(today_offset * 24 + DateTime.utc_now().hour) / (length(@timeline_days) * 24) * 100}%;"}
>
</div>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
<%!-- Final Schedule --%>
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4">
{t("Final Schedule")}
</h3>
<%!-- Day headers --%>
<div class="flex mb-1">
<div class="w-28 shrink-0"></div>
<div class="flex-1 flex">
<%= for day <- @timeline_days do %>
<div class="flex-1 text-center">
<span class="text-[10px] text-gray-400 dark:text-gray-500">
{Calendar.strftime(day, "%-d %a")}
</span>
</div>
<% end %>
</div>
</div>
<%!-- Timeline bar --%>
<div class="flex items-center">
<div class="w-28 shrink-0"></div>
<div class="flex-1 relative h-8 bg-gray-100 dark:bg-gray-800 rounded overflow-hidden">
<%= for span <- @final_spans do %>
<div
class={[
"absolute inset-y-0 flex items-center px-1 overflow-hidden",
@user_colors[span.user.id] || "bg-gray-400"
]}
style={"left: #{span.start_offset / span.total_hours * 100}%; width: #{span.duration / span.total_hours * 100}%;"}
>
<span class="text-[10px] font-medium text-white truncate">
{display_name(span.user)}
</span>
</div>
<% end %>
<%!-- Today marker --%>
<% today_offset = Date.diff(Date.utc_today(), @timeline_start) %>
<%= if today_offset >= 0 and today_offset < @timeline_days do %>
<div
class="absolute inset-y-0 w-px bg-red-500 z-10"
style={"left: #{(today_offset * 24 + DateTime.utc_now().hour) / (length(@timeline_days) * 24) * 100}%;"}
>
</div>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
<%!-- Layers --%>
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
@ -127,6 +272,45 @@
label={t("Start Date")}
/>
</div>
<%!-- User selection for new layer --%>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("Members")}
</label>
<%= if @new_layer_members != [] do %>
<div class="flex flex-wrap gap-2 mb-2">
<%= for user <- @new_layer_members do %>
<span class="inline-flex items-center gap-1 rounded-full bg-blue-100 dark:bg-blue-900/40 pl-2.5 pr-1 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">
{display_name(user)}
<button
type="button"
phx-click="new_layer_remove_user"
phx-value-id={user.id}
class="ml-0.5 rounded-full p-0.5 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
>
<.icon name="hero-x-mark" class="h-3 w-3" />
</button>
</span>
<% end %>
</div>
<% 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 %>
<select
phx-change="new_layer_add_user"
name="user_id"
class="block w-full max-w-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-sm py-1.5"
>
<option value="">{t("Select a user")}</option>
<%= for user <- available do %>
<option value={user.id}>{display_name(user)}</option>
<% end %>
</select>
<% end %>
</div>
<div class="flex items-center gap-2">
<.button type="submit" phx-disable-with={t("Saving...")}>
{t("Add Layer")}
@ -192,7 +376,7 @@
<div class="flex flex-wrap gap-2 mb-2">
<%= for member <- Enum.sort_by(layer.members, & &1.position) do %>
<span class="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-800 pl-2.5 pr-1 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">
{member.user.email}
{display_name(member.user)}
<button
phx-click="remove_member"
phx-value-id={member.id}
@ -209,25 +393,17 @@
<% existing_user_ids = Enum.map(layer.members, & &1.user_id) %>
<% available_users = Enum.reject(@org_users, &(&1.id in existing_user_ids)) %>
<%= unless Enum.empty?(available_users) do %>
<form phx-submit="add_member" class="flex items-center gap-2">
<input type="hidden" name="layer_id" value={layer.id} />
<select
name="user_id"
class="block w-full max-w-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
>
<option value="">{t("Select a member...")}</option>
<%= for user <- available_users do %>
<option value={user.id}>{user.email}</option>
<% end %>
</select>
<button
type="submit"
class="inline-flex items-center rounded-md bg-gray-100 dark:bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<.icon name="hero-plus" class="h-3 w-3 mr-1" />
{t("Add")}
</button>
</form>
<select
phx-change="add_member"
name="user_id"
phx-value-layer_id={layer.id}
class="block w-full max-w-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-sm py-1.5"
>
<option value="">{t("Select a user")}</option>
<%= for user <- available_users do %>
<option value={user.id}>{display_name(user)}</option>
<% end %>
</select>
<% end %>
</div>
</div>
@ -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 %>
<tr>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">
{override.user.email}
{display_name(override.user)}
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{Calendar.strftime(override.start_time, "%Y-%m-%d %H:%M")}