From 23304e108ca5c6802eb26580a1c05c5e6c25da7c Mon Sep 17 00:00:00 2001
From: Graham McIntire
Date: Tue, 28 Apr 2026 14:05:06 -0500
Subject: [PATCH] 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.
---
CHANGELOG.txt | 18 ++++
lib/towerops/on_call.ex | 42 ++++++++++
lib/towerops_web/live/schedule_live/show.ex | 83 +++++++------------
.../live/schedule_live/show.html.heex | 43 ++++++++--
priv/static/changelog.txt | 4 +
test/towerops/on_call_test.exs | 54 ++++++++++++
test/towerops_web/live/schedule_live_test.exs | 33 ++++++++
7 files changed, 215 insertions(+), 62 deletions(-)
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 0e45959e..f3b44738 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -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
feat(on_call): schedules list with gantt timeline (chunk 2)
- Resolver.resolve_range/3 returns contiguous on-call segments across a
diff --git a/lib/towerops/on_call.ex b/lib/towerops/on_call.ex
index 415e7b3a..ec6f2e1b 100644
--- a/lib/towerops/on_call.ex
+++ b/lib/towerops/on_call.ex
@@ -84,6 +84,48 @@ defmodule Towerops.OnCall do
Repo.delete(layer)
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 ---
def add_layer_member(attrs) do
diff --git a/lib/towerops_web/live/schedule_live/show.ex b/lib/towerops_web/live/schedule_live/show.ex
index e15d7189..5d0932d4 100644
--- a/lib/towerops_web/live/schedule_live/show.ex
+++ b/lib/towerops_web/live/schedule_live/show.ex
@@ -146,6 +146,17 @@ defmodule ToweropsWeb.ScheduleLive.Show do
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
layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id))
@@ -296,69 +307,35 @@ defmodule ToweropsWeb.ScheduleLive.Show do
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)
+ # Resolve a single-layer "schedule" so we get per-layer coverage
+ # without overrides bleeding in. The Resolver only reads :layers and
+ # :overrides off the struct, so a stub keyword-style struct is fine.
+ layer_schedule = %Towerops.OnCall.Schedule{layers: [layer], overrides: []}
+ segments_to_spans(layer_schedule, start_date, num_days)
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
+ segments_to_spans(schedule, start_date, num_days)
+ end
- 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)
+ defp segments_to_spans(schedule, start_date, num_days) do
+ total_hours = num_days * 24
+ {:ok, start_at} = DateTime.new(start_date, ~T[00:00:00], "Etc/UTC")
+ end_at = DateTime.add(start_at, total_hours * 3600, :second)
+
+ schedule
+ |> Resolver.resolve_range(start_at, end_at)
+ |> Enum.map(fn segment ->
+ start_offset = max(div(DateTime.diff(segment.start_at, start_at, :second), 3600), 0)
+ 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,
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 2ef55caa..437a2180 100644
--- a/lib/towerops_web/live/schedule_live/show.html.heex
+++ b/lib/towerops_web/live/schedule_live/show.html.heex
@@ -338,8 +338,10 @@
<% else %>
+ <% sorted_layers = Enum.sort_by(@schedule.layers, & &1.position) %>
+ <% layer_count = length(sorted_layers) %>
- <%= for layer <- Enum.sort_by(@schedule.layers, & &1.position) do %>
+ <%= for {layer, idx} <- Enum.with_index(sorted_layers) do %>
@@ -357,14 +359,37 @@
· Handoff at {Calendar.strftime(layer.handoff_time, "%H:%M")}
-
+
+
+
+
+
<%!-- Members --%>
diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt
index 3f2a9d2a..1d4ed51a 100644
--- a/priv/static/changelog.txt
+++ b/priv/static/changelog.txt
@@ -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
* 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
diff --git a/test/towerops/on_call_test.exs b/test/towerops/on_call_test.exs
index 4dd98950..50e11011 100644
--- a/test/towerops/on_call_test.exs
+++ b/test/towerops/on_call_test.exs
@@ -353,6 +353,60 @@ defmodule Towerops.OnCallTest do
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
test "resolves who is on call from schedule id", %{user: user, organization: org} do
schedule = schedule_fixture(org.id)
diff --git a/test/towerops_web/live/schedule_live_test.exs b/test/towerops_web/live/schedule_live_test.exs
index 269f98c7..08b7cf48 100644
--- a/test/towerops_web/live/schedule_live_test.exs
+++ b/test/towerops_web/live/schedule_live_test.exs
@@ -199,6 +199,39 @@ defmodule ToweropsWeb.ScheduleLiveTest do
assert html =~ "Covers weekday shifts"
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", %{
conn: conn,
organization: organization