Skip to contentVibraUI

Instructor

Who teaches the course — their face and an address to write to — with the courses they teach, the learners those have taken and their rating; reads courseRecord(id).

Preview

Install

npx shadcn@latest add @vibra/widget-academy-course-instructor-card

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

Source

app/academy/courses/[id]/components/instructor-card.tsx
import { MailIcon } from "lucide-react"

import { formatNumber } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { StatList } from "@/components/ui/stat-list"
import { UserCell } from "@/components/ui/user-cell"

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

/**
 * Who teaches the course: their face, their address, and what they teach
 * across the catalogue. The address is a plain `mailto:` link styled as an
 * icon button — a link, because that is what it is. No state, so it renders
 * on the server.
 */
export function InstructorCard({ instructor }: { instructor: Instructor | undefined }) {
  return (
    <Card data-widget="widget-academy-course-instructor-card" role="region" aria-labelledby="course-instructor-title">
      <CardHeader>
        <CardTitle id="course-instructor-title">Instructor</CardTitle>
        {instructor ? (
          <CardAction>
            <a
              href={`mailto:${instructor.email}`}
              aria-label={`Email ${instructor.name}`}
              className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
            >
              <MailIcon aria-hidden="true" />
            </a>
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        {instructor ? (
          <>
            <UserCell name={instructor.name} src={instructor.avatarUrl} email={instructor.email} />
            <StatList
              size="sm"
              divided
              items={[
                { label: "Courses", value: formatNumber(instructor.courses) },
                { label: "Learners", value: formatNumber(instructor.learners) },
                { label: "Rating", value: instructor.rating.toFixed(1) },
              ]}
            />
          </>
        ) : (
          <p className="text-sm text-muted-foreground">Nobody teaches this course yet.</p>
        )}
      </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