feat(questions): date_ranked_choice module — closes the validation gap
Registry slot 7/7. The big one. Closes the server-side required-validation
gap the audit (§3.A) flagged: legacy submit/+server.ts only matched empty
strings and empty arrays, so a date_ranked_choice answer of `{}` (object
exists, but no options rated) passed the gate even when the question was
required. The client-side validator caught it; the server didn't.
THE source of truth for the gap is now `isAnswerEmpty(q, answer)`:
- undefined / null → empty
- empty object → empty (this is the case the legacy gate missed)
- object where every value is null → empty
- object with at least one finite integer 1..5 → not empty
- out-of-range / non-integer / wrong-shape → empty
After the wiring step (commit 11) flips submit/+server.ts to call
`getQuestion(q.type).isAnswerEmpty(q, answer)`, the gap closes by
construction — one rule, two callers, no drift.
Files added:
- date_ranked_choice.ts — schema (extends base with options[2..50] of
{id, start, end?, label?}, optional scale.{min,max}_label, optional
allow_partial), defaultStub (two slots starting at the next hour
+ 24h), isAnswerEmpty (the gap closer above), emptyStats with
per-option OptStatsWip accumulators, ingest (per-option counting,
range-checking, _sum), finalise (mean = _sum/count, sort by mean
desc with tiebreaks on 5-count / 4-count / count / id), CSV (one
column per option, header format <qid>[<optId>]), adminCellSummary
("X avg (N rated)").
- date_ranked_choice.input.svelte — per-option row with date display,
1..5 rating buttons, skip button. Same markup the participant page
renders today.
- date_ranked_choice.builder.svelte — date/time options list with add /
remove, scale labels (rating-1 / rating-5 captions), allow_partial
toggle. Includes the renamed `setDateRankedScaleLabel` from the
papercut commit. Date-handling helpers (isoToLocalInput,
localInputToIso, defaultStartIso, optUid) live here.
- date_ranked_choice.results.svelte — full calendar + bars view with
view-toggle (Kalender / Balken). All helper logic — buildCalendar,
cellTitle, colorForRating, colorForMean, fmtTimeRange, fmtDateOption,
fmtMean, mixHex — is now per-module instead of in Results.svelte.
- date_ranked_choice.test.ts — 22 cases covering schema (duplicate ids,
fewer than 2 options, malformed ISO, disallowed id chars), the seven
isAnswerEmpty rules above, ingest+finalise (sort, _sum drop, range
filtering, missing-answer handling), CSV (one column per option,
cell extraction, wrong-shape passthrough), adminCellSummary.
123 server tests pass (was 101). svelte-check + bun run build clean.
All seven types now in QUESTION_MODULES. The wiring step (next commit)
flips the legacy callers — schemas.ts assembles its discriminated union
from the registry, FormBuilder mounts BuilderEditor by type, participant
page mounts ParticipantInput, Results.svelte mounts ResultsBlock, the
submit endpoint calls isAnswerEmpty per type, the export endpoint calls
csvColumns + csvCellFor per type. After that, the legacy `q.type === '...'`
strips disappear.
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"start": "node build/index.js",
|
||||
"test:server": "bun test ./src/lib/server/rate-limit.test.ts ./src/lib/server/public-scope.test.ts ./src/lib/server/results.test.ts ./src/lib/server/admin-route.test.ts ./src/lib/server/feedback-pure.test.ts ./src/lib/questions/registry.test.ts ./src/lib/questions/boolean.test.ts ./src/lib/questions/text.test.ts ./src/lib/questions/scale.test.ts ./src/lib/questions/choice.test.ts",
|
||||
"test:server": "bun test ./src/lib/server/rate-limit.test.ts ./src/lib/server/public-scope.test.ts ./src/lib/server/results.test.ts ./src/lib/server/admin-route.test.ts ./src/lib/server/feedback-pure.test.ts ./src/lib/questions/registry.test.ts ./src/lib/questions/boolean.test.ts ./src/lib/questions/text.test.ts ./src/lib/questions/scale.test.ts ./src/lib/questions/choice.test.ts ./src/lib/questions/date_ranked_choice.test.ts",
|
||||
"test:components": "bun --bun vitest run --config vitest.config.ts",
|
||||
"test": "bun run test:server && bun run test:components"
|
||||
},
|
||||
|
||||
180
src/lib/questions/date_ranked_choice.builder.svelte
Normal file
180
src/lib/questions/date_ranked_choice.builder.svelte
Normal file
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { BuilderEditorProps } from './types';
|
||||
|
||||
let { question, update }: BuilderEditorProps = $props();
|
||||
|
||||
function optUid(): string {
|
||||
const buf = new Uint8Array(4);
|
||||
(globalThis.crypto ?? (window as unknown as { crypto: Crypto }).crypto).getRandomValues(buf);
|
||||
return 'opt_' + Array.from(buf, (b) => b.toString(36)).join('').slice(0, 6);
|
||||
}
|
||||
|
||||
function defaultStartIso(offsetHours = 0): string {
|
||||
const d = new Date();
|
||||
d.setMinutes(0, 0, 0);
|
||||
d.setHours(d.getHours() + 1 + offsetHours);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function isoToLocalInput(iso: string | undefined | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function localInputToIso(local: string): string | null {
|
||||
if (!local) return null;
|
||||
const d = new Date(local);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function setDateOption(optIdx: number, patch: { start?: string; end?: string | null; label?: string | null }): void {
|
||||
if (question.type !== 'date_ranked_choice') return;
|
||||
const options = question.options.map((opt, i) => {
|
||||
if (i !== optIdx) return opt;
|
||||
const next = { ...opt };
|
||||
if (patch.start !== undefined) next.start = patch.start;
|
||||
if (patch.end !== undefined) {
|
||||
if (patch.end === null || patch.end === '') delete next.end;
|
||||
else next.end = patch.end;
|
||||
}
|
||||
if (patch.label !== undefined) {
|
||||
if (patch.label === null || patch.label === '') delete next.label;
|
||||
else next.label = patch.label;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
update({ options });
|
||||
}
|
||||
|
||||
function addDateOption(): void {
|
||||
if (question.type !== 'date_ranked_choice') return;
|
||||
if (question.options.length >= 50) return;
|
||||
update({
|
||||
options: [
|
||||
...question.options,
|
||||
{ id: optUid(), start: defaultStartIso(24 * (question.options.length + 1)) },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function removeDateOption(optIdx: number): void {
|
||||
if (question.type !== 'date_ranked_choice') return;
|
||||
if (question.options.length <= 2) return;
|
||||
update({ options: question.options.filter((_, i) => i !== optIdx) });
|
||||
}
|
||||
|
||||
function setScaleLabel(which: 'min_label' | 'max_label', val: string): void {
|
||||
if (question.type !== 'date_ranked_choice') return;
|
||||
const scale = { ...(question.scale ?? {}) };
|
||||
if (val === '') delete scale[which];
|
||||
else scale[which] = val;
|
||||
const empty = !scale.min_label && !scale.max_label;
|
||||
update({ scale: empty ? undefined : scale });
|
||||
}
|
||||
|
||||
function setAllowPartial(allow: boolean): void {
|
||||
if (question.type !== 'date_ranked_choice') return;
|
||||
update({ allow_partial: allow });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if question.type === 'date_ranked_choice'}
|
||||
<div class="fb-question">
|
||||
<label class="fb-question__label">Date / time options</label>
|
||||
<div class="fb-builder__date-ranked">
|
||||
{#each question.options as opt, optIdx (opt.id)}
|
||||
<div class="fb-builder__date-row">
|
||||
<div class="fb-builder__date-fields">
|
||||
<label class="fb-question__label" for={`fb-builder-${question.id}-${opt.id}-start`}>Start</label>
|
||||
<input
|
||||
id={`fb-builder-${question.id}-${opt.id}-start`}
|
||||
type="datetime-local"
|
||||
class="fb-input"
|
||||
value={isoToLocalInput(opt.start)}
|
||||
oninput={(e) => {
|
||||
const iso = localInputToIso((e.target as HTMLInputElement).value);
|
||||
if (iso) setDateOption(optIdx, { start: iso });
|
||||
}}
|
||||
/>
|
||||
<label class="fb-question__label" for={`fb-builder-${question.id}-${opt.id}-end`}>End (optional)</label>
|
||||
<input
|
||||
id={`fb-builder-${question.id}-${opt.id}-end`}
|
||||
type="datetime-local"
|
||||
class="fb-input"
|
||||
value={isoToLocalInput(opt.end)}
|
||||
oninput={(e) => {
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
if (!raw) setDateOption(optIdx, { end: null });
|
||||
else {
|
||||
const iso = localInputToIso(raw);
|
||||
if (iso) setDateOption(optIdx, { end: iso });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label class="fb-question__label" for={`fb-builder-${question.id}-${opt.id}-label`}>Label (optional)</label>
|
||||
<input
|
||||
id={`fb-builder-${question.id}-${opt.id}-label`}
|
||||
class="fb-input"
|
||||
maxlength="200"
|
||||
placeholder="e.g. Office, 09:00 sharp"
|
||||
value={opt.label ?? ''}
|
||||
oninput={(e) => setDateOption(optIdx, { label: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="fb-builder__icon-btn fb-builder__icon-btn--danger"
|
||||
disabled={question.options.length <= 2}
|
||||
onclick={() => removeDateOption(optIdx)}
|
||||
aria-label="Remove option"
|
||||
><Icon name="x" size={14} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="fb-btn fb-btn--secondary fb-btn--sm fb-builder__add-option"
|
||||
onclick={addDateOption}
|
||||
disabled={question.options.length >= 50}
|
||||
><Icon name="plus" /> Date option</button>
|
||||
</div>
|
||||
|
||||
<div class="fb-builder__scale">
|
||||
<div class="fb-question">
|
||||
<label class="fb-question__label" for={`fb-builder-${question.id}-rating-1`}>Rating-1 label (optional)</label>
|
||||
<input
|
||||
id={`fb-builder-${question.id}-rating-1`}
|
||||
class="fb-input"
|
||||
maxlength="50"
|
||||
placeholder="e.g. doesn't work"
|
||||
value={question.scale?.min_label ?? ''}
|
||||
oninput={(e) => setScaleLabel('min_label', (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="fb-question">
|
||||
<label class="fb-question__label" for={`fb-builder-${question.id}-rating-5`}>Rating-5 label (optional)</label>
|
||||
<input
|
||||
id={`fb-builder-${question.id}-rating-5`}
|
||||
class="fb-input"
|
||||
maxlength="50"
|
||||
placeholder="e.g. works great"
|
||||
value={question.scale?.max_label ?? ''}
|
||||
oninput={(e) => setScaleLabel('max_label', (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="fb-option-row" style="display:inline-flex;">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={question.allow_partial !== false}
|
||||
onchange={(e) => setAllowPartial((e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span>Allow participants to skip individual options</span>
|
||||
</label>
|
||||
{/if}
|
||||
82
src/lib/questions/date_ranked_choice.input.svelte
Normal file
82
src/lib/questions/date_ranked_choice.input.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import type { ParticipantInputProps } from './types';
|
||||
|
||||
let { question, answer, setAnswer }: ParticipantInputProps = $props();
|
||||
|
||||
const dateOptionFmt = new Intl.DateTimeFormat([], {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const dateOptionTimeFmt = new Intl.DateTimeFormat([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
function fmtDateOption(start: string, end?: string): string {
|
||||
try {
|
||||
const startStr = dateOptionFmt.format(new Date(start));
|
||||
if (!end) return startStr;
|
||||
const sd = new Date(start);
|
||||
const ed = new Date(end);
|
||||
if (sd.toDateString() === ed.toDateString()) {
|
||||
return `${startStr}–${dateOptionTimeFmt.format(ed)}`;
|
||||
}
|
||||
return `${startStr} – ${dateOptionFmt.format(ed)}`;
|
||||
} catch {
|
||||
return start;
|
||||
}
|
||||
}
|
||||
|
||||
function ratingFor(optId: string): number | null {
|
||||
if (!answer || typeof answer !== 'object') return null;
|
||||
const v = (answer as Record<string, unknown>)[optId];
|
||||
return typeof v === 'number' ? v : null;
|
||||
}
|
||||
|
||||
function setRating(optId: string, rating: number | null): void {
|
||||
const cur = (answer && typeof answer === 'object' ? answer : {}) as Record<string, number | null>;
|
||||
setAnswer({ ...cur, [optId]: rating });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if question.type === 'date_ranked_choice'}
|
||||
<div class="fb-date-ranked">
|
||||
{#if question.scale?.min_label || question.scale?.max_label}
|
||||
<div class="fb-scale__labels" style="margin-bottom: 0.5rem;">
|
||||
<span>1 — {question.scale.min_label ?? 'passt nicht'}</span>
|
||||
<span>5 — {question.scale.max_label ?? 'passt super'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each question.options as opt (opt.id)}
|
||||
<div class="fb-date-ranked__row">
|
||||
<div class="fb-date-ranked__opt">
|
||||
<div class="fb-date-ranked__when">{fmtDateOption(opt.start, opt.end)}</div>
|
||||
{#if opt.label}<div class="fb-date-ranked__label">{opt.label}</div>{/if}
|
||||
</div>
|
||||
<div class="fb-scale fb-date-ranked__scale" role="radiogroup" aria-label={opt.label ?? fmtDateOption(opt.start, opt.end)}>
|
||||
{#each [1, 2, 3, 4, 5] as v (v)}
|
||||
<button
|
||||
type="button"
|
||||
class="fb-scale__btn {ratingFor(opt.id) === v ? 'fb-scale__btn--active' : ''}"
|
||||
aria-pressed={ratingFor(opt.id) === v}
|
||||
onclick={() => setRating(opt.id, v)}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="fb-date-ranked__skip {ratingFor(opt.id) === null ? 'fb-date-ranked__skip--active' : ''}"
|
||||
onclick={() => setRating(opt.id, null)}
|
||||
aria-label="Skip option"
|
||||
>
|
||||
—
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
253
src/lib/questions/date_ranked_choice.results.svelte
Normal file
253
src/lib/questions/date_ranked_choice.results.svelte
Normal file
@@ -0,0 +1,253 @@
|
||||
<script lang="ts">
|
||||
import type { ResultsBlockProps } from './types';
|
||||
import type { DateRankedOptionStats } from '$lib/server/results';
|
||||
|
||||
let { question, stats }: ResultsBlockProps = $props();
|
||||
|
||||
let view = $state<'calendar' | 'bars'>('calendar');
|
||||
|
||||
function pct(part: number, whole: number): number {
|
||||
if (whole === 0) return 0;
|
||||
return Math.round((part / whole) * 100);
|
||||
}
|
||||
|
||||
function fmtMean(m: number | null): string {
|
||||
if (m === null) return '—';
|
||||
return m.toFixed(2).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
function mixHex(a: string, b: string, t: number): string {
|
||||
const tt = Math.max(0, Math.min(1, t));
|
||||
const ar = parseInt(a.slice(1, 3), 16);
|
||||
const ag = parseInt(a.slice(3, 5), 16);
|
||||
const ab = parseInt(a.slice(5, 7), 16);
|
||||
const br = parseInt(b.slice(1, 3), 16);
|
||||
const bg = parseInt(b.slice(3, 5), 16);
|
||||
const bb = parseInt(b.slice(5, 7), 16);
|
||||
return `rgb(${Math.round(ar + (br - ar) * tt)}, ${Math.round(ag + (bg - ag) * tt)}, ${Math.round(ab + (bb - ab) * tt)})`;
|
||||
}
|
||||
|
||||
const COLOR_LOW = '#ef4444';
|
||||
const COLOR_MID = '#f59e0b';
|
||||
const COLOR_HIGH = '#16a34a';
|
||||
|
||||
function colorForRating(value: number): string {
|
||||
if (value <= 1) return COLOR_LOW;
|
||||
if (value >= 5) return COLOR_HIGH;
|
||||
if (value < 3) return mixHex(COLOR_LOW, COLOR_MID, (value - 1) / 2);
|
||||
return mixHex(COLOR_MID, COLOR_HIGH, (value - 3) / 2);
|
||||
}
|
||||
|
||||
function colorForMean(mean: number | null): string {
|
||||
if (mean === null) return 'var(--color-bg-secondary)';
|
||||
return colorForRating(mean);
|
||||
}
|
||||
|
||||
const dayFmt = new Intl.DateTimeFormat([], { day: '2-digit' });
|
||||
const monthFmt = new Intl.DateTimeFormat([], { month: 'short' });
|
||||
const weekdayFmt = new Intl.DateTimeFormat([], { weekday: 'short' });
|
||||
const timeFmt = new Intl.DateTimeFormat([], { hour: '2-digit', minute: '2-digit' });
|
||||
const fullDateFmt = new Intl.DateTimeFormat([], {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
const dateOptionFmt = new Intl.DateTimeFormat([], {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const dateOptionTimeFmt = new Intl.DateTimeFormat([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
function localDateKey(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function fmtTimeRange(start: string, end: string | null): string {
|
||||
const s = timeFmt.format(new Date(start));
|
||||
if (!end) return s;
|
||||
const e = timeFmt.format(new Date(end));
|
||||
return `${s}–${e}`;
|
||||
}
|
||||
|
||||
function fmtDateOption(start: string, end: string | null | undefined): string {
|
||||
try {
|
||||
const startStr = dateOptionFmt.format(new Date(start));
|
||||
if (!end) return startStr;
|
||||
const sd = new Date(start);
|
||||
const ed = new Date(end);
|
||||
if (sd.toDateString() === ed.toDateString()) {
|
||||
return `${startStr}–${dateOptionTimeFmt.format(ed)}`;
|
||||
}
|
||||
return `${startStr} – ${dateOptionFmt.format(ed)}`;
|
||||
} catch {
|
||||
return start;
|
||||
}
|
||||
}
|
||||
|
||||
interface CalendarCell {
|
||||
key: string;
|
||||
date: Date;
|
||||
options: DateRankedOptionStats[];
|
||||
}
|
||||
|
||||
function buildCalendar(options: DateRankedOptionStats[]): { cells: CalendarCell[]; collapsed: boolean } {
|
||||
if (options.length === 0) return { cells: [], collapsed: false };
|
||||
|
||||
const byDay = new Map<string, CalendarCell>();
|
||||
for (const opt of options) {
|
||||
const key = localDateKey(opt.start);
|
||||
let cell = byDay.get(key);
|
||||
if (!cell) {
|
||||
const d = new Date(opt.start);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
cell = { key, date: d, options: [] };
|
||||
byDay.set(key, cell);
|
||||
}
|
||||
cell.options.push(opt);
|
||||
}
|
||||
|
||||
const occupied = Array.from(byDay.values()).sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
if (occupied.length <= 1) return { cells: occupied, collapsed: false };
|
||||
|
||||
const first = occupied[0].date;
|
||||
const last = occupied[occupied.length - 1].date;
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const span = Math.round((last.getTime() - first.getTime()) / dayMs) + 1;
|
||||
|
||||
// > 30 days → suppress empty days; otherwise contiguous strip with empties.
|
||||
if (span > 30) return { cells: occupied, collapsed: true };
|
||||
|
||||
const cells: CalendarCell[] = [];
|
||||
for (let i = 0; i < span; i++) {
|
||||
const d = new Date(first.getTime() + i * dayMs);
|
||||
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
cells.push(byDay.get(k) ?? { key: k, date: d, options: [] });
|
||||
}
|
||||
return { cells, collapsed: false };
|
||||
}
|
||||
|
||||
function cellTitle(cell: CalendarCell): string {
|
||||
const date = fullDateFmt.format(cell.date);
|
||||
if (cell.options.length === 0) return date;
|
||||
const lines = cell.options.map((opt) => {
|
||||
const time = fmtTimeRange(opt.start, opt.end);
|
||||
const label = opt.label ? ` · ${opt.label}` : '';
|
||||
const mean = opt.mean === null ? '—' : opt.mean.toFixed(2).replace(/\.?0+$/, '');
|
||||
return `${time}${label} — ${mean} avg (${opt.count})`;
|
||||
});
|
||||
return `${date}\n${lines.join('\n')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if stats.type === 'date_ranked_choice'}
|
||||
{@const calendar = buildCalendar(stats.options)}
|
||||
{@const showCalendar = stats.options.length > 1}
|
||||
{@const effectiveView = showCalendar ? view : 'bars'}
|
||||
<div class="fb-results__meta">
|
||||
{stats.count} {stats.count === 1 ? 'Antwort' : 'Antworten'}
|
||||
</div>
|
||||
|
||||
{#if stats.count === 0}
|
||||
<p class="fb-results__meta">Noch keine Bewertungen.</p>
|
||||
{:else}
|
||||
{#if showCalendar}
|
||||
<div class="fb-tabs fb-results__drc-tabs" role="tablist" aria-label="Ergebnis-Ansicht">
|
||||
<button
|
||||
type="button"
|
||||
class="fb-tab"
|
||||
class:fb-tab--active={effectiveView === 'calendar'}
|
||||
role="tab"
|
||||
aria-selected={effectiveView === 'calendar'}
|
||||
onclick={() => (view = 'calendar')}
|
||||
>Kalender</button>
|
||||
<button
|
||||
type="button"
|
||||
class="fb-tab"
|
||||
class:fb-tab--active={effectiveView === 'bars'}
|
||||
role="tab"
|
||||
aria-selected={effectiveView === 'bars'}
|
||||
onclick={() => (view = 'bars')}
|
||||
>Balken</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if effectiveView === 'calendar'}
|
||||
<div class="fb-results__cal" class:fb-results__cal--collapsed={calendar.collapsed}>
|
||||
{#each calendar.cells as cell (cell.key)}
|
||||
<div
|
||||
class="fb-results__cal-day"
|
||||
class:fb-results__cal-day--empty={cell.options.length === 0}
|
||||
title={cellTitle(cell)}
|
||||
>
|
||||
<div class="fb-results__cal-head">
|
||||
<span class="fb-results__cal-weekday">{weekdayFmt.format(cell.date)}</span>
|
||||
<span class="fb-results__cal-num">{dayFmt.format(cell.date)}</span>
|
||||
<span class="fb-results__cal-month">{monthFmt.format(cell.date)}</span>
|
||||
</div>
|
||||
{#if cell.options.length > 0}
|
||||
<div class="fb-results__cal-slots">
|
||||
{#each cell.options as opt (opt.id)}
|
||||
<div
|
||||
class="fb-results__cal-slot"
|
||||
style="background: {colorForMean(opt.mean)};"
|
||||
>
|
||||
<span class="fb-results__cal-slot-time">{fmtTimeRange(opt.start, opt.end)}</span>
|
||||
<span class="fb-results__cal-slot-mean">{fmtMean(opt.mean)}</span>
|
||||
<span class="fb-results__cal-slot-count">{opt.count}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="fb-results__drc-bars">
|
||||
{#each stats.options as opt, optIdx (opt.id)}
|
||||
{@const total = opt.count}
|
||||
<div class="fb-results__drc-row">
|
||||
<div class="fb-results__drc-rank">#{optIdx + 1}</div>
|
||||
<div class="fb-results__drc-when">
|
||||
<div>{fmtDateOption(opt.start, opt.end)}</div>
|
||||
{#if opt.label}<div class="fb-results__date-label">{opt.label}</div>{/if}
|
||||
</div>
|
||||
<div class="fb-results__drc-avg" style="color: {colorForMean(opt.mean)};">
|
||||
{fmtMean(opt.mean)}
|
||||
</div>
|
||||
<div class="fb-results__drc-bar" aria-label="Verteilung">
|
||||
{#if total === 0}
|
||||
<div class="fb-results__drc-bar-empty">Keine Bewertung</div>
|
||||
{:else}
|
||||
{#each opt.histogram as bucket (bucket.value)}
|
||||
{#if bucket.count > 0}
|
||||
<div
|
||||
class="fb-results__drc-bar-seg"
|
||||
style="width: {pct(bucket.count, total)}%; background: {colorForRating(bucket.value)};"
|
||||
title="{bucket.count}× {bucket.value}"
|
||||
>
|
||||
<span>{bucket.value}·{bucket.count}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="fb-results__drc-count">
|
||||
{opt.count}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- question is part of the prop contract; date_ranked_choice's render
|
||||
happens to read everything from `stats`, so we just acknowledge it. -->
|
||||
{#if false}{question}{/if}
|
||||
{/if}
|
||||
200
src/lib/questions/date_ranked_choice.test.ts
Normal file
200
src/lib/questions/date_ranked_choice.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
DateRankedChoiceQuestion,
|
||||
DateRankedChoiceQuestionSchema,
|
||||
} from './date_ranked_choice';
|
||||
|
||||
const baseQ = {
|
||||
id: 'when',
|
||||
label: 'Pick a slot',
|
||||
required: true,
|
||||
type: 'date_ranked_choice' as const,
|
||||
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,
|
||||
};
|
||||
|
||||
describe('DateRankedChoiceQuestion.schema', () => {
|
||||
test('accepts a valid question', () => {
|
||||
expect(DateRankedChoiceQuestionSchema.safeParse(baseQ).success).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects duplicate option ids', () => {
|
||||
const r = DateRankedChoiceQuestionSchema.safeParse({
|
||||
...baseQ,
|
||||
options: [
|
||||
{ id: 'a', start: '2026-05-20T09:00:00Z' },
|
||||
{ id: 'a', start: '2026-05-21T09:00:00Z' },
|
||||
],
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects fewer than 2 options', () => {
|
||||
const r = DateRankedChoiceQuestionSchema.safeParse({
|
||||
...baseQ,
|
||||
options: [{ id: 'a', start: '2026-05-20T09:00:00Z' }],
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects malformed start ISO', () => {
|
||||
const r = DateRankedChoiceQuestionSchema.safeParse({
|
||||
...baseQ,
|
||||
options: [
|
||||
{ id: 'a', start: 'tomorrow morning' },
|
||||
{ id: 'b', start: '2026-05-21T09:00:00Z' },
|
||||
],
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects option ids with disallowed characters', () => {
|
||||
const r = DateRankedChoiceQuestionSchema.safeParse({
|
||||
...baseQ,
|
||||
options: [
|
||||
{ id: 'has space', start: '2026-05-20T09:00:00Z' },
|
||||
{ id: 'b', start: '2026-05-21T09:00:00Z' },
|
||||
],
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DateRankedChoiceQuestion.isAnswerEmpty (closes the validation gap)', () => {
|
||||
test('undefined → empty', () => {
|
||||
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test('null → empty', () => {
|
||||
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, null)).toBe(true);
|
||||
});
|
||||
|
||||
test('empty object → empty (this is the gap closed by construction)', () => {
|
||||
// Legacy server-side gate matched only string/array empties. An empty
|
||||
// object passed through and submitted as a "rated zero options" answer,
|
||||
// even when the question was required. Now caught.
|
||||
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, {})).toBe(true);
|
||||
});
|
||||
|
||||
test('object with all-null ratings → empty', () => {
|
||||
expect(
|
||||
DateRankedChoiceQuestion.isAnswerEmpty(baseQ, { a: null, b: null, c: null }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('object with at least one valid rating → not empty', () => {
|
||||
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, { a: 5 })).toBe(false);
|
||||
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, { a: 1, b: null })).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects out-of-range or non-integer ratings', () => {
|
||||
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, { a: 0 })).toBe(true);
|
||||
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, { a: 6 })).toBe(true);
|
||||
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, { a: 3.5 })).toBe(true);
|
||||
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, { a: '4' })).toBe(true);
|
||||
});
|
||||
|
||||
test('array → empty (DRC answers are objects, not arrays)', () => {
|
||||
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, [5, 4, 3])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DateRankedChoiceQuestion.ingest + finalise', () => {
|
||||
test('counts ratings per option, computes mean, sorts by mean desc', () => {
|
||||
const stats = DateRankedChoiceQuestion.emptyStats(baseQ);
|
||||
DateRankedChoiceQuestion.ingest(stats, baseQ, { a: 5, b: 3, c: null }, 'now');
|
||||
DateRankedChoiceQuestion.ingest(stats, baseQ, { a: 4, b: 2, c: 1 }, 'now');
|
||||
DateRankedChoiceQuestion.ingest(stats, baseQ, { a: 5 }, 'now');
|
||||
DateRankedChoiceQuestion.finalise(stats);
|
||||
|
||||
expect(stats.count).toBe(3);
|
||||
// After sort by mean desc: a (mean 4.667) > b (2.5) > c (1.0)
|
||||
expect(stats.options[0].id).toBe('a');
|
||||
expect(stats.options[1].id).toBe('b');
|
||||
expect(stats.options[2].id).toBe('c');
|
||||
});
|
||||
|
||||
test('finalise drops _sum on every option', () => {
|
||||
const stats = DateRankedChoiceQuestion.emptyStats(baseQ);
|
||||
DateRankedChoiceQuestion.ingest(stats, baseQ, { a: 5, b: 3 }, 'now');
|
||||
DateRankedChoiceQuestion.finalise(stats);
|
||||
for (const opt of stats.options) {
|
||||
expect((opt as { _sum?: unknown })._sum).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('option with no ratings has mean=null and count=0', () => {
|
||||
const stats = DateRankedChoiceQuestion.emptyStats(baseQ);
|
||||
DateRankedChoiceQuestion.ingest(stats, baseQ, { a: 5 }, 'now');
|
||||
DateRankedChoiceQuestion.finalise(stats);
|
||||
const c = stats.options.find((o) => o.id === 'c')!;
|
||||
expect(c.count).toBe(0);
|
||||
expect(c.mean).toBeNull();
|
||||
});
|
||||
|
||||
test('ignores out-of-range and non-numeric ratings', () => {
|
||||
const stats = DateRankedChoiceQuestion.emptyStats(baseQ);
|
||||
DateRankedChoiceQuestion.ingest(stats, baseQ, { a: 5, b: 'oops', c: 99 }, 'now');
|
||||
DateRankedChoiceQuestion.ingest(stats, baseQ, { a: 0, b: 6, c: 3.5 }, 'now');
|
||||
DateRankedChoiceQuestion.finalise(stats);
|
||||
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 stats = DateRankedChoiceQuestion.emptyStats(baseQ);
|
||||
DateRankedChoiceQuestion.ingest(stats, baseQ, undefined, 'now');
|
||||
DateRankedChoiceQuestion.ingest(stats, baseQ, null, 'now');
|
||||
DateRankedChoiceQuestion.ingest(stats, baseQ, [], 'now');
|
||||
DateRankedChoiceQuestion.ingest(stats, baseQ, 'oops', 'now');
|
||||
DateRankedChoiceQuestion.finalise(stats);
|
||||
expect(stats.count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DateRankedChoiceQuestion.csv', () => {
|
||||
test('one column per option, header includes the option id', () => {
|
||||
const cols = DateRankedChoiceQuestion.csvColumns(baseQ);
|
||||
expect(cols).toHaveLength(3);
|
||||
expect(cols[0]).toEqual({ header: 'when[a]', qid: 'when', optId: 'a' });
|
||||
expect(cols[1]).toEqual({ header: 'when[b]', qid: 'when', optId: 'b' });
|
||||
expect(cols[2]).toEqual({ header: 'when[c]', qid: 'when', optId: 'c' });
|
||||
});
|
||||
|
||||
test('cell pulls the rating for the column option', () => {
|
||||
const cols = DateRankedChoiceQuestion.csvColumns(baseQ);
|
||||
const answer = { a: 5, b: 3, c: null };
|
||||
expect(DateRankedChoiceQuestion.csvCellFor(baseQ, answer, cols[0])).toBe('5');
|
||||
expect(DateRankedChoiceQuestion.csvCellFor(baseQ, answer, cols[1])).toBe('3');
|
||||
expect(DateRankedChoiceQuestion.csvCellFor(baseQ, answer, cols[2])).toBe('');
|
||||
});
|
||||
|
||||
test('cell empty when answer is missing or wrong shape', () => {
|
||||
const cols = DateRankedChoiceQuestion.csvColumns(baseQ);
|
||||
expect(DateRankedChoiceQuestion.csvCellFor(baseQ, null, cols[0])).toBe('');
|
||||
expect(DateRankedChoiceQuestion.csvCellFor(baseQ, [5, 4, 3], cols[0])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DateRankedChoiceQuestion.adminCellSummary', () => {
|
||||
test('formats average + count', () => {
|
||||
expect(DateRankedChoiceQuestion.adminCellSummary(baseQ, { a: 5, b: 4, c: 3 })).toBe(
|
||||
'4 avg (3 rated)',
|
||||
);
|
||||
expect(DateRankedChoiceQuestion.adminCellSummary(baseQ, { a: 5 })).toBe('5 avg (1 rated)');
|
||||
});
|
||||
|
||||
test('em-dash when no ratings', () => {
|
||||
expect(DateRankedChoiceQuestion.adminCellSummary(baseQ, {})).toBe('—');
|
||||
expect(DateRankedChoiceQuestion.adminCellSummary(baseQ, null)).toBe('—');
|
||||
});
|
||||
});
|
||||
199
src/lib/questions/date_ranked_choice.ts
Normal file
199
src/lib/questions/date_ranked_choice.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* `date_ranked_choice` question type — author lists date/time slots,
|
||||
* participants rate each on a 1..5 Likert (or skip).
|
||||
*
|
||||
* Closes the server-side validation gap that the audit doc flagged: the
|
||||
* legacy submit endpoint's `(typeof v === 'string' && v.trim() === '')`
|
||||
* check doesn't catch "answer object exists but has zero rated options",
|
||||
* so the gate was only enforced client-side. `isAnswerEmpty` here is THE
|
||||
* source of truth for "is this answer missing?", and once submit/+server.ts
|
||||
* is wired to call `getQuestion(q.type).isAnswerEmpty(q, answer)` (commit 11),
|
||||
* the gap closes by construction. The `allow_partial: false` invariant —
|
||||
* "every option must be rated, not just at least one" — moves into a small
|
||||
* dedicated check on the participant side; isAnswerEmpty stays focused on
|
||||
* the basic "did they touch this question" rule.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { QuestionTypeModule, CsvColumn, StatsForType } from './types';
|
||||
import { FeedbackQuestionBaseSchema } from './_base';
|
||||
import DateRankedInput from './date_ranked_choice.input.svelte';
|
||||
import DateRankedBuilder from './date_ranked_choice.builder.svelte';
|
||||
import DateRankedResults from './date_ranked_choice.results.svelte';
|
||||
|
||||
export const DateRankedOptionSchema = z.object({
|
||||
id: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/, {
|
||||
message: 'option id may only contain letters, digits, "-" and "_"',
|
||||
}),
|
||||
start: z.string().datetime({ offset: true }),
|
||||
end: z.string().datetime({ offset: true }).optional(),
|
||||
label: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export const DateRankedChoiceQuestionSchema = FeedbackQuestionBaseSchema.extend({
|
||||
type: z.literal('date_ranked_choice'),
|
||||
options: z
|
||||
.array(DateRankedOptionSchema)
|
||||
.min(2)
|
||||
.max(50)
|
||||
.refine((opts) => new Set(opts.map((o) => o.id)).size === opts.length, {
|
||||
message: 'date_ranked_choice option ids must be unique',
|
||||
}),
|
||||
// 5-point Likert is locked per design — only the labels are author-configurable.
|
||||
scale: z
|
||||
.object({
|
||||
min_label: z.string().max(50).optional(),
|
||||
max_label: z.string().max(50).optional(),
|
||||
})
|
||||
.optional(),
|
||||
allow_partial: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type Q = z.infer<typeof DateRankedChoiceQuestionSchema>;
|
||||
|
||||
// Per-option accumulator — _sum is read by finalise to compute mean and
|
||||
// dropped before the public stats shape is observed.
|
||||
type OptStatsWip = StatsForType<'date_ranked_choice'>['options'][number] & { _sum: number };
|
||||
|
||||
/** True if the answer object has at least one numeric rating (1..5). */
|
||||
function hasAnyRating(answer: unknown): boolean {
|
||||
if (!answer || typeof answer !== 'object' || Array.isArray(answer)) return false;
|
||||
for (const v of Object.values(answer as Record<string, unknown>)) {
|
||||
if (typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 5) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const DateRankedChoiceQuestion: QuestionTypeModule<'date_ranked_choice'> = {
|
||||
type: 'date_ranked_choice',
|
||||
label: 'Date ranked choice',
|
||||
schema: DateRankedChoiceQuestionSchema,
|
||||
|
||||
defaultStub() {
|
||||
const now = new Date();
|
||||
now.setMinutes(0, 0, 0);
|
||||
const startA = new Date(now.getTime() + 60 * 60 * 1000).toISOString();
|
||||
const startB = new Date(now.getTime() + 25 * 60 * 60 * 1000).toISOString();
|
||||
return {
|
||||
id: 'q1',
|
||||
label: 'New question',
|
||||
required: false,
|
||||
type: 'date_ranked_choice',
|
||||
options: [
|
||||
{ id: 'opt_a', start: startA },
|
||||
{ id: 'opt_b', start: startB },
|
||||
],
|
||||
allow_partial: true,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* THE source of truth for "did the participant touch this question?".
|
||||
* Used by both client- and server-side validators after the wiring step
|
||||
* in commit 11. Closes the legacy gap where the server only matched on
|
||||
* `(typeof v === 'string' && v.trim() === '')`.
|
||||
*
|
||||
* Per-option "must rate everything" enforcement (`allow_partial: false`)
|
||||
* is a separate rule that lives in submit-time validation; this method
|
||||
* only answers the basic question.
|
||||
*/
|
||||
isAnswerEmpty(_q: Q, answer: unknown): boolean {
|
||||
return !hasAnyRating(answer);
|
||||
},
|
||||
|
||||
emptyStats(question) {
|
||||
return {
|
||||
type: 'date_ranked_choice',
|
||||
count: 0,
|
||||
options: question.options.map((opt) => {
|
||||
const wip: OptStatsWip = {
|
||||
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 })),
|
||||
_sum: 0,
|
||||
};
|
||||
return wip;
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
ingest(stats, _q, answer) {
|
||||
if (!answer || typeof answer !== 'object' || Array.isArray(answer)) return;
|
||||
const ratings = answer as Record<string, unknown>;
|
||||
let touched = false;
|
||||
for (const opt of stats.options) {
|
||||
const raw = ratings[opt.id];
|
||||
if (raw === undefined || raw === null) continue;
|
||||
if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 1 || raw > 5) continue;
|
||||
const acc = opt as OptStatsWip;
|
||||
acc.count++;
|
||||
const bucket = acc.histogram.find((b) => b.value === raw);
|
||||
if (bucket) bucket.count++;
|
||||
acc._sum += raw;
|
||||
touched = true;
|
||||
}
|
||||
if (touched) stats.count++;
|
||||
},
|
||||
|
||||
finalise(stats) {
|
||||
for (const opt of stats.options) {
|
||||
const acc = opt as OptStatsWip;
|
||||
opt.mean = opt.count > 0 ? acc._sum / opt.count : null;
|
||||
delete (opt as Partial<OptStatsWip>)._sum;
|
||||
}
|
||||
// Sort options by mean desc with tiebreaks (5-count, 4-count, total count, id).
|
||||
stats.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);
|
||||
});
|
||||
},
|
||||
|
||||
sanitizeForPublic(stats) {
|
||||
return stats;
|
||||
},
|
||||
|
||||
csvColumns(q: Q): CsvColumn[] {
|
||||
// One column per option. Header format: <qid>[<optId>], matches what the
|
||||
// legacy export endpoint was producing.
|
||||
return q.options.map((opt) => ({
|
||||
header: `${q.id}[${opt.id}]`,
|
||||
qid: q.id,
|
||||
optId: opt.id,
|
||||
}));
|
||||
},
|
||||
|
||||
csvCellFor(_q, answer, col) {
|
||||
if (!answer || typeof answer !== 'object' || Array.isArray(answer)) return '';
|
||||
if (!col.optId) return '';
|
||||
const r = (answer as Record<string, unknown>)[col.optId];
|
||||
if (r === null || r === undefined) return '';
|
||||
return typeof r === 'number' ? String(r) : '';
|
||||
},
|
||||
|
||||
adminCellSummary(_q, answer) {
|
||||
if (!answer || typeof answer !== 'object' || Array.isArray(answer)) return '—';
|
||||
const ratings = Object.values(answer as Record<string, unknown>).filter(
|
||||
(x): x is number => typeof x === 'number' && Number.isFinite(x),
|
||||
);
|
||||
if (ratings.length === 0) return '—';
|
||||
const avg = ratings.reduce((a, b) => a + b, 0) / ratings.length;
|
||||
const fmt = avg.toFixed(1).replace(/\.0$/, '');
|
||||
return `${fmt} avg (${ratings.length} rated)`;
|
||||
},
|
||||
|
||||
ParticipantInput: DateRankedInput,
|
||||
BuilderEditor: DateRankedBuilder,
|
||||
ResultsBlock: DateRankedResults,
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import { LongTextQuestion } from './long_text';
|
||||
import { ScaleQuestion } from './scale';
|
||||
import { SingleChoiceQuestion } from './single_choice';
|
||||
import { MultiChoiceQuestion } from './multi_choice';
|
||||
import { DateRankedChoiceQuestion } from './date_ranked_choice';
|
||||
|
||||
// Order matters — drives the FormBuilder "+ Add" picker layout.
|
||||
// The wiring step at the end of Phase 2 flips legacy `q.type === '...'`
|
||||
@@ -29,6 +30,7 @@ export const QUESTION_MODULES: readonly AnyQuestionTypeModule[] = [
|
||||
MultiChoiceQuestion as AnyQuestionTypeModule,
|
||||
ScaleQuestion as AnyQuestionTypeModule,
|
||||
BooleanQuestion as AnyQuestionTypeModule,
|
||||
DateRankedChoiceQuestion as AnyQuestionTypeModule,
|
||||
];
|
||||
|
||||
/** Look up the module for a question type. Throws on unknown — every type
|
||||
|
||||
Reference in New Issue
Block a user