- `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.
304 lines
8.5 KiB
TypeScript
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);
|
|
}
|