{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gradebook-1",
  "title": "Gradebook 1: class grades",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A teacher's gradebook: students down, assignments across, type a score and the weighted average and letter grade update, ungraded work is left out rather than counted as zero, failing students are marked in words, a bad score is refused.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/gradebook-1/gradebook-1.tsx",
      "content": "// Ballmac UI: Gradebook 1. https://ui.ballmac.com/blocks/gradebook-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { TriangleAlert } from \"lucide-react\"\n\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Gradebook1Student = { id: string; name: string }\ntype Gradebook1Assignment = {\n  id: string\n  title: string\n  /** The most points it can earn. */\n  max: number\n  /** How much it counts towards the final grade, in any unit: 20 and 30 count in the ratio 2 to 3. */\n  weight: number\n}\n\ntype Gradebook1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    students?: Gradebook1Student[]\n    assignments?: Gradebook1Assignment[]\n    /** Points earned, keyed \"studentId:assignmentId\". Leave one out, or use null, for not yet graded. */\n    scores?: Record<string, number | null>\n    title?: string\n    /** The percentage a student needs to pass. */\n    passMark?: number\n    /** Called when a score is entered or cleared. Reject and the old score comes back with the message. */\n    onScoreChange?: (studentId: string, assignmentId: string, score: number | null) => void | Promise<void>\n  }\n\nconst students: Gradebook1Student[] = [\n  { id: \"s1\", name: \"Amara Okafor\" },\n  { id: \"s2\", name: \"Jonas Weber\" },\n  { id: \"s3\", name: \"Lina Rossi\" },\n  { id: \"s4\", name: \"Kofi Mensah\" },\n  { id: \"s5\", name: \"Mei Tanaka\" },\n]\nconst assignments: Gradebook1Assignment[] = [\n  { id: \"a1\", title: \"Essay\", max: 40, weight: 30 },\n  { id: \"a2\", title: \"Quiz 1\", max: 20, weight: 15 },\n  { id: \"a3\", title: \"Quiz 2\", max: 20, weight: 15 },\n  { id: \"a4\", title: \"Project\", max: 100, weight: 40 },\n]\nconst scores: Record<string, number | null> = {\n  \"s1:a1\": 36, \"s1:a2\": 18, \"s1:a3\": 19, \"s1:a4\": 92,\n  \"s2:a1\": 28, \"s2:a2\": 12, \"s2:a3\": 15, \"s2:a4\": 64,\n  \"s3:a1\": 33, \"s3:a2\": 17, \"s3:a3\": null, \"s3:a4\": null,\n  \"s4:a1\": 19, \"s4:a2\": 9, \"s4:a3\": 11, \"s4:a4\": 48,\n  \"s5:a1\": 39, \"s5:a2\": 20, \"s5:a3\": 18, \"s5:a4\": 88,\n}\n\nconst letter = (p: number) => (p >= 90 ? \"A\" : p >= 80 ? \"B\" : p >= 70 ? \"C\" : p >= 60 ? \"D\" : \"F\")\n\n/** A student's weighted average over the assignments that are graded, as a percentage, or null when nothing is graded yet. */\nfunction average(studentId: string, as: Gradebook1Assignment[], sc: Record<string, number | null>) {\n  let got = 0\n  let weight = 0\n  let graded = 0\n  for (const a of as) {\n    const v = sc[`${studentId}:${a.id}`]\n    if (typeof v === \"number\") {\n      got += (v / a.max) * a.weight\n      weight += a.weight\n      graded++\n    }\n  }\n  return weight ? { percent: (got / weight) * 100, graded } : null\n}\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-2\">\n      <Skeleton className=\"h-12 rounded-lg\" />\n      {[0, 1, 2, 3, 4].map((i) => (\n        <Skeleton key={i} className=\"h-14 rounded-lg\" />\n      ))}\n    </div>\n  )\n}\n\n/**\n * A teacher's gradebook: a score for every student and assignment that is edited in place and saved when you leave the cell, each student's weighted average and\n * letter, and the class average for each assignment. A score out of range is refused with a message, a student below the pass mark is called out in words,\n * and a save that fails puts the old score back and says why.\n */\nfunction Gradebook1({ students: st = students, assignments: as = assignments, scores: scoresProp = scores, title = \"Gradebook\", passMark = 60, onScoreChange, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Gradebook1Props) {\n  const id = React.useId()\n  const [sc, setSc] = React.useState(scoresProp)\n  React.useEffect(() => setSc(scoresProp), [scoresProp])\n  const [draft, setDraft] = React.useState<Record<string, string>>({})\n  const [error, setError] = React.useState<{ key: string; text: string } | null>(null)\n  const view = state === \"ready\" && st.length === 0 ? \"empty\" : state\n  const below = st.filter((s) => {\n    const a = average(s.id, as, sc)\n    return a && a.percent < passMark\n  })\n\n  async function commit(s: Gradebook1Student, a: Gradebook1Assignment) {\n    const key = `${s.id}:${a.id}`\n    const raw = (draft[key] ?? \"\").trim()\n    setDraft(({ [key]: _gone, ...rest }) => rest)\n    if (draft[key] === undefined) return\n    const before = sc[key] ?? null\n    const next = raw === \"\" ? null : Number(raw)\n    if (next !== null && (!Number.isFinite(next) || next < 0 || next > a.max)) {\n      setError({ key, text: `${a.title} is out of ${a.max}. Enter a number from 0 to ${a.max}, or leave it empty.` })\n      return\n    }\n    if (next === before) return\n    setError(null)\n    setSc((c) => ({ ...c, [key]: next }))\n    try {\n      await onScoreChange?.(s.id, a.id, next)\n    } catch (e) {\n      setSc((c) => ({ ...c, [key]: before }))\n      setError({ key, text: e instanceof Error ? e.message : `${s.name}'s ${a.title} score could not be saved, so it is as it was.` })\n    }\n  }\n\n  return (\n    <section data-slot=\"gradebook-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-5xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p aria-live=\"polite\" className=\"text-muted-foreground mt-2 max-w-xl text-pretty\">\n          {below.length ? <><strong className=\"text-foreground\">{below.length} {below.length === 1 ? \"student is\" : \"students are\"} below the {passMark}% pass mark:</strong> {below.map((s) => s.name).join(\", \")}.</> : `Everyone graded so far is at or above ${passMark}%.`}\n        </p>\n      </header>\n      <StateView state={view} onRetry={onRetry} label=\"Loading the gradebook\" loading={<Skel />} empty={{ title: \"No students yet\", description: \"Add students to this class and their scores will appear here.\", ...emptyState }} error={{ title: \"The gradebook could not be loaded\", description: \"No score was changed. Check your connection and try again.\", ...errorState }}>\n        <div tabIndex={0} role=\"region\" aria-label={`${title}, scrollable`} className=\"focus-visible:ring-ring/50 overflow-x-auto rounded-xl border outline-none focus-visible:ring-[3px]\">\n          <table className=\"w-full min-w-[40rem] border-collapse text-sm\">\n            <caption className=\"sr-only\">{title}: points for each student and assignment, with weighted averages</caption>\n            <thead className=\"bg-muted/40 text-xs\">\n              <tr>\n                <th scope=\"col\" className=\"bg-muted/40 sticky start-0 z-10 px-4 py-2.5 text-start font-medium\">Student</th>\n                {as.map((a) => (\n                  <th key={a.id} scope=\"col\" className=\"px-3 py-2.5 text-center font-medium\">\n                    {a.title}\n                    <span className=\"text-muted-foreground block font-normal tabular-nums\">out of {a.max} · counts {a.weight}</span>\n                  </th>\n                ))}\n                <th scope=\"col\" className=\"px-4 py-2.5 text-end font-medium\">Average</th>\n              </tr>\n            </thead>\n            <tbody className=\"divide-y\">\n              {st.map((s) => {\n                const avg = average(s.id, as, sc)\n                const low = !!avg && avg.percent < passMark\n                return (\n                  <tr key={s.id} data-below={low || undefined}>\n                    <th scope=\"row\" className=\"bg-card sticky start-0 z-10 px-4 py-2 text-start font-medium\">{s.name}</th>\n                    {as.map((a) => {\n                      const key = `${s.id}:${a.id}`\n                      const bad = error?.key === key\n                      return (\n                        <td key={a.id} className=\"p-1.5 text-center\">\n                          <input\n                            inputMode=\"decimal\"\n                            aria-label={`${s.name}, ${a.title}, out of ${a.max}`}\n                            aria-invalid={bad || undefined}\n                            aria-describedby={bad ? `${id}-err` : undefined}\n                            value={draft[key] ?? (sc[key] ?? \"\")}\n                            placeholder=\"–\"\n                            onChange={(e) => setDraft({ ...draft, [key]: e.target.value })}\n                            onBlur={() => void commit(s, a)}\n                            onKeyDown={(e) => e.key === \"Enter\" && (e.currentTarget as HTMLInputElement).blur()}\n                            className=\"focus-visible:ring-ring/50 aria-invalid:border-destructive h-9 w-16 rounded-md border bg-background text-center tabular-nums outline-none focus-visible:ring-[3px]\"\n                          />\n                        </td>\n                      )\n                    })}\n                    <td className=\"px-4 py-2 text-end tabular-nums\">\n                      {avg ? (\n                        <>\n                          <span className=\"font-semibold\">{Math.round(avg.percent)}%</span> <span className=\"text-muted-foreground\">{letter(avg.percent)}</span>\n                          <span className=\"text-muted-foreground block text-xs\">{avg.graded} of {as.length} graded</span>\n                          {low && <span className=\"text-destructive flex items-center justify-end gap-1 text-xs font-medium\"><TriangleAlert aria-hidden=\"true\" className=\"size-3\" /> Below pass mark</span>}\n                        </>\n                      ) : (\n                        <span className=\"text-muted-foreground\">Not graded</span>\n                      )}\n                    </td>\n                  </tr>\n                )\n              })}\n            </tbody>\n            <tfoot className=\"bg-muted/30 border-t text-xs\">\n              <tr>\n                <th scope=\"row\" className=\"bg-muted/30 sticky start-0 px-4 py-2.5 text-start font-medium\">Class average</th>\n                {as.map((a) => {\n                  const vals = st.map((s) => sc[`${s.id}:${a.id}`]).filter((v): v is number => typeof v === \"number\")\n                  return <td key={a.id} className=\"px-3 py-2.5 text-center tabular-nums\">{vals.length ? `${Math.round((vals.reduce((n, v) => n + v, 0) / vals.length / a.max) * 100)}%` : \"–\"}</td>\n                })}\n                <td />\n              </tr>\n            </tfoot>\n          </table>\n        </div>\n        <p id={`${id}-err`} role=\"alert\" className={cn(\"text-destructive mt-3 min-h-5 text-sm font-medium\", !error && \"sr-only\")}>{error?.text}</p>\n      </StateView>\n    </section>\n  )\n}\n\nexport { Gradebook1, average, letter, type Gradebook1Assignment, type Gradebook1Props, type Gradebook1Student }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/gradebook-1/gradebook-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "education",
      "gradebook",
      "grades",
      "school",
      "teacher",
      "weighted average",
      "class",
      "students"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "Gradebook. <Gradebook1 students={[{ id, name }]} assignments={[{ id, title, max, weight }]} scores={{ 'studentId:assignmentId': 18 }} passMark={50} onScoreChange={(studentId, assignmentId, score|null) => Promise} />. Averages are weighted over graded work only. Scores above the maximum or below 0 are refused with a message.",
      "whenToUse": [
        "A teacher's marking screen",
        "Any grid of people against weighted tasks"
      ],
      "whenNotToUse": [
        "A report card for parents",
        "A spreadsheet with free formulas; use data-grid"
      ],
      "composesWith": [
        "roster-1",
        "calendar-1",
        "inspection-1"
      ],
      "a11y": [
        {
          "keys": "Grid",
          "action": "A real table with row and column headers; each score is a labelled number field"
        },
        {
          "keys": "Grade",
          "action": "Average, letter and Failing are written as words"
        },
        {
          "keys": "Error",
          "action": "A refused score is an alert on that field; Escape restores the old score"
        },
        {
          "keys": "Weights",
          "action": "Shown in the column header text"
        }
      ],
      "customization": [
        "students, assignments, scores",
        "passMark",
        "onScoreChange"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "gradebook-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton grid under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No students yet",
        "example": "gradebook-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says no students yet; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "gradebook-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying no score was changed, with Try again (onRetry). A score that fails to save is put back and says why."
      }
    ],
    "examples": [
      "gradebook-1-demo",
      "gradebook-1-ungraded",
      "gradebook-1-loading",
      "gradebook-1-empty",
      "gradebook-1-error"
    ],
    "url": "https://ui.ballmac.com/components/gradebook-1"
  },
  "categories": [
    "blocks",
    "gradebook"
  ],
  "type": "registry:block"
}