/people/leaderboardLeaderboard
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.
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
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>
)
}Install
npx shadcn@latest add @vibra/people-leaderboardNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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`
}"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 } }
}/**
* 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))
}"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>
)
}