feat(questions): scale module (registry slot 4/7)

- scale.ts — schema (extends base with min/max/min_label/max_label),
  defaultStub (1..5), isAnswerEmpty (any finite number), full ingest +
  finalise + private ScaleStatsWip accumulator (mean = _sum / count,
  drops _sum on finalise — same pattern the legacy results.ts uses,
  now owned by the type module)
- scale.input.svelte — N-button rating row with active-state class +
  optional min/max captions below
- scale.builder.svelte — min/max numeric inputs + min_label/max_label
  text inputs (4 fields, same as today's FormBuilder branch)
- scale.results.svelte — mean + count meta line + per-bucket
  histogram bars
- scale.test.ts — 11 cases covering schema accept/reject (includes
  the boundary case that schemas don't enforce min<max — that's a
  form-level invariant), isAnswerEmpty (non-numeric / NaN / finite
  numbers), ingest + finalise (histogram, mean, garbage rejection,
  null mean for empty count, _sum drop verification), CSV +
  adminCellSummary

91 server tests pass (was 80). svelte-check + bun run build clean.
This commit is contained in:
mAi
2026-05-07 20:16:09 +02:00
parent 5f345eaf4b
commit 913e505b02
7 changed files with 308 additions and 1 deletions

View File

@@ -9,7 +9,7 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"start": "node build/index.js",
"test:server": "bun test ./src/lib/server/rate-limit.test.ts ./src/lib/server/public-scope.test.ts ./src/lib/server/results.test.ts ./src/lib/server/admin-route.test.ts ./src/lib/server/feedback-pure.test.ts ./src/lib/questions/registry.test.ts ./src/lib/questions/boolean.test.ts ./src/lib/questions/text.test.ts",
"test:server": "bun test ./src/lib/server/rate-limit.test.ts ./src/lib/server/public-scope.test.ts ./src/lib/server/results.test.ts ./src/lib/server/admin-route.test.ts ./src/lib/server/feedback-pure.test.ts ./src/lib/questions/registry.test.ts ./src/lib/questions/boolean.test.ts ./src/lib/questions/text.test.ts ./src/lib/questions/scale.test.ts",
"test:components": "bun --bun vitest run --config vitest.config.ts",
"test": "bun run test:server && bun run test:components"
},

View File

@@ -14,6 +14,7 @@ import type { AnyQuestionTypeModule } from './types';
import { BooleanQuestion } from './boolean';
import { ShortTextQuestion } from './short_text';
import { LongTextQuestion } from './long_text';
import { ScaleQuestion } from './scale';
// Order matters — drives the FormBuilder "+ Add" picker layout.
// As the remaining types land in subsequent commits they get appended here.
@@ -23,6 +24,7 @@ import { LongTextQuestion } from './long_text';
export const QUESTION_MODULES: readonly AnyQuestionTypeModule[] = [
ShortTextQuestion as AnyQuestionTypeModule,
LongTextQuestion as AnyQuestionTypeModule,
ScaleQuestion as AnyQuestionTypeModule,
BooleanQuestion as AnyQuestionTypeModule,
];

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import type { BuilderEditorProps } from './types';
let { question, update }: BuilderEditorProps = $props();
</script>
{#if question.type === 'scale'}
<div class="fb-builder__scale">
<div class="fb-question">
<label class="fb-question__label" for={`fb-builder-${question.id}-min`}>Min</label>
<input
id={`fb-builder-${question.id}-min`}
type="number"
class="fb-input"
min="0"
max="100"
value={question.min}
oninput={(e) => update({ min: Number((e.target as HTMLInputElement).value) })}
/>
</div>
<div class="fb-question">
<label class="fb-question__label" for={`fb-builder-${question.id}-max`}>Max</label>
<input
id={`fb-builder-${question.id}-max`}
type="number"
class="fb-input"
min="1"
max="100"
value={question.max}
oninput={(e) => update({ max: Number((e.target as HTMLInputElement).value) })}
/>
</div>
<div class="fb-question">
<label class="fb-question__label" for={`fb-builder-${question.id}-minlbl`}>Min label (optional)</label>
<input
id={`fb-builder-${question.id}-minlbl`}
class="fb-input"
maxlength="50"
value={question.min_label ?? ''}
oninput={(e) => {
const v = (e.target as HTMLInputElement).value;
update({ min_label: v || undefined });
}}
/>
</div>
<div class="fb-question">
<label class="fb-question__label" for={`fb-builder-${question.id}-maxlbl`}>Max label (optional)</label>
<input
id={`fb-builder-${question.id}-maxlbl`}
class="fb-input"
maxlength="50"
value={question.max_label ?? ''}
oninput={(e) => {
const v = (e.target as HTMLInputElement).value;
update({ max_label: v || undefined });
}}
/>
</div>
</div>
{/if}

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import type { ParticipantInputProps } from './types';
let { question, answer, setAnswer }: ParticipantInputProps = $props();
</script>
{#if question.type === 'scale'}
<div class="fb-scale">
{#each Array.from({ length: question.max - question.min + 1 }, (_, i) => i + question.min) as v (v)}
<button
type="button"
class="fb-scale__btn {answer === v ? 'fb-scale__btn--active' : ''}"
onclick={() => setAnswer(v)}
>
{v}
</button>
{/each}
</div>
{#if question.min_label || question.max_label}
<div class="fb-scale__labels">
<span>{question.min_label ?? question.min}</span>
<span>{question.max_label ?? question.max}</span>
</div>
{/if}
{/if}

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import type { ResultsBlockProps } from './types';
let { stats }: ResultsBlockProps = $props();
function pct(part: number, whole: number): number {
if (whole === 0) return 0;
return Math.round((part / whole) * 100);
}
function fmtMean(m: number | null): string {
if (m === null) return '—';
return m.toFixed(2).replace(/\.?0+$/, '');
}
</script>
{#if stats.type === 'scale'}
<div class="fb-results__meta">
Mean: {fmtMean(stats.mean)} · {stats.count} responses
</div>
<div class="fb-results__bars">
{#each stats.histogram as bucket (bucket.value)}
<div class="fb-results__row">
<span class="fb-results__row-label">{bucket.value}</span>
<div class="fb-results__bar-track">
<div class="fb-results__bar-fill" style="width: {pct(bucket.count, stats.count)}%;"></div>
</div>
<span class="fb-results__row-count">{bucket.count} ({pct(bucket.count, stats.count)}%)</span>
</div>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,100 @@
import { describe, test, expect } from 'bun:test';
import { ScaleQuestion, ScaleQuestionSchema } from './scale';
describe('ScaleQuestion.schema', () => {
test('accepts a valid 1-5 scale', () => {
expect(
ScaleQuestionSchema.safeParse({
id: 'rate',
label: 'How was it?',
required: true,
type: 'scale',
min: 1,
max: 5,
min_label: 'bad',
max_label: 'great',
}).success,
).toBe(true);
});
test('rejects when min ≥ max bounds (out of zod range — boundary check)', () => {
// schemas only enforce ranges, not min<max — that's the form-level
// invariant and lives in the FormBuilder UX. Schema accepts min=5/max=5
// (a degenerate scale) which is fine, and rejects max=0.
expect(ScaleQuestionSchema.safeParse({ id: 'q', label: 'x', type: 'scale', min: 1, max: 0 }).success).toBe(false);
});
test('rejects when type is wrong', () => {
expect(ScaleQuestionSchema.safeParse({ id: 'q', label: 'x', type: 'boolean' }).success).toBe(false);
});
});
describe('ScaleQuestion.isAnswerEmpty', () => {
const q = ScaleQuestion.defaultStub();
test('non-numeric → empty', () => {
expect(ScaleQuestion.isAnswerEmpty(q, undefined)).toBe(true);
expect(ScaleQuestion.isAnswerEmpty(q, null)).toBe(true);
expect(ScaleQuestion.isAnswerEmpty(q, '5')).toBe(true);
expect(ScaleQuestion.isAnswerEmpty(q, NaN)).toBe(true);
});
test('finite number → not empty (range-checking is not isAnswerEmpty\'s job)', () => {
expect(ScaleQuestion.isAnswerEmpty(q, 1)).toBe(false);
expect(ScaleQuestion.isAnswerEmpty(q, 0)).toBe(false);
expect(ScaleQuestion.isAnswerEmpty(q, 100)).toBe(false);
});
});
describe('ScaleQuestion.ingest + finalise', () => {
const q = ScaleQuestion.defaultStub();
test('histograms counts, computes mean, ignores garbage', () => {
const stats = ScaleQuestion.emptyStats(q);
ScaleQuestion.ingest(stats, q, 1, 'now');
ScaleQuestion.ingest(stats, q, 3, 'now');
ScaleQuestion.ingest(stats, q, 5, 'now');
ScaleQuestion.ingest(stats, q, 5, 'now');
ScaleQuestion.ingest(stats, q, 'bad', 'now');
ScaleQuestion.ingest(stats, q, NaN, 'now');
ScaleQuestion.finalise(stats);
expect(stats.count).toBe(4);
expect(stats.mean).toBe(3.5);
const hist = Object.fromEntries(stats.histogram.map((b) => [b.value, b.count]));
expect(hist).toEqual({ 1: 1, 2: 0, 3: 1, 4: 0, 5: 2 });
});
test('mean is null when count is zero', () => {
const stats = ScaleQuestion.emptyStats(q);
ScaleQuestion.finalise(stats);
expect(stats.count).toBe(0);
expect(stats.mean).toBeNull();
});
test('finalise drops the internal _sum accumulator', () => {
const stats = ScaleQuestion.emptyStats(q);
ScaleQuestion.ingest(stats, q, 4, 'now');
ScaleQuestion.finalise(stats);
expect((stats as { _sum?: unknown })._sum).toBeUndefined();
});
});
describe('ScaleQuestion.csv + adminCellSummary', () => {
const q = ScaleQuestion.defaultStub();
test('one column with the question id', () => {
expect(ScaleQuestion.csvColumns({ ...q, id: 'rate' })).toEqual([{ header: 'rate', qid: 'rate' }]);
});
test('cell formats numbers, blank for missing', () => {
const [col] = ScaleQuestion.csvColumns(q);
expect(ScaleQuestion.csvCellFor(q, 4, col)).toBe('4');
expect(ScaleQuestion.csvCellFor(q, null, col)).toBe('');
expect(ScaleQuestion.csvCellFor(q, undefined, col)).toBe('');
});
test('adminCellSummary same shape', () => {
expect(ScaleQuestion.adminCellSummary(q, 3)).toBe('3');
expect(ScaleQuestion.adminCellSummary(q, null)).toBe('—');
});
});

View File

@@ -0,0 +1,88 @@
/**
* `scale` question type — N-button rating row (defaults 1..5) on the
* participant side, histogram + mean on the results side.
*/
import { z } from 'zod';
import type { QuestionTypeModule, CsvColumn, StatsForType } from './types';
import { FeedbackQuestionBaseSchema } from './_base';
import ScaleInput from './scale.input.svelte';
import ScaleBuilder from './scale.builder.svelte';
import ScaleResults from './scale.results.svelte';
export const ScaleQuestionSchema = FeedbackQuestionBaseSchema.extend({
type: z.literal('scale'),
min: z.number().int().min(0).max(100),
max: z.number().int().min(1).max(100),
min_label: z.string().max(50).optional(),
max_label: z.string().max(50).optional(),
});
// Aggregator-internal accumulator. _sum gets read by finalise to compute
// mean, then deleted before the public stats shape is observed.
type ScaleStatsWip = StatsForType<'scale'> & { _sum: number };
export const ScaleQuestion: QuestionTypeModule<'scale'> = {
type: 'scale',
label: 'Scale',
schema: ScaleQuestionSchema,
defaultStub() {
return { id: 'q1', label: 'New question', required: false, type: 'scale', min: 1, max: 5 };
},
isAnswerEmpty(_q, answer) {
return typeof answer !== 'number' || !Number.isFinite(answer);
},
emptyStats(question) {
const wip: ScaleStatsWip = {
type: 'scale',
count: 0,
min: question.min,
max: question.max,
mean: null,
histogram: Array.from({ length: question.max - question.min + 1 }, (_, i) => ({
value: question.min + i,
count: 0,
})),
_sum: 0,
};
return wip;
},
ingest(stats, _q, answer) {
if (typeof answer !== 'number' || !Number.isFinite(answer)) return;
const acc = stats as ScaleStatsWip;
acc.count++;
const bucket = acc.histogram.find((b) => b.value === answer);
if (bucket) bucket.count++;
acc._sum += answer;
},
finalise(stats) {
const acc = stats as ScaleStatsWip;
stats.mean = stats.count > 0 ? acc._sum / stats.count : null;
delete (stats as Partial<ScaleStatsWip>)._sum;
},
sanitizeForPublic(stats) {
return stats;
},
csvColumns(q): CsvColumn[] {
return [{ header: q.id, qid: q.id }];
},
csvCellFor(_q, answer) {
return typeof answer === 'number' ? String(answer) : '';
},
adminCellSummary(_q, answer) {
if (typeof answer === 'number' && Number.isFinite(answer)) return String(answer);
return '—';
},
ParticipantInput: ScaleInput,
BuilderEditor: ScaleBuilder,
ResultsBlock: ScaleResults,
};