Merge mai/hermes/date-ranked-results-viz: calendar heatmap + cleaner bars + summarizeAnswer fix

This commit is contained in:
mAi
2026-05-06 14:28:48 +02:00
3 changed files with 426 additions and 67 deletions

View File

@@ -1,8 +1,17 @@
<script lang="ts">
import type { AggregatedResults, QuestionResult } from '$lib/server/results';
import type { AggregatedResults, DateRankedOptionStats, QuestionResult } from '$lib/server/results';
let { results }: { results: AggregatedResults } = $props();
// Per-question view mode for date_ranked_choice. Calendar default; toggle to bars.
let drcView = $state<Record<string, 'calendar' | 'bars'>>({});
function viewFor(qid: string): 'calendar' | 'bars' {
return drcView[qid] ?? 'calendar';
}
function setView(qid: string, v: 'calendar' | 'bars'): void {
drcView = { ...drcView, [qid]: v };
}
function pct(part: number, whole: number): number {
if (whole === 0) return 0;
return Math.round((part / whole) * 100);
@@ -18,6 +27,120 @@
return values.reduce((a, b) => (a > b ? a : b), 0);
}
/* Date-ranked-choice helpers */
function mixHex(a: string, b: string, t: number): string {
const tt = Math.max(0, Math.min(1, t));
const ar = parseInt(a.slice(1, 3), 16);
const ag = parseInt(a.slice(3, 5), 16);
const ab = parseInt(a.slice(5, 7), 16);
const br = parseInt(b.slice(1, 3), 16);
const bg = parseInt(b.slice(3, 5), 16);
const bb = parseInt(b.slice(5, 7), 16);
return `rgb(${Math.round(ar + (br - ar) * tt)}, ${Math.round(ag + (bg - ag) * tt)}, ${Math.round(ab + (bb - ab) * tt)})`;
}
const COLOR_LOW = '#ef4444'; // 1
const COLOR_MID = '#f59e0b'; // 3
const COLOR_HIGH = '#16a34a'; // 5
function colorForRating(value: number): string {
if (value <= 1) return COLOR_LOW;
if (value >= 5) return COLOR_HIGH;
if (value < 3) return mixHex(COLOR_LOW, COLOR_MID, (value - 1) / 2);
return mixHex(COLOR_MID, COLOR_HIGH, (value - 3) / 2);
}
function colorForMean(mean: number | null): string {
if (mean === null) return 'var(--color-bg-secondary)';
return colorForRating(mean);
}
const dayFmt = new Intl.DateTimeFormat([], { day: '2-digit' });
const monthFmt = new Intl.DateTimeFormat([], { month: 'short' });
const weekdayFmt = new Intl.DateTimeFormat([], { weekday: 'short' });
const timeFmt = new Intl.DateTimeFormat([], { hour: '2-digit', minute: '2-digit' });
const fullDateFmt = new Intl.DateTimeFormat([], {
weekday: 'short',
day: '2-digit',
month: 'short',
year: 'numeric',
});
function localDateKey(iso: string): string {
const d = new Date(iso);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function fmtTimeRange(start: string, end: string | null): string {
const s = timeFmt.format(new Date(start));
if (!end) return s;
const e = timeFmt.format(new Date(end));
return `${s}${e}`;
}
function fmtFullDate(iso: string): string {
try {
return fullDateFmt.format(new Date(iso));
} catch {
return iso;
}
}
interface CalendarCell {
key: string; // YYYY-MM-DD
date: Date; // midnight local
options: DateRankedOptionStats[];
}
function buildCalendar(options: DateRankedOptionStats[]): { cells: CalendarCell[]; collapsed: boolean } {
if (options.length === 0) return { cells: [], collapsed: false };
const byDay = new Map<string, CalendarCell>();
for (const opt of options) {
const key = localDateKey(opt.start);
let cell = byDay.get(key);
if (!cell) {
const d = new Date(opt.start);
d.setHours(0, 0, 0, 0);
cell = { key, date: d, options: [] };
byDay.set(key, cell);
}
cell.options.push(opt);
}
const occupied = Array.from(byDay.values()).sort((a, b) => a.date.getTime() - b.date.getTime());
if (occupied.length <= 1) return { cells: occupied, collapsed: false };
const first = occupied[0].date;
const last = occupied[occupied.length - 1].date;
const dayMs = 24 * 60 * 60 * 1000;
const span = Math.round((last.getTime() - first.getTime()) / dayMs) + 1;
// > 30 days → suppress empty days; otherwise contiguous strip with empties.
if (span > 30) return { cells: occupied, collapsed: true };
const cells: CalendarCell[] = [];
for (let i = 0; i < span; i++) {
const d = new Date(first.getTime() + i * dayMs);
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
cells.push(byDay.get(k) ?? { key: k, date: d, options: [] });
}
return { cells, collapsed: false };
}
function cellTitle(cell: CalendarCell): string {
const date = fmtFullDate(cell.date.toISOString());
if (cell.options.length === 0) return date;
const lines = cell.options.map((opt) => {
const time = fmtTimeRange(opt.start, opt.end);
const label = opt.label ? ` · ${opt.label}` : '';
const mean = opt.mean === null ? '—' : opt.mean.toFixed(2).replace(/\.?0+$/, '');
return `${time}${label}${mean} avg (${opt.count})`;
});
return `${date}\n${lines.join('\n')}`;
}
function shortDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString();
@@ -133,36 +256,106 @@
</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}
{@const drcStats = q.stats}
{@const calendar = buildCalendar(drcStats.options)}
{@const showCalendar = drcStats.options.length > 1}
{@const view = showCalendar ? viewFor(q.id) : 'bars'}
<div class="fb-results__meta">
{drcStats.count} {drcStats.count === 1 ? 'Antwort' : 'Antworten'}
</div>
{#if drcStats.count === 0}
<p class="fb-results__meta">Noch keine Bewertungen.</p>
{:else}
{#if showCalendar}
<div class="fb-tabs fb-results__drc-tabs" role="tablist" aria-label="Ergebnis-Ansicht">
<button
type="button"
class="fb-tab"
class:fb-tab--active={view === 'calendar'}
role="tab"
aria-selected={view === 'calendar'}
onclick={() => setView(q.id, 'calendar')}
>Kalender</button>
<button
type="button"
class="fb-tab"
class:fb-tab--active={view === 'bars'}
role="tab"
aria-selected={view === 'bars'}
onclick={() => setView(q.id, 'bars')}
>Balken</button>
</div>
{/if}
{#if view === 'calendar'}
<div class="fb-results__cal" class:fb-results__cal--collapsed={calendar.collapsed}>
{#each calendar.cells as cell (cell.key)}
<div
class="fb-results__cal-day"
class:fb-results__cal-day--empty={cell.options.length === 0}
title={cellTitle(cell)}
>
<div class="fb-results__cal-head">
<span class="fb-results__cal-weekday">{weekdayFmt.format(cell.date)}</span>
<span class="fb-results__cal-num">{dayFmt.format(cell.date)}</span>
<span class="fb-results__cal-month">{monthFmt.format(cell.date)}</span>
</div>
{#if cell.options.length > 0}
<div class="fb-results__cal-slots">
{#each cell.options as opt (opt.id)}
<div
class="fb-results__cal-slot"
style="background: {colorForMean(opt.mean)};"
>
<span class="fb-results__cal-slot-time">{fmtTimeRange(opt.start, opt.end)}</span>
<span class="fb-results__cal-slot-mean">{fmtMean(opt.mean)}</span>
<span class="fb-results__cal-slot-count">{opt.count}</span>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="fb-results__drc-bars">
{#each drcStats.options as opt, optIdx (opt.id)}
{@const total = opt.count}
<div class="fb-results__drc-row">
<div class="fb-results__drc-rank">#{optIdx + 1}</div>
<div class="fb-results__drc-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__drc-avg" style="color: {colorForMean(opt.mean)};">
{fmtMean(opt.mean)}
</div>
<div class="fb-results__drc-bar" aria-label="Verteilung">
{#if total === 0}
<div class="fb-results__drc-bar-empty">Keine Bewertung</div>
{:else}
{#each opt.histogram as bucket (bucket.value)}
{#if bucket.count > 0}
<div
class="fb-results__drc-bar-seg"
style="width: {pct(bucket.count, total)}%; background: {colorForRating(bucket.value)};"
title="{bucket.count}× {bucket.value}"
>
<span>{bucket.value}·{bucket.count}</span>
</div>
{/if}
{/each}
{/if}
</div>
<div class="fb-results__drc-count">
{opt.count}
</div>
</div>
{/each}
</div>
{/if}
{/if}
{: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

@@ -899,39 +899,10 @@ body { min-height: 100vh; }
}
}
.fb-results__date-ranked {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
/* date_ranked_choice — view toggle + calendar + bars */
.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__drc-tabs {
margin-bottom: 0.6rem;
}
.fb-results__date-label {
@@ -940,14 +911,199 @@ body { min-height: 100vh; }
margin-top: 0.1rem;
}
.fb-results__date-mean {
font-size: 0.85rem;
color: var(--color-text-primary);
/* Calendar heatmap — horizontal scrolling strip of day cells. */
.fb-results__cal {
display: flex;
gap: 0.4rem;
overflow-x: auto;
padding: 0.25rem 0 0.5rem 0;
scrollbar-width: thin;
}
.fb-results__date-count {
.fb-results__cal--collapsed {
gap: 0.5rem;
}
.fb-results__cal-day {
flex: 0 0 auto;
min-width: 4.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.4rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
}
.fb-results__cal-day--empty {
opacity: 0.55;
background: var(--color-bg-secondary);
}
.fb-results__cal-head {
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas: "wd num" "mo num";
column-gap: 0.4rem;
row-gap: 0;
align-items: baseline;
font-variant-numeric: tabular-nums;
}
.fb-results__cal-weekday {
grid-area: wd;
font-size: 0.7rem;
color: var(--fb-muted);
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.fb-results__cal-month {
grid-area: mo;
font-size: 0.7rem;
color: var(--fb-muted);
}
.fb-results__cal-num {
grid-area: num;
font-size: 1.4rem;
font-weight: 600;
line-height: 1;
}
.fb-results__cal-slots {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.fb-results__cal-slot {
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas: "time mean" "count count";
column-gap: 0.4rem;
padding: 0.3rem 0.4rem;
border-radius: var(--radius-md);
color: #fff;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.18);
font-size: 0.78rem;
line-height: 1.15;
}
.fb-results__cal-slot-time {
grid-area: time;
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.fb-results__cal-slot-mean {
grid-area: mean;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.fb-results__cal-slot-count {
grid-area: count;
font-size: 0.7rem;
opacity: 0.92;
font-variant-numeric: tabular-nums;
}
/* Bar list — one row per option, single stacked bar coloured by rating bucket. */
.fb-results__drc-bars {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.fb-results__drc-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas:
"rank when avg"
"bar bar count";
gap: 0.35rem 0.6rem;
align-items: center;
padding: 0.5rem 0.6rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
}
.fb-results__drc-rank {
grid-area: rank;
font-weight: 600;
color: var(--fb-muted);
font-variant-numeric: tabular-nums;
min-width: 1.75rem;
}
.fb-results__drc-when {
grid-area: when;
min-width: 0;
}
.fb-results__drc-avg {
grid-area: avg;
font-weight: 700;
font-variant-numeric: tabular-nums;
font-size: 1.4rem;
line-height: 1;
}
.fb-results__drc-bar {
grid-area: bar;
display: flex;
width: 100%;
height: 1.5rem;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-bg-secondary);
}
.fb-results__drc-bar-seg {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 0.72rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
min-width: 0;
overflow: hidden;
}
.fb-results__drc-bar-seg span {
white-space: nowrap;
overflow: hidden;
text-overflow: clip;
padding: 0 0.25rem;
}
.fb-results__drc-bar-empty {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--fb-muted);
font-size: 0.78rem;
}
.fb-results__drc-count {
grid-area: count;
font-size: 0.78rem;
color: var(--fb-muted);
font-variant-numeric: tabular-nums;
text-align: right;
}
@media (min-width: 560px) {
.fb-results__drc-row {
grid-template-columns: auto minmax(0, 1.4fr) auto minmax(0, 2fr) auto;
grid-template-areas: "rank when avg bar count";
}
}
/* ─────────────────────────────────────────────────────────────────

View File

@@ -296,6 +296,16 @@
if (v === null || v === undefined) return '—';
if (typeof v === 'boolean') return v ? 'Yes' : 'No';
if (Array.isArray(v)) return v.join(', ');
if (typeof v === 'object') {
// date_ranked_choice answers are { optId: 1..5 | null } — terse summary for the table cell.
const ratings = Object.values(v as Record<string, unknown>).filter(
(x): x is number => typeof x === 'number' && Number.isFinite(x),
);
if (ratings.length === 0) return '—';
const avg = ratings.reduce((a, b) => a + b, 0) / ratings.length;
const fmt = avg.toFixed(1).replace(/\.0$/, '');
return `${fmt} avg (${ratings.length} rated)`;
}
return String(v);
}