From 5ef08e59303c47fb34f4fc5efd213af7a4b300fb Mon Sep 17 00:00:00 2001 From: mAi Date: Wed, 6 May 2026 14:13:22 +0200 Subject: [PATCH] mAi: #1 - UI: builder + participant renderer + results display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/lib/components/FormBuilder.svelte | 184 ++++++++++++++++++++++++++ src/lib/components/Results.svelte | 58 ++++++++ src/lib/styles/feedback.css | 162 +++++++++++++++++++++++ src/routes/f/[slug]/+page.svelte | 91 +++++++++++++ 4 files changed, 495 insertions(+) diff --git a/src/lib/components/FormBuilder.svelte b/src/lib/components/FormBuilder.svelte index ece1cce..740ace6 100644 --- a/src/lib/components/FormBuilder.svelte +++ b/src/lib/components/FormBuilder.svelte @@ -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 `` 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 `` 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); } + + 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); + } + + 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); + } + + 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); + } + + 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); + } + + 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); + }
@@ -229,6 +324,95 @@ />
+ {:else if q.type === 'date_ranked_choice'} +
+ +
+ {#each q.options as opt, optIdx (opt.id)} +
+
+ + { + const iso = localInputToIso((e.target as HTMLInputElement).value); + if (iso) setDateOption(i, optIdx, { start: iso }); + }} + /> + + { + 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 }); + } + }} + /> + + setDateOption(i, optIdx, { label: (e.target as HTMLInputElement).value })} + /> +
+ +
+ {/each} +
+ +
+ +
+
+ + setScaleLabel(i, 'min_label', (e.target as HTMLInputElement).value)} + /> +
+
+ + setScaleLabel(i, 'max_label', (e.target as HTMLInputElement).value)} + /> +
+
+ + {/if}
diff --git a/src/lib/components/Results.svelte b/src/lib/components/Results.svelte index 9a01aa4..52aaf56 100644 --- a/src/lib/components/Results.svelte +++ b/src/lib/components/Results.svelte @@ -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 @@ {q.stats.no} · {pct(q.stats.no, denom)}%
+ {:else if q.stats.type === 'date_ranked_choice'} +
{q.stats.count} {q.stats.count === 1 ? 'Antwort' : 'Antworten'}
+
+ {#each q.stats.options as opt, optIdx (opt.id)} + {@const denom = maxOf(opt.histogram.map((b) => b.count))} +
+
+ #{optIdx + 1} +
+
{fmtDateOption(opt.start, opt.end)}
+ {#if opt.label}
{opt.label}
{/if} +
+
+ Schnitt: {fmtMean(opt.mean)} + · {opt.count} {opt.count === 1 ? 'Bewertung' : 'Bewertungen'} +
+
+
+ {#each opt.histogram as bucket (bucket.value)} +
+ {bucket.value} +
+
+
+ {bucket.count} +
+ {/each} +
+
+ {/each} +
{:else if q.stats.type === 'short_text' || q.stats.type === 'long_text'}
{q.stats.count} Antworten
{#if q.stats.answers.length > 0} diff --git a/src/lib/styles/feedback.css b/src/lib/styles/feedback.css index 8f424f5..60c1911 100644 --- a/src/lib/styles/feedback.css +++ b/src/lib/styles/feedback.css @@ -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. diff --git a/src/routes/f/[slug]/+page.svelte b/src/routes/f/[slug]/+page.svelte index 16dc881..9275ec8 100644 --- a/src/routes/f/[slug]/+page.svelte +++ b/src/routes/f/[slug]/+page.svelte @@ -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 | 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 | undefined) ?? {}; + answers = { ...answers, [qid]: { ...cur, [optId]: rating } }; + } + + function dateRankedRating(qid: string, optId: string): number | null { + const cur = answers[qid] as Record | 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 @@ Nein + {:else if q.type === 'date_ranked_choice'} +
+ {#if q.scale?.min_label || q.scale?.max_label} +
+ 1 — {q.scale.min_label ?? 'passt nicht'} + 5 — {q.scale.max_label ?? 'passt super'} +
+ {/if} + {#each q.options as opt (opt.id)} +
+
+
{fmtDateOption(opt.start, opt.end)}
+ {#if opt.label}
{opt.label}
{/if} +
+
+ {#each [1, 2, 3, 4, 5] as v (v)} + + {/each} + +
+
+ {/each} +
{/if} {#if q.help}