Files
fdbck/src/lib/server/results.ts
mAi 439b030471 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.
2026-05-06 14:13:11 +02:00

304 lines
8.5 KiB
TypeScript

/**
* Result aggregation + form versioning for fdbck.
*
* Aggregations are computed from the form_snapshot stored on each submission,
* so historical results stay correct even after the form is later edited.
*/
import type { FeedbackFormDefinition, FeedbackQuestion } from '../schemas';
export interface ScaleStats {
type: 'scale';
count: number;
min: number;
max: number;
mean: number | null;
histogram: { value: number; count: number }[];
}
export interface ChoiceStats {
type: 'single_choice' | 'multi_choice';
count: number;
options: { option: string; count: number }[];
other_count: number;
}
export interface BooleanStats {
type: 'boolean';
count: number;
yes: number;
no: number;
}
export interface TextStats {
type: 'short_text' | 'long_text';
count: number;
answers: { value: string; created_at: string }[];
}
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;
label: string;
type: FeedbackQuestion['type'];
stats: QuestionStats;
}
export interface AggregatedResults {
total_submissions: number;
questions: QuestionResult[];
}
export interface SubmissionRow {
answers: Record<string, unknown>;
form_snapshot: unknown | null;
created_at: string;
}
/**
* Aggregate submissions against the *current* form definition. For per-question
* counts we look at each submission's snapshot if present (so a question that
* existed only for v1 still tallies its v1 answers); for the question list
* itself we use the current definition (admins want to see today's questions).
*/
export function aggregateResults(
current: FeedbackFormDefinition,
subs: SubmissionRow[],
): AggregatedResults {
const questions: QuestionResult[] = current.questions.map((q) => ({
id: q.id,
label: q.label,
type: q.type,
stats: emptyStats(q),
}));
const byId = new Map(questions.map((q) => [q.id, q]));
for (const sub of subs) {
for (const q of questions) {
const v = sub.answers?.[q.id];
if (v === undefined || v === null) continue;
ingest(byId.get(q.id)!, current.questions.find((cq) => cq.id === q.id)!, v, sub.created_at);
}
}
for (const q of questions) finalise(q.stats);
return { total_submissions: subs.length, questions };
}
function emptyStats(q: FeedbackQuestion): QuestionStats {
switch (q.type) {
case 'scale':
return {
type: 'scale',
count: 0,
min: q.min,
max: q.max,
mean: null,
histogram: Array.from({ length: q.max - q.min + 1 }, (_, i) => ({
value: q.min + i,
count: 0,
})),
};
case 'single_choice':
case 'multi_choice':
return {
type: q.type,
count: 0,
options: q.options.map((option) => ({ option, count: 0 })),
other_count: 0,
};
case 'boolean':
return { type: 'boolean', count: 0, yes: 0, no: 0 };
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 })),
})),
};
}
}
function ingest(
out: QuestionResult,
q: FeedbackQuestion,
v: unknown,
created_at: string,
): void {
const s = out.stats;
switch (s.type) {
case 'scale': {
if (typeof v !== 'number' || !Number.isFinite(v)) return;
s.count++;
const bucket = s.histogram.find((b) => b.value === v);
if (bucket) bucket.count++;
(s as ScaleStats & { _sum?: number })._sum =
((s as ScaleStats & { _sum?: number })._sum ?? 0) + v;
return;
}
case 'single_choice': {
if (typeof v !== 'string') return;
s.count++;
const hit = s.options.find((o) => o.option === v);
if (hit) hit.count++;
else s.other_count++;
return;
}
case 'multi_choice': {
if (!Array.isArray(v)) return;
if (v.length === 0) return;
s.count++;
for (const choice of v) {
if (typeof choice !== 'string') continue;
const hit = s.options.find((o) => o.option === choice);
if (hit) hit.count++;
else s.other_count++;
}
return;
}
case 'boolean': {
if (typeof v !== 'boolean') return;
s.count++;
if (v) s.yes++;
else s.no++;
return;
}
case 'short_text':
case 'long_text': {
if (typeof v !== 'string' || v.trim() === '') return;
s.count++;
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
}
function finalise(s: QuestionStats): void {
if (s.type === 'scale') {
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);
});
}
}
/** Public-safe results: drop free-text answers (PII / open data). */
export function publicResults(r: AggregatedResults): AggregatedResults {
return {
total_submissions: r.total_submissions,
questions: r.questions.map((q) => {
if (q.stats.type === 'short_text' || q.stats.type === 'long_text') {
return { ...q, stats: { type: q.stats.type, count: q.stats.count, answers: [] } };
}
return q;
}),
};
}
/* Form versioning */
/** Today's date as `0.YYMMDD` (e.g., `0.260505`). */
export function todayVersion(now: Date = new Date()): string {
const yy = String(now.getUTCFullYear() % 100).padStart(2, '0');
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
const dd = String(now.getUTCDate()).padStart(2, '0');
return `0.${yy}${mm}${dd}`;
}
/** Next free version for today, given versions already used. `0.YYMMDD` → `.b` → `.c` … */
export function nextVersionToday(used: Set<string>, now: Date = new Date()): string {
const base = todayVersion(now);
if (!used.has(base)) return base;
for (let i = 0; i < 25; i++) {
const suffix = String.fromCharCode('b'.charCodeAt(0) + i);
const candidate = `${base}.${suffix}`;
if (!used.has(candidate)) return candidate;
}
throw new Error('Exhausted same-day version suffixes');
}
/**
* Pick the next version when an admin saves a form edit.
*
* Rule: keep the current version if it has no submissions yet (drafts are
* free); otherwise advance to today's version (or `.b`, `.c` if today is
* already used by older snapshots).
*/
export function nextVersion(args: {
currentVersion: string | undefined;
currentHasSubmissions: boolean;
usedVersions: Set<string>;
now?: Date;
}): string {
const { currentVersion, currentHasSubmissions, usedVersions, now } = args;
if (currentVersion && !currentHasSubmissions) return currentVersion;
return nextVersionToday(usedVersions, now);
}