Enrolment by week
New learners on the course each week over the last twelve complete weeks; reads courseRecord(id).
Preview
"use client"
import { formatNumber } from "@/lib/format"
import { BarChart } from "@/components/ui/bar-chart"
import { ChartCard } from "@/components/ui/chart-card"
import { type WeekPoint } from "../data"
/**
* New learners on the course each week, the last twelve complete weeks, one
* grey series. The weeks arrive computed from the server page; this is an
* island only because a chart draws in the browser.
*/
export function EnrolmentChart({ weeks }: { weeks: WeekPoint[] }) {
const total = weeks.reduce((sum, point) => sum + point.enrolments, 0)
return (
<ChartCard
data-widget="widget-academy-course-enrolment-chart"
title="Enrolment by week"
description="New learners each week, the last twelve complete weeks."
actions={<span className="text-xs tabular-nums text-muted-foreground">{`${formatNumber(total)} in twelve weeks`}</span>}
height={240}
>
<BarChart
data={weeks}
index="week"
series={[{ key: "enrolments", label: "Enrolments" }]}
valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 0 })}
height={240}
legend="none"
/>
</ChartCard>
)
}Install
$
npx shadcn@latest add @vibra/widget-academy-course-enrolment-chartNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import { formatNumber } from "@/lib/format"
import { BarChart } from "@/components/ui/bar-chart"
import { ChartCard } from "@/components/ui/chart-card"
import { type WeekPoint } from "../data"
/**
* New learners on the course each week, the last twelve complete weeks, one
* grey series. The weeks arrive computed from the server page; this is an
* island only because a chart draws in the browser.
*/
export function EnrolmentChart({ weeks }: { weeks: WeekPoint[] }) {
const total = weeks.reduce((sum, point) => sum + point.enrolments, 0)
return (
<ChartCard
data-widget="widget-academy-course-enrolment-chart"
title="Enrolment by week"
description="New learners each week, the last twelve complete weeks."
actions={<span className="text-xs tabular-nums text-muted-foreground">{`${formatNumber(total)} in twelve weeks`}</span>}
height={240}
>
<BarChart
data={weeks}
index="week"
series={[{ key: "enrolments", label: "Enrolments" }]}
valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 0 })}
height={240}
legend="none"
/>
</ChartCard>
)
}/**
* 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 }))
}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