Catalogue figures
The courses in the catalogue and the categories they cover, everyone ever enrolled, the share who finished, and the average rating; reads catalogueTotals().
Preview
import { BookOpenIcon, GraduationCapIcon, StarIcon, UsersIcon } from "lucide-react"
import { formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type CatalogueTotals } from "../data"
/**
* The catalogue in four numbers: its size, everyone it has enrolled, the
* share of them who finished, and how it is rated. No state, so it renders on
* the server.
*/
export function CatalogueFigures({ totals }: { totals: CatalogueTotals }) {
const completion = totals.learners === 0 ? 0 : totals.finished / totals.learners
return (
<StatCardGroup data-widget="widget-academy-courses-catalogue-figures" columns={4} role="region" aria-label="The catalogue in figures">
<StatCard
label="Courses"
value={formatNumber(totals.courses)}
description={`Across ${totals.categories} categories`}
icon={<BookOpenIcon />}
/>
<StatCard
label="Enrolled"
value={formatNumber(totals.learners)}
description="Learners, all time"
icon={<UsersIcon />}
/>
<StatCard
label="Completion"
value={formatPercent(completion, { maximumFractionDigits: 0 })}
description={`${formatNumber(totals.finished)} finished`}
icon={<GraduationCapIcon />}
/>
<StatCard
label="Rating"
value={totals.rating.toFixed(1)}
description={`${totals.topRated} rated 4.5 or more`}
icon={<StarIcon />}
/>
</StatCardGroup>
)
}Install
$
npx shadcn@latest add @vibra/widget-academy-courses-catalogue-figuresNeeds the @vibra registry in your components.json — set it up once.
Source
import { BookOpenIcon, GraduationCapIcon, StarIcon, UsersIcon } from "lucide-react"
import { formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type CatalogueTotals } from "../data"
/**
* The catalogue in four numbers: its size, everyone it has enrolled, the
* share of them who finished, and how it is rated. No state, so it renders on
* the server.
*/
export function CatalogueFigures({ totals }: { totals: CatalogueTotals }) {
const completion = totals.learners === 0 ? 0 : totals.finished / totals.learners
return (
<StatCardGroup data-widget="widget-academy-courses-catalogue-figures" columns={4} role="region" aria-label="The catalogue in figures">
<StatCard
label="Courses"
value={formatNumber(totals.courses)}
description={`Across ${totals.categories} categories`}
icon={<BookOpenIcon />}
/>
<StatCard
label="Enrolled"
value={formatNumber(totals.learners)}
description="Learners, all time"
icon={<UsersIcon />}
/>
<StatCard
label="Completion"
value={formatPercent(completion, { maximumFractionDigits: 0 })}
description={`${formatNumber(totals.finished)} finished`}
icon={<GraduationCapIcon />}
/>
<StatCard
label="Rating"
value={totals.rating.toFixed(1)}
description={`${totals.topRated} rated 4.5 or more`}
icon={<StarIcon />}
/>
</StatCardGroup>
)
}/**
* What /academy/courses reads. The catalogue is `db.courses`; each course's
* instructor is the `db.members` row it names. The four figures add the
* catalogue's own numbers up: the learners every course has ever enrolled,
* and how many of them its completion rate says finished — the rule the
* overview's success rate reads, so the two pages agree. "Now" is
* `REFERENCE_DATE`.
*
* The client islands import only the types below. The words the page puts on
* a category or a level, and the href of a course's page, are the dashboard's
* own, in `@/lib/dashboards/academy/vocabulary`, so nothing
* that reaches `db` is ever pulled into the browser.
*/
import { formatDate, getInitials } from "@/lib/format"
import { courseHref } from "@/lib/dashboards/academy/vocabulary"
import { REFERENCE_DATE, db, type Course, type Member } from "@/lib/sample-data"
/** One course as the catalogue prints it. */
export type CatalogueRow = {
id: string
title: string
/** The tile's letter: a course has no logo, so its initial in ink stands for it. */
initial: string
category: Course["category"]
level: Course["level"]
instructor: string
instructorAvatar?: string
lessons: number
/** Learners the course has ever enrolled. */
enrolled: number
/** The share of them who finished, 0–1. */
completionRate: number
rating: number
/** The course's own page. */
href: string
}
/** Every course in the catalogue; the table sorts, filters and pages them itself. */
export function catalogue(): CatalogueRow[] {
// Read per call, never held at module scope: an instructor renamed since the
// server started is the name on the next render.
const members = new Map(db.members.all().map((member) => [member.id, member]))
return db.courses.all().map((course) => {
const instructor = members.get(course.instructorId)
return {
id: course.id,
title: course.title,
initial: course.title.charAt(0).toUpperCase(),
category: course.category,
level: course.level,
instructor: instructor?.name ?? "Academy staff",
instructorAvatar: instructor?.avatarUrl,
lessons: course.lessons,
enrolled: course.enrolled,
completionRate: course.completionRate,
rating: course.rating,
href: courseHref(course.id),
}
})
}
export type CatalogueTotals = {
courses: number
categories: number
/** Every enrolment the catalogue has ever taken. */
learners: number
/** How many of those finished, course by course. */
finished: number
/** The mean of the courses' ratings, out of five. */
rating: number
/** Courses rated 4.5 or more. */
topRated: number
}
/** The four figures over the catalogue. */
export function catalogueTotals(): CatalogueTotals {
const courses = db.courses.all()
return {
courses: courses.length,
categories: new Set(courses.map((course) => course.category)).size,
learners: courses.reduce((sum, course) => sum + course.enrolled, 0),
finished: courses.reduce((sum, course) => sum + Math.round(course.enrolled * course.completionRate), 0),
rating: courses.length === 0 ? 0 : courses.reduce((sum, course) => sum + course.rating, 0) / courses.length,
topRated: courses.filter((course) => course.rating >= 4.5).length,
}
}
/** The line under the title: how many courses, and when they were read. */
export function lastUpdated(): string {
return `${db.courses.all().length} courses · synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}
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 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 }))
}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 Course catalogue page