- FormBuilder.svelte: new `Date ranked choice` type in the type picker. Per-option editor uses datetime-local inputs (start required, end optional) plus an optional free-text label. Below the option list, two optional rating-1 / rating-5 label fields and an "Allow participants to skip individual options" toggle (default on). Local↔UTC conversion helpers keep storage in UTC ISO 8601 while the input element shows the author's local time. - /f/[slug]: participant rows of `(date · optional label)` + 1-5 button group + a "—" skip button. Required check enforces "at least one rated" for required questions and "all rated" when allow_partial=false. - Results.svelte: ranked list of options with mean rating, count, and a per-option distribution histogram. Heading shows the option's local-time date range. Sort order comes from the aggregator (mean desc + tiebreaks). - feedback.css: layout for the new builder rows, participant rating rows (mobile-stacks), and the ranked results list. Refs m/fdbck#1.
183 lines
6.7 KiB
Svelte
183 lines
6.7 KiB
Svelte
<script lang="ts">
|
||
import type { AggregatedResults, QuestionResult } from '$lib/server/results';
|
||
|
||
let { results }: { results: AggregatedResults } = $props();
|
||
|
||
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 maxOf(values: number[]): number {
|
||
if (values.length === 0) return 0;
|
||
return values.reduce((a, b) => (a > b ? a : b), 0);
|
||
}
|
||
|
||
function shortDate(iso: string): string {
|
||
try {
|
||
return new Date(iso).toLocaleDateString();
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
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 | 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;
|
||
}
|
||
}
|
||
|
||
function questionDenominator(q: QuestionResult): number {
|
||
// For multi_choice, the count is the number of submissions that ticked
|
||
// at least one option; we render bars relative to that so percentages
|
||
// add up sensibly even when one user picks two options.
|
||
return q.stats.count;
|
||
}
|
||
</script>
|
||
|
||
<div class="fb-results">
|
||
<div class="fb-results__header">
|
||
<strong>{results.total_submissions}</strong>
|
||
{results.total_submissions === 1 ? 'Antwort' : 'Antworten'}
|
||
</div>
|
||
|
||
{#if results.total_submissions === 0}
|
||
<div class="fb-results__empty">Noch keine Antworten.</div>
|
||
{:else}
|
||
{#each results.questions as q (q.id)}
|
||
<div class="fb-results__q">
|
||
<div class="fb-results__label">{q.label}</div>
|
||
|
||
{#if q.stats.type === 'scale'}
|
||
{@const denom = maxOf(q.stats.histogram.map((b) => b.count))}
|
||
<div class="fb-results__meta">
|
||
Schnitt: <strong>{fmtMean(q.stats.mean)}</strong> · {q.stats.count} Antworten
|
||
</div>
|
||
<div class="fb-results__bars">
|
||
{#each q.stats.histogram as bucket (bucket.value)}
|
||
<div class="fb-results__row">
|
||
<span class="fb-results__row-label">{bucket.value}</span>
|
||
<div class="fb-results__bar-track">
|
||
<div class="fb-results__bar-fill" style="width: {pct(bucket.count, denom)}%"></div>
|
||
</div>
|
||
<span class="fb-results__row-count">{bucket.count}</span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{:else if q.stats.type === 'single_choice' || q.stats.type === 'multi_choice'}
|
||
{@const denom = questionDenominator(q)}
|
||
<div class="fb-results__meta">{q.stats.count} Antworten</div>
|
||
<div class="fb-results__bars">
|
||
{#each q.stats.options as opt (opt.option)}
|
||
<div class="fb-results__row">
|
||
<span class="fb-results__row-label fb-results__row-label--wide">{opt.option}</span>
|
||
<div class="fb-results__bar-track">
|
||
<div class="fb-results__bar-fill" style="width: {pct(opt.count, denom)}%"></div>
|
||
</div>
|
||
<span class="fb-results__row-count">{opt.count} · {pct(opt.count, denom)}%</span>
|
||
</div>
|
||
{/each}
|
||
{#if q.stats.other_count > 0}
|
||
<div class="fb-results__row fb-results__row--muted">
|
||
<span class="fb-results__row-label fb-results__row-label--wide">Andere (frühere Versionen)</span>
|
||
<div class="fb-results__bar-track">
|
||
<div class="fb-results__bar-fill fb-results__bar-fill--muted" style="width: {pct(q.stats.other_count, denom)}%"></div>
|
||
</div>
|
||
<span class="fb-results__row-count">{q.stats.other_count}</span>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{:else if q.stats.type === 'boolean'}
|
||
{@const denom = q.stats.count}
|
||
<div class="fb-results__meta">{q.stats.count} Antworten</div>
|
||
<div class="fb-results__bars">
|
||
<div class="fb-results__row">
|
||
<span class="fb-results__row-label">Ja</span>
|
||
<div class="fb-results__bar-track">
|
||
<div class="fb-results__bar-fill" style="width: {pct(q.stats.yes, denom)}%"></div>
|
||
</div>
|
||
<span class="fb-results__row-count">{q.stats.yes} · {pct(q.stats.yes, denom)}%</span>
|
||
</div>
|
||
<div class="fb-results__row">
|
||
<span class="fb-results__row-label">Nein</span>
|
||
<div class="fb-results__bar-track">
|
||
<div class="fb-results__bar-fill" style="width: {pct(q.stats.no, denom)}%"></div>
|
||
</div>
|
||
<span class="fb-results__row-count">{q.stats.no} · {pct(q.stats.no, denom)}%</span>
|
||
</div>
|
||
</div>
|
||
{:else if q.stats.type === 'date_ranked_choice'}
|
||
<div class="fb-results__meta">{q.stats.count} {q.stats.count === 1 ? 'Antwort' : 'Antworten'}</div>
|
||
<div class="fb-results__date-ranked">
|
||
{#each q.stats.options as opt, optIdx (opt.id)}
|
||
{@const denom = maxOf(opt.histogram.map((b) => b.count))}
|
||
<div class="fb-results__date-opt">
|
||
<div class="fb-results__date-head">
|
||
<span class="fb-results__date-rank">#{optIdx + 1}</span>
|
||
<div class="fb-results__date-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__date-mean">
|
||
Schnitt: <strong>{fmtMean(opt.mean)}</strong>
|
||
<span class="fb-results__date-count">· {opt.count} {opt.count === 1 ? 'Bewertung' : 'Bewertungen'}</span>
|
||
</div>
|
||
</div>
|
||
<div class="fb-results__bars">
|
||
{#each opt.histogram as bucket (bucket.value)}
|
||
<div class="fb-results__row">
|
||
<span class="fb-results__row-label">{bucket.value}</span>
|
||
<div class="fb-results__bar-track">
|
||
<div class="fb-results__bar-fill" style="width: {pct(bucket.count, denom)}%"></div>
|
||
</div>
|
||
<span class="fb-results__row-count">{bucket.count}</span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{:else if q.stats.type === 'short_text' || q.stats.type === 'long_text'}
|
||
<div class="fb-results__meta">{q.stats.count} Antworten</div>
|
||
{#if q.stats.answers.length > 0}
|
||
<ul class="fb-results__answers">
|
||
{#each q.stats.answers as a (a.created_at + a.value.slice(0, 20))}
|
||
<li>
|
||
<span class="fb-results__answer-date">{shortDate(a.created_at)}</span>
|
||
<span class="fb-results__answer-text">{a.value}</span>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
{/if}
|
||
</div>
|