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:
@@ -11,6 +11,7 @@
|
|||||||
multi_choice: 'Multiple choice',
|
multi_choice: 'Multiple choice',
|
||||||
scale: 'Scale',
|
scale: 'Scale',
|
||||||
boolean: 'Yes / No',
|
boolean: 'Yes / No',
|
||||||
|
date_ranked_choice: 'Date ranked choice',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPES: FeedbackQuestion['type'][] = [
|
const TYPES: FeedbackQuestion['type'][] = [
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
'multi_choice',
|
'multi_choice',
|
||||||
'scale',
|
'scale',
|
||||||
'boolean',
|
'boolean',
|
||||||
|
'date_ranked_choice',
|
||||||
];
|
];
|
||||||
|
|
||||||
function uid(): string {
|
function uid(): string {
|
||||||
@@ -28,6 +30,37 @@
|
|||||||
return 'q_' + Array.from(buf, (b) => b.toString(36)).join('').slice(0, 8);
|
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 {
|
function defaultQuestion(type: FeedbackQuestion['type']): FeedbackQuestion {
|
||||||
const base = { id: uid(), label: 'New question', required: false } as const;
|
const base = { id: uid(), label: 'New question', required: false } as const;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -41,6 +74,16 @@
|
|||||||
return { ...base, type, min: 1, max: 5 };
|
return { ...base, type, min: 1, max: 5 };
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return { ...base, type };
|
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;
|
if (q.options.length <= 2) return;
|
||||||
update(idx, { options: q.options.filter((_, i) => i !== optIdx) } as Partial<FeedbackQuestion>);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="fb-builder">
|
<div class="fb-builder">
|
||||||
@@ -229,6 +324,95 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{/if}
|
||||||
|
|
||||||
<div class="fb-question">
|
<div class="fb-question">
|
||||||
|
|||||||
@@ -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 {
|
function questionDenominator(q: QuestionResult): number {
|
||||||
// For multi_choice, the count is the number of submissions that ticked
|
// For multi_choice, the count is the number of submissions that ticked
|
||||||
// at least one option; we render bars relative to that so percentages
|
// 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>
|
<span class="fb-results__row-count">{q.stats.no} · {pct(q.stats.no, denom)}%</span>
|
||||||
</div>
|
</div>
|
||||||
</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'}
|
{:else if q.stats.type === 'short_text' || q.stats.type === 'long_text'}
|
||||||
<div class="fb-results__meta">{q.stats.count} Antworten</div>
|
<div class="fb-results__meta">{q.stats.count} Antworten</div>
|
||||||
{#if q.stats.answers.length > 0}
|
{#if q.stats.answers.length > 0}
|
||||||
|
|||||||
@@ -325,6 +325,80 @@ body { min-height: 100vh; }
|
|||||||
margin-top: 0.4rem;
|
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. */
|
/* Button system — primary base + secondary/ghost/danger variants + sm/lg/icon sizes. */
|
||||||
|
|
||||||
.fb-btn {
|
.fb-btn {
|
||||||
@@ -678,6 +752,43 @@ body { min-height: 100vh; }
|
|||||||
margin-top: 0.25rem;
|
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 {
|
.fb-builder__add {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
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.
|
Minimalist redesign — utility classes added 2026-05-06.
|
||||||
See docs/plans/ui-redesign.md for the rationale.
|
See docs/plans/ui-redesign.md for the rationale.
|
||||||
|
|||||||
@@ -190,6 +190,21 @@
|
|||||||
|
|
||||||
if (formDef) {
|
if (formDef) {
|
||||||
for (const q of formDef.questions) {
|
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) {
|
if (q.required) {
|
||||||
const v = answers[q.id];
|
const v = answers[q.id];
|
||||||
const empty =
|
const empty =
|
||||||
@@ -268,6 +283,45 @@
|
|||||||
answers = { ...answers, [qid]: value };
|
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(() => {
|
onMount(() => {
|
||||||
displayName = loadName();
|
displayName = loadName();
|
||||||
const s = loadSession();
|
const s = loadSession();
|
||||||
@@ -516,6 +570,43 @@
|
|||||||
<span>Nein</span>
|
<span>Nein</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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}
|
||||||
|
|
||||||
{#if q.help}
|
{#if q.help}
|
||||||
|
|||||||
Reference in New Issue
Block a user