- {String.capitalize(layer.rotation_type)} rotation, every {layer.rotation_interval}
- <%= case layer.rotation_type do %>
- <% "daily" -> %>
- {ngettext("day", "days", layer.rotation_interval)}
- <% "weekly" -> %>
- {ngettext("week", "weeks", layer.rotation_interval)}
- <% _ -> %>
- {ngettext("day", "days", layer.rotation_interval)}
- <% end %>
- · Handoff at {Calendar.strftime(layer.handoff_time, "%H:%M")}
-
+
+
+ <.icon name="hero-bars-3" class="h-4 w-4 text-gray-400 mt-0.5 shrink-0" />
+
+
+ {layer.name}
+
+
+ {String.capitalize(layer.rotation_type)} rotation, every {layer.rotation_interval}
+ <%= case layer.rotation_type do %>
+ <% "daily" -> %>
+ {ngettext("day", "days", layer.rotation_interval)}
+ <% "weekly" -> %>
+ {ngettext("week", "weeks", layer.rotation_interval)}
+ <% _ -> %>
+ {ngettext("day", "days", layer.rotation_interval)}
+ <% end %>
+ · Handoff at {Calendar.strftime(layer.handoff_time, "%H:%M")}
+
+
-
+
+
+ <%!-- Restrictions editor --%>
+
+
+ {t("Restrict on-call shifts")}
+
+
+
<% end %>
diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt
index 6ab86139..71110789 100644
--- a/priv/static/changelog.txt
+++ b/priv/static/changelog.txt
@@ -1,3 +1,8 @@
+2026-04-28 — Drag-and-drop reorder + restrictions + new-schedule polish
+* Drag levels and layers to reorder them on escalation policy and schedule pages (up/down arrow buttons still work)
+* Added on-call shift restrictions on schedule layers (time of day, time of week)
+* Newly-created schedules now land with the Add Layer form already open
+
2026-04-28 — Schedules List Search & Pagination
* Search bar on the schedules list to filter by schedule name
* Pagination on the schedules list (10 per page) for organizations with many schedules
diff --git a/test/towerops/on_call/resolver_test.exs b/test/towerops/on_call/resolver_test.exs
index 388aa67d..c99f0af5 100644
--- a/test/towerops/on_call/resolver_test.exs
+++ b/test/towerops/on_call/resolver_test.exs
@@ -302,6 +302,82 @@ defmodule Towerops.OnCall.ResolverTest do
end
end
+ describe "time_of_week restrictions" do
+ test "honors weekday and time-of-day restrictions", ctx do
+ schedule = schedule_fixture(ctx.organization.id)
+
+ # Restrict to Mon-Fri (1-5), 09:00-17:00
+ layer =
+ layer_fixture(schedule.id, %{
+ rotation_type: "weekly",
+ rotation_interval: 1,
+ handoff_time: ~T[09:00:00],
+ handoff_day: 1,
+ start_date: ~U[2026-03-01 00:00:00Z],
+ position: 1,
+ restriction_type: "time_of_week",
+ restrictions: %{"days" => [1, 2, 3, 4, 5], "start_time" => "09:00", "end_time" => "17:00"}
+ })
+
+ layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
+ schedule = preload_schedule(schedule)
+
+ # 2026-03-02 is a Monday — at noon should be on call
+ assert Resolver.resolve(schedule, ~U[2026-03-02 12:00:00Z]).id == ctx.user1.id
+
+ # 2026-03-02 at 18:00 — outside time window
+ assert Resolver.resolve(schedule, ~U[2026-03-02 18:00:00Z]) == nil
+
+ # 2026-03-07 (Saturday) at noon — outside weekday set
+ assert Resolver.resolve(schedule, ~U[2026-03-07 12:00:00Z]) == nil
+ end
+
+ test "treats empty days list as never-on-call", ctx do
+ schedule = schedule_fixture(ctx.organization.id)
+
+ layer =
+ layer_fixture(schedule.id, %{
+ rotation_type: "weekly",
+ rotation_interval: 1,
+ handoff_time: ~T[09:00:00],
+ handoff_day: 1,
+ start_date: ~U[2026-03-01 00:00:00Z],
+ position: 1,
+ restriction_type: "time_of_week",
+ restrictions: %{"days" => [], "start_time" => "00:00", "end_time" => "23:59"}
+ })
+
+ layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
+ schedule = preload_schedule(schedule)
+
+ assert Resolver.resolve(schedule, ~U[2026-03-02 12:00:00Z]) == nil
+ end
+
+ test "accepts string-encoded day numbers (form payloads)", ctx do
+ schedule = schedule_fixture(ctx.organization.id)
+
+ layer =
+ layer_fixture(schedule.id, %{
+ rotation_type: "weekly",
+ rotation_interval: 1,
+ handoff_time: ~T[09:00:00],
+ handoff_day: 1,
+ start_date: ~U[2026-03-01 00:00:00Z],
+ position: 1,
+ restriction_type: "time_of_week",
+ restrictions: %{"days" => ["1", "2"], "start_time" => "00:00", "end_time" => "23:59"}
+ })
+
+ layer_member_fixture(layer.id, ctx.user1.id, %{position: 0})
+ schedule = preload_schedule(schedule)
+
+ # Monday
+ assert Resolver.resolve(schedule, ~U[2026-03-02 12:00:00Z]).id == ctx.user1.id
+ # Wednesday — not in the restricted set
+ assert Resolver.resolve(schedule, ~U[2026-03-04 12:00:00Z]) == nil
+ end
+ end
+
describe "resolve_range/3" do
test "returns one segment when a single user covers the whole range", ctx do
schedule = schedule_fixture(ctx.organization.id)
diff --git a/test/towerops/on_call_test.exs b/test/towerops/on_call_test.exs
index 50e11011..b315fcdf 100644
--- a/test/towerops/on_call_test.exs
+++ b/test/towerops/on_call_test.exs
@@ -353,6 +353,136 @@ defmodule Towerops.OnCallTest do
end
end
+ describe "update_layer_restrictions/2" do
+ test "sets a time_of_day restriction", %{organization: org} do
+ schedule = schedule_fixture(org.id)
+ {:ok, layer} = OnCall.create_layer(layer_attrs(schedule.id, 0))
+
+ {:ok, updated} =
+ OnCall.update_layer_restrictions(layer, %{
+ restriction_type: "time_of_day",
+ restrictions: %{"start_time" => "09:00", "end_time" => "17:00"}
+ })
+
+ assert updated.restriction_type == "time_of_day"
+ assert updated.restrictions == %{"start_time" => "09:00", "end_time" => "17:00"}
+ end
+
+ test "rejects unknown restriction types", %{organization: org} do
+ schedule = schedule_fixture(org.id)
+ {:ok, layer} = OnCall.create_layer(layer_attrs(schedule.id, 0))
+
+ assert {:error, changeset} =
+ OnCall.update_layer_restrictions(layer, %{
+ restriction_type: "garbage",
+ restrictions: %{}
+ })
+
+ refute changeset.valid?
+ end
+
+ test "accepts string-keyed attrs (LiveView form payload shape)", %{organization: org} do
+ schedule = schedule_fixture(org.id)
+ {:ok, layer} = OnCall.create_layer(layer_attrs(schedule.id, 0))
+
+ {:ok, updated} =
+ OnCall.update_layer_restrictions(layer, %{
+ "restriction_type" => "time_of_day",
+ "restrictions" => %{"start_time" => "08:00", "end_time" => "16:00"}
+ })
+
+ assert updated.restriction_type == "time_of_day"
+ end
+ end
+
+ describe "clear_layer_restrictions/1" do
+ test "removes any restriction set on the layer", %{organization: org} do
+ schedule = schedule_fixture(org.id)
+
+ {:ok, layer} =
+ OnCall.create_layer(
+ Map.merge(layer_attrs(schedule.id, 0), %{
+ restriction_type: "time_of_day",
+ restrictions: %{"start_time" => "09:00", "end_time" => "17:00"}
+ })
+ )
+
+ {:ok, cleared} = OnCall.clear_layer_restrictions(layer)
+ assert cleared.restriction_type == nil
+ assert cleared.restrictions == nil
+ end
+ end
+
+ describe "reorder_layers/2" do
+ test "applies the given order as 0..n-1 positions", %{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, l2} = OnCall.create_layer(layer_attrs(schedule.id, 2))
+
+ assert :ok = OnCall.reorder_layers(schedule.id, [l2.id, l0.id, l1.id])
+
+ reloaded = OnCall.get_schedule!(schedule.id)
+ by_id = Map.new(reloaded.layers, &{&1.id, &1.position})
+ assert by_id[l2.id] == 0
+ assert by_id[l0.id] == 1
+ assert by_id[l1.id] == 2
+ end
+
+ test "rejects when the id list does not cover every layer", %{organization: org} do
+ schedule = schedule_fixture(org.id)
+ {:ok, l0} = OnCall.create_layer(layer_attrs(schedule.id, 0))
+ {:ok, _} = OnCall.create_layer(layer_attrs(schedule.id, 1))
+
+ assert {:error, :id_count_mismatch} = OnCall.reorder_layers(schedule.id, [l0.id])
+ end
+
+ test "rejects unknown ids", %{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))
+
+ # Same length, but one id is bogus
+ assert {:error, :unknown_id} =
+ OnCall.reorder_layers(schedule.id, [l0.id, Ecto.UUID.generate()])
+ end
+ end
+
+ describe "reorder_escalation_rules/2" do
+ test "applies the given order as 0..n-1 positions", %{organization: org} do
+ policy = escalation_policy_fixture(org.id)
+
+ {:ok, r0} =
+ OnCall.create_escalation_rule(%{
+ escalation_policy_id: policy.id,
+ position: 0,
+ timeout_minutes: 10
+ })
+
+ {:ok, r1} =
+ OnCall.create_escalation_rule(%{
+ escalation_policy_id: policy.id,
+ position: 1,
+ timeout_minutes: 20
+ })
+
+ {:ok, r2} =
+ OnCall.create_escalation_rule(%{
+ escalation_policy_id: policy.id,
+ position: 2,
+ timeout_minutes: 30
+ })
+
+ assert :ok = OnCall.reorder_escalation_rules(policy.id, [r2.id, r0.id, r1.id])
+
+ reloaded = OnCall.get_escalation_policy!(policy.id)
+ by_id = Map.new(reloaded.rules, &{&1.id, &1.position})
+ assert by_id[r2.id] == 0
+ assert by_id[r0.id] == 1
+ assert by_id[r1.id] == 2
+ 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)
diff --git a/test/towerops_web/live/escalation_policy_live_test.exs b/test/towerops_web/live/escalation_policy_live_test.exs
index 3c8eb78e..86702f6c 100644
--- a/test/towerops_web/live/escalation_policy_live_test.exs
+++ b/test/towerops_web/live/escalation_policy_live_test.exs
@@ -217,6 +217,32 @@ defmodule ToweropsWeb.EscalationPolicyLiveTest do
assert html =~ "No devices use this policy"
end
+ test "reorder_rules event applies the new order", %{conn: conn, organization: organization} do
+ policy = escalation_policy_fixture(organization.id)
+
+ {:ok, r0} =
+ OnCall.create_escalation_rule(%{
+ escalation_policy_id: policy.id,
+ position: 0,
+ timeout_minutes: 30
+ })
+
+ {:ok, r1} =
+ OnCall.create_escalation_rule(%{
+ escalation_policy_id: policy.id,
+ position: 1,
+ timeout_minutes: 60
+ })
+
+ {:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
+
+ render_hook(view, "reorder_rules", %{"ordered_ids" => [r1.id, r0.id]})
+ reloaded = OnCall.get_escalation_policy!(policy.id)
+ by_id = Map.new(reloaded.rules, &{&1.id, &1.position})
+ assert by_id[r1.id] == 0
+ assert by_id[r0.id] == 1
+ end
+
test "can move a level up via the move_rule event", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
diff --git a/test/towerops_web/live/schedule_live_test.exs b/test/towerops_web/live/schedule_live_test.exs
index efec6518..fba7f77e 100644
--- a/test/towerops_web/live/schedule_live_test.exs
+++ b/test/towerops_web/live/schedule_live_test.exs
@@ -208,6 +208,40 @@ defmodule ToweropsWeb.ScheduleLiveTest do
assert html =~ "America/New_York"
end
+ test "auto-opens the Add Layer form when no layers exist", %{
+ conn: conn,
+ organization: organization
+ } do
+ # Schedule with zero layers (just-created flow)
+ schedule = schedule_fixture(organization.id)
+
+ {:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
+
+ # The form fields are inline when show_add_layer is true
+ assert html =~ ~r/name="layer\[/
+ end
+
+ test "does not auto-open Add Layer when layers already exist", %{
+ conn: conn,
+ organization: organization
+ } do
+ schedule = schedule_fixture(organization.id)
+
+ _ =
+ layer_fixture(schedule.id, %{
+ name: "Existing",
+ position: 0,
+ 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}")
+
+ # The form should not be visible inline by default
+ refute html =~ ~r/name="layer\[/
+ end
+
test "shows description if present", %{conn: conn, organization: organization} do
schedule =
schedule_fixture(organization.id, %{
@@ -220,6 +254,36 @@ defmodule ToweropsWeb.ScheduleLiveTest do
assert html =~ "Covers weekday shifts"
end
+ test "reorder_layers event applies the new order", %{conn: conn, organization: organization} do
+ schedule = schedule_fixture(organization.id)
+
+ l0 =
+ layer_fixture(schedule.id, %{
+ name: "L0",
+ 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: "L1",
+ 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}")
+
+ render_hook(view, "reorder_layers", %{"ordered_ids" => [l1.id, l0.id]})
+ reloaded = OnCall.get_schedule!(schedule.id)
+ by_id = Map.new(reloaded.layers, &{&1.id, &1.position})
+ assert by_id[l1.id] == 0
+ assert by_id[l0.id] == 1
+ end
+
test "can reorder layers via the up/down buttons", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
@@ -326,11 +390,24 @@ defmodule ToweropsWeb.ScheduleLiveTest do
assert html =~ "No overrides configured"
end
- test "can toggle add layer form", %{conn: conn, organization: organization} do
+ test "can toggle add layer form once a layer exists", %{
+ conn: conn,
+ organization: organization
+ } do
schedule = schedule_fixture(organization.id)
+ _ =
+ layer_fixture(schedule.id, %{
+ name: "Existing",
+ position: 0,
+ 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}")
+ # With at least one layer the form is hidden by default
refute has_element?(view, "form[phx-submit='save_layer']")
view |> element("button", "Add Layer") |> render_click()
@@ -338,13 +415,14 @@ defmodule ToweropsWeb.ScheduleLiveTest do
assert has_element?(view, "form[phx-submit='save_layer']")
end
- test "can add a layer", %{conn: conn, organization: organization} do
+ test "can add a layer (form is pre-opened on a fresh schedule)", %{
+ conn: conn,
+ organization: organization
+ } do
schedule = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
- view |> element("button", "Add Layer") |> render_click()
-
view
|> form("form[phx-submit='save_layer']",
layer: %{