Leaderboard
The five members with the most points this term, ranked; reads leaderboard().
Preview
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>
)
}Install
$
npx shadcn@latest add @vibra/widget-academy-overview-leaderboardNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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