towerops/docs/plans/2026-04-28-on-call-pagerduty-style-redesign.md

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's default_escalation_policy_id) matches this policy.
  • Reordering uses up/down arrow buttons in v1; drag-and-drop is a follow-up.
  • repeat_count is 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_mode is added with values when_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"

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> rendering policy.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> rendering policy.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/3 returning [%{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.heex with:
    • 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 in CLAUDE.md).
  • 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.txt with user-facing summary; append technical notes to CHANGELOG.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_count keeps working as today.