Files
fdbck/src/lib/components/Results.svelte
mAi 5ef08e5930 mAi: #1 - UI: builder + participant renderer + results display
- 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.
2026-05-06 14:13:22 +02:00

183 lines
6.7 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>