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) %>