mAi: #1 - server: date_ranked_choice aggregation + export + tests

- `aggregateResults` ingests rating maps, tallies per-option counts +
  histogram (1-5 buckets) + running sum, then `finalise` computes per-option
  means and sorts options by mean desc with tiebreaks (count of 5s, then
  4s, then total count, then id). Question-level `count` reflects
  submissions that rated at least one option.
- Out-of-range, fractional, and non-integer ratings are silently dropped —
  the aggregator never trusts user data, schema validates it on submit.
- CSV export expands a date_ranked_choice question into one column per
  option named `<qid>[<optid>]`. JSON export is unchanged (it serialises
  the rating map directly).
- New `results.test.ts` covers: per-option counts and means, histogram
  tallying, mean-with-tiebreak ordering, ignoring bad ratings, and missing
  answers. Wires the file into the `bun test` script.

Refs m/fdbck#1.
This commit is contained in:
mAi
2026-05-06 14:13:11 +02:00
parent 91098e0965
commit 439b030471
4 changed files with 201 additions and 4 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": "bun test ./src/lib/server/rate-limit.test.ts ./src/lib/server/public-scope.test.ts"
"test": "bun test ./src/lib/server/rate-limit.test.ts ./src/lib/server/public-scope.test.ts ./src/lib/server/results.test.ts"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.5.4",

View File

@@ -0,0 +1,106 @@
import { describe, expect, test } from 'bun:test';
import { aggregateResults, type SubmissionRow } from './results';
import type { FeedbackFormDefinition } from '../schemas';
const baseForm: FeedbackFormDefinition = {
questions: [
{
id: 'when',
type: 'date_ranked_choice',
label: 'Pick a slot',
required: false,
options: [
{ id: 'a', start: '2026-05-20T09:00:00Z' },
{ id: 'b', start: '2026-05-21T09:00:00Z' },
{ id: 'c', start: '2026-05-22T09:00:00Z' },
],
allow_partial: true,
},
],
};
function sub(answers: Record<string, unknown>, form: FeedbackFormDefinition = baseForm): SubmissionRow {
return { answers, form_snapshot: form, created_at: '2026-05-06T10:00:00Z' };
}
describe('aggregateResults — date_ranked_choice', () => {
test('counts ratings per option and computes mean', () => {
const r = aggregateResults(baseForm, [
sub({ when: { a: 5, b: 3, c: null } }),
sub({ when: { a: 4, b: 2, c: 1 } }),
sub({ when: { a: 5 } }),
]);
const stats = r.questions[0].stats;
expect(stats.type).toBe('date_ranked_choice');
if (stats.type !== 'date_ranked_choice') throw new Error('narrow');
expect(stats.count).toBe(3); // submissions that rated at least one option
const a = stats.options.find((o) => o.id === 'a')!;
const b = stats.options.find((o) => o.id === 'b')!;
const c = stats.options.find((o) => o.id === 'c')!;
expect(a.count).toBe(3);
expect(a.mean).toBeCloseTo((5 + 4 + 5) / 3);
expect(b.count).toBe(2);
expect(b.mean).toBeCloseTo((3 + 2) / 2);
expect(c.count).toBe(1);
expect(c.mean).toBe(1);
});
test('histogram tallies each rating bucket', () => {
const r = aggregateResults(baseForm, [
sub({ when: { a: 5, b: 5, c: 5 } }),
sub({ when: { a: 5, b: 3, c: 1 } }),
]);
const stats = r.questions[0].stats;
if (stats.type !== 'date_ranked_choice') throw new Error('narrow');
const a = stats.options.find((o) => o.id === 'a')!;
expect(a.histogram.find((h) => h.value === 5)!.count).toBe(2);
expect(a.histogram.find((h) => h.value === 4)!.count).toBe(0);
expect(a.histogram.find((h) => h.value === 1)!.count).toBe(0);
});
test('sorts options by mean desc with 5-count tiebreak', () => {
// a and b both average 4, but a has more 5s.
const r = aggregateResults(baseForm, [
sub({ when: { a: 5, b: 5 } }),
sub({ when: { a: 5, b: 3 } }),
sub({ when: { a: 2, b: 4 } }),
sub({ when: { c: 1 } }),
]);
const stats = r.questions[0].stats;
if (stats.type !== 'date_ranked_choice') throw new Error('narrow');
// a: mean = (5+5+2)/3 = 4, two 5s
// b: mean = (5+3+4)/3 = 4, one 5
// c: mean = 1
expect(stats.options[0].id).toBe('a');
expect(stats.options[1].id).toBe('b');
expect(stats.options[2].id).toBe('c');
});
test('ignores out-of-range and non-numeric ratings', () => {
const r = aggregateResults(baseForm, [
sub({ when: { a: 5, b: 'oops' as unknown as number, c: 99 } }),
sub({ when: { a: 0, b: 6, c: 3.5 } }),
]);
const stats = r.questions[0].stats;
if (stats.type !== 'date_ranked_choice') throw new Error('narrow');
const a = stats.options.find((o) => o.id === 'a')!;
const b = stats.options.find((o) => o.id === 'b')!;
const c = stats.options.find((o) => o.id === 'c')!;
expect(a.count).toBe(1);
expect(a.mean).toBe(5);
expect(b.count).toBe(0);
expect(c.count).toBe(0);
});
test('handles missing answers without crashing', () => {
const r = aggregateResults(baseForm, [sub({}), sub({ other_q: 'foo' })]);
const stats = r.questions[0].stats;
if (stats.type !== 'date_ranked_choice') throw new Error('narrow');
expect(stats.count).toBe(0);
for (const opt of stats.options) {
expect(opt.count).toBe(0);
expect(opt.mean).toBeNull();
}
});
});

View File

@@ -35,7 +35,23 @@ export interface TextStats {
answers: { value: string; created_at: string }[];
}
export type QuestionStats = ScaleStats | ChoiceStats | BooleanStats | TextStats;
export interface DateRankedOptionStats {
id: string;
start: string;
end: string | null;
label: string | null;
count: number;
mean: number | null;
histogram: { value: number; count: number }[];
}
export interface DateRankedChoiceStats {
type: 'date_ranked_choice';
count: number;
options: DateRankedOptionStats[];
}
export type QuestionStats = ScaleStats | ChoiceStats | BooleanStats | TextStats | DateRankedChoiceStats;
export interface QuestionResult {
id: string;
@@ -113,6 +129,20 @@ function emptyStats(q: FeedbackQuestion): QuestionStats {
case 'short_text':
case 'long_text':
return { type: q.type, count: 0, answers: [] };
case 'date_ranked_choice':
return {
type: 'date_ranked_choice',
count: 0,
options: q.options.map((opt) => ({
id: opt.id,
start: opt.start,
end: opt.end ?? null,
label: opt.label ?? null,
count: 0,
mean: null,
histogram: [1, 2, 3, 4, 5].map((value) => ({ value, count: 0 })),
})),
};
}
}
@@ -167,6 +197,24 @@ function ingest(
s.answers.push({ value: v, created_at });
return;
}
case 'date_ranked_choice': {
if (!v || typeof v !== 'object' || Array.isArray(v)) return;
const ratings = v as Record<string, unknown>;
let touched = false;
for (const opt of s.options) {
const raw = ratings[opt.id];
if (raw === undefined || raw === null) continue;
if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 1 || raw > 5) continue;
opt.count++;
const bucket = opt.histogram.find((b) => b.value === raw);
if (bucket) bucket.count++;
(opt as DateRankedOptionStats & { _sum?: number })._sum =
((opt as DateRankedOptionStats & { _sum?: number })._sum ?? 0) + raw;
touched = true;
}
if (touched) s.count++;
return;
}
}
void q; // unused after switch covers all branches
}
@@ -176,6 +224,28 @@ function finalise(s: QuestionStats): void {
const sum = (s as ScaleStats & { _sum?: number })._sum;
s.mean = s.count > 0 && typeof sum === 'number' ? sum / s.count : null;
delete (s as ScaleStats & { _sum?: number })._sum;
return;
}
if (s.type === 'date_ranked_choice') {
for (const opt of s.options) {
const sum = (opt as DateRankedOptionStats & { _sum?: number })._sum;
opt.mean = opt.count > 0 && typeof sum === 'number' ? sum / opt.count : null;
delete (opt as DateRankedOptionStats & { _sum?: number })._sum;
}
// Sort by mean desc with tiebreaks: count of "5"s, then "4"s, then count desc, then id.
s.options.sort((a, b) => {
const am = a.mean ?? -Infinity;
const bm = b.mean ?? -Infinity;
if (am !== bm) return bm - am;
const a5 = a.histogram.find((h) => h.value === 5)?.count ?? 0;
const b5 = b.histogram.find((h) => h.value === 5)?.count ?? 0;
if (a5 !== b5) return b5 - a5;
const a4 = a.histogram.find((h) => h.value === 4)?.count ?? 0;
const b4 = b.histogram.find((h) => h.value === 4)?.count ?? 0;
if (a4 !== b4) return b4 - a4;
if (a.count !== b.count) return b.count - a.count;
return a.id.localeCompare(b.id);
});
}
}

View File

@@ -102,10 +102,31 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
const formDef = inst.form_definition as FeedbackFormDefinition | null;
const questions = formDef?.questions ?? [];
const subHeaders = ['id', 'created_at', 'display_name', 'client_session_id', ...questions.map((q) => q.id)];
// Expand `date_ranked_choice` questions into one column per option (e.g. `kickoff_when[opt1]`).
// Other types stay as a single column keyed by question id.
const colSpecs: { qid: string; optId?: string; header: string }[] = [];
for (const q of questions) {
if (q.type === 'date_ranked_choice') {
for (const opt of q.options) {
colSpecs.push({ qid: q.id, optId: opt.id, header: `${q.id}[${opt.id}]` });
}
} else {
colSpecs.push({ qid: q.id, header: q.id });
}
}
const subHeaders = ['id', 'created_at', 'display_name', 'client_session_id', ...colSpecs.map((c) => c.header)];
const subRows = submissions.map((row) => [
row.id, row.created_at, row.display_name, row.client_session_id,
...questions.map((q) => row.answers?.[q.id] ?? ''),
...colSpecs.map((c) => {
const v = row.answers?.[c.qid];
if (c.optId) {
if (!v || typeof v !== 'object' || Array.isArray(v)) return '';
const r = (v as Record<string, unknown>)[c.optId];
return r === null || r === undefined ? '' : r;
}
return v ?? '';
}),
]);
const submissionsCsv = rowsToCsv(subHeaders, subRows);