dimah-formv0.5.0

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/scoring
lib/form.ts
import { scoringPlugin } from "@dimah-form/scoring";
import { dimahForm } from "@dimah-form/server";

export const form = dimahForm({
  database,
  forms,
  plugins: [scoringPlugin()],
});
lib/client.ts
"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.

lib/forms/gad7.ts
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 typeLikert (field.variable + option.points)Keying (option.add)
selectChosen option's pointsThat option's add rows
multiSelectSum of selected option points ([] is 0)Sum of selected options' add rows
numberThe numeric value
booleantrue → 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.add is not reversed.
  • number — min is field.min ?? 0; field.max is required when reverse is true (not variables[].max).
  • boolean1 − value.
  • multiSelect — not supported (SCORING_REVERSE_MULTISELECT).

Set meta.scoring.reverse: true on the field.

Missing items

variables[].missing defaults to "incomplete":

PolicyUnanswered visible items
incomplete (default)raw is null, complete is false
zeroCount as 0 (running Likert total)
omitDrop 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.

On this page