Skip to contentVibraUI
Part of the SaaS dashboardinstalls at /saas/subscriptions

Subscriptions dashboard

A recurring-revenue page: MRR and who is on the book, a year of movements as a waterfall that closes exactly, the plan mix, retention by signup cohort, the trials about to run out, and every subscription ever opened.

Open the live page

A server component inside AppShell, reading db.subscriptions joined to db.customers and db.plans. Movements come off the rows' own dates — started inside the window is new, cancelled inside it is churn — and the two of them close on the book to the cent, which is why the waterfall carries two movements rather than the usual four: a plan change leaves no trace in the rows, and an invented expansion would need an invented contraction to cancel it. A churned account's own mrrCents is zeroed the moment it churns, so a cancellation is valued at the seat rate read off an account still paying that tier; without that, churn would read as zero. The cohort grid measures at the close of each month and leaves months a cohort has not lived through empty rather than counting them as losses. Composes AppShell, PageHeader, StatCardGroup, StatCard, DashboardGrid, ChartCard, WaterfallChart, DonutChart, Widget, HeatmapGrid, DataList, Badge, DataTable, StatusBadge, NumberCell, DateCell and TruncateCell.

Preview

Install

npx shadcn@latest add @vibra/saas-subscriptions

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

Source

app/saas/subscriptions/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { ChurnCohorts } from "./components/churn-cohorts"
import { MrrMovements } from "./components/mrr-movements"
import { PlanMix } from "./components/plan-mix"
import { SubscriptionStats } from "./components/subscription-stats"
import { SubscriptionTable } from "./components/subscription-table"
import { TrialsExpiring } from "./components/trials-expiring"
import {
  churnCohorts,
  currentUser,
  lastUpdated,
  liveTotal,
  mrrMovements,
  planMix,
  shellNotifications,
  subscriptionRows,
  subscriptionStats,
  trialsExpiring,
  WINDOW_MONTHS,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/saas/nav"

/**
 * The subscriptions page. A server component: it reads the book through `db`
 * once and hands plain rows to the client islands that draw them, so the data
 * layer never has to cross into the browser bundle.
 */
export default function SubscriptionsPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.subscriptions}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Subscriptions"
        description="What the book is worth, where it moved, and who is about to decide."
        meta={lastUpdated()}
      />

      <SubscriptionStats stats={subscriptionStats()} />

      <DashboardGrid>
        <DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
          <MrrMovements movements={mrrMovements()} months={WINDOW_MONTHS} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
          <PlanMix slices={planMix()} total={liveTotal()} />
        </DashboardGridItem>

        <DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
          <ChurnCohorts cohorts={churnCohorts()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
          <TrialsExpiring trials={trialsExpiring()} />
        </DashboardGridItem>

        <DashboardGridItem colSpan={12}>
          <SubscriptionTable rows={subscriptionRows()} />
        </DashboardGridItem>
      </DashboardGrid>
    </AppShell>
  )
}
app/saas/subscriptions/data.ts
/**
 * What /subscriptions reads. The book is `db.subscriptions`, one per account,
 * joined to `db.customers` for the workspace behind it and `db.plans` for the
 * tier it sits on. Movements are read off the rows' own dates — a subscription
 * that started inside the window is new, one that cancelled inside it is churn
 * — so opening plus new minus churn closes on the book exactly. Nothing here
 * reads a clock: every window ends at REFERENCE_DATE.
 */
import { getInitials } from "@/lib/format"
import {
  db,
  REFERENCE_DATE,
  type Member,
  type Subscription,
} from "@/lib/sample-data"

// The plans are reference rows nothing writes. The subscriptions and the
// accounts are read per call, never held at module scope: a cancellation or a
// new account since the server started is in the next book.
const PLANS = new Map(db.plans.all().map((plan) => [plan.id, plan]))
const subscriptions = (): Subscription[] => db.subscriptions.all()
const accountsById = () => new Map(db.customers.all().map((customer) => [customer.id, customer]))

// What a tier charges a seat a month, read off the accounts paying it today.
// A churned account's own `mrrCents` is zeroed the moment it churns, so the
// rate has to come from an account still on that tier — otherwise every
// cancellation would be worth nothing and churn would read as zero.
function seatPrices(): Map<string, number> {
  const prices = new Map<string, number>()
  for (const customer of db.customers.all()) {
    if (customer.mrrCents > 0 && !prices.has(customer.plan)) {
      prices.set(customer.plan, customer.mrrCents / customer.seats)
    }
  }
  return prices
}

/** What a subscription bills a month, in cents. A trial bills nothing. */
function billedCents(subscription: Subscription, prices: Map<string, number>): number {
  if (subscription.status === "trialing") return 0
  const tier = (PLANS.get(subscription.planId)?.name ?? "").toLowerCase()
  return subscription.seats * (prices.get(tier) ?? 0)
}

const DAY_MS = 86_400_000

const dollars = (cents: number): number => cents / 100

/** 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))
}

/** Whether a subscription was on the book at an instant. */
function live(subscription: Subscription, at: Date): boolean {
  if (subscription.startedAt > at) return false
  return !subscription.cancelledAt || subscription.cancelledAt > at
}

/** The book at an instant, in dollars. */
function bookAt(at: Date): number {
  const prices = seatPrices()
  return dollars(
    subscriptions()
      .filter((subscription) => live(subscription, at))
      .reduce((total, subscription) => total + billedCents(subscription, prices), 0)
  )
}

/** How many months of movement the waterfall covers. */
export const WINDOW_MONTHS = 12

const OPENED_AT = monthStart(WINDOW_MONTHS)

/**
 * How the book got from one close to the next. Two movements, not the usual
 * four: an account moving up or down a tier leaves no trace in the rows, and
 * an invented expansion would need an invented contraction to cancel it — so
 * opening plus new minus churn closes on the book to the cent.
 */
export function mrrMovements(): { label: string; value: number; isTotal?: boolean }[] {
  let won = 0
  let lost = 0
  const prices = seatPrices()
  for (const subscription of subscriptions()) {
    if (subscription.startedAt > OPENED_AT) won += billedCents(subscription, prices)
    const { cancelledAt } = subscription
    if (cancelledAt && cancelledAt > OPENED_AT) lost += billedCents(subscription, prices)
  }

  return [
    { label: "Opening", value: Math.round(bookAt(OPENED_AT)) },
    { label: "New", value: Math.round(dollars(won)) },
    { label: "Churn", value: -Math.round(dollars(lost)) },
    { label: "Closing", value: 0, isTotal: true },
  ]
}

const liveNow = () => subscriptions().filter((subscription) => live(subscription, REFERENCE_DATE))
const trialing = () => liveNow().filter((subscription) => subscription.status === "trialing")

export type SubscriptionStat = {
  key: string
  label: string
  value: number
  description: string
  format: "currency" | "number"
}

/** The four headline numbers: what the book is worth, and who is on it. */
export function subscriptionStats(): SubscriptionStat[] {
  const prices = seatPrices()
  const book = subscriptions()
  const onBook = book.filter((subscription) => live(subscription, REFERENCE_DATE))
  const billing = onBook.filter((subscription) => billedCents(subscription, prices) > 0)
  const closing = bookAt(REFERENCE_DATE)

  return [
    {
      key: "mrr",
      label: "MRR",
      value: Math.round(closing),
      description: `across ${billing.length} paying subscriptions`,
      format: "currency",
    },
    {
      key: "live",
      label: "Live subscriptions",
      value: onBook.length,
      description: `of ${book.length} ever opened`,
      format: "number",
    },
    {
      key: "trials",
      label: "In trial",
      value: onBook.filter((subscription) => subscription.status === "trialing").length,
      description: "not billing yet",
      format: "number",
    },
    {
      key: "arpa",
      label: "Average account",
      value: billing.length > 0 ? Math.round(closing / billing.length) : 0,
      description: "a month, across the accounts that pay",
      format: "currency",
    },
  ]
}

/** How the live book splits across the four tiers, largest first. */
export function planMix(): { name: string; value: number }[] {
  const counts = new Map<string, number>()
  for (const subscription of liveNow()) {
    const name = PLANS.get(subscription.planId)?.name ?? "Unknown"
    counts.set(name, (counts.get(name) ?? 0) + 1)
  }
  return [...counts]
    .map(([name, value]) => ({ name, value }))
    .sort((a, b) => b.value - a.value)
}

/** How many subscriptions are on the book right now, for the middle of the donut. */
export function liveTotal(): number {
  return liveNow().length
}

/** How many monthly cohorts the heatmap shows, and how far each is followed. */
export const COHORTS = 12

const COHORT_LABEL = new Intl.DateTimeFormat("en-US", {
  month: "short",
  year: "2-digit",
  timeZone: "UTC",
})

/** `months` months after `from`, on the same day of the month. */
function monthsAfter(from: Date, months: number): Date {
  return new Date(
    Date.UTC(
      from.getUTCFullYear(),
      from.getUTCMonth() + months,
      from.getUTCDate(),
      from.getUTCHours(),
      from.getUTCMinutes()
    )
  )
}

export type Cohorts = { rows: string[]; columns: string[]; values: number[][] }

/**
 * Retention by signup cohort. A cohort is the accounts that opened in one
 * month; a cell is the share of them still on the book that many months later.
 * A month a cohort has not lived through yet is left empty rather than counted
 * as a loss — the grid draws those as "no data".
 */
export function churnCohorts(): Cohorts {
  const starts = Array.from({ length: COHORTS }, (_, index) => monthStart(COHORTS - index))
  const book = subscriptions()

  return {
    rows: starts.map((start) => COHORT_LABEL.format(start)),
    columns: Array.from({ length: COHORTS }, (_, month) => `M${month}`),
    values: starts.map((start, index) => {
      const end = monthStart(COHORTS - index - 1)
      const cohort = book.filter(
        (subscription) => subscription.startedAt >= start && subscription.startedAt < end
      )

      return Array.from({ length: COHORTS }, (_, month) => {
        // Read at the close of the month, not its start: a cohort's own month
        // opens before its accounts exist, and "M0" is how many of them made it
        // to the end of the month they arrived in.
        const at = monthsAfter(start, month + 1)
        // The future has no retention to report.
        if (at > REFERENCE_DATE || cohort.length === 0) return Number.NaN
        const kept = cohort.filter((subscription) => live(subscription, at)).length
        return (kept / cohort.length) * 100
      })
    }),
  }
}

// The trial the data itself describes: every account in trial was opened
// somewhere in the last three weeks, so three weeks is the window it is on.
const TRIAL_DAYS = 21

export type Trial = {
  id: string
  company: string
  contact: string
  contactAvatar?: string
  plan: string
  seats: number
  endsAt: Date
  daysLeft: number
}

/** The trials closest to running out, soonest first. */
export function trialsExpiring(): Trial[] {
  const accounts = accountsById()
  return trialing()
    .map((subscription) => {
      const account = accounts.get(subscription.customerId)
      const endsAt = new Date(subscription.startedAt.getTime() + TRIAL_DAYS * DAY_MS)

      return {
        id: subscription.id,
        company: account?.company ?? subscription.customerId,
        contact: account?.name ?? "Unknown",
        contactAvatar: account?.avatarUrl,
        plan: PLANS.get(subscription.planId)?.name ?? "Unknown",
        seats: subscription.seats,
        endsAt,
        daysLeft: Math.max(0, Math.ceil((endsAt.getTime() - REFERENCE_DATE.getTime()) / DAY_MS)),
      }
    })
    .sort((a, b) => a.endsAt.getTime() - b.endsAt.getTime())
    .slice(0, 6)
}

export type SubscriptionRow = {
  id: string
  company: string
  plan: string
  status: Subscription["status"]
  seats: number
  mrr: number
  startedAt: Date
  renewsAt: Date
}

/** Every subscription ever opened, newest first. */
export function subscriptionRows(): SubscriptionRow[] {
  const accounts = accountsById()
  return subscriptions()
    .map((subscription) => ({
      id: subscription.id,
      company: accounts.get(subscription.customerId)?.company ?? subscription.customerId,
      plan: PLANS.get(subscription.planId)?.name ?? "Unknown",
      status: subscription.status,
      seats: subscription.seats,
      mrr: dollars(subscription.mrrCents),
      startedAt: subscription.startedAt,
      renewsAt: subscription.renewsAt,
    }))
    .sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime())
}

/** 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",
  year: "numeric",
  timeZone: "UTC",
})

/** The window the movements cover. */
export function lastUpdated(): string {
  return `${WINDOW_MONTHS} months to ${MONTH_NAME.format(REFERENCE_DATE)}`
}
app/saas/subscriptions/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

/**
 * The one thing this page changes. A server action so the page can stay a
 * server component and still hand the shell something to call, and a `Result`
 * so the caller reads the same success-or-error shape every mutation returns.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/saas/subscriptions/components/churn-cohorts.tsx
"use client"

import { HeatmapGrid } from "@/components/ui/heatmap-grid"
import { Widget } from "@/components/ui/widget"

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

const share = (value: number) => `${Math.round(value)}%`

/** Retention by signup month: each row is a cohort, each column a month of life. */
export function ChurnCohorts({ cohorts }: { cohorts: Cohorts }) {
  return (
    <Widget
      data-widget="widget-saas-subscriptions-churn-cohorts"
      title="Retention by cohort"
      description="The share of each month's signups still on the book, month by month"
      className="h-full"
      footer="Empty cells are months a cohort has not lived through yet, not months it lost."
    >
      <HeatmapGrid
        rows={cohorts.rows}
        columns={cohorts.columns}
        values={cohorts.values}
        // The floor is the worst cohort rather than zero: nothing here ever
        // retains 0%, and anchoring at zero flattens the whole grid to one
        // shade of the ramp.
        max={100}
        color="chart-2"
        cellSize={24}
        valueFormatter={share}
      />
    </Widget>
  )
}
app/saas/subscriptions/components/mrr-movements.tsx
"use client"

import { formatCurrency } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { WaterfallChart, type WaterfallMovement } from "@/components/ui/waterfall-chart"

const money = (value: number) =>
  formatCurrency(value, "USD", { compact: true, maximumFractionDigits: 1 })

export type MrrMovementsProps = {
  movements: WaterfallMovement[]
  months: number
}

/** Where the book opened, what it won and lost, and where it closed. */
export function MrrMovements({ movements, months }: MrrMovementsProps) {
  return (
    <ChartCard
      data-widget="widget-saas-subscriptions-mrr-movements"
      title="MRR movements"
      description={`Opening to closing over ${months} months`}
      height={280}
      className="h-full"
      footer="New and churn only: a plan change leaves no trace in the rows, so there is nothing to call expansion."
    >
      <WaterfallChart data={movements} height={280} valueFormatter={money} />
    </ChartCard>
  )
}
app/saas/subscriptions/components/plan-mix.tsx
"use client"

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

export type PlanMixProps = {
  slices: { name: string; value: number }[]
  total: number
}

/** How the live book splits across the four tiers. */
export function PlanMix({ slices, total }: PlanMixProps) {
  return (
    <ChartCard
      data-widget="widget-saas-subscriptions-plan-mix"
      title="Plan mix"
      description="Subscriptions on the book, by tier"
      height={280}
      className="h-full"
    >
      <DonutChart
        data={slices}
        height={280}
        centerLabel="Live"
        centerValue={formatNumber(total, { maximumFractionDigits: 0 })}
        valueFormatter={(value) => `${formatNumber(value, { maximumFractionDigits: 0 })} accounts`}
      />
    </ChartCard>
  )
}
app/saas/subscriptions/components/subscription-stats.tsx
import { CreditCardIcon, HourglassIcon, RepeatIcon, UsersIcon } from "lucide-react"

import { formatCurrency, formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

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

// One icon per headline, keyed by the stat rather than its position, so the row
// can be reordered without the icons following the wrong numbers.
const ICONS: Record<string, React.ReactNode> = {
  mrr: <CreditCardIcon />,
  live: <RepeatIcon />,
  trials: <HourglassIcon />,
  arpa: <UsersIcon />,
}

/** What the book is worth, how many are on it, and what an account is worth. */
export function SubscriptionStats({ stats }: { stats: SubscriptionStat[] }) {
  return (
    <StatCardGroup data-widget="widget-saas-subscriptions-subscription-stats" columns={4}>
      {stats.map((stat) => (
        <StatCard
          key={stat.key}
          label={stat.label}
          value={
            stat.format === "currency"
              ? formatCurrency(stat.value, "USD", { maximumFractionDigits: 0 })
              : formatNumber(stat.value, { maximumFractionDigits: 0 })
          }
          description={stat.description}
          icon={ICONS[stat.key]}
        />
      ))}
    </StatCardGroup>
  )
}
app/saas/subscriptions/components/subscription-table.tsx
"use client"

import * as React from "react"
import { ArrowRightIcon, BanIcon, ReceiptTextIcon } from "lucide-react"

import { formatCurrency } from "@/lib/format"
import {
  DataTable,
  DataTableColumnHeader,
  DataTableRowActions,
  type DataTableColumnDef,
  type DataTableFacet,
} from "@/components/ui/data-table"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
import { DateCell, NumberCell, TruncateCell } from "@/components/ui/table-cells"
import { Widget } from "@/components/ui/widget"

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

// StatusBadge resolves none of these on its own: "trialing" and "past_due" are
// not in its defaults, and "active"/"cancelled" are named here so the whole
// vocabulary reads in one place.
const STATUS_MAP: Record<string, StatusVariant> = {
  active: "success",
  trialing: "info",
  past_due: "warning",
  cancelled: "danger",
}

const FACETS: DataTableFacet<SubscriptionRow>[] = [
  {
    columnId: "status",
    title: "Status",
    options: [
      { label: "Active", value: "active" },
      { label: "Trialing", value: "trialing" },
      { label: "Past due", value: "past_due" },
      { label: "Cancelled", value: "cancelled" },
    ],
  },
  {
    columnId: "plan",
    title: "Plan",
    options: ["Free", "Starter", "Team", "Enterprise"].map((plan) => ({
      label: plan,
      value: plan,
    })),
  },
]

export function SubscriptionTable({ rows }: { rows: SubscriptionRow[] }) {
  // The title names the table too, so a screen reader announces it by name.
  const titleId = React.useId()
  const columns = React.useMemo<DataTableColumnDef<SubscriptionRow>[]>(
    () => [
      {
        accessorKey: "company",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Workspace" />,
        cell: ({ row }) => <TruncateCell maxWidth={220}>{row.original.company}</TruncateCell>,
        meta: { label: "Workspace" },
      },
      {
        accessorKey: "plan",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Plan" />,
        cell: ({ row }) => <span className="text-muted-foreground">{row.original.plan}</span>,
        meta: { label: "Plan" },
      },
      {
        accessorKey: "status",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
        cell: ({ row }) => <StatusBadge status={row.original.status} map={STATUS_MAP} />,
        meta: { label: "Status" },
      },
      {
        accessorKey: "seats",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Seats" />,
        cell: ({ row }) => <NumberCell value={row.original.seats} />,
        meta: { align: "right", label: "Seats" },
      },
      {
        accessorKey: "mrr",
        header: ({ column }) => <DataTableColumnHeader column={column} title="MRR" />,
        // Zero is not "nothing recorded" — it is a free tier, a trial, or an
        // account that has stopped paying, which reads as a dash, not as $0.
        cell: ({ row }) => (
          <span className="block text-right tabular-nums">
            {row.original.mrr > 0 ? (
              formatCurrency(row.original.mrr, "USD", { maximumFractionDigits: 0 })
            ) : (
              <span className="text-muted-foreground">—</span>
            )}
          </span>
        ),
        meta: { align: "right", label: "MRR" },
      },
      {
        accessorKey: "startedAt",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Started" />,
        cell: ({ row }) => (
          <DateCell date={row.original.startedAt} className="text-muted-foreground" />
        ),
        meta: { align: "right", label: "Started" },
      },
      {
        accessorKey: "renewsAt",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Renews" />,
        cell: ({ row }) => (
          <DateCell date={row.original.renewsAt} className="text-muted-foreground" />
        ),
        meta: { align: "right", label: "Renews" },
      },
      {
        id: "actions",
        size: 44,
        enableSorting: false,
        enableHiding: false,
        cell: ({ row }) => (
          <DataTableRowActions
            label={`Open menu for ${row.original.company}`}
            actions={[
              { label: "Open subscription", icon: <ArrowRightIcon />, onSelect: () => {} },
              { label: "View invoices", icon: <ReceiptTextIcon />, onSelect: () => {} },
              {
                label: "Cancel subscription",
                icon: <BanIcon />,
                destructive: true,
                separatorBefore: true,
                onSelect: () => {},
              },
            ]}
          />
        ),
      },
    ],
    []
  )

  return (
    <Widget titleId={titleId} data-widget="widget-saas-subscriptions-subscription-table" title="Subscriptions" description="Every subscription ever opened, newest first">
      <DataTable
        aria-labelledby={titleId}
        size="sm"
        columns={columns}
        data={rows}
        pageSize={8}
        searchKey="company"
        searchPlaceholder="Search workspaces…"
        facets={FACETS}
        enableRowSelection={false}
        getRowId={(subscription) => subscription.id}
        initialSorting={[{ id: "startedAt", desc: true }]}
      />
    </Widget>
  )
}
app/saas/subscriptions/components/trials-expiring.tsx
"use client"

import { formatDate, getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { Widget } from "@/components/ui/widget"

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

/** How the days left read, and how urgent that is. */
function countdown(daysLeft: number): { label: string; variant: "outline" | "secondary" } {
  if (daysLeft === 0) return { label: "Ends today", variant: "secondary" }
  return {
    label: `${daysLeft} ${daysLeft === 1 ? "day" : "days"} left`,
    variant: daysLeft <= 3 ? "secondary" : "outline",
  }
}

/** The trials closest to running out, soonest first. */
export function TrialsExpiring({ trials }: { trials: Trial[] }) {
  return (
    <Widget
      data-widget="widget-saas-subscriptions-trials-expiring"
      title="Trials expiring"
      description="Every trial on a 21-day window, soonest first"
      className="h-full"
    >
      <DataList divided>
        {trials.map((trial) => {
          const { label, variant } = countdown(trial.daysLeft)

          return (
            <DataListItem
              key={trial.id}
              leading={
                <Avatar size="sm">
                  {trial.contactAvatar ? <AvatarImage src={trial.contactAvatar} alt="" /> : null}
                  <AvatarFallback>{getInitials(trial.contact)}</AvatarFallback>
                </Avatar>
              }
              title={trial.company}
              description={`${trial.contact} · ${trial.plan}, ${trial.seats} seats`}
              meta={
                <span className="flex flex-col items-end gap-1">
                  <Badge variant={variant} className="tabular-nums">
                    {label}
                  </Badge>
                  <span className="text-xs text-muted-foreground tabular-nums">
                    {formatDate(trial.endsAt, "medium", { timeZone: "UTC" })}
                  </span>
                </span>
              }
            />
          )
        })}
      </DataList>
    </Widget>
  )
}