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