28 KiB
On-Call: PagerDuty-style Redesign Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Redesign the on-call Schedules and Escalation Policies UI to match the level of clarity in PagerDuty's: visual gantt-based schedule list, multi-layer schedule editor with live preview, and a "level cards + REPEAT footer" escalation policy that surfaces repeat_count inside the policy timeline instead of as a separate column/field.
Architecture: The data model is already aligned (Schedule → Layer → LayerMember, EscalationPolicy → EscalationRule → EscalationTarget, plus Override). The work is mostly LiveView UI on top of existing context functions, plus one tiny schema addition (handoff_notifications_mode) and a context helper to list devices using a policy ("Services" sidebar). repeat_count stays in the schema and engine; it's only the UI that moves into the timeline footer.
Tech Stack: Phoenix LiveView 1.1, Tailwind v4, existing Towerops.OnCall context, Towerops.OnCall.Resolver, no new JS dependencies (use Phoenix.LiveView.JS for re-ordering with up/down buttons; defer drag-and-drop).
Decisions baked in (per user discussion 2026-04-28):
- Cover all 4 chunks in this plan; execute chunk 1 (Escalation Policy show/edit) first.
- "Services" sidebar = devices whose
escalation_policy_id(or whose org'sdefault_escalation_policy_id) matches this policy. - Reordering uses up/down arrow buttons in v1; drag-and-drop is a follow-up.
repeat_countis not removed — only the standalone form input + index columns are removed; the value is edited inline in the policy timeline footer.- New field
handoff_notifications_modeis added with valueswhen_in_use_by_service(default),always,never.
Chunk overview
| Chunk | Scope | Status |
|---|---|---|
| 1 | Escalation Policy show + edit redesign + handoff_notifications_mode + Services sidebar + remove repeat-count column |
Detailed below |
| 2 | Schedules list with gantt timeline + date navigation | Outlined |
| 3 | Schedule edit with multi-layer cards + live preview gantt | Outlined |
| 4 | Polish + e2e tests + screenshots | Outlined |
Chunk 1: Escalation Policy Redesign
Task 1.1: Add handoff_notifications_mode to escalation_policies
Files:
- Create:
priv/repo/migrations/<timestamp>_add_handoff_notifications_mode_to_escalation_policies.exs - Modify:
lib/towerops/on_call/escalation_policy.ex - Test:
test/towerops/on_call/escalation_policy_test.exs
Step 1: Write the failing test
Append to test/towerops/on_call/escalation_policy_test.exs, in the existing describe "changeset/2":
test "defaults handoff_notifications_mode to \"when_in_use_by_service\"", %{organization: org} do
{:ok, policy} =
Towerops.OnCall.create_escalation_policy(%{name: "p", organization_id: org.id})
assert policy.handoff_notifications_mode == "when_in_use_by_service"
end
test "accepts the valid handoff_notifications_mode values", %{organization: org} do
for mode <- ~w(when_in_use_by_service always never) do
{:ok, policy} =
Towerops.OnCall.create_escalation_policy(%{
name: "p-#{mode}",
organization_id: org.id,
handoff_notifications_mode: mode
})
assert policy.handoff_notifications_mode == mode
end
end
test "rejects invalid handoff_notifications_mode values", %{organization: org} do
changeset =
Towerops.OnCall.EscalationPolicy.changeset(
%Towerops.OnCall.EscalationPolicy{},
%{name: "p", organization_id: org.id, handoff_notifications_mode: "garbage"}
)
refute changeset.valid?
assert errors_on(changeset).handoff_notifications_mode
end
Step 2: Run the tests to verify they fail
Run: mix test test/towerops/on_call/escalation_policy_test.exs
Expected: 3 new tests fail with KeyError / cast errors because the field doesn't exist.
Step 3: Generate and write the migration
Run: mix ecto.gen.migration add_handoff_notifications_mode_to_escalation_policies
Edit the generated file to:
defmodule Towerops.Repo.Migrations.AddHandoffNotificationsModeToEscalationPolicies do
use Ecto.Migration
def change do
alter table(:escalation_policies) do
add :handoff_notifications_mode, :string,
null: false,
default: "when_in_use_by_service"
end
end
end
Step 4: Add the field to the schema
Modify lib/towerops/on_call/escalation_policy.ex:
@valid_handoff_modes ~w(when_in_use_by_service always never)
schema "escalation_policies" do
field :name, :string
field :description, :string
field :repeat_count, :integer, default: 3
field :handoff_notifications_mode, :string, default: "when_in_use_by_service"
# ... rest stays the same
end
@optional_fields ~w(description repeat_count handoff_notifications_mode)a
def changeset(policy, attrs) do
policy
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> validate_number(:repeat_count, greater_than: 0)
|> validate_inclusion(:handoff_notifications_mode, @valid_handoff_modes)
|> foreign_key_constraint(:organization_id)
end
Step 5: Migrate and re-run tests
Run: mix ecto.migrate
Run: mix test test/towerops/on_call/escalation_policy_test.exs
Expected: all tests pass.
Step 6: Commit
git add priv/repo/migrations/*_add_handoff_notifications_mode_to_escalation_policies.exs \
lib/towerops/on_call/escalation_policy.ex \
test/towerops/on_call/escalation_policy_test.exs
git commit -m "feat(on_call): add handoff_notifications_mode to escalation policy"
Task 1.2: Add list_devices_using_policy/2 context function
The Services sidebar needs the devices using a given escalation policy — either directly via device.escalation_policy_id or indirectly when the device falls back to the org default.
Files:
- Modify:
lib/towerops/on_call.ex - Test:
test/towerops/on_call_test.exs
Step 1: Write the failing test
Append to test/towerops/on_call_test.exs:
describe "list_devices_using_policy/2" do
test "returns devices that point at the policy directly" do
org = organization_fixture()
policy = escalation_policy_fixture(org.id)
site = site_fixture(%{organization_id: org.id})
direct =
device_fixture(%{
organization_id: org.id,
site_id: site.id,
escalation_policy_id: policy.id,
name: "router-1"
})
_other =
device_fixture(%{
organization_id: org.id,
site_id: site.id,
escalation_policy_id: nil,
name: "router-2"
})
assert [device] = Towerops.OnCall.list_devices_using_policy(policy.id, org.id)
assert device.id == direct.id
end
test "returns devices that inherit via the org default policy" do
org = organization_fixture()
policy = escalation_policy_fixture(org.id)
{:ok, _} = Towerops.Organizations.update_organization(org, %{default_escalation_policy_id: policy.id})
site = site_fixture(%{organization_id: org.id})
inherited =
device_fixture(%{
organization_id: org.id,
site_id: site.id,
escalation_policy_id: nil,
name: "router-3"
})
ids = Towerops.OnCall.list_devices_using_policy(policy.id, org.id) |> Enum.map(& &1.id)
assert inherited.id in ids
end
end
Step 2: Run to verify failure
Run: mix test test/towerops/on_call_test.exs --only describe:"list_devices_using_policy/2"
Expected: FAIL with UndefinedFunctionError for list_devices_using_policy/2.
Step 3: Implement
Add to lib/towerops/on_call.ex:
alias Towerops.Devices.Device
alias Towerops.Organizations.Organization
@doc """
Returns devices in `organization_id` whose effective escalation policy is `policy_id` —
either set directly on the device, or inherited from the organization's default policy.
"""
def list_devices_using_policy(policy_id, organization_id) do
org_default_query =
from o in Organization,
where: o.id == ^organization_id and o.default_escalation_policy_id == ^policy_id,
select: o.id
Device
|> where([d], d.organization_id == ^organization_id)
|> where(
[d],
d.escalation_policy_id == ^policy_id or
(is_nil(d.escalation_policy_id) and d.organization_id in subquery(^org_default_query))
)
|> order_by([d], asc: d.name)
|> Repo.all()
end
Step 4: Re-run tests
Run: mix test test/towerops/on_call_test.exs --only describe:"list_devices_using_policy/2"
Expected: PASS.
Step 5: Commit
git add lib/towerops/on_call.ex test/towerops/on_call_test.exs
git commit -m "feat(on_call): add list_devices_using_policy/2"
Task 1.3: Reorder support — move_escalation_rule/2
For the up/down arrows on level cards.
Files:
- Modify:
lib/towerops/on_call.ex - Test:
test/towerops/on_call_test.exs
Step 1: Write the failing test
describe "move_escalation_rule/2" do
test "swaps positions with the neighbour above when direction is :up" do
org = organization_fixture()
policy = escalation_policy_fixture(org.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: 30})
{:ok, _} = OnCall.move_escalation_rule(r1, :up)
rules = OnCall.get_escalation_policy!(policy.id).rules
by_id = Map.new(rules, &{&1.id, &1.position})
assert by_id[r0.id] == 1
assert by_id[r1.id] == 0
end
test "is a no-op when moving the top rule :up" do
org = organization_fixture()
policy = escalation_policy_fixture(org.id)
{:ok, r0} = OnCall.create_escalation_rule(%{escalation_policy_id: policy.id, position: 0, timeout_minutes: 30})
assert {:ok, ^r0} = OnCall.move_escalation_rule(r0, :up)
end
test "swaps with the neighbour below when direction is :down" do
org = organization_fixture()
policy = escalation_policy_fixture(org.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: 30})
{:ok, _} = OnCall.move_escalation_rule(r0, :down)
by_id =
OnCall.get_escalation_policy!(policy.id).rules
|> Map.new(&{&1.id, &1.position})
assert by_id[r0.id] == 1
assert by_id[r1.id] == 0
end
end
Step 2: Run failing tests
Run: mix test test/towerops/on_call_test.exs --only describe:"move_escalation_rule/2"
Expected: FAIL UndefinedFunctionError.
Step 3: Implement
Add to lib/towerops/on_call.ex:
@doc """
Swaps a rule with its neighbour in the given direction (`:up` or `:down`).
Returns `{:ok, rule}` (unchanged when at the boundary), or `{:error, changeset}`.
"""
def move_escalation_rule(%EscalationRule{} = rule, direction) when direction in [:up, :down] do
policy_rules =
EscalationRule
|> where([r], r.escalation_policy_id == ^rule.escalation_policy_id)
|> order_by([r], asc: r.position)
|> Repo.all()
with neighbour when not is_nil(neighbour) <- find_neighbour(policy_rules, rule, direction),
{:ok, _} <- swap_positions(rule, neighbour) do
{:ok, Repo.get!(EscalationRule, rule.id)}
else
nil -> {:ok, rule}
{:error, _} = err -> err
end
end
defp find_neighbour(rules, rule, :up) do
Enum.find(rules, &(&1.position == rule.position - 1))
end
defp find_neighbour(rules, rule, :down) do
Enum.find(rules, &(&1.position == rule.position + 1))
end
defp swap_positions(a, b) do
Repo.transaction(fn ->
# Move `a` to a sentinel value first so the unique-position
# constraint (if any in future) doesn't collide mid-swap.
{:ok, _} =
a
|> EscalationRule.changeset(%{position: -1})
|> Repo.update()
{:ok, _} =
b
|> EscalationRule.changeset(%{position: a.position})
|> Repo.update()
{:ok, _} =
a
|> Map.put(:position, -1)
|> EscalationRule.changeset(%{position: b.position})
|> Repo.update()
:ok
end)
end
Step 4: Re-run tests
Expected: PASS.
Step 5: Commit
git add lib/towerops/on_call.ex test/towerops/on_call_test.exs
git commit -m "feat(on_call): add move_escalation_rule/2 for level reordering"
Task 1.4: Redesign the Show page (level cards + REPEAT footer + Services sidebar)
Files:
- Modify:
lib/towerops_web/live/escalation_policy_live/show.ex - Modify:
lib/towerops_web/live/escalation_policy_live/show.html.heex - Test:
test/towerops_web/live/escalation_policy_live_test.exs
Step 1: Write the failing test
Add to test/towerops_web/live/escalation_policy_live_test.exs, in the describe "show" block (or create one):
test "show page renders levels as numbered cards", %{conn: conn, organization: org, user: user} do
policy = escalation_policy_fixture(org.id, %{name: "Net", repeat_count: 2})
{:ok, r0} = Towerops.OnCall.create_escalation_rule(%{escalation_policy_id: policy.id, position: 0, timeout_minutes: 15})
{:ok, _} = Towerops.OnCall.create_escalation_target(%{escalation_rule_id: r0.id, target_type: "user", user_id: user.id})
{:ok, _view, html} =
conn |> log_in_user(user) |> live(~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "Level 1"
assert html =~ "Notify the following user immediately and escalate after"
assert html =~ "15"
assert html =~ "minutes"
end
test "show page surfaces repeat_count in the REPEAT footer", %{conn: conn, organization: org, user: user} do
policy = escalation_policy_fixture(org.id, %{name: "Net", repeat_count: 4})
{:ok, _view, html} =
conn |> log_in_user(user) |> live(~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "If no one acknowledges, repeat this policy"
assert html =~ "4"
end
test "show page lists devices using the policy in the Services sidebar", %{conn: conn, organization: org, user: user} do
policy = escalation_policy_fixture(org.id)
site = site_fixture(%{organization_id: org.id})
device =
device_fixture(%{
organization_id: org.id,
site_id: site.id,
escalation_policy_id: policy.id,
name: "core-router-7"
})
{:ok, _view, html} =
conn |> log_in_user(user) |> live(~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "Services"
assert html =~ device.name
end
Step 2: Run failing tests
Run: mix test test/towerops_web/live/escalation_policy_live_test.exs
Expected: 3 new tests fail (text not present).
Step 3: Update the LiveView mount to load services
In lib/towerops_web/live/escalation_policy_live/show.ex, add to mount/3:
services = OnCall.list_devices_using_policy(policy.id, org_id)
socket =
socket
|> assign(:page_title, policy.name)
|> assign(:active_page, "schedules")
|> assign(:policy, policy)
|> assign(:org_users, users)
|> assign(:org_schedules, schedules)
|> assign(:services, services)
|> assign(:show_add_rule, false)
In reload_policy/1 also re-load services.
Add a move_rule event handler:
def handle_event("move_rule", %{"id" => id, "direction" => direction}, socket) do
rule = Enum.find(socket.assigns.policy.rules, &(&1.id == id))
if rule do
direction_atom = String.to_existing_atom(direction)
{:ok, _} = OnCall.move_escalation_rule(rule, direction_atom)
{:noreply, reload_policy(socket)}
else
{:noreply, socket}
end
end
Step 4: Rewrite show.html.heex
Replace lib/towerops_web/live/escalation_policy_live/show.html.heex with a 2-column layout:
<Layouts.authenticated flash={@flash} current_scope={@current_scope} active_page="schedules">
<%!-- Header (existing back link, name, action buttons) stays --%>
<%!-- ... header markup ... --%>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
{t("Send On-Call Handoff Notifications:")}
<span class="font-medium text-gray-900 dark:text-white">
{handoff_mode_label(@policy.handoff_notifications_mode)}
</span>
</p>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<%!-- LEFT: Levels timeline --%>
<div class="lg:col-span-2 space-y-3">
<%= for {rule, idx} <- Enum.with_index(Enum.sort_by(@policy.rules, & &1.position)) 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={idx == 0}>
<.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={idx == length(@policy.rules) - 1}>
<.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">
<.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 user immediately and escalate after")}
<span class="font-semibold text-gray-900 dark:text-white">{rule.timeout_minutes}</span>
{t("minutes.")}
</p>
<%!-- target chips (reuse existing add_target form below) --%>
<%!-- ... existing targets render block ... --%>
</div>
<% end %>
<button phx-click="toggle_add_rule"
class="w-full rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-3 text-sm text-gray-500 dark:text-gray-400 hover:border-gray-400 hover:text-gray-700">
<.icon name="hero-plus" class="h-4 w-4 inline mr-1" /> {t("Add Level")}
</button>
<%!-- REPEAT footer --%>
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-gray-800/40 p-4 flex items-center gap-3">
<.icon name="hero-arrow-path" class="h-5 w-5 text-gray-500" />
<p class="text-sm text-gray-700 dark:text-gray-300">
{t("If no one acknowledges, repeat this policy")}
<span class="font-semibold">{@policy.repeat_count}</span>
{t("time(s).")}
</p>
</div>
</div>
<%!-- RIGHT: Services sidebar --%>
<aside class="lg:col-span-1">
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">
{t("Services (%{count})", count: length(@services))}
</h3>
<%= if @services == [] do %>
<p class="text-sm text-gray-500 dark:text-gray-400">
{t("No devices use this policy yet.")}
</p>
<% else %>
<ul class="space-y-2">
<%= for device <- @services do %>
<li>
<.link navigate={~p"/devices/#{device.id}"}
class="text-sm text-blue-600 dark:text-blue-400 hover:underline">
{device.name}
</.link>
</li>
<% end %>
</ul>
<% end %>
</div>
</aside>
</div>
</Layouts.authenticated>
Add a private helper to show.ex:
defp handoff_mode_label("when_in_use_by_service"), do: t("when in use by a service")
defp handoff_mode_label("always"), do: t("always")
defp handoff_mode_label("never"), do: t("never")
defp handoff_mode_label(_), do: t("when in use by a service")
Step 5: Re-run the new tests + the existing show tests
Run: mix test test/towerops_web/live/escalation_policy_live_test.exs
Expected: PASS. Fix any breakage in older tests that asserted the previous markup.
Step 6: Manual smoke
Run: mix phx.server, navigate to /schedules/escalation-policies/<id>, verify:
- Levels render as cards numbered 1, 2, 3.
- Up/down arrows work, with the top arrow disabled on level 1 and the bottom arrow disabled on the last level.
- REPEAT footer shows the current repeat_count.
- Services sidebar shows a device when one is wired to the policy.
Step 7: Commit
git add lib/towerops_web/live/escalation_policy_live/show.ex \
lib/towerops_web/live/escalation_policy_live/show.html.heex \
test/towerops_web/live/escalation_policy_live_test.exs
git commit -m "feat(on_call): redesign escalation policy show page"
Task 1.5: Redesign the Edit form (drop standalone repeat_count field; add levels editor)
Files:
- Modify:
lib/towerops_web/live/escalation_policy_live/form.ex - Modify:
lib/towerops_web/live/escalation_policy_live/form.html.heex - Test:
test/towerops_web/live/escalation_policy_live_test.exs
Step 1: Write the failing test
test "edit form no longer renders a standalone Repeat Count input", %{conn: conn, organization: org, user: user} do
policy = escalation_policy_fixture(org.id)
{:ok, _view, html} =
conn |> log_in_user(user) |> live(~p"/schedules/escalation-policies/#{policy.id}/edit")
refute html =~ ~r/<label[^>]*>\s*Repeat Count\s*</
refute html =~ ~r/name="escalation_policy\[repeat_count\]"/
end
test "edit form renders the handoff notifications mode select", %{conn: conn, organization: org, user: user} do
policy = escalation_policy_fixture(org.id)
{:ok, _view, html} =
conn |> log_in_user(user) |> live(~p"/schedules/escalation-policies/#{policy.id}/edit")
assert html =~ "Send On-Call Handoff Notifications"
assert html =~ "When in use by a service"
end
test "edit form has the inline repeat checkbox + count input", %{conn: conn, organization: org, user: user} do
policy = escalation_policy_fixture(org.id, %{repeat_count: 2})
{:ok, view, _html} =
conn |> log_in_user(user) |> live(~p"/schedules/escalation-policies/#{policy.id}/edit")
assert view |> element("input[name='escalation_policy[repeat_count]']") |> render() =~ "value=\"2\""
view
|> form("form",
escalation_policy: %{
name: policy.name,
repeat_count: 5,
handoff_notifications_mode: "always"
}
)
|> render_submit()
updated = Towerops.OnCall.get_escalation_policy!(policy.id)
assert updated.repeat_count == 5
assert updated.handoff_notifications_mode == "always"
end
Step 2: Run failing tests
Run: mix test test/towerops_web/live/escalation_policy_live_test.exs
Expected: failures because the standalone "Repeat Count" label still exists and "Send On-Call Handoff Notifications" doesn't.
Step 3: Update the form template
In lib/towerops_web/live/escalation_policy_live/form.html.heex, replace the existing repeat_count input block with:
<div>
<.input
field={@form[:handoff_notifications_mode]}
type="select"
label={t("Send On-Call Handoff Notifications")}
options={[
{t("When in use by a service"), "when_in_use_by_service"},
{t("Always"), "always"},
{t("Never"), "never"}
]}
/>
</div>
<div class="rounded-lg border border-gray-200 dark:border-white/10 p-4 flex items-center gap-3">
<.icon name="hero-arrow-path" class="h-5 w-5 text-gray-500" />
<span class="text-sm text-gray-700 dark:text-gray-300">
{t("If no one acknowledges, repeat this policy")}
</span>
<.input
field={@form[:repeat_count]}
type="number"
label=""
min="1"
max="10"
class="w-20"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{t("time(s).")}</span>
</div>
Step 4: Re-run tests
Run: mix test test/towerops_web/live/escalation_policy_live_test.exs
Expected: PASS.
Step 5: Commit
git add lib/towerops_web/live/escalation_policy_live/form.* \
test/towerops_web/live/escalation_policy_live_test.exs
git commit -m "feat(on_call): inline repeat in policy form, add handoff mode select"
Task 1.6: Drop the "Repeat Count" column from the index views
Files:
- Modify:
lib/towerops_web/live/escalation_policy_live/index.html.heex - Modify:
lib/towerops_web/live/schedule_live/index.html.heex - Test:
test/towerops_web/live/escalation_policy_live_test.exs
Step 1: Write the failing test
test "policy index does not show a Repeat Count column", %{conn: conn, organization: org, user: user} do
_ = escalation_policy_fixture(org.id, %{name: "p", repeat_count: 5})
{:ok, _view, html} =
conn |> log_in_user(user) |> live(~p"/schedules/escalation-policies")
refute html =~ "Repeat Count"
end
Step 2: Run failing test
Expected: FAIL because the column still exists.
Step 3: Edit the templates
In lib/towerops_web/live/escalation_policy_live/index.html.heex remove:
- the
<th>for "Repeat Count" (line ~48-50) - the
<td>renderingpolicy.repeat_count(line ~62-64)
In lib/towerops_web/live/schedule_live/index.html.heex remove:
- the
<th>for "Repeat Count" (line ~84-86) - the
<td>renderingpolicy.repeat_count(line ~101-103)
Replace either with a useful column instead — t("Levels") showing length(policy.rules) (preload rules in index.ex if not already).
Step 4: Re-run tests
Expected: PASS.
Step 5: Commit
git add lib/towerops_web/live/escalation_policy_live/index.html.heex \
lib/towerops_web/live/schedule_live/index.html.heex \
test/towerops_web/live/escalation_policy_live_test.exs
git commit -m "feat(on_call): drop Repeat Count column from policy index views"
Task 1.7: Run the full suite and fix fallout
Run: mix precommit
Expected: PASS.
If any other tests reference the old "Repeat Count" label, update them. Do not delete data assertions on repeat_count — the field still exists.
Commit any test fixups separately:
git commit -am "test: update assertions for new escalation policy markup"
Chunk 2: Schedules list with gantt timeline (outline)
Detailed when chunk 1 is merged. Sketch:
- New
Towerops.OnCall.Resolver.resolve_range/3returning[%{user_id, start_at, end_at}]segments for a date window. Test pure with multiple layers + overrides. - Replace the schedules tab in
schedule_live/index.html.heexwith:- Date nav header (
Today,<>, label, range selector dropdown) - Per-row layout: name, on-call-now avatar+name, an SVG gantt strip (or CSS grid with day cells), Export menu,
…menu. - Search input + pagination (use the existing
<.pagination>component pattern inCLAUDE.md).
- Date nav header (
- Render the gantt server-side as a CSS grid: each segment is
grid-column: <day_offset> / span <days>with the user's color. - Color for users derived deterministically from
user.id(hash → palette).
Chunk 3: Schedule edit with multi-layer cards + live preview (outline)
- Per-layer card matching image 2: Step 1 users picker, Step 2 rotation type/handoff, Step 3 start.
- Below the form, render two gantt strips: per-layer (Configuration Layers) and the resolver output (Final Schedule).
- Restrictions editor (start_time/end_time within day; weekday range).
- Add reorder + delete for layers.
Chunk 4: Polish + e2e (outline)
- Playwright e2e: create schedule with 2 layers, verify gantt; create policy with 2 levels, reorder, change repeat, verify show page.
- Empty-state polish on every page.
- Update
priv/static/changelog.txtwith user-facing summary; append technical notes toCHANGELOG.txt. - Screenshot review against the four reference images.
Out-of-scope
- Drag-and-drop level/layer reordering (use up/down arrows in v1).
- Alembic-style "compute next on-call" timeline previews longer than 12 weeks.
- iCal export improvements beyond what already exists.
- Any change to the escalation engine (
Towerops.OnCall.Escalation.advance_escalation/2) —repeat_countkeeps working as today.