Skip to contentVibraUI

Growth by month

New workspaces a month, the month in progress projected to its end at the trailing daily rate; reads growthByMonth().

Preview

Install

npx shadcn@latest add @vibra/widget-saas-customers-growth-by-month

Needs the @vibra registry in your components.json — set it up once.

Source

app/saas/customers/components/growth-by-month.tsx
"use client"

import { formatNumber } from "@/lib/format"
import { BarChart } from "@/components/ui/bar-chart"
import { ChartCard } from "@/components/ui/chart-card"

import { type GrowthPoint } from "../data"

// Fixed to UTC so the axis reads the same wherever the page is rendered, and
// carrying the year: thirteen months cross one, and two bars both reading
// "Sep" would be a year apart.
const MONTH_LABEL = new Intl.DateTimeFormat("en-US", {
  month: "short",
  year: "2-digit",
  timeZone: "UTC",
})

// The hatch that says "not counted yet". Referenced by url() rather than named
// as a chart token, which chart-core allows: a series colour that is not one of
// the eight tokens is passed through as a raw CSS paint, so the bar's fill
// resolves to this pattern and everything else about the series is unchanged.
const PATTERN_ID = "vibra-growth-projected"

const SERIES = [
  { key: "created", label: "New workspaces", color: "chart-1" as const },
  { key: "projected", label: "Projected", color: `url(#${PATTERN_ID})` },
]

const count = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })

/** A swatch for the footer key, drawn with the same paint as the bars. */
function Swatch({ fill }: { fill: string }) {
  return (
    <svg aria-hidden="true" focusable="false" width={10} height={10} className="shrink-0">
      <rect width={10} height={10} rx={2} fill={fill} />
    </svg>
  )
}

/**
 * Signups by month. The month in progress is short by definition, so it carries
 * a second, hatched bar: where it lands if the trailing rate holds.
 */
export function GrowthByMonth({ rows }: { rows: GrowthPoint[] }) {
  return (
    <ChartCard
      data-widget="widget-saas-customers-growth-by-month"
      title="Growth by month"
      description="New workspaces a month, with the month in progress projected out"
      height={340}
      className="h-full"
      footer={
        // The chart's own legend paints its swatch as a background colour,
        // which a paint-server url() cannot fill, so the key is drawn here in
        // SVG where the pattern resolves.
        <span className="flex flex-wrap items-center gap-x-4 gap-y-1">
          <span className="flex items-center gap-1.5">
            <Swatch fill="var(--chart-1)" />
            New workspaces
          </span>
          <span className="flex items-center gap-1.5">
            <Swatch fill={`url(#${PATTERN_ID})`} />
            Projected, at the trailing 90-day rate
          </span>
        </span>
      }
    >
      {/* The paint server itself. It only has to be somewhere in the document
          for url(#id) to resolve, so it sits outside the plot with no size. */}
      <svg aria-hidden="true" focusable="false" className="absolute size-0">
        <defs>
          <pattern
            id={PATTERN_ID}
            width={6}
            height={6}
            patternUnits="userSpaceOnUse"
            patternTransform="rotate(45)"
          >
            <rect width={6} height={6} fill="var(--chart-1)" opacity={0.16} />
            <rect width={2.5} height={6} fill="var(--chart-1)" opacity={0.75} />
          </pattern>
        </defs>
      </svg>

      <BarChart
        data={rows}
        index="month"
        series={SERIES}
        height={340}
        showYAxis
        showLegend={false}
        valueFormatter={count}
        indexFormatter={(value) => MONTH_LABEL.format(new Date(`${value}-01T00:00:00Z`))}
      />
    </ChartCard>
  )
}
app/saas/customers/data.ts
/**
 * What /customers/overview reads. The accounts are `db.customers`, the spend
 * that brought them in is `db.campaigns`, and the plans they sit on are
 * `db.plans`. Two things the rows do not carry are rules over them: a
 * campaign's conversion target, set from the clicks it bought, and next
 * month's projection, run on the trailing daily rate. "Now" is REFERENCE_DATE,
 * so the month in progress is always the same one.
 */
import { getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type Campaign, type Customer, type Member } from "@/lib/sample-data"

export type { Customer }

// Read per call, never held at module scope: an account or a campaign written
// since the server started is what the next render counts.
const allCustomers = () => db.customers.all()
const allCampaigns = () => db.campaigns.all()

const DAY_MS = 86_400_000

/** Midnight UTC on the first of the month `k` months before the current one. */
function monthStart(k: number): Date {
  return new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() - k, 1))
}

const THIS_MONTH = monthStart(0)
const LAST_MONTH = monthStart(1)
const PRIOR_MONTH = monthStart(2)

const createdIn = (from: Date, to: Date): number =>
  allCustomers().filter((row) => row.createdAt >= from && row.createdAt < to).length

const paying = (row: Customer): boolean => row.mrrCents > 0

export type CustomerStat = {
  key: string
  label: string
  value: number
  delta?: number
  description: string
  format: "number" | "percent"
  positiveIsGood: boolean
}

/** The four headline numbers: how many, how many new, how many pay, how many left. */
export function customerStats(): CustomerStat[] {
  const customers = allCustomers()
  const live = customers.filter((row) => row.status === "active" || row.status === "trial").length
  const fresh = createdIn(LAST_MONTH, THIS_MONTH)
  const before = createdIn(PRIOR_MONTH, LAST_MONTH)
  const churned = customers.filter((row) => row.status === "churned").length

  return [
    {
      key: "live",
      label: "Live workspaces",
      value: live,
      description: `of ${customers.length} ever created`,
      format: "number",
      positiveIsGood: true,
    },
    {
      key: "new",
      label: "New last month",
      value: fresh,
      delta: before > 0 ? fresh / before - 1 : 0,
      description: "vs the month before",
      format: "number",
      positiveIsGood: true,
    },
    {
      key: "paying",
      label: "Paying",
      value: customers.filter(paying).length,
      description: "on a plan that bills",
      format: "number",
      positiveIsGood: true,
    },
    {
      key: "churned",
      label: "Churned",
      value: churned / customers.length,
      description: "of every account ever opened",
      format: "percent",
      positiveIsGood: false,
    },
  ]
}

const isLive = (row: Customer): boolean => row.status === "active" || row.status === "trial"

/**
 * Every workspace ever opened, down to the ones on a big tier. Each step is a
 * subset of the one above it, so the funnel narrows for a reason rather than
 * comparing two unrelated counts — which is also why the clicks and leads that
 * bought these accounts are a line of copy beside it rather than a first step:
 * half a million clicks against 250 workspaces draws four invisible bars.
 */
export function acquisitionFunnel(): { label: string; value: number }[] {
  const customers = allCustomers()
  const live = customers.filter(isLive)

  return [
    { label: "Workspaces created", value: customers.length },
    { label: "Still live", value: live.length },
    { label: "On a paid plan", value: live.filter((row) => row.plan !== "free").length },
    // Not "Paying": that is the stat card above, which counts every account
    // that bills anything. This step is narrower — a live account on a paid
    // plan that is being invoiced now, which a trial on that plan is not.
    { label: "Billing now", value: live.filter(paying).length },
    {
      label: "Team or Enterprise",
      value: live.filter((row) => paying(row) && (row.plan === "team" || row.plan === "enterprise"))
        .length,
    },
  ]
}

/** What the campaigns delivered, for the line under the funnel. */
export function acquisitionSpend(): { clicks: number; leads: number } {
  const campaigns = allCampaigns()
  return {
    clicks: campaigns.reduce((total, campaign) => total + campaign.clicks, 0),
    leads: campaigns.reduce((total, campaign) => total + campaign.conversions, 0),
  }
}

/** How many complete months of signups the chart shows before the one in progress. */
export const GROWTH_MONTHS = 12

// The window the run rate is measured over: long enough to smooth a slow week,
// short enough that a projection still describes now.
const RATE_DAYS = 90

export type GrowthPoint = {
  month: string
  created: number
  /** Only the month in progress carries one; every other month is done. */
  projected: number | null
}

/**
 * Signups by month, ending on the month in progress. That last bar is only as
 * long as the month has run, so it gets a second, hatched bar beside it: where
 * it lands if the trailing daily rate holds to the end of the month.
 */
export function growthByMonth(): GrowthPoint[] {
  const rate = createdIn(new Date(REFERENCE_DATE.getTime() - RATE_DAYS * DAY_MS), REFERENCE_DATE) / RATE_DAYS
  // Day 0 of the next month is the last day of this one.
  const monthEnd = new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() + 1, 0))
  const daysLeft = Math.max(0, Math.round((monthEnd.getTime() - REFERENCE_DATE.getTime()) / DAY_MS))

  return Array.from({ length: GROWTH_MONTHS + 1 }, (_, index) => {
    const from = monthStart(GROWTH_MONTHS - index)
    const to = monthStart(GROWTH_MONTHS - index - 1)
    const created = createdIn(from, index === GROWTH_MONTHS ? REFERENCE_DATE : to)

    return {
      month: from.toISOString().slice(0, 7),
      created,
      projected: index === GROWTH_MONTHS ? created + Math.round(rate * daysLeft) : null,
    }
  })
}

// The plans as a reader sees them on the pricing page, keyed by the value a
// customer row carries: "team" → "Team".
const PLAN_NAMES = new Map(db.plans.all().map((plan) => [plan.name.toLowerCase(), plan.name]))

/** A customer's plan, as the pricing page writes it. */
export function planName(plan: Customer["plan"]): string {
  return PLAN_NAMES.get(plan) ?? plan
}

/** The plans a row can carry, in the order the pricing page lists them. */
const PLANS: Customer["plan"][] = ["free", "starter", "team", "enterprise"]

/** Every plan as the pricing page writes it, for the directory to look up — it is an island, and never reads the store. */
export function planNames(): Record<Customer["plan"], string> {
  return Object.fromEntries(PLANS.map((plan) => [plan, planName(plan)])) as Record<Customer["plan"], string>
}

/** Every account, newest first — the directory the table pages through. */
export function directory(): Customer[] {
  const customers = allCustomers()
  return [...customers].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
}

export type CampaignGoal = {
  id: string
  name: string
  channel: Campaign["channel"]
  conversions: number
  /** What the clicks it bought should have converted, rounded to a round number. */
  goal: number
  status: Campaign["status"]
  deadline: string
}

const ENDED = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })

/**
 * The five campaigns that bought the most clicks, each against its own target:
 * a twentieth of those clicks, rounded to the nearest 25, which is how a
 * marketing target gets written down. Ranked by clicks rather than by
 * conversions, so the list is not just the ones that already beat it.
 */
export function campaignGoals(): CampaignGoal[] {
  const campaigns = allCampaigns()
  return [...campaigns]
    .sort((a, b) => b.clicks - a.clicks)
    .slice(0, 5)
    .map((campaign) => ({
      id: campaign.id,
      name: campaign.name,
      channel: campaign.channel,
      conversions: campaign.conversions,
      goal: Math.max(25, Math.round((campaign.clicks * 0.05) / 25) * 25),
      status: campaign.status,
      deadline: campaign.endedAt
        ? `Ended ${ENDED.format(campaign.endedAt)}`
        : `Running since ${ENDED.format(campaign.startedAt)}`,
    }))
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

const MONTH_NAME = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" })

/** Which month the page is reading, and which one is still running. */
export function lastUpdated(): string {
  return `Closed through ${MONTH_NAME.format(LAST_MONTH)} · ${MONTH_NAME.format(THIS_MONTH)} still running`
}

Its page

On its page the card sits among the rest of the dashboard and shares its range and its data with them.

From the Customers dashboard page