# Scoring (https://form.dimah.dev/docs/scoring)



`@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 [#install]

```bash
pnpm add @dimah-form/scoring
```

```ts title="lib/form.ts"
import { scoringPlugin } from "@dimah-form/scoring";
import { dimahForm } from "@dimah-form/server";

export const form = dimahForm({
  database,
  forms,
  plugins: [scoringPlugin()],
});
```

```ts title="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.

```ts
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 [#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.

```ts title="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 [#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](#missing-items).

### Option keying [#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`.

```ts
{
  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 [#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`).
* **boolean** — `1 − value`.
* **multiSelect** — not supported (`SCORING_REVERSE_MULTISELECT`).

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

### Missing items [#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-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):

```ts
formulas: [{ id: "total", op: "sum", vars: ["subscaleA", "subscaleB"] }];
```

***

## Read scores [#read-scores]

```ts
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).

```ts
{
  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`.
