Scoring
Likert totals and option keying into named variables, computed from the response snapshot.
@dimah-form/scoring is the first-party plugin for scored questionnaires. Named variables accumulate points from selected options (and mapped number / boolean fields). Likert items map a field to one variable; keying items list { variable, points } on each option. After fill you get totals plus optional interpretation bands.
Scores are derived. They are never written into answers. The plugin does not add tables. Compute from the response definition snapshot and stored answers — never the live questionnaire.
Install
pnpm add @dimah-form/scoringimport { scoringPlugin } from "@dimah-form/scoring";
import { dimahForm } from "@dimah-form/server";
export const form = dimahForm({
database,
forms,
plugins: [scoringPlugin()],
});"use client";
import { createFormClient } from "@dimah-form/react";
import { scoringClientPlugin } from "@dimah-form/scoring/client";
import type { Form } from "@/lib/form";
const plugins = [scoringClientPlugin()] as const;
export const formClient = createFormClient<Form, typeof plugins>({
plugins,
});Import the server factory from @dimah-form/scoring and the browser companion from @dimah-form/scoring/client. Persist to your database in onScore if you need a column — the library will not store scores.
scoringPlugin({
onScore: async ({ response, scores }) => {
await db.insert(results).values({
responseId: response.id,
payload: scores,
});
},
});onScore runs from afterSubmit (after persist). Do not mutate response.answers. Throwing fails the submit HTTP response after the row is already stored.
Author a scored form
Own the scoring namespace on the opaque meta bag. Use built-in select plus meta.widget: "radio" for Likert items — do not add a likert field type.
import { scoringPlugin } from "@dimah-form/scoring";
import { createDefineForm } from "@dimah-form/server";
import type { FormDefinitionUi } from "@dimah-form/ui/types";
import { fieldTypes } from "@/lib/field-types";
const plugins = [scoringPlugin()] as const;
const defineAppForm = createDefineForm({ fieldTypes, plugins });
const likert = [
{ value: "0", label: "Not at all", meta: { scoring: { points: 0 } } },
{ value: "1", label: "Several days", meta: { scoring: { points: 1 } } },
{
value: "2",
label: "More than half the days",
meta: { scoring: { points: 2 } },
},
{ value: "3", label: "Nearly every day", meta: { scoring: { points: 3 } } },
];
export const gad7 = defineAppForm({
title: "GAD-7",
meta: {
scoring: {
variables: [{ id: "gad7", label: "GAD-7", min: 0, max: 21 }],
bands: [
{ variable: "gad7", from: 0, to: 4, label: "Minimal" },
{ variable: "gad7", from: 5, to: 9, label: "Mild" },
{ variable: "gad7", from: 10, to: 14, label: "Moderate" },
{ variable: "gad7", from: 15, to: 21, label: "Severe" },
],
},
},
fields: [
{
id: "q1",
type: "select",
required: true,
label: "Feeling nervous, anxious, or on edge",
options: likert,
meta: { widget: "radio", scoring: { variable: "gad7" } },
},
],
} satisfies FormDefinitionUi<typeof fieldTypes, typeof plugins>);createDefineForm({ plugins }) types meta.scoring. satisfies FormDefinitionUi<…, typeof plugins> adds UI keys (widget) next to the namespace. Forms without meta.scoring still init — the namespaced schema runs only when the key is present.
Contributors
| Field type | Likert (field.variable + option.points) | Keying (option.add) |
|---|---|---|
select | Chosen option's points | That option's add rows |
multiSelect | Sum of selected option points ([] is 0) | Sum of selected options' add rows |
number | The numeric value | — |
boolean | true → 1, false → 0 | — |
Hidden showWhen fields do not contribute and do not count as missing. If every contributing item for a variable is hidden, see Missing items.
Option keying
option.points is sugar for adding that number to field.meta.scoring.variable. Use option.add when a choice keys one or more variables — and omit field.meta.scoring. Do not mix points with add, and do not set field.variable on a keying item. Reverse scoring does not apply to add.
{
id: "style",
type: "select",
options: [
{
value: "parties",
label: "A crowded party",
meta: { scoring: { add: [{ variable: "extraversion", points: 2 }] } },
},
{
value: "book",
label: "A quiet evening",
meta: { scoring: { add: [{ variable: "introversion", points: 2 }] } },
},
{
value: "both",
label: "Depends on the week",
meta: {
scoring: {
add: [
{ variable: "extraversion", points: 1 },
{ variable: "introversion", points: 1 },
],
},
},
},
],
}Selecting parties adds 2 to extraversion (introversion gets 0 from this item and still counts as answered). An unanswered visible keying item increments missing on every variable any of its options names. An option with no meta.scoring contributes nothing.
Unknown add variables, unused variables, mixed points/add, and field.variable plus option.add fail validateDefinition (SCORING_UNKNOWN_VARIABLE, SCORING_UNUSED_VARIABLE, SCORING_INVALID_META, SCORING_OPTION_ADD_MIX).
Reverse scoring
reversed = min + max - points.
- select — min/max are that field's option points (a 0–3 item on a 0–21 scale reverses as
3 − points).option.addis not reversed. - number — min is
field.min ?? 0;field.maxis required whenreverseis true (notvariables[].max). - boolean —
1 − value. - multiSelect — not supported (
SCORING_REVERSE_MULTISELECT).
Set meta.scoring.reverse: true on the field.
Missing items
variables[].missing defaults to "incomplete":
| Policy | Unanswered visible items |
|---|---|
incomplete (default) | raw is null, complete is false |
zero | Count as 0 (running Likert total) |
omit | Drop from the sum; raw is null only when nothing scored |
Prefer the default for clinical totals. Set zero when a running quiz score is the product. If every contributing item is hidden, incomplete and omit are null; zero is 0.
Bands and optional sums
Bands are interpretation, not scoring. from / to are inclusive; omit either for an open end. The first matching band in document order wins.
Optional typed sums — not a formula language, and never eval. vars must be unique variable ids (not other formulas):
formulas: [{ id: "total", op: "sum", vars: ["subscaleA", "subscaleB"] }];Read scores
import { scoreResponse } from "@dimah-form/scoring/client";
const live = scoreResponse(session.snapshot, session.answers);
const stored = await formClient.getResponseScores({ responseId });
const fromApi = await form.api.getResponseScores({
query: { responseId },
});Pass the snapshot you are filling or the stored response.definition. Live preview during fill is scoreResponse(session.snapshot, session.answers) — do not fork useFormResponse. Mapping issues throw (collectScoringIssues is the non-throwing check).
{
complete: boolean,
variables: {
gad7: {
raw: number | null,
min?: number,
max?: number,
missing: number,
band?: string,
complete: boolean,
label?: string,
},
},
}Guard operation for GET /scoring/response is getResponseScores. Document validity (unknown variables, missing option points) is checked at dimahForm(), saveForm, and live form reads via metaSchema + validateDefinition. Response snapshots are not re-checked on getResponse; scoreResponse still validates mapping when you compute. Scoring is not answer validation.
Malformed mapping uses stable codes such as SCORING_UNKNOWN_VARIABLE, SCORING_UNUSED_VARIABLE, SCORING_MISSING_POINTS, SCORING_REVERSE_MULTISELECT, and SCORING_OPTION_ADD_MIX (form.$ERROR_CODES).
Every variables[].id must be referenced by field.meta.scoring.variable or option.meta.scoring.add. An unmapped variable fails validateDefinition (SCORING_UNUSED_VARIABLE) instead of scoring as a complete 0.