empowered.house ↗

payments/usage-meter.ex

Usage meter

A metered billing summary: usage against a quota, the itemised meters and the projected invoice.

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

<div class="h-96 rounded-xl border border-border">
  <.payments_usage_meter animated />
</div>
default
isometric
over the quota · custom copy
four meters · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
eyebrow string "Usage this period"
period string "Day 23 of 31"
used integer 18420 Above the quota the bar and the share turn coral.
quota integer 25000
unit string "runs" What is counted, lower case, up to 10 characters.
meters list [%{name: "Intake agent", value: 9210, amount: "€184.20"}, %{name: "Month-end close agent", ...}, ...] Up to four `%{name, value, amount}`. Names up to 22 characters, `value` is a whole number.
projected string "€496.50" The invoice at the current pace.
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/payments/usage-meter.ex
usage-meter.ex
defmodule EHVisuals.Payments.UsageMeter do
  @moduledoc """
  Metered billing at a glance: usage against the quota, the meters that make
  it up and the invoice it is heading for.
  """

  use EHVisuals.Composition,
    name: "payments/usage-meter",
    title: "Usage meter",
    description:
      "A metered billing summary: usage against a quota, the itemised meters and the projected invoice."

  @meters [
    %{name: "Intake agent", value: 9210, amount: "€184.20"},
    %{name: "Month-end close agent", value: 6480, amount: "€129.60"},
    %{name: "Stock agent", value: 2730, amount: "€54.60"}
  ]

  attr(:eyebrow, :string, default: "Usage this period")
  attr(:period, :string, default: "Day 23 of 31")
  attr(:used, :integer, default: 18_420, doc: "Above the quota the bar and the share turn coral.")
  attr(:quota, :integer, default: 25_000)
  attr(:unit, :string, default: "runs", doc: "What is counted, lower case, up to 10 characters.")

  attr(:meters, :list,
    default: @meters,
    doc:
      "Up to four `%{name, value, amount}`. Names up to 22 characters, `value` is a whole number."
  )

  attr(:projected, :string, default: "€496.50", doc: "The invoice at the current pace.")

  attr(:label, :string,
    default:
      "A usage meter at 74 percent of the quota, three itemised meters and the projected invoice"
  )

  frame_attrs()

  @spec payments_usage_meter(map()) :: Phoenix.LiveView.Rendered.t()
  def payments_usage_meter(assigns) do
    meters = Enum.take(assigns.meters, 4)
    top = meters |> Enum.map(& &1.value) |> Enum.max(fn -> 1 end) |> max(1)
    share = assigns.used / max(assigns.quota, 1)
    footer_y = 117 + length(meters) * 38 + 6

    rows =
      for {meter, index} <- Enum.with_index(meters) do
        %{
          name: meter.name,
          amount: meter.amount,
          quantity: "#{number(meter.value)} #{assigns.unit}",
          index: index,
          y: 117 + index * 38,
          bar_w: Float.round(max(meter.value / top * 84, 3), 1)
        }
      end

    assigns =
      assigns
      |> assign(:rows, rows)
      |> assign(:over, share > 1)
      |> assign(:percent, "#{round(share * 100)}% used")
      |> assign(:bar_w, Float.round(min(max(share, 0.02), 1.0) * 320, 1))
      |> assign(:used_text, number(assigns.used))
      |> assign(:quota_text, "of #{number(assigns.quota)} #{assigns.unit}")
      |> assign(:footer_y, footer_y)
      |> assign(:height, footer_y + 54)

    ~H"""
    <.frame name="payments/usage-meter" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 360 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="359" height={@height - 1} rx="12" />
          <g class="ehv-a-fade ehv-i-1">
            <text class="ehv-mono" x="20" y="30" font-size="9">{String.upcase(@eyebrow)}</text>
            <text class="ehv-t-display" x="20" y="66" font-size="28">{@used_text}</text>
            <text class="ehv-t-2" x="340" y="66" font-size="12.5" text-anchor="end">
              {@quota_text}
            </text>
          </g>
          <g class="ehv-a-fade ehv-i-2">
            <rect class="ehv-fill-soft" x="20" y="78" width="320" height="8" rx="4" />
            <rect
              class={[
                "ehv-a-grow-x ehv-i-3",
                if(@over, do: "ehv-fill-coral", else: "ehv-fill-lime")
              ]}
              x="20"
              y="78"
              width={@bar_w}
              height="8"
              rx="4"
            />
            <text
              class={["ehv-mono", if(@over, do: "ehv-t-coral", else: "ehv-t-lime")]}
              x="20"
              y="103"
              font-size="8.5"
            >
              {String.upcase(@percent)}
            </text>
            <text class="ehv-mono" x="340" y="103" font-size="8.5" text-anchor="end">
              {String.upcase(@period)}
            </text>
          </g>
          <line class="ehv-line-faint" x1="20" x2="340" y1="116.5" y2="116.5" />
          <g :for={row <- @rows} class={"ehv-a-rise ehv-i-#{row.index + 4}"}>
            <text x="20" y={row.y + 18} font-size="12.5">{row.name}</text>
            <text class="ehv-mono" x="20" y={row.y + 31} font-size="8">
              {String.upcase(row.quantity)}
            </text>
            <rect class="ehv-fill-soft" x="176" y={row.y + 18} width="84" height="4" rx="2" />
            <rect
              class={"ehv-fill-2 ehv-a-grow-x ehv-i-#{row.index + 6}"}
              x="176"
              y={row.y + 18}
              width={row.bar_w}
              height="4"
              rx="2"
            />
            <text x="340" y={row.y + 24} font-size="12.5" text-anchor="end">{row.amount}</text>
          </g>
          <g class="ehv-a-fade ehv-i-9">
            <line class="ehv-line-faint" x1="20" x2="340" y1={@footer_y + 0.5} y2={@footer_y + 0.5} />
            <text class="ehv-t-strong" x="20" y={@footer_y + 24} font-size="12.5">
              Projected invoice
            </text>
            <text class="ehv-mono" x="20" y={@footer_y + 38} font-size="8">AT THE CURRENT PACE</text>
            <text
              class="ehv-t-display"
              x="340"
              y={@footer_y + 34}
              font-size="20"
              text-anchor="end"
            >
              {@projected}
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # 18420 -> "18,420"
  defp number(value) do
    value
    |> Integer.to_string()
    |> String.reverse()
    |> String.replace(~r/(\d{3})(?=\d)/, "\\1,")
    |> String.reverse()
  end
end
assets/css/payments/usage-meter.css
usage-meter.css
/* payments/usage-meter */
.ehv-payments-usage-meter { --ehv-max-w: 390px; }