Lessons
The course lesson by lesson — its kind, its length and how many students are past it — eight at first and the rest folded out in place; reads courseRecord(id).
Preview
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { type Lesson } from "../data"
import { LESSON_KINDS, duration } from "./course-vocabulary"
const SHOWN = 8
export type LessonListProps = {
lessons: Lesson[]
/** How many students are on the roster — the whole a lesson's count is out of. */
roster: number
}
/**
* The course, lesson by lesson: its number, its kind and its length, and how
* many of the students on the roster are past it — read down the column, the
* count is where the course loses people. Eight at first; the button folds the
* rest out in place.
*/
export function LessonList({ lessons, roster }: LessonListProps) {
const [open, setOpen] = React.useState(false)
const shown = open ? lessons : lessons.slice(0, SHOWN)
const total = lessons.reduce((sum, lesson) => sum + lesson.minutes, 0)
return (
<Card data-widget="widget-academy-course-lesson-list" role="region" aria-labelledby="course-lessons-title">
<CardHeader>
<CardTitle id="course-lessons-title">Lessons</CardTitle>
<CardDescription className="tabular-nums">{`${lessons.length} lessons · ${duration(total)}`}</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ol data-slot="lesson-list" className="flex flex-col">
{shown.map((lesson) => {
const kind = LESSON_KINDS[lesson.kind]
const Icon = kind.icon
return (
<li key={lesson.number} className="flex items-center gap-3 border-b px-(--card-spacing) py-2.5 last:border-b-0">
<span className="w-5 shrink-0 text-right text-xs tabular-nums text-muted-foreground">{lesson.number}</span>
<Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">{kind.label}</span>
<span className="text-xs tabular-nums text-muted-foreground">{duration(lesson.minutes)}</span>
</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{`${lesson.passed} of ${roster}`}
<span className="sr-only"> students past it</span>
</span>
</li>
)
})}
</ol>
</CardContent>
{lessons.length > SHOWN ? (
<CardFooter>
<Button size="sm" variant="ghost" aria-expanded={open} onClick={() => setOpen((current) => !current)}>
{open ? "Show fewer" : `Show all ${lessons.length} lessons`}
</Button>
</CardFooter>
) : null}
</Card>
)
}Install
$
npx shadcn@latest add @vibra/widget-academy-course-lesson-listNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { type Lesson } from "../data"
import { LESSON_KINDS, duration } from "./course-vocabulary"
const SHOWN = 8
export type LessonListProps = {
lessons: Lesson[]
/** How many students are on the roster — the whole a lesson's count is out of. */
roster: number
}
/**
* The course, lesson by lesson: its number, its kind and its length, and how
* many of the students on the roster are past it — read down the column, the
* count is where the course loses people. Eight at first; the button folds the
* rest out in place.
*/
export function LessonList({ lessons, roster }: LessonListProps) {
const [open, setOpen] = React.useState(false)
const shown = open ? lessons : lessons.slice(0, SHOWN)
const total = lessons.reduce((sum, lesson) => sum + lesson.minutes, 0)
return (
<Card data-widget="widget-academy-course-lesson-list" role="region" aria-labelledby="course-lessons-title">
<CardHeader>
<CardTitle id="course-lessons-title">Lessons</CardTitle>
<CardDescription className="tabular-nums">{`${lessons.length} lessons · ${duration(total)}`}</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ol data-slot="lesson-list" className="flex flex-col">
{shown.map((lesson) => {
const kind = LESSON_KINDS[lesson.kind]
const Icon = kind.icon
return (
<li key={lesson.number} className="flex items-center gap-3 border-b px-(--card-spacing) py-2.5 last:border-b-0">
<span className="w-5 shrink-0 text-right text-xs tabular-nums text-muted-foreground">{lesson.number}</span>
<Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">{kind.label}</span>
<span className="text-xs tabular-nums text-muted-foreground">{duration(lesson.minutes)}</span>
</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{`${lesson.passed} of ${roster}`}
<span className="sr-only"> students past it</span>
</span>
</li>
)
})}
</ol>
</CardContent>
{lessons.length > SHOWN ? (
<CardFooter>
<Button size="sm" variant="ghost" aria-expanded={open} onClick={() => setOpen((current) => !current)}>
{open ? "Show fewer" : `Show all ${lessons.length} lessons`}
</Button>
</CardFooter>
) : null}
</Card>
)
}/**
* What /academy/courses/[id] reads. The record is a `db.courses` row; around
* it are the `db.members` row that teaches it and the `db.students` who hold a
* place on it — the roster. `Course` records its lifetime enrolment and its
* completion rate, not the weeks the learners came in or the lessons they
* passed, so the rest is rules over those rows:
*
* - the funnel runs enrolled → started → finished: finished is the enrolment
* times the completion rate (the overview's rule), and started is the
* enrolment times the share of this course's own roster that has opened it
* — never below finished, and the whole enrolment for a course with nobody
* on its roster to read;
* - a lesson is passed by a student whose progress covers it — progress ×
* lessons ÷ 100, rounded down, is how many lessons they are past;
* - the last twelve complete weeks of enrolment run at a year's pace — the
* enrolment over 52 — scattered by `seeded("academy-course/<id>/weeks")`;
* - each lesson's kind and length come from `seeded("academy-course/<id>/lessons")`:
* an introduction first, a checkpoint quiz every fifth lesson, a final
* project last, and videos, exercises and readings between.
*
* "Now" is `REFERENCE_DATE`. The client islands import only the types below.
*/
import { formatDate, getInitials } from "@/lib/format"
import { courseHref } from "@/lib/dashboards/academy/vocabulary"
import {
REFERENCE_DATE,
db,
intBetween,
pickWeighted,
seeded,
type Course,
type Member,
type Student,
} from "@/lib/sample-data"
const DAY_MS = 86_400_000
const WEEK_MS = 7 * DAY_MS
/** One student on the course, and their place on it. */
export type RosterRow = {
id: string
name: string
email: string
avatarUrl: string
/** How far through this course, as a whole percentage. */
progress: number
startedAt: Date
lastActiveAt: Date
status: Student["status"]
}
export type LessonKind = "intro" | "video" | "exercise" | "reading" | "quiz" | "project"
export type Lesson = {
number: number
kind: LessonKind
minutes: number
/** Students on the roster past this lesson. */
passed: number
}
export type WeekPoint = { week: string; enrolments: number }
export type Instructor = {
name: string
email: string
avatarUrl?: string
/** Courses in the catalogue they teach, this one included. */
courses: number
/** Every learner those courses have enrolled. */
learners: number
/** The mean rating of those courses. */
rating: number
}
export type Funnel = {
enrolled: number
started: number
finished: number
/** The roster the started step is read off: how many hold a place, and how many have opened it. */
roster: number
opened: number
}
/** The course and everything the page says about it, gathered in one read. */
export type CourseRecord = {
course: Course
href: string
instructor: Instructor | undefined
roster: RosterRow[]
/** The roster's mean progress, in whole percent; 0 with nobody on it. */
averageProgress: number
funnel: Funnel
weeks: WeekPoint[]
lessons: Lesson[]
}
/** Everyone holding a place on a course, furthest along first. */
function rosterOf(course: Course): RosterRow[] {
return db.students
.all()
.flatMap((student) =>
student.enrolled
.filter((place) => place.courseId === course.id)
.map((place) => ({
id: student.id,
name: student.name,
email: student.email,
avatarUrl: student.avatarUrl,
progress: place.progress,
startedAt: place.startedAt,
lastActiveAt: student.lastActiveAt,
status: student.status,
}))
)
.sort((a, b) => b.progress - a.progress || a.name.localeCompare(b.name))
}
/**
* The course the page falls back to when it is rendered without a route
* param — which is what the docs preview does: the one with the most students
* on it, the lowest id on a tie, because a record page is worth looking at
* when it has a roster. Read per request, like every row here.
*/
export function fallbackId(): string {
return db.courses
.all()
.sort((a, b) => rosterOf(b).length - rosterOf(a).length || a.id.localeCompare(b.id))[0].id
}
/** Every course id, for `generateStaticParams`. */
export function courseIds(): string[] {
return db.courses.all().map((course) => course.id)
}
function funnelOf(course: Course, roster: RosterRow[]): Funnel {
const finished = Math.round(course.enrolled * course.completionRate)
const opened = roster.filter((row) => row.progress > 0).length
const share = roster.length === 0 ? 1 : opened / roster.length
const started = Math.max(finished, Math.round(course.enrolled * share))
return { enrolled: course.enrolled, started, finished, roster: roster.length, opened }
}
const WEEK_LABEL = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/** Monday of the week `REFERENCE_DATE` falls in, at midnight UTC. */
function thisMonday(): number {
const today = Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate())
return today - ((new Date(today).getUTCDay() + 6) % 7) * DAY_MS
}
/** The last twelve complete weeks, oldest first, at a year's pace of the course's own enrolment. */
function weeksOf(course: Course): WeekPoint[] {
const rand = seeded(`academy-course/${course.id}/weeks`)
const monday = thisMonday()
const pace = course.enrolled / 52
return Array.from({ length: 12 }, (_, index) => ({
week: WEEK_LABEL.format(new Date(monday - (12 - index) * WEEK_MS)),
enrolments: Math.round(pace * (0.6 + rand() * 0.8)),
}))
}
// Between the introduction, the quizzes and the project: mostly video.
const BODY_MIX: readonly (readonly [LessonKind, number])[] = [
["video", 0.55],
["exercise", 0.3],
["reading", 0.15],
]
// Minutes, by kind.
const LENGTH: Record<LessonKind, [number, number]> = {
intro: [4, 8],
video: [6, 18],
exercise: [15, 35],
reading: [5, 12],
quiz: [8, 12],
project: [45, 90],
}
/** The course's lessons in order, each with the students on the roster past it. */
function lessonsOf(course: Course, roster: RosterRow[]): Lesson[] {
const rand = seeded(`academy-course/${course.id}/lessons`)
const total = course.lessons
return Array.from({ length: total }, (_, index) => {
const number = index + 1
const kind: LessonKind =
number === 1 ? "intro" : number === total ? "project" : number % 5 === 0 ? "quiz" : pickWeighted(rand, BODY_MIX)
const [low, high] = LENGTH[kind]
return {
number,
kind,
minutes: intBetween(rand, low, high),
passed: roster.filter((row) => Math.floor((row.progress * total) / 100) >= number).length,
}
})
}
function instructorOf(course: Course): Instructor | undefined {
const member = db.members.all().find((row) => row.id === course.instructorId)
if (!member) return undefined
const taught = db.courses.all().filter((row) => row.instructorId === member.id)
return {
name: member.name,
email: member.email,
avatarUrl: member.avatarUrl,
courses: taught.length,
learners: taught.reduce((sum, row) => sum + row.enrolled, 0),
rating: taught.reduce((sum, row) => sum + row.rating, 0) / taught.length,
}
}
/** The whole record, or undefined when the id names no course. */
export async function courseRecord(id: string): Promise<CourseRecord | undefined> {
const course = await db.courses.get(id)
if (!course) return undefined
const roster = rosterOf(course)
return {
course,
href: courseHref(course.id),
instructor: instructorOf(course),
roster,
averageProgress:
roster.length === 0 ? 0 : Math.round(roster.reduce((sum, row) => sum + row.progress, 0) / roster.length),
funnel: funnelOf(course, roster),
weeks: weeksOf(course),
lessons: lessonsOf(course, roster),
}
}
/** The line under the title: when the course was last touched. */
export function lastUpdated(course: Course): string {
return `Updated ${formatDate(course.updatedAt, "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 }))
}import {
BookOpenTextIcon,
CirclePlayIcon,
FlagIcon,
ListChecksIcon,
PencilLineIcon,
PresentationIcon,
type LucideIcon,
} from "lucide-react"
/**
* The words this page puts on a lesson's kind, and how a length is written.
* Vocabulary, not data — it lives beside the islands that print it rather
* than in `data.ts`, which reads `db` and must never reach the browser. The
* words the whole dashboard shares — a course's category and level, a
* student's status — are in `@/lib/dashboards/academy/vocabulary`.
*/
export const LESSON_KINDS: Record<string, { label: string; icon: LucideIcon }> = {
intro: { label: "Course introduction", icon: PresentationIcon },
video: { label: "Video lesson", icon: CirclePlayIcon },
exercise: { label: "Exercise", icon: PencilLineIcon },
reading: { label: "Reading", icon: BookOpenTextIcon },
quiz: { label: "Checkpoint quiz", icon: ListChecksIcon },
project: { label: "Final project", icon: FlagIcon },
}
/** "45 min", "1 h 20 min": a lesson's length, or the course's. */
export function duration(minutes: number): string {
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
if (hours === 0) return `${rest} min`
return rest === 0 ? `${hours} h` : `${hours} h ${rest} min`
}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 page page