Skip to contentVibraUI

Roster figures

Everyone on the roster and how many are active, how many were in this week and their share of the roster, the average progress across their places, and how many graduated; reads rosterTotals().

Preview

Install

npx shadcn@latest add @vibra/widget-academy-students-roster-figures

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

Source

app/academy/students/components/roster-figures.tsx
import { ActivityIcon, GaugeIcon, GraduationCapIcon, UsersIcon } from "lucide-react"

import { formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

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

/**
 * The roster in four numbers: who is on it, who was in this week, how far
 * through their courses they are, and who has finished everything. Drawn by
 * the page's island from the figures it holds, so an enrolment moves them.
 */
export function RosterFigures({ totals }: { totals: RosterTotals }) {
  const share = totals.students === 0 ? 0 : totals.thisWeek / totals.students

  return (
    <StatCardGroup data-widget="widget-academy-students-roster-figures" columns={4} role="region" aria-label="The roster in figures">
      <StatCard
        label="Students"
        value={formatNumber(totals.students)}
        description={`${formatNumber(totals.active)} active`}
        icon={<UsersIcon />}
      />
      <StatCard
        label="Active this week"
        value={formatNumber(totals.thisWeek)}
        description={`${formatPercent(share, { maximumFractionDigits: 0 })} of the roster`}
        icon={<ActivityIcon />}
      />
      <StatCard
        label="Average progress"
        value={`${totals.progress}%`}
        description={`Across ${formatNumber(totals.places)} places`}
        icon={<GaugeIcon />}
      />
      <StatCard
        label="Graduated"
        value={formatNumber(totals.graduated)}
        description="Finished every course they took"
        icon={<GraduationCapIcon />}
      />
    </StatCardGroup>
  )
}
app/academy/students/data.ts
/**
 * What /academy/students reads. The roster is `db.students`; each place a
 * student holds is resolved against `db.courses` for its title, so a row
 * carries everything the table and the inspector print and opening a student
 * costs no second read. A student's progress is the mean of their places'; a
 * place is "done" at 100. "This week" and "last active" are measured against
 * `REFERENCE_DATE`, in whole UTC days.
 *
 * The client islands import only the types below; `db` never reaches the
 * browser. `actions.ts` builds the row it hands back with `toStudentRow`, so a
 * row the server changed and a row the page rendered are the same shape.
 */
import { formatDate, getInitials } from "@/lib/format"
import { courseHref } from "@/lib/dashboards/academy/vocabulary"
import { REFERENCE_DATE, db, type Course, type Member, type Student } from "@/lib/sample-data"

const DAY_MS = 86_400_000

/** One place on one course, with what the inspector prints about the course. */
export type StudentPlace = {
  courseId: string
  title: string
  /** Undefined only for a place on a course the catalogue no longer holds. */
  category: Course["category"] | undefined
  /** The course's own page. */
  href: string
  progress: number
  startedAt: Date
}

/** One student as the table prints them, and everything the inspector shows. */
export type StudentRow = {
  id: string
  name: string
  email: string
  avatarUrl: string
  places: StudentPlace[]
  courseIds: string[]
  /** The mean of their places' progress, in whole percent. */
  averageProgress: number
  lastActiveAt: Date
  /** "Today", "Yesterday", "3 days ago" or the day: when they were last in, read against REFERENCE_DATE. */
  lastActive: string
  status: Student["status"]
}

/** A course the facet and the enrolment form offer. */
export type CourseOption = { id: string; title: string }

const midnight = (date: Date): number => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())

const DAY_LABEL = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })

/** When a student was last in, in whole UTC days before REFERENCE_DATE. */
export function lastActiveLabel(at: Date): string {
  const days = Math.round((midnight(REFERENCE_DATE) - midnight(at)) / DAY_MS)
  if (days <= 0) return "Today"
  if (days === 1) return "Yesterday"
  if (days < 7) return `${days} days ago`
  return DAY_LABEL.format(at)
}

/** The row for one student, their places resolved against the catalogue. */
export function toStudentRow(student: Student): StudentRow {
  const courses = new Map(db.courses.all().map((course) => [course.id, course]))
  const places = student.enrolled.map((place) => {
    const course = courses.get(place.courseId)
    return {
      courseId: place.courseId,
      title: course?.title ?? "A retired course",
      category: course?.category,
      href: courseHref(place.courseId),
      progress: place.progress,
      startedAt: place.startedAt,
    }
  })
  return {
    id: student.id,
    name: student.name,
    email: student.email,
    avatarUrl: student.avatarUrl,
    places,
    courseIds: places.map((place) => place.courseId),
    averageProgress:
      places.length === 0 ? 0 : Math.round(places.reduce((sum, place) => sum + place.progress, 0) / places.length),
    lastActiveAt: student.lastActiveAt,
    lastActive: lastActiveLabel(student.lastActiveAt),
    status: student.status,
  }
}

/** Every student on the roster; the table sorts, filters and pages them itself. */
export function studentRows(): StudentRow[] {
  return db.students.all().map(toStudentRow)
}

/** Every course in the catalogue, in catalogue order, for the facet and the form. */
export function courseOptions(): CourseOption[] {
  return db.courses.all().map((course) => ({ id: course.id, title: course.title }))
}

export type RosterTotals = {
  students: number
  active: number
  /** In within the seven days to REFERENCE_DATE. */
  thisWeek: number
  /** Every place held, and their mean progress in whole percent. */
  places: number
  progress: number
  graduated: number
}

/** The four figures over the roster. */
export function rosterTotals(): RosterTotals {
  const rows = db.students.all()
  const places = rows.flatMap((student) => student.enrolled)
  return {
    students: rows.length,
    active: rows.filter((student) => student.status === "active").length,
    thisWeek: rows.filter((student) => REFERENCE_DATE.getTime() - student.lastActiveAt.getTime() <= 7 * DAY_MS).length,
    places: places.length,
    progress: places.length === 0 ? 0 : Math.round(places.reduce((sum, place) => sum + place.progress, 0) / places.length),
    graduated: rows.filter((student) => student.status === "graduated").length,
  }
}

/** The line under the title: when the roster was read. */
export function lastUpdated(): string {
  return `Synced ${formatDate(REFERENCE_DATE, "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 Student roster page