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:
parent
08cdbe355b
commit
23304e108c
7 changed files with 215 additions and 62 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -338,8 +338,10 @@
|
|||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<% sorted_layers = Enum.sort_by(@schedule.layers, & &1.position) %>
|
||||
<% layer_count = length(sorted_layers) %>
|
||||
<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="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
|
|
@ -357,14 +359,37 @@
|
|||
· Handoff at {Calendar.strftime(layer.handoff_time, "%H:%M")}
|
||||
</p>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<.icon name="hero-trash" class="h-4 w-4" />
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
phx-click="move_layer"
|
||||
phx-value-id={layer.id}
|
||||
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"
|
||||
disabled={idx == 0}
|
||||
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>
|
||||
|
||||
<%!-- Members --%>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue