Skip to contentVibraUI

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

Install

npx shadcn@latest add @vibra/widget-academy-courses-catalogue-figures

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

Source

app/academy/courses/components/catalogue-figures.tsx
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>
  )
}
app/academy/courses/data.ts
/**
 * 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