empowered.house ↗

calendar/month-view.ex

Month view

A full month grid with events placed on their days as small bars, one of them marked.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.calendar_month_view animated />
</div>
default
isometric
another month · one event late
six weeks · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
year integer 2026
month integer 10 1 to 12. The default is a fixed month, not today.
events list [%{name: "Payroll", to: 2, from: 1}, %{name: "Stock count", ...}, ...] `%{from, to, name}` with days of the month, and an optional `tone: "risk"` for coral. Two events fit on one day. Names are cut to the width of their bar: a one-day bar holds 8 characters.
highlight integer 6 Index of the event drawn in lime. A negative index marks none.
today any 14 The day marked as today, or `nil`.
Frame props, shared by every composition
Prop Type Default What it does
animated boolean false Turns motion on. Without it the composition renders in its final, still state.
trigger "mount" | "in-view" | "in-view-repeat" "in-view" When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view.
hover boolean false Enables the hover micro-interactions the composition supports.
glow boolean true The warm incandescent halo behind the composition.
fade_out boolean false Fades the bottom edge into the surface underneath.
isometric boolean false Tilts the composition into the isometric plane of the platform stack.
theme "dark" | "light" "dark" `dark` for the site canvas, `light` for the cream reversed surface.
class string nil Extra classes for the frame.
rest global — Any other HTML attribute, set on the frame.

Source

Two files. Copy them into your app if you would rather own the code than depend on the library.

lib/eh_visuals/calendar/month-view.ex
month-view.ex
defmodule EHVisuals.Calendar.MonthView do
  @moduledoc """
  A full month as a grid, with events placed on their days as small bars.

  The grid is computed from the year and the month, weeks start on Monday.
  An event that runs over several days is one bar per week it touches, and
  events that share a day are stacked in two lanes.
  """

  use EHVisuals.Composition,
    name: "calendar/month-view",
    title: "Month view",
    description:
      "A full month grid with events placed on their days as small bars, one of them marked."

  @months ~w(January February March April May June July August September October November December)
  @weekdays ~w(Mon Tue Wed Thu Fri Sat Sun)

  @events [
    %{from: 1, to: 2, name: "Payroll"},
    %{from: 5, to: 7, name: "Stock count"},
    %{from: 6, to: 6, name: "Recount"},
    %{from: 13, to: 13, name: "Audit"},
    %{from: 15, to: 16, name: "VAT return"},
    %{from: 22, to: 26, name: "Carrier tender"},
    %{from: 28, to: 30, name: "Month-end close"}
  ]

  @cell_w 54
  @cell_h 48
  @grid_x 20
  @grid_y 78
  @lanes 2

  attr(:year, :integer, default: 2026)
  attr(:month, :integer, default: 10, doc: "1 to 12. The default is a fixed month, not today.")

  attr(:events, :list,
    default: @events,
    doc:
      "`%{from, to, name}` with days of the month, and an optional `tone: \"risk\"` for coral. Two events fit on one day. Names are cut to the width of their bar: a one-day bar holds 8 characters."
  )

  attr(:highlight, :integer,
    default: 6,
    doc: "Index of the event drawn in lime. A negative index marks none."
  )

  attr(:today, :any, default: 14, doc: "The day marked as today, or `nil`.")

  attr(:label, :string,
    default: "October 2026 as a month grid with seven events, the month-end close marked"
  )

  frame_attrs()

  @spec calendar_month_view(map()) :: Phoenix.LiveView.Rendered.t()
  def calendar_month_view(assigns) do
    month = assigns.month |> max(1) |> min(12)
    first = Date.new!(assigns.year, month, 1)
    days = Date.days_in_month(first)
    offset = Date.day_of_week(first) - 1
    rows = div(offset + days + 6, 7)
    previous_days = first |> Date.add(-1) |> Map.fetch!(:day)

    cells =
      for index <- 0..(rows * 7 - 1) do
        day = index - offset + 1

        {number, inside} =
          cond do
            day < 1 -> {previous_days + day, false}
            day > days -> {day - days, false}
            true -> {day, true}
          end

        %{
          number: number,
          inside: inside,
          today: inside and number == assigns.today,
          row: div(index, 7),
          x: @grid_x + rem(index, 7) * @cell_w,
          y: @grid_y + div(index, 7) * @cell_h
        }
      end

    placed = place(assigns.events, days)

    assigns =
      assigns
      |> assign(:heading, "#{Enum.at(@months, month - 1)} #{assigns.year}")
      |> assign(:weekdays, Enum.with_index(@weekdays))
      |> assign(:cells, cells)
      |> assign(:rows, rows)
      |> assign(:bars, Enum.flat_map(placed, &bars(&1, offset, rows)))
      |> assign(:count, count(length(placed)))
      |> assign(:grid, %{
        x: @grid_x,
        y: @grid_y,
        w: 7 * @cell_w,
        h: rows * @cell_h,
        cell_w: @cell_w,
        cell_h: @cell_h
      })
      |> assign(:height, @grid_y + rows * @cell_h + 20)

    ~H"""
    <.frame name="calendar/month-view" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 418 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="417" height={@height - 1} rx="12" />
          <g class="ehv-a-fade ehv-i-1">
            <text class="ehv-t-strong" x="20" y="38" font-size="15">{@heading}</text>
            <text class="ehv-mono" x="398" y="37" font-size="8.5" text-anchor="end">
              {String.upcase(@count)}
            </text>
            <text
              :for={{name, index} <- @weekdays}
              class={["ehv-mono", index > 4 && "ehv-t-4"]}
              x={@grid.x + index * @grid.cell_w + 7}
              y="68"
              font-size="7.5"
            >
              {String.upcase(name)}
            </text>
          </g>
          <g class="ehv-a-fade ehv-i-2">
            <rect
              class="ehv-well"
              x={@grid.x + 0.5}
              y={@grid.y + 0.5}
              width={@grid.w - 1}
              height={@grid.h - 1}
              rx="8"
            />
            <line
              :for={col <- 1..6}
              class="ehv-line-faint"
              x1={@grid.x + col * @grid.cell_w + 0.5}
              x2={@grid.x + col * @grid.cell_w + 0.5}
              y1={@grid.y + 1}
              y2={@grid.y + @grid.h - 1}
            />
            <line
              :for={row <- 1..(@rows - 1)//1}
              class="ehv-line-faint"
              x1={@grid.x + 1}
              x2={@grid.x + @grid.w - 1}
              y1={@grid.y + row * @grid.cell_h + 0.5}
              y2={@grid.y + row * @grid.cell_h + 0.5}
            />
          </g>
          <g :for={cell <- @cells} class={"ehv-a-fade ehv-i-#{min(cell.row + 3, 24)}"}>
            <rect
              :if={cell.today}
              class="ehv-fill-ink"
              x={cell.x + 4}
              y={cell.y + 5}
              width="18"
              height="13"
              rx="4"
            />
            <text
              class={[
                "ehv-mono",
                cell.today && "ehv-calendar-month-view__today",
                not cell.inside && "ehv-t-4",
                (cell.inside and not cell.today) && "ehv-t-2"
              ]}
              x={cell.x + 13}
              y={cell.y + 14.7}
              font-size="8"
              text-anchor="middle"
            >
              {cell.number}
            </text>
          </g>
          <g :for={bar <- @bars} class={"ehv-a-fade ehv-i-#{min(bar.order + 5, 24)}"}>
            <rect
              class={[
                "ehv-calendar-month-view__bar ehv-a-grow-x",
                "ehv-i-#{min(bar.order + 5, 24)}",
                "ehv-calendar-month-view__bar--#{tone(bar, @highlight)}"
              ]}
              x={bar.x}
              y={bar.y}
              width={bar.width}
              height="12"
              rx="3.5"
            />
            <text
              class={["ehv-mono", "ehv-calendar-month-view__name--#{tone(bar, @highlight)}"]}
              x={bar.x + 5}
              y={bar.y + 9}
              font-size="7"
            >
              {String.upcase(bar.name)}
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # Gives every event the first lane that is free on all of its days. An
  # event that finds no lane is left out.
  defp place(events, days) do
    events
    |> Enum.with_index()
    |> Enum.map(fn {event, index} ->
      {from, to} = Enum.min_max([Map.get(event, :from, 1), Map.get(event, :to, 1)])

      %{
        index: index,
        from: max(from, 1),
        to: min(to, days),
        name: Map.get(event, :name, ""),
        tone: Map.get(event, :tone, "ink")
      }
    end)
    |> Enum.filter(&(&1.from <= &1.to))
    |> Enum.sort_by(&{&1.from, -&1.to})
    |> Enum.reduce({[], MapSet.new()}, fn event, {placed, taken} ->
      lane =
        Enum.find(0..(@lanes - 1), fn lane ->
          Enum.all?(event.from..event.to, &(not MapSet.member?(taken, {&1, lane})))
        end)

      if lane do
        taken = Enum.reduce(event.from..event.to, taken, &MapSet.put(&2, {&1, lane}))
        {[Map.put(event, :lane, lane) | placed], taken}
      else
        {placed, taken}
      end
    end)
    |> elem(0)
    |> Enum.reverse()
    |> Enum.with_index()
    |> Enum.map(fn {event, order} -> Map.put(event, :order, order) end)
  end

  # One bar per week the event touches. The name is cut to what the bar holds.
  defp bars(event, offset, rows) do
    for row <- 0..(rows - 1),
        from = max(event.from, row * 7 - offset + 1),
        to = min(event.to, row * 7 - offset + 7),
        from <= to do
      col = from + offset - 1 - row * 7
      width = (to - from + 1) * @cell_w - 7

      %{
        index: event.index,
        order: event.order,
        tone: event.tone,
        name: name(event, from, width),
        x: @grid_x + col * @cell_w + 3.5,
        y: @grid_y + row * @cell_h + 21 + event.lane * 13.5,
        width: width
      }
    end
  end

  # The first bar of an event carries the name, cut to what fits. A bar that
  # continues in the next week repeats it only when it fits whole.
  defp name(event, from, width) do
    fits = max(trunc((width - 9) / 4.6), 0)

    cond do
      from == event.from -> String.slice(event.name, 0, fits)
      String.length(event.name) <= fits -> event.name
      true -> ""
    end
  end

  defp tone(%{index: index}, highlight) when index == highlight, do: "on"
  defp tone(%{tone: "risk"}, _highlight), do: "risk"
  defp tone(_bar, _highlight), do: "ink"

  defp count(1), do: "1 event"
  defp count(number), do: "#{number} events"
end
assets/css/calendar/month-view.css
month-view.css
/* calendar/month-view */
.ehv-calendar-month-view { --ehv-max-w: 470px; --ehv-step: 70ms; }

.ehv svg .ehv-mono.ehv-calendar-month-view__today { fill: var(--ehv-canvas); }

.ehv-calendar-month-view__bar { stroke-linejoin: round; }
.ehv-calendar-month-view__bar--ink { fill: var(--ehv-surface-3); stroke: var(--ehv-border-soft); stroke-width: 1; }
.ehv-calendar-month-view__bar--on { fill: var(--ehv-lime-fill); }
.ehv-calendar-month-view__bar--risk { fill: var(--ehv-coral-soft); stroke: var(--ehv-coral); stroke-width: 1; }

.ehv svg .ehv-mono.ehv-calendar-month-view__name--ink { fill: var(--ehv-ink-2); letter-spacing: 0.04em; }
.ehv svg .ehv-mono.ehv-calendar-month-view__name--on { fill: var(--ehv-on-lime); letter-spacing: 0.04em; }
.ehv svg .ehv-mono.ehv-calendar-month-view__name--risk { fill: var(--ehv-coral); letter-spacing: 0.04em; }

.ehv--hover .ehv-calendar-month-view__bar--ink { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-calendar-month-view__bar--ink:hover { stroke: var(--ehv-ink-2); }