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.
This commit is contained in:
mAi
2026-05-06 14:13:22 +02:00
parent 439b030471
commit 5ef08e5930
4 changed files with 495 additions and 0 deletions

View File

@@ -11,6 +11,7 @@
multi_choice: 'Multiple choice',
scale: 'Scale',
boolean: 'Yes / No',
date_ranked_choice: 'Date ranked choice',
};
const TYPES: FeedbackQuestion['type'][] = [
@@ -20,6 +21,7 @@
'multi_choice',
'scale',
'boolean',
'date_ranked_choice',
];
function uid(): string {
@@ -28,6 +30,37 @@
return 'q_' + Array.from(buf, (b) => b.toString(36)).join('').slice(0, 8);
}
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);
}
/** Round `now` to the next full hour and return as ISO 8601 UTC. */
function defaultStartIso(offsetHours = 0): string {
const d = new Date();
d.setMinutes(0, 0, 0);
d.setHours(d.getHours() + 1 + offsetHours);
return d.toISOString();
}
/** Convert a stored UTC ISO string into the `YYYY-MM-DDTHH:MM` shape that `<input type="datetime-local">` expects, in the viewer's local time. */
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())}`;
}
/** Convert a `<input type="datetime-local">` value (local time, no offset) into a UTC ISO string. */
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 defaultQuestion(type: FeedbackQuestion['type']): FeedbackQuestion {
const base = { id: uid(), label: 'New question', required: false } as const;
switch (type) {
@@ -41,6 +74,16 @@
return { ...base, type, min: 1, max: 5 };
case 'boolean':
return { ...base, type };
case 'date_ranked_choice':
return {
...base,
type,
options: [
{ id: optUid(), start: defaultStartIso(0) },
{ id: optUid(), start: defaultStartIso(24) },
],
allow_partial: true,
};
}
}
@@ -95,6 +138,58 @@
if (q.options.length <= 2) return;
update(idx, { options: q.options.filter((_, i) => i !== optIdx) } as Partial<FeedbackQuestion>);
}
function setDateOption(idx: number, optIdx: number, patch: { start?: string; end?: string | null; label?: string | null }): void {
const q = value.questions[idx];
if (q.type !== 'date_ranked_choice') return;
const options = q.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(idx, { options } as Partial<FeedbackQuestion>);
}
function addDateOption(idx: number): void {
const q = value.questions[idx];
if (q.type !== 'date_ranked_choice') return;
if (q.options.length >= 50) return;
update(idx, {
options: [...q.options, { id: optUid(), start: defaultStartIso(24 * (q.options.length + 1)) }],
} as Partial<FeedbackQuestion>);
}
function removeDateOption(idx: number, optIdx: number): void {
const q = value.questions[idx];
if (q.type !== 'date_ranked_choice') return;
if (q.options.length <= 2) return;
update(idx, { options: q.options.filter((_, i) => i !== optIdx) } as Partial<FeedbackQuestion>);
}
function setScaleLabel(idx: number, which: 'min_label' | 'max_label', val: string): void {
const q = value.questions[idx];
if (q.type !== 'date_ranked_choice') return;
const scale = { ...(q.scale ?? {}) };
if (val === '') delete scale[which];
else scale[which] = val;
const empty = !scale.min_label && !scale.max_label;
update(idx, { scale: empty ? undefined : scale } as Partial<FeedbackQuestion>);
}
function setAllowPartial(idx: number, allow: boolean): void {
const q = value.questions[idx];
if (q.type !== 'date_ranked_choice') return;
update(idx, { allow_partial: allow } as Partial<FeedbackQuestion>);
}
</script>
<div class="fb-builder">
@@ -229,6 +324,95 @@
/>
</div>
</div>
{:else if q.type === 'date_ranked_choice'}
<div class="fb-question">
<label class="fb-question__label">Date / time options</label>
<div class="fb-builder__date-ranked">
{#each q.options as opt, optIdx (opt.id)}
<div class="fb-builder__date-row">
<div class="fb-builder__date-fields">
<label class="fb-question__label">Start</label>
<input
type="datetime-local"
class="fb-input"
value={isoToLocalInput(opt.start)}
oninput={(e) => {
const iso = localInputToIso((e.target as HTMLInputElement).value);
if (iso) setDateOption(i, optIdx, { start: iso });
}}
/>
<label class="fb-question__label">End (optional)</label>
<input
type="datetime-local"
class="fb-input"
value={isoToLocalInput(opt.end)}
oninput={(e) => {
const raw = (e.target as HTMLInputElement).value;
if (!raw) setDateOption(i, optIdx, { end: null });
else {
const iso = localInputToIso(raw);
if (iso) setDateOption(i, optIdx, { end: iso });
}
}}
/>
<label class="fb-question__label">Label (optional)</label>
<input
class="fb-input"
maxlength="200"
placeholder="e.g. Office, 09:00 sharp"
value={opt.label ?? ''}
oninput={(e) => setDateOption(i, optIdx, { label: (e.target as HTMLInputElement).value })}
/>
</div>
<button
type="button"
class="fb-builder__icon-btn fb-builder__icon-btn--danger"
disabled={q.options.length <= 2}
onclick={() => removeDateOption(i, 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(i)}
disabled={q.options.length >= 50}
><Icon name="plus" /> Date option</button>
</div>
<div class="fb-builder__scale">
<div class="fb-question">
<label class="fb-question__label">Rating-1 label (optional)</label>
<input
class="fb-input"
maxlength="50"
placeholder="e.g. doesn't work"
value={q.scale?.min_label ?? ''}
oninput={(e) => setScaleLabel(i, 'min_label', (e.target as HTMLInputElement).value)}
/>
</div>
<div class="fb-question">
<label class="fb-question__label">Rating-5 label (optional)</label>
<input
class="fb-input"
maxlength="50"
placeholder="e.g. works great"
value={q.scale?.max_label ?? ''}
oninput={(e) => setScaleLabel(i, 'max_label', (e.target as HTMLInputElement).value)}
/>
</div>
</div>
<label class="fb-option-row" style="display:inline-flex;">
<input
type="checkbox"
checked={q.allow_partial !== false}
onchange={(e) => setAllowPartial(i, (e.target as HTMLInputElement).checked)}
/>
<span>Allow participants to skip individual options</span>
</label>
{/if}
<div class="fb-question">

View File

@@ -26,6 +26,33 @@
}
}
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
@@ -105,6 +132,37 @@
<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}

View File

@@ -325,6 +325,80 @@ body { min-height: 100vh; }
margin-top: 0.4rem;
}
.fb-date-ranked {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.fb-date-ranked__row {
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
flex-wrap: wrap;
}
.fb-date-ranked__opt {
flex: 1 1 220px;
min-width: 0;
}
.fb-date-ranked__when {
font-weight: 500;
color: var(--color-text-primary);
}
.fb-date-ranked__label {
font-size: 0.85rem;
color: var(--fb-muted);
margin-top: 0.15rem;
}
.fb-date-ranked__scale {
flex: 0 1 auto;
}
.fb-date-ranked__skip {
min-width: 2.5rem;
min-height: 2.5rem;
padding: 0.5rem 0.75rem;
border: 1px dashed var(--color-border-primary);
border-radius: var(--radius-md);
background: transparent;
color: var(--fb-muted);
font-size: 1rem;
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease;
}
.fb-date-ranked__skip:hover {
border-color: var(--color-primary);
}
.fb-date-ranked__skip--active {
background: var(--color-bg-secondary);
border-style: solid;
color: var(--color-text-primary);
}
@media (max-width: 480px) {
.fb-date-ranked__row {
align-items: stretch;
}
.fb-date-ranked__opt {
flex-basis: 100%;
}
.fb-date-ranked__scale {
width: 100%;
justify-content: space-between;
}
}
/* Button system — primary base + secondary/ghost/danger variants + sm/lg/icon sizes. */
.fb-btn {
@@ -678,6 +752,43 @@ body { min-height: 100vh; }
margin-top: 0.25rem;
}
.fb-builder__date-ranked {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.fb-builder__date-row {
display: flex;
gap: 0.5rem;
align-items: flex-start;
padding: 0.5rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
}
.fb-builder__date-fields {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.4rem 0.6rem;
align-items: center;
flex: 1;
min-width: 0;
}
.fb-builder__date-fields .fb-question__label {
margin: 0;
white-space: nowrap;
font-size: 0.85rem;
}
@media (max-width: 480px) {
.fb-builder__date-fields {
grid-template-columns: 1fr;
}
}
.fb-builder__add {
display: flex;
flex-wrap: wrap;
@@ -814,6 +925,57 @@ body { min-height: 100vh; }
}
}
.fb-results__date-ranked {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.fb-results__date-opt {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
}
.fb-results__date-head {
display: flex;
gap: 0.6rem;
align-items: baseline;
flex-wrap: wrap;
}
.fb-results__date-rank {
font-weight: 600;
color: var(--fb-muted);
font-variant-numeric: tabular-nums;
min-width: 1.75rem;
}
.fb-results__date-when {
flex: 1 1 200px;
min-width: 0;
}
.fb-results__date-label {
font-size: 0.8rem;
color: var(--fb-muted);
margin-top: 0.1rem;
}
.fb-results__date-mean {
font-size: 0.85rem;
color: var(--color-text-primary);
}
.fb-results__date-count {
color: var(--fb-muted);
font-weight: 400;
}
/* ─────────────────────────────────────────────────────────────────
Minimalist redesign — utility classes added 2026-05-06.
See docs/plans/ui-redesign.md for the rationale.

View File

@@ -190,6 +190,21 @@
if (formDef) {
for (const q of formDef.questions) {
if (q.type === 'date_ranked_choice') {
const map = (answers[q.id] as Record<string, number | null> | undefined) ?? {};
const rated = q.options.filter((opt) => typeof map[opt.id] === 'number');
if (q.required && rated.length === 0) {
submitError = `Bitte bewerte mindestens eine Option: ${q.label}`;
submitInFlight = false;
return;
}
if (q.allow_partial === false && rated.length < q.options.length) {
submitError = `Bitte bewerte alle Optionen: ${q.label}`;
submitInFlight = false;
return;
}
continue;
}
if (q.required) {
const v = answers[q.id];
const empty =
@@ -268,6 +283,45 @@
answers = { ...answers, [qid]: value };
}
function setDateRankedRating(qid: string, optId: string, rating: number | null): void {
const cur = (answers[qid] as Record<string, number | null> | undefined) ?? {};
answers = { ...answers, [qid]: { ...cur, [optId]: rating } };
}
function dateRankedRating(qid: string, optId: string): number | null {
const cur = answers[qid] as Record<string, number | null> | undefined;
if (!cur) return null;
const v = cur[optId];
return typeof v === 'number' ? v : null;
}
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;
}
}
onMount(() => {
displayName = loadName();
const s = loadSession();
@@ -516,6 +570,43 @@
<span>Nein</span>
</label>
</div>
{:else if q.type === 'date_ranked_choice'}
<div class="fb-date-ranked">
{#if q.scale?.min_label || q.scale?.max_label}
<div class="fb-scale__labels" style="margin-bottom: 0.5rem;">
<span>1 — {q.scale.min_label ?? 'passt nicht'}</span>
<span>5 — {q.scale.max_label ?? 'passt super'}</span>
</div>
{/if}
{#each q.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 {dateRankedRating(q.id, opt.id) === v ? 'fb-scale__btn--active' : ''}"
aria-pressed={dateRankedRating(q.id, opt.id) === v}
onclick={() => setDateRankedRating(q.id, opt.id, v)}
>
{v}
</button>
{/each}
<button
type="button"
class="fb-date-ranked__skip {dateRankedRating(q.id, opt.id) === null ? 'fb-date-ranked__skip--active' : ''}"
onclick={() => setDateRankedRating(q.id, opt.id, null)}
aria-label="Skip option"
>
</button>
</div>
</div>
{/each}
</div>
{/if}
{#if q.help}