lib/pgflow_dashboard/components/run_history_grid.ex

defmodule PgFlowDashboard.Components.RunHistoryGrid do
  @moduledoc """
  GitHub-style activity grid showing run history by step.
  """

  use Phoenix.Component

  alias PgFlowDashboard.Components.RunHistoryHelpers

  @cell_size 16
  @cell_gap 3

  @doc """
  Renders a run history grid.

  ## Attributes

    * `:grid_data` - Map of step_slug => list of run results
    * `:steps` - Ordered list of step slugs for rows
    * `:max_runs` - Maximum number of runs to display
    * `:base_path` - Base path for navigation links

  """
  attr(:grid_data, :map, required: true)
  attr(:steps, :list, required: true)
  attr(:max_runs, :integer, default: 50)
  attr(:base_path, :string, default: "/pgflow")

  def run_history_grid(assigns) do
    # Get all runs (columns)
    all_runs =
      assigns.grid_data
      |> Map.values()
      |> List.flatten()
      |> Enum.uniq_by(& &1.run_id)
      |> Enum.sort_by(& &1.started_at, {:desc, DateTime})
      |> Enum.take(assigns.max_runs)
      |> Enum.reverse()

    assigns =
      assigns
      |> assign(:runs, all_runs)
      |> assign(:cell_size, @cell_size)
      |> assign(:cell_gap, @cell_gap)

    ~H"""
    <div class="overflow-x-auto">
      <div class="inline-block min-w-full">
        <!-- Header with run indicators -->
        <div class="flex items-end mb-1" style={"padding-left: 168px"}>
          <%= for _run <- @runs do %>
            <div
              class="flex-shrink-0"
              style={"width: #{@cell_size}px; margin-right: #{@cell_gap}px"}
            >
            </div>
          <% end %>
        </div>

        <!-- Grid rows -->
        <%= for step_slug <- @steps do %>
          <div class="flex items-center mb-1">
            <!-- Step label -->
            <div class="w-40 flex-shrink-0 pr-2 text-right">
              <span class="text-sm text-slate-600 dark:text-slate-300 truncate block">
                {format_step_label(step_slug)}
              </span>
            </div>

            <!-- Cells -->
            <div class="flex">
              <%= for run <- @runs do %>
                <.grid_cell
                  run={run}
                  step_slug={step_slug}
                  cell_data={get_cell_data(@grid_data, step_slug, run.run_id)}
                  cell_size={@cell_size}
                  cell_gap={@cell_gap}
                  base_path={@base_path}
                />
              <% end %>
            </div>
          </div>
        <% end %>

        <!-- Legend -->
        <div class="mt-4 flex items-center gap-4 text-sm text-slate-500 dark:text-slate-400">
          <div class="flex items-center gap-1.5">
            <div class="w-4 h-4 rounded-sm bg-emerald-500"></div>
            <span>Completed</span>
          </div>
          <div class="flex items-center gap-1.5">
            <div class="w-4 h-4 rounded-sm bg-rose-500"></div>
            <span>Failed</span>
          </div>
          <div class="flex items-center gap-1.5">
            <div class="w-4 h-4 rounded-sm bg-sky-500"></div>
            <span>Running</span>
          </div>
          <div class="flex items-center gap-1.5">
            <div class="w-4 h-4 rounded-sm bg-slate-300 dark:bg-slate-600"></div>
            <span>Pending</span>
          </div>
        </div>
      </div>
    </div>
    """
  end

  attr(:run, :map, required: true)
  attr(:step_slug, :string, required: true)
  attr(:cell_data, :map, default: nil)
  attr(:cell_size, :integer, required: true)
  attr(:cell_gap, :integer, required: true)
  attr(:base_path, :string, required: true)

  defp grid_cell(assigns) do
    status = if assigns.cell_data, do: assigns.cell_data.status, else: nil
    run_url = "#{assigns.base_path}/runs/#{assigns.run.run_id}?step=#{assigns.step_slug}"

    assigns =
      assigns
      |> assign(:status, status)
      |> assign(:cell_color, RunHistoryHelpers.cell_color(status))
      |> assign(:run_url, run_url)

    ~H"""
    <.link
      navigate={@run_url}
      class={[
        "flex-shrink-0 rounded-sm transition-all hover:scale-125 hover:z-10 block",
        "focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-1"
      ]}
      style={"width: #{@cell_size}px; height: #{@cell_size}px; margin-right: #{@cell_gap}px; background-color: #{@cell_color}"}
      aria-label={cell_label(@step_slug, @status, @run)}
      title={cell_tooltip(@cell_data, @run)}
    ></.link>
    """
  end

  defp get_cell_data(grid_data, step_slug, run_id) do
    grid_data
    |> Map.get(step_slug, [])
    |> Enum.find(fn cell -> cell.run_id == run_id end)
  end

  defp cell_label(step_slug, status, run) do
    status_text = if status, do: to_string(status), else: "pending"
    "#{format_step_label(step_slug)}: #{status_text} (run #{String.slice(run.run_id, 0..7)})"
  end

  defp cell_tooltip(nil, run) do
    "Run #{String.slice(run.run_id, 0..7)} - Not started"
  end

  defp cell_tooltip(cell_data, run) do
    status = cell_data.status || "pending"
    duration = RunHistoryHelpers.format_duration_ms(cell_data.duration_ms)
    "Run #{String.slice(run.run_id, 0..7)}\nStatus: #{status}\nDuration: #{duration}"
  end

  defp format_step_label(slug) when is_binary(slug) do
    label =
      slug
      |> String.split("_")
      |> Enum.map_join(" ", &String.capitalize/1)

    # Allow up to 24 chars before truncating
    if String.length(label) > 24 do
      String.slice(label, 0, 21) <> "..."
    else
      label
    end
  end

  defp format_step_label(slug) when is_atom(slug), do: slug |> to_string() |> format_step_label()
  defp format_step_label(_), do: "?"
end