Agents
Who closed the most over the window, with their median first reply and their rating; reads agentLeaderboard().
Preview
import { formatDuration, formatNumber } from "@/lib/format"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"
import { agentLeaderboard, WINDOW_DAYS, type AgentRow } from "../data"
const COLUMNS: SimpleTableColumn<AgentRow>[] = [
{
key: "name",
header: "Agent",
cell: (row) => <UserCell name={row.name} email={row.email} src={row.avatarUrl} size="sm" />,
},
{
key: "resolved",
header: "Resolved",
align: "right",
cell: (row) => formatNumber(row.resolved, { maximumFractionDigits: 0 }),
},
{
key: "first",
header: "First reply",
align: "right",
cell: (row) => formatDuration(row.firstResponseMinutes * 60_000),
},
{
key: "csat",
header: "CSAT",
align: "right",
cell: (row) =>
row.csat === null ? <span className="text-muted-foreground">—</span> : row.csat.toFixed(1),
},
]
export function AgentsLeaderboard() {
return (
<Widget
data-widget="widget-support-overview-agents-leaderboard"
title="Agents"
description={`Who closed the most over ${WINDOW_DAYS} days`}
className="h-full"
contentClassName="px-0"
footer="First reply is the median across the tickets each agent closed."
>
<SimpleTable
columns={COLUMNS}
rows={agentLeaderboard()}
rowKey="id"
size="sm"
caption="Agents by tickets resolved"
/>
</Widget>
)
}Install
$
npx shadcn@latest add @vibra/widget-support-overview-agents-leaderboardNeeds the @vibra registry in your components.json — set it up once.
Source
import { formatDuration, formatNumber } from "@/lib/format"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"
import { agentLeaderboard, WINDOW_DAYS, type AgentRow } from "../data"
const COLUMNS: SimpleTableColumn<AgentRow>[] = [
{
key: "name",
header: "Agent",
cell: (row) => <UserCell name={row.name} email={row.email} src={row.avatarUrl} size="sm" />,
},
{
key: "resolved",
header: "Resolved",
align: "right",
cell: (row) => formatNumber(row.resolved, { maximumFractionDigits: 0 }),
},
{
key: "first",
header: "First reply",
align: "right",
cell: (row) => formatDuration(row.firstResponseMinutes * 60_000),
},
{
key: "csat",
header: "CSAT",
align: "right",
cell: (row) =>
row.csat === null ? <span className="text-muted-foreground">—</span> : row.csat.toFixed(1),
},
]
export function AgentsLeaderboard() {
return (
<Widget
data-widget="widget-support-overview-agents-leaderboard"
title="Agents"
description={`Who closed the most over ${WINDOW_DAYS} days`}
className="h-full"
contentClassName="px-0"
footer="First reply is the median across the tickets each agent closed."
>
<SimpleTable
columns={COLUMNS}
rows={agentLeaderboard()}
rowKey="id"
size="sm"
caption="Agents by tickets resolved"
/>
</Widget>
)
}/**
* What this page reads. There is no ticket entity, so the queue is built once
* from the two entities that do exist: every ticket takes its subject and its
* kind from a `db.notifications` row, its requester from a `db.customers` row
* and its agent from a `db.members` row, and only the timings come from
* `seeded("dashboard-support")`. Nothing is a literal and nothing reads a
* clock: a ticket is resolved when its resolution time lands before
* `REFERENCE_DATE`, still open when it does not, so the queue below is
* exactly the work that is genuinely outstanding.
*/
import { formatDuration, formatNumber, getInitials } from "@/lib/format"
import {
db,
intBetween,
pickWeighted,
REFERENCE_DATE,
rowId,
seeded,
type Member,
type Notification,
} from "@/lib/sample-data"
const MINUTE_MS = 60_000
const DAY_MS = 86_400_000
/** The window the queue and every rate on this page are measured over. */
export const WINDOW_DAYS = 30
const WINDOW_START = REFERENCE_DATE.getTime() - WINDOW_DAYS * DAY_MS
export type TicketPriority = "urgent" | "high" | "medium" | "low"
/** What a notification's kind means for the ticket it becomes. */
const PRIORITY_BY_KIND: Record<Notification["kind"], TicketPriority> = {
security: "urgent",
billing: "high",
assignment: "medium",
system: "medium",
mention: "low",
}
/** How long first contact may take, in minutes, before the promise is missed. */
export const SLA_MINUTES: Record<TicketPriority, number> = {
urgent: 30,
high: 60,
medium: 240,
low: 480,
}
// How long a ticket of each priority takes to close, in hours, at best and worst.
const RESOLUTION_HOURS: Record<TicketPriority, readonly [number, number]> = {
urgent: [1, 14],
high: [2, 30],
medium: [4, 72],
low: [8, 140],
}
// How a rating falls when the promise was kept, and when it was missed.
const RATINGS: Record<"met" | "missed", readonly (readonly [number, number])[]> = {
met: [[5, 0.55], [4, 0.3], [3, 0.1], [2, 0.03], [1, 0.02]],
missed: [[5, 0.14], [4, 0.24], [3, 0.26], [2, 0.21], [1, 0.15]],
}
export type Ticket = {
id: string
subject: string
priority: TicketPriority
company: string
requester: string
requesterAvatar?: string
agent: Member
openedAt: Date
firstResponseMinutes: number
slaMet: boolean
status: "open" | "pending" | "resolved"
resolutionMinutes: number
csat?: number
}
const COUNT = 260
/** A ticket as drawn, with the id of the account that raised it beside the names it had then. */
type Drawn = Ticket & { requesterId: string }
let drawn: Drawn[] | undefined
/**
* The window's tickets, drawn once from the block's seed — when a page first
* asks, never when the module loads. What an agent or a requester is called is
* read off the rows per request (`tickets()`), so a teammate renamed since is
* the name on the leaderboard.
*/
function draws(): Drawn[] {
if (drawn) return drawn
const rand = seeded("dashboard-support")
const subjects = db.notifications.all()
const requesters = db.customers
.all()
.filter((customer) => customer.status === "active" || customer.status === "trial")
// A viewer cannot answer a ticket, and a deactivated teammate is gone.
const agents = db.members
.all()
.filter((member) => member.status === "active" && member.role !== "viewer")
drawn = Array.from({ length: COUNT }, (_, index) => {
const subject = subjects[index % subjects.length]
const requester = requesters[intBetween(rand, 0, requesters.length - 1)]
const agent = agents[intBetween(rand, 0, agents.length - 1)]
const priority = PRIORITY_BY_KIND[subject.kind]
// Tickets arrive on working days: a weekend arrival mostly slides to the
// Tuesday after it.
let day = intBetween(rand, 0, WINDOW_DAYS - 1)
const at = new Date(WINDOW_START + day * DAY_MS)
if ((at.getUTCDay() === 0 || at.getUTCDay() === 6) && rand() < 0.55) {
day = Math.min(WINDOW_DAYS - 1, day + 2)
}
// Inside the working day, roughly 08:00 to 20:00 UTC.
const openedAt = new Date(
WINDOW_START + day * DAY_MS + (8 + rand() * 12) * 3_600_000
)
const sla = SLA_MINUTES[priority]
// Most first replies beat the promise; the rest miss it by a long way.
const slaMet = rand() < 0.87
const firstResponseMinutes = slaMet
? Math.max(2, Math.round(sla * (0.15 + rand() * 0.7)))
: Math.round(sla * (1.1 + rand() * 1.9))
const [fastest, slowest] = RESOLUTION_HOURS[priority]
const resolutionMinutes = Math.round((fastest + rand() * (slowest - fastest)) * 60)
const closesAt = openedAt.getTime() + resolutionMinutes * MINUTE_MS
const status: Ticket["status"] =
closesAt <= REFERENCE_DATE.getTime() ? "resolved" : rand() < 0.4 ? "pending" : "open"
return {
id: rowId("TCK", index + 1),
subject: subject.title,
priority,
requesterId: requester.id,
company: requester.company,
requester: requester.name,
requesterAvatar: requester.avatarUrl,
agent,
openedAt,
firstResponseMinutes,
slaMet,
status,
resolutionMinutes,
// Around three in five closed tickets come back with a rating.
csat:
status === "resolved" && rand() < 0.62
? pickWeighted(rand, RATINGS[slaMet ? "met" : "missed"])
: undefined,
}
})
return drawn
}
/** Every ticket in the window, its requester and agent as the rows say they are now. */
function tickets(): Ticket[] {
const customers = new Map(db.customers.all().map((customer) => [customer.id, customer]))
const members = new Map(db.members.all().map((member) => [member.id, member]))
return draws().map(({ requesterId, ...ticket }) => {
const requester = customers.get(requesterId)
return {
...ticket,
company: requester?.company ?? ticket.company,
requester: requester?.name ?? ticket.requester,
requesterAvatar: requester ? requester.avatarUrl : ticket.requesterAvatar,
agent: members.get(ticket.agent.id) ?? ticket.agent,
}
})
}
const resolved = (all: Ticket[]) => all.filter((ticket) => ticket.status === "resolved")
const outstanding = (all: Ticket[]) => all.filter((ticket) => ticket.status !== "resolved")
const median = (values: number[]): number => {
if (values.length === 0) return 0
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)]
}
export type VolumePoint = { date: string; opened: number; resolved: number }
/** Tickets opened and closed on each day of the window, oldest first. */
export function ticketVolume(): VolumePoint[] {
const days = Array.from({ length: WINDOW_DAYS }, (_, index) => ({
date: new Date(WINDOW_START + index * DAY_MS).toISOString().slice(0, 10),
opened: 0,
resolved: 0,
}))
const indexOf = (time: number) => Math.floor((time - WINDOW_START) / DAY_MS)
for (const ticket of tickets()) {
const opened = indexOf(ticket.openedAt.getTime())
if (opened >= 0 && opened < days.length) days[opened].opened += 1
if (ticket.status !== "resolved") continue
const closed = indexOf(ticket.openedAt.getTime() + ticket.resolutionMinutes * MINUTE_MS)
if (closed >= 0 && closed < days.length) days[closed].resolved += 1
}
return days
}
/** The share of tickets answered inside their priority's promise, as a percentage. */
export function slaAttainment(): number {
const all = tickets()
return Math.round((all.filter((ticket) => ticket.slaMet).length / all.length) * 1000) / 10
}
/** The average rating out of five, and how many people left one. */
export function csat(): { score: number; responses: number } {
const rated = resolved(tickets()).filter((ticket) => ticket.csat !== undefined)
const total = rated.reduce((sum, ticket) => sum + (ticket.csat ?? 0), 0)
return {
score: rated.length === 0 ? 0 : Math.round((total / rated.length) * 10) / 10,
responses: rated.length,
}
}
/** What is still outstanding, longest waiting first. */
export function queue(limit = 8): Ticket[] {
return outstanding(tickets())
.sort((a, b) => a.openedAt.getTime() - b.openedAt.getTime())
.slice(0, limit)
}
export type AgentRow = {
id: string
name: string
email: string
avatarUrl?: string
resolved: number
firstResponseMinutes: number
csat: number | null
}
/** Who closed the most, with the reply time and rating behind it. */
export function agentLeaderboard(limit = 6): AgentRow[] {
const rows = new Map<string, { member: Member; resolved: Ticket[] }>()
for (const ticket of resolved(tickets())) {
const row = rows.get(ticket.agent.id) ?? { member: ticket.agent, resolved: [] }
row.resolved.push(ticket)
rows.set(ticket.agent.id, row)
}
return [...rows.values()]
.map(({ member, resolved }) => {
const rated = resolved.filter((ticket) => ticket.csat !== undefined)
return {
id: member.id,
name: member.name,
email: member.email,
avatarUrl: member.avatarUrl,
resolved: resolved.length,
firstResponseMinutes: median(resolved.map((ticket) => ticket.firstResponseMinutes)),
csat:
rated.length === 0
? null
: Math.round(
(rated.reduce((sum, ticket) => sum + (ticket.csat ?? 0), 0) / rated.length) * 10
) / 10,
}
})
.sort((a, b) => b.resolved - a.resolved)
.slice(0, limit)
}
export type SupportStat = { key: string; label: string; value: string; description: string }
/** The four headline numbers over the window. */
export function supportStats(): SupportStat[] {
const all = tickets()
const open = outstanding(all)
const closed = resolved(all)
const urgent = open.filter((ticket) => ticket.priority === "urgent").length
return [
{
key: "open",
label: "Open now",
value: formatNumber(open.length, { maximumFractionDigits: 0 }),
description: urgent > 0 ? `${urgent} of them urgent` : "none of them urgent",
},
{
key: "first-response",
label: "Median first response",
value: formatDuration(
median(all.map((ticket) => ticket.firstResponseMinutes)) * MINUTE_MS
),
description: "across every priority",
},
{
key: "resolution",
label: "Median resolution",
value: formatDuration(median(closed.map((ticket) => ticket.resolutionMinutes)) * MINUTE_MS),
description: "from opened to closed",
},
{
key: "resolved",
label: `Resolved in ${WINDOW_DAYS} days`,
value: formatNumber(closed.length, { maximumFractionDigits: 0 }),
description: `out of ${formatNumber(all.length, { maximumFractionDigits: 0 })} raised`,
},
]
}
/** 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 }
}
/** The moment every "waiting for" on this page is measured against. */
export const NOW = REFERENCE_DATE
/**
* How long a ticket has been waiting, measured against `NOW` rather than the
* reader's clock — so the queue reads the same on a server render, in a test,
* and on a machine whose clock is wrong.
*/
export function waitedFor(at: Date): string {
return formatDuration(NOW.getTime() - at.getTime())
}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 Support dashboard page