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
"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>
)
}Install
$
npx shadcn@latest add @vibra/widget-academy-overview-courses-tableNeeds the @vibra registry in your components.json — set it up once.
Source
"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>
)
}/**
* 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 }
}"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) }
}/**
* 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""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