lib/pgflow_dashboard/components/gantt_timeline.ex

defmodule PgFlowDashboard.Components.GanttTimeline do
  @moduledoc """
  Gantt timeline component showing step execution timing within a run.
  Renders as SVG for crisp visuals and easy theming.
  """

  use Phoenix.Component

  @doc """
  Renders a Gantt timeline for a run's steps.

  ## Assigns
    * `:run` - The run map with started_at, completed_at, status
    * `:step_states` - List of step state maps with step_slug, started_at, completed_at, status
  """
  attr(:run, :map, required: true)
  attr(:step_states, :list, required: true)

  def gantt_timeline(assigns) do
    # Calculate timeline bounds
    run_start = assigns.run.started_at
    run_end = assigns.run.completed_at || DateTime.utc_now()

    total_duration_ms = DateTime.diff(run_end, run_start, :millisecond)
    # Ensure minimum duration to avoid division by zero
    total_duration_ms = max(total_duration_ms, 1000)

    # Sort steps by start time (nil starts go last)
    sorted_steps =
      Enum.sort_by(assigns.step_states, fn step ->
        case step.started_at do
          nil -> {1, step.step_slug}
          dt -> {0, DateTime.to_unix(dt)}
        end
      end)

    # Dimensions
    row_height = 32
    label_width = 180
    chart_width = 460
    padding = 8
    header_height = 24
    total_height = header_height + length(sorted_steps) * row_height + padding

    assigns =
      assigns
      |> assign(:sorted_steps, sorted_steps)
      |> assign(:run_start, run_start)
      |> assign(:run_end, run_end)
      |> assign(:total_duration_ms, total_duration_ms)
      |> assign(:row_height, row_height)
      |> assign(:label_width, label_width)
      |> assign(:chart_width, chart_width)
      |> assign(:padding, padding)
      |> assign(:header_height, header_height)
      |> assign(:total_height, total_height)

    ~H"""
    <div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
      <div class="px-4 py-3 border-b border-slate-200 dark:border-slate-700">
        <h3 class="text-sm font-semibold text-slate-900 dark:text-white">Timeline</h3>
        <p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
          Step execution timing · Total: {format_duration(@total_duration_ms)}
        </p>
      </div>

      <div class="p-4 overflow-x-auto">
        <svg
          width={@label_width + @chart_width + @padding * 2}
          height={@total_height}
          class="text-slate-600 dark:text-slate-400"
        >
          <!-- Time axis header -->
          <g transform={"translate(#{@label_width + @padding}, 0)"}>
            <!-- Start time -->
            <text x="0" y="16" class="fill-current text-xs" text-anchor="start">
              0s
            </text>
            <!-- End time -->
            <text x={@chart_width} y="16" class="fill-current text-xs" text-anchor="end">
              {format_duration(@total_duration_ms)}
            </text>
            <!-- Middle marker -->
            <text x={@chart_width / 2} y="16" class="fill-current text-xs" text-anchor="middle">
              {format_duration(div(@total_duration_ms, 2))}
            </text>
          </g>

          <!-- Step rows -->
          <%= for {step, idx} <- Enum.with_index(@sorted_steps) do %>
            <g transform={"translate(0, #{@header_height + idx * @row_height})"}>
              <!-- Step label -->
              <text
                x={@label_width - 8}
                y={@row_height / 2 + 4}
                class="fill-current text-xs"
                text-anchor="end"
              >
                {truncate_label(step.step_slug, 24)}
              </text>

              <!-- Row background (alternating) -->
              <rect
                x={@label_width + @padding}
                y="2"
                width={@chart_width}
                height={@row_height - 4}
                rx="2"
                class={if rem(idx, 2) == 0, do: "fill-slate-50 dark:fill-slate-700/30", else: "fill-transparent"}
              />

              <!-- Step bar -->
              <%= if step.started_at do %>
                <%
                  bar_start = calc_position(step.started_at, @run_start, @total_duration_ms, @chart_width)
                  bar_end = calc_position(step.completed_at || DateTime.utc_now(), @run_start, @total_duration_ms, @chart_width)
                  bar_width = max(bar_end - bar_start, 4)
                %>
                <rect
                  x={@label_width + @padding + bar_start}
                  y="6"
                  width={bar_width}
                  height={@row_height - 12}
                  rx="3"
                  class={bar_color(step.status)}
                />

                <!-- Duration label - centered in bar if wide, or to the right if narrow -->
                <%= if bar_width > 45 do %>
                  <text
                    x={@label_width + @padding + bar_start + bar_width / 2}
                    y={@row_height / 2 + 4}
                    class="fill-white text-xs font-medium"
                    text-anchor="middle"
                  >
                    {format_duration(step.duration_ms || 0)}
                  </text>
                <% else %>
                  <text
                    x={@label_width + @padding + bar_start + bar_width + 4}
                    y={@row_height / 2 + 4}
                    class="fill-current text-xs"
                    text-anchor="start"
                  >
                    {format_duration(step.duration_ms || 0)}
                  </text>
                <% end %>
              <% else %>
                <!-- Pending indicator -->
                <rect
                  x={@label_width + @padding}
                  y={@row_height / 2 - 2}
                  width={@chart_width}
                  height="4"
                  rx="2"
                  class="fill-slate-200 dark:fill-slate-600"
                  stroke-dasharray="4,4"
                />
              <% end %>
            </g>
          <% end %>

          <!-- Vertical grid lines -->
          <g transform={"translate(#{@label_width + @padding}, #{@header_height})"} class="stroke-slate-200 dark:stroke-slate-700">
            <line x1="0" y1="0" x2="0" y2={length(@sorted_steps) * @row_height} stroke-width="1" />
            <line x1={@chart_width / 4} y1="0" x2={@chart_width / 4} y2={length(@sorted_steps) * @row_height} stroke-width="1" stroke-dasharray="2,2" />
            <line x1={@chart_width / 2} y1="0" x2={@chart_width / 2} y2={length(@sorted_steps) * @row_height} stroke-width="1" stroke-dasharray="2,2" />
            <line x1={@chart_width * 3 / 4} y1="0" x2={@chart_width * 3 / 4} y2={length(@sorted_steps) * @row_height} stroke-width="1" stroke-dasharray="2,2" />
            <line x1={@chart_width} y1="0" x2={@chart_width} y2={length(@sorted_steps) * @row_height} stroke-width="1" />
          </g>

          <!-- "Now" indicator for running runs -->
          <%= if @run.status == "started" do %>
            <% now_pos = calc_position(DateTime.utc_now(), @run_start, @total_duration_ms, @chart_width) %>
            <g transform={"translate(#{@label_width + @padding + now_pos}, #{@header_height})"}>
              <line
                x1="0" y1="0"
                x2="0" y2={length(@sorted_steps) * @row_height}
                class="stroke-purple-500"
                stroke-width="2"
              />
              <polygon
                points="-4,0 4,0 0,6"
                class="fill-purple-500"
              />
            </g>
          <% end %>
        </svg>
      </div>

      <!-- Legend -->
      <div class="px-4 py-2 border-t border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 flex items-center gap-4 text-xs">
        <div class="flex items-center gap-1.5">
          <span class="w-3 h-3 rounded bg-emerald-500"></span>
          <span class="text-slate-600 dark:text-slate-400">Completed</span>
        </div>
        <div class="flex items-center gap-1.5">
          <span class="w-3 h-3 rounded bg-blue-500"></span>
          <span class="text-slate-600 dark:text-slate-400">Running</span>
        </div>
        <div class="flex items-center gap-1.5">
          <span class="w-3 h-3 rounded bg-red-500"></span>
          <span class="text-slate-600 dark:text-slate-400">Failed</span>
        </div>
        <div class="flex items-center gap-1.5">
          <span class="w-3 h-3 rounded bg-slate-300 dark:bg-slate-600"></span>
          <span class="text-slate-600 dark:text-slate-400">Pending</span>
        </div>
      </div>
    </div>
    """
  end

  defp calc_position(datetime, run_start, total_duration_ms, chart_width) do
    offset_ms = DateTime.diff(datetime, run_start, :millisecond)
    ratio = offset_ms / total_duration_ms
    Float.round(ratio * chart_width, 1)
  end

  defp bar_color("completed"), do: "fill-emerald-500"
  defp bar_color("started"), do: "fill-blue-500"
  defp bar_color("failed"), do: "fill-red-500"
  defp bar_color(_), do: "fill-slate-300 dark:fill-slate-600"

  defp format_duration(ms) when is_number(ms) do
    format_duration_value(ms)
  end

  defp format_duration(%Decimal{} = ms) do
    format_duration_value(Decimal.to_float(ms))
  end

  defp format_duration(ms) when is_binary(ms) do
    case Float.parse(ms) do
      {num, _} -> format_duration_value(num)
      :error -> "-"
    end
  end

  defp format_duration(_), do: "-"

  defp format_duration_value(ms) do
    cond do
      ms < 1000 -> "#{round(ms)}ms"
      ms < 60_000 -> "#{Float.round(ms / 1000, 1)}s"
      ms < 3_600_000 -> "#{Float.round(ms / 60_000, 1)}m"
      true -> "#{Float.round(ms / 3_600_000, 1)}h"
    end
  end

  defp truncate_label(label, max_len) do
    if String.length(label) > max_len do
      String.slice(label, 0, max_len - 1) <> "…"
    else
      label
    end
  end
end