feat: implement reports

This commit is contained in:
2026-09-19 20:06:41 -04:00
parent 6ae993d393
commit 994e9065e8
28 changed files with 7082 additions and 63 deletions
@@ -0,0 +1,558 @@
// coursebank — class diagnostic template
//
// This file is a template, not generated output. `coursebank report cohort`
// replaces only the marked regions below.
//
// coursebank template dump --variant cohort-report
// typst watch templates/cohort-report.typ
//
// Markers:
//
// // coursebank:begin meta course, assessment, class size, policy
// // coursebank:end meta
// // coursebank:begin data the class diagnostic
// // coursebank:end data
//
// This document is for you, not for the class. It carries item statistics, the
// revision queue, and the per-option breakdown — everything the student report
// withholds except the question text itself, which is in the bank where it
// belongs. Do not hand it out: an option table tells a reader which letter was
// keyed.
#import "@preview/mitex:0.2.7": mi
// ─────────────────────────────────────────────────────────────────────────────
// Data
// ─────────────────────────────────────────────────────────────────────────────
// coursebank:begin meta
#let cb-meta = (
course: (code: "COURSE 101", title: "Sample Course", term: "2026f"),
assessment: (id: "sample", title: "Sample assessment", date: "2026-01-01"),
generator: (tool: "coursebank", version: "0.0.0", on: "2026-01-02"),
policy: (mastery-threshold: 0.75, min-items-for-mastery: 2),
class-size: 24,
extra: (:),
)
// coursebank:end meta
// coursebank:begin data
#let cb-data = (
students: 24,
items: 36,
distribution: (
mean: 71.2, median: 73.0, sd: 11.4, min: 44.0, max: 94.0,
bins: (
(low: 40, high: 50, count: 1),
(low: 50, high: 60, count: 3),
(low: 60, high: 70, count: 6),
(low: 70, high: 80, count: 9),
(low: 80, high: 90, count: 4),
(low: 90, high: 100, count: 1),
),
),
reliability: (
alpha: 0.71, sem: 2.1, mean-p: 0.72, mean-point-biserial: 0.24,
interpretation: "Reliability is 0.71, acceptable for a classroom exam.",
),
levels: (
(level: 1, name: "Remember", items: 6, rate: 0.91),
(level: 3, name: "Apply", items: 9, rate: 0.64),
),
objectives: (
(id: "lo-sample-gap", text: [A sample objective the class struggled with.],
items: 3, rate: 0.41, meeting: 4, developing: 7, not-yet: 13, thin: 0,
below-threshold: true),
),
gaps: (
(id: "lo-sample-gap", text: [A sample objective the class struggled with.],
items: 3, rate: 0.41, meeting: 4, developing: 7, not-yet: 13, thin: 0,
below-threshold: true),
),
questions: (
(number: 1, item: "bank::q-sample-001", level: 1, objectives: ("lo-sample-gap",),
p: 0.42, point-biserial: 0.05, discrimination: 0.10, blank-rate: 0.0,
key: ("B",),
options: (
(letter: "A", count: 9, rate: 0.375, is-key: false, point-biserial: 0.11, nonfunctioning: false),
(letter: "B", count: 10, rate: 0.417, is-key: true, point-biserial: 0.05, nonfunctioning: false),
(letter: "C", count: 5, rate: 0.208, is-key: false, point-biserial: -0.2, nonfunctioning: false),
(letter: "D", count: 0, rate: 0.0, is-key: false, nonfunctioning: true),
),
flags: ("ambiguous",),
notes: ([Distractor A drew as many strong students as the key.],),
by-form: (A: 0.55, B: 0.30)),
),
revise: (),
forms: (
(id: "A", students: 12, mean: 73.5, sd: 10.2),
(id: "B", students: 12, mean: 68.9, sd: 12.1),
),
blueprint: (),
patterns: (),
warnings: (),
)
// coursebank:end data
// ─────────────────────────────────────────────────────────────────────────────
// Settings
// ─────────────────────────────────────────────────────────────────────────────
#let extra = cb-meta.at("extra", default: (:))
#let accent = rgb(extra.at("accent", default: "#1f4e79"))
#let body-font = extra.at("font", default: "Roboto")
#let body-size = eval(extra.at("font-size", default: "9.5pt"))
#let paper = extra.at("paper", default: "us-letter")
#let show-options = extra.at("option-tables", default: true)
#let threshold = cb-meta.policy.at("mastery-threshold", default: 0.75)
#let ok-color = rgb("#2a9d8f")
#let mid-color = rgb("#D19F1F")
#let bad-color = rgb("#E24E29")
#set page(
paper: paper,
margin: (x: 1.7cm, y: 2.0cm),
header: text(size: 0.8em, fill: luma(110))[
#cb-meta.course.code · #cb-meta.assessment.title · class diagnostic
#h(1fr)
instructor copy
],
footer: context text(size: 0.8em, fill: luma(110))[
#h(1fr)
Page #counter(page).display("1 of 1", both: true)
],
)
#set text(font: body-font, size: body-size, lang: "en")
#set par(justify: false, leading: 0.6em)
#show heading.where(level: 1): it => block(above: 1.4em, below: 0.7em)[
#text(size: 1.15em, weight: "bold", fill: accent)[#it.body]
#v(-0.45em)
#line(length: 100%, stroke: 0.6pt + accent.lighten(55%))
]
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
#let markup(v) = if type(v) == str { eval(v, mode: "markup") } else { v }
#let pct(rate) = str(calc.round(rate * 100)) + "%"
#let num(value, digits: 2) = str(calc.round(value, digits: digits))
#let signed(value) = (if value >= 0 { "+" } else { "" }) + num(value)
#let rate-color(rate) = {
if rate >= threshold { ok-color }
else if rate >= threshold * 0.6 { mid-color }
else { bad-color }
}
#let badge(label, color) = box(
fill: color.lighten(82%),
radius: 3pt,
inset: (x: 4pt, y: 2pt),
)[#text(size: 0.7em, weight: "bold", fill: color.darken(12%))[#label]]
#let bar(rate, color: accent, width: 2.4cm) = {
let r = calc.max(0.0, calc.min(1.0, rate))
box(baseline: 0.15em)[
#stack(
dir: ltr,
box(width: width * r, height: 0.58em, fill: color, radius: (left: 2pt)),
box(width: width * (1.0 - r), height: 0.58em, fill: luma(232), radius: (right: 2pt)),
)
]
}
#let stat-card(label, value, note: none) = block(
width: 100%,
fill: luma(247),
radius: 4pt,
inset: (x: 9pt, y: 8pt),
)[
#text(size: 0.74em, fill: luma(95))[#upper(label)]
#v(0.12em)
#text(size: 1.35em, weight: "bold", fill: accent)[#value]
#if note != none [
#v(0.08em)
#text(size: 0.76em, fill: luma(95))[#note]
]
]
// ─────────────────────────────────────────────────────────────────────────────
// Heading and headline numbers
// ─────────────────────────────────────────────────────────────────────────────
#block(below: 1em)[
#text(size: 1.5em, weight: "bold")[#cb-meta.assessment.title — class diagnostic]
#linebreak()
#text(size: 0.95em, fill: luma(110))[
#cb-meta.course.code · #cb-meta.course.term
#{
let date = cb-meta.assessment.at("date", default: none)
if date != none [ · administered #date ]
}
]
]
#let dist = cb-data.distribution
#let rel = cb-data.reliability
#grid(
columns: (1fr, 1fr, 1fr, 1fr),
gutter: 9pt,
stat-card("students", str(cb-data.students), note: str(cb-data.items) + " scored items"),
stat-card("mean", str(calc.round(dist.mean)) + "%", note: "median " + str(calc.round(dist.median)) + "%"),
stat-card("spread", "SD " + num(dist.sd, digits: 1), note: str(calc.round(dist.min)) + "–" + str(calc.round(dist.max)) + "%"),
stat-card(
"reliability",
{
let alpha = rel.at("alpha", default: none)
if alpha != none { num(alpha) } else { "n/a" }
},
note: {
let sem = rel.at("sem", default: none)
if sem != none { "SEM " + num(sem, digits: 1) + " items" } else { "KR-20" }
},
),
)
#v(0.6em)
#text(size: 0.88em, fill: luma(90))[#rel.interpretation]
// ─────────────────────────────────────────────────────────────────────────────
// Distribution
// ─────────────────────────────────────────────────────────────────────────────
#let bins = dist.at("bins", default: ())
#let peak = if bins.len() > 0 { calc.max(..bins.map(b => b.count)) } else { 0 }
#if peak > 0 [
= Score distribution
#let height = 3.2cm
#align(center)[
#grid(
columns: (1fr,) * bins.len(),
gutter: 5pt,
..bins.map(b => {
let share = if peak > 0 { b.count / peak } else { 0.0 }
stack(
dir: ttb,
spacing: 3pt,
align(center + bottom)[
#box(height: height)[
#align(bottom)[
#box(
width: 100%,
height: height * share,
fill: accent.lighten(if b.low >= 70 { 55% } else { 25% }),
radius: (top: 2pt),
)
]
]
],
align(center)[#text(size: 0.72em, weight: "bold")[#b.count]],
align(center)[#text(size: 0.68em, fill: luma(110))[#b.low]],
)
}),
)
]
#align(center)[#text(size: 0.72em, fill: luma(120))[percent scored, in ten-point bins]]
]
// ─────────────────────────────────────────────────────────────────────────────
// Forms
// ─────────────────────────────────────────────────────────────────────────────
#let forms = cb-data.at("forms", default: ())
#if forms.len() > 1 [
= Forms
#text(size: 0.9em, fill: luma(95))[
Forms differ only in order, so their means should differ only by who sat
them. A persistent gap points at an item whose permutation made it easier or
harder, and the per-question columns further down are where to look.
]
#v(0.4em)
#table(
columns: (auto, auto, auto, auto),
stroke: none,
align: (left, right, right, right),
inset: (x: 7pt, y: 4pt),
fill: (_, row) => if calc.odd(row) { luma(250) } else { white },
table.header(
text(size: 0.78em, fill: luma(95))[FORM],
text(size: 0.78em, fill: luma(95))[STUDENTS],
text(size: 0.78em, fill: luma(95))[MEAN],
text(size: 0.78em, fill: luma(95))[SD],
),
..forms.map(form => (
[*#form.id*],
[#form.students],
[#num(form.mean, digits: 1)%],
[#num(form.sd, digits: 1)],
)).flatten(),
)
]
// ─────────────────────────────────────────────────────────────────────────────
// Levels
// ─────────────────────────────────────────────────────────────────────────────
#if cb-data.levels.len() > 0 [
= By level
#table(
columns: (auto, auto, auto, 1fr),
stroke: none,
align: (left + horizon, right + horizon, right + horizon, left + horizon),
inset: (x: 7pt, y: 4pt),
fill: (_, row) => if calc.odd(row) { luma(250) } else { white },
table.header(
text(size: 0.78em, fill: luma(95))[LEVEL],
text(size: 0.78em, fill: luma(95))[ITEMS],
text(size: 0.78em, fill: luma(95))[CLASS],
text(size: 0.78em, fill: luma(95))[],
),
..cb-data.levels.map(level => (
[#level.level · *#level.name*],
[#level.items],
[#pct(level.rate)],
bar(level.rate, color: rate-color(level.rate), width: 5cm),
)).flatten(),
)
]
// ─────────────────────────────────────────────────────────────────────────────
// Objectives
// ─────────────────────────────────────────────────────────────────────────────
#let objectives = cb-data.at("objectives", default: ())
#if objectives.len() > 0 [
= By objective, worst first
#text(size: 0.9em, fill: luma(95))[
The counts split the class into how many are meeting, developing, and not yet
on each objective. An objective where the class divides evenly is a different
teaching problem from one where nearly everyone is short.
]
#v(0.4em)
#table(
columns: (1fr, auto, auto, auto, auto, auto, auto),
stroke: none,
align: (left + horizon, right + horizon, right + horizon, right + horizon, right + horizon, right + horizon, left + horizon),
inset: (x: 6pt, y: 4pt),
fill: (_, row) => if calc.odd(row) { luma(250) } else { white },
table.header(
text(size: 0.78em, fill: luma(95))[OBJECTIVE],
text(size: 0.78em, fill: luma(95))[Q],
text(size: 0.78em, fill: luma(95))[CLASS],
text(size: 0.78em, fill: luma(95))[MET],
text(size: 0.78em, fill: luma(95))[DEV],
text(size: 0.78em, fill: luma(95))[NOT],
text(size: 0.78em, fill: luma(95))[],
),
..objectives.map(objective => (
{
markup(objective.text)
if objective.at("below-threshold", default: false) {
[ #badge("below " + pct(threshold), bad-color)]
}
},
[#objective.items],
[#pct(objective.rate)],
[#objective.meeting],
[#objective.developing],
[#objective.at("not-yet", default: 0)],
bar(objective.rate, color: rate-color(objective.rate), width: 2.2cm),
)).flatten(),
)
]
// ─────────────────────────────────────────────────────────────────────────────
// Items
// ─────────────────────────────────────────────────────────────────────────────
#let questions = cb-data.at("questions", default: ())
#if questions.len() > 0 [
#pagebreak(weak: true)
= Item analysis
#text(size: 0.9em, fill: luma(95))[
#emph[p] is the share answering correctly; #emph[r] is the corrected
item-total correlation, which should be positive and is the single most
useful column; #emph[D] is the upper group minus the lower group. Option
letters are the bank's, not any one form's.
]
#v(0.4em)
#table(
columns: (auto, auto, auto, auto, auto, auto, 1fr),
stroke: none,
align: (right + horizon, right + horizon, right + horizon, right + horizon, right + horizon, right + horizon, left + horizon),
inset: (x: 5pt, y: 3.5pt),
fill: (_, row) => if calc.odd(row) { luma(250) } else { white },
table.header(
text(size: 0.78em, fill: luma(95))[Q],
text(size: 0.78em, fill: luma(95))[LVL],
text(size: 0.78em, fill: luma(95))[KEY],
text(size: 0.78em, fill: luma(95))[p],
text(size: 0.78em, fill: luma(95))[r],
text(size: 0.78em, fill: luma(95))[D],
text(size: 0.78em, fill: luma(95))[FLAGS],
),
..questions.map(q => (
[*#q.number*],
{
let level = q.at("level", default: none)
if level != none [#level] else []
},
[#q.at("key", default: ()).join("")],
text(fill: rate-color(q.p))[#num(q.p)],
{
let r = q.at("point-biserial", default: none)
if r == none { text(fill: luma(140))[n/a] }
else if r < 0.0 { text(fill: bad-color, weight: "bold")[#signed(r)] }
else if r < 0.15 { text(fill: mid-color)[#signed(r)] }
else { [#signed(r)] }
},
{
let d = q.at("discrimination", default: none)
if d != none [#signed(d)] else [—]
},
{
let flags = q.at("flags", default: ())
let forms-gap = {
let by-form = q.at("by-form", default: (:))
let values = by-form.values()
if values.len() > 1 and calc.max(..values) - calc.min(..values) >= 0.25 {
(badge("form gap " + pct(calc.max(..values) - calc.min(..values)), mid-color),)
} else { () }
}
stack(
dir: ltr,
spacing: 3pt,
..flags.map(f => badge(f, bad-color)),
..forms-gap,
)
},
)).flatten(),
)
]
// ─────────────────────────────────────────────────────────────────────────────
// The revise queue
// ─────────────────────────────────────────────────────────────────────────────
#let revise = cb-data.at("revise", default: ())
#if revise.len() > 0 [
= Before you use these again
#for q in revise [
#block(breakable: false, above: 0.9em, width: 100%)[
#text(weight: "bold", fill: accent)[Question #q.number]
#{
let item = q.at("item", default: none)
if item != none [ #text(size: 0.8em, fill: luma(120))[#item]]
}
#h(0.5em)
#stack(dir: ltr, spacing: 3pt, ..q.at("flags", default: ()).map(f => badge(f, bad-color)))
#v(0.25em)
#for note in q.at("notes", default: ()) [
#text(size: 0.9em)[— #markup(note)]
#linebreak()
]
#if show-options and q.at("options", default: ()).len() > 0 [
#v(0.3em)
#table(
columns: (auto, auto, auto, auto, 1fr),
stroke: none,
align: (center + horizon, right + horizon, right + horizon, left + horizon, left + horizon),
inset: (x: 5pt, y: 3pt),
table.header(
text(size: 0.74em, fill: luma(95))[OPT],
text(size: 0.74em, fill: luma(95))[n],
text(size: 0.74em, fill: luma(95))[SHARE],
text(size: 0.74em, fill: luma(95))[],
text(size: 0.74em, fill: luma(95))[r],
),
..q.options.map(option => (
{
if option.at("is-key", default: false) {
text(weight: "bold", fill: ok-color)[#option.letter]
} else { [#option.letter] }
},
[#option.count],
[#pct(option.rate)],
bar(
option.rate,
color: if option.at("is-key", default: false) { ok-color } else { luma(160) },
width: 2.6cm,
),
{
let r = option.at("point-biserial", default: none)
if r == none { text(fill: luma(150))[—] }
else if not option.at("is-key", default: false) and r > 0.0 {
text(fill: mid-color)[#signed(r)]
} else { text(size: 0.95em)[#signed(r)] }
},
)).flatten(),
)
]
]
]
]
// ─────────────────────────────────────────────────────────────────────────────
// Blueprint and cautions
// ─────────────────────────────────────────────────────────────────────────────
#let blueprint = cb-data.at("blueprint", default: ())
#let warnings = cb-data.at("warnings", default: ())
#let patterns = cb-data.at("patterns", default: ())
#if patterns.len() > 0 [
= Response profiles
#for pattern in patterns [
- *#pattern.label* — #pattern.students student(s)
]
]
#if blueprint.len() > 0 [
= Where the form differs from its blueprint
#for line in blueprint [
- #line
]
]
#if warnings.len() > 0 [
= Cautions
#for line in warnings [
- #text(size: 0.9em)[#line]
]
]
#v(1.2em)
#line(length: 100%, stroke: 0.5pt + luma(210))
#v(0.4em)
#text(size: 0.76em, fill: luma(120))[
Generated #cb-meta.generator.on by #cb-meta.generator.tool #cb-meta.generator.version.
With #cb-data.students students, an item statistic has a standard error of
roughly 0.2; treat single-item results as provisional and the pattern across
items as real. Instructor copy — the option tables identify the key.
]
@@ -0,0 +1,552 @@
// coursebank — individual diagnostic template
//
// This file is a template, not generated output. `coursebank report students`
// replaces only the marked regions below and leaves every other line exactly as
// you wrote it, so this is where layout decisions belong.
//
// coursebank template dump --variant student-report
// typst watch templates/student-report.typ
//
// Two markers are in play:
//
// // coursebank:begin meta course, assessment, class size, policy
// // coursebank:end meta
// // coursebank:begin data one student's diagnostic
// // coursebank:end data
//
// Both regions ship with sample values, so `typst watch` works before any report
// has been generated.
//
// What is not in the payload: the questions. There is no stem field and no option
// text field, on any question, in any configuration. A student report is handed
// back before the makeup exam is given, and a report that reproduces the paper
// cannot be. If you find yourself wanting to print the question, print its number
// and let the student read it off their own copy.
#import "@preview/mitex:0.2.7": mi
// ─────────────────────────────────────────────────────────────────────────────
// Data
// ─────────────────────────────────────────────────────────────────────────────
// coursebank:begin meta
#let cb-meta = (
course: (code: "COURSE 101", title: "Sample Course", term: "2026f"),
assessment: (id: "sample", title: "Sample assessment", date: "2026-01-01"),
generator: (tool: "coursebank", version: "0.0.0", on: "2026-01-02"),
policy: (mastery-threshold: 0.75, min-items-for-mastery: 2),
class-size: 24,
extra: (:),
)
// coursebank:end meta
// coursebank:begin data
#let cb-data = (
student-key: "s-000000000000",
name: "Sample Student",
form: "A",
score: (points: 27.0, possible: 36.0, percent: 75.0, bonus: 1.0, correct: 27, items: 36),
standing: (class-mean: 71.2, class-sd: 11.4, band: "upper half"),
levels: (
(level: 1, name: "Remember", blurb: "recalling terms, facts, and definitions",
items: 6, rate: 1.0, class-rate: 0.91, comparison: "above the class"),
(level: 3, name: "Apply", blurb: "using a procedure in a new situation",
items: 9, rate: 0.56, class-rate: 0.64, comparison: "below the class"),
),
objectives: (
(id: "lo-sample-met", text: [A sample objective this student met.], items: 3,
credit: 3.0, rate: 1.0, lower: 0.44, upper: 1.0, class-rate: 0.81,
status: "meeting", symbol: "✓", confident: false, thin-evidence: false, levels: (1, 2)),
(id: "lo-sample-gap", text: [A sample objective to work on.], items: 3,
credit: 1.0, rate: 0.33, lower: 0.06, upper: 0.79, class-rate: 0.58,
status: "not yet", symbol: "✗", confident: false, thin-evidence: false, levels: (3,)),
),
strengths: ((id: "lo-sample-met", text: [A sample objective this student met.], rate: 1.0, items: 3),),
focus: ((id: "lo-sample-gap", text: [A sample objective to work on.], rate: 0.33, items: 3),),
questions: (
(number: 1, level: 1, objectives: ("lo-sample-met",), correct: true, credit: 1.0,
bonus: false, blank: false, class-rate: 0.91, taught-in: ()),
(number: 2, level: 3, objectives: ("lo-sample-gap",), correct: false, credit: 0.0,
bonus: false, blank: false, class-rate: 0.58,
feedback: [This is the note written for the option that was chosen.],
taught-in: ("Enthalpy (L1.1), slides 12, 13",)),
),
study: (
(objective: "lo-sample-gap", text: [A sample objective to work on.], rate: 0.33,
readings: (
(citation: "KKW §6.2", lecture: "L1.1", lecture-title: "Enthalpy",
focus: [What to take from this section.], supplemental: false),
)),
),
)
// coursebank:end data
// ─────────────────────────────────────────────────────────────────────────────
// Settings
// ─────────────────────────────────────────────────────────────────────────────
#let extra = cb-meta.at("extra", default: (:))
#let accent = rgb(extra.at("accent", default: "#1f4e79"))
#let body-font = extra.at("font", default: "Roboto")
#let body-size = eval(extra.at("font-size", default: "10pt"))
#let paper = extra.at("paper", default: "us-letter")
// Turn sections off from templates/typst.yaml rather than by deleting code, so
// a course that does not want the question map keeps the rest of this file.
#let show-question-map = extra.at("question-map", default: true)
#let show-feedback = extra.at("feedback", default: true)
#let show-study = extra.at("study-plan", default: true)
#let show-comparison = extra.at("comparison", default: true)
#let threshold = cb-meta.policy.at("mastery-threshold", default: 0.75)
#let ok-color = rgb("#2a9d8f")
#let mid-color = rgb("#D19F1F")
#let bad-color = rgb("#E24E29")
#let thin-color = luma(150)
#set page(
paper: paper,
margin: (x: 1.9cm, y: 2.1cm),
header: text(size: 0.8em, fill: luma(110))[
#cb-meta.course.code · #cb-meta.assessment.title · individual diagnostic
],
footer: context text(size: 0.8em, fill: luma(110))[
#h(1fr)
Page #counter(page).display("1 of 1", both: true)
],
)
#set text(font: body-font, size: body-size, lang: "en")
#set par(justify: false, leading: 0.62em)
#show heading.where(level: 1): it => block(above: 1.4em, below: 0.7em)[
#text(size: 1.15em, weight: "bold", fill: accent)[#it.body]
#v(-0.45em)
#line(length: 100%, stroke: 0.6pt + accent.lighten(55%))
]
#show heading.where(level: 2): it => block(above: 1em, below: 0.45em)[
#text(size: 1.02em, weight: "bold")[#it.body]
]
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
#let markup(v) = if type(v) == str { eval(v, mode: "markup") } else { v }
// A rate is a fraction in 0..1; a percent is already out of 100. Keeping the two
// straight is the only arithmetic this template does.
#let pct(rate) = str(calc.round(rate * 100)) + "%"
#let pct1(value) = str(calc.round(value, digits: 0)) + "%"
#let status-color(status) = {
if status == "meeting" { ok-color }
else if status == "developing" { mid-color }
else if status == "not yet" { bad-color }
else { thin-color }
}
#let rate-color(rate) = {
if rate >= threshold { ok-color }
else if rate >= threshold * 0.6 { mid-color }
else { bad-color }
}
#let badge(label, color) = box(
fill: color.lighten(82%),
radius: 3pt,
inset: (x: 5pt, y: 2.5pt),
)[#text(size: 0.72em, weight: "bold", fill: color.darken(12%))[#label]]
// Two boxes side by side rather than an overlay: no `place`, no coordinate
// arithmetic, and it degrades to something sensible at any width.
#let bar(rate, color: accent, width: 3.6cm) = {
let r = calc.max(0.0, calc.min(1.0, rate))
box(baseline: 0.15em)[
#stack(
dir: ltr,
box(width: width * r, height: 0.62em, fill: color, radius: (left: 2pt)),
box(width: width * (1.0 - r), height: 0.62em, fill: luma(232), radius: (right: 2pt)),
)
]
}
#let stat-card(label, value, note: none) = block(
width: 100%,
fill: luma(247),
radius: 4pt,
inset: (x: 10pt, y: 9pt),
)[
#text(size: 0.75em, fill: luma(95))[#upper(label)]
#v(0.15em)
#text(size: 1.45em, weight: "bold", fill: accent)[#value]
#if note != none [
#v(0.1em)
#text(size: 0.78em, fill: luma(95))[#note]
]
]
// ─────────────────────────────────────────────────────────────────────────────
// Heading
// ─────────────────────────────────────────────────────────────────────────────
#block(below: 0.6em)[
#text(size: 1.5em, weight: "bold")[#cb-meta.assessment.title]
#h(0.6em)
#text(size: 1em, fill: luma(110))[
#cb-meta.course.code — #cb-meta.course.title
]
]
#block(below: 1.2em)[
#text(size: 0.95em)[
*#cb-data.at("name", default: cb-data.student-key)*
#{
let sid = cb-data.at("sid", default: none)
if sid != none [ · #sid ]
}
#{
let form = cb-data.at("form", default: none)
if form != none [ · Form #form ]
}
#{
let date = cb-meta.assessment.at("date", default: none)
if date != none [ · #date ]
}
]
]
// ─────────────────────────────────────────────────────────────────────────────
// Score
// ─────────────────────────────────────────────────────────────────────────────
#let score = cb-data.score
#let standing = cb-data.at("standing", default: none)
#grid(
columns: (1fr, 1fr, 1fr),
gutter: 10pt,
stat-card(
"score",
pct1(score.percent),
note: str(score.points) + " of " + str(score.possible) + " points"
+ (if score.at("bonus", default: 0.0) > 0.0 { " (+" + str(score.bonus) + " bonus)" } else { "" }),
),
stat-card(
"questions",
str(score.correct) + " / " + str(score.items),
note: "answered correctly",
),
if standing != none and show-comparison {
stat-card(
"class",
pct1(standing.class-mean),
note: "class average · you are in the " + standing.band,
)
} else {
stat-card("class size", str(cb-meta.at("class-size", default: 0)), note: "students")
},
)
// ─────────────────────────────────────────────────────────────────────────────
// Levels
// ─────────────────────────────────────────────────────────────────────────────
#if cb-data.levels.len() > 0 [
= How you did, by kind of thinking
#text(size: 0.9em, fill: luma(95))[
Questions are written at levels. Scoring well on recall and less well on
application is a different problem from scoring evenly and low, and it has a
different fix.
]
#v(0.5em)
#table(
columns: (auto, 1fr, auto, auto, auto),
stroke: none,
align: (left + horizon, left + horizon, left + horizon, right + horizon, left + horizon),
inset: (x: 5pt, y: 5pt),
fill: (_, row) => if calc.odd(row) { luma(250) } else { white },
table.header(
text(size: 0.78em, fill: luma(95))[LEVEL],
text(size: 0.78em, fill: luma(95))[WHAT IT ASKS],
text(size: 0.78em, fill: luma(95))[YOU],
text(size: 0.78em, fill: luma(95))[],
text(size: 0.78em, fill: luma(95))[CLASS],
),
..cb-data.levels.map(level => (
[*#level.name* #text(size: 0.8em, fill: luma(120))[(#level.items)]],
text(size: 0.85em, fill: luma(80))[#level.blurb],
bar(level.rate, color: rate-color(level.rate), width: 2.6cm),
[#pct(level.rate)],
{
let class-rate = level.at("class-rate", default: none)
if class-rate != none and show-comparison {
text(size: 0.85em, fill: luma(100))[#pct(class-rate)]
} else { [] }
},
)).flatten(),
)
]
// ─────────────────────────────────────────────────────────────────────────────
// Objectives
// ─────────────────────────────────────────────────────────────────────────────
#if cb-data.objectives.len() > 0 [
= What you have learned, objective by objective
#text(size: 0.9em, fill: luma(95))[
Each line is one thing the course asked you to be able to do, and how many
questions measured it. Two or three questions is thin evidence, so treat a
single line as a hint rather than a verdict; the pattern across lines is what
to trust.
]
#v(0.5em)
#table(
columns: (auto, 1fr, auto, auto, auto),
stroke: none,
align: (center + horizon, left + horizon, center + horizon, left + horizon, right + horizon),
inset: (x: 5pt, y: 5pt),
fill: (_, row) => if calc.odd(row) { luma(250) } else { white },
table.header(
text(size: 0.78em, fill: luma(95))[],
text(size: 0.78em, fill: luma(95))[OBJECTIVE],
text(size: 0.78em, fill: luma(95))[Q],
text(size: 0.78em, fill: luma(95))[YOU],
text(size: 0.78em, fill: luma(95))[CLASS],
),
..cb-data.objectives.map(objective => (
text(fill: status-color(objective.status), weight: "bold")[#objective.symbol],
{
markup(objective.text)
if objective.at("thin-evidence", default: false) {
[ #badge("too few questions to say", thin-color)]
}
},
text(size: 0.85em, fill: luma(110))[#objective.items],
{
stack(
dir: ltr,
spacing: 5pt,
bar(objective.rate, color: status-color(objective.status), width: 2.1cm),
text(size: 0.9em)[#pct(objective.rate)],
)
},
{
let class-rate = objective.at("class-rate", default: none)
if class-rate != none and show-comparison {
text(size: 0.85em, fill: luma(100))[#pct(class-rate)]
} else { [] }
},
)).flatten(),
)
#v(0.4em)
#text(size: 0.78em, fill: luma(110))[
#text(fill: ok-color, weight: "bold")[✓] meeting · #text(fill: mid-color, weight: "bold")[~]
developing · #text(fill: bad-color, weight: "bold")[✗] not yet ·
#text(fill: thin-color, weight: "bold")[?] not enough evidence. The line is
drawn at #pct(threshold).
]
]
// ─────────────────────────────────────────────────────────────────────────────
// Strengths and focus
// ─────────────────────────────────────────────────────────────────────────────
#let strengths = cb-data.at("strengths", default: ())
#let focus = cb-data.at("focus", default: ())
#if strengths.len() > 0 or focus.len() > 0 [
= Where to put your time
#grid(
columns: (1fr, 1fr),
gutter: 12pt,
if focus.len() > 0 {
block(
width: 100%,
fill: bad-color.lighten(93%),
radius: 4pt,
inset: 10pt,
)[
#text(weight: "bold", fill: bad-color.darken(15%))[Work on these first]
#v(0.35em)
#for objective in focus [
- #markup(objective.text)
#text(size: 0.8em, fill: luma(110))[ (#pct(objective.rate) of #objective.items)]
]
]
} else { [] },
if strengths.len() > 0 {
block(
width: 100%,
fill: ok-color.lighten(93%),
radius: 4pt,
inset: 10pt,
)[
#text(weight: "bold", fill: ok-color.darken(18%))[Solid, keep it]
#v(0.35em)
#for objective in strengths [
- #markup(objective.text)
]
]
} else { [] },
)
]
// ─────────────────────────────────────────────────────────────────────────────
// Study plan
// ─────────────────────────────────────────────────────────────────────────────
#let study = cb-data.at("study", default: ())
#if show-study and study.len() > 0 [
= What to read
#text(size: 0.9em, fill: luma(95))[
These are the sections behind the objectives above, taken from the course
reading list rather than chosen generically.
]
#v(0.4em)
#for group in study [
#block(breakable: false, above: 0.8em)[
== #markup(group.text)
#for reading in group.readings [
#block(inset: (left: 0.8em), above: 0.35em)[
#{
let url = reading.at("url", default: none)
let cite = text(weight: "bold")[#reading.citation]
if url != none { link(url)[#cite] } else { cite }
}
#text(size: 0.85em, fill: luma(110))[
— #reading.lecture-title (#reading.lecture)#{
if reading.at("supplemental", default: false) { ", supplemental" }
}
]
#{
let focus-note = reading.at("focus", default: none)
if focus-note != none [
\ #text(size: 0.9em)[#markup(focus-note)]
]
}
]
]
]
]
]
// ─────────────────────────────────────────────────────────────────────────────
// Question map
// ─────────────────────────────────────────────────────────────────────────────
#let questions = cb-data.at("questions", default: ())
#let question-box(q) = {
let color = if q.at("blank", default: false) { luma(160) }
else if q.at("correct", default: false) == true { ok-color }
else if q.at("credit", default: 0.0) > 0.0 { mid-color }
else { bad-color }
box(
width: 100%,
fill: color.lighten(85%),
stroke: 0.5pt + color.lighten(45%),
radius: 3pt,
inset: (x: 2pt, y: 4pt),
)[
#align(center)[
#text(size: 0.85em, weight: "bold", fill: color.darken(18%))[#q.number]
#{
let level = q.at("level", default: none)
if level != none {
linebreak()
text(size: 0.62em, fill: luma(110))[L#level]
}
}
]
]
}
#if show-question-map and questions.len() > 0 [
= Question by question
#text(size: 0.9em, fill: luma(95))[
Numbers refer to your own copy of the exam. The questions themselves are not
reproduced here.
]
#v(0.5em)
#grid(
columns: (1fr,) * 10,
gutter: 4pt,
..questions.map(question-box),
)
#v(0.5em)
#text(size: 0.78em, fill: luma(110))[
#box(width: 0.7em, height: 0.7em, fill: ok-color.lighten(70%), radius: 2pt) correct ·
#box(width: 0.7em, height: 0.7em, fill: mid-color.lighten(70%), radius: 2pt) partial ·
#box(width: 0.7em, height: 0.7em, fill: bad-color.lighten(70%), radius: 2pt) incorrect ·
#box(width: 0.7em, height: 0.7em, fill: luma(210), radius: 2pt) blank
]
]
// ─────────────────────────────────────────────────────────────────────────────
// Notes on what was missed
// ─────────────────────────────────────────────────────────────────────────────
#let missed = questions.filter(q =>
q.at("credit", default: 0.0) < 0.999 and q.at("feedback", default: none) != none
)
#if show-feedback and missed.len() > 0 [
= Notes on the ones you missed
#text(size: 0.9em, fill: luma(95))[
Each note is written for the specific option you chose, so it names the idea
behind that answer rather than restating the right one.
]
#v(0.5em)
#for q in missed [
#block(breakable: false, above: 0.7em, width: 100%)[
#grid(
columns: (2.6em, 1fr),
gutter: 6pt,
align(top)[#text(weight: "bold", fill: accent)[#q.number.]],
[
#markup(q.feedback)
#{
let taught = q.at("taught-in", default: ())
if taught.len() > 0 [
\ #text(size: 0.82em, fill: luma(110))[Covered in: #taught.join("; ")]
]
}
],
)
]
]
]
// ─────────────────────────────────────────────────────────────────────────────
// Footer note
// ─────────────────────────────────────────────────────────────────────────────
#v(1.2em)
#line(length: 100%, stroke: 0.5pt + luma(210))
#v(0.4em)
#text(size: 0.76em, fill: luma(120))[
Generated #cb-meta.generator.on by #cb-meta.generator.tool #cb-meta.generator.version
from #cb-meta.at("class-size", default: 0) student(s). Percentages on a handful of
questions carry wide uncertainty; bring this to office hours rather than reading
it as a verdict.
]