Utilities

GradeCalculator

Works out a weighted current grade from the marks so far, and the mark needed on what is left to reach each target, or that it is out of reach.

#smart#interactive#education#grades#calculator#forms

Demo

Preview

Grade calculator

Current grade 79.33 (C), from 60 of 100 weight marked.

Current grade
79.33
If the rest scores 0
47.60
Best still possible
87.60
The mark needed on what is left, for each target
TargetGradeNeeded on the 40 leftOutlook
A90More than 100Out of reach
B8081.00Possible
C7056.00Possible
Pass506.00Possible

Module out of 20

The weights add to 12, so each counts as its share of that.

Current grade 13.50 (Pass), from 6 of 12 weight marked.

Current grade
13.50
If the rest scores 0
6.75
Best still possible
16.75
The mark needed on what is left, for each target
TargetGradeNeeded on the 6 leftOutlook
Pass106.50Possible
Merit1414.50Possible
Distinction1618.50Possible

Change a mark to see on_change.

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/grade_calculator.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/grade_calculator.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/grade_calculator.qwe
component GradeCalculatorDemoLive() {    state current = null    <div.demo-stack>        <GradeCalculator id="grade-calculator-demo-merit" title="Module out of 20" top={20} name="module"                         targets={[{ label: "Pass", min: 10 }, { label: "Merit", min: 14 }, { label: "Distinction", min: 16 }]}                         parts={[{ name: "Project", weight: 3, mark: "15" }, { name: "Viva", weight: 3, mark: "60%" }, { name: "Exam", weight: 6 }]}                         on_change={(f) => { current = f == null ? "none" : str(round(f.current, 2, "half_up")) }}/>        <p.demo-note>{current == null ? "Change a mark to see on_change." : `on_change: the current grade is ${current}.`}</p>    </div>}view GradeCalculatorDemo() =    <div.demo-stack>        <GradeCalculator id="grade-calculator-demo"                         parts={[{ name: "Essay", weight: 20, mark: 85 }, { name: "Midterm", weight: 30, mark: "36/50" },                                 { name: "Labs", weight: 10, mark: 90 }, { name: "Final exam", weight: 40 }]}/>        <GradeCalculatorDemoLive/>    </div>

Source

ui/grade_calculator.qwe
// GradeCalculator: a course's weighted components, worked out as they are typed.// The current grade counts only what has a mark, its weights scaled up to fill the// whole, so 80 on the first 20% is a current 80 and not 16. For each target it says// what mark is needed on all that is left, rounded up to where that mark really// reaches the target, or that the target is secured whatever comes, or out of reach,// with the best grade still possible. Weights need not add to 100: each counts as// its share of their total, and it says so. Marks are out of `top`, and may be typed// as a fraction ("17/20") or a percentage ("85%"). All of it in decimals.////   <GradeCalculator parts={[{ name: "Essay", weight: 20, mark: 85 }, { name: "Exam", weight: 80 }]}/>//   <GradeCalculator top={20} targets={[{ label: "Pass", min: 10 }, { label: "Merit", min: 14 }]}//                    on_change={(f) => { grade = f.current }}/>//// parts: the rows to start with, each { name, weight, mark? } (a number or text; no// mark means not marked yet). targets: each { label, min }, on the same scale as// the marks (A 90, B 80, C 70 and Pass 50). goal: a target of one's own, with its own// field, left empty at the start unless given. top: the highest mark (100). title:// the heading ("Grade calculator"). id: a prefix for its fields' ids// ("grade-calculator"), unique on the page. The goal is named goal, and each row's// fields part_<n>, weight_<n> and mark_<n>, numbered as the rows were made, so a// form around it sends them; name: a prefix for those names. on_change(f): inside// one of your components, given { current, secured, best, left, targets } as// decimals after each change, targets as worked out below; null when a row is wrong.//// grade_calculator_figures(parts, targets, top) is the arithmetic, for a program's own// use: parts are { weight, mark } in decimals, mark null when there is none yet.// It gives { weight, marked, left, current, secured, best, targets }: current is null// with nothing marked, secured is the grade if the rest scored 0, best if the rest// scored top, and each target is { label, min, need, state }, where state is "secured"// or "open" while marks are to come ("open" with a need), "out" (out of reach),// and "reached" or "missed" when nothing is left.import ui/theme.qwe use ui_clslet grade_calculator_css = css`.ui-grade-calc { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; min-width: 0; max-width: 640px; padding: 20px 22px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font); }.ui-grade-calc .ui-grade-calc-title { display: block; margin: 0; font-size: 16px; font-weight: 650; }.ui-grade-calc-rows { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }.ui-grade-calc-head, .ui-grade-calc-row { display: grid; grid-template-columns: minmax(0, 1fr) 84px 96px 32px; gap: 8px; align-items: center; }.ui-grade-calc-head { color: var(--ui-muted); font-size: 12px; font-weight: 600; }.ui-grade-calc-row label { display: none; font-size: 12px; color: var(--ui-muted); }.ui-grade-calc input { box-sizing: border-box; width: 100%; min-width: 0; height: 36px; padding: 0 10px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px var(--ui-font); }.ui-grade-calc input:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-grade-calc input[aria-invalid] { border-color: var(--ui-danger); }.ui-grade-calc-num input { text-align: right; font-variant-numeric: tabular-nums; }.ui-grade-calc-remove { width: 32px; height: 32px; padding: 0; border: 1px solid transparent; border-radius: var(--ui-radius-sm); background: none; color: var(--ui-muted); font: 16px/1 var(--ui-font); cursor: pointer; }.ui-grade-calc-remove:hover { border-color: var(--ui-border); color: var(--ui-danger); }.ui-grade-calc-remove:focus-visible, .ui-grade-calc-add:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-grade-calc-bar { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 10px; }.ui-grade-calc-add { height: 34px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 13px var(--ui-font); cursor: pointer; }.ui-grade-calc-add:hover { border-color: var(--ui-strong); background: var(--ui-subtle); }.ui-grade-calc-goal { display: grid; gap: 4px; width: 130px; font-size: 13px; font-weight: 500; }.ui-grade-calc-goal input { text-align: right; font-variant-numeric: tabular-nums; }.ui-grade-calc-note { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-grade-calc-error { margin: 0; color: var(--ui-danger); font-size: 13px; }.ui-grade-calc-now { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 0; padding: 12px 16px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); }.ui-grade-calc-now div { display: grid; gap: 2px; min-width: 0; }.ui-grade-calc-now dt { color: var(--ui-muted); font-size: 12px; }.ui-grade-calc-now dd { margin: 0; font-size: 18px; font-weight: 650; font-variant-numeric: tabular-nums; }.ui-grade-calc-goals { width: 100%; border-collapse: collapse; }.ui-grade-calc-goals th, .ui-grade-calc-goals td { padding: 7px 6px; border-bottom: 1px solid var(--ui-border); text-align: left; font-variant-numeric: tabular-nums; }.ui-grade-calc-goals thead th { color: var(--ui-muted); font-size: 12px; font-weight: 600; }.ui-grade-calc-goals tbody tr:last-child th, .ui-grade-calc-goals tbody tr:last-child td { border-bottom: 0; }.ui-grade-calc-goals tbody th { font-weight: 600; }.ui-grade-calc-state { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; background: var(--ui-subtle); }.ui-grade-calc-secured, .ui-grade-calc-reached { background: var(--ui-success-soft); color: var(--ui-success); }.ui-grade-calc-open { background: var(--ui-info-soft); color: var(--ui-info); }.ui-grade-calc-out, .ui-grade-calc-missed { background: var(--ui-danger-soft); color: var(--ui-danger); }@media (max-width: 480px) {  .ui-grade-calc-head { display: none; }  .ui-grade-calc-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 32px; padding-bottom: 8px; border-bottom: 1px solid var(--ui-border); }  .ui-grade-calc-name { grid-column: 1 / -1; }  .ui-grade-calc-row label { display: block; }  .ui-grade-calc-now { grid-template-columns: minmax(0, 1fr); }}`let grade_calculator_targets = [{ label: "A", min: 90 }, { label: "B", min: 80 }, { label: "C", min: 70 }, { label: "Pass", min: 50 }]fn grade_calculator_number(text) {    let t = re.replace(trim(str(text ?? "")), "[%\\s]", "")    t == "" ? null : dec(t)}// A weight as typed: null when empty, an error value when it is not a number of 0 or more.fn grade_calculator_weight(text) {    let d = grade_calculator_number(text)    if d == null { return null }    if is_err(d) || d < 0 || d > 100000m { return err("a weight is a number of 0 or more") }    d}// A mark as typed, on the scale 0 to top: "85", "85%", "17/20". Null when empty.fn grade_calculator_mark(text, top) {    let t = re.replace(trim(str(text ?? "")), "\\s", "")    if t == "" { return null }    let bad = err(`a mark is a number from 0 to ${str(top)}, or a fraction such as 17/20`)    let parts = split(t, "/")    if len(parts) > 2 { return bad }    if len(parts) == 2 {        let a = dec(parts[0])        let b = dec(parts[1])        if is_err(a) || is_err(b) || b <= 0 || a < 0 || a > b || b > 1000000m { return bad }        return a * dec(top) / b    }    let pct = ends_with(t, "%")    let d = grade_calculator_number(t)    if is_err(d) || d < 0 { return bad }    let m = pct ? d * dec(top) / 100 : d    m > dec(top) ? bad : m}fn grade_calculator_figures(parts: list, targets: list, top) {    let high = dec(top)    let weight = parts | map((p) => p.weight) | sum()    let done = parts | filter((p) => p.mark != null)    let marked = done | map((p) => p.weight) | sum()    let points = done | map((p) => p.weight * p.mark) | sum()    let left = weight - marked    let current = marked > 0 ? points / marked : null    let secured = weight > 0 ? points / weight : null    let best = weight > 0 ? (points + left * high) / weight : null    let goals = targets | map((t) => {        let wanted = dec(t.min)        if weight == 0 { return { label: t.label, min: wanted, need: null, state: "open" } }        if left == 0 { return { label: t.label, min: wanted, need: null, state: secured >= wanted ? "reached" : "missed" } }        let need = (wanted * weight - points) / left        // Rounded up: a mark rounded down would land just under the target.        let state = need <= 0 ? "secured" : need > high ? "out" : "open"        { label: t.label, min: wanted, need: state == "open" ? round(need, 2, "ceiling") : null, state }    })    { weight: dec(weight), marked: dec(marked), left: dec(left), current, secured, best, targets: goals }}// The rows as typed into what the figures take, with what is wrong in them, by row.fn grade_calculator_read(rows: list, top) {    let problems = []    let parts = []    for r in rows {        let w = grade_calculator_weight(r.weight)        let m = grade_calculator_mark(r.mark, top)        let called = filled(trim(str(r.name ?? ""))) ?? "This row"        if is_err(w) { problems = problems | push({ id: r.id, field: "weight", text: `${called}: ${w.error}.` }) }        if is_err(m) { problems = problems | push({ id: r.id, field: "mark", text: `${called}: ${m.error}.` }) }        if w == null && m != null && !is_err(m) { problems = problems | push({ id: r.id, field: "weight", text: `${called}: has a mark but no weight.` }) }        if !is_err(w) && !is_err(m) && w != null { parts = parts | push({ weight: w, mark: m }) }    }    { parts, problems }}fn grade_calculator_rows(parts) {    let given = parts ?? []    for i in 0..len(given) {        let p = given[i]        { id: i, name: str(p.name ?? ""), weight: p.weight == null ? "" : str(p.weight), mark: p.mark == null ? "" : str(p.mark) }    }}fn grade_calculator_show(d) = d == null ? "—" : str(round(d, 2, "half_up"))// The highest target at or below the grade, for its label.fn grade_calculator_band(grade, targets: list) {    if grade == null { return null }    let met = targets | filter((t) => dec(t.min) <= grade) | sort_by((t) => dec(t.min))    len(met) == 0 ? null : last(met).label}component GradeCalculator(parts = null, targets = null, goal = null, top = 100, title: str = "Grade calculator",                          id: str = "grade-calculator", name: str? = null, on_change = null, class: str? = null) {    style global grade_calculator_css    state rows = grade_calculator_rows(parts)    state next = len(parts ?? [])    state own = goal == null ? "" : str(goal)    state fresh = -1    state adder = null    let named = (n: str) => name != null ? `${name}_${n}` : n    let wanted = targets ?? grade_calculator_targets    let own_goal = grade_calculator_number(own)    let goal_bad = own_goal != null && (is_err(own_goal) || own_goal < 0 || own_goal > dec(top))    let all_goals = own_goal == null || goal_bad ? wanted : wanted + [{ label: "Your target", min: own_goal }]    let read = grade_calculator_read(rows, top)    let f = grade_calculator_figures(read.parts, all_goals, top)    let wrong = (id_: int, field: str) => any(read.problems, (p) => p.id == id_ && p.field == field)    let band = grade_calculator_band(f.current, wanted)    let empty = f.weight == 0    let scaled = !empty && f.weight != 100    let tell = () => {        if on_change != null {            let r = grade_calculator_read(rows, top)            on_change(len(r.problems) > 0 ? null : grade_calculator_figures(r.parts, all_goals, top))        }    }    let change = (row: int, field: str, text: str) => {        rows = rows | map((r) => r.id == row ? set(r, field, text) : r)        tell()    }    let add = () => {        fresh = next        rows = rows | push({ id: next, name: "", weight: "", mark: "" })        next += 1    }    let drop = (row: int) => {        rows = rows | filter((r) => r.id != row)        tell()        if adder != null { adder.focus() }    }    let shown_state = (t) => match t.state {        "secured" => "Secured"        "out" => "Out of reach"        "reached" => "Reached"        "missed" => "Missed"        _ => "Possible"    }    let shown_need = (t) => match t.state {        "secured" => "Any mark"        "out" => `More than ${str(top)}`        "open" => t.need == null ? "—" : str(t.need)        _ => "Nothing left"    }    let summary = empty ? "Add a weight to begin." : f.current == null ? "Nothing is marked yet."        : `Current grade ${grade_calculator_show(f.current)}${band != null ? ` (${band})` : ""}, from ${str(f.marked)} of ${str(f.weight)} weight marked.`    <section class={ui_cls(["ui-grade-calc", class])} aria-labelledby={`${id}-title`}>        <h3.ui-grade-calc-title id={`${id}-title`}>{title}</h3>        <div.ui-grade-calc-head aria-hidden="true"><span>Component</span><span>Weight</span><span>Mark</span><span/></div>        <ul.ui-grade-calc-rows>            {for r in rows {                let n = r.id                <li.ui-grade-calc-row key={n}>                    <div.ui-grade-calc-name>                        <label for={`${id}-name-${n}`}>Component</label>                        <input type="text" id={`${id}-name-${n}`} name={named(`part_${n}`)} value={r.name} aria-label="Component" autocomplete="off" maxlength="80"                               @mount={(el) => { if fresh == n { el.focus() } }} @input={(e) => { change(n, "name", e.value) }}/>                    </div>                    <div.ui-grade-calc-num>                        <label for={`${id}-weight-${n}`}>Weight</label>                        <input type="text" inputmode="decimal" id={`${id}-weight-${n}`} name={named(`weight_${n}`)} value={r.weight} aria-label={`Weight of ${filled(r.name) ?? "this component"}`}                               autocomplete="off" aria-invalid={wrong(n, "weight") ? "true" : null} @input={(e) => { change(n, "weight", e.value) }}/>                    </div>                    <div.ui-grade-calc-num>                        <label for={`${id}-mark-${n}`}>Mark</label>                        <input type="text" id={`${id}-mark-${n}`} name={named(`mark_${n}`)} value={r.mark} aria-label={`Mark for ${filled(r.name) ?? "this component"}`} placeholder="not yet"                               autocomplete="off" aria-invalid={wrong(n, "mark") ? "true" : null} @input={(e) => { change(n, "mark", e.value) }}/>                    </div>                    <button.ui-grade-calc-remove type="button" aria-label={`Remove ${filled(r.name) ?? "this component"}`} @click={() => { drop(n) }}><span aria-hidden="true">×</span></button>                </li>            }}        </ul>        <div.ui-grade-calc-bar>            <button.ui-grade-calc-add type="button" @mount={(el) => { adder = el }} @click={add}>Add a component</button>            <label.ui-grade-calc-goal>                Your target                <input type="text" inputmode="decimal" name={named("goal")} value={own} autocomplete="off" aria-invalid={goal_bad ? "true" : null}                       @input={(e) => { own = e.value; tell() }}/>            </label>        </div>        {for p in read.problems => <p.ui-grade-calc-error key={`${p.id}-${p.field}`} role="alert">{p.text}</p>}        {goal_bad && <p.ui-grade-calc-error role="alert">{`Your target is a number from 0 to ${str(top)}.`}</p>}        {scaled && <p.ui-grade-calc-note>{`The weights add to ${str(f.weight)}, so each counts as its share of that.`}</p>}        <p.ui-grade-calc-note aria-live="polite">{summary}</p>        {!empty && <dl.ui-grade-calc-now>            <div.ui-grade-calc-current><dt>Current grade</dt><dd>{grade_calculator_show(f.current)}</dd></div>            <div.ui-grade-calc-secured-now><dt>If the rest scores 0</dt><dd>{grade_calculator_show(f.secured)}</dd></div>            <div.ui-grade-calc-best><dt>Best still possible</dt><dd>{f.best == null ? "—" : str(round(f.best, 2, "down"))}</dd></div>        </dl>}        {!empty && <table.ui-grade-calc-goals>            <caption class="ui-sr">{f.left > 0 ? "The mark needed on what is left, for each target" : "Each target, with nothing left to mark"}</caption>            <thead><tr><th scope="col">Target</th><th scope="col">Grade</th><th scope="col">{`Needed on the ${str(f.left)} left`}</th><th scope="col">Outlook</th></tr></thead>            <tbody>                {for t in f.targets {                    let l = t.label                    <tr key={l}>                        <th scope="row">{l}</th>                        <td>{str(t.min)}</td>                        <td>{shown_need(t)}</td>                        <td><span class={`ui-grade-calc-state ui-grade-calc-${t.state}`}>{shown_state(t)}</span></td>                    </tr>                }}            </tbody>        </table>}    </section>}
Thread
27Useful? Like it, or add to its thread below.

Thread 0 posts

On GradeCalculator: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.