feat(on_call): drag-drop reorder + restrictions editor + new-schedule polish

Three deferred followups from the chunk-4 plan, all together:

A. Restrictions editor
- Resolver: time_of_week support (weekday set + time-of-day window),
  in addition to the existing time_of_day.
- New OnCall.update_layer_restrictions/2 + clear_layer_restrictions/1.
- schedule_live/show: per-layer Restrict on-call shifts form
  (Always on call / Time of day / Time of week with Mon-Sun checkboxes),
  changes patched live and persisted via the resolver.

B. Drag-and-drop reorder
- New OnCall.reorder_layers/2 + reorder_escalation_rules/2 (atomic
  transaction; validates id count + membership).
- assets/js/sortable_list.ts: minimal HTML5 drag-and-drop hook (no
  external dep; mirrors the existing DeviceListReorder pattern),
  registered as SortableList in app.ts.
- schedule_live/show + escalation_policy_live/show: layer/level cards
  now carry data-id + draggable + a visible drag handle. Up/down arrow
  buttons remain as a fallback for users on assistive tech.

C. Unified new-schedule UX
- schedule_live/show pre-opens the Add Layer form when the schedule
  has zero layers, so the new-schedule -> add-layers flow lands ready
  to act on. Two existing tests updated to seed a layer first; two
  new tests verify the auto-open behaviour both ways.

Full suite 10,231 / 0 failures. Format/dialyzer clean.
This commit is contained in:
Graham McIntire 2026-04-28 14:27:38 -05:00
parent a7c0d362e5
commit 434386816e
14 changed files with 879 additions and 139 deletions

View file

@ -1,3 +1,37 @@
2026-04-28
feat(on_call): deferred followups - drag-drop, restrictions, new-schedule UX
Followup A — Restrictions editor
- Resolver: added time_of_week support (weekday set + time-of-day window).
- OnCall.update_layer_restrictions/2 + clear_layer_restrictions/1 helpers.
- schedule_live/show: per-layer restrictions form (always-on / time of
day / time of week with weekday checkboxes), wired to the resolver.
- Tests: 3 resolver tests, 4 context tests.
Followup B — Drag-and-drop reorder
- OnCall.reorder_layers/2 + reorder_escalation_rules/2 (atomic
transaction; validates id count + membership).
- assets/js/sortable_list.ts: minimal HTML5 drag-and-drop hook
(no external dep; mirrors the existing DeviceListReorder pattern)
registered as SortableList in app.ts.
- schedule_live/show + escalation_policy_live/show: layer/level
cards now have data-id + draggable + drag handle. Up/down arrow
buttons remain as a fallback.
- Tests: 4 context tests (layers + rules), 2 LV integration tests.
Followup C — Unified new-schedule UX
- schedule_live/show pre-opens the Add Layer form when the
schedule has zero layers, so the new-schedule -> add-layers
flow lands ready to act on.
- Tests: 2 new + 2 existing tests updated for the new behaviour.
Full suite 10,231 / 0 failures. Format/dialyzer clean.
Files: lib/towerops/on_call/{resolver.ex,on_call.ex},
lib/towerops_web/live/schedule_live/{show.ex,show.html.heex},
lib/towerops_web/live/escalation_policy_live/{show.ex,show.html.heex},
assets/js/{sortable_list.ts,app.ts},
test/towerops/on_call/{resolver_test.exs,on_call_test.exs},
test/towerops_web/live/{schedule_live_test.exs,escalation_policy_live_test.exs}
2026-04-28
feat(on_call): schedules list search/pagination + e2e refresh (chunk 4)
- ScheduleLive.Index: name search input (phx-debounce 300ms) + standard

View file

@ -29,6 +29,7 @@ import type { ChartData, ChartDataset, ChartDataPoint } from "../vendor/chart"
import cytoscape from "../vendor/cytoscape"
import type { SensorChartHook } from "./types/liveview"
import { DeviceListReorder } from "./device_list_reorder"
import { SortableList } from "./sortable_list"
import { initCookieConsent } from "./cookie_consent"
// Helper function to convert range string to milliseconds
@ -2401,7 +2402,7 @@ const WeathermapViewer = {
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 5000,
params: { _csrf_token: csrfToken, timezone: userTimezone },
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, LeafletMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse },
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, LeafletMap, DeviceListReorder, SortableList, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse },
})
// Show progress bar on live navigation and form submits

114
assets/js/sortable_list.ts Normal file
View file

@ -0,0 +1,114 @@
/**
* SortableList minimal HTML5 drag-and-drop hook for reorderable lists.
*
* Markup contract:
* <div phx-hook="SortableList" id="..." data-event="reorder_layers" data-group-key="schedule_id">
* <div data-id="<uuid>" draggable="true">...</div>
* <div data-id="<uuid>" draggable="true">...</div>
* </div>
*
* On drop, the hook pushes one event named by `data-event` with payload
* `{ ordered_ids: [...] }` carrying the new order of every direct child
* with a `data-id` attribute.
*
* No external dependency uses native HTML5 drag-and-drop. Mirrors the
* existing DeviceListReorder hook in style.
*/
export const SortableList = {
draggedEl: null as HTMLElement | null,
handleDragStart: null as ((e: DragEvent) => void) | null,
handleDragEnd: null as ((e: DragEvent) => void) | null,
handleDragOver: null as ((e: DragEvent) => void) | null,
handleDrop: null as ((e: DragEvent) => void) | null,
mounted(this: any) {
const container: HTMLElement = this.el
const eventName: string = container.dataset.event || "reorder"
const itemFromTarget = (t: EventTarget | null): HTMLElement | null => {
const el = t as HTMLElement | null
return el ? (el.closest<HTMLElement>("[data-id]") || null) : null
}
const itemIsDirectChild = (item: HTMLElement | null): item is HTMLElement => {
return !!item && item.parentElement === container
}
this.handleDragStart = (e: DragEvent) => {
const item = itemFromTarget(e.target)
if (!itemIsDirectChild(item) || item.getAttribute("draggable") !== "true") return
this.draggedEl = item
item.classList.add("opacity-50")
e.dataTransfer!.effectAllowed = "move"
}
this.handleDragEnd = (_e: DragEvent) => {
if (this.draggedEl) {
this.draggedEl.classList.remove("opacity-50")
this.draggedEl = null
}
container.querySelectorAll(".sortable-drop-target").forEach((el: Element) => {
el.classList.remove("sortable-drop-target", "border-t-4", "border-b-4", "border-blue-500")
})
}
this.handleDragOver = (e: DragEvent) => {
e.preventDefault()
if (!this.draggedEl) return
const target = itemFromTarget(e.target)
if (!itemIsDirectChild(target) || target === this.draggedEl) return
const rect = target.getBoundingClientRect()
const isBottomHalf = e.clientY > rect.top + rect.height / 2
container.querySelectorAll(".sortable-drop-target").forEach((el: Element) => {
el.classList.remove("sortable-drop-target", "border-t-4", "border-b-4", "border-blue-500")
})
target.classList.add(
"sortable-drop-target",
"border-blue-500",
isBottomHalf ? "border-b-4" : "border-t-4"
)
}
this.handleDrop = (e: DragEvent) => {
e.preventDefault()
if (!this.draggedEl) return
const target = itemFromTarget(e.target)
if (!itemIsDirectChild(target) || target === this.draggedEl) return
const rect = target.getBoundingClientRect()
const isBottomHalf = e.clientY > rect.top + rect.height / 2
// Reorder DOM optimistically so the user sees the result
// immediately; the server-side patch will reconcile.
if (isBottomHalf) {
container.insertBefore(this.draggedEl, target.nextSibling)
} else {
container.insertBefore(this.draggedEl, target)
}
const orderedIds = Array.from(
container.querySelectorAll<HTMLElement>(":scope > [data-id]")
).map(el => el.dataset.id!)
this.pushEvent(eventName, { ordered_ids: orderedIds })
}
container.addEventListener("dragstart", this.handleDragStart)
container.addEventListener("dragend", this.handleDragEnd)
container.addEventListener("dragover", this.handleDragOver)
container.addEventListener("drop", this.handleDrop)
},
destroyed(this: any) {
if (this.handleDragStart) this.el.removeEventListener("dragstart", this.handleDragStart)
if (this.handleDragEnd) this.el.removeEventListener("dragend", this.handleDragEnd)
if (this.handleDragOver) this.el.removeEventListener("dragover", this.handleDragOver)
if (this.handleDrop) this.el.removeEventListener("drop", this.handleDrop)
this.draggedEl = null
}
}

View file

@ -84,6 +84,27 @@ defmodule Towerops.OnCall do
Repo.delete(layer)
end
@doc """
Sets or clears the on-call restriction window for a layer.
Pass `nil` for `restriction_type` (or any of the value pairs) to clear
restrictions and have the layer cover its full rotation continuously.
"""
def update_layer_restrictions(%Layer{} = layer, attrs) when is_map(attrs) do
layer
|> Layer.changeset(%{
restriction_type: attrs[:restriction_type] || attrs["restriction_type"],
restrictions: attrs[:restrictions] || attrs["restrictions"]
})
|> Repo.update()
end
def clear_layer_restrictions(%Layer{} = layer) do
layer
|> Layer.changeset(%{restriction_type: nil, restrictions: nil})
|> Repo.update()
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}`.
@ -111,6 +132,61 @@ defmodule Towerops.OnCall do
defp find_neighbour_layer(layers, layer, :down), do: Enum.find(layers, &(&1.position == layer.position + 1))
@doc """
Persists a new ordering for all layers of `schedule_id`.
`ordered_ids` must contain every layer id exactly once. Positions are
assigned 0..n-1 in the order given, in a single transaction.
"""
def reorder_layers(schedule_id, ordered_ids) when is_binary(schedule_id) and is_list(ordered_ids) do
layers =
Layer
|> where([l], l.schedule_id == ^schedule_id)
|> Repo.all()
reorder_in_transaction(layers, ordered_ids, &Layer.changeset/2)
end
@doc """
Persists a new ordering for all rules of `policy_id`. Same contract as
`reorder_layers/2`.
"""
def reorder_escalation_rules(policy_id, ordered_ids) when is_binary(policy_id) and is_list(ordered_ids) do
rules =
EscalationRule
|> where([r], r.escalation_policy_id == ^policy_id)
|> Repo.all()
reorder_in_transaction(rules, ordered_ids, &EscalationRule.changeset/2)
end
defp reorder_in_transaction(records, ordered_ids, changeset_fn) do
by_id = Map.new(records, &{&1.id, &1})
cond do
length(ordered_ids) != length(records) -> {:error, :id_count_mismatch}
not Enum.all?(ordered_ids, &Map.has_key?(by_id, &1)) -> {:error, :unknown_id}
true -> persist_reorder(by_id, ordered_ids, changeset_fn)
end
end
defp persist_reorder(by_id, ordered_ids, changeset_fn) do
fn -> Enum.each(Enum.with_index(ordered_ids), &assign_position(&1, by_id, changeset_fn)) end
|> Repo.transaction()
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp assign_position({id, idx}, by_id, changeset_fn) do
{:ok, _} =
by_id
|> Map.fetch!(id)
|> changeset_fn.(%{position: idx})
|> Repo.update()
end
defp swap_layer_positions(a, b) do
a_pos = a.position
b_pos = b.position

View file

@ -181,8 +181,37 @@ defmodule Towerops.OnCall.Resolver do
end
end
defp within_restriction?(%{restriction_type: "time_of_week", restrictions: restrictions}, datetime) do
days = restrictions["days"] || []
start_time = parse_time(restrictions["start_time"])
end_time = parse_time(restrictions["end_time"])
weekday = Date.day_of_week(DateTime.to_date(datetime))
current_time = DateTime.to_time(datetime)
weekday_in_range?(days, weekday) and time_within_range?(current_time, start_time, end_time)
end
defp within_restriction?(_layer, _datetime), do: true
defp weekday_in_range?([], _weekday), do: false
defp weekday_in_range?(days, weekday) when is_list(days) do
Enum.any?(days, fn d ->
case d do
d when is_integer(d) -> d == weekday
d when is_binary(d) -> String.to_integer(d) == weekday
end
end)
end
defp time_within_range?(current, start_time, end_time) do
if Time.after?(start_time, end_time) do
Time.compare(current, start_time) in [:gt, :eq] or Time.before?(current, end_time)
else
Time.compare(current, start_time) in [:gt, :eq] and Time.before?(current, end_time)
end
end
defp compute_rotation(%{members: []}, _datetime), do: nil
defp compute_rotation(layer, datetime) do

View file

@ -100,6 +100,13 @@ defmodule ToweropsWeb.EscalationPolicyLive.Show do
end
end
def handle_event("reorder_rules", %{"ordered_ids" => ordered_ids}, socket) when is_list(ordered_ids) do
case OnCall.reorder_escalation_rules(socket.assigns.policy.id, ordered_ids) do
:ok -> {:noreply, reload_policy(socket)}
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to reorder levels"))}
end
end
def handle_event("move_rule", %{"id" => id, "direction" => direction}, socket) when direction in ["up", "down"] do
rule = Enum.find(socket.assigns.policy.rules, &(&1.id == id))

View file

@ -55,123 +55,137 @@
<% else %>
<% sorted_rules = Enum.sort_by(@policy.rules, & &1.position) %>
<% rule_count = length(sorted_rules) %>
<%= for {rule, idx} <- Enum.with_index(sorted_rules) 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-2">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{t("Level %{n}", n: idx + 1)}
</h3>
<div class="flex items-center gap-1">
<button
phx-click="move_rule"
phx-value-id={rule.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_rule"
phx-value-id={rule.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 == rule_count - 1}
title={t("Move down")}
>
<.icon name="hero-arrow-down" class="h-4 w-4" />
</button>
<button
phx-click="delete_rule"
phx-value-id={rule.id}
data-confirm={t("Delete this level?")}
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
id="level-list"
class="space-y-3"
phx-hook="SortableList"
data-event="reorder_rules"
>
<%= for {rule, idx} <- Enum.with_index(sorted_rules) do %>
<div
data-id={rule.id}
draggable="true"
class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4 cursor-move"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<.icon name="hero-bars-3" class="h-4 w-4 text-gray-400" />
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{t("Level %{n}", n: idx + 1)}
</h3>
</div>
<div class="flex items-center gap-1">
<button
phx-click="move_rule"
phx-value-id={rule.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_rule"
phx-value-id={rule.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 == rule_count - 1}
title={t("Move down")}
>
<.icon name="hero-arrow-down" class="h-4 w-4" />
</button>
<button
phx-click="delete_rule"
phx-value-id={rule.id}
data-confirm={t("Delete this level?")}
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>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
{t("Notify the following users immediately and escalate after")}
<span class="font-semibold text-gray-900 dark:text-white">
{rule.timeout_minutes}
</span>
{t("minutes.")}
</p>
<%!-- Targets --%>
<div>
<%= if Enum.empty?(rule.targets) do %>
<p class="text-xs text-gray-400 dark:text-gray-500 mb-2">
{t("No targets yet")}
</p>
<% else %>
<div class="flex flex-wrap gap-2 mb-2">
<%= for target <- rule.targets do %>
<span class="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-800 pl-2.5 pr-1 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">
<%= case target.target_type do %>
<% "user" -> %>
<.icon name="hero-user" class="h-3 w-3 mr-0.5" />
<% user = Enum.find(@org_users, &(&1.id == target.user_id)) %>
{if user, do: user.email, else: t("Unknown user")}
<% "schedule" -> %>
<.icon name="hero-calendar-days" class="h-3 w-3 mr-0.5" />
<% sched = Enum.find(@org_schedules, &(&1.id == target.schedule_id)) %>
{if sched, do: sched.name, else: t("Unknown schedule")}
<% end %>
<button
phx-click="delete_target"
phx-value-id={target.id}
class="ml-0.5 rounded-full p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<.icon name="hero-x-mark" class="h-3 w-3" />
</button>
</span>
<% end %>
</div>
<% end %>
<%!-- Add target form --%>
<form phx-submit="add_target" class="flex items-center gap-2">
<input type="hidden" name="rule_id" value={rule.id} />
<select
name="target_type"
id={"target-type-#{rule.id}"}
class="block rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
>
<option value="user">{t("User")}</option>
<option value="schedule">{t("Schedule")}</option>
</select>
<select
name="target_id"
class="block w-full max-w-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
>
<option value="">{t("Select...")}</option>
<optgroup label={t("Users")}>
<%= for user <- @org_users do %>
<option value={user.id}>{user.email}</option>
<% end %>
</optgroup>
<optgroup label={t("Schedules")}>
<%= for sched <- @org_schedules do %>
<option value={sched.id}>{sched.name}</option>
<% end %>
</optgroup>
</select>
<button
type="submit"
class="inline-flex items-center rounded-md bg-gray-100 dark:bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<.icon name="hero-plus" class="h-3 w-3 mr-1" />
{t("Add")}
</button>
</form>
</div>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
{t("Notify the following users immediately and escalate after")}
<span class="font-semibold text-gray-900 dark:text-white">
{rule.timeout_minutes}
</span>
{t("minutes.")}
</p>
<%!-- Targets --%>
<div>
<%= if Enum.empty?(rule.targets) do %>
<p class="text-xs text-gray-400 dark:text-gray-500 mb-2">
{t("No targets yet")}
</p>
<% else %>
<div class="flex flex-wrap gap-2 mb-2">
<%= for target <- rule.targets do %>
<span class="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-800 pl-2.5 pr-1 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">
<%= case target.target_type do %>
<% "user" -> %>
<.icon name="hero-user" class="h-3 w-3 mr-0.5" />
<% user = Enum.find(@org_users, &(&1.id == target.user_id)) %>
{if user, do: user.email, else: t("Unknown user")}
<% "schedule" -> %>
<.icon name="hero-calendar-days" class="h-3 w-3 mr-0.5" />
<% sched = Enum.find(@org_schedules, &(&1.id == target.schedule_id)) %>
{if sched, do: sched.name, else: t("Unknown schedule")}
<% end %>
<button
phx-click="delete_target"
phx-value-id={target.id}
class="ml-0.5 rounded-full p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<.icon name="hero-x-mark" class="h-3 w-3" />
</button>
</span>
<% end %>
</div>
<% end %>
<%!-- Add target form --%>
<form phx-submit="add_target" class="flex items-center gap-2">
<input type="hidden" name="rule_id" value={rule.id} />
<select
name="target_type"
id={"target-type-#{rule.id}"}
class="block rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
>
<option value="user">{t("User")}</option>
<option value="schedule">{t("Schedule")}</option>
</select>
<select
name="target_id"
class="block w-full max-w-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
>
<option value="">{t("Select...")}</option>
<optgroup label={t("Users")}>
<%= for user <- @org_users do %>
<option value={user.id}>{user.email}</option>
<% end %>
</optgroup>
<optgroup label={t("Schedules")}>
<%= for sched <- @org_schedules do %>
<option value={sched.id}>{sched.name}</option>
<% end %>
</optgroup>
</select>
<button
type="submit"
class="inline-flex items-center rounded-md bg-gray-100 dark:bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<.icon name="hero-plus" class="h-3 w-3 mr-1" />
{t("Add")}
</button>
</form>
</div>
</div>
<% end %>
<% end %>
</div>
<% end %>
<%!-- Add Level button + form --%>

View file

@ -29,7 +29,9 @@ defmodule ToweropsWeb.ScheduleLive.Show do
|> assign(:on_call, on_call)
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:org_users, users)
|> assign(:show_add_layer, false)
# Pre-open the Add Layer form for freshly-created schedules so the
# new-schedule -> add-layers flow lands in a state ready to act on.
|> assign(:show_add_layer, schedule.layers == [])
|> assign(:show_add_override, false)
|> assign(:new_layer_members, [])
|> assign(:timeline_start, timeline_start)
@ -157,6 +159,24 @@ defmodule ToweropsWeb.ScheduleLive.Show do
end
end
def handle_event("reorder_layers", %{"ordered_ids" => ordered_ids}, socket) when is_list(ordered_ids) do
case OnCall.reorder_layers(socket.assigns.schedule.id, ordered_ids) do
:ok -> {:noreply, reload_schedule(socket)}
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to reorder layers"))}
end
end
def handle_event("set_layer_restriction", params, socket) do
%{"layer_id" => layer_id, "restriction_type" => restriction_type} = params
layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id))
if layer do
apply_layer_restriction(socket, layer, restriction_type, params)
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))
@ -338,4 +358,50 @@ defmodule ToweropsWeb.ScheduleLive.Show do
}
end)
end
# --- Layer restriction helpers ---
defp apply_layer_restriction(socket, layer, restriction_type, params) do
restrictions = build_restrictions(restriction_type, params)
result =
if is_nil(restrictions) do
OnCall.clear_layer_restrictions(layer)
else
OnCall.update_layer_restrictions(layer, %{
restriction_type: restriction_type,
restrictions: restrictions
})
end
case result do
{:ok, _} -> {:noreply, reload_schedule(socket)}
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to update restrictions"))}
end
end
defp build_restrictions("", _params), do: nil
defp build_restrictions("time_of_day", params) do
%{
"start_time" => params["start_time"] || "09:00",
"end_time" => params["end_time"] || "17:00"
}
end
defp build_restrictions("time_of_week", params) do
%{
"start_time" => params["start_time"] || "09:00",
"end_time" => params["end_time"] || "17:00",
"days" => normalize_days(params["days"])
}
end
defp build_restrictions(_, _params), do: nil
defp normalize_days(nil), do: []
defp normalize_days(list) when is_list(list), do: list
defp normalize_days(map) when is_map(map), do: map |> Map.keys() |> Enum.filter(&(map[&1] == "true"))
defp normalize_days(other) when is_binary(other), do: [other]
defp normalize_days(_), do: []
end

View file

@ -340,26 +340,40 @@
<% else %>
<% sorted_layers = Enum.sort_by(@schedule.layers, & &1.position) %>
<% layer_count = length(sorted_layers) %>
<div class="space-y-4">
<div
id="layer-list"
class="space-y-4"
phx-hook="SortableList"
data-event="reorder_layers"
>
<%= 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>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{layer.name}</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
{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")}
</p>
<div
data-id={layer.id}
draggable="true"
class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4 cursor-move"
>
<div class="flex items-start justify-between mb-3 gap-3">
<div class="flex items-start gap-2 min-w-0">
<.icon name="hero-bars-3" class="h-4 w-4 text-gray-400 mt-0.5 shrink-0" />
<div class="min-w-0">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white truncate">
{layer.name}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">
{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")}
</p>
</div>
</div>
<div class="flex items-center gap-1">
<div class="flex items-center gap-1 shrink-0">
<button
phx-click="move_layer"
phx-value-id={layer.id}
@ -435,6 +449,76 @@
</select>
<% end %>
</div>
<%!-- Restrictions editor --%>
<div class="mt-3 pt-3 border-t border-gray-100 dark:border-white/5">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
{t("Restrict on-call shifts")}
</p>
<form
phx-change="set_layer_restriction"
phx-submit="set_layer_restriction"
class="space-y-2"
>
<input type="hidden" name="layer_id" value={layer.id} />
<select
name="restriction_type"
class="block rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1"
>
<option value="" selected={is_nil(layer.restriction_type)}>
{t("Always on call (no restriction)")}
</option>
<option value="time_of_day" selected={layer.restriction_type == "time_of_day"}>
{t("Time of day")}
</option>
<option value="time_of_week" selected={layer.restriction_type == "time_of_week"}>
{t("Time of week")}
</option>
</select>
<%= if layer.restriction_type in ["time_of_day", "time_of_week"] do %>
<div class="flex items-center gap-2 text-xs text-gray-700 dark:text-gray-300">
<span>{t("From")}</span>
<input
type="time"
name="start_time"
value={layer.restrictions["start_time"] || "09:00"}
class="rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1"
/>
<span>{t("to")}</span>
<input
type="time"
name="end_time"
value={layer.restrictions["end_time"] || "17:00"}
class="rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1"
/>
</div>
<% end %>
<%= if layer.restriction_type == "time_of_week" do %>
<div class="flex flex-wrap gap-2 text-xs text-gray-700 dark:text-gray-300">
<% selected_days =
((layer.restrictions || %{})["days"] || [])
|> Enum.map(fn d ->
case d do
d when is_integer(d) -> d
d when is_binary(d) -> String.to_integer(d)
end
end) %>
<%= for {label, day} <- [{"Mon", 1}, {"Tue", 2}, {"Wed", 3}, {"Thu", 4}, {"Fri", 5}, {"Sat", 6}, {"Sun", 7}] do %>
<label class="inline-flex items-center gap-1">
<input
type="checkbox"
name={"days[#{day}]"}
value="true"
checked={day in selected_days}
class="rounded border-gray-300 dark:border-gray-600 dark:bg-gray-800"
/>
<span>{label}</span>
</label>
<% end %>
</div>
<% end %>
</form>
</div>
</div>
<% end %>
</div>

View file

@ -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

View file

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

View file

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

View file

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

View file

@ -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: %{