feat(on_call): schedule editor layer reorder + unified resolver (chunk 3)

OnCall context
- move_layer/2: up/down position swap for schedule layers, mirror of
  move_escalation_rule/2 (tested for both directions + boundary no-op).

ScheduleLive.Show
- New move_layer event handler.
- Up/down arrow buttons on each layer card; the top arrow is disabled on
  the first layer, the bottom on the last (matches the escalation policy
  level reorder UX shipped in chunk 1).
- Replaced two per-hour walks (build_layer_spans + build_final_spans -
  ~672 resolve calls per render at 14 days) with Resolver.resolve_range/3
  + a small segments_to_spans/3 mapper. Per-layer view wraps each layer
  in a temporary single-layer %Schedule{} so the resolver code path is
  shared and overrides don't bleed into the per-layer strip.

Tests: 3 new context tests for move_layer, 1 new integration test for
the layer reorder UI. Full suite 10,214 / 0 failures. Format/dialyzer
clean.
This commit is contained in:
Graham McIntire 2026-04-28 14:05:06 -05:00
parent 08cdbe355b
commit 23304e108c
7 changed files with 215 additions and 62 deletions

View file

@ -1,3 +1,21 @@
2026-04-28
feat(on_call): schedule editor layer reorder + unified resolver (chunk 3)
- OnCall.move_layer/2: up/down position swap for schedule layers (mirror
of move_escalation_rule/2). Tested for both directions + boundary no-op.
- ScheduleLive.Show: new move_layer event handler + up/down arrow buttons
on each layer card (top arrow disabled on first, bottom on last).
- ScheduleLive.Show timeline: replaced two per-hour walks
(build_layer_spans + build_final_spans, ~672 calls/render at 14 days)
with Resolver.resolve_range/3 + a small segments_to_spans/3 mapper.
Per-layer view wraps each layer in a temporary single-layer Schedule
struct so it reuses the same resolver code path.
- Tests: 3 new context tests for move_layer, 1 new integration test for
the layer reorder UI. Full suite 10,214 / 0 failures.
Files: lib/towerops/on_call.ex, lib/towerops_web/live/schedule_live/show.ex,
lib/towerops_web/live/schedule_live/show.html.heex,
test/towerops/on_call_test.exs,
test/towerops_web/live/schedule_live_test.exs
2026-04-28 2026-04-28
feat(on_call): schedules list with gantt timeline (chunk 2) feat(on_call): schedules list with gantt timeline (chunk 2)
- Resolver.resolve_range/3 returns contiguous on-call segments across a - Resolver.resolve_range/3 returns contiguous on-call segments across a

View file

@ -84,6 +84,48 @@ defmodule Towerops.OnCall do
Repo.delete(layer) Repo.delete(layer)
end end
@doc """
Swaps a layer with its neighbour in the given direction (`:up` or `:down`).
Returns `{:ok, layer}` (unchanged when at the boundary), or `{:error, reason}`.
"""
def move_layer(%Layer{} = layer, direction) when direction in [:up, :down] do
schedule_layers =
Layer
|> where([l], l.schedule_id == ^layer.schedule_id)
|> order_by([l], asc: l.position)
|> Repo.all()
case find_neighbour_layer(schedule_layers, layer, direction) do
nil ->
{:ok, layer}
neighbour ->
case swap_layer_positions(layer, neighbour) do
:ok -> {:ok, Repo.get!(Layer, layer.id)}
{:error, _} = err -> err
end
end
end
defp find_neighbour_layer(layers, layer, :up), do: Enum.find(layers, &(&1.position == layer.position - 1))
defp find_neighbour_layer(layers, layer, :down), do: Enum.find(layers, &(&1.position == layer.position + 1))
defp swap_layer_positions(a, b) do
a_pos = a.position
b_pos = b.position
fn ->
{:ok, _} = a |> Layer.changeset(%{position: b_pos}) |> Repo.update()
{:ok, _} = b |> Layer.changeset(%{position: a_pos}) |> Repo.update()
end
|> Repo.transaction()
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
# --- Layer Members --- # --- Layer Members ---
def add_layer_member(attrs) do def add_layer_member(attrs) do

View file

@ -146,6 +146,17 @@ defmodule ToweropsWeb.ScheduleLive.Show do
end end
end end
def handle_event("move_layer", %{"id" => id, "direction" => direction}, socket) when direction in ["up", "down"] do
layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == id))
if layer do
{:ok, _} = OnCall.move_layer(layer, String.to_existing_atom(direction))
{:noreply, reload_schedule(socket)}
else
{:noreply, socket}
end
end
def handle_event("delete_layer", %{"id" => layer_id}, socket) do def handle_event("delete_layer", %{"id" => layer_id}, socket) do
layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id)) layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id))
@ -296,69 +307,35 @@ defmodule ToweropsWeb.ScheduleLive.Show do
end end
defp build_layer_spans(layer, start_date, num_days) do defp build_layer_spans(layer, start_date, num_days) do
start_dt = DateTime.new!(start_date, ~T[00:00:00], "Etc/UTC") # Resolve a single-layer "schedule" so we get per-layer coverage
total_hours = num_days * 24 # without overrides bleeding in. The Resolver only reads :layers and
# :overrides off the struct, so a stub keyword-style struct is fine.
0..(total_hours - 1) layer_schedule = %Towerops.OnCall.Schedule{layers: [layer], overrides: []}
|> Enum.map(fn hour_offset -> segments_to_spans(layer_schedule, start_date, num_days)
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 end
defp build_final_spans(schedule, start_date, num_days) do defp build_final_spans(schedule, start_date, num_days) do
start_dt = DateTime.new!(start_date, ~T[00:00:00], "Etc/UTC") segments_to_spans(schedule, start_date, num_days)
total_hours = num_days * 24 end
0..(total_hours - 1) defp segments_to_spans(schedule, start_date, num_days) do
|> Enum.map(fn hour_offset -> total_hours = num_days * 24
dt = DateTime.add(start_dt, hour_offset * 3600, :second) {:ok, start_at} = DateTime.new(start_date, ~T[00:00:00], "Etc/UTC")
user = Resolver.resolve(schedule, dt) end_at = DateTime.add(start_at, total_hours * 3600, :second)
{hour_offset, user}
end) schedule
|> Enum.chunk_by(fn {_, user} -> user && user.id end) |> Resolver.resolve_range(start_at, end_at)
|> Enum.map(fn chunk -> |> Enum.map(fn segment ->
{start_offset, user} = List.first(chunk) start_offset = max(div(DateTime.diff(segment.start_at, start_at, :second), 3600), 0)
duration = length(chunk) end_offset = min(div(DateTime.diff(segment.end_at, start_at, :second), 3600), total_hours)
duration = max(end_offset - start_offset, 1)
%{ %{
user: user, user: segment.user,
start_offset: start_offset, start_offset: start_offset,
duration: duration, duration: duration,
total_hours: total_hours total_hours: total_hours
} }
end) end)
|> Enum.reject(fn span -> is_nil(span.user) end)
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 end

View file

@ -338,8 +338,10 @@
</p> </p>
</div> </div>
<% else %> <% else %>
<% sorted_layers = Enum.sort_by(@schedule.layers, & &1.position) %>
<% layer_count = length(sorted_layers) %>
<div class="space-y-4"> <div class="space-y-4">
<%= for layer <- Enum.sort_by(@schedule.layers, & &1.position) do %> <%= for {layer, idx} <- Enum.with_index(sorted_layers) do %>
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4"> <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 class="flex items-center justify-between mb-3">
<div> <div>
@ -357,14 +359,37 @@
· Handoff at {Calendar.strftime(layer.handoff_time, "%H:%M")} · Handoff at {Calendar.strftime(layer.handoff_time, "%H:%M")}
</p> </p>
</div> </div>
<button <div class="flex items-center gap-1">
phx-click="delete_layer" <button
phx-value-id={layer.id} phx-click="move_layer"
data-confirm={t("Are you sure you want to delete this layer?")} phx-value-id={layer.id}
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors" phx-value-direction="up"
> class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:text-gray-400"
<.icon name="hero-trash" class="h-4 w-4" /> disabled={idx == 0}
</button> title={t("Move up")}
>
<.icon name="hero-arrow-up" class="h-4 w-4" />
</button>
<button
phx-click="move_layer"
phx-value-id={layer.id}
phx-value-direction="down"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:text-gray-400"
disabled={idx == layer_count - 1}
title={t("Move down")}
>
<.icon name="hero-arrow-down" class="h-4 w-4" />
</button>
<button
phx-click="delete_layer"
phx-value-id={layer.id}
data-confirm={t("Are you sure you want to delete this layer?")}
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors ml-1"
title={t("Delete")}
>
<.icon name="hero-trash" class="h-4 w-4" />
</button>
</div>
</div> </div>
<%!-- Members --%> <%!-- Members --%>

View file

@ -1,3 +1,7 @@
2026-04-28 — Schedule Layer Reorder
* Added up/down reorder controls to layers on the schedule detail page
* Schedule timelines now use the same per-day rendering engine as the schedules list
2026-04-28 — Schedules Timeline View 2026-04-28 — Schedules Timeline View
* New gantt-style schedules list shows who's on call across a date window * 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 * Date navigation (Today / prev / next) and 1/2/4-week range selector

View file

@ -353,6 +353,60 @@ defmodule Towerops.OnCallTest do
end end
end end
describe "move_layer/2" do
test "swaps positions with the neighbour above when direction is :up", %{organization: org} do
schedule = schedule_fixture(org.id)
{:ok, l0} = OnCall.create_layer(layer_attrs(schedule.id, 0))
{:ok, l1} = OnCall.create_layer(layer_attrs(schedule.id, 1))
{:ok, _} = OnCall.move_layer(l1, :up)
reloaded = OnCall.get_schedule!(schedule.id)
by_id = Map.new(reloaded.layers, &{&1.id, &1.position})
assert by_id[l0.id] == 1
assert by_id[l1.id] == 0
end
test "is a no-op at the top boundary", %{organization: org} do
schedule = schedule_fixture(org.id)
{:ok, l0} = OnCall.create_layer(layer_attrs(schedule.id, 0))
assert {:ok, returned} = OnCall.move_layer(l0, :up)
assert returned.id == l0.id
assert returned.position == 0
end
test "swaps with the neighbour below when direction is :down", %{organization: org} do
schedule = schedule_fixture(org.id)
{:ok, l0} = OnCall.create_layer(layer_attrs(schedule.id, 0))
{:ok, l1} = OnCall.create_layer(layer_attrs(schedule.id, 1))
{:ok, _} = OnCall.move_layer(l0, :down)
by_id =
schedule.id
|> OnCall.get_schedule!()
|> Map.fetch!(:layers)
|> Map.new(&{&1.id, &1.position})
assert by_id[l0.id] == 1
assert by_id[l1.id] == 0
end
end
defp layer_attrs(schedule_id, position) do
%{
name: "Layer #{position}",
position: position,
rotation_type: "weekly",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
handoff_day: 1,
start_date: ~U[2026-01-01 09:00:00Z],
schedule_id: schedule_id
}
end
describe "who_is_on_call/2" do describe "who_is_on_call/2" do
test "resolves who is on call from schedule id", %{user: user, organization: org} do test "resolves who is on call from schedule id", %{user: user, organization: org} do
schedule = schedule_fixture(org.id) schedule = schedule_fixture(org.id)

View file

@ -199,6 +199,39 @@ defmodule ToweropsWeb.ScheduleLiveTest do
assert html =~ "Covers weekday shifts" assert html =~ "Covers weekday shifts"
end end
test "can reorder layers via the up/down buttons", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
l0 =
layer_fixture(schedule.id, %{
name: "Top Layer",
position: 0,
rotation_type: "weekly",
handoff_time: ~T[09:00:00],
start_date: ~U[2026-01-01 09:00:00Z]
})
l1 =
layer_fixture(schedule.id, %{
name: "Bottom Layer",
position: 1,
rotation_type: "weekly",
handoff_time: ~T[09:00:00],
start_date: ~U[2026-01-01 09:00:00Z]
})
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
view
|> element(~s|button[phx-click="move_layer"][phx-value-id="#{l1.id}"][phx-value-direction="up"]|)
|> render_click()
reloaded = OnCall.get_schedule!(schedule.id)
by_id = Map.new(reloaded.layers, &{&1.id, &1.position})
assert by_id[l0.id] == 1
assert by_id[l1.id] == 0
end
test "shows Currently On-Call section with no one on-call", %{ test "shows Currently On-Call section with no one on-call", %{
conn: conn, conn: conn,
organization: organization organization: organization