Skip to contentVibraUI

Course roster

The students on the course now, furthest along first, each with a face, their progress, when they joined and were last in, and where they stand; reads courseRecord(id).

Preview

Install

npx shadcn@latest add @vibra/widget-academy-course-course-roster

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

Source

app/academy/courses/[id]/components/course-roster.tsx
import { STUDENT_STATUS_LABELS, STUDENT_STATUS_MAP } from "@/lib/dashboards/academy/vocabulary"
import { formatDate } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { SimpleTable } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"

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

const day = (date: Date) => formatDate(date, "medium", { timeZone: "UTC" })

/**
 * The students on the course now, furthest along first: each with a face, how
 * far through they are, when they took the place, when they were last in, and
 * where they stand. The course's enrolment figure counts everyone it has ever
 * taken; this is the part of it the academy is looking after today. No state,
 * so it renders on the server.
 */
export function CourseRoster({ rows }: { rows: RosterRow[] }) {
  return (
    <Card data-widget="widget-academy-course-course-roster" role="region" aria-labelledby="course-roster-title">
      <CardHeader>
        <CardTitle id="course-roster-title">Students on this course</CardTitle>
        <CardDescription className="tabular-nums">{`${rows.length} on the roster, furthest along first`}</CardDescription>
      </CardHeader>
      {/* A container, so the table sheds columns as its card narrows instead
          of scrolling sideways: the day each place was taken below 46rem (a
          1,280px screen), and below 32rem (a phone) the last visit and the
          status too, which then ride under the student's name. */}
      <CardContent className="@container/roster p-0">
        <SimpleTable
          size="sm"
          className="rounded-none border-0"
          rows={rows}
          rowKey="id"
          emptyMessage="Nobody is on this course right now."
          columns={[
            {
              key: "name",
              header: "Student",
              cell: (row) => (
                <UserCell
                  size="sm"
                  name={row.name}
                  src={row.avatarUrl}
                  description={
                    <>
                      <span className="@max-[32rem]/roster:hidden">{row.email}</span>
                      <span className="hidden @max-[32rem]/roster:inline">{`${STUDENT_STATUS_LABELS[row.status]} · last in ${day(row.lastActiveAt)}`}</span>
                    </>
                  }
                />
              ),
            },
            {
              key: "progress",
              header: "Progress",
              cell: (row) => (
                <span className="flex min-w-24 items-center gap-2 @[32rem]/roster:min-w-32">
                  <Progress value={row.progress} aria-label={`${row.name}'s progress`} className="flex-1" />
                  <span className="w-9 shrink-0 text-right text-xs tabular-nums text-muted-foreground">{`${row.progress}%`}</span>
                </span>
              ),
            },
            {
              key: "startedAt",
              header: "Started",
              align: "right",
              className: "@max-[46rem]/roster:hidden",
              cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{day(row.startedAt)}</span>,
            },
            {
              key: "lastActiveAt",
              header: "Last in",
              align: "right",
              className: "@max-[32rem]/roster:hidden",
              cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{day(row.lastActiveAt)}</span>,
            },
            {
              key: "status",
              header: "Status",
              className: "@max-[32rem]/roster:hidden",
              cell: (row) => (
                <StatusBadge status={row.status} label={STUDENT_STATUS_LABELS[row.status]} map={STUDENT_STATUS_MAP} />
              ),
            },
          ]}
        />
      </CardContent>
    </Card>
  )
}
app/academy/courses/[id]/data.ts
/**
 * 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