empowered.house ↗

activity/timeline.ex

Timeline

A vertical activity timeline of events and status changes grouped under day headings.

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

<div class="h-96 rounded-xl border border-border">
  <.activity_timeline animated />
</div>
default
isometric
one day · custom copy
three days · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
title string "Activity"
caption string "Order SO-1042"
days list [%{name: "Today", events: [%{time: "14:32", text: "Invoice INV-884 posted to the ERP", ...}, ...]}, ...] Up to three `%{name, events}`, newest first, eight events in all. An event is `%{time, text}` with text up to 34 characters. Optional `from` and `to` (up to 10 characters each) draw a status change under it, optional `tone: "risk"` turns it coral.
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/activity/timeline.ex
timeline.ex
defmodule EHVisuals.Activity.Timeline do
  @moduledoc """
  A vertical activity timeline: events and status changes on a rail, grouped
  under day headings, the newest one first.
  """

  use EHVisuals.Composition,
    name: "activity/timeline",
    title: "Timeline",
    description:
      "A vertical activity timeline of events and status changes grouped under day headings."

  @days [
    %{
      name: "Today",
      events: [
        %{
          time: "14:32",
          text: "Invoice INV-884 posted to the ERP",
          from: "Approved",
          to: "Posted"
        },
        %{time: "13:05", text: "Marta Nowak approved the order"},
        %{time: "09:41", text: "Intake agent matched the contract"}
      ]
    },
    %{
      name: "Yesterday",
      events: [
        %{
          time: "16:20",
          text: "Stock check failed for 2 lines",
          from: "Checking",
          to: "Blocked",
          tone: "risk"
        },
        %{time: "11:02", text: "Order SO-1042 received by mail"}
      ]
    }
  ]

  # Vertical rhythm: a day heading, an event of one line, an event that also
  # carries a status change.
  @heading 26
  @plain 30
  @changed 48

  attr(:title, :string, default: "Activity")
  attr(:caption, :string, default: "Order SO-1042")

  attr(:days, :list,
    default: @days,
    doc:
      "Up to three `%{name, events}`, newest first, eight events in all. An event is `%{time, text}` with text up to 34 characters. Optional `from` and `to` (up to 10 characters each) draw a status change under it, optional `tone: \"risk\"` turns it coral."
  )

  attr(:label, :string,
    default: "A timeline of an order over two days, ending with its invoice posted to the ERP"
  )

  frame_attrs()

  @spec activity_timeline(map()) :: Phoenix.LiveView.Rendered.t()
  def activity_timeline(assigns) do
    {groups, bottom} = layout(assigns.days)

    assigns =
      assigns
      |> assign(:groups, groups)
      |> assign(:height, bottom + 12)

    ~H"""
    <.frame name="activity/timeline" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 340 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="339" height={@height - 1} rx="12" />
          <text class="ehv-t-strong ehv-a-fade ehv-i-1" x="20" y="35" font-size="14">{@title}</text>
          <text class="ehv-mono ehv-a-fade ehv-i-1" x="320" y="35" font-size="9" text-anchor="end">
            {String.upcase(@caption)}
          </text>
          <line class="ehv-line-faint" x1="20" x2="320" y1="52.5" y2="52.5" />
          <g :for={group <- @groups}>
            <g class={"ehv-a-fade ehv-i-#{group.step}"}>
              <text class="ehv-mono ehv-t-2" x="20" y={group.y + 16} font-size="8.5">
                {String.upcase(group.name)}
              </text>
              <line
                class="ehv-line-faint"
                x1={group.rule}
                x2="320"
                y1={group.y + 12.5}
                y2={group.y + 12.5}
              />
            </g>
            <g :for={event <- group.events}>
              <line
                :if={event.rail}
                class={"ehv-line-soft ehv-a-fade ehv-i-#{event.step + 1}"}
                x1="26"
                x2="26"
                y1={event.y + 22}
                y2={event.rail}
              />
              <g class={"ehv-a-rise ehv-i-#{event.step}"}>
                <g :if={event.newest}>
                  <circle class="ehv-fill-lime ehv-a-ping" cx="26" cy={event.y + 14} r="4.5" />
                  <circle class="ehv-fill-lime" cx="26" cy={event.y + 14} r="4.5" />
                </g>
                <circle
                  :if={!event.newest and event.risk}
                  class="ehv-fill-coral"
                  cx="26"
                  cy={event.y + 14}
                  r="4"
                />
                <circle
                  :if={!event.newest and !event.risk}
                  class="ehv-node-soft"
                  cx="26"
                  cy={event.y + 14}
                  r="4"
                />
                <text class={[!event.newest && "ehv-t-2"]} x="44" y={event.y + 18} font-size="12.5">
                  {event.text}
                </text>
                <text class="ehv-mono" x="320" y={event.y + 17.5} font-size="8.5" text-anchor="end">
                  {event.time}
                </text>
                <g :if={event.change}>
                  <rect
                    class="ehv-activity-timeline__chip"
                    x="44"
                    y={event.y + 26}
                    width={event.change.from_w}
                    height="16"
                    rx="5"
                  />
                  <text
                    class="ehv-mono ehv-t-2"
                    x={44 + event.change.from_w / 2}
                    y={event.y + 37}
                    font-size="7.5"
                    text-anchor="middle"
                  >
                    {String.upcase(event.change.from)}
                  </text>
                  <path
                    class="ehv-line-ink ehv-round"
                    d={"M#{event.change.arrow} #{event.y + 34} h8 m-3 -3 l3 3 l-3 3"}
                  />
                  <rect
                    class={[
                      "ehv-activity-timeline__chip",
                      "ehv-activity-timeline__chip--#{event.change.tone}"
                    ]}
                    x={event.change.to_x}
                    y={event.y + 26}
                    width={event.change.to_w}
                    height="16"
                    rx="5"
                  />
                  <text
                    class={["ehv-mono", "ehv-activity-timeline__state--#{event.change.tone}"]}
                    x={event.change.to_x + event.change.to_w / 2}
                    y={event.y + 37}
                    font-size="7.5"
                    text-anchor="middle"
                  >
                    {String.upcase(event.change.to)}
                  </text>
                </g>
              </g>
            </g>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # Walks the days top to bottom and gives every heading and event its y, so
  # the height of the card follows from the data.
  defp layout(days) do
    {groups, {bottom, _count}} =
      days
      |> Enum.take(3)
      |> Enum.map_reduce({60, 0}, fn day, {y, count} ->
        events = day |> Map.get(:events, []) |> Enum.take(max(8 - count, 0))
        heights = Enum.map(events, &if(change?(&1), do: @changed, else: @plain))
        tops = Enum.scan([y + @heading | heights], &(&1 + &2)) |> Enum.take(length(events))
        last = length(events) - 1

        rows =
          for {{event, top}, index} <- events |> Enum.zip(tops) |> Enum.with_index() do
            %{
              y: top,
              time: Map.get(event, :time, ""),
              text: event.text,
              newest: count + index == 0,
              risk: Map.get(event, :tone) == "risk",
              rail: if(index < last, do: Enum.at(tops, index + 1) + 6),
              change: change(event),
              step: min(count + index + 3, 23)
            }
          end

        group = %{
          name: day.name,
          y: y,
          rule: Float.round(20 + String.length(day.name) * 5.9 + 10, 1),
          step: min(count + 2, 23),
          events: rows
        }

        {group, {y + @heading + Enum.sum(heights) + 4, count + length(events)}}
      end)

    {groups, bottom}
  end

  defp change?(event), do: is_binary(Map.get(event, :from)) and is_binary(Map.get(event, :to))

  # The two chips of a status change, sized from their text.
  defp change(event) do
    if change?(event) do
      from_w = Float.round(14 + String.length(event.from) * 5.1, 1)

      %{
        from: event.from,
        to: event.to,
        from_w: from_w,
        arrow: 44 + from_w + 6,
        to_x: 44 + from_w + 20,
        to_w: Float.round(14 + String.length(event.to) * 5.1, 1),
        tone: if(Map.get(event, :tone) == "risk", do: "risk", else: "ok")
      }
    end
  end
end
assets/css/activity/timeline.css
timeline.css
/* activity/timeline */
.ehv-activity-timeline { --ehv-max-w: 360px; --ehv-step: 110ms; }

/* The state an event left is neutral, the state it reached is lime or coral. */
.ehv-activity-timeline__chip { fill: var(--ehv-surface-3); stroke: var(--ehv-border-soft); stroke-width: 1; }
.ehv-activity-timeline__chip--ok { fill: var(--ehv-lime-soft); stroke: none; }
.ehv-activity-timeline__chip--risk { fill: var(--ehv-coral-soft); stroke: none; }

.ehv svg .ehv-mono.ehv-activity-timeline__state--ok { fill: var(--ehv-lime); }
.ehv svg .ehv-mono.ehv-activity-timeline__state--risk { fill: var(--ehv-coral); }