Skip to contentVibraUI
Part of the Academy dashboardinstalls at /academy

Academy dashboard

A learner's home: a welcome card with their four numbers, two learning paths, a leaderboard, the cohort's success rate, the catalogue by month, and every course with a Continue button that writes back.

Open the live page

Installs at /academy. The page is a server component inside AppShell: it reads the catalogue through db.courses and the people on it through db.members, works the learner's four chips, the two paths, the success rate and the progress split out as rules over those rows, and hands plain rows to the islands that need state. The figures no row records — the day streak, a member's points, the activity split and the twelve-month completion curve — come from seeded("academy-overview") once, in data.ts, so they are the same numbers on every render. The catalogue lists open courses first, newest touch on top; Continue calls continueCourse, which moves the row a tenth further and reports the new figure in the status line, while a course that is already complete is refused in an alert above the rows. No course tile carries a logo: the initial in an ink square stands for it. Composes AppShell, PageHeader, DashboardGrid, Card, StatCard, MetricValue, Progress, RankList, DescriptionList, ChartCard, AreaChart, DonutChart, DataTable, Rating, Badge, Callout and Button.

Preview

Install

npx shadcn@latest add @vibra/academy-overview

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

Source

app/academy/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { ActivityDonut } from "./components/activity-donut"
import { CompletionChart } from "./components/completion-chart"
import { CoursesTable } from "./components/courses-table"
import { Leaderboard } from "./components/leaderboard"
import { LearningPaths } from "./components/learning-paths"
import { ProgressTiles } from "./components/progress-tiles"
import { SuccessRatePanel } from "./components/success-rate-panel"
import { WelcomeCard } from "./components/welcome-card"
import {
  activityMix,
  completionByMonth,
  completionRange,
  courseRows,
  currentUser,
  leaderboard,
  learnerSummary,
  learningPaths,
  progressSplit,
  shellNotifications,
  successRate,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/academy/nav"

/**
 * The academy. The page is a server component: it reads the catalogue and
 * the learner's progress through `db`, works the headline numbers out, and
 * hands plain rows to the islands that need state — the charts and the table
 * whose Continue button writes back.
 */
export default function AcademyPage() {
  const summary = learnerSummary()

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.overview}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Academy"
        description="Your courses, your paths, and how the whole cohort is doing."
        meta={`${summary.inProgress} in progress · ${summary.certificates} completed`}
      />

      <DashboardGrid>
        <DashboardGridItem colSpan={{ base: 12, lg: 6 }}>
          {/* Linked: the page draws the catalogue under the card, so its button scrolls there. */}
          <WelcomeCard summary={summary} linked />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, md: 6, lg: 3 }}>
          <LearningPaths paths={learningPaths()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, md: 6, lg: 3 }}>
          <Leaderboard rows={leaderboard()} />
        </DashboardGridItem>

        <DashboardGridItem colSpan={{ base: 12, md: 6, lg: 4 }}>
          <SuccessRatePanel rate={successRate()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, md: 6, lg: 4 }}>
          <ProgressTiles split={progressSplit()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
          <ActivityDonut mix={activityMix()} />
        </DashboardGridItem>

        <DashboardGridItem colSpan={12}>
          <CompletionChart points={completionByMonth()} range={completionRange()} />
        </DashboardGridItem>

        <DashboardGridItem colSpan={12}>
          <CoursesTable rows={courseRows()} />
        </DashboardGridItem>
      </DashboardGrid>
    </AppShell>
  )
}
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 }
}
app/academy/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { REFERENCE_DATE, db, type Result } from "@/lib/sample-data"

import { toRow, type CourseRow } from "./data"

/**
 * The two things this page changes. Server actions, so the page can stay a
 * server component and still hand its client islands something to call, and
 * `Result` so every caller reads one success-or-error shape.
 */

export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

// One press is one lesson block: a tenth of the course.
const STEP = 0.1

/**
 * Moves the learner a tenth further through a course. A finished course has
 * nowhere further to go, so it is refused rather than wrapped past the end.
 */
export async function continueCourse(id: string): Promise<Result<CourseRow>> {
  const course = await db.courses.get(id)
  if (!course) {
    return { ok: false, error: { code: "not_found", message: `No course with id "${id}"` } }
  }
  if (course.progress >= 1) {
    return {
      ok: false,
      error: { code: "complete", message: `"${course.title}" is already complete — there is nothing left to continue.` },
    }
  }

  // Kept to a tenth, so 0.7 + 0.1 prints as 80% and not 80.00000000000001%.
  const progress = Math.min(1, Math.round((course.progress + STEP) * 10) / 10)
  const updated = await db.courses.update(id, { progress, updatedAt: REFERENCE_DATE })
  if (!updated.ok) return updated
  return { ok: true, data: toRow(updated.data) }
}
app/academy/components/activity-donut.tsx
"use client"

import { formatNumber } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { DonutChart } from "@/components/ui/donut-chart"

/**
 * Where the learner's time went. The shares arrive computed from the server
 * page; this is an island only because a chart draws in the browser.
 */
export function ActivityDonut({ mix }: { mix: { name: string; value: number }[] }) {
  return (
    <ChartCard
      data-widget="widget-academy-overview-activity-donut"
      title="Activity"
      description="How the term's hours were spent."
      height={208}
      className="h-full"
    >
      <DonutChart
        data={mix}
        height={208}
        innerRadius={52}
        centerLabel="Hours"
        valueFormatter={(value) => `${formatNumber(value)}h`}
      />
    </ChartCard>
  )
}
app/academy/components/completion-chart.tsx
"use client"

import { formatPercent } from "@/lib/format"
import { AreaChart } from "@/components/ui/area-chart"
import { ChartCard } from "@/components/ui/chart-card"

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

/** Average completion by month over the last year, one grey series. */
export function CompletionChart({ points, range }: { points: CompletionPoint[]; range: string }) {
  return (
    <ChartCard
      data-widget="widget-academy-overview-completion-chart"
      title="Completion by month"
      description="Average course completion across every learner."
      actions={<span className="text-xs tabular-nums text-muted-foreground">{range}</span>}
      height={260}
    >
      <AreaChart
        data={points}
        index="month"
        series={[{ key: "rate", label: "Completion" }]}
        valueFormatter={(value) => formatPercent(value, { maximumFractionDigits: 0 })}
        height={260}
        legend="none"
      />
    </ChartCard>
  )
}
app/academy/components/academy-vocabulary.ts
/**
 * The catalogue's anchor, so the welcome card's button can scroll to it. The
 * words the page puts on a category or a level are the dashboard's own, in
 * `@/lib/dashboards/academy/vocabulary`.
 */
export const COURSES_ID = "academy-courses"
app/academy/components/course-columns.tsx
"use client"

import { ChevronRightIcon, PlayIcon, RotateCcwIcon } from "lucide-react"

import { CATEGORY_LABELS, LEVEL_LABELS } from "@/lib/dashboards/academy/vocabulary"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  DataTableColumnHeader,
  DataTableRowActions,
  type DataTableColumnDef,
} from "@/components/ui/data-table"
import { Progress } from "@/components/ui/progress"
import { Rating } from "@/components/ui/rating"

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

/**
 * The catalogue's columns. The last one is the row's own control: a course
 * under way gets a Continue button, one not yet opened a menu that starts it,
 * and a finished one a menu whose only entry asks the server — which refuses,
 * and says why where the reader is looking.
 */
export function courseColumns(onContinue: (row: CourseRow) => void): DataTableColumnDef<CourseRow>[] {
  return [
    {
      accessorKey: "title",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Course" />,
      cell: ({ row }) => (
        <span className="flex items-center gap-3">
          <span
            aria-hidden="true"
            data-slot="course-initial"
            className="flex size-8 shrink-0 items-center justify-center rounded-md bg-foreground text-sm font-semibold text-background"
          >
            {row.original.initial}
          </span>
          <span className="flex min-w-0 flex-col">
            <span className="truncate font-medium">{row.original.title}</span>
            <span className="truncate text-xs text-muted-foreground">
              {`${LEVEL_LABELS[row.original.level] ?? row.original.level} · ${row.original.lessons} lessons · ${row.original.instructorName}`}
            </span>
          </span>
        </span>
      ),
      meta: { label: "Course" },
    },
    {
      accessorKey: "category",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Category" />,
      cell: ({ row }) => (
        <Badge variant="outline">{CATEGORY_LABELS[row.original.category] ?? row.original.category}</Badge>
      ),
      meta: { label: "Category" },
    },
    {
      accessorKey: "rating",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Score" />,
      cell: ({ row }) => (
        <span className="flex items-center gap-2">
          <Rating value={row.original.rating} size="sm" label={`${row.original.title} rating`} />
          <span className="text-xs tabular-nums text-muted-foreground">{row.original.rating.toFixed(1)}</span>
        </span>
      ),
      meta: { label: "Score" },
    },
    {
      accessorKey: "progress",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Progress" />,
      cell: ({ row }) => {
        const percent = Math.round(row.original.progress * 100)
        if (percent === 0) return <span className="text-sm text-muted-foreground">Not started</span>
        return (
          <span className="flex min-w-32 items-center gap-2">
            <Progress value={percent} aria-label={`${row.original.title} progress`} className="flex-1" />
            <span className="w-9 shrink-0 text-right text-xs tabular-nums text-muted-foreground">{`${percent}%`}</span>
          </span>
        )
      },
      meta: { label: "Progress" },
    },
    {
      id: "actions",
      header: () => <span className="sr-only">Actions</span>,
      enableSorting: false,
      enableHiding: false,
      cell: ({ row }) => {
        const course = row.original
        if (course.progress > 0 && course.progress < 1) {
          return (
            <Button
              size="sm"
              variant="outline"
              aria-label={`Continue ${course.title}`}
              onClick={() => onContinue(course)}
            >
              Continue
              <ChevronRightIcon data-icon="inline-end" />
            </Button>
          )
        }
        return (
          <DataTableRowActions
            label={`Open menu for ${course.title}`}
            actions={[
              course.progress === 0
                ? { label: "Start course", icon: <PlayIcon />, onSelect: () => onContinue(course) }
                : { label: "Retake course", icon: <RotateCcwIcon />, onSelect: () => onContinue(course) },
            ]}
          />
        )
      },
      meta: { align: "right", label: "Actions" },
    },
  ]
}
app/academy/components/courses-table.tsx
"use client"

import * as React from "react"

import { CATEGORY_LABELS } from "@/lib/dashboards/academy/vocabulary"
import { Callout } from "@/components/ui/callout"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DataTable } from "@/components/ui/data-table"

import { continueCourse } from "../actions"
import { type CourseRow } from "../data"

import { COURSES_ID } from "./academy-vocabulary"
import { courseColumns } from "./course-columns"

// The facet prints the badge's own words, so "Web development" is one thing
// in the filter menu and in the column.
const CATEGORY_FACET = {
  columnId: "category",
  title: "Category",
  options: Object.entries(CATEGORY_LABELS).map(([value, label]) => ({ value, label })),
}

/**
 * The catalogue. The rows arrive from the server page; this island owns them
 * from then on, so a Continue that the server accepts moves the row's bar
 * without a second read. The server's answer is printed here either way — a
 * refusal in an alert above the rows, a success in the status line under
 * them — and the two never share a slot.
 */
export function CoursesTable({ rows: initial }: { rows: CourseRow[] }) {
  // The title names the table too, so a screen reader announces it by name.
  const titleId = React.useId()
  const [rows, setRows] = React.useState(initial)
  const [notice, setNotice] = React.useState("")
  const [refusal, setRefusal] = React.useState("")

  // Only setters are closed over, which never change, so the columns holding
  // this are built once rather than on every keystroke in the search box.
  const advance = React.useCallback(async (row: CourseRow) => {
    setNotice("")
    setRefusal("")
    const result = await continueCourse(row.id)
    if (!result.ok) return setRefusal(result.error.message)
    setRows((current) => current.map((entry) => (entry.id === row.id ? result.data : entry)))
    setNotice(`${result.data.title} is now ${Math.round(result.data.progress * 100)}% complete.`)
  }, [])

  const columns = React.useMemo(() => courseColumns(advance), [advance])

  return (
    <Card data-widget="widget-academy-overview-courses-table" id={COURSES_ID} role="region" aria-label="Courses" className="scroll-mt-4">
      <CardHeader>
        <CardTitle id={titleId}>Courses</CardTitle>
        <CardDescription>The catalogue, with what you have open at the top.</CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-3">
        {refusal ? (
          <Callout variant="danger" role="alert">
            {refusal}
          </Callout>
        ) : null}

        <DataTable
          aria-labelledby={titleId}
          columns={columns}
          data={rows}
          searchKey="title"
          searchPlaceholder="Search courses"
          facets={[CATEGORY_FACET]}
          enableRowSelection={false}
          getRowId={(row) => row.id}
          emptyMessage="No courses match these filters."
        />

        <p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
          {notice}
        </p>
      </CardContent>
    </Card>
  )
}
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/components/learning-paths.tsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"

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

/**
 * The two paths a learner can be on, each a bundle of the catalogue's
 * categories and counted off the courses in them. No state, so it renders on
 * the server.
 */
export function LearningPaths({ paths }: { paths: LearningPath[] }) {
  return (
    <Card data-widget="widget-academy-overview-learning-paths" role="region" aria-label="Learning paths" className="h-full">
      <CardHeader>
        <CardTitle>Learning paths</CardTitle>
        <CardDescription>Two tracks through the catalogue.</CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-3">
        {paths.map((path) => (
          <Card key={path.id} data-slot="learning-path" size="sm">
            <CardHeader>
              <CardTitle>{path.title}</CardTitle>
              <CardDescription className="text-xs tabular-nums">
                {`${path.done} of ${path.total} modules completed`}
              </CardDescription>
            </CardHeader>
            <CardContent>
              <Progress
                value={path.total === 0 ? 0 : Math.round((path.done / path.total) * 100)}
                aria-label={`${path.title} progress`}
              />
            </CardContent>
          </Card>
        ))}
      </CardContent>
    </Card>
  )
}
app/academy/components/progress-tiles.tsx
import { formatNumber } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { StatCard } from "@/components/ui/stat-card"

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

/**
 * The learner's own catalogue in two tiles: what is under way and what is
 * done, each with its share of the whole as a bar.
 */
export function ProgressTiles({ split }: { split: ProgressSplit }) {
  const share = (count: number) => (split.total === 0 ? 0 : Math.round((count / split.total) * 100))

  return (
    <Card data-widget="widget-academy-overview-progress-tiles" role="region" aria-label="Progress" className="h-full">
      <CardHeader>
        <CardTitle>Progress</CardTitle>
        <CardDescription>{`${split.total} courses in the catalogue.`}</CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-3">
        <StatCard
          size="sm"
          tone="info"
          label="In progress"
          value={formatNumber(split.inProgress)}
          description={`${share(split.inProgress)}% of the catalogue`}
          footer={<Progress value={share(split.inProgress)} aria-label="Share in progress" />}
        />
        <StatCard
          size="sm"
          tone="success"
          label="Completed"
          value={formatNumber(split.completed)}
          description={`${share(split.completed)}% of the catalogue`}
          footer={<Progress value={share(split.completed)} aria-label="Share completed" />}
        />
      </CardContent>
    </Card>
  )
}
app/academy/components/success-rate-panel.tsx
import { formatNumber, formatPercent } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DescriptionList } from "@/components/ui/description-list"
import { MetricValue } from "@/components/ui/metric-value"
import { Progress } from "@/components/ui/progress"

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

/**
 * How the academy is doing as a whole: the share of every enrolment that
 * finished, against the term before, and the two counts behind it. No state,
 * so it renders on the server.
 */
export function SuccessRatePanel({ rate }: { rate: SuccessRate }) {
  return (
    <Card data-widget="widget-academy-overview-success-rate-panel" role="region" aria-label="Success rate" className="h-full">
      <CardHeader>
        <CardTitle>Success rate</CardTitle>
        <CardDescription>Learners who finished what they started.</CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <MetricValue
          label="Success rate"
          metric={{ kind: "percent", value: rate.rate, precision: 0 }}
          previous={rate.previous}
          compareLabel="vs last term"
        />
        <Progress value={Math.round(rate.rate * 100)} aria-label="Success rate" />
        <DescriptionList
          size="sm"
          items={[
            {
              term: "Learners",
              description: <span className="tabular-nums">{formatNumber(rate.learners)}</span>,
            },
            {
              term: "Passing",
              description: (
                <span className="tabular-nums">
                  {formatNumber(rate.passing)}
                  <span className="text-muted-foreground">{` · ${formatPercent(rate.rate)}`}</span>
                </span>
              ),
            },
          ]}
        />
      </CardContent>
    </Card>
  )
}
app/academy/components/welcome-card.tsx
"use client"

import Link from "next/link"
import { AwardIcon, BookOpenIcon, ClockIcon, FlameIcon } from "lucide-react"

import { ROUTES } from "@/lib/dashboards/academy/nav"
import { Button, buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"

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

import { COURSES_ID } from "./academy-vocabulary"

export type WelcomeCardProps = {
  summary: LearnerSummary
  /**
   * The page draws the catalogue under this card, and says so: Explore
   * courses scrolls to it. Drawn alone there is no catalogue below, so the
   * button is a link to the Courses page instead.
   */
  linked?: boolean
}

/**
 * The greeting and the four numbers a learner checks before choosing what to
 * open next. The frame takes the brand tint the way a `StatCard tone="brand"`
 * does, so the one warm card on the page is the one addressed to the reader.
 * An island only for the button, which scrolls to the catalogue rather than
 * navigating anywhere — a framed preview has nowhere to go. Drawn alone, with
 * no catalogue to scroll to, it goes to the Courses page.
 */
export function WelcomeCard({ summary, linked = false }: WelcomeCardProps) {
  const chips = [
    { icon: BookOpenIcon, text: `${summary.inProgress} courses in progress` },
    { icon: ClockIcon, text: `${summary.hoursLast30Days} hours in the last 30 days` },
    { icon: AwardIcon, text: `${summary.certificates} certificates` },
    { icon: FlameIcon, text: `${summary.streakDays} day streak` },
  ]

  return (
    <Card data-widget="widget-academy-overview-welcome-card" data-slot="welcome-card" className="h-full bg-brand-muted ring-brand/30">
      <CardHeader>
        <CardTitle>{`Welcome back, ${summary.firstName}`}</CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-5">
        <div className="flex flex-col gap-1.5">
          <h2 className="type-display text-xl text-foreground">What do you want to learn today?</h2>
          <p className="text-sm text-muted-foreground">
            {`Pick up where you left off, or open something new from the catalogue${linked ? " below" : ""}.`}
          </p>
        </div>

        <ul aria-label="Learning summary" className="flex flex-wrap gap-x-5 gap-y-2">
          {chips.map(({ icon: Icon, text }) => (
            <li key={text} className="flex items-center gap-1.5 text-sm text-muted-foreground">
              <Icon aria-hidden="true" className="size-4" />
              <span className="tabular-nums">{text}</span>
            </li>
          ))}
        </ul>

        <div>
          {linked ? (
            <Button
              onClick={() =>
                document.getElementById(COURSES_ID)?.scrollIntoView({ behavior: "smooth", block: "start" })
              }
            >
              Explore courses
            </Button>
          ) : (
            <Link href={ROUTES.courses} className={buttonVariants()}>
              Explore courses
            </Link>
          )}
        </div>
      </CardContent>
    </Card>
  )
}