Skip to contentVibraUI

Leaderboard

The five members with the most points this term, ranked; reads leaderboard().

Preview

Install

npx shadcn@latest add @vibra/widget-academy-overview-leaderboard

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

Source

app/academy/components/leaderboard.tsx
import { formatNumber } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { RankList } from "@/components/ui/rank-list"

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

/** The five members with the most points this term. */
export function Leaderboard({ rows }: { rows: LeaderRow[] }) {
  return (
    <Card data-widget="widget-academy-overview-leaderboard" role="region" aria-label="Leaderboard" className="h-full">
      <CardHeader>
        <CardTitle>Leaderboard</CardTitle>
        <CardDescription>Points this term.</CardDescription>
      </CardHeader>
      <CardContent>
        <RankList
          items={rows.map((row) => ({
            label: row.name,
            value: row.points,
            icon: (
              <span
                aria-hidden="true"
                className="flex size-5 items-center justify-center rounded-full bg-foreground text-xs font-medium text-background"
              >
                {row.initials}
              </span>
            ),
          }))}
          format={(value) => `${formatNumber(value)} pts`}
          size="sm"
        />
      </CardContent>
    </Card>
  )
}
app/academy/data.ts
/**
 * What this page reads. The catalogue, the learner's progress through it and
 * the people on the leaderboard come from `db.courses` and `db.members`; the
 * figures no row records — the day streak, a member's points, the activity
 * split and the completion curve — are generated once from
 * `seeded("dashboard-academy")`. "Now" is `REFERENCE_DATE`.
 *
 * The client islands import only the types below. The words the page puts on
 * a category or a level are the dashboard's own, in
 * `@/lib/dashboards/academy/vocabulary`, so nothing that
 * reaches `db` is ever pulled into the browser.
 */
import { getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, seeded, type Course, type Member } from "@/lib/sample-data"

export type CourseCategory = Course["category"]
export type CourseLevel = Course["level"]

/** One row of the catalogue as the table prints it. Exported because the action returns one. */
export type CourseRow = {
  id: string
  title: string
  /** The tile's letter, in place of the platform logo the reference shows. */
  initial: string
  category: CourseCategory
  level: CourseLevel
  rating: number
  lessons: number
  /** 0 is not started, 1 is finished, anything between is under way. */
  progress: number
  instructorName: string
  updatedAt: Date
}

// Read per call, never held at module scope: an instructor renamed since the
// server started is the name on the next render.
const membersById = () => new Map(db.members.all().map((member) => [member.id, member]))

// The learner: whoever owns this workspace. Declared here, above the first
// selector that reads it, so nothing below has to look further down the file.
function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

export function toRow(course: Course, members = membersById()): CourseRow {
  return {
    id: course.id,
    title: course.title,
    initial: course.title.charAt(0).toUpperCase(),
    category: course.category,
    level: course.level,
    rating: course.rating,
    lessons: course.lessons,
    progress: course.progress,
    instructorName: members.get(course.instructorId)?.name ?? "Academy staff",
    updatedAt: course.updatedAt,
  }
}

const underWay = (course: Course) => course.progress > 0 && course.progress < 1

/**
 * The catalogue in the order a learner reads it: what is under way first,
 * newest touch on top, then what has not been opened, then what is done.
 */
export function courseRows(): CourseRow[] {
  const members = membersById()
  const rank = (course: Course) => (underWay(course) ? 0 : course.progress === 0 ? 1 : 2)
  return db.courses
    .all()
    .sort((a, b) => rank(a) - rank(b) || b.updatedAt.getTime() - a.updatedAt.getTime())
    .map((course) => toRow(course, members))
}

const SEED = "dashboard-academy"

type Draws = {
  streakDays: number
  /** Each member's leaderboard points, by id. */
  points: Map<string, number>
  activityWeights: number[]
  termHours: number
  curve: number[]
}

let drawn: Draws | undefined

/**
 * Figures the rows cannot carry, drawn once in a fixed order so each is the
 * same number on every render: the streak first, then the leaderboard points,
 * then the activity split, then the curve. Drawn when a page first asks, never
 * when the module loads. The members themselves are read per request, so a
 * rename or a deactivation shows on the leaderboard straight away.
 */
function draws(): Draws {
  if (drawn) return drawn
  const rand = seeded(SEED)
  const streakDays = 5 + Math.floor(rand() * 14)
  const points = new Map(db.members.all().map((member) => [member.id, 1_800 + Math.floor(rand() * 3_400)]))
  const activityWeights = [0.55 + rand() * 0.15, 0.2 + rand() * 0.1, 0.06 + rand() * 0.06]
  const termHours = 120 + Math.floor(rand() * 80)
  const curve = Array.from({ length: 12 }, (_, index) => 0.38 + (index / 11) * 0.34 + (rand() - 0.5) * 0.06)
  drawn = { streakDays, points, activityWeights, termHours, curve }
  return drawn
}

/** A member's points; one who joined after they were drawn starts at none. */
const pointsOf = (id: string) => draws().points.get(id) ?? 0

/** A term is four months of the curve. */
const TERM_MONTHS = 4

const DAY_MS = 86_400_000

// "This month" is the trailing thirty days, not the calendar month: on the
// fourth of a month a calendar rule would show a learner three days of work.
const MONTH_AGO = new Date(REFERENCE_DATE.getTime() - 30 * DAY_MS)

export type LearnerSummary = {
  firstName: string
  inProgress: number
  /** Lessons watched in the last thirty days, at forty minutes each: lessons × progress on every course touched in that window. */
  hoursLast30Days: number
  certificates: number
  streakDays: number
}

/** The welcome card's four chips, three of them rules over the learner's own courses. */
export function learnerSummary(): LearnerSummary {
  const courses = db.courses.all()
  const minutes = courses
    .filter((course) => course.progress > 0 && course.updatedAt.getTime() >= MONTH_AGO.getTime())
    .reduce((sum, course) => sum + course.lessons * course.progress * 40, 0)
  return {
    firstName: ownerRow().name.split(" ")[0],
    inProgress: courses.filter(underWay).length,
    hoursLast30Days: Math.round(minutes / 60),
    certificates: courses.filter((course) => course.progress === 1).length,
    streakDays: draws().streakDays,
  }
}

export type LearningPath = { id: string; title: string; done: number; total: number }

// A path is a bundle of categories; its modules are the courses in them.
const PATHS: readonly { id: string; title: string; categories: CourseCategory[] }[] = [
  { id: "full-stack", title: "Full-stack developer", categories: ["Web", "Backend"] },
  { id: "product-designer", title: "Product designer", categories: ["Design", "Product"] },
]

/** The two paths, each counted off the courses in its categories. */
export function learningPaths(): LearningPath[] {
  const courses = db.courses.all()
  return PATHS.map((path) => {
    const modules = courses.filter((course) => path.categories.includes(course.category))
    return {
      id: path.id,
      title: path.title,
      done: modules.filter((course) => course.progress === 1).length,
      total: modules.length,
    }
  })
}

export type LeaderRow = { id: string; name: string; initials: string; points: number }

/** The five members with the most points. */
export function leaderboard(): LeaderRow[] {
  return db.members
    .all()
    .filter((member) => member.status === "active")
    .map((member) => ({
      id: member.id,
      name: member.name,
      initials: getInitials(member.name),
      points: pointsOf(member.id),
    }))
    .sort((a, b) => b.points - a.points)
    .slice(0, 5)
}

export type SuccessRate = { rate: number; previous: number; learners: number; passing: number }

/** The share of every enrolment that finished, against the term before. */
export function successRate(): SuccessRate {
  const courses = db.courses.all()
  const learners = courses.reduce((sum, course) => sum + course.enrolled, 0)
  const passing = courses.reduce((sum, course) => sum + Math.round(course.enrolled * course.completionRate), 0)
  const rate = learners === 0 ? 0 : passing / learners
  // The term before sat where the completion curve sat a term ago, relative to
  // where it is now: the same seeded series the chart draws, so the headline's
  // delta and the curve's climb are one story rather than two numbers.
  const { curve } = draws()
  const termAgo = curve[curve.length - 1 - TERM_MONTHS] / curve[curve.length - 1]
  return { rate, previous: Math.min(1, Math.max(0, rate * termAgo)), learners, passing }
}

export type ProgressSplit = { inProgress: number; completed: number; total: number }

/** How the catalogue splits for this learner. */
export function progressSplit(): ProgressSplit {
  const courses = db.courses.all()
  return {
    inProgress: courses.filter(underWay).length,
    completed: courses.filter((course) => course.progress === 1).length,
    total: courses.length,
  }
}

/** Where the term's hours went, in hours; the donut works the shares out. */
export function activityMix(): { name: string; value: number }[] {
  const { activityWeights, termHours } = draws()
  const total = activityWeights.reduce((sum, weight) => sum + weight, 0)
  return ["Mentoring", "Organisation", "Planning"].map((name, index) => ({
    name,
    value: Math.round((activityWeights[index] / total) * termHours),
  }))
}

const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" })
const MONTH_YEAR = new Intl.DateTimeFormat("en-US", { month: "short", year: "numeric", timeZone: "UTC" })

/** The first of the month `back` months before the reference month. */
function monthStart(back: number): Date {
  return new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() - back, 1))
}

export type CompletionPoint = { month: string; rate: number }

/** Average completion by month over the last twelve complete months, oldest first. */
export function completionByMonth(): CompletionPoint[] {
  return draws().curve.map((rate, index) => ({
    month: MONTH.format(monthStart(12 - index)),
    rate: Math.round(rate * 1000) / 1000,
  }))
}

/** "Sep 2025 – Aug 2026": the window the curve covers. */
export function completionRange(): string {
  return `${MONTH_YEAR.format(monthStart(12))} – ${MONTH_YEAR.format(monthStart(1))}`
}

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

/** The person looking at the page — the learner — whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

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 Academy dashboard page