Skip to contentVibraUI

Courses

The catalogue with what is open at the top, searchable and filtered by category, a course under way moved on a tenth through the server from its row; reads courseRows().

Preview

Install

npx shadcn@latest add @vibra/widget-academy-overview-courses-table

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

Source

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/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/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" },
    },
  ]
}

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