defmodule PhiaUi.Components.GanttChart do @moduledoc """ Week-view calendar (Google Calendar style) with hourly time slots. Renders a 7-column grid (Mon–Sun) with time rows and absolutely-positioned event blocks. Events are placed by start/end time using CSS `top`/`height` percentages computed server-side. Zero JavaScript — pure HTML + inline style positioning. ## Examples <.gantt_chart week_start={@week_start}> <:event day={~D[2026-03-09]} start_time="10:00" end_time="11:30" label="Team Meeting" color="bg-blue-500" /> <:event day={~D[2026-03-11]} start_time="14:00" end_time="15:00" label="Design Review" color="bg-purple-500" /> """ use Phoenix.Component import PhiaUi.ClassMerger, only: [cn: 1] @day_names ~w(Mon Tue Wed Thu Fri Sat Sun) # --------------------------------------------------------------------------- # Attributes # --------------------------------------------------------------------------- attr(:week_start, :any, required: true, doc: "The `Date` for Monday of the displayed week." ) attr(:start_hour, :integer, default: 8, doc: "First visible hour (0–23). Default: `8` (8am)." ) attr(:end_hour, :integer, default: 20, doc: "Last visible hour, exclusive (0–23). Default: `20` (8pm)." ) attr(:class, :string, default: nil, doc: "Additional CSS classes for the root element.") attr(:rest, :global, doc: "HTML attributes forwarded to the root `
` element.") slot(:event, doc: "An event block to render on the calendar." ) do attr(:day, :any, required: true, doc: "`Date` of the event.") attr(:start_time, :string, required: true, doc: "Start time as `\"HH:MM\"`.") attr(:end_time, :string, required: true, doc: "End time as `\"HH:MM\"`.") attr(:label, :string, doc: "Event title text.") attr(:color, :string, doc: "Tailwind bg class (e.g. `\"bg-blue-500\"`). Default: `bg-blue-500`." ) end # --------------------------------------------------------------------------- # Component # --------------------------------------------------------------------------- def gantt_chart(assigns) do total_hours = assigns.end_hour - assigns.start_hour total_minutes = total_hours * 60 assigns = assigns |> assign(:days, build_days(assigns.week_start)) |> assign(:hours, assigns.start_hour..(assigns.end_hour - 1)) |> assign(:total_hours, total_hours) |> assign(:total_minutes, total_minutes) |> assign(:grid_height_px, total_hours * 64) ~H"""
<%!-- Time labels column --%>
{format_hour(hour)}
<%!-- Day columns --%>
<%!-- Day header --%>
{day.name} {day.date.day}
<%!-- Time grid --%>
<%!-- Hour boundary lines --%>
<%!-- Current time indicator (today only) --%>
<%!-- Events for this day --%>
{Map.get(event, :label, "")} {Map.get(event, :start_time, "")} – {Map.get(event, :end_time, "")}
""" end # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- defp build_days(week_start) do today = Date.utc_today() Enum.map(0..6, fn offset -> date = Date.add(week_start, offset) %{date: date, name: Enum.at(@day_names, offset), is_today: date == today} end) end defp events_for_day(events, date) do Enum.filter(events, fn e -> Map.get(e, :day) == date end) end defp event_style(event, start_hour, total_minutes) do start_time = Map.get(event, :start_time, "#{start_hour}:00") end_time = Map.get(event, :end_time, "#{start_hour + 1}:00") top = time_offset_pct(start_time, start_hour, total_minutes) height = duration_pct(start_time, end_time, total_minutes) "top: #{top}%; height: #{height}%;" end defp time_offset_pct(time_str, start_hour, total_minutes) do minutes = time_to_minutes(time_str) - start_hour * 60 Float.round(max(minutes, 0) / total_minutes * 100, 2) end defp duration_pct(start_str, end_str, total_minutes) do duration = time_to_minutes(end_str) - time_to_minutes(start_str) Float.round(max(duration, 0) / total_minutes * 100, 2) end defp time_to_minutes(time_str) do [h, m] = String.split(time_str, ":") String.to_integer(h) * 60 + String.to_integer(m) end defp current_time_pct(start_hour, total_minutes) do now = Time.utc_now() minutes = now.hour * 60 + now.minute - start_hour * 60 (minutes / total_minutes * 100) |> max(0) |> min(100) |> Float.round(2) end defp format_hour(hour) when hour < 12, do: "#{hour}am" defp format_hour(12), do: "12pm" defp format_hour(hour), do: "#{hour - 12}pm" end