diff --git a/package.json b/package.json index b517511..31286d9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/server/results.test.ts b/src/lib/server/results.test.ts new file mode 100644 index 0000000..996e912 --- /dev/null +++ b/src/lib/server/results.test.ts @@ -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, 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(); + } + }); +}); diff --git a/src/lib/server/results.ts b/src/lib/server/results.ts index 4ba7d9c..2709fcb 100644 --- a/src/lib/server/results.ts +++ b/src/lib/server/results.ts @@ -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; + 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); + }); } } diff --git a/src/routes/api/admin/feedback/[id]/export/+server.ts b/src/routes/api/admin/feedback/[id]/export/+server.ts index ee0595e..0bbbf89 100644 --- a/src/routes/api/admin/feedback/[id]/export/+server.ts +++ b/src/routes/api/admin/feedback/[id]/export/+server.ts @@ -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)[c.optId]; + return r === null || r === undefined ? '' : r; + } + return v ?? ''; + }), ]); const submissionsCsv = rowsToCsv(subHeaders, subRows);