/** * 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; 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; 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, 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; now?: Date; }): string { const { currentVersion, currentHasSubmissions, usedVersions, now } = args; if (currentVersion && !currentHasSubmissions) return currentVersion; return nextVersionToday(usedVersions, now); }