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 --%>
<%!-- 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