towerops/lib/towerops_web/live/schedule_live/index.html.heex
Graham McIntire e7bca6174c feat(on_call): schedules list with gantt timeline (chunk 2)
Resolver
- Resolver.resolve_range/3: returns the contiguous on-call segments for a
  schedule across [start_at, end_at). Walks layer handoffs + override
  boundaries, drops gap segments, and merges adjacent same-user segments.

UserColor module
- New Towerops.OnCall.UserColor with deterministic id -> color mapping
  (Tailwind class + matching #RRGGBB hex). Refactored ScheduleLive.Show
  to use it instead of its own per-page index palette so the same person
  gets the same color across the schedules list and detail pages.

ScheduleLive.Index
- Date navigation: Today / prev / next + 1/2/4-week range selector.
- URL-driven: ?start=YYYY-MM-DD&range=N for shareable views; falls back
  to defaults on malformed values.
- Per-row card: schedule name link, on-call-now avatar swatch, day-of-week
  header strip, gantt strip rendered as a CSS grid with one column per day,
  Today marker overlay.

Tests: 5 new resolver tests, 9 new UserColor tests, 5 new ScheduleLive
integration tests. Full suite: 10,210 / 0 failures. Format/dialyzer clean.

Also: cleaned up two stale inline comments in k8s/deployment.yaml that
disagreed with the values they sat next to (replicas/maxSurge).
2026-04-28 12:48:52 -05:00

242 lines
9.9 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="schedules"
>
<div class="flex items-center justify-between mb-1">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("On-Call & Schedules")}</h1>
<div class="flex items-center gap-2">
<%= if @tab == "escalation-policies" do %>
<.link
navigate={~p"/schedules/escalation-policies/new"}
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
>
<.icon name="hero-plus" class="h-4 w-4" />
{t("New Policy")}
</.link>
<% else %>
<.link
navigate={~p"/schedules/new"}
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
>
<.icon name="hero-plus" class="h-4 w-4" />
{t("New Schedule")}
</.link>
<% end %>
</div>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
{t("Define on-call rotations and escalation policies for alert notifications.")}
</p>
<div class="flex items-center gap-2 mb-6">
<.link
patch={~p"/schedules?tab=schedules"}
class={[
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
@tab == "schedules" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
@tab != "schedules" &&
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
]}
>
{t("Schedules")}
</.link>
<.link
patch={~p"/schedules?tab=escalation-policies"}
class={[
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
@tab == "escalation-policies" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
@tab != "escalation-policies" &&
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
]}
>
{t("Escalation Policies")}
</.link>
</div>
<%= if @tab == "escalation-policies" do %>
<%= if Enum.empty?(@policies) do %>
<div class="flex items-center justify-center py-16">
<div class="text-center">
<.icon
name="hero-arrow-trending-up"
class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
/>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
{t("No escalation policies")}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("Create an escalation policy to define how alerts are escalated to on-call users.")}
</p>
</div>
</div>
<% else %>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Name")}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Levels")}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
<%= for policy <- @policies do %>
<tr
class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
phx-click={JS.navigate(~p"/schedules/escalation-policies/#{policy.id}")}
>
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
{policy.name}
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{length(policy.rules)}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<% else %>
<%!-- Date navigation header --%>
<div class="flex items-center justify-between flex-wrap gap-3 mb-4">
<div class="flex items-center gap-2">
<button
phx-click="today"
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
>
{t("Today")}
</button>
<button
phx-click="prev"
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 p-1.5 text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700"
title={t("Previous range")}
>
<.icon name="hero-chevron-left" class="h-4 w-4" />
</button>
<button
phx-click="next"
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 p-1.5 text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700"
title={t("Next range")}
>
<.icon name="hero-chevron-right" class="h-4 w-4" />
</button>
<span class="ml-2 text-sm font-semibold text-gray-900 dark:text-white">
{window_label(@start_date, @range_days)}
</span>
</div>
<form phx-change="set_range" class="inline-flex">
<select
name="range"
class="rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-sm py-1.5"
>
<option value="7" selected={@range_days == 7}>{t("1 Week")}</option>
<option value="14" selected={@range_days == 14}>{t("2 Weeks")}</option>
<option value="28" selected={@range_days == 28}>{t("4 Weeks")}</option>
</select>
</form>
</div>
<%= if Enum.empty?(@schedules) do %>
<div class="flex items-center justify-center py-16">
<div class="text-center">
<.icon
name="hero-calendar-days"
class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
/>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
{t("No schedules")}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("Create an on-call schedule to define rotation coverage.")}
</p>
</div>
</div>
<% else %>
<div class="space-y-4">
<%= for schedule <- @schedules do %>
<% days = days_in_window(@start_date, @range_days) %>
<% today_idx = today_offset(@start_date, @range_days) %>
<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>
<.link
navigate={~p"/schedules/#{schedule.id}"}
class="text-base font-semibold text-blue-600 dark:text-blue-400 hover:underline"
>
{schedule.name}
</.link>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{schedule.timezone}
</p>
</div>
<div class="text-right">
<p class="text-xs text-gray-500 dark:text-gray-400">{t("On-Call Now")}</p>
<%= if schedule.current_on_call do %>
<span class="inline-flex items-center gap-1.5">
<span class={[
"h-2 w-2 rounded-full",
schedule.current_on_call_color
]}>
</span>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{schedule.current_on_call_name}
</span>
</span>
<% else %>
<span class="text-sm text-gray-400 dark:text-gray-500">{t("No one")}</span>
<% end %>
</div>
</div>
<%!-- Day header row --%>
<div
class="grid gap-px text-xs text-gray-500 dark:text-gray-400 mb-1"
style={"grid-template-columns: repeat(#{@range_days}, minmax(0, 1fr));"}
>
<%= for {day, idx} <- Enum.with_index(days) do %>
<div class={[
"px-1 py-0.5 text-center",
today_idx == idx && "font-bold text-red-600 dark:text-red-400"
]}>
<div class="leading-tight">{Calendar.strftime(day, "%a")}</div>
<div class="leading-tight">{Calendar.strftime(day, "%-d")}</div>
</div>
<% end %>
</div>
<%!-- Gantt strip --%>
<div
class="relative grid gap-px h-8 rounded-md overflow-hidden bg-gray-100 dark:bg-gray-800"
style={"grid-template-columns: repeat(#{@range_days}, minmax(0, 1fr));"}
>
<%= for segment <- schedule.segments do %>
<div
class={[
"flex items-center justify-center text-white text-xs font-medium truncate px-1",
segment.color_class
]}
style={"grid-column: #{segment.day_offset + 1} / span #{segment.day_span};"}
title={segment.label}
>
{segment.label}
</div>
<% end %>
<%= if today_idx do %>
<div
class="pointer-events-none absolute top-0 bottom-0 w-px bg-red-500"
style={"left: calc((100% / #{@range_days}) * #{today_idx});"}
>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>
<% end %>
</Layouts.authenticated>