Skip to contentVibraUI
Part of the People dashboardinstalls at /people/leaderboard

Leaderboard

Who is ahead: a podium for the top three, a ranked list of everyone else, and one switch each for the window and the measure — revenue booked, deals closed, or support tickets answered.

Open the live page

Revenue and deals are real: an order counts for whoever owns the account it was placed on (db.customers.owner), and only orders that were paid or fulfilled count — a refunded order is money that left again, and a cancelled one never arrived. Support has no entity of its own, so the closed-ticket ledger is built in data.ts from seeded("people-leaderboard") against real db.members rows, the way support-overview builds its queue; the widget's footer says which of the three numbers is which. Only active teammates who are not viewers are on the board, because a viewer cannot own an account or close a ticket and would sit at zero saying nothing. All four windows are counted on the server, once per module, and handed to a single island that sorts them, so switching the period or the measure re-sorts an array the browser already holds — no round trip, and the first frame is already the real board. A custom range is deliberately not offered: it would be a window nothing had been counted for. The podium says its places in words — 1st, 2nd, 3rd — rather than by position or colour, so it reads the same on a phone, where the three cards stack, as it does across. Composes AppShell, PageHeader, PeriodSelect, SegmentedControl, StatCardGroup, StatCard, Badge, Widget and RankList.

Preview

Install

npx shadcn@latest add @vibra/people-leaderboard

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

Source

app/people/leaderboard/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { LeaderboardView } from "./components/leaderboard-view"
import { PODIUM_SIZE, currentUser, lastUpdated, leaderboard, shellNotifications } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/people/nav"

/**
 * Who is ahead. A server component: all four windows are counted here, against
 * `REFERENCE_DATE`, and handed to one island that sorts them — so changing the
 * period or the measure costs no round trip and the first frame is already the
 * real board.
 */
export default function LeaderboardPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.leaderboard}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Leaderboard"
        description="What the team booked, closed and answered, over the window you pick."
        meta={lastUpdated()}
      />

      <LeaderboardView board={leaderboard()} podiumSize={PODIUM_SIZE} />
    </AppShell>
  )
}
app/people/leaderboard/data.ts
/**
 * What this page reads. Revenue and deals come straight out of `db.orders`,
 * credited through `db.customers.owner` to the teammate who holds the account —
 * so the money on this page is money that was actually taken, and no order is
 * counted twice. Support has no entity of its own, so the closed-ticket ledger
 * is built here the way `support-overview` builds its queue: every agent is a
 * real `db.members` row, and only how many tickets each closed and when comes
 * from `seeded("team-leaderboard")`. "Now" is `REFERENCE_DATE`.
 */
import { getInitials } from "@/lib/format"
import {
  REFERENCE_DATE,
  db,
  intBetween,
  seeded,
  type Member,
  type Order,
} from "@/lib/sample-data"

import { PERIOD_DAYS, PERIODS, type LeaderPeriod, type LeaderRow } from "./vocabulary"

const DAY_MS = 86_400_000

/** How many teammates stand on the podium. */
export const PODIUM_SIZE = 3

// A viewer cannot own an account or close a ticket, and a teammate who has left
// is not competing; both would sit on the board at zero and say nothing. Read
// per call, like the accounts and the orders below: a sale, a refund or a
// teammate who leaves since the server started is on the next board.
const sellers = (): Member[] =>
  db.members
    .all()
    .filter((member) => member.status === "active" && member.role !== "viewer")
    .sort((a, b) => a.name.localeCompare(b.name))

// Money that arrived and stayed: a refunded order left again, and a cancelled
// or still-pending one never counted.
const booked = () => db.orders.all().filter((order) => order.status === "paid" || order.status === "fulfilled")

/** How many closed tickets the ledger holds — a year of a small support rota. */
const TICKET_COUNT = 900

type ClosedTicket = { agentId: string; closedAt: Date }

let ledger: ClosedTicket[] | undefined

/**
 * A year of closed tickets. Every agent is a real teammate; the ledger exists
 * only because there is no ticket entity to read, and the widget's footer says
 * so where the number is shown. Drawn once, when a board is first asked for —
 * never when the module loads — so the tickets hold still while the teammates
 * are read fresh: one who leaves drops off the board with theirs.
 */
function tickets(): ClosedTicket[] {
  if (ledger) return ledger
  const rand = seeded("team-leaderboard")
  const oldest = REFERENCE_DATE.getTime() - 365 * DAY_MS
  const agents = sellers()

  ledger = Array.from({ length: TICKET_COUNT }, () => {
    const agent = agents[intBetween(rand, 0, agents.length - 1)]
    // Weighted towards the recent end: a support rota's own history thins out
    // the further back you look, and a flat year would make every window the
    // same shape as every other.
    const share = rand() ** 1.6
    return {
      agentId: agent.id,
      closedAt: new Date(REFERENCE_DATE.getTime() - share * (REFERENCE_DATE.getTime() - oldest)),
    }
  })
  return ledger
}

type Book = { sellers: Member[]; booked: Order[]; ownerByCustomer: Map<string, string> }

function rowsFor(period: LeaderPeriod, book: Book): LeaderRow[] {
  const from = REFERENCE_DATE.getTime() - PERIOD_DAYS[period] * DAY_MS
  const sellerIds = new Set(book.sellers.map((member) => member.id))

  const totals = new Map<string, { revenueCents: number; deals: number; tickets: number }>(
    book.sellers.map((member) => [member.id, { revenueCents: 0, deals: 0, tickets: 0 }])
  )

  for (const order of book.booked) {
    if (order.placedAt.getTime() < from) continue
    const owner = book.ownerByCustomer.get(order.customerId)
    if (!owner || !sellerIds.has(owner)) continue
    const entry = totals.get(owner)!
    entry.revenueCents += order.totalCents
    entry.deals += 1
  }

  for (const ticket of tickets()) {
    if (ticket.closedAt.getTime() < from) continue
    const entry = totals.get(ticket.agentId)
    if (entry) entry.tickets += 1
  }

  return book.sellers.map((member) => ({
    id: member.id,
    name: member.name,
    email: member.email,
    initials: getInitials(member.name),
    role: member.role,
    ...totals.get(member.id)!,
  }))
}

/** Every teammate's standing, one array per window the reader can ask for, read as the book stands now. */
export function leaderboard(): Record<LeaderPeriod, LeaderRow[]> {
  const book: Book = {
    sellers: sellers(),
    booked: booked(),
    // Which teammate holds each account.
    ownerByCustomer: new Map(db.customers.all().map((row) => [row.id, row.owner])),
  }
  return Object.fromEntries(PERIODS.map((period) => [period, rowsFor(period, book)])) as Record<
    LeaderPeriod,
    LeaderRow[]
  >
}

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

// Fixed to UTC so the line reads the same wherever the page is rendered.
const UPDATED_AT = new Intl.DateTimeFormat("en-US", {
  dateStyle: "medium",
  timeStyle: "short",
  hourCycle: "h23",
  timeZone: "UTC",
})

/** How many teammates are on the board, and when it was last counted. */
export function lastUpdated(): string {
  return `${sellers().length} teammates · counted ${UPDATED_AT.format(REFERENCE_DATE)} UTC`
}
app/people/leaderboard/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/people/leaderboard/vocabulary.ts
/**
 * The windows and the measures this board offers, and nothing else.
 *
 * `data.ts` reads `db` at module scope, so a value a client island imports from
 * it would drag the whole sample-data store into the browser. These are plain
 * words and numbers with no rows behind them, so they live here.
 */
import { type Member } from "@/lib/sample-data"
import { type PeriodOption } from "@/components/ui/period-select"

/** The windows the board can be read over, shortest first. */
export const PERIODS = ["7d", "30d", "90d", "12m"] as const

export type LeaderPeriod = (typeof PERIODS)[number]

/** How long each window is, in days. Twelve months is counted as a plain year. */
export const PERIOD_DAYS: Record<LeaderPeriod, number> = {
  "7d": 7,
  "30d": 30,
  "90d": 90,
  "12m": 365,
}

/** What the select offers. A custom range is not among them — see the notes. */
export const PERIOD_OPTIONS: PeriodOption[] = [
  { value: "7d", label: "Last 7 days" },
  { value: "30d", label: "Last 30 days" },
  { value: "90d", label: "Last 90 days" },
  { value: "12m", label: "Last 12 months" },
]

/** The window the page opens on. */
export const DEFAULT_PERIOD: LeaderPeriod = "30d"

export type Metric = "revenue" | "deals" | "tickets"

/** What each measure is called, and how a reader should read the number under it. */
export const METRICS: { value: Metric; label: string; unit: string }[] = [
  { value: "revenue", label: "Revenue", unit: "booked on accounts they own" },
  { value: "deals", label: "Deals", unit: "orders on those accounts" },
  { value: "tickets", label: "Tickets", unit: "support tickets they closed" },
]

/** The place a rank is spoken as: 1st, 2nd, 3rd. */
export const PLACES = ["1st", "2nd", "3rd"] as const

/** One teammate's standing over one window. */
export type LeaderRow = {
  id: string
  name: string
  email: string
  initials: string
  role: Member["role"]
  /** Booked revenue on the accounts they own, in minor units. */
  revenueCents: number
  /** How many of those orders there were. */
  deals: number
  /** Support tickets they closed in the window. */
  tickets: number
}

/**
 * The board ordered by one metric, best first. Ties keep the alphabetical order
 * the rows arrived in, so a run of zeroes is stable rather than arbitrary.
 *
 * It lives here rather than in `data.ts` because the island that sorts the
 * board is a client component, and `data.ts` reads `db` at module scope.
 */
export function ranked(rows: LeaderRow[], metric: Metric): LeaderRow[] {
  const measure = (row: LeaderRow) => (metric === "revenue" ? row.revenueCents : row[metric])
  return [...rows].sort((a, b) => measure(b) - measure(a))
}
app/people/leaderboard/components/leaderboard-view.tsx
"use client"

import * as React from "react"

import { formatCurrency, formatNumber } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { PeriodSelect, type Period } from "@/components/ui/period-select"
import { RankList } from "@/components/ui/rank-list"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { Widget } from "@/components/ui/widget"

import {
  DEFAULT_PERIOD,
  METRICS,
  PERIOD_OPTIONS,
  PERIODS,
  PLACES,
  ranked,
  type LeaderPeriod,
  type LeaderRow,
  type Metric,
} from "../vocabulary"

const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })

/** Whichever period the select handed back, as long as this board has one. */
const asPeriod = (value: Period): LeaderPeriod | null =>
  (PERIODS as readonly string[]).includes(value) ? (value as LeaderPeriod) : null

/**
 * The board: a period, a measure, the top three, and everyone else.
 *
 * Every window arrives already counted, so switching either control re-sorts an
 * array the browser already holds rather than asking the server again — which
 * is also what lets the page render its true first frame on the server.
 *
 * `allowCustom` is off: a window this page has not counted has no rows behind
 * it, and offering one would mean showing an empty board for a range a reader
 * legitimately asked for.
 */
export function LeaderboardView({
  board,
  podiumSize,
}: {
  board: Record<LeaderPeriod, LeaderRow[]>
  podiumSize: number
}) {
  const [period, setPeriod] = React.useState<LeaderPeriod>(DEFAULT_PERIOD)
  const [metric, setMetric] = React.useState<Metric>("revenue")

  const rows = ranked(board[period], metric)
  const podium = rows.slice(0, podiumSize)
  const unit = METRICS.find((entry) => entry.value === metric)!.unit

  const reading = (row: LeaderRow) =>
    metric === "revenue" ? money(row.revenueCents) : formatNumber(row[metric])
  const value = (row: LeaderRow) => (metric === "revenue" ? row.revenueCents : row[metric])

  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-wrap items-center gap-2">
        <PeriodSelect
          aria-label="Period"
          size="sm"
          value={period}
          options={PERIOD_OPTIONS}
          allowCustom={false}
          onValueChange={(next) => {
            const chosen = asPeriod(next)
            if (chosen) setPeriod(chosen)
          }}
        />
        <SegmentedControl
          aria-label="Measure"
          size="sm"
          options={METRICS.map(({ value: key, label }) => ({ value: key, label }))}
          value={metric}
          onValueChange={(next) => setMetric(next as Metric)}
        />
      </div>

      <section aria-label="Podium">
        <StatCardGroup columns={3}>
          {podium.map((row, index) => (
            <StatCard
              key={row.id}
              label={
                <span className="flex items-center gap-2">
                  {/* The place is a word, not a colour or a position on the
                      page: the podium reads the same in a screen reader and on
                      a phone, where the three cards stack. */}
                  <Badge variant={index === 0 ? "default" : "secondary"}>{PLACES[index]}</Badge>
                  <span className="truncate">{row.name}</span>
                </span>
              }
              value={reading(row)}
              description={unit}
              footer={row.email}
            />
          ))}
        </StatCardGroup>
      </section>

      <Widget
        title="Full ranking"
        description={`Every teammate, by ${METRICS.find((entry) => entry.value === metric)!.label.toLowerCase()}`}
        footer="Revenue and deals are credited through the account's owner, so an order counts once. Tickets are the closed ones this window holds."
      >
        <RankList
          items={rows.map((row) => ({
            label: (
              <span className="flex min-w-0 flex-col">
                <span className="truncate">{row.name}</span>
                <span className="text-xs text-muted-foreground">{row.email}</span>
              </span>
            ),
            value: value(row),
          }))}
          format={(amount) => (metric === "revenue" ? money(amount) : formatNumber(amount))}
          showRank
        />
      </Widget>
    </div>
  )
}