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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
60
src/lib/questions/scale.builder.svelte
Normal file
60
src/lib/questions/scale.builder.svelte
Normal 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}
|
||||
25
src/lib/questions/scale.input.svelte
Normal file
25
src/lib/questions/scale.input.svelte
Normal 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}
|
||||
32
src/lib/questions/scale.results.svelte
Normal file
32
src/lib/questions/scale.results.svelte
Normal 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}
|
||||
100
src/lib/questions/scale.test.ts
Normal file
100
src/lib/questions/scale.test.ts
Normal 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('—');
|
||||
});
|
||||
});
|
||||
88
src/lib/questions/scale.ts
Normal file
88
src/lib/questions/scale.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user