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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 --%>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue