Skip to contentVibraUI

Agents

Who closed the most over the window, with their median first reply and their rating; reads agentLeaderboard().

Preview

Install

npx shadcn@latest add @vibra/widget-support-overview-agents-leaderboard

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

Source

app/support/components/agents-leaderboard.tsx
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>
  )
}
app/support/data.ts
/**
 * 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