Merge mai/hermes/date-ranked-results-viz: calendar heatmap + cleaner bars + summarizeAnswer fix
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user