calendar/month-view.ex
Month view
A full month grid with events placed on their days as small bars, one of them marked.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.calendar_month_view animated />
</div>
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
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
/* 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); }